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:
parent
b3344f9f47
commit
f76b235e9a
1 changed files with 155 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue