diff --git a/src/pages/Upload.test.tsx b/src/pages/Upload.test.tsx new file mode 100644 index 0000000..bf2ba62 --- /dev/null +++ b/src/pages/Upload.test.tsx @@ -0,0 +1,270 @@ +// @vitest-environment happy-dom +// Tell React this is an `act`-aware test environment so react-query's +// internal state updates flush through without noisy console warnings. +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; + +import React, { act, useEffect } from "react"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { MemoryRouter, useLocation } from "react-router-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { ClaimCard837, ClaimCard835 } from "./Upload"; +import { useAppStore } from "@/store"; +import type { ClaimOutput, ClaimPayment, ParsedBatch } from "@/types"; + +// Fixtures — kept tiny, just enough to exercise the drill logic. +const CLAIM_837: ClaimOutput = { + claim_id: "CLM-PERSISTED", + subscriber: { + first_name: "Jane", + last_name: "Doe", + member_id: "MEM-1", + }, + payer: { name: "Test Payer", id: "P1" }, + billing_provider: { npi: "1234567890" }, + claim: { + total_charge: 100, + place_of_service: "11", + frequency_code: "1", + prior_auth: null, + }, + service_lines: [ + { + line_number: 1, + procedure: { qualifier: "HC", code: "99213", modifiers: [] }, + charge: "100", + units: "1", + unit_type: "UN", + service_date: "2026-06-01", + }, + ], + diagnoses: [{ qualifier: "ABK", code: "J20.9" }], + validation: { passed: true, errors: [], warnings: [] }, +}; + +const CLAIM_835: ClaimPayment = { + payer_claim_control_number: "PCN-PERSISTED", + status_code: "1", + status_label: "Processed as Primary", + claim_filing_indicator: "CI", + facility_type: "11", + frequency_code: "1", + total_charge: "100", + total_paid: "80", + patient_responsibility: "20", + service_payments: [ + { + line_number: 1, + procedure_qualifier: "HC", + procedure_code: "99213", + modifiers: [], + service_date: "2026-06-01", + units: "1", + unit_type: "UN", + charge: "100", + payment: "80", + adjustments: [], + }, + ], + original_claim_id: null, +}; + +// Mount the card inside a MemoryRouter so the useNavigate call has +// a router context (without this, clicking the drill link would +// throw "useNavigate may be used only in a Router"). +function renderCard( + element: React.ReactElement, + initialEntries: string[] = ["/upload"], +): { + container: HTMLDivElement; + unmount: () => void; + tracker: { pathname: string; search: string }; +} { + const container = document.createElement("div"); + document.body.appendChild(container); + const tracker = { pathname: "/upload", search: "" }; + const Tracker = () => { + const loc = useLocation(); + useEffect(() => { + tracker.pathname = loc.pathname; + tracker.search = loc.search; + }, [loc.pathname, loc.search]); + return null; + }; + const root: Root = createRoot(container); + act(() => { + root.render( + React.createElement( + MemoryRouter, + { initialEntries }, + React.createElement(Tracker, null), + element, + ), + ); + }); + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + tracker, + }; +} + +describe("ClaimCard837 / ClaimCard835 drill link (SP21 Phase 5 Task 5.7)", () => { + beforeEach(() => { + // Reset parsedBatches to empty by default — individual tests + // populate it as needed. + useAppStore.setState({ parsedBatches: [] }); + }); + + afterEach(() => { + useAppStore.setState({ parsedBatches: [] }); + }); + + it("ClaimCard837: renders the drill link when the claim_id is in a persisted batch", async () => { + // Pre-populate the store with a parsed batch that contains this + // claim id, then expand the card and verify the link is present. + const persisted: ParsedBatch = { + id: "batch-1", + kind: "837p", + inputFilename: "test.837", + parsedAt: "2026-06-21T12:00:00Z", + claimCount: 1, + passed: 1, + failed: 0, + claimIds: ["CLM-PERSISTED"], + summary: { total_claims: 1, passed: 1, failed: 0 }, + }; + useAppStore.setState({ parsedBatches: [persisted] }); + + const { container, unmount } = renderCard( + React.createElement(ClaimCard837, { claim: CLAIM_837 }), + ); + + // Expand the card. + const header = container.querySelector("button[aria-expanded]"); + await act(async () => { + (header as HTMLButtonElement).click(); + await Promise.resolve(); + }); + + // The drill link should now be visible. + const link = container.querySelector('[data-testid="upload-claim-drill"]'); + expect(link).not.toBeNull(); + expect(link?.textContent).toContain("See claim in detail"); + unmount(); + }); + + it("ClaimCard837: does NOT render the drill link when the claim_id is not persisted", async () => { + // Streaming-only claim — the parsedBatches slice is empty, so + // the link must be absent (clicking would 404 ClaimDrawer). + const { container, unmount } = renderCard( + React.createElement(ClaimCard837, { claim: CLAIM_837 }), + ); + + const header = container.querySelector("button[aria-expanded]"); + await act(async () => { + (header as HTMLButtonElement).click(); + await Promise.resolve(); + }); + + expect( + container.querySelector('[data-testid="upload-claim-drill"]'), + ).toBeNull(); + unmount(); + }); + + it("ClaimCard837: clicking the drill link navigates to /claims?claim=ID", async () => { + const persisted: ParsedBatch = { + id: "batch-1", + kind: "837p", + inputFilename: "test.837", + parsedAt: "2026-06-21T12:00:00Z", + claimCount: 1, + passed: 1, + failed: 0, + claimIds: ["CLM-PERSISTED"], + summary: { total_claims: 1, passed: 1, failed: 0 }, + }; + useAppStore.setState({ parsedBatches: [persisted] }); + + const { container, unmount, tracker } = renderCard( + React.createElement(ClaimCard837, { claim: CLAIM_837 }), + ); + + const header = container.querySelector("button[aria-expanded]"); + await act(async () => { + (header as HTMLButtonElement).click(); + await Promise.resolve(); + }); + + const link = container.querySelector( + '[data-testid="upload-claim-drill"]', + ) as HTMLButtonElement; + await act(async () => { + link.click(); + await Promise.resolve(); + }); + + expect(tracker.pathname).toBe("/claims"); + expect(tracker.search).toBe("?claim=CLM-PERSISTED"); + unmount(); + }); + + it("ClaimCard835: renders the drill link when the PCN is persisted", async () => { + const persisted: ParsedBatch = { + id: "batch-1", + kind: "835", + inputFilename: "test.835", + parsedAt: "2026-06-21T12:00:00Z", + claimCount: 1, + passed: 1, + failed: 0, + claimIds: ["PCN-PERSISTED"], + summary: { total_claims: 1, passed: 1, failed: 0 }, + }; + useAppStore.setState({ parsedBatches: [persisted] }); + + const { container, unmount, tracker } = renderCard( + React.createElement(ClaimCard835, { claim: CLAIM_835 }), + ); + + const header = container.querySelector("button[aria-expanded]"); + await act(async () => { + (header as HTMLButtonElement).click(); + await Promise.resolve(); + }); + + const link = container.querySelector('[data-testid="upload-remit-drill"]'); + expect(link).not.toBeNull(); + await act(async () => { + (link as HTMLButtonElement).click(); + await Promise.resolve(); + }); + + // 835 cards drill to /remittances?remit=PCN (not /claims), since + // the RemitDrawer is the right surface for the payment side. + expect(tracker.pathname).toBe("/remittances"); + expect(tracker.search).toBe("?remit=PCN-PERSISTED"); + unmount(); + }); + + it("ClaimCard835: does NOT render the drill link when the PCN is not persisted", async () => { + const { container, unmount } = renderCard( + React.createElement(ClaimCard835, { claim: CLAIM_835 }), + ); + + const header = container.querySelector("button[aria-expanded]"); + await act(async () => { + (header as HTMLButtonElement).click(); + await Promise.resolve(); + }); + + expect( + container.querySelector('[data-testid="upload-remit-drill"]'), + ).toBeNull(); + unmount(); + }); +}); \ No newline at end of file diff --git a/src/pages/Upload.tsx b/src/pages/Upload.tsx index 99e631d..8a4e5cd 100644 --- a/src/pages/Upload.tsx +++ b/src/pages/Upload.tsx @@ -1,4 +1,5 @@ import { useMemo, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; import { AlertTriangle, ArrowRight, @@ -138,10 +139,22 @@ function StatPill({ ); } -function ClaimCard837({ claim }: { claim: ClaimOutput }) { +export function ClaimCard837({ claim }: { claim: ClaimOutput }) { const [open, setOpen] = useState(false); const passed = claim.validation.passed; const hasWarnings = claim.validation.warnings.length > 0; + // SP21 Phase 5 Task 5.7: a "See claim in detail →" link drills to + // /claims?claim=ID — but ONLY when this streamed claim_id has + // actually been persisted to a parsed batch. The Upload page + // streams claims as the parser emits them; until the user clicks + // "Save batch" the claim isn't visible to ClaimDrawer. + const parsedBatches = useAppStore((s) => s.parsedBatches); + const persistedClaimIds = useMemo( + () => new Set(parsedBatches.flatMap((b) => b.claimIds)), + [parsedBatches], + ); + const canDrill = persistedClaimIds.has(claim.claim_id); + const navigate = useNavigate(); return (
) : null} + + {/* SP21 Phase 5 Task 5.7: drill to the persisted claim. Only + renders when this streamed claim_id has been saved into + a parsed batch (so ClaimDrawer can find it). Before + "Save batch" the claim is streaming-only and the link + would 404. */} + {canDrill ? ( +
+ +
+ ) : null} ) : null} @@ -385,9 +423,19 @@ function ServiceLine837Row({ line }: { line: ServiceLine }) { ); } -function ClaimCard835({ claim }: { claim: ClaimPayment }) { +export function ClaimCard835({ claim }: { claim: ClaimPayment }) { const [open, setOpen] = useState(false); const passed = claim.service_payments.length > 0; + // SP21 Phase 5 Task 5.7: same drill gate as ClaimCard837. Only show + // "See claim in detail" once the claim_id is in the persisted + // batches (otherwise ClaimDrawer would 404). + const parsedBatches = useAppStore((s) => s.parsedBatches); + const persistedClaimIds = useMemo( + () => new Set(parsedBatches.flatMap((b) => b.claimIds)), + [parsedBatches], + ); + const canDrill = persistedClaimIds.has(claim.payer_claim_control_number); + const navigate = useNavigate(); return (
) : null} + + {/* SP21 Phase 5 Task 5.7: drill to the persisted remit. The + 835 cards have no separate "claim" (they're the payment + side), so the link goes to /remittances?remit=PCN which + opens the RemitDrawer for this payment. Same persisted- + gate: only show when the PCN is in a saved batch. */} + {canDrill ? ( +
+ +
+ ) : null} ) : null}