forked from cardosofelipe/fast-next-template
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>
256 lines
8.5 KiB
TypeScript
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');
|
|
});
|
|
});
|
|
});
|