Files
Ryan Moon 760e995ae3 Implement file storage layer with local provider, upload/download API, tests
- 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
2026-03-28 15:29:06 -05:00

163 lines
5.3 KiB
TypeScript

import { createClient, type ApiClient, type ApiResponse } from './client.js'
import { type TestResult, printSuiteHeader, printTestResult } from './reporter.js'
export interface TestContext {
api: ApiClient
token: string
baseUrl: string
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,
token,
baseUrl,
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
}