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

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

View File

@@ -0,0 +1,278 @@
/**
* E2E Tests for Admin Access Control
* Tests admin panel access, navigation, and stats display
*/
import { test, expect } from '@playwright/test';
import {
setupAuthenticatedMocks,
setupSuperuserMocks,
loginViaUI,
} from './helpers/auth';
test.describe('Admin Access Control', () => {
test('regular user should not see admin link in header', async ({ page }) => {
// Set up mocks for regular user (not superuser)
await setupAuthenticatedMocks(page);
await loginViaUI(page);
// Should not see admin link in navigation
const adminLinks = page.getByRole('link', { name: /admin/i });
const visibleAdminLinks = await adminLinks.count();
expect(visibleAdminLinks).toBe(0);
});
test('regular user should be redirected when accessing admin page directly', async ({
page,
}) => {
// Set up mocks for regular user
await setupAuthenticatedMocks(page);
await loginViaUI(page);
// Try to access admin page directly
await page.goto('/admin');
// Should be redirected away from admin (to login or home)
await page.waitForURL(/\/(auth\/login|$)/, { timeout: 5000 });
expect(page.url()).not.toContain('/admin');
});
test('superuser should see admin link in header', async ({ page }) => {
// Set up mocks for superuser
await setupSuperuserMocks(page);
await loginViaUI(page);
// Navigate to settings page to ensure user state is loaded
// (AuthGuard fetches user on protected pages)
await page.goto('/settings');
await page.waitForSelector('h1:has-text("Settings")', { timeout: 10000 });
// Should see admin link in header navigation bar
// Use exact text match to avoid matching "Admin Panel" from sidebar
const headerAdminLink = page
.locator('header nav')
.getByRole('link', { name: 'Admin', exact: true });
await expect(headerAdminLink).toBeVisible();
await expect(headerAdminLink).toHaveAttribute('href', '/admin');
});
test('superuser should be able to access admin dashboard', async ({
page,
}) => {
// Set up mocks for superuser
await setupSuperuserMocks(page);
await loginViaUI(page);
// Navigate to admin page
await page.goto('/admin');
// Should see admin dashboard
await expect(page).toHaveURL('/admin');
await expect(page.locator('h1')).toContainText('Admin Dashboard');
});
});
test.describe('Admin Dashboard', () => {
test.beforeEach(async ({ page }) => {
await setupSuperuserMocks(page);
await loginViaUI(page);
await page.goto('/admin');
});
test('should display page title and description', async ({ page }) => {
await expect(page.locator('h1')).toContainText('Admin Dashboard');
await expect(page.getByText(/manage users, organizations/i)).toBeVisible();
});
test('should display dashboard statistics', async ({ page }) => {
// Wait for stats container to be present
await page.waitForSelector('[data-testid="dashboard-stats"]', {
state: 'attached',
timeout: 15000,
});
// Wait for at least one stat card to finish loading (not in loading state)
await page.waitForSelector('[data-testid="stat-value"]', {
timeout: 15000,
});
// Should display all stat cards
const statCards = page.locator('[data-testid="stat-card"]');
await expect(statCards).toHaveCount(4);
// Should have stat titles (use test IDs to avoid ambiguity with sidebar)
const statTitles = page.locator('[data-testid="stat-title"]');
await expect(statTitles).toHaveCount(4);
await expect(statTitles.filter({ hasText: 'Total Users' })).toBeVisible();
await expect(statTitles.filter({ hasText: 'Active Users' })).toBeVisible();
await expect(statTitles.filter({ hasText: 'Organizations' })).toBeVisible();
await expect(
statTitles.filter({ hasText: 'Active Sessions' })
).toBeVisible();
});
test('should display quick action cards', async ({ page }) => {
await expect(
page.getByRole('heading', { name: 'Quick Actions', exact: true })
).toBeVisible();
// Should have three action cards (use unique descriptive text to avoid sidebar matches)
await expect(
page.getByText('View, create, and manage user accounts')
).toBeVisible();
await expect(
page.getByText('Manage organizations and their members')
).toBeVisible();
await expect(page.getByText('Configure system-wide settings')).toBeVisible();
});
});
test.describe('Admin Navigation', () => {
test.beforeEach(async ({ page }) => {
await setupSuperuserMocks(page);
await loginViaUI(page);
await page.goto('/admin');
});
test('should display admin sidebar', async ({ page }) => {
const sidebar = page.getByTestId('admin-sidebar');
await expect(sidebar).toBeVisible();
// Should have all navigation items
await expect(page.getByTestId('nav-dashboard')).toBeVisible();
await expect(page.getByTestId('nav-users')).toBeVisible();
await expect(page.getByTestId('nav-organizations')).toBeVisible();
await expect(page.getByTestId('nav-settings')).toBeVisible();
});
test('should display breadcrumbs', async ({ page }) => {
const breadcrumbs = page.getByTestId('breadcrumbs');
await expect(breadcrumbs).toBeVisible();
// Should show 'Admin' breadcrumb
await expect(page.getByTestId('breadcrumb-admin')).toBeVisible();
});
test('should navigate to users page', async ({ page }) => {
await page.goto('/admin/users');
await expect(page).toHaveURL('/admin/users');
await expect(page.locator('h1')).toContainText('User Management');
// Breadcrumbs should show Admin > Users
await expect(page.getByTestId('breadcrumb-admin')).toBeVisible();
await expect(page.getByTestId('breadcrumb-users')).toBeVisible();
// Sidebar users link should be active
const usersLink = page.getByTestId('nav-users');
await expect(usersLink).toHaveClass(/bg-accent/);
});
test('should navigate to organizations page', async ({ page }) => {
await page.goto('/admin/organizations');
await expect(page).toHaveURL('/admin/organizations');
await expect(page.locator('h1')).toContainText('Organizations');
// Breadcrumbs should show Admin > Organizations
await expect(page.getByTestId('breadcrumb-admin')).toBeVisible();
await expect(page.getByTestId('breadcrumb-organizations')).toBeVisible();
// Sidebar organizations link should be active
const orgsLink = page.getByTestId('nav-organizations');
await expect(orgsLink).toHaveClass(/bg-accent/);
});
test('should navigate to settings page', async ({ page }) => {
await page.goto('/admin/settings');
await expect(page).toHaveURL('/admin/settings');
await expect(page.locator('h1')).toContainText('System Settings');
// Breadcrumbs should show Admin > Settings
await expect(page.getByTestId('breadcrumb-admin')).toBeVisible();
await expect(page.getByTestId('breadcrumb-settings')).toBeVisible();
// Sidebar settings link should be active
const settingsLink = page.getByTestId('nav-settings');
await expect(settingsLink).toHaveClass(/bg-accent/);
});
test('should toggle sidebar collapse', async ({ page }) => {
const toggleButton = page.getByTestId('sidebar-toggle');
await expect(toggleButton).toBeVisible();
// Should show expanded text initially
await expect(page.getByText('Admin Panel')).toBeVisible();
// Click to collapse
await toggleButton.click();
// Text should be hidden when collapsed
await expect(page.getByText('Admin Panel')).not.toBeVisible();
// Click to expand
await toggleButton.click();
// Text should be visible again
await expect(page.getByText('Admin Panel')).toBeVisible();
});
test('should navigate back to dashboard from users page', async ({
page,
}) => {
await page.goto('/admin/users');
// Click dashboard link in sidebar
const dashboardLink = page.getByTestId('nav-dashboard');
await dashboardLink.click();
await page.waitForURL('/admin', { timeout: 5000 });
await expect(page).toHaveURL('/admin');
await expect(page.locator('h1')).toContainText('Admin Dashboard');
});
});
test.describe('Admin Breadcrumbs', () => {
test.beforeEach(async ({ page }) => {
await setupSuperuserMocks(page);
await loginViaUI(page);
});
test('should show single breadcrumb on dashboard', async ({ page }) => {
await page.goto('/admin');
const breadcrumbs = page.getByTestId('breadcrumbs');
await expect(breadcrumbs).toBeVisible();
// Should show only 'Admin' (as current page, not a link)
const adminBreadcrumb = page.getByTestId('breadcrumb-admin');
await expect(adminBreadcrumb).toBeVisible();
await expect(adminBreadcrumb).toHaveAttribute('aria-current', 'page');
});
test('should show clickable parent breadcrumb', async ({ page }) => {
await page.goto('/admin/users');
// 'Admin' should be a clickable link (test ID is on the Link element itself)
const adminBreadcrumb = page.getByTestId('breadcrumb-admin');
await expect(adminBreadcrumb).toBeVisible();
await expect(adminBreadcrumb).toHaveAttribute('href', '/admin');
// 'Users' should be current page (not a link, so it's a span)
const usersBreadcrumb = page.getByTestId('breadcrumb-users');
await expect(usersBreadcrumb).toBeVisible();
await expect(usersBreadcrumb).toHaveAttribute('aria-current', 'page');
});
test('should navigate via breadcrumb link', async ({ page }) => {
await page.goto('/admin/users');
// Click 'Admin' breadcrumb to go back to dashboard
const adminBreadcrumb = page.getByTestId('breadcrumb-admin');
await adminBreadcrumb.click();
await page.waitForURL('/admin', { timeout: 5000 });
await expect(page).toHaveURL('/admin');
});
});

