import { execFileSync } from 'node:child_process' import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' const here = path.dirname(fileURLToPath(import.meta.url)) const repoRoot = path.resolve(here, '../../../../../../') const composeFile = path.join(repoRoot, 'infrastructure/prod/docker-compose.yml') const envFile = path.join(repoRoot, 'infrastructure/e2e/.env.e2e') const prodEnvFile = path.join(repoRoot, 'infrastructure/prod/.env.prod') const postgresContainer = 'knowfoolery-postgres' const redisContainer = 'knowfoolery-redis' const apiBaseURL = process.env.FULLSTACK_API_BASE_URL ?? 'http://127.0.0.1:18096' function run(command: string, args: string[]): string { return execFileSync(command, args, { cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8', }).trim() } async function waitForURL(url: string, timeoutMs: number): Promise { const start = Date.now() while (Date.now() - start < timeoutMs) { try { const res = await fetch(url) if (res.ok) return } catch { // keep waiting } await new Promise((resolve) => setTimeout(resolve, 1000)) } throw new Error(`Timed out waiting for ${url}`) } export function sqlLiteral(value: string): string { return `'${value.replaceAll("'", "''")}'` } export function queryScalar(dbName: string, sql: string): string { return run('docker', [ 'exec', postgresContainer, 'psql', '-U', 'knowfoolery', '-d', dbName, '-t', '-A', '-c', sql, ]) } export function queryRows(dbName: string, sql: string): string { return run('docker', [ 'exec', postgresContainer, 'psql', '-U', 'knowfoolery', '-d', dbName, '-A', '-F', '|', '-c', sql, ]) } export function resetDatabases(): void { queryScalar('questions', 'TRUNCATE TABLE questions RESTART IDENTITY CASCADE;') queryScalar( 'leaderboards', 'TRUNCATE TABLE leaderboard_entries, leaderboard_player_stats RESTART IDENTITY CASCADE;' ) run('docker', ['exec', redisContainer, 'redis-cli', 'FLUSHALL']) } export function seedQuestions(): void { queryScalar( 'questions', ` INSERT INTO questions (id, theme, text, answer, hint, difficulty, is_active, created_at, updated_at) VALUES ('00000000-0000-0000-0000-000000000001', 'Général', 'Quel est le plus petit nombre premier ?', '2', 'Le seul nombre premier pair.', 'easy', true, NOW(), NOW()), ('00000000-0000-0000-0000-000000000002', 'Astronomie', 'Quelle planète est surnommée la planète rouge ?', 'Mars', '4e planète du système solaire.', 'easy', true, NOW(), NOW()) ON CONFLICT (id) DO NOTHING; ` ) } export function countActiveQuestions(): number { return Number.parseInt( queryScalar('questions', 'SELECT COUNT(*) FROM questions WHERE is_active = true;'), 10 ) } export function countLeaderboardEntries(): number { return Number.parseInt( queryScalar('leaderboards', 'SELECT COUNT(*) FROM leaderboard_entries;'), 10 ) } export function countLeaderboardEntriesByPlayer(playerID: string): number { return Number.parseInt( queryScalar( 'leaderboards', `SELECT COUNT(*) FROM leaderboard_entries WHERE player_id = ${sqlLiteral(playerID)};` ), 10 ) } export function questionIsActive(questionID: string): boolean { return ( queryScalar( 'questions', `SELECT is_active::text FROM questions WHERE id = ${sqlLiteral(questionID)};` ) === 'true' ) } export async function bringUpFullStack(): Promise { fs.copyFileSync(envFile, prodEnvFile) run('docker', [ 'compose', '-f', composeFile, '--env-file', envFile, 'up', '-d', 'postgres', 'redis', 'question-bank-service', 'leaderboard-service', 'user-service', 'admin-service', 'gateway-service', 'nginx', ]) await waitForURL(`${apiBaseURL}/nginx/health`, 180_000) await waitForURL(`${apiBaseURL}/health`, 180_000) await waitForURL(`${apiBaseURL}/api/v1/leaderboard/top10`, 180_000) resetDatabases() seedQuestions() } export function tearDownFullStack(): void { run('docker', ['compose', '-f', composeFile, '--env-file', envFile, 'down', '-v']) }