From c4936613e1ec50079d3b2c119c1f55414527626d Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 6 Nov 2026 07:39:58 +0200 Subject: [PATCH] fix(mcp): tell the agent what repairs.upload_file does, and test the rest of the repair tools --- src/app/api/mcp/route.test.ts | 320 +++++++++++++++++++++++++++++++++- src/app/api/mcp/route.ts | 6 +- 2 files changed, 314 insertions(+), 12 deletions(-) diff --git a/src/app/api/mcp/route.test.ts b/src/app/api/mcp/route.test.ts index 7ce7523..1fc9eb9 100644 --- a/src/app/api/mcp/route.test.ts +++ b/src/app/api/mcp/route.test.ts @@ -4356,8 +4356,8 @@ describe("repairs MCP tools", () => { items: [], totalItems: 0, totalPages: 0, - page: 1, - perPage: 20, + page: 2, + perPage: 5, }); const res = await POST( @@ -4366,13 +4366,18 @@ describe("repairs MCP tools", () => { id: 2, params: { name: "repairs.list", - arguments: { status: "assessing", page: 1, perPage: 20 }, + arguments: { status: "assessing", page: 2, perPage: 5 }, }, }), ); + expect(res.status).toBe(200); + const body = await res.json(); + const payload = JSON.parse(body.result.content[0].text); + expect(payload.page).toBe(2); + expect(payload.perPage).toBe(5); expect(listRepairs).toHaveBeenCalledWith( - expect.objectContaining({ status: "assessing" }), + expect.objectContaining({ status: "assessing", page: 2, perPage: 5 }), ); }); @@ -4389,6 +4394,113 @@ describe("repairs MCP tools", () => { expect(body.error.message).toMatch(/Invalid status/); }); + it("repairs.get rejects a missing id", async () => { + const res = await POST( + mcpRequest({ + method: "tools/call", + id: 9, + params: { name: "repairs.get", arguments: {} }, + }), + ); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error.message).toMatch(/Missing id for repairs\.get\./); + }); + + it("repairs.get returns the repair by id", async () => { + vi.mocked(getRepair).mockResolvedValue({ + id: "rep1", + status: "new", + } as never); + + const res = await POST( + mcpRequest({ + method: "tools/call", + id: 10, + params: { name: "repairs.get", arguments: { id: "rep1" } }, + }), + ); + + expect(res.status).toBe(200); + expect(getRepair).toHaveBeenCalledWith("rep1"); + const body = await res.json(); + expect(JSON.parse(body.result.content[0].text).id).toBe("rep1"); + }); + + it("repairs.create rejects a missing item_description", async () => { + const res = await POST( + mcpRequest({ + method: "tools/call", + id: 11, + params: { + name: "repairs.create", + arguments: { fault_description: "Hydraulic leak" }, + }, + }), + ); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error.message).toMatch( + /Missing item_description for repairs\.create\./, + ); + }); + + it("repairs.create rejects a missing fault_description", async () => { + const res = await POST( + mcpRequest({ + method: "tools/call", + id: 12, + params: { + name: "repairs.create", + arguments: { item_description: "Forklift" }, + }, + }), + ); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error.message).toMatch( + /Missing fault_description for repairs\.create\./, + ); + }); + + it("repairs.create books the machine in with the trimmed fields", async () => { + vi.mocked(createRepair).mockResolvedValue({ + id: "rep1", + status: "new", + } as never); + + const res = await POST( + mcpRequest({ + method: "tools/call", + id: 13, + params: { + name: "repairs.create", + arguments: { + item_description: " Forklift ", + fault_description: " Hydraulic leak ", + serial_no: "SN-1", + }, + }, + }), + ); + + expect(res.status).toBe(200); + expect(createRepair).toHaveBeenCalledWith( + expect.objectContaining({ + item_description: "Forklift", + fault_description: "Hydraulic leak", + serial_no: "SN-1", + // Unset relations arrive as null, not "". + customer: null, + company: null, + order: null, + supplier: null, + supplier_scope: null, + notes: null, + }), + ); + }); + it("repairs.update refuses to patch status", async () => { const res = await POST( mcpRequest({ @@ -4421,10 +4533,58 @@ describe("repairs MCP tools", () => { expect(body.error.message).toMatch(/cannot patch "repair_number"/); }); - it("repairs.advance_status returns the next step", async () => { + it("repairs.update passes only the whitelisted fields through", async () => { vi.mocked(updateRepair).mockResolvedValue({ id: "rep1", - status: "assessing", + status: "new", + } as never); + + const res = await POST( + mcpRequest({ + method: "tools/call", + id: 14, + params: { + name: "repairs.update", + arguments: { + id: "rep1", + patch: { notes: "Customer called", serial_no: "SN-2" }, + }, + }, + }), + ); + + expect(res.status).toBe(200); + expect(updateRepair).toHaveBeenCalledWith("rep1", { + notes: "Customer called", + serial_no: "SN-2", + }); + }); + + it("repairs.advance_status rejects a status outside the fixed set", async () => { + const res = await POST( + mcpRequest({ + method: "tools/call", + id: 8, + params: { + name: "repairs.advance_status", + arguments: { id: "rep1", to: "wat" }, + }, + }), + ); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error.message).toMatch(/Invalid status/); + }); + + it("repairs.advance_status returns the next step for the repair's NEW status", async () => { + // The service reports the repair back as 'approved' even though we + // asked to move it to 'quoted'. The next step must follow the + // repair's ACTUAL status, not the status we asked for — a handler + // written as nextStepFor(to) would return the quoted step and fail + // here. + vi.mocked(updateRepair).mockResolvedValue({ + id: "rep1", + status: "approved", } as never); const res = await POST( @@ -4433,15 +4593,22 @@ describe("repairs MCP tools", () => { id: 6, params: { name: "repairs.advance_status", - arguments: { id: "rep1", to: "assessing" }, + arguments: { id: "rep1", to: "quoted" }, }, }), ); + expect(res.status).toBe(200); const body = await res.json(); const payload = JSON.parse(body.result.content[0].text); - expect(payload.next_step.action).toBe("upload_supplier_quote"); - expect(payload.next_step.to).toBe("quoted"); + expect(payload.next_step).toEqual({ + label: "Start work", + action: "start_work", + to: "in_progress", + }); + // The quoted step must NOT be returned — that is the step for the + // status we asked for, not the one the repair is actually in. + expect(payload.next_step.action).not.toBe("create_customer_quote"); }); it("repairs.upload_file rejects an unknown file field", async () => { @@ -4464,4 +4631,139 @@ describe("repairs MCP tools", () => { const body = await res.json(); expect(body.error.message).toMatch(/Invalid file field/); }); + + it("repairs.upload_file appends the file to the chosen field", async () => { + vi.mocked(uploadRepairFile).mockResolvedValue({ + id: "rep1", + status: "assessing", + } as never); + + const res = await POST( + mcpRequest({ + method: "tools/call", + id: 15, + params: { + name: "repairs.upload_file", + arguments: { + id: "rep1", + field: "signed_request", + filename: "signed.pdf", + content_type: "application/pdf", + base64: "AAAA", + }, + }, + }), + ); + + expect(res.status).toBe(200); + expect(uploadRepairFile).toHaveBeenCalledWith( + "rep1", + "signed_request", + expect.any(Blob), + "signed.pdf", + ); + }); + + it("repairs.generate_repair_request rejects a missing id", async () => { + const res = await POST( + mcpRequest({ + method: "tools/call", + id: 16, + params: { name: "repairs.generate_repair_request", arguments: {} }, + }), + ); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error.message).toMatch( + /Missing id for repairs\.generate_repair_request\./, + ); + }); + + it("repairs.generate_repair_request returns the generated filename", async () => { + vi.mocked(generateRepairRequest).mockResolvedValue({ + filename: "RR-REP-2026-11-001.pdf", + }); + + const res = await POST( + mcpRequest({ + method: "tools/call", + id: 17, + params: { + name: "repairs.generate_repair_request", + arguments: { id: "rep1" }, + }, + }), + ); + + expect(res.status).toBe(200); + expect(generateRepairRequest).toHaveBeenCalledWith("rep1"); + const body = await res.json(); + expect(JSON.parse(body.result.content[0].text).filename).toBe( + "RR-REP-2026-11-001.pdf", + ); + }); + + it("repairs.create_customer_quote rejects an empty item list", async () => { + const res = await POST( + mcpRequest({ + method: "tools/call", + id: 18, + params: { + name: "repairs.create_customer_quote", + arguments: { id: "rep1", items: [] }, + }, + }), + ); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error.message).toMatch(/needs at least one line item/); + }); + + it("repairs.create_customer_quote passes the priced lines through", async () => { + vi.mocked(createCustomerQuoteFromRepair).mockResolvedValue({ + quote: { id: "q1" }, + customer: { id: "c1" }, + repair: { id: "rep1" }, + } as never); + + const res = await POST( + mcpRequest({ + method: "tools/call", + id: 19, + params: { + name: "repairs.create_customer_quote", + arguments: { + id: "rep1", + items: [ + { + description: "Hydraulic hose", + quantity: 2, + cost_price: 100, + selling_price: 180, + }, + ], + quote_notes: "Parts and labour", + replace: true, + }, + }, + }), + ); + + expect(res.status).toBe(200); + expect(createCustomerQuoteFromRepair).toHaveBeenCalledWith( + "rep1", + expect.objectContaining({ + items: [ + expect.objectContaining({ + description: "Hydraulic hose", + quantity: 2, + cost_price: 100, + selling_price: 180, + }), + ], + quote_notes: "Parts and labour", + replace: true, + }), + ); + }); }); diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index ccc4ec3..25e8c05 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -3691,7 +3691,7 @@ const tools = [ { name: "repairs.advance_status", description: - "Move a repair job to the next status. Allowed moves: new → assessing, quoted or cancelled; assessing → quoted or cancelled; quoted → approved, in_progress or cancelled; approved → in_progress or cancelled; in_progress → completed or cancelled. Returns the repair and the next step available.", + "Move a repair job to the next status. Allowed moves: new → assessing, quoted or cancelled; assessing → quoted or cancelled; quoted → approved, in_progress or cancelled; approved → in_progress or cancelled; in_progress → completed or cancelled. `completed` and `cancelled` are terminal — a job in either state cannot move again. Returns the repair and the next step available.", inputSchema: { type: "object", properties: { @@ -3704,7 +3704,7 @@ const tools = [ { name: "repairs.upload_file", description: - "Attach a file to a repair job. `field` is one of: photos (the machine as received), quote (the supplier's quote), purchase_order (our PO to the supplier), repair_request (the request the driver carries), signed_request (the supplier's signed copy handed back). The file is ADDED to the field; existing files are kept.", + "Attach a file to a repair job. `field` is one of: photos (the machine as received), quote (the supplier's quote), purchase_order (our PO to the supplier), repair_request (the request the driver carries), signed_request (the supplier's signed copy handed back). The file is ADDED to the field; existing files are kept. Uploading the supplier's quote to `quote` also moves the job on: while it is still 'Booked in' or 'With supplier' it becomes 'Quote in'.", inputSchema: { type: "object", properties: { @@ -3720,7 +3720,7 @@ const tools = [ { name: "repairs.generate_repair_request", description: - "Generate the Repair Request PDF for a repair job and send the machine to the supplier's assessment. The repair must have a supplier set. This also moves the job from 'Booked in' to 'With supplier'. The PDF is the document the driver carries with the machine; the supplier signs a copy and hands it back, which is uploaded with repairs.upload_file as signed_request.", + "Generate the Repair Request PDF for a repair job and send the machine to the supplier's assessment. The repair must have a supplier set. If the job is still 'Booked in' this also moves it to 'With supplier'; on any other status it only regenerates the PDF. The PDF is the document the driver carries with the machine; the supplier signs a copy and hands it back, which is uploaded with repairs.upload_file as signed_request. Each run ADDS another RR-.pdf to the repair_request file field, so it is not idempotent — do not run it twice expecting a single file.", inputSchema: { type: "object", properties: {