fix(tests): share next/headers cookieJar across integration suite

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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 18:08:11 +02:00
1 parent f0d53fb25c
commit 3cacdf294e
4 files changed
+103 -59

No files matched your search

+24 -21
View File
@@ -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<string, string>();
const headerJar = new Map<string, string>();
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),
+23 -17
View File
@@ -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<string, string>();
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.
+36 -21
View File
@@ -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 {
+20
View File
@@ -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<string, string>();
export const headerJar = new Map<string, string>();