Skip to content

Commit ce51fa9

Browse files
authored
Merge pull request #1591 from Skords-01/devin/1777863626-contract-tests
2 parents b4ca074 + 268efdc commit ce51fa9

8 files changed

Lines changed: 475 additions & 1 deletion

File tree

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import { describe, it, expect, beforeEach, afterAll, vi } from "vitest";
2+
import request from "supertest";
3+
import {
4+
meFixtures,
5+
type MeFixtureCase,
6+
type MeResponse,
7+
} from "@sergeant/shared";
8+
9+
/**
10+
* Producer-side contract test for `GET /api/me` / `GET /api/v1/me`.
11+
*
12+
* **Goal:** prove that the route's response builder, given a typical
13+
* Better Auth session-user shape, emits the same wire JSON as the
14+
* canonical fixtures in `@sergeant/shared/contract-fixtures/me`. The
15+
* matching consumer test lives in
16+
* `apps/web/src/test/contract/me.contract.test.ts`.
17+
*
18+
* Together these two files form the minimum viable contract:
19+
*
20+
* server-user-row → route serializer → fixture
21+
* fixture → api-client → typed UI value
22+
*
23+
* If the schema gets a new required field, BOTH tests must update —
24+
* consumer test fails on missing field in the fixture, producer test
25+
* fails on missing field in the response.
26+
*
27+
* Closes diagnostic §7.4 (`docs/diagnostics/2026-05-03-web-deep-dive/04-security-observability-testing-devx.md`).
28+
*/
29+
30+
const { mockPool, queryMock, getSessionUserMock } = vi.hoisted(() => {
31+
const queryMock = vi.fn().mockResolvedValue({ rows: [{ "?column?": 1 }] });
32+
const mockPool = {
33+
query: queryMock,
34+
connect: vi.fn(),
35+
on: vi.fn(),
36+
totalCount: 0,
37+
idleCount: 0,
38+
waitingCount: 0,
39+
};
40+
const getSessionUserMock = vi.fn().mockResolvedValue(null);
41+
return { mockPool, queryMock, getSessionUserMock };
42+
});
43+
44+
vi.mock("./../db.js", () => ({
45+
default: mockPool,
46+
pool: mockPool,
47+
query: queryMock,
48+
ensureSchema: vi.fn().mockResolvedValue(undefined),
49+
}));
50+
51+
vi.mock("./../auth.js", () => ({
52+
auth: { handler: async () => new Response(null, { status: 404 }) },
53+
getSessionUser: getSessionUserMock,
54+
getSessionUserSoft: vi.fn().mockResolvedValue(null),
55+
}));
56+
57+
import { createApp } from "./../app.js";
58+
59+
const ENV_KEYS = ["VAPID_PUBLIC_KEY", "VAPID_PRIVATE_KEY", "VAPID_EMAIL"];
60+
const savedEnv: Record<string, string | undefined> = {};
61+
for (const k of ENV_KEYS) savedEnv[k] = process.env[k];
62+
63+
beforeEach(() => {
64+
queryMock.mockReset();
65+
queryMock.mockResolvedValue({ rows: [{ "?column?": 1 }] });
66+
getSessionUserMock.mockReset();
67+
getSessionUserMock.mockResolvedValue(null);
68+
for (const k of ENV_KEYS) delete process.env[k];
69+
});
70+
71+
afterAll(() => {
72+
for (const k of ENV_KEYS) {
73+
if (savedEnv[k] === undefined) delete process.env[k];
74+
else process.env[k] = savedEnv[k];
75+
}
76+
});
77+
78+
/**
79+
* Translate a contract fixture into the shape Better Auth's
80+
* `getSessionUser()` would return for the same user. The route's job is
81+
* to flatten that into the canonical fixture; this helper is what the
82+
* test feeds the auth mock.
83+
*
84+
* `createdAt` from Better Auth comes as a `Date` (or sometimes a
85+
* stringified ISO when the adapter has already serialised it). We
86+
* exercise both paths: numbered fixtures alternate between Date and
87+
* string.
88+
*/
89+
function authedUserFromFixture(
90+
fixture: MeResponse,
91+
variant: "date" | "string" | "missing",
92+
): Record<string, unknown> {
93+
const { user } = fixture;
94+
const base: Record<string, unknown> = {
95+
id: user.id,
96+
email: user.email,
97+
name: user.name,
98+
image: user.image,
99+
emailVerified: user.emailVerified,
100+
};
101+
if (variant === "missing" || user.createdAt === null) {
102+
return base;
103+
}
104+
if (variant === "string") {
105+
base.createdAt = user.createdAt;
106+
} else {
107+
base.createdAt = new Date(user.createdAt);
108+
}
109+
return base;
110+
}
111+
112+
const NAMES: readonly MeFixtureCase[] = [
113+
"minimal",
114+
"full",
115+
"legacyNoCreatedAt",
116+
"unverified",
117+
] as const;
118+
119+
describe("contract producer: GET /api/v1/me", () => {
120+
it.each(NAMES)(
121+
"fixture %s — Date `createdAt` round-trips through the route",
122+
async (name) => {
123+
const fixture = meFixtures[name];
124+
const authed = authedUserFromFixture(fixture, "date");
125+
getSessionUserMock.mockResolvedValueOnce(authed);
126+
127+
const app = createApp();
128+
const res = await request(app)
129+
.get("/api/v1/me")
130+
.set("Authorization", "Bearer contract-stub");
131+
132+
expect(res.status).toBe(200);
133+
expect(res.body).toEqual(fixture);
134+
},
135+
);
136+
137+
it.each(NAMES)(
138+
"fixture %s — string `createdAt` round-trips through the route",
139+
async (name) => {
140+
const fixture = meFixtures[name];
141+
const authed = authedUserFromFixture(fixture, "string");
142+
getSessionUserMock.mockResolvedValueOnce(authed);
143+
144+
const app = createApp();
145+
const res = await request(app)
146+
.get("/api/v1/me")
147+
.set("Authorization", "Bearer contract-stub");
148+
149+
expect(res.status).toBe(200);
150+
expect(res.body).toEqual(fixture);
151+
},
152+
);
153+
154+
it("legacyNoCreatedAt — missing field on the auth user → null on the wire", async () => {
155+
// Older accounts may not have `createdAt` at all in the auth row.
156+
// The route normalises this to `null`, matching the
157+
// `legacyNoCreatedAt` fixture exactly.
158+
const fixture = meFixtures.legacyNoCreatedAt;
159+
const authed = authedUserFromFixture(fixture, "missing");
160+
getSessionUserMock.mockResolvedValueOnce(authed);
161+
162+
const app = createApp();
163+
const res = await request(app)
164+
.get("/api/v1/me")
165+
.set("Authorization", "Bearer contract-stub");
166+
167+
expect(res.status).toBe(200);
168+
expect(res.body).toEqual(fixture);
169+
expect(res.body.user.createdAt).toBeNull();
170+
});
171+
172+
it("response is byte-stable between /api/me and /api/v1/me for the same fixture", async () => {
173+
// Hard Rule: legacy and v1 prefixes must emit IDENTICAL bodies. If
174+
// they ever diverge, downstream code split between the two paths
175+
// would silently misbehave.
176+
const fixture = meFixtures.full;
177+
const authed = authedUserFromFixture(fixture, "date");
178+
getSessionUserMock.mockResolvedValue(authed);
179+
180+
const app = createApp();
181+
const legacy = await request(app)
182+
.get("/api/me")
183+
.set("Authorization", "Bearer x");
184+
const v1 = await request(app)
185+
.get("/api/v1/me")
186+
.set("Authorization", "Bearer x");
187+
188+
expect(legacy.status).toBe(200);
189+
expect(v1.status).toBe(200);
190+
expect(v1.body).toEqual(legacy.body);
191+
expect(v1.body).toEqual(fixture);
192+
});
193+
});
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/**
2+
* Contract test for `GET /api/me` / `GET /api/v1/me`.
3+
*
4+
* **Goal:** prove that the canonical wire-shape fixtures in
5+
* `@sergeant/shared/contract-fixtures/me` are accepted by the
6+
* api-client consumer side **byte-for-byte**, so any future drift
7+
* between the schema (`MeResponseSchema`) and either the fixture or
8+
* the consumer's parser fails CI here — not in production.
9+
*
10+
* Closes diagnostic
11+
* [`docs/diagnostics/2026-05-03-web-deep-dive/04-security-observability-testing-devx.md`](../../../../../docs/diagnostics/2026-05-03-web-deep-dive/04-security-observability-testing-devx.md) §7.4
12+
* (web↔server contract gap).
13+
*
14+
* The matching producer-side test lives in
15+
* `apps/server/src/routes/me.contract.test.ts`. Together they form the
16+
* minimal viable contract for `/api/me`. New endpoints follow the same
17+
* 2-file pattern (consumer + producer over a shared fixture).
18+
*/
19+
20+
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
21+
import {
22+
meFixtures,
23+
meRawFixtures,
24+
assertMeFixturesValid,
25+
MeResponseSchema,
26+
type MeFixtureCase,
27+
} from "@sergeant/shared";
28+
import { createHttpClient } from "@sergeant/api-client";
29+
import { createMeEndpoints } from "@sergeant/api-client";
30+
31+
const FIXTURE_NAMES: readonly MeFixtureCase[] = [
32+
"minimal",
33+
"full",
34+
"legacyNoCreatedAt",
35+
"unverified",
36+
] as const;
37+
38+
function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
39+
return new Response(JSON.stringify(body), {
40+
status: 200,
41+
headers: { "content-type": "application/json" },
42+
...init,
43+
});
44+
}
45+
46+
let originalFetch: typeof fetch;
47+
48+
beforeEach(() => {
49+
originalFetch = globalThis.fetch;
50+
});
51+
52+
afterEach(() => {
53+
globalThis.fetch = originalFetch;
54+
vi.restoreAllMocks();
55+
});
56+
57+
describe("contract: /api/me", () => {
58+
it("every named fixture parses through MeResponseSchema (sanity)", () => {
59+
// assertMeFixturesValid throws on the first fixture that no longer
60+
// matches the schema. Keep it cheap so this whole test file can be
61+
// a quick CI gate.
62+
expect(() => assertMeFixturesValid()).not.toThrow();
63+
});
64+
65+
it.each(FIXTURE_NAMES)(
66+
"fixture %s round-trips through the api-client consumer",
67+
async (name) => {
68+
const fixture = meRawFixtures[name];
69+
70+
// Mock the network so the api-client receives the canonical JSON
71+
// verbatim. If the schema gets stricter or the fixture loses a
72+
// required field, `MeResponseSchema.parse()` inside `me.get()`
73+
// throws a ZodError — and CI fails here, not in browser logs.
74+
const fetchMock = vi.fn(async () => jsonResponse(fixture));
75+
globalThis.fetch = fetchMock as unknown as typeof fetch;
76+
77+
const http = createHttpClient({ baseUrl: "http://contract.test" });
78+
const me = createMeEndpoints(http);
79+
80+
const result = await me.get();
81+
82+
// Deep-equal: api-client must NOT silently strip unknown fields,
83+
// remap nullables, or coerce strings to numbers without explicit
84+
// schema support.
85+
expect(result).toEqual(meFixtures[name]);
86+
expect(fetchMock).toHaveBeenCalledOnce();
87+
},
88+
);
89+
90+
it("rejects a payload missing a required field (drift detection)", async () => {
91+
// Drop `emailVerified` to simulate a server regression where the
92+
// serializer forgets a Hard Rule #3 update. The api-client must
93+
// refuse the response — masking it would silently drop the
94+
// verification banner in the UI.
95+
const broken = {
96+
user: {
97+
id: "user_broken_001",
98+
email: "broken@example.com",
99+
name: null,
100+
image: null,
101+
// emailVerified missing on purpose.
102+
createdAt: "2026-01-01T00:00:00.000Z",
103+
},
104+
};
105+
globalThis.fetch = vi.fn(
106+
async () => jsonResponse(broken),
107+
) as unknown as typeof fetch;
108+
109+
const http = createHttpClient({ baseUrl: "http://contract.test" });
110+
const me = createMeEndpoints(http);
111+
112+
await expect(me.get()).rejects.toThrow();
113+
});
114+
115+
it("`MeResponseSchema` accepts every fixture as `unknown` JSON", () => {
116+
// This is the producer-side guarantee mirrored on the consumer:
117+
// the same schema accepts the same fixtures from both directions.
118+
for (const name of FIXTURE_NAMES) {
119+
const parsed = MeResponseSchema.parse(meRawFixtures[name]);
120+
expect(parsed).toEqual(meFixtures[name]);
121+
}
122+
});
123+
});

