Files
lunarfront-app/packages/backend/api-tests/run.ts
Ryan Moon 5dbe837c08 Add lessons domain Phase 1: instructor and lesson type entities
Foundation tables for the lessons module with full CRUD, pagination,
search, and sorting. Includes migration, Drizzle schema, Zod validation,
services, routes, and 23 integration tests.
2026-03-30 09:17:32 -05:00

252 lines
9.4 KiB
TypeScript

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: true },
{ 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<Subprocess> {
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<string> {
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()