Add admin hooks, components, and tests for statistics, navigation, and access control

- Introduced `useAdminStats`, `useAdminUsers`, and `useAdminOrganizations` hooks for admin data fetching with React Query.
- Added `AdminSidebar`, `Breadcrumbs`, and related navigation components for the admin section.
- Implemented comprehensive unit and integration tests for admin components.
- Created E2E tests for admin access control, navigation, and dashboard functionality.
- Updated exports to include new admin components.
This commit is contained in:
Felipe Cardoso
2025-11-06 00:35:11 +01:00
parent 11a78dfcc3
commit 67860c68e3
17 changed files with 2264 additions and 62 deletions

View File

@@ -1,73 +1,93 @@
/**
* Tests for Admin Dashboard Page
* Verifies rendering of admin page placeholder content
* Verifies rendering of admin dashboard with stats and quick actions
*/
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import AdminPage from '@/app/admin/page';
// Helper function to render with QueryClientProvider
function renderWithQueryClient(component: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return render(
<QueryClientProvider client={queryClient}>
{component}
</QueryClientProvider>
);
}
describe('AdminPage', () => {
it('renders admin dashboard title', () => {
render(<AdminPage />);
renderWithQueryClient(<AdminPage />);
expect(screen.getByText('Admin Dashboard')).toBeInTheDocument();
});
it('renders description text', () => {
render(<AdminPage />);
renderWithQueryClient(<AdminPage />);
expect(
screen.getByText('Manage users, organizations, and system settings')
).toBeInTheDocument();
});
it('renders users management card', () => {
render(<AdminPage />);
it('renders quick actions section', () => {
renderWithQueryClient(<AdminPage />);
expect(screen.getByText('Users')).toBeInTheDocument();
expect(screen.getByText('Quick Actions')).toBeInTheDocument();
});
it('renders user management card', () => {
renderWithQueryClient(<AdminPage />);
expect(screen.getByText('User Management')).toBeInTheDocument();
expect(
screen.getByText('Manage user accounts and permissions')
screen.getByText('View, create, and manage user accounts')
).toBeInTheDocument();
});
it('renders organizations management card', () => {
render(<AdminPage />);
it('renders organizations card', () => {
renderWithQueryClient(<AdminPage />);
expect(screen.getByText('Organizations')).toBeInTheDocument();
// Check for the quick actions card (not the stat card)
expect(
screen.getByText('View and manage organizations')
screen.getByText('Manage organizations and their members')
).toBeInTheDocument();
});
it('renders system settings card', () => {
render(<AdminPage />);
renderWithQueryClient(<AdminPage />);
expect(screen.getByText('System')).toBeInTheDocument();
expect(screen.getByText('System Settings')).toBeInTheDocument();
expect(
screen.getByText('System settings and configuration')
screen.getByText('Configure system-wide settings')
).toBeInTheDocument();
});
it('displays coming soon messages', () => {
render(<AdminPage />);
it('renders quick actions in grid layout', () => {
renderWithQueryClient(<AdminPage />);
const comingSoonMessages = screen.getAllByText('Coming soon...');
expect(comingSoonMessages).toHaveLength(3);
});
// Check for Quick Actions heading which is above the grid
expect(screen.getByText('Quick Actions')).toBeInTheDocument();
it('renders cards in grid layout', () => {
const { container } = render(<AdminPage />);
const grid = container.querySelector('.grid');
expect(grid).toBeInTheDocument();
expect(grid).toHaveClass('gap-4', 'md:grid-cols-2', 'lg:grid-cols-3');
// Verify all three quick action cards are present
expect(screen.getByText('User Management')).toBeInTheDocument();
expect(screen.getByText('System Settings')).toBeInTheDocument();
});
it('renders with proper container structure', () => {
const { container } = render(<AdminPage />);
const { container } = renderWithQueryClient(<AdminPage />);
const containerDiv = container.querySelector('.container');
expect(containerDiv).toBeInTheDocument();
expect(containerDiv).toHaveClass('mx-auto', 'px-4', 'py-8');
expect(containerDiv).toHaveClass('mx-auto', 'px-6', 'py-8');
});
});

View File

@@ -0,0 +1,375 @@
/**
* Tests for AdminSidebar Component
* Verifies navigation, active states, collapsible behavior, and user info display
*/
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AdminSidebar } from '@/components/admin/AdminSidebar';
import { useAuth } from '@/lib/auth/AuthContext';
import { usePathname } from 'next/navigation';
import type { User } from '@/lib/stores/authStore';
// Mock dependencies
jest.mock('@/lib/auth/AuthContext', () => ({
useAuth: jest.fn(),
AuthProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
jest.mock('next/navigation', () => ({
usePathname: jest.fn(),
}));
// Helper to create mock user
function createMockUser(overrides: Partial<User> = {}): User {
return {
id: 'user-123',
email: 'admin@example.com',
first_name: 'Admin',
last_name: 'User',
phone_number: null,
is_active: true,
is_superuser: true,
created_at: new Date().toISOString(),
updated_at: null,
...overrides,
};
}
describe('AdminSidebar', () => {
beforeEach(() => {
jest.clearAllMocks();
(usePathname as jest.Mock).mockReturnValue('/admin');
(useAuth as unknown as jest.Mock).mockReturnValue({
user: createMockUser(),
});
});
describe('Rendering', () => {
it('renders sidebar with admin panel title', () => {
render(<AdminSidebar />);
expect(screen.getByText('Admin Panel')).toBeInTheDocument();
});
it('renders sidebar with correct test id', () => {
render(<AdminSidebar />);
expect(screen.getByTestId('admin-sidebar')).toBeInTheDocument();
});
it('renders all navigation items', () => {
render(<AdminSidebar />);
expect(screen.getByTestId('nav-dashboard')).toBeInTheDocument();
expect(screen.getByTestId('nav-users')).toBeInTheDocument();
expect(screen.getByTestId('nav-organizations')).toBeInTheDocument();
expect(screen.getByTestId('nav-settings')).toBeInTheDocument();
});
it('renders navigation items with correct hrefs', () => {
render(<AdminSidebar />);
expect(screen.getByTestId('nav-dashboard')).toHaveAttribute('href', '/admin');
expect(screen.getByTestId('nav-users')).toHaveAttribute('href', '/admin/users');
expect(screen.getByTestId('nav-organizations')).toHaveAttribute('href', '/admin/organizations');
expect(screen.getByTestId('nav-settings')).toHaveAttribute('href', '/admin/settings');
});
it('renders navigation items with text labels', () => {
render(<AdminSidebar />);
expect(screen.getByText('Dashboard')).toBeInTheDocument();
expect(screen.getByText('Users')).toBeInTheDocument();
expect(screen.getByText('Organizations')).toBeInTheDocument();
expect(screen.getByText('Settings')).toBeInTheDocument();
});
it('renders collapse toggle button', () => {
render(<AdminSidebar />);
const toggleButton = screen.getByTestId('sidebar-toggle');
expect(toggleButton).toBeInTheDocument();
expect(toggleButton).toHaveAttribute('aria-label', 'Collapse sidebar');
});
});
describe('Active State Highlighting', () => {
it('highlights dashboard link when on /admin', () => {
(usePathname as jest.Mock).mockReturnValue('/admin');
render(<AdminSidebar />);
const dashboardLink = screen.getByTestId('nav-dashboard');
expect(dashboardLink).toHaveClass('bg-accent');
});
it('highlights users link when on /admin/users', () => {
(usePathname as jest.Mock).mockReturnValue('/admin/users');
render(<AdminSidebar />);
const usersLink = screen.getByTestId('nav-users');
expect(usersLink).toHaveClass('bg-accent');
});
it('highlights users link when on /admin/users/123', () => {
(usePathname as jest.Mock).mockReturnValue('/admin/users/123');
render(<AdminSidebar />);
const usersLink = screen.getByTestId('nav-users');
expect(usersLink).toHaveClass('bg-accent');
});
it('highlights organizations link when on /admin/organizations', () => {
(usePathname as jest.Mock).mockReturnValue('/admin/organizations');
render(<AdminSidebar />);
const orgsLink = screen.getByTestId('nav-organizations');
expect(orgsLink).toHaveClass('bg-accent');
});
it('highlights settings link when on /admin/settings', () => {
(usePathname as jest.Mock).mockReturnValue('/admin/settings');
render(<AdminSidebar />);
const settingsLink = screen.getByTestId('nav-settings');
expect(settingsLink).toHaveClass('bg-accent');
});
it('does not highlight dashboard when on other admin routes', () => {
(usePathname as jest.Mock).mockReturnValue('/admin/users');
render(<AdminSidebar />);
const dashboardLink = screen.getByTestId('nav-dashboard');
expect(dashboardLink).not.toHaveClass('bg-accent');
expect(dashboardLink).toHaveClass('text-muted-foreground');
});
});
describe('Collapsible Behavior', () => {
it('starts in expanded state', () => {
render(<AdminSidebar />);
// Title should be visible in expanded state
expect(screen.getByText('Admin Panel')).toBeInTheDocument();
// Navigation labels should be visible
expect(screen.getByText('Dashboard')).toBeInTheDocument();
});
it('collapses when toggle button is clicked', async () => {
const user = userEvent.setup();
render(<AdminSidebar />);
const toggleButton = screen.getByTestId('sidebar-toggle');
await user.click(toggleButton);
// Title should be hidden when collapsed
await waitFor(() => {
expect(screen.queryByText('Admin Panel')).not.toBeInTheDocument();
});
// Button aria-label should update
expect(toggleButton).toHaveAttribute('aria-label', 'Expand sidebar');
});
it('expands when toggle button is clicked twice', async () => {
const user = userEvent.setup();
render(<AdminSidebar />);
const toggleButton = screen.getByTestId('sidebar-toggle');
// Collapse
await user.click(toggleButton);
await waitFor(() => {
expect(screen.queryByText('Admin Panel')).not.toBeInTheDocument();
});
// Expand
await user.click(toggleButton);
await waitFor(() => {
expect(screen.getByText('Admin Panel')).toBeInTheDocument();
});
expect(toggleButton).toHaveAttribute('aria-label', 'Collapse sidebar');
});
it('adds title attribute to links when collapsed', async () => {
const user = userEvent.setup();
render(<AdminSidebar />);
const dashboardLink = screen.getByTestId('nav-dashboard');
// No title in expanded state
expect(dashboardLink).not.toHaveAttribute('title');
// Click to collapse
const toggleButton = screen.getByTestId('sidebar-toggle');
await user.click(toggleButton);
// Title should be present in collapsed state
await waitFor(() => {
expect(dashboardLink).toHaveAttribute('title', 'Dashboard');
});
});
it('hides navigation labels when collapsed', async () => {
const user = userEvent.setup();
render(<AdminSidebar />);
const toggleButton = screen.getByTestId('sidebar-toggle');
await user.click(toggleButton);
await waitFor(() => {
// Labels should not be visible (checking specific span text)
const dashboardSpan = screen.queryByText('Dashboard');
const usersSpan = screen.queryByText('Users');
const orgsSpan = screen.queryByText('Organizations');
const settingsSpan = screen.queryByText('Settings');
expect(dashboardSpan).not.toBeInTheDocument();
expect(usersSpan).not.toBeInTheDocument();
expect(orgsSpan).not.toBeInTheDocument();
expect(settingsSpan).not.toBeInTheDocument();
});
});
});
describe('User Info Display', () => {
it('displays user info when expanded', () => {
(useAuth as unknown as jest.Mock).mockReturnValue({
user: createMockUser({
first_name: 'John',
last_name: 'Doe',
email: 'john.doe@example.com',
}),
});
render(<AdminSidebar />);
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('john.doe@example.com')).toBeInTheDocument();
});
it('displays user initial from first name', () => {
(useAuth as unknown as jest.Mock).mockReturnValue({
user: createMockUser({
first_name: 'Alice',
last_name: 'Smith',
}),
});
render(<AdminSidebar />);
expect(screen.getByText('A')).toBeInTheDocument();
});
it('displays email initial when no first name', () => {
(useAuth as unknown as jest.Mock).mockReturnValue({
user: createMockUser({
first_name: '',
email: 'test@example.com',
}),
});
render(<AdminSidebar />);
expect(screen.getByText('T')).toBeInTheDocument();
});
it('hides user info when collapsed', async () => {
const user = userEvent.setup();
(useAuth as unknown as jest.Mock).mockReturnValue({
user: createMockUser({
first_name: 'John',
last_name: 'Doe',
email: 'john.doe@example.com',
}),
});
render(<AdminSidebar />);
// User info should be visible initially
expect(screen.getByText('John Doe')).toBeInTheDocument();
// Collapse sidebar
const toggleButton = screen.getByTestId('sidebar-toggle');
await user.click(toggleButton);
// User info should be hidden
await waitFor(() => {
expect(screen.queryByText('John Doe')).not.toBeInTheDocument();
expect(screen.queryByText('john.doe@example.com')).not.toBeInTheDocument();
});
});
it('does not render user info when user is null', () => {
(useAuth as unknown as jest.Mock).mockReturnValue({
user: null,
});
render(<AdminSidebar />);
// User info section should not be present
expect(screen.queryByText(/admin@example.com/i)).not.toBeInTheDocument();
});
it('truncates long user names', () => {
(useAuth as unknown as jest.Mock).mockReturnValue({
user: createMockUser({
first_name: 'VeryLongFirstName',
last_name: 'VeryLongLastName',
email: 'verylongemail@example.com',
}),
});
render(<AdminSidebar />);
const nameElement = screen.getByText('VeryLongFirstName VeryLongLastName');
expect(nameElement).toHaveClass('truncate');
const emailElement = screen.getByText('verylongemail@example.com');
expect(emailElement).toHaveClass('truncate');
});
});
describe('Accessibility', () => {
it('has proper aria-label on toggle button', () => {
render(<AdminSidebar />);
const toggleButton = screen.getByTestId('sidebar-toggle');
expect(toggleButton).toHaveAttribute('aria-label', 'Collapse sidebar');
});
it('updates aria-label when collapsed', async () => {
const user = userEvent.setup();
render(<AdminSidebar />);
const toggleButton = screen.getByTestId('sidebar-toggle');
await user.click(toggleButton);
await waitFor(() => {
expect(toggleButton).toHaveAttribute('aria-label', 'Expand sidebar');
});
});
it('navigation links are keyboard accessible', () => {
render(<AdminSidebar />);
const dashboardLink = screen.getByTestId('nav-dashboard');
const usersLink = screen.getByTestId('nav-users');
expect(dashboardLink.tagName).toBe('A');
expect(usersLink.tagName).toBe('A');
});
});
});

View File

@@ -0,0 +1,311 @@
/**
* Tests for Breadcrumbs Component
* Verifies breadcrumb generation, navigation, and accessibility
*/
import { render, screen } from '@testing-library/react';
import { Breadcrumbs } from '@/components/admin/Breadcrumbs';
import { usePathname } from 'next/navigation';
// Mock dependencies
jest.mock('next/navigation', () => ({
usePathname: jest.fn(),
}));
describe('Breadcrumbs', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('renders breadcrumbs container with correct test id', () => {
(usePathname as jest.Mock).mockReturnValue('/admin');
render(<Breadcrumbs />);
expect(screen.getByTestId('breadcrumbs')).toBeInTheDocument();
});
it('renders breadcrumbs with proper aria-label', () => {
(usePathname as jest.Mock).mockReturnValue('/admin');
render(<Breadcrumbs />);
const nav = screen.getByRole('navigation', { name: /breadcrumb/i });
expect(nav).toBeInTheDocument();
});
it('returns null for empty pathname', () => {
(usePathname as jest.Mock).mockReturnValue('');
const { container } = render(<Breadcrumbs />);
expect(container.firstChild).toBeNull();
});
it('returns null for root pathname', () => {
(usePathname as jest.Mock).mockReturnValue('/');
const { container } = render(<Breadcrumbs />);
expect(container.firstChild).toBeNull();
});
});
describe('Single Level Navigation', () => {
it('renders single breadcrumb for /admin', () => {
(usePathname as jest.Mock).mockReturnValue('/admin');
render(<Breadcrumbs />);
expect(screen.getByTestId('breadcrumb-admin')).toBeInTheDocument();
expect(screen.getByText('Admin')).toBeInTheDocument();
});
it('renders current page without link', () => {
(usePathname as jest.Mock).mockReturnValue('/admin');
render(<Breadcrumbs />);
const breadcrumb = screen.getByTestId('breadcrumb-admin');
expect(breadcrumb.tagName).toBe('SPAN');
expect(breadcrumb).toHaveAttribute('aria-current', 'page');
});
});
describe('Multi-Level Navigation', () => {
it('renders breadcrumbs for /admin/users', () => {
(usePathname as jest.Mock).mockReturnValue('/admin/users');
render(<Breadcrumbs />);
expect(screen.getByTestId('breadcrumb-admin')).toBeInTheDocument();
expect(screen.getByTestId('breadcrumb-users')).toBeInTheDocument();
});
it('renders parent breadcrumbs as links', () => {
(usePathname as jest.Mock).mockReturnValue('/admin/users');
render(<Breadcrumbs />);
const adminBreadcrumb = screen.getByTestId('breadcrumb-admin');
expect(adminBreadcrumb.tagName).toBe('A');
expect(adminBreadcrumb).toHaveAttribute('href', '/admin');
});
it('renders last breadcrumb as current page', () => {
(usePathname as jest.Mock).mockReturnValue('/admin/users');
render(<Breadcrumbs />);
const usersBreadcrumb = screen.getByTestId('breadcrumb-users');
expect(usersBreadcrumb.tagName).toBe('SPAN');
expect(usersBreadcrumb).toHaveAttribute('aria-current', 'page');
});
it('renders breadcrumbs for /admin/organizations', () => {
(usePathname as jest.Mock).mockReturnValue('/admin/organizations');
render(<Breadcrumbs />);
expect(screen.getByText('Admin')).toBeInTheDocument();
expect(screen.getByText('Organizations')).toBeInTheDocument();
});
it('renders breadcrumbs for /admin/settings', () => {
(usePathname as jest.Mock).mockReturnValue('/admin/settings');
render(<Breadcrumbs />);
expect(screen.getByText('Admin')).toBeInTheDocument();
expect(screen.getByText('Settings')).toBeInTheDocument();
});
});
describe('Three-Level Navigation', () => {
it('renders all levels correctly', () => {
(usePathname as jest.Mock).mockReturnValue('/admin/users/123');
render(<Breadcrumbs />);
expect(screen.getByTestId('breadcrumb-admin')).toBeInTheDocument();
expect(screen.getByTestId('breadcrumb-users')).toBeInTheDocument();
expect(screen.getByTestId('breadcrumb-123')).toBeInTheDocument();
});
it('renders all parent links correctly', () => {
(usePathname as jest.Mock).mockReturnValue('/admin/users/123');
render(<Breadcrumbs />);
const adminBreadcrumb = screen.getByTestId('breadcrumb-admin');
expect(adminBreadcrumb).toHaveAttribute('href', '/admin');
const usersBreadcrumb = screen.getByTestId('breadcrumb-users');
expect(usersBreadcrumb).toHaveAttribute('href', '/admin/users');
});
it('renders last level as current page', () => {
(usePathname as jest.Mock).mockReturnValue('/admin/users/123');
render(<Breadcrumbs />);
const lastBreadcrumb = screen.getByTestId('breadcrumb-123');
expect(lastBreadcrumb.tagName).toBe('SPAN');
expect(lastBreadcrumb).toHaveAttribute('aria-current', 'page');
});
});
describe('Separator Icons', () => {
it('renders separator between breadcrumbs', () => {
(usePathname as jest.Mock).mockReturnValue('/admin/users');
const { container } = render(<Breadcrumbs />);
// ChevronRight icons should be present
const icons = container.querySelectorAll('[aria-hidden="true"]');
expect(icons.length).toBeGreaterThan(0);
});
it('does not render separator before first breadcrumb', () => {
(usePathname as jest.Mock).mockReturnValue('/admin');
const { container } = render(<Breadcrumbs />);
// No separator icons for single breadcrumb
const icons = container.querySelectorAll('[aria-hidden="true"]');
expect(icons.length).toBe(0);
});
it('renders correct number of separators', () => {
(usePathname as jest.Mock).mockReturnValue('/admin/users/123');
const { container } = render(<Breadcrumbs />);
// 3 breadcrumbs = 2 separators
const icons = container.querySelectorAll('[aria-hidden="true"]');
expect(icons.length).toBe(2);
});
});
describe('Label Mapping', () => {
it('uses predefined label for admin', () => {
(usePathname as jest.Mock).mockReturnValue('/admin');
render(<Breadcrumbs />);
expect(screen.getByText('Admin')).toBeInTheDocument();
});
it('uses predefined label for users', () => {
(usePathname as jest.Mock).mockReturnValue('/admin/users');
render(<Breadcrumbs />);
expect(screen.getByText('Users')).toBeInTheDocument();
});
it('uses predefined label for organizations', () => {
(usePathname as jest.Mock).mockReturnValue('/admin/organizations');
render(<Breadcrumbs />);
expect(screen.getByText('Organizations')).toBeInTheDocument();
});
it('uses predefined label for settings', () => {
(usePathname as jest.Mock).mockReturnValue('/admin/settings');
render(<Breadcrumbs />);
expect(screen.getByText('Settings')).toBeInTheDocument();
});
it('uses pathname segment for unmapped paths', () => {
(usePathname as jest.Mock).mockReturnValue('/admin/unknown-path');
render(<Breadcrumbs />);
expect(screen.getByText('unknown-path')).toBeInTheDocument();
});
it('displays numeric IDs as-is', () => {
(usePathname as jest.Mock).mockReturnValue('/admin/users/123');
render(<Breadcrumbs />);
expect(screen.getByText('123')).toBeInTheDocument();
});
});
describe('Styling', () => {
it('applies correct styles to parent links', () => {
(usePathname as jest.Mock).mockReturnValue('/admin/users');
render(<Breadcrumbs />);
const adminBreadcrumb = screen.getByTestId('breadcrumb-admin');
expect(adminBreadcrumb).toHaveClass('text-muted-foreground');
expect(adminBreadcrumb).toHaveClass('hover:text-foreground');
});
it('applies correct styles to current page', () => {
(usePathname as jest.Mock).mockReturnValue('/admin/users');
render(<Breadcrumbs />);
const usersBreadcrumb = screen.getByTestId('breadcrumb-users');
expect(usersBreadcrumb).toHaveClass('font-medium');
expect(usersBreadcrumb).toHaveClass('text-foreground');
});
});
describe('Accessibility', () => {
it('has proper navigation role', () => {
(usePathname as jest.Mock).mockReturnValue('/admin');
render(<Breadcrumbs />);
expect(screen.getByRole('navigation')).toBeInTheDocument();
});
it('has aria-label for navigation', () => {
(usePathname as jest.Mock).mockReturnValue('/admin');
render(<Breadcrumbs />);
const nav = screen.getByRole('navigation');
expect(nav).toHaveAttribute('aria-label', 'Breadcrumb');
});
it('marks current page with aria-current', () => {
(usePathname as jest.Mock).mockReturnValue('/admin/users');
render(<Breadcrumbs />);
const currentPage = screen.getByTestId('breadcrumb-users');
expect(currentPage).toHaveAttribute('aria-current', 'page');
});
it('marks separator icons as aria-hidden', () => {
(usePathname as jest.Mock).mockReturnValue('/admin/users');
const { container } = render(<Breadcrumbs />);
const icons = container.querySelectorAll('[aria-hidden="true"]');
icons.forEach((icon) => {
expect(icon).toHaveAttribute('aria-hidden', 'true');
});
});
it('parent breadcrumbs are keyboard accessible', () => {
(usePathname as jest.Mock).mockReturnValue('/admin/users');
render(<Breadcrumbs />);
const adminLink = screen.getByTestId('breadcrumb-admin');
expect(adminLink.tagName).toBe('A');
expect(adminLink).toHaveAttribute('href');
});
});
});

View File

@@ -0,0 +1,324 @@
/**
* Tests for StatCard Component
* Verifies stat display, loading states, and trend indicators
*/
import { render, screen } from '@testing-library/react';
import { StatCard } from '@/components/admin/StatCard';
import { Users, Activity, Building2, FileText } from 'lucide-react';
describe('StatCard', () => {
const defaultProps = {
title: 'Total Users',
value: 1234,
icon: Users,
};
describe('Rendering', () => {
it('renders stat card with test id', () => {
render(<StatCard {...defaultProps} />);
expect(screen.getByTestId('stat-card')).toBeInTheDocument();
});
it('renders title correctly', () => {
render(<StatCard {...defaultProps} />);
expect(screen.getByTestId('stat-title')).toHaveTextContent('Total Users');
});
it('renders numeric value correctly', () => {
render(<StatCard {...defaultProps} />);
expect(screen.getByTestId('stat-value')).toHaveTextContent('1234');
});
it('renders string value correctly', () => {
render(<StatCard {...defaultProps} value="Active" />);
expect(screen.getByTestId('stat-value')).toHaveTextContent('Active');
});
it('renders icon', () => {
const { container } = render(<StatCard {...defaultProps} />);
// Icon should be rendered (lucide icons render as SVG)
const svg = container.querySelector('svg');
expect(svg).toBeInTheDocument();
});
it('renders description when provided', () => {
render(
<StatCard {...defaultProps} description="Total registered users" />
);
expect(screen.getByTestId('stat-description')).toHaveTextContent(
'Total registered users'
);
});
it('does not render description when not provided', () => {
render(<StatCard {...defaultProps} />);
expect(screen.queryByTestId('stat-description')).not.toBeInTheDocument();
});
});
describe('Loading State', () => {
it('applies loading class when loading', () => {
render(<StatCard {...defaultProps} loading />);
const card = screen.getByTestId('stat-card');
expect(card).toHaveClass('animate-pulse');
});
it('shows skeleton for value when loading', () => {
render(<StatCard {...defaultProps} loading />);
// Value should not be visible
expect(screen.queryByTestId('stat-value')).not.toBeInTheDocument();
// Skeleton placeholder should be present
const card = screen.getByTestId('stat-card');
const skeleton = card.querySelector('.bg-muted.rounded');
expect(skeleton).toBeInTheDocument();
});
it('hides description when loading', () => {
render(
<StatCard
{...defaultProps}
description="Test description"
loading
/>
);
expect(screen.queryByTestId('stat-description')).not.toBeInTheDocument();
});
it('hides trend when loading', () => {
render(
<StatCard
{...defaultProps}
trend={{ value: 10, label: 'vs last month', isPositive: true }}
loading
/>
);
expect(screen.queryByTestId('stat-trend')).not.toBeInTheDocument();
});
it('applies muted styles to icon when loading', () => {
const { container } = render(<StatCard {...defaultProps} loading />);
const icon = container.querySelector('svg');
expect(icon).toHaveClass('text-muted-foreground');
});
});
describe('Trend Indicator', () => {
it('renders positive trend correctly', () => {
render(
<StatCard
{...defaultProps}
trend={{ value: 12.5, label: 'vs last month', isPositive: true }}
/>
);
const trend = screen.getByTestId('stat-trend');
expect(trend).toBeInTheDocument();
expect(trend).toHaveTextContent('↑');
expect(trend).toHaveTextContent('12.5%');
expect(trend).toHaveTextContent('vs last month');
expect(trend).toHaveClass('text-green-600');
});
it('renders negative trend correctly', () => {
render(
<StatCard
{...defaultProps}
trend={{ value: 8.3, label: 'vs last week', isPositive: false }}
/>
);
const trend = screen.getByTestId('stat-trend');
expect(trend).toBeInTheDocument();
expect(trend).toHaveTextContent('↓');
expect(trend).toHaveTextContent('8.3%');
expect(trend).toHaveTextContent('vs last week');
expect(trend).toHaveClass('text-red-600');
});
it('handles negative trend values with absolute value', () => {
render(
<StatCard
{...defaultProps}
trend={{ value: -5.0, label: 'vs last month', isPositive: false }}
/>
);
const trend = screen.getByTestId('stat-trend');
// Should display absolute value
expect(trend).toHaveTextContent('5%');
expect(trend).not.toHaveTextContent('-5%');
});
it('does not render trend when not provided', () => {
render(<StatCard {...defaultProps} />);
expect(screen.queryByTestId('stat-trend')).not.toBeInTheDocument();
});
});
describe('Icon Variations', () => {
it('renders Users icon', () => {
const { container } = render(<StatCard {...defaultProps} icon={Users} />);
const svg = container.querySelector('svg');
expect(svg).toBeInTheDocument();
});
it('renders Activity icon', () => {
const { container } = render(
<StatCard {...defaultProps} icon={Activity} />
);
const svg = container.querySelector('svg');
expect(svg).toBeInTheDocument();
});
it('renders Building2 icon', () => {
const { container } = render(
<StatCard {...defaultProps} icon={Building2} />
);
const svg = container.querySelector('svg');
expect(svg).toBeInTheDocument();
});
it('renders FileText icon', () => {
const { container } = render(
<StatCard {...defaultProps} icon={FileText} />
);
const svg = container.querySelector('svg');
expect(svg).toBeInTheDocument();
});
});
describe('Styling', () => {
it('applies custom className', () => {
render(<StatCard {...defaultProps} className="custom-class" />);
const card = screen.getByTestId('stat-card');
expect(card).toHaveClass('custom-class');
});
it('applies default card styles', () => {
render(<StatCard {...defaultProps} />);
const card = screen.getByTestId('stat-card');
expect(card).toHaveClass('rounded-lg');
expect(card).toHaveClass('border');
expect(card).toHaveClass('bg-card');
expect(card).toHaveClass('p-6');
expect(card).toHaveClass('shadow-sm');
});
it('applies primary color to icon by default', () => {
const { container } = render(<StatCard {...defaultProps} />);
const icon = container.querySelector('svg');
expect(icon).toHaveClass('text-primary');
});
it('applies correct icon background', () => {
const { container } = render(<StatCard {...defaultProps} />);
const iconWrapper = container.querySelector('.rounded-full');
expect(iconWrapper).toHaveClass('bg-primary/10');
});
it('applies muted styles when loading', () => {
const { container } = render(<StatCard {...defaultProps} loading />);
const iconWrapper = container.querySelector('.rounded-full');
expect(iconWrapper).toHaveClass('bg-muted');
});
});
describe('Complex Scenarios', () => {
it('renders all props together', () => {
render(
<StatCard
title="Active Users"
value={856}
icon={Activity}
description="Currently online"
trend={{ value: 15.2, label: 'vs yesterday', isPositive: true }}
className="custom-stat"
/>
);
expect(screen.getByTestId('stat-title')).toHaveTextContent('Active Users');
expect(screen.getByTestId('stat-value')).toHaveTextContent('856');
expect(screen.getByTestId('stat-description')).toHaveTextContent(
'Currently online'
);
expect(screen.getByTestId('stat-trend')).toHaveTextContent('↑');
expect(screen.getByTestId('stat-card')).toHaveClass('custom-stat');
});
it('handles zero value', () => {
render(<StatCard {...defaultProps} value={0} />);
expect(screen.getByTestId('stat-value')).toHaveTextContent('0');
});
it('handles very large numbers', () => {
render(<StatCard {...defaultProps} value={1234567890} />);
expect(screen.getByTestId('stat-value')).toHaveTextContent('1234567890');
});
it('handles formatted string values', () => {
render(<StatCard {...defaultProps} value="1,234" />);
expect(screen.getByTestId('stat-value')).toHaveTextContent('1,234');
});
it('handles percentage string values', () => {
render(<StatCard {...defaultProps} value="98.5%" />);
expect(screen.getByTestId('stat-value')).toHaveTextContent('98.5%');
});
});
describe('Accessibility', () => {
it('renders semantic HTML structure', () => {
render(<StatCard {...defaultProps} />);
const card = screen.getByTestId('stat-card');
expect(card.tagName).toBe('DIV');
});
it('maintains readable text contrast', () => {
render(<StatCard {...defaultProps} />);
const title = screen.getByTestId('stat-title');
expect(title).toHaveClass('text-muted-foreground');
const value = screen.getByTestId('stat-value');
expect(value).toHaveClass('font-bold');
});
it('renders description with appropriate text size', () => {
render(
<StatCard {...defaultProps} description="Test description" />
);
const description = screen.getByTestId('stat-description');
expect(description).toHaveClass('text-xs');
});
});
});