263 lines
7.6 KiB
TypeScript
263 lines
7.6 KiB
TypeScript
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||
|
|
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
||
|
|
import { createCustomerQuoteFromRepair } 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>;
|
||
|
|
}
|
||
|
|
|
||
|
|
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>) {
|
||
|
|
const getOne = vi.fn().mockImplementation(async (id: string) => {
|
||
|
|
if (id !== repair.id) {
|
||
|
|
const err = new Error("not found") as Error & { status?: number };
|
||
|
|
err.status = 404;
|
||
|
|
throw err;
|
||
|
|
}
|
||
|
|
return repair;
|
||
|
|
});
|
||
|
|
const update = vi
|
||
|
|
.fn()
|
||
|
|
.mockImplementation(async (id: string, patch: Record<string, unknown>) => ({
|
||
|
|
...repair,
|
||
|
|
id,
|
||
|
|
...patch,
|
||
|
|
}));
|
||
|
|
return {
|
||
|
|
autoCancellation: vi.fn(),
|
||
|
|
collection: (name: string) => {
|
||
|
|
if (name !== REPAIRS) {
|
||
|
|
throw new Error(`unexpected collection: ${name}`);
|
||
|
|
}
|
||
|
|
return { getOne, update } satisfies CollectionMock;
|
||
|
|
},
|
||
|
|
_getOne: getOne,
|
||
|
|
_update: update,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
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/);
|
||
|
|
});
|
||
|
|
});
|