morphit/apps/indexer/test/integration/harness.ts

214 lines
7.2 KiB
TypeScript

/**
* Integration test harness — real Postgres.
*
* Philosophy: these tests exercise SQL semantics that unit tests
* can't meaningfully cover (column types, predicate correctness,
* CTE self-joins, JSON operators, INTERVAL arithmetic). They
* complement the unit tests, they don't replace them.
*
* Running:
* TEST_DATABASE_URL=postgres://... npm test
* Without that env var, the test files that import from this
* module will describe.skip their suites, so `npm test` still
* passes on developer machines without a postgres running.
*
* Isolation: each suite creates its own Postgres schema, named
* with a random suffix. Tests in a suite share the schema (so
* cross-test data can exist if a test suite wants) but different
* suites never see each other. This matters for parallel CI runs
* against a single shared database.
*/
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
import { randomBytes } from 'node:crypto';
import pg from 'pg';
import type { Database } from '../../src/db/pool';
const HERE = fileURLToPath(new URL('.', import.meta.url));
const SRC_DB_DIR = resolve(HERE, '../../src/db');
/** URL from env, or null if integration tests should skip. */
export const TEST_DATABASE_URL = process.env.TEST_DATABASE_URL ?? null;
/** True if integration tests should run. Guard every
* `describe()` so the suite skips cleanly when Postgres isn't
* available. */
export const INTEGRATION_ENABLED = TEST_DATABASE_URL !== null;
/** One schema per suite. Use cryptographically-random bytes so
* parallel CI runs pointing at the same DB don't collide.
* Schemas are lowercase, underscores, no dashes. */
function randomSchemaName(): string {
return `morphit_test_${randomBytes(6).toString('hex')}`;
}
export interface IntegrationFixture {
readonly pool: pg.Pool;
readonly db: Database;
readonly schema: string;
/** Apply every registered migration SQL, redirected at the
* fixture's schema. NOT called automatically by setup() — either
* call this yourself after setup(), or use the setupWithMigrations()
* convenience wrapper (which every data-test should). A bare setup()
* leaves an EMPTY schema with no tables. */
applyMigrations(): Promise<void>;
/** Destroy the schema and close the pool. Always called from
* teardown() — tests don't need to invoke this directly. */
teardown(): Promise<void>;
}
/**
* Set up a fresh Postgres schema for a test suite. Returns a
* fixture with a Pool and a Database wrapper. Call teardown() in
* afterAll.
*/
export async function setup(): Promise<IntegrationFixture> {
if (!TEST_DATABASE_URL) {
throw new Error(
'setup() called but TEST_DATABASE_URL is not set — ' +
'use INTEGRATION_ENABLED to gate your describe()'
);
}
const schema = randomSchemaName();
const pool = new pg.Pool({ connectionString: TEST_DATABASE_URL });
// Create the schema using a client we control end-to-end. We
// deliberately DO NOT use a pool.on('connect') hook to set
// search_path per-connection — that hook is async and can
// race with the first query issued on a freshly checked-out
// client. Instead, every entry point below acquires a client
// and sets search_path explicitly before issuing SQL.
{
const c = await pool.connect();
try {
await c.query(`CREATE SCHEMA "${schema}"`);
} finally {
c.release();
}
}
/** Internal helper: check out a client, set its search_path to
* our test schema, and hand it to `fn`. Always releases on
* exit. Callers decide whether to wrap in a transaction. */
async function withClient<T>(fn: (client: pg.PoolClient) => Promise<T>): Promise<T> {
const client = await pool.connect();
try {
await client.query(`SET search_path TO "${schema}"`);
return await fn(client);
} finally {
client.release();
}
}
// A Database adapter that matches the production interface so
// handlers and detectors see no difference.
const db: Database = {
async withTx(fn) {
return withClient(async (client) => {
await client.query('BEGIN');
try {
const result = await fn(client);
await client.query('COMMIT');
return result;
} catch (err) {
await client.query('ROLLBACK').catch(() => undefined);
throw err;
}
});
},
async query(text, params) {
return withClient((client) => client.query(text, params as unknown[]));
},
async close() {
await pool.end();
}
};
async function applyMigrations(): Promise<void> {
// Use the production migration runner — same code path as
// boot, so a regression in MIGRATIONS or runMigrations()
// surfaces here, not silently in production. The runner
// records to schema_migrations inside our test schema (we
// have search_path set to that schema before any query).
//
// Pre-2026-05 the harness had its own SQL-file applier
// that bypassed runMigrations. That divergence allowed
// production-runner regressions (G1 missing v24 between
// v23 and v25) to slip past CI. Unifying the path is the
// audit fix.
const { runMigrations } = await import('../../src/db/migrations');
await runMigrations(db);
}
async function teardown(): Promise<void> {
try {
// DROP SCHEMA must run in the default search_path (the
// schema we're about to drop is not on it, since our
// queries set search_path to the target schema). Use a
// raw client without the search_path hook.
const c = await pool.connect();
try {
await c.query(`DROP SCHEMA "${schema}" CASCADE`);
} finally {
c.release();
}
} catch {
// Best effort — if this throws, the pool.end below still
// runs.
}
await pool.end();
}
return { pool, db, schema, applyMigrations, teardown };
}
/**
* Convenience wrapper: set up, apply migrations, return a ready
* fixture. Most tests want this one-liner.
*/
export async function setupWithMigrations(): Promise<IntegrationFixture> {
const fx = await setup();
await fx.applyMigrations();
return fx;
}
/** Quick truncation of every Morphit data table — call from
* beforeEach for tests that need a clean slate between cases
* within a suite. Excludes `indexer_state` and
* `schema_migrations`, which are harness/ops concerns rather
* than test-visible state; clearing them between cases would
* force a re-migration which isn't the point. */
export async function truncateAll(fx: IntegrationFixture): Promise<void> {
const tables = [
'account_loyalty',
'account_loyalty_milestones',
'accounts',
'chat_identities',
'chat_messages',
'fee_attestations',
'fee_transfers',
'feedback_responses',
'feedback',
// v31 (Part 113): Signal C pile-on detector storage.
// Pre-Part-118 this table was missing from truncateAll,
// meaning cross-test bleed could leave Signal C rows from a
// prior test polluting the next. Added in the same work
// unit as the /feedback suppression flag extension (Part
// 118) — both touch this table; harness should mirror the
// schema completeness.
'one_way_pile_on',
'orders',
'ops',
'profiles',
'related_accounts',
'relay_pending_transfers',
'releases',
'suspicious_reciprocity',
'witness_fee_history'
];
// Goes through db.query so search_path is set on the client
// that receives the TRUNCATE.
await fx.db.query(`TRUNCATE ${tables.map((t) => `"${t}"`).join(', ')} CASCADE`);
}