Full-stack implementation of instrument repair tracking: DB schema with repair_ticket, repair_line_item, repair_batch, and repair_service_template tables. Backend services and routes with pagination/search/sort. 20 API tests covering CRUD, status workflow, line items, and batch operations. Admin frontend with ticket list, detail with status progression, line item management, batch list/detail with approval workflow, and new ticket form with searchable account picker and intake photo uploads.
266 lines
12 KiB
TypeScript
266 lines
12 KiB
TypeScript
import { suite } from '../lib/context.js'
|
|
|
|
suite('Repairs', { tags: ['repairs', 'crud'] }, (t) => {
|
|
// --- Repair Tickets ---
|
|
|
|
t.test('creates a repair ticket (walk-in)', { tags: ['tickets', 'create'] }, async () => {
|
|
const res = await t.api.post('/v1/repair-tickets', {
|
|
customerName: 'Walk-In Customer',
|
|
customerPhone: '555-0100',
|
|
instrumentDescription: 'Yamaha Trumpet',
|
|
problemDescription: 'Stuck valve, needs cleaning',
|
|
conditionIn: 'fair',
|
|
})
|
|
t.assert.status(res, 201)
|
|
t.assert.equal(res.data.customerName, 'Walk-In Customer')
|
|
t.assert.equal(res.data.status, 'intake')
|
|
t.assert.ok(res.data.ticketNumber, 'should auto-generate ticket number')
|
|
t.assert.equal(res.data.ticketNumber.length, 6)
|
|
t.assert.ok(res.data.id)
|
|
})
|
|
|
|
t.test('creates a repair ticket with account link', { tags: ['tickets', 'create'] }, async () => {
|
|
const acct = await t.api.post('/v1/accounts', { name: 'Repair Customer', billingMode: 'consolidated' })
|
|
t.assert.status(acct, 201)
|
|
|
|
const res = await t.api.post('/v1/repair-tickets', {
|
|
customerName: 'Repair Customer',
|
|
accountId: acct.data.id,
|
|
problemDescription: 'Broken bridge on violin',
|
|
instrumentDescription: 'Student Violin 4/4',
|
|
conditionIn: 'poor',
|
|
})
|
|
t.assert.status(res, 201)
|
|
t.assert.equal(res.data.accountId, acct.data.id)
|
|
})
|
|
|
|
t.test('lists repair tickets with pagination', { tags: ['tickets', 'read', 'pagination'] }, async () => {
|
|
await t.api.post('/v1/repair-tickets', { customerName: 'List Test A', problemDescription: 'Issue A' })
|
|
await t.api.post('/v1/repair-tickets', { customerName: 'List Test B', problemDescription: 'Issue B' })
|
|
|
|
const res = await t.api.get('/v1/repair-tickets', { limit: 100 })
|
|
t.assert.status(res, 200)
|
|
t.assert.ok(res.data.data.length >= 2)
|
|
t.assert.ok(res.data.pagination.total >= 2)
|
|
})
|
|
|
|
t.test('searches repair tickets by customer name', { tags: ['tickets', 'search'] }, async () => {
|
|
await t.api.post('/v1/repair-tickets', { customerName: 'Searchable Trumpet Guy', problemDescription: 'Dent' })
|
|
|
|
const res = await t.api.get('/v1/repair-tickets', { q: 'Trumpet Guy' })
|
|
t.assert.status(res, 200)
|
|
t.assert.ok(res.data.data.some((t: { customerName: string }) => t.customerName === 'Searchable Trumpet Guy'))
|
|
})
|
|
|
|
t.test('gets repair ticket by id', { tags: ['tickets', 'read'] }, async () => {
|
|
const created = await t.api.post('/v1/repair-tickets', { customerName: 'Get By ID', problemDescription: 'Test' })
|
|
const res = await t.api.get(`/v1/repair-tickets/${created.data.id}`)
|
|
t.assert.status(res, 200)
|
|
t.assert.equal(res.data.customerName, 'Get By ID')
|
|
})
|
|
|
|
t.test('returns 404 for missing repair ticket', { tags: ['tickets', 'read'] }, async () => {
|
|
const res = await t.api.get('/v1/repair-tickets/a0000000-0000-0000-0000-999999999999')
|
|
t.assert.status(res, 404)
|
|
})
|
|
|
|
t.test('updates a repair ticket', { tags: ['tickets', 'update'] }, async () => {
|
|
const created = await t.api.post('/v1/repair-tickets', { customerName: 'Before Update', problemDescription: 'Old issue' })
|
|
const res = await t.api.patch(`/v1/repair-tickets/${created.data.id}`, { customerName: 'After Update', estimatedCost: 150 })
|
|
t.assert.status(res, 200)
|
|
t.assert.equal(res.data.customerName, 'After Update')
|
|
t.assert.equal(res.data.estimatedCost, '150.00')
|
|
})
|
|
|
|
t.test('updates ticket status through lifecycle', { tags: ['tickets', 'status'] }, async () => {
|
|
const created = await t.api.post('/v1/repair-tickets', { customerName: 'Status Test', problemDescription: 'Lifecycle' })
|
|
t.assert.equal(created.data.status, 'intake')
|
|
|
|
const diag = await t.api.post(`/v1/repair-tickets/${created.data.id}/status`, { status: 'diagnosing' })
|
|
t.assert.status(diag, 200)
|
|
t.assert.equal(diag.data.status, 'diagnosing')
|
|
|
|
const prog = await t.api.post(`/v1/repair-tickets/${created.data.id}/status`, { status: 'in_progress' })
|
|
t.assert.equal(prog.data.status, 'in_progress')
|
|
|
|
const ready = await t.api.post(`/v1/repair-tickets/${created.data.id}/status`, { status: 'ready' })
|
|
t.assert.equal(ready.data.status, 'ready')
|
|
t.assert.ok(ready.data.completedDate, 'should set completedDate on ready')
|
|
|
|
const picked = await t.api.post(`/v1/repair-tickets/${created.data.id}/status`, { status: 'picked_up' })
|
|
t.assert.equal(picked.data.status, 'picked_up')
|
|
})
|
|
|
|
t.test('soft-deletes (cancels) a repair ticket', { tags: ['tickets', 'delete'] }, async () => {
|
|
const created = await t.api.post('/v1/repair-tickets', { customerName: 'To Cancel', problemDescription: 'Cancel me' })
|
|
const res = await t.api.del(`/v1/repair-tickets/${created.data.id}`)
|
|
t.assert.status(res, 200)
|
|
t.assert.equal(res.data.status, 'cancelled')
|
|
})
|
|
|
|
t.test('sorts tickets by customer name descending', { tags: ['tickets', 'sort'] }, async () => {
|
|
await t.api.post('/v1/repair-tickets', { customerName: 'AAA Sort First', problemDescription: 'Sort test' })
|
|
await t.api.post('/v1/repair-tickets', { customerName: 'ZZZ Sort Last', problemDescription: 'Sort test' })
|
|
|
|
const res = await t.api.get('/v1/repair-tickets', { sort: 'customer_name', order: 'desc', limit: 100 })
|
|
t.assert.status(res, 200)
|
|
const names = res.data.data.map((t: { customerName: string }) => t.customerName)
|
|
const zIdx = names.findIndex((n: string) => n.includes('ZZZ'))
|
|
const aIdx = names.findIndex((n: string) => n.includes('AAA'))
|
|
t.assert.ok(zIdx < aIdx, 'ZZZ should come before AAA in desc order')
|
|
})
|
|
|
|
// --- Repair Line Items ---
|
|
|
|
t.test('creates line items (labor, part, flat_rate)', { tags: ['line-items', 'create'] }, async () => {
|
|
const ticket = await t.api.post('/v1/repair-tickets', { customerName: 'Line Item Test', problemDescription: 'Needs work' })
|
|
|
|
const labor = await t.api.post(`/v1/repair-tickets/${ticket.data.id}/line-items`, {
|
|
itemType: 'labor',
|
|
description: 'Mechanical overhaul',
|
|
qty: 2.5,
|
|
unitPrice: 65,
|
|
totalPrice: 162.50,
|
|
})
|
|
t.assert.status(labor, 201)
|
|
t.assert.equal(labor.data.itemType, 'labor')
|
|
t.assert.equal(labor.data.description, 'Mechanical overhaul')
|
|
|
|
const part = await t.api.post(`/v1/repair-tickets/${ticket.data.id}/line-items`, {
|
|
itemType: 'part',
|
|
description: 'Valve guide',
|
|
qty: 3,
|
|
unitPrice: 2.50,
|
|
totalPrice: 7.50,
|
|
})
|
|
t.assert.status(part, 201)
|
|
t.assert.equal(part.data.itemType, 'part')
|
|
|
|
const flatRate = await t.api.post(`/v1/repair-tickets/${ticket.data.id}/line-items`, {
|
|
itemType: 'flat_rate',
|
|
description: 'Bow Rehair — Full Size',
|
|
qty: 1,
|
|
unitPrice: 50,
|
|
totalPrice: 50,
|
|
})
|
|
t.assert.status(flatRate, 201)
|
|
t.assert.equal(flatRate.data.itemType, 'flat_rate')
|
|
})
|
|
|
|
t.test('lists line items for a ticket', { tags: ['line-items', 'read'] }, async () => {
|
|
const ticket = await t.api.post('/v1/repair-tickets', { customerName: 'List Items', problemDescription: 'Test' })
|
|
await t.api.post(`/v1/repair-tickets/${ticket.data.id}/line-items`, { itemType: 'labor', description: 'Work A', qty: 1, unitPrice: 50, totalPrice: 50 })
|
|
await t.api.post(`/v1/repair-tickets/${ticket.data.id}/line-items`, { itemType: 'part', description: 'Part B', qty: 2, unitPrice: 10, totalPrice: 20 })
|
|
|
|
const res = await t.api.get(`/v1/repair-tickets/${ticket.data.id}/line-items`, { limit: 100 })
|
|
t.assert.status(res, 200)
|
|
t.assert.equal(res.data.data.length, 2)
|
|
t.assert.ok(res.data.pagination)
|
|
})
|
|
|
|
t.test('updates a line item', { tags: ['line-items', 'update'] }, async () => {
|
|
const ticket = await t.api.post('/v1/repair-tickets', { customerName: 'Update Item', problemDescription: 'Test' })
|
|
const item = await t.api.post(`/v1/repair-tickets/${ticket.data.id}/line-items`, { itemType: 'labor', description: 'Old desc', qty: 1, unitPrice: 50, totalPrice: 50 })
|
|
|
|
const res = await t.api.patch(`/v1/repair-line-items/${item.data.id}`, { description: 'New desc', unitPrice: 75, totalPrice: 75 })
|
|
t.assert.status(res, 200)
|
|
t.assert.equal(res.data.description, 'New desc')
|
|
t.assert.equal(res.data.unitPrice, '75.00')
|
|
})
|
|
|
|
t.test('deletes a line item', { tags: ['line-items', 'delete'] }, async () => {
|
|
const ticket = await t.api.post('/v1/repair-tickets', { customerName: 'Delete Item', problemDescription: 'Test' })
|
|
const item = await t.api.post(`/v1/repair-tickets/${ticket.data.id}/line-items`, { itemType: 'misc', description: 'To delete', qty: 1, unitPrice: 10, totalPrice: 10 })
|
|
|
|
const res = await t.api.del(`/v1/repair-line-items/${item.data.id}`)
|
|
t.assert.status(res, 200)
|
|
|
|
const list = await t.api.get(`/v1/repair-tickets/${ticket.data.id}/line-items`, { limit: 100 })
|
|
t.assert.equal(list.data.data.length, 0)
|
|
})
|
|
|
|
// --- Repair Batches ---
|
|
|
|
t.test('creates a repair batch', { tags: ['batches', 'create'] }, async () => {
|
|
const acct = await t.api.post('/v1/accounts', { name: 'Lincoln High School', billingMode: 'consolidated' })
|
|
|
|
const res = await t.api.post('/v1/repair-batches', {
|
|
accountId: acct.data.id,
|
|
contactName: 'Band Director',
|
|
contactPhone: '555-0200',
|
|
instrumentCount: 15,
|
|
notes: 'Annual instrument checkup',
|
|
})
|
|
t.assert.status(res, 201)
|
|
t.assert.ok(res.data.batchNumber)
|
|
t.assert.equal(res.data.batchNumber.length, 6)
|
|
t.assert.equal(res.data.status, 'intake')
|
|
t.assert.equal(res.data.approvalStatus, 'pending')
|
|
t.assert.equal(res.data.instrumentCount, 15)
|
|
})
|
|
|
|
t.test('lists repair batches', { tags: ['batches', 'read'] }, async () => {
|
|
const res = await t.api.get('/v1/repair-batches', { limit: 100 })
|
|
t.assert.status(res, 200)
|
|
t.assert.ok(res.data.data.length >= 1)
|
|
t.assert.ok(res.data.pagination)
|
|
})
|
|
|
|
t.test('adds tickets to a batch', { tags: ['batches', 'tickets'] }, async () => {
|
|
const acct = await t.api.post('/v1/accounts', { name: 'Batch School', billingMode: 'consolidated' })
|
|
const batch = await t.api.post('/v1/repair-batches', { accountId: acct.data.id, instrumentCount: 2 })
|
|
|
|
const ticket1 = await t.api.post('/v1/repair-tickets', {
|
|
customerName: 'Batch School',
|
|
repairBatchId: batch.data.id,
|
|
problemDescription: 'Flute needs pads',
|
|
instrumentDescription: 'Flute',
|
|
})
|
|
const ticket2 = await t.api.post('/v1/repair-tickets', {
|
|
customerName: 'Batch School',
|
|
repairBatchId: batch.data.id,
|
|
problemDescription: 'Clarinet needs cork',
|
|
instrumentDescription: 'Clarinet',
|
|
})
|
|
|
|
t.assert.equal(ticket1.data.repairBatchId, batch.data.id)
|
|
t.assert.equal(ticket2.data.repairBatchId, batch.data.id)
|
|
|
|
const tickets = await t.api.get(`/v1/repair-batches/${batch.data.id}/tickets`, { limit: 100 })
|
|
t.assert.status(tickets, 200)
|
|
t.assert.equal(tickets.data.data.length, 2)
|
|
})
|
|
|
|
t.test('approves a batch', { tags: ['batches', 'approve'] }, async () => {
|
|
const acct = await t.api.post('/v1/accounts', { name: 'Approve School', billingMode: 'consolidated' })
|
|
const batch = await t.api.post('/v1/repair-batches', { accountId: acct.data.id })
|
|
|
|
const res = await t.api.post(`/v1/repair-batches/${batch.data.id}/approve`, {})
|
|
t.assert.status(res, 200)
|
|
t.assert.equal(res.data.approvalStatus, 'approved')
|
|
t.assert.ok(res.data.approvedBy)
|
|
t.assert.ok(res.data.approvedAt)
|
|
})
|
|
|
|
t.test('rejects a batch', { tags: ['batches', 'reject'] }, async () => {
|
|
const acct = await t.api.post('/v1/accounts', { name: 'Reject School', billingMode: 'consolidated' })
|
|
const batch = await t.api.post('/v1/repair-batches', { accountId: acct.data.id })
|
|
|
|
const res = await t.api.post(`/v1/repair-batches/${batch.data.id}/reject`, {})
|
|
t.assert.status(res, 200)
|
|
t.assert.equal(res.data.approvalStatus, 'rejected')
|
|
})
|
|
|
|
t.test('updates batch status', { tags: ['batches', 'status'] }, async () => {
|
|
const acct = await t.api.post('/v1/accounts', { name: 'Status School', billingMode: 'consolidated' })
|
|
const batch = await t.api.post('/v1/repair-batches', { accountId: acct.data.id })
|
|
|
|
const prog = await t.api.post(`/v1/repair-batches/${batch.data.id}/status`, { status: 'in_progress' })
|
|
t.assert.equal(prog.data.status, 'in_progress')
|
|
|
|
const done = await t.api.post(`/v1/repair-batches/${batch.data.id}/status`, { status: 'completed' })
|
|
t.assert.equal(done.data.status, 'completed')
|
|
t.assert.ok(done.data.completedDate)
|
|
})
|
|
})
|