diff --git a/frontend/src/composables/__tests__/useFightPolling.test.ts b/frontend/src/composables/__tests__/useFightPolling.test.ts new file mode 100644 index 0000000..8d31ddf --- /dev/null +++ b/frontend/src/composables/__tests__/useFightPolling.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { ref } from 'vue' +import { useFightPolling } from '../useFightPolling' + +// Mock EventSource +class MockEventSource { + static instances: MockEventSource[] = [] + readyState = 0 + url: string + onerror: (() => void) | null = null + onopen: (() => void) | null = null + private listeners = new Map() + + constructor(url: string) { + this.url = url + MockEventSource.instances.push(this) + // Simulate open + setTimeout(() => { + this.readyState = 1 + this.onopen?.() + }, 0) + } + + addEventListener(event: string, handler: Function) { + const handlers = this.listeners.get(event) || [] + handlers.push(handler) + this.listeners.set(event, handlers) + } + + removeEventListener(event: string, handler: Function) { + const handlers = this.listeners.get(event) || [] + this.listeners.set(event, handlers.filter(h => h !== handler)) + } + + close() { + this.readyState = 2 + } + + // Test helper to simulate an error + simulateError() { + this.onerror?.() + } +} + +describe('useFightPolling SSE reconnection', () => { + beforeEach(() => { + vi.useFakeTimers() + MockEventSource.instances = [] + // @ts-expect-error — mock global + globalThis.EventSource = MockEventSource + }) + + afterEach(() => { + vi.useRealTimers() + // @ts-expect-error — cleanup mock + delete globalThis.EventSource + }) + + it('reconnects on SSE error when fight is not finished', () => { + const fightId = ref('fight-123') + const { connectSSE, fight } = useFightPolling(fightId) + fight.value = { status: 'live' } as any + + connectSSE({}) + expect(MockEventSource.instances).toHaveLength(1) + + // Simulate SSE error + MockEventSource.instances[0].simulateError() + + // Should schedule reconnect with 1s delay + vi.advanceTimersByTime(1100) + expect(MockEventSource.instances).toHaveLength(2) + }) + + it('does not reconnect when fight is finished', () => { + const fightId = ref('fight-456') + const { connectSSE, fight } = useFightPolling(fightId) + fight.value = { status: 'finished' } as any + + connectSSE({}) + expect(MockEventSource.instances).toHaveLength(1) + + // Simulate SSE error + MockEventSource.instances[0].simulateError() + + vi.advanceTimersByTime(10000) + // Should NOT reconnect + expect(MockEventSource.instances).toHaveLength(1) + }) + + it('uses exponential backoff: each reconnect uses 1s delay (retries reset per connection)', () => { + const fightId = ref('fight-789') + const { connectSSE, fight } = useFightPolling(fightId) + fight.value = { status: 'live' } as any + + connectSSE({}) + + // Error 1 → 1s delay (sseRetries=1, delay = 1000 * 2^0 = 1000ms) + MockEventSource.instances[0].simulateError() + vi.advanceTimersByTime(1100) + expect(MockEventSource.instances).toHaveLength(2) + + // Each reconnect creates fresh sseRetries=0, so next error → 1s again + MockEventSource.instances[1].simulateError() + vi.advanceTimersByTime(1100) + expect(MockEventSource.instances).toHaveLength(3) + }) +}) diff --git a/frontend/src/composables/useFightPolling.ts b/frontend/src/composables/useFightPolling.ts index d7f1058..397e9a9 100644 --- a/frontend/src/composables/useFightPolling.ts +++ b/frontend/src/composables/useFightPolling.ts @@ -36,6 +36,7 @@ export function useFightPolling(fightId: Ref) { let pollHandle: ReturnType | null = null let pollCount = 0 let eventSource: EventSource | null = null + let sseRetries = 0 const sseListeners: SSEListenerEntry[] = [] // --- Load fight data --- @@ -182,17 +183,18 @@ export function useFightPolling(fightId: Ref) { } }) - let sseRetries = 0 eventSource.onerror = () => { sseRetries++ - if (sseRetries > 5 && eventSource) { + if (eventSource) { eventSource.close() eventSource = null - const delay = Math.min(1000 * 2 ** (sseRetries - 5), 10000) - setTimeout(() => { - if (isLive.value && !eventSource) connectSSE(handlers) - }, delay) } + // Always reconnect if fight isn't finished, regardless of isLive + if (fight.value?.status === 'finished') return + const delay = Math.min(1000 * 2 ** (sseRetries - 1), 8000) // 1s, 2s, 4s, 8s max + setTimeout(() => { + if (!eventSource && fight.value?.status !== 'finished') connectSSE(handlers) + }, delay) } eventSource.onopen = () => { sseRetries = 0 } }