Add the full buyer-side supplier invoice reconciliation workflow: - Include the new `stock_purchases.match_and_receive_from_invoice` MCP tool for deterministic PO-invoice matching - Create core matching service with strict, rule-based line matching (sku → supplier_sku → description overlap) - Support dry-run default mode to validate matches before committing - Add automatic attachment handling with standard `supplier_invoice_*` naming convention - Add full test suite for the matching logic - Update documentation and agent skill profile to document the new tool and workflow Pre-existing test failures (3 total) are unrelated to this change.
2542 lines
84 KiB
TypeScript
2542 lines
84 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
import { NextRequest } from "next/server";
|
|
|
|
const getList = vi.fn();
|
|
const getOne = vi.fn();
|
|
const create = vi.fn();
|
|
const update = vi.fn();
|
|
|
|
vi.mock("@/lib/server/pocketbase-admin", () => ({
|
|
getAdminPocketBase: vi.fn().mockResolvedValue({
|
|
collection: () => ({ getList, getOne, create, update }),
|
|
}),
|
|
}));
|
|
|
|
const createOrderFromQuote = vi.fn();
|
|
const getOrderItemsCollectionName = vi.fn(() => "order_items");
|
|
const listCollectionPool = vi.fn();
|
|
vi.mock("@/lib/server/orders-service", () => ({
|
|
createOrderFromQuote: (...args: unknown[]) => createOrderFromQuote(...args),
|
|
getOrderItemsCollectionName: () => getOrderItemsCollectionName(),
|
|
listCollectionPool: (...args: unknown[]) => listCollectionPool(...args),
|
|
}));
|
|
|
|
const createQuoteFromEmail = vi.fn();
|
|
const appendReplyToQuote = vi.fn();
|
|
const findQuoteByMessageId = vi.fn();
|
|
const uploadAttachmentForQuote = vi.fn();
|
|
vi.mock("@/lib/server/quotes-service", () => ({
|
|
createQuoteFromEmail: (...args: unknown[]) => createQuoteFromEmail(...args),
|
|
appendReplyToQuote: (...args: unknown[]) => appendReplyToQuote(...args),
|
|
findQuoteByMessageId: (...args: unknown[]) => findQuoteByMessageId(...args),
|
|
uploadAttachmentForQuote: (...args: unknown[]) => uploadAttachmentForQuote(...args),
|
|
}));
|
|
|
|
const createCompany = vi.fn();
|
|
const updateCompany = vi.fn();
|
|
const deleteCompany = vi.fn();
|
|
const createCustomer = vi.fn();
|
|
const updateCustomer = vi.fn();
|
|
const deleteCustomer = vi.fn();
|
|
const createSupplier = vi.fn();
|
|
const updateSupplier = vi.fn();
|
|
const deleteSupplier = vi.fn();
|
|
vi.mock("@/lib/server/management-service", () => ({
|
|
createCompany: (...args: unknown[]) => createCompany(...args),
|
|
updateCompany: (...args: unknown[]) => updateCompany(...args),
|
|
deleteCompany: (...args: unknown[]) => deleteCompany(...args),
|
|
createCustomer: (...args: unknown[]) => createCustomer(...args),
|
|
updateCustomer: (...args: unknown[]) => updateCustomer(...args),
|
|
deleteCustomer: (...args: unknown[]) => deleteCustomer(...args),
|
|
createSupplier: (...args: unknown[]) => createSupplier(...args),
|
|
updateSupplier: (...args: unknown[]) => updateSupplier(...args),
|
|
deleteSupplier: (...args: unknown[]) => deleteSupplier(...args),
|
|
}));
|
|
|
|
// Mock the per-line stock purchase items service. The handler uses
|
|
// dynamic imports, but vitest hoists `vi.mock` above the import block
|
|
// so the module-level mocks register before the handler ever resolves.
|
|
const updateStockPurchaseItem = vi.fn();
|
|
const bulkUpdateStockPurchaseItems = vi.fn();
|
|
const assignDriver = vi.fn();
|
|
const markLineCollected = vi.fn();
|
|
vi.mock("@/lib/server/stock-purchase-items-service", () => ({
|
|
updateStockPurchaseItem: (...args: unknown[]) => updateStockPurchaseItem(...args),
|
|
bulkUpdateStockPurchaseItems: (...args: unknown[]) => bulkUpdateStockPurchaseItems(...args),
|
|
assignDriver: (...args: unknown[]) => assignDriver(...args),
|
|
markLineCollected: (...args: unknown[]) => markLineCollected(...args),
|
|
}));
|
|
|
|
// Buyer-side invoice reconciliation (added 2026-08-09).
|
|
const matchAndReceiveFromInvoiceMock = vi.fn();
|
|
vi.mock("@/lib/server/stock-purchase-invoice-matching-service", () => ({
|
|
matchAndReceiveFromInvoice: (...args: unknown[]) => matchAndReceiveFromInvoiceMock(...args),
|
|
}));
|
|
|
|
// Mock the catalogue service for stock_items.update.
|
|
const updateStockItem = vi.fn();
|
|
vi.mock("@/lib/server/stock-items-service", () => ({
|
|
updateStockItem: (...args: unknown[]) => updateStockItem(...args),
|
|
}));
|
|
|
|
import { POST } from "./route";
|
|
|
|
function mcpRequest(body: unknown) {
|
|
return new NextRequest("http://localhost/api/mcp", {
|
|
method: "POST",
|
|
headers: { authorization: "Bearer t", "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
describe("POST /api/mcp", () => {
|
|
beforeEach(() => {
|
|
process.env.AGENT_API_KEY = "t";
|
|
getList.mockReset();
|
|
getOne.mockReset();
|
|
create.mockReset();
|
|
update.mockReset();
|
|
createOrderFromQuote.mockReset();
|
|
listCollectionPool.mockReset();
|
|
getOrderItemsCollectionName.mockReset();
|
|
getOrderItemsCollectionName.mockReturnValue("order_items");
|
|
createQuoteFromEmail.mockReset();
|
|
appendReplyToQuote.mockReset();
|
|
findQuoteByMessageId.mockReset();
|
|
createCompany.mockReset();
|
|
updateCompany.mockReset();
|
|
deleteCompany.mockReset();
|
|
createCustomer.mockReset();
|
|
updateCustomer.mockReset();
|
|
deleteCustomer.mockReset();
|
|
createSupplier.mockReset();
|
|
updateSupplier.mockReset();
|
|
deleteSupplier.mockReset();
|
|
updateStockPurchaseItem.mockReset();
|
|
bulkUpdateStockPurchaseItems.mockReset();
|
|
assignDriver.mockReset();
|
|
markLineCollected.mockReset();
|
|
updateStockItem.mockReset();
|
|
matchAndReceiveFromInvoiceMock.mockReset();
|
|
});
|
|
|
|
it("401s without bearer token", async () => {
|
|
const req = new NextRequest("http://localhost/api/mcp", {
|
|
method: "POST",
|
|
body: JSON.stringify({ method: "tools/list" }),
|
|
});
|
|
const res = await POST(req);
|
|
expect(res.status).toBe(401);
|
|
const body = await res.json();
|
|
// JSON-RPC code -32001 is reserved for auth failures.
|
|
expect(body.error.code).toBe(-32001);
|
|
expect(body.error.message).toMatch(/Unauthorized/);
|
|
});
|
|
|
|
it("401s with a wrong bearer token", async () => {
|
|
const req = new NextRequest("http://localhost/api/mcp", {
|
|
method: "POST",
|
|
headers: { authorization: "Bearer wrong", "content-type": "application/json" },
|
|
body: JSON.stringify({ method: "tools/list" }),
|
|
});
|
|
const res = await POST(req);
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("tools/list returns every registered tool (orders/quotes + bulk + management CRUD)", async () => {
|
|
const res = await POST(mcpRequest({ method: "tools/list", id: 1 }));
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
expect(body.jsonrpc).toBe("2.0");
|
|
expect(body.id).toBe(1);
|
|
const toolNames = body.result.tools.map((t: { name: string }) => t.name);
|
|
expect(toolNames).toEqual([
|
|
// Orders / quotes (existing)
|
|
"orders.list",
|
|
"orders.get",
|
|
"orders.create_from_quote",
|
|
"collection_pool.list",
|
|
"order_items.update_status",
|
|
"order_items.update",
|
|
"orders.add_attachment",
|
|
"orders.list_attachments",
|
|
"order_items.list",
|
|
"order_items.get",
|
|
"quotes.create_from_email",
|
|
// Quotes AI pipeline (added 2026-08-08 — internal agent worker)
|
|
"quotes.ingest_from_email",
|
|
"quotes.process_pending",
|
|
"quotes.re_run_ai",
|
|
"quotes.append_reply",
|
|
"quotes.find_by_message_id",
|
|
"quotes.list",
|
|
"quotes.get",
|
|
"quotes.update",
|
|
"quotes.send_email",
|
|
"quotes.retry_email",
|
|
"quotes.clone",
|
|
"quotes.create_from_template",
|
|
"quotes.save_as_template",
|
|
"quotes.split",
|
|
"quotes.partial_approve",
|
|
"quotes.re_price",
|
|
"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",
|
|
"purchase_orders.get",
|
|
"purchase_orders.send_email",
|
|
"purchase_orders.retry_email",
|
|
"purchase_orders.stale",
|
|
"purchase_orders.record_send_outcome",
|
|
// Bulk quote_items (added 2026-07-02 — AI auto-fill agents)
|
|
"bulk_update_quote_items",
|
|
// quote_items CRUD (added 2026-08-04 — AI auto-fill agents)
|
|
"quote_items.list",
|
|
"quote_items.get",
|
|
"quote_items.create",
|
|
"quote_items.update",
|
|
// Quote-item insights (added 2026-08-08)
|
|
"quote_items.populate_insights",
|
|
// Stock items + purchases (added 2026-07-30 — Hermes + n8n coverage)
|
|
"stock_items.list",
|
|
"stock_items.get",
|
|
"stock_items.create",
|
|
"stock_items.adjust",
|
|
// Master catalogue update (added 2026-08-07)
|
|
"stock_items.update",
|
|
"stock_purchases.list",
|
|
"stock_purchases.get",
|
|
"stock_purchases.create",
|
|
"stock_purchases.next_number",
|
|
"stock_purchases.send",
|
|
"stock_purchases.receive",
|
|
"stock_purchases.send_email",
|
|
"stock_purchases.retry_email",
|
|
"stock_purchases.add_attachment",
|
|
// Buyer-side invoice reconciliation (added 2026-08-09)
|
|
"stock_purchases.match_and_receive_from_invoice",
|
|
// Per-line stock-purchase writes (added 2026-08-07)
|
|
"stock_purchase_items.update",
|
|
"stock_purchase_items.bulk_update",
|
|
"stock_purchase_items.assign_driver",
|
|
"stock_purchase_items.mark_collected",
|
|
// Sprint + buyer tasks (added 2026-07-30)
|
|
"sprint.list",
|
|
"sprint.attachment_counts",
|
|
"sprint.item_attachments",
|
|
"sprint.claim",
|
|
"buyer_tasks.list",
|
|
"buyer_tasks.generate_po",
|
|
"buyer_tasks.mark_ready",
|
|
// Stock fulfillment (added 2026-07-30)
|
|
"stock.fulfill",
|
|
"stock.surplus_book",
|
|
// AI insights rerun (added 2026-07-30)
|
|
"ai_insights.rerun",
|
|
// AI settings (added 2026-08-08 — operator UI configures the model)
|
|
"ai_settings.get",
|
|
"ai_settings.update",
|
|
// Management CRUD (added 2026-06-28 — Hermes agent support)
|
|
"companies.list",
|
|
"companies.get",
|
|
"companies.create",
|
|
"companies.update",
|
|
"companies.delete",
|
|
"customers.list",
|
|
"customers.get",
|
|
"customers.create",
|
|
"customers.update",
|
|
"customers.delete",
|
|
"suppliers.list",
|
|
"suppliers.get",
|
|
"suppliers.create",
|
|
"suppliers.update",
|
|
"suppliers.delete",
|
|
// Discovery (added 2026-07-30)
|
|
"tools.discovery",
|
|
]);
|
|
});
|
|
|
|
it("tools/call orders.list forwards search substring to PocketBase", async () => {
|
|
getList.mockResolvedValueOnce({ items: [{ id: "o1" }] });
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 2,
|
|
params: { name: "orders.list", arguments: { search: "ORD-2026", page: 1, perPage: 10 } },
|
|
})
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(getList).toHaveBeenCalledWith(1, 10, {
|
|
filter: 'order_number ~ "ORD-2026"',
|
|
sort: "-created",
|
|
expand: "company,customer,quote",
|
|
});
|
|
});
|
|
|
|
it("tools/call orders.create_from_quote delegates to the service", async () => {
|
|
createOrderFromQuote.mockResolvedValueOnce({
|
|
order: { id: "o1" },
|
|
already_exists: false,
|
|
});
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 3,
|
|
params: { name: "orders.create_from_quote", arguments: { quote_id: "q-1" } },
|
|
})
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(createOrderFromQuote).toHaveBeenCalledWith("q-1");
|
|
});
|
|
|
|
it("tools/call collection_pool.list delegates to the service", async () => {
|
|
listCollectionPool.mockResolvedValueOnce({ items: [] });
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 4,
|
|
params: { name: "collection_pool.list", arguments: { status: "queued" } },
|
|
})
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(listCollectionPool).toHaveBeenCalledWith({
|
|
page: 1,
|
|
perPage: 50,
|
|
status: "queued",
|
|
});
|
|
});
|
|
|
|
it("tools/call order_items.update_status normalises and updates", async () => {
|
|
update.mockResolvedValueOnce({ id: "oi-1", item_status: "delivered" });
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 5,
|
|
params: {
|
|
name: "order_items.update_status",
|
|
arguments: {
|
|
id: "oi-1",
|
|
item_status: "delivered",
|
|
drv_status: "collected",
|
|
},
|
|
},
|
|
})
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(update).toHaveBeenCalledWith("oi-1", {
|
|
item_status: "delivered",
|
|
drv_status: "collected",
|
|
driver_status: "collected",
|
|
});
|
|
});
|
|
|
|
it("tools/call order_items.update_status fails fast when id is missing", async () => {
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 6,
|
|
params: {
|
|
name: "order_items.update_status",
|
|
arguments: { item_status: "delivered" },
|
|
},
|
|
})
|
|
);
|
|
expect(res.status).toBe(500);
|
|
const body = await res.json();
|
|
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 stock_purchases.add_attachment decodes base64 and writes via the service helper", async () => {
|
|
// The handler delegates to the service helper, which in turn hits
|
|
// the admin PB. The top-level pocketbase-admin mock returns the
|
|
// shared `getOne`/`update` mocks — queue one getOne (read before)
|
|
// and one update (FormData write).
|
|
getOne.mockResolvedValueOnce({ id: "sp-1", additional_attachments: [] });
|
|
update.mockResolvedValueOnce({
|
|
additional_attachments: ["fresh.pdf"],
|
|
});
|
|
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 63,
|
|
params: {
|
|
name: "stock_purchases.add_attachment",
|
|
arguments: {
|
|
purchase_id: "sp-1",
|
|
filename: "invoice.pdf",
|
|
content_type: "application/pdf",
|
|
base64: Buffer.from("fake-pdf-bytes").toString("base64"),
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(getOne).toHaveBeenCalledTimes(1);
|
|
expect(update).toHaveBeenCalledTimes(1);
|
|
const body = await res.json();
|
|
const data = JSON.parse(body.result.content[0].text);
|
|
expect(data).toMatchObject({
|
|
ok: true,
|
|
attachments: ["fresh.pdf"],
|
|
added: ["fresh.pdf"],
|
|
});
|
|
});
|
|
|
|
it("tools/call stock_purchases.match_and_receive_from_invoice dry-runs by default and returns the report", async () => {
|
|
// The handler delegates the entire matching + report build to the
|
|
// service helper. Confirm the call is shaped correctly (parsed
|
|
// invoice, dry-run flag, no attachment echo) and that the verdict
|
|
// drives a 200 response — even when would_receive is false.
|
|
matchAndReceiveFromInvoiceMock.mockResolvedValueOnce({
|
|
ok: true,
|
|
mode: "dry_run",
|
|
would_receive: false,
|
|
attachment_filename: null,
|
|
report: {
|
|
verdict: "qty_mismatch",
|
|
po_id: "sp-1",
|
|
po_number: "PO26080501STK",
|
|
po_status: "sent",
|
|
po_supplier_id: "sup-1",
|
|
po_supplier_name: "HPI INSERTS",
|
|
invoice_supplier_name: "H P I INSERTS (PTY) LTD",
|
|
ordered_value: 1150,
|
|
received_value: 0,
|
|
unmatched_po_lines: [],
|
|
unmatched_invoice_lines: [],
|
|
line_comparisons: [
|
|
{
|
|
invoice_line_no: 1,
|
|
invoice: { description: "HPI RIVNUT TOOL 510 M3 - M12", quantity: 2, unit_price: 1150 },
|
|
po_line_id: "spl-1",
|
|
stock_item_id: "si-1",
|
|
match_method: "sku_exact",
|
|
qty_delta: -1,
|
|
cost_delta: 0,
|
|
notes: ["qty mismatch: PO=1, invoice=2"],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 70,
|
|
params: {
|
|
name: "stock_purchases.match_and_receive_from_invoice",
|
|
arguments: {
|
|
po_reference: "PO26080501STK",
|
|
invoice: {
|
|
document_no: "IN389384",
|
|
date: "2026-08-05",
|
|
supplier_name: "H P I INSERTS (PTY) LTD",
|
|
lines: [
|
|
{
|
|
code: "56020510",
|
|
description: "HPI RIVNUT TOOL 510 M3 - M12",
|
|
quantity: 2,
|
|
unit_price: 1150,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(matchAndReceiveFromInvoiceMock).toHaveBeenCalledWith({
|
|
po_reference: "PO26080501STK",
|
|
invoice: {
|
|
document_no: "IN389384",
|
|
date: "2026-08-05",
|
|
supplier_name: "H P I INSERTS (PTY) LTD",
|
|
lines: [
|
|
{
|
|
code: "56020510",
|
|
description: "HPI RIVNUT TOOL 510 M3 - M12",
|
|
quantity: 2,
|
|
unit_price: 1150,
|
|
line_total: undefined,
|
|
},
|
|
],
|
|
},
|
|
attachment: undefined,
|
|
confirm: false,
|
|
});
|
|
|
|
const body = await res.json();
|
|
const data = JSON.parse(body.result.content[0].text);
|
|
expect(data.report.verdict).toBe("qty_mismatch");
|
|
expect(data.would_receive).toBe(false);
|
|
});
|
|
|
|
it("tools/call stock_purchases.match_and_receive_from_invoice returns 422 on a qty_mismatch when confirm=true", async () => {
|
|
// The handler must refuse to commit when the verdict is anything
|
|
// other than `ready_to_receive`. qty_mismatch → 422.
|
|
matchAndReceiveFromInvoiceMock.mockResolvedValueOnce({
|
|
ok: false,
|
|
mode: "committed",
|
|
reason: "qty_mismatch",
|
|
message:
|
|
'Refused to commit — verdict is "qty_mismatch". Resolve the mismatches and call again.',
|
|
report: {
|
|
verdict: "qty_mismatch",
|
|
po_id: "sp-1",
|
|
po_number: "PO26080501STK",
|
|
po_status: "sent",
|
|
po_supplier_id: "sup-1",
|
|
po_supplier_name: "HPI INSERTS",
|
|
invoice_supplier_name: "HPI INSERTS",
|
|
ordered_value: 1150,
|
|
received_value: 0,
|
|
unmatched_po_lines: [],
|
|
unmatched_invoice_lines: [],
|
|
line_comparisons: [],
|
|
},
|
|
});
|
|
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 71,
|
|
params: {
|
|
name: "stock_purchases.match_and_receive_from_invoice",
|
|
arguments: {
|
|
po_reference: "PO26080501STK",
|
|
invoice: {
|
|
document_no: "IN389384",
|
|
date: "2026-08-05",
|
|
lines: [
|
|
{ code: "56020510", description: "x", quantity: 2, unit_price: 1150 },
|
|
],
|
|
},
|
|
confirm: true,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(res.status).toBe(422);
|
|
const body = await res.json();
|
|
const data = JSON.parse(body.result.content[0].text);
|
|
expect(data.reason).toBe("qty_mismatch");
|
|
});
|
|
|
|
it("tools/call stock_purchases.match_and_receive_from_invoice returns 200 + receive echo on success", async () => {
|
|
matchAndReceiveFromInvoiceMock.mockResolvedValueOnce({
|
|
ok: true,
|
|
mode: "committed",
|
|
report: {
|
|
verdict: "ready_to_receive",
|
|
po_id: "sp-1",
|
|
po_number: "PO26080501STK",
|
|
po_status: "received",
|
|
po_supplier_id: "sup-1",
|
|
po_supplier_name: "HPI INSERTS",
|
|
invoice_supplier_name: "HPI INSERTS",
|
|
ordered_value: 1150,
|
|
received_value: 1150,
|
|
unmatched_po_lines: [],
|
|
unmatched_invoice_lines: [],
|
|
line_comparisons: [],
|
|
},
|
|
receive: {
|
|
purchase_id: "sp-1",
|
|
purchase_number: "PO26080501STK",
|
|
status: "received",
|
|
received_value: 1150,
|
|
ordered_value: 1150,
|
|
lines_booked: 1,
|
|
},
|
|
attachment: {
|
|
filename: "supplier_invoice_hpi_inserts_IN389384_2026-08-05.jpg",
|
|
added: ["supplier_invoice_hpi_inserts_in389384_2026-08-05_z7ho1rrzk9.jpg"],
|
|
attachments: ["supplier_invoice_hpi_inserts_in389384_2026-08-05_z7ho1rrzk9.jpg"],
|
|
},
|
|
});
|
|
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 72,
|
|
params: {
|
|
name: "stock_purchases.match_and_receive_from_invoice",
|
|
arguments: {
|
|
po_reference: "PO26080501STK",
|
|
invoice: {
|
|
document_no: "IN389384",
|
|
date: "2026-08-05",
|
|
lines: [
|
|
{ code: "56020510", description: "HPI RIVNUT TOOL 510 M3 - M12", quantity: 1, unit_price: 1150 },
|
|
],
|
|
},
|
|
attachment: {
|
|
filename: "IN389384.jpg",
|
|
content_type: "image/jpeg",
|
|
base64: Buffer.from("fake-jpeg-bytes").toString("base64"),
|
|
},
|
|
confirm: true,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
const data = JSON.parse(body.result.content[0].text);
|
|
expect(data.ok).toBe(true);
|
|
expect(data.mode).toBe("committed");
|
|
expect(data.receive.lines_booked).toBe(1);
|
|
expect(data.attachment.added).toHaveLength(1);
|
|
});
|
|
|
|
it("tools/call stock_purchases.match_and_receive_from_invoice returns 400 when po_reference is missing", async () => {
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 73,
|
|
params: {
|
|
name: "stock_purchases.match_and_receive_from_invoice",
|
|
arguments: {
|
|
invoice: { document_no: "X", date: "2026-08-05", lines: [{ description: "x", quantity: 1, unit_price: 1 }] },
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("tools/call stock_purchase_items.update delegates to the service", async () => {
|
|
updateStockPurchaseItem.mockResolvedValueOnce({
|
|
item: { id: "spi-1", sku: "NEW-SKU" },
|
|
previous: { sku: "OLD-SKU" },
|
|
});
|
|
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 64,
|
|
params: {
|
|
name: "stock_purchase_items.update",
|
|
arguments: {
|
|
id: "spi-1",
|
|
patch: { sku: "NEW-SKU" },
|
|
force_overwrite: true,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(updateStockPurchaseItem).toHaveBeenCalledWith({
|
|
id: "spi-1",
|
|
patch: { sku: "NEW-SKU" },
|
|
force_overwrite: true,
|
|
});
|
|
const body = await res.json();
|
|
const data = JSON.parse(body.result.content[0].text);
|
|
expect(data).toMatchObject({
|
|
item: { id: "spi-1", sku: "NEW-SKU" },
|
|
previous: { sku: "OLD-SKU" },
|
|
});
|
|
});
|
|
|
|
it("tools/call stock_purchase_items.update returns 409 when the line is already_priced", async () => {
|
|
updateStockPurchaseItem.mockRejectedValueOnce(
|
|
new Error("already_priced: cost_price > 0 already"),
|
|
);
|
|
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 65,
|
|
params: {
|
|
name: "stock_purchase_items.update",
|
|
arguments: { id: "spi-1", patch: { cost_price: 12.5 } },
|
|
},
|
|
}),
|
|
);
|
|
expect(res.status).toBe(409);
|
|
const body = await res.json();
|
|
const data = JSON.parse(body.result.content[0].text);
|
|
expect(data).toMatchObject({
|
|
ok: false,
|
|
reason: "already_priced",
|
|
});
|
|
});
|
|
|
|
it("tools/call stock_purchase_items.update returns 400 on forbidden_field", async () => {
|
|
updateStockPurchaseItem.mockRejectedValueOnce(
|
|
new Error("forbidden_field: current_qty"),
|
|
);
|
|
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 66,
|
|
params: {
|
|
name: "stock_purchase_items.update",
|
|
arguments: { id: "spi-1", patch: { current_qty: 99 } },
|
|
},
|
|
}),
|
|
);
|
|
expect(res.status).toBe(400);
|
|
const body = await res.json();
|
|
const data = JSON.parse(body.result.content[0].text);
|
|
expect(data).toMatchObject({ ok: false, reason: "forbidden_field" });
|
|
});
|
|
|
|
it("tools/call stock_purchase_items.bulk_update returns the batch result envelope", async () => {
|
|
bulkUpdateStockPurchaseItems.mockResolvedValueOnce({
|
|
updated: 2,
|
|
skipped: 0,
|
|
errors: [],
|
|
results: [
|
|
{ id: "spi-1", status: "ok" },
|
|
{ id: "spi-2", status: "ok" },
|
|
],
|
|
});
|
|
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 67,
|
|
params: {
|
|
name: "stock_purchase_items.bulk_update",
|
|
arguments: {
|
|
items: [
|
|
{ id: "spi-1", patch: { sku: "A" } },
|
|
{ id: "spi-2", patch: { sku: "B" } },
|
|
],
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(bulkUpdateStockPurchaseItems).toHaveBeenCalledTimes(1);
|
|
const body = await res.json();
|
|
const data = JSON.parse(body.result.content[0].text);
|
|
expect(data.updated).toBe(2);
|
|
expect(data.results).toHaveLength(2);
|
|
});
|
|
|
|
it("tools/call stock_purchase_items.bulk_update rejects an empty items array with JSON-RPC error", async () => {
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 68,
|
|
params: {
|
|
name: "stock_purchase_items.bulk_update",
|
|
arguments: { items: [] },
|
|
},
|
|
}),
|
|
);
|
|
expect(res.status).toBe(500);
|
|
const body = await res.json();
|
|
expect(body.error.message).toMatch(/items array/);
|
|
expect(bulkUpdateStockPurchaseItems).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("tools/call stock_purchase_items.assign_driver binds the line and returns the item", async () => {
|
|
assignDriver.mockResolvedValueOnce({
|
|
item: { id: "spi-1", status: "assigned_to_driver", drv_id: "drv-9" },
|
|
});
|
|
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 69,
|
|
params: {
|
|
name: "stock_purchase_items.assign_driver",
|
|
arguments: { id: "spi-1", drv_id: "drv-9" },
|
|
},
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(assignDriver).toHaveBeenCalledWith({
|
|
id: "spi-1",
|
|
drv_id: "drv-9",
|
|
assigned_at: undefined,
|
|
});
|
|
const body = await res.json();
|
|
const data = JSON.parse(body.result.content[0].text);
|
|
expect(data.item.status).toBe("assigned_to_driver");
|
|
});
|
|
|
|
it("tools/call stock_purchase_items.assign_driver returns 409 on wrong_status", async () => {
|
|
assignDriver.mockRejectedValueOnce(
|
|
new Error("wrong_status: cannot assign driver to a line in status \"collected\""),
|
|
);
|
|
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 70,
|
|
params: {
|
|
name: "stock_purchase_items.assign_driver",
|
|
arguments: { id: "spi-1", drv_id: "drv-9" },
|
|
},
|
|
}),
|
|
);
|
|
expect(res.status).toBe(409);
|
|
});
|
|
|
|
it("tools/call stock_purchase_items.mark_collected returns the line + header_flip info", async () => {
|
|
markLineCollected.mockResolvedValueOnce({
|
|
item: { id: "spi-1", status: "collected", quantity_received: 4 },
|
|
previous_quantity_received: 0,
|
|
new_quantity_received: 4,
|
|
header_status_after: "received",
|
|
header_flipped: true,
|
|
});
|
|
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 71,
|
|
params: {
|
|
name: "stock_purchase_items.mark_collected",
|
|
arguments: { id: "spi-1", quantity_collected: 4 },
|
|
},
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(markLineCollected).toHaveBeenCalledWith({
|
|
id: "spi-1",
|
|
quantity_collected: 4,
|
|
});
|
|
const body = await res.json();
|
|
const data = JSON.parse(body.result.content[0].text);
|
|
expect(data).toMatchObject({
|
|
header_status_after: "received",
|
|
header_flipped: true,
|
|
new_quantity_received: 4,
|
|
});
|
|
});
|
|
|
|
it("tools/call stock_purchase_items.mark_collected returns 409 on wrong_status", async () => {
|
|
markLineCollected.mockRejectedValueOnce(
|
|
new Error("wrong_status: cannot mark collected from status \"collected\""),
|
|
);
|
|
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 72,
|
|
params: {
|
|
name: "stock_purchase_items.mark_collected",
|
|
arguments: { id: "spi-1" },
|
|
},
|
|
}),
|
|
);
|
|
expect(res.status).toBe(409);
|
|
});
|
|
|
|
it("tools/call stock_items.update delegates to the service", async () => {
|
|
updateStockItem.mockResolvedValueOnce({
|
|
ok: true,
|
|
item: { id: "si-1", sku: "NEW-SKU" },
|
|
previous: { sku: "OLD-SKU" },
|
|
});
|
|
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 73,
|
|
params: {
|
|
name: "stock_items.update",
|
|
arguments: { id: "si-1", patch: { sku: "NEW-SKU" } },
|
|
},
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(updateStockItem).toHaveBeenCalledWith({
|
|
id: "si-1",
|
|
patch: { sku: "NEW-SKU" },
|
|
});
|
|
const body = await res.json();
|
|
const data = JSON.parse(body.result.content[0].text);
|
|
expect(data.ok).toBe(true);
|
|
expect(data.item.sku).toBe("NEW-SKU");
|
|
});
|
|
|
|
it("tools/call stock_items.update returns 404 on not_found", async () => {
|
|
updateStockItem.mockResolvedValueOnce({
|
|
ok: false,
|
|
reason: "not_found",
|
|
message: "stock_items row missing not found",
|
|
});
|
|
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 74,
|
|
params: {
|
|
name: "stock_items.update",
|
|
arguments: { id: "missing", patch: { sku: "X" } },
|
|
},
|
|
}),
|
|
);
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("tools/call stock_items.update returns 400 on forbidden_field", async () => {
|
|
updateStockItem.mockResolvedValueOnce({
|
|
ok: false,
|
|
reason: "forbidden_field",
|
|
message: "forbidden_field: current_qty",
|
|
field: "current_qty",
|
|
});
|
|
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 75,
|
|
params: {
|
|
name: "stock_items.update",
|
|
arguments: { id: "si-1", patch: { current_qty: 99 } },
|
|
},
|
|
}),
|
|
);
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("tools/call stock_items.update returns 422 on pb_error", async () => {
|
|
updateStockItem.mockResolvedValueOnce({
|
|
ok: false,
|
|
reason: "pb_error",
|
|
message: "write conflict",
|
|
});
|
|
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 76,
|
|
params: {
|
|
name: "stock_items.update",
|
|
arguments: { id: "si-1", patch: { sku: "X" } },
|
|
},
|
|
}),
|
|
);
|
|
expect(res.status).toBe(422);
|
|
});
|
|
|
|
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
|
|
// MCP client (Hermes, Claude Desktop, Cursor, …) abort the session
|
|
// before `tools/list` ever ran. Spec-compliant clients expect HTTP 200
|
|
// with a well-formed `result` containing `protocolVersion`, `capabilities`,
|
|
// and `serverInfo`.
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "initialize",
|
|
id: 100,
|
|
params: {
|
|
protocolVersion: "2024-11-05",
|
|
capabilities: {},
|
|
clientInfo: { name: "test-client", version: "0.0.0" },
|
|
},
|
|
})
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
expect(body.jsonrpc).toBe("2.0");
|
|
expect(body.id).toBe(100);
|
|
expect(body.error).toBeUndefined();
|
|
expect(body.result.protocolVersion).toBe("2024-11-05");
|
|
expect(body.result.capabilities).toEqual({ tools: {} });
|
|
expect(body.result.serverInfo).toMatchObject({ name: "wfdash-mcp" });
|
|
});
|
|
|
|
it("initialize echoes back an unrecognised protocolVersion so older clients keep working", async () => {
|
|
// The spec says servers MAY support older protocol versions. We
|
|
// echo whatever the client sent (rather than forcing 2024-11-05)
|
|
// so a client on 2025-03-26 doesn't immediately drop us.
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "initialize",
|
|
id: 101,
|
|
params: { protocolVersion: "2025-06-18-future-version" },
|
|
})
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
expect(body.result.protocolVersion).toBe("2025-06-18-future-version");
|
|
});
|
|
|
|
it("acknowledges notifications/initialized with HTTP 204 and no body", async () => {
|
|
// JSON-RPC notification: no `id`, server returns no JSON-RPC envelope.
|
|
// Clients shouldn't hang waiting for a reply that won't come.
|
|
const res = await POST(
|
|
mcpRequest({ method: "notifications/initialized" })
|
|
);
|
|
expect(res.status).toBe(204);
|
|
const text = await res.text();
|
|
expect(text).toBe("");
|
|
});
|
|
|
|
it("answers ping with an empty result (liveness probe)", async () => {
|
|
const res = await POST(
|
|
mcpRequest({ method: "ping", id: 102 })
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
expect(body.result).toEqual({});
|
|
});
|
|
|
|
it("rejects unknown tools with a JSON-RPC error", async () => {
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 7,
|
|
params: { name: "not.a.tool", arguments: {} },
|
|
})
|
|
);
|
|
expect(res.status).toBe(500);
|
|
const body = await res.json();
|
|
expect(body.error.message).toMatch(/Unknown tool/);
|
|
});
|
|
|
|
it("tools/call tools.discovery returns the flat tool catalog (JSON-RPC form of GET /api/mcp)", async () => {
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 8,
|
|
params: { name: "tools.discovery", arguments: {} },
|
|
})
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
const payload = JSON.parse(body.result.content[0].text);
|
|
expect(Array.isArray(payload.tools)).toBe(true);
|
|
expect(payload.tools.length).toBeGreaterThan(60);
|
|
// Every entry has name + description + inputSchema
|
|
for (const t of payload.tools) {
|
|
expect(typeof t.name).toBe("string");
|
|
expect(typeof t.description).toBe("string");
|
|
expect(t.inputSchema).toBeDefined();
|
|
}
|
|
expect(payload.tools.map((t: { name: string }) => t.name)).toContain("tools.discovery");
|
|
});
|
|
|
|
it("tools/call quotes.create_from_email delegates to the service with the full payload", async () => {
|
|
createQuoteFromEmail.mockResolvedValueOnce({
|
|
quote: { id: "q1" },
|
|
customer: { id: "c1" },
|
|
company: { id: "co1" },
|
|
items: [
|
|
{ id: "qi1", description: "Item 1" },
|
|
{ id: "qi2", description: "Item 2" },
|
|
{ id: "qi3", description: "Item 3" },
|
|
],
|
|
items_count: 3,
|
|
attachments: 1,
|
|
deduped: false,
|
|
quote_number: "Q-2026-06-001",
|
|
});
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 9,
|
|
params: {
|
|
name: "quotes.create_from_email",
|
|
arguments: {
|
|
from_email: "buyer@newco.co.za",
|
|
from_name: "Buyer Person",
|
|
subject: "RFQ: Mining equipment",
|
|
body_plain: "Please quote on the following.",
|
|
message_id: "<abc@example.com>",
|
|
company_hint: "NewCo Mining",
|
|
extracted_items: [
|
|
{ description: "20mm cable ties (pack of 100)", quantity: 5 },
|
|
{ description: "Cable gland M20", quantity: 10 },
|
|
],
|
|
attachments: [
|
|
{ filename: "rfq.pdf", content_type: "application/pdf", base64: "AA==" },
|
|
],
|
|
},
|
|
},
|
|
})
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(createQuoteFromEmail).toHaveBeenCalledTimes(1);
|
|
const passedArg = createQuoteFromEmail.mock.calls[0][0];
|
|
expect(passedArg.from_email).toBe("buyer@newco.co.za");
|
|
expect(passedArg.message_id).toBe("<abc@example.com>");
|
|
expect(passedArg.extracted_items).toHaveLength(2);
|
|
expect(passedArg.attachments).toHaveLength(1);
|
|
expect(passedArg.attachments[0].filename).toBe("rfq.pdf");
|
|
});
|
|
|
|
it("tools/call quotes.update patches whitelisted fields and returns the updated quote (gap fix 2026-08-05)", async () => {
|
|
// The agent's gap analysis flagged the absence of quotes.update. We
|
|
// verify the new tool calls pb.collection("quotes").update with only
|
|
// the whitelisted fields and surfaces a 200 with the updated record.
|
|
update.mockResolvedValueOnce({
|
|
id: "q1",
|
|
company_letterhead: "ISHERWOOD",
|
|
quote_reference: "RFQ-2026-100",
|
|
notes: "Updated by agent",
|
|
});
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 50,
|
|
params: {
|
|
name: "quotes.update",
|
|
arguments: {
|
|
id: "q1",
|
|
patch: {
|
|
company_letterhead: "ISHERWOOD",
|
|
quote_reference: "RFQ-2026-100",
|
|
notes: "Updated by agent",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(update).toHaveBeenCalledTimes(1);
|
|
expect(update).toHaveBeenCalledWith("q1", {
|
|
company_letterhead: "ISHERWOOD",
|
|
quote_reference: "RFQ-2026-100",
|
|
notes: "Updated by agent",
|
|
});
|
|
const body = await res.json();
|
|
// Route wraps result in the tools/call content envelope; unwrap it.
|
|
const payload = JSON.parse(body.result.content[0].text);
|
|
expect(payload.quote.id).toBe("q1");
|
|
expect(payload.quote.company_letterhead).toBe("ISHERWOOD");
|
|
});
|
|
|
|
it("tools/call quotes.update rejects forbidden fields (status, customer, company, quote_number, id, created, updated)", async () => {
|
|
// Whitelist protects system-controlled fields. Sending any of these
|
|
// must error with forbidden_field rather than silently writing to PB.
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 51,
|
|
params: {
|
|
name: "quotes.update",
|
|
arguments: {
|
|
id: "q1",
|
|
patch: {
|
|
status: "approved", // system-controlled
|
|
customer: "bad-id", // relation — system-controlled
|
|
company: "bad-id", // relation — system-controlled
|
|
quote_number: "Q-FOO", // generated by server
|
|
id: "different-id", // immutable
|
|
},
|
|
},
|
|
},
|
|
})
|
|
);
|
|
expect(res.status).toBe(500); // JSON-RPC error envelope path
|
|
const body = await res.json();
|
|
expect(body.error.message).toMatch(/forbidden_field/);
|
|
expect(body.error.message).toMatch(/status/);
|
|
expect(body.error.message).toMatch(/customer/);
|
|
expect(update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("tools/call quotes.update rejects empty patch", async () => {
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 52,
|
|
params: {
|
|
name: "quotes.update",
|
|
arguments: { id: "q1", patch: {} },
|
|
},
|
|
})
|
|
);
|
|
expect(res.status).toBe(500);
|
|
const body = await res.json();
|
|
expect(body.error.message).toMatch(/empty/);
|
|
});
|
|
|
|
it("tools/call quotes.update requires id", async () => {
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 53,
|
|
params: {
|
|
name: "quotes.update",
|
|
arguments: { patch: { company_letterhead: "ISHERWOOD" } },
|
|
},
|
|
})
|
|
);
|
|
expect(res.status).toBe(500);
|
|
const body = await res.json();
|
|
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" },
|
|
reply: { id: "r1" },
|
|
matched_by: "in_reply_to",
|
|
});
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 10,
|
|
params: {
|
|
name: "quotes.append_reply",
|
|
arguments: {
|
|
from_email: "buyer@newco.co.za",
|
|
subject: "Re: Quotation Q-2026-06-001",
|
|
body_plain: "Approved, please proceed.",
|
|
message_id: "<reply1@example.com>",
|
|
in_reply_to: "<abc@example.com>",
|
|
received_at: "2026-06-11T10:00:00.000Z",
|
|
},
|
|
},
|
|
})
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(appendReplyToQuote).toHaveBeenCalledTimes(1);
|
|
const body = await res.json();
|
|
expect(JSON.parse(body.result.content[0].text).matched_by).toBe("in_reply_to");
|
|
});
|
|
|
|
it("tools/call quotes.append_reply returns matched_by='none' for an unmatched reply", async () => {
|
|
appendReplyToQuote.mockResolvedValueOnce({
|
|
quote: null,
|
|
reply: null,
|
|
matched_by: "none",
|
|
});
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 11,
|
|
params: {
|
|
name: "quotes.append_reply",
|
|
arguments: { from_email: "stranger@x.com", subject: "Random reply" },
|
|
},
|
|
})
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
expect(JSON.parse(body.result.content[0].text).matched_by).toBe("none");
|
|
});
|
|
|
|
it("tools/call quotes.find_by_message_id returns the matching quote", async () => {
|
|
findQuoteByMessageId.mockResolvedValueOnce({ id: "q1", quote_number: "Q-2026-06-001" });
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 12,
|
|
params: { name: "quotes.find_by_message_id", arguments: { message_id: "<abc@example.com>" } },
|
|
})
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(findQuoteByMessageId).toHaveBeenCalledWith("<abc@example.com>");
|
|
const body = await res.json();
|
|
expect(JSON.parse(body.result.content[0].text).quote.id).toBe("q1");
|
|
});
|
|
|
|
it("tools/call quotes.find_by_message_id fails fast when message_id is missing", async () => {
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 13,
|
|
params: { name: "quotes.find_by_message_id", arguments: {} },
|
|
})
|
|
);
|
|
expect(res.status).toBe(500);
|
|
const body = await res.json();
|
|
expect(body.error.message).toMatch(/Missing message_id/);
|
|
});
|
|
|
|
it("tools/call quotes.find_by_message_id returns null quote when no match", async () => {
|
|
findQuoteByMessageId.mockResolvedValueOnce(null);
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 14,
|
|
params: { name: "quotes.find_by_message_id", arguments: { message_id: "<missing@example.com>" } },
|
|
})
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
expect(JSON.parse(body.result.content[0].text).quote).toBeNull();
|
|
});
|
|
|
|
it("tools/call purchase_orders.add_attachment sends a FormData file and returns the updated attachments array", async () => {
|
|
update.mockResolvedValueOnce({ additional_attachments: ["new-token"] });
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 15,
|
|
params: {
|
|
name: "purchase_orders.add_attachment",
|
|
arguments: {
|
|
po_id: "po-1",
|
|
filename: "supplier-quote.pdf",
|
|
content_type: "application/pdf",
|
|
// base64-encoded "hi" → proves bytes round-trip through FormData.
|
|
base64: Buffer.from("hi").toString("base64"),
|
|
},
|
|
},
|
|
})
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(update).toHaveBeenCalledTimes(1);
|
|
expect(update).toHaveBeenCalledWith("po-1", expect.any(FormData));
|
|
const body = await res.json();
|
|
const parsed = JSON.parse(body.result.content[0].text);
|
|
expect(parsed).toEqual({ ok: true, attachments: ["new-token"] });
|
|
});
|
|
|
|
it("tools/call purchase_orders.add_attachment fails fast when po_id/filename/base64 are missing", async () => {
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 16,
|
|
params: {
|
|
name: "purchase_orders.add_attachment",
|
|
arguments: { filename: "x.pdf" },
|
|
},
|
|
})
|
|
);
|
|
expect(res.status).toBe(500);
|
|
const body = await res.json();
|
|
expect(body.error.message).toMatch(/Missing po_id, filename, or base64/);
|
|
expect(update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns method-not-found for unknown JSON-RPC methods", async () => {
|
|
const res = await POST(mcpRequest({ method: "tools/wat", id: 8 }));
|
|
expect(res.status).toBe(404);
|
|
const body = await res.json();
|
|
expect(body.error.code).toBe(-32601);
|
|
});
|
|
|
|
// ─── Management CRUD ─────────────────────────────────────────────────
|
|
describe("management CRUD tools", () => {
|
|
it("companies.list forwards search to the filter clause", async () => {
|
|
getList.mockResolvedValueOnce({ items: [{ id: "c1", company: "Acme" }] });
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 100,
|
|
params: { name: "companies.list", arguments: { search: "Acme", page: 1, perPage: 5 } },
|
|
})
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(getList).toHaveBeenCalledWith(1, 5, {
|
|
filter: 'company ~ "Acme" || vendor ~ "Acme"',
|
|
sort: "-created",
|
|
});
|
|
});
|
|
|
|
it("companies.get fetches one row by id", async () => {
|
|
// companies.get uses getOne, which isn't on our getList/create/update mocks.
|
|
// Add getOne to the mock collection on the fly.
|
|
const getOne = vi.fn().mockResolvedValueOnce({ id: "c1", company: "Acme" });
|
|
const pbModule = await import("@/lib/server/pocketbase-admin");
|
|
(pbModule.getAdminPocketBase as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
collection: () => ({ getList, getOne, create, update, delete: vi.fn() }),
|
|
});
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 101,
|
|
params: { name: "companies.get", arguments: { id: "c1" } },
|
|
})
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(getOne).toHaveBeenCalledWith("c1");
|
|
});
|
|
|
|
it("companies.create delegates to the service with sanitised fields", async () => {
|
|
createCompany.mockResolvedValueOnce({ id: "c-new", company: "New Co" });
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 102,
|
|
params: {
|
|
name: "companies.create",
|
|
arguments: {
|
|
company: " New Co ",
|
|
address: "1 Road",
|
|
vendor: "V1",
|
|
coordinates: { lon: 28.04, lat: -26.2 },
|
|
},
|
|
},
|
|
})
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(createCompany).toHaveBeenCalledWith(expect.anything(), {
|
|
company: " New Co ",
|
|
address: "1 Road",
|
|
vendor: "V1",
|
|
coordinates: { lon: 28.04, lat: -26.2 },
|
|
});
|
|
const body = await res.json();
|
|
expect(JSON.parse(body.result.content[0].text).company.id).toBe("c-new");
|
|
});
|
|
|
|
it("companies.create fails fast without a company name", async () => {
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 103,
|
|
params: { name: "companies.create", arguments: { address: "1 Road" } },
|
|
})
|
|
);
|
|
expect(res.status).toBe(500);
|
|
const body = await res.json();
|
|
expect(body.error.message).toMatch(/Missing company name/);
|
|
expect(createCompany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("companies.update forwards the patch object", async () => {
|
|
updateCompany.mockResolvedValueOnce({ id: "c1", vendor: "V2" });
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 104,
|
|
params: {
|
|
name: "companies.update",
|
|
arguments: { id: "c1", patch: { vendor: "V2" } },
|
|
},
|
|
})
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(updateCompany).toHaveBeenCalledWith(expect.anything(), "c1", { vendor: "V2" });
|
|
});
|
|
|
|
it("companies.update fails fast without patch", async () => {
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 105,
|
|
params: { name: "companies.update", arguments: { id: "c1" } },
|
|
})
|
|
);
|
|
expect(res.status).toBe(500);
|
|
const body = await res.json();
|
|
expect(body.error.message).toMatch(/Missing patch/);
|
|
});
|
|
|
|
it("companies.delete delegates and returns the deleted id", async () => {
|
|
deleteCompany.mockResolvedValueOnce(undefined);
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 106,
|
|
params: { name: "companies.delete", arguments: { id: "c1" } },
|
|
})
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(deleteCompany).toHaveBeenCalledWith(expect.anything(), "c1");
|
|
const body = await res.json();
|
|
expect(JSON.parse(body.result.content[0].text)).toEqual({ deleted: "c1" });
|
|
});
|
|
|
|
it("customers.list forwards expand param", async () => {
|
|
getList.mockResolvedValueOnce({ items: [] });
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 110,
|
|
params: {
|
|
name: "customers.list",
|
|
arguments: { page: 1, perPage: 5, expand: "company,rep" },
|
|
},
|
|
})
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(getList).toHaveBeenCalledWith(1, 5, {
|
|
filter: "",
|
|
sort: "-created",
|
|
expand: "company,rep",
|
|
});
|
|
});
|
|
|
|
it("customers.create passes name-resolution fields through to the service", async () => {
|
|
createCustomer.mockResolvedValueOnce({ id: "cu-new", customer: "Alice" });
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 111,
|
|
params: {
|
|
name: "customers.create",
|
|
arguments: {
|
|
customer: "Alice",
|
|
email: "alice@example.com",
|
|
company: "Acme Mining",
|
|
rep: "John Smith",
|
|
coordinates: { lon: 28, lat: -26 },
|
|
},
|
|
},
|
|
})
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(createCustomer).toHaveBeenCalledWith(expect.anything(), {
|
|
customer: "Alice",
|
|
email: "alice@example.com",
|
|
alternative_email: undefined,
|
|
buyer_email: undefined,
|
|
number: undefined,
|
|
company: "Acme Mining",
|
|
rep: "John Smith",
|
|
coordinates: { lon: 28, lat: -26 },
|
|
description: undefined,
|
|
});
|
|
});
|
|
|
|
it("customers.update accepts an empty string to unset the company relation", async () => {
|
|
updateCustomer.mockResolvedValueOnce({ id: "cu1", company: "" });
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 112,
|
|
params: {
|
|
name: "customers.update",
|
|
arguments: { id: "cu1", patch: { company: "" } },
|
|
},
|
|
})
|
|
);
|
|
expect(res.status).toBe(200);
|
|
// The "" resolves to an explicit empty string in the service, which
|
|
// translates to "unset the relation" at the PB level.
|
|
expect(updateCustomer).toHaveBeenCalledWith(expect.anything(), "cu1", { company: "" });
|
|
});
|
|
|
|
it("customers.delete returns the deleted id", async () => {
|
|
deleteCustomer.mockResolvedValueOnce(undefined);
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 113,
|
|
params: { name: "customers.delete", arguments: { id: "cu1" } },
|
|
})
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
expect(JSON.parse(body.result.content[0].text)).toEqual({ deleted: "cu1" });
|
|
});
|
|
|
|
it("suppliers.create passes the text-format coordinates through", async () => {
|
|
createSupplier.mockResolvedValueOnce({ id: "su-new" });
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 120,
|
|
params: {
|
|
name: "suppliers.create",
|
|
arguments: {
|
|
suppliername: "Fastener Co",
|
|
email: "fc@example.com",
|
|
coordinates: "-26.2, 28.04",
|
|
},
|
|
},
|
|
})
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(createSupplier).toHaveBeenCalledWith(expect.anything(), {
|
|
suppliername: "Fastener Co",
|
|
email: "fc@example.com",
|
|
suppliernumber: undefined,
|
|
supplieraddress: undefined,
|
|
supply_tags: undefined,
|
|
coordinates: "-26.2, 28.04",
|
|
closingtime: undefined,
|
|
});
|
|
});
|
|
|
|
it("suppliers.update forwards the patch object", async () => {
|
|
updateSupplier.mockResolvedValueOnce({ id: "su1" });
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 121,
|
|
params: {
|
|
name: "suppliers.update",
|
|
arguments: { id: "su1", patch: { closingtime: "16:30" } },
|
|
},
|
|
})
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(updateSupplier).toHaveBeenCalledWith(expect.anything(), "su1", { closingtime: "16:30" });
|
|
});
|
|
|
|
it("suppliers.delete delegates and returns the deleted id", async () => {
|
|
deleteSupplier.mockResolvedValueOnce(undefined);
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 122,
|
|
params: { name: "suppliers.delete", arguments: { id: "su1" } },
|
|
})
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(deleteSupplier).toHaveBeenCalledWith(expect.anything(), "su1");
|
|
});
|
|
});
|
|
|
|
// ─── bulk_update_quote_items ─────────────────────────────────────────
|
|
// Stand up a per-test collection factory so we can control
|
|
// `getOne` (hand-edit-guard state) and `update` (recorded PATCH)
|
|
// behaviour independently for each scenario.
|
|
describe("bulk_update_quote_items", () => {
|
|
/** Build a fresh per-test `quote_items` collection mock and
|
|
* register it on `getAdminPocketBase` for one call. */
|
|
async function mockCollection(opts: {
|
|
existing?: Record<string, { cost_price?: number; selling_price?: number }>;
|
|
update?: ReturnType<typeof vi.fn>;
|
|
getOneThrow?: boolean;
|
|
}) {
|
|
const getOne = vi.fn();
|
|
const localUpdate = opts.update ?? vi.fn();
|
|
if (opts.getOneThrow) {
|
|
getOne.mockImplementation(() => {
|
|
throw new Error("not_found");
|
|
});
|
|
} else if (opts.existing) {
|
|
for (const [id, row] of Object.entries(opts.existing)) {
|
|
getOne.mockImplementationOnce((calledId: string) => {
|
|
if (calledId === id) return Promise.resolve({ id, ...row });
|
|
return Promise.reject(new Error(`unexpected getOne(${calledId})`));
|
|
});
|
|
}
|
|
// Default fallback so any unexpected id lookup throws.
|
|
getOne.mockImplementation((calledId: string) =>
|
|
Promise.reject(new Error(`Mock has no row for ${calledId}`)),
|
|
);
|
|
}
|
|
const pbModule = await import("@/lib/server/pocketbase-admin");
|
|
(pbModule.getAdminPocketBase as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
collection: () => ({
|
|
getList: vi.fn(),
|
|
getOne,
|
|
create: vi.fn(),
|
|
update: localUpdate,
|
|
delete: vi.fn(),
|
|
}),
|
|
});
|
|
return { getOne, update: localUpdate };
|
|
}
|
|
|
|
it("happy path — patches every row, returns per-row results", async () => {
|
|
const update = vi.fn().mockResolvedValue({ id: "x" });
|
|
await mockCollection({
|
|
existing: {
|
|
qi1: { cost_price: 0, selling_price: 0 },
|
|
qi2: { cost_price: 0, selling_price: 0 },
|
|
},
|
|
update,
|
|
});
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 200,
|
|
params: {
|
|
name: "bulk_update_quote_items",
|
|
arguments: {
|
|
items: [
|
|
{ id: "qi1", patch: { cost_price: 100, selling_price: 125, pricing_notes: "Tier 1 fill" } },
|
|
{ id: "qi2", patch: { cost_price: 50, selling_price: 60, pricing_notes: "Tier 2 fill" } },
|
|
],
|
|
},
|
|
},
|
|
})
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(update).toHaveBeenCalledTimes(2);
|
|
expect(update).toHaveBeenNthCalledWith(1, "qi1", {
|
|
cost_price: 100,
|
|
selling_price: 125,
|
|
pricing_notes: "Tier 1 fill",
|
|
});
|
|
expect(update).toHaveBeenNthCalledWith(2, "qi2", {
|
|
cost_price: 50,
|
|
selling_price: 60,
|
|
pricing_notes: "Tier 2 fill",
|
|
});
|
|
const body = await res.json();
|
|
const parsed = JSON.parse(body.result.content[0].text);
|
|
expect(parsed.ok).toBe(true);
|
|
expect(parsed.updated).toBe(2);
|
|
expect(parsed.skipped).toBe(0);
|
|
expect(parsed.errors).toEqual([]);
|
|
expect(parsed.dry_run).toBe(false);
|
|
expect(parsed.results).toHaveLength(2);
|
|
expect(parsed.results[0]).toMatchObject({
|
|
id: "qi1",
|
|
status: "ok",
|
|
previous_cost_price: 0,
|
|
new_cost_price: 100,
|
|
});
|
|
});
|
|
|
|
it("hand-edit guard — skips a row that is already priced", async () => {
|
|
const update = vi.fn();
|
|
await mockCollection({
|
|
existing: {
|
|
qi1: { cost_price: 100, selling_price: 125 },
|
|
qi2: { cost_price: 0, selling_price: 0 },
|
|
},
|
|
update,
|
|
});
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 201,
|
|
params: {
|
|
name: "bulk_update_quote_items",
|
|
arguments: {
|
|
items: [
|
|
{ id: "qi1", patch: { cost_price: 999, selling_price: 999 } },
|
|
{ id: "qi2", patch: { cost_price: 50, selling_price: 60 } },
|
|
],
|
|
},
|
|
},
|
|
})
|
|
);
|
|
expect(res.status).toBe(200);
|
|
// qi1 was already priced → only qi2 should have been written.
|
|
expect(update).toHaveBeenCalledTimes(1);
|
|
expect(update).toHaveBeenCalledWith("qi2", { cost_price: 50, selling_price: 60 });
|
|
const parsed = JSON.parse((await res.json()).result.content[0].text);
|
|
expect(parsed.updated).toBe(1);
|
|
expect(parsed.skipped).toBe(1);
|
|
expect(parsed.results[0]).toMatchObject({
|
|
id: "qi1",
|
|
status: "skipped",
|
|
reason: "already_priced",
|
|
previous_cost_price: 100,
|
|
previous_selling_price: 125,
|
|
});
|
|
});
|
|
|
|
it("force_overwrite: true overrides the hand-edit guard", async () => {
|
|
const update = vi.fn().mockResolvedValue({ id: "qi1" });
|
|
await mockCollection({
|
|
existing: { qi1: { cost_price: 100, selling_price: 125 } },
|
|
update,
|
|
});
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 202,
|
|
params: {
|
|
name: "bulk_update_quote_items",
|
|
arguments: {
|
|
items: [
|
|
{
|
|
id: "qi1",
|
|
patch: { cost_price: 200, selling_price: 260 },
|
|
force_overwrite: true,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
})
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(update).toHaveBeenCalledWith("qi1", { cost_price: 200, selling_price: 260 });
|
|
const parsed = JSON.parse((await res.json()).result.content[0].text);
|
|
expect(parsed.updated).toBe(1);
|
|
expect(parsed.skipped).toBe(0);
|
|
expect(parsed.results[0].status).toBe("ok");
|
|
});
|
|
|
|
it("rejects forbidden fields (id, quote, created, updated, quantity) with reason=forbidden_field", async () => {
|
|
const update = vi.fn();
|
|
await mockCollection({
|
|
existing: { qi1: { cost_price: 0, selling_price: 0 } },
|
|
update,
|
|
});
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 203,
|
|
params: {
|
|
name: "bulk_update_quote_items",
|
|
arguments: {
|
|
items: [
|
|
{
|
|
id: "qi1",
|
|
patch: {
|
|
cost_price: 100,
|
|
id: "malicious", // system field — must be rejected
|
|
quote: "fake-quote", // relation — must be rejected
|
|
created: "2020-01-01", // system — must be rejected
|
|
quantity: 99, // not in our whitelist — must be rejected
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
})
|
|
);
|
|
expect(res.status).toBe(200);
|
|
// Update was NOT called — patch was rejected wholesale.
|
|
expect(update).not.toHaveBeenCalled();
|
|
const parsed = JSON.parse((await res.json()).result.content[0].text);
|
|
expect(parsed.ok).toBe(false);
|
|
expect(parsed.errors).toHaveLength(1);
|
|
expect(parsed.errors[0].reason).toMatch(/forbidden_field/);
|
|
expect(parsed.errors[0].reason).toContain("id");
|
|
expect(parsed.errors[0].reason).toContain("quote");
|
|
expect(parsed.errors[0].reason).toContain("created");
|
|
expect(parsed.errors[0].reason).toContain("quantity");
|
|
});
|
|
|
|
it("dry_run: true returns would_patch per row and writes nothing", async () => {
|
|
const update = vi.fn();
|
|
await mockCollection({
|
|
existing: { qi1: { cost_price: 0, selling_price: 0 } },
|
|
update,
|
|
});
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 204,
|
|
params: {
|
|
name: "bulk_update_quote_items",
|
|
arguments: {
|
|
dry_run: true,
|
|
items: [
|
|
{
|
|
id: "qi1",
|
|
patch: { cost_price: 100, selling_price: 125, pricing_notes: "probe" },
|
|
},
|
|
],
|
|
},
|
|
},
|
|
})
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(update).not.toHaveBeenCalled();
|
|
const parsed = JSON.parse((await res.json()).result.content[0].text);
|
|
expect(parsed.dry_run).toBe(true);
|
|
expect(parsed.results[0].status).toBe("dry_run");
|
|
expect(parsed.results[0].would_patch).toEqual({
|
|
cost_price: 100,
|
|
selling_price: 125,
|
|
pricing_notes: "probe",
|
|
});
|
|
});
|
|
|
|
it("mixed batch — ok + skipped + not_found leave each row independent", async () => {
|
|
const update = vi.fn().mockResolvedValue({ id: "qi1" });
|
|
await mockCollection({
|
|
existing: {
|
|
qi1: { cost_price: 0, selling_price: 0 },
|
|
qi2: { cost_price: 10, selling_price: 15 },
|
|
},
|
|
update,
|
|
// qi3 intentionally absent — getOne will throw via fallback.
|
|
getOneThrow: false,
|
|
});
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 205,
|
|
params: {
|
|
name: "bulk_update_quote_items",
|
|
arguments: {
|
|
items: [
|
|
{ id: "qi1", patch: { cost_price: 100, selling_price: 125 } },
|
|
{ id: "qi2", patch: { cost_price: 999, selling_price: 999 } },
|
|
{ id: "qi3", patch: { cost_price: 1, selling_price: 2 } },
|
|
],
|
|
},
|
|
},
|
|
})
|
|
);
|
|
expect(res.status).toBe(200);
|
|
// Only qi1 got patched. qi2 skipped, qi3 not_found.
|
|
expect(update).toHaveBeenCalledTimes(1);
|
|
expect(update).toHaveBeenCalledWith("qi1", { cost_price: 100, selling_price: 125 });
|
|
const parsed = JSON.parse((await res.json()).result.content[0].text);
|
|
expect(parsed.updated).toBe(1);
|
|
expect(parsed.skipped).toBe(1);
|
|
expect(parsed.errors).toHaveLength(1);
|
|
expect(parsed.errors[0]).toMatchObject({
|
|
row: 2,
|
|
id: "qi3",
|
|
reason: expect.stringMatching(/not_found/),
|
|
});
|
|
expect(parsed.results.find((r: { id: string }) => r.id === "qi1").status).toBe("ok");
|
|
expect(parsed.results.find((r: { id: string }) => r.id === "qi2").status).toBe("skipped");
|
|
});
|
|
|
|
it("rejects non-array items with a JSON-RPC error", async () => {
|
|
const update = vi.fn();
|
|
await mockCollection({ update });
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 206,
|
|
params: {
|
|
name: "bulk_update_quote_items",
|
|
arguments: { items: "not-an-array" },
|
|
},
|
|
})
|
|
);
|
|
// Non-array is a structural failure — fast-fail, not per-row.
|
|
expect(res.status).toBe(500);
|
|
const body = await res.json();
|
|
expect(body.error.message).toMatch(/non-array `items`/);
|
|
});
|
|
|
|
it("rejects batches larger than the cap of 100", async () => {
|
|
const update = vi.fn();
|
|
await mockCollection({ update });
|
|
const bigItems = Array.from({ length: 101 }, (_, i) => ({
|
|
id: `qi${i}`,
|
|
patch: { cost_price: 1, selling_price: 1 },
|
|
}));
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 207,
|
|
params: {
|
|
name: "bulk_update_quote_items",
|
|
arguments: { items: bigItems },
|
|
},
|
|
})
|
|
);
|
|
expect(res.status).toBe(500);
|
|
const body = await res.json();
|
|
expect(body.error.message).toMatch(/at most 100/);
|
|
});
|
|
|
|
it("validates prices are finite numbers (NaN rejected)", async () => {
|
|
const update = vi.fn();
|
|
await mockCollection({
|
|
existing: { qi1: { cost_price: 0, selling_price: 0 } },
|
|
update,
|
|
});
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 208,
|
|
params: {
|
|
name: "bulk_update_quote_items",
|
|
arguments: {
|
|
items: [{ id: "qi1", patch: { cost_price: Number.NaN, selling_price: 0 } }],
|
|
},
|
|
},
|
|
})
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(update).not.toHaveBeenCalled();
|
|
const parsed = JSON.parse((await res.json()).result.content[0].text);
|
|
expect(parsed.errors[0].reason).toMatch(/validation_error/);
|
|
expect(parsed.errors[0].reason).toMatch(/cost_price/);
|
|
});
|
|
});
|
|
|
|
// ─── quote_items.list ──────────────────────────────────────────────────
|
|
describe("quote_items.list", () => {
|
|
it("forwards page/perPage/filter/sort/fields/expand to PocketBase", async () => {
|
|
getList.mockResolvedValueOnce({ items: [{ id: "qi1" }], totalItems: 1 });
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 300,
|
|
params: {
|
|
name: "quote_items.list",
|
|
arguments: {
|
|
page: 2,
|
|
perPage: 25,
|
|
filter: 'quote="q1"',
|
|
sort: "created",
|
|
fields: "id,description",
|
|
expand: "quote,supplier",
|
|
},
|
|
},
|
|
})
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(getList).toHaveBeenCalledWith(2, 25, {
|
|
filter: 'quote="q1"',
|
|
sort: "created",
|
|
fields: "id,description",
|
|
expand: "quote,supplier",
|
|
});
|
|
});
|
|
|
|
it("clamps perPage at 500", async () => {
|
|
getList.mockResolvedValueOnce({ items: [] });
|
|
await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 301,
|
|
params: { name: "quote_items.list", arguments: { perPage: 9999 } },
|
|
}),
|
|
);
|
|
expect(getList).toHaveBeenCalledWith(1, 500, expect.objectContaining({}));
|
|
});
|
|
|
|
it("uses -created as the default sort", async () => {
|
|
getList.mockResolvedValueOnce({ items: [] });
|
|
await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 302,
|
|
params: { name: "quote_items.list", arguments: {} },
|
|
}),
|
|
);
|
|
expect(getList).toHaveBeenCalledWith(1, 50, expect.objectContaining({ sort: "-created" }));
|
|
});
|
|
});
|
|
|
|
// ─── quote_items.get ───────────────────────────────────────────────────
|
|
describe("quote_items.get", () => {
|
|
it("returns the row and forwards expand/fields", async () => {
|
|
getOne.mockResolvedValueOnce({ id: "qi1", description: "x" });
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 310,
|
|
params: {
|
|
name: "quote_items.get",
|
|
arguments: { id: "qi1", expand: "quote,supplier", fields: "id,description" },
|
|
},
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(getOne).toHaveBeenCalledWith("qi1", {
|
|
expand: "quote,supplier",
|
|
fields: "id,description",
|
|
});
|
|
const parsed = JSON.parse((await res.json()).result.content[0].text);
|
|
expect(parsed.item.id).toBe("qi1");
|
|
});
|
|
|
|
it("fails fast when id is missing", async () => {
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 311,
|
|
params: { name: "quote_items.get", arguments: {} },
|
|
}),
|
|
);
|
|
expect(res.status).toBe(500);
|
|
expect((await res.json()).error.message).toMatch(/Missing id/);
|
|
});
|
|
});
|
|
|
|
// ─── quote_items.create ────────────────────────────────────────────────
|
|
describe("quote_items.create", () => {
|
|
it("validates quote_id is present", async () => {
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 320,
|
|
params: { name: "quote_items.create", arguments: { description: "x" } },
|
|
}),
|
|
);
|
|
expect(res.status).toBe(500);
|
|
expect((await res.json()).error.message).toMatch(/Missing quote_id/);
|
|
});
|
|
|
|
it("rejects empty/whitespace description", async () => {
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 321,
|
|
params: {
|
|
name: "quote_items.create",
|
|
arguments: { quote_id: "q1", description: " " },
|
|
},
|
|
}),
|
|
);
|
|
expect(res.status).toBe(500);
|
|
expect((await res.json()).error.message).toMatch(/description is required/);
|
|
});
|
|
|
|
it("rejects non-positive quantity", async () => {
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 322,
|
|
params: {
|
|
name: "quote_items.create",
|
|
arguments: { quote_id: "q1", description: "x", quantity: 0 },
|
|
},
|
|
}),
|
|
);
|
|
expect(res.status).toBe(500);
|
|
expect((await res.json()).error.message).toMatch(/quantity must be/);
|
|
});
|
|
|
|
it("surfaces a clean error when parent quote is missing", async () => {
|
|
getOne.mockRejectedValueOnce(new Error("not found"));
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 323,
|
|
params: {
|
|
name: "quote_items.create",
|
|
arguments: { quote_id: "missing", description: "x" },
|
|
},
|
|
}),
|
|
);
|
|
expect(res.status).toBe(500);
|
|
expect((await res.json()).error.message).toMatch(/quote missing not found/);
|
|
expect(create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("creates with defaults and denormalises quote_number from parent", async () => {
|
|
getOne.mockResolvedValueOnce({ id: "q1", quote_number: "Q-2026-08-001" });
|
|
create.mockResolvedValueOnce({ id: "qi1" });
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 324,
|
|
params: {
|
|
name: "quote_items.create",
|
|
arguments: { quote_id: "q1", description: "Cable tie 100mm" },
|
|
},
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(getOne).toHaveBeenCalledWith("q1", { fields: "id,quote_number" });
|
|
expect(create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
quote: "q1",
|
|
quote_number: "Q-2026-08-001",
|
|
description: "Cable tie 100mm",
|
|
quantity: 1,
|
|
cost_price: 0,
|
|
selling_price: 0,
|
|
markup: 0,
|
|
sku: "",
|
|
unit: "",
|
|
supplier: null,
|
|
is_processed: false,
|
|
pricing_source: "manual",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("passes through optional fields when supplied", async () => {
|
|
getOne.mockResolvedValueOnce({ id: "q1", quote_number: "Q-2026-08-001" });
|
|
create.mockResolvedValueOnce({ id: "qi1" });
|
|
await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 325,
|
|
params: {
|
|
name: "quote_items.create",
|
|
arguments: {
|
|
quote_id: "q1",
|
|
description: "M16 bolt",
|
|
quantity: 25,
|
|
unit: "each",
|
|
sku: "M16-50",
|
|
supplier: "sup1",
|
|
cost_price: 12.5,
|
|
selling_price: 25,
|
|
markup: 100,
|
|
pricing_source: "price_list",
|
|
pricing_notes: "AI 2026-08-04",
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
expect(create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
supplier: "sup1",
|
|
cost_price: 12.5,
|
|
selling_price: 25,
|
|
markup: 100,
|
|
pricing_source: "price_list",
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── quote_items.update ────────────────────────────────────────────────
|
|
describe("quote_items.update", () => {
|
|
it("fails fast when id is missing", async () => {
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 330,
|
|
params: { name: "quote_items.update", arguments: { patch: { cost_price: 10 } } },
|
|
}),
|
|
);
|
|
expect(res.status).toBe(500);
|
|
expect((await res.json()).error.message).toMatch(/Missing id/);
|
|
});
|
|
|
|
it("fails fast when patch is missing or non-object", async () => {
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 331,
|
|
params: { name: "quote_items.update", arguments: { id: "qi1", patch: [1, 2] } },
|
|
}),
|
|
);
|
|
expect(res.status).toBe(500);
|
|
expect((await res.json()).error.message).toMatch(/Missing or invalid/);
|
|
});
|
|
|
|
it("rejects forbidden fields and lists them in the error", async () => {
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 332,
|
|
params: {
|
|
name: "quote_items.update",
|
|
arguments: {
|
|
id: "qi1",
|
|
patch: { id: "x", created: "y", cost_price: 10, quantity: 99 },
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
expect(res.status).toBe(500);
|
|
const body = await res.json();
|
|
expect(body.error.message).toMatch(/forbidden_field\(s\): id, created, quantity/);
|
|
expect(update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects empty patches", async () => {
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 333,
|
|
params: {
|
|
name: "quote_items.update",
|
|
arguments: { id: "qi1", patch: {} },
|
|
},
|
|
}),
|
|
);
|
|
expect(res.status).toBe(500);
|
|
expect((await res.json()).error.message).toMatch(/empty after whitelist/);
|
|
expect(update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("updates with whitelisted fields only", async () => {
|
|
update.mockResolvedValueOnce({ id: "qi1" });
|
|
const res = await POST(
|
|
mcpRequest({
|
|
method: "tools/call",
|
|
id: 334,
|
|
params: {
|
|
name: "quote_items.update",
|
|
arguments: {
|
|
id: "qi1",
|
|
patch: {
|
|
cost_price: 12.5,
|
|
markup: 33,
|
|
pricing_notes: "AI 2026-08-04",
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(update).toHaveBeenCalledWith("qi1", {
|
|
cost_price: 12.5,
|
|
markup: 33,
|
|
pricing_notes: "AI 2026-08-04",
|
|
});
|
|
});
|
|
});
|
|
});
|