453 lines
13 KiB
TypeScript
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/,
|
|
);
|
|
});
|
|
});
|