459 lines
15 KiB
TypeScript
459 lines
15 KiB
TypeScript
// @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 <MatchedClaim /> panel mounts three hooks (data,
|
|
// live-tail, live-tail merge) and calls `useNavigate` to navigate to
|
|
// /claims?claim=<id> 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<typeof import("@/lib/api")>("@/lib/api");
|
|
return {
|
|
...actual,
|
|
matchAckToClaim,
|
|
unmatchAck,
|
|
listClaims,
|
|
};
|
|
});
|
|
vi.mock("react-router-dom", async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import("react-router-dom")>(
|
|
"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(<AckDrawer ackId={null} onClose={() => {}} />);
|
|
|
|
// 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(<AckDrawer ackId="42" onClose={() => {}} />);
|
|
|
|
expect(useAckDetail).toHaveBeenCalledWith("42");
|
|
});
|
|
|
|
it("test_renders_ack_summary_on_success", () => {
|
|
mockDetail({ data: SAMPLE_ACK });
|
|
render(<AckDrawer ackId="42" onClose={() => {}} />);
|
|
|
|
// 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(<AckDrawer ackId="42" onClose={() => {}} />);
|
|
|
|
// 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(<AckDrawer ackId="42" onClose={() => {}} />);
|
|
|
|
// 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(<AckDrawer ackId="9999" onClose={() => {}} />);
|
|
|
|
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(<AckDrawer ackId="42" onClose={() => {}} />);
|
|
|
|
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(<AckDrawer ackId="42" onClose={onClose} />);
|
|
|
|
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(<AckDrawer ackId="42" onClose={() => {}} />);
|
|
|
|
// 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(<AckDrawer ackId="42" onClose={() => {}} />);
|
|
|
|
// No raw_999_text → no Download button.
|
|
expect(document.querySelector('[data-testid="ack-drawer-download"]')).toBeNull();
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// SP28: the new <MatchedClaim /> 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=<id>
|
|
// - 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(<AckDrawer ackId="42" onClose={() => {}} />);
|
|
|
|
// 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(<AckDrawer ackId="42" onClose={() => {}} />);
|
|
|
|
// 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(<AckDrawer ackId="42" onClose={() => {}} />);
|
|
|
|
// 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(<AckDrawer ackId="42" onClose={() => {}} />);
|
|
|
|
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(<AckDrawer ackId="42" onClose={() => {}} />);
|
|
|
|
// 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(<AckDrawer ackId="42" onClose={() => {}} />);
|
|
|
|
// The panel must pass (kind="999", ackId=42) to useAckClaims so
|
|
// the hook builds the right query key.
|
|
expect(useAckClaims).toHaveBeenCalledWith("999", 42);
|
|
});
|
|
});
|