import { act, fireEvent, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { RefuseDispatchDialog } from "@/app/v/[token]/dispatch/_components/refuse-dispatch-dialog"; import { VendorPortalContext, type VendorPortalContextValue, } from "@/app/v/_components/vendor-portal-context"; import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api"; import { REFUSE_REASON_MAX_LENGTH } from "@/domain/vendor-portal/schemas/refuse-dispatch-schema"; import { queryKeys } from "@/infra/query-key/query-key"; import { createTestQueryClient, renderWithProviders } from "@/test/test-utils"; function renderDialog({ token = "portal-token", dispatchId = 9, onClose = vi.fn(), onRefused = vi.fn(), }: Partial<{ token: string; dispatchId: number; onClose: () => void; onRefused: () => void; }> = {}) { const queryClient = createTestQueryClient(); const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); const portalValue: VendorPortalContextValue = { token, vendor: { companyName: "Gateway Plumbing", vendorId: 101 }, status: "ready", error: null, }; const utils = renderWithProviders( , { withAuth: false, queryClient }, ); return { ...utils, invalidateSpy, queryClient }; } beforeEach(() => { vi.spyOn(vendorPortalApi, "refuse").mockResolvedValue({ id: 9, status: "Refused" }); }); afterEach(() => { vi.restoreAllMocks(); }); describe("RefuseDispatchDialog", () => { it("submits without a reason when the field is left empty", async () => { renderDialog(); await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Refuse dispatch" })); }); await waitFor(() => expect(vendorPortalApi.refuse).toHaveBeenCalledWith("portal-token", 9, undefined), ); }); it("trims a supplied reason before sending", async () => { renderDialog(); fireEvent.change(screen.getByLabelText(/Reason/i), { target: { value: " too busy " } }); await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Refuse dispatch" })); }); await waitFor(() => expect(vendorPortalApi.refuse).toHaveBeenCalledWith("portal-token", 9, "too busy"), ); }); it("blocks submission when the reason exceeds the length cap", async () => { renderDialog(); const overLimit = "a".repeat(REFUSE_REASON_MAX_LENGTH + 1); fireEvent.change(screen.getByLabelText(/Reason/i), { target: { value: overLimit } }); await waitFor(() => expect(screen.getByRole("button", { name: "Refuse dispatch" })).toBeDisabled(), ); expect(vendorPortalApi.refuse).not.toHaveBeenCalled(); }); it("shows a live character counter bounded by the limit", () => { renderDialog(); fireEvent.change(screen.getByLabelText(/Reason/i), { target: { value: "abc" } }); expect(screen.getByText(`3/${REFUSE_REASON_MAX_LENGTH}`)).toBeInTheDocument(); }); it("invalidates the detail and list cache and closes on success", async () => { const onClose = vi.fn(); const onRefused = vi.fn(); const { invalidateSpy } = renderDialog({ onClose, onRefused }); await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Refuse dispatch" })); }); await waitFor(() => expect(onClose).toHaveBeenCalled()); expect(onRefused).toHaveBeenCalled(); expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.vendorPortal.dispatch("portal-token", 9), }); expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.vendorPortal.dispatches("portal-token"), }); }); it("surfaces a safe inline error and keeps the dialog open on failure", async () => { vi.spyOn(vendorPortalApi, "refuse").mockRejectedValueOnce(new Error("nope")); const onClose = vi.fn(); renderDialog({ onClose }); await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Refuse dispatch" })); }); await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent(/Unable to refuse/i)); expect(onClose).not.toHaveBeenCalled(); }); it("closes without refusing when cancel is pressed", async () => { const onClose = vi.fn(); renderDialog({ onClose }); fireEvent.click(screen.getByRole("button", { name: "Cancel" })); await waitFor(() => expect(onClose).toHaveBeenCalled()); expect(vendorPortalApi.refuse).not.toHaveBeenCalled(); }); it("blocks Escape and backdrop close while a refusal is pending, then closes on success", async () => { let resolveRefusal!: (value: { id: number; status: string }) => void; vi.spyOn(vendorPortalApi, "refuse").mockImplementation( () => new Promise((resolve) => { resolveRefusal = resolve; }), ); const onClose = vi.fn(); renderDialog({ onClose }); await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Refuse dispatch" })); }); await waitFor(() => expect(screen.getByRole("button", { name: "Refusing…" })).toBeDisabled()); expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled(); const backdrop = document.querySelector(".MuiBackdrop-root"); expect(backdrop).not.toBeNull(); fireEvent.click(backdrop as Element); fireEvent.keyDown(document.body, { key: "Escape" }); expect(onClose).not.toHaveBeenCalled(); await act(async () => { resolveRefusal({ id: 9, status: "Refused" }); }); await waitFor(() => expect(onClose).toHaveBeenCalled()); }); it("clears the stale error and reason after closing and reopening", async () => { vi.spyOn(vendorPortalApi, "refuse").mockRejectedValueOnce(new Error("nope")); const onClose = vi.fn(); const onRefused = vi.fn(); const portalValue: VendorPortalContextValue = { token: "portal-token", vendor: { companyName: "Gateway Plumbing", vendorId: 101 }, status: "ready", error: null, }; const renderEl = (open: boolean) => ( ); const { rerender } = renderWithProviders(renderEl(true), { withAuth: false }); fireEvent.change(screen.getByLabelText(/Reason/i), { target: { value: "too busy" } }); await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Refuse dispatch" })); }); await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent(/Unable to refuse/i)); expect(screen.getByText(`8/${REFUSE_REASON_MAX_LENGTH}`)).toBeInTheDocument(); await act(async () => { rerender(renderEl(false)); }); await act(async () => { rerender(renderEl(true)); }); await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument()); expect(screen.getByText(`0/${REFUSE_REASON_MAX_LENGTH}`)).toBeInTheDocument(); }); });