1
0
Fork 0
polaris-task-force/tests/test-db.ts
Z8MB1E 8135c9850b feat(test): run tests against a dedicated truncated database
Bootstrap a per-run test database (ensure, push schema, truncate, seed baselines, repair sequences), wire vitest/playwright globals, and gate schema push behind DB_PUSH. Serial test files share the one database.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-22 17:58:17 -04:00

197 lines
7.3 KiB
TypeScript

import { execSync } from "node:child_process";
import { Client } from "pg";
// Global setup/teardown processes never load .env (only setupFiles do); load
// it here so every entry point sees the same variables. dotenv does not
// override variables that are already set.
import "dotenv/config";
/**
* Dedicated test database machinery for the vitest and Playwright suites.
*
* Tests never touch the development database: they run against a sibling
* `<name>_test` database that is ensured, migrated, seeded and truncated per
* run. All operations are idempotent, so a crashed run's leftovers are wiped
* by the next run no matter what leaked.
*
* Override points:
* - `TEST_DATABASE_URI` — use this exact connection string instead of the
* derived one.
* - `TEST_DB_HARD_RESET=1` — drop and recreate the test database (fresh
* schema from migrations) instead of the default truncate flow.
*/
/** Tables that must survive truncation: PostGIS internals and migration journals. */
const SKIP_TRUNCATE = new Set(["spatial_ref_sys", "__drizzle_migrations", "payload_migrations"]);
/** True when truncation should be skipped in favor of a full drop/recreate. */
export function hardResetRequested(): boolean {
return process.env.TEST_DB_HARD_RESET === "1";
}
/**
* Derive the test connection string: `TEST_DATABASE_URI` when set, otherwise
* the dev URI with its database name suffixed `_test`
* (`postgres://host/ptf_app_dev` -> `postgres://host/ptf_app_test`).
*/
export function resolveTestDatabaseUri(): string {
const override = process.env.TEST_DATABASE_URI;
if (override) return override;
const base = process.env.DATABASE_URI;
if (!base) {
throw new Error("DATABASE_URI (or TEST_DATABASE_URI) must be set to bootstrap the test database");
}
const url = new URL(base);
const segments = url.pathname.split("/").filter(Boolean);
const dbName = segments.pop();
if (!dbName) {
throw new Error("DATABASE_URI has no database name; cannot derive a test database");
}
url.pathname = [...segments, `${dbName}_test`].join("/");
return url.toString();
}
function testDatabaseName(testUri: string): string {
const name = new URL(testUri).pathname.split("/").filter(Boolean).pop();
if (!name) throw new Error("Test database URI has no database name");
return name;
}
function quotedIdentifier(identifier: string): string {
return `"${identifier.replace(/"/g, '""')}"`;
}
/** Open a client to the server's maintenance database (`postgres`). */
async function withAdminClient(testUri: string, fn: (client: Client) => Promise<void>): Promise<void> {
const url = new URL(testUri);
url.pathname = "/postgres";
const client = new Client({ connectionString: url.toString() });
await client.connect();
try {
await fn(client);
} finally {
await client.end();
}
}
/**
* Create the test database when missing. With `hardReset`, terminate
* connections, drop it and recreate from scratch instead.
*/
export async function ensureTestDatabase(testUri: string, hardReset: boolean): Promise<void> {
const dbName = testDatabaseName(testUri);
await withAdminClient(testUri, async (admin) => {
const existing = await admin.query("SELECT 1 FROM pg_database WHERE datname = $1", [dbName]);
if (hardReset && existing.rows.length > 0) {
await admin.query(
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()",
[dbName],
);
await admin.query(`DROP DATABASE ${quotedIdentifier(dbName)}`);
existing.rows.length = 0;
}
if (existing.rows.length === 0) {
await admin.query(`CREATE DATABASE ${quotedIdentifier(dbName)}`);
}
});
}
/**
* Create or diff the full schema via drizzle push. The migration chain
* assumes a schema that push originally created (its first migration alters
* existing tables), so a fresh database is built by pushing, not migrating.
*/
export function pushSchema(testUri: string): void {
execSync("bun run tests/push-schema.ts", {
cwd: process.cwd(),
env: { ...process.env, DATABASE_URI: testUri, DB_PUSH: "1" },
stdio: "inherit",
});
}
/**
* Truncate every table in the public schema except migration journals and
* PostGIS internals. Idempotent by construction: the result is the same empty
* database no matter how many rows leaked from earlier runs.
*/
export async function truncateAllData(testUri: string): Promise<void> {
const client = new Client({ connectionString: testUri });
await client.connect();
try {
const tables = await client.query(
"SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename",
);
const names = tables.rows
.map((row: { tablename: string }) => row.tablename)
.filter((name) => !SKIP_TRUNCATE.has(name));
if (names.length === 0) return;
await client.query(`TRUNCATE TABLE ${names.map(quotedIdentifier).join(", ")} CASCADE`);
} finally {
await client.end();
}
}
/**
* Re-align every `<table>_id_seq` sequence with `MAX(id)` of its table.
* Migrations backfill baseline rows with explicit ids, which leaves fresh
* sequences behind the existing rows; without this repair the next create
* collides and fails with `ValidationError: field is invalid: id`.
*/
export async function repairSequences(testUri: string): Promise<void> {
const client = new Client({ connectionString: testUri });
await client.connect();
try {
const sequences = await client.query(
"SELECT sequencename FROM pg_sequences WHERE schemaname = 'public' AND sequencename LIKE '%\\_id\\_seq'",
);
for (const row of sequences.rows) {
const tableName = String(row.sequencename).replace(/_id_seq$/, "");
const tableExists = await client.query(
"SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1",
[tableName],
);
if (tableExists.rows.length === 0) continue;
await client.query(
`SELECT setval($1::regclass, COALESCE((SELECT MAX(id) FROM ${quotedIdentifier(tableName)}), 1), true)`,
[row.sequencename],
);
}
} finally {
await client.end();
}
}
/** Seed the shared baseline data tests rely on (RBAC roles, Game Rules). */
export function runSeedScripts(testUri: string): void {
// Self-executing scripts; run them as child processes so the bootstrap
// module graph stays free of @payload-config (which vitest globalSetup and
// Playwright's transpiler resolve differently).
for (const script of ["src/tools/seed/seedRoles.ts", "tests/seed-baseline.ts"]) {
execSync(`bun run ${script}`, {
cwd: process.cwd(),
env: { ...process.env, DATABASE_URI: testUri },
stdio: "inherit",
});
}
}
/**
* Full bootstrap, safe to call repeatedly: ensure the database (drop/recreate
* on hard reset), apply migrations, truncate leftover data, reseed baselines
* and repair sequences.
*/
export async function bootstrapTestDatabase(): Promise<string> {
const testUri = resolveTestDatabaseUri();
const hardReset = hardResetRequested();
await ensureTestDatabase(testUri, hardReset);
pushSchema(testUri);
await truncateAllData(testUri);
runSeedScripts(testUri);
await repairSequences(testUri);
const dbName = testDatabaseName(testUri);
console.log(`[test-db] ready: ${dbName}${hardReset ? " (hard reset)" : ""}`);
return testUri;
}