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:
46
packages/backend/api-tests/lib/client.ts
Normal file
46
packages/backend/api-tests/lib/client.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
158
packages/backend/api-tests/lib/context.ts
Normal file
158
packages/backend/api-tests/lib/context.ts
Normal 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
|
||||
}
|
||||
63
packages/backend/api-tests/lib/reporter.ts
Normal file
63
packages/backend/api-tests/lib/reporter.ts
Normal 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}`))
|
||||
}
|
||||
Reference in New Issue
Block a user