shoc-frontend-new/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx
Codex Review Integration 58fc323ea2 fix(work-orders): use local calendar day for Past Due and stabilize slow suites
computeIsPastDue and the day view's todayKey derived today from
toISOString() (UTC). West of UTC that rolls over hours early, so work
scheduled for today showed as Past Due every evening. Both now use the
local todayIso() helper the board already uses; regression tests pin
23:30 and 00:30 in America/Sao_Paulo with fake Date.

Test stability:
- adv-search page sync: scope pagination queries with within() instead of
  scanning ~330 buttons / 3000 nodes on every waitFor poll
- pm-schedules-list-actions: static import of the edit page so module
  transform does not count against the 5s test budget
- vendor create modal / detail drawer: userEvent.setup({ delay: null });
  15s describe budget for full MUI dialog renders under parallel load
2026-09-16 22:06:33 -03:00

201 lines
8.4 KiB
TypeScript

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 { beforeEach, 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";
import type { StateCode } from "@/data/areas";
import { pickVendorState } from "./vendor-state-select";
vi.mock("@/domain/vendors/use-cases/use-vendor-facets", () => ({
useVendorFacets: () => ({ data: { companies: [], trades: [] }, isLoading: false }),
}));
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: vi.fn(), isPending: false }),
};
});
function PlainCompanyFieldsHarness() {
const { control, handleSubmit, formState } = useForm<VendorCompanyRosterFormValues>({
defaultValues: emptyVendorCompanyRosterForm,
resolver: zodResolver(vendorCompanyRosterSchema),
mode: "onChange",
});
return (
<form onSubmit={handleSubmit(() => {})}>
<VendorRosterFormFields control={control} errors={formState.errors} />
</form>
);
}
function renderCreateModal() {
renderWithProviders(<VendorCreateModal open={true} onClose={vi.fn()} />, {
route: "/vendors",
withAuth: false,
});
}
// delay: null skips user-event's per-keystroke setTimeout yields, which only
// add real-timer latency here.
let user = userEvent.setup({ delay: null });
async function fillCompanyNameAndSubmit(name = "Gateway Plumbing") {
fireEvent.change(screen.getByRole("combobox", { name: "Company (required)" }), {
target: { value: name },
});
// SH-271 made the structured address required. These tests are about the
// phone-or-email rule, so the address is filled to keep the form's only
// outstanding problem the one under test.
fillRequiredAddress();
await user.click(screen.getByRole("button", { name: "Add Vendor" }));
}
function fillRequiredAddress({
street = "6735 Trippel Rd",
city = "Theodore",
state = "AL" as StateCode,
} = {}) {
fireEvent.change(screen.getByRole("combobox", { name: "Street Address (required)" }), {
target: { value: street },
});
fireEvent.change(screen.getByRole("textbox", { name: "City (required)" }), {
target: { value: city },
});
pickVendorState(state);
}
// Each test mounts the full MUI dialog (Autocompletes, 50-option State list)
// and costs ~1s of CPU when idle; the first also pays JIT warm-up. Under
// parallel agent load the 5s default is exceeded by CPU starvation alone,
// matching the 10s budgets already used by vendors-list.test.tsx.
describe("VendorCreateModal validation", { timeout: 15_000 }, () => {
beforeEach(() => {
user = userEvent.setup({ delay: null });
});
it("explains the company phone-or-email requirement after submission", async () => {
renderCreateModal();
await fillCompanyNameAndSubmit();
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 optional technician section without counting blank drafts", () => {
renderCreateModal();
expect(screen.getByText("Technician details (optional)")).toBeInTheDocument();
expect(screen.queryByText("Technicians (1)")).not.toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Technician name (optional)" })).toBeVisible();
});
it("labels the plain company field exactly, required, with no generated asterisk", () => {
renderWithProviders(<PlainCompanyFieldsHarness />, { 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 user.type(phone, "314");
await user.click(screen.getByRole("button", { name: "Add Vendor" }));
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 user.type(email, "not-an-email");
await user.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 user.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 () => {
renderCreateModal();
const notes = screen.getByRole("textbox", { name: "Notes (optional)" });
fireEvent.change(notes, { target: { value: "abc" } });
expect(notes).toHaveAttribute("maxlength", "500");
await waitFor(() =>
expect(notes).toHaveAccessibleDescription(/3\/500 characters · Maximum 500 characters/),
);
});
});