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:
authored and GitHub committed 2026-05-08 16:03:31 +02:00
1 parent 6da94280b5
commit accdb7d44d
11 files changed
+1701 -13

No files matched your search

+34
View File
@@ -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
View File
@@ -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"
+969 -11
View File
File diff suppressed because it is too large. Load diff
+98
View File
@@ -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();
});
});
+190
View File
@@ -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();
});
});
+57
View File
@@ -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);
});
});
+75
View File
@@ -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());
});
});
+71
View File
@@ -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
View File
@@ -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
+35
View File
@@ -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"),
},
},
});