From 8135c9850baa51c7c8a8a2b6c873f6f3bad79f2a Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Tue, 22 Sep 2026 17:58:17 -0400 Subject: [PATCH] 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 --- .env.example | 7 +- playwright.config.ts | 14 +++ src/payload.config.ts | 7 +- src/tools/seed/seedRoles.ts | 22 +++- tests/e2e/global-setup.ts | 10 ++ tests/push-schema.ts | 12 +++ tests/seed-baseline.ts | 56 ++++++++++ tests/test-db.ts | 197 ++++++++++++++++++++++++++++++++++++ tests/vitest-global.ts | 10 ++ tests/vitest-teardown.ts | 14 +++ vitest.config.mts | 5 + vitest.setup.ts | 7 ++ 12 files changed, 353 insertions(+), 8 deletions(-) create mode 100644 tests/e2e/global-setup.ts create mode 100644 tests/push-schema.ts create mode 100644 tests/seed-baseline.ts create mode 100644 tests/test-db.ts create mode 100644 tests/vitest-global.ts create mode 100644 tests/vitest-teardown.ts diff --git a/.env.example b/.env.example index 4132a20..f05ae5e 100644 --- a/.env.example +++ b/.env.example @@ -66,4 +66,9 @@ APP_PORT=3000 # Shared secret required by the /api/arma/v1/* routes. The Arma server sends # 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= \ No newline at end of file +# ARMA_BRIDGE_API_KEY= +# Optional: exact URI for the test database (defaults to _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 diff --git a/playwright.config.ts b/playwright.config.ts index 4f72b0e..254de19 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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", }); diff --git a/src/payload.config.ts b/src/payload.config.ts index bfac232..3ee4ed2 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -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"), diff --git a/src/tools/seed/seedRoles.ts b/src/tools/seed/seedRoles.ts index e355af5..edc3179 100644 --- a/src/tools/seed/seedRoles.ts +++ b/src/tools/seed/seedRoles.ts @@ -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(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); diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts new file mode 100644 index 0000000..f83d285 --- /dev/null +++ b/tests/e2e/global-setup.ts @@ -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 { + await bootstrapTestDatabase(); +} diff --git a/tests/push-schema.ts b/tests/push-schema.ts new file mode 100644 index 0000000..d56a003 --- /dev/null +++ b/tests/push-schema.ts @@ -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); diff --git a/tests/seed-baseline.ts b/tests/seed-baseline.ts new file mode 100644 index 0000000..2a69360 --- /dev/null +++ b/tests/seed-baseline.ts @@ -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), + mainCurrency: currencyId, + }, + overrideAccess: true, +}); + +payload.logger.info(`[test-db] baseline ready: main currency resource ${currencyId}`); +process.exit(0); diff --git a/tests/test-db.ts b/tests/test-db.ts new file mode 100644 index 0000000..6cc00fa --- /dev/null +++ b/tests/test-db.ts @@ -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 + * `_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): Promise { + 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 { + 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 { + 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 `_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 { + 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 { + 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; +} diff --git a/tests/vitest-global.ts b/tests/vitest-global.ts new file mode 100644 index 0000000..5b7a412 --- /dev/null +++ b/tests/vitest-global.ts @@ -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 { + await bootstrapTestDatabase(); +} diff --git a/tests/vitest-teardown.ts b/tests/vitest-teardown.ts new file mode 100644 index 0000000..7643d56 --- /dev/null +++ b/tests/vitest-teardown.ts @@ -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 { + try { + await truncateAllData(resolveTestDatabaseUri()); + } catch (error) { + console.warn("[test-db] teardown truncate failed:", error); + } +} diff --git a/vitest.config.mts b/vitest.config.mts index e4f454b..ce3389f 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -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"], }, }); diff --git a/vitest.setup.ts b/vitest.setup.ts index 95514a3..2a2a862 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -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();