fix(mcp): tell the agent what repairs.upload_file does, and test the rest of the repair tools

This commit is contained in:
Dave 2026-11-06 07:39:58 +02:00
parent 9146fd46f0
commit c4936613e1
2 changed files with 314 additions and 12 deletions

View file

@ -4356,8 +4356,8 @@ describe("repairs MCP tools", () => {
items: [],
totalItems: 0,
totalPages: 0,
page: 1,
perPage: 20,
page: 2,
perPage: 5,
});
const res = await POST(
@ -4366,13 +4366,18 @@ describe("repairs MCP tools", () => {
id: 2,
params: {
name: "repairs.list",
arguments: { status: "assessing", page: 1, perPage: 20 },
arguments: { status: "assessing", page: 2, perPage: 5 },
},
}),
);
expect(res.status).toBe(200);
const body = await res.json();
const payload = JSON.parse(body.result.content[0].text);
expect(payload.page).toBe(2);
expect(payload.perPage).toBe(5);
expect(listRepairs).toHaveBeenCalledWith(
expect.objectContaining({ status: "assessing" }),
expect.objectContaining({ status: "assessing", page: 2, perPage: 5 }),
);
});
@ -4389,6 +4394,113 @@ describe("repairs MCP tools", () => {
expect(body.error.message).toMatch(/Invalid status/);
});
it("repairs.get rejects a missing id", async () => {
const res = await POST(
mcpRequest({
method: "tools/call",
id: 9,
params: { name: "repairs.get", arguments: {} },
}),
);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.error.message).toMatch(/Missing id for repairs\.get\./);
});
it("repairs.get returns the repair by id", async () => {
vi.mocked(getRepair).mockResolvedValue({
id: "rep1",
status: "new",
} as never);
const res = await POST(
mcpRequest({
method: "tools/call",
id: 10,
params: { name: "repairs.get", arguments: { id: "rep1" } },
}),
);
expect(res.status).toBe(200);
expect(getRepair).toHaveBeenCalledWith("rep1");
const body = await res.json();
expect(JSON.parse(body.result.content[0].text).id).toBe("rep1");
});
it("repairs.create rejects a missing item_description", async () => {
const res = await POST(
mcpRequest({
method: "tools/call",
id: 11,
params: {
name: "repairs.create",
arguments: { fault_description: "Hydraulic leak" },
},
}),
);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.error.message).toMatch(
/Missing item_description for repairs\.create\./,
);
});
it("repairs.create rejects a missing fault_description", async () => {
const res = await POST(
mcpRequest({
method: "tools/call",
id: 12,
params: {
name: "repairs.create",
arguments: { item_description: "Forklift" },
},
}),
);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.error.message).toMatch(
/Missing fault_description for repairs\.create\./,
);
});
it("repairs.create books the machine in with the trimmed fields", async () => {
vi.mocked(createRepair).mockResolvedValue({
id: "rep1",
status: "new",
} as never);
const res = await POST(
mcpRequest({
method: "tools/call",
id: 13,
params: {
name: "repairs.create",
arguments: {
item_description: " Forklift ",
fault_description: " Hydraulic leak ",
serial_no: "SN-1",
},
},
}),
);
expect(res.status).toBe(200);
expect(createRepair).toHaveBeenCalledWith(
expect.objectContaining({
item_description: "Forklift",
fault_description: "Hydraulic leak",
serial_no: "SN-1",
// Unset relations arrive as null, not "".
customer: null,
company: null,
order: null,
supplier: null,
supplier_scope: null,
notes: null,
}),
);
});
it("repairs.update refuses to patch status", async () => {
const res = await POST(
mcpRequest({
@ -4421,10 +4533,58 @@ describe("repairs MCP tools", () => {
expect(body.error.message).toMatch(/cannot patch "repair_number"/);
});
it("repairs.advance_status returns the next step", async () => {
it("repairs.update passes only the whitelisted fields through", async () => {
vi.mocked(updateRepair).mockResolvedValue({
id: "rep1",
status: "assessing",
status: "new",
} as never);
const res = await POST(
mcpRequest({
method: "tools/call",
id: 14,
params: {
name: "repairs.update",
arguments: {
id: "rep1",
patch: { notes: "Customer called", serial_no: "SN-2" },
},
},
}),
);
expect(res.status).toBe(200);
expect(updateRepair).toHaveBeenCalledWith("rep1", {
notes: "Customer called",
serial_no: "SN-2",
});
});
it("repairs.advance_status rejects a status outside the fixed set", async () => {
const res = await POST(
mcpRequest({
method: "tools/call",
id: 8,
params: {
name: "repairs.advance_status",
arguments: { id: "rep1", to: "wat" },
},
}),
);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.error.message).toMatch(/Invalid status/);
});
it("repairs.advance_status returns the next step for the repair's NEW status", async () => {
// The service reports the repair back as 'approved' even though we
// asked to move it to 'quoted'. The next step must follow the
// repair's ACTUAL status, not the status we asked for — a handler
// written as nextStepFor(to) would return the quoted step and fail
// here.
vi.mocked(updateRepair).mockResolvedValue({
id: "rep1",
status: "approved",
} as never);
const res = await POST(
@ -4433,15 +4593,22 @@ describe("repairs MCP tools", () => {
id: 6,
params: {
name: "repairs.advance_status",
arguments: { id: "rep1", to: "assessing" },
arguments: { id: "rep1", to: "quoted" },
},
}),
);
expect(res.status).toBe(200);
const body = await res.json();
const payload = JSON.parse(body.result.content[0].text);
expect(payload.next_step.action).toBe("upload_supplier_quote");
expect(payload.next_step.to).toBe("quoted");
expect(payload.next_step).toEqual({
label: "Start work",
action: "start_work",
to: "in_progress",
});
// The quoted step must NOT be returned — that is the step for the
// status we asked for, not the one the repair is actually in.
expect(payload.next_step.action).not.toBe("create_customer_quote");
});
it("repairs.upload_file rejects an unknown file field", async () => {
@ -4464,4 +4631,139 @@ describe("repairs MCP tools", () => {
const body = await res.json();
expect(body.error.message).toMatch(/Invalid file field/);
});
it("repairs.upload_file appends the file to the chosen field", async () => {
vi.mocked(uploadRepairFile).mockResolvedValue({
id: "rep1",
status: "assessing",
} as never);
const res = await POST(
mcpRequest({
method: "tools/call",
id: 15,
params: {
name: "repairs.upload_file",
arguments: {
id: "rep1",
field: "signed_request",
filename: "signed.pdf",
content_type: "application/pdf",
base64: "AAAA",
},
},
}),
);
expect(res.status).toBe(200);
expect(uploadRepairFile).toHaveBeenCalledWith(
"rep1",
"signed_request",
expect.any(Blob),
"signed.pdf",
);
});
it("repairs.generate_repair_request rejects a missing id", async () => {
const res = await POST(
mcpRequest({
method: "tools/call",
id: 16,
params: { name: "repairs.generate_repair_request", arguments: {} },
}),
);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.error.message).toMatch(
/Missing id for repairs\.generate_repair_request\./,
);
});
it("repairs.generate_repair_request returns the generated filename", async () => {
vi.mocked(generateRepairRequest).mockResolvedValue({
filename: "RR-REP-2026-11-001.pdf",
});
const res = await POST(
mcpRequest({
method: "tools/call",
id: 17,
params: {
name: "repairs.generate_repair_request",
arguments: { id: "rep1" },
},
}),
);
expect(res.status).toBe(200);
expect(generateRepairRequest).toHaveBeenCalledWith("rep1");
const body = await res.json();
expect(JSON.parse(body.result.content[0].text).filename).toBe(
"RR-REP-2026-11-001.pdf",
);
});
it("repairs.create_customer_quote rejects an empty item list", async () => {
const res = await POST(
mcpRequest({
method: "tools/call",
id: 18,
params: {
name: "repairs.create_customer_quote",
arguments: { id: "rep1", items: [] },
},
}),
);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.error.message).toMatch(/needs at least one line item/);
});
it("repairs.create_customer_quote passes the priced lines through", async () => {
vi.mocked(createCustomerQuoteFromRepair).mockResolvedValue({
quote: { id: "q1" },
customer: { id: "c1" },
repair: { id: "rep1" },
} as never);
const res = await POST(
mcpRequest({
method: "tools/call",
id: 19,
params: {
name: "repairs.create_customer_quote",
arguments: {
id: "rep1",
items: [
{
description: "Hydraulic hose",
quantity: 2,
cost_price: 100,
selling_price: 180,
},
],
quote_notes: "Parts and labour",
replace: true,
},
},
}),
);
expect(res.status).toBe(200);
expect(createCustomerQuoteFromRepair).toHaveBeenCalledWith(
"rep1",
expect.objectContaining({
items: [
expect.objectContaining({
description: "Hydraulic hose",
quantity: 2,
cost_price: 100,
selling_price: 180,
}),
],
quote_notes: "Parts and labour",
replace: true,
}),
);
});
});

View file

@ -3691,7 +3691,7 @@ const tools = [
{
name: "repairs.advance_status",
description:
"Move a repair job to the next status. Allowed moves: new → assessing, quoted or cancelled; assessing → quoted or cancelled; quoted → approved, in_progress or cancelled; approved → in_progress or cancelled; in_progress → completed or cancelled. Returns the repair and the next step available.",
"Move a repair job to the next status. Allowed moves: new → assessing, quoted or cancelled; assessing → quoted or cancelled; quoted → approved, in_progress or cancelled; approved → in_progress or cancelled; in_progress → completed or cancelled. `completed` and `cancelled` are terminal — a job in either state cannot move again. Returns the repair and the next step available.",
inputSchema: {
type: "object",
properties: {
@ -3704,7 +3704,7 @@ const tools = [
{
name: "repairs.upload_file",
description:
"Attach a file to a repair job. `field` is one of: photos (the machine as received), quote (the supplier's quote), purchase_order (our PO to the supplier), repair_request (the request the driver carries), signed_request (the supplier's signed copy handed back). The file is ADDED to the field; existing files are kept.",
"Attach a file to a repair job. `field` is one of: photos (the machine as received), quote (the supplier's quote), purchase_order (our PO to the supplier), repair_request (the request the driver carries), signed_request (the supplier's signed copy handed back). The file is ADDED to the field; existing files are kept. Uploading the supplier's quote to `quote` also moves the job on: while it is still 'Booked in' or 'With supplier' it becomes 'Quote in'.",
inputSchema: {
type: "object",
properties: {
@ -3720,7 +3720,7 @@ const tools = [
{
name: "repairs.generate_repair_request",
description:
"Generate the Repair Request PDF for a repair job and send the machine to the supplier's assessment. The repair must have a supplier set. This also moves the job from 'Booked in' to 'With supplier'. The PDF is the document the driver carries with the machine; the supplier signs a copy and hands it back, which is uploaded with repairs.upload_file as signed_request.",
"Generate the Repair Request PDF for a repair job and send the machine to the supplier's assessment. The repair must have a supplier set. If the job is still 'Booked in' this also moves it to 'With supplier'; on any other status it only regenerates the PDF. The PDF is the document the driver carries with the machine; the supplier signs a copy and hands it back, which is uploaded with repairs.upload_file as signed_request. Each run ADDS another RR-<number>.pdf to the repair_request file field, so it is not idempotent — do not run it twice expecting a single file.",
inputSchema: {
type: "object",
properties: {