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');
+ });
+});