CUB-125: address Grimm review — tests, type fixes, error state circuit breaker
- Add missing 'offline' to AgentStatus union type (types/index.ts) - Add max-retry circuit breaker to useSSE; error state is now reachable - Wire typed SSE payloads (SSEPayloadMap discriminated union) into useRealtimeSync - Add Vitest + 20 unit tests: useSSE lifecycle, back-off, circuit breaker, event parsing, cleanup; useRealtimeSync event-to-invalidation mapping - Rebase on dev to remove stale CUB-119 legacy-deletion commit and align CI workflow (dev already consolidated into single dev.yml) - Tests: npm test → 20/20 pass; Build: npm run build → 0 errors
This commit is contained in:
129
frontend/src/hooks/useRealtimeSync.test.tsx
Normal file
129
frontend/src/hooks/useRealtimeSync.test.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Tests for useRealtimeSync — event → query invalidation mapping.
|
||||
*
|
||||
* Uses .tsx extension so Vite/OXC can parse JSX in the wrapper component.
|
||||
*/
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import * as useSSEModule from './useSSE'
|
||||
import { useRealtimeSync } from './useRealtimeSync'
|
||||
import React from 'react'
|
||||
import type { SSEMessage } from '../services/sse'
|
||||
|
||||
describe('useRealtimeSync', () => {
|
||||
let queryClient: QueryClient
|
||||
let mockSSEOnMessage: ((msg: { type: string; data: unknown }) => void) | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
mockSSEOnMessage = null
|
||||
|
||||
// Spy on useSSE to capture the onMessage callback
|
||||
vi.spyOn(useSSEModule, 'useSSE').mockImplementation((opts) => {
|
||||
mockSSEOnMessage = opts?.onMessage ?? null
|
||||
return { status: 'connected' }
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function render() {
|
||||
return renderHook(() => useRealtimeSync(), {
|
||||
wrapper: ({ children }: { children: React.ReactNode }) => (
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children)
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
it('invalidates ["agents"] on agent.status event', async () => {
|
||||
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
render()
|
||||
|
||||
const msg: SSEMessage = {
|
||||
type: 'agent.status',
|
||||
data: { agentId: 'a1', status: 'active' },
|
||||
}
|
||||
mockSSEOnMessage!(msg)
|
||||
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['agents'] })
|
||||
expect(invalidateSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('invalidates ["tasks"] and ["agents"] on agent.task event', async () => {
|
||||
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
render()
|
||||
|
||||
const msg: SSEMessage = {
|
||||
type: 'agent.task',
|
||||
data: { agentId: 'a1', taskId: 't1', title: 'Test', action: 'assigned' },
|
||||
}
|
||||
mockSSEOnMessage!(msg)
|
||||
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['tasks'] })
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['agents'] })
|
||||
expect(invalidateSpy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('invalidates ["tasks"] and ["agents"] on agent.progress event', async () => {
|
||||
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
render()
|
||||
|
||||
const msg: SSEMessage = {
|
||||
type: 'agent.progress',
|
||||
data: { agentId: 'a1', taskId: 't1', progress: 50, message: 'working' },
|
||||
}
|
||||
mockSSEOnMessage!(msg)
|
||||
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['tasks'] })
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['agents'] })
|
||||
expect(invalidateSpy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('invalidates ["agents"], ["sessions"], ["tasks"] on fleet.update event', async () => {
|
||||
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
render()
|
||||
|
||||
const msg: SSEMessage = {
|
||||
type: 'fleet.update',
|
||||
data: { timestamp: '2026-05-20T12:00:00Z', agentCount: 5 },
|
||||
}
|
||||
mockSSEOnMessage!(msg)
|
||||
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['agents'] })
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['sessions'] })
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['tasks'] })
|
||||
expect(invalidateSpy).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('does nothing on connected event', async () => {
|
||||
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
render()
|
||||
|
||||
const msg: SSEMessage = {
|
||||
type: 'connected',
|
||||
data: { clientCount: 1 },
|
||||
}
|
||||
mockSSEOnMessage!(msg)
|
||||
|
||||
expect(invalidateSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing on unknown event types', async () => {
|
||||
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
render()
|
||||
|
||||
mockSSEOnMessage!({ type: 'unknown.event', data: {} })
|
||||
|
||||
expect(invalidateSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns sseStatus from useSSE', () => {
|
||||
const { result } = render()
|
||||
expect(result.current.sseStatus).toBe('connected')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user