mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
* 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>
99 lines
3.3 KiB
TypeScript
99 lines
3.3 KiB
TypeScript
/**
|
|
* Integration regression guard for `src/lib/auth/session.ts`.
|
|
*
|
|
* The session helpers couple Postgres (`Session` row), the `cookies()`
|
|
* adapter from `next/headers`, and the `db-compat` schema-migration
|
|
* fallback. A unit test with a mocked Prisma client cannot verify the
|
|
* end-to-end flow — particularly the expired-session purge behaviour,
|
|
* which silently deletes the row before returning null.
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
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);
|
|
},
|
|
})),
|
|
}));
|
|
|
|
// `db-compat` runs ALTER TABLE IF NOT EXISTS statements that are no-ops
|
|
// against a freshly-migrated schema but slow tests down — short-circuit.
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
});
|
|
|
|
describe("session lifecycle (real Postgres)", () => {
|
|
it("createSession writes a row and getSession reads it back", async () => {
|
|
const { createSession, getSession } = await import("@/lib/auth/session");
|
|
|
|
const user = await getPrismaClient().user.create({
|
|
data: { username: "session-test", email: "session@example.test" },
|
|
});
|
|
|
|
const sessionId = await createSession(user.id, "127.0.0.1", "vitest/1.0");
|
|
|
|
const row = await getPrismaClient().session.findUnique({
|
|
where: { id: sessionId },
|
|
});
|
|
expect(row).not.toBeNull();
|
|
expect(row?.userId).toBe(user.id);
|
|
expect(row?.ipAddress).toBe("127.0.0.1");
|
|
expect(row?.userAgent).toBe("vitest/1.0");
|
|
|
|
const result = await getSession();
|
|
expect(result).not.toBeNull();
|
|
expect(result?.user.id).toBe(user.id);
|
|
expect(result?.session.id).toBe(sessionId);
|
|
});
|
|
|
|
it("rejects expired sessions and purges the stale row", async () => {
|
|
const { createSession, getSession } = await import("@/lib/auth/session");
|
|
|
|
const user = await getPrismaClient().user.create({
|
|
data: { username: "expired-session", email: "expired@example.test" },
|
|
});
|
|
|
|
const sessionId = await createSession(user.id);
|
|
|
|
// Force the row past its expiry — getSession must purge it.
|
|
await getPrismaClient().session.update({
|
|
where: { id: sessionId },
|
|
data: { expiresAt: new Date(Date.now() - 60_000) },
|
|
});
|
|
|
|
const result = await getSession();
|
|
expect(result).toBeNull();
|
|
|
|
const purged = await getPrismaClient().session.findUnique({
|
|
where: { id: sessionId },
|
|
});
|
|
expect(purged).toBeNull();
|
|
});
|
|
|
|
it("returns null when no session cookie is present", async () => {
|
|
const { getSession } = await import("@/lib/auth/session");
|
|
expect(await getSession()).toBeNull();
|
|
});
|
|
});
|