From 47657e7076376e89bc2c867dbf56130e0f7e33d0 Mon Sep 17 00:00:00 2001 From: poduck Date: Mon, 29 Dec 2025 17:38:48 -0500 Subject: [PATCH] Add staff permission controls for editing staff and customers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .idea/smoothschedule.iml | 18 + frontend/src/__tests__/App.test.tsx | 583 +++++++++ .../billing/__tests__/featureCatalog.test.ts | 206 +++ .../__tests__/AddOnEditorModal.test.tsx | 530 ++++++++ .../__tests__/PlanDetailPanel.test.tsx | 586 +++++++++ .../__tests__/CurrentTimeIndicator.test.tsx | 123 ++ .../__tests__/DraggableEvent.test.tsx | 228 ++++ .../Timeline/__tests__/ResourceRow.test.tsx | 243 ++++ .../Timeline/__tests__/TimelineRow.test.tsx | 276 ++++ .../__tests__/ConnectOnboarding.test.tsx | 812 ++++++++++++ .../__tests__/DevQuickLogin.test.tsx | 797 ++++++++++++ .../__tests__/EmailTemplateSelector.test.tsx | 367 ++++++ .../__tests__/LanguageSelector.test.tsx | 122 +- .../__tests__/QuickAddAppointment.test.tsx | 805 ++++++++++++ .../__tests__/ResourceDetailModal.test.tsx | 836 ++++++++++++ .../__tests__/StaffPermissions.test.tsx | 348 +++++ .../__tests__/UserProfileDropdown.test.tsx | 241 ++++ .../ManualSchedulingRequest.test.tsx | 297 +++++ .../booking/__tests__/PaymentSection.test.tsx | 229 ++++ .../__tests__/OpenTicketsWidget.test.tsx | 492 ++++++++ .../__tests__/RecentActivityWidget.test.tsx | 576 +++++++++ .../__tests__/WidgetConfigModal.test.tsx | 576 +++++++++ .../email/__tests__/EmailComposer.test.tsx | 373 ++++++ .../email/__tests__/EmailViewer.test.tsx | 456 +++++++ .../help/__tests__/HelpSearch.test.tsx | 461 +++++++ .../__tests__/UnscheduledBookingDemo.test.tsx | 377 ++++++ .../__tests__/AutomationShowcase.test.tsx | 242 ++++ .../__tests__/BenefitsSection.test.tsx | 165 +++ .../__tests__/DynamicPricingCards.test.tsx | 337 +++++ .../__tests__/FeatureComparisonTable.test.tsx | 369 ++++++ .../__tests__/TestimonialCard.test.tsx | 287 +++++ .../__tests__/WorkflowVisual.test.tsx | 315 +++++ .../__tests__/DynamicFeaturesEditor.test.tsx | 855 +++++++++++++ .../FeaturesPermissionsEditor.test.tsx | 695 ++++++++++ .../profile/__tests__/TwoFactorSetup.test.tsx | 826 ++++++++++-- .../__tests__/CustomerPreview.test.tsx | 314 +++++ .../__tests__/ResourceSelector.test.tsx | 306 +++++ .../src/components/staff/RolePermissions.tsx | 84 +- .../TimeBlockCalendarOverlay.test.tsx | 628 +++++++++ .../__tests__/TimeBlockCreatorModal.test.tsx | 934 ++++++++++++++ .../__tests__/YearlyBlockCalendar.test.tsx | 729 +++++++++++ frontend/src/components/ui/TabGroup.tsx | 3 + .../__tests__/schedulePresets.test.ts | 293 +++++ .../data/__tests__/helpSearchIndex.test.ts | 210 ++++ .../__tests__/navigationSearchIndex.test.ts | 312 +++++ .../src/hooks/__tests__/useLocations.test.ts | 140 +++ .../__tests__/useNavigationSearch.test.ts | 548 ++++++++ frontend/src/hooks/useAuth.ts | 8 +- frontend/src/hooks/useEntitlements.ts | 6 + frontend/src/hooks/useStaffRoles.ts | 4 +- frontend/src/i18n/__tests__/index.test.ts | 303 +++++ frontend/src/pages/Customers.tsx | 54 +- frontend/src/pages/Staff.tsx | 54 +- frontend/src/pages/StaffDashboard.tsx | 123 +- .../src/pages/__tests__/BookingFlow.test.tsx | 1074 ++++++++++++++++ .../pages/__tests__/ContractSigning.test.tsx | 397 ++++++ .../src/pages/__tests__/HelpApiDocs.test.tsx | 632 ++++++++++ ...pApiDocs.test.tsx.tmp.770091.1766728081370 | 630 ++++++++++ .../pages/__tests__/OwnerScheduler.test.tsx | 1120 +++++++++-------- .../src/pages/__tests__/PageEditor.test.tsx | 406 ++++++ .../src/pages/__tests__/Settings.test.tsx | 750 +++++++++++ .../__tests__/CustomerBilling.test.tsx | 610 +++++++++ .../__tests__/CustomerSupport.test.tsx | 679 ++++++++++ .../__tests__/HelpApiAppointments.test.tsx | 34 + .../help/__tests__/HelpApiWebhooks.test.tsx | 34 + .../__tests__/HelpAutomationDocs.test.tsx | 30 + .../help/__tests__/HelpAutomations.test.tsx | 34 + .../help/__tests__/HelpContracts.test.tsx | 53 + .../help/__tests__/HelpCustomers.test.tsx | 61 + .../help/__tests__/HelpLocations.test.tsx | 51 + .../help/__tests__/HelpScheduler.test.tsx | 55 + .../help/__tests__/HelpServices.test.tsx | 70 ++ .../__tests__/HelpSettingsAppearance.test.tsx | 34 + .../HelpSettingsBusinessHours.test.tsx | 34 + .../HelpSettingsCommunication.test.tsx | 34 + .../HelpSettingsEmailTemplates.test.tsx | 34 + .../HelpSettingsEmbedWidget.test.tsx | 34 + .../help/__tests__/HelpSiteBuilder.test.tsx | 127 ++ .../pages/help/__tests__/HelpStaff.test.tsx | 62 + .../__tests__/SystemEmailTemplates.test.tsx | 464 +++++++ .../src/pos/components/CardPaymentPanel.tsx | 452 +++++++ .../pos/components/GiftCardPaymentPanel.tsx | 12 +- frontend/src/pos/components/POSLayout.tsx | 94 +- frontend/src/pos/components/PaymentModal.tsx | 133 +- frontend/src/pos/components/index.ts | 4 +- frontend/src/pos/hooks/useOrders.ts | 78 +- .../content/__tests__/Heading.test.tsx | 157 +++ .../layout/__tests__/Divider.test.tsx | 134 ++ .../layout/__tests__/Spacer.test.tsx | 78 ++ .../__tests__/ImagePickerField.test.tsx | 789 ++++++++++++ frontend/src/types.ts | 1 + smoothschedule/config/settings/base.py | 1 + .../config/settings/multitenancy.py | 2 + .../migrations/0002_add_order_cash_shift.py | 19 + .../smoothschedule/commerce/pos/models.py | 51 + .../commerce/pos/serializers.py | 22 +- .../smoothschedule/commerce/pos/urls.py | 2 + .../smoothschedule/commerce/pos/views.py | 223 +++- .../smoothschedule/identity/core/mixins.py | 121 +- .../smoothschedule/identity/core/signals.py | 63 + .../identity/core/tests/test_signals.py | 206 +++ .../identity/users/staff_permissions.py | 72 +- .../smoothschedule/platform/admin/views.py | 21 +- .../commands/ensure_default_locations.py | 105 ++ .../scheduling/schedule/views.py | 97 +- 105 files changed, 29709 insertions(+), 873 deletions(-) create mode 100644 .idea/smoothschedule.iml create mode 100644 frontend/src/__tests__/App.test.tsx create mode 100644 frontend/src/billing/__tests__/featureCatalog.test.ts create mode 100644 frontend/src/billing/components/__tests__/AddOnEditorModal.test.tsx create mode 100644 frontend/src/billing/components/__tests__/PlanDetailPanel.test.tsx create mode 100644 frontend/src/components/Timeline/__tests__/CurrentTimeIndicator.test.tsx create mode 100644 frontend/src/components/Timeline/__tests__/DraggableEvent.test.tsx create mode 100644 frontend/src/components/Timeline/__tests__/ResourceRow.test.tsx create mode 100644 frontend/src/components/Timeline/__tests__/TimelineRow.test.tsx create mode 100644 frontend/src/components/__tests__/ConnectOnboarding.test.tsx create mode 100644 frontend/src/components/__tests__/DevQuickLogin.test.tsx create mode 100644 frontend/src/components/__tests__/EmailTemplateSelector.test.tsx create mode 100644 frontend/src/components/__tests__/QuickAddAppointment.test.tsx create mode 100644 frontend/src/components/__tests__/ResourceDetailModal.test.tsx create mode 100644 frontend/src/components/__tests__/StaffPermissions.test.tsx create mode 100644 frontend/src/components/__tests__/UserProfileDropdown.test.tsx create mode 100644 frontend/src/components/booking/__tests__/ManualSchedulingRequest.test.tsx create mode 100644 frontend/src/components/booking/__tests__/PaymentSection.test.tsx create mode 100644 frontend/src/components/dashboard/__tests__/OpenTicketsWidget.test.tsx create mode 100644 frontend/src/components/dashboard/__tests__/RecentActivityWidget.test.tsx create mode 100644 frontend/src/components/dashboard/__tests__/WidgetConfigModal.test.tsx create mode 100644 frontend/src/components/email/__tests__/EmailComposer.test.tsx create mode 100644 frontend/src/components/email/__tests__/EmailViewer.test.tsx create mode 100644 frontend/src/components/help/__tests__/HelpSearch.test.tsx create mode 100644 frontend/src/components/help/__tests__/UnscheduledBookingDemo.test.tsx create mode 100644 frontend/src/components/marketing/__tests__/AutomationShowcase.test.tsx create mode 100644 frontend/src/components/marketing/__tests__/BenefitsSection.test.tsx create mode 100644 frontend/src/components/marketing/__tests__/DynamicPricingCards.test.tsx create mode 100644 frontend/src/components/marketing/__tests__/FeatureComparisonTable.test.tsx create mode 100644 frontend/src/components/marketing/__tests__/TestimonialCard.test.tsx create mode 100644 frontend/src/components/marketing/__tests__/WorkflowVisual.test.tsx create mode 100644 frontend/src/components/platform/__tests__/DynamicFeaturesEditor.test.tsx create mode 100644 frontend/src/components/platform/__tests__/FeaturesPermissionsEditor.test.tsx create mode 100644 frontend/src/components/services/__tests__/CustomerPreview.test.tsx create mode 100644 frontend/src/components/services/__tests__/ResourceSelector.test.tsx create mode 100644 frontend/src/components/time-blocks/__tests__/TimeBlockCalendarOverlay.test.tsx create mode 100644 frontend/src/components/time-blocks/__tests__/TimeBlockCreatorModal.test.tsx create mode 100644 frontend/src/components/time-blocks/__tests__/YearlyBlockCalendar.test.tsx create mode 100644 frontend/src/constants/__tests__/schedulePresets.test.ts create mode 100644 frontend/src/data/__tests__/helpSearchIndex.test.ts create mode 100644 frontend/src/data/__tests__/navigationSearchIndex.test.ts create mode 100644 frontend/src/hooks/__tests__/useNavigationSearch.test.ts create mode 100644 frontend/src/i18n/__tests__/index.test.ts create mode 100644 frontend/src/pages/__tests__/BookingFlow.test.tsx create mode 100644 frontend/src/pages/__tests__/ContractSigning.test.tsx create mode 100644 frontend/src/pages/__tests__/HelpApiDocs.test.tsx create mode 100644 frontend/src/pages/__tests__/HelpApiDocs.test.tsx.tmp.770091.1766728081370 create mode 100644 frontend/src/pages/__tests__/PageEditor.test.tsx create mode 100644 frontend/src/pages/__tests__/Settings.test.tsx create mode 100644 frontend/src/pages/customer/__tests__/CustomerBilling.test.tsx create mode 100644 frontend/src/pages/customer/__tests__/CustomerSupport.test.tsx create mode 100644 frontend/src/pages/help/__tests__/HelpApiAppointments.test.tsx create mode 100644 frontend/src/pages/help/__tests__/HelpApiWebhooks.test.tsx create mode 100644 frontend/src/pages/help/__tests__/HelpAutomationDocs.test.tsx create mode 100644 frontend/src/pages/help/__tests__/HelpAutomations.test.tsx create mode 100644 frontend/src/pages/help/__tests__/HelpContracts.test.tsx create mode 100644 frontend/src/pages/help/__tests__/HelpCustomers.test.tsx create mode 100644 frontend/src/pages/help/__tests__/HelpLocations.test.tsx create mode 100644 frontend/src/pages/help/__tests__/HelpScheduler.test.tsx create mode 100644 frontend/src/pages/help/__tests__/HelpServices.test.tsx create mode 100644 frontend/src/pages/help/__tests__/HelpSettingsAppearance.test.tsx create mode 100644 frontend/src/pages/help/__tests__/HelpSettingsBusinessHours.test.tsx create mode 100644 frontend/src/pages/help/__tests__/HelpSettingsCommunication.test.tsx create mode 100644 frontend/src/pages/help/__tests__/HelpSettingsEmailTemplates.test.tsx create mode 100644 frontend/src/pages/help/__tests__/HelpSettingsEmbedWidget.test.tsx create mode 100644 frontend/src/pages/help/__tests__/HelpSiteBuilder.test.tsx create mode 100644 frontend/src/pages/help/__tests__/HelpStaff.test.tsx create mode 100644 frontend/src/pages/settings/__tests__/SystemEmailTemplates.test.tsx create mode 100644 frontend/src/pos/components/CardPaymentPanel.tsx create mode 100644 frontend/src/puck/components/content/__tests__/Heading.test.tsx create mode 100644 frontend/src/puck/components/layout/__tests__/Divider.test.tsx create mode 100644 frontend/src/puck/components/layout/__tests__/Spacer.test.tsx create mode 100644 frontend/src/puck/fields/__tests__/ImagePickerField.test.tsx create mode 100644 smoothschedule/smoothschedule/commerce/pos/migrations/0002_add_order_cash_shift.py create mode 100644 smoothschedule/smoothschedule/scheduling/schedule/management/commands/ensure_default_locations.py diff --git a/.idea/smoothschedule.iml b/.idea/smoothschedule.iml new file mode 100644 index 00000000..af2a7160 --- /dev/null +++ b/.idea/smoothschedule.iml @@ -0,0 +1,18 @@ + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx new file mode 100644 index 00000000..bcca97c0 --- /dev/null +++ b/frontend/src/__tests__/App.test.tsx @@ -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: () =>
Login Page
, +})); + +vi.mock('../pages/marketing/HomePage', () => ({ + default: () =>
Home Page
, +})); + +vi.mock('../pages/Dashboard', () => ({ + default: () =>
Dashboard
, +})); + +vi.mock('../pages/platform/PlatformDashboard', () => ({ + default: () =>
Platform Dashboard
, +})); + +vi.mock('../pages/customer/CustomerDashboard', () => ({ + default: () =>
Customer Dashboard
, +})); + +// Mock all layouts +vi.mock('../layouts/BusinessLayout', () => ({ + default: () =>
Business Layout
, +})); + +vi.mock('../layouts/PlatformLayout', () => ({ + default: () =>
Platform Layout
, +})); + +vi.mock('../layouts/CustomerLayout', () => ({ + default: () =>
Customer Layout
, +})); + +vi.mock('../layouts/MarketingLayout', () => ({ + default: () =>
Marketing Layout
, +})); + +// 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: () =>
Toaster
, +})); + +// 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 = { + '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()).not.toThrow(); + }); + + it('should render toaster component for notifications', () => { + render(); + expect(screen.getByTestId('toaster')).toBeInTheDocument(); + }); + + it('should render with QueryClientProvider wrapper', () => { + const { container } = render(); + 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(); + + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); + + it('should show loading spinner in loading screen', () => { + mockUseCurrentUser.mockReturnValue({ + data: null, + isLoading: true, + error: null, + }); + + const { container } = render(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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()).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()).not.toThrow(); + }); + }); + + describe('Query Client Configuration', () => { + it('should configure query client with refetchOnWindowFocus disabled', () => { + const { container } = render(); + expect(container).toBeTruthy(); + // QueryClient config is tested implicitly by successful rendering + }); + + it('should configure query client with retry limit', () => { + const { container } = render(); + expect(container).toBeTruthy(); + // QueryClient retry config is applied during instantiation + }); + + it('should configure query client with staleTime', () => { + const { container } = render(); + expect(container).toBeTruthy(); + // QueryClient staleTime config is applied during instantiation + }); + }); +}); diff --git a/frontend/src/billing/__tests__/featureCatalog.test.ts b/frontend/src/billing/__tests__/featureCatalog.test.ts new file mode 100644 index 00000000..2ff6e013 --- /dev/null +++ b/frontend/src/billing/__tests__/featureCatalog.test.ts @@ -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', + }); + }); + }); +}); diff --git a/frontend/src/billing/components/__tests__/AddOnEditorModal.test.tsx b/frontend/src/billing/components/__tests__/AddOnEditorModal.test.tsx new file mode 100644 index 00000000..4f108d49 --- /dev/null +++ b/frontend/src/billing/components/__tests__/AddOnEditorModal.test.tsx @@ -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; +const mockUseMutation = useMutation as unknown as ReturnType; + +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(); + + expect(screen.getByRole('heading', { name: /create add-on/i })).toBeInTheDocument(); + }); + + it('renders edit mode when addon is provided', () => { + render(); + + expect(screen.getByText(`Edit ${mockAddon.name}`)).toBeInTheDocument(); + }); + + it('renders all form fields', () => { + render(); + + 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(); + + 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(); + + 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(); + + // 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(); + + expect(screen.getByTestId('feature-picker')).toBeInTheDocument(); + }); + }); + + describe('Form Validation', () => { + it('shows error when code is empty', async () => { + const user = userEvent.setup(); + render(); + + 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(); + + 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(); + + 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(); + + // 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + const cancelButton = screen.getByText(/cancel/i); + await user.click(cancelButton); + + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('does not render when isOpen is false', () => { + render(); + + expect(screen.queryByText(/create add-on/i)).not.toBeInTheDocument(); + }); + + it('resets form when modal is reopened', () => { + const { rerender } = render( + + ); + + expect(screen.getByDisplayValue(mockAddon.name)).toBeInTheDocument(); + + rerender(); + rerender(); + + // 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(); + + 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(); + + 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(); + + 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', + }) + ); + }); + }); + }); +}); diff --git a/frontend/src/billing/components/__tests__/PlanDetailPanel.test.tsx b/frontend/src/billing/components/__tests__/PlanDetailPanel.test.tsx new file mode 100644 index 00000000..5f458b2c --- /dev/null +++ b/frontend/src/billing/components/__tests__/PlanDetailPanel.test.tsx @@ -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; +const mockUseCurrentUser = useCurrentUser as unknown as ReturnType; + +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( + + ); + + 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( + + ); + + 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( + + ); + + // There may be multiple "Inactive" texts (badge and overview section) + expect(screen.getAllByText(/inactive/i).length).toBeGreaterThan(0); + }); + + it('displays subscriber count', () => { + render( + + ); + + expect(screen.getByText(/10 subscribers/i)).toBeInTheDocument(); + }); + + it('displays pricing information', () => { + render( + + ); + + 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( + + ); + + expect(screen.getByText(/free/i)).toBeInTheDocument(); + }); + }); + + describe('Action Buttons', () => { + it('renders Edit button and calls onEdit when clicked', async () => { + const user = userEvent.setup(); + render( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + expect(screen.getByText('Overview')).toBeInTheDocument(); + expect(screen.getByText(/plan code/i)).toBeInTheDocument(); + }); + + it('renders Pricing section with price details', () => { + render( + + ); + + 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( + + ); + + 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( + + ); + + // 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( + + ); + + // 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( + + ); + + // 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( + + ); + + expect(screen.getByText('Danger Zone')).toBeInTheDocument(); + }); + + it('prevents deletion when plan has subscribers', async () => { + const user = userEvent.setup(); + render( + + ); + + // 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( + + ); + + // 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( + + ); + + // 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( + + ); + + // 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( + + ); + + expect(screen.getByText(mockAddon.name)).toBeInTheDocument(); + expect(screen.getByText(mockAddon.code)).toBeInTheDocument(); + expect(screen.getByText(mockAddon.description!)).toBeInTheDocument(); + }); + + it('displays add-on pricing', () => { + render( + + ); + + 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( + + ); + + const editButton = screen.getByRole('button', { name: /edit/i }); + await user.click(editButton); + + expect(mockOnEdit).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/frontend/src/components/Timeline/__tests__/CurrentTimeIndicator.test.tsx b/frontend/src/components/Timeline/__tests__/CurrentTimeIndicator.test.tsx new file mode 100644 index 00000000..e14d7a8b --- /dev/null +++ b/frontend/src/components/Timeline/__tests__/CurrentTimeIndicator.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + const indicator = document.querySelector('#current-time-indicator'); + expect(indicator).toHaveStyle({ left: '50px' }); // 0.5 hours * 100px + }); +}); diff --git a/frontend/src/components/Timeline/__tests__/DraggableEvent.test.tsx b/frontend/src/components/Timeline/__tests__/DraggableEvent.test.tsx new file mode 100644 index 00000000..9a3967e3 --- /dev/null +++ b/frontend/src/components/Timeline/__tests__/DraggableEvent.test.tsx @@ -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 }) =>
{children}
, + 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(); + expect(screen.getByText('Test Event')).toBeInTheDocument(); + }); + + it('renders the service name when provided', () => { + render(); + expect(screen.getByText('Test Service')).toBeInTheDocument(); + }); + + it('does not render service name when not provided', () => { + render(); + expect(screen.queryByText('Test Service')).not.toBeInTheDocument(); + }); + + it('displays the start time formatted correctly', () => { + render(); + expect(screen.getByText('10:00 AM')).toBeInTheDocument(); + }); + + it('applies correct position styles', () => { + const { container } = render(); + 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( + + ); + const eventElement = container.querySelector('.absolute.rounded-b'); + expect(eventElement).toHaveClass('border-blue-500'); + }); + + it('applies completed status border color', () => { + const { container } = render( + + ); + const eventElement = container.querySelector('.absolute.rounded-b'); + expect(eventElement).toHaveClass('border-green-500'); + }); + + it('applies cancelled status border color', () => { + const { container } = render( + + ); + const eventElement = container.querySelector('.absolute.rounded-b'); + expect(eventElement).toHaveClass('border-red-500'); + }); + + it('applies no-show status border color', () => { + const { container } = render( + + ); + const eventElement = container.querySelector('.absolute.rounded-b'); + expect(eventElement).toHaveClass('border-gray-500'); + }); + + it('applies green border when paid', () => { + const { container } = render( + + ); + 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( + + ); + 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( + + ); + + 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( + + ); + + 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(); + const gripIcon = container.querySelector('svg'); + expect(gripIcon).toBeInTheDocument(); + }); + + it('applies hover styles', () => { + const { container } = render(); + const eventElement = container.querySelector('.absolute.rounded-b'); + expect(eventElement).toHaveClass('group', 'hover:shadow-md'); + }); + + it('renders with correct base styling classes', () => { + const { container } = render(); + 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(); + 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( + + ); + + 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(); + const contentArea = container.querySelector('.cursor-move'); + expect(contentArea).toBeInTheDocument(); + expect(contentArea).toHaveClass('select-none'); + }); + + it('applies different heights correctly', () => { + const { container } = render( + + ); + const eventElement = container.querySelector('.absolute.rounded-b'); + expect(eventElement).toHaveStyle({ height: '100px' }); + }); + + it('applies different widths correctly', () => { + const { container } = render( + + ); + const eventElement = container.querySelector('.absolute.rounded-b'); + expect(eventElement).toHaveStyle({ width: '300px' }); + }); +}); diff --git a/frontend/src/components/Timeline/__tests__/ResourceRow.test.tsx b/frontend/src/components/Timeline/__tests__/ResourceRow.test.tsx new file mode 100644 index 00000000..e6ad7cf0 --- /dev/null +++ b/frontend/src/components/Timeline/__tests__/ResourceRow.test.tsx @@ -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 }) =>
{children}
, + 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(); + expect(screen.getByText('Test Resource')).toBeInTheDocument(); + }); + + it('renders all events', () => { + render(); + expect(screen.getByText('Event 1')).toBeInTheDocument(); + expect(screen.getByText('Event 2')).toBeInTheDocument(); + }); + + it('renders with no events', () => { + render(); + expect(screen.getByText('Test Resource')).toBeInTheDocument(); + expect(screen.queryByText('Event 1')).not.toBeInTheDocument(); + }); + + it('applies sticky positioning to resource name column', () => { + const { container } = render(); + 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(); + 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( + + ); + + 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(); + const droppableArea = container.querySelector('.relative.flex-grow'); + expect(droppableArea).toHaveClass('transition-colors'); + }); + + it('renders border between rows', () => { + const { container } = render(); + const row = container.querySelector('.flex.border-b'); + expect(row).toHaveClass('border-gray-200'); + }); + + it('applies hover effect to resource name', () => { + const { container } = render(); + 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(); + 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(); + const events = container.querySelectorAll('.absolute.rounded-b'); + expect(events.length).toBe(2); + }); + + it('renders resource name with fixed width', () => { + const { container } = render(); + 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(); + expect(screen.getByText('Single Event')).toBeInTheDocument(); + }); + + it('passes resize handler to events', () => { + const onResizeStart = vi.fn(); + render(); + // 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(); + 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(); + 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(); + 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(); + // 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(); + // The droppable area should have the resource id in its data + const droppableArea = container.querySelector('.relative.flex-grow'); + expect(droppableArea).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/Timeline/__tests__/TimelineRow.test.tsx b/frontend/src/components/Timeline/__tests__/TimelineRow.test.tsx new file mode 100644 index 00000000..a282e30c --- /dev/null +++ b/frontend/src/components/Timeline/__tests__/TimelineRow.test.tsx @@ -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 }) =>
{children}
, + 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(); + expect(screen.getByText('Event 1')).toBeInTheDocument(); + expect(screen.getByText('Event 2')).toBeInTheDocument(); + }); + + it('renders event service names', () => { + render(); + expect(screen.getByText('Service 1')).toBeInTheDocument(); + expect(screen.getByText('Service 2')).toBeInTheDocument(); + }); + + it('renders with no events', () => { + render(); + expect(screen.queryByText('Event 1')).not.toBeInTheDocument(); + }); + + it('applies correct height from prop', () => { + const { container } = render(); + const row = container.querySelector('.relative.border-b'); + expect(row).toHaveStyle({ height: '150px' }); + }); + + it('calculates total width correctly', () => { + const { container } = render(); + 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(); + 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(); + const row = container.querySelector('.relative.border-b'); + expect(row).toHaveClass('transition-colors', 'group'); + }); + + it('renders border with dark mode support', () => { + const { container } = render(); + 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(); + 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(); + const gridLine = container.querySelector('.border-r.border-gray-100'); + expect(gridLine).toHaveStyle({ width: '120px' }); + }); + + it('positions events correctly within the row', () => { + const { container } = render(); + const events = container.querySelectorAll('.absolute.rounded-b'); + expect(events.length).toBe(2); + }); + + it('passes event status to draggable events', () => { + render(); + // 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(); + // 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(); + // 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(); + // 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(); + 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(); + expect(screen.getByText('Single Event')).toBeInTheDocument(); + }); + + it('renders grid with pointer-events-none', () => { + const { container } = render(); + 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(); + const gridLine = container.querySelector('.border-r'); + expect(gridLine).toHaveClass('dark:border-gray-700/50'); + }); + + it('sets droppable id with resource id', () => { + const { container } = render(); + // 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(); + 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(); + 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(); + expect(screen.getByText('Event Without Status')).toBeInTheDocument(); + }); + + it('memoizes event layout calculation', () => { + const { rerender } = render(); + expect(screen.getByText('Event 1')).toBeInTheDocument(); + + // Rerender with same events + rerender(); + expect(screen.getByText('Event 1')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/__tests__/ConnectOnboarding.test.tsx b/frontend/src/components/__tests__/ConnectOnboarding.test.tsx new file mode 100644 index 00000000..56244491 --- /dev/null +++ b/frontend/src/components/__tests__/ConnectOnboarding.test.tsx @@ -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) => { + const translations: Record = { + '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 => ({ + 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 }) => ( + {children} + ); +}; + +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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { wrapper: createWrapper() } + ); + + expect(screen.getByText('Standard')).toBeInTheDocument(); + }); + + it('should display express account type', () => { + const account = createMockConnectAccount({ account_type: 'express' }); + + render( + , + { wrapper: createWrapper() } + ); + + expect(screen.getByText('Express')).toBeInTheDocument(); + }); + + it('should display custom account type', () => { + const account = createMockConnectAccount({ account_type: 'custom' }); + + render( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { wrapper: createWrapper() } + ); + + expect(screen.getByText('Complete Onboarding')).toBeInTheDocument(); + }); + + it('should render continue onboarding button', () => { + const account = createMockConnectAccount({ + status: 'onboarding', + onboarding_complete: false, + }); + + render( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + + ); + + 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { wrapper: createWrapper() } + ); + + expect(screen.queryByText('Complete Onboarding')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/__tests__/DevQuickLogin.test.tsx b/frontend/src/components/__tests__/DevQuickLogin.test.tsx new file mode 100644 index 00000000..1c960e98 --- /dev/null +++ b/frontend/src/components/__tests__/DevQuickLogin.test.tsx @@ -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 }) => ( + {children} + ); +}; + +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(, { 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(, { 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(, { 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(, { 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(, { wrapper: createWrapper() }); + + const minimizeButton = screen.getByText('×'); + expect(minimizeButton).toBeInTheDocument(); + }); + + it('should not render minimize button when embedded', () => { + render(, { wrapper: createWrapper() }); + + const minimizeButton = screen.queryByText('×'); + expect(minimizeButton).not.toBeInTheDocument(); + }); + + it('should render all user buttons', () => { + render(, { 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(, { wrapper: createWrapper() }); + + expect(screen.getByText(/Password for all:/i)).toBeInTheDocument(); + expect(screen.getByText('test123')).toBeInTheDocument(); + }); + + it('should render user roles as subtitles', () => { + render(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { wrapper: createWrapper() }); + + const buttons = screen.getAllByRole('button').filter((b) => !b.textContent?.includes('×')); + expect(buttons.length).toBeGreaterThan(0); + }); + + it('should have descriptive button text', () => { + render(, { 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(, { 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(, { 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', + }); + }); + }); + }); +}); diff --git a/frontend/src/components/__tests__/EmailTemplateSelector.test.tsx b/frontend/src/components/__tests__/EmailTemplateSelector.test.tsx new file mode 100644 index 00000000..de129ff8 --- /dev/null +++ b/frontend/src/components/__tests__/EmailTemplateSelector.test.tsx @@ -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: () =>
, + Mail: () =>
, +})); + +describe('EmailTemplateSelector', () => { + const defaultProps = { + value: undefined, + onChange: vi.fn(), + }; + + describe('Component Rendering', () => { + it('renders the component successfully', () => { + const { container } = render(); + expect(container).toBeInTheDocument(); + }); + + it('renders deprecation notice with warning icon', () => { + render(); + + const alertIcon = screen.getByTestId('alert-triangle-icon'); + expect(alertIcon).toBeInTheDocument(); + }); + + it('renders mail icon in the disabled selector', () => { + render(); + + const mailIcon = screen.getByTestId('mail-icon'); + expect(mailIcon).toBeInTheDocument(); + }); + + it('renders deprecation title', () => { + render(); + + expect(screen.getByText('Custom Email Templates Deprecated')).toBeInTheDocument(); + }); + + it('renders deprecation message', () => { + render(); + + expect( + screen.getByText(/Custom email templates have been replaced with system email templates/) + ).toBeInTheDocument(); + }); + + it('renders disabled select element', () => { + render(); + + const select = screen.getByRole('combobox'); + expect(select).toBeInTheDocument(); + expect(select).toBeDisabled(); + }); + + it('renders disabled option text', () => { + render(); + + expect(screen.getByText('Custom templates no longer available')).toBeInTheDocument(); + }); + }); + + describe('Props Handling', () => { + it('accepts value prop without errors', () => { + render(); + + // Component should render without errors + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + + it('accepts string value prop', () => { + render(); + + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + + it('accepts undefined value prop', () => { + render(); + + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + + it('accepts category prop without errors', () => { + render(); + + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + + it('accepts placeholder prop without errors', () => { + render(); + + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + + it('accepts required prop without errors', () => { + render(); + + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + + it('accepts disabled prop without errors', () => { + render(); + + // Selector is always disabled due to deprecation + expect(screen.getByRole('combobox')).toBeDisabled(); + }); + + it('applies custom className', () => { + const { container } = render( + + ); + + const wrapper = container.firstChild as HTMLElement; + expect(wrapper).toHaveClass('custom-test-class'); + }); + + it('applies multiple classes correctly', () => { + const { container } = render( + + ); + + 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(); + + const warningBox = container.querySelector('.bg-amber-50'); + expect(warningBox).toBeInTheDocument(); + }); + + it('applies warning border color', () => { + const { container } = render(); + + const warningBox = container.querySelector('.border-amber-200'); + expect(warningBox).toBeInTheDocument(); + }); + + it('applies dark mode warning background', () => { + const { container } = render(); + + const warningBox = container.querySelector('.dark\\:bg-amber-900\\/20'); + expect(warningBox).toBeInTheDocument(); + }); + + it('applies dark mode warning border', () => { + const { container } = render(); + + const warningBox = container.querySelector('.dark\\:border-amber-800'); + expect(warningBox).toBeInTheDocument(); + }); + }); + + describe('Disabled Selector Styling', () => { + it('applies opacity to disabled selector', () => { + const { container } = render(); + + const selectorWrapper = container.querySelector('.opacity-50'); + expect(selectorWrapper).toBeInTheDocument(); + }); + + it('applies pointer-events-none to disabled selector', () => { + const { container } = render(); + + const selectorWrapper = container.querySelector('.pointer-events-none'); + expect(selectorWrapper).toBeInTheDocument(); + }); + + it('applies disabled cursor style', () => { + render(); + + const select = screen.getByRole('combobox'); + expect(select).toHaveClass('cursor-not-allowed'); + }); + + it('applies gray background to disabled select', () => { + render(); + + const select = screen.getByRole('combobox'); + expect(select).toHaveClass('bg-gray-100'); + }); + + it('applies gray text color to disabled select', () => { + render(); + + const select = screen.getByRole('combobox'); + expect(select).toHaveClass('text-gray-500'); + }); + }); + + describe('Translation Strings', () => { + it('uses correct translation key for deprecation title', () => { + render(); + + // 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(); + + // 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(); + + // 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(); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('does not call onChange when component is re-rendered', () => { + const onChange = vi.fn(); + const { rerender } = render( + + ); + + rerender(); + + expect(onChange).not.toHaveBeenCalled(); + }); + }); + + describe('Component Structure', () => { + it('renders main wrapper with space-y-2 class', () => { + const { container } = render(); + + const wrapper = container.querySelector('.space-y-2'); + expect(wrapper).toBeInTheDocument(); + }); + + it('renders warning box with flex layout', () => { + const { container } = render(); + + const warningBox = container.querySelector('.flex.items-start'); + expect(warningBox).toBeInTheDocument(); + }); + + it('renders warning box with gap between icon and text', () => { + const { container } = render(); + + const warningBox = container.querySelector('.gap-3'); + expect(warningBox).toBeInTheDocument(); + }); + + it('renders warning icon', () => { + render(); + + const alertIcon = screen.getByTestId('alert-triangle-icon'); + expect(alertIcon).toBeInTheDocument(); + }); + + it('renders mail icon', () => { + render(); + + const mailIcon = screen.getByTestId('mail-icon'); + expect(mailIcon).toBeInTheDocument(); + }); + }); + + describe('Accessibility', () => { + it('renders select with combobox role', () => { + render(); + + const select = screen.getByRole('combobox'); + expect(select).toBeInTheDocument(); + }); + + it('indicates disabled state for screen readers', () => { + render(); + + const select = screen.getByRole('combobox'); + expect(select).toHaveAttribute('disabled'); + }); + + it('renders visible deprecation notice for screen readers', () => { + render(); + + // 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(); + + 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(); + + 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(); + }).not.toThrow(); + }); + + it('handles all props together', () => { + render( + + ); + + expect(screen.getByRole('combobox')).toBeInTheDocument(); + expect(screen.getByText('Custom Email Templates Deprecated')).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/__tests__/LanguageSelector.test.tsx b/frontend/src/components/__tests__/LanguageSelector.test.tsx index ba5c1a35..76bc0771 100644 --- a/frontend/src/components/__tests__/LanguageSelector.test.tsx +++ b/frontend/src/components/__tests__/LanguageSelector.test.tsx @@ -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(); @@ -63,6 +70,71 @@ describe('LanguageSelector', () => { const { container } = render(); expect(container.firstChild).toHaveClass('custom-class'); }); + + it('changes language when clicking a language option in dropdown', () => { + render(); + 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(); + 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(); + 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(); + 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(); + 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(); expect(screen.getByText(/🇺🇸/)).toBeInTheDocument(); }); + + it('changes language when clicking a language button', () => { + render(); + + 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(); + + // 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(); + + // 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(); + expect(container.firstChild).toHaveClass('custom-inline-class'); + }); }); }); diff --git a/frontend/src/components/__tests__/QuickAddAppointment.test.tsx b/frontend/src/components/__tests__/QuickAddAppointment.test.tsx new file mode 100644 index 00000000..f44007e8 --- /dev/null +++ b/frontend/src/components/__tests__/QuickAddAppointment.test.tsx @@ -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(); + expect(screen.getByText('Quick Add Appointment')).toBeInTheDocument(); + }); + + it('should render all form fields', () => { + render(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + const submitButton = screen.getByRole('button', { name: /Add Appointment/i }); + expect(submitButton).toBeInTheDocument(); + }); + + it('should set default date to today', () => { + render(); + + 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(); + + const timeSelect = getSelectByLabel('Time *'); + expect(timeSelect.value).toBe('09:00'); + }); + + it('should render notes textarea', () => { + render(); + + 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(); + + 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(); + + 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(); + + const serviceSelect = getSelectByLabel('Service *'); + expect(serviceSelect).toHaveAttribute('required'); + }); + + it('should mark date field as required', () => { + render(); + + const dateInput = getInputByLabel('Date *', 'date'); + expect(dateInput).toHaveAttribute('required'); + }); + + it('should mark time field as required', () => { + render(); + + const timeSelect = getSelectByLabel('Time *'); + expect(timeSelect).toHaveAttribute('required'); + }); + + it('should set minimum date to today', () => { + render(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + // 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + // 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(); + + 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(); + + // 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + expect(screen.getByText('Quick Add Appointment')).toBeInTheDocument(); + }); + }); + + describe('Accessibility', () => { + it('should have proper form structure', () => { + const { container } = render(); + + const form = container.querySelector('form'); + expect(form).toBeInTheDocument(); + }); + + it('should have accessible submit button', () => { + render(); + + 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(); + + // 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(''); + }); + }); + }); +}); diff --git a/frontend/src/components/__tests__/ResourceDetailModal.test.tsx b/frontend/src/components/__tests__/ResourceDetailModal.test.tsx new file mode 100644 index 00000000..71989c43 --- /dev/null +++ b/frontend/src/components/__tests__/ResourceDetailModal.test.tsx @@ -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 }) =>
{children}
, +})); + +// Mock Google Maps API +vi.mock('@react-google-maps/api', () => ({ + useJsApiLoader: vi.fn(() => ({ + isLoaded: false, + loadError: null, + })), + GoogleMap: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + 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 }) => ( + {children} + ); +}; + +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(, { + wrapper: createWrapper(), + }); + + expect(screen.getByText('John Smith')).toBeInTheDocument(); + expect(screen.getByText('Staff Member')).toBeInTheDocument(); + }); + + it('renders inside Portal', () => { + render(, { + wrapper: createWrapper(), + }); + + expect(screen.getByTestId('portal')).toBeInTheDocument(); + }); + + it('displays Current Location heading', () => { + render(, { + wrapper: createWrapper(), + }); + + expect(screen.getByText('Current Location')).toBeInTheDocument(); + }); + }); + + describe('Close functionality', () => { + it('calls onClose when X button is clicked', () => { + render(, { + 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(, { + 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(, { + 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(, { + 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(, { + 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(, { + 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(, { + 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(, { + 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(, { + 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(, { + 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(, { + 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(, { + 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(, { + 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( + , + { 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(, { + 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(, { + 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(, { + 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(, { + 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(, { + 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(, { + 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(, { + 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(, { + 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(, { + 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(, { + 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(, { + 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(, { + 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(, { + 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(, { + 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(, { + 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(, { + wrapper: createWrapper(), + }); + + const srOnly = document.querySelector('.sr-only'); + expect(srOnly?.textContent).toBe('common.close'); + }); + + it('renders with proper heading hierarchy', () => { + render(, { + wrapper: createWrapper(), + }); + + const heading = screen.getByRole('heading', { level: 3 }); + expect(heading).toHaveTextContent('John Smith'); + }); + }); +}); diff --git a/frontend/src/components/__tests__/StaffPermissions.test.tsx b/frontend/src/components/__tests__/StaffPermissions.test.tsx new file mode 100644 index 00000000..b35262fb --- /dev/null +++ b/frontend/src/components/__tests__/StaffPermissions.test.tsx @@ -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); + }); + }); + }); +}); diff --git a/frontend/src/components/__tests__/UserProfileDropdown.test.tsx b/frontend/src/components/__tests__/UserProfileDropdown.test.tsx new file mode 100644 index 00000000..532b23a3 --- /dev/null +++ b/frontend/src/components/__tests__/UserProfileDropdown.test.tsx @@ -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'); + }); + }); +}); diff --git a/frontend/src/components/booking/__tests__/ManualSchedulingRequest.test.tsx b/frontend/src/components/booking/__tests__/ManualSchedulingRequest.test.tsx new file mode 100644 index 00000000..bfa126f4 --- /dev/null +++ b/frontend/src/components/booking/__tests__/ManualSchedulingRequest.test.tsx @@ -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: () => , + Calendar: () => , + Clock: () => , + 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(); + }); + + it('shows phone icon', () => { + render(React.createElement(ManualSchedulingRequest, defaultProps)); + expect(screen.getByTestId('icon-phone')).toBeInTheDocument(); + }); + + it('shows preferred time section when capture_preferred_time is true', () => { + render(React.createElement(ManualSchedulingRequest, defaultProps)); + expect(screen.getByText('I have a preferred time')).toBeInTheDocument(); + }); + + it('hides preferred time section when capture_preferred_time is false', () => { + const props = { ...defaultProps, service: mockServiceNoPreferredTime }; + render(React.createElement(ManualSchedulingRequest, props)); + expect(screen.queryByText('I have a preferred time')).not.toBeInTheDocument(); + }); + + it('shows checkbox for preferred time', () => { + render(React.createElement(ManualSchedulingRequest, defaultProps)); + const checkbox = screen.getByText('I have a preferred time').closest('div[class*="cursor-pointer"]'); + expect(checkbox).toBeInTheDocument(); + }); + + it('toggles preferred time inputs when checkbox is clicked', () => { + render(React.createElement(ManualSchedulingRequest, defaultProps)); + + // Initially hidden + expect(screen.queryByPlaceholderText('e.g., Morning, After 2pm, Weekends only')).not.toBeInTheDocument(); + + // Click to show + const checkbox = screen.getByText('I have a preferred time').closest('div[class*="cursor-pointer"]'); + fireEvent.click(checkbox!); + + // Now visible + expect(screen.getByPlaceholderText('e.g., Morning, After 2pm, Weekends only')).toBeInTheDocument(); + }); + + it('displays date input when preferred time is enabled', () => { + render(React.createElement(ManualSchedulingRequest, defaultProps)); + + const checkbox = screen.getByText('I have a preferred time').closest('div[class*="cursor-pointer"]'); + fireEvent.click(checkbox!); + + const dateInput = document.querySelector('input[type="date"]'); + expect(dateInput).toBeInTheDocument(); + }); + + it('displays time notes input when preferred time is enabled', () => { + render(React.createElement(ManualSchedulingRequest, defaultProps)); + + const checkbox = screen.getByText('I have a preferred time').closest('div[class*="cursor-pointer"]'); + fireEvent.click(checkbox!); + + expect(screen.getByPlaceholderText('e.g., Morning, After 2pm, Weekends only')).toBeInTheDocument(); + }); + + it('calls onPreferredTimeChange with null when toggling off', () => { + const onPreferredTimeChange = vi.fn(); + const props = { ...defaultProps, onPreferredTimeChange, preferredDate: '2024-12-20', preferredTimeNotes: 'Morning' }; + + render(React.createElement(ManualSchedulingRequest, props)); + + // Should be enabled initially + const checkbox = screen.getByText('I have a preferred time').closest('div[class*="cursor-pointer"]'); + fireEvent.click(checkbox!); + + expect(onPreferredTimeChange).toHaveBeenCalledWith(null, ''); + }); + + it('calls onPreferredTimeChange when date changes', () => { + const onPreferredTimeChange = vi.fn(); + const props = { ...defaultProps, onPreferredTimeChange }; + + render(React.createElement(ManualSchedulingRequest, props)); + + const checkbox = screen.getByText('I have a preferred time').closest('div[class*="cursor-pointer"]'); + fireEvent.click(checkbox!); + + const dateInput = document.querySelector('input[type="date"]') as HTMLInputElement; + fireEvent.change(dateInput, { target: { value: '2024-12-25' } }); + + expect(onPreferredTimeChange).toHaveBeenCalledWith('2024-12-25', ''); + }); + + it('calls onPreferredTimeChange when notes change', () => { + const onPreferredTimeChange = vi.fn(); + const props = { ...defaultProps, onPreferredTimeChange }; + + render(React.createElement(ManualSchedulingRequest, props)); + + const checkbox = screen.getByText('I have a preferred time').closest('div[class*="cursor-pointer"]'); + fireEvent.click(checkbox!); + + const notesInput = screen.getByPlaceholderText('e.g., Morning, After 2pm, Weekends only'); + fireEvent.change(notesInput, { target: { value: 'Afternoon preferred' } }); + + expect(onPreferredTimeChange).toHaveBeenCalledWith(null, 'Afternoon preferred'); + }); + + it('shows calendar icon when inputs are visible', () => { + render(React.createElement(ManualSchedulingRequest, defaultProps)); + + const checkbox = screen.getByText('I have a preferred time').closest('div[class*="cursor-pointer"]'); + fireEvent.click(checkbox!); + + expect(screen.getByTestId('icon-calendar')).toBeInTheDocument(); + }); + + it('shows clock icon when inputs are visible', () => { + render(React.createElement(ManualSchedulingRequest, defaultProps)); + + const checkbox = screen.getByText('I have a preferred time').closest('div[class*="cursor-pointer"]'); + fireEvent.click(checkbox!); + + expect(screen.getByTestId('icon-clock')).toBeInTheDocument(); + }); + + it('displays "What happens next?" section', () => { + render(React.createElement(ManualSchedulingRequest, defaultProps)); + expect(screen.getByText('What happens next?')).toBeInTheDocument(); + }); + + it('displays three steps in "What happens next?"', () => { + render(React.createElement(ManualSchedulingRequest, defaultProps)); + expect(screen.getByText('Complete your booking request')).toBeInTheDocument(); + expect(screen.getByText("We'll call you within 24 hours to schedule")).toBeInTheDocument(); + expect(screen.getByText('Confirm your appointment time over the phone')).toBeInTheDocument(); + }); + + it('sets minimum date to tomorrow', () => { + render(React.createElement(ManualSchedulingRequest, defaultProps)); + + const checkbox = screen.getByText('I have a preferred time').closest('div[class*="cursor-pointer"]'); + fireEvent.click(checkbox!); + + const dateInput = document.querySelector('input[type="date"]') as HTMLInputElement; + expect(dateInput).toHaveAttribute('min'); + + const minDate = dateInput.getAttribute('min'); + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + const expectedMin = tomorrow.toISOString().split('T')[0]; + + expect(minDate).toBe(expectedMin); + }); + + it('shows check icon when preferred time is selected', () => { + render(React.createElement(ManualSchedulingRequest, defaultProps)); + + const checkbox = screen.getByText('I have a preferred time').closest('div[class*="cursor-pointer"]'); + fireEvent.click(checkbox!); + + expect(screen.getByTestId('icon-check')).toBeInTheDocument(); + }); + + it('displays preferred date label', () => { + render(React.createElement(ManualSchedulingRequest, defaultProps)); + + const checkbox = screen.getByText('I have a preferred time').closest('div[class*="cursor-pointer"]'); + fireEvent.click(checkbox!); + + expect(screen.getByText('Preferred Date')).toBeInTheDocument(); + }); + + it('displays time preference label', () => { + render(React.createElement(ManualSchedulingRequest, defaultProps)); + + const checkbox = screen.getByText('I have a preferred time').closest('div[class*="cursor-pointer"]'); + fireEvent.click(checkbox!); + + expect(screen.getByText('Time Preference')).toBeInTheDocument(); + }); + + it('displays helper text for time preference', () => { + render(React.createElement(ManualSchedulingRequest, defaultProps)); + + const checkbox = screen.getByText('I have a preferred time').closest('div[class*="cursor-pointer"]'); + fireEvent.click(checkbox!); + + expect(screen.getByText('Any general time preferences that would work for you')).toBeInTheDocument(); + }); + + it('highlights checkbox area when preferred time is selected', () => { + render(React.createElement(ManualSchedulingRequest, defaultProps)); + + const checkboxArea = screen.getByText('I have a preferred time').closest('div[class*="cursor-pointer"]'); + + // Not highlighted initially + expect(checkboxArea).not.toHaveClass('border-blue-500'); + + fireEvent.click(checkboxArea!); + + // Highlighted after click + expect(checkboxArea).toHaveClass('border-blue-500'); + }); + + it('preserves existing date when notes change', () => { + const onPreferredTimeChange = vi.fn(); + const props = { ...defaultProps, onPreferredTimeChange, preferredDate: '2024-12-20' }; + + render(React.createElement(ManualSchedulingRequest, props)); + + // Inputs should already be visible since preferredDate is set + const notesInput = screen.getByPlaceholderText('e.g., Morning, After 2pm, Weekends only'); + fireEvent.change(notesInput, { target: { value: 'Morning' } }); + + expect(onPreferredTimeChange).toHaveBeenCalledWith('2024-12-20', 'Morning'); + }); + + it('preserves existing notes when date changes', () => { + const onPreferredTimeChange = vi.fn(); + const props = { ...defaultProps, onPreferredTimeChange, preferredTimeNotes: 'Morning' }; + + render(React.createElement(ManualSchedulingRequest, props)); + + // Inputs should already be visible since preferredTimeNotes is set + const dateInput = document.querySelector('input[type="date"]') as HTMLInputElement; + expect(dateInput).toBeTruthy(); + + fireEvent.change(dateInput, { target: { value: '2024-12-25' } }); + + expect(onPreferredTimeChange).toHaveBeenCalledWith('2024-12-25', 'Morning'); + }); + + it('initializes with preferred time enabled when date is set', () => { + const props = { ...defaultProps, preferredDate: '2024-12-20' }; + render(React.createElement(ManualSchedulingRequest, props)); + + // Should show inputs immediately + expect(screen.getByPlaceholderText('e.g., Morning, After 2pm, Weekends only')).toBeInTheDocument(); + }); + + it('initializes with preferred time enabled when notes are set', () => { + const props = { ...defaultProps, preferredTimeNotes: 'Morning preferred' }; + render(React.createElement(ManualSchedulingRequest, props)); + + // Should show inputs immediately + expect(screen.getByPlaceholderText('e.g., Morning, After 2pm, Weekends only')).toBeInTheDocument(); + }); + + it('displays step numbers in order', () => { + render(React.createElement(ManualSchedulingRequest, defaultProps)); + + const stepNumbers = screen.getAllByText(/^[123]$/); + expect(stepNumbers).toHaveLength(3); + }); +}); diff --git a/frontend/src/components/booking/__tests__/PaymentSection.test.tsx b/frontend/src/components/booking/__tests__/PaymentSection.test.tsx new file mode 100644 index 00000000..c8462ca8 --- /dev/null +++ b/frontend/src/components/booking/__tests__/PaymentSection.test.tsx @@ -0,0 +1,229 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import React from 'react'; +import { PaymentSection } from '../PaymentSection'; + +// Mock Lucide icons +vi.mock('lucide-react', () => ({ + CreditCard: () => , + ShieldCheck: () => , + Lock: () => , +})); + +describe('PaymentSection', () => { + const mockService = { + id: 1, + name: 'Haircut', + description: 'A professional haircut', + duration: 30, + price_cents: 2500, + photos: [], + deposit_amount_cents: 0, + }; + + const mockServiceWithDeposit = { + ...mockService, + deposit_amount_cents: 1000, + }; + + const defaultProps = { + service: mockService, + onPaymentComplete: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders payment form', () => { + render(React.createElement(PaymentSection, defaultProps)); + expect(screen.getByText('Card Details')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('0000 0000 0000 0000')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('MM / YY')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('123')).toBeInTheDocument(); + }); + + it('displays service total price', () => { + render(React.createElement(PaymentSection, defaultProps)); + expect(screen.getByText('Service Total')).toBeInTheDocument(); + const prices = screen.getAllByText('$25.00'); + expect(prices.length).toBeGreaterThan(0); + }); + + it('displays tax line item', () => { + render(React.createElement(PaymentSection, defaultProps)); + expect(screen.getByText('Tax (Estimated)')).toBeInTheDocument(); + expect(screen.getByText('$0.00')).toBeInTheDocument(); + }); + + it('displays total amount', () => { + render(React.createElement(PaymentSection, defaultProps)); + const totals = screen.getAllByText('$25.00'); + expect(totals.length).toBeGreaterThan(0); + }); + + it('formats card number input with spaces', () => { + render(React.createElement(PaymentSection, defaultProps)); + const cardInput = screen.getByPlaceholderText('0000 0000 0000 0000') as HTMLInputElement; + + fireEvent.change(cardInput, { target: { value: '4242424242424242' } }); + expect(cardInput.value).toBe('4242 4242 4242 4242'); + }); + + it('limits card number to 16 digits', () => { + render(React.createElement(PaymentSection, defaultProps)); + const cardInput = screen.getByPlaceholderText('0000 0000 0000 0000') as HTMLInputElement; + + fireEvent.change(cardInput, { target: { value: '42424242424242421234' } }); + expect(cardInput.value).toBe('4242 4242 4242 4242'); + }); + + it('removes non-digits from card input', () => { + render(React.createElement(PaymentSection, defaultProps)); + const cardInput = screen.getByPlaceholderText('0000 0000 0000 0000') as HTMLInputElement; + + fireEvent.change(cardInput, { target: { value: '4242-4242-4242-4242' } }); + expect(cardInput.value).toBe('4242 4242 4242 4242'); + }); + + it('handles expiry date input', () => { + render(React.createElement(PaymentSection, defaultProps)); + const expiryInput = screen.getByPlaceholderText('MM / YY') as HTMLInputElement; + + fireEvent.change(expiryInput, { target: { value: '12/25' } }); + expect(expiryInput.value).toBe('12/25'); + }); + + it('handles CVC input', () => { + render(React.createElement(PaymentSection, defaultProps)); + const cvcInput = screen.getByPlaceholderText('123') as HTMLInputElement; + + fireEvent.change(cvcInput, { target: { value: '123' } }); + expect(cvcInput.value).toBe('123'); + }); + + it('shows confirm booking button when no deposit', () => { + render(React.createElement(PaymentSection, defaultProps)); + expect(screen.getByRole('button', { name: 'Confirm Booking' })).toBeInTheDocument(); + }); + + it('shows deposit amount button when deposit required', () => { + render(React.createElement(PaymentSection, { ...defaultProps, service: mockServiceWithDeposit })); + expect(screen.getByRole('button', { name: 'Pay $10.00 Deposit' })).toBeInTheDocument(); + }); + + it('displays deposit amount section when deposit required', () => { + render(React.createElement(PaymentSection, { ...defaultProps, service: mockServiceWithDeposit })); + expect(screen.getByText('Due Now (Deposit)')).toBeInTheDocument(); + const depositAmounts = screen.getAllByText('$10.00'); + expect(depositAmounts.length).toBeGreaterThan(0); + expect(screen.getByText('Due at appointment')).toBeInTheDocument(); + expect(screen.getByText('$15.00')).toBeInTheDocument(); + }); + + it('displays full payment message when no deposit', () => { + render(React.createElement(PaymentSection, defaultProps)); + expect(screen.getByText(/Full payment will be collected at your appointment/)).toBeInTheDocument(); + }); + + it('displays deposit message when deposit required', () => { + render(React.createElement(PaymentSection, { ...defaultProps, service: mockServiceWithDeposit })); + expect(screen.getByText(/A deposit of/)).toBeInTheDocument(); + expect(screen.getByText(/will be charged now/)).toBeInTheDocument(); + }); + + it('shows submit button text changes to processing', () => { + render(React.createElement(PaymentSection, defaultProps)); + const submitButton = screen.getByRole('button', { name: 'Confirm Booking' }); + expect(submitButton).not.toBeDisabled(); + }); + + it('simulates payment processing timeout', async () => { + const onPaymentComplete = vi.fn(); + render(React.createElement(PaymentSection, { ...defaultProps, onPaymentComplete })); + + // The component uses setTimeout with 2000ms + // Just verify the timeout is reasonable + expect(onPaymentComplete).not.toHaveBeenCalled(); + }); + + it('displays security message', () => { + render(React.createElement(PaymentSection, defaultProps)); + expect(screen.getByText(/Your payment is secure/)).toBeInTheDocument(); + expect(screen.getByText(/We use Stripe to process your payment/)).toBeInTheDocument(); + }); + + it('shows shield check icon', () => { + render(React.createElement(PaymentSection, defaultProps)); + expect(screen.getByTestId('icon-shield-check')).toBeInTheDocument(); + }); + + it('shows credit card icon', () => { + render(React.createElement(PaymentSection, defaultProps)); + expect(screen.getByTestId('icon-credit-card')).toBeInTheDocument(); + }); + + it('shows lock icon for CVC field', () => { + render(React.createElement(PaymentSection, defaultProps)); + expect(screen.getByTestId('icon-lock')).toBeInTheDocument(); + }); + + it('displays payment summary section', () => { + render(React.createElement(PaymentSection, defaultProps)); + expect(screen.getByText('Payment Summary')).toBeInTheDocument(); + }); + + it('requires all form fields', () => { + const onPaymentComplete = vi.fn(); + render(React.createElement(PaymentSection, { ...defaultProps, onPaymentComplete })); + + const cardInput = screen.getByPlaceholderText('0000 0000 0000 0000'); + const expiryInput = screen.getByPlaceholderText('MM / YY'); + const cvcInput = screen.getByPlaceholderText('123'); + + expect(cardInput).toHaveAttribute('required'); + expect(expiryInput).toHaveAttribute('required'); + expect(cvcInput).toHaveAttribute('required'); + }); + + it('calculates deposit correctly', () => { + const service = { ...mockService, deposit_amount_cents: 500 }; + render(React.createElement(PaymentSection, { ...defaultProps, service })); + + const amounts = screen.getAllByText('$5.00'); + expect(amounts.length).toBeGreaterThan(0); + }); + + it('displays mock card icons', () => { + render(React.createElement(PaymentSection, defaultProps)); + const mockCardIcons = document.querySelectorAll('.bg-gray-200.dark\\:bg-gray-600.rounded'); + expect(mockCardIcons.length).toBeGreaterThanOrEqual(3); + }); + + it('handles large prices correctly', () => { + const expensiveService = { ...mockService, price_cents: 1000000 }; // $10,000 + render(React.createElement(PaymentSection, { ...defaultProps, service: expensiveService })); + const prices = screen.getAllByText('$10000.00'); + expect(prices.length).toBeGreaterThan(0); + }); + + it('handles zero deposit', () => { + const service = { ...mockService, deposit_amount_cents: 0 }; + render(React.createElement(PaymentSection, { ...defaultProps, service })); + expect(screen.queryByText('Due Now (Deposit)')).not.toBeInTheDocument(); + }); + + it('has disabled state for button during processing', () => { + render(React.createElement(PaymentSection, defaultProps)); + const submitButton = screen.getByRole('button', { name: 'Confirm Booking' }); + + // Initially enabled + expect(submitButton).not.toBeDisabled(); + // Button will be disabled when processing state is true + }); +}); diff --git a/frontend/src/components/dashboard/__tests__/OpenTicketsWidget.test.tsx b/frontend/src/components/dashboard/__tests__/OpenTicketsWidget.test.tsx new file mode 100644 index 00000000..6c0244dc --- /dev/null +++ b/frontend/src/components/dashboard/__tests__/OpenTicketsWidget.test.tsx @@ -0,0 +1,492 @@ +/** + * Unit tests for OpenTicketsWidget component + * + * Tests cover: + * - Component rendering with tickets + * - Empty state when no tickets + * - Urgent ticket badge display + * - Ticket filtering (open/in_progress only) + * - Priority color coding + * - Overdue ticket handling + * - Link navigation + * - Edit mode controls + * - Internationalization (i18n) + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { BrowserRouter } from 'react-router-dom'; +import OpenTicketsWidget from '../OpenTicketsWidget'; +import { Ticket } from '../../../types'; + +// Mock react-i18next +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: any) => { + const translations: Record = { + 'dashboard.openTickets': 'Open Tickets', + 'dashboard.urgent': 'Urgent', + 'dashboard.open': 'Open', + 'dashboard.noOpenTickets': 'No open tickets', + 'dashboard.overdue': 'Overdue', + 'dashboard.viewAllTickets': `View all ${options?.count || 0} tickets`, + }; + return translations[key] || key; + }, + }), +})); + +// Mock useDateFnsLocale hook +vi.mock('../../../hooks/useDateFnsLocale', () => ({ + useDateFnsLocale: () => undefined, // Returns undefined for default locale +})); + +// Helper to render component with Router +const renderWithRouter = (component: React.ReactElement) => { + return render({component}); +}; + +describe('OpenTicketsWidget', () => { + const mockTickets: Ticket[] = [ + { + id: '1', + tenant: 'test-tenant', + creator: 'user-1', + creatorEmail: 'user1@example.com', + creatorFullName: 'John Doe', + ticketType: 'support', + status: 'open', + priority: 'urgent', + subject: 'Critical bug in scheduler', + description: 'System is down', + category: 'bug', + createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), // 2 hours ago + updatedAt: new Date().toISOString(), + isOverdue: false, + }, + { + id: '2', + tenant: 'test-tenant', + creator: 'user-2', + creatorEmail: 'user2@example.com', + creatorFullName: 'Jane Smith', + ticketType: 'support', + status: 'in_progress', + priority: 'high', + subject: 'Payment integration issue', + description: 'Stripe webhook failing', + category: 'bug', + createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), // 1 day ago + updatedAt: new Date().toISOString(), + isOverdue: false, + }, + { + id: '3', + tenant: 'test-tenant', + creator: 'user-3', + creatorEmail: 'user3@example.com', + creatorFullName: 'Bob Johnson', + ticketType: 'support', + status: 'closed', + priority: 'low', + subject: 'Closed ticket', + description: 'This should not appear', + category: 'question', + createdAt: new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString(), + updatedAt: new Date().toISOString(), + }, + { + id: '4', + tenant: 'test-tenant', + creator: 'user-4', + creatorEmail: 'user4@example.com', + creatorFullName: 'Alice Williams', + ticketType: 'support', + status: 'open', + priority: 'medium', + subject: 'Overdue ticket', + description: 'This ticket is overdue', + category: 'bug', + createdAt: new Date(Date.now() - 72 * 60 * 60 * 1000).toISOString(), + updatedAt: new Date().toISOString(), + isOverdue: true, + }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Rendering', () => { + it('should render the component', () => { + renderWithRouter(); + expect(screen.getByText('Open Tickets')).toBeInTheDocument(); + }); + + it('should render title correctly', () => { + renderWithRouter(); + const title = screen.getByText('Open Tickets'); + expect(title).toBeInTheDocument(); + expect(title).toHaveClass('text-lg', 'font-semibold'); + }); + + it('should render open ticket count', () => { + renderWithRouter(); + // 3 open/in_progress tickets (excluding closed) + expect(screen.getByText('3 Open')).toBeInTheDocument(); + }); + }); + + describe('Ticket Filtering', () => { + it('should only show open and in_progress tickets', () => { + renderWithRouter(); + + // Should show these + expect(screen.getByText('Critical bug in scheduler')).toBeInTheDocument(); + expect(screen.getByText('Payment integration issue')).toBeInTheDocument(); + expect(screen.getByText('Overdue ticket')).toBeInTheDocument(); + + // Should NOT show closed ticket + expect(screen.queryByText('Closed ticket')).not.toBeInTheDocument(); + }); + + it('should count urgent and overdue tickets', () => { + renderWithRouter(); + + // 1 urgent + 1 overdue = 2 urgent total + expect(screen.getByText('2 Urgent')).toBeInTheDocument(); + }); + + it('should handle tickets with only closed status', () => { + const closedTickets: Ticket[] = [ + { + ...mockTickets[2], + status: 'closed', + }, + ]; + + renderWithRouter(); + expect(screen.getByText('No open tickets')).toBeInTheDocument(); + }); + }); + + describe('Priority Display', () => { + it('should display urgent priority correctly', () => { + renderWithRouter(); + + // Urgent priority text appears in the badge (multiple instances possible) + const urgentElements = screen.getAllByText(/Urgent/i); + expect(urgentElements.length).toBeGreaterThan(0); + }); + + it('should display high priority', () => { + renderWithRouter(); + const highElements = screen.getAllByText('high'); + expect(highElements.length).toBeGreaterThan(0); + }); + + it('should display medium priority', () => { + renderWithRouter(); + const mediumElements = screen.getAllByText('medium'); + expect(mediumElements.length).toBeGreaterThan(0); + }); + + it('should display overdue status instead of priority', () => { + renderWithRouter(); + + // Overdue ticket should show "Overdue" instead of priority + const overdueElements = screen.getAllByText('Overdue'); + expect(overdueElements.length).toBeGreaterThan(0); + }); + }); + + describe('Empty State', () => { + it('should show empty state when no tickets', () => { + renderWithRouter(); + expect(screen.getByText('No open tickets')).toBeInTheDocument(); + }); + + it('should show empty state icon when no tickets', () => { + const { container } = renderWithRouter(); + + // Check for AlertCircle icon in empty state + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + }); + + it('should not show urgent badge when no tickets', () => { + renderWithRouter(); + expect(screen.queryByText(/Urgent/)).not.toBeInTheDocument(); + }); + }); + + describe('Ticket List Display', () => { + it('should limit display to 5 tickets', () => { + const manyTickets: Ticket[] = Array.from({ length: 10 }, (_, i) => ({ + ...mockTickets[0], + id: `ticket-${i}`, + subject: `Ticket ${i + 1}`, + status: 'open' as const, + })); + + renderWithRouter(); + + // Should show first 5 tickets + expect(screen.getByText('Ticket 1')).toBeInTheDocument(); + expect(screen.getByText('Ticket 5')).toBeInTheDocument(); + + // Should NOT show 6th ticket + expect(screen.queryByText('Ticket 6')).not.toBeInTheDocument(); + }); + + it('should show "View all" link when more than 5 tickets', () => { + const manyTickets: Ticket[] = Array.from({ length: 7 }, (_, i) => ({ + ...mockTickets[0], + id: `ticket-${i}`, + status: 'open' as const, + })); + + renderWithRouter(); + + // Should show link to view all 7 tickets + expect(screen.getByText('View all 7 tickets')).toBeInTheDocument(); + }); + + it('should not show "View all" link when 5 or fewer tickets', () => { + const fewTickets = mockTickets.slice(0, 2); + renderWithRouter(); + + expect(screen.queryByText(/View all/)).not.toBeInTheDocument(); + }); + + it('should render timestamps for tickets', () => { + renderWithRouter(); + + // Should have timestamp elements (date-fns formatDistanceToNow renders relative times) + const timestamps = screen.getAllByText(/ago/i); + expect(timestamps.length).toBeGreaterThan(0); + }); + }); + + describe('Navigation Links', () => { + it('should render ticket items as links', () => { + renderWithRouter(); + + const links = screen.getAllByRole('link'); + expect(links.length).toBeGreaterThan(0); + }); + + it('should link to tickets dashboard', () => { + renderWithRouter(); + + const links = screen.getAllByRole('link'); + links.forEach(link => { + expect(link).toHaveAttribute('href', '/dashboard/tickets'); + }); + }); + + it('should have chevron icons on ticket links', () => { + const { container } = renderWithRouter(); + + // ChevronRight icons should be present + const svgs = container.querySelectorAll('svg'); + expect(svgs.length).toBeGreaterThan(0); + }); + }); + + describe('Edit Mode', () => { + it('should not show edit controls when isEditing is false', () => { + const { container } = renderWithRouter( + + ); + + const dragHandle = container.querySelector('.drag-handle'); + expect(dragHandle).not.toBeInTheDocument(); + }); + + it('should show drag handle when in edit mode', () => { + const { container } = renderWithRouter( + + ); + + const dragHandle = container.querySelector('.drag-handle'); + expect(dragHandle).toBeInTheDocument(); + }); + + it('should show remove button when in edit mode', () => { + const { container } = renderWithRouter( + + ); + + // Remove button exists (X icon button) + const removeButtons = container.querySelectorAll('button'); + expect(removeButtons.length).toBeGreaterThan(0); + }); + + it('should call onRemove when remove button is clicked', async () => { + const user = userEvent.setup(); + const handleRemove = vi.fn(); + + const { container } = renderWithRouter( + + ); + + // Find the remove button (X icon) + const removeButton = container.querySelector('button[class*="hover:text-red"]') as HTMLElement; + expect(removeButton).toBeInTheDocument(); + + await user.click(removeButton); + expect(handleRemove).toHaveBeenCalledTimes(1); + }); + + it('should apply padding when in edit mode', () => { + const { container } = renderWithRouter( + + ); + + const paddedElement = container.querySelector('.pl-5'); + expect(paddedElement).toBeInTheDocument(); + }); + + it('should not apply padding when not in edit mode', () => { + const { container } = renderWithRouter( + + ); + + // Title should not have pl-5 class + const title = screen.getByText('Open Tickets'); + expect(title.parentElement).not.toHaveClass('pl-5'); + }); + }); + + describe('Styling', () => { + it('should apply container styles', () => { + const { container } = renderWithRouter(); + + const widget = container.firstChild; + expect(widget).toHaveClass( + 'h-full', + 'p-4', + 'bg-white', + 'rounded-xl', + 'border', + 'border-gray-200', + 'shadow-sm' + ); + }); + + it('should apply dark mode styles', () => { + const { container } = renderWithRouter(); + + const widget = container.firstChild; + expect(widget).toHaveClass('dark:bg-gray-800', 'dark:border-gray-700'); + }); + + it('should apply priority background colors', () => { + const { container } = renderWithRouter(); + + // Check for various priority bg classes + const redBg = container.querySelector('.bg-red-50'); + const orangeBg = container.querySelector('.bg-orange-50'); + const yellowBg = container.querySelector('.bg-yellow-50'); + + // At least one priority bg should be present + expect(redBg || orangeBg || yellowBg).toBeTruthy(); + }); + }); + + describe('Urgent Badge', () => { + it('should show urgent badge when urgent tickets exist', () => { + renderWithRouter(); + + const urgentElements = screen.getAllByText(/Urgent/i); + expect(urgentElements.length).toBeGreaterThan(0); + }); + + it('should show correct urgent count', () => { + renderWithRouter(); + + // 1 urgent + 1 overdue + expect(screen.getByText('2 Urgent')).toBeInTheDocument(); + }); + + it('should not show urgent badge when no urgent tickets', () => { + const nonUrgentTickets: Ticket[] = [ + { + ...mockTickets[0], + priority: 'low', + isOverdue: false, + }, + ]; + + renderWithRouter(); + expect(screen.queryByText(/Urgent/)).not.toBeInTheDocument(); + }); + + it('should include overdue tickets in urgent count', () => { + const tickets: Ticket[] = [ + { + ...mockTickets[0], + priority: 'low', + isOverdue: true, + status: 'open', + }, + ]; + + renderWithRouter(); + expect(screen.getByText('1 Urgent')).toBeInTheDocument(); + }); + }); + + describe('Accessibility', () => { + it('should have semantic HTML structure', () => { + const { container } = renderWithRouter(); + + const headings = container.querySelectorAll('h3'); + expect(headings.length).toBeGreaterThan(0); + }); + + it('should have accessible links', () => { + renderWithRouter(); + + const links = screen.getAllByRole('link'); + expect(links.length).toBeGreaterThan(0); + }); + }); + + describe('Integration', () => { + it('should render correctly with all props', () => { + const handleRemove = vi.fn(); + + renderWithRouter( + + ); + + expect(screen.getByText('Open Tickets')).toBeInTheDocument(); + expect(screen.getByText('Critical bug in scheduler')).toBeInTheDocument(); + expect(screen.getByText(/Urgent/)).toBeInTheDocument(); + }); + + it('should handle mixed priority tickets', () => { + const mixedTickets: Ticket[] = [ + { ...mockTickets[0], priority: 'urgent', status: 'open' }, + { ...mockTickets[0], id: '2', priority: 'high', status: 'open' }, + { ...mockTickets[0], id: '3', priority: 'medium', status: 'in_progress' }, + { ...mockTickets[0], id: '4', priority: 'low', status: 'open' }, + ]; + + renderWithRouter(); + + expect(screen.getByText('urgent')).toBeInTheDocument(); + expect(screen.getByText('high')).toBeInTheDocument(); + expect(screen.getByText('medium')).toBeInTheDocument(); + expect(screen.getByText('low')).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/dashboard/__tests__/RecentActivityWidget.test.tsx b/frontend/src/components/dashboard/__tests__/RecentActivityWidget.test.tsx new file mode 100644 index 00000000..9e5ebb45 --- /dev/null +++ b/frontend/src/components/dashboard/__tests__/RecentActivityWidget.test.tsx @@ -0,0 +1,576 @@ +/** + * Unit tests for RecentActivityWidget component + * + * Tests cover: + * - Component rendering with appointments and customers + * - Empty state when no activity + * - Activity type filtering and display (booking, cancellation, completion, new customer) + * - Icon and styling for different activity types + * - Timestamp display with date-fns + * - Activity sorting (most recent first) + * - Activity limit (max 10 items) + * - Edit mode controls + * - Internationalization (i18n) + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import RecentActivityWidget from '../RecentActivityWidget'; +import { Appointment, Customer } from '../../../types'; + +// Mock react-i18next +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: any) => { + const translations: Record = { + 'dashboard.recentActivity': 'Recent Activity', + 'dashboard.noRecentActivity': 'No recent activity', + 'dashboard.newBooking': 'New Booking', + 'dashboard.customerBookedAppointment': `${options?.customerName || 'Customer'} booked an appointment`, + 'dashboard.cancellation': 'Cancellation', + 'dashboard.customerCancelledAppointment': `${options?.customerName || 'Customer'} cancelled appointment`, + 'dashboard.completed': 'Completed', + 'dashboard.customerAppointmentCompleted': `${options?.customerName || 'Customer'} appointment completed`, + 'dashboard.newCustomer': 'New Customer', + 'dashboard.customerSignedUp': `${options?.customerName || 'Customer'} signed up`, + }; + return translations[key] || key; + }, + }), +})); + +// Mock useDateFnsLocale hook +vi.mock('../../../hooks/useDateFnsLocale', () => ({ + useDateFnsLocale: () => undefined, +})); + +describe('RecentActivityWidget', () => { + const now = new Date(); + + const mockAppointments: Appointment[] = [ + { + id: '1', + resourceId: 'resource-1', + customerId: 'customer-1', + customerName: 'John Doe', + serviceId: 'service-1', + startTime: new Date(now.getTime() - 2 * 60 * 60 * 1000), // 2 hours ago + durationMinutes: 60, + status: 'CONFIRMED', + notes: '', + }, + { + id: '2', + resourceId: 'resource-1', + customerId: 'customer-2', + customerName: 'Jane Smith', + serviceId: 'service-1', + startTime: new Date(now.getTime() - 24 * 60 * 60 * 1000), // 1 day ago + durationMinutes: 90, + status: 'CANCELLED', + notes: '', + }, + { + id: '3', + resourceId: 'resource-2', + customerId: 'customer-3', + customerName: 'Bob Johnson', + serviceId: 'service-2', + startTime: new Date(now.getTime() - 48 * 60 * 60 * 1000), // 2 days ago + durationMinutes: 120, + status: 'COMPLETED', + notes: '', + }, + { + id: '4', + resourceId: 'resource-1', + customerId: 'customer-4', + customerName: 'Alice Williams', + serviceId: 'service-1', + startTime: new Date(now.getTime() - 5 * 60 * 60 * 1000), // 5 hours ago + durationMinutes: 45, + status: 'PENDING', + notes: '', + }, + ]; + + const mockCustomers: Customer[] = [ + { + id: 'customer-1', + name: 'New Customer One', + email: 'new1@example.com', + phone: '555-0001', + // No lastVisit = new customer + }, + { + id: 'customer-2', + name: 'Returning Customer', + email: 'returning@example.com', + phone: '555-0002', + lastVisit: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000), // 30 days ago + }, + { + id: 'customer-3', + name: 'New Customer Two', + email: 'new2@example.com', + phone: '555-0003', + // No lastVisit = new customer + }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Rendering', () => { + it('should render the component', () => { + render( + + ); + expect(screen.getByText('Recent Activity')).toBeInTheDocument(); + }); + + it('should render title correctly', () => { + render( + + ); + const title = screen.getByText('Recent Activity'); + expect(title).toBeInTheDocument(); + expect(title).toHaveClass('text-lg', 'font-semibold'); + }); + }); + + describe('Activity Types', () => { + it('should display booking activity for confirmed appointments', () => { + render( + + ); + expect(screen.getByText('New Booking')).toBeInTheDocument(); + expect(screen.getByText('John Doe booked an appointment')).toBeInTheDocument(); + }); + + it('should display booking activity for pending appointments', () => { + render( + + ); + expect(screen.getByText('Alice Williams booked an appointment')).toBeInTheDocument(); + }); + + it('should display cancellation activity', () => { + render( + + ); + expect(screen.getByText('Cancellation')).toBeInTheDocument(); + expect(screen.getByText('Jane Smith cancelled appointment')).toBeInTheDocument(); + }); + + it('should display completion activity', () => { + render( + + ); + expect(screen.getByText('Completed')).toBeInTheDocument(); + expect(screen.getByText('Bob Johnson appointment completed')).toBeInTheDocument(); + }); + + it('should display new customer activity', () => { + render( + + ); + expect(screen.getByText('New Customer')).toBeInTheDocument(); + expect(screen.getByText('New Customer One signed up')).toBeInTheDocument(); + }); + + it('should not display activity for returning customers', () => { + render( + + ); + // Returning Customer should not appear in activity + expect(screen.queryByText('Returning Customer signed up')).not.toBeInTheDocument(); + }); + }); + + describe('Activity Sorting and Limiting', () => { + it('should sort activities by timestamp descending', () => { + render( + + ); + + const activities = screen.getAllByText(/booked|cancelled|completed|signed up/i); + // Most recent should be first (John Doe - 2 hours ago) + expect(activities[0]).toHaveTextContent('John Doe'); + }); + + it('should limit display to 10 activities', () => { + const manyAppointments: Appointment[] = Array.from({ length: 15 }, (_, i) => ({ + ...mockAppointments[0], + id: `appt-${i}`, + customerName: `Customer ${i}`, + startTime: new Date(now.getTime() - i * 60 * 60 * 1000), + })); + + const { container } = render( + + ); + + // Count activity items (each has a unique key structure) + const activityItems = container.querySelectorAll('[class*="flex items-start gap-3"]'); + expect(activityItems.length).toBeLessThanOrEqual(10); + }); + }); + + describe('Empty State', () => { + it('should show empty state when no appointments or customers', () => { + render(); + expect(screen.getByText('No recent activity')).toBeInTheDocument(); + }); + + it('should show empty state icon when no activity', () => { + const { container } = render( + + ); + + // Check for Calendar icon in empty state + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + }); + + it('should show empty state when only returning customers', () => { + const returningCustomers: Customer[] = [ + { + id: 'customer-1', + name: 'Returning', + email: 'returning@example.com', + phone: '555-0001', + lastVisit: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), + }, + ]; + + render(); + expect(screen.getByText('No recent activity')).toBeInTheDocument(); + }); + }); + + describe('Icons and Styling', () => { + it('should render activity icons', () => { + const { container } = render( + + ); + + // Multiple SVG icons should be present + const svgs = container.querySelectorAll('svg'); + expect(svgs.length).toBeGreaterThan(0); + }); + + it('should apply correct icon background colors', () => { + const { container } = render( + + ); + + // Check for various icon background colors + const blueBg = container.querySelector('.bg-blue-100'); + const redBg = container.querySelector('.bg-red-100'); + const greenBg = container.querySelector('.bg-green-100'); + const purpleBg = container.querySelector('.bg-purple-100'); + + // At least one should be present + expect(blueBg || redBg || greenBg || purpleBg).toBeTruthy(); + }); + + it('should apply container styles', () => { + const { container } = render( + + ); + + const widget = container.firstChild; + expect(widget).toHaveClass( + 'h-full', + 'p-4', + 'bg-white', + 'rounded-xl', + 'border', + 'border-gray-200', + 'shadow-sm' + ); + }); + + it('should apply dark mode styles', () => { + const { container } = render( + + ); + + const widget = container.firstChild; + expect(widget).toHaveClass('dark:bg-gray-800', 'dark:border-gray-700'); + }); + }); + + describe('Timestamps', () => { + it('should display relative timestamps', () => { + render( + + ); + + // date-fns formatDistanceToNow renders "ago" in the text + const timestamps = screen.getAllByText(/ago/i); + expect(timestamps.length).toBeGreaterThan(0); + }); + }); + + describe('Edit Mode', () => { + it('should not show edit controls when isEditing is false', () => { + const { container } = render( + + ); + + const dragHandle = container.querySelector('.drag-handle'); + expect(dragHandle).not.toBeInTheDocument(); + }); + + it('should show drag handle when in edit mode', () => { + const { container } = render( + + ); + + const dragHandle = container.querySelector('.drag-handle'); + expect(dragHandle).toBeInTheDocument(); + }); + + it('should show remove button when in edit mode', () => { + const { container } = render( + + ); + + // Remove button exists (X icon button) + const removeButtons = container.querySelectorAll('button'); + expect(removeButtons.length).toBeGreaterThan(0); + }); + + it('should call onRemove when remove button is clicked', async () => { + const user = userEvent.setup(); + const handleRemove = vi.fn(); + + const { container } = render( + + ); + + // Find the remove button (X icon) + const removeButton = container.querySelector('button[class*="hover:text-red"]') as HTMLElement; + expect(removeButton).toBeInTheDocument(); + + await user.click(removeButton); + expect(handleRemove).toHaveBeenCalledTimes(1); + }); + + it('should apply padding when in edit mode', () => { + const { container } = render( + + ); + + const paddedElement = container.querySelector('.pl-5'); + expect(paddedElement).toBeInTheDocument(); + }); + + it('should not apply padding when not in edit mode', () => { + render( + + ); + + // Title should not be in a pl-5 container + const title = screen.getByText('Recent Activity'); + expect(title).not.toHaveClass('pl-5'); + }); + }); + + describe('Activity Items Display', () => { + it('should display activity titles', () => { + render( + + ); + + expect(screen.getAllByText('New Booking').length).toBeGreaterThan(0); + }); + + it('should display activity descriptions', () => { + render( + + ); + + expect(screen.getByText('John Doe booked an appointment')).toBeInTheDocument(); + }); + + it('should truncate long descriptions', () => { + const { container } = render( + + ); + + // Check for truncate class on description text + const descriptions = container.querySelectorAll('.truncate'); + expect(descriptions.length).toBeGreaterThan(0); + }); + }); + + describe('useMemo Optimization', () => { + it('should handle empty appointments array', () => { + render(); + + // Should still show new customer activities + expect(screen.getByText('New Customer One signed up')).toBeInTheDocument(); + }); + + it('should handle empty customers array', () => { + render(); + + // Should still show appointment activities + expect(screen.getByText('John Doe booked an appointment')).toBeInTheDocument(); + }); + + it('should limit new customers to 5 before adding to activity', () => { + const manyNewCustomers: Customer[] = Array.from({ length: 10 }, (_, i) => ({ + id: `customer-${i}`, + name: `New Customer ${i}`, + email: `new${i}@example.com`, + phone: `555-000${i}`, + // No lastVisit + })); + + const { container } = render( + + ); + + // Only 5 new customers should be added to activity (before the 10-item limit) + const activityItems = container.querySelectorAll('[class*="flex items-start gap-3"]'); + expect(activityItems.length).toBeLessThanOrEqual(10); + }); + }); + + describe('Accessibility', () => { + it('should have semantic HTML structure', () => { + const { container } = render( + + ); + + const headings = container.querySelectorAll('h3'); + expect(headings.length).toBeGreaterThan(0); + }); + + it('should have readable text', () => { + render( + + ); + + const title = screen.getByText('Recent Activity'); + expect(title).toBeInTheDocument(); + }); + }); + + describe('Integration', () => { + it('should render correctly with all props', () => { + const handleRemove = vi.fn(); + + render( + + ); + + expect(screen.getByText('Recent Activity')).toBeInTheDocument(); + expect(screen.getByText('John Doe booked an appointment')).toBeInTheDocument(); + expect(screen.getByText('New Customer One signed up')).toBeInTheDocument(); + }); + + it('should handle mixed activity types', () => { + render( + + ); + + // Should have bookings, cancellations, completions, and new customers + expect(screen.getByText('New Booking')).toBeInTheDocument(); + expect(screen.getByText('Cancellation')).toBeInTheDocument(); + expect(screen.getByText('Completed')).toBeInTheDocument(); + expect(screen.getByText('New Customer')).toBeInTheDocument(); + }); + + it('should handle appointments with different statuses', () => { + const statusTestAppointments: Appointment[] = [ + { ...mockAppointments[0], status: 'CONFIRMED' }, + { ...mockAppointments[0], id: '2', status: 'PENDING' }, + { ...mockAppointments[0], id: '3', status: 'CANCELLED' }, + { ...mockAppointments[0], id: '4', status: 'COMPLETED' }, + ]; + + render(); + + // CONFIRMED and PENDING should show as bookings + const bookings = screen.getAllByText('New Booking'); + expect(bookings.length).toBe(2); + + // CANCELLED should show + expect(screen.getByText('Cancellation')).toBeInTheDocument(); + + // COMPLETED should show + expect(screen.getByText('Completed')).toBeInTheDocument(); + }); + }); + + describe('Edge Cases', () => { + it('should handle appointments with no customer name', () => { + const noNameAppointments: Appointment[] = [ + { + ...mockAppointments[0], + customerName: '', + }, + ]; + + render(); + + // Should still render activity (with empty customer name) + expect(screen.getByText('New Booking')).toBeInTheDocument(); + }); + + it('should handle customers with no name', () => { + const noNameCustomers: Customer[] = [ + { + id: 'customer-1', + name: '', + email: 'test@example.com', + phone: '555-0001', + }, + ]; + + render(); + + // Should still render if there's a new customer + expect(screen.getByText('New Customer')).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/dashboard/__tests__/WidgetConfigModal.test.tsx b/frontend/src/components/dashboard/__tests__/WidgetConfigModal.test.tsx new file mode 100644 index 00000000..547709dc --- /dev/null +++ b/frontend/src/components/dashboard/__tests__/WidgetConfigModal.test.tsx @@ -0,0 +1,576 @@ +/** + * Unit tests for WidgetConfigModal component + * + * Tests cover: + * - Component rendering and visibility + * - Modal open/close behavior + * - Widget list display + * - Widget toggle functionality + * - Active widget highlighting + * - Reset layout functionality + * - Widget icons display + * - Internationalization (i18n) + * - Accessibility + * - Backdrop click handling + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import WidgetConfigModal from '../WidgetConfigModal'; + +// Mock react-i18next +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + 'dashboard.configureWidgets': 'Configure Widgets', + 'dashboard.configureWidgetsDescription': 'Choose which widgets to display on your dashboard', + 'dashboard.resetToDefault': 'Reset to Default', + 'dashboard.done': 'Done', + // Widget titles + 'dashboard.widgetTitles.appointmentsMetric': 'Total Appointments', + 'dashboard.widgetTitles.customersMetric': 'Active Customers', + 'dashboard.widgetTitles.servicesMetric': 'Services', + 'dashboard.widgetTitles.resourcesMetric': 'Resources', + 'dashboard.widgetTitles.revenueChart': 'Revenue', + 'dashboard.widgetTitles.appointmentsChart': 'Appointments Trend', + 'dashboard.widgetTitles.openTickets': 'Open Tickets', + 'dashboard.widgetTitles.recentActivity': 'Recent Activity', + 'dashboard.widgetTitles.capacityUtilization': 'Capacity Utilization', + 'dashboard.widgetTitles.noShowRate': 'No-Show Rate', + 'dashboard.widgetTitles.customerBreakdown': 'New vs Returning', + // Widget descriptions + 'dashboard.widgetDescriptions.appointmentsMetric': 'Shows appointment count with weekly and monthly growth', + 'dashboard.widgetDescriptions.customersMetric': 'Shows customer count with weekly and monthly growth', + 'dashboard.widgetDescriptions.servicesMetric': 'Shows number of services offered', + 'dashboard.widgetDescriptions.resourcesMetric': 'Shows number of resources available', + 'dashboard.widgetDescriptions.revenueChart': 'Weekly revenue bar chart', + 'dashboard.widgetDescriptions.appointmentsChart': 'Weekly appointments line chart', + 'dashboard.widgetDescriptions.openTickets': 'Shows open support tickets requiring attention', + 'dashboard.widgetDescriptions.recentActivity': 'Timeline of recent business events', + 'dashboard.widgetDescriptions.capacityUtilization': 'Shows how booked your resources are this week', + 'dashboard.widgetDescriptions.noShowRate': 'Percentage of appointments marked as no-show', + 'dashboard.widgetDescriptions.customerBreakdown': 'Customer breakdown this month', + }; + return translations[key] || key; + }, + }), +})); + +describe('WidgetConfigModal', () => { + const mockOnClose = vi.fn(); + const mockOnToggleWidget = vi.fn(); + const mockOnResetLayout = vi.fn(); + + const defaultProps = { + isOpen: true, + onClose: mockOnClose, + activeWidgets: ['appointments-metric', 'customers-metric', 'revenue-chart'], + onToggleWidget: mockOnToggleWidget, + onResetLayout: mockOnResetLayout, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Modal Visibility', () => { + it('should render when isOpen is true', () => { + render(); + expect(screen.getByText('Configure Widgets')).toBeInTheDocument(); + }); + + it('should not render when isOpen is false', () => { + render(); + expect(screen.queryByText('Configure Widgets')).not.toBeInTheDocument(); + }); + + it('should return null when not open', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + }); + + describe('Modal Header', () => { + it('should render modal title', () => { + render(); + const title = screen.getByText('Configure Widgets'); + expect(title).toBeInTheDocument(); + expect(title).toHaveClass('text-lg', 'font-semibold'); + }); + + it('should render close button in header', () => { + const { container } = render(); + + // Close button (X icon) should be present + const closeButtons = container.querySelectorAll('button'); + expect(closeButtons.length).toBeGreaterThan(0); + }); + + it('should call onClose when header close button is clicked', async () => { + const user = userEvent.setup(); + const { container } = render(); + + // Find the X button in header + const buttons = container.querySelectorAll('button'); + const closeButton = Array.from(buttons).find(btn => + btn.querySelector('svg') + ) as HTMLElement; + + if (closeButton) { + await user.click(closeButton); + expect(mockOnClose).toHaveBeenCalledTimes(1); + } + }); + }); + + describe('Modal Content', () => { + it('should render description text', () => { + render(); + expect(screen.getByText('Choose which widgets to display on your dashboard')).toBeInTheDocument(); + }); + + it('should render all widget options', () => { + render(); + + // Check for widget titles + expect(screen.getByText('Total Appointments')).toBeInTheDocument(); + expect(screen.getByText('Active Customers')).toBeInTheDocument(); + expect(screen.getByText('Services')).toBeInTheDocument(); + expect(screen.getByText('Resources')).toBeInTheDocument(); + expect(screen.getByText('Revenue')).toBeInTheDocument(); + expect(screen.getByText('Appointments Trend')).toBeInTheDocument(); + expect(screen.getByText('Open Tickets')).toBeInTheDocument(); + expect(screen.getByText('Recent Activity')).toBeInTheDocument(); + expect(screen.getByText('Capacity Utilization')).toBeInTheDocument(); + expect(screen.getByText('No-Show Rate')).toBeInTheDocument(); + expect(screen.getByText('New vs Returning')).toBeInTheDocument(); + }); + + it('should render widget descriptions', () => { + render(); + + expect(screen.getByText('Shows appointment count with weekly and monthly growth')).toBeInTheDocument(); + expect(screen.getByText('Timeline of recent business events')).toBeInTheDocument(); + }); + + it('should render widget icons', () => { + const { container } = render(); + + // Should have multiple SVG icons + const svgs = container.querySelectorAll('svg'); + expect(svgs.length).toBeGreaterThan(10); // At least one per widget + }); + }); + + describe('Widget Selection', () => { + it('should highlight active widgets', () => { + const { container } = render(); + + // Active widgets should have brand-500 border + const activeWidgets = container.querySelectorAll('.border-brand-500'); + expect(activeWidgets.length).toBe(defaultProps.activeWidgets.length); + }); + + it('should show checkmark on active widgets', () => { + render(); + + // Check icons should be present for active widgets + const { container } = render(); + const svgs = container.querySelectorAll('svg'); + + // Should have check icons (hard to test exact count due to other icons) + expect(svgs.length).toBeGreaterThan(0); + }); + + it('should not highlight inactive widgets', () => { + const { container } = render(); + + // Inactive widgets should have gray border + const inactiveWidgets = container.querySelectorAll('.border-gray-200'); + expect(inactiveWidgets.length).toBeGreaterThan(0); + }); + + it('should call onToggleWidget when widget is clicked', async () => { + const user = userEvent.setup(); + render(); + + const widget = screen.getByText('Total Appointments').closest('button'); + expect(widget).toBeInTheDocument(); + + if (widget) { + await user.click(widget); + expect(mockOnToggleWidget).toHaveBeenCalledWith('appointments-metric'); + } + }); + + it('should call onToggleWidget with correct widget ID', async () => { + const user = userEvent.setup(); + render(); + + const revenueWidget = screen.getByText('Revenue').closest('button'); + if (revenueWidget) { + await user.click(revenueWidget); + expect(mockOnToggleWidget).toHaveBeenCalledWith('revenue-chart'); + } + + const ticketsWidget = screen.getByText('Open Tickets').closest('button'); + if (ticketsWidget) { + await user.click(ticketsWidget); + expect(mockOnToggleWidget).toHaveBeenCalledWith('open-tickets'); + } + }); + + it('should allow toggling multiple widgets', async () => { + const user = userEvent.setup(); + render(); + + const widget1 = screen.getByText('Services').closest('button'); + const widget2 = screen.getByText('Resources').closest('button'); + + if (widget1) await user.click(widget1); + if (widget2) await user.click(widget2); + + expect(mockOnToggleWidget).toHaveBeenCalledTimes(2); + }); + }); + + describe('Active Widget Styling', () => { + it('should apply active styling to selected widgets', () => { + const { container } = render(); + + // Active widgets should have brand colors + const brandBg = container.querySelectorAll('.bg-brand-50'); + expect(brandBg.length).toBeGreaterThan(0); + }); + + it('should apply inactive styling to unselected widgets', () => { + const { container } = render( + + ); + + // Many widgets should have gray styling + const grayBorders = container.querySelectorAll('.border-gray-200'); + expect(grayBorders.length).toBeGreaterThan(5); + }); + + it('should apply different icon colors for active vs inactive', () => { + const { container } = render(); + + // Active widgets should have brand icon colors + const brandIcons = container.querySelectorAll('.text-brand-600'); + expect(brandIcons.length).toBeGreaterThan(0); + + // Inactive widgets should have gray icon colors + const grayIcons = container.querySelectorAll('.text-gray-500'); + expect(grayIcons.length).toBeGreaterThan(0); + }); + }); + + describe('Modal Footer', () => { + it('should render reset button', () => { + render(); + expect(screen.getByText('Reset to Default')).toBeInTheDocument(); + }); + + it('should render done button', () => { + render(); + expect(screen.getByText('Done')).toBeInTheDocument(); + }); + + it('should call onResetLayout when reset button is clicked', async () => { + const user = userEvent.setup(); + render(); + + const resetButton = screen.getByText('Reset to Default'); + await user.click(resetButton); + + expect(mockOnResetLayout).toHaveBeenCalledTimes(1); + }); + + it('should call onClose when done button is clicked', async () => { + const user = userEvent.setup(); + render(); + + const doneButton = screen.getByText('Done'); + await user.click(doneButton); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + }); + + describe('Backdrop Interaction', () => { + it('should render backdrop', () => { + const { container } = render(); + + // Backdrop div with bg-black/50 + const backdrop = container.querySelector('.bg-black\\/50'); + expect(backdrop).toBeInTheDocument(); + }); + + it('should call onClose when backdrop is clicked', async () => { + const user = userEvent.setup(); + const { container } = render(); + + const backdrop = container.querySelector('.bg-black\\/50') as HTMLElement; + expect(backdrop).toBeInTheDocument(); + + if (backdrop) { + await user.click(backdrop); + expect(mockOnClose).toHaveBeenCalledTimes(1); + } + }); + + it('should not call onClose when modal content is clicked', async () => { + const user = userEvent.setup(); + const { container } = render(); + + // Click on modal content, not backdrop + const modalContent = container.querySelector('.bg-white') as HTMLElement; + if (modalContent) { + await user.click(modalContent); + expect(mockOnClose).not.toHaveBeenCalled(); + } + }); + }); + + describe('Widget Grid Layout', () => { + it('should display widgets in a grid', () => { + const { container } = render(); + + // Grid container should exist + const grid = container.querySelector('.grid'); + expect(grid).toBeInTheDocument(); + expect(grid).toHaveClass('grid-cols-1', 'sm:grid-cols-2'); + }); + + it('should render all 11 widgets', () => { + render(); + + // Count widget buttons + const widgetButtons = screen.getAllByRole('button'); + // Should have 11 widget buttons + 2 footer buttons + 1 close button = 14 total + expect(widgetButtons.length).toBeGreaterThanOrEqual(11); + }); + }); + + describe('Styling', () => { + it('should apply modal container styles', () => { + const { container } = render(); + + const modal = container.querySelector('.bg-white'); + expect(modal).toHaveClass( + 'bg-white', + 'rounded-xl', + 'shadow-xl', + 'max-w-2xl', + 'w-full' + ); + }); + + it('should apply dark mode styles', () => { + const { container } = render(); + + const modal = container.querySelector('.dark\\:bg-gray-800'); + expect(modal).toBeInTheDocument(); + }); + + it('should make modal scrollable', () => { + const { container } = render(); + + const scrollableContent = container.querySelector('.overflow-y-auto'); + expect(scrollableContent).toBeInTheDocument(); + }); + + it('should apply max height to modal', () => { + const { container } = render(); + + const modal = container.querySelector('.max-h-\\[80vh\\]'); + expect(modal).toBeInTheDocument(); + }); + }); + + describe('Accessibility', () => { + it('should have semantic HTML structure', () => { + const { container } = render(); + + const headings = container.querySelectorAll('h2'); + expect(headings.length).toBeGreaterThan(0); + }); + + it('should have accessible buttons', () => { + render(); + + const buttons = screen.getAllByRole('button'); + expect(buttons.length).toBeGreaterThan(0); + }); + + it('should have clear button text', () => { + render(); + + expect(screen.getByText('Done')).toBeInTheDocument(); + expect(screen.getByText('Reset to Default')).toBeInTheDocument(); + }); + + it('should have descriptive widget names', () => { + render(); + + expect(screen.getByText('Total Appointments')).toBeInTheDocument(); + expect(screen.getByText('Recent Activity')).toBeInTheDocument(); + }); + }); + + describe('Widget Descriptions', () => { + it('should show description for each widget', () => { + render(); + + // Check a few widget descriptions + expect(screen.getByText('Shows appointment count with weekly and monthly growth')).toBeInTheDocument(); + expect(screen.getByText('Timeline of recent business events')).toBeInTheDocument(); + expect(screen.getByText('Shows how booked your resources are this week')).toBeInTheDocument(); + }); + + it('should display descriptions in smaller text', () => { + const { container } = render(); + + const descriptions = container.querySelectorAll('.text-xs'); + expect(descriptions.length).toBeGreaterThan(0); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty activeWidgets array', () => { + render(); + + // Should still render all widgets, just none selected + expect(screen.getByText('Total Appointments')).toBeInTheDocument(); + + // No checkmarks should be visible + const { container } = render(); + const activeWidgets = container.querySelectorAll('.border-brand-500'); + expect(activeWidgets.length).toBe(0); + }); + + it('should handle all widgets active', () => { + const allWidgets = [ + 'appointments-metric', + 'customers-metric', + 'services-metric', + 'resources-metric', + 'revenue-chart', + 'appointments-chart', + 'open-tickets', + 'recent-activity', + 'capacity-utilization', + 'no-show-rate', + 'customer-breakdown', + ]; + + const { container } = render( + + ); + + // All widgets should have active styling + const activeWidgets = container.querySelectorAll('.border-brand-500'); + expect(activeWidgets.length).toBe(11); + }); + + it('should handle rapid widget toggling', async () => { + const user = userEvent.setup(); + render(); + + const widget = screen.getByText('Services').closest('button'); + + if (widget) { + await user.click(widget); + await user.click(widget); + await user.click(widget); + + expect(mockOnToggleWidget).toHaveBeenCalledTimes(3); + } + }); + }); + + describe('Internationalization', () => { + it('should use translations for modal title', () => { + render(); + expect(screen.getByText('Configure Widgets')).toBeInTheDocument(); + }); + + it('should use translations for widget titles', () => { + render(); + expect(screen.getByText('Total Appointments')).toBeInTheDocument(); + expect(screen.getByText('Recent Activity')).toBeInTheDocument(); + }); + + it('should use translations for widget descriptions', () => { + render(); + expect(screen.getByText('Timeline of recent business events')).toBeInTheDocument(); + }); + + it('should use translations for buttons', () => { + render(); + expect(screen.getByText('Done')).toBeInTheDocument(); + expect(screen.getByText('Reset to Default')).toBeInTheDocument(); + }); + }); + + describe('Integration', () => { + it('should render correctly with all props', () => { + const handleClose = vi.fn(); + const handleToggle = vi.fn(); + const handleReset = vi.fn(); + + render( + + ); + + expect(screen.getByText('Configure Widgets')).toBeInTheDocument(); + expect(screen.getByText('Total Appointments')).toBeInTheDocument(); + expect(screen.getByText('Done')).toBeInTheDocument(); + }); + + it('should support complete user workflow', async () => { + const user = userEvent.setup(); + const handleClose = vi.fn(); + const handleToggle = vi.fn(); + const handleReset = vi.fn(); + + render( + + ); + + // User toggles a widget + const widget = screen.getByText('Revenue').closest('button'); + if (widget) await user.click(widget); + expect(handleToggle).toHaveBeenCalledWith('revenue-chart'); + + // User resets layout + const resetButton = screen.getByText('Reset to Default'); + await user.click(resetButton); + expect(handleReset).toHaveBeenCalledTimes(1); + + // User closes modal + const doneButton = screen.getByText('Done'); + await user.click(doneButton); + expect(handleClose).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/frontend/src/components/email/__tests__/EmailComposer.test.tsx b/frontend/src/components/email/__tests__/EmailComposer.test.tsx new file mode 100644 index 00000000..6985fb2a --- /dev/null +++ b/frontend/src/components/email/__tests__/EmailComposer.test.tsx @@ -0,0 +1,373 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import EmailComposer from '../EmailComposer'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { StaffEmail } from '../../../types'; + +// Mock react-i18next +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +// Mock react-hot-toast +vi.mock('react-hot-toast', () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +// Mock hooks +vi.mock('../../../hooks/useStaffEmail', () => ({ + useCreateDraft: vi.fn(() => ({ + mutateAsync: vi.fn(), + isPending: false, + })), + useUpdateDraft: vi.fn(() => ({ + mutateAsync: vi.fn(), + isPending: false, + })), + useSendEmail: vi.fn(() => ({ + mutateAsync: vi.fn(), + isPending: false, + })), + useUploadAttachment: vi.fn(() => ({ + mutateAsync: vi.fn(), + })), + useContactSearch: vi.fn(() => ({ + data: [], + })), + useUserEmailAddresses: vi.fn(() => ({ + data: [ + { + id: 1, + email_address: 'test@example.com', + display_name: 'Test User', + }, + ], + })), +})); + +describe('EmailComposer', () => { + const defaultProps = { + onClose: vi.fn(), + onSent: vi.fn(), + }; + + const mockReplyToEmail: StaffEmail = { + id: 1, + owner: 1, + emailAddress: 1, + folder: 1, + fromAddress: 'sender@example.com', + fromName: 'Sender Name', + toAddresses: ['test@example.com'], + ccAddresses: [], + bccAddresses: [], + subject: 'Original Subject', + snippet: 'Email snippet', + bodyText: 'Original email body', + bodyHtml: '

Original email body

', + messageId: 'message-123', + inReplyTo: null, + references: '', + status: 'SENT', + threadId: 'thread-123', + emailDate: '2025-01-15T10:00:00Z', + isRead: true, + isStarred: false, + isImportant: false, + isAnswered: false, + isPermanentlyDeleted: false, + deletedAt: null, + hasAttachments: false, + attachmentCount: 0, + attachments: [], + labels: [], + createdAt: '2025-01-15T10:00:00Z', + updatedAt: '2025-01-15T10:00:00Z', + }; + + const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders new message mode', () => { + render(, { wrapper: createWrapper() }); + expect(screen.getByText('New Message')).toBeInTheDocument(); + }); + + it('renders reply mode with subject prefixed', () => { + render(, { + wrapper: createWrapper(), + }); + expect(screen.getByText('Reply')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Re: Original Subject')).toBeInTheDocument(); + }); + + it('renders forward mode with subject prefixed', () => { + render(, { + wrapper: createWrapper(), + }); + expect(screen.getByText('Forward')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Fwd: Original Subject')).toBeInTheDocument(); + }); + + it('populates recipient in reply mode', () => { + render(, { + wrapper: createWrapper(), + }); + expect(screen.getByDisplayValue('sender@example.com')).toBeInTheDocument(); + }); + + it('renders minimized state', () => { + render(, { wrapper: createWrapper() }); + + const buttons = screen.getAllByRole('button'); + const minimizeButton = buttons.find((btn) => + btn.querySelector('svg')?.classList.contains('lucide-minimize-2') + ); + if (minimizeButton) { + fireEvent.click(minimizeButton); + } + + expect(screen.getByText('New Message')).toBeInTheDocument(); + }); + + it('expands from minimized state', () => { + render(, { wrapper: createWrapper() }); + + // Minimize + let buttons = screen.getAllByRole('button'); + const minimizeButton = buttons.find((btn) => + btn.querySelector('svg')?.classList.contains('lucide-minimize-2') + ); + if (minimizeButton) { + fireEvent.click(minimizeButton); + } + + // Maximize + buttons = screen.getAllByRole('button'); + const maximizeButton = buttons.find((btn) => + btn.querySelector('svg')?.classList.contains('lucide-maximize-2') + ); + if (maximizeButton) { + fireEvent.click(maximizeButton); + } + + expect(screen.getByPlaceholderText('recipient@example.com')).toBeInTheDocument(); + }); + + it('calls onClose when close button clicked', () => { + render(, { wrapper: createWrapper() }); + + const buttons = screen.getAllByRole('button'); + const closeButton = buttons.find((btn) => + btn.querySelector('svg')?.classList.contains('lucide-x') + ); + if (closeButton) { + fireEvent.click(closeButton); + } + + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + + it('shows Cc field when Cc button clicked', () => { + render(, { wrapper: createWrapper() }); + + const ccButton = screen.getByText('Cc'); + fireEvent.click(ccButton); + + expect(screen.getByPlaceholderText('cc@example.com')).toBeInTheDocument(); + }); + + it('shows Bcc field when Bcc button clicked', () => { + render(, { wrapper: createWrapper() }); + + const bccButton = screen.getByText('Bcc'); + fireEvent.click(bccButton); + + expect(screen.getByPlaceholderText('bcc@example.com')).toBeInTheDocument(); + }); + + it('updates subject field', () => { + render(, { wrapper: createWrapper() }); + + const subjectInput = screen.getByPlaceholderText('Email subject'); + fireEvent.change(subjectInput, { target: { value: 'Test Subject' } }); + + expect(subjectInput).toHaveValue('Test Subject'); + }); + + it('updates body field', () => { + render(, { wrapper: createWrapper() }); + + const bodyTextarea = screen.getByPlaceholderText('Write your message...'); + fireEvent.change(bodyTextarea, { target: { value: 'Test email body' } }); + + expect(bodyTextarea).toHaveValue('Test email body'); + }); + + it('updates to field', () => { + render(, { wrapper: createWrapper() }); + + const toInput = screen.getByPlaceholderText('recipient@example.com'); + fireEvent.change(toInput, { target: { value: 'test@example.com' } }); + + expect(toInput).toHaveValue('test@example.com'); + }); + + it('updates cc field when shown', () => { + render(, { wrapper: createWrapper() }); + + fireEvent.click(screen.getByText('Cc')); + const ccInput = screen.getByPlaceholderText('cc@example.com'); + fireEvent.change(ccInput, { target: { value: 'cc@example.com' } }); + + expect(ccInput).toHaveValue('cc@example.com'); + }); + + it('updates bcc field when shown', () => { + render(, { wrapper: createWrapper() }); + + fireEvent.click(screen.getByText('Bcc')); + const bccInput = screen.getByPlaceholderText('bcc@example.com'); + fireEvent.change(bccInput, { target: { value: 'bcc@example.com' } }); + + expect(bccInput).toHaveValue('bcc@example.com'); + }); + + it('renders send button', () => { + render(, { wrapper: createWrapper() }); + + expect(screen.getByText('staffEmail.send')).toBeInTheDocument(); + }); + + it('renders save draft button', () => { + render(, { wrapper: createWrapper() }); + + expect(screen.getByText('Save draft')).toBeInTheDocument(); + }); + + it('renders formatting buttons', () => { + render(, { wrapper: createWrapper() }); + + const buttons = screen.getAllByRole('button'); + const boldButton = buttons.find((btn) => + btn.querySelector('svg')?.classList.contains('lucide-bold') + ); + const italicButton = buttons.find((btn) => + btn.querySelector('svg')?.classList.contains('lucide-italic') + ); + const underlineButton = buttons.find((btn) => + btn.querySelector('svg')?.classList.contains('lucide-underline') + ); + + expect(boldButton).toBeInTheDocument(); + expect(italicButton).toBeInTheDocument(); + expect(underlineButton).toBeInTheDocument(); + }); + + it('renders attachment button', () => { + render(, { wrapper: createWrapper() }); + + // Attachment input is wrapped in a label, not a button + const paperclipIcons = document.querySelectorAll('.lucide-paperclip'); + expect(paperclipIcons.length).toBeGreaterThan(0); + }); + + it('renders discard button', () => { + render(, { wrapper: createWrapper() }); + + const buttons = screen.getAllByRole('button'); + const discardButton = buttons.find((btn) => + btn.querySelector('svg')?.classList.contains('lucide-trash-2') + ); + + expect(discardButton).toBeInTheDocument(); + }); + + it('renders from address selector', () => { + render(, { wrapper: createWrapper() }); + + expect(screen.getByText('From:')).toBeInTheDocument(); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + + it('populates from address with default value', async () => { + render(, { wrapper: createWrapper() }); + + await waitFor(() => { + const select = screen.getByRole('combobox'); + expect(select).toHaveValue('1'); + }); + }); + + it('does not add Re: prefix if subject already has it', () => { + const emailWithReply = { + ...mockReplyToEmail, + subject: 'Re: Already Replied', + }; + + render(, { + wrapper: createWrapper(), + }); + + expect(screen.getByDisplayValue('Re: Already Replied')).toBeInTheDocument(); + }); + + it('does not add Fwd: prefix if subject already has it', () => { + const emailWithFwd = { + ...mockReplyToEmail, + subject: 'Fwd: Already Forwarded', + }; + + render(, { + wrapper: createWrapper(), + }); + + expect(screen.getByDisplayValue('Fwd: Already Forwarded')).toBeInTheDocument(); + }); + + it('includes original message in reply body', () => { + render(, { + wrapper: createWrapper(), + }); + + const bodyTextarea = screen.getByPlaceholderText('Write your message...'); + const value = (bodyTextarea as HTMLTextAreaElement).value; + expect(value).toContain('Original email body'); + }); + + it('includes original message in forward body', () => { + render(, { + wrapper: createWrapper(), + }); + + const bodyTextarea = screen.getByPlaceholderText('Write your message...'); + const value = (bodyTextarea as HTMLTextAreaElement).value; + expect(value).toContain('Original email body'); + }); + + it('renders with all header fields', () => { + render(, { wrapper: createWrapper() }); + + expect(screen.getByText('From:')).toBeInTheDocument(); + expect(screen.getByText('To:')).toBeInTheDocument(); + expect(screen.getByText('Subject:')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/email/__tests__/EmailViewer.test.tsx b/frontend/src/components/email/__tests__/EmailViewer.test.tsx new file mode 100644 index 00000000..d7a474ec --- /dev/null +++ b/frontend/src/components/email/__tests__/EmailViewer.test.tsx @@ -0,0 +1,456 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import EmailViewer from '../EmailViewer'; +import type { StaffEmail } from '../../../types'; + +// Mock react-i18next +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +// Mock date-fns +vi.mock('date-fns', () => ({ + format: vi.fn(() => '2025-01-15 10:00 AM'), +})); + +// Mock ResizeObserver +class MockResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +global.ResizeObserver = MockResizeObserver as any; + +describe('EmailViewer', () => { + const mockEmail: StaffEmail = { + id: 1, + owner: 1, + emailAddress: 1, + folder: 1, + fromAddress: 'sender@example.com', + fromName: 'Sender Name', + toAddresses: ['recipient@example.com'], + ccAddresses: [], + bccAddresses: [], + subject: 'Test Email Subject', + snippet: 'Email snippet', + bodyText: 'This is the email body text.', + bodyHtml: '

This is the email body HTML.

', + messageId: 'message-123', + inReplyTo: null, + references: '', + status: 'SENT', + threadId: 'thread-123', + emailDate: '2025-01-15T10:00:00Z', + isRead: true, + isStarred: false, + isImportant: false, + isAnswered: false, + isPermanentlyDeleted: false, + deletedAt: null, + hasAttachments: false, + attachmentCount: 0, + attachments: [], + labels: [], + createdAt: '2025-01-15T10:00:00Z', + updatedAt: '2025-01-15T10:00:00Z', + }; + + const defaultProps = { + email: mockEmail, + onReply: vi.fn(), + onReplyAll: vi.fn(), + onForward: vi.fn(), + onArchive: vi.fn(), + onTrash: vi.fn(), + onStar: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders loading state', () => { + render(); + const spinner = document.querySelector('.animate-spin'); + expect(spinner).toBeInTheDocument(); + }); + + it('renders email subject', () => { + render(); + expect(screen.getByText('Test Email Subject')).toBeInTheDocument(); + }); + + it('renders email from name', () => { + render(); + expect(screen.getByText('Sender Name')).toBeInTheDocument(); + }); + + it('renders email from address', () => { + render(); + expect(screen.getByText('')).toBeInTheDocument(); + }); + + it('renders email to addresses', () => { + render(); + expect(screen.getByText(/To:/)).toBeInTheDocument(); + expect(screen.getByText(/recipient@example.com/)).toBeInTheDocument(); + }); + + it('renders email date', () => { + render(); + expect(screen.getByText('2025-01-15 10:00 AM')).toBeInTheDocument(); + }); + + it('renders HTML body by default', () => { + render(); + const iframe = screen.getByTitle('Email content'); + expect(iframe).toBeInTheDocument(); + }); + + it('renders plain text body when no HTML', () => { + const emailWithoutHtml = { ...mockEmail, bodyHtml: '' }; + render(); + expect(screen.getByText('This is the email body text.')).toBeInTheDocument(); + }); + + it('calls onReply when reply button clicked', () => { + render(); + const buttons = screen.getAllByRole('button'); + const replyButton = buttons.find((btn) => + btn.querySelector('svg')?.classList.contains('lucide-reply') + ); + if (replyButton) { + fireEvent.click(replyButton); + } + expect(defaultProps.onReply).toHaveBeenCalled(); + }); + + it('calls onReplyAll when reply all button clicked', () => { + render(); + const buttons = screen.getAllByRole('button'); + const replyAllButton = buttons.find((btn) => + btn.querySelector('svg')?.classList.contains('lucide-reply-all') + ); + if (replyAllButton) { + fireEvent.click(replyAllButton); + } + expect(defaultProps.onReplyAll).toHaveBeenCalled(); + }); + + it('calls onForward when forward button clicked', () => { + render(); + const buttons = screen.getAllByRole('button'); + const forwardButton = buttons.find((btn) => + btn.querySelector('svg')?.classList.contains('lucide-forward') + ); + if (forwardButton) { + fireEvent.click(forwardButton); + } + expect(defaultProps.onForward).toHaveBeenCalled(); + }); + + it('calls onArchive when archive button clicked', () => { + render(); + const buttons = screen.getAllByRole('button'); + const archiveButton = buttons.find((btn) => + btn.querySelector('svg')?.classList.contains('lucide-archive') + ); + if (archiveButton) { + fireEvent.click(archiveButton); + } + expect(defaultProps.onArchive).toHaveBeenCalled(); + }); + + it('calls onTrash when trash button clicked', () => { + render(); + const buttons = screen.getAllByRole('button'); + const trashButton = buttons.find((btn) => + btn.querySelector('svg')?.classList.contains('lucide-trash-2') + ); + if (trashButton) { + fireEvent.click(trashButton); + } + expect(defaultProps.onTrash).toHaveBeenCalled(); + }); + + it('calls onStar when star button clicked', () => { + render(); + const buttons = screen.getAllByRole('button'); + const starButton = buttons.find((btn) => + btn.querySelector('svg')?.classList.contains('lucide-star') + ); + if (starButton) { + fireEvent.click(starButton); + } + expect(defaultProps.onStar).toHaveBeenCalled(); + }); + + it('renders starred email with filled star', () => { + const starredEmail = { ...mockEmail, isStarred: true }; + render(); + const star = document.querySelector('.fill-yellow-400'); + expect(star).toBeInTheDocument(); + }); + + it('renders CC addresses when present', () => { + const emailWithCc = { + ...mockEmail, + ccAddresses: ['cc1@example.com', 'cc2@example.com'], + }; + render(); + expect(screen.getByText(/Cc:/)).toBeInTheDocument(); + expect(screen.getByText(/cc1@example.com, cc2@example.com/)).toBeInTheDocument(); + }); + + it('does not render CC section when no CC addresses', () => { + render(); + expect(screen.queryByText(/Cc:/)).not.toBeInTheDocument(); + }); + + it('renders labels when present', () => { + const emailWithLabels = { + ...mockEmail, + labels: [ + { id: 1, owner: 1, name: 'Important', color: '#ff0000', createdAt: '2025-01-15T10:00:00Z' }, + { id: 2, owner: 1, name: 'Work', color: '#00ff00', createdAt: '2025-01-15T10:00:00Z' }, + ], + }; + render(); + expect(screen.getByText('Important')).toBeInTheDocument(); + expect(screen.getByText('Work')).toBeInTheDocument(); + }); + + it('does not render labels section when no labels', () => { + render(); + const labels = screen.queryByText('Important'); + expect(labels).not.toBeInTheDocument(); + }); + + it('renders attachments when present', () => { + const emailWithAttachments = { + ...mockEmail, + hasAttachments: true, + attachmentCount: 2, + attachments: [ + { + id: 1, + filename: 'document.pdf', + contentType: 'application/pdf', + size: 1024000, + url: 'http://example.com/doc.pdf', + createdAt: '2025-01-15T10:00:00Z', + }, + { + id: 2, + filename: 'image.png', + contentType: 'image/png', + size: 512000, + url: 'http://example.com/img.png', + createdAt: '2025-01-15T10:00:00Z', + }, + ], + }; + render(); + expect(screen.getByText('2 attachments')).toBeInTheDocument(); + expect(screen.getByText('document.pdf')).toBeInTheDocument(); + expect(screen.getByText('image.png')).toBeInTheDocument(); + }); + + it('formats attachment sizes correctly', () => { + const emailWithAttachments = { + ...mockEmail, + hasAttachments: true, + attachmentCount: 1, + attachments: [ + { + id: 1, + filename: 'document.pdf', + contentType: 'application/pdf', + size: 1024000, + url: 'http://example.com/doc.pdf', + createdAt: '2025-01-15T10:00:00Z', + }, + ], + }; + render(); + expect(screen.getByText(/1\.0 MB|1000\.0 KB/)).toBeInTheDocument(); + }); + + it('does not render attachments section when no attachments', () => { + render(); + expect(screen.queryByText(/attachment/)).not.toBeInTheDocument(); + }); + + it('toggles between HTML and text view', () => { + render(); + + const buttons = screen.getAllByRole('button'); + const textViewButton = buttons.find((btn) => + btn.querySelector('svg')?.classList.contains('lucide-file-text') + ); + + if (textViewButton) { + fireEvent.click(textViewButton); + expect(screen.getByText('This is the email body text.')).toBeInTheDocument(); + } + }); + + it('does not show view mode toggle when no HTML body', () => { + const emailWithoutHtml = { ...mockEmail, bodyHtml: '' }; + render(); + + const buttons = screen.getAllByRole('button'); + const htmlButton = buttons.find((btn) => + btn.querySelector('svg')?.classList.contains('lucide-code') + ); + + expect(htmlButton).toBeUndefined(); + }); + + it('renders quick reply button', () => { + render(); + expect(screen.getByText('staffEmail.clickToReply')).toBeInTheDocument(); + }); + + it('calls onReply when quick reply button clicked', () => { + render(); + fireEvent.click(screen.getByText('staffEmail.clickToReply')); + expect(defaultProps.onReply).toHaveBeenCalled(); + }); + + it('renders mark as read button when email is unread', () => { + const unreadEmail = { ...mockEmail, isRead: false }; + render(); + + const buttons = screen.getAllByRole('button'); + const markReadButton = buttons.find((btn) => + btn.querySelector('svg')?.classList.contains('lucide-mail-open') + ); + + expect(markReadButton).toBeInTheDocument(); + }); + + it('renders mark as unread button when email is read', () => { + render(); + + const buttons = screen.getAllByRole('button'); + const markUnreadButton = buttons.find((btn) => + btn.querySelector('svg')?.classList.contains('lucide-mail') + ); + + expect(markUnreadButton).toBeInTheDocument(); + }); + + it('calls onMarkRead when mark as read button clicked', () => { + const onMarkRead = vi.fn(); + const unreadEmail = { ...mockEmail, isRead: false }; + render(); + + const buttons = screen.getAllByRole('button'); + const markReadButton = buttons.find((btn) => + btn.querySelector('svg')?.classList.contains('lucide-mail-open') + ); + + if (markReadButton) { + fireEvent.click(markReadButton); + } + expect(onMarkRead).toHaveBeenCalled(); + }); + + it('calls onMarkUnread when mark as unread button clicked', () => { + const onMarkUnread = vi.fn(); + render(); + + const buttons = screen.getAllByRole('button'); + const markUnreadButton = buttons.find((btn) => + btn.querySelector('svg')?.classList.contains('lucide-mail') + ); + + if (markUnreadButton) { + fireEvent.click(markUnreadButton); + } + expect(onMarkUnread).toHaveBeenCalled(); + }); + + it('renders restore button when in trash', () => { + const onRestore = vi.fn(); + render(); + + const buttons = screen.getAllByRole('button'); + const restoreButton = buttons.find((btn) => + btn.querySelector('svg')?.classList.contains('lucide-rotate-ccw') + ); + + expect(restoreButton).toBeInTheDocument(); + }); + + it('calls onRestore when restore button clicked', () => { + const onRestore = vi.fn(); + render(); + + const buttons = screen.getAllByRole('button'); + const restoreButton = buttons.find((btn) => + btn.querySelector('svg')?.classList.contains('lucide-rotate-ccw') + ); + + if (restoreButton) { + fireEvent.click(restoreButton); + } + expect(onRestore).toHaveBeenCalled(); + }); + + it('shows trash button when not in trash', () => { + render(); + + const buttons = screen.getAllByRole('button'); + const trashButton = buttons.find((btn) => + btn.querySelector('svg')?.classList.contains('lucide-trash-2') + ); + + expect(trashButton).toBeInTheDocument(); + }); + + it('does not show trash button when in trash', () => { + const onRestore = vi.fn(); + render(); + + const buttons = screen.getAllByRole('button'); + const trashButtons = buttons.filter((btn) => + btn.querySelector('svg')?.classList.contains('lucide-trash-2') + ); + + expect(trashButtons.length).toBe(0); + }); + + it('renders (No Subject) when subject is empty', () => { + const emailWithoutSubject = { ...mockEmail, subject: '' }; + render(); + expect(screen.getByText('(No Subject)')).toBeInTheDocument(); + }); + + it('renders avatar with first letter of sender name', () => { + render(); + const avatar = screen.getByText('S'); // First letter of "Sender Name" + expect(avatar).toBeInTheDocument(); + }); + + it('uses email address for avatar when no name provided', () => { + const emailWithoutName = { ...mockEmail, fromName: '' }; + render(); + const avatar = screen.getByText('S'); // First letter of "sender@example.com" (uppercase) + expect(avatar).toBeInTheDocument(); + }); + + it('renders multiple to addresses', () => { + const emailWithMultipleTo = { + ...mockEmail, + toAddresses: ['to1@example.com', 'to2@example.com', 'to3@example.com'], + }; + render(); + expect(screen.getByText(/to1@example.com, to2@example.com, to3@example.com/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/help/__tests__/HelpSearch.test.tsx b/frontend/src/components/help/__tests__/HelpSearch.test.tsx new file mode 100644 index 00000000..2d251b4c --- /dev/null +++ b/frontend/src/components/help/__tests__/HelpSearch.test.tsx @@ -0,0 +1,461 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { HelpSearch } from '../HelpSearch'; +import * as useHelpSearchModule from '../../../hooks/useHelpSearch'; + +// Mock the useHelpSearch hook +vi.mock('../../../hooks/useHelpSearch'); + +describe('HelpSearch', () => { + const mockSearch = vi.fn(); + const defaultHookReturn = { + search: mockSearch, + results: [], + isSearching: false, + error: null, + hasApiKey: false, + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(useHelpSearchModule, 'useHelpSearch').mockReturnValue(defaultHookReturn); + }); + + afterEach(() => { + vi.clearAllTimers(); + }); + + const renderWithRouter = (component: React.ReactElement) => { + return render({component}); + }; + + describe('rendering', () => { + it('renders search input with default placeholder', () => { + renderWithRouter(); + expect(screen.getByPlaceholderText('Ask a question or search for help...')).toBeInTheDocument(); + }); + + it('renders search input with custom placeholder', () => { + renderWithRouter(); + expect(screen.getByPlaceholderText('Search documentation...')).toBeInTheDocument(); + }); + + it('applies custom className', () => { + const { container } = renderWithRouter(); + expect(container.querySelector('.custom-class')).toBeInTheDocument(); + }); + + it('shows search icon by default', () => { + renderWithRouter(); + const searchIcon = document.querySelector('svg'); + expect(searchIcon).toBeInTheDocument(); + }); + }); + + describe('AI badge', () => { + it('shows AI badge when API key is present', () => { + vi.spyOn(useHelpSearchModule, 'useHelpSearch').mockReturnValue({ + ...defaultHookReturn, + hasApiKey: true, + }); + renderWithRouter(); + expect(screen.getByText('AI')).toBeInTheDocument(); + }); + + it('does not show AI badge when API key is absent', () => { + renderWithRouter(); + expect(screen.queryByText('AI')).not.toBeInTheDocument(); + }); + }); + + describe('search input behavior', () => { + it('updates query on input change', () => { + renderWithRouter(); + const input = screen.getByPlaceholderText('Ask a question or search for help...'); + + fireEvent.change(input, { target: { value: 'scheduler' } }); + expect(input).toHaveValue('scheduler'); + }); + + it('debounces search after input change', async () => { + vi.useFakeTimers(); + renderWithRouter(); + const input = screen.getByPlaceholderText('Ask a question or search for help...'); + + fireEvent.change(input, { target: { value: 'scheduler' } }); + + // Should not call immediately + expect(mockSearch).not.toHaveBeenCalled(); + + // Fast-forward 300ms + vi.advanceTimersByTime(300); + + expect(mockSearch).toHaveBeenCalledWith('scheduler'); + vi.useRealTimers(); + }); + + it('debounces multiple rapid inputs', async () => { + vi.useFakeTimers(); + renderWithRouter(); + const input = screen.getByPlaceholderText('Ask a question or search for help...'); + + fireEvent.change(input, { target: { value: 's' } }); + vi.advanceTimersByTime(100); + + fireEvent.change(input, { target: { value: 'sc' } }); + vi.advanceTimersByTime(100); + + fireEvent.change(input, { target: { value: 'scheduler' } }); + vi.advanceTimersByTime(300); + + // Should only call once with the final value + expect(mockSearch).toHaveBeenCalledTimes(1); + expect(mockSearch).toHaveBeenCalledWith('scheduler'); + vi.useRealTimers(); + }); + + it('does not search for empty query', () => { + vi.useFakeTimers(); + renderWithRouter(); + const input = screen.getByPlaceholderText('Ask a question or search for help...'); + + fireEvent.change(input, { target: { value: ' ' } }); + vi.advanceTimersByTime(300); + + expect(mockSearch).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); + }); + + describe('clear button', () => { + it('shows clear button when query is not empty', () => { + renderWithRouter(); + const input = screen.getByPlaceholderText('Ask a question or search for help...'); + + fireEvent.change(input, { target: { value: 'scheduler' } }); + + const clearButton = screen.getByRole('button'); + expect(clearButton).toBeInTheDocument(); + }); + + it('does not show clear button when query is empty', () => { + renderWithRouter(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('clears query and focuses input when clicked', () => { + renderWithRouter(); + const input = screen.getByPlaceholderText('Ask a question or search for help...') as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'scheduler' } }); + + const clearButton = screen.getByRole('button'); + fireEvent.click(clearButton); + + expect(input).toHaveValue(''); + }); + }); + + describe('loading state', () => { + it('shows loading spinner when searching', () => { + vi.spyOn(useHelpSearchModule, 'useHelpSearch').mockReturnValue({ + ...defaultHookReturn, + isSearching: true, + }); + + renderWithRouter(); + const spinner = document.querySelector('.animate-spin'); + expect(spinner).toBeInTheDocument(); + }); + + it('shows search icon when not searching', () => { + renderWithRouter(); + const spinner = document.querySelector('.animate-spin'); + expect(spinner).not.toBeInTheDocument(); + }); + }); + + describe('keyboard navigation', () => { + it('closes dropdown and blurs input on Escape key', () => { + vi.spyOn(useHelpSearchModule, 'useHelpSearch').mockReturnValue({ + ...defaultHookReturn, + results: [ + { + path: '/help/scheduler', + title: 'Scheduler', + description: 'Manage schedules', + category: 'Scheduling', + topics: ['scheduler', 'calendar'], + relevanceScore: 10, + }, + ], + }); + + renderWithRouter(); + const input = screen.getByPlaceholderText('Ask a question or search for help...') as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'scheduler' } }); + fireEvent.focus(input); + + fireEvent.keyDown(input, { key: 'Escape' }); + + // Results should be hidden + expect(screen.queryByText('Scheduler')).not.toBeInTheDocument(); + }); + }); + + describe('results dropdown', () => { + const mockResults = [ + { + path: '/help/scheduler', + title: 'Scheduler', + description: 'Manage your schedules', + category: 'Scheduling', + topics: ['scheduler', 'calendar'], + relevanceScore: 10, + matchReason: 'Matched: title: scheduler', + }, + { + path: '/help/services', + title: 'Services', + description: 'Configure services', + category: 'Services', + topics: ['services', 'booking'], + relevanceScore: 5, + }, + ]; + + it('shows results when available and dropdown is open', () => { + vi.spyOn(useHelpSearchModule, 'useHelpSearch').mockReturnValue({ + ...defaultHookReturn, + results: mockResults, + }); + + renderWithRouter(); + const input = screen.getByPlaceholderText('Ask a question or search for help...'); + + fireEvent.change(input, { target: { value: 'scheduler' } }); + fireEvent.focus(input); + + // Use getAllByText since titles appear in both the result and category badge + const schedulerElements = screen.getAllByText('Scheduler'); + expect(schedulerElements.length).toBeGreaterThan(0); + const servicesElements = screen.getAllByText('Services'); + expect(servicesElements.length).toBeGreaterThan(0); + }); + + it('shows match reason when available', () => { + vi.spyOn(useHelpSearchModule, 'useHelpSearch').mockReturnValue({ + ...defaultHookReturn, + results: mockResults, + }); + + renderWithRouter(); + const input = screen.getByPlaceholderText('Ask a question or search for help...'); + + fireEvent.change(input, { target: { value: 'scheduler' } }); + fireEvent.focus(input); + + expect(screen.getByText('Matched: title: scheduler')).toBeInTheDocument(); + }); + + it('shows description when match reason is not available', () => { + vi.spyOn(useHelpSearchModule, 'useHelpSearch').mockReturnValue({ + ...defaultHookReturn, + results: mockResults, + }); + + renderWithRouter(); + const input = screen.getByPlaceholderText('Ask a question or search for help...'); + + fireEvent.change(input, { target: { value: 'services' } }); + fireEvent.focus(input); + + expect(screen.getByText('Configure services')).toBeInTheDocument(); + }); + + it('shows category badge for each result', () => { + vi.spyOn(useHelpSearchModule, 'useHelpSearch').mockReturnValue({ + ...defaultHookReturn, + results: mockResults, + }); + + renderWithRouter(); + const input = screen.getByPlaceholderText('Ask a question or search for help...'); + + fireEvent.change(input, { target: { value: 'scheduler' } }); + fireEvent.focus(input); + + expect(screen.getByText('Scheduling')).toBeInTheDocument(); + // Services appears as both category and title - get all instances + const servicesElements = screen.getAllByText('Services'); + expect(servicesElements.length).toBeGreaterThanOrEqual(1); + }); + + it('clears query and closes dropdown when result is clicked', () => { + vi.spyOn(useHelpSearchModule, 'useHelpSearch').mockReturnValue({ + ...defaultHookReturn, + results: mockResults, + }); + + renderWithRouter(); + const input = screen.getByPlaceholderText('Ask a question or search for help...') as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'scheduler' } }); + fireEvent.focus(input); + + const resultLink = screen.getByText('Scheduler').closest('a'); + fireEvent.click(resultLink!); + + expect(input).toHaveValue(''); + }); + }); + + describe('empty states', () => { + it('renders component when no results found', () => { + const mockSearchFn = vi.fn(); + vi.spyOn(useHelpSearchModule, 'useHelpSearch').mockReturnValue({ + search: mockSearchFn, + results: [], + isSearching: false, + error: null, + hasApiKey: false, + }); + + const { container } = renderWithRouter(); + expect(container).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Ask a question or search for help...')).toBeInTheDocument(); + }); + + it('does not show no results message while searching', () => { + vi.spyOn(useHelpSearchModule, 'useHelpSearch').mockReturnValue({ + ...defaultHookReturn, + isSearching: true, + results: [], + }); + + renderWithRouter(); + const input = screen.getByPlaceholderText('Ask a question or search for help...'); + + fireEvent.change(input, { target: { value: 'scheduler' } }); + fireEvent.focus(input); + + expect(screen.queryByText(/No results found/)).not.toBeInTheDocument(); + }); + }); + + describe('error state', () => { + it('renders component when error is present', () => { + const mockSearchFn = vi.fn(); + vi.spyOn(useHelpSearchModule, 'useHelpSearch').mockReturnValue({ + search: mockSearchFn, + results: [], + isSearching: false, + error: 'Search failed. Please try again.', + hasApiKey: false, + }); + + const { container } = renderWithRouter(); + expect(container).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Ask a question or search for help...')).toBeInTheDocument(); + }); + + it('handles error state without crashing', () => { + const mockSearchFn = vi.fn(); + vi.spyOn(useHelpSearchModule, 'useHelpSearch').mockReturnValue({ + search: mockSearchFn, + results: [], + isSearching: false, + error: 'Search failed', + hasApiKey: false, + }); + + const { container } = renderWithRouter(); + expect(container).toBeInTheDocument(); + }); + }); + + describe('click outside behavior', () => { + it('closes dropdown when clicking outside', () => { + vi.spyOn(useHelpSearchModule, 'useHelpSearch').mockReturnValue({ + ...defaultHookReturn, + results: [ + { + path: '/help/scheduler', + title: 'Scheduler', + description: 'Manage schedules', + category: 'Scheduling', + topics: ['scheduler'], + relevanceScore: 10, + }, + ], + }); + + const { container } = renderWithRouter(); + const input = screen.getByPlaceholderText('Ask a question or search for help...'); + + fireEvent.change(input, { target: { value: 'scheduler' } }); + fireEvent.focus(input); + + expect(screen.getByText('Scheduler')).toBeInTheDocument(); + + // Click outside + fireEvent.mouseDown(container); + + // Results should be hidden + expect(screen.queryByText('Scheduler')).not.toBeInTheDocument(); + }); + }); + + describe('focus behavior', () => { + it('reopens dropdown on focus if results exist', () => { + vi.spyOn(useHelpSearchModule, 'useHelpSearch').mockReturnValue({ + ...defaultHookReturn, + results: [ + { + path: '/help/scheduler', + title: 'Scheduler', + description: 'Manage schedules', + category: 'Scheduling', + topics: ['scheduler'], + relevanceScore: 10, + }, + ], + }); + + renderWithRouter(); + const input = screen.getByPlaceholderText('Ask a question or search for help...'); + + fireEvent.change(input, { target: { value: 'scheduler' } }); + fireEvent.blur(input); + + fireEvent.focus(input); + + expect(screen.getByText('Scheduler')).toBeInTheDocument(); + }); + + it('does not open dropdown on focus if query is empty', () => { + vi.spyOn(useHelpSearchModule, 'useHelpSearch').mockReturnValue({ + ...defaultHookReturn, + results: [ + { + path: '/help/scheduler', + title: 'Scheduler', + description: 'Manage schedules', + category: 'Scheduling', + topics: ['scheduler'], + relevanceScore: 10, + }, + ], + }); + + renderWithRouter(); + const input = screen.getByPlaceholderText('Ask a question or search for help...'); + + fireEvent.focus(input); + + expect(screen.queryByText('Scheduler')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/help/__tests__/UnscheduledBookingDemo.test.tsx b/frontend/src/components/help/__tests__/UnscheduledBookingDemo.test.tsx new file mode 100644 index 00000000..b3345e68 --- /dev/null +++ b/frontend/src/components/help/__tests__/UnscheduledBookingDemo.test.tsx @@ -0,0 +1,377 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { UnscheduledBookingDemo } from '../UnscheduledBookingDemo'; + +describe('UnscheduledBookingDemo', () => { + describe('rendering', () => { + it('renders all sections by default', () => { + render(); + + expect(screen.getByText('Service Settings')).toBeInTheDocument(); + expect(screen.getByText('Customer Booking')).toBeInTheDocument(); + expect(screen.getByText('Pending Requests')).toBeInTheDocument(); + }); + + it('renders only service section when view is "service"', () => { + render(); + + expect(screen.getByText('Service Settings')).toBeInTheDocument(); + expect(screen.queryByText('Customer Booking')).not.toBeInTheDocument(); + expect(screen.queryByText('Pending Requests')).not.toBeInTheDocument(); + }); + + it('renders only customer section when view is "customer"', () => { + render(); + + expect(screen.queryByText('Service Settings')).not.toBeInTheDocument(); + expect(screen.getByText('Customer Booking')).toBeInTheDocument(); + expect(screen.queryByText('Pending Requests')).not.toBeInTheDocument(); + }); + + it('renders only pending section when view is "pending"', () => { + render(); + + expect(screen.queryByText('Service Settings')).not.toBeInTheDocument(); + expect(screen.queryByText('Customer Booking')).not.toBeInTheDocument(); + expect(screen.getByText('Pending Requests')).toBeInTheDocument(); + }); + + it('renders interactive demo caption', () => { + render(); + expect(screen.getByText('Interactive demo - click to explore the workflow')).toBeInTheDocument(); + }); + }); + + describe('service configuration section', () => { + it('shows "Requires Manual Scheduling" checkbox checked by default', () => { + render(); + expect(screen.getByText('Requires Manual Scheduling')).toBeInTheDocument(); + }); + + it('toggles "Requires Manual Scheduling" on click', () => { + render(); + + const checkbox = screen.getByText('Requires Manual Scheduling').closest('div'); + expect(checkbox).toHaveClass('border-orange-500'); + + fireEvent.click(checkbox!); + + expect(checkbox).not.toHaveClass('border-orange-500'); + }); + + it('shows "Ask for Preferred Time" when manual scheduling is enabled', () => { + render(); + expect(screen.getByText('Ask for Preferred Time')).toBeInTheDocument(); + }); + + it('hides "Ask for Preferred Time" when manual scheduling is disabled', () => { + render(); + + const manualSchedulingCheckbox = screen.getByText('Requires Manual Scheduling').closest('div'); + fireEvent.click(manualSchedulingCheckbox!); + + expect(screen.queryByText('Ask for Preferred Time')).not.toBeInTheDocument(); + }); + + it('toggles "Ask for Preferred Time" on click', () => { + render(); + + const checkbox = screen.getByText('Ask for Preferred Time').closest('div'); + expect(checkbox).toHaveClass('border-blue-500'); + + fireEvent.click(checkbox!); + + expect(checkbox).not.toHaveClass('border-blue-500'); + }); + }); + + describe('customer booking section', () => { + it('shows manual scheduling message when enabled', () => { + render(); + expect(screen.getByText("We'll call you to schedule")).toBeInTheDocument(); + }); + + it('shows standard booking flow message when manual scheduling is disabled', () => { + render(); + + const manualSchedulingCheckbox = screen.getByText('Requires Manual Scheduling').closest('div'); + fireEvent.click(manualSchedulingCheckbox!); + + expect(screen.getByText('Standard booking flow')).toBeInTheDocument(); + }); + + it('shows "I have a preferred time" checkbox when capture is enabled', () => { + render(); + expect(screen.getByText('I have a preferred time')).toBeInTheDocument(); + }); + + it('hides "I have a preferred time" when capture is disabled', () => { + render(); + + const captureCheckbox = screen.getByText('Ask for Preferred Time').closest('div'); + fireEvent.click(captureCheckbox!); + + expect(screen.queryByText('I have a preferred time')).not.toBeInTheDocument(); + }); + + it('toggles "I have a preferred time" on click', () => { + render(); + + const checkbox = screen.getByText('I have a preferred time').closest('div'); + expect(checkbox).toHaveClass('border-blue-500'); + + fireEvent.click(checkbox!); + + expect(checkbox).not.toHaveClass('border-blue-500'); + }); + + it('shows date and time inputs when customer has preferred time', () => { + render(); + + const dateInput = screen.getByDisplayValue('2025-12-26'); + const timeInput = screen.getByDisplayValue('afternoons'); + + expect(dateInput).toBeInTheDocument(); + expect(timeInput).toBeInTheDocument(); + }); + + it('hides date and time inputs when customer does not have preferred time', () => { + render(); + + const checkbox = screen.getByText('I have a preferred time').closest('div'); + fireEvent.click(checkbox!); + + expect(screen.queryByDisplayValue('2025-12-26')).not.toBeInTheDocument(); + expect(screen.queryByDisplayValue('afternoons')).not.toBeInTheDocument(); + }); + + it('updates preferred date on input change', () => { + render(); + + const dateInput = screen.getByDisplayValue('2025-12-26') as HTMLInputElement; + fireEvent.change(dateInput, { target: { value: '2025-12-27' } }); + + expect(dateInput).toHaveValue('2025-12-27'); + }); + + it('updates preferred time notes on input change', () => { + render(); + + const timeInput = screen.getByDisplayValue('afternoons') as HTMLInputElement; + fireEvent.change(timeInput, { target: { value: 'mornings' } }); + + expect(timeInput).toHaveValue('mornings'); + }); + + it('shows request callback button', () => { + render(); + expect(screen.getByText('Request Callback')).toBeInTheDocument(); + }); + }); + + describe('pending requests section', () => { + it('shows sample pending requests', () => { + render(); + + expect(screen.getByText('Jane Smith')).toBeInTheDocument(); + expect(screen.getByText('Bob Johnson')).toBeInTheDocument(); + expect(screen.getByText('Lisa Park')).toBeInTheDocument(); + }); + + it('shows service name for each request', () => { + render(); + + const serviceNames = screen.getAllByText('Free Consultation'); + expect(serviceNames).toHaveLength(3); + }); + + it('shows preferred date and time when available', () => { + render(); + + expect(screen.getByText('Prefers: Dec 26, afternoons')).toBeInTheDocument(); + expect(screen.getByText('Prefers: Dec 27, 10:00 AM')).toBeInTheDocument(); + }); + + it('shows "No preferred time" when not available', () => { + render(); + expect(screen.getByText('No preferred time')).toBeInTheDocument(); + }); + + it('toggles selection on pending item click', () => { + render(); + + const janeItem = screen.getByText('Jane Smith').closest('div'); + expect(janeItem).not.toHaveClass('ring-2'); + + fireEvent.click(janeItem!); + expect(janeItem).toHaveClass('ring-2'); + + fireEvent.click(janeItem!); + expect(janeItem).not.toHaveClass('ring-2'); + }); + + it('shows detail panel when item is selected', () => { + render(); + + const janeItem = screen.getByText('Jane Smith').closest('div'); + fireEvent.click(janeItem!); + + expect(screen.getByText('Preferred Schedule')).toBeInTheDocument(); + expect(screen.getByText('December 26, 2025 - Afternoons')).toBeInTheDocument(); + }); + + it('shows correct details for different selected items', () => { + render(); + + // Select Bob Johnson + const bobItem = screen.getByText('Bob Johnson').closest('div'); + fireEvent.click(bobItem!); + + expect(screen.getByText('December 27, 2025 - 10:00 AM')).toBeInTheDocument(); + + // Select Lisa Park + fireEvent.click(bobItem!); + const lisaItem = screen.getByText('Lisa Park').closest('div'); + fireEvent.click(lisaItem!); + + expect(screen.getByText('No preference specified')).toBeInTheDocument(); + }); + + it('rotates chevron icon when item is selected', () => { + render(); + + const janeItem = screen.getByText('Jane Smith').closest('div'); + const chevron = janeItem?.querySelector('svg'); + + expect(chevron).not.toHaveClass('rotate-90'); + + fireEvent.click(janeItem!); + expect(chevron).toHaveClass('rotate-90'); + }); + }); + + describe('integration - service and customer sections', () => { + it('updates customer section when service settings change', () => { + render(); + + // Initially shows manual scheduling message + expect(screen.getByText("We'll call you to schedule")).toBeInTheDocument(); + + // Disable manual scheduling + const manualSchedulingCheckbox = screen.getByText('Requires Manual Scheduling').closest('div'); + fireEvent.click(manualSchedulingCheckbox!); + + // Should show standard booking flow + expect(screen.getByText('Standard booking flow')).toBeInTheDocument(); + expect(screen.queryByText("We'll call you to schedule")).not.toBeInTheDocument(); + }); + + it('hides preferred time fields when service setting is disabled', () => { + render(); + + // Initially shows preferred time checkbox + expect(screen.getByText('I have a preferred time')).toBeInTheDocument(); + + // Disable preferred time capture + const captureCheckbox = screen.getByText('Ask for Preferred Time').closest('div'); + fireEvent.click(captureCheckbox!); + + // Should hide customer preferred time checkbox + expect(screen.queryByText('I have a preferred time')).not.toBeInTheDocument(); + }); + + it('cascades changes through all sections', () => { + render(); + + // Initially shows all manual scheduling features + expect(screen.getByText('Ask for Preferred Time')).toBeInTheDocument(); + expect(screen.getByText('I have a preferred time')).toBeInTheDocument(); + expect(screen.getByDisplayValue('2025-12-26')).toBeInTheDocument(); + + // Disable manual scheduling entirely + const manualSchedulingCheckbox = screen.getByText('Requires Manual Scheduling').closest('div'); + fireEvent.click(manualSchedulingCheckbox!); + + // All related features should be hidden + expect(screen.queryByText('Ask for Preferred Time')).not.toBeInTheDocument(); + expect(screen.queryByText('I have a preferred time')).not.toBeInTheDocument(); + expect(screen.queryByDisplayValue('2025-12-26')).not.toBeInTheDocument(); + }); + }); + + describe('accessibility', () => { + it('uses proper labels for interactive elements', () => { + render(); + + const manualSchedulingLabel = screen.getByText('Requires Manual Scheduling'); + expect(manualSchedulingLabel.closest('label')).toBeInTheDocument(); + + const preferredTimeLabel = screen.getByText('Ask for Preferred Time'); + expect(preferredTimeLabel.closest('label')).toBeInTheDocument(); + }); + + it('date input has proper type attribute', () => { + render(); + + const dateInput = screen.getByDisplayValue('2025-12-26'); + expect(dateInput).toHaveAttribute('type', 'date'); + }); + + it('time notes input has proper type attribute', () => { + render(); + + const timeInput = screen.getByDisplayValue('afternoons'); + expect(timeInput).toHaveAttribute('type', 'text'); + }); + + it('time notes input has placeholder', () => { + render(); + + const checkbox = screen.getByText('I have a preferred time').closest('div'); + fireEvent.click(checkbox!); + fireEvent.click(checkbox!); + + const timeInput = screen.getByPlaceholderText('e.g., afternoons'); + expect(timeInput).toBeInTheDocument(); + }); + }); + + describe('styling and visual feedback', () => { + it('applies correct border color when manual scheduling is enabled', () => { + render(); + + const checkbox = screen.getByText('Requires Manual Scheduling').closest('div'); + expect(checkbox).toHaveClass('border-orange-500'); + expect(checkbox).toHaveClass('bg-orange-50'); + }); + + it('applies correct border color when preferred time is enabled', () => { + render(); + + const checkbox = screen.getByText('Ask for Preferred Time').closest('div'); + expect(checkbox).toHaveClass('border-blue-500'); + expect(checkbox).toHaveClass('bg-blue-50'); + }); + + it('applies correct styling to pending request with preferred time', () => { + render(); + + const janePreference = screen.getByText('Prefers: Dec 26, afternoons'); + expect(janePreference.closest('div')).toHaveClass('text-blue-600'); + }); + + it('applies correct styling to pending request without preferred time', () => { + render(); + + const lisaPreference = screen.getByText('No preferred time'); + expect(lisaPreference.closest('div')).toHaveClass('text-gray-400'); + }); + + it('applies orange border to pending request items', () => { + render(); + + const janeItem = screen.getByText('Jane Smith').closest('div'); + expect(janeItem).toHaveClass('border-orange-400'); + }); + }); +}); diff --git a/frontend/src/components/marketing/__tests__/AutomationShowcase.test.tsx b/frontend/src/components/marketing/__tests__/AutomationShowcase.test.tsx new file mode 100644 index 00000000..58d4e9cd --- /dev/null +++ b/frontend/src/components/marketing/__tests__/AutomationShowcase.test.tsx @@ -0,0 +1,242 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import AutomationShowcase from '../AutomationShowcase'; + +// Mock react-i18next +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string | { days?: number }) => { + // Handle translation keys with nested structure + const translations: Record = { + 'marketing.plugins.badge': 'Automation & Plugins', + 'marketing.plugins.headline': 'Automate Your Workflow', + 'marketing.plugins.subheadline': 'Build powerful automations with our plugin system', + 'marketing.plugins.examples.winback.title': 'Win-Back Campaign', + 'marketing.plugins.examples.winback.description': 'Re-engage inactive customers', + 'marketing.plugins.examples.winback.stats.retention': '+25% retention', + 'marketing.plugins.examples.winback.stats.revenue': '$10k+ revenue', + 'marketing.plugins.examples.noshow.title': 'No-Show Prevention', + 'marketing.plugins.examples.noshow.description': 'Reduce missed appointments', + 'marketing.plugins.examples.noshow.stats.reduction': '40% reduction', + 'marketing.plugins.examples.noshow.stats.utilization': '95% utilization', + 'marketing.plugins.examples.report.title': 'Daily Reports', + 'marketing.plugins.examples.report.description': 'Automated scheduling reports', + 'marketing.plugins.examples.report.stats.timeSaved': '2h saved/day', + 'marketing.plugins.examples.report.stats.visibility': 'Real-time visibility', + 'marketing.plugins.cta': 'Explore automation features', + }; + return translations[key] || (typeof fallback === 'string' ? fallback : key); + }, + }), +})); + +// Mock framer-motion +vi.mock('framer-motion', () => ({ + motion: { + div: ({ children, ...props }: any) => React.createElement('div', props, children), + }, + AnimatePresence: ({ children }: any) => React.createElement('div', null, children), +})); + +// Mock lucide-react icons +vi.mock('lucide-react', () => ({ + Mail: () => React.createElement('div', { 'data-testid': 'mail-icon' }), + Calendar: () => React.createElement('div', { 'data-testid': 'calendar-icon' }), + Bell: () => React.createElement('div', { 'data-testid': 'bell-icon' }), + ArrowRight: () => React.createElement('div', { 'data-testid': 'arrow-right-icon' }), + Zap: () => React.createElement('div', { 'data-testid': 'zap-icon' }), + CheckCircle2: () => React.createElement('div', { 'data-testid': 'check-circle-icon' }), + Clock: () => React.createElement('div', { 'data-testid': 'clock-icon' }), + Search: () => React.createElement('div', { 'data-testid': 'search-icon' }), + FileText: () => React.createElement('div', { 'data-testid': 'file-text-icon' }), + MessageSquare: () => React.createElement('div', { 'data-testid': 'message-square-icon' }), + Sparkles: () => React.createElement('div', { 'data-testid': 'sparkles-icon' }), + ChevronRight: () => React.createElement('div', { 'data-testid': 'chevron-right-icon' }), +})); + +// Mock WorkflowVisual component +vi.mock('../WorkflowVisual', () => ({ + default: ({ variant, trigger }: { variant: string; trigger: string }) => + React.createElement( + 'div', + { 'data-testid': 'workflow-visual', 'data-variant': variant }, + trigger + ), +})); + +describe('AutomationShowcase', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('rendering', () => { + it('renders component with headline and subheadline', () => { + render(React.createElement(AutomationShowcase)); + expect(screen.getByText('Automate Your Workflow')).toBeInTheDocument(); + expect(screen.getByText('Build powerful automations with our plugin system')).toBeInTheDocument(); + }); + + it('renders badge with automation text', () => { + render(React.createElement(AutomationShowcase)); + expect(screen.getByText('Automation & Plugins')).toBeInTheDocument(); + }); + + it('renders all three automation examples', () => { + render(React.createElement(AutomationShowcase)); + const winbackElements = screen.getAllByText('Win-Back Campaign'); + expect(winbackElements.length).toBeGreaterThan(0); + expect(screen.getByText('No-Show Prevention')).toBeInTheDocument(); + expect(screen.getByText('Daily Reports')).toBeInTheDocument(); + }); + + it('renders example descriptions', () => { + render(React.createElement(AutomationShowcase)); + expect(screen.getByText('Re-engage inactive customers')).toBeInTheDocument(); + expect(screen.getByText('Reduce missed appointments')).toBeInTheDocument(); + expect(screen.getByText('Automated scheduling reports')).toBeInTheDocument(); + }); + + it('renders WorkflowVisual component', () => { + render(React.createElement(AutomationShowcase)); + expect(screen.getByTestId('workflow-visual')).toBeInTheDocument(); + }); + + it('renders CTA link', () => { + render(React.createElement(AutomationShowcase)); + const ctaLink = screen.getByText('Explore automation features').closest('a'); + expect(ctaLink).toBeInTheDocument(); + expect(ctaLink).toHaveAttribute('href', '/features'); + }); + }); + + describe('tab switching', () => { + it('renders first example as active by default', () => { + const { container } = render(React.createElement(AutomationShowcase)); + const workflowVisual = screen.getByTestId('workflow-visual'); + expect(workflowVisual).toHaveAttribute('data-variant', 'winback'); + }); + + it('switches to second example when clicked', () => { + render(React.createElement(AutomationShowcase)); + + const noShowButton = screen.getByText('No-Show Prevention').closest('button'); + if (noShowButton) { + fireEvent.click(noShowButton); + } + + const workflowVisual = screen.getByTestId('workflow-visual'); + expect(workflowVisual).toHaveAttribute('data-variant', 'noshow'); + }); + + it('switches to third example when clicked', () => { + render(React.createElement(AutomationShowcase)); + + const reportButton = screen.getByText('Daily Reports').closest('button'); + if (reportButton) { + fireEvent.click(reportButton); + } + + const workflowVisual = screen.getByTestId('workflow-visual'); + expect(workflowVisual).toHaveAttribute('data-variant', 'report'); + }); + + it('displays stats for active example', () => { + render(React.createElement(AutomationShowcase)); + + // First example (winback) should show its stats + expect(screen.getByText('+25% retention')).toBeInTheDocument(); + expect(screen.getByText('$10k+ revenue')).toBeInTheDocument(); + }); + + it('updates stats when switching examples', () => { + render(React.createElement(AutomationShowcase)); + + // Switch to no-show example + const noShowButton = screen.getByText('No-Show Prevention').closest('button'); + if (noShowButton) { + fireEvent.click(noShowButton); + } + + // Should show no-show stats + expect(screen.getByText('40% reduction')).toBeInTheDocument(); + expect(screen.getByText('95% utilization')).toBeInTheDocument(); + }); + }); + + describe('icons', () => { + it('renders badge icon', () => { + render(React.createElement(AutomationShowcase)); + expect(screen.getAllByTestId('zap-icon')).toHaveLength(1); + }); + + it('renders mail icon for winback example', () => { + render(React.createElement(AutomationShowcase)); + expect(screen.getByTestId('mail-icon')).toBeInTheDocument(); + }); + + it('renders bell icon for noshow example', () => { + render(React.createElement(AutomationShowcase)); + expect(screen.getByTestId('bell-icon')).toBeInTheDocument(); + }); + + it('renders calendar icon for report example', () => { + render(React.createElement(AutomationShowcase)); + expect(screen.getByTestId('calendar-icon')).toBeInTheDocument(); + }); + + it('renders check circle icons for stats', () => { + render(React.createElement(AutomationShowcase)); + expect(screen.getAllByTestId('check-circle-icon').length).toBeGreaterThan(0); + }); + + it('renders arrow icon in CTA', () => { + render(React.createElement(AutomationShowcase)); + expect(screen.getByTestId('arrow-right-icon')).toBeInTheDocument(); + }); + }); + + describe('interactive behavior', () => { + it('applies active styles to selected example button', () => { + const { container } = render(React.createElement(AutomationShowcase)); + + const buttons = container.querySelectorAll('button'); + const activeButton = Array.from(buttons).find(btn => + btn.className.includes('bg-white') && btn.className.includes('border-brand-500') + ); + expect(activeButton).toBeDefined(); + }); + + it('changes active button when different example is clicked', () => { + render(React.createElement(AutomationShowcase)); + + const noShowButton = screen.getByText('No-Show Prevention').closest('button'); + if (noShowButton) { + fireEvent.click(noShowButton); + expect(noShowButton).toHaveClass('bg-white'); + } + }); + + it('renders all example buttons as clickable', () => { + render(React.createElement(AutomationShowcase)); + + const buttons = screen.getAllByRole('button'); + // Should have 3 example buttons + expect(buttons.length).toBeGreaterThanOrEqual(3); + }); + }); + + describe('layout', () => { + it('renders in two-column grid layout', () => { + const { container } = render(React.createElement(AutomationShowcase)); + const gridElement = container.querySelector('.grid.lg\\:grid-cols-2'); + expect(gridElement).toBeInTheDocument(); + }); + + it('renders stats in flex layout', () => { + const { container } = render(React.createElement(AutomationShowcase)); + const statsContainer = container.querySelector('.flex.gap-4'); + expect(statsContainer).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/marketing/__tests__/BenefitsSection.test.tsx b/frontend/src/components/marketing/__tests__/BenefitsSection.test.tsx new file mode 100644 index 00000000..005655be --- /dev/null +++ b/frontend/src/components/marketing/__tests__/BenefitsSection.test.tsx @@ -0,0 +1,165 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import BenefitsSection from '../BenefitsSection'; + +// Mock react-i18next +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + 'marketing.benefits.rapidDeployment.title': 'Rapid Deployment', + 'marketing.benefits.rapidDeployment.description': 'Get started in minutes with our quick setup', + 'marketing.benefits.enterpriseSecurity.title': 'Enterprise Security', + 'marketing.benefits.enterpriseSecurity.description': 'Bank-level encryption and compliance', + 'marketing.benefits.highPerformance.title': 'High Performance', + 'marketing.benefits.highPerformance.description': 'Fast and reliable service at scale', + 'marketing.benefits.expertSupport.title': 'Expert Support', + 'marketing.benefits.expertSupport.description': '24/7 customer support from our team', + }; + return translations[key] || key; + }, + }), +})); + +// Mock lucide-react icons +vi.mock('lucide-react', () => ({ + Rocket: () => React.createElement('div', { 'data-testid': 'rocket-icon' }), + Shield: () => React.createElement('div', { 'data-testid': 'shield-icon' }), + Zap: () => React.createElement('div', { 'data-testid': 'zap-icon' }), + Headphones: () => React.createElement('div', { 'data-testid': 'headphones-icon' }), +})); + +describe('BenefitsSection', () => { + describe('rendering', () => { + it('renders component without errors', () => { + render(React.createElement(BenefitsSection)); + expect(screen.getByText('Rapid Deployment')).toBeInTheDocument(); + }); + + it('renders all four benefit cards', () => { + render(React.createElement(BenefitsSection)); + expect(screen.getByText('Rapid Deployment')).toBeInTheDocument(); + expect(screen.getByText('Enterprise Security')).toBeInTheDocument(); + expect(screen.getByText('High Performance')).toBeInTheDocument(); + expect(screen.getByText('Expert Support')).toBeInTheDocument(); + }); + + it('renders all benefit descriptions', () => { + render(React.createElement(BenefitsSection)); + expect(screen.getByText('Get started in minutes with our quick setup')).toBeInTheDocument(); + expect(screen.getByText('Bank-level encryption and compliance')).toBeInTheDocument(); + expect(screen.getByText('Fast and reliable service at scale')).toBeInTheDocument(); + expect(screen.getByText('24/7 customer support from our team')).toBeInTheDocument(); + }); + }); + + describe('icons', () => { + it('renders rocket icon for rapid deployment', () => { + render(React.createElement(BenefitsSection)); + expect(screen.getByTestId('rocket-icon')).toBeInTheDocument(); + }); + + it('renders shield icon for enterprise security', () => { + render(React.createElement(BenefitsSection)); + expect(screen.getByTestId('shield-icon')).toBeInTheDocument(); + }); + + it('renders zap icon for high performance', () => { + render(React.createElement(BenefitsSection)); + expect(screen.getByTestId('zap-icon')).toBeInTheDocument(); + }); + + it('renders headphones icon for expert support', () => { + render(React.createElement(BenefitsSection)); + expect(screen.getByTestId('headphones-icon')).toBeInTheDocument(); + }); + }); + + describe('layout', () => { + it('renders benefits in grid layout', () => { + const { container } = render(React.createElement(BenefitsSection)); + const gridElement = container.querySelector('.grid.md\\:grid-cols-2.lg\\:grid-cols-4'); + expect(gridElement).toBeInTheDocument(); + }); + + it('renders each benefit card with proper structure', () => { + const { container } = render(React.createElement(BenefitsSection)); + const cards = container.querySelectorAll('.text-center'); + expect(cards.length).toBe(4); + }); + + it('renders in a section element', () => { + const { container } = render(React.createElement(BenefitsSection)); + const section = container.querySelector('section'); + expect(section).toBeInTheDocument(); + }); + }); + + describe('styling', () => { + it('applies correct background colors to section', () => { + const { container } = render(React.createElement(BenefitsSection)); + const section = container.querySelector('.bg-white.dark\\:bg-gray-900'); + expect(section).toBeInTheDocument(); + }); + + it('applies proper spacing', () => { + const { container } = render(React.createElement(BenefitsSection)); + const section = container.querySelector('.py-20'); + expect(section).toBeInTheDocument(); + }); + + it('renders titles with proper typography', () => { + render(React.createElement(BenefitsSection)); + const titles = screen.getAllByRole('heading', { level: 3 }); + expect(titles.length).toBe(4); + }); + }); + + describe('content', () => { + it('displays all benefit titles as headings', () => { + render(React.createElement(BenefitsSection)); + expect(screen.getByRole('heading', { name: 'Rapid Deployment' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Enterprise Security' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'High Performance' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Expert Support' })).toBeInTheDocument(); + }); + + it('maintains correct order of benefits', () => { + const { container } = render(React.createElement(BenefitsSection)); + const headings = container.querySelectorAll('h3'); + expect(headings[0]).toHaveTextContent('Rapid Deployment'); + expect(headings[1]).toHaveTextContent('Enterprise Security'); + expect(headings[2]).toHaveTextContent('High Performance'); + expect(headings[3]).toHaveTextContent('Expert Support'); + }); + }); + + describe('responsive design', () => { + it('applies responsive grid classes', () => { + const { container } = render(React.createElement(BenefitsSection)); + const grid = container.querySelector('.md\\:grid-cols-2'); + expect(grid).toBeInTheDocument(); + }); + + it('applies responsive padding', () => { + const { container } = render(React.createElement(BenefitsSection)); + const paddedElement = container.querySelector('.sm\\:px-6'); + expect(paddedElement).toBeInTheDocument(); + }); + }); + + describe('hover effects', () => { + it('applies hover transform classes to cards', () => { + const { container } = render(React.createElement(BenefitsSection)); + const hoverElements = container.querySelectorAll('.hover\\:-translate-y-1'); + expect(hoverElements.length).toBe(4); + }); + + it('applies transition classes', () => { + const { container } = render(React.createElement(BenefitsSection)); + const transitionElements = container.querySelectorAll('.transition-transform'); + expect(transitionElements.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/frontend/src/components/marketing/__tests__/DynamicPricingCards.test.tsx b/frontend/src/components/marketing/__tests__/DynamicPricingCards.test.tsx new file mode 100644 index 00000000..e20af0a0 --- /dev/null +++ b/frontend/src/components/marketing/__tests__/DynamicPricingCards.test.tsx @@ -0,0 +1,337 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import DynamicPricingCards from '../DynamicPricingCards'; + +// Mock react-router-dom +vi.mock('react-router-dom', () => ({ + Link: ({ to, children, ...props }: any) => + React.createElement('a', { href: to, ...props }, children), +})); + +// Mock react-i18next +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string | { days?: number }) => { + const translations: Record = { + 'marketing.pricing.monthly': 'Monthly', + 'marketing.pricing.annual': 'Annual', + 'marketing.pricing.savePercent': 'Save ~17%', + 'marketing.pricing.mostPopular': 'Most Popular', + 'marketing.pricing.custom': 'Custom', + 'marketing.pricing.perYear': '/year', + 'marketing.pricing.perMonth': '/month', + 'marketing.pricing.contactSales': 'Contact Sales', + 'marketing.pricing.getStartedFree': 'Get Started Free', + 'marketing.pricing.startTrial': 'Start Free Trial', + 'marketing.pricing.freeForever': 'Free forever', + 'marketing.pricing.loadError': 'Unable to load pricing. Please try again later.', + }; + + // Handle dynamic trial days translation + if (key === 'marketing.pricing.trialDays' && typeof fallback === 'object' && fallback.days) { + return `${fallback.days}-day free trial`; + } + + return translations[key] || (typeof fallback === 'string' ? fallback : key); + }, + }), +})); + +// Mock lucide-react icons +vi.mock('lucide-react', () => ({ + Check: () => React.createElement('div', { 'data-testid': 'check-icon' }), + Loader2: () => React.createElement('div', { 'data-testid': 'loader-icon' }), +})); + +// Mock usePublicPlans hook +const mockPlans = [ + { + id: '1', + plan: { + id: '1', + name: 'Free', + code: 'free', + description: 'Free plan for getting started', + display_order: 0, + }, + price_monthly_cents: 0, + price_yearly_cents: 0, + is_most_popular: false, + show_price: true, + marketing_features: ['Basic scheduling', 'Up to 10 appointments'], + trial_days: 0, + }, + { + id: '2', + plan: { + id: '2', + name: 'Professional', + code: 'professional', + description: 'For growing businesses', + display_order: 1, + }, + price_monthly_cents: 2900, + price_yearly_cents: 29000, + is_most_popular: true, + show_price: true, + marketing_features: ['Unlimited appointments', 'SMS reminders', 'Priority support'], + trial_days: 14, + }, + { + id: '3', + plan: { + id: '3', + name: 'Enterprise', + code: 'enterprise', + description: 'For large organizations', + display_order: 2, + }, + price_monthly_cents: 0, + price_yearly_cents: 0, + is_most_popular: false, + show_price: false, + marketing_features: ['Custom features', 'Dedicated support', 'SLA guarantee'], + trial_days: 0, + }, +]; + +vi.mock('../../hooks/usePublicPlans', () => ({ + usePublicPlans: vi.fn(), + formatPrice: (cents: number) => { + if (cents === 0) return '$0'; + return `$${(cents / 100).toFixed(0)}`; + }, +})); + +import { usePublicPlans } from '../../hooks/usePublicPlans'; + +describe('DynamicPricingCards', () => { + beforeEach(() => { + vi.clearAllMocks(); + (usePublicPlans as any).mockReturnValue({ + data: mockPlans, + isLoading: false, + error: null, + }); + }); + + describe('loading state', () => { + it('renders loading spinner when loading', () => { + (usePublicPlans as any).mockReturnValue({ + data: null, + isLoading: true, + error: null, + }); + + render(React.createElement(DynamicPricingCards)); + expect(screen.getByTestId('loader-icon')).toBeInTheDocument(); + }); + + it('does not render plans while loading', () => { + (usePublicPlans as any).mockReturnValue({ + data: null, + isLoading: true, + error: null, + }); + + render(React.createElement(DynamicPricingCards)); + expect(screen.queryByText('Free')).not.toBeInTheDocument(); + }); + }); + + describe('error state', () => { + it('renders error message when there is an error', () => { + (usePublicPlans as any).mockReturnValue({ + data: null, + isLoading: false, + error: new Error('Failed to load'), + }); + + render(React.createElement(DynamicPricingCards)); + expect(screen.getByText('Unable to load pricing. Please try again later.')).toBeInTheDocument(); + }); + + it('renders error message when plans data is null', () => { + (usePublicPlans as any).mockReturnValue({ + data: null, + isLoading: false, + error: null, + }); + + render(React.createElement(DynamicPricingCards)); + expect(screen.getByText('Unable to load pricing. Please try again later.')).toBeInTheDocument(); + }); + }); + + describe('billing toggle', () => { + it('renders billing period toggle buttons', () => { + render(React.createElement(DynamicPricingCards)); + expect(screen.getByText('Monthly')).toBeInTheDocument(); + expect(screen.getByText('Annual')).toBeInTheDocument(); + }); + + it('defaults to monthly billing', () => { + render(React.createElement(DynamicPricingCards)); + const monthlyButton = screen.getByText('Monthly').closest('button'); + expect(monthlyButton).toHaveClass('bg-white'); + }); + + it('switches to annual billing when clicked', () => { + render(React.createElement(DynamicPricingCards)); + const annualButton = screen.getByText('Annual').closest('button'); + if (annualButton) { + fireEvent.click(annualButton); + expect(annualButton).toHaveClass('bg-white'); + } + }); + + it('displays save percentage text for annual billing', () => { + render(React.createElement(DynamicPricingCards)); + expect(screen.getByText('Save ~17%')).toBeInTheDocument(); + }); + }); + + describe('plan cards rendering', () => { + it('renders all plan cards', () => { + render(React.createElement(DynamicPricingCards)); + expect(screen.getByText('Free')).toBeInTheDocument(); + expect(screen.getByText('Professional')).toBeInTheDocument(); + expect(screen.getByText('Enterprise')).toBeInTheDocument(); + }); + + it('renders plan descriptions', () => { + render(React.createElement(DynamicPricingCards)); + expect(screen.getByText('Free plan for getting started')).toBeInTheDocument(); + expect(screen.getByText('For growing businesses')).toBeInTheDocument(); + expect(screen.getByText('For large organizations')).toBeInTheDocument(); + }); + + it('displays plans in correct order', () => { + const { container } = render(React.createElement(DynamicPricingCards)); + const planNames = Array.from(container.querySelectorAll('h3')).map((h3) => h3.textContent); + expect(planNames).toEqual(['Free', 'Professional', 'Enterprise']); + }); + }); + + describe('most popular badge', () => { + it('renders most popular badge for the professional plan', () => { + render(React.createElement(DynamicPricingCards)); + expect(screen.getByText('Most Popular')).toBeInTheDocument(); + }); + + it('applies special styling to most popular card', () => { + const { container } = render(React.createElement(DynamicPricingCards)); + const professionalCard = screen.getByText('Professional').closest('div'); + expect(professionalCard).toHaveClass('bg-brand-600'); + }); + }); + + describe('pricing display', () => { + it('displays $0 for free plan', () => { + render(React.createElement(DynamicPricingCards)); + expect(screen.getByText('$0')).toBeInTheDocument(); + }); + + it('displays monthly price for professional plan', () => { + render(React.createElement(DynamicPricingCards)); + expect(screen.getByText('$29')).toBeInTheDocument(); + }); + + it('displays "Custom" for enterprise plan', () => { + render(React.createElement(DynamicPricingCards)); + expect(screen.getByText('Custom')).toBeInTheDocument(); + }); + + it('displays "Free forever" text for free plan', () => { + render(React.createElement(DynamicPricingCards)); + expect(screen.getByText('Free forever')).toBeInTheDocument(); + }); + + it('displays trial days for professional plan', () => { + render(React.createElement(DynamicPricingCards)); + expect(screen.getByText('14-day free trial')).toBeInTheDocument(); + }); + }); + + describe('features display', () => { + it('renders features for free plan', () => { + render(React.createElement(DynamicPricingCards)); + expect(screen.getByText('Basic scheduling')).toBeInTheDocument(); + expect(screen.getByText('Up to 10 appointments')).toBeInTheDocument(); + }); + + it('renders features for professional plan', () => { + render(React.createElement(DynamicPricingCards)); + expect(screen.getByText('Unlimited appointments')).toBeInTheDocument(); + expect(screen.getByText('SMS reminders')).toBeInTheDocument(); + expect(screen.getByText('Priority support')).toBeInTheDocument(); + }); + + it('renders check icons for features', () => { + render(React.createElement(DynamicPricingCards)); + const checkIcons = screen.getAllByTestId('check-icon'); + // Should have check icons for all features across all plans + expect(checkIcons.length).toBeGreaterThan(0); + }); + }); + + describe('CTA buttons', () => { + it('renders "Get Started Free" for free plan', () => { + render(React.createElement(DynamicPricingCards)); + expect(screen.getByText('Get Started Free')).toBeInTheDocument(); + }); + + it('renders "Start Free Trial" for professional plan', () => { + render(React.createElement(DynamicPricingCards)); + expect(screen.getByText('Start Free Trial')).toBeInTheDocument(); + }); + + it('renders "Contact Sales" for enterprise plan', () => { + render(React.createElement(DynamicPricingCards)); + expect(screen.getByText('Contact Sales')).toBeInTheDocument(); + }); + + it('links to signup page with plan code for non-enterprise plans', () => { + render(React.createElement(DynamicPricingCards)); + const freeLink = screen.getByText('Get Started Free').closest('a'); + expect(freeLink).toHaveAttribute('href', '/signup?plan=free'); + }); + + it('links to contact page for enterprise plan', () => { + render(React.createElement(DynamicPricingCards)); + const enterpriseLink = screen.getByText('Contact Sales').closest('a'); + expect(enterpriseLink).toHaveAttribute('href', '/contact'); + }); + }); + + describe('className prop', () => { + it('applies custom className to wrapper', () => { + const { container } = render( + React.createElement(DynamicPricingCards, { className: 'custom-class' }) + ); + const wrapper = container.querySelector('.custom-class'); + expect(wrapper).toBeInTheDocument(); + }); + + it('applies empty string as default className', () => { + render(React.createElement(DynamicPricingCards)); + // Should render without errors + expect(screen.getByText('Free')).toBeInTheDocument(); + }); + }); + + describe('layout', () => { + it('renders plans in grid layout', () => { + const { container } = render(React.createElement(DynamicPricingCards)); + const grid = container.querySelector('.grid'); + expect(grid).toBeInTheDocument(); + }); + + it('applies responsive grid classes', () => { + const { container } = render(React.createElement(DynamicPricingCards)); + const grid = container.querySelector('.md\\:grid-cols-2.lg\\:grid-cols-3'); + expect(grid).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/marketing/__tests__/FeatureComparisonTable.test.tsx b/frontend/src/components/marketing/__tests__/FeatureComparisonTable.test.tsx new file mode 100644 index 00000000..f268fffa --- /dev/null +++ b/frontend/src/components/marketing/__tests__/FeatureComparisonTable.test.tsx @@ -0,0 +1,369 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import FeatureComparisonTable from '../FeatureComparisonTable'; + +// Mock react-i18next +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string) => { + const translations: Record = { + 'marketing.pricing.featureComparison.features': 'Features', + 'marketing.pricing.featureComparison.categories.limits': 'Limits', + 'marketing.pricing.featureComparison.categories.communication': 'Communication', + 'marketing.pricing.featureComparison.categories.booking': 'Booking', + 'marketing.pricing.featureComparison.categories.integrations': 'Integrations', + 'marketing.pricing.featureComparison.categories.branding': 'Branding', + 'marketing.pricing.featureComparison.categories.enterprise': 'Enterprise', + 'marketing.pricing.featureComparison.categories.support': 'Support', + 'marketing.pricing.featureComparison.categories.storage': 'Storage', + 'marketing.pricing.featureComparison.features.max_users': 'Team members', + 'marketing.pricing.featureComparison.features.max_resources': 'Resources', + 'marketing.pricing.featureComparison.features.email_enabled': 'Email notifications', + 'marketing.pricing.featureComparison.features.sms_enabled': 'SMS reminders', + 'marketing.pricing.featureComparison.features.online_booking': 'Online booking', + 'marketing.pricing.featureComparison.features.api_access': 'API access', + 'marketing.pricing.featureComparison.features.custom_branding': 'Custom branding', + }; + return translations[key] || (fallback || key); + }, + }), +})); + +// Mock lucide-react icons +vi.mock('lucide-react', () => ({ + Check: () => React.createElement('div', { 'data-testid': 'check-icon' }), + X: () => React.createElement('div', { 'data-testid': 'x-icon' }), + Minus: () => React.createElement('div', { 'data-testid': 'minus-icon' }), + Loader2: () => React.createElement('div', { 'data-testid': 'loader-icon' }), +})); + +// Mock usePublicPlans hook +const mockPlans = [ + { + id: '1', + plan: { + id: '1', + name: 'Free', + code: 'free', + description: 'Free plan', + display_order: 0, + }, + price_monthly_cents: 0, + price_yearly_cents: 0, + is_most_popular: false, + show_price: true, + marketing_features: [], + trial_days: 0, + features: { + max_users: 1, + max_resources: 5, + email_enabled: true, + sms_enabled: false, + online_booking: true, + api_access: false, + custom_branding: false, + max_storage_mb: 500, + }, + }, + { + id: '2', + plan: { + id: '2', + name: 'Professional', + code: 'professional', + description: 'Professional plan', + display_order: 1, + }, + price_monthly_cents: 2900, + price_yearly_cents: 29000, + is_most_popular: true, + show_price: true, + marketing_features: [], + trial_days: 14, + features: { + max_users: 0, // Unlimited + max_resources: 0, // Unlimited + email_enabled: true, + sms_enabled: true, + online_booking: true, + api_access: true, + custom_branding: true, + max_storage_mb: 5000, + }, + }, +]; + +vi.mock('../../hooks/usePublicPlans', () => ({ + usePublicPlans: vi.fn(), + getPlanFeatureValue: (plan: any, featureCode: string) => { + return plan.features?.[featureCode]; + }, + formatLimit: (value: number) => { + if (value === 0) return 'Unlimited'; + if (value >= 1000) return `${(value / 1000).toFixed(0)}k`; + return value.toString(); + }, +})); + +import { usePublicPlans } from '../../hooks/usePublicPlans'; + +describe('FeatureComparisonTable', () => { + beforeEach(() => { + vi.clearAllMocks(); + (usePublicPlans as any).mockReturnValue({ + data: mockPlans, + isLoading: false, + error: null, + }); + }); + + describe('loading state', () => { + it('renders loading spinner when loading', () => { + (usePublicPlans as any).mockReturnValue({ + data: null, + isLoading: true, + error: null, + }); + + render(React.createElement(FeatureComparisonTable)); + expect(screen.getByTestId('loader-icon')).toBeInTheDocument(); + }); + + it('does not render table while loading', () => { + (usePublicPlans as any).mockReturnValue({ + data: null, + isLoading: true, + error: null, + }); + + render(React.createElement(FeatureComparisonTable)); + expect(screen.queryByText('Features')).not.toBeInTheDocument(); + }); + }); + + describe('error state', () => { + it('renders nothing when there is an error', () => { + (usePublicPlans as any).mockReturnValue({ + data: null, + isLoading: false, + error: new Error('Failed to load'), + }); + + const { container } = render(React.createElement(FeatureComparisonTable)); + expect(container.firstChild).toBeNull(); + }); + + it('renders nothing when plans array is empty', () => { + (usePublicPlans as any).mockReturnValue({ + data: [], + isLoading: false, + error: null, + }); + + const { container } = render(React.createElement(FeatureComparisonTable)); + expect(container.firstChild).toBeNull(); + }); + }); + + describe('table structure', () => { + it('renders table element', () => { + const { container } = render(React.createElement(FeatureComparisonTable)); + const table = container.querySelector('table'); + expect(table).toBeInTheDocument(); + }); + + it('renders table header with Features column', () => { + render(React.createElement(FeatureComparisonTable)); + expect(screen.getByText('Features')).toBeInTheDocument(); + }); + + it('renders plan names in header', () => { + render(React.createElement(FeatureComparisonTable)); + expect(screen.getByText('Free')).toBeInTheDocument(); + expect(screen.getByText('Professional')).toBeInTheDocument(); + }); + + it('highlights most popular plan in header', () => { + const { container } = render(React.createElement(FeatureComparisonTable)); + const professionalHeader = screen.getByText('Professional').closest('th'); + expect(professionalHeader).toHaveClass('text-brand-600'); + }); + }); + + describe('category sections', () => { + it('renders all category headers', () => { + render(React.createElement(FeatureComparisonTable)); + expect(screen.getByText('Limits')).toBeInTheDocument(); + expect(screen.getByText('Communication')).toBeInTheDocument(); + expect(screen.getByText('Booking')).toBeInTheDocument(); + expect(screen.getByText('Integrations')).toBeInTheDocument(); + expect(screen.getByText('Branding')).toBeInTheDocument(); + expect(screen.getByText('Enterprise')).toBeInTheDocument(); + expect(screen.getByText('Support')).toBeInTheDocument(); + expect(screen.getByText('Storage')).toBeInTheDocument(); + }); + + it('renders category headers with proper styling', () => { + const { container } = render(React.createElement(FeatureComparisonTable)); + const categoryHeaders = container.querySelectorAll('.uppercase.tracking-wider'); + expect(categoryHeaders.length).toBeGreaterThan(0); + }); + }); + + describe('feature rows', () => { + it('renders feature labels', () => { + render(React.createElement(FeatureComparisonTable)); + expect(screen.getByText('Team members')).toBeInTheDocument(); + expect(screen.getByText('Resources')).toBeInTheDocument(); + expect(screen.getByText('Email notifications')).toBeInTheDocument(); + expect(screen.getByText('SMS reminders')).toBeInTheDocument(); + }); + + it('displays boolean features with check/x icons', () => { + render(React.createElement(FeatureComparisonTable)); + const checkIcons = screen.getAllByTestId('check-icon'); + const xIcons = screen.getAllByTestId('x-icon'); + expect(checkIcons.length).toBeGreaterThan(0); + expect(xIcons.length).toBeGreaterThan(0); + }); + + it('displays numeric limits correctly', () => { + render(React.createElement(FeatureComparisonTable)); + // Free plan has limit of 1 user + expect(screen.getByText('1')).toBeInTheDocument(); + // Professional plan has unlimited + expect(screen.getAllByText('Unlimited').length).toBeGreaterThan(0); + }); + }); + + describe('storage display', () => { + it('displays storage in MB for values under 1000', () => { + render(React.createElement(FeatureComparisonTable)); + expect(screen.getByText('500 MB')).toBeInTheDocument(); + }); + + it('displays storage in GB for values over 1000', () => { + render(React.createElement(FeatureComparisonTable)); + expect(screen.getByText('5 GB')).toBeInTheDocument(); + }); + + it('displays unlimited for 0 storage value', () => { + const plansWithUnlimitedStorage = [ + { + ...mockPlans[0], + features: { ...mockPlans[0].features, max_storage_mb: 0 }, + }, + ]; + + (usePublicPlans as any).mockReturnValue({ + data: plansWithUnlimitedStorage, + isLoading: false, + error: null, + }); + + render(React.createElement(FeatureComparisonTable)); + expect(screen.getByText('Unlimited')).toBeInTheDocument(); + }); + }); + + describe('most popular highlighting', () => { + it('applies background color to most popular plan columns', () => { + const { container } = render(React.createElement(FeatureComparisonTable)); + const professionalCells = container.querySelectorAll('.bg-brand-50\\/50'); + expect(professionalCells.length).toBeGreaterThan(0); + }); + + it('highlights most popular plan header', () => { + const { container } = render(React.createElement(FeatureComparisonTable)); + const professionalHeader = screen.getByText('Professional').closest('th'); + expect(professionalHeader).toHaveClass('bg-brand-50'); + }); + }); + + describe('className prop', () => { + it('applies custom className to wrapper', () => { + const { container } = render( + React.createElement(FeatureComparisonTable, { className: 'custom-class' }) + ); + const wrapper = container.querySelector('.custom-class'); + expect(wrapper).toBeInTheDocument(); + }); + + it('applies empty string as default className', () => { + render(React.createElement(FeatureComparisonTable)); + expect(screen.getByText('Features')).toBeInTheDocument(); + }); + }); + + describe('responsive design', () => { + it('applies overflow-x-auto for horizontal scrolling', () => { + const { container } = render(React.createElement(FeatureComparisonTable)); + const wrapper = container.querySelector('.overflow-x-auto'); + expect(wrapper).toBeInTheDocument(); + }); + + it('sets minimum width on table', () => { + const { container } = render(React.createElement(FeatureComparisonTable)); + const table = container.querySelector('table.min-w-\\[800px\\]'); + expect(table).toBeInTheDocument(); + }); + }); + + describe('plan sorting', () => { + it('displays plans in order by display_order', () => { + const { container } = render(React.createElement(FeatureComparisonTable)); + const headers = container.querySelectorAll('th'); + // First th is "Features", then plan names in order + expect(headers[1]).toHaveTextContent('Free'); + expect(headers[2]).toHaveTextContent('Professional'); + }); + }); + + describe('feature value rendering', () => { + it('renders check icon for true boolean values', () => { + render(React.createElement(FeatureComparisonTable)); + const checkIcons = screen.getAllByTestId('check-icon'); + expect(checkIcons.length).toBeGreaterThan(0); + }); + + it('renders x icon for false boolean values', () => { + render(React.createElement(FeatureComparisonTable)); + const xIcons = screen.getAllByTestId('x-icon'); + expect(xIcons.length).toBeGreaterThan(0); + }); + + it('handles undefined feature values gracefully', () => { + const plansWithMissingFeatures = [ + { + ...mockPlans[0], + features: {}, + }, + ]; + + (usePublicPlans as any).mockReturnValue({ + data: plansWithMissingFeatures, + isLoading: false, + error: null, + }); + + render(React.createElement(FeatureComparisonTable)); + // Should still render without errors + expect(screen.getByText('Features')).toBeInTheDocument(); + }); + }); + + describe('accessibility', () => { + it('uses semantic table structure', () => { + const { container } = render(React.createElement(FeatureComparisonTable)); + expect(container.querySelector('thead')).toBeInTheDocument(); + expect(container.querySelector('tbody')).toBeInTheDocument(); + }); + + it('uses proper table headers', () => { + const { container } = render(React.createElement(FeatureComparisonTable)); + const headers = container.querySelectorAll('th'); + expect(headers.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/frontend/src/components/marketing/__tests__/TestimonialCard.test.tsx b/frontend/src/components/marketing/__tests__/TestimonialCard.test.tsx new file mode 100644 index 00000000..b7452394 --- /dev/null +++ b/frontend/src/components/marketing/__tests__/TestimonialCard.test.tsx @@ -0,0 +1,287 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import TestimonialCard from '../TestimonialCard'; + +// Mock lucide-react icons +vi.mock('lucide-react', () => ({ + Star: () => React.createElement('div', { 'data-testid': 'star-icon' }), +})); + +describe('TestimonialCard', () => { + const defaultProps = { + quote: 'This is an amazing product!', + author: 'John Doe', + role: 'CEO', + company: 'Acme Corp', + }; + + describe('rendering', () => { + it('renders component without errors', () => { + render(React.createElement(TestimonialCard, defaultProps)); + expect(screen.getByText('This is an amazing product!')).toBeInTheDocument(); + }); + + it('renders quote text with quotation marks', () => { + render(React.createElement(TestimonialCard, defaultProps)); + const quote = screen.getByText(/"This is an amazing product!"/); + expect(quote).toBeInTheDocument(); + }); + + it('renders author name', () => { + render(React.createElement(TestimonialCard, defaultProps)); + expect(screen.getByText('John Doe')).toBeInTheDocument(); + }); + + it('renders role and company', () => { + render(React.createElement(TestimonialCard, defaultProps)); + expect(screen.getByText('CEO at Acme Corp')).toBeInTheDocument(); + }); + }); + + describe('rating stars', () => { + it('renders 5 stars by default', () => { + render(React.createElement(TestimonialCard, defaultProps)); + const stars = screen.getAllByTestId('star-icon'); + expect(stars).toHaveLength(5); + }); + + it('renders custom rating when provided', () => { + render(React.createElement(TestimonialCard, { ...defaultProps, rating: 4 })); + const stars = screen.getAllByTestId('star-icon'); + expect(stars).toHaveLength(5); + }); + + it('renders 5 stars for rating of 5', () => { + render(React.createElement(TestimonialCard, { ...defaultProps, rating: 5 })); + const stars = screen.getAllByTestId('star-icon'); + expect(stars).toHaveLength(5); + }); + + it('renders 5 stars for rating of 3', () => { + render(React.createElement(TestimonialCard, { ...defaultProps, rating: 3 })); + const stars = screen.getAllByTestId('star-icon'); + expect(stars).toHaveLength(5); + }); + + it('renders 5 stars for rating of 1', () => { + render(React.createElement(TestimonialCard, { ...defaultProps, rating: 1 })); + const stars = screen.getAllByTestId('star-icon'); + expect(stars).toHaveLength(5); + }); + + it('applies filled class to stars based on rating', () => { + const { container } = render( + React.createElement(TestimonialCard, { ...defaultProps, rating: 3 }) + ); + const filledStars = container.querySelectorAll('.fill-yellow-400'); + // 3 filled stars (based on rating of 3) + expect(filledStars.length).toBe(3); + }); + }); + + describe('avatar', () => { + it('renders avatar image when avatarUrl is provided', () => { + const propsWithAvatar = { + ...defaultProps, + avatarUrl: 'https://example.com/avatar.jpg', + }; + render(React.createElement(TestimonialCard, propsWithAvatar)); + + const avatar = screen.getByRole('img'); + expect(avatar).toBeInTheDocument(); + expect(avatar).toHaveAttribute('src', 'https://example.com/avatar.jpg'); + expect(avatar).toHaveAttribute('alt', 'John Doe'); + }); + + it('renders initials when avatarUrl is not provided', () => { + render(React.createElement(TestimonialCard, defaultProps)); + + // Should not have an img element + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + + // Should have initials displayed + expect(screen.getByText('J')).toBeInTheDocument(); + }); + + it('displays first letter of author name as initial', () => { + const propsWithDifferentName = { + ...defaultProps, + author: 'Sarah Smith', + }; + render(React.createElement(TestimonialCard, propsWithDifferentName)); + expect(screen.getByText('S')).toBeInTheDocument(); + }); + + it('handles empty author name gracefully', () => { + const propsWithEmptyName = { + ...defaultProps, + author: '', + }; + const { container } = render(React.createElement(TestimonialCard, propsWithEmptyName)); + // Should render without crashing + expect(container).toBeInTheDocument(); + }); + }); + + describe('content variations', () => { + it('renders long quotes correctly', () => { + const longQuote = + 'This is a very long testimonial that contains multiple sentences. It talks about how amazing the product is and how it has helped the business grow. The service is exceptional and the support team is always responsive.'; + const propsWithLongQuote = { + ...defaultProps, + quote: longQuote, + }; + render(React.createElement(TestimonialCard, propsWithLongQuote)); + expect(screen.getByText(new RegExp(longQuote))).toBeInTheDocument(); + }); + + it('renders short quotes correctly', () => { + const shortQuote = 'Great!'; + const propsWithShortQuote = { + ...defaultProps, + quote: shortQuote, + }; + render(React.createElement(TestimonialCard, propsWithShortQuote)); + expect(screen.getByText(new RegExp(shortQuote))).toBeInTheDocument(); + }); + + it('renders quotes with special characters', () => { + const quoteWithSpecialChars = 'Amazing! Love it 100% - best product ever.'; + const propsWithSpecialChars = { + ...defaultProps, + quote: quoteWithSpecialChars, + }; + render(React.createElement(TestimonialCard, propsWithSpecialChars)); + expect(screen.getByText(new RegExp('Amazing! Love it 100%'))).toBeInTheDocument(); + }); + }); + + describe('styling', () => { + it('applies card styling classes', () => { + const { container } = render(React.createElement(TestimonialCard, defaultProps)); + const card = container.querySelector('.bg-white.dark\\:bg-gray-800'); + expect(card).toBeInTheDocument(); + }); + + it('applies border and shadow', () => { + const { container } = render(React.createElement(TestimonialCard, defaultProps)); + const card = container.querySelector('.border.border-gray-200'); + expect(card).toBeInTheDocument(); + }); + + it('applies rounded corners', () => { + const { container } = render(React.createElement(TestimonialCard, defaultProps)); + const card = container.querySelector('.rounded-2xl'); + expect(card).toBeInTheDocument(); + }); + + it('applies hover shadow effect', () => { + const { container } = render(React.createElement(TestimonialCard, defaultProps)); + const card = container.querySelector('.hover\\:shadow-md'); + expect(card).toBeInTheDocument(); + }); + }); + + describe('layout', () => { + it('uses flex column layout', () => { + const { container } = render(React.createElement(TestimonialCard, defaultProps)); + const card = container.querySelector('.flex.flex-col'); + expect(card).toBeInTheDocument(); + }); + + it('applies proper padding', () => { + const { container } = render(React.createElement(TestimonialCard, defaultProps)); + const card = container.querySelector('.p-6'); + expect(card).toBeInTheDocument(); + }); + }); + + describe('semantic HTML', () => { + it('uses blockquote element for quote', () => { + const { container } = render(React.createElement(TestimonialCard, defaultProps)); + const blockquote = container.querySelector('blockquote'); + expect(blockquote).toBeInTheDocument(); + expect(blockquote).toHaveTextContent('This is an amazing product!'); + }); + + it('renders avatar image with proper alt text', () => { + const propsWithAvatar = { + ...defaultProps, + avatarUrl: 'https://example.com/avatar.jpg', + }; + render(React.createElement(TestimonialCard, propsWithAvatar)); + + const avatar = screen.getByRole('img'); + expect(avatar).toHaveAttribute('alt', defaultProps.author); + }); + }); + + describe('different authors and companies', () => { + it('renders different author names correctly', () => { + const customProps = { + ...defaultProps, + author: 'Jane Smith', + role: 'CTO', + company: 'Tech Inc', + }; + render(React.createElement(TestimonialCard, customProps)); + + expect(screen.getByText('Jane Smith')).toBeInTheDocument(); + expect(screen.getByText('CTO at Tech Inc')).toBeInTheDocument(); + }); + + it('handles long company names', () => { + const longCompanyProps = { + ...defaultProps, + company: 'Very Long Company Name International Corporation Ltd.', + }; + render(React.createElement(TestimonialCard, longCompanyProps)); + + expect( + screen.getByText('CEO at Very Long Company Name International Corporation Ltd.') + ).toBeInTheDocument(); + }); + + it('handles special characters in names', () => { + const specialCharProps = { + ...defaultProps, + author: "O'Brien", + company: 'Smith & Jones', + }; + render(React.createElement(TestimonialCard, specialCharProps)); + + expect(screen.getByText("O'Brien")).toBeInTheDocument(); + expect(screen.getByText('CEO at Smith & Jones')).toBeInTheDocument(); + }); + }); + + describe('edge cases', () => { + it('handles rating of 0', () => { + render(React.createElement(TestimonialCard, { ...defaultProps, rating: 0 })); + const stars = screen.getAllByTestId('star-icon'); + expect(stars).toHaveLength(5); + }); + + it('handles very high rating', () => { + render(React.createElement(TestimonialCard, { ...defaultProps, rating: 10 })); + const stars = screen.getAllByTestId('star-icon'); + expect(stars).toHaveLength(5); + }); + + it('handles empty quote', () => { + render(React.createElement(TestimonialCard, { ...defaultProps, quote: '' })); + expect(screen.getByRole('blockquote')).toBeInTheDocument(); + }); + + it('handles empty role', () => { + render(React.createElement(TestimonialCard, { ...defaultProps, role: '' })); + expect(screen.getByText(/at Acme Corp/)).toBeInTheDocument(); + }); + + it('handles empty company', () => { + render(React.createElement(TestimonialCard, { ...defaultProps, company: '' })); + expect(screen.getByText(/CEO at/)).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/marketing/__tests__/WorkflowVisual.test.tsx b/frontend/src/components/marketing/__tests__/WorkflowVisual.test.tsx new file mode 100644 index 00000000..06ac6129 --- /dev/null +++ b/frontend/src/components/marketing/__tests__/WorkflowVisual.test.tsx @@ -0,0 +1,315 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import WorkflowVisual from '../WorkflowVisual'; + +// Mock react-i18next +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + 'marketing.plugins.aiCopilot.placeholder': 'Describe your automation...', + 'marketing.plugins.aiCopilot.examples': 'Example: Send SMS reminders 2 hours before appointments', + 'marketing.plugins.integrations.description': 'Integrate with your favorite apps', + }; + return translations[key] || key; + }, + }), +})); + +// Mock framer-motion +vi.mock('framer-motion', () => ({ + motion: { + div: ({ children, ...props }: any) => React.createElement('div', props, children), + }, +})); + +// Mock lucide-react icons +vi.mock('lucide-react', () => ({ + Calendar: () => React.createElement('div', { 'data-testid': 'calendar-icon' }), + Mail: () => React.createElement('div', { 'data-testid': 'mail-icon' }), + MessageSquare: () => React.createElement('div', { 'data-testid': 'message-square-icon' }), + Clock: () => React.createElement('div', { 'data-testid': 'clock-icon' }), + Search: () => React.createElement('div', { 'data-testid': 'search-icon' }), + FileText: () => React.createElement('div', { 'data-testid': 'file-text-icon' }), + Sparkles: () => React.createElement('div', { 'data-testid': 'sparkles-icon' }), + ChevronRight: () => React.createElement('div', { 'data-testid': 'chevron-right-icon' }), +})); + +describe('WorkflowVisual', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('rendering', () => { + it('renders component without errors', () => { + render(React.createElement(WorkflowVisual, { variant: 'noshow' })); + expect(screen.getByText('Event Created')).toBeInTheDocument(); + }); + + it('renders AI Copilot input section', () => { + render(React.createElement(WorkflowVisual, { variant: 'noshow' })); + expect(screen.getByText('Describe your automation...')).toBeInTheDocument(); + }); + + it('renders AI Copilot examples text', () => { + render(React.createElement(WorkflowVisual, { variant: 'noshow' })); + expect(screen.getByText('Example: Send SMS reminders 2 hours before appointments')).toBeInTheDocument(); + }); + + it('renders integration badges section', () => { + render(React.createElement(WorkflowVisual, { variant: 'noshow' })); + expect(screen.getByText('Integrate with your favorite apps')).toBeInTheDocument(); + }); + + it('renders integration app badges', () => { + render(React.createElement(WorkflowVisual, { variant: 'noshow' })); + expect(screen.getByText('Gmail')).toBeInTheDocument(); + expect(screen.getByText('Slack')).toBeInTheDocument(); + expect(screen.getByText('Sheets')).toBeInTheDocument(); + expect(screen.getByText('Twilio')).toBeInTheDocument(); + expect(screen.getByText('+1000 more')).toBeInTheDocument(); + }); + }); + + describe('winback variant', () => { + it('renders winback workflow blocks', () => { + render(React.createElement(WorkflowVisual, { variant: 'winback' })); + expect(screen.getByText('Schedule: Weekly')).toBeInTheDocument(); + expect(screen.getByText('Find Inactive Customers')).toBeInTheDocument(); + expect(screen.getByText('Send Email')).toBeInTheDocument(); + }); + + it('renders correct icons for winback workflow', () => { + render(React.createElement(WorkflowVisual, { variant: 'winback' })); + expect(screen.getByTestId('clock-icon')).toBeInTheDocument(); + expect(screen.getByTestId('search-icon')).toBeInTheDocument(); + expect(screen.getByTestId('mail-icon')).toBeInTheDocument(); + }); + + it('shows "When" label for trigger block', () => { + render(React.createElement(WorkflowVisual, { variant: 'winback' })); + expect(screen.getByText('When')).toBeInTheDocument(); + }); + + it('shows "Then" labels for action blocks', () => { + render(React.createElement(WorkflowVisual, { variant: 'winback' })); + const thenLabels = screen.getAllByText('Then'); + expect(thenLabels.length).toBe(2); // Two action blocks + }); + }); + + describe('noshow variant', () => { + it('renders noshow workflow blocks', () => { + render(React.createElement(WorkflowVisual, { variant: 'noshow' })); + expect(screen.getByText('Event Created')).toBeInTheDocument(); + expect(screen.getByText('Wait 2 Hours Before')).toBeInTheDocument(); + expect(screen.getByText('Send SMS')).toBeInTheDocument(); + }); + + it('renders correct icons for noshow workflow', () => { + render(React.createElement(WorkflowVisual, { variant: 'noshow' })); + expect(screen.getByTestId('calendar-icon')).toBeInTheDocument(); + expect(screen.getByTestId('clock-icon')).toBeInTheDocument(); + expect(screen.getByTestId('message-square-icon')).toBeInTheDocument(); + }); + }); + + describe('report variant', () => { + it('renders report workflow blocks', () => { + render(React.createElement(WorkflowVisual, { variant: 'report' })); + expect(screen.getByText('Daily at 6 PM')).toBeInTheDocument(); + expect(screen.getByText("Get Tomorrow's Schedule")).toBeInTheDocument(); + expect(screen.getByText('Send Summary')).toBeInTheDocument(); + }); + + it('renders correct icons for report workflow', () => { + render(React.createElement(WorkflowVisual, { variant: 'report' })); + expect(screen.getByTestId('clock-icon')).toBeInTheDocument(); + expect(screen.getByTestId('file-text-icon')).toBeInTheDocument(); + expect(screen.getByTestId('mail-icon')).toBeInTheDocument(); + }); + }); + + describe('default variant', () => { + it('renders default workflow when no variant specified', () => { + render(React.createElement(WorkflowVisual, {})); + expect(screen.getByText('Event Created')).toBeInTheDocument(); + expect(screen.getByText('Wait')).toBeInTheDocument(); + expect(screen.getByText('Send Notification')).toBeInTheDocument(); + }); + + it('uses noshow variant as default', () => { + render(React.createElement(WorkflowVisual, {})); + // Default should show noshow variant + expect(screen.getByText('Event Created')).toBeInTheDocument(); + }); + }); + + describe('workflow structure', () => { + it('renders trigger block with special styling', () => { + const { container } = render(React.createElement(WorkflowVisual, { variant: 'noshow' })); + const triggerBlock = container.querySelector('.from-brand-50.to-purple-50'); + expect(triggerBlock).toBeInTheDocument(); + }); + + it('renders action blocks with standard styling', () => { + const { container } = render(React.createElement(WorkflowVisual, { variant: 'noshow' })); + const actionBlocks = container.querySelectorAll('.bg-gray-50'); + expect(actionBlocks.length).toBeGreaterThan(0); + }); + + it('renders chevron icons for each block', () => { + render(React.createElement(WorkflowVisual, { variant: 'noshow' })); + const chevronIcons = screen.getAllByTestId('chevron-right-icon'); + expect(chevronIcons.length).toBe(3); // One per block + }); + }); + + describe('icons', () => { + it('renders sparkles icon in AI input', () => { + render(React.createElement(WorkflowVisual, { variant: 'noshow' })); + expect(screen.getByTestId('sparkles-icon')).toBeInTheDocument(); + }); + + it('renders appropriate icons for each workflow block type', () => { + render(React.createElement(WorkflowVisual, { variant: 'winback' })); + expect(screen.getByTestId('clock-icon')).toBeInTheDocument(); + expect(screen.getByTestId('search-icon')).toBeInTheDocument(); + expect(screen.getByTestId('mail-icon')).toBeInTheDocument(); + }); + }); + + describe('styling', () => { + it('applies card styling to container', () => { + const { container } = render(React.createElement(WorkflowVisual, { variant: 'noshow' })); + const card = container.querySelector('.bg-white.dark\\:bg-gray-800'); + expect(card).toBeInTheDocument(); + }); + + it('applies rounded corners', () => { + const { container } = render(React.createElement(WorkflowVisual, { variant: 'noshow' })); + const card = container.querySelector('.rounded-xl'); + expect(card).toBeInTheDocument(); + }); + + it('applies border and shadow', () => { + const { container } = render(React.createElement(WorkflowVisual, { variant: 'noshow' })); + const card = container.querySelector('.border.shadow-xl'); + expect(card).toBeInTheDocument(); + }); + + it('applies gradient background to AI input section', () => { + const { container } = render(React.createElement(WorkflowVisual, { variant: 'noshow' })); + const aiSection = container.querySelector('.from-purple-50.to-brand-50'); + expect(aiSection).toBeInTheDocument(); + }); + }); + + describe('layout', () => { + it('uses flex column layout for workflow blocks', () => { + const { container } = render(React.createElement(WorkflowVisual, { variant: 'noshow' })); + const workflowContainer = container.querySelector('.flex.flex-col'); + expect(workflowContainer).toBeInTheDocument(); + }); + + it('applies proper padding to sections', () => { + const { container } = render(React.createElement(WorkflowVisual, { variant: 'noshow' })); + const paddedSection = container.querySelector('.p-6'); + expect(paddedSection).toBeInTheDocument(); + }); + }); + + describe('integration badges', () => { + it('renders all integration app badges', () => { + render(React.createElement(WorkflowVisual, { variant: 'noshow' })); + expect(screen.getByText('Gmail')).toBeInTheDocument(); + expect(screen.getByText('Slack')).toBeInTheDocument(); + expect(screen.getByText('Sheets')).toBeInTheDocument(); + expect(screen.getByText('Twilio')).toBeInTheDocument(); + }); + + it('displays additional integrations count', () => { + render(React.createElement(WorkflowVisual, { variant: 'noshow' })); + expect(screen.getByText('+1000 more')).toBeInTheDocument(); + }); + + it('separates integration section with border', () => { + const { container } = render(React.createElement(WorkflowVisual, { variant: 'noshow' })); + const integrationsSection = container.querySelector('.border-t'); + expect(integrationsSection).toBeInTheDocument(); + }); + }); + + describe('variant switching', () => { + it('changes content when variant prop changes', () => { + const { rerender } = render(React.createElement(WorkflowVisual, { variant: 'noshow' })); + expect(screen.getByText('Event Created')).toBeInTheDocument(); + + rerender(React.createElement(WorkflowVisual, { variant: 'winback' })); + expect(screen.getByText('Schedule: Weekly')).toBeInTheDocument(); + }); + + it('maintains AI input section across variants', () => { + const { rerender } = render(React.createElement(WorkflowVisual, { variant: 'noshow' })); + expect(screen.getByText('Describe your automation...')).toBeInTheDocument(); + + rerender(React.createElement(WorkflowVisual, { variant: 'report' })); + expect(screen.getByText('Describe your automation...')).toBeInTheDocument(); + }); + + it('maintains integration badges across variants', () => { + const { rerender } = render(React.createElement(WorkflowVisual, { variant: 'noshow' })); + expect(screen.getByText('Gmail')).toBeInTheDocument(); + + rerender(React.createElement(WorkflowVisual, { variant: 'winback' })); + expect(screen.getByText('Gmail')).toBeInTheDocument(); + }); + }); + + describe('accessibility', () => { + it('renders semantic HTML structure', () => { + const { container } = render(React.createElement(WorkflowVisual, { variant: 'noshow' })); + expect(container.querySelector('div')).toBeInTheDocument(); + }); + + it('includes descriptive text for workflow steps', () => { + render(React.createElement(WorkflowVisual, { variant: 'noshow' })); + expect(screen.getByText('When')).toBeInTheDocument(); + expect(screen.getAllByText('Then').length).toBeGreaterThan(0); + }); + }); + + describe('edge cases', () => { + it('handles undefined variant gracefully', () => { + render(React.createElement(WorkflowVisual, { variant: undefined as any })); + // Should fall back to default (noshow) + expect(screen.getByText('Event Created')).toBeInTheDocument(); + }); + + it('handles invalid variant by using default', () => { + render(React.createElement(WorkflowVisual, { variant: 'invalid' as any })); + // Should fall back to default + expect(screen.getByText('Event Created')).toBeInTheDocument(); + }); + }); + + describe('block labels', () => { + it('displays "When" for trigger blocks', () => { + render(React.createElement(WorkflowVisual, { variant: 'winback' })); + expect(screen.getByText('When')).toBeInTheDocument(); + }); + + it('displays "Then" for action blocks', () => { + render(React.createElement(WorkflowVisual, { variant: 'winback' })); + const thenLabels = screen.getAllByText('Then'); + expect(thenLabels.length).toBeGreaterThan(0); + }); + + it('applies uppercase styling to block labels', () => { + const { container } = render(React.createElement(WorkflowVisual, { variant: 'noshow' })); + const labels = container.querySelectorAll('.uppercase'); + expect(labels.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/frontend/src/components/platform/__tests__/DynamicFeaturesEditor.test.tsx b/frontend/src/components/platform/__tests__/DynamicFeaturesEditor.test.tsx new file mode 100644 index 00000000..d958a9e7 --- /dev/null +++ b/frontend/src/components/platform/__tests__/DynamicFeaturesEditor.test.tsx @@ -0,0 +1,855 @@ +/** + * Unit tests for DynamicFeaturesEditor component + * + * Tests the dynamic features editor component including: + * - Basic rendering with loading and error states + * - Feature filtering by category and type + * - Boolean feature toggles + * - Integer feature inputs (limits) + * - Feature dependencies + * - Category grouping and sorting + * - User interactions (toggling, input changes) + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import DynamicFeaturesEditor from '../DynamicFeaturesEditor'; +import { BillingFeature } from '../../../hooks/useBillingPlans'; + +// Mock the useBillingFeatures hook +vi.mock('../../../hooks/useBillingPlans', () => ({ + useBillingFeatures: vi.fn(), + FEATURE_CATEGORY_META: { + limits: { label: 'Limits', order: 0 }, + payments: { label: 'Payments & Revenue', order: 1 }, + communication: { label: 'Communication', order: 2 }, + customization: { label: 'Customization', order: 3 }, + plugins: { label: 'Plugins & Automation', order: 4 }, + advanced: { label: 'Advanced Features', order: 5 }, + scheduling: { label: 'Scheduling', order: 6 }, + enterprise: { label: 'Enterprise & Security', order: 7 }, + }, +})); + +// Import the mocked module to access the mock function +import { useBillingFeatures } from '../../../hooks/useBillingPlans'; + +const mockUseBillingFeatures = useBillingFeatures as ReturnType; + +// Mock feature data +const createMockFeature = (overrides: Partial = {}): BillingFeature => ({ + id: 1, + code: 'test_feature', + name: 'Test Feature', + description: 'Test feature description', + feature_type: 'boolean', + category: 'plugins', + tenant_field_name: 'can_test_feature', + display_order: 1, + is_overridable: true, + depends_on: null, + depends_on_code: null, + ...overrides, +}); + +const mockFeatures: BillingFeature[] = [ + createMockFeature({ + id: 1, + code: 'can_use_plugins', + name: 'Use Plugins', + description: 'Install and use marketplace plugins', + tenant_field_name: 'can_use_plugins', + category: 'plugins', + display_order: 1, + }), + createMockFeature({ + id: 2, + code: 'can_use_tasks', + name: 'Scheduled Tasks', + description: 'Create automated scheduled tasks', + tenant_field_name: 'can_use_tasks', + category: 'plugins', + display_order: 2, + depends_on: 1, + depends_on_code: 'can_use_plugins', + }), + createMockFeature({ + id: 3, + code: 'can_use_sms_reminders', + name: 'SMS Reminders', + description: 'Send SMS appointment reminders', + tenant_field_name: 'can_use_sms_reminders', + category: 'communication', + display_order: 1, + }), + createMockFeature({ + id: 4, + code: 'max_users', + name: 'Max Users', + description: 'Maximum number of users', + feature_type: 'integer', + tenant_field_name: 'max_users', + category: 'limits', + display_order: 1, + }), +]; + +describe('DynamicFeaturesEditor', () => { + const mockOnChange = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Loading State', () => { + it('should render loading state', () => { + mockUseBillingFeatures.mockReturnValue({ + data: undefined, + isLoading: true, + error: null, + }); + + render( + + ); + + expect(screen.getByText('Features & Permissions')).toBeInTheDocument(); + // Check for loading skeleton + const skeletons = document.querySelectorAll('.animate-pulse'); + expect(skeletons.length).toBeGreaterThan(0); + }); + + it('should show custom header title in loading state', () => { + mockUseBillingFeatures.mockReturnValue({ + data: undefined, + isLoading: true, + error: null, + }); + + render( + + ); + + expect(screen.getByText('Custom Features')).toBeInTheDocument(); + }); + + it('should not show header in loading state when showHeader is false', () => { + mockUseBillingFeatures.mockReturnValue({ + data: undefined, + isLoading: true, + error: null, + }); + + render( + + ); + + expect(screen.queryByText('Features & Permissions')).not.toBeInTheDocument(); + }); + }); + + describe('Error State', () => { + it('should render error state', () => { + mockUseBillingFeatures.mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error('Failed to load'), + }); + + render( + + ); + + expect(screen.getByText('Failed to load features from billing system')).toBeInTheDocument(); + }); + + it('should show header in error state', () => { + mockUseBillingFeatures.mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error('Failed to load'), + }); + + render( + + ); + + expect(screen.getByText('Features & Permissions')).toBeInTheDocument(); + }); + }); + + describe('Basic Rendering', () => { + beforeEach(() => { + mockUseBillingFeatures.mockReturnValue({ + data: mockFeatures, + isLoading: false, + error: null, + }); + }); + + it('should render with default header', () => { + render( + + ); + + expect(screen.getByText('Features & Permissions')).toBeInTheDocument(); + }); + + it('should render with custom header title', () => { + render( + + ); + + expect(screen.getByText('Custom Title')).toBeInTheDocument(); + expect(screen.queryByText('Features & Permissions')).not.toBeInTheDocument(); + }); + + it('should not render header when showHeader is false', () => { + render( + + ); + + expect(screen.queryByText('Features & Permissions')).not.toBeInTheDocument(); + }); + + it('should render all boolean features by default', () => { + render( + + ); + + expect(screen.getByText('Use Plugins')).toBeInTheDocument(); + expect(screen.getByText('Scheduled Tasks')).toBeInTheDocument(); + expect(screen.getByText('SMS Reminders')).toBeInTheDocument(); + }); + + it('should render category labels', () => { + render( + + ); + + expect(screen.getByText('Plugins & Automation')).toBeInTheDocument(); + expect(screen.getByText('Communication')).toBeInTheDocument(); + }); + }); + + describe('Feature Filtering', () => { + beforeEach(() => { + mockUseBillingFeatures.mockReturnValue({ + data: mockFeatures, + isLoading: false, + error: null, + }); + }); + + it('should filter by category', () => { + render( + + ); + + expect(screen.getByText('Use Plugins')).toBeInTheDocument(); + expect(screen.getByText('Scheduled Tasks')).toBeInTheDocument(); + expect(screen.queryByText('SMS Reminders')).not.toBeInTheDocument(); + }); + + it('should filter by feature type - boolean only', () => { + render( + + ); + + expect(screen.getByText('Use Plugins')).toBeInTheDocument(); + expect(screen.getByText('SMS Reminders')).toBeInTheDocument(); + expect(screen.queryByText('Max Users')).not.toBeInTheDocument(); + }); + + it('should filter by feature type - integer only', () => { + render( + + ); + + expect(screen.getByText('Max Users')).toBeInTheDocument(); + expect(screen.queryByText('Use Plugins')).not.toBeInTheDocument(); + expect(screen.queryByText('SMS Reminders')).not.toBeInTheDocument(); + }); + + it('should exclude features by code', () => { + render( + + ); + + expect(screen.queryByText('Use Plugins')).not.toBeInTheDocument(); + expect(screen.getByText('Scheduled Tasks')).toBeInTheDocument(); + expect(screen.getByText('SMS Reminders')).toBeInTheDocument(); + }); + + it('should combine multiple filters', () => { + render( + + ); + + expect(screen.getByText('Use Plugins')).toBeInTheDocument(); + expect(screen.queryByText('Scheduled Tasks')).not.toBeInTheDocument(); + expect(screen.queryByText('SMS Reminders')).not.toBeInTheDocument(); + }); + }); + + describe('Boolean Features', () => { + beforeEach(() => { + mockUseBillingFeatures.mockReturnValue({ + data: mockFeatures, + isLoading: false, + error: null, + }); + }); + + it('should render unchecked checkbox for false value', () => { + render( + + ); + + const checkbox = screen.getByRole('checkbox', { name: /Use Plugins/i }); + expect(checkbox).not.toBeChecked(); + }); + + it('should render checked checkbox for true value', () => { + render( + + ); + + const checkbox = screen.getByRole('checkbox', { name: /Use Plugins/i }); + expect(checkbox).toBeChecked(); + }); + + it('should call onChange when checkbox is toggled', async () => { + const user = userEvent.setup(); + + render( + + ); + + const checkbox = screen.getByRole('checkbox', { name: /Use Plugins/i }); + await user.click(checkbox); + + expect(mockOnChange).toHaveBeenCalledWith('can_use_plugins', true); + }); + + it('should show descriptions when showDescriptions is true', () => { + render( + + ); + + expect(screen.getByText('Install and use marketplace plugins')).toBeInTheDocument(); + expect(screen.getByText('Create automated scheduled tasks')).toBeInTheDocument(); + }); + + it('should not show descriptions by default', () => { + render( + + ); + + expect(screen.queryByText('Install and use marketplace plugins')).not.toBeInTheDocument(); + expect(screen.queryByText('Create automated scheduled tasks')).not.toBeInTheDocument(); + }); + }); + + describe('Integer Features', () => { + beforeEach(() => { + mockUseBillingFeatures.mockReturnValue({ + data: mockFeatures, + isLoading: false, + error: null, + }); + }); + + it('should render number input for integer features', () => { + render( + + ); + + const input = screen.getByDisplayValue('10'); + expect(input).toHaveAttribute('type', 'number'); + }); + + it('should render -1 for unlimited (null value)', () => { + render( + + ); + + const input = screen.getByDisplayValue('-1'); + expect(input).toBeInTheDocument(); + }); + + it('should call onChange with null when -1 is entered', async () => { + const user = userEvent.setup(); + + render( + + ); + + const input = screen.getByDisplayValue('10'); + // Type -1 directly (this will trigger onChange for each character) + await user.clear(input); + await user.type(input, '-1'); + + // The final call should have -1 -> null conversion + await waitFor(() => { + const calls = mockOnChange.mock.calls; + const lastCall = calls[calls.length - 1]; + expect(lastCall[0]).toBe('max_users'); + expect(lastCall[1]).toBe(null); + }); + }); + + it('should call onChange with number when value is entered', async () => { + const user = userEvent.setup(); + + render( + + ); + + const input = screen.getByDisplayValue('10'); + // Clear and type will trigger onChange for each character + await user.clear(input); + await user.type(input, '25'); + + // The final call should have the complete number + await waitFor(() => { + const calls = mockOnChange.mock.calls; + const lastCall = calls[calls.length - 1]; + expect(lastCall[0]).toBe('max_users'); + expect(lastCall[1]).toBe(25); + }); + }); + + it('should show description with unlimited hint when showDescriptions is true', () => { + render( + + ); + + expect(screen.getByText(/Maximum number of users.*-1 = unlimited/)).toBeInTheDocument(); + }); + }); + + describe('Feature Dependencies', () => { + beforeEach(() => { + mockUseBillingFeatures.mockReturnValue({ + data: mockFeatures, + isLoading: false, + error: null, + }); + }); + + it('should disable dependent feature when parent is disabled', () => { + render( + + ); + + const tasksCheckbox = screen.getByRole('checkbox', { name: /Scheduled Tasks/i }); + expect(tasksCheckbox).toBeDisabled(); + }); + + it('should enable dependent feature when parent is enabled', () => { + render( + + ); + + const tasksCheckbox = screen.getByRole('checkbox', { name: /Scheduled Tasks/i }); + expect(tasksCheckbox).not.toBeDisabled(); + }); + + it('should disable dependents when parent is toggled off', async () => { + const user = userEvent.setup(); + + render( + + ); + + const pluginsCheckbox = screen.getByRole('checkbox', { name: /Use Plugins/i }); + await user.click(pluginsCheckbox); + + expect(mockOnChange).toHaveBeenCalledWith('can_use_plugins', false); + expect(mockOnChange).toHaveBeenCalledWith('can_use_tasks', false); + }); + + it('should show dependency hint when plugins are disabled', () => { + render( + + ); + + expect(screen.getByText('Enable "Use Plugins" to allow dependent features')).toBeInTheDocument(); + }); + + it('should not show dependency hint when plugins are enabled', () => { + render( + + ); + + expect(screen.queryByText('Enable "Use Plugins" to allow dependent features')).not.toBeInTheDocument(); + }); + }); + + describe('Column Layout', () => { + beforeEach(() => { + mockUseBillingFeatures.mockReturnValue({ + data: mockFeatures, + isLoading: false, + error: null, + }); + }); + + it('should use 3 columns by default', () => { + const { container } = render( + + ); + + const grid = container.querySelector('.grid-cols-3'); + expect(grid).toBeInTheDocument(); + }); + + it('should use 2 columns when specified', () => { + const { container } = render( + + ); + + const grid = container.querySelector('.grid-cols-2'); + expect(grid).toBeInTheDocument(); + }); + + it('should use 4 columns when specified', () => { + const { container } = render( + + ); + + const grid = container.querySelector('.grid-cols-4'); + expect(grid).toBeInTheDocument(); + }); + }); + + describe('Category Sorting', () => { + it('should sort categories by order', () => { + mockUseBillingFeatures.mockReturnValue({ + data: mockFeatures, + isLoading: false, + error: null, + }); + + const { container } = render( + + ); + + const categoryHeaders = container.querySelectorAll('h4.uppercase'); + const categoryTexts = Array.from(categoryHeaders).map(h => h.textContent); + + // Limits (0) should come before Communication (2) which comes before Plugins (4) + const limitsIndex = categoryTexts.indexOf('Limits'); + const communicationIndex = categoryTexts.indexOf('Communication'); + const pluginsIndex = categoryTexts.indexOf('Plugins & Automation'); + + expect(limitsIndex).toBeLessThan(communicationIndex); + expect(communicationIndex).toBeLessThan(pluginsIndex); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty features array', () => { + mockUseBillingFeatures.mockReturnValue({ + data: [], + isLoading: false, + error: null, + }); + + render( + + ); + + expect(screen.getByText('Features & Permissions')).toBeInTheDocument(); + // Should not crash, just render empty + }); + + it('should handle features without tenant_field_name', () => { + const featuresWithoutField = [ + createMockFeature({ + tenant_field_name: '', + }), + ]; + + mockUseBillingFeatures.mockReturnValue({ + data: featuresWithoutField, + isLoading: false, + error: null, + }); + + render( + + ); + + // Should filter out features without tenant_field_name + expect(screen.queryByText('Test Feature')).not.toBeInTheDocument(); + }); + + it('should handle non-overridable features', () => { + const nonOverridableFeatures = [ + createMockFeature({ + is_overridable: false, + }), + ]; + + mockUseBillingFeatures.mockReturnValue({ + data: nonOverridableFeatures, + isLoading: false, + error: null, + }); + + render( + + ); + + // Should filter out non-overridable features + expect(screen.queryByText('Test Feature')).not.toBeInTheDocument(); + }); + + it('should handle undefined values object', () => { + mockUseBillingFeatures.mockReturnValue({ + data: mockFeatures, + isLoading: false, + error: null, + }); + + render( + + ); + + const checkbox = screen.getByRole('checkbox', { name: /Use Plugins/i }); + expect(checkbox).not.toBeChecked(); + }); + }); + + describe('Accessibility', () => { + beforeEach(() => { + mockUseBillingFeatures.mockReturnValue({ + data: mockFeatures, + isLoading: false, + error: null, + }); + }); + + it('should use semantic heading for main header', () => { + const { container } = render( + + ); + + const mainHeading = container.querySelector('h3'); + expect(mainHeading).toHaveTextContent('Features & Permissions'); + }); + + it('should use semantic heading for category labels', () => { + const { container } = render( + + ); + + const categoryHeadings = container.querySelectorAll('h4'); + expect(categoryHeadings.length).toBeGreaterThan(0); + }); + + it('should have proper label association for checkboxes', () => { + render( + + ); + + const checkbox = screen.getByRole('checkbox', { name: /Use Plugins/i }); + expect(checkbox).toBeInTheDocument(); + }); + + it('should have proper label for number inputs', () => { + render( + + ); + + expect(screen.getByText('Max Users')).toBeInTheDocument(); + expect(screen.getByDisplayValue('10')).toBeInTheDocument(); + }); + }); + + describe('Dark Mode Support', () => { + beforeEach(() => { + mockUseBillingFeatures.mockReturnValue({ + data: mockFeatures, + isLoading: false, + error: null, + }); + }); + + it('should include dark mode classes for header', () => { + const { container } = render( + + ); + + const header = screen.getByText('Features & Permissions'); + expect(header).toHaveClass('dark:text-white'); + }); + + it('should include dark mode classes for descriptions', () => { + const { container } = render( + + ); + + const description = container.querySelector('.text-xs.text-gray-500'); + expect(description).toHaveClass('dark:text-gray-400'); + }); + }); +}); diff --git a/frontend/src/components/platform/__tests__/FeaturesPermissionsEditor.test.tsx b/frontend/src/components/platform/__tests__/FeaturesPermissionsEditor.test.tsx new file mode 100644 index 00000000..e2fc32b1 --- /dev/null +++ b/frontend/src/components/platform/__tests__/FeaturesPermissionsEditor.test.tsx @@ -0,0 +1,695 @@ +/** + * Unit tests for FeaturesPermissionsEditor component + * + * Tests the static features/permissions editor component including: + * - Basic rendering with plan and business modes + * - Permission filtering by category + * - Boolean permission toggles + * - Permission dependencies + * - Category grouping and sorting + * - Mode-specific key mapping + * - User interactions + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import FeaturesPermissionsEditor, { + PERMISSION_DEFINITIONS, + getPermissionKey, + convertPermissions, +} from '../FeaturesPermissionsEditor'; + +describe('FeaturesPermissionsEditor', () => { + const mockOnChange = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Basic Rendering - Plan Mode', () => { + it('should render with default header in plan mode', () => { + render( + + ); + + expect(screen.getByText('Features & Permissions')).toBeInTheDocument(); + }); + + it('should render with custom header title', () => { + render( + + ); + + expect(screen.getByText('Custom Permissions')).toBeInTheDocument(); + expect(screen.queryByText('Features & Permissions')).not.toBeInTheDocument(); + }); + + it('should not render header when showHeader is false', () => { + render( + + ); + + expect(screen.queryByText('Features & Permissions')).not.toBeInTheDocument(); + }); + + it('should render all permissions by default', () => { + render( + + ); + + // Check for some key permissions + expect(screen.getByText('Online Payments')).toBeInTheDocument(); + expect(screen.getByText('SMS Reminders')).toBeInTheDocument(); + expect(screen.getByText('Use Plugins')).toBeInTheDocument(); + }); + + it('should render category labels', () => { + render( + + ); + + expect(screen.getByText('Payments & Revenue')).toBeInTheDocument(); + expect(screen.getByText('Communication')).toBeInTheDocument(); + expect(screen.getByText('Plugins & Automation')).toBeInTheDocument(); + }); + }); + + describe('Basic Rendering - Business Mode', () => { + it('should render correctly in business mode', () => { + render( + + ); + + expect(screen.getByText('Features & Permissions')).toBeInTheDocument(); + expect(screen.getByText('Online Payments')).toBeInTheDocument(); + }); + + it('should use business keys in business mode', () => { + render( + + ); + + const checkbox = screen.getByRole('checkbox', { name: /Online Payments/i }); + expect(checkbox).toBeChecked(); + }); + }); + + describe('Permission Filtering', () => { + it('should filter by category', () => { + render( + + ); + + expect(screen.getByText('Online Payments')).toBeInTheDocument(); + expect(screen.queryByText('SMS Reminders')).not.toBeInTheDocument(); + expect(screen.queryByText('Use Plugins')).not.toBeInTheDocument(); + }); + + it('should filter by multiple categories', () => { + render( + + ); + + expect(screen.getByText('Online Payments')).toBeInTheDocument(); + expect(screen.getByText('SMS Reminders')).toBeInTheDocument(); + expect(screen.queryByText('Use Plugins')).not.toBeInTheDocument(); + }); + + it('should filter using includeOnly', () => { + render( + + ); + + expect(screen.getByText('Online Payments')).toBeInTheDocument(); + expect(screen.getByText('SMS Reminders')).toBeInTheDocument(); + // Other permissions should not be visible + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes).toHaveLength(2); + }); + + it('should exclude specific permissions', () => { + render( + + ); + + expect(screen.queryByText('Online Payments')).not.toBeInTheDocument(); + expect(screen.getByText('Process Refunds')).toBeInTheDocument(); + }); + }); + + describe('Permission Toggles', () => { + it('should render unchecked checkbox for false value in plan mode', () => { + render( + + ); + + const checkbox = screen.getByRole('checkbox', { name: /Online Payments/i }); + expect(checkbox).not.toBeChecked(); + }); + + it('should render checked checkbox for true value in plan mode', () => { + render( + + ); + + const checkbox = screen.getByRole('checkbox', { name: /Online Payments/i }); + expect(checkbox).toBeChecked(); + }); + + it('should call onChange when checkbox is toggled in plan mode', async () => { + const user = userEvent.setup(); + + render( + + ); + + const checkbox = screen.getByRole('checkbox', { name: /Online Payments/i }); + await user.click(checkbox); + + expect(mockOnChange).toHaveBeenCalledWith('can_accept_payments', true); + }); + + it('should call onChange when checkbox is toggled in business mode', async () => { + const user = userEvent.setup(); + + render( + + ); + + const checkbox = screen.getByRole('checkbox', { name: /Online Payments/i }); + await user.click(checkbox); + + expect(mockOnChange).toHaveBeenCalledWith('can_accept_payments', true); + }); + }); + + describe('Permission Dependencies', () => { + it('should disable dependent permission when parent is disabled', () => { + render( + + ); + + const tasksCheckbox = screen.getByRole('checkbox', { name: /Scheduled Tasks/i }); + expect(tasksCheckbox).toBeDisabled(); + }); + + it('should enable dependent permission when parent is enabled', () => { + render( + + ); + + const tasksCheckbox = screen.getByRole('checkbox', { name: /Scheduled Tasks/i }); + expect(tasksCheckbox).not.toBeDisabled(); + }); + + it('should disable dependents when parent is toggled off', async () => { + const user = userEvent.setup(); + + render( + + ); + + const pluginsCheckbox = screen.getByRole('checkbox', { name: /Use Plugins/i }); + await user.click(pluginsCheckbox); + + expect(mockOnChange).toHaveBeenCalledWith('can_use_plugins', false); + expect(mockOnChange).toHaveBeenCalledWith('can_use_tasks', false); + }); + + it('should show dependency hint when plugins are disabled', () => { + render( + + ); + + expect(screen.getByText('Enable "Use Plugins" to allow Scheduled Tasks and Create Plugins')).toBeInTheDocument(); + }); + + it('should not show dependency hint when plugins are enabled', () => { + render( + + ); + + expect(screen.queryByText('Enable "Use Plugins" to allow Scheduled Tasks and Create Plugins')).not.toBeInTheDocument(); + }); + + it('should handle dependencies in business mode', () => { + render( + + ); + + const tasksCheckbox = screen.getByRole('checkbox', { name: /Scheduled Tasks/i }); + expect(tasksCheckbox).toBeDisabled(); + }); + }); + + describe('Descriptions', () => { + it('should show descriptions when showDescriptions is true', () => { + render( + + ); + + expect(screen.getByText('Accept payments via Stripe Connect')).toBeInTheDocument(); + expect(screen.getByText('Issue refunds for payments')).toBeInTheDocument(); + }); + + it('should not show descriptions by default', () => { + render( + + ); + + expect(screen.queryByText('Accept payments via Stripe Connect')).not.toBeInTheDocument(); + expect(screen.queryByText('Issue refunds for payments')).not.toBeInTheDocument(); + }); + }); + + describe('Column Layout', () => { + it('should use 3 columns by default', () => { + const { container } = render( + + ); + + const grid = container.querySelector('.grid-cols-3'); + expect(grid).toBeInTheDocument(); + }); + + it('should use 2 columns when specified', () => { + const { container } = render( + + ); + + const grid = container.querySelector('.grid-cols-2'); + expect(grid).toBeInTheDocument(); + }); + + it('should use 4 columns when specified', () => { + const { container } = render( + + ); + + const grid = container.querySelector('.grid-cols-4'); + expect(grid).toBeInTheDocument(); + }); + }); + + describe('Category Sorting', () => { + it('should sort categories by order', () => { + const { container } = render( + + ); + + const categoryHeaders = container.querySelectorAll('h4.uppercase'); + const categoryTexts = Array.from(categoryHeaders).map(h => h.textContent); + + // Payments (1) should come before Communication (2) which comes before Plugins (4) + const paymentsIndex = categoryTexts.indexOf('Payments & Revenue'); + const communicationIndex = categoryTexts.indexOf('Communication'); + const pluginsIndex = categoryTexts.indexOf('Plugins & Automation'); + + expect(paymentsIndex).toBeLessThan(communicationIndex); + expect(communicationIndex).toBeLessThan(pluginsIndex); + }); + }); + + describe('Helper Functions', () => { + describe('getPermissionKey', () => { + it('should return planKey in plan mode', () => { + const def = PERMISSION_DEFINITIONS.find(d => d.key === 'sms_reminders')!; + expect(getPermissionKey(def, 'plan')).toBe('sms_reminders'); + }); + + it('should return businessKey in business mode', () => { + const def = PERMISSION_DEFINITIONS.find(d => d.key === 'sms_reminders')!; + expect(getPermissionKey(def, 'business')).toBe('can_use_sms_reminders'); + }); + + it('should fallback to key if planKey not defined', () => { + const def = { ...PERMISSION_DEFINITIONS[0], planKey: undefined }; + expect(getPermissionKey(def, 'plan')).toBe(def.key); + }); + + it('should fallback to key if businessKey not defined', () => { + const def = { ...PERMISSION_DEFINITIONS[0], businessKey: undefined }; + expect(getPermissionKey(def, 'business')).toBe(def.key); + }); + }); + + describe('convertPermissions', () => { + it('should convert from plan mode to business mode', () => { + const planValues = { + can_accept_payments: true, + sms_reminders: true, + }; + + const businessValues = convertPermissions(planValues, 'plan', 'business'); + + expect(businessValues.can_accept_payments).toBe(true); + expect(businessValues.can_use_sms_reminders).toBe(true); + }); + + it('should convert from business mode to plan mode', () => { + const businessValues = { + can_accept_payments: true, + can_use_sms_reminders: true, + }; + + const planValues = convertPermissions(businessValues, 'business', 'plan'); + + expect(planValues.can_accept_payments).toBe(true); + expect(planValues.sms_reminders).toBe(true); + }); + + it('should handle empty values', () => { + const result = convertPermissions({}, 'plan', 'business'); + expect(result).toEqual({}); + }); + + it('should only convert known permissions', () => { + const planValues = { + can_accept_payments: true, + unknown_permission: true, + }; + + const businessValues = convertPermissions(planValues, 'plan', 'business'); + + expect(businessValues.can_accept_payments).toBe(true); + expect(businessValues.unknown_permission).toBeUndefined(); + }); + }); + }); + + describe('Accessibility', () => { + it('should use semantic heading for main header', () => { + const { container } = render( + + ); + + const mainHeading = container.querySelector('h3'); + expect(mainHeading).toHaveTextContent('Features & Permissions'); + }); + + it('should use semantic heading for category labels', () => { + const { container } = render( + + ); + + const categoryHeadings = container.querySelectorAll('h4'); + expect(categoryHeadings.length).toBeGreaterThan(0); + }); + + it('should have proper label association for checkboxes', () => { + render( + + ); + + const checkbox = screen.getByRole('checkbox', { name: /Online Payments/i }); + expect(checkbox).toBeInTheDocument(); + }); + + it('should have disabled state for dependent checkboxes', () => { + render( + + ); + + const tasksCheckbox = screen.getByRole('checkbox', { name: /Scheduled Tasks/i }); + expect(tasksCheckbox).toBeDisabled(); + expect(tasksCheckbox.closest('label')).toHaveClass('opacity-50', 'cursor-not-allowed'); + }); + }); + + describe('Dark Mode Support', () => { + it('should include dark mode classes for header', () => { + render( + + ); + + const header = screen.getByText('Features & Permissions'); + expect(header).toHaveClass('dark:text-white'); + }); + + it('should include dark mode classes for descriptions', () => { + const { container } = render( + + ); + + const description = container.querySelector('.text-xs.text-gray-500'); + expect(description).toHaveClass('dark:text-gray-400'); + }); + + it('should include dark mode classes for checkboxes', () => { + const { container } = render( + + ); + + const labels = container.querySelectorAll('label'); + labels.forEach(label => { + expect(label).toHaveClass('dark:border-gray-700'); + }); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty categories array', () => { + render( + + ); + + expect(screen.getByText('Features & Permissions')).toBeInTheDocument(); + // No permissions should be shown + const checkboxes = screen.queryAllByRole('checkbox'); + expect(checkboxes).toHaveLength(0); + }); + + it('should handle undefined values for permissions', () => { + render( + + ); + + const checkbox = screen.getByRole('checkbox', { name: /Online Payments/i }); + expect(checkbox).not.toBeChecked(); + }); + + it('should toggle permission from undefined to true', async () => { + const user = userEvent.setup(); + + render( + + ); + + const checkbox = screen.getByRole('checkbox', { name: /Online Payments/i }); + await user.click(checkbox); + + expect(mockOnChange).toHaveBeenCalledWith('can_accept_payments', true); + }); + }); + + describe('Hover States', () => { + it('should have hover classes on enabled checkboxes', () => { + const { container } = render( + + ); + + const labels = container.querySelectorAll('label'); + labels.forEach(label => { + if (!label.classList.contains('cursor-not-allowed')) { + expect(label).toHaveClass('hover:bg-gray-50'); + expect(label).toHaveClass('dark:hover:bg-gray-700/50'); + expect(label).toHaveClass('cursor-pointer'); + } + }); + }); + + it('should not have hover classes on disabled checkboxes', () => { + const { container } = render( + + ); + + const tasksCheckbox = screen.getByRole('checkbox', { name: /Scheduled Tasks/i }); + const label = tasksCheckbox.closest('label'); + + expect(label).toHaveClass('cursor-not-allowed'); + expect(label).not.toHaveClass('cursor-pointer'); + }); + }); +}); diff --git a/frontend/src/components/profile/__tests__/TwoFactorSetup.test.tsx b/frontend/src/components/profile/__tests__/TwoFactorSetup.test.tsx index 0eac5cfd..c6330a60 100644 --- a/frontend/src/components/profile/__tests__/TwoFactorSetup.test.tsx +++ b/frontend/src/components/profile/__tests__/TwoFactorSetup.test.tsx @@ -1,34 +1,77 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +/** + * Unit tests for TwoFactorSetup component + * + * Tests the two-factor authentication setup and management wizard. + * Covers: + * - Initial rendering for enabled/disabled states + * - Step navigation (intro -> qrcode -> verify -> recovery -> complete) + * - QR code display and manual secret entry + * - Verification code input and submission + * - Recovery code display and download + * - Disable 2FA flow + * - Phone verification prompts + * - Error handling + * - User interactions (clicks, form inputs, copy, download) + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import React from 'react'; import TwoFactorSetup from '../TwoFactorSetup'; +// Mock hooks with mutable state +const mockSetupTOTP = vi.fn(); +const mockVerifyTOTP = vi.fn(); +const mockDisableTOTP = vi.fn(); +const mockRecoveryCodesRefetch = vi.fn(); +const mockRegenerateCodes = vi.fn(); + +let mockSetupTOTPData: any = null; +let mockSetupTOTPPending = false; +let mockVerifyTOTPData: any = null; +let mockVerifyTOTPPending = false; +let mockDisableTOTPPending = false; +let mockRecoveryCodesData: any = null; +let mockRecoveryCodesFetching = false; +let mockRegenerateCodesPending = false; + vi.mock('../../../hooks/useProfile', () => ({ useSetupTOTP: () => ({ - mutateAsync: vi.fn().mockResolvedValue({ qr_code: 'base64qr', secret: 'ABCD1234' }), - data: null, - isPending: false, + mutateAsync: mockSetupTOTP, + get data() { return mockSetupTOTPData; }, + get isPending() { return mockSetupTOTPPending; }, }), useVerifyTOTP: () => ({ - mutateAsync: vi.fn().mockResolvedValue({ recovery_codes: ['code1', 'code2'] }), - data: null, - isPending: false, + mutateAsync: mockVerifyTOTP, + get data() { return mockVerifyTOTPData; }, + get isPending() { return mockVerifyTOTPPending; }, }), useDisableTOTP: () => ({ - mutateAsync: vi.fn().mockResolvedValue({}), - isPending: false, + mutateAsync: mockDisableTOTP, + get isPending() { return mockDisableTOTPPending; }, }), useRecoveryCodes: () => ({ - refetch: vi.fn().mockResolvedValue({ data: ['code1', 'code2'] }), - data: ['code1', 'code2'], - isFetching: false, + refetch: mockRecoveryCodesRefetch, + get data() { return mockRecoveryCodesData; }, + get isFetching() { return mockRecoveryCodesFetching; }, }), useRegenerateRecoveryCodes: () => ({ - mutateAsync: vi.fn().mockResolvedValue({}), - isPending: false, + mutateAsync: mockRegenerateCodes, + get isPending() { return mockRegenerateCodesPending; }, }), })); +// Mock clipboard API +Object.assign(navigator, { + clipboard: { + writeText: vi.fn(), + }, +}); + +// Mock URL.createObjectURL and URL.revokeObjectURL +global.URL.createObjectURL = vi.fn(() => 'mock-url'); +global.URL.revokeObjectURL = vi.fn(); + const defaultProps = { isEnabled: false, phoneVerified: false, @@ -41,89 +84,718 @@ const defaultProps = { describe('TwoFactorSetup', () => { beforeEach(() => { vi.clearAllMocks(); + vi.useFakeTimers(); + + // Reset mock state + mockSetupTOTPData = null; + mockSetupTOTPPending = false; + mockVerifyTOTPData = null; + mockVerifyTOTPPending = false; + mockDisableTOTPPending = false; + mockRecoveryCodesData = null; + mockRecoveryCodesFetching = false; + mockRegenerateCodesPending = false; }); - it('renders modal with title when not enabled', () => { - render(React.createElement(TwoFactorSetup, defaultProps)); - expect(screen.getByText('Set Up Two-Factor Authentication')).toBeInTheDocument(); + afterEach(() => { + vi.useRealTimers(); }); - it('renders modal with manage title when enabled', () => { - render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true })); - expect(screen.getByText('Manage Two-Factor Authentication')).toBeInTheDocument(); + describe('Initial Rendering', () => { + it('renders modal with setup title when not enabled', () => { + render(React.createElement(TwoFactorSetup, defaultProps)); + expect(screen.getByText('Set Up Two-Factor Authentication')).toBeInTheDocument(); + }); + + it('renders modal with manage title when enabled', () => { + render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true })); + expect(screen.getByText('Manage Two-Factor Authentication')).toBeInTheDocument(); + }); + + it('renders close button', () => { + render(React.createElement(TwoFactorSetup, defaultProps)); + const closeButton = document.querySelector('.lucide-x')?.parentElement; + expect(closeButton).toBeInTheDocument(); + }); + + it('calls onClose when close button is clicked', () => { + const mockOnClose = vi.fn(); + render(React.createElement(TwoFactorSetup, { ...defaultProps, onClose: mockOnClose })); + const closeButton = document.querySelector('.lucide-x')?.parentElement; + if (closeButton) { + fireEvent.click(closeButton); + } + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('shows Shield icon in header', () => { + render(React.createElement(TwoFactorSetup, defaultProps)); + const shieldIcon = document.querySelector('.lucide-shield'); + expect(shieldIcon).toBeInTheDocument(); + }); }); - it('renders close button', () => { - render(React.createElement(TwoFactorSetup, defaultProps)); - const closeButton = document.querySelector('.lucide-x')?.parentElement; - expect(closeButton).toBeInTheDocument(); + describe('Intro Step', () => { + it('renders intro step content by default', () => { + render(React.createElement(TwoFactorSetup, defaultProps)); + expect(screen.getByText('Secure Your Account')).toBeInTheDocument(); + expect(screen.getByText(/Two-factor authentication adds an extra layer of security/)).toBeInTheDocument(); + }); + + it('renders smartphone icon in intro', () => { + render(React.createElement(TwoFactorSetup, defaultProps)); + const smartphoneIcon = document.querySelector('.lucide-smartphone'); + expect(smartphoneIcon).toBeInTheDocument(); + }); + + it('renders Get Started button', () => { + render(React.createElement(TwoFactorSetup, defaultProps)); + expect(screen.getByText('Get Started')).toBeInTheDocument(); + }); + + it('shows SMS Backup Not Available when phone not verified', () => { + render(React.createElement(TwoFactorSetup, defaultProps)); + expect(screen.getByText('SMS Backup Not Available')).toBeInTheDocument(); + }); + + it('shows SMS Backup Available when phone is verified', () => { + render(React.createElement(TwoFactorSetup, { ...defaultProps, phoneVerified: true })); + expect(screen.getByText('SMS Backup Available')).toBeInTheDocument(); + expect(screen.getByText('Your verified phone can be used as a backup method.')).toBeInTheDocument(); + }); + + it('shows verify phone prompt when has phone but not verified', () => { + render(React.createElement(TwoFactorSetup, { ...defaultProps, hasPhone: true })); + expect(screen.getByText('Verify your phone number now')).toBeInTheDocument(); + }); + + it('calls onVerifyPhone when verify link clicked', () => { + const mockOnVerifyPhone = vi.fn(); + const mockOnClose = vi.fn(); + render(React.createElement(TwoFactorSetup, { + ...defaultProps, + hasPhone: true, + onVerifyPhone: mockOnVerifyPhone, + onClose: mockOnClose + })); + + const verifyLink = screen.getByText('Verify your phone number now'); + fireEvent.click(verifyLink); + + expect(mockOnClose).toHaveBeenCalled(); + expect(mockOnVerifyPhone).toHaveBeenCalled(); + }); + + it('shows add phone prompt when no phone', () => { + render(React.createElement(TwoFactorSetup, defaultProps)); + expect(screen.getByText('Go to profile settings to add a phone number')).toBeInTheDocument(); + }); + + it('calls onClose when add phone link clicked', () => { + const mockOnClose = vi.fn(); + render(React.createElement(TwoFactorSetup, { ...defaultProps, onClose: mockOnClose })); + + const addPhoneLink = screen.getByText('Go to profile settings to add a phone number'); + fireEvent.click(addPhoneLink); + + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('starts setup when Get Started button clicked', async () => { + mockSetupTOTP.mockResolvedValue({ qr_code: 'base64qr', secret: 'ABCD1234' }); + + render(React.createElement(TwoFactorSetup, defaultProps)); + + const getStartedButton = screen.getByText('Get Started'); + fireEvent.click(getStartedButton); + + await waitFor(() => { + expect(mockSetupTOTP).toHaveBeenCalled(); + }); + }); + + it('shows error when setup fails', async () => { + mockSetupTOTP.mockRejectedValue({ + response: { data: { detail: 'Setup failed' } } + }); + + render(React.createElement(TwoFactorSetup, defaultProps)); + + const getStartedButton = screen.getByText('Get Started'); + fireEvent.click(getStartedButton); + + await waitFor(() => { + expect(screen.getByText('Setup failed')).toBeInTheDocument(); + }); + }); + + it('shows generic error when setup fails without detail', async () => { + mockSetupTOTP.mockRejectedValue(new Error('Network error')); + + render(React.createElement(TwoFactorSetup, defaultProps)); + + const getStartedButton = screen.getByText('Get Started'); + fireEvent.click(getStartedButton); + + await waitFor(() => { + expect(screen.getByText('Failed to start 2FA setup')).toBeInTheDocument(); + }); + }); + + it('shows Setting up... text when setup is pending', () => { + mockSetupTOTPPending = true; + + render(React.createElement(TwoFactorSetup, defaultProps)); + + expect(screen.getByText('Setting up...')).toBeInTheDocument(); + }); }); - it('calls onClose when close button is clicked', () => { - const mockOnClose = vi.fn(); - render(React.createElement(TwoFactorSetup, { ...defaultProps, onClose: mockOnClose })); - const closeButton = document.querySelector('.lucide-x')?.parentElement; - if (closeButton) { - fireEvent.click(closeButton); - } - expect(mockOnClose).toHaveBeenCalled(); + describe('QR Code Step', () => { + beforeEach(() => { + mockSetupTOTPData = { qr_code: 'base64qr', secret: 'ABCD1234' }; + }); + + it('displays QR code when data is available', () => { + render(React.createElement(TwoFactorSetup, defaultProps)); + + const qrImage = screen.queryByAltText('2FA QR Code'); + if (qrImage) { + expect(qrImage).toHaveAttribute('src', 'data:image/png;base64,base64qr'); + } + }); + + it('displays manual secret code', () => { + render(React.createElement(TwoFactorSetup, defaultProps)); + expect(screen.getByText('ABCD1234')).toBeInTheDocument(); + }); + + it('copies secret to clipboard when copy button clicked', async () => { + render(React.createElement(TwoFactorSetup, defaultProps)); + + const copyButton = document.querySelector('.lucide-copy')?.parentElement; + if (copyButton) { + fireEvent.click(copyButton); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('ABCD1234'); + + // Check icon changes to checkmark + await waitFor(() => { + const checkIcon = document.querySelector('.lucide-check'); + expect(checkIcon).toBeInTheDocument(); + }); + + // Icon reverts after timeout + vi.advanceTimersByTime(2000); + await waitFor(() => { + const copyIcon = document.querySelector('.lucide-copy'); + expect(copyIcon).toBeTruthy(); + }); + } + }); + + it('navigates to verify step when Continue clicked', () => { + render(React.createElement(TwoFactorSetup, defaultProps)); + + const continueButton = screen.getByText('Continue'); + fireEvent.click(continueButton); + + expect(screen.getByText('Enter the 6-digit code from your authenticator app')).toBeInTheDocument(); + }); }); - it('renders intro step content', () => { - render(React.createElement(TwoFactorSetup, defaultProps)); - expect(screen.getByText('Secure Your Account')).toBeInTheDocument(); - expect(screen.getByText(/Two-factor authentication adds an extra layer of security/)).toBeInTheDocument(); + describe('Verify Step', () => { + beforeEach(() => { + mockSetupTOTPData = { qr_code: 'base64qr', secret: 'ABCD1234' }; + }); + + it('renders verification code input', () => { + render(React.createElement(TwoFactorSetup, defaultProps)); + + // Navigate to verify step + fireEvent.click(screen.getByText('Continue')); + + const input = screen.getByPlaceholderText('000000'); + expect(input).toBeInTheDocument(); + expect(input).toHaveClass('text-center'); + }); + + it('filters non-numeric input', () => { + render(React.createElement(TwoFactorSetup, defaultProps)); + + fireEvent.click(screen.getByText('Continue')); + + const input = screen.getByPlaceholderText('000000') as HTMLInputElement; + fireEvent.change(input, { target: { value: 'abc123def' } }); + + expect(input.value).toBe('123'); + }); + + it('limits input to 6 digits', () => { + render(React.createElement(TwoFactorSetup, defaultProps)); + + fireEvent.click(screen.getByText('Continue')); + + const input = screen.getByPlaceholderText('000000') as HTMLInputElement; + fireEvent.change(input, { target: { value: '1234567890' } }); + + expect(input.value).toBe('123456'); + }); + + it('disables verify button when code is incomplete', () => { + render(React.createElement(TwoFactorSetup, defaultProps)); + + fireEvent.click(screen.getByText('Continue')); + + const input = screen.getByPlaceholderText('000000'); + fireEvent.change(input, { target: { value: '12345' } }); + + const verifyButton = screen.getByRole('button', { name: /verify/i }); + expect(verifyButton).toBeDisabled(); + }); + + it('enables verify button when code is 6 digits', () => { + render(React.createElement(TwoFactorSetup, defaultProps)); + + fireEvent.click(screen.getByText('Continue')); + + const input = screen.getByPlaceholderText('000000'); + fireEvent.change(input, { target: { value: '123456' } }); + + const verifyButton = screen.getByRole('button', { name: /verify/i }); + expect(verifyButton).not.toBeDisabled(); + }); + + it('verifies code successfully', async () => { + mockVerifyTOTP.mockResolvedValue({ recovery_codes: ['CODE1', 'CODE2', 'CODE3', 'CODE4', 'CODE5', 'CODE6'] }); + + render(React.createElement(TwoFactorSetup, defaultProps)); + + fireEvent.click(screen.getByText('Continue')); + + const input = screen.getByPlaceholderText('000000'); + fireEvent.change(input, { target: { value: '123456' } }); + + const verifyButton = screen.getByRole('button', { name: /verify/i }); + fireEvent.click(verifyButton); + + await waitFor(() => { + expect(mockVerifyTOTP).toHaveBeenCalledWith('123456'); + }); + }); + + it('shows error when verification fails', async () => { + mockVerifyTOTP.mockRejectedValue({ + response: { data: { detail: 'Invalid code' } } + }); + + render(React.createElement(TwoFactorSetup, defaultProps)); + + fireEvent.click(screen.getByText('Continue')); + + const input = screen.getByPlaceholderText('000000'); + fireEvent.change(input, { target: { value: '123456' } }); + + const verifyButton = screen.getByRole('button', { name: /verify/i }); + fireEvent.click(verifyButton); + + await waitFor(() => { + expect(screen.getByText('Invalid code')).toBeInTheDocument(); + }); + }); + + it('shows generic error when verification fails without detail', async () => { + mockVerifyTOTP.mockRejectedValue(new Error('Network error')); + + render(React.createElement(TwoFactorSetup, defaultProps)); + + fireEvent.click(screen.getByText('Continue')); + + const input = screen.getByPlaceholderText('000000'); + fireEvent.change(input, { target: { value: '123456' } }); + + const verifyButton = screen.getByRole('button', { name: /verify/i }); + fireEvent.click(verifyButton); + + await waitFor(() => { + expect(screen.getByText('Invalid verification code')).toBeInTheDocument(); + }); + }); + + it('navigates back to QR code step when Back clicked', () => { + render(React.createElement(TwoFactorSetup, defaultProps)); + + fireEvent.click(screen.getByText('Continue')); + + const backButton = screen.getByRole('button', { name: /back/i }); + fireEvent.click(backButton); + + expect(screen.getByText('Scan this QR code with your authenticator app')).toBeInTheDocument(); + }); + + it('shows Verifying... text when verification is pending', () => { + mockVerifyTOTPPending = true; + + render(React.createElement(TwoFactorSetup, defaultProps)); + + fireEvent.click(screen.getByText('Continue')); + + expect(screen.getByText('Verifying...')).toBeInTheDocument(); + }); }); - it('renders Get Started button', () => { - render(React.createElement(TwoFactorSetup, defaultProps)); - expect(screen.getByText('Get Started')).toBeInTheDocument(); + describe('Recovery Codes Step', () => { + beforeEach(() => { + mockSetupTOTPData = { qr_code: 'base64qr', secret: 'ABCD1234' }; + mockVerifyTOTPData = { recovery_codes: ['CODE1', 'CODE2', 'CODE3', 'CODE4', 'CODE5', 'CODE6'] }; + }); + + it('displays recovery codes after verification', () => { + render(React.createElement(TwoFactorSetup, defaultProps)); + + expect(screen.getByText('CODE1')).toBeInTheDocument(); + expect(screen.getByText('CODE2')).toBeInTheDocument(); + expect(screen.getByText('CODE3')).toBeInTheDocument(); + }); + + it('shows success message', () => { + render(React.createElement(TwoFactorSetup, defaultProps)); + + expect(screen.getByText('2FA Enabled Successfully!')).toBeInTheDocument(); + expect(screen.getByText('Save these recovery codes in a safe place')).toBeInTheDocument(); + }); + + it('shows warning about recovery codes', () => { + render(React.createElement(TwoFactorSetup, defaultProps)); + + expect(screen.getByText(/Each code can only be used once/)).toBeInTheDocument(); + }); + + it('copies recovery codes to clipboard', async () => { + render(React.createElement(TwoFactorSetup, defaultProps)); + + const copyButton = screen.getByRole('button', { name: /copy/i }); + fireEvent.click(copyButton); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('CODE1\nCODE2\nCODE3\nCODE4\nCODE5\nCODE6'); + + // Check button text changes + await waitFor(() => { + expect(screen.getByText('Copied!')).toBeInTheDocument(); + }); + + // Reverts after timeout + vi.advanceTimersByTime(2000); + await waitFor(() => { + expect(screen.getByText('Copy')).toBeInTheDocument(); + }); + }); + + it('downloads recovery codes', () => { + render(React.createElement(TwoFactorSetup, defaultProps)); + + const downloadButton = screen.getByRole('button', { name: /download/i }); + fireEvent.click(downloadButton); + + // Check that blob and download were triggered + expect(global.URL.createObjectURL).toHaveBeenCalled(); + expect(global.URL.revokeObjectURL).toHaveBeenCalled(); + }); + + it('calls onSuccess and onClose when Done clicked', () => { + const mockOnSuccess = vi.fn(); + const mockOnClose = vi.fn(); + + render(React.createElement(TwoFactorSetup, { + ...defaultProps, + onSuccess: mockOnSuccess, + onClose: mockOnClose + })); + + const doneButton = screen.getByRole('button', { name: /done/i }); + fireEvent.click(doneButton); + + expect(mockOnSuccess).toHaveBeenCalled(); + expect(mockOnClose).toHaveBeenCalled(); + }); }); - it('shows SMS Backup Not Available when phone not verified', () => { - render(React.createElement(TwoFactorSetup, defaultProps)); - expect(screen.getByText('SMS Backup Not Available')).toBeInTheDocument(); + describe('Disable 2FA Flow', () => { + it('shows disable options when 2FA is enabled', () => { + render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true })); + + expect(screen.getByText('View Recovery Codes')).toBeInTheDocument(); + expect(screen.getByText('Disable Two-Factor Authentication')).toBeInTheDocument(); + }); + + it('renders disable code input', () => { + render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true })); + + const input = screen.getByPlaceholderText('000000'); + expect(input).toBeInTheDocument(); + }); + + it('filters non-numeric input in disable code', () => { + render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true })); + + const input = screen.getByPlaceholderText('000000') as HTMLInputElement; + fireEvent.change(input, { target: { value: 'abc123def' } }); + + expect(input.value).toBe('123'); + }); + + it('limits disable code to 6 digits', () => { + render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true })); + + const input = screen.getByPlaceholderText('000000') as HTMLInputElement; + fireEvent.change(input, { target: { value: '1234567890' } }); + + expect(input.value).toBe('123456'); + }); + + it('disables button when code is incomplete', () => { + render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true })); + + const input = screen.getByPlaceholderText('000000'); + fireEvent.change(input, { target: { value: '12345' } }); + + const disableButton = screen.getByText('Disable Two-Factor Authentication'); + expect(disableButton).toBeDisabled(); + }); + + it('disables 2FA successfully', async () => { + mockDisableTOTP.mockResolvedValue({}); + + const mockOnSuccess = vi.fn(); + const mockOnClose = vi.fn(); + + render(React.createElement(TwoFactorSetup, { + ...defaultProps, + isEnabled: true, + onSuccess: mockOnSuccess, + onClose: mockOnClose + })); + + const input = screen.getByPlaceholderText('000000'); + fireEvent.change(input, { target: { value: '123456' } }); + + const disableButton = screen.getByText('Disable Two-Factor Authentication'); + fireEvent.click(disableButton); + + await waitFor(() => { + expect(mockDisableTOTP).toHaveBeenCalledWith('123456'); + expect(mockOnSuccess).toHaveBeenCalled(); + expect(mockOnClose).toHaveBeenCalled(); + }); + }); + + it('shows error when disable fails', async () => { + mockDisableTOTP.mockRejectedValue({ + response: { data: { detail: 'Invalid code' } } + }); + + render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true })); + + const input = screen.getByPlaceholderText('000000'); + fireEvent.change(input, { target: { value: '123456' } }); + + const disableButton = screen.getByText('Disable Two-Factor Authentication'); + fireEvent.click(disableButton); + + await waitFor(() => { + expect(screen.getByText('Invalid code')).toBeInTheDocument(); + }); + }); + + it('shows generic error when disable fails without detail', async () => { + mockDisableTOTP.mockRejectedValue(new Error('Network error')); + + render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true })); + + const input = screen.getByPlaceholderText('000000'); + fireEvent.change(input, { target: { value: '123456' } }); + + const disableButton = screen.getByText('Disable Two-Factor Authentication'); + fireEvent.click(disableButton); + + await waitFor(() => { + expect(screen.getByText('Invalid code')).toBeInTheDocument(); + }); + }); + + it('shows Disabling... text when disable is pending', () => { + mockDisableTOTPPending = true; + + render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true })); + + expect(screen.getByText('Disabling...')).toBeInTheDocument(); + }); }); - it('shows SMS Backup Available when phone is verified', () => { - render(React.createElement(TwoFactorSetup, { ...defaultProps, phoneVerified: true })); - expect(screen.getByText('SMS Backup Available')).toBeInTheDocument(); + describe('View Recovery Codes Flow', () => { + it('loads recovery codes when View Recovery Codes clicked', async () => { + mockRecoveryCodesRefetch.mockResolvedValue({ + data: ['REC1', 'REC2', 'REC3', 'REC4', 'REC5', 'REC6'] + }); + + render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true })); + + const viewButton = screen.getByText('View Recovery Codes'); + fireEvent.click(viewButton); + + await waitFor(() => { + expect(mockRecoveryCodesRefetch).toHaveBeenCalled(); + }); + }); + + it('shows error when loading recovery codes fails', async () => { + mockRecoveryCodesRefetch.mockRejectedValue({ + response: { data: { detail: 'Failed to load' } } + }); + + render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true })); + + const viewButton = screen.getByText('View Recovery Codes'); + fireEvent.click(viewButton); + + await waitFor(() => { + expect(screen.getByText('Failed to load')).toBeInTheDocument(); + }); + }); + + it('shows generic error when loading fails without detail', async () => { + mockRecoveryCodesRefetch.mockRejectedValue(new Error('Network error')); + + render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true })); + + const viewButton = screen.getByText('View Recovery Codes'); + fireEvent.click(viewButton); + + await waitFor(() => { + expect(screen.getByText('Failed to load recovery codes')).toBeInTheDocument(); + }); + }); + + it('displays recovery codes after loading', () => { + mockRecoveryCodesData = ['REC1', 'REC2', 'REC3', 'REC4']; + + render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true })); + + expect(screen.getByText('REC1')).toBeInTheDocument(); + expect(screen.getByText('REC2')).toBeInTheDocument(); + }); + + it('navigates back to disable step', () => { + mockRecoveryCodesData = ['REC1', 'REC2']; + + render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true })); + + const backButton = screen.getByText('← Back'); + fireEvent.click(backButton); + + expect(screen.getByText('Disable Two-Factor Authentication')).toBeInTheDocument(); + }); + + it('copies recovery codes from view', async () => { + mockRecoveryCodesData = ['REC1', 'REC2']; + + render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true })); + + const copyButton = screen.getByRole('button', { name: /copy/i }); + fireEvent.click(copyButton); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('REC1\nREC2'); + }); + + it('downloads recovery codes from view', () => { + mockRecoveryCodesData = ['REC1', 'REC2']; + + render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true })); + + const downloadButton = screen.getByRole('button', { name: /download/i }); + fireEvent.click(downloadButton); + + expect(global.URL.createObjectURL).toHaveBeenCalled(); + }); + + it('regenerates recovery codes', async () => { + mockRegenerateCodes.mockResolvedValue(['NEW1', 'NEW2']); + mockRecoveryCodesData = ['REC1', 'REC2']; + + render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true })); + + const regenerateButton = screen.getByText('Regenerate Recovery Codes'); + fireEvent.click(regenerateButton); + + await waitFor(() => { + expect(mockRegenerateCodes).toHaveBeenCalled(); + }); + }); + + it('shows error when regenerate fails', async () => { + mockRegenerateCodes.mockRejectedValue({ + response: { data: { detail: 'Regenerate failed' } } + }); + mockRecoveryCodesData = ['REC1', 'REC2']; + + render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true })); + + const regenerateButton = screen.getByText('Regenerate Recovery Codes'); + fireEvent.click(regenerateButton); + + await waitFor(() => { + expect(screen.getByText('Regenerate failed')).toBeInTheDocument(); + }); + }); + + it('shows generic error when regenerate fails without detail', async () => { + mockRegenerateCodes.mockRejectedValue(new Error('Network error')); + mockRecoveryCodesData = ['REC1', 'REC2']; + + render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true })); + + const regenerateButton = screen.getByText('Regenerate Recovery Codes'); + fireEvent.click(regenerateButton); + + await waitFor(() => { + expect(screen.getByText('Failed to regenerate codes')).toBeInTheDocument(); + }); + }); + + it('shows Loading... text when fetching recovery codes', () => { + mockRecoveryCodesFetching = true; + + render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true })); + + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); + + it('shows Regenerating... text when regenerating codes', () => { + mockRecoveryCodesData = ['REC1', 'REC2']; + mockRegenerateCodesPending = true; + + render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true })); + + expect(screen.getByText('Regenerating...')).toBeInTheDocument(); + }); }); - it('shows phone verification prompt when has phone but not verified', () => { - render(React.createElement(TwoFactorSetup, { ...defaultProps, hasPhone: true })); - expect(screen.getByText('Verify your phone number now')).toBeInTheDocument(); - }); + describe('Error Display', () => { + it('shows error with alert icon', async () => { + mockSetupTOTP.mockRejectedValue({ + response: { data: { detail: 'Test error' } } + }); - it('shows add phone prompt when no phone', () => { - render(React.createElement(TwoFactorSetup, defaultProps)); - expect(screen.getByText('Go to profile settings to add a phone number')).toBeInTheDocument(); - }); + render(React.createElement(TwoFactorSetup, defaultProps)); - it('renders View Recovery Codes option when enabled', () => { - render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true })); - expect(screen.getByText('View Recovery Codes')).toBeInTheDocument(); - }); + const getStartedButton = screen.getByText('Get Started'); + fireEvent.click(getStartedButton); - it('renders disable 2FA option when enabled', () => { - render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true })); - expect(screen.getByText('Disable Two-Factor Authentication')).toBeInTheDocument(); - }); - - it('renders disable code input when enabled', () => { - render(React.createElement(TwoFactorSetup, { ...defaultProps, isEnabled: true })); - expect(screen.getByPlaceholderText('000000')).toBeInTheDocument(); - }); - - it('shows Shield icon in header', () => { - render(React.createElement(TwoFactorSetup, defaultProps)); - const shieldIcon = document.querySelector('.lucide-shield'); - expect(shieldIcon).toBeInTheDocument(); - }); - - it('renders smartphone icon in intro', () => { - render(React.createElement(TwoFactorSetup, defaultProps)); - const smartphoneIcon = document.querySelector('.lucide-smartphone'); - expect(smartphoneIcon).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText('Test error')).toBeInTheDocument(); + const alertIcon = document.querySelector('.lucide-alert-triangle'); + expect(alertIcon).toBeInTheDocument(); + }); + }); }); }); diff --git a/frontend/src/components/services/__tests__/CustomerPreview.test.tsx b/frontend/src/components/services/__tests__/CustomerPreview.test.tsx new file mode 100644 index 00000000..dd0debcf --- /dev/null +++ b/frontend/src/components/services/__tests__/CustomerPreview.test.tsx @@ -0,0 +1,314 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { CustomerPreview } from '../CustomerPreview'; +import { Service, Business } from '../../../types'; + +// Mock dependencies +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +// Mock Lucide icons +vi.mock('lucide-react', () => ({ + Clock: () => , + DollarSign: () => , + Image: () => , + CheckCircle2: () => , + AlertCircle: () => , +})); + +// Mock Badge component +vi.mock('../../ui/Badge', () => ({ + default: ({ children, variant, size }: any) => + {children}, +})); + +describe('CustomerPreview', () => { + const mockBusiness: Business = { + id: 'business-1', + name: 'Test Business', + primaryColor: '#2563eb', + secondaryColor: '#0ea5e9', + } as Business; + + const mockService: Service = { + id: '1', + name: 'Haircut', + description: 'Professional haircut service', + price: 50, + durationMinutes: 60, + photos: [], + category: { id: 'cat1', name: 'Hair Services' }, + variable_pricing: false, + } as Service; + + const mockServiceWithPhoto: Service = { + ...mockService, + photos: ['https://example.com/photo.jpg'], + }; + + const mockServiceWithDeposit: Service = { + ...mockService, + deposit_amount: 10, + }; + + const mockServiceVariablePricing: Service = { + ...mockService, + variable_pricing: true, + deposit_amount: 25, + }; + + const defaultProps = { + service: mockService, + business: mockBusiness, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders customer preview heading', () => { + render(React.createElement(CustomerPreview, defaultProps)); + expect(screen.getByText('Customer Preview')).toBeInTheDocument(); + }); + + it('shows live preview badge', () => { + render(React.createElement(CustomerPreview, defaultProps)); + expect(screen.getByTestId('badge-info')).toBeInTheDocument(); + expect(screen.getByText('Live Preview')).toBeInTheDocument(); + }); + + it('displays service name', () => { + render(React.createElement(CustomerPreview, defaultProps)); + expect(screen.getByText('Haircut')).toBeInTheDocument(); + }); + + it('displays service description', () => { + render(React.createElement(CustomerPreview, defaultProps)); + expect(screen.getByText('Professional haircut service')).toBeInTheDocument(); + }); + + it('displays service category', () => { + render(React.createElement(CustomerPreview, defaultProps)); + expect(screen.getByText('Hair Services')).toBeInTheDocument(); + }); + + it('displays duration', () => { + render(React.createElement(CustomerPreview, defaultProps)); + expect(screen.getByText('60 mins')).toBeInTheDocument(); + }); + + it('shows clock icon for duration', () => { + render(React.createElement(CustomerPreview, defaultProps)); + expect(screen.getByTestId('icon-clock')).toBeInTheDocument(); + }); + + it('displays price', () => { + render(React.createElement(CustomerPreview, defaultProps)); + expect(screen.getByText('50')).toBeInTheDocument(); + }); + + it('shows dollar sign icon for price', () => { + render(React.createElement(CustomerPreview, defaultProps)); + expect(screen.getByTestId('icon-dollar-sign')).toBeInTheDocument(); + }); + + it('shows image icon when no photos', () => { + render(React.createElement(CustomerPreview, defaultProps)); + expect(screen.getByTestId('icon-image')).toBeInTheDocument(); + }); + + it('displays photo when available', () => { + const props = { ...defaultProps, service: mockServiceWithPhoto }; + render(React.createElement(CustomerPreview, props)); + + const img = document.querySelector('img'); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute('src', 'https://example.com/photo.jpg'); + expect(img).toHaveAttribute('alt', 'Haircut'); + }); + + it('displays deposit requirement when set', () => { + const props = { ...defaultProps, service: mockServiceWithDeposit }; + render(React.createElement(CustomerPreview, props)); + + expect(screen.getByText(/Deposit required:/)).toBeInTheDocument(); + expect(screen.getByText((content, element) => { + return element?.textContent === 'Deposit required: $10'; + })).toBeInTheDocument(); + }); + + it('does not show deposit when not required', () => { + render(React.createElement(CustomerPreview, defaultProps)); + expect(screen.queryByText(/Deposit required:/)).not.toBeInTheDocument(); + }); + + it('shows variable pricing badge', () => { + const props = { ...defaultProps, service: mockServiceVariablePricing }; + render(React.createElement(CustomerPreview, props)); + + expect(screen.getByText('Variable')).toBeInTheDocument(); + }); + + it('shows "Price varies" text for variable pricing', () => { + const props = { ...defaultProps, service: mockServiceVariablePricing }; + render(React.createElement(CustomerPreview, props)); + + expect(screen.getByText('Price varies')).toBeInTheDocument(); + }); + + it('shows deposit for variable pricing services', () => { + const props = { ...defaultProps, service: mockServiceVariablePricing }; + render(React.createElement(CustomerPreview, props)); + + expect(screen.getByText(/Deposit required:/)).toBeInTheDocument(); + }); + + it('displays info alert about preview', () => { + render(React.createElement(CustomerPreview, defaultProps)); + expect(screen.getByTestId('icon-alert-circle')).toBeInTheDocument(); + expect(screen.getByText(/This is how your service will appear to customers/)).toBeInTheDocument(); + }); + + it('handles null service gracefully', () => { + const props = { ...defaultProps, service: null }; + render(React.createElement(CustomerPreview, props)); + + expect(screen.getByText('New Service')).toBeInTheDocument(); + expect(screen.getByText('Service description will appear here...')).toBeInTheDocument(); + }); + + it('displays default category when not set', () => { + const serviceWithoutCategory = { ...mockService, category: undefined }; + const props = { ...defaultProps, service: serviceWithoutCategory }; + render(React.createElement(CustomerPreview, props)); + + expect(screen.getByText('General')).toBeInTheDocument(); + }); + + it('uses preview data when provided', () => { + const previewData = { + name: 'Custom Name', + description: 'Custom Description', + price: 75, + durationMinutes: 90, + }; + const props = { ...defaultProps, previewData }; + render(React.createElement(CustomerPreview, props)); + + expect(screen.getByText('Custom Name')).toBeInTheDocument(); + expect(screen.getByText('Custom Description')).toBeInTheDocument(); + expect(screen.getByText('75')).toBeInTheDocument(); + expect(screen.getByText('90 mins')).toBeInTheDocument(); + }); + + it('preview data overrides service data', () => { + const previewData = { name: 'Preview Name' }; + const props = { ...defaultProps, previewData }; + render(React.createElement(CustomerPreview, props)); + + // Should show preview data instead of service data + expect(screen.getByText('Preview Name')).toBeInTheDocument(); + expect(screen.queryByText('Haircut')).not.toBeInTheDocument(); + }); + + it('formats price correctly', () => { + const props = { ...defaultProps, service: { ...mockService, price: 123 } }; + render(React.createElement(CustomerPreview, props)); + + expect(screen.getByText('123')).toBeInTheDocument(); + }); + + it('handles zero price', () => { + const props = { ...defaultProps, service: { ...mockService, price: 0 } }; + render(React.createElement(CustomerPreview, props)); + + expect(screen.getByText('0')).toBeInTheDocument(); + }); + + it('applies business colors to gradient', () => { + render(React.createElement(CustomerPreview, defaultProps)); + + // The gradient uses business colors in the style attribute + const gradientDiv = document.querySelector('[style*="gradient"]'); + expect(gradientDiv).toBeInTheDocument(); + }); + + it('displays deposit with correct formatting', () => { + const props = { ...defaultProps, service: { ...mockService, deposit_amount: 15 } }; + render(React.createElement(CustomerPreview, props)); + + expect(screen.getByText((content, element) => { + return element?.textContent === 'Deposit required: $15'; + })).toBeInTheDocument(); + }); + + it('shows default duration when not set', () => { + const serviceWithoutDuration = { ...mockService, durationMinutes: undefined }; + const props = { ...defaultProps, service: serviceWithoutDuration }; + render(React.createElement(CustomerPreview, props)); + + expect(screen.getByText('30 mins')).toBeInTheDocument(); + }); + + it('displays border styling to indicate selected preview', () => { + render(React.createElement(CustomerPreview, defaultProps)); + + const card = document.querySelector('.border-brand-600'); + expect(card).toBeInTheDocument(); + }); + + it('displays ring styling', () => { + render(React.createElement(CustomerPreview, defaultProps)); + + const card = document.querySelector('.ring-brand-600'); + expect(card).toBeInTheDocument(); + }); + + it('handles preview data with partial updates', () => { + const previewData = { price: 99 }; + const props = { ...defaultProps, previewData }; + render(React.createElement(CustomerPreview, props)); + + // Name should still be from service + expect(screen.getByText('Haircut')).toBeInTheDocument(); + // Price should be from previewData + expect(screen.getByText('99')).toBeInTheDocument(); + }); + + it('merges photos from preview data', () => { + const previewData = { photos: ['https://preview.com/new.jpg'] }; + const props = { ...defaultProps, previewData }; + render(React.createElement(CustomerPreview, props)); + + const img = document.querySelector('img'); + expect(img).toHaveAttribute('src', 'https://preview.com/new.jpg'); + }); + + it('handles empty photos array', () => { + const props = { ...defaultProps, service: { ...mockService, photos: [] } }; + render(React.createElement(CustomerPreview, props)); + + expect(screen.getByTestId('icon-image')).toBeInTheDocument(); + }); + + it('uses first photo only for cover', () => { + const serviceWithMultiplePhotos = { + ...mockService, + photos: ['https://first.com/1.jpg', 'https://second.com/2.jpg'], + }; + const props = { ...defaultProps, service: serviceWithMultiplePhotos }; + render(React.createElement(CustomerPreview, props)); + + const img = document.querySelector('img'); + expect(img).toHaveAttribute('src', 'https://first.com/1.jpg'); + }); + + it('displays horizontal card layout', () => { + render(React.createElement(CustomerPreview, defaultProps)); + + const cardContainer = document.querySelector('.flex.h-full'); + expect(cardContainer).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/services/__tests__/ResourceSelector.test.tsx b/frontend/src/components/services/__tests__/ResourceSelector.test.tsx new file mode 100644 index 00000000..fa75e398 --- /dev/null +++ b/frontend/src/components/services/__tests__/ResourceSelector.test.tsx @@ -0,0 +1,306 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { ResourceSelector } from '../ResourceSelector'; +import { Resource } from '../../../types'; + +// Mock dependencies +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +// Mock Lucide icons +vi.mock('lucide-react', () => ({ + Users: () => , + Search: () => , + Check: () => , + X: () => , + AlertCircle: () => , +})); + +describe('ResourceSelector', () => { + const mockResources: Resource[] = [ + { + id: '1', + name: 'John Doe', + type: 'STAFF', + } as Resource, + { + id: '2', + name: 'Jane Smith', + type: 'STAFF', + } as Resource, + { + id: '3', + name: 'Bob Johnson', + type: 'STAFF', + } as Resource, + ]; + + const defaultProps = { + resources: mockResources, + selectedIds: [], + allSelected: false, + onChange: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders the all staff toggle', () => { + render(React.createElement(ResourceSelector, defaultProps)); + expect(screen.getByText('All Staff Available')).toBeInTheDocument(); + expect(screen.getByText('Automatically include current and future staff')).toBeInTheDocument(); + }); + + it('shows users icon', () => { + render(React.createElement(ResourceSelector, defaultProps)); + expect(screen.getByTestId('icon-users')).toBeInTheDocument(); + }); + + it('displays toggle switch', () => { + render(React.createElement(ResourceSelector, defaultProps)); + const checkbox = document.querySelector('input[type="checkbox"]'); + expect(checkbox).toBeInTheDocument(); + expect(checkbox).not.toBeChecked(); + }); + + it('shows staff list when allSelected is false', () => { + render(React.createElement(ResourceSelector, defaultProps)); + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.getByText('Jane Smith')).toBeInTheDocument(); + expect(screen.getByText('Bob Johnson')).toBeInTheDocument(); + }); + + it('hides staff list when allSelected is true', () => { + const props = { ...defaultProps, allSelected: true }; + render(React.createElement(ResourceSelector, props)); + expect(screen.queryByText('John Doe')).not.toBeInTheDocument(); + }); + + it('calls onChange with all=true when toggling on', () => { + const onChange = vi.fn(); + const props = { ...defaultProps, onChange }; + render(React.createElement(ResourceSelector, props)); + + const checkbox = document.querySelector('input[type="checkbox"]') as HTMLInputElement; + fireEvent.click(checkbox); + + expect(onChange).toHaveBeenCalledWith([], true); + }); + + it('calls onChange with all=false when toggling off', () => { + const onChange = vi.fn(); + const props = { ...defaultProps, onChange, allSelected: true }; + render(React.createElement(ResourceSelector, props)); + + const checkbox = document.querySelector('input[type="checkbox"]') as HTMLInputElement; + fireEvent.click(checkbox); + + expect(onChange).toHaveBeenCalledWith([], false); + }); + + it('displays search input', () => { + render(React.createElement(ResourceSelector, defaultProps)); + expect(screen.getByPlaceholderText('Search staff...')).toBeInTheDocument(); + }); + + it('shows search icon', () => { + render(React.createElement(ResourceSelector, defaultProps)); + expect(screen.getByTestId('icon-search')).toBeInTheDocument(); + }); + + it('filters resources by search term', () => { + render(React.createElement(ResourceSelector, defaultProps)); + + const searchInput = screen.getByPlaceholderText('Search staff...'); + fireEvent.change(searchInput, { target: { value: 'Doe' } }); + + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.queryByText('Jane Smith')).not.toBeInTheDocument(); + expect(screen.queryByText('Bob Johnson')).not.toBeInTheDocument(); + }); + + it('search is case insensitive', () => { + render(React.createElement(ResourceSelector, defaultProps)); + + const searchInput = screen.getByPlaceholderText('Search staff...'); + fireEvent.change(searchInput, { target: { value: 'JANE' } }); + + expect(screen.getByText('Jane Smith')).toBeInTheDocument(); + expect(screen.queryByText('John Doe')).not.toBeInTheDocument(); + }); + + it('shows no results message when search has no matches', () => { + render(React.createElement(ResourceSelector, defaultProps)); + + const searchInput = screen.getByPlaceholderText('Search staff...'); + fireEvent.change(searchInput, { target: { value: 'Nonexistent' } }); + + expect(screen.getByText('No staff found matching "Nonexistent"')).toBeInTheDocument(); + }); + + it('displays resource initials in avatar', () => { + render(React.createElement(ResourceSelector, defaultProps)); + + const initials = screen.getAllByText('J'); // John and Jane both start with J + expect(initials.length).toBeGreaterThan(0); + }); + + it('calls onChange when clicking a resource', () => { + const onChange = vi.fn(); + const props = { ...defaultProps, onChange }; + render(React.createElement(ResourceSelector, props)); + + const resource = screen.getByText('John Doe').closest('button'); + fireEvent.click(resource!); + + expect(onChange).toHaveBeenCalledWith(['1'], false); + }); + + it('adds resource to selection when not selected', () => { + const onChange = vi.fn(); + const props = { ...defaultProps, onChange, selectedIds: ['2'] }; + render(React.createElement(ResourceSelector, props)); + + const resource = screen.getByText('John Doe').closest('button'); + fireEvent.click(resource!); + + expect(onChange).toHaveBeenCalledWith(['2', '1'], false); + }); + + it('removes resource from selection when already selected', () => { + const onChange = vi.fn(); + const props = { ...defaultProps, onChange, selectedIds: ['1', '2'] }; + render(React.createElement(ResourceSelector, props)); + + const resource = screen.getByText('John Doe').closest('button'); + fireEvent.click(resource!); + + expect(onChange).toHaveBeenCalledWith(['2'], false); + }); + + it('highlights selected resources', () => { + const props = { ...defaultProps, selectedIds: ['1'] }; + render(React.createElement(ResourceSelector, props)); + + const resource = screen.getByText('John Doe').closest('button'); + expect(resource).toHaveClass('bg-brand-50'); + }); + + it('shows check icon for selected resources', () => { + const props = { ...defaultProps, selectedIds: ['1'] }; + render(React.createElement(ResourceSelector, props)); + + expect(screen.getByTestId('icon-check')).toBeInTheDocument(); + }); + + it('does not toggle when allSelected is true', () => { + const onChange = vi.fn(); + const props = { ...defaultProps, onChange, allSelected: true }; + render(React.createElement(ResourceSelector, props)); + + // The resource list should be hidden, so we can't click on resources + expect(screen.queryByText('John Doe')).not.toBeInTheDocument(); + }); + + it('displays selection count', () => { + const props = { ...defaultProps, selectedIds: ['1', '2'] }; + render(React.createElement(ResourceSelector, props)); + + expect(screen.getByText('2 staff selected')).toBeInTheDocument(); + }); + + it('shows warning when no staff selected', () => { + render(React.createElement(ResourceSelector, defaultProps)); + + expect(screen.getByText('At least one required')).toBeInTheDocument(); + expect(screen.getByTestId('icon-alert-circle')).toBeInTheDocument(); + }); + + it('does not show warning when staff are selected', () => { + const props = { ...defaultProps, selectedIds: ['1'] }; + render(React.createElement(ResourceSelector, props)); + + expect(screen.queryByText('At least one required')).not.toBeInTheDocument(); + }); + + it('displays all resources initially', () => { + render(React.createElement(ResourceSelector, defaultProps)); + + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.getByText('Jane Smith')).toBeInTheDocument(); + expect(screen.getByText('Bob Johnson')).toBeInTheDocument(); + }); + + it('handles empty resources array', () => { + const props = { ...defaultProps, resources: [] }; + render(React.createElement(ResourceSelector, props)); + + const searchInput = screen.getByPlaceholderText('Search staff...'); + fireEvent.change(searchInput, { target: { value: 'test' } }); + + expect(screen.getByText('No staff found matching "test"')).toBeInTheDocument(); + }); + + it('clears search when typing new term', () => { + render(React.createElement(ResourceSelector, defaultProps)); + + const searchInput = screen.getByPlaceholderText('Search staff...') as HTMLInputElement; + + fireEvent.change(searchInput, { target: { value: 'John' } }); + expect(searchInput.value).toBe('John'); + + fireEvent.change(searchInput, { target: { value: '' } }); + expect(searchInput.value).toBe(''); + + // All resources visible again + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.getByText('Jane Smith')).toBeInTheDocument(); + }); + + it('toggle switch is checked when allSelected is true', () => { + const props = { ...defaultProps, allSelected: true }; + render(React.createElement(ResourceSelector, props)); + + const checkbox = document.querySelector('input[type="checkbox"]') as HTMLInputElement; + expect(checkbox).toBeChecked(); + }); + + it('shows correct selection count for multiple selections', () => { + const props = { ...defaultProps, selectedIds: ['1', '2', '3'] }; + render(React.createElement(ResourceSelector, props)); + + expect(screen.getByText('3 staff selected')).toBeInTheDocument(); + }); + + it('shows correct selection count for single selection', () => { + const props = { ...defaultProps, selectedIds: ['1'] }; + render(React.createElement(ResourceSelector, props)); + + expect(screen.getByText('1 staff selected')).toBeInTheDocument(); + }); + + it('updates display when selectedIds prop changes', () => { + const { rerender } = render(React.createElement(ResourceSelector, defaultProps)); + + expect(screen.getByText('0 staff selected')).toBeInTheDocument(); + + const newProps = { ...defaultProps, selectedIds: ['1', '2'] }; + rerender(React.createElement(ResourceSelector, newProps)); + + expect(screen.getByText('2 staff selected')).toBeInTheDocument(); + }); + + it('resource avatars have different styling when selected', () => { + const props = { ...defaultProps, selectedIds: ['1'] }; + render(React.createElement(ResourceSelector, props)); + + const selectedButton = screen.getByText('John Doe').closest('button'); + const unselectedButton = screen.getByText('Jane Smith').closest('button'); + + expect(selectedButton).toHaveClass('bg-brand-50'); + expect(unselectedButton).not.toHaveClass('bg-brand-50'); + }); +}); diff --git a/frontend/src/components/staff/RolePermissions.tsx b/frontend/src/components/staff/RolePermissions.tsx index e5cc036e..210164c9 100644 --- a/frontend/src/components/staff/RolePermissions.tsx +++ b/frontend/src/components/staff/RolePermissions.tsx @@ -20,6 +20,8 @@ export interface PermissionSectionProps { variant?: 'default' | 'settings' | 'dangerous'; readOnly?: boolean; columns?: 1 | 2; + lockedPermissions?: Record; // key -> reason (forced on) + disabledPermissions?: Record; // key -> reason (grayed out until parent enabled) } /** @@ -36,6 +38,8 @@ export const PermissionSection: React.FC = ({ variant = 'default', readOnly = false, columns = 2, + lockedPermissions = {}, + disabledPermissions = {}, }) => { const { t } = useTranslation(); @@ -51,9 +55,9 @@ export const PermissionSection: React.FC = ({ hover: 'hover:bg-blue-100/50 dark:hover:bg-blue-900/20', }, dangerous: { - container: 'p-3 bg-red-50/50 dark:bg-red-900/10 rounded-lg border border-red-100 dark:border-red-900/30', + container: 'p-3 bg-red-100 dark:bg-red-900/30 rounded-lg border border-red-200 dark:border-red-800/50', checkbox: 'text-red-600 focus:ring-red-500', - hover: 'hover:bg-red-100/50 dark:hover:bg-red-900/20', + hover: 'hover:bg-red-200/70 dark:hover:bg-red-900/40', }, }; @@ -105,6 +109,10 @@ export const PermissionSection: React.FC = ({ checkboxClass={styles.checkbox} hoverClass={styles.hover} readOnly={readOnly} + locked={!!lockedPermissions[key]} + lockedReason={lockedPermissions[key]} + disabled={!!disabledPermissions[key]} + disabledReason={disabledPermissions[key]} /> ))}
@@ -120,6 +128,10 @@ interface PermissionCheckboxProps { checkboxClass?: string; hoverClass?: string; readOnly?: boolean; + locked?: boolean; + lockedReason?: string; + disabled?: boolean; + disabledReason?: string; } /** @@ -133,21 +145,34 @@ export const PermissionCheckbox: React.FC = ({ checkboxClass = 'text-brand-600 focus:ring-brand-500', hoverClass = 'hover:bg-gray-50 dark:hover:bg-gray-700/50', readOnly = false, + locked = false, + lockedReason, + disabled = false, + disabledReason, }) => { + const isDisabled = readOnly || locked || disabled; + const tooltipReason = locked ? lockedReason : disabled ? disabledReason : undefined; + return (