test(mcp): cover order_items.update + quotes.update regenerate_pdf
- order_items.update happy path: patches cost_price + qty_invoiced + supplier_invoice_number, verifies the whitelist strips nothing and the row is written - order_items.update forbidden: system-controlled fields (order, purchase_order, quote_item, id) are rejected - quotes.update regenerate_pdf:true: patch is saved first, then the PDF endpoint is called with the freshly-persisted letterhead, and the response includes both the updated quote and the new PDF attachment metadata - tools/list test count: 77 (was 76) All 1385 tests pass. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
106af81934
commit
7f1603d9b0
1 changed files with 135 additions and 0 deletions
|
|
@ -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" },
|
||||
|
|
|
|||
Loading…
Reference in a new issue