docs/diagnostics/2026-05-03-web-deep-dive/00-overview.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@
9090
| 11 | `index.css` decomposition | 3 | 2 | 1.50 | [02 §1.4](./02-architecture-and-state.md) |
9191
| 12 | Rate-limiter `cost`-multiplier для AI streams | 3 | 2 | 1.50 | [03 §4.5](./03-backend-and-performance.md) |
9292
| 13 | OpenAPI generation + typed client out of zod | 4 | 3 | 1.33 | [03 §4.7](./03-backend-and-performance.md) |
93-
| 14 | Contract tests web↔server | 4 | 2 | 2.00 | [04 §7.4](./04-security-observability-testing-devx.md) |
93+
| 14 | Contract tests web↔server | 4 | 2 | 2.00 | [04 §7.4](./04-security-observability-testing-devx.md) — done (`/api/me` fixtures + consumer/producer tests) |
9494
| 15 | `tsconfig.strict: true` для `apps/web` поетапно | 5 | 4 | 1.25 | [02 §1.0](./02-architecture-and-state.md) |
9595
| 16 | Storybook для top 20 компонентів | 3 | 3 | 1.00 | [04 §8.6](./04-security-observability-testing-devx.md) |
9696
| 17 | Mutation testing на критичних модулях (квартально) | 4 | 4 | 1.00 | [04 §7.3](./04-security-observability-testing-devx.md) |

