Backend: - Add TenantCustomTier model for per-tenant feature overrides - Update EntitlementService to check custom tier before plan features - Add custom_tier action on TenantViewSet (GET/PUT/DELETE) - Add Celery task for grace period management (30-day expiry) Frontend: - Add DynamicFeaturesEditor component for dynamic feature management - Fix BusinessEditModal to load features from plan defaults when no custom tier - Update limits (max_users, max_resources, etc.) to use featureValues - Remove outdated canonical feature check from FeaturePicker (removes warning icons) - Add useBillingPlans hook for accessing billing system data - Add custom tier API functions to platform.ts Features now follow consistent rules: - Load from plan defaults when no custom tier exists - Load from custom tier when one exists - Reset to plan defaults when plan changes - Save to custom tier on edit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
717 lines
19 KiB
TypeScript
717 lines
19 KiB
TypeScript
/**
|
|
* Unit tests for TopBar component
|
|
*
|
|
* Tests the top navigation bar that appears at the top of the application.
|
|
* Covers:
|
|
* - Rendering of all UI elements (search, theme toggle, notifications, etc.)
|
|
* - Menu button for mobile view
|
|
* - Theme toggle functionality
|
|
* - User profile dropdown integration
|
|
* - Language selector integration
|
|
* - Notification dropdown integration
|
|
* - Sandbox toggle integration
|
|
* - Search input
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { render, screen, fireEvent } from '@testing-library/react';
|
|
import { BrowserRouter } from 'react-router-dom';
|
|
import TopBar from '../TopBar';
|
|
import { User } from '../../types';
|
|
|
|
// Mock child components
|
|
vi.mock('../UserProfileDropdown', () => ({
|
|
default: ({ user }: { user: User }) => (
|
|
<div data-testid="user-profile-dropdown">User: {user.email}</div>
|
|
),
|
|
}));
|
|
|
|
vi.mock('../LanguageSelector', () => ({
|
|
default: () => <div data-testid="language-selector">Language Selector</div>,
|
|
}));
|
|
|
|
vi.mock('../NotificationDropdown', () => ({
|
|
default: ({ onTicketClick }: { onTicketClick?: (id: string) => void }) => (
|
|
<div data-testid="notification-dropdown">Notifications</div>
|
|
),
|
|
}));
|
|
|
|
vi.mock('../SandboxToggle', () => ({
|
|
default: ({ isSandbox, sandboxEnabled, onToggle, isToggling }: any) => (
|
|
<div data-testid="sandbox-toggle">
|
|
Sandbox: {isSandbox ? 'On' : 'Off'}
|
|
<button onClick={onToggle} disabled={isToggling}>
|
|
Toggle Sandbox
|
|
</button>
|
|
</div>
|
|
),
|
|
}));
|
|
|
|
// Mock SandboxContext
|
|
const mockUseSandbox = vi.fn();
|
|
vi.mock('../../contexts/SandboxContext', () => ({
|
|
useSandbox: () => mockUseSandbox(),
|
|
}));
|
|
|
|
// Mock react-i18next
|
|
vi.mock('react-i18next', () => ({
|
|
useTranslation: () => ({
|
|
t: (key: string) => {
|
|
const translations: Record<string, string> = {
|
|
'common.search': 'Search...',
|
|
};
|
|
return translations[key] || key;
|
|
},
|
|
}),
|
|
}));
|
|
|
|
// Test data factory for User objects
|
|
const createMockUser = (overrides?: Partial<User>): User => ({
|
|
id: '1',
|
|
email: 'test@example.com',
|
|
firstName: 'Test',
|
|
lastName: 'User',
|
|
role: 'owner',
|
|
phone: '+1234567890',
|
|
preferences: {
|
|
email: true,
|
|
sms: false,
|
|
in_app: true,
|
|
},
|
|
twoFactorEnabled: false,
|
|
profilePictureUrl: undefined,
|
|
...overrides,
|
|
});
|
|
|
|
// Wrapper component that provides router context
|
|
const renderWithRouter = (ui: React.ReactElement) => {
|
|
return render(<BrowserRouter>{ui}</BrowserRouter>);
|
|
};
|
|
|
|
describe('TopBar', () => {
|
|
const mockToggleTheme = vi.fn();
|
|
const mockOnMenuClick = vi.fn();
|
|
const mockOnTicketClick = vi.fn();
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockUseSandbox.mockReturnValue({
|
|
isSandbox: false,
|
|
sandboxEnabled: true,
|
|
toggleSandbox: vi.fn(),
|
|
isToggling: false,
|
|
});
|
|
});
|
|
|
|
describe('Rendering', () => {
|
|
it('should render the top bar with all main elements', () => {
|
|
const user = createMockUser();
|
|
|
|
renderWithRouter(
|
|
<TopBar
|
|
user={user}
|
|
isDarkMode={false}
|
|
toggleTheme={mockToggleTheme}
|
|
onMenuClick={mockOnMenuClick}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTestId('user-profile-dropdown')).toBeInTheDocument();
|
|
expect(screen.getByTestId('language-selector')).toBeInTheDocument();
|
|
expect(screen.getByTestId('notification-dropdown')).toBeInTheDocument();
|
|
expect(screen.getByTestId('sandbox-toggle')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render search input on desktop', () => {
|
|
const user = createMockUser();
|
|
|
|
renderWithRouter(
|
|
<TopBar
|
|
user={user}
|
|
isDarkMode={false}
|
|
toggleTheme={mockToggleTheme}
|
|
onMenuClick={mockOnMenuClick}
|
|
/>
|
|
);
|
|
|
|
const searchInput = screen.getByPlaceholderText('Search...');
|
|
expect(searchInput).toBeInTheDocument();
|
|
expect(searchInput).toHaveClass('w-full');
|
|
});
|
|
|
|
it('should render mobile menu button', () => {
|
|
const user = createMockUser();
|
|
|
|
renderWithRouter(
|
|
<TopBar
|
|
user={user}
|
|
isDarkMode={false}
|
|
toggleTheme={mockToggleTheme}
|
|
onMenuClick={mockOnMenuClick}
|
|
/>
|
|
);
|
|
|
|
const menuButton = screen.getByLabelText('Open sidebar');
|
|
expect(menuButton).toBeInTheDocument();
|
|
});
|
|
|
|
it('should pass user to UserProfileDropdown', () => {
|
|
const user = createMockUser({ email: 'john@example.com' });
|
|
|
|
renderWithRouter(
|
|
<TopBar
|
|
user={user}
|
|
isDarkMode={false}
|
|
toggleTheme={mockToggleTheme}
|
|
onMenuClick={mockOnMenuClick}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText('User: john@example.com')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render with dark mode styles when isDarkMode is true', () => {
|
|
const user = createMockUser();
|
|
|
|
const { container } = renderWithRouter(
|
|
<TopBar
|
|
user={user}
|
|
isDarkMode={true}
|
|
toggleTheme={mockToggleTheme}
|
|
onMenuClick={mockOnMenuClick}
|
|
/>
|
|
);
|
|
|
|
const header = container.querySelector('header');
|
|
expect(header).toHaveClass('dark:bg-gray-800', 'dark:border-gray-700');
|
|
});
|
|
});
|
|
|
|
describe('Theme Toggle', () => {
|
|
it('should render moon icon when in light mode', () => {
|
|
const user = createMockUser();
|
|
|
|
renderWithRouter(
|
|
<TopBar
|
|
user={user}
|
|
isDarkMode={false}
|
|
toggleTheme={mockToggleTheme}
|
|
onMenuClick={mockOnMenuClick}
|
|
/>
|
|
);
|
|
|
|
// The button should exist
|
|
const buttons = screen.getAllByRole('button');
|
|
const themeButton = buttons.find(btn =>
|
|
btn.className.includes('text-gray-400')
|
|
);
|
|
expect(themeButton).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render sun icon when in dark mode', () => {
|
|
const user = createMockUser();
|
|
|
|
renderWithRouter(
|
|
<TopBar
|
|
user={user}
|
|
isDarkMode={true}
|
|
toggleTheme={mockToggleTheme}
|
|
onMenuClick={mockOnMenuClick}
|
|
/>
|
|
);
|
|
|
|
// The button should exist
|
|
const buttons = screen.getAllByRole('button');
|
|
const themeButton = buttons.find(btn =>
|
|
btn.className.includes('text-gray-400')
|
|
);
|
|
expect(themeButton).toBeInTheDocument();
|
|
});
|
|
|
|
it('should call toggleTheme when theme button is clicked', () => {
|
|
const user = createMockUser();
|
|
|
|
renderWithRouter(
|
|
<TopBar
|
|
user={user}
|
|
isDarkMode={false}
|
|
toggleTheme={mockToggleTheme}
|
|
onMenuClick={mockOnMenuClick}
|
|
/>
|
|
);
|
|
|
|
// Find the theme toggle button by finding buttons, then clicking the one with the theme classes
|
|
const buttons = screen.getAllByRole('button');
|
|
// The theme button is the one with the hover styles and not the menu button
|
|
const themeButton = buttons.find(btn =>
|
|
btn.className.includes('text-gray-400') &&
|
|
btn.className.includes('hover:text-gray-600') &&
|
|
!btn.getAttribute('aria-label')
|
|
);
|
|
|
|
expect(themeButton).toBeTruthy();
|
|
if (themeButton) {
|
|
fireEvent.click(themeButton);
|
|
expect(mockToggleTheme).toHaveBeenCalledTimes(1);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Mobile Menu Button', () => {
|
|
it('should render menu button with correct aria-label', () => {
|
|
const user = createMockUser();
|
|
|
|
renderWithRouter(
|
|
<TopBar
|
|
user={user}
|
|
isDarkMode={false}
|
|
toggleTheme={mockToggleTheme}
|
|
onMenuClick={mockOnMenuClick}
|
|
/>
|
|
);
|
|
|
|
const menuButton = screen.getByLabelText('Open sidebar');
|
|
expect(menuButton).toHaveAttribute('aria-label', 'Open sidebar');
|
|
});
|
|
|
|
it('should call onMenuClick when menu button is clicked', () => {
|
|
const user = createMockUser();
|
|
|
|
renderWithRouter(
|
|
<TopBar
|
|
user={user}
|
|
isDarkMode={false}
|
|
toggleTheme={mockToggleTheme}
|
|
onMenuClick={mockOnMenuClick}
|
|
/>
|
|
);
|
|
|
|
const menuButton = screen.getByLabelText('Open sidebar');
|
|
fireEvent.click(menuButton);
|
|
|
|
expect(mockOnMenuClick).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should have mobile-only classes on menu button', () => {
|
|
const user = createMockUser();
|
|
|
|
const { container } = renderWithRouter(
|
|
<TopBar
|
|
user={user}
|
|
isDarkMode={false}
|
|
toggleTheme={mockToggleTheme}
|
|
onMenuClick={mockOnMenuClick}
|
|
/>
|
|
);
|
|
|
|
const menuButton = screen.getByLabelText('Open sidebar');
|
|
expect(menuButton).toHaveClass('md:hidden');
|
|
});
|
|
});
|
|
|
|
describe('Search Input', () => {
|
|
it('should render search input with correct placeholder', () => {
|
|
const user = createMockUser();
|
|
|
|
renderWithRouter(
|
|
<TopBar
|
|
user={user}
|
|
isDarkMode={false}
|
|
toggleTheme={mockToggleTheme}
|
|
onMenuClick={mockOnMenuClick}
|
|
/>
|
|
);
|
|
|
|
const searchInput = screen.getByPlaceholderText('Search...');
|
|
expect(searchInput).toHaveAttribute('type', 'text');
|
|
});
|
|
|
|
it('should have search icon', () => {
|
|
const user = createMockUser();
|
|
|
|
renderWithRouter(
|
|
<TopBar
|
|
user={user}
|
|
isDarkMode={false}
|
|
toggleTheme={mockToggleTheme}
|
|
onMenuClick={mockOnMenuClick}
|
|
/>
|
|
);
|
|
|
|
// Search icon should be present
|
|
const searchInput = screen.getByPlaceholderText('Search...');
|
|
expect(searchInput.parentElement?.querySelector('span')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should allow typing in search input', () => {
|
|
const user = createMockUser();
|
|
|
|
renderWithRouter(
|
|
<TopBar
|
|
user={user}
|
|
isDarkMode={false}
|
|
toggleTheme={mockToggleTheme}
|
|
onMenuClick={mockOnMenuClick}
|
|
/>
|
|
);
|
|
|
|
const searchInput = screen.getByPlaceholderText('Search...') as HTMLInputElement;
|
|
fireEvent.change(searchInput, { target: { value: 'test query' } });
|
|
|
|
expect(searchInput.value).toBe('test query');
|
|
});
|
|
|
|
it('should have focus styles on search input', () => {
|
|
const user = createMockUser();
|
|
|
|
renderWithRouter(
|
|
<TopBar
|
|
user={user}
|
|
isDarkMode={false}
|
|
toggleTheme={mockToggleTheme}
|
|
onMenuClick={mockOnMenuClick}
|
|
/>
|
|
);
|
|
|
|
const searchInput = screen.getByPlaceholderText('Search...');
|
|
expect(searchInput).toHaveClass('focus:outline-none', 'focus:border-brand-500');
|
|
});
|
|
});
|
|
|
|
describe('Sandbox Integration', () => {
|
|
it('should render SandboxToggle component', () => {
|
|
const user = createMockUser();
|
|
|
|
renderWithRouter(
|
|
<TopBar
|
|
user={user}
|
|
isDarkMode={false}
|
|
toggleTheme={mockToggleTheme}
|
|
onMenuClick={mockOnMenuClick}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTestId('sandbox-toggle')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should pass sandbox state to SandboxToggle', () => {
|
|
const user = createMockUser();
|
|
mockUseSandbox.mockReturnValue({
|
|
isSandbox: true,
|
|
sandboxEnabled: true,
|
|
toggleSandbox: vi.fn(),
|
|
isToggling: false,
|
|
});
|
|
|
|
renderWithRouter(
|
|
<TopBar
|
|
user={user}
|
|
isDarkMode={false}
|
|
toggleTheme={mockToggleTheme}
|
|
onMenuClick={mockOnMenuClick}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText(/Sandbox: On/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('should handle sandbox toggle being disabled', () => {
|
|
const user = createMockUser();
|
|
mockUseSandbox.mockReturnValue({
|
|
isSandbox: false,
|
|
sandboxEnabled: false,
|
|
toggleSandbox: vi.fn(),
|
|
isToggling: false,
|
|
});
|
|
|
|
renderWithRouter(
|
|
<TopBar
|
|
user={user}
|
|
isDarkMode={false}
|
|
toggleTheme={mockToggleTheme}
|
|
onMenuClick={mockOnMenuClick}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTestId('sandbox-toggle')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Notification Integration', () => {
|
|
it('should render NotificationDropdown', () => {
|
|
const user = createMockUser();
|
|
|
|
renderWithRouter(
|
|
<TopBar
|
|
user={user}
|
|
isDarkMode={false}
|
|
toggleTheme={mockToggleTheme}
|
|
onMenuClick={mockOnMenuClick}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTestId('notification-dropdown')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should pass onTicketClick to NotificationDropdown when provided', () => {
|
|
const user = createMockUser();
|
|
|
|
renderWithRouter(
|
|
<TopBar
|
|
user={user}
|
|
isDarkMode={false}
|
|
toggleTheme={mockToggleTheme}
|
|
onMenuClick={mockOnMenuClick}
|
|
onTicketClick={mockOnTicketClick}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTestId('notification-dropdown')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should work without onTicketClick prop', () => {
|
|
const user = createMockUser();
|
|
|
|
renderWithRouter(
|
|
<TopBar
|
|
user={user}
|
|
isDarkMode={false}
|
|
toggleTheme={mockToggleTheme}
|
|
onMenuClick={mockOnMenuClick}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTestId('notification-dropdown')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Language Selector Integration', () => {
|
|
it('should render LanguageSelector', () => {
|
|
const user = createMockUser();
|
|
|
|
renderWithRouter(
|
|
<TopBar
|
|
user={user}
|
|
isDarkMode={false}
|
|
toggleTheme={mockToggleTheme}
|
|
onMenuClick={mockOnMenuClick}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTestId('language-selector')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Different User Roles', () => {
|
|
it('should render for owner role', () => {
|
|
const user = createMockUser({ role: 'owner' });
|
|
|
|
renderWithRouter(
|
|
<TopBar
|
|
user={user}
|
|
isDarkMode={false}
|
|
toggleTheme={mockToggleTheme}
|
|
onMenuClick={mockOnMenuClick}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTestId('user-profile-dropdown')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render for manager role', () => {
|
|
const user = createMockUser({ role: 'manager' });
|
|
|
|
renderWithRouter(
|
|
<TopBar
|
|
user={user}
|
|
isDarkMode={false}
|
|
toggleTheme={mockToggleTheme}
|
|
onMenuClick={mockOnMenuClick}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTestId('user-profile-dropdown')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render for staff role', () => {
|
|
const user = createMockUser({ role: 'staff' });
|
|
|
|
renderWithRouter(
|
|
<TopBar
|
|
user={user}
|
|
isDarkMode={false}
|
|
toggleTheme={mockToggleTheme}
|
|
onMenuClick={mockOnMenuClick}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTestId('user-profile-dropdown')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render for platform roles', () => {
|
|
const user = createMockUser({ role: 'platform_manager' });
|
|
|
|
renderWithRouter(
|
|
<TopBar
|
|
user={user}
|
|
isDarkMode={false}
|
|
toggleTheme={mockToggleTheme}
|
|
onMenuClick={mockOnMenuClick}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTestId('user-profile-dropdown')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Layout and Styling', () => {
|
|
it('should have fixed height', () => {
|
|
const user = createMockUser();
|
|
|
|
const { container } = renderWithRouter(
|
|
<TopBar
|
|
user={user}
|
|
isDarkMode={false}
|
|
toggleTheme={mockToggleTheme}
|
|
onMenuClick={mockOnMenuClick}
|
|
/>
|
|
);
|
|
|
|
const header = container.querySelector('header');
|
|
expect(header).toHaveClass('h-16');
|
|
});
|
|
|
|
it('should have border at bottom', () => {
|
|
const user = createMockUser();
|
|
|
|
const { container } = renderWithRouter(
|
|
<TopBar
|
|
user={user}
|
|
isDarkMode={false}
|
|
toggleTheme={mockToggleTheme}
|
|
onMenuClick={mockOnMenuClick}
|
|
/>
|
|
);
|
|
|
|
const header = container.querySelector('header');
|
|
expect(header).toHaveClass('border-b');
|
|
});
|
|
|
|
it('should use flexbox layout', () => {
|
|
const user = createMockUser();
|
|
|
|
const { container } = renderWithRouter(
|
|
<TopBar
|
|
user={user}
|
|
isDarkMode={false}
|
|
toggleTheme={mockToggleTheme}
|
|
onMenuClick={mockOnMenuClick}
|
|
/>
|
|
);
|
|
|
|
const header = container.querySelector('header');
|
|
expect(header).toHaveClass('flex', 'items-center', 'justify-between');
|
|
});
|
|
|
|
it('should have responsive padding', () => {
|
|
const user = createMockUser();
|
|
|
|
const { container } = renderWithRouter(
|
|
<TopBar
|
|
user={user}
|
|
isDarkMode={false}
|
|
toggleTheme={mockToggleTheme}
|
|
onMenuClick={mockOnMenuClick}
|
|
/>
|
|
);
|
|
|
|
const header = container.querySelector('header');
|
|
expect(header).toHaveClass('px-4', 'sm:px-8');
|
|
});
|
|
});
|
|
|
|
describe('Accessibility', () => {
|
|
it('should have semantic header element', () => {
|
|
const user = createMockUser();
|
|
|
|
const { container } = renderWithRouter(
|
|
<TopBar
|
|
user={user}
|
|
isDarkMode={false}
|
|
toggleTheme={mockToggleTheme}
|
|
onMenuClick={mockOnMenuClick}
|
|
/>
|
|
);
|
|
|
|
expect(container.querySelector('header')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should have proper button roles', () => {
|
|
const user = createMockUser();
|
|
|
|
renderWithRouter(
|
|
<TopBar
|
|
user={user}
|
|
isDarkMode={false}
|
|
toggleTheme={mockToggleTheme}
|
|
onMenuClick={mockOnMenuClick}
|
|
/>
|
|
);
|
|
|
|
const buttons = screen.getAllByRole('button');
|
|
expect(buttons.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should have focus styles on interactive elements', () => {
|
|
const user = createMockUser();
|
|
|
|
renderWithRouter(
|
|
<TopBar
|
|
user={user}
|
|
isDarkMode={false}
|
|
toggleTheme={mockToggleTheme}
|
|
onMenuClick={mockOnMenuClick}
|
|
/>
|
|
);
|
|
|
|
const menuButton = screen.getByLabelText('Open sidebar');
|
|
expect(menuButton).toHaveClass('focus:outline-none', 'focus:ring-2');
|
|
});
|
|
});
|
|
|
|
describe('Responsive Behavior', () => {
|
|
it('should hide search on mobile', () => {
|
|
const user = createMockUser();
|
|
|
|
const { container } = renderWithRouter(
|
|
<TopBar
|
|
user={user}
|
|
isDarkMode={false}
|
|
toggleTheme={mockToggleTheme}
|
|
onMenuClick={mockOnMenuClick}
|
|
/>
|
|
);
|
|
|
|
// Search container is a relative div with hidden md:block classes
|
|
const searchContainer = container.querySelector('.hidden.md\\:block');
|
|
expect(searchContainer).toBeInTheDocument();
|
|
});
|
|
|
|
it('should show menu button only on mobile', () => {
|
|
const user = createMockUser();
|
|
|
|
renderWithRouter(
|
|
<TopBar
|
|
user={user}
|
|
isDarkMode={false}
|
|
toggleTheme={mockToggleTheme}
|
|
onMenuClick={mockOnMenuClick}
|
|
/>
|
|
);
|
|
|
|
const menuButton = screen.getByLabelText('Open sidebar');
|
|
expect(menuButton).toHaveClass('md:hidden');
|
|
});
|
|
});
|
|
});
|