shoc-frontend-new/src/test/app/(protected)/vendors/use-vendor-roster-form.test.tsx
Alexandre Brandizzi d6755a63b6
Some checks failed
Frontend checks / Build and test (push) Has been cancelled
Frontend checks / governance (push) Has been cancelled
Frontend checks / Visual regression (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
fix(vendors): clarify blank technician draft state (#157)
* fix(vendors): clarify blank technician draft state

* test(vendors): update technician section snapshots

---------

Co-authored-by: Codex Review Integration <codex-review@local.invalid>
2026-08-28 15:00:02 -04:00

716 lines
22 KiB
TypeScript

import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { useController, useFieldArray } from "react-hook-form";
import { toast } from "react-toastify";
import { beforeEach, describe, expect, it, vi } from "vitest";
const rosterGet = vi.fn();
const saveMutate = vi.fn();
vi.mock("@/domain/vendors/api/vendor-company-roster-api", () => ({
vendorCompanyRosterApi: { get: (...args: unknown[]) => rosterGet(...args) },
}));
vi.mock("@/domain/vendors/use-cases/use-vendor-company-roster", () => ({
useVendorCompanyRoster: () => rosterQueryResult,
}));
vi.mock("react-toastify", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
let rosterQueryResult: {
data: VendorCompanyRoster | undefined;
isLoading: boolean;
isError: boolean;
error: Error | null;
refetch: ReturnType<typeof vi.fn>;
} = { data: undefined, isLoading: false, isError: false, error: null, refetch: vi.fn() };
vi.mock("@/domain/vendors/use-cases/use-vendor-facets", () => ({
useVendorFacets: () => ({ data: { companies: [], trades: [] } }),
}));
vi.mock("@/domain/vendors/use-cases/use-save-vendor-company-roster", async () => {
const actual = await vi.importActual<
typeof import("@/domain/vendors/use-cases/use-save-vendor-company-roster")
>("@/domain/vendors/use-cases/use-save-vendor-company-roster");
return {
...actual,
useSaveVendorCompanyRoster: () => ({ mutate: saveMutate, isPending: false }),
};
});
import { useVendorRosterForm } from "@/app/(protected)/vendors/_components/use-vendor-roster-form";
import type { VendorCompanyRosterFormValues } from "@/domain/vendors/schemas/vendor-roster-schema";
import type { VendorCompanyRoster, VendorFacetCompany } from "@/domain/vendors/types/vendor";
const company: VendorFacetCompany = {
companyId: 5,
name: "Gateway Plumbing",
companyPhone: "(314) 555-0100",
email: "dispatch@gateway.test",
address: "1 Main St",
city: "St. Louis",
state: "MO",
zip: "63101",
googleMapsUrl: "",
};
const roster: VendorCompanyRoster = {
companyId: 5,
rowVersion: "rv-1",
name: "Gateway Plumbing",
companyPhone: "(314) 555-0100",
email: "dispatch@gateway.test",
address: "1 Main St",
city: "St. Louis",
state: "MO",
zip: "63101",
googleMapsUrl: "",
notes: "",
technicians: [],
};
const secondCompany: VendorFacetCompany = {
...company,
companyId: 6,
name: "Metro HVAC",
companyPhone: "(314) 555-0110",
email: "dispatch@metro.test",
};
const secondRoster: VendorCompanyRoster = {
...roster,
companyId: 6,
name: "Metro HVAC",
companyPhone: "(314) 555-0110",
email: "dispatch@metro.test",
};
const rosterWithTechnicians: VendorCompanyRoster = {
...roster,
technicians: [
{
id: 11,
contactName: "Ray Holt",
phone: "(314) 555-0122",
email: "",
preferredContact: "Phone",
tradeSpecialties: "Plumbing",
isActive: true,
totalJobs: 0,
},
{
id: 12,
contactName: "Amy Santiago",
phone: "(314) 555-0133",
email: "",
preferredContact: "Text",
tradeSpecialties: "HVAC",
isActive: true,
totalJobs: 0,
},
],
};
const newTechnician = {
contactName: "Dana Kim",
phone: "(314) 555-0144",
email: "dana@test.test",
preferredContact: "Text" as const,
tradeSpecialties: "HVAC",
isActive: true,
};
function submitValues(
technicians: VendorCompanyRosterFormValues["technicians"],
): VendorCompanyRosterFormValues {
return {
name: "Gateway Plumbing",
companyPhone: "(314) 555-0100",
email: "dispatch@gateway.test",
address: "1 Main St",
city: "St. Louis",
state: "MO",
zip: "63101",
googleMapsUrl: "",
notes: "",
technicians,
};
}
function resetRosterQuery(): void {
rosterQueryResult = {
data: undefined,
isLoading: false,
isError: false,
error: null,
refetch: vi.fn(),
};
}
beforeEach(() => {
resetRosterQuery();
});
function createClient(): QueryClient {
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
}
/**
* SH-250: mirrors the app's real staleTime (src/lib/query/query-client.ts). The default
* test client uses staleTime 0, which silently masks cache-staleness defects on the
* reload path — the conflict recovery must not depend on an empty cache.
*/
function createCachingClient(): QueryClient {
return new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 5 * 60 * 1000 } },
});
}
function makeWrapper(client: QueryClient) {
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
}
function deferRoster(): {
promise: Promise<VendorCompanyRoster>;
resolve: (value: VendorCompanyRoster) => void;
} {
let resolve!: (value: VendorCompanyRoster) => void;
const promise = new Promise<VendorCompanyRoster>((resolver) => {
resolve = resolver;
});
return { promise, resolve };
}
describe("useVendorRosterForm load-error handling", () => {
beforeEach(() => {
rosterGet.mockReset();
saveMutate.mockReset();
});
it("shows a safe retryable error when selecting an existing company fetch is rejected", async () => {
rosterGet.mockRejectedValueOnce(new Error("Internal server error"));
const { result } = renderHook(() => useVendorRosterForm({ mode: "create" }), {
wrapper: makeWrapper(createClient()),
});
expect(result.current.loadErrorMessage).toBeNull();
await act(async () => {
await result.current.selectCompany(company);
});
expect(result.current.loadErrorMessage).toBe("Couldn't load that company's roster. Try again.");
expect(result.current.selectedCompanyId).toBeNull();
});
it("preserves entered form data when selecting an existing company fetch is rejected", async () => {
rosterGet.mockRejectedValueOnce(new Error("Internal server error"));
const { result } = renderHook(
() => {
const form = useVendorRosterForm({ mode: "create" });
const name = useController({ control: form.control, name: "name" });
return { form, name };
},
{ wrapper: makeWrapper(createClient()) },
);
await act(async () => {
result.current.name.field.onChange("Draft Vendor");
});
expect(result.current.name.field.value).toBe("Draft Vendor");
await act(async () => {
await result.current.form.selectCompany(company);
});
expect(result.current.name.field.value).toBe("Draft Vendor");
expect(result.current.form.loadErrorMessage).toBe(
"Couldn't load that company's roster. Try again.",
);
});
it("recovers and populates the form on retry after a rejected fetch", async () => {
rosterGet.mockRejectedValueOnce(new Error("boom"));
const { result } = renderHook(() => useVendorRosterForm({ mode: "create" }), {
wrapper: makeWrapper(createClient()),
});
await act(async () => {
await result.current.selectCompany(company);
});
expect(result.current.loadErrorMessage).not.toBeNull();
rosterGet.mockResolvedValueOnce(roster);
await act(async () => {
result.current.retryLoad();
});
await waitFor(() => expect(result.current.loadErrorMessage).toBeNull());
expect(result.current.selectedCompanyId).toBe(5);
});
it("clears the selected roster when the form is reset", async () => {
rosterGet.mockResolvedValueOnce(roster);
const { result } = renderHook(() => useVendorRosterForm({ mode: "create" }), {
wrapper: makeWrapper(createClient()),
});
await act(async () => {
await result.current.selectCompany(company);
});
expect(result.current.selectedCompanyId).toBe(5);
act(() => result.current.resetForm());
expect(result.current.selectedCompanyId).toBeNull();
});
it("does not expose raw server details in the error message", async () => {
rosterGet.mockRejectedValueOnce(new Error("Database connection failed at 10.0.0.1"));
const { result } = renderHook(() => useVendorRosterForm({ mode: "create" }), {
wrapper: makeWrapper(createClient()),
});
await act(async () => {
await result.current.selectCompany(company);
});
expect(result.current.loadErrorMessage).not.toContain("Database");
expect(result.current.loadErrorMessage).not.toContain("10.0.0.1");
});
});
describe("useVendorRosterForm prototype defaults", () => {
it("does not persist the prototype's untouched optional technician card", () => {
const { result } = renderHook(
() => useVendorRosterForm({ mode: "create", startWithTechnician: true }),
{ wrapper: makeWrapper(createClient()) },
);
act(() => {
result.current.submit({
name: "Independent Vendor LLC",
companyPhone: "(314) 555-0199",
email: "",
address: "",
city: "",
state: "",
zip: "",
googleMapsUrl: "",
notes: "",
technicians: [
{
contactName: "",
phone: "",
email: "",
preferredContact: "Phone",
tradeSpecialties: "",
isActive: true,
},
],
});
});
expect(saveMutate).toHaveBeenCalledWith(
expect.objectContaining({ values: expect.objectContaining({ technicians: [] }) }),
expect.any(Object),
);
});
});
describe("useVendorRosterForm technician removal", () => {
it("keeps a valid roster saveable after an existing technician is removed", async () => {
rosterQueryResult = {
data: rosterWithTechnicians,
isLoading: false,
isError: false,
error: null,
refetch: vi.fn(),
};
const { result } = renderHook(
() => {
const form = useVendorRosterForm({ mode: "update", companyId: 5 });
const technicians = useFieldArray({ control: form.control, name: "technicians" });
return { form, technicians };
},
{ wrapper: makeWrapper(createClient()) },
);
await waitFor(() => expect(result.current.form.isValid).toBe(true));
act(() => result.current.technicians.remove(1));
await waitFor(() => expect(result.current.form.isValid).toBe(true));
});
});
describe("useVendorRosterForm stale-selection handling", () => {
beforeEach(() => {
rosterGet.mockReset();
});
it("keeps the newest company roster when two lookups resolve out of order", async () => {
const first = deferRoster();
const second = deferRoster();
rosterGet.mockImplementationOnce(() => first.promise);
rosterGet.mockImplementationOnce(() => second.promise);
const { result } = renderHook(
() => {
const form = useVendorRosterForm({ mode: "create" });
const name = useController({ control: form.control, name: "name" });
return { form, name };
},
{ wrapper: makeWrapper(createClient()) },
);
let firstSelect!: Promise<void>;
let secondSelect!: Promise<void>;
act(() => {
firstSelect = result.current.form.selectCompany(company);
});
act(() => {
secondSelect = result.current.form.selectCompany(secondCompany);
});
await act(async () => {
second.resolve(secondRoster);
await secondSelect;
});
await act(async () => {
first.resolve(roster);
await firstSelect;
});
expect(result.current.form.selectedCompanyId).toBe(6);
expect(result.current.name.field.value).toBe("Metro HVAC");
});
it("keeps a free-text draft when a pending lookup resolves after clearing to free text", async () => {
const lookup = deferRoster();
rosterGet.mockImplementationOnce(() => lookup.promise);
const { result } = renderHook(
() => {
const form = useVendorRosterForm({ mode: "create" });
const name = useController({ control: form.control, name: "name" });
return { form, name };
},
{ wrapper: makeWrapper(createClient()) },
);
let pendingSelect!: Promise<void>;
act(() => {
pendingSelect = result.current.form.selectCompany(company);
});
act(() => {
result.current.form.clearSelectedCompany("Draft Vendor");
});
await act(async () => {
result.current.name.field.onChange("Draft Vendor");
});
expect(result.current.name.field.value).toBe("Draft Vendor");
await act(async () => {
lookup.resolve(roster);
await pendingSelect;
});
expect(result.current.form.selectedCompanyId).toBeNull();
expect(result.current.name.field.value).toBe("Draft Vendor");
});
});
describe("useVendorRosterForm additive add flow (SH-250)", () => {
beforeEach(() => {
rosterGet.mockReset();
saveMutate.mockReset();
vi.mocked(toast.error).mockReset();
});
it("issues an additive add — never update — when an existing company is selected", async () => {
rosterGet.mockResolvedValueOnce(roster);
const { result } = renderHook(
() => {
const form = useVendorRosterForm({ mode: "create", startWithTechnician: true });
const technician = useController({
control: form.control,
name: "technicians.0.contactName",
});
return { form, technician };
},
{ wrapper: makeWrapper(createClient()) },
);
await act(async () => {
await result.current.form.selectCompany(company);
});
await act(async () => {
result.current.technician.field.onChange("Dana Kim");
});
act(() => {
result.current.form.submit(submitValues([newTechnician]));
});
expect(saveMutate).toHaveBeenCalledTimes(1);
const input = saveMutate.mock.calls[0]?.[0] as { mode: string };
expect(input.mode).toBe("add");
expect(saveMutate).toHaveBeenCalledWith(
expect.objectContaining({
companyId: 5,
rowVersion: "rv-1",
originalRoster: roster,
values: expect.objectContaining({ technicians: [newTechnician] }),
}),
expect.any(Object),
);
});
it("still creates via POST when no existing company is matched", () => {
const { result } = renderHook(
() => useVendorRosterForm({ mode: "create", startWithTechnician: true }),
{ wrapper: makeWrapper(createClient()) },
);
act(() => {
result.current.submit(submitValues([newTechnician]));
});
expect(saveMutate).toHaveBeenCalledTimes(1);
expect(saveMutate.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({ mode: "create", companyId: undefined, rowVersion: undefined }),
);
});
it("blocks submit when the selected company has nothing to add", async () => {
rosterGet.mockResolvedValueOnce(roster);
const { result } = renderHook(() => useVendorRosterForm({ mode: "create" }), {
wrapper: makeWrapper(createClient()),
});
await act(async () => {
await result.current.selectCompany(company);
});
act(() => {
result.current.submit(submitValues([]));
});
expect(saveMutate).not.toHaveBeenCalled();
expect(vi.mocked(toast.error)).toHaveBeenCalledTimes(1);
expect(vi.mocked(toast.error)).toHaveBeenCalledWith(
"No changes to save. Enter technician details or update a company field.",
);
});
it("keeps the entered technician and retries with the fresh rowVersion after reload", async () => {
rosterGet.mockResolvedValueOnce(roster);
const { result } = renderHook(
() => {
const form = useVendorRosterForm({ mode: "create", startWithTechnician: true });
const technician = useController({
control: form.control,
name: "technicians.0.contactName",
});
return { form, technician };
},
{ wrapper: makeWrapper(createClient()) },
);
await act(async () => {
await result.current.form.selectCompany(company);
});
await act(async () => {
result.current.technician.field.onChange("Dana Kim");
});
act(() => {
result.current.form.submit(submitValues([newTechnician]));
});
expect(saveMutate.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ rowVersion: "rv-1" }));
rosterGet.mockResolvedValueOnce({ ...roster, rowVersion: "rv-2" });
await act(async () => {
result.current.form.reload();
});
await waitFor(() => expect(result.current.form.selectedCompanyId).toBe(5));
act(() => {
result.current.form.submit(submitValues([newTechnician]));
});
expect(saveMutate.mock.calls[1]?.[0]).toEqual(
expect.objectContaining({ mode: "add", rowVersion: "rv-2" }),
);
});
it("refreshes the visible company fields on reload, not just rowVersion", async () => {
rosterGet.mockResolvedValueOnce(roster);
const { result } = renderHook(
() => {
const form = useVendorRosterForm({ mode: "create", startWithTechnician: true });
const name = useController({ control: form.control, name: "name" });
return { form, name };
},
{ wrapper: makeWrapper(createClient()) },
);
await act(async () => {
await result.current.form.selectCompany(company);
});
expect(result.current.name.field.value).toBe("Gateway Plumbing");
// Someone else renamed the company, which is what caused the 409.
rosterGet.mockResolvedValueOnce({
...roster,
rowVersion: "rv-2",
name: "Gateway Plumbing & Drain",
});
await act(async () => {
result.current.form.reload();
});
// Reload must not leave the form on the pre-conflict name, or the retry
// would diff stale fields and overwrite the concurrent update.
await waitFor(() => expect(result.current.name.field.value).toBe("Gateway Plumbing & Drain"));
});
it("reload bypasses the cache so the retry carries the fresh rowVersion", async () => {
rosterGet.mockResolvedValueOnce(roster);
const { result } = renderHook(
() => useVendorRosterForm({ mode: "create", startWithTechnician: true }),
{ wrapper: makeWrapper(createCachingClient()) },
);
await act(async () => {
await result.current.selectCompany(company);
});
expect(rosterGet).toHaveBeenCalledTimes(1);
act(() => {
result.current.submit(submitValues([newTechnician]));
});
expect(saveMutate.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ rowVersion: "rv-1" }));
// The 409 never invalidates this key, and in the Add flow nothing else observes it.
// Without staleTime: 0 on the reload fetch, the cached rv-1 is served straight back
// and every retry 409s again.
rosterGet.mockResolvedValueOnce({ ...roster, rowVersion: "rv-2" });
await act(async () => {
result.current.reload();
});
await waitFor(() => expect(rosterGet).toHaveBeenCalledTimes(2));
act(() => {
result.current.submit(submitValues([newTechnician]));
});
expect(saveMutate.mock.calls[1]?.[0]).toEqual(
expect.objectContaining({ mode: "add", rowVersion: "rv-2" }),
);
});
it("does not regress the Edit Vendor reconcile path (mode update)", () => {
rosterQueryResult = {
data: rosterWithTechnicians,
isLoading: false,
isError: false,
error: null,
refetch: vi.fn(),
};
const { result } = renderHook(() => useVendorRosterForm({ mode: "update", vendorId: 7 }), {
wrapper: makeWrapper(createClient()),
});
act(() => {
result.current.submit(submitValues([...rosterWithTechnicians.technicians, newTechnician]));
});
expect(saveMutate).toHaveBeenCalledTimes(1);
expect(saveMutate.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({
mode: "update",
companyId: 5,
rowVersion: "rv-1",
originalRoster: rosterWithTechnicians,
values: expect.objectContaining({
technicians: [...rosterWithTechnicians.technicians, newTechnician],
}),
}),
);
});
});
describe("useVendorRosterForm technician autopopulation (SH-246)", () => {
beforeEach(() => {
rosterGet.mockReset();
saveMutate.mockReset();
});
it("does not autopopulate technician fields when a company is selected", async () => {
rosterGet.mockResolvedValueOnce(rosterWithTechnicians);
const { result } = renderHook(
() => {
const form = useVendorRosterForm({ mode: "create" });
const technicians = useController({ control: form.control, name: "technicians" });
const name = useController({ control: form.control, name: "name" });
return { form, technicians, name };
},
{ wrapper: makeWrapper(createClient()) },
);
await act(async () => {
await result.current.form.selectCompany(company);
});
const technicians = result.current.technicians.field.value;
expect(technicians).toEqual([
{
contactName: "",
phone: "",
email: "",
tradeSpecialties: "",
isActive: true,
},
]);
// SH-250: the blank row must not carry a fabricated preferredContact, since
// the control was removed from the form and `append` omits it too.
expect(technicians[0]).not.toHaveProperty("preferredContact");
expect(technicians).not.toContainEqual(expect.objectContaining({ contactName: "Ray Holt" }));
expect(technicians).not.toContainEqual(
expect.objectContaining({ contactName: "Amy Santiago" }),
);
expect(result.current.name.field.value).toBe("Gateway Plumbing");
expect(result.current.form.selectedCompanyId).toBe(5);
});
it("keeps a typed technician when the company selection is cleared to free text", async () => {
rosterGet.mockResolvedValueOnce(roster);
const { result } = renderHook(
() => {
const form = useVendorRosterForm({ mode: "create", startWithTechnician: true });
const technician = useController({
control: form.control,
name: "technicians.0.contactName",
});
return { form, technician };
},
{ wrapper: makeWrapper(createClient()) },
);
await act(async () => {
await result.current.form.selectCompany(company);
});
await act(async () => {
result.current.technician.field.onChange("Dana Kim");
});
act(() => {
result.current.form.clearSelectedCompany("Draft Vendor");
});
expect(result.current.form.selectedCompanyId).toBeNull();
expect(result.current.technician.field.value).toBe("Dana Kim");
});
});