Files
HealthLog/tests/integration/idempotency-replay.test.ts
T
accdb7d44d test(integration): Postgres testcontainers + 4 critical-path tests (rate-limit race, idempotency, cascade, session) (#137)
* test(integration): Postgres testcontainers + 4 critical-path tests

Adds an integration test suite that runs against a real Postgres booted
in-process via testcontainers. Lives under tests/integration/, runs via
`pnpm test:integration`, and is excluded from the default `pnpm test`
loop so the 477-test unit suite stays fast (~1.2s).

Covers four regression-prone behaviours that pure unit tests cannot
validate against a mocked client:

- rate-limit.test.ts — atomic UPSERT under concurrency (5/6 allowed)
  and window-reset semantics for src/lib/rate-limit.ts
- idempotency-replay.test.ts — cached replay with X-Idempotent-Replay,
  expired-row purge, and the do-not-cache contract for 401/403/408/429/5xx
  on top of src/lib/idempotency.ts
- cascade-delete.test.ts — GDPR Art. 17 erasure: user.delete wipes
  every onDelete:Cascade table and SetNulls AuditLog/Feedback
- auth-flow.test.ts — createSession/getSession round-trip plus
  expired-session purge for src/lib/auth/session.ts

Adds .github/workflows/integration.yml triggering on pull_request and
push to main. Local Docker is not running in this worktree environment;
the suite was verified by collecting all 10 tests via `vitest list` and
will execute in CI.

Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(integration): rebase onto v1.4 + exclude live agent worktrees from default test scope

Rebase brings PR #137's testcontainers infrastructure on top of the
user-locale migration drift fix landed in #140 — the integration suite
should now bootstrap a fresh Postgres container with all 25 migrations
applied cleanly.

vitest.config.mts also gains a `.claude/worktrees/**` exclude so live
agent worktrees (which mirror `src/` while their parent agents are
running) don't double-execute the project's tests against possibly-stale
copies.

* test(integration): boot one Postgres container per run via vitest globalSetup

The first iteration of the testcontainers suite started/stopped a
fresh container in each test file's beforeAll/afterAll. That works
in isolation but breaks when files share a worker (`pool: forks`,
`fileParallelism: false`, `isolate: false`):

  1. Test file A boots container 1, sets DATABASE_URL.
  2. The application's Prisma singleton in src/lib/db.ts is built
     when test code dynamic-imports @/lib/auth/session — it captures
     container 1's URL.
  3. afterAll stops container 1.
  4. Test file B boots container 2, sets DATABASE_URL to a new port.
  5. Test file B imports application code that resolves to the same
     singleton — still pointing at the dead container 1. Queries fail
     with a generic PrismaClientKnownRequestError.

Fix: move the container lifecycle to vitest's globalSetup. One
container, booted once, torn down at the end of the run. Tests still
get isolation because beforeEach truncates every personal-data table
in dependency-safe CASCADE order.

  - tests/integration/global-setup.ts boots postgres:16-alpine,
    runs `pnpm db:migrate:deploy`, exports a teardown for end-of-run.
  - tests/integration/setup.ts now exports getPrismaClient() (which
    delegates to the application singleton) and truncateAllTables().
    No more startTestDb / stopTestDb.
  - All four test files lose their beforeAll/afterAll boilerplate.
  - vitest.integration.config.mts wires the new globalSetup.
  - truncate list adds refresh_tokens (added in 0025_refresh_tokens).

Result: pnpm test:integration → 10/10 pass in 3.9s (was 6/10 with
4 cross-file failures). pnpm test → 658/658 unchanged.

Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 16:03:31 +02:00

160 lines
5.4 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 { 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.
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("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
// 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",
},
});
});
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);
});
});