Files
CapaKraken/apps/web/src/hooks/useTimelineSSE.test.ts
T

244 lines
6.6 KiB
TypeScript

import { SSE_EVENT_TYPES } from "@capakraken/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getTimelineSseResyncKeys } from "./timelineSsePolicy.js";
const invalidateQueries = vi.fn();
const effectCleanups: Array<() => void> = [];
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({
invalidateQueries,
}),
}));
vi.mock("react", async () => {
const actual = await vi.importActual<typeof import("react")>("react");
return {
...actual,
useEffect: (callback: () => void | (() => void)) => {
const cleanup = callback();
if (typeof cleanup === "function") {
effectCleanups.push(cleanup);
}
},
useRef: <T,>(value: T) => ({ current: value }),
};
});
import { useTimelineSSE } from "./useTimelineSSE.js";
class MockEventSource {
static instances: MockEventSource[] = [];
onopen: (() => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
close = vi.fn();
constructor(public readonly url: string) {
MockEventSource.instances.push(this);
}
emitOpen() {
this.onopen?.();
}
emitMessage(data: string) {
this.onmessage?.({ data } as MessageEvent);
}
emitError() {
this.onerror?.();
}
}
type VisibilityHandler = () => void;
class MockVisibilityDocument {
visibilityState: "visible" | "hidden" = "visible";
private readonly listeners = new Set<VisibilityHandler>();
addEventListener(type: string, listener: VisibilityHandler) {
if (type === "visibilitychange") {
this.listeners.add(listener);
}
}
removeEventListener(type: string, listener: VisibilityHandler) {
if (type === "visibilitychange") {
this.listeners.delete(listener);
}
}
setVisibility(state: "visible" | "hidden") {
this.visibilityState = state;
for (const listener of this.listeners) {
listener();
}
}
}
describe("useTimelineSSE", () => {
let mockDocument: MockVisibilityDocument;
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
effectCleanups.length = 0;
MockEventSource.instances.length = 0;
mockDocument = new MockVisibilityDocument();
vi.stubGlobal("EventSource", MockEventSource);
vi.stubGlobal("document", mockDocument);
});
afterEach(() => {
while (effectCleanups.length > 0) {
effectCleanups.pop()?.();
}
vi.unstubAllGlobals();
vi.useRealTimers();
});
it("ignores malformed SSE payloads without invalidating queries", () => {
useTimelineSSE();
expect(MockEventSource.instances).toHaveLength(1);
MockEventSource.instances[0]?.emitMessage("{not-json");
expect(invalidateQueries).not.toHaveBeenCalled();
});
it("does not invalidate queries for ping messages", () => {
useTimelineSSE();
expect(MockEventSource.instances).toHaveLength(1);
MockEventSource.instances[0]?.emitOpen();
MockEventSource.instances[0]?.emitMessage(JSON.stringify({ type: SSE_EVENT_TYPES.PING }));
expect(invalidateQueries).not.toHaveBeenCalled();
});
it("does not resync on the initial successful SSE connection", () => {
useTimelineSSE();
expect(MockEventSource.instances).toHaveLength(1);
MockEventSource.instances[0]?.emitOpen();
expect(invalidateQueries).not.toHaveBeenCalled();
});
it("resets reconnect backoff after ping and reconnects only once per pending timer", () => {
useTimelineSSE();
const firstConnection = MockEventSource.instances[0];
expect(firstConnection).toBeDefined();
firstConnection?.emitError();
firstConnection?.emitError();
expect(firstConnection?.close).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1999);
expect(MockEventSource.instances).toHaveLength(1);
vi.advanceTimersByTime(1);
expect(MockEventSource.instances).toHaveLength(2);
const secondConnection = MockEventSource.instances[1];
secondConnection?.emitMessage(JSON.stringify({ type: SSE_EVENT_TYPES.PING }));
secondConnection?.emitError();
vi.advanceTimersByTime(1999);
expect(MockEventSource.instances).toHaveLength(2);
vi.advanceTimersByTime(1);
expect(MockEventSource.instances).toHaveLength(3);
expect(invalidateQueries).not.toHaveBeenCalled();
});
it("resyncs timeline queries exactly once after a successful reconnect", () => {
useTimelineSSE();
const firstConnection = MockEventSource.instances[0];
expect(firstConnection).toBeDefined();
firstConnection?.emitOpen();
firstConnection?.emitError();
vi.advanceTimersByTime(2_000);
expect(MockEventSource.instances).toHaveLength(2);
const secondConnection = MockEventSource.instances[1];
expect(secondConnection).toBeDefined();
secondConnection?.emitOpen();
secondConnection?.emitOpen();
expect(invalidateQueries).toHaveBeenCalledTimes(getTimelineSseResyncKeys().length);
expect(invalidateQueries.mock.calls).toEqual(
getTimelineSseResyncKeys().map((queryKey) => [{ queryKey }]),
);
});
it("clears a pending reconnect when the hook is disposed", () => {
useTimelineSSE();
const firstConnection = MockEventSource.instances[0];
expect(firstConnection).toBeDefined();
firstConnection?.emitError();
while (effectCleanups.length > 0) {
effectCleanups.pop()?.();
}
vi.advanceTimersByTime(30_000);
expect(MockEventSource.instances).toHaveLength(1);
expect(firstConnection?.close).toHaveBeenCalled();
});
it("does not connect while the page is hidden on mount, then connects when visible", () => {
mockDocument.visibilityState = "hidden";
useTimelineSSE();
expect(MockEventSource.instances).toHaveLength(0);
mockDocument.setVisibility("visible");
expect(MockEventSource.instances).toHaveLength(1);
expect(invalidateQueries).not.toHaveBeenCalled();
});
it("closes the active connection while hidden and resyncs once when visible again", () => {
useTimelineSSE();
const firstConnection = MockEventSource.instances[0];
expect(firstConnection).toBeDefined();
firstConnection?.emitOpen();
mockDocument.setVisibility("hidden");
expect(firstConnection?.close).toHaveBeenCalledTimes(1);
expect(MockEventSource.instances).toHaveLength(1);
mockDocument.setVisibility("visible");
expect(MockEventSource.instances).toHaveLength(2);
const secondConnection = MockEventSource.instances[1];
secondConnection?.emitOpen();
expect(invalidateQueries).toHaveBeenCalledTimes(getTimelineSseResyncKeys().length);
expect(invalidateQueries.mock.calls).toEqual(
getTimelineSseResyncKeys().map((queryKey) => [{ queryKey }]),
);
});
});