diff --git a/src/app/api/mcp/route.test.ts b/src/app/api/mcp/route.test.ts index bce05af..06e86f8 100644 --- a/src/app/api/mcp/route.test.ts +++ b/src/app/api/mcp/route.test.ts @@ -132,6 +132,7 @@ describe("POST /api/mcp", () => { "quotes.find_by_message_id", "quotes.list", "quotes.get", + "quotes.update", "quotes.send_email", "quotes.retry_email", "quotes.clone", @@ -399,7 +400,12 @@ describe("POST /api/mcp", () => { quote: { id: "q1" }, customer: { id: "c1" }, company: { id: "co1" }, - items: 3, + items: [ + { id: "qi1", description: "Item 1" }, + { id: "qi2", description: "Item 2" }, + { id: "qi3", description: "Item 3" }, + ], + items_count: 3, attachments: 1, deduped: false, quote_number: "Q-2026-06-001", @@ -438,6 +444,109 @@ describe("POST /api/mcp", () => { expect(passedArg.attachments[0].filename).toBe("rfq.pdf"); }); + it("tools/call quotes.update patches whitelisted fields and returns the updated quote (gap fix 2026-08-05)", async () => { + // The agent's gap analysis flagged the absence of quotes.update. We + // verify the new tool calls pb.collection("quotes").update with only + // the whitelisted fields and surfaces a 200 with the updated record. + update.mockResolvedValueOnce({ + id: "q1", + company_letterhead: "ISHERWOOD", + quote_reference: "RFQ-2026-100", + notes: "Updated by agent", + }); + const res = await POST( + mcpRequest({ + method: "tools/call", + id: 50, + params: { + name: "quotes.update", + arguments: { + id: "q1", + patch: { + company_letterhead: "ISHERWOOD", + quote_reference: "RFQ-2026-100", + notes: "Updated by agent", + }, + }, + }, + }) + ); + expect(res.status).toBe(200); + expect(update).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledWith("q1", { + company_letterhead: "ISHERWOOD", + quote_reference: "RFQ-2026-100", + notes: "Updated by agent", + }); + const body = await res.json(); + // Route wraps result in the tools/call content envelope; unwrap it. + const payload = JSON.parse(body.result.content[0].text); + expect(payload.quote.id).toBe("q1"); + expect(payload.quote.company_letterhead).toBe("ISHERWOOD"); + }); + + it("tools/call quotes.update rejects forbidden fields (status, customer, company, quote_number, id, created, updated)", async () => { + // Whitelist protects system-controlled fields. Sending any of these + // must error with forbidden_field rather than silently writing to PB. + const res = await POST( + mcpRequest({ + method: "tools/call", + id: 51, + params: { + name: "quotes.update", + arguments: { + id: "q1", + patch: { + status: "approved", // system-controlled + customer: "bad-id", // relation — system-controlled + company: "bad-id", // relation — system-controlled + quote_number: "Q-FOO", // generated by server + id: "different-id", // immutable + }, + }, + }, + }) + ); + expect(res.status).toBe(500); // JSON-RPC error envelope path + const body = await res.json(); + expect(body.error.message).toMatch(/forbidden_field/); + expect(body.error.message).toMatch(/status/); + expect(body.error.message).toMatch(/customer/); + expect(update).not.toHaveBeenCalled(); + }); + + it("tools/call quotes.update rejects empty patch", async () => { + const res = await POST( + mcpRequest({ + method: "tools/call", + id: 52, + params: { + name: "quotes.update", + arguments: { id: "q1", patch: {} }, + }, + }) + ); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error.message).toMatch(/empty/); + }); + + it("tools/call quotes.update requires id", async () => { + const res = await POST( + mcpRequest({ + method: "tools/call", + id: 53, + params: { + name: "quotes.update", + arguments: { patch: { company_letterhead: "ISHERWOOD" } }, + }, + }) + ); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error.message).toMatch(/Missing id/); + }); + 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" }, diff --git a/src/lib/server/quotes-service.test.ts b/src/lib/server/quotes-service.test.ts index b900f8a..6779ac2 100644 --- a/src/lib/server/quotes-service.test.ts +++ b/src/lib/server/quotes-service.test.ts @@ -467,7 +467,8 @@ describe("createQuoteFromEmail", () => { }); expect(result.deduped).toBe(false); expect(result.quote_number).toMatch(/^Q\d{4}\d{2}001$/); - expect(result.items).toBe(2); + expect(result.items_count).toBe(2); + expect(result.items).toHaveLength(2); // Attachments are no longer created in this path — the n8n flow calls // `attachments.upload_for_quote` once per file in a follow-up step, so // the create call must return 0 to avoid a 5 MB base64 POST that the @@ -497,6 +498,33 @@ describe("createQuoteFromEmail", () => { } }); + it("preserves the `unit` field from extracted_items (gap fix 2026-08-05)", async () => { + // Before the fix, the `unit` field was dropped during the create loop + // even though `extracted_items` carries it. The agent's gap analysis + // flagged this — confirm the field is now written to the row. + const result = await createQuoteFromEmail({ + ...basePayload, + extracted_items: [ + { description: "Cable gland M20", quantity: 10, unit: "ea" }, + { description: "Cable ties 200mm", quantity: 2, unit: "pack" }, + ], + }); + expect(result.items).toHaveLength(2); + const units = result.items.map((i) => i.unit).sort(); + expect(units).toEqual(["ea", "pack"]); + }); + + it("returns the actual items array in the result (gap fix 2026-08-05)", async () => { + // Before the fix, result.items was a count. Returning the array lets + // the agent confirm what was created and follow up with quote_items.update. + const result = await createQuoteFromEmail(basePayload); + expect(result.items_count).toBe(2); + expect(result.items).toHaveLength(2); + expect(result.items[0]).toHaveProperty("id"); + expect(result.items[0]).toHaveProperty("description"); + expect(result.items[0]).toHaveProperty("is_processed", false); + }); + it("dedupes by email_message_id — second call returns the same quote", async () => { const first = await createQuoteFromEmail(basePayload); const second = await createQuoteFromEmail(basePayload); @@ -567,7 +595,8 @@ describe("createQuoteFromEmail", () => { }); expect(result.quote.description).toBe("(no subject)"); expect(result.quote.email_subject).toBe("(no subject)"); - expect(result.items).toBe(0); + expect(result.items_count).toBe(0); + expect(result.items).toEqual([]); }); it("placeholder subject + empty body + empty from_email all combine cleanly", async () => { @@ -589,7 +618,8 @@ describe("createQuoteFromEmail", () => { expect(result.quote.description).toBe("(no subject)"); expect(result.quote.email_subject).toBe("(no subject)"); expect(result.quote.email_sender).toBe(""); - expect(result.items).toBe(0); + expect(result.items_count).toBe(0); + expect(result.items).toEqual([]); }); it("creates a quote with a sentinel 'Unknown Sender' customer when from_email is empty", async () => {