Add admin UX improvements, constants refactor, and comprehensive tests

- Introduced constants for admin hooks: `STATS_FETCH_LIMIT`, `DEFAULT_PAGE_LIMIT`, and `STATS_REFETCH_INTERVAL` to enhance readability and maintainability.
- Updated query guards to ensure data fetching is restricted to superusers.
- Enhanced accessibility across admin components by adding `aria-hidden` attributes and improving focus-visible styles.
- Simplified `useAdminStats`, `useAdminUsers`, and `useAdminOrganizations` with shared constants.
- Added 403 Forbidden page with proper structure, styling, and tests.
- Implemented new tests for admin hooks, DashboardStats, AdminLayout, and ForbiddenPage for better coverage.
This commit is contained in:
Felipe Cardoso
2025-11-06 10:08:43 +01:00
parent abce06ad67
commit 9c72fe87f9
14 changed files with 852 additions and 40 deletions

View File

@@ -0,0 +1,169 @@
/**
* Tests for Admin Layout
* Verifies layout rendering, auth guard, and accessibility features
*/
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import AdminLayout from '@/app/admin/layout';
import { useAuth } from '@/lib/auth/AuthContext';
// Mock dependencies
jest.mock('@/lib/auth/AuthContext');
jest.mock('@/components/layout/Header', () => ({
Header: () => <header data-testid="header">Header</header>,
}));
jest.mock('@/components/layout/Footer', () => ({
Footer: () => <footer data-testid="footer">Footer</footer>,
}));
jest.mock('@/components/admin/AdminSidebar', () => ({
AdminSidebar: () => <aside data-testid="sidebar">Sidebar</aside>,
}));
jest.mock('@/components/admin/Breadcrumbs', () => ({
Breadcrumbs: () => <div data-testid="breadcrumbs">Breadcrumbs</div>,
}));
// Mock next/navigation
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: jest.fn(),
replace: jest.fn(),
prefetch: jest.fn(),
}),
usePathname: () => '/admin',
}));
const mockUseAuth = useAuth as jest.MockedFunction<typeof useAuth>;
describe('AdminLayout', () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
jest.clearAllMocks();
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
it('renders layout with all components for superuser', () => {
mockUseAuth.mockReturnValue({
user: { is_superuser: true } as any,
isAuthenticated: true,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
});
render(
<AdminLayout>
<div>Test Content</div>
</AdminLayout>,
{ wrapper }
);
expect(screen.getByTestId('header')).toBeInTheDocument();
expect(screen.getByTestId('footer')).toBeInTheDocument();
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
expect(screen.getByTestId('breadcrumbs')).toBeInTheDocument();
expect(screen.getByText('Test Content')).toBeInTheDocument();
});
it('renders skip link with correct attributes', () => {
mockUseAuth.mockReturnValue({
user: { is_superuser: true } as any,
isAuthenticated: true,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
});
render(
<AdminLayout>
<div>Test Content</div>
</AdminLayout>,
{ wrapper }
);
const skipLink = screen.getByText('Skip to main content');
expect(skipLink).toBeInTheDocument();
expect(skipLink).toHaveAttribute('href', '#main-content');
expect(skipLink).toHaveClass('sr-only');
});
it('renders main element with id', () => {
mockUseAuth.mockReturnValue({
user: { is_superuser: true } as any,
isAuthenticated: true,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
});
const { container } = render(
<AdminLayout>
<div>Test Content</div>
</AdminLayout>,
{ wrapper }
);
const mainElement = container.querySelector('#main-content');
expect(mainElement).toBeInTheDocument();
expect(mainElement?.tagName).toBe('MAIN');
});
it('renders children inside main content area', () => {
mockUseAuth.mockReturnValue({
user: { is_superuser: true } as any,
isAuthenticated: true,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
});
render(
<AdminLayout>
<div data-testid="child-content">Child Content</div>
</AdminLayout>,
{ wrapper }
);
const mainElement = screen.getByTestId('child-content').closest('main');
expect(mainElement).toHaveAttribute('id', 'main-content');
});
it('applies correct layout structure classes', () => {
mockUseAuth.mockReturnValue({
user: { is_superuser: true } as any,
isAuthenticated: true,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
});
const { container } = render(
<AdminLayout>
<div>Test Content</div>
</AdminLayout>,
{ wrapper }
);
// Check root container has min-height class
const rootDiv = container.querySelector('.min-h-screen');
expect(rootDiv).toBeInTheDocument();
expect(rootDiv).toHaveClass('flex', 'flex-col');
// Check main content area has flex and overflow classes
const mainElement = container.querySelector('#main-content');
expect(mainElement).toHaveClass('flex-1', 'overflow-y-auto');
});
});