View File

@@ -35,6 +35,21 @@ export const MOCK_SESSION = {
is_current: true,
};
/**
* Mock superuser data for E2E testing
*/
export const MOCK_SUPERUSER = {
id: '00000000-0000-0000-0000-000000000003',
email: 'admin@example.com',
first_name: 'Admin',
last_name: 'User',
phone_number: null,
is_active: true,
is_superuser: true,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
/**
* Authenticate user via REAL login flow
* Tests actual user behavior: fill form → submit → API call → store tokens → redirect
@@ -164,3 +179,129 @@ export async function setupAuthenticatedMocks(page: Page): Promise<void> {
* This tests the actual production code path including encryption.
*/
}
/**
* Set up API mocking for superuser E2E tests
* Similar to setupAuthenticatedMocks but returns MOCK_SUPERUSER instead
* Also mocks admin endpoints for stats display
*
* @param page Playwright page object
*/
export async function setupSuperuserMocks(page: Page): Promise<void> {
// Set E2E test mode flag
await page.addInitScript(() => {
(window as any).__PLAYWRIGHT_TEST__ = true;
});
const baseURL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000';
// Mock POST /api/v1/auth/login - Login endpoint (returns superuser)
await page.route(`${baseURL}/api/v1/auth/login`, async (route: Route) => {
if (route.request().method() === 'POST') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
user: MOCK_SUPERUSER,
access_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwMDAwMDAwMC0wMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDMiLCJleHAiOjk5OTk5OTk5OTl9.signature',
refresh_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwMDAwMDAwMC0wMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDQiLCJleHAiOjk5OTk5OTk5OTl9.signature',
expires_in: 3600,
token_type: 'bearer',
}),
});
} else {
await route.continue();
}
});
// Mock GET /api/v1/users/me - Get current user (superuser)
await page.route(`${baseURL}/api/v1/users/me`, async (route: Route) => {
if (route.request().method() === 'GET') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(MOCK_SUPERUSER),
});
} else if (route.request().method() === 'PATCH') {
const postData = route.request().postDataJSON();
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ...MOCK_SUPERUSER, ...postData }),
});
} else {
await route.continue();
}
});
// Mock GET /api/v1/admin/users - Get all users (admin endpoint)
await page.route(`${baseURL}/api/v1/admin/users*`, async (route: Route) => {
if (route.request().method() === 'GET') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: [MOCK_USER, MOCK_SUPERUSER],
pagination: {
total: 2,
page: 1,
page_size: 50,
total_pages: 1,
},
}),
});
} else {
await route.continue();
}
});
// Mock GET /api/v1/admin/organizations - Get all organizations (admin endpoint)
await page.route(`${baseURL}/api/v1/admin/organizations*`, async (route: Route) => {
if (route.request().method() === 'GET') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: [],
pagination: {
total: 0,
page: 1,
page_size: 50,
total_pages: 0,
},
}),
});
} else {
await route.continue();
}
});
// Mock sessions endpoints (same as regular user)
await page.route(`${baseURL}/api/v1/sessions**`, async (route: Route) => {
if (route.request().method() === 'GET') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
sessions: [MOCK_SESSION],
}),
});
} else {
await route.continue();
}
});
await page.route(`${baseURL}/api/v1/sessions/*`, async (route: Route) => {
if (route.request().method() === 'DELETE') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
message: 'Session revoked successfully',
}),
});
} else {
await route.continue();
}
});
}

