test(04-06): add failing RED tests for LIST-04 SSE fan-out + bounded backoff

- API: 5 failing fan-out spy tests (subscribeListEvents receives 0 events since publishListEvent seams commented out in lists.ts)
- API: 4 D-04 scoped subscription tests (green — listAccess primitives from 04-02 already proven)
- PWA: useListSSE.test.ts — all 7 tests fail (module-not-found; hook not yet created)
- Covers: item:added/updated/deleted, list:updated/deleted fan-out + D-11 bounded backoff exhaustion + D-10 reconnect invalidation
This commit is contained in:
Lucas Berger
2026-06-09 13:25:39 -04:00
parent f12093c910
commit 5a8d1efe1c
2 changed files with 504 additions and 14 deletions
+324 -14
View File
@@ -1,22 +1,332 @@
/**
* Wave-0 RED stubs for useListSSE hook.
* Tests for useListSSE — D-11 bounded backoff + D-10 reconnect invalidation.
*
* Covers D-11: bounded (capped exponential) backoff before showing the
* "disconnected / updates paused" indicator.
* 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)
*
* Tests use a mock EventSource that simulates connect/disconnect scenarios.
* Downstream plans implement the actual useListSSE.ts hook.
*
* Run: pnpm --filter @familysync/pwa test
* Run: pnpm --filter @familysync/pwa exec vitest run src/hooks/useListSSE.test.ts
*/
import { describe, it } from 'vitest'
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.todo('starts connected when EventSource fires the open event')
it.todo('attempts to reconnect with exponential backoff on error')
it.todo('stops retrying after the configured max attempts and sets status to disconnected')
it.todo('resets backoff counter on successful reconnect')
it.todo('calls onMessage callback with parsed event data when a message arrives')
it.todo('cleans up EventSource on unmount')
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.closeCalled ?? (firstEs as unknown as { close: () => void; 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()
})
})