diff --git a/src/__tests__/integration-truncate-deadlock-retry.test.ts b/src/__tests__/integration-truncate-deadlock-retry.test.ts index a5b423a11..1852ef3f0 100644 --- a/src/__tests__/integration-truncate-deadlock-retry.test.ts +++ b/src/__tests__/integration-truncate-deadlock-retry.test.ts @@ -12,6 +12,18 @@ import { describe, expect, it, vi } from "vitest"; import { truncateAllTables } from "../../tests/integration/setup"; +import type { IntegrationDatabasePlan } from "../../tests/integration/worker-database"; + +/** What global setup reads off the migrated template, cut down to the shape. */ +const PLAN: IntegrationDatabasePlan = { + templateSchemas: ["public"], + tables: ["app_settings", "mood_tag_categories", "mood_tags", "users"], + reference: [ + { table: "app_settings", rowsJson: '[{"id":"singleton"}]' }, + { table: "mood_tag_categories", rowsJson: '[{"id":"c1"}]' }, + { table: "mood_tags", rowsJson: '[{"id":"t1","category_id":"c1"}]' }, + ], +}; function deadlock(): Error & { code: string } { const error = new Error("deadlock detected") as Error & { code: string }; @@ -19,45 +31,61 @@ function deadlock(): Error & { code: string } { return error; } -function fakeClient(executeRaw: ReturnType) { - const empty = vi.fn().mockResolvedValue([]); - return { - $executeRawUnsafe: executeRaw, - moodTag: { findMany: empty, createMany: vi.fn() }, - moodTagCategory: { findMany: empty, createMany: vi.fn() }, - } as unknown as Parameters[0]; +type Execute = (query: string, ...values: unknown[]) => Promise; + +function fakeClient(executeRaw: Execute) { + return { $executeRawUnsafe: executeRaw }; } describe("truncateAllTables", () => { it("retries a deadlocked truncate and succeeds once the lock clears", async () => { const executeRaw = vi - .fn() + .fn() .mockRejectedValueOnce(deadlock()) .mockRejectedValueOnce(deadlock()) .mockResolvedValue(0); - await truncateAllTables(fakeClient(executeRaw)); + await truncateAllTables(fakeClient(executeRaw), PLAN); - expect(executeRaw).toHaveBeenCalledTimes(3); + // Three truncate attempts, then the two seeded tables put back. + expect(executeRaw).toHaveBeenCalledTimes(5); + }); + + it("empties every catalogued table and puts the seeded rows back in key order", async () => { + const executeRaw = vi.fn().mockResolvedValue(0); + + await truncateAllTables(fakeClient(executeRaw), PLAN); + + const statements = executeRaw.mock.calls.map((call) => String(call[0])); + expect(statements[0]).toBe( + 'TRUNCATE TABLE "app_settings", "mood_tag_categories", "mood_tags", "users" RESTART IDENTITY CASCADE;', + ); + // The settings singleton starts empty; the catalogues come back, the + // category before the tag that references it. + expect(statements.slice(1)).toEqual([ + expect.stringContaining('INSERT INTO "mood_tag_categories"'), + expect.stringContaining('INSERT INTO "mood_tags"'), + ]); + expect(executeRaw.mock.calls[2][1]).toBe(PLAN.reference[2].rowsJson); }); it("gives up on an error that is not a deadlock, on the first attempt", async () => { const other = new Error("permission denied") as Error & { code: string }; other.code = "42501"; - const executeRaw = vi.fn().mockRejectedValue(other); + const executeRaw = vi.fn().mockRejectedValue(other); - await expect(truncateAllTables(fakeClient(executeRaw))).rejects.toThrow( - "permission denied", - ); + await expect( + truncateAllTables(fakeClient(executeRaw), PLAN), + ).rejects.toThrow("permission denied"); expect(executeRaw).toHaveBeenCalledTimes(1); }); it("stops retrying a deadlock that never clears", async () => { - const executeRaw = vi.fn().mockRejectedValue(deadlock()); + const executeRaw = vi.fn().mockRejectedValue(deadlock()); - await expect(truncateAllTables(fakeClient(executeRaw))).rejects.toThrow( - "deadlock detected", - ); + await expect( + truncateAllTables(fakeClient(executeRaw), PLAN), + ).rejects.toThrow("deadlock detected"); expect(executeRaw).toHaveBeenCalledTimes(5); }); }); diff --git a/tests/integration/environment-setup.ts b/tests/integration/environment-setup.ts index 2613d151e..9adf0e3c7 100644 --- a/tests/integration/environment-setup.ts +++ b/tests/integration/environment-setup.ts @@ -1,12 +1,14 @@ import { afterEach, inject, vi } from "vitest"; -declare module "vitest" { - export interface ProvidedContext { - integrationDatabaseUrl: string; - } -} +import { prepareWorkerDatabase } from "./worker-database"; -process.env.DATABASE_URL = inject("integrationDatabaseUrl"); +// Each worker runs against its own clone of the migrated template (see +// `worker-database.ts`). The URL is final before any application module is +// imported, so the Prisma singleton is built against the clone. +process.env.DATABASE_URL = await prepareWorkerDatabase( + inject("integrationDatabaseUrl"), + inject("integrationDatabasePlan"), +); process.env.ENCRYPTION_KEY = "0000000000000000000000000000000000000000000000000000000000000000"; delete process.env.ENCRYPTION_KEYS; @@ -33,6 +35,39 @@ afterEach(async () => { await settleBackgroundTasks(); }); +/** + * Route handlers read cookies and headers through `next/headers`, which only + * works inside a Next.js request scope. Every file gets the same stand-in, + * backed by the shared jars in `mock-next-headers.ts`, and the same no-op for + * the boot-time compatibility probe. A file that needs different behaviour + * still declares its own `vi.mock`, which takes precedence over this one. + */ +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, + has: (name: string) => headerJar.has(name.toLowerCase()), + })), + 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); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + /** * The same pin the unit setup applies (see `vitest.setup.ts`): the Fitbit * suites run before the Web API turndown, whatever the real date, and a test diff --git a/tests/integration/global-setup.ts b/tests/integration/global-setup.ts index 5ee724dd9..31fdc912b 100644 --- a/tests/integration/global-setup.ts +++ b/tests/integration/global-setup.ts @@ -1,47 +1,84 @@ /** * 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. + * integration run, migrates ONE template database, and hands the workers + * what they need to clone it. The teardown returned at the end stops the + * container after all test files have finished. * - * Why globalSetup instead of beforeAll/afterAll per file: + * Why one container and a template: + * - Applying the migration history takes seconds; copying a migrated + * database with `CREATE DATABASE … TEMPLATE` takes a fraction of one. + * Each Vitest worker therefore gets its own clone (see + * `worker-database.ts`), cloned once per worker rather than once per + * file or test, and files run in parallel without seeing each other's + * rows, advisory locks or queue schema. * - 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`. + * module load time from `process.env.DATABASE_URL`. A worker's URL is + * fixed before any application module loads (`environment-setup.ts`), + * so the singleton always points at that worker's clone. + * + * What the template carries besides the schema is recorded here as well: + * the rows the migrations seed (the mood-tag and symptom catalogues, the + * settings singleton). `setup.ts -> truncateAllTables` empties every table + * and puts exactly these rows back, so a test starts from the state a fresh + * install has, and the truncate list is the catalogue rather than a list + * somebody has to remember to extend. */ import { execSync } from "node:child_process"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { Client } from "pg"; import type { TestProject } from "vitest/node"; import { PostgreSqlContainer, type StartedPostgreSqlContainer, } from "@testcontainers/postgresql"; +import { + TEMPLATE_DATABASE, + type IntegrationDatabasePlan, + type ReferenceTable, +} from "./worker-database"; + const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const PROJECT_ROOT = resolve(__dirname, "..", ".."); let container: StartedPostgreSqlContainer | null = null; +declare module "vitest" { + export interface ProvidedContext { + integrationDatabaseUrl: string; + integrationDatabasePlan: IntegrationDatabasePlan; + } +} + export default async function setup( project: TestProject, ): Promise<() => Promise> { container = await new PostgreSqlContainer("postgres:16-alpine") - .withDatabase("healthlog_test") + .withDatabase(TEMPLATE_DATABASE) .withUsername("healthlog") .withPassword("healthlog") + // A throwaway cluster: durability buys nothing here and costs every + // commit a flush. Several workers each hold a Prisma pool and, in the + // queue tests, a pg-boss pool, so the default of 100 connections is + // raised well past what parallel workers can open. + .withCommand([ + "postgres", + "-c", + "fsync=off", + "-c", + "synchronous_commit=off", + "-c", + "full_page_writes=off", + "-c", + "max_connections=400", + ]) .start(); const url = container.getConnectionUri() + "?schema=public&pgbouncer=false"; process.env.DATABASE_URL = url; - project.provide("integrationDatabaseUrl", url); // The explicit child environment keeps Prisma CLI on the same container. execSync("pnpm db:migrate:deploy", { @@ -50,6 +87,9 @@ export default async function setup( env: { ...process.env, DATABASE_URL: url }, }); + project.provide("integrationDatabaseUrl", url); + project.provide("integrationDatabasePlan", await readTemplate(url)); + return async () => { if (container) { await container.stop({ remove: true }); @@ -57,3 +97,108 @@ export default async function setup( } }; } + +/** + * Read what a fresh install holds: its schemas, its tables, and the rows the + * migrations seed, in an order that satisfies the foreign keys between them. + * The connection is closed before any worker clones the template, because + * Postgres refuses to copy a database somebody is connected to. + */ +async function readTemplate(url: string): Promise { + const client = new Client({ connectionString: url }); + await client.connect(); + try { + const schemas = ( + await client.query<{ nspname: string }>( + `SELECT nspname FROM pg_namespace + WHERE nspname NOT LIKE 'pg\\_%' AND nspname <> 'information_schema' + ORDER BY nspname`, + ) + ).rows.map((r) => r.nspname); + + const tables = ( + await client.query<{ tablename: string }>( + `SELECT tablename FROM pg_tables + WHERE schemaname = 'public' AND tablename <> '_prisma_migrations' + ORDER BY tablename`, + ) + ).rows.map((r) => r.tablename); + if (tables.length < 100) { + throw new Error( + `the migrated template has only ${tables.length} tables; the migration step did not run`, + ); + } + + const seeded: string[] = []; + for (const table of tables) { + const { rows } = await client.query<{ present: boolean }>( + `SELECT EXISTS (SELECT 1 FROM "${table}") AS present`, + ); + if (rows[0]?.present) seeded.push(table); + } + + // A seeded table whose key comes from a sequence would collide with the + // next insert once `RESTART IDENTITY` rewinds it. None does today; the + // check keeps it that way rather than leaving a silent collision. + const sequenced = await client.query<{ table: string }>( + `SELECT DISTINCT c.relname AS table + FROM pg_depend d + JOIN pg_class s ON s.oid = d.objid AND s.relkind = 'S' + JOIN pg_class c ON c.oid = d.refobjid + WHERE c.relname = ANY($1::text[])`, + [seeded], + ); + if (sequenced.rows.length > 0) { + throw new Error( + `seeded tables with sequence-backed keys are not supported by the reseed: ${sequenced.rows.map((r) => r.table).join(", ")}`, + ); + } + + const edges = ( + await client.query<{ child: string; parent: string }>( + `SELECT conrelid::regclass::text AS child, confrelid::regclass::text AS parent + FROM pg_constraint + WHERE contype = 'f' + AND conrelid::regclass::text = ANY($1::text[]) + AND confrelid::regclass::text = ANY($1::text[]) + AND conrelid <> confrelid`, + [seeded], + ) + ).rows; + + const reference: ReferenceTable[] = []; + for (const table of topologicalOrder(seeded, edges)) { + const { rows } = await client.query<{ rows: string }>( + `SELECT COALESCE(json_agg(t), '[]'::json)::text AS rows FROM "${table}" t`, + ); + reference.push({ table, rowsJson: rows[0].rows }); + } + + return { templateSchemas: schemas, tables, reference }; + } finally { + await client.end(); + } +} + +function topologicalOrder( + tables: readonly string[], + edges: ReadonlyArray<{ child: string; parent: string }>, +): string[] { + const ordered: string[] = []; + const pending = new Set(tables); + while (pending.size > 0) { + const ready = [...pending].filter( + (t) => !edges.some((e) => e.child === t && pending.has(e.parent)), + ); + if (ready.length === 0) { + throw new Error( + `seeded tables reference each other in a cycle: ${[...pending].join(", ")}`, + ); + } + for (const t of ready.sort()) { + ordered.push(t); + pending.delete(t); + } + } + return ordered; +} diff --git a/tests/integration/mock-next-headers.ts b/tests/integration/mock-next-headers.ts index f6de28c2e..2a012bdf6 100644 --- a/tests/integration/mock-next-headers.ts +++ b/tests/integration/mock-next-headers.ts @@ -1,20 +1,17 @@ /** * Shared `next/headers` mock state for the integration suite. * - * Background: vitest runs the integration suite with `isolate: false` - * (one worker, one container). The factory passed to - * `vi.mock("next/headers", ...)` is resolved ONCE per worker — - * whichever test file loads first wins. If two files each declare a - * top-level `const cookieJar = new Map()` and reference it from their - * own `vi.mock("next/headers", ...)` factory, only the first file's - * Map is ever read; the second file's writes silently disappear and - * its tests flake based on import order. + * `environment-setup.ts` mocks `next/headers` for every file with a stand-in + * that reads these jars, so a test sets a cookie or a header here and the + * route handler under test sees it. Files are isolated (`isolate: true`), so + * each file gets fresh jars; clear them in `beforeEach` to keep cases + * independent. The route test kit (`tests/kit/`) fills them from the `auth` + * option of `callRoute`, so a kit test never touches them directly. * - * Fix: every integration file imports `cookieJar` + `headerJar` from - * THIS module (a singleton) and clears them in `beforeEach`. The - * `vi.mock` factory inside each test file resolves the Maps via - * `await import()` so it sees the same Map instances regardless of - * which file's mock factory ran first. + * Many files still declare their own identical `vi.mock("next/headers")`. + * That predates the shared one, is harmless (a file's own mock wins), and is + * only needed where a file wants different behaviour, such as consuming + * `queuedSessionIds` below. */ export const cookieJar = new Map(); export const headerJar = new Map(); diff --git a/tests/integration/restore-consolidation-deadlock.test.ts b/tests/integration/restore-consolidation-deadlock.test.ts index c07e422de..8f9df2686 100644 --- a/tests/integration/restore-consolidation-deadlock.test.ts +++ b/tests/integration/restore-consolidation-deadlock.test.ts @@ -153,7 +153,8 @@ async function untilHeldInTrigger() { const prisma = getPrismaClient(); for (let i = 0; i < 100; i++) { const rows = await prisma.$queryRaw>` - SELECT count(*) AS n FROM pg_stat_activity WHERE wait_event = 'PgSleep' + SELECT count(*) AS n FROM pg_stat_activity + WHERE wait_event = 'PgSleep' AND datname = current_database() `; if (Number(rows[0]?.n ?? 0) > 0) return; await new Promise((resolve) => setTimeout(resolve, 50)); diff --git a/tests/integration/rollup-concurrent-recompute.test.ts b/tests/integration/rollup-concurrent-recompute.test.ts index 552ccdab3..d9ea3d6b3 100644 --- a/tests/integration/rollup-concurrent-recompute.test.ts +++ b/tests/integration/rollup-concurrent-recompute.test.ts @@ -68,6 +68,7 @@ async function waitForRollupLockWaiter(timeoutMs = 5_000): Promise { SELECT count(*)::bigint AS n FROM pg_stat_activity WHERE wait_event_type = 'Lock' + AND datname = current_database() AND query ILIKE '%measurement_rollups%' `; if (Number(rows[0]?.n ?? 0) > 0) return true; diff --git a/tests/integration/setup.ts b/tests/integration/setup.ts index fca0c34da..bca5f427f 100644 --- a/tests/integration/setup.ts +++ b/tests/integration/setup.ts @@ -3,8 +3,14 @@ * is started once in global-setup.ts and bridged into each worker by * environment-setup.ts before application modules load. */ +import { inject } from "vitest"; + import { prisma } from "@/lib/db"; import { assertRecordContext } from "./mock-next-headers"; +import { + START_EMPTY_TABLES, + type IntegrationDatabasePlan, +} from "./worker-database"; import type { PrismaClient } from "@/generated/prisma/client"; /** @@ -17,101 +23,37 @@ export function getPrismaClient(): PrismaClient { return prisma; } -/** Lazily-captured global mood-tag catalogue (NULL user_id rows). */ -let catalogueSnapshot: Awaited< - ReturnType -> | null = null; - /** - * v1.17.0 — same treatment for the seeded mood-tag categories: - * `mood_tag_categories.user_id` now FKs to `users` (custom groups), so the - * users TRUNCATE cascades into the category table too. The categories must - * restore BEFORE the tags (tags FK into categories). + * Return the database to the state a fresh install has: every table in + * `public` emptied, then the rows the migrations seed put back (the mood-tag, + * cycle-symptom and illness-symptom catalogues). Call from beforeEach() to + * make tests independent. + * + * Both lists come from the migrated template, read once in + * `global-setup.ts`: the tables from the catalogue (`pg_tables` minus the + * migration bookkeeping), the seeded rows from the tables that hold any + * before a test has run. The list used to be written by hand and covered 42 + * of 166 tables; a table outside it kept whatever the previous file left, + * which is how a case came to pass or fail depending on file order. Now a new + * table is covered by existing, and the one exception is declared in + * `START_EMPTY_TABLES` with its reason. + * + * Instance-wide schemas the application creates at runtime (pg-boss) are not + * touched between tests of one file, because a file may hold a running queue + * across them; they are dropped when the next file starts in the worker (see + * `worker-database.ts`). */ -let categorySnapshot: Awaited< - ReturnType -> | null = null; +export async function truncateAllTables( + client: { + $executeRawUnsafe(query: string, ...values: unknown[]): Promise; + }, + plan: IntegrationDatabasePlan = inject("integrationDatabasePlan"), +): Promise { + const quoted = plan.tables.map((t) => `"${t}"`).join(", "); + const reseed = plan.reference.filter( + (r) => !START_EMPTY_TABLES.includes(r.table), + ); -/** - * 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 { - // 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", - "coach_conversations", - "coach_messages", - "coach_usage", - "data_backups", - "devices", - // Instance-wide, keyed by encryption key id: a canary left by one test - // would make the next test's boot check verify instead of write. - "encryption_key_canaries", - "host_metrics", - "idempotency_keys", - "integration_statuses", - "measurement_rollups", - "measurements", - "medication_intake_events", - "medication_intake_import_jobs", - "medication_schedules", - "medications", - "mood_entries", - "notification_channels", - "notification_preferences", - // Instance-wide OAuth callback claims, keyed on a hash of the state: not - // reached by the users cascade. - "oauth_callback_claims", - // Host-wide off-host backup state: not keyed to a user, so the users - // cascade does not reach it, and a file that reads it whole (the purge - // job, the encryption view) saw what earlier files left behind. - "offhost_backup_key_use", - "offhost_purge_requests", - "passkeys", - "personal_records", - "push_subscriptions", - "rate_limits", - "refresh_tokens", - "reminder_phase_configs", - "sessions", - "step_up_elevations", - "telegram_reminder_messages", - "telegram_scheduled_deletions", - "user_achievements", - "users", - "withings_connections", - "withings_oauth_states", - "workout_routes", - "workouts", - ]; - - // v1.13.0 — the mood-tag catalogue (`mood_tags` rows with NULL user_id) is - // global reference data seeded by migrations and is intentionally NOT in the - // truncate list, so tests can rely on it. But `mood_tags.user_id` now FKs to - // `users`, so `TRUNCATE users CASCADE` cascades into `mood_tags` and wipes - // the catalogue too (TRUNCATE CASCADE truncates dependent tables wholesale, - // regardless of the ON DELETE action). Snapshot the catalogue once (before - // the first truncate clears it) and restore it after each truncate so the - // migration-seeded catalogue persists across tests as it did before the FK. - if (catalogueSnapshot === null) { - catalogueSnapshot = await client.moodTag.findMany({ - where: { userId: null }, - }); - } - if (categorySnapshot === null) { - categorySnapshot = await client.moodTagCategory.findMany({ - where: { userId: null }, - }); - } - - const quoted = tables.map((t) => `"${t}"`).join(", "); // A truncate takes the strictest lock there is on every table at once, so it // loses to anything still holding a row lock. Thirty-nine places in the // server write without awaiting the result (a last-used stamp, a delivery @@ -136,18 +78,14 @@ export async function truncateAllTables(client: PrismaClient): Promise { } } - // Categories first — the tag rows FK into them. - if (categorySnapshot.length > 0) { - await client.moodTagCategory.createMany({ - data: categorySnapshot, - skipDuplicates: true, - }); - } - if (catalogueSnapshot.length > 0) { - await client.moodTag.createMany({ - data: catalogueSnapshot, - skipDuplicates: true, - }); + // In foreign-key order, as global setup sorted them. The rows go back + // exactly as the template holds them, so a test that edited a catalogue + // entry does not hand the edit to the next one. + for (const { table, rowsJson } of reseed) { + await client.$executeRawUnsafe( + `INSERT INTO "${table}" SELECT * FROM json_populate_recordset(NULL::"${table}", $1::json)`, + rowsJson, + ); } } diff --git a/tests/integration/worker-database.ts b/tests/integration/worker-database.ts new file mode 100644 index 000000000..86e600231 --- /dev/null +++ b/tests/integration/worker-database.ts @@ -0,0 +1,129 @@ +/** + * One database per Vitest worker, cloned from the migrated template. + * + * `global-setup.ts` migrates `TEMPLATE_DATABASE` once. Each worker, on its + * first file, copies it into `healthlog_test_w` with + * `CREATE DATABASE … TEMPLATE`, and every later file in that worker reuses + * the copy. Rows, advisory locks (scoped per database), `LISTEN` channels and + * the pg-boss schema therefore never cross between files running at the same + * time in different workers. + * + * Postgres refuses to copy a database while anyone is connected to it, + * including another copy in progress, so clones are serialised with an + * advisory lock held on the maintenance database. Nothing connects to the + * template after global setup has read it. + * + * This module deliberately imports nothing from the application: it runs + * from `environment-setup.ts` before `DATABASE_URL` is final. + */ +import { Client } from "pg"; + +export const TEMPLATE_DATABASE = "healthlog_test"; + +/** + * Tables the migrations seed but a test starts without. + * + * `app_settings` holds the instance singleton. The application creates it on + * first read, and tests create it with the values they mean to test; a seeded + * copy would make every such `create` collide. Every other seeded table (the + * mood-tag, cycle-symptom and illness-symptom catalogues) is reference data a + * fresh install always has, so it is put back after each truncate. + */ +export const START_EMPTY_TABLES: readonly string[] = ["app_settings"]; + +export interface ReferenceTable { + table: string; + /** The template's rows as a JSON array, in insert order across tables. */ + rowsJson: string; +} + +export interface IntegrationDatabasePlan { + /** Schemas a fresh install has; anything else is runtime state. */ + templateSchemas: string[]; + /** Every table in `public` except the migration bookkeeping. */ + tables: string[]; + /** Seeded tables in foreign-key order, with their seeded rows. */ + reference: ReferenceTable[]; +} + +/** The worker's database name. `VITEST_POOL_ID` runs from 1 to maxWorkers. */ +export function workerDatabaseName( + poolId: string | undefined = process.env.VITEST_POOL_ID, +): string { + const id = Number.parseInt(poolId ?? "1", 10); + if (!Number.isInteger(id) || id < 1) { + throw new Error(`unusable VITEST_POOL_ID: ${String(poolId)}`); + } + return `${TEMPLATE_DATABASE}_w${id}`; +} + +function withDatabase(url: string, database: string): string { + const parsed = new URL(url); + parsed.pathname = `/${database}`; + return parsed.toString(); +} + +/** + * Make sure this worker's clone exists and carries no runtime schema left by + * an earlier file, then return its URL. + * + * Runtime schemas are the ones a fresh install does not have: pg-boss creates + * `pgboss` the first time a queue starts, and a few migration tests create + * probe schemas. A file starts without them, so whether a queue table exists + * never depends on which file happened to run earlier in the same worker. + */ +export async function prepareWorkerDatabase( + templateUrl: string, + plan: IntegrationDatabasePlan, +): Promise { + const name = workerDatabaseName(); + const maintenance = new Client({ + connectionString: withDatabase(templateUrl, "postgres"), + }); + await maintenance.connect(); + try { + const exists = async () => + ( + await maintenance.query( + `SELECT 1 FROM pg_database WHERE datname = $1`, + [name], + ) + ).rowCount === 1; + if (!(await exists())) { + await maintenance.query( + `SELECT pg_advisory_lock(hashtext('healthlog-integration-clone'))`, + ); + try { + if (!(await exists())) { + await maintenance.query( + `CREATE DATABASE "${name}" TEMPLATE "${TEMPLATE_DATABASE}"`, + ); + } + } finally { + await maintenance.query( + `SELECT pg_advisory_unlock(hashtext('healthlog-integration-clone'))`, + ); + } + } + } finally { + await maintenance.end(); + } + + const url = withDatabase(templateUrl, name); + const db = new Client({ connectionString: url }); + await db.connect(); + try { + const { rows } = await db.query<{ nspname: string }>( + `SELECT nspname FROM pg_namespace + WHERE nspname NOT LIKE 'pg\\_%' AND nspname <> 'information_schema' + AND nspname <> ALL($1::text[])`, + [plan.templateSchemas], + ); + for (const { nspname } of rows) { + await db.query(`DROP SCHEMA "${nspname.replace(/"/g, '""')}" CASCADE`); + } + } finally { + await db.end(); + } + return url; +} diff --git a/vitest.integration.config.mts b/vitest.integration.config.mts index 50770dc21..41512022d 100644 --- a/vitest.integration.config.mts +++ b/vitest.integration.config.mts @@ -1,10 +1,21 @@ import { defineConfig } from "vitest/config"; import { dirname, resolve } from "node:path"; +import { availableParallelism } from "node:os"; import { fileURLToPath } from "node:url"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); +function integrationWorkers(): number { + const pinned = Number.parseInt( + process.env.HEALTHLOG_INTEGRATION_WORKERS ?? "", + 10, + ); + if (Number.isInteger(pinned) && pinned > 0) return pinned; + // Half the cores: the other half runs Postgres and the container runtime. + return Math.max(1, Math.min(8, Math.floor(availableParallelism() / 2))); +} + // 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 @@ -30,11 +41,16 @@ export default defineConfig({ // 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). + // Files run in parallel, one database per worker: global setup migrates a + // template once and each worker clones it on its first file (see + // `tests/integration/worker-database.ts`). The worker count follows the + // host unless `HEALTHLOG_INTEGRATION_WORKERS` pins it; CI pins it to what + // a runner shared with the Postgres container can carry. pool: "forks", - fileParallelism: false, + fileParallelism: true, + maxWorkers: integrationWorkers(), + // Order dependence is looked for on purpose by shuffling file order + // (`--sequence.shuffle`), not by keeping the suite serial. // v1.4.37 — switched from `isolate: false` to `isolate: true`. // The previous setting reused the module graph across files inside // one fork; mock declarations (`vi.mock("@/lib/notifications/...")`, @@ -48,8 +64,8 @@ export default defineConfig({ // expensive boot cost is unaffected. isolate: true, // One container for the whole run; tests truncate between them. - // Bridge the global Testcontainers URL into each isolated worker before - // application modules read DATABASE_URL at import time. + // Point each isolated worker at its own clone before application modules + // read DATABASE_URL at import time. setupFiles: ["./tests/integration/environment-setup.ts"], globalSetup: ["./tests/integration/global-setup.ts"], },