From 4993352cc925ce67ebfbc65a8b1cf74a3d410abb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 04:08:52 +0200 Subject: [PATCH] test(api): parse real responses against the schemas that publish them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Response drift was structurally undetectable. `openapi:check` proves the YAML matches the registry and the route-coverage guard proves the verb is listed; both pass when the registered schema disagrees with what `apiSuccess()` sends, and the coverage guard says so in its own doc comment. That is how `/api/sync/changes` shipped five domains against a three-domain contract and how the error envelope went years without declaring the field the multi-issue 422 was built for. Two bodies, the two that were broken: a full `/api/sync/changes` page with live and soft-deleted rows in all five domains, its `cursorExpired` short-circuit (a second shape under the same schema), and a 422 carrying more than one issue. `safeParse` alone would not have caught either finding, because Zod strips undeclared keys instead of refusing them — a schema short of the wire parses cleanly. So the parsed value is compared back to the raw body; a strip means the contract is missing something the server sends. Removing `cycleDays` / `cycles` from the sync schema, or `details` from the envelope, fails all three cases. --- src/lib/openapi/routes/sync.ts | 10 +- .../openapi-response-shape.test.ts | 298 ++++++++++++++++++ 2 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 tests/integration/openapi-response-shape.test.ts diff --git a/src/lib/openapi/routes/sync.ts b/src/lib/openapi/routes/sync.ts index 33071b202..007fd424c 100644 --- a/src/lib/openapi/routes/sync.ts +++ b/src/lib/openapi/routes/sync.ts @@ -236,7 +236,15 @@ const syncChangesQuery = z }) .meta({ id: "SyncChangesQuery" }); -const syncChangesResponse = z +/** + * Exported so a test can `safeParse` a real `/api/sync/changes` body against + * it. Nothing else checks a response SHAPE against its published schema — + * `openapi:check` proves the YAML matches the registry, and the route-coverage + * guard proves the path is listed, so a schema that disagrees with what + * `apiSuccess()` sends passes both. That is how this endpoint went five + * domains wide against a three-domain contract. + */ +export const syncChangesResponse = z .object({ serverNow: z.iso.datetime({ offset: true }), cursor: z diff --git a/tests/integration/openapi-response-shape.test.ts b/tests/integration/openapi-response-shape.test.ts new file mode 100644 index 000000000..89ee4f550 --- /dev/null +++ b/tests/integration/openapi-response-shape.test.ts @@ -0,0 +1,298 @@ +/** + * A real response, parsed against the schema that publishes it. + * + * Nothing in the gate did this before. `pnpm openapi:check` proves the YAML + * matches the registry; `openapi-route-coverage-guard` proves the verb is + * listed. Both pass when the registered schema disagrees with what the handler + * actually sends, and that gap is how `/api/sync/changes` ended up shipping + * five domains against a three-domain contract while the body was invalid + * against its own published schema. + * + * Two responses are covered — the ones the audit found broken: + * + * - `GET /api/sync/changes`, the multi-domain delta the native client mirrors + * its whole history from. + * - a multi-issue 422, whose `details.issues` array is the reason the + * envelope exists and was the field the envelope did not declare. + * + * The check is stricter than `safeParse` alone. Zod strips undeclared keys + * rather than refusing them, so a pass would say nothing about a field the + * server sends and the contract omits — exactly the direction both High + * findings drifted in. Comparing the parsed value back to the raw body catches + * the strip: if anything was dropped, the schema is short of the wire. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; +import { syncChangesResponse } from "@/lib/openapi/routes/sync"; +import { errorEnvelope } from "@/lib/openapi/routes/shared"; + +const TEST_USER_ID = "user-openapi-response-shape"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + cookieJar.clear(); + + // FEMALE so the cycle module is on by default and the two domains the + // contract used to omit actually carry rows. + await prisma.user.create({ + data: { + id: TEST_USER_ID, + username: "openapi-response-shape", + email: "openapi-response-shape@example.test", + gender: "FEMALE", + timezone: "UTC", + }, + }); + const session = await prisma.session.create({ + data: { + userId: TEST_USER_ID, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +/** + * Seed one live row and one soft-deleted row per domain, so every `upserts` + * and every `tombstones` array in the page is non-empty. An all-empty page + * would parse against almost any schema. + */ +async function seedEveryDomain(): Promise { + const prisma = getPrismaClient(); + const at = new Date("2026-02-02T08:00:00.000Z"); + const gone = new Date("2026-02-02T09:00:00.000Z"); + + await prisma.measurement.createMany({ + data: [ + { + id: "shape-measurement-live", + userId: TEST_USER_ID, + type: "WEIGHT", + value: 79.2, + unit: "kg", + measuredAt: at, + source: "MANUAL", + externalId: "shape-live", + }, + { + id: "shape-measurement-gone", + userId: TEST_USER_ID, + type: "PULSE", + value: 61, + unit: "bpm", + measuredAt: at, + source: "MANUAL", + externalId: "shape-gone", + deletedAt: gone, + }, + ], + }); + + await prisma.moodEntry.createMany({ + data: [ + { + id: "shape-mood-live", + userId: TEST_USER_ID, + date: "2026-02-02", + mood: "GOOD", + score: 4, + moodLoggedAt: at, + source: "MANUAL", + }, + { + id: "shape-mood-gone", + userId: TEST_USER_ID, + date: "2026-02-01", + mood: "OKAY", + score: 3, + moodLoggedAt: at, + source: "MANUAL", + deletedAt: gone, + }, + ], + }); + + const medication = await prisma.medication.create({ + data: { + id: "shape-medication", + userId: TEST_USER_ID, + name: "Test compound", + dose: "1 tablet", + }, + }); + await prisma.medicationIntakeEvent.createMany({ + data: [ + { + id: "shape-intake-live", + userId: TEST_USER_ID, + medicationId: medication.id, + scheduledFor: at, + takenAt: at, + skipped: false, + source: "WEB", + }, + { + id: "shape-intake-gone", + userId: TEST_USER_ID, + medicationId: medication.id, + scheduledFor: gone, + takenAt: null, + skipped: true, + source: "WEB", + deletedAt: gone, + }, + ], + }); + + await prisma.menstrualCycle.createMany({ + data: [ + { + id: "shape-cycle-live", + userId: TEST_USER_ID, + startDate: "2026-01-05", + }, + { + id: "shape-cycle-gone", + userId: TEST_USER_ID, + startDate: "2025-12-05", + deletedAt: gone, + }, + ], + }); + await prisma.cycleDayLog.createMany({ + data: [ + { + id: "shape-day-live", + userId: TEST_USER_ID, + date: "2026-01-05", + cycleId: "shape-cycle-live", + flow: "MEDIUM", + source: "MANUAL", + }, + { + id: "shape-day-gone", + userId: TEST_USER_ID, + date: "2026-01-04", + source: "MANUAL", + deletedAt: gone, + }, + ], + }); +} + +describe("published response schemas against real bodies", () => { + it("parses a full /api/sync/changes page and drops nothing", async () => { + await seedEveryDomain(); + const { GET } = await import("@/app/api/sync/changes/route"); + + const res = await GET( + new NextRequest("http://localhost/api/sync/changes") as never, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: unknown }; + + const parsed = syncChangesResponse.safeParse(body.data); + expect(parsed.error?.issues ?? []).toEqual([]); + expect(parsed.success).toBe(true); + + // Every domain actually carried rows, so the parse above was asked a real + // question rather than being handed five empty pairs. + const changes = ( + body.data as { + changes: Record; + } + ).changes; + for (const domain of [ + "measurements", + "mood", + "intakes", + "cycleDays", + "cycles", + ]) { + expect(changes[domain].upserts.length).toBeGreaterThan(0); + expect(changes[domain].tombstones.length).toBeGreaterThan(0); + } + + // Zod strips what it does not declare, so an equal round-trip is the part + // that proves the contract is not short of the wire. + expect(parsed.data).toEqual(body.data); + }); + + it("parses the cursorExpired short-circuit page too", async () => { + const { GET } = await import("@/app/api/sync/changes/route"); + + // A cursor whose watermark predates tombstone retention forces the + // early return, which builds its own literal page rather than the one + // the domain walks assemble — a second shape under the same schema. + const { encodeCursor } = await import("@/lib/sync/cursor"); + const stale = encodeCursor({ + measurements: { + updatedAtMs: Date.now() - 400 * 24 * 60 * 60 * 1000, + id: "long-gone", + }, + }); + + const res = await GET( + new NextRequest( + `http://localhost/api/sync/changes?cursor=${stale}`, + ) as never, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { cursorExpired: boolean } }; + + const parsed = syncChangesResponse.safeParse(body.data); + expect(parsed.error?.issues ?? []).toEqual([]); + expect(parsed.data).toEqual(body.data); + }); + + it("parses a multi-issue 422 against the published error envelope", async () => { + const { GET } = await import("@/app/api/measurements/route"); + + const res = await GET( + new NextRequest( + "http://localhost/api/measurements?limit=0&offset=-3&sortDir=sideways", + ) as never, + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + details?: { issues: unknown[] }; + }; + + // The envelope exists to report every bad field at once; if only one + // arrives, the fixture stopped exercising the multi-issue path. + expect(body.details?.issues.length ?? 0).toBeGreaterThan(1); + + const parsed = errorEnvelope.safeParse(body); + expect(parsed.error?.issues ?? []).toEqual([]); + expect(parsed.data).toEqual(body); + }); +});