From 52f45285b64ed4f2cac59ab33888f7fc74121f98 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Tue, 8 Sep 2026 06:49:08 -0700 Subject: [PATCH] feat(tags): automate Semaphore taxonomy sync (#2184) Replaces the manual, one-off Semaphore batch load with a weekly, DB-flag-gated cron that pulls the SAPCore model from the Semaphore SES allterms REST API and upserts Tags keyed on semaphoreId. Pipeline (all pure/injectable for test): client.fetchAllTerms -> mapper.mapAllTerms -> applier.applyTerms - srv/lib/semaphore-sync/client.js SES REST client; resolves semaphore-destination, fails shut on any HTTP/parse/shape error. - srv/lib/semaphore-sync/mapper.js pure SES term -> tag row; titlePath + normalized name; config-driven class->flag mapping. - srv/lib/semaphore-sync/applier.js upsert keyed on semaphoreId (distinct from the CSV importer's name-keyed path); adopts legacy name matches; idempotent; dryRun. - srv/jobs/semaphore-tag-sync-job.js orchestrator; flag-gated, config-driven, fail-shut. Registered Sunday 04:47 UTC. - SEMAPHORE_SYNC_ENABLED DB feature flag (default OFF, dev-only); ImsConfig semaphore.sync.* tuning keys (dryRun defaults ON). - cds.requires.semaphore REST destination + .cdsrc.json mock for dev/unit. No schema change: Tags already carries semaphoreId/isActualTag/isInterestItem. Ships dark; validate on DEV in dryRun before enabling. 37 new unit tests. Design: docs/superpowers/specs/2026-09-08-2184-semaphore-auto-sync-design.md --- .cdsrc.json | 6 + ...6-09-08-2184-semaphore-auto-sync-design.md | 100 +++++++++++ package.json | 17 ++ srv/jobs/scheduler.js | 16 ++ srv/jobs/semaphore-tag-sync-job.js | 128 ++++++++++++++ srv/lib/feature-flags/registry.js | 8 + srv/lib/semaphore-sync/applier.js | 85 ++++++++++ srv/lib/semaphore-sync/client.js | 127 ++++++++++++++ srv/lib/semaphore-sync/mapper.js | 157 ++++++++++++++++++ test/unit/semaphore-client.test.js | 88 ++++++++++ test/unit/semaphore-mapper.test.js | 98 +++++++++++ test/unit/semaphore-sync-applier.test.js | 75 +++++++++ test/unit/semaphore-tag-sync-job.test.js | 83 +++++++++ 13 files changed, 988 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-08-2184-semaphore-auto-sync-design.md create mode 100644 srv/jobs/semaphore-tag-sync-job.js create mode 100644 srv/lib/semaphore-sync/applier.js create mode 100644 srv/lib/semaphore-sync/client.js create mode 100644 srv/lib/semaphore-sync/mapper.js create mode 100644 test/unit/semaphore-client.test.js create mode 100644 test/unit/semaphore-mapper.test.js create mode 100644 test/unit/semaphore-sync-applier.test.js create mode 100644 test/unit/semaphore-tag-sync-job.test.js diff --git a/.cdsrc.json b/.cdsrc.json index 6465c1dd4..08663418c 100644 --- a/.cdsrc.json +++ b/.cdsrc.json @@ -72,6 +72,12 @@ "credentials": { "url": "http://localhost:0/ngds-mock" } + }, + "semaphore": { + "kind": "rest", + "credentials": { + "url": "http://localhost:0/semaphore-mock" + } } } } diff --git a/docs/superpowers/specs/2026-09-08-2184-semaphore-auto-sync-design.md b/docs/superpowers/specs/2026-09-08-2184-semaphore-auto-sync-design.md new file mode 100644 index 000000000..05404c193 --- /dev/null +++ b/docs/superpowers/specs/2026-09-08-2184-semaphore-auto-sync-design.md @@ -0,0 +1,100 @@ +# Semaphore Taxonomy Auto-Sync (#2184) + +**Date:** 2026-09-08 +**Issue:** [sap-tutorials/tutorials-ims#2184](https://github.com/sap-tutorials/tutorials-ims/issues/2184) — "Automate Loading Data from Semaphore" +**Status:** implemented behind a default-OFF DB feature flag (`SEMAPHORE_SYNC_ENABLED`); ships dark, awaits a Service Account + `semaphore-destination`. + +## Problem + +Tag taxonomy is currently loaded from Progress/Smartlogic **Semaphore** by a manual, +one-off batch. Terms drift (renames, new industry clusters, retired products) and the +`Tags` table goes stale until someone re-runs the load by hand. The issue asks: can this +be automated? + +## Answer + +Yes. Semaphore's **SES (Semantic Enhancement Server)** exposes a read-only REST API. The +`allterms` command returns every term of a model as JSON, filterable by class: + +``` +GET {base}/{model}/{lang}/allterms.json[?FILTER=CL=] +e.g. https://sap.data.progress.cloud/semantic/prodses/SAPCore/en/allterms.json +``` + +Auth needs a Semaphore **Service Account** + API User role + token (requested by email in +parallel — see the issue comment). Productive/unattended use requires a Service Account +rather than a personal user. + +## Design + +Pipeline (weekly cron, all pure/injectable for test): + +``` +client.fetchAllTerms(SAPCore) → SES allterms JSON + → mapper.mapAllTerms(data) → [{ semaphoreId, label, name, titlePath, isActualTag, isInterestItem }] + → applier.applyTerms(rows) → upsert into Tags, keyed on semaphoreId +``` + +### Components + +| File | Role | +|---|---| +| `srv/lib/semaphore-sync/client.js` | SES REST client. Resolves `semaphore-destination` (auth token / basic), builds the allterms URL, GETs with a 20s timeout, **fails shut** (throws) on any HTTP/parse/shape error. | +| `srv/lib/semaphore-sync/mapper.js` | Pure transform SES term → tag row. Derives `titlePath` (human "A : B") and `name` (normalized). Class→flag mapping is **config-driven** (`actualTagClasses` / `interestItemClasses`). | +| `srv/lib/semaphore-sync/applier.js` | Upsert engine keyed on `semaphoreId` (distinct from the CSV importer which keys on `name`). Adopts a legacy row by name when it has no `semaphoreId` yet. Idempotent; supports `dryRun`. | +| `srv/jobs/semaphore-tag-sync-job.js` | Orchestrator. Flag-gated, config-driven, fail-shut. | + +### No schema change + +`Tags` already carries `semaphoreId`, `isActualTag`, `isInterestItem` (PR-1 of #385). The +manual load and the one-time migration are the only current sources of `semaphoreId`; this +job keeps it fresh. + +### Configuration (DB-driven — no env vars) + +Feature flag (registry `kind:'db'`, ImsConfig `flag.semaphore.sync`, default **OFF**, `dev-only`): + +- `SEMAPHORE_SYNC_ENABLED` — master switch. Off → job no-ops (`reason:'flag-off'`). + +ImsConfig string keys tune a run without redeploy: + +| Key | Default | Purpose | +|---|---|---| +| `semaphore.sync.model` | `SAPCore` | SES model name | +| `semaphore.sync.lang` | `en` | language | +| `semaphore.sync.filter` | — | optional SES `FILTER` clause | +| `semaphore.sync.actualTagClasses` | — | comma-sep classes → `isActualTag=true` | +| `semaphore.sync.interestItemClasses` | — | comma-sep classes → `isInterestItem=true` | +| `semaphore.sync.dryRun` | `true` | **report-only** until validated against real data | + +### Destination + +`cds.requires.semaphore` (kind `rest`) → BTP destination `semaphore-destination` +(`hybrid` + `production`). Base/dev/unit profiles use a mock URL in `.cdsrc.json`. The +Service Account token lives in the destination / BTP Credential Store — **never** in source. + +### Safety + +- **DEV-first, not prod-only** (unlike NGDS/feedback) — the mapping must be validated on DEV + before PROD; `dryRun` defaults ON so the *first* enabled runs only report the plan. +- **Fail-shut fetch:** a transient SES outage or garbled payload throws before the applier + is reached, so the taxonomy is never wiped by an empty response. A FAILED run is recorded. +- **Idempotent upsert:** re-running with the same payload reports everything `unchanged`. +- Weekly cadence: **Sunday 04:47 UTC**, 20-min lock (off the existing minute grid). + +## Open items (need the Service Account before flipping ON) + +1. Confirm the exact `paths` element shape and the class URIs that denote "actual tag" vs + "interest item" against a live SAPCore payload — the mapper is deliberately config-driven + and conservative (every term an actual tag, none an interest item) until then. +2. Confirm the destination auth flavour (OAuth2 client-credentials vs. long-lived bearer); + `client.deriveAuth()` already handles token / basic. +3. Run the job in `dryRun` on DEV, tune the class lists, then flip `dryRun` off, then enable + the flag on PROD. + +## Tests + +- `test/unit/semaphore-mapper.test.js` — transform, dedupe, class→flag, malformed input. +- `test/unit/semaphore-client.test.js` — URL builder, auth derivation, HTTP/shape errors. +- `test/unit/semaphore-sync-applier.test.js` — insert/update/adopt/idempotent/dryRun (DB-backed). +- `test/unit/semaphore-tag-sync-job.test.js` — flag gate, dryRun default, write path, fail-shut. diff --git a/package.json b/package.json index 43e068cd8..92c0795df 100644 --- a/package.json +++ b/package.json @@ -328,6 +328,23 @@ } } }, + "semaphore": { + "kind": "rest", + "[hybrid]": { + "kind": "rest", + "credentials": { + "destination": "semaphore-destination", + "path": "/semantic/prodses" + } + }, + "[production]": { + "kind": "rest", + "credentials": { + "destination": "semaphore-destination", + "path": "/semantic/prodses" + } + } + }, "telemetry": { "kind": "telemetry-to-console", "tracing": { diff --git a/srv/jobs/scheduler.js b/srv/jobs/scheduler.js index 4b139708b..bc1f11c96 100644 --- a/srv/jobs/scheduler.js +++ b/srv/jobs/scheduler.js @@ -969,6 +969,22 @@ export function registerJobs() { }, }); + // #2184 — weekly Semaphore taxonomy auto-sync (replaces the manual SES batch + // load). Sunday 04:47 UTC — off-cluster from the :00/:07/:11/:13/:17/:19/:23/ + // :31/:37/:43/:57 minute grid. Gated by the SEMAPHORE_SYNC_ENABLED DB flag + // (default OFF); flag-off runs no-op. Fail-shut — a bad fetch never writes. + // Lazy-import keeps boot fast + avoids pulling @sap-cloud-sdk at module load. + registerJob({ + jobName: 'semaphore-tag-sync', + schedule: '47 4 * * 0', + ttlMs: 20 * 60 * 1000, + description: 'Sync Semaphore SES taxonomy (SAPCore) into Tags, keyed on semaphoreId (weekly)', + fn: async (logId, opts) => { + const { runSemaphoreTagSync } = await import('./semaphore-tag-sync-job.js'); + return runSemaphoreTagSync(logId, opts); + }, + }); + // #1030 — Every 6 h at minute 17 (off :00/:30 to avoid stampede). Keeps the // Row 3 homepage events band fresh without incurring LLM cost — this job // ONLY re-pulls Khoros + RSS and upserts CommunityEvents (title, url, diff --git a/srv/jobs/semaphore-tag-sync-job.js b/srv/jobs/semaphore-tag-sync-job.js new file mode 100644 index 000000000..9d3842e50 --- /dev/null +++ b/srv/jobs/semaphore-tag-sync-job.js @@ -0,0 +1,128 @@ +// srv/jobs/semaphore-tag-sync-job.js +// +// Weekly cron that replaces the manual, one-off Semaphore batch load (#2184). +// Pulls the SAPCore taxonomy from the Semaphore SES `allterms` API, maps terms +// to Tag rows, and upserts them keyed on `semaphoreId` (a renamed term updates +// in place instead of duplicating). +// +// client.fetchAllTerms → mapper.mapAllTerms → applier.applyTerms +// +// Gates + config (all DB-driven — Tom prefers admin config over env vars): +// - Feature flag SEMAPHORE_SYNC_ENABLED (ImsConfig flag.semaphore.sync, +// default OFF, dev-only). Flag off → job no-ops with reason:'flag-off'. +// - ImsConfig string keys tune the run without a redeploy: +// semaphore.sync.model (default 'SAPCore') +// semaphore.sync.lang (default 'en') +// semaphore.sync.filter (optional SES FILTER clause) +// semaphore.sync.actualTagClasses (comma-separated class names/URIs) +// semaphore.sync.interestItemClasses (comma-separated class names/URIs) +// semaphore.sync.dryRun ('true'/'false', DEFAULT 'true') — +// first live runs only report the plan so the class→flag mapping can be +// validated against real data before it writes. Flip to 'false' to persist. +// +// FAIL-OPEN / FAIL-SHUT: a fetch or mapping error is caught, logged, and +// returned as { ok:false, error } — the cron chassis records a FAILED run and +// NOTHING is written. The applier is only reached on a well-formed, non-empty +// terms[] payload, so a transient outage can never wipe the taxonomy. + +import cds from '@sap/cds'; +import { fetchAllTerms } from '../lib/semaphore-sync/client.js'; +import { mapAllTerms } from '../lib/semaphore-sync/mapper.js'; +import { applyTerms } from '../lib/semaphore-sync/applier.js'; +import { isFlagEnabled } from '../lib/feature-flags/db-flags.js'; + +const LOG = cds.log('semaphore-sync'); +const NS = 'com.sap.developers.ims'; + +const CONFIG_KEYS = [ + 'semaphore.sync.model', + 'semaphore.sync.lang', + 'semaphore.sync.filter', + 'semaphore.sync.actualTagClasses', + 'semaphore.sync.interestItemClasses', + 'semaphore.sync.dryRun', +]; + +function splitList(v) { + return String(v ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); +} + +// Read the semaphore.sync.* string config from ImsConfig in one SELECT. +async function readConfig(db) { + const { ImsConfig } = cds.entities(NS); + let rows = []; + try { + rows = await db.run( + SELECT.from(ImsConfig).columns('key', 'value').where({ key: { in: CONFIG_KEYS } }), + ); + } catch (e) { + LOG.warn(`ImsConfig read failed, using defaults: ${e.message}`); + } + const map = new Map(rows.map((r) => [r.key, r.value])); + return { + model: map.get('semaphore.sync.model') || 'SAPCore', + lang: map.get('semaphore.sync.lang') || 'en', + filter: map.get('semaphore.sync.filter') || undefined, + actualTagClasses: splitList(map.get('semaphore.sync.actualTagClasses')), + interestItemClasses: splitList(map.get('semaphore.sync.interestItemClasses')), + // dryRun defaults to TRUE — first live runs report the plan without writing. + dryRun: (map.get('semaphore.sync.dryRun') ?? 'true').toLowerCase() !== 'false', + }; +} + +/** + * @param {*} _logId reserved for the cron chassis (unused) + * @param {object} [opts] test/manual seam: { _deps } forwarded to fetchAllTerms + * @returns {Promise} run summary + */ +export async function runSemaphoreTagSync(_logId, opts = {}) { + if (!isFlagEnabled('SEMAPHORE_SYNC_ENABLED')) { + LOG.info('semaphore-sync skipped: flag off'); + return { ok: true, skipped: true, reason: 'flag-off' }; + } + + const db = cds.db ?? (await cds.connect.to('db')); + const cfg = await readConfig(db); + + let data; + try { + data = await fetchAllTerms({ + model: cfg.model, + lang: cfg.lang, + filter: cfg.filter, + _deps: opts._deps, + }); + } catch (e) { + // Fail-shut: never write on a bad/empty fetch. + LOG.error(`semaphore-sync fetch failed: ${e.message}`); + return { ok: false, error: e.message, model: cfg.model }; + } + + const { rows, skipped } = mapAllTerms(data, { + actualTagClasses: cfg.actualTagClasses, + interestItemClasses: cfg.interestItemClasses, + }); + + let applied = { inserted: 0, updated: 0, unchanged: 0, total: rows.length }; + try { + applied = await applyTerms(rows, { db, dryRun: cfg.dryRun }); + } catch (e) { + LOG.error(`semaphore-sync upsert failed: ${e.message}`); + return { ok: false, error: e.message, mapped: rows.length, skipped: skipped.length }; + } + + const summary = { + ok: true, + model: cfg.model, + dryRun: cfg.dryRun, + fetched: Array.isArray(data.terms) ? data.terms.length : 0, + mapped: rows.length, + skipped: skipped.length, + ...applied, + }; + LOG.info(`semaphore-sync summary: ${JSON.stringify(summary)}`); + return summary; +} diff --git a/srv/lib/feature-flags/registry.js b/srv/lib/feature-flags/registry.js index 8fb415355..30877839e 100644 --- a/srv/lib/feature-flags/registry.js +++ b/srv/lib/feature-flags/registry.js @@ -308,4 +308,12 @@ export const FEATURE_FLAGS = [ description: 'When true, the nightly freshness-scan job runs the detector across the tutorial catalog. DB-driven config (ImsConfig key flag.freshness.scan); no env var. Default OFF.', howToChange: featureFlagUpsert('FRESHNESS_SCAN_ENABLED', 'flag.freshness.scan'), }, + // ---- Taxonomy ---- + { + key: 'SEMAPHORE_SYNC_ENABLED', label: 'Semaphore taxonomy auto-sync', category: 'Taxonomy', + kind: 'db', imsConfigKey: 'flag.semaphore.sync', + valueType: 'boolean', default: false, status: 'dev-only', + description: 'When true, the weekly semaphore-tag-sync job pulls the SAPCore model from the Semaphore SES allterms API and upserts Tags (keyed on semaphoreId). Fail-open: a fetch/mapping error records a FAILED run and never mutates tags. Pairs with ImsConfig keys semaphore.sync.{model,lang,filter,actualTagClasses,interestItemClasses,dryRun} and the semaphore-destination. DB-driven config (ImsConfig key flag.semaphore.sync); no env var. Default OFF (#2184).', + howToChange: featureFlagUpsert('SEMAPHORE_SYNC_ENABLED', 'flag.semaphore.sync'), + }, ]; diff --git a/srv/lib/semaphore-sync/applier.js b/srv/lib/semaphore-sync/applier.js new file mode 100644 index 000000000..83ece2f15 --- /dev/null +++ b/srv/lib/semaphore-sync/applier.js @@ -0,0 +1,85 @@ +// srv/lib/semaphore-sync/applier.js +// +// Persist mapped Semaphore terms (from srv/lib/semaphore-sync/mapper.js) into the +// `Tags` entity. This is a distinct upsert engine from the CSV importer's +// srv/lib/tag-import/applier.js: the CSV path keys on `name` (author-typed rows +// carry no Semaphore id), whereas taxonomy sync keys on the stable natural key +// `semaphoreId`, so a renamed term updates in place instead of duplicating. +// +// Match order per row: +// 1. by semaphoreId → UPDATE (authoritative once a tag has been synced) +// 2. by normalized name (only when semaphoreId not yet present on the row) +// → adopt: attach semaphoreId + flags to a legacy/CSV row +// 3. no match → INSERT (ID assigned here: cds.db INSERT does NOT auto-fill a +// UUID key on HANA, only on SQLite — see memory cds-db-insert-omitting-uuid-key) +// +// Idempotent: a second run with the same payload reports every row `unchanged`. +// dryRun:true computes the same plan without writing — used to validate the +// class→flag mapping against the live SAPCore model before the flag is flipped. + +import cds from '@sap/cds'; + +// Fields we consider when deciding whether an existing row needs an UPDATE. +const TRACKED = ['label', 'name', 'titlePath', 'isActualTag', 'isInterestItem', 'semaphoreId']; + +function differs(existing, row) { + return TRACKED.some((f) => (existing[f] ?? null) !== (row[f] ?? null)); +} + +/** + * Upsert mapper rows into Tags. + * + * @param {Array} rows Output of mapAllTerms().rows + * @param {object} [opts] + * @param {boolean} [opts.dryRun=false] compute the plan without writing + * @param {object} [opts.db] cds db (defaults to cds.db / connect) + * @returns {Promise<{inserted:number, updated:number, unchanged:number, total:number}>} + */ +export async function applyTerms(rows, opts = {}) { + const { dryRun = false } = opts; + const db = opts.db ?? cds.db ?? (await cds.connect.to('db')); + const { Tags } = cds.entities('com.sap.developers.ims'); + + let inserted = 0; + let updated = 0; + let unchanged = 0; + const total = Array.isArray(rows) ? rows.length : 0; + + for (const row of rows ?? []) { + const fields = { + semaphoreId: row.semaphoreId, + label: row.label, + name: row.name, + titlePath: row.titlePath, + isActualTag: !!row.isActualTag, + isInterestItem: !!row.isInterestItem, + }; + + // 1. Existing by semaphoreId. + let existing = await db.run(SELECT.one.from(Tags).where({ semaphoreId: row.semaphoreId })); + // 2. Adopt a legacy/CSV row that matches by name but has no semaphoreId yet. + if (!existing) { + existing = await db.run( + SELECT.one.from(Tags).where({ name: row.name, semaphoreId: null }), + ); + } + + if (existing) { + if (differs(existing, fields)) { + if (!dryRun) await db.run(UPDATE(Tags, existing.ID).set(fields)); + updated++; + } else { + unchanged++; + } + continue; + } + + // 3. Insert. Assign the UUID key explicitly for HANA parity. + if (!dryRun) { + await db.run(INSERT.into(Tags).entries({ ID: cds.utils.uuid(), ...fields })); + } + inserted++; + } + + return { inserted, updated, unchanged, total }; +} diff --git a/srv/lib/semaphore-sync/client.js b/srv/lib/semaphore-sync/client.js new file mode 100644 index 000000000..442e490c6 --- /dev/null +++ b/srv/lib/semaphore-sync/client.js @@ -0,0 +1,127 @@ +// srv/lib/semaphore-sync/client.js +// +// Thin read-only client for the Semaphore SES (Semantic Enhancement Server) +// REST API — the `allterms` command that returns every term of a model as JSON. +// +// GET {baseUrl}/{model}/{lang}/allterms.json[?FILTER=CL=] +// e.g. https://sap.data.progress.cloud/semantic/prodses/SAPCore/en/allterms.json +// +// Connectivity + auth come from the BTP `semaphore-destination` (resolved via +// cds.requires.semaphore → @sap-cloud-sdk/connectivity). The SES tenant is +// reached with a Service Account token (issue #2184); we read the auth material +// off the resolved SDK Destination and attach an Authorization header, mirroring +// how srv/lib/ngds-client.js handles ngds-destination. +// +// Fail-shut on the network side: every path throws a descriptive Error rather +// than returning partial data, so the calling job records a FAILED PipelineLog +// row instead of silently wiping tags from an empty/garbled response. +// +// ⚠️ The precise auth flavour (OAuth2 client-credentials vs. a long-lived bearer +// token property) is not confirmable until the Service Account is issued. We +// prefer the SDK-resolved auth token, fall back to an explicit token property, +// then to Basic — whichever the destination is configured with will work. + +import { getDestination } from '@sap-cloud-sdk/connectivity'; + +const DEFAULT_TIMEOUT_MS = 20_000; + +// Build the allterms URL. Pure + exported for unit testing. +export function buildAllTermsUrl(baseUrl, { model, lang = 'en', filter } = {}) { + if (!baseUrl) throw new Error('semaphore: missing base URL'); + if (!model) throw new Error('semaphore: missing model name'); + const root = String(baseUrl).replace(/\/+$/, ''); + const path = `${root}/${encodeURIComponent(model)}/${encodeURIComponent(lang)}/allterms.json`; + if (!filter) return path; + // FILTER is passed through verbatim (e.g. "CL=INDUSTRY_CLUSTER"); encode the + // value so multi-clause filters survive as a single query parameter. + return `${path}?FILTER=${encodeURIComponent(filter)}`; +} + +// Derive { baseUrl, authHeader } from a resolved SDK Destination. Prefers an +// SDK-resolved OAuth/token (dest.authTokens), then an explicit token property, +// then Basic auth from username/password. +export function deriveAuth(dest) { + if (!dest) throw new Error("Destination 'semaphore-destination' not found"); + const op = dest.originalProperties ?? {}; + const baseUrl = (dest.url ?? op.URL ?? '').replace(/\/+$/, ''); + if (!baseUrl) throw new Error('semaphore-destination has no URL'); + + // 1. SDK already fetched a token for OAuth2*/OAuth2ClientCredentials/token dests. + const sdkToken = Array.isArray(dest.authTokens) && dest.authTokens[0]?.value; + if (sdkToken) { + const type = dest.authTokens[0].type || 'Bearer'; + return { baseUrl, authHeader: `${type} ${sdkToken}` }; + } + // 2. Explicit long-lived token carried as a custom destination property. + const apiToken = op.apiToken ?? op.APIToken ?? op.token ?? dest.apiToken; + if (apiToken) return { baseUrl, authHeader: `Bearer ${apiToken}` }; + + // 3. Basic auth fallback. + const user = dest.username ?? op.User; + const pass = dest.password ?? op.Password; + if (user && pass) { + return { baseUrl, authHeader: `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}` }; + } + throw new Error('semaphore-destination has no usable auth (token/basic)'); +} + +/** + * Fetch and parse the SES allterms response for a model. + * + * @param {object} opts + * @param {string} opts.model model name, e.g. "SAPCore" + * @param {string} [opts.lang='en'] + * @param {string} [opts.filter] SES FILTER clause, e.g. "CL=INDUSTRY_CLUSTER" + * @param {number} [opts.timeoutMs] + * @param {string} [opts.destinationName='semaphore-destination'] + * @param {object} [opts._deps] test seam: { getDestination, fetch } + * @returns {Promise} parsed allterms JSON ({ terms:[...] }) + */ +export async function fetchAllTerms(opts = {}) { + const { + model, + lang = 'en', + filter, + timeoutMs = DEFAULT_TIMEOUT_MS, + destinationName = 'semaphore-destination', + _deps = {}, + } = opts; + + const _getDestination = _deps.getDestination ?? getDestination; + const _fetch = _deps.fetch ?? fetch; + + const dest = await _getDestination({ destinationName }); + const { baseUrl, authHeader } = deriveAuth(dest); + const url = buildAllTermsUrl(baseUrl, { model, lang, filter }); + + const ac = new AbortController(); + const tid = setTimeout(() => ac.abort(), timeoutMs); + let res; + try { + res = await _fetch(url, { + method: 'GET', + headers: { Authorization: authHeader, Accept: 'application/json' }, + signal: ac.signal, + }); + } catch (err) { + throw new Error(`semaphore allterms fetch failed: ${err.message}`); + } finally { + clearTimeout(tid); + } + + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`semaphore allterms HTTP ${res.status}: ${body.slice(0, 200)}`); + } + + let data; + try { + data = await res.json(); + } catch (err) { + throw new Error(`semaphore allterms returned non-JSON: ${err.message}`); + } + if (!data || !Array.isArray(data.terms)) { + throw new Error('semaphore allterms response missing terms[] array'); + } + return data; +} diff --git a/srv/lib/semaphore-sync/mapper.js b/srv/lib/semaphore-sync/mapper.js new file mode 100644 index 000000000..c27181ce6 --- /dev/null +++ b/srv/lib/semaphore-sync/mapper.js @@ -0,0 +1,157 @@ +// srv/lib/semaphore-sync/mapper.js +// +// Pure transformation: an SES (Semantic Enhancement Server) `allterms.json` +// response → rows ready for the tag upsert engine (srv/lib/tag-import/applier.js). +// +// SES allterms shape (see docs.progress.com .../ses-api/allterms.html): +// { parameters:{...}, total:"N", terms:[ { term:{ name, id, classes:[...], +// paths:[{ name, path:[...] }], metadata:{...} } }, ... ] } +// Some SES deployments flatten the wrapper (element IS the term). We accept both. +// +// Output row (consumed by applier.apply with the extended field set): +// { semaphoreId, label, name, titlePath, isActualTag, isInterestItem } +// - semaphoreId : term.id (stable natural key for upsert) +// - label : term.name verbatim (display form, e.g. "SAP S/4HANA") +// - name : normalized/lower-cased label (matches legacy IMS_TAG.name) +// - titlePath : human hierarchical path ("Software Product : SAP S/4HANA"). +// Downstream titlePathToMdFormat() splits on ':' or '/', so we +// emit that human form — NOT the pre-slugged mdFormat. +// - isActualTag / isInterestItem : derived from term.classes via opts. +// +// ⚠️ VERIFY-AGAINST-REAL-PAYLOAD: the exact `paths` element shape and the class +// URIs/names that denote "actual tag" vs "interest item" are not knowable until +// we can call the live SAPCore model with a Service Account (issue #2184). The +// class→flag mapping is therefore config-driven (opts.actualTagClasses / +// opts.interestItemClasses); run the job in dry-run mode against real data and +// tune these before flipping the feature flag on. Defaults are deliberately +// conservative: every synced term is an actual tag, none an interest item. + +const PATH_SEP = ' : '; + +// Case-insensitive membership test tolerant of full class URIs vs short names. +// A term class "http://sap/schema#IndustryCluster" matches config entry +// "IndustryCluster" or the full URI. +function classMatches(termClasses, wanted) { + if (!Array.isArray(termClasses) || termClasses.length === 0) return false; + if (!Array.isArray(wanted) || wanted.length === 0) return false; + const wantedLc = wanted.map((w) => String(w).toLowerCase()); + return termClasses.some((c) => { + const cl = String(c ?? '').toLowerCase(); + const short = cl.includes('#') ? cl.slice(cl.lastIndexOf('#') + 1) + : cl.includes('/') ? cl.slice(cl.lastIndexOf('/') + 1) + : cl; + return wantedLc.some((w) => w === cl || w === short); + }); +} + +// Normalize a display label to the legacy IMS_TAG.name form: lower-cased, with +// path/slash separators reduced to spaces and runs of whitespace collapsed. +// e.g. "SAP S/4HANA" → "sap s 4hana". +export function normalizeName(label) { + return String(label ?? '') + .toLowerCase() + .replace(/[/:]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +// Extract the ordered ancestor→leaf segment names for a term. Defensive across +// the SES `paths` variants seen in the docs: paths[i].path may be an array of +// node objects ({name}) or strings; paths[i] itself may carry a name. Falls +// back to the term's own name when no usable path is present. +function deriveSegments(term) { + const paths = Array.isArray(term.paths) ? term.paths : []; + for (const p of paths) { + const nodes = Array.isArray(p?.path) ? p.path : Array.isArray(p) ? p : null; + if (!nodes) continue; + const names = nodes + .map((n) => (typeof n === 'string' ? n : n?.name)) + .map((n) => (n == null ? '' : String(n).trim())) + .filter(Boolean); + if (names.length) return names; + } + return []; +} + +// Build the human titlePath: ancestor segments joined by " : ", with the term's +// own display name guaranteed to be the last segment. +export function deriveTitlePath(term) { + const label = String(term.name ?? '').trim(); + const segments = deriveSegments(term); + if (segments.length === 0) return label; + if (label && segments[segments.length - 1].toLowerCase() !== label.toLowerCase()) { + segments.push(label); + } + return segments.join(PATH_SEP); +} + +// Unwrap the `{ term: {...} }` envelope when present. +function unwrap(el) { + if (el && typeof el === 'object' && el.term && typeof el.term === 'object') return el.term; + return el; +} + +/** + * Map a parsed SES allterms response to upsert-ready tag rows. + * + * @param {object} data Parsed allterms JSON ({ terms:[...] }). + * @param {object} [opts] + * @param {string[]} [opts.actualTagClasses] class names/URIs → isActualTag=true + * @param {string[]} [opts.interestItemClasses] class names/URIs → isInterestItem=true + * @param {boolean} [opts.defaultIsActualTag=true] isActualTag when no class config matches + * @returns {{ rows: Array, skipped: Array }} + */ +export function mapAllTerms(data, opts = {}) { + const { + actualTagClasses = [], + interestItemClasses = [], + defaultIsActualTag = true, + } = opts; + + const terms = Array.isArray(data?.terms) ? data.terms : []; + const rows = []; + const skipped = []; + const bySemaphoreId = new Map(); + + for (const el of terms) { + const term = unwrap(el); + if (!term || typeof term !== 'object') { + skipped.push({ reason: 'not-an-object', raw: el }); + continue; + } + const semaphoreId = term.id == null ? '' : String(term.id).trim(); + const label = String(term.name ?? '').trim(); + if (!semaphoreId) { + skipped.push({ reason: 'missing-id', raw: term }); + continue; + } + if (!label) { + skipped.push({ reason: 'missing-name', raw: term }); + continue; + } + + const isInterestItem = classMatches(term.classes, interestItemClasses); + const isActualTag = actualTagClasses.length + ? classMatches(term.classes, actualTagClasses) + : defaultIsActualTag; + + const row = { + semaphoreId, + label, + name: normalizeName(label), + titlePath: deriveTitlePath(term), + isActualTag, + isInterestItem, + }; + + // De-dupe on semaphoreId (last write wins) — SES should be unique but be safe. + if (bySemaphoreId.has(semaphoreId)) { + rows[bySemaphoreId.get(semaphoreId)] = row; + } else { + bySemaphoreId.set(semaphoreId, rows.length); + rows.push(row); + } + } + + return { rows, skipped }; +} diff --git a/test/unit/semaphore-client.test.js b/test/unit/semaphore-client.test.js new file mode 100644 index 000000000..a3136aaf1 --- /dev/null +++ b/test/unit/semaphore-client.test.js @@ -0,0 +1,88 @@ +// test/unit/semaphore-client.test.js +import { describe, it, expect, vi } from 'vitest'; +import { buildAllTermsUrl, deriveAuth, fetchAllTerms } from '../../srv/lib/semaphore-sync/client.js'; + +describe('buildAllTermsUrl', () => { + it('builds the canonical allterms path', () => { + expect(buildAllTermsUrl('https://sap.data.progress.cloud/semantic/prodses', { model: 'SAPCore' })) + .toBe('https://sap.data.progress.cloud/semantic/prodses/SAPCore/en/allterms.json'); + }); + + it('honours lang and trims trailing slashes', () => { + expect(buildAllTermsUrl('https://x/ses/', { model: 'M', lang: 'de' })) + .toBe('https://x/ses/M/de/allterms.json'); + }); + + it('appends an encoded FILTER clause', () => { + expect(buildAllTermsUrl('https://x/ses', { model: 'M', filter: 'CL=INDUSTRY_CLUSTER' })) + .toBe('https://x/ses/M/en/allterms.json?FILTER=CL%3DINDUSTRY_CLUSTER'); + }); + + it('throws on missing base url or model', () => { + expect(() => buildAllTermsUrl('', { model: 'M' })).toThrow(/base URL/); + expect(() => buildAllTermsUrl('https://x', {})).toThrow(/model/); + }); +}); + +describe('deriveAuth', () => { + it('prefers an SDK-resolved auth token', () => { + expect(deriveAuth({ url: 'https://x/', authTokens: [{ type: 'Bearer', value: 'tok' }] })) + .toEqual({ baseUrl: 'https://x', authHeader: 'Bearer tok' }); + }); + + it('falls back to an explicit token property', () => { + expect(deriveAuth({ url: 'https://x', originalProperties: { apiToken: 'abc' } })) + .toEqual({ baseUrl: 'https://x', authHeader: 'Bearer abc' }); + }); + + it('falls back to Basic auth', () => { + const { authHeader } = deriveAuth({ url: 'https://x', username: 'u', password: 'p' }); + expect(authHeader).toBe(`Basic ${Buffer.from('u:p').toString('base64')}`); + }); + + it('throws when no auth material is present', () => { + expect(() => deriveAuth({ url: 'https://x' })).toThrow(/no usable auth/); + expect(() => deriveAuth(null)).toThrow(/not found/); + expect(() => deriveAuth({})).toThrow(/no URL/); + }); +}); + +describe('fetchAllTerms', () => { + const dest = { url: 'https://ses/prodses', authTokens: [{ type: 'Bearer', value: 't' }] }; + + it('resolves destination, builds URL, and returns parsed terms', async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ terms: [{ term: { id: '1', name: 'A' } }] }), + }); + const getDestination = vi.fn().mockResolvedValue(dest); + const data = await fetchAllTerms({ model: 'SAPCore', filter: 'CL=X', _deps: { fetch, getDestination } }); + + expect(getDestination).toHaveBeenCalledWith({ destinationName: 'semaphore-destination' }); + const [url, init] = fetch.mock.calls[0]; + expect(url).toBe('https://ses/prodses/SAPCore/en/allterms.json?FILTER=CL%3DX'); + expect(init.headers.Authorization).toBe('Bearer t'); + expect(data.terms).toHaveLength(1); + }); + + it('throws on HTTP error with status + body snippet', async () => { + const fetch = vi.fn().mockResolvedValue({ ok: false, status: 403, text: async () => 'forbidden' }); + const getDestination = vi.fn().mockResolvedValue(dest); + await expect(fetchAllTerms({ model: 'M', _deps: { fetch, getDestination } })) + .rejects.toThrow(/HTTP 403: forbidden/); + }); + + it('throws when the payload lacks a terms[] array', async () => { + const fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ oops: true }) }); + const getDestination = vi.fn().mockResolvedValue(dest); + await expect(fetchAllTerms({ model: 'M', _deps: { fetch, getDestination } })) + .rejects.toThrow(/missing terms/); + }); + + it('wraps network errors', async () => { + const fetch = vi.fn().mockRejectedValue(new Error('ECONNRESET')); + const getDestination = vi.fn().mockResolvedValue(dest); + await expect(fetchAllTerms({ model: 'M', _deps: { fetch, getDestination } })) + .rejects.toThrow(/fetch failed: ECONNRESET/); + }); +}); diff --git a/test/unit/semaphore-mapper.test.js b/test/unit/semaphore-mapper.test.js new file mode 100644 index 000000000..2870c1de6 --- /dev/null +++ b/test/unit/semaphore-mapper.test.js @@ -0,0 +1,98 @@ +// test/unit/semaphore-mapper.test.js +import { describe, it, expect } from 'vitest'; +import { mapAllTerms, normalizeName, deriveTitlePath } from '../../srv/lib/semaphore-sync/mapper.js'; + +// Representative SES allterms.json fragment (shape per docs.progress.com ses-api). +const SAMPLE = { + parameters: {}, + total: '3', + terms: [ + { + term: { + name: 'SAP S/4HANA', + id: '73554900100700000651', + classes: ['http://sap/schema#SoftwareProduct'], + paths: [{ name: 'Software Product', path: [{ name: 'Software Product' }] }], + }, + }, + { + // flattened variant (no { term } wrapper), interest-item class, string path nodes + name: 'Retail', + id: '73554900100700000652', + classes: ['IndustryCluster'], + paths: [{ path: ['Industry Cluster'] }], + }, + { + term: { name: 'License', id: '73554900100700000653', classes: [] }, + }, + ], +}; + +describe('semaphore mapper', () => { + it('normalizeName lower-cases and reduces slash/colon separators', () => { + expect(normalizeName('SAP S/4HANA')).toBe('sap s 4hana'); + expect(normalizeName('Software Product : SAP')).toBe('software product sap'); + expect(normalizeName(null)).toBe(''); + }); + + it('deriveTitlePath builds ancestor : leaf and appends the leaf name', () => { + expect(deriveTitlePath({ name: 'SAP S/4HANA', paths: [{ path: [{ name: 'Software Product' }] }] })) + .toBe('Software Product : SAP S/4HANA'); + // no path → just the label + expect(deriveTitlePath({ name: 'License', paths: [] })).toBe('License'); + // leaf already terminal → not duplicated + expect(deriveTitlePath({ name: 'Retail', paths: [{ path: ['Industry Cluster', 'Retail'] }] })) + .toBe('Industry Cluster : Retail'); + }); + + it('maps terms to upsert rows keyed by semaphoreId', () => { + const { rows, skipped } = mapAllTerms(SAMPLE, { + interestItemClasses: ['IndustryCluster'], + }); + expect(skipped).toEqual([]); + expect(rows).toHaveLength(3); + + const s4 = rows.find((r) => r.semaphoreId === '73554900100700000651'); + expect(s4).toMatchObject({ + label: 'SAP S/4HANA', + name: 'sap s 4hana', + titlePath: 'Software Product : SAP S/4HANA', + isActualTag: true, // default when no actualTagClasses config + isInterestItem: false, + }); + + const retail = rows.find((r) => r.semaphoreId === '73554900100700000652'); + expect(retail.isInterestItem).toBe(true); // class matched interestItemClasses + expect(retail.titlePath).toBe('Industry Cluster : Retail'); + }); + + it('honours actualTagClasses when supplied (opt-in classification)', () => { + const { rows } = mapAllTerms(SAMPLE, { + actualTagClasses: ['SoftwareProduct'], // short-name match against full URI + }); + const s4 = rows.find((r) => r.semaphoreId === '73554900100700000651'); + const license = rows.find((r) => r.semaphoreId === '73554900100700000653'); + expect(s4.isActualTag).toBe(true); + expect(license.isActualTag).toBe(false); // no matching class + }); + + it('skips terms missing id or name, and dedupes on semaphoreId', () => { + const { rows, skipped } = mapAllTerms({ + terms: [ + { term: { name: 'No Id' } }, + { term: { id: 'x1' } }, + { term: { name: 'First', id: 'dup' } }, + { term: { name: 'Second', id: 'dup' } }, + ], + }); + expect(skipped.map((s) => s.reason)).toEqual(['missing-id', 'missing-name']); + const dup = rows.filter((r) => r.semaphoreId === 'dup'); + expect(dup).toHaveLength(1); + expect(dup[0].label).toBe('Second'); // last write wins + }); + + it('returns empty result for a malformed response', () => { + expect(mapAllTerms(null)).toEqual({ rows: [], skipped: [] }); + expect(mapAllTerms({})).toEqual({ rows: [], skipped: [] }); + }); +}); diff --git a/test/unit/semaphore-sync-applier.test.js b/test/unit/semaphore-sync-applier.test.js new file mode 100644 index 000000000..f6c47b49c --- /dev/null +++ b/test/unit/semaphore-sync-applier.test.js @@ -0,0 +1,75 @@ +// test/unit/semaphore-sync-applier.test.js +import cds from '@sap/cds'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { applyTerms } from '../../srv/lib/semaphore-sync/applier.js'; + +cds.test('serve', '--project', '.', '--in-memory'); + +const ROW = (over = {}) => ({ + semaphoreId: 's1', + label: 'SAP S/4HANA', + name: 'sap s 4hana', + titlePath: 'Software Product : SAP S/4HANA', + isActualTag: true, + isInterestItem: false, + ...over, +}); + +describe('semaphore applyTerms', () => { + let db; + let Tags; + + beforeEach(async () => { + db = await cds.connect.to('db'); + ({ Tags } = cds.entities('com.sap.developers.ims')); + await DELETE.from(Tags); + }); + + it('inserts a new term with all Semaphore fields', async () => { + const res = await applyTerms([ROW()], { db }); + expect(res).toEqual({ inserted: 1, updated: 0, unchanged: 0, total: 1 }); + const t = await SELECT.one.from(Tags).where({ semaphoreId: 's1' }); + expect(t).toMatchObject({ + name: 'sap s 4hana', label: 'SAP S/4HANA', + titlePath: 'Software Product : SAP S/4HANA', + isActualTag: true, isInterestItem: false, + }); + expect(t.ID).toBeTruthy(); + }); + + it('is idempotent: a second run reports everything unchanged', async () => { + await applyTerms([ROW()], { db }); + const res = await applyTerms([ROW()], { db }); + expect(res).toEqual({ inserted: 0, updated: 0, unchanged: 1, total: 1 }); + expect(await SELECT.from(Tags)).toHaveLength(1); + }); + + it('updates in place when a synced term is renamed (same semaphoreId)', async () => { + await applyTerms([ROW()], { db }); + const res = await applyTerms([ROW({ label: 'SAP S/4HANA Cloud', titlePath: 'Software Product : SAP S/4HANA Cloud' })], { db }); + expect(res).toEqual({ inserted: 0, updated: 1, unchanged: 0, total: 1 }); + const rows = await SELECT.from(Tags).where({ semaphoreId: 's1' }); + expect(rows).toHaveLength(1); // no duplicate + expect(rows[0].label).toBe('SAP S/4HANA Cloud'); + }); + + it('adopts a legacy row matched by name that lacks a semaphoreId', async () => { + await INSERT.into(Tags).entries({ ID: 'legacy-1', name: 'sap s 4hana', titlePath: 'old', legacyId: 42 }); + const res = await applyTerms([ROW()], { db }); + expect(res).toEqual({ inserted: 0, updated: 1, unchanged: 0, total: 1 }); + const t = await SELECT.one.from(Tags).where({ ID: 'legacy-1' }); + expect(t.semaphoreId).toBe('s1'); + expect(t.titlePath).toBe('Software Product : SAP S/4HANA'); + expect(await SELECT.from(Tags)).toHaveLength(1); // adopted, not duplicated + }); + + it('dryRun computes the plan without writing', async () => { + const res = await applyTerms([ROW()], { db, dryRun: true }); + expect(res).toEqual({ inserted: 1, updated: 0, unchanged: 0, total: 1 }); + expect(await SELECT.from(Tags)).toHaveLength(0); + }); + + it('handles an empty payload', async () => { + expect(await applyTerms([], { db })).toEqual({ inserted: 0, updated: 0, unchanged: 0, total: 0 }); + }); +}); diff --git a/test/unit/semaphore-tag-sync-job.test.js b/test/unit/semaphore-tag-sync-job.test.js new file mode 100644 index 000000000..2f96ea654 --- /dev/null +++ b/test/unit/semaphore-tag-sync-job.test.js @@ -0,0 +1,83 @@ +// test/unit/semaphore-tag-sync-job.test.js +import cds from '@sap/cds'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { runSemaphoreTagSync } from '../../srv/jobs/semaphore-tag-sync-job.js'; +import { __setFlagForTest, __resetFlagsForTest } from '../../srv/lib/feature-flags/db-flags.js'; + +cds.test('serve', '--project', '.', '--in-memory'); + +// A fake SES allterms payload + an injectable fetch/getDestination pair. +const PAYLOAD = { + terms: [ + { term: { name: 'SAP S/4HANA', id: 's1', classes: ['SoftwareProduct'], paths: [{ path: ['Software Product'] }] } }, + { term: { name: 'Retail', id: 's2', classes: ['IndustryCluster'] } }, + ], +}; +const okDeps = (payload = PAYLOAD) => ({ + getDestination: async () => ({ url: 'https://ses/prodses', authTokens: [{ type: 'Bearer', value: 't' }] }), + fetch: async () => ({ ok: true, json: async () => payload }), +}); + +async function setConfig(db, entries) { + const { ImsConfig } = cds.entities('com.sap.developers.ims'); + for (const [key, value] of Object.entries(entries)) { + await db.run(INSERT.into(ImsConfig).entries({ ID: cds.utils.uuid(), key, value })); + } +} + +describe('runSemaphoreTagSync', () => { + let db; + let Tags; + let ImsConfig; + + beforeEach(async () => { + db = await cds.connect.to('db'); + ({ Tags, ImsConfig } = cds.entities('com.sap.developers.ims')); + await DELETE.from(Tags); + await DELETE.from(ImsConfig); + __resetFlagsForTest(); + }); + afterEach(() => __resetFlagsForTest()); + + it('no-ops when the feature flag is off', async () => { + __setFlagForTest('SEMAPHORE_SYNC_ENABLED', false); + const res = await runSemaphoreTagSync(null, { _deps: okDeps() }); + expect(res).toEqual({ ok: true, skipped: true, reason: 'flag-off' }); + expect(await SELECT.from(Tags)).toHaveLength(0); + }); + + it('dryRun default: reports the plan without writing', async () => { + __setFlagForTest('SEMAPHORE_SYNC_ENABLED', true); + await setConfig(db, { 'semaphore.sync.interestItemClasses': 'IndustryCluster' }); + const res = await runSemaphoreTagSync(null, { _deps: okDeps() }); + expect(res.ok).toBe(true); + expect(res.dryRun).toBe(true); + expect(res).toMatchObject({ fetched: 2, mapped: 2, inserted: 2, updated: 0 }); + expect(await SELECT.from(Tags)).toHaveLength(0); // dry run wrote nothing + }); + + it('writes tags when dryRun is disabled', async () => { + __setFlagForTest('SEMAPHORE_SYNC_ENABLED', true); + await setConfig(db, { + 'semaphore.sync.dryRun': 'false', + 'semaphore.sync.interestItemClasses': 'IndustryCluster', + }); + const res = await runSemaphoreTagSync(null, { _deps: okDeps() }); + expect(res).toMatchObject({ ok: true, dryRun: false, inserted: 2 }); + const tags = await SELECT.from(Tags); + expect(tags).toHaveLength(2); + const retail = tags.find((t) => t.semaphoreId === 's2'); + expect(retail.isInterestItem).toBe(true); + }); + + it('fails shut on a fetch error — writes nothing', async () => { + __setFlagForTest('SEMAPHORE_SYNC_ENABLED', true); + await setConfig(db, { 'semaphore.sync.dryRun': 'false' }); + const deps = { getDestination: async () => ({ url: 'https://ses', authTokens: [{ value: 't' }] }), + fetch: async () => ({ ok: false, status: 500, text: async () => 'boom' }) }; + const res = await runSemaphoreTagSync(null, { _deps: deps }); + expect(res.ok).toBe(false); + expect(res.error).toMatch(/HTTP 500/); + expect(await SELECT.from(Tags)).toHaveLength(0); + }); +});