ffacfd8665
- backend: _ack_summary_for_claims helper attaches claim_acks payload to inbox rejected-lane rows (3 tests) - frontend: InboxRow renders newest 3 AK2 chips + '+N more' overflow under the rejection reason, with '999 not linked' marker when an ack couldn't be linked to a claim - frontend: per-row Resubmit button downloads a single corrected 837 via api.serializeClaim837 (no bulk modal, no zip — one click, one .x12 file) - frontend: 2 new tests for the chip rendering and the per-row download flow
882 lines
30 KiB
TypeScript
882 lines
30 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react";
|
|
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";
|
|
import * as apiModule from "@/lib/api";
|
|
import * as downloadModule from "@/lib/download";
|
|
|
|
// The Inbox's write-affordance BulkBars (resubmit / acknowledge /
|
|
// dismiss) are wrapped in RoleGate, which reads the auth context to
|
|
// decide whether the current user has write permission. We don't want
|
|
// the tests to spin up the full AuthProvider + /api/auth/me probe —
|
|
// instead we stub useAuth to return an admin user so RoleGate renders
|
|
// the BulkBars synchronously on first render.
|
|
vi.mock("@/auth/useAuth", () => ({
|
|
useAuth: () => ({
|
|
status: "authenticated" as const,
|
|
user: {
|
|
id: "test-admin",
|
|
username: "test-admin",
|
|
role: "admin" as const,
|
|
created_at: "2026-01-01T00:00:00Z",
|
|
},
|
|
login: vi.fn(),
|
|
logout: vi.fn(),
|
|
refresh: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
// SP21 Phase 4 Task 4.4: Inbox now uses `useNavigate` (for unmatched
|
|
// claim drilldown to /claims?claim=ID), so the render harness needs
|
|
// 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 } },
|
|
});
|
|
// 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(() => {
|
|
cleanup();
|
|
vi.unstubAllGlobals();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("Inbox page", () => {
|
|
it("renders the five lane headers", async () => {
|
|
// SP14: the Payer-Rejected lane was added (277CA STC A4/A6/A7).
|
|
// The Inbox now renders 5 lanes: REJECTED, PAYER REJECTED,
|
|
// CANDIDATES, UNMATCHED, DONE.
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({
|
|
rejected: [],
|
|
payer_rejected: [],
|
|
candidates: [],
|
|
unmatched: [],
|
|
done_today: [],
|
|
}),
|
|
}),
|
|
);
|
|
const { container } = renderInbox();
|
|
await waitFor(() => {
|
|
expect(container.textContent).toContain("REJECTED");
|
|
expect(container.textContent).toContain("PAYER REJECTED");
|
|
expect(container.textContent).toContain("CANDIDATES");
|
|
expect(container.textContent).toContain("UNMATCHED");
|
|
expect(container.textContent).toContain("DONE");
|
|
});
|
|
});
|
|
|
|
it("SP14: payer-rejected row count rolls up into the need-eyes header", async () => {
|
|
// The "need eyes" total in the header is the operator's
|
|
// anchor for the day. Payer-rejected claims are actionable
|
|
// (operator must acknowledge or follow up), so they count
|
|
// toward the need-eyes total alongside 999 rejected, candidates,
|
|
// and unmatched.
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({
|
|
rejected: [],
|
|
payer_rejected: [
|
|
{ id: "PR1", kind: "claim", patient_control_number: "PR1",
|
|
charge_amount: 250, payer_id: "CO_TXIX", provider_npi: "1881068062",
|
|
state: "submitted", rejection_reason: "A7 invalid dx",
|
|
service_date_from: null, payer_rejected_status_code: "A7",
|
|
payer_rejected_reason: "invalid diagnosis",
|
|
payer_rejected_at: "2026-06-20T10:00:00Z" },
|
|
],
|
|
candidates: [],
|
|
unmatched: [],
|
|
done_today: [],
|
|
}),
|
|
}),
|
|
);
|
|
const { container } = renderInbox();
|
|
await waitFor(() => {
|
|
expect(container.textContent).toContain("1");
|
|
expect(container.textContent).toContain("items need eyes");
|
|
});
|
|
});
|
|
|
|
it("SP14: acknowledging selected payer-rejected claims calls the API and refetches", async () => {
|
|
// The user selects a payer-rejected claim, hits Acknowledge, and
|
|
// the inbox calls /api/inbox/payer-rejected/acknowledge with the
|
|
// claim id, then refetches the lane payload.
|
|
const fetchMock = vi.fn().mockImplementation(async (url: string) => {
|
|
if (url.includes("/api/inbox/lanes")) {
|
|
return {
|
|
ok: true,
|
|
json: async () => ({
|
|
rejected: [],
|
|
payer_rejected: [
|
|
{ id: "PR1", kind: "claim", patient_control_number: "PR1",
|
|
charge_amount: 250, payer_id: "CO_TXIX", provider_npi: "1881068062",
|
|
state: "submitted", rejection_reason: "A7 invalid dx",
|
|
service_date_from: null, payer_rejected_status_code: "A7",
|
|
payer_rejected_reason: "invalid diagnosis",
|
|
payer_rejected_at: "2026-06-20T10:00:00Z" },
|
|
],
|
|
candidates: [],
|
|
unmatched: [],
|
|
done_today: [],
|
|
}),
|
|
};
|
|
}
|
|
if (url.includes("/api/inbox/payer-rejected/acknowledge")) {
|
|
return {
|
|
ok: true,
|
|
json: async () => ({
|
|
ok: true,
|
|
transitioned: 1,
|
|
already_acked: 0,
|
|
not_found: 0,
|
|
not_rejected: 0,
|
|
}),
|
|
};
|
|
}
|
|
return { ok: true, json: async () => ({}) };
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const { container, getByTestId } = renderInbox();
|
|
await waitFor(() => {
|
|
expect(container.textContent).toContain("PR1");
|
|
});
|
|
|
|
// Select the PR1 claim via its checkbox.
|
|
const checkbox = container.querySelector(
|
|
'input[type="checkbox"][aria-label="Select PR1"]',
|
|
) as HTMLInputElement;
|
|
expect(checkbox).toBeTruthy();
|
|
await act(async () => {
|
|
checkbox.click();
|
|
});
|
|
|
|
// Hit Acknowledge. The bulk action bar button is wired to the
|
|
// /api/inbox/payer-rejected/acknowledge endpoint.
|
|
const ackButton = getByTestId("payer-rejected-acknowledge");
|
|
await act(async () => {
|
|
ackButton.click();
|
|
});
|
|
|
|
// The mock was called with the acknowledge URL.
|
|
const ackCall = fetchMock.mock.calls.find(
|
|
(c) => typeof c[0] === "string" && c[0].includes("/api/inbox/payer-rejected/acknowledge"),
|
|
);
|
|
expect(ackCall).toBeTruthy();
|
|
expect(ackCall).toBeDefined();
|
|
const ackBody = JSON.parse(ackCall![1].body);
|
|
expect(ackBody.claim_ids).toEqual(["PR1"]);
|
|
});
|
|
|
|
it("SP8: multi-select resubmit opens bundle modal; Resubmit + Download triggers api + downloadBlob", async () => {
|
|
// Two rejected claims → user selects both → Re-submit → modal appears
|
|
// → user clicks Resubmit + Download → backend is hit with
|
|
// ?download=true and the returned blob is handed to downloadBlob.
|
|
const fetchMock = vi.fn().mockImplementation(async (url: string) => {
|
|
if (url.includes("/api/inbox/lanes")) {
|
|
return {
|
|
ok: true,
|
|
json: async () => ({
|
|
rejected: [
|
|
{ id: "C1", kind: "claim", payer_claim_control_number: "C1",
|
|
charge_amount: 100, payer_id: "P1", provider_npi: "N1",
|
|
state: "rejected", rejection_reason: "old", service_date: null,
|
|
score: null },
|
|
{ id: "C2", kind: "claim", payer_claim_control_number: "C2",
|
|
charge_amount: 200, payer_id: "P1", provider_npi: "N1",
|
|
state: "rejected", rejection_reason: "old", service_date: null,
|
|
score: null },
|
|
],
|
|
payer_rejected: [],
|
|
candidates: [],
|
|
unmatched: [],
|
|
done_today: [],
|
|
}),
|
|
};
|
|
}
|
|
if (url.includes("/api/inbox/rejected/resubmit")) {
|
|
return {
|
|
ok: true,
|
|
blob: async () => new Blob(["PK\u0003\u0004fake-zip-bytes"], { type: "application/zip" }),
|
|
headers: {
|
|
get: (name: string) =>
|
|
name.toLowerCase() === "content-disposition"
|
|
? 'attachment; filename="resubmit-2-claims.zip"'
|
|
: name.toLowerCase() === "x-cyclone-serialize-errors"
|
|
? null
|
|
: null,
|
|
},
|
|
};
|
|
}
|
|
return { ok: true, json: async () => ({}) };
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
// Spy on the two side-effects we care about: the api call + the
|
|
// browser download. Both are module-scope so we install spies and
|
|
// reset between tests (afterEach already calls vi.restoreAllMocks
|
|
// but we mockReset to clear call history from other tests).
|
|
const resubmitDownloadSpy = vi
|
|
.spyOn(inboxApi, "resubmitRejectedWithDownload")
|
|
.mockResolvedValue({
|
|
blob: new Blob(["PK\u0003\u0004fake-zip-bytes"], { type: "application/zip" }),
|
|
filename: "resubmit-2-claims.zip",
|
|
serializeErrors: [],
|
|
});
|
|
const downloadSpy = vi
|
|
.spyOn(downloadModule, "downloadBlob")
|
|
.mockImplementation(() => {});
|
|
|
|
const { container, getByText } = renderInbox();
|
|
await waitFor(() => {
|
|
expect(container.textContent).toContain("C1");
|
|
expect(container.textContent).toContain("C2");
|
|
});
|
|
|
|
// Select both rejected rows (the checkbox is the only <input> per row).
|
|
const checkboxes = container.querySelectorAll('input[type="checkbox"]');
|
|
expect(checkboxes.length).toBeGreaterThanOrEqual(2);
|
|
await act(async () => {
|
|
fireEvent.click(checkboxes[0]);
|
|
fireEvent.click(checkboxes[1]);
|
|
});
|
|
|
|
// BulkBar shows a Re-submit button for the rejected lane.
|
|
const resubmitBtn = getByText(/Re-submit \(2\)/);
|
|
await act(async () => {
|
|
fireEvent.click(resubmitBtn);
|
|
});
|
|
|
|
// Modal appears with both options.
|
|
const modal = container.querySelector(
|
|
'[data-testid="resubmit-bundle-modal"]',
|
|
);
|
|
expect(modal).not.toBeNull();
|
|
|
|
const downloadBtn = container.querySelector(
|
|
'[data-testid="resubmit-modal-download"]',
|
|
) as HTMLButtonElement | null;
|
|
expect(downloadBtn).not.toBeNull();
|
|
await act(async () => {
|
|
fireEvent.click(downloadBtn!);
|
|
});
|
|
|
|
// The api was called with the selected ids, and the blob was
|
|
// handed to downloadBlob with the right filename.
|
|
expect(resubmitDownloadSpy).toHaveBeenCalledTimes(1);
|
|
expect(resubmitDownloadSpy).toHaveBeenCalledWith(["C1", "C2"]);
|
|
expect(downloadSpy).toHaveBeenCalledTimes(1);
|
|
const [filename, blob] = downloadSpy.mock.calls[0];
|
|
expect(filename).toBe("resubmit-2-claims.zip");
|
|
expect(blob).toBeInstanceOf(Blob);
|
|
});
|
|
|
|
it("SP21 Task 4.4: clicking a candidate row opens the RemitDrawer", async () => {
|
|
// Candidates are remits (payer_claim_control_number-keyed) — the
|
|
// row's `id` is the remit id, so clicking drills into the
|
|
// RemitDrawer via `?remit=ID`.
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({
|
|
rejected: [],
|
|
payer_rejected: [],
|
|
candidates: [
|
|
{
|
|
id: "REM-7",
|
|
kind: "remit",
|
|
payer_claim_control_number: "REM-7",
|
|
charge_amount: 200,
|
|
payer_id: "P1",
|
|
rendering_provider_npi: "1234567890",
|
|
service_date: "2026-06-19",
|
|
candidates: [],
|
|
},
|
|
],
|
|
unmatched: [],
|
|
done_today: [],
|
|
}),
|
|
}),
|
|
);
|
|
|
|
const { container } = renderInbox();
|
|
await waitFor(() => {
|
|
expect(container.textContent).toContain("REM-7");
|
|
});
|
|
|
|
// No drawer yet — the URL is `/inbox` with no `?remit=`.
|
|
expect(
|
|
container.querySelector('[data-testid="remit-drawer"]')
|
|
).toBeNull();
|
|
|
|
// Click the candidate row. The InboxRow renders as a <tr> with
|
|
// the remit id in its first cell; clicking that row bubbles to
|
|
// the Lane's onRowClick handler we wired in Task 4.4.
|
|
const cell = Array.from(container.querySelectorAll("td")).find(
|
|
(td) => td.textContent === "REM-7",
|
|
);
|
|
const row = cell?.closest("tr");
|
|
expect(row).toBeTruthy();
|
|
await act(async () => {
|
|
fireEvent.click(row as HTMLElement);
|
|
});
|
|
|
|
// Drawer portals into document.body — check there, not container.
|
|
await waitFor(() => {
|
|
expect(
|
|
document.body.querySelector('[data-testid="remit-drawer"]'),
|
|
).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
it("SP21 Task 4.4: deep-link ?remit=ID opens the drawer on mount", async () => {
|
|
// Pre-set the URL so the hook reads REM-7 off `window.location.search`
|
|
// during its `useState` initializer — no click needed. The inbox
|
|
// already mounts the drawer, so a deep link to /inbox?remit=REM-7
|
|
// should land with the drawer open.
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({
|
|
rejected: [],
|
|
payer_rejected: [],
|
|
candidates: [],
|
|
unmatched: [],
|
|
done_today: [],
|
|
}),
|
|
}),
|
|
);
|
|
(window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(
|
|
"http://localhost/inbox?remit=REM-7",
|
|
);
|
|
|
|
renderInbox();
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
document.body.querySelector('[data-testid="remit-drawer"]'),
|
|
).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");
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// SP28: the new "ACK ORPHANS" lane (D7). Mirrors the payer-rejected
|
|
// lane shape; per-row "Dismiss" + "Link to…" actions; empty state
|
|
// when no orphans; live-tail integration drops a row that just got
|
|
// linked via the manual-match endpoint.
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* URL-aware fetch mock for the inbox page. Routes `/api/inbox/lanes`
|
|
* to the supplied `lanes`, and `/api/inbox/ack-orphans?kind=<k>` to
|
|
* the matching subset of `orphans`. The hook fires three parallel
|
|
* requests (one per kind: 999 / 277ca / ta1), so the mock must
|
|
* filter by `kind` to avoid returning the same payload three times.
|
|
* Default orphan response is empty so tests that don't care about
|
|
* the new lane just get a clean "No orphan acks." placeholder.
|
|
*/
|
|
function makeInboxFetch(
|
|
lanes: Record<string, unknown>,
|
|
orphans: Array<{ kind: string } & Record<string, unknown>> = [],
|
|
) {
|
|
return vi.fn().mockImplementation(async (url: string) => {
|
|
if (url.includes("/api/inbox/ack-orphans")) {
|
|
// Pull the `kind` query param out of the URL so the mock
|
|
// returns only the orphans for the requested kind.
|
|
const m = /[?&]kind=([^&]+)/.exec(url);
|
|
const requestedKind = m?.[1] ?? "";
|
|
const filtered = orphans.filter((o) => o.kind === requestedKind);
|
|
return {
|
|
ok: true,
|
|
json: async () => ({ items: filtered, total: filtered.length }),
|
|
};
|
|
}
|
|
return { ok: true, json: async () => lanes };
|
|
});
|
|
}
|
|
|
|
it("test_ack_orphans_lane_renders_empty_state_when_no_orphans", async () => {
|
|
vi.stubGlobal("fetch", makeInboxFetch({
|
|
rejected: [], payer_rejected: [], candidates: [], unmatched: [], done_today: [],
|
|
}));
|
|
const view = renderInbox();
|
|
await waitFor(() => {
|
|
expect(view.container.textContent).toContain("ACK ORPHANS");
|
|
});
|
|
// Empty-state copy must surface — the lane's friendly "nothing
|
|
// here" treatment rather than a quiet section.
|
|
expect(view.container.textContent).toContain("No orphan acks.");
|
|
// No rows rendered.
|
|
expect(
|
|
view.container.querySelectorAll('[data-testid="ack-orphan-row"]').length
|
|
).toBe(0);
|
|
});
|
|
|
|
it("test_ack_orphans_lane_renders_one_row_per_orphan", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
makeInboxFetch(
|
|
{
|
|
rejected: [],
|
|
payer_rejected: [],
|
|
candidates: [],
|
|
unmatched: [],
|
|
done_today: [],
|
|
},
|
|
[
|
|
{
|
|
id: 100,
|
|
kind: "999",
|
|
pcn: "991102989",
|
|
sourceBatchId: "b-orphan-1",
|
|
parsedAt: "2026-07-01T12:00:00Z",
|
|
candidates: [{ claimId: "CLM-1", score: 0.92, tier: "strong" }],
|
|
},
|
|
{
|
|
id: 200,
|
|
kind: "ta1",
|
|
pcn: "000000001",
|
|
sourceBatchId: "b-orphan-2",
|
|
parsedAt: "2026-07-01T11:00:00Z",
|
|
candidates: [],
|
|
},
|
|
],
|
|
),
|
|
);
|
|
const view = renderInbox();
|
|
await waitFor(() => {
|
|
// Wait until both rows are in the DOM.
|
|
expect(
|
|
view.container.querySelectorAll('[data-testid="ack-orphan-row"]')
|
|
.length,
|
|
).toBe(2);
|
|
});
|
|
// PCN is rendered verbatim — the operator's primary identifier.
|
|
expect(view.container.textContent).toContain("991102989");
|
|
expect(view.container.textContent).toContain("000000001");
|
|
// The kind badge is visible per row.
|
|
const badges = view.container.querySelectorAll(
|
|
'[data-testid="ack-orphan-kind-badge"]',
|
|
);
|
|
expect(badges.length).toBe(2);
|
|
// The header count is 2 (matches the row count).
|
|
expect(
|
|
view.container.querySelector('[data-testid="ack-orphans-count"]')
|
|
?.textContent,
|
|
).toBe("2");
|
|
});
|
|
|
|
it("test_ack_orphans_lane_dismiss_button_drops_row_optimistically", async () => {
|
|
const orphans = [
|
|
{
|
|
id: 300,
|
|
kind: "277ca",
|
|
pcn: "X-1",
|
|
sourceBatchId: "b-orphan-3",
|
|
parsedAt: "2026-07-01T12:00:00Z",
|
|
candidates: [],
|
|
},
|
|
];
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
makeInboxFetch(
|
|
{
|
|
rejected: [],
|
|
payer_rejected: [],
|
|
candidates: [],
|
|
unmatched: [],
|
|
done_today: [],
|
|
},
|
|
orphans,
|
|
),
|
|
);
|
|
const view = renderInbox();
|
|
await waitFor(() => {
|
|
expect(
|
|
view.container.querySelector('[data-testid="ack-orphan-row"]'),
|
|
).not.toBeNull();
|
|
});
|
|
// Click the dismiss button — the row drops optimistically.
|
|
const dismissBtn = view.container.querySelector(
|
|
'[data-testid="ack-orphan-dismiss"]',
|
|
) as HTMLButtonElement | null;
|
|
expect(dismissBtn).not.toBeNull();
|
|
await act(async () => {
|
|
dismissBtn!.click();
|
|
});
|
|
// Empty-state copy surfaces now.
|
|
await waitFor(() => {
|
|
expect(view.container.textContent).toContain("No orphan acks.");
|
|
});
|
|
});
|
|
|
|
it("test_ack_orphans_lane_link_to_dropdown_lists_candidates", async () => {
|
|
const orphans = [
|
|
{
|
|
id: 400,
|
|
kind: "999",
|
|
pcn: "Y-1",
|
|
sourceBatchId: "b-orphan-4",
|
|
parsedAt: "2026-07-01T12:00:00Z",
|
|
candidates: [
|
|
{ claimId: "CLM-A", score: 0.95, tier: "strong" },
|
|
{ claimId: "CLM-B", score: 0.62, tier: "weak" },
|
|
],
|
|
},
|
|
];
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
makeInboxFetch(
|
|
{
|
|
rejected: [],
|
|
payer_rejected: [],
|
|
candidates: [],
|
|
unmatched: [],
|
|
done_today: [],
|
|
},
|
|
orphans,
|
|
),
|
|
);
|
|
const view = renderInbox();
|
|
await waitFor(() => {
|
|
expect(
|
|
view.container.querySelector('[data-testid="ack-orphan-row"]'),
|
|
).not.toBeNull();
|
|
});
|
|
// Open the candidate list.
|
|
const toggle = view.container.querySelector(
|
|
'[data-testid="ack-orphan-link-toggle"]',
|
|
) as HTMLButtonElement | null;
|
|
expect(toggle).not.toBeNull();
|
|
await act(async () => {
|
|
toggle!.click();
|
|
});
|
|
await waitFor(() => {
|
|
expect(
|
|
view.container.querySelector('[data-testid="ack-orphan-candidate-list"]'),
|
|
).not.toBeNull();
|
|
});
|
|
// Two candidate options render — one per backend-supplied match.
|
|
const options = view.container.querySelectorAll(
|
|
'[data-testid="ack-orphan-candidate-option"]',
|
|
);
|
|
expect(options.length).toBe(2);
|
|
expect(options[0]?.getAttribute("data-claim-id")).toBe("CLM-A");
|
|
expect(options[1]?.getAttribute("data-claim-id")).toBe("CLM-B");
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// SP29: per-row Resubmit button on rejected-lane claim rows downloads
|
|
// the single-claim corrected 837. The bulk resubmit path (multi-select)
|
|
// is exercised above in the "SP8 multi-select resubmit" test; this
|
|
// covers the dedicated single-claim gesture and the operator's
|
|
// most-click flow ("I see one reject in the lane, I click Resubmit
|
|
// next to it").
|
|
// -------------------------------------------------------------------------
|
|
it("SP29: per-row Resubmit button downloads the single-claim 837", async () => {
|
|
// Lane returns one rejected claim. The page is responsible for
|
|
// calling api.serializeClaim837(id) and handing the returned
|
|
// text to downloadTextFile as <id>.x12.
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockImplementation(async (url: string) => {
|
|
if (url.includes("/api/inbox/lanes")) {
|
|
return {
|
|
ok: true,
|
|
json: async () => ({
|
|
rejected: [
|
|
{
|
|
id: "REJ1",
|
|
kind: "claim",
|
|
patient_control_number: "REJ1",
|
|
charge_amount: 175,
|
|
payer_id: "P1",
|
|
provider_npi: "1234567890",
|
|
state: "rejected",
|
|
rejection_reason: "999 AK9 R",
|
|
rejected_at: "2026-07-02T09:00:00Z",
|
|
service_date_from: null,
|
|
claim_acks: {
|
|
total: 2,
|
|
rejected: 1,
|
|
items: [
|
|
{
|
|
ack_id: 50,
|
|
set_control_number: "991102989",
|
|
set_accept_reject_code: "R",
|
|
ak2_index: 0,
|
|
linked_at: "2026-07-02T09:00:00Z",
|
|
},
|
|
{
|
|
ack_id: 51,
|
|
set_control_number: "991102989",
|
|
set_accept_reject_code: "A",
|
|
ak2_index: 1,
|
|
linked_at: "2026-07-02T08:59:00Z",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
payer_rejected: [],
|
|
candidates: [],
|
|
unmatched: [],
|
|
done_today: [],
|
|
}),
|
|
};
|
|
}
|
|
return { ok: true, json: async () => ({}) };
|
|
}),
|
|
);
|
|
|
|
// Spy on the api call AND the download sink. The page calls
|
|
// `api.serializeClaim837(id)` (namespace import from @/lib/api),
|
|
// and downloadTextFile is on the download module.
|
|
const serializeSpy = vi
|
|
.spyOn(apiModule.api, "serializeClaim837")
|
|
.mockResolvedValue({
|
|
text: "ISA*00*~...",
|
|
filename: "claim-REJ1.x12",
|
|
});
|
|
const downloadSpy = vi
|
|
.spyOn(downloadModule, "downloadTextFile")
|
|
.mockImplementation(() => {});
|
|
|
|
const view = renderInbox();
|
|
await waitFor(() => {
|
|
expect(view.container.textContent).toContain("REJ1");
|
|
});
|
|
|
|
// Click the per-row Resubmit button — its testid includes the
|
|
// claim id so the operator can target one row at a time.
|
|
const btn = view.container.querySelector(
|
|
'[data-testid="resubmit-REJ1"]',
|
|
) as HTMLButtonElement | null;
|
|
expect(btn).not.toBeNull();
|
|
expect(btn!.textContent).toBe("Resubmit");
|
|
|
|
await act(async () => {
|
|
fireEvent.click(btn!);
|
|
});
|
|
|
|
// The api was called with the row's claim id, and the resulting
|
|
// .x12 was handed to downloadTextFile with text/x12 mime so the
|
|
// browser saves it as a download.
|
|
await waitFor(() => {
|
|
expect(serializeSpy).toHaveBeenCalledTimes(1);
|
|
expect(serializeSpy).toHaveBeenCalledWith("REJ1");
|
|
});
|
|
expect(downloadSpy).toHaveBeenCalledTimes(1);
|
|
const [filename, mime, text] = downloadSpy.mock.calls[0];
|
|
expect(filename).toBe("claim-REJ1.x12");
|
|
expect(mime).toBe("text/x12");
|
|
expect(text).toBe("ISA*00*~...");
|
|
});
|
|
});
|