- StorageProvider interface with LocalProvider (S3 placeholder) - File table with entity_type/entity_id references, content type, path - POST /v1/files (multipart upload), GET /v1/files (list by entity), GET /v1/files/:id (metadata), GET /v1/files/serve/* (content), DELETE /v1/files/:id - member_identifier drops base64 columns, uses file_id FKs - File validation: type whitelist, size limits, per-entity max - Fastify storage plugin injects provider into app - 6 API tests for upload, list, get, delete, validation - Test runner kills stale port before starting backend
207 lines
6.8 KiB
TypeScript
207 lines
6.8 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 ?? 'forte'
|
|
const DB_PASS = process.env.DB_PASS ?? 'forte'
|
|
const TEST_DB = 'forte_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
|
|
await testSql`INSERT INTO company (id, name, timezone) VALUES (${COMPANY_ID}, 'Test Music Co.', 'America/Chicago')`
|
|
await testSql`INSERT INTO location (id, company_id, name) VALUES (${LOCATION_ID}, ${COMPANY_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 (company_id, name, slug, description, is_system, sort_order) VALUES (${COMPANY_ID}, ${s.name}, ${s.slug}, ${s.description}, true, ${s.sortOrder})`
|
|
}
|
|
for (const c of SYSTEM_ITEM_CONDITIONS) {
|
|
await testSql`INSERT INTO item_condition (company_id, name, slug, description, is_system, sort_order) VALUES (${COMPANY_ID}, ${c.name}, ${c.slug}, ${c.description}, true, ${c.sortOrder})`
|
|
}
|
|
|
|
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/forte-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> {
|
|
// Register needs x-company-id header
|
|
const headers = { 'Content-Type': 'application/json', 'x-company-id': COMPANY_ID }
|
|
const registerRes = await fetch(`${BASE_URL}/v1/auth/register`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify({
|
|
email: 'test@forte.dev',
|
|
password: 'testpassword123',
|
|
firstName: 'Test',
|
|
lastName: 'Runner',
|
|
role: 'admin',
|
|
}),
|
|
})
|
|
const registerData = await registerRes.json() as { token?: string }
|
|
if (registerRes.status === 201 && registerData.token) return registerData.token
|
|
|
|
// Already exists — login
|
|
const loginRes = await fetch(`${BASE_URL}/v1/auth/login`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email: 'test@forte.dev', password: 'testpassword123' }),
|
|
})
|
|
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()
|