Files
syndarix/frontend/tests/components/activity/ActivityFeed.test.tsx
Felipe Cardoso a4c91cb8c3 refactor(frontend): clean up code by consolidating multi-line JSX into single lines where feasible
- Refactored JSX elements to improve readability by collapsing multi-line props and attributes into single lines if their length permits.
- Improved consistency in component imports by grouping and consolidating them.
- No functional changes, purely restructuring for clarity and maintainability.
2026-01-01 11:46:57 +01:00

501 lines
18 KiB
TypeScript

/**
* Tests for ActivityFeed Component
*
* Tests cover:
* - Rendering with events
* - Connection state indicator
* - Search functionality
* - Filter functionality
* - Event expansion
* - Approval actions
* - Time-based grouping
* - Loading state
* - Empty state
*/
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ActivityFeed } from '@/components/activity/ActivityFeed';
import { EventType, type ProjectEvent } from '@/lib/types/events';
// ============================================================================
// Test Data
// ============================================================================
const createMockEvent = (overrides: Partial<ProjectEvent> = {}): ProjectEvent => ({
id: `event-${Math.random().toString(36).substr(2, 9)}`,
type: EventType.AGENT_MESSAGE,
timestamp: new Date().toISOString(),
project_id: 'project-001',
actor_id: 'agent-001',
actor_type: 'agent',
payload: {
agent_instance_id: 'agent-001',
message: 'Test message',
message_type: 'info',
},
...overrides,
});
const mockEvents: ProjectEvent[] = [
// Today's events
createMockEvent({
id: 'event-001',
type: EventType.APPROVAL_REQUESTED,
timestamp: new Date().toISOString(),
payload: {
approval_id: 'apr-001',
approval_type: 'architecture_decision',
description: 'Approval required for API design',
requested_by: 'Architect',
},
}),
createMockEvent({
id: 'event-002',
type: EventType.AGENT_MESSAGE,
timestamp: new Date(Date.now() - 1000 * 60 * 30).toISOString(),
payload: {
agent_instance_id: 'agent-002',
message: 'Completed JWT implementation',
message_type: 'info',
},
}),
// Yesterday's event
createMockEvent({
id: 'event-003',
type: EventType.ISSUE_CREATED,
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(),
payload: {
issue_id: 'issue-001',
title: 'Add rate limiting',
priority: 'medium',
},
}),
// This week's event
createMockEvent({
id: 'event-004',
type: EventType.SPRINT_STARTED,
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3).toISOString(),
payload: {
sprint_id: 'sprint-001',
sprint_name: 'Sprint 1',
goal: 'Complete auth module',
},
}),
// Older event
createMockEvent({
id: 'event-005',
type: EventType.WORKFLOW_COMPLETED,
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24 * 10).toISOString(),
payload: {
workflow_id: 'wf-001',
duration_seconds: 3600,
},
}),
];
// ============================================================================
// Tests
// ============================================================================
describe('ActivityFeed', () => {
const defaultProps = {
events: mockEvents,
connectionState: 'connected' as const,
};
describe('Rendering', () => {
it('renders the activity feed with test id', () => {
render(<ActivityFeed {...defaultProps} />);
expect(screen.getByTestId('activity-feed')).toBeInTheDocument();
});
it('renders the header with title', () => {
render(<ActivityFeed {...defaultProps} />);
expect(screen.getByText('Activity Feed')).toBeInTheDocument();
});
it('renders custom title when provided', () => {
render(<ActivityFeed {...defaultProps} title="Project Activity" />);
expect(screen.getByText('Project Activity')).toBeInTheDocument();
});
it('hides header when showHeader is false', () => {
render(<ActivityFeed {...defaultProps} showHeader={false} />);
expect(screen.queryByText('Activity Feed')).not.toBeInTheDocument();
});
it('renders events', () => {
render(<ActivityFeed {...defaultProps} />);
expect(screen.getByTestId('event-item-event-001')).toBeInTheDocument();
expect(screen.getByTestId('event-item-event-002')).toBeInTheDocument();
});
it('applies custom className', () => {
render(<ActivityFeed {...defaultProps} className="custom-class" />);
expect(screen.getByTestId('activity-feed')).toHaveClass('custom-class');
});
});
describe('Connection State', () => {
it('renders connection indicator', () => {
render(<ActivityFeed {...defaultProps} />);
expect(screen.getByTestId('connection-indicator')).toBeInTheDocument();
});
it('shows "Live" when connected', () => {
render(<ActivityFeed {...defaultProps} connectionState="connected" />);
expect(screen.getByText('Live')).toBeInTheDocument();
});
it('shows "Connecting..." when connecting', () => {
render(<ActivityFeed {...defaultProps} connectionState="connecting" />);
expect(screen.getByText('Connecting...')).toBeInTheDocument();
});
it('shows "Disconnected" when disconnected', () => {
render(<ActivityFeed {...defaultProps} connectionState="disconnected" />);
expect(screen.getByText('Disconnected')).toBeInTheDocument();
});
it('shows "Error" when error state', () => {
render(<ActivityFeed {...defaultProps} connectionState="error" />);
expect(screen.getByText('Error')).toBeInTheDocument();
});
it('shows reconnect button when disconnected', () => {
const onReconnect = jest.fn();
render(
<ActivityFeed {...defaultProps} connectionState="disconnected" onReconnect={onReconnect} />
);
const reconnectButton = screen.getByLabelText('Reconnect');
expect(reconnectButton).toBeInTheDocument();
});
it('calls onReconnect when reconnect button clicked', async () => {
const user = userEvent.setup();
const onReconnect = jest.fn();
render(
<ActivityFeed {...defaultProps} connectionState="disconnected" onReconnect={onReconnect} />
);
await user.click(screen.getByLabelText('Reconnect'));
expect(onReconnect).toHaveBeenCalledTimes(1);
});
});
describe('Search Functionality', () => {
it('renders search input when enableSearch is true', () => {
render(<ActivityFeed {...defaultProps} enableSearch />);
expect(screen.getByTestId('search-input')).toBeInTheDocument();
});
it('hides search input when enableSearch is false', () => {
render(<ActivityFeed {...defaultProps} enableSearch={false} />);
expect(screen.queryByTestId('search-input')).not.toBeInTheDocument();
});
it('filters events based on search query', async () => {
const user = userEvent.setup();
render(<ActivityFeed {...defaultProps} enableSearch />);
const searchInput = screen.getByTestId('search-input');
await user.type(searchInput, 'JWT');
// Event with JWT in message should be visible
expect(screen.getByText(/Completed JWT implementation/)).toBeInTheDocument();
// Other events should be filtered out
expect(screen.queryByText(/Approval required for API design/)).not.toBeInTheDocument();
});
it('shows empty state when search finds no results', async () => {
const user = userEvent.setup();
render(<ActivityFeed {...defaultProps} enableSearch />);
const searchInput = screen.getByTestId('search-input');
await user.type(searchInput, 'nonexistent query xyz');
expect(screen.getByTestId('empty-state')).toBeInTheDocument();
expect(screen.getByText('No activity found')).toBeInTheDocument();
});
});
describe('Filter Functionality', () => {
it('renders filter toggle when enableFiltering is true', () => {
render(<ActivityFeed {...defaultProps} enableFiltering />);
expect(screen.getByTestId('filter-toggle')).toBeInTheDocument();
});
it('hides filter toggle when enableFiltering is false', () => {
render(<ActivityFeed {...defaultProps} enableFiltering={false} />);
expect(screen.queryByTestId('filter-toggle')).not.toBeInTheDocument();
});
it('shows filter panel when filter toggle is clicked', async () => {
const user = userEvent.setup();
render(<ActivityFeed {...defaultProps} enableFiltering />);
await user.click(screen.getByTestId('filter-toggle'));
expect(screen.getByTestId('filter-panel')).toBeInTheDocument();
});
it('filters events by category when filter is selected', async () => {
const user = userEvent.setup();
render(<ActivityFeed {...defaultProps} enableFiltering />);
// Open filter panel
await user.click(screen.getByTestId('filter-toggle'));
// Select Issues category
const issuesCheckbox = screen.getByLabelText(/Issues/);
await user.click(issuesCheckbox);
// Only issue events should be visible
expect(screen.getByText(/Add rate limiting/)).toBeInTheDocument();
// Agent events should be filtered out
expect(screen.queryByText(/Completed JWT implementation/)).not.toBeInTheDocument();
});
it('shows pending only when filter is selected', async () => {
const user = userEvent.setup();
render(<ActivityFeed {...defaultProps} enableFiltering />);
// Open filter panel
await user.click(screen.getByTestId('filter-toggle'));
// Select pending only
const pendingCheckbox = screen.getByLabelText(/Show only pending approvals/);
await user.click(pendingCheckbox);
// Only approval requested events should be visible
expect(screen.getByText(/Approval required for API design/)).toBeInTheDocument();
// Other events should be filtered out
expect(screen.queryByText(/Completed JWT implementation/)).not.toBeInTheDocument();
});
it('clears filters when Clear Filters is clicked', async () => {
const user = userEvent.setup();
render(<ActivityFeed {...defaultProps} enableFiltering enableSearch />);
// Add search query
await user.type(screen.getByTestId('search-input'), 'JWT');
// Open filter panel and select a filter
await user.click(screen.getByTestId('filter-toggle'));
await user.click(screen.getByLabelText(/Issues/));
// Clear filters
await user.click(screen.getByText('Clear Filters'));
// All events should be visible again
expect(screen.getByText(/Completed JWT implementation/)).toBeInTheDocument();
expect(screen.getByText(/Approval required for API design/)).toBeInTheDocument();
});
});
describe('Event Expansion', () => {
it('expands event details when clicked', async () => {
const user = userEvent.setup();
render(<ActivityFeed {...defaultProps} />);
const eventItem = screen.getByTestId('event-item-event-001');
await user.click(eventItem);
expect(screen.getByTestId('event-details')).toBeInTheDocument();
});
it('collapses event details when clicked again', async () => {
const user = userEvent.setup();
render(<ActivityFeed {...defaultProps} />);
const eventItem = screen.getByTestId('event-item-event-001');
// Expand
await user.click(eventItem);
expect(screen.getByTestId('event-details')).toBeInTheDocument();
// Collapse
await user.click(eventItem);
expect(screen.queryByTestId('event-details')).not.toBeInTheDocument();
});
it('shows raw payload in expanded details', async () => {
const user = userEvent.setup();
render(<ActivityFeed {...defaultProps} />);
const eventItem = screen.getByTestId('event-item-event-001');
await user.click(eventItem);
// Check for payload content
expect(screen.getByText(/View raw payload/)).toBeInTheDocument();
});
});
describe('Approval Actions', () => {
it('shows approve and reject buttons for pending approvals', () => {
render(<ActivityFeed {...defaultProps} onApprove={jest.fn()} onReject={jest.fn()} />);
const eventItem = screen.getByTestId('event-item-event-001');
expect(within(eventItem).getByTestId('approve-button')).toBeInTheDocument();
expect(within(eventItem).getByTestId('reject-button')).toBeInTheDocument();
});
it('does not show action buttons for non-approval events', () => {
render(<ActivityFeed {...defaultProps} onApprove={jest.fn()} onReject={jest.fn()} />);
const eventItem = screen.getByTestId('event-item-event-002');
expect(within(eventItem).queryByTestId('approve-button')).not.toBeInTheDocument();
});
it('calls onApprove when approve button clicked', async () => {
const user = userEvent.setup();
const onApprove = jest.fn();
render(<ActivityFeed {...defaultProps} onApprove={onApprove} />);
const eventItem = screen.getByTestId('event-item-event-001');
await user.click(within(eventItem).getByTestId('approve-button'));
expect(onApprove).toHaveBeenCalledTimes(1);
expect(onApprove).toHaveBeenCalledWith(expect.objectContaining({ id: 'event-001' }));
});
it('calls onReject when reject button clicked', async () => {
const user = userEvent.setup();
const onReject = jest.fn();
render(<ActivityFeed {...defaultProps} onReject={onReject} />);
const eventItem = screen.getByTestId('event-item-event-001');
await user.click(within(eventItem).getByTestId('reject-button'));
expect(onReject).toHaveBeenCalledTimes(1);
expect(onReject).toHaveBeenCalledWith(expect.objectContaining({ id: 'event-001' }));
});
it('shows pending count badge', () => {
render(<ActivityFeed {...defaultProps} />);
expect(screen.getByText('1 pending')).toBeInTheDocument();
});
});
describe('Time-Based Grouping', () => {
it('groups events by time period', () => {
render(<ActivityFeed {...defaultProps} />);
// Check for time period headers
expect(screen.getByTestId('event-group-today')).toBeInTheDocument();
});
it('shows event count in group header', () => {
render(<ActivityFeed {...defaultProps} />);
const todayGroup = screen.getByTestId('event-group-today');
// Today has 2 events in our mock data
expect(within(todayGroup).getByText('2')).toBeInTheDocument();
});
});
describe('Loading State', () => {
it('shows loading skeleton when isLoading is true', () => {
render(<ActivityFeed {...defaultProps} isLoading />);
expect(screen.getByTestId('loading-skeleton')).toBeInTheDocument();
});
it('hides events when loading', () => {
render(<ActivityFeed {...defaultProps} isLoading />);
expect(screen.queryByTestId('event-item-event-001')).not.toBeInTheDocument();
});
});
describe('Empty State', () => {
it('shows empty state when no events', () => {
render(<ActivityFeed {...defaultProps} events={[]} />);
expect(screen.getByTestId('empty-state')).toBeInTheDocument();
});
it('shows appropriate message when no events and no filters', () => {
render(<ActivityFeed {...defaultProps} events={[]} />);
expect(screen.getByText(/Activity will appear here/)).toBeInTheDocument();
});
it('shows appropriate message when filtered to empty', async () => {
const user = userEvent.setup();
render(<ActivityFeed {...defaultProps} enableSearch />);
await user.type(screen.getByTestId('search-input'), 'nonexistent');
expect(screen.getByText(/Try adjusting your search or filters/)).toBeInTheDocument();
});
});
describe('Event Click Handler', () => {
it('calls onEventClick when event is clicked', async () => {
const user = userEvent.setup();
const onEventClick = jest.fn();
render(<ActivityFeed {...defaultProps} onEventClick={onEventClick} />);
await user.click(screen.getByTestId('event-item-event-001'));
expect(onEventClick).toHaveBeenCalledTimes(1);
expect(onEventClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'event-001' }));
});
});
describe('Compact Mode', () => {
it('applies compact styling when compact is true', () => {
render(<ActivityFeed {...defaultProps} compact />);
// Check for compact-specific styling (p-2 instead of p-4)
const eventItem = screen.getByTestId('event-item-event-001');
// The event item should have compact padding
expect(eventItem).toHaveClass('p-2');
});
});
describe('Accessibility', () => {
it('has proper ARIA labels for interactive elements', () => {
render(
<ActivityFeed {...defaultProps} onReconnect={jest.fn()} connectionState="disconnected" />
);
expect(screen.getByLabelText('Reconnect')).toBeInTheDocument();
});
it('event items are keyboard accessible', async () => {
const user = userEvent.setup();
render(<ActivityFeed {...defaultProps} />);
const eventItem = screen.getByTestId('event-item-event-001');
// Focus and activate with keyboard
eventItem.focus();
await user.keyboard('{Enter}');
expect(screen.getByTestId('event-details')).toBeInTheDocument();
});
it('renders semantic HTML structure', () => {
render(<ActivityFeed {...defaultProps} />);
// Check for proper heading hierarchy
const heading = screen.getByText('Today');
expect(heading.tagName).toBe('H3');
});
});
describe('Max Height', () => {
it('applies max height styling', () => {
const { container } = render(<ActivityFeed {...defaultProps} maxHeight={500} />);
const scrollContainer = container.querySelector('.overflow-y-auto');
expect(scrollContainer).toHaveStyle({ maxHeight: '500px' });
});
it('handles string max height', () => {
const { container } = render(<ActivityFeed {...defaultProps} maxHeight="auto" />);
const scrollContainer = container.querySelector('.overflow-y-auto');
expect(scrollContainer).toHaveStyle({ maxHeight: 'auto' });
});
});
});