Producer contract tests (WING-324)¶
Why this exists¶
Three producer/consumer drift incidents shipped in one week, all with green test suites:
| Incident | Drift | Symptom |
|---|---|---|
| WING-305 | Consumer read features['allowPersonaSeeds'].status; server sends a top-level boolean |
Persona picker 100% unreachable on every edition — gate failed closed, silently |
| WING-318/319 | API sends personaKey; frontend interface declared key |
persona.key always undefined: empty confirm dialog, every card marked DEFAULT |
| WING-315 | Server added experience to JobQueryItem; client ITEM_KEYS allowlist never updated |
Fail-closed validator rejected every non-empty /api/job-query page |
One root cause: the consumer's declared shape was written from the consumer's own assumptions, and its tests pinned fixtures built from those same assumptions. A hand-built fixture encoding the same wrong nesting as the code is not a test — it is the bug asserting itself.
The convention¶
Every DTO/JSON boundary a frontend consumer depends on (server→client above all) gets a producer contract suite with four mechanical parts:
- Verbatim fixture. At least one payload captured byte-for-byte from the running
producer, checked in under
frontend/src/app/testing/producer-fixtures/as aProducerCapturewhoseendpoint,capturedAt, andoriginfields are mandatory — a fixture with no provenance is visibly illegitimate. Never hand-edit a fixture; re-capture. - Acceptance. The suite drives the consumer's real parsing/validation over the fixture and asserts it accepts (for allowlist validators: parses to completion; for interfaces: the consumer's field paths yield usable values).
- Key-set equality.
expectKeySetMatchdiffs the payload's own keys (union across items for arrays) against the consumer's declared interface keys or runtime allowlist. Any new or renamed producer field fails the suite — in both directions: an unexpected payload key and a declared key the producer no longer sends are each a failure. Interface key lists are declared withdeclareInterfaceKeys<T>(), which is compile-time exhaustive, so the runtime list cannot itself drift from the interface. - Negative pin. Assert the historical wrong shape is absent (e.g.
featureshas noallowPersonaSeedsentry; persona cards have nokey), so the exact past misread can never return green.
Known limits and knobs:
- Optional interface fields the producer may omit must be listed in
options.optionalwith a why-comment at the call site — key-set equality is exact in both directions by design (a declared key the producer never sends is treated as a stale declaration until you say otherwise). Equality being stricter than a fail-closed allowlist is deliberate: extra allowlist entries are harmless to the app but are exactly how WING-315's "fix that never landed" class of drift hides. - Key-set equality does not catch value-type drift (
true→"true") or nested DTO shape changes. The acceptance leg covers what the consumer's parser type-checks; add explicittypeof/enum asserts for gate fields the parser does not validate. - Single-edition captures cover a single edition. The
/capabilitiesfixture is the desktop edition by deliberate scope; cloud editions ship a different envelope and need their own capture (or a documented gap) before a cloud-side gate leans on this suite. - The
Origin: http://localhost:4200header used below is a dev-only trust affordance ofRequireLocalToken(any local process can forge it; browsers cannot). It is a capture convenience, not an API contract.
Helper: frontend/src/app/testing/producer-contract.ts. Existing suites (read these as
templates):
frontend/src/app/core/job-query-client.contract.spec.ts— POST/api/job-query(allowlist seam; pins the exported realJOB_QUERY_*_KEYS, not a copy)frontend/src/app/core/runtime-capabilities.contract.spec.ts— GET/capabilities(capability-gate seam — the highest-risk kind: a wrong field path fails closed and silently hides the feature)frontend/src/app/core/demo-personas.contract.spec.ts— GET/api/demo/personas
Capture procedure¶
Run the real producer in isolation and save exactly what it serialized:
# 1. Isolated desktop-edition API on a scratch port, in the SANDBOX environment.
# WARNING: overriding the USERPROFILE env var does NOT isolate anything — .NET resolves
# SpecialFolder.UserProfile through the Windows known-folder API, which ignores the env
# var, so the API silently reads/writes your REAL ~/Wingman/data (this exact mistake put
# a real saved-jobs page into a fixture once; it was caught at review). The supported
# seam is `--WorkWingman:Environment=sandbox`, which routes every path to
# ~/Wingman/sandbox-data (see AppEnvironment).
dotnet run --project src/WorkWingman.Api --no-build \
-- --WorkWingman:ApiUrl=http://127.0.0.1:5599 --WorkWingman:Environment=sandbox
# 2. Capture. RequireLocalToken endpoints trust the dev origin — send
# Origin: http://localhost:4200. Seed state first when the seam needs data
# (e.g. load a demo persona so /api/job-query has rows).
curl -s http://127.0.0.1:5599/capabilities
curl -s -H 'Origin: http://localhost:4200' http://127.0.0.1:5599/api/demo/personas
curl -s -H 'Origin: http://localhost:4200' -H 'Content-Type: application/json' \
-d '{"personaKey":"veteran"}' http://127.0.0.1:5599/api/demo/load-persona
curl -s -H 'Origin: http://localhost:4200' -H 'Content-Type: application/json' \
-d '{"bucket":"SavedJobs","offset":0,"limit":200}' http://127.0.0.1:5599/api/job-query
Then paste the response unmodified into a ProducerCapture fixture and fill in endpoint,
capturedAt (ISO date), and origin (edition/config, seed state, exact request — enough for
anyone to re-capture). Demo-persona seeds are the standard data source: realistic, deterministic,
and PII-free.
Rules:
- Never capture /api/job-query (or any user-data seam) from a real workspace. A real
SavedJobs page is the user's private job search. Capture only from the sandbox environment,
and verify before committing that every item is
isDemo: true(grep -c '"isDemo": true'is the receipt). - Never trim or prettify values. Reformatting whitespace via
JSON.stringifyis fine; the key set and every value must be exactly what the producer sent. - Cloud editions: capture against a local server-edition run or a staging tenant — never production customer data.
- When a contract test goes red on purpose (you changed the server): update the consumer, then re-capture the fixture from the updated producer in the same PR. Updating the fixture by hand to match the consumer defeats the entire mechanism.
When to add one¶
- Any new server endpoint a frontend consumer parses.
- Any capability/permission/feature-flag gate (highest risk: wrong field paths fail closed and silently hide features).
- Any fail-closed allowlist validator (second-highest risk: one new producer field rejects everything).
- Retroactively, whenever a "feature doesn't show up" bug turns out to be a consumer field-path or allowlist mismatch — the fix PR must include the contract suite that would have caught it.
Related standing rules: "Contract tests — fixtures must come from the producer" and "No claim without a receipt" (CLAUDE.md).