// @vitest-environment happy-dom // AckDrawer wires `useAckDetail` (TanStack Query) and renders a Radix // Dialog portal — both need an act-aware, DOM-backed environment or // React logs warnings and the portal can't mount. (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; import { afterEach, describe, it, expect, vi } from "vitest"; import { cleanup, render } from "@testing-library/react"; import { ApiError } from "@/lib/api"; import { AckDrawer } from "@/components/AckDrawer"; import type { Ack } from "@/types"; // Mock the hook BEFORE the import above is resolved (vitest hoists // `vi.mock` to the top of the file regardless of where it appears // syntactically). Mocking the hook directly — rather than mocking // `api.getAck` — lets each test pin the hook's exact return shape // without standing up a real `QueryClient`. const { useAckDetail } = vi.hoisted(() => ({ useAckDetail: vi.fn(), })); vi.mock("@/hooks/useAckDetail", () => ({ useAckDetail, })); /** * Minimal valid `Ack` fixture — every required key present so the * component typechecks. The wire shape extends with `raw_999_text` * (and `rawJson`), per `useAckDetail`'s `AckDetail` type — populated * when the backend serves it; absent on older rows. */ const SAMPLE_ACK: Ack & { raw_999_text: string } = { id: 42, sourceBatchId: "b-uuid-1", acceptedCount: 3, rejectedCount: 1, receivedCount: 4, ackCode: "P", parsedAt: "2026-06-20T12:00:00Z", raw_999_text: "ISA*...*~\nGS*...*~\nST*999*0001~", }; /** * Configure the mocked hook's return value for a single test. The * `refetch` default is a fresh `vi.fn()` — tests that need to assert * on it can override via `overrides.refetch`. */ function mockDetail( overrides: Partial<{ data: (Ack & { raw_999_text?: string }) | null; isLoading: boolean; isError: boolean; error: Error | null; refetch: () => void; }> = {} ) { useAckDetail.mockReturnValue({ data: null, isLoading: false, isError: false, error: null, refetch: vi.fn(), ...overrides, }); } // happy-dom keeps `document.body` between tests; without cleanup, // `screen.getByText(...)` would find nodes from earlier renders. afterEach(() => { cleanup(); vi.clearAllMocks(); }); describe("AckDrawer", () => { it("test_renders_nothing_when_ackId_is_null", () => { mockDetail({ data: null }); render( {}} />); // No ack content should be in the document when the drawer is // closed — Radix's Dialog gates the portal on `open`. expect(document.body.textContent).not.toContain("b-uuid-1"); }); it("test_calls_useAckDetail_with_ackId", () => { mockDetail({ data: SAMPLE_ACK }); render( {}} />); expect(useAckDetail).toHaveBeenCalledWith("42"); }); it("test_renders_ack_summary_on_success", () => { mockDetail({ data: SAMPLE_ACK }); render( {}} />); // Header shows the source batch id as the title. expect(document.body.textContent).toContain("b-uuid-1"); // Counts (3 accepted, 1 rejected, 4 received) are surfaced as // StatTile values. expect(document.body.textContent).toContain("3"); expect(document.body.textContent).toContain("1"); expect(document.body.textContent).toContain("4"); }); it("test_renders_ack_code_pill_with_human_label", () => { mockDetail({ data: { ...SAMPLE_ACK, ackCode: "P" } }); render( {}} />); // The pill renders a human label, not just the bare code letter. expect(document.body.textContent).toContain("Partially accepted"); }); it("test_renders_skeleton_while_loading", () => { mockDetail({ isLoading: true }); render( {}} />); // The `Skeleton` primitive sets `aria-busy="true"` — a stable // hook for the loading state. expect(document.querySelectorAll('[aria-busy="true"]').length).toBeGreaterThan(0); // And the ack id should NOT have leaked in yet. expect(document.body.textContent).not.toContain("b-uuid-1"); }); it("test_renders_not_found_error_on_404", () => { mockDetail({ isError: true, error: new ApiError(404, "Ack ghost not found"), }); render( {}} />); const errEl = document.querySelector('[data-testid="ack-drawer-error-not_found"]'); expect(errEl).not.toBeNull(); // Body should mention "doesn't exist" — the not_found COPY key. expect(errEl?.textContent).toContain("doesn't exist"); // And the retry button should NOT be present (not_found has no // retry affordance — retrying a 404 won't help). expect(document.querySelector('[data-testid="error-retry"]')).toBeNull(); }); it("test_renders_network_error_with_retry", () => { mockDetail({ isError: true, error: new Error("network down") }); render( {}} />); const errEl = document.querySelector('[data-testid="ack-drawer-error-network"]'); expect(errEl).not.toBeNull(); expect(errEl?.textContent).toContain("Couldn't reach the server"); // Network variant shows a Retry button. const retryBtn = document.querySelector( '[data-testid="error-retry"]' ) as HTMLButtonElement | null; expect(retryBtn).not.toBeNull(); }); it("test_close_button_calls_onClose", () => { const onClose = vi.fn<() => void>(); mockDetail({ isError: true, error: new Error("network down") }); render(); const closeBtn = document.querySelector( '[data-testid="error-close"]' ) as HTMLButtonElement | null; expect(closeBtn).not.toBeNull(); closeBtn!.click(); expect(onClose).toHaveBeenCalledTimes(1); }); it("test_renders_download_button_when_raw_999_text_present", () => { mockDetail({ data: SAMPLE_ACK }); render( {}} />); // The header action slot populates with the Download 999 button // only when the ack detail carries raw_999_text. const dlBtn = document.querySelector('[data-testid="ack-drawer-download"]'); expect(dlBtn).not.toBeNull(); }); it("test_omits_download_button_when_raw_999_text_absent", () => { const data: Ack = { id: 42, sourceBatchId: "b-uuid-1", acceptedCount: 3, rejectedCount: 1, receivedCount: 4, ackCode: "A", parsedAt: "2026-06-20T12:00:00Z", }; mockDetail({ data }); render( {}} />); // No raw_999_text → no Download button. expect(document.querySelector('[data-testid="ack-drawer-download"]')).toBeNull(); }); });