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:
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