Add standalone API test runner with accounts and members suites

Custom test framework that starts the backend, creates a test DB, runs
migrations, and hits real HTTP endpoints. Supports --suite and --tag
filtering. 24 tests covering account CRUD, member inheritance, state
normalization, move, search, and auto-generated numbers. Run with
bun run api-test.
This commit is contained in:
Ryan Moon
2026-03-28 12:52:04 -05:00
parent b9e984cfa3
commit f47bfdbcdb
7 changed files with 733 additions and 0 deletions

View File

@@ -0,0 +1,46 @@
export interface ApiResponse<T = unknown> {
status: number
data: T
ok: boolean
}
export interface ApiClient {
get<T = unknown>(path: string, params?: Record<string, unknown>): Promise<ApiResponse<T>>
post<T = unknown>(path: string, body?: unknown): Promise<ApiResponse<T>>
patch<T = unknown>(path: string, body?: unknown): Promise<ApiResponse<T>>
del<T = unknown>(path: string): Promise<ApiResponse<T>>
}
function buildQueryString(params?: Record<string, unknown>): string {
if (!params) return ''
const sp = new URLSearchParams()
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== null && v !== '') sp.set(k, String(v))
}
const qs = sp.toString()
return qs ? `?${qs}` : ''
}
export function createClient(baseUrl: string, token?: string): ApiClient {
async function request<T>(method: string, path: string, body?: unknown): Promise<ApiResponse<T>> {
const headers: Record<string, string> = {}
if (body !== undefined) headers['Content-Type'] = 'application/json'
if (token) headers['Authorization'] = `Bearer ${token}`
const res = await fetch(`${baseUrl}${path}`, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
})
const data = await res.json() as T
return { status: res.status, data, ok: res.ok }
}
return {
get: <T>(path: string, params?: Record<string, unknown>) => request<T>('GET', `${path}${buildQueryString(params)}`),
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
del: <T>(path: string) => request<T>('DELETE', path),
}
}

View File

