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>
This commit is contained in:
2025-12-30 01:34:41 +01:00
parent d6db6af964
commit fcda8f0f96
14 changed files with 3138 additions and 0 deletions

View File

@@ -0,0 +1,475 @@
/**
* 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).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
});
});
});

View File

@@ -0,0 +1,255 @@
/**
* 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');
});
});
});

View File

@@ -0,0 +1,167 @@
/**
* Tests for Event Types and Type Guards
*/
import {
EventType,
type ProjectEvent,
isEventType,
isAgentEvent,
isIssueEvent,
isSprintEvent,
isApprovalEvent,
isWorkflowEvent,
isProjectEvent,
} from '@/lib/types/events';
/**
* Helper to create mock event
*/
function createMockEvent(overrides: Partial<ProjectEvent> = {}): ProjectEvent {
return {
id: 'event-123',
type: EventType.AGENT_MESSAGE,
timestamp: new Date().toISOString(),
project_id: 'project-123',
actor_id: 'agent-456',
actor_type: 'agent',
payload: {},
...overrides,
};
}
describe('Event Types', () => {
describe('EventType enum', () => {
it('should have correct agent event values', () => {
expect(EventType.AGENT_SPAWNED).toBe('agent.spawned');
expect(EventType.AGENT_STATUS_CHANGED).toBe('agent.status_changed');
expect(EventType.AGENT_MESSAGE).toBe('agent.message');
expect(EventType.AGENT_TERMINATED).toBe('agent.terminated');
});
it('should have correct issue event values', () => {
expect(EventType.ISSUE_CREATED).toBe('issue.created');
expect(EventType.ISSUE_UPDATED).toBe('issue.updated');
expect(EventType.ISSUE_ASSIGNED).toBe('issue.assigned');
expect(EventType.ISSUE_CLOSED).toBe('issue.closed');
});
it('should have correct sprint event values', () => {
expect(EventType.SPRINT_STARTED).toBe('sprint.started');
expect(EventType.SPRINT_COMPLETED).toBe('sprint.completed');
});
it('should have correct approval event values', () => {
expect(EventType.APPROVAL_REQUESTED).toBe('approval.requested');
expect(EventType.APPROVAL_GRANTED).toBe('approval.granted');
expect(EventType.APPROVAL_DENIED).toBe('approval.denied');
});
it('should have correct workflow event values', () => {
expect(EventType.WORKFLOW_STARTED).toBe('workflow.started');
expect(EventType.WORKFLOW_STEP_COMPLETED).toBe('workflow.step_completed');
expect(EventType.WORKFLOW_COMPLETED).toBe('workflow.completed');
expect(EventType.WORKFLOW_FAILED).toBe('workflow.failed');
});
it('should have correct project event values', () => {
expect(EventType.PROJECT_CREATED).toBe('project.created');
expect(EventType.PROJECT_UPDATED).toBe('project.updated');
expect(EventType.PROJECT_ARCHIVED).toBe('project.archived');
});
});
describe('isEventType', () => {
it('should return true for matching event type', () => {
const event = createMockEvent({ type: EventType.AGENT_MESSAGE });
expect(isEventType(event, EventType.AGENT_MESSAGE)).toBe(true);
});
it('should return false for non-matching event type', () => {
const event = createMockEvent({ type: EventType.AGENT_MESSAGE });
expect(isEventType(event, EventType.ISSUE_CREATED)).toBe(false);
});
});
describe('isAgentEvent', () => {
it('should return true for agent events', () => {
expect(isAgentEvent(createMockEvent({ type: EventType.AGENT_SPAWNED }))).toBe(true);
expect(isAgentEvent(createMockEvent({ type: EventType.AGENT_MESSAGE }))).toBe(true);
expect(isAgentEvent(createMockEvent({ type: EventType.AGENT_STATUS_CHANGED }))).toBe(true);
expect(isAgentEvent(createMockEvent({ type: EventType.AGENT_TERMINATED }))).toBe(true);
});
it('should return false for non-agent events', () => {
expect(isAgentEvent(createMockEvent({ type: EventType.ISSUE_CREATED }))).toBe(false);
expect(isAgentEvent(createMockEvent({ type: EventType.SPRINT_STARTED }))).toBe(false);
});
});
describe('isIssueEvent', () => {
it('should return true for issue events', () => {
expect(isIssueEvent(createMockEvent({ type: EventType.ISSUE_CREATED }))).toBe(true);
expect(isIssueEvent(createMockEvent({ type: EventType.ISSUE_UPDATED }))).toBe(true);
expect(isIssueEvent(createMockEvent({ type: EventType.ISSUE_ASSIGNED }))).toBe(true);
expect(isIssueEvent(createMockEvent({ type: EventType.ISSUE_CLOSED }))).toBe(true);
});
it('should return false for non-issue events', () => {
expect(isIssueEvent(createMockEvent({ type: EventType.AGENT_MESSAGE }))).toBe(false);
expect(isIssueEvent(createMockEvent({ type: EventType.SPRINT_STARTED }))).toBe(false);
});
});
describe('isSprintEvent', () => {
it('should return true for sprint events', () => {
expect(isSprintEvent(createMockEvent({ type: EventType.SPRINT_STARTED }))).toBe(true);
expect(isSprintEvent(createMockEvent({ type: EventType.SPRINT_COMPLETED }))).toBe(true);
});
it('should return false for non-sprint events', () => {
expect(isSprintEvent(createMockEvent({ type: EventType.AGENT_MESSAGE }))).toBe(false);
expect(isSprintEvent(createMockEvent({ type: EventType.ISSUE_CREATED }))).toBe(false);
});
});
describe('isApprovalEvent', () => {
it('should return true for approval events', () => {
expect(isApprovalEvent(createMockEvent({ type: EventType.APPROVAL_REQUESTED }))).toBe(true);
expect(isApprovalEvent(createMockEvent({ type: EventType.APPROVAL_GRANTED }))).toBe(true);
expect(isApprovalEvent(createMockEvent({ type: EventType.APPROVAL_DENIED }))).toBe(true);
});
it('should return false for non-approval events', () => {
expect(isApprovalEvent(createMockEvent({ type: EventType.AGENT_MESSAGE }))).toBe(false);
expect(isApprovalEvent(createMockEvent({ type: EventType.WORKFLOW_STARTED }))).toBe(false);
});
});
describe('isWorkflowEvent', () => {
it('should return true for workflow events', () => {
expect(isWorkflowEvent(createMockEvent({ type: EventType.WORKFLOW_STARTED }))).toBe(true);
expect(isWorkflowEvent(createMockEvent({ type: EventType.WORKFLOW_STEP_COMPLETED }))).toBe(
true
);
expect(isWorkflowEvent(createMockEvent({ type: EventType.WORKFLOW_COMPLETED }))).toBe(true);
expect(isWorkflowEvent(createMockEvent({ type: EventType.WORKFLOW_FAILED }))).toBe(true);
});
it('should return false for non-workflow events', () => {
expect(isWorkflowEvent(createMockEvent({ type: EventType.AGENT_MESSAGE }))).toBe(false);
expect(isWorkflowEvent(createMockEvent({ type: EventType.SPRINT_STARTED }))).toBe(false);
});
});
describe('isProjectEvent', () => {
it('should return true for project events', () => {
expect(isProjectEvent(createMockEvent({ type: EventType.PROJECT_CREATED }))).toBe(true);
expect(isProjectEvent(createMockEvent({ type: EventType.PROJECT_UPDATED }))).toBe(true);
expect(isProjectEvent(createMockEvent({ type: EventType.PROJECT_ARCHIVED }))).toBe(true);
});
it('should return false for non-project events', () => {
expect(isProjectEvent(createMockEvent({ type: EventType.AGENT_MESSAGE }))).toBe(false);
expect(isProjectEvent(createMockEvent({ type: EventType.ISSUE_CREATED }))).toBe(false);
});
});
});