1
0
Fork 0

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>
This commit is contained in:
Jason Fraley 2026-09-22 17:58:17 -04:00
parent 18bb6712cd
commit 8135c9850b
12 changed files with 353 additions and 8 deletions

View file

@ -67,3 +67,8 @@ APP_PORT=3000
# it as the `x-arma-bridge-key` header alongside `x-arma-server-id`. Generate
# with: openssl rand -hex 32. Leave unset to disable the bridge (routes 503).
# ARMA_BRIDGE_API_KEY=
# Optional: exact URI for the test database (defaults to <DATABASE_URI database>_test)
# TEST_DATABASE_URI=postgres://ptfapp:polaristaskforcedb@127.0.0.1:5432/ptf-app-dev-test
# Optional: set to 1 to drop and recreate the test database from scratch instead of truncating
# TEST_DB_HARD_RESET=1

View file

@ -6,6 +6,15 @@ import { defineConfig, devices } from "@playwright/test";
*/
import "dotenv/config";
import { resolveTestDatabaseUri } from "./tests/test-db";
/**
* E2E runs against the dedicated test database, never the development one.
* The webServer (dev server) inherits DATABASE_URI pointed at the test DB;
* global setup guarantees it exists, is migrated and is empty.
*/
const testDatabaseUri = resolveTestDatabaseUri();
/**
* See https://playwright.dev/docs/test-configuration.
*/
@ -53,5 +62,10 @@ export default defineConfig({
command: "pnpm dev",
reuseExistingServer: true,
url: "http://localhost:3000",
env: {
...process.env,
DATABASE_URI: testDatabaseUri,
},
},
globalSetup: "./tests/e2e/global-setup.ts",
});

View file

