From f76b235e9a468b8142b197eea2b96d379dc2ef77 Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Wed, 5 Aug 2026 11:51:23 +0200 Subject: [PATCH] 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 --- src/app/api/mcp/route.test.ts | 155 ++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/src/app/api/mcp/route.test.ts b/src/app/api/mcp/route.test.ts index 06e86f8..4929800 100644 --- a/src/app/api/mcp/route.test.ts +++ b/src/app/api/mcp/route.test.ts @@ -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