Files
HealthLog/tests/integration/idempotency-replay.test.ts
T
Marc-André BombeckandClaude Opus 4.7 3cacdf294e 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>
2026-05-09 18:08:11 +02:00

175 lines
6.0 KiB
TypeScript

/**
* Integration regression guard for `src/lib/idempotency.ts`.
*
* Mobile clients retry POST/PUT/PATCH/DELETE with the same
* `Idempotency-Key`; the wrapper must replay the cached response without
* re-running the side-effect. These tests exercise the cache table
* (`idempotency_keys`) end-to-end against a real Postgres.
*/
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 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", 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),
}));
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({
data: {
id: TEST_USER_ID,
username: "idempotency-test",
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 {
const headers: Record<string, string> = {
"content-type": "application/json",
};
if (key) headers["idempotency-key"] = key;
return new NextRequest(`http://localhost${path}`, {
method: "POST",
headers,
body: JSON.stringify({ ok: true }),
});
}
describe("withIdempotency (real Postgres)", () => {
it("replays the cached response and skips the handler on retry", async () => {
const { withIdempotency } = await import("@/lib/idempotency");
let invocations = 0;
const handler = withIdempotency(async () => {
invocations++;
return NextResponse.json({ count: invocations }, { status: 201 });
});
const first = await handler(makeRequest("abc-12345678"));
expect(first.status).toBe(201);
expect(await first.json()).toEqual({ count: 1 });
expect(invocations).toBe(1);
const replay = await handler(makeRequest("abc-12345678"));
expect(replay.status).toBe(201);
expect(replay.headers.get("X-Idempotent-Replay")).toBe("true");
expect(await replay.json()).toEqual({ count: 1 });
expect(invocations).toBe(1); // handler did NOT run again
const rows = await getPrismaClient().idempotencyKey.findMany({
where: { userId: TEST_USER_ID, key: "abc-12345678" },
});
expect(rows).toHaveLength(1);
});
it("re-runs the handler after the cached row's TTL has elapsed", async () => {
const { withIdempotency } = await import("@/lib/idempotency");
let invocations = 0;
const handler = withIdempotency(async () => {
invocations++;
return NextResponse.json({ count: invocations }, { status: 201 });
});
await handler(makeRequest("expired-12345678"));
expect(invocations).toBe(1);
// Force the cached row past its expiry — the wrapper purges and
// falls through to the handler.
await getPrismaClient().idempotencyKey.updateMany({
where: { userId: TEST_USER_ID, key: "expired-12345678" },
data: { expiresAt: new Date(Date.now() - 1_000) },
});
const second = await handler(makeRequest("expired-12345678"));
expect(second.status).toBe(201);
expect(await second.json()).toEqual({ count: 2 });
expect(invocations).toBe(2);
const rows = await getPrismaClient().idempotencyKey.findMany({
where: { userId: TEST_USER_ID, key: "expired-12345678" },
});
// Original row was deleted as stale, then a fresh one was inserted.
expect(rows).toHaveLength(1);
expect(rows[0]?.expiresAt.getTime()).toBeGreaterThan(Date.now());
});
it("does NOT cache 401/403/408/429/5xx responses", async () => {
const { withIdempotency } = await import("@/lib/idempotency");
const cases = [401, 403, 408, 429, 500, 502, 503];
for (const status of cases) {
const key = `noncacheable-${status}-abcdef`;
const handler = withIdempotency(async () =>
NextResponse.json({ status }, { status }),
);
const response = await handler(makeRequest(key));
expect(response.status).toBe(status);
const row = await getPrismaClient().idempotencyKey.findFirst({
where: { userId: TEST_USER_ID, key },
});
expect(row).toBeNull();
}
});
it("DOES cache 4xx-validation responses (e.g. 422)", async () => {
const { withIdempotency } = await import("@/lib/idempotency");
const handler = withIdempotency(async () =>
NextResponse.json({ error: "validation" }, { status: 422 }),
);
await handler(makeRequest("validation-12345678"));
const row = await getPrismaClient().idempotencyKey.findFirst({
where: { userId: TEST_USER_ID, key: "validation-12345678" },
});
expect(row?.responseStatus).toBe(422);
});
});