mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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>
This commit is contained in:
11 files changed
+1701
-13
No files matched your search
@@ -0,0 +1,34 @@
|
||||
name: Integration tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
integration:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- run: pnpm db:generate
|
||||
|
||||
- run: pnpm test:integration
|
||||
env:
|
||||
# The integration suite boots a Postgres testcontainer and points
|
||||
# Prisma at it via DATABASE_URL set inside `tests/integration/setup.ts`.
|
||||
# These env vars satisfy code paths that read them at module load.
|
||||
API_TOKEN_HMAC_KEY: integration-tests-do-not-use-in-prod
|
||||
ENCRYPTION_KEY: 0000000000000000000000000000000000000000000000000000000000000000
|
||||
SESSION_SECRET: integration-tests
|
||||
+4
-1
@@ -17,7 +17,8 @@
|
||||
"format:check": "prettier --check .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"test:integration": "vitest run --config vitest.integration.config.mts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1045.0",
|
||||
@@ -51,6 +52,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testcontainers/postgresql": "^11.14.0",
|
||||
"@types/node": "^25",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
@@ -64,6 +66,7 @@
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"shadcn": "^4.6.0",
|
||||
"tailwindcss": "^4",
|
||||
"testcontainers": "^11.14.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^6",
|
||||
"vitest": "^4.1.5"
|
||||
|
||||
Generated
+969
-11
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Integration regression guard for the GDPR Art. 17 erasure path.
|
||||
*
|
||||
* Deleting a user must wipe every personal-data row that references
|
||||
* them. The schema declares `onDelete: Cascade` for personal data and
|
||||
* `onDelete: SetNull` for audit-style rows (AuditLog, Feedback). This
|
||||
* test verifies the actual database FKs match those declarations — a
|
||||
* missing CASCADE would leave orphan rows behind, violating GDPR.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
});
|
||||
|
||||
describe("user.delete cascades to all personal-data tables", () => {
|
||||
it("removes every personal-data row when the user is deleted", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "erasure-target",
|
||||
email: "erasure@example.test",
|
||||
},
|
||||
});
|
||||
|
||||
// Seed personal data across every cascading relation.
|
||||
await prisma.session.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.passkey.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
credentialId: "cred-1",
|
||||
credentialPublicKey: Buffer.from("pubkey"),
|
||||
counter: BigInt(0),
|
||||
credentialDeviceType: "singleDevice",
|
||||
transports: ["internal"],
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: "WEIGHT",
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
measuredAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const med = await prisma.medication.create({
|
||||
data: { userId: user.id, name: "Aspirin", dose: "100mg" },
|
||||
});
|
||||
|
||||
await prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
medicationId: med.id,
|
||||
scheduledFor: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.moodEntry.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
date: "2026-05-08",
|
||||
mood: "GUT",
|
||||
score: 4,
|
||||
moodLoggedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.apiToken.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: "test-token",
|
||||
tokenHash: "hash-" + user.id,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.dataBackup.create({
|
||||
data: { userId: user.id, type: "MANUAL", data: "{}" },
|
||||
});
|
||||
|
||||
await prisma.userAchievement.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
achievementId: "first-login",
|
||||
unlockedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.idempotencyKey.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
key: "abcdef12345678",
|
||||
method: "POST",
|
||||
path: "/api/test",
|
||||
responseStatus: 200,
|
||||
responseBody: "{}",
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.device.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
platform: "ios",
|
||||
token: "apns-token-" + user.id,
|
||||
bundleId: "app.healthlog.ios",
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.pushSubscription.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
endpoint: "https://push.example/sub-" + user.id,
|
||||
p256dh: "p256dh",
|
||||
auth: "auth",
|
||||
},
|
||||
});
|
||||
|
||||
// Audit logs / feedback are intentionally SetNull, not Cascade —
|
||||
// they survive deletion with a null userId for compliance triage.
|
||||
const auditRow = await prisma.auditLog.create({
|
||||
data: { userId: user.id, action: "auth.login" },
|
||||
});
|
||||
const feedbackRow = await prisma.feedback.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
category: "BUG",
|
||||
subject: "test",
|
||||
description: "test",
|
||||
},
|
||||
});
|
||||
|
||||
// ── act ──
|
||||
await prisma.user.delete({ where: { id: user.id } });
|
||||
|
||||
// ── assert: every cascading table is empty for that user id ──
|
||||
expect(await prisma.session.count({ where: { userId: user.id } })).toBe(0);
|
||||
expect(await prisma.passkey.count({ where: { userId: user.id } })).toBe(0);
|
||||
expect(await prisma.measurement.count({ where: { userId: user.id } })).toBe(
|
||||
0,
|
||||
);
|
||||
expect(await prisma.medication.count({ where: { userId: user.id } })).toBe(
|
||||
0,
|
||||
);
|
||||
expect(
|
||||
await prisma.medicationIntakeEvent.count({
|
||||
where: { userId: user.id },
|
||||
}),
|
||||
).toBe(0);
|
||||
expect(await prisma.moodEntry.count({ where: { userId: user.id } })).toBe(
|
||||
0,
|
||||
);
|
||||
expect(await prisma.apiToken.count({ where: { userId: user.id } })).toBe(0);
|
||||
expect(await prisma.dataBackup.count({ where: { userId: user.id } })).toBe(
|
||||
0,
|
||||
);
|
||||
expect(
|
||||
await prisma.userAchievement.count({ where: { userId: user.id } }),
|
||||
).toBe(0);
|
||||
expect(
|
||||
await prisma.idempotencyKey.count({ where: { userId: user.id } }),
|
||||
).toBe(0);
|
||||
expect(await prisma.device.count({ where: { userId: user.id } })).toBe(0);
|
||||
expect(
|
||||
await prisma.pushSubscription.count({ where: { userId: user.id } }),
|
||||
).toBe(0);
|
||||
|
||||
// SetNull rows survive but lose their userId.
|
||||
const auditAfter = await prisma.auditLog.findUnique({
|
||||
where: { id: auditRow.id },
|
||||
});
|
||||
expect(auditAfter?.userId).toBeNull();
|
||||
|
||||
const feedbackAfter = await prisma.feedback.findUnique({
|
||||
where: { id: feedbackRow.id },
|
||||
});
|
||||
expect(feedbackAfter?.userId).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Vitest globalSetup — boots ONE Postgres testcontainer for the whole
|
||||
* integration run, applies migrations, and exposes the connection URL
|
||||
* via process.env.DATABASE_URL. The teardown returned at the end stops
|
||||
* the container after all test files have finished.
|
||||
*
|
||||
* Why globalSetup instead of beforeAll/afterAll per file:
|
||||
* - The application's Prisma singleton in `src/lib/db.ts` is built at
|
||||
* module load time using the current `process.env.DATABASE_URL`. If
|
||||
* the container is rotated between test files, the singleton keeps
|
||||
* pointing at the dead container and queries fail. Booting once,
|
||||
* keeping the URL stable, and truncating between tests is both
|
||||
* faster and safer.
|
||||
* - Truncate-between-tests still gives per-test isolation; see
|
||||
* `setup.ts -> truncateAllTables`.
|
||||
*/
|
||||
import { execSync } from "node:child_process";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
PostgreSqlContainer,
|
||||
type StartedPostgreSqlContainer,
|
||||
} from "@testcontainers/postgresql";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const PROJECT_ROOT = resolve(__dirname, "..", "..");
|
||||
|
||||
let container: StartedPostgreSqlContainer | null = null;
|
||||
|
||||
export default async function setup(): Promise<() => Promise<void>> {
|
||||
container = await new PostgreSqlContainer("postgres:16-alpine")
|
||||
.withDatabase("healthlog_test")
|
||||
.withUsername("healthlog")
|
||||
.withPassword("healthlog")
|
||||
.start();
|
||||
|
||||
const url = container.getConnectionUri() + "?schema=public&pgbouncer=false";
|
||||
process.env.DATABASE_URL = url;
|
||||
|
||||
// Provided so child processes (e.g. Prisma's CLI) inherit the URL.
|
||||
// Vitest also forwards process.env to test workers, so the
|
||||
// application Prisma singleton sees this URL when it loads.
|
||||
execSync("pnpm db:migrate:deploy", {
|
||||
cwd: PROJECT_ROOT,
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, DATABASE_URL: url },
|
||||
});
|
||||
|
||||
return async () => {
|
||||
if (container) {
|
||||
await container.stop({ remove: true });
|
||||
container = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Integration regression guard for `src/lib/rate-limit.ts`.
|
||||
*
|
||||
* The limiter relies on a single atomic SQL upsert (`INSERT ... ON
|
||||
* CONFLICT DO UPDATE`) so concurrent calls cannot exceed the cap. These
|
||||
* tests prove that contract against a real Postgres — a unit test with
|
||||
* a mocked client could not detect a missing UPSERT or a misplaced
|
||||
* window-reset branch.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
});
|
||||
|
||||
describe("checkRateLimit (real Postgres)", () => {
|
||||
it("permits exactly `limit` of N concurrent calls in the same window", async () => {
|
||||
// Lazy import so `process.env.DATABASE_URL` is set by startTestDb()
|
||||
// *before* the module pulls in `@/lib/db`.
|
||||
const { checkRateLimit } = await import("@/lib/rate-limit");
|
||||
|
||||
const key = "test:concurrent:1.2.3.4";
|
||||
const limit = 5;
|
||||
const windowMs = 60_000;
|
||||
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 6 }, () => checkRateLimit(key, limit, windowMs)),
|
||||
);
|
||||
|
||||
const allowed = results.filter((r) => r.allowed).length;
|
||||
const denied = results.filter((r) => !r.allowed).length;
|
||||
|
||||
expect(allowed).toBe(5);
|
||||
expect(denied).toBe(1);
|
||||
|
||||
const row = await getPrismaClient().rateLimit.findUnique({
|
||||
where: { key },
|
||||
});
|
||||
expect(row?.count).toBe(6);
|
||||
});
|
||||
|
||||
it("resets the counter once the window expires", async () => {
|
||||
const { checkRateLimit } = await import("@/lib/rate-limit");
|
||||
|
||||
const key = "test:reset:1.2.3.4";
|
||||
const limit = 3;
|
||||
|
||||
// Burn the budget with a short 50ms window.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const r = await checkRateLimit(key, limit, 50);
|
||||
expect(r.allowed).toBe(true);
|
||||
}
|
||||
const denied = await checkRateLimit(key, limit, 50);
|
||||
expect(denied.allowed).toBe(false);
|
||||
|
||||
// Manually expire the window: cheaper than sleeping and avoids
|
||||
// flaky timing on slow CI runners. The branch under test compares
|
||||
// `reset_at < NOW()`, so any past timestamp triggers the reset.
|
||||
await getPrismaClient().rateLimit.update({
|
||||
where: { key },
|
||||
data: { resetAt: new Date(Date.now() - 1_000) },
|
||||
});
|
||||
|
||||
const afterReset = await checkRateLimit(key, limit, 60_000);
|
||||
expect(afterReset.allowed).toBe(true);
|
||||
|
||||
const row = await getPrismaClient().rateLimit.findUnique({
|
||||
where: { key },
|
||||
});
|
||||
expect(row?.count).toBe(1);
|
||||
expect(row?.resetAt.getTime()).toBeGreaterThan(Date.now());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Per-test helpers for the integration suite. The Postgres testcontainer
|
||||
* is started ONCE in `global-setup.ts` and torn down at the end of the
|
||||
* run, so test files only need:
|
||||
*
|
||||
* import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
*
|
||||
* beforeEach(async () => { await truncateAllTables(getPrismaClient()); });
|
||||
*
|
||||
* No beforeAll(startTestDb) / afterAll(stopTestDb) is needed — the
|
||||
* container is alive whenever the test file is loaded, and Vitest's
|
||||
* worker inherits `process.env.DATABASE_URL` from globalSetup so the
|
||||
* application's Prisma singleton (`src/lib/db.ts`) connects to the
|
||||
* testcontainer transparently.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* The application's Prisma singleton. Tests use this so any code
|
||||
* imported via `await import("@/lib/...")` shares the exact same client
|
||||
* instance — no risk of a divergent connection pool reading stale
|
||||
* truncation state.
|
||||
*/
|
||||
export function getPrismaClient(): PrismaClient {
|
||||
return prisma;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate every user-facing table in dependency-safe order using
|
||||
* `TRUNCATE … RESTART IDENTITY CASCADE`. Call from beforeEach() to make
|
||||
* tests independent. The list mirrors `prisma/schema.prisma` minus
|
||||
* prisma-internal tables (_prisma_migrations).
|
||||
*/
|
||||
export async function truncateAllTables(client: PrismaClient): Promise<void> {
|
||||
// CASCADE means we only need to enumerate the tables; FK chains are
|
||||
// handled by Postgres. Listed alphabetically for diff stability.
|
||||
const tables = [
|
||||
"api_tokens",
|
||||
"app_settings",
|
||||
"audit_logs",
|
||||
"auth_challenges",
|
||||
"data_backups",
|
||||
"devices",
|
||||
"feedback",
|
||||
"idempotency_keys",
|
||||
"measurements",
|
||||
"medication_intake_events",
|
||||
"medication_schedules",
|
||||
"medications",
|
||||
"mood_entries",
|
||||
"notification_channels",
|
||||
"notification_preferences",
|
||||
"passkeys",
|
||||
"push_subscriptions",
|
||||
"rate_limits",
|
||||
"refresh_tokens",
|
||||
"reminder_phase_configs",
|
||||
"sessions",
|
||||
"telegram_reminder_messages",
|
||||
"telegram_scheduled_deletions",
|
||||
"user_achievements",
|
||||
"users",
|
||||
"withings_connections",
|
||||
];
|
||||
|
||||
const quoted = tables.map((t) => `"${t}"`).join(", ");
|
||||
await client.$executeRawUnsafe(
|
||||
`TRUNCATE TABLE ${quoted} RESTART IDENTITY CASCADE;`,
|
||||
);
|
||||
}
|
||||
+9
-1
@@ -11,7 +11,15 @@ export default defineConfig({
|
||||
environment: "node",
|
||||
// Default `pnpm test` keeps unit tests only — integration suite uses
|
||||
// testcontainers and runs separately via `pnpm test:integration`.
|
||||
exclude: ["**/node_modules/**", "**/dist/**", "tests/integration/**"],
|
||||
exclude: [
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"tests/integration/**",
|
||||
// Live agent worktrees create copies of `src/` under
|
||||
// `.claude/worktrees/`; vitest would otherwise pick those copies up
|
||||
// and run their tests twice — possibly against stale snapshots.
|
||||
".claude/worktrees/**",
|
||||
],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
// `include` enumerates the full source tree so the report shows
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Integration suite — boots a real Postgres in a testcontainer and runs
|
||||
// migrations against it. Slow (container boot ~5–15s on a cold pull),
|
||||
// so excluded from the default `pnpm test` and triggered explicitly via
|
||||
// `pnpm test:integration`.
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["tests/integration/**/*.test.ts"],
|
||||
// Container boot + migration apply is slow; give each test a generous
|
||||
// budget and beforeAll/afterAll twice that.
|
||||
testTimeout: 60_000,
|
||||
hookTimeout: 120_000,
|
||||
// Run sequentially in a single fork so files reuse one container and
|
||||
// amortise the migration-apply cost. Vitest 4 moved these to the top
|
||||
// level (the old `poolOptions.forks.singleFork` was removed).
|
||||
pool: "forks",
|
||||
fileParallelism: false,
|
||||
isolate: false,
|
||||
// One container for the whole run; tests truncate between them.
|
||||
globalSetup: ["./tests/integration/global-setup.ts"],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user