Remove DB-dependent unit tests, use api-tests for integration testing
All tests in __tests__/ were hitting the database directly. Integration testing is handled by the api-tests/ suite instead.
This commit is contained in:
@@ -1,491 +0,0 @@
|
|||||||
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('Processor link 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: `pl-${Date.now()}@test.com` })
|
|
||||||
token = auth.token
|
|
||||||
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/accounts',
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { name: 'Link Test Account' },
|
|
||||||
})
|
|
||||||
accountId = res.json().id
|
|
||||||
})
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('creates a processor link', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/v1/accounts/${accountId}/processor-links`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { processor: 'stripe', processorCustomerId: 'cus_test123' },
|
|
||||||
})
|
|
||||||
expect(res.statusCode).toBe(201)
|
|
||||||
expect(res.json().processor).toBe('stripe')
|
|
||||||
expect(res.json().processorCustomerId).toBe('cus_test123')
|
|
||||||
expect(res.json().accountId).toBe(accountId)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('lists processor links for an account', async () => {
|
|
||||||
await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/v1/accounts/${accountId}/processor-links`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { processor: 'stripe', processorCustomerId: 'cus_1' },
|
|
||||||
})
|
|
||||||
await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/v1/accounts/${accountId}/processor-links`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { processor: 'global_payments', processorCustomerId: 'gp_1' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/v1/accounts/${accountId}/processor-links`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
expect(res.statusCode).toBe(200)
|
|
||||||
expect(res.json().data.length).toBe(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('updates a processor link', async () => {
|
|
||||||
const createRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/v1/accounts/${accountId}/processor-links`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { processor: 'stripe', processorCustomerId: 'cus_1' },
|
|
||||||
})
|
|
||||||
const id = createRes.json().id
|
|
||||||
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: `/v1/processor-links/${id}`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { isActive: false },
|
|
||||||
})
|
|
||||||
expect(res.statusCode).toBe(200)
|
|
||||||
expect(res.json().isActive).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('deletes a processor link', async () => {
|
|
||||||
const createRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/v1/accounts/${accountId}/processor-links`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { processor: 'stripe', processorCustomerId: 'cus_del' },
|
|
||||||
})
|
|
||||||
const id = createRes.json().id
|
|
||||||
|
|
||||||
const delRes = await app.inject({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: `/v1/processor-links/${id}`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
expect(delRes.statusCode).toBe(200)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Payment method 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: `pm-${Date.now()}@test.com` })
|
|
||||||
token = auth.token
|
|
||||||
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/accounts',
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { name: 'Payment Test Account' },
|
|
||||||
})
|
|
||||||
accountId = res.json().id
|
|
||||||
})
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('creates a payment method', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/v1/accounts/${accountId}/payment-methods`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: {
|
|
||||||
processor: 'stripe',
|
|
||||||
processorPaymentMethodId: 'pm_test123',
|
|
||||||
cardBrand: 'visa',
|
|
||||||
lastFour: '4242',
|
|
||||||
expMonth: 12,
|
|
||||||
expYear: 2027,
|
|
||||||
isDefault: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect(res.statusCode).toBe(201)
|
|
||||||
expect(res.json().cardBrand).toBe('visa')
|
|
||||||
expect(res.json().lastFour).toBe('4242')
|
|
||||||
expect(res.json().isDefault).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('lists payment methods for an account', async () => {
|
|
||||||
await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/v1/accounts/${accountId}/payment-methods`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { processor: 'stripe', processorPaymentMethodId: 'pm_1', lastFour: '1111' },
|
|
||||||
})
|
|
||||||
await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/v1/accounts/${accountId}/payment-methods`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { processor: 'stripe', processorPaymentMethodId: 'pm_2', lastFour: '2222' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/v1/accounts/${accountId}/payment-methods`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
expect(res.statusCode).toBe(200)
|
|
||||||
expect(res.json().data.length).toBe(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('sets new default and unsets old default', async () => {
|
|
||||||
const first = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/v1/accounts/${accountId}/payment-methods`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { processor: 'stripe', processorPaymentMethodId: 'pm_1', isDefault: true },
|
|
||||||
})
|
|
||||||
const firstId = first.json().id
|
|
||||||
|
|
||||||
const second = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/v1/accounts/${accountId}/payment-methods`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { processor: 'stripe', processorPaymentMethodId: 'pm_2', isDefault: true },
|
|
||||||
})
|
|
||||||
expect(second.json().isDefault).toBe(true)
|
|
||||||
|
|
||||||
// First should no longer be default
|
|
||||||
const getFirst = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/v1/payment-methods/${firstId}`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
expect(getFirst.json().isDefault).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('deletes a payment method', async () => {
|
|
||||||
const createRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/v1/accounts/${accountId}/payment-methods`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { processor: 'stripe', processorPaymentMethodId: 'pm_del' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const delRes = await app.inject({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: `/v1/payment-methods/${createRes.json().id}`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
expect(delRes.statusCode).toBe(200)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Tax exemption 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: `tax-${Date.now()}@test.com` })
|
|
||||||
token = auth.token
|
|
||||||
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/accounts',
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { name: 'Tax Test Account' },
|
|
||||||
})
|
|
||||||
accountId = res.json().id
|
|
||||||
})
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('creates a tax exemption', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/v1/accounts/${accountId}/tax-exemptions`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: {
|
|
||||||
certificateNumber: 'TX-12345',
|
|
||||||
certificateType: 'resale',
|
|
||||||
issuingState: 'TX',
|
|
||||||
expiresAt: '2027-12-31',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect(res.statusCode).toBe(201)
|
|
||||||
expect(res.json().certificateNumber).toBe('TX-12345')
|
|
||||||
expect(res.json().status).toBe('pending')
|
|
||||||
expect(res.json().issuingState).toBe('TX')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('lists tax exemptions for an account', async () => {
|
|
||||||
await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/v1/accounts/${accountId}/tax-exemptions`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { certificateNumber: 'CERT-1' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/v1/accounts/${accountId}/tax-exemptions`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
expect(res.statusCode).toBe(200)
|
|
||||||
expect(res.json().data.length).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('approves a tax exemption', async () => {
|
|
||||||
const createRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/v1/accounts/${accountId}/tax-exemptions`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { certificateNumber: 'CERT-APPROVE' },
|
|
||||||
})
|
|
||||||
const id = createRes.json().id
|
|
||||||
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/v1/tax-exemptions/${id}/approve`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
expect(res.statusCode).toBe(200)
|
|
||||||
expect(res.json().status).toBe('approved')
|
|
||||||
expect(res.json().approvedBy).toBeTruthy()
|
|
||||||
expect(res.json().approvedAt).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('revokes a tax exemption with reason', async () => {
|
|
||||||
const createRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/v1/accounts/${accountId}/tax-exemptions`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { certificateNumber: 'CERT-REVOKE' },
|
|
||||||
})
|
|
||||||
const id = createRes.json().id
|
|
||||||
|
|
||||||
// Approve first
|
|
||||||
await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/v1/tax-exemptions/${id}/approve`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/v1/tax-exemptions/${id}/revoke`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { reason: 'Certificate expired' },
|
|
||||||
})
|
|
||||||
expect(res.statusCode).toBe(200)
|
|
||||||
expect(res.json().status).toBe('none')
|
|
||||||
expect(res.json().revokedReason).toBe('Certificate expired')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects revoke without reason', async () => {
|
|
||||||
const createRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/v1/accounts/${accountId}/tax-exemptions`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { certificateNumber: 'CERT-NO-REASON' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/v1/tax-exemptions/${createRes.json().id}/revoke`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: {},
|
|
||||||
})
|
|
||||||
expect(res.statusCode).toBe(400)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('updates a tax exemption', async () => {
|
|
||||||
const createRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/v1/accounts/${accountId}/tax-exemptions`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { certificateNumber: 'OLD-CERT' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: `/v1/tax-exemptions/${createRes.json().id}`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { certificateNumber: 'NEW-CERT', issuingState: 'CA' },
|
|
||||||
})
|
|
||||||
expect(res.statusCode).toBe(200)
|
|
||||||
expect(res.json().certificateNumber).toBe('NEW-CERT')
|
|
||||||
expect(res.json().issuingState).toBe('CA')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Lookup table 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, { email: `lookup-${Date.now()}@test.com` })
|
|
||||||
token = auth.token
|
|
||||||
})
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Unit statuses', () => {
|
|
||||||
it('lists system-seeded unit statuses', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/v1/unit-statuses',
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
expect(res.statusCode).toBe(200)
|
|
||||||
const slugs = res.json().data.map((s: { slug: string }) => s.slug)
|
|
||||||
expect(slugs).toContain('available')
|
|
||||||
expect(slugs).toContain('sold')
|
|
||||||
expect(slugs).toContain('on_trial')
|
|
||||||
expect(slugs).toContain('layaway')
|
|
||||||
expect(slugs).toContain('lost')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('creates a custom status', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/unit-statuses',
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { name: 'On Display', slug: 'on_display', description: 'Showroom floor' },
|
|
||||||
})
|
|
||||||
expect(res.statusCode).toBe(201)
|
|
||||||
expect(res.json().slug).toBe('on_display')
|
|
||||||
expect(res.json().isSystem).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects duplicate slug', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/unit-statuses',
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { name: 'Available Copy', slug: 'available' },
|
|
||||||
})
|
|
||||||
expect(res.statusCode).toBe(409)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('blocks deleting a system status', async () => {
|
|
||||||
const list = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/v1/unit-statuses',
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
const systemStatus = list.json().data.find((s: { isSystem: boolean }) => s.isSystem)
|
|
||||||
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: `/v1/unit-statuses/${systemStatus.id}`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
expect(res.statusCode).toBe(403)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('allows deleting a custom status', async () => {
|
|
||||||
const createRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/unit-statuses',
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { name: 'Temp', slug: 'temp_status' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: `/v1/unit-statuses/${createRes.json().id}`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
expect(res.statusCode).toBe(200)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Item conditions', () => {
|
|
||||||
it('lists system-seeded item conditions', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/v1/item-conditions',
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
expect(res.statusCode).toBe(200)
|
|
||||||
const slugs = res.json().data.map((c: { slug: string }) => 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 res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/item-conditions',
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { name: 'Refurbished', slug: 'refurbished', description: 'Professionally restored' },
|
|
||||||
})
|
|
||||||
expect(res.statusCode).toBe(201)
|
|
||||||
expect(res.json().slug).toBe('refurbished')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,326 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
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'
|
|
||||||
|
|
||||||
describe('Auth routes', () => {
|
|
||||||
let app: FastifyInstance
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
app = await createTestApp()
|
|
||||||
})
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await cleanDb(app)
|
|
||||||
await seedTestCompany(app)
|
|
||||||
})
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('POST /v1/auth/register', () => {
|
|
||||||
it('creates a user and returns token', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/auth/register',
|
|
||||||
headers: { 'x-company-id': TEST_COMPANY_ID },
|
|
||||||
payload: {
|
|
||||||
email: 'staff@musicstore.com',
|
|
||||||
password: 'securepassword',
|
|
||||||
firstName: 'Jane',
|
|
||||||
lastName: 'Doe',
|
|
||||||
role: 'staff',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(response.statusCode).toBe(201)
|
|
||||||
const body = response.json()
|
|
||||||
expect(body.user.email).toBe('staff@musicstore.com')
|
|
||||||
expect(body.user.firstName).toBe('Jane')
|
|
||||||
expect(body.user.role).toBe('staff')
|
|
||||||
expect(body.token).toBeDefined()
|
|
||||||
expect(body.user.passwordHash).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects duplicate email within same company', async () => {
|
|
||||||
await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/auth/register',
|
|
||||||
headers: { 'x-company-id': TEST_COMPANY_ID },
|
|
||||||
payload: {
|
|
||||||
email: 'dupe@test.com',
|
|
||||||
password: 'password123',
|
|
||||||
firstName: 'First',
|
|
||||||
lastName: 'User',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/auth/register',
|
|
||||||
headers: { 'x-company-id': TEST_COMPANY_ID },
|
|
||||||
payload: {
|
|
||||||
email: 'dupe@test.com',
|
|
||||||
password: 'password456',
|
|
||||||
firstName: 'Second',
|
|
||||||
lastName: 'User',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(response.statusCode).toBe(409)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects invalid email', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/auth/register',
|
|
||||||
headers: { 'x-company-id': TEST_COMPANY_ID },
|
|
||||||
payload: {
|
|
||||||
email: 'not-an-email',
|
|
||||||
password: 'password123',
|
|
||||||
firstName: 'Bad',
|
|
||||||
lastName: 'Email',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(response.statusCode).toBe(400)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects short password', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/auth/register',
|
|
||||||
headers: { 'x-company-id': TEST_COMPANY_ID },
|
|
||||||
payload: {
|
|
||||||
email: 'short@test.com',
|
|
||||||
password: '123',
|
|
||||||
firstName: 'Short',
|
|
||||||
lastName: 'Pass',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(response.statusCode).toBe(400)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('POST /v1/auth/login', () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/auth/register',
|
|
||||||
headers: { 'x-company-id': TEST_COMPANY_ID },
|
|
||||||
payload: {
|
|
||||||
email: 'login@test.com',
|
|
||||||
password: 'correctpassword',
|
|
||||||
firstName: 'Login',
|
|
||||||
lastName: 'User',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns token with valid credentials', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/auth/login',
|
|
||||||
headers: { 'x-company-id': TEST_COMPANY_ID },
|
|
||||||
payload: {
|
|
||||||
email: 'login@test.com',
|
|
||||||
password: 'correctpassword',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200)
|
|
||||||
const body = response.json()
|
|
||||||
expect(body.token).toBeDefined()
|
|
||||||
expect(body.user.email).toBe('login@test.com')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects wrong password', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/auth/login',
|
|
||||||
headers: { 'x-company-id': TEST_COMPANY_ID },
|
|
||||||
payload: {
|
|
||||||
email: 'login@test.com',
|
|
||||||
password: 'wrongpassword',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(response.statusCode).toBe(401)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects nonexistent email', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/auth/login',
|
|
||||||
headers: { 'x-company-id': TEST_COMPANY_ID },
|
|
||||||
payload: {
|
|
||||||
email: 'nobody@test.com',
|
|
||||||
password: 'whatever',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(response.statusCode).toBe(401)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { describe, it, expect, beforeAll, afterAll } from 'bun:test'
|
|
||||||
import type { FastifyInstance } from 'fastify'
|
|
||||||
import { createTestApp } from '../../../src/test/helpers.js'
|
|
||||||
|
|
||||||
describe('GET /v1/health', () => {
|
|
||||||
let app: FastifyInstance
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
app = await createTestApp()
|
|
||||||
})
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns ok when db and redis are connected', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/v1/health',
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200)
|
|
||||||
|
|
||||||
const body = response.json()
|
|
||||||
expect(body.status).toBe('ok')
|
|
||||||
expect(body.db).toBe('connected')
|
|
||||||
expect(body.redis).toBe('connected')
|
|
||||||
expect(body.timestamp).toBeDefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'bun:test'
|
|
||||||
import type { FastifyInstance } from 'fastify'
|
|
||||||
import {
|
|
||||||
createTestApp,
|
|
||||||
cleanDb,
|
|
||||||
seedTestCompany,
|
|
||||||
registerAndLogin,
|
|
||||||
} from '../../../src/test/helpers.js'
|
|
||||||
|
|
||||||
describe('Category 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()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('creates a category', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/categories',
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { name: 'Guitars', description: 'All guitars', sortOrder: 1 },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(response.statusCode).toBe(201)
|
|
||||||
expect(response.json().name).toBe('Guitars')
|
|
||||||
expect(response.json().sortOrder).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('creates a child category', async () => {
|
|
||||||
const parentRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/categories',
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { name: 'Instruments' },
|
|
||||||
})
|
|
||||||
const parentId = parentRes.json().id
|
|
||||||
|
|
||||||
const childRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/categories',
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { name: 'Brass', parentId },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(childRes.statusCode).toBe(201)
|
|
||||||
expect(childRes.json().parentId).toBe(parentId)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('lists categories sorted by sortOrder', async () => {
|
|
||||||
await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/categories',
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { name: 'Zzz Last', sortOrder: 99 },
|
|
||||||
})
|
|
||||||
await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/categories',
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { name: 'Aaa First', sortOrder: 1 },
|
|
||||||
})
|
|
||||||
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/v1/categories',
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200)
|
|
||||||
const body = response.json()
|
|
||||||
expect(body.data.length).toBe(2)
|
|
||||||
expect(body.data[0].name).toBe('Aaa First')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('soft-deletes a category', async () => {
|
|
||||||
const createRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/categories',
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { name: 'To Delete' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const delRes = await app.inject({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: `/v1/categories/${createRes.json().id}`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
expect(delRes.json().isActive).toBe(false)
|
|
||||||
|
|
||||||
const listRes = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/v1/categories',
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
expect(listRes.json().data.length).toBe(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Supplier 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, { email: `supplier-${Date.now()}@test.com` })
|
|
||||||
token = auth.token
|
|
||||||
})
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('creates a supplier', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/suppliers',
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: {
|
|
||||||
name: 'RS Musical',
|
|
||||||
contactName: 'Bob Smith',
|
|
||||||
email: 'bob@rsmusical.com',
|
|
||||||
paymentTerms: 'Net 30',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(response.statusCode).toBe(201)
|
|
||||||
expect(response.json().name).toBe('RS Musical')
|
|
||||||
expect(response.json().paymentTerms).toBe('Net 30')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('searches suppliers by name', async () => {
|
|
||||||
await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/suppliers',
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { name: "Ferree's Tools" },
|
|
||||||
})
|
|
||||||
await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/suppliers',
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { name: 'Allied Supply' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/v1/suppliers?q=ferree',
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200)
|
|
||||||
expect(response.json().data.length).toBe(1)
|
|
||||||
expect(response.json().data[0].name).toBe("Ferree's Tools")
|
|
||||||
})
|
|
||||||
|
|
||||||
it('updates a supplier', async () => {
|
|
||||||
const createRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/suppliers',
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { name: 'Old Name' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const updateRes = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: `/v1/suppliers/${createRes.json().id}`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { name: 'New Name', paymentTerms: 'COD' },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(updateRes.statusCode).toBe(200)
|
|
||||||
expect(updateRes.json().name).toBe('New Name')
|
|
||||||
expect(updateRes.json().paymentTerms).toBe('COD')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,360 +0,0 @@
|
|||||||
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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'bun:test'
|
|
||||||
import type { FastifyInstance } from 'fastify'
|
|
||||||
import {
|
|
||||||
createTestApp,
|
|
||||||
cleanDb,
|
|
||||||
seedTestCompany,
|
|
||||||
registerAndLogin,
|
|
||||||
} from '../../../src/test/helpers.js'
|
|
||||||
|
|
||||||
describe('Product 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()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('creates a non-serialized product', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/products',
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: {
|
|
||||||
name: 'Guitar Strings - D\'Addario EJ16',
|
|
||||||
sku: 'STR-DAD-EJ16',
|
|
||||||
upc: '019954121266',
|
|
||||||
brand: "D'Addario",
|
|
||||||
price: 6.99,
|
|
||||||
qtyOnHand: 24,
|
|
||||||
qtyReorderPoint: 10,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(response.statusCode).toBe(201)
|
|
||||||
const body = response.json()
|
|
||||||
expect(body.name).toBe('Guitar Strings - D\'Addario EJ16')
|
|
||||||
expect(body.isSerialized).toBe(false)
|
|
||||||
expect(body.qtyOnHand).toBe(24)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('creates a serialized product', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/products',
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: {
|
|
||||||
name: 'Yamaha YTR-2330 Trumpet',
|
|
||||||
sku: 'BRASS-YAM-2330',
|
|
||||||
brand: 'Yamaha',
|
|
||||||
model: 'YTR-2330',
|
|
||||||
isSerialized: true,
|
|
||||||
price: 1299.99,
|
|
||||||
minPrice: 1100.00,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(response.statusCode).toBe(201)
|
|
||||||
expect(response.json().isSerialized).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('searches by name, sku, and brand', async () => {
|
|
||||||
await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/products',
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { name: 'Fender Stratocaster', brand: 'Fender', sku: 'GTR-FND-STRAT' },
|
|
||||||
})
|
|
||||||
await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/products',
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { name: 'Gibson Les Paul', brand: 'Gibson', sku: 'GTR-GIB-LP' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const byName = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/v1/products?q=stratocaster',
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
expect(byName.json().data.length).toBe(1)
|
|
||||||
|
|
||||||
const bySku = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/v1/products?q=GTR-GIB',
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
expect(bySku.json().data.length).toBe(1)
|
|
||||||
|
|
||||||
const byBrand = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/v1/products?q=fender',
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
expect(byBrand.json().data.length).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('logs price change on update', async () => {
|
|
||||||
const createRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/products',
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { name: 'Test Item', price: 10.00 },
|
|
||||||
})
|
|
||||||
const id = createRes.json().id
|
|
||||||
|
|
||||||
const updateRes = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: `/v1/products/${id}`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { price: 12.50 },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(updateRes.statusCode).toBe(200)
|
|
||||||
expect(updateRes.json().price).toBe('12.50')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Inventory unit routes', () => {
|
|
||||||
let app: FastifyInstance
|
|
||||||
let token: string
|
|
||||||
let productId: string
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
app = await createTestApp()
|
|
||||||
})
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await cleanDb(app)
|
|
||||||
await seedTestCompany(app)
|
|
||||||
const auth = await registerAndLogin(app, { email: `unit-${Date.now()}@test.com` })
|
|
||||||
token = auth.token
|
|
||||||
|
|
||||||
const productRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/products',
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { name: 'Yamaha Trumpet', isSerialized: true, price: 1200 },
|
|
||||||
})
|
|
||||||
productId = productRes.json().id
|
|
||||||
})
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('creates an inventory unit with serial number', async () => {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/v1/products/${productId}/units`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: {
|
|
||||||
serialNumber: 'YAM-2024-001234',
|
|
||||||
condition: 'new',
|
|
||||||
purchaseDate: '2024-09-15',
|
|
||||||
purchaseCost: 650.00,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(response.statusCode).toBe(201)
|
|
||||||
const body = response.json()
|
|
||||||
expect(body.serialNumber).toBe('YAM-2024-001234')
|
|
||||||
expect(body.condition).toBe('new')
|
|
||||||
expect(body.status).toBe('available')
|
|
||||||
expect(body.purchaseCost).toBe('650.00')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('lists units for a product', async () => {
|
|
||||||
await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/v1/products/${productId}/units`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { serialNumber: 'SN-001' },
|
|
||||||
})
|
|
||||||
await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/v1/products/${productId}/units`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { serialNumber: 'SN-002' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/v1/products/${productId}/units`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200)
|
|
||||||
expect(response.json().data.length).toBe(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('updates unit status and condition', async () => {
|
|
||||||
const createRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `/v1/products/${productId}/units`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { serialNumber: 'SN-UPDATE' },
|
|
||||||
})
|
|
||||||
const unitId = createRes.json().id
|
|
||||||
|
|
||||||
const updateRes = await app.inject({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: `/v1/units/${unitId}`,
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
payload: { status: 'rented', condition: 'good' },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(updateRes.statusCode).toBe(200)
|
|
||||||
expect(updateRes.json().status).toBe('rented')
|
|
||||||
expect(updateRes.json().condition).toBe('good')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test'
|
|
||||||
import type { FastifyInstance } from 'fastify'
|
|
||||||
import { createTestApp, cleanDb, seedTestCompany, registerAndLogin } from '../../../src/test/helpers.js'
|
|
||||||
|
|
||||||
describe('WebDAV', () => {
|
|
||||||
let app: FastifyInstance
|
|
||||||
let basicAuth: string
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
app = await createTestApp()
|
|
||||||
})
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await cleanDb(app)
|
|
||||||
await seedTestCompany(app)
|
|
||||||
const { user } = await registerAndLogin(app, {
|
|
||||||
email: 'webdav@forte.dev',
|
|
||||||
password: 'webdavpass1234',
|
|
||||||
})
|
|
||||||
basicAuth = 'Basic ' + Buffer.from('webdav@forte.dev:webdavpass1234').toString('base64')
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('OPTIONS', () => {
|
|
||||||
it('returns DAV headers on root', async () => {
|
|
||||||
const res = await app.inject({ method: 'OPTIONS', url: '/webdav/' })
|
|
||||||
expect(res.statusCode).toBe(200)
|
|
||||||
expect(res.headers['dav']).toContain('1')
|
|
||||||
expect(res.headers['allow']).toContain('PROPFIND')
|
|
||||||
expect(res.headers['allow']).toContain('GET')
|
|
||||||
expect(res.headers['allow']).toContain('PUT')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns DAV headers on wildcard path', async () => {
|
|
||||||
const res = await app.inject({ method: 'OPTIONS', url: '/webdav/some/path' })
|
|
||||||
expect(res.statusCode).toBe(200)
|
|
||||||
expect(res.headers['dav']).toContain('1')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Authentication', () => {
|
|
||||||
it('returns 401 without credentials', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'PROPFIND' as any,
|
|
||||||
url: '/webdav/',
|
|
||||||
})
|
|
||||||
expect(res.statusCode).toBe(401)
|
|
||||||
expect(res.headers['www-authenticate']).toContain('Basic')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns 401 with wrong password', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'PROPFIND' as any,
|
|
||||||
url: '/webdav/',
|
|
||||||
headers: { authorization: 'Basic ' + Buffer.from('webdav@forte.dev:wrongpass').toString('base64') },
|
|
||||||
})
|
|
||||||
expect(res.statusCode).toBe(401)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('succeeds with correct credentials', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'PROPFIND' as any,
|
|
||||||
url: '/webdav/',
|
|
||||||
headers: { authorization: basicAuth, depth: '0' },
|
|
||||||
})
|
|
||||||
expect(res.statusCode).toBe(207)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('PROPFIND', () => {
|
|
||||||
it('lists root with depth 0', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'PROPFIND' as any,
|
|
||||||
url: '/webdav/',
|
|
||||||
headers: { authorization: basicAuth, depth: '0' },
|
|
||||||
})
|
|
||||||
expect(res.statusCode).toBe(207)
|
|
||||||
expect(res.headers['content-type']).toContain('application/xml')
|
|
||||||
expect(res.body).toContain('<D:multistatus')
|
|
||||||
expect(res.body).toContain('<D:collection/')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('lists root folders with depth 1', async () => {
|
|
||||||
// Create a folder first via MKCOL
|
|
||||||
await app.inject({
|
|
||||||
method: 'MKCOL' as any,
|
|
||||||
url: '/webdav/Test%20Folder',
|
|
||||||
headers: { authorization: basicAuth },
|
|
||||||
})
|
|
||||||
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'PROPFIND' as any,
|
|
||||||
url: '/webdav/',
|
|
||||||
headers: { authorization: basicAuth, depth: '1' },
|
|
||||||
})
|
|
||||||
expect(res.statusCode).toBe(207)
|
|
||||||
expect(res.body).toContain('Test Folder')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('MKCOL', () => {
|
|
||||||
it('creates a folder', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'MKCOL' as any,
|
|
||||||
url: '/webdav/Documents',
|
|
||||||
headers: { authorization: basicAuth },
|
|
||||||
})
|
|
||||||
expect(res.statusCode).toBe(201)
|
|
||||||
|
|
||||||
// Verify it appears in PROPFIND
|
|
||||||
const listing = await app.inject({
|
|
||||||
method: 'PROPFIND' as any,
|
|
||||||
url: '/webdav/',
|
|
||||||
headers: { authorization: basicAuth, depth: '1' },
|
|
||||||
})
|
|
||||||
expect(listing.body).toContain('Documents')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns 405 if folder already exists', async () => {
|
|
||||||
await app.inject({
|
|
||||||
method: 'MKCOL' as any,
|
|
||||||
url: '/webdav/Documents',
|
|
||||||
headers: { authorization: basicAuth },
|
|
||||||
})
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'MKCOL' as any,
|
|
||||||
url: '/webdav/Documents',
|
|
||||||
headers: { authorization: basicAuth },
|
|
||||||
})
|
|
||||||
expect(res.statusCode).toBe(405)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('PUT / GET / DELETE', () => {
|
|
||||||
it('uploads, downloads, and deletes a file', async () => {
|
|
||||||
// Create parent folder
|
|
||||||
await app.inject({
|
|
||||||
method: 'MKCOL' as any,
|
|
||||||
url: '/webdav/Uploads',
|
|
||||||
headers: { authorization: basicAuth },
|
|
||||||
})
|
|
||||||
|
|
||||||
// PUT a file
|
|
||||||
const fileContent = 'Hello, WebDAV!'
|
|
||||||
const putRes = await app.inject({
|
|
||||||
method: 'PUT',
|
|
||||||
url: '/webdav/Uploads/test.txt',
|
|
||||||
headers: {
|
|
||||||
authorization: basicAuth,
|
|
||||||
'content-type': 'text/plain',
|
|
||||||
},
|
|
||||||
body: fileContent,
|
|
||||||
})
|
|
||||||
expect(putRes.statusCode).toBe(201)
|
|
||||||
expect(putRes.headers['etag']).toBeDefined()
|
|
||||||
|
|
||||||
// GET the file
|
|
||||||
const getRes = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/webdav/Uploads/test.txt',
|
|
||||||
headers: { authorization: basicAuth },
|
|
||||||
})
|
|
||||||
expect(getRes.statusCode).toBe(200)
|
|
||||||
expect(getRes.body).toBe(fileContent)
|
|
||||||
expect(getRes.headers['content-type']).toContain('text/plain')
|
|
||||||
|
|
||||||
// DELETE the file
|
|
||||||
const delRes = await app.inject({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: '/webdav/Uploads/test.txt',
|
|
||||||
headers: { authorization: basicAuth },
|
|
||||||
})
|
|
||||||
expect(delRes.statusCode).toBe(204)
|
|
||||||
|
|
||||||
// Verify it's gone
|
|
||||||
const getRes2 = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/webdav/Uploads/test.txt',
|
|
||||||
headers: { authorization: basicAuth },
|
|
||||||
})
|
|
||||||
expect(getRes2.statusCode).toBe(404)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('overwrites an existing file with PUT', async () => {
|
|
||||||
await app.inject({
|
|
||||||
method: 'MKCOL' as any,
|
|
||||||
url: '/webdav/Overwrite',
|
|
||||||
headers: { authorization: basicAuth },
|
|
||||||
})
|
|
||||||
|
|
||||||
// Upload original
|
|
||||||
await app.inject({
|
|
||||||
method: 'PUT',
|
|
||||||
url: '/webdav/Overwrite/doc.txt',
|
|
||||||
headers: { authorization: basicAuth, 'content-type': 'text/plain' },
|
|
||||||
body: 'version 1',
|
|
||||||
})
|
|
||||||
|
|
||||||
// Overwrite
|
|
||||||
const putRes = await app.inject({
|
|
||||||
method: 'PUT',
|
|
||||||
url: '/webdav/Overwrite/doc.txt',
|
|
||||||
headers: { authorization: basicAuth, 'content-type': 'text/plain' },
|
|
||||||
body: 'version 2',
|
|
||||||
})
|
|
||||||
expect(putRes.statusCode).toBe(204)
|
|
||||||
|
|
||||||
// Verify new content
|
|
||||||
const getRes = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/webdav/Overwrite/doc.txt',
|
|
||||||
headers: { authorization: basicAuth },
|
|
||||||
})
|
|
||||||
expect(getRes.body).toBe('version 2')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('DELETE folder', () => {
|
|
||||||
it('deletes a folder', async () => {
|
|
||||||
await app.inject({
|
|
||||||
method: 'MKCOL' as any,
|
|
||||||
url: '/webdav/ToDelete',
|
|
||||||
headers: { authorization: basicAuth },
|
|
||||||
})
|
|
||||||
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: '/webdav/ToDelete',
|
|
||||||
headers: { authorization: basicAuth },
|
|
||||||
})
|
|
||||||
expect(res.statusCode).toBe(204)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('LOCK / UNLOCK', () => {
|
|
||||||
it('returns a lock token', async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'LOCK' as any,
|
|
||||||
url: '/webdav/some-resource',
|
|
||||||
headers: { authorization: basicAuth },
|
|
||||||
})
|
|
||||||
expect(res.statusCode).toBe(200)
|
|
||||||
expect(res.headers['lock-token']).toContain('opaquelocktoken:')
|
|
||||||
expect(res.body).toContain('<D:lockdiscovery')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('unlocks a resource', async () => {
|
|
||||||
const lockRes = await app.inject({
|
|
||||||
method: 'LOCK' as any,
|
|
||||||
url: '/webdav/some-resource',
|
|
||||||
headers: { authorization: basicAuth },
|
|
||||||
})
|
|
||||||
const lockToken = lockRes.headers['lock-token'] as string
|
|
||||||
|
|
||||||
const unlockRes = await app.inject({
|
|
||||||
method: 'UNLOCK' as any,
|
|
||||||
url: '/webdav/some-resource',
|
|
||||||
headers: {
|
|
||||||
authorization: basicAuth,
|
|
||||||
'lock-token': lockToken,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect(unlockRes.statusCode).toBe(204)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('HEAD', () => {
|
|
||||||
it('returns headers for a file', async () => {
|
|
||||||
await app.inject({
|
|
||||||
method: 'MKCOL' as any,
|
|
||||||
url: '/webdav/HeadTest',
|
|
||||||
headers: { authorization: basicAuth },
|
|
||||||
})
|
|
||||||
await app.inject({
|
|
||||||
method: 'PUT',
|
|
||||||
url: '/webdav/HeadTest/file.txt',
|
|
||||||
headers: { authorization: basicAuth, 'content-type': 'text/plain' },
|
|
||||||
body: 'test content',
|
|
||||||
})
|
|
||||||
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'HEAD',
|
|
||||||
url: '/webdav/HeadTest/file.txt',
|
|
||||||
headers: { authorization: basicAuth },
|
|
||||||
})
|
|
||||||
expect(res.statusCode).toBe(200)
|
|
||||||
expect(res.headers['content-type']).toContain('text/plain')
|
|
||||||
expect(res.headers['etag']).toBeDefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
[test]
|
|
||||||
preload = ["./src/test/setup.ts"]
|
|
||||||
timeout = 15000
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import type { FastifyInstance } from 'fastify'
|
|
||||||
import { buildApp } from '../main.js'
|
|
||||||
import { sql, eq } from 'drizzle-orm'
|
|
||||||
import { companies, locations } from '../db/schema/stores.js'
|
|
||||||
import { UnitStatusService, ItemConditionService } from '../services/lookup.service.js'
|
|
||||||
import { RbacService } from '../services/rbac.service.js'
|
|
||||||
import { roles } from '../db/schema/rbac.js'
|
|
||||||
import { users } from '../db/schema/users.js'
|
|
||||||
|
|
||||||
export const TEST_COMPANY_ID = '00000000-0000-0000-0000-000000000099'
|
|
||||||
export const TEST_LOCATION_ID = '00000000-0000-0000-0000-000000000099'
|
|
||||||
|
|
||||||
export async function createTestApp(): Promise<FastifyInstance> {
|
|
||||||
const app = await buildApp()
|
|
||||||
await app.ready()
|
|
||||||
return app
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cleanDb(app: FastifyInstance): Promise<void> {
|
|
||||||
await app.db.execute(sql`
|
|
||||||
DO $$ DECLARE
|
|
||||||
r RECORD;
|
|
||||||
BEGIN
|
|
||||||
SET client_min_messages TO WARNING;
|
|
||||||
FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP
|
|
||||||
EXECUTE 'TRUNCATE TABLE ' || quote_ident(r.tablename) || ' CASCADE';
|
|
||||||
END LOOP;
|
|
||||||
RESET client_min_messages;
|
|
||||||
END $$
|
|
||||||
`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function seedTestCompany(app: FastifyInstance): Promise<void> {
|
|
||||||
await app.db.insert(companies).values({
|
|
||||||
id: TEST_COMPANY_ID,
|
|
||||||
name: 'Test Music Co.',
|
|
||||||
timezone: 'America/Chicago',
|
|
||||||
})
|
|
||||||
await app.db.insert(locations).values({
|
|
||||||
id: TEST_LOCATION_ID,
|
|
||||||
name: 'Test Location',
|
|
||||||
})
|
|
||||||
|
|
||||||
await UnitStatusService.seedDefaults(app.db)
|
|
||||||
await ItemConditionService.seedDefaults(app.db)
|
|
||||||
|
|
||||||
// Seed RBAC permissions and default roles
|
|
||||||
await RbacService.seedPermissions(app.db)
|
|
||||||
await RbacService.seedDefaultRoles(app.db)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function registerAndLogin(
|
|
||||||
app: FastifyInstance,
|
|
||||||
overrides: {
|
|
||||||
email?: string
|
|
||||||
password?: string
|
|
||||||
firstName?: string
|
|
||||||
lastName?: string
|
|
||||||
role?: string
|
|
||||||
} = {},
|
|
||||||
): Promise<{ token: string; user: Record<string, unknown> }> {
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/auth/register',
|
|
||||||
headers: { 'x-company-id': TEST_COMPANY_ID },
|
|
||||||
payload: {
|
|
||||||
email: overrides.email ?? 'test@forte.dev',
|
|
||||||
password: overrides.password ?? 'testpassword1234',
|
|
||||||
firstName: overrides.firstName ?? 'Test',
|
|
||||||
lastName: overrides.lastName ?? 'User',
|
|
||||||
role: overrides.role ?? 'admin',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const body = response.json()
|
|
||||||
|
|
||||||
// Assign the admin role to the test user so they have all permissions
|
|
||||||
if (body.user?.id) {
|
|
||||||
const [adminRole] = await app.db
|
|
||||||
.select()
|
|
||||||
.from(roles)
|
|
||||||
.where(eq(roles.slug, 'admin'))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (adminRole) {
|
|
||||||
await RbacService.assignRole(app.db, body.user.id, adminRole.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-login to get a fresh token (permissions are loaded on authenticate)
|
|
||||||
const loginRes = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/auth/login',
|
|
||||||
payload: {
|
|
||||||
email: overrides.email ?? 'test@forte.dev',
|
|
||||||
password: overrides.password ?? 'testpassword1234',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const loginBody = loginRes.json()
|
|
||||||
return { token: loginBody.token, user: loginBody.user }
|
|
||||||
}
|
|
||||||
|
|
||||||
return { token: body.token, user: body.user }
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import postgres from 'postgres'
|
|
||||||
|
|
||||||
const TEST_DB_URL =
|
|
||||||
process.env.DATABASE_URL?.replace(/\/forte$/, '/forte_test') ??
|
|
||||||
'postgresql://forte:forte@localhost:5432/forte_test'
|
|
||||||
|
|
||||||
// Override DATABASE_URL for all tests to use forte_test
|
|
||||||
process.env.DATABASE_URL = TEST_DB_URL
|
|
||||||
process.env.NODE_ENV = 'test'
|
|
||||||
process.env.LOG_LEVEL = 'silent'
|
|
||||||
process.env.JWT_SECRET = 'test-secret-for-jwt-signing'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure the forte_test database exists before tests run.
|
|
||||||
*/
|
|
||||||
async function ensureTestDb() {
|
|
||||||
const adminUrl = TEST_DB_URL.replace(/\/forte_test$/, '/postgres')
|
|
||||||
const sql = postgres(adminUrl)
|
|
||||||
|
|
||||||
const result = await sql`SELECT 1 FROM pg_database WHERE datname = 'forte_test'`
|
|
||||||
if (result.length === 0) {
|
|
||||||
await sql`CREATE DATABASE forte_test`
|
|
||||||
}
|
|
||||||
await sql.end()
|
|
||||||
}
|
|
||||||
|
|
||||||
await ensureTestDb()
|
|
||||||
Reference in New Issue
Block a user