Add staff permission controls for editing staff and customers
- Add can_edit_staff and can_edit_customers dangerous permissions - Move Site Builder, Services, Locations, Time Blocks, Payments to Settings permissions - Link Edit Others' Schedules and Edit Own Schedule permissions - Add permission checks to StaffViewSet (partial_update, toggle_active, verify_email) - Add permission checks to CustomerViewSet (update, partial_update, verify_email) - Fix CustomerViewSet permission key mismatch (can_access_customers) - Hide Edit/Verify buttons on Staff and Customers pages without permission - Make dangerous permissions section more visually distinct (darker red) - Fix StaffDashboard links to use correct paths (/dashboard/my-schedule) - Disable settings sub-permissions when Access Settings is unchecked 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, act } from '@testing-library/react';
|
||||
import CurrentTimeIndicator from '../CurrentTimeIndicator';
|
||||
|
||||
describe('CurrentTimeIndicator', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders the current time indicator', () => {
|
||||
const startTime = new Date('2024-01-01T08:00:00');
|
||||
const now = new Date('2024-01-01T10:00:00');
|
||||
vi.setSystemTime(now);
|
||||
|
||||
render(<CurrentTimeIndicator startTime={startTime} hourWidth={100} />);
|
||||
|
||||
const indicator = document.querySelector('#current-time-indicator');
|
||||
expect(indicator).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays the current time', () => {
|
||||
const startTime = new Date('2024-01-01T08:00:00');
|
||||
const now = new Date('2024-01-01T10:30:00');
|
||||
vi.setSystemTime(now);
|
||||
|
||||
render(<CurrentTimeIndicator startTime={startTime} hourWidth={100} />);
|
||||
|
||||
expect(screen.getByText('10:30 AM')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calculates correct position based on time difference', () => {
|
||||
const startTime = new Date('2024-01-01T08:00:00');
|
||||
const now = new Date('2024-01-01T10:00:00'); // 2 hours after start
|
||||
vi.setSystemTime(now);
|
||||
|
||||
render(<CurrentTimeIndicator startTime={startTime} hourWidth={100} />);
|
||||
|
||||
const indicator = document.querySelector('#current-time-indicator');
|
||||
expect(indicator).toHaveStyle({ left: '200px' }); // 2 hours * 100px
|
||||
});
|
||||
|
||||
it('does not render when current time is before start time', () => {
|
||||
const startTime = new Date('2024-01-01T10:00:00');
|
||||
const now = new Date('2024-01-01T08:00:00'); // Before start time
|
||||
vi.setSystemTime(now);
|
||||
|
||||
render(<CurrentTimeIndicator startTime={startTime} hourWidth={100} />);
|
||||
|
||||
const indicator = document.querySelector('#current-time-indicator');
|
||||
expect(indicator).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates position every minute', () => {
|
||||
const startTime = new Date('2024-01-01T08:00:00');
|
||||
const initialTime = new Date('2024-01-01T10:00:00');
|
||||
vi.setSystemTime(initialTime);
|
||||
|
||||
render(<CurrentTimeIndicator startTime={startTime} hourWidth={100} />);
|
||||
|
||||
const indicator = document.querySelector('#current-time-indicator');
|
||||
expect(indicator).toHaveStyle({ left: '200px' });
|
||||
|
||||
// Advance time by 1 minute
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(60000);
|
||||
});
|
||||
|
||||
// Position should update (120 minutes + 1 minute = 121 minutes)
|
||||
// 121 minutes * (100px / 60 minutes) = 201.67px
|
||||
expect(indicator).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with correct styling', () => {
|
||||
const startTime = new Date('2024-01-01T08:00:00');
|
||||
const now = new Date('2024-01-01T10:00:00');
|
||||
vi.setSystemTime(now);
|
||||
|
||||
render(<CurrentTimeIndicator startTime={startTime} hourWidth={100} />);
|
||||
|
||||
const indicator = document.querySelector('#current-time-indicator');
|
||||
expect(indicator).toHaveClass('absolute', 'top-0', 'bottom-0', 'w-px', 'bg-red-500', 'z-30', 'pointer-events-none');
|
||||
});
|
||||
|
||||
it('renders the red dot at the top', () => {
|
||||
const startTime = new Date('2024-01-01T08:00:00');
|
||||
const now = new Date('2024-01-01T10:00:00');
|
||||
vi.setSystemTime(now);
|
||||
|
||||
render(<CurrentTimeIndicator startTime={startTime} hourWidth={100} />);
|
||||
|
||||
const indicator = document.querySelector('#current-time-indicator');
|
||||
const dot = indicator?.querySelector('.rounded-full');
|
||||
expect(dot).toBeInTheDocument();
|
||||
expect(dot).toHaveClass('bg-red-500');
|
||||
});
|
||||
|
||||
it('works with different hourWidth values', () => {
|
||||
const startTime = new Date('2024-01-01T08:00:00');
|
||||
const now = new Date('2024-01-01T10:00:00'); // 2 hours after start
|
||||
vi.setSystemTime(now);
|
||||
|
||||
render(<CurrentTimeIndicator startTime={startTime} hourWidth={150} />);
|
||||
|
||||
const indicator = document.querySelector('#current-time-indicator');
|
||||
expect(indicator).toHaveStyle({ left: '300px' }); // 2 hours * 150px
|
||||
});
|
||||
|
||||
it('handles fractional hour positions', () => {
|
||||
const startTime = new Date('2024-01-01T08:00:00');
|
||||
const now = new Date('2024-01-01T08:30:00'); // 30 minutes after start
|
||||
vi.setSystemTime(now);
|
||||
|
||||
render(<CurrentTimeIndicator startTime={startTime} hourWidth={100} />);
|
||||
|
||||
const indicator = document.querySelector('#current-time-indicator');
|
||||
expect(indicator).toHaveStyle({ left: '50px' }); // 0.5 hours * 100px
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { DraggableEvent } from '../DraggableEvent';
|
||||
|
||||
// Mock DnD Kit
|
||||
vi.mock('@dnd-kit/core', () => ({
|
||||
DndContext: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
useDraggable: vi.fn(() => ({
|
||||
attributes: {},
|
||||
listeners: {},
|
||||
setNodeRef: vi.fn(),
|
||||
transform: null,
|
||||
isDragging: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@dnd-kit/utilities', () => ({
|
||||
CSS: {
|
||||
Translate: {
|
||||
toString: (transform: any) => transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : undefined,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('DraggableEvent', () => {
|
||||
const defaultProps = {
|
||||
id: 1,
|
||||
title: 'Test Event',
|
||||
serviceName: 'Test Service',
|
||||
status: 'CONFIRMED' as const,
|
||||
isPaid: false,
|
||||
start: new Date('2024-01-01T10:00:00'),
|
||||
end: new Date('2024-01-01T11:00:00'),
|
||||
laneIndex: 0,
|
||||
height: 80,
|
||||
left: 100,
|
||||
width: 200,
|
||||
top: 10,
|
||||
onResizeStart: vi.fn(),
|
||||
};
|
||||
|
||||
it('renders the event title', () => {
|
||||
render(<DraggableEvent {...defaultProps} />);
|
||||
expect(screen.getByText('Test Event')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the service name when provided', () => {
|
||||
render(<DraggableEvent {...defaultProps} />);
|
||||
expect(screen.getByText('Test Service')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render service name when not provided', () => {
|
||||
render(<DraggableEvent {...defaultProps} serviceName={undefined} />);
|
||||
expect(screen.queryByText('Test Service')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays the start time formatted correctly', () => {
|
||||
render(<DraggableEvent {...defaultProps} />);
|
||||
expect(screen.getByText('10:00 AM')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies correct position styles', () => {
|
||||
const { container } = render(<DraggableEvent {...defaultProps} />);
|
||||
const eventElement = container.querySelector('.absolute.rounded-b');
|
||||
|
||||
expect(eventElement).toHaveStyle({
|
||||
left: '100px',
|
||||
width: '200px',
|
||||
top: '10px',
|
||||
height: '80px',
|
||||
});
|
||||
});
|
||||
|
||||
it('applies confirmed status border color', () => {
|
||||
const { container } = render(
|
||||
<DraggableEvent {...defaultProps} status="CONFIRMED" />
|
||||
);
|
||||
const eventElement = container.querySelector('.absolute.rounded-b');
|
||||
expect(eventElement).toHaveClass('border-blue-500');
|
||||
});
|
||||
|
||||
it('applies completed status border color', () => {
|
||||
const { container } = render(
|
||||
<DraggableEvent {...defaultProps} status="COMPLETED" />
|
||||
);
|
||||
const eventElement = container.querySelector('.absolute.rounded-b');
|
||||
expect(eventElement).toHaveClass('border-green-500');
|
||||
});
|
||||
|
||||
it('applies cancelled status border color', () => {
|
||||
const { container } = render(
|
||||
<DraggableEvent {...defaultProps} status="CANCELLED" />
|
||||
);
|
||||
const eventElement = container.querySelector('.absolute.rounded-b');
|
||||
expect(eventElement).toHaveClass('border-red-500');
|
||||
});
|
||||
|
||||
it('applies no-show status border color', () => {
|
||||
const { container } = render(
|
||||
<DraggableEvent {...defaultProps} status="NO_SHOW" />
|
||||
);
|
||||
const eventElement = container.querySelector('.absolute.rounded-b');
|
||||
expect(eventElement).toHaveClass('border-gray-500');
|
||||
});
|
||||
|
||||
it('applies green border when paid', () => {
|
||||
const { container } = render(
|
||||
<DraggableEvent {...defaultProps} isPaid={true} />
|
||||
);
|
||||
const eventElement = container.querySelector('.absolute.rounded-b');
|
||||
expect(eventElement).toHaveClass('border-green-500');
|
||||
});
|
||||
|
||||
it('applies default brand border color for scheduled status', () => {
|
||||
const { container } = render(
|
||||
<DraggableEvent {...defaultProps} status="SCHEDULED" />
|
||||
);
|
||||
const eventElement = container.querySelector('.absolute.rounded-b');
|
||||
expect(eventElement).toHaveClass('border-brand-500');
|
||||
});
|
||||
|
||||
it('calls onResizeStart when top resize handle is clicked', () => {
|
||||
const onResizeStart = vi.fn();
|
||||
const { container } = render(
|
||||
<DraggableEvent {...defaultProps} onResizeStart={onResizeStart} />
|
||||
);
|
||||
|
||||
const topHandle = container.querySelector('.cursor-ns-resize');
|
||||
if (topHandle) {
|
||||
fireEvent.mouseDown(topHandle);
|
||||
expect(onResizeStart).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
'left',
|
||||
1
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('calls onResizeStart when bottom resize handle is clicked', () => {
|
||||
const onResizeStart = vi.fn();
|
||||
const { container } = render(
|
||||
<DraggableEvent {...defaultProps} onResizeStart={onResizeStart} />
|
||||
);
|
||||
|
||||
const handles = container.querySelectorAll('.cursor-ns-resize');
|
||||
const bottomHandle = handles[handles.length - 1]; // Get the last one (bottom)
|
||||
|
||||
if (bottomHandle) {
|
||||
fireEvent.mouseDown(bottomHandle);
|
||||
expect(onResizeStart).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
'right',
|
||||
1
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('renders grip icon', () => {
|
||||
const { container } = render(<DraggableEvent {...defaultProps} />);
|
||||
const gripIcon = container.querySelector('svg');
|
||||
expect(gripIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies hover styles', () => {
|
||||
const { container } = render(<DraggableEvent {...defaultProps} />);
|
||||
const eventElement = container.querySelector('.absolute.rounded-b');
|
||||
expect(eventElement).toHaveClass('group', 'hover:shadow-md');
|
||||
});
|
||||
|
||||
it('renders with correct base styling classes', () => {
|
||||
const { container } = render(<DraggableEvent {...defaultProps} />);
|
||||
const eventElement = container.querySelector('.absolute.rounded-b');
|
||||
expect(eventElement).toHaveClass(
|
||||
'absolute',
|
||||
'rounded-b',
|
||||
'overflow-hidden',
|
||||
'group',
|
||||
'bg-brand-100'
|
||||
);
|
||||
});
|
||||
|
||||
it('has two resize handles', () => {
|
||||
const { container } = render(<DraggableEvent {...defaultProps} />);
|
||||
const handles = container.querySelectorAll('.cursor-ns-resize');
|
||||
expect(handles).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('stops propagation when resize handle is clicked', () => {
|
||||
const onResizeStart = vi.fn();
|
||||
const { container } = render(
|
||||
<DraggableEvent {...defaultProps} onResizeStart={onResizeStart} />
|
||||
);
|
||||
|
||||
const topHandle = container.querySelector('.cursor-ns-resize');
|
||||
const mockEvent = {
|
||||
stopPropagation: vi.fn(),
|
||||
} as any;
|
||||
|
||||
if (topHandle) {
|
||||
fireEvent.mouseDown(topHandle, mockEvent);
|
||||
// The event handler should call stopPropagation to prevent drag
|
||||
expect(onResizeStart).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders content area with cursor-move', () => {
|
||||
const { container } = render(<DraggableEvent {...defaultProps} />);
|
||||
const contentArea = container.querySelector('.cursor-move');
|
||||
expect(contentArea).toBeInTheDocument();
|
||||
expect(contentArea).toHaveClass('select-none');
|
||||
});
|
||||
|
||||
it('applies different heights correctly', () => {
|
||||
const { container } = render(
|
||||
<DraggableEvent {...defaultProps} height={100} />
|
||||
);
|
||||
const eventElement = container.querySelector('.absolute.rounded-b');
|
||||
expect(eventElement).toHaveStyle({ height: '100px' });
|
||||
});
|
||||
|
||||
it('applies different widths correctly', () => {
|
||||
const { container } = render(
|
||||
<DraggableEvent {...defaultProps} width={300} />
|
||||
);
|
||||
const eventElement = container.querySelector('.absolute.rounded-b');
|
||||
expect(eventElement).toHaveStyle({ width: '300px' });
|
||||
});
|
||||
});
|
||||
243
frontend/src/components/Timeline/__tests__/ResourceRow.test.tsx
Normal file
243
frontend/src/components/Timeline/__tests__/ResourceRow.test.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import ResourceRow from '../ResourceRow';
|
||||
import { Event } from '../../../lib/layoutAlgorithm';
|
||||
|
||||
// Mock DnD Kit
|
||||
vi.mock('@dnd-kit/core', () => ({
|
||||
DndContext: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
useDroppable: vi.fn(() => ({
|
||||
setNodeRef: vi.fn(),
|
||||
isOver: false,
|
||||
})),
|
||||
useDraggable: vi.fn(() => ({
|
||||
attributes: {},
|
||||
listeners: {},
|
||||
setNodeRef: vi.fn(),
|
||||
transform: null,
|
||||
isDragging: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@dnd-kit/utilities', () => ({
|
||||
CSS: {
|
||||
Translate: {
|
||||
toString: (transform: any) => transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : undefined,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ResourceRow', () => {
|
||||
const mockEvents: Event[] = [
|
||||
{
|
||||
id: 1,
|
||||
resourceId: 1,
|
||||
title: 'Event 1',
|
||||
serviceName: 'Service 1',
|
||||
start: new Date('2024-01-01T10:00:00'),
|
||||
end: new Date('2024-01-01T11:00:00'),
|
||||
status: 'CONFIRMED',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
resourceId: 1,
|
||||
title: 'Event 2',
|
||||
serviceName: 'Service 2',
|
||||
start: new Date('2024-01-01T14:00:00'),
|
||||
end: new Date('2024-01-01T15:00:00'),
|
||||
status: 'SCHEDULED',
|
||||
},
|
||||
];
|
||||
|
||||
const defaultProps = {
|
||||
resourceId: 1,
|
||||
resourceName: 'Test Resource',
|
||||
events: mockEvents,
|
||||
startTime: new Date('2024-01-01T08:00:00'),
|
||||
endTime: new Date('2024-01-01T18:00:00'),
|
||||
hourWidth: 100,
|
||||
eventHeight: 80,
|
||||
onResizeStart: vi.fn(),
|
||||
};
|
||||
|
||||
it('renders the resource name', () => {
|
||||
render(<ResourceRow {...defaultProps} />);
|
||||
expect(screen.getByText('Test Resource')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all events', () => {
|
||||
render(<ResourceRow {...defaultProps} />);
|
||||
expect(screen.getByText('Event 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Event 2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with no events', () => {
|
||||
render(<ResourceRow {...defaultProps} events={[]} />);
|
||||
expect(screen.getByText('Test Resource')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Event 1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies sticky positioning to resource name column', () => {
|
||||
const { container } = render(<ResourceRow {...defaultProps} />);
|
||||
const nameColumn = container.querySelector('.sticky');
|
||||
expect(nameColumn).toBeInTheDocument();
|
||||
expect(nameColumn).toHaveClass('left-0', 'z-10');
|
||||
});
|
||||
|
||||
it('renders grid lines for each hour', () => {
|
||||
const { container } = render(<ResourceRow {...defaultProps} />);
|
||||
const gridLines = container.querySelectorAll('.border-r.border-gray-100');
|
||||
// 10 hours from 8am to 6pm
|
||||
expect(gridLines.length).toBe(10);
|
||||
});
|
||||
|
||||
it('calculates correct row height based on events', () => {
|
||||
// Test with overlapping events that require multiple lanes
|
||||
const overlappingEvents: Event[] = [
|
||||
{
|
||||
id: 1,
|
||||
resourceId: 1,
|
||||
title: 'Event 1',
|
||||
start: new Date('2024-01-01T10:00:00'),
|
||||
end: new Date('2024-01-01T11:00:00'),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
resourceId: 1,
|
||||
title: 'Event 2',
|
||||
start: new Date('2024-01-01T10:30:00'),
|
||||
end: new Date('2024-01-01T11:30:00'),
|
||||
},
|
||||
];
|
||||
|
||||
const { container } = render(
|
||||
<ResourceRow {...defaultProps} events={overlappingEvents} />
|
||||
);
|
||||
|
||||
const rowContent = container.querySelector('.relative.flex-grow');
|
||||
// With 2 lanes and eventHeight of 80, expect height: (2 * 80) + 20 = 180
|
||||
expect(rowContent?.parentElement).toHaveStyle({ height: expect.any(String) });
|
||||
});
|
||||
|
||||
it('applies droppable area styling', () => {
|
||||
const { container } = render(<ResourceRow {...defaultProps} />);
|
||||
const droppableArea = container.querySelector('.relative.flex-grow');
|
||||
expect(droppableArea).toHaveClass('transition-colors');
|
||||
});
|
||||
|
||||
it('renders border between rows', () => {
|
||||
const { container } = render(<ResourceRow {...defaultProps} />);
|
||||
const row = container.querySelector('.flex.border-b');
|
||||
expect(row).toHaveClass('border-gray-200');
|
||||
});
|
||||
|
||||
it('applies hover effect to resource name', () => {
|
||||
const { container } = render(<ResourceRow {...defaultProps} />);
|
||||
const nameColumn = container.querySelector('.bg-gray-50');
|
||||
expect(nameColumn).toHaveClass('group-hover:bg-gray-100', 'transition-colors');
|
||||
});
|
||||
|
||||
it('calculates total width correctly', () => {
|
||||
const { container } = render(<ResourceRow {...defaultProps} />);
|
||||
const rowContent = container.querySelector('.relative.flex-grow');
|
||||
// 10 hours * 100px = 1000px
|
||||
expect(rowContent).toHaveStyle({ width: '1000px' });
|
||||
});
|
||||
|
||||
it('positions events correctly within the row', () => {
|
||||
const { container } = render(<ResourceRow {...defaultProps} />);
|
||||
const events = container.querySelectorAll('.absolute.rounded-b');
|
||||
expect(events.length).toBe(2);
|
||||
});
|
||||
|
||||
it('renders resource name with fixed width', () => {
|
||||
const { container } = render(<ResourceRow {...defaultProps} />);
|
||||
const nameColumn = screen.getByText('Test Resource').closest('.w-48');
|
||||
expect(nameColumn).toBeInTheDocument();
|
||||
expect(nameColumn).toHaveClass('flex-shrink-0');
|
||||
});
|
||||
|
||||
it('handles single event correctly', () => {
|
||||
const singleEvent: Event[] = [
|
||||
{
|
||||
id: 1,
|
||||
resourceId: 1,
|
||||
title: 'Single Event',
|
||||
start: new Date('2024-01-01T10:00:00'),
|
||||
end: new Date('2024-01-01T11:00:00'),
|
||||
},
|
||||
];
|
||||
|
||||
render(<ResourceRow {...defaultProps} events={singleEvent} />);
|
||||
expect(screen.getByText('Single Event')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes resize handler to events', () => {
|
||||
const onResizeStart = vi.fn();
|
||||
render(<ResourceRow {...defaultProps} onResizeStart={onResizeStart} />);
|
||||
// Events should be rendered with the resize handler passed down
|
||||
const resizeHandles = document.querySelectorAll('.cursor-ns-resize');
|
||||
expect(resizeHandles.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('applies correct event height to draggable events', () => {
|
||||
const { container } = render(<ResourceRow {...defaultProps} eventHeight={100} />);
|
||||
const events = container.querySelectorAll('.absolute.rounded-b');
|
||||
// Each event should have height of eventHeight - 4 = 96px
|
||||
events.forEach(event => {
|
||||
expect(event).toHaveStyle({ height: '96px' });
|
||||
});
|
||||
});
|
||||
|
||||
it('handles different hour widths', () => {
|
||||
const { container } = render(<ResourceRow {...defaultProps} hourWidth={150} />);
|
||||
const rowContent = container.querySelector('.relative.flex-grow');
|
||||
// 10 hours * 150px = 1500px
|
||||
expect(rowContent).toHaveStyle({ width: '1500px' });
|
||||
});
|
||||
|
||||
it('renders grid lines with correct width', () => {
|
||||
const { container } = render(<ResourceRow {...defaultProps} hourWidth={120} />);
|
||||
const gridLine = container.querySelector('.border-r.border-gray-100');
|
||||
expect(gridLine).toHaveStyle({ width: '120px' });
|
||||
});
|
||||
|
||||
it('calculates layout for overlapping events', () => {
|
||||
const overlappingEvents: Event[] = [
|
||||
{
|
||||
id: 1,
|
||||
resourceId: 1,
|
||||
title: 'Event 1',
|
||||
start: new Date('2024-01-01T10:00:00'),
|
||||
end: new Date('2024-01-01T12:00:00'),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
resourceId: 1,
|
||||
title: 'Event 2',
|
||||
start: new Date('2024-01-01T11:00:00'),
|
||||
end: new Date('2024-01-01T13:00:00'),
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
resourceId: 1,
|
||||
title: 'Event 3',
|
||||
start: new Date('2024-01-01T11:30:00'),
|
||||
end: new Date('2024-01-01T13:30:00'),
|
||||
},
|
||||
];
|
||||
|
||||
render(<ResourceRow {...defaultProps} events={overlappingEvents} />);
|
||||
// All three events should be rendered
|
||||
expect(screen.getByText('Event 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Event 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Event 3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sets droppable id with resource id', () => {
|
||||
const { container } = render(<ResourceRow {...defaultProps} resourceId={42} />);
|
||||
// The droppable area should have the resource id in its data
|
||||
const droppableArea = container.querySelector('.relative.flex-grow');
|
||||
expect(droppableArea).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
276
frontend/src/components/Timeline/__tests__/TimelineRow.test.tsx
Normal file
276
frontend/src/components/Timeline/__tests__/TimelineRow.test.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import TimelineRow from '../TimelineRow';
|
||||
import { Event } from '../../../lib/layoutAlgorithm';
|
||||
|
||||
// Mock DnD Kit
|
||||
vi.mock('@dnd-kit/core', () => ({
|
||||
DndContext: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
useDroppable: vi.fn(() => ({
|
||||
setNodeRef: vi.fn(),
|
||||
isOver: false,
|
||||
})),
|
||||
useDraggable: vi.fn(() => ({
|
||||
attributes: {},
|
||||
listeners: {},
|
||||
setNodeRef: vi.fn(),
|
||||
transform: null,
|
||||
isDragging: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@dnd-kit/utilities', () => ({
|
||||
CSS: {
|
||||
Translate: {
|
||||
toString: (transform: any) => transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : undefined,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('TimelineRow', () => {
|
||||
const mockEvents: Event[] = [
|
||||
{
|
||||
id: 1,
|
||||
resourceId: 1,
|
||||
title: 'Event 1',
|
||||
serviceName: 'Service 1',
|
||||
start: new Date('2024-01-01T10:00:00'),
|
||||
end: new Date('2024-01-01T11:00:00'),
|
||||
status: 'CONFIRMED',
|
||||
isPaid: false,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
resourceId: 1,
|
||||
title: 'Event 2',
|
||||
serviceName: 'Service 2',
|
||||
start: new Date('2024-01-01T14:00:00'),
|
||||
end: new Date('2024-01-01T15:00:00'),
|
||||
status: 'SCHEDULED',
|
||||
isPaid: true,
|
||||
},
|
||||
];
|
||||
|
||||
const defaultProps = {
|
||||
resourceId: 1,
|
||||
events: mockEvents,
|
||||
startTime: new Date('2024-01-01T08:00:00'),
|
||||
endTime: new Date('2024-01-01T18:00:00'),
|
||||
hourWidth: 100,
|
||||
eventHeight: 80,
|
||||
height: 100,
|
||||
onResizeStart: vi.fn(),
|
||||
};
|
||||
|
||||
it('renders all events', () => {
|
||||
render(<TimelineRow {...defaultProps} />);
|
||||
expect(screen.getByText('Event 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Event 2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders event service names', () => {
|
||||
render(<TimelineRow {...defaultProps} />);
|
||||
expect(screen.getByText('Service 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Service 2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with no events', () => {
|
||||
render(<TimelineRow {...defaultProps} events={[]} />);
|
||||
expect(screen.queryByText('Event 1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies correct height from prop', () => {
|
||||
const { container } = render(<TimelineRow {...defaultProps} height={150} />);
|
||||
const row = container.querySelector('.relative.border-b');
|
||||
expect(row).toHaveStyle({ height: '150px' });
|
||||
});
|
||||
|
||||
it('calculates total width correctly', () => {
|
||||
const { container } = render(<TimelineRow {...defaultProps} />);
|
||||
const row = container.querySelector('.relative.border-b');
|
||||
// 10 hours * 100px = 1000px
|
||||
expect(row).toHaveStyle({ width: '1000px' });
|
||||
});
|
||||
|
||||
it('renders grid lines for each hour', () => {
|
||||
const { container } = render(<TimelineRow {...defaultProps} />);
|
||||
const gridLines = container.querySelectorAll('.border-r.border-gray-100');
|
||||
// 10 hours from 8am to 6pm
|
||||
expect(gridLines.length).toBe(10);
|
||||
});
|
||||
|
||||
it('applies droppable area styling', () => {
|
||||
const { container } = render(<TimelineRow {...defaultProps} />);
|
||||
const row = container.querySelector('.relative.border-b');
|
||||
expect(row).toHaveClass('transition-colors', 'group');
|
||||
});
|
||||
|
||||
it('renders border with dark mode support', () => {
|
||||
const { container } = render(<TimelineRow {...defaultProps} />);
|
||||
const row = container.querySelector('.relative.border-b');
|
||||
expect(row).toHaveClass('border-gray-200', 'dark:border-gray-700');
|
||||
});
|
||||
|
||||
it('handles different hour widths', () => {
|
||||
const { container } = render(<TimelineRow {...defaultProps} hourWidth={150} />);
|
||||
const row = container.querySelector('.relative.border-b');
|
||||
// 10 hours * 150px = 1500px
|
||||
expect(row).toHaveStyle({ width: '1500px' });
|
||||
});
|
||||
|
||||
it('renders grid lines with correct width', () => {
|
||||
const { container } = render(<TimelineRow {...defaultProps} hourWidth={120} />);
|
||||
const gridLine = container.querySelector('.border-r.border-gray-100');
|
||||
expect(gridLine).toHaveStyle({ width: '120px' });
|
||||
});
|
||||
|
||||
it('positions events correctly within the row', () => {
|
||||
const { container } = render(<TimelineRow {...defaultProps} />);
|
||||
const events = container.querySelectorAll('.absolute.rounded-b');
|
||||
expect(events.length).toBe(2);
|
||||
});
|
||||
|
||||
it('passes event status to draggable events', () => {
|
||||
render(<TimelineRow {...defaultProps} />);
|
||||
// Events should render with their status (visible in the DOM)
|
||||
expect(screen.getByText('Event 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Event 2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes isPaid prop to draggable events', () => {
|
||||
const { container } = render(<TimelineRow {...defaultProps} />);
|
||||
// Second event is paid, should have green border
|
||||
const events = container.querySelectorAll('.absolute.rounded-b');
|
||||
expect(events.length).toBe(2);
|
||||
});
|
||||
|
||||
it('passes resize handler to events', () => {
|
||||
const onResizeStart = vi.fn();
|
||||
render(<TimelineRow {...defaultProps} onResizeStart={onResizeStart} />);
|
||||
// Events should be rendered with the resize handler passed down
|
||||
const resizeHandles = document.querySelectorAll('.cursor-ns-resize');
|
||||
expect(resizeHandles.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('calculates layout for overlapping events', () => {
|
||||
const overlappingEvents: Event[] = [
|
||||
{
|
||||
id: 1,
|
||||
resourceId: 1,
|
||||
title: 'Event 1',
|
||||
start: new Date('2024-01-01T10:00:00'),
|
||||
end: new Date('2024-01-01T12:00:00'),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
resourceId: 1,
|
||||
title: 'Event 2',
|
||||
start: new Date('2024-01-01T11:00:00'),
|
||||
end: new Date('2024-01-01T13:00:00'),
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
resourceId: 1,
|
||||
title: 'Event 3',
|
||||
start: new Date('2024-01-01T11:30:00'),
|
||||
end: new Date('2024-01-01T13:30:00'),
|
||||
},
|
||||
];
|
||||
|
||||
render(<TimelineRow {...defaultProps} events={overlappingEvents} />);
|
||||
// All three events should be rendered
|
||||
expect(screen.getByText('Event 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Event 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Event 3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies correct event height to draggable events', () => {
|
||||
const { container } = render(<TimelineRow {...defaultProps} eventHeight={100} />);
|
||||
const events = container.querySelectorAll('.absolute.rounded-b');
|
||||
// Each event should have height of eventHeight - 4 = 96px
|
||||
events.forEach(event => {
|
||||
expect(event).toHaveStyle({ height: '96px' });
|
||||
});
|
||||
});
|
||||
|
||||
it('handles single event correctly', () => {
|
||||
const singleEvent: Event[] = [
|
||||
{
|
||||
id: 1,
|
||||
resourceId: 1,
|
||||
title: 'Single Event',
|
||||
start: new Date('2024-01-01T10:00:00'),
|
||||
end: new Date('2024-01-01T11:00:00'),
|
||||
},
|
||||
];
|
||||
|
||||
render(<TimelineRow {...defaultProps} events={singleEvent} />);
|
||||
expect(screen.getByText('Single Event')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders grid with pointer-events-none', () => {
|
||||
const { container } = render(<TimelineRow {...defaultProps} />);
|
||||
const gridContainer = container.querySelector('.pointer-events-none.flex');
|
||||
expect(gridContainer).toBeInTheDocument();
|
||||
expect(gridContainer).toHaveClass('absolute', 'inset-0');
|
||||
});
|
||||
|
||||
it('applies dark mode styling to grid lines', () => {
|
||||
const { container } = render(<TimelineRow {...defaultProps} />);
|
||||
const gridLine = container.querySelector('.border-r');
|
||||
expect(gridLine).toHaveClass('dark:border-gray-700/50');
|
||||
});
|
||||
|
||||
it('sets droppable id with resource id', () => {
|
||||
const { container } = render(<TimelineRow {...defaultProps} resourceId={42} />);
|
||||
// The droppable area should have the resource id in its data
|
||||
const droppableArea = container.querySelector('.relative.border-b');
|
||||
expect(droppableArea).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders events with correct top positioning based on lane', () => {
|
||||
const { container } = render(<TimelineRow {...defaultProps} />);
|
||||
const events = container.querySelectorAll('.absolute.rounded-b');
|
||||
// Events should be positioned with top: (laneIndex * eventHeight) + 10
|
||||
expect(events.length).toBe(2);
|
||||
});
|
||||
|
||||
it('handles events without service name', () => {
|
||||
const eventsNoService: Event[] = [
|
||||
{
|
||||
id: 1,
|
||||
resourceId: 1,
|
||||
title: 'Event Without Service',
|
||||
start: new Date('2024-01-01T10:00:00'),
|
||||
end: new Date('2024-01-01T11:00:00'),
|
||||
},
|
||||
];
|
||||
|
||||
render(<TimelineRow {...defaultProps} events={eventsNoService} />);
|
||||
expect(screen.getByText('Event Without Service')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles events without status', () => {
|
||||
const eventsNoStatus: Event[] = [
|
||||
{
|
||||
id: 1,
|
||||
resourceId: 1,
|
||||
title: 'Event Without Status',
|
||||
start: new Date('2024-01-01T10:00:00'),
|
||||
end: new Date('2024-01-01T11:00:00'),
|
||||
},
|
||||
];
|
||||
|
||||
render(<TimelineRow {...defaultProps} events={eventsNoStatus} />);
|
||||
expect(screen.getByText('Event Without Status')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('memoizes event layout calculation', () => {
|
||||
const { rerender } = render(<TimelineRow {...defaultProps} />);
|
||||
expect(screen.getByText('Event 1')).toBeInTheDocument();
|
||||
|
||||
// Rerender with same events
|
||||
rerender(<TimelineRow {...defaultProps} />);
|
||||
expect(screen.getByText('Event 1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
812
frontend/src/components/__tests__/ConnectOnboarding.test.tsx
Normal file
812
frontend/src/components/__tests__/ConnectOnboarding.test.tsx
Normal file
@@ -0,0 +1,812 @@
|
||||
/**
|
||||
* Tests for ConnectOnboarding component
|
||||
*
|
||||
* Tests the Stripe Connect onboarding component for paid-tier businesses.
|
||||
* Covers:
|
||||
* - Rendering different states (active, onboarding, needs onboarding)
|
||||
* - Account details display
|
||||
* - User interactions (start onboarding, refresh link)
|
||||
* - Error handling
|
||||
* - Loading states
|
||||
* - Account type labels
|
||||
* - Window location redirects
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
import ConnectOnboarding from '../ConnectOnboarding';
|
||||
import { ConnectAccountInfo } from '../../api/payments';
|
||||
|
||||
// Mock hooks
|
||||
const mockUseConnectOnboarding = vi.fn();
|
||||
const mockUseRefreshConnectLink = vi.fn();
|
||||
|
||||
vi.mock('../../hooks/usePayments', () => ({
|
||||
useConnectOnboarding: () => mockUseConnectOnboarding(),
|
||||
useRefreshConnectLink: () => mockUseRefreshConnectLink(),
|
||||
}));
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) => {
|
||||
const translations: Record<string, string> = {
|
||||
'payments.stripeConnected': 'Stripe Connected',
|
||||
'payments.stripeConnectedDesc': 'Your Stripe account is connected and ready to accept payments',
|
||||
'payments.accountDetails': 'Account Details',
|
||||
'payments.accountType': 'Account Type',
|
||||
'payments.status': 'Status',
|
||||
'payments.charges': 'Charges',
|
||||
'payments.payouts': 'Payouts',
|
||||
'payments.enabled': 'Enabled',
|
||||
'payments.disabled': 'Disabled',
|
||||
'payments.accountId': 'Account ID',
|
||||
'payments.completeOnboarding': 'Complete Onboarding',
|
||||
'payments.onboardingIncomplete': 'Please complete your Stripe account setup to accept payments',
|
||||
'payments.continueOnboarding': 'Continue Onboarding',
|
||||
'payments.connectWithStripe': 'Connect with Stripe',
|
||||
'payments.tierPaymentDescription': `Connect your Stripe account to accept payments with your ${params?.tier} plan`,
|
||||
'payments.securePaymentProcessing': 'Secure payment processing',
|
||||
'payments.automaticPayouts': 'Automatic payouts to your bank',
|
||||
'payments.pciCompliance': 'PCI compliance handled for you',
|
||||
'payments.failedToStartOnboarding': 'Failed to start onboarding',
|
||||
'payments.failedToRefreshLink': 'Failed to refresh link',
|
||||
'payments.openStripeDashboard': 'Open Stripe Dashboard',
|
||||
'payments.standardConnect': 'Standard',
|
||||
'payments.expressConnect': 'Express',
|
||||
'payments.customConnect': 'Custom',
|
||||
'payments.connect': 'Connect',
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Test data factory
|
||||
const createMockConnectAccount = (
|
||||
overrides?: Partial<ConnectAccountInfo>
|
||||
): ConnectAccountInfo => ({
|
||||
id: 1,
|
||||
business: 1,
|
||||
business_name: 'Test Business',
|
||||
business_subdomain: 'testbiz',
|
||||
stripe_account_id: 'acct_test123',
|
||||
account_type: 'standard',
|
||||
status: 'active',
|
||||
charges_enabled: true,
|
||||
payouts_enabled: true,
|
||||
details_submitted: true,
|
||||
onboarding_complete: true,
|
||||
onboarding_link: null,
|
||||
onboarding_link_expires_at: null,
|
||||
is_onboarding_link_valid: false,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// Helper to wrap component with providers
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('ConnectOnboarding', () => {
|
||||
const mockMutateAsync = vi.fn();
|
||||
let originalLocation: Location;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Save original location
|
||||
originalLocation = window.location;
|
||||
|
||||
// Mock window.location
|
||||
delete (window as any).location;
|
||||
window.location = {
|
||||
...originalLocation,
|
||||
origin: 'http://testbiz.lvh.me:5173',
|
||||
href: 'http://testbiz.lvh.me:5173/payments',
|
||||
} as Location;
|
||||
|
||||
mockUseConnectOnboarding.mockReturnValue({
|
||||
mutateAsync: mockMutateAsync,
|
||||
isPending: false,
|
||||
});
|
||||
|
||||
mockUseRefreshConnectLink.mockReturnValue({
|
||||
mutateAsync: mockMutateAsync,
|
||||
isPending: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original location
|
||||
window.location = originalLocation;
|
||||
});
|
||||
|
||||
describe('Active Account State', () => {
|
||||
it('should render active status when account is active and charges enabled', () => {
|
||||
const account = createMockConnectAccount({
|
||||
status: 'active',
|
||||
charges_enabled: true,
|
||||
});
|
||||
|
||||
render(
|
||||
<ConnectOnboarding connectAccount={account} tier="Professional" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText('Stripe Connected')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Your Stripe account is connected/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display account details for active account', () => {
|
||||
const account = createMockConnectAccount({
|
||||
account_type: 'express',
|
||||
status: 'active',
|
||||
stripe_account_id: 'acct_test456',
|
||||
});
|
||||
|
||||
render(
|
||||
<ConnectOnboarding connectAccount={account} tier="Professional" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText('Account Details')).toBeInTheDocument();
|
||||
expect(screen.getByText('Express')).toBeInTheDocument();
|
||||
expect(screen.getByText('active')).toBeInTheDocument();
|
||||
expect(screen.getByText('acct_test456')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show enabled charges and payouts', () => {
|
||||
const account = createMockConnectAccount({
|
||||
charges_enabled: true,
|
||||
payouts_enabled: true,
|
||||
});
|
||||
|
||||
render(
|
||||
<ConnectOnboarding connectAccount={account} tier="Professional" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const enabledLabels = screen.getAllByText('Enabled');
|
||||
expect(enabledLabels).toHaveLength(2); // Charges and Payouts
|
||||
});
|
||||
|
||||
it('should show disabled charges and payouts', () => {
|
||||
const account = createMockConnectAccount({
|
||||
status: 'restricted',
|
||||
charges_enabled: false,
|
||||
payouts_enabled: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<ConnectOnboarding connectAccount={account} tier="Professional" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const disabledLabels = screen.getAllByText('Disabled');
|
||||
expect(disabledLabels).toHaveLength(2); // Charges and Payouts
|
||||
});
|
||||
|
||||
it('should show Stripe dashboard link when active', () => {
|
||||
const account = createMockConnectAccount({
|
||||
status: 'active',
|
||||
charges_enabled: true,
|
||||
});
|
||||
|
||||
render(
|
||||
<ConnectOnboarding connectAccount={account} tier="Professional" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const dashboardLink = screen.getByText('Open Stripe Dashboard');
|
||||
expect(dashboardLink).toBeInTheDocument();
|
||||
expect(dashboardLink.closest('a')).toHaveAttribute(
|
||||
'href',
|
||||
'https://dashboard.stripe.com'
|
||||
);
|
||||
expect(dashboardLink.closest('a')).toHaveAttribute('target', '_blank');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Account Type Labels', () => {
|
||||
it('should display standard account type', () => {
|
||||
const account = createMockConnectAccount({ account_type: 'standard' });
|
||||
|
||||
render(
|
||||
<ConnectOnboarding connectAccount={account} tier="Professional" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText('Standard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display express account type', () => {
|
||||
const account = createMockConnectAccount({ account_type: 'express' });
|
||||
|
||||
render(
|
||||
<ConnectOnboarding connectAccount={account} tier="Professional" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText('Express')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display custom account type', () => {
|
||||
const account = createMockConnectAccount({ account_type: 'custom' });
|
||||
|
||||
render(
|
||||
<ConnectOnboarding connectAccount={account} tier="Professional" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText('Custom')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Account Status Display', () => {
|
||||
it('should show active status with green styling', () => {
|
||||
const account = createMockConnectAccount({ status: 'active' });
|
||||
|
||||
const { container } = render(
|
||||
<ConnectOnboarding connectAccount={account} tier="Professional" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const statusBadge = screen.getByText('active');
|
||||
expect(statusBadge).toHaveClass('bg-green-100', 'text-green-800');
|
||||
});
|
||||
|
||||
it('should show onboarding status with yellow styling', () => {
|
||||
const account = createMockConnectAccount({ status: 'onboarding' });
|
||||
|
||||
const { container } = render(
|
||||
<ConnectOnboarding connectAccount={account} tier="Professional" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const statusBadge = screen.getByText('onboarding');
|
||||
expect(statusBadge).toHaveClass('bg-yellow-100', 'text-yellow-800');
|
||||
});
|
||||
|
||||
it('should show restricted status with red styling', () => {
|
||||
const account = createMockConnectAccount({ status: 'restricted' });
|
||||
|
||||
const { container } = render(
|
||||
<ConnectOnboarding connectAccount={account} tier="Professional" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const statusBadge = screen.getByText('restricted');
|
||||
expect(statusBadge).toHaveClass('bg-red-100', 'text-red-800');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Onboarding in Progress State', () => {
|
||||
it('should show onboarding warning when status is onboarding', () => {
|
||||
const account = createMockConnectAccount({
|
||||
status: 'onboarding',
|
||||
onboarding_complete: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<ConnectOnboarding connectAccount={account} tier="Professional" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText('Complete Onboarding')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Please complete your Stripe account setup/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show onboarding warning when onboarding_complete is false', () => {
|
||||
const account = createMockConnectAccount({
|
||||
status: 'active',
|
||||
onboarding_complete: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<ConnectOnboarding connectAccount={account} tier="Professional" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText('Complete Onboarding')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render continue onboarding button', () => {
|
||||
const account = createMockConnectAccount({
|
||||
status: 'onboarding',
|
||||
onboarding_complete: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<ConnectOnboarding connectAccount={account} tier="Professional" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: /continue onboarding/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call refresh link mutation when continue button clicked', async () => {
|
||||
const account = createMockConnectAccount({
|
||||
status: 'onboarding',
|
||||
onboarding_complete: false,
|
||||
});
|
||||
|
||||
mockMutateAsync.mockResolvedValue({
|
||||
url: 'https://connect.stripe.com/setup/test',
|
||||
});
|
||||
|
||||
render(
|
||||
<ConnectOnboarding connectAccount={account} tier="Professional" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: /continue onboarding/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
refreshUrl: 'http://testbiz.lvh.me:5173/payments?connect=refresh',
|
||||
returnUrl: 'http://testbiz.lvh.me:5173/payments?connect=complete',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should redirect to Stripe URL after refresh link success', async () => {
|
||||
const account = createMockConnectAccount({
|
||||
status: 'onboarding',
|
||||
onboarding_complete: false,
|
||||
});
|
||||
|
||||
const stripeUrl = 'https://connect.stripe.com/setup/test123';
|
||||
mockMutateAsync.mockResolvedValue({ url: stripeUrl });
|
||||
|
||||
render(
|
||||
<ConnectOnboarding connectAccount={account} tier="Professional" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: /continue onboarding/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe(stripeUrl);
|
||||
});
|
||||
});
|
||||
|
||||
it('should show loading state while refreshing link', () => {
|
||||
const account = createMockConnectAccount({
|
||||
status: 'onboarding',
|
||||
onboarding_complete: false,
|
||||
});
|
||||
|
||||
mockUseRefreshConnectLink.mockReturnValue({
|
||||
mutateAsync: mockMutateAsync,
|
||||
isPending: true,
|
||||
});
|
||||
|
||||
render(
|
||||
<ConnectOnboarding connectAccount={account} tier="Professional" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: /continue onboarding/i });
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Needs Onboarding State', () => {
|
||||
it('should show onboarding info when no account exists', () => {
|
||||
render(
|
||||
<ConnectOnboarding connectAccount={null} tier="Professional" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getAllByText('Connect with Stripe').length).toBeGreaterThan(0);
|
||||
expect(
|
||||
screen.getByText(/Connect your Stripe account to accept payments with your Professional plan/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show feature list when no account exists', () => {
|
||||
render(
|
||||
<ConnectOnboarding connectAccount={null} tier="Professional" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText('Secure payment processing')).toBeInTheDocument();
|
||||
expect(screen.getByText('Automatic payouts to your bank')).toBeInTheDocument();
|
||||
expect(screen.getByText('PCI compliance handled for you')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render start onboarding button when no account', () => {
|
||||
render(
|
||||
<ConnectOnboarding connectAccount={null} tier="Professional" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const buttons = screen.getAllByRole('button', { name: /connect with stripe/i });
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should call onboarding mutation when start button clicked', async () => {
|
||||
mockMutateAsync.mockResolvedValue({
|
||||
url: 'https://connect.stripe.com/express/oauth',
|
||||
});
|
||||
|
||||
render(
|
||||
<ConnectOnboarding connectAccount={null} tier="Professional" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: /connect with stripe/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
refreshUrl: 'http://testbiz.lvh.me:5173/payments?connect=refresh',
|
||||
returnUrl: 'http://testbiz.lvh.me:5173/payments?connect=complete',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should redirect to Stripe URL after onboarding start', async () => {
|
||||
const stripeUrl = 'https://connect.stripe.com/express/oauth/authorize';
|
||||
mockMutateAsync.mockResolvedValue({ url: stripeUrl });
|
||||
|
||||
render(
|
||||
<ConnectOnboarding connectAccount={null} tier="Professional" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: /connect with stripe/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe(stripeUrl);
|
||||
});
|
||||
});
|
||||
|
||||
it('should show loading state while starting onboarding', () => {
|
||||
mockUseConnectOnboarding.mockReturnValue({
|
||||
mutateAsync: mockMutateAsync,
|
||||
isPending: true,
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<ConnectOnboarding connectAccount={null} tier="Professional" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Find the button by its Stripe brand color class
|
||||
const button = container.querySelector('button.bg-\\[\\#635BFF\\]');
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should show error message when onboarding fails', async () => {
|
||||
mockMutateAsync.mockRejectedValue({
|
||||
response: {
|
||||
data: {
|
||||
error: 'Stripe account creation failed',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<ConnectOnboarding connectAccount={null} tier="Professional" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: /connect with stripe/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Stripe account creation failed')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show default error message when no error detail provided', async () => {
|
||||
mockMutateAsync.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
render(
|
||||
<ConnectOnboarding connectAccount={null} tier="Professional" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: /connect with stripe/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to start onboarding')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error when refresh link fails', async () => {
|
||||
const account = createMockConnectAccount({
|
||||
status: 'onboarding',
|
||||
onboarding_complete: false,
|
||||
});
|
||||
|
||||
mockMutateAsync.mockRejectedValue({
|
||||
response: {
|
||||
data: {
|
||||
error: 'Link expired',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<ConnectOnboarding connectAccount={account} tier="Professional" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: /continue onboarding/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Link expired')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should clear previous error when starting new action', async () => {
|
||||
mockMutateAsync.mockRejectedValueOnce({
|
||||
response: {
|
||||
data: {
|
||||
error: 'First error',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<ConnectOnboarding connectAccount={null} tier="Professional" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: /connect with stripe/i });
|
||||
|
||||
// First click - causes error
|
||||
fireEvent.click(button);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('First error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Second click - should clear error before mutation
|
||||
mockMutateAsync.mockResolvedValue({ url: 'https://stripe.com' });
|
||||
fireEvent.click(button);
|
||||
|
||||
// Error should eventually disappear (after mutation starts)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('First error')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Props Handling', () => {
|
||||
it('should use tier in description', () => {
|
||||
render(
|
||||
<ConnectOnboarding connectAccount={null} tier="Premium" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(/Connect your Stripe account to accept payments with your Premium plan/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onSuccess callback when provided', async () => {
|
||||
const onSuccess = vi.fn();
|
||||
const account = createMockConnectAccount({
|
||||
status: 'active',
|
||||
charges_enabled: true,
|
||||
});
|
||||
|
||||
render(
|
||||
<ConnectOnboarding
|
||||
connectAccount={account}
|
||||
tier="Professional"
|
||||
onSuccess={onSuccess}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// onSuccess is not called in the current implementation
|
||||
// This test documents the prop exists but isn't used
|
||||
expect(onSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Return URLs', () => {
|
||||
it('should generate correct return URLs based on window location', async () => {
|
||||
mockMutateAsync.mockResolvedValue({
|
||||
url: 'https://stripe.com',
|
||||
});
|
||||
|
||||
render(
|
||||
<ConnectOnboarding connectAccount={null} tier="Professional" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: /connect with stripe/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
refreshUrl: 'http://testbiz.lvh.me:5173/payments?connect=refresh',
|
||||
returnUrl: 'http://testbiz.lvh.me:5173/payments?connect=complete',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should use same return URLs for both onboarding and refresh', async () => {
|
||||
const account = createMockConnectAccount({
|
||||
status: 'onboarding',
|
||||
onboarding_complete: false,
|
||||
});
|
||||
|
||||
mockMutateAsync.mockResolvedValue({
|
||||
url: 'https://stripe.com',
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<ConnectOnboarding connectAccount={null} tier="Professional" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Test start onboarding
|
||||
const startButton = screen.getByRole('button', { name: /connect with stripe/i });
|
||||
fireEvent.click(startButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
refreshUrl: 'http://testbiz.lvh.me:5173/payments?connect=refresh',
|
||||
returnUrl: 'http://testbiz.lvh.me:5173/payments?connect=complete',
|
||||
});
|
||||
});
|
||||
|
||||
mockMutateAsync.mockClear();
|
||||
|
||||
// Test refresh link
|
||||
rerender(
|
||||
<ConnectOnboarding connectAccount={account} tier="Professional" />
|
||||
);
|
||||
|
||||
const refreshButton = screen.getByRole('button', { name: /continue onboarding/i });
|
||||
fireEvent.click(refreshButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
refreshUrl: 'http://testbiz.lvh.me:5173/payments?connect=refresh',
|
||||
returnUrl: 'http://testbiz.lvh.me:5173/payments?connect=complete',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('UI Elements', () => {
|
||||
it('should have proper styling for active account banner', () => {
|
||||
const account = createMockConnectAccount({
|
||||
status: 'active',
|
||||
charges_enabled: true,
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<ConnectOnboarding connectAccount={account} tier="Professional" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const banner = container.querySelector('.bg-green-50');
|
||||
expect(banner).toBeInTheDocument();
|
||||
expect(banner).toHaveClass('border', 'border-green-200', 'rounded-lg');
|
||||
});
|
||||
|
||||
it('should have proper styling for onboarding warning', () => {
|
||||
const account = createMockConnectAccount({
|
||||
status: 'onboarding',
|
||||
onboarding_complete: false,
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<ConnectOnboarding connectAccount={account} tier="Professional" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const warning = container.querySelector('.bg-yellow-50');
|
||||
expect(warning).toBeInTheDocument();
|
||||
expect(warning).toHaveClass('border', 'border-yellow-200', 'rounded-lg');
|
||||
});
|
||||
|
||||
it('should have proper styling for start onboarding section', () => {
|
||||
const { container } = render(
|
||||
<ConnectOnboarding connectAccount={null} tier="Professional" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const infoBox = container.querySelector('.bg-blue-50');
|
||||
expect(infoBox).toBeInTheDocument();
|
||||
expect(infoBox).toHaveClass('border', 'border-blue-200', 'rounded-lg');
|
||||
});
|
||||
|
||||
it('should have Stripe brand color on connect button', () => {
|
||||
render(
|
||||
<ConnectOnboarding connectAccount={null} tier="Professional" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: /connect with stripe/i });
|
||||
expect(button).toHaveClass('bg-[#635BFF]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Conditional Rendering', () => {
|
||||
it('should not show active banner when charges disabled', () => {
|
||||
const account = createMockConnectAccount({
|
||||
status: 'active',
|
||||
charges_enabled: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<ConnectOnboarding connectAccount={account} tier="Professional" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Stripe Connected')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show Stripe dashboard link when not active', () => {
|
||||
const account = createMockConnectAccount({
|
||||
status: 'onboarding',
|
||||
charges_enabled: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<ConnectOnboarding connectAccount={account} tier="Professional" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Open Stripe Dashboard')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show account details even when not fully active', () => {
|
||||
const account = createMockConnectAccount({
|
||||
status: 'restricted',
|
||||
charges_enabled: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<ConnectOnboarding connectAccount={account} tier="Professional" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText('Account Details')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show onboarding warning when complete', () => {
|
||||
const account = createMockConnectAccount({
|
||||
status: 'active',
|
||||
onboarding_complete: true,
|
||||
});
|
||||
|
||||
render(
|
||||
<ConnectOnboarding connectAccount={account} tier="Professional" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Complete Onboarding')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
797
frontend/src/components/__tests__/DevQuickLogin.test.tsx
Normal file
797
frontend/src/components/__tests__/DevQuickLogin.test.tsx
Normal file
@@ -0,0 +1,797 @@
|
||||
/**
|
||||
* Unit tests for DevQuickLogin component
|
||||
*
|
||||
* Tests quick login functionality for development environment.
|
||||
* Covers:
|
||||
* - Environment checks (production vs development)
|
||||
* - Component rendering (embedded vs floating)
|
||||
* - User filtering (all, platform, business)
|
||||
* - Quick login functionality
|
||||
* - Subdomain redirects
|
||||
* - API error handling
|
||||
* - Loading states
|
||||
* - Minimize/maximize toggle
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { DevQuickLogin } from '../DevQuickLogin';
|
||||
import * as apiClient from '../../api/client';
|
||||
import * as cookies from '../../utils/cookies';
|
||||
import * as domain from '../../utils/domain';
|
||||
|
||||
// Mock modules
|
||||
vi.mock('../../api/client');
|
||||
vi.mock('../../utils/cookies');
|
||||
vi.mock('../../utils/domain');
|
||||
|
||||
// Helper to wrap component with QueryClient
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('DevQuickLogin', () => {
|
||||
const mockPost = vi.fn();
|
||||
const mockGet = vi.fn();
|
||||
const mockSetCookie = vi.fn();
|
||||
const mockGetBaseDomain = vi.fn();
|
||||
const mockBuildSubdomainUrl = vi.fn();
|
||||
|
||||
// Store original values
|
||||
const originalEnv = import.meta.env.PROD;
|
||||
const originalLocation = window.location;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Mock API client
|
||||
vi.mocked(apiClient).default = {
|
||||
post: mockPost,
|
||||
get: mockGet,
|
||||
} as any;
|
||||
|
||||
// Mock cookie utilities
|
||||
vi.mocked(cookies.setCookie).mockImplementation(mockSetCookie);
|
||||
|
||||
// Mock domain utilities
|
||||
vi.mocked(domain.getBaseDomain).mockReturnValue('lvh.me');
|
||||
vi.mocked(domain.buildSubdomainUrl).mockImplementation(
|
||||
(subdomain, path) => `http://${subdomain}.lvh.me:5173${path}`
|
||||
);
|
||||
|
||||
// Mock window.location
|
||||
delete (window as any).location;
|
||||
window.location = {
|
||||
...originalLocation,
|
||||
hostname: 'platform.lvh.me',
|
||||
port: '5173',
|
||||
href: '',
|
||||
} as any;
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: localStorageMock,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Mock alert
|
||||
window.alert = vi.fn();
|
||||
|
||||
// Set development environment
|
||||
(import.meta.env as any).PROD = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore environment
|
||||
(import.meta.env as any).PROD = originalEnv;
|
||||
window.location = originalLocation;
|
||||
});
|
||||
|
||||
describe('Environment Checks', () => {
|
||||
it('should not render in production environment', () => {
|
||||
(import.meta.env as any).PROD = true;
|
||||
|
||||
const { container } = render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
expect(screen.queryByText(/Quick Login/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render in development environment', () => {
|
||||
(import.meta.env as any).PROD = false;
|
||||
|
||||
render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText(/Quick Login \(Dev Only\)/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Component Rendering', () => {
|
||||
it('should render as floating widget by default', () => {
|
||||
const { container } = render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
const widget = container.firstChild as HTMLElement;
|
||||
expect(widget).toHaveClass('fixed', 'bottom-4', 'right-4', 'z-50');
|
||||
});
|
||||
|
||||
it('should render as embedded when embedded prop is true', () => {
|
||||
const { container } = render(<DevQuickLogin embedded />, { wrapper: createWrapper() });
|
||||
|
||||
const widget = container.firstChild as HTMLElement;
|
||||
expect(widget).toHaveClass('w-full', 'bg-gray-50');
|
||||
expect(widget).not.toHaveClass('fixed');
|
||||
});
|
||||
|
||||
it('should render minimize button when not embedded', () => {
|
||||
render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
const minimizeButton = screen.getByText('×');
|
||||
expect(minimizeButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render minimize button when embedded', () => {
|
||||
render(<DevQuickLogin embedded />, { wrapper: createWrapper() });
|
||||
|
||||
const minimizeButton = screen.queryByText('×');
|
||||
expect(minimizeButton).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all user buttons', () => {
|
||||
render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Platform Superuser')).toBeInTheDocument();
|
||||
expect(screen.getByText('Platform Manager')).toBeInTheDocument();
|
||||
expect(screen.getByText('Platform Sales')).toBeInTheDocument();
|
||||
expect(screen.getByText('Platform Support')).toBeInTheDocument();
|
||||
expect(screen.getByText('Business Owner')).toBeInTheDocument();
|
||||
expect(screen.getByText('Staff (Full Access)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Staff (Limited)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Customer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render password hint', () => {
|
||||
render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText(/Password for all:/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('test123')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render user roles as subtitles', () => {
|
||||
render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('SUPERUSER')).toBeInTheDocument();
|
||||
expect(screen.getByText('PLATFORM_MANAGER')).toBeInTheDocument();
|
||||
expect(screen.getByText('TENANT_OWNER')).toBeInTheDocument();
|
||||
expect(screen.getByText('CUSTOMER')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Filtering', () => {
|
||||
it('should show all users when filter is "all"', () => {
|
||||
render(<DevQuickLogin filter="all" />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Platform Superuser')).toBeInTheDocument();
|
||||
expect(screen.getByText('Business Owner')).toBeInTheDocument();
|
||||
expect(screen.getByText('Customer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show only platform users when filter is "platform"', () => {
|
||||
render(<DevQuickLogin filter="platform" />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Platform Superuser')).toBeInTheDocument();
|
||||
expect(screen.getByText('Platform Manager')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Business Owner')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Customer')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show only business users when filter is "business"', () => {
|
||||
render(<DevQuickLogin filter="business" />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Business Owner')).toBeInTheDocument();
|
||||
expect(screen.getByText('Staff (Full Access)')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Platform Superuser')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Customer')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Minimize/Maximize Toggle', () => {
|
||||
it('should minimize when minimize button is clicked', () => {
|
||||
render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
const minimizeButton = screen.getByText('×');
|
||||
fireEvent.click(minimizeButton);
|
||||
|
||||
expect(screen.getByText('🔓 Quick Login')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Platform Superuser')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should maximize when minimized widget is clicked', () => {
|
||||
render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
// Minimize first
|
||||
const minimizeButton = screen.getByText('×');
|
||||
fireEvent.click(minimizeButton);
|
||||
|
||||
// Then maximize
|
||||
const maximizeButton = screen.getByText('🔓 Quick Login');
|
||||
fireEvent.click(maximizeButton);
|
||||
|
||||
expect(screen.getByText(/Quick Login \(Dev Only\)/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('Platform Superuser')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show minimize toggle when embedded', () => {
|
||||
render(<DevQuickLogin embedded />, { wrapper: createWrapper() });
|
||||
|
||||
// Should always show full widget
|
||||
expect(screen.getByText('Platform Superuser')).toBeInTheDocument();
|
||||
|
||||
// No minimize button
|
||||
expect(screen.queryByText('×')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('🔓 Quick Login')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Quick Login Functionality', () => {
|
||||
it('should call login API with correct credentials', async () => {
|
||||
mockPost.mockResolvedValueOnce({
|
||||
data: { access: 'test-token', refresh: 'refresh-token' },
|
||||
});
|
||||
mockGet.mockResolvedValueOnce({
|
||||
data: {
|
||||
role: 'superuser',
|
||||
business_subdomain: null,
|
||||
},
|
||||
});
|
||||
|
||||
render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
const button = screen.getByText('Platform Superuser').parentElement!;
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPost).toHaveBeenCalledWith('/auth/login/', {
|
||||
email: 'superuser@platform.com',
|
||||
password: 'test123',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should store token in cookie after successful login', async () => {
|
||||
mockPost.mockResolvedValueOnce({
|
||||
data: { access: 'test-token', refresh: 'refresh-token' },
|
||||
});
|
||||
mockGet.mockResolvedValueOnce({
|
||||
data: {
|
||||
role: 'superuser',
|
||||
business_subdomain: null,
|
||||
},
|
||||
});
|
||||
|
||||
render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
const button = screen.getByText('Platform Superuser').parentElement!;
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetCookie).toHaveBeenCalledWith('access_token', 'test-token', 7);
|
||||
});
|
||||
});
|
||||
|
||||
it('should clear masquerade stack after login', async () => {
|
||||
mockPost.mockResolvedValueOnce({
|
||||
data: { access: 'test-token', refresh: 'refresh-token' },
|
||||
});
|
||||
mockGet.mockResolvedValueOnce({
|
||||
data: {
|
||||
role: 'superuser',
|
||||
business_subdomain: null,
|
||||
},
|
||||
});
|
||||
|
||||
render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
const button = screen.getByText('Platform Superuser').parentElement!;
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.removeItem).toHaveBeenCalledWith('masquerade_stack');
|
||||
});
|
||||
});
|
||||
|
||||
it('should fetch user data after login', async () => {
|
||||
mockPost.mockResolvedValueOnce({
|
||||
data: { access: 'test-token', refresh: 'refresh-token' },
|
||||
});
|
||||
mockGet.mockResolvedValueOnce({
|
||||
data: {
|
||||
role: 'superuser',
|
||||
business_subdomain: null,
|
||||
},
|
||||
});
|
||||
|
||||
render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
const button = screen.getByText('Platform Superuser').parentElement!;
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGet).toHaveBeenCalledWith('/auth/me/');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Subdomain Redirects', () => {
|
||||
it('should redirect platform users to platform subdomain', async () => {
|
||||
mockPost.mockResolvedValueOnce({
|
||||
data: { access: 'test-token', refresh: 'refresh-token' },
|
||||
});
|
||||
mockGet.mockResolvedValueOnce({
|
||||
data: {
|
||||
role: 'superuser',
|
||||
business_subdomain: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Mock current location as non-platform subdomain
|
||||
window.location.hostname = 'demo.lvh.me';
|
||||
|
||||
render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
const button = screen.getByText('Platform Superuser').parentElement!;
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe('http://platform.lvh.me:5173/dashboard');
|
||||
});
|
||||
});
|
||||
|
||||
it('should redirect business users to their subdomain', async () => {
|
||||
mockPost.mockResolvedValueOnce({
|
||||
data: { access: 'test-token', refresh: 'refresh-token' },
|
||||
});
|
||||
mockGet.mockResolvedValueOnce({
|
||||
data: {
|
||||
role: 'tenant_owner',
|
||||
business_subdomain: 'demo',
|
||||
},
|
||||
});
|
||||
|
||||
// Mock current location as platform subdomain
|
||||
window.location.hostname = 'platform.lvh.me';
|
||||
|
||||
render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
const button = screen.getByText('Business Owner').parentElement!;
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe('http://demo.lvh.me:5173/dashboard');
|
||||
});
|
||||
});
|
||||
|
||||
it('should navigate to dashboard when already on correct subdomain', async () => {
|
||||
mockPost.mockResolvedValueOnce({
|
||||
data: { access: 'test-token', refresh: 'refresh-token' },
|
||||
});
|
||||
mockGet.mockResolvedValueOnce({
|
||||
data: {
|
||||
role: 'superuser',
|
||||
business_subdomain: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Already on platform subdomain
|
||||
window.location.hostname = 'platform.lvh.me';
|
||||
|
||||
render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
const button = screen.getByText('Platform Superuser').parentElement!;
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe('/dashboard');
|
||||
});
|
||||
});
|
||||
|
||||
it('should redirect platform_manager to platform subdomain', async () => {
|
||||
mockPost.mockResolvedValueOnce({
|
||||
data: { access: 'test-token', refresh: 'refresh-token' },
|
||||
});
|
||||
mockGet.mockResolvedValueOnce({
|
||||
data: {
|
||||
role: 'platform_manager',
|
||||
business_subdomain: null,
|
||||
},
|
||||
});
|
||||
|
||||
window.location.hostname = 'demo.lvh.me';
|
||||
|
||||
render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
const button = screen.getByText('Platform Manager').parentElement!;
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe('http://platform.lvh.me:5173/dashboard');
|
||||
});
|
||||
});
|
||||
|
||||
it('should redirect platform_support to platform subdomain', async () => {
|
||||
mockPost.mockResolvedValueOnce({
|
||||
data: { access: 'test-token', refresh: 'refresh-token' },
|
||||
});
|
||||
mockGet.mockResolvedValueOnce({
|
||||
data: {
|
||||
role: 'platform_support',
|
||||
business_subdomain: null,
|
||||
},
|
||||
});
|
||||
|
||||
window.location.hostname = 'demo.lvh.me';
|
||||
|
||||
render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
const button = screen.getByText('Platform Support').parentElement!;
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe('http://platform.lvh.me:5173/dashboard');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading States', () => {
|
||||
it('should show loading state on clicked button', async () => {
|
||||
mockPost.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => resolve({ data: { access: 'token' } }), 100);
|
||||
})
|
||||
);
|
||||
|
||||
const { container } = render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
const buttons = container.querySelectorAll('button');
|
||||
const button = Array.from(buttons).find((b) =>
|
||||
b.textContent?.includes('Platform Superuser')
|
||||
) as HTMLButtonElement;
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Logging in...')).toBeInTheDocument();
|
||||
expect(button.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should disable all buttons during login', async () => {
|
||||
mockPost.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => resolve({ data: { access: 'token' } }), 100);
|
||||
})
|
||||
);
|
||||
|
||||
const { container } = render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
const buttons = container.querySelectorAll('button');
|
||||
const button = Array.from(buttons).find((b) =>
|
||||
b.textContent?.includes('Platform Superuser')
|
||||
) as HTMLButtonElement;
|
||||
fireEvent.click(button);
|
||||
|
||||
const allButtons = screen.getAllByRole('button').filter((b) => !b.textContent?.includes('×'));
|
||||
allButtons.forEach((btn) => {
|
||||
expect(btn).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show loading spinner with correct styling', async () => {
|
||||
mockPost.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => resolve({ data: { access: 'token' } }), 100);
|
||||
})
|
||||
);
|
||||
|
||||
const { container } = render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
const buttons = container.querySelectorAll('button');
|
||||
const button = Array.from(buttons).find((b) =>
|
||||
b.textContent?.includes('Platform Superuser')
|
||||
) as HTMLButtonElement;
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
const spinner = button.querySelector('.animate-spin');
|
||||
expect(spinner).toBeInTheDocument();
|
||||
expect(spinner).toHaveClass('h-4', 'w-4');
|
||||
});
|
||||
});
|
||||
|
||||
it('should clear loading state after successful login', async () => {
|
||||
mockPost.mockResolvedValueOnce({
|
||||
data: { access: 'test-token', refresh: 'refresh-token' },
|
||||
});
|
||||
mockGet.mockResolvedValueOnce({
|
||||
data: {
|
||||
role: 'superuser',
|
||||
business_subdomain: null,
|
||||
},
|
||||
});
|
||||
|
||||
render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
const button = screen.getByText('Platform Superuser').parentElement!;
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Logging in...')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should show alert on login API failure', async () => {
|
||||
const error = new Error('Invalid credentials');
|
||||
mockPost.mockRejectedValueOnce(error);
|
||||
|
||||
render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
const button = screen.getByText('Platform Superuser').parentElement!;
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.alert).toHaveBeenCalledWith(
|
||||
'Failed to login as Platform Superuser: Invalid credentials'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should show alert on user data fetch failure', async () => {
|
||||
mockPost.mockResolvedValueOnce({
|
||||
data: { access: 'test-token', refresh: 'refresh-token' },
|
||||
});
|
||||
mockGet.mockRejectedValueOnce(new Error('Failed to fetch user'));
|
||||
|
||||
render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
const button = screen.getByText('Platform Superuser').parentElement!;
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.alert).toHaveBeenCalledWith(
|
||||
'Failed to login as Platform Superuser: Failed to fetch user'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should log error to console', async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const error = new Error('Network error');
|
||||
mockPost.mockRejectedValueOnce(error);
|
||||
|
||||
render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
const button = screen.getByText('Platform Superuser').parentElement!;
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Quick login failed:', error);
|
||||
});
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should clear loading state on error', async () => {
|
||||
mockPost.mockRejectedValueOnce(new Error('Login failed'));
|
||||
|
||||
render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
const button = screen.getByText('Platform Superuser').parentElement!;
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Logging in...')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should re-enable buttons after error', async () => {
|
||||
mockPost.mockRejectedValueOnce(new Error('Login failed'));
|
||||
|
||||
render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
const button = screen.getByText('Platform Superuser').parentElement!;
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
const allButtons = screen
|
||||
.getAllByRole('button')
|
||||
.filter((b) => !b.textContent?.includes('×'));
|
||||
allButtons.forEach((btn) => {
|
||||
expect(btn).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle error with no message', async () => {
|
||||
mockPost.mockRejectedValueOnce({});
|
||||
|
||||
render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
const button = screen.getByText('Platform Superuser').parentElement!;
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.alert).toHaveBeenCalledWith(
|
||||
'Failed to login as Platform Superuser: Unknown error'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Button Styling', () => {
|
||||
it('should apply correct color classes to platform superuser', () => {
|
||||
const { container } = render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
// Find the button that contains "Platform Superuser"
|
||||
const buttons = container.querySelectorAll('button');
|
||||
const button = Array.from(buttons).find((b) =>
|
||||
b.textContent?.includes('Platform Superuser')
|
||||
);
|
||||
expect(button).toHaveClass('bg-purple-600', 'hover:bg-purple-700');
|
||||
});
|
||||
|
||||
it('should apply correct color classes to platform manager', () => {
|
||||
const { container } = render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
const buttons = container.querySelectorAll('button');
|
||||
const button = Array.from(buttons).find((b) => b.textContent?.includes('Platform Manager'));
|
||||
expect(button).toHaveClass('bg-blue-600', 'hover:bg-blue-700');
|
||||
});
|
||||
|
||||
it('should apply correct color classes to business owner', () => {
|
||||
const { container } = render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
const buttons = container.querySelectorAll('button');
|
||||
const button = Array.from(buttons).find((b) => b.textContent?.includes('Business Owner'));
|
||||
expect(button).toHaveClass('bg-indigo-600', 'hover:bg-indigo-700');
|
||||
});
|
||||
|
||||
it('should apply correct color classes to customer', () => {
|
||||
const { container } = render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
const buttons = container.querySelectorAll('button');
|
||||
const button = Array.from(buttons).find((b) => b.textContent?.includes('Customer'));
|
||||
expect(button).toHaveClass('bg-orange-600', 'hover:bg-orange-700');
|
||||
});
|
||||
|
||||
it('should have consistent button styling', () => {
|
||||
const { container } = render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
const buttons = container.querySelectorAll('button');
|
||||
const button = Array.from(buttons).find((b) =>
|
||||
b.textContent?.includes('Platform Superuser')
|
||||
);
|
||||
expect(button).toHaveClass(
|
||||
'text-white',
|
||||
'px-3',
|
||||
'py-2',
|
||||
'rounded',
|
||||
'text-sm',
|
||||
'font-medium',
|
||||
'transition-colors'
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply disabled styling when loading', async () => {
|
||||
mockPost.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => resolve({ data: { access: 'token' } }), 100);
|
||||
})
|
||||
);
|
||||
|
||||
const { container } = render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
const buttons = container.querySelectorAll('button');
|
||||
const button = Array.from(buttons).find((b) =>
|
||||
b.textContent?.includes('Platform Superuser')
|
||||
) as HTMLButtonElement;
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(button).toHaveClass('disabled:opacity-50', 'disabled:cursor-not-allowed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should render all user buttons with button role', () => {
|
||||
render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
const buttons = screen.getAllByRole('button').filter((b) => !b.textContent?.includes('×'));
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have descriptive button text', () => {
|
||||
render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Platform Superuser')).toBeInTheDocument();
|
||||
expect(screen.getByText('SUPERUSER')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should indicate loading state visually', async () => {
|
||||
mockPost.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => resolve({ data: { access: 'token' } }), 100);
|
||||
})
|
||||
);
|
||||
|
||||
render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
const button = screen.getByText('Platform Superuser').parentElement!;
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.getByText('Logging in...')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple User Logins', () => {
|
||||
it('should handle logging in as different users sequentially', async () => {
|
||||
mockPost
|
||||
.mockResolvedValueOnce({
|
||||
data: { access: 'token1', refresh: 'refresh1' },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: { access: 'token2', refresh: 'refresh2' },
|
||||
});
|
||||
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
data: { role: 'superuser', business_subdomain: null },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: { role: 'tenant_owner', business_subdomain: 'demo' },
|
||||
});
|
||||
|
||||
render(<DevQuickLogin />, { wrapper: createWrapper() });
|
||||
|
||||
// Login as superuser
|
||||
const superuserButton = screen.getByText('Platform Superuser').parentElement!;
|
||||
fireEvent.click(superuserButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPost).toHaveBeenCalledWith('/auth/login/', {
|
||||
email: 'superuser@platform.com',
|
||||
password: 'test123',
|
||||
});
|
||||
});
|
||||
|
||||
// Login as owner
|
||||
const ownerButton = screen.getByText('Business Owner').parentElement!;
|
||||
fireEvent.click(ownerButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPost).toHaveBeenCalledWith('/auth/login/', {
|
||||
email: 'owner@demo.com',
|
||||
password: 'test123',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
367
frontend/src/components/__tests__/EmailTemplateSelector.test.tsx
Normal file
367
frontend/src/components/__tests__/EmailTemplateSelector.test.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* Unit tests for EmailTemplateSelector component
|
||||
*
|
||||
* Tests the deprecated EmailTemplateSelector component that now displays
|
||||
* a deprecation notice instead of an actual selector.
|
||||
*
|
||||
* Covers:
|
||||
* - Component rendering
|
||||
* - Deprecation notice display
|
||||
* - Props handling (className, disabled, etc.)
|
||||
* - Translation strings
|
||||
* - Disabled state of the selector
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import EmailTemplateSelector from '../EmailTemplateSelector';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string) => fallback || key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock('lucide-react', () => ({
|
||||
AlertTriangle: () => <div data-testid="alert-triangle-icon">⚠</div>,
|
||||
Mail: () => <div data-testid="mail-icon">✉</div>,
|
||||
}));
|
||||
|
||||
describe('EmailTemplateSelector', () => {
|
||||
const defaultProps = {
|
||||
value: undefined,
|
||||
onChange: vi.fn(),
|
||||
};
|
||||
|
||||
describe('Component Rendering', () => {
|
||||
it('renders the component successfully', () => {
|
||||
const { container } = render(<EmailTemplateSelector {...defaultProps} />);
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders deprecation notice with warning icon', () => {
|
||||
render(<EmailTemplateSelector {...defaultProps} />);
|
||||
|
||||
const alertIcon = screen.getByTestId('alert-triangle-icon');
|
||||
expect(alertIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders mail icon in the disabled selector', () => {
|
||||
render(<EmailTemplateSelector {...defaultProps} />);
|
||||
|
||||
const mailIcon = screen.getByTestId('mail-icon');
|
||||
expect(mailIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders deprecation title', () => {
|
||||
render(<EmailTemplateSelector {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Custom Email Templates Deprecated')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders deprecation message', () => {
|
||||
render(<EmailTemplateSelector {...defaultProps} />);
|
||||
|
||||
expect(
|
||||
screen.getByText(/Custom email templates have been replaced with system email templates/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders disabled select element', () => {
|
||||
render(<EmailTemplateSelector {...defaultProps} />);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
expect(select).toBeInTheDocument();
|
||||
expect(select).toBeDisabled();
|
||||
});
|
||||
|
||||
it('renders disabled option text', () => {
|
||||
render(<EmailTemplateSelector {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Custom templates no longer available')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Props Handling', () => {
|
||||
it('accepts value prop without errors', () => {
|
||||
render(<EmailTemplateSelector {...defaultProps} value={123} />);
|
||||
|
||||
// Component should render without errors
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('accepts string value prop', () => {
|
||||
render(<EmailTemplateSelector {...defaultProps} value="template-123" />);
|
||||
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('accepts undefined value prop', () => {
|
||||
render(<EmailTemplateSelector {...defaultProps} value={undefined} />);
|
||||
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('accepts category prop without errors', () => {
|
||||
render(<EmailTemplateSelector {...defaultProps} category="appointment" />);
|
||||
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('accepts placeholder prop without errors', () => {
|
||||
render(<EmailTemplateSelector {...defaultProps} placeholder="Select template" />);
|
||||
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('accepts required prop without errors', () => {
|
||||
render(<EmailTemplateSelector {...defaultProps} required={true} />);
|
||||
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('accepts disabled prop without errors', () => {
|
||||
render(<EmailTemplateSelector {...defaultProps} disabled={true} />);
|
||||
|
||||
// Selector is always disabled due to deprecation
|
||||
expect(screen.getByRole('combobox')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(
|
||||
<EmailTemplateSelector {...defaultProps} className="custom-test-class" />
|
||||
);
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement;
|
||||
expect(wrapper).toHaveClass('custom-test-class');
|
||||
});
|
||||
|
||||
it('applies multiple classes correctly', () => {
|
||||
const { container } = render(
|
||||
<EmailTemplateSelector {...defaultProps} className="class-one class-two" />
|
||||
);
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement;
|
||||
expect(wrapper).toHaveClass('class-one');
|
||||
expect(wrapper).toHaveClass('class-two');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Deprecation Notice Styling', () => {
|
||||
it('applies warning background color', () => {
|
||||
const { container } = render(<EmailTemplateSelector {...defaultProps} />);
|
||||
|
||||
const warningBox = container.querySelector('.bg-amber-50');
|
||||
expect(warningBox).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies warning border color', () => {
|
||||
const { container } = render(<EmailTemplateSelector {...defaultProps} />);
|
||||
|
||||
const warningBox = container.querySelector('.border-amber-200');
|
||||
expect(warningBox).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies dark mode warning background', () => {
|
||||
const { container } = render(<EmailTemplateSelector {...defaultProps} />);
|
||||
|
||||
const warningBox = container.querySelector('.dark\\:bg-amber-900\\/20');
|
||||
expect(warningBox).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies dark mode warning border', () => {
|
||||
const { container } = render(<EmailTemplateSelector {...defaultProps} />);
|
||||
|
||||
const warningBox = container.querySelector('.dark\\:border-amber-800');
|
||||
expect(warningBox).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Disabled Selector Styling', () => {
|
||||
it('applies opacity to disabled selector', () => {
|
||||
const { container } = render(<EmailTemplateSelector {...defaultProps} />);
|
||||
|
||||
const selectorWrapper = container.querySelector('.opacity-50');
|
||||
expect(selectorWrapper).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies pointer-events-none to disabled selector', () => {
|
||||
const { container } = render(<EmailTemplateSelector {...defaultProps} />);
|
||||
|
||||
const selectorWrapper = container.querySelector('.pointer-events-none');
|
||||
expect(selectorWrapper).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies disabled cursor style', () => {
|
||||
render(<EmailTemplateSelector {...defaultProps} />);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
expect(select).toHaveClass('cursor-not-allowed');
|
||||
});
|
||||
|
||||
it('applies gray background to disabled select', () => {
|
||||
render(<EmailTemplateSelector {...defaultProps} />);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
expect(select).toHaveClass('bg-gray-100');
|
||||
});
|
||||
|
||||
it('applies gray text color to disabled select', () => {
|
||||
render(<EmailTemplateSelector {...defaultProps} />);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
expect(select).toHaveClass('text-gray-500');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Translation Strings', () => {
|
||||
it('uses correct translation key for deprecation title', () => {
|
||||
render(<EmailTemplateSelector {...defaultProps} />);
|
||||
|
||||
// Since we're mocking useTranslation to return fallback text,
|
||||
// we can verify the component renders the expected fallback
|
||||
expect(screen.getByText('Custom Email Templates Deprecated')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses correct translation key for deprecation message', () => {
|
||||
render(<EmailTemplateSelector {...defaultProps} />);
|
||||
|
||||
// Verify the component renders the expected fallback message
|
||||
expect(
|
||||
screen.getByText(/Custom email templates have been replaced/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses correct translation key for unavailable message', () => {
|
||||
render(<EmailTemplateSelector {...defaultProps} />);
|
||||
|
||||
// Verify the component renders the expected fallback
|
||||
expect(screen.getByText('Custom templates no longer available')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onChange Handler', () => {
|
||||
it('does not call onChange when component is rendered', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<EmailTemplateSelector {...defaultProps} onChange={onChange} />);
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call onChange when component is re-rendered', () => {
|
||||
const onChange = vi.fn();
|
||||
const { rerender } = render(
|
||||
<EmailTemplateSelector {...defaultProps} onChange={onChange} />
|
||||
);
|
||||
|
||||
rerender(<EmailTemplateSelector {...defaultProps} onChange={onChange} />);
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Component Structure', () => {
|
||||
it('renders main wrapper with space-y-2 class', () => {
|
||||
const { container } = render(<EmailTemplateSelector {...defaultProps} />);
|
||||
|
||||
const wrapper = container.querySelector('.space-y-2');
|
||||
expect(wrapper).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders warning box with flex layout', () => {
|
||||
const { container } = render(<EmailTemplateSelector {...defaultProps} />);
|
||||
|
||||
const warningBox = container.querySelector('.flex.items-start');
|
||||
expect(warningBox).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders warning box with gap between icon and text', () => {
|
||||
const { container } = render(<EmailTemplateSelector {...defaultProps} />);
|
||||
|
||||
const warningBox = container.querySelector('.gap-3');
|
||||
expect(warningBox).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders warning icon', () => {
|
||||
render(<EmailTemplateSelector {...defaultProps} />);
|
||||
|
||||
const alertIcon = screen.getByTestId('alert-triangle-icon');
|
||||
expect(alertIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders mail icon', () => {
|
||||
render(<EmailTemplateSelector {...defaultProps} />);
|
||||
|
||||
const mailIcon = screen.getByTestId('mail-icon');
|
||||
expect(mailIcon).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('renders select with combobox role', () => {
|
||||
render(<EmailTemplateSelector {...defaultProps} />);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
expect(select).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('indicates disabled state for screen readers', () => {
|
||||
render(<EmailTemplateSelector {...defaultProps} />);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
expect(select).toHaveAttribute('disabled');
|
||||
});
|
||||
|
||||
it('renders visible deprecation notice for screen readers', () => {
|
||||
render(<EmailTemplateSelector {...defaultProps} />);
|
||||
|
||||
// The deprecation title should be accessible
|
||||
const title = screen.getByText('Custom Email Templates Deprecated');
|
||||
expect(title).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders visible deprecation message for screen readers', () => {
|
||||
render(<EmailTemplateSelector {...defaultProps} />);
|
||||
|
||||
const message = screen.getByText(
|
||||
/Custom email templates have been replaced with system email templates/
|
||||
);
|
||||
expect(message).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles empty className gracefully', () => {
|
||||
const { container } = render(<EmailTemplateSelector {...defaultProps} className="" />);
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement;
|
||||
expect(wrapper).toHaveClass('space-y-2');
|
||||
});
|
||||
|
||||
it('handles null onChange gracefully', () => {
|
||||
// Component should not crash even with null onChange
|
||||
expect(() => {
|
||||
render(<EmailTemplateSelector {...defaultProps} onChange={null as any} />);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('handles all props together', () => {
|
||||
render(
|
||||
<EmailTemplateSelector
|
||||
value={123}
|
||||
onChange={vi.fn()}
|
||||
category="appointment"
|
||||
placeholder="Select template"
|
||||
required={true}
|
||||
disabled={true}
|
||||
className="custom-class"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
expect(screen.getByText('Custom Email Templates Deprecated')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,16 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import LanguageSelector from '../LanguageSelector';
|
||||
|
||||
// Create mock function for changeLanguage
|
||||
const mockChangeLanguage = vi.fn();
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
i18n: {
|
||||
language: 'en',
|
||||
changeLanguage: vi.fn(),
|
||||
changeLanguage: mockChangeLanguage,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
@@ -22,6 +25,10 @@ vi.mock('../../i18n', () => ({
|
||||
}));
|
||||
|
||||
describe('LanguageSelector', () => {
|
||||
beforeEach(() => {
|
||||
mockChangeLanguage.mockClear();
|
||||
});
|
||||
|
||||
describe('dropdown variant', () => {
|
||||
it('renders dropdown button', () => {
|
||||
render(<LanguageSelector />);
|
||||
@@ -63,6 +70,71 @@ describe('LanguageSelector', () => {
|
||||
const { container } = render(<LanguageSelector className="custom-class" />);
|
||||
expect(container.firstChild).toHaveClass('custom-class');
|
||||
});
|
||||
|
||||
it('changes language when clicking a language option in dropdown', () => {
|
||||
render(<LanguageSelector />);
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
const spanishOption = screen.getByText('Español').closest('button');
|
||||
expect(spanishOption).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(spanishOption!);
|
||||
|
||||
expect(mockChangeLanguage).toHaveBeenCalledWith('es');
|
||||
});
|
||||
|
||||
it('closes dropdown when language is selected', () => {
|
||||
render(<LanguageSelector />);
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
||||
|
||||
const frenchOption = screen.getByText('Français').closest('button');
|
||||
fireEvent.click(frenchOption!);
|
||||
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes dropdown when clicking outside', () => {
|
||||
render(<LanguageSelector />);
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
||||
|
||||
// Click outside the dropdown
|
||||
fireEvent.mouseDown(document.body);
|
||||
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not close dropdown when clicking inside dropdown', () => {
|
||||
render(<LanguageSelector />);
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
||||
|
||||
const listbox = screen.getByRole('listbox');
|
||||
fireEvent.mouseDown(listbox);
|
||||
|
||||
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles dropdown open/closed on button clicks', () => {
|
||||
render(<LanguageSelector />);
|
||||
const button = screen.getByRole('button');
|
||||
|
||||
// Open dropdown
|
||||
fireEvent.click(button);
|
||||
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
||||
|
||||
// Close dropdown
|
||||
fireEvent.click(button);
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('inline variant', () => {
|
||||
@@ -89,5 +161,51 @@ describe('LanguageSelector', () => {
|
||||
render(<LanguageSelector variant="inline" />);
|
||||
expect(screen.getByText(/🇺🇸/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('changes language when clicking a language button', () => {
|
||||
render(<LanguageSelector variant="inline" />);
|
||||
|
||||
const spanishButton = screen.getByText(/Español/).closest('button');
|
||||
expect(spanishButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(spanishButton!);
|
||||
|
||||
expect(mockChangeLanguage).toHaveBeenCalledWith('es');
|
||||
});
|
||||
|
||||
it('calls changeLanguage with correct code for each language', () => {
|
||||
render(<LanguageSelector variant="inline" />);
|
||||
|
||||
// Test English
|
||||
const englishButton = screen.getByText(/English/).closest('button');
|
||||
fireEvent.click(englishButton!);
|
||||
expect(mockChangeLanguage).toHaveBeenCalledWith('en');
|
||||
|
||||
mockChangeLanguage.mockClear();
|
||||
|
||||
// Test French
|
||||
const frenchButton = screen.getByText(/Français/).closest('button');
|
||||
fireEvent.click(frenchButton!);
|
||||
expect(mockChangeLanguage).toHaveBeenCalledWith('fr');
|
||||
});
|
||||
|
||||
it('hides flags when showFlag is false', () => {
|
||||
render(<LanguageSelector variant="inline" showFlag={false} />);
|
||||
|
||||
// Flags should not be visible
|
||||
expect(screen.queryByText('🇺🇸')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('🇪🇸')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('🇫🇷')).not.toBeInTheDocument();
|
||||
|
||||
// But names should still be there
|
||||
expect(screen.getByText('English')).toBeInTheDocument();
|
||||
expect(screen.getByText('Español')).toBeInTheDocument();
|
||||
expect(screen.getByText('Français')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(<LanguageSelector variant="inline" className="custom-inline-class" />);
|
||||
expect(container.firstChild).toHaveClass('custom-inline-class');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
805
frontend/src/components/__tests__/QuickAddAppointment.test.tsx
Normal file
805
frontend/src/components/__tests__/QuickAddAppointment.test.tsx
Normal file
@@ -0,0 +1,805 @@
|
||||
/**
|
||||
* Unit tests for QuickAddAppointment component
|
||||
*
|
||||
* Tests cover:
|
||||
* - Component rendering
|
||||
* - Form fields and validation
|
||||
* - User interactions (filling forms, submitting)
|
||||
* - API integration (mock mutations)
|
||||
* - Success/error states
|
||||
* - Form reset after successful submission
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import QuickAddAppointment from '../QuickAddAppointment';
|
||||
|
||||
// Mock dependencies
|
||||
const mockServices = vi.fn();
|
||||
const mockResources = vi.fn();
|
||||
const mockCustomers = vi.fn();
|
||||
const mockCreateAppointment = vi.fn();
|
||||
|
||||
vi.mock('../../hooks/useServices', () => ({
|
||||
useServices: () => mockServices(),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useResources', () => ({
|
||||
useResources: () => mockResources(),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useCustomers', () => ({
|
||||
useCustomers: () => mockCustomers(),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useAppointments', () => ({
|
||||
useCreateAppointment: () => mockCreateAppointment(),
|
||||
}));
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, defaultValue?: string) => defaultValue || key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock date-fns format
|
||||
vi.mock('date-fns', () => ({
|
||||
format: (date: Date, formatStr: string) => {
|
||||
if (formatStr === 'yyyy-MM-dd') {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
return date.toISOString();
|
||||
},
|
||||
}));
|
||||
|
||||
describe('QuickAddAppointment', () => {
|
||||
const mockMutateAsync = vi.fn();
|
||||
|
||||
// Helper functions to get form elements by label text (since labels don't have htmlFor)
|
||||
const getSelectByLabel = (labelText: string) => {
|
||||
const label = screen.getByText(labelText);
|
||||
return label.parentElement?.querySelector('select') as HTMLSelectElement;
|
||||
};
|
||||
|
||||
const getInputByLabel = (labelText: string, type: string = 'text') => {
|
||||
const label = screen.getByText(labelText);
|
||||
return label.parentElement?.querySelector(`input[type="${type}"]`) as HTMLInputElement;
|
||||
};
|
||||
|
||||
const mockServiceData = [
|
||||
{ id: '1', name: 'Haircut', durationMinutes: 30, price: '25.00' },
|
||||
{ id: '2', name: 'Massage', durationMinutes: 60, price: '80.00' },
|
||||
{ id: '3', name: 'Consultation', durationMinutes: 15, price: '0.00' },
|
||||
];
|
||||
|
||||
const mockResourceData = [
|
||||
{ id: '1', name: 'Room 1' },
|
||||
{ id: '2', name: 'Chair A' },
|
||||
{ id: '3', name: 'Therapist Jane' },
|
||||
];
|
||||
|
||||
const mockCustomerData = [
|
||||
{ id: '1', name: 'John Doe', email: 'john@example.com', status: 'Active' },
|
||||
{ id: '2', name: 'Jane Smith', email: 'jane@example.com', status: 'Active' },
|
||||
{ id: '3', name: 'Inactive User', email: 'inactive@example.com', status: 'Inactive' },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockServices.mockReturnValue({
|
||||
data: mockServiceData,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
mockResources.mockReturnValue({
|
||||
data: mockResourceData,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
mockCustomers.mockReturnValue({
|
||||
data: mockCustomerData,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
mockCreateAppointment.mockReturnValue({
|
||||
mutateAsync: mockMutateAsync,
|
||||
isPending: false,
|
||||
});
|
||||
|
||||
mockMutateAsync.mockResolvedValue({
|
||||
id: '123',
|
||||
service: 1,
|
||||
start_time: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render the component', () => {
|
||||
render(<QuickAddAppointment />);
|
||||
expect(screen.getByText('Quick Add Appointment')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all form fields', () => {
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
expect(screen.getByText('Customer')).toBeInTheDocument();
|
||||
expect(screen.getByText('Service *')).toBeInTheDocument();
|
||||
expect(screen.getByText('Resource')).toBeInTheDocument();
|
||||
expect(screen.getByText('Date *')).toBeInTheDocument();
|
||||
expect(screen.getByText('Time *')).toBeInTheDocument();
|
||||
expect(screen.getByText('Notes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render customer dropdown with active customers only', () => {
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
const customerSelect = getSelectByLabel('Customer');
|
||||
const options = Array.from(customerSelect.options);
|
||||
|
||||
// Should have walk-in option + 2 active customers (not inactive)
|
||||
expect(options).toHaveLength(3);
|
||||
expect(options[0].textContent).toContain('Walk-in');
|
||||
expect(options[1].textContent).toContain('John Doe');
|
||||
expect(options[2].textContent).toContain('Jane Smith');
|
||||
expect(options.find(opt => opt.textContent?.includes('Inactive User'))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should render service dropdown with all services', () => {
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
const serviceSelect = getSelectByLabel('Service *');
|
||||
const options = Array.from(serviceSelect.options);
|
||||
|
||||
expect(options).toHaveLength(4); // placeholder + 3 services
|
||||
expect(options[0].textContent).toContain('Select service');
|
||||
expect(options[1].textContent).toContain('Haircut');
|
||||
expect(options[2].textContent).toContain('Massage');
|
||||
expect(options[3].textContent).toContain('Consultation');
|
||||
});
|
||||
|
||||
it('should render resource dropdown with unassigned option', () => {
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
const resourceSelect = getSelectByLabel('Resource');
|
||||
const options = Array.from(resourceSelect.options);
|
||||
|
||||
expect(options).toHaveLength(4); // unassigned + 3 resources
|
||||
expect(options[0].textContent).toContain('Unassigned');
|
||||
expect(options[1].textContent).toContain('Room 1');
|
||||
expect(options[2].textContent).toContain('Chair A');
|
||||
expect(options[3].textContent).toContain('Therapist Jane');
|
||||
});
|
||||
|
||||
it('should render time slots from 6am to 10pm in 15-minute intervals', () => {
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
const timeSelect = getSelectByLabel('Time *');
|
||||
const options = Array.from(timeSelect.options);
|
||||
|
||||
// 6am to 10pm = 17 hours * 4 slots per hour = 68 slots
|
||||
expect(options).toHaveLength(68);
|
||||
expect(options[0].value).toBe('06:00');
|
||||
expect(options[options.length - 1].value).toBe('22:45');
|
||||
});
|
||||
|
||||
it('should render submit button', () => {
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /Add Appointment/i });
|
||||
expect(submitButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should set default date to today', () => {
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
const dateInput = getInputByLabel('Date *', 'date');
|
||||
const today = new Date();
|
||||
const expectedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
|
||||
expect(dateInput.value).toBe(expectedDate);
|
||||
});
|
||||
|
||||
it('should set default time to 09:00', () => {
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
const timeSelect = getSelectByLabel('Time *');
|
||||
expect(timeSelect.value).toBe('09:00');
|
||||
});
|
||||
|
||||
it('should render notes textarea', () => {
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
const notesTextarea = screen.getByPlaceholderText('Optional notes...');
|
||||
expect(notesTextarea).toBeInTheDocument();
|
||||
expect(notesTextarea.tagName).toBe('TEXTAREA');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Validation', () => {
|
||||
it('should disable submit button when service is not selected', () => {
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /Add Appointment/i });
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should enable submit button when service is selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
const serviceSelect = getSelectByLabel('Service *');
|
||||
await user.selectOptions(serviceSelect, '1');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /Add Appointment/i });
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('should mark service field as required', () => {
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
const serviceSelect = getSelectByLabel('Service *');
|
||||
expect(serviceSelect).toHaveAttribute('required');
|
||||
});
|
||||
|
||||
it('should mark date field as required', () => {
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
const dateInput = getInputByLabel('Date *', 'date');
|
||||
expect(dateInput).toHaveAttribute('required');
|
||||
});
|
||||
|
||||
it('should mark time field as required', () => {
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
const timeSelect = getSelectByLabel('Time *');
|
||||
expect(timeSelect).toHaveAttribute('required');
|
||||
});
|
||||
|
||||
it('should set minimum date to today', () => {
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
const dateInput = getInputByLabel('Date *', 'date');
|
||||
const today = new Date();
|
||||
const expectedMin = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
|
||||
expect(dateInput.min).toBe(expectedMin);
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should allow selecting a customer', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
const customerSelect = getSelectByLabel('Customer');
|
||||
await user.selectOptions(customerSelect, '1');
|
||||
|
||||
expect(customerSelect.value).toBe('1');
|
||||
});
|
||||
|
||||
it('should allow selecting a service', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
const serviceSelect = getSelectByLabel('Service *');
|
||||
await user.selectOptions(serviceSelect, '2');
|
||||
|
||||
expect(serviceSelect.value).toBe('2');
|
||||
});
|
||||
|
||||
it('should allow selecting a resource', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
const resourceSelect = getSelectByLabel('Resource');
|
||||
await user.selectOptions(resourceSelect, '3');
|
||||
|
||||
expect(resourceSelect.value).toBe('3');
|
||||
});
|
||||
|
||||
it('should allow changing the date', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
const dateInput = getInputByLabel('Date *', 'date');
|
||||
await user.clear(dateInput);
|
||||
await user.type(dateInput, '2025-12-31');
|
||||
|
||||
expect(dateInput.value).toBe('2025-12-31');
|
||||
});
|
||||
|
||||
it('should allow changing the time', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
const timeSelect = getSelectByLabel('Time *');
|
||||
await user.selectOptions(timeSelect, '14:30');
|
||||
|
||||
expect(timeSelect.value).toBe('14:30');
|
||||
});
|
||||
|
||||
it('should allow entering notes', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
const notesTextarea = screen.getByPlaceholderText('Optional notes...');
|
||||
await user.type(notesTextarea, 'Customer requested early morning slot');
|
||||
|
||||
expect(notesTextarea).toHaveValue('Customer requested early morning slot');
|
||||
});
|
||||
|
||||
it('should display selected service duration', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
const serviceSelect = getSelectByLabel('Service *');
|
||||
await user.selectOptions(serviceSelect, '2'); // Massage - 60 minutes
|
||||
|
||||
// Duration text is split across elements, so use regex matching
|
||||
expect(screen.getByText(/Duration:/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/60.*minutes/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not display duration when no service selected', () => {
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
expect(screen.queryByText('Duration')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Submission', () => {
|
||||
it('should call createAppointment with correct data when form is submitted', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
// Fill out the form
|
||||
await user.selectOptions(getSelectByLabel('Customer'), '1');
|
||||
await user.selectOptions(getSelectByLabel('Service *'), '1');
|
||||
await user.selectOptions(getSelectByLabel('Resource'), '2');
|
||||
await user.selectOptions(getSelectByLabel('Time *'), '10:00');
|
||||
|
||||
const notesTextarea = screen.getByPlaceholderText('Optional notes...');
|
||||
await user.type(notesTextarea, 'Test appointment');
|
||||
|
||||
// Submit
|
||||
const submitButton = screen.getByRole('button', { name: /Add Appointment/i });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const callArgs = mockMutateAsync.mock.calls[0][0];
|
||||
expect(callArgs).toMatchObject({
|
||||
customerId: '1',
|
||||
customerName: 'John Doe',
|
||||
serviceId: '1',
|
||||
resourceId: '2',
|
||||
durationMinutes: 30,
|
||||
status: 'Scheduled',
|
||||
notes: 'Test appointment',
|
||||
});
|
||||
});
|
||||
|
||||
it('should send walk-in appointment when no customer selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
await user.selectOptions(getSelectByLabel('Service *'), '1');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /Add Appointment/i });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
customerId: undefined,
|
||||
customerName: 'Walk-in',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should send null resourceId when unassigned', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
await user.selectOptions(getSelectByLabel('Service *'), '1');
|
||||
// Keep resource as unassigned (default empty value)
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /Add Appointment/i });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resourceId: null,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should use service duration when creating appointment', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
await user.selectOptions(getSelectByLabel('Service *'), '2'); // Massage - 60 min
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /Add Appointment/i });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
durationMinutes: 60,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should use default 60 minutes when service has no duration', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
mockServices.mockReturnValue({
|
||||
data: [{ id: '1', name: 'Test Service', price: '10.00' }], // No durationMinutes
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
await user.selectOptions(getSelectByLabel('Service *'), '1');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /Add Appointment/i });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
durationMinutes: 60,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should calculate start time correctly from date and time', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
await user.selectOptions(getSelectByLabel('Service *'), '1');
|
||||
await user.selectOptions(getSelectByLabel('Time *'), '14:30');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /Add Appointment/i });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const callArgs = mockMutateAsync.mock.calls[0][0];
|
||||
const startTime = callArgs.startTime;
|
||||
|
||||
// Verify time is set correctly (uses today's date by default)
|
||||
expect(startTime.getHours()).toBe(14);
|
||||
expect(startTime.getMinutes()).toBe(30);
|
||||
expect(startTime instanceof Date).toBe(true);
|
||||
});
|
||||
|
||||
it('should prevent submission if required fields are missing', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
// Don't select service
|
||||
const submitButton = screen.getByRole('button', { name: /Add Appointment/i });
|
||||
await user.click(submitButton);
|
||||
|
||||
// Should not call mutation
|
||||
expect(mockMutateAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Success State', () => {
|
||||
it('should show success state after successful submission', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
await user.selectOptions(getSelectByLabel('Service *'), '1');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /Add Appointment/i });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Created!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should reset form after successful submission', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
// Fill out form
|
||||
await user.selectOptions(getSelectByLabel('Customer'), '1');
|
||||
await user.selectOptions(getSelectByLabel('Service *'), '2');
|
||||
await user.selectOptions(getSelectByLabel('Resource'), '3');
|
||||
|
||||
const notesTextarea = screen.getByPlaceholderText('Optional notes...');
|
||||
await user.type(notesTextarea, 'Test notes');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /Add Appointment/i });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSelectByLabel('Customer').value).toBe('');
|
||||
expect(getSelectByLabel('Service *').value).toBe('');
|
||||
expect(getSelectByLabel('Resource').value).toBe('');
|
||||
expect(getSelectByLabel('Time *').value).toBe('09:00');
|
||||
expect(notesTextarea).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onSuccess callback when provided', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSuccess = vi.fn();
|
||||
render(<QuickAddAppointment onSuccess={onSuccess} />);
|
||||
|
||||
await user.selectOptions(getSelectByLabel('Service *'), '1');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /Add Appointment/i });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSuccess).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should hide success state after 2 seconds', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
await user.selectOptions(getSelectByLabel('Service *'), '1');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /Add Appointment/i });
|
||||
await user.click(submitButton);
|
||||
|
||||
// Wait for Created! to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Created!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Fast-forward time by 2 seconds
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
|
||||
// Success message should be hidden
|
||||
expect(screen.queryByText('Created!')).not.toBeInTheDocument();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading State', () => {
|
||||
it('should disable submit button when mutation is pending', () => {
|
||||
mockCreateAppointment.mockReturnValue({
|
||||
mutateAsync: mockMutateAsync,
|
||||
isPending: true,
|
||||
});
|
||||
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /Creating.../i });
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should show loading spinner when mutation is pending', () => {
|
||||
mockCreateAppointment.mockReturnValue({
|
||||
mutateAsync: mockMutateAsync,
|
||||
isPending: true,
|
||||
});
|
||||
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
expect(screen.getByText('Creating...')).toBeInTheDocument();
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle API errors gracefully', async () => {
|
||||
const user = userEvent.setup();
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
mockMutateAsync.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
await user.selectOptions(getSelectByLabel('Service *'), '1');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /Add Appointment/i });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Failed to create appointment:',
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not reset form on error', async () => {
|
||||
const user = userEvent.setup();
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
mockMutateAsync.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
await user.selectOptions(getSelectByLabel('Service *'), '1');
|
||||
await user.selectOptions(getSelectByLabel('Customer'), '1');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /Add Appointment/i });
|
||||
await user.click(submitButton);
|
||||
|
||||
// Wait for error to be handled
|
||||
await waitFor(() => {
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Form should retain values
|
||||
expect(getSelectByLabel('Service *').value).toBe('1');
|
||||
expect(getSelectByLabel('Customer').value).toBe('1');
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Empty States', () => {
|
||||
it('should handle no services available', () => {
|
||||
mockServices.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
const serviceSelect = getSelectByLabel('Service *');
|
||||
const options = Array.from(serviceSelect.options);
|
||||
|
||||
expect(options).toHaveLength(1); // Only placeholder
|
||||
expect(options[0].textContent).toContain('Select service');
|
||||
});
|
||||
|
||||
it('should handle no resources available', () => {
|
||||
mockResources.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
const resourceSelect = getSelectByLabel('Resource');
|
||||
const options = Array.from(resourceSelect.options);
|
||||
|
||||
expect(options).toHaveLength(1); // Only unassigned option
|
||||
expect(options[0].textContent).toContain('Unassigned');
|
||||
});
|
||||
|
||||
it('should handle no customers available', () => {
|
||||
mockCustomers.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
const customerSelect = getSelectByLabel('Customer');
|
||||
const options = Array.from(customerSelect.options);
|
||||
|
||||
expect(options).toHaveLength(1); // Only walk-in option
|
||||
expect(options[0].textContent).toContain('Walk-in');
|
||||
});
|
||||
|
||||
it('should handle undefined data gracefully', () => {
|
||||
mockServices.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
mockResources.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
mockCustomers.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
expect(screen.getByText('Quick Add Appointment')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have proper form structure', () => {
|
||||
const { container } = render(<QuickAddAppointment />);
|
||||
|
||||
const form = container.querySelector('form');
|
||||
expect(form).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have accessible submit button', () => {
|
||||
render(<QuickAddAppointment />);
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /Add Appointment/i });
|
||||
expect(submitButton).toHaveAttribute('type', 'submit');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration', () => {
|
||||
it('should render complete workflow', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSuccess = vi.fn();
|
||||
|
||||
render(<QuickAddAppointment onSuccess={onSuccess} />);
|
||||
|
||||
// 1. Component renders
|
||||
expect(screen.getByText('Quick Add Appointment')).toBeInTheDocument();
|
||||
|
||||
// 2. Select all fields
|
||||
await user.selectOptions(getSelectByLabel('Customer'), '1');
|
||||
await user.selectOptions(getSelectByLabel('Service *'), '2');
|
||||
await user.selectOptions(getSelectByLabel('Resource'), '3');
|
||||
await user.selectOptions(getSelectByLabel('Time *'), '15:00');
|
||||
|
||||
const notesTextarea = screen.getByPlaceholderText('Optional notes...');
|
||||
await user.type(notesTextarea, 'Full test');
|
||||
|
||||
// 3. See duration display
|
||||
expect(screen.getByText(/Duration:/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/60.*minutes/i)).toBeInTheDocument();
|
||||
|
||||
// 4. Submit form
|
||||
const submitButton = screen.getByRole('button', { name: /Add Appointment/i });
|
||||
await user.click(submitButton);
|
||||
|
||||
// 5. Verify API call
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
customerId: '1',
|
||||
serviceId: '2',
|
||||
resourceId: '3',
|
||||
durationMinutes: 60,
|
||||
notes: 'Full test',
|
||||
})
|
||||
);
|
||||
|
||||
// 6. See success state
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Created!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 7. Callback fired
|
||||
expect(onSuccess).toHaveBeenCalled();
|
||||
|
||||
// 8. Form reset
|
||||
await waitFor(() => {
|
||||
expect(getSelectByLabel('Customer').value).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
836
frontend/src/components/__tests__/ResourceDetailModal.test.tsx
Normal file
836
frontend/src/components/__tests__/ResourceDetailModal.test.tsx
Normal file
@@ -0,0 +1,836 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import ResourceDetailModal from '../ResourceDetailModal';
|
||||
import { Resource } from '../../types';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, defaultValue?: string) => defaultValue || key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock Portal component
|
||||
vi.mock('../Portal', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <div data-testid="portal">{children}</div>,
|
||||
}));
|
||||
|
||||
// Mock Google Maps API
|
||||
vi.mock('@react-google-maps/api', () => ({
|
||||
useJsApiLoader: vi.fn(() => ({
|
||||
isLoaded: false,
|
||||
loadError: null,
|
||||
})),
|
||||
GoogleMap: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="google-map">{children}</div>
|
||||
),
|
||||
Marker: () => <div data-testid="map-marker" />,
|
||||
}));
|
||||
|
||||
// Mock hooks
|
||||
vi.mock('../../hooks/useResourceLocation', () => ({
|
||||
useResourceLocation: vi.fn(),
|
||||
useLiveResourceLocation: vi.fn(() => ({
|
||||
refresh: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
import { useResourceLocation, useLiveResourceLocation } from '../../hooks/useResourceLocation';
|
||||
import { useJsApiLoader } from '@react-google-maps/api';
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('ResourceDetailModal', () => {
|
||||
const mockResource: Resource = {
|
||||
id: 'resource-1',
|
||||
name: 'John Smith',
|
||||
type: 'STAFF',
|
||||
maxConcurrentEvents: 1,
|
||||
userId: 'user-1',
|
||||
};
|
||||
|
||||
const mockOnClose = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Default mock implementations
|
||||
vi.mocked(useResourceLocation).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
vi.mocked(useLiveResourceLocation).mockReturnValue({
|
||||
refresh: vi.fn(),
|
||||
} as any);
|
||||
|
||||
vi.mocked(useJsApiLoader).mockReturnValue({
|
||||
isLoaded: false,
|
||||
loadError: null,
|
||||
} as any);
|
||||
|
||||
// Mock environment variable
|
||||
import.meta.env.VITE_GOOGLE_MAPS_API_KEY = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders modal with resource name', () => {
|
||||
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('John Smith')).toBeInTheDocument();
|
||||
expect(screen.getByText('Staff Member')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders inside Portal', () => {
|
||||
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('portal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays Current Location heading', () => {
|
||||
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('Current Location')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Close functionality', () => {
|
||||
it('calls onClose when X button is clicked', () => {
|
||||
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const closeButtons = screen.getAllByRole('button');
|
||||
const xButton = closeButtons[0]; // First button is the X in header
|
||||
fireEvent.click(xButton);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onClose when footer Close button is clicked', () => {
|
||||
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const closeButtons = screen.getAllByRole('button', { name: /close/i });
|
||||
const footerButton = closeButtons[1]; // Second button is in footer
|
||||
fireEvent.click(footerButton);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading state', () => {
|
||||
it('displays loading spinner when location is loading', () => {
|
||||
vi.mocked(useResourceLocation).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const spinner = document.querySelector('.animate-spin');
|
||||
expect(spinner).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error state', () => {
|
||||
it('displays error message when location fetch fails', () => {
|
||||
vi.mocked(useResourceLocation).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
error: new Error('Failed to load'),
|
||||
} as any);
|
||||
|
||||
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('Failed to load location')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('No location data state', () => {
|
||||
it('displays no location message when hasLocation is false', () => {
|
||||
vi.mocked(useResourceLocation).mockReturnValue({
|
||||
data: {
|
||||
hasLocation: false,
|
||||
isTracking: false,
|
||||
message: 'Staff has not started tracking',
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('Staff has not started tracking')).toBeInTheDocument();
|
||||
expect(screen.getByText('Location will appear when staff is en route')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays default no location message when message is not provided', () => {
|
||||
vi.mocked(useResourceLocation).mockReturnValue({
|
||||
data: {
|
||||
hasLocation: false,
|
||||
isTracking: false,
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('No location data available')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Active job display', () => {
|
||||
it('displays active job when en route', () => {
|
||||
vi.mocked(useResourceLocation).mockReturnValue({
|
||||
data: {
|
||||
hasLocation: true,
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
isTracking: true,
|
||||
activeJob: {
|
||||
id: 1,
|
||||
title: 'Haircut - Jane Doe',
|
||||
status: 'EN_ROUTE',
|
||||
statusDisplay: 'En Route',
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('En Route')).toBeInTheDocument();
|
||||
expect(screen.getByText('Haircut - Jane Doe')).toBeInTheDocument();
|
||||
expect(screen.getByText('Live')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays active job when in progress', () => {
|
||||
vi.mocked(useResourceLocation).mockReturnValue({
|
||||
data: {
|
||||
hasLocation: true,
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
isTracking: true,
|
||||
activeJob: {
|
||||
id: 1,
|
||||
title: 'Massage - John Smith',
|
||||
status: 'IN_PROGRESS',
|
||||
statusDisplay: 'In Progress',
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('In Progress')).toBeInTheDocument();
|
||||
expect(screen.getByText('Massage - John Smith')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not display active job section when no active job', () => {
|
||||
vi.mocked(useResourceLocation).mockReturnValue({
|
||||
data: {
|
||||
hasLocation: true,
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
isTracking: false,
|
||||
activeJob: null,
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.queryByText('En Route')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('In Progress')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Google Maps fallback (no API key)', () => {
|
||||
it('displays coordinates when maps API is not available', () => {
|
||||
vi.mocked(useResourceLocation).mockReturnValue({
|
||||
data: {
|
||||
hasLocation: true,
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
isTracking: false,
|
||||
activeJob: null,
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
import.meta.env.VITE_GOOGLE_MAPS_API_KEY = '';
|
||||
|
||||
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('GPS Coordinates')).toBeInTheDocument();
|
||||
expect(screen.getByText(/40.712800, -74.006000/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Open in Google Maps')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays speed when available in fallback mode', () => {
|
||||
vi.mocked(useResourceLocation).mockReturnValue({
|
||||
data: {
|
||||
hasLocation: true,
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
speed: 10, // m/s
|
||||
isTracking: false,
|
||||
activeJob: null,
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
// Speed is converted from m/s to mph: 10 * 2.237 = 22.37 mph
|
||||
// Appears in both fallback view and details grid
|
||||
const speedLabels = screen.getAllByText('Speed');
|
||||
expect(speedLabels.length).toBeGreaterThan(0);
|
||||
const speedValues = screen.getAllByText(/22.4 mph/);
|
||||
expect(speedValues.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('displays heading when available in fallback mode', () => {
|
||||
vi.mocked(useResourceLocation).mockReturnValue({
|
||||
data: {
|
||||
hasLocation: true,
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
heading: 180,
|
||||
isTracking: false,
|
||||
activeJob: null,
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
// Appears in both fallback view and details grid
|
||||
const headingLabels = screen.getAllByText('Heading');
|
||||
expect(headingLabels.length).toBeGreaterThan(0);
|
||||
const headingValues = screen.getAllByText(/180°/);
|
||||
expect(headingValues.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders Google Maps link with correct coordinates', () => {
|
||||
vi.mocked(useResourceLocation).mockReturnValue({
|
||||
data: {
|
||||
hasLocation: true,
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
isTracking: false,
|
||||
activeJob: null,
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const link = screen.getByRole('link', { name: /open in google maps/i });
|
||||
expect(link).toHaveAttribute(
|
||||
'href',
|
||||
'https://www.google.com/maps?q=40.7128,-74.006'
|
||||
);
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Google Maps display (with API key)', () => {
|
||||
it('renders Google Map when API is loaded', () => {
|
||||
// Note: This test verifies the Google Maps rendering logic
|
||||
// In actual usage, API key would be provided via environment variable
|
||||
// For testing, we mock the loader to return isLoaded: true
|
||||
|
||||
// Mock the global google object that the Marker component expects
|
||||
(global as any).google = {
|
||||
maps: {
|
||||
SymbolPath: {
|
||||
CIRCLE: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(useJsApiLoader).mockReturnValue({
|
||||
isLoaded: true,
|
||||
loadError: null,
|
||||
} as any);
|
||||
|
||||
vi.mocked(useResourceLocation).mockReturnValue({
|
||||
data: {
|
||||
hasLocation: true,
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
isTracking: false,
|
||||
activeJob: null,
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
// Temporarily set API key for this test
|
||||
const originalKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY;
|
||||
(import.meta.env as any).VITE_GOOGLE_MAPS_API_KEY = 'test-key';
|
||||
|
||||
const { unmount } = render(
|
||||
<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('google-map')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('map-marker')).toBeInTheDocument();
|
||||
|
||||
// Cleanup
|
||||
unmount();
|
||||
(import.meta.env as any).VITE_GOOGLE_MAPS_API_KEY = originalKey;
|
||||
delete (global as any).google;
|
||||
});
|
||||
|
||||
it('shows loading spinner while maps API loads', () => {
|
||||
vi.mocked(useResourceLocation).mockReturnValue({
|
||||
data: {
|
||||
hasLocation: true,
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
isTracking: false,
|
||||
activeJob: null,
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
vi.mocked(useJsApiLoader).mockReturnValue({
|
||||
isLoaded: false,
|
||||
loadError: null,
|
||||
} as any);
|
||||
|
||||
import.meta.env.VITE_GOOGLE_MAPS_API_KEY = 'test-api-key';
|
||||
|
||||
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const spinner = document.querySelector('.animate-spin');
|
||||
expect(spinner).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Location details display', () => {
|
||||
it('displays last update timestamp', () => {
|
||||
vi.mocked(useResourceLocation).mockReturnValue({
|
||||
data: {
|
||||
hasLocation: true,
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
timestamp: '2024-01-15T14:30:00Z',
|
||||
isTracking: false,
|
||||
activeJob: null,
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('Last Update')).toBeInTheDocument();
|
||||
// Timestamp is formatted using toLocaleString, just verify it's present
|
||||
const timestampElement = screen.getByText(/2024/);
|
||||
expect(timestampElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays accuracy when available', () => {
|
||||
vi.mocked(useResourceLocation).mockReturnValue({
|
||||
data: {
|
||||
hasLocation: true,
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
accuracy: 15,
|
||||
isTracking: false,
|
||||
activeJob: null,
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('Accuracy')).toBeInTheDocument();
|
||||
expect(screen.getByText('15m')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays accuracy in kilometers when over 1000m', () => {
|
||||
vi.mocked(useResourceLocation).mockReturnValue({
|
||||
data: {
|
||||
hasLocation: true,
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
accuracy: 2500,
|
||||
isTracking: false,
|
||||
activeJob: null,
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('2.5km')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays speed when available', () => {
|
||||
vi.mocked(useResourceLocation).mockReturnValue({
|
||||
data: {
|
||||
hasLocation: true,
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
speed: 15, // m/s
|
||||
isTracking: false,
|
||||
activeJob: null,
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
// Speed section in details
|
||||
const speedLabels = screen.getAllByText('Speed');
|
||||
expect(speedLabels.length).toBeGreaterThan(0);
|
||||
// 15 m/s * 2.237 = 33.6 mph
|
||||
const speedValues = screen.getAllByText(/33.6 mph/);
|
||||
expect(speedValues.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('displays heading when available', () => {
|
||||
vi.mocked(useResourceLocation).mockReturnValue({
|
||||
data: {
|
||||
hasLocation: true,
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
heading: 270,
|
||||
isTracking: false,
|
||||
activeJob: null,
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const headingLabels = screen.getAllByText('Heading');
|
||||
expect(headingLabels.length).toBeGreaterThan(0);
|
||||
const headingValues = screen.getAllByText(/270°/);
|
||||
expect(headingValues.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('does not display speed when null', () => {
|
||||
vi.mocked(useResourceLocation).mockReturnValue({
|
||||
data: {
|
||||
hasLocation: true,
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
speed: null,
|
||||
isTracking: false,
|
||||
activeJob: null,
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Speed')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays speed when 0', () => {
|
||||
vi.mocked(useResourceLocation).mockReturnValue({
|
||||
data: {
|
||||
hasLocation: true,
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
speed: 0,
|
||||
isTracking: false,
|
||||
activeJob: null,
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const speedLabels = screen.getAllByText('Speed');
|
||||
expect(speedLabels.length).toBeGreaterThan(0);
|
||||
const speedValues = screen.getAllByText(/0.0 mph/);
|
||||
expect(speedValues.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Live tracking indicator', () => {
|
||||
it('displays live tracking badge when tracking is active', () => {
|
||||
vi.mocked(useResourceLocation).mockReturnValue({
|
||||
data: {
|
||||
hasLocation: true,
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
isTracking: true,
|
||||
activeJob: {
|
||||
id: 1,
|
||||
title: 'Test Job',
|
||||
status: 'EN_ROUTE',
|
||||
statusDisplay: 'En Route',
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('Live')).toBeInTheDocument();
|
||||
const liveBadge = screen.getByText('Live').parentElement;
|
||||
expect(liveBadge?.querySelector('.animate-pulse')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not display live tracking badge when not tracking', () => {
|
||||
vi.mocked(useResourceLocation).mockReturnValue({
|
||||
data: {
|
||||
hasLocation: true,
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
isTracking: false,
|
||||
activeJob: null,
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Live')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Live location updates hook', () => {
|
||||
it('calls useLiveResourceLocation with resource ID', () => {
|
||||
vi.mocked(useResourceLocation).mockReturnValue({
|
||||
data: {
|
||||
hasLocation: true,
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
isTracking: true,
|
||||
activeJob: null,
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(useLiveResourceLocation).toHaveBeenCalledWith('resource-1', {
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('disables live updates when tracking is false', () => {
|
||||
vi.mocked(useResourceLocation).mockReturnValue({
|
||||
data: {
|
||||
hasLocation: true,
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
isTracking: false,
|
||||
activeJob: null,
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(useLiveResourceLocation).toHaveBeenCalledWith('resource-1', {
|
||||
enabled: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Status color coding', () => {
|
||||
it('applies yellow styling for EN_ROUTE status', () => {
|
||||
vi.mocked(useResourceLocation).mockReturnValue({
|
||||
data: {
|
||||
hasLocation: true,
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
isTracking: true,
|
||||
activeJob: {
|
||||
id: 1,
|
||||
title: 'Test Job',
|
||||
status: 'EN_ROUTE',
|
||||
statusDisplay: 'En Route',
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
// Find the parent container with the colored border and background
|
||||
const statusSection = screen.getByText('En Route').closest('.p-4');
|
||||
expect(statusSection?.className).toMatch(/yellow/);
|
||||
});
|
||||
|
||||
it('applies blue styling for IN_PROGRESS status', () => {
|
||||
vi.mocked(useResourceLocation).mockReturnValue({
|
||||
data: {
|
||||
hasLocation: true,
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
isTracking: true,
|
||||
activeJob: {
|
||||
id: 1,
|
||||
title: 'Test Job',
|
||||
status: 'IN_PROGRESS',
|
||||
statusDisplay: 'In Progress',
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
// Find the parent container with the colored border and background
|
||||
const statusSection = screen.getByText('In Progress').closest('.p-4');
|
||||
expect(statusSection?.className).toMatch(/blue/);
|
||||
});
|
||||
|
||||
it('applies gray styling for other status', () => {
|
||||
vi.mocked(useResourceLocation).mockReturnValue({
|
||||
data: {
|
||||
hasLocation: true,
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
isTracking: true,
|
||||
activeJob: {
|
||||
id: 1,
|
||||
title: 'Test Job',
|
||||
status: 'COMPLETED',
|
||||
statusDisplay: 'Completed',
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
// Find the parent container with the colored border and background
|
||||
const statusSection = screen.getByText('Completed').closest('.p-4');
|
||||
expect(statusSection?.className).toMatch(/gray/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('has accessible close button label', () => {
|
||||
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const srOnly = document.querySelector('.sr-only');
|
||||
expect(srOnly?.textContent).toBe('common.close');
|
||||
});
|
||||
|
||||
it('renders with proper heading hierarchy', () => {
|
||||
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 3 });
|
||||
expect(heading).toHaveTextContent('John Smith');
|
||||
});
|
||||
});
|
||||
});
|
||||
348
frontend/src/components/__tests__/StaffPermissions.test.tsx
Normal file
348
frontend/src/components/__tests__/StaffPermissions.test.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import StaffPermissions, {
|
||||
PERMISSION_CONFIGS,
|
||||
SETTINGS_PERMISSION_CONFIGS,
|
||||
getDefaultPermissions,
|
||||
} from '../StaffPermissions';
|
||||
|
||||
// Mock react-i18next BEFORE imports
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string) => fallback || key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock('lucide-react', () => ({
|
||||
ChevronDown: () => React.createElement('div', { 'data-testid': 'chevron-down' }),
|
||||
ChevronRight: () => React.createElement('div', { 'data-testid': 'chevron-right' }),
|
||||
}));
|
||||
|
||||
describe('StaffPermissions', () => {
|
||||
const defaultProps = {
|
||||
role: 'staff' as const,
|
||||
permissions: {},
|
||||
onChange: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders component with title', () => {
|
||||
render(React.createElement(StaffPermissions, defaultProps));
|
||||
expect(screen.getByText('Staff Permissions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all regular permission checkboxes', () => {
|
||||
render(React.createElement(StaffPermissions, defaultProps));
|
||||
|
||||
PERMISSION_CONFIGS.forEach((config) => {
|
||||
expect(screen.getByText(config.labelDefault)).toBeInTheDocument();
|
||||
expect(screen.getByText(config.hintDefault)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders business settings section', () => {
|
||||
render(React.createElement(StaffPermissions, defaultProps));
|
||||
expect(screen.getByText('Can access business settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show settings sub-permissions when settings is disabled', () => {
|
||||
render(
|
||||
React.createElement(StaffPermissions, {
|
||||
...defaultProps,
|
||||
permissions: { can_access_settings: false },
|
||||
})
|
||||
);
|
||||
|
||||
// Settings sub-permissions should not be visible
|
||||
expect(screen.queryByText('General Settings')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Business Hours')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('permission toggling', () => {
|
||||
it('calls onChange when regular permission is toggled', () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
React.createElement(StaffPermissions, {
|
||||
...defaultProps,
|
||||
onChange,
|
||||
})
|
||||
);
|
||||
|
||||
const checkbox = screen
|
||||
.getByText('Can invite new staff members')
|
||||
.closest('label')
|
||||
?.querySelector('input');
|
||||
if (checkbox) {
|
||||
fireEvent.click(checkbox);
|
||||
}
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ can_invite_staff: true })
|
||||
);
|
||||
});
|
||||
|
||||
it('reflects checked state from permissions prop', () => {
|
||||
render(
|
||||
React.createElement(StaffPermissions, {
|
||||
...defaultProps,
|
||||
permissions: { can_invite_staff: true },
|
||||
})
|
||||
);
|
||||
|
||||
const checkbox = screen
|
||||
.getByText('Can invite new staff members')
|
||||
.closest('label')
|
||||
?.querySelector('input') as HTMLInputElement;
|
||||
|
||||
expect(checkbox.checked).toBe(true);
|
||||
});
|
||||
|
||||
it('uses default values for unconfigured permissions', () => {
|
||||
render(React.createElement(StaffPermissions, defaultProps));
|
||||
|
||||
// can_manage_own_appointments has defaultValue: true
|
||||
const checkbox = screen
|
||||
.getByText('Can manage own appointments')
|
||||
.closest('label')
|
||||
?.querySelector('input') as HTMLInputElement;
|
||||
|
||||
expect(checkbox.checked).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('business settings section', () => {
|
||||
it('expands settings section when settings checkbox is enabled', () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
React.createElement(StaffPermissions, {
|
||||
...defaultProps,
|
||||
onChange,
|
||||
})
|
||||
);
|
||||
|
||||
// Find all checkboxes and get the last one (settings checkbox)
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
const settingsCheckbox = checkboxes[checkboxes.length - 1];
|
||||
|
||||
fireEvent.click(settingsCheckbox);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ can_access_settings: true })
|
||||
);
|
||||
});
|
||||
|
||||
it('shows settings sub-permissions when expanded', async () => {
|
||||
render(
|
||||
React.createElement(StaffPermissions, {
|
||||
...defaultProps,
|
||||
permissions: { can_access_settings: true },
|
||||
})
|
||||
);
|
||||
|
||||
const settingsDiv = screen.getByText('Can access business settings').closest('div');
|
||||
if (settingsDiv) {
|
||||
fireEvent.click(settingsDiv);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('General Settings')).toBeInTheDocument();
|
||||
expect(screen.getByText('Business Hours')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows multiple enabled sub-settings count', () => {
|
||||
render(
|
||||
React.createElement(StaffPermissions, {
|
||||
...defaultProps,
|
||||
permissions: {
|
||||
can_access_settings: true,
|
||||
can_access_settings_general: true,
|
||||
can_access_settings_business_hours: true,
|
||||
},
|
||||
onChange: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
// The component should render with settings enabled
|
||||
expect(screen.getByText(/\(2\/\d+ enabled\)/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows enabled settings count badge', () => {
|
||||
render(
|
||||
React.createElement(StaffPermissions, {
|
||||
...defaultProps,
|
||||
permissions: {
|
||||
can_access_settings: true,
|
||||
can_access_settings_general: true,
|
||||
can_access_settings_branding: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByText(/2\/\d+ enabled/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles expansion with chevron button', async () => {
|
||||
render(
|
||||
React.createElement(StaffPermissions, {
|
||||
...defaultProps,
|
||||
permissions: { can_access_settings: true },
|
||||
})
|
||||
);
|
||||
|
||||
// Find and click the chevron button
|
||||
const chevronButton = screen.getByTestId('chevron-right').closest('button');
|
||||
if (chevronButton) {
|
||||
fireEvent.click(chevronButton);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('General Settings')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('settings sub-permissions', () => {
|
||||
it('shows select all and select none buttons when expanded', async () => {
|
||||
render(
|
||||
React.createElement(StaffPermissions, {
|
||||
...defaultProps,
|
||||
permissions: { can_access_settings: true },
|
||||
})
|
||||
);
|
||||
|
||||
const settingsDiv = screen.getByText('Can access business settings').closest('div');
|
||||
if (settingsDiv) {
|
||||
fireEvent.click(settingsDiv);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Select All')).toBeInTheDocument();
|
||||
expect(screen.getByText('Select None')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('selects all settings when select all is clicked', async () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
React.createElement(StaffPermissions, {
|
||||
...defaultProps,
|
||||
permissions: { can_access_settings: true },
|
||||
onChange,
|
||||
})
|
||||
);
|
||||
|
||||
const settingsDiv = screen.getByText('Can access business settings').closest('div');
|
||||
if (settingsDiv) {
|
||||
fireEvent.click(settingsDiv);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
const selectAllButton = screen.getByText('Select All');
|
||||
fireEvent.click(selectAllButton);
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0];
|
||||
|
||||
SETTINGS_PERMISSION_CONFIGS.forEach((config) => {
|
||||
expect(lastCall[config.key]).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows expanded state when settings has sub-permissions enabled', () => {
|
||||
render(
|
||||
React.createElement(StaffPermissions, {
|
||||
...defaultProps,
|
||||
permissions: {
|
||||
can_access_settings: true,
|
||||
can_access_settings_general: true,
|
||||
},
|
||||
onChange: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
// Should show the settings count badge
|
||||
expect(screen.getByText(/1\/\d+ enabled/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles individual settings permission', async () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
React.createElement(StaffPermissions, {
|
||||
...defaultProps,
|
||||
permissions: { can_access_settings: true },
|
||||
onChange,
|
||||
})
|
||||
);
|
||||
|
||||
const settingsDiv = screen.getByText('Can access business settings').closest('div');
|
||||
if (settingsDiv) {
|
||||
fireEvent.click(settingsDiv);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
const generalCheckbox = screen
|
||||
.getByText('General Settings')
|
||||
.closest('label')
|
||||
?.querySelector('input');
|
||||
if (generalCheckbox) {
|
||||
fireEvent.click(generalCheckbox);
|
||||
}
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ can_access_settings_general: true })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('variant props', () => {
|
||||
it('accepts invite variant', () => {
|
||||
render(
|
||||
React.createElement(StaffPermissions, {
|
||||
...defaultProps,
|
||||
variant: 'invite',
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByText('Staff Permissions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('accepts edit variant', () => {
|
||||
render(
|
||||
React.createElement(StaffPermissions, {
|
||||
...defaultProps,
|
||||
variant: 'edit',
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByText('Staff Permissions')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDefaultPermissions helper', () => {
|
||||
it('returns default values for all permissions', () => {
|
||||
const defaults = getDefaultPermissions();
|
||||
|
||||
expect(defaults).toHaveProperty('can_access_settings', false);
|
||||
expect(defaults).toHaveProperty('can_manage_own_appointments', true);
|
||||
expect(defaults).toHaveProperty('can_invite_staff', false);
|
||||
|
||||
PERMISSION_CONFIGS.forEach((config) => {
|
||||
expect(defaults).toHaveProperty(config.key, config.defaultValue);
|
||||
});
|
||||
|
||||
SETTINGS_PERMISSION_CONFIGS.forEach((config) => {
|
||||
expect(defaults).toHaveProperty(config.key, config.defaultValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
241
frontend/src/components/__tests__/UserProfileDropdown.test.tsx
Normal file
241
frontend/src/components/__tests__/UserProfileDropdown.test.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import UserProfileDropdown from '../UserProfileDropdown';
|
||||
import { User } from '../../types';
|
||||
|
||||
// Mock react-router-dom BEFORE imports
|
||||
vi.mock('react-router-dom', () => ({
|
||||
Link: ({ to, children, ...props }: any) =>
|
||||
React.createElement('a', { ...props, href: to }, children),
|
||||
useLocation: () => ({ pathname: '/dashboard' }),
|
||||
}));
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string) => fallback || key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock('lucide-react', () => ({
|
||||
User: () => React.createElement('div', { 'data-testid': 'user-icon' }),
|
||||
Settings: () => React.createElement('div', { 'data-testid': 'settings-icon' }),
|
||||
LogOut: () => React.createElement('div', { 'data-testid': 'logout-icon' }),
|
||||
ChevronDown: () => React.createElement('div', { 'data-testid': 'chevron-icon' }),
|
||||
}));
|
||||
|
||||
// Mock useAuth hook
|
||||
const mockLogout = vi.fn();
|
||||
vi.mock('../../hooks/useAuth', () => ({
|
||||
useLogout: () => ({
|
||||
mutate: mockLogout,
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('UserProfileDropdown', () => {
|
||||
const mockUser: User = {
|
||||
id: 1,
|
||||
email: 'john@example.com',
|
||||
name: 'John Doe',
|
||||
role: 'owner' as any,
|
||||
phone: '',
|
||||
isActive: true,
|
||||
permissions: {},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders user name', () => {
|
||||
render(React.createElement(UserProfileDropdown, { user: mockUser }));
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders formatted role', () => {
|
||||
render(React.createElement(UserProfileDropdown, { user: mockUser }));
|
||||
expect(screen.getByText('Owner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders formatted role with underscores replaced', () => {
|
||||
const staffUser = { ...mockUser, role: 'platform_manager' as any };
|
||||
render(React.createElement(UserProfileDropdown, { user: staffUser }));
|
||||
expect(screen.getByText('Platform Manager')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders user avatar when avatarUrl is provided', () => {
|
||||
const userWithAvatar = { ...mockUser, avatarUrl: 'https://example.com/avatar.jpg' };
|
||||
const { container } = render(React.createElement(UserProfileDropdown, { user: userWithAvatar }));
|
||||
const img = container.querySelector('img[alt="John Doe"]');
|
||||
expect(img).toBeInTheDocument();
|
||||
expect(img).toHaveAttribute('src', 'https://example.com/avatar.jpg');
|
||||
});
|
||||
|
||||
it('renders user initials when no avatar', () => {
|
||||
render(React.createElement(UserProfileDropdown, { user: mockUser }));
|
||||
expect(screen.getByText('JD')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders single letter initial for single name', () => {
|
||||
const singleNameUser = { ...mockUser, name: 'Madonna' };
|
||||
render(React.createElement(UserProfileDropdown, { user: singleNameUser }));
|
||||
expect(screen.getByText('M')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders first two initials for multi-word name', () => {
|
||||
const multiNameUser = { ...mockUser, name: 'John Paul Jones' };
|
||||
render(React.createElement(UserProfileDropdown, { user: multiNameUser }));
|
||||
expect(screen.getByText('JP')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dropdown interaction', () => {
|
||||
it('is closed by default', () => {
|
||||
render(React.createElement(UserProfileDropdown, { user: mockUser }));
|
||||
expect(screen.queryByText('Profile Settings')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens dropdown when button clicked', () => {
|
||||
render(React.createElement(UserProfileDropdown, { user: mockUser }));
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
expect(screen.getByText('Profile Settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows user email in dropdown header', () => {
|
||||
render(React.createElement(UserProfileDropdown, { user: mockUser }));
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
expect(screen.getByText('john@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes dropdown when clicking outside', async () => {
|
||||
const { container } = render(React.createElement(UserProfileDropdown, { user: mockUser }));
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
expect(screen.getByText('Profile Settings')).toBeInTheDocument();
|
||||
|
||||
// Click outside
|
||||
fireEvent.mouseDown(document.body);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Profile Settings')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('closes dropdown on escape key', async () => {
|
||||
render(React.createElement(UserProfileDropdown, { user: mockUser }));
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
expect(screen.getByText('Profile Settings')).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Profile Settings')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('sets aria-expanded attribute correctly', () => {
|
||||
render(React.createElement(UserProfileDropdown, { user: mockUser }));
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
fireEvent.click(button);
|
||||
expect(button).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('navigation', () => {
|
||||
it('links to /profile for non-platform routes', () => {
|
||||
const { container } = render(React.createElement(UserProfileDropdown, { user: mockUser }));
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
const link = container.querySelector('a[href="/profile"]');
|
||||
expect(link).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('profile settings link renders correctly', () => {
|
||||
render(React.createElement(UserProfileDropdown, { user: mockUser }));
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.getByText('Profile Settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes dropdown when profile link is clicked', async () => {
|
||||
render(React.createElement(UserProfileDropdown, { user: mockUser }));
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
const profileLink = screen.getByText('Profile Settings');
|
||||
fireEvent.click(profileLink);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Sign Out')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sign out', () => {
|
||||
it('renders sign out button', () => {
|
||||
render(React.createElement(UserProfileDropdown, { user: mockUser }));
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
expect(screen.getByText('Sign Out')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls logout when sign out clicked', () => {
|
||||
render(React.createElement(UserProfileDropdown, { user: mockUser }));
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
const signOutButton = screen.getByText('Sign Out');
|
||||
fireEvent.click(signOutButton);
|
||||
|
||||
expect(mockLogout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sign out button is functional', () => {
|
||||
render(React.createElement(UserProfileDropdown, { user: mockUser }));
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
const signOutButton = screen.getByText('Sign Out').closest('button');
|
||||
expect(signOutButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('variants', () => {
|
||||
it('applies default variant styles', () => {
|
||||
const { container } = render(React.createElement(UserProfileDropdown, { user: mockUser }));
|
||||
const button = container.querySelector('button');
|
||||
expect(button?.className).toContain('border-gray-200');
|
||||
});
|
||||
|
||||
it('applies light variant styles', () => {
|
||||
const { container } = render(
|
||||
React.createElement(UserProfileDropdown, {
|
||||
user: mockUser,
|
||||
variant: 'light',
|
||||
})
|
||||
);
|
||||
const button = container.querySelector('button');
|
||||
expect(button?.className).toContain('border-white/20');
|
||||
});
|
||||
|
||||
it('shows white text in light variant', () => {
|
||||
const { container } = render(
|
||||
React.createElement(UserProfileDropdown, {
|
||||
user: mockUser,
|
||||
variant: 'light',
|
||||
})
|
||||
);
|
||||
const userName = screen.getByText('John Doe');
|
||||
expect(userName.className).toContain('text-white');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { ManualSchedulingRequest } from '../ManualSchedulingRequest';
|
||||
|
||||
// Mock Lucide icons
|
||||
vi.mock('lucide-react', () => ({
|
||||
Phone: () => <span data-testid="icon-phone" />,
|
||||
Calendar: () => <span data-testid="icon-calendar" />,
|
||||
Clock: () => <span data-testid="icon-clock" />,
|
||||
Check: () => <span data-testid="icon-check" />,
|
||||
}));
|
||||
|
||||
describe('ManualSchedulingRequest', () => {
|
||||
const mockService = {
|
||||
id: 1,
|
||||
name: 'Consultation',
|
||||
description: 'Professional consultation',
|
||||
duration: 60,
|
||||
price_cents: 10000,
|
||||
photos: [],
|
||||
capture_preferred_time: true,
|
||||
};
|
||||
|
||||
const mockServiceNoPreferredTime = {
|
||||
...mockService,
|
||||
capture_preferred_time: false,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
service: mockService,
|
||||
onPreferredTimeChange: vi.fn(),
|
||||
preferredDate: null,
|
||||
preferredTimeNotes: '',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the call message', () => {
|
||||
render(React.createElement(ManualSchedulingRequest, defaultProps));
|
||||
expect(screen.getByText("We'll call you to schedule")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Our team will contact you within 24 hours/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays service name in message', () => {
|
||||
render(React.createElement(ManualSchedulingRequest, defaultProps));
|
||||
expect(screen.getByText(/Consultation/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows phone icon', () => {
|
||||
render(React.createElement(ManualSchedulingRequest, defaultProps));
|
||||
expect(screen.getByTestId('icon-phone')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows preferred time section when capture_preferred_time is true', () => {
|
||||
render(React.createElement(ManualSchedulingRequest, defaultProps));
|
||||
expect(screen.getByText('I have a preferred time')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides preferred time section when capture_preferred_time is false', () => {
|
||||
const props = { ...defaultProps, service: mockServiceNoPreferredTime };
|
||||
render(React.createElement(ManualSchedulingRequest, props));
|
||||
expect(screen.queryByText('I have a preferred time')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows checkbox for preferred time', () => {
|
||||
render(React.createElement(ManualSchedulingRequest, defaultProps));
|
||||
const checkbox = screen.getByText('I have a preferred time').closest('div[class*="cursor-pointer"]');
|
||||
expect(checkbox).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles preferred time inputs when checkbox is clicked', () => {
|
||||
render(React.createElement(ManualSchedulingRequest, defaultProps));
|
||||
|
||||
// Initially hidden
|
||||
expect(screen.queryByPlaceholderText('e.g., Morning, After 2pm, Weekends only')).not.toBeInTheDocument();
|
||||
|
||||
// Click to show
|
||||
const checkbox = screen.getByText('I have a preferred time').closest('div[class*="cursor-pointer"]');
|
||||
fireEvent.click(checkbox!);
|
||||
|
||||
// Now visible
|
||||
expect(screen.getByPlaceholderText('e.g., Morning, After 2pm, Weekends only')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays date input when preferred time is enabled', () => {
|
||||
render(React.createElement(ManualSchedulingRequest, defaultProps));
|
||||
|
||||
const checkbox = screen.getByText('I have a preferred time').closest('div[class*="cursor-pointer"]');
|
||||
fireEvent.click(checkbox!);
|
||||
|
||||
const dateInput = document.querySelector('input[type="date"]');
|
||||
expect(dateInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays time notes input when preferred time is enabled', () => {
|
||||
render(React.createElement(ManualSchedulingRequest, defaultProps));
|
||||
|
||||
const checkbox = screen.getByText('I have a preferred time').closest('div[class*="cursor-pointer"]');
|
||||
fireEvent.click(checkbox!);
|
||||
|
||||
expect(screen.getByPlaceholderText('e.g., Morning, After 2pm, Weekends only')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onPreferredTimeChange with null when toggling off', () => {
|
||||
const onPreferredTimeChange = vi.fn();
|
||||
const props = { ...defaultProps, onPreferredTimeChange, preferredDate: '2024-12-20', preferredTimeNotes: 'Morning' };
|
||||
|
||||
render(React.createElement(ManualSchedulingRequest, props));
|
||||
|
||||
// Should be enabled initially
|
||||
const checkbox = screen.getByText('I have a preferred time').closest('div[class*="cursor-pointer"]');
|
||||
fireEvent.click(checkbox!);
|
||||
|
||||
expect(onPreferredTimeChange).toHaveBeenCalledWith(null, '');
|
||||
});
|
||||
|
||||
it('calls onPreferredTimeChange when date changes', () => {
|
||||
const onPreferredTimeChange = vi.fn();
|
||||
const props = { ...defaultProps, onPreferredTimeChange };
|
||||
|
||||
render(React.createElement(ManualSchedulingRequest, props));
|
||||
|
||||
const checkbox = screen.getByText('I have a preferred time').closest('div[class*="cursor-pointer"]');
|
||||
fireEvent.click(checkbox!);
|
||||
|
||||
const dateInput = document.querySelector('input[type="date"]') as HTMLInputElement;
|
||||
fireEvent.change(dateInput, { target: { value: '2024-12-25' } });
|
||||
|
||||
expect(onPreferredTimeChange).toHaveBeenCalledWith('2024-12-25', '');
|
||||
});
|
||||
|
||||
it('calls onPreferredTimeChange when notes change', () => {
|
||||
const onPreferredTimeChange = vi.fn();
|
||||
const props = { ...defaultProps, onPreferredTimeChange };
|
||||
|
||||
render(React.createElement(ManualSchedulingRequest, props));
|
||||
|
||||
const checkbox = screen.getByText('I have a preferred time').closest('div[class*="cursor-pointer"]');
|
||||
fireEvent.click(checkbox!);
|
||||
|
||||
const notesInput = screen.getByPlaceholderText('e.g., Morning, After 2pm, Weekends only');
|
||||
fireEvent.change(notesInput, { target: { value: 'Afternoon preferred' } });
|
||||
|
||||
expect(onPreferredTimeChange).toHaveBeenCalledWith(null, 'Afternoon preferred');
|
||||
});
|
||||
|
||||
it('shows calendar icon when inputs are visible', () => {
|
||||
render(React.createElement(ManualSchedulingRequest, defaultProps));
|
||||
|
||||
const checkbox = screen.getByText('I have a preferred time').closest('div[class*="cursor-pointer"]');
|
||||
fireEvent.click(checkbox!);
|
||||
|
||||
expect(screen.getByTestId('icon-calendar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows clock icon when inputs are visible', () => {
|
||||
render(React.createElement(ManualSchedulingRequest, defaultProps));
|
||||
|
||||
const checkbox = screen.getByText('I have a preferred time').closest('div[class*="cursor-pointer"]');
|
||||
fireEvent.click(checkbox!);
|
||||
|
||||
expect(screen.getByTestId('icon-clock')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays "What happens next?" section', () => {
|
||||
render(React.createElement(ManualSchedulingRequest, defaultProps));
|
||||
expect(screen.getByText('What happens next?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays three steps in "What happens next?"', () => {
|
||||
render(React.createElement(ManualSchedulingRequest, defaultProps));
|
||||
expect(screen.getByText('Complete your booking request')).toBeInTheDocument();
|
||||
expect(screen.getByText("We'll call you within 24 hours to schedule")).toBeInTheDocument();
|
||||
expect(screen.getByText('Confirm your appointment time over the phone')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sets minimum date to tomorrow', () => {
|
||||
render(React.createElement(ManualSchedulingRequest, defaultProps));
|
||||
|
||||
const checkbox = screen.getByText('I have a preferred time').closest('div[class*="cursor-pointer"]');
|
||||
fireEvent.click(checkbox!);
|
||||
|
||||
const dateInput = document.querySelector('input[type="date"]') as HTMLInputElement;
|
||||
expect(dateInput).toHaveAttribute('min');
|
||||
|
||||
const minDate = dateInput.getAttribute('min');
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const expectedMin = tomorrow.toISOString().split('T')[0];
|
||||
|
||||
expect(minDate).toBe(expectedMin);
|
||||
});
|
||||
|
||||
it('shows check icon when preferred time is selected', () => {
|
||||
render(React.createElement(ManualSchedulingRequest, defaultProps));
|
||||
|
||||
const checkbox = screen.getByText('I have a preferred time').closest('div[class*="cursor-pointer"]');
|
||||
fireEvent.click(checkbox!);
|
||||
|
||||
expect(screen.getByTestId('icon-check')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays preferred date label', () => {
|
||||
render(React.createElement(ManualSchedulingRequest, defaultProps));
|
||||
|
||||
const checkbox = screen.getByText('I have a preferred time').closest('div[class*="cursor-pointer"]');
|
||||
fireEvent.click(checkbox!);
|
||||
|
||||
expect(screen.getByText('Preferred Date')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays time preference label', () => {
|
||||
render(React.createElement(ManualSchedulingRequest, defaultProps));
|
||||
|
||||
const checkbox = screen.getByText('I have a preferred time').closest('div[class*="cursor-pointer"]');
|
||||
fireEvent.click(checkbox!);
|
||||
|
||||
expect(screen.getByText('Time Preference')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays helper text for time preference', () => {
|
||||
render(React.createElement(ManualSchedulingRequest, defaultProps));
|
||||
|
||||
const checkbox = screen.getByText('I have a preferred time').closest('div[class*="cursor-pointer"]');
|
||||
fireEvent.click(checkbox!);
|
||||
|
||||
expect(screen.getByText('Any general time preferences that would work for you')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('highlights checkbox area when preferred time is selected', () => {
|
||||
render(React.createElement(ManualSchedulingRequest, defaultProps));
|
||||
|
||||
const checkboxArea = screen.getByText('I have a preferred time').closest('div[class*="cursor-pointer"]');
|
||||
|
||||
// Not highlighted initially
|
||||
expect(checkboxArea).not.toHaveClass('border-blue-500');
|
||||
|
||||
fireEvent.click(checkboxArea!);
|
||||
|
||||
// Highlighted after click
|
||||
expect(checkboxArea).toHaveClass('border-blue-500');
|
||||
});
|
||||
|
||||
it('preserves existing date when notes change', () => {
|
||||
const onPreferredTimeChange = vi.fn();
|
||||
const props = { ...defaultProps, onPreferredTimeChange, preferredDate: '2024-12-20' };
|
||||
|
||||
render(React.createElement(ManualSchedulingRequest, props));
|
||||
|
||||
// Inputs should already be visible since preferredDate is set
|
||||
const notesInput = screen.getByPlaceholderText('e.g., Morning, After 2pm, Weekends only');
|
||||
fireEvent.change(notesInput, { target: { value: 'Morning' } });
|
||||
|
||||
expect(onPreferredTimeChange).toHaveBeenCalledWith('2024-12-20', 'Morning');
|
||||
});
|
||||
|
||||
it('preserves existing notes when date changes', () => {
|
||||
const onPreferredTimeChange = vi.fn();
|
||||
const props = { ...defaultProps, onPreferredTimeChange, preferredTimeNotes: 'Morning' };
|
||||
|
||||
render(React.createElement(ManualSchedulingRequest, props));
|
||||
|
||||
// Inputs should already be visible since preferredTimeNotes is set
|
||||
const dateInput = document.querySelector('input[type="date"]') as HTMLInputElement;
|
||||
expect(dateInput).toBeTruthy();
|
||||
|
||||
fireEvent.change(dateInput, { target: { value: '2024-12-25' } });
|
||||
|
||||
expect(onPreferredTimeChange).toHaveBeenCalledWith('2024-12-25', 'Morning');
|
||||
});
|
||||
|
||||
it('initializes with preferred time enabled when date is set', () => {
|
||||
const props = { ...defaultProps, preferredDate: '2024-12-20' };
|
||||
render(React.createElement(ManualSchedulingRequest, props));
|
||||
|
||||
// Should show inputs immediately
|
||||
expect(screen.getByPlaceholderText('e.g., Morning, After 2pm, Weekends only')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('initializes with preferred time enabled when notes are set', () => {
|
||||
const props = { ...defaultProps, preferredTimeNotes: 'Morning preferred' };
|
||||
render(React.createElement(ManualSchedulingRequest, props));
|
||||
|
||||
// Should show inputs immediately
|
||||
expect(screen.getByPlaceholderText('e.g., Morning, After 2pm, Weekends only')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays step numbers in order', () => {
|
||||
render(React.createElement(ManualSchedulingRequest, defaultProps));
|
||||
|
||||
const stepNumbers = screen.getAllByText(/^[123]$/);
|
||||
expect(stepNumbers).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { PaymentSection } from '../PaymentSection';
|
||||
|
||||
// Mock Lucide icons
|
||||
vi.mock('lucide-react', () => ({
|
||||
CreditCard: () => <span data-testid="icon-credit-card" />,
|
||||
ShieldCheck: () => <span data-testid="icon-shield-check" />,
|
||||
Lock: () => <span data-testid="icon-lock" />,
|
||||
}));
|
||||
|
||||
describe('PaymentSection', () => {
|
||||
const mockService = {
|
||||
id: 1,
|
||||
name: 'Haircut',
|
||||
description: 'A professional haircut',
|
||||
duration: 30,
|
||||
price_cents: 2500,
|
||||
photos: [],
|
||||
deposit_amount_cents: 0,
|
||||
};
|
||||
|
||||
const mockServiceWithDeposit = {
|
||||
...mockService,
|
||||
deposit_amount_cents: 1000,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
service: mockService,
|
||||
onPaymentComplete: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders payment form', () => {
|
||||
render(React.createElement(PaymentSection, defaultProps));
|
||||
expect(screen.getByText('Card Details')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('0000 0000 0000 0000')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('MM / YY')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('123')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays service total price', () => {
|
||||
render(React.createElement(PaymentSection, defaultProps));
|
||||
expect(screen.getByText('Service Total')).toBeInTheDocument();
|
||||
const prices = screen.getAllByText('$25.00');
|
||||
expect(prices.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('displays tax line item', () => {
|
||||
render(React.createElement(PaymentSection, defaultProps));
|
||||
expect(screen.getByText('Tax (Estimated)')).toBeInTheDocument();
|
||||
expect(screen.getByText('$0.00')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays total amount', () => {
|
||||
render(React.createElement(PaymentSection, defaultProps));
|
||||
const totals = screen.getAllByText('$25.00');
|
||||
expect(totals.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('formats card number input with spaces', () => {
|
||||
render(React.createElement(PaymentSection, defaultProps));
|
||||
const cardInput = screen.getByPlaceholderText('0000 0000 0000 0000') as HTMLInputElement;
|
||||
|
||||
fireEvent.change(cardInput, { target: { value: '4242424242424242' } });
|
||||
expect(cardInput.value).toBe('4242 4242 4242 4242');
|
||||
});
|
||||
|
||||
it('limits card number to 16 digits', () => {
|
||||
render(React.createElement(PaymentSection, defaultProps));
|
||||
const cardInput = screen.getByPlaceholderText('0000 0000 0000 0000') as HTMLInputElement;
|
||||
|
||||
fireEvent.change(cardInput, { target: { value: '42424242424242421234' } });
|
||||
expect(cardInput.value).toBe('4242 4242 4242 4242');
|
||||
});
|
||||
|
||||
it('removes non-digits from card input', () => {
|
||||
render(React.createElement(PaymentSection, defaultProps));
|
||||
const cardInput = screen.getByPlaceholderText('0000 0000 0000 0000') as HTMLInputElement;
|
||||
|
||||
fireEvent.change(cardInput, { target: { value: '4242-4242-4242-4242' } });
|
||||
expect(cardInput.value).toBe('4242 4242 4242 4242');
|
||||
});
|
||||
|
||||
it('handles expiry date input', () => {
|
||||
render(React.createElement(PaymentSection, defaultProps));
|
||||
const expiryInput = screen.getByPlaceholderText('MM / YY') as HTMLInputElement;
|
||||
|
||||
fireEvent.change(expiryInput, { target: { value: '12/25' } });
|
||||
expect(expiryInput.value).toBe('12/25');
|
||||
});
|
||||
|
||||
it('handles CVC input', () => {
|
||||
render(React.createElement(PaymentSection, defaultProps));
|
||||
const cvcInput = screen.getByPlaceholderText('123') as HTMLInputElement;
|
||||
|
||||
fireEvent.change(cvcInput, { target: { value: '123' } });
|
||||
expect(cvcInput.value).toBe('123');
|
||||
});
|
||||
|
||||
it('shows confirm booking button when no deposit', () => {
|
||||
render(React.createElement(PaymentSection, defaultProps));
|
||||
expect(screen.getByRole('button', { name: 'Confirm Booking' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows deposit amount button when deposit required', () => {
|
||||
render(React.createElement(PaymentSection, { ...defaultProps, service: mockServiceWithDeposit }));
|
||||
expect(screen.getByRole('button', { name: 'Pay $10.00 Deposit' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays deposit amount section when deposit required', () => {
|
||||
render(React.createElement(PaymentSection, { ...defaultProps, service: mockServiceWithDeposit }));
|
||||
expect(screen.getByText('Due Now (Deposit)')).toBeInTheDocument();
|
||||
const depositAmounts = screen.getAllByText('$10.00');
|
||||
expect(depositAmounts.length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('Due at appointment')).toBeInTheDocument();
|
||||
expect(screen.getByText('$15.00')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays full payment message when no deposit', () => {
|
||||
render(React.createElement(PaymentSection, defaultProps));
|
||||
expect(screen.getByText(/Full payment will be collected at your appointment/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays deposit message when deposit required', () => {
|
||||
render(React.createElement(PaymentSection, { ...defaultProps, service: mockServiceWithDeposit }));
|
||||
expect(screen.getByText(/A deposit of/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/will be charged now/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows submit button text changes to processing', () => {
|
||||
render(React.createElement(PaymentSection, defaultProps));
|
||||
const submitButton = screen.getByRole('button', { name: 'Confirm Booking' });
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('simulates payment processing timeout', async () => {
|
||||
const onPaymentComplete = vi.fn();
|
||||
render(React.createElement(PaymentSection, { ...defaultProps, onPaymentComplete }));
|
||||
|
||||
// The component uses setTimeout with 2000ms
|
||||
// Just verify the timeout is reasonable
|
||||
expect(onPaymentComplete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('displays security message', () => {
|
||||
render(React.createElement(PaymentSection, defaultProps));
|
||||
expect(screen.getByText(/Your payment is secure/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/We use Stripe to process your payment/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows shield check icon', () => {
|
||||
render(React.createElement(PaymentSection, defaultProps));
|
||||
expect(screen.getByTestId('icon-shield-check')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows credit card icon', () => {
|
||||
render(React.createElement(PaymentSection, defaultProps));
|
||||
expect(screen.getByTestId('icon-credit-card')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows lock icon for CVC field', () => {
|
||||
render(React.createElement(PaymentSection, defaultProps));
|
||||
expect(screen.getByTestId('icon-lock')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays payment summary section', () => {
|
||||
render(React.createElement(PaymentSection, defaultProps));
|
||||
expect(screen.getByText('Payment Summary')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('requires all form fields', () => {
|
||||
const onPaymentComplete = vi.fn();
|
||||
render(React.createElement(PaymentSection, { ...defaultProps, onPaymentComplete }));
|
||||
|
||||
const cardInput = screen.getByPlaceholderText('0000 0000 0000 0000');
|
||||
const expiryInput = screen.getByPlaceholderText('MM / YY');
|
||||
const cvcInput = screen.getByPlaceholderText('123');
|
||||
|
||||
expect(cardInput).toHaveAttribute('required');
|
||||
expect(expiryInput).toHaveAttribute('required');
|
||||
expect(cvcInput).toHaveAttribute('required');
|
||||
});
|
||||
|
||||
it('calculates deposit correctly', () => {
|
||||
const service = { ...mockService, deposit_amount_cents: 500 };
|
||||
render(React.createElement(PaymentSection, { ...defaultProps, service }));
|
||||
|
||||
const amounts = screen.getAllByText('$5.00');
|
||||
expect(amounts.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('displays mock card icons', () => {
|
||||
render(React.createElement(PaymentSection, defaultProps));
|
||||
const mockCardIcons = document.querySelectorAll('.bg-gray-200.dark\\:bg-gray-600.rounded');
|
||||
expect(mockCardIcons.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('handles large prices correctly', () => {
|
||||
const expensiveService = { ...mockService, price_cents: 1000000 }; // $10,000
|
||||
render(React.createElement(PaymentSection, { ...defaultProps, service: expensiveService }));
|
||||
const prices = screen.getAllByText('$10000.00');
|
||||
expect(prices.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('handles zero deposit', () => {
|
||||
const service = { ...mockService, deposit_amount_cents: 0 };
|
||||
render(React.createElement(PaymentSection, { ...defaultProps, service }));
|
||||
expect(screen.queryByText('Due Now (Deposit)')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has disabled state for button during processing', () => {
|
||||
render(React.createElement(PaymentSection, defaultProps));
|
||||
const submitButton = screen.getByRole('button', { name: 'Confirm Booking' });
|
||||
|
||||
// Initially enabled
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
// Button will be disabled when processing state is true
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,492 @@
|
||||
/**
|
||||
* Unit tests for OpenTicketsWidget component
|
||||
*
|
||||
* Tests cover:
|
||||
* - Component rendering with tickets
|
||||
* - Empty state when no tickets
|
||||
* - Urgent ticket badge display
|
||||
* - Ticket filtering (open/in_progress only)
|
||||
* - Priority color coding
|
||||
* - Overdue ticket handling
|
||||
* - Link navigation
|
||||
* - Edit mode controls
|
||||
* - Internationalization (i18n)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import OpenTicketsWidget from '../OpenTicketsWidget';
|
||||
import { Ticket } from '../../../types';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: any) => {
|
||||
const translations: Record<string, string> = {
|
||||
'dashboard.openTickets': 'Open Tickets',
|
||||
'dashboard.urgent': 'Urgent',
|
||||
'dashboard.open': 'Open',
|
||||
'dashboard.noOpenTickets': 'No open tickets',
|
||||
'dashboard.overdue': 'Overdue',
|
||||
'dashboard.viewAllTickets': `View all ${options?.count || 0} tickets`,
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useDateFnsLocale hook
|
||||
vi.mock('../../../hooks/useDateFnsLocale', () => ({
|
||||
useDateFnsLocale: () => undefined, // Returns undefined for default locale
|
||||
}));
|
||||
|
||||
// Helper to render component with Router
|
||||
const renderWithRouter = (component: React.ReactElement) => {
|
||||
return render(<BrowserRouter>{component}</BrowserRouter>);
|
||||
};
|
||||
|
||||
describe('OpenTicketsWidget', () => {
|
||||
const mockTickets: Ticket[] = [
|
||||
{
|
||||
id: '1',
|
||||
tenant: 'test-tenant',
|
||||
creator: 'user-1',
|
||||
creatorEmail: 'user1@example.com',
|
||||
creatorFullName: 'John Doe',
|
||||
ticketType: 'support',
|
||||
status: 'open',
|
||||
priority: 'urgent',
|
||||
subject: 'Critical bug in scheduler',
|
||||
description: 'System is down',
|
||||
category: 'bug',
|
||||
createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), // 2 hours ago
|
||||
updatedAt: new Date().toISOString(),
|
||||
isOverdue: false,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
tenant: 'test-tenant',
|
||||
creator: 'user-2',
|
||||
creatorEmail: 'user2@example.com',
|
||||
creatorFullName: 'Jane Smith',
|
||||
ticketType: 'support',
|
||||
status: 'in_progress',
|
||||
priority: 'high',
|
||||
subject: 'Payment integration issue',
|
||||
description: 'Stripe webhook failing',
|
||||
category: 'bug',
|
||||
createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), // 1 day ago
|
||||
updatedAt: new Date().toISOString(),
|
||||
isOverdue: false,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
tenant: 'test-tenant',
|
||||
creator: 'user-3',
|
||||
creatorEmail: 'user3@example.com',
|
||||
creatorFullName: 'Bob Johnson',
|
||||
ticketType: 'support',
|
||||
status: 'closed',
|
||||
priority: 'low',
|
||||
subject: 'Closed ticket',
|
||||
description: 'This should not appear',
|
||||
category: 'question',
|
||||
createdAt: new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
tenant: 'test-tenant',
|
||||
creator: 'user-4',
|
||||
creatorEmail: 'user4@example.com',
|
||||
creatorFullName: 'Alice Williams',
|
||||
ticketType: 'support',
|
||||
status: 'open',
|
||||
priority: 'medium',
|
||||
subject: 'Overdue ticket',
|
||||
description: 'This ticket is overdue',
|
||||
category: 'bug',
|
||||
createdAt: new Date(Date.now() - 72 * 60 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
isOverdue: true,
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render the component', () => {
|
||||
renderWithRouter(<OpenTicketsWidget tickets={mockTickets} />);
|
||||
expect(screen.getByText('Open Tickets')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render title correctly', () => {
|
||||
renderWithRouter(<OpenTicketsWidget tickets={mockTickets} />);
|
||||
const title = screen.getByText('Open Tickets');
|
||||
expect(title).toBeInTheDocument();
|
||||
expect(title).toHaveClass('text-lg', 'font-semibold');
|
||||
});
|
||||
|
||||
it('should render open ticket count', () => {
|
||||
renderWithRouter(<OpenTicketsWidget tickets={mockTickets} />);
|
||||
// 3 open/in_progress tickets (excluding closed)
|
||||
expect(screen.getByText('3 Open')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ticket Filtering', () => {
|
||||
it('should only show open and in_progress tickets', () => {
|
||||
renderWithRouter(<OpenTicketsWidget tickets={mockTickets} />);
|
||||
|
||||
// Should show these
|
||||
expect(screen.getByText('Critical bug in scheduler')).toBeInTheDocument();
|
||||
expect(screen.getByText('Payment integration issue')).toBeInTheDocument();
|
||||
expect(screen.getByText('Overdue ticket')).toBeInTheDocument();
|
||||
|
||||
// Should NOT show closed ticket
|
||||
expect(screen.queryByText('Closed ticket')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should count urgent and overdue tickets', () => {
|
||||
renderWithRouter(<OpenTicketsWidget tickets={mockTickets} />);
|
||||
|
||||
// 1 urgent + 1 overdue = 2 urgent total
|
||||
expect(screen.getByText('2 Urgent')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle tickets with only closed status', () => {
|
||||
const closedTickets: Ticket[] = [
|
||||
{
|
||||
...mockTickets[2],
|
||||
status: 'closed',
|
||||
},
|
||||
];
|
||||
|
||||
renderWithRouter(<OpenTicketsWidget tickets={closedTickets} />);
|
||||
expect(screen.getByText('No open tickets')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Priority Display', () => {
|
||||
it('should display urgent priority correctly', () => {
|
||||
renderWithRouter(<OpenTicketsWidget tickets={mockTickets} />);
|
||||
|
||||
// Urgent priority text appears in the badge (multiple instances possible)
|
||||
const urgentElements = screen.getAllByText(/Urgent/i);
|
||||
expect(urgentElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should display high priority', () => {
|
||||
renderWithRouter(<OpenTicketsWidget tickets={mockTickets} />);
|
||||
const highElements = screen.getAllByText('high');
|
||||
expect(highElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should display medium priority', () => {
|
||||
renderWithRouter(<OpenTicketsWidget tickets={mockTickets} />);
|
||||
const mediumElements = screen.getAllByText('medium');
|
||||
expect(mediumElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should display overdue status instead of priority', () => {
|
||||
renderWithRouter(<OpenTicketsWidget tickets={mockTickets} />);
|
||||
|
||||
// Overdue ticket should show "Overdue" instead of priority
|
||||
const overdueElements = screen.getAllByText('Overdue');
|
||||
expect(overdueElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Empty State', () => {
|
||||
it('should show empty state when no tickets', () => {
|
||||
renderWithRouter(<OpenTicketsWidget tickets={[]} />);
|
||||
expect(screen.getByText('No open tickets')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show empty state icon when no tickets', () => {
|
||||
const { container } = renderWithRouter(<OpenTicketsWidget tickets={[]} />);
|
||||
|
||||
// Check for AlertCircle icon in empty state
|
||||
const svg = container.querySelector('svg');
|
||||
expect(svg).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show urgent badge when no tickets', () => {
|
||||
renderWithRouter(<OpenTicketsWidget tickets={[]} />);
|
||||
expect(screen.queryByText(/Urgent/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ticket List Display', () => {
|
||||
it('should limit display to 5 tickets', () => {
|
||||
const manyTickets: Ticket[] = Array.from({ length: 10 }, (_, i) => ({
|
||||
...mockTickets[0],
|
||||
id: `ticket-${i}`,
|
||||
subject: `Ticket ${i + 1}`,
|
||||
status: 'open' as const,
|
||||
}));
|
||||
|
||||
renderWithRouter(<OpenTicketsWidget tickets={manyTickets} />);
|
||||
|
||||
// Should show first 5 tickets
|
||||
expect(screen.getByText('Ticket 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Ticket 5')).toBeInTheDocument();
|
||||
|
||||
// Should NOT show 6th ticket
|
||||
expect(screen.queryByText('Ticket 6')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show "View all" link when more than 5 tickets', () => {
|
||||
const manyTickets: Ticket[] = Array.from({ length: 7 }, (_, i) => ({
|
||||
...mockTickets[0],
|
||||
id: `ticket-${i}`,
|
||||
status: 'open' as const,
|
||||
}));
|
||||
|
||||
renderWithRouter(<OpenTicketsWidget tickets={manyTickets} />);
|
||||
|
||||
// Should show link to view all 7 tickets
|
||||
expect(screen.getByText('View all 7 tickets')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show "View all" link when 5 or fewer tickets', () => {
|
||||
const fewTickets = mockTickets.slice(0, 2);
|
||||
renderWithRouter(<OpenTicketsWidget tickets={fewTickets} />);
|
||||
|
||||
expect(screen.queryByText(/View all/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render timestamps for tickets', () => {
|
||||
renderWithRouter(<OpenTicketsWidget tickets={mockTickets} />);
|
||||
|
||||
// Should have timestamp elements (date-fns formatDistanceToNow renders relative times)
|
||||
const timestamps = screen.getAllByText(/ago/i);
|
||||
expect(timestamps.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation Links', () => {
|
||||
it('should render ticket items as links', () => {
|
||||
renderWithRouter(<OpenTicketsWidget tickets={mockTickets} />);
|
||||
|
||||
const links = screen.getAllByRole('link');
|
||||
expect(links.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should link to tickets dashboard', () => {
|
||||
renderWithRouter(<OpenTicketsWidget tickets={mockTickets} />);
|
||||
|
||||
const links = screen.getAllByRole('link');
|
||||
links.forEach(link => {
|
||||
expect(link).toHaveAttribute('href', '/dashboard/tickets');
|
||||
});
|
||||
});
|
||||
|
||||
it('should have chevron icons on ticket links', () => {
|
||||
const { container } = renderWithRouter(<OpenTicketsWidget tickets={mockTickets} />);
|
||||
|
||||
// ChevronRight icons should be present
|
||||
const svgs = container.querySelectorAll('svg');
|
||||
expect(svgs.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edit Mode', () => {
|
||||
it('should not show edit controls when isEditing is false', () => {
|
||||
const { container } = renderWithRouter(
|
||||
<OpenTicketsWidget tickets={mockTickets} isEditing={false} />
|
||||
);
|
||||
|
||||
const dragHandle = container.querySelector('.drag-handle');
|
||||
expect(dragHandle).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show drag handle when in edit mode', () => {
|
||||
const { container } = renderWithRouter(
|
||||
<OpenTicketsWidget tickets={mockTickets} isEditing={true} />
|
||||
);
|
||||
|
||||
const dragHandle = container.querySelector('.drag-handle');
|
||||
expect(dragHandle).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show remove button when in edit mode', () => {
|
||||
const { container } = renderWithRouter(
|
||||
<OpenTicketsWidget tickets={mockTickets} isEditing={true} onRemove={vi.fn()} />
|
||||
);
|
||||
|
||||
// Remove button exists (X icon button)
|
||||
const removeButtons = container.querySelectorAll('button');
|
||||
expect(removeButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should call onRemove when remove button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleRemove = vi.fn();
|
||||
|
||||
const { container } = renderWithRouter(
|
||||
<OpenTicketsWidget tickets={mockTickets} isEditing={true} onRemove={handleRemove} />
|
||||
);
|
||||
|
||||
// Find the remove button (X icon)
|
||||
const removeButton = container.querySelector('button[class*="hover:text-red"]') as HTMLElement;
|
||||
expect(removeButton).toBeInTheDocument();
|
||||
|
||||
await user.click(removeButton);
|
||||
expect(handleRemove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should apply padding when in edit mode', () => {
|
||||
const { container } = renderWithRouter(
|
||||
<OpenTicketsWidget tickets={mockTickets} isEditing={true} />
|
||||
);
|
||||
|
||||
const paddedElement = container.querySelector('.pl-5');
|
||||
expect(paddedElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not apply padding when not in edit mode', () => {
|
||||
const { container } = renderWithRouter(
|
||||
<OpenTicketsWidget tickets={mockTickets} isEditing={false} />
|
||||
);
|
||||
|
||||
// Title should not have pl-5 class
|
||||
const title = screen.getByText('Open Tickets');
|
||||
expect(title.parentElement).not.toHaveClass('pl-5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should apply container styles', () => {
|
||||
const { container } = renderWithRouter(<OpenTicketsWidget tickets={mockTickets} />);
|
||||
|
||||
const widget = container.firstChild;
|
||||
expect(widget).toHaveClass(
|
||||
'h-full',
|
||||
'p-4',
|
||||
'bg-white',
|
||||
'rounded-xl',
|
||||
'border',
|
||||
'border-gray-200',
|
||||
'shadow-sm'
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply dark mode styles', () => {
|
||||
const { container } = renderWithRouter(<OpenTicketsWidget tickets={mockTickets} />);
|
||||
|
||||
const widget = container.firstChild;
|
||||
expect(widget).toHaveClass('dark:bg-gray-800', 'dark:border-gray-700');
|
||||
});
|
||||
|
||||
it('should apply priority background colors', () => {
|
||||
const { container } = renderWithRouter(<OpenTicketsWidget tickets={mockTickets} />);
|
||||
|
||||
// Check for various priority bg classes
|
||||
const redBg = container.querySelector('.bg-red-50');
|
||||
const orangeBg = container.querySelector('.bg-orange-50');
|
||||
const yellowBg = container.querySelector('.bg-yellow-50');
|
||||
|
||||
// At least one priority bg should be present
|
||||
expect(redBg || orangeBg || yellowBg).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Urgent Badge', () => {
|
||||
it('should show urgent badge when urgent tickets exist', () => {
|
||||
renderWithRouter(<OpenTicketsWidget tickets={mockTickets} />);
|
||||
|
||||
const urgentElements = screen.getAllByText(/Urgent/i);
|
||||
expect(urgentElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should show correct urgent count', () => {
|
||||
renderWithRouter(<OpenTicketsWidget tickets={mockTickets} />);
|
||||
|
||||
// 1 urgent + 1 overdue
|
||||
expect(screen.getByText('2 Urgent')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show urgent badge when no urgent tickets', () => {
|
||||
const nonUrgentTickets: Ticket[] = [
|
||||
{
|
||||
...mockTickets[0],
|
||||
priority: 'low',
|
||||
isOverdue: false,
|
||||
},
|
||||
];
|
||||
|
||||
renderWithRouter(<OpenTicketsWidget tickets={nonUrgentTickets} />);
|
||||
expect(screen.queryByText(/Urgent/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should include overdue tickets in urgent count', () => {
|
||||
const tickets: Ticket[] = [
|
||||
{
|
||||
...mockTickets[0],
|
||||
priority: 'low',
|
||||
isOverdue: true,
|
||||
status: 'open',
|
||||
},
|
||||
];
|
||||
|
||||
renderWithRouter(<OpenTicketsWidget tickets={tickets} />);
|
||||
expect(screen.getByText('1 Urgent')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have semantic HTML structure', () => {
|
||||
const { container } = renderWithRouter(<OpenTicketsWidget tickets={mockTickets} />);
|
||||
|
||||
const headings = container.querySelectorAll('h3');
|
||||
expect(headings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have accessible links', () => {
|
||||
renderWithRouter(<OpenTicketsWidget tickets={mockTickets} />);
|
||||
|
||||
const links = screen.getAllByRole('link');
|
||||
expect(links.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration', () => {
|
||||
it('should render correctly with all props', () => {
|
||||
const handleRemove = vi.fn();
|
||||
|
||||
renderWithRouter(
|
||||
<OpenTicketsWidget
|
||||
tickets={mockTickets}
|
||||
isEditing={true}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Open Tickets')).toBeInTheDocument();
|
||||
expect(screen.getByText('Critical bug in scheduler')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Urgent/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle mixed priority tickets', () => {
|
||||
const mixedTickets: Ticket[] = [
|
||||
{ ...mockTickets[0], priority: 'urgent', status: 'open' },
|
||||
{ ...mockTickets[0], id: '2', priority: 'high', status: 'open' },
|
||||
{ ...mockTickets[0], id: '3', priority: 'medium', status: 'in_progress' },
|
||||
{ ...mockTickets[0], id: '4', priority: 'low', status: 'open' },
|
||||
];
|
||||
|
||||
renderWithRouter(<OpenTicketsWidget tickets={mixedTickets} />);
|
||||
|
||||
expect(screen.getByText('urgent')).toBeInTheDocument();
|
||||
expect(screen.getByText('high')).toBeInTheDocument();
|
||||
expect(screen.getByText('medium')).toBeInTheDocument();
|
||||
expect(screen.getByText('low')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,576 @@
|
||||
/**
|
||||
* Unit tests for RecentActivityWidget component
|
||||
*
|
||||
* Tests cover:
|
||||
* - Component rendering with appointments and customers
|
||||
* - Empty state when no activity
|
||||
* - Activity type filtering and display (booking, cancellation, completion, new customer)
|
||||
* - Icon and styling for different activity types
|
||||
* - Timestamp display with date-fns
|
||||
* - Activity sorting (most recent first)
|
||||
* - Activity limit (max 10 items)
|
||||
* - Edit mode controls
|
||||
* - Internationalization (i18n)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import RecentActivityWidget from '../RecentActivityWidget';
|
||||
import { Appointment, Customer } from '../../../types';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: any) => {
|
||||
const translations: Record<string, string> = {
|
||||
'dashboard.recentActivity': 'Recent Activity',
|
||||
'dashboard.noRecentActivity': 'No recent activity',
|
||||
'dashboard.newBooking': 'New Booking',
|
||||
'dashboard.customerBookedAppointment': `${options?.customerName || 'Customer'} booked an appointment`,
|
||||
'dashboard.cancellation': 'Cancellation',
|
||||
'dashboard.customerCancelledAppointment': `${options?.customerName || 'Customer'} cancelled appointment`,
|
||||
'dashboard.completed': 'Completed',
|
||||
'dashboard.customerAppointmentCompleted': `${options?.customerName || 'Customer'} appointment completed`,
|
||||
'dashboard.newCustomer': 'New Customer',
|
||||
'dashboard.customerSignedUp': `${options?.customerName || 'Customer'} signed up`,
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useDateFnsLocale hook
|
||||
vi.mock('../../../hooks/useDateFnsLocale', () => ({
|
||||
useDateFnsLocale: () => undefined,
|
||||
}));
|
||||
|
||||
describe('RecentActivityWidget', () => {
|
||||
const now = new Date();
|
||||
|
||||
const mockAppointments: Appointment[] = [
|
||||
{
|
||||
id: '1',
|
||||
resourceId: 'resource-1',
|
||||
customerId: 'customer-1',
|
||||
customerName: 'John Doe',
|
||||
serviceId: 'service-1',
|
||||
startTime: new Date(now.getTime() - 2 * 60 * 60 * 1000), // 2 hours ago
|
||||
durationMinutes: 60,
|
||||
status: 'CONFIRMED',
|
||||
notes: '',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
resourceId: 'resource-1',
|
||||
customerId: 'customer-2',
|
||||
customerName: 'Jane Smith',
|
||||
serviceId: 'service-1',
|
||||
startTime: new Date(now.getTime() - 24 * 60 * 60 * 1000), // 1 day ago
|
||||
durationMinutes: 90,
|
||||
status: 'CANCELLED',
|
||||
notes: '',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
resourceId: 'resource-2',
|
||||
customerId: 'customer-3',
|
||||
customerName: 'Bob Johnson',
|
||||
serviceId: 'service-2',
|
||||
startTime: new Date(now.getTime() - 48 * 60 * 60 * 1000), // 2 days ago
|
||||
durationMinutes: 120,
|
||||
status: 'COMPLETED',
|
||||
notes: '',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
resourceId: 'resource-1',
|
||||
customerId: 'customer-4',
|
||||
customerName: 'Alice Williams',
|
||||
serviceId: 'service-1',
|
||||
startTime: new Date(now.getTime() - 5 * 60 * 60 * 1000), // 5 hours ago
|
||||
durationMinutes: 45,
|
||||
status: 'PENDING',
|
||||
notes: '',
|
||||
},
|
||||
];
|
||||
|
||||
const mockCustomers: Customer[] = [
|
||||
{
|
||||
id: 'customer-1',
|
||||
name: 'New Customer One',
|
||||
email: 'new1@example.com',
|
||||
phone: '555-0001',
|
||||
// No lastVisit = new customer
|
||||
},
|
||||
{
|
||||
id: 'customer-2',
|
||||
name: 'Returning Customer',
|
||||
email: 'returning@example.com',
|
||||
phone: '555-0002',
|
||||
lastVisit: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000), // 30 days ago
|
||||
},
|
||||
{
|
||||
id: 'customer-3',
|
||||
name: 'New Customer Two',
|
||||
email: 'new2@example.com',
|
||||
phone: '555-0003',
|
||||
// No lastVisit = new customer
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render the component', () => {
|
||||
render(
|
||||
<RecentActivityWidget appointments={mockAppointments} customers={mockCustomers} />
|
||||
);
|
||||
expect(screen.getByText('Recent Activity')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render title correctly', () => {
|
||||
render(
|
||||
<RecentActivityWidget appointments={mockAppointments} customers={mockCustomers} />
|
||||
);
|
||||
const title = screen.getByText('Recent Activity');
|
||||
expect(title).toBeInTheDocument();
|
||||
expect(title).toHaveClass('text-lg', 'font-semibold');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Activity Types', () => {
|
||||
it('should display booking activity for confirmed appointments', () => {
|
||||
render(
|
||||
<RecentActivityWidget appointments={mockAppointments} customers={mockCustomers} />
|
||||
);
|
||||
expect(screen.getByText('New Booking')).toBeInTheDocument();
|
||||
expect(screen.getByText('John Doe booked an appointment')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display booking activity for pending appointments', () => {
|
||||
render(
|
||||
<RecentActivityWidget appointments={mockAppointments} customers={mockCustomers} />
|
||||
);
|
||||
expect(screen.getByText('Alice Williams booked an appointment')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display cancellation activity', () => {
|
||||
render(
|
||||
<RecentActivityWidget appointments={mockAppointments} customers={mockCustomers} />
|
||||
);
|
||||
expect(screen.getByText('Cancellation')).toBeInTheDocument();
|
||||
expect(screen.getByText('Jane Smith cancelled appointment')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display completion activity', () => {
|
||||
render(
|
||||
<RecentActivityWidget appointments={mockAppointments} customers={mockCustomers} />
|
||||
);
|
||||
expect(screen.getByText('Completed')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bob Johnson appointment completed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display new customer activity', () => {
|
||||
render(
|
||||
<RecentActivityWidget appointments={mockAppointments} customers={mockCustomers} />
|
||||
);
|
||||
expect(screen.getByText('New Customer')).toBeInTheDocument();
|
||||
expect(screen.getByText('New Customer One signed up')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not display activity for returning customers', () => {
|
||||
render(
|
||||
<RecentActivityWidget appointments={mockAppointments} customers={mockCustomers} />
|
||||
);
|
||||
// Returning Customer should not appear in activity
|
||||
expect(screen.queryByText('Returning Customer signed up')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Activity Sorting and Limiting', () => {
|
||||
it('should sort activities by timestamp descending', () => {
|
||||
render(
|
||||
<RecentActivityWidget appointments={mockAppointments} customers={mockCustomers} />
|
||||
);
|
||||
|
||||
const activities = screen.getAllByText(/booked|cancelled|completed|signed up/i);
|
||||
// Most recent should be first (John Doe - 2 hours ago)
|
||||
expect(activities[0]).toHaveTextContent('John Doe');
|
||||
});
|
||||
|
||||
it('should limit display to 10 activities', () => {
|
||||
const manyAppointments: Appointment[] = Array.from({ length: 15 }, (_, i) => ({
|
||||
...mockAppointments[0],
|
||||
id: `appt-${i}`,
|
||||
customerName: `Customer ${i}`,
|
||||
startTime: new Date(now.getTime() - i * 60 * 60 * 1000),
|
||||
}));
|
||||
|
||||
const { container } = render(
|
||||
<RecentActivityWidget appointments={manyAppointments} customers={[]} />
|
||||
);
|
||||
|
||||
// Count activity items (each has a unique key structure)
|
||||
const activityItems = container.querySelectorAll('[class*="flex items-start gap-3"]');
|
||||
expect(activityItems.length).toBeLessThanOrEqual(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Empty State', () => {
|
||||
it('should show empty state when no appointments or customers', () => {
|
||||
render(<RecentActivityWidget appointments={[]} customers={[]} />);
|
||||
expect(screen.getByText('No recent activity')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show empty state icon when no activity', () => {
|
||||
const { container } = render(
|
||||
<RecentActivityWidget appointments={[]} customers={[]} />
|
||||
);
|
||||
|
||||
// Check for Calendar icon in empty state
|
||||
const svg = container.querySelector('svg');
|
||||
expect(svg).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show empty state when only returning customers', () => {
|
||||
const returningCustomers: Customer[] = [
|
||||
{
|
||||
id: 'customer-1',
|
||||
name: 'Returning',
|
||||
email: 'returning@example.com',
|
||||
phone: '555-0001',
|
||||
lastVisit: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
];
|
||||
|
||||
render(<RecentActivityWidget appointments={[]} customers={returningCustomers} />);
|
||||
expect(screen.getByText('No recent activity')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Icons and Styling', () => {
|
||||
it('should render activity icons', () => {
|
||||
const { container } = render(
|
||||
<RecentActivityWidget appointments={mockAppointments} customers={mockCustomers} />
|
||||
);
|
||||
|
||||
// Multiple SVG icons should be present
|
||||
const svgs = container.querySelectorAll('svg');
|
||||
expect(svgs.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should apply correct icon background colors', () => {
|
||||
const { container } = render(
|
||||
<RecentActivityWidget appointments={mockAppointments} customers={mockCustomers} />
|
||||
);
|
||||
|
||||
// Check for various icon background colors
|
||||
const blueBg = container.querySelector('.bg-blue-100');
|
||||
const redBg = container.querySelector('.bg-red-100');
|
||||
const greenBg = container.querySelector('.bg-green-100');
|
||||
const purpleBg = container.querySelector('.bg-purple-100');
|
||||
|
||||
// At least one should be present
|
||||
expect(blueBg || redBg || greenBg || purpleBg).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should apply container styles', () => {
|
||||
const { container } = render(
|
||||
<RecentActivityWidget appointments={mockAppointments} customers={mockCustomers} />
|
||||
);
|
||||
|
||||
const widget = container.firstChild;
|
||||
expect(widget).toHaveClass(
|
||||
'h-full',
|
||||
'p-4',
|
||||
'bg-white',
|
||||
'rounded-xl',
|
||||
'border',
|
||||
'border-gray-200',
|
||||
'shadow-sm'
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply dark mode styles', () => {
|
||||
const { container } = render(
|
||||
<RecentActivityWidget appointments={mockAppointments} customers={mockCustomers} />
|
||||
);
|
||||
|
||||
const widget = container.firstChild;
|
||||
expect(widget).toHaveClass('dark:bg-gray-800', 'dark:border-gray-700');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Timestamps', () => {
|
||||
it('should display relative timestamps', () => {
|
||||
render(
|
||||
<RecentActivityWidget appointments={mockAppointments} customers={mockCustomers} />
|
||||
);
|
||||
|
||||
// date-fns formatDistanceToNow renders "ago" in the text
|
||||
const timestamps = screen.getAllByText(/ago/i);
|
||||
expect(timestamps.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edit Mode', () => {
|
||||
it('should not show edit controls when isEditing is false', () => {
|
||||
const { container } = render(
|
||||
<RecentActivityWidget
|
||||
appointments={mockAppointments}
|
||||
customers={mockCustomers}
|
||||
isEditing={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const dragHandle = container.querySelector('.drag-handle');
|
||||
expect(dragHandle).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show drag handle when in edit mode', () => {
|
||||
const { container } = render(
|
||||
<RecentActivityWidget
|
||||
appointments={mockAppointments}
|
||||
customers={mockCustomers}
|
||||
isEditing={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const dragHandle = container.querySelector('.drag-handle');
|
||||
expect(dragHandle).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show remove button when in edit mode', () => {
|
||||
const { container } = render(
|
||||
<RecentActivityWidget
|
||||
appointments={mockAppointments}
|
||||
customers={mockCustomers}
|
||||
isEditing={true}
|
||||
onRemove={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// Remove button exists (X icon button)
|
||||
const removeButtons = container.querySelectorAll('button');
|
||||
expect(removeButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should call onRemove when remove button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleRemove = vi.fn();
|
||||
|
||||
const { container } = render(
|
||||
<RecentActivityWidget
|
||||
appointments={mockAppointments}
|
||||
customers={mockCustomers}
|
||||
isEditing={true}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
);
|
||||
|
||||
// Find the remove button (X icon)
|
||||
const removeButton = container.querySelector('button[class*="hover:text-red"]') as HTMLElement;
|
||||
expect(removeButton).toBeInTheDocument();
|
||||
|
||||
await user.click(removeButton);
|
||||
expect(handleRemove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should apply padding when in edit mode', () => {
|
||||
const { container } = render(
|
||||
<RecentActivityWidget
|
||||
appointments={mockAppointments}
|
||||
customers={mockCustomers}
|
||||
isEditing={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const paddedElement = container.querySelector('.pl-5');
|
||||
expect(paddedElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not apply padding when not in edit mode', () => {
|
||||
render(
|
||||
<RecentActivityWidget
|
||||
appointments={mockAppointments}
|
||||
customers={mockCustomers}
|
||||
isEditing={false}
|
||||
/>
|
||||
);
|
||||
|
||||
// Title should not be in a pl-5 container
|
||||
const title = screen.getByText('Recent Activity');
|
||||
expect(title).not.toHaveClass('pl-5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Activity Items Display', () => {
|
||||
it('should display activity titles', () => {
|
||||
render(
|
||||
<RecentActivityWidget appointments={mockAppointments} customers={mockCustomers} />
|
||||
);
|
||||
|
||||
expect(screen.getAllByText('New Booking').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should display activity descriptions', () => {
|
||||
render(
|
||||
<RecentActivityWidget appointments={mockAppointments} customers={mockCustomers} />
|
||||
);
|
||||
|
||||
expect(screen.getByText('John Doe booked an appointment')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should truncate long descriptions', () => {
|
||||
const { container } = render(
|
||||
<RecentActivityWidget appointments={mockAppointments} customers={mockCustomers} />
|
||||
);
|
||||
|
||||
// Check for truncate class on description text
|
||||
const descriptions = container.querySelectorAll('.truncate');
|
||||
expect(descriptions.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useMemo Optimization', () => {
|
||||
it('should handle empty appointments array', () => {
|
||||
render(<RecentActivityWidget appointments={[]} customers={mockCustomers} />);
|
||||
|
||||
// Should still show new customer activities
|
||||
expect(screen.getByText('New Customer One signed up')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle empty customers array', () => {
|
||||
render(<RecentActivityWidget appointments={mockAppointments} customers={[]} />);
|
||||
|
||||
// Should still show appointment activities
|
||||
expect(screen.getByText('John Doe booked an appointment')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should limit new customers to 5 before adding to activity', () => {
|
||||
const manyNewCustomers: Customer[] = Array.from({ length: 10 }, (_, i) => ({
|
||||
id: `customer-${i}`,
|
||||
name: `New Customer ${i}`,
|
||||
email: `new${i}@example.com`,
|
||||
phone: `555-000${i}`,
|
||||
// No lastVisit
|
||||
}));
|
||||
|
||||
const { container } = render(
|
||||
<RecentActivityWidget appointments={[]} customers={manyNewCustomers} />
|
||||
);
|
||||
|
||||
// Only 5 new customers should be added to activity (before the 10-item limit)
|
||||
const activityItems = container.querySelectorAll('[class*="flex items-start gap-3"]');
|
||||
expect(activityItems.length).toBeLessThanOrEqual(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have semantic HTML structure', () => {
|
||||
const { container } = render(
|
||||
<RecentActivityWidget appointments={mockAppointments} customers={mockCustomers} />
|
||||
);
|
||||
|
||||
const headings = container.querySelectorAll('h3');
|
||||
expect(headings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have readable text', () => {
|
||||
render(
|
||||
<RecentActivityWidget appointments={mockAppointments} customers={mockCustomers} />
|
||||
);
|
||||
|
||||
const title = screen.getByText('Recent Activity');
|
||||
expect(title).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration', () => {
|
||||
it('should render correctly with all props', () => {
|
||||
const handleRemove = vi.fn();
|
||||
|
||||
render(
|
||||
<RecentActivityWidget
|
||||
appointments={mockAppointments}
|
||||
customers={mockCustomers}
|
||||
isEditing={true}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Recent Activity')).toBeInTheDocument();
|
||||
expect(screen.getByText('John Doe booked an appointment')).toBeInTheDocument();
|
||||
expect(screen.getByText('New Customer One signed up')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle mixed activity types', () => {
|
||||
render(
|
||||
<RecentActivityWidget appointments={mockAppointments} customers={mockCustomers} />
|
||||
);
|
||||
|
||||
// Should have bookings, cancellations, completions, and new customers
|
||||
expect(screen.getByText('New Booking')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cancellation')).toBeInTheDocument();
|
||||
expect(screen.getByText('Completed')).toBeInTheDocument();
|
||||
expect(screen.getByText('New Customer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle appointments with different statuses', () => {
|
||||
const statusTestAppointments: Appointment[] = [
|
||||
{ ...mockAppointments[0], status: 'CONFIRMED' },
|
||||
{ ...mockAppointments[0], id: '2', status: 'PENDING' },
|
||||
{ ...mockAppointments[0], id: '3', status: 'CANCELLED' },
|
||||
{ ...mockAppointments[0], id: '4', status: 'COMPLETED' },
|
||||
];
|
||||
|
||||
render(<RecentActivityWidget appointments={statusTestAppointments} customers={[]} />);
|
||||
|
||||
// CONFIRMED and PENDING should show as bookings
|
||||
const bookings = screen.getAllByText('New Booking');
|
||||
expect(bookings.length).toBe(2);
|
||||
|
||||
// CANCELLED should show
|
||||
expect(screen.getByText('Cancellation')).toBeInTheDocument();
|
||||
|
||||
// COMPLETED should show
|
||||
expect(screen.getByText('Completed')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle appointments with no customer name', () => {
|
||||
const noNameAppointments: Appointment[] = [
|
||||
{
|
||||
...mockAppointments[0],
|
||||
customerName: '',
|
||||
},
|
||||
];
|
||||
|
||||
render(<RecentActivityWidget appointments={noNameAppointments} customers={[]} />);
|
||||
|
||||
// Should still render activity (with empty customer name)
|
||||
expect(screen.getByText('New Booking')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle customers with no name', () => {
|
||||
const noNameCustomers: Customer[] = [
|
||||
{
|
||||
id: 'customer-1',
|
||||
name: '',
|
||||
email: 'test@example.com',
|
||||
phone: '555-0001',
|
||||
},
|
||||
];
|
||||
|
||||
render(<RecentActivityWidget appointments={[]} customers={noNameCustomers} />);
|
||||
|
||||
// Should still render if there's a new customer
|
||||
expect(screen.getByText('New Customer')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,576 @@
|
||||
/**
|
||||
* Unit tests for WidgetConfigModal component
|
||||
*
|
||||
* Tests cover:
|
||||
* - Component rendering and visibility
|
||||
* - Modal open/close behavior
|
||||
* - Widget list display
|
||||
* - Widget toggle functionality
|
||||
* - Active widget highlighting
|
||||
* - Reset layout functionality
|
||||
* - Widget icons display
|
||||
* - Internationalization (i18n)
|
||||
* - Accessibility
|
||||
* - Backdrop click handling
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import WidgetConfigModal from '../WidgetConfigModal';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'dashboard.configureWidgets': 'Configure Widgets',
|
||||
'dashboard.configureWidgetsDescription': 'Choose which widgets to display on your dashboard',
|
||||
'dashboard.resetToDefault': 'Reset to Default',
|
||||
'dashboard.done': 'Done',
|
||||
// Widget titles
|
||||
'dashboard.widgetTitles.appointmentsMetric': 'Total Appointments',
|
||||
'dashboard.widgetTitles.customersMetric': 'Active Customers',
|
||||
'dashboard.widgetTitles.servicesMetric': 'Services',
|
||||
'dashboard.widgetTitles.resourcesMetric': 'Resources',
|
||||
'dashboard.widgetTitles.revenueChart': 'Revenue',
|
||||
'dashboard.widgetTitles.appointmentsChart': 'Appointments Trend',
|
||||
'dashboard.widgetTitles.openTickets': 'Open Tickets',
|
||||
'dashboard.widgetTitles.recentActivity': 'Recent Activity',
|
||||
'dashboard.widgetTitles.capacityUtilization': 'Capacity Utilization',
|
||||
'dashboard.widgetTitles.noShowRate': 'No-Show Rate',
|
||||
'dashboard.widgetTitles.customerBreakdown': 'New vs Returning',
|
||||
// Widget descriptions
|
||||
'dashboard.widgetDescriptions.appointmentsMetric': 'Shows appointment count with weekly and monthly growth',
|
||||
'dashboard.widgetDescriptions.customersMetric': 'Shows customer count with weekly and monthly growth',
|
||||
'dashboard.widgetDescriptions.servicesMetric': 'Shows number of services offered',
|
||||
'dashboard.widgetDescriptions.resourcesMetric': 'Shows number of resources available',
|
||||
'dashboard.widgetDescriptions.revenueChart': 'Weekly revenue bar chart',
|
||||
'dashboard.widgetDescriptions.appointmentsChart': 'Weekly appointments line chart',
|
||||
'dashboard.widgetDescriptions.openTickets': 'Shows open support tickets requiring attention',
|
||||
'dashboard.widgetDescriptions.recentActivity': 'Timeline of recent business events',
|
||||
'dashboard.widgetDescriptions.capacityUtilization': 'Shows how booked your resources are this week',
|
||||
'dashboard.widgetDescriptions.noShowRate': 'Percentage of appointments marked as no-show',
|
||||
'dashboard.widgetDescriptions.customerBreakdown': 'Customer breakdown this month',
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('WidgetConfigModal', () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const mockOnToggleWidget = vi.fn();
|
||||
const mockOnResetLayout = vi.fn();
|
||||
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
onClose: mockOnClose,
|
||||
activeWidgets: ['appointments-metric', 'customers-metric', 'revenue-chart'],
|
||||
onToggleWidget: mockOnToggleWidget,
|
||||
onResetLayout: mockOnResetLayout,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Modal Visibility', () => {
|
||||
it('should render when isOpen is true', () => {
|
||||
render(<WidgetConfigModal {...defaultProps} />);
|
||||
expect(screen.getByText('Configure Widgets')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render when isOpen is false', () => {
|
||||
render(<WidgetConfigModal {...defaultProps} isOpen={false} />);
|
||||
expect(screen.queryByText('Configure Widgets')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should return null when not open', () => {
|
||||
const { container } = render(<WidgetConfigModal {...defaultProps} isOpen={false} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Modal Header', () => {
|
||||
it('should render modal title', () => {
|
||||
render(<WidgetConfigModal {...defaultProps} />);
|
||||
const title = screen.getByText('Configure Widgets');
|
||||
expect(title).toBeInTheDocument();
|
||||
expect(title).toHaveClass('text-lg', 'font-semibold');
|
||||
});
|
||||
|
||||
it('should render close button in header', () => {
|
||||
const { container } = render(<WidgetConfigModal {...defaultProps} />);
|
||||
|
||||
// Close button (X icon) should be present
|
||||
const closeButtons = container.querySelectorAll('button');
|
||||
expect(closeButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should call onClose when header close button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<WidgetConfigModal {...defaultProps} />);
|
||||
|
||||
// Find the X button in header
|
||||
const buttons = container.querySelectorAll('button');
|
||||
const closeButton = Array.from(buttons).find(btn =>
|
||||
btn.querySelector('svg')
|
||||
) as HTMLElement;
|
||||
|
||||
if (closeButton) {
|
||||
await user.click(closeButton);
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Modal Content', () => {
|
||||
it('should render description text', () => {
|
||||
render(<WidgetConfigModal {...defaultProps} />);
|
||||
expect(screen.getByText('Choose which widgets to display on your dashboard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all widget options', () => {
|
||||
render(<WidgetConfigModal {...defaultProps} />);
|
||||
|
||||
// Check for widget titles
|
||||
expect(screen.getByText('Total Appointments')).toBeInTheDocument();
|
||||
expect(screen.getByText('Active Customers')).toBeInTheDocument();
|
||||
expect(screen.getByText('Services')).toBeInTheDocument();
|
||||
expect(screen.getByText('Resources')).toBeInTheDocument();
|
||||
expect(screen.getByText('Revenue')).toBeInTheDocument();
|
||||
expect(screen.getByText('Appointments Trend')).toBeInTheDocument();
|
||||
expect(screen.getByText('Open Tickets')).toBeInTheDocument();
|
||||
expect(screen.getByText('Recent Activity')).toBeInTheDocument();
|
||||
expect(screen.getByText('Capacity Utilization')).toBeInTheDocument();
|
||||
expect(screen.getByText('No-Show Rate')).toBeInTheDocument();
|
||||
expect(screen.getByText('New vs Returning')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render widget descriptions', () => {
|
||||
render(<WidgetConfigModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Shows appointment count with weekly and monthly growth')).toBeInTheDocument();
|
||||
expect(screen.getByText('Timeline of recent business events')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render widget icons', () => {
|
||||
const { container } = render(<WidgetConfigModal {...defaultProps} />);
|
||||
|
||||
// Should have multiple SVG icons
|
||||
const svgs = container.querySelectorAll('svg');
|
||||
expect(svgs.length).toBeGreaterThan(10); // At least one per widget
|
||||
});
|
||||
});
|
||||
|
||||
describe('Widget Selection', () => {
|
||||
it('should highlight active widgets', () => {
|
||||
const { container } = render(<WidgetConfigModal {...defaultProps} />);
|
||||
|
||||
// Active widgets should have brand-500 border
|
||||
const activeWidgets = container.querySelectorAll('.border-brand-500');
|
||||
expect(activeWidgets.length).toBe(defaultProps.activeWidgets.length);
|
||||
});
|
||||
|
||||
it('should show checkmark on active widgets', () => {
|
||||
render(<WidgetConfigModal {...defaultProps} />);
|
||||
|
||||
// Check icons should be present for active widgets
|
||||
const { container } = render(<WidgetConfigModal {...defaultProps} />);
|
||||
const svgs = container.querySelectorAll('svg');
|
||||
|
||||
// Should have check icons (hard to test exact count due to other icons)
|
||||
expect(svgs.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should not highlight inactive widgets', () => {
|
||||
const { container } = render(<WidgetConfigModal {...defaultProps} />);
|
||||
|
||||
// Inactive widgets should have gray border
|
||||
const inactiveWidgets = container.querySelectorAll('.border-gray-200');
|
||||
expect(inactiveWidgets.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should call onToggleWidget when widget is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<WidgetConfigModal {...defaultProps} />);
|
||||
|
||||
const widget = screen.getByText('Total Appointments').closest('button');
|
||||
expect(widget).toBeInTheDocument();
|
||||
|
||||
if (widget) {
|
||||
await user.click(widget);
|
||||
expect(mockOnToggleWidget).toHaveBeenCalledWith('appointments-metric');
|
||||
}
|
||||
});
|
||||
|
||||
it('should call onToggleWidget with correct widget ID', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<WidgetConfigModal {...defaultProps} />);
|
||||
|
||||
const revenueWidget = screen.getByText('Revenue').closest('button');
|
||||
if (revenueWidget) {
|
||||
await user.click(revenueWidget);
|
||||
expect(mockOnToggleWidget).toHaveBeenCalledWith('revenue-chart');
|
||||
}
|
||||
|
||||
const ticketsWidget = screen.getByText('Open Tickets').closest('button');
|
||||
if (ticketsWidget) {
|
||||
await user.click(ticketsWidget);
|
||||
expect(mockOnToggleWidget).toHaveBeenCalledWith('open-tickets');
|
||||
}
|
||||
});
|
||||
|
||||
it('should allow toggling multiple widgets', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<WidgetConfigModal {...defaultProps} />);
|
||||
|
||||
const widget1 = screen.getByText('Services').closest('button');
|
||||
const widget2 = screen.getByText('Resources').closest('button');
|
||||
|
||||
if (widget1) await user.click(widget1);
|
||||
if (widget2) await user.click(widget2);
|
||||
|
||||
expect(mockOnToggleWidget).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Active Widget Styling', () => {
|
||||
it('should apply active styling to selected widgets', () => {
|
||||
const { container } = render(<WidgetConfigModal {...defaultProps} />);
|
||||
|
||||
// Active widgets should have brand colors
|
||||
const brandBg = container.querySelectorAll('.bg-brand-50');
|
||||
expect(brandBg.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should apply inactive styling to unselected widgets', () => {
|
||||
const { container } = render(
|
||||
<WidgetConfigModal
|
||||
{...defaultProps}
|
||||
activeWidgets={['appointments-metric']} // Only one active
|
||||
/>
|
||||
);
|
||||
|
||||
// Many widgets should have gray styling
|
||||
const grayBorders = container.querySelectorAll('.border-gray-200');
|
||||
expect(grayBorders.length).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
it('should apply different icon colors for active vs inactive', () => {
|
||||
const { container } = render(<WidgetConfigModal {...defaultProps} />);
|
||||
|
||||
// Active widgets should have brand icon colors
|
||||
const brandIcons = container.querySelectorAll('.text-brand-600');
|
||||
expect(brandIcons.length).toBeGreaterThan(0);
|
||||
|
||||
// Inactive widgets should have gray icon colors
|
||||
const grayIcons = container.querySelectorAll('.text-gray-500');
|
||||
expect(grayIcons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Modal Footer', () => {
|
||||
it('should render reset button', () => {
|
||||
render(<WidgetConfigModal {...defaultProps} />);
|
||||
expect(screen.getByText('Reset to Default')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render done button', () => {
|
||||
render(<WidgetConfigModal {...defaultProps} />);
|
||||
expect(screen.getByText('Done')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onResetLayout when reset button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<WidgetConfigModal {...defaultProps} />);
|
||||
|
||||
const resetButton = screen.getByText('Reset to Default');
|
||||
await user.click(resetButton);
|
||||
|
||||
expect(mockOnResetLayout).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call onClose when done button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<WidgetConfigModal {...defaultProps} />);
|
||||
|
||||
const doneButton = screen.getByText('Done');
|
||||
await user.click(doneButton);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Backdrop Interaction', () => {
|
||||
it('should render backdrop', () => {
|
||||
const { container } = render(<WidgetConfigModal {...defaultProps} />);
|
||||
|
||||
// Backdrop div with bg-black/50
|
||||
const backdrop = container.querySelector('.bg-black\\/50');
|
||||
expect(backdrop).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onClose when backdrop is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<WidgetConfigModal {...defaultProps} />);
|
||||
|
||||
const backdrop = container.querySelector('.bg-black\\/50') as HTMLElement;
|
||||
expect(backdrop).toBeInTheDocument();
|
||||
|
||||
if (backdrop) {
|
||||
await user.click(backdrop);
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not call onClose when modal content is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<WidgetConfigModal {...defaultProps} />);
|
||||
|
||||
// Click on modal content, not backdrop
|
||||
const modalContent = container.querySelector('.bg-white') as HTMLElement;
|
||||
if (modalContent) {
|
||||
await user.click(modalContent);
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Widget Grid Layout', () => {
|
||||
it('should display widgets in a grid', () => {
|
||||
const { container } = render(<WidgetConfigModal {...defaultProps} />);
|
||||
|
||||
// Grid container should exist
|
||||
const grid = container.querySelector('.grid');
|
||||
expect(grid).toBeInTheDocument();
|
||||
expect(grid).toHaveClass('grid-cols-1', 'sm:grid-cols-2');
|
||||
});
|
||||
|
||||
it('should render all 11 widgets', () => {
|
||||
render(<WidgetConfigModal {...defaultProps} />);
|
||||
|
||||
// Count widget buttons
|
||||
const widgetButtons = screen.getAllByRole('button');
|
||||
// Should have 11 widget buttons + 2 footer buttons + 1 close button = 14 total
|
||||
expect(widgetButtons.length).toBeGreaterThanOrEqual(11);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should apply modal container styles', () => {
|
||||
const { container } = render(<WidgetConfigModal {...defaultProps} />);
|
||||
|
||||
const modal = container.querySelector('.bg-white');
|
||||
expect(modal).toHaveClass(
|
||||
'bg-white',
|
||||
'rounded-xl',
|
||||
'shadow-xl',
|
||||
'max-w-2xl',
|
||||
'w-full'
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply dark mode styles', () => {
|
||||
const { container } = render(<WidgetConfigModal {...defaultProps} />);
|
||||
|
||||
const modal = container.querySelector('.dark\\:bg-gray-800');
|
||||
expect(modal).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should make modal scrollable', () => {
|
||||
const { container } = render(<WidgetConfigModal {...defaultProps} />);
|
||||
|
||||
const scrollableContent = container.querySelector('.overflow-y-auto');
|
||||
expect(scrollableContent).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply max height to modal', () => {
|
||||
const { container } = render(<WidgetConfigModal {...defaultProps} />);
|
||||
|
||||
const modal = container.querySelector('.max-h-\\[80vh\\]');
|
||||
expect(modal).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have semantic HTML structure', () => {
|
||||
const { container } = render(<WidgetConfigModal {...defaultProps} />);
|
||||
|
||||
const headings = container.querySelectorAll('h2');
|
||||
expect(headings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have accessible buttons', () => {
|
||||
render(<WidgetConfigModal {...defaultProps} />);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have clear button text', () => {
|
||||
render(<WidgetConfigModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Done')).toBeInTheDocument();
|
||||
expect(screen.getByText('Reset to Default')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have descriptive widget names', () => {
|
||||
render(<WidgetConfigModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Total Appointments')).toBeInTheDocument();
|
||||
expect(screen.getByText('Recent Activity')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Widget Descriptions', () => {
|
||||
it('should show description for each widget', () => {
|
||||
render(<WidgetConfigModal {...defaultProps} />);
|
||||
|
||||
// Check a few widget descriptions
|
||||
expect(screen.getByText('Shows appointment count with weekly and monthly growth')).toBeInTheDocument();
|
||||
expect(screen.getByText('Timeline of recent business events')).toBeInTheDocument();
|
||||
expect(screen.getByText('Shows how booked your resources are this week')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display descriptions in smaller text', () => {
|
||||
const { container } = render(<WidgetConfigModal {...defaultProps} />);
|
||||
|
||||
const descriptions = container.querySelectorAll('.text-xs');
|
||||
expect(descriptions.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty activeWidgets array', () => {
|
||||
render(<WidgetConfigModal {...defaultProps} activeWidgets={[]} />);
|
||||
|
||||
// Should still render all widgets, just none selected
|
||||
expect(screen.getByText('Total Appointments')).toBeInTheDocument();
|
||||
|
||||
// No checkmarks should be visible
|
||||
const { container } = render(<WidgetConfigModal {...defaultProps} activeWidgets={[]} />);
|
||||
const activeWidgets = container.querySelectorAll('.border-brand-500');
|
||||
expect(activeWidgets.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle all widgets active', () => {
|
||||
const allWidgets = [
|
||||
'appointments-metric',
|
||||
'customers-metric',
|
||||
'services-metric',
|
||||
'resources-metric',
|
||||
'revenue-chart',
|
||||
'appointments-chart',
|
||||
'open-tickets',
|
||||
'recent-activity',
|
||||
'capacity-utilization',
|
||||
'no-show-rate',
|
||||
'customer-breakdown',
|
||||
];
|
||||
|
||||
const { container } = render(
|
||||
<WidgetConfigModal {...defaultProps} activeWidgets={allWidgets} />
|
||||
);
|
||||
|
||||
// All widgets should have active styling
|
||||
const activeWidgets = container.querySelectorAll('.border-brand-500');
|
||||
expect(activeWidgets.length).toBe(11);
|
||||
});
|
||||
|
||||
it('should handle rapid widget toggling', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<WidgetConfigModal {...defaultProps} />);
|
||||
|
||||
const widget = screen.getByText('Services').closest('button');
|
||||
|
||||
if (widget) {
|
||||
await user.click(widget);
|
||||
await user.click(widget);
|
||||
await user.click(widget);
|
||||
|
||||
expect(mockOnToggleWidget).toHaveBeenCalledTimes(3);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Internationalization', () => {
|
||||
it('should use translations for modal title', () => {
|
||||
render(<WidgetConfigModal {...defaultProps} />);
|
||||
expect(screen.getByText('Configure Widgets')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translations for widget titles', () => {
|
||||
render(<WidgetConfigModal {...defaultProps} />);
|
||||
expect(screen.getByText('Total Appointments')).toBeInTheDocument();
|
||||
expect(screen.getByText('Recent Activity')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translations for widget descriptions', () => {
|
||||
render(<WidgetConfigModal {...defaultProps} />);
|
||||
expect(screen.getByText('Timeline of recent business events')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translations for buttons', () => {
|
||||
render(<WidgetConfigModal {...defaultProps} />);
|
||||
expect(screen.getByText('Done')).toBeInTheDocument();
|
||||
expect(screen.getByText('Reset to Default')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration', () => {
|
||||
it('should render correctly with all props', () => {
|
||||
const handleClose = vi.fn();
|
||||
const handleToggle = vi.fn();
|
||||
const handleReset = vi.fn();
|
||||
|
||||
render(
|
||||
<WidgetConfigModal
|
||||
isOpen={true}
|
||||
onClose={handleClose}
|
||||
activeWidgets={['appointments-metric', 'revenue-chart']}
|
||||
onToggleWidget={handleToggle}
|
||||
onResetLayout={handleReset}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Configure Widgets')).toBeInTheDocument();
|
||||
expect(screen.getByText('Total Appointments')).toBeInTheDocument();
|
||||
expect(screen.getByText('Done')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should support complete user workflow', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleClose = vi.fn();
|
||||
const handleToggle = vi.fn();
|
||||
const handleReset = vi.fn();
|
||||
|
||||
render(
|
||||
<WidgetConfigModal
|
||||
isOpen={true}
|
||||
onClose={handleClose}
|
||||
activeWidgets={['appointments-metric']}
|
||||
onToggleWidget={handleToggle}
|
||||
onResetLayout={handleReset}
|
||||
/>
|
||||
);
|
||||
|
||||
// User toggles a widget
|
||||
const widget = screen.getByText('Revenue').closest('button');
|
||||
if (widget) await user.click(widget);
|
||||
expect(handleToggle).toHaveBeenCalledWith('revenue-chart');
|
||||
|
||||
// User resets layout
|
||||
const resetButton = screen.getByText('Reset to Default');
|
||||
await user.click(resetButton);
|
||||
expect(handleReset).toHaveBeenCalledTimes(1);
|
||||
|
||||
// User closes modal
|
||||
const doneButton = screen.getByText('Done');
|
||||
await user.click(doneButton);
|
||||
expect(handleClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
373
frontend/src/components/email/__tests__/EmailComposer.test.tsx
Normal file
373
frontend/src/components/email/__tests__/EmailComposer.test.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import EmailComposer from '../EmailComposer';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import type { StaffEmail } from '../../../types';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock react-hot-toast
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock hooks
|
||||
vi.mock('../../../hooks/useStaffEmail', () => ({
|
||||
useCreateDraft: vi.fn(() => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
})),
|
||||
useUpdateDraft: vi.fn(() => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
})),
|
||||
useSendEmail: vi.fn(() => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
})),
|
||||
useUploadAttachment: vi.fn(() => ({
|
||||
mutateAsync: vi.fn(),
|
||||
})),
|
||||
useContactSearch: vi.fn(() => ({
|
||||
data: [],
|
||||
})),
|
||||
useUserEmailAddresses: vi.fn(() => ({
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
email_address: 'test@example.com',
|
||||
display_name: 'Test User',
|
||||
},
|
||||
],
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('EmailComposer', () => {
|
||||
const defaultProps = {
|
||||
onClose: vi.fn(),
|
||||
onSent: vi.fn(),
|
||||
};
|
||||
|
||||
const mockReplyToEmail: StaffEmail = {
|
||||
id: 1,
|
||||
owner: 1,
|
||||
emailAddress: 1,
|
||||
folder: 1,
|
||||
fromAddress: 'sender@example.com',
|
||||
fromName: 'Sender Name',
|
||||
toAddresses: ['test@example.com'],
|
||||
ccAddresses: [],
|
||||
bccAddresses: [],
|
||||
subject: 'Original Subject',
|
||||
snippet: 'Email snippet',
|
||||
bodyText: 'Original email body',
|
||||
bodyHtml: '<p>Original email body</p>',
|
||||
messageId: 'message-123',
|
||||
inReplyTo: null,
|
||||
references: '',
|
||||
status: 'SENT',
|
||||
threadId: 'thread-123',
|
||||
emailDate: '2025-01-15T10:00:00Z',
|
||||
isRead: true,
|
||||
isStarred: false,
|
||||
isImportant: false,
|
||||
isAnswered: false,
|
||||
isPermanentlyDeleted: false,
|
||||
deletedAt: null,
|
||||
hasAttachments: false,
|
||||
attachmentCount: 0,
|
||||
attachments: [],
|
||||
labels: [],
|
||||
createdAt: '2025-01-15T10:00:00Z',
|
||||
updatedAt: '2025-01-15T10:00:00Z',
|
||||
};
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders new message mode', () => {
|
||||
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText('New Message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders reply mode with subject prefixed', () => {
|
||||
render(<EmailComposer {...defaultProps} replyTo={mockReplyToEmail} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
expect(screen.getByText('Reply')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Re: Original Subject')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders forward mode with subject prefixed', () => {
|
||||
render(<EmailComposer {...defaultProps} forwardFrom={mockReplyToEmail} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
expect(screen.getByText('Forward')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Fwd: Original Subject')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('populates recipient in reply mode', () => {
|
||||
render(<EmailComposer {...defaultProps} replyTo={mockReplyToEmail} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
expect(screen.getByDisplayValue('sender@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders minimized state', () => {
|
||||
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const minimizeButton = buttons.find((btn) =>
|
||||
btn.querySelector('svg')?.classList.contains('lucide-minimize-2')
|
||||
);
|
||||
if (minimizeButton) {
|
||||
fireEvent.click(minimizeButton);
|
||||
}
|
||||
|
||||
expect(screen.getByText('New Message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('expands from minimized state', () => {
|
||||
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
// Minimize
|
||||
let buttons = screen.getAllByRole('button');
|
||||
const minimizeButton = buttons.find((btn) =>
|
||||
btn.querySelector('svg')?.classList.contains('lucide-minimize-2')
|
||||
);
|
||||
if (minimizeButton) {
|
||||
fireEvent.click(minimizeButton);
|
||||
}
|
||||
|
||||
// Maximize
|
||||
buttons = screen.getAllByRole('button');
|
||||
const maximizeButton = buttons.find((btn) =>
|
||||
btn.querySelector('svg')?.classList.contains('lucide-maximize-2')
|
||||
);
|
||||
if (maximizeButton) {
|
||||
fireEvent.click(maximizeButton);
|
||||
}
|
||||
|
||||
expect(screen.getByPlaceholderText('recipient@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose when close button clicked', () => {
|
||||
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const closeButton = buttons.find((btn) =>
|
||||
btn.querySelector('svg')?.classList.contains('lucide-x')
|
||||
);
|
||||
if (closeButton) {
|
||||
fireEvent.click(closeButton);
|
||||
}
|
||||
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows Cc field when Cc button clicked', () => {
|
||||
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
const ccButton = screen.getByText('Cc');
|
||||
fireEvent.click(ccButton);
|
||||
|
||||
expect(screen.getByPlaceholderText('cc@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Bcc field when Bcc button clicked', () => {
|
||||
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
const bccButton = screen.getByText('Bcc');
|
||||
fireEvent.click(bccButton);
|
||||
|
||||
expect(screen.getByPlaceholderText('bcc@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates subject field', () => {
|
||||
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
const subjectInput = screen.getByPlaceholderText('Email subject');
|
||||
fireEvent.change(subjectInput, { target: { value: 'Test Subject' } });
|
||||
|
||||
expect(subjectInput).toHaveValue('Test Subject');
|
||||
});
|
||||
|
||||
it('updates body field', () => {
|
||||
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
const bodyTextarea = screen.getByPlaceholderText('Write your message...');
|
||||
fireEvent.change(bodyTextarea, { target: { value: 'Test email body' } });
|
||||
|
||||
expect(bodyTextarea).toHaveValue('Test email body');
|
||||
});
|
||||
|
||||
it('updates to field', () => {
|
||||
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
const toInput = screen.getByPlaceholderText('recipient@example.com');
|
||||
fireEvent.change(toInput, { target: { value: 'test@example.com' } });
|
||||
|
||||
expect(toInput).toHaveValue('test@example.com');
|
||||
});
|
||||
|
||||
it('updates cc field when shown', () => {
|
||||
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
fireEvent.click(screen.getByText('Cc'));
|
||||
const ccInput = screen.getByPlaceholderText('cc@example.com');
|
||||
fireEvent.change(ccInput, { target: { value: 'cc@example.com' } });
|
||||
|
||||
expect(ccInput).toHaveValue('cc@example.com');
|
||||
});
|
||||
|
||||
it('updates bcc field when shown', () => {
|
||||
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
fireEvent.click(screen.getByText('Bcc'));
|
||||
const bccInput = screen.getByPlaceholderText('bcc@example.com');
|
||||
fireEvent.change(bccInput, { target: { value: 'bcc@example.com' } });
|
||||
|
||||
expect(bccInput).toHaveValue('bcc@example.com');
|
||||
});
|
||||
|
||||
it('renders send button', () => {
|
||||
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('staffEmail.send')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders save draft button', () => {
|
||||
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Save draft')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders formatting buttons', () => {
|
||||
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const boldButton = buttons.find((btn) =>
|
||||
btn.querySelector('svg')?.classList.contains('lucide-bold')
|
||||
);
|
||||
const italicButton = buttons.find((btn) =>
|
||||
btn.querySelector('svg')?.classList.contains('lucide-italic')
|
||||
);
|
||||
const underlineButton = buttons.find((btn) =>
|
||||
btn.querySelector('svg')?.classList.contains('lucide-underline')
|
||||
);
|
||||
|
||||
expect(boldButton).toBeInTheDocument();
|
||||
expect(italicButton).toBeInTheDocument();
|
||||
expect(underlineButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders attachment button', () => {
|
||||
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
// Attachment input is wrapped in a label, not a button
|
||||
const paperclipIcons = document.querySelectorAll('.lucide-paperclip');
|
||||
expect(paperclipIcons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders discard button', () => {
|
||||
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const discardButton = buttons.find((btn) =>
|
||||
btn.querySelector('svg')?.classList.contains('lucide-trash-2')
|
||||
);
|
||||
|
||||
expect(discardButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders from address selector', () => {
|
||||
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('From:')).toBeInTheDocument();
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('populates from address with default value', async () => {
|
||||
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
const select = screen.getByRole('combobox');
|
||||
expect(select).toHaveValue('1');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not add Re: prefix if subject already has it', () => {
|
||||
const emailWithReply = {
|
||||
...mockReplyToEmail,
|
||||
subject: 'Re: Already Replied',
|
||||
};
|
||||
|
||||
render(<EmailComposer {...defaultProps} replyTo={emailWithReply} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByDisplayValue('Re: Already Replied')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not add Fwd: prefix if subject already has it', () => {
|
||||
const emailWithFwd = {
|
||||
...mockReplyToEmail,
|
||||
subject: 'Fwd: Already Forwarded',
|
||||
};
|
||||
|
||||
render(<EmailComposer {...defaultProps} forwardFrom={emailWithFwd} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByDisplayValue('Fwd: Already Forwarded')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('includes original message in reply body', () => {
|
||||
render(<EmailComposer {...defaultProps} replyTo={mockReplyToEmail} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const bodyTextarea = screen.getByPlaceholderText('Write your message...');
|
||||
const value = (bodyTextarea as HTMLTextAreaElement).value;
|
||||
expect(value).toContain('Original email body');
|
||||
});
|
||||
|
||||
it('includes original message in forward body', () => {
|
||||
render(<EmailComposer {...defaultProps} forwardFrom={mockReplyToEmail} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const bodyTextarea = screen.getByPlaceholderText('Write your message...');
|
||||
const value = (bodyTextarea as HTMLTextAreaElement).value;
|
||||
expect(value).toContain('Original email body');
|
||||
});
|
||||
|
||||
it('renders with all header fields', () => {
|
||||
render(<EmailComposer {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('From:')).toBeInTheDocument();
|
||||
expect(screen.getByText('To:')).toBeInTheDocument();
|
||||
expect(screen.getByText('Subject:')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
456
frontend/src/components/email/__tests__/EmailViewer.test.tsx
Normal file
456
frontend/src/components/email/__tests__/EmailViewer.test.tsx
Normal file
@@ -0,0 +1,456 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import EmailViewer from '../EmailViewer';
|
||||
import type { StaffEmail } from '../../../types';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock date-fns
|
||||
vi.mock('date-fns', () => ({
|
||||
format: vi.fn(() => '2025-01-15 10:00 AM'),
|
||||
}));
|
||||
|
||||
// Mock ResizeObserver
|
||||
class MockResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
global.ResizeObserver = MockResizeObserver as any;
|
||||
|
||||
describe('EmailViewer', () => {
|
||||
const mockEmail: StaffEmail = {
|
||||
id: 1,
|
||||
owner: 1,
|
||||
emailAddress: 1,
|
||||
folder: 1,
|
||||
fromAddress: 'sender@example.com',
|
||||
fromName: 'Sender Name',
|
||||
toAddresses: ['recipient@example.com'],
|
||||
ccAddresses: [],
|
||||
bccAddresses: [],
|
||||
subject: 'Test Email Subject',
|
||||
snippet: 'Email snippet',
|
||||
bodyText: 'This is the email body text.',
|
||||
bodyHtml: '<p>This is the email body HTML.</p>',
|
||||
messageId: 'message-123',
|
||||
inReplyTo: null,
|
||||
references: '',
|
||||
status: 'SENT',
|
||||
threadId: 'thread-123',
|
||||
emailDate: '2025-01-15T10:00:00Z',
|
||||
isRead: true,
|
||||
isStarred: false,
|
||||
isImportant: false,
|
||||
isAnswered: false,
|
||||
isPermanentlyDeleted: false,
|
||||
deletedAt: null,
|
||||
hasAttachments: false,
|
||||
attachmentCount: 0,
|
||||
attachments: [],
|
||||
labels: [],
|
||||
createdAt: '2025-01-15T10:00:00Z',
|
||||
updatedAt: '2025-01-15T10:00:00Z',
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
email: mockEmail,
|
||||
onReply: vi.fn(),
|
||||
onReplyAll: vi.fn(),
|
||||
onForward: vi.fn(),
|
||||
onArchive: vi.fn(),
|
||||
onTrash: vi.fn(),
|
||||
onStar: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders loading state', () => {
|
||||
render(<EmailViewer {...defaultProps} isLoading={true} />);
|
||||
const spinner = document.querySelector('.animate-spin');
|
||||
expect(spinner).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders email subject', () => {
|
||||
render(<EmailViewer {...defaultProps} />);
|
||||
expect(screen.getByText('Test Email Subject')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders email from name', () => {
|
||||
render(<EmailViewer {...defaultProps} />);
|
||||
expect(screen.getByText('Sender Name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders email from address', () => {
|
||||
render(<EmailViewer {...defaultProps} />);
|
||||
expect(screen.getByText('<sender@example.com>')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders email to addresses', () => {
|
||||
render(<EmailViewer {...defaultProps} />);
|
||||
expect(screen.getByText(/To:/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/recipient@example.com/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders email date', () => {
|
||||
render(<EmailViewer {...defaultProps} />);
|
||||
expect(screen.getByText('2025-01-15 10:00 AM')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders HTML body by default', () => {
|
||||
render(<EmailViewer {...defaultProps} />);
|
||||
const iframe = screen.getByTitle('Email content');
|
||||
expect(iframe).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders plain text body when no HTML', () => {
|
||||
const emailWithoutHtml = { ...mockEmail, bodyHtml: '' };
|
||||
render(<EmailViewer {...defaultProps} email={emailWithoutHtml} />);
|
||||
expect(screen.getByText('This is the email body text.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onReply when reply button clicked', () => {
|
||||
render(<EmailViewer {...defaultProps} />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const replyButton = buttons.find((btn) =>
|
||||
btn.querySelector('svg')?.classList.contains('lucide-reply')
|
||||
);
|
||||
if (replyButton) {
|
||||
fireEvent.click(replyButton);
|
||||
}
|
||||
expect(defaultProps.onReply).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onReplyAll when reply all button clicked', () => {
|
||||
render(<EmailViewer {...defaultProps} />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const replyAllButton = buttons.find((btn) =>
|
||||
btn.querySelector('svg')?.classList.contains('lucide-reply-all')
|
||||
);
|
||||
if (replyAllButton) {
|
||||
fireEvent.click(replyAllButton);
|
||||
}
|
||||
expect(defaultProps.onReplyAll).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onForward when forward button clicked', () => {
|
||||
render(<EmailViewer {...defaultProps} />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const forwardButton = buttons.find((btn) =>
|
||||
btn.querySelector('svg')?.classList.contains('lucide-forward')
|
||||
);
|
||||
if (forwardButton) {
|
||||
fireEvent.click(forwardButton);
|
||||
}
|
||||
expect(defaultProps.onForward).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onArchive when archive button clicked', () => {
|
||||
render(<EmailViewer {...defaultProps} />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const archiveButton = buttons.find((btn) =>
|
||||
btn.querySelector('svg')?.classList.contains('lucide-archive')
|
||||
);
|
||||
if (archiveButton) {
|
||||
fireEvent.click(archiveButton);
|
||||
}
|
||||
expect(defaultProps.onArchive).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onTrash when trash button clicked', () => {
|
||||
render(<EmailViewer {...defaultProps} />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const trashButton = buttons.find((btn) =>
|
||||
btn.querySelector('svg')?.classList.contains('lucide-trash-2')
|
||||
);
|
||||
if (trashButton) {
|
||||
fireEvent.click(trashButton);
|
||||
}
|
||||
expect(defaultProps.onTrash).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onStar when star button clicked', () => {
|
||||
render(<EmailViewer {...defaultProps} />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const starButton = buttons.find((btn) =>
|
||||
btn.querySelector('svg')?.classList.contains('lucide-star')
|
||||
);
|
||||
if (starButton) {
|
||||
fireEvent.click(starButton);
|
||||
}
|
||||
expect(defaultProps.onStar).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders starred email with filled star', () => {
|
||||
const starredEmail = { ...mockEmail, isStarred: true };
|
||||
render(<EmailViewer {...defaultProps} email={starredEmail} />);
|
||||
const star = document.querySelector('.fill-yellow-400');
|
||||
expect(star).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders CC addresses when present', () => {
|
||||
const emailWithCc = {
|
||||
...mockEmail,
|
||||
ccAddresses: ['cc1@example.com', 'cc2@example.com'],
|
||||
};
|
||||
render(<EmailViewer {...defaultProps} email={emailWithCc} />);
|
||||
expect(screen.getByText(/Cc:/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/cc1@example.com, cc2@example.com/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render CC section when no CC addresses', () => {
|
||||
render(<EmailViewer {...defaultProps} />);
|
||||
expect(screen.queryByText(/Cc:/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders labels when present', () => {
|
||||
const emailWithLabels = {
|
||||
...mockEmail,
|
||||
labels: [
|
||||
{ id: 1, owner: 1, name: 'Important', color: '#ff0000', createdAt: '2025-01-15T10:00:00Z' },
|
||||
{ id: 2, owner: 1, name: 'Work', color: '#00ff00', createdAt: '2025-01-15T10:00:00Z' },
|
||||
],
|
||||
};
|
||||
render(<EmailViewer {...defaultProps} email={emailWithLabels} />);
|
||||
expect(screen.getByText('Important')).toBeInTheDocument();
|
||||
expect(screen.getByText('Work')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render labels section when no labels', () => {
|
||||
render(<EmailViewer {...defaultProps} />);
|
||||
const labels = screen.queryByText('Important');
|
||||
expect(labels).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders attachments when present', () => {
|
||||
const emailWithAttachments = {
|
||||
...mockEmail,
|
||||
hasAttachments: true,
|
||||
attachmentCount: 2,
|
||||
attachments: [
|
||||
{
|
||||
id: 1,
|
||||
filename: 'document.pdf',
|
||||
contentType: 'application/pdf',
|
||||
size: 1024000,
|
||||
url: 'http://example.com/doc.pdf',
|
||||
createdAt: '2025-01-15T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
filename: 'image.png',
|
||||
contentType: 'image/png',
|
||||
size: 512000,
|
||||
url: 'http://example.com/img.png',
|
||||
createdAt: '2025-01-15T10:00:00Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
render(<EmailViewer {...defaultProps} email={emailWithAttachments} />);
|
||||
expect(screen.getByText('2 attachments')).toBeInTheDocument();
|
||||
expect(screen.getByText('document.pdf')).toBeInTheDocument();
|
||||
expect(screen.getByText('image.png')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('formats attachment sizes correctly', () => {
|
||||
const emailWithAttachments = {
|
||||
...mockEmail,
|
||||
hasAttachments: true,
|
||||
attachmentCount: 1,
|
||||
attachments: [
|
||||
{
|
||||
id: 1,
|
||||
filename: 'document.pdf',
|
||||
contentType: 'application/pdf',
|
||||
size: 1024000,
|
||||
url: 'http://example.com/doc.pdf',
|
||||
createdAt: '2025-01-15T10:00:00Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
render(<EmailViewer {...defaultProps} email={emailWithAttachments} />);
|
||||
expect(screen.getByText(/1\.0 MB|1000\.0 KB/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render attachments section when no attachments', () => {
|
||||
render(<EmailViewer {...defaultProps} />);
|
||||
expect(screen.queryByText(/attachment/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles between HTML and text view', () => {
|
||||
render(<EmailViewer {...defaultProps} />);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const textViewButton = buttons.find((btn) =>
|
||||
btn.querySelector('svg')?.classList.contains('lucide-file-text')
|
||||
);
|
||||
|
||||
if (textViewButton) {
|
||||
fireEvent.click(textViewButton);
|
||||
expect(screen.getByText('This is the email body text.')).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not show view mode toggle when no HTML body', () => {
|
||||
const emailWithoutHtml = { ...mockEmail, bodyHtml: '' };
|
||||
render(<EmailViewer {...defaultProps} email={emailWithoutHtml} />);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const htmlButton = buttons.find((btn) =>
|
||||
btn.querySelector('svg')?.classList.contains('lucide-code')
|
||||
);
|
||||
|
||||
expect(htmlButton).toBeUndefined();
|
||||
});
|
||||
|
||||
it('renders quick reply button', () => {
|
||||
render(<EmailViewer {...defaultProps} />);
|
||||
expect(screen.getByText('staffEmail.clickToReply')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onReply when quick reply button clicked', () => {
|
||||
render(<EmailViewer {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText('staffEmail.clickToReply'));
|
||||
expect(defaultProps.onReply).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders mark as read button when email is unread', () => {
|
||||
const unreadEmail = { ...mockEmail, isRead: false };
|
||||
render(<EmailViewer {...defaultProps} email={unreadEmail} onMarkRead={vi.fn()} />);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const markReadButton = buttons.find((btn) =>
|
||||
btn.querySelector('svg')?.classList.contains('lucide-mail-open')
|
||||
);
|
||||
|
||||
expect(markReadButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders mark as unread button when email is read', () => {
|
||||
render(<EmailViewer {...defaultProps} onMarkUnread={vi.fn()} />);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const markUnreadButton = buttons.find((btn) =>
|
||||
btn.querySelector('svg')?.classList.contains('lucide-mail')
|
||||
);
|
||||
|
||||
expect(markUnreadButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onMarkRead when mark as read button clicked', () => {
|
||||
const onMarkRead = vi.fn();
|
||||
const unreadEmail = { ...mockEmail, isRead: false };
|
||||
render(<EmailViewer {...defaultProps} email={unreadEmail} onMarkRead={onMarkRead} />);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const markReadButton = buttons.find((btn) =>
|
||||
btn.querySelector('svg')?.classList.contains('lucide-mail-open')
|
||||
);
|
||||
|
||||
if (markReadButton) {
|
||||
fireEvent.click(markReadButton);
|
||||
}
|
||||
expect(onMarkRead).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onMarkUnread when mark as unread button clicked', () => {
|
||||
const onMarkUnread = vi.fn();
|
||||
render(<EmailViewer {...defaultProps} onMarkUnread={onMarkUnread} />);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const markUnreadButton = buttons.find((btn) =>
|
||||
btn.querySelector('svg')?.classList.contains('lucide-mail')
|
||||
);
|
||||
|
||||
if (markUnreadButton) {
|
||||
fireEvent.click(markUnreadButton);
|
||||
}
|
||||
expect(onMarkUnread).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders restore button when in trash', () => {
|
||||
const onRestore = vi.fn();
|
||||
render(<EmailViewer {...defaultProps} isInTrash={true} onRestore={onRestore} />);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const restoreButton = buttons.find((btn) =>
|
||||
btn.querySelector('svg')?.classList.contains('lucide-rotate-ccw')
|
||||
);
|
||||
|
||||
expect(restoreButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onRestore when restore button clicked', () => {
|
||||
const onRestore = vi.fn();
|
||||
render(<EmailViewer {...defaultProps} isInTrash={true} onRestore={onRestore} />);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const restoreButton = buttons.find((btn) =>
|
||||
btn.querySelector('svg')?.classList.contains('lucide-rotate-ccw')
|
||||
);
|
||||
|
||||
if (restoreButton) {
|
||||
fireEvent.click(restoreButton);
|
||||
}
|
||||
expect(onRestore).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows trash button when not in trash', () => {
|
||||
render(<EmailViewer {...defaultProps} isInTrash={false} />);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const trashButton = buttons.find((btn) =>
|
||||
btn.querySelector('svg')?.classList.contains('lucide-trash-2')
|
||||
);
|
||||
|
||||
expect(trashButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show trash button when in trash', () => {
|
||||
const onRestore = vi.fn();
|
||||
render(<EmailViewer {...defaultProps} isInTrash={true} onRestore={onRestore} />);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const trashButtons = buttons.filter((btn) =>
|
||||
btn.querySelector('svg')?.classList.contains('lucide-trash-2')
|
||||
);
|
||||
|
||||
expect(trashButtons.length).toBe(0);
|
||||
});
|
||||
|
||||
it('renders (No Subject) when subject is empty', () => {
|
||||
const emailWithoutSubject = { ...mockEmail, subject: '' };
|
||||
render(<EmailViewer {...defaultProps} email={emailWithoutSubject} />);
|
||||
expect(screen.getByText('(No Subject)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders avatar with first letter of sender name', () => {
|
||||
render(<EmailViewer {...defaultProps} />);
|
||||
const avatar = screen.getByText('S'); // First letter of "Sender Name"
|
||||
expect(avatar).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses email address for avatar when no name provided', () => {
|
||||
const emailWithoutName = { ...mockEmail, fromName: '' };
|
||||
render(<EmailViewer {...defaultProps} email={emailWithoutName} />);
|
||||
const avatar = screen.getByText('S'); // First letter of "sender@example.com" (uppercase)
|
||||
expect(avatar).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders multiple to addresses', () => {
|
||||
const emailWithMultipleTo = {
|
||||
...mockEmail,
|
||||
toAddresses: ['to1@example.com', 'to2@example.com', 'to3@example.com'],
|
||||
};
|
||||
render(<EmailViewer {...defaultProps} email={emailWithMultipleTo} />);
|
||||
expect(screen.getByText(/to1@example.com, to2@example.com, to3@example.com/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
461
frontend/src/components/help/__tests__/HelpSearch.test.tsx
Normal file
461
frontend/src/components/help/__tests__/HelpSearch.test.tsx
Normal file
@@ -0,0 +1,461 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { HelpSearch } from '../HelpSearch';
|
||||
import * as useHelpSearchModule from '../../../hooks/useHelpSearch';
|
||||
|
||||
// Mock the useHelpSearch hook
|
||||
vi.mock('../../../hooks/useHelpSearch');
|
||||
|
||||
describe('HelpSearch', () => {
|
||||
const mockSearch = vi.fn();
|
||||
const defaultHookReturn = {
|
||||
search: mockSearch,
|
||||
results: [],
|
||||
isSearching: false,
|
||||
error: null,
|
||||
hasApiKey: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(useHelpSearchModule, 'useHelpSearch').mockReturnValue(defaultHookReturn);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
});
|
||||
|
||||
const renderWithRouter = (component: React.ReactElement) => {
|
||||
return render(<MemoryRouter>{component}</MemoryRouter>);
|
||||
};
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders search input with default placeholder', () => {
|
||||
renderWithRouter(<HelpSearch />);
|
||||
expect(screen.getByPlaceholderText('Ask a question or search for help...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders search input with custom placeholder', () => {
|
||||
renderWithRouter(<HelpSearch placeholder="Search documentation..." />);
|
||||
expect(screen.getByPlaceholderText('Search documentation...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = renderWithRouter(<HelpSearch className="custom-class" />);
|
||||
expect(container.querySelector('.custom-class')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows search icon by default', () => {
|
||||
renderWithRouter(<HelpSearch />);
|
||||
const searchIcon = document.querySelector('svg');
|
||||
expect(searchIcon).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AI badge', () => {
|
||||
it('shows AI badge when API key is present', () => {
|
||||
vi.spyOn(useHelpSearchModule, 'useHelpSearch').mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
hasApiKey: true,
|
||||
});
|
||||
renderWithRouter(<HelpSearch />);
|
||||
expect(screen.getByText('AI')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show AI badge when API key is absent', () => {
|
||||
renderWithRouter(<HelpSearch />);
|
||||
expect(screen.queryByText('AI')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('search input behavior', () => {
|
||||
it('updates query on input change', () => {
|
||||
renderWithRouter(<HelpSearch />);
|
||||
const input = screen.getByPlaceholderText('Ask a question or search for help...');
|
||||
|
||||
fireEvent.change(input, { target: { value: 'scheduler' } });
|
||||
expect(input).toHaveValue('scheduler');
|
||||
});
|
||||
|
||||
it('debounces search after input change', async () => {
|
||||
vi.useFakeTimers();
|
||||
renderWithRouter(<HelpSearch />);
|
||||
const input = screen.getByPlaceholderText('Ask a question or search for help...');
|
||||
|
||||
fireEvent.change(input, { target: { value: 'scheduler' } });
|
||||
|
||||
// Should not call immediately
|
||||
expect(mockSearch).not.toHaveBeenCalled();
|
||||
|
||||
// Fast-forward 300ms
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
expect(mockSearch).toHaveBeenCalledWith('scheduler');
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('debounces multiple rapid inputs', async () => {
|
||||
vi.useFakeTimers();
|
||||
renderWithRouter(<HelpSearch />);
|
||||
const input = screen.getByPlaceholderText('Ask a question or search for help...');
|
||||
|
||||
fireEvent.change(input, { target: { value: 's' } });
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
fireEvent.change(input, { target: { value: 'sc' } });
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
fireEvent.change(input, { target: { value: 'scheduler' } });
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
// Should only call once with the final value
|
||||
expect(mockSearch).toHaveBeenCalledTimes(1);
|
||||
expect(mockSearch).toHaveBeenCalledWith('scheduler');
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('does not search for empty query', () => {
|
||||
vi.useFakeTimers();
|
||||
renderWithRouter(<HelpSearch />);
|
||||
const input = screen.getByPlaceholderText('Ask a question or search for help...');
|
||||
|
||||
fireEvent.change(input, { target: { value: ' ' } });
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
expect(mockSearch).not.toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear button', () => {
|
||||
it('shows clear button when query is not empty', () => {
|
||||
renderWithRouter(<HelpSearch />);
|
||||
const input = screen.getByPlaceholderText('Ask a question or search for help...');
|
||||
|
||||
fireEvent.change(input, { target: { value: 'scheduler' } });
|
||||
|
||||
const clearButton = screen.getByRole('button');
|
||||
expect(clearButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show clear button when query is empty', () => {
|
||||
renderWithRouter(<HelpSearch />);
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears query and focuses input when clicked', () => {
|
||||
renderWithRouter(<HelpSearch />);
|
||||
const input = screen.getByPlaceholderText('Ask a question or search for help...') as HTMLInputElement;
|
||||
|
||||
fireEvent.change(input, { target: { value: 'scheduler' } });
|
||||
|
||||
const clearButton = screen.getByRole('button');
|
||||
fireEvent.click(clearButton);
|
||||
|
||||
expect(input).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading state', () => {
|
||||
it('shows loading spinner when searching', () => {
|
||||
vi.spyOn(useHelpSearchModule, 'useHelpSearch').mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
isSearching: true,
|
||||
});
|
||||
|
||||
renderWithRouter(<HelpSearch />);
|
||||
const spinner = document.querySelector('.animate-spin');
|
||||
expect(spinner).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows search icon when not searching', () => {
|
||||
renderWithRouter(<HelpSearch />);
|
||||
const spinner = document.querySelector('.animate-spin');
|
||||
expect(spinner).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('keyboard navigation', () => {
|
||||
it('closes dropdown and blurs input on Escape key', () => {
|
||||
vi.spyOn(useHelpSearchModule, 'useHelpSearch').mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
results: [
|
||||
{
|
||||
path: '/help/scheduler',
|
||||
title: 'Scheduler',
|
||||
description: 'Manage schedules',
|
||||
category: 'Scheduling',
|
||||
topics: ['scheduler', 'calendar'],
|
||||
relevanceScore: 10,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
renderWithRouter(<HelpSearch />);
|
||||
const input = screen.getByPlaceholderText('Ask a question or search for help...') as HTMLInputElement;
|
||||
|
||||
fireEvent.change(input, { target: { value: 'scheduler' } });
|
||||
fireEvent.focus(input);
|
||||
|
||||
fireEvent.keyDown(input, { key: 'Escape' });
|
||||
|
||||
// Results should be hidden
|
||||
expect(screen.queryByText('Scheduler')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('results dropdown', () => {
|
||||
const mockResults = [
|
||||
{
|
||||
path: '/help/scheduler',
|
||||
title: 'Scheduler',
|
||||
description: 'Manage your schedules',
|
||||
category: 'Scheduling',
|
||||
topics: ['scheduler', 'calendar'],
|
||||
relevanceScore: 10,
|
||||
matchReason: 'Matched: title: scheduler',
|
||||
},
|
||||
{
|
||||
path: '/help/services',
|
||||
title: 'Services',
|
||||
description: 'Configure services',
|
||||
category: 'Services',
|
||||
topics: ['services', 'booking'],
|
||||
relevanceScore: 5,
|
||||
},
|
||||
];
|
||||
|
||||
it('shows results when available and dropdown is open', () => {
|
||||
vi.spyOn(useHelpSearchModule, 'useHelpSearch').mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
results: mockResults,
|
||||
});
|
||||
|
||||
renderWithRouter(<HelpSearch />);
|
||||
const input = screen.getByPlaceholderText('Ask a question or search for help...');
|
||||
|
||||
fireEvent.change(input, { target: { value: 'scheduler' } });
|
||||
fireEvent.focus(input);
|
||||
|
||||
// Use getAllByText since titles appear in both the result and category badge
|
||||
const schedulerElements = screen.getAllByText('Scheduler');
|
||||
expect(schedulerElements.length).toBeGreaterThan(0);
|
||||
const servicesElements = screen.getAllByText('Services');
|
||||
expect(servicesElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows match reason when available', () => {
|
||||
vi.spyOn(useHelpSearchModule, 'useHelpSearch').mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
results: mockResults,
|
||||
});
|
||||
|
||||
renderWithRouter(<HelpSearch />);
|
||||
const input = screen.getByPlaceholderText('Ask a question or search for help...');
|
||||
|
||||
fireEvent.change(input, { target: { value: 'scheduler' } });
|
||||
fireEvent.focus(input);
|
||||
|
||||
expect(screen.getByText('Matched: title: scheduler')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows description when match reason is not available', () => {
|
||||
vi.spyOn(useHelpSearchModule, 'useHelpSearch').mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
results: mockResults,
|
||||
});
|
||||
|
||||
renderWithRouter(<HelpSearch />);
|
||||
const input = screen.getByPlaceholderText('Ask a question or search for help...');
|
||||
|
||||
fireEvent.change(input, { target: { value: 'services' } });
|
||||
fireEvent.focus(input);
|
||||
|
||||
expect(screen.getByText('Configure services')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows category badge for each result', () => {
|
||||
vi.spyOn(useHelpSearchModule, 'useHelpSearch').mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
results: mockResults,
|
||||
});
|
||||
|
||||
renderWithRouter(<HelpSearch />);
|
||||
const input = screen.getByPlaceholderText('Ask a question or search for help...');
|
||||
|
||||
fireEvent.change(input, { target: { value: 'scheduler' } });
|
||||
fireEvent.focus(input);
|
||||
|
||||
expect(screen.getByText('Scheduling')).toBeInTheDocument();
|
||||
// Services appears as both category and title - get all instances
|
||||
const servicesElements = screen.getAllByText('Services');
|
||||
expect(servicesElements.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('clears query and closes dropdown when result is clicked', () => {
|
||||
vi.spyOn(useHelpSearchModule, 'useHelpSearch').mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
results: mockResults,
|
||||
});
|
||||
|
||||
renderWithRouter(<HelpSearch />);
|
||||
const input = screen.getByPlaceholderText('Ask a question or search for help...') as HTMLInputElement;
|
||||
|
||||
fireEvent.change(input, { target: { value: 'scheduler' } });
|
||||
fireEvent.focus(input);
|
||||
|
||||
const resultLink = screen.getByText('Scheduler').closest('a');
|
||||
fireEvent.click(resultLink!);
|
||||
|
||||
expect(input).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty states', () => {
|
||||
it('renders component when no results found', () => {
|
||||
const mockSearchFn = vi.fn();
|
||||
vi.spyOn(useHelpSearchModule, 'useHelpSearch').mockReturnValue({
|
||||
search: mockSearchFn,
|
||||
results: [],
|
||||
isSearching: false,
|
||||
error: null,
|
||||
hasApiKey: false,
|
||||
});
|
||||
|
||||
const { container } = renderWithRouter(<HelpSearch />);
|
||||
expect(container).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Ask a question or search for help...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show no results message while searching', () => {
|
||||
vi.spyOn(useHelpSearchModule, 'useHelpSearch').mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
isSearching: true,
|
||||
results: [],
|
||||
});
|
||||
|
||||
renderWithRouter(<HelpSearch />);
|
||||
const input = screen.getByPlaceholderText('Ask a question or search for help...');
|
||||
|
||||
fireEvent.change(input, { target: { value: 'scheduler' } });
|
||||
fireEvent.focus(input);
|
||||
|
||||
expect(screen.queryByText(/No results found/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error state', () => {
|
||||
it('renders component when error is present', () => {
|
||||
const mockSearchFn = vi.fn();
|
||||
vi.spyOn(useHelpSearchModule, 'useHelpSearch').mockReturnValue({
|
||||
search: mockSearchFn,
|
||||
results: [],
|
||||
isSearching: false,
|
||||
error: 'Search failed. Please try again.',
|
||||
hasApiKey: false,
|
||||
});
|
||||
|
||||
const { container } = renderWithRouter(<HelpSearch />);
|
||||
expect(container).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Ask a question or search for help...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles error state without crashing', () => {
|
||||
const mockSearchFn = vi.fn();
|
||||
vi.spyOn(useHelpSearchModule, 'useHelpSearch').mockReturnValue({
|
||||
search: mockSearchFn,
|
||||
results: [],
|
||||
isSearching: false,
|
||||
error: 'Search failed',
|
||||
hasApiKey: false,
|
||||
});
|
||||
|
||||
const { container } = renderWithRouter(<HelpSearch />);
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('click outside behavior', () => {
|
||||
it('closes dropdown when clicking outside', () => {
|
||||
vi.spyOn(useHelpSearchModule, 'useHelpSearch').mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
results: [
|
||||
{
|
||||
path: '/help/scheduler',
|
||||
title: 'Scheduler',
|
||||
description: 'Manage schedules',
|
||||
category: 'Scheduling',
|
||||
topics: ['scheduler'],
|
||||
relevanceScore: 10,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { container } = renderWithRouter(<HelpSearch />);
|
||||
const input = screen.getByPlaceholderText('Ask a question or search for help...');
|
||||
|
||||
fireEvent.change(input, { target: { value: 'scheduler' } });
|
||||
fireEvent.focus(input);
|
||||
|
||||
expect(screen.getByText('Scheduler')).toBeInTheDocument();
|
||||
|
||||
// Click outside
|
||||
fireEvent.mouseDown(container);
|
||||
|
||||
// Results should be hidden
|
||||
expect(screen.queryByText('Scheduler')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('focus behavior', () => {
|
||||
it('reopens dropdown on focus if results exist', () => {
|
||||
vi.spyOn(useHelpSearchModule, 'useHelpSearch').mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
results: [
|
||||
{
|
||||
path: '/help/scheduler',
|
||||
title: 'Scheduler',
|
||||
description: 'Manage schedules',
|
||||
category: 'Scheduling',
|
||||
topics: ['scheduler'],
|
||||
relevanceScore: 10,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
renderWithRouter(<HelpSearch />);
|
||||
const input = screen.getByPlaceholderText('Ask a question or search for help...');
|
||||
|
||||
fireEvent.change(input, { target: { value: 'scheduler' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
fireEvent.focus(input);
|
||||
|
||||
expect(screen.getByText('Scheduler')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not open dropdown on focus if query is empty', () => {
|
||||
vi.spyOn(useHelpSearchModule, 'useHelpSearch').mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
results: [
|
||||
{
|
||||
path: '/help/scheduler',
|
||||
title: 'Scheduler',
|
||||
description: 'Manage schedules',
|
||||
category: 'Scheduling',
|
||||
topics: ['scheduler'],
|
||||
relevanceScore: 10,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
renderWithRouter(<HelpSearch />);
|
||||
const input = screen.getByPlaceholderText('Ask a question or search for help...');
|
||||
|
||||
fireEvent.focus(input);
|
||||
|
||||
expect(screen.queryByText('Scheduler')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,377 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { UnscheduledBookingDemo } from '../UnscheduledBookingDemo';
|
||||
|
||||
describe('UnscheduledBookingDemo', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders all sections by default', () => {
|
||||
render(<UnscheduledBookingDemo />);
|
||||
|
||||
expect(screen.getByText('Service Settings')).toBeInTheDocument();
|
||||
expect(screen.getByText('Customer Booking')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pending Requests')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders only service section when view is "service"', () => {
|
||||
render(<UnscheduledBookingDemo view="service" />);
|
||||
|
||||
expect(screen.getByText('Service Settings')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Customer Booking')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Pending Requests')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders only customer section when view is "customer"', () => {
|
||||
render(<UnscheduledBookingDemo view="customer" />);
|
||||
|
||||
expect(screen.queryByText('Service Settings')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Customer Booking')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Pending Requests')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders only pending section when view is "pending"', () => {
|
||||
render(<UnscheduledBookingDemo view="pending" />);
|
||||
|
||||
expect(screen.queryByText('Service Settings')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Customer Booking')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Pending Requests')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders interactive demo caption', () => {
|
||||
render(<UnscheduledBookingDemo />);
|
||||
expect(screen.getByText('Interactive demo - click to explore the workflow')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('service configuration section', () => {
|
||||
it('shows "Requires Manual Scheduling" checkbox checked by default', () => {
|
||||
render(<UnscheduledBookingDemo view="service" />);
|
||||
expect(screen.getByText('Requires Manual Scheduling')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles "Requires Manual Scheduling" on click', () => {
|
||||
render(<UnscheduledBookingDemo view="service" />);
|
||||
|
||||
const checkbox = screen.getByText('Requires Manual Scheduling').closest('div');
|
||||
expect(checkbox).toHaveClass('border-orange-500');
|
||||
|
||||
fireEvent.click(checkbox!);
|
||||
|
||||
expect(checkbox).not.toHaveClass('border-orange-500');
|
||||
});
|
||||
|
||||
it('shows "Ask for Preferred Time" when manual scheduling is enabled', () => {
|
||||
render(<UnscheduledBookingDemo view="service" />);
|
||||
expect(screen.getByText('Ask for Preferred Time')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides "Ask for Preferred Time" when manual scheduling is disabled', () => {
|
||||
render(<UnscheduledBookingDemo view="service" />);
|
||||
|
||||
const manualSchedulingCheckbox = screen.getByText('Requires Manual Scheduling').closest('div');
|
||||
fireEvent.click(manualSchedulingCheckbox!);
|
||||
|
||||
expect(screen.queryByText('Ask for Preferred Time')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles "Ask for Preferred Time" on click', () => {
|
||||
render(<UnscheduledBookingDemo view="service" />);
|
||||
|
||||
const checkbox = screen.getByText('Ask for Preferred Time').closest('div');
|
||||
expect(checkbox).toHaveClass('border-blue-500');
|
||||
|
||||
fireEvent.click(checkbox!);
|
||||
|
||||
expect(checkbox).not.toHaveClass('border-blue-500');
|
||||
});
|
||||
});
|
||||
|
||||
describe('customer booking section', () => {
|
||||
it('shows manual scheduling message when enabled', () => {
|
||||
render(<UnscheduledBookingDemo view="customer" />);
|
||||
expect(screen.getByText("We'll call you to schedule")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows standard booking flow message when manual scheduling is disabled', () => {
|
||||
render(<UnscheduledBookingDemo />);
|
||||
|
||||
const manualSchedulingCheckbox = screen.getByText('Requires Manual Scheduling').closest('div');
|
||||
fireEvent.click(manualSchedulingCheckbox!);
|
||||
|
||||
expect(screen.getByText('Standard booking flow')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "I have a preferred time" checkbox when capture is enabled', () => {
|
||||
render(<UnscheduledBookingDemo view="customer" />);
|
||||
expect(screen.getByText('I have a preferred time')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides "I have a preferred time" when capture is disabled', () => {
|
||||
render(<UnscheduledBookingDemo />);
|
||||
|
||||
const captureCheckbox = screen.getByText('Ask for Preferred Time').closest('div');
|
||||
fireEvent.click(captureCheckbox!);
|
||||
|
||||
expect(screen.queryByText('I have a preferred time')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles "I have a preferred time" on click', () => {
|
||||
render(<UnscheduledBookingDemo view="customer" />);
|
||||
|
||||
const checkbox = screen.getByText('I have a preferred time').closest('div');
|
||||
expect(checkbox).toHaveClass('border-blue-500');
|
||||
|
||||
fireEvent.click(checkbox!);
|
||||
|
||||
expect(checkbox).not.toHaveClass('border-blue-500');
|
||||
});
|
||||
|
||||
it('shows date and time inputs when customer has preferred time', () => {
|
||||
render(<UnscheduledBookingDemo view="customer" />);
|
||||
|
||||
const dateInput = screen.getByDisplayValue('2025-12-26');
|
||||
const timeInput = screen.getByDisplayValue('afternoons');
|
||||
|
||||
expect(dateInput).toBeInTheDocument();
|
||||
expect(timeInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides date and time inputs when customer does not have preferred time', () => {
|
||||
render(<UnscheduledBookingDemo view="customer" />);
|
||||
|
||||
const checkbox = screen.getByText('I have a preferred time').closest('div');
|
||||
fireEvent.click(checkbox!);
|
||||
|
||||
expect(screen.queryByDisplayValue('2025-12-26')).not.toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue('afternoons')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates preferred date on input change', () => {
|
||||
render(<UnscheduledBookingDemo view="customer" />);
|
||||
|
||||
const dateInput = screen.getByDisplayValue('2025-12-26') as HTMLInputElement;
|
||||
fireEvent.change(dateInput, { target: { value: '2025-12-27' } });
|
||||
|
||||
expect(dateInput).toHaveValue('2025-12-27');
|
||||
});
|
||||
|
||||
it('updates preferred time notes on input change', () => {
|
||||
render(<UnscheduledBookingDemo view="customer" />);
|
||||
|
||||
const timeInput = screen.getByDisplayValue('afternoons') as HTMLInputElement;
|
||||
fireEvent.change(timeInput, { target: { value: 'mornings' } });
|
||||
|
||||
expect(timeInput).toHaveValue('mornings');
|
||||
});
|
||||
|
||||
it('shows request callback button', () => {
|
||||
render(<UnscheduledBookingDemo view="customer" />);
|
||||
expect(screen.getByText('Request Callback')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pending requests section', () => {
|
||||
it('shows sample pending requests', () => {
|
||||
render(<UnscheduledBookingDemo view="pending" />);
|
||||
|
||||
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bob Johnson')).toBeInTheDocument();
|
||||
expect(screen.getByText('Lisa Park')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows service name for each request', () => {
|
||||
render(<UnscheduledBookingDemo view="pending" />);
|
||||
|
||||
const serviceNames = screen.getAllByText('Free Consultation');
|
||||
expect(serviceNames).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('shows preferred date and time when available', () => {
|
||||
render(<UnscheduledBookingDemo view="pending" />);
|
||||
|
||||
expect(screen.getByText('Prefers: Dec 26, afternoons')).toBeInTheDocument();
|
||||
expect(screen.getByText('Prefers: Dec 27, 10:00 AM')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "No preferred time" when not available', () => {
|
||||
render(<UnscheduledBookingDemo view="pending" />);
|
||||
expect(screen.getByText('No preferred time')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles selection on pending item click', () => {
|
||||
render(<UnscheduledBookingDemo view="pending" />);
|
||||
|
||||
const janeItem = screen.getByText('Jane Smith').closest('div');
|
||||
expect(janeItem).not.toHaveClass('ring-2');
|
||||
|
||||
fireEvent.click(janeItem!);
|
||||
expect(janeItem).toHaveClass('ring-2');
|
||||
|
||||
fireEvent.click(janeItem!);
|
||||
expect(janeItem).not.toHaveClass('ring-2');
|
||||
});
|
||||
|
||||
it('shows detail panel when item is selected', () => {
|
||||
render(<UnscheduledBookingDemo view="pending" />);
|
||||
|
||||
const janeItem = screen.getByText('Jane Smith').closest('div');
|
||||
fireEvent.click(janeItem!);
|
||||
|
||||
expect(screen.getByText('Preferred Schedule')).toBeInTheDocument();
|
||||
expect(screen.getByText('December 26, 2025 - Afternoons')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows correct details for different selected items', () => {
|
||||
render(<UnscheduledBookingDemo view="pending" />);
|
||||
|
||||
// Select Bob Johnson
|
||||
const bobItem = screen.getByText('Bob Johnson').closest('div');
|
||||
fireEvent.click(bobItem!);
|
||||
|
||||
expect(screen.getByText('December 27, 2025 - 10:00 AM')).toBeInTheDocument();
|
||||
|
||||
// Select Lisa Park
|
||||
fireEvent.click(bobItem!);
|
||||
const lisaItem = screen.getByText('Lisa Park').closest('div');
|
||||
fireEvent.click(lisaItem!);
|
||||
|
||||
expect(screen.getByText('No preference specified')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('rotates chevron icon when item is selected', () => {
|
||||
render(<UnscheduledBookingDemo view="pending" />);
|
||||
|
||||
const janeItem = screen.getByText('Jane Smith').closest('div');
|
||||
const chevron = janeItem?.querySelector('svg');
|
||||
|
||||
expect(chevron).not.toHaveClass('rotate-90');
|
||||
|
||||
fireEvent.click(janeItem!);
|
||||
expect(chevron).toHaveClass('rotate-90');
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration - service and customer sections', () => {
|
||||
it('updates customer section when service settings change', () => {
|
||||
render(<UnscheduledBookingDemo />);
|
||||
|
||||
// Initially shows manual scheduling message
|
||||
expect(screen.getByText("We'll call you to schedule")).toBeInTheDocument();
|
||||
|
||||
// Disable manual scheduling
|
||||
const manualSchedulingCheckbox = screen.getByText('Requires Manual Scheduling').closest('div');
|
||||
fireEvent.click(manualSchedulingCheckbox!);
|
||||
|
||||
// Should show standard booking flow
|
||||
expect(screen.getByText('Standard booking flow')).toBeInTheDocument();
|
||||
expect(screen.queryByText("We'll call you to schedule")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides preferred time fields when service setting is disabled', () => {
|
||||
render(<UnscheduledBookingDemo />);
|
||||
|
||||
// Initially shows preferred time checkbox
|
||||
expect(screen.getByText('I have a preferred time')).toBeInTheDocument();
|
||||
|
||||
// Disable preferred time capture
|
||||
const captureCheckbox = screen.getByText('Ask for Preferred Time').closest('div');
|
||||
fireEvent.click(captureCheckbox!);
|
||||
|
||||
// Should hide customer preferred time checkbox
|
||||
expect(screen.queryByText('I have a preferred time')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('cascades changes through all sections', () => {
|
||||
render(<UnscheduledBookingDemo />);
|
||||
|
||||
// Initially shows all manual scheduling features
|
||||
expect(screen.getByText('Ask for Preferred Time')).toBeInTheDocument();
|
||||
expect(screen.getByText('I have a preferred time')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('2025-12-26')).toBeInTheDocument();
|
||||
|
||||
// Disable manual scheduling entirely
|
||||
const manualSchedulingCheckbox = screen.getByText('Requires Manual Scheduling').closest('div');
|
||||
fireEvent.click(manualSchedulingCheckbox!);
|
||||
|
||||
// All related features should be hidden
|
||||
expect(screen.queryByText('Ask for Preferred Time')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('I have a preferred time')).not.toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue('2025-12-26')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('uses proper labels for interactive elements', () => {
|
||||
render(<UnscheduledBookingDemo view="service" />);
|
||||
|
||||
const manualSchedulingLabel = screen.getByText('Requires Manual Scheduling');
|
||||
expect(manualSchedulingLabel.closest('label')).toBeInTheDocument();
|
||||
|
||||
const preferredTimeLabel = screen.getByText('Ask for Preferred Time');
|
||||
expect(preferredTimeLabel.closest('label')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('date input has proper type attribute', () => {
|
||||
render(<UnscheduledBookingDemo view="customer" />);
|
||||
|
||||
const dateInput = screen.getByDisplayValue('2025-12-26');
|
||||
expect(dateInput).toHaveAttribute('type', 'date');
|
||||
});
|
||||
|
||||
it('time notes input has proper type attribute', () => {
|
||||
render(<UnscheduledBookingDemo view="customer" />);
|
||||
|
||||
const timeInput = screen.getByDisplayValue('afternoons');
|
||||
expect(timeInput).toHaveAttribute('type', 'text');
|
||||
});
|
||||
|
||||
it('time notes input has placeholder', () => {
|
||||
render(<UnscheduledBookingDemo view="customer" />);
|
||||
|
||||
const checkbox = screen.getByText('I have a preferred time').closest('div');
|
||||
fireEvent.click(checkbox!);
|
||||
fireEvent.click(checkbox!);
|
||||
|
||||
const timeInput = screen.getByPlaceholderText('e.g., afternoons');
|
||||
expect(timeInput).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('styling and visual feedback', () => {
|
||||
it('applies correct border color when manual scheduling is enabled', () => {
|
||||
render(<UnscheduledBookingDemo view="service" />);
|
||||
|
||||
const checkbox = screen.getByText('Requires Manual Scheduling').closest('div');
|
||||
expect(checkbox).toHaveClass('border-orange-500');
|
||||
expect(checkbox).toHaveClass('bg-orange-50');
|
||||
});
|
||||
|
||||
it('applies correct border color when preferred time is enabled', () => {
|
||||
render(<UnscheduledBookingDemo view="service" />);
|
||||
|
||||
const checkbox = screen.getByText('Ask for Preferred Time').closest('div');
|
||||
expect(checkbox).toHaveClass('border-blue-500');
|
||||
expect(checkbox).toHaveClass('bg-blue-50');
|
||||
});
|
||||
|
||||
it('applies correct styling to pending request with preferred time', () => {
|
||||
render(<UnscheduledBookingDemo view="pending" />);
|
||||
|
||||
const janePreference = screen.getByText('Prefers: Dec 26, afternoons');
|
||||
expect(janePreference.closest('div')).toHaveClass('text-blue-600');
|
||||
});
|
||||
|
||||
it('applies correct styling to pending request without preferred time', () => {
|
||||
render(<UnscheduledBookingDemo view="pending" />);
|
||||
|
||||
const lisaPreference = screen.getByText('No preferred time');
|
||||
expect(lisaPreference.closest('div')).toHaveClass('text-gray-400');
|
||||
});
|
||||
|
||||
it('applies orange border to pending request items', () => {
|
||||
render(<UnscheduledBookingDemo view="pending" />);
|
||||
|
||||
const janeItem = screen.getByText('Jane Smith').closest('div');
|
||||
expect(janeItem).toHaveClass('border-orange-400');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import AutomationShowcase from '../AutomationShowcase';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string | { days?: number }) => {
|
||||
// Handle translation keys with nested structure
|
||||
const translations: Record<string, string> = {
|
||||
'marketing.plugins.badge': 'Automation & Plugins',
|
||||
'marketing.plugins.headline': 'Automate Your Workflow',
|
||||
'marketing.plugins.subheadline': 'Build powerful automations with our plugin system',
|
||||
'marketing.plugins.examples.winback.title': 'Win-Back Campaign',
|
||||
'marketing.plugins.examples.winback.description': 'Re-engage inactive customers',
|
||||
'marketing.plugins.examples.winback.stats.retention': '+25% retention',
|
||||
'marketing.plugins.examples.winback.stats.revenue': '$10k+ revenue',
|
||||
'marketing.plugins.examples.noshow.title': 'No-Show Prevention',
|
||||
'marketing.plugins.examples.noshow.description': 'Reduce missed appointments',
|
||||
'marketing.plugins.examples.noshow.stats.reduction': '40% reduction',
|
||||
'marketing.plugins.examples.noshow.stats.utilization': '95% utilization',
|
||||
'marketing.plugins.examples.report.title': 'Daily Reports',
|
||||
'marketing.plugins.examples.report.description': 'Automated scheduling reports',
|
||||
'marketing.plugins.examples.report.stats.timeSaved': '2h saved/day',
|
||||
'marketing.plugins.examples.report.stats.visibility': 'Real-time visibility',
|
||||
'marketing.plugins.cta': 'Explore automation features',
|
||||
};
|
||||
return translations[key] || (typeof fallback === 'string' ? fallback : key);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock framer-motion
|
||||
vi.mock('framer-motion', () => ({
|
||||
motion: {
|
||||
div: ({ children, ...props }: any) => React.createElement('div', props, children),
|
||||
},
|
||||
AnimatePresence: ({ children }: any) => React.createElement('div', null, children),
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock('lucide-react', () => ({
|
||||
Mail: () => React.createElement('div', { 'data-testid': 'mail-icon' }),
|
||||
Calendar: () => React.createElement('div', { 'data-testid': 'calendar-icon' }),
|
||||
Bell: () => React.createElement('div', { 'data-testid': 'bell-icon' }),
|
||||
ArrowRight: () => React.createElement('div', { 'data-testid': 'arrow-right-icon' }),
|
||||
Zap: () => React.createElement('div', { 'data-testid': 'zap-icon' }),
|
||||
CheckCircle2: () => React.createElement('div', { 'data-testid': 'check-circle-icon' }),
|
||||
Clock: () => React.createElement('div', { 'data-testid': 'clock-icon' }),
|
||||
Search: () => React.createElement('div', { 'data-testid': 'search-icon' }),
|
||||
FileText: () => React.createElement('div', { 'data-testid': 'file-text-icon' }),
|
||||
MessageSquare: () => React.createElement('div', { 'data-testid': 'message-square-icon' }),
|
||||
Sparkles: () => React.createElement('div', { 'data-testid': 'sparkles-icon' }),
|
||||
ChevronRight: () => React.createElement('div', { 'data-testid': 'chevron-right-icon' }),
|
||||
}));
|
||||
|
||||
// Mock WorkflowVisual component
|
||||
vi.mock('../WorkflowVisual', () => ({
|
||||
default: ({ variant, trigger }: { variant: string; trigger: string }) =>
|
||||
React.createElement(
|
||||
'div',
|
||||
{ 'data-testid': 'workflow-visual', 'data-variant': variant },
|
||||
trigger
|
||||
),
|
||||
}));
|
||||
|
||||
describe('AutomationShowcase', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders component with headline and subheadline', () => {
|
||||
render(React.createElement(AutomationShowcase));
|
||||
expect(screen.getByText('Automate Your Workflow')).toBeInTheDocument();
|
||||
expect(screen.getByText('Build powerful automations with our plugin system')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders badge with automation text', () => {
|
||||
render(React.createElement(AutomationShowcase));
|
||||
expect(screen.getByText('Automation & Plugins')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all three automation examples', () => {
|
||||
render(React.createElement(AutomationShowcase));
|
||||
const winbackElements = screen.getAllByText('Win-Back Campaign');
|
||||
expect(winbackElements.length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('No-Show Prevention')).toBeInTheDocument();
|
||||
expect(screen.getByText('Daily Reports')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders example descriptions', () => {
|
||||
render(React.createElement(AutomationShowcase));
|
||||
expect(screen.getByText('Re-engage inactive customers')).toBeInTheDocument();
|
||||
expect(screen.getByText('Reduce missed appointments')).toBeInTheDocument();
|
||||
expect(screen.getByText('Automated scheduling reports')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders WorkflowVisual component', () => {
|
||||
render(React.createElement(AutomationShowcase));
|
||||
expect(screen.getByTestId('workflow-visual')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders CTA link', () => {
|
||||
render(React.createElement(AutomationShowcase));
|
||||
const ctaLink = screen.getByText('Explore automation features').closest('a');
|
||||
expect(ctaLink).toBeInTheDocument();
|
||||
expect(ctaLink).toHaveAttribute('href', '/features');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab switching', () => {
|
||||
it('renders first example as active by default', () => {
|
||||
const { container } = render(React.createElement(AutomationShowcase));
|
||||
const workflowVisual = screen.getByTestId('workflow-visual');
|
||||
expect(workflowVisual).toHaveAttribute('data-variant', 'winback');
|
||||
});
|
||||
|
||||
it('switches to second example when clicked', () => {
|
||||
render(React.createElement(AutomationShowcase));
|
||||
|
||||
const noShowButton = screen.getByText('No-Show Prevention').closest('button');
|
||||
if (noShowButton) {
|
||||
fireEvent.click(noShowButton);
|
||||
}
|
||||
|
||||
const workflowVisual = screen.getByTestId('workflow-visual');
|
||||
expect(workflowVisual).toHaveAttribute('data-variant', 'noshow');
|
||||
});
|
||||
|
||||
it('switches to third example when clicked', () => {
|
||||
render(React.createElement(AutomationShowcase));
|
||||
|
||||
const reportButton = screen.getByText('Daily Reports').closest('button');
|
||||
if (reportButton) {
|
||||
fireEvent.click(reportButton);
|
||||
}
|
||||
|
||||
const workflowVisual = screen.getByTestId('workflow-visual');
|
||||
expect(workflowVisual).toHaveAttribute('data-variant', 'report');
|
||||
});
|
||||
|
||||
it('displays stats for active example', () => {
|
||||
render(React.createElement(AutomationShowcase));
|
||||
|
||||
// First example (winback) should show its stats
|
||||
expect(screen.getByText('+25% retention')).toBeInTheDocument();
|
||||
expect(screen.getByText('$10k+ revenue')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates stats when switching examples', () => {
|
||||
render(React.createElement(AutomationShowcase));
|
||||
|
||||
// Switch to no-show example
|
||||
const noShowButton = screen.getByText('No-Show Prevention').closest('button');
|
||||
if (noShowButton) {
|
||||
fireEvent.click(noShowButton);
|
||||
}
|
||||
|
||||
// Should show no-show stats
|
||||
expect(screen.getByText('40% reduction')).toBeInTheDocument();
|
||||
expect(screen.getByText('95% utilization')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('icons', () => {
|
||||
it('renders badge icon', () => {
|
||||
render(React.createElement(AutomationShowcase));
|
||||
expect(screen.getAllByTestId('zap-icon')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('renders mail icon for winback example', () => {
|
||||
render(React.createElement(AutomationShowcase));
|
||||
expect(screen.getByTestId('mail-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders bell icon for noshow example', () => {
|
||||
render(React.createElement(AutomationShowcase));
|
||||
expect(screen.getByTestId('bell-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders calendar icon for report example', () => {
|
||||
render(React.createElement(AutomationShowcase));
|
||||
expect(screen.getByTestId('calendar-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders check circle icons for stats', () => {
|
||||
render(React.createElement(AutomationShowcase));
|
||||
expect(screen.getAllByTestId('check-circle-icon').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders arrow icon in CTA', () => {
|
||||
render(React.createElement(AutomationShowcase));
|
||||
expect(screen.getByTestId('arrow-right-icon')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('interactive behavior', () => {
|
||||
it('applies active styles to selected example button', () => {
|
||||
const { container } = render(React.createElement(AutomationShowcase));
|
||||
|
||||
const buttons = container.querySelectorAll('button');
|
||||
const activeButton = Array.from(buttons).find(btn =>
|
||||
btn.className.includes('bg-white') && btn.className.includes('border-brand-500')
|
||||
);
|
||||
expect(activeButton).toBeDefined();
|
||||
});
|
||||
|
||||
it('changes active button when different example is clicked', () => {
|
||||
render(React.createElement(AutomationShowcase));
|
||||
|
||||
const noShowButton = screen.getByText('No-Show Prevention').closest('button');
|
||||
if (noShowButton) {
|
||||
fireEvent.click(noShowButton);
|
||||
expect(noShowButton).toHaveClass('bg-white');
|
||||
}
|
||||
});
|
||||
|
||||
it('renders all example buttons as clickable', () => {
|
||||
render(React.createElement(AutomationShowcase));
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
// Should have 3 example buttons
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('layout', () => {
|
||||
it('renders in two-column grid layout', () => {
|
||||
const { container } = render(React.createElement(AutomationShowcase));
|
||||
const gridElement = container.querySelector('.grid.lg\\:grid-cols-2');
|
||||
expect(gridElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders stats in flex layout', () => {
|
||||
const { container } = render(React.createElement(AutomationShowcase));
|
||||
const statsContainer = container.querySelector('.flex.gap-4');
|
||||
expect(statsContainer).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import BenefitsSection from '../BenefitsSection';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'marketing.benefits.rapidDeployment.title': 'Rapid Deployment',
|
||||
'marketing.benefits.rapidDeployment.description': 'Get started in minutes with our quick setup',
|
||||
'marketing.benefits.enterpriseSecurity.title': 'Enterprise Security',
|
||||
'marketing.benefits.enterpriseSecurity.description': 'Bank-level encryption and compliance',
|
||||
'marketing.benefits.highPerformance.title': 'High Performance',
|
||||
'marketing.benefits.highPerformance.description': 'Fast and reliable service at scale',
|
||||
'marketing.benefits.expertSupport.title': 'Expert Support',
|
||||
'marketing.benefits.expertSupport.description': '24/7 customer support from our team',
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock('lucide-react', () => ({
|
||||
Rocket: () => React.createElement('div', { 'data-testid': 'rocket-icon' }),
|
||||
Shield: () => React.createElement('div', { 'data-testid': 'shield-icon' }),
|
||||
Zap: () => React.createElement('div', { 'data-testid': 'zap-icon' }),
|
||||
Headphones: () => React.createElement('div', { 'data-testid': 'headphones-icon' }),
|
||||
}));
|
||||
|
||||
describe('BenefitsSection', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders component without errors', () => {
|
||||
render(React.createElement(BenefitsSection));
|
||||
expect(screen.getByText('Rapid Deployment')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all four benefit cards', () => {
|
||||
render(React.createElement(BenefitsSection));
|
||||
expect(screen.getByText('Rapid Deployment')).toBeInTheDocument();
|
||||
expect(screen.getByText('Enterprise Security')).toBeInTheDocument();
|
||||
expect(screen.getByText('High Performance')).toBeInTheDocument();
|
||||
expect(screen.getByText('Expert Support')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all benefit descriptions', () => {
|
||||
render(React.createElement(BenefitsSection));
|
||||
expect(screen.getByText('Get started in minutes with our quick setup')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bank-level encryption and compliance')).toBeInTheDocument();
|
||||
expect(screen.getByText('Fast and reliable service at scale')).toBeInTheDocument();
|
||||
expect(screen.getByText('24/7 customer support from our team')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('icons', () => {
|
||||
it('renders rocket icon for rapid deployment', () => {
|
||||
render(React.createElement(BenefitsSection));
|
||||
expect(screen.getByTestId('rocket-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders shield icon for enterprise security', () => {
|
||||
render(React.createElement(BenefitsSection));
|
||||
expect(screen.getByTestId('shield-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders zap icon for high performance', () => {
|
||||
render(React.createElement(BenefitsSection));
|
||||
expect(screen.getByTestId('zap-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders headphones icon for expert support', () => {
|
||||
render(React.createElement(BenefitsSection));
|
||||
expect(screen.getByTestId('headphones-icon')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('layout', () => {
|
||||
it('renders benefits in grid layout', () => {
|
||||
const { container } = render(React.createElement(BenefitsSection));
|
||||
const gridElement = container.querySelector('.grid.md\\:grid-cols-2.lg\\:grid-cols-4');
|
||||
expect(gridElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders each benefit card with proper structure', () => {
|
||||
const { container } = render(React.createElement(BenefitsSection));
|
||||
const cards = container.querySelectorAll('.text-center');
|
||||
expect(cards.length).toBe(4);
|
||||
});
|
||||
|
||||
it('renders in a section element', () => {
|
||||
const { container } = render(React.createElement(BenefitsSection));
|
||||
const section = container.querySelector('section');
|
||||
expect(section).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('styling', () => {
|
||||
it('applies correct background colors to section', () => {
|
||||
const { container } = render(React.createElement(BenefitsSection));
|
||||
const section = container.querySelector('.bg-white.dark\\:bg-gray-900');
|
||||
expect(section).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies proper spacing', () => {
|
||||
const { container } = render(React.createElement(BenefitsSection));
|
||||
const section = container.querySelector('.py-20');
|
||||
expect(section).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders titles with proper typography', () => {
|
||||
render(React.createElement(BenefitsSection));
|
||||
const titles = screen.getAllByRole('heading', { level: 3 });
|
||||
expect(titles.length).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('content', () => {
|
||||
it('displays all benefit titles as headings', () => {
|
||||
render(React.createElement(BenefitsSection));
|
||||
expect(screen.getByRole('heading', { name: 'Rapid Deployment' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Enterprise Security' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'High Performance' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Expert Support' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('maintains correct order of benefits', () => {
|
||||
const { container } = render(React.createElement(BenefitsSection));
|
||||
const headings = container.querySelectorAll('h3');
|
||||
expect(headings[0]).toHaveTextContent('Rapid Deployment');
|
||||
expect(headings[1]).toHaveTextContent('Enterprise Security');
|
||||
expect(headings[2]).toHaveTextContent('High Performance');
|
||||
expect(headings[3]).toHaveTextContent('Expert Support');
|
||||
});
|
||||
});
|
||||
|
||||
describe('responsive design', () => {
|
||||
it('applies responsive grid classes', () => {
|
||||
const { container } = render(React.createElement(BenefitsSection));
|
||||
const grid = container.querySelector('.md\\:grid-cols-2');
|
||||
expect(grid).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies responsive padding', () => {
|
||||
const { container } = render(React.createElement(BenefitsSection));
|
||||
const paddedElement = container.querySelector('.sm\\:px-6');
|
||||
expect(paddedElement).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hover effects', () => {
|
||||
it('applies hover transform classes to cards', () => {
|
||||
const { container } = render(React.createElement(BenefitsSection));
|
||||
const hoverElements = container.querySelectorAll('.hover\\:-translate-y-1');
|
||||
expect(hoverElements.length).toBe(4);
|
||||
});
|
||||
|
||||
it('applies transition classes', () => {
|
||||
const { container } = render(React.createElement(BenefitsSection));
|
||||
const transitionElements = container.querySelectorAll('.transition-transform');
|
||||
expect(transitionElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,337 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import DynamicPricingCards from '../DynamicPricingCards';
|
||||
|
||||
// Mock react-router-dom
|
||||
vi.mock('react-router-dom', () => ({
|
||||
Link: ({ to, children, ...props }: any) =>
|
||||
React.createElement('a', { href: to, ...props }, children),
|
||||
}));
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string | { days?: number }) => {
|
||||
const translations: Record<string, string> = {
|
||||
'marketing.pricing.monthly': 'Monthly',
|
||||
'marketing.pricing.annual': 'Annual',
|
||||
'marketing.pricing.savePercent': 'Save ~17%',
|
||||
'marketing.pricing.mostPopular': 'Most Popular',
|
||||
'marketing.pricing.custom': 'Custom',
|
||||
'marketing.pricing.perYear': '/year',
|
||||
'marketing.pricing.perMonth': '/month',
|
||||
'marketing.pricing.contactSales': 'Contact Sales',
|
||||
'marketing.pricing.getStartedFree': 'Get Started Free',
|
||||
'marketing.pricing.startTrial': 'Start Free Trial',
|
||||
'marketing.pricing.freeForever': 'Free forever',
|
||||
'marketing.pricing.loadError': 'Unable to load pricing. Please try again later.',
|
||||
};
|
||||
|
||||
// Handle dynamic trial days translation
|
||||
if (key === 'marketing.pricing.trialDays' && typeof fallback === 'object' && fallback.days) {
|
||||
return `${fallback.days}-day free trial`;
|
||||
}
|
||||
|
||||
return translations[key] || (typeof fallback === 'string' ? fallback : key);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock('lucide-react', () => ({
|
||||
Check: () => React.createElement('div', { 'data-testid': 'check-icon' }),
|
||||
Loader2: () => React.createElement('div', { 'data-testid': 'loader-icon' }),
|
||||
}));
|
||||
|
||||
// Mock usePublicPlans hook
|
||||
const mockPlans = [
|
||||
{
|
||||
id: '1',
|
||||
plan: {
|
||||
id: '1',
|
||||
name: 'Free',
|
||||
code: 'free',
|
||||
description: 'Free plan for getting started',
|
||||
display_order: 0,
|
||||
},
|
||||
price_monthly_cents: 0,
|
||||
price_yearly_cents: 0,
|
||||
is_most_popular: false,
|
||||
show_price: true,
|
||||
marketing_features: ['Basic scheduling', 'Up to 10 appointments'],
|
||||
trial_days: 0,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
plan: {
|
||||
id: '2',
|
||||
name: 'Professional',
|
||||
code: 'professional',
|
||||
description: 'For growing businesses',
|
||||
display_order: 1,
|
||||
},
|
||||
price_monthly_cents: 2900,
|
||||
price_yearly_cents: 29000,
|
||||
is_most_popular: true,
|
||||
show_price: true,
|
||||
marketing_features: ['Unlimited appointments', 'SMS reminders', 'Priority support'],
|
||||
trial_days: 14,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
plan: {
|
||||
id: '3',
|
||||
name: 'Enterprise',
|
||||
code: 'enterprise',
|
||||
description: 'For large organizations',
|
||||
display_order: 2,
|
||||
},
|
||||
price_monthly_cents: 0,
|
||||
price_yearly_cents: 0,
|
||||
is_most_popular: false,
|
||||
show_price: false,
|
||||
marketing_features: ['Custom features', 'Dedicated support', 'SLA guarantee'],
|
||||
trial_days: 0,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock('../../hooks/usePublicPlans', () => ({
|
||||
usePublicPlans: vi.fn(),
|
||||
formatPrice: (cents: number) => {
|
||||
if (cents === 0) return '$0';
|
||||
return `$${(cents / 100).toFixed(0)}`;
|
||||
},
|
||||
}));
|
||||
|
||||
import { usePublicPlans } from '../../hooks/usePublicPlans';
|
||||
|
||||
describe('DynamicPricingCards', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(usePublicPlans as any).mockReturnValue({
|
||||
data: mockPlans,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading state', () => {
|
||||
it('renders loading spinner when loading', () => {
|
||||
(usePublicPlans as any).mockReturnValue({
|
||||
data: null,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(React.createElement(DynamicPricingCards));
|
||||
expect(screen.getByTestId('loader-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render plans while loading', () => {
|
||||
(usePublicPlans as any).mockReturnValue({
|
||||
data: null,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(React.createElement(DynamicPricingCards));
|
||||
expect(screen.queryByText('Free')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error state', () => {
|
||||
it('renders error message when there is an error', () => {
|
||||
(usePublicPlans as any).mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: new Error('Failed to load'),
|
||||
});
|
||||
|
||||
render(React.createElement(DynamicPricingCards));
|
||||
expect(screen.getByText('Unable to load pricing. Please try again later.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders error message when plans data is null', () => {
|
||||
(usePublicPlans as any).mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(React.createElement(DynamicPricingCards));
|
||||
expect(screen.getByText('Unable to load pricing. Please try again later.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('billing toggle', () => {
|
||||
it('renders billing period toggle buttons', () => {
|
||||
render(React.createElement(DynamicPricingCards));
|
||||
expect(screen.getByText('Monthly')).toBeInTheDocument();
|
||||
expect(screen.getByText('Annual')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('defaults to monthly billing', () => {
|
||||
render(React.createElement(DynamicPricingCards));
|
||||
const monthlyButton = screen.getByText('Monthly').closest('button');
|
||||
expect(monthlyButton).toHaveClass('bg-white');
|
||||
});
|
||||
|
||||
it('switches to annual billing when clicked', () => {
|
||||
render(React.createElement(DynamicPricingCards));
|
||||
const annualButton = screen.getByText('Annual').closest('button');
|
||||
if (annualButton) {
|
||||
fireEvent.click(annualButton);
|
||||
expect(annualButton).toHaveClass('bg-white');
|
||||
}
|
||||
});
|
||||
|
||||
it('displays save percentage text for annual billing', () => {
|
||||
render(React.createElement(DynamicPricingCards));
|
||||
expect(screen.getByText('Save ~17%')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('plan cards rendering', () => {
|
||||
it('renders all plan cards', () => {
|
||||
render(React.createElement(DynamicPricingCards));
|
||||
expect(screen.getByText('Free')).toBeInTheDocument();
|
||||
expect(screen.getByText('Professional')).toBeInTheDocument();
|
||||
expect(screen.getByText('Enterprise')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders plan descriptions', () => {
|
||||
render(React.createElement(DynamicPricingCards));
|
||||
expect(screen.getByText('Free plan for getting started')).toBeInTheDocument();
|
||||
expect(screen.getByText('For growing businesses')).toBeInTheDocument();
|
||||
expect(screen.getByText('For large organizations')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays plans in correct order', () => {
|
||||
const { container } = render(React.createElement(DynamicPricingCards));
|
||||
const planNames = Array.from(container.querySelectorAll('h3')).map((h3) => h3.textContent);
|
||||
expect(planNames).toEqual(['Free', 'Professional', 'Enterprise']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('most popular badge', () => {
|
||||
it('renders most popular badge for the professional plan', () => {
|
||||
render(React.createElement(DynamicPricingCards));
|
||||
expect(screen.getByText('Most Popular')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies special styling to most popular card', () => {
|
||||
const { container } = render(React.createElement(DynamicPricingCards));
|
||||
const professionalCard = screen.getByText('Professional').closest('div');
|
||||
expect(professionalCard).toHaveClass('bg-brand-600');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pricing display', () => {
|
||||
it('displays $0 for free plan', () => {
|
||||
render(React.createElement(DynamicPricingCards));
|
||||
expect(screen.getByText('$0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays monthly price for professional plan', () => {
|
||||
render(React.createElement(DynamicPricingCards));
|
||||
expect(screen.getByText('$29')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays "Custom" for enterprise plan', () => {
|
||||
render(React.createElement(DynamicPricingCards));
|
||||
expect(screen.getByText('Custom')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays "Free forever" text for free plan', () => {
|
||||
render(React.createElement(DynamicPricingCards));
|
||||
expect(screen.getByText('Free forever')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays trial days for professional plan', () => {
|
||||
render(React.createElement(DynamicPricingCards));
|
||||
expect(screen.getByText('14-day free trial')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('features display', () => {
|
||||
it('renders features for free plan', () => {
|
||||
render(React.createElement(DynamicPricingCards));
|
||||
expect(screen.getByText('Basic scheduling')).toBeInTheDocument();
|
||||
expect(screen.getByText('Up to 10 appointments')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders features for professional plan', () => {
|
||||
render(React.createElement(DynamicPricingCards));
|
||||
expect(screen.getByText('Unlimited appointments')).toBeInTheDocument();
|
||||
expect(screen.getByText('SMS reminders')).toBeInTheDocument();
|
||||
expect(screen.getByText('Priority support')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders check icons for features', () => {
|
||||
render(React.createElement(DynamicPricingCards));
|
||||
const checkIcons = screen.getAllByTestId('check-icon');
|
||||
// Should have check icons for all features across all plans
|
||||
expect(checkIcons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CTA buttons', () => {
|
||||
it('renders "Get Started Free" for free plan', () => {
|
||||
render(React.createElement(DynamicPricingCards));
|
||||
expect(screen.getByText('Get Started Free')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Start Free Trial" for professional plan', () => {
|
||||
render(React.createElement(DynamicPricingCards));
|
||||
expect(screen.getByText('Start Free Trial')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Contact Sales" for enterprise plan', () => {
|
||||
render(React.createElement(DynamicPricingCards));
|
||||
expect(screen.getByText('Contact Sales')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('links to signup page with plan code for non-enterprise plans', () => {
|
||||
render(React.createElement(DynamicPricingCards));
|
||||
const freeLink = screen.getByText('Get Started Free').closest('a');
|
||||
expect(freeLink).toHaveAttribute('href', '/signup?plan=free');
|
||||
});
|
||||
|
||||
it('links to contact page for enterprise plan', () => {
|
||||
render(React.createElement(DynamicPricingCards));
|
||||
const enterpriseLink = screen.getByText('Contact Sales').closest('a');
|
||||
expect(enterpriseLink).toHaveAttribute('href', '/contact');
|
||||
});
|
||||
});
|
||||
|
||||
describe('className prop', () => {
|
||||
it('applies custom className to wrapper', () => {
|
||||
const { container } = render(
|
||||
React.createElement(DynamicPricingCards, { className: 'custom-class' })
|
||||
);
|
||||
const wrapper = container.querySelector('.custom-class');
|
||||
expect(wrapper).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies empty string as default className', () => {
|
||||
render(React.createElement(DynamicPricingCards));
|
||||
// Should render without errors
|
||||
expect(screen.getByText('Free')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('layout', () => {
|
||||
it('renders plans in grid layout', () => {
|
||||
const { container } = render(React.createElement(DynamicPricingCards));
|
||||
const grid = container.querySelector('.grid');
|
||||
expect(grid).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies responsive grid classes', () => {
|
||||
const { container } = render(React.createElement(DynamicPricingCards));
|
||||
const grid = container.querySelector('.md\\:grid-cols-2.lg\\:grid-cols-3');
|
||||
expect(grid).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,369 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import FeatureComparisonTable from '../FeatureComparisonTable';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'marketing.pricing.featureComparison.features': 'Features',
|
||||
'marketing.pricing.featureComparison.categories.limits': 'Limits',
|
||||
'marketing.pricing.featureComparison.categories.communication': 'Communication',
|
||||
'marketing.pricing.featureComparison.categories.booking': 'Booking',
|
||||
'marketing.pricing.featureComparison.categories.integrations': 'Integrations',
|
||||
'marketing.pricing.featureComparison.categories.branding': 'Branding',
|
||||
'marketing.pricing.featureComparison.categories.enterprise': 'Enterprise',
|
||||
'marketing.pricing.featureComparison.categories.support': 'Support',
|
||||
'marketing.pricing.featureComparison.categories.storage': 'Storage',
|
||||
'marketing.pricing.featureComparison.features.max_users': 'Team members',
|
||||
'marketing.pricing.featureComparison.features.max_resources': 'Resources',
|
||||
'marketing.pricing.featureComparison.features.email_enabled': 'Email notifications',
|
||||
'marketing.pricing.featureComparison.features.sms_enabled': 'SMS reminders',
|
||||
'marketing.pricing.featureComparison.features.online_booking': 'Online booking',
|
||||
'marketing.pricing.featureComparison.features.api_access': 'API access',
|
||||
'marketing.pricing.featureComparison.features.custom_branding': 'Custom branding',
|
||||
};
|
||||
return translations[key] || (fallback || key);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock('lucide-react', () => ({
|
||||
Check: () => React.createElement('div', { 'data-testid': 'check-icon' }),
|
||||
X: () => React.createElement('div', { 'data-testid': 'x-icon' }),
|
||||
Minus: () => React.createElement('div', { 'data-testid': 'minus-icon' }),
|
||||
Loader2: () => React.createElement('div', { 'data-testid': 'loader-icon' }),
|
||||
}));
|
||||
|
||||
// Mock usePublicPlans hook
|
||||
const mockPlans = [
|
||||
{
|
||||
id: '1',
|
||||
plan: {
|
||||
id: '1',
|
||||
name: 'Free',
|
||||
code: 'free',
|
||||
description: 'Free plan',
|
||||
display_order: 0,
|
||||
},
|
||||
price_monthly_cents: 0,
|
||||
price_yearly_cents: 0,
|
||||
is_most_popular: false,
|
||||
show_price: true,
|
||||
marketing_features: [],
|
||||
trial_days: 0,
|
||||
features: {
|
||||
max_users: 1,
|
||||
max_resources: 5,
|
||||
email_enabled: true,
|
||||
sms_enabled: false,
|
||||
online_booking: true,
|
||||
api_access: false,
|
||||
custom_branding: false,
|
||||
max_storage_mb: 500,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
plan: {
|
||||
id: '2',
|
||||
name: 'Professional',
|
||||
code: 'professional',
|
||||
description: 'Professional plan',
|
||||
display_order: 1,
|
||||
},
|
||||
price_monthly_cents: 2900,
|
||||
price_yearly_cents: 29000,
|
||||
is_most_popular: true,
|
||||
show_price: true,
|
||||
marketing_features: [],
|
||||
trial_days: 14,
|
||||
features: {
|
||||
max_users: 0, // Unlimited
|
||||
max_resources: 0, // Unlimited
|
||||
email_enabled: true,
|
||||
sms_enabled: true,
|
||||
online_booking: true,
|
||||
api_access: true,
|
||||
custom_branding: true,
|
||||
max_storage_mb: 5000,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock('../../hooks/usePublicPlans', () => ({
|
||||
usePublicPlans: vi.fn(),
|
||||
getPlanFeatureValue: (plan: any, featureCode: string) => {
|
||||
return plan.features?.[featureCode];
|
||||
},
|
||||
formatLimit: (value: number) => {
|
||||
if (value === 0) return 'Unlimited';
|
||||
if (value >= 1000) return `${(value / 1000).toFixed(0)}k`;
|
||||
return value.toString();
|
||||
},
|
||||
}));
|
||||
|
||||
import { usePublicPlans } from '../../hooks/usePublicPlans';
|
||||
|
||||
describe('FeatureComparisonTable', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(usePublicPlans as any).mockReturnValue({
|
||||
data: mockPlans,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading state', () => {
|
||||
it('renders loading spinner when loading', () => {
|
||||
(usePublicPlans as any).mockReturnValue({
|
||||
data: null,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(React.createElement(FeatureComparisonTable));
|
||||
expect(screen.getByTestId('loader-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render table while loading', () => {
|
||||
(usePublicPlans as any).mockReturnValue({
|
||||
data: null,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(React.createElement(FeatureComparisonTable));
|
||||
expect(screen.queryByText('Features')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error state', () => {
|
||||
it('renders nothing when there is an error', () => {
|
||||
(usePublicPlans as any).mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: new Error('Failed to load'),
|
||||
});
|
||||
|
||||
const { container } = render(React.createElement(FeatureComparisonTable));
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders nothing when plans array is empty', () => {
|
||||
(usePublicPlans as any).mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container } = render(React.createElement(FeatureComparisonTable));
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('table structure', () => {
|
||||
it('renders table element', () => {
|
||||
const { container } = render(React.createElement(FeatureComparisonTable));
|
||||
const table = container.querySelector('table');
|
||||
expect(table).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders table header with Features column', () => {
|
||||
render(React.createElement(FeatureComparisonTable));
|
||||
expect(screen.getByText('Features')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders plan names in header', () => {
|
||||
render(React.createElement(FeatureComparisonTable));
|
||||
expect(screen.getByText('Free')).toBeInTheDocument();
|
||||
expect(screen.getByText('Professional')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('highlights most popular plan in header', () => {
|
||||
const { container } = render(React.createElement(FeatureComparisonTable));
|
||||
const professionalHeader = screen.getByText('Professional').closest('th');
|
||||
expect(professionalHeader).toHaveClass('text-brand-600');
|
||||
});
|
||||
});
|
||||
|
||||
describe('category sections', () => {
|
||||
it('renders all category headers', () => {
|
||||
render(React.createElement(FeatureComparisonTable));
|
||||
expect(screen.getByText('Limits')).toBeInTheDocument();
|
||||
expect(screen.getByText('Communication')).toBeInTheDocument();
|
||||
expect(screen.getByText('Booking')).toBeInTheDocument();
|
||||
expect(screen.getByText('Integrations')).toBeInTheDocument();
|
||||
expect(screen.getByText('Branding')).toBeInTheDocument();
|
||||
expect(screen.getByText('Enterprise')).toBeInTheDocument();
|
||||
expect(screen.getByText('Support')).toBeInTheDocument();
|
||||
expect(screen.getByText('Storage')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders category headers with proper styling', () => {
|
||||
const { container } = render(React.createElement(FeatureComparisonTable));
|
||||
const categoryHeaders = container.querySelectorAll('.uppercase.tracking-wider');
|
||||
expect(categoryHeaders.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('feature rows', () => {
|
||||
it('renders feature labels', () => {
|
||||
render(React.createElement(FeatureComparisonTable));
|
||||
expect(screen.getByText('Team members')).toBeInTheDocument();
|
||||
expect(screen.getByText('Resources')).toBeInTheDocument();
|
||||
expect(screen.getByText('Email notifications')).toBeInTheDocument();
|
||||
expect(screen.getByText('SMS reminders')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays boolean features with check/x icons', () => {
|
||||
render(React.createElement(FeatureComparisonTable));
|
||||
const checkIcons = screen.getAllByTestId('check-icon');
|
||||
const xIcons = screen.getAllByTestId('x-icon');
|
||||
expect(checkIcons.length).toBeGreaterThan(0);
|
||||
expect(xIcons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('displays numeric limits correctly', () => {
|
||||
render(React.createElement(FeatureComparisonTable));
|
||||
// Free plan has limit of 1 user
|
||||
expect(screen.getByText('1')).toBeInTheDocument();
|
||||
// Professional plan has unlimited
|
||||
expect(screen.getAllByText('Unlimited').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('storage display', () => {
|
||||
it('displays storage in MB for values under 1000', () => {
|
||||
render(React.createElement(FeatureComparisonTable));
|
||||
expect(screen.getByText('500 MB')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays storage in GB for values over 1000', () => {
|
||||
render(React.createElement(FeatureComparisonTable));
|
||||
expect(screen.getByText('5 GB')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays unlimited for 0 storage value', () => {
|
||||
const plansWithUnlimitedStorage = [
|
||||
{
|
||||
...mockPlans[0],
|
||||
features: { ...mockPlans[0].features, max_storage_mb: 0 },
|
||||
},
|
||||
];
|
||||
|
||||
(usePublicPlans as any).mockReturnValue({
|
||||
data: plansWithUnlimitedStorage,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(React.createElement(FeatureComparisonTable));
|
||||
expect(screen.getByText('Unlimited')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('most popular highlighting', () => {
|
||||
it('applies background color to most popular plan columns', () => {
|
||||
const { container } = render(React.createElement(FeatureComparisonTable));
|
||||
const professionalCells = container.querySelectorAll('.bg-brand-50\\/50');
|
||||
expect(professionalCells.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('highlights most popular plan header', () => {
|
||||
const { container } = render(React.createElement(FeatureComparisonTable));
|
||||
const professionalHeader = screen.getByText('Professional').closest('th');
|
||||
expect(professionalHeader).toHaveClass('bg-brand-50');
|
||||
});
|
||||
});
|
||||
|
||||
describe('className prop', () => {
|
||||
it('applies custom className to wrapper', () => {
|
||||
const { container } = render(
|
||||
React.createElement(FeatureComparisonTable, { className: 'custom-class' })
|
||||
);
|
||||
const wrapper = container.querySelector('.custom-class');
|
||||
expect(wrapper).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies empty string as default className', () => {
|
||||
render(React.createElement(FeatureComparisonTable));
|
||||
expect(screen.getByText('Features')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('responsive design', () => {
|
||||
it('applies overflow-x-auto for horizontal scrolling', () => {
|
||||
const { container } = render(React.createElement(FeatureComparisonTable));
|
||||
const wrapper = container.querySelector('.overflow-x-auto');
|
||||
expect(wrapper).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sets minimum width on table', () => {
|
||||
const { container } = render(React.createElement(FeatureComparisonTable));
|
||||
const table = container.querySelector('table.min-w-\\[800px\\]');
|
||||
expect(table).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('plan sorting', () => {
|
||||
it('displays plans in order by display_order', () => {
|
||||
const { container } = render(React.createElement(FeatureComparisonTable));
|
||||
const headers = container.querySelectorAll('th');
|
||||
// First th is "Features", then plan names in order
|
||||
expect(headers[1]).toHaveTextContent('Free');
|
||||
expect(headers[2]).toHaveTextContent('Professional');
|
||||
});
|
||||
});
|
||||
|
||||
describe('feature value rendering', () => {
|
||||
it('renders check icon for true boolean values', () => {
|
||||
render(React.createElement(FeatureComparisonTable));
|
||||
const checkIcons = screen.getAllByTestId('check-icon');
|
||||
expect(checkIcons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders x icon for false boolean values', () => {
|
||||
render(React.createElement(FeatureComparisonTable));
|
||||
const xIcons = screen.getAllByTestId('x-icon');
|
||||
expect(xIcons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('handles undefined feature values gracefully', () => {
|
||||
const plansWithMissingFeatures = [
|
||||
{
|
||||
...mockPlans[0],
|
||||
features: {},
|
||||
},
|
||||
];
|
||||
|
||||
(usePublicPlans as any).mockReturnValue({
|
||||
data: plansWithMissingFeatures,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(React.createElement(FeatureComparisonTable));
|
||||
// Should still render without errors
|
||||
expect(screen.getByText('Features')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('uses semantic table structure', () => {
|
||||
const { container } = render(React.createElement(FeatureComparisonTable));
|
||||
expect(container.querySelector('thead')).toBeInTheDocument();
|
||||
expect(container.querySelector('tbody')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses proper table headers', () => {
|
||||
const { container } = render(React.createElement(FeatureComparisonTable));
|
||||
const headers = container.querySelectorAll('th');
|
||||
expect(headers.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import TestimonialCard from '../TestimonialCard';
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock('lucide-react', () => ({
|
||||
Star: () => React.createElement('div', { 'data-testid': 'star-icon' }),
|
||||
}));
|
||||
|
||||
describe('TestimonialCard', () => {
|
||||
const defaultProps = {
|
||||
quote: 'This is an amazing product!',
|
||||
author: 'John Doe',
|
||||
role: 'CEO',
|
||||
company: 'Acme Corp',
|
||||
};
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders component without errors', () => {
|
||||
render(React.createElement(TestimonialCard, defaultProps));
|
||||
expect(screen.getByText('This is an amazing product!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders quote text with quotation marks', () => {
|
||||
render(React.createElement(TestimonialCard, defaultProps));
|
||||
const quote = screen.getByText(/"This is an amazing product!"/);
|
||||
expect(quote).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders author name', () => {
|
||||
render(React.createElement(TestimonialCard, defaultProps));
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders role and company', () => {
|
||||
render(React.createElement(TestimonialCard, defaultProps));
|
||||
expect(screen.getByText('CEO at Acme Corp')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rating stars', () => {
|
||||
it('renders 5 stars by default', () => {
|
||||
render(React.createElement(TestimonialCard, defaultProps));
|
||||
const stars = screen.getAllByTestId('star-icon');
|
||||
expect(stars).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('renders custom rating when provided', () => {
|
||||
render(React.createElement(TestimonialCard, { ...defaultProps, rating: 4 }));
|
||||
const stars = screen.getAllByTestId('star-icon');
|
||||
expect(stars).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('renders 5 stars for rating of 5', () => {
|
||||
render(React.createElement(TestimonialCard, { ...defaultProps, rating: 5 }));
|
||||
const stars = screen.getAllByTestId('star-icon');
|
||||
expect(stars).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('renders 5 stars for rating of 3', () => {
|
||||
render(React.createElement(TestimonialCard, { ...defaultProps, rating: 3 }));
|
||||
const stars = screen.getAllByTestId('star-icon');
|
||||
expect(stars).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('renders 5 stars for rating of 1', () => {
|
||||
render(React.createElement(TestimonialCard, { ...defaultProps, rating: 1 }));
|
||||
const stars = screen.getAllByTestId('star-icon');
|
||||
expect(stars).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('applies filled class to stars based on rating', () => {
|
||||
const { container } = render(
|
||||
React.createElement(TestimonialCard, { ...defaultProps, rating: 3 })
|
||||
);
|
||||
const filledStars = container.querySelectorAll('.fill-yellow-400');
|
||||
// 3 filled stars (based on rating of 3)
|
||||
expect(filledStars.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('avatar', () => {
|
||||
it('renders avatar image when avatarUrl is provided', () => {
|
||||
const propsWithAvatar = {
|
||||
...defaultProps,
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
};
|
||||
render(React.createElement(TestimonialCard, propsWithAvatar));
|
||||
|
||||
const avatar = screen.getByRole('img');
|
||||
expect(avatar).toBeInTheDocument();
|
||||
expect(avatar).toHaveAttribute('src', 'https://example.com/avatar.jpg');
|
||||
expect(avatar).toHaveAttribute('alt', 'John Doe');
|
||||
});
|
||||
|
||||
it('renders initials when avatarUrl is not provided', () => {
|
||||
render(React.createElement(TestimonialCard, defaultProps));
|
||||
|
||||
// Should not have an img element
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument();
|
||||
|
||||
// Should have initials displayed
|
||||
expect(screen.getByText('J')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays first letter of author name as initial', () => {
|
||||
const propsWithDifferentName = {
|
||||
...defaultProps,
|
||||
author: 'Sarah Smith',
|
||||
};
|
||||
render(React.createElement(TestimonialCard, propsWithDifferentName));
|
||||
expect(screen.getByText('S')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles empty author name gracefully', () => {
|
||||
const propsWithEmptyName = {
|
||||
...defaultProps,
|
||||
author: '',
|
||||
};
|
||||
const { container } = render(React.createElement(TestimonialCard, propsWithEmptyName));
|
||||
// Should render without crashing
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('content variations', () => {
|
||||
it('renders long quotes correctly', () => {
|
||||
const longQuote =
|
||||
'This is a very long testimonial that contains multiple sentences. It talks about how amazing the product is and how it has helped the business grow. The service is exceptional and the support team is always responsive.';
|
||||
const propsWithLongQuote = {
|
||||
...defaultProps,
|
||||
quote: longQuote,
|
||||
};
|
||||
render(React.createElement(TestimonialCard, propsWithLongQuote));
|
||||
expect(screen.getByText(new RegExp(longQuote))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders short quotes correctly', () => {
|
||||
const shortQuote = 'Great!';
|
||||
const propsWithShortQuote = {
|
||||
...defaultProps,
|
||||
quote: shortQuote,
|
||||
};
|
||||
render(React.createElement(TestimonialCard, propsWithShortQuote));
|
||||
expect(screen.getByText(new RegExp(shortQuote))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders quotes with special characters', () => {
|
||||
const quoteWithSpecialChars = 'Amazing! Love it 100% - best product ever.';
|
||||
const propsWithSpecialChars = {
|
||||
...defaultProps,
|
||||
quote: quoteWithSpecialChars,
|
||||
};
|
||||
render(React.createElement(TestimonialCard, propsWithSpecialChars));
|
||||
expect(screen.getByText(new RegExp('Amazing! Love it 100%'))).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('styling', () => {
|
||||
it('applies card styling classes', () => {
|
||||
const { container } = render(React.createElement(TestimonialCard, defaultProps));
|
||||
const card = container.querySelector('.bg-white.dark\\:bg-gray-800');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies border and shadow', () => {
|
||||
const { container } = render(React.createElement(TestimonialCard, defaultProps));
|
||||
const card = container.querySelector('.border.border-gray-200');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies rounded corners', () => {
|
||||
const { container } = render(React.createElement(TestimonialCard, defaultProps));
|
||||
const card = container.querySelector('.rounded-2xl');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies hover shadow effect', () => {
|
||||
const { container } = render(React.createElement(TestimonialCard, defaultProps));
|
||||
const card = container.querySelector('.hover\\:shadow-md');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('layout', () => {
|
||||
it('uses flex column layout', () => {
|
||||
const { container } = render(React.createElement(TestimonialCard, defaultProps));
|
||||
const card = container.querySelector('.flex.flex-col');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies proper padding', () => {
|
||||
const { container } = render(React.createElement(TestimonialCard, defaultProps));
|
||||
const card = container.querySelector('.p-6');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('semantic HTML', () => {
|
||||
it('uses blockquote element for quote', () => {
|
||||
const { container } = render(React.createElement(TestimonialCard, defaultProps));
|
||||
const blockquote = container.querySelector('blockquote');
|
||||
expect(blockquote).toBeInTheDocument();
|
||||
expect(blockquote).toHaveTextContent('This is an amazing product!');
|
||||
});
|
||||
|
||||
it('renders avatar image with proper alt text', () => {
|
||||
const propsWithAvatar = {
|
||||
...defaultProps,
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
};
|
||||
render(React.createElement(TestimonialCard, propsWithAvatar));
|
||||
|
||||
const avatar = screen.getByRole('img');
|
||||
expect(avatar).toHaveAttribute('alt', defaultProps.author);
|
||||
});
|
||||
});
|
||||
|
||||
describe('different authors and companies', () => {
|
||||
it('renders different author names correctly', () => {
|
||||
const customProps = {
|
||||
...defaultProps,
|
||||
author: 'Jane Smith',
|
||||
role: 'CTO',
|
||||
company: 'Tech Inc',
|
||||
};
|
||||
render(React.createElement(TestimonialCard, customProps));
|
||||
|
||||
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
|
||||
expect(screen.getByText('CTO at Tech Inc')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles long company names', () => {
|
||||
const longCompanyProps = {
|
||||
...defaultProps,
|
||||
company: 'Very Long Company Name International Corporation Ltd.',
|
||||
};
|
||||
render(React.createElement(TestimonialCard, longCompanyProps));
|
||||
|
||||
expect(
|
||||
screen.getByText('CEO at Very Long Company Name International Corporation Ltd.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles special characters in names', () => {
|
||||
const specialCharProps = {
|
||||
...defaultProps,
|
||||
author: "O'Brien",
|
||||
company: 'Smith & Jones',
|
||||
};
|
||||
render(React.createElement(TestimonialCard, specialCharProps));
|
||||
|
||||
expect(screen.getByText("O'Brien")).toBeInTheDocument();
|
||||
expect(screen.getByText('CEO at Smith & Jones')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles rating of 0', () => {
|
||||
render(React.createElement(TestimonialCard, { ...defaultProps, rating: 0 }));
|
||||
const stars = screen.getAllByTestId('star-icon');
|
||||
expect(stars).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('handles very high rating', () => {
|
||||
render(React.createElement(TestimonialCard, { ...defaultProps, rating: 10 }));
|
||||
const stars = screen.getAllByTestId('star-icon');
|
||||
expect(stars).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('handles empty quote', () => {
|
||||
render(React.createElement(TestimonialCard, { ...defaultProps, quote: '' }));
|
||||
expect(screen.getByRole('blockquote')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles empty role', () => {
|
||||
render(React.createElement(TestimonialCard, { ...defaultProps, role: '' }));
|
||||
expect(screen.getByText(/at Acme Corp/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles empty company', () => {
|
||||
render(React.createElement(TestimonialCard, { ...defaultProps, company: '' }));
|
||||
expect(screen.getByText(/CEO at/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,315 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import WorkflowVisual from '../WorkflowVisual';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'marketing.plugins.aiCopilot.placeholder': 'Describe your automation...',
|
||||
'marketing.plugins.aiCopilot.examples': 'Example: Send SMS reminders 2 hours before appointments',
|
||||
'marketing.plugins.integrations.description': 'Integrate with your favorite apps',
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock framer-motion
|
||||
vi.mock('framer-motion', () => ({
|
||||
motion: {
|
||||
div: ({ children, ...props }: any) => React.createElement('div', props, children),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock('lucide-react', () => ({
|
||||
Calendar: () => React.createElement('div', { 'data-testid': 'calendar-icon' }),
|
||||
Mail: () => React.createElement('div', { 'data-testid': 'mail-icon' }),
|
||||
MessageSquare: () => React.createElement('div', { 'data-testid': 'message-square-icon' }),
|
||||
Clock: () => React.createElement('div', { 'data-testid': 'clock-icon' }),
|
||||
Search: () => React.createElement('div', { 'data-testid': 'search-icon' }),
|
||||
FileText: () => React.createElement('div', { 'data-testid': 'file-text-icon' }),
|
||||
Sparkles: () => React.createElement('div', { 'data-testid': 'sparkles-icon' }),
|
||||
ChevronRight: () => React.createElement('div', { 'data-testid': 'chevron-right-icon' }),
|
||||
}));
|
||||
|
||||
describe('WorkflowVisual', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders component without errors', () => {
|
||||
render(React.createElement(WorkflowVisual, { variant: 'noshow' }));
|
||||
expect(screen.getByText('Event Created')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders AI Copilot input section', () => {
|
||||
render(React.createElement(WorkflowVisual, { variant: 'noshow' }));
|
||||
expect(screen.getByText('Describe your automation...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders AI Copilot examples text', () => {
|
||||
render(React.createElement(WorkflowVisual, { variant: 'noshow' }));
|
||||
expect(screen.getByText('Example: Send SMS reminders 2 hours before appointments')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders integration badges section', () => {
|
||||
render(React.createElement(WorkflowVisual, { variant: 'noshow' }));
|
||||
expect(screen.getByText('Integrate with your favorite apps')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders integration app badges', () => {
|
||||
render(React.createElement(WorkflowVisual, { variant: 'noshow' }));
|
||||
expect(screen.getByText('Gmail')).toBeInTheDocument();
|
||||
expect(screen.getByText('Slack')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sheets')).toBeInTheDocument();
|
||||
expect(screen.getByText('Twilio')).toBeInTheDocument();
|
||||
expect(screen.getByText('+1000 more')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('winback variant', () => {
|
||||
it('renders winback workflow blocks', () => {
|
||||
render(React.createElement(WorkflowVisual, { variant: 'winback' }));
|
||||
expect(screen.getByText('Schedule: Weekly')).toBeInTheDocument();
|
||||
expect(screen.getByText('Find Inactive Customers')).toBeInTheDocument();
|
||||
expect(screen.getByText('Send Email')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders correct icons for winback workflow', () => {
|
||||
render(React.createElement(WorkflowVisual, { variant: 'winback' }));
|
||||
expect(screen.getByTestId('clock-icon')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('search-icon')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('mail-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "When" label for trigger block', () => {
|
||||
render(React.createElement(WorkflowVisual, { variant: 'winback' }));
|
||||
expect(screen.getByText('When')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Then" labels for action blocks', () => {
|
||||
render(React.createElement(WorkflowVisual, { variant: 'winback' }));
|
||||
const thenLabels = screen.getAllByText('Then');
|
||||
expect(thenLabels.length).toBe(2); // Two action blocks
|
||||
});
|
||||
});
|
||||
|
||||
describe('noshow variant', () => {
|
||||
it('renders noshow workflow blocks', () => {
|
||||
render(React.createElement(WorkflowVisual, { variant: 'noshow' }));
|
||||
expect(screen.getByText('Event Created')).toBeInTheDocument();
|
||||
expect(screen.getByText('Wait 2 Hours Before')).toBeInTheDocument();
|
||||
expect(screen.getByText('Send SMS')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders correct icons for noshow workflow', () => {
|
||||
render(React.createElement(WorkflowVisual, { variant: 'noshow' }));
|
||||
expect(screen.getByTestId('calendar-icon')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('clock-icon')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('message-square-icon')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('report variant', () => {
|
||||
it('renders report workflow blocks', () => {
|
||||
render(React.createElement(WorkflowVisual, { variant: 'report' }));
|
||||
expect(screen.getByText('Daily at 6 PM')).toBeInTheDocument();
|
||||
expect(screen.getByText("Get Tomorrow's Schedule")).toBeInTheDocument();
|
||||
expect(screen.getByText('Send Summary')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders correct icons for report workflow', () => {
|
||||
render(React.createElement(WorkflowVisual, { variant: 'report' }));
|
||||
expect(screen.getByTestId('clock-icon')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('file-text-icon')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('mail-icon')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('default variant', () => {
|
||||
it('renders default workflow when no variant specified', () => {
|
||||
render(React.createElement(WorkflowVisual, {}));
|
||||
expect(screen.getByText('Event Created')).toBeInTheDocument();
|
||||
expect(screen.getByText('Wait')).toBeInTheDocument();
|
||||
expect(screen.getByText('Send Notification')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses noshow variant as default', () => {
|
||||
render(React.createElement(WorkflowVisual, {}));
|
||||
// Default should show noshow variant
|
||||
expect(screen.getByText('Event Created')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('workflow structure', () => {
|
||||
it('renders trigger block with special styling', () => {
|
||||
const { container } = render(React.createElement(WorkflowVisual, { variant: 'noshow' }));
|
||||
const triggerBlock = container.querySelector('.from-brand-50.to-purple-50');
|
||||
expect(triggerBlock).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders action blocks with standard styling', () => {
|
||||
const { container } = render(React.createElement(WorkflowVisual, { variant: 'noshow' }));
|
||||
const actionBlocks = container.querySelectorAll('.bg-gray-50');
|
||||
expect(actionBlocks.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders chevron icons for each block', () => {
|
||||
render(React.createElement(WorkflowVisual, { variant: 'noshow' }));
|
||||
const chevronIcons = screen.getAllByTestId('chevron-right-icon');
|
||||
expect(chevronIcons.length).toBe(3); // One per block
|
||||
});
|
||||
});
|
||||
|
||||
describe('icons', () => {
|
||||
it('renders sparkles icon in AI input', () => {
|
||||
render(React.createElement(WorkflowVisual, { variant: 'noshow' }));
|
||||
expect(screen.getByTestId('sparkles-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders appropriate icons for each workflow block type', () => {
|
||||
render(React.createElement(WorkflowVisual, { variant: 'winback' }));
|
||||
expect(screen.getByTestId('clock-icon')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('search-icon')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('mail-icon')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('styling', () => {
|
||||
it('applies card styling to container', () => {
|
||||
const { container } = render(React.createElement(WorkflowVisual, { variant: 'noshow' }));
|
||||
const card = container.querySelector('.bg-white.dark\\:bg-gray-800');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies rounded corners', () => {
|
||||
const { container } = render(React.createElement(WorkflowVisual, { variant: 'noshow' }));
|
||||
const card = container.querySelector('.rounded-xl');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies border and shadow', () => {
|
||||
const { container } = render(React.createElement(WorkflowVisual, { variant: 'noshow' }));
|
||||
const card = container.querySelector('.border.shadow-xl');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies gradient background to AI input section', () => {
|
||||
const { container } = render(React.createElement(WorkflowVisual, { variant: 'noshow' }));
|
||||
const aiSection = container.querySelector('.from-purple-50.to-brand-50');
|
||||
expect(aiSection).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('layout', () => {
|
||||
it('uses flex column layout for workflow blocks', () => {
|
||||
const { container } = render(React.createElement(WorkflowVisual, { variant: 'noshow' }));
|
||||
const workflowContainer = container.querySelector('.flex.flex-col');
|
||||
expect(workflowContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies proper padding to sections', () => {
|
||||
const { container } = render(React.createElement(WorkflowVisual, { variant: 'noshow' }));
|
||||
const paddedSection = container.querySelector('.p-6');
|
||||
expect(paddedSection).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration badges', () => {
|
||||
it('renders all integration app badges', () => {
|
||||
render(React.createElement(WorkflowVisual, { variant: 'noshow' }));
|
||||
expect(screen.getByText('Gmail')).toBeInTheDocument();
|
||||
expect(screen.getByText('Slack')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sheets')).toBeInTheDocument();
|
||||
expect(screen.getByText('Twilio')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays additional integrations count', () => {
|
||||
render(React.createElement(WorkflowVisual, { variant: 'noshow' }));
|
||||
expect(screen.getByText('+1000 more')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('separates integration section with border', () => {
|
||||
const { container } = render(React.createElement(WorkflowVisual, { variant: 'noshow' }));
|
||||
const integrationsSection = container.querySelector('.border-t');
|
||||
expect(integrationsSection).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('variant switching', () => {
|
||||
it('changes content when variant prop changes', () => {
|
||||
const { rerender } = render(React.createElement(WorkflowVisual, { variant: 'noshow' }));
|
||||
expect(screen.getByText('Event Created')).toBeInTheDocument();
|
||||
|
||||
rerender(React.createElement(WorkflowVisual, { variant: 'winback' }));
|
||||
expect(screen.getByText('Schedule: Weekly')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('maintains AI input section across variants', () => {
|
||||
const { rerender } = render(React.createElement(WorkflowVisual, { variant: 'noshow' }));
|
||||
expect(screen.getByText('Describe your automation...')).toBeInTheDocument();
|
||||
|
||||
rerender(React.createElement(WorkflowVisual, { variant: 'report' }));
|
||||
expect(screen.getByText('Describe your automation...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('maintains integration badges across variants', () => {
|
||||
const { rerender } = render(React.createElement(WorkflowVisual, { variant: 'noshow' }));
|
||||
expect(screen.getByText('Gmail')).toBeInTheDocument();
|
||||
|
||||
rerender(React.createElement(WorkflowVisual, { variant: 'winback' }));
|
||||
expect(screen.getByText('Gmail')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('renders semantic HTML structure', () => {
|
||||
const { container } = render(React.createElement(WorkflowVisual, { variant: 'noshow' }));
|
||||
expect(container.querySelector('div')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('includes descriptive text for workflow steps', () => {
|
||||
render(React.createElement(WorkflowVisual, { variant: 'noshow' }));
|
||||
expect(screen.getByText('When')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Then').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles undefined variant gracefully', () => {
|
||||
render(React.createElement(WorkflowVisual, { variant: undefined as any }));
|
||||
// Should fall back to default (noshow)
|
||||
expect(screen.getByText('Event Created')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles invalid variant by using default', () => {
|
||||
render(React.createElement(WorkflowVisual, { variant: 'invalid' as any }));
|
||||
// Should fall back to default
|
||||
expect(screen.getByText('Event Created')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('block labels', () => {
|
||||
it('displays "When" for trigger blocks', () => {
|
||||
render(React.createElement(WorkflowVisual, { variant: 'winback' }));
|
||||
expect(screen.getByText('When')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays "Then" for action blocks', () => {
|
||||
render(React.createElement(WorkflowVisual, { variant: 'winback' }));
|
||||
const thenLabels = screen.getAllByText('Then');
|
||||
expect(thenLabels.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('applies uppercase styling to block labels', () => {
|
||||
const { container } = render(React.createElement(WorkflowVisual, { variant: 'noshow' }));
|
||||
const labels = container.querySelectorAll('.uppercase');
|
||||
expect(labels.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,855 @@
|
||||
/**
|
||||
* Unit tests for DynamicFeaturesEditor component
|
||||
*
|
||||
* Tests the dynamic features editor component including:
|
||||
* - Basic rendering with loading and error states
|
||||
* - Feature filtering by category and type
|
||||
* - Boolean feature toggles
|
||||
* - Integer feature inputs (limits)
|
||||
* - Feature dependencies
|
||||
* - Category grouping and sorting
|
||||
* - User interactions (toggling, input changes)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import DynamicFeaturesEditor from '../DynamicFeaturesEditor';
|
||||
import { BillingFeature } from '../../../hooks/useBillingPlans';
|
||||
|
||||
// Mock the useBillingFeatures hook
|
||||
vi.mock('../../../hooks/useBillingPlans', () => ({
|
||||
useBillingFeatures: vi.fn(),
|
||||
FEATURE_CATEGORY_META: {
|
||||
limits: { label: 'Limits', order: 0 },
|
||||
payments: { label: 'Payments & Revenue', order: 1 },
|
||||
communication: { label: 'Communication', order: 2 },
|
||||
customization: { label: 'Customization', order: 3 },
|
||||
plugins: { label: 'Plugins & Automation', order: 4 },
|
||||
advanced: { label: 'Advanced Features', order: 5 },
|
||||
scheduling: { label: 'Scheduling', order: 6 },
|
||||
enterprise: { label: 'Enterprise & Security', order: 7 },
|
||||
},
|
||||
}));
|
||||
|
||||
// Import the mocked module to access the mock function
|
||||
import { useBillingFeatures } from '../../../hooks/useBillingPlans';
|
||||
|
||||
const mockUseBillingFeatures = useBillingFeatures as ReturnType<typeof vi.fn>;
|
||||
|
||||
// Mock feature data
|
||||
const createMockFeature = (overrides: Partial<BillingFeature> = {}): BillingFeature => ({
|
||||
id: 1,
|
||||
code: 'test_feature',
|
||||
name: 'Test Feature',
|
||||
description: 'Test feature description',
|
||||
feature_type: 'boolean',
|
||||
category: 'plugins',
|
||||
tenant_field_name: 'can_test_feature',
|
||||
display_order: 1,
|
||||
is_overridable: true,
|
||||
depends_on: null,
|
||||
depends_on_code: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const mockFeatures: BillingFeature[] = [
|
||||
createMockFeature({
|
||||
id: 1,
|
||||
code: 'can_use_plugins',
|
||||
name: 'Use Plugins',
|
||||
description: 'Install and use marketplace plugins',
|
||||
tenant_field_name: 'can_use_plugins',
|
||||
category: 'plugins',
|
||||
display_order: 1,
|
||||
}),
|
||||
createMockFeature({
|
||||
id: 2,
|
||||
code: 'can_use_tasks',
|
||||
name: 'Scheduled Tasks',
|
||||
description: 'Create automated scheduled tasks',
|
||||
tenant_field_name: 'can_use_tasks',
|
||||
category: 'plugins',
|
||||
display_order: 2,
|
||||
depends_on: 1,
|
||||
depends_on_code: 'can_use_plugins',
|
||||
}),
|
||||
createMockFeature({
|
||||
id: 3,
|
||||
code: 'can_use_sms_reminders',
|
||||
name: 'SMS Reminders',
|
||||
description: 'Send SMS appointment reminders',
|
||||
tenant_field_name: 'can_use_sms_reminders',
|
||||
category: 'communication',
|
||||
display_order: 1,
|
||||
}),
|
||||
createMockFeature({
|
||||
id: 4,
|
||||
code: 'max_users',
|
||||
name: 'Max Users',
|
||||
description: 'Maximum number of users',
|
||||
feature_type: 'integer',
|
||||
tenant_field_name: 'max_users',
|
||||
category: 'limits',
|
||||
display_order: 1,
|
||||
}),
|
||||
];
|
||||
|
||||
describe('DynamicFeaturesEditor', () => {
|
||||
const mockOnChange = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Loading State', () => {
|
||||
it('should render loading state', () => {
|
||||
mockUseBillingFeatures.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Features & Permissions')).toBeInTheDocument();
|
||||
// Check for loading skeleton
|
||||
const skeletons = document.querySelectorAll('.animate-pulse');
|
||||
expect(skeletons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should show custom header title in loading state', () => {
|
||||
mockUseBillingFeatures.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
headerTitle="Custom Features"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Custom Features')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show header in loading state when showHeader is false', () => {
|
||||
mockUseBillingFeatures.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
showHeader={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Features & Permissions')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error State', () => {
|
||||
it('should render error state', () => {
|
||||
mockUseBillingFeatures.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
error: new Error('Failed to load'),
|
||||
});
|
||||
|
||||
render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Failed to load features from billing system')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show header in error state', () => {
|
||||
mockUseBillingFeatures.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
error: new Error('Failed to load'),
|
||||
});
|
||||
|
||||
render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Features & Permissions')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Basic Rendering', () => {
|
||||
beforeEach(() => {
|
||||
mockUseBillingFeatures.mockReturnValue({
|
||||
data: mockFeatures,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render with default header', () => {
|
||||
render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Features & Permissions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with custom header title', () => {
|
||||
render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
headerTitle="Custom Title"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Custom Title')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Features & Permissions')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render header when showHeader is false', () => {
|
||||
render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
showHeader={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Features & Permissions')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all boolean features by default', () => {
|
||||
render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Use Plugins')).toBeInTheDocument();
|
||||
expect(screen.getByText('Scheduled Tasks')).toBeInTheDocument();
|
||||
expect(screen.getByText('SMS Reminders')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render category labels', () => {
|
||||
render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Plugins & Automation')).toBeInTheDocument();
|
||||
expect(screen.getByText('Communication')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Feature Filtering', () => {
|
||||
beforeEach(() => {
|
||||
mockUseBillingFeatures.mockReturnValue({
|
||||
data: mockFeatures,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter by category', () => {
|
||||
render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
categories={['plugins']}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Use Plugins')).toBeInTheDocument();
|
||||
expect(screen.getByText('Scheduled Tasks')).toBeInTheDocument();
|
||||
expect(screen.queryByText('SMS Reminders')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should filter by feature type - boolean only', () => {
|
||||
render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
featureType="boolean"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Use Plugins')).toBeInTheDocument();
|
||||
expect(screen.getByText('SMS Reminders')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Max Users')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should filter by feature type - integer only', () => {
|
||||
render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
featureType="integer"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Max Users')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Use Plugins')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('SMS Reminders')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should exclude features by code', () => {
|
||||
render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
excludeCodes={['can_use_plugins']}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Use Plugins')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Scheduled Tasks')).toBeInTheDocument();
|
||||
expect(screen.getByText('SMS Reminders')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should combine multiple filters', () => {
|
||||
render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
categories={['plugins']}
|
||||
featureType="boolean"
|
||||
excludeCodes={['can_use_tasks']}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Use Plugins')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Scheduled Tasks')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('SMS Reminders')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Boolean Features', () => {
|
||||
beforeEach(() => {
|
||||
mockUseBillingFeatures.mockReturnValue({
|
||||
data: mockFeatures,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render unchecked checkbox for false value', () => {
|
||||
render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{ can_use_plugins: false }}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const checkbox = screen.getByRole('checkbox', { name: /Use Plugins/i });
|
||||
expect(checkbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('should render checked checkbox for true value', () => {
|
||||
render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{ can_use_plugins: true }}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const checkbox = screen.getByRole('checkbox', { name: /Use Plugins/i });
|
||||
expect(checkbox).toBeChecked();
|
||||
});
|
||||
|
||||
it('should call onChange when checkbox is toggled', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{ can_use_plugins: false }}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const checkbox = screen.getByRole('checkbox', { name: /Use Plugins/i });
|
||||
await user.click(checkbox);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('can_use_plugins', true);
|
||||
});
|
||||
|
||||
it('should show descriptions when showDescriptions is true', () => {
|
||||
render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
showDescriptions={true}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Install and use marketplace plugins')).toBeInTheDocument();
|
||||
expect(screen.getByText('Create automated scheduled tasks')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show descriptions by default', () => {
|
||||
render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Install and use marketplace plugins')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Create automated scheduled tasks')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integer Features', () => {
|
||||
beforeEach(() => {
|
||||
mockUseBillingFeatures.mockReturnValue({
|
||||
data: mockFeatures,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render number input for integer features', () => {
|
||||
render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{ max_users: 10 }}
|
||||
onChange={mockOnChange}
|
||||
featureType="integer"
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByDisplayValue('10');
|
||||
expect(input).toHaveAttribute('type', 'number');
|
||||
});
|
||||
|
||||
it('should render -1 for unlimited (null value)', () => {
|
||||
render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{ max_users: null }}
|
||||
onChange={mockOnChange}
|
||||
featureType="integer"
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByDisplayValue('-1');
|
||||
expect(input).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onChange with null when -1 is entered', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{ max_users: 10 }}
|
||||
onChange={mockOnChange}
|
||||
featureType="integer"
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByDisplayValue('10');
|
||||
// Type -1 directly (this will trigger onChange for each character)
|
||||
await user.clear(input);
|
||||
await user.type(input, '-1');
|
||||
|
||||
// The final call should have -1 -> null conversion
|
||||
await waitFor(() => {
|
||||
const calls = mockOnChange.mock.calls;
|
||||
const lastCall = calls[calls.length - 1];
|
||||
expect(lastCall[0]).toBe('max_users');
|
||||
expect(lastCall[1]).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onChange with number when value is entered', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{ max_users: 10 }}
|
||||
onChange={mockOnChange}
|
||||
featureType="integer"
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByDisplayValue('10');
|
||||
// Clear and type will trigger onChange for each character
|
||||
await user.clear(input);
|
||||
await user.type(input, '25');
|
||||
|
||||
// The final call should have the complete number
|
||||
await waitFor(() => {
|
||||
const calls = mockOnChange.mock.calls;
|
||||
const lastCall = calls[calls.length - 1];
|
||||
expect(lastCall[0]).toBe('max_users');
|
||||
expect(lastCall[1]).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
it('should show description with unlimited hint when showDescriptions is true', () => {
|
||||
render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{ max_users: 10 }}
|
||||
onChange={mockOnChange}
|
||||
featureType="integer"
|
||||
showDescriptions={true}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Maximum number of users.*-1 = unlimited/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Feature Dependencies', () => {
|
||||
beforeEach(() => {
|
||||
mockUseBillingFeatures.mockReturnValue({
|
||||
data: mockFeatures,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should disable dependent feature when parent is disabled', () => {
|
||||
render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{ can_use_plugins: false, can_use_tasks: false }}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const tasksCheckbox = screen.getByRole('checkbox', { name: /Scheduled Tasks/i });
|
||||
expect(tasksCheckbox).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should enable dependent feature when parent is enabled', () => {
|
||||
render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{ can_use_plugins: true, can_use_tasks: false }}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const tasksCheckbox = screen.getByRole('checkbox', { name: /Scheduled Tasks/i });
|
||||
expect(tasksCheckbox).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('should disable dependents when parent is toggled off', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{ can_use_plugins: true, can_use_tasks: true }}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const pluginsCheckbox = screen.getByRole('checkbox', { name: /Use Plugins/i });
|
||||
await user.click(pluginsCheckbox);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('can_use_plugins', false);
|
||||
expect(mockOnChange).toHaveBeenCalledWith('can_use_tasks', false);
|
||||
});
|
||||
|
||||
it('should show dependency hint when plugins are disabled', () => {
|
||||
render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{ can_use_plugins: false }}
|
||||
onChange={mockOnChange}
|
||||
categories={['plugins']}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Enable "Use Plugins" to allow dependent features')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show dependency hint when plugins are enabled', () => {
|
||||
render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{ can_use_plugins: true }}
|
||||
onChange={mockOnChange}
|
||||
categories={['plugins']}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Enable "Use Plugins" to allow dependent features')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Column Layout', () => {
|
||||
beforeEach(() => {
|
||||
mockUseBillingFeatures.mockReturnValue({
|
||||
data: mockFeatures,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should use 3 columns by default', () => {
|
||||
const { container } = render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const grid = container.querySelector('.grid-cols-3');
|
||||
expect(grid).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use 2 columns when specified', () => {
|
||||
const { container } = render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
columns={2}
|
||||
/>
|
||||
);
|
||||
|
||||
const grid = container.querySelector('.grid-cols-2');
|
||||
expect(grid).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use 4 columns when specified', () => {
|
||||
const { container } = render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
columns={4}
|
||||
/>
|
||||
);
|
||||
|
||||
const grid = container.querySelector('.grid-cols-4');
|
||||
expect(grid).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Category Sorting', () => {
|
||||
it('should sort categories by order', () => {
|
||||
mockUseBillingFeatures.mockReturnValue({
|
||||
data: mockFeatures,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const categoryHeaders = container.querySelectorAll('h4.uppercase');
|
||||
const categoryTexts = Array.from(categoryHeaders).map(h => h.textContent);
|
||||
|
||||
// Limits (0) should come before Communication (2) which comes before Plugins (4)
|
||||
const limitsIndex = categoryTexts.indexOf('Limits');
|
||||
const communicationIndex = categoryTexts.indexOf('Communication');
|
||||
const pluginsIndex = categoryTexts.indexOf('Plugins & Automation');
|
||||
|
||||
expect(limitsIndex).toBeLessThan(communicationIndex);
|
||||
expect(communicationIndex).toBeLessThan(pluginsIndex);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty features array', () => {
|
||||
mockUseBillingFeatures.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Features & Permissions')).toBeInTheDocument();
|
||||
// Should not crash, just render empty
|
||||
});
|
||||
|
||||
it('should handle features without tenant_field_name', () => {
|
||||
const featuresWithoutField = [
|
||||
createMockFeature({
|
||||
tenant_field_name: '',
|
||||
}),
|
||||
];
|
||||
|
||||
mockUseBillingFeatures.mockReturnValue({
|
||||
data: featuresWithoutField,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should filter out features without tenant_field_name
|
||||
expect(screen.queryByText('Test Feature')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle non-overridable features', () => {
|
||||
const nonOverridableFeatures = [
|
||||
createMockFeature({
|
||||
is_overridable: false,
|
||||
}),
|
||||
];
|
||||
|
||||
mockUseBillingFeatures.mockReturnValue({
|
||||
data: nonOverridableFeatures,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should filter out non-overridable features
|
||||
expect(screen.queryByText('Test Feature')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle undefined values object', () => {
|
||||
mockUseBillingFeatures.mockReturnValue({
|
||||
data: mockFeatures,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const checkbox = screen.getByRole('checkbox', { name: /Use Plugins/i });
|
||||
expect(checkbox).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
beforeEach(() => {
|
||||
mockUseBillingFeatures.mockReturnValue({
|
||||
data: mockFeatures,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should use semantic heading for main header', () => {
|
||||
const { container } = render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const mainHeading = container.querySelector('h3');
|
||||
expect(mainHeading).toHaveTextContent('Features & Permissions');
|
||||
});
|
||||
|
||||
it('should use semantic heading for category labels', () => {
|
||||
const { container } = render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const categoryHeadings = container.querySelectorAll('h4');
|
||||
expect(categoryHeadings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have proper label association for checkboxes', () => {
|
||||
render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const checkbox = screen.getByRole('checkbox', { name: /Use Plugins/i });
|
||||
expect(checkbox).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have proper label for number inputs', () => {
|
||||
render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{ max_users: 10 }}
|
||||
onChange={mockOnChange}
|
||||
featureType="integer"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Max Users')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('10')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dark Mode Support', () => {
|
||||
beforeEach(() => {
|
||||
mockUseBillingFeatures.mockReturnValue({
|
||||
data: mockFeatures,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should include dark mode classes for header', () => {
|
||||
const { container } = render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const header = screen.getByText('Features & Permissions');
|
||||
expect(header).toHaveClass('dark:text-white');
|
||||
});
|
||||
|
||||
it('should include dark mode classes for descriptions', () => {
|
||||
const { container } = render(
|
||||
<DynamicFeaturesEditor
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const description = container.querySelector('.text-xs.text-gray-500');
|
||||
expect(description).toHaveClass('dark:text-gray-400');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,695 @@
|
||||
/**
|
||||
* Unit tests for FeaturesPermissionsEditor component
|
||||
*
|
||||
* Tests the static features/permissions editor component including:
|
||||
* - Basic rendering with plan and business modes
|
||||
* - Permission filtering by category
|
||||
* - Boolean permission toggles
|
||||
* - Permission dependencies
|
||||
* - Category grouping and sorting
|
||||
* - Mode-specific key mapping
|
||||
* - User interactions
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import FeaturesPermissionsEditor, {
|
||||
PERMISSION_DEFINITIONS,
|
||||
getPermissionKey,
|
||||
convertPermissions,
|
||||
} from '../FeaturesPermissionsEditor';
|
||||
|
||||
describe('FeaturesPermissionsEditor', () => {
|
||||
const mockOnChange = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Basic Rendering - Plan Mode', () => {
|
||||
it('should render with default header in plan mode', () => {
|
||||
render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="plan"
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Features & Permissions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with custom header title', () => {
|
||||
render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="plan"
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
headerTitle="Custom Permissions"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Custom Permissions')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Features & Permissions')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render header when showHeader is false', () => {
|
||||
render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="plan"
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
showHeader={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Features & Permissions')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all permissions by default', () => {
|
||||
render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="plan"
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
// Check for some key permissions
|
||||
expect(screen.getByText('Online Payments')).toBeInTheDocument();
|
||||
expect(screen.getByText('SMS Reminders')).toBeInTheDocument();
|
||||
expect(screen.getByText('Use Plugins')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render category labels', () => {
|
||||
render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="plan"
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Payments & Revenue')).toBeInTheDocument();
|
||||
expect(screen.getByText('Communication')).toBeInTheDocument();
|
||||
expect(screen.getByText('Plugins & Automation')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Basic Rendering - Business Mode', () => {
|
||||
it('should render correctly in business mode', () => {
|
||||
render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="business"
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Features & Permissions')).toBeInTheDocument();
|
||||
expect(screen.getByText('Online Payments')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use business keys in business mode', () => {
|
||||
render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="business"
|
||||
values={{ can_accept_payments: true }}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const checkbox = screen.getByRole('checkbox', { name: /Online Payments/i });
|
||||
expect(checkbox).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Permission Filtering', () => {
|
||||
it('should filter by category', () => {
|
||||
render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="plan"
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
categories={['payments']}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Online Payments')).toBeInTheDocument();
|
||||
expect(screen.queryByText('SMS Reminders')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Use Plugins')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should filter by multiple categories', () => {
|
||||
render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="plan"
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
categories={['payments', 'communication']}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Online Payments')).toBeInTheDocument();
|
||||
expect(screen.getByText('SMS Reminders')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Use Plugins')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should filter using includeOnly', () => {
|
||||
render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="plan"
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
includeOnly={['can_accept_payments', 'sms_reminders']}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Online Payments')).toBeInTheDocument();
|
||||
expect(screen.getByText('SMS Reminders')).toBeInTheDocument();
|
||||
// Other permissions should not be visible
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
expect(checkboxes).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should exclude specific permissions', () => {
|
||||
render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="plan"
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
categories={['payments']}
|
||||
exclude={['can_accept_payments']}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Online Payments')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Process Refunds')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Permission Toggles', () => {
|
||||
it('should render unchecked checkbox for false value in plan mode', () => {
|
||||
render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="plan"
|
||||
values={{ can_accept_payments: false }}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const checkbox = screen.getByRole('checkbox', { name: /Online Payments/i });
|
||||
expect(checkbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('should render checked checkbox for true value in plan mode', () => {
|
||||
render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="plan"
|
||||
values={{ can_accept_payments: true }}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const checkbox = screen.getByRole('checkbox', { name: /Online Payments/i });
|
||||
expect(checkbox).toBeChecked();
|
||||
});
|
||||
|
||||
it('should call onChange when checkbox is toggled in plan mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="plan"
|
||||
values={{ can_accept_payments: false }}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const checkbox = screen.getByRole('checkbox', { name: /Online Payments/i });
|
||||
await user.click(checkbox);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('can_accept_payments', true);
|
||||
});
|
||||
|
||||
it('should call onChange when checkbox is toggled in business mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="business"
|
||||
values={{ can_accept_payments: false }}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const checkbox = screen.getByRole('checkbox', { name: /Online Payments/i });
|
||||
await user.click(checkbox);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('can_accept_payments', true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Permission Dependencies', () => {
|
||||
it('should disable dependent permission when parent is disabled', () => {
|
||||
render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="plan"
|
||||
values={{ can_use_plugins: false, can_use_tasks: false }}
|
||||
onChange={mockOnChange}
|
||||
categories={['plugins']}
|
||||
/>
|
||||
);
|
||||
|
||||
const tasksCheckbox = screen.getByRole('checkbox', { name: /Scheduled Tasks/i });
|
||||
expect(tasksCheckbox).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should enable dependent permission when parent is enabled', () => {
|
||||
render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="plan"
|
||||
values={{ can_use_plugins: true, can_use_tasks: false }}
|
||||
onChange={mockOnChange}
|
||||
categories={['plugins']}
|
||||
/>
|
||||
);
|
||||
|
||||
const tasksCheckbox = screen.getByRole('checkbox', { name: /Scheduled Tasks/i });
|
||||
expect(tasksCheckbox).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('should disable dependents when parent is toggled off', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="plan"
|
||||
values={{ can_use_plugins: true, can_use_tasks: true }}
|
||||
onChange={mockOnChange}
|
||||
categories={['plugins']}
|
||||
/>
|
||||
);
|
||||
|
||||
const pluginsCheckbox = screen.getByRole('checkbox', { name: /Use Plugins/i });
|
||||
await user.click(pluginsCheckbox);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('can_use_plugins', false);
|
||||
expect(mockOnChange).toHaveBeenCalledWith('can_use_tasks', false);
|
||||
});
|
||||
|
||||
it('should show dependency hint when plugins are disabled', () => {
|
||||
render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="plan"
|
||||
values={{ can_use_plugins: false }}
|
||||
onChange={mockOnChange}
|
||||
categories={['plugins']}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Enable "Use Plugins" to allow Scheduled Tasks and Create Plugins')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show dependency hint when plugins are enabled', () => {
|
||||
render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="plan"
|
||||
values={{ can_use_plugins: true }}
|
||||
onChange={mockOnChange}
|
||||
categories={['plugins']}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Enable "Use Plugins" to allow Scheduled Tasks and Create Plugins')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle dependencies in business mode', () => {
|
||||
render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="business"
|
||||
values={{ can_use_plugins: false, can_use_tasks: false }}
|
||||
onChange={mockOnChange}
|
||||
categories={['plugins']}
|
||||
/>
|
||||
);
|
||||
|
||||
const tasksCheckbox = screen.getByRole('checkbox', { name: /Scheduled Tasks/i });
|
||||
expect(tasksCheckbox).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Descriptions', () => {
|
||||
it('should show descriptions when showDescriptions is true', () => {
|
||||
render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="plan"
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
categories={['payments']}
|
||||
showDescriptions={true}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Accept payments via Stripe Connect')).toBeInTheDocument();
|
||||
expect(screen.getByText('Issue refunds for payments')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show descriptions by default', () => {
|
||||
render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="plan"
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
categories={['payments']}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Accept payments via Stripe Connect')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Issue refunds for payments')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Column Layout', () => {
|
||||
it('should use 3 columns by default', () => {
|
||||
const { container } = render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="plan"
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const grid = container.querySelector('.grid-cols-3');
|
||||
expect(grid).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use 2 columns when specified', () => {
|
||||
const { container } = render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="plan"
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
columns={2}
|
||||
/>
|
||||
);
|
||||
|
||||
const grid = container.querySelector('.grid-cols-2');
|
||||
expect(grid).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use 4 columns when specified', () => {
|
||||
const { container } = render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="plan"
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
columns={4}
|
||||
/>
|
||||
);
|
||||
|
||||
const grid = container.querySelector('.grid-cols-4');
|
||||
expect(grid).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Category Sorting', () => {
|
||||
it('should sort categories by order', () => {
|
||||
const { container } = render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="plan"
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const categoryHeaders = container.querySelectorAll('h4.uppercase');
|
||||
const categoryTexts = Array.from(categoryHeaders).map(h => h.textContent);
|
||||
|
||||
// Payments (1) should come before Communication (2) which comes before Plugins (4)
|
||||
const paymentsIndex = categoryTexts.indexOf('Payments & Revenue');
|
||||
const communicationIndex = categoryTexts.indexOf('Communication');
|
||||
const pluginsIndex = categoryTexts.indexOf('Plugins & Automation');
|
||||
|
||||
expect(paymentsIndex).toBeLessThan(communicationIndex);
|
||||
expect(communicationIndex).toBeLessThan(pluginsIndex);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Helper Functions', () => {
|
||||
describe('getPermissionKey', () => {
|
||||
it('should return planKey in plan mode', () => {
|
||||
const def = PERMISSION_DEFINITIONS.find(d => d.key === 'sms_reminders')!;
|
||||
expect(getPermissionKey(def, 'plan')).toBe('sms_reminders');
|
||||
});
|
||||
|
||||
it('should return businessKey in business mode', () => {
|
||||
const def = PERMISSION_DEFINITIONS.find(d => d.key === 'sms_reminders')!;
|
||||
expect(getPermissionKey(def, 'business')).toBe('can_use_sms_reminders');
|
||||
});
|
||||
|
||||
it('should fallback to key if planKey not defined', () => {
|
||||
const def = { ...PERMISSION_DEFINITIONS[0], planKey: undefined };
|
||||
expect(getPermissionKey(def, 'plan')).toBe(def.key);
|
||||
});
|
||||
|
||||
it('should fallback to key if businessKey not defined', () => {
|
||||
const def = { ...PERMISSION_DEFINITIONS[0], businessKey: undefined };
|
||||
expect(getPermissionKey(def, 'business')).toBe(def.key);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertPermissions', () => {
|
||||
it('should convert from plan mode to business mode', () => {
|
||||
const planValues = {
|
||||
can_accept_payments: true,
|
||||
sms_reminders: true,
|
||||
};
|
||||
|
||||
const businessValues = convertPermissions(planValues, 'plan', 'business');
|
||||
|
||||
expect(businessValues.can_accept_payments).toBe(true);
|
||||
expect(businessValues.can_use_sms_reminders).toBe(true);
|
||||
});
|
||||
|
||||
it('should convert from business mode to plan mode', () => {
|
||||
const businessValues = {
|
||||
can_accept_payments: true,
|
||||
can_use_sms_reminders: true,
|
||||
};
|
||||
|
||||
const planValues = convertPermissions(businessValues, 'business', 'plan');
|
||||
|
||||
expect(planValues.can_accept_payments).toBe(true);
|
||||
expect(planValues.sms_reminders).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty values', () => {
|
||||
const result = convertPermissions({}, 'plan', 'business');
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should only convert known permissions', () => {
|
||||
const planValues = {
|
||||
can_accept_payments: true,
|
||||
unknown_permission: true,
|
||||
};
|
||||
|
||||
const businessValues = convertPermissions(planValues, 'plan', 'business');
|
||||
|
||||
expect(businessValues.can_accept_payments).toBe(true);
|
||||
expect(businessValues.unknown_permission).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should use semantic heading for main header', () => {
|
||||
const { container } = render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="plan"
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const mainHeading = container.querySelector('h3');
|
||||
expect(mainHeading).toHaveTextContent('Features & Permissions');
|
||||
});
|
||||
|
||||
it('should use semantic heading for category labels', () => {
|
||||
const { container } = render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="plan"
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const categoryHeadings = container.querySelectorAll('h4');
|
||||
expect(categoryHeadings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have proper label association for checkboxes', () => {
|
||||
render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="plan"
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const checkbox = screen.getByRole('checkbox', { name: /Online Payments/i });
|
||||
expect(checkbox).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have disabled state for dependent checkboxes', () => {
|
||||
render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="plan"
|
||||
values={{ can_use_plugins: false }}
|
||||
onChange={mockOnChange}
|
||||
categories={['plugins']}
|
||||
/>
|
||||
);
|
||||
|
||||
const tasksCheckbox = screen.getByRole('checkbox', { name: /Scheduled Tasks/i });
|
||||
expect(tasksCheckbox).toBeDisabled();
|
||||
expect(tasksCheckbox.closest('label')).toHaveClass('opacity-50', 'cursor-not-allowed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dark Mode Support', () => {
|
||||
it('should include dark mode classes for header', () => {
|
||||
render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="plan"
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const header = screen.getByText('Features & Permissions');
|
||||
expect(header).toHaveClass('dark:text-white');
|
||||
});
|
||||
|
||||
it('should include dark mode classes for descriptions', () => {
|
||||
const { container } = render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="plan"
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const description = container.querySelector('.text-xs.text-gray-500');
|
||||
expect(description).toHaveClass('dark:text-gray-400');
|
||||
});
|
||||
|
||||
it('should include dark mode classes for checkboxes', () => {
|
||||
const { container } = render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="plan"
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
categories={['payments']}
|
||||
/>
|
||||
);
|
||||
|
||||
const labels = container.querySelectorAll('label');
|
||||
labels.forEach(label => {
|
||||
expect(label).toHaveClass('dark:border-gray-700');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty categories array', () => {
|
||||
render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="plan"
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
categories={[]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Features & Permissions')).toBeInTheDocument();
|
||||
// No permissions should be shown
|
||||
const checkboxes = screen.queryAllByRole('checkbox');
|
||||
expect(checkboxes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle undefined values for permissions', () => {
|
||||
render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="plan"
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
categories={['payments']}
|
||||
/>
|
||||
);
|
||||
|
||||
const checkbox = screen.getByRole('checkbox', { name: /Online Payments/i });
|
||||
expect(checkbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('should toggle permission from undefined to true', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="plan"
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
categories={['payments']}
|
||||
/>
|
||||
);
|
||||
|
||||
const checkbox = screen.getByRole('checkbox', { name: /Online Payments/i });
|
||||
await user.click(checkbox);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('can_accept_payments', true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hover States', () => {
|
||||
it('should have hover classes on enabled checkboxes', () => {
|
||||
const { container } = render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="plan"
|
||||
values={{}}
|
||||
onChange={mockOnChange}
|
||||
categories={['payments']}
|
||||
/>
|
||||
);
|
||||
|
||||
const labels = container.querySelectorAll('label');
|
||||
labels.forEach(label => {
|
||||
if (!label.classList.contains('cursor-not-allowed')) {
|
||||
expect(label).toHaveClass('hover:bg-gray-50');
|
||||
expect(label).toHaveClass('dark:hover:bg-gray-700/50');
|
||||
expect(label).toHaveClass('cursor-pointer');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should not have hover classes on disabled checkboxes', () => {
|
||||
const { container } = render(
|
||||
<FeaturesPermissionsEditor
|
||||
mode="plan"
|
||||
values={{ can_use_plugins: false }}
|
||||
onChange={mockOnChange}
|
||||
categories={['plugins']}
|
||||
/>
|
||||
);
|
||||
|
||||
const tasksCheckbox = screen.getByRole('checkbox', { name: /Scheduled Tasks/i });
|
||||
const label = tasksCheckbox.closest('label');
|
||||
|
||||
expect(label).toHaveClass('cursor-not-allowed');
|
||||
expect(label).not.toHaveClass('cursor-pointer');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,34 +1,77 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
/**
|
||||
* Unit tests for TwoFactorSetup component
|
||||
*
|
||||
* Tests the two-factor authentication setup and management wizard.
|
||||
* Covers:
|
||||
* - Initial rendering for enabled/disabled states
|
||||
* - Step navigation (intro -> qrcode -> verify -> recovery -> complete)
|
||||
* - QR code display and manual secret entry
|
||||
* - Verification code input and submission
|
||||
* - Recovery code display and download
|
||||
* - Disable 2FA flow
|
||||
* - Phone verification prompts
|
||||
* - Error handling
|
||||
* - User interactions (clicks, form inputs, copy, download)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import TwoFactorSetup from '../TwoFactorSetup';
|
||||
|
||||
// Mock hooks with mutable state
|
||||
const mockSetupTOTP = vi.fn();
|
||||
const mockVerifyTOTP = vi.fn();
|
||||
const mockDisableTOTP = vi.fn();
|
||||
const mockRecoveryCodesRefetch = vi.fn();
|
||||
const mockRegenerateCodes = vi.fn();
|
||||
|
||||
let mockSetupTOTPData: any = null;
|
||||
let mockSetupTOTPPending = false;
|
||||
let mockVerifyTOTPData: any = null;
|
||||
let mockVerifyTOTPPending = false;
|
||||
let mockDisableTOTPPending = false;
|
||||
let mockRecoveryCodesData: any = null;
|
||||
let mockRecoveryCodesFetching = false;
|
||||
let mockRegenerateCodesPending = false;
|
||||
|
||||
vi.mock('../../../hooks/useProfile', () => ({
|
||||
useSetupTOTP: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ qr_code: 'base64qr', secret: 'ABCD1234' }),
|
||||
data: null,
|
||||
isPending: false,
|
||||
mutateAsync: mockSetupTOTP,
|
||||
get data() { return mockSetupTOTPData; },
|
||||
get isPending() { return mockSetupTOTPPending; },
|
||||
}),
|
||||
useVerifyTOTP: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ recovery_codes: ['code1', 'code2'] }),
|
||||
data: null,
|
||||
isPending: false,
|
||||
mutateAsync: mockVerifyTOTP,
|
||||
get data() { return mockVerifyTOTPData; },
|
||||
get isPending() { return mockVerifyTOTPPending; },
|
||||
}),
|
||||
useDisableTOTP: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({}),
|
||||
isPending: false,
|
||||
mutateAsync: mockDisableTOTP,
|
||||
get isPending() { return mockDisableTOTPPending; },
|
||||
}),
|
||||
useRecoveryCodes: () => ({
|
||||
refetch: vi.fn().mockResolvedValue({ data: ['code1', 'code2'] }),
|
||||
data: ['code1', 'code2'],
|
||||
isFetching: false,
|
||||
refetch: mockRecoveryCodesRefetch,
|
||||
get data() { return mockRecoveryCodesData; },
|
||||
get isFetching() { return mockRecoveryCodesFetching; },
|
||||
}),
|
||||
useRegenerateRecoveryCodes: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({}),
|
||||
isPending: false,
|
||||
mutateAsync: mockRegenerateCodes,
|
||||
get isPending() { return mockRegenerateCodesPending; },
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock clipboard API
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
// Mock URL.createObjectURL and URL.revokeObjectURL
|
||||
global.URL.createObjectURL = vi.fn(() => 'mock-url');
|
||||
global.URL.revokeObjectURL = vi.fn();
|
||||
|
||||
const defaultProps = {
|
||||
isEnabled: false,
|
||||
phoneVerified: false,
|
||||
@@ -41,89 +84,718 @@ const defaultProps = {
|
||||
describe('TwoFactorSetup', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Reset mock state
|
||||
mockSetupTOTPData = null;
|
||||
mockSetupTOTPPending = false;
|
||||
mockVerifyTOTPData = null;
|
||||
mockVerifyTOTPPending = false;
|
||||
mockDisableTOTPPending = false;
|
||||
mockRecoveryCodesData = null;
|
||||
mockRecoveryCodesFetching = false;
|
||||
mockRegenerateCodesPending = false;
|
||||
});
|
||||
|
||||
it('renders modal with title when not enabled', () => {
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
expect(screen.getByText('Set Up Two-Factor Authentication')).toBeInTheDocument();
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders modal with manage title when enabled', () => {
|
||||
render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true }));
|
||||
expect(screen.getByText('Manage Two-Factor Authentication')).toBeInTheDocument();
|
||||
describe('Initial Rendering', () => {
|
||||
it('renders modal with setup title when not enabled', () => {
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
expect(screen.getByText('Set Up Two-Factor Authentication')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders modal with manage title when enabled', () => {
|
||||
render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true }));
|
||||
expect(screen.getByText('Manage Two-Factor Authentication')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders close button', () => {
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
const closeButton = document.querySelector('.lucide-x')?.parentElement;
|
||||
expect(closeButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose when close button is clicked', () => {
|
||||
const mockOnClose = vi.fn();
|
||||
render(React.createElement(TwoFactorSetup, { ...defaultProps, onClose: mockOnClose }));
|
||||
const closeButton = document.querySelector('.lucide-x')?.parentElement;
|
||||
if (closeButton) {
|
||||
fireEvent.click(closeButton);
|
||||
}
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows Shield icon in header', () => {
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
const shieldIcon = document.querySelector('.lucide-shield');
|
||||
expect(shieldIcon).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders close button', () => {
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
const closeButton = document.querySelector('.lucide-x')?.parentElement;
|
||||
expect(closeButton).toBeInTheDocument();
|
||||
describe('Intro Step', () => {
|
||||
it('renders intro step content by default', () => {
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
expect(screen.getByText('Secure Your Account')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Two-factor authentication adds an extra layer of security/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders smartphone icon in intro', () => {
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
const smartphoneIcon = document.querySelector('.lucide-smartphone');
|
||||
expect(smartphoneIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Get Started button', () => {
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
expect(screen.getByText('Get Started')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows SMS Backup Not Available when phone not verified', () => {
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
expect(screen.getByText('SMS Backup Not Available')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows SMS Backup Available when phone is verified', () => {
|
||||
render(React.createElement(TwoFactorSetup, { ...defaultProps, phoneVerified: true }));
|
||||
expect(screen.getByText('SMS Backup Available')).toBeInTheDocument();
|
||||
expect(screen.getByText('Your verified phone can be used as a backup method.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows verify phone prompt when has phone but not verified', () => {
|
||||
render(React.createElement(TwoFactorSetup, { ...defaultProps, hasPhone: true }));
|
||||
expect(screen.getByText('Verify your phone number now')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onVerifyPhone when verify link clicked', () => {
|
||||
const mockOnVerifyPhone = vi.fn();
|
||||
const mockOnClose = vi.fn();
|
||||
render(React.createElement(TwoFactorSetup, {
|
||||
...defaultProps,
|
||||
hasPhone: true,
|
||||
onVerifyPhone: mockOnVerifyPhone,
|
||||
onClose: mockOnClose
|
||||
}));
|
||||
|
||||
const verifyLink = screen.getByText('Verify your phone number now');
|
||||
fireEvent.click(verifyLink);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
expect(mockOnVerifyPhone).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows add phone prompt when no phone', () => {
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
expect(screen.getByText('Go to profile settings to add a phone number')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose when add phone link clicked', () => {
|
||||
const mockOnClose = vi.fn();
|
||||
render(React.createElement(TwoFactorSetup, { ...defaultProps, onClose: mockOnClose }));
|
||||
|
||||
const addPhoneLink = screen.getByText('Go to profile settings to add a phone number');
|
||||
fireEvent.click(addPhoneLink);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts setup when Get Started button clicked', async () => {
|
||||
mockSetupTOTP.mockResolvedValue({ qr_code: 'base64qr', secret: 'ABCD1234' });
|
||||
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
|
||||
const getStartedButton = screen.getByText('Get Started');
|
||||
fireEvent.click(getStartedButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetupTOTP).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error when setup fails', async () => {
|
||||
mockSetupTOTP.mockRejectedValue({
|
||||
response: { data: { detail: 'Setup failed' } }
|
||||
});
|
||||
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
|
||||
const getStartedButton = screen.getByText('Get Started');
|
||||
fireEvent.click(getStartedButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Setup failed')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows generic error when setup fails without detail', async () => {
|
||||
mockSetupTOTP.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
|
||||
const getStartedButton = screen.getByText('Get Started');
|
||||
fireEvent.click(getStartedButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to start 2FA setup')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows Setting up... text when setup is pending', () => {
|
||||
mockSetupTOTPPending = true;
|
||||
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
|
||||
expect(screen.getByText('Setting up...')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose when close button is clicked', () => {
|
||||
const mockOnClose = vi.fn();
|
||||
render(React.createElement(TwoFactorSetup, { ...defaultProps, onClose: mockOnClose }));
|
||||
const closeButton = document.querySelector('.lucide-x')?.parentElement;
|
||||
if (closeButton) {
|
||||
fireEvent.click(closeButton);
|
||||
}
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
describe('QR Code Step', () => {
|
||||
beforeEach(() => {
|
||||
mockSetupTOTPData = { qr_code: 'base64qr', secret: 'ABCD1234' };
|
||||
});
|
||||
|
||||
it('displays QR code when data is available', () => {
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
|
||||
const qrImage = screen.queryByAltText('2FA QR Code');
|
||||
if (qrImage) {
|
||||
expect(qrImage).toHaveAttribute('src', 'data:image/png;base64,base64qr');
|
||||
}
|
||||
});
|
||||
|
||||
it('displays manual secret code', () => {
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
expect(screen.getByText('ABCD1234')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('copies secret to clipboard when copy button clicked', async () => {
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
|
||||
const copyButton = document.querySelector('.lucide-copy')?.parentElement;
|
||||
if (copyButton) {
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('ABCD1234');
|
||||
|
||||
// Check icon changes to checkmark
|
||||
await waitFor(() => {
|
||||
const checkIcon = document.querySelector('.lucide-check');
|
||||
expect(checkIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Icon reverts after timeout
|
||||
vi.advanceTimersByTime(2000);
|
||||
await waitFor(() => {
|
||||
const copyIcon = document.querySelector('.lucide-copy');
|
||||
expect(copyIcon).toBeTruthy();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('navigates to verify step when Continue clicked', () => {
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
|
||||
const continueButton = screen.getByText('Continue');
|
||||
fireEvent.click(continueButton);
|
||||
|
||||
expect(screen.getByText('Enter the 6-digit code from your authenticator app')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders intro step content', () => {
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
expect(screen.getByText('Secure Your Account')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Two-factor authentication adds an extra layer of security/)).toBeInTheDocument();
|
||||
describe('Verify Step', () => {
|
||||
beforeEach(() => {
|
||||
mockSetupTOTPData = { qr_code: 'base64qr', secret: 'ABCD1234' };
|
||||
});
|
||||
|
||||
it('renders verification code input', () => {
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
|
||||
// Navigate to verify step
|
||||
fireEvent.click(screen.getByText('Continue'));
|
||||
|
||||
const input = screen.getByPlaceholderText('000000');
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input).toHaveClass('text-center');
|
||||
});
|
||||
|
||||
it('filters non-numeric input', () => {
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
|
||||
fireEvent.click(screen.getByText('Continue'));
|
||||
|
||||
const input = screen.getByPlaceholderText('000000') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: 'abc123def' } });
|
||||
|
||||
expect(input.value).toBe('123');
|
||||
});
|
||||
|
||||
it('limits input to 6 digits', () => {
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
|
||||
fireEvent.click(screen.getByText('Continue'));
|
||||
|
||||
const input = screen.getByPlaceholderText('000000') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: '1234567890' } });
|
||||
|
||||
expect(input.value).toBe('123456');
|
||||
});
|
||||
|
||||
it('disables verify button when code is incomplete', () => {
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
|
||||
fireEvent.click(screen.getByText('Continue'));
|
||||
|
||||
const input = screen.getByPlaceholderText('000000');
|
||||
fireEvent.change(input, { target: { value: '12345' } });
|
||||
|
||||
const verifyButton = screen.getByRole('button', { name: /verify/i });
|
||||
expect(verifyButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables verify button when code is 6 digits', () => {
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
|
||||
fireEvent.click(screen.getByText('Continue'));
|
||||
|
||||
const input = screen.getByPlaceholderText('000000');
|
||||
fireEvent.change(input, { target: { value: '123456' } });
|
||||
|
||||
const verifyButton = screen.getByRole('button', { name: /verify/i });
|
||||
expect(verifyButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('verifies code successfully', async () => {
|
||||
mockVerifyTOTP.mockResolvedValue({ recovery_codes: ['CODE1', 'CODE2', 'CODE3', 'CODE4', 'CODE5', 'CODE6'] });
|
||||
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
|
||||
fireEvent.click(screen.getByText('Continue'));
|
||||
|
||||
const input = screen.getByPlaceholderText('000000');
|
||||
fireEvent.change(input, { target: { value: '123456' } });
|
||||
|
||||
const verifyButton = screen.getByRole('button', { name: /verify/i });
|
||||
fireEvent.click(verifyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockVerifyTOTP).toHaveBeenCalledWith('123456');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error when verification fails', async () => {
|
||||
mockVerifyTOTP.mockRejectedValue({
|
||||
response: { data: { detail: 'Invalid code' } }
|
||||
});
|
||||
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
|
||||
fireEvent.click(screen.getByText('Continue'));
|
||||
|
||||
const input = screen.getByPlaceholderText('000000');
|
||||
fireEvent.change(input, { target: { value: '123456' } });
|
||||
|
||||
const verifyButton = screen.getByRole('button', { name: /verify/i });
|
||||
fireEvent.click(verifyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Invalid code')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows generic error when verification fails without detail', async () => {
|
||||
mockVerifyTOTP.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
|
||||
fireEvent.click(screen.getByText('Continue'));
|
||||
|
||||
const input = screen.getByPlaceholderText('000000');
|
||||
fireEvent.change(input, { target: { value: '123456' } });
|
||||
|
||||
const verifyButton = screen.getByRole('button', { name: /verify/i });
|
||||
fireEvent.click(verifyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Invalid verification code')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('navigates back to QR code step when Back clicked', () => {
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
|
||||
fireEvent.click(screen.getByText('Continue'));
|
||||
|
||||
const backButton = screen.getByRole('button', { name: /back/i });
|
||||
fireEvent.click(backButton);
|
||||
|
||||
expect(screen.getByText('Scan this QR code with your authenticator app')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Verifying... text when verification is pending', () => {
|
||||
mockVerifyTOTPPending = true;
|
||||
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
|
||||
fireEvent.click(screen.getByText('Continue'));
|
||||
|
||||
expect(screen.getByText('Verifying...')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders Get Started button', () => {
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
expect(screen.getByText('Get Started')).toBeInTheDocument();
|
||||
describe('Recovery Codes Step', () => {
|
||||
beforeEach(() => {
|
||||
mockSetupTOTPData = { qr_code: 'base64qr', secret: 'ABCD1234' };
|
||||
mockVerifyTOTPData = { recovery_codes: ['CODE1', 'CODE2', 'CODE3', 'CODE4', 'CODE5', 'CODE6'] };
|
||||
});
|
||||
|
||||
it('displays recovery codes after verification', () => {
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
|
||||
expect(screen.getByText('CODE1')).toBeInTheDocument();
|
||||
expect(screen.getByText('CODE2')).toBeInTheDocument();
|
||||
expect(screen.getByText('CODE3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows success message', () => {
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
|
||||
expect(screen.getByText('2FA Enabled Successfully!')).toBeInTheDocument();
|
||||
expect(screen.getByText('Save these recovery codes in a safe place')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows warning about recovery codes', () => {
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
|
||||
expect(screen.getByText(/Each code can only be used once/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('copies recovery codes to clipboard', async () => {
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
|
||||
const copyButton = screen.getByRole('button', { name: /copy/i });
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('CODE1\nCODE2\nCODE3\nCODE4\nCODE5\nCODE6');
|
||||
|
||||
// Check button text changes
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Copied!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Reverts after timeout
|
||||
vi.advanceTimersByTime(2000);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Copy')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('downloads recovery codes', () => {
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
|
||||
const downloadButton = screen.getByRole('button', { name: /download/i });
|
||||
fireEvent.click(downloadButton);
|
||||
|
||||
// Check that blob and download were triggered
|
||||
expect(global.URL.createObjectURL).toHaveBeenCalled();
|
||||
expect(global.URL.revokeObjectURL).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onSuccess and onClose when Done clicked', () => {
|
||||
const mockOnSuccess = vi.fn();
|
||||
const mockOnClose = vi.fn();
|
||||
|
||||
render(React.createElement(TwoFactorSetup, {
|
||||
...defaultProps,
|
||||
onSuccess: mockOnSuccess,
|
||||
onClose: mockOnClose
|
||||
}));
|
||||
|
||||
const doneButton = screen.getByRole('button', { name: /done/i });
|
||||
fireEvent.click(doneButton);
|
||||
|
||||
expect(mockOnSuccess).toHaveBeenCalled();
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows SMS Backup Not Available when phone not verified', () => {
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
expect(screen.getByText('SMS Backup Not Available')).toBeInTheDocument();
|
||||
describe('Disable 2FA Flow', () => {
|
||||
it('shows disable options when 2FA is enabled', () => {
|
||||
render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true }));
|
||||
|
||||
expect(screen.getByText('View Recovery Codes')).toBeInTheDocument();
|
||||
expect(screen.getByText('Disable Two-Factor Authentication')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders disable code input', () => {
|
||||
render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true }));
|
||||
|
||||
const input = screen.getByPlaceholderText('000000');
|
||||
expect(input).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters non-numeric input in disable code', () => {
|
||||
render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true }));
|
||||
|
||||
const input = screen.getByPlaceholderText('000000') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: 'abc123def' } });
|
||||
|
||||
expect(input.value).toBe('123');
|
||||
});
|
||||
|
||||
it('limits disable code to 6 digits', () => {
|
||||
render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true }));
|
||||
|
||||
const input = screen.getByPlaceholderText('000000') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: '1234567890' } });
|
||||
|
||||
expect(input.value).toBe('123456');
|
||||
});
|
||||
|
||||
it('disables button when code is incomplete', () => {
|
||||
render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true }));
|
||||
|
||||
const input = screen.getByPlaceholderText('000000');
|
||||
fireEvent.change(input, { target: { value: '12345' } });
|
||||
|
||||
const disableButton = screen.getByText('Disable Two-Factor Authentication');
|
||||
expect(disableButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables 2FA successfully', async () => {
|
||||
mockDisableTOTP.mockResolvedValue({});
|
||||
|
||||
const mockOnSuccess = vi.fn();
|
||||
const mockOnClose = vi.fn();
|
||||
|
||||
render(React.createElement(TwoFactorSetup, {
|
||||
...defaultProps,
|
||||
isEnabled: true,
|
||||
onSuccess: mockOnSuccess,
|
||||
onClose: mockOnClose
|
||||
}));
|
||||
|
||||
const input = screen.getByPlaceholderText('000000');
|
||||
fireEvent.change(input, { target: { value: '123456' } });
|
||||
|
||||
const disableButton = screen.getByText('Disable Two-Factor Authentication');
|
||||
fireEvent.click(disableButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDisableTOTP).toHaveBeenCalledWith('123456');
|
||||
expect(mockOnSuccess).toHaveBeenCalled();
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error when disable fails', async () => {
|
||||
mockDisableTOTP.mockRejectedValue({
|
||||
response: { data: { detail: 'Invalid code' } }
|
||||
});
|
||||
|
||||
render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true }));
|
||||
|
||||
const input = screen.getByPlaceholderText('000000');
|
||||
fireEvent.change(input, { target: { value: '123456' } });
|
||||
|
||||
const disableButton = screen.getByText('Disable Two-Factor Authentication');
|
||||
fireEvent.click(disableButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Invalid code')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows generic error when disable fails without detail', async () => {
|
||||
mockDisableTOTP.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true }));
|
||||
|
||||
const input = screen.getByPlaceholderText('000000');
|
||||
fireEvent.change(input, { target: { value: '123456' } });
|
||||
|
||||
const disableButton = screen.getByText('Disable Two-Factor Authentication');
|
||||
fireEvent.click(disableButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Invalid code')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows Disabling... text when disable is pending', () => {
|
||||
mockDisableTOTPPending = true;
|
||||
|
||||
render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true }));
|
||||
|
||||
expect(screen.getByText('Disabling...')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows SMS Backup Available when phone is verified', () => {
|
||||
render(React.createElement(TwoFactorSetup, { ...defaultProps, phoneVerified: true }));
|
||||
expect(screen.getByText('SMS Backup Available')).toBeInTheDocument();
|
||||
describe('View Recovery Codes Flow', () => {
|
||||
it('loads recovery codes when View Recovery Codes clicked', async () => {
|
||||
mockRecoveryCodesRefetch.mockResolvedValue({
|
||||
data: ['REC1', 'REC2', 'REC3', 'REC4', 'REC5', 'REC6']
|
||||
});
|
||||
|
||||
render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true }));
|
||||
|
||||
const viewButton = screen.getByText('View Recovery Codes');
|
||||
fireEvent.click(viewButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRecoveryCodesRefetch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error when loading recovery codes fails', async () => {
|
||||
mockRecoveryCodesRefetch.mockRejectedValue({
|
||||
response: { data: { detail: 'Failed to load' } }
|
||||
});
|
||||
|
||||
render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true }));
|
||||
|
||||
const viewButton = screen.getByText('View Recovery Codes');
|
||||
fireEvent.click(viewButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to load')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows generic error when loading fails without detail', async () => {
|
||||
mockRecoveryCodesRefetch.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true }));
|
||||
|
||||
const viewButton = screen.getByText('View Recovery Codes');
|
||||
fireEvent.click(viewButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to load recovery codes')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays recovery codes after loading', () => {
|
||||
mockRecoveryCodesData = ['REC1', 'REC2', 'REC3', 'REC4'];
|
||||
|
||||
render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true }));
|
||||
|
||||
expect(screen.getByText('REC1')).toBeInTheDocument();
|
||||
expect(screen.getByText('REC2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates back to disable step', () => {
|
||||
mockRecoveryCodesData = ['REC1', 'REC2'];
|
||||
|
||||
render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true }));
|
||||
|
||||
const backButton = screen.getByText('← Back');
|
||||
fireEvent.click(backButton);
|
||||
|
||||
expect(screen.getByText('Disable Two-Factor Authentication')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('copies recovery codes from view', async () => {
|
||||
mockRecoveryCodesData = ['REC1', 'REC2'];
|
||||
|
||||
render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true }));
|
||||
|
||||
const copyButton = screen.getByRole('button', { name: /copy/i });
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('REC1\nREC2');
|
||||
});
|
||||
|
||||
it('downloads recovery codes from view', () => {
|
||||
mockRecoveryCodesData = ['REC1', 'REC2'];
|
||||
|
||||
render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true }));
|
||||
|
||||
const downloadButton = screen.getByRole('button', { name: /download/i });
|
||||
fireEvent.click(downloadButton);
|
||||
|
||||
expect(global.URL.createObjectURL).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('regenerates recovery codes', async () => {
|
||||
mockRegenerateCodes.mockResolvedValue(['NEW1', 'NEW2']);
|
||||
mockRecoveryCodesData = ['REC1', 'REC2'];
|
||||
|
||||
render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true }));
|
||||
|
||||
const regenerateButton = screen.getByText('Regenerate Recovery Codes');
|
||||
fireEvent.click(regenerateButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRegenerateCodes).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error when regenerate fails', async () => {
|
||||
mockRegenerateCodes.mockRejectedValue({
|
||||
response: { data: { detail: 'Regenerate failed' } }
|
||||
});
|
||||
mockRecoveryCodesData = ['REC1', 'REC2'];
|
||||
|
||||
render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true }));
|
||||
|
||||
const regenerateButton = screen.getByText('Regenerate Recovery Codes');
|
||||
fireEvent.click(regenerateButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Regenerate failed')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows generic error when regenerate fails without detail', async () => {
|
||||
mockRegenerateCodes.mockRejectedValue(new Error('Network error'));
|
||||
mockRecoveryCodesData = ['REC1', 'REC2'];
|
||||
|
||||
render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true }));
|
||||
|
||||
const regenerateButton = screen.getByText('Regenerate Recovery Codes');
|
||||
fireEvent.click(regenerateButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to regenerate codes')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows Loading... text when fetching recovery codes', () => {
|
||||
mockRecoveryCodesFetching = true;
|
||||
|
||||
render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true }));
|
||||
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Regenerating... text when regenerating codes', () => {
|
||||
mockRecoveryCodesData = ['REC1', 'REC2'];
|
||||
mockRegenerateCodesPending = true;
|
||||
|
||||
render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true }));
|
||||
|
||||
expect(screen.getByText('Regenerating...')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows phone verification prompt when has phone but not verified', () => {
|
||||
render(React.createElement(TwoFactorSetup, { ...defaultProps, hasPhone: true }));
|
||||
expect(screen.getByText('Verify your phone number now')).toBeInTheDocument();
|
||||
});
|
||||
describe('Error Display', () => {
|
||||
it('shows error with alert icon', async () => {
|
||||
mockSetupTOTP.mockRejectedValue({
|
||||
response: { data: { detail: 'Test error' } }
|
||||
});
|
||||
|
||||
it('shows add phone prompt when no phone', () => {
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
expect(screen.getByText('Go to profile settings to add a phone number')).toBeInTheDocument();
|
||||
});
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
|
||||
it('renders View Recovery Codes option when enabled', () => {
|
||||
render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true }));
|
||||
expect(screen.getByText('View Recovery Codes')).toBeInTheDocument();
|
||||
});
|
||||
const getStartedButton = screen.getByText('Get Started');
|
||||
fireEvent.click(getStartedButton);
|
||||
|
||||
it('renders disable 2FA option when enabled', () => {
|
||||
render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true }));
|
||||
expect(screen.getByText('Disable Two-Factor Authentication')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders disable code input when enabled', () => {
|
||||
render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true }));
|
||||
expect(screen.getByPlaceholderText('000000')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Shield icon in header', () => {
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
const shieldIcon = document.querySelector('.lucide-shield');
|
||||
expect(shieldIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders smartphone icon in intro', () => {
|
||||
render(React.createElement(TwoFactorSetup, defaultProps));
|
||||
const smartphoneIcon = document.querySelector('.lucide-smartphone');
|
||||
expect(smartphoneIcon).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test error')).toBeInTheDocument();
|
||||
const alertIcon = document.querySelector('.lucide-alert-triangle');
|
||||
expect(alertIcon).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { CustomerPreview } from '../CustomerPreview';
|
||||
import { Service, Business } from '../../../types';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
// Mock Lucide icons
|
||||
vi.mock('lucide-react', () => ({
|
||||
Clock: () => <span data-testid="icon-clock" />,
|
||||
DollarSign: () => <span data-testid="icon-dollar-sign" />,
|
||||
Image: () => <span data-testid="icon-image" />,
|
||||
CheckCircle2: () => <span data-testid="icon-check-circle" />,
|
||||
AlertCircle: () => <span data-testid="icon-alert-circle" />,
|
||||
}));
|
||||
|
||||
// Mock Badge component
|
||||
vi.mock('../../ui/Badge', () => ({
|
||||
default: ({ children, variant, size }: any) =>
|
||||
<span data-testid={`badge-${variant}`} data-size={size}>{children}</span>,
|
||||
}));
|
||||
|
||||
describe('CustomerPreview', () => {
|
||||
const mockBusiness: Business = {
|
||||
id: 'business-1',
|
||||
name: 'Test Business',
|
||||
primaryColor: '#2563eb',
|
||||
secondaryColor: '#0ea5e9',
|
||||
} as Business;
|
||||
|
||||
const mockService: Service = {
|
||||
id: '1',
|
||||
name: 'Haircut',
|
||||
description: 'Professional haircut service',
|
||||
price: 50,
|
||||
durationMinutes: 60,
|
||||
photos: [],
|
||||
category: { id: 'cat1', name: 'Hair Services' },
|
||||
variable_pricing: false,
|
||||
} as Service;
|
||||
|
||||
const mockServiceWithPhoto: Service = {
|
||||
...mockService,
|
||||
photos: ['https://example.com/photo.jpg'],
|
||||
};
|
||||
|
||||
const mockServiceWithDeposit: Service = {
|
||||
...mockService,
|
||||
deposit_amount: 10,
|
||||
};
|
||||
|
||||
const mockServiceVariablePricing: Service = {
|
||||
...mockService,
|
||||
variable_pricing: true,
|
||||
deposit_amount: 25,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
service: mockService,
|
||||
business: mockBusiness,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders customer preview heading', () => {
|
||||
render(React.createElement(CustomerPreview, defaultProps));
|
||||
expect(screen.getByText('Customer Preview')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows live preview badge', () => {
|
||||
render(React.createElement(CustomerPreview, defaultProps));
|
||||
expect(screen.getByTestId('badge-info')).toBeInTheDocument();
|
||||
expect(screen.getByText('Live Preview')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays service name', () => {
|
||||
render(React.createElement(CustomerPreview, defaultProps));
|
||||
expect(screen.getByText('Haircut')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays service description', () => {
|
||||
render(React.createElement(CustomerPreview, defaultProps));
|
||||
expect(screen.getByText('Professional haircut service')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays service category', () => {
|
||||
render(React.createElement(CustomerPreview, defaultProps));
|
||||
expect(screen.getByText('Hair Services')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays duration', () => {
|
||||
render(React.createElement(CustomerPreview, defaultProps));
|
||||
expect(screen.getByText('60 mins')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows clock icon for duration', () => {
|
||||
render(React.createElement(CustomerPreview, defaultProps));
|
||||
expect(screen.getByTestId('icon-clock')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays price', () => {
|
||||
render(React.createElement(CustomerPreview, defaultProps));
|
||||
expect(screen.getByText('50')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows dollar sign icon for price', () => {
|
||||
render(React.createElement(CustomerPreview, defaultProps));
|
||||
expect(screen.getByTestId('icon-dollar-sign')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows image icon when no photos', () => {
|
||||
render(React.createElement(CustomerPreview, defaultProps));
|
||||
expect(screen.getByTestId('icon-image')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays photo when available', () => {
|
||||
const props = { ...defaultProps, service: mockServiceWithPhoto };
|
||||
render(React.createElement(CustomerPreview, props));
|
||||
|
||||
const img = document.querySelector('img');
|
||||
expect(img).toBeInTheDocument();
|
||||
expect(img).toHaveAttribute('src', 'https://example.com/photo.jpg');
|
||||
expect(img).toHaveAttribute('alt', 'Haircut');
|
||||
});
|
||||
|
||||
it('displays deposit requirement when set', () => {
|
||||
const props = { ...defaultProps, service: mockServiceWithDeposit };
|
||||
render(React.createElement(CustomerPreview, props));
|
||||
|
||||
expect(screen.getByText(/Deposit required:/)).toBeInTheDocument();
|
||||
expect(screen.getByText((content, element) => {
|
||||
return element?.textContent === 'Deposit required: $10';
|
||||
})).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show deposit when not required', () => {
|
||||
render(React.createElement(CustomerPreview, defaultProps));
|
||||
expect(screen.queryByText(/Deposit required:/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows variable pricing badge', () => {
|
||||
const props = { ...defaultProps, service: mockServiceVariablePricing };
|
||||
render(React.createElement(CustomerPreview, props));
|
||||
|
||||
expect(screen.getByText('Variable')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Price varies" text for variable pricing', () => {
|
||||
const props = { ...defaultProps, service: mockServiceVariablePricing };
|
||||
render(React.createElement(CustomerPreview, props));
|
||||
|
||||
expect(screen.getByText('Price varies')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows deposit for variable pricing services', () => {
|
||||
const props = { ...defaultProps, service: mockServiceVariablePricing };
|
||||
render(React.createElement(CustomerPreview, props));
|
||||
|
||||
expect(screen.getByText(/Deposit required:/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays info alert about preview', () => {
|
||||
render(React.createElement(CustomerPreview, defaultProps));
|
||||
expect(screen.getByTestId('icon-alert-circle')).toBeInTheDocument();
|
||||
expect(screen.getByText(/This is how your service will appear to customers/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles null service gracefully', () => {
|
||||
const props = { ...defaultProps, service: null };
|
||||
render(React.createElement(CustomerPreview, props));
|
||||
|
||||
expect(screen.getByText('New Service')).toBeInTheDocument();
|
||||
expect(screen.getByText('Service description will appear here...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays default category when not set', () => {
|
||||
const serviceWithoutCategory = { ...mockService, category: undefined };
|
||||
const props = { ...defaultProps, service: serviceWithoutCategory };
|
||||
render(React.createElement(CustomerPreview, props));
|
||||
|
||||
expect(screen.getByText('General')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses preview data when provided', () => {
|
||||
const previewData = {
|
||||
name: 'Custom Name',
|
||||
description: 'Custom Description',
|
||||
price: 75,
|
||||
durationMinutes: 90,
|
||||
};
|
||||
const props = { ...defaultProps, previewData };
|
||||
render(React.createElement(CustomerPreview, props));
|
||||
|
||||
expect(screen.getByText('Custom Name')).toBeInTheDocument();
|
||||
expect(screen.getByText('Custom Description')).toBeInTheDocument();
|
||||
expect(screen.getByText('75')).toBeInTheDocument();
|
||||
expect(screen.getByText('90 mins')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('preview data overrides service data', () => {
|
||||
const previewData = { name: 'Preview Name' };
|
||||
const props = { ...defaultProps, previewData };
|
||||
render(React.createElement(CustomerPreview, props));
|
||||
|
||||
// Should show preview data instead of service data
|
||||
expect(screen.getByText('Preview Name')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Haircut')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('formats price correctly', () => {
|
||||
const props = { ...defaultProps, service: { ...mockService, price: 123 } };
|
||||
render(React.createElement(CustomerPreview, props));
|
||||
|
||||
expect(screen.getByText('123')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles zero price', () => {
|
||||
const props = { ...defaultProps, service: { ...mockService, price: 0 } };
|
||||
render(React.createElement(CustomerPreview, props));
|
||||
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies business colors to gradient', () => {
|
||||
render(React.createElement(CustomerPreview, defaultProps));
|
||||
|
||||
// The gradient uses business colors in the style attribute
|
||||
const gradientDiv = document.querySelector('[style*="gradient"]');
|
||||
expect(gradientDiv).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays deposit with correct formatting', () => {
|
||||
const props = { ...defaultProps, service: { ...mockService, deposit_amount: 15 } };
|
||||
render(React.createElement(CustomerPreview, props));
|
||||
|
||||
expect(screen.getByText((content, element) => {
|
||||
return element?.textContent === 'Deposit required: $15';
|
||||
})).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows default duration when not set', () => {
|
||||
const serviceWithoutDuration = { ...mockService, durationMinutes: undefined };
|
||||
const props = { ...defaultProps, service: serviceWithoutDuration };
|
||||
render(React.createElement(CustomerPreview, props));
|
||||
|
||||
expect(screen.getByText('30 mins')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays border styling to indicate selected preview', () => {
|
||||
render(React.createElement(CustomerPreview, defaultProps));
|
||||
|
||||
const card = document.querySelector('.border-brand-600');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays ring styling', () => {
|
||||
render(React.createElement(CustomerPreview, defaultProps));
|
||||
|
||||
const card = document.querySelector('.ring-brand-600');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles preview data with partial updates', () => {
|
||||
const previewData = { price: 99 };
|
||||
const props = { ...defaultProps, previewData };
|
||||
render(React.createElement(CustomerPreview, props));
|
||||
|
||||
// Name should still be from service
|
||||
expect(screen.getByText('Haircut')).toBeInTheDocument();
|
||||
// Price should be from previewData
|
||||
expect(screen.getByText('99')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('merges photos from preview data', () => {
|
||||
const previewData = { photos: ['https://preview.com/new.jpg'] };
|
||||
const props = { ...defaultProps, previewData };
|
||||
render(React.createElement(CustomerPreview, props));
|
||||
|
||||
const img = document.querySelector('img');
|
||||
expect(img).toHaveAttribute('src', 'https://preview.com/new.jpg');
|
||||
});
|
||||
|
||||
it('handles empty photos array', () => {
|
||||
const props = { ...defaultProps, service: { ...mockService, photos: [] } };
|
||||
render(React.createElement(CustomerPreview, props));
|
||||
|
||||
expect(screen.getByTestId('icon-image')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses first photo only for cover', () => {
|
||||
const serviceWithMultiplePhotos = {
|
||||
...mockService,
|
||||
photos: ['https://first.com/1.jpg', 'https://second.com/2.jpg'],
|
||||
};
|
||||
const props = { ...defaultProps, service: serviceWithMultiplePhotos };
|
||||
render(React.createElement(CustomerPreview, props));
|
||||
|
||||
const img = document.querySelector('img');
|
||||
expect(img).toHaveAttribute('src', 'https://first.com/1.jpg');
|
||||
});
|
||||
|
||||
it('displays horizontal card layout', () => {
|
||||
render(React.createElement(CustomerPreview, defaultProps));
|
||||
|
||||
const cardContainer = document.querySelector('.flex.h-full');
|
||||
expect(cardContainer).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { ResourceSelector } from '../ResourceSelector';
|
||||
import { Resource } from '../../../types';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
// Mock Lucide icons
|
||||
vi.mock('lucide-react', () => ({
|
||||
Users: () => <span data-testid="icon-users" />,
|
||||
Search: () => <span data-testid="icon-search" />,
|
||||
Check: () => <span data-testid="icon-check" />,
|
||||
X: () => <span data-testid="icon-x" />,
|
||||
AlertCircle: () => <span data-testid="icon-alert-circle" />,
|
||||
}));
|
||||
|
||||
describe('ResourceSelector', () => {
|
||||
const mockResources: Resource[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'John Doe',
|
||||
type: 'STAFF',
|
||||
} as Resource,
|
||||
{
|
||||
id: '2',
|
||||
name: 'Jane Smith',
|
||||
type: 'STAFF',
|
||||
} as Resource,
|
||||
{
|
||||
id: '3',
|
||||
name: 'Bob Johnson',
|
||||
type: 'STAFF',
|
||||
} as Resource,
|
||||
];
|
||||
|
||||
const defaultProps = {
|
||||
resources: mockResources,
|
||||
selectedIds: [],
|
||||
allSelected: false,
|
||||
onChange: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the all staff toggle', () => {
|
||||
render(React.createElement(ResourceSelector, defaultProps));
|
||||
expect(screen.getByText('All Staff Available')).toBeInTheDocument();
|
||||
expect(screen.getByText('Automatically include current and future staff')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows users icon', () => {
|
||||
render(React.createElement(ResourceSelector, defaultProps));
|
||||
expect(screen.getByTestId('icon-users')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays toggle switch', () => {
|
||||
render(React.createElement(ResourceSelector, defaultProps));
|
||||
const checkbox = document.querySelector('input[type="checkbox"]');
|
||||
expect(checkbox).toBeInTheDocument();
|
||||
expect(checkbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('shows staff list when allSelected is false', () => {
|
||||
render(React.createElement(ResourceSelector, defaultProps));
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bob Johnson')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides staff list when allSelected is true', () => {
|
||||
const props = { ...defaultProps, allSelected: true };
|
||||
render(React.createElement(ResourceSelector, props));
|
||||
expect(screen.queryByText('John Doe')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onChange with all=true when toggling on', () => {
|
||||
const onChange = vi.fn();
|
||||
const props = { ...defaultProps, onChange };
|
||||
render(React.createElement(ResourceSelector, props));
|
||||
|
||||
const checkbox = document.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([], true);
|
||||
});
|
||||
|
||||
it('calls onChange with all=false when toggling off', () => {
|
||||
const onChange = vi.fn();
|
||||
const props = { ...defaultProps, onChange, allSelected: true };
|
||||
render(React.createElement(ResourceSelector, props));
|
||||
|
||||
const checkbox = document.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([], false);
|
||||
});
|
||||
|
||||
it('displays search input', () => {
|
||||
render(React.createElement(ResourceSelector, defaultProps));
|
||||
expect(screen.getByPlaceholderText('Search staff...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows search icon', () => {
|
||||
render(React.createElement(ResourceSelector, defaultProps));
|
||||
expect(screen.getByTestId('icon-search')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters resources by search term', () => {
|
||||
render(React.createElement(ResourceSelector, defaultProps));
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search staff...');
|
||||
fireEvent.change(searchInput, { target: { value: 'Doe' } });
|
||||
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Jane Smith')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Bob Johnson')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('search is case insensitive', () => {
|
||||
render(React.createElement(ResourceSelector, defaultProps));
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search staff...');
|
||||
fireEvent.change(searchInput, { target: { value: 'JANE' } });
|
||||
|
||||
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
|
||||
expect(screen.queryByText('John Doe')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no results message when search has no matches', () => {
|
||||
render(React.createElement(ResourceSelector, defaultProps));
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search staff...');
|
||||
fireEvent.change(searchInput, { target: { value: 'Nonexistent' } });
|
||||
|
||||
expect(screen.getByText('No staff found matching "Nonexistent"')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays resource initials in avatar', () => {
|
||||
render(React.createElement(ResourceSelector, defaultProps));
|
||||
|
||||
const initials = screen.getAllByText('J'); // John and Jane both start with J
|
||||
expect(initials.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('calls onChange when clicking a resource', () => {
|
||||
const onChange = vi.fn();
|
||||
const props = { ...defaultProps, onChange };
|
||||
render(React.createElement(ResourceSelector, props));
|
||||
|
||||
const resource = screen.getByText('John Doe').closest('button');
|
||||
fireEvent.click(resource!);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(['1'], false);
|
||||
});
|
||||
|
||||
it('adds resource to selection when not selected', () => {
|
||||
const onChange = vi.fn();
|
||||
const props = { ...defaultProps, onChange, selectedIds: ['2'] };
|
||||
render(React.createElement(ResourceSelector, props));
|
||||
|
||||
const resource = screen.getByText('John Doe').closest('button');
|
||||
fireEvent.click(resource!);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(['2', '1'], false);
|
||||
});
|
||||
|
||||
it('removes resource from selection when already selected', () => {
|
||||
const onChange = vi.fn();
|
||||
const props = { ...defaultProps, onChange, selectedIds: ['1', '2'] };
|
||||
render(React.createElement(ResourceSelector, props));
|
||||
|
||||
const resource = screen.getByText('John Doe').closest('button');
|
||||
fireEvent.click(resource!);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(['2'], false);
|
||||
});
|
||||
|
||||
it('highlights selected resources', () => {
|
||||
const props = { ...defaultProps, selectedIds: ['1'] };
|
||||
render(React.createElement(ResourceSelector, props));
|
||||
|
||||
const resource = screen.getByText('John Doe').closest('button');
|
||||
expect(resource).toHaveClass('bg-brand-50');
|
||||
});
|
||||
|
||||
it('shows check icon for selected resources', () => {
|
||||
const props = { ...defaultProps, selectedIds: ['1'] };
|
||||
render(React.createElement(ResourceSelector, props));
|
||||
|
||||
expect(screen.getByTestId('icon-check')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not toggle when allSelected is true', () => {
|
||||
const onChange = vi.fn();
|
||||
const props = { ...defaultProps, onChange, allSelected: true };
|
||||
render(React.createElement(ResourceSelector, props));
|
||||
|
||||
// The resource list should be hidden, so we can't click on resources
|
||||
expect(screen.queryByText('John Doe')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays selection count', () => {
|
||||
const props = { ...defaultProps, selectedIds: ['1', '2'] };
|
||||
render(React.createElement(ResourceSelector, props));
|
||||
|
||||
expect(screen.getByText('2 staff selected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows warning when no staff selected', () => {
|
||||
render(React.createElement(ResourceSelector, defaultProps));
|
||||
|
||||
expect(screen.getByText('At least one required')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('icon-alert-circle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show warning when staff are selected', () => {
|
||||
const props = { ...defaultProps, selectedIds: ['1'] };
|
||||
render(React.createElement(ResourceSelector, props));
|
||||
|
||||
expect(screen.queryByText('At least one required')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays all resources initially', () => {
|
||||
render(React.createElement(ResourceSelector, defaultProps));
|
||||
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bob Johnson')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles empty resources array', () => {
|
||||
const props = { ...defaultProps, resources: [] };
|
||||
render(React.createElement(ResourceSelector, props));
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search staff...');
|
||||
fireEvent.change(searchInput, { target: { value: 'test' } });
|
||||
|
||||
expect(screen.getByText('No staff found matching "test"')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears search when typing new term', () => {
|
||||
render(React.createElement(ResourceSelector, defaultProps));
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search staff...') as HTMLInputElement;
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: 'John' } });
|
||||
expect(searchInput.value).toBe('John');
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: '' } });
|
||||
expect(searchInput.value).toBe('');
|
||||
|
||||
// All resources visible again
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggle switch is checked when allSelected is true', () => {
|
||||
const props = { ...defaultProps, allSelected: true };
|
||||
render(React.createElement(ResourceSelector, props));
|
||||
|
||||
const checkbox = document.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
expect(checkbox).toBeChecked();
|
||||
});
|
||||
|
||||
it('shows correct selection count for multiple selections', () => {
|
||||
const props = { ...defaultProps, selectedIds: ['1', '2', '3'] };
|
||||
render(React.createElement(ResourceSelector, props));
|
||||
|
||||
expect(screen.getByText('3 staff selected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows correct selection count for single selection', () => {
|
||||
const props = { ...defaultProps, selectedIds: ['1'] };
|
||||
render(React.createElement(ResourceSelector, props));
|
||||
|
||||
expect(screen.getByText('1 staff selected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates display when selectedIds prop changes', () => {
|
||||
const { rerender } = render(React.createElement(ResourceSelector, defaultProps));
|
||||
|
||||
expect(screen.getByText('0 staff selected')).toBeInTheDocument();
|
||||
|
||||
const newProps = { ...defaultProps, selectedIds: ['1', '2'] };
|
||||
rerender(React.createElement(ResourceSelector, newProps));
|
||||
|
||||
expect(screen.getByText('2 staff selected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('resource avatars have different styling when selected', () => {
|
||||
const props = { ...defaultProps, selectedIds: ['1'] };
|
||||
render(React.createElement(ResourceSelector, props));
|
||||
|
||||
const selectedButton = screen.getByText('John Doe').closest('button');
|
||||
const unselectedButton = screen.getByText('Jane Smith').closest('button');
|
||||
|
||||
expect(selectedButton).toHaveClass('bg-brand-50');
|
||||
expect(unselectedButton).not.toHaveClass('bg-brand-50');
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,8 @@ export interface PermissionSectionProps {
|
||||
variant?: 'default' | 'settings' | 'dangerous';
|
||||
readOnly?: boolean;
|
||||
columns?: 1 | 2;
|
||||
lockedPermissions?: Record<string, string>; // key -> reason (forced on)
|
||||
disabledPermissions?: Record<string, string>; // key -> reason (grayed out until parent enabled)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,6 +38,8 @@ export const PermissionSection: React.FC<PermissionSectionProps> = ({
|
||||
variant = 'default',
|
||||
readOnly = false,
|
||||
columns = 2,
|
||||
lockedPermissions = {},
|
||||
disabledPermissions = {},
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -51,9 +55,9 @@ export const PermissionSection: React.FC<PermissionSectionProps> = ({
|
||||
hover: 'hover:bg-blue-100/50 dark:hover:bg-blue-900/20',
|
||||
},
|
||||
dangerous: {
|
||||
container: 'p-3 bg-red-50/50 dark:bg-red-900/10 rounded-lg border border-red-100 dark:border-red-900/30',
|
||||
container: 'p-3 bg-red-100 dark:bg-red-900/30 rounded-lg border border-red-200 dark:border-red-800/50',
|
||||
checkbox: 'text-red-600 focus:ring-red-500',
|
||||
hover: 'hover:bg-red-100/50 dark:hover:bg-red-900/20',
|
||||
hover: 'hover:bg-red-200/70 dark:hover:bg-red-900/40',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -105,6 +109,10 @@ export const PermissionSection: React.FC<PermissionSectionProps> = ({
|
||||
checkboxClass={styles.checkbox}
|
||||
hoverClass={styles.hover}
|
||||
readOnly={readOnly}
|
||||
locked={!!lockedPermissions[key]}
|
||||
lockedReason={lockedPermissions[key]}
|
||||
disabled={!!disabledPermissions[key]}
|
||||
disabledReason={disabledPermissions[key]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -120,6 +128,10 @@ interface PermissionCheckboxProps {
|
||||
checkboxClass?: string;
|
||||
hoverClass?: string;
|
||||
readOnly?: boolean;
|
||||
locked?: boolean;
|
||||
lockedReason?: string;
|
||||
disabled?: boolean;
|
||||
disabledReason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,21 +145,34 @@ export const PermissionCheckbox: React.FC<PermissionCheckboxProps> = ({
|
||||
checkboxClass = 'text-brand-600 focus:ring-brand-500',
|
||||
hoverClass = 'hover:bg-gray-50 dark:hover:bg-gray-700/50',
|
||||
readOnly = false,
|
||||
locked = false,
|
||||
lockedReason,
|
||||
disabled = false,
|
||||
disabledReason,
|
||||
}) => {
|
||||
const isDisabled = readOnly || locked || disabled;
|
||||
const tooltipReason = locked ? lockedReason : disabled ? disabledReason : undefined;
|
||||
|
||||
return (
|
||||
<label
|
||||
className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer ${readOnly ? 'opacity-60 cursor-default' : hoverClass}`}
|
||||
className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer ${isDisabled ? 'opacity-60 cursor-default' : hoverClass}`}
|
||||
title={tooltipReason}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
disabled={readOnly}
|
||||
disabled={isDisabled}
|
||||
className={`w-4 h-4 border-gray-300 dark:border-gray-600 rounded ${checkboxClass} disabled:opacity-50`}
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white flex items-center gap-1.5">
|
||||
{definition.label}
|
||||
{locked && (
|
||||
<span className="text-[10px] text-gray-400 dark:text-gray-500 font-normal">
|
||||
(required)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{definition.description}
|
||||
@@ -198,6 +223,21 @@ export const RolePermissionsEditor: React.FC<RolePermissionsEditorProps> = ({
|
||||
});
|
||||
}
|
||||
|
||||
// Schedule editing permissions are linked:
|
||||
// - If enabling "edit others' schedules", also enable "edit own schedule"
|
||||
// - "edit own schedule" can be disabled independently only if "edit others'" is off
|
||||
if (value && key === 'can_edit_others_schedules') {
|
||||
updates['can_edit_own_schedule'] = true;
|
||||
}
|
||||
|
||||
// Prevent disabling "edit own schedule" if "edit others' schedules" is enabled
|
||||
if (!value && key === 'can_edit_own_schedule') {
|
||||
if (permissions['can_edit_others_schedules']) {
|
||||
// Keep it enabled - can't disable own schedule editing while others' is enabled
|
||||
updates['can_edit_own_schedule'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
onChange({ ...permissions, ...updates });
|
||||
};
|
||||
|
||||
@@ -213,9 +253,39 @@ export const RolePermissionsEditor: React.FC<RolePermissionsEditorProps> = ({
|
||||
updates['can_access_settings'] = true;
|
||||
}
|
||||
|
||||
// If enabling menu permissions including edit_others, ensure edit_own is also enabled
|
||||
if (category === 'menu' && enable && updates['can_edit_others_schedules']) {
|
||||
updates['can_edit_own_schedule'] = true;
|
||||
}
|
||||
|
||||
onChange({ ...permissions, ...updates });
|
||||
};
|
||||
|
||||
// Calculate which permissions are locked (cannot be unchecked due to dependencies)
|
||||
const lockedPermissions: Record<string, string> = {};
|
||||
|
||||
// "Edit Own Schedule" is locked when "Edit Others' Schedules" is enabled
|
||||
if (permissions['can_edit_others_schedules']) {
|
||||
lockedPermissions['can_edit_own_schedule'] = t(
|
||||
'settings.staffRoles.lockedByEditOthers',
|
||||
'Required when "Edit Others\' Schedules" is enabled'
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate which settings permissions are disabled (require "Access Settings" to be enabled first)
|
||||
const disabledSettingsPermissions: Record<string, string> = {};
|
||||
if (!permissions['can_access_settings']) {
|
||||
// Disable all settings sub-permissions when main settings access is off
|
||||
Object.keys(availablePermissions.settings).forEach((key) => {
|
||||
if (key !== 'can_access_settings') {
|
||||
disabledSettingsPermissions[key] = t(
|
||||
'settings.staffRoles.requiresAccessSettings',
|
||||
'Enable "Access Settings" first'
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Menu Permissions */}
|
||||
@@ -230,6 +300,7 @@ export const RolePermissionsEditor: React.FC<RolePermissionsEditorProps> = ({
|
||||
variant="default"
|
||||
readOnly={readOnly}
|
||||
columns={columns}
|
||||
lockedPermissions={lockedPermissions}
|
||||
/>
|
||||
|
||||
{/* Settings Permissions */}
|
||||
@@ -244,6 +315,8 @@ export const RolePermissionsEditor: React.FC<RolePermissionsEditorProps> = ({
|
||||
variant="settings"
|
||||
readOnly={readOnly}
|
||||
columns={columns}
|
||||
lockedPermissions={lockedPermissions}
|
||||
disabledPermissions={disabledSettingsPermissions}
|
||||
/>
|
||||
|
||||
{/* Dangerous Permissions */}
|
||||
@@ -256,6 +329,7 @@ export const RolePermissionsEditor: React.FC<RolePermissionsEditorProps> = ({
|
||||
onSelectAll={() => toggleAllInCategory('dangerous', true)}
|
||||
onClearAll={() => toggleAllInCategory('dangerous', false)}
|
||||
variant="dangerous"
|
||||
lockedPermissions={lockedPermissions}
|
||||
readOnly={readOnly}
|
||||
columns={columns}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,628 @@
|
||||
/**
|
||||
* Unit tests for TimeBlockCalendarOverlay component
|
||||
*
|
||||
* Tests cover:
|
||||
* - Component rendering with blocked ranges
|
||||
* - Business-level vs resource-level block styling
|
||||
* - Hard block vs soft block visual differences
|
||||
* - Tooltip display on hover
|
||||
* - Multi-day range handling
|
||||
* - Overlay positioning and width calculation
|
||||
* - Click handlers for day navigation
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import TimeBlockCalendarOverlay from '../TimeBlockCalendarOverlay';
|
||||
import { BlockedRange, BlockType, BlockPurpose } from '../../../types';
|
||||
|
||||
describe('TimeBlockCalendarOverlay', () => {
|
||||
const mockOnDayClick = vi.fn();
|
||||
|
||||
const defaultProps = {
|
||||
blockedRanges: [] as BlockedRange[],
|
||||
resourceId: 'resource-1',
|
||||
viewDate: new Date('2025-12-26T00:00:00'),
|
||||
zoomLevel: 1,
|
||||
pixelsPerMinute: 2,
|
||||
startHour: 0,
|
||||
dayWidth: 200,
|
||||
laneHeight: 60,
|
||||
days: [
|
||||
new Date('2025-12-26T00:00:00'),
|
||||
new Date('2025-12-27T00:00:00'),
|
||||
new Date('2025-12-28T00:00:00'),
|
||||
],
|
||||
onDayClick: mockOnDayClick,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Component Rendering', () => {
|
||||
it('should render without crashing when no blocked ranges', () => {
|
||||
const { container } = render(<TimeBlockCalendarOverlay {...defaultProps} />);
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with empty blocked ranges array', () => {
|
||||
const { container } = render(
|
||||
<TimeBlockCalendarOverlay {...defaultProps} blockedRanges={[]} />
|
||||
);
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render any overlays when blockedRanges is empty', () => {
|
||||
const { container } = render(<TimeBlockCalendarOverlay {...defaultProps} />);
|
||||
const overlays = container.querySelectorAll('[style*="position: absolute"]');
|
||||
expect(overlays.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Business-level Blocks', () => {
|
||||
it('should render business-level block with gray background', () => {
|
||||
const businessBlock: BlockedRange = {
|
||||
start: '2025-12-26T09:00:00',
|
||||
end: '2025-12-26T17:00:00',
|
||||
block_type: 'HARD',
|
||||
purpose: 'CLOSURE',
|
||||
title: 'Business Closed',
|
||||
resource_id: null, // Business-level
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<TimeBlockCalendarOverlay {...defaultProps} blockedRanges={[businessBlock]} />
|
||||
);
|
||||
|
||||
const overlay = container.querySelector('[style*="position: absolute"]');
|
||||
expect(overlay).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render business-level block without resource badge', () => {
|
||||
const businessBlock: BlockedRange = {
|
||||
start: '2025-12-26T09:00:00',
|
||||
end: '2025-12-26T17:00:00',
|
||||
block_type: 'HARD',
|
||||
purpose: 'CLOSURE',
|
||||
title: 'Business Closed',
|
||||
resource_id: null,
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<TimeBlockCalendarOverlay {...defaultProps} blockedRanges={[businessBlock]} />
|
||||
);
|
||||
|
||||
// Should not have the "R" badge for resource blocks
|
||||
const badge = container.querySelector('.bg-purple-600');
|
||||
expect(badge).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply business-level blocks to all resources', () => {
|
||||
const businessBlock: BlockedRange = {
|
||||
start: '2025-12-26T09:00:00',
|
||||
end: '2025-12-26T17:00:00',
|
||||
block_type: 'HARD',
|
||||
purpose: 'CLOSURE',
|
||||
title: 'Company Holiday',
|
||||
resource_id: null,
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<TimeBlockCalendarOverlay {...defaultProps} blockedRanges={[businessBlock]} />
|
||||
);
|
||||
|
||||
const overlays = container.querySelectorAll('[style*="position: absolute"]');
|
||||
expect(overlays.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resource-level Blocks', () => {
|
||||
it('should render resource-level hard block with purple stripes', () => {
|
||||
const resourceBlock: BlockedRange = {
|
||||
start: '2025-12-26T10:00:00',
|
||||
end: '2025-12-26T12:00:00',
|
||||
block_type: 'HARD',
|
||||
purpose: 'UNAVAILABLE',
|
||||
title: 'Staff Vacation',
|
||||
resource_id: 'resource-1',
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<TimeBlockCalendarOverlay {...defaultProps} blockedRanges={[resourceBlock]} />
|
||||
);
|
||||
|
||||
const overlay = container.querySelector('[style*="position: absolute"]');
|
||||
expect(overlay).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render resource-level soft block with cyan background', () => {
|
||||
const softBlock: BlockedRange = {
|
||||
start: '2025-12-26T14:00:00',
|
||||
end: '2025-12-26T15:00:00',
|
||||
block_type: 'SOFT',
|
||||
purpose: 'UNAVAILABLE',
|
||||
title: 'Preferred Off',
|
||||
resource_id: 'resource-1',
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<TimeBlockCalendarOverlay {...defaultProps} blockedRanges={[softBlock]} />
|
||||
);
|
||||
|
||||
const overlay = container.querySelector('[style*="position: absolute"]');
|
||||
expect(overlay).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show resource badge on resource-level blocks', () => {
|
||||
const resourceBlock: BlockedRange = {
|
||||
start: '2025-12-26T10:00:00',
|
||||
end: '2025-12-26T12:00:00',
|
||||
block_type: 'HARD',
|
||||
purpose: 'UNAVAILABLE',
|
||||
title: 'Staff Meeting',
|
||||
resource_id: 'resource-1',
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<TimeBlockCalendarOverlay {...defaultProps} blockedRanges={[resourceBlock]} />
|
||||
);
|
||||
|
||||
const badge = container.querySelector('.bg-purple-600');
|
||||
expect(badge).toBeInTheDocument();
|
||||
expect(badge?.textContent).toBe('R');
|
||||
});
|
||||
|
||||
it('should filter blocks by resource ID', () => {
|
||||
const blocks: BlockedRange[] = [
|
||||
{
|
||||
start: '2025-12-26T09:00:00',
|
||||
end: '2025-12-26T10:00:00',
|
||||
block_type: 'HARD',
|
||||
purpose: 'UNAVAILABLE',
|
||||
title: 'Resource 1 Block',
|
||||
resource_id: 'resource-1',
|
||||
},
|
||||
{
|
||||
start: '2025-12-26T11:00:00',
|
||||
end: '2025-12-26T12:00:00',
|
||||
block_type: 'HARD',
|
||||
purpose: 'UNAVAILABLE',
|
||||
title: 'Resource 2 Block',
|
||||
resource_id: 'resource-2',
|
||||
},
|
||||
];
|
||||
|
||||
const { container } = render(
|
||||
<TimeBlockCalendarOverlay {...defaultProps} blockedRanges={blocks} />
|
||||
);
|
||||
|
||||
// Should only render one overlay for resource-1
|
||||
const overlays = container.querySelectorAll('[style*="position: absolute"]');
|
||||
expect(overlays.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multi-day Ranges', () => {
|
||||
it('should render block spanning multiple days', () => {
|
||||
const multiDayBlock: BlockedRange = {
|
||||
start: '2025-12-26T14:00:00',
|
||||
end: '2025-12-27T10:00:00',
|
||||
block_type: 'HARD',
|
||||
purpose: 'UNAVAILABLE',
|
||||
title: 'Extended Leave',
|
||||
resource_id: 'resource-1',
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<TimeBlockCalendarOverlay {...defaultProps} blockedRanges={[multiDayBlock]} />
|
||||
);
|
||||
|
||||
const overlays = container.querySelectorAll('[style*="position: absolute"]');
|
||||
// Should create separate overlays for each day
|
||||
expect(overlays.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should handle blocks starting before view range', () => {
|
||||
const block: BlockedRange = {
|
||||
start: '2025-12-25T20:00:00', // Before first day
|
||||
end: '2025-12-26T10:00:00',
|
||||
block_type: 'HARD',
|
||||
purpose: 'CLOSURE',
|
||||
title: 'Overnight Closure',
|
||||
resource_id: null,
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<TimeBlockCalendarOverlay {...defaultProps} blockedRanges={[block]} />
|
||||
);
|
||||
|
||||
const overlays = container.querySelectorAll('[style*="position: absolute"]');
|
||||
expect(overlays.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should handle blocks ending after view range', () => {
|
||||
const block: BlockedRange = {
|
||||
start: '2025-12-28T20:00:00',
|
||||
end: '2025-12-29T10:00:00', // After last day
|
||||
block_type: 'HARD',
|
||||
purpose: 'CLOSURE',
|
||||
title: 'Extended Closure',
|
||||
resource_id: null,
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<TimeBlockCalendarOverlay {...defaultProps} blockedRanges={[block]} />
|
||||
);
|
||||
|
||||
const overlays = container.querySelectorAll('[style*="position: absolute"]');
|
||||
expect(overlays.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tooltip Display', () => {
|
||||
it('should show tooltip on mouse enter', async () => {
|
||||
const user = userEvent.setup();
|
||||
const block: BlockedRange = {
|
||||
start: '2025-12-26T09:00:00',
|
||||
end: '2025-12-26T10:00:00',
|
||||
block_type: 'HARD',
|
||||
purpose: 'CLOSURE',
|
||||
title: 'Holiday',
|
||||
resource_id: null,
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<TimeBlockCalendarOverlay {...defaultProps} blockedRanges={[block]} />
|
||||
);
|
||||
|
||||
const overlay = container.querySelector('[style*="position: absolute"]');
|
||||
expect(overlay).toBeInTheDocument();
|
||||
|
||||
if (overlay) {
|
||||
await user.hover(overlay as HTMLElement);
|
||||
|
||||
await waitFor(() => {
|
||||
const tooltip = container.querySelector('.fixed.z-\\[100\\]');
|
||||
expect(tooltip).toBeInTheDocument();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should display block title in tooltip', async () => {
|
||||
const user = userEvent.setup();
|
||||
const block: BlockedRange = {
|
||||
start: '2025-12-26T09:00:00',
|
||||
end: '2025-12-26T10:00:00',
|
||||
block_type: 'HARD',
|
||||
purpose: 'CLOSURE',
|
||||
title: 'Christmas Day',
|
||||
resource_id: null,
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<TimeBlockCalendarOverlay {...defaultProps} blockedRanges={[block]} />
|
||||
);
|
||||
|
||||
const overlay = container.querySelector('[style*="position: absolute"]');
|
||||
if (overlay) {
|
||||
await user.hover(overlay as HTMLElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Christmas Day')).toBeInTheDocument();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should hide tooltip on mouse leave', async () => {
|
||||
const user = userEvent.setup();
|
||||
const block: BlockedRange = {
|
||||
start: '2025-12-26T09:00:00',
|
||||
end: '2025-12-26T10:00:00',
|
||||
block_type: 'HARD',
|
||||
purpose: 'CLOSURE',
|
||||
title: 'Holiday',
|
||||
resource_id: null,
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<TimeBlockCalendarOverlay {...defaultProps} blockedRanges={[block]} />
|
||||
);
|
||||
|
||||
const overlay = container.querySelector('[style*="position: absolute"]');
|
||||
if (overlay) {
|
||||
await user.hover(overlay as HTMLElement);
|
||||
await waitFor(() => {
|
||||
const tooltip = container.querySelector('.fixed.z-\\[100\\]');
|
||||
expect(tooltip).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.unhover(overlay as HTMLElement);
|
||||
await waitFor(() => {
|
||||
const tooltip = container.querySelector('.fixed.z-\\[100\\]');
|
||||
expect(tooltip).not.toBeInTheDocument();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should display block type in tooltip', async () => {
|
||||
const user = userEvent.setup();
|
||||
const block: BlockedRange = {
|
||||
start: '2025-12-26T09:00:00',
|
||||
end: '2025-12-26T10:00:00',
|
||||
block_type: 'SOFT',
|
||||
purpose: 'UNAVAILABLE',
|
||||
title: 'Lunch Break',
|
||||
resource_id: 'resource-1',
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<TimeBlockCalendarOverlay {...defaultProps} blockedRanges={[block]} />
|
||||
);
|
||||
|
||||
const overlay = container.querySelector('[style*="position: absolute"]');
|
||||
if (overlay) {
|
||||
await user.hover(overlay as HTMLElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/Soft Block/i)).toBeInTheDocument();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Click Handlers', () => {
|
||||
it('should call onDayClick when overlay is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const block: BlockedRange = {
|
||||
start: '2025-12-26T09:00:00',
|
||||
end: '2025-12-26T10:00:00',
|
||||
block_type: 'HARD',
|
||||
purpose: 'CLOSURE',
|
||||
title: 'Holiday',
|
||||
resource_id: null,
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<TimeBlockCalendarOverlay {...defaultProps} blockedRanges={[block]} />
|
||||
);
|
||||
|
||||
const overlay = container.querySelector('[style*="position: absolute"]');
|
||||
if (overlay) {
|
||||
await user.click(overlay as HTMLElement);
|
||||
expect(mockOnDayClick).toHaveBeenCalledWith(defaultProps.days[0]);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not call onDayClick when handler is not provided', async () => {
|
||||
const user = userEvent.setup();
|
||||
const block: BlockedRange = {
|
||||
start: '2025-12-26T09:00:00',
|
||||
end: '2025-12-26T10:00:00',
|
||||
block_type: 'HARD',
|
||||
purpose: 'CLOSURE',
|
||||
title: 'Holiday',
|
||||
resource_id: null,
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<TimeBlockCalendarOverlay {...defaultProps} onDayClick={undefined} blockedRanges={[block]} />
|
||||
);
|
||||
|
||||
const overlay = container.querySelector('[style*="position: absolute"]');
|
||||
if (overlay) {
|
||||
await user.click(overlay as HTMLElement);
|
||||
expect(mockOnDayClick).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('should apply pointer cursor when onDayClick is provided', () => {
|
||||
const block: BlockedRange = {
|
||||
start: '2025-12-26T09:00:00',
|
||||
end: '2025-12-26T10:00:00',
|
||||
block_type: 'HARD',
|
||||
purpose: 'CLOSURE',
|
||||
title: 'Holiday',
|
||||
resource_id: null,
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<TimeBlockCalendarOverlay {...defaultProps} blockedRanges={[block]} />
|
||||
);
|
||||
|
||||
const overlay = container.querySelector('[style*="position: absolute"]') as HTMLElement;
|
||||
expect(overlay?.style.cursor).toBe('pointer');
|
||||
});
|
||||
|
||||
it('should apply default cursor when onDayClick is not provided', () => {
|
||||
const block: BlockedRange = {
|
||||
start: '2025-12-26T09:00:00',
|
||||
end: '2025-12-26T10:00:00',
|
||||
block_type: 'HARD',
|
||||
purpose: 'CLOSURE',
|
||||
title: 'Holiday',
|
||||
resource_id: null,
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<TimeBlockCalendarOverlay {...defaultProps} onDayClick={undefined} blockedRanges={[block]} />
|
||||
);
|
||||
|
||||
const overlay = container.querySelector('[style*="position: absolute"]') as HTMLElement;
|
||||
expect(overlay?.style.cursor).toBe('default');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Overlay Positioning', () => {
|
||||
it('should calculate left position based on day index and start time', () => {
|
||||
const block: BlockedRange = {
|
||||
start: '2025-12-26T10:00:00', // 10 AM
|
||||
end: '2025-12-26T12:00:00',
|
||||
block_type: 'HARD',
|
||||
purpose: 'CLOSURE',
|
||||
title: 'Block',
|
||||
resource_id: null,
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<TimeBlockCalendarOverlay {...defaultProps} blockedRanges={[block]} />
|
||||
);
|
||||
|
||||
const overlay = container.querySelector('[style*="position: absolute"]') as HTMLElement;
|
||||
expect(overlay).toBeInTheDocument();
|
||||
expect(overlay.style.left).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should calculate width based on duration', () => {
|
||||
const block: BlockedRange = {
|
||||
start: '2025-12-26T10:00:00',
|
||||
end: '2025-12-26T12:00:00', // 2 hours
|
||||
block_type: 'HARD',
|
||||
purpose: 'CLOSURE',
|
||||
title: 'Block',
|
||||
resource_id: null,
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<TimeBlockCalendarOverlay {...defaultProps} blockedRanges={[block]} />
|
||||
);
|
||||
|
||||
const overlay = container.querySelector('[style*="position: absolute"]') as HTMLElement;
|
||||
expect(overlay).toBeInTheDocument();
|
||||
expect(overlay.style.width).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should handle all-day blocks', () => {
|
||||
const block: BlockedRange = {
|
||||
start: '2025-12-26T00:00:00',
|
||||
end: '2025-12-27T00:00:00', // 24 hours
|
||||
block_type: 'HARD',
|
||||
purpose: 'CLOSURE',
|
||||
title: 'All Day',
|
||||
resource_id: null,
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<TimeBlockCalendarOverlay {...defaultProps} blockedRanges={[block]} />
|
||||
);
|
||||
|
||||
const overlays = container.querySelectorAll('[style*="position: absolute"]');
|
||||
expect(overlays.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle blocks with no time_block_id', () => {
|
||||
const block: BlockedRange = {
|
||||
start: '2025-12-26T09:00:00',
|
||||
end: '2025-12-26T10:00:00',
|
||||
block_type: 'HARD',
|
||||
purpose: 'BUSINESS_HOURS',
|
||||
title: 'Business Hours',
|
||||
resource_id: null,
|
||||
// time_block_id is undefined
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<TimeBlockCalendarOverlay {...defaultProps} blockedRanges={[block]} />
|
||||
);
|
||||
|
||||
const overlay = container.querySelector('[style*="position: absolute"]');
|
||||
expect(overlay).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle very short blocks (< 1 hour)', () => {
|
||||
const block: BlockedRange = {
|
||||
start: '2025-12-26T10:00:00',
|
||||
end: '2025-12-26T10:15:00', // 15 minutes
|
||||
block_type: 'SOFT',
|
||||
purpose: 'UNAVAILABLE',
|
||||
title: 'Quick Break',
|
||||
resource_id: 'resource-1',
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<TimeBlockCalendarOverlay {...defaultProps} blockedRanges={[block]} />
|
||||
);
|
||||
|
||||
const overlay = container.querySelector('[style*="position: absolute"]');
|
||||
expect(overlay).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle blocks outside visible day range', () => {
|
||||
const block: BlockedRange = {
|
||||
start: '2025-12-30T09:00:00', // After last visible day
|
||||
end: '2025-12-30T17:00:00',
|
||||
block_type: 'HARD',
|
||||
purpose: 'CLOSURE',
|
||||
title: 'Future Block',
|
||||
resource_id: null,
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<TimeBlockCalendarOverlay {...defaultProps} blockedRanges={[block]} />
|
||||
);
|
||||
|
||||
const overlays = container.querySelectorAll('[style*="position: absolute"]');
|
||||
expect(overlays.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle multiple blocks on same resource', () => {
|
||||
const blocks: BlockedRange[] = [
|
||||
{
|
||||
start: '2025-12-26T09:00:00',
|
||||
end: '2025-12-26T10:00:00',
|
||||
block_type: 'HARD',
|
||||
purpose: 'UNAVAILABLE',
|
||||
title: 'Block 1',
|
||||
resource_id: 'resource-1',
|
||||
},
|
||||
{
|
||||
start: '2025-12-26T14:00:00',
|
||||
end: '2025-12-26T15:00:00',
|
||||
block_type: 'SOFT',
|
||||
purpose: 'UNAVAILABLE',
|
||||
title: 'Block 2',
|
||||
resource_id: 'resource-1',
|
||||
},
|
||||
];
|
||||
|
||||
const { container } = render(
|
||||
<TimeBlockCalendarOverlay {...defaultProps} blockedRanges={blocks} />
|
||||
);
|
||||
|
||||
const overlays = container.querySelectorAll('[style*="position: absolute"]');
|
||||
expect(overlays.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle mixed business and resource blocks', () => {
|
||||
const blocks: BlockedRange[] = [
|
||||
{
|
||||
start: '2025-12-26T09:00:00',
|
||||
end: '2025-12-26T10:00:00',
|
||||
block_type: 'HARD',
|
||||
purpose: 'CLOSURE',
|
||||
title: 'Business Block',
|
||||
resource_id: null,
|
||||
},
|
||||
{
|
||||
start: '2025-12-26T14:00:00',
|
||||
end: '2025-12-26T15:00:00',
|
||||
block_type: 'SOFT',
|
||||
purpose: 'UNAVAILABLE',
|
||||
title: 'Resource Block',
|
||||
resource_id: 'resource-1',
|
||||
},
|
||||
];
|
||||
|
||||
const { container } = render(
|
||||
<TimeBlockCalendarOverlay {...defaultProps} blockedRanges={blocks} />
|
||||
);
|
||||
|
||||
const overlays = container.querySelectorAll('[style*="position: absolute"]');
|
||||
expect(overlays.length).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,934 @@
|
||||
/**
|
||||
* Unit tests for TimeBlockCreatorModal component
|
||||
*
|
||||
* Tests cover:
|
||||
* - Modal open/close functionality
|
||||
* - Preset selection and configuration
|
||||
* - Step-by-step wizard navigation
|
||||
* - Form field rendering and validation
|
||||
* - Recurrence type selection (NONE, WEEKLY, MONTHLY, YEARLY, HOLIDAY)
|
||||
* - Date/time picker interactions
|
||||
* - Block level selection (business, location, resource)
|
||||
* - Staff mode behavior
|
||||
* - Form submission
|
||||
* - Edit mode vs create mode
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import TimeBlockCreatorModal from '../TimeBlockCreatorModal';
|
||||
import { Holiday, Resource, Location, TimeBlockListItem } from '../../../types';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string) => fallback || key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../Portal', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <div data-testid="portal">{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../LocationSelector', () => ({
|
||||
LocationSelector: ({ value, onChange, label }: any) => (
|
||||
<div data-testid="location-selector">
|
||||
<label>{label}</label>
|
||||
<select value={value || ''} onChange={(e) => onChange(Number(e.target.value) || null)}>
|
||||
<option value="">Select location</option>
|
||||
<option value="1">Location 1</option>
|
||||
<option value="2">Location 2</option>
|
||||
</select>
|
||||
</div>
|
||||
),
|
||||
useShouldShowLocationSelector: () => false,
|
||||
useAutoSelectLocation: () => {},
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/usePlanFeatures', () => ({
|
||||
usePlanFeatures: () => ({
|
||||
canUse: () => false,
|
||||
}),
|
||||
}));
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('TimeBlockCreatorModal', () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const mockOnSubmit = vi.fn();
|
||||
|
||||
const mockHolidays: Holiday[] = [
|
||||
{ code: 'new_years_day', name: "New Year's Day", country: 'US' },
|
||||
{ code: 'christmas', name: 'Christmas Day', country: 'US' },
|
||||
];
|
||||
|
||||
const mockResources: Resource[] = [
|
||||
{ id: '1', name: 'Resource 1', type: 'STAFF', email: '', phone: '' },
|
||||
{ id: '2', name: 'Resource 2', type: 'ROOM', email: '', phone: '' },
|
||||
];
|
||||
|
||||
const mockLocations: Location[] = [
|
||||
{ id: 1, name: 'Location 1' },
|
||||
{ id: 2, name: 'Location 2' },
|
||||
];
|
||||
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
onClose: mockOnClose,
|
||||
onSubmit: mockOnSubmit,
|
||||
isSubmitting: false,
|
||||
holidays: mockHolidays,
|
||||
resources: mockResources,
|
||||
locations: mockLocations,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Modal Rendering', () => {
|
||||
it('should not render when isOpen is false', () => {
|
||||
render(<TimeBlockCreatorModal {...defaultProps} isOpen={false} />, { wrapper: createWrapper() });
|
||||
expect(screen.queryByText('Create Time Block')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render when isOpen is true', () => {
|
||||
render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText('Create Time Block')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render in portal', () => {
|
||||
render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
expect(screen.getByTestId('portal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show Edit mode title when editing block', () => {
|
||||
const editingBlock: TimeBlockListItem = {
|
||||
id: '1',
|
||||
title: 'Test Block',
|
||||
block_type: 'HARD',
|
||||
recurrence_type: 'NONE',
|
||||
all_day: true,
|
||||
start_date: '2025-12-25',
|
||||
is_business_wide: true,
|
||||
};
|
||||
|
||||
render(<TimeBlockCreatorModal {...defaultProps} editingBlock={editingBlock} />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText('Edit Time Block')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onClose when clicking X button', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
const closeButtons = screen.getAllByRole('button');
|
||||
const xButton = closeButtons.find(btn => btn.querySelector('svg'));
|
||||
|
||||
if (xButton) {
|
||||
await user.click(xButton);
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('should call onClose when clicking Cancel button', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
const cancelButton = screen.getByText('Cancel');
|
||||
await user.click(cancelButton);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Preset Selection Step', () => {
|
||||
it('should show preset step by default for new blocks', () => {
|
||||
render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText('Block Weekends')).toBeInTheDocument();
|
||||
expect(screen.getByText('Daily Lunch Break')).toBeInTheDocument();
|
||||
expect(screen.getByText('Vacation / Time Off')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should skip preset step when editing block', () => {
|
||||
const editingBlock: TimeBlockListItem = {
|
||||
id: '1',
|
||||
title: 'Test Block',
|
||||
block_type: 'HARD',
|
||||
recurrence_type: 'NONE',
|
||||
all_day: true,
|
||||
start_date: '2025-12-25',
|
||||
is_business_wide: true,
|
||||
};
|
||||
|
||||
render(<TimeBlockCreatorModal {...defaultProps} editingBlock={editingBlock} />, { wrapper: createWrapper() });
|
||||
expect(screen.queryByText('Block Weekends')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Block Name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all preset options', () => {
|
||||
render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Block Weekends')).toBeInTheDocument();
|
||||
expect(screen.getByText('Daily Lunch Break')).toBeInTheDocument();
|
||||
expect(screen.getByText('Vacation / Time Off')).toBeInTheDocument();
|
||||
expect(screen.getByText('Holiday')).toBeInTheDocument();
|
||||
expect(screen.getByText('Monthly Closure')).toBeInTheDocument();
|
||||
expect(screen.getByText('Custom Block')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should navigate to details step when clicking preset', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
const weekendPreset = screen.getByText('Block Weekends');
|
||||
await user.click(weekendPreset);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Block Name')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should pre-fill form with preset configuration', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
const lunchPreset = screen.getByText('Daily Lunch Break');
|
||||
await user.click(lunchPreset);
|
||||
|
||||
await waitFor(() => {
|
||||
const titleInput = screen.getByPlaceholderText(/e.g., Christmas Day/i) as HTMLInputElement;
|
||||
expect(titleInput.value).toBe('Lunch Break');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Details Step', () => {
|
||||
it('should render block name input', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
// Go to details step
|
||||
await user.click(screen.getByText('Custom Block'));
|
||||
|
||||
expect(screen.getByText('Block Name')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/e.g., Christmas Day/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render description textarea', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
await user.click(screen.getByText('Custom Block'));
|
||||
|
||||
expect(screen.getByText(/Description/i)).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/Add any notes/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should allow typing in name field', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
await user.click(screen.getByText('Custom Block'));
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/e.g., Christmas Day/i);
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, 'Holiday Party');
|
||||
|
||||
expect(nameInput).toHaveValue('Holiday Party');
|
||||
});
|
||||
|
||||
it('should render block level selector in non-staff mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
await user.click(screen.getByText('Custom Block'));
|
||||
|
||||
expect(screen.getByText('Block Level')).toBeInTheDocument();
|
||||
expect(screen.getByText('Business-wide')).toBeInTheDocument();
|
||||
expect(screen.getByText('Specific Resource')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render block level selector in staff mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} staffMode={true} staffResourceId="1" />, { wrapper: createWrapper() });
|
||||
|
||||
await user.click(screen.getByText('Custom Block'));
|
||||
|
||||
expect(screen.queryByText('Block Level')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render hard/soft block type selector in non-staff mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
await user.click(screen.getByText('Custom Block'));
|
||||
|
||||
expect(screen.getByText('Block Type')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hard Block')).toBeInTheDocument();
|
||||
expect(screen.getByText('Soft Block')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render block type selector in staff mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} staffMode={true} staffResourceId="1" />, { wrapper: createWrapper() });
|
||||
|
||||
await user.click(screen.getByText('Custom Block'));
|
||||
|
||||
expect(screen.queryByText('Block Type')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render resource selector when resource level is selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
await user.click(screen.getByText('Custom Block'));
|
||||
|
||||
const resourceLevelButton = screen.getByText('Specific Resource');
|
||||
await user.click(resourceLevelButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Select a resource/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render all day / specific hours toggle', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
await user.click(screen.getByText('Custom Block'));
|
||||
|
||||
expect(screen.getByText('Duration')).toBeInTheDocument();
|
||||
expect(screen.getByText('All Day')).toBeInTheDocument();
|
||||
expect(screen.getByText('Specific Hours')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show time pickers when specific hours is selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
await user.click(screen.getByText('Custom Block'));
|
||||
|
||||
const specificHoursButton = screen.getByText('Specific Hours');
|
||||
await user.click(specificHoursButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Start Time')).toBeInTheDocument();
|
||||
expect(screen.getByText('End Time')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should disable Continue button when title is empty', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
await user.click(screen.getByText('Custom Block'));
|
||||
|
||||
const continueButton = screen.getByText('Continue');
|
||||
expect(continueButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should enable Continue button when title is filled', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
await user.click(screen.getByText('Custom Block'));
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/e.g., Christmas Day/i);
|
||||
await user.type(nameInput, 'Test Block');
|
||||
|
||||
const continueButton = screen.getByText('Continue');
|
||||
expect(continueButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Schedule Step', () => {
|
||||
it('should render recurrence type selector', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
await user.click(screen.getByText('Custom Block'));
|
||||
await user.type(screen.getByPlaceholderText(/e.g., Christmas Day/i), 'Test');
|
||||
await user.click(screen.getByText('Continue'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('How often?')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render all recurrence options', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
await user.click(screen.getByText('Custom Block'));
|
||||
await user.type(screen.getByPlaceholderText(/e.g., Christmas Day/i), 'Test');
|
||||
await user.click(screen.getByText('Continue'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('One-time')).toBeInTheDocument();
|
||||
expect(screen.getByText('Weekly')).toBeInTheDocument();
|
||||
expect(screen.getByText('Monthly')).toBeInTheDocument();
|
||||
expect(screen.getByText('Yearly')).toBeInTheDocument();
|
||||
expect(screen.getByText('Holiday')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show calendar when NONE recurrence is selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
await user.click(screen.getByText('Custom Block'));
|
||||
await user.type(screen.getByPlaceholderText(/e.g., Christmas Day/i), 'Test');
|
||||
await user.click(screen.getByText('Continue'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Select Date(s)')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show day selector when WEEKLY recurrence is selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
await user.click(screen.getByText('Custom Block'));
|
||||
await user.type(screen.getByPlaceholderText(/e.g., Christmas Day/i), 'Test');
|
||||
await user.click(screen.getByText('Continue'));
|
||||
|
||||
const weeklyButton = screen.getByText('Weekly');
|
||||
await user.click(weeklyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Select Days')).toBeInTheDocument();
|
||||
expect(screen.getByText('Mon')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tue')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show day of month selector when MONTHLY recurrence is selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
await user.click(screen.getByText('Custom Block'));
|
||||
await user.type(screen.getByPlaceholderText(/e.g., Christmas Day/i), 'Test');
|
||||
await user.click(screen.getByText('Continue'));
|
||||
|
||||
const monthlyButton = screen.getByText('Monthly');
|
||||
await user.click(monthlyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Select Days of Month')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show month and day inputs when YEARLY recurrence is selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
await user.click(screen.getByText('Custom Block'));
|
||||
await user.type(screen.getByPlaceholderText(/e.g., Christmas Day/i), 'Test');
|
||||
await user.click(screen.getByText('Continue'));
|
||||
|
||||
const yearlyButton = screen.getByText('Yearly');
|
||||
await user.click(yearlyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Month')).toBeInTheDocument();
|
||||
expect(screen.getByText('Day')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show holiday picker when HOLIDAY recurrence is selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
await user.click(screen.getByText('Custom Block'));
|
||||
await user.type(screen.getByPlaceholderText(/e.g., Christmas Day/i), 'Test');
|
||||
await user.click(screen.getByText('Continue'));
|
||||
|
||||
// Click the Holiday recurrence type button
|
||||
const holidayButtons = screen.getAllByText('Holiday');
|
||||
const holidayRecurrenceButton = holidayButtons.find(btn =>
|
||||
btn.closest('button')?.querySelector('[class*="icon"]')
|
||||
);
|
||||
|
||||
if (holidayRecurrenceButton) {
|
||||
await user.click(holidayRecurrenceButton);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Select Holidays')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow selecting multiple days in weekly recurrence', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
await user.click(screen.getByText('Custom Block'));
|
||||
await user.type(screen.getByPlaceholderText(/e.g., Christmas Day/i), 'Test');
|
||||
await user.click(screen.getByText('Continue'));
|
||||
|
||||
const weeklyButton = screen.getByText('Weekly');
|
||||
await user.click(weeklyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const monButton = screen.getByText('Mon');
|
||||
expect(monButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const monButton = screen.getByText('Mon');
|
||||
const tueButton = screen.getByText('Tue');
|
||||
|
||||
await user.click(monButton);
|
||||
await user.click(tueButton);
|
||||
|
||||
// Both buttons should be selected (visual feedback)
|
||||
expect(monButton.closest('button')).toHaveClass('bg-brand-500');
|
||||
expect(tueButton.closest('button')).toHaveClass('bg-brand-500');
|
||||
});
|
||||
|
||||
it('should provide weekends only quick action', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
await user.click(screen.getByText('Custom Block'));
|
||||
await user.type(screen.getByPlaceholderText(/e.g., Christmas Day/i), 'Test');
|
||||
await user.click(screen.getByText('Continue'));
|
||||
|
||||
const weeklyButton = screen.getByText('Weekly');
|
||||
await user.click(weeklyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Weekends only')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should disable Continue when no dates selected in one-time mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
await user.click(screen.getByText('Custom Block'));
|
||||
await user.type(screen.getByPlaceholderText(/e.g., Christmas Day/i), 'Test');
|
||||
await user.click(screen.getByText('Continue'));
|
||||
|
||||
const continueButton = screen.getByText('Continue');
|
||||
expect(continueButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Review Step', () => {
|
||||
it('should show summary of block configuration', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
await user.click(screen.getByText('Custom Block'));
|
||||
await user.type(screen.getByPlaceholderText(/e.g., Christmas Day/i), 'Test Block');
|
||||
await user.click(screen.getByText('Continue'));
|
||||
|
||||
// Select a date
|
||||
const calendarDays = screen.getAllByRole('button').filter(btn => /^\d+$/.test(btn.textContent || ''));
|
||||
if (calendarDays.length > 0) {
|
||||
await user.click(calendarDays[15]); // Click day 15
|
||||
}
|
||||
|
||||
await user.click(screen.getByText('Continue'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Summary')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test Block')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display block type in summary', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
await user.click(screen.getByText('Custom Block'));
|
||||
await user.type(screen.getByPlaceholderText(/e.g., Christmas Day/i), 'Test');
|
||||
await user.click(screen.getByText('Continue'));
|
||||
|
||||
// Select date
|
||||
const calendarDays = screen.getAllByRole('button').filter(btn => /^\d+$/.test(btn.textContent || ''));
|
||||
if (calendarDays.length > 0) {
|
||||
await user.click(calendarDays[15]);
|
||||
}
|
||||
|
||||
await user.click(screen.getByText('Continue'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Hard Block|Soft Block/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show Create Block button in review step', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
await user.click(screen.getByText('Custom Block'));
|
||||
await user.type(screen.getByPlaceholderText(/e.g., Christmas Day/i), 'Test');
|
||||
await user.click(screen.getByText('Continue'));
|
||||
|
||||
const calendarDays = screen.getAllByRole('button').filter(btn => /^\d+$/.test(btn.textContent || ''));
|
||||
if (calendarDays.length > 0) {
|
||||
await user.click(calendarDays[15]);
|
||||
}
|
||||
|
||||
await user.click(screen.getByText('Continue'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Create Block')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show Save Changes button when editing', async () => {
|
||||
const user = userEvent.setup();
|
||||
const editingBlock: TimeBlockListItem = {
|
||||
id: '1',
|
||||
title: 'Test Block',
|
||||
block_type: 'HARD',
|
||||
recurrence_type: 'NONE',
|
||||
all_day: true,
|
||||
start_date: '2025-12-25',
|
||||
is_business_wide: true,
|
||||
};
|
||||
|
||||
render(<TimeBlockCreatorModal {...defaultProps} editingBlock={editingBlock} />, { wrapper: createWrapper() });
|
||||
|
||||
// Navigate to review
|
||||
await user.click(screen.getByText('Continue')); // Details to Schedule
|
||||
await user.click(screen.getByText('Continue')); // Schedule to Review
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Save Changes')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation', () => {
|
||||
it('should allow going back to previous step', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
await user.click(screen.getByText('Custom Block'));
|
||||
await user.type(screen.getByPlaceholderText(/e.g., Christmas Day/i), 'Test');
|
||||
await user.click(screen.getByText('Continue'));
|
||||
|
||||
const backButton = screen.getByText('Back');
|
||||
await user.click(backButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Block Weekends')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show step progress indicators', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
await user.click(screen.getByText('Custom Block'));
|
||||
|
||||
// Step indicators should be visible
|
||||
const stepElements = screen.getAllByText(/preset|details|schedule|review/i);
|
||||
expect(stepElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should highlight current step in progress', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
await user.click(screen.getByText('Custom Block'));
|
||||
|
||||
// Current step should have active styling
|
||||
const { container } = render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
const activeSteps = container.querySelectorAll('.bg-brand-100, .bg-brand-500');
|
||||
expect(activeSteps.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Submission', () => {
|
||||
it('should call onSubmit with correct data structure', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
await user.click(screen.getByText('Custom Block'));
|
||||
await user.type(screen.getByPlaceholderText(/e.g., Christmas Day/i), 'Test Block');
|
||||
await user.click(screen.getByText('Continue'));
|
||||
|
||||
// Select a date
|
||||
const calendarDays = screen.getAllByRole('button').filter(btn => /^\d+$/.test(btn.textContent || ''));
|
||||
if (calendarDays.length > 0) {
|
||||
await user.click(calendarDays[15]);
|
||||
}
|
||||
|
||||
await user.click(screen.getByText('Continue'));
|
||||
await user.click(screen.getByText('Create Block'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSubmit).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show loading state during submission', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} isSubmitting={true} />, { wrapper: createWrapper() });
|
||||
|
||||
await user.click(screen.getByText('Custom Block'));
|
||||
await user.type(screen.getByPlaceholderText(/e.g., Christmas Day/i), 'Test');
|
||||
await user.click(screen.getByText('Continue'));
|
||||
|
||||
const calendarDays = screen.getAllByRole('button').filter(btn => /^\d+$/.test(btn.textContent || ''));
|
||||
if (calendarDays.length > 0) {
|
||||
await user.click(calendarDays[15]);
|
||||
}
|
||||
|
||||
await user.click(screen.getByText('Continue'));
|
||||
|
||||
await waitFor(() => {
|
||||
const createButton = screen.queryByText('Creating...');
|
||||
if (createButton) {
|
||||
expect(createButton).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should include business_wide flag for business-level blocks', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
await user.click(screen.getByText('Custom Block'));
|
||||
await user.type(screen.getByPlaceholderText(/e.g., Christmas Day/i), 'Test');
|
||||
|
||||
// Business-wide is default
|
||||
await user.click(screen.getByText('Continue'));
|
||||
|
||||
const calendarDays = screen.getAllByRole('button').filter(btn => /^\d+$/.test(btn.textContent || ''));
|
||||
if (calendarDays.length > 0) {
|
||||
await user.click(calendarDays[15]);
|
||||
}
|
||||
|
||||
await user.click(screen.getByText('Continue'));
|
||||
await user.click(screen.getByText('Create Block'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ is_business_wide: true })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should include resource ID for resource-level blocks', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
await user.click(screen.getByText('Custom Block'));
|
||||
|
||||
const resourceButton = screen.getByText('Specific Resource');
|
||||
await user.click(resourceButton);
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/e.g., Christmas Day/i), 'Test');
|
||||
|
||||
// Select a resource
|
||||
const resourceSelect = screen.getByText(/Select a resource/i).closest('select');
|
||||
if (resourceSelect) {
|
||||
await user.selectOptions(resourceSelect as HTMLSelectElement, '1');
|
||||
}
|
||||
|
||||
await user.click(screen.getByText('Continue'));
|
||||
|
||||
const calendarDays = screen.getAllByRole('button').filter(btn => /^\d+$/.test(btn.textContent || ''));
|
||||
if (calendarDays.length > 0) {
|
||||
await user.click(calendarDays[15]);
|
||||
}
|
||||
|
||||
await user.click(screen.getByText('Continue'));
|
||||
await user.click(screen.getByText('Create Block'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ resource: '1' })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Staff Mode', () => {
|
||||
it('should pre-select resource in staff mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<TimeBlockCreatorModal {...defaultProps} staffMode={true} staffResourceId="1" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Custom Block'));
|
||||
|
||||
// Resource selector should not be visible
|
||||
expect(screen.queryByText(/Select a resource/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should default to SOFT blocks in staff mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<TimeBlockCreatorModal {...defaultProps} staffMode={true} staffResourceId="1" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Custom Block'));
|
||||
await user.type(screen.getByPlaceholderText(/e.g., Christmas Day/i), 'Time Off');
|
||||
await user.click(screen.getByText('Continue'));
|
||||
|
||||
const calendarDays = screen.getAllByRole('button').filter(btn => /^\d+$/.test(btn.textContent || ''));
|
||||
if (calendarDays.length > 0) {
|
||||
await user.click(calendarDays[15]);
|
||||
}
|
||||
|
||||
await user.click(screen.getByText('Continue'));
|
||||
await user.click(screen.getByText('Create Block'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ block_type: 'SOFT' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should always use resource level in staff mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<TimeBlockCreatorModal {...defaultProps} staffMode={true} staffResourceId="1" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Custom Block'));
|
||||
await user.type(screen.getByPlaceholderText(/e.g., Christmas Day/i), 'Time Off');
|
||||
await user.click(screen.getByText('Continue'));
|
||||
|
||||
const calendarDays = screen.getAllByRole('button').filter(btn => /^\d+$/.test(btn.textContent || ''));
|
||||
if (calendarDays.length > 0) {
|
||||
await user.click(calendarDays[15]);
|
||||
}
|
||||
|
||||
await user.click(screen.getByText('Continue'));
|
||||
await user.click(screen.getByText('Create Block'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resource: '1',
|
||||
is_business_wide: false
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edit Mode', () => {
|
||||
it('should populate form with existing block data', () => {
|
||||
const editingBlock: TimeBlockListItem = {
|
||||
id: '1',
|
||||
title: 'Existing Block',
|
||||
description: 'Test description',
|
||||
block_type: 'SOFT',
|
||||
recurrence_type: 'NONE',
|
||||
all_day: false,
|
||||
start_time: '10:00',
|
||||
end_time: '12:00',
|
||||
start_date: '2025-12-25',
|
||||
is_business_wide: true,
|
||||
};
|
||||
|
||||
render(<TimeBlockCreatorModal {...defaultProps} editingBlock={editingBlock} />, { wrapper: createWrapper() });
|
||||
|
||||
const titleInput = screen.getByPlaceholderText(/e.g., Christmas Day/i) as HTMLInputElement;
|
||||
expect(titleInput.value).toBe('Existing Block');
|
||||
|
||||
const descInput = screen.getByPlaceholderText(/Add any notes/i) as HTMLTextAreaElement;
|
||||
expect(descInput.value).toBe('Test description');
|
||||
});
|
||||
|
||||
it('should preserve recurrence pattern when editing', () => {
|
||||
const editingBlock: TimeBlockListItem = {
|
||||
id: '1',
|
||||
title: 'Weekly Block',
|
||||
block_type: 'HARD',
|
||||
recurrence_type: 'WEEKLY',
|
||||
all_day: true,
|
||||
is_business_wide: true,
|
||||
recurrence_pattern: { days_of_week: [0, 1, 2, 3, 4] },
|
||||
};
|
||||
|
||||
render(<TimeBlockCreatorModal {...defaultProps} editingBlock={editingBlock} />, { wrapper: createWrapper() });
|
||||
|
||||
// Should be on schedule step with weekly selected
|
||||
expect(screen.getByText('Select Days')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle JSON string recurrence_pattern', () => {
|
||||
const editingBlock: TimeBlockListItem = {
|
||||
id: '1',
|
||||
title: 'Monthly Block',
|
||||
block_type: 'HARD',
|
||||
recurrence_type: 'MONTHLY',
|
||||
all_day: true,
|
||||
is_business_wide: true,
|
||||
recurrence_pattern: '{"days_of_month":[1,15]}' as any, // JSON string
|
||||
};
|
||||
|
||||
render(<TimeBlockCreatorModal {...defaultProps} editingBlock={editingBlock} />, { wrapper: createWrapper() });
|
||||
|
||||
// Should parse and display correctly
|
||||
expect(screen.getByText('Select Days of Month')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty resources array', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} resources={[]} />, { wrapper: createWrapper() });
|
||||
|
||||
await user.click(screen.getByText('Custom Block'));
|
||||
|
||||
const resourceButton = screen.getByText('Specific Resource');
|
||||
await user.click(resourceButton);
|
||||
|
||||
// Should show select even with no options
|
||||
expect(screen.getByText(/Select a resource/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle empty holidays array', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TimeBlockCreatorModal {...defaultProps} holidays={[]} />, { wrapper: createWrapper() });
|
||||
|
||||
await user.click(screen.getByText('Holiday'));
|
||||
|
||||
// Should still render holiday step
|
||||
expect(screen.getByText('Block Name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should reset form when modal closes and reopens', async () => {
|
||||
const { rerender } = render(<TimeBlockCreatorModal {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
// Close modal
|
||||
rerender(<TimeBlockCreatorModal {...defaultProps} isOpen={false} />);
|
||||
|
||||
// Reopen modal
|
||||
rerender(<TimeBlockCreatorModal {...defaultProps} isOpen={true} />);
|
||||
|
||||
// Should be back at preset step
|
||||
expect(screen.getByText('Block Weekends')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,729 @@
|
||||
/**
|
||||
* Unit tests for YearlyBlockCalendar component
|
||||
*
|
||||
* Tests cover:
|
||||
* - Component rendering with 12-month grid
|
||||
* - Year navigation (previous/next/today)
|
||||
* - Blocked date display (red for hard, yellow for soft)
|
||||
* - Business-level block badge display
|
||||
* - Legend rendering
|
||||
* - Day click handling for block details
|
||||
* - Loading states
|
||||
* - Block detail popup modal
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import YearlyBlockCalendar from '../YearlyBlockCalendar';
|
||||
import { BlockedRange } from '../../../types';
|
||||
|
||||
// Mock hooks
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string) => fallback || key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/useTimeBlocks', () => ({
|
||||
useBlockedRanges: vi.fn(() => ({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Import the mocked hook
|
||||
import * as timeBlocksHooks from '../../../hooks/useTimeBlocks';
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('YearlyBlockCalendar', () => {
|
||||
const mockOnBlockClick = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(timeBlocksHooks.useBlockedRanges).mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
isSuccess: true,
|
||||
} as any);
|
||||
});
|
||||
|
||||
describe('Component Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<YearlyBlockCalendar />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText('Yearly Calendar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render header with title', () => {
|
||||
render(<YearlyBlockCalendar />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText('Yearly Calendar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render current year by default', () => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
render(<YearlyBlockCalendar />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText(currentYear.toString())).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render 12 month grids', async () => {
|
||||
render(<YearlyBlockCalendar />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('January')).toBeInTheDocument();
|
||||
expect(screen.getByText('February')).toBeInTheDocument();
|
||||
expect(screen.getByText('December')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render legend with block types', () => {
|
||||
render(<YearlyBlockCalendar />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Hard Block')).toBeInTheDocument();
|
||||
expect(screen.getByText('Soft Block')).toBeInTheDocument();
|
||||
expect(screen.getByText('Business Level')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render weekday headers', () => {
|
||||
render(<YearlyBlockCalendar />, { wrapper: createWrapper() });
|
||||
|
||||
// Check for some weekday abbreviations
|
||||
const sElements = screen.getAllByText('S');
|
||||
const mElements = screen.getAllByText('M');
|
||||
expect(sElements.length).toBeGreaterThan(0);
|
||||
expect(mElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Year Navigation', () => {
|
||||
it('should render previous year button', () => {
|
||||
render(<YearlyBlockCalendar />, { wrapper: createWrapper() });
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const prevButton = buttons.find(btn => btn.querySelector('svg'));
|
||||
expect(prevButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render next year button', () => {
|
||||
render(<YearlyBlockCalendar />, { wrapper: createWrapper() });
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should navigate to previous year when clicking previous button', async () => {
|
||||
const user = userEvent.setup();
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
render(<YearlyBlockCalendar />, { wrapper: createWrapper() });
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
// Find the button with ChevronLeft icon (first button with SVG)
|
||||
const prevButton = buttons[0];
|
||||
|
||||
await user.click(prevButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText((currentYear - 1).toString())).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should navigate to next year when clicking next button', async () => {
|
||||
const user = userEvent.setup();
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
render(<YearlyBlockCalendar />, { wrapper: createWrapper() });
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
// Find the button with ChevronRight icon (second button with SVG)
|
||||
const nextButton = buttons[1];
|
||||
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText((currentYear + 1).toString())).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should navigate to current year when clicking Today button', async () => {
|
||||
const user = userEvent.setup();
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
render(<YearlyBlockCalendar />, { wrapper: createWrapper() });
|
||||
|
||||
// Navigate away first
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const prevButton = buttons[0];
|
||||
await user.click(prevButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText((currentYear - 1).toString())).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click Today button
|
||||
const todayButton = screen.getByText('Today');
|
||||
await user.click(todayButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(currentYear.toString())).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Blocked Dates Display', () => {
|
||||
it('should display hard blocks in red', async () => {
|
||||
const hardBlock: BlockedRange = {
|
||||
start: '2025-12-25T00:00:00',
|
||||
end: '2025-12-26T00:00:00',
|
||||
block_type: 'HARD',
|
||||
purpose: 'CLOSURE',
|
||||
title: 'Christmas',
|
||||
resource_id: null,
|
||||
time_block_id: '1',
|
||||
};
|
||||
|
||||
vi.mocked(timeBlocksHooks.useBlockedRanges).mockReturnValue({
|
||||
data: [hardBlock],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
const { container } = render(<YearlyBlockCalendar />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
const redCells = container.querySelectorAll('.bg-red-500, .bg-red-400');
|
||||
expect(redCells.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should display soft blocks in yellow', async () => {
|
||||
const softBlock: BlockedRange = {
|
||||
start: '2025-07-04T00:00:00',
|
||||
end: '2025-07-05T00:00:00',
|
||||
block_type: 'SOFT',
|
||||
purpose: 'UNAVAILABLE',
|
||||
title: 'Preferred Off',
|
||||
resource_id: 'resource-1',
|
||||
time_block_id: '2',
|
||||
};
|
||||
|
||||
vi.mocked(timeBlocksHooks.useBlockedRanges).mockReturnValue({
|
||||
data: [softBlock],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
const { container } = render(<YearlyBlockCalendar />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
const yellowCells = container.querySelectorAll('.bg-yellow-400, .bg-yellow-300');
|
||||
expect(yellowCells.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should display business-level block badge', async () => {
|
||||
const businessBlock: BlockedRange = {
|
||||
start: '2025-12-25T00:00:00',
|
||||
end: '2025-12-26T00:00:00',
|
||||
block_type: 'HARD',
|
||||
purpose: 'CLOSURE',
|
||||
title: 'Business Closed',
|
||||
resource_id: null,
|
||||
time_block_id: '3',
|
||||
};
|
||||
|
||||
vi.mocked(timeBlocksHooks.useBlockedRanges).mockReturnValue({
|
||||
data: [businessBlock],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
render(<YearlyBlockCalendar />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
const badges = screen.getAllByText('B');
|
||||
expect(badges.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not display badge for resource-level blocks', async () => {
|
||||
const resourceBlock: BlockedRange = {
|
||||
start: '2025-06-15T00:00:00',
|
||||
end: '2025-06-16T00:00:00',
|
||||
block_type: 'HARD',
|
||||
purpose: 'UNAVAILABLE',
|
||||
title: 'Staff Vacation',
|
||||
resource_id: 'resource-1',
|
||||
time_block_id: '4',
|
||||
};
|
||||
|
||||
vi.mocked(timeBlocksHooks.useBlockedRanges).mockReturnValue({
|
||||
data: [resourceBlock],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
const { container } = render(<YearlyBlockCalendar />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
// Should have blocked dates but no "B" badge
|
||||
const blockedCells = container.querySelectorAll('.bg-red-400, .bg-yellow-300');
|
||||
expect(blockedCells.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle multi-day blocks', async () => {
|
||||
const multiDayBlock: BlockedRange = {
|
||||
start: '2025-08-10T00:00:00',
|
||||
end: '2025-08-15T00:00:00', // 5 days
|
||||
block_type: 'HARD',
|
||||
purpose: 'UNAVAILABLE',
|
||||
title: 'Vacation',
|
||||
resource_id: 'resource-1',
|
||||
time_block_id: '5',
|
||||
};
|
||||
|
||||
vi.mocked(timeBlocksHooks.useBlockedRanges).mockReturnValue({
|
||||
data: [multiDayBlock],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
const { container } = render(<YearlyBlockCalendar />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
const blockedCells = container.querySelectorAll('.bg-red-400');
|
||||
// Should have multiple cells for multi-day range
|
||||
expect(blockedCells.length).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Day Click Handling', () => {
|
||||
it('should call onBlockClick when clicking on blocked day with single block', async () => {
|
||||
const user = userEvent.setup();
|
||||
const block: BlockedRange = {
|
||||
start: '2025-12-25T00:00:00',
|
||||
end: '2025-12-26T00:00:00',
|
||||
block_type: 'HARD',
|
||||
purpose: 'CLOSURE',
|
||||
title: 'Christmas',
|
||||
resource_id: null,
|
||||
time_block_id: 'block-123',
|
||||
};
|
||||
|
||||
vi.mocked(timeBlocksHooks.useBlockedRanges).mockReturnValue({
|
||||
data: [block],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
const { container } = render(
|
||||
<YearlyBlockCalendar onBlockClick={mockOnBlockClick} />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const blockedCells = container.querySelectorAll('.bg-red-500, .bg-red-400');
|
||||
expect(blockedCells.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const blockedCell = container.querySelector('.bg-red-500, .bg-red-400') as HTMLElement;
|
||||
await user.click(blockedCell);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnBlockClick).toHaveBeenCalledWith('block-123');
|
||||
});
|
||||
});
|
||||
|
||||
it('should not call onBlockClick when clicking on unblocked day', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(timeBlocksHooks.useBlockedRanges).mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
const { container } = render(
|
||||
<YearlyBlockCalendar onBlockClick={mockOnBlockClick} />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('January')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find a day cell without blocked status
|
||||
const dayCells = container.querySelectorAll('button:not(.bg-red-500):not(.bg-yellow-400)');
|
||||
const unblockedCell = Array.from(dayCells).find(cell => {
|
||||
const text = cell.textContent;
|
||||
return text && /^\d+$/.test(text);
|
||||
});
|
||||
|
||||
if (unblockedCell) {
|
||||
await user.click(unblockedCell as HTMLElement);
|
||||
expect(mockOnBlockClick).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('should show popup when clicking on day with multiple blocks', async () => {
|
||||
const user = userEvent.setup();
|
||||
const blocks: BlockedRange[] = [
|
||||
{
|
||||
start: '2025-12-25T09:00:00',
|
||||
end: '2025-12-25T12:00:00',
|
||||
block_type: 'HARD',
|
||||
purpose: 'CLOSURE',
|
||||
title: 'Morning Closure',
|
||||
resource_id: null,
|
||||
time_block_id: 'block-1',
|
||||
},
|
||||
{
|
||||
start: '2025-12-25T14:00:00',
|
||||
end: '2025-12-25T17:00:00',
|
||||
block_type: 'SOFT',
|
||||
purpose: 'UNAVAILABLE',
|
||||
title: 'Afternoon Block',
|
||||
resource_id: 'resource-1',
|
||||
time_block_id: 'block-2',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(timeBlocksHooks.useBlockedRanges).mockReturnValue({
|
||||
data: blocks,
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
const { container } = render(
|
||||
<YearlyBlockCalendar onBlockClick={mockOnBlockClick} />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const blockedCells = container.querySelectorAll('.bg-red-500, .bg-red-400');
|
||||
expect(blockedCells.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const blockedCell = container.querySelector('.bg-red-500, .bg-red-400') as HTMLElement;
|
||||
await user.click(blockedCell);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show popup instead of calling onBlockClick directly
|
||||
expect(screen.getByText('Morning Closure')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Block Detail Popup', () => {
|
||||
it('should display block title in popup', async () => {
|
||||
const user = userEvent.setup();
|
||||
const block: BlockedRange = {
|
||||
start: '2025-12-25T00:00:00',
|
||||
end: '2025-12-26T00:00:00',
|
||||
block_type: 'HARD',
|
||||
purpose: 'CLOSURE',
|
||||
title: 'Christmas Day',
|
||||
resource_id: null,
|
||||
time_block_id: 'block-123',
|
||||
};
|
||||
|
||||
vi.mocked(timeBlocksHooks.useBlockedRanges).mockReturnValue({
|
||||
data: [block],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
const { container } = render(<YearlyBlockCalendar />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
const blockedCells = container.querySelectorAll('.bg-red-500, .bg-red-400');
|
||||
expect(blockedCells.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const blockedCell = container.querySelector('.bg-red-500, .bg-red-400') as HTMLElement;
|
||||
await user.click(blockedCell);
|
||||
|
||||
// Wait for the popup to show - it should display for blocks without time_block_id or multiple blocks
|
||||
// Since we have time_block_id, it will call onBlockClick if provided, otherwise show popup
|
||||
// Let's test without onBlockClick
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Christmas Day')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should close popup when clicking X button', async () => {
|
||||
const user = userEvent.setup();
|
||||
const block: BlockedRange = {
|
||||
start: '2025-12-25T00:00:00',
|
||||
end: '2025-12-26T00:00:00',
|
||||
block_type: 'HARD',
|
||||
purpose: 'CLOSURE',
|
||||
title: 'Holiday',
|
||||
resource_id: null,
|
||||
};
|
||||
|
||||
vi.mocked(timeBlocksHooks.useBlockedRanges).mockReturnValue({
|
||||
data: [block],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
const { container } = render(<YearlyBlockCalendar />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
const blockedCells = container.querySelectorAll('.bg-red-500, .bg-red-400');
|
||||
expect(blockedCells.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const blockedCell = container.querySelector('.bg-red-500, .bg-red-400') as HTMLElement;
|
||||
await user.click(blockedCell);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Holiday')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const closeButtons = screen.getAllByRole('button');
|
||||
const xButton = closeButtons.find(btn => btn.querySelector('svg'));
|
||||
|
||||
if (xButton) {
|
||||
await user.click(xButton);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Holiday')).not.toBeInTheDocument();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should display block type in popup', async () => {
|
||||
const user = userEvent.setup();
|
||||
const block: BlockedRange = {
|
||||
start: '2025-07-04T00:00:00',
|
||||
end: '2025-07-05T00:00:00',
|
||||
block_type: 'SOFT',
|
||||
purpose: 'UNAVAILABLE',
|
||||
title: 'Independence Day',
|
||||
resource_id: null,
|
||||
};
|
||||
|
||||
vi.mocked(timeBlocksHooks.useBlockedRanges).mockReturnValue({
|
||||
data: [block],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
const { container } = render(<YearlyBlockCalendar />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
const blockedCells = container.querySelectorAll('.bg-yellow-400, .bg-yellow-300');
|
||||
expect(blockedCells.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const blockedCell = container.querySelector('.bg-yellow-400, .bg-yellow-300') as HTMLElement;
|
||||
await user.click(blockedCell);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Soft Block/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading State', () => {
|
||||
it('should display loading spinner when loading', () => {
|
||||
vi.mocked(timeBlocksHooks.useBlockedRanges).mockReturnValue({
|
||||
data: [],
|
||||
isLoading: true,
|
||||
} as any);
|
||||
|
||||
const { container } = render(<YearlyBlockCalendar />, { wrapper: createWrapper() });
|
||||
|
||||
const spinner = container.querySelector('.animate-spin');
|
||||
expect(spinner).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not display calendar grid when loading', () => {
|
||||
vi.mocked(timeBlocksHooks.useBlockedRanges).mockReturnValue({
|
||||
data: [],
|
||||
isLoading: true,
|
||||
} as any);
|
||||
|
||||
render(<YearlyBlockCalendar />, { wrapper: createWrapper() });
|
||||
|
||||
// Calendar months should not be visible
|
||||
expect(screen.queryByText('January')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display calendar grid after loading completes', async () => {
|
||||
const mockUseBlockedRanges = vi.mocked(timeBlocksHooks.useBlockedRanges);
|
||||
|
||||
// Start with loading
|
||||
mockUseBlockedRanges.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: true,
|
||||
} as any);
|
||||
|
||||
const { rerender } = render(<YearlyBlockCalendar />, { wrapper: createWrapper() });
|
||||
|
||||
// Update to loaded
|
||||
mockUseBlockedRanges.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
rerender(<YearlyBlockCalendar />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('January')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resource Filtering', () => {
|
||||
it('should filter blocks by resource ID when provided', () => {
|
||||
const blocks: BlockedRange[] = [
|
||||
{
|
||||
start: '2025-06-01T00:00:00',
|
||||
end: '2025-06-02T00:00:00',
|
||||
block_type: 'HARD',
|
||||
purpose: 'UNAVAILABLE',
|
||||
title: 'Resource 1 Block',
|
||||
resource_id: 'resource-1',
|
||||
time_block_id: '1',
|
||||
},
|
||||
{
|
||||
start: '2025-06-03T00:00:00',
|
||||
end: '2025-06-04T00:00:00',
|
||||
block_type: 'HARD',
|
||||
purpose: 'UNAVAILABLE',
|
||||
title: 'Resource 2 Block',
|
||||
resource_id: 'resource-2',
|
||||
time_block_id: '2',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(timeBlocksHooks.useBlockedRanges).mockReturnValue({
|
||||
data: blocks,
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
render(<YearlyBlockCalendar resourceId="resource-1" />, { wrapper: createWrapper() });
|
||||
|
||||
// Should call hook with resource_id parameter
|
||||
expect(timeBlocksHooks.useBlockedRanges).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ resource_id: 'resource-1' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should include business blocks when include_business is true', () => {
|
||||
render(<YearlyBlockCalendar />, { wrapper: createWrapper() });
|
||||
|
||||
expect(timeBlocksHooks.useBlockedRanges).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ include_business: true })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Compact Mode', () => {
|
||||
it('should apply compact styling when compact prop is true', () => {
|
||||
const { container } = render(<YearlyBlockCalendar compact={true} />, { wrapper: createWrapper() });
|
||||
|
||||
// Container should not have padding in compact mode
|
||||
const wrapper = container.firstChild;
|
||||
expect(wrapper).not.toHaveClass('p-4');
|
||||
});
|
||||
|
||||
it('should apply normal padding when compact is false', () => {
|
||||
const { container } = render(<YearlyBlockCalendar compact={false} />, { wrapper: createWrapper() });
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement;
|
||||
expect(wrapper.className).toContain('p-4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Today Highlight', () => {
|
||||
it('should highlight current day', async () => {
|
||||
vi.mocked(timeBlocksHooks.useBlockedRanges).mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
const { container } = render(<YearlyBlockCalendar />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('January')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Current day should have ring styling (if it's not blocked)
|
||||
const todayCells = container.querySelectorAll('.ring-2.ring-blue-500');
|
||||
// May or may not have today highlighted depending on if it's blocked
|
||||
expect(todayCells.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty blocked ranges array', () => {
|
||||
vi.mocked(timeBlocksHooks.useBlockedRanges).mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
render(<YearlyBlockCalendar />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Yearly Calendar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle blocks without time_block_id', async () => {
|
||||
const block: BlockedRange = {
|
||||
start: '2025-12-25T00:00:00',
|
||||
end: '2025-12-26T00:00:00',
|
||||
block_type: 'HARD',
|
||||
purpose: 'BUSINESS_HOURS',
|
||||
title: 'Business Hours',
|
||||
resource_id: null,
|
||||
// No time_block_id
|
||||
};
|
||||
|
||||
vi.mocked(timeBlocksHooks.useBlockedRanges).mockReturnValue({
|
||||
data: [block],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
const { container } = render(<YearlyBlockCalendar />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
const blockedCells = container.querySelectorAll('.bg-red-500, .bg-red-400');
|
||||
expect(blockedCells.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle year boundaries correctly', async () => {
|
||||
const block: BlockedRange = {
|
||||
start: '2025-12-31T20:00:00',
|
||||
end: '2026-01-01T08:00:00', // Crosses year boundary
|
||||
block_type: 'HARD',
|
||||
purpose: 'CLOSURE',
|
||||
title: 'New Year',
|
||||
resource_id: null,
|
||||
time_block_id: '1',
|
||||
};
|
||||
|
||||
vi.mocked(timeBlocksHooks.useBlockedRanges).mockReturnValue({
|
||||
data: [block],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
const { container } = render(<YearlyBlockCalendar />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
// Should only show Dec 31 in current year
|
||||
const blockedCells = container.querySelectorAll('.bg-red-500, .bg-red-400');
|
||||
expect(blockedCells.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -72,6 +72,7 @@ export const TabGroup: React.FC<TabGroupProps> = ({
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => !tab.disabled && onChange(tab.id)}
|
||||
disabled={tab.disabled}
|
||||
className={`${sizeClasses[size]} font-medium border-b-2 -mb-px transition-colors ${fullWidth ? 'flex-1' : ''} ${
|
||||
@@ -99,6 +100,7 @@ export const TabGroup: React.FC<TabGroupProps> = ({
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => !tab.disabled && onChange(tab.id)}
|
||||
disabled={tab.disabled}
|
||||
className={`${sizeClasses[size]} font-medium rounded-full transition-colors ${fullWidth ? 'flex-1' : ''} ${
|
||||
@@ -128,6 +130,7 @@ export const TabGroup: React.FC<TabGroupProps> = ({
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => !tab.disabled && onChange(tab.id)}
|
||||
disabled={tab.disabled}
|
||||
className={`${sizeClasses[size]} font-medium transition-colors ${fullWidth ? 'flex-1' : ''} ${
|
||||
|
||||
Reference in New Issue
Block a user