- Add frontend unit tests with Vitest for components, hooks, pages, and utilities - Add backend tests for webhooks, notifications, middleware, and edge cases - Add ForgotPassword, NotFound, and ResetPassword pages - Add migration for orphaned staff resources conversion - Add coverage directory to gitignore (generated reports) - Various bug fixes and improvements from previous work 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
703 lines
18 KiB
TypeScript
703 lines
18 KiB
TypeScript
/**
|
|
* Unit tests for MetricWidget component
|
|
*
|
|
* Tests cover:
|
|
* - Component rendering with title and value
|
|
* - Growth/trend indicators (positive, negative, neutral)
|
|
* - Change percentage formatting
|
|
* - Weekly and monthly metrics display
|
|
* - Icon rendering
|
|
* - Edit mode with drag handle and remove button
|
|
* - Internationalization (i18n)
|
|
* - Accessibility
|
|
*/
|
|
|
|
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 MetricWidget from '../MetricWidget';
|
|
|
|
// Mock react-i18next
|
|
vi.mock('react-i18next', () => ({
|
|
useTranslation: () => ({
|
|
t: (key: string) => {
|
|
const translations: Record<string, string> = {
|
|
'dashboard.weekLabel': 'Week:',
|
|
'dashboard.monthLabel': 'Month:',
|
|
};
|
|
return translations[key] || key;
|
|
},
|
|
}),
|
|
}));
|
|
|
|
describe('MetricWidget', () => {
|
|
const mockGrowthData = {
|
|
weekly: { value: 100, change: 5.5 },
|
|
monthly: { value: 400, change: -2.3 },
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('Rendering', () => {
|
|
it('should render the component', () => {
|
|
render(
|
|
<MetricWidget
|
|
title="Total Revenue"
|
|
value="$12,345"
|
|
growth={mockGrowthData}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText('Total Revenue')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render title correctly', () => {
|
|
render(
|
|
<MetricWidget
|
|
title="Total Customers"
|
|
value={150}
|
|
growth={mockGrowthData}
|
|
/>
|
|
);
|
|
|
|
const title = screen.getByText('Total Customers');
|
|
expect(title).toBeInTheDocument();
|
|
expect(title).toHaveClass('text-sm', 'font-medium', 'text-gray-500');
|
|
});
|
|
|
|
it('should render numeric value', () => {
|
|
render(
|
|
<MetricWidget
|
|
title="Total Appointments"
|
|
value={42}
|
|
growth={mockGrowthData}
|
|
/>
|
|
);
|
|
|
|
const value = screen.getByText('42');
|
|
expect(value).toBeInTheDocument();
|
|
expect(value).toHaveClass('text-2xl', 'font-bold', 'text-gray-900');
|
|
});
|
|
|
|
it('should render string value', () => {
|
|
render(
|
|
<MetricWidget
|
|
title="Revenue"
|
|
value="$25,000"
|
|
growth={mockGrowthData}
|
|
/>
|
|
);
|
|
|
|
const value = screen.getByText('$25,000');
|
|
expect(value).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render with custom icon', () => {
|
|
const CustomIcon = () => <span data-testid="custom-icon">💰</span>;
|
|
|
|
render(
|
|
<MetricWidget
|
|
title="Revenue"
|
|
value="$1000"
|
|
growth={mockGrowthData}
|
|
icon={<CustomIcon />}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTestId('custom-icon')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render without icon', () => {
|
|
const { container } = render(
|
|
<MetricWidget
|
|
title="Revenue"
|
|
value="$1000"
|
|
growth={mockGrowthData}
|
|
/>
|
|
);
|
|
|
|
const iconContainer = container.querySelector('.text-brand-500');
|
|
expect(iconContainer).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Trend Indicators', () => {
|
|
describe('Positive Change', () => {
|
|
it('should show positive trend icon for weekly growth', () => {
|
|
const positiveGrowth = {
|
|
weekly: { value: 100, change: 10.5 },
|
|
monthly: { value: 400, change: 0 },
|
|
};
|
|
|
|
const { container } = render(
|
|
<MetricWidget
|
|
title="Revenue"
|
|
value="$1000"
|
|
growth={positiveGrowth}
|
|
/>
|
|
);
|
|
|
|
const changeText = screen.getByText('+10.5%');
|
|
expect(changeText).toBeInTheDocument();
|
|
|
|
// Check for TrendingUp icon (lucide-react renders as SVG)
|
|
const svgs = container.querySelectorAll('svg');
|
|
expect(svgs.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should apply positive change styling', () => {
|
|
const positiveGrowth = {
|
|
weekly: { value: 100, change: 15 },
|
|
monthly: { value: 400, change: 0 },
|
|
};
|
|
|
|
render(
|
|
<MetricWidget
|
|
title="Revenue"
|
|
value="$1000"
|
|
growth={positiveGrowth}
|
|
/>
|
|
);
|
|
|
|
const changeElement = screen.getByText('+15.0%').closest('span');
|
|
expect(changeElement).toHaveClass('text-green-700', 'bg-green-50');
|
|
});
|
|
|
|
it('should format positive change with plus sign', () => {
|
|
const positiveGrowth = {
|
|
weekly: { value: 100, change: 7.8 },
|
|
monthly: { value: 400, change: 3.2 },
|
|
};
|
|
|
|
render(
|
|
<MetricWidget
|
|
title="Revenue"
|
|
value="$1000"
|
|
growth={positiveGrowth}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText('+7.8%')).toBeInTheDocument();
|
|
expect(screen.getByText('+3.2%')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Negative Change', () => {
|
|
it('should show negative trend icon for monthly growth', () => {
|
|
const negativeGrowth = {
|
|
weekly: { value: 100, change: 0 },
|
|
monthly: { value: 400, change: -5.5 },
|
|
};
|
|
|
|
const { container } = render(
|
|
<MetricWidget
|
|
title="Revenue"
|
|
value="$1000"
|
|
growth={negativeGrowth}
|
|
/>
|
|
);
|
|
|
|
const changeText = screen.getByText('-5.5%');
|
|
expect(changeText).toBeInTheDocument();
|
|
|
|
// Check for TrendingDown icon
|
|
const svgs = container.querySelectorAll('svg');
|
|
expect(svgs.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should apply negative change styling', () => {
|
|
const negativeGrowth = {
|
|
weekly: { value: 100, change: -12.3 },
|
|
monthly: { value: 400, change: 0 },
|
|
};
|
|
|
|
render(
|
|
<MetricWidget
|
|
title="Revenue"
|
|
value="$1000"
|
|
growth={negativeGrowth}
|
|
/>
|
|
);
|
|
|
|
const changeElement = screen.getByText('-12.3%').closest('span');
|
|
expect(changeElement).toHaveClass('text-red-700', 'bg-red-50');
|
|
});
|
|
|
|
it('should format negative change without extra minus sign', () => {
|
|
const negativeGrowth = {
|
|
weekly: { value: 100, change: -8.9 },
|
|
monthly: { value: 400, change: -15.2 },
|
|
};
|
|
|
|
render(
|
|
<MetricWidget
|
|
title="Revenue"
|
|
value="$1000"
|
|
growth={negativeGrowth}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText('-8.9%')).toBeInTheDocument();
|
|
expect(screen.getByText('-15.2%')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Zero Change', () => {
|
|
it('should show neutral trend icon for zero change', () => {
|
|
const zeroGrowth = {
|
|
weekly: { value: 100, change: 0 },
|
|
monthly: { value: 400, change: 0 },
|
|
};
|
|
|
|
const { container } = render(
|
|
<MetricWidget
|
|
title="Revenue"
|
|
value="$1000"
|
|
growth={zeroGrowth}
|
|
/>
|
|
);
|
|
|
|
const changeTexts = screen.getAllByText('0%');
|
|
expect(changeTexts).toHaveLength(2);
|
|
|
|
// Check for Minus icon
|
|
const svgs = container.querySelectorAll('svg');
|
|
expect(svgs.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should apply neutral change styling', () => {
|
|
const zeroGrowth = {
|
|
weekly: { value: 100, change: 0 },
|
|
monthly: { value: 400, change: 0 },
|
|
};
|
|
|
|
render(
|
|
<MetricWidget
|
|
title="Revenue"
|
|
value="$1000"
|
|
growth={zeroGrowth}
|
|
/>
|
|
);
|
|
|
|
const changeElements = screen.getAllByText('0%');
|
|
changeElements.forEach((element) => {
|
|
const spanElement = element.closest('span');
|
|
expect(spanElement).toHaveClass('text-gray-700', 'bg-gray-50');
|
|
});
|
|
});
|
|
|
|
it('should format zero change as 0%', () => {
|
|
const zeroGrowth = {
|
|
weekly: { value: 100, change: 0 },
|
|
monthly: { value: 400, change: 0 },
|
|
};
|
|
|
|
render(
|
|
<MetricWidget
|
|
title="Revenue"
|
|
value="$1000"
|
|
growth={zeroGrowth}
|
|
/>
|
|
);
|
|
|
|
const changeTexts = screen.getAllByText('0%');
|
|
expect(changeTexts).toHaveLength(2);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Weekly and Monthly Metrics', () => {
|
|
it('should display weekly label', () => {
|
|
render(
|
|
<MetricWidget
|
|
title="Revenue"
|
|
value="$1000"
|
|
growth={mockGrowthData}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText('Week:')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should display monthly label', () => {
|
|
render(
|
|
<MetricWidget
|
|
title="Revenue"
|
|
value="$1000"
|
|
growth={mockGrowthData}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText('Month:')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should display weekly change percentage', () => {
|
|
render(
|
|
<MetricWidget
|
|
title="Revenue"
|
|
value="$1000"
|
|
growth={mockGrowthData}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText('+5.5%')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should display monthly change percentage', () => {
|
|
render(
|
|
<MetricWidget
|
|
title="Revenue"
|
|
value="$1000"
|
|
growth={mockGrowthData}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText('-2.3%')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should handle different weekly and monthly trends', () => {
|
|
const mixedGrowth = {
|
|
weekly: { value: 100, change: 12.5 },
|
|
monthly: { value: 400, change: -8.2 },
|
|
};
|
|
|
|
render(
|
|
<MetricWidget
|
|
title="Revenue"
|
|
value="$1000"
|
|
growth={mixedGrowth}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText('+12.5%')).toBeInTheDocument();
|
|
expect(screen.getByText('-8.2%')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should format change values to one decimal place', () => {
|
|
const preciseGrowth = {
|
|
weekly: { value: 100, change: 5.456 },
|
|
monthly: { value: 400, change: -3.789 },
|
|
};
|
|
|
|
render(
|
|
<MetricWidget
|
|
title="Revenue"
|
|
value="$1000"
|
|
growth={preciseGrowth}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText('+5.5%')).toBeInTheDocument();
|
|
expect(screen.getByText('-3.8%')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Edit Mode', () => {
|
|
it('should not show edit controls when isEditing is false', () => {
|
|
const { container } = render(
|
|
<MetricWidget
|
|
title="Revenue"
|
|
value="$1000"
|
|
growth={mockGrowthData}
|
|
isEditing={false}
|
|
/>
|
|
);
|
|
|
|
const dragHandle = container.querySelector('.drag-handle');
|
|
expect(dragHandle).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('should show drag handle when in edit mode', () => {
|
|
const { container } = render(
|
|
<MetricWidget
|
|
title="Revenue"
|
|
value="$1000"
|
|
growth={mockGrowthData}
|
|
isEditing={true}
|
|
/>
|
|
);
|
|
|
|
const dragHandle = container.querySelector('.drag-handle');
|
|
expect(dragHandle).toBeInTheDocument();
|
|
});
|
|
|
|
it('should show remove button when in edit mode', () => {
|
|
render(
|
|
<MetricWidget
|
|
title="Revenue"
|
|
value="$1000"
|
|
growth={mockGrowthData}
|
|
isEditing={true}
|
|
onRemove={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
const removeButton = screen.getByRole('button');
|
|
expect(removeButton).toBeInTheDocument();
|
|
});
|
|
|
|
it('should call onRemove when remove button is clicked', async () => {
|
|
const user = userEvent.setup();
|
|
const handleRemove = vi.fn();
|
|
|
|
render(
|
|
<MetricWidget
|
|
title="Revenue"
|
|
value="$1000"
|
|
growth={mockGrowthData}
|
|
isEditing={true}
|
|
onRemove={handleRemove}
|
|
/>
|
|
);
|
|
|
|
const removeButton = screen.getByRole('button');
|
|
await user.click(removeButton);
|
|
|
|
expect(handleRemove).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should apply padding when in edit mode', () => {
|
|
const { container } = render(
|
|
<MetricWidget
|
|
title="Revenue"
|
|
value="$1000"
|
|
growth={mockGrowthData}
|
|
isEditing={true}
|
|
/>
|
|
);
|
|
|
|
const contentContainer = container.querySelector('.pl-5');
|
|
expect(contentContainer).toBeInTheDocument();
|
|
});
|
|
|
|
it('should not apply padding when not in edit mode', () => {
|
|
const { container } = render(
|
|
<MetricWidget
|
|
title="Revenue"
|
|
value="$1000"
|
|
growth={mockGrowthData}
|
|
isEditing={false}
|
|
/>
|
|
);
|
|
|
|
const contentContainer = container.querySelector('.pl-5');
|
|
expect(contentContainer).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Styling', () => {
|
|
it('should apply container styles', () => {
|
|
const { container } = render(
|
|
<MetricWidget
|
|
title="Revenue"
|
|
value="$1000"
|
|
growth={mockGrowthData}
|
|
/>
|
|
);
|
|
|
|
const widget = container.firstChild;
|
|
expect(widget).toHaveClass(
|
|
'h-full',
|
|
'p-4',
|
|
'bg-white',
|
|
'rounded-xl',
|
|
'border',
|
|
'border-gray-200',
|
|
'shadow-sm',
|
|
'relative',
|
|
'group'
|
|
);
|
|
});
|
|
|
|
it('should apply dark mode styles', () => {
|
|
const { container } = render(
|
|
<MetricWidget
|
|
title="Revenue"
|
|
value="$1000"
|
|
growth={mockGrowthData}
|
|
/>
|
|
);
|
|
|
|
const widget = container.firstChild;
|
|
expect(widget).toHaveClass('dark:bg-gray-800', 'dark:border-gray-700');
|
|
});
|
|
|
|
it('should apply trend badge styles', () => {
|
|
const { container } = render(
|
|
<MetricWidget
|
|
title="Revenue"
|
|
value="$1000"
|
|
growth={mockGrowthData}
|
|
/>
|
|
);
|
|
|
|
const badges = container.querySelectorAll('.rounded-full');
|
|
expect(badges.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('Accessibility', () => {
|
|
it('should have semantic HTML structure', () => {
|
|
const { container } = render(
|
|
<MetricWidget
|
|
title="Revenue"
|
|
value="$1000"
|
|
growth={mockGrowthData}
|
|
/>
|
|
);
|
|
|
|
const paragraphs = container.querySelectorAll('p');
|
|
const divs = container.querySelectorAll('div');
|
|
|
|
expect(paragraphs.length).toBeGreaterThan(0);
|
|
expect(divs.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should have readable text contrast', () => {
|
|
render(
|
|
<MetricWidget
|
|
title="Revenue"
|
|
value="$1000"
|
|
growth={mockGrowthData}
|
|
/>
|
|
);
|
|
|
|
const title = screen.getByText('Revenue');
|
|
expect(title).toHaveClass('text-gray-500');
|
|
});
|
|
|
|
it('should make remove button accessible when in edit mode', () => {
|
|
render(
|
|
<MetricWidget
|
|
title="Revenue"
|
|
value="$1000"
|
|
growth={mockGrowthData}
|
|
isEditing={true}
|
|
onRemove={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
const button = screen.getByRole('button');
|
|
expect(button).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Internationalization', () => {
|
|
it('should use translation for week label', () => {
|
|
render(
|
|
<MetricWidget
|
|
title="Revenue"
|
|
value="$1000"
|
|
growth={mockGrowthData}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText('Week:')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should use translation for month label', () => {
|
|
render(
|
|
<MetricWidget
|
|
title="Revenue"
|
|
value="$1000"
|
|
growth={mockGrowthData}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText('Month:')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Integration', () => {
|
|
it('should render correctly with all props', () => {
|
|
const CustomIcon = () => <span data-testid="icon">📊</span>;
|
|
const handleRemove = vi.fn();
|
|
const fullGrowth = {
|
|
weekly: { value: 150, change: 10 },
|
|
monthly: { value: 600, change: -5 },
|
|
};
|
|
|
|
render(
|
|
<MetricWidget
|
|
title="Total Revenue"
|
|
value="$15,000"
|
|
growth={fullGrowth}
|
|
icon={<CustomIcon />}
|
|
isEditing={true}
|
|
onRemove={handleRemove}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText('Total Revenue')).toBeInTheDocument();
|
|
expect(screen.getByText('$15,000')).toBeInTheDocument();
|
|
expect(screen.getByTestId('icon')).toBeInTheDocument();
|
|
expect(screen.getByText('+10.0%')).toBeInTheDocument();
|
|
expect(screen.getByText('-5.0%')).toBeInTheDocument();
|
|
expect(screen.getByRole('button')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should handle edge case values', () => {
|
|
const edgeCaseGrowth = {
|
|
weekly: { value: 0, change: 0 },
|
|
monthly: { value: 1000000, change: 99.9 },
|
|
};
|
|
|
|
render(
|
|
<MetricWidget
|
|
title="Edge Case"
|
|
value={0}
|
|
growth={edgeCaseGrowth}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText('Edge Case')).toBeInTheDocument();
|
|
expect(screen.getByText('0')).toBeInTheDocument();
|
|
expect(screen.getByText('0%')).toBeInTheDocument();
|
|
expect(screen.getByText('+99.9%')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should maintain layout with long titles', () => {
|
|
render(
|
|
<MetricWidget
|
|
title="Very Long Metric Title That Should Still Display Properly"
|
|
value="$1000"
|
|
growth={mockGrowthData}
|
|
/>
|
|
);
|
|
|
|
const title = screen.getByText('Very Long Metric Title That Should Still Display Properly');
|
|
expect(title).toBeInTheDocument();
|
|
expect(title).toHaveClass('text-sm');
|
|
});
|
|
|
|
it('should handle large numeric values', () => {
|
|
render(
|
|
<MetricWidget
|
|
title="Revenue"
|
|
value="$1,234,567,890"
|
|
growth={mockGrowthData}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText('$1,234,567,890')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should display multiple trend indicators simultaneously', () => {
|
|
const { container } = render(
|
|
<MetricWidget
|
|
title="Revenue"
|
|
value="$1000"
|
|
growth={mockGrowthData}
|
|
/>
|
|
);
|
|
|
|
// Should have trend indicators for both weekly and monthly
|
|
const trendBadges = container.querySelectorAll('.rounded-full');
|
|
expect(trendBadges.length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
});
|
|
});
|