diff --git a/apps/server/src/routes/me.contract.test.ts b/apps/server/src/routes/me.contract.test.ts new file mode 100644 index 000000000..3f42050c1 --- /dev/null +++ b/apps/server/src/routes/me.contract.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, beforeEach, afterAll, vi } from "vitest"; +import request from "supertest"; +import { + meFixtures, + type MeFixtureCase, + type MeResponse, +} from "@sergeant/shared"; + +/** + * Producer-side contract test for `GET /api/me` / `GET /api/v1/me`. + * + * **Goal:** prove that the route's response builder, given a typical + * Better Auth session-user shape, emits the same wire JSON as the + * canonical fixtures in `@sergeant/shared/contract-fixtures/me`. The + * matching consumer test lives in + * `apps/web/src/test/contract/me.contract.test.ts`. + * + * Together these two files form the minimum viable contract: + * + * server-user-row → route serializer → fixture + * fixture → api-client → typed UI value + * + * If the schema gets a new required field, BOTH tests must update — + * consumer test fails on missing field in the fixture, producer test + * fails on missing field in the response. + * + * Closes diagnostic §7.4 (`docs/diagnostics/2026-05-03-web-deep-dive/04-security-observability-testing-devx.md`). + */ + +const { mockPool, queryMock, getSessionUserMock } = vi.hoisted(() => { + const queryMock = vi.fn().mockResolvedValue({ rows: [{ "?column?": 1 }] }); + const mockPool = { + query: queryMock, + connect: vi.fn(), + on: vi.fn(), + totalCount: 0, + idleCount: 0, + waitingCount: 0, + }; + const getSessionUserMock = vi.fn().mockResolvedValue(null); + return { mockPool, queryMock, getSessionUserMock }; +}); + +vi.mock("./../db.js", () => ({ + default: mockPool, + pool: mockPool, + query: queryMock, + ensureSchema: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("./../auth.js", () => ({ + auth: { handler: async () => new Response(null, { status: 404 }) }, + getSessionUser: getSessionUserMock, + getSessionUserSoft: vi.fn().mockResolvedValue(null), +})); + +import { createApp } from "./../app.js"; + +const ENV_KEYS = ["VAPID_PUBLIC_KEY", "VAPID_PRIVATE_KEY", "VAPID_EMAIL"]; +const savedEnv: Record = {}; +for (const k of ENV_KEYS) savedEnv[k] = process.env[k]; + +beforeEach(() => { + queryMock.mockReset(); + queryMock.mockResolvedValue({ rows: [{ "?column?": 1 }] }); + getSessionUserMock.mockReset(); + getSessionUserMock.mockResolvedValue(null); + for (const k of ENV_KEYS) delete process.env[k]; +}); + +afterAll(() => { + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } +}); + +/** + * Translate a contract fixture into the shape Better Auth's + * `getSessionUser()` would return for the same user. The route's job is + * to flatten that into the canonical fixture; this helper is what the + * test feeds the auth mock. + * + * `createdAt` from Better Auth comes as a `Date` (or sometimes a + * stringified ISO when the adapter has already serialised it). We + * exercise both paths: numbered fixtures alternate between Date and + * string. + */ +function authedUserFromFixture( + fixture: MeResponse, + variant: "date" | "string" | "missing", +): Record { + const { user } = fixture; + const base: Record = { + id: user.id, + email: user.email, + name: user.name, + image: user.image, + emailVerified: user.emailVerified, + }; + if (variant === "missing" || user.createdAt === null) { + return base; + } + if (variant === "string") { + base.createdAt = user.createdAt; + } else { + base.createdAt = new Date(user.createdAt); + } + return base; +} + +const NAMES: readonly MeFixtureCase[] = [ + "minimal", + "full", + "legacyNoCreatedAt", + "unverified", +] as const; + +describe("contract producer: GET /api/v1/me", () => { + it.each(NAMES)( + "fixture %s — Date `createdAt` round-trips through the route", + async (name) => { + const fixture = meFixtures[name]; + const authed = authedUserFromFixture(fixture, "date"); + getSessionUserMock.mockResolvedValueOnce(authed); + + const app = createApp(); + const res = await request(app) + .get("/api/v1/me") + .set("Authorization", "Bearer contract-stub"); + + expect(res.status).toBe(200); + expect(res.body).toEqual(fixture); + }, + ); + + it.each(NAMES)( + "fixture %s — string `createdAt` round-trips through the route", + async (name) => { + const fixture = meFixtures[name]; + const authed = authedUserFromFixture(fixture, "string"); + getSessionUserMock.mockResolvedValueOnce(authed); + + const app = createApp(); + const res = await request(app) + .get("/api/v1/me") + .set("Authorization", "Bearer contract-stub"); + + expect(res.status).toBe(200); + expect(res.body).toEqual(fixture); + }, + ); + + it("legacyNoCreatedAt — missing field on the auth user → null on the wire", async () => { + // Older accounts may not have `createdAt` at all in the auth row. + // The route normalises this to `null`, matching the + // `legacyNoCreatedAt` fixture exactly. + const fixture = meFixtures.legacyNoCreatedAt; + const authed = authedUserFromFixture(fixture, "missing"); + getSessionUserMock.mockResolvedValueOnce(authed); + + const app = createApp(); + const res = await request(app) + .get("/api/v1/me") + .set("Authorization", "Bearer contract-stub"); + + expect(res.status).toBe(200); + expect(res.body).toEqual(fixture); + expect(res.body.user.createdAt).toBeNull(); + }); + + it("response is byte-stable between /api/me and /api/v1/me for the same fixture", async () => { + // Hard Rule: legacy and v1 prefixes must emit IDENTICAL bodies. If + // they ever diverge, downstream code split between the two paths + // would silently misbehave. + const fixture = meFixtures.full; + const authed = authedUserFromFixture(fixture, "date"); + getSessionUserMock.mockResolvedValue(authed); + + const app = createApp(); + const legacy = await request(app) + .get("/api/me") + .set("Authorization", "Bearer x"); + const v1 = await request(app) + .get("/api/v1/me") + .set("Authorization", "Bearer x"); + + expect(legacy.status).toBe(200); + expect(v1.status).toBe(200); + expect(v1.body).toEqual(legacy.body); + expect(v1.body).toEqual(fixture); + }); +}); diff --git a/apps/web/src/test/contract/me.contract.test.ts b/apps/web/src/test/contract/me.contract.test.ts new file mode 100644 index 000000000..edcf10ca4 --- /dev/null +++ b/apps/web/src/test/contract/me.contract.test.ts @@ -0,0 +1,123 @@ +/** + * Contract test for `GET /api/me` / `GET /api/v1/me`. + * + * **Goal:** prove that the canonical wire-shape fixtures in + * `@sergeant/shared/contract-fixtures/me` are accepted by the + * api-client consumer side **byte-for-byte**, so any future drift + * between the schema (`MeResponseSchema`) and either the fixture or + * the consumer's parser fails CI here — not in production. + * + * Closes diagnostic + * [`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 + * (web↔server contract gap). + * + * The matching producer-side test lives in + * `apps/server/src/routes/me.contract.test.ts`. Together they form the + * minimal viable contract for `/api/me`. New endpoints follow the same + * 2-file pattern (consumer + producer over a shared fixture). + */ + +import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; +import { + meFixtures, + meRawFixtures, + assertMeFixturesValid, + MeResponseSchema, + type MeFixtureCase, +} from "@sergeant/shared"; +import { createHttpClient } from "@sergeant/api-client"; +import { createMeEndpoints } from "@sergeant/api-client"; + +const FIXTURE_NAMES: readonly MeFixtureCase[] = [ + "minimal", + "full", + "legacyNoCreatedAt", + "unverified", +] as const; + +function jsonResponse(body: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + ...init, + }); +} + +let originalFetch: typeof fetch; + +beforeEach(() => { + originalFetch = globalThis.fetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); +}); + +describe("contract: /api/me", () => { + it("every named fixture parses through MeResponseSchema (sanity)", () => { + // assertMeFixturesValid throws on the first fixture that no longer + // matches the schema. Keep it cheap so this whole test file can be + // a quick CI gate. + expect(() => assertMeFixturesValid()).not.toThrow(); + }); + + it.each(FIXTURE_NAMES)( + "fixture %s round-trips through the api-client consumer", + async (name) => { + const fixture = meRawFixtures[name]; + + // Mock the network so the api-client receives the canonical JSON + // verbatim. If the schema gets stricter or the fixture loses a + // required field, `MeResponseSchema.parse()` inside `me.get()` + // throws a ZodError — and CI fails here, not in browser logs. + const fetchMock = vi.fn(async () => jsonResponse(fixture)); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const http = createHttpClient({ baseUrl: "http://contract.test" }); + const me = createMeEndpoints(http); + + const result = await me.get(); + + // Deep-equal: api-client must NOT silently strip unknown fields, + // remap nullables, or coerce strings to numbers without explicit + // schema support. + expect(result).toEqual(meFixtures[name]); + expect(fetchMock).toHaveBeenCalledOnce(); + }, + ); + + it("rejects a payload missing a required field (drift detection)", async () => { + // Drop `emailVerified` to simulate a server regression where the + // serializer forgets a Hard Rule #3 update. The api-client must + // refuse the response — masking it would silently drop the + // verification banner in the UI. + const broken = { + user: { + id: "user_broken_001", + email: "broken@example.com", + name: null, + image: null, + // emailVerified missing on purpose. + createdAt: "2026-01-01T00:00:00.000Z", + }, + }; + globalThis.fetch = vi.fn( + async () => jsonResponse(broken), + ) as unknown as typeof fetch; + + const http = createHttpClient({ baseUrl: "http://contract.test" }); + const me = createMeEndpoints(http); + + await expect(me.get()).rejects.toThrow(); + }); + + it("`MeResponseSchema` accepts every fixture as `unknown` JSON", () => { + // This is the producer-side guarantee mirrored on the consumer: + // the same schema accepts the same fixtures from both directions. + for (const name of FIXTURE_NAMES) { + const parsed = MeResponseSchema.parse(meRawFixtures[name]); + expect(parsed).toEqual(meFixtures[name]); + } + }); +}); diff --git a/docs/diagnostics/2026-05-03-web-deep-dive/00-overview.md b/docs/diagnostics/2026-05-03-web-deep-dive/00-overview.md index 5b0bfa363..1534ef9b0 100644 --- a/docs/diagnostics/2026-05-03-web-deep-dive/00-overview.md +++ b/docs/diagnostics/2026-05-03-web-deep-dive/00-overview.md @@ -1,6 +1,6 @@ # Web deep-dive — Overview -> **Last validated:** 2026-05-03 by @Skords-01. +> **Last validated:** 2026-05-04 by @Skords-01. > **Status:** Active > **Scope:** `apps/web` + `apps/server` + `packages/*` (mobile — лише дотичні точки). > **Related:** @@ -90,7 +90,7 @@ | 11 | `index.css` decomposition | 3 | 2 | 1.50 | [02 §1.4](./02-architecture-and-state.md) | | 12 | Rate-limiter `cost`-multiplier для AI streams | 3 | 2 | 1.50 | [03 §4.5](./03-backend-and-performance.md) | | 13 | OpenAPI generation + typed client out of zod | 4 | 3 | 1.33 | [03 §4.7](./03-backend-and-performance.md) | -| 14 | Contract tests web↔server | 4 | 2 | 2.00 | [04 §7.4](./04-security-observability-testing-devx.md) | +| 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) | | 15 | `tsconfig.strict: true` для `apps/web` поетапно | 5 | 4 | 1.25 | [02 §1.0](./02-architecture-and-state.md) | | 16 | Storybook для top 20 компонентів | 3 | 3 | 1.00 | [04 §8.6](./04-security-observability-testing-devx.md) | | 17 | Mutation testing на критичних модулях (квартально) | 4 | 4 | 1.00 | [04 §7.3](./04-security-observability-testing-devx.md) | diff --git a/docs/diagnostics/2026-05-03-web-deep-dive/04-security-observability-testing-devx.md b/docs/diagnostics/2026-05-03-web-deep-dive/04-security-observability-testing-devx.md index 2d1fb79e8..3d414d69a 100644 --- a/docs/diagnostics/2026-05-03-web-deep-dive/04-security-observability-testing-devx.md +++ b/docs/diagnostics/2026-05-03-web-deep-dive/04-security-observability-testing-devx.md @@ -1,6 +1,6 @@ # Web deep-dive — Security, observability, testing & DevX -> **Last validated:** 2026-05-03 by @Skords-01. +> **Last validated:** 2026-05-04 by @Skords-01. > **Status:** Active > **Scope:** PII у логах, Sentry↔requestId correlation, CSP, contract тести, mutation testing, Storybook, C4-діаграми, CHANGELOG, Hard Rules registry, agent onboarding, audit docs status. > **Related:** [`00-overview.md`](./00-overview.md), `docs/audits/`, `docs/security/`, `docs/agents/`. @@ -194,6 +194,13 @@ ## 7.4 [Bad] No contract tests web↔server +> **2026-05-04 update.** Запущено мінімальний contract layer для `/api/me`: +> +> - Канонічні фікстури — `packages/shared/src/contract-fixtures/me.ts` (4 кейси: `minimal`, `full`, `legacyNoCreatedAt`, `unverified`). +> - Consumer side — `apps/web/src/test/contract/me.contract.test.ts` (api-client + `MeResponseSchema`). +> - Producer side — `apps/server/src/routes/me.contract.test.ts` (route handler через supertest). +> - 17 contract assertions, 0 production code touched. Pattern документовано в `packages/shared/src/contract-fixtures/README.md`. Наступні endpoint-и розширюють той самий каталог. + **Що бачу.** Pact / OpenAPI-validation немає. Кожна сторона припускає shape — це причина drift-у §4.7. **Recommendation.** diff --git a/packages/shared/src/contract-fixtures/README.md b/packages/shared/src/contract-fixtures/README.md new file mode 100644 index 000000000..31f9afa02 --- /dev/null +++ b/packages/shared/src/contract-fixtures/README.md @@ -0,0 +1,55 @@ +# Contract fixtures + +Single source of truth for canonical API request / response shapes, +shared between `apps/server` (producer), `packages/api-client` +(consumer), and `apps/web` / `apps/mobile` (UI). + +## Why + +The diagnostic in +[`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 calls out that we have unit tests on each side of the wire but +**no** test that locks the wire format itself. With the same `Zod` +schema imported on both sides, drift is theoretically impossible — but +practically, Hard Rule #3 in `AGENTS.md` ("API contract: server +response shape ↔ `api-client` types ↔ test") still relies on humans +remembering to update three files in the same PR. + +A contract fixture flips that around: + +- **One fixture**, checked in, hand-curated to be canonical. +- The server's tests run it through the response builder + schema + parser → the fixture is a "golden" shape the producer must emit. +- The api-client's tests feed the same fixture into a mocked `fetch` + → the consumer must accept it byte-for-byte. +- A fixture that no longer parses through the schema = the schema + changed without the fixture being updated. CI fails on **either** + side, immediately. + +## Layout + +``` +contract-fixtures/ +├── README.md ← this file +├── index.ts ← barrel +└── me.ts ← /api/me canonical shapes (User, MeResponse) +``` + +Each module exports: + +- `Fixtures` — a non-empty `Record` of named cases + (`minimal`, `full`, `legacyNoCreatedAt`, …). +- `RawFixtures` — the same cases as `unknown`, suitable for + feeding into a parser to verify it accepts the wire JSON. Useful when + you want to test the schema's `.parse()` path explicitly. + +## Adding a fixture + +1. Add a new case to the matching module (or create a new one keyed by + the route path: `me.ts`, `nutritionAnalyze.ts`, …). +2. Export it through `index.ts`. +3. Add an assertion in the corresponding contract test + (`apps/web/src/test/contract/.contract.test.ts`) that the + api-client accepts the fixture, AND in the server-side test that + the route emits the fixture given matching inputs. +4. CI now blocks any future schema drift that breaks either side. diff --git a/packages/shared/src/contract-fixtures/index.ts b/packages/shared/src/contract-fixtures/index.ts new file mode 100644 index 000000000..e50a40659 --- /dev/null +++ b/packages/shared/src/contract-fixtures/index.ts @@ -0,0 +1,5 @@ +/** + * Contract fixtures barrel — see `./README.md` for the rationale and + * the workflow for adding a new endpoint. + */ +export * from "./me"; diff --git a/packages/shared/src/contract-fixtures/me.ts b/packages/shared/src/contract-fixtures/me.ts new file mode 100644 index 000000000..1885322ff --- /dev/null +++ b/packages/shared/src/contract-fixtures/me.ts @@ -0,0 +1,85 @@ +/** + * Canonical fixtures for `GET /api/me` / `GET /api/v1/me`. + * + * The route is described in `apps/server/src/routes/me.ts` and consumed + * by `packages/api-client/src/endpoints/me.ts`. Both sides validate via + * `MeResponseSchema` from `../schemas/api`. + * + * Each named case represents a real shape the producer might emit: + * + * - `minimal` — newly created account, no display name, no avatar, + * email verified. + * - `full` — fully populated profile (name, avatar, email). + * - `legacyNoCreatedAt` — pre-`createdAt` accounts where the column was + * nullable. Schema must accept `createdAt: null` (see + * `UserSchema` rationale at `schemas/api.ts:32`). + * - `unverified` — email present but not yet verified — the UI + * conditionally surfaces a "verify email" banner off this flag. + */ + +import { MeResponseSchema, type MeResponse } from "../schemas/api"; + +export const meFixtures = { + minimal: { + user: { + id: "user_minimal_001", + email: "minimal@example.com", + name: null, + image: null, + emailVerified: true, + createdAt: "2026-01-15T10:30:00.000Z", + }, + }, + full: { + user: { + id: "user_full_002", + email: "full@example.com", + name: "Тест Фульний", + image: "https://avatars.example.com/full.png", + emailVerified: true, + createdAt: "2025-09-01T08:15:42.000Z", + }, + }, + legacyNoCreatedAt: { + user: { + id: "user_legacy_003", + email: "legacy@example.com", + name: "Legacy User", + image: null, + emailVerified: true, + createdAt: null, + }, + }, + unverified: { + user: { + id: "user_unverified_004", + email: "pending@example.com", + name: null, + image: null, + emailVerified: false, + createdAt: "2026-04-20T12:00:00.000Z", + }, + }, +} as const satisfies Record; + +export type MeFixtureCase = keyof typeof meFixtures; + +/** + * Same fixtures, but typed as `unknown` — pass these into + * `MeResponseSchema.parse()` to exercise the runtime parser path. The + * `as const satisfies …` shape above already proves the static type is + * valid; the `unknown` view proves the schema also accepts the JSON. + */ +export const meRawFixtures: Record = meFixtures; + +/** Cheap self-check: every named fixture must parse through the schema. */ +export function assertMeFixturesValid(): void { + for (const [name, fixture] of Object.entries(meFixtures)) { + const result = MeResponseSchema.safeParse(fixture); + if (!result.success) { + throw new Error( + `Contract fixture "me.${name}" no longer matches MeResponseSchema: ${result.error.message}`, + ); + } + } +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 54679a988..213158193 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -116,3 +116,9 @@ export * from "./lib/fileImport"; // DOM-free visual-keyboard-inset hook contract (platform adapters register at // app bootstrap). export * from "./hooks/useVisualKeyboardInset"; + +// Contract fixtures — canonical wire-shape samples shared between +// `apps/server` (producer) and `packages/api-client` (consumer). See +// `./contract-fixtures/README.md` and the diagnostic in +// `docs/diagnostics/2026-05-03-web-deep-dive/04-security-observability-testing-devx.md` §7.4. +export * from "./contract-fixtures";