From c7b2c82700db4b5fe7f76738e538ad043a13c3d8 Mon Sep 17 00:00:00 2001 From: Felipe Cardoso Date: Thu, 1 Jan 2026 17:20:34 +0100 Subject: [PATCH] test(frontend): add unit tests for Dashboard components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive test coverage for dashboard components: - Dashboard.test.tsx: Main component integration tests - WelcomeHeader.test.tsx: User greeting and time-based messages - DashboardQuickStats.test.tsx: Stats cards rendering and links - RecentProjects.test.tsx: Project cards grid and navigation - PendingApprovals.test.tsx: Approval items and actions - EmptyState.test.tsx: New user onboarding experience 46 tests covering rendering, interactions, and edge cases. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../components/dashboard/Dashboard.test.tsx | 211 ++++++++++++++++++ .../dashboard/DashboardQuickStats.test.tsx | 67 ++++++ .../components/dashboard/EmptyState.test.tsx | 57 +++++ .../dashboard/PendingApprovals.test.tsx | 123 ++++++++++ .../dashboard/RecentProjects.test.tsx | 120 ++++++++++ .../dashboard/WelcomeHeader.test.tsx | 131 +++++++++++ 6 files changed, 709 insertions(+) create mode 100644 frontend/tests/components/dashboard/Dashboard.test.tsx create mode 100644 frontend/tests/components/dashboard/DashboardQuickStats.test.tsx create mode 100644 frontend/tests/components/dashboard/EmptyState.test.tsx create mode 100644 frontend/tests/components/dashboard/PendingApprovals.test.tsx create mode 100644 frontend/tests/components/dashboard/RecentProjects.test.tsx create mode 100644 frontend/tests/components/dashboard/WelcomeHeader.test.tsx diff --git a/frontend/tests/components/dashboard/Dashboard.test.tsx b/frontend/tests/components/dashboard/Dashboard.test.tsx new file mode 100644 index 0000000..e944e5c --- /dev/null +++ b/frontend/tests/components/dashboard/Dashboard.test.tsx @@ -0,0 +1,211 @@ +/** + * Dashboard Component Tests + */ + +import { render, screen } from '@testing-library/react'; +import { Dashboard } from '@/components/dashboard/Dashboard'; +import { useAuth } from '@/lib/auth/AuthContext'; +import { useDashboard } from '@/lib/api/hooks/useDashboard'; +import { useProjectEvents } from '@/lib/hooks/useProjectEvents'; +import { useProjectEventsFromStore } from '@/lib/stores/eventStore'; + +// Mock dependencies +jest.mock('@/lib/auth/AuthContext', () => ({ + useAuth: jest.fn(), +})); + +jest.mock('@/lib/api/hooks/useDashboard', () => ({ + useDashboard: jest.fn(), +})); + +jest.mock('@/lib/hooks/useProjectEvents', () => ({ + useProjectEvents: jest.fn(), +})); + +jest.mock('@/lib/stores/eventStore', () => ({ + useProjectEventsFromStore: jest.fn(), +})); + +// Mock next-intl navigation +jest.mock('@/lib/i18n/routing', () => ({ + Link: ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ), +})); + +// Mock sonner +jest.mock('sonner', () => ({ + toast: { + success: jest.fn(), + info: jest.fn(), + error: jest.fn(), + }, +})); + +const mockUseAuth = useAuth as jest.MockedFunction; +const mockUseDashboard = useDashboard as jest.MockedFunction; +const mockUseProjectEvents = useProjectEvents as jest.MockedFunction; +const mockUseProjectEventsFromStore = useProjectEventsFromStore as jest.MockedFunction< + typeof useProjectEventsFromStore +>; + +describe('Dashboard', () => { + const mockUser = { + id: '1', + email: 'test@example.com', + first_name: 'Test', + is_active: true, + is_superuser: false, + created_at: '', + }; + + const mockDashboardData = { + stats: { + activeProjects: 3, + runningAgents: 8, + openIssues: 24, + pendingApprovals: 2, + }, + recentProjects: [ + { + id: 'proj-1', + name: 'Test Project', + description: 'Test description', + status: 'active' as const, + autonomy_level: 'milestone' as const, + created_at: '2025-01-01T00:00:00Z', + owner_id: 'user-1', + progress: 50, + openIssues: 5, + activeAgents: 2, + lastActivity: '5 min ago', + }, + ], + pendingApprovals: [ + { + id: 'approval-1', + type: 'sprint_boundary' as const, + title: 'Sprint Review', + description: 'Review sprint', + projectId: 'proj-1', + projectName: 'Test Project', + requestedBy: 'Agent', + requestedAt: new Date().toISOString(), + priority: 'high' as const, + }, + ], + }; + + beforeEach(() => { + jest.clearAllMocks(); + + mockUseAuth.mockReturnValue({ + user: mockUser, + isAuthenticated: true, + isLoading: false, + error: null, + login: jest.fn(), + logout: jest.fn(), + clearError: jest.fn(), + checkAuth: jest.fn(), + }); + + mockUseDashboard.mockReturnValue({ + data: mockDashboardData, + isLoading: false, + error: null, + isError: false, + isPending: false, + isSuccess: true, + status: 'success', + } as ReturnType); + + mockUseProjectEvents.mockReturnValue({ + connectionState: 'connected', + events: [], + isConnected: true, + error: null, + retryCount: 0, + reconnect: jest.fn(), + disconnect: jest.fn(), + clearEvents: jest.fn(), + }); + + mockUseProjectEventsFromStore.mockReturnValue([]); + }); + + it('renders welcome header', () => { + render(); + + // User first name appears in welcome message + expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument(); + }); + + it('renders quick stats', () => { + render(); + + expect(screen.getByText('Active Projects')).toBeInTheDocument(); + expect(screen.getByText('Running Agents')).toBeInTheDocument(); + expect(screen.getByText('Open Issues')).toBeInTheDocument(); + // "Pending Approvals" appears in both stats and approvals section + const pendingApprovalsTexts = screen.getAllByText('Pending Approvals'); + expect(pendingApprovalsTexts.length).toBeGreaterThan(0); + }); + + it('renders recent projects section', () => { + render(); + + expect(screen.getByText('Recent Projects')).toBeInTheDocument(); + // Use getAllByText since project name appears in multiple places + const projectNames = screen.getAllByText('Test Project'); + expect(projectNames.length).toBeGreaterThan(0); + }); + + it('renders pending approvals section when approvals exist', () => { + render(); + + // Check for pending approvals header + const approvalHeaders = screen.getAllByText('Pending Approvals'); + expect(approvalHeaders.length).toBeGreaterThan(0); + }); + + it('shows empty state when no projects', () => { + mockUseDashboard.mockReturnValue({ + data: { ...mockDashboardData, recentProjects: [] }, + isLoading: false, + error: null, + isError: false, + isPending: false, + isSuccess: true, + status: 'success', + } as unknown as ReturnType); + + render(); + + expect(screen.getByText(/Welcome to Syndarix/)).toBeInTheDocument(); + expect(screen.getByText(/Create Your First Project/)).toBeInTheDocument(); + }); + + it('shows loading state', () => { + mockUseDashboard.mockReturnValue({ + data: undefined, + isLoading: true, + error: null, + isError: false, + isPending: true, + isSuccess: false, + status: 'pending', + } as ReturnType); + + render(); + + // Should show skeleton loading states + expect(screen.getByText('Recent Projects')).toBeInTheDocument(); + }); + + it('applies custom className', () => { + const { container } = render(); + + expect(container.firstChild).toHaveClass('custom-class'); + }); +}); diff --git a/frontend/tests/components/dashboard/DashboardQuickStats.test.tsx b/frontend/tests/components/dashboard/DashboardQuickStats.test.tsx new file mode 100644 index 0000000..ffbad80 --- /dev/null +++ b/frontend/tests/components/dashboard/DashboardQuickStats.test.tsx @@ -0,0 +1,67 @@ +/** + * DashboardQuickStats Component Tests + */ + +import { render, screen } from '@testing-library/react'; +import { DashboardQuickStats } from '@/components/dashboard/DashboardQuickStats'; +import type { DashboardStats } from '@/lib/api/hooks/useDashboard'; + +describe('DashboardQuickStats', () => { + const mockStats: DashboardStats = { + activeProjects: 5, + runningAgents: 12, + openIssues: 34, + pendingApprovals: 3, + }; + + it('renders all four stat cards', () => { + render(); + + expect(screen.getByText('Active Projects')).toBeInTheDocument(); + expect(screen.getByText('Running Agents')).toBeInTheDocument(); + expect(screen.getByText('Open Issues')).toBeInTheDocument(); + expect(screen.getByText('Pending Approvals')).toBeInTheDocument(); + }); + + it('displays correct stat values', () => { + render(); + + expect(screen.getByText('5')).toBeInTheDocument(); + expect(screen.getByText('12')).toBeInTheDocument(); + expect(screen.getByText('34')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + }); + + it('displays descriptions for each stat', () => { + render(); + + expect(screen.getByText('Currently in progress')).toBeInTheDocument(); + expect(screen.getByText('Working on tasks')).toBeInTheDocument(); + expect(screen.getByText('Across all projects')).toBeInTheDocument(); + expect(screen.getByText('Awaiting your review')).toBeInTheDocument(); + }); + + it('shows zero values when stats are undefined', () => { + render(); + + // Should show 0 for all stats + const zeros = screen.getAllByText('0'); + expect(zeros).toHaveLength(4); + }); + + it('shows loading state when isLoading is true', () => { + render(); + + // StatCard shows loading animation + const statCards = screen.getAllByTestId('stat-card'); + statCards.forEach((card) => { + expect(card).toHaveClass('animate-pulse'); + }); + }); + + it('applies custom className', () => { + const { container } = render(); + + expect(container.firstChild).toHaveClass('custom-class'); + }); +}); diff --git a/frontend/tests/components/dashboard/EmptyState.test.tsx b/frontend/tests/components/dashboard/EmptyState.test.tsx new file mode 100644 index 0000000..0727c6e --- /dev/null +++ b/frontend/tests/components/dashboard/EmptyState.test.tsx @@ -0,0 +1,57 @@ +/** + * EmptyState Component Tests + */ + +import { render, screen } from '@testing-library/react'; +import { EmptyState } from '@/components/dashboard/EmptyState'; + +// Mock next-intl navigation +jest.mock('@/lib/i18n/routing', () => ({ + Link: ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ), +})); + +describe('EmptyState', () => { + it('displays welcome message with user name', () => { + render(); + + expect(screen.getByText(/Welcome to Syndarix, John!/)).toBeInTheDocument(); + }); + + it('displays default greeting when no userName provided', () => { + render(); + + expect(screen.getByText(/Welcome to Syndarix, there!/)).toBeInTheDocument(); + }); + + it('displays description text', () => { + render(); + + expect(screen.getByText(/Get started by creating your first project/)).toBeInTheDocument(); + }); + + it('displays Create Your First Project button', () => { + render(); + + const createButton = screen.getByRole('link', { name: /Create Your First Project/i }); + expect(createButton).toBeInTheDocument(); + expect(createButton).toHaveAttribute('href', '/projects/new'); + }); + + it('displays quick action links', () => { + render(); + + const agentsLink = screen.getByRole('link', { name: /Set up AI agent types/i }); + expect(agentsLink).toHaveAttribute('href', '/agents'); + + const settingsLink = screen.getByRole('link', { name: /Configure your account/i }); + expect(settingsLink).toHaveAttribute('href', '/settings'); + }); + + it('applies custom className', () => { + const { container } = render(); + + expect(container.firstChild).toHaveClass('custom-class'); + }); +}); diff --git a/frontend/tests/components/dashboard/PendingApprovals.test.tsx b/frontend/tests/components/dashboard/PendingApprovals.test.tsx new file mode 100644 index 0000000..aaa78e4 --- /dev/null +++ b/frontend/tests/components/dashboard/PendingApprovals.test.tsx @@ -0,0 +1,123 @@ +/** + * PendingApprovals Component Tests + */ + +import { render, screen, fireEvent } from '@testing-library/react'; +import { PendingApprovals } from '@/components/dashboard/PendingApprovals'; +import type { PendingApproval } from '@/lib/api/hooks/useDashboard'; + +// Mock next-intl navigation +jest.mock('@/lib/i18n/routing', () => ({ + Link: ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ), +})); + +describe('PendingApprovals', () => { + const mockApprovals: PendingApproval[] = [ + { + id: 'approval-1', + type: 'sprint_boundary', + title: 'Sprint 3 Completion', + description: 'Review sprint deliverables', + projectId: 'proj-1', + projectName: 'E-Commerce Platform', + requestedBy: 'Product Owner Agent', + requestedAt: new Date().toISOString(), + priority: 'high', + }, + { + id: 'approval-2', + type: 'code_review', + title: 'PR #123 Review', + description: 'Authentication module changes', + projectId: 'proj-2', + projectName: 'Banking App', + requestedBy: 'Developer Agent', + requestedAt: new Date().toISOString(), + priority: 'medium', + }, + ]; + + it('renders approval items', () => { + render(); + + expect(screen.getByText('Sprint 3 Completion')).toBeInTheDocument(); + expect(screen.getByText('PR #123 Review')).toBeInTheDocument(); + }); + + it('displays section header with count', () => { + render(); + + expect(screen.getByText('Pending Approvals')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + }); + + it('displays approval descriptions', () => { + render(); + + expect(screen.getByText('Review sprint deliverables')).toBeInTheDocument(); + expect(screen.getByText('Authentication module changes')).toBeInTheDocument(); + }); + + it('displays project names with links', () => { + render(); + + const projectLink = screen.getByRole('link', { name: 'E-Commerce Platform' }); + expect(projectLink).toHaveAttribute('href', '/projects/proj-1'); + }); + + it('displays requestor information', () => { + render(); + + expect(screen.getByText(/Product Owner Agent/)).toBeInTheDocument(); + expect(screen.getByText(/Developer Agent/)).toBeInTheDocument(); + }); + + it('displays priority badges', () => { + render(); + + expect(screen.getByText('High')).toBeInTheDocument(); + expect(screen.getByText('Medium')).toBeInTheDocument(); + }); + + it('calls onApprove when Approve button clicked', () => { + const onApprove = jest.fn(); + render(); + + const approveButtons = screen.getAllByRole('button', { name: /Approve/i }); + fireEvent.click(approveButtons[0]); + + expect(onApprove).toHaveBeenCalledWith(mockApprovals[0]); + }); + + it('calls onReject when Reject button clicked', () => { + const onReject = jest.fn(); + render(); + + const rejectButtons = screen.getAllByRole('button', { name: /Reject/i }); + fireEvent.click(rejectButtons[0]); + + expect(onReject).toHaveBeenCalledWith(mockApprovals[0]); + }); + + it('does not render when no approvals and not loading', () => { + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + it('shows loading skeletons when isLoading is true', () => { + render(); + + expect(screen.getByText('Pending Approvals')).toBeInTheDocument(); + }); + + it('applies custom className', () => { + const { container } = render( + + ); + + expect(container.firstChild).toHaveClass('custom-class'); + }); +}); diff --git a/frontend/tests/components/dashboard/RecentProjects.test.tsx b/frontend/tests/components/dashboard/RecentProjects.test.tsx new file mode 100644 index 0000000..41936de --- /dev/null +++ b/frontend/tests/components/dashboard/RecentProjects.test.tsx @@ -0,0 +1,120 @@ +/** + * RecentProjects Component Tests + */ + +import { render, screen } from '@testing-library/react'; +import { RecentProjects } from '@/components/dashboard/RecentProjects'; +import type { DashboardProject } from '@/lib/api/hooks/useDashboard'; + +// Mock next-intl navigation +jest.mock('@/lib/i18n/routing', () => ({ + Link: ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ), +})); + +describe('RecentProjects', () => { + const mockProjects: DashboardProject[] = [ + { + id: 'proj-1', + name: 'Project One', + description: 'First project description', + status: 'active', + autonomy_level: 'milestone', + created_at: '2025-01-01T00:00:00Z', + owner_id: 'user-1', + progress: 75, + openIssues: 5, + activeAgents: 3, + currentSprint: 'Sprint 2', + lastActivity: '5 minutes ago', + }, + { + id: 'proj-2', + name: 'Project Two', + description: 'Second project description', + status: 'paused', + autonomy_level: 'full_control', + created_at: '2025-01-02T00:00:00Z', + owner_id: 'user-1', + progress: 30, + openIssues: 8, + activeAgents: 0, + lastActivity: '2 days ago', + }, + ]; + + it('renders project cards', () => { + render(); + + expect(screen.getByText('Project One')).toBeInTheDocument(); + expect(screen.getByText('Project Two')).toBeInTheDocument(); + }); + + it('displays section header with View all link', () => { + render(); + + expect(screen.getByText('Recent Projects')).toBeInTheDocument(); + const viewAllLink = screen.getByRole('link', { name: /View all/i }); + expect(viewAllLink).toHaveAttribute('href', '/projects'); + }); + + it('displays project descriptions', () => { + render(); + + expect(screen.getByText('First project description')).toBeInTheDocument(); + expect(screen.getByText('Second project description')).toBeInTheDocument(); + }); + + it('displays project metrics', () => { + render(); + + // Check agents count + expect(screen.getByText('3 agents')).toBeInTheDocument(); + expect(screen.getByText('0 agents')).toBeInTheDocument(); + + // Check issues count + expect(screen.getByText('5 issues')).toBeInTheDocument(); + expect(screen.getByText('8 issues')).toBeInTheDocument(); + }); + + it('displays sprint info when available', () => { + render(); + + expect(screen.getByText('Sprint 2')).toBeInTheDocument(); + }); + + it('displays last activity time', () => { + render(); + + expect(screen.getByText('5 minutes ago')).toBeInTheDocument(); + expect(screen.getByText('2 days ago')).toBeInTheDocument(); + }); + + it('shows loading skeletons when isLoading is true', () => { + render(); + + // Should show skeleton cards + expect(screen.getByText('Recent Projects')).toBeInTheDocument(); + }); + + it('shows empty state when no projects', () => { + render(); + + expect(screen.getByText('No projects yet')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Create your first project/i })).toBeInTheDocument(); + }); + + it('links project cards to project detail page', () => { + render(); + + const projectLink = screen.getByRole('link', { name: /Project One/i }); + expect(projectLink).toHaveAttribute('href', '/projects/proj-1'); + }); + + it('applies custom className', () => { + const { container } = render(); + + expect(container.firstChild).toHaveClass('custom-class'); + }); +}); diff --git a/frontend/tests/components/dashboard/WelcomeHeader.test.tsx b/frontend/tests/components/dashboard/WelcomeHeader.test.tsx new file mode 100644 index 0000000..48afa82 --- /dev/null +++ b/frontend/tests/components/dashboard/WelcomeHeader.test.tsx @@ -0,0 +1,131 @@ +/** + * WelcomeHeader Component Tests + */ + +import { render, screen } from '@testing-library/react'; +import { WelcomeHeader } from '@/components/dashboard/WelcomeHeader'; +import { useAuth } from '@/lib/auth/AuthContext'; + +// Mock useAuth hook +jest.mock('@/lib/auth/AuthContext', () => ({ + useAuth: jest.fn(), +})); + +// Mock next-intl navigation +jest.mock('@/lib/i18n/routing', () => ({ + Link: ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ), +})); + +const mockUseAuth = useAuth as jest.MockedFunction; + +describe('WelcomeHeader', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('displays greeting with user first name', () => { + mockUseAuth.mockReturnValue({ + user: { id: '1', email: 'john@example.com', first_name: 'John', is_active: true, is_superuser: false, created_at: '' }, + isAuthenticated: true, + isLoading: false, + error: null, + login: jest.fn(), + logout: jest.fn(), + clearError: jest.fn(), + checkAuth: jest.fn(), + }); + + render(); + + expect(screen.getByText(/John/)).toBeInTheDocument(); + }); + + it('falls back to email prefix when first_name is empty', () => { + mockUseAuth.mockReturnValue({ + user: { id: '1', email: 'jane@example.com', first_name: '', is_active: true, is_superuser: false, created_at: '' }, + isAuthenticated: true, + isLoading: false, + error: null, + login: jest.fn(), + logout: jest.fn(), + clearError: jest.fn(), + checkAuth: jest.fn(), + }); + + render(); + + expect(screen.getByText(/jane/)).toBeInTheDocument(); + }); + + it('displays default greeting when no user', () => { + mockUseAuth.mockReturnValue({ + user: null, + isAuthenticated: false, + isLoading: false, + error: null, + login: jest.fn(), + logout: jest.fn(), + clearError: jest.fn(), + checkAuth: jest.fn(), + }); + + render(); + + expect(screen.getByText(/there/)).toBeInTheDocument(); + }); + + it('displays subtitle text', () => { + mockUseAuth.mockReturnValue({ + user: { id: '1', email: 'test@example.com', first_name: 'Test', is_active: true, is_superuser: false, created_at: '' }, + isAuthenticated: true, + isLoading: false, + error: null, + login: jest.fn(), + logout: jest.fn(), + clearError: jest.fn(), + checkAuth: jest.fn(), + }); + + render(); + + expect(screen.getByText(/Here's what's happening/)).toBeInTheDocument(); + }); + + it('displays Create Project button', () => { + mockUseAuth.mockReturnValue({ + user: { id: '1', email: 'test@example.com', first_name: 'Test', is_active: true, is_superuser: false, created_at: '' }, + isAuthenticated: true, + isLoading: false, + error: null, + login: jest.fn(), + logout: jest.fn(), + clearError: jest.fn(), + checkAuth: jest.fn(), + }); + + render(); + + const createButton = screen.getByRole('link', { name: /Create Project/i }); + expect(createButton).toBeInTheDocument(); + expect(createButton).toHaveAttribute('href', '/projects/new'); + }); + + it('applies custom className', () => { + mockUseAuth.mockReturnValue({ + user: null, + isAuthenticated: false, + isLoading: false, + error: null, + login: jest.fn(), + logout: jest.fn(), + clearError: jest.fn(), + checkAuth: jest.fn(), + }); + + const { container } = render(); + + expect(container.firstChild).toHaveClass('custom-class'); + }); +});