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.
252 lines
9.4 KiB
TypeScript
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()
|