feat(sp6): useInboxLanes refetches on SP5 tail events
This commit is contained in:
@@ -1,7 +1,32 @@
|
|||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
// React's `act` warnings need an act-aware environment; mirror the other
|
||||||
import { cleanup, renderHook, waitFor } from "@testing-library/react";
|
// 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 { 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(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -9,20 +34,42 @@ afterEach(() => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function emptyLanesPayload() {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
rejected: [],
|
||||||
|
candidates: [],
|
||||||
|
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", () => {
|
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 () => {
|
it("loads lanes on mount", async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(emptyLanesPayload()));
|
||||||
"fetch",
|
|
||||||
vi.fn().mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({
|
|
||||||
rejected: [],
|
|
||||||
candidates: [],
|
|
||||||
unmatched: [],
|
|
||||||
done_today: [],
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const { result } = renderHook(() => useInboxLanes());
|
const { result } = renderHook(() => useInboxLanes());
|
||||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
expect(result.current.lanes).toEqual({
|
expect(result.current.lanes).toEqual({
|
||||||
@@ -42,4 +89,33 @@ describe("useInboxLanes", () => {
|
|||||||
await waitFor(() => expect(result.current.error).not.toBeNull());
|
await waitFor(() => expect(result.current.error).not.toBeNull());
|
||||||
expect(result.current.error?.message).toMatch(/500/);
|
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 { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { fetchInboxLanes, type InboxLanes } from "@/lib/inbox-api";
|
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() {
|
export function useInboxLanes() {
|
||||||
const [lanes, setLanes] = useState<InboxLanes>({
|
const [lanes, setLanes] = useState<InboxLanes>({
|
||||||
@@ -13,7 +37,22 @@ export function useInboxLanes() {
|
|||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<Error | null>(null);
|
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 () => {
|
const refetch = useCallback(async () => {
|
||||||
try {
|
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(() => {
|
useEffect(() => {
|
||||||
void refetch();
|
void refetch();
|
||||||
pollRef.current = setInterval(() => {
|
|
||||||
void refetch();
|
|
||||||
}, POLL_INTERVAL_MS);
|
|
||||||
return () => {
|
|
||||||
if (pollRef.current) clearInterval(pollRef.current);
|
|
||||||
};
|
|
||||||
}, [refetch]);
|
}, [refetch]);
|
||||||
|
|
||||||
return { lanes, loading, error, refetch };
|
return { lanes, loading, error, refetch };
|
||||||
|
|||||||
Reference in New Issue
Block a user