33800292aa
- apps/api/Dockerfile: build NestJS, run prisma migrate deploy on start - apps/web/Dockerfile + nginx.conf: build Vite, serve static, proxy /api -> api - docker-compose.coolify.yml: full prod stack (postgres, redis, minio, keycloak, api, web) - .dockerignore / .gitignore / .gitattributes Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
297 lines
9.6 KiB
TypeScript
297 lines
9.6 KiB
TypeScript
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<string, string> = {};
|
|
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<void>((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<number>((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<void>((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<void>((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<ReturnType<typeof startTemporaryApi>> | 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;
|
|
});
|