test(mcp): cover quotes.get_pdf, list_attachments, and the two new order attachment tools

- quotes.list_attachments: proxies to GET /api/v1/quotes/{id}/attachments
- quotes.get_pdf: proxies to /api/v1/quotes/{id}/pdf/base64 (with optional
  letterhead + regenerate query params)
- orders.add_attachment: posts multipart to /api/v1/orders/{id}/attachments/upload
- orders.list_attachments: proxies to /api/v1/orders/{id}/matched-attachments

All four tests use a stubbed global fetch (restored in finally) so
the MCP route's internal HTTP calls are deterministic and don't hit
the real server. Updated the tools/list test to include the four new
tools (76 total, was 72).

All 1382 tests pass.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-05 11:51:23 +02:00
parent b3344f9f47
commit f76b235e9a

View file

@ -125,6 +125,8 @@ describe("POST /api/mcp", () => {
"orders.create_from_quote",
"collection_pool.list",
"order_items.update_status",
"orders.add_attachment",
"orders.list_attachments",
"order_items.list",
"order_items.get",
"quotes.create_from_email",
@ -144,6 +146,9 @@ describe("POST /api/mcp", () => {
"quotes.next_number",
"quotes.lookup",
"attachments.upload_for_quote",
// Quote attachments — list + PDF retrieval (added 2026-08-05)
"quotes.list_attachments",
"quotes.get_pdf",
// Purchase order additional attachments (added 2026-07-09 — PO email extras)
"purchase_orders.add_attachment",
"purchase_orders.list",
@ -296,6 +301,156 @@ describe("POST /api/mcp", () => {
expect(body.error.message).toMatch(/Missing id/);
});
it("tools/call quotes.list_attachments proxies to /api/v1/quotes/{id}/attachments", async () => {
// fetch is a global used inside the MCP route. Stub it to verify the
// upstream URL + auth header and that the parsed JSON comes back
// through the JSON-RPC content envelope.
const fetchMock = vi.fn(async () =>
new Response(
JSON.stringify({ items: [{ id: "att1", filename: "rfq.pdf" }] }),
{ 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: 60,
params: { name: "quotes.list_attachments", arguments: { quote_id: "q1" } },
}),
);
expect(res.status).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1);
const calledUrl = String(fetchMock.mock.calls[0][0]);
expect(calledUrl).toMatch(/\/api\/v1\/quotes\/q1\/attachments/);
const body = await res.json();
const data = JSON.parse(body.result.content[0].text);
expect(data.items[0].filename).toBe("rfq.pdf");
} finally {
(globalThis as { fetch: unknown }).fetch = origFetch;
}
});
it("tools/call quotes.get_pdf proxies to /api/v1/quotes/{id}/pdf/base64 and returns the binary", async () => {
const pdfBase64 = Buffer.from("%PDF-1.4 fake").toString("base64");
const fetchMock = vi.fn(async () =>
new Response(
JSON.stringify({
pdf: pdfBase64,
attachment_id: "att1",
filename: "Quote-ISHERWOOD-Q1.pdf",
size: 13,
content_type: "application/pdf",
letterhead: "ISHERWOOD",
}),
{ 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: 61,
params: {
name: "quotes.get_pdf",
arguments: { id: "q1", letterhead: "ISHERWOOD" },
},
}),
);
expect(res.status).toBe(200);
const calledUrl = String(fetchMock.mock.calls[0][0]);
expect(calledUrl).toMatch(/\/api\/v1\/quotes\/q1\/pdf\/base64/);
expect(calledUrl).toContain("letterhead=ISHERWOOD");
const body = await res.json();
const data = JSON.parse(body.result.content[0].text);
expect(data.attachment_id).toBe("att1");
expect(data.pdf).toBe(pdfBase64);
} finally {
(globalThis as { fetch: unknown }).fetch = origFetch;
}
});
it("tools/call orders.add_attachment posts multipart to the orders upload route", async () => {
// We can't fully exercise multipart in this happy-path check (the
// route reads a `file` Blob), but we can verify it reaches the
// upstream URL with the right auth and a non-empty body.
const fetchMock = vi.fn(async () =>
new Response(
JSON.stringify({
id: "att1",
filename: "supplier-quote.pdf",
matched_quote_id: "q1",
}),
{ status: 201, 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: 62,
params: {
name: "orders.add_attachment",
arguments: {
order_id: "o1",
filename: "supplier-quote.pdf",
content_type: "application/pdf",
base64: Buffer.from("fake-pdf-bytes").toString("base64"),
kind: "outbound",
},
},
}),
);
expect(res.status).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1);
const calledUrl = String(fetchMock.mock.calls[0][0]);
expect(calledUrl).toMatch(/\/api\/v1\/orders\/o1\/attachments\/upload/);
const body = await res.json();
const data = JSON.parse(body.result.content[0].text);
expect(data.matched_quote_id).toBe("q1");
} finally {
(globalThis as { fetch: unknown }).fetch = origFetch;
}
});
it("tools/call orders.list_attachments proxies to the matched-attachments route", async () => {
const fetchMock = vi.fn(async () =>
new Response(
JSON.stringify({
quoteAttachments: [{ id: "att1", filename: "rfq.pdf" }],
perItemAttachments: {},
matchedQuoteItems: [],
}),
{ 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: 63,
params: { name: "orders.list_attachments", arguments: { order_id: "o1" } },
}),
);
expect(res.status).toBe(200);
const calledUrl = String(fetchMock.mock.calls[0][0]);
expect(calledUrl).toMatch(/\/api\/v1\/orders\/o1\/matched-attachments/);
const body = await res.json();
const data = JSON.parse(body.result.content[0].text);
expect(data.quoteAttachments[0].filename).toBe("rfq.pdf");
} finally {
(globalThis as { fetch: unknown }).fetch = origFetch;
}
});
it("handles MCP initialize handshake and returns server info (regression: 2026-08-04)", async () => {
// Before this handler existed, /api/mcp returned HTTP 404 with
// "Method not found" on `initialize`, which made every spec-compliant