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:
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user