forked from cardosofelipe/fast-next-template
- Raise coverage thresholds to 90% statements/lines/functions, 85% branches - Add comprehensive tests for ProjectDashboard, ProjectWizard, and all wizard steps - Add tests for issue management: IssueDetailPanel, BulkActions, IssueFilters - Expand IssueTable tests with keyboard navigation, dropdown menu, edge cases - Add useIssues hook tests covering all mutations and optimistic updates - Expand eventStore tests with selector hooks and additional scenarios - Expand useProjectEvents tests with error recovery, ping events, edge cases - Add PriorityBadge, StatusBadge, SyncStatusIndicator fallback branch tests - Add constants.test.ts for comprehensive constant validation Bug fixes: - Fix false positive rollback test to properly verify onMutate context setup - Replace deprecated substr() with substring() in mock helpers - Fix type errors: ProjectComplexity, ClientMode enum values - Fix unused imports and variables across test files - Fix @ts-expect-error directives and method override signatures 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
672 lines
18 KiB
TypeScript
672 lines
18 KiB
TypeScript
/**
|
|
* 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> = {}): 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,
|
|
},
|
|
},
|
|
}));
|
|
|
|
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();
|
|
});
|
|
});
|
|
});
|