Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
256 changes: 254 additions & 2 deletions .claude/skills/tdd-from-schema/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<uuid>';
if (ISO_DATE_RE.test(value)) return '<iso-date>';
return value;
}
if (typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const key of Object.keys(value as object).sort()) {
out[key] = normalizeForSnapshot((value as Record<string, unknown>)[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<string, string | null> {
const keys = ['content-type', 'cache-control', 'vary', ...extra];
const out: Record<string, string | null> = {};
for (const key of keys) out[key] = res.headers.get(key);
return out;
}
```

### Step 4 -- Create the Hono Test App Helper
Expand Down Expand Up @@ -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<typeof getTestApp>;

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:

Expand Down Expand Up @@ -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

Expand Down
Loading