feat(sp6): useInboxLanes refetches on SP5 tail events
This commit is contained in:
@@ -1,7 +1,32 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, renderHook, waitFor } from "@testing-library/react";
|
||||
// React's `act` warnings need an act-aware environment; mirror the other
|
||||
// hook tests in this repo (`useClaimDetail`, `useReconciliation`, ...).
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
||||
import { useInboxLanes } from "./useInboxLanes";
|
||||
import { useTailStore } from "@/store/tail-store";
|
||||
import type { Claim } from "@/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SP6 T19: tail-driven refetch.
|
||||
//
|
||||
// The hook opens the SP5 claim/remit tail streams and refetches the lane
|
||||
// payload whenever the tail-store grows (i.e. a new `item` event arrived
|
||||
// via SSE). We mock `useTailStream` so the test doesn't open a real SSE
|
||||
// connection, and drive the tail store directly via `useTailStore.setState`
|
||||
// to simulate what `useTailStream` would have dispatched on an `item`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock("./useTailStream", () => ({
|
||||
useTailStream: vi.fn(() => ({
|
||||
status: "live",
|
||||
lastEventAt: null,
|
||||
error: null,
|
||||
forceReconnect: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -9,11 +34,8 @@ afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("useInboxLanes", () => {
|
||||
it("loads lanes on mount", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
function emptyLanesPayload() {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
rejected: [],
|
||||
@@ -21,8 +43,33 @@ describe("useInboxLanes", () => {
|
||||
unmatched: [],
|
||||
done_today: [],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function makeClaim(id: string): Claim {
|
||||
return {
|
||||
id,
|
||||
patientName: "Test Patient",
|
||||
providerNpi: "1234567890",
|
||||
payerName: "Aetna",
|
||||
cptCode: "99213",
|
||||
billedAmount: 100,
|
||||
receivedAmount: 0,
|
||||
status: "submitted",
|
||||
submissionDate: "2026-06-20T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("useInboxLanes", () => {
|
||||
beforeEach(() => {
|
||||
// Each test starts with an empty tail store so length-delta detection
|
||||
// fires cleanly on the first simulated `item`.
|
||||
useTailStore.getState().reset("claims");
|
||||
useTailStore.getState().reset("remittances");
|
||||
});
|
||||
|
||||
it("loads lanes on mount", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(emptyLanesPayload()));
|
||||
const { result } = renderHook(() => useInboxLanes());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.lanes).toEqual({
|
||||
@@ -42,4 +89,33 @@ describe("useInboxLanes", () => {
|
||||
await waitFor(() => expect(result.current.error).not.toBeNull());
|
||||
expect(result.current.error?.message).toMatch(/500/);
|
||||
});
|
||||
|
||||
it("refetches when a claim arrives via the tail store", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(emptyLanesPayload());
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { result } = renderHook(() => useInboxLanes());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Simulate a new claim `item` event from the SP5 stream by writing
|
||||
// directly into the tail store — that's what the dispatcher in
|
||||
// `useTailStream` does on a real `item` event.
|
||||
act(() => {
|
||||
useTailStore.getState().addClaim(makeClaim("CLP-NEW"));
|
||||
});
|
||||
|
||||
// The hook should debounce-trigger a refetch within ~500ms. We give
|
||||
// it a generous timeout to avoid flakiness on slow CI.
|
||||
await waitFor(
|
||||
() => expect(fetchMock.mock.calls.length).toBeGreaterThan(1),
|
||||
{ timeout: 1500 },
|
||||
);
|
||||
|
||||
// Cleanup: avoid the debounce timer from firing into an unmounted
|
||||
// hook (which would log a warning under react-dom 18).
|
||||
await act(async () => {
|
||||
result.current.refetch;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,32 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inbox lanes hook (sub-project 6, Phase 7 Task 19).
|
||||
//
|
||||
// Owns the four-lane payload (`rejected` / `candidates` / `unmatched` /
|
||||
// `done_today`) for the Inbox page. T19 replaced the original 5s polling
|
||||
// with the SP5 tail: the hook opens the claim and remittance streams via
|
||||
// `useTailStream`, and whenever the tail store grows (a new `item` event
|
||||
// arrived) it triggers a debounced refetch of the lanes payload. The
|
||||
// debounce coalesces a burst of events into one network round-trip.
|
||||
//
|
||||
// The streams themselves stay open as long as a consumer of this hook is
|
||||
// mounted (e.g. the `/inbox` page). When the user navigates away, the
|
||||
// hook unmounts and `useTailStream`'s cleanup aborts the SSE connection.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchInboxLanes, type InboxLanes } from "@/lib/inbox-api";
|
||||
import { useTailStream } from "./useTailStream";
|
||||
import { useTailStore } from "@/store/tail-store";
|
||||
|
||||
/** Default poll interval. Tail-integration (T19) will replace this. */
|
||||
const POLL_INTERVAL_MS = 5_000;
|
||||
/**
|
||||
* Coalesce bursty tail events into a single network round-trip. With 5s
|
||||
* polling this was implicitly handled by the interval; with the tail we
|
||||
* have to do it explicitly. 250ms is short enough that the user sees the
|
||||
* new row within their natural attention span (well under the spec's
|
||||
* "live updates" SLO), and long enough that a 10-event burst doesn't
|
||||
* fire 10 fetches.
|
||||
*/
|
||||
const REFETCH_DEBOUNCE_MS = 250;
|
||||
|
||||
export function useInboxLanes() {
|
||||
const [lanes, setLanes] = useState<InboxLanes>({
|
||||
@@ -13,7 +37,22 @@ export function useInboxLanes() {
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Keep the SP5 tail connections alive while this hook is mounted. The
|
||||
// hook itself doesn't read their return value — events are dispatched
|
||||
// into the tail store, which we subscribe to below. The `useTailStream`
|
||||
// call is what opens the SSE stream and keeps it alive; without these
|
||||
// two lines the inbox would silently stop updating once the user
|
||||
// navigated away from a page that already mounted a tail.
|
||||
useTailStream("claims");
|
||||
useTailStream("remittances");
|
||||
|
||||
// Subscribe to the tail-store order arrays. We watch the *length* (not
|
||||
// the contents) because the store's `addClaim`/`addRemittance` returns
|
||||
// a new array reference on every insert, which is exactly what zustand
|
||||
// uses to flag a re-render.
|
||||
const claimOrderLen = useTailStore((s) => s.claimOrder.length);
|
||||
const remitOrderLen = useTailStore((s) => s.remitOrder.length);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
try {
|
||||
@@ -27,14 +66,51 @@ export function useInboxLanes() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Hold the latest `refetch` in a ref so the debounce timer always
|
||||
// closes over the current closure without forcing the effect below to
|
||||
// re-subscribe on every render.
|
||||
const refetchRef = useRef(refetch);
|
||||
refetchRef.current = refetch;
|
||||
|
||||
// Track the order lengths we last observed so the very first render
|
||||
// (with the store already at its initial size, possibly non-zero if a
|
||||
// sibling page has been populating it) doesn't trigger a spurious
|
||||
// refetch. -1 sentinel means "haven't observed yet".
|
||||
const prevLenRef = useRef<{ claims: number; remits: number }>({
|
||||
claims: -1,
|
||||
remits: -1,
|
||||
});
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const prev = prevLenRef.current;
|
||||
const claimsChanged = prev.claims !== claimOrderLen;
|
||||
const remitsChanged = prev.remits !== remitOrderLen;
|
||||
prevLenRef.current = { claims: claimOrderLen, remits: remitOrderLen };
|
||||
|
||||
// Skip the initial observation — the lanes have already been
|
||||
// fetched via the mount effect below. Only react to *changes*.
|
||||
if (prev.claims === -1) return;
|
||||
if (!claimsChanged && !remitsChanged) return;
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
debounceRef.current = null;
|
||||
void refetchRef.current();
|
||||
}, REFETCH_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
debounceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [claimOrderLen, remitOrderLen]);
|
||||
|
||||
// Initial fetch on mount. Runs once; the tail-driven effect above
|
||||
// handles subsequent updates.
|
||||
useEffect(() => {
|
||||
void refetch();
|
||||
pollRef.current = setInterval(() => {
|
||||
void refetch();
|
||||
}, POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
};
|
||||
}, [refetch]);
|
||||
|
||||
return { lanes, loading, error, refetch };
|
||||
|
||||
Reference in New Issue
Block a user