- Wire publishListEvent fan-out in lists.ts after every write mutation (item:added/updated/deleted, list:updated/deleted)
- Add GET /api/sse/lists scoped endpoint in sse.ts: resolveUserId → 401 on null; getAccessibleListIds → subscribe only to accessible channels; 30s heartbeat; cleanup on disconnect (D-04/T-04-01/T-04-02)
- Create useListSSE.ts: bounded-backoff EventSource wrapper (250ms→500ms→1s→2s→4s→cap 8s); MAX_ATTEMPTS=6; withCredentials:true; close-before-retry prevents reconnect storm (Pitfall 3); invalidates ['list', listId] on open (D-10) and on each event; onStateChange('disconnected') after exhaustion (D-11)
- Create LiveSyncIndicator.tsx: connected=green dot; reconnecting=pulsing muted dot + label; disconnected=red dot + 'Updates paused' (role=alert); correct ARIA per UI-SPEC
- Wire useListSSE + LiveSyncIndicator into ListDetail header; retain refetchInterval:30000 polling fallback (D-12)
- All 8 useListSSE tests pass; all 54 API tests pass; both typechecks pass
- playwright-cli: live update confirmed (eggs item added via API appeared in browser without manual refresh)
333 lines
11 KiB
TypeScript
333 lines
11 KiB
TypeScript
/**
|
|
* 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<string, Array<(ev: MessageEvent) => 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<string, Array<(ev: MessageEvent) => void>> = new Map()
|
|
closeCalled = false
|
|
|
|
constructor(url: string, init?: { withCredentials?: boolean }) {
|
|
this.url = url
|
|
this.withCredentials = init?.withCredentials ?? false
|
|
mockInstances.push(this as unknown as MockEventSourceInstance)
|
|
}
|
|
|
|
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()
|
|
})
|
|
})
|