Drop company_id column from all 22 domain tables via migration. Remove companyId from JWT payload, auth plugins, all service method signatures (~215 occurrences), all route handlers (~105 occurrences), test runner, test suites, and frontend auth store/types. The company table stays as store settings (name, timezone). Tenant isolation in a SaaS deployment would be at the database level (one DB per customer) not the application level. All 107 API tests pass. Zero TSC errors across all packages.
236 lines
8.1 KiB
TypeScript
236 lines
8.1 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 (company table stays as store settings)
|
|
await testSql`INSERT INTO company (id, name, timezone) VALUES (${COMPANY_ID}, 'Test Music Co.', '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})`
|
|
}
|
|
}
|
|
}
|
|
|
|
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> {
|
|
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@forte.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@forte.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()
|