Drop company_id column from all 22 domain tables via migration. Remove companyId from JWT payload, auth plugins, all service method signatures (~215 occurrences), all route handlers (~105 occurrences), test runner, test suites, and frontend auth store/types. The company table stays as store settings (name, timezone). Tenant isolation in a SaaS deployment would be at the database level (one DB per customer) not the application level. All 107 API tests pass. Zero TSC errors across all packages.
327 lines
9.1 KiB
TypeScript
327 lines
9.1 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('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.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)
|
|
})
|
|
})
|
|
})
|