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

+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,
);
}
}