feat(sp28): Inbox ack orphans lane with Dismiss + Link to dropdown
This commit is contained in:
@@ -563,4 +563,210 @@ describe("Inbox page", () => {
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user