Files
redefined-designs/backend/tests/integration/setup/testDb.ts
T
bermudalambandClaude Opus 5 813216f971 test(integration): say so when the database loses its schema (#154)
When the integration run lost its schema partway through, it presented as 36 assertion errors about categories, price filters and favourite notifications. The real message — `relation "items" does not exist` — was further down the same log, and hours went into chasing the assertions instead.

The cause is still open and needs runner-side evidence this cannot reach: whether the Postgres service container is being recreated mid-run, which would come back with an empty data directory. This is the half that can be fixed from here — whatever the cause, the next occurrence reads as "the database lost its schema" on the first line, names which tables are gone, and says that nothing in the suite drops tables so the database was replaced underneath the run.

globalSetup asserts once after migrating, which establishes the fact the rest of the run depends on. Without it, a run that never had a schema and one that lost it midway are indistinguishable from the failures they produce.

resetDb checks only when its TRUNCATE fails, rather than on every reset. It runs in a beforeEach several hundred times a suite, and an extra round trip each time to guard against a rare event would be paying continuously for it. When the schema is fine, an unrelated failure is passed through untouched rather than dressed up as a schema problem.

The diagnostic is tested against a database that has actually lost its schema, not reasoned about. An earlier attempt dropped the schema before the run and proved nothing — globalSetup re-migrates, so it repaired itself and every test passed. The suite drops and rebuilds around each case and restores in afterAll; the full integration suite was then run twice to confirm the restore holds for everything ordered after it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 18:43:54 -05:00

133 lines
5.0 KiB
TypeScript
Executable File

import { Pool } from 'pg';
import { runner } from 'node-pg-migrate';
import path from 'path';
export const testPool = new Pool({
host: process.env.TEST_PGHOST || 'localhost',
port: parseInt(process.env.TEST_PGPORT || '55432', 10),
user: process.env.TEST_PGUSER || 'redefined_test',
password: process.env.TEST_PGPASSWORD || 'redefined_test',
database: process.env.TEST_PGDATABASE || 'redefined_test'
});
export async function migrate(): Promise<void> {
await runner({
databaseUrl: {
host: process.env.TEST_PGHOST || 'localhost',
port: parseInt(process.env.TEST_PGPORT || '55432', 10),
user: process.env.TEST_PGUSER || 'redefined_test',
password: process.env.TEST_PGPASSWORD || 'redefined_test',
database: process.env.TEST_PGDATABASE || 'redefined_test'
},
dir: path.resolve(__dirname, '..', '..', '..', 'migrations'),
direction: 'up',
migrationsTable: 'pgmigrations',
count: Infinity
});
}
/**
* The tables `resetDb` truncates. Also, therefore, exactly the set whose absence
* makes this suite meaningless.
*/
const REQUIRED_TABLES = [
'admin_settings',
'cart_items',
'carts',
'categories',
'checkout_items',
'checkouts',
'customer_sessions',
'customer_tokens',
'customers',
'favorites',
'item_drafts',
'item_images',
'item_tags',
'items',
'orders',
'shipping_addresses',
'tags',
'upload_links'
] as const;
/** Which of them the database does not currently have. */
async function missingTables(): Promise<string[]> {
const { rows } = await testPool.query<{ table_name: string }>(
`SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'`
);
const present = new Set(rows.map((row) => row.table_name));
return REQUIRED_TABLES.filter((name) => !present.has(name));
}
/**
* Says plainly when the schema has gone, rather than letting the suite report
* it as unrelated logic failures.
*
* In #154 the integration run lost its schema partway through — a Postgres
* service container recreated mid-run comes back with an empty data directory —
* and it presented as 36 assertion errors about categories, price filters and
* favourite notifications. The real message, `relation "items" does not exist`,
* was further down the same log. Hours went into chasing the assertions.
*
* The cause is still open and needs runner-side evidence. This is the half that
* can be fixed from here: whatever the cause, the next occurrence should read as
* "the database lost its schema" on the first line.
*/
export async function assertSchemaPresent(context: string): Promise<void> {
const missing = await missingTables();
if (missing.length === 0) return;
throw new Error(
`The test database has no schema (${context}).
` +
`Missing ${missing.length} of ${REQUIRED_TABLES.length} tables: ${missing.join(', ')}.
` +
`Migrations ran at the start of this run, so the schema existed and has since gone. ` +
`Nothing in this suite drops tables — TRUNCATE does not — so the database itself was ` +
`replaced or restarted underneath the run. On CI the likeliest cause is the Postgres ` +
`service container being recreated, which comes back with an empty data directory. ` +
`See #154.`
);
}
export async function resetDb(): Promise<void> {
// The schema check runs only when the truncate fails, not on every reset.
// resetDb runs in a beforeEach several hundred times a suite, and an extra
// round trip each time to guard against something that has happened once
// would be paying continuously for a rare event. A failure here is where the
// information is worth having.
try {
await testPool.query(`
TRUNCATE TABLE item_drafts, upload_links, orders, checkout_items, checkouts,
shipping_addresses, cart_items, carts, customer_tokens, customer_sessions, favorites,
customers, item_tags, item_images, items, tags, categories
RESTART IDENTITY CASCADE
`);
} catch (err) {
await assertSchemaPresent('while resetting between tests');
// The schema is there, so this is not #154 — let the original speak.
throw err;
}
// admin_settings is not truncated — it holds the seeded cart_expiry_hours
// default that other suites read. But the email template rows in it are test
// data like any other, and a stored template outliving the suite that wrote
// it silently changes the mail every later suite asserts on. That is not
// hypothetical: a subject of "Gone" written by the template tests reached the
// favorite-alert tests and made five of them fail somewhere else entirely.
//
// Same reasoning, same failure, for the intake settings (#227): a ceiling of
// 1 left behind by the ceiling suite makes every later submission refuse with
// a 503, in files that never mention a ceiling.
await testPool.query(
`DELETE FROM admin_settings WHERE key LIKE 'email\_%' OR key LIKE 'intake\_%'`
);
}
export async function closeDb(): Promise<void> {
await testPool.end();
}