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:
SJCoetz 2026-08-05 15:52:30 +02:00
parent 106af81934
commit 7f1603d9b0

View file

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