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();
});
});