Files
lunarfront-app/packages/backend/__tests__/routes/v1/members.test.ts
Ryan Moon b9e984cfa3 Add member address, state normalization, account inheritance, fix member form
- 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
2026-03-28 12:31:02 -05:00

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