Add TenantCustomTier system and fix BusinessEditModal feature loading

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>
This commit is contained in:
poduck
2025-12-12 21:00:54 -05:00
parent d25c578e59
commit b384d9912a
183 changed files with 47627 additions and 3955 deletions

View File

@@ -0,0 +1,166 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import ApiTokensSection from '../ApiTokensSection';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, defaultValue?: string) => defaultValue || key,
}),
}));
// Mock the hooks
const mockTokens = [
{
id: '1',
name: 'Test Token',
key_prefix: 'abc123',
scopes: ['read:appointments', 'write:appointments'],
is_active: true,
created_at: '2024-01-01T00:00:00Z',
last_used_at: '2024-01-02T00:00:00Z',
expires_at: null,
created_by: { full_name: 'John Doe', username: 'john' },
},
{
id: '2',
name: 'Revoked Token',
key_prefix: 'xyz789',
scopes: ['read:resources'],
is_active: false,
created_at: '2024-01-01T00:00:00Z',
last_used_at: null,
expires_at: null,
created_by: null,
},
];
const mockUseApiTokens = vi.fn();
const mockUseCreateApiToken = vi.fn();
const mockUseRevokeApiToken = vi.fn();
const mockUseUpdateApiToken = vi.fn();
vi.mock('../../hooks/useApiTokens', () => ({
useApiTokens: () => mockUseApiTokens(),
useCreateApiToken: () => mockUseCreateApiToken(),
useRevokeApiToken: () => mockUseRevokeApiToken(),
useUpdateApiToken: () => mockUseUpdateApiToken(),
API_SCOPES: [
{ value: 'read:appointments', label: 'Read Appointments', description: 'View appointments' },
{ value: 'write:appointments', label: 'Write Appointments', description: 'Create/edit appointments' },
{ value: 'read:resources', label: 'Read Resources', description: 'View resources' },
],
SCOPE_PRESETS: {
read_only: { label: 'Read Only', description: 'View data only', scopes: ['read:appointments', 'read:resources'] },
read_write: { label: 'Read & Write', description: 'Full access', scopes: ['read:appointments', 'write:appointments', 'read:resources'] },
custom: { label: 'Custom', description: 'Select individual permissions', scopes: [] },
},
}));
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('ApiTokensSection', () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseCreateApiToken.mockReturnValue({ mutateAsync: vi.fn(), isPending: false });
mockUseRevokeApiToken.mockReturnValue({ mutateAsync: vi.fn(), isPending: false });
mockUseUpdateApiToken.mockReturnValue({ mutateAsync: vi.fn(), isPending: false });
});
it('renders loading state', () => {
mockUseApiTokens.mockReturnValue({ data: undefined, isLoading: true, error: null });
render(<ApiTokensSection />, { wrapper: createWrapper() });
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
});
it('renders error state', () => {
mockUseApiTokens.mockReturnValue({ data: undefined, isLoading: false, error: new Error('Failed') });
render(<ApiTokensSection />, { wrapper: createWrapper() });
expect(screen.getByText(/Failed to load API tokens/)).toBeInTheDocument();
});
it('renders empty state when no tokens', () => {
mockUseApiTokens.mockReturnValue({ data: [], isLoading: false, error: null });
render(<ApiTokensSection />, { wrapper: createWrapper() });
expect(screen.getByText('No API tokens yet')).toBeInTheDocument();
});
it('renders tokens list', () => {
mockUseApiTokens.mockReturnValue({ data: mockTokens, isLoading: false, error: null });
render(<ApiTokensSection />, { wrapper: createWrapper() });
expect(screen.getByText('Test Token')).toBeInTheDocument();
expect(screen.getByText('Revoked Token')).toBeInTheDocument();
});
it('renders section title', () => {
mockUseApiTokens.mockReturnValue({ data: [], isLoading: false, error: null });
render(<ApiTokensSection />, { wrapper: createWrapper() });
expect(screen.getByText('API Tokens')).toBeInTheDocument();
});
it('renders New Token button', () => {
mockUseApiTokens.mockReturnValue({ data: [], isLoading: false, error: null });
render(<ApiTokensSection />, { wrapper: createWrapper() });
expect(screen.getByText('New Token')).toBeInTheDocument();
});
it('renders API Docs link', () => {
mockUseApiTokens.mockReturnValue({ data: [], isLoading: false, error: null });
render(<ApiTokensSection />, { wrapper: createWrapper() });
expect(screen.getByText('API Docs')).toBeInTheDocument();
});
it('opens new token modal when button clicked', () => {
mockUseApiTokens.mockReturnValue({ data: mockTokens, isLoading: false, error: null });
render(<ApiTokensSection />, { wrapper: createWrapper() });
fireEvent.click(screen.getByText('New Token'));
// Modal title should appear
expect(screen.getByRole('heading', { name: 'Create API Token' })).toBeInTheDocument();
});
it('shows active tokens count', () => {
mockUseApiTokens.mockReturnValue({ data: mockTokens, isLoading: false, error: null });
render(<ApiTokensSection />, { wrapper: createWrapper() });
expect(screen.getByText(/Active Tokens \(1\)/)).toBeInTheDocument();
});
it('shows revoked tokens count', () => {
mockUseApiTokens.mockReturnValue({ data: mockTokens, isLoading: false, error: null });
render(<ApiTokensSection />, { wrapper: createWrapper() });
expect(screen.getByText(/Revoked Tokens \(1\)/)).toBeInTheDocument();
});
it('shows token key prefix', () => {
mockUseApiTokens.mockReturnValue({ data: mockTokens, isLoading: false, error: null });
render(<ApiTokensSection />, { wrapper: createWrapper() });
expect(screen.getByText(/abc123••••••••/)).toBeInTheDocument();
});
it('shows revoked badge for inactive tokens', () => {
mockUseApiTokens.mockReturnValue({ data: mockTokens, isLoading: false, error: null });
render(<ApiTokensSection />, { wrapper: createWrapper() });
expect(screen.getByText('Revoked')).toBeInTheDocument();
});
it('renders description text', () => {
mockUseApiTokens.mockReturnValue({ data: [], isLoading: false, error: null });
render(<ApiTokensSection />, { wrapper: createWrapper() });
expect(screen.getByText(/Create and manage API tokens/)).toBeInTheDocument();
});
it('renders create button in empty state', () => {
mockUseApiTokens.mockReturnValue({ data: [], isLoading: false, error: null });
render(<ApiTokensSection />, { wrapper: createWrapper() });
expect(screen.getByText('Create API Token')).toBeInTheDocument();
});
});

View File

