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>
801 lines
24 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|