fix(mcp): tell the agent what repairs.upload_file does, and test the rest of the repair tools
This commit is contained in:
parent
9146fd46f0
commit
c4936613e1
2 changed files with 314 additions and 12 deletions
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue