diff --git a/.claude/skills/tdd-from-schema/SKILL.md b/.claude/skills/tdd-from-schema/SKILL.md index 14de318..dcf4ad0 100644 --- a/.claude/skills/tdd-from-schema/SKILL.md +++ b/.claude/skills/tdd-from-schema/SKILL.md @@ -252,6 +252,48 @@ export function expectNotFoundError(body: any) { expect(body).toHaveProperty('error'); expect(body.error).toHaveProperty('code', 'NOT_FOUND'); } + +// Snapshot normalizers -- strip volatile values so snapshots stay stable +// across runs without losing the ability to detect schema drift. +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})$/; + +export function normalizeForSnapshot(value: unknown): unknown { + if (value === null || value === undefined) return value; + if (Array.isArray(value)) return value.map(normalizeForSnapshot); + if (typeof value === 'string') { + if (UUID_RE.test(value)) return ''; + if (ISO_DATE_RE.test(value)) return ''; + return value; + } + if (typeof value === 'object') { + const out: Record = {}; + for (const key of Object.keys(value as object).sort()) { + out[key] = normalizeForSnapshot((value as Record)[key]); + } + return out; + } + return value; +} + +// Snapshot just the *shape* of a response (sorted top-level keys) -- catches +// added/removed fields without coupling to values that legitimately change. +export function responseShape(body: unknown): string[] { + if (body === null || typeof body !== 'object') return []; + return Object.keys(body as object).sort(); +} + +// Pick the headers we actually care about for contract stability. Vary the +// list per route group if you add custom headers (e.g. X-RateLimit-*). +export function snapshotHeaders( + res: Response, + extra: string[] = [], +): Record { + const keys = ['content-type', 'cache-control', 'vary', ...extra]; + const out: Record = {}; + for (const key of keys) out[key] = res.headers.get(key); + return out; +} ``` ### Step 4 -- Create the Hono Test App Helper @@ -882,7 +924,215 @@ describe('Orders API', () => { }); ``` -### Step 7 -- Confirm RED Phase (Hard Gate) +### Step 7 -- Add Response Schema Snapshot Tests + +Status codes and `expectPaginatedResponse` catch broad shape changes, but they +do **not** catch "the `email` field was renamed to `emailAddress`" or "the +`address` object lost its `country` key." Those are breaking changes for every +client. Add a focused snapshot suite per resource that captures: + +1. **Response body keys** -- with `toMatchInlineSnapshot()` for visibility in + the test file itself. Derive the expected keys from the OpenAPI spec / + Drizzle schema when generating the test so the inline value is correct from + day one (the test then fails in RED until the handler matches, and acts as + a regression check in GREEN). +2. **Full normalized response body** -- with `toMatchSnapshot()` to a sibling + `__snapshots__` directory. Pass the body through `normalizeForSnapshot()` + so UUIDs and timestamps collapse to placeholders. +3. **Response headers** -- `Content-Type`, `Cache-Control`, and any custom + headers (rate-limit, deprecation, request-id) the API contract promises. +4. **Error response shapes** -- the validation, not-found, unauthorized, and + forbidden envelopes. Consumers branch on these too. + +**Example: Books snapshot file** + +```typescript +// api/tests/routes/books.snapshot.test.ts +import { describe, it, expect, beforeEach } from 'vitest'; +import { getTestApp } from '../helpers/app'; +import { + createTestAdmin, + createTestBook, + generateTestToken, + authHeader, + normalizeForSnapshot, + responseShape, + snapshotHeaders, +} from '../helpers'; + +describe('Books API -- response schema snapshots', () => { + let app: ReturnType; + + beforeEach(() => { + app = getTestApp(); + }); + + it('GET /books/:id response body keys match the contract', async () => { + const book = await createTestBook(); + + const res = await app.request(`/books/${book.id}`); + const body = await res.json(); + + expect(responseShape(body)).toMatchInlineSnapshot(` + [ + "author", + "createdAt", + "id", + "isbn", + "price", + "stock", + "title", + "updatedAt", + ] + `); + }); + + it('GET /books/:id full response matches snapshot', async () => { + const book = await createTestBook({ + title: 'Snapshot Book', + author: 'Snapshot Author', + isbn: '9780000000099', + price: '19.99', + stock: 7, + }); + + const res = await app.request(`/books/${book.id}`); + const body = await res.json(); + + expect(normalizeForSnapshot(body)).toMatchSnapshot(); + }); + + it('GET /books response headers match contract', async () => { + const res = await app.request('/books'); + + expect(snapshotHeaders(res)).toMatchInlineSnapshot(` + { + "cache-control": null, + "content-type": "application/json; charset=UTF-8", + "vary": null, + } + `); + }); + + it('GET /books paginated envelope matches snapshot', async () => { + await createTestBook({ title: 'A', isbn: '9780000001001' }); + await createTestBook({ title: 'B', isbn: '9780000001002' }); + + const res = await app.request('/books?page=1&limit=10'); + const body = await res.json(); + + expect(responseShape(body)).toMatchInlineSnapshot(` + [ + "data", + "meta", + ] + `); + expect(responseShape(body.meta)).toMatchInlineSnapshot(` + [ + "limit", + "page", + "total", + "totalPages", + ] + `); + }); + + // --- Error shapes --- + + it('400 validation error shape', async () => { + const admin = await createTestAdmin(); + const token = await generateTestToken(admin.id, 'admin'); + + const res = await app.request('/books', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeader(token) }, + body: JSON.stringify({ title: 'Only Title' }), + }); + const body = await res.json(); + + expect(res.status).toBe(400); + expect(responseShape(body.error)).toMatchInlineSnapshot(` + [ + "code", + "details", + "message", + ] + `); + expect(body.error.code).toMatchInlineSnapshot('"VALIDATION_ERROR"'); + }); + + it('404 not-found error shape', async () => { + const res = await app.request('/books/00000000-0000-0000-0000-000000000000'); + const body = await res.json(); + + expect(res.status).toBe(404); + expect(normalizeForSnapshot(body)).toMatchInlineSnapshot(` + { + "error": { + "code": "NOT_FOUND", + "message": "Book not found", + }, + } + `); + }); + + it('401 unauthorized error shape', async () => { + const res = await app.request('/books', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + const body = await res.json(); + + expect(res.status).toBe(401); + expect(responseShape(body.error)).toMatchInlineSnapshot(` + [ + "code", + "message", + ] + `); + }); +}); +``` + +**Generation rules** (the route-generation step relies on these): + +- One `*.snapshot.test.ts` file per resource, alongside the CRUD test file. +- Inline snapshots (`toMatchInlineSnapshot`) for: top-level key lists, error + envelope keys, error `code` literals, and the headers object. These are the + **contract-critical** fields -- breakage shows up in the test file itself in + code review. +- File snapshots (`toMatchSnapshot`) for: full normalized response bodies and + any deeply-nested shapes. Stored under `tests/routes/__snapshots__/`. +- Always pass response bodies through `normalizeForSnapshot()` before + `toMatchSnapshot()` so UUIDs and ISO timestamps do not cause spurious diffs. + +**Updating snapshots when a change is intentional** + +A failing snapshot test means one of two things: + +1. **Regression** -- the handler stopped returning a field the contract + promised. Fix the handler. +2. **Intentional contract change** -- the OpenAPI spec was deliberately + updated. After verifying the change is correct *and* documented in the + spec, regenerate snapshots: + + ```bash + cd api && pnpm vitest run -u # update all snapshots + cd api && pnpm vitest run tests/routes/books -u # update one resource + ``` + + Commit the snapshot diff (`__snapshots__/*.snap` files and any inline + snapshot changes) as part of the same PR that changes the response shape, + and call it out in the PR description so reviewers explicitly acknowledge + the consumer-visible change. + +> Snapshot tests fail in RED for the same reason every other test in this +> phase fails -- there is no handler yet. File snapshots will be created on +> the first GREEN run; inline snapshots derived from the spec already encode +> the expected shape and will start passing as soon as the handler matches. + +### Step 8 -- Confirm RED Phase (Hard Gate) Run the tests and confirm they all fail: @@ -928,9 +1178,11 @@ cd api && pnpm vitest run --reporter=json 2>&1 | node -e " |---|---|---| | Test config | `api/vitest.config.ts` | Vitest configuration | | Test setup | `api/tests/setup.ts` | Database container and cleanup | -| Test helpers | `api/tests/helpers/index.ts` | Factories, auth helpers, assertions | +| Test helpers | `api/tests/helpers/index.ts` | Factories, auth helpers, assertions, snapshot normalizers | | App helper | `api/tests/helpers/app.ts` | Test app instantiation | | Route tests | `api/tests/routes/*.test.ts` | One test file per resource | +| Schema snapshot tests | `api/tests/routes/*.snapshot.test.ts` | Response body keys, normalized bodies, headers, and error envelopes | +| Snapshot store | `api/tests/routes/__snapshots__/*.snap` | Generated on first GREEN run; committed to the repo as the contract record | ## Integration