From 71c4e61735affd417827c329ac92761331bbadf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Wed, 1 Apr 2026 09:10:45 +0200 Subject: [PATCH] test(web): cover timeline sse edge paths --- apps/web/src/hooks/useTimelineSSE.test.ts | 137 ++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 apps/web/src/hooks/useTimelineSSE.test.ts diff --git a/apps/web/src/hooks/useTimelineSSE.test.ts b/apps/web/src/hooks/useTimelineSSE.test.ts new file mode 100644 index 0000000..f891116 --- /dev/null +++ b/apps/web/src/hooks/useTimelineSSE.test.ts @@ -0,0 +1,137 @@ +import { SSE_EVENT_TYPES } from "@capakraken/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +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?.(); + } +} + +describe("useTimelineSSE", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + effectCleanups.length = 0; + MockEventSource.instances.length = 0; + vi.stubGlobal("EventSource", MockEventSource); + }); + + 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]?.emitMessage(JSON.stringify({ type: SSE_EVENT_TYPES.PING })); + + 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(2); + + 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("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(); + }); +});