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

@@ -6,7 +6,7 @@ import {
seedTestCompany,
registerAndLogin,
TEST_COMPANY_ID,
} from '../../test/helpers.js'
} from '../../../src/test/helpers.js'
describe('Processor link routes', () => {
let app: FastifyInstance

View File

@@ -6,7 +6,7 @@ import {
seedTestCompany,
registerAndLogin,
TEST_COMPANY_ID,
} from '../../test/helpers.js'
} from '../../../src/test/helpers.js'
describe('Account routes', () => {
let app: FastifyInstance

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'bun:test'
import type { FastifyInstance } from 'fastify'
import { createTestApp, cleanDb, seedTestCompany, TEST_COMPANY_ID } from '../../test/helpers.js'
import { createTestApp, cleanDb, seedTestCompany, TEST_COMPANY_ID } from '../../../src/test/helpers.js'
describe('Auth routes', () => {
let app: FastifyInstance

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, beforeAll, afterAll } from 'bun:test'
import type { FastifyInstance } from 'fastify'
import { createTestApp } from '../../test/helpers.js'
import { createTestApp } from '../../../src/test/helpers.js'
describe('GET /v1/health', () => {
let app: FastifyInstance

View File

@@ -5,7 +5,7 @@ import {
cleanDb,
seedTestCompany,
registerAndLogin,
} from '../../test/helpers.js'
} from '../../../src/test/helpers.js'
describe('Category routes', () => {
let app: FastifyInstance

View File

@@ -5,7 +5,7 @@ import {
cleanDb,
seedTestCompany,
registerAndLogin,
} from '../../test/helpers.js'
} from '../../../src/test/helpers.js'
describe('Product routes', () => {
let app: FastifyInstance

View File

@@ -0,0 +1,240 @@
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'
import { AccountService, PaymentMethodService, TaxExemptionService } from '../../src/services/account.service.js'
describe('PaymentMethodService', () => {
let app: FastifyInstance
let accountId: string
beforeAll(async () => {
app = await createTestApp()
})
beforeEach(async () => {
await cleanDb(app)
await seedTestCompany(app)
const auth = await registerAndLogin(app, { email: `pm-svc-${Date.now()}@test.com` })
const account = await AccountService.create(app.db, TEST_COMPANY_ID, { name: 'PM Test' })
accountId = account.id
})
afterAll(async () => {
await app.close()
})
it('creates a payment method', async () => {
const pm = await PaymentMethodService.create(app.db, TEST_COMPANY_ID, {
accountId,
processor: 'stripe',
processorPaymentMethodId: 'pm_abc',
cardBrand: 'visa',
lastFour: '4242',
expMonth: 12,
expYear: 2027,
isDefault: false,
})
expect(pm.processorPaymentMethodId).toBe('pm_abc')
expect(pm.isDefault).toBe(false)
})
it('unsets old default when creating a new default', async () => {
const first = await PaymentMethodService.create(app.db, TEST_COMPANY_ID, {
accountId,
processor: 'stripe',
processorPaymentMethodId: 'pm_first',
isDefault: true,
})
expect(first.isDefault).toBe(true)
const second = await PaymentMethodService.create(app.db, TEST_COMPANY_ID, {
accountId,
processor: 'stripe',
processorPaymentMethodId: 'pm_second',
isDefault: true,
})
expect(second.isDefault).toBe(true)
// First should no longer be default
const firstRefreshed = await PaymentMethodService.getById(app.db, TEST_COMPANY_ID, first.id)
expect(firstRefreshed!.isDefault).toBe(false)
})
it('unsets old default when updating to default', async () => {
const first = await PaymentMethodService.create(app.db, TEST_COMPANY_ID, {
accountId,
processor: 'stripe',
processorPaymentMethodId: 'pm_1',
isDefault: true,
})
const second = await PaymentMethodService.create(app.db, TEST_COMPANY_ID, {
accountId,
processor: 'stripe',
processorPaymentMethodId: 'pm_2',
isDefault: false,
})
await PaymentMethodService.update(app.db, TEST_COMPANY_ID, second.id, { isDefault: true })
const firstRefreshed = await PaymentMethodService.getById(app.db, TEST_COMPANY_ID, first.id)
expect(firstRefreshed!.isDefault).toBe(false)
const secondRefreshed = await PaymentMethodService.getById(app.db, TEST_COMPANY_ID, second.id)
expect(secondRefreshed!.isDefault).toBe(true)
})
it('does not affect other accounts when setting default', async () => {
const otherAccount = await AccountService.create(app.db, TEST_COMPANY_ID, { name: 'Other' })
const otherPm = await PaymentMethodService.create(app.db, TEST_COMPANY_ID, {
accountId: otherAccount.id,
processor: 'stripe',
processorPaymentMethodId: 'pm_other',
isDefault: true,
})
// Create default on our account — should not touch the other account's default
await PaymentMethodService.create(app.db, TEST_COMPANY_ID, {
accountId,
processor: 'stripe',
processorPaymentMethodId: 'pm_ours',
isDefault: true,
})
const otherRefreshed = await PaymentMethodService.getById(app.db, TEST_COMPANY_ID, otherPm.id)
expect(otherRefreshed!.isDefault).toBe(true)
})
it('lists only methods for the requested account', async () => {
const otherAccount = await AccountService.create(app.db, TEST_COMPANY_ID, { name: 'Other' })
await PaymentMethodService.create(app.db, TEST_COMPANY_ID, {
accountId,
processor: 'stripe',
processorPaymentMethodId: 'pm_a',
isDefault: false,
})
await PaymentMethodService.create(app.db, TEST_COMPANY_ID, {
accountId: otherAccount.id,
processor: 'stripe',
processorPaymentMethodId: 'pm_b',
isDefault: false,
})
const methods = await PaymentMethodService.listByAccount(app.db, TEST_COMPANY_ID, accountId)
expect(methods.length).toBe(1)
expect(methods[0].processorPaymentMethodId).toBe('pm_a')
})
it('deletes a payment method', async () => {
const pm = await PaymentMethodService.create(app.db, TEST_COMPANY_ID, {
accountId,
processor: 'stripe',
processorPaymentMethodId: 'pm_del',
isDefault: false,
})
const deleted = await PaymentMethodService.delete(app.db, TEST_COMPANY_ID, pm.id)
expect(deleted).not.toBeNull()
const fetched = await PaymentMethodService.getById(app.db, TEST_COMPANY_ID, pm.id)
expect(fetched).toBeNull()
})
})
describe('TaxExemptionService', () => {
let app: FastifyInstance
let accountId: string
let userId: string
beforeAll(async () => {
app = await createTestApp()
})
beforeEach(async () => {
await cleanDb(app)
await seedTestCompany(app)
const auth = await registerAndLogin(app, { email: `tax-svc-${Date.now()}@test.com` })
userId = (auth.user as { id: string }).id
const account = await AccountService.create(app.db, TEST_COMPANY_ID, { name: 'Tax Test' })
accountId = account.id
})
afterAll(async () => {
await app.close()
})
it('creates an exemption in pending status', async () => {
const exemption = await TaxExemptionService.create(app.db, TEST_COMPANY_ID, {
accountId,
certificateNumber: 'TX-001',
certificateType: 'resale',
issuingState: 'TX',
})
expect(exemption.status).toBe('pending')
expect(exemption.certificateNumber).toBe('TX-001')
expect(exemption.approvedBy).toBeNull()
})
it('approves a pending exemption', async () => {
const exemption = await TaxExemptionService.create(app.db, TEST_COMPANY_ID, {
accountId,
certificateNumber: 'TX-002',
})
const approved = await TaxExemptionService.approve(app.db, TEST_COMPANY_ID, exemption.id, userId)
expect(approved!.status).toBe('approved')
expect(approved!.approvedBy).toBe(userId)
expect(approved!.approvedAt).toBeTruthy()
})
it('revokes an exemption with reason', async () => {
const exemption = await TaxExemptionService.create(app.db, TEST_COMPANY_ID, {
accountId,
certificateNumber: 'TX-003',
})
await TaxExemptionService.approve(app.db, TEST_COMPANY_ID, exemption.id, userId)
const revoked = await TaxExemptionService.revoke(app.db, TEST_COMPANY_ID, exemption.id, userId, 'Expired')
expect(revoked!.status).toBe('none')
expect(revoked!.revokedBy).toBe(userId)
expect(revoked!.revokedReason).toBe('Expired')
expect(revoked!.revokedAt).toBeTruthy()
})
it('lists exemptions for an account', async () => {
await TaxExemptionService.create(app.db, TEST_COMPANY_ID, {
accountId,
certificateNumber: 'CERT-A',
})
await TaxExemptionService.create(app.db, TEST_COMPANY_ID, {
accountId,
certificateNumber: 'CERT-B',
})
const list = await TaxExemptionService.listByAccount(app.db, TEST_COMPANY_ID, accountId)
expect(list.length).toBe(2)
})
it('updates exemption details', async () => {
const exemption = await TaxExemptionService.create(app.db, TEST_COMPANY_ID, {
accountId,
certificateNumber: 'OLD',
})
const updated = await TaxExemptionService.update(app.db, TEST_COMPANY_ID, exemption.id, {
certificateNumber: 'NEW',
issuingState: 'CA',
})
expect(updated!.certificateNumber).toBe('NEW')
expect(updated!.issuingState).toBe('CA')
})
it('returns null for nonexistent exemption', async () => {
const result = await TaxExemptionService.getById(app.db, TEST_COMPANY_ID, '00000000-0000-0000-0000-000000000000')
expect(result).toBeNull()
})
})

View File

@@ -0,0 +1,152 @@
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'bun:test'
import type { FastifyInstance } from 'fastify'
import { createTestApp, cleanDb, seedTestCompany, TEST_COMPANY_ID } from '../../src/test/helpers.js'
import { UnitStatusService, ItemConditionService } from '../../src/services/lookup.service.js'
describe('UnitStatusService', () => {
let app: FastifyInstance
beforeAll(async () => {
app = await createTestApp()
})
beforeEach(async () => {
await cleanDb(app)
await seedTestCompany(app)
})
afterAll(async () => {
await app.close()
})
describe('seedForCompany', () => {
it('seeds system statuses on first call', async () => {
const statuses = await UnitStatusService.list(app.db, TEST_COMPANY_ID)
expect(statuses.length).toBeGreaterThanOrEqual(8)
expect(statuses.every((s) => s.isSystem)).toBe(true)
})
it('is idempotent — second seed does not duplicate', async () => {
await UnitStatusService.seedForCompany(app.db, TEST_COMPANY_ID)
const statuses = await UnitStatusService.list(app.db, TEST_COMPANY_ID)
const slugs = statuses.map((s) => s.slug)
const uniqueSlugs = new Set(slugs)
expect(slugs.length).toBe(uniqueSlugs.size)
})
})
describe('getBySlug', () => {
it('returns a system status by slug', async () => {
const status = await UnitStatusService.getBySlug(app.db, TEST_COMPANY_ID, 'available')
expect(status).not.toBeNull()
expect(status!.slug).toBe('available')
expect(status!.isSystem).toBe(true)
})
it('returns null for nonexistent slug', async () => {
const status = await UnitStatusService.getBySlug(app.db, TEST_COMPANY_ID, 'nonexistent')
expect(status).toBeNull()
})
})
describe('validateSlug', () => {
it('returns true for valid active slug', async () => {
expect(await UnitStatusService.validateSlug(app.db, TEST_COMPANY_ID, 'sold')).toBe(true)
})
it('returns false for nonexistent slug', async () => {
expect(await UnitStatusService.validateSlug(app.db, TEST_COMPANY_ID, 'bogus')).toBe(false)
})
})
describe('create', () => {
it('creates a custom (non-system) status', async () => {
const custom = await UnitStatusService.create(app.db, TEST_COMPANY_ID, {
name: 'In Transit',
slug: 'in_transit',
description: 'Being shipped between locations',
sortOrder: 99,
})
expect(custom.slug).toBe('in_transit')
expect(custom.isSystem).toBe(false)
})
})
describe('delete', () => {
it('throws when deleting a system status', async () => {
const system = await UnitStatusService.getBySlug(app.db, TEST_COMPANY_ID, 'available')
expect(system).not.toBeNull()
expect(() => UnitStatusService.delete(app.db, TEST_COMPANY_ID, system!.id)).toThrow('system')
})
it('deletes a custom status', async () => {
const custom = await UnitStatusService.create(app.db, TEST_COMPANY_ID, {
name: 'Temp',
slug: 'temp',
sortOrder: 0,
})
const deleted = await UnitStatusService.delete(app.db, TEST_COMPANY_ID, custom.id)
expect(deleted).not.toBeNull()
expect(deleted!.slug).toBe('temp')
})
it('returns null for nonexistent id', async () => {
const result = await UnitStatusService.delete(app.db, TEST_COMPANY_ID, '00000000-0000-0000-0000-000000000000')
expect(result).toBeNull()
})
})
describe('update', () => {
it('throws when deactivating a system status', async () => {
const system = await UnitStatusService.getBySlug(app.db, TEST_COMPANY_ID, 'sold')
expect(() => UnitStatusService.update(app.db, TEST_COMPANY_ID, system!.id, { isActive: false })).toThrow('system')
})
it('allows renaming a custom status', async () => {
const custom = await UnitStatusService.create(app.db, TEST_COMPANY_ID, {
name: 'Old Name',
slug: 'custom_rename',
sortOrder: 0,
})
const updated = await UnitStatusService.update(app.db, TEST_COMPANY_ID, custom.id, { name: 'New Name' })
expect(updated!.name).toBe('New Name')
})
})
})
describe('ItemConditionService', () => {
let app: FastifyInstance
beforeAll(async () => {
app = await createTestApp()
})
beforeEach(async () => {
await cleanDb(app)
await seedTestCompany(app)
})
afterAll(async () => {
await app.close()
})
it('seeds system conditions', async () => {
const conditions = await ItemConditionService.list(app.db, TEST_COMPANY_ID)
const slugs = conditions.map((c) => c.slug)
expect(slugs).toContain('new')
expect(slugs).toContain('excellent')
expect(slugs).toContain('good')
expect(slugs).toContain('fair')
expect(slugs).toContain('poor')
})
it('creates a custom condition', async () => {
const custom = await ItemConditionService.create(app.db, TEST_COMPANY_ID, {
name: 'Refurbished',
slug: 'refurbished',
sortOrder: 10,
})
expect(custom.isSystem).toBe(false)
expect(await ItemConditionService.validateSlug(app.db, TEST_COMPANY_ID, 'refurbished')).toBe(true)
})
})

View File

@@ -0,0 +1,68 @@
import { describe, it, expect } from 'bun:test'
import { formatCurrency, roundCurrency, toCents, toDollars } from '../../src/utils/currency.js'
describe('formatCurrency', () => {
it('formats whole dollars', () => {
expect(formatCurrency(100)).toBe('$100.00')
})
it('formats cents', () => {
expect(formatCurrency(49.99)).toBe('$49.99')
})
it('formats zero', () => {
expect(formatCurrency(0)).toBe('$0.00')
})
it('formats thousands with comma', () => {
expect(formatCurrency(1234.56)).toBe('$1,234.56')
})
it('formats negative amounts', () => {
expect(formatCurrency(-25.50)).toBe('-$25.50')
})
})
describe('roundCurrency', () => {
it('rounds to 2 decimal places', () => {
expect(roundCurrency(1.005)).toBe(1.01)
expect(roundCurrency(1.004)).toBe(1)
})
it('handles already-rounded values', () => {
expect(roundCurrency(10.50)).toBe(10.50)
})
it('handles zero', () => {
expect(roundCurrency(0)).toBe(0)
})
})
describe('toCents', () => {
it('converts dollars to cents', () => {
expect(toCents(1.00)).toBe(100)
expect(toCents(49.99)).toBe(4999)
})
it('rounds fractional cents', () => {
// 1.005 * 100 = 100.49999... in IEEE 754, so Math.round gives 100
// This is a known floating point limitation — use toCents(roundCurrency(x)) for precision
expect(toCents(1.005)).toBe(100)
expect(toCents(1.006)).toBe(101)
})
it('handles zero', () => {
expect(toCents(0)).toBe(0)
})
})
describe('toDollars', () => {
it('converts cents to dollars', () => {
expect(toDollars(100)).toBe(1.00)
expect(toDollars(4999)).toBe(49.99)
})
it('handles zero', () => {
expect(toDollars(0)).toBe(0)
})
})

View File

@@ -0,0 +1,79 @@
import { describe, it, expect } from 'bun:test'
import { isMinor, capBillingDay, todayISO } from '../../src/utils/dates.js'
describe('isMinor', () => {
it('returns true for a child born 10 years ago', () => {
const dob = new Date()
dob.setFullYear(dob.getFullYear() - 10)
expect(isMinor(dob)).toBe(true)
})
it('returns false for an adult born 25 years ago', () => {
const dob = new Date()
dob.setFullYear(dob.getFullYear() - 25)
expect(isMinor(dob)).toBe(false)
})
it('returns true for someone turning 18 later this year', () => {
const dob = new Date()
dob.setFullYear(dob.getFullYear() - 18)
dob.setMonth(dob.getMonth() + 1) // birthday is next month
expect(isMinor(dob)).toBe(true)
})
it('returns false for someone who turned 18 earlier this year', () => {
const dob = new Date()
dob.setFullYear(dob.getFullYear() - 18)
dob.setMonth(dob.getMonth() - 1) // birthday was last month
expect(isMinor(dob)).toBe(false)
})
it('returns false on their 18th birthday', () => {
const dob = new Date()
dob.setFullYear(dob.getFullYear() - 18)
expect(isMinor(dob)).toBe(false)
})
it('accepts a string date', () => {
expect(isMinor('2000-01-01')).toBe(false)
expect(isMinor('2020-01-01')).toBe(true)
})
it('returns true for a newborn', () => {
expect(isMinor(new Date())).toBe(true)
})
})
describe('capBillingDay', () => {
it('caps at 28', () => {
expect(capBillingDay(31)).toBe(28)
expect(capBillingDay(29)).toBe(28)
})
it('floors at 1', () => {
expect(capBillingDay(0)).toBe(1)
expect(capBillingDay(-5)).toBe(1)
})
it('passes through valid days', () => {
expect(capBillingDay(15)).toBe(15)
expect(capBillingDay(1)).toBe(1)
expect(capBillingDay(28)).toBe(28)
})
it('floors fractional days', () => {
expect(capBillingDay(15.7)).toBe(15)
})
})
describe('todayISO', () => {
it('returns YYYY-MM-DD format', () => {
const result = todayISO()
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/)
})
it('matches today', () => {
const expected = new Date().toISOString().split('T')[0]
expect(todayISO()).toBe(expected)
})
})