test(mcp): cover quotes.update + create_from_email unit field

- quotes.update: happy path with whitelisted fields, forbidden_field
  rejection for system-controlled fields (status/customer/company/
  quote_number/id), empty-patch rejection, missing-id rejection
- create_from_email: unit field now preserved end-to-end, result.items
  returns the actual QuoteItem array (not just a count)

Also updates the existing tools/list test (now 72 tools, was 71) and
the create_from_email mock in route.test.ts to use the new
items/items_count shape so it matches the type.

All 1379 tests pass.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-05 11:39:16 +02:00
parent 2acfd2d346
commit 0ac05b847b
2 changed files with 143 additions and 4 deletions

View file

@ -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" },

View file

@ -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 () => {