From 3cacdf294e12a52e0a93ba61a0c6a3dc25697655 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 9 May 2026 18:08:11 +0200 Subject: [PATCH] fix(tests): share next/headers cookieJar across integration suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The integration suite runs with `vitest isolate: false` (one worker, one container — migration cost demands it). Each test file declared its own `vi.mock("next/headers", ...)` whose factory closed over a file-local `cookieJar` Map. Vitest resolves a mock factory ONCE per worker — whichever file loaded first won the registration, and the other files' Map writes silently disappeared. That made `admin-data-wipe.test.ts` flake intermittently (~12.5% per empirical 8-run sample) when `idempotency-replay.test.ts` happened to load first: its `vi.mock("@/lib/auth/session", ...)` then leaked, returning a stub user without an admin role, so `requireAdmin()` returned 401 and the wipe assertion failed with `expected 401 to be 200`. Fix: extract cookieJar + headerJar into module-singleton Maps in `tests/integration/mock-next-headers.ts`. All three integration files import them and resolve them via dynamic-import inside the `vi.mock("next/headers", ...)` factory body, so the factory always sees the same backing Maps regardless of which file's registration won the race. The `vi.mock("@/lib/auth/session", ...)` stub in idempotency-replay was also removed; the test now seeds a real Session row + cookie like the other two files. After: 10/10 consecutive `pnpm test:integration` runs green. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/integration/admin-data-wipe.test.ts | 45 ++++++++-------- tests/integration/auth-flow.test.ts | 40 ++++++++------ tests/integration/idempotency-replay.test.ts | 57 ++++++++++++-------- tests/integration/mock-next-headers.ts | 20 +++++++ 4 files changed, 103 insertions(+), 59 deletions(-) create mode 100644 tests/integration/mock-next-headers.ts diff --git a/tests/integration/admin-data-wipe.test.ts b/tests/integration/admin-data-wipe.test.ts index 6ce84e255..1712129fd 100644 --- a/tests/integration/admin-data-wipe.test.ts +++ b/tests/integration/admin-data-wipe.test.ts @@ -18,31 +18,34 @@ */ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; // next/headers cookie + ip headers stub for getSession() inside // requireAdmin(). The admin route reads x-forwarded-for too; we stub -// both to keep the handler reachable. -const cookieJar = new Map(); -const headerJar = new Map(); - -vi.mock("next/headers", () => ({ - 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); - }, - })), -})); +// both to keep the handler reachable. Maps live in +// `mock-next-headers.ts` — see that file for the rationale (suite +// runs with vitest `isolate: false`, so per-file Maps would leak). +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), diff --git a/tests/integration/auth-flow.test.ts b/tests/integration/auth-flow.test.ts index 0207ab317..756ee9e30 100644 --- a/tests/integration/auth-flow.test.ts +++ b/tests/integration/auth-flow.test.ts @@ -9,28 +9,34 @@ */ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { cookieJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; // Stateful in-memory cookie jar so `createSession()` (which calls // cookies().set) hands the value off to a subsequent `getSession()` // (which calls cookies().get), exactly as the request lifecycle does. -const cookieJar = new Map(); - -vi.mock("next/headers", () => ({ - headers: vi.fn(async () => ({ get: () => 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); - }, - })), -})); +// The Map lives in `mock-next-headers.ts` so all integration files +// share the same backing store under vitest `isolate: false`. +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); + }, + })), + }; +}); // `db-compat` runs ALTER TABLE IF NOT EXISTS statements that are no-ops // against a freshly-migrated schema but slow tests down — short-circuit. diff --git a/tests/integration/idempotency-replay.test.ts b/tests/integration/idempotency-replay.test.ts index 04a0f72bf..567760834 100644 --- a/tests/integration/idempotency-replay.test.ts +++ b/tests/integration/idempotency-replay.test.ts @@ -9,32 +9,37 @@ import { NextRequest, NextResponse } from "next/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { cookieJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; // `withIdempotency()` calls `getSession()` (cookie-backed) by default and -// falls back to a Bearer-token lookup. We stub `next/headers` to return -// a fixed user id via the cookie path so the integration stays focused -// on the cache-replay contract rather than the auth machinery. +// falls back to a Bearer-token lookup. We seed a real Session row + +// cookie jar so the integration exercises the production auth path — +// no `vi.mock("@/lib/auth/session", ...)` (a module-level mock leaks +// across files under vitest `isolate: false` and made +// `admin-data-wipe.test.ts` flake when this file loaded first). const TEST_USER_ID = "user-idempotency-test"; -vi.mock("next/headers", () => ({ - headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ - get: () => undefined, - set: () => {}, - delete: () => {}, - })), -})); - -vi.mock("@/lib/auth/session", () => ({ - getSession: vi.fn(async () => ({ - session: { - id: "session-stub", - expiresAt: new Date(Date.now() + 1_000_000), - }, - user: { id: TEST_USER_ID }, - })), -})); +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), @@ -42,6 +47,7 @@ vi.mock("@/lib/db-compat", () => ({ beforeEach(async () => { await truncateAllTables(getPrismaClient()); + cookieJar.clear(); // Seed the user the resolver claims to authenticate as so the FK // constraint on idempotency_keys.user_id holds. await getPrismaClient().user.create({ @@ -51,6 +57,15 @@ beforeEach(async () => { email: "idempotency@example.test", }, }); + // Real Session row + cookie so getSession() returns the seeded user + // via the production code path. + const session = await getPrismaClient().session.create({ + data: { + userId: TEST_USER_ID, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); }); function makeRequest(key: string | null, path = "/api/test"): NextRequest { diff --git a/tests/integration/mock-next-headers.ts b/tests/integration/mock-next-headers.ts new file mode 100644 index 000000000..e2acd04f0 --- /dev/null +++ b/tests/integration/mock-next-headers.ts @@ -0,0 +1,20 @@ +/** + * Shared `next/headers` mock state for the integration suite. + * + * Background: vitest runs the integration suite with `isolate: false` + * (one worker, one container). The factory passed to + * `vi.mock("next/headers", ...)` is resolved ONCE per worker — + * whichever test file loads first wins. If two files each declare a + * top-level `const cookieJar = new Map()` and reference it from their + * own `vi.mock("next/headers", ...)` factory, only the first file's + * Map is ever read; the second file's writes silently disappear and + * its tests flake based on import order. + * + * Fix: every integration file imports `cookieJar` + `headerJar` from + * THIS module (a singleton) and clears them in `beforeEach`. The + * `vi.mock` factory inside each test file resolves the Maps via + * `await import()` so it sees the same Map instances regardless of + * which file's mock factory ran first. + */ +export const cookieJar = new Map(); +export const headerJar = new Map();