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:
@@ -24,13 +24,19 @@ export default function AdminLayout({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<AuthGuard requireAdmin>
|
<AuthGuard requireAdmin>
|
||||||
|
<a
|
||||||
|
href="#main-content"
|
||||||
|
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-primary-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
Skip to main content
|
||||||
|
</a>
|
||||||
<div className="flex min-h-screen flex-col">
|
<div className="flex min-h-screen flex-col">
|
||||||
<Header />
|
<Header />
|
||||||
<div className="flex flex-1">
|
<div className="flex flex-1">
|
||||||
<AdminSidebar />
|
<AdminSidebar />
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
<Breadcrumbs />
|
<Breadcrumbs />
|
||||||
<main className="flex-1 overflow-y-auto">
|
<main id="main-content" className="flex-1 overflow-y-auto">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ export default function AdminOrganizationsPage() {
|
|||||||
{/* Back Button + Header */}
|
{/* Back Button + Header */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Link href="/admin">
|
<Link href="/admin">
|
||||||
<Button variant="outline" size="icon">
|
<Button variant="outline" size="icon" aria-label="Back to Admin Dashboard">
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export default function AdminPage() {
|
|||||||
<Link href="/admin/users" className="block">
|
<Link href="/admin/users" className="block">
|
||||||
<div className="rounded-lg border bg-card p-6 transition-colors hover:bg-accent cursor-pointer">
|
<div className="rounded-lg border bg-card p-6 transition-colors hover:bg-accent cursor-pointer">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<Users className="h-5 w-5 text-primary" />
|
<Users className="h-5 w-5 text-primary" aria-hidden="true" />
|
||||||
<h3 className="font-semibold">User Management</h3>
|
<h3 className="font-semibold">User Management</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -51,7 +51,7 @@ export default function AdminPage() {
|
|||||||
<Link href="/admin/organizations" className="block">
|
<Link href="/admin/organizations" className="block">
|
||||||
<div className="rounded-lg border bg-card p-6 transition-colors hover:bg-accent cursor-pointer">
|
<div className="rounded-lg border bg-card p-6 transition-colors hover:bg-accent cursor-pointer">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<Building2 className="h-5 w-5 text-primary" />
|
<Building2 className="h-5 w-5 text-primary" aria-hidden="true" />
|
||||||
<h3 className="font-semibold">Organizations</h3>
|
<h3 className="font-semibold">Organizations</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -63,7 +63,7 @@ export default function AdminPage() {
|
|||||||
<Link href="/admin/settings" className="block">
|
<Link href="/admin/settings" className="block">
|
||||||
<div className="rounded-lg border bg-card p-6 transition-colors hover:bg-accent cursor-pointer">
|
<div className="rounded-lg border bg-card p-6 transition-colors hover:bg-accent cursor-pointer">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<Settings className="h-5 w-5 text-primary" />
|
<Settings className="h-5 w-5 text-primary" aria-hidden="true" />
|
||||||
<h3 className="font-semibold">System Settings</h3>
|
<h3 className="font-semibold">System Settings</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ export default function AdminSettingsPage() {
|
|||||||
{/* Back Button + Header */}
|
{/* Back Button + Header */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Link href="/admin">
|
<Link href="/admin">
|
||||||
<Button variant="outline" size="icon">
|
<Button variant="outline" size="icon" aria-label="Back to Admin Dashboard">
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
53
frontend/src/app/forbidden/page.tsx
Normal file
53
frontend/src/app/forbidden/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="container mx-auto px-6 py-16">
|
||||||
|
<div className="flex min-h-[60vh] flex-col items-center justify-center text-center">
|
||||||
|
<div className="mb-8 rounded-full bg-destructive/10 p-6">
|
||||||
|
<ShieldAlert
|
||||||
|
className="h-16 w-16 text-destructive"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="mb-4 text-4xl font-bold tracking-tight">
|
||||||
|
403 - Access Forbidden
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mb-2 text-lg text-muted-foreground max-w-md">
|
||||||
|
You don't have permission to access this resource.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="mb-8 text-sm text-muted-foreground max-w-md">
|
||||||
|
This page requires administrator privileges. If you believe you should
|
||||||
|
have access, please contact your system administrator.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Button asChild variant="default">
|
||||||
|
<Link href="/dashboard">Go to Dashboard</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/">Go to Home</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -69,14 +69,14 @@ export function AdminSidebar() {
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setCollapsed(!collapsed)}
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
className="rounded-md p-2 hover:bg-accent"
|
className="rounded-md p-2 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||||
data-testid="sidebar-toggle"
|
data-testid="sidebar-toggle"
|
||||||
>
|
>
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" aria-hidden="true" />
|
||||||
) : (
|
) : (
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,6 +96,7 @@ export function AdminSidebar() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||||
'hover:bg-accent hover:text-accent-foreground',
|
'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
|
isActive
|
||||||
? 'bg-accent text-accent-foreground'
|
? 'bg-accent text-accent-foreground'
|
||||||
: 'text-muted-foreground',
|
: 'text-muted-foreground',
|
||||||
@@ -104,7 +105,7 @@ export function AdminSidebar() {
|
|||||||
title={collapsed ? item.name : undefined}
|
title={collapsed ? item.name : undefined}
|
||||||
data-testid={`nav-${item.name.toLowerCase()}`}
|
data-testid={`nav-${item.name.toLowerCase()}`}
|
||||||
>
|
>
|
||||||
<Icon className="h-5 w-5 flex-shrink-0" />
|
<Icon className="h-5 w-5 flex-shrink-0" aria-hidden="true" />
|
||||||
{!collapsed && <span>{item.name}</span>}
|
{!collapsed && <span>{item.name}</span>}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function DashboardStats() {
|
|||||||
if (isError) {
|
if (isError) {
|
||||||
return (
|
return (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" aria-hidden="true" />
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Failed to load dashboard statistics: {error?.message || 'Unknown error'}
|
Failed to load dashboard statistics: {error?.message || 'Unknown error'}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export function StatCard({
|
|||||||
'h-6 w-6',
|
'h-6 w-6',
|
||||||
loading ? 'text-muted-foreground' : 'text-primary'
|
loading ? 'text-muted-foreground' : 'text-primary'
|
||||||
)}
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,14 @@
|
|||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { adminListUsers, adminListOrganizations } from '@/lib/api/client';
|
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
|
* Admin Stats interface
|
||||||
@@ -31,6 +39,8 @@ export interface AdminStats {
|
|||||||
* @returns Admin statistics including user and organization counts
|
* @returns Admin statistics including user and organization counts
|
||||||
*/
|
*/
|
||||||
export function useAdminStats() {
|
export function useAdminStats() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['admin', 'stats'],
|
queryKey: ['admin', 'stats'],
|
||||||
queryFn: async (): Promise<AdminStats> => {
|
queryFn: async (): Promise<AdminStats> => {
|
||||||
@@ -39,7 +49,7 @@ export function useAdminStats() {
|
|||||||
const usersResponse = await adminListUsers({
|
const usersResponse = await adminListUsers({
|
||||||
query: {
|
query: {
|
||||||
page: 1,
|
page: 1,
|
||||||
limit: 10000, // High limit to get all users for stats
|
limit: STATS_FETCH_LIMIT,
|
||||||
},
|
},
|
||||||
throwOnError: false,
|
throwOnError: false,
|
||||||
});
|
});
|
||||||
@@ -58,7 +68,7 @@ export function useAdminStats() {
|
|||||||
const orgsResponse = await adminListOrganizations({
|
const orgsResponse = await adminListOrganizations({
|
||||||
query: {
|
query: {
|
||||||
page: 1,
|
page: 1,
|
||||||
limit: 10000, // High limit to get all orgs for stats
|
limit: STATS_FETCH_LIMIT,
|
||||||
},
|
},
|
||||||
throwOnError: false,
|
throwOnError: false,
|
||||||
});
|
});
|
||||||
@@ -93,9 +103,11 @@ export function useAdminStats() {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
// Refetch every 30 seconds for near real-time stats
|
// Refetch every 30 seconds for near real-time stats
|
||||||
refetchInterval: 30000,
|
refetchInterval: STATS_REFETCH_INTERVAL,
|
||||||
// Keep previous data while refetching to avoid UI flicker
|
// Keep previous data while refetching to avoid UI flicker
|
||||||
placeholderData: (previousData) => previousData,
|
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
|
* @param limit - Number of records per page
|
||||||
* @returns Paginated list of users
|
* @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({
|
return useQuery({
|
||||||
queryKey: ['admin', 'users', page, limit],
|
queryKey: ['admin', 'users', page, limit],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -122,6 +136,8 @@ export function useAdminUsers(page = 1, limit = 50) {
|
|||||||
// Type assertion: if no error, response has data
|
// Type assertion: if no error, response has data
|
||||||
return (response as { data: unknown }).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
|
* @param limit - Number of records per page
|
||||||
* @returns Paginated list of organizations
|
* @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({
|
return useQuery({
|
||||||
queryKey: ['admin', 'organizations', page, limit],
|
queryKey: ['admin', 'organizations', page, limit],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -148,5 +166,7 @@ export function useAdminOrganizations(page = 1, limit = 50) {
|
|||||||
// Type assertion: if no error, response has data
|
// Type assertion: if no error, response has data
|
||||||
return (response as { data: unknown }).data;
|
return (response as { data: unknown }).data;
|
||||||
},
|
},
|
||||||
|
// Only fetch if user is a superuser (frontend guard)
|
||||||
|
enabled: user?.is_superuser === true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
169
frontend/tests/app/admin/layout.test.tsx
Normal file
169
frontend/tests/app/admin/layout.test.tsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,35 +4,44 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
||||||
import AdminPage from '@/app/admin/page';
|
import AdminPage from '@/app/admin/page';
|
||||||
|
import { useAdminStats } from '@/lib/api/hooks/useAdmin';
|
||||||
|
|
||||||
// Helper function to render with QueryClientProvider
|
// Mock the useAdminStats hook
|
||||||
function renderWithQueryClient(component: React.ReactElement) {
|
jest.mock('@/lib/api/hooks/useAdmin');
|
||||||
const queryClient = new QueryClient({
|
|
||||||
defaultOptions: {
|
const mockUseAdminStats = useAdminStats as jest.MockedFunction<typeof useAdminStats>;
|
||||||
queries: {
|
|
||||||
retry: false,
|
// 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(
|
return render(<AdminPage />);
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
{component}
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('AdminPage', () => {
|
describe('AdminPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it('renders admin dashboard title', () => {
|
it('renders admin dashboard title', () => {
|
||||||
renderWithQueryClient(<AdminPage />);
|
renderWithMockedStats();
|
||||||
|
|
||||||
expect(screen.getByText('Admin Dashboard')).toBeInTheDocument();
|
expect(screen.getByText('Admin Dashboard')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders description text', () => {
|
it('renders description text', () => {
|
||||||
renderWithQueryClient(<AdminPage />);
|
renderWithMockedStats();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByText('Manage users, organizations, and system settings')
|
screen.getByText('Manage users, organizations, and system settings')
|
||||||
@@ -40,13 +49,13 @@ describe('AdminPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders quick actions section', () => {
|
it('renders quick actions section', () => {
|
||||||
renderWithQueryClient(<AdminPage />);
|
renderWithMockedStats();
|
||||||
|
|
||||||
expect(screen.getByText('Quick Actions')).toBeInTheDocument();
|
expect(screen.getByText('Quick Actions')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders user management card', () => {
|
it('renders user management card', () => {
|
||||||
renderWithQueryClient(<AdminPage />);
|
renderWithMockedStats();
|
||||||
|
|
||||||
expect(screen.getByText('User Management')).toBeInTheDocument();
|
expect(screen.getByText('User Management')).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
@@ -55,7 +64,7 @@ describe('AdminPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders organizations card', () => {
|
it('renders organizations card', () => {
|
||||||
renderWithQueryClient(<AdminPage />);
|
renderWithMockedStats();
|
||||||
|
|
||||||
// Check for the quick actions card (not the stat card)
|
// Check for the quick actions card (not the stat card)
|
||||||
expect(
|
expect(
|
||||||
@@ -64,7 +73,7 @@ describe('AdminPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders system settings card', () => {
|
it('renders system settings card', () => {
|
||||||
renderWithQueryClient(<AdminPage />);
|
renderWithMockedStats();
|
||||||
|
|
||||||
expect(screen.getByText('System Settings')).toBeInTheDocument();
|
expect(screen.getByText('System Settings')).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
@@ -73,7 +82,7 @@ describe('AdminPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders quick actions in grid layout', () => {
|
it('renders quick actions in grid layout', () => {
|
||||||
renderWithQueryClient(<AdminPage />);
|
renderWithMockedStats();
|
||||||
|
|
||||||
// Check for Quick Actions heading which is above the grid
|
// Check for Quick Actions heading which is above the grid
|
||||||
expect(screen.getByText('Quick Actions')).toBeInTheDocument();
|
expect(screen.getByText('Quick Actions')).toBeInTheDocument();
|
||||||
@@ -84,7 +93,7 @@ describe('AdminPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders with proper container structure', () => {
|
it('renders with proper container structure', () => {
|
||||||
const { container } = renderWithQueryClient(<AdminPage />);
|
const { container } = renderWithMockedStats();
|
||||||
|
|
||||||
const containerDiv = container.querySelector('.container');
|
const containerDiv = container.querySelector('.container');
|
||||||
expect(containerDiv).toBeInTheDocument();
|
expect(containerDiv).toBeInTheDocument();
|
||||||
|
|||||||
66
frontend/tests/app/forbidden/page.test.tsx
Normal file
66
frontend/tests/app/forbidden/page.test.tsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
157
frontend/tests/components/admin/DashboardStats.test.tsx
Normal file
157
frontend/tests/components/admin/DashboardStats.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
330
frontend/tests/lib/api/hooks/useAdmin.test.tsx
Normal file
330
frontend/tests/lib/api/hooks/useAdmin.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user