Files
cyclone/src/hooks/useTailStream.test.ts
T

404 lines
13 KiB
TypeScript

// @vitest-environment happy-dom
// 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 React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import { useTailStream } from "./useTailStream";
import { useTailStore } from "@/store/tail-store";
import type { Claim } from "@/types";
import type { TailEvent } from "@/lib/tail-stream";
// ---------------------------------------------------------------------------
// Mock `streamTail` so we can drive the async iterator from the test. The
// hook calls `streamTail(resource, { signal })` once per connection attempt;
// each call must return an async iterator we control. We replace it with a
// factory that records every generator we hand out so the test can push
// events / errors / close on demand.
// ---------------------------------------------------------------------------
vi.mock("@/lib/tail-stream", () => ({
streamTail: vi.fn(),
}));
import { streamTail } from "@/lib/tail-stream";
const mockStreamTail = vi.mocked(streamTail);
/**
* One controllable async iterator. `push(ev)` resolves the next pending
* `next()` with `ev`. `failWith(err)` rejects the next pending `next()`.
* `close()` resolves any pending `next()` with `done: true` so the
* `for await` loop exits cleanly (this is how a server-side EOF looks to
* the hook — the spec says that triggers a reconnect).
*
* If `next()` is called when no event is queued and the iterator isn't
* closed, we suspend on a promise — exactly mirroring `ReadableStream`'s
* behaviour and letting the test decide when each event arrives.
*/
function makeCtrl() {
const queue: TailEvent[] = [];
let resolveNext: ((v: IteratorResult<TailEvent>) => void) | null = null;
let rejectNext: ((e: unknown) => void) | null = null;
let done = false;
let pendingError: unknown = null;
const next = (): Promise<IteratorResult<TailEvent>> => {
if (pendingError !== null) {
const e = pendingError;
pendingError = null;
return Promise.reject(e);
}
if (queue.length > 0) {
return Promise.resolve({ value: queue.shift() as TailEvent, done: false });
}
if (done) {
return Promise.resolve({ value: undefined as unknown as TailEvent, done: true });
}
return new Promise<IteratorResult<TailEvent>>((resolve, reject) => {
resolveNext = resolve;
rejectNext = reject;
});
};
const iter: AsyncIterableIterator<TailEvent> = {
next,
return: () => {
done = true;
if (resolveNext) {
const r = resolveNext;
resolveNext = null;
rejectNext = null;
r({ value: undefined as unknown as TailEvent, done: true });
}
return Promise.resolve({ value: undefined as unknown as TailEvent, done: true });
},
throw: (err: unknown) => {
done = true;
if (rejectNext) {
const r = rejectNext;
resolveNext = null;
rejectNext = null;
r(err);
}
return Promise.reject(err);
},
[Symbol.asyncIterator]() {
return this;
},
};
return {
iter,
push(ev: TailEvent): void {
if (resolveNext) {
const r = resolveNext;
resolveNext = null;
rejectNext = null;
r({ value: ev, done: false });
} else {
queue.push(ev);
}
},
close(): void {
done = true;
if (resolveNext) {
const r = resolveNext;
resolveNext = null;
rejectNext = null;
r({ value: undefined as unknown as TailEvent, done: true });
}
},
failWith(err: unknown): void {
if (rejectNext) {
const r = rejectNext;
resolveNext = null;
rejectNext = null;
r(err);
} else {
pendingError = err;
}
},
};
}
type Ctrl = ReturnType<typeof makeCtrl>;
/** Configure `mockStreamTail` so every call returns a fresh controllable iterator. */
function trackCalls(): { ctrls: Ctrl[] } {
const ctrls: Ctrl[] = [];
mockStreamTail.mockImplementation(() => {
const c = makeCtrl();
ctrls.push(c);
return c.iter;
});
return { ctrls };
}
/** Same `renderHook` shim used by `useClaimDetail`, `useReconciliation`, etc. */
function renderHook<TResult>(setup: () => TResult): {
result: { current: TResult | undefined };
unmount: () => void;
} {
const result: { current: TResult | undefined } = { current: undefined };
const container = document.createElement("div");
document.body.appendChild(container);
function Probe() {
result.current = setup();
return null;
}
const root: Root = createRoot(container);
act(() => {
root.render(React.createElement(Probe));
});
return {
result,
unmount: () => {
act(() => root.unmount());
container.remove();
},
};
}
/** Flush microtasks + React state until `predicate()` holds (or we time out). */
async function waitFor(
predicate: () => boolean,
timeoutMs = 1000,
): Promise<void> {
const start = Date.now();
while (!predicate()) {
if (Date.now() - start > timeoutMs) {
throw new Error(
`waitFor: predicate did not hold within ${timeoutMs}ms`,
);
}
await act(async () => {
await Promise.resolve();
});
}
}
/** Build a valid Claim shape for the dispatch test. */
function makeClaim(id: string, patientName: string): Claim {
return {
id,
patientName,
providerNpi: "1234567890",
payerName: "Medicaid",
cptCode: "99213",
billedAmount: 100,
receivedAmount: 0,
status: "submitted",
submissionDate: "2026-06-20T00:00:00Z",
};
}
describe("useTailStream", () => {
beforeEach(() => {
mockStreamTail.mockReset();
// Reset the singleton tail-store between tests so each case sees a
// clean claims/remittances/activity slate.
useTailStore.getState().reset("claims");
useTailStore.getState().reset("remittances");
useTailStore.getState().reset("activity");
});
afterEach(() => {
// Restore real timers so a fake-timers-using test doesn't leak into
// the next case.
vi.useRealTimers();
});
it("test_on_mount_status_connecting_then_live_after_snapshot_end", async () => {
const { ctrls } = trackCalls();
const { result, unmount } = renderHook(() => useTailStream("claims"));
// Wait for the effect to call streamTail once.
await waitFor(() => ctrls.length === 1);
// Initial status: connecting.
expect(result.current?.status).toBe("connecting");
// Server finishes replaying the snapshot — status flips to live.
ctrls[0].push({ type: "snapshot_end", data: { count: 0 } });
await waitFor(() => result.current?.status === "live");
expect(result.current?.status).toBe("live");
unmount();
});
it("test_on_stream_error_status_error", async () => {
const { ctrls } = trackCalls();
const { result, unmount } = renderHook(() => useTailStream("claims"));
await waitFor(() => ctrls.length === 1);
// Simulate a stream-level error event (yielded, not thrown). The hook
// should turn this into an Error and surface `status === "error"`.
ctrls[0].failWith(new Error("boom"));
await waitFor(() => result.current?.status === "error");
expect(result.current?.status).toBe("error");
expect(result.current?.error).toBeInstanceOf(Error);
expect((result.current?.error as Error).message).toBe("boom");
unmount();
});
it("test_on_abort_status_closed_no_reconnect", async () => {
const { ctrls } = trackCalls();
const { unmount } = renderHook(() => useTailStream("claims"));
await waitFor(() => ctrls.length === 1);
// Capture the AbortSignal the hook passed to streamTail — the contract
// is that the hook aborts it on unmount.
const call = mockStreamTail.mock.calls[0];
expect(call).toBeDefined();
const opts = call[1] as { signal?: AbortSignal } | undefined;
expect(opts?.signal).toBeDefined();
expect(opts?.signal?.aborted).toBe(false);
unmount();
// After unmount the signal MUST be aborted — that's how `streamTail`
// tears down its fetch loop, and it's the externally-observable
// evidence that we cleaned up. (`status === "closed"` is set on the
// hook's state, but a renderHook shim can't observe post-unmount
// re-renders, so we assert on the abort instead.)
expect(opts?.signal?.aborted).toBe(true);
// No reconnect must be scheduled. Advance past the entire backoff
// window and confirm `streamTail` is still at exactly one call.
vi.useFakeTimers();
try {
await act(async () => {
vi.advanceTimersByTime(60_000);
});
expect(mockStreamTail).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
it("test_on_event_dispatches_to_tail_store", async () => {
const { ctrls } = trackCalls();
const { unmount } = renderHook(() => useTailStream("claims"));
await waitFor(() => ctrls.length === 1);
// Bring the stream to "live" so the dispatcher is in the happy path.
ctrls[0].push({ type: "snapshot_end", data: { count: 0 } });
await waitFor(() => useTailStore.getState().claims !== undefined);
// Push an item — it must end up in the claims slice.
ctrls[0].push({
type: "item",
data: makeClaim("CLM-1", "Patient One"),
});
await waitFor(
() => useTailStore.getState().claims["CLM-1"] !== undefined,
);
const stored = useTailStore.getState().claims["CLM-1"];
expect(stored).toBeDefined();
expect(stored?.patientName).toBe("Patient One");
unmount();
});
it("test_reconnect_status_cycles_reconnecting_to_connecting_to_live", async () => {
const { ctrls } = trackCalls();
// Compress the backoff schedule so the test doesn't sit on a real
// 1-second timer. We override the global setTimeout / clearTimeout to
// schedule timers via `setImmediate`-style micro-tasks instead of
// wall-clock — but the simpler path is just `vi.useFakeTimers` and
// manually advance. We go with the simpler path.
vi.useFakeTimers();
try {
const { result, unmount } = renderHook(() => useTailStream("claims"));
await act(async () => {
// Yield so the initial useEffect runs and calls streamTail.
await Promise.resolve();
});
expect(ctrls.length).toBe(1);
// First attempt fails — hook sets status=error and schedules a
// reconnect after the first backoff step (1000ms in the real
// schedule, but we advance just past it).
ctrls[0].failWith(new Error("first failed"));
await act(async () => {
await Promise.resolve();
});
expect(result.current?.status).toBe("error");
// Advance past the first backoff step. The hook should reopen —
// status becomes "connecting" (attempt counter resets on success
// but only after snapshot_end; while retrying it's still
// "reconnecting" until the new attempt actually opens).
await act(async () => {
vi.advanceTimersByTime(1500);
await Promise.resolve();
});
expect(ctrls.length).toBe(2);
// Second attempt completes the snapshot — status flips to live.
ctrls[1].push({ type: "snapshot_end", data: { count: 0 } });
await waitFor(() => result.current?.status === "live");
expect(result.current?.status).toBe("live");
unmount();
} finally {
vi.useRealTimers();
}
});
it("test_reconnect_dedup_duplicate_items_appear_once_in_store", async () => {
const { ctrls } = trackCalls();
const { unmount } = renderHook(() => useTailStream("claims"));
await waitFor(() => ctrls.length === 1);
ctrls[0].push({ type: "snapshot_end", data: { count: 0 } });
await waitFor(() => useTailStore.getState().claims !== undefined);
// Push the same claim twice (mimicking a snapshot replay on
// reconnect). The store must keep exactly one copy.
ctrls[0].push({
type: "item",
data: makeClaim("CLM-DUP", "Original"),
});
ctrls[0].push({
type: "item",
data: makeClaim("CLM-DUP", "Updated"),
});
await waitFor(
() => useTailStore.getState().claims["CLM-DUP"] !== undefined,
);
// Give the second push time to be processed — we want to assert it
// was rejected by the store's first-write-wins rule.
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(Object.keys(useTailStore.getState().claims)).toHaveLength(1);
expect(useTailStore.getState().claims["CLM-DUP"]?.patientName).toBe(
"Original",
);
unmount();
});
});