mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the login, register, token and session paths, mirroring columns every migration under prisma/migrations already creates. The entrypoint applies those migrations before the server listens and stops the boot when one fails, so the mirror never had work to do, and DDL on a request path needs the table's owner: a connection that may only read and write rows fails every login on it. Drop the module, its call sites and the mocks that stubbed it out.
171 lines
5.9 KiB
TypeScript
171 lines
5.9 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);
|
|
},
|
|
})),
|
|
};
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|