3 Commits

Author SHA1 Message Date
poduck
47657e7076 Add staff permission controls for editing staff and customers
- Add can_edit_staff and can_edit_customers dangerous permissions
- Move Site Builder, Services, Locations, Time Blocks, Payments to Settings permissions
- Link Edit Others' Schedules and Edit Own Schedule permissions
- Add permission checks to StaffViewSet (partial_update, toggle_active, verify_email)
- Add permission checks to CustomerViewSet (update, partial_update, verify_email)
- Fix CustomerViewSet permission key mismatch (can_access_customers)
- Hide Edit/Verify buttons on Staff and Customers pages without permission
- Make dangerous permissions section more visually distinct (darker red)
- Fix StaffDashboard links to use correct paths (/dashboard/my-schedule)
- Disable settings sub-permissions when Access Settings is unchecked

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 17:38:48 -05:00
poduck
d7700a68fd Fix POS layout overflow by using h-full instead of h-screen
POSLayout was using h-screen which made it 100vh tall, but it's nested
inside a flex container that already accounts for POSHeader height.
Changed to h-full so it properly fills the available space.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 11:35:29 -05:00
poduck
1aa5b76e3b Add Point of Sale system and tax rate lookup integration
POS System:
- Full POS interface with product grid, cart panel, and payment flow
- Product and category management with barcode scanning support
- Cash drawer operations and shift management
- Order history and receipt generation
- Thermal printer integration (ESC/POS protocol)
- Gift card support with purchase and redemption
- Inventory tracking with low stock alerts
- Customer selection and walk-in support

Tax Rate Integration:
- ZIP-to-state mapping for automatic state detection
- SST boundary data import for 24 member states
- Static rates for uniform-rate states (IN, MA, CT, etc.)
- Statewide jurisdiction fallback for simple lookups
- Tax rate suggestion in location editor with auto-apply
- Multiple data sources: SST, CDTFA, TX Comptroller, Avalara

UI Improvements:
- POS renders full-screen outside BusinessLayout
- Clear cart button prominently in cart header
- Tax rate limited to 2 decimal places
- Location tax rate field with suggestion UI

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 11:31:19 -05:00
252 changed files with 91199 additions and 763 deletions

18
.idea/smoothschedule.iml generated Normal file
View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<module version="4">
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
<component name="TemplatesService">
<option name="TEMPLATE_CONFIGURATION" value="Jinja2" />
<option name="TEMPLATE_FOLDERS">
<list>
<option value="$MODULE_DIR$/smoothschedule/templates" />
</list>
</option>
</component>
<component name="TestRunnerService">
<option name="PROJECT_TEST_RUNNER" value="py.test" />
</component>
</module>

View File

@@ -130,6 +130,8 @@ const PublicPage = React.lazy(() => import('./pages/PublicPage')); // Import Pub
const BookingFlow = React.lazy(() => import('./pages/BookingFlow')); // Import Booking Flow
const Locations = React.lazy(() => import('./pages/Locations')); // Import Locations management page
const MediaGalleryPage = React.lazy(() => import('./pages/MediaGalleryPage')); // Import Media Gallery page
const POS = React.lazy(() => import('./pages/POS')); // Import Point of Sale page
const Products = React.lazy(() => import('./pages/Products')); // Import Products management page
// Settings pages
const SettingsLayout = React.lazy(() => import('./layouts/SettingsLayout'));
@@ -765,6 +767,18 @@ const AppContent: React.FC = () => {
<Route path="/login" element={<Navigate to="/dashboard" replace />} />
<Route path="/sign/:token" element={<ContractSigning />} />
{/* Point of Sale - Full screen mode outside BusinessLayout */}
<Route
path="/dashboard/pos"
element={
canAccess('can_access_pos') ? (
<POS />
) : (
<Navigate to="/dashboard" />
)
}
/>
{/* Dashboard routes inside BusinessLayout */}
<Route
element={
@@ -989,6 +1003,17 @@ const AppContent: React.FC = () => {
)
}
/>
{/* Products Management */}
<Route
path="/dashboard/products"
element={
canAccess('can_access_pos') ? (
<Products />
) : (
<Navigate to="/dashboard" />
)
}
/>
{/* Settings Routes with Nested Layout */}
{/* Owners have full access, staff need can_access_settings permission */}
{canAccess('can_access_settings') ? (

View File

@@ -0,0 +1,583 @@
/**
* Unit Tests for App Component
*
* Test Coverage:
* - Router setup and initialization
* - Loading states
* - Error states
* - Basic rendering
* - QueryClient provider
* - Toaster component
*
* Note: Due to complex routing logic based on subdomains and authentication state,
* detailed routing tests are covered in E2E tests. These unit tests focus on
* basic component rendering and state handling.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import App from '../App';
// Mock all lazy-loaded pages to avoid Suspense issues in tests
vi.mock('../pages/LoginPage', () => ({
default: () => <div data-testid="login-page">Login Page</div>,
}));
vi.mock('../pages/marketing/HomePage', () => ({
default: () => <div data-testid="home-page">Home Page</div>,
}));
vi.mock('../pages/Dashboard', () => ({
default: () => <div data-testid="dashboard">Dashboard</div>,
}));
vi.mock('../pages/platform/PlatformDashboard', () => ({
default: () => <div data-testid="platform-dashboard">Platform Dashboard</div>,
}));
vi.mock('../pages/customer/CustomerDashboard', () => ({
default: () => <div data-testid="customer-dashboard">Customer Dashboard</div>,
}));
// Mock all layouts
vi.mock('../layouts/BusinessLayout', () => ({
default: () => <div data-testid="business-layout">Business Layout</div>,
}));
vi.mock('../layouts/PlatformLayout', () => ({
default: () => <div data-testid="platform-layout">Platform Layout</div>,
}));
vi.mock('../layouts/CustomerLayout', () => ({
default: () => <div data-testid="customer-layout">Customer Layout</div>,
}));
vi.mock('../layouts/MarketingLayout', () => ({
default: () => <div data-testid="marketing-layout">Marketing Layout</div>,
}));
// Mock hooks
const mockUseCurrentUser = vi.fn();
const mockUseCurrentBusiness = vi.fn();
const mockUseMasquerade = vi.fn();
const mockUseLogout = vi.fn();
const mockUseUpdateBusiness = vi.fn();
const mockUsePlanFeatures = vi.fn();
vi.mock('../hooks/useAuth', () => ({
useCurrentUser: () => mockUseCurrentUser(),
useMasquerade: () => mockUseMasquerade(),
useLogout: () => mockUseLogout(),
}));
vi.mock('../hooks/useBusiness', () => ({
useCurrentBusiness: () => mockUseCurrentBusiness(),
useUpdateBusiness: () => mockUseUpdateBusiness(),
}));
vi.mock('../hooks/usePlanFeatures', () => ({
usePlanFeatures: () => mockUsePlanFeatures(),
}));
// Mock react-hot-toast
vi.mock('react-hot-toast', () => ({
Toaster: () => <div data-testid="toaster">Toaster</div>,
}));
// Mock cookies utility
vi.mock('../utils/cookies', () => ({
setCookie: vi.fn(),
deleteCookie: vi.fn(),
getCookie: vi.fn(),
}));
// Mock i18n
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'common.loading': 'Loading...',
'common.error': 'Error',
'common.reload': 'Reload',
};
return translations[key] || key;
},
}),
}));
describe('App', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default mock implementations
mockUseCurrentUser.mockReturnValue({
data: null,
isLoading: false,
error: null,
});
mockUseCurrentBusiness.mockReturnValue({
data: null,
isLoading: false,
error: null,
});
mockUseMasquerade.mockReturnValue({
mutate: vi.fn(),
});
mockUseLogout.mockReturnValue({
mutate: vi.fn(),
});
mockUseUpdateBusiness.mockReturnValue({
mutate: vi.fn(),
});
mockUsePlanFeatures.mockReturnValue({
canUse: vi.fn(() => true),
});
// Mock window.location
delete (window as any).location;
(window as any).location = {
hostname: 'localhost',
port: '5173',
protocol: 'http:',
pathname: '/',
search: '',
hash: '',
href: 'http://localhost:5173/',
reload: vi.fn(),
replace: vi.fn(),
};
// Mock localStorage
const localStorageMock = {
getItem: vi.fn(() => null),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
};
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
writable: true,
});
// Mock matchMedia for dark mode
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Mock documentElement classList for dark mode
document.documentElement.classList.toggle = vi.fn();
});
describe('Component Rendering', () => {
it('should render App component without crashing', () => {
expect(() => render(<App />)).not.toThrow();
});
it('should render toaster component for notifications', () => {
render(<App />);
expect(screen.getByTestId('toaster')).toBeInTheDocument();
});
it('should render with QueryClientProvider wrapper', () => {
const { container } = render(<App />);
expect(container.firstChild).toBeTruthy();
});
});
describe('Loading State', () => {
it('should show loading screen when user data is loading', () => {
mockUseCurrentUser.mockReturnValue({
data: null,
isLoading: true,
error: null,
});
render(<App />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('should show loading spinner in loading screen', () => {
mockUseCurrentUser.mockReturnValue({
data: null,
isLoading: true,
error: null,
});
const { container } = render(<App />);
const spinner = container.querySelector('.animate-spin');
expect(spinner).toBeInTheDocument();
});
it('should show loading screen when processing URL tokens', () => {
(window as any).location.search = '?access_token=test&refresh_token=test';
render(<App />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
});
describe('Error State', () => {
it('should show error screen when user fetch fails', async () => {
mockUseCurrentUser.mockReturnValue({
data: null,
isLoading: false,
error: new Error('Failed to fetch user'),
});
render(<App />);
await waitFor(() => {
expect(screen.getByText('Error')).toBeInTheDocument();
expect(screen.getByText('Failed to fetch user')).toBeInTheDocument();
});
});
it('should show reload button in error screen', async () => {
mockUseCurrentUser.mockReturnValue({
data: null,
isLoading: false,
error: new Error('Network error'),
});
render(<App />);
await waitFor(() => {
const reloadButton = screen.getByRole('button', { name: /reload/i });
expect(reloadButton).toBeInTheDocument();
});
});
it('should display error message in error screen', async () => {
const errorMessage = 'Connection timeout';
mockUseCurrentUser.mockReturnValue({
data: null,
isLoading: false,
error: new Error(errorMessage),
});
render(<App />);
await waitFor(() => {
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
});
});
describe('Dark Mode', () => {
it('should initialize dark mode from localStorage when set to true', () => {
window.localStorage.getItem = vi.fn((key) => {
if (key === 'darkMode') return 'true';
return null;
});
render(<App />);
expect(window.localStorage.getItem).toHaveBeenCalledWith('darkMode');
});
it('should initialize dark mode from localStorage when set to false', () => {
window.localStorage.getItem = vi.fn((key) => {
if (key === 'darkMode') return 'false';
return null;
});
render(<App />);
expect(window.localStorage.getItem).toHaveBeenCalledWith('darkMode');
});
it('should check system preference when dark mode not in localStorage', () => {
const mockMatchMedia = vi.fn().mockImplementation((query) => ({
matches: query === '(prefers-color-scheme: dark)',
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: mockMatchMedia,
});
render(<App />);
expect(mockMatchMedia).toHaveBeenCalledWith('(prefers-color-scheme: dark)');
});
it('should apply dark mode class to documentElement', () => {
window.localStorage.getItem = vi.fn((key) => {
if (key === 'darkMode') return 'true';
return null;
});
render(<App />);
expect(document.documentElement.classList.toggle).toHaveBeenCalled();
});
});
describe('Customer Users', () => {
const customerUser = {
id: '3',
email: 'customer@demo.com',
role: 'customer',
name: 'Customer User',
email_verified: true,
business_subdomain: 'demo',
};
const business = {
id: '1',
name: 'Demo Business',
subdomain: 'demo',
status: 'Active',
primaryColor: '#2563eb',
};
beforeEach(() => {
(window as any).location.hostname = 'demo.lvh.me';
});
it('should show loading when business is loading for customer', () => {
mockUseCurrentUser.mockReturnValue({
data: customerUser,
isLoading: false,
error: null,
});
mockUseCurrentBusiness.mockReturnValue({
data: null,
isLoading: true,
error: null,
});
render(<App />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('should show error when business not found for customer', async () => {
mockUseCurrentUser.mockReturnValue({
data: customerUser,
isLoading: false,
error: null,
});
mockUseCurrentBusiness.mockReturnValue({
data: null,
isLoading: false,
error: null,
});
render(<App />);
await waitFor(() => {
expect(screen.getByText('Business Not Found')).toBeInTheDocument();
});
});
it('should show error message for customer without business', async () => {
mockUseCurrentUser.mockReturnValue({
data: customerUser,
isLoading: false,
error: null,
});
mockUseCurrentBusiness.mockReturnValue({
data: null,
isLoading: false,
error: null,
});
render(<App />);
await waitFor(() => {
expect(screen.getByText(/unable to load business data/i)).toBeInTheDocument();
});
});
});
describe('URL Token Processing', () => {
it('should detect tokens in URL parameters', () => {
(window as any).location.search = '?access_token=abc123&refresh_token=xyz789';
render(<App />);
// Should show loading while processing tokens
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('should not trigger processing without both tokens', () => {
mockUseCurrentUser.mockReturnValue({
data: null,
isLoading: false,
error: null,
});
(window as any).location.search = '?access_token=abc123';
render(<App />);
// Should not be processing tokens (would show loading if it was)
// Instead should render normal unauthenticated state
});
it('should not trigger processing with empty tokens', () => {
mockUseCurrentUser.mockReturnValue({
data: null,
isLoading: false,
error: null,
});
(window as any).location.search = '';
render(<App />);
// Should render normal state, not loading from token processing
});
});
describe('Root Domain Detection', () => {
it('should detect localhost as root domain', () => {
(window as any).location.hostname = 'localhost';
mockUseCurrentUser.mockReturnValue({
data: null,
isLoading: false,
error: null,
});
render(<App />);
// Root domain should render marketing layout or login for unauthenticated users
// The exact behavior is tested in integration tests
});
it('should detect 127.0.0.1 as root domain', () => {
(window as any).location.hostname = '127.0.0.1';
mockUseCurrentUser.mockReturnValue({
data: null,
isLoading: false,
error: null,
});
render(<App />);
// Similar to localhost test
});
it('should detect lvh.me as root domain', () => {
(window as any).location.hostname = 'lvh.me';
mockUseCurrentUser.mockReturnValue({
data: null,
isLoading: false,
error: null,
});
render(<App />);
// Root domain behavior
});
it('should detect platform.lvh.me as subdomain', () => {
(window as any).location.hostname = 'platform.lvh.me';
mockUseCurrentUser.mockReturnValue({
data: null,
isLoading: false,
error: null,
});
render(<App />);
// Platform subdomain behavior - different from root
});
it('should detect business.lvh.me as subdomain', () => {
(window as any).location.hostname = 'demo.lvh.me';
mockUseCurrentUser.mockReturnValue({
data: null,
isLoading: false,
error: null,
});
render(<App />);
// Business subdomain behavior
});
});
describe('SEO Meta Tags', () => {
it('should handle subdomain routing for SEO', () => {
(window as any).location.hostname = 'demo.lvh.me';
mockUseCurrentUser.mockReturnValue({
data: null,
isLoading: false,
error: null,
});
// Meta tag manipulation happens in useEffect via DOM manipulation
// This is best tested in E2E tests
expect(() => render(<App />)).not.toThrow();
});
it('should handle root domain routing for SEO', () => {
(window as any).location.hostname = 'localhost';
mockUseCurrentUser.mockReturnValue({
data: null,
isLoading: false,
error: null,
});
// Root domain behavior for marketing pages
expect(() => render(<App />)).not.toThrow();
});
});
describe('Query Client Configuration', () => {
it('should configure query client with refetchOnWindowFocus disabled', () => {
const { container } = render(<App />);
expect(container).toBeTruthy();
// QueryClient config is tested implicitly by successful rendering
});
it('should configure query client with retry limit', () => {
const { container } = render(<App />);
expect(container).toBeTruthy();
// QueryClient retry config is applied during instantiation
});
it('should configure query client with staleTime', () => {
const { container } = render(<App />);
expect(container).toBeTruthy();
// QueryClient staleTime config is applied during instantiation
});
});
});

View File

@@ -0,0 +1,206 @@
/**
* Tests for Feature Catalog
*
* TDD: These tests define the expected behavior of the feature catalog utilities.
*/
import { describe, it, expect } from 'vitest';
import {
FEATURE_CATALOG,
BOOLEAN_FEATURES,
INTEGER_FEATURES,
getFeatureInfo,
isCanonicalFeature,
getFeaturesByType,
getFeaturesByCategory,
getAllCategories,
formatCategoryName,
} from '../featureCatalog';
describe('Feature Catalog', () => {
describe('Constants', () => {
it('exports BOOLEAN_FEATURES array', () => {
expect(Array.isArray(BOOLEAN_FEATURES)).toBe(true);
expect(BOOLEAN_FEATURES.length).toBeGreaterThan(0);
});
it('exports INTEGER_FEATURES array', () => {
expect(Array.isArray(INTEGER_FEATURES)).toBe(true);
expect(INTEGER_FEATURES.length).toBeGreaterThan(0);
});
it('exports FEATURE_CATALOG array combining both types', () => {
expect(Array.isArray(FEATURE_CATALOG)).toBe(true);
expect(FEATURE_CATALOG.length).toBe(BOOLEAN_FEATURES.length + INTEGER_FEATURES.length);
});
it('all boolean features have correct type', () => {
BOOLEAN_FEATURES.forEach((feature) => {
expect(feature.type).toBe('boolean');
expect(feature).toHaveProperty('code');
expect(feature).toHaveProperty('name');
expect(feature).toHaveProperty('description');
expect(feature).toHaveProperty('category');
});
});
it('all integer features have correct type', () => {
INTEGER_FEATURES.forEach((feature) => {
expect(feature.type).toBe('integer');
expect(feature).toHaveProperty('code');
expect(feature).toHaveProperty('name');
expect(feature).toHaveProperty('description');
expect(feature).toHaveProperty('category');
});
});
it('all feature codes are unique', () => {
const codes = FEATURE_CATALOG.map((f) => f.code);
const uniqueCodes = new Set(codes);
expect(uniqueCodes.size).toBe(codes.length);
});
});
describe('getFeatureInfo', () => {
it('returns feature info for valid code', () => {
const feature = getFeatureInfo('sms_enabled');
expect(feature).toBeDefined();
expect(feature?.code).toBe('sms_enabled');
expect(feature?.type).toBe('boolean');
});
it('returns undefined for invalid code', () => {
const feature = getFeatureInfo('invalid_feature');
expect(feature).toBeUndefined();
});
it('returns correct feature for integer type', () => {
const feature = getFeatureInfo('max_users');
expect(feature).toBeDefined();
expect(feature?.code).toBe('max_users');
expect(feature?.type).toBe('integer');
});
});
describe('isCanonicalFeature', () => {
it('returns true for features in catalog', () => {
expect(isCanonicalFeature('sms_enabled')).toBe(true);
expect(isCanonicalFeature('max_users')).toBe(true);
expect(isCanonicalFeature('api_access')).toBe(true);
});
it('returns false for features not in catalog', () => {
expect(isCanonicalFeature('custom_feature')).toBe(false);
expect(isCanonicalFeature('nonexistent')).toBe(false);
expect(isCanonicalFeature('')).toBe(false);
});
});
describe('getFeaturesByType', () => {
it('returns all boolean features', () => {
const booleanFeatures = getFeaturesByType('boolean');
expect(booleanFeatures.length).toBe(BOOLEAN_FEATURES.length);
expect(booleanFeatures.every((f) => f.type === 'boolean')).toBe(true);
});
it('returns all integer features', () => {
const integerFeatures = getFeaturesByType('integer');
expect(integerFeatures.length).toBe(INTEGER_FEATURES.length);
expect(integerFeatures.every((f) => f.type === 'integer')).toBe(true);
});
});
describe('getFeaturesByCategory', () => {
it('returns features for communication category', () => {
const features = getFeaturesByCategory('communication');
expect(features.length).toBeGreaterThan(0);
expect(features.every((f) => f.category === 'communication')).toBe(true);
});
it('returns features for limits category', () => {
const features = getFeaturesByCategory('limits');
expect(features.length).toBeGreaterThan(0);
expect(features.every((f) => f.category === 'limits')).toBe(true);
});
it('returns features for access category', () => {
const features = getFeaturesByCategory('access');
expect(features.length).toBeGreaterThan(0);
expect(features.every((f) => f.category === 'access')).toBe(true);
});
it('returns empty array for non-existent category', () => {
const features = getFeaturesByCategory('nonexistent' as any);
expect(features.length).toBe(0);
});
});
describe('getAllCategories', () => {
it('returns array of unique categories', () => {
const categories = getAllCategories();
expect(Array.isArray(categories)).toBe(true);
expect(categories.length).toBeGreaterThan(0);
// Check for duplicates
const uniqueCategories = new Set(categories);
expect(uniqueCategories.size).toBe(categories.length);
});
it('includes expected categories', () => {
const categories = getAllCategories();
expect(categories).toContain('communication');
expect(categories).toContain('limits');
expect(categories).toContain('access');
expect(categories).toContain('branding');
expect(categories).toContain('support');
expect(categories).toContain('integrations');
expect(categories).toContain('security');
expect(categories).toContain('scheduling');
});
});
describe('formatCategoryName', () => {
it('formats category names correctly', () => {
expect(formatCategoryName('communication')).toBe('Communication');
expect(formatCategoryName('limits')).toBe('Limits & Quotas');
expect(formatCategoryName('access')).toBe('Access & Features');
expect(formatCategoryName('branding')).toBe('Branding & Customization');
expect(formatCategoryName('support')).toBe('Support');
expect(formatCategoryName('integrations')).toBe('Integrations');
expect(formatCategoryName('security')).toBe('Security & Compliance');
expect(formatCategoryName('scheduling')).toBe('Scheduling & Booking');
});
});
describe('Specific Feature Validation', () => {
it('includes sms_enabled feature', () => {
const feature = getFeatureInfo('sms_enabled');
expect(feature).toMatchObject({
code: 'sms_enabled',
name: 'SMS Messaging',
type: 'boolean',
category: 'communication',
});
});
it('includes max_users feature', () => {
const feature = getFeatureInfo('max_users');
expect(feature).toMatchObject({
code: 'max_users',
name: 'Maximum Team Members',
type: 'integer',
category: 'limits',
});
});
it('includes api_access feature', () => {
const feature = getFeatureInfo('api_access');
expect(feature).toMatchObject({
code: 'api_access',
name: 'API Access',
type: 'boolean',
category: 'access',
});
});
});
});

View File

@@ -0,0 +1,530 @@
/**
* Tests for AddOnEditorModal Component
*
* TDD: These tests define the expected behavior of the AddOnEditorModal component.
*/
// Mocks must come BEFORE imports
vi.mock('@tanstack/react-query', () => ({
useQuery: vi.fn(),
useMutation: vi.fn(),
useQueryClient: vi.fn(),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { language: 'en' },
}),
Trans: ({ children }: { children: React.ReactNode }) => children,
}));
vi.mock('../FeaturePicker', () => ({
FeaturePicker: ({ onChange, selectedFeatures }: any) =>
React.createElement('div', { 'data-testid': 'feature-picker' }, [
React.createElement('input', {
key: 'feature-input',
type: 'text',
'data-testid': 'feature-picker-input',
onChange: (e: any) => {
if (e.target.value === 'add-feature') {
onChange([
...selectedFeatures,
{ feature_code: 'test_feature', bool_value: true, int_value: null },
]);
}
},
}),
React.createElement(
'div',
{ key: 'feature-count' },
`Selected: ${selectedFeatures.length}`
),
]),
}));
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useQuery, useMutation } from '@tanstack/react-query';
import { AddOnEditorModal } from '../AddOnEditorModal';
import type { AddOnProduct } from '../../../hooks/useBillingAdmin';
const mockUseQuery = useQuery as unknown as ReturnType<typeof vi.fn>;
const mockUseMutation = useMutation as unknown as ReturnType<typeof vi.fn>;
describe('AddOnEditorModal', () => {
const mockOnClose = vi.fn();
const mockMutateAsync = vi.fn();
const mockFeatures = [
{ id: 1, code: 'sms_enabled', name: 'SMS Enabled', description: 'SMS messaging', feature_type: 'boolean' as const },
{ id: 2, code: 'max_users', name: 'Max Users', description: 'User limit', feature_type: 'integer' as const },
];
const mockAddon: AddOnProduct = {
id: 1,
code: 'test_addon',
name: 'Test Add-On',
description: 'Test description',
price_monthly_cents: 1000,
price_one_time_cents: 500,
stripe_product_id: 'prod_test',
stripe_price_id: 'price_test',
is_stackable: true,
is_active: true,
features: [
{
id: 1,
feature: mockFeatures[0],
bool_value: true,
int_value: null,
value: true,
},
],
};
beforeEach(() => {
vi.clearAllMocks();
// Mock useFeatures
mockUseQuery.mockReturnValue({
data: mockFeatures,
isLoading: false,
error: null,
});
// Mock mutations
mockUseMutation.mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: false,
error: null,
});
});
describe('Rendering', () => {
it('renders create mode when no addon is provided', () => {
render(<AddOnEditorModal isOpen={true} onClose={mockOnClose} />);
expect(screen.getByRole('heading', { name: /create add-on/i })).toBeInTheDocument();
});
it('renders edit mode when addon is provided', () => {
render(<AddOnEditorModal isOpen={true} onClose={mockOnClose} addon={mockAddon} />);
expect(screen.getByText(`Edit ${mockAddon.name}`)).toBeInTheDocument();
});
it('renders all form fields', () => {
render(<AddOnEditorModal isOpen={true} onClose={mockOnClose} />);
expect(screen.getByText('Code')).toBeInTheDocument();
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText('Description')).toBeInTheDocument();
expect(screen.getByText('Monthly Price')).toBeInTheDocument();
expect(screen.getByText('One-Time Price')).toBeInTheDocument();
expect(screen.getByText(/active.*available for purchase/i)).toBeInTheDocument();
expect(screen.getByText(/stackable.*can purchase multiple/i)).toBeInTheDocument();
});
it('populates form fields in edit mode', () => {
render(<AddOnEditorModal isOpen={true} onClose={mockOnClose} addon={mockAddon} />);
expect(screen.getByDisplayValue(mockAddon.code)).toBeInTheDocument();
expect(screen.getByDisplayValue(mockAddon.name)).toBeInTheDocument();
expect(screen.getByDisplayValue(mockAddon.description!)).toBeInTheDocument();
expect(screen.getByDisplayValue('10.00')).toBeInTheDocument(); // $10.00
expect(screen.getByDisplayValue('5.00')).toBeInTheDocument(); // $5.00
});
it('disables code field in edit mode', () => {
render(<AddOnEditorModal isOpen={true} onClose={mockOnClose} addon={mockAddon} />);
const codeInput = screen.getByDisplayValue(mockAddon.code);
expect(codeInput).toBeDisabled();
});
it('shows loading state when features are loading', () => {
mockUseQuery.mockReturnValueOnce({
data: undefined,
isLoading: true,
error: null,
});
render(<AddOnEditorModal isOpen={true} onClose={mockOnClose} />);
// In reality, the FeaturePicker doesn't render when loading
// But our mock always renders. Instead, let's verify modal still renders
expect(screen.getByRole('heading', { name: /create add-on/i })).toBeInTheDocument();
});
it('renders FeaturePicker component', () => {
render(<AddOnEditorModal isOpen={true} onClose={mockOnClose} />);
expect(screen.getByTestId('feature-picker')).toBeInTheDocument();
});
});
describe('Form Validation', () => {
it('shows error when code is empty', async () => {
const user = userEvent.setup();
render(<AddOnEditorModal isOpen={true} onClose={mockOnClose} />);
const submitButton = screen.getByRole('button', { name: /create add-on/i });
await user.click(submitButton);
expect(screen.getByText(/code is required/i)).toBeInTheDocument();
expect(mockMutateAsync).not.toHaveBeenCalled();
});
it('shows error when code has invalid characters', async () => {
const user = userEvent.setup();
render(<AddOnEditorModal isOpen={true} onClose={mockOnClose} />);
const codeInput = screen.getByPlaceholderText(/sms_credits_pack/i);
await user.type(codeInput, 'Invalid Code!');
const submitButton = screen.getByRole('button', { name: /create add-on/i });
await user.click(submitButton);
expect(screen.getByText(/code must be lowercase letters, numbers, and underscores only/i)).toBeInTheDocument();
expect(mockMutateAsync).not.toHaveBeenCalled();
});
it('shows error when name is empty', async () => {
const user = userEvent.setup();
render(<AddOnEditorModal isOpen={true} onClose={mockOnClose} />);
const codeInput = screen.getByPlaceholderText(/sms_credits_pack/i);
await user.type(codeInput, 'valid_code');
const submitButton = screen.getByRole('button', { name: /create add-on/i });
await user.click(submitButton);
expect(screen.getByText(/name is required/i)).toBeInTheDocument();
expect(mockMutateAsync).not.toHaveBeenCalled();
});
it('validates price inputs have correct attributes', () => {
render(<AddOnEditorModal isOpen={true} onClose={mockOnClose} />);
// The inputs have type=number so negative values are prevented by HTML validation
const priceInputs = screen.getAllByDisplayValue('0.00');
const monthlyPriceInput = priceInputs[0];
expect(monthlyPriceInput).toHaveAttribute('type', 'number');
expect(monthlyPriceInput).toHaveAttribute('min', '0');
});
it('clears error when user corrects invalid input', async () => {
const user = userEvent.setup();
render(<AddOnEditorModal isOpen={true} onClose={mockOnClose} />);
const submitButton = screen.getByRole('button', { name: /create add-on/i });
await user.click(submitButton);
expect(screen.getByText(/code is required/i)).toBeInTheDocument();
const codeInput = screen.getByPlaceholderText(/sms_credits_pack/i);
await user.type(codeInput, 'valid_code');
expect(screen.queryByText(/code is required/i)).not.toBeInTheDocument();
});
});
describe('User Interactions', () => {
it('updates code field', async () => {
const user = userEvent.setup();
render(<AddOnEditorModal isOpen={true} onClose={mockOnClose} />);
const codeInput = screen.getByPlaceholderText(/sms_credits_pack/i);
await user.type(codeInput, 'test_addon');
expect(screen.getByDisplayValue('test_addon')).toBeInTheDocument();
});
it('updates name field', async () => {
const user = userEvent.setup();
render(<AddOnEditorModal isOpen={true} onClose={mockOnClose} />);
const nameInput = screen.getByPlaceholderText(/sms credits pack/i);
await user.type(nameInput, 'Test Add-On');
expect(screen.getByDisplayValue('Test Add-On')).toBeInTheDocument();
});
it('updates description field', async () => {
const user = userEvent.setup();
render(<AddOnEditorModal isOpen={true} onClose={mockOnClose} />);
const descriptionInput = screen.getByPlaceholderText(/description of the add-on/i);
await user.type(descriptionInput, 'Test description');
expect(screen.getByDisplayValue('Test description')).toBeInTheDocument();
});
it('toggles is_active checkbox', async () => {
const user = userEvent.setup();
render(<AddOnEditorModal isOpen={true} onClose={mockOnClose} />);
const activeCheckbox = screen.getByRole('checkbox', { name: /active.*available for purchase/i });
expect(activeCheckbox).toBeChecked(); // Default is true
await user.click(activeCheckbox);
expect(activeCheckbox).not.toBeChecked();
});
it('toggles is_stackable checkbox', async () => {
const user = userEvent.setup();
render(<AddOnEditorModal isOpen={true} onClose={mockOnClose} />);
const stackableCheckbox = screen.getByRole('checkbox', { name: /stackable.*can purchase multiple/i });
expect(stackableCheckbox).not.toBeChecked(); // Default is false
await user.click(stackableCheckbox);
expect(stackableCheckbox).toBeChecked();
});
it('updates monthly price', async () => {
const user = userEvent.setup();
render(<AddOnEditorModal isOpen={true} onClose={mockOnClose} />);
const priceInputs = screen.getAllByDisplayValue('0.00');
const monthlyPriceInput = priceInputs[0];
await user.clear(monthlyPriceInput);
await user.type(monthlyPriceInput, '15.99');
expect(screen.getByDisplayValue('15.99')).toBeInTheDocument();
});
it('updates one-time price', async () => {
const user = userEvent.setup();
render(<AddOnEditorModal isOpen={true} onClose={mockOnClose} />);
const priceInputs = screen.getAllByDisplayValue('0.00');
const oneTimePriceInput = priceInputs[1]; // Second one is one-time
await user.clear(oneTimePriceInput);
await user.type(oneTimePriceInput, '9.99');
expect(screen.getByDisplayValue('9.99')).toBeInTheDocument();
});
it('can add features using FeaturePicker', async () => {
const user = userEvent.setup();
render(<AddOnEditorModal isOpen={true} onClose={mockOnClose} />);
const featureInput = screen.getByTestId('feature-picker-input');
await user.type(featureInput, 'add-feature');
expect(screen.getByText('Selected: 1')).toBeInTheDocument();
});
});
describe('Form Submission', () => {
it('creates addon with valid data', async () => {
const user = userEvent.setup();
mockMutateAsync.mockResolvedValueOnce({});
render(<AddOnEditorModal isOpen={true} onClose={mockOnClose} />);
await user.type(screen.getByPlaceholderText(/sms_credits_pack/i), 'new_addon');
await user.type(screen.getByPlaceholderText(/sms credits pack/i), 'New Add-On');
await user.type(screen.getByPlaceholderText(/description of the add-on/i), 'Description');
const monthlyPriceInputs = screen.getAllByDisplayValue('0.00');
const monthlyPriceInput = monthlyPriceInputs[0];
await user.clear(monthlyPriceInput);
await user.type(monthlyPriceInput, '19.99');
const submitButton = screen.getByRole('button', { name: /create add-on/i });
await user.click(submitButton);
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
code: 'new_addon',
name: 'New Add-On',
description: 'Description',
price_monthly_cents: 1999,
price_one_time_cents: 0,
is_stackable: false,
is_active: true,
features: [],
})
);
});
await waitFor(() => {
expect(mockOnClose).toHaveBeenCalled();
});
});
it('updates addon in edit mode', async () => {
const user = userEvent.setup();
mockMutateAsync.mockResolvedValueOnce({});
render(<AddOnEditorModal isOpen={true} onClose={mockOnClose} addon={mockAddon} />);
const nameInput = screen.getByDisplayValue(mockAddon.name);
await user.clear(nameInput);
await user.type(nameInput, 'Updated Name');
const submitButton = screen.getByRole('button', { name: /save changes/i });
await user.click(submitButton);
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
id: mockAddon.id,
name: 'Updated Name',
})
);
});
await waitFor(() => {
expect(mockOnClose).toHaveBeenCalled();
});
});
it('includes selected features in payload', async () => {
const user = userEvent.setup();
mockMutateAsync.mockResolvedValueOnce({});
render(<AddOnEditorModal isOpen={true} onClose={mockOnClose} />);
await user.type(screen.getByPlaceholderText(/sms_credits_pack/i), 'addon_with_features');
await user.type(screen.getByPlaceholderText(/sms credits pack/i), 'Add-On With Features');
// Add a feature using the mocked FeaturePicker
const featureInput = screen.getByTestId('feature-picker-input');
await user.type(featureInput, 'add-feature');
const submitButton = screen.getByRole('button', { name: /create add-on/i });
await user.click(submitButton);
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
features: [
{ feature_code: 'test_feature', bool_value: true, int_value: null },
],
})
);
});
});
it('shows loading state during submission', () => {
// We can't easily test the actual pending state since mocking is complex
// Instead, let's verify that the button is enabled by default (not pending)
render(<AddOnEditorModal isOpen={true} onClose={mockOnClose} />);
const submitButton = screen.getByRole('button', { name: /create add-on/i });
// Submit button should be enabled when not pending
expect(submitButton).not.toBeDisabled();
});
it('handles submission error gracefully', async () => {
const user = userEvent.setup();
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
mockMutateAsync.mockRejectedValueOnce(new Error('API Error'));
render(<AddOnEditorModal isOpen={true} onClose={mockOnClose} />);
await user.type(screen.getByPlaceholderText(/sms_credits_pack/i), 'test_addon');
await user.type(screen.getByPlaceholderText(/sms credits pack/i), 'Test Add-On');
const submitButton = screen.getByRole('button', { name: /create add-on/i });
await user.click(submitButton);
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to save add-on:',
expect.any(Error)
);
});
expect(mockOnClose).not.toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
describe('Modal Behavior', () => {
it('calls onClose when cancel button is clicked', async () => {
const user = userEvent.setup();
render(<AddOnEditorModal isOpen={true} onClose={mockOnClose} />);
const cancelButton = screen.getByText(/cancel/i);
await user.click(cancelButton);
expect(mockOnClose).toHaveBeenCalled();
});
it('does not render when isOpen is false', () => {
render(<AddOnEditorModal isOpen={false} onClose={mockOnClose} />);
expect(screen.queryByText(/create add-on/i)).not.toBeInTheDocument();
});
it('resets form when modal is reopened', () => {
const { rerender } = render(
<AddOnEditorModal isOpen={true} onClose={mockOnClose} addon={mockAddon} />
);
expect(screen.getByDisplayValue(mockAddon.name)).toBeInTheDocument();
rerender(<AddOnEditorModal isOpen={false} onClose={mockOnClose} addon={mockAddon} />);
rerender(<AddOnEditorModal isOpen={true} onClose={mockOnClose} />);
// Should show create mode with empty fields
expect(screen.getByRole('heading', { name: /create add-on/i })).toBeInTheDocument();
expect(screen.queryByDisplayValue(mockAddon.name)).not.toBeInTheDocument();
});
});
describe('Stripe Integration', () => {
it('shows info alert when no Stripe product ID is configured', () => {
render(<AddOnEditorModal isOpen={true} onClose={mockOnClose} />);
expect(
screen.getByText(/configure stripe ids to enable purchasing/i)
).toBeInTheDocument();
});
it('hides info alert when Stripe product ID is entered', async () => {
const user = userEvent.setup();
render(<AddOnEditorModal isOpen={true} onClose={mockOnClose} />);
const stripeProductInput = screen.getByPlaceholderText(/prod_\.\.\./i);
await user.type(stripeProductInput, 'prod_test123');
expect(
screen.queryByText(/configure stripe ids to enable purchasing/i)
).not.toBeInTheDocument();
});
it('includes Stripe IDs in submission payload', async () => {
const user = userEvent.setup();
mockMutateAsync.mockResolvedValueOnce({});
render(<AddOnEditorModal isOpen={true} onClose={mockOnClose} />);
await user.type(screen.getByPlaceholderText(/sms_credits_pack/i), 'addon_with_stripe');
await user.type(screen.getByPlaceholderText(/sms credits pack/i), 'Add-On With Stripe');
await user.type(screen.getByPlaceholderText(/prod_\.\.\./i), 'prod_test');
await user.type(screen.getByPlaceholderText(/price_\.\.\./i), 'price_test');
const submitButton = screen.getByRole('button', { name: /create add-on/i });
await user.click(submitButton);
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
stripe_product_id: 'prod_test',
stripe_price_id: 'price_test',
})
);
});
});
});
});

