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:
parent
2acfd2d346
commit
0ac05b847b
2 changed files with 143 additions and 4 deletions
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue