test(integration): run files in parallel, one cloned database per worker

Global setup migrates one template database and reads what a fresh install
holds: its schemas, its tables and the rows the migrations seed. Each Vitest
worker clones the template on its first file with CREATE DATABASE ... TEMPLATE
and keeps the clone for the rest of the run, so files run in parallel without
seeing each other's rows, advisory locks or queue schema. Schemas created at
runtime (pg-boss, migration probes) are dropped when the next file starts.

The truncate list is the catalogue now, every table in public except the
migration bookkeeping, instead of a hand-kept list of 42 of 166 tables. The
seeded catalogues are put back after each truncate in foreign-key order, so a
test starts from what a fresh install has. The settings singleton is the one
seeded table a test starts without, declared with its reason.

next/headers and the boot compatibility probe are mocked once in the setup
file. Two lock-wait probes now look at their own database only, since other
workers' backends share pg_stat_activity.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 05:30:00 +02:00
1 parent fdd0f50130
commit a6787cfc03
9 files changed
+451 -161

No files matched your search

@@ -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<typeof vi.fn>) {
const empty = vi.fn().mockResolvedValue([]);
return {
$executeRawUnsafe: executeRaw,
moodTag: { findMany: empty, createMany: vi.fn() },
moodTagCategory: { findMany: empty, createMany: vi.fn() },
} as unknown as Parameters<typeof truncateAllTables>[0];
type Execute = (query: string, ...values: unknown[]) => Promise<number>;
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<Execute>()
.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<Execute>().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<Execute>().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<Execute>().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);
});
});
+41 -6
View File
@@ -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
+158 -13
View File
@@ -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<void>> {
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<IntegrationDatabasePlan> {
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;
}
+10 -13
View File
@@ -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<string, string>();
export const headerJar = new Map<string, string>();
@@ -153,7 +153,8 @@ async function untilHeldInTrigger() {
const prisma = getPrismaClient();
for (let i = 0; i < 100; i++) {
const rows = await prisma.$queryRaw<Array<{ n: bigint }>>`
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));
@@ -68,6 +68,7 @@ async function waitForRollupLockWaiter(timeoutMs = 5_000): Promise<boolean> {
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;
+42 -104
View File
@@ -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<PrismaClient["moodTag"]["findMany"]>
> | 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<PrismaClient["moodTagCategory"]["findMany"]>
> | null = null;
export async function truncateAllTables(
client: {
$executeRawUnsafe(query: string, ...values: unknown[]): Promise<unknown>;
},
plan: IntegrationDatabasePlan = inject("integrationDatabasePlan"),
): Promise<void> {
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<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",
"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<void> {
}
}
// 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,
);
}
}
+129
View File
@@ -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<pool id>` 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<string> {
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;
}
+22 -6
View File
@@ -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"],
},