import { existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { spawn, ChildProcess } from 'node:child_process'; import { createServer } from 'node:net'; import { PrismaClient } from '@prisma/client'; type CommandEnv = NodeJS.ProcessEnv; function loadEnvFile(filePath: string) { if (!existsSync(filePath)) return; const content = readFileSync(filePath, 'utf8'); for (const line of content.split(/\r?\n/)) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); if (!match) continue; const [, key, rawValue] = match; if (process.env[key] !== undefined) continue; process.env[key] = rawValue.trim().replace(/^['"]|['"]$/g, ''); } } function loadLocalEnv() { loadEnvFile(resolve(process.cwd(), '.env')); loadEnvFile(resolve(process.cwd(), '..', '..', '.env')); } function timestamp() { const now = new Date(); const pad = (value: number) => String(value).padStart(2, '0'); return [ now.getFullYear(), pad(now.getMonth() + 1), pad(now.getDate()), '_', pad(now.getHours()), pad(now.getMinutes()), pad(now.getSeconds()), ].join(''); } function databaseNameFromUrl(url: string) { return decodeURIComponent(new URL(url).pathname.replace(/^\//, '')); } function databaseUrl(baseUrl: string, dbName: string) { const url = new URL(baseUrl); url.pathname = `/${dbName}`; return url.toString(); } function adminUrl(baseUrl: string) { const url = new URL(baseUrl); url.pathname = '/postgres'; return url.toString(); } function assertTestDatabaseName(dbName: string) { if (!/^hrm_medpark_test_[A-Za-z0-9_]+$/.test(dbName)) { throw new Error(`Refusing unsafe database name "${dbName}". Expected hrm_medpark_test_*.`); } } function quotedIdentifier(dbName: string) { assertTestDatabaseName(dbName); return `"${dbName.replace(/"/g, '""')}"`; } function pnpmCommand() { return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; } function cleanEnv(env: CommandEnv) { const cleaned: Record = {}; for (const [key, value] of Object.entries(env)) { if (value === undefined || key.startsWith('=')) continue; cleaned[key] = value; } return cleaned; } async function createDatabase(baseUrl: string, dbName: string) { assertTestDatabaseName(dbName); const admin = new PrismaClient({ datasources: { db: { url: adminUrl(baseUrl) } } }); try { await admin.$executeRawUnsafe(`CREATE DATABASE ${quotedIdentifier(dbName)}`); } finally { await admin.$disconnect(); } } async function dropDatabase(baseUrl: string, dbName: string) { assertTestDatabaseName(dbName); const admin = new PrismaClient({ datasources: { db: { url: adminUrl(baseUrl) } } }); try { await admin.$executeRawUnsafe( 'SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()', dbName, ); await admin.$executeRawUnsafe(`DROP DATABASE IF EXISTS ${quotedIdentifier(dbName)}`); } finally { await admin.$disconnect(); } } async function runCommand(label: string, args: string[], env: CommandEnv) { console.log(`\n> ${label}`); await new Promise((resolvePromise, reject) => { const child = spawn(pnpmCommand(), args, { cwd: process.cwd(), env: cleanEnv(env), stdio: 'inherit', shell: process.platform === 'win32', }); child.on('error', reject); child.on('exit', (code) => { if (code === 0) resolvePromise(); else reject(new Error(`${label} failed with exit code ${code ?? 'unknown'}`)); }); }); } async function findFreePort() { return new Promise((resolvePromise, reject) => { const server = createServer(); server.on('error', reject); server.listen(0, '127.0.0.1', () => { const address = server.address(); if (!address || typeof address === 'string') { server.close(); reject(new Error('Unable to allocate a free API port')); return; } const port = address.port; server.close(() => resolvePromise(port)); }); }); } async function waitForApi(baseUrl: string, child: ChildProcess) { const startedAt = Date.now(); while (Date.now() - startedAt < 60_000) { if (child.exitCode !== null) { throw new Error(`Temporary API exited before becoming ready (exit code ${child.exitCode})`); } try { const response = await fetch(`${baseUrl}/auth/dev-login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'readiness', role: 'hr_admin' }), }); if (response.status === 200 || response.status === 201) return; } catch { // API is still booting. } await new Promise((resolvePromise) => setTimeout(resolvePromise, 1000)); } throw new Error(`Temporary API did not become ready at ${baseUrl}`); } async function startTemporaryApi(dbUrl: string, dbName: string) { const port = Number(process.env.TEST_API_PORT ?? (await findFreePort())); const baseUrl = `http://127.0.0.1:${port}/api/v1`; const bucket = `hrm-docs-test-${dbName.replace(/_/g, '-')}`.slice(0, 63); const env: CommandEnv = { ...process.env, DATABASE_URL: dbUrl, PORT: String(port), NODE_ENV: 'test', ALLOW_DEV_LOGIN: 'true', MINIO_BUCKET: bucket, }; console.log(`\n> Starting temporary API on ${baseUrl}`); const child = spawn(pnpmCommand(), ['exec', 'nest', 'start'], { cwd: process.cwd(), env: cleanEnv(env), stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32', }); child.stdout?.on('data', (chunk: Buffer) => process.stdout.write(`[api] ${chunk.toString()}`)); child.stderr?.on('data', (chunk: Buffer) => process.stderr.write(`[api] ${chunk.toString()}`)); await waitForApi(baseUrl, child); return { child, baseUrl }; } async function stopTemporaryApi(child: ChildProcess | null) { if (!child || child.exitCode !== null) return; const waitForExit = async () => { if (child.exitCode !== null) return; await new Promise((resolvePromise) => { const timeout = setTimeout(() => resolvePromise(), 10_000); const done = () => { clearTimeout(timeout); resolvePromise(); }; child.once('exit', done); child.once('close', done); }); }; if (process.platform === 'win32' && child.pid) { await new Promise((resolvePromise) => { const killer = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' }); killer.on('exit', () => resolvePromise()); killer.on('error', () => resolvePromise()); }); await waitForExit(); child.stdout?.destroy(); child.stderr?.destroy(); child.unref(); return; } child.kill('SIGTERM'); await waitForExit(); child.stdout?.destroy(); child.stderr?.destroy(); child.unref(); } async function run() { loadLocalEnv(); const baseUrl = process.env.DATABASE_URL; if (!baseUrl) throw new Error('DATABASE_URL is required. Put it in apps/api/.env or export it before running testdb:run.'); const dbName = `hrm_medpark_test_${timestamp()}`; const dbUrl = databaseUrl(baseUrl, dbName); const commandEnv: CommandEnv = { ...process.env, DATABASE_URL: dbUrl }; let api: Awaited> | null = null; console.log(`Creating temporary database ${dbName}...`); await createDatabase(baseUrl, dbName); try { await runCommand('prisma migrate deploy', ['exec', 'prisma', 'migrate', 'deploy'], commandEnv); await runCommand('seed test data', ['exec', 'ts-node', 'scripts/seed-test-data.ts'], commandEnv); api = await startTemporaryApi(dbUrl, dbName); await runCommand('verify functionality', ['exec', 'ts-node', 'scripts/verify-functionality.ts'], { ...commandEnv, API_BASE_URL: api.baseUrl, }); console.log('\nTemporary database is ready for manual checks.'); console.log(`DATABASE_URL=${dbUrl}`); console.log('\nRun API manually from the repo root with:'); console.log(` $env:DATABASE_URL='${dbUrl}'; pnpm.cmd --filter api dev`); console.log('\nDrop it later with:'); console.log(` $env:TEST_DATABASE_URL='${dbUrl}'; pnpm.cmd --filter api testdb:drop`); } catch (error) { console.error('\nTest database run failed. The database was left in place for inspection:'); console.error(`DATABASE_URL=${dbUrl}`); throw error; } finally { await stopTemporaryApi(api?.child ?? null); } } function targetDatabaseFromArgs(baseUrl: string) { const arg = process.argv.slice(3).find((value) => value !== '--') ?? process.env.TEST_DATABASE_URL ?? process.env.TEST_DATABASE_NAME; if (!arg) { throw new Error('Provide a test DB name or URL: pnpm.cmd --filter api testdb:drop -- hrm_medpark_test_YYYYMMDD_HHMMSS'); } if (/^postgres(?:ql)?:\/\//.test(arg)) { return { dbName: databaseNameFromUrl(arg), baseUrl: arg }; } return { dbName: arg, baseUrl }; } async function drop() { loadLocalEnv(); const baseUrl = process.env.DATABASE_URL; if (!baseUrl) throw new Error('DATABASE_URL is required for admin connection'); const target = targetDatabaseFromArgs(baseUrl); assertTestDatabaseName(target.dbName); console.log(`Dropping temporary database ${target.dbName}...`); await dropDatabase(target.baseUrl, target.dbName); console.log(`Dropped ${target.dbName}.`); } async function main() { const command = process.argv[2] ?? 'run'; if (command === 'run') { await run(); return; } if (command === 'drop') { await drop(); return; } throw new Error(`Unknown command "${command}". Use "run" or "drop".`); } main().catch((error: unknown) => { console.error(error); process.exitCode = 1; });