Move tests to __tests__ folders, add unit tests for shared utils and services

Restructure tests into __tests__/ directories at package root so they can
be excluded from production builds. Add unit tests for dates, currency,
lookup service, payment method default logic, and tax exemption state
transitions.
This commit is contained in:
Ryan Moon
2026-03-27 21:14:42 -05:00
parent 0a2d6e23af
commit 01d6ff3fa3
10 changed files with 545 additions and 6 deletions

View File

@@ -0,0 +1,327 @@
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('Account routes', () => {
let app: FastifyInstance
let token: string
beforeAll(async () => {
app = await createTestApp()
})
beforeEach(async () => {
await cleanDb(app)
await seedTestCompany(app)
const auth = await registerAndLogin(app)
token = auth.token
})
afterAll(async () => {
await app.close()
})
describe('POST /v1/accounts', () => {
it('creates an account', async () => {
const response = await app.inject({
method: 'POST',
url: '/v1/accounts',
headers: { authorization: `Bearer ${token}` },
payload: {
name: 'Smith Family',
email: 'smith@example.com',
phone: '512-555-1234',
billingMode: 'consolidated',
},
})
expect(response.statusCode).toBe(201)
const body = response.json()
expect(body.name).toBe('Smith Family')
expect(body.email).toBe('smith@example.com')
expect(body.companyId).toBe(TEST_COMPANY_ID)
expect(body.billingMode).toBe('consolidated')
})
it('rejects missing name', async () => {
const response = await app.inject({
method: 'POST',
url: '/v1/accounts',
headers: { authorization: `Bearer ${token}` },
payload: { email: 'noname@test.com' },
})
expect(response.statusCode).toBe(400)
})
})
describe('GET /v1/accounts', () => {
it('lists accounts for the company', async () => {
await app.inject({
method: 'POST',
url: '/v1/accounts',
headers: { authorization: `Bearer ${token}` },
payload: { name: 'Account One' },
})
await app.inject({
method: 'POST',
url: '/v1/accounts',
headers: { authorization: `Bearer ${token}` },
payload: { name: 'Account Two' },
})
const response = await app.inject({
method: 'GET',
url: '/v1/accounts',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(200)
const body = response.json()
expect(body.data.length).toBe(2)
expect(body.pagination.total).toBe(2)
expect(body.pagination.page).toBe(1)
})
})
describe('GET /v1/accounts?q=', () => {
it('searches by name', async () => {
await app.inject({
method: 'POST',
url: '/v1/accounts',
headers: { authorization: `Bearer ${token}` },
payload: { name: 'Johnson Family', phone: '555-9999' },
})
await app.inject({
method: 'POST',
url: '/v1/accounts',
headers: { authorization: `Bearer ${token}` },
payload: { name: 'Williams Music' },
})
const response = await app.inject({
method: 'GET',
url: '/v1/accounts?q=johnson',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(200)
const body = response.json()
expect(body.data.length).toBe(1)
expect(body.data[0].name).toBe('Johnson Family')
})
it('searches by phone', async () => {
await app.inject({
method: 'POST',
url: '/v1/accounts',
headers: { authorization: `Bearer ${token}` },
payload: { name: 'Phone Test', phone: '512-867-5309' },
})
const response = await app.inject({
method: 'GET',
url: '/v1/accounts?q=867-5309',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(200)
expect(response.json().data.length).toBe(1)
})
it('paginates results', async () => {
for (let i = 0; i < 5; i++) {
await app.inject({
method: 'POST',
url: '/v1/accounts',
headers: { authorization: `Bearer ${token}` },
payload: { name: `Account ${i}` },
})
}
const response = await app.inject({
method: 'GET',
url: '/v1/accounts?page=1&limit=2',
headers: { authorization: `Bearer ${token}` },
})
const body = response.json()
expect(body.data.length).toBe(2)
expect(body.pagination.total).toBe(5)
expect(body.pagination.totalPages).toBe(3)
expect(body.pagination.page).toBe(1)
})
})
describe('PATCH /v1/accounts/:id', () => {
it('updates an account', async () => {
const createRes = await app.inject({
method: 'POST',
url: '/v1/accounts',
headers: { authorization: `Bearer ${token}` },
payload: { name: 'Original Name' },
})
const id = createRes.json().id
const response = await app.inject({
method: 'PATCH',
url: `/v1/accounts/${id}`,
headers: { authorization: `Bearer ${token}` },
payload: { name: 'Updated Name' },
})
expect(response.statusCode).toBe(200)
expect(response.json().name).toBe('Updated Name')
})
})
describe('DELETE /v1/accounts/:id', () => {
it('soft-deletes an account', async () => {
const createRes = await app.inject({
method: 'POST',
url: '/v1/accounts',
headers: { authorization: `Bearer ${token}` },
payload: { name: 'To Delete' },
})
const id = createRes.json().id
const delRes = await app.inject({
method: 'DELETE',
url: `/v1/accounts/${id}`,
headers: { authorization: `Bearer ${token}` },
})
expect(delRes.statusCode).toBe(200)
expect(delRes.json().isActive).toBe(false)
// Should not appear in list
const listRes = await app.inject({
method: 'GET',
url: '/v1/accounts',
headers: { authorization: `Bearer ${token}` },
})
expect(listRes.json().data.length).toBe(0)
})
})
})
describe('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: `member-test-${Date.now()}@test.com` })
token = auth.token
const accountRes = await app.inject({
method: 'POST',
url: '/v1/accounts',
headers: { authorization: `Bearer ${token}` },
payload: { name: 'Test Family' },
})
accountId = accountRes.json().id
})
afterAll(async () => {
await app.close()
})
describe('POST /v1/accounts/:accountId/members', () => {
it('creates a member', async () => {
const response = await app.inject({
method: 'POST',
url: `/v1/accounts/${accountId}/members`,
headers: { authorization: `Bearer ${token}` },
payload: {
firstName: 'Emma',
lastName: 'Chen',
dateOfBirth: '2015-03-15',
email: 'emma@test.com',
},
})
expect(response.statusCode).toBe(201)
const body = response.json()
expect(body.firstName).toBe('Emma')
expect(body.lastName).toBe('Chen')
expect(body.isMinor).toBe(true)
expect(body.accountId).toBe(accountId)
})
it('marks adult members as not minor', async () => {
const response = await app.inject({
method: 'POST',
url: `/v1/accounts/${accountId}/members`,
headers: { authorization: `Bearer ${token}` },
payload: {
firstName: 'John',
lastName: 'Chen',
dateOfBirth: '1985-06-20',
},
})
expect(response.statusCode).toBe(201)
expect(response.json().isMinor).toBe(false)
})
})
describe('GET /v1/accounts/:accountId/members', () => {
it('lists members for an account', async () => {
await app.inject({
method: 'POST',
url: `/v1/accounts/${accountId}/members`,
headers: { authorization: `Bearer ${token}` },
payload: { firstName: 'Child', lastName: 'One' },
})
await app.inject({
method: 'POST',
url: `/v1/accounts/${accountId}/members`,
headers: { authorization: `Bearer ${token}` },
payload: { firstName: 'Child', lastName: 'Two' },
})
const response = await app.inject({
method: 'GET',
url: `/v1/accounts/${accountId}/members`,
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(200)
expect(response.json().data.length).toBe(2)
})
})
describe('PATCH /members/:id', () => {
it('updates a member and recalculates isMinor', async () => {
const createRes = await app.inject({
method: 'POST',
url: `/v1/accounts/${accountId}/members`,
headers: { authorization: `Bearer ${token}` },
payload: { firstName: 'Kid', lastName: 'Test', dateOfBirth: '2015-01-01' },
})
const memberId = createRes.json().id
const response = await app.inject({
method: 'PATCH',
url: `/v1/members/${memberId}`,
headers: { authorization: `Bearer ${token}` },
payload: { dateOfBirth: '1980-01-01' },
})
expect(response.statusCode).toBe(200)
expect(response.json().isMinor).toBe(false)
})
})
})