import { spawn, type Subprocess } from 'bun' import postgres from 'postgres' import { printHeader, printSummary, type TestResult } from './lib/reporter.js' import { getSuites, runSuite } from './lib/context.js' import { createClient } from './lib/client.js' // --- Config --- const DB_HOST = process.env.DB_HOST ?? 'localhost' const DB_PORT = Number(process.env.DB_PORT ?? '5432') const DB_USER = process.env.DB_USER ?? 'lunarfront' const DB_PASS = process.env.DB_PASS ?? 'lunarfront' const TEST_DB = 'lunarfront_api_test' const TEST_PORT = 8001 const BASE_URL = `http://localhost:${TEST_PORT}` const COMPANY_ID = 'a0000000-0000-0000-0000-000000000001' const LOCATION_ID = 'a0000000-0000-0000-0000-000000000002' // --- Parse CLI args --- const args = process.argv.slice(2) let filterSuite: string | undefined let filterTags: string[] | undefined for (let i = 0; i < args.length; i++) { if (args[i] === '--suite' && args[i + 1]) filterSuite = args[++i] if (args[i] === '--tag' && args[i + 1]) filterTags = args[++i].split(',') } // --- DB setup --- async function setupDatabase() { const adminSql = postgres(`postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/postgres`) const [exists] = await adminSql`SELECT 1 FROM pg_database WHERE datname = ${TEST_DB}` if (!exists) { await adminSql.unsafe(`CREATE DATABASE ${TEST_DB}`) console.log(` Created database ${TEST_DB}`) } await adminSql.end() // Run migrations const { execSync } = await import('child_process') execSync(`bunx drizzle-kit migrate`, { cwd: new URL('..', import.meta.url).pathname, env: { ...process.env, DATABASE_URL: `postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${TEST_DB}`, }, stdio: 'pipe', }) // Truncate all tables const testSql = postgres(`postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${TEST_DB}`) await testSql.unsafe(` DO $$ DECLARE r RECORD; BEGIN SET client_min_messages TO WARNING; FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP EXECUTE 'TRUNCATE TABLE ' || quote_ident(r.tablename) || ' CASCADE'; END LOOP; RESET client_min_messages; END $$ `) // Seed company + location (company table stays as store settings) await testSql`INSERT INTO company (id, name, timezone) VALUES (${COMPANY_ID}, 'Test Store', 'America/Chicago')` await testSql`INSERT INTO location (id, name) VALUES (${LOCATION_ID}, 'Test Location')` // Seed lookup tables const { SYSTEM_UNIT_STATUSES, SYSTEM_ITEM_CONDITIONS } = await import('../src/db/schema/lookups.js') for (const s of SYSTEM_UNIT_STATUSES) { await testSql`INSERT INTO inventory_unit_status (name, slug, description, is_system, sort_order) VALUES (${s.name}, ${s.slug}, ${s.description}, true, ${s.sortOrder})` } for (const c of SYSTEM_ITEM_CONDITIONS) { await testSql`INSERT INTO item_condition (name, slug, description, is_system, sort_order) VALUES (${c.name}, ${c.slug}, ${c.description}, true, ${c.sortOrder})` } // Seed RBAC permissions and default roles const { SYSTEM_PERMISSIONS, DEFAULT_ROLES } = await import('../src/db/seeds/rbac.js') for (const p of SYSTEM_PERMISSIONS) { await testSql`INSERT INTO permission (slug, domain, action, description) VALUES (${p.slug}, ${p.domain}, ${p.action}, ${p.description}) ON CONFLICT (slug) DO NOTHING` } const permRows = await testSql`SELECT id, slug FROM permission` const permMap = new Map(permRows.map((r: any) => [r.slug, r.id])) for (const roleDef of DEFAULT_ROLES) { const [role] = await testSql`INSERT INTO role (name, slug, description, is_system) VALUES (${roleDef.name}, ${roleDef.slug}, ${roleDef.description}, true) RETURNING id` for (const permSlug of roleDef.permissions) { const permId = permMap.get(permSlug) if (permId) { await testSql`INSERT INTO role_permission (role_id, permission_id) VALUES (${role.id}, ${permId})` } } } // Seed default modules const DEFAULT_MODULES = [ { slug: 'inventory', name: 'Inventory', description: 'Product catalog, stock tracking, and unit management', enabled: true }, { slug: 'pos', name: 'Point of Sale', description: 'Sales transactions, cash drawer, and receipts', enabled: true }, { slug: 'repairs', name: 'Repairs', description: 'Repair ticket management, batches, and service templates', enabled: true }, { slug: 'rentals', name: 'Rentals', description: 'Rental agreements and billing', enabled: false }, { slug: 'lessons', name: 'Lessons', description: 'Scheduling, staff management, and billing', enabled: false }, { slug: 'files', name: 'Files', description: 'Shared file storage with folder organization', enabled: true }, { slug: 'vault', name: 'Vault', description: 'Encrypted password and secret manager', enabled: true }, { slug: 'email', name: 'Email', description: 'Email campaigns, templates, and sending', enabled: false }, { slug: 'reports', name: 'Reports', description: 'Business reports and data export', enabled: true }, ] for (const m of DEFAULT_MODULES) { await testSql`INSERT INTO module_config (slug, name, description, licensed, enabled) VALUES (${m.slug}, ${m.name}, ${m.description}, true, ${m.enabled}) ON CONFLICT (slug) DO NOTHING` } await testSql.end() console.log(' Database ready') } // --- Start backend --- async function killPort(port: number) { try { const { execSync } = await import('child_process') execSync(`lsof -ti:${port} | xargs kill -9 2>/dev/null || true`, { stdio: 'pipe' }) await new Promise((r) => setTimeout(r, 1000)) } catch {} } async function startBackend(): Promise { await killPort(TEST_PORT) const proc = spawn({ cmd: ['bun', 'run', 'src/main.ts'], cwd: new URL('..', import.meta.url).pathname, env: { ...process.env, DATABASE_URL: `postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${TEST_DB}`, REDIS_URL: process.env.REDIS_URL ?? 'redis://localhost:6379', JWT_SECRET: 'test-secret-for-api-tests', PORT: String(TEST_PORT), HOST: '0.0.0.0', NODE_ENV: 'development', LOG_LEVEL: 'error', STORAGE_LOCAL_PATH: '/tmp/lunarfront-test-files', }, stdout: 'pipe', stderr: 'pipe', }) // Wait for health check for (let i = 0; i < 30; i++) { try { const res = await fetch(`${BASE_URL}/v1/health`) if (res.ok) { console.log(` Backend running on port ${TEST_PORT}`) return proc } } catch {} await new Promise((r) => setTimeout(r, 500)) } // Check if process died const exitCode = proc.exitCode if (exitCode !== null) { console.error(` Backend exited with code ${exitCode}`) } proc.kill() throw new Error('Backend failed to start within 15 seconds') } // --- Register test user --- async function registerTestUser(): Promise { const testPassword = 'testpassword1234' const headers = { 'Content-Type': 'application/json' } const registerRes = await fetch(`${BASE_URL}/v1/auth/register`, { method: 'POST', headers, body: JSON.stringify({ email: 'test@lunarfront.dev', password: testPassword, firstName: 'Test', lastName: 'Runner', role: 'admin', }), }) const registerData = await registerRes.json() as { token?: string; user?: { id: string } } // Assign admin role to the user via direct SQL if (registerRes.status === 201 && registerData.user) { const assignSql = postgres(`postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${TEST_DB}`) const [adminRole] = await assignSql`SELECT id FROM role WHERE slug = 'admin' LIMIT 1` if (adminRole) { await assignSql`INSERT INTO user_role_assignment (user_id, role_id) VALUES (${registerData.user.id}, ${adminRole.id}) ON CONFLICT DO NOTHING` } await assignSql.end() } // Login to get token with permissions loaded const loginRes = await fetch(`${BASE_URL}/v1/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'test@lunarfront.dev', password: testPassword }), }) const loginData = await loginRes.json() as { token?: string } if (loginRes.status !== 200 || !loginData.token) throw new Error(`Auth failed: ${JSON.stringify(loginData)}`) return loginData.token } // --- Import suites --- async function loadSuites() { const { readdirSync } = await import('fs') const suitesDir = new URL('./suites/', import.meta.url).pathname const files = readdirSync(suitesDir).filter((f: string) => f.endsWith('.ts')) for (const file of files) { await import(`./suites/${file}`) } } // --- Main --- async function main() { let backend: Subprocess | undefined try { console.log('\n Setting up...') await setupDatabase() backend = await startBackend() const token = await registerTestUser() console.log(' Authenticated\n') await loadSuites() const suites = getSuites().filter((s) => !filterSuite || s.name.toLowerCase().includes(filterSuite.toLowerCase())) printHeader(BASE_URL) const allResults: TestResult[] = [] const startTime = performance.now() for (const s of suites) { const results = await runSuite(s, BASE_URL, token, filterTags) allResults.push(...results) } const totalDuration = performance.now() - startTime printSummary(allResults, totalDuration) const failed = allResults.some((r) => !r.passed) process.exit(failed ? 1 : 0) } catch (err) { console.error('\n Fatal error:', err) process.exit(1) } finally { if (backend) backend.kill() } } main()