mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
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:
1 parent
f0d53fb25c
commit
3cacdf294e
4 files changed
+103
-59
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>();
|
||||
Reference in new issue
Block a user