// @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, })); // SP28: the new panel mounts three hooks (data, // live-tail, live-tail merge) and calls `useNavigate` to navigate to // /claims?claim= when the operator clicks a claim card's // "open claim" button. Mock each one at the module boundary so the // test never opens a real fetch or a real navigation. The hook // mocks return "empty" by default so the panel renders the // "no claim links yet" empty state — tests that need to populate // the panel override via `setAckClaimsRows(...)`. const { useAckClaims, useTailStream, useMergedTail, useNavigate, matchAckToClaim, unmatchAck, listClaims, } = vi.hoisted(() => ({ useAckClaims: vi.fn(), useTailStream: vi.fn(), useMergedTail: vi.fn(), useNavigate: vi.fn(), matchAckToClaim: vi.fn(), unmatchAck: vi.fn(), listClaims: vi.fn(), })); vi.mock("@/hooks/useAckClaims", () => ({ useAckClaims })); vi.mock("@/hooks/useTailStream", () => ({ useTailStream })); vi.mock("@/hooks/useMergedTail", () => ({ useMergedTail })); vi.mock("@/lib/api", async () => { const actual = await vi.importActual("@/lib/api"); return { ...actual, matchAckToClaim, unmatchAck, listClaims, }; }); vi.mock("react-router-dom", async () => { const actual = await vi.importActual( "react-router-dom", ); return { ...actual, useNavigate, }; }); /** * Configure the mocked `useAckClaims` + `useMergedTail` hooks 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 setAckClaimsRows( rows: Array<{ id: number; claimId: string | null; batchId: string | null; ackId: number; ackKind: "999" | "277ca" | "ta1"; ak2Index: number | null; setControlNumber: string | null; setAcceptRejectCode: string | null; linkedAt: string; claimState?: string; }>, ) { useAckClaims.mockReturnValue({ data: rows, isLoading: false, isError: false, error: null, refetch: vi.fn(), }); useMergedTail.mockReturnValue(rows); } /** * 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(); // Default SP28 mocks — empty list, no errors, no-op navigate. Tests // that exercise the MatchedClaim panel override via // `setAckClaimsRows(...)`. useAckClaims.mockReturnValue({ data: [], isLoading: false, isError: false, error: null, refetch: vi.fn(), }); useTailStream.mockReturnValue({ status: "live", lastEventAt: null, error: null, forceReconnect: vi.fn(), }); useMergedTail.mockImplementation( (_resource: unknown, baseItems: unknown) => baseItems, ); useNavigate.mockReturnValue(vi.fn()); matchAckToClaim.mockResolvedValue({ link_id: 1, claim_id: "CLM-1", ack_id: 42, ack_kind: "999", set_control_number: "991102989", set_accept_reject_code: "A", linked_at: "2026-07-02T12:00:00Z", linked_by: "manual", }); unmatchAck.mockResolvedValue(undefined); listClaims.mockResolvedValue({ items: [], total: 0, returned: 0, has_more: false }); }); 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(); }); // ------------------------------------------------------------------------- // SP28: the new panel. // Tests assert: // - panel renders when `useAckClaims` returns a non-empty list // - panel renders empty state with "Link to claim…" dropdown when // the list is empty (default) // - the "open claim" button navigates to /claims?claim= // - the panel mounts the live-tail subscription via // `useTailStream("claim-acks")` // - TA1 batch-level rows (claimId === null) render with the // originating Batch id, not a claim card // ------------------------------------------------------------------------- it("test_matched_claim_panel_renders_empty_state_when_no_links", () => { mockDetail({ data: SAMPLE_ACK }); render( {}} />); // The panel must be present. expect(document.querySelector('[data-testid="matched-claim-panel"]')) .not.toBeNull(); // The empty-state copy must surface. expect(document.querySelector('[data-testid="matched-claim-empty"]')) .not.toBeNull(); // The "Link to claim…" dropdown is the manual-match affordance // (per D5/D9 — any logged-in user can run it). expect(document.querySelector('[data-testid="link-to-claim-dropdown"]')) .not.toBeNull(); expect(document.querySelector('[data-testid="link-to-claim-toggle"]')) .not.toBeNull(); // No row rendered. expect(document.querySelector('[data-testid="matched-claim-card"]')).toBeNull(); }); it("test_matched_claim_panel_renders_card_per_claim_link", () => { setAckClaimsRows([ { id: 1, claimId: "CLM-1", batchId: null, ackId: 42, ackKind: "999", ak2Index: 0, setControlNumber: "991102989", setAcceptRejectCode: "A", linkedAt: "2026-07-02T12:00:00Z", claimState: "submitted", }, { id: 2, claimId: "CLM-2", batchId: null, ackId: 42, ackKind: "999", ak2Index: 1, setControlNumber: "991102990", setAcceptRejectCode: "R", linkedAt: "2026-07-02T12:00:01Z", claimState: "denied", }, ]); mockDetail({ data: SAMPLE_ACK }); render( {}} />); // Per-AK2 granularity (D1): one card per link row. const cards = document.querySelectorAll('[data-testid="matched-claim-card"]'); expect(cards.length).toBe(2); // The header count must reflect the row count. expect(document.querySelector('[data-testid="matched-claim-count"]') ?.textContent).toContain("(2)"); // Each card renders its claim id. const ids = document.querySelectorAll('[data-testid="matched-claim-id"]'); expect(ids[0]?.textContent).toContain("CLM-1"); expect(ids[1]?.textContent).toContain("CLM-2"); }); it("test_matched_claim_panel_renders_ta1_batch_row_with_originating_batch", () => { // TA1 batch-level rows have claimId === null. The panel renders // them with the originating Batch id, not a claim card (D4). setAckClaimsRows([ { id: 1, claimId: null, batchId: "B-TA1-1", ackId: 42, ackKind: "ta1", ak2Index: null, setControlNumber: null, setAcceptRejectCode: null, linkedAt: "2026-07-02T12:00:00Z", claimState: "n/a", }, ]); mockDetail({ data: SAMPLE_ACK }); render( {}} />); // The TA1 row renders as a Batch card, not a claim card. expect(document.querySelector('[data-testid="matched-claim-card-ta1"]')) .not.toBeNull(); expect(document.querySelector('[data-testid="matched-claim-card"]')).toBeNull(); // The originating batch id is surfaced verbatim. expect(document.querySelector('[data-testid="matched-claim-batch-id"]') ?.textContent).toContain("B-TA1-1"); }); it("test_matched_claim_panel_open_claim_button_navigates_to_claims_with_claim_id", () => { setAckClaimsRows([ { id: 1, claimId: "CLM-1", batchId: null, ackId: 42, ackKind: "999", ak2Index: 0, setControlNumber: "991102989", setAcceptRejectCode: "A", linkedAt: "2026-07-02T12:00:00Z", claimState: "submitted", }, ]); const navigate = vi.fn(); useNavigate.mockReturnValue(navigate); mockDetail({ data: SAMPLE_ACK }); render( {}} />); const openBtn = document.querySelector( '[data-testid="open-claim-drawer"]', ) as HTMLButtonElement | null; expect(openBtn).not.toBeNull(); openBtn!.click(); // Cross-page navigation — the ClaimDrawer is mounted on /claims. expect(navigate).toHaveBeenCalledTimes(1); expect(navigate).toHaveBeenCalledWith("/claims?claim=CLM-1"); }); it("test_matched_claim_panel_mounts_live_tail_subscription", () => { mockDetail({ data: SAMPLE_ACK }); render( {}} />); // The panel opens one claim-acks live-tail stream. Per // cyclone-frontend-page convention #3, the subscription lives on // the drawer, not on the data hook. expect(useTailStream).toHaveBeenCalledWith("claim-acks"); }); it("test_matched_claim_panel_calls_useAckClaims_with_kind_and_ack_id", () => { mockDetail({ data: SAMPLE_ACK }); render( {}} />); // The panel must pass (kind="999", ackId=42) to useAckClaims so // the hook builds the right query key. expect(useAckClaims).toHaveBeenCalledWith("999", 42); }); });