diff --git a/src/app/api/mcp/route.test.ts b/src/app/api/mcp/route.test.ts index 4929800..5aa56a6 100644 --- a/src/app/api/mcp/route.test.ts +++ b/src/app/api/mcp/route.test.ts @@ -125,6 +125,7 @@ describe("POST /api/mcp", () => { "orders.create_from_quote", "collection_pool.list", "order_items.update_status", + "order_items.update", "orders.add_attachment", "orders.list_attachments", "order_items.list", @@ -702,6 +703,140 @@ describe("POST /api/mcp", () => { expect(body.error.message).toMatch(/Missing id/); }); + it("tools/call quotes.update with regenerate_pdf:true persists the patch AND re-renders the PDF in one call", async () => { + // End-to-end: switching letterhead from CNL → ISHERWOOD and + // getting the new PDF back, all in one MCP call. Verifies the + // patch is saved FIRST, then the PDF endpoint is called using + // the freshly-persisted letterhead. + update.mockResolvedValueOnce({ + id: "q1", + company_letterhead: "ISHERWOOD", + quote_number: "Q-2026-06-001", + }); + const fetchMock = vi.fn(async () => + new Response( + JSON.stringify({ + ok: true, + attachmentId: "att-pdf-1", + status: "approval_pending", + pdfGeneratedAt: "2026-08-05T01:23:45Z", + filename: "Quote-ISHERWOOD-Q-2026-06-001.pdf", + skippedItemIds: [], + readyItemCount: 3, + totalItemCount: 3, + isPartial: false, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + const origFetch = globalThis.fetch; + (globalThis as { fetch: unknown }).fetch = fetchMock; + try { + const res = await POST( + mcpRequest({ + method: "tools/call", + id: 54, + params: { + name: "quotes.update", + arguments: { + id: "q1", + patch: { company_letterhead: "ISHERWOOD" }, + regenerate_pdf: true, + }, + }, + }), + ); + expect(res.status).toBe(200); + // Patch is saved first + expect(update).toHaveBeenCalledWith("q1", { company_letterhead: "ISHERWOOD" }); + // Then the PDF endpoint is called with the persisted letterhead + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toMatch(/\/api\/v1\/quotes\/q1\/pdf/); + const body = JSON.parse((init as RequestInit).body as string); + expect(body.letterhead).toBe("ISHERWOOD"); + // Response includes both the updated quote AND the rendered PDF + const respBody = await res.json(); + const payload = JSON.parse(respBody.result.content[0].text); + expect(payload.quote.company_letterhead).toBe("ISHERWOOD"); + expect(payload.pdf.attachmentId).toBe("att-pdf-1"); + expect(payload.pdf.filename).toBe("Quote-ISHERWOOD-Q-2026-06-001.pdf"); + } finally { + (globalThis as { fetch: unknown }).fetch = origFetch; + } + }); + + it("tools/call order_items.update patches whitelisted fields for invoice reconciliation (gap fix 2026-08-05)", async () => { + // The agent's reconciliation workflow: read what was collected + // (qty_collected), compare to what the supplier invoiced + // (qty_invoiced + supplier_invoice_number + cost_price), and patch + // the differences. We verify the tool calls the update with only + // the whitelisted fields. + update.mockResolvedValueOnce({ + id: "oi1", + cost_price: 42.5, + qty_invoiced: 9, + qty_collected: 10, + supplier_invoice_number: "INV-2026-8876", + }); + const res = await POST( + mcpRequest({ + method: "tools/call", + id: 70, + params: { + name: "order_items.update", + arguments: { + id: "oi1", + patch: { + cost_price: 42.5, + qty_invoiced: 9, + supplier_invoice_number: "INV-2026-8876", + }, + }, + }, + }), + ); + expect(res.status).toBe(200); + expect(update).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledWith("oi1", { + cost_price: 42.5, + qty_invoiced: 9, + supplier_invoice_number: "INV-2026-8876", + }); + const body = await res.json(); + const payload = JSON.parse(body.result.content[0].text); + expect(payload.item.cost_price).toBe(42.5); + expect(payload.item.qty_invoiced).toBe(9); + }); + + it("tools/call order_items.update rejects system-controlled fields (order, purchase_order, quote_item, product)", async () => { + // Relations must not be patchable via this tool — the agent + // should never reassign an order_item to a different order or PO. + const res = await POST( + mcpRequest({ + method: "tools/call", + id: 71, + params: { + name: "order_items.update", + arguments: { + id: "oi1", + patch: { + order: "o2", // relation — system-controlled + purchase_order: "po2", // relation — system-controlled + quote_item: "qi2", // relation — system-controlled + id: "different-id", // immutable + }, + }, + }, + }), + ); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error.message).toMatch(/forbidden_field/); + expect(body.error.message).toMatch(/order/); + expect(update).not.toHaveBeenCalled(); + }); + it("tools/call quotes.append_reply delegates to the service and surfaces matched_by", async () => { appendReplyToQuote.mockResolvedValueOnce({ quote: { id: "q1", quote_number: "Q-2026-06-001" },