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') }) }) })