Some checks failed
System Monitoring / Health Checks (push) Has been cancelled
System Monitoring / Performance Monitoring (push) Has been cancelled
System Monitoring / Database Monitoring (push) Has been cancelled
System Monitoring / Cache Monitoring (push) Has been cancelled
System Monitoring / Log Monitoring (push) Has been cancelled
System Monitoring / Resource Monitoring (push) Has been cancelled
System Monitoring / Uptime Monitoring (push) Has been cancelled
System Monitoring / Backup Monitoring (push) Has been cancelled
System Monitoring / Security Monitoring (push) Has been cancelled
System Monitoring / Monitoring Dashboard (push) Has been cancelled
System Monitoring / Alerting (push) Has been cancelled
Security Scanning / Dependency Scanning (push) Has been cancelled
Security Scanning / Code Security Scanning (push) Has been cancelled
Security Scanning / Secrets Scanning (push) Has been cancelled
Security Scanning / Container Security Scanning (push) Has been cancelled
Security Scanning / Compliance Checking (push) Has been cancelled
Security Scanning / Security Dashboard (push) Has been cancelled
Security Scanning / Security Remediation (push) Has been cancelled
598 lines
18 KiB
TypeScript
598 lines
18 KiB
TypeScript
/**
|
|
* Frontend Component Tests - Dashboard Components
|
|
*
|
|
* Tests for dashboard-related components:
|
|
* - DashboardLayout
|
|
* - StatsCard
|
|
* - ChartComponent
|
|
* - ActivityFeed
|
|
* - NotificationPanel
|
|
*
|
|
* Author: Claude
|
|
*/
|
|
|
|
import React from 'react';
|
|
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
|
import '@testing-library/jest-dom';
|
|
import { BrowserRouter } from 'react-router-dom';
|
|
|
|
// Mock components for testing
|
|
const StatsCard = ({ title, value, change, changeType, icon }) => (
|
|
<div data-testid="stats-card">
|
|
<div data-testid="card-title">{title}</div>
|
|
<div data-testid="card-value">{value}</div>
|
|
{change && (
|
|
<div data-testid={`card-change-${changeType}`}>
|
|
{change > 0 ? '+' : ''}{change}%
|
|
</div>
|
|
)}
|
|
{icon && <div data-testid="card-icon">{icon}</div>}
|
|
</div>
|
|
);
|
|
|
|
const ChartComponent = ({ data, type, height = 300 }) => (
|
|
<div data-testid="chart-component">
|
|
<div data-testid="chart-type">{type}</div>
|
|
<div data-testid="chart-height">{height}</div>
|
|
<div data-testid="chart-data-points">{data?.length || 0}</div>
|
|
</div>
|
|
);
|
|
|
|
const ActivityFeed = ({ activities, loading }) => (
|
|
<div data-testid="activity-feed">
|
|
{loading ? (
|
|
<div data-testid="loading-spinner">Loading...</div>
|
|
) : (
|
|
<div data-testid="activities-list">
|
|
{activities?.map((activity, index) => (
|
|
<div key={index} data-testid={`activity-${index}`}>
|
|
<div data-testid="activity-type">{activity.type}</div>
|
|
<div data-testid="activity-description">{activity.description}</div>
|
|
<div data-testid="activity-time">{activity.timestamp}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
const NotificationPanel = ({ notifications, onMarkAsRead, onDismiss }) => (
|
|
<div data-testid="notification-panel">
|
|
<div data-testid="notifications-header">Notifications</div>
|
|
<div data-testid="notifications-list">
|
|
{notifications?.map((notification, index) => (
|
|
<div key={index} data-testid={`notification-${index}`}>
|
|
<div data-testid="notification-title">{notification.title}</div>
|
|
<div data-testid="notification-message">{notification.message}</div>
|
|
<div data-testid="notification-type">{notification.type}</div>
|
|
<div data-testid="notification-read">{notification.read ? 'Read' : 'Unread'}</div>
|
|
{!notification.read && (
|
|
<button
|
|
data-testid={`mark-read-${index}`}
|
|
onClick={() => onMarkAsRead?.(notification.id)}
|
|
>
|
|
Mark as Read
|
|
</button>
|
|
)}
|
|
<button
|
|
data-testid={`dismiss-${index}`}
|
|
onClick={() => onDismiss?.(notification.id)}
|
|
>
|
|
Dismiss
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{(!notifications || notifications.length === 0) && (
|
|
<div data-testid="no-notifications">No notifications</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
// Test data
|
|
const mockActivities = [
|
|
{
|
|
id: 1,
|
|
type: 'user_created',
|
|
description: 'New user registered: John Doe',
|
|
timestamp: '2024-01-15T10:30:00Z',
|
|
user: { name: 'John Doe' }
|
|
},
|
|
{
|
|
id: 2,
|
|
type: 'payment_processed',
|
|
description: 'Payment of RM299.00 processed',
|
|
timestamp: '2024-01-15T09:15:00Z',
|
|
amount: 299.00
|
|
},
|
|
{
|
|
id: 3,
|
|
type: 'login_attempt',
|
|
description: 'Failed login attempt from unknown device',
|
|
timestamp: '2024-01-15T08:00:00Z',
|
|
successful: false
|
|
}
|
|
];
|
|
|
|
const mockNotifications = [
|
|
{
|
|
id: 1,
|
|
title: 'Welcome!',
|
|
message: 'Welcome to the platform',
|
|
type: 'info',
|
|
read: false,
|
|
timestamp: '2024-01-15T10:00:00Z'
|
|
},
|
|
{
|
|
id: 2,
|
|
title: 'Payment Received',
|
|
message: 'Your subscription payment was successful',
|
|
type: 'success',
|
|
read: true,
|
|
timestamp: '2024-01-14T15:30:00Z'
|
|
},
|
|
{
|
|
id: 3,
|
|
title: 'Security Alert',
|
|
message: 'New login detected from unknown device',
|
|
type: 'warning',
|
|
read: false,
|
|
timestamp: '2024-01-14T12:00:00Z'
|
|
}
|
|
];
|
|
|
|
const mockChartData = [
|
|
{ month: 'Jan', value: 1000 },
|
|
{ month: 'Feb', value: 1200 },
|
|
{ month: 'Mar', value: 1100 },
|
|
{ month: 'Apr', value: 1400 },
|
|
{ month: 'May', value: 1300 },
|
|
{ month: 'Jun', value: 1500 }
|
|
];
|
|
|
|
describe('Dashboard Components', () => {
|
|
describe('StatsCard', () => {
|
|
test('renders stats card with basic props', () => {
|
|
render(
|
|
<StatsCard
|
|
title="Total Users"
|
|
value="1,234"
|
|
change={12.5}
|
|
changeType="positive"
|
|
icon="👥"
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTestId('stats-card')).toBeInTheDocument();
|
|
expect(screen.getByTestId('card-title')).toHaveTextContent('Total Users');
|
|
expect(screen.getByTestId('card-value')).toHaveTextContent('1,234');
|
|
expect(screen.getByTestId('card-change-positive')).toHaveTextContent('+12.5%');
|
|
expect(screen.getByTestId('card-icon')).toHaveTextContent('👥');
|
|
});
|
|
|
|
test('renders stats card with negative change', () => {
|
|
render(
|
|
<StatsCard
|
|
title="Bounce Rate"
|
|
value="45.2%"
|
|
change={-3.2}
|
|
changeType="negative"
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTestId('card-change-negative')).toHaveTextContent('-3.2%');
|
|
});
|
|
|
|
test('renders stats card without change', () => {
|
|
render(
|
|
<StatsCard
|
|
title="Total Revenue"
|
|
value="RM 45,000"
|
|
/>
|
|
);
|
|
|
|
expect(screen.queryByTestId(/card-change-/)).not.toBeInTheDocument();
|
|
});
|
|
|
|
test('formats large numbers correctly', () => {
|
|
const testCases = [
|
|
{ input: '1000000', expected: '1,000,000' },
|
|
{ input: '2500000.50', expected: '2,500,000.50' },
|
|
{ input: '1234567890', expected: '1,234,567,890' }
|
|
];
|
|
|
|
testCases.forEach(({ input, expected }) => {
|
|
render(<StatsCard title="Test" value={input} />);
|
|
expect(screen.getByTestId('card-value')).toHaveTextContent(expected);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('ChartComponent', () => {
|
|
test('renders chart with data', () => {
|
|
render(
|
|
<ChartComponent
|
|
data={mockChartData}
|
|
type="line"
|
|
height={400}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTestId('chart-component')).toBeInTheDocument();
|
|
expect(screen.getByTestId('chart-type')).toHaveTextContent('line');
|
|
expect(screen.getByTestId('chart-height')).toHaveTextContent('400');
|
|
expect(screen.getByTestId('chart-data-points')).toHaveTextContent('6');
|
|
});
|
|
|
|
test('renders chart without data', () => {
|
|
render(
|
|
<ChartComponent
|
|
data={[]}
|
|
type="bar"
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTestId('chart-data-points')).toHaveTextContent('0');
|
|
});
|
|
|
|
test('renders chart with null data', () => {
|
|
render(
|
|
<ChartComponent
|
|
data={null}
|
|
type="pie"
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTestId('chart-data-points')).toHaveTextContent('0');
|
|
});
|
|
|
|
test('supports different chart types', () => {
|
|
const chartTypes = ['line', 'bar', 'pie', 'area', 'scatter'];
|
|
|
|
chartTypes.forEach(type => {
|
|
const { unmount } = render(
|
|
<ChartComponent
|
|
data={mockChartData}
|
|
type={type}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTestId('chart-type')).toHaveTextContent(type);
|
|
unmount();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('ActivityFeed', () => {
|
|
test('renders activity feed with activities', () => {
|
|
const mockOnActivityClick = jest.fn();
|
|
|
|
render(
|
|
<ActivityFeed
|
|
activities={mockActivities}
|
|
loading={false}
|
|
onActivityClick={mockOnActivityClick}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTestId('activity-feed')).toBeInTheDocument();
|
|
expect(screen.getByTestId('activities-list')).toBeInTheDocument();
|
|
expect(screen.getAllByTestId(/activity-/)).toHaveLength(3);
|
|
|
|
// Check first activity
|
|
expect(screen.getByTestId('activity-0')).toBeInTheDocument();
|
|
expect(screen.getByTestId('activity-type')).toHaveTextContent('user_created');
|
|
expect(screen.getByTestId('activity-description')).toHaveTextContent('New user registered: John Doe');
|
|
});
|
|
|
|
test('renders loading state', () => {
|
|
render(
|
|
<ActivityFeed
|
|
activities={[]}
|
|
loading={true}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
|
|
expect(screen.queryByTestId('activities-list')).not.toBeInTheDocument();
|
|
});
|
|
|
|
test('renders empty state', () => {
|
|
render(
|
|
<ActivityFeed
|
|
activities={[]}
|
|
loading={false}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTestId('activities-list')).toBeInTheDocument();
|
|
expect(screen.queryByTestId(/activity-/)).not.toBeInTheDocument();
|
|
});
|
|
|
|
test('formats timestamps correctly', () => {
|
|
const formatTimestamp = (timestamp) => {
|
|
const date = new Date(timestamp);
|
|
const now = new Date();
|
|
const diffMs = now - date;
|
|
const diffMinutes = Math.floor(diffMs / 60000);
|
|
const diffHours = Math.floor(diffMs / 3600000);
|
|
const diffDays = Math.floor(diffMs / 86400000);
|
|
|
|
if (diffMinutes < 1) return 'Just now';
|
|
if (diffMinutes < 60) return `${diffMinutes}m ago`;
|
|
if (diffHours < 24) return `${diffHours}h ago`;
|
|
if (diffDays < 7) return `${diffDays}d ago`;
|
|
return date.toLocaleDateString();
|
|
};
|
|
|
|
const recent = new Date().toISOString();
|
|
const hourAgo = new Date(Date.now() - 3600000).toISOString();
|
|
const dayAgo = new Date(Date.now() - 86400000).toISOString();
|
|
|
|
expect(formatTimestamp(recent)).toMatch(/Just now|\d+m ago/);
|
|
expect(formatTimestamp(hourAgo)).toMatch(/\d+h ago/);
|
|
expect(formatTimestamp(dayAgo)).toMatch(/\d+d ago/);
|
|
});
|
|
|
|
test('handles activity click', () => {
|
|
const mockOnActivityClick = jest.fn();
|
|
|
|
render(
|
|
<ActivityFeed
|
|
activities={mockActivities}
|
|
loading={false}
|
|
onActivityClick={mockOnActivityClick}
|
|
/>
|
|
);
|
|
|
|
const firstActivity = screen.getByTestId('activity-0');
|
|
fireEvent.click(firstActivity);
|
|
|
|
expect(mockOnActivityClick).toHaveBeenCalledWith(mockActivities[0]);
|
|
});
|
|
});
|
|
|
|
describe('NotificationPanel', () => {
|
|
const mockOnMarkAsRead = jest.fn();
|
|
const mockOnDismiss = jest.fn();
|
|
|
|
beforeEach(() => {
|
|
mockOnMarkAsRead.mockClear();
|
|
mockOnDismiss.mockClear();
|
|
});
|
|
|
|
test('renders notification panel with notifications', () => {
|
|
render(
|
|
<NotificationPanel
|
|
notifications={mockNotifications}
|
|
onMarkAsRead={mockOnMarkAsRead}
|
|
onDismiss={mockOnDismiss}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTestId('notification-panel')).toBeInTheDocument();
|
|
expect(screen.getByTestId('notifications-header')).toHaveTextContent('Notifications');
|
|
expect(screen.getAllByTestId(/notification-/)).toHaveLength(3);
|
|
});
|
|
|
|
test('renders empty state', () => {
|
|
render(
|
|
<NotificationPanel
|
|
notifications={[]}
|
|
onMarkAsRead={mockOnMarkAsRead}
|
|
onDismiss={mockOnDismiss}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTestId('no-notifications')).toBeInTheDocument();
|
|
expect(screen.getByTestId('no-notifications')).toHaveTextContent('No notifications');
|
|
});
|
|
|
|
test('handles mark as read action', async () => {
|
|
render(
|
|
<NotificationPanel
|
|
notifications={mockNotifications}
|
|
onMarkAsRead={mockOnMarkAsRead}
|
|
onDismiss={mockOnDismiss}
|
|
/>
|
|
);
|
|
|
|
const firstNotification = screen.getByTestId('notification-0');
|
|
const markReadButton = screen.getByTestId('mark-read-0');
|
|
|
|
await act(async () => {
|
|
fireEvent.click(markReadButton);
|
|
});
|
|
|
|
expect(mockOnMarkAsRead).toHaveBeenCalledWith(mockNotifications[0].id);
|
|
});
|
|
|
|
test('handles dismiss action', async () => {
|
|
render(
|
|
<NotificationPanel
|
|
notifications={mockNotifications}
|
|
onMarkAsRead={mockOnMarkAsRead}
|
|
onDismiss={mockOnDismiss}
|
|
/>
|
|
);
|
|
|
|
const dismissButton = screen.getByTestId('dismiss-0');
|
|
|
|
await act(async () => {
|
|
fireEvent.click(dismissButton);
|
|
});
|
|
|
|
expect(mockOnDismiss).toHaveBeenCalledWith(mockNotifications[0].id);
|
|
});
|
|
|
|
test('shows read/unread status correctly', () => {
|
|
render(
|
|
<NotificationPanel
|
|
notifications={mockNotifications}
|
|
onMarkAsRead={mockOnMarkAsRead}
|
|
onDismiss={mockOnDismiss}
|
|
/>
|
|
);
|
|
|
|
// First notification should be unread
|
|
expect(screen.getByTestId('notification-0')).toHaveTextContent('Unread');
|
|
expect(screen.getByTestId('mark-read-0')).toBeInTheDocument();
|
|
|
|
// Second notification should be read
|
|
expect(screen.getByTestId('notification-1')).toHaveTextContent('Read');
|
|
expect(screen.queryByTestId('mark-read-1')).not.toBeInTheDocument();
|
|
});
|
|
|
|
test('shows different notification types', () => {
|
|
const typeNotifications = [
|
|
{ id: 1, title: 'Info', message: 'Info message', type: 'info', read: false },
|
|
{ id: 2, title: 'Success', message: 'Success message', type: 'success', read: false },
|
|
{ id: 3, title: 'Warning', message: 'Warning message', type: 'warning', read: false },
|
|
{ id: 4, title: 'Error', message: 'Error message', type: 'error', read: false }
|
|
];
|
|
|
|
render(
|
|
<NotificationPanel
|
|
notifications={typeNotifications}
|
|
onMarkAsRead={jest.fn()}
|
|
onDismiss={jest.fn()}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getAllByTestId(/notification-/)).toHaveLength(4);
|
|
expect(screen.getByTestId('notification-0')).toHaveTextContent('info');
|
|
expect(screen.getByTestId('notification-1')).toHaveTextContent('success');
|
|
expect(screen.getByTestId('notification-2')).toHaveTextContent('warning');
|
|
expect(screen.getByTestId('notification-3')).toHaveTextContent('error');
|
|
});
|
|
});
|
|
|
|
describe('Dashboard Integration', () => {
|
|
test('components work together in dashboard layout', () => {
|
|
const DashboardLayout = ({ children }) => (
|
|
<div data-testid="dashboard-layout">
|
|
<div data-testid="sidebar">Sidebar</div>
|
|
<div data-testid="main-content">
|
|
<div data-testid="header">Header</div>
|
|
<div data-testid="dashboard-content">
|
|
{children}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
render(
|
|
<DashboardLayout>
|
|
<StatsCard title="Test Stat" value="100" />
|
|
<ChartComponent data={mockChartData} type="line" />
|
|
<ActivityFeed activities={mockActivities} loading={false} />
|
|
<NotificationPanel notifications={mockNotifications} />
|
|
</DashboardLayout>
|
|
);
|
|
|
|
expect(screen.getByTestId('dashboard-layout')).toBeInTheDocument();
|
|
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
|
|
expect(screen.getByTestId('main-content')).toBeInTheDocument();
|
|
expect(screen.getByTestId('header')).toBeInTheDocument();
|
|
expect(screen.getByTestId('dashboard-content')).toBeInTheDocument();
|
|
expect(screen.getByTestId('stats-card')).toBeInTheDocument();
|
|
expect(screen.getByTestId('chart-component')).toBeInTheDocument();
|
|
expect(screen.getByTestId('activity-feed')).toBeInTheDocument();
|
|
expect(screen.getByTestId('notification-panel')).toBeInTheDocument();
|
|
});
|
|
|
|
test('handles responsive behavior', () => {
|
|
// Test mobile viewport
|
|
Object.defineProperty(window, 'innerWidth', {
|
|
writable: true,
|
|
configurable: true,
|
|
value: 375,
|
|
});
|
|
|
|
render(
|
|
<div>
|
|
<StatsCard title="Mobile Test" value="100" />
|
|
<ChartComponent data={mockChartData} type="line" height={200} />
|
|
</div>
|
|
);
|
|
|
|
expect(screen.getByTestId('stats-card')).toBeInTheDocument();
|
|
expect(screen.getByTestId('chart-component')).toBeInTheDocument();
|
|
expect(screen.getByTestId('chart-height')).toHaveTextContent('200');
|
|
});
|
|
});
|
|
|
|
describe('Performance Considerations', () => {
|
|
test('handles large datasets efficiently', () => {
|
|
const largeDataset = Array.from({ length: 1000 }, (_, i) => ({
|
|
month: `Month ${i}`,
|
|
value: Math.floor(Math.random() * 1000)
|
|
}));
|
|
|
|
const startTime = performance.now();
|
|
render(<ChartComponent data={largeDataset} type="line" />);
|
|
const endTime = performance.now();
|
|
|
|
// Should render within 100ms for 1000 data points
|
|
expect(endTime - startTime).toBeLessThan(100);
|
|
expect(screen.getByTestId('chart-data-points')).toHaveTextContent('1000');
|
|
});
|
|
|
|
test('debounces rapid interactions', () => {
|
|
jest.useFakeTimers();
|
|
|
|
const mockOnChange = jest.fn();
|
|
const { rerender } = render(
|
|
<StatsCard
|
|
title="Debounce Test"
|
|
value="100"
|
|
onChange={mockOnChange}
|
|
/>
|
|
);
|
|
|
|
// Simulate rapid changes
|
|
for (let i = 0; i < 10; i++) {
|
|
rerender(
|
|
<StatsCard
|
|
title="Debounce Test"
|
|
value={i.toString()}
|
|
onChange={mockOnChange}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Fast-forward timer
|
|
act(() => {
|
|
jest.advanceTimersByTime(500);
|
|
});
|
|
|
|
// Should only call once after debouncing
|
|
expect(mockOnChange).toHaveBeenCalledTimes(1);
|
|
|
|
jest.useRealTimers();
|
|
});
|
|
});
|
|
|
|
describe('Error Handling', () => {
|
|
test('handles missing props gracefully', () => {
|
|
render(<StatsCard />);
|
|
|
|
expect(screen.getByTestId('stats-card')).toBeInTheDocument();
|
|
expect(screen.getByTestId('card-title')).toBeInTheDocument();
|
|
expect(screen.getByTestId('card-value')).toBeInTheDocument();
|
|
});
|
|
|
|
test('handles invalid data types', () => {
|
|
const invalidData = [
|
|
{ month: 'Jan', value: 'invalid' },
|
|
{ month: 'Feb', value: null },
|
|
{ month: 'Mar', value: undefined }
|
|
];
|
|
|
|
render(<ChartComponent data={invalidData} type="line" />);
|
|
|
|
expect(screen.getByTestId('chart-component')).toBeInTheDocument();
|
|
expect(screen.getByTestId('chart-data-points')).toHaveTextContent('3');
|
|
});
|
|
});
|
|
}); |