/** * Tests for useProjectEvents Hook */ import { renderHook, act, waitFor } from '@testing-library/react'; import { useProjectEvents } from '@/lib/hooks/useProjectEvents'; import { useEventStore } from '@/lib/stores/eventStore'; import { EventType, type ProjectEvent } from '@/lib/types/events'; // Mock useAuth const mockUseAuth = jest.fn(); jest.mock('@/lib/auth/AuthContext', () => ({ useAuth: (selector?: (state: unknown) => unknown) => { const state = mockUseAuth(); return selector ? selector(state) : state; }, })); // Mock config jest.mock('@/config/app.config', () => ({ __esModule: true, default: { api: { url: 'http://localhost:8000', }, debug: { api: false, }, demo: { enabled: false, }, }, })); // Mock EventSource class MockEventSource { static instances: MockEventSource[] = []; url: string; readyState: number = 0; onopen: ((event: Event) => void) | null = null; onmessage: ((event: MessageEvent) => void) | null = null; onerror: ((event: Event) => void) | null = null; constructor(url: string) { this.url = url; MockEventSource.instances.push(this); } close() { this.readyState = 2; // CLOSED } addEventListener() {} removeEventListener() {} // Test helpers simulateOpen() { this.readyState = 1; // OPEN if (this.onopen) { this.onopen(new Event('open')); } } simulateMessage(data: string) { if (this.onmessage) { this.onmessage(new MessageEvent('message', { data })); } } simulateError() { this.readyState = 2; // CLOSED if (this.onerror) { this.onerror(new Event('error')); } } } // Type augmentation for EventSource declare global { interface Window { EventSource: typeof MockEventSource; } } // @ts-expect-error - Mocking global EventSource global.EventSource = MockEventSource; /** * Helper to create mock event */ function createMockEvent(overrides: Partial = {}): ProjectEvent { return { id: `event-${Math.random().toString(36).substring(2, 11)}`, type: EventType.AGENT_MESSAGE, timestamp: new Date().toISOString(), project_id: 'project-123', actor_id: 'agent-456', actor_type: 'agent', payload: { message: 'Test message' }, ...overrides, }; } describe('useProjectEvents', () => { beforeEach(() => { jest.clearAllMocks(); MockEventSource.instances = []; // Reset event store useEventStore.setState({ eventsByProject: {}, maxEvents: 100, }); // Default auth state - authenticated mockUseAuth.mockReturnValue({ isAuthenticated: true, accessToken: 'test-access-token', }); }); describe('initialization', () => { it('should start disconnected', () => { const { result } = renderHook(() => useProjectEvents('project-123', { autoConnect: false })); expect(result.current.connectionState).toBe('disconnected'); expect(result.current.isConnected).toBe(false); expect(result.current.events).toEqual([]); }); it('should auto-connect when enabled and authenticated', async () => { renderHook(() => useProjectEvents('project-123')); await waitFor(() => { expect(MockEventSource.instances.length).toBeGreaterThan(0); }); const eventSource = MockEventSource.instances[0]; expect(eventSource.url).toContain('/api/v1/projects/project-123/events'); expect(eventSource.url).toContain('token=test-access-token'); }); it('should not connect when not authenticated', () => { mockUseAuth.mockReturnValue({ isAuthenticated: false, accessToken: null, }); renderHook(() => useProjectEvents('project-123')); expect(MockEventSource.instances).toHaveLength(0); }); it('should not connect when no project ID', () => { renderHook(() => useProjectEvents('')); expect(MockEventSource.instances).toHaveLength(0); }); }); describe('connection state', () => { it('should update to connected on open', async () => { const { result } = renderHook(() => useProjectEvents('project-123')); await waitFor(() => { expect(MockEventSource.instances.length).toBeGreaterThan(0); }); const eventSource = MockEventSource.instances[0]; act(() => { eventSource.simulateOpen(); }); await waitFor(() => { expect(result.current.connectionState).toBe('connected'); expect(result.current.isConnected).toBe(true); }); }); it('should update to error on connection error', async () => { const { result } = renderHook(() => useProjectEvents('project-123', { maxRetryAttempts: 1, }) ); await waitFor(() => { expect(MockEventSource.instances.length).toBeGreaterThan(0); }); const eventSource = MockEventSource.instances[0]; act(() => { eventSource.simulateError(); }); await waitFor(() => { expect(result.current.error).not.toBeNull(); }); }); it('should call onConnectionChange callback', async () => { const onConnectionChange = jest.fn(); renderHook(() => useProjectEvents('project-123', { onConnectionChange, }) ); await waitFor(() => { expect(MockEventSource.instances.length).toBeGreaterThan(0); }); // Should have been called with 'connecting' expect(onConnectionChange).toHaveBeenCalledWith('connecting'); const eventSource = MockEventSource.instances[0]; act(() => { eventSource.simulateOpen(); }); await waitFor(() => { expect(onConnectionChange).toHaveBeenCalledWith('connected'); }); }); }); describe('event handling', () => { it('should add events to store on message', async () => { const { result } = renderHook(() => useProjectEvents('project-123')); await waitFor(() => { expect(MockEventSource.instances.length).toBeGreaterThan(0); }); const eventSource = MockEventSource.instances[0]; act(() => { eventSource.simulateOpen(); }); const mockEvent = createMockEvent(); act(() => { eventSource.simulateMessage(JSON.stringify(mockEvent)); }); await waitFor(() => { expect(result.current.events).toHaveLength(1); expect(result.current.events[0].id).toBe(mockEvent.id); }); }); it('should call onEvent callback', async () => { const onEvent = jest.fn(); renderHook(() => useProjectEvents('project-123', { onEvent, }) ); await waitFor(() => { expect(MockEventSource.instances.length).toBeGreaterThan(0); }); const eventSource = MockEventSource.instances[0]; act(() => { eventSource.simulateOpen(); }); const mockEvent = createMockEvent(); act(() => { eventSource.simulateMessage(JSON.stringify(mockEvent)); }); await waitFor(() => { expect(onEvent).toHaveBeenCalledWith(expect.objectContaining({ id: mockEvent.id })); }); }); it('should ignore invalid JSON', async () => { const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); const { result } = renderHook(() => useProjectEvents('project-123')); await waitFor(() => { expect(MockEventSource.instances.length).toBeGreaterThan(0); }); const eventSource = MockEventSource.instances[0]; act(() => { eventSource.simulateOpen(); eventSource.simulateMessage('not valid json'); }); expect(result.current.events).toHaveLength(0); expect(consoleWarnSpy).toHaveBeenCalled(); consoleWarnSpy.mockRestore(); }); it('should ignore events with missing required fields', async () => { const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); const { result } = renderHook(() => useProjectEvents('project-123')); await waitFor(() => { expect(MockEventSource.instances.length).toBeGreaterThan(0); }); const eventSource = MockEventSource.instances[0]; act(() => { eventSource.simulateOpen(); eventSource.simulateMessage(JSON.stringify({ message: 'missing fields' })); }); expect(result.current.events).toHaveLength(0); expect(consoleWarnSpy).toHaveBeenCalled(); consoleWarnSpy.mockRestore(); }); }); describe('disconnect and reconnect', () => { it('should disconnect when called', async () => { const { result } = renderHook(() => useProjectEvents('project-123')); await waitFor(() => { expect(MockEventSource.instances.length).toBeGreaterThan(0); }); const initialInstanceCount = MockEventSource.instances.length; const eventSource = MockEventSource.instances[initialInstanceCount - 1]; act(() => { eventSource.simulateOpen(); }); await waitFor(() => { expect(result.current.isConnected).toBe(true); }); act(() => { result.current.disconnect(); }); // After disconnect, the connection state should be disconnected expect(result.current.connectionState).toBe('disconnected'); expect(result.current.isConnected).toBe(false); }); it('should reconnect when called', async () => { const { result } = renderHook(() => useProjectEvents('project-123')); await waitFor(() => { expect(MockEventSource.instances.length).toBeGreaterThan(0); }); act(() => { result.current.disconnect(); }); const instanceCountBeforeReconnect = MockEventSource.instances.length; act(() => { result.current.reconnect(); }); await waitFor(() => { expect(MockEventSource.instances.length).toBeGreaterThan(instanceCountBeforeReconnect); }); }); }); describe('clearEvents', () => { it('should clear events for the project', async () => { const { result } = renderHook(() => useProjectEvents('project-123')); await waitFor(() => { expect(MockEventSource.instances.length).toBeGreaterThan(0); }); const eventSource = MockEventSource.instances[0]; act(() => { eventSource.simulateOpen(); eventSource.simulateMessage(JSON.stringify(createMockEvent())); }); await waitFor(() => { expect(result.current.events).toHaveLength(1); }); act(() => { result.current.clearEvents(); }); expect(result.current.events).toHaveLength(0); }); }); describe('retry behavior', () => { it('should increment retry count on error', async () => { const { result } = renderHook(() => useProjectEvents('project-123', { maxRetryAttempts: 5, initialRetryDelay: 10, // Short delay for test }) ); await waitFor(() => { expect(MockEventSource.instances.length).toBeGreaterThan(0); }); const eventSource = MockEventSource.instances[0]; act(() => { eventSource.simulateError(); }); await waitFor(() => { expect(result.current.retryCount).toBeGreaterThanOrEqual(0); }); }); it('should call onError callback', async () => { const onError = jest.fn(); renderHook(() => useProjectEvents('project-123', { onError, maxRetryAttempts: 1, }) ); await waitFor(() => { expect(MockEventSource.instances.length).toBeGreaterThan(0); }); const eventSource = MockEventSource.instances[0]; act(() => { eventSource.simulateError(); }); await waitFor(() => { expect(onError).toHaveBeenCalled(); }); }); it('should stop retrying after max attempts reached', async () => { // This test verifies that when maxRetryAttempts is reached, the connection // state transitions to 'error' and stops attempting further retries const onConnectionChange = jest.fn(); const { result } = renderHook(() => useProjectEvents('project-123', { maxRetryAttempts: 1, // Only 1 attempt allowed onConnectionChange, }) ); await waitFor(() => { expect(MockEventSource.instances.length).toBeGreaterThan(0); }); // First connection - will fail const eventSource = MockEventSource.instances[MockEventSource.instances.length - 1]; // Simulate connection opening then error (which triggers retry count check) act(() => { eventSource.simulateError(); }); // After first error with maxRetryAttempts=1, when retry count reaches limit, // the connection should transition to error state await waitFor( () => { // Either error state reached or retry scheduled expect( result.current.connectionState === 'error' || result.current.retryCount >= 1 ).toBeTruthy(); }, { timeout: 5000 } ); }); it('should use custom initial retry delay', () => { // Test that custom options are accepted const { result } = renderHook(() => useProjectEvents('project-123', { initialRetryDelay: 5000, maxRetryDelay: 60000, maxRetryAttempts: 10, }) ); // Verify hook returns expected structure expect(result.current.retryCount).toBe(0); expect(result.current.reconnect).toBeDefined(); expect(result.current.disconnect).toBeDefined(); }); }); describe('cleanup', () => { it('should close connection on unmount', async () => { const { unmount } = renderHook(() => useProjectEvents('project-123')); await waitFor(() => { expect(MockEventSource.instances.length).toBeGreaterThan(0); }); const eventSource = MockEventSource.instances[0]; unmount(); expect(eventSource.readyState).toBe(2); // CLOSED }); }); describe('EventSource creation failure', () => { it('should handle EventSource constructor throwing', async () => { // Save original EventSource const OriginalEventSource = global.EventSource; // Mock EventSource to throw const ThrowingEventSource = function () { throw new Error('Network not available'); }; // @ts-expect-error - Mocking global EventSource global.EventSource = ThrowingEventSource; const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); const onError = jest.fn(); const { result } = renderHook(() => useProjectEvents('project-123', { onError, maxRetryAttempts: 1, }) ); await waitFor(() => { expect(onError).toHaveBeenCalled(); }); expect(result.current.connectionState).toBe('error'); expect(result.current.error?.code).toBe('CREATION_FAILED'); consoleErrorSpy.mockRestore(); global.EventSource = OriginalEventSource as typeof EventSource; }); }); describe('auth state changes', () => { it('should disconnect when authentication is lost', async () => { const { result, rerender } = renderHook(() => useProjectEvents('project-123')); await waitFor(() => { expect(MockEventSource.instances.length).toBeGreaterThan(0); }); const eventSource = MockEventSource.instances[MockEventSource.instances.length - 1]; act(() => { eventSource.simulateOpen(); }); await waitFor(() => { expect(result.current.isConnected).toBe(true); }); // Simulate auth loss mockUseAuth.mockReturnValue({ isAuthenticated: false, accessToken: null, }); // Rerender to trigger auth check rerender(); await waitFor(() => { expect(result.current.connectionState).toBe('disconnected'); }); }); it('should not connect when access token is missing', async () => { mockUseAuth.mockReturnValue({ isAuthenticated: true, accessToken: null, // Auth state but no token }); renderHook(() => useProjectEvents('project-123')); // Should not create any EventSource instances expect(MockEventSource.instances).toHaveLength(0); }); }); describe('ping event handling', () => { it('should handle ping events silently', async () => { const pingListeners: (() => void)[] = []; // Enhanced mock that tracks ping listeners class MockEventSourceWithPing extends MockEventSource { override addEventListener(type?: string, listener?: () => void) { if (type === 'ping' && listener) { pingListeners.push(listener); } } } const OriginalEventSource = global.EventSource; global.EventSource = MockEventSourceWithPing as unknown as typeof EventSource; renderHook(() => useProjectEvents('project-123')); await waitFor(() => { expect(MockEventSource.instances.length).toBeGreaterThan(0); }); // Ping listener should have been registered expect(pingListeners.length).toBeGreaterThan(0); // Call ping listener - should not throw expect(() => { pingListeners[0](); }).not.toThrow(); global.EventSource = OriginalEventSource as typeof EventSource; }); }); describe('debug mode logging', () => { it('should log when debug.api is enabled', async () => { // Re-mock config with debug enabled jest.doMock('@/config/app.config', () => ({ __esModule: true, default: { api: { url: 'http://localhost:8000', }, debug: { api: true, }, demo: { enabled: false, }, }, })); const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); renderHook(() => useProjectEvents('project-123')); await waitFor(() => { expect(MockEventSource.instances.length).toBeGreaterThan(0); }); // Note: Debug logging tested indirectly through branch coverage // The actual logging behavior depends on runtime config consoleLogSpy.mockRestore(); }); }); });