/** * 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) => (
Sidebar - {business.name} - {user.name} - {isCollapsed ? 'Collapsed' : 'Expanded'}
), })); vi.mock('../../components/TopBar', () => ({ default: ({ user, isDarkMode, toggleTheme, onMenuClick }: any) => (
TopBar - {user.name} - {isDarkMode ? 'Dark' : 'Light'}
Help
), })); vi.mock('../../components/TrialBanner', () => ({ default: ({ business }: any) => (
Trial Banner - {business.name}
), })); vi.mock('../../components/SandboxBanner', () => ({ default: ({ isSandbox, onSwitchToLive }: any) => (
Sandbox: {isSandbox ? 'Yes' : 'No'}
), })); vi.mock('../../components/QuotaWarningBanner', () => ({ default: ({ overages }: any) => (
Quota Warning - {overages.length} overages
), })); vi.mock('../../components/QuotaOverageModal', () => ({ default: ({ overages }: any) => (
Quota Modal - {overages.length} overages
), resetQuotaOverageModalDismissal: vi.fn(), })); vi.mock('../../components/MasqueradeBanner', () => ({ default: ({ effectiveUser, originalUser, onStop }: any) => (
Masquerading as {effectiveUser.name} (Original: {originalUser.name})
), })); vi.mock('../../components/OnboardingWizard', () => ({ default: ({ business, onComplete, onSkip }: any) => (
Onboarding - {business.name}
), })); vi.mock('../../components/TicketModal', () => ({ default: ({ ticket, onClose }: any) => (
Ticket #{ticket.id}
), })); // HelpButton is now rendered inside TopBar, not as a separate component // 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) =>
{children}
, 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) => (
Outlet Content - User: {context?.user?.name}
), }; }); 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( ); }; describe('Basic Rendering', () => { it('should render the layout with all main components', () => { renderLayout(); expect(screen.getAllByTestId('sidebar').length).toBeGreaterThan(0); expect(screen.getByTestId('topbar')).toBeInTheDocument(); expect(screen.getByTestId('outlet')).toBeInTheDocument(); expect(screen.getByTestId('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.getAllByTestId('sidebar')[0]; 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.getAllByTestId('sidebar')[0]; expect(sidebar).toHaveTextContent('Expanded'); }); it('should hide mobile menu by default', () => { renderLayout(); // Mobile menu has translate-x-full class when closed const container = screen.getAllByTestId('sidebar')[0].closest('div'); // The visible sidebar on desktop should exist expect(screen.getAllByTestId('sidebar').length).toBeGreaterThan(0); }); 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.getAllByTestId('sidebar').length).toBeGreaterThan(0); }); }); 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.getAllByTestId('sidebar')[0]; 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.getAllByTestId('sidebar')[0]).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(); // Find the flex container that wraps sidebar and main content const flexContainer = container.querySelector('.flex.h-full'); expect(flexContainer).toBeInTheDocument(); }); 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('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.getAllByTestId('sidebar')[0]).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.getAllByTestId('sidebar')[0]).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.getAllByTestId('sidebar').length).toBeGreaterThan(0); expect(screen.getByTestId('topbar')).toBeInTheDocument(); expect(screen.getByTestId('outlet')).toBeInTheDocument(); expect(screen.getByTestId('help-button')).toBeInTheDocument(); }); }); });