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:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
375
frontend/tests/components/admin/AdminSidebar.test.tsx
Normal file
375
frontend/tests/components/admin/AdminSidebar.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
311
frontend/tests/components/admin/Breadcrumbs.test.tsx
Normal file
311
frontend/tests/components/admin/Breadcrumbs.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
324
frontend/tests/components/admin/StatCard.test.tsx
Normal file
324
frontend/tests/components/admin/StatCard.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user