/** * 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, }, }, })); // 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).substr(2, 9)}`, 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(); }); }); }); 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 }); }); });