docs/diagnostics/2026-05-03-web-deep-dive/04-security-observability-testing-devx.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,13 @@
194194

195195
## 7.4 [Bad] No contract tests web↔server
196196

197+
> **2026-05-04 update.** Запущено мінімальний contract layer для `/api/me`:
198+
>
199+
> - Канонічні фікстури — `packages/shared/src/contract-fixtures/me.ts` (4 кейси: `minimal`, `full`, `legacyNoCreatedAt`, `unverified`).
200+
> - Consumer side — `apps/web/src/test/contract/me.contract.test.ts` (api-client + `MeResponseSchema`).
201+
> - Producer side — `apps/server/src/routes/me.contract.test.ts` (route handler через supertest).
202+
> - 17 contract assertions, 0 production code touched. Pattern документовано в `packages/shared/src/contract-fixtures/README.md`. Наступні endpoint-и розширюють той самий каталог.
203+
197204
**Що бачу.** Pact / OpenAPI-validation немає. Кожна сторона припускає shape — це причина drift-у §4.7.
198205

199206
**Recommendation.**
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Contract fixtures
2+
3+
Single source of truth for canonical API request / response shapes,
4+
shared between `apps/server` (producer), `packages/api-client`
5+
(consumer), and `apps/web` / `apps/mobile` (UI).
6+
7+
## Why
8+
9+
The diagnostic in
10+
[`docs/diagnostics/2026-05-03-web-deep-dive/04-security-observability-testing-devx.md`](../../../../docs/diagnostics/2026-05-03-web-deep-dive/04-security-observability-testing-devx.md)
11+
§7.4 calls out that we have unit tests on each side of the wire but
12+
**no** test that locks the wire format itself. With the same `Zod`
13+
schema imported on both sides, drift is theoretically impossible — but
14+
practically, Hard Rule #3 in `AGENTS.md` ("API contract: server
15+
response shape ↔ `api-client` types ↔ test") still relies on humans
16+
remembering to update three files in the same PR.
17+
18+
A contract fixture flips that around:
19+
20+
- **One fixture**, checked in, hand-curated to be canonical.
21+
- The server's tests run it through the response builder + schema
22+
parser → the fixture is a "golden" shape the producer must emit.
23+
- The api-client's tests feed the same fixture into a mocked `fetch`
24+
→ the consumer must accept it byte-for-byte.
25+
- A fixture that no longer parses through the schema = the schema
26+
changed without the fixture being updated. CI fails on **either**
27+
side, immediately.
28+
29+
## Layout
30+
31+
```
32+
contract-fixtures/
33+
├── README.md ← this file
34+
├── index.ts ← barrel
35+
└── me.ts ← /api/me canonical shapes (User, MeResponse)
36+
```
37+
38+
Each module exports:
39+
40+
- `<endpoint>Fixtures` — a non-empty `Record<string, T>` of named cases
41+
(`minimal`, `full`, `legacyNoCreatedAt`, …).
42+
- `<endpoint>RawFixtures` — the same cases as `unknown`, suitable for
43+
feeding into a parser to verify it accepts the wire JSON. Useful when
44+
you want to test the schema's `.parse()` path explicitly.
45+
46+
## Adding a fixture
47+
48+
1. Add a new case to the matching module (or create a new one keyed by
49+
the route path: `me.ts`, `nutritionAnalyze.ts`, …).
50+
2. Export it through `index.ts`.
51+
3. Add an assertion in the corresponding contract test
52+
(`apps/web/src/test/contract/<name>.contract.test.ts`) that the
53+
api-client accepts the fixture, AND in the server-side test that
54+
the route emits the fixture given matching inputs.
55+
4. CI now blocks any future schema drift that breaks either side.

0 commit comments

Comments
 (0)