/** * Tests for useListSSE — D-11 bounded backoff + D-10 reconnect invalidation. * * Covers: * - D-11: bounded exponential backoff (250→500→1000→2000→4000→cap 8000ms) * - D-11: stop retrying after MAX_ATTEMPTS (≥6), transition to 'disconnected' * - D-10: on successful open, reset attempt counter + invalidate ['list', listId] * - on each list-change event, invalidate ['list', listId] * - cleanup: closes EventSource and clears timers on unmount (no reconnect storm — Pitfall 3) * * Run: pnpm --filter @familysync/pwa exec vitest run src/hooks/useListSSE.test.ts */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import type { ReactNode } from 'react'; import React from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; // --------------------------------------------------------------------------- // Mock EventSource // // The browser EventSource is not available in jsdom. We provide a minimal mock // that lets tests control open/error/message events synchronously. // --------------------------------------------------------------------------- type MockEventSourceInstance = { url: string; withCredentials: boolean; readyState: number; onopen: ((ev: Event) => void) | null; onerror: ((ev: Event) => void) | null; listeners: Map void>>; addEventListener: (type: string, handler: (ev: MessageEvent) => void) => void; close: () => void; // Test helpers — trigger events _triggerOpen: () => void; _triggerError: () => void; _triggerMessage: (type: string, data: unknown) => void; }; let mockInstances: MockEventSourceInstance[] = []; class MockEventSource { url: string; withCredentials: boolean; readyState: number = 0; onopen: ((ev: Event) => void) | null = null; onerror: ((ev: Event) => void) | null = null; listeners: Map void>> = new Map(); closeCalled = false; constructor(url: string, init?: { withCredentials?: boolean }) { this.url = url; this.withCredentials = init?.withCredentials ?? false; mockInstances.push(this); } addEventListener(type: string, handler: (ev: MessageEvent) => void) { const existing = this.listeners.get(type) ?? []; this.listeners.set(type, [...existing, handler]); } close() { this.closeCalled = true; this.readyState = 2; // CLOSED } _triggerOpen() { this.readyState = 1; // OPEN this.onopen?.({} as Event); } _triggerError() { this.onerror?.({} as Event); } _triggerMessage(type: string, data: unknown) { const handlers = this.listeners.get(type) ?? []; const event = new MessageEvent(type, { data: JSON.stringify(data) }); handlers.forEach((h) => h(event)); } } // --------------------------------------------------------------------------- // Test setup // --------------------------------------------------------------------------- function makeWrapper(queryClient: QueryClient) { return function Wrapper({ children }: { children: ReactNode }) { return React.createElement(QueryClientProvider, { client: queryClient }, children); }; } beforeEach(() => { mockInstances = []; vi.useFakeTimers(); // Replace global EventSource with mock vi.stubGlobal('EventSource', MockEventSource); }); afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); mockInstances = []; }); // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('useListSSE — D-11 bounded backoff', () => { it('reports connected and resets attempt counter when EventSource fires open', async () => { const { useListSSE } = await import('./useListSSE.js'); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const stateChanges: string[] = []; const { unmount } = renderHook( () => useListSSE({ listId: 1, onStateChange: (s) => stateChanges.push(s) }), { wrapper: makeWrapper(queryClient) }, ); // Trigger open on the created EventSource const es = mockInstances[0] as unknown as { _triggerOpen: () => void }; act(() => { es._triggerOpen(); }); expect(stateChanges).toContain('connected'); unmount(); }); it('invalidates [list, listId] query on successful open (D-10 full refetch on reconnect)', async () => { const { useListSSE } = await import('./useListSSE.js'); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); const { unmount } = renderHook(() => useListSSE({ listId: 42, onStateChange: vi.fn() }), { wrapper: makeWrapper(queryClient), }); act(() => { const es = mockInstances[0] as unknown as { _triggerOpen: () => void }; es._triggerOpen(); }); expect(invalidateSpy).toHaveBeenCalledWith(expect.objectContaining({ queryKey: ['list', 42] })); unmount(); }); it('invalidates [list, listId] when a list-change SSE event is received', async () => { const { useListSSE } = await import('./useListSSE.js'); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); const { unmount } = renderHook(() => useListSSE({ listId: 7, onStateChange: vi.fn() }), { wrapper: makeWrapper(queryClient), }); act(() => { const es = mockInstances[0] as unknown as { _triggerOpen: () => void; _triggerMessage: (type: string, data: unknown) => void; }; es._triggerOpen(); es._triggerMessage('item:added', { type: 'item:added', listId: 7 }); }); const calls = invalidateSpy.mock.calls; const hasListQuery = calls.some((args) => { const opts = args[0] as { queryKey?: unknown[] }; return JSON.stringify(opts?.queryKey) === JSON.stringify(['list', 7]); }); expect(hasListQuery).toBe(true); unmount(); }); it('transitions to reconnecting on first error and schedules retry after 250ms (D-11 step 0)', async () => { const { useListSSE } = await import('./useListSSE.js'); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const stateChanges: string[] = []; const { unmount } = renderHook( () => useListSSE({ listId: 1, onStateChange: (s) => stateChanges.push(s) }), { wrapper: makeWrapper(queryClient) }, ); act(() => { mockInstances[0]._triggerError(); }); // Should be reconnecting (not disconnected — still have attempts left) expect(stateChanges).toContain('reconnecting'); // Advance 250ms — a new EventSource should be created act(() => { vi.advanceTimersByTime(250); }); expect(mockInstances.length).toBeGreaterThanOrEqual(2); // reconnected unmount(); }); it('stops retrying after MAX_ATTEMPTS and transitions to disconnected (D-11 backoff exhausted)', async () => { const { useListSSE } = await import('./useListSSE.js'); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const stateChanges: string[] = []; renderHook(() => useListSSE({ listId: 1, onStateChange: (s) => stateChanges.push(s) }), { wrapper: makeWrapper(queryClient), }); // Exhaust all 6 backoff steps: 250→500→1000→2000→4000→8000ms const backoffSteps = [250, 500, 1000, 2000, 4000, 8000]; for (const delay of backoffSteps) { // Trigger error on the latest EventSource instance act(() => { const es = mockInstances[mockInstances.length - 1]; es._triggerError(); }); // Advance to the next backoff delay to allow next connection attempt act(() => { vi.advanceTimersByTime(delay); }); } // After exhausting all attempts, trigger error on last instance act(() => { const es = mockInstances[mockInstances.length - 1]; es._triggerError(); }); // State must be 'disconnected' — no more retries const lastState = stateChanges[stateChanges.length - 1]; expect(lastState).toBe('disconnected'); // No additional timer scheduled — advancing time should not create a new instance const instanceCount = mockInstances.length; act(() => { vi.advanceTimersByTime(30_000); }); expect(mockInstances.length).toBe(instanceCount); // no new connection attempt }); it('resets backoff counter on successful reconnect (D-11 — counter reset)', async () => { const { useListSSE } = await import('./useListSSE.js'); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const stateChanges: string[] = []; renderHook(() => useListSSE({ listId: 1, onStateChange: (s) => stateChanges.push(s) }), { wrapper: makeWrapper(queryClient), }); // Fail once act(() => { mockInstances[0]._triggerError(); }); act(() => { vi.advanceTimersByTime(250); // first backoff }); // Reconnect successfully act(() => { const latest = mockInstances[mockInstances.length - 1]; latest._triggerOpen(); }); expect(stateChanges).toContain('connected'); // The attempt counter should be reset. Fail again — should reconnect, not give up. const statesBefore = stateChanges.length; act(() => { const latest = mockInstances[mockInstances.length - 1]; latest._triggerError(); }); // Should be reconnecting again (counter reset → still has attempts) const newStates = stateChanges.slice(statesBefore); expect(newStates).toContain('reconnecting'); }); it('closes EventSource and clears timers on unmount (no reconnect storm — Pitfall 3)', async () => { const { useListSSE } = await import('./useListSSE.js'); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const { unmount } = renderHook(() => useListSSE({ listId: 1, onStateChange: vi.fn() }), { wrapper: makeWrapper(queryClient), }); const firstEs = mockInstances[0]; // Trigger error to schedule a reconnect timer act(() => { firstEs._triggerError(); }); // Unmount — should cancel the pending timer and close the EventSource unmount(); const instanceCountAfterUnmount = mockInstances.length; // Advance well past any backoff timer — no new instances should be created act(() => { vi.advanceTimersByTime(30_000); }); expect(mockInstances.length).toBe(instanceCountAfterUnmount); // no new connection post-unmount expect((firstEs as unknown as { closeCalled?: boolean }).closeCalled).toBeTruthy(); }); it('uses withCredentials: true on EventSource (Pitfall 7 — session cookie)', async () => { const { useListSSE } = await import('./useListSSE.js'); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const { unmount } = renderHook(() => useListSSE({ listId: 1, onStateChange: vi.fn() }), { wrapper: makeWrapper(queryClient), }); const es = mockInstances[0]; expect(es.withCredentials).toBe(true); unmount(); }); });