Files
familysync/apps/api/test/global-setup.ts
T
Lucas Berger 24cb7569bf fix(quick-260613-ndv): reset familysync_test each run (truncate-all)
CREATE DATABASE IF NOT EXISTS reuses the prior run's data, so the test DB
would itself accumulate users run-over-run (the same flaky list_shares
fan-out the dev DB suffered). Truncate every table (except the drizzle
migration ledger) at globalSetup start, FK-safe, so each run is a clean
deterministic slate. Verified: familysync_test users 186 -> 93 across a run
(reset, not doubled); dev familysync untouched at 3.
2026-06-13 17:14:51 -04:00

137 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Vitest globalSetup — provisions and migrates familysync_test for local runs.
*
* Runs ONCE in the main vitest process before any test file.
* Under CI (process.env.CI truthy), returns immediately — CI provisions its own
* `familysync` service DB and must not be touched.
*
* Local flow:
* 1. Root-connects → CREATE DATABASE IF NOT EXISTS familysync_test
* 2. GRANT ALL PRIVILEGES ON familysync_test.* to the app user
* 3. App-user connects → drizzle migrate() applies committed SQL migrations
*
* Security:
* - Root password read from process.env.DB_ROOT_PASSWORD (default 'root' matches dev compose only)
* - App user validated against /^[A-Za-z0-9_]+$/ before GRANT interpolation (T-ndv-03)
* - This file never runs in CI (T-ndv-04)
*/
import mysql from 'mysql2/promise';
import { drizzle } from 'drizzle-orm/mysql2';
import { migrate } from 'drizzle-orm/mysql2/migrator';
import { fileURLToPath } from 'node:url';
const TEST_DB = 'familysync_test';
export default async function setup(): Promise<void> {
const isCI = !!process.env.CI;
if (isCI) {
// CI provisions its own database via the mariadb service + db:migrate step.
// Do nothing here so the CI flow is completely unaffected (T-ndv-04).
return;
}
// ── Connection params (dev defaults match docker-compose.dev.yml) ──────────
const host = process.env.DB_HOST ?? '127.0.0.1';
const port = Number(process.env.DB_PORT ?? 3306);
const appUser = process.env.DB_USER ?? 'familysync';
const appPassword = process.env.DB_PASSWORD ?? '';
// ── Root creds — read from env; dev default 'root' only for convenience ────
const rootUser = process.env.DB_ROOT_USER ?? 'root';
const rootPassword = process.env.DB_ROOT_PASSWORD ?? 'root';
// ── Security: validate appUser before interpolating into GRANT ──────────────
if (!/^[A-Za-z0-9_]+$/.test(appUser)) {
throw new Error(
`[global-setup] DB_USER '${appUser}' contains characters not allowed in a GRANT statement. Aborting.`,
);
}
// ── Step 13: Root connection — CREATE DATABASE + GRANT ────────────────────
const rootConn = await mysql.createConnection({
host,
port,
user: rootUser,
password: rootPassword,
// No database selected — we are creating one
});
try {
await rootConn.query(`CREATE DATABASE IF NOT EXISTS \`${TEST_DB}\``);
// GRANT does not accept parameterised identifiers; user is validated above (T-ndv-03).
// Try @'%' first (Docker/network access); fall back to @'localhost' if the user was
// created with a localhost host qualifier.
try {
await rootConn.query(
`GRANT ALL PRIVILEGES ON \`${TEST_DB}\`.* TO '${appUser}'@'%'`,
);
} catch {
// If @'%' fails (user only exists as @'localhost'), try that form.
await rootConn.query(
`GRANT ALL PRIVILEGES ON \`${TEST_DB}\`.* TO '${appUser}'@'localhost'`,
);
}
await rootConn.query('FLUSH PRIVILEGES');
} finally {
await rootConn.end();
}
// ── Step 4: Apply committed migrations to familysync_test ──────────────────
// Use a Pool (not a Connection) — drizzle-orm/mysql2 session.all() calls
// client.execute() and transaction() calls client.getConnection(), both of
// which are pool methods. createPool with connectionLimit:1 is the minimal form.
const appPool = mysql.createPool({
host,
port,
user: appUser,
password: appPassword,
database: TEST_DB,
connectionLimit: 1,
multipleStatements: true, // required by drizzle migrator for multi-statement SQL files
});
try {
// Pass pool as the first arg + config as the second. Do NOT use the
// { client: pool, mode } combined-config form — drizzle-orm@0.45.2 has a
// bug in isConfig() where the `mode` branch always returns false (its OR
// condition is a tautology), so the combined form falls through to
// construct({ client, mode }, undefined) and the session client becomes
// the plain config object (no .query()). The two-arg form is safe.
const db = drizzle(appPool, { mode: 'default' });
const migrationsFolder = fileURLToPath(
new URL('../src/db/migrations', import.meta.url),
);
await migrate(db, { migrationsFolder });
// Clean slate each run: truncate every table (except drizzle's migration
// ledger) so familysync_test does NOT accumulate rows across runs. Without
// this, CREATE DATABASE IF NOT EXISTS reuses the prior run's data and the
// users table grows unbounded — re-creating the slow/flaky list_shares
// fan-out the dev DB used to suffer, just in the test DB. FK-safe via the
// session FOREIGN_KEY_CHECKS toggle (scoped to this one connection).
const conn = await appPool.getConnection();
try {
const [rows] = await conn.query(
`SELECT table_name AS t FROM information_schema.tables
WHERE table_schema = ? AND table_type = 'BASE TABLE'
AND table_name <> '__drizzle_migrations'`,
[TEST_DB],
);
await conn.query('SET FOREIGN_KEY_CHECKS = 0');
for (const { t } of rows as Array<{ t: string }>) {
await conn.query(`TRUNCATE TABLE \`${t}\``);
}
await conn.query('SET FOREIGN_KEY_CHECKS = 1');
} finally {
conn.release();
}
} finally {
await appPool.end();
}
console.log('[global-setup] provisioned + migrated + reset familysync_test');
}