- Address field on member table (jsonb, same format as account) - Members inherit email, phone, address from account when not provided - State normalization: "Texas" → "TX", "california" → "CA" via shared util - Member form drops zodResolver to fix optional field validation flashing - Account name auto-format: "First Last - Account" - US state lookup with full name + code support
361 lines
11 KiB
TypeScript
361 lines
11 KiB
TypeScript
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'bun:test'
|
|
import type { FastifyInstance } from 'fastify'
|
|
import {
|
|
createTestApp,
|
|
cleanDb,
|
|
seedTestCompany,
|
|
registerAndLogin,
|
|
TEST_COMPANY_ID,
|
|
} from '../../../src/test/helpers.js'
|
|
|
|
describe('Top-level member routes', () => {
|
|
let app: FastifyInstance
|
|
let token: string
|
|
let accountId: string
|
|
|
|
beforeAll(async () => {
|
|
app = await createTestApp()
|
|
})
|
|
|
|
beforeEach(async () => {
|
|
await cleanDb(app)
|
|
await seedTestCompany(app)
|
|
const auth = await registerAndLogin(app, { email: `members-${Date.now()}@test.com` })
|
|
token = auth.token
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/v1/accounts',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { name: 'Test Family' },
|
|
})
|
|
accountId = res.json().id
|
|
})
|
|
|
|
afterAll(async () => {
|
|
await app.close()
|
|
})
|
|
|
|
describe('GET /v1/members', () => {
|
|
it('lists all members across accounts', async () => {
|
|
const account2Res = await app.inject({
|
|
method: 'POST',
|
|
url: '/v1/accounts',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { name: 'Other Family' },
|
|
})
|
|
const account2Id = account2Res.json().id
|
|
|
|
await app.inject({
|
|
method: 'POST',
|
|
url: `/v1/accounts/${accountId}/members`,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { firstName: 'Alice', lastName: 'Smith' },
|
|
})
|
|
await app.inject({
|
|
method: 'POST',
|
|
url: `/v1/accounts/${account2Id}/members`,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { firstName: 'Bob', lastName: 'Jones' },
|
|
})
|
|
|
|
const res = await app.inject({
|
|
method: 'GET',
|
|
url: '/v1/members',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
})
|
|
|
|
expect(res.statusCode).toBe(200)
|
|
const body = res.json()
|
|
expect(body.data.length).toBe(2)
|
|
expect(body.data[0].accountName).toBeTruthy()
|
|
})
|
|
|
|
it('searches members by name', async () => {
|
|
await app.inject({
|
|
method: 'POST',
|
|
url: `/v1/accounts/${accountId}/members`,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { firstName: 'Alice', lastName: 'Smith' },
|
|
})
|
|
await app.inject({
|
|
method: 'POST',
|
|
url: `/v1/accounts/${accountId}/members`,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { firstName: 'Bob', lastName: 'Jones' },
|
|
})
|
|
|
|
const res = await app.inject({
|
|
method: 'GET',
|
|
url: '/v1/members?q=alice',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
})
|
|
|
|
expect(res.statusCode).toBe(200)
|
|
expect(res.json().data.length).toBe(1)
|
|
expect(res.json().data[0].firstName).toBe('Alice')
|
|
})
|
|
})
|
|
|
|
describe('Auto-generated numbers', () => {
|
|
it('generates account number on create', async () => {
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/v1/accounts',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { name: 'Number Test' },
|
|
})
|
|
|
|
expect(res.statusCode).toBe(201)
|
|
const body = res.json()
|
|
expect(body.accountNumber).toBeTruthy()
|
|
expect(body.accountNumber.length).toBe(6)
|
|
})
|
|
|
|
it('generates member number on create', async () => {
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: `/v1/accounts/${accountId}/members`,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { firstName: 'Test', lastName: 'Member' },
|
|
})
|
|
|
|
expect(res.statusCode).toBe(201)
|
|
expect(res.json().memberNumber).toBeTruthy()
|
|
expect(res.json().memberNumber.length).toBe(6)
|
|
})
|
|
})
|
|
|
|
describe('Primary member', () => {
|
|
it('auto-sets primary member on first member create', async () => {
|
|
const memberRes = await app.inject({
|
|
method: 'POST',
|
|
url: `/v1/accounts/${accountId}/members`,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { firstName: 'Primary', lastName: 'Person' },
|
|
})
|
|
const memberId = memberRes.json().id
|
|
|
|
const accountRes = await app.inject({
|
|
method: 'GET',
|
|
url: `/v1/accounts/${accountId}`,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
})
|
|
|
|
expect(accountRes.json().primaryMemberId).toBe(memberId)
|
|
})
|
|
|
|
it('does not overwrite primary on second member', async () => {
|
|
const first = await app.inject({
|
|
method: 'POST',
|
|
url: `/v1/accounts/${accountId}/members`,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { firstName: 'First', lastName: 'Person' },
|
|
})
|
|
|
|
await app.inject({
|
|
method: 'POST',
|
|
url: `/v1/accounts/${accountId}/members`,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { firstName: 'Second', lastName: 'Person' },
|
|
})
|
|
|
|
const accountRes = await app.inject({
|
|
method: 'GET',
|
|
url: `/v1/accounts/${accountId}`,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
})
|
|
|
|
expect(accountRes.json().primaryMemberId).toBe(first.json().id)
|
|
})
|
|
})
|
|
|
|
describe('POST /v1/members/:id/move', () => {
|
|
it('moves member to existing account', async () => {
|
|
const memberRes = await app.inject({
|
|
method: 'POST',
|
|
url: `/v1/accounts/${accountId}/members`,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { firstName: 'Mover', lastName: 'Person' },
|
|
})
|
|
const memberId = memberRes.json().id
|
|
|
|
const newAccountRes = await app.inject({
|
|
method: 'POST',
|
|
url: '/v1/accounts',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { name: 'Target Account' },
|
|
})
|
|
const targetId = newAccountRes.json().id
|
|
|
|
const moveRes = await app.inject({
|
|
method: 'POST',
|
|
url: `/v1/members/${memberId}/move`,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { accountId: targetId },
|
|
})
|
|
|
|
expect(moveRes.statusCode).toBe(200)
|
|
expect(moveRes.json().accountId).toBe(targetId)
|
|
})
|
|
|
|
it('moves member to new account when no accountId given', async () => {
|
|
const memberRes = await app.inject({
|
|
method: 'POST',
|
|
url: `/v1/accounts/${accountId}/members`,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { firstName: 'Solo', lastName: 'Flyer' },
|
|
})
|
|
const memberId = memberRes.json().id
|
|
|
|
const moveRes = await app.inject({
|
|
method: 'POST',
|
|
url: `/v1/members/${memberId}/move`,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: {},
|
|
})
|
|
|
|
expect(moveRes.statusCode).toBe(200)
|
|
expect(moveRes.json().accountId).not.toBe(accountId)
|
|
})
|
|
})
|
|
|
|
describe('Account search includes members', () => {
|
|
it('finds account by member name', async () => {
|
|
await app.inject({
|
|
method: 'POST',
|
|
url: `/v1/accounts/${accountId}/members`,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { firstName: 'UniqueChildName', lastName: 'Smith' },
|
|
})
|
|
|
|
const res = await app.inject({
|
|
method: 'GET',
|
|
url: '/v1/accounts?q=UniqueChildName',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
})
|
|
|
|
expect(res.statusCode).toBe(200)
|
|
expect(res.json().data.length).toBe(1)
|
|
expect(res.json().data[0].id).toBe(accountId)
|
|
})
|
|
})
|
|
|
|
describe('isMinor flag', () => {
|
|
it('accepts explicit isMinor without DOB', async () => {
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: `/v1/accounts/${accountId}/members`,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { firstName: 'Kid', lastName: 'NoDate', isMinor: true },
|
|
})
|
|
|
|
expect(res.statusCode).toBe(201)
|
|
expect(res.json().isMinor).toBe(true)
|
|
expect(res.json().dateOfBirth).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('Account inheritance', () => {
|
|
it('inherits email, phone from account when not provided', async () => {
|
|
const acctRes = await app.inject({
|
|
method: 'POST',
|
|
url: '/v1/accounts',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { name: 'Email Family', email: 'family@test.com', phone: '555-1234' },
|
|
})
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: `/v1/accounts/${acctRes.json().id}/members`,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { firstName: 'Child', lastName: 'One' },
|
|
})
|
|
|
|
expect(res.statusCode).toBe(201)
|
|
expect(res.json().email).toBe('family@test.com')
|
|
expect(res.json().phone).toBe('555-1234')
|
|
})
|
|
|
|
it('uses member email when provided instead of account', async () => {
|
|
const acctRes = await app.inject({
|
|
method: 'POST',
|
|
url: '/v1/accounts',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { name: 'Override Family', email: 'family@test.com' },
|
|
})
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: `/v1/accounts/${acctRes.json().id}/members`,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { firstName: 'Adult', lastName: 'Own', email: 'own@test.com' },
|
|
})
|
|
|
|
expect(res.statusCode).toBe(201)
|
|
expect(res.json().email).toBe('own@test.com')
|
|
})
|
|
|
|
it('inherits address from account when not provided', async () => {
|
|
const acctRes = await app.inject({
|
|
method: 'POST',
|
|
url: '/v1/accounts',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: {
|
|
name: 'Address Family',
|
|
address: { street: '123 Main St', city: 'Austin', state: 'TX', zip: '78701' },
|
|
},
|
|
})
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: `/v1/accounts/${acctRes.json().id}/members`,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { firstName: 'Kid', lastName: 'Home' },
|
|
})
|
|
|
|
expect(res.statusCode).toBe(201)
|
|
expect(res.json().address.city).toBe('Austin')
|
|
expect(res.json().address.state).toBe('TX')
|
|
})
|
|
})
|
|
|
|
describe('State normalization', () => {
|
|
it('normalizes state name to code on account', async () => {
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/v1/accounts',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { name: 'State Test', address: { state: 'texas' } },
|
|
})
|
|
|
|
expect(res.statusCode).toBe(201)
|
|
expect(res.json().address.state).toBe('TX')
|
|
})
|
|
|
|
it('normalizes full state name to code', async () => {
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/v1/accounts',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { name: 'State Name Test', address: { state: 'California' } },
|
|
})
|
|
|
|
expect(res.statusCode).toBe(201)
|
|
expect(res.json().address.state).toBe('CA')
|
|
})
|
|
|
|
it('leaves unrecognized state codes as-is', async () => {
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/v1/accounts',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { name: 'Foreign Test', address: { state: 'ON' } },
|
|
})
|
|
|
|
expect(res.statusCode).toBe(201)
|
|
expect(res.json().address.state).toBe('ON')
|
|
})
|
|
})
|
|
})
|