feat(project-engine-client): stateful mock core — store, resource ops, seeds (LLMO-5460)#1665
feat(project-engine-client): stateful mock core — store, resource ops, seeds (LLMO-5460)#1665aliciadriani wants to merge 48 commits into
Conversation
|
This PR will trigger a minor release when merged. |
Type-surface finding (surfaced by the new CI guard)The The mismatch:
Why it matters (footgun, not cosmetics): the client's public type ( Options:
Hard constraint (option 1): do the Parked for the #4 adoption (where real call sites make the cost and the exact shape-to-omit concrete; the transform is a non-trivial recursive mapped type). The client type itself lives in #1661. The type-test fixture moves in lockstep — whatever's decided, Tracked as an LLMO-5461 sub-task linked to the #4 adoption work. |
aliciadriani
left a comment
There was a problem hiding this comment.
Inline comments below carry the proposed fix for each finding from the review.
Process — Should Fix (not inline): the PR description is stale relative to the branch. The body says step 5 (Counterfact runner) and step 8 (E2E + CI wiring) are "Deferred to a follow-up PR," but this branch already contains run.js, the route handlers, __reset.js, the full E2E suite, and the new E2E (project-engine mock) CI job. The "Contents" list also omits context.js, run.js, the handlers, and the type-surface test. Please refresh the description to match the 24-file / +1579 diff so reviewers can size scope.
Fix: update the PR body — move steps 5 & 8 from "Deferred" to "Contents," and add context.js, run.js, the counterfact handlers, __reset.js, the E2E suite, and test/types/index.type-test.ts to the contents list.
| export function POST($) { | ||
| const { path, body, context } = $; | ||
| const created = context.ops.prompts.createMany( | ||
| { workspaceId: path.id, projectId: path.project_id }, | ||
| (body?.items ?? []).map((text) => ({ name: text })), | ||
| ); | ||
| const first = created[0] ?? {}; | ||
| return $.response[200].json({ id: first.id, name: first.name }); |
There was a problem hiding this comment.
Should Fix — this create handler is mounted on a path the spec and the consumer don't use.
The spec defines only delete on /aio/prompts (aio-delete-prompt-by-ids-v2) — there is no POST here. The real create is POST /aio/prompts/tagged (aio-create-prompts-with-tags) and the real read is POST /aio/prompts/by_tags (aio-list-prompts-by-tag-ids) — exactly what docs/mock-statefulness.md lists as the consumer's stateful prompt ops.
Consequences as written:
- The E2E
creates aio prompts (v2)case drives a non-specPOST, so there's no200response schema for Counterfact to validate the{ id, name }envelope against — the "response validation stays on" guarantee doesn't cover this handler. - When
spacecat-api-serviceruns against the mock, prompt create (tagged) and read (by_tags) hit Counterfact's stateless stubs, so the documented prompt write-then-read spine isn't actually stateful.
Fix: keep DELETE in this file, move create into a new aio/prompts/tagged.js, and add a aio/prompts/by_tags.js read handler (the store + ops already support both via createMany / list). Repoint the E2E case at /v2/.../aio/prompts/tagged.
// aio/prompts/tagged.js — POST create (operationId aio-create-prompts-with-tags)
export function POST($) {
const { path, body, context } = $;
const created = context.ops.prompts.createMany(
{ workspaceId: path.id, projectId: path.project_id },
(body?.items ?? []).map((text) => ({ name: text })),
);
const first = created[0] ?? {};
return $.response[200].json({ id: first.id, name: first.name });
}// aio/prompts/by_tags.js — POST list/read (operationId aio-list-prompts-by-tag-ids)
// shape the envelope to match the spec's 200 response for this op
export function POST($) {
const { path, context } = $;
const items = context.ops.prompts.list({ workspaceId: path.id, projectId: path.project_id });
return $.response[200].json({ items, page: 1, total: items.length });
}If this is intentionally deferred to the adoption PR, please scope it out in the description and drop the POST /aio/prompts row from the README's stateful-endpoints table, which currently advertises it as "create prompts."
There was a problem hiding this comment.
Resolved in cbe35f3 — the bogus POST /aio/prompts handler was removed and the correct paths were wired:
aio/prompts/tagged.js→POSTcreate (201,IDsWithStatsResponse)aio/prompts/by_tags.js→POSTlist (200,AIOPromptsWithStatusListResponse)aio/prompts.jsretains onlyDELETE→ 204
The E2E now drives tagged → by_tags with a real write-then-read assertion and response validation on, closing the false-coverage gap.
| { id: 'model-gpt4o', name: 'gpt-4o' }, | ||
| ], | ||
| [collectionKey('prompts', { workspaceId: WORKSPACE_ID, projectId: PROJECT_ID })]: [ | ||
| { id: 'prompt-1', text: 'What is the best running shoe?' }, |
There was a problem hiding this comment.
Nit — seed/created prompt field mismatch. Seed prompts use text, but prompts.createMany stores { name } and the create handler returns { id, name }. If a by_tags read handler is added (see the prompts.js comment), seeded vs. created prompts will have inconsistent shapes. Align the seed to name:
| { id: 'prompt-1', text: 'What is the best running shoe?' }, | |
| { id: 'prompt-1', name: 'What is the best running shoe?' }, |
There was a problem hiding this comment.
Resolved in cbe35f3 — seed prompts now use name (+ tags: []) matching the AIOPromptWithStatus shape, consistent with what createMany stores and by_tags returns.
| const id = entity.id ?? InMemoryStore.#generateId(); | ||
| /** @type {Entity} */ | ||
| const stored = { ...entity, id }; | ||
| this.#collection(name).set(id, stored); |
There was a problem hiding this comment.
Nit (optional, no change required). create with an explicit existing id silently overwrites via Map.set (a real API would 409). Fine for a mock; flagging only because seeds use fixed ids, so a seed-id collision would be swallowed.
If you want collisions to surface during dev, the minimal guard is:
if (entity.id && this.#collection(name).has(entity.id)) {
throw new Error(`duplicate id ${entity.id} in collection ${name}`);
}Otherwise leave as-is — overwrite-on-duplicate is acceptable mock behaviour.
There was a problem hiding this comment.
Added in 1f5c8cf — the guard throws on an explicit id collision:
if (entity.id && this.#collection(name).has(entity.id)) {
throw new Error(`duplicate id ${entity.id} in collection ${name}`);
}Seed-id collisions now surface immediately instead of being silently swallowed.
| { workspaceId: path.id, projectId: path.project_id }, | ||
| { model: { id: body.model_id }, prompts_count: 0 }, | ||
| ); | ||
| return $.response[200].json(created); |
There was a problem hiding this comment.
Nit (no change required). This POST returns 200 while the projects create returns 201. CI response-validation is on and green, so 200 is what the spec declares for add ai_model — the inconsistency is spec-driven, not a bug. Noting only for awareness.
There was a problem hiding this comment.
Acknowledged — spec-driven inconsistency, no action needed.
| } | ||
|
|
||
| /** DELETE — batch-delete prompts (body: { ids }). */ | ||
| export function DELETE($) { |
There was a problem hiding this comment.
Nit (no change required). prompts.removeMany (and ai_models.removeMany) compute a removed count that this handler discards — it always returns 204. The spec DELETE is 204, so this is correct; the count is exercised at the ops layer in stateful.test.js. Flagging only so the unused return value reads as deliberate.
There was a problem hiding this comment.
Acknowledged — the count is exercised at the ops layer; the handler deliberately discards it and always returns 204 per spec.
…-Data-Jwt header The generated `paths` mark `Auth-Data-Jwt` as a required per-operation header (faithful to the API contract), but the client injects that header at runtime via middleware (headers.set) and overwrites whatever a caller passes. So the typed surface was forcing every consumer to pass a value that's always ignored — a footgun (a "real" token passed there silently does nothing). Narrow the *exported* client type only: - Add a type-only mapped transform over the generated `paths` (ClientPaths) that removes `Auth-Data-Jwt` from each operation's header params. Auth-Data-Jwt is the sole header in the contract, so the emptied bag collapses to `never` — passing a header is now a compile error, not just optional, which actively closes the footgun. A genuine consumer header (if ever added) is preserved. - Type SerenityProjectEngineApiClient / the factory return against ClientPaths instead of raw Client<paths>. Generated types, the vendored spec, and the Pydantic models are untouched — they remain the honest API contract; only the client surface hides the header it supplies itself. Runtime (client.js, middleware) is unchanged. Validated with a throwaway tsc harness (not committed): a consumer calls an op without Auth-Data-Jwt (compiles), path params stay required, passing Auth-Data-Jwt is rejected, and the bogus-path drift canary still trips. Lint clean; unit suite 51 passing at 100% coverage (type-only change). Guard reconciliation (cross-PR): the type-surface guard fixture lives in #1665 (test/types/index.type-test.ts) and currently asserts the un-narrowed surface (it passes header: { 'Auth-Data-Jwt' }). That fixture must be updated when these land together at main — see the follow-up note on #1665. Relates to the #4 adoption, where consumer call sites drop the header argument. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Resolved — prompts stateful slice reconciled against the consumer and the spec (commit Follow-up to Should Fix #1 (the What was wrong. The stateful create handler was mounted on
Because the spec declares no The actual consumer flow (
Fix (option a — wire the correct handlers, repoint the E2E):
Validation. The E2E now exercises the real spec operations with response validation on, so the Spec quirk worth flagging. On |
…atching deployed transport (LLMO-5461) The wrapper authenticated by forwarding the raw IMS token as the `Auth-Data-Jwt` header. Against the only reachable Project Engine endpoint — the Adobe-hosted gateway — that 401s: the gateway authenticates `Authorization: Bearer <IMS>` and injects Semrush's native `Auth-Data-Jwt` server-side. This replicates the proven prod consumer, spacecat-api-service `src/support/serenity/rest-transport.js`, whose `buildHeaders()` sends `Authorization: Bearer` and whose comment notes the `Auth-Data-Jwt` branch was "deliberately removed". - client.js: authMiddleware now sets `Authorization: Bearer <token>` (was `Auth-Data-Jwt`); still no exchange/minting — the gateway exchanges the bearer server-side. - client.js: own the `/enterprise/projects/api` prefix like rest-transport — normalise baseUrl to its origin (drop any path/credentials) + the fixed prefix, and fail fast on an invalid baseUrl. Idempotent for callers that already include the prefix, so the unit tests and the #1665 mock e2e are unaffected. - index.d.ts: keep the type-narrowing pointed at `Auth-Data-Jwt`. The generated `paths` still mark it required, but the gateway injects it server-side, so a consumer must not pass it; dropping the narrowing would force them to. The new `Authorization` header is middleware-injected, outside the typed params, so it needs no narrowing. Comments/JSDoc updated to the gateway-exchange model. - tests: assert `Authorization: Bearer` (not `Auth-Data-Jwt`); add baseUrl normalisation + fail-fast coverage; reword the spec/title references. Gates: lint clean, 29 unit passing @ 100% coverage, throwaway tsc confirms the narrowing still holds (no-header call compiles; passing `Auth-Data-Jwt` is rejected). Contract basis: confirmed by Rainer Friederich's first-hand live test (`Authorization: Bearer` -> 200, `Auth-Data-Jwt` -> 401) plus the deployed rest-transport.js. NOT independently re-probed — our IMS identity is not provisioned on the Semrush side (the project's known no-live-access constraint), so a local probe 401s on every header. LLMO-5461 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Heads-up for the rebase onto #1661 (now that it carries the generation-time overlay 1. 2. The route prefix breaks when you switch specs. Counterfact honors a Swagger-2.0
Net diff to const SPEC = join(packageRoot, 'build', 'openapi3.json'); // was spec/projectengine_swagger_public.yaml
// ...ensure build/openapi3.json exists first (npm run generate)...
[findCounterfactBin(), SPEC, BASE_PATH, '--port', String(PORT), '--serve',
'--prefix', '/enterprise/projects/api', '--no-validate-request', '--no-update-check']Alternative (bigger change): keep feeding Swagger 2.0 so |
…ul mock Port the scaffold's generic, resource-agnostic InMemoryStore to JS + JSDoc (mocha + chai). This is the statefulness primitive for the Counterfact mock: collection-keyed CRUD plus seed/reset, with deep cloning on every read/write so a loaded seed snapshot is never mutated and reset() restores a pristine copy. Knows nothing about specific resources — the stateful handlers (projects, ai_models, prompts per the recommended first cut) plug their collections into it in a follow-up commit; non-stateful endpoints keep Counterfact's auto-generated schema response. 100% coverage; lint clean. LLMO-5460 (epic LLMO-5459) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e mock Confirm the spike against both consumers: spacecat-api-service is the only Project Engine consumer (llmo-data-retrieval-service makes zero calls), and the write-then-read spine is exactly projects, ai_models (per project), and prompts (aio, per project) — the recommended first cut. Documented as the AC floor in docs/mock-statefulness.md. - src/mock/stateful.js: the confirmed stateful set as pure operations over InMemoryStore (per-workspace/per-project collection scoping + the CRUD each group needs). No Counterfact or HTTP coupling, so it is unit-tested on its own; the runner adapts these into per-path handlers and maps results into spec-valid response envelopes. - src/mock/seeds.js: named seed sets (empty-workspace, workspace-with-data) keyed to the same collections, plus DEFAULT_SEED and SEED_IDS. Loaded via store.load(...); store.reset() restores between tests. 100% coverage; lint clean. LLMO-5460 (epic LLMO-5459) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Materializes the committed stateful handlers into a gitignored .counterfact scratch tree and launches Counterfact against the Project Engine spec. The shared in-memory store is wired in via a generated root Context, seeded by MOCK_SEED, with a __reset control route for E2E isolation. Validated end-to-end against a running server: projects list/create/get/ patch/delete, ai_models list/add, v2 aio prompts create, and __reset restoring seed state. Key runner details discovered against the live server: - Materialize the whole tree as .ts so Counterfact's transpiler emits .cjs with matching rewritten specifiers; .js sources emit CJS content under a "type": "module" package and fail to load. - Pass --serve explicitly so Counterfact does not default-enable codegen, which would append typed random() stubs onto our handlers (duplicate GET/POST). Transpile + load still run under --serve. - --no-validate-request: the spec marks Auth-Data-Jwt required but Node lowercases inbound header names, so validation 400s every request. run.js and the materialized handlers require a live server, so they are excluded from coverage; the unit-testable Context/store/ops keep 100%. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a Mock section covering MOCK_PORT/MOCK_SEED, the base URL, the stateful endpoints and the __reset control route, plus a note on how the runner materializes handlers. Drop the stale "later PR" marker now that the mock ships. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t the live mock Adds an env-gated (MOCK_E2E=1) E2E suite that boots the stateful Counterfact mock via src/mock/run.js and drives it through the real client: projects list/create/get/patch/delete, ai_models list/add, v2 aio prompts, and __reset isolation between cases. Self-managed lifecycle (spawn in before, process-group teardown in after). Kept deliberately out of the fast path: - file glob *.e2e.js (not *.test.js) so default `npm test` never loads it, preserving the unit suite's speed and 100% coverage with no live-server dep - `npm run test:e2e` runs it in isolation (--no-package so the package.json mocha spec/reporters don't bleed in) - a dedicated `E2E (project-engine mock)` CI job runs it, independent of the release gate - root eslint override allows devDependencies in *.e2e.js (the import rule's built-in test glob only covers *.test.js) Scoped to this package (client <-> mock contract); the api-service-level E2E rides with the adoption PR (LLMO-5461 #4, blocked on publish). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t + robust teardown Address review points on the live-mock E2E: - CI scope: add a cheap `changes` guard job (plain git merge-base diff, no third-party action) that the E2E job `needs`/`if`-gates, so Counterfact only boots when the project-engine-client package actually changed — not on every monorepo push. `on.paths` can't be used here: the workflow triggers on push, so a path filter would gate `test`/`release` too. - Ports: replace the hardcoded 4099 with an OS-assigned free port (MOCK_E2E_PORT still honoured for manual pinning) to avoid collisions across runs/jobs. - Teardown: after() now awaits the detached process group's exit (bounded) so the port is freed and no mock server is orphaned, including when before()/a test throws (mocha still runs after-hooks). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The typed client is this package's deliverable, but the public surface (hand-written src/index.d.ts) was unguarded: it can silently drift from the generated `paths` and degrade to `any`, and both the unit and E2E suites are type-blind (plain JS). Per repo convention every sibling package hand-writes its .d.ts and none emit from JSDoc, so rather than diverge to a checkJs build, add an additive consumer type-test: - test/types/index.type-test.ts: compile-only fixture asserting the factory returns Client<paths>, a generated operation stays typed, and bogus path/component keys are rejected (an @ts-expect-error canary that fails the build if the surface collapses to `any`). - tsconfig.types-test.json: tsc --noEmit, bundler resolution so `.js` import specifiers resolve to the .d.ts / generated .ts. - `npm run test:types`; pinned typescript devDep so a transitive-hoist change can't silently disable the guard. - CI: runs in the existing path-gated project-engine job (tsc-only, no server), so it only fires when the package or its generated types change. Verified the guard fails on real drift (client => any -> TS2578 unused-directive; factory return change -> TS2322) and is clean otherwise. Surfaced finding (tracked separately, not fixed here): the generated spec marks `Auth-Data-Jwt` as a required per-call header, so TS consumers must pass it even though the client injects it at runtime — a DX wart in the typed surface. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ths (tagged/by_tags)
The stateful prompt handler was mounted on POST /aio/prompts, which the spec
defines as delete-only and no consumer calls — false coverage that also escaped
response validation (no 200 schema to check the envelope against). The real
spacecat-api-service prompt flow (rest-transport.js) is:
- create: POST /aio/prompts/tagged (body { prompts: { [tag]: [text] } })
- list: POST /aio/prompts/by_tags (body { tag_ids, page, ... })
- delete: DELETE /aio/prompts (body { ids })
Reconciled the spike's deferred call-site check against the actual consumer and
the spec:
- Remove the bogus POST from aio/prompts.js (keep DELETE → 204, the only op the
spec defines on that path).
- Add aio/prompts/tagged.js → 201 IDsWithStatsResponse { ids, existing_count }.
- Add aio/prompts/by_tags.js → 200 AIOPromptsWithStatusListResponse
{ items, page, total, unassigned }; empty tag_ids lists all, else OR-filter.
Created prompts carry a synthesized AIOTag so by_tags filtering is meaningful.
- Align the workspace-with-data prompt seed to AIOPromptWithStatus (name + tags,
was the stale `text` field).
- Repoint the E2E at tagged/by_tags with a real write-then-read assertion plus a
delete-reflects-in-list case.
- Update the README stateful-endpoints table.
Validated: npm test (51 passing, 100% coverage), npm run lint, npm run test:types,
npm run test:e2e (8 passing against the live mock with response validation on).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Switch SPEC from the raw Swagger 2.0 file to build/openapi3.json (the overlay-corrected OAS3 artifact produced by npm run generate). Two fixes follow from this: 1. Counterfact now sees CR1 (GET /v1/ai_models) and auto-generates a stub for it, closing the gap where that path was missing from the mock. 2. Counterfact honours Swagger 2.0 basePath as a serving prefix but ignores OAS3 servers[0].url. Add --prefix /enterprise/projects/api explicitly so all routes (stateful handlers + auto-generated stubs) are served under the correct base path and the real client resolves them correctly. Also add a fast-fail guard in materialize() that exits with a clear message when build/openapi3.json is absent, so developers get actionable feedback instead of a cryptic Counterfact error. README: update the Pipeline diagram and description to reflect the mock now reads the OAS3 artifact, document npm run generate as a prerequisite, and update the mock table entry. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
831bdf3 to
0ccd959
Compare
Seed entries use fixed ids. A seed-id collision via create() was silently swallowed via Map.set overwrite. Now throws on explicit id collision so the bug surfaces during dev instead of being masked. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Rebase + review round resolved. Rebase onto current `main` — the branch is now stacked cleanly on top of the squash-merged #1661 (`0465ab0`). Conflicts were mechanical (all from the foundation files now in `main`); resolved by keeping the overlay-inclusive `generate` script from `main` and the mock-runner additions from this branch.
Type-surface follow-up (same commit `0ccd959`): `test/types/index.type-test.ts` dropped the `Auth-Data-Jwt` header from the typed call — the overlay (CR2) already removes it from every operation in the generated surface, so passing it was a type error against the current `main` types. Inline nits (all in `cbe35f3` or `1f5c8cf`):
Process fix: PR description updated below to reflect the full 24-file / +1579 diff. Gates: `npm test` 98 passing 100% coverage · `npm run lint` clean · `npm run test:types` clean. |
…endencies Regenerated after adding sinon and typescript devDependencies to the project-engine-client package. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… cover duplicate-id guard - Add 'Generate corrected OAS3 artifact' step to the E2E job so the mock runner finds build/openapi3.json (gitignored) before it starts. - Add missing test for InMemoryStore.create duplicate-id guard to restore 100% coverage (lines 77-78 were uncovered, failing the coverage threshold). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main merged js-yaml@4.2.0 (#1704); project-engine-client's workspace entry was missing js-yaml@4.2.0 + argparse@2.0.1 from the lock file. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
rainer-friederich
left a comment
There was a problem hiding this comment.
Hey @aliciadriani,
Verdict: Request changes - solid, well-layered mock with a clean published-surface boundary and a faithful auth model; the blocking items are all small (a cross-cutting auth-guard hardening, two real test gaps, and a self-consistency fix), none are hard bugs.
Changes: adds a stateful Counterfact mock of the Semrush Project Engine API to project-engine-client (in-memory store, resource ops, typed factories, seeds, AI-unit quota, bearer auth, a live runner + route handlers, an in-package E2E), plus published-client @ts-check/type regen and a generated python model package. (58 files).
Must fix before merge
-
[Important] Auth-guard injection fails open on handler-style drift -
mock/run.js:111(injectAuthGuard). The bearer guard is injected by regex-matchingexport [async] function GET|POST|PUT|PATCH|DELETE($) {. Every committed handler matches (all 30 verified, so no live bug), but a future handler in another style (export const GET = ($) =>, destructured params, a newOPTIONS/HEAD) gets no guard and serves unauthenticated silently - andrun.jsis coverage-excluded, exercised only by the gated E2E. Fix: assert at materialization that every real-route method received the guard (throw on miss), or add a unit test enumeratingcounterfact/routes/**. -
[Important] Child-resource writes skip parent-project existence -
mock/counterfact/routes/v2/.../ai_models.js:25-34(and the v1 sibling / prompts / benchmarks).POST .../ai_modelscallsops.ai_models.addwith no check the project exists, returning 201 where the live API 404s. A consumer flow that adds a model to a deleted project passes the mock but fails prod - the exact bug class the mock exists to catch. Fix: a sharedrequireProject(scope)guard the child writers run first, or document the gap inmock-statefulness.md. -
[Important]
by_tagstag-filter branch untested even by the E2E -mock/counterfact/routes/v2/.../aio/prompts/by_tags.js:29-31. The E2E only callsby_tagswithtag_ids: [](the list-all branch); the filter branch - the consumer's actual "list by tags" read path, with OR semantics - is never executed, and handlers are coverage-excluded so nothing flags it. Fix: one test creating tagged prompts under two tags and asserting a non-emptytag_idsreturns only the matching subset. -
[Important] Published
src/internal.jsretry change shipped without a test -src/internal.js:153.result && typeof result.catch === 'function'becameresult instanceof Promise, a behavior tightening (a non-Promise thenable's rejection now escapes the swallow). The existing test stays green because a nativeasyncfn returns a real Promise, so the change is unpinned. It is defensible (out-of-contract input) but it ships in published code. Fix: add an assertion pinning the chosen behavior, or revert to the duck-typed thenable check. -
[Important] Factory-pattern doc/code inconsistency -
init_status.js:22({ initialized: true }),publish.js:32({ message: 'publish accepted' }),brand_urls.jsDELETE. These emit inline response literals, but the packageCLAUDE.mdstates the lone exception to the factory rule isci/competitors. Fix: addcreateBasicResponseMock/createInitStatusMockand route through them, or amend the CLAUDE.md exception list to name the scalar wrappers.
Non-blocking (13): minor issues and suggestions
- suggestion: CI workflow has no
timeout-minutes(project-engine-mock-e2e.yaml, jobruns-onat line 37) - a wedged boot inherits GitHub's 6h default; addtimeout-minutes: 15. - suggestion: E2E spawns the server with
stdio: 'ignore', so a boot/transpile failure surfaces only as the opaque "mock did not become ready in time"; capture child stderr and include the tail in the readiness-timeout error. - nit:
.nycrc.jsonexcludesrun.js+counterfact/routes/**(justified) - add a one-line comment pointing at the E2E as the compensating control. - suggestion: E2E teardown does
Promise.race([exited, sleep(5000)])but never escalates toSIGKILLif SIGTERM is ignored. - suggestion:
LIB_FILESinrun.jsis a hand-maintained list; it could self-validate by parsingcontext.js's sibling imports (fails loudly at boot today, but far from the edit). - nit:
ci/competitorsis the one handler still using an inline literal (CICompetitorhas no factory) - addcreateCiCompetitorMockto close the single-source-of-truth loop. - nit: package
CLAUDE.mdsaysglobalThis.cryptois typed via libdom, buttsconfig.jsonuses"lib": ["esnext"]; the type comes from@types/node. - suggestion: store deep-clone isolation has no test mutating a
create()/get()/list()return and re-reading (the "mutate a returned entity must not corrupt the store" case); the clone path is covered, so this is a hardening gap. - nit:
__quotawith a missingworkspaceIdcallsquota.set(undefined, ...)and returns{ id: undefined }; a one-line 400 guard would make the control route self-explaining. - nit: E2E
baseUrluseshttp://localhost:${port}whilepickPortbinds on127.0.0.1; pin both to127.0.0.1to drop a DNS variable. - suggestion:
auth.js- add a whitespace-only-token canary test ('Bearer ') and a comment that.trim()is what makes a trailing-whitespace token fail, locking the.trim()+\Scontract. - suggestion: the E2E readiness probe could hit the auth-exempt
/__dumpinstead of a seeded route, removing theBearer readiness-probeworkaround and the seed dependency. - nit:
python/serenity_project_engine/http_server.pyis an inert datamodel-codegen pydantic model (no server) - the filename misleads.
Out of scope, worth tracking
- The api-service consumer
getInitStatuscalls the dead/v1init_status path (degrades toinitialized: null); the mock faithfully reproduces the live 404 (overlay CR8). The real fix is a spacecat-api-service change - worth a tracking ticket so this PR's CR8 note isn't the only record. - The E2E proves mock-vs-spec, not consumer-wiring end-to-end; that lands with the adoption PR.
No Critical issues, and the architecture/layering, published-surface boundary (files: ["src"], counterfact is a devDependency), supply chain, and bearer-auth model all reviewed clean (the auth model was independently confirmed faithful). The Request-changes state is driven entirely by the 5 Important items above, all fixable on-branch.
Addresses the multi-agent /pr-review findings (no published src behaviour change): - Auth-guard injection now fails LOUD: injectAuthGuard counts every exported HTTP method and throws at materialization if any didn't match the guard pattern, so a future handler in an unmatched shape can't silently ship unauthenticated. - Every route handler now builds its response via a factory (no inline literals): added createBasicResponseMock / createInitStatusMock / createCiCompetitorMock and routed publish, benchmarks delete/update, brand_urls delete, init_status, and ci/competitors through them — the lone factory exception is gone. - by_tags filter branch is now exercised: new E2E asserts a non-empty tag_ids returns only the matching prompts (the consumer's real read path). - Pinned the src/internal.js retry tightening (instanceof Promise): a non-Promise thenable return is left untouched (.catch not invoked). - Documented the deliberate "child writes don't require a live parent project" fidelity simplification in mock-statefulness.md (quota meters per workspace). - Hardening: CI job timeout-minutes, E2E captures+reports server stderr on a readiness timeout, SIGKILL escalation, 127.0.0.1 probe; __quota 400 on missing workspaceId; store clone-isolation + auth whitespace-token tests; CLAUDE.md crypto/lib and factory-list fixes. Gates: types, lint, unit 154 @ 100%, e2e 30. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressed the local review (the MysticatBot-equivalent pass — this PR's 4900-LOC diff is over the bot's limit) in Must fix before merge (all 5 addressed)
Non-blocking
Out of scope, worth tracking
|
All 5 blocking findings addressed in 4bd7c51 (see consolidated comment); gates green (types, lint, unit 154 @ 100%, e2e 30).
Re-review (6-agent local pass) of the post-fix tree. All prior fixes verified;
addressed the new findings:
- docs(mock-statefulness): the design-of-record understated the shipped stateful
set (listed 3, ships 5). Add benchmarks + brand_urls to the consumer-inventory
table and the confirmed-set line so the doc matches STATEFUL_RESOURCES; note the
existing_count=0 fidelity simplification.
- mock: extract injectAuthGuard into its own pure module (mock/inject-auth-guard.js)
so the auth-bypass safety net is unit-testable (run.js launches the server at
import and is coverage-excluded). Broaden the declared-method count to every shape
the comment claims it catches — arrow `export const VERB =` and OPTIONS/HEAD now
trip the count-vs-guarded throw, so the guarantee matches the code. Add
test/mock/inject-auth-guard.test.js (100%).
- test(e2e): drive the benchmark PUT (updateBenchmark) — the only consumer-critical
stateful route the suite did not exercise (create -> PUT brand_aliases -> list).
- mock(quota): correct the over-allocation comment — the mock returns the 405 with a
JSON `{ message }` body, not an nginx-style body; the consumer keys on the status.
- docs(CLAUDE): note this package enforces branches:100 (vs monorepo-standard 97%)
and the coverage exclusions; clarify ./mock/* is workspace-symlink-only, never
published.
- docs(mock-usage): add a bind-to-localhost caveat for the unauthenticated __* routes.
Gates: types, lint, unit 162 @ 100%, e2e 31.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Ran a second local multi-agent review (6 agents: staff-engineer, architect, project-conventions, security, sre, qa) over the post-fix tree (this PR's ~4.9k-LOC diff is over the MysticatBot limit, so the local pass is the stand-in). All five prior-review fixes were independently verified as correctly resolved. The new findings are addressed in Addressed:
Declined / no change:
Gates after |
…ite/202 shapes
Live cross-check against the test Semrush workspace (adobe-hackathon, ws
c522f571…, verified 2026-06-25) surfaced mock-handler and one swagger gap
versus the real Project Engine API:
- createProject: the live API returns a draft ProjectResponse with the request
nested under settings.ai (live_id/draft_id mirrored, is_draft/publish_status
set), NOT a flat echo of the request body. The handler now maps the request
through a new typed factory (createProjectResponseFromRequest) so a
create-then-read matches live; the old `{...body}` echo leaked request-only
fields (country_code/language_id/…) and dropped settings.
- 202 action acks (publishProject, deleteBenchmarks, updateBenchmark,
deleteBrandUrls): live returns an EMPTY body (content-length 0; the swagger
declares no 202 schema). Handlers now return an empty 202 instead of a
BasicResponse envelope.
- addAiModel: live resolves the catalog model's icon onto the response;
createAiModelMock now carries icon.
- Overlay CR9: model.AISettings gains primary_url — the live settings.ai
returns it on every project but the vendored swagger omits it (the only
genuine swagger gap; all other divergences were mock-handler bugs, not
schema errors). Regenerated src/generated/types.ts.
- Corrected the CR5 comment (addAiModel does return name+icon, only key is
empty) and the stale "three resource groups" note in stateful.js.
Tests: new factory unit + type tests (100%/branches:100 held), e2e pins the
nested create shape, the icon on add, and the empty-body 202s (31 passing).
Not changed: createTaggedPrompts/publish nginx-405 on the hackathon tenant
(environment route-gating, not the prod contract) and the brand-topics
missing-param 400 (request validation is intentionally off in the mock).
Introduced by: N/A
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Live fidelity round 2 — write endpoints + a real swagger gap Cross-checked the mock against the real Semrush Project Engine API (test workspace Mock-handler fixes (the swagger was already correct for these — the bugs were in the materialized handlers):
Overlay fix (the one genuine swagger gap):
Also corrected the CR5 comment (the add path does return Deliberately NOT changed (verified environment/design, not mock bugs):
Gates: |
…country query param
Live `GET /v1/.../brand-topics` requires both `domain` and `country` query params
(the swagger marks both required) and 400s with
`{ message: '<param> query param is required' }` when one is absent — verified live
2026-06-25 (no-domain -> 400). The mock disables request validation globally, so a
single in-handler guard reproduces that 400 (the prior handler served 200 regardless).
The consumer always passes both, so no flow regresses; this just makes the missing-param
path 1:1 with live. e2e pins both the no-domain and no-country 400s.
Introduced by: N/A
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Follow-up to the round-2 comment — Revisiting the two items I'd flagged as "deliberately not changed":
Gates after this change: lint ✓, |
…rfact mock
The runner launched with `--no-validate-request` because the vendored spec
declared a required `Auth-Data-Jwt` HEADER param that Counterfact matched
case-sensitively against Node's lowercased inbound headers, 400ing every
request. CR2 removes that header from every operation (and it was the only
header param), so the casing problem is gone and request validation is safe to
enable. The overlay-corrected spec is now the request contract: a missing
required query param (brand-topics `domain`/`country`) or body field
(project-create `type`) gets a 400 before the handler runs, matching live —
spec-driven enforcement across every endpoint, replacing the per-handler
brand-topics guard added earlier.
- run.js: drop `--no-validate-request`; rewrite the rationale comment.
- brand-topics handler: remove the now-redundant in-handler 400 guard
(validation 400s first). The live exact-body `{message}` is traded for the
validator's generic message; status is the contract the consumer keys on.
- e2e: send the required `type` on every project create/patch and the required
`draft` query param on getProject (the real consumer already sends both);
add cases pinning the query-param 400 and the body-field 400 (33 passing).
- docs: mock-usage notes both request + response validation are on.
Introduced by: N/A
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Request validation now enabled — spec-driven, replacing the per-handler guard ( You asked whether the brand-topics 400 should be spec-driven rather than a hand-coded guard. It should — and the blocker that kept request validation off turned out to be stale, so I enabled it properly. The old reason ( What this gives us (matching live, across every endpoint, not just brand-topics):
So I removed the per-handler brand-topics guard — the validator 400s first now. (Trade-off: the 400 body is Counterfact's generic validator message, not the live Enabling it surfaced a few under-specified requests in the e2e — exactly what validation is for — all now fixed to match what the real consumer already sends:
I probed all 15 write endpoints; only those (plus brand-topics' query params) were under-specified — the other 13 bodies validated as-is, and the consumer's Gates: lint ✓, |
rainer-friederich
left a comment
There was a problem hiding this comment.
Hey @aliciadriani,
Verdict: Request changes - the shipping mock code is correct and live-verified; two small, in-scope gaps are worth closing first (a test that doesn't lock a contract it claims to, and a seed helper that can't express two of its own resource types).
Changes: adds a stateful Counterfact mock (store, stateful ops, typed factories, seeds, quota meter, bearer-auth guard, route handlers, runner), a spec overlay, generated types, unit + gated-E2E tests, docs, and a path-gated CI job to spacecat-shared-project-engine-client (64 files).
Note: this PR's diff (~5.3k LOC) is over the MysticatBot limit, so this is the local multi-agent stand-in (7 reviewers). CI is green.
Must fix before merge
- [Important] E2E asserts state only for delete-benchmarks / update-benchmark / delete-brand-urls - the empty-body 202 contract is locked only for
publish-packages/spacecat-shared-project-engine-client/test/e2e/project-engine-mock.e2e.js:516(details inline) - [Important]
buildSeedcan't seed benchmarks/brand_urls despite its docstring's "mirror the rows it inserted into Postgres" promise -packages/spacecat-shared-project-engine-client/mock/seeds.js:143(details inline)
Non-blocking (9): suggestions and nits
- suggestion: derive
LIB_FILESfromcontext.js's actual imports (or assert the_lib/set equals them after materialize) instead of the hand-maintained list - a missing entry fails the E2E boot, but the manual sync is a footgun the next contributor will hit -mock/run.js:87. - suggestion:
QUOTA_COLLECTIONtest asserts the constant equals'quota'(tautological); insteadquota.set(...)thenstore.list(QUOTA_COLLECTION)to prove they're wired at runtime -test/mock/quota.test.js. - suggestion:
factories.type-test.tstype-asserts only 5 of 12 factories; addcreateBenchmarkMockandcreateBrandUrlMock(the ones the overlay drift-guard exists for) -test/types/factories.type-test.ts. - suggestion: add a
push: { branches: [main] }trigger (or keep the documented PR-only-signal intent on purpose) so the live-E2E + type-surface check also run post-merge, not just on PRs -.github/workflows/project-engine-mock-e2e.yaml:16. - nit:
beforeEach'sPOST /__resetthrows an opaque "fetch failed" if the mock died mid-suite; guard on the existingserverExitedflag for an actionable message -test/e2e/project-engine-mock.e2e.js. - nit: copy the lib files with
readFileSync(join(here, file), 'utf8')- the current call returns a Buffer; it works only becausewriteFileSyncwrites the bytes -mock/run.js:133. - nit: one-line comment on
GUARDABLE_METHODexplaining whyOPTIONS/HEADare inDECLARED_METHODbut intentionally absent here (the fail-closed asymmetry) -mock/inject-auth-guard.js. - nit: the PR body "## Validation" counts (147 unit / 29 e2e) are stale vs HEAD (the latest fix round reports 165 unit / 33 e2e); refresh them.
- nit:
quota.sethas a read-then-write that's a TOCTOU only if the store ever becomes multi-writer; a one-line note on the single-writer invariant would age well -mock/quota.js.
Out of scope, worth tracking
- The overlay (
corrections.yaml, 9 corrections) is verified manually and timestamped inline; there is no automated freshness gate, so a future upstream Semrush change can silently diverge from a correction. A scheduled contract test against the sandbox workspace would close the loop - belongs with the adoption PR / the Jira epic, not here. - CI uses floating action tags (
actions/checkout@v6, etc.); this matches the repo-widemain.yamlpattern, so SHA-pinning is a repo-wide pass, not this PR. - The e2e job passes
MYSTICAT_DATA_SERVICE_REPO_READ_TOKENthrough the sharedsetup-node-npmaction (same asmain.yaml). It's the monorepo install convention; if the rootnpm ciprovably doesn't need it for this workflow it could be dropped, but that's an action-level change, not introduced here.
The architecture is sound and the review surfaced no bugs in shipping code: the generic-store -> stateful-ops -> typed-factories -> Counterfact-handlers layering is clean, the auth guard is fail-closed at the single materialization seam (the prior fail-open drift bug is fixed and the drift vectors are unit-proven), the overlay-as-source-of-truth with a gitignored build artifact is the right call, and the 100%-unit / E2E-for-the-server-surface split is the correct trade-off for a mock that can't run under npm test. The live-fidelity verification (timestamped against a real workspace) is exemplary.
| // seeded own-brand + the new competitor | ||
| expect(listed.aio_benchmarks.map((b) => b.id)).to.include(created.ids[0]); | ||
|
|
||
| await client.DELETE('/v1/workspaces/{id}/projects/{project_id}/ai_models/benchmarks', { |
There was a problem hiding this comment.
issue (blocking): The empty-body 202 contract is locked only for publish (the raw-fetch expect(await rawPub.text()).to.equal('') at ~line 600). The comment at line 594 says delete-benchmarks, update-benchmark, AND delete-brand-urls all return a 202 with an EMPTY body, but those three tests assert state only - client.DELETE/client.PUT swallow the response body, so a regression that drops body: '' and returns a { message } envelope (the exact Counterfact {status:202} -> "Accepted" gotcha this PR fixes) would still pass. These handlers are coverage-excluded, so the E2E is their only gate. Add a raw-fetch status === 202 && (await res.text()) === '' assertion to the benchmark-delete, benchmark-PUT, and brand-url-delete cases, mirroring the publish test.
There was a problem hiding this comment.
Fixed in 81ff578f — the delete-benchmarks, update-benchmark (PUT v1), and delete-brand-urls e2e cases now raw-fetch and assert status === 202 && (await res.text()) === "", matching the publish lock. A regression to a {message} body now fails the suite.
| * quota?: { projects?: number | null, prompts?: number | null } }} spec | ||
| * @returns {import('./store.js').Snapshot} | ||
| */ | ||
| export function buildSeed({ workspaceId, projects = [], quota }) { |
There was a problem hiding this comment.
issue (blocking): buildSeed's docstring says it lets a caller "mirror the rows it inserted into Postgres," but SeedProject only accepts aiModels and prompts - benchmarks and brand_urls (both now part of the stateful set, and both present in WORKSPACE_WITH_DATA above) can't be expressed. The cross-repo harness this helper exists for would have to hand-write raw collectionKey(...) snapshot JSON, bypassing the factory drift-guard. Either extend SeedProject with optional benchmarks/brandUrls routed through collectionKey (the same way aiModels/prompts are handled on lines 152-153), or narrow the docstring to state the helper covers projects/ai_models/prompts only.
There was a problem hiding this comment.
Fixed in 81ff578f — SeedProject now accepts benchmarks and brandUrls (grouped by benchmark id), routed through collectionKey like aiModels/prompts. Added a seeds.test case that loads both and reads them back via the stateful ops.
…handler The only consumer (spacecat-api-service rest-transport `addAiModel`) adds AI models via the v2 POST; on the v1 ai_models path it uses GET (list) + DELETE only (confirmed against api-service origin/main — its own docstring states "v2 ai_models is POST-only ... list/delete stay on v1"). The v1 POST was dead surface that returned 200 with no consumer, contradicting the consumer-driven floor in docs/mock-statefulness.md. A v1 POST now 404s in the mock. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…r settings.ai
Live-verified updateProject (PATCH /v1/.../projects/{id}) against the real
Semrush API (test workspace, 2026-06-25): it returns 200 with the full updated
draft ProjectResponse (not a 202 ack), and a flat ProjectUpdateRequest body
(brand_name_display, brand_names) is reflected NESTED under settings.ai, never
echoed at the top level. The mock's PATCH handler shallow-merged the flat body,
so those fields landed at the top level while settings.ai kept stale values —
wrong for the consumer's only PATCH use (the brand-alias re-sync). The e2e
missed it because it only patched `name` (genuinely top-level).
Adds the typed `applyProjectUpdate` factory (mirrors createProjectResponseFromRequest
field placement), wires the PATCH handler through it, and extends the e2e to
patch brand fields and assert they nest under settings.ai (and are absent at the
top level). Unit tests keep factories.js at 100% branches.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…enchmarks, harden mock tooling Addresses the local multi-agent review on #1665: - e2e: lock the empty-body 202 contract for delete-benchmarks, update-benchmark (PUT), and delete-brand-urls via raw fetch (previously only publish asserted it; the typed client swallows the body, so a regression to a {message} body passed). - buildSeed: support `benchmarks` and `brandUrls` on a SeedProject (both are part of the stateful set and the cross-repo harness needs to seed them); routed through collectionKey like aiModels/prompts. - run.js: derive LIB_FILES from the actual mock/*.js files instead of a hand-maintained list (a new context.js import is now materialized automatically); read lib sources as utf8. - quota.test: replace the tautological QUOTA_COLLECTION==='quota' assertion with a behavioral one (set → store.list(QUOTA_COLLECTION) reflects it). - factories.type-test: add createBenchmarkMock/createBrandUrlMock/applyProjectUpdate. - e2e beforeEach: fail with a clear message if the mock died mid-suite. - inject-auth-guard: document why OPTIONS/HEAD are fail-closed (counted, not guarded). - quota.set: note the single-writer (no-TOCTOU) invariant. - CI: add a push:[main] trigger so the type-surface check + live E2E also run post-merge, not only on PRs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressed the local review in Must fix (both done)
Non-blocking (all addressed)
Out of scope, left tracked (not in this PR)
Bonus (from the follow-up audit you asked for, beyond the review)
|
…ct stale auto-stub claims The mock runner serves with `--serve` (no `generate`): unmodelled paths 404, nothing falls back to a Counterfact random stub. Five places still described the old, rejected auto-generated-fallback behaviour and contradicted the runner — a trap for an extending agent (skip modelling an endpoint expecting a free stub, get a 404 instead). Corrected in mock/stateful.js, mock/store.js, README.md, docs/mock-usage.md, docs/mock-statefulness.md. Added two sections to docs/mock-usage.md: - §9 Extending the mock — a numbered add-an-endpoint recipe (spec/overlay → routes file mirroring the URL → canonical `export function VERB($)` shape the auth-guard asserts on → factory-built entities → stateful ops → empty-202 contract → e2e case → gate). - §10 Capturing real API responses — the live-fidelity workflow against the adobe-hackathon stage tenant (token via `mysticat auth token --ims`, curl -i to spot empty-body 202s), how to fold a captured shape into a typed factory / overlay CR, the write-auth + trap cleanup rule, and the don't-mirror-tenant-quirks (nginx 405) caveat. Doc/comment-only; no behaviour change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…no hardcoded ids, real 405 cause The validation tenant is a PROD Semrush workspace, not stage. Replace the hardcoded workspace UUID + base URL with where to resolve them each time: the prod base URL from Vault (dx_mysticat/prod/api-service SEMRUSH_PROJECTS_BASE_URL), and a test workspace id from the Semrush UI / prod DB / the user-manager family endpoint. Also correct the 405 caveat: it was "payment required" (the workspace had no AI-unit allocation — units must be transferred from the parent workspace), NOT nginx gateway route-gating. That 405 is the metered-quota gate the mock already reproduces (§6), so the note now says to allocate units before capturing a success response, not to ignore it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rls list 404 quirk
Closed the live-verification gap on the metered write paths by replaying them against a
funded prod test workspace (throwaway projects, trap-cleaned, zero residue). Every mock
handler matches live — no handler change needed. Two doc-only corrections:
- mock-usage §10: createProject returns 201 (not 200); pin the full live-verified status
set (createTaggedPrompts 201 {ids,existing_count}, createBenchmarks/createBrandUrls 200
{ids,existing_count}, publish/delete-acks empty 202, deletePromptsByIds 204).
- mock-statefulness: record a known simplification — live GET brand_urls 404s on a
non-main-brand benchmark (POST to the same id succeeds); the mock returns 200 {brand_urls}
for any id, which is fine since the consumer only lists on the main-brand benchmark.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ks fresh-project brand_urls proof)
Live, a project's main-brand benchmark generates asynchronously — it did not appear within
~60s of create, even after a publish — so listBrandUrls (populated) / deleteBrandUrls can't be
ground-truthed on a fresh project in-session. createBrandUrls 200 {ids} is confirmed and
deleteBrandUrls is the same handler as the confirmed deleteBenchmarks empty-202.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…+ project_id to match live (CR10) A live listBenchmarks item always returns primary_url + root_domain (the benchmark domain's URL/registrable root) alongside project_id — verified live 2026-06-25 against a real project's main-brand + competitor benchmarks. primary_url/root_domain were absent from the vendored swagger (the CR9 pattern); project_id was in the schema but the mock never populated it. - overlay CR10 adds primary_url + root_domain to model.AIOBenchmarkWithCounters (optional, like CR9); regenerated types.ts + pydantic models. - createBenchmarkMock defaults primary_url/root_domain off the effective domain and defaults project_id; the listBenchmarks handler routes stored rows through the factory and stamps the path project_id, so every listed benchmark is faithful (created or seeded). - type-test guards the new fields (compile fails if CR10 is dropped); unit + e2e assert them. Verified: test:types, lint, unit 100% (branches 100), e2e 33 passing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… caveats to §10 testing guide Two caveats that bit the live verification but weren't in the "how to test the API" section: - Live is eventually-consistent: create-then-immediately-read can look empty/404 (a just-POSTed prompt/brand-url isn't listed yet; the main-brand benchmark generates asynchronously, >60s). Capture populated reads against a settled pre-existing project, not a fresh one. The mock is immediately-consistent on purpose, so this is a capture-time concern only. - Source the request method/path/body from the consumer's rest-transport on origin/main (not a stale worktree); a metered create needs a funded workspace + a well-formed body (real language_id / location_id) or it 400s before you see the shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… "replicating async" guidance - Fix an overstatement: the consumer does NOT only list brand URLs on the main-brand benchmark. ensureOwnBrandBenchmark can create its own competitor benchmark (when main-brand is absent) and list on that, which live 404s until processed; the per-market try/catch catches it and skips. - Add a "Replicating live async behaviour" section: model the observable scenarios deterministically via seed/control state (absent main-brand benchmark; opt-in unprocessed-benchmark 404), never via timers — determinism is the point of a test double; these scenarios belong in the consumer e2e. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ecipe for sibling clients) Captures everything this PR did — spec→types overlay pipeline, mock architecture + conventions, coverage/test layout, and the live-fidelity validation methodology (replay the consumer's calls against the real Semrush API, funded prod workspace, trap-cleanup, overlay CRn for live-only fields, eventual-consistency caveats) — plus an exact checklist to repeat it for user-manager-client (PRs #1708/#1685, the /enterprise/users/api lifecycle gateway). Includes the eslint convention note. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…icable recipe for sibling clients)" The replication recipe is a team handover/runbook, not package documentation that ships with the repo — moving it to a local (gitignored) handover doc instead. The in-repo mock-usage.md and mock-statefulness.md remain as the package's own docs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hmarks re-derive, fix stale LIB_FILES docs Local /review-kit:pr-review on PR #1665 (re-review of the CR10 commit). Code reviewers passed; findings addressed: - [Important] Stale "must add to LIB_FILES" instruction (auto-derived since the run.js change) contradicted the PR's own §9 — fixed in CLAUDE.md, docs/mock-usage.md §8 + §11. - listBenchmarks GET now force-re-derives primary_url/root_domain off the CURRENT domain (drops any stored value), fixing a latent staleness if a PUT ever changed a benchmark's domain, and making the handler genuinely lockable. New e2e seeds a benchmark whose stored URLs diverge from its domain and asserts the list re-derives them (the handler is coverage-excluded, so the e2e is its only gate). - Doc/comment nits: reconciled the CR10 "registrable root" claim (the mock mirrors the full domain, no extraction) in the overlay + factory; §10 extend-recipe now references CR10, not CR9. Gates: lint, types, unit 170 @ 100% (branches 100), e2e 34 passing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
What
The stateful Project Engine Counterfact mock — LLMO-5460 (epic LLMO-5459). A generic in-memory store + the confirmed stateful resource ops, the live Counterfact runner that wires them into per-path handlers, named seeds, typed mock factories, AI-unit quota metering, bearer-auth enforcement, a test-only control surface, and an in-package E2E that drives the real client against a booted mock.
Now based directly on
main(the earlier stack #1660 → #1661 has merged).Spike outcome (confirmed against both consumers)
Grepped the real consumers to set the AC floor (
docs/mock-statefulness.md):spacecat-api-serviceis the only Project Engine consumer;llmo-data-retrieval-servicemakes zero calls.rest-transport.js) and the spec: create isPOST .../aio/prompts/tagged, list isPOST .../aio/prompts/by_tags, delete isDELETE .../aio/prompts.Contents
Mock library (
mock/)store.js— genericInMemoryStore: collection-keyed CRUD + seed/reset, deep-clone on every read/write (a loaded seed is never mutated;reset()restores a pristine copy). Throws on explicit id collision.stateful.js— the confirmed stateful set as pure ops over the store (per-workspace/per-project scoping). No Counterfact/HTTP coupling, so unit-tested directly.factories.js— the typed mock-factory layer:createProjectMock,createProjectAiModelMock,createAiModelMock,createPromptMock,createBenchmarkMock,createBrandUrlMock,createLanguageMock,createTagNodeMock,createBrandTopicMock— each(Partial<T>) => Ttyped against the generated schemas. The single tsc-checked source of truth for every entity shape the mock emits.seeds.js— named seed sets (empty-workspace,workspace-with-data),DEFAULT_SEED,SEED_IDS, all built through the factories.quota.js— AI-unit quota meter (the disguised-405 the live API returns on over-allocation), enforced on project create / prompt write / publish; configurable viabuildSeed({ quota })or thePOST /__quotacontrol route.auth.js— bearer-auth model: every real route requiresAuthorization: Bearer <token>(presence, not validity) or returns401 { detail: 'Not authenticated' }, exactly like the live gateway; the__*control routes are exempt.context.js— the Counterfact per-path Context: wires the pure store + ops + factories + quota + auth to a seed selected at startup and exposesreset().run.js— the live runner. Materializes the committed handlers into a gitignored.counterfact/tree (as.ts), injecting the bearer-auth guard onto every handler at the single materialization seam (injectAuthGuard, so no handler can forget it), and launches Counterfact with--serveagainst the corrected OAS3 artifact (build/openapi3.json) under--prefix /enterprise/projects/api.npm run generateis required before first boot.npm run mock.Counterfact route handlers (
mock/counterfact/routes/)v1/workspaces/{id}/projects(GET/POST),.../{project_id}(GET/PATCH/DELETE),.../ai_models(GET/POST),v2/.../aio/prompts/tagged(POST create),.../by_tags(POST list),.../aio/prompts(DELETE).ai_models+ai_models/benchmarks,aio/benchmarks/{benchmark_id}/brand_urls,aio/tags,brand-topics,ci/competitors,publish,aio/init_status.v1/ai_modelsandv1/languagesreturn the full live taxonomy (11 global models, 38 languages — real ids/keys/icons/codes, captured verbatim) mapped through the factories.__*, auth-exempt):__reset(seed restore),__seed(load a named seed),__quota(set quota),__dump(inspect store state).Tests, docs, CI
test/mock/*.test.js— unit tests for store, stateful ops, factories, seeds, context, quota, and auth (100% coverage;counterfact/routes/**+run.jsexcluded).test/e2e/project-engine-mock.e2e.js— boots the live mock and drives it through the real client; gated behindMOCK_E2E=1, outside the defaultnpm testglob. Exercises the prompt write-then-read spine (tagged → by_tags), the quota 405, and the bearer-auth 401, with response validation on.test/types/*.type-test.ts— compile-only guards: the public type surface stays assignable toClient<paths>, and the factories stay schema-faithful (workspace_iddrift + wrong-field-type are@ts-expect-errorcanaries).docs/mock-usage.md— usage manual for humans + agents (quick start, bearer auth, full endpoint inventory, seeds, control routes, quota, driving from tests, internals, troubleshooting).spec/overlays/corrections.yaml— spec corrections (the vendored swagger is never edited);build/openapi3.jsonis regenerated from it.E2E (project-engine mock)job (type-surface check + live E2E), scoped via a merge-base diff so it only runs when this package changes.Validation
npm test— 169 passing, 100% statements / branches / functions / linesnpm run lint— cleannpm run test:types— cleannpm run test:e2e— 33 passing against the live mock (response validation on)Follow-up (separate PR)
Adopting the mock into
spacecat-api-service's own E2E/local-dev (running its Project Engine calls against this mock) lands with the adoption PR — out of scope here, which delivers the mock and its self-contained E2E. The pre-existing consumer buggetInitStatushits the v1aio/init_statuspath (404s live; v2 is correct) and is tracked for that adoption PR.🤖 Generated with Claude Code