View File

@@ -4,35 +4,44 @@
*/
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import AdminPage from '@/app/admin/page';
import { useAdminStats } from '@/lib/api/hooks/useAdmin';
// Helper function to render with QueryClientProvider
function renderWithQueryClient(component: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
// Mock the useAdminStats hook
jest.mock('@/lib/api/hooks/useAdmin');
const mockUseAdminStats = useAdminStats as jest.MockedFunction<typeof useAdminStats>;
// Helper function to render with default mocked stats
function renderWithMockedStats() {
mockUseAdminStats.mockReturnValue({
data: {
totalUsers: 100,
activeUsers: 80,
totalOrganizations: 20,
totalSessions: 30,
},
});
isLoading: false,
isError: false,
error: null,
} as any);
return render(
<QueryClientProvider client={queryClient}>
{component}
</QueryClientProvider>
);
return render(<AdminPage />);
}
describe('AdminPage', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders admin dashboard title', () => {
renderWithQueryClient(<AdminPage />);
renderWithMockedStats();
expect(screen.getByText('Admin Dashboard')).toBeInTheDocument();
});
it('renders description text', () => {
renderWithQueryClient(<AdminPage />);
renderWithMockedStats();
expect(
screen.getByText('Manage users, organizations, and system settings')
@@ -40,13 +49,13 @@ describe('AdminPage', () => {
});
it('renders quick actions section', () => {
renderWithQueryClient(<AdminPage />);
renderWithMockedStats();
expect(screen.getByText('Quick Actions')).toBeInTheDocument();
});
it('renders user management card', () => {
renderWithQueryClient(<AdminPage />);
renderWithMockedStats();
expect(screen.getByText('User Management')).toBeInTheDocument();
expect(
@@ -55,7 +64,7 @@ describe('AdminPage', () => {
});
it('renders organizations card', () => {
renderWithQueryClient(<AdminPage />);
renderWithMockedStats();
// Check for the quick actions card (not the stat card)
expect(
@@ -64,7 +73,7 @@ describe('AdminPage', () => {
});
it('renders system settings card', () => {
renderWithQueryClient(<AdminPage />);
renderWithMockedStats();
expect(screen.getByText('System Settings')).toBeInTheDocument();
expect(
@@ -73,7 +82,7 @@ describe('AdminPage', () => {
});
it('renders quick actions in grid layout', () => {
renderWithQueryClient(<AdminPage />);
renderWithMockedStats();
// Check for Quick Actions heading which is above the grid
expect(screen.getByText('Quick Actions')).toBeInTheDocument();
@@ -84,7 +93,7 @@ describe('AdminPage', () => {
});
it('renders with proper container structure', () => {
const { container } = renderWithQueryClient(<AdminPage />);
const { container } = renderWithMockedStats();
const containerDiv = container.querySelector('.container');
expect(containerDiv).toBeInTheDocument();

View File

@@ -0,0 +1,66 @@
/**
* Tests for 403 Forbidden Page
* Verifies rendering of access forbidden message and navigation
*/
import { render, screen } from '@testing-library/react';
import ForbiddenPage from '@/app/forbidden/page';
describe('ForbiddenPage', () => {
it('renders page heading', () => {
render(<ForbiddenPage />);
expect(
screen.getByRole('heading', { name: /403 - Access Forbidden/i })
).toBeInTheDocument();
});
it('renders permission denied message', () => {
render(<ForbiddenPage />);
expect(
screen.getByText(/You don't have permission to access this resource/)
).toBeInTheDocument();
});
it('renders admin privileges message', () => {
render(<ForbiddenPage />);
expect(
screen.getByText(/This page requires administrator privileges/)
).toBeInTheDocument();
});
it('renders link to dashboard', () => {
render(<ForbiddenPage />);
const dashboardLink = screen.getByRole('link', {
name: /Go to Dashboard/i,
});
expect(dashboardLink).toBeInTheDocument();
expect(dashboardLink).toHaveAttribute('href', '/dashboard');
});
it('renders link to home', () => {
render(<ForbiddenPage />);
const homeLink = screen.getByRole('link', { name: /Go to Home/i });
expect(homeLink).toBeInTheDocument();
expect(homeLink).toHaveAttribute('href', '/');
});
it('renders shield alert icon with aria-hidden', () => {
const { container } = render(<ForbiddenPage />);
const icon = container.querySelector('[aria-hidden="true"]');
expect(icon).toBeInTheDocument();
});
it('renders with proper container structure', () => {
const { container } = render(<ForbiddenPage />);
const containerDiv = container.querySelector('.container');
expect(containerDiv).toBeInTheDocument();
expect(containerDiv).toHaveClass('mx-auto', 'px-6', 'py-16');
});
});

View File

@@ -0,0 +1,157 @@
/**
* Tests for DashboardStats Component
* Verifies dashboard statistics display and error handling
*/
import { render, screen } from '@testing-library/react';
import { DashboardStats } from '@/components/admin/DashboardStats';
import { useAdminStats } from '@/lib/api/hooks/useAdmin';
// Mock the useAdminStats hook
jest.mock('@/lib/api/hooks/useAdmin');
const mockUseAdminStats = useAdminStats as jest.MockedFunction<typeof useAdminStats>;
describe('DashboardStats', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders all stat cards with data', () => {
mockUseAdminStats.mockReturnValue({
data: {
totalUsers: 150,
activeUsers: 120,
totalOrganizations: 25,
totalSessions: 45,
},
isLoading: false,
isError: false,
error: null,
} as any);
render(<DashboardStats />);
// Check stat cards are rendered
expect(screen.getByText('Total Users')).toBeInTheDocument();
expect(screen.getByText('150')).toBeInTheDocument();
expect(screen.getByText('All registered users')).toBeInTheDocument();
expect(screen.getByText('Active Users')).toBeInTheDocument();
expect(screen.getByText('120')).toBeInTheDocument();
expect(screen.getByText('Users with active status')).toBeInTheDocument();
expect(screen.getByText('Organizations')).toBeInTheDocument();
expect(screen.getByText('25')).toBeInTheDocument();
expect(screen.getByText('Total organizations')).toBeInTheDocument();
expect(screen.getByText('Active Sessions')).toBeInTheDocument();
expect(screen.getByText('45')).toBeInTheDocument();
expect(screen.getByText('Current active sessions')).toBeInTheDocument();
});
it('renders loading state', () => {
mockUseAdminStats.mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
error: null,
} as any);
render(<DashboardStats />);
// StatCard component should render loading state
expect(screen.getByText('Total Users')).toBeInTheDocument();
expect(screen.getByText('Active Users')).toBeInTheDocument();
expect(screen.getByText('Organizations')).toBeInTheDocument();
expect(screen.getByText('Active Sessions')).toBeInTheDocument();
});
it('renders error state', () => {
mockUseAdminStats.mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
error: new Error('Network error occurred'),
} as any);
render(<DashboardStats />);
expect(screen.getByText(/Failed to load dashboard statistics/)).toBeInTheDocument();
expect(screen.getByText(/Network error occurred/)).toBeInTheDocument();
});
it('renders error state with default message when error message is missing', () => {
mockUseAdminStats.mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
error: {} as any,
} as any);
render(<DashboardStats />);
expect(screen.getByText(/Failed to load dashboard statistics/)).toBeInTheDocument();
expect(screen.getByText(/Unknown error/)).toBeInTheDocument();
});
it('renders with zero values', () => {
mockUseAdminStats.mockReturnValue({
data: {
totalUsers: 0,
activeUsers: 0,
totalOrganizations: 0,
totalSessions: 0,
},
isLoading: false,
isError: false,
error: null,
} as any);
render(<DashboardStats />);
// Check all zeros are displayed
const zeroValues = screen.getAllByText('0');
expect(zeroValues.length).toBe(4); // 4 stat cards with 0 value
});
it('renders with dashboard-stats test id', () => {
mockUseAdminStats.mockReturnValue({
data: {
totalUsers: 100,
activeUsers: 80,
totalOrganizations: 20,
totalSessions: 30,
},
isLoading: false,
isError: false,
error: null,
} as any);
const { container } = render(<DashboardStats />);
const dashboardStats = container.querySelector('[data-testid="dashboard-stats"]');
expect(dashboardStats).toBeInTheDocument();
expect(dashboardStats).toHaveClass('grid', 'gap-4', 'md:grid-cols-2', 'lg:grid-cols-4');
});
it('renders icons with aria-hidden', () => {
mockUseAdminStats.mockReturnValue({
data: {
totalUsers: 100,
activeUsers: 80,
totalOrganizations: 20,
totalSessions: 30,
},
isLoading: false,
isError: false,
error: null,
} as any);
const { container } = render(<DashboardStats />);
// Check that icons have aria-hidden attribute
const icons = container.querySelectorAll('[aria-hidden="true"]');
expect(icons.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,330 @@
/**
* Tests for useAdmin hooks
* Verifies admin statistics and list fetching functionality
*/
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useAdminStats, useAdminUsers, useAdminOrganizations } from '@/lib/api/hooks/useAdmin';
import { adminListUsers, adminListOrganizations } from '@/lib/api/client';
import { useAuth } from '@/lib/auth/AuthContext';
// Mock dependencies
jest.mock('@/lib/api/client');
jest.mock('@/lib/auth/AuthContext');
const mockAdminListUsers = adminListUsers as jest.MockedFunction<typeof adminListUsers>;
const mockAdminListOrganizations = adminListOrganizations as jest.MockedFunction<typeof adminListOrganizations>;
const mockUseAuth = useAuth as jest.MockedFunction<typeof useAuth>;
describe('useAdmin hooks', () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
jest.clearAllMocks();
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
describe('useAdminStats', () => {
const mockUsersData = {
data: {
data: [
{ is_active: true },
{ is_active: true },
{ is_active: false },
],
pagination: { total: 3, page: 1, limit: 10000 },
},
};
const mockOrgsData = {
data: {
pagination: { total: 5 },
},
};
it('fetches and calculates stats when user is superuser', async () => {
mockUseAuth.mockReturnValue({
user: { is_superuser: true } as any,
isAuthenticated: true,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
});
mockAdminListUsers.mockResolvedValue(mockUsersData as any);
mockAdminListOrganizations.mockResolvedValue(mockOrgsData as any);
const { result } = renderHook(() => useAdminStats(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({
totalUsers: 3,
activeUsers: 2,
totalOrganizations: 5,
totalSessions: 0,
});
expect(mockAdminListUsers).toHaveBeenCalledWith({
query: { page: 1, limit: 10000 },
throwOnError: false,
});
expect(mockAdminListOrganizations).toHaveBeenCalledWith({
query: { page: 1, limit: 10000 },
throwOnError: false,
});
});
it('does not fetch when user is not superuser', async () => {
mockUseAuth.mockReturnValue({
user: { is_superuser: false } as any,
isAuthenticated: true,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
});
const { result } = renderHook(() => useAdminStats(), { wrapper });
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBeUndefined();
expect(mockAdminListUsers).not.toHaveBeenCalled();
expect(mockAdminListOrganizations).not.toHaveBeenCalled();
});
it('does not fetch when user is null', async () => {
mockUseAuth.mockReturnValue({
user: null,
isAuthenticated: false,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
});
const { result } = renderHook(() => useAdminStats(), { wrapper });
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBeUndefined();
expect(mockAdminListUsers).not.toHaveBeenCalled();
});
it('handles users API error', async () => {
mockUseAuth.mockReturnValue({
user: { is_superuser: true } as any,
isAuthenticated: true,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
});
mockAdminListUsers.mockResolvedValue({ error: 'Users fetch failed' } as any);
const { result } = renderHook(() => useAdminStats(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toBeDefined();
});
it('handles organizations API error', async () => {
mockUseAuth.mockReturnValue({
user: { is_superuser: true } as any,
isAuthenticated: true,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
});
mockAdminListUsers.mockResolvedValue(mockUsersData as any);
mockAdminListOrganizations.mockResolvedValue({ error: 'Orgs fetch failed' } as any);
const { result } = renderHook(() => useAdminStats(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toBeDefined();
});
});
describe('useAdminUsers', () => {
const mockResponse = {
data: {
data: [{ id: '1' }, { id: '2' }],
pagination: { total: 2, page: 1, limit: 50 },
},
};
it('fetches users when user is superuser', async () => {
mockUseAuth.mockReturnValue({
user: { is_superuser: true } as any,
isAuthenticated: true,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
});
mockAdminListUsers.mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useAdminUsers(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockResponse.data);
expect(mockAdminListUsers).toHaveBeenCalledWith({
query: { page: 1, limit: 50 },
throwOnError: false,
});
});
it('uses custom page and limit parameters', async () => {
mockUseAuth.mockReturnValue({
user: { is_superuser: true } as any,
isAuthenticated: true,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
});
mockAdminListUsers.mockResolvedValue(mockResponse as any);
renderHook(() => useAdminUsers(2, 100), { wrapper });
await waitFor(() => {
expect(mockAdminListUsers).toHaveBeenCalledWith({
query: { page: 2, limit: 100 },
throwOnError: false,
});
});
});
it('does not fetch when user is not superuser', async () => {
mockUseAuth.mockReturnValue({
user: { is_superuser: false } as any,
isAuthenticated: true,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
});
const { result } = renderHook(() => useAdminUsers(), { wrapper });
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBeUndefined();
expect(mockAdminListUsers).not.toHaveBeenCalled();
});
it('handles API error', async () => {
mockUseAuth.mockReturnValue({
user: { is_superuser: true } as any,
isAuthenticated: true,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
});
mockAdminListUsers.mockResolvedValue({ error: 'Fetch failed' } as any);
const { result } = renderHook(() => useAdminUsers(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toBeDefined();
});
});
describe('useAdminOrganizations', () => {
const mockResponse = {
data: {
data: [{ id: '1' }, { id: '2' }],
pagination: { total: 2, page: 1, limit: 50 },
},
};
it('fetches organizations when user is superuser', async () => {
mockUseAuth.mockReturnValue({
user: { is_superuser: true } as any,
isAuthenticated: true,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
});
mockAdminListOrganizations.mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useAdminOrganizations(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockResponse.data);
expect(mockAdminListOrganizations).toHaveBeenCalledWith({
query: { page: 1, limit: 50 },
throwOnError: false,
});
});
it('uses custom page and limit parameters', async () => {
mockUseAuth.mockReturnValue({
user: { is_superuser: true } as any,
isAuthenticated: true,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
});
mockAdminListOrganizations.mockResolvedValue(mockResponse as any);
renderHook(() => useAdminOrganizations(3, 25), { wrapper });
await waitFor(() => {
expect(mockAdminListOrganizations).toHaveBeenCalledWith({
query: { page: 3, limit: 25 },
throwOnError: false,
});
});
});
it('does not fetch when user is not superuser', async () => {
mockUseAuth.mockReturnValue({
user: { is_superuser: false } as any,
isAuthenticated: true,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
});
const { result } = renderHook(() => useAdminOrganizations(), { wrapper });
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBeUndefined();
expect(mockAdminListOrganizations).not.toHaveBeenCalled();
});
it('handles API error', async () => {
mockUseAuth.mockReturnValue({
user: { is_superuser: true } as any,
isAuthenticated: true,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
});
mockAdminListOrganizations.mockResolvedValue({ error: 'Fetch failed' } as any);
const { result } = renderHook(() => useAdminOrganizations(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toBeDefined();
});
});
});