View File

@@ -1,12 +1,14 @@
/**
* Admin Route Group Layout
* Wraps all admin routes with AuthGuard requiring superuser privileges
* Includes sidebar navigation and breadcrumbs
*/
import type { Metadata } from 'next';
import { AuthGuard } from '@/components/auth';
import { Header } from '@/components/layout/Header';
import { Footer } from '@/components/layout/Footer';
import { AdminSidebar, Breadcrumbs } from '@/components/admin';
export const metadata: Metadata = {
title: {
@@ -24,9 +26,15 @@ export default function AdminLayout({
<AuthGuard requireAdmin>
<div className="flex min-h-screen flex-col">
<Header />
<main className="flex-1">
{children}
</main>
<div className="flex flex-1">
<AdminSidebar />
<div className="flex flex-1 flex-col">
<Breadcrumbs />
<main className="flex-1 overflow-y-auto">
{children}
</main>
</div>
</div>
<Footer />
</div>
</AuthGuard>

View File

@@ -0,0 +1,62 @@
/**
* Admin Organizations Page
* Displays and manages all organizations
* Protected by AuthGuard in layout with requireAdmin=true
*/
/* istanbul ignore next - Next.js type import for metadata */
import type { Metadata } from 'next';
import Link from 'next/link';
import { ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
/* istanbul ignore next - Next.js metadata, not executable code */
export const metadata: Metadata = {
title: 'Organizations',
};
export default function AdminOrganizationsPage() {
return (
<div className="container mx-auto px-6 py-8">
<div className="space-y-6">
{/* Back Button + Header */}
<div className="flex items-center gap-4">
<Link href="/admin">
<Button variant="outline" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold tracking-tight">
Organizations
</h1>
<p className="mt-2 text-muted-foreground">
Manage organizations and their members
</p>
</div>
</div>
{/* Placeholder Content */}
<div className="rounded-lg border bg-card p-12 text-center">
<h3 className="text-xl font-semibold mb-2">
Organization Management Coming Soon
</h3>
<p className="text-muted-foreground max-w-md mx-auto">
This page will allow you to view all organizations, manage their
members, and perform administrative tasks.
</p>
<p className="text-sm text-muted-foreground mt-4">
Features will include:
</p>
<ul className="text-sm text-muted-foreground mt-2 max-w-sm mx-auto text-left">
<li> Organization list with search and filtering</li>
<li> View organization details and members</li>
<li> Manage organization memberships</li>
<li> Organization statistics and activity</li>
<li> Bulk operations</li>
</ul>
</div>
</div>
</div>
);
}

View File

@@ -1,11 +1,14 @@
/**
* Admin Dashboard Page
* Placeholder for future admin functionality
* Displays admin statistics and management options
* Protected by AuthGuard in layout with requireAdmin=true
*/
/* istanbul ignore next - Next.js type import for metadata */
import type { Metadata } from 'next';
import Link from 'next/link';
import { DashboardStats } from '@/components/admin';
import { Users, Building2, Settings } from 'lucide-react';
/* istanbul ignore next - Next.js metadata, not executable code */
export const metadata: Metadata = {
@@ -14,8 +17,9 @@ export const metadata: Metadata = {
export default function AdminPage() {
return (
<div className="container mx-auto px-4 py-8">
<div className="space-y-6">
<div className="container mx-auto px-6 py-8">
<div className="space-y-8">
{/* Page Header */}
<div>
<h1 className="text-3xl font-bold tracking-tight">
Admin Dashboard
@@ -25,35 +29,48 @@ export default function AdminPage() {
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<div className="rounded-lg border bg-card p-6">
<h3 className="font-semibold text-lg mb-2">Users</h3>
<p className="text-sm text-muted-foreground">
Manage user accounts and permissions
</p>
<p className="text-xs text-muted-foreground mt-4">
Coming soon...
</p>
</div>
{/* Stats Grid */}
<DashboardStats />
<div className="rounded-lg border bg-card p-6">
<h3 className="font-semibold text-lg mb-2">Organizations</h3>
<p className="text-sm text-muted-foreground">
View and manage organizations
</p>
<p className="text-xs text-muted-foreground mt-4">
Coming soon...
</p>
</div>
{/* Quick Actions */}
<div>
<h2 className="text-xl font-semibold mb-4">Quick Actions</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Link href="/admin/users" className="block">
<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">
<Users className="h-5 w-5 text-primary" />
<h3 className="font-semibold">User Management</h3>
</div>
<p className="text-sm text-muted-foreground">
View, create, and manage user accounts
</p>
</div>
</Link>
<div className="rounded-lg border bg-card p-6">
<h3 className="font-semibold text-lg mb-2">System</h3>
<p className="text-sm text-muted-foreground">
System settings and configuration
</p>
<p className="text-xs text-muted-foreground mt-4">
Coming soon...
</p>
<Link href="/admin/organizations" className="block">
<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">
<Building2 className="h-5 w-5 text-primary" />
<h3 className="font-semibold">Organizations</h3>
</div>
<p className="text-sm text-muted-foreground">
Manage organizations and their members
</p>
</div>
</Link>
<Link href="/admin/settings" className="block">
<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">
<Settings className="h-5 w-5 text-primary" />
<h3 className="font-semibold">System Settings</h3>
</div>
<p className="text-sm text-muted-foreground">
Configure system-wide settings
</p>
</div>
</Link>
</div>
</div>
</div>

View File

@@ -0,0 +1,62 @@
/**
* Admin Settings Page
* System-wide settings and configuration
* Protected by AuthGuard in layout with requireAdmin=true
*/
/* istanbul ignore next - Next.js type import for metadata */
import type { Metadata } from 'next';
import Link from 'next/link';
import { ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
/* istanbul ignore next - Next.js metadata, not executable code */
export const metadata: Metadata = {
title: 'System Settings',
};
export default function AdminSettingsPage() {
return (
<div className="container mx-auto px-6 py-8">
<div className="space-y-6">
{/* Back Button + Header */}
<div className="flex items-center gap-4">
<Link href="/admin">
<Button variant="outline" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold tracking-tight">
System Settings
</h1>
<p className="mt-2 text-muted-foreground">
Configure system-wide settings and preferences
</p>
</div>
</div>
{/* Placeholder Content */}
<div className="rounded-lg border bg-card p-12 text-center">
<h3 className="text-xl font-semibold mb-2">
System Settings Coming Soon
</h3>
<p className="text-muted-foreground max-w-md mx-auto">
This page will allow you to configure system-wide settings,
preferences, and advanced options.
</p>
<p className="text-sm text-muted-foreground mt-4">
Features will include:
</p>
<ul className="text-sm text-muted-foreground mt-2 max-w-sm mx-auto text-left">
<li> General system configuration</li>
<li> Email and notification settings</li>
<li> Security and authentication options</li>
<li> API and integration settings</li>
<li> Maintenance and backup tools</li>
</ul>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,62 @@
/**
* Admin Users Page
* Displays and manages all users
* Protected by AuthGuard in layout with requireAdmin=true
*/
/* istanbul ignore next - Next.js type import for metadata */
import type { Metadata } from 'next';
import Link from 'next/link';
import { ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
/* istanbul ignore next - Next.js metadata, not executable code */
export const metadata: Metadata = {
title: 'User Management',
};
export default function AdminUsersPage() {
return (
<div className="container mx-auto px-6 py-8">
<div className="space-y-6">
{/* Back Button + Header */}
<div className="flex items-center gap-4">
<Link href="/admin">
<Button variant="outline" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold tracking-tight">
User Management
</h1>
<p className="mt-2 text-muted-foreground">
View, create, and manage user accounts
</p>
</div>
</div>
{/* Placeholder Content */}
<div className="rounded-lg border bg-card p-12 text-center">
<h3 className="text-xl font-semibold mb-2">
User Management Coming Soon
</h3>
<p className="text-muted-foreground max-w-md mx-auto">
This page will allow you to view all users, create new accounts,
manage permissions, and perform bulk operations.
</p>
<p className="text-sm text-muted-foreground mt-4">
Features will include:
</p>
<ul className="text-sm text-muted-foreground mt-2 max-w-sm mx-auto text-left">
<li> User list with search and filtering</li>
<li> Create/edit/delete user accounts</li>
<li> Activate/deactivate users</li>
<li> Role and permission management</li>
<li> Bulk operations</li>
</ul>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,135 @@
/**
* Admin Sidebar Navigation
* Displays navigation links for admin section
*/
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
import {
LayoutDashboard,
Users,
Building2,
Settings,
ChevronLeft,
ChevronRight,
} from 'lucide-react';
import { useState } from 'react';
import { useAuth } from '@/lib/auth/AuthContext';
interface NavItem {
name: string;
href: string;
icon: React.ComponentType<{ className?: string }>;
}
const navItems: NavItem[] = [
{
name: 'Dashboard',
href: '/admin',
icon: LayoutDashboard,
},
{
name: 'Users',
href: '/admin/users',
icon: Users,
},
{
name: 'Organizations',
href: '/admin/organizations',
icon: Building2,
},
{
name: 'Settings',
href: '/admin/settings',
icon: Settings,
},
];
export function AdminSidebar() {
const pathname = usePathname();
const { user } = useAuth();
const [collapsed, setCollapsed] = useState(false);
return (
<aside
className={cn(
'border-r bg-muted/40 transition-all duration-300',
collapsed ? 'w-16' : 'w-64'
)}
data-testid="admin-sidebar"
>
<div className="flex h-full flex-col">
{/* Sidebar Header */}
<div className="flex h-16 items-center justify-between border-b px-4">
{!collapsed && (
<h2 className="text-lg font-semibold">Admin Panel</h2>
)}
<button
onClick={() => setCollapsed(!collapsed)}
className="rounded-md p-2 hover:bg-accent"
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
data-testid="sidebar-toggle"
>
{collapsed ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronLeft className="h-4 w-4" />
)}
</button>
</div>
{/* Navigation Links */}
<nav className="flex-1 space-y-1 p-2">
{navItems.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== '/admin' && pathname.startsWith(item.href));
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
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',
isActive
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground',
collapsed && 'justify-center'
)}
title={collapsed ? item.name : undefined}
data-testid={`nav-${item.name.toLowerCase()}`}
>
<Icon className="h-5 w-5 flex-shrink-0" />
{!collapsed && <span>{item.name}</span>}
</Link>
);
})}
</nav>
{/* User Info */}
{!collapsed && user && (
<div className="border-t p-4">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground text-sm font-medium">
{user.first_name?.[0] || user.email[0].toUpperCase()}
</div>
<div className="flex-1 overflow-hidden">
<p className="text-sm font-medium truncate">
{user.first_name} {user.last_name}
</p>
<p className="text-xs text-muted-foreground truncate">
{user.email}
</p>
</div>
</div>
</div>
)}
</div>
</aside>
);
}

View File

@@ -0,0 +1,92 @@
/**
* Admin Breadcrumbs
* Displays navigation breadcrumb trail for admin pages
*/
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { ChevronRight } from 'lucide-react';
interface BreadcrumbItem {
label: string;
href: string;
}
const pathLabels: Record<string, string> = {
admin: 'Admin',
users: 'Users',
organizations: 'Organizations',
settings: 'Settings',
};
export function Breadcrumbs() {
const pathname = usePathname();
// Generate breadcrumb items from pathname
const generateBreadcrumbs = (): BreadcrumbItem[] => {
const segments = pathname.split('/').filter(Boolean);
const breadcrumbs: BreadcrumbItem[] = [];
let currentPath = '';
segments.forEach((segment) => {
currentPath += `/${segment}`;
const label = pathLabels[segment] || segment;
breadcrumbs.push({
label,
href: currentPath,
});
});
return breadcrumbs;
};
const breadcrumbs = generateBreadcrumbs();
if (breadcrumbs.length === 0) {
return null;
}
return (
<nav
aria-label="Breadcrumb"
className="border-b bg-background px-6 py-3"
data-testid="breadcrumbs"
>
<ol className="flex items-center space-x-2 text-sm">
{breadcrumbs.map((breadcrumb, index) => {
const isLast = index === breadcrumbs.length - 1;
return (
<li key={breadcrumb.href} className="flex items-center">
{index > 0 && (
<ChevronRight
className="mx-2 h-4 w-4 text-muted-foreground"
aria-hidden="true"
/>
)}
{isLast ? (
<span
className="font-medium text-foreground"
aria-current="page"
data-testid={`breadcrumb-${breadcrumb.label.toLowerCase()}`}
>
{breadcrumb.label}
</span>
) : (
<Link
href={breadcrumb.href}
className="text-muted-foreground hover:text-foreground transition-colors"
data-testid={`breadcrumb-${breadcrumb.label.toLowerCase()}`}
>
{breadcrumb.label}
</Link>
)}
</li>
);
})}
</ol>
</nav>
);
}

View File

@@ -0,0 +1,63 @@
/**
* DashboardStats Component
* Displays admin dashboard statistics in stat cards
*/
'use client';
import { useAdminStats } from '@/lib/api/hooks/useAdmin';
import { StatCard } from './StatCard';
import { Users, UserCheck, Building2, Activity } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { AlertCircle } from 'lucide-react';
export function DashboardStats() {
const { data: stats, isLoading, isError, error } = useAdminStats();
if (isError) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Failed to load dashboard statistics: {error?.message || 'Unknown error'}
</AlertDescription>
</Alert>
);
}
return (
<div
className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"
data-testid="dashboard-stats"
>
<StatCard
title="Total Users"
value={stats?.totalUsers ?? 0}
icon={Users}
description="All registered users"
loading={isLoading}
/>
<StatCard
title="Active Users"
value={stats?.activeUsers ?? 0}
icon={UserCheck}
description="Users with active status"
loading={isLoading}
/>
<StatCard
title="Organizations"
value={stats?.totalOrganizations ?? 0}
icon={Building2}
description="Total organizations"
loading={isLoading}
/>
<StatCard
title="Active Sessions"
value={stats?.totalSessions ?? 0}
icon={Activity}
description="Current active sessions"
loading={isLoading}
/>
</div>
);
}

View File

@@ -0,0 +1,98 @@
/**
* StatCard Component
* Displays a statistic card with icon, title, and value
*/
import { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
interface StatCardProps {
title: string;
value: string | number;
icon: LucideIcon;
description?: string;
loading?: boolean;
trend?: {
value: number;
label: string;
isPositive?: boolean;
};
className?: string;
}
export function StatCard({
title,
value,
icon: Icon,
description,
loading = false,
trend,
className,
}: StatCardProps) {
return (
<div
className={cn(
'rounded-lg border bg-card p-6 shadow-sm',
loading && 'animate-pulse',
className
)}
data-testid="stat-card"
>
<div className="flex items-center justify-between">
<div className="space-y-1 flex-1">
<p
className="text-sm font-medium text-muted-foreground"
data-testid="stat-title"
>
{title}
</p>
<div className="flex items-baseline gap-2">
{loading ? (
<div className="h-8 w-24 bg-muted rounded" />
) : (
<p
className="text-3xl font-bold tracking-tight"
data-testid="stat-value"
>
{value}
</p>
)}
</div>
{description && !loading && (
<p
className="text-xs text-muted-foreground"
data-testid="stat-description"
>
{description}
</p>
)}
{trend && !loading && (
<div
className={cn(
'text-xs font-medium',
trend.isPositive ? 'text-green-600' : 'text-red-600'
)}
data-testid="stat-trend"
>
{trend.isPositive ? '↑' : '↓'} {Math.abs(trend.value)}%{' '}
{trend.label}
</div>
)}
</div>
<div
className={cn(
'rounded-full p-3',
loading ? 'bg-muted' : 'bg-primary/10'
)}
>
<Icon
className={cn(
'h-6 w-6',
loading ? 'text-muted-foreground' : 'text-primary'
)}
/>
</div>
</div>
</div>
);
}

View File

@@ -1,4 +1,6 @@
// Admin-specific components
// Examples: UserTable, OrganizationForm, StatisticsCard, etc.
export {};
export { AdminSidebar } from './AdminSidebar';
export { Breadcrumbs } from './Breadcrumbs';
export { StatCard } from './StatCard';
export { DashboardStats } from './DashboardStats';

View File

@@ -0,0 +1,152 @@
/**
* Admin Hooks
* React Query hooks for admin operations
*
* TODO - Stats Optimization (Option A):
* Currently calculating stats from multiple endpoints (Option B).
* For better performance at scale, consider implementing a dedicated
* /api/v1/admin/stats endpoint that returns pre-calculated counts
* to avoid fetching full lists.
*/
'use client';
import { useQuery } from '@tanstack/react-query';
import { adminListUsers, adminListOrganizations } from '@/lib/api/client';
/**
* Admin Stats interface
*/
export interface AdminStats {
totalUsers: number;
activeUsers: number;
totalOrganizations: number;
totalSessions: number; // TODO: Requires admin sessions endpoint
}
/**
* Hook to fetch admin statistics
* Calculates stats from existing endpoints (Option B)
*
* @returns Admin statistics including user and organization counts
*/
export function useAdminStats() {
return useQuery({
queryKey: ['admin', 'stats'],
queryFn: async (): Promise<AdminStats> => {
// Fetch users list
// Use high limit to get all users for stats calculation
const usersResponse = await adminListUsers({
query: {
page: 1,
limit: 10000, // High limit to get all users for stats
},
throwOnError: false,
});
if ('error' in usersResponse) {
throw new Error('Failed to fetch users');
}
// Type assertion: if no error, response has data
const usersData = (usersResponse as { data: { data: Array<{ is_active: boolean }>; pagination: { total: number } } }).data;
const users = usersData?.data || [];
const totalUsers = usersData?.pagination?.total || 0;
const activeUsers = users.filter((u) => u.is_active).length;
// Fetch organizations list
const orgsResponse = await adminListOrganizations({
query: {
page: 1,
limit: 10000, // High limit to get all orgs for stats
},
throwOnError: false,
});
if ('error' in orgsResponse) {
throw new Error('Failed to fetch organizations');
}
// Type assertion: if no error, response has data
const orgsData = (orgsResponse as { data: { pagination: { total: number } } }).data;
const totalOrganizations = orgsData?.pagination?.total || 0;
// TODO: Add admin sessions endpoint
// Currently no admin-level endpoint exists to fetch all sessions
// across all users. The /api/v1/sessions/me endpoint only returns
// sessions for the current user.
//
// Once backend implements /api/v1/admin/sessions, uncomment below:
// const sessionsResponse = await adminListSessions({
// query: { page: 1, limit: 10000 },
// throwOnError: false,
// });
// const totalSessions = sessionsResponse.data?.pagination?.total || 0;
const totalSessions = 0; // Placeholder until admin sessions endpoint exists
return {
totalUsers,
activeUsers,
totalOrganizations,
totalSessions,
};
},
// Refetch every 30 seconds for near real-time stats
refetchInterval: 30000,
// Keep previous data while refetching to avoid UI flicker
placeholderData: (previousData) => previousData,
});
}
/**
* Hook to fetch paginated list of all users (for admin)
*
* @param page - Page number (1-indexed)
* @param limit - Number of records per page
* @returns Paginated list of users
*/
export function useAdminUsers(page = 1, limit = 50) {
return useQuery({
queryKey: ['admin', 'users', page, limit],
queryFn: async () => {
const response = await adminListUsers({
query: { page, limit },
throwOnError: false,
});
if ('error' in response) {
throw new Error('Failed to fetch users');
}
// Type assertion: if no error, response has data
return (response as { data: unknown }).data;
},
});
}
/**
* Hook to fetch paginated list of all organizations (for admin)
*
* @param page - Page number (1-indexed)
* @param limit - Number of records per page
* @returns Paginated list of organizations
*/
export function useAdminOrganizations(page = 1, limit = 50) {
return useQuery({
queryKey: ['admin', 'organizations', page, limit],
queryFn: async () => {
const response = await adminListOrganizations({
query: { page, limit },
throwOnError: false,
});
if ('error' in response) {
throw new Error('Failed to fetch organizations');
}
// Type assertion: if no error, response has data
return (response as { data: unknown }).data;
},
});
}

View File

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

View File

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

View File

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

View File

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