From 7e30ee29af93effea7cd16b6541e63b8e714b3f5 Mon Sep 17 00:00:00 2001 From: Rainer Friederich Date: Mon, 29 Jun 2026 12:41:35 +0200 Subject: [PATCH 1/5] fix(project-engine-client): round-trip created+published market in the mock (#1745) The Project Engine Counterfact mock dropped a just-created market: the create read-view returned an empty settings.ai.language.name, so the consumer's langOf mapped it to null and listMarkets dropped the project, and publish never flipped the stored status. Resolve the create read-view's language.name to the ISO code (via a new shared mock/language-catalog.js id<->name<->iso catalog) and country.name via Intl.DisplayNames, and make publish flip the stored publish_status draft->live and stamp published_at (leaving is_draft as-is, matching live). Also fold in the second-angle live sweep (2026-06-29) fidelity items: - read-view echoes null (not []/'') for an omitted brand_names / location_name - prompt create dedups by text into existing_count; benchmark create 409s on a duplicate brand name / alias / domain - DELETE of a non-existent project 404s (serenity treats 404 as success) - document the no-ownership model, error layering, pagination asymmetry, and the empty brand-topics prompts as intentional mock simplifications All mock-fidelity / doc items; no production-code change. Unit 100% coverage, types + lint clean, e2e (38) green incl. the round-trip. Introduced by: #1745 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../docs/mock-statefulness.md | 93 ++++++++++++++++-- .../mock/context.js | 5 + .../mock/counterfact/routes/v1/languages.js | 52 ++-------- .../workspaces/{id}/projects/{project_id}.js | 12 ++- .../{id}/projects/{project_id}/publish.js | 14 +++ .../{project_id}/ai_models/benchmarks.js | 30 +++++- .../{project_id}/aio/prompts/tagged.js | 11 ++- .../mock/factories.js | 55 ++++++++++- .../mock/language-catalog.js | 98 +++++++++++++++++++ .../test/e2e/project-engine-mock.e2e.js | 83 ++++++++++++++++ .../test/mock/context.test.js | 8 ++ .../test/mock/factories.test.js | 32 +++++- .../test/mock/language-catalog.test.js | 47 +++++++++ 13 files changed, 471 insertions(+), 69 deletions(-) create mode 100644 packages/spacecat-shared-project-engine-client/mock/language-catalog.js create mode 100644 packages/spacecat-shared-project-engine-client/test/mock/language-catalog.test.js diff --git a/packages/spacecat-shared-project-engine-client/docs/mock-statefulness.md b/packages/spacecat-shared-project-engine-client/docs/mock-statefulness.md index 21b118183..959f539ea 100644 --- a/packages/spacecat-shared-project-engine-client/docs/mock-statefulness.md +++ b/packages/spacecat-shared-project-engine-client/docs/mock-statefulness.md @@ -77,7 +77,31 @@ change, the live proof. relies on it — those cases provision an allocation and write prompts/publish under freshly minted, never-created project ids to exercise metering in isolation. If a future consumer flow needs unknown-project 404s, add a shared `requireProject(scope)` guard to the child writers and - seed the parent projects the quota cases use. + seed the parent projects the quota cases use. (Live evidence, 2026-06-29: GET / DELETE / publish / + add-prompt / by_tags / list-ai_models on a non-existent `project_id` all `404 { message: "not + found" }`. The mock now mirrors this for **DELETE project** — see the read-view section — but child + writes still succeed against a bogus project id, on purpose.) +- **No tenant / ownership model.** PE `GET projects` returns `200 { items: [] }` for *any* workspace + id; live `403 { invalid access attempt }`s an unknown/unowned workspace (and `200 { items: [] }` + only for an owned-but-empty one) — the mock has no notion of ownership. Likewise the **bearer gate + is presence-only**: a garbage `Bearer xxx` passes (live `401`s it). Both are deliberate — the + consumer always operates on workspaces it owns, with a real IMS token. (verified 2026-06-29) +- **Two live error layers collapsed into one.** Live has a gateway/auth layer (`application/json`, + `{detail}`/`{message}`) and a PE app/handler layer (**`text/plain`**, Go-validator/handler bodies: + `400` field-validation, `404 {message:"not found"}`, `500 {message:"internal server error"}`, + `409` conflict). The mock returns every error as `application/json` with a single envelope, and + does **not** reproduce the unguarded **500s** live throws for a bad input it never validates + (unknown `language_id`, `type:"seo"`, `GET projects` with no params / `?live=true`). The consumer + reads status, not error bodies (`mapError` redacts upstream), so this is shape-only. (2026-06-29) +- **Pagination is asymmetric across catalogs.** `GET /v1/languages` ignores `page`/`limit` (always + all 38, `page:1`) — the mock matches; `GET /v1/ai_models` and `GET projects` *do* paginate live — + the mock does not honor `page`/`limit` on those (returns the full list). No consumer paginates + these, so it is unmodelled rather than wrong. (2026-06-29) +- **`brand-topics` `prompts` is empty in the mock.** Live `GET brand-topics` returns each topic with + a large multilingual keyword array under `prompts`; the mock's two static topics carry + `prompts: []`, so serenity's volume-ranked `generateAndAttachPrompts` (which reads per-topic + `prompts`) gets nothing from the mock — a fidelity gap for the prompt-generation flow only. Seed + richer topics if a cross-repo e2e drives that path. (2026-06-29) - **`listBrandUrls` always returns `200 { brand_urls }`; live can `404`.** Live (verified 2026-06-25) `GET …/aio/benchmarks/{bid}/brand_urls` returns `404 { message: "not found" }` when `{bid}` is not the project's listable (auto-created main-brand) benchmark — even though a `POST` @@ -93,18 +117,71 @@ change, the live proof. immediately readable on purpose (deterministic test double); to exercise the consumer's absent-benchmark / `404`-skip branches, model them **deterministically via seed/control state**, never via time-based delays — see "Replicating live async behaviour" below. -- **Create ops report `existing_count: 0` unconditionally.** `POST .../aio/prompts/tagged` and - `POST .../ai_models/benchmarks` always return `existing_count: 0` — the mock models no dedup - against already-present rows, so the consumer's "some already present" branch - (`existing_count > 0`) cannot be exercised against this mock. Deliberate: the confirmed consumer - flows create into freshly scoped collections, and dedup fidelity adds store complexity no flow - reads. Add a name/domain-keyed existing-count if a future flow depends on it. +- **Create ops now model live dedup/conflict (#1745).** `POST .../aio/prompts/tagged` dedups by + prompt **text** — a text already present is not re-created, it is counted in `existing_count` and + gets no new id (live, 2026-06-29). `POST .../ai_models/benchmarks` instead treats a duplicate + brand name / alias / domain as a **hard `409`** `{ message: "ai benchmark conflict: duplicate + brand name or alias" }` (benchmarks and prompts dedup *differently* live) and creates nothing for + the batch. Not modelled: **slice-uniqueness** (two US/en projects both succeed with distinct ids — + PE does not enforce it; that invariant lives only in serenity's `findBySlice`/DB), matching live. + +## Project read-view fidelity (#1745, live-pinned 2026-06-29) + +The created/published project read-view is reconstructed by the consumer into an addressable +**market** (`spacecat-api-service` `subworkspace-projects.js`), so its `settings.ai` shape is +load-bearing. Two fields are resolved by the mock to match live, and the publish action mutates the +stored project: + +- **`settings.ai.language.name` = the ISO code, NOT the English display name.** Live returns the + ISO code (`"en"`) here on both the create response and every read-view, while `language.id` is the + catalog UUID sent on create (the SAME UUID `GET /v1/languages` returns under the English display + name `"English"` — only `name` differs between the two views). The consumer's `langOf` reads + `settings.ai.language.name` directly and lowercases it as the slice code, so the create factory + resolves `language_id` → ISO via the shared **`mock/language-catalog.js`** (`isoForLanguageId`), + which carries an `iso` column alongside the live `id`/`name`. Only `en` is live-verified; the + other ISO codes are standard ISO 639-1, best-effort so any market a downstream e2e exercises + round-trips. (`GET /v1/languages` still serves just `{ id, name }` — the `iso` column is + mock-internal.) +- **`settings.ai.country.name`** is populated from the country code via `Intl.DisplayNames` (region). + **Documented divergence:** live returns the short informal name (`"USA"` for `us`); Intl returns + `"United States"`. Accepted because **no consumer reads `country.name`** — `geoOf` resolves geo + from `country.code` → `resolveLocation` — so it is fidelity-only, and a hand-maintained + code→informal-name catalog would be unread maintenance. `location.id`/`name` are echoed from the + request verbatim (matches live for the consumer-driven create path). +- **Publish moves the stored read-view to live.** `POST .../publish` flips the stored project's + `publish_status` `draft` → `live` and stamps `published_at`, so a later `GET`/list reports the + published status (the slice's `mapPublishStatus(publish_status)` reads `live`). **`is_draft` is + left as-is** — live keeps `is_draft: true` after publish (only `publish_status`/`published_at` + change; live's full quirk is `true` on create → `false` on GET-as-draft → `true` post-publish), + and the consumer ignores `is_draft` entirely. The publish update is a no-op for an unknown project + id, so the metering cases (which publish under never-created ids) are unaffected. + +## Endpoint status-code quirks the mock does NOT reproduce (live errors; mock is permissive) + +Live returns errors for several call *shapes* the consumer never sends; the mock answers normally. +None affects the consumer, but they are documented so a future caller doesn't assume mock parity +(live captured 2026-06-29): + +| call | live | mock | why it's safe | +| --- | --- | --- | --- | +| `GET /v1/.../projects` (no params) | **500** | 200 | the consumer always sends `?type=ai` | +| `GET /v1/.../projects?live=true` | **500** | 200 | the consumer never sends `live=` | +| `GET .../aio/prompts` | **405** | (DELETE only) | reads go via `by_tags` (POST) | +| `GET .../aio/prompts/tagged` | **405** | (POST only) | create is POST | +| `GET .../ci/competitors` | **405** | (PUT only) | update is PUT | +| `POST .../publish` | 202, `content-length: 0`, **no `Content-Type`** | 202 + `Content-Type: application/json` | consumer keys off status/`response.ok`; body empty either way (Counterfact limit, see responses.js) | ## Replicating live async behaviour (don't use timers) Live Semrush is **eventually consistent**: a just-created prompt/brand-URL isn't listed yet, and a project's main-brand benchmark is generated asynchronously (it didn't appear within ~60s of create, -even after a publish — verified 2026-06-25). The mock is deliberately **immediately consistent**. +even after a publish — verified 2026-06-25). A further, distinct case (live-pinned 2026-06-29): +the project **list** (`GET projects?type=ai`) lags a new project by ~4 s, and **prompt reads** +(`POST aio/prompts/by_tags`) are **publish-gated** — a created prompt stays invisible until the +project is published, then appears (this is a published-snapshot read, not lag). The end-to-end +consumer flow closes anyway because `POST /prompts` runs `publishAffected`; a test that lists +prompts *before* that publish would diverge from the immediately-consistent mock. The mock is +deliberately **immediately consistent**. **Do NOT make the mock time-based** (delays, "appears after N seconds / N reads"). A test double's value is determinism; introducing wall-clock async makes consumer tests flaky and slow, and the diff --git a/packages/spacecat-shared-project-engine-client/mock/context.js b/packages/spacecat-shared-project-engine-client/mock/context.js index ffa533dae..fba4c13e5 100644 --- a/packages/spacecat-shared-project-engine-client/mock/context.js +++ b/packages/spacecat-shared-project-engine-client/mock/context.js @@ -43,6 +43,7 @@ import { createQuota } from './quota.js'; import { authError } from './auth.js'; import { emptyAck } from './responses.js'; import * as factories from './factories.js'; +import { LANGUAGE_CATALOG } from './language-catalog.js'; import { SEEDS, DEFAULT_SEED } from './seeds.js'; /** @@ -82,6 +83,10 @@ export class Context { // literals — the factory is the single, tsc-checked source of truth for each shape, so the // handlers can't drift from the spec the way duplicated literals would. this.factories = factories; + // The canonical language catalog (mock/language-catalog.js). Exposed so the `GET /v1/languages` + // route serves it without duplicating the 38-entry list, mirroring how `factories` is shared — + // every route reads its lib data through `$.context`, never an import. + this.languageCatalog = LANGUAGE_CATALOG; } /** diff --git a/packages/spacecat-shared-project-engine-client/mock/counterfact/routes/v1/languages.js b/packages/spacecat-shared-project-engine-client/mock/counterfact/routes/v1/languages.js index 9fbc182f5..b063ca523 100644 --- a/packages/spacecat-shared-project-engine-client/mock/counterfact/routes/v1/languages.js +++ b/packages/spacecat-shared-project-engine-client/mock/counterfact/routes/v1/languages.js @@ -14,54 +14,16 @@ * Static handler for GET /v1/languages — the language catalog the consumer * (spacecat-api-service `listLanguages`) reads to resolve an ISO 639-1 code to a Semrush language * UUID. The live shape is `{ page, total, items: [{ id, name }] }` — items carry NO key/icon (that - * is the ai_models shape). This is the FULL live catalog (38 languages, real UUIDs + names), - * captured verbatim 2026-06-25 so the mock returns the same taxonomy the consumer resolves against. + * is the ai_models shape) and NO `iso` (that field is mock-only, for the read-view resolver). The + * catalog (38 languages, real UUIDs + names, captured verbatim 2026-06-25) lives in + * `mock/language-catalog.js` so it is shared with the project read-view factory rather than + * duplicated here; it is exposed on the per-request context as `$.context.languageCatalog`. * Materialized into `.counterfact/routes/` by the mock runner; excluded from coverage. */ -const LANGUAGES = [ - { id: '76b01191-fa8b-4c1d-a71f-55ca8996669c', name: 'Afrikaans' }, - { id: '7d3efff9-f6b6-4818-ac6b-0d1d2c047803', name: 'Arabic' }, - { id: 'cfcdee66-f9b3-4f39-9066-141b8de1a52d', name: 'Armenian' }, - { id: 'c5d84e73-66f9-4f17-989f-9a421d17cfe3', name: 'Azerbaijani' }, - { id: 'ac1ace1e-22a1-40e9-b869-4fe2d0467603', name: 'Bulgarian' }, - { id: '728bef4c-94cf-4e14-bc06-56534751c71a', name: 'Chinese Simplified' }, - { id: '4fecf249-03fa-430a-b0b1-42421c5c1f7d', name: 'Chinese Traditional' }, - { id: 'e64e7f7d-11d9-4830-a022-f370e0a74bf7', name: 'Czech' }, - { id: '4f407a00-600e-4bdc-bf18-e5d8c7f758e3', name: 'Danish' }, - { id: '9090239f-5a3e-4101-a828-9bde09ad378b', name: 'Dutch' }, - { id: '5a0a33ed-7f5c-4901-befd-a042c0350da1', name: 'English' }, - { id: 'f8b95d67-6ac5-48c2-9ed1-73b9e52d7f92', name: 'Filipino' }, - { id: '0a28a154-72ac-421c-86cf-1c4d240f3cda', name: 'Finnish' }, - { id: '471aba35-ee1f-4207-b6f6-8c6550bbbab8', name: 'French' }, - { id: '5227c7cd-4136-4fca-919c-42e666787319', name: 'Georgian' }, - { id: 'e5282ae9-83a6-4ea3-b3cf-5e99d8f51eca', name: 'German' }, - { id: 'd7db513e-b5f6-4b11-9309-eda861451008', name: 'Greek' }, - { id: 'a2c8acc6-be1f-47de-94aa-3c2587922922', name: 'Hebrew' }, - { id: 'a3cd47fe-dc60-4cca-89e3-16984a1755af', name: 'Hindi' }, - { id: '495ccfcb-d61f-4544-9dc1-24d6393c9d18', name: 'Hungarian' }, - { id: 'd5376885-d3d8-4e6c-92ea-61d5ff9a7369', name: 'Indonesian' }, - { id: '3ea61bf2-ebf4-40b5-8a59-900e701d966d', name: 'Italian' }, - { id: '54bb0cca-b095-444b-be88-818a02e9beeb', name: 'Japanese' }, - { id: 'ad6bf224-42b0-46b6-b59a-21ea9d021fb8', name: 'Kazakh' }, - { id: 'c181d356-b65e-4ffa-b706-f61f96503d58', name: 'Korean' }, - { id: '1de63814-323e-4b2f-bd36-20fe15de14ae', name: 'Kurdish' }, - { id: '9bb46132-a85a-41c8-8d56-38bf5a050e76', name: 'Malay' }, - { id: 'a3b6e3f2-82f1-4b0b-8223-ae224d54bd63', name: 'Norwegian' }, - { id: 'd53c0677-c451-46b2-9650-83dbca109f25', name: 'Polish' }, - { id: 'a4e931c1-22c8-45d4-897e-a653acbc8126', name: 'Portuguese' }, - { id: 'd64b3fb0-62eb-4d7e-947b-952194314b97', name: 'Romanian' }, - { id: '19933246-6952-4929-ad0d-6cee874ea553', name: 'Russian' }, - { id: '6abdf315-f969-4643-894f-aefabe7ed057', name: 'Slovak' }, - { id: '29047b68-fe6f-4525-8ef7-8b1c2349af5d', name: 'Spanish' }, - { id: '5c400ede-f822-402e-be32-4a88049e74e4', name: 'Swedish' }, - { id: 'b8208c02-3e73-433b-b6c2-2784d0ba9bfa', name: 'Thai' }, - { id: '71630579-fd14-4357-81b3-0ae4ecd54975', name: 'Turkish' }, - { id: '12aa4de5-3526-4b96-909a-c033c1f4e32c', name: 'Vietnamese' }, -]; - -/** GET — list the language catalog (each row shaped via the factory). */ +/** GET — list the language catalog `{ id, name }` (each row shaped via the factory). */ export function GET($) { - const items = LANGUAGES.map((l) => $.context.factories.createLanguageMock(l)); + const items = $.context.languageCatalog + .map(({ id, name }) => $.context.factories.createLanguageMock({ id, name })); return $.response[200].json({ page: 1, total: items.length, items }); } diff --git a/packages/spacecat-shared-project-engine-client/mock/counterfact/routes/v1/workspaces/{id}/projects/{project_id}.js b/packages/spacecat-shared-project-engine-client/mock/counterfact/routes/v1/workspaces/{id}/projects/{project_id}.js index a689f9c4a..9cea415f7 100644 --- a/packages/spacecat-shared-project-engine-client/mock/counterfact/routes/v1/workspaces/{id}/projects/{project_id}.js +++ b/packages/spacecat-shared-project-engine-client/mock/counterfact/routes/v1/workspaces/{id}/projects/{project_id}.js @@ -47,9 +47,17 @@ export function PATCH($) { return $.response[200].json(updated); } -/** DELETE — remove a project. */ +/** + * DELETE — remove a project. Live returns 404 `{ message: 'not found' }` for a non-existent project + * (verified 2026-06-29); the mock mirrors the status (bare 404, like the sibling GET — the + * text/plain error body is a documented simplification). The consumer treats a delete-404 as + * success (idempotent decommission), so the status change is consumer-equivalent. + */ export function DELETE($) { const { path, context } = $; - context.ops.projects.remove({ workspaceId: path.id }, path.project_id); + const removed = context.ops.projects.remove({ workspaceId: path.id }, path.project_id); + if (!removed) { + return { status: 404 }; + } return { status: 204 }; } diff --git a/packages/spacecat-shared-project-engine-client/mock/counterfact/routes/v1/workspaces/{id}/projects/{project_id}/publish.js b/packages/spacecat-shared-project-engine-client/mock/counterfact/routes/v1/workspaces/{id}/projects/{project_id}/publish.js index 91aa39c1d..88e696b2d 100644 --- a/packages/spacecat-shared-project-engine-client/mock/counterfact/routes/v1/workspaces/{id}/projects/{project_id}/publish.js +++ b/packages/spacecat-shared-project-engine-client/mock/counterfact/routes/v1/workspaces/{id}/projects/{project_id}/publish.js @@ -18,6 +18,15 @@ * returns the disguised quota 405 — exactly the "publishing an empty-units child 405s" behaviour * the consumer's `republishBestEffort` swallows (see mock/quota.js). Excluded from coverage * (materialized handler). + * + * Stateful side-effect: on a successful publish the stored project's read-view is moved to the + * live state — `publish_status` flips `draft` → `live` and `published_at` is set — so a later + * `GET`/list reflects the publish (the slice's reported status reads `live`, not `draft`; #1745). + * `is_draft` is deliberately LEFT as-is: live keeps `is_draft: true` after publish (only + * `publish_status`/`published_at` change), and the consumer ignores `is_draft` anyway. The update + * is a no-op for an unknown project id (the quota cases publish under never-created ids), so the + * 202 ack is unaffected — child writes do not assert the parent project exists (see + * docs/mock-statefulness.md). */ /** POST — publish the draft → 202 (empty body); 405 when the workspace has 0 prompt units. */ @@ -30,6 +39,11 @@ export function POST($) { contentType: 'application/json', }; } + // Reflect the publish in the stored read-view: draft → live, stamp published_at, keep is_draft. + context.ops.projects.update({ workspaceId: path.id }, path.project_id, { + publish_status: 'live', + published_at: new Date().toISOString(), + }); // Empty body (content-length 0) like live. The explicit content type (via emptyAck) bypasses // Counterfact's response negotiation, which would otherwise 406 under `Accept: application/json`. return context.emptyAck(202); diff --git a/packages/spacecat-shared-project-engine-client/mock/counterfact/routes/v2/workspaces/{id}/projects/{project_id}/ai_models/benchmarks.js b/packages/spacecat-shared-project-engine-client/mock/counterfact/routes/v2/workspaces/{id}/projects/{project_id}/ai_models/benchmarks.js index a45c07844..8e75f1573 100644 --- a/packages/spacecat-shared-project-engine-client/mock/counterfact/routes/v2/workspaces/{id}/projects/{project_id}/ai_models/benchmarks.js +++ b/packages/spacecat-shared-project-engine-client/mock/counterfact/routes/v2/workspaces/{id}/projects/{project_id}/ai_models/benchmarks.js @@ -17,15 +17,39 @@ * always a competitor (`main_brand: false` — the API cannot set the system-managed own brand). * Writes to the SAME store key (`benchmarks:{ws}:{pid}`) the v1 list/delete use, so a subsequent * list reflects it. Live: 200 `IDsWithStatsResponse` `{ ids, existing_count }` (verified - * 2026-06-25). Excluded from coverage (materialized handler). + * 2026-06-25). Unlike prompts (which dedup into `existing_count`), a benchmark whose brand name, + * alias, or domain collides with an existing one is a HARD **409** conflict + * `{ message: 'ai benchmark conflict: duplicate brand name or alias' }` (verified live 2026-06-29); + * the whole batch is rejected, nothing is created. Excluded from coverage (materialized handler). */ -/** POST — batch-create benchmarks (body: array) → 200 { ids, existing_count }. */ +/** Case-insensitive identity tokens (brand name + aliases + domain) a benchmark conflicts on. */ +const identityTokens = (b) => [b?.brand_name, b?.domain, ...(b?.brand_aliases ?? [])] + .filter((t) => typeof t === 'string' && t.length > 0) + .map((t) => t.toLowerCase()); + +/** POST — batch-create benchmarks (body: array) → 200 { ids, existing_count }; 409 on conflict. */ export function POST($) { const { path, body, context } = $; + const scope = { workspaceId: path.id, projectId: path.project_id }; const entries = Array.isArray(body) ? body : []; + + // Reject if any incoming benchmark collides (brand name / alias / domain, case-insensitive) with + // one already in the project — the live hard-409, not a silent duplicate (#1745 second sweep). + const taken = new Set(context.ops.benchmarks.list(scope).flatMap(identityTokens)); + const conflicts = entries.some((b) => identityTokens(b).some((t) => taken.has(t))); + if (conflicts) { + return { + status: 409, + body: context.factories.createBasicResponseMock({ + message: 'ai benchmark conflict: duplicate brand name or alias', + }), + contentType: 'application/json', + }; + } + const created = context.ops.benchmarks.createMany( - { workspaceId: path.id, projectId: path.project_id }, + scope, entries.map((b) => context.factories.createBenchmarkMock({ brand_name: b?.brand_name ?? '', domain: b?.domain ?? '', diff --git a/packages/spacecat-shared-project-engine-client/mock/counterfact/routes/v2/workspaces/{id}/projects/{project_id}/aio/prompts/tagged.js b/packages/spacecat-shared-project-engine-client/mock/counterfact/routes/v2/workspaces/{id}/projects/{project_id}/aio/prompts/tagged.js index d5def470e..87512fd5b 100644 --- a/packages/spacecat-shared-project-engine-client/mock/counterfact/routes/v2/workspaces/{id}/projects/{project_id}/aio/prompts/tagged.js +++ b/packages/spacecat-shared-project-engine-client/mock/counterfact/routes/v2/workspaces/{id}/projects/{project_id}/aio/prompts/tagged.js @@ -37,7 +37,14 @@ export function POST($) { const scope = { workspaceId: path.id, projectId: path.project_id }; const promptsByText = body?.prompts ?? {}; - const toCreate = Object.entries(promptsByText).map(([text, tags]) => context.factories + // Live dedups prompts by TEXT: a text already present in the project is not re-created — it is + // counted in `existing_count` and gets no new id (verified 2026-06-29). Partition the request + // against the stored prompt texts so only genuinely-new texts are created (object keys are + // unique, so there are no in-batch text duplicates to fold in). + const existingTexts = new Set(context.ops.prompts.list(scope).map((p) => p.name)); + const newEntries = Object.entries(promptsByText).filter(([text]) => !existingTexts.has(text)); + const existingCount = Object.keys(promptsByText).length - newEntries.length; + const toCreate = newEntries.map(([text, tags]) => context.factories .createPromptMock({ name: text, is_new: true, @@ -53,5 +60,5 @@ export function POST($) { } const created = context.ops.prompts.createMany(scope, toCreate); - return $.response[201].json({ ids: created.map((p) => p.id), existing_count: 0 }); + return $.response[201].json({ ids: created.map((p) => p.id), existing_count: existingCount }); } diff --git a/packages/spacecat-shared-project-engine-client/mock/factories.js b/packages/spacecat-shared-project-engine-client/mock/factories.js index 246641150..daf195958 100644 --- a/packages/spacecat-shared-project-engine-client/mock/factories.js +++ b/packages/spacecat-shared-project-engine-client/mock/factories.js @@ -40,8 +40,47 @@ /** @typedef {Schemas['model.AIOProjectInitializedResponse']} InitStatus */ /** @typedef {Schemas['model.CICompetitor']} CiCompetitor */ +import { isoForLanguageId } from './language-catalog.js'; + const uuid = () => globalThis.crypto.randomUUID(); +// Built once: the locale + options are constant, so a per-call constructor would be wasteful. +const REGION_NAMES = new Intl.DisplayNames(['en'], { type: 'region' }); + +/** + * `null`, typed as `any`, for read-view fields the live API echoes as `null` when the create + * request omitted them (`brand_names`, `location.name`). The generated schema types those as + * `string[]`/`string` with no null variant, so this localizes the one unavoidable cast to a single + * documented spot rather than scattering `@ts-ignore`s across the literal. + * @returns {any} + */ +const nullable = () => null; + +/** + * Resolves a 2-letter country code to the informal country name the live project read-view returns + * as `settings.ai.country.name` (e.g. `de` → "Germany"). Live actually returns the short informal + * name ("USA" for `us`), which no built-in reproduces; `Intl.DisplayNames` is the faithful-enough, + * zero-maintenance choice (it returns "United States" for `us` — the one documented divergence — + * and matches live for most others). Not load-bearing: no consumer reads `country.name` (geo + * resolves from `country.code`), so a `''` fallback for an empty/invalid code is safe. + * @param {string} [code] the 2-letter country code (e.g. `us`) + * @returns {string} the English country name, or `''` when the code is empty/invalid + */ +const countryName = (code) => { + if (!code) { + return ''; + } + try { + // With the default `fallback: 'code'`, `.of()` returns the resolved name for a known region + // and the code itself for an unknown-but-valid one — never nullish here — so `String(...)` + // (not `?? ''`) keeps this branchless while satisfying the `string | undefined` return type. + return String(REGION_NAMES.of(code.toUpperCase())); + } catch { + // `.of()` throws a RangeError for a structurally invalid region code — fall back to empty. + return ''; + } +}; + /** * A catalog AI model (`AIModelResponse`). Only `id` is required by the spec; `name`/`key` are * realistic defaults. `icon` is included because the live add path (`POST .../ai_models`) resolves @@ -123,11 +162,19 @@ export const createProjectResponseFromRequest = (request = {}) => { ai: { models_stats: { models: [], models_count: 0 }, prompts_count: 0, - brand_names: request.brand_names ?? [], + // Live echoes `null` (not `[]`/`''`) for an omitted brand_names / location_name on the + // read-view (verified 2026-06-29). The consumer always sends these, so it is fidelity-only; + // the casts satisfy the schema (string[]/string), which has no explicit null variant. + brand_names: request.brand_names ?? nullable(), brand_name_display: request.brand_name_display ?? '', - language: { id: request.language_id ?? '', name: '' }, - country: { code: request.country_code ?? '', name: '' }, - location: { id: request.location_id ?? 0, name: request.location_name ?? '' }, + // Live read-view: `language.name` is the ISO code (e.g. "en"), NOT the English display + // name, and `language.id` is the catalog UUID sent on create. `langOf` reads + // `language.name` directly as the slice code, so resolving the id → ISO is the load-bearing + // round-trip fix (#1745). `country.name` is the informal country name; not consumed, + // populated for fidelity. + language: { id: request.language_id ?? '', name: isoForLanguageId(request.language_id) }, + country: { code: request.country_code ?? '', name: countryName(request.country_code) }, + location: { id: request.location_id ?? 0, name: request.location_name ?? nullable() }, primary_url: request.domain ?? '', segments_count: 0, benchmarks_count: 0, diff --git a/packages/spacecat-shared-project-engine-client/mock/language-catalog.js b/packages/spacecat-shared-project-engine-client/mock/language-catalog.js new file mode 100644 index 000000000..2c4321f4e --- /dev/null +++ b/packages/spacecat-shared-project-engine-client/mock/language-catalog.js @@ -0,0 +1,98 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +// @ts-check + +/** + * The canonical Project Engine language catalog — the single source of truth shared by the + * `GET /v1/languages` route (which returns `{ id, name }`) and the project read-view factory + * (which resolves a create request's `language_id` back to the ISO code the live read-view + * carries). It is the FULL live catalog (38 languages, real UUIDs + English display names), + * captured verbatim 2026-06-25 against the test workspace. + * + * Each row carries THREE values, because the live API uses two different names for a language in + * two different views (confirmed live 2026-06-29): + * - `id` — the Semrush catalog UUID. The consumer (`spacecat-api-service resolveLanguageId`) + * resolves an ISO code → English display `name` → this `id`, then sends it on create. + * - `name` — the English display name (e.g. "English"). This is what `GET /v1/languages` returns. + * - `iso` — the ISO 639-1 code (e.g. "en"). This is what the PROJECT read-view returns as + * `settings.ai.language.name` — NOT the English display name. The consumer's `langOf` + * reads `settings.ai.language.name` directly as the slice language code, so a created + * project must echo the ISO code here for a market to round-trip (see {@link + * isoForLanguageId} and factories.js `createProjectResponseFromRequest`). + * + * Only `en` is live-verified as the read-view value (issue #1745); the other `iso` codes are the + * conventional language codes for each language — ISO 639-1 where one exists, with two exceptions + * (`zh-tw` is a BCP 47 tag for Chinese Traditional; `fil` is ISO 639-2/3 for Filipino, which has no + * 639-1 code) — best-effort so any market a downstream IT exercises round-trips. Catalog `id` / + * `name` are all live-captured. + */ + +/** + * @typedef {{ id: string, name: string, iso: string }} LanguageCatalogEntry + */ + +/** @type {ReadonlyArray} */ +export const LANGUAGE_CATALOG = Object.freeze([ + { id: '76b01191-fa8b-4c1d-a71f-55ca8996669c', name: 'Afrikaans', iso: 'af' }, + { id: '7d3efff9-f6b6-4818-ac6b-0d1d2c047803', name: 'Arabic', iso: 'ar' }, + { id: 'cfcdee66-f9b3-4f39-9066-141b8de1a52d', name: 'Armenian', iso: 'hy' }, + { id: 'c5d84e73-66f9-4f17-989f-9a421d17cfe3', name: 'Azerbaijani', iso: 'az' }, + { id: 'ac1ace1e-22a1-40e9-b869-4fe2d0467603', name: 'Bulgarian', iso: 'bg' }, + { id: '728bef4c-94cf-4e14-bc06-56534751c71a', name: 'Chinese Simplified', iso: 'zh' }, + { id: '4fecf249-03fa-430a-b0b1-42421c5c1f7d', name: 'Chinese Traditional', iso: 'zh-tw' }, + { id: 'e64e7f7d-11d9-4830-a022-f370e0a74bf7', name: 'Czech', iso: 'cs' }, + { id: '4f407a00-600e-4bdc-bf18-e5d8c7f758e3', name: 'Danish', iso: 'da' }, + { id: '9090239f-5a3e-4101-a828-9bde09ad378b', name: 'Dutch', iso: 'nl' }, + { id: '5a0a33ed-7f5c-4901-befd-a042c0350da1', name: 'English', iso: 'en' }, + { id: 'f8b95d67-6ac5-48c2-9ed1-73b9e52d7f92', name: 'Filipino', iso: 'fil' }, + { id: '0a28a154-72ac-421c-86cf-1c4d240f3cda', name: 'Finnish', iso: 'fi' }, + { id: '471aba35-ee1f-4207-b6f6-8c6550bbbab8', name: 'French', iso: 'fr' }, + { id: '5227c7cd-4136-4fca-919c-42e666787319', name: 'Georgian', iso: 'ka' }, + { id: 'e5282ae9-83a6-4ea3-b3cf-5e99d8f51eca', name: 'German', iso: 'de' }, + { id: 'd7db513e-b5f6-4b11-9309-eda861451008', name: 'Greek', iso: 'el' }, + { id: 'a2c8acc6-be1f-47de-94aa-3c2587922922', name: 'Hebrew', iso: 'he' }, + { id: 'a3cd47fe-dc60-4cca-89e3-16984a1755af', name: 'Hindi', iso: 'hi' }, + { id: '495ccfcb-d61f-4544-9dc1-24d6393c9d18', name: 'Hungarian', iso: 'hu' }, + { id: 'd5376885-d3d8-4e6c-92ea-61d5ff9a7369', name: 'Indonesian', iso: 'id' }, + { id: '3ea61bf2-ebf4-40b5-8a59-900e701d966d', name: 'Italian', iso: 'it' }, + { id: '54bb0cca-b095-444b-be88-818a02e9beeb', name: 'Japanese', iso: 'ja' }, + { id: 'ad6bf224-42b0-46b6-b59a-21ea9d021fb8', name: 'Kazakh', iso: 'kk' }, + { id: 'c181d356-b65e-4ffa-b706-f61f96503d58', name: 'Korean', iso: 'ko' }, + { id: '1de63814-323e-4b2f-bd36-20fe15de14ae', name: 'Kurdish', iso: 'ku' }, + { id: '9bb46132-a85a-41c8-8d56-38bf5a050e76', name: 'Malay', iso: 'ms' }, + { id: 'a3b6e3f2-82f1-4b0b-8223-ae224d54bd63', name: 'Norwegian', iso: 'no' }, + { id: 'd53c0677-c451-46b2-9650-83dbca109f25', name: 'Polish', iso: 'pl' }, + { id: 'a4e931c1-22c8-45d4-897e-a653acbc8126', name: 'Portuguese', iso: 'pt' }, + { id: 'd64b3fb0-62eb-4d7e-947b-952194314b97', name: 'Romanian', iso: 'ro' }, + { id: '19933246-6952-4929-ad0d-6cee874ea553', name: 'Russian', iso: 'ru' }, + { id: '6abdf315-f969-4643-894f-aefabe7ed057', name: 'Slovak', iso: 'sk' }, + { id: '29047b68-fe6f-4525-8ef7-8b1c2349af5d', name: 'Spanish', iso: 'es' }, + { id: '5c400ede-f822-402e-be32-4a88049e74e4', name: 'Swedish', iso: 'sv' }, + { id: 'b8208c02-3e73-433b-b6c2-2784d0ba9bfa', name: 'Thai', iso: 'th' }, + { id: '71630579-fd14-4357-81b3-0ae4ecd54975', name: 'Turkish', iso: 'tr' }, + { id: '12aa4de5-3526-4b96-909a-c033c1f4e32c', name: 'Vietnamese', iso: 'vi' }, +]); + +/** Reverse index: catalog UUID → ISO 639-1 code. */ +const ISO_BY_ID = new Map(LANGUAGE_CATALOG.map((l) => [l.id, l.iso])); + +/** + * Resolves a catalog language UUID (what a create request sends as `language_id`) to the ISO code + * the live project read-view returns as `settings.ai.language.name`. Returns `''` for an empty or + * unknown id, mirroring the create factory's other empty defaults. + * @param {string} [id] the catalog language UUID + * @returns {string} the ISO 639-1 code, or `''` when the id is empty/unknown + */ +export function isoForLanguageId(id) { + return (id && ISO_BY_ID.get(id)) || ''; +} diff --git a/packages/spacecat-shared-project-engine-client/test/e2e/project-engine-mock.e2e.js b/packages/spacecat-shared-project-engine-client/test/e2e/project-engine-mock.e2e.js index d30040973..6543a3376 100644 --- a/packages/spacecat-shared-project-engine-client/test/e2e/project-engine-mock.e2e.js +++ b/packages/spacecat-shared-project-engine-client/test/e2e/project-engine-mock.e2e.js @@ -208,6 +208,46 @@ async function waitForReady(baseUrl, deadline, getStderr) { expect(list.total).to.equal(2); }); + it('round-trips a created + published market: language.name = ISO code, status flips to live (#1745)', async () => { + const ENGLISH_ID = '5a0a33ed-7f5c-4901-befd-a042c0350da1'; // catalog "English" + const { data: created } = await client.POST('/v1/workspaces/{id}/projects', { + params: { path: { id: SEED_WORKSPACE } }, + body: { + name: 'Adobe · US · en', + type: 'ai', + domain: 'adobe.com', + brand_names: ['Adobe'], + brand_name_display: 'Adobe', + language_id: ENGLISH_ID, + country_code: 'us', + location_id: 2840, + location_name: 'United States', + }, + }); + // The created read-view carries the live shapes the consumer's langOf/geoOf reconstruct a + // market from: language.name is the ISO code (NOT the English display name), location echoed. + expect(created.settings.ai.language).to.deep.equal({ id: ENGLISH_ID, name: 'en' }); + expect(created.settings.ai.country).to.deep.equal({ code: 'us', name: 'United States' }); + expect(created.settings.ai.location).to.deep.equal({ id: 2840, name: 'United States' }); + expect(created).to.include({ is_draft: true, publish_status: 'draft' }); + + // Publish moves the stored read-view to live: publish_status flips, published_at is stamped, + // is_draft stays true (matches live — only publish_status/published_at change). + const { response: pubRes } = await client.POST( + '/v1/workspaces/{id}/projects/{project_id}/publish', + { params: { path: { id: SEED_WORKSPACE, project_id: created.id } } }, + ); + expect(pubRes.status).to.equal(202); + + const { data: list } = await client.GET('/v1/workspaces/{id}/projects', { + params: { path: { id: SEED_WORKSPACE } }, + }); + const published = list.items.find((p) => p.id === created.id); + expect(published.settings.ai.language.name).to.equal('en'); + expect(published).to.include({ publish_status: 'live', is_draft: true }); + expect(published.published_at).to.be.a('string').with.length.greaterThan(0); + }); + it('patches a project: name stays top-level, brand fields nest under settings.ai (like live)', async () => { const { data: created } = await client.POST('/v1/workspaces/{id}/projects', { params: { path: { id: SEED_WORKSPACE } }, @@ -246,6 +286,13 @@ async function waitForReady(baseUrl, deadline, getStderr) { expect(error).to.not.equal(undefined); }); + it('404s a DELETE of a non-existent project (live shape; serenity treats 404 as success)', async () => { + const { response } = await client.DELETE('/v1/workspaces/{id}/projects/{project_id}', { + params: { path: { id: SEED_WORKSPACE, project_id: 'ffffffff-0000-4000-8000-000000000000' } }, + }); + expect(response.status).to.equal(404); + }); + // Mirrors the real consumer (spacecat-api-service): add via the v2 route, list via v1. // The v2 POST writes to the same version-agnostic store key, so the v1 GET sees it. it('adds an ai_model via v2 and lists it back via v1', async () => { @@ -300,6 +347,21 @@ async function waitForReady(baseUrl, deadline, getStderr) { expect(listed.items.map((p) => p.name)).to.include.members(['What is X?', 'Tell me Y']); }); + // Live dedups prompts by text: re-creating an existing text yields no new id and existing_count:1 + // (#1745 second sweep). The seed already holds "What is the best running shoe?". + it('dedups a duplicate prompt text into existing_count (no new id)', async () => { + const { data: dup, error } = await client.POST( + '/v2/workspaces/{id}/projects/{project_id}/aio/prompts/tagged', + { + params: { path: { id: SEED_WORKSPACE, project_id: SEED_PROJECT } }, + body: { prompts: { 'What is the best running shoe?': ['brand'], 'A brand new prompt': ['brand'] } }, + }, + ); + expect(error).to.equal(undefined); + expect(dup.ids).to.have.length(1); // only the genuinely-new text is created + expect(dup.existing_count).to.equal(1); // the duplicate text is counted, not re-created + }); + // The consumer's real read path (listPromptsByTags) passes non-empty tag_ids — exercise the // filter branch (OR semantics), not just the list-all branch. `tagged` stores a deterministic // tag id `tag-`, so a prompt tagged 'brand' is found via tag_ids: ['tag-brand']. @@ -448,6 +510,9 @@ async function waitForReady(baseUrl, deadline, getStderr) { expect(data.total).to.equal(38); expect(data.items).to.be.an('array').with.length(38); expect(data.items[0]).to.include.keys(['id', 'name']); + // The mock-only `iso` column (used by the project read-view resolver) is NOT served here — the + // live catalog item is just `{ id, name }`. + expect(data.items[0]).to.not.have.property('iso'); expect(data.items).to.deep.include({ id: '5a0a33ed-7f5c-4901-befd-a042c0350da1', name: 'English' }); }); @@ -607,6 +672,24 @@ async function waitForReady(baseUrl, deadline, getStderr) { expect(after.aio_benchmarks.map((b) => b.id)).to.not.include(created.ids[0]); }); + // Live rejects a duplicate competitor (same brand name / alias / domain) with a hard 409, unlike + // prompts which dedup into existing_count (#1745 second sweep). + it('409s a duplicate benchmark (brand name or domain conflict)', async () => { + const path = { id: SEED_WORKSPACE, project_id: SEED_PROJECT }; + const { error: firstErr } = await client.POST( + '/v2/workspaces/{id}/projects/{project_id}/ai_models/benchmarks', + { params: { path }, body: [{ brand_name: 'Dup Brand', domain: 'dup.example' }] }, + ); + expect(firstErr).to.equal(undefined); + // Same domain again (different brand name) → conflict. + const { response: dupRes, error: dupErr } = await client.POST( + '/v2/workspaces/{id}/projects/{project_id}/ai_models/benchmarks', + { params: { path }, body: [{ brand_name: 'Other Brand', domain: 'dup.example' }] }, + ); + expect(dupRes.status).to.equal(409); + expect(dupErr).to.deep.equal({ message: 'ai benchmark conflict: duplicate brand name or alias' }); + }); + // Mirrors the consumer's updateBenchmark: PUT a brand_aliases re-sync, list reflects it. it('updates a benchmark in place (PUT v1) and the list reflects the change', async () => { // Live ack: 202 with an EMPTY body (verified 2026-06-25) — raw fetch asserts the empty body. diff --git a/packages/spacecat-shared-project-engine-client/test/mock/context.test.js b/packages/spacecat-shared-project-engine-client/test/mock/context.test.js index ae70b0990..76153b4e0 100644 --- a/packages/spacecat-shared-project-engine-client/test/mock/context.test.js +++ b/packages/spacecat-shared-project-engine-client/test/mock/context.test.js @@ -28,6 +28,14 @@ describe('mock Context', () => { expect(ctx.ops.projects.list({ workspaceId })).to.have.length(1); }); + it('exposes the language catalog for the GET /v1/languages route', () => { + const ctx = new Context(); + expect(ctx.languageCatalog).to.be.an('array').with.length(38); + expect(ctx.languageCatalog).to.deep.include({ + id: '5a0a33ed-7f5c-4901-befd-a042c0350da1', name: 'English', iso: 'en', + }); + }); + it('selects a named seed', () => { const ctx = new Context({ seed: 'empty-workspace' }); expect(ctx.seedName).to.equal('empty-workspace'); diff --git a/packages/spacecat-shared-project-engine-client/test/mock/factories.test.js b/packages/spacecat-shared-project-engine-client/test/mock/factories.test.js index c870b4d25..d015a7b5b 100644 --- a/packages/spacecat-shared-project-engine-client/test/mock/factories.test.js +++ b/packages/spacecat-shared-project-engine-client/test/mock/factories.test.js @@ -41,7 +41,7 @@ describe('factories — live-shaped entities', () => { domain: 'adobe.com', brand_names: ['Adobe'], brand_name_display: 'Adobe', - language_id: 'lang-uuid', + language_id: '5a0a33ed-7f5c-4901-befd-a042c0350da1', // catalog "English" country_code: 'us', location_id: 2840, location_name: 'United States', @@ -69,20 +69,42 @@ describe('factories — live-shaped entities', () => { benchmarks_count: 0, products_count: 0, }); - expect(p.settings.ai.language).to.deep.equal({ id: 'lang-uuid', name: '' }); - expect(p.settings.ai.country).to.deep.equal({ code: 'us', name: '' }); + // language.name = ISO code resolved from the catalog id (live read-view shape, #1745), NOT the + // English display name; country.name = the Intl region name resolved from the code. + expect(p.settings.ai.language) + .to.deep.equal({ id: '5a0a33ed-7f5c-4901-befd-a042c0350da1', name: 'en' }); + expect(p.settings.ai.country).to.deep.equal({ code: 'us', name: 'United States' }); expect(p.settings.ai.location).to.deep.equal({ id: 2840, name: 'United States' }); expect(p.settings.ai.models_stats).to.deep.equal({ models: [], models_count: 0 }); }); + it('createProjectResponseFromRequest leaves language/country names empty for an unknown id/code', () => { + // An unknown language_id (not in the catalog) resolves to '' (id is still echoed). A + // structurally invalid country code makes Intl.DisplayNames.of throw — caught → ''. + const p = createProjectResponseFromRequest({ language_id: 'not-a-catalog-id', country_code: 'usa' }); + expect(p.settings.ai.language).to.deep.equal({ id: 'not-a-catalog-id', name: '' }); + expect(p.settings.ai.country).to.deep.equal({ code: 'usa', name: '' }); + }); + + it('createProjectResponseFromRequest resolves a non-en catalog language id (e.g. German → de)', () => { + const p = createProjectResponseFromRequest({ + language_id: 'e5282ae9-83a6-4ea3-b3cf-5e99d8f51eca', // catalog "German" + country_code: 'de', + }); + expect(p.settings.ai.language) + .to.deep.equal({ id: 'e5282ae9-83a6-4ea3-b3cf-5e99d8f51eca', name: 'de' }); + expect(p.settings.ai.country).to.deep.equal({ code: 'de', name: 'Germany' }); + }); + it('createProjectResponseFromRequest falls back to defaults for an empty request', () => { const p = createProjectResponseFromRequest(); expect(p).to.include({ type: 'ai', name: 'Seeded Project', domain: '' }); - expect(p.settings.ai.brand_names).to.deep.equal([]); + // Live echoes null (not [] / '') for an omitted brand_names / location_name (2026-06-29). + expect(p.settings.ai.brand_names).to.equal(null); expect(p.settings.ai.brand_name_display).to.equal(''); expect(p.settings.ai.language).to.deep.equal({ id: '', name: '' }); expect(p.settings.ai.country).to.deep.equal({ code: '', name: '' }); - expect(p.settings.ai.location).to.deep.equal({ id: 0, name: '' }); + expect(p.settings.ai.location).to.deep.equal({ id: 0, name: null }); expect(p.settings.ai.primary_url).to.equal(''); }); diff --git a/packages/spacecat-shared-project-engine-client/test/mock/language-catalog.test.js b/packages/spacecat-shared-project-engine-client/test/mock/language-catalog.test.js new file mode 100644 index 000000000..f817ba514 --- /dev/null +++ b/packages/spacecat-shared-project-engine-client/test/mock/language-catalog.test.js @@ -0,0 +1,47 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { expect } from 'chai'; +import { LANGUAGE_CATALOG, isoForLanguageId } from '../../mock/language-catalog.js'; + +const ENGLISH_ID = '5a0a33ed-7f5c-4901-befd-a042c0350da1'; + +describe('language-catalog — the canonical id ↔ name ↔ iso catalog', () => { + it('is the full live taxonomy (38 entries), each with id + English name + iso', () => { + expect(LANGUAGE_CATALOG).to.be.an('array').with.length(38); + for (const entry of LANGUAGE_CATALOG) { + expect(entry.id).to.match(/^[0-9a-f-]{36}$/); + expect(entry.name).to.be.a('string').with.length.greaterThan(0); + expect(entry.iso).to.be.a('string').with.length.greaterThan(0); + } + // English is the live-verified read-view value. + expect(LANGUAGE_CATALOG).to.deep.include({ id: ENGLISH_ID, name: 'English', iso: 'en' }); + }); + + it('ids are unique (a faithful reverse index needs no collisions)', () => { + const ids = LANGUAGE_CATALOG.map((l) => l.id); + expect(new Set(ids).size).to.equal(ids.length); + }); + + it('isoForLanguageId resolves a known catalog id to its ISO code', () => { + expect(isoForLanguageId(ENGLISH_ID)).to.equal('en'); + }); + + it('isoForLanguageId returns "" for an unknown id', () => { + expect(isoForLanguageId('not-a-catalog-id')).to.equal(''); + }); + + it('isoForLanguageId returns "" for an empty/undefined id', () => { + expect(isoForLanguageId('')).to.equal(''); + expect(isoForLanguageId()).to.equal(''); + }); +}); From 64002641cdd61685a055970cd0a553f1f68ce62c Mon Sep 17 00:00:00 2001 From: Rainer Friederich Date: Mon, 29 Jun 2026 12:41:51 +0200 Subject: [PATCH 2/5] fix(user-manager-client): live-pin over-alloc 422, 403 family on unknown ws (#1745) Second-angle live sweep (2026-06-29) fidelity items for the User Manager mock, complementary to the project-engine round-trip fixes: - correct the pool-exhaustion 422 to the live string "insufficient available units in subscription" (child + transfer handlers) - GET family on an unknown workspace now 403s, mirroring GET status (was 200 []) - document the no-ownership / presence-only-bearer model, the resources-not-actually -required-on-create divergence, the stuck-not-ready / 422-on-delete zombie window, and the spurious mock-only role field as intentional simplifications All mock-fidelity / doc items; no production-code change. Unit 100% coverage, types + lint clean, e2e (14) green incl. the new family-403 case. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../docs/mock-statefulness.md | 46 +++++++++++++++++-- .../docs/mock-usage.md | 9 ++-- .../routes/v1/workspaces/{id}/family.js | 16 +++++-- .../routes/v2/workspaces/{id}/child.js | 8 ++-- .../v2/workspaces/{id}/resources/transfer.js | 8 ++-- .../test/e2e/user-manager-mock.e2e.js | 12 ++++- 6 files changed, 79 insertions(+), 20 deletions(-) diff --git a/packages/spacecat-shared-user-manager-client/docs/mock-statefulness.md b/packages/spacecat-shared-user-manager-client/docs/mock-statefulness.md index 2d6a17acd..1114c487e 100644 --- a/packages/spacecat-shared-user-manager-client/docs/mock-statefulness.md +++ b/packages/spacecat-shared-user-manager-client/docs/mock-statefulness.md @@ -68,6 +68,24 @@ activate → re-activate (re-grant) → deactivate lifecycle behaves identically unit-level net-zero on the parent pool (`…/resources` `ai.prompts`/`projects` byte-identical before/after). +**Re-replay 2026-06-29 (spacecat-shared#1745 sweep)** — three refinements to the above, all live +observations the deterministic mock intentionally collapses: + +- **`POST .../child` → HTTP 200** returning the child with **`status: "not ready"`**, settling to + `created` in ~3 s (the mock returns `created` at once; reproduce the window via the `__status` + budget — see "Known fidelity simplifications"). +- **Delete is eventually consistent with a transient `deleting` window.** `DELETE .../{id}` → + `200 { id }`; immediately after, `GET .../status` → **`200 { "status": "deleting" }`** (a status + value outside the documented `not ready | created | error` enum) and the child is **still in + `family`**; within ~5 s it settles to the `403 { "message": "invalid access attempt" }` the mock + returns at once. The transient `deleting`/200 window is unmodelled (the mock jumps straight to the + settled `403`). +- **Live child envelope is richer than the factory** (confirms "Lean workspace envelope" below): + live also returns `icon`, `created_at`, `last_updated_at`, `keywords_count`, + `published_projects_count`, `pagespeed_urls_count`, `is_admin`, `users`, `is_master`, + `subscription_tier`, `products`, `product_tiers`, `parent`, `partnership_enabled` — and notably + does **NOT** return `role` (see the `role`-drift note below). + ## Known fidelity simplifications - **Child boots `created` immediately.** Live `createSubworkspace` returns the child as **`not @@ -87,10 +105,30 @@ before/after). populates (`icon`, `is_master`, `subscription_tier`, `product_tiers`, `parent`, counts). The consumer reads only `id` / `title` / `status` (required via overlay CR3), so the factory is a faithful-shape stand-in; all extra fields are optional in the schema. -- **Pool-exhaustion 422 message is inferred.** The 422 envelope (`{ message }`) is live-pinned, but - the exact "insufficient available units" string was not triggered live (it would require - over-drawing a real pool); only the "workspace not ready" 422 variant was observed. The status - (422) is the contract the consumer keys off. +- **`role` is a mock-only field (live drift).** `createWorkspaceMock` populates `role: 'owner'`, but + the live child envelope does **not** carry `role` at all (confirmed 2026-06-29). It is a valid + optional schema field and the consumer never reads it, so this is harmless fidelity drift — left + as-is rather than risking a fixture/overlay change for a field nothing depends on. +- **Pool-exhaustion 422 message — now live-pinned.** The over-allocation 422 string was triggered + live on 2026-06-29 and is **`{ message: "insufficient available units in subscription" }`** — the + mock (`child.js` / `transfer.js`) now returns exactly this (previously the inferred + `"insufficient available units"`). The other live 422 variant, `{ message: "workspace not ready" }` + (transfer/delete racing a still-settling child), remains unmodelled — see the lock bullet above. +- **No tenant / ownership model; presence-only bearer.** `GET status` and `GET family` 403 an + **unknown** workspace id (the mock has no descendants for it), but the mock otherwise serves any id + it does know without an ownership check, and the bearer gate is **presence-only** — a garbage + `Bearer xxx` passes (live `401`s it). Deliberate: the consumer always uses a real IMS token on + workspaces it owns. (`GET family` on an unknown id was aligned to `status`'s 403 on 2026-06-29 — + it previously returned `200 []`.) +- **`resources` is treated as required on child create; live tolerates its absence.** The mock reads + `body.resources.ai` to meter the draw, so an omitted `resources` allocates nothing; live (2026-06-29) + **200**s a child create with no `resources`, creating the child on the default tier. The consumer + always sends `resources`, so this is fidelity-only. +- **Stuck-`not ready` zombies are not modelled.** Live, a child created against an exhausted/contended + parent pool can stay `not ready` for hours and is **un-deletable** (`DELETE` → `422 { "workspace + not ready" }`); the finite parent pool means rapid raw child-creates can strand zombie workspaces. + Serenity avoids this by sizing the allocation to the market count and polling `status`→`created` + before any transfer/delete. The mock's immediate-`created` hides this entirely (by design). (2026-06-29) ## Replicating live async behaviour (don't use timers) diff --git a/packages/spacecat-shared-user-manager-client/docs/mock-usage.md b/packages/spacecat-shared-user-manager-client/docs/mock-usage.md index 77011e0d9..26bb4563b 100644 --- a/packages/spacecat-shared-user-manager-client/docs/mock-usage.md +++ b/packages/spacecat-shared-user-manager-client/docs/mock-usage.md @@ -87,12 +87,13 @@ to model exhaustion: ```bash curl -s -XPOST localhost:4010/enterprise/users/api/__quota \ -H 'content-type: application/json' -d '{"workspaceId":"","projects":0,"prompts":0}' -# now a createSubworkspace under → 422 { "message": "insufficient available units" } +# now a createSubworkspace under → 422 { "message": "insufficient available units in subscription" } ``` -The 422 **envelope** (`{ message }`) is live-confirmed (2026-06-26). Live also returns a second 422 -variant — `{ "message": "workspace not ready" }` — when a transfer/delete races a still-settling -child; the mock is immediately-consistent and does not model that lock (see mock-statefulness.md). +The 422 string is live-pinned (2026-06-29): `{ "message": "insufficient available units in +subscription" }`. Live also returns a second 422 variant — `{ "message": "workspace not ready" }` — +when a transfer/delete races a still-settling child; the mock is immediately-consistent and does not +model that lock (see mock-statefulness.md). ## 7. Driving the mock from tests / an agent diff --git a/packages/spacecat-shared-user-manager-client/mock/counterfact/routes/v1/workspaces/{id}/family.js b/packages/spacecat-shared-user-manager-client/mock/counterfact/routes/v1/workspaces/{id}/family.js index 37f1b26a2..a14bdd18d 100644 --- a/packages/spacecat-shared-user-manager-client/mock/counterfact/routes/v1/workspaces/{id}/family.js +++ b/packages/spacecat-shared-user-manager-client/mock/counterfact/routes/v1/workspaces/{id}/family.js @@ -14,13 +14,21 @@ * Stateful handler for GET /v1/workspaces/{id}/family — the consumer's `listWorkspaceFamily`, * returning the workspace `{id}` plus every descendant as a TOP-LEVEL array (the live shape; the * consumer reads the bare array, not an `{ items }` envelope). Used for ambiguous-create recovery - * (match a child by title) and the linked-sub-workspace guard. An unknown `{id}` yields an empty - * array (no descendants). Materialized into `.counterfact/routes/` by the mock runner; excluded - * from coverage. + * (match a child by title) and the linked-sub-workspace guard. An unknown `{id}` is a 403 "invalid + * access attempt" — live 403s `family` on a workspace that doesn't exist / you don't own (verified + * 2026-06-29), mirroring `status`. Materialized into `.counterfact/routes/` by the mock runner; + * excluded from coverage. */ -/** GET — the workspace's family (itself + descendants) as a top-level array. */ +/** GET — the workspace's family (itself + descendants) as a top-level array; 403 if unknown. */ export function GET($) { const { path, context } = $; + if (!context.ops.workspaces.exists(path.id)) { + return { + status: 403, + body: context.factories.createBasicResponseMock({ message: 'invalid access attempt' }), + contentType: 'application/json', + }; + } return $.response[200].json(context.ops.workspaces.listFamily(path.id)); } diff --git a/packages/spacecat-shared-user-manager-client/mock/counterfact/routes/v2/workspaces/{id}/child.js b/packages/spacecat-shared-user-manager-client/mock/counterfact/routes/v2/workspaces/{id}/child.js index be52f5e1a..45fd1d04f 100644 --- a/packages/spacecat-shared-user-manager-client/mock/counterfact/routes/v2/workspaces/{id}/child.js +++ b/packages/spacecat-shared-user-manager-client/mock/counterfact/routes/v2/workspaces/{id}/child.js @@ -16,8 +16,8 @@ * `resources` carries the `{ ai: { projects, prompts } }` allocation carved off the parent's pool. * An unknown parent is a 403 "invalid access attempt" (mirrors status/transfer/delete), never a * silent orphan create. Otherwise metered: when the parent's pool can't cover the allocation it - * returns the 422 "insufficient available units" (see mock/quota.js); else it draws the units, - * creates a `created` child linked to the parent, and returns the new workspace. Materialized into + * returns the 422 "insufficient available units in subscription" (see mock/quota.js); else it draws + * the units, creates a `created` child linked to the parent, returns it. Materialized into * `.counterfact/routes/` by the mock runner; excluded from coverage. */ @@ -37,7 +37,9 @@ export function POST($) { if (!context.quota.canAllocate(path.id, ai)) { return { status: 422, - body: context.factories.createBasicResponseMock({ message: 'insufficient available units' }), + body: context.factories.createBasicResponseMock({ + message: 'insufficient available units in subscription', + }), contentType: 'application/json', }; } diff --git a/packages/spacecat-shared-user-manager-client/mock/counterfact/routes/v2/workspaces/{id}/resources/transfer.js b/packages/spacecat-shared-user-manager-client/mock/counterfact/routes/v2/workspaces/{id}/resources/transfer.js index 27ba03e0d..d084c7a8e 100644 --- a/packages/spacecat-shared-user-manager-client/mock/counterfact/routes/v2/workspaces/{id}/resources/transfer.js +++ b/packages/spacecat-shared-user-manager-client/mock/counterfact/routes/v2/workspaces/{id}/resources/transfer.js @@ -14,8 +14,8 @@ * Stateful handler for POST /v2/workspaces/{id}/resources/transfer — the consumer's * `transferWorkspaceResources` (grant/re-grant or release an allocation onto the workspace `{id}`). * Body is `{ resources: { ai: { projects, prompts } } }`. The allocation draws from the workspace's - * PARENT pool: when the parent can't cover it, 422 "insufficient available units" (see - * mock/quota.js); otherwise the units are drawn and the updated child `workspaceResponse` is + * PARENT pool: when the parent can't cover it, 422 "insufficient available units in subscription" + * (see mock/quota.js); otherwise the units are drawn and the updated child `workspaceResponse` is * returned — overlay CR4: live returns the workspace, NOT the spec's `WorkspaceResourcesV2` * (verified live 2026-06-26). The consumer only needs the 200. A missing workspace is a 403. * Materialized into `.counterfact/routes/` by the mock runner; excluded from coverage. @@ -37,7 +37,9 @@ export function POST($) { if (!context.quota.canAllocate(parentId, ai)) { return { status: 422, - body: context.factories.createBasicResponseMock({ message: 'insufficient available units' }), + body: context.factories.createBasicResponseMock({ + message: 'insufficient available units in subscription', + }), contentType: 'application/json', }; } diff --git a/packages/spacecat-shared-user-manager-client/test/e2e/user-manager-mock.e2e.js b/packages/spacecat-shared-user-manager-client/test/e2e/user-manager-mock.e2e.js index 929219fb2..96b059d9d 100644 --- a/packages/spacecat-shared-user-manager-client/test/e2e/user-manager-mock.e2e.js +++ b/packages/spacecat-shared-user-manager-client/test/e2e/user-manager-mock.e2e.js @@ -256,7 +256,7 @@ async function waitForReady(apiBase, deadline, getStderr) { body: { title: 'Second [e2e00005]', resources: { ai: { projects: 1, prompts: 500 } } }, }); expect(second.response.status).to.equal(422); - expect(second.error).to.deep.equal({ message: 'insufficient available units' }); + expect(second.error).to.deep.equal({ message: 'insufficient available units in subscription' }); }); it('rejects an unauthenticated request with 401 { detail }', async () => { @@ -277,7 +277,15 @@ async function waitForReady(apiBase, deadline, getStderr) { }); expect(response.status).to.equal(422); // Pin the live 422 envelope string (the handler is coverage-excluded — assert it here). - expect(error).to.deep.equal({ message: 'insufficient available units' }); + expect(error).to.deep.equal({ message: 'insufficient available units in subscription' }); + }); + + it('403s GET family on an unknown workspace (mirrors status — no ownership of an unseeded id)', async () => { + const { error, response } = await client.GET('/v1/workspaces/{id}/family', { + params: { path: { id: '00000000-0000-4000-8000-000000000000' } }, + }); + expect(response.status).to.equal(403); + expect(error).to.deep.equal({ message: 'invalid access attempt' }); }); it('404s an unmodelled path (serve-only — no random stubs)', async () => { From dcded4b485639d26c4649af140b8ab3b89acb2b4 Mon Sep 17 00:00:00 2001 From: Rainer Friederich Date: Mon, 29 Jun 2026 13:01:41 +0200 Subject: [PATCH 3/5] fix(project-engine-client): address MysticatBot review on #1746 - benchmark create now detects intra-batch conflicts (fold each entry's identity tokens into the seen-set while iterating), not just collisions with stored rows - identityTokens guards against a non-array brand_aliases (spread of a string would iterate characters into spurious tokens) - e2e: cover the brand_aliases cross-collision branch + an intra-batch duplicate; assert published_at is a valid ISO 8601 timestamp (round-trips through Date) - nullable read-view helper is now a typed constant instead of a function call - language-catalog: export a CATALOG_CAPTURED datestamp so live-catalog drift is discoverable; assert its shape in the catalog test Co-Authored-By: Claude Opus 4.8 (1M context) --- .../docs/mock-statefulness.md | 3 +- .../{project_id}/ai_models/benchmarks.js | 19 ++++++++--- .../mock/factories.js | 8 ++--- .../mock/language-catalog.js | 7 ++++ .../test/e2e/project-engine-mock.e2e.js | 33 +++++++++++++++++++ .../test/mock/language-catalog.test.js | 6 +++- 6 files changed, 66 insertions(+), 10 deletions(-) diff --git a/packages/spacecat-shared-project-engine-client/docs/mock-statefulness.md b/packages/spacecat-shared-project-engine-client/docs/mock-statefulness.md index 959f539ea..94302e6c0 100644 --- a/packages/spacecat-shared-project-engine-client/docs/mock-statefulness.md +++ b/packages/spacecat-shared-project-engine-client/docs/mock-statefulness.md @@ -122,7 +122,8 @@ change, the live proof. gets no new id (live, 2026-06-29). `POST .../ai_models/benchmarks` instead treats a duplicate brand name / alias / domain as a **hard `409`** `{ message: "ai benchmark conflict: duplicate brand name or alias" }` (benchmarks and prompts dedup *differently* live) and creates nothing for - the batch. Not modelled: **slice-uniqueness** (two US/en projects both succeed with distinct ids — + the batch — including two conflicting entries **within the same batch**, not just against + already-stored rows. Not modelled: **slice-uniqueness** (two US/en projects both succeed with distinct ids — PE does not enforce it; that invariant lives only in serenity's `findBySlice`/DB), matching live. ## Project read-view fidelity (#1745, live-pinned 2026-06-29) diff --git a/packages/spacecat-shared-project-engine-client/mock/counterfact/routes/v2/workspaces/{id}/projects/{project_id}/ai_models/benchmarks.js b/packages/spacecat-shared-project-engine-client/mock/counterfact/routes/v2/workspaces/{id}/projects/{project_id}/ai_models/benchmarks.js index 8e75f1573..7da47d1e1 100644 --- a/packages/spacecat-shared-project-engine-client/mock/counterfact/routes/v2/workspaces/{id}/projects/{project_id}/ai_models/benchmarks.js +++ b/packages/spacecat-shared-project-engine-client/mock/counterfact/routes/v2/workspaces/{id}/projects/{project_id}/ai_models/benchmarks.js @@ -24,9 +24,13 @@ */ /** Case-insensitive identity tokens (brand name + aliases + domain) a benchmark conflicts on. */ -const identityTokens = (b) => [b?.brand_name, b?.domain, ...(b?.brand_aliases ?? [])] - .filter((t) => typeof t === 'string' && t.length > 0) - .map((t) => t.toLowerCase()); +const identityTokens = (b) => [ + b?.brand_name, + b?.domain, + // Guard the spread: a raw request entry's `brand_aliases` may not be an array (spreading a + // string would iterate its characters into spurious tokens). + ...(Array.isArray(b?.brand_aliases) ? b.brand_aliases : []), +].filter((t) => typeof t === 'string' && t.length > 0).map((t) => t.toLowerCase()); /** POST — batch-create benchmarks (body: array) → 200 { ids, existing_count }; 409 on conflict. */ export function POST($) { @@ -36,8 +40,15 @@ export function POST($) { // Reject if any incoming benchmark collides (brand name / alias / domain, case-insensitive) with // one already in the project — the live hard-409, not a silent duplicate (#1745 second sweep). + // Each entry's tokens are folded into `taken` as we iterate, so two conflicting entries in the + // SAME batch collide too, not just against already-stored benchmarks. const taken = new Set(context.ops.benchmarks.list(scope).flatMap(identityTokens)); - const conflicts = entries.some((b) => identityTokens(b).some((t) => taken.has(t))); + const conflicts = entries.some((b) => { + const tokens = identityTokens(b); + const hit = tokens.some((t) => taken.has(t)); + tokens.forEach((t) => taken.add(t)); + return hit; + }); if (conflicts) { return { status: 409, diff --git a/packages/spacecat-shared-project-engine-client/mock/factories.js b/packages/spacecat-shared-project-engine-client/mock/factories.js index daf195958..6c3b3c0c8 100644 --- a/packages/spacecat-shared-project-engine-client/mock/factories.js +++ b/packages/spacecat-shared-project-engine-client/mock/factories.js @@ -52,9 +52,9 @@ const REGION_NAMES = new Intl.DisplayNames(['en'], { type: 'region' }); * request omitted them (`brand_names`, `location.name`). The generated schema types those as * `string[]`/`string` with no null variant, so this localizes the one unavoidable cast to a single * documented spot rather than scattering `@ts-ignore`s across the literal. - * @returns {any} + * @type {any} */ -const nullable = () => null; +const NULLABLE = null; /** * Resolves a 2-letter country code to the informal country name the live project read-view returns @@ -165,7 +165,7 @@ export const createProjectResponseFromRequest = (request = {}) => { // Live echoes `null` (not `[]`/`''`) for an omitted brand_names / location_name on the // read-view (verified 2026-06-29). The consumer always sends these, so it is fidelity-only; // the casts satisfy the schema (string[]/string), which has no explicit null variant. - brand_names: request.brand_names ?? nullable(), + brand_names: request.brand_names ?? NULLABLE, brand_name_display: request.brand_name_display ?? '', // Live read-view: `language.name` is the ISO code (e.g. "en"), NOT the English display // name, and `language.id` is the catalog UUID sent on create. `langOf` reads @@ -174,7 +174,7 @@ export const createProjectResponseFromRequest = (request = {}) => { // populated for fidelity. language: { id: request.language_id ?? '', name: isoForLanguageId(request.language_id) }, country: { code: request.country_code ?? '', name: countryName(request.country_code) }, - location: { id: request.location_id ?? 0, name: request.location_name ?? nullable() }, + location: { id: request.location_id ?? 0, name: request.location_name ?? NULLABLE }, primary_url: request.domain ?? '', segments_count: 0, benchmarks_count: 0, diff --git a/packages/spacecat-shared-project-engine-client/mock/language-catalog.js b/packages/spacecat-shared-project-engine-client/mock/language-catalog.js index 2c4321f4e..edb0092cb 100644 --- a/packages/spacecat-shared-project-engine-client/mock/language-catalog.js +++ b/packages/spacecat-shared-project-engine-client/mock/language-catalog.js @@ -37,6 +37,13 @@ * `name` are all live-captured. */ +/** + * The date the catalog (`id`/`name`) was captured verbatim from the live Semrush gateway, so drift + * from the live taxonomy is discoverable/greppable rather than buried in prose. Bump it when the + * catalog is re-captured. + */ +export const CATALOG_CAPTURED = '2026-06-25'; + /** * @typedef {{ id: string, name: string, iso: string }} LanguageCatalogEntry */ diff --git a/packages/spacecat-shared-project-engine-client/test/e2e/project-engine-mock.e2e.js b/packages/spacecat-shared-project-engine-client/test/e2e/project-engine-mock.e2e.js index 6543a3376..c529375cb 100644 --- a/packages/spacecat-shared-project-engine-client/test/e2e/project-engine-mock.e2e.js +++ b/packages/spacecat-shared-project-engine-client/test/e2e/project-engine-mock.e2e.js @@ -246,6 +246,8 @@ async function waitForReady(baseUrl, deadline, getStderr) { expect(published.settings.ai.language.name).to.equal('en'); expect(published).to.include({ publish_status: 'live', is_draft: true }); expect(published.published_at).to.be.a('string').with.length.greaterThan(0); + // published_at is a valid ISO 8601 timestamp (round-trips through Date). + expect(new Date(published.published_at).toISOString()).to.equal(published.published_at); }); it('patches a project: name stays top-level, brand fields nest under settings.ai (like live)', async () => { @@ -690,6 +692,37 @@ async function waitForReady(baseUrl, deadline, getStderr) { expect(dupErr).to.deep.equal({ message: 'ai benchmark conflict: duplicate brand name or alias' }); }); + // The conflict set includes brand_aliases (case-insensitive); a later brand_name that matches an + // existing alias must 409 too — exercises the alias spread/lowercase branch. + it('409s a benchmark whose brand_name collides with an existing brand alias', async () => { + const path = { id: SEED_WORKSPACE, project_id: SEED_PROJECT }; + const { error: firstErr } = await client.POST( + '/v2/workspaces/{id}/projects/{project_id}/ai_models/benchmarks', + { params: { path }, body: [{ brand_name: 'Aliased', domain: 'aliased.example', brand_aliases: ['MyAlias'] }] }, + ); + expect(firstErr).to.equal(undefined); + const { response: dupRes } = await client.POST( + '/v2/workspaces/{id}/projects/{project_id}/ai_models/benchmarks', + { params: { path }, body: [{ brand_name: 'myalias', domain: 'other.example' }] }, + ); + expect(dupRes.status).to.equal(409); + }); + + // Two conflicting entries in ONE batch collide against each other (not just against stored rows). + it('409s an intra-batch duplicate (two conflicting entries in one POST)', async () => { + const { response } = await client.POST( + '/v2/workspaces/{id}/projects/{project_id}/ai_models/benchmarks', + { + params: { path: { id: SEED_WORKSPACE, project_id: SEED_PROJECT } }, + body: [ + { brand_name: 'Batch X', domain: 'batch-a.example' }, + { brand_name: 'Batch X', domain: 'batch-b.example' }, + ], + }, + ); + expect(response.status).to.equal(409); + }); + // Mirrors the consumer's updateBenchmark: PUT a brand_aliases re-sync, list reflects it. it('updates a benchmark in place (PUT v1) and the list reflects the change', async () => { // Live ack: 202 with an EMPTY body (verified 2026-06-25) — raw fetch asserts the empty body. diff --git a/packages/spacecat-shared-project-engine-client/test/mock/language-catalog.test.js b/packages/spacecat-shared-project-engine-client/test/mock/language-catalog.test.js index f817ba514..92c8c82d7 100644 --- a/packages/spacecat-shared-project-engine-client/test/mock/language-catalog.test.js +++ b/packages/spacecat-shared-project-engine-client/test/mock/language-catalog.test.js @@ -11,7 +11,7 @@ */ import { expect } from 'chai'; -import { LANGUAGE_CATALOG, isoForLanguageId } from '../../mock/language-catalog.js'; +import { LANGUAGE_CATALOG, isoForLanguageId, CATALOG_CAPTURED } from '../../mock/language-catalog.js'; const ENGLISH_ID = '5a0a33ed-7f5c-4901-befd-a042c0350da1'; @@ -32,6 +32,10 @@ describe('language-catalog — the canonical id ↔ name ↔ iso catalog', () => expect(new Set(ids).size).to.equal(ids.length); }); + it('exposes the capture date so live-catalog drift is discoverable', () => { + expect(CATALOG_CAPTURED).to.match(/^\d{4}-\d{2}-\d{2}$/); + }); + it('isoForLanguageId resolves a known catalog id to its ISO code', () => { expect(isoForLanguageId(ENGLISH_ID)).to.equal('en'); }); From 8d582bc841bb8125d22ff6be77e2664a18890c3c Mon Sep 17 00:00:00 2001 From: Rainer Friederich Date: Mon, 29 Jun 2026 13:15:30 +0200 Subject: [PATCH 4/5] test(project-engine-client): pin intra-batch domain collision (MysticatBot nit on #1745) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an e2e that POSTs two benchmarks with distinct brand names but a shared domain in one batch and asserts 409 — pins that the intra-batch conflict check is generic across token types (domain, not just brand_name), per MysticatBot's non-blocking suggestion on the approved review. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test/e2e/project-engine-mock.e2e.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/spacecat-shared-project-engine-client/test/e2e/project-engine-mock.e2e.js b/packages/spacecat-shared-project-engine-client/test/e2e/project-engine-mock.e2e.js index c529375cb..46502246d 100644 --- a/packages/spacecat-shared-project-engine-client/test/e2e/project-engine-mock.e2e.js +++ b/packages/spacecat-shared-project-engine-client/test/e2e/project-engine-mock.e2e.js @@ -723,6 +723,22 @@ async function waitForReady(baseUrl, deadline, getStderr) { expect(response.status).to.equal(409); }); + // The intra-batch check is generic across token types — a shared DOMAIN (distinct brand names) + // within a single batch must 409 too, not just a shared brand name. + it('409s an intra-batch domain collision (distinct names, same domain in one POST)', async () => { + const { response } = await client.POST( + '/v2/workspaces/{id}/projects/{project_id}/ai_models/benchmarks', + { + params: { path: { id: SEED_WORKSPACE, project_id: SEED_PROJECT } }, + body: [ + { brand_name: 'Batch Y', domain: 'batch-dup.example' }, + { brand_name: 'Batch Z', domain: 'batch-dup.example' }, + ], + }, + ); + expect(response.status).to.equal(409); + }); + // Mirrors the consumer's updateBenchmark: PUT a brand_aliases re-sync, list reflects it. it('updates a benchmark in place (PUT v1) and the list reflects the change', async () => { // Live ack: 202 with an EMPTY body (verified 2026-06-25) — raw fetch asserts the empty body. From b3818f5ad36618fb73cf4411dc29fac8d6406c12 Mon Sep 17 00:00:00 2001 From: Rainer Friederich Date: Mon, 29 Jun 2026 14:08:33 +0200 Subject: [PATCH 5/5] test(project-engine-client): pin typeless-PATCH 400 (omit-type bug not hidden, #1745) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The completeness sweep on #1745 flagged that serenity's updateProject omits the required `type`, which 400s against live Semrush. The vendored spec already marks ProjectUpdateRequest.type required and the mock enforces it at Counterfact's request-validation seam, so the mock does NOT hide the bug — but there was no test proving it. Add an e2e asserting a typeless PATCH 400s, so a future overlay/handler change that dropped the requirement would be caught (and the serenity bug surfaces in the IT rather than passing silently). No overlay/spec change needed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test/e2e/project-engine-mock.e2e.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/spacecat-shared-project-engine-client/test/e2e/project-engine-mock.e2e.js b/packages/spacecat-shared-project-engine-client/test/e2e/project-engine-mock.e2e.js index 46502246d..3b6f488de 100644 --- a/packages/spacecat-shared-project-engine-client/test/e2e/project-engine-mock.e2e.js +++ b/packages/spacecat-shared-project-engine-client/test/e2e/project-engine-mock.e2e.js @@ -273,6 +273,23 @@ async function waitForReady(baseUrl, deadline, getStderr) { expect(patched).to.not.have.property('brand_names'); }); + // Live `PATCH .../projects/{id}` REQUIRES `type` — a typeless body 400s (Go-validator, + // confirmed live 2026-06-29, issue #1745). The vendored spec already marks + // ProjectUpdateRequest.type required, so Counterfact rejects it at the request-validation seam. + // This pins that the mock does NOT hide the serenity bug where updateProject omits `type` + // (spacecat-api-service brand-aliases.js) — the IT will surface it. + it('400s a PATCH that omits the required `type` (does not hide the serenity omit-type bug)', async () => { + const { data: created } = await client.POST('/v1/workspaces/{id}/projects', { + params: { path: { id: SEED_WORKSPACE } }, + body: { name: 'NeedsType', type: 'ai' }, + }); + const { response } = await client.PATCH('/v1/workspaces/{id}/projects/{project_id}', { + params: { path: { id: SEED_WORKSPACE, project_id: created.id } }, + body: { brand_names: ['Acme'] }, + }); + expect(response.status).to.equal(400); + }); + it('deletes a project (404 on subsequent read)', async () => { const { data: created } = await client.POST('/v1/workspaces/{id}/projects', { params: { path: { id: SEED_WORKSPACE } },