View File

@@ -0,0 +1,586 @@
/**
* Tests for PlanDetailPanel Component
*
* TDD: These tests define the expected behavior of the PlanDetailPanel component.
*/
// Mocks must come BEFORE imports
vi.mock('@tanstack/react-query', () => ({
useQuery: vi.fn(),
useMutation: vi.fn(),
useQueryClient: vi.fn(),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { language: 'en' },
}),
Trans: ({ children }: { children: React.ReactNode }) => children,
}));
vi.mock('../../../hooks/useAuth', () => ({
useCurrentUser: vi.fn(),
}));
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useMutation } from '@tanstack/react-query';
import { useCurrentUser } from '../../../hooks/useAuth';
import { PlanDetailPanel } from '../PlanDetailPanel';
import type { PlanWithVersions, AddOnProduct, PlanVersion } from '../../../hooks/useBillingAdmin';
const mockUseMutation = useMutation as unknown as ReturnType<typeof vi.fn>;
const mockUseCurrentUser = useCurrentUser as unknown as ReturnType<typeof vi.fn>;
describe('PlanDetailPanel', () => {
const mockOnEdit = vi.fn();
const mockOnDuplicate = vi.fn();
const mockOnCreateVersion = vi.fn();
const mockOnEditVersion = vi.fn();
const mockMutateAsync = vi.fn();
const mockPlanVersion: PlanVersion = {
id: 1,
plan: {} as any,
version: 1,
name: 'Version 1',
is_public: true,
is_legacy: false,
starts_at: null,
ends_at: null,
price_monthly_cents: 2999,
price_yearly_cents: 29990,
transaction_fee_percent: '2.5',
transaction_fee_fixed_cents: 30,
trial_days: 14,
sms_price_per_message_cents: 1,
masked_calling_price_per_minute_cents: 5,
proxy_number_monthly_fee_cents: 1000,
default_auto_reload_enabled: false,
default_auto_reload_threshold_cents: 0,
default_auto_reload_amount_cents: 0,
is_most_popular: false,
show_price: true,
marketing_features: ['Feature 1', 'Feature 2'],
stripe_product_id: 'prod_test',
stripe_price_id_monthly: 'price_monthly',
stripe_price_id_yearly: 'price_yearly',
is_available: true,
features: [
{
id: 1,
feature: { id: 1, code: 'test_feature', name: 'Test Feature', description: '', feature_type: 'boolean' },
bool_value: true,
int_value: null,
value: true,
},
],
subscriber_count: 5,
created_at: '2024-01-01T00:00:00Z',
};
const mockPlan: PlanWithVersions = {
id: 1,
code: 'pro',
name: 'Pro Plan',
description: 'Professional plan for businesses',
is_active: true,
display_order: 1,
total_subscribers: 10,
versions: [mockPlanVersion],
active_version: mockPlanVersion,
};
const mockAddon: AddOnProduct = {
id: 1,
code: 'extra_users',
name: 'Extra Users',
description: 'Add more users to your account',
price_monthly_cents: 500,
price_one_time_cents: 0,
stripe_product_id: 'prod_addon',
stripe_price_id: 'price_addon',
is_stackable: true,
is_active: true,
features: [],
};
beforeEach(() => {
vi.clearAllMocks();
// Mock mutations
mockUseMutation.mockReturnValue({
mutate: vi.fn(),
mutateAsync: mockMutateAsync,
isPending: false,
error: null,
});
// Mock current user (non-superuser by default)
mockUseCurrentUser.mockReturnValue({
data: { is_superuser: false },
isLoading: false,
error: null,
});
});
describe('Empty State', () => {
it('renders empty state when no plan or addon provided', () => {
render(
<PlanDetailPanel
plan={null}
addon={null}
onEdit={mockOnEdit}
onDuplicate={mockOnDuplicate}
onCreateVersion={mockOnCreateVersion}
onEditVersion={mockOnEditVersion}
/>
);
expect(screen.getByText(/select a plan or add-on from the catalog/i)).toBeInTheDocument();
});
});
describe('Plan Details', () => {
it('renders plan header with name and code', () => {
render(
<PlanDetailPanel
plan={mockPlan}
addon={null}
onEdit={mockOnEdit}
onDuplicate={mockOnDuplicate}
onCreateVersion={mockOnCreateVersion}
onEditVersion={mockOnEditVersion}
/>
);
expect(screen.getByText(mockPlan.name)).toBeInTheDocument();
// Code appears in header and Overview section
expect(screen.getAllByText(mockPlan.code).length).toBeGreaterThan(0);
expect(screen.getByText(mockPlan.description!)).toBeInTheDocument();
});
it('shows inactive badge when plan is not active', () => {
const inactivePlan = { ...mockPlan, is_active: false };
render(
<PlanDetailPanel
plan={inactivePlan}
addon={null}
onEdit={mockOnEdit}
onDuplicate={mockOnDuplicate}
onCreateVersion={mockOnCreateVersion}
onEditVersion={mockOnEditVersion}
/>
);
// There may be multiple "Inactive" texts (badge and overview section)
expect(screen.getAllByText(/inactive/i).length).toBeGreaterThan(0);
});
it('displays subscriber count', () => {
render(
<PlanDetailPanel
plan={mockPlan}
addon={null}
onEdit={mockOnEdit}
onDuplicate={mockOnDuplicate}
onCreateVersion={mockOnCreateVersion}
onEditVersion={mockOnEditVersion}
/>
);
expect(screen.getByText(/10 subscribers/i)).toBeInTheDocument();
});
it('displays pricing information', () => {
render(
<PlanDetailPanel
plan={mockPlan}
addon={null}
onEdit={mockOnEdit}
onDuplicate={mockOnDuplicate}
onCreateVersion={mockOnCreateVersion}
onEditVersion={mockOnEditVersion}
/>
);
expect(screen.getByText(/\$29.99\/mo/i)).toBeInTheDocument();
});
it('shows "Free" when price is 0', () => {
const freePlan = {
...mockPlan,
active_version: {
...mockPlanVersion,
price_monthly_cents: 0,
},
};
render(
<PlanDetailPanel
plan={freePlan}
addon={null}
onEdit={mockOnEdit}
onDuplicate={mockOnDuplicate}
onCreateVersion={mockOnCreateVersion}
onEditVersion={mockOnEditVersion}
/>
);
expect(screen.getByText(/free/i)).toBeInTheDocument();
});
});
describe('Action Buttons', () => {
it('renders Edit button and calls onEdit when clicked', async () => {
const user = userEvent.setup();
render(
<PlanDetailPanel
plan={mockPlan}
addon={null}
onEdit={mockOnEdit}
onDuplicate={mockOnDuplicate}
onCreateVersion={mockOnCreateVersion}
onEditVersion={mockOnEditVersion}
/>
);
const editButton = screen.getByRole('button', { name: /edit/i });
await user.click(editButton);
expect(mockOnEdit).toHaveBeenCalledTimes(1);
});
it('renders Duplicate button and calls onDuplicate when clicked', async () => {
const user = userEvent.setup();
render(
<PlanDetailPanel
plan={mockPlan}
addon={null}
onEdit={mockOnEdit}
onDuplicate={mockOnDuplicate}
onCreateVersion={mockOnCreateVersion}
onEditVersion={mockOnEditVersion}
/>
);
const duplicateButton = screen.getByRole('button', { name: /duplicate/i });
await user.click(duplicateButton);
expect(mockOnDuplicate).toHaveBeenCalledTimes(1);
});
it('renders New Version button and calls onCreateVersion when clicked', async () => {
const user = userEvent.setup();
render(
<PlanDetailPanel
plan={mockPlan}
addon={null}
onEdit={mockOnEdit}
onDuplicate={mockOnDuplicate}
onCreateVersion={mockOnCreateVersion}
onEditVersion={mockOnEditVersion}
/>
);
const newVersionButton = screen.getByRole('button', { name: /new version/i });
await user.click(newVersionButton);
expect(mockOnCreateVersion).toHaveBeenCalledTimes(1);
});
});
describe('Collapsible Sections', () => {
it('renders Overview section', () => {
render(
<PlanDetailPanel
plan={mockPlan}
addon={null}
onEdit={mockOnEdit}
onDuplicate={mockOnDuplicate}
onCreateVersion={mockOnCreateVersion}
onEditVersion={mockOnEditVersion}
/>
);
expect(screen.getByText('Overview')).toBeInTheDocument();
expect(screen.getByText(/plan code/i)).toBeInTheDocument();
});
it('renders Pricing section with price details', () => {
render(
<PlanDetailPanel
plan={mockPlan}
addon={null}
onEdit={mockOnEdit}
onDuplicate={mockOnDuplicate}
onCreateVersion={mockOnCreateVersion}
onEditVersion={mockOnEditVersion}
/>
);
expect(screen.getByText('Pricing')).toBeInTheDocument();
// Monthly price
expect(screen.getByText('$29.99')).toBeInTheDocument();
// Yearly price
expect(screen.getByText('$299.90')).toBeInTheDocument();
});
it('renders Features section', () => {
render(
<PlanDetailPanel
plan={mockPlan}
addon={null}
onEdit={mockOnEdit}
onDuplicate={mockOnDuplicate}
onCreateVersion={mockOnCreateVersion}
onEditVersion={mockOnEditVersion}
/>
);
expect(screen.getByText(/features \(1\)/i)).toBeInTheDocument();
expect(screen.getByText('Test Feature')).toBeInTheDocument();
});
it('toggles section visibility when clicked', async () => {
const user = userEvent.setup();
render(
<PlanDetailPanel
plan={mockPlan}
addon={null}
onEdit={mockOnEdit}
onDuplicate={mockOnDuplicate}
onCreateVersion={mockOnCreateVersion}
onEditVersion={mockOnEditVersion}
/>
);
// Overview should be expanded by default
expect(screen.getByText(/plan code/i)).toBeVisible();
// Click to collapse
const overviewButton = screen.getByRole('button', { name: /overview/i });
await user.click(overviewButton);
// Content should be hidden now
expect(screen.queryByText(/plan code/i)).not.toBeInTheDocument();
});
});
describe('Versions Section', () => {
it('renders versions list', async () => {
const user = userEvent.setup();
render(
<PlanDetailPanel
plan={mockPlan}
addon={null}
onEdit={mockOnEdit}
onDuplicate={mockOnDuplicate}
onCreateVersion={mockOnCreateVersion}
onEditVersion={mockOnEditVersion}
/>
);
// Versions section header should be visible
expect(screen.getByText(/versions \(1\)/i)).toBeInTheDocument();
// Expand Versions section
const versionsButton = screen.getByRole('button', { name: /versions \(1\)/i });
await user.click(versionsButton);
expect(screen.getByText('v1')).toBeInTheDocument();
expect(screen.getAllByText('Version 1').length).toBeGreaterThan(0);
});
it('shows subscriber count for each version', async () => {
const user = userEvent.setup();
render(
<PlanDetailPanel
plan={mockPlan}
addon={null}
onEdit={mockOnEdit}
onDuplicate={mockOnDuplicate}
onCreateVersion={mockOnCreateVersion}
onEditVersion={mockOnEditVersion}
/>
);
// Expand Versions section
const versionsButton = screen.getByRole('button', { name: /versions \(1\)/i });
await user.click(versionsButton);
expect(screen.getByText(/5 subscribers/i)).toBeInTheDocument();
});
});
describe('Danger Zone', () => {
it('renders Danger Zone section', () => {
render(
<PlanDetailPanel
plan={mockPlan}
addon={null}
onEdit={mockOnEdit}
onDuplicate={mockOnDuplicate}
onCreateVersion={mockOnCreateVersion}
onEditVersion={mockOnEditVersion}
/>
);
expect(screen.getByText('Danger Zone')).toBeInTheDocument();
});
it('prevents deletion when plan has subscribers', async () => {
const user = userEvent.setup();
render(
<PlanDetailPanel
plan={mockPlan}
addon={null}
onEdit={mockOnEdit}
onDuplicate={mockOnDuplicate}
onCreateVersion={mockOnCreateVersion}
onEditVersion={mockOnEditVersion}
/>
);
// Expand Danger Zone
const dangerZoneButton = screen.getByRole('button', { name: /danger zone/i });
await user.click(dangerZoneButton);
// Should show warning message
expect(screen.getByText(/has 10 active subscriber\(s\) and cannot be deleted/i)).toBeInTheDocument();
// Delete button should not exist
expect(screen.queryByRole('button', { name: /delete plan/i })).not.toBeInTheDocument();
});
it('shows delete button when plan has no subscribers', async () => {
const user = userEvent.setup();
const planWithoutSubscribers = { ...mockPlan, total_subscribers: 0 };
render(
<PlanDetailPanel
plan={planWithoutSubscribers}
addon={null}
onEdit={mockOnEdit}
onDuplicate={mockOnDuplicate}
onCreateVersion={mockOnCreateVersion}
onEditVersion={mockOnEditVersion}
/>
);
// Expand Danger Zone
const dangerZoneButton = screen.getByRole('button', { name: /danger zone/i });
await user.click(dangerZoneButton);
// Delete button should exist
expect(screen.getByRole('button', { name: /delete plan/i })).toBeInTheDocument();
});
it('shows force push button for superusers with subscribers', async () => {
const user = userEvent.setup();
// Mock superuser
mockUseCurrentUser.mockReturnValue({
data: { is_superuser: true },
isLoading: false,
error: null,
});
render(
<PlanDetailPanel
plan={mockPlan}
addon={null}
onEdit={mockOnEdit}
onDuplicate={mockOnDuplicate}
onCreateVersion={mockOnCreateVersion}
onEditVersion={mockOnEditVersion}
/>
);
// Expand Danger Zone
const dangerZoneButton = screen.getByRole('button', { name: /danger zone/i });
await user.click(dangerZoneButton);
// Should show force push button
expect(screen.getByRole('button', { name: /force push to subscribers/i })).toBeInTheDocument();
});
it('does not show force push button for non-superusers', async () => {
const user = userEvent.setup();
render(
<PlanDetailPanel
plan={mockPlan}
addon={null}
onEdit={mockOnEdit}
onDuplicate={mockOnDuplicate}
onCreateVersion={mockOnCreateVersion}
onEditVersion={mockOnEditVersion}
/>
);
// Expand Danger Zone
const dangerZoneButton = screen.getByRole('button', { name: /danger zone/i });
await user.click(dangerZoneButton);
// Should NOT show force push button
expect(screen.queryByRole('button', { name: /force push to subscribers/i })).not.toBeInTheDocument();
});
});
describe('Add-On Details', () => {
it('renders add-on header with name and code', () => {
render(
<PlanDetailPanel
plan={null}
addon={mockAddon}
onEdit={mockOnEdit}
onDuplicate={mockOnDuplicate}
onCreateVersion={mockOnCreateVersion}
onEditVersion={mockOnEditVersion}
/>
);
expect(screen.getByText(mockAddon.name)).toBeInTheDocument();
expect(screen.getByText(mockAddon.code)).toBeInTheDocument();
expect(screen.getByText(mockAddon.description!)).toBeInTheDocument();
});
it('displays add-on pricing', () => {
render(
<PlanDetailPanel
plan={null}
addon={mockAddon}
onEdit={mockOnEdit}
onDuplicate={mockOnDuplicate}
onCreateVersion={mockOnCreateVersion}
onEditVersion={mockOnEditVersion}
/>
);
expect(screen.getByText('$5.00')).toBeInTheDocument(); // Monthly price
expect(screen.getByText('$0.00')).toBeInTheDocument(); // One-time price
});
it('renders Edit button for add-on', async () => {
const user = userEvent.setup();
render(
<PlanDetailPanel
plan={null}
addon={mockAddon}
onEdit={mockOnEdit}
onDuplicate={mockOnDuplicate}
onCreateVersion={mockOnCreateVersion}
onEditVersion={mockOnEditVersion}
/>
);
const editButton = screen.getByRole('button', { name: /edit/i });
await user.click(editButton);
expect(mockOnEdit).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -17,10 +17,13 @@ import {
CalendarOff,
Image,
BarChart3,
ShoppingCart,
Package,
} from 'lucide-react';
import { Business, User } from '../types';
import { useLogout } from '../hooks/useAuth';
import { usePlanFeatures } from '../hooks/usePlanFeatures';
import { useEntitlements, FEATURE_CODES } from '../hooks/useEntitlements';
import SmoothScheduleLogo from './SmoothScheduleLogo';
import UnfinishedBadge from './ui/UnfinishedBadge';
import {
@@ -41,6 +44,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
const { role } = user;
const logoutMutation = useLogout();
const { canUse } = usePlanFeatures();
const { hasFeature } = useEntitlements();
// Helper to check if user has a specific staff permission
// Owners always have all permissions
@@ -139,6 +143,24 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
)}
</SidebarSection>
{/* Point of Sale Section - Requires tenant feature AND user permission */}
{hasFeature(FEATURE_CODES.CAN_USE_POS) && hasPermission('can_access_pos') && (
<SidebarSection title={t('nav.sections.pos', 'Point of Sale')} isCollapsed={isCollapsed}>
<SidebarItem
to="/dashboard/pos"
icon={ShoppingCart}
label={t('nav.pos', 'Point of Sale')}
isCollapsed={isCollapsed}
/>
<SidebarItem
to="/dashboard/products"
icon={Package}
label={t('nav.products', 'Products')}
isCollapsed={isCollapsed}
/>
</SidebarSection>
)}
{/* Staff-only: My Schedule and My Availability */}
{((isStaff && hasPermission('can_access_my_schedule')) ||
((role === 'staff' || role === 'resource') && hasPermission('can_access_my_availability'))) && (

View File

@@ -0,0 +1,123 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, act } from '@testing-library/react';
import CurrentTimeIndicator from '../CurrentTimeIndicator';
describe('CurrentTimeIndicator', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it('renders the current time indicator', () => {
const startTime = new Date('2024-01-01T08:00:00');
const now = new Date('2024-01-01T10:00:00');
vi.setSystemTime(now);
render(<CurrentTimeIndicator startTime={startTime} hourWidth={100} />);
const indicator = document.querySelector('#current-time-indicator');
expect(indicator).toBeInTheDocument();
});
it('displays the current time', () => {
const startTime = new Date('2024-01-01T08:00:00');
const now = new Date('2024-01-01T10:30:00');
vi.setSystemTime(now);
render(<CurrentTimeIndicator startTime={startTime} hourWidth={100} />);
expect(screen.getByText('10:30 AM')).toBeInTheDocument();
});
it('calculates correct position based on time difference', () => {
const startTime = new Date('2024-01-01T08:00:00');
const now = new Date('2024-01-01T10:00:00'); // 2 hours after start
vi.setSystemTime(now);
render(<CurrentTimeIndicator startTime={startTime} hourWidth={100} />);
const indicator = document.querySelector('#current-time-indicator');
expect(indicator).toHaveStyle({ left: '200px' }); // 2 hours * 100px
});
it('does not render when current time is before start time', () => {
const startTime = new Date('2024-01-01T10:00:00');
const now = new Date('2024-01-01T08:00:00'); // Before start time
vi.setSystemTime(now);
render(<CurrentTimeIndicator startTime={startTime} hourWidth={100} />);
const indicator = document.querySelector('#current-time-indicator');
expect(indicator).not.toBeInTheDocument();
});
it('updates position every minute', () => {
const startTime = new Date('2024-01-01T08:00:00');
const initialTime = new Date('2024-01-01T10:00:00');
vi.setSystemTime(initialTime);
render(<CurrentTimeIndicator startTime={startTime} hourWidth={100} />);
const indicator = document.querySelector('#current-time-indicator');
expect(indicator).toHaveStyle({ left: '200px' });
// Advance time by 1 minute
act(() => {
vi.advanceTimersByTime(60000);
});
// Position should update (120 minutes + 1 minute = 121 minutes)
// 121 minutes * (100px / 60 minutes) = 201.67px
expect(indicator).toBeInTheDocument();
});
it('renders with correct styling', () => {
const startTime = new Date('2024-01-01T08:00:00');
const now = new Date('2024-01-01T10:00:00');
vi.setSystemTime(now);
render(<CurrentTimeIndicator startTime={startTime} hourWidth={100} />);
const indicator = document.querySelector('#current-time-indicator');
expect(indicator).toHaveClass('absolute', 'top-0', 'bottom-0', 'w-px', 'bg-red-500', 'z-30', 'pointer-events-none');
});
it('renders the red dot at the top', () => {
const startTime = new Date('2024-01-01T08:00:00');
const now = new Date('2024-01-01T10:00:00');
vi.setSystemTime(now);
render(<CurrentTimeIndicator startTime={startTime} hourWidth={100} />);
const indicator = document.querySelector('#current-time-indicator');
const dot = indicator?.querySelector('.rounded-full');
expect(dot).toBeInTheDocument();
expect(dot).toHaveClass('bg-red-500');
});
it('works with different hourWidth values', () => {
const startTime = new Date('2024-01-01T08:00:00');
const now = new Date('2024-01-01T10:00:00'); // 2 hours after start
vi.setSystemTime(now);
render(<CurrentTimeIndicator startTime={startTime} hourWidth={150} />);
const indicator = document.querySelector('#current-time-indicator');
expect(indicator).toHaveStyle({ left: '300px' }); // 2 hours * 150px
});
it('handles fractional hour positions', () => {
const startTime = new Date('2024-01-01T08:00:00');
const now = new Date('2024-01-01T08:30:00'); // 30 minutes after start
vi.setSystemTime(now);
render(<CurrentTimeIndicator startTime={startTime} hourWidth={100} />);
const indicator = document.querySelector('#current-time-indicator');
expect(indicator).toHaveStyle({ left: '50px' }); // 0.5 hours * 100px
});
});

View File

@@ -0,0 +1,228 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { DraggableEvent } from '../DraggableEvent';
// Mock DnD Kit
vi.mock('@dnd-kit/core', () => ({
DndContext: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
useDraggable: vi.fn(() => ({
attributes: {},
listeners: {},
setNodeRef: vi.fn(),
transform: null,
isDragging: false,
})),
}));
vi.mock('@dnd-kit/utilities', () => ({
CSS: {
Translate: {
toString: (transform: any) => transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : undefined,
},
},
}));
describe('DraggableEvent', () => {
const defaultProps = {
id: 1,
title: 'Test Event',
serviceName: 'Test Service',
status: 'CONFIRMED' as const,
isPaid: false,
start: new Date('2024-01-01T10:00:00'),
end: new Date('2024-01-01T11:00:00'),
laneIndex: 0,
height: 80,
left: 100,
width: 200,
top: 10,
onResizeStart: vi.fn(),
};
it('renders the event title', () => {
render(<DraggableEvent {...defaultProps} />);
expect(screen.getByText('Test Event')).toBeInTheDocument();
});
it('renders the service name when provided', () => {
render(<DraggableEvent {...defaultProps} />);
expect(screen.getByText('Test Service')).toBeInTheDocument();
});
it('does not render service name when not provided', () => {
render(<DraggableEvent {...defaultProps} serviceName={undefined} />);
expect(screen.queryByText('Test Service')).not.toBeInTheDocument();
});
it('displays the start time formatted correctly', () => {
render(<DraggableEvent {...defaultProps} />);
expect(screen.getByText('10:00 AM')).toBeInTheDocument();
});
it('applies correct position styles', () => {
const { container } = render(<DraggableEvent {...defaultProps} />);
const eventElement = container.querySelector('.absolute.rounded-b');
expect(eventElement).toHaveStyle({
left: '100px',
width: '200px',
top: '10px',
height: '80px',
});
});
it('applies confirmed status border color', () => {
const { container } = render(
<DraggableEvent {...defaultProps} status="CONFIRMED" />
);
const eventElement = container.querySelector('.absolute.rounded-b');
expect(eventElement).toHaveClass('border-blue-500');
});
it('applies completed status border color', () => {
const { container } = render(
<DraggableEvent {...defaultProps} status="COMPLETED" />
);
const eventElement = container.querySelector('.absolute.rounded-b');
expect(eventElement).toHaveClass('border-green-500');
});
it('applies cancelled status border color', () => {
const { container } = render(
<DraggableEvent {...defaultProps} status="CANCELLED" />
);
const eventElement = container.querySelector('.absolute.rounded-b');
expect(eventElement).toHaveClass('border-red-500');
});
it('applies no-show status border color', () => {
const { container } = render(
<DraggableEvent {...defaultProps} status="NO_SHOW" />
);
const eventElement = container.querySelector('.absolute.rounded-b');
expect(eventElement).toHaveClass('border-gray-500');
});
it('applies green border when paid', () => {
const { container } = render(
<DraggableEvent {...defaultProps} isPaid={true} />
);
const eventElement = container.querySelector('.absolute.rounded-b');
expect(eventElement).toHaveClass('border-green-500');
});
it('applies default brand border color for scheduled status', () => {
const { container } = render(
<DraggableEvent {...defaultProps} status="SCHEDULED" />
);
const eventElement = container.querySelector('.absolute.rounded-b');
expect(eventElement).toHaveClass('border-brand-500');
});
it('calls onResizeStart when top resize handle is clicked', () => {
const onResizeStart = vi.fn();
const { container } = render(
<DraggableEvent {...defaultProps} onResizeStart={onResizeStart} />
);
const topHandle = container.querySelector('.cursor-ns-resize');
if (topHandle) {
fireEvent.mouseDown(topHandle);
expect(onResizeStart).toHaveBeenCalledWith(
expect.any(Object),
'left',
1
);
}
});
it('calls onResizeStart when bottom resize handle is clicked', () => {
const onResizeStart = vi.fn();
const { container } = render(
<DraggableEvent {...defaultProps} onResizeStart={onResizeStart} />
);
const handles = container.querySelectorAll('.cursor-ns-resize');
const bottomHandle = handles[handles.length - 1]; // Get the last one (bottom)
if (bottomHandle) {
fireEvent.mouseDown(bottomHandle);
expect(onResizeStart).toHaveBeenCalledWith(
expect.any(Object),
'right',
1
);
}
});
it('renders grip icon', () => {
const { container } = render(<DraggableEvent {...defaultProps} />);
const gripIcon = container.querySelector('svg');
expect(gripIcon).toBeInTheDocument();
});
it('applies hover styles', () => {
const { container } = render(<DraggableEvent {...defaultProps} />);
const eventElement = container.querySelector('.absolute.rounded-b');
expect(eventElement).toHaveClass('group', 'hover:shadow-md');
});
it('renders with correct base styling classes', () => {
const { container } = render(<DraggableEvent {...defaultProps} />);
const eventElement = container.querySelector('.absolute.rounded-b');
expect(eventElement).toHaveClass(
'absolute',
'rounded-b',
'overflow-hidden',
'group',
'bg-brand-100'
);
});
it('has two resize handles', () => {
const { container } = render(<DraggableEvent {...defaultProps} />);
const handles = container.querySelectorAll('.cursor-ns-resize');
expect(handles).toHaveLength(2);
});
it('stops propagation when resize handle is clicked', () => {
const onResizeStart = vi.fn();
const { container } = render(
<DraggableEvent {...defaultProps} onResizeStart={onResizeStart} />
);
const topHandle = container.querySelector('.cursor-ns-resize');
const mockEvent = {
stopPropagation: vi.fn(),
} as any;
if (topHandle) {
fireEvent.mouseDown(topHandle, mockEvent);
// The event handler should call stopPropagation to prevent drag
expect(onResizeStart).toHaveBeenCalled();
}
});
it('renders content area with cursor-move', () => {
const { container } = render(<DraggableEvent {...defaultProps} />);
const contentArea = container.querySelector('.cursor-move');
expect(contentArea).toBeInTheDocument();
expect(contentArea).toHaveClass('select-none');
});
it('applies different heights correctly', () => {
const { container } = render(
<DraggableEvent {...defaultProps} height={100} />
);
const eventElement = container.querySelector('.absolute.rounded-b');
expect(eventElement).toHaveStyle({ height: '100px' });
});
it('applies different widths correctly', () => {
const { container } = render(
<DraggableEvent {...defaultProps} width={300} />
);
const eventElement = container.querySelector('.absolute.rounded-b');
expect(eventElement).toHaveStyle({ width: '300px' });
});
});

View File

@@ -0,0 +1,243 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import ResourceRow from '../ResourceRow';
import { Event } from '../../../lib/layoutAlgorithm';
// Mock DnD Kit
vi.mock('@dnd-kit/core', () => ({
DndContext: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
useDroppable: vi.fn(() => ({
setNodeRef: vi.fn(),
isOver: false,
})),
useDraggable: vi.fn(() => ({
attributes: {},
listeners: {},
setNodeRef: vi.fn(),
transform: null,
isDragging: false,
})),
}));
vi.mock('@dnd-kit/utilities', () => ({
CSS: {
Translate: {
toString: (transform: any) => transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : undefined,
},
},
}));
describe('ResourceRow', () => {
const mockEvents: Event[] = [
{
id: 1,
resourceId: 1,
title: 'Event 1',
serviceName: 'Service 1',
start: new Date('2024-01-01T10:00:00'),
end: new Date('2024-01-01T11:00:00'),
status: 'CONFIRMED',
},
{
id: 2,
resourceId: 1,
title: 'Event 2',
serviceName: 'Service 2',
start: new Date('2024-01-01T14:00:00'),
end: new Date('2024-01-01T15:00:00'),
status: 'SCHEDULED',
},
];
const defaultProps = {
resourceId: 1,
resourceName: 'Test Resource',
events: mockEvents,
startTime: new Date('2024-01-01T08:00:00'),
endTime: new Date('2024-01-01T18:00:00'),
hourWidth: 100,
eventHeight: 80,
onResizeStart: vi.fn(),
};
it('renders the resource name', () => {
render(<ResourceRow {...defaultProps} />);
expect(screen.getByText('Test Resource')).toBeInTheDocument();
});
it('renders all events', () => {
render(<ResourceRow {...defaultProps} />);
expect(screen.getByText('Event 1')).toBeInTheDocument();
expect(screen.getByText('Event 2')).toBeInTheDocument();
});
it('renders with no events', () => {
render(<ResourceRow {...defaultProps} events={[]} />);
expect(screen.getByText('Test Resource')).toBeInTheDocument();
expect(screen.queryByText('Event 1')).not.toBeInTheDocument();
});
it('applies sticky positioning to resource name column', () => {
const { container } = render(<ResourceRow {...defaultProps} />);
const nameColumn = container.querySelector('.sticky');
expect(nameColumn).toBeInTheDocument();
expect(nameColumn).toHaveClass('left-0', 'z-10');
});
it('renders grid lines for each hour', () => {
const { container } = render(<ResourceRow {...defaultProps} />);
const gridLines = container.querySelectorAll('.border-r.border-gray-100');
// 10 hours from 8am to 6pm
expect(gridLines.length).toBe(10);
});
it('calculates correct row height based on events', () => {
// Test with overlapping events that require multiple lanes
const overlappingEvents: Event[] = [
{
id: 1,
resourceId: 1,
title: 'Event 1',
start: new Date('2024-01-01T10:00:00'),
end: new Date('2024-01-01T11:00:00'),
},
{
id: 2,
resourceId: 1,
title: 'Event 2',
start: new Date('2024-01-01T10:30:00'),
end: new Date('2024-01-01T11:30:00'),
},
];
const { container } = render(
<ResourceRow {...defaultProps} events={overlappingEvents} />
);
const rowContent = container.querySelector('.relative.flex-grow');
// With 2 lanes and eventHeight of 80, expect height: (2 * 80) + 20 = 180
expect(rowContent?.parentElement).toHaveStyle({ height: expect.any(String) });
});
it('applies droppable area styling', () => {
const { container } = render(<ResourceRow {...defaultProps} />);
const droppableArea = container.querySelector('.relative.flex-grow');
expect(droppableArea).toHaveClass('transition-colors');
});
it('renders border between rows', () => {
const { container } = render(<ResourceRow {...defaultProps} />);
const row = container.querySelector('.flex.border-b');
expect(row).toHaveClass('border-gray-200');
});
it('applies hover effect to resource name', () => {
const { container } = render(<ResourceRow {...defaultProps} />);
const nameColumn = container.querySelector('.bg-gray-50');
expect(nameColumn).toHaveClass('group-hover:bg-gray-100', 'transition-colors');
});
it('calculates total width correctly', () => {
const { container } = render(<ResourceRow {...defaultProps} />);
const rowContent = container.querySelector('.relative.flex-grow');
// 10 hours * 100px = 1000px
expect(rowContent).toHaveStyle({ width: '1000px' });
});
it('positions events correctly within the row', () => {
const { container } = render(<ResourceRow {...defaultProps} />);
const events = container.querySelectorAll('.absolute.rounded-b');
expect(events.length).toBe(2);
});
it('renders resource name with fixed width', () => {
const { container } = render(<ResourceRow {...defaultProps} />);
const nameColumn = screen.getByText('Test Resource').closest('.w-48');
expect(nameColumn).toBeInTheDocument();
expect(nameColumn).toHaveClass('flex-shrink-0');
});
it('handles single event correctly', () => {
const singleEvent: Event[] = [
{
id: 1,
resourceId: 1,
title: 'Single Event',
start: new Date('2024-01-01T10:00:00'),
end: new Date('2024-01-01T11:00:00'),
},
];
render(<ResourceRow {...defaultProps} events={singleEvent} />);
expect(screen.getByText('Single Event')).toBeInTheDocument();
});
it('passes resize handler to events', () => {
const onResizeStart = vi.fn();
render(<ResourceRow {...defaultProps} onResizeStart={onResizeStart} />);
// Events should be rendered with the resize handler passed down
const resizeHandles = document.querySelectorAll('.cursor-ns-resize');
expect(resizeHandles.length).toBeGreaterThan(0);
});
it('applies correct event height to draggable events', () => {
const { container } = render(<ResourceRow {...defaultProps} eventHeight={100} />);
const events = container.querySelectorAll('.absolute.rounded-b');
// Each event should have height of eventHeight - 4 = 96px
events.forEach(event => {
expect(event).toHaveStyle({ height: '96px' });
});
});
it('handles different hour widths', () => {
const { container } = render(<ResourceRow {...defaultProps} hourWidth={150} />);
const rowContent = container.querySelector('.relative.flex-grow');
// 10 hours * 150px = 1500px
expect(rowContent).toHaveStyle({ width: '1500px' });
});
it('renders grid lines with correct width', () => {
const { container } = render(<ResourceRow {...defaultProps} hourWidth={120} />);
const gridLine = container.querySelector('.border-r.border-gray-100');
expect(gridLine).toHaveStyle({ width: '120px' });
});
it('calculates layout for overlapping events', () => {
const overlappingEvents: Event[] = [
{
id: 1,
resourceId: 1,
title: 'Event 1',
start: new Date('2024-01-01T10:00:00'),
end: new Date('2024-01-01T12:00:00'),
},
{
id: 2,
resourceId: 1,
title: 'Event 2',
start: new Date('2024-01-01T11:00:00'),
end: new Date('2024-01-01T13:00:00'),
},
{
id: 3,
resourceId: 1,
title: 'Event 3',
start: new Date('2024-01-01T11:30:00'),
end: new Date('2024-01-01T13:30:00'),
},
];
render(<ResourceRow {...defaultProps} events={overlappingEvents} />);
// All three events should be rendered
expect(screen.getByText('Event 1')).toBeInTheDocument();
expect(screen.getByText('Event 2')).toBeInTheDocument();
expect(screen.getByText('Event 3')).toBeInTheDocument();
});
it('sets droppable id with resource id', () => {
const { container } = render(<ResourceRow {...defaultProps} resourceId={42} />);
// The droppable area should have the resource id in its data
const droppableArea = container.querySelector('.relative.flex-grow');
expect(droppableArea).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,276 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import TimelineRow from '../TimelineRow';
import { Event } from '../../../lib/layoutAlgorithm';
// Mock DnD Kit
vi.mock('@dnd-kit/core', () => ({
DndContext: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
useDroppable: vi.fn(() => ({
setNodeRef: vi.fn(),
isOver: false,
})),
useDraggable: vi.fn(() => ({
attributes: {},
listeners: {},
setNodeRef: vi.fn(),
transform: null,
isDragging: false,
})),
}));
vi.mock('@dnd-kit/utilities', () => ({
CSS: {
Translate: {
toString: (transform: any) => transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : undefined,
},
},
}));
describe('TimelineRow', () => {
const mockEvents: Event[] = [
{
id: 1,
resourceId: 1,
title: 'Event 1',
serviceName: 'Service 1',
start: new Date('2024-01-01T10:00:00'),
end: new Date('2024-01-01T11:00:00'),
status: 'CONFIRMED',
isPaid: false,
},
{
id: 2,
resourceId: 1,
title: 'Event 2',
serviceName: 'Service 2',
start: new Date('2024-01-01T14:00:00'),
end: new Date('2024-01-01T15:00:00'),
status: 'SCHEDULED',
isPaid: true,
},
];
const defaultProps = {
resourceId: 1,
events: mockEvents,
startTime: new Date('2024-01-01T08:00:00'),
endTime: new Date('2024-01-01T18:00:00'),
hourWidth: 100,
eventHeight: 80,
height: 100,
onResizeStart: vi.fn(),
};
it('renders all events', () => {
render(<TimelineRow {...defaultProps} />);
expect(screen.getByText('Event 1')).toBeInTheDocument();
expect(screen.getByText('Event 2')).toBeInTheDocument();
});
it('renders event service names', () => {
render(<TimelineRow {...defaultProps} />);
expect(screen.getByText('Service 1')).toBeInTheDocument();
expect(screen.getByText('Service 2')).toBeInTheDocument();
});
it('renders with no events', () => {
render(<TimelineRow {...defaultProps} events={[]} />);
expect(screen.queryByText('Event 1')).not.toBeInTheDocument();
});
it('applies correct height from prop', () => {
const { container } = render(<TimelineRow {...defaultProps} height={150} />);
const row = container.querySelector('.relative.border-b');
expect(row).toHaveStyle({ height: '150px' });
});
it('calculates total width correctly', () => {
const { container } = render(<TimelineRow {...defaultProps} />);
const row = container.querySelector('.relative.border-b');
// 10 hours * 100px = 1000px
expect(row).toHaveStyle({ width: '1000px' });
});
it('renders grid lines for each hour', () => {
const { container } = render(<TimelineRow {...defaultProps} />);
const gridLines = container.querySelectorAll('.border-r.border-gray-100');
// 10 hours from 8am to 6pm
expect(gridLines.length).toBe(10);
});
it('applies droppable area styling', () => {
const { container } = render(<TimelineRow {...defaultProps} />);
const row = container.querySelector('.relative.border-b');
expect(row).toHaveClass('transition-colors', 'group');
});
it('renders border with dark mode support', () => {
const { container } = render(<TimelineRow {...defaultProps} />);
const row = container.querySelector('.relative.border-b');
expect(row).toHaveClass('border-gray-200', 'dark:border-gray-700');
});
it('handles different hour widths', () => {
const { container } = render(<TimelineRow {...defaultProps} hourWidth={150} />);
const row = container.querySelector('.relative.border-b');
// 10 hours * 150px = 1500px
expect(row).toHaveStyle({ width: '1500px' });
});
it('renders grid lines with correct width', () => {
const { container } = render(<TimelineRow {...defaultProps} hourWidth={120} />);
const gridLine = container.querySelector('.border-r.border-gray-100');
expect(gridLine).toHaveStyle({ width: '120px' });
});
it('positions events correctly within the row', () => {
const { container } = render(<TimelineRow {...defaultProps} />);
const events = container.querySelectorAll('.absolute.rounded-b');
expect(events.length).toBe(2);
});
it('passes event status to draggable events', () => {
render(<TimelineRow {...defaultProps} />);
// Events should render with their status (visible in the DOM)
expect(screen.getByText('Event 1')).toBeInTheDocument();
expect(screen.getByText('Event 2')).toBeInTheDocument();
});
it('passes isPaid prop to draggable events', () => {
const { container } = render(<TimelineRow {...defaultProps} />);
// Second event is paid, should have green border
const events = container.querySelectorAll('.absolute.rounded-b');
expect(events.length).toBe(2);
});
it('passes resize handler to events', () => {
const onResizeStart = vi.fn();
render(<TimelineRow {...defaultProps} onResizeStart={onResizeStart} />);
// Events should be rendered with the resize handler passed down
const resizeHandles = document.querySelectorAll('.cursor-ns-resize');
expect(resizeHandles.length).toBeGreaterThan(0);
});
it('calculates layout for overlapping events', () => {
const overlappingEvents: Event[] = [
{
id: 1,
resourceId: 1,
title: 'Event 1',
start: new Date('2024-01-01T10:00:00'),
end: new Date('2024-01-01T12:00:00'),
},
{
id: 2,
resourceId: 1,
title: 'Event 2',
start: new Date('2024-01-01T11:00:00'),
end: new Date('2024-01-01T13:00:00'),
},
{
id: 3,
resourceId: 1,
title: 'Event 3',
start: new Date('2024-01-01T11:30:00'),
end: new Date('2024-01-01T13:30:00'),
},
];
render(<TimelineRow {...defaultProps} events={overlappingEvents} />);
// All three events should be rendered
expect(screen.getByText('Event 1')).toBeInTheDocument();
expect(screen.getByText('Event 2')).toBeInTheDocument();
expect(screen.getByText('Event 3')).toBeInTheDocument();
});
it('applies correct event height to draggable events', () => {
const { container } = render(<TimelineRow {...defaultProps} eventHeight={100} />);
const events = container.querySelectorAll('.absolute.rounded-b');
// Each event should have height of eventHeight - 4 = 96px
events.forEach(event => {
expect(event).toHaveStyle({ height: '96px' });
});
});
it('handles single event correctly', () => {
const singleEvent: Event[] = [
{
id: 1,
resourceId: 1,
title: 'Single Event',
start: new Date('2024-01-01T10:00:00'),
end: new Date('2024-01-01T11:00:00'),
},
];
render(<TimelineRow {...defaultProps} events={singleEvent} />);
expect(screen.getByText('Single Event')).toBeInTheDocument();
});
it('renders grid with pointer-events-none', () => {
const { container } = render(<TimelineRow {...defaultProps} />);
const gridContainer = container.querySelector('.pointer-events-none.flex');
expect(gridContainer).toBeInTheDocument();
expect(gridContainer).toHaveClass('absolute', 'inset-0');
});
it('applies dark mode styling to grid lines', () => {
const { container } = render(<TimelineRow {...defaultProps} />);
const gridLine = container.querySelector('.border-r');
expect(gridLine).toHaveClass('dark:border-gray-700/50');
});
it('sets droppable id with resource id', () => {
const { container } = render(<TimelineRow {...defaultProps} resourceId={42} />);
// The droppable area should have the resource id in its data
const droppableArea = container.querySelector('.relative.border-b');
expect(droppableArea).toBeInTheDocument();
});
it('renders events with correct top positioning based on lane', () => {
const { container } = render(<TimelineRow {...defaultProps} />);
const events = container.querySelectorAll('.absolute.rounded-b');
// Events should be positioned with top: (laneIndex * eventHeight) + 10
expect(events.length).toBe(2);
});
it('handles events without service name', () => {
const eventsNoService: Event[] = [
{
id: 1,
resourceId: 1,
title: 'Event Without Service',
start: new Date('2024-01-01T10:00:00'),
end: new Date('2024-01-01T11:00:00'),
},
];
render(<TimelineRow {...defaultProps} events={eventsNoService} />);
expect(screen.getByText('Event Without Service')).toBeInTheDocument();
});
it('handles events without status', () => {
const eventsNoStatus: Event[] = [
{
id: 1,
resourceId: 1,
title: 'Event Without Status',
start: new Date('2024-01-01T10:00:00'),
end: new Date('2024-01-01T11:00:00'),
},
];
render(<TimelineRow {...defaultProps} events={eventsNoStatus} />);
expect(screen.getByText('Event Without Status')).toBeInTheDocument();
});
it('memoizes event layout calculation', () => {
const { rerender } = render(<TimelineRow {...defaultProps} />);
expect(screen.getByText('Event 1')).toBeInTheDocument();
// Rerender with same events
rerender(<TimelineRow {...defaultProps} />);
expect(screen.getByText('Event 1')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,812 @@
/**
* Tests for ConnectOnboarding component
*
* Tests the Stripe Connect onboarding component for paid-tier businesses.
* Covers:
* - Rendering different states (active, onboarding, needs onboarding)
* - Account details display
* - User interactions (start onboarding, refresh link)
* - Error handling
* - Loading states
* - Account type labels
* - Window location redirects
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import ConnectOnboarding from '../ConnectOnboarding';
import { ConnectAccountInfo } from '../../api/payments';
// Mock hooks
const mockUseConnectOnboarding = vi.fn();
const mockUseRefreshConnectLink = vi.fn();
vi.mock('../../hooks/usePayments', () => ({
useConnectOnboarding: () => mockUseConnectOnboarding(),
useRefreshConnectLink: () => mockUseRefreshConnectLink(),
}));
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, params?: Record<string, unknown>) => {
const translations: Record<string, string> = {
'payments.stripeConnected': 'Stripe Connected',
'payments.stripeConnectedDesc': 'Your Stripe account is connected and ready to accept payments',
'payments.accountDetails': 'Account Details',
'payments.accountType': 'Account Type',
'payments.status': 'Status',
'payments.charges': 'Charges',
'payments.payouts': 'Payouts',
'payments.enabled': 'Enabled',
'payments.disabled': 'Disabled',
'payments.accountId': 'Account ID',
'payments.completeOnboarding': 'Complete Onboarding',
'payments.onboardingIncomplete': 'Please complete your Stripe account setup to accept payments',
'payments.continueOnboarding': 'Continue Onboarding',
'payments.connectWithStripe': 'Connect with Stripe',
'payments.tierPaymentDescription': `Connect your Stripe account to accept payments with your ${params?.tier} plan`,
'payments.securePaymentProcessing': 'Secure payment processing',
'payments.automaticPayouts': 'Automatic payouts to your bank',
'payments.pciCompliance': 'PCI compliance handled for you',
'payments.failedToStartOnboarding': 'Failed to start onboarding',
'payments.failedToRefreshLink': 'Failed to refresh link',
'payments.openStripeDashboard': 'Open Stripe Dashboard',
'payments.standardConnect': 'Standard',
'payments.expressConnect': 'Express',
'payments.customConnect': 'Custom',
'payments.connect': 'Connect',
};
return translations[key] || key;
},
}),
}));
// Test data factory
const createMockConnectAccount = (
overrides?: Partial<ConnectAccountInfo>
): ConnectAccountInfo => ({
id: 1,
business: 1,
business_name: 'Test Business',
business_subdomain: 'testbiz',
stripe_account_id: 'acct_test123',
account_type: 'standard',
status: 'active',
charges_enabled: true,
payouts_enabled: true,
details_submitted: true,
onboarding_complete: true,
onboarding_link: null,
onboarding_link_expires_at: null,
is_onboarding_link_valid: false,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
...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}>{children}</QueryClientProvider>
);
};
describe('ConnectOnboarding', () => {
const mockMutateAsync = vi.fn();
let originalLocation: Location;
beforeEach(() => {
vi.clearAllMocks();
// Save original location
originalLocation = window.location;
// Mock window.location
delete (window as any).location;
window.location = {
...originalLocation,
origin: 'http://testbiz.lvh.me:5173',
href: 'http://testbiz.lvh.me:5173/payments',
} as Location;
mockUseConnectOnboarding.mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: false,
});
mockUseRefreshConnectLink.mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: false,
});
});
afterEach(() => {
// Restore original location
window.location = originalLocation;
});
describe('Active Account State', () => {
it('should render active status when account is active and charges enabled', () => {
const account = createMockConnectAccount({
status: 'active',
charges_enabled: true,
});
render(
<ConnectOnboarding connectAccount={account} tier="Professional" />,
{ wrapper: createWrapper() }
);
expect(screen.getByText('Stripe Connected')).toBeInTheDocument();
expect(screen.getByText(/Your Stripe account is connected/)).toBeInTheDocument();
});
it('should display account details for active account', () => {
const account = createMockConnectAccount({
account_type: 'express',
status: 'active',
stripe_account_id: 'acct_test456',
});
render(
<ConnectOnboarding connectAccount={account} tier="Professional" />,
{ wrapper: createWrapper() }
);
expect(screen.getByText('Account Details')).toBeInTheDocument();
expect(screen.getByText('Express')).toBeInTheDocument();
expect(screen.getByText('active')).toBeInTheDocument();
expect(screen.getByText('acct_test456')).toBeInTheDocument();
});
it('should show enabled charges and payouts', () => {
const account = createMockConnectAccount({
charges_enabled: true,
payouts_enabled: true,
});
render(
<ConnectOnboarding connectAccount={account} tier="Professional" />,
{ wrapper: createWrapper() }
);
const enabledLabels = screen.getAllByText('Enabled');
expect(enabledLabels).toHaveLength(2); // Charges and Payouts
});
it('should show disabled charges and payouts', () => {
const account = createMockConnectAccount({
status: 'restricted',
charges_enabled: false,
payouts_enabled: false,
});
render(
<ConnectOnboarding connectAccount={account} tier="Professional" />,
{ wrapper: createWrapper() }
);
const disabledLabels = screen.getAllByText('Disabled');
expect(disabledLabels).toHaveLength(2); // Charges and Payouts
});
it('should show Stripe dashboard link when active', () => {
const account = createMockConnectAccount({
status: 'active',
charges_enabled: true,
});
render(
<ConnectOnboarding connectAccount={account} tier="Professional" />,
{ wrapper: createWrapper() }
);
const dashboardLink = screen.getByText('Open Stripe Dashboard');
expect(dashboardLink).toBeInTheDocument();
expect(dashboardLink.closest('a')).toHaveAttribute(
'href',
'https://dashboard.stripe.com'
);
expect(dashboardLink.closest('a')).toHaveAttribute('target', '_blank');
});
});
describe('Account Type Labels', () => {
it('should display standard account type', () => {
const account = createMockConnectAccount({ account_type: 'standard' });
render(
<ConnectOnboarding connectAccount={account} tier="Professional" />,
{ wrapper: createWrapper() }
);
expect(screen.getByText('Standard')).toBeInTheDocument();
});
it('should display express account type', () => {
const account = createMockConnectAccount({ account_type: 'express' });
render(
<ConnectOnboarding connectAccount={account} tier="Professional" />,
{ wrapper: createWrapper() }
);
expect(screen.getByText('Express')).toBeInTheDocument();
});
it('should display custom account type', () => {
const account = createMockConnectAccount({ account_type: 'custom' });
render(
<ConnectOnboarding connectAccount={account} tier="Professional" />,
{ wrapper: createWrapper() }
);
expect(screen.getByText('Custom')).toBeInTheDocument();
});
});
describe('Account Status Display', () => {
it('should show active status with green styling', () => {
const account = createMockConnectAccount({ status: 'active' });
const { container } = render(
<ConnectOnboarding connectAccount={account} tier="Professional" />,
{ wrapper: createWrapper() }
);
const statusBadge = screen.getByText('active');
expect(statusBadge).toHaveClass('bg-green-100', 'text-green-800');
});
it('should show onboarding status with yellow styling', () => {
const account = createMockConnectAccount({ status: 'onboarding' });
const { container } = render(
<ConnectOnboarding connectAccount={account} tier="Professional" />,
{ wrapper: createWrapper() }
);
const statusBadge = screen.getByText('onboarding');
expect(statusBadge).toHaveClass('bg-yellow-100', 'text-yellow-800');
});
it('should show restricted status with red styling', () => {
const account = createMockConnectAccount({ status: 'restricted' });
const { container } = render(
<ConnectOnboarding connectAccount={account} tier="Professional" />,
{ wrapper: createWrapper() }
);
const statusBadge = screen.getByText('restricted');
expect(statusBadge).toHaveClass('bg-red-100', 'text-red-800');
});
});
describe('Onboarding in Progress State', () => {
it('should show onboarding warning when status is onboarding', () => {
const account = createMockConnectAccount({
status: 'onboarding',
onboarding_complete: false,
});
render(
<ConnectOnboarding connectAccount={account} tier="Professional" />,
{ wrapper: createWrapper() }
);
expect(screen.getByText('Complete Onboarding')).toBeInTheDocument();
expect(screen.getByText(/Please complete your Stripe account setup/)).toBeInTheDocument();
});
it('should show onboarding warning when onboarding_complete is false', () => {
const account = createMockConnectAccount({
status: 'active',
onboarding_complete: false,
});
render(
<ConnectOnboarding connectAccount={account} tier="Professional" />,
{ wrapper: createWrapper() }
);
expect(screen.getByText('Complete Onboarding')).toBeInTheDocument();
});
it('should render continue onboarding button', () => {
const account = createMockConnectAccount({
status: 'onboarding',
onboarding_complete: false,
});
render(
<ConnectOnboarding connectAccount={account} tier="Professional" />,
{ wrapper: createWrapper() }
);
const button = screen.getByRole('button', { name: /continue onboarding/i });
expect(button).toBeInTheDocument();
});
it('should call refresh link mutation when continue button clicked', async () => {
const account = createMockConnectAccount({
status: 'onboarding',
onboarding_complete: false,
});
mockMutateAsync.mockResolvedValue({
url: 'https://connect.stripe.com/setup/test',
});
render(
<ConnectOnboarding connectAccount={account} tier="Professional" />,
{ wrapper: createWrapper() }
);
const button = screen.getByRole('button', { name: /continue onboarding/i });
fireEvent.click(button);
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith({
refreshUrl: 'http://testbiz.lvh.me:5173/payments?connect=refresh',
returnUrl: 'http://testbiz.lvh.me:5173/payments?connect=complete',
});
});
});
it('should redirect to Stripe URL after refresh link success', async () => {
const account = createMockConnectAccount({
status: 'onboarding',
onboarding_complete: false,
});
const stripeUrl = 'https://connect.stripe.com/setup/test123';
mockMutateAsync.mockResolvedValue({ url: stripeUrl });
render(
<ConnectOnboarding connectAccount={account} tier="Professional" />,
{ wrapper: createWrapper() }
);
const button = screen.getByRole('button', { name: /continue onboarding/i });
fireEvent.click(button);
await waitFor(() => {
expect(window.location.href).toBe(stripeUrl);
});
});
it('should show loading state while refreshing link', () => {
const account = createMockConnectAccount({
status: 'onboarding',
onboarding_complete: false,
});
mockUseRefreshConnectLink.mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: true,
});
render(
<ConnectOnboarding connectAccount={account} tier="Professional" />,
{ wrapper: createWrapper() }
);
const button = screen.getByRole('button', { name: /continue onboarding/i });
expect(button).toBeDisabled();
});
});
describe('Needs Onboarding State', () => {
it('should show onboarding info when no account exists', () => {
render(
<ConnectOnboarding connectAccount={null} tier="Professional" />,
{ wrapper: createWrapper() }
);
expect(screen.getAllByText('Connect with Stripe').length).toBeGreaterThan(0);
expect(
screen.getByText(/Connect your Stripe account to accept payments with your Professional plan/)
).toBeInTheDocument();
});
it('should show feature list when no account exists', () => {
render(
<ConnectOnboarding connectAccount={null} tier="Professional" />,
{ wrapper: createWrapper() }
);
expect(screen.getByText('Secure payment processing')).toBeInTheDocument();
expect(screen.getByText('Automatic payouts to your bank')).toBeInTheDocument();
expect(screen.getByText('PCI compliance handled for you')).toBeInTheDocument();
});
it('should render start onboarding button when no account', () => {
render(
<ConnectOnboarding connectAccount={null} tier="Professional" />,
{ wrapper: createWrapper() }
);
const buttons = screen.getAllByRole('button', { name: /connect with stripe/i });
expect(buttons.length).toBeGreaterThan(0);
});
it('should call onboarding mutation when start button clicked', async () => {
mockMutateAsync.mockResolvedValue({
url: 'https://connect.stripe.com/express/oauth',
});
render(
<ConnectOnboarding connectAccount={null} tier="Professional" />,
{ wrapper: createWrapper() }
);
const button = screen.getByRole('button', { name: /connect with stripe/i });
fireEvent.click(button);
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith({
refreshUrl: 'http://testbiz.lvh.me:5173/payments?connect=refresh',
returnUrl: 'http://testbiz.lvh.me:5173/payments?connect=complete',
});
});
});
it('should redirect to Stripe URL after onboarding start', async () => {
const stripeUrl = 'https://connect.stripe.com/express/oauth/authorize';
mockMutateAsync.mockResolvedValue({ url: stripeUrl });
render(
<ConnectOnboarding connectAccount={null} tier="Professional" />,
{ wrapper: createWrapper() }
);
const button = screen.getByRole('button', { name: /connect with stripe/i });
fireEvent.click(button);
await waitFor(() => {
expect(window.location.href).toBe(stripeUrl);
});
});
it('should show loading state while starting onboarding', () => {
mockUseConnectOnboarding.mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: true,
});
const { container } = render(
<ConnectOnboarding connectAccount={null} tier="Professional" />,
{ wrapper: createWrapper() }
);
// Find the button by its Stripe brand color class
const button = container.querySelector('button.bg-\\[\\#635BFF\\]');
expect(button).toBeInTheDocument();
expect(button).toBeDisabled();
});
});
describe('Error Handling', () => {
it('should show error message when onboarding fails', async () => {
mockMutateAsync.mockRejectedValue({
response: {
data: {
error: 'Stripe account creation failed',
},
},
});
render(
<ConnectOnboarding connectAccount={null} tier="Professional" />,
{ wrapper: createWrapper() }
);
const button = screen.getByRole('button', { name: /connect with stripe/i });
fireEvent.click(button);
await waitFor(() => {
expect(screen.getByText('Stripe account creation failed')).toBeInTheDocument();
});
});
it('should show default error message when no error detail provided', async () => {
mockMutateAsync.mockRejectedValue(new Error('Network error'));
render(
<ConnectOnboarding connectAccount={null} tier="Professional" />,
{ wrapper: createWrapper() }
);
const button = screen.getByRole('button', { name: /connect with stripe/i });
fireEvent.click(button);
await waitFor(() => {
expect(screen.getByText('Failed to start onboarding')).toBeInTheDocument();
});
});
it('should show error when refresh link fails', async () => {
const account = createMockConnectAccount({
status: 'onboarding',
onboarding_complete: false,
});
mockMutateAsync.mockRejectedValue({
response: {
data: {
error: 'Link expired',
},
},
});
render(
<ConnectOnboarding connectAccount={account} tier="Professional" />,
{ wrapper: createWrapper() }
);
const button = screen.getByRole('button', { name: /continue onboarding/i });
fireEvent.click(button);
await waitFor(() => {
expect(screen.getByText('Link expired')).toBeInTheDocument();
});
});
it('should clear previous error when starting new action', async () => {
mockMutateAsync.mockRejectedValueOnce({
response: {
data: {
error: 'First error',
},
},
});
render(
<ConnectOnboarding connectAccount={null} tier="Professional" />,
{ wrapper: createWrapper() }
);
const button = screen.getByRole('button', { name: /connect with stripe/i });
// First click - causes error
fireEvent.click(button);
await waitFor(() => {
expect(screen.getByText('First error')).toBeInTheDocument();
});
// Second click - should clear error before mutation
mockMutateAsync.mockResolvedValue({ url: 'https://stripe.com' });
fireEvent.click(button);
// Error should eventually disappear (after mutation starts)
await waitFor(() => {
expect(screen.queryByText('First error')).not.toBeInTheDocument();
});
});
});
describe('Props Handling', () => {
it('should use tier in description', () => {
render(
<ConnectOnboarding connectAccount={null} tier="Premium" />,
{ wrapper: createWrapper() }
);
expect(
screen.getByText(/Connect your Stripe account to accept payments with your Premium plan/)
).toBeInTheDocument();
});
it('should call onSuccess callback when provided', async () => {
const onSuccess = vi.fn();
const account = createMockConnectAccount({
status: 'active',
charges_enabled: true,
});
render(
<ConnectOnboarding
connectAccount={account}
tier="Professional"
onSuccess={onSuccess}
/>,
{ wrapper: createWrapper() }
);
// onSuccess is not called in the current implementation
// This test documents the prop exists but isn't used
expect(onSuccess).not.toHaveBeenCalled();
});
});
describe('Return URLs', () => {
it('should generate correct return URLs based on window location', async () => {
mockMutateAsync.mockResolvedValue({
url: 'https://stripe.com',
});
render(
<ConnectOnboarding connectAccount={null} tier="Professional" />,
{ wrapper: createWrapper() }
);
const button = screen.getByRole('button', { name: /connect with stripe/i });
fireEvent.click(button);
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith({
refreshUrl: 'http://testbiz.lvh.me:5173/payments?connect=refresh',
returnUrl: 'http://testbiz.lvh.me:5173/payments?connect=complete',
});
});
});
it('should use same return URLs for both onboarding and refresh', async () => {
const account = createMockConnectAccount({
status: 'onboarding',
onboarding_complete: false,
});
mockMutateAsync.mockResolvedValue({
url: 'https://stripe.com',
});
const { rerender } = render(
<ConnectOnboarding connectAccount={null} tier="Professional" />,
{ wrapper: createWrapper() }
);
// Test start onboarding
const startButton = screen.getByRole('button', { name: /connect with stripe/i });
fireEvent.click(startButton);
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith({
refreshUrl: 'http://testbiz.lvh.me:5173/payments?connect=refresh',
returnUrl: 'http://testbiz.lvh.me:5173/payments?connect=complete',
});
});
mockMutateAsync.mockClear();
// Test refresh link
rerender(
<ConnectOnboarding connectAccount={account} tier="Professional" />
);
const refreshButton = screen.getByRole('button', { name: /continue onboarding/i });
fireEvent.click(refreshButton);
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith({
refreshUrl: 'http://testbiz.lvh.me:5173/payments?connect=refresh',
returnUrl: 'http://testbiz.lvh.me:5173/payments?connect=complete',
});
});
});
});
describe('UI Elements', () => {
it('should have proper styling for active account banner', () => {
const account = createMockConnectAccount({
status: 'active',
charges_enabled: true,
});
const { container } = render(
<ConnectOnboarding connectAccount={account} tier="Professional" />,
{ wrapper: createWrapper() }
);
const banner = container.querySelector('.bg-green-50');
expect(banner).toBeInTheDocument();
expect(banner).toHaveClass('border', 'border-green-200', 'rounded-lg');
});
it('should have proper styling for onboarding warning', () => {
const account = createMockConnectAccount({
status: 'onboarding',
onboarding_complete: false,
});
const { container } = render(
<ConnectOnboarding connectAccount={account} tier="Professional" />,
{ wrapper: createWrapper() }
);
const warning = container.querySelector('.bg-yellow-50');
expect(warning).toBeInTheDocument();
expect(warning).toHaveClass('border', 'border-yellow-200', 'rounded-lg');
});
it('should have proper styling for start onboarding section', () => {
const { container } = render(
<ConnectOnboarding connectAccount={null} tier="Professional" />,
{ wrapper: createWrapper() }
);
const infoBox = container.querySelector('.bg-blue-50');
expect(infoBox).toBeInTheDocument();
expect(infoBox).toHaveClass('border', 'border-blue-200', 'rounded-lg');
});
it('should have Stripe brand color on connect button', () => {
render(
<ConnectOnboarding connectAccount={null} tier="Professional" />,
{ wrapper: createWrapper() }
);
const button = screen.getByRole('button', { name: /connect with stripe/i });
expect(button).toHaveClass('bg-[#635BFF]');
});
});
describe('Conditional Rendering', () => {
it('should not show active banner when charges disabled', () => {
const account = createMockConnectAccount({
status: 'active',
charges_enabled: false,
});
render(
<ConnectOnboarding connectAccount={account} tier="Professional" />,
{ wrapper: createWrapper() }
);
expect(screen.queryByText('Stripe Connected')).not.toBeInTheDocument();
});
it('should not show Stripe dashboard link when not active', () => {
const account = createMockConnectAccount({
status: 'onboarding',
charges_enabled: false,
});
render(
<ConnectOnboarding connectAccount={account} tier="Professional" />,
{ wrapper: createWrapper() }
);
expect(screen.queryByText('Open Stripe Dashboard')).not.toBeInTheDocument();
});
it('should show account details even when not fully active', () => {
const account = createMockConnectAccount({
status: 'restricted',
charges_enabled: false,
});
render(
<ConnectOnboarding connectAccount={account} tier="Professional" />,
{ wrapper: createWrapper() }
);
expect(screen.getByText('Account Details')).toBeInTheDocument();
});
it('should not show onboarding warning when complete', () => {
const account = createMockConnectAccount({
status: 'active',
onboarding_complete: true,
});
render(
<ConnectOnboarding connectAccount={account} tier="Professional" />,
{ wrapper: createWrapper() }
);
expect(screen.queryByText('Complete Onboarding')).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,797 @@
/**
* Unit tests for DevQuickLogin component
*
* Tests quick login functionality for development environment.
* Covers:
* - Environment checks (production vs development)
* - Component rendering (embedded vs floating)
* - User filtering (all, platform, business)
* - Quick login functionality
* - Subdomain redirects
* - API error handling
* - Loading states
* - Minimize/maximize toggle
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { DevQuickLogin } from '../DevQuickLogin';
import * as apiClient from '../../api/client';
import * as cookies from '../../utils/cookies';
import * as domain from '../../utils/domain';
// Mock modules
vi.mock('../../api/client');
vi.mock('../../utils/cookies');
vi.mock('../../utils/domain');
// 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('DevQuickLogin', () => {
const mockPost = vi.fn();
const mockGet = vi.fn();
const mockSetCookie = vi.fn();
const mockGetBaseDomain = vi.fn();
const mockBuildSubdomainUrl = vi.fn();
// Store original values
const originalEnv = import.meta.env.PROD;
const originalLocation = window.location;
beforeEach(() => {
vi.clearAllMocks();
// Mock API client
vi.mocked(apiClient).default = {
post: mockPost,
get: mockGet,
} as any;
// Mock cookie utilities
vi.mocked(cookies.setCookie).mockImplementation(mockSetCookie);
// Mock domain utilities
vi.mocked(domain.getBaseDomain).mockReturnValue('lvh.me');
vi.mocked(domain.buildSubdomainUrl).mockImplementation(
(subdomain, path) => `http://${subdomain}.lvh.me:5173${path}`
);
// Mock window.location
delete (window as any).location;
window.location = {
...originalLocation,
hostname: 'platform.lvh.me',
port: '5173',
href: '',
} as any;
// Mock localStorage
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
};
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
writable: true,
});
// Mock alert
window.alert = vi.fn();
// Set development environment
(import.meta.env as any).PROD = false;
});
afterEach(() => {
// Restore environment
(import.meta.env as any).PROD = originalEnv;
window.location = originalLocation;
});
describe('Environment Checks', () => {
it('should not render in production environment', () => {
(import.meta.env as any).PROD = true;
const { container } = render(<DevQuickLogin />, { wrapper: createWrapper() });
expect(container.firstChild).toBeNull();
expect(screen.queryByText(/Quick Login/i)).not.toBeInTheDocument();
});
it('should render in development environment', () => {
(import.meta.env as any).PROD = false;
render(<DevQuickLogin />, { wrapper: createWrapper() });
expect(screen.getByText(/Quick Login \(Dev Only\)/i)).toBeInTheDocument();
});
});
describe('Component Rendering', () => {
it('should render as floating widget by default', () => {
const { container } = render(<DevQuickLogin />, { wrapper: createWrapper() });
const widget = container.firstChild as HTMLElement;
expect(widget).toHaveClass('fixed', 'bottom-4', 'right-4', 'z-50');
});
it('should render as embedded when embedded prop is true', () => {
const { container } = render(<DevQuickLogin embedded />, { wrapper: createWrapper() });
const widget = container.firstChild as HTMLElement;
expect(widget).toHaveClass('w-full', 'bg-gray-50');
expect(widget).not.toHaveClass('fixed');
});
it('should render minimize button when not embedded', () => {
render(<DevQuickLogin />, { wrapper: createWrapper() });
const minimizeButton = screen.getByText('×');
expect(minimizeButton).toBeInTheDocument();
});
it('should not render minimize button when embedded', () => {
render(<DevQuickLogin embedded />, { wrapper: createWrapper() });
const minimizeButton = screen.queryByText('×');
expect(minimizeButton).not.toBeInTheDocument();
});
it('should render all user buttons', () => {
render(<DevQuickLogin />, { wrapper: createWrapper() });
expect(screen.getByText('Platform Superuser')).toBeInTheDocument();
expect(screen.getByText('Platform Manager')).toBeInTheDocument();
expect(screen.getByText('Platform Sales')).toBeInTheDocument();
expect(screen.getByText('Platform Support')).toBeInTheDocument();
expect(screen.getByText('Business Owner')).toBeInTheDocument();
expect(screen.getByText('Staff (Full Access)')).toBeInTheDocument();
expect(screen.getByText('Staff (Limited)')).toBeInTheDocument();
expect(screen.getByText('Customer')).toBeInTheDocument();
});
it('should render password hint', () => {
render(<DevQuickLogin />, { wrapper: createWrapper() });
expect(screen.getByText(/Password for all:/i)).toBeInTheDocument();
expect(screen.getByText('test123')).toBeInTheDocument();
});
it('should render user roles as subtitles', () => {
render(<DevQuickLogin />, { wrapper: createWrapper() });
expect(screen.getByText('SUPERUSER')).toBeInTheDocument();
expect(screen.getByText('PLATFORM_MANAGER')).toBeInTheDocument();
expect(screen.getByText('TENANT_OWNER')).toBeInTheDocument();
expect(screen.getByText('CUSTOMER')).toBeInTheDocument();
});
});
describe('User Filtering', () => {
it('should show all users when filter is "all"', () => {
render(<DevQuickLogin filter="all" />, { wrapper: createWrapper() });
expect(screen.getByText('Platform Superuser')).toBeInTheDocument();
expect(screen.getByText('Business Owner')).toBeInTheDocument();
expect(screen.getByText('Customer')).toBeInTheDocument();
});
it('should show only platform users when filter is "platform"', () => {
render(<DevQuickLogin filter="platform" />, { wrapper: createWrapper() });
expect(screen.getByText('Platform Superuser')).toBeInTheDocument();
expect(screen.getByText('Platform Manager')).toBeInTheDocument();
expect(screen.queryByText('Business Owner')).not.toBeInTheDocument();
expect(screen.queryByText('Customer')).not.toBeInTheDocument();
});
it('should show only business users when filter is "business"', () => {
render(<DevQuickLogin filter="business" />, { wrapper: createWrapper() });
expect(screen.getByText('Business Owner')).toBeInTheDocument();
expect(screen.getByText('Staff (Full Access)')).toBeInTheDocument();
expect(screen.queryByText('Platform Superuser')).not.toBeInTheDocument();
expect(screen.queryByText('Customer')).not.toBeInTheDocument();
});
});
describe('Minimize/Maximize Toggle', () => {
it('should minimize when minimize button is clicked', () => {
render(<DevQuickLogin />, { wrapper: createWrapper() });
const minimizeButton = screen.getByText('×');
fireEvent.click(minimizeButton);
expect(screen.getByText('🔓 Quick Login')).toBeInTheDocument();
expect(screen.queryByText('Platform Superuser')).not.toBeInTheDocument();
});
it('should maximize when minimized widget is clicked', () => {
render(<DevQuickLogin />, { wrapper: createWrapper() });
// Minimize first
const minimizeButton = screen.getByText('×');
fireEvent.click(minimizeButton);
// Then maximize
const maximizeButton = screen.getByText('🔓 Quick Login');
fireEvent.click(maximizeButton);
expect(screen.getByText(/Quick Login \(Dev Only\)/i)).toBeInTheDocument();
expect(screen.getByText('Platform Superuser')).toBeInTheDocument();
});
it('should not show minimize toggle when embedded', () => {
render(<DevQuickLogin embedded />, { wrapper: createWrapper() });
// Should always show full widget
expect(screen.getByText('Platform Superuser')).toBeInTheDocument();
// No minimize button
expect(screen.queryByText('×')).not.toBeInTheDocument();
expect(screen.queryByText('🔓 Quick Login')).not.toBeInTheDocument();
});
});
describe('Quick Login Functionality', () => {
it('should call login API with correct credentials', async () => {
mockPost.mockResolvedValueOnce({
data: { access: 'test-token', refresh: 'refresh-token' },
});
mockGet.mockResolvedValueOnce({
data: {
role: 'superuser',
business_subdomain: null,
},
});
render(<DevQuickLogin />, { wrapper: createWrapper() });
const button = screen.getByText('Platform Superuser').parentElement!;
fireEvent.click(button);
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith('/auth/login/', {
email: 'superuser@platform.com',
password: 'test123',
});
});
});
it('should store token in cookie after successful login', async () => {
mockPost.mockResolvedValueOnce({
data: { access: 'test-token', refresh: 'refresh-token' },
});
mockGet.mockResolvedValueOnce({
data: {
role: 'superuser',
business_subdomain: null,
},
});
render(<DevQuickLogin />, { wrapper: createWrapper() });
const button = screen.getByText('Platform Superuser').parentElement!;
fireEvent.click(button);
await waitFor(() => {
expect(mockSetCookie).toHaveBeenCalledWith('access_token', 'test-token', 7);
});
});
it('should clear masquerade stack after login', async () => {
mockPost.mockResolvedValueOnce({
data: { access: 'test-token', refresh: 'refresh-token' },
});
mockGet.mockResolvedValueOnce({
data: {
role: 'superuser',
business_subdomain: null,
},
});
render(<DevQuickLogin />, { wrapper: createWrapper() });
const button = screen.getByText('Platform Superuser').parentElement!;
fireEvent.click(button);
await waitFor(() => {
expect(localStorage.removeItem).toHaveBeenCalledWith('masquerade_stack');
});
});
it('should fetch user data after login', async () => {
mockPost.mockResolvedValueOnce({
data: { access: 'test-token', refresh: 'refresh-token' },
});
mockGet.mockResolvedValueOnce({
data: {
role: 'superuser',
business_subdomain: null,
},
});
render(<DevQuickLogin />, { wrapper: createWrapper() });
const button = screen.getByText('Platform Superuser').parentElement!;
fireEvent.click(button);
await waitFor(() => {
expect(mockGet).toHaveBeenCalledWith('/auth/me/');
});
});
});
describe('Subdomain Redirects', () => {
it('should redirect platform users to platform subdomain', async () => {
mockPost.mockResolvedValueOnce({
data: { access: 'test-token', refresh: 'refresh-token' },
});
mockGet.mockResolvedValueOnce({
data: {
role: 'superuser',
business_subdomain: null,
},
});
// Mock current location as non-platform subdomain
window.location.hostname = 'demo.lvh.me';
render(<DevQuickLogin />, { wrapper: createWrapper() });
const button = screen.getByText('Platform Superuser').parentElement!;
fireEvent.click(button);
await waitFor(() => {
expect(window.location.href).toBe('http://platform.lvh.me:5173/dashboard');
});
});
it('should redirect business users to their subdomain', async () => {
mockPost.mockResolvedValueOnce({
data: { access: 'test-token', refresh: 'refresh-token' },
});
mockGet.mockResolvedValueOnce({
data: {
role: 'tenant_owner',
business_subdomain: 'demo',
},
});
// Mock current location as platform subdomain
window.location.hostname = 'platform.lvh.me';
render(<DevQuickLogin />, { wrapper: createWrapper() });
const button = screen.getByText('Business Owner').parentElement!;
fireEvent.click(button);
await waitFor(() => {
expect(window.location.href).toBe('http://demo.lvh.me:5173/dashboard');
});
});
it('should navigate to dashboard when already on correct subdomain', async () => {
mockPost.mockResolvedValueOnce({
data: { access: 'test-token', refresh: 'refresh-token' },
});
mockGet.mockResolvedValueOnce({
data: {
role: 'superuser',
business_subdomain: null,
},
});
// Already on platform subdomain
window.location.hostname = 'platform.lvh.me';
render(<DevQuickLogin />, { wrapper: createWrapper() });
const button = screen.getByText('Platform Superuser').parentElement!;
fireEvent.click(button);
await waitFor(() => {
expect(window.location.href).toBe('/dashboard');
});
});
it('should redirect platform_manager to platform subdomain', async () => {
mockPost.mockResolvedValueOnce({
data: { access: 'test-token', refresh: 'refresh-token' },
});
mockGet.mockResolvedValueOnce({
data: {
role: 'platform_manager',
business_subdomain: null,
},
});
window.location.hostname = 'demo.lvh.me';
render(<DevQuickLogin />, { wrapper: createWrapper() });
const button = screen.getByText('Platform Manager').parentElement!;
fireEvent.click(button);
await waitFor(() => {
expect(window.location.href).toBe('http://platform.lvh.me:5173/dashboard');
});
});
it('should redirect platform_support to platform subdomain', async () => {
mockPost.mockResolvedValueOnce({
data: { access: 'test-token', refresh: 'refresh-token' },
});
mockGet.mockResolvedValueOnce({
data: {
role: 'platform_support',
business_subdomain: null,
},
});
window.location.hostname = 'demo.lvh.me';
render(<DevQuickLogin />, { wrapper: createWrapper() });
const button = screen.getByText('Platform Support').parentElement!;
fireEvent.click(button);
await waitFor(() => {
expect(window.location.href).toBe('http://platform.lvh.me:5173/dashboard');
});
});
});
describe('Loading States', () => {
it('should show loading state on clicked button', async () => {
mockPost.mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(() => resolve({ data: { access: 'token' } }), 100);
})
);
const { container } = render(<DevQuickLogin />, { wrapper: createWrapper() });
const buttons = container.querySelectorAll('button');
const button = Array.from(buttons).find((b) =>
b.textContent?.includes('Platform Superuser')
) as HTMLButtonElement;
fireEvent.click(button);
await waitFor(() => {
expect(screen.getByText('Logging in...')).toBeInTheDocument();
expect(button.querySelector('.animate-spin')).toBeInTheDocument();
});
});
it('should disable all buttons during login', async () => {
mockPost.mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(() => resolve({ data: { access: 'token' } }), 100);
})
);
const { container } = render(<DevQuickLogin />, { wrapper: createWrapper() });
const buttons = container.querySelectorAll('button');
const button = Array.from(buttons).find((b) =>
b.textContent?.includes('Platform Superuser')
) as HTMLButtonElement;
fireEvent.click(button);
const allButtons = screen.getAllByRole('button').filter((b) => !b.textContent?.includes('×'));
allButtons.forEach((btn) => {
expect(btn).toBeDisabled();
});
});
it('should show loading spinner with correct styling', async () => {
mockPost.mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(() => resolve({ data: { access: 'token' } }), 100);
})
);
const { container } = render(<DevQuickLogin />, { wrapper: createWrapper() });
const buttons = container.querySelectorAll('button');
const button = Array.from(buttons).find((b) =>
b.textContent?.includes('Platform Superuser')
) as HTMLButtonElement;
fireEvent.click(button);
await waitFor(() => {
const spinner = button.querySelector('.animate-spin');
expect(spinner).toBeInTheDocument();
expect(spinner).toHaveClass('h-4', 'w-4');
});
});
it('should clear loading state after successful login', async () => {
mockPost.mockResolvedValueOnce({
data: { access: 'test-token', refresh: 'refresh-token' },
});
mockGet.mockResolvedValueOnce({
data: {
role: 'superuser',
business_subdomain: null,
},
});
render(<DevQuickLogin />, { wrapper: createWrapper() });
const button = screen.getByText('Platform Superuser').parentElement!;
fireEvent.click(button);
await waitFor(() => {
expect(screen.queryByText('Logging in...')).not.toBeInTheDocument();
});
});
});
describe('Error Handling', () => {
it('should show alert on login API failure', async () => {
const error = new Error('Invalid credentials');
mockPost.mockRejectedValueOnce(error);
render(<DevQuickLogin />, { wrapper: createWrapper() });
const button = screen.getByText('Platform Superuser').parentElement!;
fireEvent.click(button);
await waitFor(() => {
expect(window.alert).toHaveBeenCalledWith(
'Failed to login as Platform Superuser: Invalid credentials'
);
});
});
it('should show alert on user data fetch failure', async () => {
mockPost.mockResolvedValueOnce({
data: { access: 'test-token', refresh: 'refresh-token' },
});
mockGet.mockRejectedValueOnce(new Error('Failed to fetch user'));
render(<DevQuickLogin />, { wrapper: createWrapper() });
const button = screen.getByText('Platform Superuser').parentElement!;
fireEvent.click(button);
await waitFor(() => {
expect(window.alert).toHaveBeenCalledWith(
'Failed to login as Platform Superuser: Failed to fetch user'
);
});
});
it('should log error to console', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const error = new Error('Network error');
mockPost.mockRejectedValueOnce(error);
render(<DevQuickLogin />, { wrapper: createWrapper() });
const button = screen.getByText('Platform Superuser').parentElement!;
fireEvent.click(button);
await waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalledWith('Quick login failed:', error);
});
consoleErrorSpy.mockRestore();
});
it('should clear loading state on error', async () => {
mockPost.mockRejectedValueOnce(new Error('Login failed'));
render(<DevQuickLogin />, { wrapper: createWrapper() });
const button = screen.getByText('Platform Superuser').parentElement!;
fireEvent.click(button);
await waitFor(() => {
expect(screen.queryByText('Logging in...')).not.toBeInTheDocument();
});
});
it('should re-enable buttons after error', async () => {
mockPost.mockRejectedValueOnce(new Error('Login failed'));
render(<DevQuickLogin />, { wrapper: createWrapper() });
const button = screen.getByText('Platform Superuser').parentElement!;
fireEvent.click(button);
await waitFor(() => {
const allButtons = screen
.getAllByRole('button')
.filter((b) => !b.textContent?.includes('×'));
allButtons.forEach((btn) => {
expect(btn).not.toBeDisabled();
});
});
});
it('should handle error with no message', async () => {
mockPost.mockRejectedValueOnce({});
render(<DevQuickLogin />, { wrapper: createWrapper() });
const button = screen.getByText('Platform Superuser').parentElement!;
fireEvent.click(button);
await waitFor(() => {
expect(window.alert).toHaveBeenCalledWith(
'Failed to login as Platform Superuser: Unknown error'
);
});
});
});
describe('Button Styling', () => {
it('should apply correct color classes to platform superuser', () => {
const { container } = render(<DevQuickLogin />, { wrapper: createWrapper() });
// Find the button that contains "Platform Superuser"
const buttons = container.querySelectorAll('button');
const button = Array.from(buttons).find((b) =>
b.textContent?.includes('Platform Superuser')
);
expect(button).toHaveClass('bg-purple-600', 'hover:bg-purple-700');
});
it('should apply correct color classes to platform manager', () => {
const { container } = render(<DevQuickLogin />, { wrapper: createWrapper() });
const buttons = container.querySelectorAll('button');
const button = Array.from(buttons).find((b) => b.textContent?.includes('Platform Manager'));
expect(button).toHaveClass('bg-blue-600', 'hover:bg-blue-700');
});
it('should apply correct color classes to business owner', () => {
const { container } = render(<DevQuickLogin />, { wrapper: createWrapper() });
const buttons = container.querySelectorAll('button');
const button = Array.from(buttons).find((b) => b.textContent?.includes('Business Owner'));
expect(button).toHaveClass('bg-indigo-600', 'hover:bg-indigo-700');
});
it('should apply correct color classes to customer', () => {
const { container } = render(<DevQuickLogin />, { wrapper: createWrapper() });
const buttons = container.querySelectorAll('button');
const button = Array.from(buttons).find((b) => b.textContent?.includes('Customer'));
expect(button).toHaveClass('bg-orange-600', 'hover:bg-orange-700');
});
it('should have consistent button styling', () => {
const { container } = render(<DevQuickLogin />, { wrapper: createWrapper() });
const buttons = container.querySelectorAll('button');
const button = Array.from(buttons).find((b) =>
b.textContent?.includes('Platform Superuser')
);
expect(button).toHaveClass(
'text-white',
'px-3',
'py-2',
'rounded',
'text-sm',
'font-medium',
'transition-colors'
);
});
it('should apply disabled styling when loading', async () => {
mockPost.mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(() => resolve({ data: { access: 'token' } }), 100);
})
);
const { container } = render(<DevQuickLogin />, { wrapper: createWrapper() });
const buttons = container.querySelectorAll('button');
const button = Array.from(buttons).find((b) =>
b.textContent?.includes('Platform Superuser')
) as HTMLButtonElement;
fireEvent.click(button);
expect(button).toHaveClass('disabled:opacity-50', 'disabled:cursor-not-allowed');
});
});
describe('Accessibility', () => {
it('should render all user buttons with button role', () => {
render(<DevQuickLogin />, { wrapper: createWrapper() });
const buttons = screen.getAllByRole('button').filter((b) => !b.textContent?.includes('×'));
expect(buttons.length).toBeGreaterThan(0);
});
it('should have descriptive button text', () => {
render(<DevQuickLogin />, { wrapper: createWrapper() });
expect(screen.getByText('Platform Superuser')).toBeInTheDocument();
expect(screen.getByText('SUPERUSER')).toBeInTheDocument();
});
it('should indicate loading state visually', async () => {
mockPost.mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(() => resolve({ data: { access: 'token' } }), 100);
})
);
render(<DevQuickLogin />, { wrapper: createWrapper() });
const button = screen.getByText('Platform Superuser').parentElement!;
fireEvent.click(button);
expect(screen.getByText('Logging in...')).toBeInTheDocument();
});
});
describe('Multiple User Logins', () => {
it('should handle logging in as different users sequentially', async () => {
mockPost
.mockResolvedValueOnce({
data: { access: 'token1', refresh: 'refresh1' },
})
.mockResolvedValueOnce({
data: { access: 'token2', refresh: 'refresh2' },
});
mockGet
.mockResolvedValueOnce({
data: { role: 'superuser', business_subdomain: null },
})
.mockResolvedValueOnce({
data: { role: 'tenant_owner', business_subdomain: 'demo' },
});
render(<DevQuickLogin />, { wrapper: createWrapper() });
// Login as superuser
const superuserButton = screen.getByText('Platform Superuser').parentElement!;
fireEvent.click(superuserButton);
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith('/auth/login/', {
email: 'superuser@platform.com',
password: 'test123',
});
});
// Login as owner
const ownerButton = screen.getByText('Business Owner').parentElement!;
fireEvent.click(ownerButton);
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith('/auth/login/', {
email: 'owner@demo.com',
password: 'test123',
});
});
});
});
});

View File

@@ -0,0 +1,367 @@
/**
* Unit tests for EmailTemplateSelector component
*
* Tests the deprecated EmailTemplateSelector component that now displays
* a deprecation notice instead of an actual selector.
*
* Covers:
* - Component rendering
* - Deprecation notice display
* - Props handling (className, disabled, etc.)
* - Translation strings
* - Disabled state of the selector
*/
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import EmailTemplateSelector from '../EmailTemplateSelector';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string) => fallback || key,
}),
}));
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
AlertTriangle: () => <div data-testid="alert-triangle-icon">âš </div>,
Mail: () => <div data-testid="mail-icon">✉</div>,
}));
describe('EmailTemplateSelector', () => {
const defaultProps = {
value: undefined,
onChange: vi.fn(),
};
describe('Component Rendering', () => {
it('renders the component successfully', () => {
const { container } = render(<EmailTemplateSelector {...defaultProps} />);
expect(container).toBeInTheDocument();
});
it('renders deprecation notice with warning icon', () => {
render(<EmailTemplateSelector {...defaultProps} />);
const alertIcon = screen.getByTestId('alert-triangle-icon');
expect(alertIcon).toBeInTheDocument();
});
it('renders mail icon in the disabled selector', () => {
render(<EmailTemplateSelector {...defaultProps} />);
const mailIcon = screen.getByTestId('mail-icon');
expect(mailIcon).toBeInTheDocument();
});
it('renders deprecation title', () => {
render(<EmailTemplateSelector {...defaultProps} />);
expect(screen.getByText('Custom Email Templates Deprecated')).toBeInTheDocument();
});
it('renders deprecation message', () => {
render(<EmailTemplateSelector {...defaultProps} />);
expect(
screen.getByText(/Custom email templates have been replaced with system email templates/)
).toBeInTheDocument();
});
it('renders disabled select element', () => {
render(<EmailTemplateSelector {...defaultProps} />);
const select = screen.getByRole('combobox');
expect(select).toBeInTheDocument();
expect(select).toBeDisabled();
});
it('renders disabled option text', () => {
render(<EmailTemplateSelector {...defaultProps} />);
expect(screen.getByText('Custom templates no longer available')).toBeInTheDocument();
});
});
describe('Props Handling', () => {
it('accepts value prop without errors', () => {
render(<EmailTemplateSelector {...defaultProps} value={123} />);
// Component should render without errors
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
it('accepts string value prop', () => {
render(<EmailTemplateSelector {...defaultProps} value="template-123" />);
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
it('accepts undefined value prop', () => {
render(<EmailTemplateSelector {...defaultProps} value={undefined} />);
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
it('accepts category prop without errors', () => {
render(<EmailTemplateSelector {...defaultProps} category="appointment" />);
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
it('accepts placeholder prop without errors', () => {
render(<EmailTemplateSelector {...defaultProps} placeholder="Select template" />);
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
it('accepts required prop without errors', () => {
render(<EmailTemplateSelector {...defaultProps} required={true} />);
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
it('accepts disabled prop without errors', () => {
render(<EmailTemplateSelector {...defaultProps} disabled={true} />);
// Selector is always disabled due to deprecation
expect(screen.getByRole('combobox')).toBeDisabled();
});
it('applies custom className', () => {
const { container } = render(
<EmailTemplateSelector {...defaultProps} className="custom-test-class" />
);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper).toHaveClass('custom-test-class');
});
it('applies multiple classes correctly', () => {
const { container } = render(
<EmailTemplateSelector {...defaultProps} className="class-one class-two" />
);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper).toHaveClass('class-one');
expect(wrapper).toHaveClass('class-two');
});
});
describe('Deprecation Notice Styling', () => {
it('applies warning background color', () => {
const { container } = render(<EmailTemplateSelector {...defaultProps} />);
const warningBox = container.querySelector('.bg-amber-50');
expect(warningBox).toBeInTheDocument();
});
it('applies warning border color', () => {
const { container } = render(<EmailTemplateSelector {...defaultProps} />);
const warningBox = container.querySelector('.border-amber-200');
expect(warningBox).toBeInTheDocument();
});
it('applies dark mode warning background', () => {
const { container } = render(<EmailTemplateSelector {...defaultProps} />);
const warningBox = container.querySelector('.dark\\:bg-amber-900\\/20');
expect(warningBox).toBeInTheDocument();
});
it('applies dark mode warning border', () => {
const { container } = render(<EmailTemplateSelector {...defaultProps} />);
const warningBox = container.querySelector('.dark\\:border-amber-800');
expect(warningBox).toBeInTheDocument();
});
});
describe('Disabled Selector Styling', () => {
it('applies opacity to disabled selector', () => {
const { container } = render(<EmailTemplateSelector {...defaultProps} />);
const selectorWrapper = container.querySelector('.opacity-50');
expect(selectorWrapper).toBeInTheDocument();
});
it('applies pointer-events-none to disabled selector', () => {
const { container } = render(<EmailTemplateSelector {...defaultProps} />);
const selectorWrapper = container.querySelector('.pointer-events-none');
expect(selectorWrapper).toBeInTheDocument();
});
it('applies disabled cursor style', () => {
render(<EmailTemplateSelector {...defaultProps} />);
const select = screen.getByRole('combobox');
expect(select).toHaveClass('cursor-not-allowed');
});
it('applies gray background to disabled select', () => {
render(<EmailTemplateSelector {...defaultProps} />);
const select = screen.getByRole('combobox');
expect(select).toHaveClass('bg-gray-100');
});
it('applies gray text color to disabled select', () => {
render(<EmailTemplateSelector {...defaultProps} />);
const select = screen.getByRole('combobox');
expect(select).toHaveClass('text-gray-500');
});
});
describe('Translation Strings', () => {
it('uses correct translation key for deprecation title', () => {
render(<EmailTemplateSelector {...defaultProps} />);
// Since we're mocking useTranslation to return fallback text,
// we can verify the component renders the expected fallback
expect(screen.getByText('Custom Email Templates Deprecated')).toBeInTheDocument();
});
it('uses correct translation key for deprecation message', () => {
render(<EmailTemplateSelector {...defaultProps} />);
// Verify the component renders the expected fallback message
expect(
screen.getByText(/Custom email templates have been replaced/)
).toBeInTheDocument();
});
it('uses correct translation key for unavailable message', () => {
render(<EmailTemplateSelector {...defaultProps} />);
// Verify the component renders the expected fallback
expect(screen.getByText('Custom templates no longer available')).toBeInTheDocument();
});
});
describe('onChange Handler', () => {
it('does not call onChange when component is rendered', () => {
const onChange = vi.fn();
render(<EmailTemplateSelector {...defaultProps} onChange={onChange} />);
expect(onChange).not.toHaveBeenCalled();
});
it('does not call onChange when component is re-rendered', () => {
const onChange = vi.fn();
const { rerender } = render(
<EmailTemplateSelector {...defaultProps} onChange={onChange} />
);
rerender(<EmailTemplateSelector {...defaultProps} onChange={onChange} />);
expect(onChange).not.toHaveBeenCalled();
});
});
describe('Component Structure', () => {
it('renders main wrapper with space-y-2 class', () => {
const { container } = render(<EmailTemplateSelector {...defaultProps} />);
const wrapper = container.querySelector('.space-y-2');
expect(wrapper).toBeInTheDocument();
});
it('renders warning box with flex layout', () => {
const { container } = render(<EmailTemplateSelector {...defaultProps} />);
const warningBox = container.querySelector('.flex.items-start');
expect(warningBox).toBeInTheDocument();
});
it('renders warning box with gap between icon and text', () => {
const { container } = render(<EmailTemplateSelector {...defaultProps} />);
const warningBox = container.querySelector('.gap-3');
expect(warningBox).toBeInTheDocument();
});
it('renders warning icon', () => {
render(<EmailTemplateSelector {...defaultProps} />);
const alertIcon = screen.getByTestId('alert-triangle-icon');
expect(alertIcon).toBeInTheDocument();
});
it('renders mail icon', () => {
render(<EmailTemplateSelector {...defaultProps} />);
const mailIcon = screen.getByTestId('mail-icon');
expect(mailIcon).toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('renders select with combobox role', () => {
render(<EmailTemplateSelector {...defaultProps} />);
const select = screen.getByRole('combobox');
expect(select).toBeInTheDocument();
});
it('indicates disabled state for screen readers', () => {
render(<EmailTemplateSelector {...defaultProps} />);
const select = screen.getByRole('combobox');
expect(select).toHaveAttribute('disabled');
});
it('renders visible deprecation notice for screen readers', () => {
render(<EmailTemplateSelector {...defaultProps} />);
// The deprecation title should be accessible
const title = screen.getByText('Custom Email Templates Deprecated');
expect(title).toBeVisible();
});
it('renders visible deprecation message for screen readers', () => {
render(<EmailTemplateSelector {...defaultProps} />);
const message = screen.getByText(
/Custom email templates have been replaced with system email templates/
);
expect(message).toBeVisible();
});
});
describe('Edge Cases', () => {
it('handles empty className gracefully', () => {
const { container } = render(<EmailTemplateSelector {...defaultProps} className="" />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper).toHaveClass('space-y-2');
});
it('handles null onChange gracefully', () => {
// Component should not crash even with null onChange
expect(() => {
render(<EmailTemplateSelector {...defaultProps} onChange={null as any} />);
}).not.toThrow();
});
it('handles all props together', () => {
render(
<EmailTemplateSelector
value={123}
onChange={vi.fn()}
category="appointment"
placeholder="Select template"
required={true}
disabled={true}
className="custom-class"
/>
);
expect(screen.getByRole('combobox')).toBeInTheDocument();
expect(screen.getByText('Custom Email Templates Deprecated')).toBeInTheDocument();
});
});
});

View File

@@ -1,13 +1,16 @@
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import LanguageSelector from '../LanguageSelector';
// Create mock function for changeLanguage
const mockChangeLanguage = vi.fn();
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
i18n: {
language: 'en',
changeLanguage: vi.fn(),
changeLanguage: mockChangeLanguage,
},
}),
}));
@@ -22,6 +25,10 @@ vi.mock('../../i18n', () => ({
}));
describe('LanguageSelector', () => {
beforeEach(() => {
mockChangeLanguage.mockClear();
});
describe('dropdown variant', () => {
it('renders dropdown button', () => {
render(<LanguageSelector />);
@@ -63,6 +70,71 @@ describe('LanguageSelector', () => {
const { container } = render(<LanguageSelector className="custom-class" />);
expect(container.firstChild).toHaveClass('custom-class');
});
it('changes language when clicking a language option in dropdown', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
fireEvent.click(button);
const spanishOption = screen.getByText('Español').closest('button');
expect(spanishOption).toBeInTheDocument();
fireEvent.click(spanishOption!);
expect(mockChangeLanguage).toHaveBeenCalledWith('es');
});
it('closes dropdown when language is selected', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(screen.getByRole('listbox')).toBeInTheDocument();
const frenchOption = screen.getByText('Français').closest('button');
fireEvent.click(frenchOption!);
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
});
it('closes dropdown when clicking outside', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(screen.getByRole('listbox')).toBeInTheDocument();
// Click outside the dropdown
fireEvent.mouseDown(document.body);
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
});
it('does not close dropdown when clicking inside dropdown', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(screen.getByRole('listbox')).toBeInTheDocument();
const listbox = screen.getByRole('listbox');
fireEvent.mouseDown(listbox);
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
it('toggles dropdown open/closed on button clicks', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
// Open dropdown
fireEvent.click(button);
expect(screen.getByRole('listbox')).toBeInTheDocument();
// Close dropdown
fireEvent.click(button);
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
});
});
describe('inline variant', () => {
@@ -89,5 +161,51 @@ describe('LanguageSelector', () => {
render(<LanguageSelector variant="inline" />);
expect(screen.getByText(/🇺🇸/)).toBeInTheDocument();
});
it('changes language when clicking a language button', () => {
render(<LanguageSelector variant="inline" />);
const spanishButton = screen.getByText(/Español/).closest('button');
expect(spanishButton).toBeInTheDocument();
fireEvent.click(spanishButton!);
expect(mockChangeLanguage).toHaveBeenCalledWith('es');
});
it('calls changeLanguage with correct code for each language', () => {
render(<LanguageSelector variant="inline" />);
// Test English
const englishButton = screen.getByText(/English/).closest('button');
fireEvent.click(englishButton!);
expect(mockChangeLanguage).toHaveBeenCalledWith('en');
mockChangeLanguage.mockClear();
// Test French
const frenchButton = screen.getByText(/Français/).closest('button');
fireEvent.click(frenchButton!);
expect(mockChangeLanguage).toHaveBeenCalledWith('fr');
});
it('hides flags when showFlag is false', () => {
render(<LanguageSelector variant="inline" showFlag={false} />);
// Flags should not be visible
expect(screen.queryByText('🇺🇸')).not.toBeInTheDocument();
expect(screen.queryByText('🇪🇸')).not.toBeInTheDocument();
expect(screen.queryByText('🇫🇷')).not.toBeInTheDocument();
// But names should still be there
expect(screen.getByText('English')).toBeInTheDocument();
expect(screen.getByText('Español')).toBeInTheDocument();
expect(screen.getByText('Français')).toBeInTheDocument();
});
it('applies custom className', () => {
const { container } = render(<LanguageSelector variant="inline" className="custom-inline-class" />);
expect(container.firstChild).toHaveClass('custom-inline-class');
});
});
});

View File

@@ -0,0 +1,805 @@
/**
* Unit tests for QuickAddAppointment component
*
* Tests cover:
* - Component rendering
* - Form fields and validation
* - User interactions (filling forms, submitting)
* - API integration (mock mutations)
* - Success/error states
* - Form reset after successful submission
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import QuickAddAppointment from '../QuickAddAppointment';
// Mock dependencies
const mockServices = vi.fn();
const mockResources = vi.fn();
const mockCustomers = vi.fn();
const mockCreateAppointment = vi.fn();
vi.mock('../../hooks/useServices', () => ({
useServices: () => mockServices(),
}));
vi.mock('../../hooks/useResources', () => ({
useResources: () => mockResources(),
}));
vi.mock('../../hooks/useCustomers', () => ({
useCustomers: () => mockCustomers(),
}));
vi.mock('../../hooks/useAppointments', () => ({
useCreateAppointment: () => mockCreateAppointment(),
}));
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, defaultValue?: string) => defaultValue || key,
}),
}));
// Mock date-fns format
vi.mock('date-fns', () => ({
format: (date: Date, formatStr: string) => {
if (formatStr === 'yyyy-MM-dd') {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
return date.toISOString();
},
}));
describe('QuickAddAppointment', () => {
const mockMutateAsync = vi.fn();
// Helper functions to get form elements by label text (since labels don't have htmlFor)
const getSelectByLabel = (labelText: string) => {
const label = screen.getByText(labelText);
return label.parentElement?.querySelector('select') as HTMLSelectElement;
};
const getInputByLabel = (labelText: string, type: string = 'text') => {
const label = screen.getByText(labelText);
return label.parentElement?.querySelector(`input[type="${type}"]`) as HTMLInputElement;
};
const mockServiceData = [
{ id: '1', name: 'Haircut', durationMinutes: 30, price: '25.00' },
{ id: '2', name: 'Massage', durationMinutes: 60, price: '80.00' },
{ id: '3', name: 'Consultation', durationMinutes: 15, price: '0.00' },
];
const mockResourceData = [
{ id: '1', name: 'Room 1' },
{ id: '2', name: 'Chair A' },
{ id: '3', name: 'Therapist Jane' },
];
const mockCustomerData = [
{ id: '1', name: 'John Doe', email: 'john@example.com', status: 'Active' },
{ id: '2', name: 'Jane Smith', email: 'jane@example.com', status: 'Active' },
{ id: '3', name: 'Inactive User', email: 'inactive@example.com', status: 'Inactive' },
];
beforeEach(() => {
vi.clearAllMocks();
mockServices.mockReturnValue({
data: mockServiceData,
isLoading: false,
});
mockResources.mockReturnValue({
data: mockResourceData,
isLoading: false,
});
mockCustomers.mockReturnValue({
data: mockCustomerData,
isLoading: false,
});
mockCreateAppointment.mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: false,
});
mockMutateAsync.mockResolvedValue({
id: '123',
service: 1,
start_time: new Date().toISOString(),
});
});
describe('Rendering', () => {
it('should render the component', () => {
render(<QuickAddAppointment />);
expect(screen.getByText('Quick Add Appointment')).toBeInTheDocument();
});
it('should render all form fields', () => {
render(<QuickAddAppointment />);
expect(screen.getByText('Customer')).toBeInTheDocument();
expect(screen.getByText('Service *')).toBeInTheDocument();
expect(screen.getByText('Resource')).toBeInTheDocument();
expect(screen.getByText('Date *')).toBeInTheDocument();
expect(screen.getByText('Time *')).toBeInTheDocument();
expect(screen.getByText('Notes')).toBeInTheDocument();
});
it('should render customer dropdown with active customers only', () => {
render(<QuickAddAppointment />);
const customerSelect = getSelectByLabel('Customer');
const options = Array.from(customerSelect.options);
// Should have walk-in option + 2 active customers (not inactive)
expect(options).toHaveLength(3);
expect(options[0].textContent).toContain('Walk-in');
expect(options[1].textContent).toContain('John Doe');
expect(options[2].textContent).toContain('Jane Smith');
expect(options.find(opt => opt.textContent?.includes('Inactive User'))).toBeUndefined();
});
it('should render service dropdown with all services', () => {
render(<QuickAddAppointment />);
const serviceSelect = getSelectByLabel('Service *');
const options = Array.from(serviceSelect.options);
expect(options).toHaveLength(4); // placeholder + 3 services
expect(options[0].textContent).toContain('Select service');
expect(options[1].textContent).toContain('Haircut');
expect(options[2].textContent).toContain('Massage');
expect(options[3].textContent).toContain('Consultation');
});
it('should render resource dropdown with unassigned option', () => {
render(<QuickAddAppointment />);
const resourceSelect = getSelectByLabel('Resource');
const options = Array.from(resourceSelect.options);
expect(options).toHaveLength(4); // unassigned + 3 resources
expect(options[0].textContent).toContain('Unassigned');
expect(options[1].textContent).toContain('Room 1');
expect(options[2].textContent).toContain('Chair A');
expect(options[3].textContent).toContain('Therapist Jane');
});
it('should render time slots from 6am to 10pm in 15-minute intervals', () => {
render(<QuickAddAppointment />);
const timeSelect = getSelectByLabel('Time *');
const options = Array.from(timeSelect.options);
// 6am to 10pm = 17 hours * 4 slots per hour = 68 slots
expect(options).toHaveLength(68);
expect(options[0].value).toBe('06:00');
expect(options[options.length - 1].value).toBe('22:45');
});
it('should render submit button', () => {
render(<QuickAddAppointment />);
const submitButton = screen.getByRole('button', { name: /Add Appointment/i });
expect(submitButton).toBeInTheDocument();
});
it('should set default date to today', () => {
render(<QuickAddAppointment />);
const dateInput = getInputByLabel('Date *', 'date');
const today = new Date();
const expectedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
expect(dateInput.value).toBe(expectedDate);
});
it('should set default time to 09:00', () => {
render(<QuickAddAppointment />);
const timeSelect = getSelectByLabel('Time *');
expect(timeSelect.value).toBe('09:00');
});
it('should render notes textarea', () => {
render(<QuickAddAppointment />);
const notesTextarea = screen.getByPlaceholderText('Optional notes...');
expect(notesTextarea).toBeInTheDocument();
expect(notesTextarea.tagName).toBe('TEXTAREA');
});
});
describe('Form Validation', () => {
it('should disable submit button when service is not selected', () => {
render(<QuickAddAppointment />);
const submitButton = screen.getByRole('button', { name: /Add Appointment/i });
expect(submitButton).toBeDisabled();
});
it('should enable submit button when service is selected', async () => {
const user = userEvent.setup();
render(<QuickAddAppointment />);
const serviceSelect = getSelectByLabel('Service *');
await user.selectOptions(serviceSelect, '1');
const submitButton = screen.getByRole('button', { name: /Add Appointment/i });
expect(submitButton).not.toBeDisabled();
});
it('should mark service field as required', () => {
render(<QuickAddAppointment />);
const serviceSelect = getSelectByLabel('Service *');
expect(serviceSelect).toHaveAttribute('required');
});
it('should mark date field as required', () => {
render(<QuickAddAppointment />);
const dateInput = getInputByLabel('Date *', 'date');
expect(dateInput).toHaveAttribute('required');
});
it('should mark time field as required', () => {
render(<QuickAddAppointment />);
const timeSelect = getSelectByLabel('Time *');
expect(timeSelect).toHaveAttribute('required');
});
it('should set minimum date to today', () => {
render(<QuickAddAppointment />);
const dateInput = getInputByLabel('Date *', 'date');
const today = new Date();
const expectedMin = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
expect(dateInput.min).toBe(expectedMin);
});
});
describe('User Interactions', () => {
it('should allow selecting a customer', async () => {
const user = userEvent.setup();
render(<QuickAddAppointment />);
const customerSelect = getSelectByLabel('Customer');
await user.selectOptions(customerSelect, '1');
expect(customerSelect.value).toBe('1');
});
it('should allow selecting a service', async () => {
const user = userEvent.setup();
render(<QuickAddAppointment />);
const serviceSelect = getSelectByLabel('Service *');
await user.selectOptions(serviceSelect, '2');
expect(serviceSelect.value).toBe('2');
});
it('should allow selecting a resource', async () => {
const user = userEvent.setup();
render(<QuickAddAppointment />);
const resourceSelect = getSelectByLabel('Resource');
await user.selectOptions(resourceSelect, '3');
expect(resourceSelect.value).toBe('3');
});
it('should allow changing the date', async () => {
const user = userEvent.setup();
render(<QuickAddAppointment />);
const dateInput = getInputByLabel('Date *', 'date');
await user.clear(dateInput);
await user.type(dateInput, '2025-12-31');
expect(dateInput.value).toBe('2025-12-31');
});
it('should allow changing the time', async () => {
const user = userEvent.setup();
render(<QuickAddAppointment />);
const timeSelect = getSelectByLabel('Time *');
await user.selectOptions(timeSelect, '14:30');
expect(timeSelect.value).toBe('14:30');
});
it('should allow entering notes', async () => {
const user = userEvent.setup();
render(<QuickAddAppointment />);
const notesTextarea = screen.getByPlaceholderText('Optional notes...');
await user.type(notesTextarea, 'Customer requested early morning slot');
expect(notesTextarea).toHaveValue('Customer requested early morning slot');
});
it('should display selected service duration', async () => {
const user = userEvent.setup();
render(<QuickAddAppointment />);
const serviceSelect = getSelectByLabel('Service *');
await user.selectOptions(serviceSelect, '2'); // Massage - 60 minutes
// Duration text is split across elements, so use regex matching
expect(screen.getByText(/Duration:/i)).toBeInTheDocument();
expect(screen.getByText(/60.*minutes/i)).toBeInTheDocument();
});
it('should not display duration when no service selected', () => {
render(<QuickAddAppointment />);
expect(screen.queryByText('Duration')).not.toBeInTheDocument();
});
});
describe('Form Submission', () => {
it('should call createAppointment with correct data when form is submitted', async () => {
const user = userEvent.setup();
render(<QuickAddAppointment />);
// Fill out the form
await user.selectOptions(getSelectByLabel('Customer'), '1');
await user.selectOptions(getSelectByLabel('Service *'), '1');
await user.selectOptions(getSelectByLabel('Resource'), '2');
await user.selectOptions(getSelectByLabel('Time *'), '10:00');
const notesTextarea = screen.getByPlaceholderText('Optional notes...');
await user.type(notesTextarea, 'Test appointment');
// Submit
const submitButton = screen.getByRole('button', { name: /Add Appointment/i });
await user.click(submitButton);
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled();
});
const callArgs = mockMutateAsync.mock.calls[0][0];
expect(callArgs).toMatchObject({
customerId: '1',
customerName: 'John Doe',
serviceId: '1',
resourceId: '2',
durationMinutes: 30,
status: 'Scheduled',
notes: 'Test appointment',
});
});
it('should send walk-in appointment when no customer selected', async () => {
const user = userEvent.setup();
render(<QuickAddAppointment />);
await user.selectOptions(getSelectByLabel('Service *'), '1');
const submitButton = screen.getByRole('button', { name: /Add Appointment/i });
await user.click(submitButton);
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
customerId: undefined,
customerName: 'Walk-in',
})
);
});
});
it('should send null resourceId when unassigned', async () => {
const user = userEvent.setup();
render(<QuickAddAppointment />);
await user.selectOptions(getSelectByLabel('Service *'), '1');
// Keep resource as unassigned (default empty value)
const submitButton = screen.getByRole('button', { name: /Add Appointment/i });
await user.click(submitButton);
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
resourceId: null,
})
);
});
});
it('should use service duration when creating appointment', async () => {
const user = userEvent.setup();
render(<QuickAddAppointment />);
await user.selectOptions(getSelectByLabel('Service *'), '2'); // Massage - 60 min
const submitButton = screen.getByRole('button', { name: /Add Appointment/i });
await user.click(submitButton);
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
durationMinutes: 60,
})
);
});
});
it('should use default 60 minutes when service has no duration', async () => {
const user = userEvent.setup();
mockServices.mockReturnValue({
data: [{ id: '1', name: 'Test Service', price: '10.00' }], // No durationMinutes
isLoading: false,
});
render(<QuickAddAppointment />);
await user.selectOptions(getSelectByLabel('Service *'), '1');
const submitButton = screen.getByRole('button', { name: /Add Appointment/i });
await user.click(submitButton);
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
durationMinutes: 60,
})
);
});
});
it('should calculate start time correctly from date and time', async () => {
const user = userEvent.setup();
render(<QuickAddAppointment />);
await user.selectOptions(getSelectByLabel('Service *'), '1');
await user.selectOptions(getSelectByLabel('Time *'), '14:30');
const submitButton = screen.getByRole('button', { name: /Add Appointment/i });
await user.click(submitButton);
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled();
});
const callArgs = mockMutateAsync.mock.calls[0][0];
const startTime = callArgs.startTime;
// Verify time is set correctly (uses today's date by default)
expect(startTime.getHours()).toBe(14);
expect(startTime.getMinutes()).toBe(30);
expect(startTime instanceof Date).toBe(true);
});
it('should prevent submission if required fields are missing', async () => {
const user = userEvent.setup();
render(<QuickAddAppointment />);
// Don't select service
const submitButton = screen.getByRole('button', { name: /Add Appointment/i });
await user.click(submitButton);
// Should not call mutation
expect(mockMutateAsync).not.toHaveBeenCalled();
});
});
describe('Success State', () => {
it('should show success state after successful submission', async () => {
const user = userEvent.setup();
render(<QuickAddAppointment />);
await user.selectOptions(getSelectByLabel('Service *'), '1');
const submitButton = screen.getByRole('button', { name: /Add Appointment/i });
await user.click(submitButton);
await waitFor(() => {
expect(screen.getByText('Created!')).toBeInTheDocument();
});
});
it('should reset form after successful submission', async () => {
const user = userEvent.setup();
render(<QuickAddAppointment />);
// Fill out form
await user.selectOptions(getSelectByLabel('Customer'), '1');
await user.selectOptions(getSelectByLabel('Service *'), '2');
await user.selectOptions(getSelectByLabel('Resource'), '3');
const notesTextarea = screen.getByPlaceholderText('Optional notes...');
await user.type(notesTextarea, 'Test notes');
const submitButton = screen.getByRole('button', { name: /Add Appointment/i });
await user.click(submitButton);
await waitFor(() => {
expect(getSelectByLabel('Customer').value).toBe('');
expect(getSelectByLabel('Service *').value).toBe('');
expect(getSelectByLabel('Resource').value).toBe('');
expect(getSelectByLabel('Time *').value).toBe('09:00');
expect(notesTextarea).toHaveValue('');
});
});
it('should call onSuccess callback when provided', async () => {
const user = userEvent.setup();
const onSuccess = vi.fn();
render(<QuickAddAppointment onSuccess={onSuccess} />);
await user.selectOptions(getSelectByLabel('Service *'), '1');
const submitButton = screen.getByRole('button', { name: /Add Appointment/i });
await user.click(submitButton);
await waitFor(() => {
expect(onSuccess).toHaveBeenCalled();
});
});
it('should hide success state after 2 seconds', async () => {
vi.useFakeTimers();
try {
const user = userEvent.setup({ delay: null });
render(<QuickAddAppointment />);
await user.selectOptions(getSelectByLabel('Service *'), '1');
const submitButton = screen.getByRole('button', { name: /Add Appointment/i });
await user.click(submitButton);
// Wait for Created! to appear
await waitFor(() => {
expect(screen.getByText('Created!')).toBeInTheDocument();
});
// Fast-forward time by 2 seconds
await vi.advanceTimersByTimeAsync(2000);
// Success message should be hidden
expect(screen.queryByText('Created!')).not.toBeInTheDocument();
} finally {
vi.useRealTimers();
}
});
});
describe('Loading State', () => {
it('should disable submit button when mutation is pending', () => {
mockCreateAppointment.mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: true,
});
render(<QuickAddAppointment />);
const submitButton = screen.getByRole('button', { name: /Creating.../i });
expect(submitButton).toBeDisabled();
});
it('should show loading spinner when mutation is pending', () => {
mockCreateAppointment.mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: true,
});
render(<QuickAddAppointment />);
expect(screen.getByText('Creating...')).toBeInTheDocument();
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
});
});
describe('Error Handling', () => {
it('should handle API errors gracefully', async () => {
const user = userEvent.setup();
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
mockMutateAsync.mockRejectedValueOnce(new Error('Network error'));
render(<QuickAddAppointment />);
await user.selectOptions(getSelectByLabel('Service *'), '1');
const submitButton = screen.getByRole('button', { name: /Add Appointment/i });
await user.click(submitButton);
await waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to create appointment:',
expect.any(Error)
);
});
consoleErrorSpy.mockRestore();
});
it('should not reset form on error', async () => {
const user = userEvent.setup();
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
mockMutateAsync.mockRejectedValueOnce(new Error('Network error'));
render(<QuickAddAppointment />);
await user.selectOptions(getSelectByLabel('Service *'), '1');
await user.selectOptions(getSelectByLabel('Customer'), '1');
const submitButton = screen.getByRole('button', { name: /Add Appointment/i });
await user.click(submitButton);
// Wait for error to be handled
await waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalled();
});
// Form should retain values
expect(getSelectByLabel('Service *').value).toBe('1');
expect(getSelectByLabel('Customer').value).toBe('1');
consoleErrorSpy.mockRestore();
});
});
describe('Empty States', () => {
it('should handle no services available', () => {
mockServices.mockReturnValue({
data: [],
isLoading: false,
});
render(<QuickAddAppointment />);
const serviceSelect = getSelectByLabel('Service *');
const options = Array.from(serviceSelect.options);
expect(options).toHaveLength(1); // Only placeholder
expect(options[0].textContent).toContain('Select service');
});
it('should handle no resources available', () => {
mockResources.mockReturnValue({
data: [],
isLoading: false,
});
render(<QuickAddAppointment />);
const resourceSelect = getSelectByLabel('Resource');
const options = Array.from(resourceSelect.options);
expect(options).toHaveLength(1); // Only unassigned option
expect(options[0].textContent).toContain('Unassigned');
});
it('should handle no customers available', () => {
mockCustomers.mockReturnValue({
data: [],
isLoading: false,
});
render(<QuickAddAppointment />);
const customerSelect = getSelectByLabel('Customer');
const options = Array.from(customerSelect.options);
expect(options).toHaveLength(1); // Only walk-in option
expect(options[0].textContent).toContain('Walk-in');
});
it('should handle undefined data gracefully', () => {
mockServices.mockReturnValue({
data: undefined,
isLoading: false,
});
mockResources.mockReturnValue({
data: undefined,
isLoading: false,
});
mockCustomers.mockReturnValue({
data: undefined,
isLoading: false,
});
render(<QuickAddAppointment />);
expect(screen.getByText('Quick Add Appointment')).toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('should have proper form structure', () => {
const { container } = render(<QuickAddAppointment />);
const form = container.querySelector('form');
expect(form).toBeInTheDocument();
});
it('should have accessible submit button', () => {
render(<QuickAddAppointment />);
const submitButton = screen.getByRole('button', { name: /Add Appointment/i });
expect(submitButton).toHaveAttribute('type', 'submit');
});
});
describe('Integration', () => {
it('should render complete workflow', async () => {
const user = userEvent.setup();
const onSuccess = vi.fn();
render(<QuickAddAppointment onSuccess={onSuccess} />);
// 1. Component renders
expect(screen.getByText('Quick Add Appointment')).toBeInTheDocument();
// 2. Select all fields
await user.selectOptions(getSelectByLabel('Customer'), '1');
await user.selectOptions(getSelectByLabel('Service *'), '2');
await user.selectOptions(getSelectByLabel('Resource'), '3');
await user.selectOptions(getSelectByLabel('Time *'), '15:00');
const notesTextarea = screen.getByPlaceholderText('Optional notes...');
await user.type(notesTextarea, 'Full test');
// 3. See duration display
expect(screen.getByText(/Duration:/i)).toBeInTheDocument();
expect(screen.getByText(/60.*minutes/i)).toBeInTheDocument();
// 4. Submit form
const submitButton = screen.getByRole('button', { name: /Add Appointment/i });
await user.click(submitButton);
// 5. Verify API call
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled();
});
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
customerId: '1',
serviceId: '2',
resourceId: '3',
durationMinutes: 60,
notes: 'Full test',
})
);
// 6. See success state
await waitFor(() => {
expect(screen.getByText('Created!')).toBeInTheDocument();
});
// 7. Callback fired
expect(onSuccess).toHaveBeenCalled();
// 8. Form reset
await waitFor(() => {
expect(getSelectByLabel('Customer').value).toBe('');
});
});
});
});

View File

@@ -0,0 +1,836 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import ResourceDetailModal from '../ResourceDetailModal';
import { Resource } 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 data-testid="portal">{children}</div>,
}));
// Mock Google Maps API
vi.mock('@react-google-maps/api', () => ({
useJsApiLoader: vi.fn(() => ({
isLoaded: false,
loadError: null,
})),
GoogleMap: ({ children }: { children: React.ReactNode }) => (
<div data-testid="google-map">{children}</div>
),
Marker: () => <div data-testid="map-marker" />,
}));
// Mock hooks
vi.mock('../../hooks/useResourceLocation', () => ({
useResourceLocation: vi.fn(),
useLiveResourceLocation: vi.fn(() => ({
refresh: vi.fn(),
})),
}));
import { useResourceLocation, useLiveResourceLocation } from '../../hooks/useResourceLocation';
import { useJsApiLoader } from '@react-google-maps/api';
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('ResourceDetailModal', () => {
const mockResource: Resource = {
id: 'resource-1',
name: 'John Smith',
type: 'STAFF',
maxConcurrentEvents: 1,
userId: 'user-1',
};
const mockOnClose = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
// Default mock implementations
vi.mocked(useResourceLocation).mockReturnValue({
data: undefined,
isLoading: false,
error: null,
} as any);
vi.mocked(useLiveResourceLocation).mockReturnValue({
refresh: vi.fn(),
} as any);
vi.mocked(useJsApiLoader).mockReturnValue({
isLoaded: false,
loadError: null,
} as any);
// Mock environment variable
import.meta.env.VITE_GOOGLE_MAPS_API_KEY = '';
});
afterEach(() => {
cleanup();
});
describe('Rendering', () => {
it('renders modal with resource name', () => {
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
wrapper: createWrapper(),
});
expect(screen.getByText('John Smith')).toBeInTheDocument();
expect(screen.getByText('Staff Member')).toBeInTheDocument();
});
it('renders inside Portal', () => {
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
wrapper: createWrapper(),
});
expect(screen.getByTestId('portal')).toBeInTheDocument();
});
it('displays Current Location heading', () => {
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
wrapper: createWrapper(),
});
expect(screen.getByText('Current Location')).toBeInTheDocument();
});
});
describe('Close functionality', () => {
it('calls onClose when X button is clicked', () => {
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
wrapper: createWrapper(),
});
const closeButtons = screen.getAllByRole('button');
const xButton = closeButtons[0]; // First button is the X in header
fireEvent.click(xButton);
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it('calls onClose when footer Close button is clicked', () => {
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
wrapper: createWrapper(),
});
const closeButtons = screen.getAllByRole('button', { name: /close/i });
const footerButton = closeButtons[1]; // Second button is in footer
fireEvent.click(footerButton);
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
});
describe('Loading state', () => {
it('displays loading spinner when location is loading', () => {
vi.mocked(useResourceLocation).mockReturnValue({
data: undefined,
isLoading: true,
error: null,
} as any);
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
wrapper: createWrapper(),
});
const spinner = document.querySelector('.animate-spin');
expect(spinner).toBeInTheDocument();
});
});
describe('Error state', () => {
it('displays error message when location fetch fails', () => {
vi.mocked(useResourceLocation).mockReturnValue({
data: undefined,
isLoading: false,
error: new Error('Failed to load'),
} as any);
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
wrapper: createWrapper(),
});
expect(screen.getByText('Failed to load location')).toBeInTheDocument();
});
});
describe('No location data state', () => {
it('displays no location message when hasLocation is false', () => {
vi.mocked(useResourceLocation).mockReturnValue({
data: {
hasLocation: false,
isTracking: false,
message: 'Staff has not started tracking',
},
isLoading: false,
error: null,
} as any);
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
wrapper: createWrapper(),
});
expect(screen.getByText('Staff has not started tracking')).toBeInTheDocument();
expect(screen.getByText('Location will appear when staff is en route')).toBeInTheDocument();
});
it('displays default no location message when message is not provided', () => {
vi.mocked(useResourceLocation).mockReturnValue({
data: {
hasLocation: false,
isTracking: false,
},
isLoading: false,
error: null,
} as any);
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
wrapper: createWrapper(),
});
expect(screen.getByText('No location data available')).toBeInTheDocument();
});
});
describe('Active job display', () => {
it('displays active job when en route', () => {
vi.mocked(useResourceLocation).mockReturnValue({
data: {
hasLocation: true,
latitude: 40.7128,
longitude: -74.0060,
isTracking: true,
activeJob: {
id: 1,
title: 'Haircut - Jane Doe',
status: 'EN_ROUTE',
statusDisplay: 'En Route',
},
},
isLoading: false,
error: null,
} as any);
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
wrapper: createWrapper(),
});
expect(screen.getByText('En Route')).toBeInTheDocument();
expect(screen.getByText('Haircut - Jane Doe')).toBeInTheDocument();
expect(screen.getByText('Live')).toBeInTheDocument();
});
it('displays active job when in progress', () => {
vi.mocked(useResourceLocation).mockReturnValue({
data: {
hasLocation: true,
latitude: 40.7128,
longitude: -74.0060,
isTracking: true,
activeJob: {
id: 1,
title: 'Massage - John Smith',
status: 'IN_PROGRESS',
statusDisplay: 'In Progress',
},
},
isLoading: false,
error: null,
} as any);
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
wrapper: createWrapper(),
});
expect(screen.getByText('In Progress')).toBeInTheDocument();
expect(screen.getByText('Massage - John Smith')).toBeInTheDocument();
});
it('does not display active job section when no active job', () => {
vi.mocked(useResourceLocation).mockReturnValue({
data: {
hasLocation: true,
latitude: 40.7128,
longitude: -74.0060,
isTracking: false,
activeJob: null,
},
isLoading: false,
error: null,
} as any);
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
wrapper: createWrapper(),
});
expect(screen.queryByText('En Route')).not.toBeInTheDocument();
expect(screen.queryByText('In Progress')).not.toBeInTheDocument();
});
});
describe('Google Maps fallback (no API key)', () => {
it('displays coordinates when maps API is not available', () => {
vi.mocked(useResourceLocation).mockReturnValue({
data: {
hasLocation: true,
latitude: 40.7128,
longitude: -74.0060,
isTracking: false,
activeJob: null,
},
isLoading: false,
error: null,
} as any);
import.meta.env.VITE_GOOGLE_MAPS_API_KEY = '';
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
wrapper: createWrapper(),
});
expect(screen.getByText('GPS Coordinates')).toBeInTheDocument();
expect(screen.getByText(/40.712800, -74.006000/)).toBeInTheDocument();
expect(screen.getByText('Open in Google Maps')).toBeInTheDocument();
});
it('displays speed when available in fallback mode', () => {
vi.mocked(useResourceLocation).mockReturnValue({
data: {
hasLocation: true,
latitude: 40.7128,
longitude: -74.0060,
speed: 10, // m/s
isTracking: false,
activeJob: null,
},
isLoading: false,
error: null,
} as any);
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
wrapper: createWrapper(),
});
// Speed is converted from m/s to mph: 10 * 2.237 = 22.37 mph
// Appears in both fallback view and details grid
const speedLabels = screen.getAllByText('Speed');
expect(speedLabels.length).toBeGreaterThan(0);
const speedValues = screen.getAllByText(/22.4 mph/);
expect(speedValues.length).toBeGreaterThan(0);
});
it('displays heading when available in fallback mode', () => {
vi.mocked(useResourceLocation).mockReturnValue({
data: {
hasLocation: true,
latitude: 40.7128,
longitude: -74.0060,
heading: 180,
isTracking: false,
activeJob: null,
},
isLoading: false,
error: null,
} as any);
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
wrapper: createWrapper(),
});
// Appears in both fallback view and details grid
const headingLabels = screen.getAllByText('Heading');
expect(headingLabels.length).toBeGreaterThan(0);
const headingValues = screen.getAllByText(/180°/);
expect(headingValues.length).toBeGreaterThan(0);
});
it('renders Google Maps link with correct coordinates', () => {
vi.mocked(useResourceLocation).mockReturnValue({
data: {
hasLocation: true,
latitude: 40.7128,
longitude: -74.0060,
isTracking: false,
activeJob: null,
},
isLoading: false,
error: null,
} as any);
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link', { name: /open in google maps/i });
expect(link).toHaveAttribute(
'href',
'https://www.google.com/maps?q=40.7128,-74.006'
);
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
});
});
describe('Google Maps display (with API key)', () => {
it('renders Google Map when API is loaded', () => {
// Note: This test verifies the Google Maps rendering logic
// In actual usage, API key would be provided via environment variable
// For testing, we mock the loader to return isLoaded: true
// Mock the global google object that the Marker component expects
(global as any).google = {
maps: {
SymbolPath: {
CIRCLE: 0,
},
},
};
vi.mocked(useJsApiLoader).mockReturnValue({
isLoaded: true,
loadError: null,
} as any);
vi.mocked(useResourceLocation).mockReturnValue({
data: {
hasLocation: true,
latitude: 40.7128,
longitude: -74.0060,
isTracking: false,
activeJob: null,
},
isLoading: false,
error: null,
} as any);
// Temporarily set API key for this test
const originalKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY;
(import.meta.env as any).VITE_GOOGLE_MAPS_API_KEY = 'test-key';
const { unmount } = render(
<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />,
{ wrapper: createWrapper() }
);
expect(screen.getByTestId('google-map')).toBeInTheDocument();
expect(screen.getByTestId('map-marker')).toBeInTheDocument();
// Cleanup
unmount();
(import.meta.env as any).VITE_GOOGLE_MAPS_API_KEY = originalKey;
delete (global as any).google;
});
it('shows loading spinner while maps API loads', () => {
vi.mocked(useResourceLocation).mockReturnValue({
data: {
hasLocation: true,
latitude: 40.7128,
longitude: -74.0060,
isTracking: false,
activeJob: null,
},
isLoading: false,
error: null,
} as any);
vi.mocked(useJsApiLoader).mockReturnValue({
isLoaded: false,
loadError: null,
} as any);
import.meta.env.VITE_GOOGLE_MAPS_API_KEY = 'test-api-key';
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
wrapper: createWrapper(),
});
const spinner = document.querySelector('.animate-spin');
expect(spinner).toBeInTheDocument();
});
});
describe('Location details display', () => {
it('displays last update timestamp', () => {
vi.mocked(useResourceLocation).mockReturnValue({
data: {
hasLocation: true,
latitude: 40.7128,
longitude: -74.0060,
timestamp: '2024-01-15T14:30:00Z',
isTracking: false,
activeJob: null,
},
isLoading: false,
error: null,
} as any);
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
wrapper: createWrapper(),
});
expect(screen.getByText('Last Update')).toBeInTheDocument();
// Timestamp is formatted using toLocaleString, just verify it's present
const timestampElement = screen.getByText(/2024/);
expect(timestampElement).toBeInTheDocument();
});
it('displays accuracy when available', () => {
vi.mocked(useResourceLocation).mockReturnValue({
data: {
hasLocation: true,
latitude: 40.7128,
longitude: -74.0060,
accuracy: 15,
isTracking: false,
activeJob: null,
},
isLoading: false,
error: null,
} as any);
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
wrapper: createWrapper(),
});
expect(screen.getByText('Accuracy')).toBeInTheDocument();
expect(screen.getByText('15m')).toBeInTheDocument();
});
it('displays accuracy in kilometers when over 1000m', () => {
vi.mocked(useResourceLocation).mockReturnValue({
data: {
hasLocation: true,
latitude: 40.7128,
longitude: -74.0060,
accuracy: 2500,
isTracking: false,
activeJob: null,
},
isLoading: false,
error: null,
} as any);
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
wrapper: createWrapper(),
});
expect(screen.getByText('2.5km')).toBeInTheDocument();
});
it('displays speed when available', () => {
vi.mocked(useResourceLocation).mockReturnValue({
data: {
hasLocation: true,
latitude: 40.7128,
longitude: -74.0060,
speed: 15, // m/s
isTracking: false,
activeJob: null,
},
isLoading: false,
error: null,
} as any);
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
wrapper: createWrapper(),
});
// Speed section in details
const speedLabels = screen.getAllByText('Speed');
expect(speedLabels.length).toBeGreaterThan(0);
// 15 m/s * 2.237 = 33.6 mph
const speedValues = screen.getAllByText(/33.6 mph/);
expect(speedValues.length).toBeGreaterThan(0);
});
it('displays heading when available', () => {
vi.mocked(useResourceLocation).mockReturnValue({
data: {
hasLocation: true,
latitude: 40.7128,
longitude: -74.0060,
heading: 270,
isTracking: false,
activeJob: null,
},
isLoading: false,
error: null,
} as any);
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
wrapper: createWrapper(),
});
const headingLabels = screen.getAllByText('Heading');
expect(headingLabels.length).toBeGreaterThan(0);
const headingValues = screen.getAllByText(/270°/);
expect(headingValues.length).toBeGreaterThan(0);
});
it('does not display speed when null', () => {
vi.mocked(useResourceLocation).mockReturnValue({
data: {
hasLocation: true,
latitude: 40.7128,
longitude: -74.0060,
speed: null,
isTracking: false,
activeJob: null,
},
isLoading: false,
error: null,
} as any);
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
wrapper: createWrapper(),
});
expect(screen.queryByText('Speed')).not.toBeInTheDocument();
});
it('displays speed when 0', () => {
vi.mocked(useResourceLocation).mockReturnValue({
data: {
hasLocation: true,
latitude: 40.7128,
longitude: -74.0060,
speed: 0,
isTracking: false,
activeJob: null,
},
isLoading: false,
error: null,
} as any);
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
wrapper: createWrapper(),
});
const speedLabels = screen.getAllByText('Speed');
expect(speedLabels.length).toBeGreaterThan(0);
const speedValues = screen.getAllByText(/0.0 mph/);
expect(speedValues.length).toBeGreaterThan(0);
});
});
describe('Live tracking indicator', () => {
it('displays live tracking badge when tracking is active', () => {
vi.mocked(useResourceLocation).mockReturnValue({
data: {
hasLocation: true,
latitude: 40.7128,
longitude: -74.0060,
isTracking: true,
activeJob: {
id: 1,
title: 'Test Job',
status: 'EN_ROUTE',
statusDisplay: 'En Route',
},
},
isLoading: false,
error: null,
} as any);
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
wrapper: createWrapper(),
});
expect(screen.getByText('Live')).toBeInTheDocument();
const liveBadge = screen.getByText('Live').parentElement;
expect(liveBadge?.querySelector('.animate-pulse')).toBeInTheDocument();
});
it('does not display live tracking badge when not tracking', () => {
vi.mocked(useResourceLocation).mockReturnValue({
data: {
hasLocation: true,
latitude: 40.7128,
longitude: -74.0060,
isTracking: false,
activeJob: null,
},
isLoading: false,
error: null,
} as any);
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
wrapper: createWrapper(),
});
expect(screen.queryByText('Live')).not.toBeInTheDocument();
});
});
describe('Live location updates hook', () => {
it('calls useLiveResourceLocation with resource ID', () => {
vi.mocked(useResourceLocation).mockReturnValue({
data: {
hasLocation: true,
latitude: 40.7128,
longitude: -74.0060,
isTracking: true,
activeJob: null,
},
isLoading: false,
error: null,
} as any);
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
wrapper: createWrapper(),
});
expect(useLiveResourceLocation).toHaveBeenCalledWith('resource-1', {
enabled: true,
});
});
it('disables live updates when tracking is false', () => {
vi.mocked(useResourceLocation).mockReturnValue({
data: {
hasLocation: true,
latitude: 40.7128,
longitude: -74.0060,
isTracking: false,
activeJob: null,
},
isLoading: false,
error: null,
} as any);
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
wrapper: createWrapper(),
});
expect(useLiveResourceLocation).toHaveBeenCalledWith('resource-1', {
enabled: false,
});
});
});
describe('Status color coding', () => {
it('applies yellow styling for EN_ROUTE status', () => {
vi.mocked(useResourceLocation).mockReturnValue({
data: {
hasLocation: true,
latitude: 40.7128,
longitude: -74.0060,
isTracking: true,
activeJob: {
id: 1,
title: 'Test Job',
status: 'EN_ROUTE',
statusDisplay: 'En Route',
},
},
isLoading: false,
error: null,
} as any);
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
wrapper: createWrapper(),
});
// Find the parent container with the colored border and background
const statusSection = screen.getByText('En Route').closest('.p-4');
expect(statusSection?.className).toMatch(/yellow/);
});
it('applies blue styling for IN_PROGRESS status', () => {
vi.mocked(useResourceLocation).mockReturnValue({
data: {
hasLocation: true,
latitude: 40.7128,
longitude: -74.0060,
isTracking: true,
activeJob: {
id: 1,
title: 'Test Job',
status: 'IN_PROGRESS',
statusDisplay: 'In Progress',
},
},
isLoading: false,
error: null,
} as any);
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
wrapper: createWrapper(),
});
// Find the parent container with the colored border and background
const statusSection = screen.getByText('In Progress').closest('.p-4');
expect(statusSection?.className).toMatch(/blue/);
});
it('applies gray styling for other status', () => {
vi.mocked(useResourceLocation).mockReturnValue({
data: {
hasLocation: true,
latitude: 40.7128,
longitude: -74.0060,
isTracking: true,
activeJob: {
id: 1,
title: 'Test Job',
status: 'COMPLETED',
statusDisplay: 'Completed',
},
},
isLoading: false,
error: null,
} as any);
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
wrapper: createWrapper(),
});
// Find the parent container with the colored border and background
const statusSection = screen.getByText('Completed').closest('.p-4');
expect(statusSection?.className).toMatch(/gray/);
});
});
describe('Accessibility', () => {
it('has accessible close button label', () => {
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
wrapper: createWrapper(),
});
const srOnly = document.querySelector('.sr-only');
expect(srOnly?.textContent).toBe('common.close');
});
it('renders with proper heading hierarchy', () => {
render(<ResourceDetailModal resource={mockResource} onClose={mockOnClose} />, {
wrapper: createWrapper(),
});
const heading = screen.getByRole('heading', { level: 3 });
expect(heading).toHaveTextContent('John Smith');
});
});
});

View File

@@ -0,0 +1,348 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import React from 'react';
import StaffPermissions, {
PERMISSION_CONFIGS,
SETTINGS_PERMISSION_CONFIGS,
getDefaultPermissions,
} from '../StaffPermissions';
// Mock react-i18next BEFORE imports
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string) => fallback || key,
}),
}));
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
ChevronDown: () => React.createElement('div', { 'data-testid': 'chevron-down' }),
ChevronRight: () => React.createElement('div', { 'data-testid': 'chevron-right' }),
}));
describe('StaffPermissions', () => {
const defaultProps = {
role: 'staff' as const,
permissions: {},
onChange: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('rendering', () => {
it('renders component with title', () => {
render(React.createElement(StaffPermissions, defaultProps));
expect(screen.getByText('Staff Permissions')).toBeInTheDocument();
});
it('renders all regular permission checkboxes', () => {
render(React.createElement(StaffPermissions, defaultProps));
PERMISSION_CONFIGS.forEach((config) => {
expect(screen.getByText(config.labelDefault)).toBeInTheDocument();
expect(screen.getByText(config.hintDefault)).toBeInTheDocument();
});
});
it('renders business settings section', () => {
render(React.createElement(StaffPermissions, defaultProps));
expect(screen.getByText('Can access business settings')).toBeInTheDocument();
});
it('does not show settings sub-permissions when settings is disabled', () => {
render(
React.createElement(StaffPermissions, {
...defaultProps,
permissions: { can_access_settings: false },
})
);
// Settings sub-permissions should not be visible
expect(screen.queryByText('General Settings')).not.toBeInTheDocument();
expect(screen.queryByText('Business Hours')).not.toBeInTheDocument();
});
});
describe('permission toggling', () => {
it('calls onChange when regular permission is toggled', () => {
const onChange = vi.fn();
render(
React.createElement(StaffPermissions, {
...defaultProps,
onChange,
})
);
const checkbox = screen
.getByText('Can invite new staff members')
.closest('label')
?.querySelector('input');
if (checkbox) {
fireEvent.click(checkbox);
}
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ can_invite_staff: true })
);
});
it('reflects checked state from permissions prop', () => {
render(
React.createElement(StaffPermissions, {
...defaultProps,
permissions: { can_invite_staff: true },
})
);
const checkbox = screen
.getByText('Can invite new staff members')
.closest('label')
?.querySelector('input') as HTMLInputElement;
expect(checkbox.checked).toBe(true);
});
it('uses default values for unconfigured permissions', () => {
render(React.createElement(StaffPermissions, defaultProps));
// can_manage_own_appointments has defaultValue: true
const checkbox = screen
.getByText('Can manage own appointments')
.closest('label')
?.querySelector('input') as HTMLInputElement;
expect(checkbox.checked).toBe(true);
});
});
describe('business settings section', () => {
it('expands settings section when settings checkbox is enabled', () => {
const onChange = vi.fn();
render(
React.createElement(StaffPermissions, {
...defaultProps,
onChange,
})
);
// Find all checkboxes and get the last one (settings checkbox)
const checkboxes = screen.getAllByRole('checkbox');
const settingsCheckbox = checkboxes[checkboxes.length - 1];
fireEvent.click(settingsCheckbox);
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ can_access_settings: true })
);
});
it('shows settings sub-permissions when expanded', async () => {
render(
React.createElement(StaffPermissions, {
...defaultProps,
permissions: { can_access_settings: true },
})
);
const settingsDiv = screen.getByText('Can access business settings').closest('div');
if (settingsDiv) {
fireEvent.click(settingsDiv);
}
await waitFor(() => {
expect(screen.getByText('General Settings')).toBeInTheDocument();
expect(screen.getByText('Business Hours')).toBeInTheDocument();
});
});
it('shows multiple enabled sub-settings count', () => {
render(
React.createElement(StaffPermissions, {
...defaultProps,
permissions: {
can_access_settings: true,
can_access_settings_general: true,
can_access_settings_business_hours: true,
},
onChange: vi.fn(),
})
);
// The component should render with settings enabled
expect(screen.getByText(/\(2\/\d+ enabled\)/)).toBeInTheDocument();
});
it('shows enabled settings count badge', () => {
render(
React.createElement(StaffPermissions, {
...defaultProps,
permissions: {
can_access_settings: true,
can_access_settings_general: true,
can_access_settings_branding: true,
},
})
);
expect(screen.getByText(/2\/\d+ enabled/)).toBeInTheDocument();
});
it('toggles expansion with chevron button', async () => {
render(
React.createElement(StaffPermissions, {
...defaultProps,
permissions: { can_access_settings: true },
})
);
// Find and click the chevron button
const chevronButton = screen.getByTestId('chevron-right').closest('button');
if (chevronButton) {
fireEvent.click(chevronButton);
}
await waitFor(() => {
expect(screen.getByText('General Settings')).toBeInTheDocument();
});
});
});
describe('settings sub-permissions', () => {
it('shows select all and select none buttons when expanded', async () => {
render(
React.createElement(StaffPermissions, {
...defaultProps,
permissions: { can_access_settings: true },
})
);
const settingsDiv = screen.getByText('Can access business settings').closest('div');
if (settingsDiv) {
fireEvent.click(settingsDiv);
}
await waitFor(() => {
expect(screen.getByText('Select All')).toBeInTheDocument();
expect(screen.getByText('Select None')).toBeInTheDocument();
});
});
it('selects all settings when select all is clicked', async () => {
const onChange = vi.fn();
render(
React.createElement(StaffPermissions, {
...defaultProps,
permissions: { can_access_settings: true },
onChange,
})
);
const settingsDiv = screen.getByText('Can access business settings').closest('div');
if (settingsDiv) {
fireEvent.click(settingsDiv);
}
await waitFor(() => {
const selectAllButton = screen.getByText('Select All');
fireEvent.click(selectAllButton);
});
expect(onChange).toHaveBeenCalled();
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0];
SETTINGS_PERMISSION_CONFIGS.forEach((config) => {
expect(lastCall[config.key]).toBe(true);
});
});
it('shows expanded state when settings has sub-permissions enabled', () => {
render(
React.createElement(StaffPermissions, {
...defaultProps,
permissions: {
can_access_settings: true,
can_access_settings_general: true,
},
onChange: vi.fn(),
})
);
// Should show the settings count badge
expect(screen.getByText(/1\/\d+ enabled/)).toBeInTheDocument();
});
it('toggles individual settings permission', async () => {
const onChange = vi.fn();
render(
React.createElement(StaffPermissions, {
...defaultProps,
permissions: { can_access_settings: true },
onChange,
})
);
const settingsDiv = screen.getByText('Can access business settings').closest('div');
if (settingsDiv) {
fireEvent.click(settingsDiv);
}
await waitFor(() => {
const generalCheckbox = screen
.getByText('General Settings')
.closest('label')
?.querySelector('input');
if (generalCheckbox) {
fireEvent.click(generalCheckbox);
}
});
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ can_access_settings_general: true })
);
});
});
describe('variant props', () => {
it('accepts invite variant', () => {
render(
React.createElement(StaffPermissions, {
...defaultProps,
variant: 'invite',
})
);
expect(screen.getByText('Staff Permissions')).toBeInTheDocument();
});
it('accepts edit variant', () => {
render(
React.createElement(StaffPermissions, {
...defaultProps,
variant: 'edit',
})
);
expect(screen.getByText('Staff Permissions')).toBeInTheDocument();
});
});
describe('getDefaultPermissions helper', () => {
it('returns default values for all permissions', () => {
const defaults = getDefaultPermissions();
expect(defaults).toHaveProperty('can_access_settings', false);
expect(defaults).toHaveProperty('can_manage_own_appointments', true);
expect(defaults).toHaveProperty('can_invite_staff', false);
PERMISSION_CONFIGS.forEach((config) => {
expect(defaults).toHaveProperty(config.key, config.defaultValue);
});
SETTINGS_PERMISSION_CONFIGS.forEach((config) => {
expect(defaults).toHaveProperty(config.key, config.defaultValue);
});
});
});
});

View File

@@ -0,0 +1,241 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import React from 'react';
import UserProfileDropdown from '../UserProfileDropdown';
import { User } from '../../types';
// Mock react-router-dom BEFORE imports
vi.mock('react-router-dom', () => ({
Link: ({ to, children, ...props }: any) =>
React.createElement('a', { ...props, href: to }, children),
useLocation: () => ({ pathname: '/dashboard' }),
}));
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string) => fallback || key,
}),
}));
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
User: () => React.createElement('div', { 'data-testid': 'user-icon' }),
Settings: () => React.createElement('div', { 'data-testid': 'settings-icon' }),
LogOut: () => React.createElement('div', { 'data-testid': 'logout-icon' }),
ChevronDown: () => React.createElement('div', { 'data-testid': 'chevron-icon' }),
}));
// Mock useAuth hook
const mockLogout = vi.fn();
vi.mock('../../hooks/useAuth', () => ({
useLogout: () => ({
mutate: mockLogout,
isPending: false,
}),
}));
describe('UserProfileDropdown', () => {
const mockUser: User = {
id: 1,
email: 'john@example.com',
name: 'John Doe',
role: 'owner' as any,
phone: '',
isActive: true,
permissions: {},
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('rendering', () => {
it('renders user name', () => {
render(React.createElement(UserProfileDropdown, { user: mockUser }));
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
it('renders formatted role', () => {
render(React.createElement(UserProfileDropdown, { user: mockUser }));
expect(screen.getByText('Owner')).toBeInTheDocument();
});
it('renders formatted role with underscores replaced', () => {
const staffUser = { ...mockUser, role: 'platform_manager' as any };
render(React.createElement(UserProfileDropdown, { user: staffUser }));
expect(screen.getByText('Platform Manager')).toBeInTheDocument();
});
it('renders user avatar when avatarUrl is provided', () => {
const userWithAvatar = { ...mockUser, avatarUrl: 'https://example.com/avatar.jpg' };
const { container } = render(React.createElement(UserProfileDropdown, { user: userWithAvatar }));
const img = container.querySelector('img[alt="John Doe"]');
expect(img).toBeInTheDocument();
expect(img).toHaveAttribute('src', 'https://example.com/avatar.jpg');
});
it('renders user initials when no avatar', () => {
render(React.createElement(UserProfileDropdown, { user: mockUser }));
expect(screen.getByText('JD')).toBeInTheDocument();
});
it('renders single letter initial for single name', () => {
const singleNameUser = { ...mockUser, name: 'Madonna' };
render(React.createElement(UserProfileDropdown, { user: singleNameUser }));
expect(screen.getByText('M')).toBeInTheDocument();
});
it('renders first two initials for multi-word name', () => {
const multiNameUser = { ...mockUser, name: 'John Paul Jones' };
render(React.createElement(UserProfileDropdown, { user: multiNameUser }));
expect(screen.getByText('JP')).toBeInTheDocument();
});
});
describe('dropdown interaction', () => {
it('is closed by default', () => {
render(React.createElement(UserProfileDropdown, { user: mockUser }));
expect(screen.queryByText('Profile Settings')).not.toBeInTheDocument();
});
it('opens dropdown when button clicked', () => {
render(React.createElement(UserProfileDropdown, { user: mockUser }));
const button = screen.getByRole('button');
fireEvent.click(button);
expect(screen.getByText('Profile Settings')).toBeInTheDocument();
});
it('shows user email in dropdown header', () => {
render(React.createElement(UserProfileDropdown, { user: mockUser }));
const button = screen.getByRole('button');
fireEvent.click(button);
expect(screen.getByText('john@example.com')).toBeInTheDocument();
});
it('closes dropdown when clicking outside', async () => {
const { container } = render(React.createElement(UserProfileDropdown, { user: mockUser }));
const button = screen.getByRole('button');
fireEvent.click(button);
expect(screen.getByText('Profile Settings')).toBeInTheDocument();
// Click outside
fireEvent.mouseDown(document.body);
await waitFor(() => {
expect(screen.queryByText('Profile Settings')).not.toBeInTheDocument();
});
});
it('closes dropdown on escape key', async () => {
render(React.createElement(UserProfileDropdown, { user: mockUser }));
const button = screen.getByRole('button');
fireEvent.click(button);
expect(screen.getByText('Profile Settings')).toBeInTheDocument();
fireEvent.keyDown(document, { key: 'Escape' });
await waitFor(() => {
expect(screen.queryByText('Profile Settings')).not.toBeInTheDocument();
});
});
it('sets aria-expanded attribute correctly', () => {
render(React.createElement(UserProfileDropdown, { user: mockUser }));
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(button);
expect(button).toHaveAttribute('aria-expanded', 'true');
});
});
describe('navigation', () => {
it('links to /profile for non-platform routes', () => {
const { container } = render(React.createElement(UserProfileDropdown, { user: mockUser }));
const button = screen.getByRole('button');
fireEvent.click(button);
const link = container.querySelector('a[href="/profile"]');
expect(link).toBeInTheDocument();
});
it('profile settings link renders correctly', () => {
render(React.createElement(UserProfileDropdown, { user: mockUser }));
const button = screen.getByRole('button');
fireEvent.click(button);
expect(screen.getByText('Profile Settings')).toBeInTheDocument();
});
it('closes dropdown when profile link is clicked', async () => {
render(React.createElement(UserProfileDropdown, { user: mockUser }));
const button = screen.getByRole('button');
fireEvent.click(button);
const profileLink = screen.getByText('Profile Settings');
fireEvent.click(profileLink);
await waitFor(() => {
expect(screen.queryByText('Sign Out')).not.toBeInTheDocument();
});
});
});
describe('sign out', () => {
it('renders sign out button', () => {
render(React.createElement(UserProfileDropdown, { user: mockUser }));
const button = screen.getByRole('button');
fireEvent.click(button);
expect(screen.getByText('Sign Out')).toBeInTheDocument();
});
it('calls logout when sign out clicked', () => {
render(React.createElement(UserProfileDropdown, { user: mockUser }));
const button = screen.getByRole('button');
fireEvent.click(button);
const signOutButton = screen.getByText('Sign Out');
fireEvent.click(signOutButton);
expect(mockLogout).toHaveBeenCalled();
});
it('sign out button is functional', () => {
render(React.createElement(UserProfileDropdown, { user: mockUser }));
const button = screen.getByRole('button');
fireEvent.click(button);
const signOutButton = screen.getByText('Sign Out').closest('button');
expect(signOutButton).not.toBeDisabled();
});
});
describe('variants', () => {
it('applies default variant styles', () => {
const { container } = render(React.createElement(UserProfileDropdown, { user: mockUser }));
const button = container.querySelector('button');
expect(button?.className).toContain('border-gray-200');
});
it('applies light variant styles', () => {
const { container } = render(
React.createElement(UserProfileDropdown, {
user: mockUser,
variant: 'light',
})
);
const button = container.querySelector('button');
expect(button?.className).toContain('border-white/20');
});
it('shows white text in light variant', () => {
const { container } = render(
React.createElement(UserProfileDropdown, {
user: mockUser,
variant: 'light',
})
);
const userName = screen.getByText('John Doe');
expect(userName.className).toContain('text-white');
});
});
});

View File

@@ -0,0 +1,297 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import React from 'react';
import { ManualSchedulingRequest } from '../ManualSchedulingRequest';
// Mock Lucide icons
vi.mock('lucide-react', () => ({
Phone: () => <span data-testid="icon-phone" />,
Calendar: () => <span data-testid="icon-calendar" />,
Clock: () => <span data-testid="icon-clock" />,
Check: () => <span data-testid="icon-check" />,
}));
describe('ManualSchedulingRequest', () => {
const mockService = {
id: 1,
name: 'Consultation',
description: 'Professional consultation',
duration: 60,
price_cents: 10000,
photos: [],
capture_preferred_time: true,
};
const mockServiceNoPreferredTime = {
...mockService,
capture_preferred_time: false,
};
const defaultProps = {
service: mockService,
onPreferredTimeChange: vi.fn(),
preferredDate: null,
preferredTimeNotes: '',
};
beforeEach(() => {
vi.clearAllMocks();
});
it('renders the call message', () => {
render(React.createElement(ManualSchedulingRequest, defaultProps));
expect(screen.getByText("We'll call you to schedule")).toBeInTheDocument();
expect(screen.getByText(/Our team will contact you within 24 hours/)).toBeInTheDocument();
});
it('displays service name in message', () => {
render(React.createElement(ManualSchedulingRequest, defaultProps));
expect(screen.getByText(/Consultation/)).toBeInTheDocument();
});