@ -414,10 +414,11 @@ export default buildConfig({
connectionString: process.env.DATABASE_URI || "",
},
// push: process.env.NODE_ENV === "development",
push: false,
// Env-gated drizzle push: only the test-database bootstrap (DB_PUSH=1) uses
// it, because the migration chain assumes a schema that push created.
push: process.env.DB_PUSH === "1",
prodMigrations: migrations,
}),
sharp,
}), sharp,
bin: [
{
scriptPath: path.resolve(dirname, "scripts/gameTick.ts"),

View file

@ -1,6 +1,6 @@
import { getPayload } from "payload";
import config from "@payload-config";
import type { Permission } from "@/permissions";
import { ALL_PERMISSION_VALUES, type Permission } from "@/permissions";
const resolvedConfig = await config;
const COLLECTION_SLUGS = resolvedConfig.collections.map((c) => c.slug);
@ -13,6 +13,16 @@ const ALL_ADMIN_PAGE_MANAGE: Permission[] = COLLECTION_SLUGS.map(
(slug) => `admin:${slug}:manage` as Permission,
);
/**
* Registry grants only: the Roles field's select options come from the
* permission registry, so collection reads generated for plugin-injected
* collections (story-beats, voice-submissions, ...) are not grantable and
* would fail validation.
*/
const GRANTABLE = new Set<string>(ALL_PERMISSION_VALUES);
const grantable = (permissions: Permission[]): Permission[] =>
permissions.filter((p) => GRANTABLE.has(p));
const USER_PERMISSIONS: Permission[] = [
"users:read",
"ranks:read",
@ -168,8 +178,8 @@ export const seedRoles = async () => {
roleIdMap.set(role.slug, doc.id);
if (role.isSystem) {
const existingPerms = doc.permissions ?? [];
const missing = role.permissions.filter((p) => !existingPerms.includes(p));
const existingPerms = (doc.permissions ?? []).filter((p) => GRANTABLE.has(p));
const missing = grantable(role.permissions).filter((p) => !existingPerms.includes(p));
if (missing.length > 0) {
await payload.update({
collection: "roles",
@ -199,7 +209,7 @@ export const seedRoles = async () => {
name: role.name,
slug: role.slug,
description: role.description,
permissions: role.permissions,
permissions: grantable(role.permissions),
isSystem: role.isSystem,
isSuperuser: role.isSuperuser,
},
@ -266,3 +276,7 @@ export const seedRoles = async () => {
};
await seedRoles();
// CLI script: the Payload pool keeps the event loop alive after completion;
// exit explicitly (same convention as the game-tick bins).
process.exit(0);

10
tests/e2e/global-setup.ts Normal file
View file

@ -0,0 +1,10 @@
import { bootstrapTestDatabase } from "../test-db";
/**
* Playwright global setup: ensure the dedicated test database exists, is
* migrated, seeded and emptied before the dev server (started by webServer
* with DATABASE_URI pointed at it) serves the e2e suite.
*/
export default async function globalSetup(): Promise<void> {
await bootstrapTestDatabase();
}

12
tests/push-schema.ts Normal file
View file

@ -0,0 +1,12 @@
import { getPayload } from "payload";
import config from "@payload-config";
/**
* One-shot schema push for the test database. Run with DB_PUSH=1 and
* DATABASE_URI pointing at the test database (tests/test-db.ts does this);
* initializing the Payload adapter with push: true makes drizzle create or
* diff the full schema. Exits immediately after initialization.
*/
const payload = await getPayload({ config });
void payload;
process.exit(0);

56
tests/seed-baseline.ts Normal file
View file

@ -0,0 +1,56 @@
import { getPayload } from "payload";
import config from "@payload-config";
/**
* Baseline data every test run can assume: a Game Rules global with a main
* currency resource. Runs after truncation, so it recreates the baseline on
* the empty database each run (idempotent by construction). Banking and
* economy code paths throw without a main currency.
*/
const CURRENCY_CODE_NAME = "res_test_credits";
const payload = await getPayload({ config });
const existingCurrency = await payload.find({
collection: "resources",
where: { codeName: { equals: CURRENCY_CODE_NAME } },
limit: 1,
overrideAccess: true,
});
let currencyId = existingCurrency.docs[0]?.id as number | undefined;
if (!currencyId) {
const created = await payload.create({
collection: "resources",
data: {
name: "Test Credits",
codeName: CURRENCY_CODE_NAME,
unitOfMeasure: "dollar",
approvalStatus: "in_progress",
type: "abstract",
baseValue: 1,
rarity: "common",
massPerUnit: 0,
gridWidth: 1,
gridHeight: 1,
tradeable: false,
},
draft: false,
overrideAccess: true,
});
currencyId = created.id as number;
}
const rules = await payload.findGlobal({ slug: "game-rules" });
await payload.updateGlobal({
slug: "game-rules",
data: {
...(rules as unknown as Record<string, unknown>),
mainCurrency: currencyId,
},
overrideAccess: true,
});
payload.logger.info(`[test-db] baseline ready: main currency resource ${currencyId}`);
process.exit(0);

197
tests/test-db.ts Normal file
View file

@ -0,0 +1,197 @@
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;
}

10
tests/vitest-global.ts Normal file
View file

@ -0,0 +1,10 @@
import { bootstrapTestDatabase } from "./test-db";
/**
* Vitest global setup: point the suite at the dedicated test database and
* leave it in a known state (ensured, migrated, truncated, seeded, sequences
* repaired) before any test file loads.
*/
export default async function globalSetup(): Promise<void> {
await bootstrapTestDatabase();
}

14
tests/vitest-teardown.ts Normal file
View file

@ -0,0 +1,14 @@
import { resolveTestDatabaseUri, truncateAllData } from "./test-db";
/**
* Vitest global teardown: leave the test database empty after the run. The
* next run truncates again anyway, so this is hygiene, not correctness:
* failures here must never fail the suite.
*/
export default async function globalTeardown(): Promise<void> {
try {
await truncateAllData(resolveTestDatabaseUri());
} catch (error) {
console.warn("[test-db] teardown truncate failed:", error);
}
}

View file

@ -8,5 +8,10 @@ export default defineConfig({
environment: "jsdom",
setupFiles: ["./vitest.setup.ts"],
include: ["tests/int/**/*.int.spec.ts"],
// Serial test files: every spec shares one test database, and parallel
// workers against it produced flaky cross-file interference.
fileParallelism: false,
globalSetup: ["./tests/vitest-global.ts"],
globalTeardown: ["./tests/vitest-teardown.ts"],
},
});

View file

@ -2,3 +2,10 @@
// Load .env files
import "dotenv/config";
import { resolveTestDatabaseUri } from "./tests/test-db";
// Tests never touch the development database: every payload connection in the
// test process targets the dedicated test database (see tests/test-db.ts).
// Must run before any test file imports the Payload config.
process.env.DATABASE_URI = resolveTestDatabaseUri();