Files
syndarix/frontend/tests/lib/stores/eventStore.test.ts
Felipe Cardoso fcda8f0f96 feat(frontend): Implement client-side SSE handling (#35)
Implements real-time event streaming on the frontend with:

- Event types and type guards matching backend EventType enum
- Zustand-based event store with per-project buffering
- useProjectEvents hook with auto-reconnection and exponential backoff
- ConnectionStatus component showing connection state
- EventList component with expandable payloads and filtering

All 105 tests passing. Follows design system guidelines.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 01:34:41 +01:00

256 lines
8.5 KiB
TypeScript

/**
* Tests for Event Store
*/
import { useEventStore } from '@/lib/stores/eventStore';
import { EventType, type ProjectEvent } from '@/lib/types/events';
/**
* Helper to create mock event
*/
function createMockEvent(overrides: Partial<ProjectEvent> = {}): 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('Event Store', () => {
beforeEach(() => {
// Reset store state
useEventStore.setState({
eventsByProject: {},
maxEvents: 100,
});
});
describe('addEvent', () => {
it('should add an event to the store', () => {
const event = createMockEvent();
useEventStore.getState().addEvent(event);
const events = useEventStore.getState().getProjectEvents('project-123');
expect(events).toHaveLength(1);
expect(events[0]).toEqual(event);
});
it('should add events to correct project', () => {
const event1 = createMockEvent({ project_id: 'project-1' });
const event2 = createMockEvent({ project_id: 'project-2' });
useEventStore.getState().addEvent(event1);
useEventStore.getState().addEvent(event2);
expect(useEventStore.getState().getProjectEvents('project-1')).toHaveLength(1);
expect(useEventStore.getState().getProjectEvents('project-2')).toHaveLength(1);
});
it('should skip duplicate event IDs', () => {
const event = createMockEvent({ id: 'unique-id' });
useEventStore.getState().addEvent(event);
useEventStore.getState().addEvent(event);
const events = useEventStore.getState().getProjectEvents('project-123');
expect(events).toHaveLength(1);
});
it('should trim events to maxEvents limit', () => {
useEventStore.getState().setMaxEvents(5);
// Add 10 events
for (let i = 0; i < 10; i++) {
useEventStore.getState().addEvent(createMockEvent({ id: `event-${i}` }));
}
const events = useEventStore.getState().getProjectEvents('project-123');
expect(events).toHaveLength(5);
// Should keep the last 5 events
expect(events[0].id).toBe('event-5');
expect(events[4].id).toBe('event-9');
});
});
describe('addEvents', () => {
it('should add multiple events at once', () => {
const events = [
createMockEvent({ id: 'event-1' }),
createMockEvent({ id: 'event-2' }),
createMockEvent({ id: 'event-3' }),
];
useEventStore.getState().addEvents(events);
const storedEvents = useEventStore.getState().getProjectEvents('project-123');
expect(storedEvents).toHaveLength(3);
});
it('should handle empty array', () => {
useEventStore.getState().addEvents([]);
const events = useEventStore.getState().getProjectEvents('project-123');
expect(events).toHaveLength(0);
});
it('should filter out duplicate events', () => {
const existingEvent = createMockEvent({ id: 'existing-event' });
useEventStore.getState().addEvent(existingEvent);
const newEvents = [
createMockEvent({ id: 'existing-event' }), // Duplicate
createMockEvent({ id: 'new-event-1' }),
createMockEvent({ id: 'new-event-2' }),
];
useEventStore.getState().addEvents(newEvents);
const storedEvents = useEventStore.getState().getProjectEvents('project-123');
expect(storedEvents).toHaveLength(3);
});
it('should add events to multiple projects', () => {
const events = [
createMockEvent({ id: 'event-1', project_id: 'project-1' }),
createMockEvent({ id: 'event-2', project_id: 'project-2' }),
createMockEvent({ id: 'event-3', project_id: 'project-1' }),
];
useEventStore.getState().addEvents(events);
expect(useEventStore.getState().getProjectEvents('project-1')).toHaveLength(2);
expect(useEventStore.getState().getProjectEvents('project-2')).toHaveLength(1);
});
});
describe('clearProjectEvents', () => {
it('should clear events for a specific project', () => {
const event1 = createMockEvent({ project_id: 'project-1' });
const event2 = createMockEvent({ project_id: 'project-2' });
useEventStore.getState().addEvent(event1);
useEventStore.getState().addEvent(event2);
useEventStore.getState().clearProjectEvents('project-1');
expect(useEventStore.getState().getProjectEvents('project-1')).toHaveLength(0);
expect(useEventStore.getState().getProjectEvents('project-2')).toHaveLength(1);
});
it('should handle clearing non-existent project', () => {
expect(() => {
useEventStore.getState().clearProjectEvents('non-existent');
}).not.toThrow();
});
});
describe('clearAllEvents', () => {
it('should clear all events from all projects', () => {
const event1 = createMockEvent({ project_id: 'project-1' });
const event2 = createMockEvent({ project_id: 'project-2' });
useEventStore.getState().addEvent(event1);
useEventStore.getState().addEvent(event2);
useEventStore.getState().clearAllEvents();
expect(useEventStore.getState().getProjectEvents('project-1')).toHaveLength(0);
expect(useEventStore.getState().getProjectEvents('project-2')).toHaveLength(0);
});
});
describe('getProjectEvents', () => {
it('should return empty array for non-existent project', () => {
const events = useEventStore.getState().getProjectEvents('non-existent');
expect(events).toEqual([]);
});
it('should return events for existing project', () => {
const event = createMockEvent();
useEventStore.getState().addEvent(event);
const events = useEventStore.getState().getProjectEvents('project-123');
expect(events).toHaveLength(1);
expect(events[0]).toEqual(event);
});
});
describe('getFilteredEvents', () => {
it('should filter events by type', () => {
const agentEvent = createMockEvent({ type: EventType.AGENT_MESSAGE });
const issueEvent = createMockEvent({ type: EventType.ISSUE_CREATED });
const sprintEvent = createMockEvent({ type: EventType.SPRINT_STARTED });
useEventStore.getState().addEvents([agentEvent, issueEvent, sprintEvent]);
const filtered = useEventStore.getState().getFilteredEvents('project-123', [
EventType.AGENT_MESSAGE,
EventType.ISSUE_CREATED,
]);
expect(filtered).toHaveLength(2);
expect(filtered.map((e) => e.type)).toContain(EventType.AGENT_MESSAGE);
expect(filtered.map((e) => e.type)).toContain(EventType.ISSUE_CREATED);
});
it('should return all events when types array is empty', () => {
const event1 = createMockEvent({ type: EventType.AGENT_MESSAGE });
const event2 = createMockEvent({ type: EventType.ISSUE_CREATED });
useEventStore.getState().addEvents([event1, event2]);
const filtered = useEventStore.getState().getFilteredEvents('project-123', []);
expect(filtered).toHaveLength(2);
});
it('should return empty array for non-existent project', () => {
const filtered = useEventStore.getState().getFilteredEvents('non-existent', [
EventType.AGENT_MESSAGE,
]);
expect(filtered).toEqual([]);
});
});
describe('setMaxEvents', () => {
it('should update maxEvents setting', () => {
useEventStore.getState().setMaxEvents(50);
expect(useEventStore.getState().maxEvents).toBe(50);
});
it('should use default for invalid values', () => {
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
useEventStore.getState().setMaxEvents(0);
expect(useEventStore.getState().maxEvents).toBe(100); // Default
useEventStore.getState().setMaxEvents(-5);
expect(useEventStore.getState().maxEvents).toBe(100); // Default
expect(consoleWarnSpy).toHaveBeenCalled();
consoleWarnSpy.mockRestore();
});
it('should trim existing events when reducing maxEvents', () => {
// Add 10 events
for (let i = 0; i < 10; i++) {
useEventStore.getState().addEvent(createMockEvent({ id: `event-${i}` }));
}
expect(useEventStore.getState().getProjectEvents('project-123')).toHaveLength(10);
useEventStore.getState().setMaxEvents(5);
const events = useEventStore.getState().getProjectEvents('project-123');
expect(events).toHaveLength(5);
// Should keep the last 5 events
expect(events[0].id).toBe('event-5');
});
});
});