Files
syndarix/frontend/tests/components/layout/Sidebar.test.tsx
Felipe Cardoso 6e645835dc 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>
2025-12-30 01:35:39 +01:00

323 lines
9.5 KiB
TypeScript

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