From 727275af593e97307d45f5a111930c960d722a52 Mon Sep 17 00:00:00 2001 From: Ryan Moon Date: Sat, 28 Mar 2026 09:18:32 -0500 Subject: [PATCH] Add integration tests for members list, move, auto-numbers, primary member, isMinor flag --- .../__tests__/routes/v1/members.test.ts | 258 ++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 packages/backend/__tests__/routes/v1/members.test.ts diff --git a/packages/backend/__tests__/routes/v1/members.test.ts b/packages/backend/__tests__/routes/v1/members.test.ts new file mode 100644 index 0000000..6977c2b --- /dev/null +++ b/packages/backend/__tests__/routes/v1/members.test.ts @@ -0,0 +1,258 @@ +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() + }) + }) +})