Skip to content

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:

  1. Verbatim fixture. At least one payload captured byte-for-byte from the running producer, checked in under frontend/src/app/testing/producer-fixtures/ as a ProducerCapture whose endpoint, capturedAt, and origin fields are mandatory — a fixture with no provenance is visibly illegitimate. Never hand-edit a fixture; re-capture.
  2. 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).
  3. Key-set equality. expectKeySetMatch diffs 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 with declareInterfaceKeys<T>(), which is compile-time exhaustive, so the runtime list cannot itself drift from the interface.
  4. Negative pin. Assert the historical wrong shape is absent (e.g. features has no allowPersonaSeeds entry; persona cards have no key), 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.optional with 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 explicit typeof/enum asserts for gate fields the parser does not validate.
  • Single-edition captures cover a single edition. The /capabilities fixture 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:4200 header used below is a dev-only trust affordance of RequireLocalToken (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 real JOB_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.stringify is 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).