forked from cardosofelipe/fast-next-template
feat(frontend): Implement navigation and layout (#44)
Implements the main navigation and layout structure: - Sidebar component with collapsible navigation and keyboard shortcut - AppHeader with project switcher and user menu - AppBreadcrumbs with auto-generation from pathname - ProjectSwitcher dropdown for quick project navigation - UserMenu with profile, settings, and logout - AppLayout component combining all layout elements Features: - Responsive design (mobile sidebar sheet, desktop sidebar) - Keyboard navigation (Cmd/Ctrl+B to toggle sidebar) - Dark mode support - WCAG AA accessible (ARIA labels, focus management) All 125 tests passing. Follows design system guidelines. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
179
frontend/tests/components/layout/AppBreadcrumbs.test.tsx
Normal file
179
frontend/tests/components/layout/AppBreadcrumbs.test.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Tests for AppBreadcrumbs Component
|
||||
* Verifies breadcrumb generation, navigation, and accessibility
|
||||
*/
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { AppBreadcrumbs } from '@/components/layout/AppBreadcrumbs';
|
||||
import { mockUsePathname } from 'next-intl/navigation';
|
||||
|
||||
describe('AppBreadcrumbs', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUsePathname.mockReturnValue('/projects');
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders breadcrumb navigation', () => {
|
||||
render(<AppBreadcrumbs />);
|
||||
expect(screen.getByTestId('breadcrumbs')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with correct aria-label', () => {
|
||||
render(<AppBreadcrumbs />);
|
||||
|
||||
const nav = screen.getByTestId('breadcrumbs');
|
||||
expect(nav).toHaveAttribute('aria-label', 'Breadcrumb');
|
||||
});
|
||||
|
||||
it('renders home icon by default', () => {
|
||||
render(<AppBreadcrumbs />);
|
||||
expect(screen.getByTestId('breadcrumb-home')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides home icon when showHome is false', () => {
|
||||
render(<AppBreadcrumbs showHome={false} />);
|
||||
expect(screen.queryByTestId('breadcrumb-home')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auto-generated Breadcrumbs', () => {
|
||||
it('generates breadcrumb from pathname', () => {
|
||||
mockUsePathname.mockReturnValue('/projects');
|
||||
|
||||
render(<AppBreadcrumbs />);
|
||||
|
||||
expect(screen.getByTestId('breadcrumb-projects')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('generates nested breadcrumbs', () => {
|
||||
mockUsePathname.mockReturnValue('/projects/my-project/issues');
|
||||
|
||||
render(<AppBreadcrumbs />);
|
||||
|
||||
expect(screen.getByTestId('breadcrumb-projects')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('breadcrumb-my-project')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('breadcrumb-issues')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses label mappings for known paths', () => {
|
||||
mockUsePathname.mockReturnValue('/admin/agent-types');
|
||||
|
||||
render(<AppBreadcrumbs />);
|
||||
|
||||
expect(screen.getByText('Admin')).toBeInTheDocument();
|
||||
expect(screen.getByText('Agent Types')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses segment as label for unknown paths', () => {
|
||||
mockUsePathname.mockReturnValue('/custom-path');
|
||||
|
||||
render(<AppBreadcrumbs />);
|
||||
|
||||
expect(screen.getByText('custom-path')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom Breadcrumbs', () => {
|
||||
it('uses provided items instead of auto-generation', () => {
|
||||
mockUsePathname.mockReturnValue('/some/path');
|
||||
|
||||
const items = [
|
||||
{ label: 'Custom', href: '/custom', current: false },
|
||||
{ label: 'Path', href: '/custom/path', current: true },
|
||||
];
|
||||
|
||||
render(<AppBreadcrumbs items={items} />);
|
||||
|
||||
expect(screen.getByTestId('breadcrumb-custom')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('breadcrumb-path')).toBeInTheDocument();
|
||||
expect(screen.queryByText('some')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Active State', () => {
|
||||
it('marks last item as current page', () => {
|
||||
mockUsePathname.mockReturnValue('/projects/issues');
|
||||
|
||||
render(<AppBreadcrumbs />);
|
||||
|
||||
const issuesItem = screen.getByTestId('breadcrumb-issues');
|
||||
expect(issuesItem).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
it('renders non-current items as links', () => {
|
||||
mockUsePathname.mockReturnValue('/projects/issues');
|
||||
|
||||
render(<AppBreadcrumbs />);
|
||||
|
||||
const projectsLink = screen.getByTestId('breadcrumb-projects');
|
||||
expect(projectsLink.tagName).toBe('A');
|
||||
expect(projectsLink).toHaveAttribute('href', '/projects');
|
||||
});
|
||||
|
||||
it('renders current item as span (not a link)', () => {
|
||||
mockUsePathname.mockReturnValue('/projects');
|
||||
|
||||
render(<AppBreadcrumbs />);
|
||||
|
||||
const projectsItem = screen.getByTestId('breadcrumb-projects');
|
||||
expect(projectsItem.tagName).toBe('SPAN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Separators', () => {
|
||||
it('renders chevron separators between items', () => {
|
||||
mockUsePathname.mockReturnValue('/projects/issues');
|
||||
|
||||
render(<AppBreadcrumbs />);
|
||||
|
||||
// There should be separators: home -> projects -> issues
|
||||
const separators = screen.getAllByRole('listitem');
|
||||
expect(separators.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Empty State', () => {
|
||||
it('returns null when no breadcrumbs', () => {
|
||||
mockUsePathname.mockReturnValue('/');
|
||||
|
||||
const { container } = render(<AppBreadcrumbs showHome={false} />);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('home link has accessible label', () => {
|
||||
render(<AppBreadcrumbs />);
|
||||
|
||||
const homeLink = screen.getByTestId('breadcrumb-home');
|
||||
expect(homeLink).toHaveAttribute('aria-label', 'Home');
|
||||
});
|
||||
|
||||
it('links have focus-visible styling', () => {
|
||||
mockUsePathname.mockReturnValue('/projects/issues');
|
||||
|
||||
render(<AppBreadcrumbs />);
|
||||
|
||||
const projectsLink = screen.getByTestId('breadcrumb-projects');
|
||||
expect(projectsLink).toHaveClass('focus-visible:ring-2');
|
||||
});
|
||||
|
||||
it('navigation has proper landmark role', () => {
|
||||
render(<AppBreadcrumbs />);
|
||||
|
||||
const nav = screen.getByRole('navigation');
|
||||
expect(nav).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom className', () => {
|
||||
it('applies custom className', () => {
|
||||
render(<AppBreadcrumbs className="custom-class" />);
|
||||
|
||||
const nav = screen.getByTestId('breadcrumbs');
|
||||
expect(nav).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
});
|
||||
177
frontend/tests/components/layout/AppHeader.test.tsx
Normal file
177
frontend/tests/components/layout/AppHeader.test.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Tests for AppHeader Component
|
||||
* Verifies header rendering, project switcher integration, and responsive behavior
|
||||
*/
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { AppHeader } from '@/components/layout/AppHeader';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import { useLogout } from '@/lib/api/hooks/useAuth';
|
||||
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('@/lib/api/hooks/useAuth', () => ({
|
||||
useLogout: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/components/theme', () => ({
|
||||
ThemeToggle: () => <div data-testid="theme-toggle">Theme Toggle</div>,
|
||||
}));
|
||||
|
||||
jest.mock('@/components/i18n', () => ({
|
||||
LocaleSwitcher: () => <div data-testid="locale-switcher">Locale Switcher</div>,
|
||||
}));
|
||||
|
||||
// Helper to create mock user
|
||||
function createMockUser(overrides: Partial<User> = {}): User {
|
||||
return {
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
phone_number: null,
|
||||
is_active: true,
|
||||
is_superuser: false,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AppHeader', () => {
|
||||
const mockProjects = [
|
||||
{ id: '1', slug: 'project-one', name: 'Project One' },
|
||||
{ id: '2', slug: 'project-two', name: 'Project Two' },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
(useAuth as unknown as jest.Mock).mockReturnValue({
|
||||
user: createMockUser(),
|
||||
});
|
||||
|
||||
(useLogout as jest.Mock).mockReturnValue({
|
||||
mutate: jest.fn(),
|
||||
isPending: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders header element', () => {
|
||||
render(<AppHeader />);
|
||||
expect(screen.getByTestId('app-header')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with sticky positioning', () => {
|
||||
render(<AppHeader />);
|
||||
|
||||
const header = screen.getByTestId('app-header');
|
||||
expect(header).toHaveClass('sticky', 'top-0');
|
||||
});
|
||||
|
||||
it('renders theme toggle', () => {
|
||||
render(<AppHeader />);
|
||||
expect(screen.getByTestId('theme-toggle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders locale switcher', () => {
|
||||
render(<AppHeader />);
|
||||
// LocaleSwitcher renders a button with aria-label="switchLanguage"
|
||||
expect(screen.getByRole('button', { name: /switchLanguage/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders user menu', () => {
|
||||
render(<AppHeader />);
|
||||
expect(screen.getByTestId('user-menu-trigger')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logo', () => {
|
||||
it('renders logo on mobile', () => {
|
||||
render(<AppHeader />);
|
||||
|
||||
const logoLink = screen.getByRole('link', { name: /syndarix home/i });
|
||||
expect(logoLink).toBeInTheDocument();
|
||||
expect(logoLink).toHaveAttribute('href', '/');
|
||||
});
|
||||
|
||||
it('contains syndarix text', () => {
|
||||
render(<AppHeader />);
|
||||
|
||||
expect(screen.getByText('Syndarix')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Project Switcher', () => {
|
||||
it('renders project switcher when projects are provided', () => {
|
||||
render(<AppHeader projects={mockProjects} />);
|
||||
|
||||
// Multiple switchers may render for desktop/mobile - just check at least one exists
|
||||
expect(screen.getAllByTestId('project-switcher-trigger').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('does not render project switcher when no projects', () => {
|
||||
render(<AppHeader projects={[]} />);
|
||||
|
||||
expect(screen.queryByTestId('project-switcher-trigger')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays current project name', () => {
|
||||
render(
|
||||
<AppHeader
|
||||
projects={mockProjects}
|
||||
currentProject={mockProjects[0]}
|
||||
/>
|
||||
);
|
||||
|
||||
// Multiple instances may show the project name
|
||||
expect(screen.getAllByText('Project One').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('calls onProjectChange when project is changed', async () => {
|
||||
const mockOnChange = jest.fn();
|
||||
|
||||
render(
|
||||
<AppHeader
|
||||
projects={mockProjects}
|
||||
onProjectChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
// The actual test of project switching is in ProjectSwitcher.test.tsx
|
||||
// Here we just verify the prop is passed by checking switcher exists
|
||||
expect(screen.getAllByTestId('project-switcher-trigger').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('header has proper element type', () => {
|
||||
render(<AppHeader />);
|
||||
|
||||
const header = screen.getByTestId('app-header');
|
||||
expect(header.tagName).toBe('HEADER');
|
||||
});
|
||||
|
||||
it('logo link is accessible', () => {
|
||||
render(<AppHeader />);
|
||||
|
||||
const logoLink = screen.getByRole('link', { name: /syndarix home/i });
|
||||
expect(logoLink).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom className', () => {
|
||||
it('applies custom className', () => {
|
||||
render(<AppHeader className="custom-class" />);
|
||||
|
||||
const header = screen.getByTestId('app-header');
|
||||
expect(header).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
});
|
||||
386
frontend/tests/components/layout/AppLayout.test.tsx
Normal file
386
frontend/tests/components/layout/AppLayout.test.tsx
Normal file
@@ -0,0 +1,386 @@
|
||||
/**
|
||||
* Tests for AppLayout and related components
|
||||
* Verifies layout structure, responsive behavior, and component integration
|
||||
*/
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { AppLayout, PageContainer, PageHeader } from '@/components/layout/AppLayout';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import { useLogout } from '@/lib/api/hooks/useAuth';
|
||||
import { mockUsePathname } from 'next-intl/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('@/lib/api/hooks/useAuth', () => ({
|
||||
useLogout: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/components/theme', () => ({
|
||||
ThemeToggle: () => <div data-testid="theme-toggle">Theme Toggle</div>,
|
||||
}));
|
||||
|
||||
jest.mock('@/components/i18n', () => ({
|
||||
LocaleSwitcher: () => <div data-testid="locale-switcher">Locale Switcher</div>,
|
||||
}));
|
||||
|
||||
// Helper to create mock user
|
||||
function createMockUser(overrides: Partial<User> = {}): User {
|
||||
return {
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
phone_number: null,
|
||||
is_active: true,
|
||||
is_superuser: false,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AppLayout', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUsePathname.mockReturnValue('/projects');
|
||||
|
||||
(useAuth as unknown as jest.Mock).mockReturnValue({
|
||||
user: createMockUser(),
|
||||
});
|
||||
|
||||
(useLogout as jest.Mock).mockReturnValue({
|
||||
mutate: jest.fn(),
|
||||
isPending: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders layout container', () => {
|
||||
render(
|
||||
<AppLayout>
|
||||
<div>Content</div>
|
||||
</AppLayout>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('app-layout')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders children', () => {
|
||||
render(
|
||||
<AppLayout>
|
||||
<div data-testid="test-content">Test Content</div>
|
||||
</AppLayout>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('test-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders header', () => {
|
||||
render(
|
||||
<AppLayout>
|
||||
<div>Content</div>
|
||||
</AppLayout>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('app-header')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders sidebar', () => {
|
||||
render(
|
||||
<AppLayout>
|
||||
<div>Content</div>
|
||||
</AppLayout>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders breadcrumbs', () => {
|
||||
render(
|
||||
<AppLayout>
|
||||
<div>Content</div>
|
||||
</AppLayout>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('breadcrumbs')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders main content area', () => {
|
||||
render(
|
||||
<AppLayout>
|
||||
<div>Content</div>
|
||||
</AppLayout>
|
||||
);
|
||||
|
||||
const main = screen.getByRole('main');
|
||||
expect(main).toBeInTheDocument();
|
||||
expect(main).toHaveAttribute('id', 'main-content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Configuration Options', () => {
|
||||
it('hides sidebar when hideSidebar is true', () => {
|
||||
render(
|
||||
<AppLayout hideSidebar>
|
||||
<div>Content</div>
|
||||
</AppLayout>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('sidebar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides breadcrumbs when hideBreadcrumbs is true', () => {
|
||||
render(
|
||||
<AppLayout hideBreadcrumbs>
|
||||
<div>Content</div>
|
||||
</AppLayout>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('breadcrumbs')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes custom breadcrumbs to AppBreadcrumbs', () => {
|
||||
const customBreadcrumbs = [
|
||||
{ label: 'Custom', href: '/custom', current: true },
|
||||
];
|
||||
|
||||
render(
|
||||
<AppLayout breadcrumbs={customBreadcrumbs}>
|
||||
<div>Content</div>
|
||||
</AppLayout>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('breadcrumb-custom')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes project slug to sidebar', () => {
|
||||
const currentProject = { id: '1', slug: 'test-project', name: 'Test' };
|
||||
|
||||
render(
|
||||
<AppLayout currentProject={currentProject}>
|
||||
<div>Content</div>
|
||||
</AppLayout>
|
||||
);
|
||||
|
||||
// Sidebar should show project-specific navigation
|
||||
expect(screen.getByTestId('nav-dashboard')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom ClassNames', () => {
|
||||
it('applies className to main content', () => {
|
||||
render(
|
||||
<AppLayout className="custom-main">
|
||||
<div>Content</div>
|
||||
</AppLayout>
|
||||
);
|
||||
|
||||
const main = screen.getByRole('main');
|
||||
expect(main).toHaveClass('custom-main');
|
||||
});
|
||||
|
||||
it('applies wrapperClassName to outer container', () => {
|
||||
render(
|
||||
<AppLayout wrapperClassName="custom-wrapper">
|
||||
<div>Content</div>
|
||||
</AppLayout>
|
||||
);
|
||||
|
||||
const layout = screen.getByTestId('app-layout');
|
||||
expect(layout).toHaveClass('custom-wrapper');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('main content has tabIndex for skip link support', () => {
|
||||
render(
|
||||
<AppLayout>
|
||||
<div>Content</div>
|
||||
</AppLayout>
|
||||
);
|
||||
|
||||
const main = screen.getByRole('main');
|
||||
expect(main).toHaveAttribute('tabIndex', '-1');
|
||||
});
|
||||
|
||||
it('layout has min-h-screen for full viewport', () => {
|
||||
render(
|
||||
<AppLayout>
|
||||
<div>Content</div>
|
||||
</AppLayout>
|
||||
);
|
||||
|
||||
const layout = screen.getByTestId('app-layout');
|
||||
expect(layout).toHaveClass('min-h-screen');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PageContainer', () => {
|
||||
describe('Rendering', () => {
|
||||
it('renders container', () => {
|
||||
render(
|
||||
<PageContainer>
|
||||
<div>Content</div>
|
||||
</PageContainer>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('page-container')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders children', () => {
|
||||
render(
|
||||
<PageContainer>
|
||||
<div data-testid="test-content">Test Content</div>
|
||||
</PageContainer>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('test-content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Max Width', () => {
|
||||
it('defaults to max-w-6xl', () => {
|
||||
render(
|
||||
<PageContainer>
|
||||
<div>Content</div>
|
||||
</PageContainer>
|
||||
);
|
||||
|
||||
const container = screen.getByTestId('page-container');
|
||||
expect(container).toHaveClass('max-w-6xl');
|
||||
});
|
||||
|
||||
it('applies custom max width', () => {
|
||||
render(
|
||||
<PageContainer maxWidth="md">
|
||||
<div>Content</div>
|
||||
</PageContainer>
|
||||
);
|
||||
|
||||
const container = screen.getByTestId('page-container');
|
||||
expect(container).toHaveClass('max-w-md');
|
||||
});
|
||||
|
||||
it('applies full width', () => {
|
||||
render(
|
||||
<PageContainer maxWidth="full">
|
||||
<div>Content</div>
|
||||
</PageContainer>
|
||||
);
|
||||
|
||||
const container = screen.getByTestId('page-container');
|
||||
expect(container).toHaveClass('max-w-full');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Styling', () => {
|
||||
it('has container and centering classes', () => {
|
||||
render(
|
||||
<PageContainer>
|
||||
<div>Content</div>
|
||||
</PageContainer>
|
||||
);
|
||||
|
||||
const container = screen.getByTestId('page-container');
|
||||
expect(container).toHaveClass('container', 'mx-auto');
|
||||
});
|
||||
|
||||
it('has responsive padding', () => {
|
||||
render(
|
||||
<PageContainer>
|
||||
<div>Content</div>
|
||||
</PageContainer>
|
||||
);
|
||||
|
||||
const container = screen.getByTestId('page-container');
|
||||
expect(container).toHaveClass('px-4', 'py-6', 'lg:px-6', 'lg:py-8');
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
render(
|
||||
<PageContainer className="custom-class">
|
||||
<div>Content</div>
|
||||
</PageContainer>
|
||||
);
|
||||
|
||||
const container = screen.getByTestId('page-container');
|
||||
expect(container).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PageHeader', () => {
|
||||
describe('Rendering', () => {
|
||||
it('renders page header', () => {
|
||||
render(<PageHeader title="Test Title" />);
|
||||
|
||||
expect(screen.getByTestId('page-header')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders title', () => {
|
||||
render(<PageHeader title="Test Title" />);
|
||||
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Test Title');
|
||||
});
|
||||
|
||||
it('renders description when provided', () => {
|
||||
render(<PageHeader title="Title" description="Test description" />);
|
||||
|
||||
expect(screen.getByText('Test description')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render description when not provided', () => {
|
||||
render(<PageHeader title="Title" />);
|
||||
|
||||
const header = screen.getByTestId('page-header');
|
||||
expect(header.querySelectorAll('p')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders actions when provided', () => {
|
||||
render(
|
||||
<PageHeader
|
||||
title="Title"
|
||||
actions={<button data-testid="action-button">Action</button>}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('action-button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Styling', () => {
|
||||
it('has responsive flex layout', () => {
|
||||
render(<PageHeader title="Title" />);
|
||||
|
||||
const header = screen.getByTestId('page-header');
|
||||
expect(header).toHaveClass('flex', 'flex-col', 'sm:flex-row');
|
||||
});
|
||||
|
||||
it('title has responsive text size', () => {
|
||||
render(<PageHeader title="Title" />);
|
||||
|
||||
const title = screen.getByRole('heading', { level: 1 });
|
||||
expect(title).toHaveClass('text-2xl', 'sm:text-3xl');
|
||||
});
|
||||
|
||||
it('description has muted styling', () => {
|
||||
render(<PageHeader title="Title" description="Description" />);
|
||||
|
||||
const description = screen.getByText('Description');
|
||||
expect(description).toHaveClass('text-muted-foreground');
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
render(<PageHeader title="Title" className="custom-class" />);
|
||||
|
||||
const header = screen.getByTestId('page-header');
|
||||
expect(header).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
});
|
||||
273
frontend/tests/components/layout/ProjectSwitcher.test.tsx
Normal file
273
frontend/tests/components/layout/ProjectSwitcher.test.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Tests for ProjectSwitcher Component
|
||||
* Verifies project selection, navigation, and accessibility
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ProjectSwitcher, ProjectSelect } from '@/components/layout/ProjectSwitcher';
|
||||
import { mockUseRouter } from 'next-intl/navigation';
|
||||
|
||||
// Mock useRouter
|
||||
const mockPush = jest.fn();
|
||||
|
||||
describe('ProjectSwitcher', () => {
|
||||
const mockProjects = [
|
||||
{ id: '1', slug: 'project-one', name: 'Project One' },
|
||||
{ id: '2', slug: 'project-two', name: 'Project Two' },
|
||||
{ id: '3', slug: 'project-three', name: 'Project Three' },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseRouter.mockReturnValue({
|
||||
push: mockPush,
|
||||
replace: jest.fn(),
|
||||
prefetch: jest.fn(),
|
||||
back: jest.fn(),
|
||||
forward: jest.fn(),
|
||||
refresh: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders create project button when no projects', () => {
|
||||
render(<ProjectSwitcher projects={[]} />);
|
||||
|
||||
expect(screen.getByTestId('create-project-button')).toBeInTheDocument();
|
||||
expect(screen.getByText('Create Project')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders project switcher trigger when projects exist', () => {
|
||||
render(<ProjectSwitcher projects={mockProjects} />);
|
||||
|
||||
expect(screen.getByTestId('project-switcher-trigger')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays current project name', () => {
|
||||
render(
|
||||
<ProjectSwitcher
|
||||
projects={mockProjects}
|
||||
currentProject={mockProjects[0]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Project One')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays placeholder when no current project', () => {
|
||||
render(<ProjectSwitcher projects={mockProjects} />);
|
||||
|
||||
expect(screen.getByText('Select Project')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dropdown Menu', () => {
|
||||
it('opens dropdown when trigger is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ProjectSwitcher projects={mockProjects} />);
|
||||
|
||||
const trigger = screen.getByTestId('project-switcher-trigger');
|
||||
await user.click(trigger);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('project-switcher-menu')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays all projects in dropdown', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ProjectSwitcher projects={mockProjects} />);
|
||||
|
||||
const trigger = screen.getByTestId('project-switcher-trigger');
|
||||
await user.click(trigger);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('project-option-project-one')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('project-option-project-two')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('project-option-project-three')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows current indicator on selected project', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ProjectSwitcher
|
||||
projects={mockProjects}
|
||||
currentProject={mockProjects[0]}
|
||||
/>
|
||||
);
|
||||
|
||||
const trigger = screen.getByTestId('project-switcher-trigger');
|
||||
await user.click(trigger);
|
||||
|
||||
await waitFor(() => {
|
||||
const currentOption = screen.getByTestId('project-option-project-one');
|
||||
expect(currentOption).toHaveTextContent('Current');
|
||||
});
|
||||
});
|
||||
|
||||
it('includes create new project option', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ProjectSwitcher projects={mockProjects} />);
|
||||
|
||||
const trigger = screen.getByTestId('project-switcher-trigger');
|
||||
await user.click(trigger);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-project-option')).toBeInTheDocument();
|
||||
expect(screen.getByText('Create New Project')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation', () => {
|
||||
it('navigates to project when option is selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ProjectSwitcher projects={mockProjects} />);
|
||||
|
||||
const trigger = screen.getByTestId('project-switcher-trigger');
|
||||
await user.click(trigger);
|
||||
|
||||
const projectOption = await screen.findByTestId('project-option-project-two');
|
||||
await user.click(projectOption);
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/projects/project-two');
|
||||
});
|
||||
|
||||
it('calls onProjectChange callback when provided', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockOnChange = jest.fn();
|
||||
|
||||
render(
|
||||
<ProjectSwitcher
|
||||
projects={mockProjects}
|
||||
onProjectChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const trigger = screen.getByTestId('project-switcher-trigger');
|
||||
await user.click(trigger);
|
||||
|
||||
const projectOption = await screen.findByTestId('project-option-project-two');
|
||||
await user.click(projectOption);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('project-two');
|
||||
expect(mockPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('navigates to create project page', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ProjectSwitcher projects={mockProjects} />);
|
||||
|
||||
const trigger = screen.getByTestId('project-switcher-trigger');
|
||||
await user.click(trigger);
|
||||
|
||||
const createOption = await screen.findByTestId('create-project-option');
|
||||
await user.click(createOption);
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/projects/new');
|
||||
});
|
||||
|
||||
it('navigates from empty state button', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ProjectSwitcher projects={[]} />);
|
||||
|
||||
const createButton = screen.getByTestId('create-project-button');
|
||||
await user.click(createButton);
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/projects/new');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('has accessible label on trigger', () => {
|
||||
render(
|
||||
<ProjectSwitcher
|
||||
projects={mockProjects}
|
||||
currentProject={mockProjects[0]}
|
||||
/>
|
||||
);
|
||||
|
||||
const trigger = screen.getByTestId('project-switcher-trigger');
|
||||
expect(trigger).toHaveAttribute(
|
||||
'aria-label',
|
||||
'Switch project, current: Project One'
|
||||
);
|
||||
});
|
||||
|
||||
it('has accessible label when no current project', () => {
|
||||
render(<ProjectSwitcher projects={mockProjects} />);
|
||||
|
||||
const trigger = screen.getByTestId('project-switcher-trigger');
|
||||
expect(trigger).toHaveAttribute('aria-label', 'Select project');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProjectSelect', () => {
|
||||
const mockProjects = [
|
||||
{ id: '1', slug: 'project-one', name: 'Project One' },
|
||||
{ id: '2', slug: 'project-two', name: 'Project Two' },
|
||||
];
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders select component', () => {
|
||||
render(
|
||||
<ProjectSelect
|
||||
projects={mockProjects}
|
||||
onValueChange={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('project-select')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays placeholder', () => {
|
||||
render(
|
||||
<ProjectSelect
|
||||
projects={mockProjects}
|
||||
onValueChange={jest.fn()}
|
||||
placeholder="Choose a project"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Choose a project')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has combobox role', () => {
|
||||
render(
|
||||
<ProjectSelect
|
||||
projects={mockProjects}
|
||||
onValueChange={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
render(
|
||||
<ProjectSelect
|
||||
projects={mockProjects}
|
||||
onValueChange={jest.fn()}
|
||||
className="custom-class"
|
||||
/>
|
||||
);
|
||||
|
||||
const select = screen.getByTestId('project-select');
|
||||
expect(select).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
|
||||
// Note: Selection interaction tests are skipped because Radix UI Select
|
||||
// doesn't properly open in JSDOM environment. The component is tested
|
||||
// through E2E tests instead.
|
||||
});
|
||||
322
frontend/tests/components/layout/Sidebar.test.tsx
Normal file
322
frontend/tests/components/layout/Sidebar.test.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* Tests for Sidebar Component
|
||||
* Verifies navigation, collapsible behavior, project context, and accessibility
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Sidebar } from '@/components/layout/Sidebar';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import { mockUsePathname } from 'next-intl/navigation';
|
||||
import type { User } from '@/lib/stores/authStore';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('@/lib/auth/AuthContext', () => ({
|
||||
useAuth: jest.fn(),
|
||||
AuthProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
// Helper to create mock user
|
||||
function createMockUser(overrides: Partial<User> = {}): User {
|
||||
return {
|
||||
id: 'user-123',
|
||||
email: 'user@example.com',
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
phone_number: null,
|
||||
is_active: true,
|
||||
is_superuser: false,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Sidebar', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUsePathname.mockReturnValue('/projects');
|
||||
(useAuth as unknown as jest.Mock).mockReturnValue({
|
||||
user: createMockUser(),
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders sidebar with navigation header', () => {
|
||||
render(<Sidebar />);
|
||||
expect(screen.getByText('Navigation')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders sidebar with correct test id', () => {
|
||||
render(<Sidebar />);
|
||||
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders main navigation items', () => {
|
||||
render(<Sidebar />);
|
||||
expect(screen.getByTestId('nav-projects')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders collapse toggle button', () => {
|
||||
render(<Sidebar />);
|
||||
|
||||
const toggleButton = screen.getByTestId('sidebar-toggle');
|
||||
expect(toggleButton).toBeInTheDocument();
|
||||
expect(toggleButton).toHaveAttribute('aria-label', 'Collapse sidebar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Project Navigation', () => {
|
||||
it('renders project-specific navigation when projectSlug is provided', () => {
|
||||
render(<Sidebar projectSlug="my-project" />);
|
||||
|
||||
expect(screen.getByTestId('nav-dashboard')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('nav-issues')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('nav-sprints')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('nav-agents')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('nav-settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render project navigation without projectSlug', () => {
|
||||
render(<Sidebar />);
|
||||
|
||||
expect(screen.queryByTestId('nav-dashboard')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('nav-issues')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('generates correct hrefs for project navigation', () => {
|
||||
render(<Sidebar projectSlug="test-project" />);
|
||||
|
||||
expect(screen.getByTestId('nav-dashboard')).toHaveAttribute(
|
||||
'href',
|
||||
'/projects/test-project/dashboard'
|
||||
);
|
||||
expect(screen.getByTestId('nav-issues')).toHaveAttribute(
|
||||
'href',
|
||||
'/projects/test-project/issues'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admin Navigation', () => {
|
||||
it('renders admin navigation for superusers', () => {
|
||||
(useAuth as unknown as jest.Mock).mockReturnValue({
|
||||
user: createMockUser({ is_superuser: true }),
|
||||
});
|
||||
|
||||
render(<Sidebar />);
|
||||
|
||||
expect(screen.getByTestId('nav-agent-types')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('nav-admin-panel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render admin navigation for regular users', () => {
|
||||
(useAuth as unknown as jest.Mock).mockReturnValue({
|
||||
user: createMockUser({ is_superuser: false }),
|
||||
});
|
||||
|
||||
render(<Sidebar />);
|
||||
|
||||
expect(screen.queryByTestId('nav-agent-types')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('nav-admin-panel')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Active State Highlighting', () => {
|
||||
it('highlights projects link when on /projects', () => {
|
||||
mockUsePathname.mockReturnValue('/projects');
|
||||
|
||||
render(<Sidebar />);
|
||||
|
||||
const projectsLink = screen.getByTestId('nav-projects');
|
||||
expect(projectsLink).toHaveClass('bg-accent');
|
||||
});
|
||||
|
||||
it('highlights issues link when on project issues page', () => {
|
||||
mockUsePathname.mockReturnValue('/projects/my-project/issues');
|
||||
|
||||
render(<Sidebar projectSlug="my-project" />);
|
||||
|
||||
const issuesLink = screen.getByTestId('nav-issues');
|
||||
expect(issuesLink).toHaveClass('bg-accent');
|
||||
});
|
||||
|
||||
it('sets aria-current on active link', () => {
|
||||
mockUsePathname.mockReturnValue('/projects');
|
||||
|
||||
render(<Sidebar />);
|
||||
|
||||
const projectsLink = screen.getByTestId('nav-projects');
|
||||
expect(projectsLink).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Collapsible Behavior', () => {
|
||||
it('starts in expanded state', () => {
|
||||
render(<Sidebar />);
|
||||
|
||||
expect(screen.getByText('Navigation')).toBeInTheDocument();
|
||||
expect(screen.getByText('Projects')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('collapses when toggle button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<Sidebar />);
|
||||
|
||||
const toggleButton = screen.getByTestId('sidebar-toggle');
|
||||
await user.click(toggleButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Navigation')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(toggleButton).toHaveAttribute('aria-label', 'Expand sidebar');
|
||||
});
|
||||
|
||||
it('expands when toggle button is clicked twice', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<Sidebar />);
|
||||
|
||||
const toggleButton = screen.getByTestId('sidebar-toggle');
|
||||
|
||||
// Collapse
|
||||
await user.click(toggleButton);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Navigation')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Expand
|
||||
await user.click(toggleButton);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Navigation')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('adds title attribute to links when collapsed', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<Sidebar />);
|
||||
|
||||
const projectsLink = screen.getByTestId('nav-projects');
|
||||
|
||||
// No title in expanded state
|
||||
expect(projectsLink).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(projectsLink).toHaveAttribute('title', 'Projects');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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@example.com',
|
||||
}),
|
||||
});
|
||||
|
||||
render(<Sidebar />);
|
||||
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
expect(screen.getByText('john@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays user initial from first name', () => {
|
||||
(useAuth as unknown as jest.Mock).mockReturnValue({
|
||||
user: createMockUser({
|
||||
first_name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
}),
|
||||
});
|
||||
|
||||
render(<Sidebar />);
|
||||
|
||||
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(<Sidebar />);
|
||||
|
||||
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@example.com',
|
||||
}),
|
||||
});
|
||||
|
||||
render(<Sidebar />);
|
||||
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
|
||||
const toggleButton = screen.getByTestId('sidebar-toggle');
|
||||
await user.click(toggleButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('John Doe')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('john@example.com')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mobile Navigation', () => {
|
||||
it('renders mobile menu trigger button', () => {
|
||||
render(<Sidebar />);
|
||||
|
||||
expect(screen.getByTestId('mobile-menu-trigger')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has accessible label on mobile trigger', () => {
|
||||
render(<Sidebar />);
|
||||
|
||||
const trigger = screen.getByTestId('mobile-menu-trigger');
|
||||
expect(trigger).toHaveAttribute('aria-label', 'Open navigation menu');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('has proper aria-label on sidebar', () => {
|
||||
render(<Sidebar />);
|
||||
|
||||
const sidebar = screen.getByTestId('sidebar');
|
||||
expect(sidebar).toHaveAttribute('aria-label', 'Main navigation');
|
||||
});
|
||||
|
||||
it('navigation links are keyboard accessible', () => {
|
||||
render(<Sidebar />);
|
||||
|
||||
const projectsLink = screen.getByTestId('nav-projects');
|
||||
expect(projectsLink.tagName).toBe('A');
|
||||
});
|
||||
|
||||
it('has proper focus styling classes on links', () => {
|
||||
render(<Sidebar />);
|
||||
|
||||
const projectsLink = screen.getByTestId('nav-projects');
|
||||
expect(projectsLink).toHaveClass('focus-visible:ring-2');
|
||||
});
|
||||
});
|
||||
});
|
||||
323
frontend/tests/components/layout/UserMenu.test.tsx
Normal file
323
frontend/tests/components/layout/UserMenu.test.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* Tests for UserMenu Component
|
||||
* Verifies user menu rendering, navigation, and logout functionality
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { UserMenu } from '@/components/layout/UserMenu';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import { useLogout } from '@/lib/api/hooks/useAuth';
|
||||
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('@/lib/api/hooks/useAuth', () => ({
|
||||
useLogout: jest.fn(),
|
||||
}));
|
||||
|
||||
// Helper to create mock user
|
||||
function createMockUser(overrides: Partial<User> = {}): User {
|
||||
return {
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
phone_number: null,
|
||||
is_active: true,
|
||||
is_superuser: false,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('UserMenu', () => {
|
||||
const mockLogout = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
(useLogout as jest.Mock).mockReturnValue({
|
||||
mutate: mockLogout,
|
||||
isPending: false,
|
||||
});
|
||||
|
||||
(useAuth as unknown as jest.Mock).mockReturnValue({
|
||||
user: createMockUser(),
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders user menu trigger', () => {
|
||||
render(<UserMenu />);
|
||||
expect(screen.getByTestId('user-menu-trigger')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays user initials in avatar', () => {
|
||||
(useAuth as unknown as jest.Mock).mockReturnValue({
|
||||
user: createMockUser({
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
}),
|
||||
});
|
||||
|
||||
render(<UserMenu />);
|
||||
|
||||
expect(screen.getByText('JD')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays single initial when no last name', () => {
|
||||
(useAuth as unknown as jest.Mock).mockReturnValue({
|
||||
user: createMockUser({
|
||||
first_name: 'John',
|
||||
last_name: null,
|
||||
}),
|
||||
});
|
||||
|
||||
render(<UserMenu />);
|
||||
|
||||
expect(screen.getByText('J')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays default initial when no first name', () => {
|
||||
(useAuth as unknown as jest.Mock).mockReturnValue({
|
||||
user: createMockUser({
|
||||
first_name: '',
|
||||
}),
|
||||
});
|
||||
|
||||
render(<UserMenu />);
|
||||
|
||||
expect(screen.getByText('U')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('returns null when no user', () => {
|
||||
(useAuth as unknown as jest.Mock).mockReturnValue({
|
||||
user: null,
|
||||
});
|
||||
|
||||
const { container } = render(<UserMenu />);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dropdown Menu', () => {
|
||||
it('opens menu when trigger is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<UserMenu />);
|
||||
|
||||
const trigger = screen.getByTestId('user-menu-trigger');
|
||||
await user.click(trigger);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('user-menu-content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays user info in menu header', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
(useAuth as unknown as jest.Mock).mockReturnValue({
|
||||
user: createMockUser({
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john@example.com',
|
||||
}),
|
||||
});
|
||||
|
||||
render(<UserMenu />);
|
||||
|
||||
const trigger = screen.getByTestId('user-menu-trigger');
|
||||
await user.click(trigger);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
expect(screen.getByText('john@example.com')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation Links', () => {
|
||||
it('includes profile link', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<UserMenu />);
|
||||
|
||||
const trigger = screen.getByTestId('user-menu-trigger');
|
||||
await user.click(trigger);
|
||||
|
||||
const profileLink = await screen.findByTestId('user-menu-profile');
|
||||
expect(profileLink).toHaveAttribute('href', '/settings/profile');
|
||||
});
|
||||
|
||||
it('includes password/settings link', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<UserMenu />);
|
||||
|
||||
const trigger = screen.getByTestId('user-menu-trigger');
|
||||
await user.click(trigger);
|
||||
|
||||
const passwordLink = await screen.findByTestId('user-menu-password');
|
||||
expect(passwordLink).toHaveAttribute('href', '/settings/password');
|
||||
});
|
||||
|
||||
it('includes sessions link', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<UserMenu />);
|
||||
|
||||
const trigger = screen.getByTestId('user-menu-trigger');
|
||||
await user.click(trigger);
|
||||
|
||||
const sessionsLink = await screen.findByTestId('user-menu-sessions');
|
||||
expect(sessionsLink).toHaveAttribute('href', '/settings/sessions');
|
||||
});
|
||||
|
||||
it('includes preferences link', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<UserMenu />);
|
||||
|
||||
const trigger = screen.getByTestId('user-menu-trigger');
|
||||
await user.click(trigger);
|
||||
|
||||
const preferencesLink = await screen.findByTestId('user-menu-preferences');
|
||||
expect(preferencesLink).toHaveAttribute('href', '/settings/preferences');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admin Link', () => {
|
||||
it('shows admin link for superusers', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
(useAuth as unknown as jest.Mock).mockReturnValue({
|
||||
user: createMockUser({ is_superuser: true }),
|
||||
});
|
||||
|
||||
render(<UserMenu />);
|
||||
|
||||
const trigger = screen.getByTestId('user-menu-trigger');
|
||||
await user.click(trigger);
|
||||
|
||||
const adminLink = await screen.findByTestId('user-menu-admin');
|
||||
expect(adminLink).toHaveAttribute('href', '/admin');
|
||||
});
|
||||
|
||||
it('does not show admin link for regular users', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
(useAuth as unknown as jest.Mock).mockReturnValue({
|
||||
user: createMockUser({ is_superuser: false }),
|
||||
});
|
||||
|
||||
render(<UserMenu />);
|
||||
|
||||
const trigger = screen.getByTestId('user-menu-trigger');
|
||||
await user.click(trigger);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('user-menu-admin')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logout Functionality', () => {
|
||||
it('calls logout when logout button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<UserMenu />);
|
||||
|
||||
const trigger = screen.getByTestId('user-menu-trigger');
|
||||
await user.click(trigger);
|
||||
|
||||
const logoutButton = await screen.findByTestId('user-menu-logout');
|
||||
await user.click(logoutButton);
|
||||
|
||||
expect(mockLogout).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows loading state when logging out', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
(useLogout as jest.Mock).mockReturnValue({
|
||||
mutate: mockLogout,
|
||||
isPending: true,
|
||||
});
|
||||
|
||||
render(<UserMenu />);
|
||||
|
||||
const trigger = screen.getByTestId('user-menu-trigger');
|
||||
await user.click(trigger);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('loggingOut')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('disables logout button when logging out', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
(useLogout as jest.Mock).mockReturnValue({
|
||||
mutate: mockLogout,
|
||||
isPending: true,
|
||||
});
|
||||
|
||||
render(<UserMenu />);
|
||||
|
||||
const trigger = screen.getByTestId('user-menu-trigger');
|
||||
await user.click(trigger);
|
||||
|
||||
const logoutButton = await screen.findByTestId('user-menu-logout');
|
||||
expect(logoutButton).toHaveAttribute('data-disabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('has accessible label on trigger', () => {
|
||||
(useAuth as unknown as jest.Mock).mockReturnValue({
|
||||
user: createMockUser({
|
||||
first_name: 'John',
|
||||
}),
|
||||
});
|
||||
|
||||
render(<UserMenu />);
|
||||
|
||||
const trigger = screen.getByTestId('user-menu-trigger');
|
||||
expect(trigger).toHaveAttribute('aria-label', 'User menu for John');
|
||||
});
|
||||
|
||||
it('uses email when no first name', () => {
|
||||
(useAuth as unknown as jest.Mock).mockReturnValue({
|
||||
user: createMockUser({
|
||||
first_name: '',
|
||||
email: 'test@example.com',
|
||||
}),
|
||||
});
|
||||
|
||||
render(<UserMenu />);
|
||||
|
||||
const trigger = screen.getByTestId('user-menu-trigger');
|
||||
expect(trigger).toHaveAttribute('aria-label', 'User menu for test@example.com');
|
||||
});
|
||||
|
||||
it('logout button has destructive styling', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<UserMenu />);
|
||||
|
||||
const trigger = screen.getByTestId('user-menu-trigger');
|
||||
await user.click(trigger);
|
||||
|
||||
const logoutButton = await screen.findByTestId('user-menu-logout');
|
||||
expect(logoutButton).toHaveClass('text-destructive');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user