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("react"); return { ...actual, useEffect: (callback: () => void | (() => void)) => { const cleanup = callback(); if (typeof cleanup === "function") { effectCleanups.push(cleanup); } }, useRef: (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(); 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 }]), ); }); });