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:
parent
18bb6712cd
commit
8135c9850b
12 changed files with 353 additions and 8 deletions
|
|
@ -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=
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
10
tests/e2e/global-setup.ts
Normal 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
12
tests/push-schema.ts
Normal 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
56
tests/seed-baseline.ts
Normal 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
197
tests/test-db.ts
Normal 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
10
tests/vitest-global.ts
Normal 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
14
tests/vitest-teardown.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"],
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue