From 9c72fe87f9f9d12843e4acab1971ce6f3f5b8336 Mon Sep 17 00:00:00 2001 From: Felipe Cardoso Date: Thu, 6 Nov 2025 10:08:43 +0100 Subject: [PATCH] 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. --- frontend/src/app/admin/layout.tsx | 8 +- frontend/src/app/admin/organizations/page.tsx | 4 +- frontend/src/app/admin/page.tsx | 6 +- frontend/src/app/admin/settings/page.tsx | 4 +- frontend/src/app/forbidden/page.tsx | 53 +++ .../src/components/admin/AdminSidebar.tsx | 9 +- .../src/components/admin/DashboardStats.tsx | 2 +- frontend/src/components/admin/StatCard.tsx | 1 + frontend/src/lib/api/hooks/useAdmin.tsx | 30 +- frontend/tests/app/admin/layout.test.tsx | 169 +++++++++ frontend/tests/app/admin/page.test.tsx | 53 +-- frontend/tests/app/forbidden/page.test.tsx | 66 ++++ .../components/admin/DashboardStats.test.tsx | 157 +++++++++ .../tests/lib/api/hooks/useAdmin.test.tsx | 330 ++++++++++++++++++ 14 files changed, 852 insertions(+), 40 deletions(-) create mode 100644 frontend/src/app/forbidden/page.tsx create mode 100644 frontend/tests/app/admin/layout.test.tsx create mode 100644 frontend/tests/app/forbidden/page.test.tsx create mode 100644 frontend/tests/components/admin/DashboardStats.test.tsx create mode 100644 frontend/tests/lib/api/hooks/useAdmin.test.tsx diff --git a/frontend/src/app/admin/layout.tsx b/frontend/src/app/admin/layout.tsx index 6fff351..391a548 100644 --- a/frontend/src/app/admin/layout.tsx +++ b/frontend/src/app/admin/layout.tsx @@ -24,13 +24,19 @@ export default function AdminLayout({ }) { return ( + + Skip to main content +
-
+
{children}
diff --git a/frontend/src/app/admin/organizations/page.tsx b/frontend/src/app/admin/organizations/page.tsx index 5221a64..89e26c7 100644 --- a/frontend/src/app/admin/organizations/page.tsx +++ b/frontend/src/app/admin/organizations/page.tsx @@ -22,8 +22,8 @@ export default function AdminOrganizationsPage() { {/* Back Button + Header */}
-
diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 045d91a..8f197a5 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -39,7 +39,7 @@ export default function AdminPage() {
- +

@@ -51,7 +51,7 @@ export default function AdminPage() {

- +

@@ -63,7 +63,7 @@ export default function AdminPage() {

- +

diff --git a/frontend/src/app/admin/settings/page.tsx b/frontend/src/app/admin/settings/page.tsx index 44488f0..d20f1ab 100644 --- a/frontend/src/app/admin/settings/page.tsx +++ b/frontend/src/app/admin/settings/page.tsx @@ -22,8 +22,8 @@ export default function AdminSettingsPage() { {/* Back Button + Header */}

-
diff --git a/frontend/src/app/forbidden/page.tsx b/frontend/src/app/forbidden/page.tsx new file mode 100644 index 0000000..d3b5778 --- /dev/null +++ b/frontend/src/app/forbidden/page.tsx @@ -0,0 +1,53 @@ +/** + * 403 Forbidden Page + * Displayed when users try to access resources they don't have permission for + */ + +/* istanbul ignore next - Next.js type import for metadata */ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { ShieldAlert } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +/* istanbul ignore next - Next.js metadata, not executable code */ +export const metadata: Metadata = { + title: '403 - Forbidden', + description: 'You do not have permission to access this resource', +}; + +export default function ForbiddenPage() { + return ( +
+
+
+
+ +

+ 403 - Access Forbidden +

+ +

+ You don't have permission to access this resource. +

+ +

+ This page requires administrator privileges. If you believe you should + have access, please contact your system administrator. +

+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/admin/AdminSidebar.tsx b/frontend/src/components/admin/AdminSidebar.tsx index ceb84c5..9108463 100644 --- a/frontend/src/components/admin/AdminSidebar.tsx +++ b/frontend/src/components/admin/AdminSidebar.tsx @@ -69,14 +69,14 @@ export function AdminSidebar() { )}
@@ -96,6 +96,7 @@ export function AdminSidebar() { className={cn( 'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors', 'hover:bg-accent hover:text-accent-foreground', + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', isActive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground', @@ -104,7 +105,7 @@ export function AdminSidebar() { title={collapsed ? item.name : undefined} data-testid={`nav-${item.name.toLowerCase()}`} > - +
diff --git a/frontend/src/lib/api/hooks/useAdmin.tsx b/frontend/src/lib/api/hooks/useAdmin.tsx index 762a150..ebe60b7 100644 --- a/frontend/src/lib/api/hooks/useAdmin.tsx +++ b/frontend/src/lib/api/hooks/useAdmin.tsx @@ -13,6 +13,14 @@ import { useQuery } from '@tanstack/react-query'; import { adminListUsers, adminListOrganizations } from '@/lib/api/client'; +import { useAuth } from '@/lib/auth/AuthContext'; + +/** + * Constants for admin hooks + */ +const STATS_FETCH_LIMIT = 10000; // High limit to fetch all records for stats calculation +const STATS_REFETCH_INTERVAL = 30000; // 30 seconds - refetch interval for near real-time stats +const DEFAULT_PAGE_LIMIT = 50; // Default number of records per page for paginated lists /** * Admin Stats interface @@ -31,6 +39,8 @@ export interface AdminStats { * @returns Admin statistics including user and organization counts */ export function useAdminStats() { + const { user } = useAuth(); + return useQuery({ queryKey: ['admin', 'stats'], queryFn: async (): Promise => { @@ -39,7 +49,7 @@ export function useAdminStats() { const usersResponse = await adminListUsers({ query: { page: 1, - limit: 10000, // High limit to get all users for stats + limit: STATS_FETCH_LIMIT, }, throwOnError: false, }); @@ -58,7 +68,7 @@ export function useAdminStats() { const orgsResponse = await adminListOrganizations({ query: { page: 1, - limit: 10000, // High limit to get all orgs for stats + limit: STATS_FETCH_LIMIT, }, throwOnError: false, }); @@ -93,9 +103,11 @@ export function useAdminStats() { }; }, // Refetch every 30 seconds for near real-time stats - refetchInterval: 30000, + refetchInterval: STATS_REFETCH_INTERVAL, // Keep previous data while refetching to avoid UI flicker placeholderData: (previousData) => previousData, + // Only fetch if user is a superuser (frontend guard) + enabled: user?.is_superuser === true, }); } @@ -106,7 +118,9 @@ export function useAdminStats() { * @param limit - Number of records per page * @returns Paginated list of users */ -export function useAdminUsers(page = 1, limit = 50) { +export function useAdminUsers(page = 1, limit = DEFAULT_PAGE_LIMIT) { + const { user } = useAuth(); + return useQuery({ queryKey: ['admin', 'users', page, limit], queryFn: async () => { @@ -122,6 +136,8 @@ export function useAdminUsers(page = 1, limit = 50) { // Type assertion: if no error, response has data return (response as { data: unknown }).data; }, + // Only fetch if user is a superuser (frontend guard) + enabled: user?.is_superuser === true, }); } @@ -132,7 +148,9 @@ export function useAdminUsers(page = 1, limit = 50) { * @param limit - Number of records per page * @returns Paginated list of organizations */ -export function useAdminOrganizations(page = 1, limit = 50) { +export function useAdminOrganizations(page = 1, limit = DEFAULT_PAGE_LIMIT) { + const { user } = useAuth(); + return useQuery({ queryKey: ['admin', 'organizations', page, limit], queryFn: async () => { @@ -148,5 +166,7 @@ export function useAdminOrganizations(page = 1, limit = 50) { // Type assertion: if no error, response has data return (response as { data: unknown }).data; }, + // Only fetch if user is a superuser (frontend guard) + enabled: user?.is_superuser === true, }); } diff --git a/frontend/tests/app/admin/layout.test.tsx b/frontend/tests/app/admin/layout.test.tsx new file mode 100644 index 0000000..603a1fc --- /dev/null +++ b/frontend/tests/app/admin/layout.test.tsx @@ -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
, +})); +jest.mock('@/components/layout/Footer', () => ({ + Footer: () =>
Footer
, +})); +jest.mock('@/components/admin/AdminSidebar', () => ({ + AdminSidebar: () => , +})); +jest.mock('@/components/admin/Breadcrumbs', () => ({ + Breadcrumbs: () =>
Breadcrumbs
, +})); + +// 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; + +describe('AdminLayout', () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + jest.clearAllMocks(); + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + 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( + +
Test Content
+
, + { 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( + +
Test Content
+
, + { 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( + +
Test Content
+
, + { 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( + +
Child Content
+
, + { 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( + +
Test Content
+
, + { 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'); + }); +}); diff --git a/frontend/tests/app/admin/page.test.tsx b/frontend/tests/app/admin/page.test.tsx index 3506532..51cae4c 100644 --- a/frontend/tests/app/admin/page.test.tsx +++ b/frontend/tests/app/admin/page.test.tsx @@ -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; + +// 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( - - {component} - - ); + return render(); } describe('AdminPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('renders admin dashboard title', () => { - renderWithQueryClient(); + renderWithMockedStats(); expect(screen.getByText('Admin Dashboard')).toBeInTheDocument(); }); it('renders description text', () => { - renderWithQueryClient(); + renderWithMockedStats(); expect( screen.getByText('Manage users, organizations, and system settings') @@ -40,13 +49,13 @@ describe('AdminPage', () => { }); it('renders quick actions section', () => { - renderWithQueryClient(); + renderWithMockedStats(); expect(screen.getByText('Quick Actions')).toBeInTheDocument(); }); it('renders user management card', () => { - renderWithQueryClient(); + renderWithMockedStats(); expect(screen.getByText('User Management')).toBeInTheDocument(); expect( @@ -55,7 +64,7 @@ describe('AdminPage', () => { }); it('renders organizations card', () => { - renderWithQueryClient(); + renderWithMockedStats(); // Check for the quick actions card (not the stat card) expect( @@ -64,7 +73,7 @@ describe('AdminPage', () => { }); it('renders system settings card', () => { - renderWithQueryClient(); + renderWithMockedStats(); expect(screen.getByText('System Settings')).toBeInTheDocument(); expect( @@ -73,7 +82,7 @@ describe('AdminPage', () => { }); it('renders quick actions in grid layout', () => { - renderWithQueryClient(); + 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(); + const { container } = renderWithMockedStats(); const containerDiv = container.querySelector('.container'); expect(containerDiv).toBeInTheDocument(); diff --git a/frontend/tests/app/forbidden/page.test.tsx b/frontend/tests/app/forbidden/page.test.tsx new file mode 100644 index 0000000..8c8e4e9 --- /dev/null +++ b/frontend/tests/app/forbidden/page.test.tsx @@ -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(); + + expect( + screen.getByRole('heading', { name: /403 - Access Forbidden/i }) + ).toBeInTheDocument(); + }); + + it('renders permission denied message', () => { + render(); + + expect( + screen.getByText(/You don't have permission to access this resource/) + ).toBeInTheDocument(); + }); + + it('renders admin privileges message', () => { + render(); + + expect( + screen.getByText(/This page requires administrator privileges/) + ).toBeInTheDocument(); + }); + + it('renders link to dashboard', () => { + render(); + + const dashboardLink = screen.getByRole('link', { + name: /Go to Dashboard/i, + }); + expect(dashboardLink).toBeInTheDocument(); + expect(dashboardLink).toHaveAttribute('href', '/dashboard'); + }); + + it('renders link to home', () => { + render(); + + 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(); + + const icon = container.querySelector('[aria-hidden="true"]'); + expect(icon).toBeInTheDocument(); + }); + + it('renders with proper container structure', () => { + const { container } = render(); + + const containerDiv = container.querySelector('.container'); + expect(containerDiv).toBeInTheDocument(); + expect(containerDiv).toHaveClass('mx-auto', 'px-6', 'py-16'); + }); +}); diff --git a/frontend/tests/components/admin/DashboardStats.test.tsx b/frontend/tests/components/admin/DashboardStats.test.tsx new file mode 100644 index 0000000..20ba79b --- /dev/null +++ b/frontend/tests/components/admin/DashboardStats.test.tsx @@ -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; + +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(); + + // 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(); + + // 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(); + + 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(); + + 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(); + + // 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(); + + 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(); + + // Check that icons have aria-hidden attribute + const icons = container.querySelectorAll('[aria-hidden="true"]'); + expect(icons.length).toBeGreaterThan(0); + }); +}); diff --git a/frontend/tests/lib/api/hooks/useAdmin.test.tsx b/frontend/tests/lib/api/hooks/useAdmin.test.tsx new file mode 100644 index 0000000..36558e5 --- /dev/null +++ b/frontend/tests/lib/api/hooks/useAdmin.test.tsx @@ -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; +const mockAdminListOrganizations = adminListOrganizations as jest.MockedFunction; +const mockUseAuth = useAuth as jest.MockedFunction; + +describe('useAdmin hooks', () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + jest.clearAllMocks(); + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + 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(); + }); + }); +});