Skip to content

Commit d3d823c

Browse files
fix(project-engine-client, user-manager-client): round-trip created+published market + live-fidelity sweep (#1745) (#1746)
<!-- mysticat-pr-skill --> ## 1. Abstract Raises the fidelity of the Project Engine and User Manager Counterfact mocks (`spacecat-shared-project-engine-client`, `spacecat-shared-user-manager-client`) so a created+published market round-trips, plus the complementary live-vs-mock divergences a second sweep surfaced. Mock + documentation only — no production-code change. ## 2. Reasoning With the publish 406 fixed in 1.3.1 (#1742), `POST markets` / `activate` reach a published `201` against the PE mock, but a just-created market did not round-trip: it never appeared in `GET markets`, `GET markets/{geo}/{lang}` 404'd, and a prompt against that slice was skipped. Root cause: the mock's created-project read-view returned an empty `settings.ai.language.name`, so the consumer's `langOf` resolved `null` and `listMarkets` dropped the project; and `publish` never flipped the stored `publish_status`. A subsequent live-fidelity sweep against the real Semrush gateway (captured in issue 1745) surfaced a set of complementary mock-vs-live divergences worth closing while here. None is a production bug. ## 3. High-level overview of the changes Project Engine mock: - The created read-view now resolves `settings.ai.language.name` to the ISO code the live API returns there (e.g. `en`) — not the empty string, and not the English display name — via a new shared `mock/language-catalog.js` (canonical `id` ↔ display-name ↔ `iso` catalog). The `GET /v1/languages` route now reads that catalog through the request context instead of carrying its own duplicate copy. `country.name` is resolved from the country code via `Intl.DisplayNames`. - `publish` now moves the stored project to the live state: `publish_status` flips `draft` → `live` and `published_at` is stamped, while `is_draft` is left unchanged (matching live, which keeps it `true` post-publish). A later list/get therefore reports the published status. - Net behaviour: create → publish → `GET markets` now lists the market with `languageCode` and a `live` status, `GET markets/{geo}/{lang}` resolves, and a prompt attaches to the slice. - Second-sweep fidelity (live-pinned 2026-06-29): the read-view echoes `null` (not `[]`/`''`) for an omitted `brand_names` / `location_name`; prompt creation dedups by text into `existing_count`; benchmark creation returns a hard `409` on a duplicate brand name / alias / domain — including two conflicting entries within the same batch; deleting a non-existent project returns `404` (the consumer treats a delete-404 as success). User Manager mock: - The pool-exhaustion `422` now returns the live string `insufficient available units in subscription`. - `GET family` on an unknown workspace now returns `403`, mirroring `GET status` (previously `200 []`). Both packages' `docs/mock-statefulness.md` now record the intentional simplifications the sweep confirmed (no tenant/ownership model and presence-only bearer; the two-layer live error model and the unguarded 500s the mock does not reproduce; pagination asymmetry across catalogs; empty `brand-topics` prompts; the UM stuck-`not ready` / 422-on-delete zombie window and the spurious mock-only `role` field). ## 4. Required information - Jira / issue: #1745 ## 5. Affected / used mysticat-workspace projects - `spacecat-api-service` — consumed (contract). Its serenity API-level integration suite boots these two mock images; this PR makes the created-market read-back and prompt-lifecycle assertions there pass. No code change required in that repo for the mock behaviour, but it picks up the corrected images once these packages release. ## 6. Additional information outside the code - Drove the booted PE and UM mocks end-to-end through the serenity-relevant lifecycle (create → publish → list round-trip; prompt-text dedup; duplicate-benchmark `409`; non-existent-project delete `404`; UM over-allocation `422` string; UM `family` on an unknown workspace `403`) and confirmed the read-back shapes match the live shapes captured in the issue. The live shapes themselves were captured against the real Semrush prod gateway in the sweeps recorded on issue 1745. ## 7. Test plan - (a) Local: ran each package's mock end-to-end via its e2e harness (which boots the Counterfact server) and exercised the round-trip, dedup, conflict, delete-404, and the UM string/403 paths described above, plus the existing lifecycle suites, all against the booted mock. - (b) Per-environment: not applicable — these packages ship only the mock under `mock/` (used by local dev and the cross-repo e2e harness) and the typed client `src/`; there is no service deployment. Downstream verification happens in `spacecat-api-service`'s serenity IT once the corrected mock images are consumed. ## 8. Deployment & merge order - Related: #1742 (publish 406, fixed in 1.3.1) — already merged; this PR builds on it. - Related: adobe/spacecat-api-service PR 2709 — consumes the corrected PE mock image in the serenity IT; it can drop its documented round-trip caveats once these packages release and its mock image tag picks them up. No hard merge ordering within this repo; independent to merge. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e87371a commit d3d823c

19 files changed

Lines changed: 639 additions & 89 deletions

File tree

packages/spacecat-shared-project-engine-client/docs/mock-statefulness.md

Lines changed: 86 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,31 @@ change, the live proof.
7777
relies on it — those cases provision an allocation and write prompts/publish under freshly
7878
minted, never-created project ids to exercise metering in isolation. If a future consumer flow
7979
needs unknown-project 404s, add a shared `requireProject(scope)` guard to the child writers and
80-
seed the parent projects the quota cases use.
80+
seed the parent projects the quota cases use. (Live evidence, 2026-06-29: GET / DELETE / publish /
81+
add-prompt / by_tags / list-ai_models on a non-existent `project_id` all `404 { message: "not
82+
found" }`. The mock now mirrors this for **DELETE project** — see the read-view section — but child
83+
writes still succeed against a bogus project id, on purpose.)
84+
- **No tenant / ownership model.** PE `GET projects` returns `200 { items: [] }` for *any* workspace
85+
id; live `403 { invalid access attempt }`s an unknown/unowned workspace (and `200 { items: [] }`
86+
only for an owned-but-empty one) — the mock has no notion of ownership. Likewise the **bearer gate
87+
is presence-only**: a garbage `Bearer xxx` passes (live `401`s it). Both are deliberate — the
88+
consumer always operates on workspaces it owns, with a real IMS token. (verified 2026-06-29)
89+
- **Two live error layers collapsed into one.** Live has a gateway/auth layer (`application/json`,
90+
`{detail}`/`{message}`) and a PE app/handler layer (**`text/plain`**, Go-validator/handler bodies:
91+
`400` field-validation, `404 {message:"not found"}`, `500 {message:"internal server error"}`,
92+
`409` conflict). The mock returns every error as `application/json` with a single envelope, and
93+
does **not** reproduce the unguarded **500s** live throws for a bad input it never validates
94+
(unknown `language_id`, `type:"seo"`, `GET projects` with no params / `?live=true`). The consumer
95+
reads status, not error bodies (`mapError` redacts upstream), so this is shape-only. (2026-06-29)
96+
- **Pagination is asymmetric across catalogs.** `GET /v1/languages` ignores `page`/`limit` (always
97+
all 38, `page:1`) — the mock matches; `GET /v1/ai_models` and `GET projects` *do* paginate live —
98+
the mock does not honor `page`/`limit` on those (returns the full list). No consumer paginates
99+
these, so it is unmodelled rather than wrong. (2026-06-29)
100+
- **`brand-topics` `prompts` is empty in the mock.** Live `GET brand-topics` returns each topic with
101+
a large multilingual keyword array under `prompts`; the mock's two static topics carry
102+
`prompts: []`, so serenity's volume-ranked `generateAndAttachPrompts` (which reads per-topic
103+
`prompts`) gets nothing from the mock — a fidelity gap for the prompt-generation flow only. Seed
104+
richer topics if a cross-repo e2e drives that path. (2026-06-29)
81105
- **`listBrandUrls` always returns `200 { brand_urls }`; live can `404`.** Live (verified
82106
2026-06-25) `GET …/aio/benchmarks/{bid}/brand_urls` returns `404 { message: "not found" }` when
83107
`{bid}` is not the project's listable (auto-created main-brand) benchmark — even though a `POST`
@@ -93,18 +117,72 @@ change, the live proof.
93117
immediately readable on purpose (deterministic test double); to exercise the consumer's
94118
absent-benchmark / `404`-skip branches, model them **deterministically via seed/control state**,
95119
never via time-based delays — see "Replicating live async behaviour" below.
96-
- **Create ops report `existing_count: 0` unconditionally.** `POST .../aio/prompts/tagged` and
97-
`POST .../ai_models/benchmarks` always return `existing_count: 0` — the mock models no dedup
98-
against already-present rows, so the consumer's "some already present" branch
99-
(`existing_count > 0`) cannot be exercised against this mock. Deliberate: the confirmed consumer
100-
flows create into freshly scoped collections, and dedup fidelity adds store complexity no flow
101-
reads. Add a name/domain-keyed existing-count if a future flow depends on it.
120+
- **Create ops now model live dedup/conflict (#1745).** `POST .../aio/prompts/tagged` dedups by
121+
prompt **text** — a text already present is not re-created, it is counted in `existing_count` and
122+
gets no new id (live, 2026-06-29). `POST .../ai_models/benchmarks` instead treats a duplicate
123+
brand name / alias / domain as a **hard `409`** `{ message: "ai benchmark conflict: duplicate
124+
brand name or alias" }` (benchmarks and prompts dedup *differently* live) and creates nothing for
125+
the batch — including two conflicting entries **within the same batch**, not just against
126+
already-stored rows. Not modelled: **slice-uniqueness** (two US/en projects both succeed with distinct ids —
127+
PE does not enforce it; that invariant lives only in serenity's `findBySlice`/DB), matching live.
128+
129+
## Project read-view fidelity (#1745, live-pinned 2026-06-29)
130+
131+
The created/published project read-view is reconstructed by the consumer into an addressable
132+
**market** (`spacecat-api-service` `subworkspace-projects.js`), so its `settings.ai` shape is
133+
load-bearing. Two fields are resolved by the mock to match live, and the publish action mutates the
134+
stored project:
135+
136+
- **`settings.ai.language.name` = the ISO code, NOT the English display name.** Live returns the
137+
ISO code (`"en"`) here on both the create response and every read-view, while `language.id` is the
138+
catalog UUID sent on create (the SAME UUID `GET /v1/languages` returns under the English display
139+
name `"English"` — only `name` differs between the two views). The consumer's `langOf` reads
140+
`settings.ai.language.name` directly and lowercases it as the slice code, so the create factory
141+
resolves `language_id` → ISO via the shared **`mock/language-catalog.js`** (`isoForLanguageId`),
142+
which carries an `iso` column alongside the live `id`/`name`. Only `en` is live-verified; the
143+
other ISO codes are standard ISO 639-1, best-effort so any market a downstream e2e exercises
144+
round-trips. (`GET /v1/languages` still serves just `{ id, name }` — the `iso` column is
145+
mock-internal.)
146+
- **`settings.ai.country.name`** is populated from the country code via `Intl.DisplayNames` (region).
147+
**Documented divergence:** live returns the short informal name (`"USA"` for `us`); Intl returns
148+
`"United States"`. Accepted because **no consumer reads `country.name`**`geoOf` resolves geo
149+
from `country.code``resolveLocation` — so it is fidelity-only, and a hand-maintained
150+
code→informal-name catalog would be unread maintenance. `location.id`/`name` are echoed from the
151+
request verbatim (matches live for the consumer-driven create path).
152+
- **Publish moves the stored read-view to live.** `POST .../publish` flips the stored project's
153+
`publish_status` `draft``live` and stamps `published_at`, so a later `GET`/list reports the
154+
published status (the slice's `mapPublishStatus(publish_status)` reads `live`). **`is_draft` is
155+
left as-is** — live keeps `is_draft: true` after publish (only `publish_status`/`published_at`
156+
change; live's full quirk is `true` on create → `false` on GET-as-draft → `true` post-publish),
157+
and the consumer ignores `is_draft` entirely. The publish update is a no-op for an unknown project
158+
id, so the metering cases (which publish under never-created ids) are unaffected.
159+
160+
## Endpoint status-code quirks the mock does NOT reproduce (live errors; mock is permissive)
161+
162+
Live returns errors for several call *shapes* the consumer never sends; the mock answers normally.
163+
None affects the consumer, but they are documented so a future caller doesn't assume mock parity
164+
(live captured 2026-06-29):
165+
166+
| call | live | mock | why it's safe |
167+
| --- | --- | --- | --- |
168+
| `GET /v1/.../projects` (no params) | **500** | 200 | the consumer always sends `?type=ai` |
169+
| `GET /v1/.../projects?live=true` | **500** | 200 | the consumer never sends `live=` |
170+
| `GET .../aio/prompts` | **405** | (DELETE only) | reads go via `by_tags` (POST) |
171+
| `GET .../aio/prompts/tagged` | **405** | (POST only) | create is POST |
172+
| `GET .../ci/competitors` | **405** | (PUT only) | update is PUT |
173+
| `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) |
102174

103175
## Replicating live async behaviour (don't use timers)
104176

105177
Live Semrush is **eventually consistent**: a just-created prompt/brand-URL isn't listed yet, and a
106178
project's main-brand benchmark is generated asynchronously (it didn't appear within ~60s of create,
107-
even after a publish — verified 2026-06-25). The mock is deliberately **immediately consistent**.
179+
even after a publish — verified 2026-06-25). A further, distinct case (live-pinned 2026-06-29):
180+
the project **list** (`GET projects?type=ai`) lags a new project by ~4 s, and **prompt reads**
181+
(`POST aio/prompts/by_tags`) are **publish-gated** — a created prompt stays invisible until the
182+
project is published, then appears (this is a published-snapshot read, not lag). The end-to-end
183+
consumer flow closes anyway because `POST /prompts` runs `publishAffected`; a test that lists
184+
prompts *before* that publish would diverge from the immediately-consistent mock. The mock is
185+
deliberately **immediately consistent**.
108186

109187
**Do NOT make the mock time-based** (delays, "appears after N seconds / N reads"). A test double's
110188
value is determinism; introducing wall-clock async makes consumer tests flaky and slow, and the

packages/spacecat-shared-project-engine-client/mock/context.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { createQuota } from './quota.js';
4343
import { authError } from './auth.js';
4444
import { emptyAck } from './responses.js';
4545
import * as factories from './factories.js';
46+
import { LANGUAGE_CATALOG } from './language-catalog.js';
4647
import { SEEDS, DEFAULT_SEED } from './seeds.js';
4748

4849
/**
@@ -82,6 +83,10 @@ export class Context {
8283
// literals — the factory is the single, tsc-checked source of truth for each shape, so the
8384
// handlers can't drift from the spec the way duplicated literals would.
8485
this.factories = factories;
86+
// The canonical language catalog (mock/language-catalog.js). Exposed so the `GET /v1/languages`
87+
// route serves it without duplicating the 38-entry list, mirroring how `factories` is shared —
88+
// every route reads its lib data through `$.context`, never an import.
89+
this.languageCatalog = LANGUAGE_CATALOG;
8590
}
8691

8792
/**

packages/spacecat-shared-project-engine-client/mock/counterfact/routes/v1/languages.js

Lines changed: 7 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -14,54 +14,16 @@
1414
* Static handler for GET /v1/languages — the language catalog the consumer
1515
* (spacecat-api-service `listLanguages`) reads to resolve an ISO 639-1 code to a Semrush language
1616
* UUID. The live shape is `{ page, total, items: [{ id, name }] }` — items carry NO key/icon (that
17-
* is the ai_models shape). This is the FULL live catalog (38 languages, real UUIDs + names),
18-
* captured verbatim 2026-06-25 so the mock returns the same taxonomy the consumer resolves against.
17+
* is the ai_models shape) and NO `iso` (that field is mock-only, for the read-view resolver). The
18+
* catalog (38 languages, real UUIDs + names, captured verbatim 2026-06-25) lives in
19+
* `mock/language-catalog.js` so it is shared with the project read-view factory rather than
20+
* duplicated here; it is exposed on the per-request context as `$.context.languageCatalog`.
1921
* Materialized into `.counterfact/routes/` by the mock runner; excluded from coverage.
2022
*/
2123

22-
const LANGUAGES = [
23-
{ id: '76b01191-fa8b-4c1d-a71f-55ca8996669c', name: 'Afrikaans' },
24-
{ id: '7d3efff9-f6b6-4818-ac6b-0d1d2c047803', name: 'Arabic' },
25-
{ id: 'cfcdee66-f9b3-4f39-9066-141b8de1a52d', name: 'Armenian' },
26-
{ id: 'c5d84e73-66f9-4f17-989f-9a421d17cfe3', name: 'Azerbaijani' },
27-
{ id: 'ac1ace1e-22a1-40e9-b869-4fe2d0467603', name: 'Bulgarian' },
28-
{ id: '728bef4c-94cf-4e14-bc06-56534751c71a', name: 'Chinese Simplified' },
29-
{ id: '4fecf249-03fa-430a-b0b1-42421c5c1f7d', name: 'Chinese Traditional' },
30-
{ id: 'e64e7f7d-11d9-4830-a022-f370e0a74bf7', name: 'Czech' },
31-
{ id: '4f407a00-600e-4bdc-bf18-e5d8c7f758e3', name: 'Danish' },
32-
{ id: '9090239f-5a3e-4101-a828-9bde09ad378b', name: 'Dutch' },
33-
{ id: '5a0a33ed-7f5c-4901-befd-a042c0350da1', name: 'English' },
34-
{ id: 'f8b95d67-6ac5-48c2-9ed1-73b9e52d7f92', name: 'Filipino' },
35-
{ id: '0a28a154-72ac-421c-86cf-1c4d240f3cda', name: 'Finnish' },
36-
{ id: '471aba35-ee1f-4207-b6f6-8c6550bbbab8', name: 'French' },
37-
{ id: '5227c7cd-4136-4fca-919c-42e666787319', name: 'Georgian' },
38-
{ id: 'e5282ae9-83a6-4ea3-b3cf-5e99d8f51eca', name: 'German' },
39-
{ id: 'd7db513e-b5f6-4b11-9309-eda861451008', name: 'Greek' },
40-
{ id: 'a2c8acc6-be1f-47de-94aa-3c2587922922', name: 'Hebrew' },
41-
{ id: 'a3cd47fe-dc60-4cca-89e3-16984a1755af', name: 'Hindi' },
42-
{ id: '495ccfcb-d61f-4544-9dc1-24d6393c9d18', name: 'Hungarian' },
43-
{ id: 'd5376885-d3d8-4e6c-92ea-61d5ff9a7369', name: 'Indonesian' },
44-
{ id: '3ea61bf2-ebf4-40b5-8a59-900e701d966d', name: 'Italian' },
45-
{ id: '54bb0cca-b095-444b-be88-818a02e9beeb', name: 'Japanese' },
46-
{ id: 'ad6bf224-42b0-46b6-b59a-21ea9d021fb8', name: 'Kazakh' },
47-
{ id: 'c181d356-b65e-4ffa-b706-f61f96503d58', name: 'Korean' },
48-
{ id: '1de63814-323e-4b2f-bd36-20fe15de14ae', name: 'Kurdish' },
49-
{ id: '9bb46132-a85a-41c8-8d56-38bf5a050e76', name: 'Malay' },
50-
{ id: 'a3b6e3f2-82f1-4b0b-8223-ae224d54bd63', name: 'Norwegian' },
51-
{ id: 'd53c0677-c451-46b2-9650-83dbca109f25', name: 'Polish' },
52-
{ id: 'a4e931c1-22c8-45d4-897e-a653acbc8126', name: 'Portuguese' },
53-
{ id: 'd64b3fb0-62eb-4d7e-947b-952194314b97', name: 'Romanian' },
54-
{ id: '19933246-6952-4929-ad0d-6cee874ea553', name: 'Russian' },
55-
{ id: '6abdf315-f969-4643-894f-aefabe7ed057', name: 'Slovak' },
56-
{ id: '29047b68-fe6f-4525-8ef7-8b1c2349af5d', name: 'Spanish' },
57-
{ id: '5c400ede-f822-402e-be32-4a88049e74e4', name: 'Swedish' },
58-
{ id: 'b8208c02-3e73-433b-b6c2-2784d0ba9bfa', name: 'Thai' },
59-
{ id: '71630579-fd14-4357-81b3-0ae4ecd54975', name: 'Turkish' },
60-
{ id: '12aa4de5-3526-4b96-909a-c033c1f4e32c', name: 'Vietnamese' },
61-
];
62-
63-
/** GET — list the language catalog (each row shaped via the factory). */
24+
/** GET — list the language catalog `{ id, name }` (each row shaped via the factory). */
6425
export function GET($) {
65-
const items = LANGUAGES.map((l) => $.context.factories.createLanguageMock(l));
26+
const items = $.context.languageCatalog
27+
.map(({ id, name }) => $.context.factories.createLanguageMock({ id, name }));
6628
return $.response[200].json({ page: 1, total: items.length, items });
6729
}

packages/spacecat-shared-project-engine-client/mock/counterfact/routes/v1/workspaces/{id}/projects/{project_id}.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,17 @@ export function PATCH($) {
4747
return $.response[200].json(updated);
4848
}
4949

50-
/** DELETE — remove a project. */
50+
/**
51+
* DELETE — remove a project. Live returns 404 `{ message: 'not found' }` for a non-existent project
52+
* (verified 2026-06-29); the mock mirrors the status (bare 404, like the sibling GET — the
53+
* text/plain error body is a documented simplification). The consumer treats a delete-404 as
54+
* success (idempotent decommission), so the status change is consumer-equivalent.
55+
*/
5156
export function DELETE($) {
5257
const { path, context } = $;
53-
context.ops.projects.remove({ workspaceId: path.id }, path.project_id);
58+
const removed = context.ops.projects.remove({ workspaceId: path.id }, path.project_id);
59+
if (!removed) {
60+
return { status: 404 };
61+
}
5462
return { status: 204 };
5563
}

packages/spacecat-shared-project-engine-client/mock/counterfact/routes/v1/workspaces/{id}/projects/{project_id}/publish.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@
1818
* returns the disguised quota 405 — exactly the "publishing an empty-units child 405s" behaviour
1919
* the consumer's `republishBestEffort` swallows (see mock/quota.js). Excluded from coverage
2020
* (materialized handler).
21+
*
22+
* Stateful side-effect: on a successful publish the stored project's read-view is moved to the
23+
* live state — `publish_status` flips `draft` → `live` and `published_at` is set — so a later
24+
* `GET`/list reflects the publish (the slice's reported status reads `live`, not `draft`; #1745).
25+
* `is_draft` is deliberately LEFT as-is: live keeps `is_draft: true` after publish (only
26+
* `publish_status`/`published_at` change), and the consumer ignores `is_draft` anyway. The update
27+
* is a no-op for an unknown project id (the quota cases publish under never-created ids), so the
28+
* 202 ack is unaffected — child writes do not assert the parent project exists (see
29+
* docs/mock-statefulness.md).
2130
*/
2231

2332
/** POST — publish the draft → 202 (empty body); 405 when the workspace has 0 prompt units. */
@@ -30,6 +39,11 @@ export function POST($) {
3039
contentType: 'application/json',
3140
};
3241
}
42+
// Reflect the publish in the stored read-view: draft → live, stamp published_at, keep is_draft.
43+
context.ops.projects.update({ workspaceId: path.id }, path.project_id, {
44+
publish_status: 'live',
45+
published_at: new Date().toISOString(),
46+
});
3347
// Empty body (content-length 0) like live. The explicit content type (via emptyAck) bypasses
3448
// Counterfact's response negotiation, which would otherwise 406 under `Accept: application/json`.
3549
return context.emptyAck(202);

packages/spacecat-shared-project-engine-client/mock/counterfact/routes/v2/workspaces/{id}/projects/{project_id}/ai_models/benchmarks.js

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,50 @@
1717
* always a competitor (`main_brand: false` — the API cannot set the system-managed own brand).
1818
* Writes to the SAME store key (`benchmarks:{ws}:{pid}`) the v1 list/delete use, so a subsequent
1919
* list reflects it. Live: 200 `IDsWithStatsResponse` `{ ids, existing_count }` (verified
20-
* 2026-06-25). Excluded from coverage (materialized handler).
20+
* 2026-06-25). Unlike prompts (which dedup into `existing_count`), a benchmark whose brand name,
21+
* alias, or domain collides with an existing one is a HARD **409** conflict
22+
* `{ message: 'ai benchmark conflict: duplicate brand name or alias' }` (verified live 2026-06-29);
23+
* the whole batch is rejected, nothing is created. Excluded from coverage (materialized handler).
2124
*/
2225

23-
/** POST — batch-create benchmarks (body: array) → 200 { ids, existing_count }. */
26+
/** Case-insensitive identity tokens (brand name + aliases + domain) a benchmark conflicts on. */
27+
const identityTokens = (b) => [
28+
b?.brand_name,
29+
b?.domain,
30+
// Guard the spread: a raw request entry's `brand_aliases` may not be an array (spreading a
31+
// string would iterate its characters into spurious tokens).
32+
...(Array.isArray(b?.brand_aliases) ? b.brand_aliases : []),
33+
].filter((t) => typeof t === 'string' && t.length > 0).map((t) => t.toLowerCase());
34+
35+
/** POST — batch-create benchmarks (body: array) → 200 { ids, existing_count }; 409 on conflict. */
2436
export function POST($) {
2537
const { path, body, context } = $;
38+
const scope = { workspaceId: path.id, projectId: path.project_id };
2639
const entries = Array.isArray(body) ? body : [];
40+
41+
// Reject if any incoming benchmark collides (brand name / alias / domain, case-insensitive) with
42+
// one already in the project — the live hard-409, not a silent duplicate (#1745 second sweep).
43+
// Each entry's tokens are folded into `taken` as we iterate, so two conflicting entries in the
44+
// SAME batch collide too, not just against already-stored benchmarks.
45+
const taken = new Set(context.ops.benchmarks.list(scope).flatMap(identityTokens));
46+
const conflicts = entries.some((b) => {
47+
const tokens = identityTokens(b);
48+
const hit = tokens.some((t) => taken.has(t));
49+
tokens.forEach((t) => taken.add(t));
50+
return hit;
51+
});
52+
if (conflicts) {
53+
return {
54+
status: 409,
55+
body: context.factories.createBasicResponseMock({
56+
message: 'ai benchmark conflict: duplicate brand name or alias',
57+
}),
58+
contentType: 'application/json',
59+
};
60+
}
61+
2762
const created = context.ops.benchmarks.createMany(
28-
{ workspaceId: path.id, projectId: path.project_id },
63+
scope,
2964
entries.map((b) => context.factories.createBenchmarkMock({
3065
brand_name: b?.brand_name ?? '',
3166
domain: b?.domain ?? '',

0 commit comments

Comments
 (0)