@@ -1,429 +1,114 @@
/**
* Unit tests for ConfirmationModal component
*
* Tests all modal functionality including:
* - Rendering with different props (title, message, variants)
* - User interactions (confirm, cancel, close button)
* - Custom button labels
* - Loading states
* - Modal visibility (isOpen true/false)
* - Different modal variants (info, warning, danger, success)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { I18nextProvider } from 'react-i18next';
import i18n from 'i18next';
import ConfirmationModal from '../ConfirmationModal';
// Setup i18n for tests
beforeEach(() => {
i18n.init({
lng: 'en',
fallbackLng: 'en',
resources: {
en: {
translation: {
common: {
confirm: 'Confirm',
cancel: 'Cancel',
},
},
},
},
interpolation: {
escapeValue: false,
},
});
});
// Test wrapper with i18n provider
const renderWithI18n = (component: React.ReactElement) => {
return render(<I18nextProvider i18n={i18n}>{component}</I18nextProvider>);
};
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
describe('ConfirmationModal', () => {
const defaultProps = {
isOpen: true,
onClose: vi.fn(),
onConfirm: vi.fn(),
title: 'Confirm Action',
message: 'Are you sure you want to proceed?',
title: 'Test Title',
message: 'Test message',
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('should render modal with title and message', () => {
renderWithI18n(<ConfirmationModal {...defaultProps} />);
it('returns null when not open', () => {
const { container } = render(
<ConfirmationModal {...defaultProps} isOpen={false} />
);
expect(container.firstChild).toBeNull();
});
expect(screen.getByText('Confirm Action')).toBeInTheDocument();
expect(screen.getByText('Are you sure you want to proceed?')).toBeInTheDocument();
});
it('renders title when open', () => {
render(<ConfirmationModal {...defaultProps} />);
expect(screen.getByText('Test Title')).toBeInTheDocument();
});
it('should render modal with React node as message', () => {
const messageNode = (
<div>
<p>First paragraph</p>
<p>Second paragraph</p>
</div>
);
it('renders message when open', () => {
render(<ConfirmationModal {...defaultProps} />);
expect(screen.getByText('Test message')).toBeInTheDocument();
});
renderWithI18n(<ConfirmationModal {...defaultProps} message={messageNode} />);
it('renders message as ReactNode', () => {
render(
<ConfirmationModal
{...defaultProps}
message={<span data-testid="custom-message">Custom content</span>}
/>
);
expect(screen.getByTestId('custom-message')).toBeInTheDocument();
});
expect(screen.getByText('First paragraph')).toBeInTheDocument();
expect(screen.getByText('Second paragraph')).toBeInTheDocument();
});
it('calls onClose when close button is clicked', () => {
render(<ConfirmationModal {...defaultProps} />);
const buttons = screen.getAllByRole('button');
fireEvent.click(buttons[0]);
expect(defaultProps.onClose).toHaveBeenCalled();
});
it('should not render when isOpen is false', () => {
const { container } = renderWithI18n(
<ConfirmationModal {...defaultProps} isOpen={false} />
);
it('calls onClose when cancel button is clicked', () => {
render(<ConfirmationModal {...defaultProps} />);
fireEvent.click(screen.getByText('common.cancel'));
expect(defaultProps.onClose).toHaveBeenCalled();
});
expect(container).toBeEmptyDOMElement();
});
it('calls onConfirm when confirm button is clicked', () => {
render(<ConfirmationModal {...defaultProps} />);
fireEvent.click(screen.getByText('common.confirm'));
expect(defaultProps.onConfirm).toHaveBeenCalled();
});
it('should render default confirm and cancel buttons', () => {
renderWithI18n(<ConfirmationModal {...defaultProps} />);
it('uses custom confirm text', () => {
render(<ConfirmationModal {...defaultProps} confirmText="Yes, delete" />);
expect(screen.getByText('Yes, delete')).toBeInTheDocument();
});
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
});
it('uses custom cancel text', () => {
render(<ConfirmationModal {...defaultProps} cancelText="No, keep" />);
expect(screen.getByText('No, keep')).toBeInTheDocument();
});
it('should render custom button labels', () => {
renderWithI18n(
<ConfirmationModal
{...defaultProps}
confirmText="Yes, delete it"
cancelText="No, keep it"
/>
);
it('renders info variant', () => {
render(<ConfirmationModal {...defaultProps} variant="info" />);
expect(screen.getByText('Test Title')).toBeInTheDocument();
});
expect(screen.getByRole('button', { name: 'Yes, delete it' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'No, keep it' })).toBeInTheDocument();
});
it('renders warning variant', () => {
render(<ConfirmationModal {...defaultProps} variant="warning" />);
expect(screen.getByText('Test Title')).toBeInTheDocument();
});
it('should render close button in header', () => {
renderWithI18n(<ConfirmationModal {...defaultProps} />);
it('renders danger variant', () => {
render(<ConfirmationModal {...defaultProps} variant="danger" />);
expect(screen.getByText('Test Title')).toBeInTheDocument();
});
// Close button is an SVG icon, so we find it by its parent button
const closeButtons = screen.getAllByRole('button');
const closeButton = closeButtons.find((button) =>
button.querySelector('svg') && button !== screen.getByRole('button', { name: /confirm/i })
);
it('renders success variant', () => {
render(<ConfirmationModal {...defaultProps} variant="success" />);
expect(screen.getByText('Test Title')).toBeInTheDocument();
});
expect(closeButton).toBeInTheDocument();
it('disables buttons when loading', () => {
render(<ConfirmationModal {...defaultProps} isLoading={true} />);
const buttons = screen.getAllByRole('button');
buttons.forEach((button) => {
expect(button).toBeDisabled();
});
});
describe('User Interactions', () => {
it('should call onConfirm when confirm button is clicked', () => {
const onConfirm = vi.fn();
renderWithI18n(<ConfirmationModal {...defaultProps} onConfirm={onConfirm} />);
const confirmButton = screen.getByRole('button', { name: /confirm/i });
fireEvent.click(confirmButton);
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it('should call onClose when cancel button is clicked', () => {
const onClose = vi.fn();
renderWithI18n(<ConfirmationModal {...defaultProps} onClose={onClose} />);
const cancelButton = screen.getByRole('button', { name: /cancel/i });
fireEvent.click(cancelButton);
expect(onClose).toHaveBeenCalledTimes(1);
});
it('should call onClose when close button is clicked', () => {
const onClose = vi.fn();
renderWithI18n(<ConfirmationModal {...defaultProps} onClose={onClose} />);
// Find the close button (X icon in header)
const buttons = screen.getAllByRole('button');
const closeButton = buttons.find((button) =>
button.querySelector('svg') && !button.textContent?.includes('Confirm')
);
if (closeButton) {
fireEvent.click(closeButton);
expect(onClose).toHaveBeenCalledTimes(1);
}
});
it('should not call onConfirm multiple times on multiple clicks', () => {
const onConfirm = vi.fn();
renderWithI18n(<ConfirmationModal {...defaultProps} onConfirm={onConfirm} />);
const confirmButton = screen.getByRole('button', { name: /confirm/i });
fireEvent.click(confirmButton);
fireEvent.click(confirmButton);
fireEvent.click(confirmButton);
expect(onConfirm).toHaveBeenCalledTimes(3);
});
});
describe('Loading State', () => {
it('should show loading spinner when isLoading is true', () => {
renderWithI18n(<ConfirmationModal {...defaultProps} isLoading={true} />);
const confirmButton = screen.getByRole('button', { name: /confirm/i });
const spinner = confirmButton.querySelector('svg.animate-spin');
expect(spinner).toBeInTheDocument();
});
it('should disable confirm button when loading', () => {
renderWithI18n(<ConfirmationModal {...defaultProps} isLoading={true} />);
const confirmButton = screen.getByRole('button', { name: /confirm/i });
expect(confirmButton).toBeDisabled();
});
it('should disable cancel button when loading', () => {
renderWithI18n(<ConfirmationModal {...defaultProps} isLoading={true} />);
const cancelButton = screen.getByRole('button', { name: /cancel/i });
expect(cancelButton).toBeDisabled();
});
it('should disable close button when loading', () => {
renderWithI18n(<ConfirmationModal {...defaultProps} isLoading={true} />);
const buttons = screen.getAllByRole('button');
const closeButton = buttons.find((button) =>
button.querySelector('svg') && !button.textContent?.includes('Confirm')
);
expect(closeButton).toBeDisabled();
});
it('should not call onConfirm when button is disabled due to loading', () => {
const onConfirm = vi.fn();
renderWithI18n(
<ConfirmationModal {...defaultProps} onConfirm={onConfirm} isLoading={true} />
);
const confirmButton = screen.getByRole('button', { name: /confirm/i });
fireEvent.click(confirmButton);
// Button is disabled, so onClick should not fire
expect(onConfirm).not.toHaveBeenCalled();
});
});
describe('Modal Variants', () => {
it('should render info variant by default', () => {
const { container } = renderWithI18n(<ConfirmationModal {...defaultProps} />);
// Info variant has blue styling
const iconContainer = container.querySelector('.bg-blue-100');
expect(iconContainer).toBeInTheDocument();
});
it('should render info variant with correct styling', () => {
const { container } = renderWithI18n(
<ConfirmationModal {...defaultProps} variant="info" />
);
const iconContainer = container.querySelector('.bg-blue-100');
expect(iconContainer).toBeInTheDocument();
const confirmButton = screen.getByRole('button', { name: /confirm/i });
expect(confirmButton).toHaveClass('bg-blue-600');
});
it('should render warning variant with correct styling', () => {
const { container } = renderWithI18n(
<ConfirmationModal {...defaultProps} variant="warning" />
);
const iconContainer = container.querySelector('.bg-amber-100');
expect(iconContainer).toBeInTheDocument();
const confirmButton = screen.getByRole('button', { name: /confirm/i });
expect(confirmButton).toHaveClass('bg-amber-600');
});
it('should render danger variant with correct styling', () => {
const { container } = renderWithI18n(
<ConfirmationModal {...defaultProps} variant="danger" />
);
const iconContainer = container.querySelector('.bg-red-100');
expect(iconContainer).toBeInTheDocument();
const confirmButton = screen.getByRole('button', { name: /confirm/i });
expect(confirmButton).toHaveClass('bg-red-600');
});
it('should render success variant with correct styling', () => {
const { container } = renderWithI18n(
<ConfirmationModal {...defaultProps} variant="success" />
);
const iconContainer = container.querySelector('.bg-green-100');
expect(iconContainer).toBeInTheDocument();
const confirmButton = screen.getByRole('button', { name: /confirm/i });
expect(confirmButton).toHaveClass('bg-green-600');
});
});
describe('Accessibility', () => {
it('should have proper button roles', () => {
renderWithI18n(<ConfirmationModal {...defaultProps} />);
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBeGreaterThanOrEqual(2); // At least confirm and cancel
});
it('should have backdrop overlay', () => {
const { container } = renderWithI18n(<ConfirmationModal {...defaultProps} />);
const backdrop = container.querySelector('.fixed.inset-0.bg-black\\/50');
expect(backdrop).toBeInTheDocument();
});
it('should have modal content container', () => {
const { container } = renderWithI18n(<ConfirmationModal {...defaultProps} />);
const modal = container.querySelector('.bg-white.dark\\:bg-gray-800.rounded-xl');
expect(modal).toBeInTheDocument();
});
});
describe('Edge Cases', () => {
it('should handle empty title', () => {
renderWithI18n(<ConfirmationModal {...defaultProps} title="" />);
const confirmButton = screen.getByRole('button', { name: /confirm/i });
expect(confirmButton).toBeInTheDocument();
});
it('should handle empty message', () => {
renderWithI18n(<ConfirmationModal {...defaultProps} message="" />);
const title = screen.getByText('Confirm Action');
expect(title).toBeInTheDocument();
});
it('should handle very long title', () => {
const longTitle = 'A'.repeat(200);
renderWithI18n(<ConfirmationModal {...defaultProps} title={longTitle} />);
expect(screen.getByText(longTitle)).toBeInTheDocument();
});
it('should handle very long message', () => {
const longMessage = 'B'.repeat(500);
renderWithI18n(<ConfirmationModal {...defaultProps} message={longMessage} />);
expect(screen.getByText(longMessage)).toBeInTheDocument();
});
it('should handle rapid open/close state changes', () => {
const { rerender } = renderWithI18n(<ConfirmationModal {...defaultProps} isOpen={true} />);
expect(screen.getByText('Confirm Action')).toBeInTheDocument();
rerender(
<I18nextProvider i18n={i18n}>
<ConfirmationModal {...defaultProps} isOpen={false} />
</I18nextProvider>
);
expect(screen.queryByText('Confirm Action')).not.toBeInTheDocument();
rerender(
<I18nextProvider i18n={i18n}>
<ConfirmationModal {...defaultProps} isOpen={true} />
</I18nextProvider>
);
expect(screen.getByText('Confirm Action')).toBeInTheDocument();
});
});
describe('Complete User Flows', () => {
it('should support complete confirmation flow', () => {
const onConfirm = vi.fn();
const onClose = vi.fn();
renderWithI18n(
<ConfirmationModal
{...defaultProps}
onConfirm={onConfirm}
onClose={onClose}
title="Delete Item"
message="Are you sure you want to delete this item?"
variant="danger"
confirmText="Delete"
cancelText="Cancel"
/>
);
// User sees the modal
expect(screen.getByText('Delete Item')).toBeInTheDocument();
expect(screen.getByText('Are you sure you want to delete this item?')).toBeInTheDocument();
// User clicks confirm
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
expect(onConfirm).toHaveBeenCalledTimes(1);
expect(onClose).not.toHaveBeenCalled();
});
it('should support complete cancellation flow', () => {
const onConfirm = vi.fn();
const onClose = vi.fn();
renderWithI18n(
<ConfirmationModal
{...defaultProps}
onConfirm={onConfirm}
onClose={onClose}
variant="warning"
/>
);
// User sees the modal
expect(screen.getByText('Confirm Action')).toBeInTheDocument();
// User clicks cancel
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(onClose).toHaveBeenCalledTimes(1);
expect(onConfirm).not.toHaveBeenCalled();
});
it('should support loading state during async operation', () => {
const onConfirm = vi.fn();
const { rerender } = renderWithI18n(
<ConfirmationModal {...defaultProps} onConfirm={onConfirm} isLoading={false} />
);
// Initial state - buttons enabled
const confirmButton = screen.getByRole('button', { name: /confirm/i });
expect(confirmButton).not.toBeDisabled();
// User clicks confirm
fireEvent.click(confirmButton);
expect(onConfirm).toHaveBeenCalledTimes(1);
// Parent component sets loading state
rerender(
<I18nextProvider i18n={i18n}>
<ConfirmationModal {...defaultProps} onConfirm={onConfirm} isLoading={true} />
</I18nextProvider>
);
// Buttons now disabled during async operation
expect(screen.getByRole('button', { name: /confirm/i })).toBeDisabled();
expect(screen.getByRole('button', { name: /cancel/i })).toBeDisabled();
});
it('shows spinner when loading', () => {
render(<ConfirmationModal {...defaultProps} isLoading={true} />);
const spinner = document.querySelector('.animate-spin');
expect(spinner).toBeInTheDocument();
});
});

View File

@@ -1,752 +1,83 @@
/**
* Unit tests for EmailTemplateSelector component
*
* Tests cover:
* - Rendering with templates list
* - Template selection and onChange callback
* - Selected template display (active state)
* - Empty templates array handling
* - Loading states
* - Disabled state
* - Category filtering
* - Template info display
* - Edit link functionality
* - Internationalization (i18n)
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React, { type ReactNode } from 'react';
import EmailTemplateSelector from '../EmailTemplateSelector';
import apiClient from '../../api/client';
import { EmailTemplate } from '../../types';
// Mock apiClient
vi.mock('../../api/client', () => ({
default: {
get: vi.fn(),
},
}));
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback: string) => fallback,
t: (key: string, defaultValue?: string) => defaultValue || key,
}),
}));
// Test data factories
const createMockEmailTemplate = (overrides?: Partial<EmailTemplate>): EmailTemplate => ({
id: '1',
name: 'Test Template',
description: 'Test description',
subject: 'Test Subject',
htmlContent: '<p>Test content</p>',
textContent: 'Test content',
scope: 'BUSINESS',
isDefault: false,
category: 'APPOINTMENT',
...overrides,
});
// Mock API client
vi.mock('../../api/client', () => ({
default: {
get: vi.fn(() => Promise.resolve({ data: [] })),
},
}));
// Test wrapper with QueryClient
const createWrapper = (queryClient: QueryClient) => {
return ({ children }: { children: ReactNode }) => (
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('EmailTemplateSelector', () => {
let queryClient: QueryClient;
const mockOnChange = vi.fn();
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
});
vi.clearAllMocks();
it('renders select element', () => {
render(
<EmailTemplateSelector value={undefined} onChange={() => {}} />,
{ wrapper: createWrapper() }
);
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
afterEach(() => {
queryClient.clear();
it('shows placeholder text after loading', async () => {
render(
<EmailTemplateSelector
value={undefined}
onChange={() => {}}
placeholder="Select a template"
/>,
{ wrapper: createWrapper() }
);
// Wait for loading to finish and placeholder to appear
await screen.findByText('Select a template');
});
describe('Rendering with templates', () => {
it('should render with templates list', async () => {
const mockTemplates = [
createMockEmailTemplate({ id: '1', name: 'Welcome Email' }),
createMockEmailTemplate({ id: '2', name: 'Confirmation Email', category: 'CONFIRMATION' }),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.options.length).toBeGreaterThan(1);
});
const select = screen.getByRole('combobox') as HTMLSelectElement;
const options = Array.from(select.options);
expect(options).toHaveLength(3); // placeholder + 2 templates
expect(options[1]).toHaveTextContent('Welcome Email (APPOINTMENT)');
expect(options[2]).toHaveTextContent('Confirmation Email (CONFIRMATION)');
});
it('should render templates without category suffix for OTHER category', async () => {
const mockTemplates = [
createMockEmailTemplate({ id: '1', name: 'Custom Email', category: 'OTHER' }),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.options.length).toBeGreaterThan(1);
});
const select = screen.getByRole('combobox') as HTMLSelectElement;
const options = Array.from(select.options);
expect(options[1]).toHaveTextContent('Custom Email');
expect(options[1]).not.toHaveTextContent('(OTHER)');
});
it('should convert numeric IDs to strings', async () => {
const mockData = [
{
id: 123,
name: 'Numeric ID Template',
description: 'Test',
category: 'REMINDER',
scope: 'BUSINESS',
updated_at: '2025-01-01T00:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockData });
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.options.length).toBeGreaterThan(1);
});
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.options[1].value).toBe('123');
});
it('is disabled when disabled prop is true', () => {
render(
<EmailTemplateSelector value={undefined} onChange={() => {}} disabled />,
{ wrapper: createWrapper() }
);
expect(screen.getByRole('combobox')).toBeDisabled();
});
describe('Template selection', () => {
it('should select template on click', async () => {
const mockTemplates = [
createMockEmailTemplate({ id: '1', name: 'Template 1' }),
createMockEmailTemplate({ id: '2', name: 'Template 2' }),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.options.length).toBeGreaterThan(1);
});
const select = screen.getByRole('combobox') as HTMLSelectElement;
fireEvent.change(select, { target: { value: '2' } });
expect(mockOnChange).toHaveBeenCalledWith('2');
});
it('should call onChange with undefined when selecting empty option', async () => {
const mockTemplates = [
createMockEmailTemplate({ id: '1', name: 'Template 1' }),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
render(
<EmailTemplateSelector value="1" onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
const select = screen.getByRole('combobox') as HTMLSelectElement;
fireEvent.change(select, { target: { value: '' } });
expect(mockOnChange).toHaveBeenCalledWith(undefined);
});
it('should handle numeric value prop', async () => {
const mockTemplates = [
createMockEmailTemplate({ id: '1', name: 'Template 1' }),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
render(
<EmailTemplateSelector value={1} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.options.length).toBeGreaterThan(1);
});
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.value).toBe('1');
});
it('applies custom className', () => {
const { container } = render(
<EmailTemplateSelector
value={undefined}
onChange={() => {}}
className="custom-class"
/>,
{ wrapper: createWrapper() }
);
expect(container.firstChild).toHaveClass('custom-class');
});
describe('Selected template display', () => {
it('should show selected template as active', async () => {
const mockTemplates = [
createMockEmailTemplate({
id: '1',
name: 'Selected Template',
description: 'This template is selected',
}),
createMockEmailTemplate({ id: '2', name: 'Other Template' }),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
render(
<EmailTemplateSelector value="1" onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.options.length).toBeGreaterThan(1);
});
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.value).toBe('1');
});
it('should display selected template info with description', async () => {
const mockTemplates = [
createMockEmailTemplate({
id: '1',
name: 'Template Name',
description: 'Template description text',
}),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
render(
<EmailTemplateSelector value="1" onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
expect(screen.getByText('Template description text')).toBeInTheDocument();
});
});
it('should display template name when description is empty', async () => {
const mockTemplates = [
createMockEmailTemplate({
id: '1',
name: 'No Description Template',
description: '',
}),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
render(
<EmailTemplateSelector value="1" onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
expect(screen.getByText('No Description Template')).toBeInTheDocument();
});
});
it('should display edit link for selected template', async () => {
const mockTemplates = [
createMockEmailTemplate({ id: '1', name: 'Editable Template' }),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
render(
<EmailTemplateSelector value="1" onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const editLink = screen.getByRole('link', { name: /edit/i });
expect(editLink).toBeInTheDocument();
expect(editLink).toHaveAttribute('href', '#/email-templates');
expect(editLink).toHaveAttribute('target', '_blank');
expect(editLink).toHaveAttribute('rel', 'noopener noreferrer');
});
});
it('should not display template info when no template is selected', async () => {
const mockTemplates = [
createMockEmailTemplate({ id: '1', name: 'Template 1' }),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
const editLink = screen.queryByRole('link', { name: /edit/i });
expect(editLink).not.toBeInTheDocument();
});
});
describe('Empty templates array', () => {
it('should handle empty templates array', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
expect(screen.getByText(/no email templates yet/i)).toBeInTheDocument();
});
});
it('should display create link when templates array is empty', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const createLink = screen.getByRole('link', { name: /create your first template/i });
expect(createLink).toBeInTheDocument();
expect(createLink).toHaveAttribute('href', '#/email-templates');
});
});
it('should render select with only placeholder option when empty', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.options).toHaveLength(1); // only placeholder
});
});
});
describe('Loading states', () => {
it('should show loading text in placeholder when loading', async () => {
vi.mocked(apiClient.get).mockImplementation(
() => new Promise(() => {}) // Never resolves to keep loading state
);
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.options[0]).toHaveTextContent('Loading...');
});
it('should disable select when loading', async () => {
vi.mocked(apiClient.get).mockImplementation(
() => new Promise(() => {}) // Never resolves
);
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
const select = screen.getByRole('combobox');
expect(select).toBeDisabled();
});
it('should not show empty state while loading', () => {
vi.mocked(apiClient.get).mockImplementation(
() => new Promise(() => {}) // Never resolves
);
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
const emptyMessage = screen.queryByText(/no email templates yet/i);
expect(emptyMessage).not.toBeInTheDocument();
});
});
describe('Disabled state', () => {
it('should disable select when disabled prop is true', async () => {
const mockTemplates = [
createMockEmailTemplate({ id: '1', name: 'Template 1' }),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} disabled={true} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const select = screen.getByRole('combobox');
expect(select).toBeDisabled();
});
});
it('should apply disabled attribute when disabled prop is true', async () => {
const mockTemplates = [
createMockEmailTemplate({ id: '1', name: 'Template 1' }),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} disabled={true} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const select = screen.getByRole('combobox');
expect(select).toBeDisabled();
});
// Verify the select element has disabled attribute
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select).toHaveAttribute('disabled');
});
});
describe('Category filtering', () => {
it('should fetch templates with category filter', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} category="REMINDER" />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
expect(apiClient.get).toHaveBeenCalledWith('/email-templates/?category=REMINDER');
});
});
it('should fetch templates without category filter when not provided', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
expect(apiClient.get).toHaveBeenCalledWith('/email-templates/?');
});
});
it('should refetch when category changes', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const { rerender } = render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} category="REMINDER" />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
expect(apiClient.get).toHaveBeenCalledWith('/email-templates/?category=REMINDER');
});
vi.clearAllMocks();
rerender(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} category="CONFIRMATION" />
);
await waitFor(() => {
expect(apiClient.get).toHaveBeenCalledWith('/email-templates/?category=CONFIRMATION');
});
});
});
describe('Props and customization', () => {
it('should use custom placeholder when provided', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
render(
<EmailTemplateSelector
value={undefined}
onChange={mockOnChange}
placeholder="Choose an email template"
/>,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.options[0]).toHaveTextContent('Choose an email template');
});
});
it('should use default placeholder when not provided', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.options[0]).toHaveTextContent('Select a template...');
});
});
it('should apply custom className', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
render(
<EmailTemplateSelector
value={undefined}
onChange={mockOnChange}
className="custom-class"
/>,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const container = screen.getByRole('combobox').parentElement?.parentElement;
expect(container).toHaveClass('custom-class');
});
});
it('should work without className prop', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
});
});
describe('Icons', () => {
it('should display Mail icon', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const container = screen.getByRole('combobox').parentElement;
const svg = container?.querySelector('svg');
expect(svg).toBeInTheDocument();
});
});
it('should display ExternalLink icon for selected template', async () => {
const mockTemplates = [
createMockEmailTemplate({ id: '1', name: 'Template 1' }),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
render(
<EmailTemplateSelector value="1" onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const editLink = screen.getByRole('link', { name: /edit/i });
const svg = editLink.querySelector('svg');
expect(svg).toBeInTheDocument();
});
});
});
describe('API error handling', () => {
it('should handle API errors gracefully', async () => {
const error = new Error('API Error');
vi.mocked(apiClient.get).mockRejectedValueOnce(error);
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
// Component should still render the select
const select = screen.getByRole('combobox');
expect(select).toBeInTheDocument();
});
it('shows empty state message when no templates', async () => {
render(
<EmailTemplateSelector value={undefined} onChange={() => {}} />,
{ wrapper: createWrapper() }
);
// Wait for loading to finish
await screen.findByText('No email templates yet.');
});
});

View File

@@ -0,0 +1,86 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import FloatingHelpButton from '../FloatingHelpButton';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, defaultValue?: string) => defaultValue || key,
}),
}));
describe('FloatingHelpButton', () => {
const renderWithRouter = (initialPath: string) => {
return render(
<MemoryRouter initialEntries={[initialPath]}>
<FloatingHelpButton />
</MemoryRouter>
);
};
it('renders help link on dashboard', () => {
renderWithRouter('/dashboard');
const link = screen.getByRole('link');
expect(link).toBeInTheDocument();
});
it('links to correct help page for dashboard', () => {
renderWithRouter('/dashboard');
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/help/dashboard');
});
it('links to correct help page for scheduler', () => {
renderWithRouter('/scheduler');
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/help/scheduler');
});
it('links to correct help page for services', () => {
renderWithRouter('/services');
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/help/services');
});
it('links to correct help page for resources', () => {
renderWithRouter('/resources');
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/help/resources');
});
it('links to correct help page for settings', () => {
renderWithRouter('/settings/general');
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/help/settings/general');
});
it('returns null on help pages', () => {
const { container } = renderWithRouter('/help/dashboard');
expect(container.firstChild).toBeNull();
});
it('has aria-label', () => {
renderWithRouter('/dashboard');
const link = screen.getByRole('link');
expect(link).toHaveAttribute('aria-label', 'Help');
});
it('has title attribute', () => {
renderWithRouter('/dashboard');
const link = screen.getByRole('link');
expect(link).toHaveAttribute('title', 'Help');
});
it('links to default help for unknown routes', () => {
renderWithRouter('/unknown-route');
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/help');
});
it('handles dynamic routes by matching prefix', () => {
renderWithRouter('/customers/123');
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/help/customers');
});
});

View File

@@ -1,264 +1,57 @@
/**
* Unit tests for HelpButton component
*
* Tests cover:
* - Component rendering
* - Link navigation
* - Icon display
* - Text display and responsive behavior
* - Accessibility attributes
* - Custom className prop
* - Internationalization (i18n)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import React from 'react';
import HelpButton from '../HelpButton';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback: string) => fallback,
t: (key: string, defaultValue?: string) => defaultValue || key,
}),
}));
// Test wrapper with Router
const createWrapper = () => {
return ({ children }: { children: React.ReactNode }) => (
<BrowserRouter>{children}</BrowserRouter>
);
};
describe('HelpButton', () => {
beforeEach(() => {
vi.clearAllMocks();
const renderHelpButton = (props: { helpPath: string; className?: string }) => {
return render(
<BrowserRouter>
<HelpButton {...props} />
</BrowserRouter>
);
};
it('renders help link', () => {
renderHelpButton({ helpPath: '/help/dashboard' });
const link = screen.getByRole('link');
expect(link).toBeInTheDocument();
});
describe('Rendering', () => {
it('should render the button', () => {
render(<HelpButton helpPath="/help/getting-started" />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link');
expect(link).toBeInTheDocument();
});
it('should render as a Link component with correct href', () => {
render(<HelpButton helpPath="/help/resources" />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/help/resources');
});
it('should render with different help paths', () => {
const { rerender } = render(<HelpButton helpPath="/help/page1" />, {
wrapper: createWrapper(),
});
let link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/help/page1');
rerender(<HelpButton helpPath="/help/page2" />);
link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/help/page2');
});
it('has correct href', () => {
renderHelpButton({ helpPath: '/help/dashboard' });
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/help/dashboard');
});
describe('Icon Display', () => {
it('should display the HelpCircle icon', () => {
render(<HelpButton helpPath="/help" />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link');
// Check for SVG icon (lucide-react renders as SVG)
const svg = link.querySelector('svg');
expect(svg).toBeInTheDocument();
});
it('renders help text', () => {
renderHelpButton({ helpPath: '/help/test' });
expect(screen.getByText('Help')).toBeInTheDocument();
});
describe('Text Display', () => {
it('should display help text', () => {
render(<HelpButton helpPath="/help" />, {
wrapper: createWrapper(),
});
const text = screen.getByText('Help');
expect(text).toBeInTheDocument();
});
it('should apply responsive class to hide text on small screens', () => {
render(<HelpButton helpPath="/help" />, {
wrapper: createWrapper(),
});
const text = screen.getByText('Help');
expect(text).toHaveClass('hidden', 'sm:inline');
});
it('has title attribute', () => {
renderHelpButton({ helpPath: '/help/test' });
const link = screen.getByRole('link');
expect(link).toHaveAttribute('title', 'Help');
});
describe('Accessibility', () => {
it('should have title attribute', () => {
render(<HelpButton helpPath="/help" />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link');
expect(link).toHaveAttribute('title', 'Help');
});
it('should be keyboard accessible as a link', () => {
render(<HelpButton helpPath="/help" />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link');
expect(link).toBeInTheDocument();
expect(link.tagName).toBe('A');
});
it('should have accessible name from text content', () => {
render(<HelpButton helpPath="/help" />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link', { name: /help/i });
expect(link).toBeInTheDocument();
});
it('applies custom className', () => {
renderHelpButton({ helpPath: '/help/test', className: 'custom-class' });
const link = screen.getByRole('link');
expect(link).toHaveClass('custom-class');
});
describe('Styling', () => {
it('should apply default classes', () => {
render(<HelpButton helpPath="/help" />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link');
expect(link).toHaveClass('inline-flex');
expect(link).toHaveClass('items-center');
expect(link).toHaveClass('gap-1.5');
expect(link).toHaveClass('px-3');
expect(link).toHaveClass('py-1.5');
expect(link).toHaveClass('text-sm');
expect(link).toHaveClass('rounded-lg');
expect(link).toHaveClass('transition-colors');
});
it('should apply color classes for light mode', () => {
render(<HelpButton helpPath="/help" />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link');
expect(link).toHaveClass('text-gray-500');
expect(link).toHaveClass('hover:text-brand-600');
expect(link).toHaveClass('hover:bg-gray-100');
});
it('should apply color classes for dark mode', () => {
render(<HelpButton helpPath="/help" />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link');
expect(link).toHaveClass('dark:text-gray-400');
expect(link).toHaveClass('dark:hover:text-brand-400');
expect(link).toHaveClass('dark:hover:bg-gray-800');
});
it('should apply custom className when provided', () => {
render(<HelpButton helpPath="/help" className="custom-class" />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link');
expect(link).toHaveClass('custom-class');
});
it('should merge custom className with default classes', () => {
render(<HelpButton helpPath="/help" className="ml-auto" />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link');
expect(link).toHaveClass('ml-auto');
expect(link).toHaveClass('inline-flex');
expect(link).toHaveClass('items-center');
});
it('should work without custom className', () => {
render(<HelpButton helpPath="/help" />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link');
expect(link).toBeInTheDocument();
});
});
describe('Internationalization', () => {
it('should use translation for help text', () => {
render(<HelpButton helpPath="/help" />, {
wrapper: createWrapper(),
});
// The mock returns the fallback value
const text = screen.getByText('Help');
expect(text).toBeInTheDocument();
});
it('should use translation for title attribute', () => {
render(<HelpButton helpPath="/help" />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link');
expect(link).toHaveAttribute('title', 'Help');
});
});
describe('Integration', () => {
it('should render correctly with all props together', () => {
render(
<HelpButton
helpPath="/help/advanced"
className="custom-styling"
/>,
{ wrapper: createWrapper() }
);
const link = screen.getByRole('link');
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', '/help/advanced');
expect(link).toHaveAttribute('title', 'Help');
expect(link).toHaveClass('custom-styling');
expect(link).toHaveClass('inline-flex');
const icon = link.querySelector('svg');
expect(icon).toBeInTheDocument();
const text = screen.getByText('Help');
expect(text).toBeInTheDocument();
});
it('should maintain structure with icon and text', () => {
render(<HelpButton helpPath="/help" />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link');
const svg = link.querySelector('svg');
const span = link.querySelector('span');
expect(svg).toBeInTheDocument();
expect(span).toBeInTheDocument();
expect(span).toHaveTextContent('Help');
});
it('has default styles', () => {
renderHelpButton({ helpPath: '/help/test' });
const link = screen.getByRole('link');
expect(link).toHaveClass('inline-flex');
expect(link).toHaveClass('items-center');
});
});

View File

@@ -1,560 +1,93 @@
/**
* Unit tests for LanguageSelector component
*
* Tests cover:
* - Rendering both dropdown and inline variants
* - Current language display
* - Dropdown open/close functionality
* - Language selection and change
* - Available languages display
* - Flag display
* - Click outside to close dropdown
* - Accessibility attributes
* - Responsive text hiding
* - Custom className prop
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import LanguageSelector from '../LanguageSelector';
// Mock i18n
const mockChangeLanguage = vi.fn();
const mockCurrentLanguage = 'en';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: {
language: mockCurrentLanguage,
changeLanguage: mockChangeLanguage,
language: 'en',
changeLanguage: vi.fn(),
},
}),
}));
// Mock i18n module with supported languages
// Mock i18n module
vi.mock('../../i18n', () => ({
supportedLanguages: [
{ code: 'en', name: 'English', flag: '🇺🇸' },
{ code: 'es', name: 'Español', flag: '🇪🇸' },
{ code: 'fr', name: 'Français', flag: '🇫🇷' },
{ code: 'de', name: 'Deutsch', flag: '🇩🇪' },
],
}));
describe('LanguageSelector', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
describe('Dropdown Variant (Default)', () => {
describe('Rendering', () => {
it('should render the language selector button', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button', { expanded: false });
expect(button).toBeInTheDocument();
});
it('should display current language name on desktop', () => {
render(<LanguageSelector />);
const languageName = screen.getByText('English');
expect(languageName).toBeInTheDocument();
expect(languageName).toHaveClass('hidden', 'sm:inline');
});
it('should display current language flag by default', () => {
render(<LanguageSelector />);
const flag = screen.getByText('🇺🇸');
expect(flag).toBeInTheDocument();
});
it('should display Globe icon', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
const svg = button.querySelector('svg');
expect(svg).toBeInTheDocument();
});
it('should display ChevronDown icon', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
const chevron = button.querySelector('svg.w-4.h-4.transition-transform');
expect(chevron).toBeInTheDocument();
});
it('should not display flag when showFlag is false', () => {
render(<LanguageSelector showFlag={false} />);
const flag = screen.queryByText('🇺🇸');
expect(flag).not.toBeInTheDocument();
});
it('should not show dropdown by default', () => {
render(<LanguageSelector />);
const dropdown = screen.queryByRole('listbox');
expect(dropdown).not.toBeInTheDocument();
});
});
describe('Dropdown Open/Close', () => {
it('should open dropdown when button clicked', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
fireEvent.click(button);
const dropdown = screen.getByRole('listbox');
expect(dropdown).toBeInTheDocument();
expect(button).toHaveAttribute('aria-expanded', 'true');
});
it('should close dropdown when button clicked again', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
// Open
fireEvent.click(button);
expect(screen.getByRole('listbox')).toBeInTheDocument();
// Close
fireEvent.click(button);
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
expect(button).toHaveAttribute('aria-expanded', 'false');
});
it('should rotate chevron icon when dropdown is open', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
const chevron = button.querySelector('svg.w-4.h-4.transition-transform');
// Initially not rotated
expect(chevron).not.toHaveClass('rotate-180');
// Open dropdown
fireEvent.click(button);
expect(chevron).toHaveClass('rotate-180');
});
it('should close dropdown when clicking outside', async () => {
render(
<div>
<LanguageSelector />
<button>Outside Button</button>
</div>
);
const button = screen.getByRole('button', { expanded: false });
fireEvent.click(button);
expect(screen.getByRole('listbox')).toBeInTheDocument();
// Click outside
const outsideButton = screen.getByText('Outside Button');
fireEvent.mouseDown(outsideButton);
await waitFor(() => {
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
});
});
it('should not close dropdown when clicking inside dropdown', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
fireEvent.click(button);
const dropdown = screen.getByRole('listbox');
fireEvent.mouseDown(dropdown);
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
});
describe('Language Selection', () => {
it('should display all available languages in dropdown', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(screen.getAllByText('English')).toHaveLength(2); // One in button, one in dropdown
expect(screen.getByText('Español')).toBeInTheDocument();
expect(screen.getByText('Français')).toBeInTheDocument();
expect(screen.getByText('Deutsch')).toBeInTheDocument();
});
it('should display flags for all languages in dropdown', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(screen.getAllByText('🇺🇸')).toHaveLength(2); // One in button, one in dropdown
expect(screen.getByText('🇪🇸')).toBeInTheDocument();
expect(screen.getByText('🇫🇷')).toBeInTheDocument();
expect(screen.getByText('🇩🇪')).toBeInTheDocument();
});
it('should mark current language with Check icon', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
fireEvent.click(button);
const options = screen.getAllByRole('option');
const englishOption = options.find(opt => opt.textContent?.includes('English'));
expect(englishOption).toHaveAttribute('aria-selected', 'true');
// Check icon should be present
const checkIcon = englishOption?.querySelector('svg.w-4.h-4');
expect(checkIcon).toBeInTheDocument();
});
it('should change language when option clicked', async () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
fireEvent.click(button);
const spanishOption = screen.getAllByRole('option').find(
opt => opt.textContent?.includes('Español')
);
fireEvent.click(spanishOption!);
await waitFor(() => {
expect(mockChangeLanguage).toHaveBeenCalledWith('es');
});
});
it('should close dropdown after language selection', async () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
fireEvent.click(button);
const frenchOption = screen.getAllByRole('option').find(
opt => opt.textContent?.includes('Français')
);
fireEvent.click(frenchOption!);
await waitFor(() => {
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
});
});
it('should highlight selected language with brand color', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
fireEvent.click(button);
const options = screen.getAllByRole('option');
const englishOption = options.find(opt => opt.textContent?.includes('English'));
expect(englishOption).toHaveClass('bg-brand-50', 'dark:bg-brand-900/20');
expect(englishOption).toHaveClass('text-brand-700', 'dark:text-brand-300');
});
it('should not highlight non-selected languages with brand color', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
fireEvent.click(button);
const options = screen.getAllByRole('option');
const spanishOption = options.find(opt => opt.textContent?.includes('Español'));
expect(spanishOption).toHaveClass('text-gray-700', 'dark:text-gray-300');
expect(spanishOption).not.toHaveClass('bg-brand-50');
});
});
describe('Accessibility', () => {
it('should have proper ARIA attributes on button', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-expanded', 'false');
expect(button).toHaveAttribute('aria-haspopup', 'listbox');
});
it('should update aria-expanded when dropdown opens', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(button);
expect(button).toHaveAttribute('aria-expanded', 'true');
});
it('should have aria-label on listbox', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
fireEvent.click(button);
const listbox = screen.getByRole('listbox');
expect(listbox).toHaveAttribute('aria-label', 'Select language');
});
it('should mark language options as selected correctly', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
fireEvent.click(button);
const options = screen.getAllByRole('option');
const englishOption = options.find(opt => opt.textContent?.includes('English'));
const spanishOption = options.find(opt => opt.textContent?.includes('Español'));
expect(englishOption).toHaveAttribute('aria-selected', 'true');
expect(spanishOption).toHaveAttribute('aria-selected', 'false');
});
});
describe('Styling', () => {
it('should apply default classes to button', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
expect(button).toHaveClass('flex', 'items-center', 'gap-2');
expect(button).toHaveClass('px-3', 'py-2');
expect(button).toHaveClass('rounded-lg');
expect(button).toHaveClass('transition-colors');
});
it('should apply custom className when provided', () => {
render(<LanguageSelector className="custom-class" />);
const container = screen.getByRole('button').parentElement;
expect(container).toHaveClass('custom-class');
});
it('should apply dropdown animation classes', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
fireEvent.click(button);
const dropdown = screen.getByRole('listbox').parentElement;
expect(dropdown).toHaveClass('animate-in', 'fade-in', 'slide-in-from-top-2');
});
it('should apply focus ring on button', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
expect(button).toHaveClass('focus:outline-none', 'focus:ring-2', 'focus:ring-brand-500');
});
});
});
describe('Inline Variant', () => {
describe('Rendering', () => {
it('should render inline variant when specified', () => {
render(<LanguageSelector variant="inline" />);
// Should show buttons, not a dropdown
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBe(4); // One for each language
});
it('should display all languages as separate buttons', () => {
render(<LanguageSelector variant="inline" />);
expect(screen.getByText('English')).toBeInTheDocument();
expect(screen.getByText('Español')).toBeInTheDocument();
expect(screen.getByText('Français')).toBeInTheDocument();
expect(screen.getByText('Deutsch')).toBeInTheDocument();
});
it('should display flags in inline variant by default', () => {
render(<LanguageSelector variant="inline" />);
expect(screen.getByText('🇺🇸')).toBeInTheDocument();
expect(screen.getByText('🇪🇸')).toBeInTheDocument();
expect(screen.getByText('🇫🇷')).toBeInTheDocument();
expect(screen.getByText('🇩🇪')).toBeInTheDocument();
});
it('should not display flags when showFlag is false', () => {
render(<LanguageSelector variant="inline" showFlag={false} />);
expect(screen.queryByText('🇺🇸')).not.toBeInTheDocument();
expect(screen.queryByText('🇪🇸')).not.toBeInTheDocument();
});
it('should highlight current language button', () => {
render(<LanguageSelector variant="inline" />);
const englishButton = screen.getByRole('button', { name: /English/i });
expect(englishButton).toHaveClass('bg-brand-600', 'text-white');
});
it('should not highlight non-selected language buttons', () => {
render(<LanguageSelector variant="inline" />);
const spanishButton = screen.getByRole('button', { name: /Español/i });
expect(spanishButton).toHaveClass('bg-gray-100', 'text-gray-700');
expect(spanishButton).not.toHaveClass('bg-brand-600');
});
});
describe('Language Selection', () => {
it('should change language when button clicked', async () => {
render(<LanguageSelector variant="inline" />);
const frenchButton = screen.getByRole('button', { name: /Français/i });
fireEvent.click(frenchButton);
await waitFor(() => {
expect(mockChangeLanguage).toHaveBeenCalledWith('fr');
});
});
it('should change language for each available language', async () => {
render(<LanguageSelector variant="inline" />);
const germanButton = screen.getByRole('button', { name: /Deutsch/i });
fireEvent.click(germanButton);
await waitFor(() => {
expect(mockChangeLanguage).toHaveBeenCalledWith('de');
});
});
});
describe('Styling', () => {
it('should apply flex layout classes', () => {
const { container } = render(<LanguageSelector variant="inline" />);
const wrapper = container.firstChild;
expect(wrapper).toHaveClass('flex', 'flex-wrap', 'gap-2');
});
it('should apply custom className when provided', () => {
const { container } = render(<LanguageSelector variant="inline" className="my-custom-class" />);
const wrapper = container.firstChild;
expect(wrapper).toHaveClass('my-custom-class');
});
it('should apply button styling classes', () => {
render(<LanguageSelector variant="inline" />);
const buttons = screen.getAllByRole('button');
buttons.forEach(button => {
expect(button).toHaveClass('px-3', 'py-1.5', 'rounded-lg', 'text-sm', 'font-medium', 'transition-colors');
});
});
it('should apply hover classes to non-selected buttons', () => {
render(<LanguageSelector variant="inline" />);
const spanishButton = screen.getByRole('button', { name: /Español/i });
expect(spanishButton).toHaveClass('hover:bg-gray-200', 'dark:hover:bg-gray-600');
});
});
});
describe('Integration', () => {
it('should render correctly with all dropdown props together', () => {
render(
<LanguageSelector
variant="dropdown"
showFlag={true}
className="custom-class"
/>
);
describe('dropdown variant', () => {
it('renders dropdown button', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
expect(screen.getByText('English')).toBeInTheDocument();
expect(screen.getByText('🇺🇸')).toBeInTheDocument();
const container = button.parentElement;
expect(container).toHaveClass('custom-class');
});
it('should render correctly with all inline props together', () => {
const { container } = render(
<LanguageSelector
variant="inline"
showFlag={true}
className="inline-custom"
/>
);
const wrapper = container.firstChild;
expect(wrapper).toHaveClass('inline-custom');
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBe(4);
it('shows current language flag by default', () => {
render(<LanguageSelector />);
expect(screen.getByText('🇺🇸')).toBeInTheDocument();
});
it('shows current language name on larger screens', () => {
render(<LanguageSelector />);
expect(screen.getByText('English')).toBeInTheDocument();
});
it('should maintain dropdown functionality across re-renders', () => {
const { rerender } = render(<LanguageSelector />);
it('opens dropdown on click', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
rerender(<LanguageSelector className="updated" />);
expect(screen.getByRole('listbox')).toBeInTheDocument();
it('shows all languages when open', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(screen.getByText('Español')).toBeInTheDocument();
expect(screen.getByText('Français')).toBeInTheDocument();
});
it('hides flag when showFlag is false', () => {
render(<LanguageSelector showFlag={false} />);
expect(screen.queryByText('🇺🇸')).not.toBeInTheDocument();
});
it('applies custom className', () => {
const { container } = render(<LanguageSelector className="custom-class" />);
expect(container.firstChild).toHaveClass('custom-class');
});
});
describe('Edge Cases', () => {
it('should handle missing language gracefully', () => {
// The component should fall back to the first language if current language is not found
render(<LanguageSelector />);
// Should still render without crashing
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
});
it('should cleanup event listener on unmount', () => {
const { unmount } = render(<LanguageSelector />);
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith('mousedown', expect.any(Function));
});
it('should not call changeLanguage when clicking current language', async () => {
describe('inline variant', () => {
it('renders all language buttons', () => {
render(<LanguageSelector variant="inline" />);
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBe(3);
});
const englishButton = screen.getByRole('button', { name: /English/i });
fireEvent.click(englishButton);
it('renders language names', () => {
render(<LanguageSelector variant="inline" />);
expect(screen.getByText(/English/)).toBeInTheDocument();
expect(screen.getByText(/Español/)).toBeInTheDocument();
expect(screen.getByText(/Français/)).toBeInTheDocument();
});
await waitFor(() => {
expect(mockChangeLanguage).toHaveBeenCalledWith('en');
});
it('highlights current language', () => {
render(<LanguageSelector variant="inline" />);
const englishButton = screen.getByText(/English/).closest('button');
expect(englishButton).toHaveClass('bg-brand-600');
});
// Even if clicking the current language, it still calls changeLanguage
// This is expected behavior (idempotent)
it('shows flags by default', () => {
render(<LanguageSelector variant="inline" />);
expect(screen.getByText(/🇺🇸/)).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,201 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import { LocationSelector, useShouldShowLocationSelector } from '../LocationSelector';
import { renderHook } from '@testing-library/react';
// Mock the useLocations hook
vi.mock('../../hooks/useLocations', () => ({
useLocations: vi.fn(),
}));
import { useLocations } from '../../hooks/useLocations';
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
};
describe('LocationSelector', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders nothing when loading', () => {
vi.mocked(useLocations).mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
} as any);
const onChange = vi.fn();
const { container } = render(
<LocationSelector value={null} onChange={onChange} />,
{ wrapper: createWrapper() }
);
expect(container.firstChild).toBeNull();
});
it('renders nothing when there is only one location', () => {
vi.mocked(useLocations).mockReturnValue({
data: [{ id: 1, name: 'Main Office', is_active: true, is_primary: true }],
isLoading: false,
isError: false,
} as any);
const onChange = vi.fn();
const { container } = render(
<LocationSelector value={null} onChange={onChange} />,
{ wrapper: createWrapper() }
);
expect(container.firstChild).toBeNull();
});
it('renders selector when multiple locations exist', () => {
vi.mocked(useLocations).mockReturnValue({
data: [
{ id: 1, name: 'Main Office', is_active: true, is_primary: true },
{ id: 2, name: 'Branch Office', is_active: true, is_primary: false },
],
isLoading: false,
isError: false,
} as any);
const onChange = vi.fn();
render(<LocationSelector value={null} onChange={onChange} />, {
wrapper: createWrapper(),
});
expect(screen.getByLabelText('Location')).toBeInTheDocument();
expect(screen.getByText('Main Office (Primary)')).toBeInTheDocument();
expect(screen.getByText('Branch Office')).toBeInTheDocument();
});
it('shows single location when forceShow is true', () => {
vi.mocked(useLocations).mockReturnValue({
data: [{ id: 1, name: 'Main Office', is_active: true, is_primary: true }],
isLoading: false,
isError: false,
} as any);
const onChange = vi.fn();
render(<LocationSelector value={null} onChange={onChange} forceShow />, {
wrapper: createWrapper(),
});
expect(screen.getByLabelText('Location')).toBeInTheDocument();
});
it('calls onChange when selection changes', () => {
vi.mocked(useLocations).mockReturnValue({
data: [
{ id: 1, name: 'Main Office', is_active: true, is_primary: true },
{ id: 2, name: 'Branch Office', is_active: true, is_primary: false },
],
isLoading: false,
isError: false,
} as any);
const onChange = vi.fn();
render(<LocationSelector value={null} onChange={onChange} />, {
wrapper: createWrapper(),
});
const select = screen.getByLabelText('Location');
fireEvent.change(select, { target: { value: '2' } });
expect(onChange).toHaveBeenCalledWith(2);
});
it('marks inactive locations appropriately', () => {
vi.mocked(useLocations).mockReturnValue({
data: [
{ id: 1, name: 'Main Office', is_active: true, is_primary: true },
{ id: 2, name: 'Old Branch', is_active: false, is_primary: false },
],
isLoading: false,
isError: false,
} as any);
const onChange = vi.fn();
render(<LocationSelector value={null} onChange={onChange} includeInactive />, {
wrapper: createWrapper(),
});
expect(screen.getByText('Old Branch (Inactive)')).toBeInTheDocument();
});
it('displays custom label', () => {
vi.mocked(useLocations).mockReturnValue({
data: [
{ id: 1, name: 'Location A', is_active: true, is_primary: false },
{ id: 2, name: 'Location B', is_active: true, is_primary: false },
],
isLoading: false,
isError: false,
} as any);
const onChange = vi.fn();
render(<LocationSelector value={null} onChange={onChange} label="Select Store" />, {
wrapper: createWrapper(),
});
expect(screen.getByLabelText('Select Store')).toBeInTheDocument();
});
});
describe('useShouldShowLocationSelector', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns false when loading', () => {
vi.mocked(useLocations).mockReturnValue({
data: undefined,
isLoading: true,
} as any);
const { result } = renderHook(() => useShouldShowLocationSelector(), {
wrapper: createWrapper(),
});
expect(result.current).toBe(false);
});
it('returns false when only one location', () => {
vi.mocked(useLocations).mockReturnValue({
data: [{ id: 1, name: 'Main', is_active: true }],
isLoading: false,
} as any);
const { result } = renderHook(() => useShouldShowLocationSelector(), {
wrapper: createWrapper(),
});
expect(result.current).toBe(false);
});
it('returns true when multiple locations exist', () => {
vi.mocked(useLocations).mockReturnValue({
data: [
{ id: 1, name: 'Main', is_active: true },
{ id: 2, name: 'Branch', is_active: true },
],
isLoading: false,
} as any);
const { result } = renderHook(() => useShouldShowLocationSelector(), {
wrapper: createWrapper(),
});
expect(result.current).toBe(true);
});
});

View File

@@ -1,534 +1,68 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import React from 'react';
import MasqueradeBanner from '../MasqueradeBanner';
import { User } from '../../types';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: any) => {
const translations: Record<string, string> = {
'platform.masquerade.masqueradingAs': 'Masquerading as',
'platform.masquerade.loggedInAs': `Logged in as ${options?.name || ''}`,
'platform.masquerade.returnTo': `Return to ${options?.name || ''}`,
'platform.masquerade.stopMasquerading': 'Stop Masquerading',
};
return translations[key] || key;
t: (key: string, options?: { name?: string }) => {
if (options?.name) return `${key} ${options.name}`;
return key;
},
}),
}));
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
Eye: ({ size }: { size: number }) => <svg data-testid="eye-icon" width={size} height={size} />,
XCircle: ({ size }: { size: number }) => <svg data-testid="xcircle-icon" width={size} height={size} />,
}));
describe('MasqueradeBanner', () => {
const mockOnStop = vi.fn();
const effectiveUser: User = {
id: '2',
name: 'John Doe',
email: 'john@example.com',
role: 'owner',
};
const originalUser: User = {
id: '1',
name: 'Admin User',
email: 'admin@platform.com',
role: 'superuser',
};
const previousUser: User = {
id: '3',
name: 'Manager User',
email: 'manager@example.com',
role: 'platform_manager',
const defaultProps = {
effectiveUser: { id: '1', name: 'John Doe', email: 'john@test.com', role: 'staff' as const },
originalUser: { id: '2', name: 'Admin User', email: 'admin@test.com', role: 'superuser' as const },
previousUser: null,
onStop: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('renders the banner with correct structure', () => {
const { container } = render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
// Check for main container - it's the first child div
const banner = container.firstChild as HTMLElement;
expect(banner).toBeInTheDocument();
expect(banner).toHaveClass('bg-orange-600', 'text-white');
});
it('displays the Eye icon', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const eyeIcon = screen.getByTestId('eye-icon');
expect(eyeIcon).toBeInTheDocument();
expect(eyeIcon).toHaveAttribute('width', '18');
expect(eyeIcon).toHaveAttribute('height', '18');
});
it('displays the XCircle icon in the button', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const xCircleIcon = screen.getByTestId('xcircle-icon');
expect(xCircleIcon).toBeInTheDocument();
expect(xCircleIcon).toHaveAttribute('width', '14');
expect(xCircleIcon).toHaveAttribute('height', '14');
});
it('renders effective user name', () => {
render(<MasqueradeBanner {...defaultProps} />);
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
describe('User Information Display', () => {
it('displays the effective user name and role', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText(/owner/i)).toBeInTheDocument();
});
it('displays the original user name', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
expect(screen.getByText(/Logged in as Admin User/i)).toBeInTheDocument();
});
it('displays masquerading as message', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
expect(screen.getByText(/Masquerading as/i)).toBeInTheDocument();
});
it('displays different user roles correctly', () => {
const staffUser: User = {
id: '4',
name: 'Staff Member',
email: 'staff@example.com',
role: 'staff',
};
render(
<MasqueradeBanner
effectiveUser={staffUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
expect(screen.getByText('Staff Member')).toBeInTheDocument();
// Use a more specific query to avoid matching "Staff Member" text
expect(screen.getByText(/\(staff\)/i)).toBeInTheDocument();
});
it('renders effective user role', () => {
render(<MasqueradeBanner {...defaultProps} />);
// The role is split across elements: "(" + "staff" + ")"
expect(screen.getByText(/staff/)).toBeInTheDocument();
});
describe('Stop Masquerade Button', () => {
it('renders the stop masquerade button when no previous user', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const button = screen.getByRole('button', { name: /Stop Masquerading/i });
expect(button).toBeInTheDocument();
});
it('renders the return to user button when previous user exists', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={previousUser}
onStop={mockOnStop}
/>
);
const button = screen.getByRole('button', { name: /Return to Manager User/i });
expect(button).toBeInTheDocument();
});
it('calls onStop when button is clicked', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const button = screen.getByRole('button', { name: /Stop Masquerading/i });
fireEvent.click(button);
expect(mockOnStop).toHaveBeenCalledTimes(1);
});
it('calls onStop when return button is clicked with previous user', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={previousUser}
onStop={mockOnStop}
/>
);
const button = screen.getByRole('button', { name: /Return to Manager User/i });
fireEvent.click(button);
expect(mockOnStop).toHaveBeenCalledTimes(1);
});
it('can be clicked multiple times', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const button = screen.getByRole('button', { name: /Stop Masquerading/i });
fireEvent.click(button);
fireEvent.click(button);
fireEvent.click(button);
expect(mockOnStop).toHaveBeenCalledTimes(3);
});
it('renders original user info', () => {
render(<MasqueradeBanner {...defaultProps} />);
expect(screen.getByText(/Admin User/)).toBeInTheDocument();
});
describe('Styling and Visual State', () => {
it('has warning/info styling with orange background', () => {
const { container } = render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const banner = container.firstChild as HTMLElement;
expect(banner).toHaveClass('bg-orange-600');
expect(banner).toHaveClass('text-white');
});
it('has proper button styling', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const button = screen.getByRole('button', { name: /Stop Masquerading/i });
expect(button).toHaveClass('bg-white');
expect(button).toHaveClass('text-orange-600');
expect(button).toHaveClass('hover:bg-orange-50');
});
it('has animated pulse effect on Eye icon container', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const eyeIcon = screen.getByTestId('eye-icon');
const iconContainer = eyeIcon.closest('div');
expect(iconContainer).toHaveClass('animate-pulse');
});
it('has proper layout classes for flexbox', () => {
const { container } = render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const banner = container.firstChild as HTMLElement;
expect(banner).toHaveClass('flex');
expect(banner).toHaveClass('items-center');
expect(banner).toHaveClass('justify-between');
});
it('has z-index for proper stacking', () => {
const { container } = render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const banner = container.firstChild as HTMLElement;
expect(banner).toHaveClass('z-50');
expect(banner).toHaveClass('relative');
});
it('has shadow for visual prominence', () => {
const { container } = render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const banner = container.firstChild as HTMLElement;
expect(banner).toHaveClass('shadow-md');
});
it('calls onStop when button is clicked', () => {
render(<MasqueradeBanner {...defaultProps} />);
const stopButton = screen.getByRole('button');
fireEvent.click(stopButton);
expect(defaultProps.onStop).toHaveBeenCalled();
});
describe('Edge Cases', () => {
it('handles users with numeric IDs', () => {
const numericIdUser: User = {
id: 123,
name: 'Numeric User',
email: 'numeric@example.com',
role: 'customer',
};
render(
<MasqueradeBanner
effectiveUser={numericIdUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
expect(screen.getByText('Numeric User')).toBeInTheDocument();
});
it('handles users with long names', () => {
const longNameUser: User = {
id: '5',
name: 'This Is A Very Long User Name That Should Still Display Properly',
email: 'longname@example.com',
role: 'manager',
};
render(
<MasqueradeBanner
effectiveUser={longNameUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
expect(
screen.getByText('This Is A Very Long User Name That Should Still Display Properly')
).toBeInTheDocument();
});
it('handles all possible user roles', () => {
const roles: Array<User['role']> = [
'superuser',
'platform_manager',
'platform_support',
'owner',
'manager',
'staff',
'resource',
'customer',
];
roles.forEach((role) => {
const { unmount } = render(
<MasqueradeBanner
effectiveUser={{ ...effectiveUser, role }}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
expect(screen.getByText(new RegExp(role, 'i'))).toBeInTheDocument();
unmount();
});
});
it('handles previousUser being null', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
expect(screen.getByRole('button', { name: /Stop Masquerading/i })).toBeInTheDocument();
expect(screen.queryByText(/Return to/i)).not.toBeInTheDocument();
});
it('handles previousUser being defined', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={previousUser}
onStop={mockOnStop}
/>
);
expect(screen.getByRole('button', { name: /Return to Manager User/i })).toBeInTheDocument();
expect(screen.queryByText(/Stop Masquerading/i)).not.toBeInTheDocument();
});
it('shows return to previous user text when previousUser exists', () => {
const propsWithPrevious = {
...defaultProps,
previousUser: { id: '3', name: 'Manager', email: 'manager@test.com', role: 'manager' as const },
};
render(<MasqueradeBanner {...propsWithPrevious} />);
expect(screen.getByText(/platform.masquerade.returnTo/)).toBeInTheDocument();
});
describe('Accessibility', () => {
it('has a clickable button element', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
expect(button.tagName).toBe('BUTTON');
});
it('button has descriptive text', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const button = screen.getByRole('button');
expect(button).toHaveTextContent(/Stop Masquerading/i);
});
it('displays user information in semantic HTML', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const strongElement = screen.getByText('John Doe');
expect(strongElement.tagName).toBe('STRONG');
});
it('shows stop masquerading text when no previousUser', () => {
render(<MasqueradeBanner {...defaultProps} />);
expect(screen.getByText('platform.masquerade.stopMasquerading')).toBeInTheDocument();
});
describe('Component Integration', () => {
it('renders without crashing with minimal props', () => {
const minimalEffectiveUser: User = {
id: '1',
name: 'Test',
email: 'test@test.com',
role: 'customer',
};
const minimalOriginalUser: User = {
id: '2',
name: 'Admin',
email: 'admin@test.com',
role: 'superuser',
};
expect(() =>
render(
<MasqueradeBanner
effectiveUser={minimalEffectiveUser}
originalUser={minimalOriginalUser}
previousUser={null}
onStop={mockOnStop}
/>
)
).not.toThrow();
});
it('renders all required elements together', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
// Check all major elements are present
expect(screen.getByTestId('eye-icon')).toBeInTheDocument();
expect(screen.getByTestId('xcircle-icon')).toBeInTheDocument();
expect(screen.getByText(/Masquerading as/i)).toBeInTheDocument();
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText(/Logged in as Admin User/i)).toBeInTheDocument();
expect(screen.getByRole('button')).toBeInTheDocument();
});
it('renders with masquerading label', () => {
render(<MasqueradeBanner {...defaultProps} />);
expect(screen.getByText(/platform.masquerade.masqueradingAs/)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,463 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
import NotificationDropdown from '../NotificationDropdown';
import { Notification } from '../../api/notifications';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, defaultValue?: string) => defaultValue || key,
}),
}));
// Mock react-router-dom navigate
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
...actual,
useNavigate: () => mockNavigate,
};
});
// Mock hooks
const mockNotifications: Notification[] = [
{
id: 1,
verb: 'created',
read: false,
timestamp: new Date().toISOString(),
data: {},
actor_type: 'user',
actor_display: 'John Doe',
target_type: 'appointment',
target_display: 'Appointment with Jane',
target_url: '/appointments/1',
},
{
id: 2,
verb: 'updated',
read: true,
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), // 2 hours ago
data: {},
actor_type: 'user',
actor_display: 'Jane Smith',
target_type: 'event',
target_display: 'Meeting scheduled',
target_url: '/events/2',
},
{
id: 3,
verb: 'created a ticket',
read: false,
timestamp: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), // 1 day ago
data: { ticket_id: '123' },
actor_type: 'user',
actor_display: 'Support Team',
target_type: 'ticket',
target_display: 'Ticket #123',
target_url: null,
},
{
id: 4,
verb: 'requested time off',
read: false,
timestamp: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(), // 10 days ago
data: { type: 'time_off_request' },
actor_type: 'user',
actor_display: 'Bob Johnson',
target_type: null,
target_display: 'Time off request',
target_url: null,
},
];
vi.mock('../../hooks/useNotifications', () => ({
useNotifications: vi.fn(),
useUnreadNotificationCount: vi.fn(),
useMarkNotificationRead: vi.fn(),
useMarkAllNotificationsRead: vi.fn(),
useClearAllNotifications: vi.fn(),
}));
import {
useNotifications,
useUnreadNotificationCount,
useMarkNotificationRead,
useMarkAllNotificationsRead,
useClearAllNotifications,
} from '../../hooks/useNotifications';
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
<BrowserRouter>{children}</BrowserRouter>
</QueryClientProvider>
);
};
describe('NotificationDropdown', () => {
const mockMarkRead = vi.fn();
const mockMarkAllRead = vi.fn();
const mockClearAll = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
// Default mock implementations
vi.mocked(useNotifications).mockReturnValue({
data: mockNotifications,
isLoading: false,
} as any);
vi.mocked(useUnreadNotificationCount).mockReturnValue({
data: 2,
} as any);
vi.mocked(useMarkNotificationRead).mockReturnValue({
mutate: mockMarkRead,
isPending: false,
} as any);
vi.mocked(useMarkAllNotificationsRead).mockReturnValue({
mutate: mockMarkAllRead,
isPending: false,
} as any);
vi.mocked(useClearAllNotifications).mockReturnValue({
mutate: mockClearAll,
isPending: false,
} as any);
});
describe('Rendering', () => {
it('renders bell icon button', () => {
render(<NotificationDropdown />, { wrapper: createWrapper() });
expect(screen.getByRole('button', { name: /open notifications/i })).toBeInTheDocument();
});
it('displays unread count badge when there are unread notifications', () => {
render(<NotificationDropdown />, { wrapper: createWrapper() });
expect(screen.getByText('2')).toBeInTheDocument();
});
it('does not display badge when unread count is 0', () => {
vi.mocked(useUnreadNotificationCount).mockReturnValue({
data: 0,
} as any);
render(<NotificationDropdown />, { wrapper: createWrapper() });
expect(screen.queryByText('2')).not.toBeInTheDocument();
});
it('displays "99+" when unread count exceeds 99', () => {
vi.mocked(useUnreadNotificationCount).mockReturnValue({
data: 150,
} as any);
render(<NotificationDropdown />, { wrapper: createWrapper() });
expect(screen.getByText('99+')).toBeInTheDocument();
});
it('does not render dropdown when closed', () => {
render(<NotificationDropdown />, { wrapper: createWrapper() });
expect(screen.queryByText('Notifications')).not.toBeInTheDocument();
});
});
describe('Dropdown interactions', () => {
it('opens dropdown when bell icon is clicked', () => {
render(<NotificationDropdown />, { wrapper: createWrapper() });
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
expect(screen.getByText('Notifications')).toBeInTheDocument();
});
it('closes dropdown when close button is clicked', () => {
render(<NotificationDropdown />, { wrapper: createWrapper() });
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
const closeButtons = screen.getAllByRole('button');
const closeButton = closeButtons.find(btn => btn.querySelector('svg'));
fireEvent.click(closeButton!);
expect(screen.queryByText('Notifications')).not.toBeInTheDocument();
});
it('closes dropdown when clicking outside', async () => {
render(<NotificationDropdown />, { wrapper: createWrapper() });
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
expect(screen.getByText('Notifications')).toBeInTheDocument();
// Simulate clicking outside
fireEvent.mouseDown(document.body);
await waitFor(() => {
expect(screen.queryByText('Notifications')).not.toBeInTheDocument();
});
});
});
describe('Notification list', () => {
it('displays all notifications when dropdown is open', () => {
render(<NotificationDropdown />, { wrapper: createWrapper() });
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
expect(screen.getByText('Support Team')).toBeInTheDocument();
expect(screen.getByText('Bob Johnson')).toBeInTheDocument();
});
it('displays loading state', () => {
vi.mocked(useNotifications).mockReturnValue({
data: [],
isLoading: true,
} as any);
render(<NotificationDropdown />, { wrapper: createWrapper() });
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
expect(screen.getByText('common.loading')).toBeInTheDocument();
});
it('displays empty state when no notifications', () => {
vi.mocked(useNotifications).mockReturnValue({
data: [],
isLoading: false,
} as any);
render(<NotificationDropdown />, { wrapper: createWrapper() });
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
expect(screen.getByText('No notifications yet')).toBeInTheDocument();
});
it('highlights unread notifications', () => {
render(<NotificationDropdown />, { wrapper: createWrapper() });
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
const notificationButtons = screen.getAllByRole('button');
const unreadNotification = notificationButtons.find(btn =>
btn.textContent?.includes('John Doe')
);
expect(unreadNotification).toHaveClass('bg-blue-50/50');
});
});
describe('Notification actions', () => {
it('marks notification as read when clicked', () => {
render(<NotificationDropdown />, { wrapper: createWrapper() });
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
const notification = screen.getByText('John Doe').closest('button');
fireEvent.click(notification!);
expect(mockMarkRead).toHaveBeenCalledWith(1);
});
it('navigates to target URL when notification is clicked', () => {
render(<NotificationDropdown />, { wrapper: createWrapper() });
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
const notification = screen.getByText('John Doe').closest('button');
fireEvent.click(notification!);
expect(mockNavigate).toHaveBeenCalledWith('/appointments/1');
});
it('calls onTicketClick for ticket notifications', () => {
const mockOnTicketClick = vi.fn();
render(<NotificationDropdown onTicketClick={mockOnTicketClick} />, { wrapper: createWrapper() });
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
const ticketNotification = screen.getByText('Support Team').closest('button');
fireEvent.click(ticketNotification!);
expect(mockOnTicketClick).toHaveBeenCalledWith('123');
});
it('navigates to time-blocks for time off requests', () => {
render(<NotificationDropdown />, { wrapper: createWrapper() });
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
const timeOffNotification = screen.getByText('Bob Johnson').closest('button');
fireEvent.click(timeOffNotification!);
expect(mockNavigate).toHaveBeenCalledWith('/time-blocks');
});
it('marks all notifications as read', () => {
render(<NotificationDropdown />, { wrapper: createWrapper() });
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
// Find the mark all read button (CheckCheck icon)
const buttons = screen.getAllByRole('button');
const markAllReadButton = buttons.find(btn =>
btn.getAttribute('title')?.includes('Mark all as read')
);
fireEvent.click(markAllReadButton!);
expect(mockMarkAllRead).toHaveBeenCalled();
});
it('clears all read notifications', () => {
render(<NotificationDropdown />, { wrapper: createWrapper() });
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
const clearButton = screen.getByText('Clear read');
fireEvent.click(clearButton);
expect(mockClearAll).toHaveBeenCalled();
});
it('navigates to notifications page when "View all" is clicked', () => {
render(<NotificationDropdown />, { wrapper: createWrapper() });
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
const viewAllButton = screen.getByText('View all');
fireEvent.click(viewAllButton);
expect(mockNavigate).toHaveBeenCalledWith('/notifications');
});
});
describe('Notification icons', () => {
it('displays Clock icon for time off requests', () => {
render(<NotificationDropdown />, { wrapper: createWrapper() });
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
const timeOffNotification = screen.getByText('Bob Johnson').closest('button');
const icon = timeOffNotification?.querySelector('svg');
expect(icon).toBeInTheDocument();
});
it('displays Ticket icon for ticket notifications', () => {
render(<NotificationDropdown />, { wrapper: createWrapper() });
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
const ticketNotification = screen.getByText('Support Team').closest('button');
const icon = ticketNotification?.querySelector('svg');
expect(icon).toBeInTheDocument();
});
it('displays Calendar icon for event notifications', () => {
render(<NotificationDropdown />, { wrapper: createWrapper() });
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
const eventNotification = screen.getByText('Jane Smith').closest('button');
const icon = eventNotification?.querySelector('svg');
expect(icon).toBeInTheDocument();
});
});
describe('Timestamp formatting', () => {
it('displays "Just now" for recent notifications', () => {
render(<NotificationDropdown />, { wrapper: createWrapper() });
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
// The first notification is just now
expect(screen.getByText('Just now')).toBeInTheDocument();
});
it('displays relative time for older notifications', () => {
render(<NotificationDropdown />, { wrapper: createWrapper() });
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
// Check if notification timestamps are rendered
// We have 4 notifications in our mock data, each should have a timestamp
const notificationButtons = screen.getAllByRole('button').filter(btn =>
btn.textContent?.includes('John Doe') ||
btn.textContent?.includes('Jane Smith') ||
btn.textContent?.includes('Support Team') ||
btn.textContent?.includes('Bob Johnson')
);
expect(notificationButtons.length).toBeGreaterThan(0);
// At least one notification should have a timestamp
const hasTimestamp = notificationButtons.some(btn => btn.textContent?.match(/Just now|\d+[hmd] ago|\d{1,2}\/\d{1,2}\/\d{4}/));
expect(hasTimestamp).toBe(true);
});
});
describe('Variants', () => {
it('renders with light variant', () => {
render(<NotificationDropdown variant="light" />, { wrapper: createWrapper() });
const button = screen.getByRole('button', { name: /open notifications/i });
expect(button).toHaveClass('text-white/80');
});
it('renders with dark variant (default)', () => {
render(<NotificationDropdown variant="dark" />, { wrapper: createWrapper() });
const button = screen.getByRole('button', { name: /open notifications/i });
expect(button).toHaveClass('text-gray-400');
});
});
describe('Loading states', () => {
it('disables mark all read button when mutation is pending', () => {
vi.mocked(useMarkAllNotificationsRead).mockReturnValue({
mutate: mockMarkAllRead,
isPending: true,
} as any);
render(<NotificationDropdown />, { wrapper: createWrapper() });
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
const buttons = screen.getAllByRole('button');
const markAllReadButton = buttons.find(btn =>
btn.getAttribute('title')?.includes('Mark all as read')
);
expect(markAllReadButton).toBeDisabled();
});
it('disables clear all button when mutation is pending', () => {
vi.mocked(useClearAllNotifications).mockReturnValue({
mutate: mockClearAll,
isPending: true,
} as any);
render(<NotificationDropdown />, { wrapper: createWrapper() });
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
const clearButton = screen.getByText('Clear read');
expect(clearButton).toBeDisabled();
});
});
describe('Footer visibility', () => {
it('shows footer when there are notifications', () => {
render(<NotificationDropdown />, { wrapper: createWrapper() });
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
expect(screen.getByText('Clear read')).toBeInTheDocument();
expect(screen.getByText('View all')).toBeInTheDocument();
});
it('hides footer when there are no notifications', () => {
vi.mocked(useNotifications).mockReturnValue({
data: [],
isLoading: false,
} as any);
render(<NotificationDropdown />, { wrapper: createWrapper() });
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
expect(screen.queryByText('Clear read')).not.toBeInTheDocument();
expect(screen.queryByText('View all')).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,577 @@
/**
* Unit tests for OAuthButtons component
*
* Tests OAuth provider buttons for social login.
* Covers:
* - Rendering providers from API
* - Button clicks and OAuth initiation
* - Loading states (initial load and button clicks)
* - Provider-specific styling (colors, icons)
* - Disabled state
* - Error handling
* - Empty state (no providers)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import OAuthButtons from '../OAuthButtons';
// Mock hooks
const mockUseOAuthProviders = vi.fn();
const mockUseInitiateOAuth = vi.fn();
vi.mock('../../hooks/useOAuth', () => ({
useOAuthProviders: () => mockUseOAuthProviders(),
useInitiateOAuth: () => mockUseInitiateOAuth(),
}));
// Helper to wrap component with QueryClient
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('OAuthButtons', () => {
const mockMutate = vi.fn();
const mockOnSuccess = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
mockUseInitiateOAuth.mockReturnValue({
mutate: mockMutate,
isPending: false,
variables: null,
});
});
describe('Loading State', () => {
it('should show loading spinner while fetching providers', () => {
mockUseOAuthProviders.mockReturnValue({
data: undefined,
isLoading: true,
});
const { container } = render(<OAuthButtons />, { wrapper: createWrapper() });
// Look for the spinner SVG element with animate-spin class
const spinner = container.querySelector('.animate-spin');
expect(spinner).toBeInTheDocument();
});
it('should not show providers while loading', () => {
mockUseOAuthProviders.mockReturnValue({
data: undefined,
isLoading: true,
});
render(<OAuthButtons />, { wrapper: createWrapper() });
expect(screen.queryByRole('button', { name: /continue with/i })).not.toBeInTheDocument();
});
});
describe('Empty State', () => {
it('should render nothing when no providers are available', () => {
mockUseOAuthProviders.mockReturnValue({
data: [],
isLoading: false,
});
const { container } = render(<OAuthButtons />, { wrapper: createWrapper() });
expect(container.firstChild).toBeNull();
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('should render nothing when providers data is null', () => {
mockUseOAuthProviders.mockReturnValue({
data: null,
isLoading: false,
});
const { container } = render(<OAuthButtons />, { wrapper: createWrapper() });
expect(container.firstChild).toBeNull();
});
});
describe('Provider Rendering', () => {
it('should render Google provider button', () => {
mockUseOAuthProviders.mockReturnValue({
data: [{ name: 'google', display_name: 'Google' }],
isLoading: false,
});
render(<OAuthButtons />, { wrapper: createWrapper() });
const button = screen.getByRole('button', { name: /continue with google/i });
expect(button).toBeInTheDocument();
});
it('should render multiple provider buttons', () => {
mockUseOAuthProviders.mockReturnValue({
data: [
{ name: 'google', display_name: 'Google' },
{ name: 'facebook', display_name: 'Facebook' },
{ name: 'apple', display_name: 'Apple' },
],
isLoading: false,
});
render(<OAuthButtons />, { wrapper: createWrapper() });
expect(screen.getByRole('button', { name: /continue with google/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /continue with facebook/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /continue with apple/i })).toBeInTheDocument();
});
it('should apply Google-specific styling (white bg, border)', () => {
mockUseOAuthProviders.mockReturnValue({
data: [{ name: 'google', display_name: 'Google' }],
isLoading: false,
});
render(<OAuthButtons />, { wrapper: createWrapper() });
const button = screen.getByRole('button', { name: /continue with google/i });
expect(button).toHaveClass('bg-white', 'text-gray-900', 'border-gray-300');
});
it('should apply Apple-specific styling (black bg)', () => {
mockUseOAuthProviders.mockReturnValue({
data: [{ name: 'apple', display_name: 'Apple' }],
isLoading: false,
});
render(<OAuthButtons />, { wrapper: createWrapper() });
const button = screen.getByRole('button', { name: /continue with apple/i });
expect(button).toHaveClass('bg-black', 'text-white');
});
it('should apply Facebook-specific styling (blue bg)', () => {
mockUseOAuthProviders.mockReturnValue({
data: [{ name: 'facebook', display_name: 'Facebook' }],
isLoading: false,
});
render(<OAuthButtons />, { wrapper: createWrapper() });
const button = screen.getByRole('button', { name: /continue with facebook/i });
expect(button).toHaveClass('bg-[#1877F2]', 'text-white');
});
it('should apply LinkedIn-specific styling', () => {
mockUseOAuthProviders.mockReturnValue({
data: [{ name: 'linkedin', display_name: 'LinkedIn' }],
isLoading: false,
});
render(<OAuthButtons />, { wrapper: createWrapper() });
const button = screen.getByRole('button', { name: /continue with linkedin/i });
expect(button).toHaveClass('bg-[#0A66C2]', 'text-white');
});
it('should render unknown provider with fallback styling', () => {
mockUseOAuthProviders.mockReturnValue({
data: [{ name: 'custom_provider', display_name: 'Custom Provider' }],
isLoading: false,
});
render(<OAuthButtons />, { wrapper: createWrapper() });
const button = screen.getByRole('button', { name: /continue with custom provider/i });
expect(button).toBeInTheDocument();
expect(button).toHaveClass('bg-gray-600', 'text-white');
});
it('should display provider icons', () => {
mockUseOAuthProviders.mockReturnValue({
data: [
{ name: 'google', display_name: 'Google' },
{ name: 'facebook', display_name: 'Facebook' },
],
isLoading: false,
});
render(<OAuthButtons />, { wrapper: createWrapper() });
// Icons should be present (rendered as text in config)
expect(screen.getByText('G')).toBeInTheDocument(); // Google icon
expect(screen.getByText('f')).toBeInTheDocument(); // Facebook icon
});
});
describe('Button Clicks', () => {
it('should call OAuth initiation when button is clicked', () => {
mockUseOAuthProviders.mockReturnValue({
data: [{ name: 'google', display_name: 'Google' }],
isLoading: false,
});
render(<OAuthButtons />, { wrapper: createWrapper() });
const button = screen.getByRole('button', { name: /continue with google/i });
fireEvent.click(button);
expect(mockMutate).toHaveBeenCalledWith('google', expect.any(Object));
});
it('should call onSuccess callback after successful OAuth initiation', () => {
mockUseOAuthProviders.mockReturnValue({
data: [{ name: 'google', display_name: 'Google' }],
isLoading: false,
});
mockMutate.mockImplementation((provider, { onSuccess }) => {
onSuccess?.();
});
render(<OAuthButtons onSuccess={mockOnSuccess} />, { wrapper: createWrapper() });
const button = screen.getByRole('button', { name: /continue with google/i });
fireEvent.click(button);
expect(mockOnSuccess).toHaveBeenCalledTimes(1);
});
it('should handle multiple provider clicks', () => {
mockUseOAuthProviders.mockReturnValue({
data: [
{ name: 'google', display_name: 'Google' },
{ name: 'facebook', display_name: 'Facebook' },
],
isLoading: false,
});
render(<OAuthButtons />, { wrapper: createWrapper() });
const googleButton = screen.getByRole('button', { name: /continue with google/i });
const facebookButton = screen.getByRole('button', { name: /continue with facebook/i });
fireEvent.click(googleButton);
expect(mockMutate).toHaveBeenCalledWith('google', expect.any(Object));
fireEvent.click(facebookButton);
expect(mockMutate).toHaveBeenCalledWith('facebook', expect.any(Object));
});
it('should not initiate OAuth when button is disabled', () => {
mockUseOAuthProviders.mockReturnValue({
data: [{ name: 'google', display_name: 'Google' }],
isLoading: false,
});
render(<OAuthButtons disabled={true} />, { wrapper: createWrapper() });
const button = screen.getByRole('button', { name: /continue with google/i });
fireEvent.click(button);
expect(mockMutate).not.toHaveBeenCalled();
});
it('should not initiate OAuth when another button is pending', () => {
mockUseOAuthProviders.mockReturnValue({
data: [{ name: 'google', display_name: 'Google' }],
isLoading: false,
});
mockUseInitiateOAuth.mockReturnValue({
mutate: mockMutate,
isPending: true,
variables: 'google',
});
render(<OAuthButtons />, { wrapper: createWrapper() });
const button = screen.getByRole('button', { name: /connecting/i });
fireEvent.click(button);
// Should not call mutate again
expect(mockMutate).not.toHaveBeenCalled();
});
});
describe('Loading State During OAuth', () => {
it('should show loading state on clicked button', () => {
mockUseOAuthProviders.mockReturnValue({
data: [{ name: 'google', display_name: 'Google' }],
isLoading: false,
});
mockUseInitiateOAuth.mockReturnValue({
mutate: mockMutate,
isPending: true,
variables: 'google',
});
render(<OAuthButtons />, { wrapper: createWrapper() });
expect(screen.getByText(/connecting/i)).toBeInTheDocument();
expect(screen.queryByText(/continue with google/i)).not.toBeInTheDocument();
});
it('should show spinner icon during loading', () => {
mockUseOAuthProviders.mockReturnValue({
data: [{ name: 'google', display_name: 'Google' }],
isLoading: false,
});
mockUseInitiateOAuth.mockReturnValue({
mutate: mockMutate,
isPending: true,
variables: 'google',
});
const { container } = render(<OAuthButtons />, { wrapper: createWrapper() });
// Loader2 icon should be rendered
const spinner = container.querySelector('.animate-spin');
expect(spinner).toBeInTheDocument();
});
it('should only show loading on the clicked button', () => {
mockUseOAuthProviders.mockReturnValue({
data: [
{ name: 'google', display_name: 'Google' },
{ name: 'facebook', display_name: 'Facebook' },
],
isLoading: false,
});
mockUseInitiateOAuth.mockReturnValue({
mutate: mockMutate,
isPending: true,
variables: 'google',
});
render(<OAuthButtons />, { wrapper: createWrapper() });
// Google button should show loading
expect(screen.getByText(/connecting/i)).toBeInTheDocument();
// Facebook button should still show normal text
expect(screen.getByText(/continue with facebook/i)).toBeInTheDocument();
});
});
describe('Disabled State', () => {
it('should disable all buttons when disabled prop is true', () => {
mockUseOAuthProviders.mockReturnValue({
data: [
{ name: 'google', display_name: 'Google' },
{ name: 'facebook', display_name: 'Facebook' },
],
isLoading: false,
});
render(<OAuthButtons disabled={true} />, { wrapper: createWrapper() });
const googleButton = screen.getByRole('button', { name: /continue with google/i });
const facebookButton = screen.getByRole('button', { name: /continue with facebook/i });
expect(googleButton).toBeDisabled();
expect(facebookButton).toBeDisabled();
});
it('should apply disabled styling when disabled', () => {
mockUseOAuthProviders.mockReturnValue({
data: [{ name: 'google', display_name: 'Google' }],
isLoading: false,
});
render(<OAuthButtons disabled={true} />, { wrapper: createWrapper() });
const button = screen.getByRole('button', { name: /continue with google/i });
expect(button).toHaveClass('disabled:opacity-50', 'disabled:cursor-not-allowed');
});
it('should disable all buttons during OAuth pending', () => {
mockUseOAuthProviders.mockReturnValue({
data: [
{ name: 'google', display_name: 'Google' },
{ name: 'facebook', display_name: 'Facebook' },
],
isLoading: false,
});
mockUseInitiateOAuth.mockReturnValue({
mutate: mockMutate,
isPending: true,
variables: 'google',
});
render(<OAuthButtons />, { wrapper: createWrapper() });
const buttons = screen.getAllByRole('button');
buttons.forEach(button => {
expect(button).toBeDisabled();
});
});
});
describe('Error Handling', () => {
it('should log error on OAuth initiation failure', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
mockUseOAuthProviders.mockReturnValue({
data: [{ name: 'google', display_name: 'Google' }],
isLoading: false,
});
const error = new Error('OAuth error');
mockMutate.mockImplementation((provider, { onError }) => {
onError?.(error);
});
render(<OAuthButtons />, { wrapper: createWrapper() });
const button = screen.getByRole('button', { name: /continue with google/i });
fireEvent.click(button);
expect(consoleErrorSpy).toHaveBeenCalledWith('OAuth initiation error:', error);
consoleErrorSpy.mockRestore();
});
});
describe('Provider Variants', () => {
it('should render Microsoft provider', () => {
mockUseOAuthProviders.mockReturnValue({
data: [{ name: 'microsoft', display_name: 'Microsoft' }],
isLoading: false,
});
render(<OAuthButtons />, { wrapper: createWrapper() });
const button = screen.getByRole('button', { name: /continue with microsoft/i });
expect(button).toBeInTheDocument();
expect(button).toHaveClass('bg-[#00A4EF]');
});
it('should render X (Twitter) provider', () => {
mockUseOAuthProviders.mockReturnValue({
data: [{ name: 'x', display_name: 'X' }],
isLoading: false,
});
render(<OAuthButtons />, { wrapper: createWrapper() });
const button = screen.getByRole('button', { name: /continue with x/i });
expect(button).toBeInTheDocument();
expect(button).toHaveClass('bg-black');
});
it('should render Twitch provider', () => {
mockUseOAuthProviders.mockReturnValue({
data: [{ name: 'twitch', display_name: 'Twitch' }],
isLoading: false,
});
render(<OAuthButtons />, { wrapper: createWrapper() });
const button = screen.getByRole('button', { name: /continue with twitch/i });
expect(button).toBeInTheDocument();
expect(button).toHaveClass('bg-[#9146FF]');
});
});
describe('Button Styling', () => {
it('should have consistent button styling', () => {
mockUseOAuthProviders.mockReturnValue({
data: [{ name: 'google', display_name: 'Google' }],
isLoading: false,
});
render(<OAuthButtons />, { wrapper: createWrapper() });
const button = screen.getByRole('button', { name: /continue with google/i });
expect(button).toHaveClass(
'w-full',
'flex',
'items-center',
'justify-center',
'rounded-lg',
'shadow-sm'
);
});
it('should have hover transition styles', () => {
mockUseOAuthProviders.mockReturnValue({
data: [{ name: 'google', display_name: 'Google' }],
isLoading: false,
});
render(<OAuthButtons />, { wrapper: createWrapper() });
const button = screen.getByRole('button', { name: /continue with google/i });
expect(button).toHaveClass('transition-all', 'duration-200');
});
it('should have focus ring styles', () => {
mockUseOAuthProviders.mockReturnValue({
data: [{ name: 'google', display_name: 'Google' }],
isLoading: false,
});
render(<OAuthButtons />, { wrapper: createWrapper() });
const button = screen.getByRole('button', { name: /continue with google/i });
expect(button).toHaveClass('focus:outline-none', 'focus:ring-2');
});
});
describe('Accessibility', () => {
it('should have button role for all providers', () => {
mockUseOAuthProviders.mockReturnValue({
data: [
{ name: 'google', display_name: 'Google' },
{ name: 'facebook', display_name: 'Facebook' },
],
isLoading: false,
});
render(<OAuthButtons />, { wrapper: createWrapper() });
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(2);
});
it('should have descriptive button text', () => {
mockUseOAuthProviders.mockReturnValue({
data: [{ name: 'google', display_name: 'Google' }],
isLoading: false,
});
render(<OAuthButtons />, { wrapper: createWrapper() });
expect(screen.getByRole('button', { name: /continue with google/i })).toBeInTheDocument();
});
it('should indicate loading state to screen readers', () => {
mockUseOAuthProviders.mockReturnValue({
data: [{ name: 'google', display_name: 'Google' }],
isLoading: false,
});
mockUseInitiateOAuth.mockReturnValue({
mutate: mockMutate,
isPending: true,
variables: 'google',
});
render(<OAuthButtons />, { wrapper: createWrapper() });
expect(screen.getByText(/connecting/i)).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,827 @@
/**
* Unit tests for OnboardingWizard component
*
* Tests the multi-step onboarding wizard for new businesses.
* Covers:
* - Step navigation (welcome -> stripe -> complete)
* - Step indicator visualization
* - Welcome step rendering and buttons
* - Stripe Connect integration step
* - Completion step
* - Skip functionality
* - Auto-advance on Stripe connection
* - URL parameter handling (OAuth callback)
* - Loading states
* - Business update on completion
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter, useSearchParams } from 'react-router-dom';
import OnboardingWizard from '../OnboardingWizard';
import { Business } from '../../types';
// Mock hooks
const mockUsePaymentConfig = vi.fn();
const mockUseUpdateBusiness = vi.fn();
const mockSetSearchParams = vi.fn();
const mockSearchParams = new URLSearchParams();
vi.mock('../../hooks/usePayments', () => ({
usePaymentConfig: () => mockUsePaymentConfig(),
}));
vi.mock('../../hooks/useBusiness', () => ({
useUpdateBusiness: () => mockUseUpdateBusiness(),
}));
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
...actual,
useSearchParams: () => [mockSearchParams, mockSetSearchParams],
};
});
// Mock ConnectOnboardingEmbed component
vi.mock('../ConnectOnboardingEmbed', () => ({
default: ({ onComplete, onError }: any) => (
<div data-testid="connect-embed">
<button onClick={() => onComplete()}>Complete Embed</button>
<button onClick={() => onError('Test error')}>Trigger Error</button>
</div>
),
}));
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, params?: Record<string, unknown>) => {
const translations: Record<string, string> = {
'onboarding.steps.welcome': 'Welcome',
'onboarding.steps.payments': 'Payments',
'onboarding.steps.complete': 'Complete',
'onboarding.welcome.title': `Welcome to ${params?.businessName}!`,
'onboarding.welcome.subtitle': "Let's get you set up",
'onboarding.welcome.whatsIncluded': "What's Included",
'onboarding.welcome.connectStripe': 'Connect to Stripe',
'onboarding.welcome.automaticPayouts': 'Automatic payouts',
'onboarding.welcome.pciCompliance': 'PCI compliance',
'onboarding.welcome.getStarted': 'Get Started',
'onboarding.welcome.skip': 'Skip for now',
'onboarding.stripe.title': 'Connect Stripe',
'onboarding.stripe.subtitle': `Accept payments with your ${params?.plan} plan`,
'onboarding.stripe.checkingStatus': 'Checking status...',
'onboarding.stripe.connected.title': 'Connected!',
'onboarding.stripe.connected.subtitle': 'Your account is ready',
'onboarding.stripe.continue': 'Continue',
'onboarding.stripe.doLater': 'Do this later',
'onboarding.complete.title': "You're all set!",
'onboarding.complete.subtitle': 'Ready to start',
'onboarding.complete.checklist.accountCreated': 'Account created',
'onboarding.complete.checklist.stripeConfigured': 'Stripe configured',
'onboarding.complete.checklist.readyForPayments': 'Ready for payments',
'onboarding.complete.goToDashboard': 'Go to Dashboard',
'onboarding.skipForNow': 'Skip for now',
};
return translations[key] || key;
},
}),
}));
// Test data factory
const createMockBusiness = (overrides?: Partial<Business>): Business => ({
id: '1',
name: 'Test Business',
subdomain: 'testbiz',
primaryColor: '#3B82F6',
secondaryColor: '#1E40AF',
whitelabelEnabled: false,
paymentsEnabled: false,
requirePaymentMethodToBook: false,
cancellationWindowHours: 24,
lateCancellationFeePercent: 50,
plan: 'Professional',
...overrides,
});
// Helper to wrap component with providers
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
<BrowserRouter>{children}</BrowserRouter>
</QueryClientProvider>
);
};
describe('OnboardingWizard', () => {
const mockOnComplete = vi.fn();
const mockOnSkip = vi.fn();
const mockRefetch = vi.fn();
const mockMutateAsync = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
mockSearchParams.delete('connect');
mockUsePaymentConfig.mockReturnValue({
data: {
connect_account: null,
},
isLoading: false,
refetch: mockRefetch,
});
mockUseUpdateBusiness.mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: false,
});
});
describe('Modal Rendering', () => {
it('should render modal overlay', () => {
const business = createMockBusiness();
const { container } = render(
<OnboardingWizard
business={business}
onComplete={mockOnComplete}
/>,
{ wrapper: createWrapper() }
);
// Modal has the fixed class for overlay
const modal = container.querySelector('.fixed');
expect(modal).toBeInTheDocument();
expect(modal).toHaveClass('inset-0');
});
it('should render close button', () => {
const business = createMockBusiness();
render(
<OnboardingWizard
business={business}
onComplete={mockOnComplete}
/>,
{ wrapper: createWrapper() }
);
const closeButton = screen.getAllByRole('button').find(btn =>
btn.querySelector('svg')
);
expect(closeButton).toBeInTheDocument();
});
it('should have scrollable content', () => {
const business = createMockBusiness();
const { container } = render(
<OnboardingWizard
business={business}
onComplete={mockOnComplete}
/>,
{ wrapper: createWrapper() }
);
const modal = container.querySelector('.overflow-auto');
expect(modal).toBeInTheDocument();
});
});
describe('Step Indicator', () => {
it('should render step indicator with 3 steps', () => {
const business = createMockBusiness();
const { container } = render(
<OnboardingWizard
business={business}
onComplete={mockOnComplete}
/>,
{ wrapper: createWrapper() }
);
const stepCircles = container.querySelectorAll('.rounded-full.w-8.h-8');
expect(stepCircles.length).toBe(3);
});
it('should highlight current step', () => {
const business = createMockBusiness();
const { container } = render(
<OnboardingWizard
business={business}
onComplete={mockOnComplete}
/>,
{ wrapper: createWrapper() }
);
const activeStep = container.querySelector('.bg-blue-600');
expect(activeStep).toBeInTheDocument();
});
it('should show completed steps with checkmark', async () => {
const business = createMockBusiness();
const { container } = render(
<OnboardingWizard
business={business}
onComplete={mockOnComplete}
/>,
{ wrapper: createWrapper() }
);
// Move to next step
const getStartedButton = screen.getByRole('button', { name: /get started/i });
fireEvent.click(getStartedButton);
// First step should show green background after navigation
await waitFor(() => {
const completedStep = container.querySelector('.bg-green-500');
expect(completedStep).toBeTruthy();
});
});
});
describe('Welcome Step', () => {
it('should render welcome step by default', () => {
const business = createMockBusiness({ name: 'Test Business' });
render(
<OnboardingWizard
business={business}
onComplete={mockOnComplete}
/>,
{ wrapper: createWrapper() }
);
expect(screen.getByText(/welcome to test business/i)).toBeInTheDocument();
});
it('should render sparkles icon', () => {
const business = createMockBusiness();
const { container } = render(
<OnboardingWizard
business={business}
onComplete={mockOnComplete}
/>,
{ wrapper: createWrapper() }
);
const iconCircle = container.querySelector('.bg-gradient-to-br.from-blue-500');
expect(iconCircle).toBeInTheDocument();
});
it('should show features list', () => {
const business = createMockBusiness();
render(
<OnboardingWizard
business={business}
onComplete={mockOnComplete}
/>,
{ wrapper: createWrapper() }
);
expect(screen.getByText(/connect to stripe/i)).toBeInTheDocument();
expect(screen.getByText(/automatic payouts/i)).toBeInTheDocument();
expect(screen.getByText(/pci compliance/i)).toBeInTheDocument();
});
it('should render Get Started button', () => {
const business = createMockBusiness();
render(
<OnboardingWizard
business={business}
onComplete={mockOnComplete}
/>,
{ wrapper: createWrapper() }
);
const button = screen.getByRole('button', { name: /get started/i });
expect(button).toBeInTheDocument();
expect(button).toHaveClass('bg-blue-600');
});
it('should render Skip button', () => {
const business = createMockBusiness();
render(
<OnboardingWizard
business={business}
onComplete={mockOnComplete}
/>,
{ wrapper: createWrapper() }
);
// Look for the skip button with exact text (not the close button with title)
const skipButtons = screen.getAllByRole('button', { name: /skip for now/i });
expect(skipButtons.length).toBeGreaterThan(0);
});
it('should advance to stripe step on Get Started click', () => {
const business = createMockBusiness();
render(
<OnboardingWizard
business={business}
onComplete={mockOnComplete}
/>,
{ wrapper: createWrapper() }
);
const getStartedButton = screen.getByRole('button', { name: /get started/i });
fireEvent.click(getStartedButton);
expect(screen.getByText(/connect stripe/i)).toBeInTheDocument();
});
});
describe('Stripe Connect Step', () => {
beforeEach(() => {
// Start at Stripe step
const business = createMockBusiness();
render(
<OnboardingWizard
business={business}
onComplete={mockOnComplete}
/>,
{ wrapper: createWrapper() }
);
const getStartedButton = screen.getByRole('button', { name: /get started/i });
fireEvent.click(getStartedButton);
});
it('should render Stripe step after welcome', () => {
expect(screen.getByText(/connect stripe/i)).toBeInTheDocument();
});
it('should show loading while checking status', () => {
mockUsePaymentConfig.mockReturnValue({
data: undefined,
isLoading: true,
refetch: mockRefetch,
});
const business = createMockBusiness();
render(
<OnboardingWizard
business={business}
onComplete={mockOnComplete}
/>,
{ wrapper: createWrapper() }
);
// Navigate to stripe step
fireEvent.click(screen.getByRole('button', { name: /get started/i }));
expect(screen.getByText(/checking status/i)).toBeInTheDocument();
});
it('should render ConnectOnboardingEmbed when not connected', () => {
expect(screen.getByTestId('connect-embed')).toBeInTheDocument();
});
it('should show success message when already connected', () => {
mockUsePaymentConfig.mockReturnValue({
data: {
connect_account: {
status: 'active',
charges_enabled: true,
},
},
isLoading: false,
refetch: mockRefetch,
});
const business = createMockBusiness();
render(
<OnboardingWizard
business={business}
onComplete={mockOnComplete}
/>,
{ wrapper: createWrapper() }
);
fireEvent.click(screen.getByRole('button', { name: /get started/i }));
// Component auto-advances to complete step when already connected
expect(screen.getByText(/you're all set!/i)).toBeInTheDocument();
});
it('should render Do This Later button', () => {
expect(screen.getByRole('button', { name: /do this later/i })).toBeInTheDocument();
});
it('should handle embedded onboarding completion', async () => {
const completeButton = screen.getByText('Complete Embed');
fireEvent.click(completeButton);
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalled();
});
});
it('should handle embedded onboarding error', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const errorButton = screen.getByText('Trigger Error');
fireEvent.click(errorButton);
expect(consoleErrorSpy).toHaveBeenCalledWith('Embedded onboarding error:', 'Test error');
consoleErrorSpy.mockRestore();
});
});
describe('Complete Step', () => {
it('should render complete step when Stripe is connected', () => {
mockUsePaymentConfig.mockReturnValue({
data: {
connect_account: {
status: 'active',
charges_enabled: true,
},
},
isLoading: false,
refetch: mockRefetch,
});
const business = createMockBusiness();
render(
<OnboardingWizard
business={business}
onComplete={mockOnComplete}
/>,
{ wrapper: createWrapper() }
);
// Navigate to stripe step - will auto-advance to complete since connected
fireEvent.click(screen.getByRole('button', { name: /get started/i }));
expect(screen.getByText(/you're all set!/i)).toBeInTheDocument();
});
it('should show completion checklist', () => {
mockUsePaymentConfig.mockReturnValue({
data: {
connect_account: {
status: 'active',
charges_enabled: true,
},
},
isLoading: false,
refetch: mockRefetch,
});
const business = createMockBusiness();
render(
<OnboardingWizard
business={business}
onComplete={mockOnComplete}
/>,
{ wrapper: createWrapper() }
);
fireEvent.click(screen.getByRole('button', { name: /get started/i }));
expect(screen.getByText(/account created/i)).toBeInTheDocument();
expect(screen.getByText(/stripe configured/i)).toBeInTheDocument();
expect(screen.getByText(/ready for payments/i)).toBeInTheDocument();
});
it('should render Go to Dashboard button', () => {
mockUsePaymentConfig.mockReturnValue({
data: {
connect_account: {
status: 'active',
charges_enabled: true,
},
},
isLoading: false,
refetch: mockRefetch,
});
const business = createMockBusiness();
render(
<OnboardingWizard
business={business}
onComplete={mockOnComplete}
/>,
{ wrapper: createWrapper() }
);
fireEvent.click(screen.getByRole('button', { name: /get started/i }));
expect(screen.getByRole('button', { name: /go to dashboard/i })).toBeInTheDocument();
});
it('should call onComplete when dashboard button clicked', async () => {
mockMutateAsync.mockResolvedValue({});
mockUsePaymentConfig.mockReturnValue({
data: {
connect_account: {
status: 'active',
charges_enabled: true,
},
},
isLoading: false,
refetch: mockRefetch,
});
const business = createMockBusiness();
render(
<OnboardingWizard
business={business}
onComplete={mockOnComplete}
/>,
{ wrapper: createWrapper() }
);
fireEvent.click(screen.getByRole('button', { name: /get started/i }));
const dashboardButton = screen.getByRole('button', { name: /go to dashboard/i });
fireEvent.click(dashboardButton);
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith({ initialSetupComplete: true });
expect(mockOnComplete).toHaveBeenCalled();
});
});
});
describe('Skip Functionality', () => {
it('should call onSkip when skip button clicked on welcome', async () => {
mockMutateAsync.mockResolvedValue({});
const business = createMockBusiness();
render(
<OnboardingWizard
business={business}
onComplete={mockOnComplete}
onSkip={mockOnSkip}
/>,
{ wrapper: createWrapper() }
);
// Find the text-based skip button (not the X close button)
const skipButtons = screen.getAllByRole('button', { name: /skip for now/i });
const skipButton = skipButtons.find(btn => btn.textContent?.includes('Skip for now'));
if (skipButton) {
fireEvent.click(skipButton);
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith({ initialSetupComplete: true });
expect(mockOnSkip).toHaveBeenCalled();
});
}
});
it('should call onComplete when no onSkip provided', async () => {
mockMutateAsync.mockResolvedValue({});
const business = createMockBusiness();
render(
<OnboardingWizard
business={business}
onComplete={mockOnComplete}
/>,
{ wrapper: createWrapper() }
);
const skipButtons = screen.getAllByRole('button', { name: /skip for now/i });
const skipButton = skipButtons.find(btn => btn.textContent?.includes('Skip for now'));
if (skipButton) {
fireEvent.click(skipButton);
await waitFor(() => {
expect(mockOnComplete).toHaveBeenCalled();
});
}
});
it('should update business setup complete flag on skip', async () => {
mockMutateAsync.mockResolvedValue({});
const business = createMockBusiness();
render(
<OnboardingWizard
business={business}
onComplete={mockOnComplete}
/>,
{ wrapper: createWrapper() }
);
const skipButtons = screen.getAllByRole('button', { name: /skip for now/i });
const skipButton = skipButtons.find(btn => btn.textContent?.includes('Skip for now'));
if (skipButton) {
fireEvent.click(skipButton);
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith({ initialSetupComplete: true });
});
}
});
it('should close wizard when X button clicked', async () => {
mockMutateAsync.mockResolvedValue({});
const business = createMockBusiness();
const { container } = render(
<OnboardingWizard
business={business}
onComplete={mockOnComplete}
/>,
{ wrapper: createWrapper() }
);
// Find X button (close button)
const closeButtons = screen.getAllByRole('button');
const xButton = closeButtons.find(btn => btn.querySelector('svg') && !btn.textContent?.trim());
if (xButton) {
fireEvent.click(xButton);
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled();
});
}
});
});
describe('Auto-advance on Stripe Connection', () => {
it('should auto-advance to complete when Stripe connects', async () => {
const business = createMockBusiness();
// Start not connected
mockUsePaymentConfig.mockReturnValue({
data: {
connect_account: null,
},
isLoading: false,
refetch: mockRefetch,
});
const { rerender } = render(
<OnboardingWizard
business={business}
onComplete={mockOnComplete}
/>,
{ wrapper: createWrapper() }
);
// Navigate to stripe step
fireEvent.click(screen.getByRole('button', { name: /get started/i }));
// Simulate Stripe connection
mockUsePaymentConfig.mockReturnValue({
data: {
connect_account: {
status: 'active',
charges_enabled: true,
},
},
isLoading: false,
refetch: mockRefetch,
});
rerender(
<OnboardingWizard
business={business}
onComplete={mockOnComplete}
/>
);
await waitFor(() => {
expect(screen.getByText(/you're all set!/i)).toBeInTheDocument();
});
});
});
describe('URL Parameter Handling', () => {
it('should handle connect=complete query parameter', () => {
mockSearchParams.set('connect', 'complete');
const business = createMockBusiness();
render(
<OnboardingWizard
business={business}
onComplete={mockOnComplete}
/>,
{ wrapper: createWrapper() }
);
expect(mockRefetch).toHaveBeenCalled();
expect(mockSetSearchParams).toHaveBeenCalledWith({});
});
it('should handle connect=refresh query parameter', () => {
mockSearchParams.set('connect', 'refresh');
const business = createMockBusiness();
render(
<OnboardingWizard
business={business}
onComplete={mockOnComplete}
/>,
{ wrapper: createWrapper() }
);
expect(mockRefetch).toHaveBeenCalled();
expect(mockSetSearchParams).toHaveBeenCalledWith({});
});
});
describe('Loading States', () => {
it('should disable dashboard button while updating', () => {
mockUseUpdateBusiness.mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: true,
});
mockUsePaymentConfig.mockReturnValue({
data: {
connect_account: {
status: 'active',
charges_enabled: true,
},
},
isLoading: false,
refetch: mockRefetch,
});
const business = createMockBusiness();
render(
<OnboardingWizard
business={business}
onComplete={mockOnComplete}
/>,
{ wrapper: createWrapper() }
);
fireEvent.click(screen.getByRole('button', { name: /get started/i }));
// Dashboard button should be disabled while updating
const buttons = screen.getAllByRole('button');
const dashboardButton = buttons.find(btn => btn.textContent?.includes('Dashboard') || btn.querySelector('.animate-spin'));
if (dashboardButton) {
expect(dashboardButton).toBeDisabled();
}
});
});
describe('Accessibility', () => {
it('should have proper modal structure', () => {
const business = createMockBusiness();
const { container } = render(
<OnboardingWizard
business={business}
onComplete={mockOnComplete}
/>,
{ wrapper: createWrapper() }
);
// Modal overlay with fixed positioning
const modalOverlay = container.querySelector('.fixed.z-50');
expect(modalOverlay).toBeInTheDocument();
});
it('should have semantic headings', () => {
const business = createMockBusiness();
render(
<OnboardingWizard
business={business}
onComplete={mockOnComplete}
/>,
{ wrapper: createWrapper() }
);
expect(screen.getByRole('heading', { level: 2 })).toBeInTheDocument();
});
it('should have accessible buttons', () => {
const business = createMockBusiness();
render(
<OnboardingWizard
business={business}
onComplete={mockOnComplete}
/>,
{ wrapper: createWrapper() }
);
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,481 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import ResourceCalendar from '../ResourceCalendar';
import { Appointment } from '../../types';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, defaultValue?: string) => defaultValue || key,
}),
}));
// Mock Portal component
vi.mock('../Portal', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
// Mock date-fns to control time-based tests
vi.mock('date-fns', async () => {
const actual = await vi.importActual('date-fns');
return {
...actual,
};
});
// Use today's date for appointments so they show up in the calendar
const today = new Date();
today.setHours(10, 0, 0, 0);
const mockAppointments: Appointment[] = [
{
id: '1',
resourceId: 'resource-1',
customerId: 'customer-1',
customerName: 'John Doe',
serviceId: 'service-1',
startTime: new Date(today.getTime()),
durationMinutes: 60,
status: 'SCHEDULED',
notes: 'First appointment',
depositAmount: null,
depositTransactionId: '',
finalPrice: null,
finalChargeTransactionId: '',
isVariablePricing: false,
remainingBalance: null,
overpaidAmount: null,
},
{
id: '2',
resourceId: 'resource-1',
customerId: 'customer-2',
customerName: 'Jane Smith',
serviceId: 'service-2',
startTime: new Date(today.getTime() + 4.5 * 60 * 60 * 1000), // 14:30
durationMinutes: 90,
status: 'SCHEDULED',
notes: 'Second appointment',
depositAmount: null,
depositTransactionId: '',
finalPrice: null,
finalChargeTransactionId: '',
isVariablePricing: false,
remainingBalance: null,
overpaidAmount: null,
},
{
id: '3',
resourceId: 'resource-2',
customerId: 'customer-3',
customerName: 'Bob Johnson',
serviceId: 'service-1',
startTime: new Date(today.getTime() + 1 * 60 * 60 * 1000), // 11:00
durationMinutes: 45,
status: 'SCHEDULED',
notes: 'Different resource',
depositAmount: null,
depositTransactionId: '',
finalPrice: null,
finalChargeTransactionId: '',
isVariablePricing: false,
remainingBalance: null,
overpaidAmount: null,
},
];
vi.mock('../../hooks/useAppointments', () => ({
useAppointments: vi.fn(),
useUpdateAppointment: vi.fn(),
}));
import { useAppointments, useUpdateAppointment } from '../../hooks/useAppointments';
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('ResourceCalendar', () => {
const mockOnClose = vi.fn();
const mockUpdateMutate = vi.fn();
const defaultProps = {
resourceId: 'resource-1',
resourceName: 'Dr. Smith',
onClose: mockOnClose,
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useAppointments).mockReturnValue({
data: mockAppointments,
isLoading: false,
} as any);
vi.mocked(useUpdateAppointment).mockReturnValue({
mutate: mockUpdateMutate,
mutateAsync: vi.fn(),
} as any);
});
describe('Rendering', () => {
it('renders calendar modal', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByText('Dr. Smith Calendar')).toBeInTheDocument();
});
it('displays close button', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
const closeButtons = screen.getAllByRole('button');
const closeButton = closeButtons.find(btn => btn.querySelector('svg'));
expect(closeButton).toBeInTheDocument();
});
it('calls onClose when close button is clicked', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
const closeButtons = screen.getAllByRole('button');
const closeButton = closeButtons.find(btn => btn.querySelector('svg'));
fireEvent.click(closeButton!);
expect(mockOnClose).toHaveBeenCalled();
});
it('displays resource name in title', () => {
render(<ResourceCalendar {...defaultProps} resourceName="Conference Room A" />, {
wrapper: createWrapper(),
});
expect(screen.getByText('Conference Room A Calendar')).toBeInTheDocument();
});
});
describe('View modes', () => {
it('renders day view by default', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
const dayButton = screen.getByRole('button', { name: /^day$/i });
expect(dayButton).toHaveClass('bg-white');
});
it('switches to week view when week button is clicked', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
const weekButton = screen.getByRole('button', { name: /^week$/i });
fireEvent.click(weekButton);
expect(weekButton).toHaveClass('bg-white');
});
it('switches to month view when month button is clicked', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
const monthButton = screen.getByRole('button', { name: /^month$/i });
fireEvent.click(monthButton);
expect(monthButton).toHaveClass('bg-white');
});
});
describe('Navigation', () => {
it('displays Today button', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByRole('button', { name: /today/i })).toBeInTheDocument();
});
it('displays previous and next navigation buttons', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
const buttons = screen.getAllByRole('button');
const navButtons = buttons.filter(btn => btn.querySelector('svg'));
expect(navButtons.length).toBeGreaterThan(2);
});
it('navigates to previous day in day view', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
const buttons = screen.getAllByRole('button');
const prevButton = buttons.find(btn => {
const svg = btn.querySelector('svg');
return svg && btn.querySelector('[class*="ChevronLeft"]');
});
// Initial date rendering
const initialText = screen.getByText(/\w+, \w+ \d+, \d{4}/);
const initialDate = initialText.textContent;
if (prevButton) {
fireEvent.click(prevButton);
const newText = screen.getByText(/\w+, \w+ \d+, \d{4}/);
expect(newText.textContent).not.toBe(initialDate);
}
});
it('clicks Today button to reset to current date', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
const todayButton = screen.getByRole('button', { name: /today/i });
fireEvent.click(todayButton);
// Should display current date
expect(screen.getByText(/\w+, \w+ \d+, \d{4}/)).toBeInTheDocument();
});
});
describe('Appointments display', () => {
it('displays appointments for the selected resource', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
});
it('filters out appointments for other resources', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.queryByText('Bob Johnson')).not.toBeInTheDocument();
});
it('displays appointment customer names', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
});
it('displays appointment time and duration', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
// Check for time format (e.g., "10:00 AM • 60 min")
// Use getAllByText since there might be multiple appointments with same duration
const timeElements = screen.getAllByText(/10:00 AM/);
expect(timeElements.length).toBeGreaterThan(0);
const durationElements = screen.getAllByText(/1h/);
expect(durationElements.length).toBeGreaterThan(0);
});
});
describe('Loading states', () => {
it('displays loading message when loading', () => {
vi.mocked(useAppointments).mockReturnValue({
data: [],
isLoading: true,
} as any);
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByText('scheduler.loadingAppointments')).toBeInTheDocument();
});
it('displays empty state when no appointments', () => {
vi.mocked(useAppointments).mockReturnValue({
data: [],
isLoading: false,
} as any);
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByText('scheduler.noAppointmentsScheduled')).toBeInTheDocument();
});
});
describe('Week view', () => {
it('renders week view when week button is clicked', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
const weekButton = screen.getByRole('button', { name: /^week$/i });
fireEvent.click(weekButton);
// Verify week button is active (has bg-white class)
expect(weekButton).toHaveClass('bg-white');
});
it('week view shows different content than day view', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
// Get content in day view
const dayViewContent = document.body.textContent || '';
// Switch to week view
fireEvent.click(screen.getByRole('button', { name: /^week$/i }));
// Get content in week view
const weekViewContent = document.body.textContent || '';
// Week view and day view should have different content
// (Week view shows multiple days, day view shows single day timeline)
expect(weekViewContent).not.toBe(dayViewContent);
// Week view should show hint text for clicking days
expect(screen.getByText(/click a day to view details/i)).toBeInTheDocument();
});
});
describe('Month view', () => {
it('displays calendar grid in month view', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
fireEvent.click(screen.getByRole('button', { name: /month/i }));
// Should show weekday headers
expect(screen.getByText('Mon')).toBeInTheDocument();
expect(screen.getByText('Tue')).toBeInTheDocument();
expect(screen.getByText('Wed')).toBeInTheDocument();
});
it('shows appointment count in month view cells', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
fireEvent.click(screen.getByRole('button', { name: /month/i }));
// Should show "2 appts" for the day with 2 appointments
expect(screen.getByText(/2 appt/)).toBeInTheDocument();
});
it('clicking a day in month view switches to week view', async () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
fireEvent.click(screen.getByRole('button', { name: /month/i }));
// Find day cells and click one
const dayCells = screen.getAllByText(/^\d+$/);
if (dayCells.length > 0) {
fireEvent.click(dayCells[0].closest('div')!);
await waitFor(() => {
const weekButton = screen.getByRole('button', { name: /week/i });
expect(weekButton).toHaveClass('bg-white');
});
}
});
});
describe('Drag and drop (day view)', () => {
it('displays drag hint in day view', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByText(/drag to move/i)).toBeInTheDocument();
});
it('displays click hint in week/month view', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
fireEvent.click(screen.getByRole('button', { name: /week/i }));
expect(screen.getByText(/click a day to view details/i)).toBeInTheDocument();
});
});
describe('Appointment interactions', () => {
it('renders appointments with appropriate styling in day view', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
// Verify appointments are rendered
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
// Verify they have parent elements (appointment containers)
const appointment1 = screen.getByText('John Doe').parentElement;
const appointment2 = screen.getByText('Jane Smith').parentElement;
expect(appointment1).toBeInTheDocument();
expect(appointment2).toBeInTheDocument();
});
});
describe('Duration formatting', () => {
it('formats duration less than 60 minutes as minutes', () => {
const shortAppointment: Appointment = {
...mockAppointments[0],
durationMinutes: 45,
};
vi.mocked(useAppointments).mockReturnValue({
data: [shortAppointment],
isLoading: false,
} as any);
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByText(/45 min/)).toBeInTheDocument();
});
it('formats duration 60+ minutes as hours', () => {
const longAppointment: Appointment = {
...mockAppointments[0],
durationMinutes: 120,
};
vi.mocked(useAppointments).mockReturnValue({
data: [longAppointment],
isLoading: false,
} as any);
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByText(/2h/)).toBeInTheDocument();
});
it('formats duration with hours and minutes', () => {
const mixedAppointment: Appointment = {
...mockAppointments[0],
durationMinutes: 90,
};
vi.mocked(useAppointments).mockReturnValue({
data: [mixedAppointment],
isLoading: false,
} as any);
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByText(/1h 30m/)).toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('has accessible button labels', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByRole('button', { name: /^day$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^week$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^month$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^today$/i })).toBeInTheDocument();
});
});
describe('Overlapping appointments', () => {
it('handles overlapping appointments with lane layout', () => {
const todayAt10 = new Date();
todayAt10.setHours(10, 0, 0, 0);
const todayAt1030 = new Date();
todayAt1030.setHours(10, 30, 0, 0);
const overlappingAppointments: Appointment[] = [
{
...mockAppointments[0],
startTime: todayAt10,
durationMinutes: 120,
},
{
...mockAppointments[1],
id: '2',
startTime: todayAt1030,
durationMinutes: 60,
},
];
vi.mocked(useAppointments).mockReturnValue({
data: overlappingAppointments,
isLoading: false,
} as any);
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
});
});
describe('Props variations', () => {
it('works with different resource IDs', () => {
render(<ResourceCalendar {...defaultProps} resourceId="resource-2" />, {
wrapper: createWrapper(),
});
expect(screen.getByText('Bob Johnson')).toBeInTheDocument();
});
it('updates when resource name changes', () => {
const { rerender } = render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByText('Dr. Smith Calendar')).toBeInTheDocument();
rerender(
<QueryClientProvider client={new QueryClient()}>
<ResourceCalendar {...defaultProps} resourceName="Dr. Jones" />
</QueryClientProvider>
);
expect(screen.getByText('Dr. Jones Calendar')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,86 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import SandboxBanner from '../SandboxBanner';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, defaultValue?: string) => defaultValue || key,
}),
}));
describe('SandboxBanner', () => {
const defaultProps = {
isSandbox: true,
onSwitchToLive: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
it('renders when in sandbox mode', () => {
render(<SandboxBanner {...defaultProps} />);
expect(screen.getByText('TEST MODE')).toBeInTheDocument();
});
it('returns null when not in sandbox mode', () => {
const { container } = render(<SandboxBanner {...defaultProps} isSandbox={false} />);
expect(container.firstChild).toBeNull();
});
it('renders banner description', () => {
render(<SandboxBanner {...defaultProps} />);
expect(screen.getByText(/You are viewing test data/)).toBeInTheDocument();
});
it('renders switch to live button', () => {
render(<SandboxBanner {...defaultProps} />);
expect(screen.getByText('Switch to Live')).toBeInTheDocument();
});
it('calls onSwitchToLive when button clicked', () => {
const onSwitchToLive = vi.fn();
render(<SandboxBanner {...defaultProps} onSwitchToLive={onSwitchToLive} />);
fireEvent.click(screen.getByText('Switch to Live'));
expect(onSwitchToLive).toHaveBeenCalled();
});
it('disables button when switching', () => {
render(<SandboxBanner {...defaultProps} isSwitching />);
expect(screen.getByText('Switching...')).toBeDisabled();
});
it('shows switching text when isSwitching is true', () => {
render(<SandboxBanner {...defaultProps} isSwitching />);
expect(screen.getByText('Switching...')).toBeInTheDocument();
});
it('renders dismiss button when onDismiss provided', () => {
render(<SandboxBanner {...defaultProps} onDismiss={() => {}} />);
expect(screen.getByTitle('Dismiss')).toBeInTheDocument();
});
it('does not render dismiss button when onDismiss not provided', () => {
render(<SandboxBanner {...defaultProps} />);
expect(screen.queryByTitle('Dismiss')).not.toBeInTheDocument();
});
it('calls onDismiss when dismiss button clicked', () => {
const onDismiss = vi.fn();
render(<SandboxBanner {...defaultProps} onDismiss={onDismiss} />);
fireEvent.click(screen.getByTitle('Dismiss'));
expect(onDismiss).toHaveBeenCalled();
});
it('has gradient background', () => {
const { container } = render(<SandboxBanner {...defaultProps} />);
expect(container.firstChild).toHaveClass('bg-gradient-to-r');
});
it('renders flask icon', () => {
const { container } = render(<SandboxBanner {...defaultProps} />);
const svg = container.querySelector('svg');
expect(svg).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,108 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import SandboxToggle from '../SandboxToggle';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, defaultValue?: string) => defaultValue || key,
}),
}));
describe('SandboxToggle', () => {
const defaultProps = {
isSandbox: false,
sandboxEnabled: true,
onToggle: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
it('renders when sandbox is enabled', () => {
render(<SandboxToggle {...defaultProps} />);
expect(screen.getByText('Live')).toBeInTheDocument();
expect(screen.getByText('Test')).toBeInTheDocument();
});
it('returns null when sandbox not enabled', () => {
const { container } = render(<SandboxToggle {...defaultProps} sandboxEnabled={false} />);
expect(container.firstChild).toBeNull();
});
it('highlights Live button when not in sandbox mode', () => {
render(<SandboxToggle {...defaultProps} isSandbox={false} />);
const liveButton = screen.getByText('Live').closest('button');
expect(liveButton).toHaveClass('bg-green-600');
});
it('highlights Test button when in sandbox mode', () => {
render(<SandboxToggle {...defaultProps} isSandbox={true} />);
const testButton = screen.getByText('Test').closest('button');
expect(testButton).toHaveClass('bg-orange-500');
});
it('calls onToggle with false when Live clicked', () => {
const onToggle = vi.fn();
render(<SandboxToggle {...defaultProps} isSandbox={true} onToggle={onToggle} />);
fireEvent.click(screen.getByText('Live'));
expect(onToggle).toHaveBeenCalledWith(false);
});
it('calls onToggle with true when Test clicked', () => {
const onToggle = vi.fn();
render(<SandboxToggle {...defaultProps} isSandbox={false} onToggle={onToggle} />);
fireEvent.click(screen.getByText('Test'));
expect(onToggle).toHaveBeenCalledWith(true);
});
it('disables Live button when already in live mode', () => {
render(<SandboxToggle {...defaultProps} isSandbox={false} />);
const liveButton = screen.getByText('Live').closest('button');
expect(liveButton).toBeDisabled();
});
it('disables Test button when already in sandbox mode', () => {
render(<SandboxToggle {...defaultProps} isSandbox={true} />);
const testButton = screen.getByText('Test').closest('button');
expect(testButton).toBeDisabled();
});
it('disables both buttons when toggling', () => {
render(<SandboxToggle {...defaultProps} isToggling />);
const liveButton = screen.getByText('Live').closest('button');
const testButton = screen.getByText('Test').closest('button');
expect(liveButton).toBeDisabled();
expect(testButton).toBeDisabled();
});
it('applies opacity when toggling', () => {
render(<SandboxToggle {...defaultProps} isToggling />);
const liveButton = screen.getByText('Live').closest('button');
expect(liveButton).toHaveClass('opacity-50');
});
it('applies custom className', () => {
const { container } = render(<SandboxToggle {...defaultProps} className="custom-class" />);
expect(container.firstChild).toHaveClass('custom-class');
});
it('has title for Live button', () => {
render(<SandboxToggle {...defaultProps} />);
const liveButton = screen.getByText('Live').closest('button');
expect(liveButton).toHaveAttribute('title', 'Live Mode - Production data');
});
it('has title for Test button', () => {
render(<SandboxToggle {...defaultProps} />);
const testButton = screen.getByText('Test').closest('button');
expect(testButton).toHaveAttribute('title', 'Test Mode - Sandbox data');
});
it('renders icons', () => {
const { container } = render(<SandboxToggle {...defaultProps} />);
const svgs = container.querySelectorAll('svg');
expect(svgs.length).toBe(2); // Zap and FlaskConical icons
});
});

View File

@@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import SmoothScheduleLogo from '../SmoothScheduleLogo';
describe('SmoothScheduleLogo', () => {
it('renders an SVG element', () => {
const { container } = render(<SmoothScheduleLogo />);
const svg = container.querySelector('svg');
expect(svg).toBeInTheDocument();
});
it('has correct viewBox', () => {
const { container } = render(<SmoothScheduleLogo />);
const svg = container.querySelector('svg');
expect(svg).toHaveAttribute('viewBox', '0 0 1730 1100');
});
it('uses currentColor for fill', () => {
const { container } = render(<SmoothScheduleLogo />);
const svg = container.querySelector('svg');
expect(svg).toHaveAttribute('fill', 'currentColor');
});
it('applies custom className', () => {
const { container } = render(<SmoothScheduleLogo className="custom-logo-class" />);
const svg = container.querySelector('svg');
expect(svg).toHaveClass('custom-logo-class');
});
it('renders without className when not provided', () => {
const { container } = render(<SmoothScheduleLogo />);
const svg = container.querySelector('svg');
expect(svg).toBeInTheDocument();
});
it('contains path elements', () => {
const { container } = render(<SmoothScheduleLogo />);
const paths = container.querySelectorAll('path');
expect(paths.length).toBeGreaterThan(0);
});
it('has xmlns attribute', () => {
const { container } = render(<SmoothScheduleLogo />);
const svg = container.querySelector('svg');
expect(svg).toHaveAttribute('xmlns', 'http://www.w3.org/2000/svg');
});
});

View File

@@ -0,0 +1,716 @@
/**
* 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');
});
});
});

View File

@@ -508,4 +508,230 @@ describe('TrialBanner', () => {
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
});
});
describe('Additional Edge Cases', () => {
it('should handle negative days left gracefully', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: -5,
});
renderWithRouter(<TrialBanner business={business} />);
// Should still render (backend shouldn't send this, but defensive coding)
expect(screen.getByText(/-5 days left in trial/i)).toBeInTheDocument();
});
it('should handle fractional days by rounding', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 5.7 as number,
});
renderWithRouter(<TrialBanner business={business} />);
// Should display with the value received
expect(screen.getByText(/5.7 days left in trial/i)).toBeInTheDocument();
});
it('should transition from urgent to non-urgent styling on update', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 3,
});
const { container, rerender } = renderWithRouter(<TrialBanner business={business} />);
// Initially urgent
expect(container.querySelector('.from-red-500')).toBeInTheDocument();
// Update to non-urgent
const updatedBusiness = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
rerender(
<BrowserRouter>
<TrialBanner business={updatedBusiness} />
</BrowserRouter>
);
// Should now be non-urgent
expect(container.querySelector('.from-blue-600')).toBeInTheDocument();
expect(container.querySelector('.from-red-500')).not.toBeInTheDocument();
});
it('should handle business without name gracefully', () => {
const business = createMockBusiness({
name: '',
isTrialActive: true,
daysLeftInTrial: 10,
});
renderWithRouter(<TrialBanner business={business} />);
// Should still render the banner
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
});
it('should handle switching from active to inactive trial', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 5,
});
const { rerender } = renderWithRouter(<TrialBanner business={business} />);
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
// Update to inactive
const updatedBusiness = createMockBusiness({
isTrialActive: false,
daysLeftInTrial: 5,
});
rerender(
<BrowserRouter>
<TrialBanner business={updatedBusiness} />
</BrowserRouter>
);
// Should no longer render
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
});
});
describe('Button Interactions', () => {
it('should prevent multiple rapid clicks on upgrade button', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
renderWithRouter(<TrialBanner business={business} />);
const upgradeButton = screen.getByRole('button', { name: /upgrade now/i });
// Rapid clicks
fireEvent.click(upgradeButton);
fireEvent.click(upgradeButton);
fireEvent.click(upgradeButton);
// Navigate should still only be called once per click (no debouncing in component)
expect(mockNavigate).toHaveBeenCalledTimes(3);
});
it('should not interfere with other buttons after dismiss', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
renderWithRouter(<TrialBanner business={business} />);
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
fireEvent.click(dismissButton);
// Banner is gone
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
// Upgrade button should also be gone
expect(screen.queryByRole('button', { name: /upgrade now/i })).not.toBeInTheDocument();
});
});
describe('Visual States', () => {
it('should have shadow and proper background for visibility', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
const { container } = renderWithRouter(<TrialBanner business={business} />);
const banner = container.querySelector('.shadow-md');
expect(banner).toBeInTheDocument();
});
it('should have gradient background for visual appeal', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
const { container } = renderWithRouter(<TrialBanner business={business} />);
const gradient = container.querySelector('.bg-gradient-to-r');
expect(gradient).toBeInTheDocument();
});
it('should show hover states on interactive elements', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
renderWithRouter(<TrialBanner business={business} />);
const upgradeButton = screen.getByRole('button', { name: /upgrade now/i });
expect(upgradeButton).toHaveClass('hover:bg-blue-50');
});
it('should have appropriate spacing and padding', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
const { container } = renderWithRouter(<TrialBanner business={business} />);
// Check for padding classes
const contentContainer = container.querySelector('.py-3');
expect(contentContainer).toBeInTheDocument();
});
});
describe('Icon Rendering', () => {
it('should render icons with proper size', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
const { container } = renderWithRouter(<TrialBanner business={business} />);
// Icons should have consistent size classes
const iconContainer = container.querySelector('.rounded-full');
expect(iconContainer).toBeInTheDocument();
});
it('should show different icons for urgent vs non-urgent states', () => {
const nonUrgentBusiness = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
const { container: container1, unmount } = renderWithRouter(
<TrialBanner business={nonUrgentBusiness} />
);
// Non-urgent should not have pulse animation
expect(container1.querySelector('.animate-pulse')).not.toBeInTheDocument();
unmount();
const urgentBusiness = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 2,
});
const { container: container2 } = renderWithRouter(
<TrialBanner business={urgentBusiness} />
);
// Urgent should have pulse animation
expect(container2.querySelector('.animate-pulse')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,567 @@
/**
* Unit tests for UpgradePrompt, LockedSection, and LockedButton components
*
* Tests upgrade prompts that appear when features are not available in the current plan.
* Covers:
* - Different variants (inline, banner, overlay)
* - Different sizes (sm, md, lg)
* - Feature names and descriptions
* - Navigation to billing page
* - LockedSection wrapper behavior
* - LockedButton disabled state and tooltip
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, within } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import {
UpgradePrompt,
LockedSection,
LockedButton,
} from '../UpgradePrompt';
import { FeatureKey } from '../../hooks/usePlanFeatures';
// Mock react-router-dom's Link component
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
...actual,
Link: ({ to, children, className, ...props }: any) => (
<a href={to} className={className} {...props}>
{children}
</a>
),
};
});
// Wrapper component that provides router context
const renderWithRouter = (ui: React.ReactElement) => {
return render(<BrowserRouter>{ui}</BrowserRouter>);
};
describe('UpgradePrompt', () => {
describe('Inline Variant', () => {
it('should render inline upgrade prompt with lock icon', () => {
renderWithRouter(<UpgradePrompt feature="sms_reminders" variant="inline" />);
expect(screen.getByText('Upgrade Required')).toBeInTheDocument();
// Check for styling classes
const container = screen.getByText('Upgrade Required').parentElement;
expect(container).toHaveClass('bg-amber-50', 'text-amber-700');
});
it('should render small badge style for inline variant', () => {
const { container } = renderWithRouter(
<UpgradePrompt feature="webhooks" variant="inline" />
);
const badge = container.querySelector('.bg-amber-50');
expect(badge).toBeInTheDocument();
expect(badge).toHaveClass('text-xs', 'rounded-md');
});
it('should not show description or upgrade button in inline variant', () => {
renderWithRouter(<UpgradePrompt feature="api_access" variant="inline" />);
expect(screen.queryByText(/integrate with external/i)).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: /upgrade your plan/i })).not.toBeInTheDocument();
});
it('should render for any feature in inline mode', () => {
const features: FeatureKey[] = ['plugins', 'custom_domain', 'white_label'];
features.forEach((feature) => {
const { unmount } = renderWithRouter(
<UpgradePrompt feature={feature} variant="inline" />
);
expect(screen.getByText('Upgrade Required')).toBeInTheDocument();
unmount();
});
});
});
describe('Banner Variant', () => {
it('should render banner with feature name and crown icon', () => {
renderWithRouter(<UpgradePrompt feature="sms_reminders" variant="banner" />);
expect(screen.getByText(/sms reminders.*upgrade required/i)).toBeInTheDocument();
});
it('should render feature description by default', () => {
renderWithRouter(<UpgradePrompt feature="sms_reminders" variant="banner" />);
expect(
screen.getByText(/send automated sms reminders to customers and staff/i)
).toBeInTheDocument();
});
it('should hide description when showDescription is false', () => {
renderWithRouter(
<UpgradePrompt
feature="sms_reminders"
variant="banner"
showDescription={false}
/>
);
expect(
screen.queryByText(/send automated sms reminders/i)
).not.toBeInTheDocument();
});
it('should render upgrade button linking to billing settings', () => {
renderWithRouter(<UpgradePrompt feature="webhooks" variant="banner" />);
const upgradeLink = screen.getByRole('link', { name: /upgrade your plan/i });
expect(upgradeLink).toBeInTheDocument();
expect(upgradeLink).toHaveAttribute('href', '/settings/billing');
});
it('should have gradient styling for banner variant', () => {
const { container } = renderWithRouter(
<UpgradePrompt feature="api_access" variant="banner" />
);
const banner = container.querySelector('.bg-gradient-to-br.from-amber-50');
expect(banner).toBeInTheDocument();
expect(banner).toHaveClass('border-2', 'border-amber-300');
});
it('should render crown icon in banner', () => {
renderWithRouter(<UpgradePrompt feature="custom_domain" variant="banner" />);
// Crown icon should be in the button text
const upgradeButton = screen.getByRole('link', { name: /upgrade your plan/i });
expect(upgradeButton).toBeInTheDocument();
});
it('should render all feature names correctly', () => {
const features: FeatureKey[] = [
'webhooks',
'api_access',
'custom_domain',
'white_label',
'plugins',
];
features.forEach((feature) => {
const { unmount } = renderWithRouter(
<UpgradePrompt feature={feature} variant="banner" />
);
// Feature name should be in the heading
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument();
unmount();
});
});
});
describe('Overlay Variant', () => {
it('should render overlay with blurred children', () => {
renderWithRouter(
<UpgradePrompt feature="sms_reminders" variant="overlay">
<div data-testid="locked-content">Locked Content</div>
</UpgradePrompt>
);
const lockedContent = screen.getByTestId('locked-content');
expect(lockedContent).toBeInTheDocument();
// Check that parent has blur styling
const parent = lockedContent.parentElement;
expect(parent).toHaveClass('blur-sm', 'opacity-50');
});
it('should render feature name and description in overlay', () => {
renderWithRouter(
<UpgradePrompt feature="webhooks" variant="overlay">
<div>Content</div>
</UpgradePrompt>
);
expect(screen.getByText('Webhooks')).toBeInTheDocument();
expect(
screen.getByText(/integrate with external services using webhooks/i)
).toBeInTheDocument();
});
it('should render lock icon in overlay', () => {
const { container } = renderWithRouter(
<UpgradePrompt feature="api_access" variant="overlay">
<div>Content</div>
</UpgradePrompt>
);
// Lock icon should be in a rounded circle
const iconCircle = container.querySelector('.rounded-full.bg-gradient-to-br');
expect(iconCircle).toBeInTheDocument();
});
it('should render upgrade button in overlay', () => {
renderWithRouter(
<UpgradePrompt feature="custom_domain" variant="overlay">
<div>Content</div>
</UpgradePrompt>
);
const upgradeLink = screen.getByRole('link', { name: /upgrade your plan/i });
expect(upgradeLink).toBeInTheDocument();
expect(upgradeLink).toHaveAttribute('href', '/settings/billing');
});
it('should apply small size styling', () => {
const { container } = renderWithRouter(
<UpgradePrompt feature="plugins" variant="overlay" size="sm">
<div>Content</div>
</UpgradePrompt>
);
const overlayContent = container.querySelector('.p-4');
expect(overlayContent).toBeInTheDocument();
});
it('should apply medium size styling by default', () => {
const { container } = renderWithRouter(
<UpgradePrompt feature="plugins" variant="overlay">
<div>Content</div>
</UpgradePrompt>
);
const overlayContent = container.querySelector('.p-6');
expect(overlayContent).toBeInTheDocument();
});
it('should apply large size styling', () => {
const { container } = renderWithRouter(
<UpgradePrompt feature="plugins" variant="overlay" size="lg">
<div>Content</div>
</UpgradePrompt>
);
const overlayContent = container.querySelector('.p-8');
expect(overlayContent).toBeInTheDocument();
});
it('should make children non-interactive', () => {
renderWithRouter(
<UpgradePrompt feature="white_label" variant="overlay">
<button data-testid="locked-button">Click Me</button>
</UpgradePrompt>
);
const button = screen.getByTestId('locked-button');
const parent = button.parentElement;
expect(parent).toHaveClass('pointer-events-none');
});
});
describe('Default Behavior', () => {
it('should default to banner variant when no variant specified', () => {
renderWithRouter(<UpgradePrompt feature="sms_reminders" />);
// Banner should show feature name in heading
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument();
expect(screen.getByText(/sms reminders.*upgrade required/i)).toBeInTheDocument();
});
it('should show description by default', () => {
renderWithRouter(<UpgradePrompt feature="webhooks" />);
expect(
screen.getByText(/integrate with external services/i)
).toBeInTheDocument();
});
it('should use medium size by default', () => {
const { container } = renderWithRouter(
<UpgradePrompt feature="plugins" variant="overlay">
<div>Content</div>
</UpgradePrompt>
);
const overlayContent = container.querySelector('.p-6');
expect(overlayContent).toBeInTheDocument();
});
});
});
describe('LockedSection', () => {
describe('Unlocked State', () => {
it('should render children when not locked', () => {
renderWithRouter(
<LockedSection feature="sms_reminders" isLocked={false}>
<div data-testid="content">Available Content</div>
</LockedSection>
);
expect(screen.getByTestId('content')).toBeInTheDocument();
expect(screen.getByText('Available Content')).toBeInTheDocument();
});
it('should not show upgrade prompt when unlocked', () => {
renderWithRouter(
<LockedSection feature="webhooks" isLocked={false}>
<div>Content</div>
</LockedSection>
);
expect(screen.queryByText(/upgrade required/i)).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: /upgrade your plan/i })).not.toBeInTheDocument();
});
});
describe('Locked State', () => {
it('should show banner prompt by default when locked', () => {
renderWithRouter(
<LockedSection feature="sms_reminders" isLocked={true}>
<div>Content</div>
</LockedSection>
);
expect(screen.getByText(/sms reminders.*upgrade required/i)).toBeInTheDocument();
});
it('should show overlay prompt when variant is overlay', () => {
renderWithRouter(
<LockedSection feature="api_access" isLocked={true} variant="overlay">
<div data-testid="locked-content">Locked Content</div>
</LockedSection>
);
expect(screen.getByTestId('locked-content')).toBeInTheDocument();
expect(screen.getByText('API Access')).toBeInTheDocument();
});
it('should show fallback content instead of upgrade prompt when provided', () => {
renderWithRouter(
<LockedSection
feature="custom_domain"
isLocked={true}
fallback={<div data-testid="fallback">Custom Fallback</div>}
>
<div>Original Content</div>
</LockedSection>
);
expect(screen.getByTestId('fallback')).toBeInTheDocument();
expect(screen.getByText('Custom Fallback')).toBeInTheDocument();
expect(screen.queryByText(/upgrade required/i)).not.toBeInTheDocument();
});
it('should not render original children when locked without overlay', () => {
renderWithRouter(
<LockedSection feature="webhooks" isLocked={true} variant="banner">
<div data-testid="original">Original Content</div>
</LockedSection>
);
expect(screen.queryByTestId('original')).not.toBeInTheDocument();
expect(screen.getByText(/webhooks.*upgrade required/i)).toBeInTheDocument();
});
it('should render blurred children with overlay variant', () => {
renderWithRouter(
<LockedSection feature="plugins" isLocked={true} variant="overlay">
<div data-testid="blurred-content">Blurred Content</div>
</LockedSection>
);
const content = screen.getByTestId('blurred-content');
expect(content).toBeInTheDocument();
expect(content.parentElement).toHaveClass('blur-sm');
});
});
describe('Different Features', () => {
it('should work with different feature keys', () => {
const features: FeatureKey[] = [
'white_label',
'custom_oauth',
'can_create_plugins',
'tasks',
];
features.forEach((feature) => {
const { unmount } = renderWithRouter(
<LockedSection feature={feature} isLocked={true}>
<div>Content</div>
</LockedSection>
);
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument();
unmount();
});
});
});
});
describe('LockedButton', () => {
describe('Unlocked State', () => {
it('should render normal clickable button when not locked', () => {
const handleClick = vi.fn();
renderWithRouter(
<LockedButton
feature="sms_reminders"
isLocked={false}
onClick={handleClick}
className="custom-class"
>
Click Me
</LockedButton>
);
const button = screen.getByRole('button', { name: /click me/i });
expect(button).toBeInTheDocument();
expect(button).not.toBeDisabled();
expect(button).toHaveClass('custom-class');
fireEvent.click(button);
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('should not show lock icon when unlocked', () => {
renderWithRouter(
<LockedButton feature="webhooks" isLocked={false}>
Submit
</LockedButton>
);
const button = screen.getByRole('button', { name: /submit/i });
expect(button.querySelector('svg')).not.toBeInTheDocument();
});
});
describe('Locked State', () => {
it('should render disabled button with lock icon when locked', () => {
renderWithRouter(
<LockedButton feature="api_access" isLocked={true}>
Submit
</LockedButton>
);
const button = screen.getByRole('button', { name: /submit/i });
expect(button).toBeDisabled();
expect(button).toHaveClass('opacity-50', 'cursor-not-allowed');
});
it('should display lock icon when locked', () => {
renderWithRouter(
<LockedButton feature="custom_domain" isLocked={true}>
Save
</LockedButton>
);
const button = screen.getByRole('button');
expect(button.textContent).toContain('Save');
});
it('should show tooltip on hover when locked', () => {
const { container } = renderWithRouter(
<LockedButton feature="plugins" isLocked={true}>
Create Plugin
</LockedButton>
);
// Tooltip should exist in DOM
const tooltip = container.querySelector('.opacity-0');
expect(tooltip).toBeInTheDocument();
expect(tooltip?.textContent).toContain('Upgrade Required');
});
it('should not trigger onClick when locked', () => {
const handleClick = vi.fn();
renderWithRouter(
<LockedButton
feature="white_label"
isLocked={true}
onClick={handleClick}
>
Click Me
</LockedButton>
);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(handleClick).not.toHaveBeenCalled();
});
it('should apply custom className even when locked', () => {
renderWithRouter(
<LockedButton
feature="webhooks"
isLocked={true}
className="custom-btn"
>
Submit
</LockedButton>
);
const button = screen.getByRole('button');
expect(button).toHaveClass('custom-btn');
});
it('should display feature name in tooltip', () => {
const { container } = renderWithRouter(
<LockedButton feature="sms_reminders" isLocked={true}>
Send SMS
</LockedButton>
);
const tooltip = container.querySelector('.whitespace-nowrap');
expect(tooltip?.textContent).toContain('SMS Reminders');
});
});
describe('Different Features', () => {
it('should work with various feature keys', () => {
const features: FeatureKey[] = [
'export_data',
'video_conferencing',
'two_factor_auth',
'masked_calling',
];
features.forEach((feature) => {
const { unmount } = renderWithRouter(
<LockedButton feature={feature} isLocked={true}>
Action
</LockedButton>
);
const button = screen.getByRole('button');
expect(button).toBeDisabled();
unmount();
});
});
});
describe('Accessibility', () => {
it('should have proper button role when unlocked', () => {
renderWithRouter(
<LockedButton feature="plugins" isLocked={false}>
Save
</LockedButton>
);
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
});
it('should have proper button role when locked', () => {
renderWithRouter(
<LockedButton feature="webhooks" isLocked={true}>
Submit
</LockedButton>
);
expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument();
});
it('should indicate disabled state for screen readers', () => {
renderWithRouter(
<LockedButton feature="api_access" isLocked={true}>
Create
</LockedButton>
);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('disabled');
});
});
});