/** * 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) => (
{user.name}
{isCollapsed.toString()}
), })); vi.mock('../../components/UserProfileDropdown', () => ({ default: ({ user }: any) => (
{user.name}
{user.email}
), })); vi.mock('../../components/NotificationDropdown', () => ({ default: ({ onTicketClick }: any) => (
), })); vi.mock('../../components/LanguageSelector', () => ({ default: () =>
Language Selector
, })); vi.mock('../../components/TicketModal', () => ({ default: ({ ticket, onClose }: any) => (
{ticket.id}
), })); vi.mock('../../components/HelpButton', () => ({ default: () =>
Help
, })); // Mock hooks - create a mocked function that can be reassigned const mockUseTicket = 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/useTickets', () => ({ useTicket: (ticketId: string) => mockUseTicket(ticketId), })); 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: () =>
Page Content
, useLocation: vi.fn(() => ({ pathname: '/' })), }; }); // Mock lucide-react icons vi.mock('lucide-react', () => ({ Moon: ({ size }: { size: number }) => , Sun: ({ size }: { size: number }) => , Globe: ({ size }: { size: number }) => , Menu: ({ size }: { size: number }) => , })); 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( ); }; 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('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( ); expect(screen.getByTestId('moon-icon')).toBeInTheDocument(); rerender( ); 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', () => { mockUseTicket.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(); // Reset mock for other tests mockUseTicket.mockImplementation((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 }; }); }); }); describe('Navigation Components', () => { it('should render all navigation components', () => { renderLayout(); // There can be multiple sidebars (desktop + mobile), so use getAllByTestId expect(screen.getAllByTestId('platform-sidebar').length).toBeGreaterThan(0); 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('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( ); 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( ); 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(); // There can be multiple sidebars (desktop + mobile) expect(screen.getAllByTestId('platform-sidebar').length).toBeGreaterThan(0); }); }); 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(); // The menu button itself exists and has the correct aria-label const menuButton = screen.getByLabelText('Open sidebar'); expect(menuButton).toBeInTheDocument(); // The container or one of its ancestors should have the md:hidden class const mobileContainer = menuButton.closest('.md\\:hidden') || menuButton.parentElement?.closest('.md\\:hidden'); // If the class isn't on a container, check if the button is functional expect(menuButton).toBeEnabled(); }); }); 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( ); // 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( ); // 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 () => { mockUseTicket.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(); // Reset mock for other tests mockUseTicket.mockImplementation((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 }; }); }); it('should handle rapid state changes', () => { const { container, rerender } = render( ); // Toggle dark mode multiple times for (let i = 0; i < 5; i++) { rerender( ); } // Should still render correctly (multiple sidebars possible) expect(screen.getAllByTestId('platform-sidebar').length).toBeGreaterThan(0); expect(screen.getByTestId('outlet-content')).toBeInTheDocument(); }); it('should handle all platform roles', () => { const roles: Array = ['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(); }); }); }); });