diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png index d39596dc..bc36f902 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png index 33356fdc..4e9cf8d2 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png differ diff --git a/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts index f242b7cf..8c63f029 100644 --- a/e2e/vendors/vendors.spec.ts +++ b/e2e/vendors/vendors.spec.ts @@ -450,8 +450,86 @@ test.describe("Vendor directory prototype parity", () => { page.getByRole("button", { name: "Add Vendor", exact: true }).last(), ).toBeEnabled(); - await page.getByRole("combobox", { name: "Company" }).click(); + const companyLabel = page + .getByRole("dialog", { name: /Add Vendor/ }) + .locator("label") + .filter({ hasText: "Company (required)" }); + await expect(companyLabel).toBeVisible(); + const companyLabelMetrics = await companyLabel.evaluate((element) => { + const field = element.closest(".MuiFormControl-root"); + const labelRect = element.getBoundingClientRect(); + const fieldRect = field?.getBoundingClientRect(); + return { + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + withinFieldGeometry: fieldRect + ? labelRect.left >= fieldRect.left && + labelRect.right <= fieldRect.right && + labelRect.top >= fieldRect.top && + labelRect.bottom <= fieldRect.bottom + : false, + }; + }); + expect(companyLabelMetrics.clientWidth).toBeGreaterThanOrEqual(companyLabelMetrics.scrollWidth); + expect(companyLabelMetrics.withinFieldGeometry).toBe(true); + + await page.getByRole("combobox", { name: "Company (required)" }).click(); await page.getByRole("option", { name: "Gateway Plumbing" }).click(); + const floatedCompanyLabel = page + .getByRole("dialog", { name: /Add Vendor/ }) + .locator("label") + .filter({ hasText: "Company (required)" }); + const floatedCompanyField = floatedCompanyLabel.locator(".."); + await expect(floatedCompanyLabel).toHaveText("Company (required)"); + await expect(floatedCompanyField.locator("legend")).toHaveText("Company (required)"); + const floatedMetrics = await floatedCompanyLabel.evaluate((element) => { + const labelRect = element.getBoundingClientRect(); + const legendRect = element + .closest(".MuiFormControl-root") + ?.querySelector("legend") + ?.getBoundingClientRect(); + return { + labelClientWidth: element.clientWidth, + labelScrollWidth: element.scrollWidth, + legendWidth: legendRect?.width ?? 0, + labelWidth: labelRect.width, + }; + }); + expect(floatedMetrics.labelClientWidth).toBeGreaterThanOrEqual(floatedMetrics.labelScrollWidth); + expect(floatedMetrics.legendWidth).toBeGreaterThanOrEqual(floatedMetrics.labelWidth); + + // Scrolling focus down the form and back to Company must keep the floated label + // inside the dialog's visible scrollport. + const dialogContent = page + .getByRole("dialog", { name: /Add Vendor/ }) + .locator(".MuiDialogContent-root"); + await page.getByLabel("Technician name (optional)").click(); + await expect + .poll(() => dialogContent.evaluate((element) => element.scrollTop)) + .toBeGreaterThan(0); + await page.getByRole("combobox", { name: "Company (required)" }).click(); + await expect + .poll(() => dialogContent.evaluate((element) => element.scrollTop)) + .toBeLessThanOrEqual(1); + await expect(floatedCompanyLabel).toBeVisible(); + const labelScrollportMetrics = await floatedCompanyLabel.evaluate((element) => { + const content = element.closest(".MuiDialogContent-root"); + return { + labelTop: element.getBoundingClientRect().top, + labelBottom: element.getBoundingClientRect().bottom, + dialogTop: content?.getBoundingClientRect().top ?? Number.NaN, + dialogBottom: content?.getBoundingClientRect().bottom ?? Number.NaN, + overflowY: content ? getComputedStyle(content).overflowY : "", + }; + }); + expect(["auto", "scroll"]).toContain(labelScrollportMetrics.overflowY); + expect(labelScrollportMetrics.labelTop).toBeGreaterThanOrEqual( + labelScrollportMetrics.dialogTop - 1, + ); + expect(labelScrollportMetrics.labelBottom).toBeLessThanOrEqual( + labelScrollportMetrics.dialogBottom + 1, + ); + await expect(page.getByLabel("Company Phone (optional)")).toHaveValue("314-555-0100"); await expect(page.getByRole("textbox", { name: "Email (optional)", exact: true })).toHaveValue( "dispatch@gateway.test", @@ -560,7 +638,7 @@ test.describe("Vendor directory prototype parity", () => { await expect( page.getByRole("button", { name: "Add Vendor", exact: true }).last(), ).toBeEnabled(); - await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC"); + await page.getByRole("combobox", { name: "Company (required)" }).fill("Independent Vendor LLC"); await page.getByLabel("Company Phone (optional)").fill("3145550199"); await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click(); diff --git a/src/app/(protected)/vendors/_components/vendor-company-contact-fields.tsx b/src/app/(protected)/vendors/_components/vendor-company-contact-fields.tsx new file mode 100644 index 00000000..e379223f --- /dev/null +++ b/src/app/(protected)/vendors/_components/vendor-company-contact-fields.tsx @@ -0,0 +1,89 @@ +import { Controller, useWatch, type Control, type FieldErrors } from "react-hook-form"; +import { Stack, TextField } from "@mui/material"; +import { Text } from "@/components/ui/text"; +import { + VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE, + type VendorCompanyRosterFormValues, +} from "@/domain/vendors/schemas/vendor-roster-schema"; +import { formatNorthAmericanPhone } from "@/lib/format/na-phone"; + +const COMPANY_CONTACT_ERROR_ID = "vendor-company-contact-error"; +const COMPANY_PHONE_HELPER_ID = "vendor-company-phone-helper"; +const COMPANY_EMAIL_HELPER_ID = "vendor-company-email-helper"; + +interface VendorCompanyContactFieldsProps { + control: Control; + errors: FieldErrors; +} + +export function VendorCompanyContactFields({ control, errors }: VendorCompanyContactFieldsProps) { + const companyPhone = useWatch({ control, name: "companyPhone" }) ?? ""; + const companyEmail = useWatch({ control, name: "email" }) ?? ""; + const showCompanyContactError = Boolean( + errors.companyContact && !companyPhone.trim() && !companyEmail.trim(), + ); + const contactDescribedBy = (helperId: string, hasHelper: boolean) => + [hasHelper ? helperId : null, showCompanyContactError ? COMPANY_CONTACT_ERROR_ID : null] + .filter(Boolean) + .join(" ") || undefined; + + return ( + <> + + ( + field.onChange(formatNorthAmericanPhone(event.target.value))} + error={Boolean(errors.companyPhone)} + helperText={errors.companyPhone?.message} + slotProps={{ + formHelperText: { id: COMPANY_PHONE_HELPER_ID }, + htmlInput: { + "aria-describedby": contactDescribedBy( + COMPANY_PHONE_HELPER_ID, + Boolean(errors.companyPhone?.message), + ), + }, + }} + fullWidth + /> + )} + /> + ( + + )} + /> + + + {VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE} + + + ); +} diff --git a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx index 002a81bf..55e3506d 100644 --- a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx +++ b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx @@ -54,7 +54,12 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) { Register a vendor company and its technicians. - + {form.loadErrorMessage !== null && ( diff --git a/src/app/(protected)/vendors/_components/vendor-roster-conflict-alert.tsx b/src/app/(protected)/vendors/_components/vendor-roster-conflict-alert.tsx index 5a1ff44e..30878dc3 100644 --- a/src/app/(protected)/vendors/_components/vendor-roster-conflict-alert.tsx +++ b/src/app/(protected)/vendors/_components/vendor-roster-conflict-alert.tsx @@ -8,6 +8,15 @@ interface VendorRosterConflictAlertProps { } export function VendorRosterConflictAlert({ conflict, onReload }: VendorRosterConflictAlertProps) { + if (conflict.kind === "duplicate") { + return ( + + Company name already exists + {conflict.message} + + ); + } + if (conflict.kind === "stale") { return ( ; @@ -64,9 +65,9 @@ function CompanyNameField({ render={({ field }) => ( ( )} /> @@ -143,40 +147,7 @@ function CompanyFields({ onSelectCompany={onSelectCompany} onClearSelectedCompany={onClearSelectedCompany} /> - - ( - field.onChange(formatNorthAmericanPhone(event.target.value))} - error={Boolean(errors.companyPhone)} - helperText={errors.companyPhone?.message} - fullWidth - /> - )} - /> - ( - - )} - /> - + { if (!isHTTPError(error)) return undefined; + if (error.data !== undefined && error.data !== null) return error.data; try { return await error.response.json(); } catch { diff --git a/src/domain/vendors/mappers/vendor-roster-mapper.ts b/src/domain/vendors/mappers/vendor-roster-mapper.ts index 641042ef..d3fb7ceb 100644 --- a/src/domain/vendors/mappers/vendor-roster-mapper.ts +++ b/src/domain/vendors/mappers/vendor-roster-mapper.ts @@ -179,8 +179,13 @@ export function mapRosterConflict(raw: unknown, fallbackMessage: string): Vendor const item = asRecord(raw); const blockedRaw = item.blockedWorkOrders ?? item.BlockedWorkOrders ?? item.openWorkOrders; const blockedWorkOrders = Array.isArray(blockedRaw) ? blockedRaw.map(mapBlockedWorkOrder) : []; + const code = readString(item, "code", "Code"); const kind: VendorRosterConflictKind = - blockedWorkOrders.length > 0 ? "open-work-orders" : "stale"; + code === "duplicate_vendor_company_name" + ? "duplicate" + : blockedWorkOrders.length > 0 + ? "open-work-orders" + : "stale"; const message = readString(item, "message", "Message") || fallbackMessage; return { kind, message, blockedWorkOrders }; } diff --git a/src/domain/vendors/schemas/vendor-roster-schema.ts b/src/domain/vendors/schemas/vendor-roster-schema.ts index 744a3581..e532a7ba 100644 --- a/src/domain/vendors/schemas/vendor-roster-schema.ts +++ b/src/domain/vendors/schemas/vendor-roster-schema.ts @@ -51,6 +51,10 @@ export type RosterTechnicianValues = z.infer; const baseCompanyFields = { name: z.string().trim().min(1, "Company is required"), + // Validation-only identity for the phone-or-email group. It is never + // registered as an input and Zod omits it from parsed values when absent, + // so it cannot enter the vendor payload. + companyContact: z.never().optional(), companyPhone: northAmericanPhone, email: optionalEmail, address: z.string(), @@ -64,14 +68,20 @@ const baseCompanyFields = { technicians: z.array(rosterTechnicianSchema), }; +export const VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE = + "Provide a company phone or email (at least one required)"; + +// The phone-or-email rule spans two optional inputs, so it is reported at a +// validation-only path instead of being attributed to `companyPhone`, which +// would mark that input individually invalid. export const vendorCompanyRosterSchema = z.object(baseCompanyFields).superRefine((data, ctx) => { const hasPhone = Boolean(data.companyPhone && data.companyPhone.trim()); const hasEmail = Boolean(data.email && data.email.trim()); if (!hasPhone && !hasEmail) { ctx.addIssue({ code: z.ZodIssueCode.custom, - path: ["companyPhone"], - message: "Provide a company phone or email (at least one required)", + path: ["companyContact"], + message: VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE, }); } }); diff --git a/src/domain/vendors/types/vendor.ts b/src/domain/vendors/types/vendor.ts index 74e6519e..b6ee694d 100644 --- a/src/domain/vendors/types/vendor.ts +++ b/src/domain/vendors/types/vendor.ts @@ -145,7 +145,7 @@ export interface VendorRosterBlockedWorkOrder { scheduledDate?: string; } -export type VendorRosterConflictKind = "open-work-orders" | "stale"; +export type VendorRosterConflictKind = "duplicate" | "open-work-orders" | "stale"; export interface VendorRosterConflict { kind: VendorRosterConflictKind; diff --git a/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx b/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx index 661f00d7..214fecea 100644 --- a/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx +++ b/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx @@ -1,7 +1,16 @@ import { fireEvent, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; import { describe, expect, it, vi } from "vitest"; import { VendorCreateModal } from "@/app/(protected)/vendors/_components/vendor-create-modal"; +import { VendorRosterFormFields } from "@/app/(protected)/vendors/_components/vendor-roster-form-fields"; +import { + VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE, + emptyVendorCompanyRosterForm, + vendorCompanyRosterSchema, + type VendorCompanyRosterFormValues, +} from "@/domain/vendors/schemas/vendor-roster-schema"; import { renderWithProviders } from "@/test/test-utils"; vi.mock("@/domain/vendors/use-cases/use-vendor-facets", () => ({ @@ -18,28 +27,128 @@ vi.mock("@/domain/vendors/use-cases/use-save-vendor-company-roster", async () => }; }); +function PlainCompanyFieldsHarness() { + const { control, handleSubmit, formState } = useForm({ + defaultValues: emptyVendorCompanyRosterForm, + resolver: zodResolver(vendorCompanyRosterSchema), + mode: "onChange", + }); + return ( +
{})}> + + + ); +} + +function renderCreateModal() { + renderWithProviders(, { + route: "/vendors", + withAuth: false, + }); +} + +async function fillCompanyNameAndSubmit(name = "Gateway Plumbing") { + fireEvent.change(screen.getByRole("combobox", { name: "Company (required)" }), { + target: { value: name }, + }); + await userEvent.click(screen.getByRole("button", { name: "Add Vendor" })); +} + describe("VendorCreateModal validation", () => { it("explains the company phone-or-email requirement after submission", async () => { - renderWithProviders(, { - route: "/vendors", - withAuth: false, - }); + renderCreateModal(); + await fillCompanyNameAndSubmit(); - fireEvent.change(screen.getByRole("combobox", { name: /Company/ }), { - target: { value: "Gateway Plumbing" }, - }); + expect(await screen.findByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).toBeInTheDocument(); + }); + + it("labels the autocomplete company field exactly, required, with no generated asterisk", () => { + renderCreateModal(); + + const company = screen.getByRole("combobox", { name: "Company (required)" }); + expect(company).toBeRequired(); + expect(screen.getAllByText("Company (required)")).toHaveLength(2); + expect(screen.queryByText("*")).not.toBeInTheDocument(); + expect(document.querySelector(".MuiFormLabel-asterisk")).toBeNull(); + expect(company.closest(".MuiFormControl-root")?.querySelector("legend")).toHaveTextContent( + /^Company \(required\)$/, + ); + }); + + it("labels the plain company field exactly, required, with no generated asterisk", () => { + renderWithProviders(, { route: "/vendors", withAuth: false }); + + const company = screen.getByRole("textbox", { name: "Company (required)" }); + expect(company).toBeRequired(); + expect(screen.getAllByText("Company (required)")).toHaveLength(2); + expect(screen.queryByText("*")).not.toBeInTheDocument(); + expect(document.querySelector(".MuiFormLabel-asterisk")).toBeNull(); + expect(company.closest(".MuiFormControl-root")?.querySelector("legend")).toHaveTextContent( + /^Company \(required\)$/, + ); + }); + + it("shows the phone-or-email error once without marking either input individually invalid", async () => { + renderCreateModal(); + await fillCompanyNameAndSubmit(); + + const message = await screen.findByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE); + expect(message).toHaveAttribute("id"); + expect(screen.getAllByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).toHaveLength(1); + + const phone = screen.getByRole("textbox", { name: "Company Phone (optional)" }); + const email = screen.getByRole("textbox", { name: "Email (optional)" }); + expect(phone).not.toHaveAttribute("aria-invalid", "true"); + expect(email).not.toHaveAttribute("aria-invalid", "true"); + expect(phone.getAttribute("aria-describedby")).toContain(message.getAttribute("id")); + expect(email.getAttribute("aria-describedby")).toContain(message.getAttribute("id")); + }); + + it("marks only the phone input invalid when the phone is malformed", async () => { + renderCreateModal(); + await fillCompanyNameAndSubmit(); + const phone = screen.getByRole("textbox", { name: "Company Phone (optional)" }); + const email = screen.getByRole("textbox", { name: "Email (optional)" }); + + await userEvent.type(phone, "314"); await userEvent.click(screen.getByRole("button", { name: "Add Vendor" })); - expect( - await screen.findByText("Provide a company phone or email (at least one required)"), - ).toBeInTheDocument(); + expect(await screen.findByText("Enter a 10-digit phone number")).toBeInTheDocument(); + expect(phone).toHaveAttribute("aria-invalid", "true"); + expect(email).not.toHaveAttribute("aria-invalid", "true"); + expect(screen.queryByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).not.toBeInTheDocument(); + }); + + it("marks only the email input invalid when the email is malformed", async () => { + renderCreateModal(); + await fillCompanyNameAndSubmit(); + const phone = screen.getByRole("textbox", { name: "Company Phone (optional)" }); + const email = screen.getByRole("textbox", { name: "Email (optional)" }); + + await userEvent.type(email, "not-an-email"); + await userEvent.click(screen.getByRole("button", { name: "Add Vendor" })); + + expect(await screen.findByText("Invalid email")).toBeInTheDocument(); + expect(email).toHaveAttribute("aria-invalid", "true"); + expect(phone).not.toHaveAttribute("aria-invalid", "true"); + expect(screen.queryByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).not.toBeInTheDocument(); + }); + + it("clears the phone-or-email error once a valid phone is entered", async () => { + renderCreateModal(); + await fillCompanyNameAndSubmit(); + const phone = screen.getByRole("textbox", { name: "Company Phone (optional)" }); + + expect(await screen.findByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).toBeInTheDocument(); + + await userEvent.type(phone, "3145550100"); + await waitFor(() => + expect(screen.queryByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).not.toBeInTheDocument(), + ); }); it("shows the notes limit and live character counter", async () => { - renderWithProviders(, { - route: "/vendors", - withAuth: false, - }); + renderCreateModal(); const notes = screen.getByRole("textbox", { name: "Notes (optional)" }); fireEvent.change(notes, { target: { value: "abc" } }); diff --git a/src/test/app/(protected)/vendors/vendor-roster-conflict-alert.test.tsx b/src/test/app/(protected)/vendors/vendor-roster-conflict-alert.test.tsx new file mode 100644 index 00000000..25f61538 --- /dev/null +++ b/src/test/app/(protected)/vendors/vendor-roster-conflict-alert.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { VendorRosterConflictAlert } from "@/app/(protected)/vendors/_components/vendor-roster-conflict-alert"; + +describe("VendorRosterConflictAlert", () => { + it("explains a duplicate company without offering a stale-data reload", () => { + render( + , + ); + + expect(screen.getByText("Company name already exists")).toBeInTheDocument(); + expect(screen.getByText("Another vendor company already uses that name.")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Reload" })).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/vendors/vendors-list.test.tsx b/src/test/app/(protected)/vendors/vendors-list.test.tsx index cb7164e1..8d51d3cf 100644 --- a/src/test/app/(protected)/vendors/vendors-list.test.tsx +++ b/src/test/app/(protected)/vendors/vendors-list.test.tsx @@ -329,7 +329,7 @@ describe("VendorsListPage", () => { renderWithProviders(, { route: "/vendors", withAuth: false }); await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" })); - const company = screen.getByRole("textbox", { name: "Company" }); + const company = screen.getByRole("textbox", { name: "Company (required)" }); await userEvent.clear(company); await userEvent.type(company, "Draft Company Name"); await userEvent.click(screen.getByRole("switch", { name: "Active status" })); @@ -362,7 +362,9 @@ describe("VendorsListPage", () => { await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" })); expect(screen.getByRole("button", { name: "Save changes" })).toBeInTheDocument(); - expect(screen.getByRole("textbox", { name: "Company" })).toHaveValue("Gateway Plumbing"); + expect(screen.getByRole("textbox", { name: "Company (required)" })).toHaveValue( + "Gateway Plumbing", + ); }); it("opens inline company edit when the row has no vendor id", async () => { @@ -385,6 +387,8 @@ describe("VendorsListPage", () => { await userEvent.click(screen.getByRole("button", { name: "Edit vendor Gateway Plumbing" })); expect(screen.getByRole("button", { name: "Save changes" })).toBeInTheDocument(); - expect(screen.getByRole("textbox", { name: "Company" })).toHaveValue("Gateway Plumbing"); + expect(screen.getByRole("textbox", { name: "Company (required)" })).toHaveValue( + "Gateway Plumbing", + ); }); }); diff --git a/src/test/domain/vendors/api/vendor-company-roster-api.test.ts b/src/test/domain/vendors/api/vendor-company-roster-api.test.ts index 32fb5aeb..ed95e3d4 100644 --- a/src/test/domain/vendors/api/vendor-company-roster-api.test.ts +++ b/src/test/domain/vendors/api/vendor-company-roster-api.test.ts @@ -118,6 +118,60 @@ describe("vendorCompanyRosterApi", () => { ); }); + it("throws a duplicate conflict for the stable create response", async () => { + apiPost.mockRejectedValueOnce( + httpError(409, { + code: "duplicate_vendor_company_name", + message: "Another vendor company already uses that name.", + }), + ); + + await expect(vendorCompanyRosterApi.create({ name: "Solo Co" })).rejects.toSatisfy( + (error: unknown) => { + if (!isVendorRosterConflictError(error)) return false; + return ( + error.conflict.kind === "duplicate" && + error.conflict.message === "Another vendor company already uses that name." + ); + }, + ); + }); + + it("classifies a duplicate 409 from parsed error.data when the response body is consumed", async () => { + const conflictError = new Error("HTTPError") as Error & { + __kyHTTPError: true; + data: unknown; + response: { status: number; json: () => Promise; text: () => Promise }; + }; + conflictError.__kyHTTPError = true; + conflictError.data = { + code: "duplicate_vendor_company_name", + message: "Another vendor company already uses that name.", + }; + const consumedBody = () => { + const rejection = Promise.reject(new TypeError("Body has already been consumed")); + rejection.catch(() => {}); + return rejection; + }; + conflictError.response = { + status: 409, + json: () => consumedBody(), + text: () => consumedBody(), + }; + + apiPost.mockRejectedValueOnce(conflictError); + + await expect(vendorCompanyRosterApi.create({ name: "Solo Co" })).rejects.toSatisfy( + (error: unknown) => { + if (!isVendorRosterConflictError(error)) return false; + return ( + error.conflict.kind === "duplicate" && + error.conflict.message === "Another vendor company already uses that name." + ); + }, + ); + }); + it("rethrows non-conflict errors untouched", async () => { const generic = new Error("boom"); apiPost.mockRejectedValueOnce(generic); diff --git a/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts b/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts index 66e8d28b..65494d15 100644 --- a/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts +++ b/src/test/domain/vendors/mappers/vendor-roster-mapper.test.ts @@ -113,6 +113,20 @@ describe("vendor roster mapper", () => { expect(conflict.message).toBe("stale rowversion"); }); + it("classifies the stable duplicate-company response", () => { + const conflict = mapRosterConflict( + { + code: "duplicate_vendor_company_name", + message: "Another vendor company already uses that name.", + }, + "fallback", + ); + + expect(conflict.kind).toBe("duplicate"); + expect(conflict.message).toBe("Another vendor company already uses that name."); + expect(conflict.blockedWorkOrders).toEqual([]); + }); + it("preserves an explicit Phone/Email/Text preferred contact on read", () => { expect( mapRosterTechnician({ contactName: "A", preferredContact: "Phone" }).preferredContact, diff --git a/src/test/domain/vendors/schemas/vendor-roster-schema.test.ts b/src/test/domain/vendors/schemas/vendor-roster-schema.test.ts index 646f8e73..d5772d1a 100644 --- a/src/test/domain/vendors/schemas/vendor-roster-schema.test.ts +++ b/src/test/domain/vendors/schemas/vendor-roster-schema.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE, emptyRosterTechnician, emptyVendorCompanyRosterForm, isAbsoluteHttpsUrl, @@ -30,12 +31,29 @@ describe("vendorCompanyRosterSchema", () => { if (!result.success) { expect( result.error.issues.some( - (issue) => issue.message === "Provide a company phone or email (at least one required)", + (issue) => issue.message === VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE, ), ).toBe(true); } }); + it("reports the phone-or-email rule at the companyContact path, not on companyPhone", () => { + const result = vendorCompanyRosterSchema.safeParse({ + ...emptyVendorCompanyRosterForm, + name: "Solo Co", + }); + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues.find( + (candidate) => candidate.message === VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE, + ); + expect(issue?.path).toEqual(["companyContact"]); + expect(result.error.issues.some((candidate) => candidate.path.includes("companyPhone"))).toBe( + false, + ); + } + }); + it("accepts a company with zero technicians when phone is present", () => { const result = vendorCompanyRosterSchema.safeParse({ ...emptyVendorCompanyRosterForm, @@ -43,6 +61,7 @@ describe("vendorCompanyRosterSchema", () => { companyPhone: "(314) 555-0100", }); expect(result.success).toBe(true); + if (result.success) expect(result.data).not.toHaveProperty("companyContact"); }); it("accepts a company with email only and multiple technicians", () => {