Root-cause fix for cross-file DB pollution in the vitest suite, which surfaced as intermittent FK-violation failures once the suite hit 12 test files. tests/setup.ts previously mutated the global process.env.DATABASE_URL and relied on src/lib/prisma.ts's singleton picking it up, which is racy across files sharing a worker process (process.env isn't reset between files even though Vitest resets each file's module registry). fileParallelism: false only masked this. Now each test file's own tests/setup.ts creates its own throwaway Postgres database and vi.mock()s "@/lib/prisma" with a dedicated PrismaClient constructed via the explicit datasourceUrl option, so no mutable state is shared between files and fileParallelism can go back to its default (true). Verified with 4 consecutive full `npm test` runs (327/327 passing each time) and no leftover throwaway databases after each run.
107 lines
4.8 KiB
TypeScript
107 lines
4.8 KiB
TypeScript
// Creates a throwaway Postgres database per test-file run against the
|
|
// docker-compose `db` service, and drops it afterwards. Mirrors the old
|
|
// sqlite temp-file-per-run isolation, adapted to Postgres (CREATE/DROP
|
|
// DATABASE instead of a throwaway file).
|
|
//
|
|
// Creating/dropping the throwaway database itself stays raw `pg` (test
|
|
// infra, not the app's data-access layer) — but applying the schema to the
|
|
// fresh database goes through `prisma migrate deploy`, and the actual
|
|
// per-file PrismaClient used by both test bodies and app code (route
|
|
// handlers etc., all of which import `prisma` from "@/lib/prisma") is a
|
|
// throwaway PrismaClient instance created here, wired up explicitly.
|
|
//
|
|
// Root-cause note (2026-08-04): this used to work by mutating the global
|
|
// `process.env.DATABASE_URL` in a `beforeAll` and relying on the app's
|
|
// `src/lib/prisma.ts` singleton to pick that up. That's unsafe the moment
|
|
// two test files' setup/query timing can interleave within the same
|
|
// worker process — `process.env` is shared across all test files that
|
|
// happen to run in the same worker (Vitest's `test.isolate` resets each
|
|
// file's *module registry*, not `process.env`), so one file's mutation can
|
|
// leak into another's PrismaClient construction/reconnect. That forced
|
|
// `fileParallelism: false` as a blunt workaround (see git blame on
|
|
// vitest.config.ts), and even that stopped being sufficient once the
|
|
// worker pool got reused across enough sequential files (12 test files —
|
|
// see git history around this comment for the concrete FK-violation
|
|
// failures that motivated this rewrite).
|
|
//
|
|
// Fix: never touch `process.env.DATABASE_URL` at all. Instead, `vi.mock`
|
|
// "@/lib/prisma" (the same module id every test file and every app module
|
|
// imports `prisma` from) with a factory that creates this file's own
|
|
// throwaway database and returns a dedicated `PrismaClient` pointed at it
|
|
// via the explicit `datasourceUrl` constructor option (Prisma 5.2+) —
|
|
// never via the ambient env var. `vi.mock` factories are cached per
|
|
// isolated test-file module registry, so every import of "@/lib/prisma"
|
|
// within a given test file (the test file itself, and any route handler /
|
|
// lib module it pulls in) resolves to that one file-scoped client, while
|
|
// two different test files never share any mutable global state. That
|
|
// makes `fileParallelism: true` (the default, restored in
|
|
// vitest.config.ts) safe again.
|
|
import { randomUUID } from "node:crypto";
|
|
import { execSync } from "node:child_process";
|
|
import { Client } from "pg";
|
|
import { PrismaClient } from "@prisma/client";
|
|
import { afterAll, vi } from "vitest";
|
|
|
|
const baseUrl = new URL(
|
|
process.env.DATABASE_URL ||
|
|
"postgres://asset_license_mgmt:change-me-local-dev@db:5432/asset_license_mgmt"
|
|
);
|
|
const testDbName = `test_${randomUUID().replace(/-/g, "_")}`;
|
|
|
|
function adminUrl(): string {
|
|
const url = new URL(baseUrl.toString());
|
|
url.pathname = "/postgres";
|
|
return url.toString();
|
|
}
|
|
|
|
function testDbUrl(): string {
|
|
const url = new URL(baseUrl.toString());
|
|
url.pathname = `/${testDbName}`;
|
|
return url.toString();
|
|
}
|
|
|
|
async function withAdminClient<T>(fn: (client: Client) => Promise<T>): Promise<T> {
|
|
const client = new Client({ connectionString: adminUrl() });
|
|
await client.connect();
|
|
try {
|
|
return await fn(client);
|
|
} finally {
|
|
await client.end();
|
|
}
|
|
}
|
|
|
|
// Set by the "@/lib/prisma" mock factory below once it has created this
|
|
// file's throwaway database and client, so afterAll can tear both down.
|
|
// Populated synchronously-in-effect before any test body can run, since
|
|
// the mock factory is awaited as part of resolving the first import of
|
|
// "@/lib/prisma" (which every test file and every route/lib module under
|
|
// test does at the top of its own module graph, ahead of any test body).
|
|
let testPrisma: PrismaClient | undefined;
|
|
|
|
vi.mock("@/lib/prisma", async () => {
|
|
await withAdminClient((client) => client.query(`CREATE DATABASE ${testDbName}`));
|
|
|
|
// Apply the Prisma migration history to the fresh throwaway database.
|
|
// `migrate deploy` replays the same real migrations production uses
|
|
// (prisma/migrations), rather than a schema-diffing shortcut like
|
|
// `db push` — keeping this test setup honest about what actually runs
|
|
// outside tests too. Pass the target database explicitly via the
|
|
// subprocess's own `env` rather than mutating this process's
|
|
// `process.env` — the whole point of this rewrite is to avoid any
|
|
// shared mutable global between test files.
|
|
execSync("npx prisma migrate deploy", {
|
|
stdio: "inherit",
|
|
env: { ...process.env, DATABASE_URL: testDbUrl() },
|
|
});
|
|
|
|
testPrisma = new PrismaClient({ datasourceUrl: testDbUrl() });
|
|
return { prisma: testPrisma };
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await testPrisma?.$disconnect();
|
|
await withAdminClient((client) =>
|
|
client.query(`DROP DATABASE IF EXISTS ${testDbName} WITH (FORCE)`)
|
|
);
|
|
});
|