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>
324 lines
8.7 KiB
TypeScript
324 lines
8.7 KiB
TypeScript
/**
|
|
* 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');
|
|
});
|
|
});
|
|
});
|