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();