feat(inbox): rejected + payer_rejected + done_today rows drillable

This commit is contained in:
Tyler
2026-06-21 17:32:09 -06:00
parent 5053a1ea8e
commit 4a8ce1a524
2 changed files with 216 additions and 5 deletions
+189 -2
View File
@@ -1,7 +1,8 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { useEffect, useState } from "react";
import { MemoryRouter, useLocation, useNavigate } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import Inbox from "./Inbox";
import * as inboxApi from "@/lib/inbox-api";
@@ -12,17 +13,46 @@ import * as downloadModule from "@/lib/download";
// a Router context. We use a fresh QueryClient per test so the
// drawer's per-remit query doesn't leak cache between cases, and a
// MemoryRouter so `useNavigate` has a router to push into.
//
// Phase 5 Task 5.4: MemoryRouter doesn't update window.location, so
// the navigation assertion uses a small LocationTracker component
// mounted under the same router that records the current pathname +
// search after each render. Tests then read `tracker.last` instead
// of `window.location`.
function LocationTracker({ on }: { on: (pathname: string, search: string) => void }) {
const loc = useLocation();
useEffect(() => {
on(loc.pathname, loc.search);
}, [loc.pathname, loc.search, on]);
return null;
}
function renderInbox() {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, retryDelay: 0 } },
});
return render(
// Capture the last location seen so tests can assert on navigation
// without poking at window.location (MemoryRouter doesn't touch it).
const captured = { pathname: "/inbox", search: "" };
const view = render(
<MemoryRouter initialEntries={["/inbox"]}>
<QueryClientProvider client={qc}>
<LocationTracker
on={(pathname, search) => {
captured.pathname = pathname;
captured.search = search;
}}
/>
<Inbox />
</QueryClientProvider>
</MemoryRouter>,
);
// Attach the tracker so individual tests can read it. Note: the
// captured value updates asynchronously after navigation, but since
// LocationTracker runs in the same render pass as the navigate,
// tests should `waitFor` it.
(view as unknown as { tracker: typeof captured }).tracker = captured;
return view as ReturnType<typeof render> & { tracker: typeof captured };
}
afterEach(() => {
@@ -355,4 +385,161 @@ describe("Inbox page", () => {
).not.toBeNull();
});
});
it("SP21 Task 5.4: clicking a rejected claim row navigates to /claims?claim=ID", async () => {
// Task 5.4 wires the rejected lane's onRowClick to navigate to
// the ClaimDrawer via `?claim=ID` on the /claims route. The
// MemoryRouter (initial /inbox) lets us observe the URL change
// via the LocationTracker helper mounted in the render harness.
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
rejected: [
{
id: "REJ1",
kind: "claim",
payer_claim_control_number: "REJ1",
charge_amount: 175,
payer_id: "P1",
provider_npi: "1234567890",
state: "rejected",
rejection_reason: "999 reject",
service_date: null,
score: null,
},
],
payer_rejected: [],
candidates: [],
unmatched: [],
done_today: [],
}),
}),
);
const view = renderInbox();
await waitFor(() => {
expect(view.container.textContent).toContain("REJ1");
});
// Click the rejected row. The InboxRow renders the claim id as
// its primary text cell; clicking that row bubbles up to the
// Lane's onRowClick handler we wired in Task 5.4.
const cell = Array.from(view.container.querySelectorAll("td")).find(
(td) => td.textContent === "REJ1",
);
const row = cell?.closest("tr");
expect(row).toBeTruthy();
await act(async () => {
fireEvent.click(row as HTMLElement);
});
// MemoryRouter navigates to /claims?claim=REJ1 — verify the
// tracker observed the new pathname + search.
await waitFor(() => {
expect(view.tracker.pathname).toBe("/claims");
expect(view.tracker.search).toBe("?claim=REJ1");
});
});
it("SP21 Task 5.4: clicking a done_today claim row navigates to /claims?claim=ID", async () => {
// done_today rows are claim-shaped — same drill pattern as the
// rejected lane. Verifies that the wiring covers the trailing
// "shipped today" lane too, not just the error lanes.
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
rejected: [],
payer_rejected: [],
candidates: [],
unmatched: [],
done_today: [
{
id: "DONE1",
kind: "claim",
patient_control_number: "DONE1",
charge_amount: 88,
payer_id: "P1",
provider_npi: "1234567890",
state: "submitted",
service_date_from: "2026-06-21",
},
],
}),
}),
);
const view = renderInbox();
await waitFor(() => {
expect(view.container.textContent).toContain("DONE1");
});
const cell = Array.from(view.container.querySelectorAll("td")).find(
(td) => td.textContent === "DONE1",
);
const row = cell?.closest("tr");
expect(row).toBeTruthy();
await act(async () => {
fireEvent.click(row as HTMLElement);
});
await waitFor(() => {
expect(view.tracker.pathname).toBe("/claims");
expect(view.tracker.search).toBe("?claim=DONE1");
});
});
it("SP21 Task 5.4: clicking the row checkbox does not bubble to onRowClick", async () => {
// Per the plan's §self-review #5, the Lane's RowCheckbox already
// calls e.stopPropagation() — verify that's still the case by
// clicking the checkbox and confirming the URL didn't change to
// /claims. (If stopPropagation regressed, the row click handler
// would fire and navigate.)
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
rejected: [
{
id: "REJ1",
kind: "claim",
payer_claim_control_number: "REJ1",
charge_amount: 175,
payer_id: "P1",
provider_npi: "1234567890",
state: "rejected",
rejection_reason: "999 reject",
service_date: null,
score: null,
},
],
payer_rejected: [],
candidates: [],
unmatched: [],
done_today: [],
}),
}),
);
const view = renderInbox();
await waitFor(() => {
expect(view.container.textContent).toContain("REJ1");
});
const checkbox = view.container.querySelector(
'input[type="checkbox"][aria-label="Select REJ1"]',
) as HTMLInputElement;
expect(checkbox).toBeTruthy();
await act(async () => {
checkbox.click();
});
// URL should still be /inbox — clicking the checkbox selected
// the row but did not drill into the claim drawer.
expect(view.tracker.pathname).toBe("/inbox");
});
});
+27 -3
View File
@@ -295,7 +295,16 @@ export default function Inbox() {
name="REJECTED"
accent="oxblood"
rows={lanes.rejected}
onRowClick={() => {}}
// SP21 Phase 5 Task 5.4: rejected claims drill into the
// ClaimDrawer. All rows here are claims (kind === "claim"),
// so the branch is straightforward — the type union still
// requires the defensive check, but a row click on a claim
// here is always a claim drill.
onRowClick={(row) => {
if (row.kind === "claim") {
navigate(`/claims?claim=${encodeURIComponent(row.id)}`);
}
}}
onSelectionChange={(ids) => setLaneSelected("rejected", ids)}
/>
{/*
@@ -308,7 +317,13 @@ export default function Inbox() {
name="PAYER REJECTED"
accent="oxblood"
rows={lanes.payer_rejected}
onRowClick={() => {}}
// SP21 Phase 5 Task 5.4: payer-rejected claims also drill
// into the ClaimDrawer — same shape as the rejected lane.
onRowClick={(row) => {
if (row.kind === "claim") {
navigate(`/claims?claim=${encodeURIComponent(row.id)}`);
}
}}
onSelectionChange={(ids) => setLaneSelected("payer_rejected", ids)}
/>
<Lane
@@ -344,7 +359,16 @@ export default function Inbox() {
name="DONE"
accent="muted"
rows={lanes.done_today}
onRowClick={() => {}}
// SP21 Phase 5 Task 5.4: done_today rows are also claim
// drills — same as rejected / payer_rejected. The drawer
// surfaces the claim's current state + recent history,
// which is exactly what an operator wants when reviewing
// "what got shipped today".
onRowClick={(row) => {
if (row.kind === "claim") {
navigate(`/claims?claim=${encodeURIComponent(row.id)}`);
}
}}
onSelectionChange={(ids) => setLaneSelected("done_today", ids)}
/>
</main>