/** * Vitest global setup. Runs once per test file before any test bodies. * * Responsibilities: * 1. Load .env.test (falls back to env vars already in process.env if missing). * 2. Override DATABASE_URL / VALKEY_URL with the *test* equivalents BEFORE any * backend service modules are imported — services capture `getDb()` at module * load, so this rewrite must happen first. * 3. Run the migration against the test schema (idempotent — single migrate.js). * 4. Provide a `beforeEach` truncate hook for any test that imports the helper. * * Why this file is small: helpers live under test/helpers/* and are imported lazily * by individual test files. This file is intentionally just env setup + migrate. */ import { existsSync } from 'node:fs' import { resolve, dirname } from 'node:path' import { fileURLToPath } from 'node:url' import { config as loadDotenv } from 'dotenv' import { spawnSync } from 'node:child_process' import { afterAll, beforeAll } from 'vitest' const __dirname = dirname(fileURLToPath(import.meta.url)) const backendRoot = resolve(__dirname, '..') // 1. Load .env.test if it exists const envTestPath = resolve(backendRoot, '.env.test') if (existsSync(envTestPath)) { loadDotenv({ path: envTestPath }) } // 2. Validate required env (fail fast — silent fallback to dev DB would be catastrophic) const required = ['TEST_DATABASE_URL', 'TEST_VALKEY_URL', 'AUTH_JWT_SECRET'] for (const key of required) { if (!process.env[key]) { throw new Error( `Missing required test env var: ${key}. Copy .env.test.example → .env.test or set it in your shell.` ) } } if (process.env.AUTH_JWT_SECRET.length < 32) { throw new Error('AUTH_JWT_SECRET must be at least 32 chars') } const TEST_SCHEMA = process.env.TEST_DB_SCHEMA || 'halobestie_test' if (TEST_SCHEMA === 'public') { // Hard guard: schema isolation only works if we don't reuse the dev `public` schema. throw new Error( `TEST_DB_SCHEMA must not be "public" (would clobber dev tables). ` + `Set TEST_DB_SCHEMA to something like "halobestie_test".` ) } // 3. Build the schema-scoped URL used by getDb() in services. // The `?options=-c search_path=...` query param tells Postgres to set search_path // on every new connection. All CREATE TABLE / INSERT / SELECT then default to the // test schema, leaving the dev `public` schema untouched. const baseTestUrl = process.env.TEST_DATABASE_URL const sep = baseTestUrl.includes('?') ? '&' : '?' const scopedTestUrl = `${baseTestUrl}${sep}options=${encodeURIComponent(`-c search_path=${TEST_SCHEMA},public`)}` // CRITICAL: rewrite the env vars services read at module load time. Must happen before // any `import { ... } from '../src/services/...'` in a test or helper. process.env.DATABASE_URL = scopedTestUrl process.env.VALKEY_URL = process.env.TEST_VALKEY_URL beforeAll(async () => { // Ensure the schema exists. Use a one-shot connection that's NOT the singleton. const { default: postgres } = await import('postgres') const bootstrap = postgres(process.env.TEST_DATABASE_URL) try { await bootstrap`CREATE SCHEMA IF NOT EXISTS ${bootstrap(TEST_SCHEMA)}` } finally { await bootstrap.end() } // Run the migration via a child process so we don't conflict with the singleton // sql client this test process will use. migrate.js calls sql.end() at the bottom, // which would tear down the shared client if invoked in-process. const result = spawnSync( process.execPath, [resolve(backendRoot, 'src/db/migrate.js')], { env: { ...process.env, DATABASE_URL: scopedTestUrl, }, cwd: backendRoot, encoding: 'utf8', } ) if (result.status !== 0) { throw new Error( `Test migration failed (exit ${result.status}):\n` + `stdout: ${result.stdout}\nstderr: ${result.stderr}` ) } }, 60_000) afterAll(async () => { // Best-effort cleanup of any singletons opened by the test process. Each helper that // opens a connection registers its own teardown; this is a safety net. try { const { getDb } = await import('../src/db/client.js') const sql = getDb() await sql.end({ timeout: 5 }) } catch { // Singleton was never created — fine. } })