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.
159 lines
5.2 KiB
TypeScript
159 lines
5.2 KiB
TypeScript
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
|
|
}
|