WFDASH-WEBAPP/src/lib/server/repairs-service.test.ts

453 lines
13 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import {
createCustomerQuoteFromRepair,
createRepair,
updateRepair,
uploadRepairFile,
getRepairStatusCounts,
linkRepairToOrder,
} from "@/lib/server/repairs-service";
import { createQuote } from "@/lib/server/quotes-service";
vi.mock("@/lib/server/pocketbase-admin", () => ({
getAdminPocketBase: vi.fn(),
}));
vi.mock("@/lib/server/quotes-service", () => ({
// Only `createQuote` is needed by the function under test.
createQuote: vi.fn(),
}));
const REPAIRS = "repairs";
interface CollectionMock {
getOne?: ReturnType<typeof vi.fn>;
update?: ReturnType<typeof vi.fn>;
getList?: ReturnType<typeof vi.fn>;
create?: ReturnType<typeof vi.fn>;
}
const ORDERS = "orders";
const baseRepair = {
id: "rep1",
repair_number: "REP-2026-09-001",
customer: "cust1",
company: "co1",
order: "",
supplier: "",
status: "quoted",
item_description: "Hydraulic ram",
fault_description: "Seal leak",
notes: "",
serial_no: "SN-12345",
photos: [],
quote: ["supplier-quote.pdf"],
purchase_order: [],
repair_request: [],
};
function makePb(
repair: typeof baseRepair & Record<string, unknown>,
order?: Record<string, unknown>,
listImpl?: (filter: string) => number,
) {
const records: Record<string, Record<string, unknown>> = {
[REPAIRS]: repair,
// The orders collection always exists so the order-link helpers can
// be exercised. With no order row passed in, `getOne` on it answers
// "not found" for every id — which is exactly the "no such order"
// case the link helper has to reject.
[ORDERS]: order ?? {},
};
const notFound = () => {
const err = new Error("not found") as Error & { status?: number };
err.status = 404;
return err;
};
const find = (id: string) => {
for (const rec of Object.values(records)) {
if (rec.id === id) return rec;
}
return null;
};
const getOne = vi.fn().mockImplementation(async (id: string) => {
const rec = find(id);
if (!rec) throw notFound();
return rec;
});
const update = vi
.fn()
.mockImplementation(async (id: string, patch: Record<string, unknown>) => ({
...repair,
id,
...patch,
}));
const getList = vi.fn().mockImplementation(
async (
_page: number,
_perPage: number,
opts?: { filter?: string },
) => {
const totalItems = listImpl ? listImpl(String(opts?.filter ?? "")) : 0;
return {
items: [],
totalItems,
totalPages: totalItems > 0 ? 1 : 0,
page: 1,
perPage: 1,
};
},
);
return {
autoCancellation: vi.fn(),
collection: (name: string) => {
if (!records[name]) {
throw new Error(`unexpected collection: ${name}`);
}
return { getOne, update, getList } satisfies CollectionMock;
},
_getOne: getOne,
_update: update,
_getList: getList,
};
}
function mockAdminPb(pb: ReturnType<typeof makePb>) {
vi.mocked(getAdminPocketBase).mockResolvedValue(
pb as unknown as Awaited<ReturnType<typeof getAdminPocketBase>>,
);
}
describe("createCustomerQuoteFromRepair", () => {
beforeEach(() => vi.clearAllMocks());
it("creates a quote from the repair context and patches the back-reference", async () => {
const pb = makePb(baseRepair);
mockAdminPb(pb);
vi.mocked(createQuote).mockResolvedValue({
quote: { id: "quote-new", quote_number: "Q-2026-09-099" } as never,
customer: { id: "cust1", customer: "Acme Mining" } as never,
company: { id: "co1", company: "Acme Group" } as never,
items: [],
quote_number: "Q-2026-09-099",
});
const result = await createCustomerQuoteFromRepair("rep1", {
items: [
{
description: "Replace seal kit",
quantity: 1,
cost_price: 1200,
markup: 25,
},
],
});
// createQuote received the right customer/company/description and
// did NOT get a status override (defaults to "new").
expect(createQuote).toHaveBeenCalledWith(
expect.objectContaining({
customer: "cust1",
company: "co1",
description: "Repair of Hydraulic ram (S/N SN-12345)",
items: [
expect.objectContaining({ description: "Replace seal kit" }),
],
}),
);
// No status override was passed.
const passedStatus = vi.mocked(createQuote).mock.calls[0][0].status;
expect(passedStatus).toBeUndefined();
// Back-reference is patched on the repair.
expect(pb._update).toHaveBeenCalledWith("rep1", {
customer_quote: "quote-new",
});
// Return payload surfaces the new quote + refreshed repair.
expect(result.quote.id).toBe("quote-new");
expect(result.customer.id).toBe("cust1");
expect(result.repair.customer_quote).toBe("quote-new");
});
it("omits the serial-number suffix when serial_no is empty", async () => {
const pb = makePb({ ...baseRepair, serial_no: "" });
mockAdminPb(pb);
vi.mocked(createQuote).mockResolvedValue({
quote: { id: "quote-new" } as never,
customer: { id: "cust1" } as never,
company: null,
items: [],
quote_number: "Q-x",
});
await createCustomerQuoteFromRepair("rep1", {
items: [{ description: "Strip & quote" }],
});
const description = vi.mocked(createQuote).mock.calls[0][0].description;
expect(description).toBe("Repair of Hydraulic ram");
expect(description).not.toMatch(/S\/N/);
});
it("respects an operator-supplied description override", async () => {
const pb = makePb(baseRepair);
mockAdminPb(pb);
vi.mocked(createQuote).mockResolvedValue({
quote: { id: "quote-new" } as never,
customer: { id: "cust1" } as never,
company: null,
items: [],
quote_number: "Q-x",
});
await createCustomerQuoteFromRepair("rep1", {
items: [{ description: "x" }],
description: "Custom subject for the email",
});
expect(vi.mocked(createQuote).mock.calls[0][0].description).toBe(
"Custom subject for the email",
);
});
it("forwards quote_notes and validity_days when supplied", async () => {
const pb = makePb(baseRepair);
mockAdminPb(pb);
vi.mocked(createQuote).mockResolvedValue({
quote: { id: "quote-new" } as never,
customer: { id: "cust1" } as never,
company: null,
items: [],
quote_number: "Q-x",
});
await createCustomerQuoteFromRepair("rep1", {
items: [{ description: "x" }],
quote_notes: "Includes collection + return",
validity_days: 14,
});
const passed = vi.mocked(createQuote).mock.calls[0][0];
expect(passed.quote_notes).toBe("Includes collection + return");
expect(passed.validity_days).toBe(14);
});
it("rejects when the repair has no customer", async () => {
const pb = makePb({ ...baseRepair, customer: "" });
mockAdminPb(pb);
vi.mocked(createQuote).mockResolvedValue({
quote: { id: "x" } as never,
customer: { id: "x" } as never,
company: null,
items: [],
quote_number: "x",
});
await expect(
createCustomerQuoteFromRepair("rep1", {
items: [{ description: "x" }],
}),
).rejects.toThrow(/no customer set/);
// Did not call createQuote, did not patch the repair.
expect(createQuote).not.toHaveBeenCalled();
expect(pb._update).not.toHaveBeenCalled();
});
it("rejects when an existing customer_quote link is set and replace is not", async () => {
const pb = makePb({ ...baseRepair, customer_quote: "quote-old" });
mockAdminPb(pb);
vi.mocked(createQuote).mockResolvedValue({
quote: { id: "x" } as never,
customer: { id: "x" } as never,
company: null,
items: [],
quote_number: "x",
});
await expect(
createCustomerQuoteFromRepair("rep1", {
items: [{ description: "x" }],
}),
).rejects.toThrow(/already has a customer quote/);
expect(createQuote).not.toHaveBeenCalled();
});
it("allows overwriting the back-reference when replace=true", async () => {
const pb = makePb({ ...baseRepair, customer_quote: "quote-old" });
mockAdminPb(pb);
vi.mocked(createQuote).mockResolvedValue({
quote: { id: "quote-new" } as never,
customer: { id: "cust1" } as never,
company: null,
items: [],
quote_number: "Q-new",
});
const result = await createCustomerQuoteFromRepair("rep1", {
items: [{ description: "x" }],
replace: true,
});
expect(pb._update).toHaveBeenCalledWith("rep1", {
customer_quote: "quote-new",
});
expect(result.repair.customer_quote).toBe("quote-new");
});
it("rejects when items is empty", async () => {
const pb = makePb(baseRepair);
mockAdminPb(pb);
await expect(
createCustomerQuoteFromRepair("rep1", { items: [] }),
).rejects.toThrow(/items must be a non-empty array/);
});
});
describe("supplier_scope and signed_request", () => {
beforeEach(() => vi.clearAllMocks());
it("writes supplier_scope on create", async () => {
const create = vi.fn().mockResolvedValue({ ...baseRepair, id: "rep-new" });
const pb = makePb(baseRepair);
const originalCollection = pb.collection;
(pb as unknown as { collection: unknown }).collection = (name: string) => {
const c = originalCollection(name) as Record<string, unknown>;
return name === REPAIRS ? { ...c, create } : c;
};
mockAdminPb(pb);
await createRepair({
item_description: "Welding machine",
fault_description: "Will not strike",
supplier_scope: "Strip and quote the strike mechanism",
});
expect(create).toHaveBeenCalledWith(
expect.objectContaining({
supplier_scope: "Strip and quote the strike mechanism",
}),
);
});
it("writes supplier_scope on update", async () => {
const pb = makePb(baseRepair);
mockAdminPb(pb);
await updateRepair("rep1", { supplier_scope: "Check the transformer" });
expect(pb._update).toHaveBeenCalledWith(
"rep1",
expect.objectContaining({ supplier_scope: "Check the transformer" }),
);
});
it("appends a signed_request file without dropping the existing ones", async () => {
const pb = makePb({
...baseRepair,
signed_request: ["signed-copy-1.pdf"],
});
mockAdminPb(pb);
const blob = new Blob([new Uint8Array([1, 2, 3])], {
type: "application/pdf",
});
await uploadRepairFile("rep1", "signed_request", blob, "signed-copy-2.pdf");
const form = pb._update.mock.calls[0][1] as FormData;
expect(form.getAll("signed_request")).toHaveLength(2);
expect(form.getAll("signed_request")[0]).toBe("signed-copy-1.pdf");
});
it("returns the bumped status after a supplier quote is uploaded", async () => {
const pb = makePb({ ...baseRepair, status: "assessing" });
mockAdminPb(pb);
const blob = new Blob([new Uint8Array([1, 2, 3])], {
type: "application/pdf",
});
const updated = await uploadRepairFile(
"rep1",
"quote",
blob,
"supplier-quote.pdf",
);
expect(updated.status).toBe("quoted");
});
});
describe("getRepairStatusCounts", () => {
beforeEach(() => vi.clearAllMocks());
it("counts every status and totals them", async () => {
const counts: Record<string, number> = {
'status = "new"': 3,
'status = "assessing"': 1,
'status = "quoted"': 2,
'status = "approved"': 0,
'status = "in_progress"': 1,
'status = "completed"': 4,
'status = "cancelled"': 1,
};
const pb = makePb(baseRepair, undefined, (filter) => counts[filter] ?? 0);
mockAdminPb(pb);
const result = await getRepairStatusCounts();
expect(result.total).toBe(12);
expect(result.byStatus).toEqual({
new: 3,
assessing: 1,
quoted: 2,
approved: 0,
in_progress: 1,
completed: 4,
cancelled: 1,
});
});
});
describe("linkRepairToOrder", () => {
beforeEach(() => vi.clearAllMocks());
it("writes the link in both directions", async () => {
const pb = makePb(baseRepair, {
id: "ord1",
order_number: "O-2026-11-001",
is_repair_order: false,
});
mockAdminPb(pb);
const result = await linkRepairToOrder("ord1", "rep1");
expect(pb._update).toHaveBeenCalledWith("ord1", {
repair: "rep1",
is_repair_order: true,
});
expect(pb._update).toHaveBeenCalledWith("rep1", { order: "ord1" });
expect(result.order.repair).toBe("rep1");
expect(result.order.is_repair_order).toBe(true);
expect(result.repair.order).toBe("ord1");
});
it("throws when the order does not exist", async () => {
const pb = makePb(baseRepair);
mockAdminPb(pb);
await expect(linkRepairToOrder("nope", "rep1")).rejects.toThrow(
/not found/,
);
});
});