diff --git a/package.json b/package.json index 03268b63..7fe457d7 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "test": "jest", "test:unit": "jest --testPathPatterns 'src/.*\\.spec\\.ts$' --runInBand", - "test:sql": "jest --testPathPatterns 'test/.*\\.spec\\.ts$' --runInBand", + "test:sql": "jest --config ./test/jest-sql.config.js", "typecheck:tests": "tsc -p tsconfig.spec.json", "test:watch": "jest --watch", "test:cov": "jest --coverage", diff --git a/test/jest-sql.config.js b/test/jest-sql.config.js new file mode 100644 index 00000000..a72845aa --- /dev/null +++ b/test/jest-sql.config.js @@ -0,0 +1,14 @@ +// SQL-suite config: same transform/mapping as the base package.json config, +// but scoped to test/ and wired to the shared-container global setup so the +// migration pipeline runs once and every suite clones the template database. +// Suites are independent (one clone each), so they run on jest's default +// parallel workers. +const base = require("../package.json").jest; + +module.exports = { + ...base, + rootDir: "../src", + roots: ["/../test"], + globalSetup: "/../test/utils/jest-global-setup.ts", + globalTeardown: "/../test/utils/jest-global-teardown.ts", +}; diff --git a/test/migrations.spec.ts b/test/migrations.spec.ts index ba8e58c1..cf9929e9 100644 --- a/test/migrations.spec.ts +++ b/test/migrations.spec.ts @@ -1,90 +1,24 @@ -import { Logger } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { - PostgreSqlContainer, - StartedPostgreSqlContainer, -} from "@testcontainers/postgresql"; -import { HasuraService } from "./../src/hasura/hasura.service"; -import { PostgresService } from "./../src/postgres/postgres.service"; - -// Boots a throwaway Postgres (same TimescaleDB/PG17 image prod runs) and drives -// the real HasuraService.setup() through the full migration -> enums -> functions -// -> views -> triggers pipeline. This is the path that runs on a fresh install, -// and it is what regresses when an object's type changes underneath a file the -// auto-loader still re-applies (e.g. a view file left behind after the object -// became a table). Asserting it completes guards the cold-start path end to end. +import { bootMigratedDb, SqlTestDb } from "./utils/sql-test-db"; + +// Guards the cold-start migration -> enums -> functions -> views -> triggers +// pipeline: it is what regresses when an object's type changes underneath a +// file the auto-loader still re-applies (e.g. a view file left behind after +// the object became a table). Under jest-sql.config.js the pipeline runs for +// real in the global setup and this suite asserts against a clone of the +// result; standalone runs migrate from scratch per suite. describe("hasura migrations (cold start)", () => { - // Image, extensions, and connection user mirror production - // (5stack-panel/base/timescaledb): create_hypertable migrations need - // TimescaleDB, and setup() calls pg_stat_statements_reset() after migrating, - // which requires pg_stat_statements in shared_preload_libraries. - const IMAGE = "timescale/timescaledb:latest-pg17"; - - let container: StartedPostgreSqlContainer; - let postgresService: PostgresService; + let db: SqlTestDb; beforeAll(async () => { - container = await new PostgreSqlContainer(IMAGE) - .withDatabase("hasura") - .withUsername("hasura") - .withPassword("hasura") - .withCommand([ - "postgres", - "-c", - "shared_preload_libraries=timescaledb,pg_stat_statements", - ]) - .start(); - - const configService = new ConfigService({ - postgres: { - connections: { - default: { - host: container.getHost(), - port: container.getPort(), - user: container.getUsername(), - password: container.getPassword(), - database: container.getDatabase(), - max: 5, - }, - }, - }, - app: { - demosDomain: "demos.test", - relayDomain: "relay.test", - }, - }); - - const logger = new Logger("MigrationTest"); - postgresService = new PostgresService(configService, logger); - - // The prod image provisions the timescaledb extension outside the migrations; - // do the same so create_hypertable migrations resolve. - await postgresService.query( - "CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE", - ); - - const hasuraService = new HasuraService( - logger, - // CacheService is unused by setup(); the GraphQL/cache paths are not exercised. - null as never, - configService, - postgresService, - ); - - await hasuraService.setup(); + db = await bootMigratedDb("MigrationTest"); }, 600_000); afterAll(async () => { - // Drain the pool before the container goes away, otherwise pg emits an - // idle-client error when the socket is torn out from under it. - await ( - postgresService as unknown as { pool: { end(): Promise } } - )?.pool?.end(); - await container?.stop(); + await db?.stop(); }); const relkind = async (name: string) => { - const rows = await postgresService.query>( + const rows = await db.postgres.query>( "SELECT relkind FROM pg_class WHERE relname = $1 AND relnamespace = 'public'::regnamespace", [name], ); diff --git a/test/utils/fixtures.ts b/test/utils/fixtures.ts index c4fe487b..3781d160 100644 --- a/test/utils/fixtures.ts +++ b/test/utils/fixtures.ts @@ -47,6 +47,7 @@ export type KillOptions = { export class Fixtures { private seq = 0; + private killSeq = 0; constructor( private readonly postgres: PostgresService, @@ -241,7 +242,11 @@ export class Fixtures { victim, opts.victimTeam ?? "CT", opts.weapon ?? "ak47", - opts.time ?? new Date().toISOString(), + // The pkey is (match_map_id, time, attacker, attacked); two default-time + // kills for the same pair can land in the same millisecond, so mint a + // unique recent timestamp that still increases in insertion order. + opts.time ?? + new Date(Date.now() - 60_000 + ++this.killSeq).toISOString(), opts.headshot ?? false, ], ); diff --git a/test/utils/jest-global-setup.ts b/test/utils/jest-global-setup.ts new file mode 100644 index 00000000..2e25db74 --- /dev/null +++ b/test/utils/jest-global-setup.ts @@ -0,0 +1,27 @@ +import type { StartedPostgreSqlContainer } from "@testcontainers/postgresql"; +import { bootContainerAndMigrate, endPool } from "./sql-test-db"; + +declare global { + // eslint-disable-next-line no-var + var __SQL_TEST_CONTAINER__: StartedPostgreSqlContainer | undefined; +} + +// Boots one shared container and runs the full migration pipeline once, into +// the database the suites then clone via CREATE DATABASE ... TEMPLATE. Workers +// inherit process.env, so the connection details travel that way; the +// container handle stays on globalThis for the teardown (same process). +export default async function globalSetup(): Promise { + const { container, postgres } = await bootContainerAndMigrate( + "SqlTestTemplate", + ); + + // The template must have zero connections when suites clone from it. + await endPool(postgres); + + process.env.SQL_TEST_HOST = container!.getHost(); + process.env.SQL_TEST_PORT = String(container!.getPort()); + process.env.SQL_TEST_USER = container!.getUsername(); + process.env.SQL_TEST_PASSWORD = container!.getPassword(); + + globalThis.__SQL_TEST_CONTAINER__ = container; +} diff --git a/test/utils/jest-global-teardown.ts b/test/utils/jest-global-teardown.ts new file mode 100644 index 00000000..bd7c3677 --- /dev/null +++ b/test/utils/jest-global-teardown.ts @@ -0,0 +1,3 @@ +export default async function globalTeardown(): Promise { + await globalThis.__SQL_TEST_CONTAINER__?.stop(); +} diff --git a/test/utils/sql-test-db.ts b/test/utils/sql-test-db.ts index 5b019e33..2e98cf20 100644 --- a/test/utils/sql-test-db.ts +++ b/test/utils/sql-test-db.ts @@ -1,5 +1,7 @@ +import { randomBytes } from "crypto"; import { Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; +import { Client } from "pg"; import { PostgreSqlContainer, StartedPostgreSqlContainer, @@ -12,20 +14,60 @@ import { PostgresService } from "../../src/postgres/postgres.service"; // TimescaleDB, and setup() calls pg_stat_statements_reset() after migrating, // which requires pg_stat_statements in shared_preload_libraries. const IMAGE = "timescale/timescaledb:latest-pg17"; +const TEMPLATE_DB = "hasura"; + +// Serializes CREATE DATABASE ... TEMPLATE across parallel jest workers; the +// template must have no concurrent access while it's being copied. +const CLONE_LOCK_ID = 421337; export interface SqlTestDb { - container: StartedPostgreSqlContainer; + container?: StartedPostgreSqlContainer; postgres: PostgresService; hasura: HasuraService; stop(): Promise; } +function makeServices(connection: { + host: string; + port: number; + user: string; + password: string; + database: string; +}, loggerName: string): { postgres: PostgresService; hasura: HasuraService } { + const configService = new ConfigService({ + postgres: { connections: { default: { ...connection, max: 5 } } }, + app: { demosDomain: "demos.test", relayDomain: "relay.test" }, + }); + + const logger = new Logger(loggerName); + const postgres = new PostgresService(configService, logger); + const hasura = new HasuraService( + logger, + // CacheService is unused by setup(); the GraphQL/cache paths are not exercised. + null as never, + configService, + postgres, + ); + + return { postgres, hasura }; +} + +export async function endPool(postgres: PostgresService): Promise { + // Drain the pool before its database goes away, otherwise pg emits an + // idle-client error when the socket is torn out from under it. + await ( + postgres as unknown as { pool: { end(): Promise } } + )?.pool?.end(); +} + // Boots a throwaway Postgres and drives the real HasuraService.setup() through // the full migration -> enums -> functions -> views -> triggers pipeline, so // trigger/function behavior under test matches a fresh install exactly. -export async function bootMigratedDb(loggerName: string): Promise { +export async function bootContainerAndMigrate( + loggerName: string, +): Promise { const container = await new PostgreSqlContainer(IMAGE) - .withDatabase("hasura") + .withDatabase(TEMPLATE_DB) .withUsername("hasura") .withPassword("hasura") .withCommand([ @@ -37,57 +79,105 @@ export async function bootMigratedDb(loggerName: string): Promise { // server start so seeding servers works. "-c", "fivestack.app_key=test-app-key", + // Parallel suites each hold a small pool against this one server. + "-c", + "max_connections=200", + // Scheduler/telemetry workers open their own connections to every + // database with the extension installed; a connection to the template + // database would make CREATE DATABASE ... TEMPLATE fail. Tests exercise + // no timescale jobs, so turn them off. + "-c", + "timescaledb.max_background_workers=0", + "-c", + "timescaledb.telemetry_level=off", ]) .start(); - const configService = new ConfigService({ - postgres: { - connections: { - default: { - host: container.getHost(), - port: container.getPort(), - user: container.getUsername(), - password: container.getPassword(), - database: container.getDatabase(), - max: 5, - }, - }, - }, - app: { demosDomain: "demos.test", relayDomain: "relay.test" }, - }); - - const logger = new Logger(loggerName); - const postgres = new PostgresService(configService, logger); - // The prod image provisions the timescaledb extension outside the migrations; // do the same so create_hypertable migrations resolve. - await postgres.query("CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE"); - - const hasuraService = new HasuraService( - logger, - // CacheService is unused by setup(); the GraphQL/cache paths are not exercised. - null as never, - configService, - postgres, + const { postgres, hasura } = makeServices( + { + host: container.getHost(), + port: container.getPort(), + user: container.getUsername(), + password: container.getPassword(), + database: container.getDatabase(), + }, + loggerName, ); + await postgres.query("CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE"); - await hasuraService.setup(); + await hasura.setup(); return { container, postgres, - hasura: hasuraService, + hasura, stop: async () => { - // Drain the pool before the container goes away, otherwise pg emits an - // idle-client error when the socket is torn out from under it. - await ( - postgres as unknown as { pool: { end(): Promise } } - )?.pool?.end(); + await endPool(postgres); await container?.stop(); }, }; } +// Fast path used under test/jest-sql.config.js: the global setup already +// booted one container and migrated the template database, so a suite only +// needs its own copy — CREATE DATABASE ... TEMPLATE is a file-level clone +// that takes a fraction of a second instead of a container boot plus the +// full migration pipeline. +async function cloneFromTemplate(loggerName: string): Promise { + const connection = { + host: process.env.SQL_TEST_HOST!, + port: Number(process.env.SQL_TEST_PORT), + user: process.env.SQL_TEST_USER!, + password: process.env.SQL_TEST_PASSWORD!, + }; + + const database = `test_${loggerName + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_")}_${randomBytes(4).toString("hex")}`; + + // CREATE DATABASE cannot run inside a pool/transaction; use a raw client + // against the maintenance database. + const admin = new Client({ ...connection, database: "postgres" }); + await admin.connect(); + try { + await admin.query("SELECT pg_advisory_lock($1)", [CLONE_LOCK_ID]); + try { + await admin.query( + `CREATE DATABASE "${database}" TEMPLATE ${TEMPLATE_DB}`, + ); + } finally { + await admin.query("SELECT pg_advisory_unlock($1)", [CLONE_LOCK_ID]); + } + } finally { + await admin.end(); + } + + const { postgres, hasura } = makeServices( + { ...connection, database }, + loggerName, + ); + + return { + postgres, + hasura, + stop: async () => { + // The shared container outlives the suite (global teardown stops it); + // clones are cheap and die with it, so dropping them isn't worth a + // maintenance connection here. + await endPool(postgres); + }, + }; +} + +export async function bootMigratedDb(loggerName: string): Promise { + if (process.env.SQL_TEST_HOST) { + return cloneFromTemplate(loggerName); + } + return bootContainerAndMigrate(loggerName); +} + // Runs fn inside a transaction that carries Hasura session variables, the way // requests arrive through Hasura. current_setting('hasura.user') is read by // many triggers, so the config must share the transaction's connection.