- Add frontend unit tests with Vitest for components, hooks, pages, and utilities - Add backend tests for webhooks, notifications, middleware, and edge cases - Add ForgotPassword, NotFound, and ResetPassword pages - Add migration for orphaned staff resources conversion - Add coverage directory to gitignore (generated reports) - Various bug fixes and improvements from previous work 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
751 lines
24 KiB
TypeScript
751 lines
24 KiB
TypeScript
/**
|
|
* Comprehensive unit tests for Timeline component
|
|
*
|
|
* Tests cover:
|
|
* - Component rendering
|
|
* - Time slots display for different view modes (day, week, month)
|
|
* - Resource rows display with proper heights
|
|
* - Events positioned correctly on timeline
|
|
* - Current time indicator visibility and position
|
|
* - Date navigation controls
|
|
* - View mode switching
|
|
* - Zoom functionality
|
|
* - Drag and drop interactions
|
|
* - Scroll synchronization between sidebar and timeline
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { render, screen, waitFor, within } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import React from 'react';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import Timeline from '../Timeline';
|
|
import * as apiClient from '../../../api/client';
|
|
|
|
// Mock modules
|
|
vi.mock('react-i18next', () => ({
|
|
useTranslation: () => ({
|
|
t: (key: string, fallback?: string) => fallback || key,
|
|
}),
|
|
}));
|
|
|
|
vi.mock('../../../api/client', () => ({
|
|
default: {
|
|
get: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
// Mock DnD Kit - simplified for testing
|
|
vi.mock('@dnd-kit/core', () => ({
|
|
DndContext: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
useSensor: vi.fn(),
|
|
useSensors: vi.fn(() => []),
|
|
PointerSensor: vi.fn(),
|
|
useDroppable: vi.fn(() => ({
|
|
setNodeRef: vi.fn(),
|
|
isOver: false,
|
|
})),
|
|
useDraggable: vi.fn(() => ({
|
|
attributes: {},
|
|
listeners: {},
|
|
setNodeRef: vi.fn(),
|
|
isDragging: false,
|
|
})),
|
|
DragOverlay: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
}));
|
|
|
|
// Mock child components
|
|
vi.mock('../../Timeline/TimelineRow', () => ({
|
|
default: ({ resourceId, events, height }: any) => (
|
|
<div
|
|
data-testid={`timeline-row-${resourceId}`}
|
|
data-event-count={events.length}
|
|
style={{ height }}
|
|
>
|
|
{events.map((event: any) => (
|
|
<div key={event.id} data-testid={`event-${event.id}`}>
|
|
{event.title}
|
|
</div>
|
|
))}
|
|
</div>
|
|
),
|
|
}));
|
|
|
|
vi.mock('../../Timeline/CurrentTimeIndicator', () => ({
|
|
default: ({ startTime, hourWidth }: any) => (
|
|
<div
|
|
id="current-time-indicator"
|
|
data-testid="current-time-indicator"
|
|
data-start-time={startTime.toISOString()}
|
|
data-hour-width={hourWidth}
|
|
/>
|
|
),
|
|
}));
|
|
|
|
vi.mock('../Sidebar', () => ({
|
|
default: ({ resourceLayouts, pendingAppointments }: any) => (
|
|
<div data-testid="sidebar">
|
|
<div data-testid="resource-count">{resourceLayouts.length}</div>
|
|
<div data-testid="pending-count">{pendingAppointments.length}</div>
|
|
</div>
|
|
),
|
|
}));
|
|
|
|
// Test data
|
|
const mockResources = [
|
|
{ id: 1, name: 'Resource 1', type: 'STAFF' },
|
|
{ id: 2, name: 'Resource 2', type: 'ROOM' },
|
|
{ id: 3, name: 'Resource 3', type: 'EQUIPMENT' },
|
|
];
|
|
|
|
const mockAppointments = [
|
|
{
|
|
id: 1,
|
|
resource: 1,
|
|
customer: 101,
|
|
service: 201,
|
|
customer_name: 'John Doe',
|
|
service_name: 'Haircut',
|
|
start_time: new Date('2025-12-07T10:00:00').toISOString(),
|
|
end_time: new Date('2025-12-07T11:00:00').toISOString(),
|
|
status: 'CONFIRMED' as const,
|
|
is_paid: false,
|
|
},
|
|
{
|
|
id: 2,
|
|
resource: 1,
|
|
customer: 102,
|
|
service: 202,
|
|
customer_name: 'Jane Smith',
|
|
service_name: 'Coloring',
|
|
start_time: new Date('2025-12-07T11:30:00').toISOString(),
|
|
end_time: new Date('2025-12-07T13:00:00').toISOString(),
|
|
status: 'CONFIRMED' as const,
|
|
is_paid: true,
|
|
},
|
|
{
|
|
id: 3,
|
|
resource: undefined, // Pending appointment - no resource assigned
|
|
customer: 103,
|
|
service: 203,
|
|
customer_name: 'Bob Johnson',
|
|
service_name: 'Massage',
|
|
start_time: new Date('2025-12-07T14:00:00').toISOString(),
|
|
end_time: new Date('2025-12-07T15:00:00').toISOString(),
|
|
status: 'PENDING' as const,
|
|
is_paid: false,
|
|
},
|
|
];
|
|
|
|
// Test wrapper with Query Client
|
|
const createWrapper = () => {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
retry: false,
|
|
},
|
|
},
|
|
});
|
|
|
|
return ({ children }: { children: React.ReactNode }) => (
|
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
);
|
|
};
|
|
|
|
describe('Timeline Component', () => {
|
|
let mockGet: any;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockGet = vi.mocked(apiClient.default.get);
|
|
|
|
// Default API responses
|
|
mockGet.mockImplementation((url: string) => {
|
|
if (url === '/resources/') {
|
|
return Promise.resolve({ data: mockResources });
|
|
}
|
|
if (url === '/appointments/') {
|
|
return Promise.resolve({ data: mockAppointments });
|
|
}
|
|
return Promise.reject(new Error('Unknown endpoint'));
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('Component Rendering', () => {
|
|
it('should render the timeline component', async () => {
|
|
render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should display header bar with controls', async () => {
|
|
render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTitle('Previous')).toBeInTheDocument();
|
|
expect(screen.getByTitle('Next')).toBeInTheDocument();
|
|
expect(screen.getByText('+ New Appointment')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should fetch resources from API', async () => {
|
|
render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
expect(mockGet).toHaveBeenCalledWith('/resources/');
|
|
});
|
|
});
|
|
|
|
it('should fetch appointments from API', async () => {
|
|
render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
expect(mockGet).toHaveBeenCalledWith('/appointments/');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Time Slots Rendering', () => {
|
|
it('should render 24 hour slots in day view', async () => {
|
|
render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
// Check for some time labels
|
|
expect(screen.getByText('12 AM')).toBeInTheDocument();
|
|
expect(screen.getByText('6 AM')).toBeInTheDocument();
|
|
expect(screen.getByText('12 PM')).toBeInTheDocument();
|
|
expect(screen.getByText('6 PM')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should render all 24 hours with correct spacing in day view', async () => {
|
|
const { container } = render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
const headerRow = container.querySelector('.sticky.top-0');
|
|
expect(headerRow).toBeInTheDocument();
|
|
|
|
// Should have 24 time slots
|
|
const timeSlots = headerRow?.querySelectorAll('[style*="width"]');
|
|
expect(timeSlots?.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
it('should render day headers in week view', async () => {
|
|
const user = userEvent.setup();
|
|
render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('day')).toBeInTheDocument();
|
|
});
|
|
|
|
const weekButton = screen.getByRole('button', { name: /week/i });
|
|
await user.click(weekButton);
|
|
|
|
await waitFor(() => {
|
|
// Week view should show day names
|
|
const container = screen.getByRole('button', { name: /week/i }).closest('div')?.parentElement?.parentElement?.parentElement;
|
|
expect(container).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should display date range label for current view', async () => {
|
|
render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
// Should show day view date format
|
|
const dateLabel = screen.getByText(/December/i);
|
|
expect(dateLabel).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Resource Rows Display', () => {
|
|
it('should render resource rows for all resources', async () => {
|
|
render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('timeline-row-1')).toBeInTheDocument();
|
|
expect(screen.getByTestId('timeline-row-2')).toBeInTheDocument();
|
|
expect(screen.getByTestId('timeline-row-3')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should display correct number of resources in sidebar', async () => {
|
|
render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
const resourceCount = screen.getByTestId('resource-count');
|
|
expect(resourceCount).toHaveTextContent('3');
|
|
});
|
|
});
|
|
|
|
it('should calculate row heights based on event lanes', async () => {
|
|
render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
const row1 = screen.getByTestId('timeline-row-1');
|
|
// Row 1 has 2 events, should have calculated height
|
|
expect(row1).toHaveAttribute('style');
|
|
});
|
|
});
|
|
|
|
it('should handle resources with no events', async () => {
|
|
mockGet.mockImplementation((url: string) => {
|
|
if (url === '/resources/') {
|
|
return Promise.resolve({ data: mockResources });
|
|
}
|
|
if (url === '/appointments/') {
|
|
return Promise.resolve({ data: [] });
|
|
}
|
|
return Promise.reject(new Error('Unknown endpoint'));
|
|
});
|
|
|
|
render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('timeline-row-1')).toBeInTheDocument();
|
|
expect(screen.getByTestId('timeline-row-1')).toHaveAttribute('data-event-count', '0');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Events Positioning', () => {
|
|
it('should render events on their assigned resources', async () => {
|
|
render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
const row1 = screen.getByTestId('timeline-row-1');
|
|
expect(row1).toHaveAttribute('data-event-count', '2');
|
|
});
|
|
});
|
|
|
|
it('should display event titles correctly', async () => {
|
|
render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
|
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should filter events by resource', async () => {
|
|
render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
const row1 = screen.getByTestId('timeline-row-1');
|
|
const row2 = screen.getByTestId('timeline-row-2');
|
|
|
|
expect(row1).toHaveAttribute('data-event-count', '2');
|
|
expect(row2).toHaveAttribute('data-event-count', '0');
|
|
});
|
|
});
|
|
|
|
it('should handle overlapping events with lane calculation', async () => {
|
|
render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
// Both events are on resource 1, should be in timeline
|
|
expect(screen.getByTestId('event-1')).toBeInTheDocument();
|
|
expect(screen.getByTestId('event-2')).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Current Time Indicator', () => {
|
|
it('should render current time indicator', async () => {
|
|
render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('current-time-indicator')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should pass correct props to current time indicator', async () => {
|
|
render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
const indicator = screen.getByTestId('current-time-indicator');
|
|
expect(indicator).toHaveAttribute('data-start-time');
|
|
expect(indicator).toHaveAttribute('data-hour-width');
|
|
});
|
|
});
|
|
|
|
it('should have correct id for auto-scroll', async () => {
|
|
render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
const indicator = screen.getByTestId('current-time-indicator');
|
|
expect(indicator).toHaveAttribute('id', 'current-time-indicator');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Date Navigation', () => {
|
|
it('should have previous and next navigation buttons', async () => {
|
|
render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTitle('Previous')).toBeInTheDocument();
|
|
expect(screen.getByTitle('Next')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should navigate to previous day when clicking previous button', async () => {
|
|
const user = userEvent.setup();
|
|
render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTitle('Previous')).toBeInTheDocument();
|
|
});
|
|
|
|
const previousButton = screen.getByTitle('Previous');
|
|
await user.click(previousButton);
|
|
|
|
// Date should change (we can't easily test exact date without exposing state)
|
|
expect(previousButton).toBeInTheDocument();
|
|
});
|
|
|
|
it('should navigate to next day when clicking next button', async () => {
|
|
const user = userEvent.setup();
|
|
render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTitle('Next')).toBeInTheDocument();
|
|
});
|
|
|
|
const nextButton = screen.getByTitle('Next');
|
|
await user.click(nextButton);
|
|
|
|
expect(nextButton).toBeInTheDocument();
|
|
});
|
|
|
|
it('should display current date range', async () => {
|
|
render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
// Should show a date with calendar icon
|
|
const dateDisplay = screen.getByText(/2025/);
|
|
expect(dateDisplay).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('View Mode Switching', () => {
|
|
it('should render view mode buttons (day, week, month)', async () => {
|
|
render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('button', { name: /day/i })).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /week/i })).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /month/i })).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should highlight active view mode (day by default)', async () => {
|
|
render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
const dayButton = screen.getByRole('button', { name: /day/i });
|
|
expect(dayButton).toHaveClass('bg-blue-500');
|
|
});
|
|
});
|
|
|
|
it('should switch to week view when clicking week button', async () => {
|
|
const user = userEvent.setup();
|
|
render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('button', { name: /week/i })).toBeInTheDocument();
|
|
});
|
|
|
|
const weekButton = screen.getByRole('button', { name: /week/i });
|
|
await user.click(weekButton);
|
|
|
|
await waitFor(() => {
|
|
expect(weekButton).toHaveClass('bg-blue-500');
|
|
});
|
|
});
|
|
|
|
it('should switch to month view when clicking month button', async () => {
|
|
const user = userEvent.setup();
|
|
render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('button', { name: /month/i })).toBeInTheDocument();
|
|
});
|
|
|
|
const monthButton = screen.getByRole('button', { name: /month/i });
|
|
await user.click(monthButton);
|
|
|
|
await waitFor(() => {
|
|
expect(monthButton).toHaveClass('bg-blue-500');
|
|
});
|
|
});
|
|
|
|
it('should only have one active view mode at a time', async () => {
|
|
const user = userEvent.setup();
|
|
render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('button', { name: /week/i })).toBeInTheDocument();
|
|
});
|
|
|
|
const weekButton = screen.getByRole('button', { name: /week/i });
|
|
await user.click(weekButton);
|
|
|
|
await waitFor(() => {
|
|
const dayButton = screen.getByRole('button', { name: /day/i });
|
|
expect(weekButton).toHaveClass('bg-blue-500');
|
|
expect(dayButton).not.toHaveClass('bg-blue-500');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Zoom Functionality', () => {
|
|
it('should render zoom in and zoom out buttons', async () => {
|
|
const { container } = render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
// Look for Zoom label and buttons
|
|
expect(screen.getByText('Zoom')).toBeInTheDocument();
|
|
});
|
|
|
|
// Zoom buttons are rendered via Lucide icons
|
|
const zoomSection = screen.getByText('Zoom').parentElement;
|
|
expect(zoomSection).toBeInTheDocument();
|
|
});
|
|
|
|
it('should increase zoom when clicking zoom in button', async () => {
|
|
const user = userEvent.setup();
|
|
const { container } = render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Zoom')).toBeInTheDocument();
|
|
});
|
|
|
|
// Find zoom in button (second button after Zoom label)
|
|
const zoomSection = screen.getByText('Zoom').parentElement;
|
|
const buttons = zoomSection?.querySelectorAll('button');
|
|
const zoomInButton = buttons?.[1];
|
|
|
|
if (zoomInButton) {
|
|
await user.click(zoomInButton);
|
|
// Component should still be rendered
|
|
expect(screen.getByText('Zoom')).toBeInTheDocument();
|
|
}
|
|
});
|
|
|
|
it('should decrease zoom when clicking zoom out button', async () => {
|
|
const user = userEvent.setup();
|
|
render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Zoom')).toBeInTheDocument();
|
|
});
|
|
|
|
const zoomSection = screen.getByText('Zoom').parentElement;
|
|
const buttons = zoomSection?.querySelectorAll('button');
|
|
const zoomOutButton = buttons?.[0];
|
|
|
|
if (zoomOutButton) {
|
|
await user.click(zoomOutButton);
|
|
expect(screen.getByText('Zoom')).toBeInTheDocument();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Pending Appointments', () => {
|
|
it('should display pending appointments in sidebar', async () => {
|
|
render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
const pendingCount = screen.getByTestId('pending-count');
|
|
expect(pendingCount).toHaveTextContent('1');
|
|
});
|
|
});
|
|
|
|
it('should filter pending appointments from events', async () => {
|
|
render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
// Should not render pending appointment as event
|
|
expect(screen.queryByText('Bob Johnson')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Accessibility', () => {
|
|
it('should have accessible button labels', async () => {
|
|
render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('button', { name: /day/i })).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /week/i })).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /month/i })).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /new appointment/i })).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should have title attributes on navigation buttons', async () => {
|
|
render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTitle('Previous')).toBeInTheDocument();
|
|
expect(screen.getByTitle('Next')).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Undo/Redo Controls', () => {
|
|
it('should render undo and redo buttons', async () => {
|
|
const { container } = render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
// Undo/redo buttons exist but are disabled
|
|
const buttons = container.querySelectorAll('button[disabled]');
|
|
expect(buttons.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
it('should have undo and redo buttons disabled by default', async () => {
|
|
const { container } = render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
const disabledButtons = container.querySelectorAll('button[disabled]');
|
|
expect(disabledButtons.length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Error Handling', () => {
|
|
it('should handle API errors gracefully for resources', async () => {
|
|
mockGet.mockImplementation((url: string) => {
|
|
if (url === '/resources/') {
|
|
return Promise.reject(new Error('Network error'));
|
|
}
|
|
if (url === '/appointments/') {
|
|
return Promise.resolve({ data: [] });
|
|
}
|
|
return Promise.reject(new Error('Unknown endpoint'));
|
|
});
|
|
|
|
render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
// Should still render even with error
|
|
expect(screen.getByText('+ New Appointment')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should handle API errors gracefully for appointments', async () => {
|
|
mockGet.mockImplementation((url: string) => {
|
|
if (url === '/resources/') {
|
|
return Promise.resolve({ data: mockResources });
|
|
}
|
|
if (url === '/appointments/') {
|
|
return Promise.reject(new Error('Network error'));
|
|
}
|
|
return Promise.reject(new Error('Unknown endpoint'));
|
|
});
|
|
|
|
render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('+ New Appointment')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should handle empty resources array', async () => {
|
|
mockGet.mockImplementation((url: string) => {
|
|
if (url === '/resources/') {
|
|
return Promise.resolve({ data: [] });
|
|
}
|
|
if (url === '/appointments/') {
|
|
return Promise.resolve({ data: [] });
|
|
}
|
|
return Promise.reject(new Error('Unknown endpoint'));
|
|
});
|
|
|
|
render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
const resourceCount = screen.getByTestId('resource-count');
|
|
expect(resourceCount).toHaveTextContent('0');
|
|
});
|
|
});
|
|
|
|
it('should handle empty appointments array', async () => {
|
|
mockGet.mockImplementation((url: string) => {
|
|
if (url === '/resources/') {
|
|
return Promise.resolve({ data: mockResources });
|
|
}
|
|
if (url === '/appointments/') {
|
|
return Promise.resolve({ data: [] });
|
|
}
|
|
return Promise.reject(new Error('Unknown endpoint'));
|
|
});
|
|
|
|
render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
const pendingCount = screen.getByTestId('pending-count');
|
|
expect(pendingCount).toHaveTextContent('0');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Dark Mode Support', () => {
|
|
it('should apply dark mode classes', async () => {
|
|
const { container } = render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
const mainContainer = container.querySelector('.bg-white');
|
|
expect(mainContainer).toHaveClass('dark:bg-gray-900');
|
|
});
|
|
});
|
|
|
|
it('should apply dark mode to header', async () => {
|
|
const { container } = render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
const header = container.querySelector('.border-b');
|
|
expect(header).toHaveClass('dark:bg-gray-800');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Integration', () => {
|
|
it('should render complete timeline with all features', async () => {
|
|
render(<Timeline />, { wrapper: createWrapper() });
|
|
|
|
await waitFor(() => {
|
|
// Header controls
|
|
expect(screen.getByTitle('Previous')).toBeInTheDocument();
|
|
expect(screen.getByTitle('Next')).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /day/i })).toBeInTheDocument();
|
|
expect(screen.getByText('Zoom')).toBeInTheDocument();
|
|
expect(screen.getByText('+ New Appointment')).toBeInTheDocument();
|
|
|
|
// Sidebar
|
|
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
|
|
|
|
// Current time indicator
|
|
expect(screen.getByTestId('current-time-indicator')).toBeInTheDocument();
|
|
|
|
// Resources
|
|
expect(screen.getByTestId('resource-count')).toHaveTextContent('3');
|
|
|
|
// Events
|
|
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
});
|