---
name: external-api-client-self-confirming-fixture-trap
description: |
  Catch the class of bug where an external-API client + its DTOs are built
  against an IMAGINED response/request schema (wrong field names, wrong
  endpoints) yet a full unit suite is green — because the tests use
  self-authored mock fixtures with the SAME imagined shape the implementation
  assumes. The tests prove internal self-consistency, not that the client
  matches the real provider. Use when: (1) building or reviewing a client for
  any external API (Everee, QuickBooks, Shopify, WhenIWork, Twilio, Ably,
  OpenAI, Stripe, etc.), (2) a typed DTO accessor (getId/getStatus/getName)
  silently returns null in production while unit tests pass, (3) a POST/PUT
  "create" call 404s/405s against the real API though the path looked right,
  (4) you have many green tests for an integration that has never actually
  hit the real provider, (5) you need to map an API's required request body
  but the docs are incomplete. Covers the self-confirming-fixture trap and the
  4xx-driven contract-discovery technique for safely mapping a real schema.
author: Claude Code
version: 1.0.0
date: 2026-06-03
---

# The self-confirming fixture trap (external API clients)

## Problem

You can build an entire external-API client + DTO layer against a **made-up**
schema — wrong field names, wrong endpoint paths — and watch a full unit suite
pass green, because the unit tests feed the client **mock fixtures that were
authored from the same imagination** as the implementation. The fixtures and
the code agree with each other; neither agrees with the real provider.

Real example (spec-050 Everee, T15):
- `EvereeWorkerDTO::getId()` read `$data['id']`, `getStatus()` read
  `$data['status']`, `getLegalFirstName()` read `$data['legalFirstName']`.
- The REAL Everee worker resource has **none** of those keys — it uses
  `workerId` (UUID), `lifecycleStatus`/`onboardingStatus`/`tinVerificationStatus`
  (no bare `status`), and `firstName`/`lastName`.
- So against real data **every typed accessor returned `null`** — yet ~740
  unit tests were green because every fixture used `id`/`status`/`legalFirstName`.
- `EvereeApiClient::createWorker()` POSTed to bare `/workers` → **HTTP 405**
  (that path is GET-list only; real create endpoints are `/workers/employee`,
  `/embedded/workers/employee`). No test caught it — no test hit the real API.

**A green unit suite tells you nothing about external-contract correctness when
the fixtures were authored rather than captured.**

## Context / Trigger Conditions

- Building/reviewing a client for any external API; especially DTOs with typed
  accessors over a raw decoded body.
- Symptom: a typed accessor returns `null`/empty in production but the unit
  tests assert it returns a value (because the fixture has the imagined key).
- Symptom: a create/update call returns 404/405/400 against the real API even
  though the unit "happy path" is green.
- Smell: an integration has extensive unit coverage but has **never** been run
  against the real sandbox/provider (no recorded fixtures, no smoke script).
- You need to construct a valid request body for an endpoint whose required
  fields aren't fully documented.

## Solution

### A. Treat authored fixtures as unverified until captured

1. Run a **live smoke** (read AND write) against the real sandbox before
   trusting any external-API client. Read-only first (GET/list), then a single
   write if authorized.
2. **Capture the real response body** and make THAT the fixture. Replace
   imagined fixtures with the captured shape (record-and-replay). Commit the
   captured JSON as the test fixture so the contract is locked.
3. Add a regression test that asserts the typed accessors against the **real
   captured shape** — it must fail against the pre-fix (imagined) accessors.
4. Keep legacy/imagined keys only as explicit `?? fallbacks` if you need
   backward-compat, and document that the real key is canonical.

### B. 4xx-driven contract discovery (safe, no state created)

When an endpoint's required request body isn't fully documented, let the API
teach you the contract:

1. POST a minimal payload to the real sandbox.
2. Read the `400`/`422` validation error — it names the next missing/invalid
   field. Real Everee examples:
   - `"Missing property 'workLocationId'. This property is required when 'useHomeAddress' is 'false'."`
   - `"Validation failed: 'legalWorkAddress' must not be null"`
3. Add/fix that field and re-POST. Iterate. A handful of round-trips enumerates
   the required schema, including nested/polymorphic objects (e.g. the real
   shape was `legalWorkAddress: { useHomeAddress: true }` — the selector lives
   INSIDE the object, not top-level).
4. **4xx/422 responses create no state**, so this loop is safe to run against a
   sandbox. Stop once you get a 2xx and capture that response as the fixture.

Guardrails: use a sandbox token, obviously-fake data (`example.com` email,
`555-01xx` phone), keep the script in `/tmp` (never commit it / never write the
token to disk — pass it via an inline env var), and tell the owner what test
records you created so they can clean up.

## Verification

- The new accessor/parse test built from the **captured** shape FAILS against
  the old code and PASSES against the fix. (Prove it: temporarily revert the
  accessor, run the test, watch it fail, restore.)
- A live smoke run shows the real call succeeding end-to-end (2xx + correct
  parse), not just the mocked unit test.

## Example

spec-050 T15 (commits `d6fce3aae`, `8518033be`): a read smoke caught
`listWorkers()` mis-parsing the real `{"items":[...]}` paginated envelope, and
a write smoke (create worker via `/embedded/workers/employee`) caught the DTO
field-name drift (`workerId`/`firstName`/`lifecycleStatus`) and the 405 on
bare `/workers`. The fix added `EvereeWorkerDTOTest` built from the captured
201 body; it fails against the pre-fix accessors.

## Notes

- Companion mechanic skill: `php-array-cast-private-property-mangling`
  (a DIFFERENT reason a check silently no-ops on real data — `(array)$dto`
  key mangling). Companion process skill:
  `never_propose_merge_with_unchecked_e2e` (don't call a feature done on unit
  green alone). This skill is the external-API-specific root cause: fixtures
  authored, not captured.
- The trap is worst for "thin DTO over raw JSON" patterns, where `fromArray`
  never throws on unexpected shapes — it just stores the array and every
  accessor returns null. Consider asserting presence of expected keys at parse
  time, or a contract test against a captured fixture, to fail loudly instead.
- Applies to every external integration in buyerkiosk-web: Everee, QuickBooks,
  Shopify, WhenIWork, Twilio/Vonage, Ably, OpenAI. If any of them has unit
  tests but no captured-response fixtures, treat its client as unverified.
- Record-and-replay tooling (e.g. Guzzle MockHandler fed by a captured body,
  VCR-style cassettes) makes "capture once, replay in CI" cheap — but the
  capture must come from a REAL call.

## References

- spec-050 commits `d6fce3aae` (read-path envelope fix) and `8518033be`
  (write-path DTO shape fix) for the live captures + regression tests.
