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`)
1061 lines
36 KiB
TypeScript
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/);
|
|
});
|
|
});
|
|
});
|