From 21ef7e7059d01ce41ea1523c219a80d80bc2154f Mon Sep 17 00:00:00 2001 From: Ryan Moon Date: Sun, 29 Mar 2026 11:56:06 -0500 Subject: [PATCH] Expand repair tests to 43 cases, fix default status to new MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comprehensive test coverage for repairs: full status lifecycle (new → picked_up), in_transit branch, pending_parts round-trip, delivered alternate ending, reopen cancelled, validation errors, search by instrument, filter by status and isBatch, notes CRUD with visibility and status capture, service templates CRUD with soft-delete, signed URL generation and access. Migration to set column default to new. 107 total API tests passing. --- packages/backend/api-tests/suites/repairs.ts | 402 ++++++++++++++---- .../db/migrations/0020_repair_default_new.sql | 1 + .../src/db/migrations/meta/_journal.json | 7 + 3 files changed, 332 insertions(+), 78 deletions(-) create mode 100644 packages/backend/src/db/migrations/0020_repair_default_new.sql diff --git a/packages/backend/api-tests/suites/repairs.ts b/packages/backend/api-tests/suites/repairs.ts index 24c42ba..0b35dd2 100644 --- a/packages/backend/api-tests/suites/repairs.ts +++ b/packages/backend/api-tests/suites/repairs.ts @@ -1,9 +1,9 @@ import { suite } from '../lib/context.js' -suite('Repairs', { tags: ['repairs', 'crud'] }, (t) => { - // --- Repair Tickets --- +suite('Repairs', { tags: ['repairs'] }, (t) => { + // ─── Repair Tickets: CRUD ─── - t.test('creates a repair ticket (walk-in)', { tags: ['tickets', 'create'] }, async () => { + t.test('creates a repair ticket (walk-in) with default status new', { tags: ['tickets', 'create'] }, async () => { const res = await t.api.post('/v1/repair-tickets', { customerName: 'Walk-In Customer', customerPhone: '555-0100', @@ -13,16 +13,15 @@ suite('Repairs', { tags: ['repairs', 'crud'] }, (t) => { }) 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.status, 'new') + t.assert.ok(res.data.ticketNumber) t.assert.equal(res.data.ticketNumber.length, 6) t.assert.ok(res.data.id) + t.assert.ok(res.data.intakeDate) }) 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, @@ -34,22 +33,14 @@ suite('Repairs', { tags: ['repairs', 'crud'] }, (t) => { 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('rejects ticket creation without required fields', { tags: ['tickets', 'create', 'validation'] }, async () => { + const res = await t.api.post('/v1/repair-tickets', {}) + t.assert.status(res, 400) }) - 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('rejects ticket without problem description', { tags: ['tickets', 'create', 'validation'] }, async () => { + const res = await t.api.post('/v1/repair-tickets', { customerName: 'Test' }) + t.assert.status(res, 400) }) t.test('gets repair ticket by id', { tags: ['tickets', 'read'] }, async () => { @@ -66,20 +57,42 @@ suite('Repairs', { tags: ['repairs', 'crud'] }, (t) => { 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 }) + const res = await t.api.patch(`/v1/repair-tickets/${created.data.id}`, { + customerName: 'After Update', + estimatedCost: 150, + technicianNotes: 'Needs full overhaul', + }) t.assert.status(res, 200) t.assert.equal(res.data.customerName, 'After Update') t.assert.equal(res.data.estimatedCost, '150.00') + t.assert.equal(res.data.technicianNotes, 'Needs full overhaul') }) - 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') + 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') + }) + + // ─── Repair Tickets: Status Flow ─── + + t.test('full status lifecycle: new → intake → diagnosing → pending_approval → approved → in_progress → ready → picked_up', { tags: ['tickets', 'status'] }, async () => { + const created = await t.api.post('/v1/repair-tickets', { customerName: 'Full Flow', problemDescription: 'Lifecycle test' }) + t.assert.equal(created.data.status, 'new') + + const intake = await t.api.post(`/v1/repair-tickets/${created.data.id}/status`, { status: 'intake' }) + t.assert.equal(intake.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 pending = await t.api.post(`/v1/repair-tickets/${created.data.id}/status`, { status: 'pending_approval' }) + t.assert.equal(pending.data.status, 'pending_approval') + + const approved = await t.api.post(`/v1/repair-tickets/${created.data.id}/status`, { status: 'approved' }) + t.assert.equal(approved.data.status, 'approved') + const prog = await t.api.post(`/v1/repair-tickets/${created.data.id}/status`, { status: 'in_progress' }) t.assert.equal(prog.data.status, 'in_progress') @@ -91,16 +104,77 @@ suite('Repairs', { tags: ['repairs', 'crud'] }, (t) => { 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.test('in_transit branch: new → in_transit → intake', { tags: ['tickets', 'status'] }, async () => { + const created = await t.api.post('/v1/repair-tickets', { customerName: 'Transit Test', problemDescription: 'School pickup' }) + t.assert.equal(created.data.status, 'new') + + const transit = await t.api.post(`/v1/repair-tickets/${created.data.id}/status`, { status: 'in_transit' }) + t.assert.equal(transit.data.status, 'in_transit') + + const intake = await t.api.post(`/v1/repair-tickets/${created.data.id}/status`, { status: 'intake' }) + t.assert.equal(intake.data.status, 'intake') + }) + + t.test('pending_parts branch: in_progress → pending_parts → in_progress', { tags: ['tickets', 'status'] }, async () => { + const created = await t.api.post('/v1/repair-tickets', { customerName: 'Parts Test', problemDescription: 'Needs parts' }) + await t.api.post(`/v1/repair-tickets/${created.data.id}/status`, { status: 'intake' }) + await t.api.post(`/v1/repair-tickets/${created.data.id}/status`, { status: 'in_progress' }) + + const pending = await t.api.post(`/v1/repair-tickets/${created.data.id}/status`, { status: 'pending_parts' }) + t.assert.equal(pending.data.status, 'pending_parts') + + const back = await t.api.post(`/v1/repair-tickets/${created.data.id}/status`, { status: 'in_progress' }) + t.assert.equal(back.data.status, 'in_progress') + }) + + t.test('delivered as alternate ending', { tags: ['tickets', 'status'] }, async () => { + const created = await t.api.post('/v1/repair-tickets', { customerName: 'Delivery Test', problemDescription: 'School batch' }) + await t.api.post(`/v1/repair-tickets/${created.data.id}/status`, { status: 'ready' }) + + const delivered = await t.api.post(`/v1/repair-tickets/${created.data.id}/status`, { status: 'delivered' }) + t.assert.equal(delivered.data.status, 'delivered') + t.assert.ok(delivered.data.completedDate) + }) + + t.test('reopen cancelled ticket back to new', { tags: ['tickets', 'status'] }, async () => { + const created = await t.api.post('/v1/repair-tickets', { customerName: 'Reopen Test', problemDescription: 'Cancel then reopen' }) + await t.api.post(`/v1/repair-tickets/${created.data.id}/status`, { status: 'cancelled' }) + + const reopened = await t.api.post(`/v1/repair-tickets/${created.data.id}/status`, { status: 'new' }) + t.assert.equal(reopened.data.status, 'new') + }) + + // ─── Repair Tickets: List, Search, Sort, Filter ─── + + t.test('lists repair tickets with pagination', { tags: ['tickets', 'read', 'pagination'] }, async () => { + await t.api.post('/v1/repair-tickets', { customerName: 'Page Test A', problemDescription: 'A' }) + await t.api.post('/v1/repair-tickets', { customerName: 'Page Test B', problemDescription: 'B' }) + + const res = await t.api.get('/v1/repair-tickets', { limit: 100 }) t.assert.status(res, 200) - t.assert.equal(res.data.status, 'cancelled') + t.assert.ok(res.data.data.length >= 2) + t.assert.ok(res.data.pagination.total >= 2) + }) + + t.test('searches 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('searches tickets by instrument description', { tags: ['tickets', 'search'] }, async () => { + await t.api.post('/v1/repair-tickets', { customerName: 'Instrument Search', problemDescription: 'Test', instrumentDescription: 'Selmer Mark VI Saxophone' }) + + const res = await t.api.get('/v1/repair-tickets', { q: 'Mark VI' }) + t.assert.status(res, 200) + t.assert.ok(res.data.data.some((t: { instrumentDescription: string }) => t.instrumentDescription?.includes('Mark VI'))) }) 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' }) + await t.api.post('/v1/repair-tickets', { customerName: 'AAA Sort First', problemDescription: 'Sort' }) + await t.api.post('/v1/repair-tickets', { customerName: 'ZZZ Sort Last', problemDescription: 'Sort' }) const res = await t.api.get('/v1/repair-tickets', { sort: 'customer_name', order: 'desc', limit: 100 }) t.assert.status(res, 200) @@ -110,41 +184,56 @@ suite('Repairs', { tags: ['repairs', 'crud'] }, (t) => { t.assert.ok(zIdx < aIdx, 'ZZZ should come before AAA in desc order') }) - // --- Repair Line Items --- + t.test('filters tickets by status', { tags: ['tickets', 'filter'] }, async () => { + const ticket = await t.api.post('/v1/repair-tickets', { customerName: 'Filter Status', problemDescription: 'Test' }) + await t.api.post(`/v1/repair-tickets/${ticket.data.id}/status`, { status: 'intake' }) - t.test('creates line items (labor, part, flat_rate)', { tags: ['line-items', 'create'] }, async () => { + const res = await t.api.get('/v1/repair-tickets', { status: 'intake', limit: 100 }) + t.assert.status(res, 200) + t.assert.ok(res.data.data.every((t: { status: string }) => t.status === 'intake')) + }) + + t.test('filters tickets by isBatch', { tags: ['tickets', 'filter'] }, async () => { + const acct = await t.api.post('/v1/accounts', { name: 'Filter Batch School', billingMode: 'consolidated' }) + const batch = await t.api.post('/v1/repair-batches', { accountId: acct.data.id }) + await t.api.post('/v1/repair-tickets', { customerName: 'Batch Ticket', repairBatchId: batch.data.id, problemDescription: 'In batch' }) + + const batchOnly = await t.api.get('/v1/repair-tickets', { isBatch: 'true', limit: 100 }) + t.assert.status(batchOnly, 200) + t.assert.ok(batchOnly.data.data.every((t: { repairBatchId: string | null }) => t.repairBatchId !== null)) + + const individualOnly = await t.api.get('/v1/repair-tickets', { isBatch: 'false', limit: 100 }) + t.assert.status(individualOnly, 200) + t.assert.ok(individualOnly.data.data.every((t: { repairBatchId: string | null }) => t.repairBatchId === null)) + }) + + // ─── Repair Line Items ─── + + t.test('creates line items (labor, part, flat_rate, misc)', { 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, + 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, + itemType: 'part', description: 'Valve guide', qty: 3, unitPrice: 2.50, totalPrice: 7.50, cost: 1.25, }) t.assert.status(part, 201) - t.assert.equal(part.data.itemType, 'part') + t.assert.equal(part.data.cost, '1.25') 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, + itemType: 'flat_rate', description: 'Bow Rehair', qty: 1, unitPrice: 50, totalPrice: 50, }) t.assert.status(flatRate, 201) - t.assert.equal(flatRate.data.itemType, 'flat_rate') + + const misc = await t.api.post(`/v1/repair-tickets/${ticket.data.id}/line-items`, { + itemType: 'misc', description: 'Expedite fee', qty: 1, unitPrice: 15, totalPrice: 15, + }) + t.assert.status(misc, 201) + t.assert.equal(misc.data.itemType, 'misc') }) t.test('lists line items for a ticket', { tags: ['line-items', 'read'] }, async () => { @@ -160,11 +249,11 @@ suite('Repairs', { tags: ['repairs', 'crud'] }, (t) => { 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 item = await t.api.post(`/v1/repair-tickets/${ticket.data.id}/line-items`, { itemType: 'labor', description: 'Old', 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 }) + const res = await t.api.patch(`/v1/repair-line-items/${item.data.id}`, { description: 'New', unitPrice: 75, totalPrice: 75 }) t.assert.status(res, 200) - t.assert.equal(res.data.description, 'New desc') + t.assert.equal(res.data.description, 'New') t.assert.equal(res.data.unitPrice, '75.00') }) @@ -179,7 +268,71 @@ suite('Repairs', { tags: ['repairs', 'crud'] }, (t) => { t.assert.equal(list.data.data.length, 0) }) - // --- Repair Batches --- + // ─── Repair Notes ─── + + t.test('creates a note on a ticket', { tags: ['notes', 'create'] }, async () => { + const ticket = await t.api.post('/v1/repair-tickets', { customerName: 'Note Test', problemDescription: 'Test' }) + + const res = await t.api.post(`/v1/repair-tickets/${ticket.data.id}/notes`, { + content: 'Valve is corroded, needs replacement', + visibility: 'internal', + }) + t.assert.status(res, 201) + t.assert.equal(res.data.content, 'Valve is corroded, needs replacement') + t.assert.equal(res.data.visibility, 'internal') + t.assert.ok(res.data.authorName) + t.assert.ok(res.data.authorId) + t.assert.equal(res.data.ticketStatus, 'new') + }) + + t.test('creates a customer-visible note', { tags: ['notes', 'create'] }, async () => { + const ticket = await t.api.post('/v1/repair-tickets', { customerName: 'Customer Note', problemDescription: 'Test' }) + + const res = await t.api.post(`/v1/repair-tickets/${ticket.data.id}/notes`, { + content: 'Your instrument is ready for pickup', + visibility: 'customer', + }) + t.assert.status(res, 201) + t.assert.equal(res.data.visibility, 'customer') + }) + + t.test('lists notes for a ticket in chronological order', { tags: ['notes', 'read'] }, async () => { + const ticket = await t.api.post('/v1/repair-tickets', { customerName: 'List Notes', problemDescription: 'Test' }) + await t.api.post(`/v1/repair-tickets/${ticket.data.id}/notes`, { content: 'First note' }) + await t.api.post(`/v1/repair-tickets/${ticket.data.id}/notes`, { content: 'Second note' }) + + const res = await t.api.get(`/v1/repair-tickets/${ticket.data.id}/notes`) + t.assert.status(res, 200) + t.assert.equal(res.data.data.length, 2) + t.assert.equal(res.data.data[0].content, 'First note') + t.assert.equal(res.data.data[1].content, 'Second note') + }) + + t.test('note captures ticket status at time of creation', { tags: ['notes', 'status'] }, async () => { + const ticket = await t.api.post('/v1/repair-tickets', { customerName: 'Status Note', problemDescription: 'Test' }) + await t.api.post(`/v1/repair-tickets/${ticket.data.id}/status`, { status: 'in_progress' }) + + const note = await t.api.post(`/v1/repair-tickets/${ticket.data.id}/notes`, { content: 'Working on it' }) + t.assert.equal(note.data.ticketStatus, 'in_progress') + }) + + t.test('deletes a note', { tags: ['notes', 'delete'] }, async () => { + const ticket = await t.api.post('/v1/repair-tickets', { customerName: 'Delete Note', problemDescription: 'Test' }) + const note = await t.api.post(`/v1/repair-tickets/${ticket.data.id}/notes`, { content: 'To delete' }) + + const res = await t.api.del(`/v1/repair-notes/${note.data.id}`) + t.assert.status(res, 200) + + const list = await t.api.get(`/v1/repair-tickets/${ticket.data.id}/notes`) + t.assert.equal(list.data.data.length, 0) + }) + + t.test('returns 404 when creating note for missing ticket', { tags: ['notes', 'validation'] }, async () => { + const res = await t.api.post('/v1/repair-tickets/a0000000-0000-0000-0000-999999999999/notes', { content: 'Test' }) + t.assert.status(res, 404) + }) + + // ─── 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' }) @@ -199,40 +352,51 @@ suite('Repairs', { tags: ['repairs', 'crud'] }, (t) => { t.assert.equal(res.data.instrumentCount, 15) }) - t.test('lists repair batches', { tags: ['batches', 'read'] }, async () => { + t.test('returns 404 for missing batch', { tags: ['batches', 'read'] }, async () => { + const res = await t.api.get('/v1/repair-batches/a0000000-0000-0000-0000-999999999999') + t.assert.status(res, 404) + }) + + t.test('lists repair batches with pagination', { tags: ['batches', 'read', 'pagination'] }, 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' }) + t.test('searches batches by contact name', { tags: ['batches', 'search'] }, async () => { + const acct = await t.api.post('/v1/accounts', { name: 'Search Batch School', billingMode: 'consolidated' }) + await t.api.post('/v1/repair-batches', { accountId: acct.data.id, contactName: 'Findable Director' }) + + const res = await t.api.get('/v1/repair-batches', { q: 'Findable', limit: 100 }) + t.assert.status(res, 200) + t.assert.ok(res.data.data.some((b: { contactName: string }) => b.contactName === 'Findable Director')) + }) + + t.test('updates a batch', { tags: ['batches', 'update'] }, async () => { + const acct = await t.api.post('/v1/accounts', { name: 'Update Batch School', billingMode: 'consolidated' }) + const batch = await t.api.post('/v1/repair-batches', { accountId: acct.data.id, instrumentCount: 5 }) + + const res = await t.api.patch(`/v1/repair-batches/${batch.data.id}`, { instrumentCount: 10, contactName: 'Updated Director' }) + t.assert.status(res, 200) + t.assert.equal(res.data.contactName, 'Updated Director') + }) + + t.test('adds tickets to a batch and lists them', { tags: ['batches', 'tickets'] }, async () => { + const acct = await t.api.post('/v1/accounts', { name: 'Batch Tickets 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) + await t.api.post('/v1/repair-tickets', { customerName: 'Batch Tickets School', repairBatchId: batch.data.id, problemDescription: 'Flute pads', instrumentDescription: 'Flute' }) + await t.api.post('/v1/repair-tickets', { customerName: 'Batch Tickets School', repairBatchId: batch.data.id, problemDescription: 'Clarinet cork', instrumentDescription: 'Clarinet' }) 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.assert.ok(tickets.data.pagination) }) t.test('approves a batch', { tags: ['batches', 'approve'] }, async () => { - const acct = await t.api.post('/v1/accounts', { name: 'Approve School', billingMode: 'consolidated' }) + const acct = await t.api.post('/v1/accounts', { name: 'Approve Batch 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`, {}) @@ -243,7 +407,7 @@ suite('Repairs', { tags: ['repairs', 'crud'] }, (t) => { }) t.test('rejects a batch', { tags: ['batches', 'reject'] }, async () => { - const acct = await t.api.post('/v1/accounts', { name: 'Reject School', billingMode: 'consolidated' }) + const acct = await t.api.post('/v1/accounts', { name: 'Reject Batch 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`, {}) @@ -251,8 +415,8 @@ suite('Repairs', { tags: ['repairs', 'crud'] }, (t) => { 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' }) + t.test('updates batch status with timestamps', { tags: ['batches', 'status'] }, async () => { + const acct = await t.api.post('/v1/accounts', { name: 'Status Batch 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' }) @@ -261,5 +425,87 @@ suite('Repairs', { tags: ['repairs', 'crud'] }, (t) => { 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) + + const delivered = await t.api.post(`/v1/repair-batches/${batch.data.id}/status`, { status: 'delivered' }) + t.assert.equal(delivered.data.status, 'delivered') + t.assert.ok(delivered.data.deliveredDate) + }) + + // ─── Service Templates ─── + + t.test('creates a service template', { tags: ['templates', 'create'] }, async () => { + const res = await t.api.post('/v1/repair-service-templates', { + name: 'Bow Rehair', + instrumentType: 'Violin', + size: '4/4', + itemType: 'flat_rate', + defaultPrice: 65, + defaultCost: 15, + }) + t.assert.status(res, 201) + t.assert.equal(res.data.name, 'Bow Rehair') + t.assert.equal(res.data.instrumentType, 'Violin') + t.assert.equal(res.data.size, '4/4') + t.assert.equal(res.data.defaultPrice, '65.00') + t.assert.equal(res.data.defaultCost, '15.00') + }) + + t.test('lists service templates with search', { tags: ['templates', 'read'] }, async () => { + await t.api.post('/v1/repair-service-templates', { name: 'String Change', instrumentType: 'Guitar', defaultPrice: 25 }) + + const res = await t.api.get('/v1/repair-service-templates', { q: 'String', limit: 100 }) + t.assert.status(res, 200) + t.assert.ok(res.data.data.some((t: { name: string }) => t.name === 'String Change')) + t.assert.ok(res.data.pagination) + }) + + t.test('updates a service template', { tags: ['templates', 'update'] }, async () => { + const created = await t.api.post('/v1/repair-service-templates', { name: 'Pad Replace', defaultPrice: 30 }) + const res = await t.api.patch(`/v1/repair-service-templates/${created.data.id}`, { defaultPrice: 35, instrumentType: 'Clarinet' }) + t.assert.status(res, 200) + t.assert.equal(res.data.defaultPrice, '35.00') + t.assert.equal(res.data.instrumentType, 'Clarinet') + }) + + t.test('soft-deletes a service template', { tags: ['templates', 'delete'] }, async () => { + const created = await t.api.post('/v1/repair-service-templates', { name: 'To Delete Template', defaultPrice: 10 }) + const res = await t.api.del(`/v1/repair-service-templates/${created.data.id}`) + t.assert.status(res, 200) + t.assert.equal(res.data.isActive, false) + + // Should not appear in list (list filters active only) + const list = await t.api.get('/v1/repair-service-templates', { q: 'To Delete Template', limit: 100 }) + t.assert.equal(list.data.data.length, 0) + }) + + // ─── Signed URLs ─── + + t.test('generates a signed URL for a file', { tags: ['files', 'signed-url'] }, async () => { + // Upload a file first + const ticket = await t.api.post('/v1/repair-tickets', { customerName: 'Signed URL Test', problemDescription: 'Test' }) + + const formData = new FormData() + formData.append('entityType', 'repair_ticket') + formData.append('entityId', ticket.data.id) + formData.append('category', 'intake') + formData.append('file', new Blob(['test image data'], { type: 'image/jpeg' }), 'test.jpg') + + const uploadRes = await fetch(`${t.baseUrl}/v1/files`, { + method: 'POST', + headers: { Authorization: `Bearer ${t.token}` }, + body: formData, + }) + t.assert.equal(uploadRes.status, 201) + const uploaded = await uploadRes.json() as { id: string } + + // Get signed URL + const signedRes = await t.api.get(`/v1/files/signed-url/${uploaded.id}`) + t.assert.status(signedRes, 200) + t.assert.ok(signedRes.data.url) + t.assert.contains(signedRes.data.url, 'token=') + + // Fetch via signed URL (no auth header) + const fileRes = await fetch(`${t.baseUrl}${signedRes.data.url}`) + t.assert.equal(fileRes.status, 200) }) }) diff --git a/packages/backend/src/db/migrations/0020_repair_default_new.sql b/packages/backend/src/db/migrations/0020_repair_default_new.sql new file mode 100644 index 0000000..e30312c --- /dev/null +++ b/packages/backend/src/db/migrations/0020_repair_default_new.sql @@ -0,0 +1 @@ +ALTER TABLE "repair_ticket" ALTER COLUMN "status" SET DEFAULT 'new'; diff --git a/packages/backend/src/db/migrations/meta/_journal.json b/packages/backend/src/db/migrations/meta/_journal.json index b7233f2..d5f0975 100644 --- a/packages/backend/src/db/migrations/meta/_journal.json +++ b/packages/backend/src/db/migrations/meta/_journal.json @@ -141,6 +141,13 @@ "when": 1774790000000, "tag": "0019_repair_new_status", "breakpoints": true + }, + { + "idx": 20, + "version": "7", + "when": 1774800000000, + "tag": "0020_repair_default_new", + "breakpoints": true } ] } \ No newline at end of file