Files
familysync/apps/api/test/global-setup.ts
T
Lucas Berger 4740d86701 chore(quick-260613-ndv-02): clean-slate comment in setup.ts + README local-test docs
- Update apps/api/test/setup.ts header: clarify tests run against familysync_test
  (provisioned by global-setup.ts), document users-cleanup decision (intact across
  tests), and note CI-vs-local env difference
- Add apps/api/README.md "Running API tests locally" section: documents the test
  DB isolation, run command, DB_ROOT_PASSWORD requirement, and CI no-op behaviour
- Fix apps/api/test/global-setup.ts: switch from drizzle({ client, mode }) to
  drizzle(pool, { mode }) — drizzle-orm@0.45.2 isConfig() has a tautological OR
  in the `mode` branch that always returns false, causing the combined-config form
  to pass the config object as the client (client.query is not a function); two-arg
  form routes correctly; 244/244 tests pass against familysync_test
2026-06-13 17:02:50 -04:00

114 lines
4.4 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 });
} finally {
await appPool.end();
}
console.log('[global-setup] provisioned + migrated familysync_test');
}