- Enhanced `nss-fasteners-pb-import.json` to compute `selling_price` as `cost_price * (1 + 80%)` and included `markup` in the records. - Updated `nss-fasteners-pb-transform.ts` to allow configurable CSV and output file names via environment variables, improving flexibility for different product runs. - Added support for uploading photos in the repair detail page, including validation for file types and sizes, and implemented a dialog for creating customer quotes linked to repairs. - Introduced a new `customer_quote` field in the `Repair` type to link generated quotes back to their respective repairs. These changes streamline the pricing process and enhance the user experience in managing repairs and quotes.
262 lines
7.6 KiB
TypeScript
262 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/);
|
|
});
|
|
});
|