WFDASH-WEBAPP/src/components/customer-form.test.tsx
Dave bbfa888cc1 feat(customers): add status toggle and site/location field
Two new fields added to the customer add/edit form, both
operator-facing for the active/inactive customer workflow:

  - customers.status (bool): flips the customer active/inactive.
    Legacy rows pre-date the field and load as undefined; both
    the form and the read side collapse that to active=true so
    the migration date doesn't silently flip the existing base.

  - customers.site_location (text): free-text site/shaft label
    keyed off the customer's company. Distinct from the existing
    'site' free-text mine tag owned by the pin tool. Storage is
    plain text on purpose, with a /api/v1/customers/sites
    endpoint returning distinct existing values for an input
    + datalist autocomplete under the company combobox.

  surfaces:
  - Active/Inactive badge on customer detail and list rows
  - site_location row in the customer Details card and under
    the company line on the list (desktop + mobile card)
  - Switch under the sales-rep section of the form to flip
    status; existing customers stay active by default

  also fixes a UX trap on edit: previously opening the edit
  form showed 'Select company...' / 'Select rep...' as placeholders
  for an instant until the lookup lists resolved, making a real,
  saved company/rep look unlinked. CompanySearch and UserSearch
  now accept a selectedLabel fallback; CustomerForm passes the
  resolved customer.expand.company / customer.expand.rep names
  through so the triggers display the linked names immediately.

  listDistinctSiteLocations helper added to customers-service.ts
  alongside the existing listDistinctSites.
2026-09-16 09:47:09 +02:00

164 lines
5.1 KiB
TypeScript

import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { CustomerForm } from "./customer-form";
const {
createMock,
findCustomerDuplicatesMock,
getAllCompaniesMock,
} = vi.hoisted(() => ({
createMock: vi.fn(),
findCustomerDuplicatesMock: vi.fn(),
getAllCompaniesMock: vi.fn(),
}));
vi.mock("@/lib/pocketbase", () => ({
__esModule: true,
default: () => ({
collection: () => ({
create: createMock,
update: vi.fn(),
}),
}),
}));
vi.mock("sonner", () => ({
toast: { error: vi.fn(), success: vi.fn() },
}));
vi.mock("@/lib/pb-helpers", () => ({
getAllCompanies: getAllCompaniesMock,
findCustomerDuplicates: findCustomerDuplicatesMock,
}));
vi.mock("@/components/ui/company-search", () => ({
CompanySearch: () => <div>Company Search</div>,
}));
vi.mock("@/components/ui/user-search", () => ({
UserSearch: () => <div>User Search</div>,
}));
// `CustomerForm` now fetches `/api/v1/customers/sites` (added 2026-09
// for the `site_location` `<datalist>` autocomplete). jsdom has no
// global `fetch` — stub one so the effect's no-result promise
// resolves cleanly without each test having to wire fetch up.
const stubFetch = () =>
Promise.resolve(new Response(JSON.stringify({ items: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
})) as unknown as Response;
beforeAll(() => {
if (typeof globalThis.fetch !== "function") {
globalThis.fetch = stubFetch as unknown as typeof fetch;
}
});
afterEach(() => {
vi.clearAllMocks();
getAllCompaniesMock.mockResolvedValue([]);
findCustomerDuplicatesMock.mockResolvedValue([]);
});
// The form's useEffect on mount calls `getAllCompanies()` to populate
// the company combobox. The pre-existing test (delivery-coords
// position) never awaited that promise — it just used `render` — and
// the form reads `companies.length` in a separate effect. Provide a
// safe default before each test.
beforeAll(() => {
getAllCompaniesMock.mockResolvedValue([]);
});
describe("CustomerForm", () => {
it("shows delivery coordinates in the contact section before details", () => {
render(
<CustomerForm
open
onOpenChange={() => undefined}
onSaved={() => undefined}
/>
);
const content = document.body.textContent ?? "";
expect(content.indexOf("Delivery Coordinates")).toBeGreaterThan(content.indexOf("Contact"));
expect(content.indexOf("Delivery Coordinates")).toBeLessThan(content.indexOf("Details"));
});
it("blocks save when an existing customer has the same (name, company) pair and lets the operator override", async () => {
// Form is filling out (John Smith, company=acme-id, email=).
// PB returns an existing customer with the same name+company —
// a real collision. Submit should pop the duplicate dialog.
findCustomerDuplicatesMock.mockResolvedValueOnce([
{
id: "cu-existing",
customer: "John Smith",
company: "acme-id",
email: "",
coordinates: { lon: 0, lat: 0 },
} as never,
]);
createMock.mockResolvedValue({ id: "cu1" });
render(
<CustomerForm
open
onOpenChange={() => undefined}
onSaved={() => undefined}
/>
);
const form = screen.getByRole("dialog").querySelector("form")!;
fireEvent.change(within(form).getByPlaceholderText("Full name or business name"), {
target: { value: "john smith" },
});
fireEvent.click(within(form).getByRole("button", { name: "Create Customer" }));
// Wait for the dialog to show the existing match.
await waitFor(() => {
expect(screen.getByText("John Smith")).toBeInTheDocument();
});
// The write was NOT performed yet.
expect(createMock).not.toHaveBeenCalled();
// "Save anyway" should land the write.
fireEvent.click(screen.getByRole("button", { name: /save anyway/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith(
expect.objectContaining({ customer: "john smith" })
);
});
});
it("passes the current record's id as excludeId in edit mode", async () => {
// The form is bound to an existing customer. The duplicate check
// must exclude the record being edited so it doesn't flag itself.
const existing = { id: "cu-self", customer: "John Smith" };
findCustomerDuplicatesMock.mockResolvedValue([]);
render(
<CustomerForm
open
onOpenChange={() => undefined}
onSaved={() => undefined}
customer={existing as never}
/>
);
const form = screen.getByRole("dialog").querySelector("form")!;
fireEvent.change(within(form).getByPlaceholderText("Full name or business name"), {
target: { value: "John Smith" },
});
// The live check debounces by 300ms — wait for the helper to be
// called with the record's id as the excludeId.
await waitFor(() => {
expect(findCustomerDuplicatesMock).toHaveBeenCalledWith(
"John Smith",
"",
"",
"cu-self"
);
});
});
});