@@ -0,0 +1,158 @@
import { createClient, type ApiClient, type ApiResponse } from './client.js'
import { type TestResult, printSuiteHeader, printTestResult } from './reporter.js'
export interface TestContext {
api: ApiClient
test: (name: string, optsOrFn: { tags?: string[] } | (() => Promise<void>), maybeFn?: () => Promise<void>) => void
assert: {
status: (res: ApiResponse, expected: number) => void
equal: <T>(actual: T, expected: T, msg?: string) => void
notEqual: <T>(actual: T, expected: T, msg?: string) => void
ok: (value: unknown, msg?: string) => void
truthy: (value: unknown, msg?: string) => void
falsy: (value: unknown, msg?: string) => void
includes: (arr: unknown[], value: unknown, msg?: string) => void
contains: (str: string, sub: string, msg?: string) => void
greaterThan: (actual: number, expected: number, msg?: string) => void
throws: (fn: () => unknown, msg?: string) => void
}
}
class AssertionError extends Error {
constructor(message: string) {
super(message)
this.name = 'AssertionError'
}
}
function makeAssert() {
return {
status(res: ApiResponse, expected: number) {
if (res.status !== expected) {
const body = typeof res.data === 'object' ? JSON.stringify(res.data) : String(res.data)
throw new AssertionError(`Expected status ${expected}, got ${res.status}: ${body.slice(0, 200)}`)
}
},
equal<T>(actual: T, expected: T, msg?: string) {
if (actual !== expected) {
throw new AssertionError(msg ?? `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`)
}
},
notEqual<T>(actual: T, expected: T, msg?: string) {
if (actual === expected) {
throw new AssertionError(msg ?? `Expected value to not equal ${JSON.stringify(expected)}`)
}
},
ok(value: unknown, msg?: string) {
if (!value) throw new AssertionError(msg ?? `Expected truthy, got ${JSON.stringify(value)}`)
},
truthy(value: unknown, msg?: string) {
if (!value) throw new AssertionError(msg ?? `Expected truthy, got ${JSON.stringify(value)}`)
},
falsy(value: unknown, msg?: string) {
if (value) throw new AssertionError(msg ?? `Expected falsy, got ${JSON.stringify(value)}`)
},
includes(arr: unknown[], value: unknown, msg?: string) {
if (!arr.includes(value)) {
throw new AssertionError(msg ?? `Expected array to include ${JSON.stringify(value)}`)
}
},
contains(str: string, sub: string, msg?: string) {
if (!str.includes(sub)) {
throw new AssertionError(msg ?? `Expected "${str}" to contain "${sub}"`)
}
},
greaterThan(actual: number, expected: number, msg?: string) {
if (actual <= expected) {
throw new AssertionError(msg ?? `Expected ${actual} > ${expected}`)
}
},
throws(fn: () => unknown, msg?: string) {
try {
fn()
throw new AssertionError(msg ?? 'Expected function to throw')
} catch (e) {
if (e instanceof AssertionError) throw e
}
},
}
}
interface RegisteredTest {
name: string
tags: string[]
fn: () => Promise<void>
}
export interface SuiteDefinition {
name: string
tags: string[]
setup: (ctx: TestContext) => void | Promise<void>
}
const registeredSuites: SuiteDefinition[] = []
export function suite(
name: string,
optsOrFn: { tags?: string[] } | ((ctx: TestContext) => void | Promise<void>),
maybeFn?: (ctx: TestContext) => void | Promise<void>,
) {
const opts = typeof optsOrFn === 'function' ? {} : optsOrFn
const fn = typeof optsOrFn === 'function' ? optsOrFn : maybeFn!
registeredSuites.push({ name, tags: opts.tags ?? [], setup: fn })
}
export function getSuites(): SuiteDefinition[] {
return registeredSuites
}
export async function runSuite(
suiteDef: SuiteDefinition,
baseUrl: string,
token: string,
filterTags?: string[],
): Promise<TestResult[]> {
const tests: RegisteredTest[] = []
const api = createClient(baseUrl, token)
const ctx: TestContext = {
api,
assert: makeAssert(),
test(name, optsOrFn, maybeFn) {
const opts = typeof optsOrFn === 'function' ? {} : optsOrFn
const fn = typeof optsOrFn === 'function' ? optsOrFn : maybeFn!
tests.push({ name, tags: (opts as { tags?: string[] }).tags ?? [], fn })
},
}
await suiteDef.setup(ctx)
printSuiteHeader(suiteDef.name)
const results: TestResult[] = []
for (const test of tests) {
// Tag filtering
if (filterTags && filterTags.length > 0) {
const allTags = [...suiteDef.tags, ...test.tags]
if (!filterTags.some((t) => allTags.includes(t))) continue
}
const start = performance.now()
try {
await test.fn()
const duration = Math.round(performance.now() - start)
const result: TestResult = { name: test.name, suite: suiteDef.name, tags: test.tags, passed: true, duration }
results.push(result)
printTestResult(result)
} catch (err) {
const duration = Math.round(performance.now() - start)
const error = err instanceof Error ? err.message : String(err)
const result: TestResult = { name: test.name, suite: suiteDef.name, tags: test.tags, passed: false, duration, error }
results.push(result)
printTestResult(result)
}
}
console.log('')
return results
}

View File

@@ -0,0 +1,63 @@
const green = (s: string) => `\x1b[32m${s}\x1b[0m`
const red = (s: string) => `\x1b[31m${s}\x1b[0m`
const gray = (s: string) => `\x1b[90m${s}\x1b[0m`
const bold = (s: string) => `\x1b[1m${s}\x1b[0m`
const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`
export interface TestResult {
name: string
suite: string
tags: string[]
passed: boolean
duration: number
error?: string
}
export function printHeader(baseUrl: string) {
console.log('')
console.log(bold(`API Tests — ${baseUrl}`))
console.log('')
}
export function printSuiteHeader(name: string) {
console.log(` ${bold(name)}`)
}
export function printTestResult(result: TestResult) {
const icon = result.passed ? green('✓') : red('✗')
const duration = gray(`(${result.duration}ms)`)
console.log(` ${icon} ${result.name} ${duration}`)
if (result.error) {
console.log(` ${red(result.error)}`)
}
}
export function printSummary(results: TestResult[], totalDuration: number) {
const passed = results.filter((r) => r.passed).length
const failed = results.filter((r) => !r.passed).length
const total = results.length
console.log('')
console.log('─'.repeat(50))
if (failed === 0) {
console.log(green(` ${passed} passed`) + gray(` (${(totalDuration / 1000).toFixed(1)}s)`))
} else {
console.log(
` ${green(`${passed} passed`)}, ${red(`${failed} failed`)}` +
gray(` of ${total} (${(totalDuration / 1000).toFixed(1)}s)`),
)
console.log('')
console.log(red(' Failed tests:'))
for (const r of results.filter((r) => !r.passed)) {
console.log(red(`${r.suite} > ${r.name}`))
if (r.error) console.log(` ${r.error}`)
}
}
console.log('')
}
export function printSkipped(reason: string) {
console.log(yellow(` ⊘ Skipped: ${reason}`))
}