Files
smoothschedule/frontend/src/layouts/__tests__/BusinessLayout.test.tsx
poduck 18eeda62e8 Add staff email client with WebSocket real-time updates
Implements a complete email client for platform staff members:

Backend:
- Add routing_mode field to PlatformEmailAddress (PLATFORM/STAFF)
- Create staff_email app with models for folders, emails, attachments, labels
- IMAP service for fetching emails with folder mapping
- SMTP service for sending emails with attachment support
- Celery tasks for periodic sync and full sync operations
- WebSocket consumer for real-time notifications
- Comprehensive API viewsets with filtering and actions

Frontend:
- Thunderbird-style three-pane email interface
- Multi-account support with drag-and-drop ordering
- Email composer with rich text editor
- Email viewer with thread support
- Real-time WebSocket updates for new emails and sync status
- 94 unit tests covering models, serializers, views, services, and consumers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 23:40:27 -05:00

801 lines
24 KiB
TypeScript

/**
* 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 data-testid="help-button">Help</div>
</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>
),
}));
// 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) => <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.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();
});
});
});