WFDASH-WEBAPP/src/app/api/mcp/route.test.ts
SJCoetz 811493bedb feat(collection-pool): add page and service function
Restores the /collection-pool feature in this codebase.

- src/lib/server/orders-service.ts
  - Re-adds `listCollectionPool({ status, page, perPage })` which
    queries the `collection_pool` PB collection via the admin
    client (filter on pool_status, sort -created, expand
    order_item/order/supplier/drv_id). MCP route.ts already
    imported this — without it, the MCP route failed to
    compile.

- src/app/(app)/collection-pool/{page,components,view-model}*.tsx
  - Re-creates the page directory (5 files, 2 315 lines) carrying
    the supplier-group + per-stop checkbox UI, the
    `CollectionPoolRowSource = "customer" | "stock"` union, the
    assignment-state badge helpers (buildProximityMarkerMap,
    buildSupplierGroups, buildVisibleRows), and the
    MarkReadyDialog / AssignDriverDialog wiring.

- src/app/api/mcp/route.test.ts
  - Adds `const listCollectionPool = vi.fn()` and exposes it in
    the vi.mock factory so the existing collection_pool.list test
    cases (and the bulk_update_quote_items ones that call
    `.mockReset()` on every fixture) stop crashing with
    'ReferenceError: listCollectionPool is not defined'.

Not in this commit (kept narrow per request):
- The /api/v1/collection-pool route handler and its test
- The 'Collection Pool' entry in the sidebar nav
- The migration script that creates the live `collection_pool`
  PB collection (it doesn't exist on the cloud PB instance
  yet; the page render relies on it being created separately
  or via `npm run pb:migrate:orders-v2`)
2026-07-27 15:04:30 +02:00

1061 lines
36 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from "vitest";
import { NextRequest } from "next/server";
const getList = vi.fn();
const create = vi.fn();
const update = vi.fn();
vi.mock("@/lib/server/pocketbase-admin", () => ({
getAdminPocketBase: vi.fn().mockResolvedValue({
collection: () => ({ getList, 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),
}));
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();
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();
});
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.create_from_quote",
"collection_pool.list",
"order_items.update_status",
"quotes.create_from_email",
"quotes.append_reply",
"quotes.find_by_message_id",
"attachments.upload_for_quote",
// Purchase order additional attachments (added 2026-07-09 — PO email extras)
"purchase_orders.add_attachment",
// Bulk quote_items (added 2026-07-02 — AI auto-fill agents)
"bulk_update_quote_items",
// 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",
]);
});
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("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 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: 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.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/);
});
});
});