From f47bfdbcdb9044c0584f387c22013e75811002d0 Mon Sep 17 00:00:00 2001 From: Ryan Moon Date: Sat, 28 Mar 2026 12:52:04 -0500 Subject: [PATCH] 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. --- packages/backend/api-tests/lib/client.ts | 46 ++++ packages/backend/api-tests/lib/context.ts | 158 ++++++++++++++ packages/backend/api-tests/lib/reporter.ts | 63 ++++++ packages/backend/api-tests/run.ts | 196 ++++++++++++++++++ packages/backend/api-tests/suites/accounts.ts | 103 +++++++++ packages/backend/api-tests/suites/members.ts | 166 +++++++++++++++ packages/backend/package.json | 1 + 7 files changed, 733 insertions(+) create mode 100644 packages/backend/api-tests/lib/client.ts create mode 100644 packages/backend/api-tests/lib/context.ts create mode 100644 packages/backend/api-tests/lib/reporter.ts create mode 100644 packages/backend/api-tests/run.ts create mode 100644 packages/backend/api-tests/suites/accounts.ts create mode 100644 packages/backend/api-tests/suites/members.ts diff --git a/packages/backend/api-tests/lib/client.ts b/packages/backend/api-tests/lib/client.ts new file mode 100644 index 0000000..243b21d --- /dev/null +++ b/packages/backend/api-tests/lib/client.ts @@ -0,0 +1,46 @@ +export interface ApiResponse { + status: number + data: T + ok: boolean +} + +export interface ApiClient { + get(path: string, params?: Record): Promise> + post(path: string, body?: unknown): Promise> + patch(path: string, body?: unknown): Promise> + del(path: string): Promise> +} + +function buildQueryString(params?: Record): 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(method: string, path: string, body?: unknown): Promise> { + const headers: Record = {} + 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: (path: string, params?: Record) => request('GET', `${path}${buildQueryString(params)}`), + post: (path: string, body?: unknown) => request('POST', path, body), + patch: (path: string, body?: unknown) => request('PATCH', path, body), + del: (path: string) => request('DELETE', path), + } +} diff --git a/packages/backend/api-tests/lib/context.ts b/packages/backend/api-tests/lib/context.ts new file mode 100644 index 0000000..93e8392 --- /dev/null +++ b/packages/backend/api-tests/lib/context.ts @@ -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), maybeFn?: () => Promise) => void + assert: { + status: (res: ApiResponse, expected: number) => void + equal: (actual: T, expected: T, msg?: string) => void + notEqual: (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(actual: T, expected: T, msg?: string) { + if (actual !== expected) { + throw new AssertionError(msg ?? `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`) + } + }, + notEqual(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 +} + +export interface SuiteDefinition { + name: string + tags: string[] + setup: (ctx: TestContext) => void | Promise +} + +const registeredSuites: SuiteDefinition[] = [] + +export function suite( + name: string, + optsOrFn: { tags?: string[] } | ((ctx: TestContext) => void | Promise), + maybeFn?: (ctx: TestContext) => void | Promise, +) { + 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 { + 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 +} diff --git a/packages/backend/api-tests/lib/reporter.ts b/packages/backend/api-tests/lib/reporter.ts new file mode 100644 index 0000000..97220c1 --- /dev/null +++ b/packages/backend/api-tests/lib/reporter.ts @@ -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}`)) +} diff --git a/packages/backend/api-tests/run.ts b/packages/backend/api-tests/run.ts new file mode 100644 index 0000000..81f7e8f --- /dev/null +++ b/packages/backend/api-tests/run.ts @@ -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 { + 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 { + // 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() diff --git a/packages/backend/api-tests/suites/accounts.ts b/packages/backend/api-tests/suites/accounts.ts new file mode 100644 index 0000000..2b19cc7 --- /dev/null +++ b/packages/backend/api-tests/suites/accounts.ts @@ -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) + }) +}) diff --git a/packages/backend/api-tests/suites/members.ts b/packages/backend/api-tests/suites/members.ts new file mode 100644 index 0000000..0b3e540 --- /dev/null +++ b/packages/backend/api-tests/suites/members.ts @@ -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) + }) +}) diff --git a/packages/backend/package.json b/packages/backend/package.json index 1b40c9b..5a5f61f 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -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",