From 67860c68e393d578273a97e705d118a25c46721f Mon Sep 17 00:00:00 2001 From: Felipe Cardoso Date: Thu, 6 Nov 2025 00:35:11 +0100 Subject: [PATCH] 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. --- frontend/e2e/admin-access.spec.ts | 278 +++++++++++++ frontend/e2e/helpers/auth.ts | 141 +++++++ frontend/src/app/admin/layout.tsx | 14 +- frontend/src/app/admin/organizations/page.tsx | 62 +++ frontend/src/app/admin/page.tsx | 77 ++-- frontend/src/app/admin/settings/page.tsx | 62 +++ frontend/src/app/admin/users/page.tsx | 62 +++ .../src/components/admin/AdminSidebar.tsx | 135 +++++++ frontend/src/components/admin/Breadcrumbs.tsx | 92 +++++ .../src/components/admin/DashboardStats.tsx | 63 +++ frontend/src/components/admin/StatCard.tsx | 98 +++++ frontend/src/components/admin/index.ts | 6 +- frontend/src/lib/api/hooks/useAdmin.tsx | 152 +++++++ frontend/tests/app/admin/page.test.tsx | 74 ++-- .../components/admin/AdminSidebar.test.tsx | 375 ++++++++++++++++++ .../components/admin/Breadcrumbs.test.tsx | 311 +++++++++++++++ .../tests/components/admin/StatCard.test.tsx | 324 +++++++++++++++ 17 files changed, 2264 insertions(+), 62 deletions(-) create mode 100644 frontend/e2e/admin-access.spec.ts create mode 100644 frontend/src/app/admin/organizations/page.tsx create mode 100644 frontend/src/app/admin/settings/page.tsx create mode 100644 frontend/src/app/admin/users/page.tsx create mode 100644 frontend/src/components/admin/AdminSidebar.tsx create mode 100644 frontend/src/components/admin/Breadcrumbs.tsx create mode 100644 frontend/src/components/admin/DashboardStats.tsx create mode 100644 frontend/src/components/admin/StatCard.tsx create mode 100644 frontend/src/lib/api/hooks/useAdmin.tsx create mode 100644 frontend/tests/components/admin/AdminSidebar.test.tsx create mode 100644 frontend/tests/components/admin/Breadcrumbs.test.tsx create mode 100644 frontend/tests/components/admin/StatCard.test.tsx diff --git a/frontend/e2e/admin-access.spec.ts b/frontend/e2e/admin-access.spec.ts new file mode 100644 index 0000000..6f17878 --- /dev/null +++ b/frontend/e2e/admin-access.spec.ts @@ -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'); + }); +}); diff --git a/frontend/e2e/helpers/auth.ts b/frontend/e2e/helpers/auth.ts index 02dbb2d..750c81b 100644 --- a/frontend/e2e/helpers/auth.ts +++ b/frontend/e2e/helpers/auth.ts @@ -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 { * 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 { + // 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(); + } + }); +} diff --git a/frontend/src/app/admin/layout.tsx b/frontend/src/app/admin/layout.tsx index 4ddf804..6fff351 100644 --- a/frontend/src/app/admin/layout.tsx +++ b/frontend/src/app/admin/layout.tsx @@ -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({
-
- {children} -
+
+ +
+ +
+ {children} +
+
+
diff --git a/frontend/src/app/admin/organizations/page.tsx b/frontend/src/app/admin/organizations/page.tsx new file mode 100644 index 0000000..5221a64 --- /dev/null +++ b/frontend/src/app/admin/organizations/page.tsx @@ -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 ( +
+
+ {/* Back Button + Header */} +
+ + + +
+

+ Organizations +

+

+ Manage organizations and their members +

+
+
+ + {/* Placeholder Content */} +
+

+ Organization Management Coming Soon +

+

+ This page will allow you to view all organizations, manage their + members, and perform administrative tasks. +

+

+ Features will include: +

+
    +
  • • Organization list with search and filtering
  • +
  • • View organization details and members
  • +
  • • Manage organization memberships
  • +
  • • Organization statistics and activity
  • +
  • • Bulk operations
  • +
+
+
+
+ ); +} diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index a281188..045d91a 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -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 ( -
-
+
+
+ {/* Page Header */}

Admin Dashboard @@ -25,35 +29,48 @@ export default function AdminPage() {

-
-
-

Users

-

- Manage user accounts and permissions -

-

- Coming soon... -

-
+ {/* Stats Grid */} + -
-

Organizations

-

- View and manage organizations -

-

- Coming soon... -

-
+ {/* Quick Actions */} +
+

Quick Actions

+
+ +
+
+ +

User Management

+
+

+ View, create, and manage user accounts +

+
+ -
-

System

-

- System settings and configuration -

-

- Coming soon... -

+ +
+
+ +

Organizations

+
+

+ Manage organizations and their members +

+
+ + + +
+
+ +

System Settings

+
+

+ Configure system-wide settings +

+
+
diff --git a/frontend/src/app/admin/settings/page.tsx b/frontend/src/app/admin/settings/page.tsx new file mode 100644 index 0000000..44488f0 --- /dev/null +++ b/frontend/src/app/admin/settings/page.tsx @@ -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 ( +
+
+ {/* Back Button + Header */} +
+ + + +
+

+ System Settings +

+

+ Configure system-wide settings and preferences +

+
+
+ + {/* Placeholder Content */} +
+

+ System Settings Coming Soon +

+

+ This page will allow you to configure system-wide settings, + preferences, and advanced options. +

+

+ Features will include: +

+
    +
  • • General system configuration
  • +
  • • Email and notification settings
  • +
  • • Security and authentication options
  • +
  • • API and integration settings
  • +
  • • Maintenance and backup tools
  • +
+
+
+
+ ); +} diff --git a/frontend/src/app/admin/users/page.tsx b/frontend/src/app/admin/users/page.tsx new file mode 100644 index 0000000..30056c6 --- /dev/null +++ b/frontend/src/app/admin/users/page.tsx @@ -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 ( +
+
+ {/* Back Button + Header */} +
+ + + +
+

+ User Management +

+

+ View, create, and manage user accounts +

+
+
+ + {/* Placeholder Content */} +
+

+ User Management Coming Soon +

+

+ This page will allow you to view all users, create new accounts, + manage permissions, and perform bulk operations. +

+

+ Features will include: +

+
    +
  • • User list with search and filtering
  • +
  • • Create/edit/delete user accounts
  • +
  • • Activate/deactivate users
  • +
  • • Role and permission management
  • +
  • • Bulk operations
  • +
+
+
+
+ ); +} diff --git a/frontend/src/components/admin/AdminSidebar.tsx b/frontend/src/components/admin/AdminSidebar.tsx new file mode 100644 index 0000000..ceb84c5 --- /dev/null +++ b/frontend/src/components/admin/AdminSidebar.tsx @@ -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 ( + + ); +} diff --git a/frontend/src/components/admin/Breadcrumbs.tsx b/frontend/src/components/admin/Breadcrumbs.tsx new file mode 100644 index 0000000..46e91bc --- /dev/null +++ b/frontend/src/components/admin/Breadcrumbs.tsx @@ -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 = { + 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 ( + + ); +} diff --git a/frontend/src/components/admin/DashboardStats.tsx b/frontend/src/components/admin/DashboardStats.tsx new file mode 100644 index 0000000..346d8aa --- /dev/null +++ b/frontend/src/components/admin/DashboardStats.tsx @@ -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 ( + + + + Failed to load dashboard statistics: {error?.message || 'Unknown error'} + + + ); + } + + return ( +
+ + + + +
+ ); +} diff --git a/frontend/src/components/admin/StatCard.tsx b/frontend/src/components/admin/StatCard.tsx new file mode 100644 index 0000000..2960cff --- /dev/null +++ b/frontend/src/components/admin/StatCard.tsx @@ -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 ( +
+
+
+

+ {title} +

+
+ {loading ? ( +
+ ) : ( +

+ {value} +

+ )} +
+ {description && !loading && ( +

+ {description} +

+ )} + {trend && !loading && ( +
+ {trend.isPositive ? '↑' : '↓'} {Math.abs(trend.value)}%{' '} + {trend.label} +
+ )} +
+
+ +
+
+
+ ); +} diff --git a/frontend/src/components/admin/index.ts b/frontend/src/components/admin/index.ts index 6bd110a..03de93d 100755 --- a/frontend/src/components/admin/index.ts +++ b/frontend/src/components/admin/index.ts @@ -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'; diff --git a/frontend/src/lib/api/hooks/useAdmin.tsx b/frontend/src/lib/api/hooks/useAdmin.tsx new file mode 100644 index 0000000..762a150 --- /dev/null +++ b/frontend/src/lib/api/hooks/useAdmin.tsx @@ -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 => { + // 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; + }, + }); +} diff --git a/frontend/tests/app/admin/page.test.tsx b/frontend/tests/app/admin/page.test.tsx index 28ea57a..3506532 100644 --- a/frontend/tests/app/admin/page.test.tsx +++ b/frontend/tests/app/admin/page.test.tsx @@ -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( + + {component} + + ); +} + describe('AdminPage', () => { it('renders admin dashboard title', () => { - render(); + renderWithQueryClient(); expect(screen.getByText('Admin Dashboard')).toBeInTheDocument(); }); it('renders description text', () => { - render(); + renderWithQueryClient(); expect( screen.getByText('Manage users, organizations, and system settings') ).toBeInTheDocument(); }); - it('renders users management card', () => { - render(); + it('renders quick actions section', () => { + renderWithQueryClient(); - expect(screen.getByText('Users')).toBeInTheDocument(); + expect(screen.getByText('Quick Actions')).toBeInTheDocument(); + }); + + it('renders user management card', () => { + renderWithQueryClient(); + + 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(); + it('renders organizations card', () => { + renderWithQueryClient(); - 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(); + renderWithQueryClient(); - 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(); + it('renders quick actions in grid layout', () => { + renderWithQueryClient(); - 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(); - - 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(); + const { container } = renderWithQueryClient(); 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'); }); }); diff --git a/frontend/tests/components/admin/AdminSidebar.test.tsx b/frontend/tests/components/admin/AdminSidebar.test.tsx new file mode 100644 index 0000000..3a1f582 --- /dev/null +++ b/frontend/tests/components/admin/AdminSidebar.test.tsx @@ -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 { + 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(); + expect(screen.getByText('Admin Panel')).toBeInTheDocument(); + }); + + it('renders sidebar with correct test id', () => { + render(); + expect(screen.getByTestId('admin-sidebar')).toBeInTheDocument(); + }); + + it('renders all navigation items', () => { + render(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + // 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + // 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(); + + // 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(); + + 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(); + + 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(); + + 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(); + + const dashboardLink = screen.getByTestId('nav-dashboard'); + const usersLink = screen.getByTestId('nav-users'); + + expect(dashboardLink.tagName).toBe('A'); + expect(usersLink.tagName).toBe('A'); + }); + }); +}); diff --git a/frontend/tests/components/admin/Breadcrumbs.test.tsx b/frontend/tests/components/admin/Breadcrumbs.test.tsx new file mode 100644 index 0000000..a9dbd44 --- /dev/null +++ b/frontend/tests/components/admin/Breadcrumbs.test.tsx @@ -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(); + + expect(screen.getByTestId('breadcrumbs')).toBeInTheDocument(); + }); + + it('renders breadcrumbs with proper aria-label', () => { + (usePathname as jest.Mock).mockReturnValue('/admin'); + + render(); + + 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(); + + expect(container.firstChild).toBeNull(); + }); + + it('returns null for root pathname', () => { + (usePathname as jest.Mock).mockReturnValue('/'); + + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + }); + + describe('Single Level Navigation', () => { + it('renders single breadcrumb for /admin', () => { + (usePathname as jest.Mock).mockReturnValue('/admin'); + + render(); + + expect(screen.getByTestId('breadcrumb-admin')).toBeInTheDocument(); + expect(screen.getByText('Admin')).toBeInTheDocument(); + }); + + it('renders current page without link', () => { + (usePathname as jest.Mock).mockReturnValue('/admin'); + + render(); + + 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(); + + 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(); + + 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(); + + 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(); + + expect(screen.getByText('Admin')).toBeInTheDocument(); + expect(screen.getByText('Organizations')).toBeInTheDocument(); + }); + + it('renders breadcrumbs for /admin/settings', () => { + (usePathname as jest.Mock).mockReturnValue('/admin/settings'); + + render(); + + 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(); + + 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(); + + 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(); + + 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(); + + // 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(); + + // 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(); + + // 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(); + + expect(screen.getByText('Admin')).toBeInTheDocument(); + }); + + it('uses predefined label for users', () => { + (usePathname as jest.Mock).mockReturnValue('/admin/users'); + + render(); + + expect(screen.getByText('Users')).toBeInTheDocument(); + }); + + it('uses predefined label for organizations', () => { + (usePathname as jest.Mock).mockReturnValue('/admin/organizations'); + + render(); + + expect(screen.getByText('Organizations')).toBeInTheDocument(); + }); + + it('uses predefined label for settings', () => { + (usePathname as jest.Mock).mockReturnValue('/admin/settings'); + + render(); + + expect(screen.getByText('Settings')).toBeInTheDocument(); + }); + + it('uses pathname segment for unmapped paths', () => { + (usePathname as jest.Mock).mockReturnValue('/admin/unknown-path'); + + render(); + + expect(screen.getByText('unknown-path')).toBeInTheDocument(); + }); + + it('displays numeric IDs as-is', () => { + (usePathname as jest.Mock).mockReturnValue('/admin/users/123'); + + render(); + + expect(screen.getByText('123')).toBeInTheDocument(); + }); + }); + + describe('Styling', () => { + it('applies correct styles to parent links', () => { + (usePathname as jest.Mock).mockReturnValue('/admin/users'); + + render(); + + 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(); + + 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(); + + expect(screen.getByRole('navigation')).toBeInTheDocument(); + }); + + it('has aria-label for navigation', () => { + (usePathname as jest.Mock).mockReturnValue('/admin'); + + render(); + + 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(); + + 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(); + + 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(); + + const adminLink = screen.getByTestId('breadcrumb-admin'); + expect(adminLink.tagName).toBe('A'); + expect(adminLink).toHaveAttribute('href'); + }); + }); +}); diff --git a/frontend/tests/components/admin/StatCard.test.tsx b/frontend/tests/components/admin/StatCard.test.tsx new file mode 100644 index 0000000..8756c02 --- /dev/null +++ b/frontend/tests/components/admin/StatCard.test.tsx @@ -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(); + + expect(screen.getByTestId('stat-card')).toBeInTheDocument(); + }); + + it('renders title correctly', () => { + render(); + + expect(screen.getByTestId('stat-title')).toHaveTextContent('Total Users'); + }); + + it('renders numeric value correctly', () => { + render(); + + expect(screen.getByTestId('stat-value')).toHaveTextContent('1234'); + }); + + it('renders string value correctly', () => { + render(); + + expect(screen.getByTestId('stat-value')).toHaveTextContent('Active'); + }); + + it('renders icon', () => { + const { container } = render(); + + // Icon should be rendered (lucide icons render as SVG) + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + }); + + it('renders description when provided', () => { + render( + + ); + + expect(screen.getByTestId('stat-description')).toHaveTextContent( + 'Total registered users' + ); + }); + + it('does not render description when not provided', () => { + render(); + + expect(screen.queryByTestId('stat-description')).not.toBeInTheDocument(); + }); + }); + + describe('Loading State', () => { + it('applies loading class when loading', () => { + render(); + + const card = screen.getByTestId('stat-card'); + expect(card).toHaveClass('animate-pulse'); + }); + + it('shows skeleton for value when loading', () => { + render(); + + // 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( + + ); + + expect(screen.queryByTestId('stat-description')).not.toBeInTheDocument(); + }); + + it('hides trend when loading', () => { + render( + + ); + + expect(screen.queryByTestId('stat-trend')).not.toBeInTheDocument(); + }); + + it('applies muted styles to icon when loading', () => { + const { container } = render(); + + const icon = container.querySelector('svg'); + expect(icon).toHaveClass('text-muted-foreground'); + }); + }); + + describe('Trend Indicator', () => { + it('renders positive trend correctly', () => { + render( + + ); + + 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( + + ); + + 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( + + ); + + 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(); + + expect(screen.queryByTestId('stat-trend')).not.toBeInTheDocument(); + }); + }); + + describe('Icon Variations', () => { + it('renders Users icon', () => { + const { container } = render(); + + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + }); + + it('renders Activity icon', () => { + const { container } = render( + + ); + + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + }); + + it('renders Building2 icon', () => { + const { container } = render( + + ); + + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + }); + + it('renders FileText icon', () => { + const { container } = render( + + ); + + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + }); + }); + + describe('Styling', () => { + it('applies custom className', () => { + render(); + + const card = screen.getByTestId('stat-card'); + expect(card).toHaveClass('custom-class'); + }); + + it('applies default card styles', () => { + render(); + + 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(); + + const icon = container.querySelector('svg'); + expect(icon).toHaveClass('text-primary'); + }); + + it('applies correct icon background', () => { + const { container } = render(); + + const iconWrapper = container.querySelector('.rounded-full'); + expect(iconWrapper).toHaveClass('bg-primary/10'); + }); + + it('applies muted styles when loading', () => { + const { container } = render(); + + const iconWrapper = container.querySelector('.rounded-full'); + expect(iconWrapper).toHaveClass('bg-muted'); + }); + }); + + describe('Complex Scenarios', () => { + it('renders all props together', () => { + render( + + ); + + 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(); + + expect(screen.getByTestId('stat-value')).toHaveTextContent('0'); + }); + + it('handles very large numbers', () => { + render(); + + expect(screen.getByTestId('stat-value')).toHaveTextContent('1234567890'); + }); + + it('handles formatted string values', () => { + render(); + + expect(screen.getByTestId('stat-value')).toHaveTextContent('1,234'); + }); + + it('handles percentage string values', () => { + render(); + + expect(screen.getByTestId('stat-value')).toHaveTextContent('98.5%'); + }); + }); + + describe('Accessibility', () => { + it('renders semantic HTML structure', () => { + render(); + + const card = screen.getByTestId('stat-card'); + expect(card.tagName).toBe('DIV'); + }); + + it('maintains readable text contrast', () => { + render(); + + 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( + + ); + + const description = screen.getByTestId('stat-description'); + expect(description).toHaveClass('text-xs'); + }); + }); +});