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}`))
|
||||
}
|
||||
196
packages/backend/api-tests/run.ts
Normal file
196
packages/backend/api-tests/run.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
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
|
||||
await testSql`INSERT INTO company (id, name, timezone) VALUES (${COMPANY_ID}, 'Test Music Co.', 'America/Chicago')`
|
||||
await testSql`INSERT INTO location (id, company_id, name) VALUES (${LOCATION_ID}, ${COMPANY_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 (company_id, name, slug, description, is_system, sort_order) VALUES (${COMPANY_ID}, ${s.name}, ${s.slug}, ${s.description}, true, ${s.sortOrder})`
|
||||
}
|
||||
for (const c of SYSTEM_ITEM_CONDITIONS) {
|
||||
await testSql`INSERT INTO item_condition (company_id, name, slug, description, is_system, sort_order) VALUES (${COMPANY_ID}, ${c.name}, ${c.slug}, ${c.description}, true, ${c.sortOrder})`
|
||||
}
|
||||
|
||||
await testSql.end()
|
||||
console.log(' Database ready')
|
||||
}
|
||||
|
||||
// --- Start backend ---
|
||||
async function startBackend(): Promise<Subprocess> {
|
||||
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',
|
||||
},
|
||||
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> {
|
||||
// Register needs x-company-id header
|
||||
const headers = { 'Content-Type': 'application/json', 'x-company-id': COMPANY_ID }
|
||||
const registerRes = await fetch(`${BASE_URL}/v1/auth/register`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
email: 'test@forte.dev',
|
||||
password: 'testpassword123',
|
||||
firstName: 'Test',
|
||||
lastName: 'Runner',
|
||||
role: 'admin',
|
||||
}),
|
||||
})
|
||||
const registerData = await registerRes.json() as { token?: string }
|
||||
if (registerRes.status === 201 && registerData.token) return registerData.token
|
||||
|
||||
// Already exists — login
|
||||
const loginRes = await fetch(`${BASE_URL}/v1/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: 'test@forte.dev', password: 'testpassword123' }),
|
||||
})
|
||||
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()
|
||||
103
packages/backend/api-tests/suites/accounts.ts
Normal file
103
packages/backend/api-tests/suites/accounts.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { suite } from '../lib/context.js'
|
||||
|
||||
suite('Accounts', { tags: ['accounts', 'crud'] }, (t) => {
|
||||
t.test('creates an account', { tags: ['create'] }, async () => {
|
||||
const res = await t.api.post('/v1/accounts', { name: 'Test Account' })
|
||||
t.assert.status(res, 201)
|
||||
t.assert.equal(res.data.name, 'Test Account')
|
||||
t.assert.ok(res.data.id)
|
||||
t.assert.ok(res.data.accountNumber, 'should auto-generate account number')
|
||||
t.assert.equal(res.data.accountNumber.length, 6)
|
||||
})
|
||||
|
||||
t.test('creates account with address and normalizes state', { tags: ['create'] }, async () => {
|
||||
const res = await t.api.post('/v1/accounts', {
|
||||
name: 'Texas Family',
|
||||
address: { street: '123 Main', city: 'Austin', state: 'texas', zip: '78701' },
|
||||
})
|
||||
t.assert.status(res, 201)
|
||||
t.assert.equal(res.data.address.state, 'TX')
|
||||
})
|
||||
|
||||
t.test('lists accounts with pagination', { tags: ['read'] }, async () => {
|
||||
await t.api.post('/v1/accounts', { name: 'List Test A' })
|
||||
await t.api.post('/v1/accounts', { name: 'List Test B' })
|
||||
|
||||
const res = await t.api.get('/v1/accounts', { limit: 100 })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.ok(res.data.data.length >= 2)
|
||||
t.assert.ok(res.data.pagination.total >= 2)
|
||||
})
|
||||
|
||||
t.test('searches accounts by name', { tags: ['search'] }, async () => {
|
||||
await t.api.post('/v1/accounts', { name: 'Searchable Unicorn' })
|
||||
|
||||
const res = await t.api.get('/v1/accounts', { q: 'Unicorn' })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.ok(res.data.data.some((a: { name: string }) => a.name === 'Searchable Unicorn'))
|
||||
})
|
||||
|
||||
t.test('searches accounts by member name', { tags: ['search'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Member Search Family' })
|
||||
await t.api.post(`/v1/accounts/${acct.data.id}/members`, {
|
||||
firstName: 'FindableKid',
|
||||
lastName: 'Smith',
|
||||
})
|
||||
|
||||
const res = await t.api.get('/v1/accounts', { q: 'FindableKid' })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.ok(res.data.data.some((a: { id: string }) => a.id === acct.data.id))
|
||||
})
|
||||
|
||||
t.test('gets account by id', { tags: ['read'] }, async () => {
|
||||
const created = await t.api.post('/v1/accounts', { name: 'Get By ID' })
|
||||
const res = await t.api.get(`/v1/accounts/${created.data.id}`)
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.name, 'Get By ID')
|
||||
})
|
||||
|
||||
t.test('returns 404 for missing account', { tags: ['read'] }, async () => {
|
||||
const res = await t.api.get('/v1/accounts/a0000000-0000-0000-0000-999999999999')
|
||||
t.assert.status(res, 404)
|
||||
})
|
||||
|
||||
t.test('updates an account', { tags: ['update'] }, async () => {
|
||||
const created = await t.api.post('/v1/accounts', { name: 'Before Update' })
|
||||
const res = await t.api.patch(`/v1/accounts/${created.data.id}`, { name: 'After Update' })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.name, 'After Update')
|
||||
})
|
||||
|
||||
t.test('soft-deletes an account', { tags: ['delete'] }, async () => {
|
||||
const created = await t.api.post('/v1/accounts', { name: 'To Delete' })
|
||||
const res = await t.api.del(`/v1/accounts/${created.data.id}`)
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.isActive, false)
|
||||
})
|
||||
|
||||
t.test('sets primary member on first member create', { tags: ['create'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Primary Test' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, {
|
||||
firstName: 'First',
|
||||
lastName: 'Member',
|
||||
})
|
||||
|
||||
const refreshed = await t.api.get(`/v1/accounts/${acct.data.id}`)
|
||||
t.assert.equal(refreshed.data.primaryMemberId, member.data.id)
|
||||
})
|
||||
|
||||
t.test('does not overwrite primary on second member', { tags: ['create'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Primary Keep' })
|
||||
const first = await t.api.post(`/v1/accounts/${acct.data.id}/members`, {
|
||||
firstName: 'First',
|
||||
lastName: 'One',
|
||||
})
|
||||
await t.api.post(`/v1/accounts/${acct.data.id}/members`, {
|
||||
firstName: 'Second',
|
||||
lastName: 'One',
|
||||
})
|
||||
|
||||
const refreshed = await t.api.get(`/v1/accounts/${acct.data.id}`)
|
||||
t.assert.equal(refreshed.data.primaryMemberId, first.data.id)
|
||||
})
|
||||
})
|
||||
166
packages/backend/api-tests/suites/members.ts
Normal file
166
packages/backend/api-tests/suites/members.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { suite } from '../lib/context.js'
|
||||
|
||||
suite('Members', { tags: ['members', 'crud'] }, (t) => {
|
||||
t.test('creates a member with auto-generated number', { tags: ['create'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Member Create Test' })
|
||||
const res = await t.api.post(`/v1/accounts/${acct.data.id}/members`, {
|
||||
firstName: 'Alice',
|
||||
lastName: 'Test',
|
||||
})
|
||||
t.assert.status(res, 201)
|
||||
t.assert.equal(res.data.firstName, 'Alice')
|
||||
t.assert.ok(res.data.memberNumber, 'should auto-generate member number')
|
||||
t.assert.equal(res.data.memberNumber.length, 6)
|
||||
})
|
||||
|
||||
t.test('inherits email from account when not provided', { tags: ['inheritance'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', {
|
||||
name: 'Inherit Email',
|
||||
email: 'family@test.com',
|
||||
phone: '555-1234',
|
||||
})
|
||||
const res = await t.api.post(`/v1/accounts/${acct.data.id}/members`, {
|
||||
firstName: 'Child',
|
||||
lastName: 'Inherit',
|
||||
})
|
||||
t.assert.status(res, 201)
|
||||
t.assert.equal(res.data.email, 'family@test.com')
|
||||
t.assert.equal(res.data.phone, '555-1234')
|
||||
})
|
||||
|
||||
t.test('uses member email when explicitly provided', { tags: ['inheritance'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', {
|
||||
name: 'Override Email',
|
||||
email: 'family@test.com',
|
||||
})
|
||||
const res = await t.api.post(`/v1/accounts/${acct.data.id}/members`, {
|
||||
firstName: 'Adult',
|
||||
lastName: 'Own',
|
||||
email: 'own@test.com',
|
||||
})
|
||||
t.assert.status(res, 201)
|
||||
t.assert.equal(res.data.email, 'own@test.com')
|
||||
})
|
||||
|
||||
t.test('inherits address from account when not provided', { tags: ['inheritance'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', {
|
||||
name: 'Inherit Address',
|
||||
address: { street: '123 Main St', city: 'Austin', state: 'TX', zip: '78701' },
|
||||
})
|
||||
const res = await t.api.post(`/v1/accounts/${acct.data.id}/members`, {
|
||||
firstName: 'Kid',
|
||||
lastName: 'Home',
|
||||
})
|
||||
t.assert.status(res, 201)
|
||||
t.assert.equal(res.data.address.city, 'Austin')
|
||||
t.assert.equal(res.data.address.state, 'TX')
|
||||
})
|
||||
|
||||
t.test('accepts explicit isMinor flag without DOB', { tags: ['create'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Minor Test' })
|
||||
const res = await t.api.post(`/v1/accounts/${acct.data.id}/members`, {
|
||||
firstName: 'Kid',
|
||||
lastName: 'NoDate',
|
||||
isMinor: true,
|
||||
})
|
||||
t.assert.status(res, 201)
|
||||
t.assert.equal(res.data.isMinor, true)
|
||||
})
|
||||
|
||||
t.test('creates member without email or DOB', { tags: ['create'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Minimal Member' })
|
||||
const res = await t.api.post(`/v1/accounts/${acct.data.id}/members`, {
|
||||
firstName: 'Just',
|
||||
lastName: 'Name',
|
||||
})
|
||||
t.assert.status(res, 201)
|
||||
t.assert.equal(res.data.firstName, 'Just')
|
||||
})
|
||||
|
||||
t.test('lists all members across accounts', { tags: ['read'] }, async () => {
|
||||
const acct1 = await t.api.post('/v1/accounts', { name: 'List A' })
|
||||
const acct2 = await t.api.post('/v1/accounts', { name: 'List B' })
|
||||
await t.api.post(`/v1/accounts/${acct1.data.id}/members`, { firstName: 'A1', lastName: 'One' })
|
||||
await t.api.post(`/v1/accounts/${acct2.data.id}/members`, { firstName: 'B1', lastName: 'Two' })
|
||||
|
||||
const res = await t.api.get('/v1/members', { limit: 100 })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.greaterThan(res.data.data.length, 1)
|
||||
t.assert.ok(res.data.data[0].accountName, 'should include account name')
|
||||
})
|
||||
|
||||
t.test('searches members by name', { tags: ['search'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Search Member Acct' })
|
||||
await t.api.post(`/v1/accounts/${acct.data.id}/members`, {
|
||||
firstName: 'UniqueSearchName',
|
||||
lastName: 'Person',
|
||||
})
|
||||
|
||||
const res = await t.api.get('/v1/members', { q: 'UniqueSearchName' })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.ok(res.data.data.some((m: { firstName: string }) => m.firstName === 'UniqueSearchName'))
|
||||
})
|
||||
|
||||
t.test('moves member to existing account', { tags: ['move'] }, async () => {
|
||||
const acct1 = await t.api.post('/v1/accounts', { name: 'Source Acct' })
|
||||
const acct2 = await t.api.post('/v1/accounts', { name: 'Target Acct' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct1.data.id}/members`, {
|
||||
firstName: 'Mover',
|
||||
lastName: 'Person',
|
||||
})
|
||||
|
||||
const res = await t.api.post(`/v1/members/${member.data.id}/move`, {
|
||||
accountId: acct2.data.id,
|
||||
})
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.accountId, acct2.data.id)
|
||||
})
|
||||
|
||||
t.test('moves member to new account when no accountId given', { tags: ['move'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Move Source' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, {
|
||||
firstName: 'Solo',
|
||||
lastName: 'Flyer',
|
||||
})
|
||||
|
||||
const res = await t.api.post(`/v1/members/${member.data.id}/move`, {})
|
||||
t.assert.status(res, 200)
|
||||
t.assert.notEqual(res.data.accountId, acct.data.id)
|
||||
})
|
||||
|
||||
t.test('normalizes state on member address via account inheritance', { tags: ['inheritance'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', {
|
||||
name: 'State Inherit',
|
||||
address: { state: 'California' },
|
||||
})
|
||||
const res = await t.api.post(`/v1/accounts/${acct.data.id}/members`, {
|
||||
firstName: 'Cal',
|
||||
lastName: 'Kid',
|
||||
})
|
||||
t.assert.status(res, 201)
|
||||
t.assert.equal(res.data.address.state, 'CA')
|
||||
})
|
||||
|
||||
t.test('updates a member', { tags: ['update'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Update Test' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, {
|
||||
firstName: 'Before',
|
||||
lastName: 'Update',
|
||||
})
|
||||
|
||||
const res = await t.api.patch(`/v1/members/${member.data.id}`, { firstName: 'After' })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.firstName, 'After')
|
||||
})
|
||||
|
||||
t.test('deletes a member', { tags: ['delete'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Delete Test' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, {
|
||||
firstName: 'Gone',
|
||||
lastName: 'Soon',
|
||||
})
|
||||
|
||||
const res = await t.api.del(`/v1/members/${member.data.id}`)
|
||||
t.assert.status(res, 200)
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@
|
||||
"start": "bun run src/main.ts",
|
||||
"test": "bun test",
|
||||
"test:watch": "bun test --watch",
|
||||
"api-test": "bun run api-tests/run.ts",
|
||||
"lint": "eslint src/",
|
||||
"db:generate": "bunx drizzle-kit generate",
|
||||
"db:migrate": "bunx drizzle-kit migrate",
|
||||
|
||||
Reference in New Issue
Block a user