Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 14 additions & 0 deletions test/jest-sql.config.js
Original file line number Diff line number Diff line change
@@ -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: ["<rootDir>/../test"],
globalSetup: "<rootDir>/../test/utils/jest-global-setup.ts",
globalTeardown: "<rootDir>/../test/utils/jest-global-teardown.ts",
};
90 changes: 12 additions & 78 deletions test/migrations.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void> } }
)?.pool?.end();
await container?.stop();
await db?.stop();
});

const relkind = async (name: string) => {
const rows = await postgresService.query<Array<{ relkind: string }>>(
const rows = await db.postgres.query<Array<{ relkind: string }>>(
"SELECT relkind FROM pg_class WHERE relname = $1 AND relnamespace = 'public'::regnamespace",
[name],
);
Expand Down
7 changes: 6 additions & 1 deletion test/utils/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export type KillOptions = {

export class Fixtures {
private seq = 0;
private killSeq = 0;

constructor(
private readonly postgres: PostgresService,
Expand Down Expand Up @@ -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,
],
);
Expand Down
27 changes: 27 additions & 0 deletions test/utils/jest-global-setup.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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;
}
3 changes: 3 additions & 0 deletions test/utils/jest-global-teardown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default async function globalTeardown(): Promise<void> {
await globalThis.__SQL_TEST_CONTAINER__?.stop();
}
164 changes: 127 additions & 37 deletions test/utils/sql-test-db.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<void>;
}

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<void> {
// 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<void> } }
)?.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<SqlTestDb> {
export async function bootContainerAndMigrate(
loggerName: string,
): Promise<SqlTestDb> {
const container = await new PostgreSqlContainer(IMAGE)
.withDatabase("hasura")
.withDatabase(TEMPLATE_DB)
.withUsername("hasura")
.withPassword("hasura")
.withCommand([
Expand All @@ -37,57 +79,105 @@ export async function bootMigratedDb(loggerName: string): Promise<SqlTestDb> {
// 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<void> } }
)?.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<SqlTestDb> {
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<SqlTestDb> {
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.
Expand Down
Loading