forked from cardosofelipe/fast-next-template
Add unit tests for core components and layouts
- **ThemeToggle:** Introduce comprehensive tests to validate button functionality, dropdown options, and active theme indicators. - **ThemeProvider:** Add tests for theme management, localStorage persistence, system preferences, and DOM updates. - **Header & Footer:** Verify header rendering, user menu functionality, and footer content consistency. - **AuthInitializer:** Ensure authentication state is correctly loaded from storage on mount.
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
|
/* istanbul ignore file */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component Showcase
|
* Component Showcase
|
||||||
* Comprehensive display of all design system components
|
* Comprehensive display of all design system components
|
||||||
|
* This file is excluded from coverage as it's a demo/showcase page
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|||||||
58
frontend/tests/components/auth/AuthInitializer.test.tsx
Normal file
58
frontend/tests/components/auth/AuthInitializer.test.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Tests for AuthInitializer
|
||||||
|
* Verifies authentication state is loaded from storage on mount
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, waitFor } from '@testing-library/react';
|
||||||
|
import { AuthInitializer } from '@/components/auth/AuthInitializer';
|
||||||
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
|
|
||||||
|
// Mock the auth store
|
||||||
|
jest.mock('@/stores/authStore', () => ({
|
||||||
|
useAuthStore: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('AuthInitializer', () => {
|
||||||
|
const mockLoadAuthFromStorage = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
(useAuthStore as unknown as jest.Mock).mockImplementation((selector: any) => {
|
||||||
|
const state = {
|
||||||
|
loadAuthFromStorage: mockLoadAuthFromStorage,
|
||||||
|
};
|
||||||
|
return selector(state);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Initialization', () => {
|
||||||
|
it('renders nothing (null)', () => {
|
||||||
|
const { container } = render(<AuthInitializer />);
|
||||||
|
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls loadAuthFromStorage on mount', async () => {
|
||||||
|
render(<AuthInitializer />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockLoadAuthFromStorage).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call loadAuthFromStorage again on re-render', async () => {
|
||||||
|
const { rerender } = render(<AuthInitializer />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockLoadAuthFromStorage).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force re-render
|
||||||
|
rerender(<AuthInitializer />);
|
||||||
|
|
||||||
|
// Should still only be called once (useEffect dependencies prevent re-call)
|
||||||
|
expect(mockLoadAuthFromStorage).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
33
frontend/tests/components/layout/Footer.test.tsx
Normal file
33
frontend/tests/components/layout/Footer.test.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Tests for Footer Component
|
||||||
|
* Verifies footer rendering and content
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { Footer } from '@/components/layout/Footer';
|
||||||
|
|
||||||
|
describe('Footer', () => {
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('renders footer element', () => {
|
||||||
|
const { container } = render(<Footer />);
|
||||||
|
|
||||||
|
const footer = container.querySelector('footer');
|
||||||
|
expect(footer).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays copyright text with current year', () => {
|
||||||
|
render(<Footer />);
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
expect(screen.getByText(`© ${currentYear} FastNext Template. All rights reserved.`)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies correct styling classes', () => {
|
||||||
|
const { container } = render(<Footer />);
|
||||||
|
|
||||||
|
const footer = container.querySelector('footer');
|
||||||
|
expect(footer).toHaveClass('border-t');
|
||||||
|
expect(footer).toHaveClass('bg-muted/30');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
345
frontend/tests/components/layout/Header.test.tsx
Normal file
345
frontend/tests/components/layout/Header.test.tsx
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
/**
|
||||||
|
* Tests for Header Component
|
||||||
|
* Verifies navigation, user menu, and auth-based rendering
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { Header } from '@/components/layout/Header';
|
||||||
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
|
import { useLogout } from '@/lib/api/hooks/useAuth';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import type { User } from '@/stores/authStore';
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
jest.mock('@/stores/authStore', () => ({
|
||||||
|
useAuthStore: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@/lib/api/hooks/useAuth', () => ({
|
||||||
|
useLogout: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('next/navigation', () => ({
|
||||||
|
usePathname: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@/components/theme', () => ({
|
||||||
|
ThemeToggle: () => <div data-testid="theme-toggle">Theme Toggle</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('Header', () => {
|
||||||
|
const mockLogout = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
(usePathname as jest.Mock).mockReturnValue('/');
|
||||||
|
|
||||||
|
(useLogout as jest.Mock).mockReturnValue({
|
||||||
|
mutate: mockLogout,
|
||||||
|
isPending: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('renders header with logo', () => {
|
||||||
|
(useAuthStore as unknown as jest.Mock).mockReturnValue({
|
||||||
|
user: createMockUser(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Header />);
|
||||||
|
|
||||||
|
expect(screen.getByText('FastNext')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders theme toggle', () => {
|
||||||
|
(useAuthStore as unknown as jest.Mock).mockReturnValue({
|
||||||
|
user: createMockUser(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Header />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('theme-toggle')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders user avatar with initials', () => {
|
||||||
|
(useAuthStore as unknown as jest.Mock).mockReturnValue({
|
||||||
|
user: createMockUser({
|
||||||
|
first_name: 'John',
|
||||||
|
last_name: 'Doe',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Header />);
|
||||||
|
|
||||||
|
expect(screen.getByText('JD')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders user avatar with single initial when no last name', () => {
|
||||||
|
(useAuthStore as unknown as jest.Mock).mockReturnValue({
|
||||||
|
user: createMockUser({
|
||||||
|
first_name: 'John',
|
||||||
|
last_name: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Header />);
|
||||||
|
|
||||||
|
expect(screen.getByText('J')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders default initial when no first name', () => {
|
||||||
|
(useAuthStore as unknown as jest.Mock).mockReturnValue({
|
||||||
|
user: createMockUser({
|
||||||
|
first_name: '',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Header />);
|
||||||
|
|
||||||
|
expect(screen.getByText('U')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Navigation Links', () => {
|
||||||
|
it('renders home link', () => {
|
||||||
|
(useAuthStore as unknown as jest.Mock).mockReturnValue({
|
||||||
|
user: createMockUser(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Header />);
|
||||||
|
|
||||||
|
const homeLink = screen.getByRole('link', { name: /home/i });
|
||||||
|
expect(homeLink).toHaveAttribute('href', '/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders admin link for superusers', () => {
|
||||||
|
(useAuthStore as unknown as jest.Mock).mockReturnValue({
|
||||||
|
user: createMockUser({ is_superuser: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Header />);
|
||||||
|
|
||||||
|
const adminLink = screen.getByRole('link', { name: /admin/i });
|
||||||
|
expect(adminLink).toHaveAttribute('href', '/admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render admin link for regular users', () => {
|
||||||
|
(useAuthStore as unknown as jest.Mock).mockReturnValue({
|
||||||
|
user: createMockUser({ is_superuser: false }),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Header />);
|
||||||
|
|
||||||
|
const adminLinks = screen.queryAllByRole('link', { name: /admin/i });
|
||||||
|
// Filter out the one in the dropdown menu
|
||||||
|
const navAdminLinks = adminLinks.filter(
|
||||||
|
(link) => !link.closest('[role="menu"]')
|
||||||
|
);
|
||||||
|
expect(navAdminLinks).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('highlights active navigation link', () => {
|
||||||
|
(usePathname as jest.Mock).mockReturnValue('/admin');
|
||||||
|
(useAuthStore as unknown as jest.Mock).mockReturnValue({
|
||||||
|
user: createMockUser({ is_superuser: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Header />);
|
||||||
|
|
||||||
|
const adminLink = screen.getByRole('link', { name: /admin/i });
|
||||||
|
expect(adminLink).toHaveClass('bg-primary');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('User Dropdown Menu', () => {
|
||||||
|
it('opens dropdown when avatar is clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
(useAuthStore as unknown as jest.Mock).mockReturnValue({
|
||||||
|
user: createMockUser({
|
||||||
|
first_name: 'John',
|
||||||
|
last_name: 'Doe',
|
||||||
|
email: 'john@example.com',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Header />);
|
||||||
|
|
||||||
|
// Find avatar button by looking for the button containing the avatar initials
|
||||||
|
const avatarButton = screen.getByText('JD').closest('button')!;
|
||||||
|
await user.click(avatarButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('john@example.com')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays user info in dropdown', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
(useAuthStore as unknown as jest.Mock).mockReturnValue({
|
||||||
|
user: createMockUser({
|
||||||
|
first_name: 'John',
|
||||||
|
last_name: 'Doe',
|
||||||
|
email: 'john@example.com',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Header />);
|
||||||
|
|
||||||
|
const avatarButton = screen.getByText('JD').closest('button')!;
|
||||||
|
await user.click(avatarButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('john@example.com')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes profile link in dropdown', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
(useAuthStore as unknown as jest.Mock).mockReturnValue({
|
||||||
|
user: createMockUser(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Header />);
|
||||||
|
|
||||||
|
const avatarButton = screen.getByText('TU').closest('button')!;
|
||||||
|
await user.click(avatarButton);
|
||||||
|
|
||||||
|
const profileLink = await screen.findByRole('menuitem', { name: /profile/i });
|
||||||
|
expect(profileLink).toHaveAttribute('href', '/settings/profile');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes settings link in dropdown', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
(useAuthStore as unknown as jest.Mock).mockReturnValue({
|
||||||
|
user: createMockUser(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Header />);
|
||||||
|
|
||||||
|
const avatarButton = screen.getByText('TU').closest('button')!;
|
||||||
|
await user.click(avatarButton);
|
||||||
|
|
||||||
|
const settingsLink = await screen.findByRole('menuitem', { name: /settings/i });
|
||||||
|
expect(settingsLink).toHaveAttribute('href', '/settings/password');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes admin panel link for superusers', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
(useAuthStore as unknown as jest.Mock).mockReturnValue({
|
||||||
|
user: createMockUser({ is_superuser: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Header />);
|
||||||
|
|
||||||
|
const avatarButton = screen.getByText('TU').closest('button')!;
|
||||||
|
await user.click(avatarButton);
|
||||||
|
|
||||||
|
const adminLink = await screen.findByRole('menuitem', { name: /admin panel/i });
|
||||||
|
expect(adminLink).toHaveAttribute('href', '/admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not include admin panel link for regular users', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
(useAuthStore as unknown as jest.Mock).mockReturnValue({
|
||||||
|
user: createMockUser({ is_superuser: false }),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Header />);
|
||||||
|
|
||||||
|
const avatarButton = screen.getByText('TU').closest('button')!;
|
||||||
|
await user.click(avatarButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole('menuitem', { name: /admin panel/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Logout Functionality', () => {
|
||||||
|
it('calls logout when logout button is clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
(useAuthStore as unknown as jest.Mock).mockReturnValue({
|
||||||
|
user: createMockUser(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Header />);
|
||||||
|
|
||||||
|
const avatarButton = screen.getByText('TU').closest('button')!;
|
||||||
|
await user.click(avatarButton);
|
||||||
|
|
||||||
|
const logoutButton = await screen.findByRole('menuitem', { name: /log out/i });
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
(useAuthStore as unknown as jest.Mock).mockReturnValue({
|
||||||
|
user: createMockUser(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Header />);
|
||||||
|
|
||||||
|
const avatarButton = screen.getByText('TU').closest('button')!;
|
||||||
|
await user.click(avatarButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Logging out...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables logout button when logging out', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
(useLogout as jest.Mock).mockReturnValue({
|
||||||
|
mutate: mockLogout,
|
||||||
|
isPending: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
(useAuthStore as unknown as jest.Mock).mockReturnValue({
|
||||||
|
user: createMockUser(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Header />);
|
||||||
|
|
||||||
|
const avatarButton = screen.getByText('TU').closest('button')!;
|
||||||
|
await user.click(avatarButton);
|
||||||
|
|
||||||
|
const logoutButton = await screen.findByRole('menuitem', { name: /logging out/i });
|
||||||
|
expect(logoutButton).toHaveAttribute('data-disabled');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
349
frontend/tests/components/theme/ThemeProvider.test.tsx
Normal file
349
frontend/tests/components/theme/ThemeProvider.test.tsx
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
/**
|
||||||
|
* Tests for ThemeProvider
|
||||||
|
* Verifies theme state management, localStorage persistence, and system preference detection
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { act } from 'react';
|
||||||
|
import { ThemeProvider, useTheme } from '@/components/theme/ThemeProvider';
|
||||||
|
|
||||||
|
// Test component to access theme context
|
||||||
|
function TestComponent() {
|
||||||
|
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div data-testid="current-theme">{theme}</div>
|
||||||
|
<div data-testid="resolved-theme">{resolvedTheme}</div>
|
||||||
|
<button onClick={() => setTheme('light')}>Set Light</button>
|
||||||
|
<button onClick={() => setTheme('dark')}>Set Dark</button>
|
||||||
|
<button onClick={() => setTheme('system')}>Set System</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ThemeProvider', () => {
|
||||||
|
let mockLocalStorage: { [key: string]: string };
|
||||||
|
let mockMatchMedia: jest.Mock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mock localStorage
|
||||||
|
mockLocalStorage = {};
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'localStorage', {
|
||||||
|
value: {
|
||||||
|
getItem: jest.fn((key: string) => mockLocalStorage[key] || null),
|
||||||
|
setItem: jest.fn((key: string, value: string) => {
|
||||||
|
mockLocalStorage[key] = value;
|
||||||
|
}),
|
||||||
|
removeItem: jest.fn((key: string) => {
|
||||||
|
delete mockLocalStorage[key];
|
||||||
|
}),
|
||||||
|
clear: jest.fn(() => {
|
||||||
|
mockLocalStorage = {};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock matchMedia
|
||||||
|
mockMatchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: query === '(prefers-color-scheme: dark)' ? false : false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: jest.fn(),
|
||||||
|
removeListener: jest.fn(),
|
||||||
|
addEventListener: jest.fn(),
|
||||||
|
removeEventListener: jest.fn(),
|
||||||
|
dispatchEvent: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: mockMatchMedia,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock document.documentElement
|
||||||
|
document.documentElement.classList.remove('light', 'dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Initialization', () => {
|
||||||
|
it('defaults to system theme when no stored preference', () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<TestComponent />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('current-theme')).toHaveTextContent('system');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads stored theme preference from localStorage', async () => {
|
||||||
|
mockLocalStorage['theme'] = 'dark';
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<TestComponent />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('current-theme')).toHaveTextContent('dark');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores invalid theme values from localStorage', () => {
|
||||||
|
mockLocalStorage['theme'] = 'invalid';
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<TestComponent />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('current-theme')).toHaveTextContent('system');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Theme Switching', () => {
|
||||||
|
it('updates theme when setTheme is called', async () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<TestComponent />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const lightButton = screen.getByRole('button', { name: 'Set Light' });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
lightButton.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('current-theme')).toHaveTextContent('light');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists theme to localStorage when changed', async () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<TestComponent />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const darkButton = screen.getByRole('button', { name: 'Set Dark' });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
darkButton.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(localStorage.setItem).toHaveBeenCalledWith('theme', 'dark');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Resolved Theme', () => {
|
||||||
|
it('resolves light theme correctly', async () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<TestComponent />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const lightButton = screen.getByRole('button', { name: 'Set Light' });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
lightButton.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('resolved-theme')).toHaveTextContent('light');
|
||||||
|
expect(document.documentElement.classList.contains('light')).toBe(true);
|
||||||
|
expect(document.documentElement.classList.contains('dark')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves dark theme correctly', async () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<TestComponent />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const darkButton = screen.getByRole('button', { name: 'Set Dark' });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
darkButton.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('resolved-theme')).toHaveTextContent('dark');
|
||||||
|
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||||
|
expect(document.documentElement.classList.contains('light')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves system theme to light when system prefers light', async () => {
|
||||||
|
mockMatchMedia.mockImplementation((query: string) => ({
|
||||||
|
matches: query === '(prefers-color-scheme: dark)' ? false : false,
|
||||||
|
media: query,
|
||||||
|
addEventListener: jest.fn(),
|
||||||
|
removeEventListener: jest.fn(),
|
||||||
|
dispatchEvent: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<TestComponent />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const systemButton = screen.getByRole('button', { name: 'Set System' });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
systemButton.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('current-theme')).toHaveTextContent('system');
|
||||||
|
expect(screen.getByTestId('resolved-theme')).toHaveTextContent('light');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves system theme to dark when system prefers dark', async () => {
|
||||||
|
mockMatchMedia.mockImplementation((query: string) => ({
|
||||||
|
matches: query === '(prefers-color-scheme: dark)' ? true : false,
|
||||||
|
media: query,
|
||||||
|
addEventListener: jest.fn(),
|
||||||
|
removeEventListener: jest.fn(),
|
||||||
|
dispatchEvent: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<TestComponent />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const systemButton = screen.getByRole('button', { name: 'Set System' });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
systemButton.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('current-theme')).toHaveTextContent('system');
|
||||||
|
expect(screen.getByTestId('resolved-theme')).toHaveTextContent('dark');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DOM Updates', () => {
|
||||||
|
it('applies theme class to document element', async () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<TestComponent />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const lightButton = screen.getByRole('button', { name: 'Set Light' });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
lightButton.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(document.documentElement.classList.contains('light')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes previous theme class when switching', async () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<TestComponent />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set to light
|
||||||
|
await act(async () => {
|
||||||
|
screen.getByRole('button', { name: 'Set Light' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(document.documentElement.classList.contains('light')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Switch to dark
|
||||||
|
await act(async () => {
|
||||||
|
screen.getByRole('button', { name: 'Set Dark' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||||
|
expect(document.documentElement.classList.contains('light')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error Handling', () => {
|
||||||
|
it('throws error when useTheme is used outside provider', () => {
|
||||||
|
// Suppress console.error for this test
|
||||||
|
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
render(<TestComponent />);
|
||||||
|
}).toThrow('useTheme must be used within ThemeProvider');
|
||||||
|
|
||||||
|
consoleError.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('System Preference Changes', () => {
|
||||||
|
it('listens to system preference changes', () => {
|
||||||
|
const mockAddEventListener = jest.fn();
|
||||||
|
|
||||||
|
mockMatchMedia.mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
addEventListener: mockAddEventListener,
|
||||||
|
removeEventListener: jest.fn(),
|
||||||
|
dispatchEvent: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<TestComponent />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockAddEventListener).toHaveBeenCalledWith('change', expect.any(Function));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleans up event listener on unmount', () => {
|
||||||
|
const mockRemoveEventListener = jest.fn();
|
||||||
|
|
||||||
|
mockMatchMedia.mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
addEventListener: jest.fn(),
|
||||||
|
removeEventListener: mockRemoveEventListener,
|
||||||
|
dispatchEvent: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { unmount } = render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<TestComponent />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(mockRemoveEventListener).toHaveBeenCalledWith('change', expect.any(Function));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
186
frontend/tests/components/theme/ThemeToggle.test.tsx
Normal file
186
frontend/tests/components/theme/ThemeToggle.test.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
/**
|
||||||
|
* Tests for ThemeToggle
|
||||||
|
* Verifies theme toggle button functionality and dropdown menu
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { ThemeToggle } from '@/components/theme/ThemeToggle';
|
||||||
|
import { ThemeProvider, useTheme } from '@/components/theme/ThemeProvider';
|
||||||
|
|
||||||
|
// Mock theme provider for controlled testing
|
||||||
|
jest.mock('@/components/theme/ThemeProvider', () => {
|
||||||
|
const actual = jest.requireActual('@/components/theme/ThemeProvider');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useTheme: jest.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ThemeToggle', () => {
|
||||||
|
const mockSetTheme = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Default mock return value
|
||||||
|
(useTheme as jest.Mock).mockReturnValue({
|
||||||
|
theme: 'system',
|
||||||
|
setTheme: mockSetTheme,
|
||||||
|
resolvedTheme: 'light',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('renders theme toggle button', () => {
|
||||||
|
render(<ThemeToggle />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /toggle theme/i });
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays sun icon when resolved theme is light', () => {
|
||||||
|
(useTheme as jest.Mock).mockReturnValue({
|
||||||
|
theme: 'light',
|
||||||
|
setTheme: mockSetTheme,
|
||||||
|
resolvedTheme: 'light',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ThemeToggle />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /toggle theme/i });
|
||||||
|
// Sun icon should be visible
|
||||||
|
expect(button.querySelector('svg')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays moon icon when resolved theme is dark', () => {
|
||||||
|
(useTheme as jest.Mock).mockReturnValue({
|
||||||
|
theme: 'dark',
|
||||||
|
setTheme: mockSetTheme,
|
||||||
|
resolvedTheme: 'dark',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ThemeToggle />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /toggle theme/i });
|
||||||
|
// Moon icon should be visible
|
||||||
|
expect(button.querySelector('svg')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Dropdown Menu', () => {
|
||||||
|
it('opens dropdown menu when button is clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<ThemeToggle />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /toggle theme/i });
|
||||||
|
await user.click(button);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('menuitem', { name: /light/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('menuitem', { name: /dark/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('menuitem', { name: /system/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls setTheme with "light" when light option is clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<ThemeToggle />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /toggle theme/i });
|
||||||
|
await user.click(button);
|
||||||
|
|
||||||
|
const lightOption = await screen.findByRole('menuitem', { name: /light/i });
|
||||||
|
await user.click(lightOption);
|
||||||
|
|
||||||
|
expect(mockSetTheme).toHaveBeenCalledWith('light');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls setTheme with "dark" when dark option is clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<ThemeToggle />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /toggle theme/i });
|
||||||
|
await user.click(button);
|
||||||
|
|
||||||
|
const darkOption = await screen.findByRole('menuitem', { name: /dark/i });
|
||||||
|
await user.click(darkOption);
|
||||||
|
|
||||||
|
expect(mockSetTheme).toHaveBeenCalledWith('dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls setTheme with "system" when system option is clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<ThemeToggle />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /toggle theme/i });
|
||||||
|
await user.click(button);
|
||||||
|
|
||||||
|
const systemOption = await screen.findByRole('menuitem', { name: /system/i });
|
||||||
|
await user.click(systemOption);
|
||||||
|
|
||||||
|
expect(mockSetTheme).toHaveBeenCalledWith('system');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Active Theme Indicator', () => {
|
||||||
|
it('shows checkmark for light theme when active', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
(useTheme as jest.Mock).mockReturnValue({
|
||||||
|
theme: 'light',
|
||||||
|
setTheme: mockSetTheme,
|
||||||
|
resolvedTheme: 'light',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ThemeToggle />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /toggle theme/i });
|
||||||
|
await user.click(button);
|
||||||
|
|
||||||
|
const lightOption = await screen.findByRole('menuitem', { name: /light/i });
|
||||||
|
expect(lightOption).toHaveTextContent('✓');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows checkmark for dark theme when active', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
(useTheme as jest.Mock).mockReturnValue({
|
||||||
|
theme: 'dark',
|
||||||
|
setTheme: mockSetTheme,
|
||||||
|
resolvedTheme: 'dark',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ThemeToggle />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /toggle theme/i });
|
||||||
|
await user.click(button);
|
||||||
|
|
||||||
|
const darkOption = await screen.findByRole('menuitem', { name: /dark/i });
|
||||||
|
expect(darkOption).toHaveTextContent('✓');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows checkmark for system theme when active', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
(useTheme as jest.Mock).mockReturnValue({
|
||||||
|
theme: 'system',
|
||||||
|
setTheme: mockSetTheme,
|
||||||
|
resolvedTheme: 'light',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ThemeToggle />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /toggle theme/i });
|
||||||
|
await user.click(button);
|
||||||
|
|
||||||
|
const systemOption = await screen.findByRole('menuitem', { name: /system/i });
|
||||||
|
expect(systemOption).toHaveTextContent('✓');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user