feat: Add comprehensive test suite and misc improvements
- Add frontend unit tests with Vitest for components, hooks, pages, and utilities - Add backend tests for webhooks, notifications, middleware, and edge cases - Add ForgotPassword, NotFound, and ResetPassword pages - Add migration for orphaned staff resources conversion - Add coverage directory to gitignore (generated reports) - Various bug fixes and improvements from previous work 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
800
frontend/src/layouts/__tests__/BusinessLayout.test.tsx
Normal file
800
frontend/src/layouts/__tests__/BusinessLayout.test.tsx
Normal file
@@ -0,0 +1,800 @@
|
||||
/**
|
||||
* Comprehensive unit tests for BusinessLayout component
|
||||
*
|
||||
* Tests all layout functionality including:
|
||||
* - Rendering children content via Outlet
|
||||
* - Sidebar navigation present (desktop and mobile)
|
||||
* - TopBar/header rendering
|
||||
* - Mobile responsive behavior
|
||||
* - User info displayed
|
||||
* - Masquerade banner display
|
||||
* - Trial banner display
|
||||
* - Sandbox banner display
|
||||
* - Quota warning/modal display
|
||||
* - Onboarding wizard display
|
||||
* - Ticket modal display
|
||||
* - Brand color application
|
||||
* - Trial expiration redirect
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { BrowserRouter, MemoryRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import BusinessLayout from '../BusinessLayout';
|
||||
import { Business, User } from '../../types';
|
||||
|
||||
// Mock all child components
|
||||
vi.mock('../../components/Sidebar', () => ({
|
||||
default: ({ business, user, isCollapsed }: any) => (
|
||||
<div data-testid="sidebar">
|
||||
Sidebar - {business.name} - {user.name} - {isCollapsed ? 'Collapsed' : 'Expanded'}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/TopBar', () => ({
|
||||
default: ({ user, isDarkMode, toggleTheme, onMenuClick }: any) => (
|
||||
<div data-testid="topbar">
|
||||
TopBar - {user.name} - {isDarkMode ? 'Dark' : 'Light'}
|
||||
<button onClick={toggleTheme} data-testid="theme-toggle">Toggle Theme</button>
|
||||
<button onClick={onMenuClick} data-testid="menu-button">Menu</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/TrialBanner', () => ({
|
||||
default: ({ business }: any) => (
|
||||
<div data-testid="trial-banner">Trial Banner - {business.name}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/SandboxBanner', () => ({
|
||||
default: ({ isSandbox, onSwitchToLive }: any) => (
|
||||
<div data-testid="sandbox-banner">
|
||||
Sandbox: {isSandbox ? 'Yes' : 'No'}
|
||||
<button onClick={() => onSwitchToLive()} data-testid="sandbox-toggle">Switch to Live</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/QuotaWarningBanner', () => ({
|
||||
default: ({ overages }: any) => (
|
||||
<div data-testid="quota-warning-banner">Quota Warning - {overages.length} overages</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/QuotaOverageModal', () => ({
|
||||
default: ({ overages }: any) => (
|
||||
<div data-testid="quota-overage-modal">Quota Modal - {overages.length} overages</div>
|
||||
),
|
||||
resetQuotaOverageModalDismissal: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/MasqueradeBanner', () => ({
|
||||
default: ({ effectiveUser, originalUser, onStop }: any) => (
|
||||
<div data-testid="masquerade-banner">
|
||||
Masquerading as {effectiveUser.name} (Original: {originalUser.name})
|
||||
<button onClick={onStop} data-testid="stop-masquerade">Stop</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/OnboardingWizard', () => ({
|
||||
default: ({ business, onComplete, onSkip }: any) => (
|
||||
<div data-testid="onboarding-wizard">
|
||||
Onboarding - {business.name}
|
||||
<button onClick={onComplete} data-testid="complete-onboarding">Complete</button>
|
||||
<button onClick={onSkip} data-testid="skip-onboarding">Skip</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/TicketModal', () => ({
|
||||
default: ({ ticket, onClose }: any) => (
|
||||
<div data-testid="ticket-modal">
|
||||
Ticket #{ticket.id}
|
||||
<button onClick={onClose} data-testid="close-ticket">Close</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/FloatingHelpButton', () => ({
|
||||
default: () => <div data-testid="floating-help-button">Help</div>,
|
||||
}));
|
||||
|
||||
// Mock hooks
|
||||
vi.mock('../../hooks/useAuth', () => ({
|
||||
useStopMasquerade: vi.fn(() => ({
|
||||
mutate: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useNotificationWebSocket', () => ({
|
||||
useNotificationWebSocket: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useTickets', () => ({
|
||||
useTicket: vi.fn((id) => ({
|
||||
data: id ? { id, title: 'Test Ticket' } : null,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useScrollToTop', () => ({
|
||||
useScrollToTop: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock SandboxContext
|
||||
const mockToggleSandbox = vi.fn();
|
||||
vi.mock('../../contexts/SandboxContext', () => ({
|
||||
SandboxProvider: ({ children }: any) => <div>{children}</div>,
|
||||
useSandbox: () => ({
|
||||
isSandbox: false,
|
||||
sandboxEnabled: true,
|
||||
toggleSandbox: mockToggleSandbox,
|
||||
isToggling: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock color utilities
|
||||
vi.mock('../../utils/colorUtils', () => ({
|
||||
applyBrandColors: vi.fn(),
|
||||
applyColorPalette: vi.fn(),
|
||||
defaultColorPalette: {},
|
||||
}));
|
||||
|
||||
// Mock react-router-dom's Outlet
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
Outlet: ({ context }: any) => (
|
||||
<div data-testid="outlet">
|
||||
Outlet Content - User: {context?.user?.name}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
describe('BusinessLayout', () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
const mockBusiness: Business = {
|
||||
id: '1',
|
||||
name: 'Test Business',
|
||||
subdomain: 'test',
|
||||
primaryColor: '#2563eb',
|
||||
secondaryColor: '#0ea5e9',
|
||||
whitelabelEnabled: false,
|
||||
plan: 'Professional',
|
||||
status: 'Active',
|
||||
paymentsEnabled: true,
|
||||
requirePaymentMethodToBook: false,
|
||||
cancellationWindowHours: 24,
|
||||
lateCancellationFeePercent: 50,
|
||||
isTrialActive: false,
|
||||
isTrialExpired: false,
|
||||
};
|
||||
|
||||
const mockUser: User = {
|
||||
id: '1',
|
||||
name: 'John Doe',
|
||||
email: 'john@test.com',
|
||||
role: 'owner',
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
business: mockBusiness,
|
||||
user: mockUser,
|
||||
darkMode: false,
|
||||
toggleTheme: vi.fn(),
|
||||
onSignOut: vi.fn(),
|
||||
updateBusiness: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
const renderLayout = (props = {}, initialRoute = '/') => {
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[initialRoute]}>
|
||||
<BusinessLayout {...defaultProps} {...props} />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Basic Rendering', () => {
|
||||
it('should render the layout with all main components', () => {
|
||||
renderLayout();
|
||||
|
||||
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('topbar')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('outlet')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('floating-help-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render children content via Outlet', () => {
|
||||
renderLayout();
|
||||
|
||||
const outlet = screen.getByTestId('outlet');
|
||||
expect(outlet).toBeInTheDocument();
|
||||
expect(outlet).toHaveTextContent('Outlet Content - User: John Doe');
|
||||
});
|
||||
|
||||
it('should pass context to Outlet with user, business, and updateBusiness', () => {
|
||||
renderLayout();
|
||||
|
||||
const outlet = screen.getByTestId('outlet');
|
||||
expect(outlet).toHaveTextContent('User: John Doe');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sidebar Navigation', () => {
|
||||
it('should render sidebar with business and user info', () => {
|
||||
renderLayout();
|
||||
|
||||
const sidebar = screen.getByTestId('sidebar');
|
||||
expect(sidebar).toBeInTheDocument();
|
||||
expect(sidebar).toHaveTextContent('Test Business');
|
||||
expect(sidebar).toHaveTextContent('John Doe');
|
||||
});
|
||||
|
||||
it('should render sidebar in expanded state by default on desktop', () => {
|
||||
renderLayout();
|
||||
|
||||
const sidebar = screen.getByTestId('sidebar');
|
||||
expect(sidebar).toHaveTextContent('Expanded');
|
||||
});
|
||||
|
||||
it('should hide mobile menu by default', () => {
|
||||
renderLayout();
|
||||
|
||||
// Mobile menu has translate-x-full class when closed
|
||||
const container = screen.getByTestId('sidebar').closest('div');
|
||||
// The visible sidebar on desktop should exist
|
||||
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should open mobile menu when menu button is clicked', () => {
|
||||
renderLayout();
|
||||
|
||||
const menuButton = screen.getByTestId('menu-button');
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
// After clicking, mobile menu should be visible
|
||||
// Both mobile and desktop sidebars exist in DOM
|
||||
const sidebars = screen.getAllByTestId('sidebar');
|
||||
expect(sidebars.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Header/TopBar', () => {
|
||||
it('should render TopBar with user info', () => {
|
||||
renderLayout();
|
||||
|
||||
const topbar = screen.getByTestId('topbar');
|
||||
expect(topbar).toBeInTheDocument();
|
||||
expect(topbar).toHaveTextContent('John Doe');
|
||||
});
|
||||
|
||||
it('should display dark mode state in TopBar', () => {
|
||||
renderLayout({ darkMode: true });
|
||||
|
||||
const topbar = screen.getByTestId('topbar');
|
||||
expect(topbar).toHaveTextContent('Dark');
|
||||
});
|
||||
|
||||
it('should display light mode state in TopBar', () => {
|
||||
renderLayout({ darkMode: false });
|
||||
|
||||
const topbar = screen.getByTestId('topbar');
|
||||
expect(topbar).toHaveTextContent('Light');
|
||||
});
|
||||
|
||||
it('should call toggleTheme when theme toggle is clicked', () => {
|
||||
const toggleTheme = vi.fn();
|
||||
renderLayout({ toggleTheme });
|
||||
|
||||
const themeToggle = screen.getByTestId('theme-toggle');
|
||||
fireEvent.click(themeToggle);
|
||||
|
||||
expect(toggleTheme).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mobile Responsive Behavior', () => {
|
||||
it('should toggle mobile menu when menu button is clicked', () => {
|
||||
renderLayout();
|
||||
|
||||
const menuButton = screen.getByTestId('menu-button');
|
||||
|
||||
// Click to open
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
// Both mobile and desktop sidebars should exist
|
||||
expect(screen.getAllByTestId('sidebar').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should render mobile and desktop sidebars separately', () => {
|
||||
renderLayout();
|
||||
|
||||
// Desktop sidebar should be visible
|
||||
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Info Display', () => {
|
||||
it('should display user name in TopBar', () => {
|
||||
renderLayout();
|
||||
|
||||
const topbar = screen.getByTestId('topbar');
|
||||
expect(topbar).toHaveTextContent('John Doe');
|
||||
});
|
||||
|
||||
it('should display user name in Sidebar', () => {
|
||||
renderLayout();
|
||||
|
||||
const sidebar = screen.getByTestId('sidebar');
|
||||
expect(sidebar).toHaveTextContent('John Doe');
|
||||
});
|
||||
|
||||
it('should display different user roles correctly', () => {
|
||||
const staffUser: User = {
|
||||
id: '2',
|
||||
name: 'Jane Smith',
|
||||
email: 'jane@test.com',
|
||||
role: 'staff',
|
||||
};
|
||||
|
||||
renderLayout({ user: staffUser });
|
||||
|
||||
expect(screen.getByTestId('sidebar')).toHaveTextContent('Jane Smith');
|
||||
expect(screen.getByTestId('topbar')).toHaveTextContent('Jane Smith');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Masquerade Banner', () => {
|
||||
it('should not display masquerade banner when not masquerading', () => {
|
||||
renderLayout();
|
||||
|
||||
expect(screen.queryByTestId('masquerade-banner')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display masquerade banner when masquerading', () => {
|
||||
// Simulate masquerade stack in localStorage
|
||||
const masqueradeStack = [
|
||||
{
|
||||
user_id: '999',
|
||||
username: 'admin',
|
||||
role: 'superuser',
|
||||
},
|
||||
];
|
||||
localStorage.setItem('masquerade_stack', JSON.stringify(masqueradeStack));
|
||||
|
||||
renderLayout();
|
||||
|
||||
expect(screen.getByTestId('masquerade-banner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call stop masquerade when stop button is clicked', async () => {
|
||||
const { useStopMasquerade } = await import('../../hooks/useAuth');
|
||||
const mockMutate = vi.fn();
|
||||
(useStopMasquerade as any).mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
});
|
||||
|
||||
const masqueradeStack = [
|
||||
{
|
||||
user_id: '999',
|
||||
username: 'admin',
|
||||
role: 'superuser',
|
||||
},
|
||||
];
|
||||
localStorage.setItem('masquerade_stack', JSON.stringify(masqueradeStack));
|
||||
|
||||
renderLayout();
|
||||
|
||||
const stopButton = screen.getByTestId('stop-masquerade');
|
||||
fireEvent.click(stopButton);
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Trial Banner', () => {
|
||||
it('should display trial banner when trial is active and payments not enabled', () => {
|
||||
const trialBusiness = {
|
||||
...mockBusiness,
|
||||
isTrialActive: true,
|
||||
paymentsEnabled: false,
|
||||
plan: 'Professional',
|
||||
};
|
||||
|
||||
renderLayout({ business: trialBusiness });
|
||||
|
||||
expect(screen.getByTestId('trial-banner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not display trial banner when trial is not active', () => {
|
||||
const activeBusiness = {
|
||||
...mockBusiness,
|
||||
isTrialActive: false,
|
||||
paymentsEnabled: false,
|
||||
};
|
||||
|
||||
renderLayout({ business: activeBusiness });
|
||||
|
||||
expect(screen.queryByTestId('trial-banner')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not display trial banner when payments are enabled', () => {
|
||||
const paidBusiness = {
|
||||
...mockBusiness,
|
||||
isTrialActive: true,
|
||||
paymentsEnabled: true,
|
||||
};
|
||||
|
||||
renderLayout({ business: paidBusiness });
|
||||
|
||||
expect(screen.queryByTestId('trial-banner')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not display trial banner for Free plan even if trial active', () => {
|
||||
const freeBusiness = {
|
||||
...mockBusiness,
|
||||
isTrialActive: true,
|
||||
paymentsEnabled: false,
|
||||
plan: 'Free' as const,
|
||||
};
|
||||
|
||||
renderLayout({ business: freeBusiness });
|
||||
|
||||
expect(screen.queryByTestId('trial-banner')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sandbox Banner', () => {
|
||||
it('should display sandbox banner', () => {
|
||||
renderLayout();
|
||||
|
||||
expect(screen.getByTestId('sandbox-banner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call toggleSandbox when switch button is clicked', () => {
|
||||
renderLayout();
|
||||
|
||||
const toggleButton = screen.getByTestId('sandbox-toggle');
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
expect(mockToggleSandbox).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Quota Warning and Modal', () => {
|
||||
it('should display quota warning banner when user has overages', () => {
|
||||
const userWithOverages: User = {
|
||||
...mockUser,
|
||||
quota_overages: [
|
||||
{
|
||||
id: 1,
|
||||
quota_type: 'resources',
|
||||
display_name: 'Resources',
|
||||
current_usage: 15,
|
||||
allowed_limit: 10,
|
||||
overage_amount: 5,
|
||||
days_remaining: 7,
|
||||
grace_period_ends_at: '2025-12-14',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
renderLayout({ user: userWithOverages });
|
||||
|
||||
expect(screen.getByTestId('quota-warning-banner')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('quota-warning-banner')).toHaveTextContent('1 overages');
|
||||
});
|
||||
|
||||
it('should display quota overage modal when user has overages', () => {
|
||||
const userWithOverages: User = {
|
||||
...mockUser,
|
||||
quota_overages: [
|
||||
{
|
||||
id: 1,
|
||||
quota_type: 'resources',
|
||||
display_name: 'Resources',
|
||||
current_usage: 15,
|
||||
allowed_limit: 10,
|
||||
overage_amount: 5,
|
||||
days_remaining: 7,
|
||||
grace_period_ends_at: '2025-12-14',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
renderLayout({ user: userWithOverages });
|
||||
|
||||
expect(screen.getByTestId('quota-overage-modal')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('quota-overage-modal')).toHaveTextContent('1 overages');
|
||||
});
|
||||
|
||||
it('should not display quota components when user has no overages', () => {
|
||||
renderLayout();
|
||||
|
||||
expect(screen.queryByTestId('quota-warning-banner')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('quota-overage-modal')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Onboarding Wizard', () => {
|
||||
it('should not display onboarding wizard by default', () => {
|
||||
renderLayout();
|
||||
|
||||
expect(screen.queryByTestId('onboarding-wizard')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display onboarding wizard when returning from Stripe Connect', () => {
|
||||
renderLayout({}, '/?onboarding=true');
|
||||
|
||||
expect(screen.getByTestId('onboarding-wizard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call updateBusiness when onboarding is completed', () => {
|
||||
const updateBusiness = vi.fn();
|
||||
renderLayout({ updateBusiness }, '/?onboarding=true');
|
||||
|
||||
const completeButton = screen.getByTestId('complete-onboarding');
|
||||
fireEvent.click(completeButton);
|
||||
|
||||
expect(updateBusiness).toHaveBeenCalledWith({ initialSetupComplete: true });
|
||||
});
|
||||
|
||||
it('should disable payments when onboarding is skipped', () => {
|
||||
const updateBusiness = vi.fn();
|
||||
renderLayout({ updateBusiness }, '/?onboarding=true');
|
||||
|
||||
const skipButton = screen.getByTestId('skip-onboarding');
|
||||
fireEvent.click(skipButton);
|
||||
|
||||
expect(updateBusiness).toHaveBeenCalledWith({ paymentsEnabled: false });
|
||||
});
|
||||
|
||||
it('should hide onboarding wizard after completion', () => {
|
||||
renderLayout({}, '/?onboarding=true');
|
||||
|
||||
expect(screen.getByTestId('onboarding-wizard')).toBeInTheDocument();
|
||||
|
||||
const completeButton = screen.getByTestId('complete-onboarding');
|
||||
fireEvent.click(completeButton);
|
||||
|
||||
expect(screen.queryByTestId('onboarding-wizard')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ticket Modal', () => {
|
||||
it('should not display ticket modal by default', () => {
|
||||
renderLayout();
|
||||
|
||||
expect(screen.queryByTestId('ticket-modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Note: Ticket modal opening requires TopBar to call onTicketClick prop
|
||||
// This would require a more complex mock of TopBar component
|
||||
});
|
||||
|
||||
describe('Brand Colors', () => {
|
||||
it('should apply brand colors on mount', async () => {
|
||||
const { applyBrandColors } = await import('../../utils/colorUtils');
|
||||
|
||||
renderLayout();
|
||||
|
||||
expect(applyBrandColors).toHaveBeenCalledWith('#2563eb', '#0ea5e9');
|
||||
});
|
||||
|
||||
it('should apply default secondary color if not provided', async () => {
|
||||
const { applyBrandColors } = await import('../../utils/colorUtils');
|
||||
|
||||
const businessWithoutSecondary = {
|
||||
...mockBusiness,
|
||||
secondaryColor: undefined,
|
||||
};
|
||||
|
||||
renderLayout({ business: businessWithoutSecondary });
|
||||
|
||||
expect(applyBrandColors).toHaveBeenCalledWith('#2563eb', '#2563eb');
|
||||
});
|
||||
|
||||
it('should reset colors on unmount', async () => {
|
||||
const { applyColorPalette, defaultColorPalette } = await import('../../utils/colorUtils');
|
||||
|
||||
const { unmount } = renderLayout();
|
||||
unmount();
|
||||
|
||||
expect(applyColorPalette).toHaveBeenCalledWith(defaultColorPalette);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Layout Structure', () => {
|
||||
it('should have flex layout structure', () => {
|
||||
const { container } = renderLayout();
|
||||
|
||||
const mainDiv = container.firstChild;
|
||||
expect(mainDiv).toHaveClass('flex', 'h-full');
|
||||
});
|
||||
|
||||
it('should have main content area with overflow-auto', () => {
|
||||
renderLayout();
|
||||
|
||||
// The main element should exist
|
||||
const outlet = screen.getByTestId('outlet');
|
||||
const mainElement = outlet.closest('main');
|
||||
expect(mainElement).toBeInTheDocument();
|
||||
expect(mainElement).toHaveClass('flex-1', 'overflow-auto');
|
||||
});
|
||||
|
||||
it('should render floating help button', () => {
|
||||
renderLayout();
|
||||
|
||||
expect(screen.getByTestId('floating-help-button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle user with minimal properties', () => {
|
||||
const minimalUser: User = {
|
||||
id: '1',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
role: 'customer',
|
||||
};
|
||||
|
||||
renderLayout({ user: minimalUser });
|
||||
|
||||
expect(screen.getByTestId('sidebar')).toHaveTextContent('Test User');
|
||||
expect(screen.getByTestId('topbar')).toHaveTextContent('Test User');
|
||||
});
|
||||
|
||||
it('should handle business with minimal properties', () => {
|
||||
const minimalBusiness: Business = {
|
||||
id: '1',
|
||||
name: 'Minimal Business',
|
||||
subdomain: 'minimal',
|
||||
primaryColor: '#000000',
|
||||
secondaryColor: '#ffffff',
|
||||
whitelabelEnabled: false,
|
||||
paymentsEnabled: false,
|
||||
requirePaymentMethodToBook: false,
|
||||
cancellationWindowHours: 0,
|
||||
lateCancellationFeePercent: 0,
|
||||
};
|
||||
|
||||
renderLayout({ business: minimalBusiness });
|
||||
|
||||
expect(screen.getByTestId('sidebar')).toHaveTextContent('Minimal Business');
|
||||
});
|
||||
|
||||
it('should handle invalid masquerade stack in localStorage', () => {
|
||||
localStorage.setItem('masquerade_stack', 'invalid-json');
|
||||
|
||||
expect(() => renderLayout()).not.toThrow();
|
||||
expect(screen.queryByTestId('masquerade-banner')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle multiple quota overages', () => {
|
||||
const userWithMultipleOverages: User = {
|
||||
...mockUser,
|
||||
quota_overages: [
|
||||
{
|
||||
id: 1,
|
||||
quota_type: 'resources',
|
||||
display_name: 'Resources',
|
||||
current_usage: 15,
|
||||
allowed_limit: 10,
|
||||
overage_amount: 5,
|
||||
days_remaining: 7,
|
||||
grace_period_ends_at: '2025-12-14',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
quota_type: 'customers',
|
||||
display_name: 'Customers',
|
||||
current_usage: 550,
|
||||
allowed_limit: 500,
|
||||
overage_amount: 50,
|
||||
days_remaining: 7,
|
||||
grace_period_ends_at: '2025-12-14',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
renderLayout({ user: userWithMultipleOverages });
|
||||
|
||||
expect(screen.getByTestId('quota-warning-banner')).toHaveTextContent('2 overages');
|
||||
expect(screen.getByTestId('quota-overage-modal')).toHaveTextContent('2 overages');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have main content area with tabIndex for focus', () => {
|
||||
renderLayout();
|
||||
|
||||
const outlet = screen.getByTestId('outlet');
|
||||
const mainElement = outlet.closest('main');
|
||||
expect(mainElement).toHaveAttribute('tabIndex', '-1');
|
||||
});
|
||||
|
||||
it('should have focus:outline-none on main content', () => {
|
||||
renderLayout();
|
||||
|
||||
const outlet = screen.getByTestId('outlet');
|
||||
const mainElement = outlet.closest('main');
|
||||
expect(mainElement).toHaveClass('focus:outline-none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Component Integration', () => {
|
||||
it('should render all components together without crashing', () => {
|
||||
const userWithOverages: User = {
|
||||
...mockUser,
|
||||
quota_overages: [
|
||||
{
|
||||
id: 1,
|
||||
quota_type: 'resources',
|
||||
display_name: 'Resources',
|
||||
current_usage: 15,
|
||||
allowed_limit: 10,
|
||||
overage_amount: 5,
|
||||
days_remaining: 7,
|
||||
grace_period_ends_at: '2025-12-14',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const trialBusiness = {
|
||||
...mockBusiness,
|
||||
isTrialActive: true,
|
||||
paymentsEnabled: false,
|
||||
};
|
||||
|
||||
localStorage.setItem(
|
||||
'masquerade_stack',
|
||||
JSON.stringify([
|
||||
{
|
||||
user_id: '999',
|
||||
username: 'admin',
|
||||
role: 'superuser',
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
renderLayout({ user: userWithOverages, business: trialBusiness }, '/?onboarding=true')
|
||||
).not.toThrow();
|
||||
|
||||
// All banners and components should be present
|
||||
expect(screen.getByTestId('masquerade-banner')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('quota-warning-banner')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('quota-overage-modal')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('sandbox-banner')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('trial-banner')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('onboarding-wizard')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('topbar')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('outlet')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('floating-help-button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
972
frontend/src/layouts/__tests__/CustomerLayout.test.tsx
Normal file
972
frontend/src/layouts/__tests__/CustomerLayout.test.tsx
Normal file
@@ -0,0 +1,972 @@
|
||||
/**
|
||||
* Unit tests for CustomerLayout component
|
||||
*
|
||||
* Tests all layout functionality including:
|
||||
* - Rendering children content via Outlet
|
||||
* - Customer navigation links (Dashboard, Book, Billing, Support)
|
||||
* - Header rendering with business branding
|
||||
* - Logo/branding display
|
||||
* - Dark mode toggle
|
||||
* - User profile dropdown
|
||||
* - Notification dropdown
|
||||
* - Masquerade banner
|
||||
* - Theme toggling
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react';
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom';
|
||||
import React from 'react';
|
||||
import CustomerLayout from '../CustomerLayout';
|
||||
import { User, Business } from '../../types';
|
||||
|
||||
// Mock the hooks and components
|
||||
vi.mock('../../hooks/useAuth', () => ({
|
||||
useStopMasquerade: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useScrollToTop', () => ({
|
||||
useScrollToTop: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/MasqueradeBanner', () => ({
|
||||
default: ({ effectiveUser, originalUser, onStop }: any) => (
|
||||
<div data-testid="masquerade-banner">
|
||||
Masquerading as {effectiveUser.name}
|
||||
<button onClick={onStop}>Stop</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/UserProfileDropdown', () => ({
|
||||
default: ({ user, variant }: any) => (
|
||||
<div data-testid="user-profile-dropdown" data-variant={variant}>
|
||||
{user.name}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/NotificationDropdown', () => ({
|
||||
default: ({ variant, onTicketClick }: any) => (
|
||||
<div data-testid="notification-dropdown" data-variant={variant}>
|
||||
<button onClick={() => onTicketClick?.('ticket-123')}>Notification</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock('lucide-react', () => ({
|
||||
LayoutDashboard: ({ size }: { size: number }) => (
|
||||
<svg data-testid="layout-dashboard-icon" width={size} height={size} />
|
||||
),
|
||||
CalendarPlus: ({ size }: { size: number }) => (
|
||||
<svg data-testid="calendar-plus-icon" width={size} height={size} />
|
||||
),
|
||||
CreditCard: ({ size }: { size: number }) => (
|
||||
<svg data-testid="credit-card-icon" width={size} height={size} />
|
||||
),
|
||||
HelpCircle: ({ size }: { size: number }) => (
|
||||
<svg data-testid="help-circle-icon" width={size} height={size} />
|
||||
),
|
||||
Sun: ({ size }: { size: number }) => <svg data-testid="sun-icon" width={size} height={size} />,
|
||||
Moon: ({ size }: { size: number }) => <svg data-testid="moon-icon" width={size} height={size} />,
|
||||
}));
|
||||
|
||||
describe('CustomerLayout', () => {
|
||||
const mockToggleTheme = vi.fn();
|
||||
|
||||
const mockUser: User = {
|
||||
id: '1',
|
||||
name: 'John Customer',
|
||||
email: 'john@customer.com',
|
||||
role: 'customer',
|
||||
};
|
||||
|
||||
const mockBusiness: Business = {
|
||||
id: '1',
|
||||
name: 'Acme Corporation',
|
||||
subdomain: 'acme',
|
||||
primaryColor: '#3b82f6',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
const renderWithRouter = (
|
||||
ui: React.ReactElement,
|
||||
{ route = '/' }: { route?: string } = {}
|
||||
) => {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[route]}>
|
||||
<Routes>
|
||||
<Route path="*" element={ui}>
|
||||
<Route
|
||||
index
|
||||
element={<div data-testid="outlet-content">Dashboard Content</div>}
|
||||
/>
|
||||
<Route
|
||||
path="book"
|
||||
element={<div data-testid="outlet-content">Book Content</div>}
|
||||
/>
|
||||
<Route
|
||||
path="payments"
|
||||
element={<div data-testid="outlet-content">Payments Content</div>}
|
||||
/>
|
||||
<Route
|
||||
path="support"
|
||||
element={<div data-testid="outlet-content">Support Content</div>}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders the layout with correct structure', () => {
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
// Check for header
|
||||
expect(screen.getByRole('banner')).toBeInTheDocument();
|
||||
|
||||
// Check for main content area
|
||||
expect(screen.getByRole('main')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has proper layout classes', () => {
|
||||
const { container } = renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
const layout = container.querySelector('.h-full.flex.flex-col');
|
||||
expect(layout).toBeInTheDocument();
|
||||
expect(layout).toHaveClass('bg-gray-50', 'dark:bg-gray-900');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Children Content (Outlet)', () => {
|
||||
it('renders children content via Outlet', () => {
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('outlet-content')).toBeInTheDocument();
|
||||
expect(screen.getByText('Dashboard Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders different routes correctly', () => {
|
||||
const { unmount } = renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>,
|
||||
{ route: '/book' }
|
||||
);
|
||||
|
||||
expect(screen.getByText('Book Content')).toBeInTheDocument();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Header', () => {
|
||||
it('renders header with business primary color', () => {
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
const header = screen.getByRole('banner');
|
||||
expect(header).toHaveStyle({ backgroundColor: '#3b82f6' });
|
||||
});
|
||||
|
||||
it('has proper header styling classes', () => {
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
const header = screen.getByRole('banner');
|
||||
expect(header).toHaveClass('text-white', 'shadow-md');
|
||||
});
|
||||
|
||||
it('has proper header height', () => {
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
const headerInner = screen.getByRole('banner').querySelector('.h-16');
|
||||
expect(headerInner).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Branding/Logo', () => {
|
||||
it('displays business name', () => {
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Acme Corporation')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays business logo with first letter', () => {
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
const logo = screen.getByText('A');
|
||||
expect(logo).toBeInTheDocument();
|
||||
expect(logo).toHaveClass('font-bold', 'text-lg');
|
||||
expect(logo).toHaveStyle({ color: '#3b82f6' });
|
||||
});
|
||||
|
||||
it('logo has correct styling', () => {
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
const logo = screen.getByText('A').closest('div');
|
||||
expect(logo).toHaveClass('w-8', 'h-8', 'bg-white', 'rounded-lg');
|
||||
});
|
||||
|
||||
it('displays different business names correctly', () => {
|
||||
const differentBusiness: Business = {
|
||||
id: '2',
|
||||
name: 'XYZ Services',
|
||||
subdomain: 'xyz',
|
||||
primaryColor: '#ef4444',
|
||||
};
|
||||
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={differentBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('XYZ Services')).toBeInTheDocument();
|
||||
expect(screen.getByText('X')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles single character business names', () => {
|
||||
const singleCharBusiness: Business = {
|
||||
id: '3',
|
||||
name: 'Q',
|
||||
subdomain: 'q',
|
||||
primaryColor: '#10b981',
|
||||
};
|
||||
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={singleCharBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
// Both the logo and business name display 'Q'
|
||||
const qElements = screen.getAllByText('Q');
|
||||
expect(qElements).toHaveLength(2); // Logo and business name
|
||||
});
|
||||
});
|
||||
|
||||
describe('Customer Navigation', () => {
|
||||
it('renders all navigation links', () => {
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('link', { name: /dashboard/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /book appointment/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /billing/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /support/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigation links have correct paths', () => {
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('href', '/');
|
||||
expect(screen.getByRole('link', { name: /book appointment/i })).toHaveAttribute(
|
||||
'href',
|
||||
'/book'
|
||||
);
|
||||
expect(screen.getByRole('link', { name: /billing/i })).toHaveAttribute(
|
||||
'href',
|
||||
'/payments'
|
||||
);
|
||||
expect(screen.getByRole('link', { name: /support/i })).toHaveAttribute('href', '/support');
|
||||
});
|
||||
|
||||
it('navigation links have icons', () => {
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('layout-dashboard-icon')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('calendar-plus-icon')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('credit-card-icon')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('help-circle-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigation is hidden on mobile (md breakpoint)', () => {
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
const nav = screen.getByRole('navigation');
|
||||
expect(nav).toHaveClass('hidden', 'md:flex');
|
||||
});
|
||||
|
||||
it('navigation links have proper styling', () => {
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
const dashboardLink = screen.getByRole('link', { name: /dashboard/i });
|
||||
expect(dashboardLink).toHaveClass(
|
||||
'text-sm',
|
||||
'font-medium',
|
||||
'text-white/80',
|
||||
'hover:text-white'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dark Mode Toggle', () => {
|
||||
it('renders dark mode toggle button', () => {
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
const toggleButton = screen.getByRole('button', {
|
||||
name: /switch to dark mode/i,
|
||||
});
|
||||
expect(toggleButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays Moon icon when dark mode is off', () => {
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('moon-icon')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('sun-icon')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays Sun icon when dark mode is on', () => {
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={true}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('sun-icon')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('moon-icon')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls toggleTheme when clicked', () => {
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
const toggleButton = screen.getByRole('button', {
|
||||
name: /switch to dark mode/i,
|
||||
});
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
expect(mockToggleTheme).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('has proper aria-label for dark mode off', () => {
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
const toggleButton = screen.getByRole('button', {
|
||||
name: 'Switch to dark mode',
|
||||
});
|
||||
expect(toggleButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has proper aria-label for dark mode on', () => {
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={true}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
const toggleButton = screen.getByRole('button', {
|
||||
name: 'Switch to light mode',
|
||||
});
|
||||
expect(toggleButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles multiple times correctly', () => {
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
const toggleButton = screen.getByRole('button', {
|
||||
name: /switch to dark mode/i,
|
||||
});
|
||||
|
||||
fireEvent.click(toggleButton);
|
||||
fireEvent.click(toggleButton);
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
expect(mockToggleTheme).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Profile Dropdown', () => {
|
||||
it('renders user profile dropdown', () => {
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('user-profile-dropdown')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes user to profile dropdown', () => {
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdown = screen.getByTestId('user-profile-dropdown');
|
||||
expect(dropdown).toHaveTextContent('John Customer');
|
||||
});
|
||||
|
||||
it('uses light variant for profile dropdown', () => {
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdown = screen.getByTestId('user-profile-dropdown');
|
||||
expect(dropdown).toHaveAttribute('data-variant', 'light');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Notification Dropdown', () => {
|
||||
it('renders notification dropdown', () => {
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('notification-dropdown')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses light variant for notification dropdown', () => {
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdown = screen.getByTestId('notification-dropdown');
|
||||
expect(dropdown).toHaveAttribute('data-variant', 'light');
|
||||
});
|
||||
|
||||
it('handles ticket notification click', () => {
|
||||
const { container } = renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdown = screen.getByTestId('notification-dropdown');
|
||||
const notificationButton = within(dropdown).getByText('Notification');
|
||||
|
||||
fireEvent.click(notificationButton);
|
||||
|
||||
// Should navigate to support page - we can't easily test navigation in this setup
|
||||
// but the component sets up the handler
|
||||
expect(dropdown).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Masquerade Banner', () => {
|
||||
it('does not show masquerade banner when no masquerade data', () => {
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('masquerade-banner')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows masquerade banner when masquerade data exists', () => {
|
||||
const masqueradeStack = [
|
||||
{
|
||||
user_id: '2',
|
||||
username: 'admin',
|
||||
role: 'superuser',
|
||||
},
|
||||
];
|
||||
|
||||
localStorage.setItem('masquerade_stack', JSON.stringify(masqueradeStack));
|
||||
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('masquerade-banner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays correct user in masquerade banner', () => {
|
||||
const masqueradeStack = [
|
||||
{
|
||||
user_id: '2',
|
||||
username: 'admin',
|
||||
role: 'superuser',
|
||||
},
|
||||
];
|
||||
|
||||
localStorage.setItem('masquerade_stack', JSON.stringify(masqueradeStack));
|
||||
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/masquerading as john customer/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles invalid masquerade stack JSON gracefully', () => {
|
||||
localStorage.setItem('masquerade_stack', 'invalid json');
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('masquerade-banner')).not.toBeInTheDocument();
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Main Content Area', () => {
|
||||
it('renders main content with proper classes', () => {
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
const main = screen.getByRole('main');
|
||||
expect(main).toHaveClass('flex-1', 'overflow-y-auto');
|
||||
});
|
||||
|
||||
it('has container with proper padding', () => {
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
const main = screen.getByRole('main');
|
||||
const container = main.querySelector('.container');
|
||||
expect(container).toBeInTheDocument();
|
||||
expect(container).toHaveClass('mx-auto', 'px-4', 'sm:px-6', 'lg:px-8', 'py-8');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Responsive Design', () => {
|
||||
it('has responsive header padding', () => {
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
const headerContainer = screen
|
||||
.getByRole('banner')
|
||||
.querySelector('.container');
|
||||
expect(headerContainer).toHaveClass('px-4', 'sm:px-6', 'lg:px-8');
|
||||
});
|
||||
|
||||
it('has responsive navigation visibility', () => {
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
const nav = screen.getByRole('navigation');
|
||||
expect(nav).toHaveClass('hidden', 'md:flex');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration', () => {
|
||||
it('renders all components together', () => {
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
// Header elements
|
||||
expect(screen.getByRole('banner')).toBeInTheDocument();
|
||||
expect(screen.getByText('Acme Corporation')).toBeInTheDocument();
|
||||
|
||||
// Navigation
|
||||
expect(screen.getByRole('link', { name: /dashboard/i })).toBeInTheDocument();
|
||||
|
||||
// User interactions
|
||||
expect(screen.getByTestId('user-profile-dropdown')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('notification-dropdown')).toBeInTheDocument();
|
||||
|
||||
// Content
|
||||
expect(screen.getByRole('main')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('outlet-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles all props correctly', () => {
|
||||
const customBusiness: Business = {
|
||||
id: '99',
|
||||
name: 'Custom Business',
|
||||
subdomain: 'custom',
|
||||
primaryColor: '#8b5cf6',
|
||||
};
|
||||
|
||||
const customUser: User = {
|
||||
id: '99',
|
||||
name: 'Custom User',
|
||||
email: 'custom@test.com',
|
||||
role: 'customer',
|
||||
};
|
||||
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={customBusiness}
|
||||
user={customUser}
|
||||
darkMode={true}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Custom Business')).toBeInTheDocument();
|
||||
expect(screen.getByText('Custom User')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('sun-icon')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles business with special characters in name', () => {
|
||||
const specialBusiness: Business = {
|
||||
id: '1',
|
||||
name: "O'Reilly & Sons",
|
||||
subdomain: 'oreilly',
|
||||
primaryColor: '#3b82f6',
|
||||
};
|
||||
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={specialBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("O'Reilly & Sons")).toBeInTheDocument();
|
||||
expect(screen.getByText('O')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles very long business names', () => {
|
||||
const longNameBusiness: Business = {
|
||||
id: '1',
|
||||
name: 'Very Long Business Name That Should Still Display Properly',
|
||||
subdomain: 'longname',
|
||||
primaryColor: '#3b82f6',
|
||||
};
|
||||
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={longNameBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText('Very Long Business Name That Should Still Display Properly')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles different primary colors', () => {
|
||||
const colorVariations = ['#000000', '#ffffff', '#ff0000', '#00ff00', '#0000ff'];
|
||||
|
||||
colorVariations.forEach((color) => {
|
||||
const coloredBusiness: Business = {
|
||||
id: '1',
|
||||
name: 'Test Business',
|
||||
subdomain: 'test',
|
||||
primaryColor: color,
|
||||
};
|
||||
|
||||
const { unmount } = renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={coloredBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
const header = screen.getByRole('banner');
|
||||
expect(header).toHaveStyle({ backgroundColor: color });
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('has semantic header element', () => {
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
const header = screen.getByRole('banner');
|
||||
expect(header.tagName).toBe('HEADER');
|
||||
});
|
||||
|
||||
it('has semantic main element', () => {
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
const main = screen.getByRole('main');
|
||||
expect(main.tagName).toBe('MAIN');
|
||||
});
|
||||
|
||||
it('has semantic nav element', () => {
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
const nav = screen.getByRole('navigation');
|
||||
expect(nav.tagName).toBe('NAV');
|
||||
});
|
||||
|
||||
it('navigation links have accessible text', () => {
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
const dashboardLink = screen.getByRole('link', { name: /dashboard/i });
|
||||
expect(dashboardLink).toHaveAccessibleName();
|
||||
});
|
||||
|
||||
it('dark mode toggle has aria-label', () => {
|
||||
renderWithRouter(
|
||||
<CustomerLayout
|
||||
business={mockBusiness}
|
||||
user={mockUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
const toggleButton = screen.getByRole('button', {
|
||||
name: /switch to dark mode/i,
|
||||
});
|
||||
expect(toggleButton).toHaveAttribute('aria-label');
|
||||
});
|
||||
});
|
||||
});
|
||||
759
frontend/src/layouts/__tests__/ManagerLayout.test.tsx
Normal file
759
frontend/src/layouts/__tests__/ManagerLayout.test.tsx
Normal file
@@ -0,0 +1,759 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import ManagerLayout from '../ManagerLayout';
|
||||
import { User } from '../../types';
|
||||
|
||||
// Mock react-router-dom's Outlet
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
Outlet: () => <div data-testid="outlet-content">Page Content</div>,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock PlatformSidebar component
|
||||
vi.mock('../../components/PlatformSidebar', () => ({
|
||||
default: ({ user, isCollapsed, toggleCollapse, onSignOut }: any) => (
|
||||
<div data-testid="platform-sidebar">
|
||||
<div data-testid="sidebar-user">{user.name}</div>
|
||||
<div data-testid="sidebar-role">{user.role}</div>
|
||||
<button onClick={toggleCollapse} data-testid="sidebar-collapse">
|
||||
{isCollapsed ? 'Expand' : 'Collapse'}
|
||||
</button>
|
||||
<button onClick={onSignOut} data-testid="sidebar-signout">
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock('lucide-react', () => ({
|
||||
Moon: ({ size }: { size: number }) => <svg data-testid="moon-icon" width={size} height={size} />,
|
||||
Sun: ({ size }: { size: number }) => <svg data-testid="sun-icon" width={size} height={size} />,
|
||||
Bell: ({ size }: { size: number }) => <svg data-testid="bell-icon" width={size} height={size} />,
|
||||
Globe: ({ size }: { size: number }) => <svg data-testid="globe-icon" width={size} height={size} />,
|
||||
Menu: ({ size }: { size: number }) => <svg data-testid="menu-icon" width={size} height={size} />,
|
||||
}));
|
||||
|
||||
// Mock useScrollToTop hook
|
||||
vi.mock('../../hooks/useScrollToTop', () => ({
|
||||
useScrollToTop: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('ManagerLayout', () => {
|
||||
const mockToggleTheme = vi.fn();
|
||||
const mockOnSignOut = vi.fn();
|
||||
|
||||
const managerUser: User = {
|
||||
id: '1',
|
||||
name: 'John Manager',
|
||||
email: 'manager@platform.com',
|
||||
role: 'platform_manager',
|
||||
};
|
||||
|
||||
const superUser: User = {
|
||||
id: '2',
|
||||
name: 'Admin User',
|
||||
email: 'admin@platform.com',
|
||||
role: 'superuser',
|
||||
};
|
||||
|
||||
const supportUser: User = {
|
||||
id: '3',
|
||||
name: 'Support User',
|
||||
email: 'support@platform.com',
|
||||
role: 'platform_support',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderLayout = (user: User = managerUser, darkMode: boolean = false) => {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<ManagerLayout
|
||||
user={user}
|
||||
darkMode={darkMode}
|
||||
toggleTheme={mockToggleTheme}
|
||||
onSignOut={mockOnSignOut}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Rendering Children Content', () => {
|
||||
it('renders the main layout structure', () => {
|
||||
renderLayout();
|
||||
|
||||
// Check that main container exists
|
||||
const mainContainer = screen.getByRole('main');
|
||||
expect(mainContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Outlet for nested routes', () => {
|
||||
renderLayout();
|
||||
|
||||
// Check that Outlet content is rendered
|
||||
expect(screen.getByTestId('outlet-content')).toBeInTheDocument();
|
||||
expect(screen.getByText('Page Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the header with correct elements', () => {
|
||||
renderLayout();
|
||||
|
||||
// Check header exists with proper structure
|
||||
const header = screen.getByRole('banner');
|
||||
expect(header).toBeInTheDocument();
|
||||
expect(header).toHaveClass('bg-white', 'dark:bg-gray-800');
|
||||
});
|
||||
|
||||
it('renders main content area with correct styling', () => {
|
||||
renderLayout();
|
||||
|
||||
const mainContent = screen.getByRole('main');
|
||||
expect(mainContent).toHaveClass('flex-1', 'overflow-auto', 'bg-gray-50', 'dark:bg-gray-900');
|
||||
});
|
||||
|
||||
it('applies dark mode classes correctly', () => {
|
||||
renderLayout(managerUser, true);
|
||||
|
||||
const mainContent = screen.getByRole('main');
|
||||
expect(mainContent).toHaveClass('dark:bg-gray-900');
|
||||
});
|
||||
|
||||
it('applies light mode classes correctly', () => {
|
||||
renderLayout(managerUser, false);
|
||||
|
||||
const mainContent = screen.getByRole('main');
|
||||
expect(mainContent).toHaveClass('bg-gray-50');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Manager-Specific Navigation', () => {
|
||||
it('renders PlatformSidebar with correct props', () => {
|
||||
renderLayout();
|
||||
|
||||
const sidebars = screen.getAllByTestId('platform-sidebar');
|
||||
expect(sidebars.length).toBe(2); // Mobile and desktop
|
||||
|
||||
// Check user data is passed correctly (using first sidebar)
|
||||
const userElements = screen.getAllByTestId('sidebar-user');
|
||||
expect(userElements[0]).toHaveTextContent('John Manager');
|
||||
const roleElements = screen.getAllByTestId('sidebar-role');
|
||||
expect(roleElements[0]).toHaveTextContent('platform_manager');
|
||||
});
|
||||
|
||||
it('displays Management Console in breadcrumb', () => {
|
||||
renderLayout();
|
||||
|
||||
expect(screen.getByText('Management Console')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays domain information in breadcrumb', () => {
|
||||
renderLayout();
|
||||
|
||||
expect(screen.getByText('smoothschedule.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders globe icon in breadcrumb', () => {
|
||||
renderLayout();
|
||||
|
||||
const globeIcon = screen.getByTestId('globe-icon');
|
||||
expect(globeIcon).toBeInTheDocument();
|
||||
expect(globeIcon).toHaveAttribute('width', '16');
|
||||
expect(globeIcon).toHaveAttribute('height', '16');
|
||||
});
|
||||
|
||||
it('hides breadcrumb on mobile', () => {
|
||||
renderLayout();
|
||||
|
||||
const breadcrumb = screen.getByText('Management Console').closest('div');
|
||||
expect(breadcrumb).toHaveClass('hidden', 'md:flex');
|
||||
});
|
||||
|
||||
it('handles sidebar collapse state', () => {
|
||||
renderLayout();
|
||||
|
||||
const collapseButton = screen.getByTestId('sidebar-collapse');
|
||||
expect(collapseButton).toHaveTextContent('Collapse');
|
||||
|
||||
// Click to collapse
|
||||
fireEvent.click(collapseButton);
|
||||
|
||||
// Note: The sidebar is mocked, so we just verify the button exists
|
||||
expect(collapseButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders desktop sidebar by default', () => {
|
||||
renderLayout();
|
||||
|
||||
const sidebar = screen.getByTestId('platform-sidebar');
|
||||
const desktopSidebar = sidebar.closest('.md\\:flex');
|
||||
expect(desktopSidebar).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('mobile sidebar is hidden by default', () => {
|
||||
renderLayout();
|
||||
|
||||
// Mobile menu should be off-screen initially
|
||||
const mobileSidebar = screen.getAllByTestId('platform-sidebar')[0];
|
||||
const mobileContainer = mobileSidebar.closest('.fixed');
|
||||
expect(mobileContainer).toHaveClass('-translate-x-full');
|
||||
});
|
||||
|
||||
it('mobile menu button opens mobile sidebar', () => {
|
||||
renderLayout();
|
||||
|
||||
const menuButton = screen.getByLabelText('Open sidebar');
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
// After clicking, mobile sidebar should be visible
|
||||
const mobileSidebar = screen.getAllByTestId('platform-sidebar')[0];
|
||||
const mobileContainer = mobileSidebar.closest('.fixed');
|
||||
expect(mobileContainer).toHaveClass('translate-x-0');
|
||||
});
|
||||
|
||||
it('clicking backdrop closes mobile menu', () => {
|
||||
renderLayout();
|
||||
|
||||
// Open mobile menu
|
||||
const menuButton = screen.getByLabelText('Open sidebar');
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
// Find and click backdrop
|
||||
const backdrop = document.querySelector('.bg-black\\/50');
|
||||
expect(backdrop).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(backdrop!);
|
||||
|
||||
// Mobile sidebar should be hidden again
|
||||
const mobileSidebar = screen.getAllByTestId('platform-sidebar')[0];
|
||||
const mobileContainer = mobileSidebar.closest('.fixed');
|
||||
expect(mobileContainer).toHaveClass('-translate-x-full');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Access Controls', () => {
|
||||
it('allows platform_manager role to access layout', () => {
|
||||
renderLayout(managerUser);
|
||||
|
||||
expect(screen.getByTestId('sidebar-role')).toHaveTextContent('platform_manager');
|
||||
expect(screen.getByTestId('outlet-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('allows superuser role to access layout', () => {
|
||||
renderLayout(superUser);
|
||||
|
||||
expect(screen.getByTestId('sidebar-role')).toHaveTextContent('superuser');
|
||||
expect(screen.getByTestId('outlet-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('allows platform_support role to access layout', () => {
|
||||
renderLayout(supportUser);
|
||||
|
||||
expect(screen.getByTestId('sidebar-role')).toHaveTextContent('platform_support');
|
||||
expect(screen.getByTestId('outlet-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders sign out button for authenticated users', () => {
|
||||
renderLayout();
|
||||
|
||||
const signOutButton = screen.getByTestId('sidebar-signout');
|
||||
expect(signOutButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onSignOut when sign out button is clicked', () => {
|
||||
renderLayout();
|
||||
|
||||
const signOutButton = screen.getByTestId('sidebar-signout');
|
||||
fireEvent.click(signOutButton);
|
||||
|
||||
expect(mockOnSignOut).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders layout for different user emails', () => {
|
||||
const customUser: User = {
|
||||
...managerUser,
|
||||
email: 'custom@example.com',
|
||||
};
|
||||
|
||||
renderLayout(customUser);
|
||||
expect(screen.getByTestId('outlet-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders layout for users with numeric IDs', () => {
|
||||
const numericIdUser: User = {
|
||||
...managerUser,
|
||||
id: 123,
|
||||
};
|
||||
|
||||
renderLayout(numericIdUser);
|
||||
expect(screen.getByTestId('outlet-content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Theme Toggle', () => {
|
||||
it('renders theme toggle button', () => {
|
||||
renderLayout();
|
||||
|
||||
const themeButton = screen.getByRole('button', { name: '' }).parentElement?.querySelector('button');
|
||||
expect(themeButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays Moon icon in light mode', () => {
|
||||
renderLayout(managerUser, false);
|
||||
|
||||
const moonIcon = screen.getByTestId('moon-icon');
|
||||
expect(moonIcon).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('sun-icon')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays Sun icon in dark mode', () => {
|
||||
renderLayout(managerUser, true);
|
||||
|
||||
const sunIcon = screen.getByTestId('sun-icon');
|
||||
expect(sunIcon).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('moon-icon')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls toggleTheme when theme button is clicked', () => {
|
||||
renderLayout();
|
||||
|
||||
// Find the button containing the moon icon
|
||||
const moonIcon = screen.getByTestId('moon-icon');
|
||||
const themeButton = moonIcon.closest('button');
|
||||
|
||||
expect(themeButton).toBeInTheDocument();
|
||||
fireEvent.click(themeButton!);
|
||||
|
||||
expect(mockToggleTheme).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('theme button has proper styling', () => {
|
||||
renderLayout();
|
||||
|
||||
const moonIcon = screen.getByTestId('moon-icon');
|
||||
const themeButton = moonIcon.closest('button');
|
||||
|
||||
expect(themeButton).toHaveClass('text-gray-400', 'hover:text-gray-600');
|
||||
});
|
||||
|
||||
it('icon size is correct', () => {
|
||||
renderLayout();
|
||||
|
||||
const moonIcon = screen.getByTestId('moon-icon');
|
||||
expect(moonIcon).toHaveAttribute('width', '20');
|
||||
expect(moonIcon).toHaveAttribute('height', '20');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Notification Bell', () => {
|
||||
it('renders notification bell icon', () => {
|
||||
renderLayout();
|
||||
|
||||
const bellIcon = screen.getByTestId('bell-icon');
|
||||
expect(bellIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('bell icon has correct size', () => {
|
||||
renderLayout();
|
||||
|
||||
const bellIcon = screen.getByTestId('bell-icon');
|
||||
expect(bellIcon).toHaveAttribute('width', '20');
|
||||
expect(bellIcon).toHaveAttribute('height', '20');
|
||||
});
|
||||
|
||||
it('bell button has proper styling', () => {
|
||||
renderLayout();
|
||||
|
||||
const bellIcon = screen.getByTestId('bell-icon');
|
||||
const bellButton = bellIcon.closest('button');
|
||||
|
||||
expect(bellButton).toHaveClass('text-gray-400', 'hover:text-gray-600');
|
||||
});
|
||||
|
||||
it('bell button is clickable', () => {
|
||||
renderLayout();
|
||||
|
||||
const bellIcon = screen.getByTestId('bell-icon');
|
||||
const bellButton = bellIcon.closest('button');
|
||||
|
||||
expect(bellButton).toBeInTheDocument();
|
||||
fireEvent.click(bellButton!);
|
||||
|
||||
// Button should be clickable (no error thrown)
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mobile Menu', () => {
|
||||
it('renders mobile menu button with Menu icon', () => {
|
||||
renderLayout();
|
||||
|
||||
const menuIcon = screen.getByTestId('menu-icon');
|
||||
expect(menuIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('mobile menu button has correct aria-label', () => {
|
||||
renderLayout();
|
||||
|
||||
const menuButton = screen.getByLabelText('Open sidebar');
|
||||
expect(menuButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('mobile menu button is only visible on mobile', () => {
|
||||
renderLayout();
|
||||
|
||||
const menuButton = screen.getByLabelText('Open sidebar');
|
||||
expect(menuButton).toHaveClass('md:hidden');
|
||||
});
|
||||
|
||||
it('menu icon has correct size', () => {
|
||||
renderLayout();
|
||||
|
||||
const menuIcon = screen.getByTestId('menu-icon');
|
||||
expect(menuIcon).toHaveAttribute('width', '24');
|
||||
expect(menuIcon).toHaveAttribute('height', '24');
|
||||
});
|
||||
|
||||
it('toggles mobile menu visibility', () => {
|
||||
renderLayout();
|
||||
|
||||
const menuButton = screen.getByLabelText('Open sidebar');
|
||||
|
||||
// Initially closed
|
||||
let mobileSidebar = screen.getAllByTestId('platform-sidebar')[0];
|
||||
let mobileContainer = mobileSidebar.closest('.fixed');
|
||||
expect(mobileContainer).toHaveClass('-translate-x-full');
|
||||
|
||||
// Open menu
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
mobileSidebar = screen.getAllByTestId('platform-sidebar')[0];
|
||||
mobileContainer = mobileSidebar.closest('.fixed');
|
||||
expect(mobileContainer).toHaveClass('translate-x-0');
|
||||
});
|
||||
|
||||
it('mobile backdrop appears when menu is open', () => {
|
||||
renderLayout();
|
||||
|
||||
const menuButton = screen.getByLabelText('Open sidebar');
|
||||
|
||||
// No backdrop initially
|
||||
expect(document.querySelector('.bg-black\\/50')).not.toBeInTheDocument();
|
||||
|
||||
// Open menu
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
// Backdrop should appear
|
||||
expect(document.querySelector('.bg-black\\/50')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('mobile backdrop has correct z-index', () => {
|
||||
renderLayout();
|
||||
|
||||
const menuButton = screen.getByLabelText('Open sidebar');
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
const backdrop = document.querySelector('.bg-black\\/50');
|
||||
expect(backdrop).toHaveClass('z-30');
|
||||
});
|
||||
|
||||
it('mobile sidebar has higher z-index than backdrop', () => {
|
||||
renderLayout();
|
||||
|
||||
const mobileSidebar = screen.getAllByTestId('platform-sidebar')[0];
|
||||
const mobileContainer = mobileSidebar.closest('.fixed');
|
||||
|
||||
expect(mobileContainer).toHaveClass('z-40');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Layout Responsiveness', () => {
|
||||
it('applies responsive padding to header', () => {
|
||||
renderLayout();
|
||||
|
||||
const header = screen.getByRole('banner');
|
||||
expect(header).toHaveClass('px-4', 'sm:px-8');
|
||||
});
|
||||
|
||||
it('main content has proper spacing', () => {
|
||||
renderLayout();
|
||||
|
||||
const mainContent = screen.getByRole('main');
|
||||
expect(mainContent).toHaveClass('p-8');
|
||||
});
|
||||
|
||||
it('desktop sidebar is hidden on mobile', () => {
|
||||
renderLayout();
|
||||
|
||||
const desktopSidebar = screen.getAllByTestId('platform-sidebar')[1].closest('.md\\:flex');
|
||||
expect(desktopSidebar).toHaveClass('hidden');
|
||||
});
|
||||
|
||||
it('layout uses flexbox for proper structure', () => {
|
||||
renderLayout();
|
||||
|
||||
const container = screen.getByRole('main').closest('.flex');
|
||||
expect(container).toHaveClass('flex', 'h-full');
|
||||
});
|
||||
|
||||
it('main content area is scrollable', () => {
|
||||
renderLayout();
|
||||
|
||||
const mainContent = screen.getByRole('main');
|
||||
expect(mainContent).toHaveClass('overflow-auto');
|
||||
});
|
||||
|
||||
it('layout has proper height constraints', () => {
|
||||
renderLayout();
|
||||
|
||||
const container = screen.getByRole('main').closest('.flex');
|
||||
expect(container).toHaveClass('h-full');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Styling and Visual State', () => {
|
||||
it('applies background color classes', () => {
|
||||
renderLayout();
|
||||
|
||||
const container = screen.getByRole('main').closest('.flex');
|
||||
expect(container).toHaveClass('bg-gray-100', 'dark:bg-gray-900');
|
||||
});
|
||||
|
||||
it('header has border', () => {
|
||||
renderLayout();
|
||||
|
||||
const header = screen.getByRole('banner');
|
||||
expect(header).toHaveClass('border-b', 'border-gray-200', 'dark:border-gray-700');
|
||||
});
|
||||
|
||||
it('header has fixed height', () => {
|
||||
renderLayout();
|
||||
|
||||
const header = screen.getByRole('banner');
|
||||
expect(header).toHaveClass('h-16');
|
||||
});
|
||||
|
||||
it('applies transition classes for animations', () => {
|
||||
renderLayout();
|
||||
|
||||
const mobileSidebar = screen.getAllByTestId('platform-sidebar')[0];
|
||||
const mobileContainer = mobileSidebar.closest('.fixed');
|
||||
|
||||
expect(mobileContainer).toHaveClass('transition-transform', 'duration-300', 'ease-in-out');
|
||||
});
|
||||
|
||||
it('buttons have hover states', () => {
|
||||
renderLayout();
|
||||
|
||||
const moonIcon = screen.getByTestId('moon-icon');
|
||||
const themeButton = moonIcon.closest('button');
|
||||
|
||||
expect(themeButton).toHaveClass('hover:text-gray-600');
|
||||
});
|
||||
|
||||
it('menu button has negative margin for alignment', () => {
|
||||
renderLayout();
|
||||
|
||||
const menuButton = screen.getByLabelText('Open sidebar');
|
||||
expect(menuButton).toHaveClass('-ml-2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scroll Behavior', () => {
|
||||
it('calls useScrollToTop hook on mount', () => {
|
||||
const { useScrollToTop } = require('../../hooks/useScrollToTop');
|
||||
|
||||
renderLayout();
|
||||
|
||||
expect(useScrollToTop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes main content ref to useScrollToTop', () => {
|
||||
const { useScrollToTop } = require('../../hooks/useScrollToTop');
|
||||
|
||||
renderLayout();
|
||||
|
||||
// Verify hook was called with a ref
|
||||
expect(useScrollToTop).toHaveBeenCalledWith(expect.objectContaining({
|
||||
current: expect.any(Object),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles user without optional fields', () => {
|
||||
const minimalUser: User = {
|
||||
id: '1',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
role: 'platform_manager',
|
||||
};
|
||||
|
||||
renderLayout(minimalUser);
|
||||
expect(screen.getByTestId('outlet-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with extremely long user names', () => {
|
||||
const longNameUser: User = {
|
||||
...managerUser,
|
||||
name: 'This Is An Extremely Long User Name That Should Still Render Properly Without Breaking The Layout',
|
||||
};
|
||||
|
||||
renderLayout(longNameUser);
|
||||
expect(screen.getByTestId('sidebar-user')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles rapid theme toggle clicks', () => {
|
||||
renderLayout();
|
||||
|
||||
const moonIcon = screen.getByTestId('moon-icon');
|
||||
const themeButton = moonIcon.closest('button');
|
||||
|
||||
fireEvent.click(themeButton!);
|
||||
fireEvent.click(themeButton!);
|
||||
fireEvent.click(themeButton!);
|
||||
|
||||
expect(mockToggleTheme).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('handles rapid mobile menu toggles', () => {
|
||||
renderLayout();
|
||||
|
||||
const menuButton = screen.getByLabelText('Open sidebar');
|
||||
|
||||
fireEvent.click(menuButton);
|
||||
fireEvent.click(menuButton);
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
// Should not crash
|
||||
expect(menuButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('maintains state during re-renders', () => {
|
||||
const { rerender } = renderLayout();
|
||||
|
||||
// Open mobile menu
|
||||
const menuButton = screen.getByLabelText('Open sidebar');
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
// Re-render with same props
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<ManagerLayout
|
||||
user={managerUser}
|
||||
darkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
onSignOut={mockOnSignOut}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// State should persist
|
||||
const mobileSidebar = screen.getAllByTestId('platform-sidebar')[0];
|
||||
const mobileContainer = mobileSidebar.closest('.fixed');
|
||||
expect(mobileContainer).toHaveClass('translate-x-0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('header has correct semantic role', () => {
|
||||
renderLayout();
|
||||
|
||||
const header = screen.getByRole('banner');
|
||||
expect(header.tagName).toBe('HEADER');
|
||||
});
|
||||
|
||||
it('main has correct semantic role', () => {
|
||||
renderLayout();
|
||||
|
||||
const main = screen.getByRole('main');
|
||||
expect(main.tagName).toBe('MAIN');
|
||||
});
|
||||
|
||||
it('buttons have proper interactive elements', () => {
|
||||
renderLayout();
|
||||
|
||||
const menuButton = screen.getByLabelText('Open sidebar');
|
||||
expect(menuButton.tagName).toBe('BUTTON');
|
||||
});
|
||||
|
||||
it('mobile menu button has aria-label', () => {
|
||||
renderLayout();
|
||||
|
||||
const menuButton = screen.getByLabelText('Open sidebar');
|
||||
expect(menuButton).toHaveAttribute('aria-label', 'Open sidebar');
|
||||
});
|
||||
|
||||
it('all interactive elements are keyboard accessible', () => {
|
||||
renderLayout();
|
||||
|
||||
const menuButton = screen.getByLabelText('Open sidebar');
|
||||
const moonIcon = screen.getByTestId('moon-icon');
|
||||
const themeButton = moonIcon.closest('button');
|
||||
const bellIcon = screen.getByTestId('bell-icon');
|
||||
const bellButton = bellIcon.closest('button');
|
||||
|
||||
expect(menuButton.tagName).toBe('BUTTON');
|
||||
expect(themeButton?.tagName).toBe('BUTTON');
|
||||
expect(bellButton?.tagName).toBe('BUTTON');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Component Integration', () => {
|
||||
it('renders without crashing', () => {
|
||||
expect(() => renderLayout()).not.toThrow();
|
||||
});
|
||||
|
||||
it('renders all major sections together', () => {
|
||||
renderLayout();
|
||||
|
||||
expect(screen.getByTestId('platform-sidebar')).toBeInTheDocument();
|
||||
expect(screen.getByRole('banner')).toBeInTheDocument();
|
||||
expect(screen.getByRole('main')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('outlet-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes correct props to PlatformSidebar', () => {
|
||||
renderLayout();
|
||||
|
||||
expect(screen.getByTestId('sidebar-user')).toHaveTextContent('John Manager');
|
||||
expect(screen.getByTestId('sidebar-signout')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('integrates with React Router Outlet', () => {
|
||||
renderLayout();
|
||||
|
||||
const outlet = screen.getByTestId('outlet-content');
|
||||
expect(outlet).toHaveTextContent('Page Content');
|
||||
});
|
||||
|
||||
it('handles multiple simultaneous interactions', () => {
|
||||
renderLayout();
|
||||
|
||||
// Open mobile menu
|
||||
const menuButton = screen.getByLabelText('Open sidebar');
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
// Toggle theme
|
||||
const moonIcon = screen.getByTestId('moon-icon');
|
||||
const themeButton = moonIcon.closest('button');
|
||||
fireEvent.click(themeButton!);
|
||||
|
||||
// Click bell
|
||||
const bellIcon = screen.getByTestId('bell-icon');
|
||||
const bellButton = bellIcon.closest('button');
|
||||
fireEvent.click(bellButton!);
|
||||
|
||||
expect(mockToggleTheme).toHaveBeenCalledTimes(1);
|
||||
const mobileSidebar = screen.getAllByTestId('platform-sidebar')[0];
|
||||
const mobileContainer = mobileSidebar.closest('.fixed');
|
||||
expect(mobileContainer).toHaveClass('translate-x-0');
|
||||
});
|
||||
});
|
||||
});
|
||||
736
frontend/src/layouts/__tests__/MarketingLayout.test.tsx
Normal file
736
frontend/src/layouts/__tests__/MarketingLayout.test.tsx
Normal file
@@ -0,0 +1,736 @@
|
||||
/**
|
||||
* Unit tests for MarketingLayout component
|
||||
*
|
||||
* Tests cover:
|
||||
* - Rendering children content via Outlet
|
||||
* - Contains Navbar component with correct props
|
||||
* - Contains Footer component
|
||||
* - Dark mode state management and localStorage integration
|
||||
* - Theme toggle functionality
|
||||
* - Document class toggling for dark mode
|
||||
* - Correct layout structure and styling
|
||||
* - Scroll-to-top behavior via useScrollToTop hook
|
||||
* - User prop passing to Navbar
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import MarketingLayout from '../MarketingLayout';
|
||||
import { User } from '../../api/auth';
|
||||
|
||||
// Mock child components
|
||||
vi.mock('../../components/marketing/Navbar', () => ({
|
||||
default: ({ darkMode, toggleTheme, user }: any) => (
|
||||
<div data-testid="navbar">
|
||||
<span data-testid="navbar-darkmode">{darkMode ? 'dark' : 'light'}</span>
|
||||
<button data-testid="navbar-toggle" onClick={toggleTheme}>
|
||||
Toggle Theme
|
||||
</button>
|
||||
{user && <span data-testid="navbar-user">{user.email}</span>}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/marketing/Footer', () => ({
|
||||
default: () => <div data-testid="footer">Footer Content</div>,
|
||||
}));
|
||||
|
||||
const mockUseScrollToTop = vi.fn();
|
||||
vi.mock('../../hooks/useScrollToTop', () => ({
|
||||
useScrollToTop: mockUseScrollToTop,
|
||||
}));
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Create a wrapper component with Router
|
||||
const TestWrapper = ({ children, initialRoute = '/' }: { children: React.ReactNode; initialRoute?: string }) => {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={children}>
|
||||
<Route index element={<div data-testid="home-content">Home Page</div>} />
|
||||
<Route path="about" element={<div data-testid="about-content">About Page</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('MarketingLayout', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Clear localStorage before each test
|
||||
localStorage.clear();
|
||||
// Clear document.documentElement classes
|
||||
document.documentElement.classList.remove('dark');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Component Rendering', () => {
|
||||
it('should render the layout', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MarketingLayout />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('navbar')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('footer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render children content via Outlet', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MarketingLayout />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('home-content')).toBeInTheDocument();
|
||||
expect(screen.getByText('Home Page')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all major layout sections', () => {
|
||||
const { container } = render(
|
||||
<TestWrapper>
|
||||
<MarketingLayout />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Check for main container
|
||||
const mainElement = container.querySelector('main');
|
||||
expect(mainElement).toBeInTheDocument();
|
||||
|
||||
// Check for navbar, main, and footer
|
||||
expect(screen.getByTestId('navbar')).toBeInTheDocument();
|
||||
expect(mainElement).toBeInTheDocument();
|
||||
expect(screen.getByTestId('footer')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navbar Component', () => {
|
||||
it('should render Navbar component', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MarketingLayout />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('navbar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should pass darkMode prop to Navbar', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MarketingLayout />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const darkModeIndicator = screen.getByTestId('navbar-darkmode');
|
||||
expect(darkModeIndicator).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should pass toggleTheme function to Navbar', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MarketingLayout />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const toggleButton = screen.getByTestId('navbar-toggle');
|
||||
expect(toggleButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should pass user prop to Navbar when provided', () => {
|
||||
const mockUser: User = {
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
username: 'testuser',
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
role: 'owner',
|
||||
business_id: 1,
|
||||
business_name: 'Test Business',
|
||||
business_subdomain: 'test',
|
||||
business_logo: null,
|
||||
timezone: 'UTC',
|
||||
language: 'en',
|
||||
onboarding_completed: true,
|
||||
};
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MarketingLayout user={mockUser} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('navbar-user')).toHaveTextContent('test@example.com');
|
||||
});
|
||||
|
||||
it('should not render user info in Navbar when user is null', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MarketingLayout user={null} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('navbar-user')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render user info in Navbar when user is undefined', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MarketingLayout />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('navbar-user')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Footer Component', () => {
|
||||
it('should render Footer component', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MarketingLayout />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('footer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render Footer content', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MarketingLayout />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Footer Content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Layout Styling and Structure', () => {
|
||||
it('should apply correct base classes to root container', () => {
|
||||
const { container } = render(
|
||||
<TestWrapper>
|
||||
<MarketingLayout />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const rootDiv = container.querySelector('.min-h-screen');
|
||||
expect(rootDiv).toBeInTheDocument();
|
||||
expect(rootDiv).toHaveClass('min-h-screen');
|
||||
expect(rootDiv).toHaveClass('flex');
|
||||
expect(rootDiv).toHaveClass('flex-col');
|
||||
expect(rootDiv).toHaveClass('bg-white');
|
||||
expect(rootDiv).toHaveClass('dark:bg-gray-900');
|
||||
expect(rootDiv).toHaveClass('transition-colors');
|
||||
expect(rootDiv).toHaveClass('duration-200');
|
||||
});
|
||||
|
||||
it('should apply correct classes to main element', () => {
|
||||
const { container } = render(
|
||||
<TestWrapper>
|
||||
<MarketingLayout />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const mainElement = container.querySelector('main');
|
||||
expect(mainElement).toHaveClass('flex-1');
|
||||
expect(mainElement).toHaveClass('pt-16');
|
||||
expect(mainElement).toHaveClass('lg:pt-20');
|
||||
});
|
||||
|
||||
it('should maintain flexbox layout structure', () => {
|
||||
const { container } = render(
|
||||
<TestWrapper>
|
||||
<MarketingLayout />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const rootDiv = container.querySelector('.flex.flex-col');
|
||||
expect(rootDiv).toBeInTheDocument();
|
||||
|
||||
// Verify main has flex-1 for proper spacing
|
||||
const mainElement = rootDiv?.querySelector('main.flex-1');
|
||||
expect(mainElement).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dark Mode State Management', () => {
|
||||
it('should initialize dark mode from localStorage if available', () => {
|
||||
localStorage.setItem('darkMode', JSON.stringify(true));
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MarketingLayout />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('navbar-darkmode')).toHaveTextContent('dark');
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||
});
|
||||
|
||||
it('should initialize dark mode to false when not in localStorage', () => {
|
||||
// matchMedia is mocked to return false in setup.ts
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MarketingLayout />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('navbar-darkmode')).toHaveTextContent('light');
|
||||
});
|
||||
|
||||
it('should respect system preference when no localStorage value exists', () => {
|
||||
// Override matchMedia to return true for dark mode preference
|
||||
window.matchMedia = vi.fn().mockImplementation((query) => ({
|
||||
matches: query === '(prefers-color-scheme: dark)',
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MarketingLayout />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('navbar-darkmode')).toHaveTextContent('dark');
|
||||
});
|
||||
|
||||
it('should save dark mode preference to localStorage on mount', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MarketingLayout />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// The component should save the initial dark mode state to localStorage
|
||||
const savedValue = localStorage.getItem('darkMode');
|
||||
expect(savedValue).toBeDefined();
|
||||
expect(['true', 'false']).toContain(savedValue);
|
||||
});
|
||||
|
||||
it('should update localStorage when dark mode is toggled', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MarketingLayout />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Get initial state
|
||||
const initialDarkMode = screen.getByTestId('navbar-darkmode').textContent;
|
||||
const expectedAfterToggle = initialDarkMode === 'light' ? 'true' : 'false';
|
||||
|
||||
const toggleButton = screen.getByTestId('navbar-toggle');
|
||||
await user.click(toggleButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const savedValue = localStorage.getItem('darkMode');
|
||||
expect(savedValue).toBe(expectedAfterToggle);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Theme Toggle Functionality', () => {
|
||||
it('should toggle dark mode when toggle button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MarketingLayout />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const darkModeIndicator = screen.getByTestId('navbar-darkmode');
|
||||
const initialState = darkModeIndicator.textContent;
|
||||
|
||||
const toggleButton = screen.getByTestId('navbar-toggle');
|
||||
await user.click(toggleButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const newState = screen.getByTestId('navbar-darkmode').textContent;
|
||||
expect(newState).not.toBe(initialState);
|
||||
expect(['light', 'dark']).toContain(newState);
|
||||
});
|
||||
});
|
||||
|
||||
it('should toggle back to light mode when clicked again', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MarketingLayout />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const toggleButton = screen.getByTestId('navbar-toggle');
|
||||
const initialState = screen.getByTestId('navbar-darkmode').textContent;
|
||||
|
||||
// First toggle
|
||||
await user.click(toggleButton);
|
||||
await waitFor(() => {
|
||||
const firstToggleState = screen.getByTestId('navbar-darkmode').textContent;
|
||||
expect(firstToggleState).not.toBe(initialState);
|
||||
});
|
||||
|
||||
const afterFirstToggle = screen.getByTestId('navbar-darkmode').textContent;
|
||||
|
||||
// Second toggle - should go back to initial state
|
||||
await user.click(toggleButton);
|
||||
await waitFor(() => {
|
||||
const secondToggleState = screen.getByTestId('navbar-darkmode').textContent;
|
||||
expect(secondToggleState).toBe(initialState);
|
||||
expect(secondToggleState).not.toBe(afterFirstToggle);
|
||||
});
|
||||
});
|
||||
|
||||
it('should add dark class to document when dark mode is enabled', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Start with light mode explicitly
|
||||
localStorage.setItem('darkMode', 'false');
|
||||
document.documentElement.classList.remove('dark');
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MarketingLayout />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const toggleButton = screen.getByTestId('navbar-toggle');
|
||||
await user.click(toggleButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove dark class from document when dark mode is disabled', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Start with dark mode enabled
|
||||
localStorage.setItem('darkMode', JSON.stringify(true));
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MarketingLayout />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||
});
|
||||
|
||||
const toggleButton = screen.getByTestId('navbar-toggle');
|
||||
await user.click(toggleButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should persist dark mode state across multiple toggles', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Start with explicit light mode
|
||||
localStorage.setItem('darkMode', 'false');
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MarketingLayout />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const toggleButton = screen.getByTestId('navbar-toggle');
|
||||
const initialValue = localStorage.getItem('darkMode');
|
||||
|
||||
// Toggle on
|
||||
await user.click(toggleButton);
|
||||
await waitFor(() => {
|
||||
const newValue = localStorage.getItem('darkMode');
|
||||
expect(newValue).not.toBe(initialValue);
|
||||
});
|
||||
|
||||
const afterFirstToggle = localStorage.getItem('darkMode');
|
||||
|
||||
// Toggle off
|
||||
await user.click(toggleButton);
|
||||
await waitFor(() => {
|
||||
const newValue = localStorage.getItem('darkMode');
|
||||
expect(newValue).toBe(initialValue);
|
||||
expect(newValue).not.toBe(afterFirstToggle);
|
||||
});
|
||||
|
||||
// Toggle on again
|
||||
await user.click(toggleButton);
|
||||
await waitFor(() => {
|
||||
const newValue = localStorage.getItem('darkMode');
|
||||
expect(newValue).toBe(afterFirstToggle);
|
||||
expect(newValue).not.toBe(initialValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Document Class Management', () => {
|
||||
it('should apply dark class to document.documentElement when dark mode is true', async () => {
|
||||
localStorage.setItem('darkMode', JSON.stringify(true));
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MarketingLayout />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not apply dark class when dark mode is false', async () => {
|
||||
localStorage.setItem('darkMode', JSON.stringify(false));
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MarketingLayout />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should update document class when dark mode changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Start with explicit light mode
|
||||
localStorage.setItem('darkMode', 'false');
|
||||
document.documentElement.classList.remove('dark');
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MarketingLayout />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Initially light mode
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false);
|
||||
|
||||
// Toggle to dark mode
|
||||
const toggleButton = screen.getByTestId('navbar-toggle');
|
||||
await user.click(toggleButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||
});
|
||||
|
||||
// Toggle back to light mode
|
||||
await user.click(toggleButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scroll Behavior', () => {
|
||||
it('should call useScrollToTop hook', () => {
|
||||
mockUseScrollToTop.mockClear();
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MarketingLayout />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(mockUseScrollToTop).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration Tests', () => {
|
||||
it('should render complete layout with all components and props', async () => {
|
||||
const mockUser: User = {
|
||||
id: 1,
|
||||
email: 'integration@example.com',
|
||||
username: 'integrationuser',
|
||||
first_name: 'Integration',
|
||||
last_name: 'Test',
|
||||
role: 'manager',
|
||||
business_id: 1,
|
||||
business_name: 'Integration Business',
|
||||
business_subdomain: 'integration',
|
||||
business_logo: null,
|
||||
timezone: 'America/New_York',
|
||||
language: 'en',
|
||||
onboarding_completed: true,
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<TestWrapper>
|
||||
<MarketingLayout user={mockUser} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Verify all major components
|
||||
expect(screen.getByTestId('navbar')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('footer')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('home-content')).toBeInTheDocument();
|
||||
|
||||
// Verify user is passed to navbar
|
||||
expect(screen.getByTestId('navbar-user')).toHaveTextContent('integration@example.com');
|
||||
|
||||
// Verify main element exists and has proper styling
|
||||
const mainElement = container.querySelector('main');
|
||||
expect(mainElement).toBeInTheDocument();
|
||||
expect(mainElement).toHaveClass('flex-1', 'pt-16', 'lg:pt-20');
|
||||
|
||||
// Verify root container styling
|
||||
const rootDiv = container.querySelector('.min-h-screen');
|
||||
expect(rootDiv).toHaveClass('flex', 'flex-col', 'bg-white', 'dark:bg-gray-900');
|
||||
});
|
||||
|
||||
it('should maintain layout structure when switching routes', async () => {
|
||||
const { container } = render(
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<MarketingLayout />}>
|
||||
<Route index element={<div data-testid="home-content">Home</div>} />
|
||||
<Route path="about" element={<div data-testid="about-content">About</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
// Verify home content is rendered
|
||||
expect(screen.getByTestId('home-content')).toBeInTheDocument();
|
||||
|
||||
// Navbar and Footer should persist
|
||||
expect(screen.getByTestId('navbar')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('footer')).toBeInTheDocument();
|
||||
|
||||
// Main element structure should remain
|
||||
const mainElement = container.querySelector('main');
|
||||
expect(mainElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle dark mode toggle with user prop', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockUser: User = {
|
||||
id: 1,
|
||||
email: 'darkmode@example.com',
|
||||
username: 'darkmodeuser',
|
||||
first_name: 'Dark',
|
||||
last_name: 'Mode',
|
||||
role: 'owner',
|
||||
business_id: 1,
|
||||
business_name: 'Dark Mode Business',
|
||||
business_subdomain: 'darkmode',
|
||||
business_logo: null,
|
||||
timezone: 'UTC',
|
||||
language: 'en',
|
||||
onboarding_completed: true,
|
||||
};
|
||||
|
||||
// Start with light mode
|
||||
localStorage.setItem('darkMode', 'false');
|
||||
document.documentElement.classList.remove('dark');
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MarketingLayout user={mockUser} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const toggleButton = screen.getByTestId('navbar-toggle');
|
||||
const initialDarkModeState = screen.getByTestId('navbar-darkmode').textContent;
|
||||
|
||||
// Verify user is displayed
|
||||
expect(screen.getByTestId('navbar-user')).toHaveTextContent('darkmode@example.com');
|
||||
|
||||
// Toggle dark mode
|
||||
await user.click(toggleButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const newDarkModeState = screen.getByTestId('navbar-darkmode').textContent;
|
||||
expect(newDarkModeState).not.toBe(initialDarkModeState);
|
||||
expect(screen.getByTestId('navbar-user')).toHaveTextContent('darkmode@example.com');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle SSR environment gracefully (window undefined)', () => {
|
||||
// This test verifies the typeof window !== 'undefined' check
|
||||
// In jsdom, window is always defined, but the code should handle its absence
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MarketingLayout />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('navbar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle missing localStorage gracefully', async () => {
|
||||
// Temporarily break localStorage
|
||||
const originalSetItem = localStorage.setItem;
|
||||
localStorage.setItem = vi.fn(() => {
|
||||
throw new Error('localStorage error');
|
||||
});
|
||||
|
||||
// Should not crash
|
||||
const { container } = render(
|
||||
<TestWrapper>
|
||||
<MarketingLayout />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(container).toBeInTheDocument();
|
||||
|
||||
// Restore localStorage
|
||||
localStorage.setItem = originalSetItem;
|
||||
});
|
||||
|
||||
it('should handle undefined user prop gracefully', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MarketingLayout user={undefined} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('navbar')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('navbar-user')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle null user prop gracefully', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MarketingLayout user={null} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('navbar')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('navbar-user')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
657
frontend/src/layouts/__tests__/PlatformLayout.test.tsx
Normal file
657
frontend/src/layouts/__tests__/PlatformLayout.test.tsx
Normal file
@@ -0,0 +1,657 @@
|
||||
/**
|
||||
* Unit tests for PlatformLayout component
|
||||
*
|
||||
* Tests all layout functionality including:
|
||||
* - Rendering children content via Outlet
|
||||
* - Platform admin navigation (sidebar, mobile menu)
|
||||
* - User info displays (UserProfileDropdown, theme toggle, language selector)
|
||||
* - Notification dropdown
|
||||
* - Ticket modal functionality
|
||||
* - Mobile menu behavior
|
||||
* - Floating help button
|
||||
* - Scroll to top on route change
|
||||
* - Conditional padding for special routes
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter, useLocation } from 'react-router-dom';
|
||||
import PlatformLayout from '../PlatformLayout';
|
||||
import { User } from '../../types';
|
||||
|
||||
// Mock child components
|
||||
vi.mock('../../components/PlatformSidebar', () => ({
|
||||
default: ({ user, isCollapsed, toggleCollapse }: any) => (
|
||||
<div data-testid="platform-sidebar">
|
||||
<div data-testid="sidebar-user">{user.name}</div>
|
||||
<div data-testid="sidebar-collapsed">{isCollapsed.toString()}</div>
|
||||
<button onClick={toggleCollapse} data-testid="toggle-collapse">Toggle</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/UserProfileDropdown', () => ({
|
||||
default: ({ user }: any) => (
|
||||
<div data-testid="user-profile-dropdown">
|
||||
<div data-testid="profile-user-name">{user.name}</div>
|
||||
<div data-testid="profile-user-email">{user.email}</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/NotificationDropdown', () => ({
|
||||
default: ({ onTicketClick }: any) => (
|
||||
<div data-testid="notification-dropdown">
|
||||
<button onClick={() => onTicketClick('ticket-123')} data-testid="notification-ticket-btn">
|
||||
Open Ticket
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/LanguageSelector', () => ({
|
||||
default: () => <div data-testid="language-selector">Language Selector</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../../components/TicketModal', () => ({
|
||||
default: ({ ticket, onClose }: any) => (
|
||||
<div data-testid="ticket-modal">
|
||||
<div data-testid="ticket-id">{ticket.id}</div>
|
||||
<button onClick={onClose} data-testid="close-ticket-modal">Close</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/FloatingHelpButton', () => ({
|
||||
default: () => <div data-testid="floating-help-button">Help</div>,
|
||||
}));
|
||||
|
||||
// Mock hooks
|
||||
vi.mock('../../hooks/useTickets', () => ({
|
||||
useTicket: vi.fn((ticketId) => {
|
||||
if (ticketId === 'ticket-123') {
|
||||
return {
|
||||
data: {
|
||||
id: 'ticket-123',
|
||||
subject: 'Test Ticket',
|
||||
description: 'Test description',
|
||||
status: 'OPEN',
|
||||
priority: 'MEDIUM',
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
return { data: null, isLoading: false, error: null };
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useScrollToTop', () => ({
|
||||
useScrollToTop: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock react-router-dom Outlet
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
Outlet: () => <div data-testid="outlet-content">Page Content</div>,
|
||||
useLocation: vi.fn(() => ({ pathname: '/' })),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock('lucide-react', () => ({
|
||||
Moon: ({ size }: { size: number }) => <svg data-testid="moon-icon" width={size} height={size} />,
|
||||
Sun: ({ size }: { size: number }) => <svg data-testid="sun-icon" width={size} height={size} />,
|
||||
Globe: ({ size }: { size: number }) => <svg data-testid="globe-icon" width={size} height={size} />,
|
||||
Menu: ({ size }: { size: number }) => <svg data-testid="menu-icon" width={size} height={size} />,
|
||||
}));
|
||||
|
||||
describe('PlatformLayout', () => {
|
||||
const mockUser: User = {
|
||||
id: '1',
|
||||
name: 'John Doe',
|
||||
email: 'john@platform.com',
|
||||
role: 'superuser',
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
user: mockUser,
|
||||
darkMode: false,
|
||||
toggleTheme: vi.fn(),
|
||||
onSignOut: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderLayout = (props = {}) => {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<PlatformLayout {...defaultProps} {...props} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render the layout with all main components', () => {
|
||||
renderLayout();
|
||||
|
||||
// Check for main structural elements (there are 2 sidebars: mobile and desktop)
|
||||
expect(screen.getAllByTestId('platform-sidebar')).toHaveLength(2);
|
||||
expect(screen.getByTestId('user-profile-dropdown')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('notification-dropdown')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('language-selector')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('floating-help-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render children content via Outlet', () => {
|
||||
renderLayout();
|
||||
|
||||
expect(screen.getByTestId('outlet-content')).toBeInTheDocument();
|
||||
expect(screen.getByText('Page Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the platform header with branding', () => {
|
||||
renderLayout();
|
||||
|
||||
expect(screen.getByText('smoothschedule.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('Admin Console')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('globe-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render desktop sidebar (hidden on mobile)', () => {
|
||||
const { container } = renderLayout();
|
||||
|
||||
const desktopSidebar = container.querySelector('.hidden.md\\:flex.md\\:flex-shrink-0');
|
||||
expect(desktopSidebar).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render mobile menu button', () => {
|
||||
renderLayout();
|
||||
|
||||
const menuButton = screen.getByLabelText('Open sidebar');
|
||||
expect(menuButton).toBeInTheDocument();
|
||||
expect(screen.getByTestId('menu-icon')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Info Display', () => {
|
||||
it('should display user info in UserProfileDropdown', () => {
|
||||
renderLayout();
|
||||
|
||||
expect(screen.getByTestId('profile-user-name')).toHaveTextContent('John Doe');
|
||||
expect(screen.getByTestId('profile-user-email')).toHaveTextContent('john@platform.com');
|
||||
});
|
||||
|
||||
it('should display user in sidebar', () => {
|
||||
renderLayout();
|
||||
|
||||
// Both mobile and desktop sidebars show user
|
||||
const sidebarUsers = screen.getAllByTestId('sidebar-user');
|
||||
expect(sidebarUsers).toHaveLength(2);
|
||||
sidebarUsers.forEach(el => expect(el).toHaveTextContent('John Doe'));
|
||||
});
|
||||
|
||||
it('should handle different user roles', () => {
|
||||
const managerUser: User = {
|
||||
id: '2',
|
||||
name: 'Jane Manager',
|
||||
email: 'jane@platform.com',
|
||||
role: 'platform_manager',
|
||||
};
|
||||
|
||||
renderLayout({ user: managerUser });
|
||||
|
||||
expect(screen.getByTestId('profile-user-name')).toHaveTextContent('Jane Manager');
|
||||
expect(screen.getByTestId('profile-user-email')).toHaveTextContent('jane@platform.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Theme Toggle', () => {
|
||||
it('should show Moon icon when in light mode', () => {
|
||||
renderLayout({ darkMode: false });
|
||||
|
||||
expect(screen.getByTestId('moon-icon')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('sun-icon')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show Sun icon when in dark mode', () => {
|
||||
renderLayout({ darkMode: true });
|
||||
|
||||
expect(screen.getByTestId('sun-icon')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('moon-icon')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call toggleTheme when theme button is clicked', () => {
|
||||
const toggleTheme = vi.fn();
|
||||
renderLayout({ toggleTheme });
|
||||
|
||||
const themeButton = screen.getByTestId('moon-icon').closest('button');
|
||||
expect(themeButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(themeButton!);
|
||||
expect(toggleTheme).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should toggle between light and dark mode icons', () => {
|
||||
const { rerender } = render(
|
||||
<MemoryRouter>
|
||||
<PlatformLayout {...defaultProps} darkMode={false} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('moon-icon')).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<PlatformLayout {...defaultProps} darkMode={true} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('sun-icon')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mobile Menu', () => {
|
||||
it('should not show mobile menu by default', () => {
|
||||
const { container } = renderLayout();
|
||||
|
||||
const mobileMenu = container.querySelector('.fixed.inset-y-0.left-0.z-40');
|
||||
expect(mobileMenu).toHaveClass('-translate-x-full');
|
||||
});
|
||||
|
||||
it('should open mobile menu when menu button is clicked', () => {
|
||||
const { container } = renderLayout();
|
||||
|
||||
const menuButton = screen.getByLabelText('Open sidebar');
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
const mobileMenu = container.querySelector('.fixed.inset-y-0.left-0.z-40');
|
||||
expect(mobileMenu).toHaveClass('translate-x-0');
|
||||
});
|
||||
|
||||
it('should show backdrop when mobile menu is open', () => {
|
||||
const { container } = renderLayout();
|
||||
|
||||
// Initially no backdrop
|
||||
let backdrop = container.querySelector('.fixed.inset-0.z-30.bg-black\\/50');
|
||||
expect(backdrop).not.toBeInTheDocument();
|
||||
|
||||
// Open menu
|
||||
const menuButton = screen.getByLabelText('Open sidebar');
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
// Backdrop should appear
|
||||
backdrop = container.querySelector('.fixed.inset-0.z-30.bg-black\\/50');
|
||||
expect(backdrop).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should close mobile menu when backdrop is clicked', () => {
|
||||
const { container } = renderLayout();
|
||||
|
||||
// Open menu
|
||||
const menuButton = screen.getByLabelText('Open sidebar');
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
// Verify menu is open
|
||||
let mobileMenu = container.querySelector('.fixed.inset-y-0.left-0.z-40');
|
||||
expect(mobileMenu).toHaveClass('translate-x-0');
|
||||
|
||||
// Click backdrop
|
||||
const backdrop = container.querySelector('.fixed.inset-0.z-30.bg-black\\/50');
|
||||
fireEvent.click(backdrop!);
|
||||
|
||||
// Menu should be closed
|
||||
mobileMenu = container.querySelector('.fixed.inset-y-0.left-0.z-40');
|
||||
expect(mobileMenu).toHaveClass('-translate-x-full');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sidebar Collapse', () => {
|
||||
it('should toggle sidebar collapse state', () => {
|
||||
renderLayout();
|
||||
|
||||
// Initially not collapsed (both mobile and desktop)
|
||||
const collapsedStates = screen.getAllByTestId('sidebar-collapsed');
|
||||
expect(collapsedStates).toHaveLength(2);
|
||||
collapsedStates.forEach(el => expect(el).toHaveTextContent('false'));
|
||||
|
||||
// Click toggle button
|
||||
const toggleButtons = screen.getAllByTestId('toggle-collapse');
|
||||
expect(toggleButtons.length).toBeGreaterThan(0);
|
||||
fireEvent.click(toggleButtons[1]); // Desktop sidebar
|
||||
|
||||
// Verify button exists and can be clicked
|
||||
expect(toggleButtons[1]).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ticket Modal', () => {
|
||||
it('should not show ticket modal by default', () => {
|
||||
renderLayout();
|
||||
|
||||
expect(screen.queryByTestId('ticket-modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should open ticket modal when notification is clicked', async () => {
|
||||
renderLayout();
|
||||
|
||||
const notificationButton = screen.getByTestId('notification-ticket-btn');
|
||||
fireEvent.click(notificationButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('ticket-modal')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('ticket-id')).toHaveTextContent('ticket-123');
|
||||
});
|
||||
});
|
||||
|
||||
it('should close ticket modal when close button is clicked', async () => {
|
||||
renderLayout();
|
||||
|
||||
// Open modal
|
||||
const notificationButton = screen.getByTestId('notification-ticket-btn');
|
||||
fireEvent.click(notificationButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('ticket-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Close modal
|
||||
const closeButton = screen.getByTestId('close-ticket-modal');
|
||||
fireEvent.click(closeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('ticket-modal')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not render modal if ticket data is not available', () => {
|
||||
const { useTicket } = require('../../hooks/useTickets');
|
||||
useTicket.mockReturnValue({ data: null, isLoading: false, error: null });
|
||||
|
||||
renderLayout();
|
||||
|
||||
const notificationButton = screen.getByTestId('notification-ticket-btn');
|
||||
fireEvent.click(notificationButton);
|
||||
|
||||
expect(screen.queryByTestId('ticket-modal')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation Components', () => {
|
||||
it('should render all navigation components', () => {
|
||||
renderLayout();
|
||||
|
||||
expect(screen.getByTestId('platform-sidebar')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('user-profile-dropdown')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('notification-dropdown')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('language-selector')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render floating help button', () => {
|
||||
renderLayout();
|
||||
|
||||
expect(screen.getByTestId('floating-help-button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Route-specific Padding', () => {
|
||||
it('should apply padding to main content by default', () => {
|
||||
const { container } = renderLayout();
|
||||
|
||||
const mainContent = container.querySelector('main');
|
||||
expect(mainContent).toHaveClass('p-8');
|
||||
});
|
||||
|
||||
it('should not apply padding for API docs route', () => {
|
||||
const mockUseLocation = useLocation as any;
|
||||
mockUseLocation.mockReturnValue({ pathname: '/help/api-docs' });
|
||||
|
||||
const { container } = render(
|
||||
<MemoryRouter initialEntries={['/help/api-docs']}>
|
||||
<PlatformLayout {...defaultProps} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const mainContent = container.querySelector('main');
|
||||
expect(mainContent).not.toHaveClass('p-8');
|
||||
});
|
||||
|
||||
it('should apply padding for other routes', () => {
|
||||
const mockUseLocation = useLocation as any;
|
||||
mockUseLocation.mockReturnValue({ pathname: '/platform/dashboard' });
|
||||
|
||||
const { container } = render(
|
||||
<MemoryRouter initialEntries={['/platform/dashboard']}>
|
||||
<PlatformLayout {...defaultProps} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const mainContent = container.querySelector('main');
|
||||
expect(mainContent).toHaveClass('p-8');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have proper ARIA label for mobile menu button', () => {
|
||||
renderLayout();
|
||||
|
||||
const menuButton = screen.getByLabelText('Open sidebar');
|
||||
expect(menuButton).toHaveAttribute('aria-label', 'Open sidebar');
|
||||
});
|
||||
|
||||
it('should have semantic main element', () => {
|
||||
const { container } = renderLayout();
|
||||
|
||||
const mainElement = container.querySelector('main');
|
||||
expect(mainElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have semantic header element', () => {
|
||||
const { container } = renderLayout();
|
||||
|
||||
const headerElement = container.querySelector('header');
|
||||
expect(headerElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have proper structure for navigation', () => {
|
||||
renderLayout();
|
||||
|
||||
expect(screen.getByTestId('platform-sidebar')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Layout Structure', () => {
|
||||
it('should have correct flex layout classes', () => {
|
||||
const { container } = renderLayout();
|
||||
|
||||
const mainContainer = container.querySelector('.flex.h-screen.bg-gray-100');
|
||||
expect(mainContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have scrollable main content area', () => {
|
||||
const { container } = renderLayout();
|
||||
|
||||
const mainContent = container.querySelector('main');
|
||||
expect(mainContent).toHaveClass('flex-1', 'overflow-auto');
|
||||
});
|
||||
|
||||
it('should have fixed height header', () => {
|
||||
const { container } = renderLayout();
|
||||
|
||||
const header = container.querySelector('header');
|
||||
expect(header).toHaveClass('h-16');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Responsive Design', () => {
|
||||
it('should hide branding text on mobile', () => {
|
||||
const { container } = renderLayout();
|
||||
|
||||
const brandingContainer = screen.getByText('smoothschedule.com').parentElement;
|
||||
expect(brandingContainer).toHaveClass('hidden', 'md:flex');
|
||||
});
|
||||
|
||||
it('should show mobile menu button only on mobile', () => {
|
||||
const { container } = renderLayout();
|
||||
|
||||
const menuButton = screen.getByLabelText('Open sidebar').parentElement;
|
||||
expect(menuButton).toHaveClass('md:hidden');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dark Mode Styling', () => {
|
||||
it('should apply dark mode classes when darkMode is true', () => {
|
||||
const { container } = renderLayout({ darkMode: true });
|
||||
|
||||
const mainContainer = container.querySelector('.dark\\:bg-gray-900');
|
||||
expect(mainContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have light mode classes by default', () => {
|
||||
const { container } = renderLayout({ darkMode: false });
|
||||
|
||||
const header = container.querySelector('header');
|
||||
expect(header).toHaveClass('bg-white');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration Tests', () => {
|
||||
it('should handle complete user flow: open menu, view ticket, close all', async () => {
|
||||
const { container } = renderLayout();
|
||||
|
||||
// 1. Open mobile menu
|
||||
const menuButton = screen.getByLabelText('Open sidebar');
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
let mobileMenu = container.querySelector('.fixed.inset-y-0.left-0.z-40');
|
||||
expect(mobileMenu).toHaveClass('translate-x-0');
|
||||
|
||||
// 2. Close mobile menu via backdrop
|
||||
const backdrop = container.querySelector('.fixed.inset-0.z-30.bg-black\\/50');
|
||||
fireEvent.click(backdrop!);
|
||||
|
||||
mobileMenu = container.querySelector('.fixed.inset-y-0.left-0.z-40');
|
||||
expect(mobileMenu).toHaveClass('-translate-x-full');
|
||||
|
||||
// 3. Open ticket modal
|
||||
const notificationButton = screen.getByTestId('notification-ticket-btn');
|
||||
fireEvent.click(notificationButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('ticket-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 4. Close ticket modal
|
||||
const closeTicketButton = screen.getByTestId('close-ticket-modal');
|
||||
fireEvent.click(closeTicketButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('ticket-modal')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should toggle theme and update icon', () => {
|
||||
const toggleTheme = vi.fn();
|
||||
const { rerender } = render(
|
||||
<MemoryRouter>
|
||||
<PlatformLayout {...defaultProps} darkMode={false} toggleTheme={toggleTheme} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Light mode - Moon icon
|
||||
expect(screen.getByTestId('moon-icon')).toBeInTheDocument();
|
||||
|
||||
// Click toggle
|
||||
const themeButton = screen.getByTestId('moon-icon').closest('button');
|
||||
fireEvent.click(themeButton!);
|
||||
expect(toggleTheme).toHaveBeenCalled();
|
||||
|
||||
// Simulate parent state change to dark mode
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<PlatformLayout {...defaultProps} darkMode={true} toggleTheme={toggleTheme} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Dark mode - Sun icon
|
||||
expect(screen.getByTestId('sun-icon')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle user with minimal data', () => {
|
||||
const minimalUser: User = {
|
||||
id: '999',
|
||||
name: 'Test',
|
||||
email: 'test@test.com',
|
||||
role: 'platform_support',
|
||||
};
|
||||
|
||||
renderLayout({ user: minimalUser });
|
||||
|
||||
expect(screen.getByTestId('profile-user-name')).toHaveTextContent('Test');
|
||||
expect(screen.getByTestId('profile-user-email')).toHaveTextContent('test@test.com');
|
||||
});
|
||||
|
||||
it('should handle undefined ticket ID gracefully', async () => {
|
||||
const { useTicket } = require('../../hooks/useTickets');
|
||||
useTicket.mockImplementation((ticketId: any) => {
|
||||
if (!ticketId || ticketId === 'undefined') {
|
||||
return { data: null, isLoading: false, error: null };
|
||||
}
|
||||
return { data: { id: ticketId }, isLoading: false, error: null };
|
||||
});
|
||||
|
||||
renderLayout();
|
||||
|
||||
// Modal should not appear for undefined ticket
|
||||
expect(screen.queryByTestId('ticket-modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle rapid state changes', () => {
|
||||
const { container, rerender } = render(
|
||||
<MemoryRouter>
|
||||
<PlatformLayout {...defaultProps} darkMode={false} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Toggle dark mode multiple times
|
||||
for (let i = 0; i < 5; i++) {
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<PlatformLayout {...defaultProps} darkMode={i % 2 === 0} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
// Should still render correctly
|
||||
expect(screen.getByTestId('platform-sidebar')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('outlet-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle all platform roles', () => {
|
||||
const roles: Array<User['role']> = ['superuser', 'platform_manager', 'platform_support'];
|
||||
|
||||
roles.forEach((role) => {
|
||||
const roleUser: User = {
|
||||
id: `user-${role}`,
|
||||
name: `${role} User`,
|
||||
email: `${role}@platform.com`,
|
||||
role,
|
||||
};
|
||||
|
||||
const { unmount } = renderLayout({ user: roleUser });
|
||||
expect(screen.getByTestId('profile-user-name')).toHaveTextContent(`${role} User`);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
782
frontend/src/layouts/__tests__/PublicSiteLayout.test.tsx
Normal file
782
frontend/src/layouts/__tests__/PublicSiteLayout.test.tsx
Normal file
@@ -0,0 +1,782 @@
|
||||
/**
|
||||
* Unit tests for PublicSiteLayout component
|
||||
*
|
||||
* Tests cover:
|
||||
* - Rendering children content
|
||||
* - Layout structure (header, main, footer)
|
||||
* - Business branding application (primary color, logo, name)
|
||||
* - Navigation links (website pages, customer login)
|
||||
* - Footer copyright information
|
||||
* - Scroll restoration via useScrollToTop hook
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import React from 'react';
|
||||
import PublicSiteLayout from '../PublicSiteLayout';
|
||||
import { Business } from '../../types';
|
||||
|
||||
// Mock useScrollToTop hook
|
||||
vi.mock('../../hooks/useScrollToTop', () => ({
|
||||
useScrollToTop: vi.fn(),
|
||||
}));
|
||||
|
||||
// Test wrapper with Router
|
||||
const createWrapper = () => {
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<BrowserRouter>{children}</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
// Mock business data factory
|
||||
const createMockBusiness = (overrides?: Partial<Business>): Business => ({
|
||||
id: 'biz-1',
|
||||
name: 'Test Business',
|
||||
subdomain: 'testbiz',
|
||||
primaryColor: '#3B82F6',
|
||||
secondaryColor: '#10B981',
|
||||
logoUrl: undefined,
|
||||
whitelabelEnabled: false,
|
||||
paymentsEnabled: false,
|
||||
requirePaymentMethodToBook: false,
|
||||
cancellationWindowHours: 24,
|
||||
lateCancellationFeePercent: 50,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('PublicSiteLayout', () => {
|
||||
let mockBusiness: Business;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockBusiness = createMockBusiness();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render the layout component', () => {
|
||||
const { container } = render(
|
||||
<PublicSiteLayout business={mockBusiness}>
|
||||
<div>Test Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render children content', () => {
|
||||
render(
|
||||
<PublicSiteLayout business={mockBusiness}>
|
||||
<div>Child Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText('Child Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render multiple children', () => {
|
||||
render(
|
||||
<PublicSiteLayout business={mockBusiness}>
|
||||
<div>First Child</div>
|
||||
<div>Second Child</div>
|
||||
<div>Third Child</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText('First Child')).toBeInTheDocument();
|
||||
expect(screen.getByText('Second Child')).toBeInTheDocument();
|
||||
expect(screen.getByText('Third Child')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Layout Structure', () => {
|
||||
it('should have header, main, and footer sections', () => {
|
||||
const { container } = render(
|
||||
<PublicSiteLayout business={mockBusiness}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const header = container.querySelector('header');
|
||||
const main = container.querySelector('main');
|
||||
const footer = container.querySelector('footer');
|
||||
|
||||
expect(header).toBeInTheDocument();
|
||||
expect(main).toBeInTheDocument();
|
||||
expect(footer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply min-h-screen to container', () => {
|
||||
const { container } = render(
|
||||
<PublicSiteLayout business={mockBusiness}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const rootDiv = container.firstChild as HTMLElement;
|
||||
expect(rootDiv).toHaveClass('min-h-screen');
|
||||
});
|
||||
|
||||
it('should apply background color classes', () => {
|
||||
const { container } = render(
|
||||
<PublicSiteLayout business={mockBusiness}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const rootDiv = container.firstChild as HTMLElement;
|
||||
expect(rootDiv).toHaveClass('bg-gray-50');
|
||||
expect(rootDiv).toHaveClass('dark:bg-gray-900');
|
||||
});
|
||||
|
||||
it('should apply text color classes', () => {
|
||||
const { container } = render(
|
||||
<PublicSiteLayout business={mockBusiness}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const rootDiv = container.firstChild as HTMLElement;
|
||||
expect(rootDiv).toHaveClass('text-gray-900');
|
||||
expect(rootDiv).toHaveClass('dark:text-white');
|
||||
});
|
||||
|
||||
it('should render main content in a container with padding', () => {
|
||||
const { container } = render(
|
||||
<PublicSiteLayout business={mockBusiness}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const main = container.querySelector('main');
|
||||
expect(main).toHaveClass('container');
|
||||
expect(main).toHaveClass('mx-auto');
|
||||
expect(main).toHaveClass('px-4');
|
||||
expect(main).toHaveClass('py-12');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Business Branding', () => {
|
||||
describe('Header Styling', () => {
|
||||
it('should apply business primary color to header background', () => {
|
||||
const business = createMockBusiness({ primaryColor: '#FF5733' });
|
||||
const { container } = render(
|
||||
<PublicSiteLayout business={business}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const header = container.querySelector('header');
|
||||
expect(header).toHaveStyle({ backgroundColor: '#FF5733' });
|
||||
});
|
||||
|
||||
it('should apply shadow to header', () => {
|
||||
const { container } = render(
|
||||
<PublicSiteLayout business={mockBusiness}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const header = container.querySelector('header');
|
||||
expect(header).toHaveClass('shadow-md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Business Logo/Name Display', () => {
|
||||
it('should display business name', () => {
|
||||
const business = createMockBusiness({ name: 'Acme Corp' });
|
||||
render(
|
||||
<PublicSiteLayout business={business}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText('Acme Corp')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display business name initials in logo placeholder', () => {
|
||||
const business = createMockBusiness({ name: 'Test Business' });
|
||||
render(
|
||||
<PublicSiteLayout business={business}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText('TE')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should capitalize initials', () => {
|
||||
const business = createMockBusiness({ name: 'acme corp' });
|
||||
render(
|
||||
<PublicSiteLayout business={business}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText('AC')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply primary color to logo placeholder text', () => {
|
||||
const business = createMockBusiness({
|
||||
name: 'Test Business',
|
||||
primaryColor: '#FF0000',
|
||||
});
|
||||
const { container } = render(
|
||||
<PublicSiteLayout business={business}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// The text "TE" is inside a div, not its parent
|
||||
const logoPlaceholder = screen.getByText('TE');
|
||||
expect(logoPlaceholder).toHaveStyle({ color: 'rgb(255, 0, 0)' });
|
||||
});
|
||||
|
||||
it('should have white background for logo placeholder', () => {
|
||||
render(
|
||||
<PublicSiteLayout business={mockBusiness}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// The text "TE" is inside the div that has bg-white
|
||||
const logoPlaceholder = screen.getByText('TE');
|
||||
expect(logoPlaceholder).toHaveClass('bg-white');
|
||||
});
|
||||
|
||||
it('should style business name in header', () => {
|
||||
render(
|
||||
<PublicSiteLayout business={mockBusiness}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const businessName = screen.getByText('Test Business');
|
||||
expect(businessName).toHaveClass('font-bold');
|
||||
expect(businessName).toHaveClass('text-xl');
|
||||
expect(businessName).toHaveClass('text-white');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Different Primary Colors', () => {
|
||||
it('should work with hex color', () => {
|
||||
const business = createMockBusiness({ primaryColor: '#3B82F6' });
|
||||
const { container } = render(
|
||||
<PublicSiteLayout business={business}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const header = container.querySelector('header');
|
||||
expect(header).toHaveStyle({ backgroundColor: '#3B82F6' });
|
||||
});
|
||||
|
||||
it('should work with rgb color', () => {
|
||||
const business = createMockBusiness({ primaryColor: 'rgb(59, 130, 246)' });
|
||||
const { container } = render(
|
||||
<PublicSiteLayout business={business}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const header = container.querySelector('header');
|
||||
expect(header).toHaveStyle({ backgroundColor: 'rgb(59, 130, 246)' });
|
||||
});
|
||||
|
||||
it('should work with named color', () => {
|
||||
const business = createMockBusiness({ primaryColor: 'blue' });
|
||||
const { container } = render(
|
||||
<PublicSiteLayout business={business}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const header = container.querySelector('header');
|
||||
// Browsers may convert 'blue' to rgb format
|
||||
const style = header?.getAttribute('style');
|
||||
expect(style).toContain('blue');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation', () => {
|
||||
describe('Website Pages Navigation', () => {
|
||||
it('should render website page links when websitePages exist', () => {
|
||||
const business = createMockBusiness({
|
||||
websitePages: {
|
||||
'/about': { name: 'About', content: [] },
|
||||
'/services': { name: 'Services', content: [] },
|
||||
'/contact': { name: 'Contact', content: [] },
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<PublicSiteLayout business={business}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByRole('link', { name: 'About' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Services' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Contact' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should link to correct paths', () => {
|
||||
const business = createMockBusiness({
|
||||
websitePages: {
|
||||
'/about': { name: 'About', content: [] },
|
||||
'/services': { name: 'Services', content: [] },
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<PublicSiteLayout business={business}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const aboutLink = screen.getByRole('link', { name: 'About' });
|
||||
const servicesLink = screen.getByRole('link', { name: 'Services' });
|
||||
|
||||
expect(aboutLink).toHaveAttribute('href', '/about');
|
||||
expect(servicesLink).toHaveAttribute('href', '/services');
|
||||
});
|
||||
|
||||
it('should not render page links when websitePages is undefined', () => {
|
||||
const business = createMockBusiness({
|
||||
websitePages: undefined,
|
||||
});
|
||||
|
||||
render(
|
||||
<PublicSiteLayout business={business}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Only customer login link should exist
|
||||
const links = screen.getAllByRole('link');
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0]).toHaveTextContent('Customer Login');
|
||||
});
|
||||
|
||||
it('should not render page links when websitePages is empty', () => {
|
||||
const business = createMockBusiness({
|
||||
websitePages: {},
|
||||
});
|
||||
|
||||
render(
|
||||
<PublicSiteLayout business={business}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Only customer login link should exist
|
||||
const links = screen.getAllByRole('link');
|
||||
expect(links).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should style navigation links with white text', () => {
|
||||
const business = createMockBusiness({
|
||||
websitePages: {
|
||||
'/about': { name: 'About', content: [] },
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<PublicSiteLayout business={business}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const aboutLink = screen.getByRole('link', { name: 'About' });
|
||||
expect(aboutLink).toHaveClass('text-white/80');
|
||||
expect(aboutLink).toHaveClass('hover:text-white');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Customer Login Link', () => {
|
||||
it('should render customer login link', () => {
|
||||
render(
|
||||
<PublicSiteLayout business={mockBusiness}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const loginLink = screen.getByRole('link', { name: /customer login/i });
|
||||
expect(loginLink).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should link to portal dashboard', () => {
|
||||
render(
|
||||
<PublicSiteLayout business={mockBusiness}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const loginLink = screen.getByRole('link', { name: /customer login/i });
|
||||
expect(loginLink).toHaveAttribute('href', '/portal/dashboard');
|
||||
});
|
||||
|
||||
it('should style customer login as a button', () => {
|
||||
render(
|
||||
<PublicSiteLayout business={mockBusiness}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const loginLink = screen.getByRole('link', { name: /customer login/i });
|
||||
expect(loginLink).toHaveClass('px-4');
|
||||
expect(loginLink).toHaveClass('py-2');
|
||||
expect(loginLink).toHaveClass('bg-white/20');
|
||||
expect(loginLink).toHaveClass('text-white');
|
||||
expect(loginLink).toHaveClass('rounded-lg');
|
||||
expect(loginLink).toHaveClass('hover:bg-white/30');
|
||||
});
|
||||
|
||||
it('should render login link even without website pages', () => {
|
||||
const business = createMockBusiness({
|
||||
websitePages: undefined,
|
||||
});
|
||||
|
||||
render(
|
||||
<PublicSiteLayout business={business}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const loginLink = screen.getByRole('link', { name: /customer login/i });
|
||||
expect(loginLink).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation Container', () => {
|
||||
it('should render navigation in a flex container', () => {
|
||||
const { container } = render(
|
||||
<PublicSiteLayout business={mockBusiness}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const nav = container.querySelector('nav');
|
||||
expect(nav).toHaveClass('flex');
|
||||
expect(nav).toHaveClass('items-center');
|
||||
expect(nav).toHaveClass('gap-4');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Footer', () => {
|
||||
it('should render footer element', () => {
|
||||
const { container } = render(
|
||||
<PublicSiteLayout business={mockBusiness}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const footer = container.querySelector('footer');
|
||||
expect(footer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display copyright with current year', () => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
render(
|
||||
<PublicSiteLayout business={mockBusiness}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText(new RegExp(currentYear.toString()))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display business name in copyright', () => {
|
||||
const business = createMockBusiness({ name: 'Acme Corp' });
|
||||
render(
|
||||
<PublicSiteLayout business={business}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Business name appears in both header and footer, use getAllByText
|
||||
const matches = screen.getAllByText(/Acme Corp/);
|
||||
expect(matches.length).toBeGreaterThan(0);
|
||||
// Check specifically in the footer by looking for copyright text
|
||||
expect(screen.getByText(/© .* Acme Corp. All Rights Reserved./)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display full copyright text', () => {
|
||||
const business = createMockBusiness({ name: 'Test Business' });
|
||||
const currentYear = new Date().getFullYear();
|
||||
render(
|
||||
<PublicSiteLayout business={business}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(`© ${currentYear} Test Business. All Rights Reserved.`)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should style footer with background colors', () => {
|
||||
const { container } = render(
|
||||
<PublicSiteLayout business={mockBusiness}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const footer = container.querySelector('footer');
|
||||
expect(footer).toHaveClass('bg-gray-100');
|
||||
expect(footer).toHaveClass('dark:bg-gray-800');
|
||||
});
|
||||
|
||||
it('should apply padding to footer', () => {
|
||||
const { container } = render(
|
||||
<PublicSiteLayout business={mockBusiness}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const footer = container.querySelector('footer');
|
||||
expect(footer).toHaveClass('py-6');
|
||||
expect(footer).toHaveClass('mt-12');
|
||||
});
|
||||
|
||||
it('should center footer text', () => {
|
||||
const { container } = render(
|
||||
<PublicSiteLayout business={mockBusiness}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const footer = container.querySelector('footer');
|
||||
const footerContent = footer?.querySelector('div');
|
||||
expect(footerContent).toHaveClass('text-center');
|
||||
expect(footerContent).toHaveClass('text-sm');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scroll Behavior', () => {
|
||||
it('should call useScrollToTop hook', async () => {
|
||||
// Get the mocked module
|
||||
const { useScrollToTop: mockUseScrollToTop } = await import('../../hooks/useScrollToTop');
|
||||
|
||||
render(
|
||||
<PublicSiteLayout business={mockBusiness}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(mockUseScrollToTop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call useScrollToTop on each render', async () => {
|
||||
// Get the mocked module
|
||||
const { useScrollToTop: mockUseScrollToTop } = await import('../../hooks/useScrollToTop');
|
||||
|
||||
// Clear previous calls
|
||||
vi.mocked(mockUseScrollToTop).mockClear();
|
||||
|
||||
const { rerender } = render(
|
||||
<PublicSiteLayout business={mockBusiness}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(mockUseScrollToTop).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(
|
||||
<PublicSiteLayout business={mockBusiness}>
|
||||
<div>New Content</div>
|
||||
</PublicSiteLayout>
|
||||
);
|
||||
|
||||
expect(mockUseScrollToTop).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration', () => {
|
||||
it('should render complete layout with all sections', () => {
|
||||
const business = createMockBusiness({
|
||||
name: 'Complete Business',
|
||||
primaryColor: '#3B82F6',
|
||||
websitePages: {
|
||||
'/about': { name: 'About', content: [] },
|
||||
'/services': { name: 'Services', content: [] },
|
||||
},
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<PublicSiteLayout business={business}>
|
||||
<h1>Main Content</h1>
|
||||
<p>This is the main content</p>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Header exists with branding
|
||||
const header = container.querySelector('header');
|
||||
expect(header).toBeInTheDocument();
|
||||
expect(screen.getByText('Complete Business')).toBeInTheDocument();
|
||||
expect(screen.getByText('CO')).toBeInTheDocument();
|
||||
|
||||
// Navigation links exist
|
||||
expect(screen.getByRole('link', { name: 'About' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Services' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /customer login/i })).toBeInTheDocument();
|
||||
|
||||
// Main content exists
|
||||
expect(screen.getByText('Main Content')).toBeInTheDocument();
|
||||
expect(screen.getByText('This is the main content')).toBeInTheDocument();
|
||||
|
||||
// Footer exists with copyright
|
||||
const currentYear = new Date().getFullYear();
|
||||
expect(
|
||||
screen.getByText(`© ${currentYear} Complete Business. All Rights Reserved.`)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle different business configurations', () => {
|
||||
const business1 = createMockBusiness({
|
||||
name: 'Business A',
|
||||
primaryColor: '#FF0000',
|
||||
websitePages: undefined,
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<PublicSiteLayout business={business1}>
|
||||
<div>Content A</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText('Business A')).toBeInTheDocument();
|
||||
expect(screen.getByText('Content A')).toBeInTheDocument();
|
||||
|
||||
const business2 = createMockBusiness({
|
||||
name: 'Business B',
|
||||
primaryColor: '#00FF00',
|
||||
websitePages: {
|
||||
'/home': { name: 'Home', content: [] },
|
||||
},
|
||||
});
|
||||
|
||||
rerender(
|
||||
<PublicSiteLayout business={business2}>
|
||||
<div>Content B</div>
|
||||
</PublicSiteLayout>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Business B')).toBeInTheDocument();
|
||||
expect(screen.getByText('Content B')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Home' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle business with single character name', () => {
|
||||
const business = createMockBusiness({ name: 'X' });
|
||||
render(
|
||||
<PublicSiteLayout business={business}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Single character name appears in both header and footer
|
||||
const matches = screen.getAllByText('X');
|
||||
expect(matches.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle business with very long name', () => {
|
||||
const business = createMockBusiness({
|
||||
name: 'Very Long Business Name That Should Still Work Properly',
|
||||
});
|
||||
render(
|
||||
<PublicSiteLayout business={business}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText('Very Long Business Name That Should Still Work Properly')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('VE')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle empty children', () => {
|
||||
render(
|
||||
<PublicSiteLayout business={mockBusiness}>
|
||||
{null}
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const main = screen.getByRole('main');
|
||||
expect(main).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle many website pages', () => {
|
||||
const business = createMockBusiness({
|
||||
websitePages: {
|
||||
'/page1': { name: 'Page 1', content: [] },
|
||||
'/page2': { name: 'Page 2', content: [] },
|
||||
'/page3': { name: 'Page 3', content: [] },
|
||||
'/page4': { name: 'Page 4', content: [] },
|
||||
'/page5': { name: 'Page 5', content: [] },
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<PublicSiteLayout business={business}>
|
||||
<div>Content</div>
|
||||
</PublicSiteLayout>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Page 1' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Page 2' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Page 3' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Page 4' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Page 5' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
650
frontend/src/layouts/__tests__/SettingsLayout.test.tsx
Normal file
650
frontend/src/layouts/__tests__/SettingsLayout.test.tsx
Normal file
@@ -0,0 +1,650 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import SettingsLayout from '../SettingsLayout';
|
||||
import { Business, User, PlanPermissions } from '../../types';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'settings.backToApp': 'Back to App',
|
||||
'settings.title': 'Settings',
|
||||
'settings.sections.business': 'Business',
|
||||
'settings.sections.branding': 'Branding',
|
||||
'settings.sections.integrations': 'Integrations',
|
||||
'settings.sections.access': 'Access',
|
||||
'settings.sections.communication': 'Communication',
|
||||
'settings.sections.billing': 'Billing',
|
||||
'settings.general.title': 'General',
|
||||
'settings.general.description': 'Name, timezone, contact',
|
||||
'settings.resourceTypes.title': 'Resource Types',
|
||||
'settings.resourceTypes.description': 'Staff, rooms, equipment',
|
||||
'settings.booking.title': 'Booking',
|
||||
'settings.booking.description': 'Booking URL, redirects',
|
||||
'settings.appearance.title': 'Appearance',
|
||||
'settings.appearance.description': 'Logo, colors, theme',
|
||||
'settings.emailTemplates.title': 'Email Templates',
|
||||
'settings.emailTemplates.description': 'Customize email designs',
|
||||
'settings.customDomains.title': 'Custom Domains',
|
||||
'settings.customDomains.description': 'Use your own domain',
|
||||
'settings.api.title': 'API & Webhooks',
|
||||
'settings.api.description': 'API tokens, webhooks',
|
||||
'settings.authentication.title': 'Authentication',
|
||||
'settings.authentication.description': 'OAuth, social login',
|
||||
'settings.email.title': 'Email Setup',
|
||||
'settings.email.description': 'Email addresses for tickets',
|
||||
'settings.smsCalling.title': 'SMS & Calling',
|
||||
'settings.smsCalling.description': 'Credits, phone numbers',
|
||||
'settings.billing.title': 'Plan & Billing',
|
||||
'settings.billing.description': 'Subscription, invoices',
|
||||
'settings.quota.title': 'Quota Management',
|
||||
'settings.quota.description': 'Usage limits, archiving',
|
||||
};
|
||||
return translations[key] || fallback || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock('lucide-react', () => ({
|
||||
ArrowLeft: ({ size }: { size: number }) => <svg data-testid="arrow-left-icon" width={size} height={size} />,
|
||||
Building2: ({ size }: { size: number }) => <svg data-testid="building2-icon" width={size} height={size} />,
|
||||
Palette: ({ size }: { size: number }) => <svg data-testid="palette-icon" width={size} height={size} />,
|
||||
Layers: ({ size }: { size: number }) => <svg data-testid="layers-icon" width={size} height={size} />,
|
||||
Globe: ({ size }: { size: number }) => <svg data-testid="globe-icon" width={size} height={size} />,
|
||||
Key: ({ size }: { size: number }) => <svg data-testid="key-icon" width={size} height={size} />,
|
||||
Lock: ({ size }: { size: number }) => <svg data-testid="lock-icon" width={size} height={size} />,
|
||||
Mail: ({ size }: { size: number }) => <svg data-testid="mail-icon" width={size} height={size} />,
|
||||
Phone: ({ size }: { size: number }) => <svg data-testid="phone-icon" width={size} height={size} />,
|
||||
CreditCard: ({ size }: { size: number }) => <svg data-testid="credit-card-icon" width={size} height={size} />,
|
||||
AlertTriangle: ({ size }: { size: number }) => <svg data-testid="alert-triangle-icon" width={size} height={size} />,
|
||||
Calendar: ({ size }: { size: number }) => <svg data-testid="calendar-icon" width={size} height={size} />,
|
||||
}));
|
||||
|
||||
// Mock usePlanFeatures hook
|
||||
const mockCanUse = vi.fn();
|
||||
vi.mock('../../hooks/usePlanFeatures', () => ({
|
||||
usePlanFeatures: () => ({
|
||||
canUse: mockCanUse,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('SettingsLayout', () => {
|
||||
const mockUser: User = {
|
||||
id: '1',
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
role: 'owner',
|
||||
};
|
||||
|
||||
const mockBusiness: Business = {
|
||||
id: 'business-1',
|
||||
name: 'Test Business',
|
||||
subdomain: 'testbiz',
|
||||
primaryColor: '#3B82F6',
|
||||
secondaryColor: '#10B981',
|
||||
whitelabelEnabled: false,
|
||||
plan: 'Professional',
|
||||
paymentsEnabled: false,
|
||||
requirePaymentMethodToBook: false,
|
||||
cancellationWindowHours: 24,
|
||||
lateCancellationFeePercent: 0,
|
||||
};
|
||||
|
||||
const mockUpdateBusiness = vi.fn();
|
||||
|
||||
const mockOutletContext = {
|
||||
user: mockUser,
|
||||
business: mockBusiness,
|
||||
updateBusiness: mockUpdateBusiness,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default: all features are unlocked
|
||||
mockCanUse.mockReturnValue(true);
|
||||
});
|
||||
|
||||
const renderWithRouter = (initialPath = '/settings/general') => {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<Routes>
|
||||
<Route path="/settings/*" element={<SettingsLayout />}>
|
||||
<Route path="general" element={<div>General Settings Content</div>} />
|
||||
<Route path="branding" element={<div>Branding Settings Content</div>} />
|
||||
<Route path="api" element={<div>API Settings Content</div>} />
|
||||
<Route path="billing" element={<div>Billing Settings Content</div>} />
|
||||
</Route>
|
||||
<Route path="/" element={<div>Home Page</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders the layout with sidebar and content area', () => {
|
||||
renderWithRouter();
|
||||
|
||||
// Check for sidebar
|
||||
const sidebar = screen.getByRole('complementary');
|
||||
expect(sidebar).toBeInTheDocument();
|
||||
expect(sidebar).toHaveClass('w-64', 'bg-white');
|
||||
|
||||
// Check for main content area
|
||||
const main = screen.getByRole('main');
|
||||
expect(main).toBeInTheDocument();
|
||||
expect(main).toHaveClass('flex-1', 'overflow-y-auto');
|
||||
});
|
||||
|
||||
it('renders the Settings title', () => {
|
||||
renderWithRouter();
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all section headings', () => {
|
||||
renderWithRouter();
|
||||
expect(screen.getByText('Business')).toBeInTheDocument();
|
||||
expect(screen.getByText('Branding')).toBeInTheDocument();
|
||||
expect(screen.getByText('Integrations')).toBeInTheDocument();
|
||||
expect(screen.getByText('Access')).toBeInTheDocument();
|
||||
expect(screen.getByText('Communication')).toBeInTheDocument();
|
||||
expect(screen.getByText('Billing')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders children content from Outlet', () => {
|
||||
renderWithRouter('/settings/general');
|
||||
expect(screen.getByText('General Settings Content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Back Button', () => {
|
||||
it('renders the back button', () => {
|
||||
renderWithRouter();
|
||||
const backButton = screen.getByRole('button', { name: /back to app/i });
|
||||
expect(backButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays ArrowLeft icon in back button', () => {
|
||||
renderWithRouter();
|
||||
expect(screen.getByTestId('arrow-left-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates to home when back button is clicked', () => {
|
||||
renderWithRouter('/settings/general');
|
||||
const backButton = screen.getByRole('button', { name: /back to app/i });
|
||||
fireEvent.click(backButton);
|
||||
|
||||
// Should navigate to home
|
||||
expect(screen.getByText('Home Page')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has correct styling for back button', () => {
|
||||
renderWithRouter();
|
||||
const backButton = screen.getByRole('button', { name: /back to app/i });
|
||||
expect(backButton).toHaveClass('text-gray-600', 'hover:text-gray-900', 'transition-colors');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation Links', () => {
|
||||
describe('Business Section', () => {
|
||||
it('renders General settings link', () => {
|
||||
renderWithRouter();
|
||||
const generalLink = screen.getByRole('link', { name: /General/i });
|
||||
expect(generalLink).toBeInTheDocument();
|
||||
expect(generalLink).toHaveAttribute('href', '/settings/general');
|
||||
});
|
||||
|
||||
it('renders Resource Types settings link', () => {
|
||||
renderWithRouter();
|
||||
const resourceTypesLink = screen.getByRole('link', { name: /Resource Types/i });
|
||||
expect(resourceTypesLink).toBeInTheDocument();
|
||||
expect(resourceTypesLink).toHaveAttribute('href', '/settings/resource-types');
|
||||
});
|
||||
|
||||
it('renders Booking settings link', () => {
|
||||
renderWithRouter();
|
||||
const bookingLink = screen.getByRole('link', { name: /Booking/i });
|
||||
expect(bookingLink).toBeInTheDocument();
|
||||
expect(bookingLink).toHaveAttribute('href', '/settings/booking');
|
||||
});
|
||||
|
||||
it('displays icons for Business section links', () => {
|
||||
renderWithRouter();
|
||||
expect(screen.getByTestId('building2-icon')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('layers-icon')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('calendar-icon')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Branding Section', () => {
|
||||
it('renders Appearance settings link', () => {
|
||||
renderWithRouter();
|
||||
const appearanceLink = screen.getByRole('link', { name: /Appearance/i });
|
||||
expect(appearanceLink).toBeInTheDocument();
|
||||
expect(appearanceLink).toHaveAttribute('href', '/settings/branding');
|
||||
});
|
||||
|
||||
it('renders Email Templates settings link', () => {
|
||||
renderWithRouter();
|
||||
const emailTemplatesLink = screen.getByRole('link', { name: /Email Templates/i });
|
||||
expect(emailTemplatesLink).toBeInTheDocument();
|
||||
expect(emailTemplatesLink).toHaveAttribute('href', '/settings/email-templates');
|
||||
});
|
||||
|
||||
it('renders Custom Domains settings link', () => {
|
||||
renderWithRouter();
|
||||
const customDomainsLink = screen.getByRole('link', { name: /Custom Domains/i });
|
||||
expect(customDomainsLink).toBeInTheDocument();
|
||||
expect(customDomainsLink).toHaveAttribute('href', '/settings/custom-domains');
|
||||
});
|
||||
|
||||
it('displays icons for Branding section links', () => {
|
||||
renderWithRouter();
|
||||
expect(screen.getByTestId('palette-icon')).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId('mail-icon').length).toBeGreaterThan(0);
|
||||
expect(screen.getByTestId('globe-icon')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integrations Section', () => {
|
||||
it('renders API & Webhooks settings link', () => {
|
||||
renderWithRouter();
|
||||
const apiLink = screen.getByRole('link', { name: /API & Webhooks/i });
|
||||
expect(apiLink).toBeInTheDocument();
|
||||
expect(apiLink).toHaveAttribute('href', '/settings/api');
|
||||
});
|
||||
|
||||
it('displays Key icon for API link', () => {
|
||||
renderWithRouter();
|
||||
expect(screen.getByTestId('key-icon')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Access Section', () => {
|
||||
it('renders Authentication settings link', () => {
|
||||
renderWithRouter();
|
||||
const authLink = screen.getByRole('link', { name: /Authentication/i });
|
||||
expect(authLink).toBeInTheDocument();
|
||||
expect(authLink).toHaveAttribute('href', '/settings/authentication');
|
||||
});
|
||||
|
||||
it('displays Lock icon for Authentication link', () => {
|
||||
renderWithRouter();
|
||||
expect(screen.getAllByTestId('lock-icon').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Communication Section', () => {
|
||||
it('renders Email Setup settings link', () => {
|
||||
renderWithRouter();
|
||||
const emailSetupLink = screen.getByRole('link', { name: /Email Setup/i });
|
||||
expect(emailSetupLink).toBeInTheDocument();
|
||||
expect(emailSetupLink).toHaveAttribute('href', '/settings/email');
|
||||
});
|
||||
|
||||
it('renders SMS & Calling settings link', () => {
|
||||
renderWithRouter();
|
||||
const smsLink = screen.getByRole('link', { name: /SMS & Calling/i });
|
||||
expect(smsLink).toBeInTheDocument();
|
||||
expect(smsLink).toHaveAttribute('href', '/settings/sms-calling');
|
||||
});
|
||||
|
||||
it('displays Phone icon for SMS & Calling link', () => {
|
||||
renderWithRouter();
|
||||
expect(screen.getByTestId('phone-icon')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Billing Section', () => {
|
||||
it('renders Plan & Billing settings link', () => {
|
||||
renderWithRouter();
|
||||
const billingLink = screen.getByRole('link', { name: /Plan & Billing/i });
|
||||
expect(billingLink).toBeInTheDocument();
|
||||
expect(billingLink).toHaveAttribute('href', '/settings/billing');
|
||||
});
|
||||
|
||||
it('renders Quota Management settings link', () => {
|
||||
renderWithRouter();
|
||||
const quotaLink = screen.getByRole('link', { name: /Quota Management/i });
|
||||
expect(quotaLink).toBeInTheDocument();
|
||||
expect(quotaLink).toHaveAttribute('href', '/settings/quota');
|
||||
});
|
||||
|
||||
it('displays icons for Billing section links', () => {
|
||||
renderWithRouter();
|
||||
expect(screen.getByTestId('credit-card-icon')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('alert-triangle-icon')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Active Section Highlighting', () => {
|
||||
it('highlights the General link when on /settings/general', () => {
|
||||
renderWithRouter('/settings/general');
|
||||
const generalLink = screen.getByRole('link', { name: /General/i });
|
||||
expect(generalLink).toHaveClass('bg-brand-50', 'text-brand-700');
|
||||
});
|
||||
|
||||
it('highlights the Branding link when on /settings/branding', () => {
|
||||
renderWithRouter('/settings/branding');
|
||||
const brandingLink = screen.getByRole('link', { name: /Appearance/i });
|
||||
expect(brandingLink).toHaveClass('bg-brand-50', 'text-brand-700');
|
||||
});
|
||||
|
||||
it('highlights the API link when on /settings/api', () => {
|
||||
renderWithRouter('/settings/api');
|
||||
const apiLink = screen.getByRole('link', { name: /API & Webhooks/i });
|
||||
expect(apiLink).toHaveClass('bg-brand-50', 'text-brand-700');
|
||||
});
|
||||
|
||||
it('highlights the Billing link when on /settings/billing', () => {
|
||||
renderWithRouter('/settings/billing');
|
||||
const billingLink = screen.getByRole('link', { name: /Plan & Billing/i });
|
||||
expect(billingLink).toHaveClass('bg-brand-50', 'text-brand-700');
|
||||
});
|
||||
|
||||
it('does not highlight links when on different pages', () => {
|
||||
renderWithRouter('/settings/general');
|
||||
const brandingLink = screen.getByRole('link', { name: /Appearance/i });
|
||||
expect(brandingLink).not.toHaveClass('bg-brand-50', 'text-brand-700');
|
||||
expect(brandingLink).toHaveClass('text-gray-600');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Locked Features', () => {
|
||||
beforeEach(() => {
|
||||
// Reset mock for locked feature tests
|
||||
mockCanUse.mockImplementation((feature: string) => {
|
||||
// Lock specific features
|
||||
if (feature === 'white_label') return false;
|
||||
if (feature === 'custom_domain') return false;
|
||||
if (feature === 'api_access') return false;
|
||||
if (feature === 'custom_oauth') return false;
|
||||
if (feature === 'sms_reminders') return false;
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
it('shows lock icon for Appearance link when white_label is locked', () => {
|
||||
renderWithRouter();
|
||||
const appearanceLink = screen.getByRole('link', { name: /Appearance/i });
|
||||
const lockIcons = within(appearanceLink).queryAllByTestId('lock-icon');
|
||||
expect(lockIcons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows lock icon for Custom Domains link when custom_domain is locked', () => {
|
||||
renderWithRouter();
|
||||
const customDomainsLink = screen.getByRole('link', { name: /Custom Domains/i });
|
||||
const lockIcons = within(customDomainsLink).queryAllByTestId('lock-icon');
|
||||
expect(lockIcons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows lock icon for API link when api_access is locked', () => {
|
||||
renderWithRouter();
|
||||
const apiLink = screen.getByRole('link', { name: /API & Webhooks/i });
|
||||
const lockIcons = within(apiLink).queryAllByTestId('lock-icon');
|
||||
expect(lockIcons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows lock icon for Authentication link when custom_oauth is locked', () => {
|
||||
renderWithRouter();
|
||||
const authLink = screen.getByRole('link', { name: /Authentication/i });
|
||||
const lockIcons = within(authLink).queryAllByTestId('lock-icon');
|
||||
expect(lockIcons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows lock icon for SMS & Calling link when sms_reminders is locked', () => {
|
||||
renderWithRouter();
|
||||
const smsLink = screen.getByRole('link', { name: /SMS & Calling/i });
|
||||
const lockIcons = within(smsLink).queryAllByTestId('lock-icon');
|
||||
expect(lockIcons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('applies locked styling to locked links', () => {
|
||||
renderWithRouter();
|
||||
const appearanceLink = screen.getByRole('link', { name: /Appearance/i });
|
||||
expect(appearanceLink).toHaveClass('text-gray-400');
|
||||
});
|
||||
|
||||
it('does not show lock icon for unlocked features', () => {
|
||||
// Reset to all unlocked
|
||||
mockCanUse.mockReturnValue(true);
|
||||
renderWithRouter();
|
||||
|
||||
const generalLink = screen.getByRole('link', { name: /General/i });
|
||||
const lockIcons = within(generalLink).queryAllByTestId('lock-icon');
|
||||
expect(lockIcons.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Outlet Context', () => {
|
||||
it('passes parent context to child routes', () => {
|
||||
const ChildComponent = () => {
|
||||
const context = require('react-router-dom').useOutletContext();
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="user-name">{context.user?.name}</div>
|
||||
<div data-testid="business-name">{context.business?.name}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/settings/general']}>
|
||||
<Routes>
|
||||
<Route path="/settings/*" element={<SettingsLayout />}>
|
||||
<Route path="general" element={<ChildComponent />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('user-name')).toHaveTextContent('John Doe');
|
||||
expect(screen.getByTestId('business-name')).toHaveTextContent('Test Business');
|
||||
});
|
||||
|
||||
it('passes isFeatureLocked to child routes when feature is locked', () => {
|
||||
mockCanUse.mockImplementation((feature: string) => {
|
||||
return feature !== 'white_label';
|
||||
});
|
||||
|
||||
const ChildComponent = () => {
|
||||
const context = require('react-router-dom').useOutletContext();
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="is-locked">{String(context.isFeatureLocked)}</div>
|
||||
<div data-testid="locked-feature">{context.lockedFeature || 'none'}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/settings/branding']}>
|
||||
<Routes>
|
||||
<Route path="/settings/*" element={<SettingsLayout />}>
|
||||
<Route path="branding" element={<ChildComponent />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('is-locked')).toHaveTextContent('true');
|
||||
expect(screen.getByTestId('locked-feature')).toHaveTextContent('white_label');
|
||||
});
|
||||
|
||||
it('passes isFeatureLocked as false when feature is unlocked', () => {
|
||||
mockCanUse.mockReturnValue(true);
|
||||
|
||||
const ChildComponent = () => {
|
||||
const context = require('react-router-dom').useOutletContext();
|
||||
return <div data-testid="is-locked">{String(context.isFeatureLocked)}</div>;
|
||||
};
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/settings/general']}>
|
||||
<Routes>
|
||||
<Route path="/settings/*" element={<SettingsLayout />}>
|
||||
<Route path="general" element={<ChildComponent />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('is-locked')).toHaveTextContent('false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Layout Structure', () => {
|
||||
it('has proper flexbox layout', () => {
|
||||
renderWithRouter();
|
||||
const layout = screen.getByRole('complementary').parentElement;
|
||||
expect(layout).toHaveClass('flex', 'h-full');
|
||||
});
|
||||
|
||||
it('sidebar has correct width and styling', () => {
|
||||
renderWithRouter();
|
||||
const sidebar = screen.getByRole('complementary');
|
||||
expect(sidebar).toHaveClass('w-64', 'bg-white', 'border-r', 'flex', 'flex-col', 'shrink-0');
|
||||
});
|
||||
|
||||
it('main content area has proper overflow handling', () => {
|
||||
renderWithRouter();
|
||||
const main = screen.getByRole('main');
|
||||
expect(main).toHaveClass('flex-1', 'overflow-y-auto');
|
||||
});
|
||||
|
||||
it('content is constrained with max-width', () => {
|
||||
renderWithRouter();
|
||||
const contentWrapper = screen.getByText('General Settings Content').parentElement;
|
||||
expect(contentWrapper).toHaveClass('max-w-4xl', 'mx-auto', 'p-8');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Link Descriptions', () => {
|
||||
it('displays description for General settings', () => {
|
||||
renderWithRouter();
|
||||
expect(screen.getByText('Name, timezone, contact')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays description for Resource Types settings', () => {
|
||||
renderWithRouter();
|
||||
expect(screen.getByText('Staff, rooms, equipment')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays description for Appearance settings', () => {
|
||||
renderWithRouter();
|
||||
expect(screen.getByText('Logo, colors, theme')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays description for API settings', () => {
|
||||
renderWithRouter();
|
||||
expect(screen.getByText('API tokens, webhooks')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays description for Billing settings', () => {
|
||||
renderWithRouter();
|
||||
expect(screen.getByText('Subscription, invoices')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dark Mode Support', () => {
|
||||
it('has dark mode classes on sidebar', () => {
|
||||
renderWithRouter();
|
||||
const sidebar = screen.getByRole('complementary');
|
||||
expect(sidebar).toHaveClass('dark:bg-gray-800', 'dark:border-gray-700');
|
||||
});
|
||||
|
||||
it('has dark mode classes on Settings title', () => {
|
||||
renderWithRouter();
|
||||
const title = screen.getByText('Settings');
|
||||
expect(title).toHaveClass('dark:text-white');
|
||||
});
|
||||
|
||||
it('has dark mode classes on layout background', () => {
|
||||
renderWithRouter();
|
||||
const layout = screen.getByRole('complementary').parentElement;
|
||||
expect(layout).toHaveClass('dark:bg-gray-900');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('uses semantic HTML with aside element', () => {
|
||||
renderWithRouter();
|
||||
const sidebar = screen.getByRole('complementary');
|
||||
expect(sidebar.tagName).toBe('ASIDE');
|
||||
});
|
||||
|
||||
it('uses semantic HTML with main element', () => {
|
||||
renderWithRouter();
|
||||
const main = screen.getByRole('main');
|
||||
expect(main.tagName).toBe('MAIN');
|
||||
});
|
||||
|
||||
it('uses semantic HTML with nav element', () => {
|
||||
renderWithRouter();
|
||||
const nav = screen.getByRole('navigation');
|
||||
expect(nav.tagName).toBe('NAV');
|
||||
});
|
||||
|
||||
it('all navigation links are keyboard accessible', () => {
|
||||
renderWithRouter();
|
||||
const links = screen.getAllByRole('link');
|
||||
links.forEach(link => {
|
||||
expect(link).toHaveAttribute('href');
|
||||
});
|
||||
});
|
||||
|
||||
it('back button is keyboard accessible', () => {
|
||||
renderWithRouter();
|
||||
const backButton = screen.getByRole('button', { name: /back to app/i });
|
||||
expect(backButton.tagName).toBe('BUTTON');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles navigation between different settings pages', () => {
|
||||
const { rerender } = renderWithRouter('/settings/general');
|
||||
expect(screen.getByText('General Settings Content')).toBeInTheDocument();
|
||||
|
||||
// Navigate to branding
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/settings/branding']}>
|
||||
<Routes>
|
||||
<Route path="/settings/*" element={<SettingsLayout />}>
|
||||
<Route path="branding" element={<div>Branding Settings Content</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Branding Settings Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles all features being locked', () => {
|
||||
mockCanUse.mockReturnValue(false);
|
||||
renderWithRouter();
|
||||
|
||||
// Should still render all links, just with locked styling
|
||||
expect(screen.getByRole('link', { name: /Appearance/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /Custom Domains/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /API & Webhooks/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles all features being unlocked', () => {
|
||||
mockCanUse.mockReturnValue(true);
|
||||
renderWithRouter();
|
||||
|
||||
// Lock icons should not be visible
|
||||
const appearanceLink = screen.getByRole('link', { name: /Appearance/i });
|
||||
const lockIcons = within(appearanceLink).queryAllByTestId('lock-icon');
|
||||
expect(lockIcons.length).toBe(0);
|
||||
});
|
||||
|
||||
it('renders without crashing when no route matches', () => {
|
||||
expect(() => renderWithRouter('/settings/nonexistent')).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user