36 Commits

Author SHA1 Message Date
poduck
e7733449dd Move tenant dashboard routes under /dashboard/ prefix
- Update App.tsx routes to use /dashboard/ prefix for all business user routes
- Add redirect from / to /dashboard for authenticated business users
- Update Sidebar.tsx navigation links with /dashboard/ prefix
- Update SettingsLayout.tsx settings navigation paths
- Update all help pages with /dashboard/help/ routes
- Update navigate() calls in components:
  - TrialBanner, PaymentSettingsSection, NotificationDropdown
  - BusinessLayout, UpgradePrompt, QuotaWarningBanner
  - QuotaOverageModal, OpenTicketsWidget, CreatePlugin
  - MyPlugins, PluginMarketplace, HelpTicketing
  - HelpGuide, Upgrade, TrialExpired
  - CustomDomainsSettings, QuotaSettings
- Fix hardcoded lvh.me URL in BusinessEditModal to use buildSubdomainUrl

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 01:48:45 -05:00
poduck
29bcb27e76 Add Puck site builder with preview and draft functionality
Frontend:
- Add comprehensive Puck component library (Layout, Content, Booking, Contact)
- Add Services component with usePublicServices hook integration
- Add 150+ icons to IconList component organized by category
- Add preview modal with viewport toggles (desktop/tablet/mobile)
- Add draft save/discard functionality with localStorage persistence
- Add draft status indicator in PageEditor toolbar
- Fix useSites hooks to use correct API URLs (/pages/{id}/)

Backend:
- Add SiteConfig model for theme, header, footer configuration
- Add Page SEO fields (meta_title, meta_description, og_image, etc.)
- Add puck_data validation for component structure
- Add create_missing_sites management command
- Fix PageViewSet to use EntitlementService for permissions
- Add comprehensive tests for site builder functionality

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 01:32:11 -05:00
poduck
41caccd31a Add max_public_pages feature and site builder access control
- Add max_public_pages billing feature (Free=0, Starter=1, Growth=5, Pro=10)
- Gate site builder access based on max_public_pages entitlement
- Auto-create Site with default booking page for new tenants
- Update PageEditor to use useEntitlements hook for permission checks
- Replace hardcoded limits in BusinessEditModal with DynamicFeaturesEditor
- Add force update functionality for superusers in PlanEditorWizard
- Add comprehensive filters to all safe scripting get_* methods
- Update plugin documentation with full filter reference

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 00:27:15 -05:00
poduck
aa9d920612 Fix plan permissions using correct billing feature codes
The /api/business/current/ endpoint was using legacy permission key names
instead of the actual feature codes from the billing catalog. This caused
tenants on paid plans to be incorrectly locked out of features.

- Updated current_business_view to use correct feature codes (e.g.,
  'can_use_plugins' instead of 'plugins', 'sms_enabled' instead of
  'sms_reminders')
- Updated test to mock billing subscription and has_feature correctly

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 21:48:09 -05:00
poduck
b384d9912a Add TenantCustomTier system and fix BusinessEditModal feature loading
Backend:
- Add TenantCustomTier model for per-tenant feature overrides
- Update EntitlementService to check custom tier before plan features
- Add custom_tier action on TenantViewSet (GET/PUT/DELETE)
- Add Celery task for grace period management (30-day expiry)

Frontend:
- Add DynamicFeaturesEditor component for dynamic feature management
- Fix BusinessEditModal to load features from plan defaults when no custom tier
- Update limits (max_users, max_resources, etc.) to use featureValues
- Remove outdated canonical feature check from FeaturePicker (removes warning icons)
- Add useBillingPlans hook for accessing billing system data
- Add custom tier API functions to platform.ts

Features now follow consistent rules:
- Load from plan defaults when no custom tier exists
- Load from custom tier when one exists
- Reset to plan defaults when plan changes
- Save to custom tier on edit

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 21:00:54 -05:00
poduck
d25c578e59 Remove Tiers & Pricing tab from Platform Settings
Billing management is now handled on the dedicated Billing page.
Removed ~1050 lines of dead code including TiersSettingsTab, PlanRow,
and PlanModal components.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 03:15:39 -05:00
poduck
a8c271b5e3 Add stackable add-ons with compounding integer features
- Add is_stackable field to AddOnProduct model for add-ons that can be
  purchased multiple times
- Add quantity field to SubscriptionAddOn for tracking purchase count
- Update EntitlementService to ADD integer add-on values to base plan
  (instead of max) and multiply by quantity for stackable add-ons
- Add feature selection to AddOnEditorModal using FeaturePicker component
- Add AddOnFeatureSerializer for nested feature CRUD on add-ons
- Fix Create Add-on button styling to use solid blue (was muted outline)
- Widen billing sidebar from 320px to 384px to prevent text wrapping

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 03:10:53 -05:00
poduck
6afa3d7415 Refactor billing system: add-ons in wizard, remove business_tier, move to top-level app
- Add add-ons step to plan creation wizard (step 4 of 5)
- Remove redundant business_tier field from both billing systems:
  - commerce.billing.PlanVersion (new system)
  - platform.admin.SubscriptionPlan (legacy system)
- Move billing app from commerce.billing to top-level smoothschedule.billing
- Create BillingManagement page at /platform/billing with sidebar link
- Update plan matching logic to use plan.name instead of business_tier

Frontend:
- Add BillingManagement.tsx page
- Add BillingPlansTab.tsx with unified plan wizard
- Add useBillingAdmin.ts hooks
- Update TenantInviteModal, BusinessEditModal, BillingSettings to use plan.name
- Remove business_tier from usePlatformSettings, payments.ts types

Backend:
- Move billing app to smoothschedule/billing/
- Add migrations 0006-0009 for plan version settings, feature seeding, business_tier removal
- Add platform_admin migration 0013 to remove business_tier
- Update seed_subscription_plans command
- Update tasks.py to map tier by plan name

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 01:25:43 -05:00
poduck
17786c5ec0 Merge feature/site-builder: Add booking flow and business hours
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 20:20:25 -05:00
poduck
4a66246708 Add booking flow, business hours, and dark mode support
Features:
- Complete multi-step booking flow with service selection, date/time picker,
  auth (login/signup with email verification), payment, and confirmation
- Business hours settings page for defining when business is open
- TimeBlock purpose field (BUSINESS_HOURS, CLOSURE, UNAVAILABLE)
- Service resource assignment with prep/takedown time buffers
- Availability checking respects business hours and service buffers
- Customer registration via email verification code

UI/UX:
- Full dark mode support for all booking components
- Separate first/last name fields in signup form
- Back buttons on each wizard step
- Removed auto-redirect from confirmation page

API:
- Public endpoints for services, availability, business hours
- Customer verification and registration endpoints
- Tenant lookup from X-Business-Subdomain header

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 20:20:18 -05:00
poduck
76c0d71aa0 Implement Site Builder with Puck and Booking Widget 2025-12-10 23:54:10 -05:00
poduck
384fe0fd86 Refactor Services page UI, disable full test coverage, and add WIP badges 2025-12-10 23:11:41 -05:00
poduck
4afcaa2b0d chore: Update uv.lock file
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-10 15:35:19 -05:00
poduck
8c52d6a275 refactor: Extract reusable UI components and add TDD documentation
- Add comprehensive TDD documentation to CLAUDE.md with coverage requirements and examples
- Extract reusable UI components to frontend/src/components/ui/ (Modal, FormInput, Button, Alert, etc.)
- Add shared constants (schedulePresets) and utility hooks (useCrudMutation, useFormValidation)
- Update frontend/CLAUDE.md with component documentation and usage examples
- Refactor CreateTaskModal to use shared components and constants
- Fix test assertions to be more robust and accurate across all test files

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-10 15:27:27 -05:00
poduck
18c9a69d75 fix: Store service prices in cents and fix contracts permission
- Update Service model to use price_cents/deposit_amount_cents as IntegerField
- Add @property methods for backward compatibility (price, deposit_amount return dollars)
- Update ServiceSerializer to convert dollars <-> cents on read/write
- Add migration to convert column types from numeric to integer
- Fix BusinessEditModal to properly use typed PlatformBusiness interface
- Add missing feature permission fields to PlatformBusiness TypeScript interface

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 03:37:13 -05:00
poduck
30ec150d90 feat: Add subscription/billing/entitlement system
Implements a complete billing system with:

Backend (Django):
- New billing app with models: Feature, Plan, PlanVersion, PlanFeature,
  Subscription, AddOnProduct, AddOnFeature, SubscriptionAddOn,
  EntitlementOverride, Invoice, InvoiceLine
- EntitlementService with resolution order: overrides > add-ons > plan
- Invoice generation service with immutable snapshots
- DRF API endpoints for entitlements, subscription, plans, invoices
- Data migrations to seed initial plans and convert existing tenants
- Bridge to legacy Tenant.has_feature() with fallback support
- 75 tests covering models, services, and API endpoints

Frontend (React):
- Billing API client (getEntitlements, getPlans, getInvoices, etc.)
- useEntitlements hook with hasFeature() and getLimit() helpers
- FeatureGate and LimitGate components for conditional rendering
- 29 tests for API, hook, and components

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 03:10:30 -05:00
poduck
ba2c656243 perf: Optimize slow tests with shared tenant fixtures
- Add session-scoped shared_tenant and second_shared_tenant fixtures to conftest.py
- Refactor test_models.py and test_user_model.py to use shared fixtures
- Avoid ~40s migration overhead per tenant by reusing fixtures across tests
- Add pytest-xdist to dev dependencies for future parallel test execution

Previously 4 tests each created their own tenant (~40s each = ~160s total).
Now they share session-scoped tenants, reducing overhead significantly.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 02:22:43 -05:00
poduck
485f86086b feat: Unified FeaturesPermissionsEditor component for plan and business permissions
- Create reusable FeaturesPermissionsEditor component with support for both
  subscription plan editing and individual business permission overrides
- Add can_use_contracts field to Tenant model for per-business contracts toggle
- Update PlatformSettings.tsx to use unified component for plan permissions
- Update BusinessEditModal.tsx to use unified component for business permissions
- Update PlatformBusinessUpdate API interface with all permission fields
- Add contracts permission mapping to tenant sync task

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 01:37:04 -05:00
poduck
2f6ea82114 fix: Update djstripe signal imports and fix test mocking
- Use correct WEBHOOK_SIGNALS dict access for payment intent signals
- Simplify webhook tests by removing complex djstripe module mocking
- Fix TimezoneSerializerMixin tests to expect dynamic field addition
- Update TenantViewSet tests to mock exclude() chain for public schema

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 00:24:37 -05:00
poduck
507222316c fix: Add django.setup() to deploy script plugin seeding
The python -c one-liner wasn't initializing Django's app registry,
causing AppRegistryNotReady error when calling get_tenant_model().
2025-12-09 14:29:08 -05:00
poduck
c5c108c76f fix: Exclude public schema from platform businesses listing 2025-12-09 14:21:15 -05:00
poduck
90fa628cb5 feat: Add customer appointment details modal and ATM-style currency input
- Add appointment detail modal to CustomerDashboard with payment info display
  - Shows service, date/time, duration, status, and notes
  - Displays payment summary: service price, deposit paid, payment made, amount due
  - Print receipt functionality with secure DOM manipulation
  - Cancel appointment button for upcoming appointments

- Add CurrencyInput component for ATM-style price entry
  - Digits entered as cents, shift left as more digits added (e.g., "1234" → $12.34)
  - Robust input validation: handles keyboard, mobile, paste, drop, IME
  - Only allows integer digits (0-9)

- Update useAppointments hook to map payment fields from backend
  - Converts amounts from cents to dollars for display

- Update Services page to use CurrencyInput for price and deposit fields

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 12:46:10 -05:00
poduck
7f389830f8 docs: Update README with comprehensive setup and deployment guide
- Updated project structure to reflect current domain-based organization
- Added detailed local development setup with lvh.me explanation
- Added production deployment instructions (quick deploy and fresh server)
- Documented environment variables configuration
- Added architecture diagrams for multi-tenancy and request flow
- Included troubleshooting section for common issues
- Updated role hierarchy documentation
- Added configuration files reference table

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 10:53:02 -05:00
poduck
30909f3268 fix: Add WebSocket proxy configuration to nginx
The nginx.conf was missing a location block for /ws/ paths, causing
WebSocket connections to fall through to the SPA catch-all and return
index.html instead of proxying to Django/Daphne.

Added proper WebSocket proxy configuration with:
- HTTP/1.1 upgrade headers for WebSocket protocol
- 24-hour read timeout for long-lived connections
- Standard proxy headers for Django

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 10:46:37 -05:00
poduck
df45a6f5d7 fix: Use request.tenant for staff filtering in multi-tenant context
- UserTenantFilteredMixin now uses request.tenant (from django-tenants
  middleware) instead of request.user.tenant for filtering
- ResourceSerializer._get_valid_user uses request.tenant for validation
- Frontend useResources sends user_id instead of user field

This fixes 400 errors when creating staff resources because the tenant
context is now correctly derived from the subdomain being accessed.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 10:31:18 -05:00
poduck
156ad09232 fix: Use request.tenant instead of request.user.tenant for user validation
Platform-level users (owners) may have tenant=None on their user record
but still access tenant subdomains. The _get_valid_user method now uses
request.tenant (from django-tenants middleware) which is set based on
the subdomain being accessed, not the user's tenant FK.

This fixes 400 Bad Request errors when platform users try to create
resources with staff assignments.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 10:11:38 -05:00
poduck
8dc2248f1f feat: Add comprehensive test suite and misc improvements
- Add frontend unit tests with Vitest for components, hooks, pages, and utilities
- Add backend tests for webhooks, notifications, middleware, and edge cases
- Add ForgotPassword, NotFound, and ResetPassword pages
- Add migration for orphaned staff resources conversion
- Add coverage directory to gitignore (generated reports)
- Various bug fixes and improvements from previous work

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 02:36:46 -05:00
poduck
c220612214 Revert "chore: Add WIP test files to gitignore for clean deploy"
This reverts commit 33137289ef.
2025-12-08 02:35:50 -05:00
poduck
33137289ef chore: Add WIP test files to gitignore for clean deploy 2025-12-08 02:34:56 -05:00
poduck
b2be35bdfa chore: Add coverage to gitignore 2025-12-08 02:34:21 -05:00
poduck
a4b23e44b6 feat(messaging): Add broadcast messaging system for owners and managers
- Add BroadcastMessage and MessageRecipient models for sending messages to groups or individuals
- Add Messages page with compose form and sent messages list
- Support targeting by role (owners, managers, staff, customers) or individual users
- Add can_send_messages permission (owners always, managers by default with revocable permission)
- Add autofill search dropdown with infinite scroll for selecting individual recipients
- Add staff permission toggle for managers' messaging access
- Integrate Messages link in sidebar for users with permission

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 02:33:27 -05:00
poduck
67ce2c433c Merge remote-tracking branch 'origin/main' into refactor/organize-django-apps
# Conflicts:
#	smoothschedule/smoothschedule/scheduling/schedule/serializers.py
2025-12-07 21:12:09 -05:00
poduck
1391374d45 test: Add comprehensive unit test coverage for all domains
This commit adds extensive unit tests across all Django app domains,
increasing test coverage significantly. All tests use mocks to avoid
database dependencies and follow the testing pyramid approach.

Domains covered:
- identity/core: mixins, models, permissions, OAuth, quota service
- identity/users: models, API views, MFA, services
- commerce/tickets: signals, serializers, views, email notifications
- commerce/payments: services, views
- communication/credits: models, tasks, views
- communication/mobile: serializers, views
- communication/notifications: models, serializers, views
- platform/admin: serializers, views
- platform/api: models, views, token security
- scheduling/schedule: models, serializers, services, signals, views
- scheduling/contracts: serializers, views
- scheduling/analytics: views

Key improvements:
- Fixed 54 previously failing tests in signals and serializers
- All tests use proper mocking patterns (no @pytest.mark.django_db)
- Added test factories for creating mock objects
- Updated conftest.py with shared fixtures

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 21:10:26 -05:00
poduck
b9e90e6f46 docs: Add comprehensive testing guidelines to CLAUDE.md
Add testing documentation emphasizing mocked unit tests over slow
database-hitting integration tests due to django-tenants overhead.

Guidelines include:
- Testing pyramid philosophy (prefer unit tests)
- Unit test examples with mocks
- Serializer and ViewSet testing patterns
- When to use integration tests (sparingly)
- Repository pattern for testable code
- Dependency injection examples
- Test file structure conventions
- Commands for running tests with coverage

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 19:12:01 -05:00
poduck
1af79cc019 refactor: Reorganize tests into tests/ directories
Follow cookiecutter-django convention by placing tests in dedicated
tests/ directories within each app instead of single tests.py files.

Changes:
- Created tests/ directories with __init__.py for all 13 apps
- Moved analytics/tests.py → analytics/tests/test_views.py
- Moved schedule/test_export.py → schedule/tests/test_export.py
- Moved platform/api/tests_token_security.py → platform/api/tests/test_token_security.py
- Deleted empty placeholder tests.py files

All apps now have a tests/ directory ready for proper test organization.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 18:31:00 -05:00
poduck
156cc2676d refactor: Reorganize Django apps into domain-based structure
Restructured 13 Django apps from flat/mixed organization into 5 logical
domain packages following cookiecutter-django conventions:

- identity/: core (tenant/domain models, middleware, mixins), users
- scheduling/: schedule, contracts, analytics
- communication/: notifications, credits, mobile, messaging
- commerce/: payments, tickets
- platform/: admin, api

Key changes:
- Moved all apps to smoothschedule/smoothschedule/{domain}/{app}/
- Updated all import paths across the codebase
- Updated settings (base.py, multitenancy.py, test.py)
- Updated URL configuration in config/urls.py
- Updated middleware and permission paths
- Preserved app_label in AppConfig for migration compatibility
- Updated CLAUDE.md documentation with new structure

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 18:24:50 -05:00
869 changed files with 203221 additions and 6828 deletions

View File

@@ -0,0 +1,12 @@
{
"permissions": {
"allow": [
"Bash(find:*)",
"Bash(grep:*)",
"Bash(cat:*)",
"WebSearch"
],
"deny": [],
"ask": []
}
}

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
# Test coverage reports (generated)
frontend/coverage/

215
CLAUDE.md
View File

@@ -21,6 +21,169 @@
Note: `lvh.me` resolves to `127.0.0.1` - required for subdomain cookies to work.
## CRITICAL: Test-Driven Development (TDD) Required
**All code changes MUST follow TDD.** This is non-negotiable.
### TDD Workflow
1. **Write tests FIRST** before writing any implementation code
2. **Run tests** to verify they fail (red)
3. **Write minimal code** to make tests pass (green)
4. **Refactor** while keeping tests green
5. **Repeat** for each new feature or bug fix
### Coverage Requirements
| Target | Minimum | Goal |
|--------|---------|------|
| Backend (Django) | **80%** | 100% |
| Frontend (React) | **80%** | 100% |
### Running Tests with Coverage
**Backend (Django):**
```bash
cd /home/poduck/Desktop/smoothschedule2/smoothschedule
# Run all tests with coverage
docker compose -f docker-compose.local.yml exec django pytest --cov --cov-report=term-missing
# Run tests for a specific app
docker compose -f docker-compose.local.yml exec django pytest smoothschedule/scheduling/schedule/tests/ --cov=smoothschedule/scheduling/schedule
# Run a single test file
docker compose -f docker-compose.local.yml exec django pytest smoothschedule/path/to/test_file.py -v
# Run tests matching a pattern
docker compose -f docker-compose.local.yml exec django pytest -k "test_create_resource" -v
```
**Frontend (React):**
```bash
cd /home/poduck/Desktop/smoothschedule2/frontend
# Run all tests with coverage
npm test -- --coverage
# Run tests in watch mode during development
npm test
# Run a single test file
npm test -- src/hooks/__tests__/useResources.test.ts
# Run tests matching a pattern
npm test -- -t "should create resource"
```
### Test File Organization
**Backend:**
```
smoothschedule/smoothschedule/{domain}/{app}/
├── models.py
├── views.py
├── serializers.py
└── tests/
├── __init__.py
├── test_models.py # Model unit tests
├── test_serializers.py # Serializer tests
├── test_views.py # API endpoint tests
└── factories.py # Test factories (optional)
```
**Frontend:**
```
frontend/src/
├── hooks/
│ ├── useResources.ts
│ └── __tests__/
│ └── useResources.test.ts
├── components/
│ ├── MyComponent.tsx
│ └── __tests__/
│ └── MyComponent.test.tsx
└── pages/
├── MyPage.tsx
└── __tests__/
└── MyPage.test.tsx
```
### What to Test
**Backend:**
- Model methods and properties
- Model validation (clean methods)
- Serializer validation
- API endpoints (all HTTP methods)
- Permission classes
- Custom querysets and managers
- Signals
- Celery tasks
- Utility functions
**Frontend:**
- Custom hooks (state changes, API calls)
- Component rendering
- User interactions (clicks, form submissions)
- Conditional rendering
- Error states
- Loading states
- API client functions
### TDD Example - Adding a New Feature
**Step 1: Write the test first**
```python
# Backend: test_views.py
def test_create_resource_with_schedule(self, api_client, tenant):
"""New feature: resources can have a default schedule."""
data = {
"name": "Test Resource",
"type": "STAFF",
"default_schedule": {
"monday": {"start": "09:00", "end": "17:00"},
"tuesday": {"start": "09:00", "end": "17:00"},
}
}
response = api_client.post("/api/resources/", data, format="json")
assert response.status_code == 201
assert response.data["default_schedule"]["monday"]["start"] == "09:00"
```
```typescript
// Frontend: useResources.test.ts
it('should create resource with schedule', async () => {
const { result } = renderHook(() => useCreateResource());
await act(async () => {
await result.current.mutateAsync({
name: 'Test Resource',
type: 'STAFF',
defaultSchedule: { monday: { start: '09:00', end: '17:00' } }
});
});
expect(mockApiClient.post).toHaveBeenCalledWith('/resources/', expect.objectContaining({
default_schedule: expect.any(Object)
}));
});
```
**Step 2: Run tests - they should FAIL**
**Step 3: Write minimal implementation to make tests pass**
**Step 4: Refactor if needed while keeping tests green**
### Pre-Commit Checklist
Before committing ANY code:
1. [ ] Tests written BEFORE implementation
2. [ ] All tests pass
3. [ ] Coverage meets minimum threshold (80%)
4. [ ] No skipped or disabled tests without justification
## CRITICAL: Backend Runs in Docker
**NEVER run Django commands directly.** Always use Docker Compose:
@@ -178,25 +341,51 @@ toUTC(date) // For API requests
formatForDisplay(utcString, businessTimezone) // For displaying
```
## Key Django Apps
## Django App Organization (Domain-Based)
Apps are organized into domain packages under `smoothschedule/smoothschedule/`:
### Identity Domain
| App | Location | Purpose |
|-----|----------|---------|
| `schedule` | `smoothschedule/smoothschedule/schedule/` | Resources, Events, Services |
| `users` | `smoothschedule/smoothschedule/users/` | Authentication, User model |
| `tenants` | `smoothschedule/smoothschedule/tenants/` | Multi-tenancy (Business model) |
| `core` | `smoothschedule/core/` | Shared mixins, permissions, middleware |
| `payments` | `smoothschedule/payments/` | Stripe integration, subscriptions |
| `platform_admin` | `smoothschedule/platform_admin/` | Platform administration |
| `core` | `identity/core/` | Tenant, Domain, PermissionGrant, middleware, mixins |
| `users` | `identity/users/` | User model, authentication, MFA |
### Scheduling Domain
| App | Location | Purpose |
|-----|----------|---------|
| `schedule` | `scheduling/schedule/` | Resources, Events, Services, Participants |
| `contracts` | `scheduling/contracts/` | Contract/e-signature system |
| `analytics` | `scheduling/analytics/` | Business analytics and reporting |
### Communication Domain
| App | Location | Purpose |
|-----|----------|---------|
| `notifications` | `communication/notifications/` | Notification system |
| `credits` | `communication/credits/` | SMS/calling credits |
| `mobile` | `communication/mobile/` | Field employee mobile app |
| `messaging` | `communication/messaging/` | Email templates and messaging |
### Commerce Domain
| App | Location | Purpose |
|-----|----------|---------|
| `payments` | `commerce/payments/` | Stripe Connect payments bridge |
| `tickets` | `commerce/tickets/` | Support ticket system |
### Platform Domain
| App | Location | Purpose |
|-----|----------|---------|
| `admin` | `platform/admin/` | Platform administration, subscriptions |
| `api` | `platform/api/` | Public API v1 for third-party integrations |
## Core Mixins & Base Classes
Located in `smoothschedule/core/mixins.py`. Use these to avoid code duplication.
Located in `smoothschedule/smoothschedule/identity/core/mixins.py`. Use these to avoid code duplication.
### Permission Classes
```python
from core.mixins import DenyStaffWritePermission, DenyStaffAllAccessPermission, DenyStaffListPermission
from smoothschedule.identity.core.mixins import DenyStaffWritePermission, DenyStaffAllAccessPermission, DenyStaffListPermission
class MyViewSet(ModelViewSet):
# Block write operations for staff (GET allowed)
@@ -236,7 +425,7 @@ docker compose -f docker-compose.local.yml exec django python manage.py shell
```
```python
from smoothschedule.users.models import User
from smoothschedule.identity.users.models import User
# Find the staff member
staff = User.objects.get(email='john@example.com')
@@ -263,7 +452,7 @@ Then grant via: `staff.permissions['can_manage_equipment'] = True`
### QuerySet Mixins
```python
from core.mixins import TenantFilteredQuerySetMixin, UserTenantFilteredMixin
from smoothschedule.identity.core.mixins import TenantFilteredQuerySetMixin, UserTenantFilteredMixin
# For tenant-scoped models (automatic django-tenants filtering)
class ResourceViewSet(TenantFilteredQuerySetMixin, ModelViewSet):
@@ -282,7 +471,7 @@ class CustomerViewSet(UserTenantFilteredMixin, ModelViewSet):
### Feature Permission Mixins
```python
from core.mixins import PluginFeatureRequiredMixin, TaskFeatureRequiredMixin
from smoothschedule.identity.core.mixins import PluginFeatureRequiredMixin, TaskFeatureRequiredMixin
# Checks can_use_plugins feature on list/retrieve/create
class PluginViewSet(PluginFeatureRequiredMixin, ModelViewSet):
@@ -297,7 +486,7 @@ class ScheduledTaskViewSet(TaskFeatureRequiredMixin, TenantFilteredQuerySetMixin
```python
from rest_framework.views import APIView
from core.mixins import TenantAPIView, TenantRequiredAPIView
from smoothschedule.identity.core.mixins import TenantAPIView, TenantRequiredAPIView
# Optional tenant - use self.get_tenant()
class MyView(TenantAPIView, APIView):

383
PLAN_APP_REORGANIZATION.md Normal file
View File

@@ -0,0 +1,383 @@
# Django App Reorganization Plan - Option C (Domain-Based)
## Overview
Reorganize Django apps from their current scattered locations into a clean domain-based structure within `smoothschedule/smoothschedule/`.
**Branch:** `refactor/organize-django-apps`
**Risk Level:** Medium-High (migration history must be preserved)
**Estimated Parallel Agents:** 6-8
---
## Current State Analysis
### Current App Locations (Inconsistent)
| App | Current Location | Registered As |
|-----|-----------------|---------------|
| core | `smoothschedule/core/` | `"core"` |
| schedule | `smoothschedule/schedule/` | `"schedule"` |
| payments | `smoothschedule/payments/` | `"payments"` |
| platform_admin | `smoothschedule/platform_admin/` | `"platform_admin.apps.PlatformAdminConfig"` |
| analytics | `smoothschedule/analytics/` | `"analytics"` |
| notifications | `smoothschedule/notifications/` | `"notifications"` |
| tickets | `smoothschedule/tickets/` | `"tickets"` |
| contracts | `smoothschedule/contracts/` | **NOT REGISTERED** |
| communication | `smoothschedule/communication/` | **NOT REGISTERED** |
| users | `smoothschedule/smoothschedule/users/` | `"smoothschedule.users"` |
| comms_credits | `smoothschedule/smoothschedule/comms_credits/` | `"smoothschedule.comms_credits"` |
| field_mobile | `smoothschedule/smoothschedule/field_mobile/` | `"smoothschedule.field_mobile"` |
| public_api | `smoothschedule/smoothschedule/public_api/` | `"smoothschedule.public_api"` |
### Migration Counts by App
| App | Migrations | Complexity |
|-----|------------|------------|
| core | 22 | High (Tenant model) |
| schedule | 30 | High (main business logic) |
| payments | 1 | Low |
| platform_admin | 12 | Medium |
| users | 10 | Medium |
| tickets | 13 | Medium |
| contracts | 1 | Low |
| notifications | 1 | Low |
| comms_credits | 2 | Low |
| field_mobile | 1 | Low |
| public_api | 3 | Low |
| analytics | 0 | None |
| communication | 1 | Low |
---
## Target Structure (Option C - Domain-Based)
```
smoothschedule/smoothschedule/
├── __init__.py
├── identity/ # User & Tenant Management
│ ├── __init__.py
│ ├── core/ # Multi-tenancy, permissions, OAuth
│ │ └── (moved from smoothschedule/core/)
│ └── users/ # User model, auth, invitations
│ └── (keep at current location, just move parent)
├── scheduling/ # Core Business Logic
│ ├── __init__.py
│ ├── schedule/ # Resources, Events, Services, Plugins
│ │ └── (moved from smoothschedule/schedule/)
│ ├── contracts/ # E-signatures, legal documents
│ │ └── (moved from smoothschedule/contracts/)
│ └── analytics/ # Reporting, dashboards
│ └── (moved from smoothschedule/analytics/)
├── communication/ # Messaging & Notifications
│ ├── __init__.py
│ ├── notifications/ # In-app notifications
│ │ └── (moved from smoothschedule/notifications/)
│ ├── credits/ # SMS/voice credits (renamed from comms_credits)
│ │ └── (moved from smoothschedule/smoothschedule/comms_credits/)
│ ├── mobile/ # Field employee app (renamed from field_mobile)
│ │ └── (moved from smoothschedule/smoothschedule/field_mobile/)
│ └── messaging/ # Twilio conversations (renamed from communication)
│ └── (moved from smoothschedule/communication/)
├── commerce/ # Payments & Support
│ ├── __init__.py
│ ├── payments/ # Stripe Connect, transactions
│ │ └── (moved from smoothschedule/payments/)
│ └── tickets/ # Support tickets, email integration
│ └── (moved from smoothschedule/tickets/)
└── platform/ # Platform Administration
├── __init__.py
├── admin/ # Platform settings, subscriptions (renamed)
│ └── (moved from smoothschedule/platform_admin/)
└── api/ # Public API v1 (renamed from public_api)
└── (moved from smoothschedule/smoothschedule/public_api/)
```
---
## Critical Constraints
### 1. Migration History Preservation
Django migrations contain the app label in their `dependencies` and `app_label` references. We MUST:
- **Keep `app_label` unchanged** in each app's `Meta` class
- Update `AppConfig.name` to the new dotted path
- Django will use the `app_label` (not the path) for migration tracking
### 2. Foreign Key String References
Models use string references like `'users.User'` and `'core.Tenant'`. These reference `app_label`, not the module path, so they remain valid.
### 3. Import Path Updates
All imports across the codebase must be updated:
- `from core.models import Tenant``from smoothschedule.identity.core.models import Tenant`
- `from schedule.models import Event``from smoothschedule.scheduling.schedule.models import Event`
### 4. URL Configuration
`config/urls.py` imports views directly - all import paths must be updated.
### 5. Settings Files
- `config/settings/base.py` - `LOCAL_APPS`
- `config/settings/multitenancy.py` - `SHARED_APPS`, `TENANT_APPS`
---
## Implementation Phases
### Phase 1: Preparation (Serial)
**Agent 1: Setup & Verification**
1. Create all domain package directories with `__init__.py` files
2. Verify Docker is running and database is accessible
3. Run existing tests to establish baseline
4. Create backup of current migration state
```bash
# Create domain packages
mkdir -p smoothschedule/smoothschedule/identity
mkdir -p smoothschedule/smoothschedule/scheduling
mkdir -p smoothschedule/smoothschedule/communication
mkdir -p smoothschedule/smoothschedule/commerce
mkdir -p smoothschedule/smoothschedule/platform
# Create __init__.py files
touch smoothschedule/smoothschedule/identity/__init__.py
touch smoothschedule/smoothschedule/scheduling/__init__.py
touch smoothschedule/smoothschedule/communication/__init__.py
touch smoothschedule/smoothschedule/commerce/__init__.py
touch smoothschedule/smoothschedule/platform/__init__.py
```
---
### Phase 2: Move Apps (Parallel - 5 Agents)
Each agent handles one domain. For each app move:
1. **Move directory** to new location
2. **Update `apps.py`** - change `name` to new dotted path, keep `label` same
3. **Update internal imports** within the app
4. **Add explicit `app_label`** to all model Meta classes (if not present)
#### Agent 2: Identity Domain
Move and update:
- `smoothschedule/core/``smoothschedule/smoothschedule/identity/core/`
- `smoothschedule/smoothschedule/users/``smoothschedule/smoothschedule/identity/users/`
**apps.py changes:**
```python
# identity/core/apps.py
class CoreConfig(AppConfig):
name = "smoothschedule.identity.core" # NEW
label = "core" # KEEP SAME
verbose_name = "Core"
# identity/users/apps.py
class UsersConfig(AppConfig):
name = "smoothschedule.identity.users" # NEW
label = "users" # KEEP SAME
```
#### Agent 3: Scheduling Domain
Move and update:
- `smoothschedule/schedule/``smoothschedule/smoothschedule/scheduling/schedule/`
- `smoothschedule/contracts/``smoothschedule/smoothschedule/scheduling/contracts/`
- `smoothschedule/analytics/``smoothschedule/smoothschedule/scheduling/analytics/`
#### Agent 4: Communication Domain
Move and update:
- `smoothschedule/notifications/``smoothschedule/smoothschedule/communication/notifications/`
- `smoothschedule/smoothschedule/comms_credits/``smoothschedule/smoothschedule/communication/credits/`
- `smoothschedule/smoothschedule/field_mobile/``smoothschedule/smoothschedule/communication/mobile/`
- `smoothschedule/communication/``smoothschedule/smoothschedule/communication/messaging/`
**Note:** Rename apps for clarity:
- `comms_credits` label stays same, path changes
- `field_mobile` label stays same, path changes
- `communication` label stays same, path changes
#### Agent 5: Commerce Domain
Move and update:
- `smoothschedule/payments/``smoothschedule/smoothschedule/commerce/payments/`
- `smoothschedule/tickets/``smoothschedule/smoothschedule/commerce/tickets/`
#### Agent 6: Platform Domain
Move and update:
- `smoothschedule/platform_admin/``smoothschedule/smoothschedule/platform/admin/`
- `smoothschedule/smoothschedule/public_api/``smoothschedule/smoothschedule/platform/api/`
---
### Phase 3: Update Settings (Serial)
**Agent 7: Settings Configuration**
Update `config/settings/base.py`:
```python
LOCAL_APPS = [
# Identity
"smoothschedule.identity.users",
"smoothschedule.identity.core",
# Scheduling
"smoothschedule.scheduling.schedule",
"smoothschedule.scheduling.contracts",
"smoothschedule.scheduling.analytics",
# Communication
"smoothschedule.communication.notifications",
"smoothschedule.communication.credits",
"smoothschedule.communication.mobile",
"smoothschedule.communication.messaging",
# Commerce
"smoothschedule.commerce.payments",
"smoothschedule.commerce.tickets",
# Platform
"smoothschedule.platform.admin",
"smoothschedule.platform.api",
]
```
Update `config/settings/multitenancy.py`:
```python
SHARED_APPS = [
'django_tenants',
'smoothschedule.identity.core',
'smoothschedule.platform.admin',
# ... rest of shared apps with new paths
]
TENANT_APPS = [
'django.contrib.contenttypes',
'smoothschedule.scheduling.schedule',
'smoothschedule.commerce.payments',
'smoothschedule.scheduling.contracts',
]
```
---
### Phase 4: Update All Import Paths (Parallel - Multiple Agents)
**This is the largest task.** Each agent handles specific import patterns:
#### Agent 8: Core Imports
Find and replace across entire codebase:
- `from core.models import``from smoothschedule.identity.core.models import`
- `from core.``from smoothschedule.identity.core.`
- `import core``import smoothschedule.identity.core as core`
#### Agent 9: Schedule Imports
- `from schedule.models import``from smoothschedule.scheduling.schedule.models import`
- `from schedule.``from smoothschedule.scheduling.schedule.`
#### Agent 10: Users/Auth Imports
- `from smoothschedule.users.``from smoothschedule.identity.users.`
- `from users.``from smoothschedule.identity.users.`
#### Agent 11: Other App Imports
Handle remaining apps:
- payments, tickets, notifications, contracts, analytics
- platform_admin, public_api, comms_credits, field_mobile, communication
---
### Phase 5: URL Configuration Updates (Serial)
**Agent 12: URL Updates**
Update `config/urls.py` with new import paths:
```python
# Old
from schedule.views import ResourceViewSet, EventViewSet
from core.api_views import business_current
# New
from smoothschedule.scheduling.schedule.views import ResourceViewSet, EventViewSet
from smoothschedule.identity.core.api_views import business_current
```
---
### Phase 6: Cleanup & Verification (Serial)
**Agent 13: Cleanup**
1. Remove old empty directories at top level
2. Remove deprecated `smoothschedule/smoothschedule/schedule/` directory
3. Update `CLAUDE.md` documentation
4. Update any remaining references
**Agent 14: Verification**
1. Run `docker compose exec django python manage.py check`
2. Run `docker compose exec django python manage.py makemigrations --check`
3. Run `docker compose exec django python manage.py migrate --check`
4. Run test suite
5. Manual smoke test of key endpoints
---
## App Label Mapping Reference
| Old Import Path | New Import Path | app_label (unchanged) |
|----------------|-----------------|----------------------|
| `core` | `smoothschedule.identity.core` | `core` |
| `smoothschedule.users` | `smoothschedule.identity.users` | `users` |
| `schedule` | `smoothschedule.scheduling.schedule` | `schedule` |
| `contracts` | `smoothschedule.scheduling.contracts` | `contracts` |
| `analytics` | `smoothschedule.scheduling.analytics` | `analytics` |
| `notifications` | `smoothschedule.communication.notifications` | `notifications` |
| `smoothschedule.comms_credits` | `smoothschedule.communication.credits` | `comms_credits` |
| `smoothschedule.field_mobile` | `smoothschedule.communication.mobile` | `field_mobile` |
| `communication` | `smoothschedule.communication.messaging` | `communication` |
| `payments` | `smoothschedule.commerce.payments` | `payments` |
| `tickets` | `smoothschedule.commerce.tickets` | `tickets` |
| `platform_admin` | `smoothschedule.platform.admin` | `platform_admin` |
| `smoothschedule.public_api` | `smoothschedule.platform.api` | `public_api` |
---
## Rollback Plan
If issues are encountered:
1. **Git Reset:** `git checkout main` and delete branch
2. **Database:** No migration changes, database remains intact
3. **Docker:** Rebuild containers if needed
---
## Success Criteria
- [ ] All apps moved to domain-based structure
- [ ] `python manage.py check` passes
- [ ] `python manage.py makemigrations --check` shows no changes
- [ ] All existing tests pass
- [ ] Frontend can communicate with API
- [ ] Mobile app can communicate with API
- [ ] CLAUDE.md updated with new structure
---
## Execution Order
```
Phase 1 (Serial): Agent 1 - Setup
Phase 2 (Parallel): Agents 2-6 - Move apps by domain
Phase 3 (Serial): Agent 7 - Update settings
Phase 4 (Parallel): Agents 8-11 - Update imports
Phase 5 (Serial): Agent 12 - URL updates
Phase 6 (Serial): Agents 13-14 - Cleanup & verify
```
**Total Agents:** 14 (8 can run in parallel at peak)

655
README.md
View File

@@ -1,257 +1,470 @@
# SmoothSchedule - Multi-Tenant Scheduling Platform
A production-ready multi-tenant SaaS platform for resource scheduling and orchestration.
A production-ready multi-tenant SaaS platform for resource scheduling, appointments, and business management.
## 🎯 Features
## Features
- **Multi-Tenancy**: PostgreSQL schema-per-tenant using django-tenants
- **8-Tier Role Hierarchy**: From SUPERUSER to CUSTOMER with strict permissions
- **Modern Stack**: Django 5.2 + React 18 + Vite
- **Docker Ready**: Complete production & development Docker Compose setup
- **Cloud Storage**: DigitalOcean Spaces (S3-compatible) for static/media files
- **Auto SSL**: Let's Encrypt certificates via Traefik reverse proxy
- **Task Queue**: Celery + Redis for background jobs
- **Real-time**: Django Channels + WebSockets support
-**Production Ready**: Fully configured for deployment
- **Multi-Tenancy**: PostgreSQL schema-per-tenant using django-tenants
- **8-Tier Role Hierarchy**: SUPERUSER, PLATFORM_MANAGER, PLATFORM_SALES, PLATFORM_SUPPORT, TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF, CUSTOMER
- **Modern Stack**: Django 5.2 + React 19 + TypeScript + Vite
- **Real-time Updates**: Django Channels + WebSockets
- **Background Tasks**: Celery + Redis
- **Auto SSL**: Let's Encrypt certificates via Traefik
- **Cloud Storage**: DigitalOcean Spaces (S3-compatible)
- **Docker Ready**: Complete Docker Compose setup for dev and production
## 📚 Documentation
## Project Structure
- **[PRODUCTION_DEPLOYMENT.md](PRODUCTION_DEPLOYMENT.md)** - **Manual step-by-step production deployment** (start here for fresh deployments)
- **[QUICK-REFERENCE.md](QUICK-REFERENCE.md)** - Common commands and quick start
- **[PRODUCTION-READY.md](PRODUCTION-READY.md)** - Production deployment status
- **[DEPLOYMENT.md](DEPLOYMENT.md)** - Comprehensive deployment guide
- **[CLAUDE.md](CLAUDE.md)** - Development guide and architecture
```
smoothschedule2/
├── frontend/ # React + Vite + TypeScript
│ ├── src/
│ │ ├── api/ # API client and hooks
│ │ ├── components/ # Reusable UI components
│ │ ├── hooks/ # React Query hooks
│ │ ├── pages/ # Page components
│ │ └── types.ts # TypeScript interfaces
│ ├── nginx.conf # Production nginx config
│ └── Dockerfile.prod # Production frontend container
├── smoothschedule/ # Django backend
│ ├── config/ # Django settings
│ │ └── settings/
│ │ ├── base.py # Base settings
│ │ ├── local.py # Local development
│ │ └── production.py # Production settings
│ ├── smoothschedule/ # Django apps (domain-based)
│ │ ├── identity/ # Users, tenants, authentication
│ │ │ ├── core/ # Tenant, Domain, middleware
│ │ │ └── users/ # User model, MFA, auth
│ │ ├── scheduling/ # Core scheduling
│ │ │ ├── schedule/ # Resources, Events, Services
│ │ │ ├── contracts/ # E-signatures
│ │ │ └── analytics/ # Business analytics
│ │ ├── communication/ # Notifications, SMS, mobile
│ │ ├── commerce/ # Payments, tickets
│ │ └── platform/ # Admin, public API
│ ├── docker-compose.local.yml
│ └── docker-compose.production.yml
├── deploy.sh # Automated deployment script
└── CLAUDE.md # Development guide
```
## 🚀 Quick Start
---
### Local Development
## Local Development Setup
### Prerequisites
- **Docker** and **Docker Compose** (for backend)
- **Node.js 22+** and **npm** (for frontend)
- **Git**
### Step 1: Clone the Repository
```bash
# Start backend (Django in Docker)
git clone https://github.com/your-repo/smoothschedule.git
cd smoothschedule
```
### Step 2: Start the Backend (Django in Docker)
```bash
cd smoothschedule
# Start all backend services
docker compose -f docker-compose.local.yml up -d
# Start frontend (React with Vite)
cd ../frontend
npm install
npm run dev
# Wait for services to initialize (first time takes longer)
sleep 30
# Access the app
# Frontend: http://platform.lvh.me:5173
# Backend API: http://lvh.me:8000/api
# Run database migrations
docker compose -f docker-compose.local.yml exec django python manage.py migrate
# Create a superuser (optional)
docker compose -f docker-compose.local.yml exec django python manage.py createsuperuser
```
See [CLAUDE.md](CLAUDE.md) for detailed development instructions.
### Production Deployment
For **fresh deployments or complete reset**, follow [PRODUCTION_DEPLOYMENT.md](PRODUCTION_DEPLOYMENT.md) for manual step-by-step instructions.
For **routine updates**, use the automated script:
### Step 3: Start the Frontend (React with Vite)
```bash
# Deploy to production server (code changes only)
./deploy.sh poduck@smoothschedule.com
cd ../frontend
# Install dependencies
npm install
# Start development server
npm run dev
```
See [PRODUCTION-READY.md](PRODUCTION-READY.md) for deployment checklist and [DEPLOYMENT.md](DEPLOYMENT.md) for detailed steps.
### Step 4: Access the Application
## 🏗️ Architecture
The application uses `lvh.me` (resolves to 127.0.0.1) for subdomain-based multi-tenancy:
| URL | Purpose |
|-----|---------|
| http://platform.lvh.me:5173 | Platform admin dashboard |
| http://demo.lvh.me:5173 | Demo tenant (if created) |
| http://lvh.me:8000/api/ | Backend API |
| http://lvh.me:8000/admin/ | Django admin |
**Why `lvh.me`?** Browsers don't allow cookies with `domain=.localhost`, but `lvh.me` resolves to 127.0.0.1 and allows proper cookie sharing across subdomains.
### Local Development Commands
```bash
# Backend commands (always use docker compose)
cd smoothschedule
# View logs
docker compose -f docker-compose.local.yml logs -f django
# Run migrations
docker compose -f docker-compose.local.yml exec django python manage.py migrate
# Django shell
docker compose -f docker-compose.local.yml exec django python manage.py shell
# Run tests
docker compose -f docker-compose.local.yml exec django pytest
# Stop all services
docker compose -f docker-compose.local.yml down
# Frontend commands
cd frontend
# Run tests
npm test
# Type checking
npm run typecheck
# Lint
npm run lint
```
### Creating a Test Tenant
```bash
cd smoothschedule
docker compose -f docker-compose.local.yml exec django python manage.py shell
```
```python
from smoothschedule.identity.core.models import Tenant, Domain
# Create tenant
tenant = Tenant.objects.create(
name="Demo Business",
schema_name="demo",
)
# Create domain
Domain.objects.create(
domain="demo.lvh.me",
tenant=tenant,
is_primary=True,
)
print(f"Created tenant: {tenant.name}")
print(f"Access at: http://demo.lvh.me:5173")
```
---
## Production Deployment
### Prerequisites
- Ubuntu/Debian server with Docker and Docker Compose
- Domain name with DNS configured:
- `A` record: `yourdomain.com` → Server IP
- `A` record: `*.yourdomain.com` → Server IP (wildcard)
- SSH access to the server
### Quick Deploy (Existing Server)
For routine updates to an existing production server:
```bash
# From your local machine
./deploy.sh user@yourdomain.com
# Or deploy specific services
./deploy.sh user@yourdomain.com nginx
./deploy.sh user@yourdomain.com django
```
### Fresh Server Deployment
#### Step 1: Server Setup
SSH into your server and install Docker:
```bash
ssh user@yourdomain.com
# Install Docker
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER
# Logout and login for group changes
exit
ssh user@yourdomain.com
```
#### Step 2: Clone Repository
```bash
cd ~
git clone https://github.com/your-repo/smoothschedule.git smoothschedule
cd smoothschedule
```
#### Step 3: Configure Environment Variables
Create production environment files:
```bash
mkdir -p smoothschedule/.envs/.production
# Django configuration
cat > smoothschedule/.envs/.production/.django << 'EOF'
DJANGO_SECRET_KEY=your-random-secret-key-here
DJANGO_DEBUG=False
DJANGO_ALLOWED_HOSTS=yourdomain.com,*.yourdomain.com
DJANGO_ADMIN_URL=your-secret-admin-path/
FRONTEND_URL=https://platform.yourdomain.com
PLATFORM_BASE_URL=https://platform.yourdomain.com
REDIS_URL=redis://redis:6379/0
CELERY_BROKER_URL=redis://redis:6379/0
# DigitalOcean Spaces (or S3)
DJANGO_AWS_ACCESS_KEY_ID=your-access-key
DJANGO_AWS_SECRET_ACCESS_KEY=your-secret-key
DJANGO_AWS_STORAGE_BUCKET_NAME=your-bucket
DJANGO_AWS_S3_ENDPOINT_URL=https://nyc3.digitaloceanspaces.com
DJANGO_AWS_S3_REGION_NAME=nyc3
# SSL
DJANGO_SECURE_SSL_REDIRECT=True
DJANGO_SESSION_COOKIE_SECURE=True
DJANGO_CSRF_COOKIE_SECURE=True
# Cloudflare (for wildcard SSL)
CF_DNS_API_TOKEN=your-cloudflare-api-token
EOF
# PostgreSQL configuration
cat > smoothschedule/.envs/.production/.postgres << 'EOF'
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_DB=smoothschedule
POSTGRES_USER=smoothschedule_user
POSTGRES_PASSWORD=your-secure-database-password
EOF
```
#### Step 4: Build and Start
```bash
cd ~/smoothschedule/smoothschedule
# Build all images
docker compose -f docker-compose.production.yml build
# Start services
docker compose -f docker-compose.production.yml up -d
# Wait for startup
sleep 30
# Run migrations
docker compose -f docker-compose.production.yml exec django python manage.py migrate
# Collect static files
docker compose -f docker-compose.production.yml exec django python manage.py collectstatic --noinput
# Create superuser
docker compose -f docker-compose.production.yml exec django python manage.py createsuperuser
```
#### Step 5: Verify Deployment
```bash
# Check all containers are running
docker compose -f docker-compose.production.yml ps
# View logs
docker compose -f docker-compose.production.yml logs -f
# Test endpoints
curl https://yourdomain.com/api/health/
```
### Production URLs
| URL | Purpose |
|-----|---------|
| https://yourdomain.com | Marketing site |
| https://platform.yourdomain.com | Platform admin |
| https://*.yourdomain.com | Tenant subdomains |
| https://api.yourdomain.com | API (if configured) |
| https://yourdomain.com:5555 | Flower (Celery monitoring) |
### Production Management Commands
```bash
ssh user@yourdomain.com
cd ~/smoothschedule/smoothschedule
# View logs
docker compose -f docker-compose.production.yml logs -f django
# Restart services
docker compose -f docker-compose.production.yml restart
# Run migrations
docker compose -f docker-compose.production.yml exec django python manage.py migrate
# Django shell
docker compose -f docker-compose.production.yml exec django python manage.py shell
# Database backup
docker compose -f docker-compose.production.yml exec postgres pg_dump -U smoothschedule_user smoothschedule > backup.sql
```
---
## Architecture
### Multi-Tenancy Model
```
┌─────────────────────────────────────────┐
│ PostgreSQL Database │
├─────────────────────────────────────────┤
public (shared schema) │
├─ Tenants │
├─ Domains │
│ ├─ Users │
└─ PermissionGrants │
├─────────────────────────────────────────┤
tenant_demo (schema for Demo Company) │
├─ Appointments │
│ ├─ Resources │
└─ Customers │
├─────────────────────────────────────────┤
│ tenant_acme (schema for Acme Corp) │
│ ├─ Appointments │
│ ├─ Resources │
│ └─ Customers │
└─────────────────────────────────────────┘
PostgreSQL Database
├── public (shared schema)
│ ├── Tenants
├── Domains
├── Users
└── PermissionGrants
├── demo (tenant schema)
├── Resources
│ ├── Events
├── Services
└── Customers
└── acme (tenant schema)
├── Resources
├── Events
└── ...
```
### Role Hierarchy
| Role | Level | Access Scope |
|---------------------|----------|---------------------------|
| SUPERUSER | Platform | All tenants (god mode) |
| PLATFORM_MANAGER | Platform | All tenants |
| PLATFORM_SALES | Platform | Demo accounts only |
| PLATFORM_SUPPORT | Platform | Tenant users |
| TENANT_OWNER | Tenant | Own tenant (full access) |
| TENANT_MANAGER | Tenant | Own tenant |
| TENANT_STAFF | Tenant | Own tenant (limited) |
| CUSTOMER | Tenant | Own data only |
| Role | Level | Access |
|------|-------|--------|
| SUPERUSER | Platform | All tenants (god mode) |
| PLATFORM_MANAGER | Platform | All tenants |
| PLATFORM_SALES | Platform | Demo accounts only |
| PLATFORM_SUPPORT | Platform | Can masquerade as tenant users |
| TENANT_OWNER | Tenant | Full tenant access |
| TENANT_MANAGER | Tenant | Most tenant features |
| TENANT_STAFF | Tenant | Limited tenant access |
| CUSTOMER | Tenant | Own data only |
### Masquerading Matrix
| Hijacker Role | Can Masquerade As |
|--------------------|----------------------------------|
| SUPERUSER | Anyone |
| PLATFORM_SUPPORT | Tenant users |
| PLATFORM_SALES | Demo accounts (`is_temporary=True`) |
| TENANT_OWNER | Staff in same tenant |
| Others | No one |
**Security Rules:**
- Cannot hijack yourself
- Cannot hijack SUPERUSERs (except by other SUPERUSERs)
- Maximum depth: 1 (no hijack chains)
- All attempts logged to `logs/masquerade.log`
## 📁 Project Structure
### Request Flow
```
smoothschedule/
├── config/
└── settings.py # Multi-tenancy & security config
├── core/
├── models.py # Tenant, Domain, PermissionGrant
├── permissions.py # Hijack permission matrix
│ ├── middleware.py # Masquerade audit logging
│ └── admin.py # Django admin for core models
├── users/
│ ├── models.py # Custom User with 8-tier roles
│ └── admin.py # User admin with hijack button
├── logs/
│ ├── security.log # General security events
│ └── masquerade.log # Hijack activity (JSON)
└── setup_project.sh # Automated setup script
Browser → Traefik (SSL) → nginx (frontend) or django (API)
React SPA
/api/* → django:5000
/ws/* → django:5000 (WebSocket)
```
## 🔐 Security Features
### Audit Logging
All masquerade activity is logged in JSON format:
```json
{
"timestamp": "2024-01-15T10:30:00Z",
"action": "HIJACK_START",
"hijacker_email": "support@smoothschedule.com",
"hijacked_email": "customer@demo.com",
"ip_address": "192.168.1.1",
"session_key": "abc123..."
}
```
### Permission Grants (30-Minute Window)
Time-limited elevated permissions:
```python
from core.models import PermissionGrant
grant = PermissionGrant.create_grant(
grantor=admin_user,
grantee=support_user,
action="view_billing",
reason="Customer requested billing support",
duration_minutes=30,
)
# Check if active
if grant.is_active():
# Perform privileged action
pass
```
## 🧪 Testing Masquerading
1. Access Django Admin: `http://localhost:8000/admin/`
2. Create test users with different roles
3. Click "Hijack" button next to a user
4. Verify audit logs: `docker-compose exec django cat logs/masquerade.log`
## 📊 Admin Interface
- **Tenant Management**: View tenants, domains, subscription tiers
- **User Management**: Color-coded roles, masquerade buttons
- **Permission Grants**: Active/expired/revoked status, bulk revoke
- **Domain Verification**: AWS Route53 integration status
## 🛠️ Development
### Adding Tenant Apps
Edit `config/settings.py`:
```python
TENANT_APPS = [
'django.contrib.contenttypes',
'appointments', # Your app
'resources', # Your app
'billing', # Your app
]
```
### Custom Domain Setup
```python
domain = Domain.objects.create(
domain="app.customdomain.com",
tenant=tenant,
is_custom_domain=True,
route53_zone_id="Z1234567890ABC",
)
```
Then configure Route53 CNAME: `app.customdomain.com``smoothschedule.yourhost.com`
## 📖 Key Files Reference
| File | Purpose |
|------|---------|
| `setup_project.sh` | Automated project initialization |
| `config/settings.py` | Multi-tenancy, middleware, security config |
| `core/models.py` | Tenant, Domain, PermissionGrant models |
| `core/permissions.py` | Masquerading permission matrix |
| `core/middleware.py` | Audit logging for masquerading |
| `users/models.py` | Custom User with 8-tier roles |
## 📝 Important Notes
- **Django Admin**: The ONLY HTML interface (everything else is API)
- **Middleware Order**: `TenantMainMiddleware` must be first, `MasqueradeAuditMiddleware` after `HijackUserMiddleware`
- **Tenant Isolation**: Each tenant's data is in a separate PostgreSQL schema
- **Production**: Update `SECRET_KEY`, database credentials, and AWS keys via environment variables
## 🐛 Troubleshooting
**Cannot create tenant users:**
- Error: "Users with role TENANT_STAFF must be assigned to a tenant"
- Solution: Set `user.tenant = tenant_instance` before saving
**Hijack button doesn't appear:**
- Check `HIJACK_AUTHORIZATION_CHECK` in settings
- Verify `HijackUserAdminMixin` in `users/admin.py`
- Ensure user has permission per matrix rules
**Migrations fail:**
- Run shared migrations first: `migrate_schemas --shared`
- Then run tenant migrations: `migrate_schemas`
## 📄 License
MIT
## 🤝 Contributing
This is a production skeleton. Extend `TENANT_APPS` with your business logic.
---
**Built with ❤️ for multi-tenant SaaS perfection**
## Configuration Files
### Backend
| File | Purpose |
|------|---------|
| `smoothschedule/docker-compose.local.yml` | Local Docker services |
| `smoothschedule/docker-compose.production.yml` | Production Docker services |
| `smoothschedule/.envs/.local/` | Local environment variables |
| `smoothschedule/.envs/.production/` | Production environment variables |
| `smoothschedule/config/settings/` | Django settings |
| `smoothschedule/compose/production/traefik/traefik.yml` | Traefik routing config |
### Frontend
| File | Purpose |
|------|---------|
| `frontend/.env.development` | Local environment variables |
| `frontend/.env.production` | Production environment variables |
| `frontend/nginx.conf` | Production nginx config |
| `frontend/vite.config.ts` | Vite bundler config |
---
## Troubleshooting
### Backend won't start
```bash
# Check Docker logs
docker compose -f docker-compose.local.yml logs django
# Common issues:
# - Database not ready: wait longer, then restart django
# - Missing migrations: run migrate command
# - Port conflict: check if 8000 is in use
```
### Frontend can't connect to API
```bash
# Verify backend is running
curl http://lvh.me:8000/api/
# Check CORS settings in Django
# Ensure CORS_ALLOWED_ORIGINS includes http://platform.lvh.me:5173
```
### WebSockets disconnecting
```bash
# Check nginx has /ws/ proxy configured
# Verify django is running ASGI (Daphne)
# Check production traefik/nginx logs
```
### Multi-tenant issues
```bash
# Check tenant exists
docker compose exec django python manage.py shell
>>> from smoothschedule.identity.core.models import Tenant, Domain
>>> Tenant.objects.all()
>>> Domain.objects.all()
```
---
## Additional Documentation
- **[CLAUDE.md](CLAUDE.md)** - Development guide, coding standards, architecture details
- **[DEPLOYMENT.md](DEPLOYMENT.md)** - Comprehensive deployment guide
- **[PRODUCTION_DEPLOYMENT.md](PRODUCTION_DEPLOYMENT.md)** - Step-by-step manual deployment
---
## License
MIT

View File

@@ -184,6 +184,8 @@ if [[ "$SKIP_MIGRATE" != "true" ]]; then
echo ">>> Seeding/updating platform plugins for all tenants..."
docker compose -f docker-compose.production.yml exec -T django sh -c 'export DATABASE_URL=postgres://\${POSTGRES_USER}:\${POSTGRES_PASSWORD}@\${POSTGRES_HOST}:\${POSTGRES_PORT}/\${POSTGRES_DB} && python -c "
import django
django.setup()
from django_tenants.utils import get_tenant_model
from django.core.management import call_command
Tenant = get_tenant_model()

576
docs/SITE_BUILDER_DESIGN.md Normal file
View File

@@ -0,0 +1,576 @@
# Puck Site Builder - Design Document
## Overview
This document describes the architecture, data model, migration strategy, and security decisions for the SmoothSchedule Puck-based site builder.
## Goals
1. **Production-quality site builder** - Enable tenants to build unique pages using nested layout primitives, theme tokens, and booking-native blocks
2. **Backward compatibility** - Existing pages must continue to render
3. **Multi-tenant safety** - Full tenant isolation for all page data
4. **Security** - No arbitrary script injection; sanitized embeds only
5. **Feature gating** - Hide/disable blocks based on plan without breaking existing content
## Data Model
### Current Schema (Existing)
```
Site
├── tenant (OneToOne → Tenant)
├── primary_domain
├── is_enabled
├── template_key
└── pages[] (Page)
Page
├── site (FK → Site)
├── slug
├── path
├── title
├── is_home
├── is_published
├── order
├── puck_data (JSONField - Puck Data payload)
└── version (int - for migrations)
```
### New Schema Additions
#### SiteConfig (New Model)
Stores global theme tokens and chrome settings. One per Site, not duplicated per page.
```python
class SiteConfig(models.Model):
site = models.OneToOneField(Site, on_delete=models.CASCADE, related_name='config')
# Theme Tokens
theme = models.JSONField(default=dict)
# Structure:
# {
# "colors": {
# "primary": "#3b82f6",
# "secondary": "#64748b",
# "accent": "#f59e0b",
# "background": "#ffffff",
# "surface": "#f8fafc",
# "text": "#1e293b",
# "textMuted": "#64748b"
# },
# "typography": {
# "fontFamily": "Inter, system-ui, sans-serif",
# "headingFontFamily": null, # null = use fontFamily
# "baseFontSize": "16px",
# "scale": 1.25 # type scale ratio
# },
# "buttons": {
# "borderRadius": "8px",
# "primaryStyle": "solid", # solid | outline | ghost
# "secondaryStyle": "outline"
# },
# "sections": {
# "containerMaxWidth": "1280px",
# "defaultPaddingY": "80px"
# }
# }
# Global Chrome
header = models.JSONField(default=dict)
# Structure:
# {
# "enabled": true,
# "logo": { "src": "", "alt": "", "width": 120 },
# "navigation": [
# { "label": "Home", "href": "/" },
# { "label": "Services", "href": "/services" },
# { "label": "Book Now", "href": "/book", "style": "button" }
# ],
# "sticky": true,
# "style": "default" # default | transparent | minimal
# }
footer = models.JSONField(default=dict)
# Structure:
# {
# "enabled": true,
# "columns": [
# {
# "title": "Company",
# "links": [{ "label": "About", "href": "/about" }]
# }
# ],
# "copyright": "© 2024 {business_name}. All rights reserved.",
# "socialLinks": [
# { "platform": "facebook", "url": "" },
# { "platform": "instagram", "url": "" }
# ]
# }
version = models.PositiveIntegerField(default=1)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
```
#### Page Model Enhancements
Add SEO and navigation fields to existing Page model:
```python
# Add to existing Page model:
meta_title = models.CharField(max_length=255, blank=True)
meta_description = models.TextField(blank=True)
og_image = models.URLField(blank=True)
canonical_url = models.URLField(blank=True)
noindex = models.BooleanField(default=False)
include_in_nav = models.BooleanField(default=True)
hide_chrome = models.BooleanField(default=False) # Landing page mode
```
### Puck Data Schema
The `puck_data` JSONField stores the Puck editor payload:
```json
{
"content": [
{
"type": "Section",
"props": {
"id": "section-abc123",
"background": { "type": "color", "value": "#f8fafc" },
"padding": "large",
"containerWidth": "default",
"anchorId": "hero"
}
}
],
"root": {},
"zones": {
"section-abc123:content": [
{
"type": "Heading",
"props": { "text": "Welcome", "level": "h1" }
}
]
}
}
```
### Version Strategy
- `Page.version` tracks payload schema version
- `SiteConfig.version` tracks theme/chrome schema version
- Migrations are handled on read (lazy migration)
- On save, always write latest version
## API Endpoints
### Existing (No Changes)
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `GET /api/sites/me/` | GET | Get current site |
| `GET /api/sites/me/pages/` | GET | List pages |
| `POST /api/sites/me/pages/` | POST | Create page |
| `PATCH /api/sites/me/pages/{id}/` | PATCH | Update page |
| `DELETE /api/sites/me/pages/{id}/` | DELETE | Delete page |
| `GET /api/public/page/` | GET | Get home page (public) |
### New Endpoints
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `GET /api/sites/me/config/` | GET | Get site config (theme, chrome) |
| `PATCH /api/sites/me/config/` | PATCH | Update site config |
| `GET /api/public/page/{slug}/` | GET | Get page by slug (public) |
## Component Library
### Categories
1. **Layout** - Section, Columns, Card, Spacer, Divider
2. **Content** - Heading, RichText, Image, Button, IconList, Testimonial, FAQ
3. **Booking** - BookingWidget, ServiceCatalog
4. **Contact** - ContactForm, BusinessHours, Map
### Component Specification
#### Section (Layout)
The fundamental building block for page sections.
```typescript
{
type: "Section",
label: "Section",
fields: {
background: {
type: "custom", // Color picker, image upload, or gradient
options: ["none", "color", "image", "gradient"]
},
overlay: {
type: "custom", // Overlay color + opacity
},
padding: {
type: "select",
options: ["none", "small", "medium", "large", "xlarge"]
},
containerWidth: {
type: "select",
options: ["narrow", "default", "wide", "full"]
},
anchorId: { type: "text" },
hideOnMobile: { type: "checkbox" },
hideOnTablet: { type: "checkbox" },
hideOnDesktop: { type: "checkbox" }
},
render: ({ puck }) => (
<section>
<div className={containerClass}>
<DropZone zone="content" />
</div>
</section>
)
}
```
#### Columns (Layout)
Flexible column layout with nested drop zones.
```typescript
{
type: "Columns",
fields: {
columns: {
type: "select",
options: ["2", "3", "4", "2-1", "1-2"] // ratios
},
gap: {
type: "select",
options: ["none", "small", "medium", "large"]
},
verticalAlign: {
type: "select",
options: ["top", "center", "bottom", "stretch"]
},
stackOnMobile: { type: "checkbox", default: true }
},
render: ({ columns, puck }) => (
<div className="grid">
{Array.from({ length: columnCount }).map((_, i) => (
<DropZone zone={`column-${i}`} key={i} />
))}
</div>
)
}
```
#### BookingWidget (Booking)
Embedded booking interface - SmoothSchedule's differentiator.
```typescript
{
type: "BookingWidget",
fields: {
serviceMode: {
type: "select",
options: [
{ label: "All Services", value: "all" },
{ label: "By Category", value: "category" },
{ label: "Specific Services", value: "specific" }
]
},
categoryId: { type: "text" }, // When mode = category
serviceIds: { type: "array" }, // When mode = specific
showDuration: { type: "checkbox", default: true },
showPrice: { type: "checkbox", default: true },
showDeposits: { type: "checkbox", default: true },
requireLogin: { type: "checkbox", default: false },
ctaAfterBooking: { type: "text" }
}
}
```
## Security Measures
### 1. XSS Prevention
All text content is rendered through React, which auto-escapes HTML by default.
For rich text (RichText component):
- Store content as structured JSON (Slate/Tiptap document format), not raw HTML
- Render using a safe renderer that only supports whitelisted elements (p, strong, em, a, ul, ol, li)
- Never render raw HTML strings directly into the DOM
- All user-provided content goes through React's safe text rendering
### 2. Embed/Script Injection
No arbitrary embeds allowed. Map component only supports:
- Google Maps embed URLs (maps.google.com/*)
- OpenStreetMap iframes
Implementation:
```typescript
const ALLOWED_EMBED_DOMAINS = [
'www.google.com/maps/embed',
'maps.google.com',
'www.openstreetmap.org'
];
function isAllowedEmbed(url: string): boolean {
return ALLOWED_EMBED_DOMAINS.some(domain =>
url.startsWith(`https://${domain}`)
);
}
```
### 3. Backend Validation
```python
# In PageSerializer.validate_puck_data()
def validate_puck_data(self, value):
# 1. Size limit
if len(json.dumps(value)) > 5_000_000: # 5MB limit
raise ValidationError("Page data too large")
# 2. Validate structure
if not isinstance(value.get('content'), list):
raise ValidationError("Invalid puck_data structure")
# 3. Scan for disallowed content
serialized = json.dumps(value).lower()
disallowed = ['<script', 'javascript:', 'onerror=', 'onload=']
for pattern in disallowed:
if pattern in serialized:
raise ValidationError("Disallowed content detected")
return value
```
### 4. Tenant Isolation
All queries are automatically tenant-scoped:
- `get_queryset()` filters by `site__tenant=request.tenant`
- `perform_create()` assigns site from tenant context
- No cross-tenant data access possible
## Migration Strategy
### Current State Analysis
The existing implementation already uses Puck with 3 components:
- Hero
- TextSection
- Booking
No "enum-based component list" migration needed - the system is already Puck-native.
### Forward Migration
When adding new component types or changing prop schemas:
1. **Version field** tracks schema version per page
2. **Lazy migration** on read - transform old format to new
3. **Save updates version** - always writes latest format
Example migration:
```typescript
// v1 → v2: Hero.align was string, now object with breakpoint values
function migrateHeroV1toV2(props: any): any {
if (typeof props.align === 'string') {
return {
...props,
align: {
mobile: 'center',
tablet: props.align,
desktop: props.align
}
};
}
return props;
}
```
### Migration Registry
```typescript
const MIGRATIONS: Record<number, (data: PuckData) => PuckData> = {
2: migrateV1toV2,
3: migrateV2toV3,
};
function migratePuckData(data: PuckData, currentVersion: number): PuckData {
let migrated = data;
for (let v = currentVersion + 1; v <= LATEST_VERSION; v++) {
if (MIGRATIONS[v]) {
migrated = MIGRATIONS[v](migrated);
}
}
return migrated;
}
```
## Feature Gating
### Plan-Based Component Access
Some components are gated by plan features:
- **ContactForm** - requires `can_use_contact_form` feature
- **ServiceCatalog** - requires `can_use_service_catalog` feature
### Implementation
1. **Config generation** passes feature flags to frontend:
```typescript
function getComponentConfig(features: Features): Config {
const components = { ...baseComponents };
if (!features.can_use_contact_form) {
delete components.ContactForm;
}
return { components };
}
```
2. **Rendering** always includes all component renderers:
```typescript
// Full config for rendering (never gated)
const renderConfig = { components: allComponents };
// Gated config for editing
const editorConfig = getComponentConfig(features);
```
This ensures pages with gated components still render correctly, even if the user can't add new instances.
## Editor UX Enhancements
### Viewport Toggles
Desktop (default), Tablet (768px), Mobile (375px)
### Outline Navigation
Tree view of page structure with:
- Drag to reorder
- Click to select
- Collapse/expand zones
### Categorized Component Palette
- Layout: Section, Columns, Card, Spacer, Divider
- Content: Heading, RichText, Image, Button, IconList, Testimonial, FAQ
- Booking: BookingWidget, ServiceCatalog
- Contact: ContactForm, BusinessHours, Map
### Page Settings Panel
Accessible via page icon in header:
- Title & slug
- Meta title & description
- OG image
- Canonical URL
- Index/noindex toggle
- Include in navigation toggle
- Hide chrome toggle (landing page mode)
## File Structure
### Backend
```
smoothschedule/platform/tenant_sites/
├── models.py # Site, SiteConfig, Page, Domain
├── serializers.py # API serializers with validation
├── views.py # ViewSets and API views
├── validators.py # Puck data validation helpers
├── migrations/
│ └── 0002_siteconfig_page_seo_fields.py
└── tests/
├── __init__.py
├── test_models.py
├── test_serializers.py
├── test_views.py
└── test_tenant_isolation.py
```
### Frontend
```
frontend/src/
├── puck/
│ ├── config.ts # Main Puck config export
│ ├── types.ts # Component prop types
│ ├── migrations.ts # Data migration functions
│ ├── components/
│ │ ├── layout/
│ │ │ ├── Section.tsx
│ │ │ ├── Columns.tsx
│ │ │ ├── Card.tsx
│ │ │ ├── Spacer.tsx
│ │ │ └── Divider.tsx
│ │ ├── content/
│ │ │ ├── Heading.tsx
│ │ │ ├── RichText.tsx
│ │ │ ├── Image.tsx
│ │ │ ├── Button.tsx
│ │ │ ├── IconList.tsx
│ │ │ ├── Testimonial.tsx
│ │ │ └── FAQ.tsx
│ │ ├── booking/
│ │ │ ├── BookingWidget.tsx
│ │ │ └── ServiceCatalog.tsx
│ │ └── contact/
│ │ ├── ContactForm.tsx
│ │ ├── BusinessHours.tsx
│ │ └── Map.tsx
│ └── fields/
│ ├── ColorPicker.tsx
│ ├── BackgroundPicker.tsx
│ └── RichTextEditor.tsx
├── pages/
│ ├── PageEditor.tsx # Enhanced editor
│ └── PublicPage.tsx # Public renderer
├── hooks/
│ └── useSites.ts # Site/Page/Config hooks
└── __tests__/
└── puck/
├── migrations.test.ts
├── components.test.tsx
└── config.test.ts
```
## Implementation Order
1. **Tests First** (TDD)
- Backend: tenant isolation, CRUD, validation
- Frontend: migration, rendering, feature gating
2. **Data Model**
- Add SiteConfig model
- Add Page SEO fields
- Create migrations
3. **API**
- SiteConfig endpoints
- Enhanced PageSerializer validation
4. **Components**
- Layout primitives (Section, Columns)
- Content blocks (Heading, RichText, Image)
- Booking blocks (enhanced BookingWidget)
- Contact blocks (ContactForm, BusinessHours)
5. **Editor**
- Viewport toggles
- Categorized palette
- Page settings panel
6. **Public Rendering**
- Apply theme tokens
- Render header/footer chrome

View File

@@ -13,7 +13,10 @@ This is the React frontend for SmoothSchedule, a multi-tenant scheduling platfor
├── frontend/ # This React frontend
│ ├── src/
│ │ ├── api/client.ts # Axios API client
│ │ ├── components/ # Reusable components
│ │ ├── components/ # Feature components
│ │ │ └── ui/ # Reusable UI components (see below)
│ │ ├── constants/ # Shared constants
│ │ │ └── schedulePresets.ts # Schedule/cron presets
│ │ ├── hooks/ # React Query hooks (useResources, useAuth, etc.)
│ │ ├── pages/ # Page components
│ │ ├── types.ts # TypeScript interfaces
@@ -31,6 +34,125 @@ This is the React frontend for SmoothSchedule, a multi-tenant scheduling platfor
└── users/ # User management
```
## Reusable UI Components
All reusable UI components are in `src/components/ui/`. Import from the barrel file:
```typescript
import { Modal, FormInput, Button, Alert } from '../components/ui';
```
### Available Components
| Component | Description |
|-----------|-------------|
| **Modal** | Reusable modal dialog with header, body, footer |
| **ModalFooter** | Standardized modal footer with buttons |
| **FormInput** | Text input with label, error, hint support |
| **FormSelect** | Select dropdown with label, error support |
| **FormTextarea** | Textarea with label, error support |
| **FormCurrencyInput** | ATM-style currency input (cents) |
| **CurrencyInput** | Raw currency input component |
| **Button** | Button with variants, loading state, icons |
| **SubmitButton** | Pre-configured submit button |
| **Alert** | Alert banner (error, success, warning, info) |
| **ErrorMessage** | Error alert shorthand |
| **SuccessMessage** | Success alert shorthand |
| **TabGroup** | Tab navigation (default, pills, underline) |
| **StepIndicator** | Multi-step wizard indicator |
| **LoadingSpinner** | Loading spinner with variants |
| **PageLoading** | Full page loading state |
| **Card** | Card container with header/body/footer |
| **EmptyState** | Empty state placeholder |
| **Badge** | Status badges |
### Usage Examples
```typescript
// Modal with form
<Modal isOpen={isOpen} onClose={onClose} title="Edit Resource" size="lg">
<FormInput
label="Name"
value={name}
onChange={(e) => setName(e.target.value)}
error={errors.name}
required
/>
<FormSelect
label="Type"
value={type}
onChange={(e) => setType(e.target.value)}
options={[
{ value: 'STAFF', label: 'Staff' },
{ value: 'ROOM', label: 'Room' },
]}
/>
</Modal>
// Alert messages
{error && <ErrorMessage message={error} />}
{success && <SuccessMessage message="Saved successfully!" />}
// Tabs
<TabGroup
tabs={[
{ id: 'details', label: 'Details' },
{ id: 'schedule', label: 'Schedule' },
]}
activeTab={activeTab}
onChange={setActiveTab}
/>
```
## Utility Hooks
### useCrudMutation
Factory hook for CRUD mutations with React Query:
```typescript
import { useCrudMutation, createCrudHooks } from '../hooks/useCrudMutation';
// Simple usage
const createResource = useCrudMutation<Resource, CreateResourceData>({
endpoint: '/resources',
method: 'POST',
invalidateKeys: [['resources']],
});
// Create all CRUD hooks at once
const { useCreate, useUpdate, useDelete } = createCrudHooks<Resource>('/resources', 'resources');
```
### useFormValidation
Schema-based form validation:
```typescript
import { useFormValidation, required, email, minLength } from '../hooks/useFormValidation';
const schema = {
email: [required('Email is required'), email('Invalid email')],
password: [required(), minLength(8, 'Min 8 characters')],
};
const { errors, validateForm, isValid } = useFormValidation(schema);
const handleSubmit = () => {
if (validateForm(formData)) {
// Submit
}
};
```
## Constants
### Schedule Presets
```typescript
import { SCHEDULE_PRESETS, TRIGGER_OPTIONS, OFFSET_PRESETS } from '../constants/schedulePresets';
```
## Local Development Domain Setup
### Why lvh.me instead of localhost?

View File

@@ -63,6 +63,19 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
# Proxy WebSocket connections to Django (Daphne/ASGI)
location /ws/ {
proxy_pass http://django:5000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
}
# Proxy Static/Media to Django (if served by WhiteNoise/Django)
location /static/ {
proxy_pass http://django:5000;

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@measured/puck": "^0.20.2",
"@react-google-maps/api": "^2.20.7",
"@stripe/connect-js": "^3.3.31",
"@stripe/react-connect-js": "^3.3.31",
@@ -35,27 +36,38 @@
"@eslint/js": "^9.39.1",
"@playwright/test": "^1.48.0",
"@tailwindcss/postcss": "^4.1.17",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.6",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.0.15",
"autoprefixer": "^10.4.22",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"jsdom": "^27.2.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"vite": "^7.2.4"
"vite": "^7.2.4",
"vitest": "^4.0.15"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "playwright test",
"test:ui": "playwright test --ui",
"test:headed": "playwright test --headed"
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest --watch",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed"
}
}

View File

@@ -41,6 +41,7 @@ const Scheduler = React.lazy(() => import('./pages/Scheduler'));
const Customers = React.lazy(() => import('./pages/Customers'));
const Settings = React.lazy(() => import('./pages/Settings'));
const Payments = React.lazy(() => import('./pages/Payments'));
const Messages = React.lazy(() => import('./pages/Messages'));
const Resources = React.lazy(() => import('./pages/Resources'));
const Services = React.lazy(() => import('./pages/Services'));
const Staff = React.lazy(() => import('./pages/Staff'));
@@ -62,6 +63,7 @@ const PlatformEmailAddresses = React.lazy(() => import('./pages/platform/Platfor
const PlatformUsers = React.lazy(() => import('./pages/platform/PlatformUsers'));
const PlatformStaff = React.lazy(() => import('./pages/platform/PlatformStaff'));
const PlatformSettings = React.lazy(() => import('./pages/platform/PlatformSettings'));
const BillingManagement = React.lazy(() => import('./pages/platform/BillingManagement'));
const ProfileSettings = React.lazy(() => import('./pages/ProfileSettings'));
const VerifyEmail = React.lazy(() => import('./pages/VerifyEmail'));
const EmailVerificationRequired = React.lazy(() => import('./pages/EmailVerificationRequired'));
@@ -109,6 +111,10 @@ const EmailTemplates = React.lazy(() => import('./pages/EmailTemplates')); // Im
const Contracts = React.lazy(() => import('./pages/Contracts')); // Import Contracts page
const ContractTemplates = React.lazy(() => import('./pages/ContractTemplates')); // Import Contract Templates page
const ContractSigning = React.lazy(() => import('./pages/ContractSigning')); // Import Contract Signing page (public)
const PageEditor = React.lazy(() => import('./pages/PageEditor')); // Import PageEditor
const PublicPage = React.lazy(() => import('./pages/PublicPage')); // Import PublicPage
const BookingFlow = React.lazy(() => import('./pages/BookingFlow')); // Import Booking Flow
const Locations = React.lazy(() => import('./pages/Locations')); // Import Locations management page
// Settings pages
const SettingsLayout = React.lazy(() => import('./layouts/SettingsLayout'));
@@ -123,6 +129,7 @@ const EmailSettings = React.lazy(() => import('./pages/settings/EmailSettings'))
const CommunicationSettings = React.lazy(() => import('./pages/settings/CommunicationSettings'));
const BillingSettings = React.lazy(() => import('./pages/settings/BillingSettings'));
const QuotaSettings = React.lazy(() => import('./pages/settings/QuotaSettings'));
const BusinessHoursSettings = React.lazy(() => import('./pages/settings/BusinessHoursSettings'));
import { Toaster } from 'react-hot-toast'; // Import Toaster for notifications
@@ -345,7 +352,8 @@ const AppContent: React.FC = () => {
return (
<Suspense fallback={<LoadingScreen />}>
<Routes>
<Route path="/" element={<TenantLandingPage subdomain={currentSubdomain} />} />
<Route path="/" element={<PublicPage />} />
<Route path="/book" element={<BookingFlow />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
@@ -489,7 +497,10 @@ const AppContent: React.FC = () => {
<Route path="/help/plugins" element={<HelpPluginDocs />} />
<Route path="/help/email" element={<HelpEmailSettings />} />
{user.role === 'superuser' && (
<Route path="/platform/settings" element={<PlatformSettings />} />
<>
<Route path="/platform/settings" element={<PlatformSettings />} />
<Route path="/platform/billing" element={<BillingManagement />} />
</>
)}
<Route path="/platform/profile" element={<ProfileSettings />} />
<Route path="/verify-email" element={<VerifyEmail />} />
@@ -623,7 +634,7 @@ const AppContent: React.FC = () => {
const isTrialExpired = business.isTrialExpired || (business.status === 'Trial' && business.trialEnd && new Date(business.trialEnd) < new Date());
// Allowed routes when trial is expired
const allowedWhenExpired = ['/trial-expired', '/upgrade', '/settings', '/profile'];
const allowedWhenExpired = ['/dashboard/trial-expired', '/dashboard/upgrade', '/dashboard/settings', '/dashboard/profile'];
const currentPath = window.location.pathname;
const isOnAllowedRoute = allowedWhenExpired.some(route => currentPath.startsWith(route));
@@ -632,15 +643,15 @@ const AppContent: React.FC = () => {
return (
<Suspense fallback={<LoadingScreen />}>
<Routes>
<Route path="/trial-expired" element={<TrialExpired />} />
<Route path="/upgrade" element={<Upgrade />} />
<Route path="/profile" element={<ProfileSettings />} />
<Route path="/dashboard/trial-expired" element={<TrialExpired />} />
<Route path="/dashboard/upgrade" element={<Upgrade />} />
<Route path="/dashboard/profile" element={<ProfileSettings />} />
{/* Trial-expired users can access billing settings to upgrade */}
<Route
path="/settings/*"
element={hasAccess(['owner']) ? <Navigate to="/upgrade" /> : <Navigate to="/trial-expired" />}
path="/dashboard/settings/*"
element={hasAccess(['owner']) ? <Navigate to="/dashboard/upgrade" /> : <Navigate to="/dashboard/trial-expired" />}
/>
<Route path="*" element={<Navigate to="/trial-expired" replace />} />
<Route path="*" element={<Navigate to="/dashboard/trial-expired" replace />} />
</Routes>
</Suspense>
);
@@ -661,30 +672,33 @@ const AppContent: React.FC = () => {
/>
}
>
{/* Redirect root to dashboard */}
<Route path="/" element={<Navigate to="/dashboard" replace />} />
{/* Trial and Upgrade Routes */}
<Route path="/trial-expired" element={<TrialExpired />} />
<Route path="/upgrade" element={<Upgrade />} />
<Route path="/dashboard/trial-expired" element={<TrialExpired />} />
<Route path="/dashboard/upgrade" element={<Upgrade />} />
{/* Regular Routes */}
<Route
path="/"
path="/dashboard"
element={user.role === 'resource' ? <ResourceDashboard /> : user.role === 'staff' ? <StaffDashboard user={user} /> : <Dashboard />}
/>
{/* Staff Schedule - vertical timeline view */}
<Route
path="/my-schedule"
path="/dashboard/my-schedule"
element={
hasAccess(['staff']) ? (
<StaffSchedule user={user} />
) : (
<Navigate to="/" />
<Navigate to="/dashboard" />
)
}
/>
<Route path="/scheduler" element={<Scheduler />} />
<Route path="/tickets" element={<Tickets />} />
<Route path="/dashboard/scheduler" element={<Scheduler />} />
<Route path="/dashboard/tickets" element={<Tickets />} />
<Route
path="/help"
path="/dashboard/help"
element={
user.role === 'staff' ? (
<StaffHelp user={user} />
@@ -693,192 +707,210 @@ const AppContent: React.FC = () => {
)
}
/>
<Route path="/help/guide" element={<HelpGuide />} />
<Route path="/help/ticketing" element={<HelpTicketing />} />
<Route path="/help/api" element={<HelpApiDocs />} />
<Route path="/help/plugins/docs" element={<HelpPluginDocs />} />
<Route path="/help/email" element={<HelpEmailSettings />} />
<Route path="/dashboard/help/guide" element={<HelpGuide />} />
<Route path="/dashboard/help/ticketing" element={<HelpTicketing />} />
<Route path="/dashboard/help/api" element={<HelpApiDocs />} />
<Route path="/dashboard/help/plugins/docs" element={<HelpPluginDocs />} />
<Route path="/dashboard/help/email" element={<HelpEmailSettings />} />
{/* New help pages */}
<Route path="/help/dashboard" element={<HelpDashboard />} />
<Route path="/help/scheduler" element={<HelpScheduler />} />
<Route path="/help/tasks" element={<HelpTasks />} />
<Route path="/help/customers" element={<HelpCustomers />} />
<Route path="/help/services" element={<HelpServices />} />
<Route path="/help/resources" element={<HelpResources />} />
<Route path="/help/staff" element={<HelpStaff />} />
<Route path="/help/time-blocks" element={<HelpTimeBlocks />} />
<Route path="/help/messages" element={<HelpMessages />} />
<Route path="/help/payments" element={<HelpPayments />} />
<Route path="/help/contracts" element={<HelpContracts />} />
<Route path="/help/plugins" element={<HelpPlugins />} />
<Route path="/help/settings/general" element={<HelpSettingsGeneral />} />
<Route path="/help/settings/resource-types" element={<HelpSettingsResourceTypes />} />
<Route path="/help/settings/booking" element={<HelpSettingsBooking />} />
<Route path="/help/settings/appearance" element={<HelpSettingsAppearance />} />
<Route path="/help/settings/email" element={<HelpSettingsEmail />} />
<Route path="/help/settings/domains" element={<HelpSettingsDomains />} />
<Route path="/help/settings/api" element={<HelpSettingsApi />} />
<Route path="/help/settings/auth" element={<HelpSettingsAuth />} />
<Route path="/help/settings/billing" element={<HelpSettingsBilling />} />
<Route path="/help/settings/quota" element={<HelpSettingsQuota />} />
<Route path="/dashboard/help/dashboard" element={<HelpDashboard />} />
<Route path="/dashboard/help/scheduler" element={<HelpScheduler />} />
<Route path="/dashboard/help/tasks" element={<HelpTasks />} />
<Route path="/dashboard/help/customers" element={<HelpCustomers />} />
<Route path="/dashboard/help/services" element={<HelpServices />} />
<Route path="/dashboard/help/resources" element={<HelpResources />} />
<Route path="/dashboard/help/staff" element={<HelpStaff />} />
<Route path="/dashboard/help/time-blocks" element={<HelpTimeBlocks />} />
<Route path="/dashboard/help/messages" element={<HelpMessages />} />
<Route path="/dashboard/help/payments" element={<HelpPayments />} />
<Route path="/dashboard/help/contracts" element={<HelpContracts />} />
<Route path="/dashboard/help/plugins" element={<HelpPlugins />} />
<Route path="/dashboard/help/settings/general" element={<HelpSettingsGeneral />} />
<Route path="/dashboard/help/settings/resource-types" element={<HelpSettingsResourceTypes />} />
<Route path="/dashboard/help/settings/booking" element={<HelpSettingsBooking />} />
<Route path="/dashboard/help/settings/appearance" element={<HelpSettingsAppearance />} />
<Route path="/dashboard/help/settings/email" element={<HelpSettingsEmail />} />
<Route path="/dashboard/help/settings/domains" element={<HelpSettingsDomains />} />
<Route path="/dashboard/help/settings/api" element={<HelpSettingsApi />} />
<Route path="/dashboard/help/settings/auth" element={<HelpSettingsAuth />} />
<Route path="/dashboard/help/settings/billing" element={<HelpSettingsBilling />} />
<Route path="/dashboard/help/settings/quota" element={<HelpSettingsQuota />} />
<Route
path="/plugins/marketplace"
path="/dashboard/plugins/marketplace"
element={
hasAccess(['owner', 'manager']) ? (
<PluginMarketplace />
) : (
<Navigate to="/" />
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/plugins/my-plugins"
path="/dashboard/plugins/my-plugins"
element={
hasAccess(['owner', 'manager']) ? (
<MyPlugins />
) : (
<Navigate to="/" />
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/plugins/create"
path="/dashboard/plugins/create"
element={
hasAccess(['owner', 'manager']) ? (
<CreatePlugin />
) : (
<Navigate to="/" />
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/tasks"
path="/dashboard/tasks"
element={
hasAccess(['owner', 'manager']) ? (
<Tasks />
) : (
<Navigate to="/" />
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/email-templates"
path="/dashboard/email-templates"
element={
hasAccess(['owner', 'manager']) ? (
<EmailTemplates />
) : (
<Navigate to="/" />
<Navigate to="/dashboard" />
)
}
/>
<Route path="/support" element={<PlatformSupport />} />
<Route path="/dashboard/support" element={<PlatformSupport />} />
<Route
path="/customers"
path="/dashboard/customers"
element={
hasAccess(['owner', 'manager']) ? (
<Customers onMasquerade={handleMasquerade} effectiveUser={user} />
) : (
<Navigate to="/" />
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/services"
path="/dashboard/services"
element={
hasAccess(['owner', 'manager']) ? (
<Services />
) : (
<Navigate to="/" />
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/resources"
path="/dashboard/resources"
element={
hasAccess(['owner', 'manager']) ? (
<Resources onMasquerade={handleMasquerade} effectiveUser={user} />
) : (
<Navigate to="/" />
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/staff"
path="/dashboard/staff"
element={
hasAccess(['owner', 'manager']) ? (
<Staff onMasquerade={handleMasquerade} effectiveUser={user} />
) : (
<Navigate to="/" />
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/time-blocks"
path="/dashboard/time-blocks"
element={
hasAccess(['owner', 'manager']) ? (
<TimeBlocks />
) : (
<Navigate to="/" />
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/my-availability"
path="/dashboard/locations"
element={
hasAccess(['owner', 'manager']) ? (
<Locations />
) : (
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/dashboard/my-availability"
element={
hasAccess(['staff', 'resource']) ? (
<MyAvailability user={user} />
) : (
<Navigate to="/" />
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/contracts"
path="/dashboard/contracts"
element={
hasAccess(['owner', 'manager']) && canUse('contracts') ? (
<Contracts />
) : (
<Navigate to="/" />
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/contracts/templates"
path="/dashboard/contracts/templates"
element={
hasAccess(['owner', 'manager']) && canUse('contracts') ? (
<ContractTemplates />
) : (
<Navigate to="/" />
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/payments"
path="/dashboard/payments"
element={
hasAccess(['owner', 'manager']) ? <Payments /> : <Navigate to="/" />
hasAccess(['owner', 'manager']) ? <Payments /> : <Navigate to="/dashboard" />
}
/>
<Route
path="/messages"
path="/dashboard/messages"
element={
hasAccess(['owner', 'manager']) && user?.can_send_messages ? (
<Messages />
) : (
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/dashboard/site-editor"
element={
hasAccess(['owner', 'manager']) ? (
<div className="p-8">
<h1 className="text-2xl font-bold mb-4">Messages</h1>
<p className="text-gray-600">Messages feature coming soon...</p>
</div>
<PageEditor />
) : (
<Navigate to="/" />
<Navigate to="/dashboard" />
)
}
/>
{/* Settings Routes with Nested Layout */}
{hasAccess(['owner']) ? (
<Route path="/settings" element={<SettingsLayout />}>
<Route index element={<Navigate to="/settings/general" replace />} />
<Route path="/dashboard/settings" element={<SettingsLayout />}>
<Route index element={<Navigate to="/dashboard/settings/general" replace />} />
<Route path="general" element={<GeneralSettings />} />
<Route path="branding" element={<BrandingSettings />} />
<Route path="resource-types" element={<ResourceTypesSettings />} />
<Route path="booking" element={<BookingSettings />} />
<Route path="business-hours" element={<BusinessHoursSettings />} />
<Route path="email-templates" element={<EmailTemplates />} />
<Route path="custom-domains" element={<CustomDomainsSettings />} />
<Route path="api" element={<ApiSettings />} />
@@ -889,11 +921,11 @@ const AppContent: React.FC = () => {
<Route path="quota" element={<QuotaSettings />} />
</Route>
) : (
<Route path="/settings/*" element={<Navigate to="/" />} />
<Route path="/dashboard/settings/*" element={<Navigate to="/dashboard" />} />
)}
<Route path="/profile" element={<ProfileSettings />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="*" element={<Navigate to="/" />} />
<Route path="/dashboard/profile" element={<ProfileSettings />} />
<Route path="/dashboard/verify-email" element={<VerifyEmail />} />
<Route path="*" element={<Navigate to="/dashboard" />} />
</Route>
</Routes>
</Suspense>

View File

@@ -0,0 +1,159 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
post: vi.fn(),
get: vi.fn(),
},
}));
import {
login,
logout,
getCurrentUser,
refreshToken,
masquerade,
stopMasquerade,
} from '../auth';
import apiClient from '../client';
describe('auth API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('login', () => {
it('sends credentials to login endpoint', async () => {
const mockResponse = {
data: {
access: 'access-token',
refresh: 'refresh-token',
user: { id: 1, email: 'test@example.com' },
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await login({ email: 'test@example.com', password: 'password' });
expect(apiClient.post).toHaveBeenCalledWith('/auth/login/', {
email: 'test@example.com',
password: 'password',
});
expect(result).toEqual(mockResponse.data);
});
it('returns MFA required response', async () => {
const mockResponse = {
data: {
mfa_required: true,
user_id: 1,
mfa_methods: ['TOTP', 'SMS'],
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await login({ email: 'test@example.com', password: 'password' });
expect(result.mfa_required).toBe(true);
expect(result.mfa_methods).toContain('TOTP');
});
});
describe('logout', () => {
it('calls logout endpoint', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await logout();
expect(apiClient.post).toHaveBeenCalledWith('/auth/logout/');
});
});
describe('getCurrentUser', () => {
it('fetches current user from API', async () => {
const mockUser = {
id: 1,
email: 'test@example.com',
name: 'Test User',
role: 'owner',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockUser });
const result = await getCurrentUser();
expect(apiClient.get).toHaveBeenCalledWith('/auth/me/');
expect(result).toEqual(mockUser);
});
});
describe('refreshToken', () => {
it('sends refresh token to API', async () => {
const mockResponse = { data: { access: 'new-access-token' } };
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await refreshToken('old-refresh-token');
expect(apiClient.post).toHaveBeenCalledWith('/auth/refresh/', {
refresh: 'old-refresh-token',
});
expect(result.access).toBe('new-access-token');
});
});
describe('masquerade', () => {
it('sends masquerade request with user_pk', async () => {
const mockResponse = {
data: {
access: 'masq-access',
refresh: 'masq-refresh',
user: { id: 2, email: 'other@example.com' },
masquerade_stack: [{ user_id: 1, username: 'admin', role: 'superuser' }],
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await masquerade(2);
expect(apiClient.post).toHaveBeenCalledWith('/auth/hijack/acquire/', {
user_pk: 2,
hijack_history: undefined,
});
expect(result.masquerade_stack).toHaveLength(1);
});
it('sends masquerade request with history', async () => {
const history = [{ user_id: 1, username: 'admin', role: 'superuser' as const }];
vi.mocked(apiClient.post).mockResolvedValue({ data: {} });
await masquerade(2, history);
expect(apiClient.post).toHaveBeenCalledWith('/auth/hijack/acquire/', {
user_pk: 2,
hijack_history: history,
});
});
});
describe('stopMasquerade', () => {
it('sends release request with masquerade stack', async () => {
const stack = [{ user_id: 1, username: 'admin', role: 'superuser' as const }];
const mockResponse = {
data: {
access: 'orig-access',
refresh: 'orig-refresh',
user: { id: 1 },
masquerade_stack: [],
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await stopMasquerade(stack);
expect(apiClient.post).toHaveBeenCalledWith('/auth/hijack/release/', {
masquerade_stack: stack,
});
expect(result.masquerade_stack).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,214 @@
/**
* Tests for Billing API client functions
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import apiClient from '../client';
import {
getEntitlements,
getCurrentSubscription,
getPlans,
getAddOns,
getInvoices,
getInvoice,
Entitlements,
Subscription,
PlanVersion,
AddOnProduct,
Invoice,
} from '../billing';
// Mock the API client
vi.mock('../client', () => ({
default: {
get: vi.fn(),
},
}));
describe('Billing API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getEntitlements', () => {
it('fetches entitlements from /api/me/entitlements/', async () => {
const mockEntitlements: Entitlements = {
can_use_sms_reminders: true,
can_use_mobile_app: false,
max_users: 10,
max_resources: 25,
};
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockEntitlements });
const result = await getEntitlements();
expect(apiClient.get).toHaveBeenCalledWith('/me/entitlements/');
expect(result).toEqual(mockEntitlements);
});
it('returns empty object on error', async () => {
vi.mocked(apiClient.get).mockRejectedValueOnce(new Error('Network error'));
const result = await getEntitlements();
expect(result).toEqual({});
});
});
describe('getCurrentSubscription', () => {
it('fetches subscription from /api/me/subscription/', async () => {
const mockSubscription: Subscription = {
id: 1,
status: 'active',
plan_version: {
id: 10,
name: 'Pro Plan v1',
is_legacy: false,
plan: { code: 'pro', name: 'Pro' },
price_monthly_cents: 7900,
price_yearly_cents: 79000,
},
current_period_start: '2024-01-01T00:00:00Z',
current_period_end: '2024-02-01T00:00:00Z',
};
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockSubscription });
const result = await getCurrentSubscription();
expect(apiClient.get).toHaveBeenCalledWith('/me/subscription/');
expect(result).toEqual(mockSubscription);
});
it('returns null when no subscription (404)', async () => {
const error = { response: { status: 404 } };
vi.mocked(apiClient.get).mockRejectedValueOnce(error);
const result = await getCurrentSubscription();
expect(result).toBeNull();
});
});
describe('getPlans', () => {
it('fetches public plans from /api/billing/plans/', async () => {
const mockPlans: PlanVersion[] = [
{
id: 1,
name: 'Free Plan',
is_legacy: false,
is_public: true,
plan: { code: 'free', name: 'Free' },
price_monthly_cents: 0,
price_yearly_cents: 0,
},
{
id: 2,
name: 'Pro Plan',
is_legacy: false,
is_public: true,
plan: { code: 'pro', name: 'Pro' },
price_monthly_cents: 7900,
price_yearly_cents: 79000,
},
];
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockPlans });
const result = await getPlans();
expect(apiClient.get).toHaveBeenCalledWith('/billing/plans/');
expect(result).toEqual(mockPlans);
expect(result).toHaveLength(2);
});
});
describe('getAddOns', () => {
it('fetches active add-ons from /api/billing/addons/', async () => {
const mockAddOns: AddOnProduct[] = [
{
id: 1,
code: 'sms_pack',
name: 'SMS Pack',
price_monthly_cents: 500,
price_one_time_cents: 0,
is_stackable: false,
is_active: true,
},
];
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockAddOns });
const result = await getAddOns();
expect(apiClient.get).toHaveBeenCalledWith('/billing/addons/');
expect(result).toEqual(mockAddOns);
});
});
describe('getInvoices', () => {
it('fetches invoices from /api/billing/invoices/', async () => {
const mockInvoices: Invoice[] = [
{
id: 1,
status: 'paid',
period_start: '2024-01-01T00:00:00Z',
period_end: '2024-02-01T00:00:00Z',
subtotal_amount: 7900,
total_amount: 7900,
plan_name_at_billing: 'Pro Plan',
created_at: '2024-01-01T00:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockInvoices });
const result = await getInvoices();
expect(apiClient.get).toHaveBeenCalledWith('/billing/invoices/');
expect(result).toEqual(mockInvoices);
});
});
describe('getInvoice', () => {
it('fetches a single invoice by ID', async () => {
const mockInvoice: Invoice = {
id: 1,
status: 'paid',
period_start: '2024-01-01T00:00:00Z',
period_end: '2024-02-01T00:00:00Z',
subtotal_amount: 7900,
total_amount: 7900,
plan_name_at_billing: 'Pro Plan',
created_at: '2024-01-01T00:00:00Z',
lines: [
{
id: 1,
line_type: 'plan',
description: 'Pro Plan',
quantity: 1,
unit_amount: 7900,
total_amount: 7900,
},
],
};
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockInvoice });
const result = await getInvoice(1);
expect(apiClient.get).toHaveBeenCalledWith('/billing/invoices/1/');
expect(result).toEqual(mockInvoice);
});
it('returns null when invoice not found (404)', async () => {
const error = { response: { status: 404 } };
vi.mocked(apiClient.get).mockRejectedValueOnce(error);
const result = await getInvoice(999);
expect(result).toBeNull();
});
});
});

View File

@@ -0,0 +1,632 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
import {
getResources,
getBusinessUsers,
getBusinessOAuthSettings,
updateBusinessOAuthSettings,
getBusinessOAuthCredentials,
updateBusinessOAuthCredentials,
} from '../business';
import apiClient from '../client';
describe('business API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getResources', () => {
it('fetches all resources from API', async () => {
const mockResources = [
{
id: '1',
name: 'Resource 1',
type: 'STAFF',
maxConcurrentEvents: 1,
},
{
id: '2',
name: 'Resource 2',
type: 'EQUIPMENT',
maxConcurrentEvents: 3,
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResources });
const result = await getResources();
expect(apiClient.get).toHaveBeenCalledWith('/resources/');
expect(result).toEqual(mockResources);
});
it('returns empty array when no resources exist', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getResources();
expect(result).toEqual([]);
});
});
describe('getBusinessUsers', () => {
it('fetches all business users from API', async () => {
const mockUsers = [
{
id: '1',
email: 'owner@example.com',
name: 'Business Owner',
role: 'owner',
},
{
id: '2',
email: 'staff@example.com',
name: 'Staff Member',
role: 'staff',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockUsers });
const result = await getBusinessUsers();
expect(apiClient.get).toHaveBeenCalledWith('/business/users/');
expect(result).toEqual(mockUsers);
});
it('returns empty array when no users exist', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getBusinessUsers();
expect(result).toEqual([]);
});
});
describe('getBusinessOAuthSettings', () => {
it('fetches OAuth settings and transforms snake_case to camelCase', async () => {
const mockBackendResponse = {
settings: {
enabled_providers: ['google', 'microsoft'],
allow_registration: true,
auto_link_by_email: false,
use_custom_credentials: true,
},
available_providers: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Sign in with Google',
},
{
id: 'microsoft',
name: 'Microsoft',
icon: 'microsoft-icon',
description: 'Sign in with Microsoft',
},
],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBackendResponse });
const result = await getBusinessOAuthSettings();
expect(apiClient.get).toHaveBeenCalledWith('/business/oauth-settings/');
expect(result).toEqual({
settings: {
enabledProviders: ['google', 'microsoft'],
allowRegistration: true,
autoLinkByEmail: false,
useCustomCredentials: true,
},
availableProviders: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Sign in with Google',
},
{
id: 'microsoft',
name: 'Microsoft',
icon: 'microsoft-icon',
description: 'Sign in with Microsoft',
},
],
});
});
it('handles empty enabled providers array', async () => {
const mockBackendResponse = {
settings: {
enabled_providers: [],
allow_registration: false,
auto_link_by_email: false,
use_custom_credentials: false,
},
available_providers: [],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBackendResponse });
const result = await getBusinessOAuthSettings();
expect(result.settings.enabledProviders).toEqual([]);
expect(result.availableProviders).toEqual([]);
});
it('handles undefined enabled_providers by using empty array', async () => {
const mockBackendResponse = {
settings: {
allow_registration: true,
auto_link_by_email: true,
use_custom_credentials: false,
},
available_providers: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Google OAuth',
},
],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBackendResponse });
const result = await getBusinessOAuthSettings();
expect(result.settings.enabledProviders).toEqual([]);
});
it('handles undefined available_providers by using empty array', async () => {
const mockBackendResponse = {
settings: {
enabled_providers: ['google'],
allow_registration: true,
auto_link_by_email: true,
use_custom_credentials: false,
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBackendResponse });
const result = await getBusinessOAuthSettings();
expect(result.availableProviders).toEqual([]);
});
});
describe('updateBusinessOAuthSettings', () => {
it('updates OAuth settings and transforms camelCase to snake_case', async () => {
const frontendSettings = {
enabledProviders: ['google', 'microsoft'],
allowRegistration: true,
autoLinkByEmail: false,
useCustomCredentials: true,
};
const mockBackendResponse = {
settings: {
enabled_providers: ['google', 'microsoft'],
allow_registration: true,
auto_link_by_email: false,
use_custom_credentials: true,
},
available_providers: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Google OAuth',
},
],
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
const result = await updateBusinessOAuthSettings(frontendSettings);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-settings/', {
enabled_providers: ['google', 'microsoft'],
allow_registration: true,
auto_link_by_email: false,
use_custom_credentials: true,
});
expect(result).toEqual({
settings: {
enabledProviders: ['google', 'microsoft'],
allowRegistration: true,
autoLinkByEmail: false,
useCustomCredentials: true,
},
availableProviders: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Google OAuth',
},
],
});
});
it('sends only provided fields to backend', async () => {
const partialSettings = {
enabledProviders: ['google'],
};
const mockBackendResponse = {
settings: {
enabled_providers: ['google'],
allow_registration: true,
auto_link_by_email: false,
use_custom_credentials: false,
},
available_providers: [],
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
await updateBusinessOAuthSettings(partialSettings);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-settings/', {
enabled_providers: ['google'],
});
});
it('handles updating only allowRegistration', async () => {
const partialSettings = {
allowRegistration: false,
};
const mockBackendResponse = {
settings: {
enabled_providers: [],
allow_registration: false,
auto_link_by_email: true,
use_custom_credentials: false,
},
available_providers: [],
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
await updateBusinessOAuthSettings(partialSettings);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-settings/', {
allow_registration: false,
});
});
it('handles updating only autoLinkByEmail', async () => {
const partialSettings = {
autoLinkByEmail: true,
};
const mockBackendResponse = {
settings: {
enabled_providers: [],
allow_registration: false,
auto_link_by_email: true,
use_custom_credentials: false,
},
available_providers: [],
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
await updateBusinessOAuthSettings(partialSettings);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-settings/', {
auto_link_by_email: true,
});
});
it('handles updating only useCustomCredentials', async () => {
const partialSettings = {
useCustomCredentials: true,
};
const mockBackendResponse = {
settings: {
enabled_providers: [],
allow_registration: false,
auto_link_by_email: false,
use_custom_credentials: true,
},
available_providers: [],
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
await updateBusinessOAuthSettings(partialSettings);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-settings/', {
use_custom_credentials: true,
});
});
it('handles boolean false values correctly', async () => {
const settings = {
allowRegistration: false,
autoLinkByEmail: false,
useCustomCredentials: false,
};
const mockBackendResponse = {
settings: {
enabled_providers: [],
allow_registration: false,
auto_link_by_email: false,
use_custom_credentials: false,
},
available_providers: [],
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
await updateBusinessOAuthSettings(settings);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-settings/', {
allow_registration: false,
auto_link_by_email: false,
use_custom_credentials: false,
});
});
it('does not send undefined fields', async () => {
const settings = {};
const mockBackendResponse = {
settings: {
enabled_providers: [],
allow_registration: true,
auto_link_by_email: true,
use_custom_credentials: false,
},
available_providers: [],
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
await updateBusinessOAuthSettings(settings);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-settings/', {});
});
});
describe('getBusinessOAuthCredentials', () => {
it('fetches OAuth credentials from API', async () => {
const mockBackendResponse = {
credentials: {
google: {
client_id: 'google-client-id',
client_secret: 'google-secret',
has_secret: true,
},
microsoft: {
client_id: 'microsoft-client-id',
client_secret: '',
has_secret: false,
},
},
use_custom_credentials: true,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBackendResponse });
const result = await getBusinessOAuthCredentials();
expect(apiClient.get).toHaveBeenCalledWith('/business/oauth-credentials/');
expect(result).toEqual({
credentials: {
google: {
client_id: 'google-client-id',
client_secret: 'google-secret',
has_secret: true,
},
microsoft: {
client_id: 'microsoft-client-id',
client_secret: '',
has_secret: false,
},
},
useCustomCredentials: true,
});
});
it('handles empty credentials object', async () => {
const mockBackendResponse = {
credentials: {},
use_custom_credentials: false,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBackendResponse });
const result = await getBusinessOAuthCredentials();
expect(result.credentials).toEqual({});
expect(result.useCustomCredentials).toBe(false);
});
it('handles undefined credentials by using empty object', async () => {
const mockBackendResponse = {
use_custom_credentials: false,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBackendResponse });
const result = await getBusinessOAuthCredentials();
expect(result.credentials).toEqual({});
});
});
describe('updateBusinessOAuthCredentials', () => {
it('updates OAuth credentials', async () => {
const credentials = {
credentials: {
google: {
client_id: 'new-google-client-id',
client_secret: 'new-google-secret',
},
},
useCustomCredentials: true,
};
const mockBackendResponse = {
credentials: {
google: {
client_id: 'new-google-client-id',
client_secret: 'new-google-secret',
has_secret: true,
},
},
use_custom_credentials: true,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
const result = await updateBusinessOAuthCredentials(credentials);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-credentials/', {
credentials: {
google: {
client_id: 'new-google-client-id',
client_secret: 'new-google-secret',
},
},
use_custom_credentials: true,
});
expect(result).toEqual({
credentials: {
google: {
client_id: 'new-google-client-id',
client_secret: 'new-google-secret',
has_secret: true,
},
},
useCustomCredentials: true,
});
});
it('updates only credentials without useCustomCredentials', async () => {
const data = {
credentials: {
microsoft: {
client_id: 'microsoft-id',
},
},
};
const mockBackendResponse = {
credentials: {
microsoft: {
client_id: 'microsoft-id',
client_secret: '',
has_secret: false,
},
},
use_custom_credentials: false,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
await updateBusinessOAuthCredentials(data);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-credentials/', {
credentials: {
microsoft: {
client_id: 'microsoft-id',
},
},
});
});
it('updates only useCustomCredentials without credentials', async () => {
const data = {
useCustomCredentials: false,
};
const mockBackendResponse = {
credentials: {},
use_custom_credentials: false,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
await updateBusinessOAuthCredentials(data);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-credentials/', {
use_custom_credentials: false,
});
});
it('handles partial credential updates', async () => {
const data = {
credentials: {
google: {
client_id: 'updated-id',
},
microsoft: {
client_secret: 'updated-secret',
},
},
};
const mockBackendResponse = {
credentials: {
google: {
client_id: 'updated-id',
client_secret: 'existing-secret',
has_secret: true,
},
microsoft: {
client_id: 'existing-id',
client_secret: 'updated-secret',
has_secret: true,
},
},
use_custom_credentials: true,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
const result = await updateBusinessOAuthCredentials(data);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-credentials/', {
credentials: {
google: {
client_id: 'updated-id',
},
microsoft: {
client_secret: 'updated-secret',
},
},
});
expect(result.credentials.google.client_id).toBe('updated-id');
expect(result.credentials.microsoft.client_secret).toBe('updated-secret');
});
it('handles empty data object', async () => {
const data = {};
const mockBackendResponse = {
credentials: {},
use_custom_credentials: false,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
await updateBusinessOAuthCredentials(data);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-credentials/', {});
});
it('handles undefined credentials in response by using empty object', async () => {
const data = {
useCustomCredentials: true,
};
const mockBackendResponse = {
use_custom_credentials: true,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
const result = await updateBusinessOAuthCredentials(data);
expect(result.credentials).toEqual({});
});
});
});

View File

@@ -0,0 +1,183 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import axios from 'axios';
// Mock dependencies
vi.mock('../../utils/cookies', () => ({
getCookie: vi.fn(),
setCookie: vi.fn(),
deleteCookie: vi.fn(),
}));
vi.mock('../../utils/domain', () => ({
getBaseDomain: vi.fn(() => 'lvh.me'),
}));
vi.mock('../config', () => ({
API_BASE_URL: 'http://api.lvh.me:8000',
getSubdomain: vi.fn(),
}));
describe('api/client', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
});
describe('request interceptor', () => {
it('adds auth token from cookie when available', async () => {
const cookies = await import('../../utils/cookies');
const config = await import('../config');
vi.mocked(cookies.getCookie).mockReturnValue('test-token-123');
vi.mocked(config.getSubdomain).mockReturnValue(null);
// Re-import client to apply mocks
vi.resetModules();
// Mock the interceptors
const mockConfig = {
headers: {} as Record<string, string>,
};
// Simulate what the request interceptor does
const token = cookies.getCookie('access_token');
if (token) {
mockConfig.headers['Authorization'] = `Token ${token}`;
}
expect(mockConfig.headers['Authorization']).toBe('Token test-token-123');
});
it('does not add auth header when no token', async () => {
const cookies = await import('../../utils/cookies');
vi.mocked(cookies.getCookie).mockReturnValue(null);
const mockConfig = {
headers: {} as Record<string, string>,
};
const token = cookies.getCookie('access_token');
if (token) {
mockConfig.headers['Authorization'] = `Token ${token}`;
}
expect(mockConfig.headers['Authorization']).toBeUndefined();
});
it('adds business subdomain header when on business site', async () => {
const config = await import('../config');
vi.mocked(config.getSubdomain).mockReturnValue('demo');
const mockConfig = {
headers: {} as Record<string, string>,
};
const subdomain = config.getSubdomain();
if (subdomain && subdomain !== 'platform') {
mockConfig.headers['X-Business-Subdomain'] = subdomain;
}
expect(mockConfig.headers['X-Business-Subdomain']).toBe('demo');
});
it('does not add subdomain header on platform site', async () => {
const config = await import('../config');
vi.mocked(config.getSubdomain).mockReturnValue('platform');
const mockConfig = {
headers: {} as Record<string, string>,
};
const subdomain = config.getSubdomain();
if (subdomain && subdomain !== 'platform') {
mockConfig.headers['X-Business-Subdomain'] = subdomain;
}
expect(mockConfig.headers['X-Business-Subdomain']).toBeUndefined();
});
it('adds sandbox mode header when in test mode', async () => {
// Set sandbox mode in localStorage
window.localStorage.setItem('sandbox_mode', 'true');
const mockConfig = {
headers: {} as Record<string, string>,
};
// Simulate the getSandboxMode logic
let isSandbox = false;
try {
isSandbox = window.localStorage.getItem('sandbox_mode') === 'true';
} catch {
isSandbox = false;
}
if (isSandbox) {
mockConfig.headers['X-Sandbox-Mode'] = 'true';
}
expect(mockConfig.headers['X-Sandbox-Mode']).toBe('true');
});
it('does not add sandbox header when not in test mode', async () => {
localStorage.removeItem('sandbox_mode');
const mockConfig = {
headers: {} as Record<string, string>,
};
const isSandbox = localStorage.getItem('sandbox_mode') === 'true';
if (isSandbox) {
mockConfig.headers['X-Sandbox-Mode'] = 'true';
}
expect(mockConfig.headers['X-Sandbox-Mode']).toBeUndefined();
});
});
describe('getSandboxMode', () => {
it('returns false when localStorage throws', () => {
// Simulate localStorage throwing (e.g., in private browsing)
const originalGetItem = localStorage.getItem;
localStorage.getItem = () => {
throw new Error('Access denied');
};
// Function should return false on error
let result = false;
try {
result = localStorage.getItem('sandbox_mode') === 'true';
} catch {
result = false;
}
expect(result).toBe(false);
localStorage.getItem = originalGetItem;
});
it('returns false when sandbox_mode is not set', () => {
localStorage.removeItem('sandbox_mode');
const result = localStorage.getItem('sandbox_mode') === 'true';
expect(result).toBe(false);
});
it('returns true when sandbox_mode is "true"', () => {
window.localStorage.setItem('sandbox_mode', 'true');
const result = window.localStorage.getItem('sandbox_mode') === 'true';
expect(result).toBe(true);
});
it('returns false when sandbox_mode is "false"', () => {
localStorage.setItem('sandbox_mode', 'false');
const result = localStorage.getItem('sandbox_mode') === 'true';
expect(result).toBe(false);
});
});
});

View File

@@ -0,0 +1,143 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock the domain module before importing config
vi.mock('../../utils/domain', () => ({
getBaseDomain: vi.fn(),
isRootDomain: vi.fn(),
}));
// Helper to mock window.location
const mockLocation = (hostname: string, protocol = 'https:', port = '') => {
Object.defineProperty(window, 'location', {
value: {
hostname,
protocol,
port,
},
writable: true,
});
};
describe('api/config', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
// Clear any env vars
delete (import.meta as unknown as { env: Record<string, unknown> }).env.VITE_API_URL;
});
describe('getSubdomain', () => {
it('returns null for root domain', async () => {
const domain = await import('../../utils/domain');
vi.mocked(domain.isRootDomain).mockReturnValue(true);
mockLocation('lvh.me');
const { getSubdomain } = await import('../config');
expect(getSubdomain()).toBeNull();
});
it('returns subdomain for business site', async () => {
const domain = await import('../../utils/domain');
vi.mocked(domain.isRootDomain).mockReturnValue(false);
mockLocation('demo.lvh.me');
const { getSubdomain } = await import('../config');
expect(getSubdomain()).toBe('demo');
});
it('returns null for platform subdomain', async () => {
const domain = await import('../../utils/domain');
vi.mocked(domain.isRootDomain).mockReturnValue(false);
mockLocation('platform.lvh.me');
const { getSubdomain } = await import('../config');
expect(getSubdomain()).toBeNull();
});
it('returns subdomain for www', async () => {
const domain = await import('../../utils/domain');
vi.mocked(domain.isRootDomain).mockReturnValue(false);
mockLocation('www.lvh.me');
const { getSubdomain } = await import('../config');
expect(getSubdomain()).toBe('www');
});
it('returns subdomain for api', async () => {
const domain = await import('../../utils/domain');
vi.mocked(domain.isRootDomain).mockReturnValue(false);
mockLocation('api.lvh.me');
const { getSubdomain } = await import('../config');
expect(getSubdomain()).toBe('api');
});
it('handles production business subdomain', async () => {
const domain = await import('../../utils/domain');
vi.mocked(domain.isRootDomain).mockReturnValue(false);
mockLocation('acme-corp.smoothschedule.com');
const { getSubdomain } = await import('../config');
expect(getSubdomain()).toBe('acme-corp');
});
});
describe('isPlatformSite', () => {
it('returns true for platform subdomain', async () => {
mockLocation('platform.lvh.me');
const { isPlatformSite } = await import('../config');
expect(isPlatformSite()).toBe(true);
});
it('returns true for platform in production', async () => {
mockLocation('platform.smoothschedule.com');
const { isPlatformSite } = await import('../config');
expect(isPlatformSite()).toBe(true);
});
it('returns false for business subdomain', async () => {
mockLocation('demo.lvh.me');
const { isPlatformSite } = await import('../config');
expect(isPlatformSite()).toBe(false);
});
it('returns false for root domain', async () => {
mockLocation('lvh.me');
const { isPlatformSite } = await import('../config');
expect(isPlatformSite()).toBe(false);
});
});
describe('isBusinessSite', () => {
it('returns true for business subdomain', async () => {
const domain = await import('../../utils/domain');
vi.mocked(domain.isRootDomain).mockReturnValue(false);
mockLocation('demo.lvh.me');
const { isBusinessSite } = await import('../config');
expect(isBusinessSite()).toBe(true);
});
it('returns false for platform site', async () => {
const domain = await import('../../utils/domain');
vi.mocked(domain.isRootDomain).mockReturnValue(false);
mockLocation('platform.lvh.me');
const { isBusinessSite } = await import('../config');
expect(isBusinessSite()).toBe(false);
});
it('returns false for root domain', async () => {
const domain = await import('../../utils/domain');
vi.mocked(domain.isRootDomain).mockReturnValue(true);
mockLocation('lvh.me');
const { isBusinessSite } = await import('../config');
expect(isBusinessSite()).toBe(false);
});
});
});

View File

@@ -0,0 +1,267 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}));
import {
getCustomDomains,
addCustomDomain,
deleteCustomDomain,
verifyCustomDomain,
setPrimaryDomain,
} from '../customDomains';
import apiClient from '../client';
describe('customDomains API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getCustomDomains', () => {
it('fetches all custom domains for the current business', async () => {
const mockDomains = [
{
id: 1,
domain: 'example.com',
is_verified: true,
ssl_provisioned: true,
is_primary: true,
verification_token: 'token123',
dns_txt_record: 'smoothschedule-verify=token123',
dns_txt_record_name: '_smoothschedule.example.com',
created_at: '2024-01-01T00:00:00Z',
verified_at: '2024-01-02T00:00:00Z',
},
{
id: 2,
domain: 'custom.com',
is_verified: false,
ssl_provisioned: false,
is_primary: false,
verification_token: 'token456',
dns_txt_record: 'smoothschedule-verify=token456',
dns_txt_record_name: '_smoothschedule.custom.com',
created_at: '2024-01-03T00:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockDomains });
const result = await getCustomDomains();
expect(apiClient.get).toHaveBeenCalledWith('/business/domains/');
expect(result).toEqual(mockDomains);
expect(result).toHaveLength(2);
});
it('returns empty array when no domains exist', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getCustomDomains();
expect(apiClient.get).toHaveBeenCalledWith('/business/domains/');
expect(result).toEqual([]);
expect(result).toHaveLength(0);
});
});
describe('addCustomDomain', () => {
it('adds a new custom domain with lowercase and trimmed domain', async () => {
const mockDomain = {
id: 1,
domain: 'example.com',
is_verified: false,
ssl_provisioned: false,
is_primary: false,
verification_token: 'token123',
dns_txt_record: 'smoothschedule-verify=token123',
dns_txt_record_name: '_smoothschedule.example.com',
created_at: '2024-01-01T00:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockDomain });
const result = await addCustomDomain('Example.com');
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/', {
domain: 'example.com',
});
expect(result).toEqual(mockDomain);
});
it('transforms domain to lowercase before sending', async () => {
const mockDomain = {
id: 1,
domain: 'uppercase.com',
is_verified: false,
ssl_provisioned: false,
is_primary: false,
verification_token: 'token123',
dns_txt_record: 'smoothschedule-verify=token123',
dns_txt_record_name: '_smoothschedule.uppercase.com',
created_at: '2024-01-01T00:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockDomain });
await addCustomDomain('UPPERCASE.COM');
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/', {
domain: 'uppercase.com',
});
});
it('trims whitespace from domain before sending', async () => {
const mockDomain = {
id: 1,
domain: 'trimmed.com',
is_verified: false,
ssl_provisioned: false,
is_primary: false,
verification_token: 'token123',
dns_txt_record: 'smoothschedule-verify=token123',
dns_txt_record_name: '_smoothschedule.trimmed.com',
created_at: '2024-01-01T00:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockDomain });
await addCustomDomain(' trimmed.com ');
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/', {
domain: 'trimmed.com',
});
});
it('transforms domain with both uppercase and whitespace', async () => {
const mockDomain = {
id: 1,
domain: 'mixed.com',
is_verified: false,
ssl_provisioned: false,
is_primary: false,
verification_token: 'token123',
dns_txt_record: 'smoothschedule-verify=token123',
dns_txt_record_name: '_smoothschedule.mixed.com',
created_at: '2024-01-01T00:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockDomain });
await addCustomDomain(' MiXeD.COM ');
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/', {
domain: 'mixed.com',
});
});
});
describe('deleteCustomDomain', () => {
it('deletes a custom domain by ID', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await deleteCustomDomain(1);
expect(apiClient.delete).toHaveBeenCalledWith('/business/domains/1/');
});
it('returns void on successful deletion', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
const result = await deleteCustomDomain(42);
expect(result).toBeUndefined();
});
});
describe('verifyCustomDomain', () => {
it('verifies a custom domain and returns verification status', async () => {
const mockResponse = {
verified: true,
message: 'Domain verified successfully',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await verifyCustomDomain(1);
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/1/verify/');
expect(result).toEqual(mockResponse);
expect(result.verified).toBe(true);
expect(result.message).toBe('Domain verified successfully');
});
it('returns failure status when verification fails', async () => {
const mockResponse = {
verified: false,
message: 'DNS records not found. Please check your configuration.',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await verifyCustomDomain(2);
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/2/verify/');
expect(result.verified).toBe(false);
expect(result.message).toContain('DNS records not found');
});
it('handles different domain IDs correctly', async () => {
const mockResponse = {
verified: true,
message: 'Success',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
await verifyCustomDomain(999);
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/999/verify/');
});
});
describe('setPrimaryDomain', () => {
it('sets a custom domain as primary', async () => {
const mockDomain = {
id: 1,
domain: 'example.com',
is_verified: true,
ssl_provisioned: true,
is_primary: true,
verification_token: 'token123',
dns_txt_record: 'smoothschedule-verify=token123',
dns_txt_record_name: '_smoothschedule.example.com',
created_at: '2024-01-01T00:00:00Z',
verified_at: '2024-01-02T00:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockDomain });
const result = await setPrimaryDomain(1);
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/1/set-primary/');
expect(result).toEqual(mockDomain);
expect(result.is_primary).toBe(true);
});
it('returns updated domain with is_primary flag', async () => {
const mockDomain = {
id: 5,
domain: 'newprimary.com',
is_verified: true,
ssl_provisioned: true,
is_primary: true,
verification_token: 'token789',
dns_txt_record: 'smoothschedule-verify=token789',
dns_txt_record_name: '_smoothschedule.newprimary.com',
created_at: '2024-01-05T00:00:00Z',
verified_at: '2024-01-06T00:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockDomain });
const result = await setPrimaryDomain(5);
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/5/set-primary/');
expect(result.id).toBe(5);
expect(result.domain).toBe('newprimary.com');
expect(result.is_primary).toBe(true);
});
});
});

View File

@@ -0,0 +1,649 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
import {
searchDomains,
getDomainPrices,
registerDomain,
getRegisteredDomains,
getDomainRegistration,
updateNameservers,
toggleAutoRenew,
renewDomain,
syncDomain,
getSearchHistory,
DomainAvailability,
DomainPrice,
DomainRegisterRequest,
DomainRegistration,
DomainSearchHistory,
} from '../domains';
import apiClient from '../client';
describe('domains API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('searchDomains', () => {
it('searches for domains with default TLDs', async () => {
const mockResults: DomainAvailability[] = [
{
domain: 'example.com',
available: true,
price: 12.99,
premium: false,
premium_price: null,
},
{
domain: 'example.net',
available: false,
price: null,
premium: false,
premium_price: null,
},
{
domain: 'example.org',
available: true,
price: 14.99,
premium: false,
premium_price: null,
},
];
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResults });
const result = await searchDomains('example');
expect(apiClient.post).toHaveBeenCalledWith('/domains/search/search/', {
query: 'example',
tlds: ['.com', '.net', '.org'],
});
expect(result).toEqual(mockResults);
expect(result).toHaveLength(3);
});
it('searches for domains with custom TLDs', async () => {
const mockResults: DomainAvailability[] = [
{
domain: 'mybusiness.io',
available: true,
price: 39.99,
premium: false,
premium_price: null,
},
{
domain: 'mybusiness.dev',
available: true,
price: 12.99,
premium: false,
premium_price: null,
},
];
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResults });
const result = await searchDomains('mybusiness', ['.io', '.dev']);
expect(apiClient.post).toHaveBeenCalledWith('/domains/search/search/', {
query: 'mybusiness',
tlds: ['.io', '.dev'],
});
expect(result).toEqual(mockResults);
});
it('handles premium domain results', async () => {
const mockResults: DomainAvailability[] = [
{
domain: 'premium.com',
available: true,
price: 12.99,
premium: true,
premium_price: 5000.0,
},
];
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResults });
const result = await searchDomains('premium');
expect(result[0].premium).toBe(true);
expect(result[0].premium_price).toBe(5000.0);
});
});
describe('getDomainPrices', () => {
it('fetches domain prices for all TLDs', async () => {
const mockPrices: DomainPrice[] = [
{
tld: '.com',
registration: 12.99,
renewal: 14.99,
transfer: 12.99,
},
{
tld: '.net',
registration: 14.99,
renewal: 16.99,
transfer: 14.99,
},
{
tld: '.org',
registration: 14.99,
renewal: 16.99,
transfer: 14.99,
},
{
tld: '.io',
registration: 39.99,
renewal: 39.99,
transfer: 39.99,
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockPrices });
const result = await getDomainPrices();
expect(apiClient.get).toHaveBeenCalledWith('/domains/search/prices/');
expect(result).toEqual(mockPrices);
expect(result).toHaveLength(4);
});
});
describe('registerDomain', () => {
it('registers a new domain with full contact information', async () => {
const registerRequest: DomainRegisterRequest = {
domain: 'newbusiness.com',
years: 2,
whois_privacy: true,
auto_renew: true,
nameservers: ['ns1.digitalocean.com', 'ns2.digitalocean.com'],
contact: {
first_name: 'John',
last_name: 'Doe',
email: 'john@example.com',
phone: '+1.5551234567',
address: '123 Main St',
city: 'New York',
state: 'NY',
zip_code: '10001',
country: 'US',
},
auto_configure: true,
};
const mockRegistration: DomainRegistration = {
id: 1,
domain: 'newbusiness.com',
status: 'pending',
registered_at: null,
expires_at: null,
auto_renew: true,
whois_privacy: true,
purchase_price: 25.98,
renewal_price: null,
nameservers: ['ns1.digitalocean.com', 'ns2.digitalocean.com'],
days_until_expiry: null,
is_expiring_soon: false,
created_at: '2024-01-15T10:00:00Z',
registrant_first_name: 'John',
registrant_last_name: 'Doe',
registrant_email: 'john@example.com',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockRegistration });
const result = await registerDomain(registerRequest);
expect(apiClient.post).toHaveBeenCalledWith('/domains/search/register/', registerRequest);
expect(result).toEqual(mockRegistration);
expect(result.status).toBe('pending');
});
it('registers domain without optional nameservers', async () => {
const registerRequest: DomainRegisterRequest = {
domain: 'simple.com',
years: 1,
whois_privacy: false,
auto_renew: false,
contact: {
first_name: 'Jane',
last_name: 'Smith',
email: 'jane@example.com',
phone: '+1.5559876543',
address: '456 Oak Ave',
city: 'Boston',
state: 'MA',
zip_code: '02101',
country: 'US',
},
auto_configure: false,
};
const mockRegistration: DomainRegistration = {
id: 2,
domain: 'simple.com',
status: 'pending',
registered_at: null,
expires_at: null,
auto_renew: false,
whois_privacy: false,
purchase_price: 12.99,
renewal_price: null,
nameservers: [],
days_until_expiry: null,
is_expiring_soon: false,
created_at: '2024-01-15T10:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockRegistration });
const result = await registerDomain(registerRequest);
expect(result.whois_privacy).toBe(false);
expect(result.auto_renew).toBe(false);
expect(result.nameservers).toEqual([]);
});
});
describe('getRegisteredDomains', () => {
it('fetches all registered domains for current business', async () => {
const mockDomains: DomainRegistration[] = [
{
id: 1,
domain: 'business1.com',
status: 'active',
registered_at: '2023-01-15T10:00:00Z',
expires_at: '2025-01-15T10:00:00Z',
auto_renew: true,
whois_privacy: true,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: ['ns1.digitalocean.com', 'ns2.digitalocean.com'],
days_until_expiry: 365,
is_expiring_soon: false,
created_at: '2023-01-15T09:00:00Z',
},
{
id: 2,
domain: 'business2.net',
status: 'active',
registered_at: '2024-01-01T10:00:00Z',
expires_at: '2024-03-01T10:00:00Z',
auto_renew: false,
whois_privacy: false,
purchase_price: 14.99,
renewal_price: 16.99,
nameservers: ['ns1.example.com', 'ns2.example.com'],
days_until_expiry: 30,
is_expiring_soon: true,
created_at: '2024-01-01T09:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockDomains });
const result = await getRegisteredDomains();
expect(apiClient.get).toHaveBeenCalledWith('/domains/registrations/');
expect(result).toEqual(mockDomains);
expect(result).toHaveLength(2);
expect(result[1].is_expiring_soon).toBe(true);
});
it('handles empty domain list', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getRegisteredDomains();
expect(result).toEqual([]);
expect(result).toHaveLength(0);
});
});
describe('getDomainRegistration', () => {
it('fetches a single domain registration by ID', async () => {
const mockDomain: DomainRegistration = {
id: 5,
domain: 'example.com',
status: 'active',
registered_at: '2023-06-01T10:00:00Z',
expires_at: '2025-06-01T10:00:00Z',
auto_renew: true,
whois_privacy: true,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: ['ns1.digitalocean.com', 'ns2.digitalocean.com', 'ns3.digitalocean.com'],
days_until_expiry: 500,
is_expiring_soon: false,
created_at: '2023-06-01T09:30:00Z',
registrant_first_name: 'Alice',
registrant_last_name: 'Johnson',
registrant_email: 'alice@example.com',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockDomain });
const result = await getDomainRegistration(5);
expect(apiClient.get).toHaveBeenCalledWith('/domains/registrations/5/');
expect(result).toEqual(mockDomain);
expect(result.registrant_email).toBe('alice@example.com');
});
it('fetches domain with failed status', async () => {
const mockDomain: DomainRegistration = {
id: 10,
domain: 'failed.com',
status: 'failed',
registered_at: null,
expires_at: null,
auto_renew: false,
whois_privacy: false,
purchase_price: null,
renewal_price: null,
nameservers: [],
days_until_expiry: null,
is_expiring_soon: false,
created_at: '2024-01-10T10:00:00Z',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockDomain });
const result = await getDomainRegistration(10);
expect(result.status).toBe('failed');
expect(result.registered_at).toBeNull();
});
});
describe('updateNameservers', () => {
it('updates nameservers for a domain', async () => {
const nameservers = [
'ns1.customdns.com',
'ns2.customdns.com',
'ns3.customdns.com',
'ns4.customdns.com',
];
const mockUpdated: DomainRegistration = {
id: 3,
domain: 'updated.com',
status: 'active',
registered_at: '2023-01-01T10:00:00Z',
expires_at: '2024-01-01T10:00:00Z',
auto_renew: true,
whois_privacy: true,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: nameservers,
days_until_expiry: 100,
is_expiring_soon: false,
created_at: '2023-01-01T09:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUpdated });
const result = await updateNameservers(3, nameservers);
expect(apiClient.post).toHaveBeenCalledWith('/domains/registrations/3/update_nameservers/', {
nameservers: nameservers,
});
expect(result.nameservers).toEqual(nameservers);
expect(result.nameservers).toHaveLength(4);
});
it('updates to default DigitalOcean nameservers', async () => {
const nameservers = ['ns1.digitalocean.com', 'ns2.digitalocean.com', 'ns3.digitalocean.com'];
const mockUpdated: DomainRegistration = {
id: 7,
domain: 'reset.com',
status: 'active',
registered_at: '2023-01-01T10:00:00Z',
expires_at: '2024-01-01T10:00:00Z',
auto_renew: false,
whois_privacy: false,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: nameservers,
days_until_expiry: 200,
is_expiring_soon: false,
created_at: '2023-01-01T09:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUpdated });
const result = await updateNameservers(7, nameservers);
expect(result.nameservers).toEqual(nameservers);
});
});
describe('toggleAutoRenew', () => {
it('enables auto-renewal for a domain', async () => {
const mockUpdated: DomainRegistration = {
id: 4,
domain: 'autorenew.com',
status: 'active',
registered_at: '2023-01-01T10:00:00Z',
expires_at: '2024-01-01T10:00:00Z',
auto_renew: true,
whois_privacy: true,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: ['ns1.digitalocean.com', 'ns2.digitalocean.com'],
days_until_expiry: 150,
is_expiring_soon: false,
created_at: '2023-01-01T09:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUpdated });
const result = await toggleAutoRenew(4, true);
expect(apiClient.post).toHaveBeenCalledWith('/domains/registrations/4/toggle_auto_renew/', {
auto_renew: true,
});
expect(result.auto_renew).toBe(true);
});
it('disables auto-renewal for a domain', async () => {
const mockUpdated: DomainRegistration = {
id: 6,
domain: 'noautorenew.com',
status: 'active',
registered_at: '2023-01-01T10:00:00Z',
expires_at: '2024-01-01T10:00:00Z',
auto_renew: false,
whois_privacy: false,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: ['ns1.example.com', 'ns2.example.com'],
days_until_expiry: 60,
is_expiring_soon: true,
created_at: '2023-01-01T09:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUpdated });
const result = await toggleAutoRenew(6, false);
expect(apiClient.post).toHaveBeenCalledWith('/domains/registrations/6/toggle_auto_renew/', {
auto_renew: false,
});
expect(result.auto_renew).toBe(false);
});
});
describe('renewDomain', () => {
it('renews domain for 1 year (default)', async () => {
const mockRenewed: DomainRegistration = {
id: 8,
domain: 'renew.com',
status: 'active',
registered_at: '2022-01-01T10:00:00Z',
expires_at: '2025-01-01T10:00:00Z',
auto_renew: true,
whois_privacy: true,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: ['ns1.digitalocean.com', 'ns2.digitalocean.com'],
days_until_expiry: 365,
is_expiring_soon: false,
created_at: '2022-01-01T09:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockRenewed });
const result = await renewDomain(8);
expect(apiClient.post).toHaveBeenCalledWith('/domains/registrations/8/renew/', {
years: 1,
});
expect(result).toEqual(mockRenewed);
});
it('renews domain for multiple years', async () => {
const mockRenewed: DomainRegistration = {
id: 9,
domain: 'longterm.com',
status: 'active',
registered_at: '2022-01-01T10:00:00Z',
expires_at: '2027-01-01T10:00:00Z',
auto_renew: false,
whois_privacy: false,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: ['ns1.example.com', 'ns2.example.com'],
days_until_expiry: 1095,
is_expiring_soon: false,
created_at: '2022-01-01T09:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockRenewed });
const result = await renewDomain(9, 5);
expect(apiClient.post).toHaveBeenCalledWith('/domains/registrations/9/renew/', {
years: 5,
});
expect(result).toEqual(mockRenewed);
});
it('renews domain for 2 years', async () => {
const mockRenewed: DomainRegistration = {
id: 11,
domain: 'twoyears.com',
status: 'active',
registered_at: '2023-01-01T10:00:00Z',
expires_at: '2026-01-01T10:00:00Z',
auto_renew: true,
whois_privacy: true,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: ['ns1.digitalocean.com', 'ns2.digitalocean.com'],
days_until_expiry: 730,
is_expiring_soon: false,
created_at: '2023-01-01T09:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockRenewed });
const result = await renewDomain(11, 2);
expect(apiClient.post).toHaveBeenCalledWith('/domains/registrations/11/renew/', {
years: 2,
});
expect(result.expires_at).toBe('2026-01-01T10:00:00Z');
});
});
describe('syncDomain', () => {
it('syncs domain information from NameSilo', async () => {
const mockSynced: DomainRegistration = {
id: 12,
domain: 'synced.com',
status: 'active',
registered_at: '2023-05-15T10:00:00Z',
expires_at: '2024-05-15T10:00:00Z',
auto_renew: true,
whois_privacy: true,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: ['ns1.namesilo.com', 'ns2.namesilo.com'],
days_until_expiry: 120,
is_expiring_soon: false,
created_at: '2023-05-15T09:30:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockSynced });
const result = await syncDomain(12);
expect(apiClient.post).toHaveBeenCalledWith('/domains/registrations/12/sync/');
expect(result).toEqual(mockSynced);
});
it('syncs domain and updates status', async () => {
const mockSynced: DomainRegistration = {
id: 13,
domain: 'expired.com',
status: 'expired',
registered_at: '2020-01-01T10:00:00Z',
expires_at: '2023-01-01T10:00:00Z',
auto_renew: false,
whois_privacy: false,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: [],
days_until_expiry: -365,
is_expiring_soon: false,
created_at: '2020-01-01T09:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockSynced });
const result = await syncDomain(13);
expect(result.status).toBe('expired');
expect(result.days_until_expiry).toBeLessThan(0);
});
});
describe('getSearchHistory', () => {
it('fetches domain search history', async () => {
const mockHistory: DomainSearchHistory[] = [
{
id: 1,
searched_domain: 'example.com',
was_available: true,
price: 12.99,
searched_at: '2024-01-15T10:00:00Z',
},
{
id: 2,
searched_domain: 'taken.com',
was_available: false,
price: null,
searched_at: '2024-01-15T10:05:00Z',
},
{
id: 3,
searched_domain: 'premium.com',
was_available: true,
price: 5000.0,
searched_at: '2024-01-15T10:10:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockHistory });
const result = await getSearchHistory();
expect(apiClient.get).toHaveBeenCalledWith('/domains/history/');
expect(result).toEqual(mockHistory);
expect(result).toHaveLength(3);
expect(result[1].was_available).toBe(false);
expect(result[2].price).toBe(5000.0);
});
it('handles empty search history', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getSearchHistory();
expect(result).toEqual([]);
expect(result).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,877 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}));
import {
getMFAStatus,
sendPhoneVerification,
verifyPhone,
enableSMSMFA,
setupTOTP,
verifyTOTPSetup,
generateBackupCodes,
getBackupCodesStatus,
disableMFA,
sendMFALoginCode,
verifyMFALogin,
listTrustedDevices,
revokeTrustedDevice,
revokeAllTrustedDevices,
} from '../mfa';
import apiClient from '../client';
describe('MFA API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ============================================================================
// MFA Status
// ============================================================================
describe('getMFAStatus', () => {
it('fetches MFA status from API', async () => {
const mockStatus = {
mfa_enabled: true,
mfa_method: 'TOTP' as const,
methods: ['TOTP' as const, 'BACKUP' as const],
phone_last_4: '1234',
phone_verified: true,
totp_verified: true,
backup_codes_count: 8,
backup_codes_generated_at: '2024-01-01T00:00:00Z',
trusted_devices_count: 2,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
const result = await getMFAStatus();
expect(apiClient.get).toHaveBeenCalledWith('/auth/mfa/status/');
expect(result).toEqual(mockStatus);
});
it('returns status when MFA is disabled', async () => {
const mockStatus = {
mfa_enabled: false,
mfa_method: 'NONE' as const,
methods: [],
phone_last_4: null,
phone_verified: false,
totp_verified: false,
backup_codes_count: 0,
backup_codes_generated_at: null,
trusted_devices_count: 0,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
const result = await getMFAStatus();
expect(result.mfa_enabled).toBe(false);
expect(result.mfa_method).toBe('NONE');
expect(result.methods).toHaveLength(0);
});
it('returns status with both SMS and TOTP enabled', async () => {
const mockStatus = {
mfa_enabled: true,
mfa_method: 'BOTH' as const,
methods: ['SMS' as const, 'TOTP' as const, 'BACKUP' as const],
phone_last_4: '5678',
phone_verified: true,
totp_verified: true,
backup_codes_count: 10,
backup_codes_generated_at: '2024-01-15T12:00:00Z',
trusted_devices_count: 3,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
const result = await getMFAStatus();
expect(result.mfa_method).toBe('BOTH');
expect(result.methods).toContain('SMS');
expect(result.methods).toContain('TOTP');
expect(result.methods).toContain('BACKUP');
});
});
// ============================================================================
// SMS Setup
// ============================================================================
describe('sendPhoneVerification', () => {
it('sends phone verification code', async () => {
const mockResponse = {
data: {
success: true,
message: 'Verification code sent to +1234567890',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await sendPhoneVerification('+1234567890');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/phone/send/', {
phone: '+1234567890',
});
expect(result).toEqual(mockResponse.data);
expect(result.success).toBe(true);
});
it('handles different phone number formats', async () => {
const mockResponse = {
data: { success: true, message: 'Code sent' },
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
await sendPhoneVerification('555-123-4567');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/phone/send/', {
phone: '555-123-4567',
});
});
});
describe('verifyPhone', () => {
it('verifies phone with valid code', async () => {
const mockResponse = {
data: {
success: true,
message: 'Phone number verified successfully',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await verifyPhone('123456');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/phone/verify/', {
code: '123456',
});
expect(result.success).toBe(true);
});
it('handles verification failure', async () => {
const mockResponse = {
data: {
success: false,
message: 'Invalid verification code',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await verifyPhone('000000');
expect(result.success).toBe(false);
expect(result.message).toContain('Invalid');
});
});
describe('enableSMSMFA', () => {
it('enables SMS MFA successfully', async () => {
const mockResponse = {
data: {
success: true,
message: 'SMS MFA enabled successfully',
mfa_method: 'SMS',
backup_codes: ['code1', 'code2', 'code3'],
backup_codes_message: 'Save these backup codes',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await enableSMSMFA();
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/sms/enable/');
expect(result.success).toBe(true);
expect(result.mfa_method).toBe('SMS');
expect(result.backup_codes).toHaveLength(3);
});
it('enables SMS MFA without generating backup codes', async () => {
const mockResponse = {
data: {
success: true,
message: 'SMS MFA enabled',
mfa_method: 'SMS',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await enableSMSMFA();
expect(result.success).toBe(true);
expect(result.backup_codes).toBeUndefined();
});
});
// ============================================================================
// TOTP Setup (Authenticator App)
// ============================================================================
describe('setupTOTP', () => {
it('initializes TOTP setup with QR code', async () => {
const mockResponse = {
data: {
success: true,
secret: 'JBSWY3DPEHPK3PXP',
qr_code: 'data:image/png;base64,iVBORw0KGgoAAAANS...',
provisioning_uri: 'otpauth://totp/SmoothSchedule:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=SmoothSchedule',
message: 'Scan the QR code with your authenticator app',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await setupTOTP();
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/totp/setup/');
expect(result.success).toBe(true);
expect(result.secret).toBe('JBSWY3DPEHPK3PXP');
expect(result.qr_code).toContain('data:image/png');
expect(result.provisioning_uri).toContain('otpauth://totp/');
});
it('returns provisioning URI for manual entry', async () => {
const mockResponse = {
data: {
success: true,
secret: 'SECRETKEY123',
qr_code: 'data:image/png;base64,ABC...',
provisioning_uri: 'otpauth://totp/App:user@test.com?secret=SECRETKEY123',
message: 'Setup message',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await setupTOTP();
expect(result.provisioning_uri).toContain('SECRETKEY123');
});
});
describe('verifyTOTPSetup', () => {
it('verifies TOTP code and completes setup', async () => {
const mockResponse = {
data: {
success: true,
message: 'TOTP authentication enabled successfully',
mfa_method: 'TOTP',
backup_codes: ['backup1', 'backup2', 'backup3', 'backup4', 'backup5'],
backup_codes_message: 'Store these codes securely',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await verifyTOTPSetup('123456');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/totp/verify/', {
code: '123456',
});
expect(result.success).toBe(true);
expect(result.mfa_method).toBe('TOTP');
expect(result.backup_codes).toHaveLength(5);
});
it('handles invalid TOTP code', async () => {
const mockResponse = {
data: {
success: false,
message: 'Invalid TOTP code',
mfa_method: '',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await verifyTOTPSetup('000000');
expect(result.success).toBe(false);
expect(result.message).toContain('Invalid');
});
});
// ============================================================================
// Backup Codes
// ============================================================================
describe('generateBackupCodes', () => {
it('generates new backup codes', async () => {
const mockResponse = {
data: {
success: true,
backup_codes: [
'AAAA-BBBB-CCCC',
'DDDD-EEEE-FFFF',
'GGGG-HHHH-IIII',
'JJJJ-KKKK-LLLL',
'MMMM-NNNN-OOOO',
'PPPP-QQQQ-RRRR',
'SSSS-TTTT-UUUU',
'VVVV-WWWW-XXXX',
'YYYY-ZZZZ-1111',
'2222-3333-4444',
],
message: 'Backup codes generated successfully',
warning: 'Previous backup codes have been invalidated',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await generateBackupCodes();
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/backup-codes/');
expect(result.success).toBe(true);
expect(result.backup_codes).toHaveLength(10);
expect(result.warning).toContain('invalidated');
});
it('generates codes in correct format', async () => {
const mockResponse = {
data: {
success: true,
backup_codes: ['CODE-1234-ABCD', 'CODE-5678-EFGH'],
message: 'Generated',
warning: 'Old codes invalidated',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await generateBackupCodes();
result.backup_codes.forEach(code => {
expect(code).toMatch(/^[A-Z0-9]+-[A-Z0-9]+-[A-Z0-9]+$/);
});
});
});
describe('getBackupCodesStatus', () => {
it('returns backup codes status', async () => {
const mockResponse = {
data: {
count: 8,
generated_at: '2024-01-15T10:30:00Z',
},
};
vi.mocked(apiClient.get).mockResolvedValue(mockResponse);
const result = await getBackupCodesStatus();
expect(apiClient.get).toHaveBeenCalledWith('/auth/mfa/backup-codes/status/');
expect(result.count).toBe(8);
expect(result.generated_at).toBe('2024-01-15T10:30:00Z');
});
it('returns status when no codes exist', async () => {
const mockResponse = {
data: {
count: 0,
generated_at: null,
},
};
vi.mocked(apiClient.get).mockResolvedValue(mockResponse);
const result = await getBackupCodesStatus();
expect(result.count).toBe(0);
expect(result.generated_at).toBeNull();
});
});
// ============================================================================
// Disable MFA
// ============================================================================
describe('disableMFA', () => {
it('disables MFA with password', async () => {
const mockResponse = {
data: {
success: true,
message: 'MFA has been disabled',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await disableMFA({ password: 'mypassword123' });
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/disable/', {
password: 'mypassword123',
});
expect(result.success).toBe(true);
expect(result.message).toContain('disabled');
});
it('disables MFA with valid MFA code', async () => {
const mockResponse = {
data: {
success: true,
message: 'MFA disabled successfully',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await disableMFA({ mfa_code: '123456' });
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/disable/', {
mfa_code: '123456',
});
expect(result.success).toBe(true);
});
it('handles both password and MFA code', async () => {
const mockResponse = {
data: {
success: true,
message: 'MFA disabled',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
await disableMFA({ password: 'pass', mfa_code: '654321' });
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/disable/', {
password: 'pass',
mfa_code: '654321',
});
});
it('handles incorrect credentials', async () => {
const mockResponse = {
data: {
success: false,
message: 'Invalid password or MFA code',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await disableMFA({ password: 'wrongpass' });
expect(result.success).toBe(false);
expect(result.message).toContain('Invalid');
});
});
// ============================================================================
// MFA Login Challenge
// ============================================================================
describe('sendMFALoginCode', () => {
it('sends SMS code for login', async () => {
const mockResponse = {
data: {
success: true,
message: 'Verification code sent to your phone',
method: 'SMS',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await sendMFALoginCode(42, 'SMS');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/send/', {
user_id: 42,
method: 'SMS',
});
expect(result.success).toBe(true);
expect(result.method).toBe('SMS');
});
it('defaults to SMS method when not specified', async () => {
const mockResponse = {
data: {
success: true,
message: 'Code sent',
method: 'SMS',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
await sendMFALoginCode(123);
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/send/', {
user_id: 123,
method: 'SMS',
});
});
it('sends TOTP method (no actual code sent)', async () => {
const mockResponse = {
data: {
success: true,
message: 'Use your authenticator app',
method: 'TOTP',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await sendMFALoginCode(99, 'TOTP');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/send/', {
user_id: 99,
method: 'TOTP',
});
expect(result.method).toBe('TOTP');
});
});
describe('verifyMFALogin', () => {
it('verifies MFA code and completes login', async () => {
const mockResponse = {
data: {
success: true,
access: 'access-token-xyz',
refresh: 'refresh-token-abc',
user: {
id: 42,
email: 'user@example.com',
username: 'john_doe',
first_name: 'John',
last_name: 'Doe',
full_name: 'John Doe',
role: 'owner',
business_subdomain: 'business1',
mfa_enabled: true,
},
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await verifyMFALogin(42, '123456', 'TOTP', false);
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/verify/', {
user_id: 42,
code: '123456',
method: 'TOTP',
trust_device: false,
});
expect(result.success).toBe(true);
expect(result.access).toBe('access-token-xyz');
expect(result.user.email).toBe('user@example.com');
});
it('verifies SMS code', async () => {
const mockResponse = {
data: {
success: true,
access: 'token1',
refresh: 'token2',
user: {
id: 1,
email: 'test@test.com',
username: 'test',
first_name: 'Test',
last_name: 'User',
full_name: 'Test User',
role: 'staff',
business_subdomain: null,
mfa_enabled: true,
},
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await verifyMFALogin(1, '654321', 'SMS');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/verify/', {
user_id: 1,
code: '654321',
method: 'SMS',
trust_device: false,
});
expect(result.success).toBe(true);
});
it('verifies backup code', async () => {
const mockResponse = {
data: {
success: true,
access: 'token-a',
refresh: 'token-b',
user: {
id: 5,
email: 'backup@test.com',
username: 'backup_user',
first_name: 'Backup',
last_name: 'Test',
full_name: 'Backup Test',
role: 'manager',
business_subdomain: 'company',
mfa_enabled: true,
},
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await verifyMFALogin(5, 'AAAA-BBBB-CCCC', 'BACKUP');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/verify/', {
user_id: 5,
code: 'AAAA-BBBB-CCCC',
method: 'BACKUP',
trust_device: false,
});
expect(result.success).toBe(true);
});
it('trusts device after successful verification', async () => {
const mockResponse = {
data: {
success: true,
access: 'trusted-access',
refresh: 'trusted-refresh',
user: {
id: 10,
email: 'trusted@example.com',
username: 'trusted',
first_name: 'Trusted',
last_name: 'User',
full_name: 'Trusted User',
role: 'owner',
business_subdomain: 'trusted-biz',
mfa_enabled: true,
},
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
await verifyMFALogin(10, '999888', 'TOTP', true);
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/verify/', {
user_id: 10,
code: '999888',
method: 'TOTP',
trust_device: true,
});
});
it('defaults trustDevice to false', async () => {
const mockResponse = {
data: {
success: true,
access: 'a',
refresh: 'b',
user: {
id: 1,
email: 'e@e.com',
username: 'u',
first_name: 'F',
last_name: 'L',
full_name: 'F L',
role: 'staff',
business_subdomain: null,
mfa_enabled: true,
},
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
await verifyMFALogin(1, '111111', 'SMS');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/verify/', {
user_id: 1,
code: '111111',
method: 'SMS',
trust_device: false,
});
});
it('handles invalid MFA code', async () => {
const mockResponse = {
data: {
success: false,
access: '',
refresh: '',
user: {
id: 0,
email: '',
username: '',
first_name: '',
last_name: '',
full_name: '',
role: '',
business_subdomain: null,
mfa_enabled: false,
},
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await verifyMFALogin(1, 'invalid', 'TOTP');
expect(result.success).toBe(false);
});
});
// ============================================================================
// Trusted Devices
// ============================================================================
describe('listTrustedDevices', () => {
it('lists all trusted devices', async () => {
const mockDevices = {
devices: [
{
id: 1,
name: 'Chrome on Windows',
ip_address: '192.168.1.100',
created_at: '2024-01-01T10:00:00Z',
last_used_at: '2024-01-15T14:30:00Z',
expires_at: '2024-02-01T10:00:00Z',
is_current: true,
},
{
id: 2,
name: 'Safari on iPhone',
ip_address: '192.168.1.101',
created_at: '2024-01-05T12:00:00Z',
last_used_at: '2024-01-14T09:15:00Z',
expires_at: '2024-02-05T12:00:00Z',
is_current: false,
},
],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockDevices });
const result = await listTrustedDevices();
expect(apiClient.get).toHaveBeenCalledWith('/auth/mfa/devices/');
expect(result.devices).toHaveLength(2);
expect(result.devices[0].is_current).toBe(true);
expect(result.devices[1].name).toBe('Safari on iPhone');
});
it('returns empty list when no devices', async () => {
const mockDevices = { devices: [] };
vi.mocked(apiClient.get).mockResolvedValue({ data: mockDevices });
const result = await listTrustedDevices();
expect(result.devices).toHaveLength(0);
});
it('includes device metadata', async () => {
const mockDevices = {
devices: [
{
id: 99,
name: 'Firefox on Linux',
ip_address: '10.0.0.50',
created_at: '2024-01-10T08:00:00Z',
last_used_at: '2024-01-16T16:45:00Z',
expires_at: '2024-02-10T08:00:00Z',
is_current: false,
},
],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockDevices });
const result = await listTrustedDevices();
const device = result.devices[0];
expect(device.id).toBe(99);
expect(device.name).toBe('Firefox on Linux');
expect(device.ip_address).toBe('10.0.0.50');
expect(device.created_at).toBeTruthy();
expect(device.last_used_at).toBeTruthy();
expect(device.expires_at).toBeTruthy();
});
});
describe('revokeTrustedDevice', () => {
it('revokes a specific device', async () => {
const mockResponse = {
data: {
success: true,
message: 'Device revoked successfully',
},
};
vi.mocked(apiClient.delete).mockResolvedValue(mockResponse);
const result = await revokeTrustedDevice(42);
expect(apiClient.delete).toHaveBeenCalledWith('/auth/mfa/devices/42/');
expect(result.success).toBe(true);
expect(result.message).toContain('revoked');
});
it('handles different device IDs', async () => {
const mockResponse = {
data: { success: true, message: 'Revoked' },
};
vi.mocked(apiClient.delete).mockResolvedValue(mockResponse);
await revokeTrustedDevice(999);
expect(apiClient.delete).toHaveBeenCalledWith('/auth/mfa/devices/999/');
});
it('handles device not found', async () => {
const mockResponse = {
data: {
success: false,
message: 'Device not found',
},
};
vi.mocked(apiClient.delete).mockResolvedValue(mockResponse);
const result = await revokeTrustedDevice(0);
expect(result.success).toBe(false);
expect(result.message).toContain('not found');
});
});
describe('revokeAllTrustedDevices', () => {
it('revokes all trusted devices', async () => {
const mockResponse = {
data: {
success: true,
message: 'All devices revoked successfully',
count: 5,
},
};
vi.mocked(apiClient.delete).mockResolvedValue(mockResponse);
const result = await revokeAllTrustedDevices();
expect(apiClient.delete).toHaveBeenCalledWith('/auth/mfa/devices/revoke-all/');
expect(result.success).toBe(true);
expect(result.count).toBe(5);
expect(result.message).toContain('All devices revoked');
});
it('returns zero count when no devices to revoke', async () => {
const mockResponse = {
data: {
success: true,
message: 'No devices to revoke',
count: 0,
},
};
vi.mocked(apiClient.delete).mockResolvedValue(mockResponse);
const result = await revokeAllTrustedDevices();
expect(result.count).toBe(0);
});
it('includes count of revoked devices', async () => {
const mockResponse = {
data: {
success: true,
message: 'Devices revoked',
count: 12,
},
};
vi.mocked(apiClient.delete).mockResolvedValue(mockResponse);
const result = await revokeAllTrustedDevices();
expect(result.count).toBe(12);
expect(result.success).toBe(true);
});
});
});

View File

@@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}));
import {
getNotifications,
getUnreadCount,
markNotificationRead,
markAllNotificationsRead,
clearAllNotifications,
} from '../notifications';
import apiClient from '../client';
describe('notifications API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getNotifications', () => {
it('fetches all notifications without params', async () => {
const mockNotifications = [
{ id: 1, verb: 'created', read: false, timestamp: '2024-01-01T00:00:00Z' },
{ id: 2, verb: 'updated', read: true, timestamp: '2024-01-02T00:00:00Z' },
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockNotifications });
const result = await getNotifications();
expect(apiClient.get).toHaveBeenCalledWith('/notifications/');
expect(result).toEqual(mockNotifications);
});
it('applies read filter', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getNotifications({ read: false });
expect(apiClient.get).toHaveBeenCalledWith('/notifications/?read=false');
});
it('applies limit parameter', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getNotifications({ limit: 10 });
expect(apiClient.get).toHaveBeenCalledWith('/notifications/?limit=10');
});
it('applies multiple parameters', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getNotifications({ read: true, limit: 5 });
expect(apiClient.get).toHaveBeenCalledWith('/notifications/?read=true&limit=5');
});
});
describe('getUnreadCount', () => {
it('returns unread count', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: { count: 5 } });
const result = await getUnreadCount();
expect(apiClient.get).toHaveBeenCalledWith('/notifications/unread_count/');
expect(result).toBe(5);
});
it('returns 0 when no unread notifications', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: { count: 0 } });
const result = await getUnreadCount();
expect(result).toBe(0);
});
});
describe('markNotificationRead', () => {
it('marks single notification as read', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await markNotificationRead(42);
expect(apiClient.post).toHaveBeenCalledWith('/notifications/42/mark_read/');
});
});
describe('markAllNotificationsRead', () => {
it('marks all notifications as read', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await markAllNotificationsRead();
expect(apiClient.post).toHaveBeenCalledWith('/notifications/mark_all_read/');
});
});
describe('clearAllNotifications', () => {
it('clears all read notifications', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await clearAllNotifications();
expect(apiClient.delete).toHaveBeenCalledWith('/notifications/clear_all/');
});
});
});

View File

@@ -0,0 +1,441 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}));
import {
getOAuthProviders,
initiateOAuth,
handleOAuthCallback,
getOAuthConnections,
disconnectOAuth,
} from '../oauth';
import apiClient from '../client';
describe('oauth API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getOAuthProviders', () => {
it('fetches list of enabled OAuth providers', async () => {
const mockProviders = [
{
name: 'google',
display_name: 'Google',
icon: 'google-icon.svg',
},
{
name: 'microsoft',
display_name: 'Microsoft',
icon: 'microsoft-icon.svg',
},
{
name: 'github',
display_name: 'GitHub',
icon: 'github-icon.svg',
},
];
vi.mocked(apiClient.get).mockResolvedValue({
data: { providers: mockProviders },
});
const result = await getOAuthProviders();
expect(apiClient.get).toHaveBeenCalledWith('/auth/oauth/providers/');
expect(result).toEqual(mockProviders);
});
it('returns empty array when no providers enabled', async () => {
vi.mocked(apiClient.get).mockResolvedValue({
data: { providers: [] },
});
const result = await getOAuthProviders();
expect(result).toEqual([]);
});
it('extracts providers from nested response', async () => {
const mockProviders = [
{
name: 'google',
display_name: 'Google',
icon: 'google-icon.svg',
},
];
vi.mocked(apiClient.get).mockResolvedValue({
data: { providers: mockProviders },
});
const result = await getOAuthProviders();
// Verify it returns response.data.providers, not response.data
expect(result).toEqual(mockProviders);
expect(Array.isArray(result)).toBe(true);
});
});
describe('initiateOAuth', () => {
it('initiates OAuth flow for Google', async () => {
const mockResponse = {
authorization_url: 'https://accounts.google.com/o/oauth2/auth?client_id=123&redirect_uri=...',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponse });
const result = await initiateOAuth('google');
expect(apiClient.get).toHaveBeenCalledWith('/auth/oauth/google/authorize/');
expect(result).toEqual(mockResponse);
expect(result.authorization_url).toContain('accounts.google.com');
});
it('initiates OAuth flow for Microsoft', async () => {
const mockResponse = {
authorization_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=...',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponse });
const result = await initiateOAuth('microsoft');
expect(apiClient.get).toHaveBeenCalledWith('/auth/oauth/microsoft/authorize/');
expect(result).toEqual(mockResponse);
});
it('initiates OAuth flow for GitHub', async () => {
const mockResponse = {
authorization_url: 'https://github.com/login/oauth/authorize?client_id=xyz&scope=...',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponse });
const result = await initiateOAuth('github');
expect(apiClient.get).toHaveBeenCalledWith('/auth/oauth/github/authorize/');
expect(result.authorization_url).toContain('github.com');
});
it('includes state parameter in authorization URL', async () => {
const mockResponse = {
authorization_url: 'https://provider.com/auth?state=random-state-token',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponse });
const result = await initiateOAuth('google');
expect(result.authorization_url).toContain('state=');
});
});
describe('handleOAuthCallback', () => {
it('exchanges authorization code for tokens', async () => {
const mockResponse = {
access: 'access-token-123',
refresh: 'refresh-token-456',
user: {
id: 1,
username: 'johndoe',
email: 'john@example.com',
name: 'John Doe',
role: 'owner',
is_staff: false,
is_superuser: false,
},
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await handleOAuthCallback('google', 'auth-code-xyz', 'state-token-abc');
expect(apiClient.post).toHaveBeenCalledWith('/auth/oauth/google/callback/', {
code: 'auth-code-xyz',
state: 'state-token-abc',
});
expect(result).toEqual(mockResponse);
expect(result.access).toBe('access-token-123');
expect(result.refresh).toBe('refresh-token-456');
expect(result.user.email).toBe('john@example.com');
});
it('handles callback with business user', async () => {
const mockResponse = {
access: 'access-token',
refresh: 'refresh-token',
user: {
id: 2,
username: 'staffmember',
email: 'staff@business.com',
name: 'Staff Member',
role: 'staff',
is_staff: true,
is_superuser: false,
business: 5,
business_name: 'My Business',
business_subdomain: 'mybiz',
},
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await handleOAuthCallback('microsoft', 'code-123', 'state-456');
expect(result.user.business).toBe(5);
expect(result.user.business_name).toBe('My Business');
expect(result.user.business_subdomain).toBe('mybiz');
});
it('handles callback with avatar URL', async () => {
const mockResponse = {
access: 'access-token',
refresh: 'refresh-token',
user: {
id: 3,
username: 'user',
email: 'user@example.com',
name: 'User Name',
role: 'customer',
avatar_url: 'https://avatar.com/user.jpg',
is_staff: false,
is_superuser: false,
},
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await handleOAuthCallback('github', 'code-abc', 'state-def');
expect(result.user.avatar_url).toBe('https://avatar.com/user.jpg');
});
it('handles superuser login via OAuth', async () => {
const mockResponse = {
access: 'admin-access-token',
refresh: 'admin-refresh-token',
user: {
id: 1,
username: 'admin',
email: 'admin@platform.com',
name: 'Platform Admin',
role: 'superuser',
is_staff: true,
is_superuser: true,
},
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await handleOAuthCallback('google', 'admin-code', 'admin-state');
expect(result.user.is_superuser).toBe(true);
expect(result.user.role).toBe('superuser');
});
it('sends correct data for different providers', async () => {
vi.mocked(apiClient.post).mockResolvedValue({
data: {
access: 'token',
refresh: 'token',
user: { id: 1, email: 'test@test.com', name: 'Test', role: 'owner', is_staff: false, is_superuser: false, username: 'test' },
},
});
await handleOAuthCallback('github', 'code-1', 'state-1');
expect(apiClient.post).toHaveBeenCalledWith('/auth/oauth/github/callback/', {
code: 'code-1',
state: 'state-1',
});
await handleOAuthCallback('microsoft', 'code-2', 'state-2');
expect(apiClient.post).toHaveBeenCalledWith('/auth/oauth/microsoft/callback/', {
code: 'code-2',
state: 'state-2',
});
});
});
describe('getOAuthConnections', () => {
it('fetches list of connected OAuth accounts', async () => {
const mockConnections = [
{
id: 'conn-1',
provider: 'google',
provider_user_id: 'google-user-123',
email: 'user@gmail.com',
connected_at: '2024-01-15T10:30:00Z',
},
{
id: 'conn-2',
provider: 'microsoft',
provider_user_id: 'ms-user-456',
email: 'user@outlook.com',
connected_at: '2024-02-20T14:45:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({
data: { connections: mockConnections },
});
const result = await getOAuthConnections();
expect(apiClient.get).toHaveBeenCalledWith('/auth/oauth/connections/');
expect(result).toEqual(mockConnections);
expect(result).toHaveLength(2);
});
it('returns empty array when no connections exist', async () => {
vi.mocked(apiClient.get).mockResolvedValue({
data: { connections: [] },
});
const result = await getOAuthConnections();
expect(result).toEqual([]);
});
it('extracts connections from nested response', async () => {
const mockConnections = [
{
id: 'conn-1',
provider: 'github',
provider_user_id: 'github-123',
connected_at: '2024-03-01T09:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({
data: { connections: mockConnections },
});
const result = await getOAuthConnections();
// Verify it returns response.data.connections, not response.data
expect(result).toEqual(mockConnections);
expect(Array.isArray(result)).toBe(true);
});
it('handles connections without email field', async () => {
const mockConnections = [
{
id: 'conn-1',
provider: 'github',
provider_user_id: 'github-user-789',
connected_at: '2024-04-10T12:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({
data: { connections: mockConnections },
});
const result = await getOAuthConnections();
expect(result[0].email).toBeUndefined();
expect(result[0].provider).toBe('github');
});
it('handles multiple connections from same provider', async () => {
const mockConnections = [
{
id: 'conn-1',
provider: 'google',
provider_user_id: 'google-user-1',
email: 'work@gmail.com',
connected_at: '2024-01-01T00:00:00Z',
},
{
id: 'conn-2',
provider: 'google',
provider_user_id: 'google-user-2',
email: 'personal@gmail.com',
connected_at: '2024-01-02T00:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({
data: { connections: mockConnections },
});
const result = await getOAuthConnections();
expect(result).toHaveLength(2);
expect(result.filter((c) => c.provider === 'google')).toHaveLength(2);
});
});
describe('disconnectOAuth', () => {
it('disconnects Google OAuth account', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await disconnectOAuth('google');
expect(apiClient.delete).toHaveBeenCalledWith('/auth/oauth/connections/google/');
});
it('disconnects Microsoft OAuth account', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await disconnectOAuth('microsoft');
expect(apiClient.delete).toHaveBeenCalledWith('/auth/oauth/connections/microsoft/');
});
it('disconnects GitHub OAuth account', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await disconnectOAuth('github');
expect(apiClient.delete).toHaveBeenCalledWith('/auth/oauth/connections/github/');
});
it('returns void on successful disconnect', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
const result = await disconnectOAuth('google');
expect(result).toBeUndefined();
});
it('handles disconnect for custom provider', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await disconnectOAuth('custom-provider');
expect(apiClient.delete).toHaveBeenCalledWith('/auth/oauth/connections/custom-provider/');
});
});
describe('error handling', () => {
it('propagates errors from getOAuthProviders', async () => {
const error = new Error('Network error');
vi.mocked(apiClient.get).mockRejectedValue(error);
await expect(getOAuthProviders()).rejects.toThrow('Network error');
});
it('propagates errors from initiateOAuth', async () => {
const error = new Error('Provider not configured');
vi.mocked(apiClient.get).mockRejectedValue(error);
await expect(initiateOAuth('google')).rejects.toThrow('Provider not configured');
});
it('propagates errors from handleOAuthCallback', async () => {
const error = new Error('Invalid authorization code');
vi.mocked(apiClient.post).mockRejectedValue(error);
await expect(handleOAuthCallback('google', 'bad-code', 'state')).rejects.toThrow('Invalid authorization code');
});
it('propagates errors from getOAuthConnections', async () => {
const error = new Error('Unauthorized');
vi.mocked(apiClient.get).mockRejectedValue(error);
await expect(getOAuthConnections()).rejects.toThrow('Unauthorized');
});
it('propagates errors from disconnectOAuth', async () => {
const error = new Error('Connection not found');
vi.mocked(apiClient.delete).mockRejectedValue(error);
await expect(disconnectOAuth('google')).rejects.toThrow('Connection not found');
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,989 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
import {
getBusinesses,
updateBusiness,
createBusiness,
deleteBusiness,
getUsers,
getBusinessUsers,
verifyUserEmail,
getTenantInvitations,
createTenantInvitation,
resendTenantInvitation,
cancelTenantInvitation,
getInvitationByToken,
acceptInvitation,
type PlatformBusiness,
type PlatformBusinessUpdate,
type PlatformBusinessCreate,
type PlatformUser,
type TenantInvitation,
type TenantInvitationCreate,
type TenantInvitationDetail,
type TenantInvitationAccept,
} from '../platform';
import apiClient from '../client';
describe('platform API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ============================================================================
// Business Management
// ============================================================================
describe('getBusinesses', () => {
it('fetches all businesses from API', async () => {
const mockBusinesses: PlatformBusiness[] = [
{
id: 1,
name: 'Acme Corp',
subdomain: 'acme',
tier: 'PROFESSIONAL',
is_active: true,
created_on: '2025-01-01T00:00:00Z',
user_count: 5,
owner: {
id: 10,
username: 'john',
full_name: 'John Doe',
email: 'john@acme.com',
role: 'owner',
email_verified: true,
},
max_users: 20,
max_resources: 50,
contact_email: 'contact@acme.com',
phone: '555-1234',
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: true,
},
{
id: 2,
name: 'Beta LLC',
subdomain: 'beta',
tier: 'STARTER',
is_active: true,
created_on: '2025-01-02T00:00:00Z',
user_count: 2,
owner: null,
max_users: 5,
max_resources: 10,
can_manage_oauth_credentials: false,
can_accept_payments: false,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: false,
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBusinesses });
const result = await getBusinesses();
expect(apiClient.get).toHaveBeenCalledWith('/platform/businesses/');
expect(result).toEqual(mockBusinesses);
expect(result).toHaveLength(2);
expect(result[0].name).toBe('Acme Corp');
expect(result[1].owner).toBeNull();
});
it('returns empty array when no businesses exist', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getBusinesses();
expect(result).toEqual([]);
});
});
describe('updateBusiness', () => {
it('updates a business with full data', async () => {
const businessId = 1;
const updateData: PlatformBusinessUpdate = {
name: 'Updated Name',
is_active: false,
subscription_tier: 'ENTERPRISE',
max_users: 100,
max_resources: 500,
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: true,
can_white_label: true,
can_api_access: true,
};
const mockResponse: PlatformBusiness = {
id: 1,
name: 'Updated Name',
subdomain: 'acme',
tier: 'ENTERPRISE',
is_active: false,
created_on: '2025-01-01T00:00:00Z',
user_count: 5,
owner: null,
max_users: 100,
max_resources: 500,
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: true,
can_white_label: true,
can_api_access: true,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
const result = await updateBusiness(businessId, updateData);
expect(apiClient.patch).toHaveBeenCalledWith(
'/platform/businesses/1/',
updateData
);
expect(result).toEqual(mockResponse);
expect(result.name).toBe('Updated Name');
expect(result.is_active).toBe(false);
});
it('updates a business with partial data', async () => {
const businessId = 2;
const updateData: PlatformBusinessUpdate = {
is_active: true,
};
const mockResponse: PlatformBusiness = {
id: 2,
name: 'Beta LLC',
subdomain: 'beta',
tier: 'STARTER',
is_active: true,
created_on: '2025-01-02T00:00:00Z',
user_count: 2,
owner: null,
max_users: 5,
max_resources: 10,
can_manage_oauth_credentials: false,
can_accept_payments: false,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: false,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
const result = await updateBusiness(businessId, updateData);
expect(apiClient.patch).toHaveBeenCalledWith(
'/platform/businesses/2/',
updateData
);
expect(result.is_active).toBe(true);
});
it('updates only specific permissions', async () => {
const businessId = 3;
const updateData: PlatformBusinessUpdate = {
can_accept_payments: true,
can_api_access: true,
};
const mockResponse: PlatformBusiness = {
id: 3,
name: 'Gamma Inc',
subdomain: 'gamma',
tier: 'PROFESSIONAL',
is_active: true,
created_on: '2025-01-03T00:00:00Z',
user_count: 10,
owner: null,
max_users: 20,
max_resources: 50,
can_manage_oauth_credentials: false,
can_accept_payments: true,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: true,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
await updateBusiness(businessId, updateData);
expect(apiClient.patch).toHaveBeenCalledWith(
'/platform/businesses/3/',
updateData
);
});
});
describe('createBusiness', () => {
it('creates a business with minimal data', async () => {
const createData: PlatformBusinessCreate = {
name: 'New Business',
subdomain: 'newbiz',
};
const mockResponse: PlatformBusiness = {
id: 10,
name: 'New Business',
subdomain: 'newbiz',
tier: 'FREE',
is_active: true,
created_on: '2025-01-15T00:00:00Z',
user_count: 0,
owner: null,
max_users: 3,
max_resources: 5,
can_manage_oauth_credentials: false,
can_accept_payments: false,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: false,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await createBusiness(createData);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/businesses/',
createData
);
expect(result).toEqual(mockResponse);
expect(result.id).toBe(10);
expect(result.subdomain).toBe('newbiz');
});
it('creates a business with full data including owner', async () => {
const createData: PlatformBusinessCreate = {
name: 'Premium Business',
subdomain: 'premium',
subscription_tier: 'ENTERPRISE',
is_active: true,
max_users: 100,
max_resources: 500,
contact_email: 'contact@premium.com',
phone: '555-9999',
can_manage_oauth_credentials: true,
owner_email: 'owner@premium.com',
owner_name: 'Jane Smith',
owner_password: 'secure-password',
};
const mockResponse: PlatformBusiness = {
id: 11,
name: 'Premium Business',
subdomain: 'premium',
tier: 'ENTERPRISE',
is_active: true,
created_on: '2025-01-15T10:00:00Z',
user_count: 1,
owner: {
id: 20,
username: 'owner@premium.com',
full_name: 'Jane Smith',
email: 'owner@premium.com',
role: 'owner',
email_verified: false,
},
max_users: 100,
max_resources: 500,
contact_email: 'contact@premium.com',
phone: '555-9999',
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: true,
can_white_label: true,
can_api_access: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await createBusiness(createData);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/businesses/',
createData
);
expect(result.owner).not.toBeNull();
expect(result.owner?.email).toBe('owner@premium.com');
});
it('creates a business with custom tier and limits', async () => {
const createData: PlatformBusinessCreate = {
name: 'Custom Business',
subdomain: 'custom',
subscription_tier: 'PROFESSIONAL',
max_users: 50,
max_resources: 100,
};
const mockResponse: PlatformBusiness = {
id: 12,
name: 'Custom Business',
subdomain: 'custom',
tier: 'PROFESSIONAL',
is_active: true,
created_on: '2025-01-15T12:00:00Z',
user_count: 0,
owner: null,
max_users: 50,
max_resources: 100,
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await createBusiness(createData);
expect(result.max_users).toBe(50);
expect(result.max_resources).toBe(100);
});
});
describe('deleteBusiness', () => {
it('deletes a business by ID', async () => {
const businessId = 5;
vi.mocked(apiClient.delete).mockResolvedValue({});
await deleteBusiness(businessId);
expect(apiClient.delete).toHaveBeenCalledWith('/platform/businesses/5/');
});
it('handles deletion with no response data', async () => {
const businessId = 10;
vi.mocked(apiClient.delete).mockResolvedValue({ data: undefined });
const result = await deleteBusiness(businessId);
expect(apiClient.delete).toHaveBeenCalledWith('/platform/businesses/10/');
expect(result).toBeUndefined();
});
});
// ============================================================================
// User Management
// ============================================================================
describe('getUsers', () => {
it('fetches all users from API', async () => {
const mockUsers: PlatformUser[] = [
{
id: 1,
email: 'admin@platform.com',
username: 'admin',
name: 'Platform Admin',
role: 'superuser',
is_active: true,
is_staff: true,
is_superuser: true,
email_verified: true,
business: null,
date_joined: '2024-01-01T00:00:00Z',
last_login: '2025-01-15T10:00:00Z',
},
{
id: 2,
email: 'user@acme.com',
username: 'user1',
name: 'Acme User',
role: 'staff',
is_active: true,
is_staff: false,
is_superuser: false,
email_verified: true,
business: 1,
business_name: 'Acme Corp',
business_subdomain: 'acme',
date_joined: '2024-06-01T00:00:00Z',
last_login: '2025-01-14T15:30:00Z',
},
{
id: 3,
email: 'inactive@example.com',
username: 'inactive',
is_active: false,
is_staff: false,
is_superuser: false,
email_verified: false,
business: null,
date_joined: '2024-03-15T00:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockUsers });
const result = await getUsers();
expect(apiClient.get).toHaveBeenCalledWith('/platform/users/');
expect(result).toEqual(mockUsers);
expect(result).toHaveLength(3);
expect(result[0].is_superuser).toBe(true);
expect(result[1].business_name).toBe('Acme Corp');
expect(result[2].is_active).toBe(false);
});
it('returns empty array when no users exist', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getUsers();
expect(result).toEqual([]);
});
});
describe('getBusinessUsers', () => {
it('fetches users for a specific business', async () => {
const businessId = 1;
const mockUsers: PlatformUser[] = [
{
id: 10,
email: 'owner@acme.com',
username: 'owner',
name: 'John Doe',
role: 'owner',
is_active: true,
is_staff: false,
is_superuser: false,
email_verified: true,
business: 1,
business_name: 'Acme Corp',
business_subdomain: 'acme',
date_joined: '2024-01-01T00:00:00Z',
last_login: '2025-01-15T09:00:00Z',
},
{
id: 11,
email: 'staff@acme.com',
username: 'staff1',
name: 'Jane Smith',
role: 'staff',
is_active: true,
is_staff: false,
is_superuser: false,
email_verified: true,
business: 1,
business_name: 'Acme Corp',
business_subdomain: 'acme',
date_joined: '2024-03-01T00:00:00Z',
last_login: '2025-01-14T16:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockUsers });
const result = await getBusinessUsers(businessId);
expect(apiClient.get).toHaveBeenCalledWith('/platform/users/?business=1');
expect(result).toEqual(mockUsers);
expect(result).toHaveLength(2);
expect(result.every(u => u.business === 1)).toBe(true);
});
it('returns empty array when business has no users', async () => {
const businessId = 99;
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getBusinessUsers(businessId);
expect(apiClient.get).toHaveBeenCalledWith('/platform/users/?business=99');
expect(result).toEqual([]);
});
it('handles different business IDs correctly', async () => {
const businessId = 5;
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getBusinessUsers(businessId);
expect(apiClient.get).toHaveBeenCalledWith('/platform/users/?business=5');
});
});
describe('verifyUserEmail', () => {
it('verifies a user email by ID', async () => {
const userId = 10;
vi.mocked(apiClient.post).mockResolvedValue({});
await verifyUserEmail(userId);
expect(apiClient.post).toHaveBeenCalledWith('/platform/users/10/verify_email/');
});
it('handles verification with no response data', async () => {
const userId = 25;
vi.mocked(apiClient.post).mockResolvedValue({ data: undefined });
const result = await verifyUserEmail(userId);
expect(apiClient.post).toHaveBeenCalledWith('/platform/users/25/verify_email/');
expect(result).toBeUndefined();
});
});
// ============================================================================
// Tenant Invitations
// ============================================================================
describe('getTenantInvitations', () => {
it('fetches all tenant invitations from API', async () => {
const mockInvitations: TenantInvitation[] = [
{
id: 1,
email: 'newclient@example.com',
token: 'abc123token',
status: 'PENDING',
suggested_business_name: 'New Client Corp',
subscription_tier: 'PROFESSIONAL',
custom_max_users: 50,
custom_max_resources: 100,
permissions: {
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: true,
},
personal_message: 'Welcome to our platform!',
invited_by: 1,
invited_by_email: 'admin@platform.com',
created_at: '2025-01-10T10:00:00Z',
expires_at: '2025-01-24T10:00:00Z',
accepted_at: null,
created_tenant: null,
created_tenant_name: null,
created_user: null,
created_user_email: null,
},
{
id: 2,
email: 'accepted@example.com',
token: 'xyz789token',
status: 'ACCEPTED',
suggested_business_name: 'Accepted Business',
subscription_tier: 'STARTER',
custom_max_users: null,
custom_max_resources: null,
permissions: {},
personal_message: '',
invited_by: 1,
invited_by_email: 'admin@platform.com',
created_at: '2025-01-05T08:00:00Z',
expires_at: '2025-01-19T08:00:00Z',
accepted_at: '2025-01-06T12:00:00Z',
created_tenant: 5,
created_tenant_name: 'Accepted Business',
created_user: 15,
created_user_email: 'accepted@example.com',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockInvitations });
const result = await getTenantInvitations();
expect(apiClient.get).toHaveBeenCalledWith('/platform/tenant-invitations/');
expect(result).toEqual(mockInvitations);
expect(result).toHaveLength(2);
expect(result[0].status).toBe('PENDING');
expect(result[1].status).toBe('ACCEPTED');
});
it('returns empty array when no invitations exist', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getTenantInvitations();
expect(result).toEqual([]);
});
});
describe('createTenantInvitation', () => {
it('creates a tenant invitation with minimal data', async () => {
const createData: TenantInvitationCreate = {
email: 'client@example.com',
subscription_tier: 'STARTER',
};
const mockResponse: TenantInvitation = {
id: 10,
email: 'client@example.com',
token: 'generated-token-123',
status: 'PENDING',
suggested_business_name: '',
subscription_tier: 'STARTER',
custom_max_users: null,
custom_max_resources: null,
permissions: {},
personal_message: '',
invited_by: 1,
invited_by_email: 'admin@platform.com',
created_at: '2025-01-15T14:00:00Z',
expires_at: '2025-01-29T14:00:00Z',
accepted_at: null,
created_tenant: null,
created_tenant_name: null,
created_user: null,
created_user_email: null,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await createTenantInvitation(createData);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/',
createData
);
expect(result).toEqual(mockResponse);
expect(result.email).toBe('client@example.com');
expect(result.status).toBe('PENDING');
});
it('creates a tenant invitation with full data', async () => {
const createData: TenantInvitationCreate = {
email: 'vip@example.com',
suggested_business_name: 'VIP Corp',
subscription_tier: 'ENTERPRISE',
custom_max_users: 500,
custom_max_resources: 1000,
permissions: {
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: true,
can_white_label: true,
can_api_access: true,
},
personal_message: 'Welcome to our premium tier!',
};
const mockResponse: TenantInvitation = {
id: 11,
email: 'vip@example.com',
token: 'vip-token-456',
status: 'PENDING',
suggested_business_name: 'VIP Corp',
subscription_tier: 'ENTERPRISE',
custom_max_users: 500,
custom_max_resources: 1000,
permissions: {
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: true,
can_white_label: true,
can_api_access: true,
},
personal_message: 'Welcome to our premium tier!',
invited_by: 1,
invited_by_email: 'admin@platform.com',
created_at: '2025-01-15T15:00:00Z',
expires_at: '2025-01-29T15:00:00Z',
accepted_at: null,
created_tenant: null,
created_tenant_name: null,
created_user: null,
created_user_email: null,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await createTenantInvitation(createData);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/',
createData
);
expect(result.suggested_business_name).toBe('VIP Corp');
expect(result.custom_max_users).toBe(500);
expect(result.permissions.can_white_label).toBe(true);
});
it('creates invitation with partial permissions', async () => {
const createData: TenantInvitationCreate = {
email: 'partial@example.com',
subscription_tier: 'PROFESSIONAL',
permissions: {
can_accept_payments: true,
},
};
const mockResponse: TenantInvitation = {
id: 12,
email: 'partial@example.com',
token: 'partial-token',
status: 'PENDING',
suggested_business_name: '',
subscription_tier: 'PROFESSIONAL',
custom_max_users: null,
custom_max_resources: null,
permissions: {
can_accept_payments: true,
},
personal_message: '',
invited_by: 1,
invited_by_email: 'admin@platform.com',
created_at: '2025-01-15T16:00:00Z',
expires_at: '2025-01-29T16:00:00Z',
accepted_at: null,
created_tenant: null,
created_tenant_name: null,
created_user: null,
created_user_email: null,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await createTenantInvitation(createData);
expect(result.permissions.can_accept_payments).toBe(true);
});
});
describe('resendTenantInvitation', () => {
it('resends a tenant invitation by ID', async () => {
const invitationId = 5;
vi.mocked(apiClient.post).mockResolvedValue({});
await resendTenantInvitation(invitationId);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/5/resend/'
);
});
it('handles resend with no response data', async () => {
const invitationId = 10;
vi.mocked(apiClient.post).mockResolvedValue({ data: undefined });
const result = await resendTenantInvitation(invitationId);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/10/resend/'
);
expect(result).toBeUndefined();
});
});
describe('cancelTenantInvitation', () => {
it('cancels a tenant invitation by ID', async () => {
const invitationId = 7;
vi.mocked(apiClient.post).mockResolvedValue({});
await cancelTenantInvitation(invitationId);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/7/cancel/'
);
});
it('handles cancellation with no response data', async () => {
const invitationId = 15;
vi.mocked(apiClient.post).mockResolvedValue({ data: undefined });
const result = await cancelTenantInvitation(invitationId);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/15/cancel/'
);
expect(result).toBeUndefined();
});
});
describe('getInvitationByToken', () => {
it('fetches invitation details by token', async () => {
const token = 'abc123token';
const mockInvitation: TenantInvitationDetail = {
email: 'invited@example.com',
suggested_business_name: 'Invited Corp',
subscription_tier: 'PROFESSIONAL',
effective_max_users: 20,
effective_max_resources: 50,
permissions: {
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: true,
},
expires_at: '2025-01-30T12:00:00Z',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockInvitation });
const result = await getInvitationByToken(token);
expect(apiClient.get).toHaveBeenCalledWith(
'/platform/tenant-invitations/token/abc123token/'
);
expect(result).toEqual(mockInvitation);
expect(result.email).toBe('invited@example.com');
expect(result.effective_max_users).toBe(20);
});
it('handles tokens with special characters', async () => {
const token = 'token-with-dashes_and_underscores';
const mockInvitation: TenantInvitationDetail = {
email: 'test@example.com',
suggested_business_name: 'Test',
subscription_tier: 'FREE',
effective_max_users: 3,
effective_max_resources: 5,
permissions: {},
expires_at: '2025-02-01T00:00:00Z',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockInvitation });
await getInvitationByToken(token);
expect(apiClient.get).toHaveBeenCalledWith(
'/platform/tenant-invitations/token/token-with-dashes_and_underscores/'
);
});
it('fetches invitation with custom limits', async () => {
const token = 'custom-limits-token';
const mockInvitation: TenantInvitationDetail = {
email: 'custom@example.com',
suggested_business_name: 'Custom Business',
subscription_tier: 'ENTERPRISE',
effective_max_users: 1000,
effective_max_resources: 5000,
permissions: {
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: true,
can_white_label: true,
can_api_access: true,
},
expires_at: '2025-03-01T12:00:00Z',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockInvitation });
const result = await getInvitationByToken(token);
expect(result.effective_max_users).toBe(1000);
expect(result.effective_max_resources).toBe(5000);
});
});
describe('acceptInvitation', () => {
it('accepts an invitation with full data', async () => {
const token = 'accept-token-123';
const acceptData: TenantInvitationAccept = {
email: 'newowner@example.com',
password: 'secure-password',
first_name: 'John',
last_name: 'Doe',
business_name: 'New Business LLC',
subdomain: 'newbiz',
contact_email: 'contact@newbiz.com',
phone: '555-1234',
};
const mockResponse = {
detail: 'Invitation accepted successfully. Your account has been created.',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await acceptInvitation(token, acceptData);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/token/accept-token-123/accept/',
acceptData
);
expect(result).toEqual(mockResponse);
expect(result.detail).toContain('successfully');
});
it('accepts an invitation with minimal data', async () => {
const token = 'minimal-token';
const acceptData: TenantInvitationAccept = {
email: 'minimal@example.com',
password: 'password123',
first_name: 'Jane',
last_name: 'Smith',
business_name: 'Minimal Business',
subdomain: 'minimal',
};
const mockResponse = {
detail: 'Account created successfully.',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await acceptInvitation(token, acceptData);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/token/minimal-token/accept/',
acceptData
);
expect(result.detail).toBe('Account created successfully.');
});
it('handles acceptance with optional contact fields', async () => {
const token = 'optional-fields-token';
const acceptData: TenantInvitationAccept = {
email: 'test@example.com',
password: 'testpass',
first_name: 'Test',
last_name: 'User',
business_name: 'Test Business',
subdomain: 'testbiz',
contact_email: 'info@testbiz.com',
};
const mockResponse = {
detail: 'Welcome to the platform!',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
await acceptInvitation(token, acceptData);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/token/optional-fields-token/accept/',
expect.objectContaining({
contact_email: 'info@testbiz.com',
})
);
});
it('preserves all required fields in request', async () => {
const token = 'complete-token';
const acceptData: TenantInvitationAccept = {
email: 'complete@example.com',
password: 'strong-password-123',
first_name: 'Complete',
last_name: 'User',
business_name: 'Complete Business Corp',
subdomain: 'complete',
contact_email: 'support@complete.com',
phone: '555-9876',
};
vi.mocked(apiClient.post).mockResolvedValue({
data: { detail: 'Success' },
});
await acceptInvitation(token, acceptData);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/token/complete-token/accept/',
expect.objectContaining({
email: 'complete@example.com',
password: 'strong-password-123',
first_name: 'Complete',
last_name: 'User',
business_name: 'Complete Business Corp',
subdomain: 'complete',
contact_email: 'support@complete.com',
phone: '555-9876',
})
);
});
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,335 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
import {
getProfile,
updateProfile,
uploadAvatar,
deleteAvatar,
sendVerificationEmail,
verifyEmail,
requestEmailChange,
confirmEmailChange,
changePassword,
setupTOTP,
verifyTOTP,
disableTOTP,
getRecoveryCodes,
regenerateRecoveryCodes,
getSessions,
revokeSession,
revokeOtherSessions,
getLoginHistory,
sendPhoneVerification,
verifyPhoneCode,
getUserEmails,
addUserEmail,
deleteUserEmail,
sendUserEmailVerification,
verifyUserEmail,
setPrimaryEmail,
} from '../profile';
import apiClient from '../client';
describe('profile API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getProfile', () => {
it('fetches user profile', async () => {
const mockProfile = {
id: 1,
email: 'test@example.com',
name: 'Test User',
email_verified: true,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockProfile });
const result = await getProfile();
expect(apiClient.get).toHaveBeenCalledWith('/auth/profile/');
expect(result).toEqual(mockProfile);
});
});
describe('updateProfile', () => {
it('updates profile with provided data', async () => {
const mockUpdated = { id: 1, name: 'Updated Name' };
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockUpdated });
const result = await updateProfile({ name: 'Updated Name' });
expect(apiClient.patch).toHaveBeenCalledWith('/auth/profile/', { name: 'Updated Name' });
expect(result).toEqual(mockUpdated);
});
});
describe('uploadAvatar', () => {
it('uploads avatar file', async () => {
const mockResponse = { avatar_url: 'https://example.com/avatar.jpg' };
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const file = new File(['test'], 'avatar.jpg', { type: 'image/jpeg' });
const result = await uploadAvatar(file);
expect(apiClient.post).toHaveBeenCalledWith(
'/auth/profile/avatar/',
expect.any(FormData),
{ headers: { 'Content-Type': 'multipart/form-data' } }
);
expect(result.avatar_url).toBe('https://example.com/avatar.jpg');
});
});
describe('deleteAvatar', () => {
it('deletes user avatar', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await deleteAvatar();
expect(apiClient.delete).toHaveBeenCalledWith('/auth/profile/avatar/');
});
});
describe('email verification', () => {
it('sends verification email', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await sendVerificationEmail();
expect(apiClient.post).toHaveBeenCalledWith('/auth/email/verify/send/');
});
it('verifies email with token', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await verifyEmail('verification-token');
expect(apiClient.post).toHaveBeenCalledWith('/auth/email/verify/confirm/', {
token: 'verification-token',
});
});
});
describe('email change', () => {
it('requests email change', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await requestEmailChange('new@example.com');
expect(apiClient.post).toHaveBeenCalledWith('/auth/email/change/', {
new_email: 'new@example.com',
});
});
it('confirms email change', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await confirmEmailChange('change-token');
expect(apiClient.post).toHaveBeenCalledWith('/auth/email/change/confirm/', {
token: 'change-token',
});
});
});
describe('changePassword', () => {
it('changes password with current and new', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await changePassword('oldPassword', 'newPassword');
expect(apiClient.post).toHaveBeenCalledWith('/auth/password/change/', {
current_password: 'oldPassword',
new_password: 'newPassword',
});
});
});
describe('2FA / TOTP', () => {
it('sets up TOTP', async () => {
const mockSetup = {
secret: 'ABCD1234',
qr_code: 'base64...',
provisioning_uri: 'otpauth://...',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockSetup });
const result = await setupTOTP();
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/totp/setup/');
expect(result.secret).toBe('ABCD1234');
});
it('verifies TOTP code', async () => {
const mockResponse = { success: true, backup_codes: ['code1', 'code2'] };
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await verifyTOTP('123456');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/totp/verify/', { code: '123456' });
expect(result.success).toBe(true);
expect(result.recovery_codes).toEqual(['code1', 'code2']);
});
it('disables TOTP', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await disableTOTP('123456');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/disable/', { mfa_code: '123456' });
});
it('gets recovery codes status', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: {} });
const result = await getRecoveryCodes();
expect(apiClient.get).toHaveBeenCalledWith('/auth/mfa/backup-codes/status/');
expect(result).toEqual([]);
});
it('regenerates recovery codes', async () => {
const mockCodes = ['code1', 'code2', 'code3'];
vi.mocked(apiClient.post).mockResolvedValue({ data: { backup_codes: mockCodes } });
const result = await regenerateRecoveryCodes();
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/backup-codes/');
expect(result).toEqual(mockCodes);
});
});
describe('sessions', () => {
it('gets sessions', async () => {
const mockSessions = [
{ id: '1', device_info: 'Chrome', ip_address: '1.1.1.1', is_current: true },
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockSessions });
const result = await getSessions();
expect(apiClient.get).toHaveBeenCalledWith('/auth/sessions/');
expect(result).toEqual(mockSessions);
});
it('revokes session', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await revokeSession('session-123');
expect(apiClient.delete).toHaveBeenCalledWith('/auth/sessions/session-123/');
});
it('revokes other sessions', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await revokeOtherSessions();
expect(apiClient.post).toHaveBeenCalledWith('/auth/sessions/revoke-others/');
});
it('gets login history', async () => {
const mockHistory = [
{ id: '1', timestamp: '2024-01-01', success: true },
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockHistory });
const result = await getLoginHistory();
expect(apiClient.get).toHaveBeenCalledWith('/auth/login-history/');
expect(result).toEqual(mockHistory);
});
});
describe('phone verification', () => {
it('sends phone verification', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await sendPhoneVerification('555-1234');
expect(apiClient.post).toHaveBeenCalledWith('/auth/phone/verify/send/', {
phone: '555-1234',
});
});
it('verifies phone code', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await verifyPhoneCode('123456');
expect(apiClient.post).toHaveBeenCalledWith('/auth/phone/verify/confirm/', {
code: '123456',
});
});
});
describe('multiple emails', () => {
it('gets user emails', async () => {
const mockEmails = [
{ id: 1, email: 'primary@example.com', is_primary: true, verified: true },
{ id: 2, email: 'secondary@example.com', is_primary: false, verified: false },
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockEmails });
const result = await getUserEmails();
expect(apiClient.get).toHaveBeenCalledWith('/auth/emails/');
expect(result).toEqual(mockEmails);
});
it('adds user email', async () => {
const mockEmail = { id: 3, email: 'new@example.com', is_primary: false, verified: false };
vi.mocked(apiClient.post).mockResolvedValue({ data: mockEmail });
const result = await addUserEmail('new@example.com');
expect(apiClient.post).toHaveBeenCalledWith('/auth/emails/', { email: 'new@example.com' });
expect(result).toEqual(mockEmail);
});
it('deletes user email', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await deleteUserEmail(2);
expect(apiClient.delete).toHaveBeenCalledWith('/auth/emails/2/');
});
it('sends user email verification', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await sendUserEmailVerification(2);
expect(apiClient.post).toHaveBeenCalledWith('/auth/emails/2/send-verification/');
});
it('verifies user email', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await verifyUserEmail(2, 'verify-token');
expect(apiClient.post).toHaveBeenCalledWith('/auth/emails/2/verify/', {
token: 'verify-token',
});
});
it('sets primary email', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await setPrimaryEmail(2);
expect(apiClient.post).toHaveBeenCalledWith('/auth/emails/2/set-primary/');
});
});
});

View File

@@ -0,0 +1,609 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
},
}));
import {
getQuotaStatus,
getQuotaResources,
archiveResources,
unarchiveResource,
getOverageDetail,
QuotaStatus,
QuotaResourcesResponse,
ArchiveResponse,
QuotaOverageDetail,
} from '../quota';
import apiClient from '../client';
describe('quota API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getQuotaStatus', () => {
it('fetches quota status from API', async () => {
const mockQuotaStatus: QuotaStatus = {
active_overages: [
{
id: 1,
quota_type: 'resources',
display_name: 'Resources',
current_usage: 15,
allowed_limit: 10,
overage_amount: 5,
days_remaining: 7,
grace_period_ends_at: '2025-12-14T00:00:00Z',
},
],
usage: {
resources: {
current: 15,
limit: 10,
display_name: 'Resources',
},
staff: {
current: 3,
limit: 5,
display_name: 'Staff Members',
},
services: {
current: 8,
limit: 20,
display_name: 'Services',
},
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockQuotaStatus });
const result = await getQuotaStatus();
expect(apiClient.get).toHaveBeenCalledWith('/quota/status/');
expect(result).toEqual(mockQuotaStatus);
expect(result.active_overages).toHaveLength(1);
expect(result.usage.resources.current).toBe(15);
});
it('returns empty active_overages when no overages exist', async () => {
const mockQuotaStatus: QuotaStatus = {
active_overages: [],
usage: {
resources: {
current: 5,
limit: 10,
display_name: 'Resources',
},
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockQuotaStatus });
const result = await getQuotaStatus();
expect(result.active_overages).toHaveLength(0);
expect(result.usage.resources.current).toBeLessThan(result.usage.resources.limit);
});
it('handles multiple quota types in usage', async () => {
const mockQuotaStatus: QuotaStatus = {
active_overages: [],
usage: {
resources: {
current: 5,
limit: 10,
display_name: 'Resources',
},
staff: {
current: 2,
limit: 5,
display_name: 'Staff Members',
},
services: {
current: 15,
limit: 20,
display_name: 'Services',
},
customers: {
current: 100,
limit: 500,
display_name: 'Customers',
},
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockQuotaStatus });
const result = await getQuotaStatus();
expect(Object.keys(result.usage)).toHaveLength(4);
expect(result.usage).toHaveProperty('resources');
expect(result.usage).toHaveProperty('staff');
expect(result.usage).toHaveProperty('services');
expect(result.usage).toHaveProperty('customers');
});
});
describe('getQuotaResources', () => {
it('fetches resources for a specific quota type', async () => {
const mockResourcesResponse: QuotaResourcesResponse = {
quota_type: 'resources',
resources: [
{
id: 1,
name: 'Conference Room A',
type: 'room',
created_at: '2025-01-01T10:00:00Z',
is_archived: false,
archived_at: null,
},
{
id: 2,
name: 'Conference Room B',
type: 'room',
created_at: '2025-01-02T11:00:00Z',
is_archived: false,
archived_at: null,
},
],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResourcesResponse });
const result = await getQuotaResources('resources');
expect(apiClient.get).toHaveBeenCalledWith('/quota/resources/resources/');
expect(result).toEqual(mockResourcesResponse);
expect(result.quota_type).toBe('resources');
expect(result.resources).toHaveLength(2);
});
it('fetches staff members for staff quota type', async () => {
const mockStaffResponse: QuotaResourcesResponse = {
quota_type: 'staff',
resources: [
{
id: 10,
name: 'John Doe',
email: 'john@example.com',
role: 'staff',
created_at: '2025-01-15T09:00:00Z',
is_archived: false,
archived_at: null,
},
{
id: 11,
name: 'Jane Smith',
email: 'jane@example.com',
role: 'manager',
created_at: '2025-01-16T09:00:00Z',
is_archived: false,
archived_at: null,
},
],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStaffResponse });
const result = await getQuotaResources('staff');
expect(apiClient.get).toHaveBeenCalledWith('/quota/resources/staff/');
expect(result.quota_type).toBe('staff');
expect(result.resources[0]).toHaveProperty('email');
expect(result.resources[0]).toHaveProperty('role');
});
it('fetches services for services quota type', async () => {
const mockServicesResponse: QuotaResourcesResponse = {
quota_type: 'services',
resources: [
{
id: 20,
name: 'Haircut',
duration: 30,
price: '25.00',
created_at: '2025-02-01T10:00:00Z',
is_archived: false,
archived_at: null,
},
{
id: 21,
name: 'Color Treatment',
duration: 90,
price: '75.00',
created_at: '2025-02-02T10:00:00Z',
is_archived: false,
archived_at: null,
},
],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockServicesResponse });
const result = await getQuotaResources('services');
expect(apiClient.get).toHaveBeenCalledWith('/quota/resources/services/');
expect(result.quota_type).toBe('services');
expect(result.resources[0]).toHaveProperty('duration');
expect(result.resources[0]).toHaveProperty('price');
});
it('includes archived resources', async () => {
const mockResourcesResponse: QuotaResourcesResponse = {
quota_type: 'resources',
resources: [
{
id: 1,
name: 'Active Resource',
created_at: '2025-01-01T10:00:00Z',
is_archived: false,
archived_at: null,
},
{
id: 2,
name: 'Archived Resource',
created_at: '2024-12-01T10:00:00Z',
is_archived: true,
archived_at: '2025-12-01T15:30:00Z',
},
],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResourcesResponse });
const result = await getQuotaResources('resources');
expect(result.resources).toHaveLength(2);
expect(result.resources[0].is_archived).toBe(false);
expect(result.resources[1].is_archived).toBe(true);
expect(result.resources[1].archived_at).toBe('2025-12-01T15:30:00Z');
});
it('handles empty resources list', async () => {
const mockEmptyResponse: QuotaResourcesResponse = {
quota_type: 'resources',
resources: [],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockEmptyResponse });
const result = await getQuotaResources('resources');
expect(result.resources).toHaveLength(0);
expect(result.quota_type).toBe('resources');
});
});
describe('archiveResources', () => {
it('archives multiple resources successfully', async () => {
const mockArchiveResponse: ArchiveResponse = {
archived_count: 3,
current_usage: 7,
limit: 10,
is_resolved: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockArchiveResponse });
const result = await archiveResources('resources', [1, 2, 3]);
expect(apiClient.post).toHaveBeenCalledWith('/quota/archive/', {
quota_type: 'resources',
resource_ids: [1, 2, 3],
});
expect(result).toEqual(mockArchiveResponse);
expect(result.archived_count).toBe(3);
expect(result.is_resolved).toBe(true);
});
it('archives single resource', async () => {
const mockArchiveResponse: ArchiveResponse = {
archived_count: 1,
current_usage: 9,
limit: 10,
is_resolved: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockArchiveResponse });
const result = await archiveResources('staff', [5]);
expect(apiClient.post).toHaveBeenCalledWith('/quota/archive/', {
quota_type: 'staff',
resource_ids: [5],
});
expect(result.archived_count).toBe(1);
});
it('indicates overage is still not resolved after archiving', async () => {
const mockArchiveResponse: ArchiveResponse = {
archived_count: 2,
current_usage: 12,
limit: 10,
is_resolved: false,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockArchiveResponse });
const result = await archiveResources('resources', [1, 2]);
expect(result.is_resolved).toBe(false);
expect(result.current_usage).toBeGreaterThan(result.limit);
});
it('handles archiving with different quota types', async () => {
const mockArchiveResponse: ArchiveResponse = {
archived_count: 5,
current_usage: 15,
limit: 20,
is_resolved: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockArchiveResponse });
await archiveResources('services', [10, 11, 12, 13, 14]);
expect(apiClient.post).toHaveBeenCalledWith('/quota/archive/', {
quota_type: 'services',
resource_ids: [10, 11, 12, 13, 14],
});
});
it('handles empty resource_ids array', async () => {
const mockArchiveResponse: ArchiveResponse = {
archived_count: 0,
current_usage: 10,
limit: 10,
is_resolved: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockArchiveResponse });
const result = await archiveResources('resources', []);
expect(apiClient.post).toHaveBeenCalledWith('/quota/archive/', {
quota_type: 'resources',
resource_ids: [],
});
expect(result.archived_count).toBe(0);
});
});
describe('unarchiveResource', () => {
it('unarchives a resource successfully', async () => {
const mockUnarchiveResponse = {
success: true,
resource_id: 5,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUnarchiveResponse });
const result = await unarchiveResource('resources', 5);
expect(apiClient.post).toHaveBeenCalledWith('/quota/unarchive/', {
quota_type: 'resources',
resource_id: 5,
});
expect(result).toEqual(mockUnarchiveResponse);
expect(result.success).toBe(true);
expect(result.resource_id).toBe(5);
});
it('unarchives staff member', async () => {
const mockUnarchiveResponse = {
success: true,
resource_id: 10,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUnarchiveResponse });
const result = await unarchiveResource('staff', 10);
expect(apiClient.post).toHaveBeenCalledWith('/quota/unarchive/', {
quota_type: 'staff',
resource_id: 10,
});
expect(result.success).toBe(true);
});
it('unarchives service', async () => {
const mockUnarchiveResponse = {
success: true,
resource_id: 20,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUnarchiveResponse });
const result = await unarchiveResource('services', 20);
expect(apiClient.post).toHaveBeenCalledWith('/quota/unarchive/', {
quota_type: 'services',
resource_id: 20,
});
expect(result.resource_id).toBe(20);
});
it('handles unsuccessful unarchive', async () => {
const mockUnarchiveResponse = {
success: false,
resource_id: 5,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUnarchiveResponse });
const result = await unarchiveResource('resources', 5);
expect(result.success).toBe(false);
});
});
describe('getOverageDetail', () => {
it('fetches detailed overage information', async () => {
const mockOverageDetail: QuotaOverageDetail = {
id: 1,
quota_type: 'resources',
display_name: 'Resources',
current_usage: 15,
allowed_limit: 10,
overage_amount: 5,
days_remaining: 7,
grace_period_ends_at: '2025-12-14T00:00:00Z',
status: 'active',
created_at: '2025-12-07T10:00:00Z',
initial_email_sent_at: '2025-12-07T10:05:00Z',
week_reminder_sent_at: null,
day_reminder_sent_at: null,
archived_resource_ids: [],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOverageDetail });
const result = await getOverageDetail(1);
expect(apiClient.get).toHaveBeenCalledWith('/quota/overages/1/');
expect(result).toEqual(mockOverageDetail);
expect(result.status).toBe('active');
expect(result.overage_amount).toBe(5);
});
it('includes sent email timestamps', async () => {
const mockOverageDetail: QuotaOverageDetail = {
id: 2,
quota_type: 'staff',
display_name: 'Staff Members',
current_usage: 8,
allowed_limit: 5,
overage_amount: 3,
days_remaining: 3,
grace_period_ends_at: '2025-12-10T00:00:00Z',
status: 'active',
created_at: '2025-11-30T10:00:00Z',
initial_email_sent_at: '2025-11-30T10:05:00Z',
week_reminder_sent_at: '2025-12-03T09:00:00Z',
day_reminder_sent_at: '2025-12-06T09:00:00Z',
archived_resource_ids: [],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOverageDetail });
const result = await getOverageDetail(2);
expect(result.initial_email_sent_at).toBe('2025-11-30T10:05:00Z');
expect(result.week_reminder_sent_at).toBe('2025-12-03T09:00:00Z');
expect(result.day_reminder_sent_at).toBe('2025-12-06T09:00:00Z');
});
it('includes archived resource IDs', async () => {
const mockOverageDetail: QuotaOverageDetail = {
id: 3,
quota_type: 'resources',
display_name: 'Resources',
current_usage: 10,
allowed_limit: 10,
overage_amount: 0,
days_remaining: 5,
grace_period_ends_at: '2025-12-12T00:00:00Z',
status: 'resolved',
created_at: '2025-12-01T10:00:00Z',
initial_email_sent_at: '2025-12-01T10:05:00Z',
week_reminder_sent_at: null,
day_reminder_sent_at: null,
archived_resource_ids: [1, 3, 5, 7],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOverageDetail });
const result = await getOverageDetail(3);
expect(result.archived_resource_ids).toHaveLength(4);
expect(result.archived_resource_ids).toEqual([1, 3, 5, 7]);
expect(result.status).toBe('resolved');
});
it('handles resolved overage with zero overage_amount', async () => {
const mockOverageDetail: QuotaOverageDetail = {
id: 4,
quota_type: 'services',
display_name: 'Services',
current_usage: 18,
allowed_limit: 20,
overage_amount: 0,
days_remaining: 0,
grace_period_ends_at: '2025-12-05T00:00:00Z',
status: 'resolved',
created_at: '2025-11-25T10:00:00Z',
initial_email_sent_at: '2025-11-25T10:05:00Z',
week_reminder_sent_at: '2025-11-28T09:00:00Z',
day_reminder_sent_at: null,
archived_resource_ids: [20, 21],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOverageDetail });
const result = await getOverageDetail(4);
expect(result.overage_amount).toBe(0);
expect(result.status).toBe('resolved');
expect(result.current_usage).toBeLessThanOrEqual(result.allowed_limit);
});
it('handles expired overage', async () => {
const mockOverageDetail: QuotaOverageDetail = {
id: 5,
quota_type: 'resources',
display_name: 'Resources',
current_usage: 15,
allowed_limit: 10,
overage_amount: 5,
days_remaining: 0,
grace_period_ends_at: '2025-12-06T00:00:00Z',
status: 'expired',
created_at: '2025-11-20T10:00:00Z',
initial_email_sent_at: '2025-11-20T10:05:00Z',
week_reminder_sent_at: '2025-11-27T09:00:00Z',
day_reminder_sent_at: '2025-12-05T09:00:00Z',
archived_resource_ids: [],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOverageDetail });
const result = await getOverageDetail(5);
expect(result.status).toBe('expired');
expect(result.days_remaining).toBe(0);
expect(result.overage_amount).toBeGreaterThan(0);
});
it('handles null email timestamps when no reminders sent', async () => {
const mockOverageDetail: QuotaOverageDetail = {
id: 6,
quota_type: 'staff',
display_name: 'Staff Members',
current_usage: 6,
allowed_limit: 5,
overage_amount: 1,
days_remaining: 14,
grace_period_ends_at: '2025-12-21T00:00:00Z',
status: 'active',
created_at: '2025-12-07T10:00:00Z',
initial_email_sent_at: null,
week_reminder_sent_at: null,
day_reminder_sent_at: null,
archived_resource_ids: [],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOverageDetail });
const result = await getOverageDetail(6);
expect(result.initial_email_sent_at).toBeNull();
expect(result.week_reminder_sent_at).toBeNull();
expect(result.day_reminder_sent_at).toBeNull();
});
});
});

View File

@@ -0,0 +1,208 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
},
}));
import {
getSandboxStatus,
toggleSandboxMode,
resetSandboxData,
} from '../sandbox';
import apiClient from '../client';
describe('sandbox API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getSandboxStatus', () => {
it('fetches sandbox status from API', async () => {
const mockStatus = {
sandbox_mode: true,
sandbox_enabled: true,
sandbox_schema: 'test_business_sandbox',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
const result = await getSandboxStatus();
expect(apiClient.get).toHaveBeenCalledWith('/sandbox/status/');
expect(result).toEqual(mockStatus);
});
it('returns sandbox disabled status', async () => {
const mockStatus = {
sandbox_mode: false,
sandbox_enabled: false,
sandbox_schema: null,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
const result = await getSandboxStatus();
expect(result.sandbox_mode).toBe(false);
expect(result.sandbox_enabled).toBe(false);
expect(result.sandbox_schema).toBeNull();
});
it('returns sandbox enabled but not active', async () => {
const mockStatus = {
sandbox_mode: false,
sandbox_enabled: true,
sandbox_schema: 'test_business_sandbox',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
const result = await getSandboxStatus();
expect(result.sandbox_mode).toBe(false);
expect(result.sandbox_enabled).toBe(true);
expect(result.sandbox_schema).toBe('test_business_sandbox');
});
});
describe('toggleSandboxMode', () => {
it('enables sandbox mode', async () => {
const mockResponse = {
data: {
sandbox_mode: true,
message: 'Sandbox mode enabled',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await toggleSandboxMode(true);
expect(apiClient.post).toHaveBeenCalledWith('/sandbox/toggle/', {
sandbox: true,
});
expect(result.sandbox_mode).toBe(true);
expect(result.message).toBe('Sandbox mode enabled');
});
it('disables sandbox mode', async () => {
const mockResponse = {
data: {
sandbox_mode: false,
message: 'Sandbox mode disabled',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await toggleSandboxMode(false);
expect(apiClient.post).toHaveBeenCalledWith('/sandbox/toggle/', {
sandbox: false,
});
expect(result.sandbox_mode).toBe(false);
expect(result.message).toBe('Sandbox mode disabled');
});
it('handles toggle with true parameter', async () => {
const mockResponse = {
data: {
sandbox_mode: true,
message: 'Switched to test data',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
await toggleSandboxMode(true);
expect(apiClient.post).toHaveBeenCalledWith('/sandbox/toggle/', {
sandbox: true,
});
});
it('handles toggle with false parameter', async () => {
const mockResponse = {
data: {
sandbox_mode: false,
message: 'Switched to live data',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
await toggleSandboxMode(false);
expect(apiClient.post).toHaveBeenCalledWith('/sandbox/toggle/', {
sandbox: false,
});
});
});
describe('resetSandboxData', () => {
it('resets sandbox data successfully', async () => {
const mockResponse = {
data: {
message: 'Sandbox data reset successfully',
sandbox_schema: 'test_business_sandbox',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await resetSandboxData();
expect(apiClient.post).toHaveBeenCalledWith('/sandbox/reset/');
expect(result.message).toBe('Sandbox data reset successfully');
expect(result.sandbox_schema).toBe('test_business_sandbox');
});
it('returns schema name after reset', async () => {
const mockResponse = {
data: {
message: 'Data reset complete',
sandbox_schema: 'my_company_sandbox',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await resetSandboxData();
expect(result.sandbox_schema).toBe('my_company_sandbox');
});
it('calls reset endpoint without parameters', async () => {
const mockResponse = {
data: {
message: 'Reset successful',
sandbox_schema: 'test_sandbox',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
await resetSandboxData();
expect(apiClient.post).toHaveBeenCalledWith('/sandbox/reset/');
expect(apiClient.post).toHaveBeenCalledTimes(1);
});
});
describe('error handling', () => {
it('propagates errors from getSandboxStatus', async () => {
const error = new Error('Network error');
vi.mocked(apiClient.get).mockRejectedValue(error);
await expect(getSandboxStatus()).rejects.toThrow('Network error');
});
it('propagates errors from toggleSandboxMode', async () => {
const error = new Error('Unauthorized');
vi.mocked(apiClient.post).mockRejectedValue(error);
await expect(toggleSandboxMode(true)).rejects.toThrow('Unauthorized');
});
it('propagates errors from resetSandboxData', async () => {
const error = new Error('Forbidden');
vi.mocked(apiClient.post).mockRejectedValue(error);
await expect(resetSandboxData()).rejects.toThrow('Forbidden');
});
});
});

View File

@@ -0,0 +1,793 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
import {
getTicketEmailAddresses,
getTicketEmailAddress,
createTicketEmailAddress,
updateTicketEmailAddress,
deleteTicketEmailAddress,
testImapConnection,
testSmtpConnection,
fetchEmailsNow,
setAsDefault,
TicketEmailAddressListItem,
TicketEmailAddress,
TicketEmailAddressCreate,
TestConnectionResponse,
FetchEmailsResponse,
} from '../ticketEmailAddresses';
import apiClient from '../client';
describe('ticketEmailAddresses API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getTicketEmailAddresses', () => {
it('should fetch all ticket email addresses', async () => {
const mockAddresses: TicketEmailAddressListItem[] = [
{
id: 1,
display_name: 'Support',
email_address: 'support@example.com',
color: '#FF5733',
is_active: true,
is_default: true,
last_check_at: '2025-12-07T10:00:00Z',
emails_processed_count: 42,
created_at: '2025-12-01T10:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
},
{
id: 2,
display_name: 'Sales',
email_address: 'sales@example.com',
color: '#3357FF',
is_active: true,
is_default: false,
emails_processed_count: 15,
created_at: '2025-12-02T10:00:00Z',
updated_at: '2025-12-05T10:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockAddresses });
const result = await getTicketEmailAddresses();
expect(apiClient.get).toHaveBeenCalledWith('/tickets/email-addresses/');
expect(result).toEqual(mockAddresses);
expect(result).toHaveLength(2);
});
it('should return empty array when no addresses exist', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getTicketEmailAddresses();
expect(apiClient.get).toHaveBeenCalledWith('/tickets/email-addresses/');
expect(result).toEqual([]);
expect(result).toHaveLength(0);
});
it('should throw error when API call fails', async () => {
const mockError = new Error('Network error');
vi.mocked(apiClient.get).mockRejectedValue(mockError);
await expect(getTicketEmailAddresses()).rejects.toThrow('Network error');
expect(apiClient.get).toHaveBeenCalledWith('/tickets/email-addresses/');
});
});
describe('getTicketEmailAddress', () => {
it('should fetch a specific ticket email address by ID', async () => {
const mockAddress: TicketEmailAddress = {
id: 1,
tenant: 100,
tenant_name: 'Test Business',
display_name: 'Support',
email_address: 'support@example.com',
color: '#FF5733',
imap_host: 'imap.gmail.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'support@example.com',
imap_folder: 'INBOX',
smtp_host: 'smtp.gmail.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'support@example.com',
is_active: true,
is_default: true,
last_check_at: '2025-12-07T10:00:00Z',
emails_processed_count: 42,
created_at: '2025-12-01T10:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
is_imap_configured: true,
is_smtp_configured: true,
is_fully_configured: true,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockAddress });
const result = await getTicketEmailAddress(1);
expect(apiClient.get).toHaveBeenCalledWith('/tickets/email-addresses/1/');
expect(result).toEqual(mockAddress);
expect(result.id).toBe(1);
expect(result.email_address).toBe('support@example.com');
});
it('should handle fetching with different IDs', async () => {
const mockAddress: TicketEmailAddress = {
id: 999,
tenant: 100,
tenant_name: 'Test Business',
display_name: 'Sales',
email_address: 'sales@example.com',
color: '#3357FF',
imap_host: 'imap.example.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'sales@example.com',
imap_folder: 'INBOX',
smtp_host: 'smtp.example.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'sales@example.com',
is_active: true,
is_default: false,
emails_processed_count: 0,
created_at: '2025-12-01T10:00:00Z',
updated_at: '2025-12-01T10:00:00Z',
is_imap_configured: true,
is_smtp_configured: true,
is_fully_configured: true,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockAddress });
const result = await getTicketEmailAddress(999);
expect(apiClient.get).toHaveBeenCalledWith('/tickets/email-addresses/999/');
expect(result.id).toBe(999);
});
it('should throw error when address not found', async () => {
const mockError = new Error('Not found');
vi.mocked(apiClient.get).mockRejectedValue(mockError);
await expect(getTicketEmailAddress(999)).rejects.toThrow('Not found');
expect(apiClient.get).toHaveBeenCalledWith('/tickets/email-addresses/999/');
});
});
describe('createTicketEmailAddress', () => {
it('should create a new ticket email address', async () => {
const createData: TicketEmailAddressCreate = {
display_name: 'Support',
email_address: 'support@example.com',
color: '#FF5733',
imap_host: 'imap.gmail.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'support@example.com',
imap_password: 'secure_password',
imap_folder: 'INBOX',
smtp_host: 'smtp.gmail.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'support@example.com',
smtp_password: 'secure_password',
is_active: true,
is_default: false,
};
const mockResponse: TicketEmailAddress = {
id: 1,
tenant: 100,
tenant_name: 'Test Business',
...createData,
imap_password: undefined, // Passwords are not returned in response
smtp_password: undefined,
last_check_at: undefined,
last_error: undefined,
emails_processed_count: 0,
created_at: '2025-12-07T10:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
is_imap_configured: true,
is_smtp_configured: true,
is_fully_configured: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await createTicketEmailAddress(createData);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/', createData);
expect(result).toEqual(mockResponse);
expect(result.id).toBe(1);
expect(result.display_name).toBe('Support');
});
it('should handle creating with minimal required fields', async () => {
const createData: TicketEmailAddressCreate = {
display_name: 'Minimal',
email_address: 'minimal@example.com',
color: '#000000',
imap_host: 'imap.example.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'minimal@example.com',
imap_password: 'password',
imap_folder: 'INBOX',
smtp_host: 'smtp.example.com',
smtp_port: 587,
smtp_use_tls: false,
smtp_use_ssl: false,
smtp_username: 'minimal@example.com',
smtp_password: 'password',
is_active: false,
is_default: false,
};
const mockResponse: TicketEmailAddress = {
id: 2,
tenant: 100,
tenant_name: 'Test Business',
...createData,
imap_password: undefined,
smtp_password: undefined,
emails_processed_count: 0,
created_at: '2025-12-07T10:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
is_imap_configured: true,
is_smtp_configured: true,
is_fully_configured: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await createTicketEmailAddress(createData);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/', createData);
expect(result.id).toBe(2);
});
it('should throw error when validation fails', async () => {
const invalidData: TicketEmailAddressCreate = {
display_name: '',
email_address: 'invalid-email',
color: '#FF5733',
imap_host: '',
imap_port: 993,
imap_use_ssl: true,
imap_username: '',
imap_password: '',
imap_folder: 'INBOX',
smtp_host: '',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: '',
smtp_password: '',
is_active: true,
is_default: false,
};
const mockError = new Error('Validation error');
vi.mocked(apiClient.post).mockRejectedValue(mockError);
await expect(createTicketEmailAddress(invalidData)).rejects.toThrow('Validation error');
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/', invalidData);
});
});
describe('updateTicketEmailAddress', () => {
it('should update an existing ticket email address', async () => {
const updateData: Partial<TicketEmailAddressCreate> = {
display_name: 'Updated Support',
color: '#00FF00',
};
const mockResponse: TicketEmailAddress = {
id: 1,
tenant: 100,
tenant_name: 'Test Business',
display_name: 'Updated Support',
email_address: 'support@example.com',
color: '#00FF00',
imap_host: 'imap.gmail.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'support@example.com',
imap_folder: 'INBOX',
smtp_host: 'smtp.gmail.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'support@example.com',
is_active: true,
is_default: true,
emails_processed_count: 42,
created_at: '2025-12-01T10:00:00Z',
updated_at: '2025-12-07T11:00:00Z',
is_imap_configured: true,
is_smtp_configured: true,
is_fully_configured: true,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
const result = await updateTicketEmailAddress(1, updateData);
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/email-addresses/1/', updateData);
expect(result).toEqual(mockResponse);
expect(result.display_name).toBe('Updated Support');
expect(result.color).toBe('#00FF00');
});
it('should update IMAP configuration', async () => {
const updateData: Partial<TicketEmailAddressCreate> = {
imap_host: 'imap.newserver.com',
imap_port: 993,
imap_password: 'new_password',
};
const mockResponse: TicketEmailAddress = {
id: 1,
tenant: 100,
tenant_name: 'Test Business',
display_name: 'Support',
email_address: 'support@example.com',
color: '#FF5733',
imap_host: 'imap.newserver.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'support@example.com',
imap_folder: 'INBOX',
smtp_host: 'smtp.gmail.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'support@example.com',
is_active: true,
is_default: true,
emails_processed_count: 42,
created_at: '2025-12-01T10:00:00Z',
updated_at: '2025-12-07T11:00:00Z',
is_imap_configured: true,
is_smtp_configured: true,
is_fully_configured: true,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
const result = await updateTicketEmailAddress(1, updateData);
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/email-addresses/1/', updateData);
expect(result.imap_host).toBe('imap.newserver.com');
});
it('should update SMTP configuration', async () => {
const updateData: Partial<TicketEmailAddressCreate> = {
smtp_host: 'smtp.newserver.com',
smtp_port: 465,
smtp_use_tls: false,
smtp_use_ssl: true,
};
const mockResponse: TicketEmailAddress = {
id: 1,
tenant: 100,
tenant_name: 'Test Business',
display_name: 'Support',
email_address: 'support@example.com',
color: '#FF5733',
imap_host: 'imap.gmail.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'support@example.com',
imap_folder: 'INBOX',
smtp_host: 'smtp.newserver.com',
smtp_port: 465,
smtp_use_tls: false,
smtp_use_ssl: true,
smtp_username: 'support@example.com',
is_active: true,
is_default: true,
emails_processed_count: 42,
created_at: '2025-12-01T10:00:00Z',
updated_at: '2025-12-07T11:00:00Z',
is_imap_configured: true,
is_smtp_configured: true,
is_fully_configured: true,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
const result = await updateTicketEmailAddress(1, updateData);
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/email-addresses/1/', updateData);
expect(result.smtp_host).toBe('smtp.newserver.com');
expect(result.smtp_port).toBe(465);
expect(result.smtp_use_ssl).toBe(true);
});
it('should toggle is_active status', async () => {
const updateData: Partial<TicketEmailAddressCreate> = {
is_active: false,
};
const mockResponse: TicketEmailAddress = {
id: 1,
tenant: 100,
tenant_name: 'Test Business',
display_name: 'Support',
email_address: 'support@example.com',
color: '#FF5733',
imap_host: 'imap.gmail.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'support@example.com',
imap_folder: 'INBOX',
smtp_host: 'smtp.gmail.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'support@example.com',
is_active: false,
is_default: true,
emails_processed_count: 42,
created_at: '2025-12-01T10:00:00Z',
updated_at: '2025-12-07T11:00:00Z',
is_imap_configured: true,
is_smtp_configured: true,
is_fully_configured: true,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
const result = await updateTicketEmailAddress(1, updateData);
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/email-addresses/1/', updateData);
expect(result.is_active).toBe(false);
});
it('should throw error when update fails', async () => {
const updateData: Partial<TicketEmailAddressCreate> = {
display_name: 'Invalid',
};
const mockError = new Error('Update failed');
vi.mocked(apiClient.patch).mockRejectedValue(mockError);
await expect(updateTicketEmailAddress(1, updateData)).rejects.toThrow('Update failed');
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/email-addresses/1/', updateData);
});
});
describe('deleteTicketEmailAddress', () => {
it('should delete a ticket email address', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({ data: undefined });
await deleteTicketEmailAddress(1);
expect(apiClient.delete).toHaveBeenCalledWith('/tickets/email-addresses/1/');
});
it('should handle deletion of different IDs', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({ data: undefined });
await deleteTicketEmailAddress(999);
expect(apiClient.delete).toHaveBeenCalledWith('/tickets/email-addresses/999/');
});
it('should throw error when deletion fails', async () => {
const mockError = new Error('Cannot delete default address');
vi.mocked(apiClient.delete).mockRejectedValue(mockError);
await expect(deleteTicketEmailAddress(1)).rejects.toThrow('Cannot delete default address');
expect(apiClient.delete).toHaveBeenCalledWith('/tickets/email-addresses/1/');
});
it('should throw error when address not found', async () => {
const mockError = new Error('Not found');
vi.mocked(apiClient.delete).mockRejectedValue(mockError);
await expect(deleteTicketEmailAddress(999)).rejects.toThrow('Not found');
expect(apiClient.delete).toHaveBeenCalledWith('/tickets/email-addresses/999/');
});
});
describe('testImapConnection', () => {
it('should test IMAP connection successfully', async () => {
const mockResponse: TestConnectionResponse = {
success: true,
message: 'IMAP connection successful',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await testImapConnection(1);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/test_imap/');
expect(result).toEqual(mockResponse);
expect(result.success).toBe(true);
expect(result.message).toBe('IMAP connection successful');
});
it('should handle failed IMAP connection', async () => {
const mockResponse: TestConnectionResponse = {
success: false,
message: 'Authentication failed: Invalid credentials',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await testImapConnection(1);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/test_imap/');
expect(result.success).toBe(false);
expect(result.message).toContain('Invalid credentials');
});
it('should handle network errors during IMAP test', async () => {
const mockError = new Error('Network error');
vi.mocked(apiClient.post).mockRejectedValue(mockError);
await expect(testImapConnection(1)).rejects.toThrow('Network error');
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/test_imap/');
});
it('should test IMAP connection for different addresses', async () => {
const mockResponse: TestConnectionResponse = {
success: true,
message: 'IMAP connection successful',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
await testImapConnection(42);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/42/test_imap/');
});
});
describe('testSmtpConnection', () => {
it('should test SMTP connection successfully', async () => {
const mockResponse: TestConnectionResponse = {
success: true,
message: 'SMTP connection successful',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await testSmtpConnection(1);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/test_smtp/');
expect(result).toEqual(mockResponse);
expect(result.success).toBe(true);
expect(result.message).toBe('SMTP connection successful');
});
it('should handle failed SMTP connection', async () => {
const mockResponse: TestConnectionResponse = {
success: false,
message: 'Connection refused: Unable to connect to SMTP server',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await testSmtpConnection(1);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/test_smtp/');
expect(result.success).toBe(false);
expect(result.message).toContain('Connection refused');
});
it('should handle TLS/SSL errors during SMTP test', async () => {
const mockResponse: TestConnectionResponse = {
success: false,
message: 'SSL certificate verification failed',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await testSmtpConnection(1);
expect(result.success).toBe(false);
expect(result.message).toContain('SSL certificate');
});
it('should handle network errors during SMTP test', async () => {
const mockError = new Error('Network error');
vi.mocked(apiClient.post).mockRejectedValue(mockError);
await expect(testSmtpConnection(1)).rejects.toThrow('Network error');
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/test_smtp/');
});
it('should test SMTP connection for different addresses', async () => {
const mockResponse: TestConnectionResponse = {
success: true,
message: 'SMTP connection successful',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
await testSmtpConnection(99);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/99/test_smtp/');
});
});
describe('fetchEmailsNow', () => {
it('should fetch emails successfully', async () => {
const mockResponse: FetchEmailsResponse = {
success: true,
message: 'Successfully processed 5 emails',
processed: 5,
errors: 0,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await fetchEmailsNow(1);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/fetch_now/');
expect(result).toEqual(mockResponse);
expect(result.success).toBe(true);
expect(result.processed).toBe(5);
expect(result.errors).toBe(0);
});
it('should handle fetching with no new emails', async () => {
const mockResponse: FetchEmailsResponse = {
success: true,
message: 'No new emails to process',
processed: 0,
errors: 0,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await fetchEmailsNow(1);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/fetch_now/');
expect(result.success).toBe(true);
expect(result.processed).toBe(0);
});
it('should handle errors during email processing', async () => {
const mockResponse: FetchEmailsResponse = {
success: false,
message: 'Failed to connect to IMAP server',
processed: 0,
errors: 1,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await fetchEmailsNow(1);
expect(result.success).toBe(false);
expect(result.errors).toBe(1);
expect(result.message).toContain('Failed to connect');
});
it('should handle partial processing with errors', async () => {
const mockResponse: FetchEmailsResponse = {
success: true,
message: 'Processed 8 emails with 2 errors',
processed: 8,
errors: 2,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await fetchEmailsNow(1);
expect(result.success).toBe(true);
expect(result.processed).toBe(8);
expect(result.errors).toBe(2);
});
it('should handle network errors during fetch', async () => {
const mockError = new Error('Network error');
vi.mocked(apiClient.post).mockRejectedValue(mockError);
await expect(fetchEmailsNow(1)).rejects.toThrow('Network error');
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/fetch_now/');
});
it('should fetch emails for different addresses', async () => {
const mockResponse: FetchEmailsResponse = {
success: true,
message: 'Successfully processed 3 emails',
processed: 3,
errors: 0,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
await fetchEmailsNow(42);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/42/fetch_now/');
});
});
describe('setAsDefault', () => {
it('should set email address as default successfully', async () => {
const mockResponse = {
success: true,
message: 'Email address set as default',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await setAsDefault(2);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/2/set_as_default/');
expect(result).toEqual(mockResponse);
expect(result.success).toBe(true);
expect(result.message).toBe('Email address set as default');
});
it('should handle setting default for different addresses', async () => {
const mockResponse = {
success: true,
message: 'Email address set as default',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
await setAsDefault(99);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/99/set_as_default/');
});
it('should handle failure to set as default', async () => {
const mockResponse = {
success: false,
message: 'Cannot set inactive email as default',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await setAsDefault(1);
expect(result.success).toBe(false);
expect(result.message).toContain('Cannot set inactive');
});
it('should handle network errors when setting default', async () => {
const mockError = new Error('Network error');
vi.mocked(apiClient.post).mockRejectedValue(mockError);
await expect(setAsDefault(1)).rejects.toThrow('Network error');
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/set_as_default/');
});
it('should handle not found errors', async () => {
const mockError = new Error('Email address not found');
vi.mocked(apiClient.post).mockRejectedValue(mockError);
await expect(setAsDefault(999)).rejects.toThrow('Email address not found');
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/999/set_as_default/');
});
});
});

View File

@@ -0,0 +1,703 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
import {
getTicketEmailSettings,
updateTicketEmailSettings,
testImapConnection,
testSmtpConnection,
testEmailConnection,
fetchEmailsNow,
getIncomingEmails,
reprocessIncomingEmail,
detectEmailProvider,
getOAuthStatus,
initiateGoogleOAuth,
initiateMicrosoftOAuth,
getOAuthCredentials,
deleteOAuthCredential,
type TicketEmailSettings,
type TicketEmailSettingsUpdate,
type TestConnectionResult,
type FetchNowResult,
type IncomingTicketEmail,
type EmailProviderDetectResult,
type OAuthStatusResult,
type OAuthInitiateResult,
type OAuthCredential,
} from '../ticketEmailSettings';
import apiClient from '../client';
describe('ticketEmailSettings API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getTicketEmailSettings', () => {
it('should call GET /tickets/email-settings/', async () => {
const mockSettings: TicketEmailSettings = {
imap_host: 'imap.gmail.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'support@example.com',
imap_password_masked: '***',
imap_folder: 'INBOX',
smtp_host: 'smtp.gmail.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'support@example.com',
smtp_password_masked: '***',
smtp_from_email: 'support@example.com',
smtp_from_name: 'Support Team',
support_email_address: 'support@example.com',
support_email_domain: 'example.com',
is_enabled: true,
delete_after_processing: false,
check_interval_seconds: 300,
max_attachment_size_mb: 10,
allowed_attachment_types: ['pdf', 'jpg', 'png'],
last_check_at: '2025-12-07T10:00:00Z',
last_error: '',
emails_processed_count: 42,
is_configured: true,
is_imap_configured: true,
is_smtp_configured: true,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockSettings });
const result = await getTicketEmailSettings();
expect(apiClient.get).toHaveBeenCalledWith('/tickets/email-settings/');
expect(apiClient.get).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockSettings);
});
});
describe('updateTicketEmailSettings', () => {
it('should call PATCH /tickets/email-settings/ with update data', async () => {
const updateData: TicketEmailSettingsUpdate = {
imap_host: 'imap.outlook.com',
imap_port: 993,
is_enabled: true,
};
const mockResponse: TicketEmailSettings = {
imap_host: 'imap.outlook.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'support@example.com',
imap_password_masked: '***',
imap_folder: 'INBOX',
smtp_host: 'smtp.outlook.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'support@example.com',
smtp_password_masked: '***',
smtp_from_email: 'support@example.com',
smtp_from_name: 'Support Team',
support_email_address: 'support@example.com',
support_email_domain: 'example.com',
is_enabled: true,
delete_after_processing: false,
check_interval_seconds: 300,
max_attachment_size_mb: 10,
allowed_attachment_types: ['pdf', 'jpg', 'png'],
last_check_at: null,
last_error: '',
emails_processed_count: 0,
is_configured: true,
is_imap_configured: true,
is_smtp_configured: true,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
const result = await updateTicketEmailSettings(updateData);
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/email-settings/', updateData);
expect(apiClient.patch).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResponse);
});
it('should handle password updates', async () => {
const updateData: TicketEmailSettingsUpdate = {
imap_password: 'newpassword123',
smtp_password: 'newsmtppass456',
};
const mockResponse: TicketEmailSettings = {
imap_host: 'imap.gmail.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'support@example.com',
imap_password_masked: '***',
imap_folder: 'INBOX',
smtp_host: 'smtp.gmail.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'support@example.com',
smtp_password_masked: '***',
smtp_from_email: 'support@example.com',
smtp_from_name: 'Support Team',
support_email_address: 'support@example.com',
support_email_domain: 'example.com',
is_enabled: true,
delete_after_processing: false,
check_interval_seconds: 300,
max_attachment_size_mb: 10,
allowed_attachment_types: ['pdf', 'jpg', 'png'],
last_check_at: null,
last_error: '',
emails_processed_count: 0,
is_configured: true,
is_imap_configured: true,
is_smtp_configured: true,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
const result = await updateTicketEmailSettings(updateData);
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/email-settings/', updateData);
expect(result).toEqual(mockResponse);
});
});
describe('testImapConnection', () => {
it('should call POST /tickets/email-settings/test-imap/', async () => {
const mockResult: TestConnectionResult = {
success: true,
message: 'IMAP connection successful',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await testImapConnection();
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-settings/test-imap/');
expect(apiClient.post).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResult);
});
it('should handle connection failures', async () => {
const mockResult: TestConnectionResult = {
success: false,
message: 'Failed to connect: Invalid credentials',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await testImapConnection();
expect(result.success).toBe(false);
expect(result.message).toContain('Failed to connect');
});
});
describe('testSmtpConnection', () => {
it('should call POST /tickets/email-settings/test-smtp/', async () => {
const mockResult: TestConnectionResult = {
success: true,
message: 'SMTP connection successful',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await testSmtpConnection();
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-settings/test-smtp/');
expect(apiClient.post).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResult);
});
it('should handle SMTP connection failures', async () => {
const mockResult: TestConnectionResult = {
success: false,
message: 'SMTP error: Connection refused',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await testSmtpConnection();
expect(result.success).toBe(false);
expect(result.message).toContain('Connection refused');
});
});
describe('testEmailConnection (legacy alias)', () => {
it('should be an alias for testImapConnection', async () => {
const mockResult: TestConnectionResult = {
success: true,
message: 'Connection successful',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await testEmailConnection();
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-settings/test-imap/');
expect(result).toEqual(mockResult);
});
});
describe('fetchEmailsNow', () => {
it('should call POST /tickets/email-settings/fetch-now/', async () => {
const mockResult: FetchNowResult = {
success: true,
message: 'Successfully processed 5 emails',
processed: 5,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await fetchEmailsNow();
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-settings/fetch-now/');
expect(apiClient.post).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResult);
});
it('should handle no new emails', async () => {
const mockResult: FetchNowResult = {
success: true,
message: 'No new emails found',
processed: 0,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await fetchEmailsNow();
expect(result.processed).toBe(0);
expect(result.success).toBe(true);
});
});
describe('getIncomingEmails', () => {
it('should call GET /tickets/incoming-emails/ without params', async () => {
const mockEmails: IncomingTicketEmail[] = [
{
id: 1,
message_id: '<msg1@example.com>',
from_address: 'customer@example.com',
from_name: 'John Doe',
to_address: 'support@example.com',
subject: 'Help needed',
body_text: 'I need assistance with...',
extracted_reply: 'I need assistance with...',
ticket: 123,
ticket_subject: 'Help needed',
matched_user: 456,
ticket_id_from_email: '#123',
processing_status: 'PROCESSED',
processing_status_display: 'Processed',
error_message: '',
email_date: '2025-12-07T09:00:00Z',
received_at: '2025-12-07T09:01:00Z',
processed_at: '2025-12-07T09:02:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockEmails });
const result = await getIncomingEmails();
expect(apiClient.get).toHaveBeenCalledWith('/tickets/incoming-emails/', { params: undefined });
expect(apiClient.get).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockEmails);
});
it('should call GET /tickets/incoming-emails/ with status filter', async () => {
const mockEmails: IncomingTicketEmail[] = [];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockEmails });
const result = await getIncomingEmails({ status: 'FAILED' });
expect(apiClient.get).toHaveBeenCalledWith('/tickets/incoming-emails/', {
params: { status: 'FAILED' },
});
expect(result).toEqual(mockEmails);
});
it('should call GET /tickets/incoming-emails/ with ticket filter', async () => {
const mockEmails: IncomingTicketEmail[] = [];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockEmails });
const result = await getIncomingEmails({ ticket: 123 });
expect(apiClient.get).toHaveBeenCalledWith('/tickets/incoming-emails/', {
params: { ticket: 123 },
});
expect(result).toEqual(mockEmails);
});
it('should call GET /tickets/incoming-emails/ with multiple filters', async () => {
const mockEmails: IncomingTicketEmail[] = [];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockEmails });
const result = await getIncomingEmails({ status: 'PROCESSED', ticket: 123 });
expect(apiClient.get).toHaveBeenCalledWith('/tickets/incoming-emails/', {
params: { status: 'PROCESSED', ticket: 123 },
});
expect(result).toEqual(mockEmails);
});
});
describe('reprocessIncomingEmail', () => {
it('should call POST /tickets/incoming-emails/:id/reprocess/', async () => {
const mockResponse = {
success: true,
message: 'Email reprocessed successfully',
comment_id: 789,
ticket_id: 123,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await reprocessIncomingEmail(456);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/incoming-emails/456/reprocess/');
expect(apiClient.post).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResponse);
});
it('should handle reprocessing failures', async () => {
const mockResponse = {
success: false,
message: 'Failed to reprocess: Invalid email format',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await reprocessIncomingEmail(999);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/incoming-emails/999/reprocess/');
expect(result.success).toBe(false);
expect(result.message).toContain('Failed to reprocess');
});
});
describe('detectEmailProvider', () => {
it('should call POST /tickets/email-settings/detect/ with email', async () => {
const mockResult: EmailProviderDetectResult = {
success: true,
email: 'user@gmail.com',
domain: 'gmail.com',
detected: true,
detected_via: 'domain_lookup',
provider: 'google',
display_name: 'Gmail',
imap_host: 'imap.gmail.com',
imap_port: 993,
smtp_host: 'smtp.gmail.com',
smtp_port: 587,
oauth_supported: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await detectEmailProvider('user@gmail.com');
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-settings/detect/', {
email: 'user@gmail.com',
});
expect(apiClient.post).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResult);
});
it('should detect Microsoft provider', async () => {
const mockResult: EmailProviderDetectResult = {
success: true,
email: 'user@outlook.com',
domain: 'outlook.com',
detected: true,
detected_via: 'domain_lookup',
provider: 'microsoft',
display_name: 'Outlook.com',
imap_host: 'outlook.office365.com',
imap_port: 993,
smtp_host: 'smtp.office365.com',
smtp_port: 587,
oauth_supported: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await detectEmailProvider('user@outlook.com');
expect(result.provider).toBe('microsoft');
expect(result.oauth_supported).toBe(true);
});
it('should detect custom domain via MX records', async () => {
const mockResult: EmailProviderDetectResult = {
success: true,
email: 'admin@company.com',
domain: 'company.com',
detected: true,
detected_via: 'mx_record',
provider: 'google',
display_name: 'Google Workspace',
oauth_supported: true,
message: 'Detected Google Workspace via MX records',
notes: 'Use OAuth for best security',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await detectEmailProvider('admin@company.com');
expect(result.detected_via).toBe('mx_record');
expect(result.provider).toBe('google');
});
it('should handle unknown provider', async () => {
const mockResult: EmailProviderDetectResult = {
success: true,
email: 'user@custom-server.com',
domain: 'custom-server.com',
detected: false,
provider: 'unknown',
display_name: 'Unknown Provider',
oauth_supported: false,
message: 'Could not auto-detect email provider',
suggested_imap_port: 993,
suggested_smtp_port: 587,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await detectEmailProvider('user@custom-server.com');
expect(result.detected).toBe(false);
expect(result.provider).toBe('unknown');
expect(result.oauth_supported).toBe(false);
});
});
describe('getOAuthStatus', () => {
it('should call GET /oauth/status/', async () => {
const mockStatus: OAuthStatusResult = {
google: { configured: true },
microsoft: { configured: false },
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
const result = await getOAuthStatus();
expect(apiClient.get).toHaveBeenCalledWith('/oauth/status/');
expect(apiClient.get).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockStatus);
});
it('should handle no OAuth configured', async () => {
const mockStatus: OAuthStatusResult = {
google: { configured: false },
microsoft: { configured: false },
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
const result = await getOAuthStatus();
expect(result.google.configured).toBe(false);
expect(result.microsoft.configured).toBe(false);
});
});
describe('initiateGoogleOAuth', () => {
it('should call POST /oauth/google/initiate/ with default purpose', async () => {
const mockResult: OAuthInitiateResult = {
success: true,
authorization_url: 'https://accounts.google.com/o/oauth2/auth?...',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await initiateGoogleOAuth();
expect(apiClient.post).toHaveBeenCalledWith('/oauth/google/initiate/', { purpose: 'email' });
expect(apiClient.post).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResult);
});
it('should call POST /oauth/google/initiate/ with custom purpose', async () => {
const mockResult: OAuthInitiateResult = {
success: true,
authorization_url: 'https://accounts.google.com/o/oauth2/auth?...',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await initiateGoogleOAuth('calendar');
expect(apiClient.post).toHaveBeenCalledWith('/oauth/google/initiate/', { purpose: 'calendar' });
expect(result).toEqual(mockResult);
});
it('should handle OAuth initiation errors', async () => {
const mockResult: OAuthInitiateResult = {
success: false,
error: 'OAuth client credentials not configured',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await initiateGoogleOAuth();
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
});
describe('initiateMicrosoftOAuth', () => {
it('should call POST /oauth/microsoft/initiate/ with default purpose', async () => {
const mockResult: OAuthInitiateResult = {
success: true,
authorization_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?...',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await initiateMicrosoftOAuth();
expect(apiClient.post).toHaveBeenCalledWith('/oauth/microsoft/initiate/', { purpose: 'email' });
expect(apiClient.post).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResult);
});
it('should call POST /oauth/microsoft/initiate/ with custom purpose', async () => {
const mockResult: OAuthInitiateResult = {
success: true,
authorization_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?...',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await initiateMicrosoftOAuth('calendar');
expect(apiClient.post).toHaveBeenCalledWith('/oauth/microsoft/initiate/', {
purpose: 'calendar',
});
expect(result).toEqual(mockResult);
});
it('should handle Microsoft OAuth errors', async () => {
const mockResult: OAuthInitiateResult = {
success: false,
error: 'Microsoft OAuth not configured',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await initiateMicrosoftOAuth();
expect(result.success).toBe(false);
expect(result.error).toBe('Microsoft OAuth not configured');
});
});
describe('getOAuthCredentials', () => {
it('should call GET /oauth/credentials/', async () => {
const mockCredentials: OAuthCredential[] = [
{
id: 1,
provider: 'google',
email: 'support@example.com',
purpose: 'email',
is_valid: true,
is_expired: false,
last_used_at: '2025-12-07T09:00:00Z',
last_error: '',
created_at: '2025-01-01T00:00:00Z',
},
{
id: 2,
provider: 'microsoft',
email: 'admin@example.com',
purpose: 'email',
is_valid: false,
is_expired: true,
last_used_at: '2025-11-01T10:00:00Z',
last_error: 'Token expired',
created_at: '2025-01-15T00:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockCredentials });
const result = await getOAuthCredentials();
expect(apiClient.get).toHaveBeenCalledWith('/oauth/credentials/');
expect(apiClient.get).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockCredentials);
expect(result).toHaveLength(2);
});
it('should handle empty credentials list', async () => {
const mockCredentials: OAuthCredential[] = [];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockCredentials });
const result = await getOAuthCredentials();
expect(result).toEqual([]);
expect(result).toHaveLength(0);
});
});
describe('deleteOAuthCredential', () => {
it('should call DELETE /oauth/credentials/:id/', async () => {
const mockResponse = {
success: true,
message: 'OAuth credential deleted successfully',
};
vi.mocked(apiClient.delete).mockResolvedValue({ data: mockResponse });
const result = await deleteOAuthCredential(123);
expect(apiClient.delete).toHaveBeenCalledWith('/oauth/credentials/123/');
expect(apiClient.delete).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResponse);
});
it('should handle deletion of non-existent credential', async () => {
const mockResponse = {
success: false,
message: 'Credential not found',
};
vi.mocked(apiClient.delete).mockResolvedValue({ data: mockResponse });
const result = await deleteOAuthCredential(999);
expect(apiClient.delete).toHaveBeenCalledWith('/oauth/credentials/999/');
expect(result.success).toBe(false);
});
});
});

View File

@@ -0,0 +1,577 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
import {
getTickets,
getTicket,
createTicket,
updateTicket,
deleteTicket,
getTicketComments,
createTicketComment,
getTicketTemplates,
getTicketTemplate,
getCannedResponses,
refreshTicketEmails,
} from '../tickets';
import apiClient from '../client';
import type { Ticket, TicketComment, TicketTemplate, CannedResponse } from '../../types';
describe('tickets API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getTickets', () => {
it('fetches all tickets without filters', async () => {
const mockTickets: Ticket[] = [
{
id: '1',
creator: 'user1',
creatorEmail: 'user1@example.com',
creatorFullName: 'User One',
ticketType: 'CUSTOMER',
status: 'OPEN',
priority: 'HIGH',
subject: 'Test Ticket',
description: 'Test description',
category: 'TECHNICAL',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
},
{
id: '2',
creator: 'user2',
creatorEmail: 'user2@example.com',
creatorFullName: 'User Two',
ticketType: 'PLATFORM',
status: 'IN_PROGRESS',
priority: 'MEDIUM',
subject: 'Another Ticket',
description: 'Another description',
category: 'BILLING',
createdAt: '2024-01-02T00:00:00Z',
updatedAt: '2024-01-02T00:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTickets });
const result = await getTickets();
expect(apiClient.get).toHaveBeenCalledWith('/tickets/');
expect(result).toEqual(mockTickets);
});
it('applies status filter', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getTickets({ status: 'OPEN' });
expect(apiClient.get).toHaveBeenCalledWith('/tickets/?status=OPEN');
});
it('applies priority filter', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getTickets({ priority: 'HIGH' });
expect(apiClient.get).toHaveBeenCalledWith('/tickets/?priority=HIGH');
});
it('applies category filter', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getTickets({ category: 'TECHNICAL' });
expect(apiClient.get).toHaveBeenCalledWith('/tickets/?category=TECHNICAL');
});
it('applies ticketType filter with snake_case conversion', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getTickets({ ticketType: 'CUSTOMER' });
expect(apiClient.get).toHaveBeenCalledWith('/tickets/?ticket_type=CUSTOMER');
});
it('applies assignee filter', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getTickets({ assignee: 'user123' });
expect(apiClient.get).toHaveBeenCalledWith('/tickets/?assignee=user123');
});
it('applies multiple filters', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getTickets({
status: 'OPEN',
priority: 'HIGH',
category: 'BILLING',
ticketType: 'CUSTOMER',
assignee: 'user456',
});
expect(apiClient.get).toHaveBeenCalledWith(
'/tickets/?status=OPEN&priority=HIGH&category=BILLING&ticket_type=CUSTOMER&assignee=user456'
);
});
it('applies partial filters', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getTickets({ status: 'CLOSED', priority: 'LOW' });
expect(apiClient.get).toHaveBeenCalledWith('/tickets/?status=CLOSED&priority=LOW');
});
it('handles empty filters object', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getTickets({});
expect(apiClient.get).toHaveBeenCalledWith('/tickets/');
});
});
describe('getTicket', () => {
it('fetches a single ticket by ID', async () => {
const mockTicket: Ticket = {
id: '123',
creator: 'user1',
creatorEmail: 'user1@example.com',
creatorFullName: 'User One',
assignee: 'user2',
assigneeEmail: 'user2@example.com',
assigneeFullName: 'User Two',
ticketType: 'CUSTOMER',
status: 'IN_PROGRESS',
priority: 'HIGH',
subject: 'Important Ticket',
description: 'This needs attention',
category: 'TECHNICAL',
relatedAppointmentId: 'appt-456',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-02T00:00:00Z',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTicket });
const result = await getTicket('123');
expect(apiClient.get).toHaveBeenCalledWith('/tickets/123/');
expect(result).toEqual(mockTicket);
});
});
describe('createTicket', () => {
it('creates a new ticket', async () => {
const newTicketData: Partial<Ticket> = {
subject: 'New Ticket',
description: 'New ticket description',
ticketType: 'CUSTOMER',
priority: 'MEDIUM',
category: 'GENERAL_INQUIRY',
};
const createdTicket: Ticket = {
id: '789',
creator: 'current-user',
creatorEmail: 'current@example.com',
creatorFullName: 'Current User',
status: 'OPEN',
createdAt: '2024-01-03T00:00:00Z',
updatedAt: '2024-01-03T00:00:00Z',
...newTicketData,
} as Ticket;
vi.mocked(apiClient.post).mockResolvedValue({ data: createdTicket });
const result = await createTicket(newTicketData);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/', newTicketData);
expect(result).toEqual(createdTicket);
});
it('creates a ticket with all optional fields', async () => {
const newTicketData: Partial<Ticket> = {
subject: 'Complex Ticket',
description: 'Complex description',
ticketType: 'STAFF_REQUEST',
priority: 'URGENT',
category: 'TIME_OFF',
assignee: 'manager-123',
relatedAppointmentId: 'appt-999',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: {} });
await createTicket(newTicketData);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/', newTicketData);
});
});
describe('updateTicket', () => {
it('updates a ticket', async () => {
const updateData: Partial<Ticket> = {
status: 'RESOLVED',
priority: 'LOW',
};
const updatedTicket: Ticket = {
id: '123',
creator: 'user1',
creatorEmail: 'user1@example.com',
creatorFullName: 'User One',
ticketType: 'CUSTOMER',
subject: 'Existing Ticket',
description: 'Existing description',
category: 'TECHNICAL',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-05T00:00:00Z',
...updateData,
} as Ticket;
vi.mocked(apiClient.patch).mockResolvedValue({ data: updatedTicket });
const result = await updateTicket('123', updateData);
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/123/', updateData);
expect(result).toEqual(updatedTicket);
});
it('updates ticket assignee', async () => {
const updateData = { assignee: 'new-assignee-456' };
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
await updateTicket('123', updateData);
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/123/', updateData);
});
it('updates multiple ticket fields', async () => {
const updateData: Partial<Ticket> = {
status: 'CLOSED',
priority: 'LOW',
assignee: 'user789',
category: 'RESOLVED',
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
await updateTicket('456', updateData);
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/456/', updateData);
});
});
describe('deleteTicket', () => {
it('deletes a ticket', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await deleteTicket('123');
expect(apiClient.delete).toHaveBeenCalledWith('/tickets/123/');
});
it('returns void', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
const result = await deleteTicket('456');
expect(result).toBeUndefined();
});
});
describe('getTicketComments', () => {
it('fetches all comments for a ticket', async () => {
const mockComments: TicketComment[] = [
{
id: 'c1',
ticket: 't1',
author: 'user1',
authorEmail: 'user1@example.com',
authorFullName: 'User One',
commentText: 'First comment',
createdAt: '2024-01-01T00:00:00Z',
isInternal: false,
},
{
id: 'c2',
ticket: 't1',
author: 'user2',
authorEmail: 'user2@example.com',
authorFullName: 'User Two',
commentText: 'Second comment',
createdAt: '2024-01-02T00:00:00Z',
isInternal: true,
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockComments });
const result = await getTicketComments('t1');
expect(apiClient.get).toHaveBeenCalledWith('/tickets/t1/comments/');
expect(result).toEqual(mockComments);
});
it('handles ticket with no comments', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getTicketComments('t999');
expect(apiClient.get).toHaveBeenCalledWith('/tickets/t999/comments/');
expect(result).toEqual([]);
});
});
describe('createTicketComment', () => {
it('creates a new comment on a ticket', async () => {
const commentData: Partial<TicketComment> = {
commentText: 'This is a new comment',
isInternal: false,
};
const createdComment: TicketComment = {
id: 'c123',
ticket: 't1',
author: 'current-user',
authorEmail: 'current@example.com',
authorFullName: 'Current User',
createdAt: '2024-01-03T00:00:00Z',
...commentData,
} as TicketComment;
vi.mocked(apiClient.post).mockResolvedValue({ data: createdComment });
const result = await createTicketComment('t1', commentData);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/t1/comments/', commentData);
expect(result).toEqual(createdComment);
});
it('creates an internal comment', async () => {
const commentData: Partial<TicketComment> = {
commentText: 'Internal note',
isInternal: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: {} });
await createTicketComment('t2', commentData);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/t2/comments/', commentData);
});
});
describe('getTicketTemplates', () => {
it('fetches all ticket templates', async () => {
const mockTemplates: TicketTemplate[] = [
{
id: 'tmpl1',
name: 'Bug Report Template',
description: 'Template for bug reports',
ticketType: 'CUSTOMER',
category: 'TECHNICAL',
defaultPriority: 'HIGH',
subjectTemplate: 'Bug: {{title}}',
descriptionTemplate: 'Steps to reproduce:\n{{steps}}',
isActive: true,
createdAt: '2024-01-01T00:00:00Z',
},
{
id: 'tmpl2',
tenant: 'tenant123',
name: 'Time Off Request',
description: 'Staff time off template',
ticketType: 'STAFF_REQUEST',
category: 'TIME_OFF',
defaultPriority: 'MEDIUM',
subjectTemplate: 'Time Off: {{dates}}',
descriptionTemplate: 'Reason:\n{{reason}}',
isActive: true,
createdAt: '2024-01-02T00:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTemplates });
const result = await getTicketTemplates();
expect(apiClient.get).toHaveBeenCalledWith('/tickets/templates/');
expect(result).toEqual(mockTemplates);
});
it('handles empty template list', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getTicketTemplates();
expect(result).toEqual([]);
});
});
describe('getTicketTemplate', () => {
it('fetches a single ticket template by ID', async () => {
const mockTemplate: TicketTemplate = {
id: 'tmpl123',
name: 'Feature Request Template',
description: 'Template for feature requests',
ticketType: 'CUSTOMER',
category: 'FEATURE_REQUEST',
defaultPriority: 'LOW',
subjectTemplate: 'Feature Request: {{feature}}',
descriptionTemplate: 'Description:\n{{description}}\n\nBenefit:\n{{benefit}}',
isActive: true,
createdAt: '2024-01-01T00:00:00Z',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTemplate });
const result = await getTicketTemplate('tmpl123');
expect(apiClient.get).toHaveBeenCalledWith('/tickets/templates/tmpl123/');
expect(result).toEqual(mockTemplate);
});
});
describe('getCannedResponses', () => {
it('fetches all canned responses', async () => {
const mockResponses: CannedResponse[] = [
{
id: 'cr1',
title: 'Thank You Response',
content: 'Thank you for contacting us. We will get back to you soon.',
category: 'GENERAL_INQUIRY',
isActive: true,
useCount: 42,
createdBy: 'admin',
createdAt: '2024-01-01T00:00:00Z',
},
{
id: 'cr2',
tenant: 'tenant456',
title: 'Billing Issue',
content: 'We have received your billing inquiry and are investigating.',
category: 'BILLING',
isActive: true,
useCount: 18,
createdBy: 'manager',
createdAt: '2024-01-02T00:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponses });
const result = await getCannedResponses();
expect(apiClient.get).toHaveBeenCalledWith('/tickets/canned-responses/');
expect(result).toEqual(mockResponses);
});
it('handles empty canned responses list', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getCannedResponses();
expect(result).toEqual([]);
});
});
describe('refreshTicketEmails', () => {
it('successfully refreshes ticket emails', async () => {
const mockResult = {
success: true,
processed: 5,
results: [
{
address: 'support@example.com',
display_name: 'Support',
processed: 3,
status: 'success',
last_check_at: '2024-01-05T12:00:00Z',
},
{
address: 'help@example.com',
display_name: 'Help Desk',
processed: 2,
status: 'success',
last_check_at: '2024-01-05T12:00:00Z',
},
],
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await refreshTicketEmails();
expect(apiClient.post).toHaveBeenCalledWith('/tickets/refresh-emails/');
expect(result.success).toBe(true);
expect(result.processed).toBe(5);
expect(result.results).toHaveLength(2);
});
it('handles refresh with errors', async () => {
const mockResult = {
success: false,
processed: 0,
results: [
{
address: 'invalid@example.com',
display_name: 'Invalid Email',
status: 'error',
error: 'Connection timeout',
},
],
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await refreshTicketEmails();
expect(result.success).toBe(false);
expect(result.processed).toBe(0);
expect(result.results[0].status).toBe('error');
expect(result.results[0].error).toBe('Connection timeout');
});
it('handles partial success', async () => {
const mockResult = {
success: true,
processed: 2,
results: [
{
address: 'working@example.com',
processed: 2,
status: 'success',
},
{
address: null,
status: 'skipped',
message: 'No email address configured',
},
],
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await refreshTicketEmails();
expect(result.success).toBe(true);
expect(result.processed).toBe(2);
expect(result.results).toHaveLength(2);
expect(result.results[0].status).toBe('success');
expect(result.results[1].status).toBe('skipped');
});
it('handles no configured email addresses', async () => {
const mockResult = {
success: false,
processed: 0,
results: [],
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await refreshTicketEmails();
expect(result.success).toBe(false);
expect(result.processed).toBe(0);
expect(result.results).toHaveLength(0);
});
});
});

View File

@@ -47,6 +47,7 @@ export interface LoginResponse {
business?: number;
business_name?: string;
business_subdomain?: string;
can_send_messages?: boolean;
};
masquerade_stack?: MasqueradeStackEntry[];
// MFA challenge response
@@ -73,6 +74,7 @@ export interface User {
can_invite_staff?: boolean;
can_access_tickets?: boolean;
can_edit_schedule?: boolean;
can_send_messages?: boolean;
linked_resource_id?: number;
quota_overages?: QuotaOverage[];
}
@@ -134,3 +136,11 @@ export const stopMasquerade = async (
);
return response.data;
};
/**
* Request password reset email
*/
export const forgotPassword = async (email: string): Promise<{ message: string }> => {
const response = await apiClient.post<{ message: string }>('/auth/password-reset/', { email });
return response.data;
};

188
frontend/src/api/billing.ts Normal file
View File

@@ -0,0 +1,188 @@
/**
* Billing API
*
* API client functions for the billing/subscription system.
*/
import apiClient from './client';
// ============================================================================
// Types
// ============================================================================
/**
* Entitlements - a map of feature codes to their values.
* Boolean features indicate permission (true/false).
* Integer features indicate limits.
*/
export interface Entitlements {
[key: string]: boolean | number | null;
}
/**
* Plan information (nested in PlanVersion)
*/
export interface Plan {
code: string;
name: string;
description?: string;
}
/**
* Plan version with pricing and features
*/
export interface PlanVersion {
id: number;
name: string;
is_legacy: boolean;
is_public?: boolean;
plan: Plan;
price_monthly_cents: number;
price_yearly_cents: number;
features?: PlanFeature[];
}
/**
* Feature attached to a plan version
*/
export interface PlanFeature {
feature_code: string;
feature_name: string;
feature_type: 'boolean' | 'integer';
bool_value?: boolean;
int_value?: number;
}
/**
* Current subscription
*/
export interface Subscription {
id: number;
status: 'active' | 'canceled' | 'past_due' | 'trialing';
plan_version: PlanVersion;
current_period_start: string;
current_period_end: string;
canceled_at?: string;
stripe_subscription_id?: string;
}
/**
* Add-on product
*/
export interface AddOnProduct {
id: number;
code: string;
name: string;
description?: string;
price_monthly_cents: number;
price_one_time_cents: number;
stripe_product_id?: string;
stripe_price_id?: string;
is_stackable: boolean;
is_active: boolean;
}
/**
* Invoice line item
*/
export interface InvoiceLine {
id: number;
line_type: 'plan' | 'addon' | 'adjustment' | 'credit';
description: string;
quantity: number;
unit_amount: number;
total_amount: number;
}
/**
* Invoice
*/
export interface Invoice {
id: number;
status: 'draft' | 'pending' | 'paid' | 'void' | 'uncollectible';
period_start: string;
period_end: string;
subtotal_amount: number;
total_amount: number;
plan_name_at_billing: string;
plan_code_at_billing?: string;
created_at: string;
paid_at?: string;
lines?: InvoiceLine[];
}
// ============================================================================
// API Functions
// ============================================================================
/**
* Get effective entitlements for the current business.
* Returns a map of feature codes to their values.
*/
export const getEntitlements = async (): Promise<Entitlements> => {
try {
const response = await apiClient.get<Entitlements>('/me/entitlements/');
return response.data;
} catch (error) {
console.error('Failed to fetch entitlements:', error);
return {};
}
};
/**
* Get the current subscription for the business.
* Returns null if no subscription exists.
*/
export const getCurrentSubscription = async (): Promise<Subscription | null> => {
try {
const response = await apiClient.get<Subscription>('/me/subscription/');
return response.data;
} catch (error: any) {
if (error?.response?.status === 404) {
return null;
}
console.error('Failed to fetch subscription:', error);
throw error;
}
};
/**
* Get available plans (public, non-legacy plans).
*/
export const getPlans = async (): Promise<PlanVersion[]> => {
const response = await apiClient.get<PlanVersion[]>('/billing/plans/');
return response.data;
};
/**
* Get available add-on products.
*/
export const getAddOns = async (): Promise<AddOnProduct[]> => {
const response = await apiClient.get<AddOnProduct[]>('/billing/addons/');
return response.data;
};
/**
* Get invoices for the current business.
*/
export const getInvoices = async (): Promise<Invoice[]> => {
const response = await apiClient.get<Invoice[]>('/billing/invoices/');
return response.data;
};
/**
* Get a single invoice by ID.
* Returns null if not found.
*/
export const getInvoice = async (invoiceId: number): Promise<Invoice | null> => {
try {
const response = await apiClient.get<Invoice>(`/billing/invoices/${invoiceId}/`);
return response.data;
} catch (error: any) {
if (error?.response?.status === 404) {
return null;
}
console.error('Failed to fetch invoice:', error);
throw error;
}
};

View File

@@ -443,7 +443,6 @@ export interface SubscriptionPlan {
name: string;
description: string;
plan_type: 'base' | 'addon';
business_tier: string;
price_monthly: number | null;
price_yearly: number | null;
features: string[];

View File

@@ -25,6 +25,7 @@ export interface PlatformBusiness {
owner: PlatformBusinessOwner | null;
max_users: number;
max_resources: number;
max_pages: number;
contact_email?: string;
phone?: string;
// Platform permissions
@@ -33,6 +34,25 @@ export interface PlatformBusiness {
can_use_custom_domain: boolean;
can_white_label: boolean;
can_api_access: boolean;
// Feature permissions (optional - returned by API but may not always be present in tests)
can_add_video_conferencing?: boolean;
can_connect_to_api?: boolean;
can_book_repeated_events?: boolean;
can_require_2fa?: boolean;
can_download_logs?: boolean;
can_delete_data?: boolean;
can_use_sms_reminders?: boolean;
can_use_masked_phone_numbers?: boolean;
can_use_pos?: boolean;
can_use_mobile_app?: boolean;
can_export_data?: boolean;
can_use_plugins?: boolean;
can_use_tasks?: boolean;
can_create_plugins?: boolean;
can_use_webhooks?: boolean;
can_use_calendar_sync?: boolean;
can_use_contracts?: boolean;
can_customize_booking_page?: boolean;
}
export interface PlatformBusinessUpdate {
@@ -41,11 +61,39 @@ export interface PlatformBusinessUpdate {
subscription_tier?: string;
max_users?: number;
max_resources?: number;
max_pages?: number;
// Platform permissions
can_manage_oauth_credentials?: boolean;
can_accept_payments?: boolean;
can_use_custom_domain?: boolean;
can_white_label?: boolean;
can_api_access?: boolean;
// Feature permissions
can_add_video_conferencing?: boolean;
can_connect_to_api?: boolean;
can_book_repeated_events?: boolean;
can_require_2fa?: boolean;
can_download_logs?: boolean;
can_delete_data?: boolean;
can_use_sms_reminders?: boolean;
can_use_masked_phone_numbers?: boolean;
can_use_pos?: boolean;
can_use_mobile_app?: boolean;
can_export_data?: boolean;
can_use_plugins?: boolean;
can_use_tasks?: boolean;
can_create_plugins?: boolean;
can_use_webhooks?: boolean;
can_use_calendar_sync?: boolean;
can_use_contracts?: boolean;
can_customize_booking_page?: boolean;
can_process_refunds?: boolean;
can_create_packages?: boolean;
can_use_email_templates?: boolean;
advanced_reporting?: boolean;
priority_support?: boolean;
dedicated_support?: boolean;
sso_enabled?: boolean;
}
export interface PlatformBusinessCreate {
@@ -55,6 +103,7 @@ export interface PlatformBusinessCreate {
is_active?: boolean;
max_users?: number;
max_resources?: number;
max_pages?: number;
contact_email?: string;
phone?: string;
can_manage_oauth_credentials?: boolean;
@@ -103,6 +152,27 @@ export const updateBusiness = async (
return response.data;
};
/**
* Change a business's subscription plan (platform admin only)
*/
export interface ChangePlanResponse {
detail: string;
plan_code: string;
plan_name: string;
version: number;
}
export const changeBusinessPlan = async (
businessId: number,
planCode: string
): Promise<ChangePlanResponse> => {
const response = await apiClient.post<ChangePlanResponse>(
`/platform/businesses/${businessId}/change_plan/`,
{ plan_code: planCode }
);
return response.data;
};
/**
* Create a new business (platform admin only)
*/
@@ -280,3 +350,46 @@ export const acceptInvitation = async (
);
return response.data;
};
// ============================================================================
// Tenant Custom Tier
// ============================================================================
import { TenantCustomTier } from '../types';
/**
* Get a business's custom tier (if it exists)
*/
export const getCustomTier = async (businessId: number): Promise<TenantCustomTier | null> => {
try {
const response = await apiClient.get<TenantCustomTier>(`/platform/businesses/${businessId}/custom_tier/`);
return response.data;
} catch (error: any) {
if (error.response?.status === 404) {
return null;
}
throw error;
}
};
/**
* Update or create a custom tier for a business
*/
export const updateCustomTier = async (
businessId: number,
features: Record<string, boolean | number>,
notes?: string
): Promise<TenantCustomTier> => {
const response = await apiClient.put<TenantCustomTier>(
`/platform/businesses/${businessId}/custom_tier/`,
{ features, notes }
);
return response.data;
};
/**
* Delete a business's custom tier
*/
export const deleteCustomTier = async (businessId: number): Promise<void> => {
await apiClient.delete(`/platform/businesses/${businessId}/custom_tier/`);
};

View File

@@ -0,0 +1,376 @@
/**
* AddOnEditorModal Component
*
* Modal for creating or editing add-on products with feature selection.
*/
import React, { useState, useEffect } from 'react';
import { Loader2 } from 'lucide-react';
import { Modal, FormInput, Alert } from '../../components/ui';
import { FeaturePicker } from './FeaturePicker';
import {
useFeatures,
useCreateAddOnProduct,
useUpdateAddOnProduct,
type AddOnProduct,
type AddOnFeatureWrite,
} from '../../hooks/useBillingAdmin';
// =============================================================================
// Types
// =============================================================================
export interface AddOnEditorModalProps {
isOpen: boolean;
onClose: () => void;
addon?: AddOnProduct | null;
}
interface FormData {
code: string;
name: string;
description: string;
price_monthly_cents: number;
price_one_time_cents: number;
stripe_product_id: string;
stripe_price_id: string;
is_stackable: boolean;
is_active: boolean;
selectedFeatures: AddOnFeatureWrite[];
}
// =============================================================================
// Component
// =============================================================================
export const AddOnEditorModal: React.FC<AddOnEditorModalProps> = ({
isOpen,
onClose,
addon,
}) => {
const isEditMode = !!addon;
const [formData, setFormData] = useState<FormData>({
code: '',
name: '',
description: '',
price_monthly_cents: 0,
price_one_time_cents: 0,
stripe_product_id: '',
stripe_price_id: '',
is_stackable: false,
is_active: true,
selectedFeatures: [],
});
const [errors, setErrors] = useState<Partial<Record<keyof FormData, string>>>({});
// Fetch features
const { data: features, isLoading: featuresLoading } = useFeatures();
const createMutation = useCreateAddOnProduct();
const updateMutation = useUpdateAddOnProduct();
// Initialize form when addon changes
useEffect(() => {
if (addon) {
setFormData({
code: addon.code,
name: addon.name,
description: addon.description || '',
price_monthly_cents: addon.price_monthly_cents,
price_one_time_cents: addon.price_one_time_cents,
stripe_product_id: addon.stripe_product_id || '',
stripe_price_id: addon.stripe_price_id || '',
is_stackable: addon.is_stackable,
is_active: addon.is_active,
selectedFeatures:
addon.features?.map((af) => ({
feature_code: af.feature.code,
bool_value: af.bool_value,
int_value: af.int_value,
})) || [],
});
} else {
setFormData({
code: '',
name: '',
description: '',
price_monthly_cents: 0,
price_one_time_cents: 0,
stripe_product_id: '',
stripe_price_id: '',
is_stackable: false,
is_active: true,
selectedFeatures: [],
});
}
setErrors({});
}, [addon, isOpen]);
const validate = (): boolean => {
const newErrors: Partial<Record<keyof FormData, string>> = {};
if (!formData.code.trim()) {
newErrors.code = 'Code is required';
} else if (!/^[a-z0-9_]+$/.test(formData.code)) {
newErrors.code = 'Code must be lowercase letters, numbers, and underscores only';
}
if (!formData.name.trim()) {
newErrors.name = 'Name is required';
}
if (formData.price_monthly_cents < 0) {
newErrors.price_monthly_cents = 'Price cannot be negative';
}
if (formData.price_one_time_cents < 0) {
newErrors.price_one_time_cents = 'Price cannot be negative';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async () => {
if (!validate()) return;
const payload = {
code: formData.code,
name: formData.name,
description: formData.description,
price_monthly_cents: formData.price_monthly_cents,
price_one_time_cents: formData.price_one_time_cents,
stripe_product_id: formData.stripe_product_id,
stripe_price_id: formData.stripe_price_id,
is_stackable: formData.is_stackable,
is_active: formData.is_active,
features: formData.selectedFeatures,
};
try {
if (isEditMode && addon) {
await updateMutation.mutateAsync({
id: addon.id,
...payload,
});
} else {
await createMutation.mutateAsync(payload);
}
onClose();
} catch (error) {
console.error('Failed to save add-on:', error);
}
};
const handleChange = (field: keyof FormData, value: string | number | boolean) => {
setFormData((prev) => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
};
const isPending = createMutation.isPending || updateMutation.isPending;
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={isEditMode ? `Edit ${addon?.name}` : 'Create Add-On'}
size="4xl"
>
<div className="space-y-6">
{/* Basic Info */}
<div className="space-y-4">
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Basic Information</h4>
<div className="grid grid-cols-2 gap-4">
<FormInput
label="Code"
value={formData.code}
onChange={(e) => handleChange('code', e.target.value)}
error={errors.code}
placeholder="sms_credits_pack"
disabled={isEditMode}
hint={isEditMode ? 'Code cannot be changed' : 'Unique identifier (lowercase, underscores)'}
/>
<FormInput
label="Name"
value={formData.name}
onChange={(e) => handleChange('name', e.target.value)}
error={errors.name}
placeholder="SMS Credits Pack"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Description
</label>
<textarea
value={formData.description}
onChange={(e) => handleChange('description', e.target.value)}
placeholder="Description of the add-on..."
rows={2}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
/>
</div>
<div className="flex items-center gap-6">
<div className="flex items-center gap-3">
<input
type="checkbox"
id="is_active"
checked={formData.is_active}
onChange={(e) => handleChange('is_active', e.target.checked)}
className="rounded border-gray-300 dark:border-gray-600"
/>
<label htmlFor="is_active" className="text-sm text-gray-700 dark:text-gray-300">
Active (available for purchase)
</label>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="is_stackable"
checked={formData.is_stackable}
onChange={(e) => handleChange('is_stackable', e.target.checked)}
className="rounded border-gray-300 dark:border-gray-600"
/>
<label htmlFor="is_stackable" className="text-sm text-gray-700 dark:text-gray-300">
Stackable (can purchase multiple, values compound)
</label>
</div>
</div>
</div>
{/* Pricing */}
<div className="space-y-4">
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Pricing</h4>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Monthly Price
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
type="number"
min="0"
step="0.01"
value={(formData.price_monthly_cents / 100).toFixed(2)}
onChange={(e) =>
handleChange('price_monthly_cents', Math.round(parseFloat(e.target.value || '0') * 100))
}
className="w-full pl-7 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
/>
</div>
{errors.price_monthly_cents && (
<p className="mt-1 text-sm text-red-600">{errors.price_monthly_cents}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
One-Time Price
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
type="number"
min="0"
step="0.01"
value={(formData.price_one_time_cents / 100).toFixed(2)}
onChange={(e) =>
handleChange('price_one_time_cents', Math.round(parseFloat(e.target.value || '0') * 100))
}
className="w-full pl-7 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
/>
</div>
{errors.price_one_time_cents && (
<p className="mt-1 text-sm text-red-600">{errors.price_one_time_cents}</p>
)}
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
For one-time purchases (credits, etc.)
</p>
</div>
</div>
</div>
{/* Features */}
<div className="space-y-4">
<h4 className="text-sm font-medium text-gray-900 dark:text-white">
Features Granted by This Add-On
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Select the features that subscribers will receive when they purchase this add-on.
</p>
{featuresLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-blue-600" />
</div>
) : (
<FeaturePicker
features={features || []}
selectedFeatures={formData.selectedFeatures}
onChange={(selected) =>
setFormData((prev) => ({ ...prev, selectedFeatures: selected }))
}
/>
)}
</div>
{/* Stripe Integration */}
<div className="space-y-4">
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Stripe Integration</h4>
<div className="grid grid-cols-2 gap-4">
<FormInput
label="Stripe Product ID"
value={formData.stripe_product_id}
onChange={(e) => handleChange('stripe_product_id', e.target.value)}
placeholder="prod_..."
/>
<FormInput
label="Stripe Price ID"
value={formData.stripe_price_id}
onChange={(e) => handleChange('stripe_price_id', e.target.value)}
placeholder="price_..."
/>
</div>
{!formData.stripe_product_id && (
<Alert
variant="info"
message="Configure Stripe IDs to enable purchasing. Create the product in Stripe Dashboard first."
/>
)}
</div>
</div>
{/* Footer */}
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
>
Cancel
</button>
<button
type="button"
onClick={handleSubmit}
disabled={isPending}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isPending && <Loader2 className="w-4 h-4 animate-spin" />}
{isEditMode ? 'Save Changes' : 'Create Add-On'}
</button>
</div>
</Modal>
);
};

View File

@@ -0,0 +1,337 @@
/**
* CatalogListPanel Component
*
* Left sidebar panel displaying a searchable, filterable list of plans and add-ons.
* Supports filtering by type, status, visibility, and legacy status.
*/
import React, { useState, useMemo } from 'react';
import { Search, Plus, Package, Puzzle, Eye, EyeOff, Archive } from 'lucide-react';
import { Badge } from '../../components/ui';
// =============================================================================
// Types
// =============================================================================
export interface CatalogItem {
id: number;
type: 'plan' | 'addon';
code: string;
name: string;
isActive: boolean;
isPublic: boolean;
isLegacy: boolean;
priceMonthly?: number;
priceYearly?: number;
subscriberCount?: number;
stripeProductId?: string;
}
export interface CatalogListPanelProps {
items: CatalogItem[];
selectedId: number | null;
onSelect: (item: CatalogItem) => void;
onCreatePlan: () => void;
onCreateAddon: () => void;
isLoading?: boolean;
}
type TypeFilter = 'all' | 'plan' | 'addon';
type StatusFilter = 'all' | 'active' | 'inactive';
type VisibilityFilter = 'all' | 'public' | 'hidden';
type LegacyFilter = 'all' | 'current' | 'legacy';
// =============================================================================
// Component
// =============================================================================
export const CatalogListPanel: React.FC<CatalogListPanelProps> = ({
items,
selectedId,
onSelect,
onCreatePlan,
onCreateAddon,
isLoading = false,
}) => {
const [searchTerm, setSearchTerm] = useState('');
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const [visibilityFilter, setVisibilityFilter] = useState<VisibilityFilter>('all');
const [legacyFilter, setLegacyFilter] = useState<LegacyFilter>('all');
// Filter items
const filteredItems = useMemo(() => {
return items.filter((item) => {
// Type filter
if (typeFilter !== 'all' && item.type !== typeFilter) return false;
// Status filter
if (statusFilter === 'active' && !item.isActive) return false;
if (statusFilter === 'inactive' && item.isActive) return false;
// Visibility filter
if (visibilityFilter === 'public' && !item.isPublic) return false;
if (visibilityFilter === 'hidden' && item.isPublic) return false;
// Legacy filter
if (legacyFilter === 'current' && item.isLegacy) return false;
if (legacyFilter === 'legacy' && !item.isLegacy) return false;
// Search filter
if (searchTerm) {
const term = searchTerm.toLowerCase();
return (
item.name.toLowerCase().includes(term) ||
item.code.toLowerCase().includes(term)
);
}
return true;
});
}, [items, typeFilter, statusFilter, visibilityFilter, legacyFilter, searchTerm]);
const formatPrice = (cents?: number): string => {
if (cents === undefined || cents === 0) return 'Free';
return `$${(cents / 100).toFixed(2)}`;
};
return (
<div className="flex flex-col h-full bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700">
{/* Header with Create buttons */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex gap-2">
<button
onClick={onCreatePlan}
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700"
>
<Plus className="w-4 h-4" />
Create Plan
</button>
<button
onClick={onCreateAddon}
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700"
>
<Plus className="w-4 h-4" />
Create Add-on
</button>
</div>
</div>
{/* Search */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search by name or code..."
className="w-full pl-10 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
</div>
{/* Filters */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700 space-y-3">
<div className="grid grid-cols-2 gap-2">
<div>
<label htmlFor="type-filter" className="sr-only">
Type
</label>
<select
id="type-filter"
aria-label="Type"
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value as TypeFilter)}
className="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="all">All Types</option>
<option value="plan">Base Plans</option>
<option value="addon">Add-ons</option>
</select>
</div>
<div>
<label htmlFor="status-filter" className="sr-only">
Status
</label>
<select
id="status-filter"
aria-label="Status"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as StatusFilter)}
className="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label htmlFor="visibility-filter" className="sr-only">
Visibility
</label>
<select
id="visibility-filter"
aria-label="Visibility"
value={visibilityFilter}
onChange={(e) => setVisibilityFilter(e.target.value as VisibilityFilter)}
className="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="all">All Visibility</option>
<option value="public">Public</option>
<option value="hidden">Hidden</option>
</select>
</div>
<div>
<label htmlFor="legacy-filter" className="sr-only">
Legacy
</label>
<select
id="legacy-filter"
aria-label="Legacy"
value={legacyFilter}
onChange={(e) => setLegacyFilter(e.target.value as LegacyFilter)}
className="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="all">All Versions</option>
<option value="current">Current</option>
<option value="legacy">Legacy</option>
</select>
</div>
</div>
</div>
{/* Items List */}
<div className="flex-1 overflow-y-auto">
{filteredItems.length === 0 ? (
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
<Package className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p>No items found</p>
{searchTerm && (
<p className="text-xs mt-1">Try adjusting your search or filters</p>
)}
</div>
) : (
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{filteredItems.map((item) => (
<CatalogListItem
key={`${item.type}-${item.id}`}
item={item}
isSelected={selectedId === item.id}
onSelect={() => onSelect(item)}
formatPrice={formatPrice}
/>
))}
</div>
)}
</div>
</div>
);
};
// =============================================================================
// List Item Component
// =============================================================================
interface CatalogListItemProps {
item: CatalogItem;
isSelected: boolean;
onSelect: () => void;
formatPrice: (cents?: number) => string;
}
const CatalogListItem: React.FC<CatalogListItemProps> = ({
item,
isSelected,
onSelect,
formatPrice,
}) => {
return (
<button
onClick={onSelect}
className={`w-full p-4 text-left transition-colors ${
isSelected
? 'bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-600'
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50'
}`}
>
<div className="flex items-start gap-3">
{/* Icon */}
<div
className={`p-2 rounded-lg ${
item.type === 'plan'
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400'
: 'bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400'
}`}
>
{item.type === 'plan' ? (
<Package className="w-4 h-4" />
) : (
<Puzzle className="w-4 h-4" />
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-gray-900 dark:text-white truncate">
{item.name}
</span>
<span className="px-1.5 py-0.5 text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded">
{item.code}
</span>
</div>
{/* Badges */}
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
{/* Type badge */}
<span
className={`px-1.5 py-0.5 text-xs font-medium rounded ${
item.type === 'plan'
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400'
: 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400'
}`}
>
{item.type === 'plan' ? 'Base' : 'Add-on'}
</span>
{/* Status badge */}
{!item.isActive && (
<span className="px-1.5 py-0.5 text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded">
Inactive
</span>
)}
{/* Visibility badge */}
{!item.isPublic && (
<span className="px-1.5 py-0.5 text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded flex items-center gap-1">
<EyeOff className="w-3 h-3" />
Hidden
</span>
)}
{/* Legacy badge */}
{item.isLegacy && (
<span className="px-1.5 py-0.5 text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded flex items-center gap-1">
<Archive className="w-3 h-3" />
Legacy
</span>
)}
</div>
{/* Price and subscriber count */}
<div className="flex items-center gap-3 mt-2 text-xs text-gray-500 dark:text-gray-400">
<span className="font-medium text-gray-900 dark:text-white">
{formatPrice(item.priceMonthly)}/mo
</span>
{item.subscriberCount !== undefined && (
<span>{item.subscriberCount} subscribers</span>
)}
</div>
</div>
</div>
</button>
);
};

View File

@@ -0,0 +1,252 @@
/**
* FeaturePicker Component
*
* A searchable picker for selecting features to include in a plan or version.
* Features are grouped by type (boolean capabilities vs integer limits).
* Features are loaded dynamically from the billing API.
*/
import React, { useState, useMemo } from 'react';
import { Check, Sliders, Search, X } from 'lucide-react';
import type { Feature, PlanFeatureWrite } from '../../hooks/useBillingAdmin';
export interface FeaturePickerProps {
/** Available features from the API */
features: Feature[];
/** Currently selected features with their values */
selectedFeatures: PlanFeatureWrite[];
/** Callback when selection changes */
onChange: (features: PlanFeatureWrite[]) => void;
/** Optional: Show compact view */
compact?: boolean;
}
export const FeaturePicker: React.FC<FeaturePickerProps> = ({
features,
selectedFeatures,
onChange,
compact = false,
}) => {
const [searchTerm, setSearchTerm] = useState('');
// Group features by type
const { booleanFeatures, integerFeatures } = useMemo(() => {
const boolean = features.filter((f) => f.feature_type === 'boolean');
const integer = features.filter((f) => f.feature_type === 'integer');
return { booleanFeatures: boolean, integerFeatures: integer };
}, [features]);
// Filter by search term
const filteredBooleanFeatures = useMemo(() => {
if (!searchTerm) return booleanFeatures;
const term = searchTerm.toLowerCase();
return booleanFeatures.filter(
(f) =>
f.name.toLowerCase().includes(term) ||
f.code.toLowerCase().includes(term) ||
f.description?.toLowerCase().includes(term)
);
}, [booleanFeatures, searchTerm]);
const filteredIntegerFeatures = useMemo(() => {
if (!searchTerm) return integerFeatures;
const term = searchTerm.toLowerCase();
return integerFeatures.filter(
(f) =>
f.name.toLowerCase().includes(term) ||
f.code.toLowerCase().includes(term) ||
f.description?.toLowerCase().includes(term)
);
}, [integerFeatures, searchTerm]);
const hasNoResults =
searchTerm && filteredBooleanFeatures.length === 0 && filteredIntegerFeatures.length === 0;
// Check if a feature is selected
const isSelected = (code: string): boolean => {
return selectedFeatures.some((f) => f.feature_code === code);
};
// Get selected feature data
const getSelectedFeature = (code: string): PlanFeatureWrite | undefined => {
return selectedFeatures.find((f) => f.feature_code === code);
};
// Toggle boolean feature selection
const toggleBooleanFeature = (code: string) => {
if (isSelected(code)) {
onChange(selectedFeatures.filter((f) => f.feature_code !== code));
} else {
onChange([
...selectedFeatures,
{ feature_code: code, bool_value: true, int_value: null },
]);
}
};
// Toggle integer feature selection
const toggleIntegerFeature = (code: string) => {
if (isSelected(code)) {
onChange(selectedFeatures.filter((f) => f.feature_code !== code));
} else {
onChange([
...selectedFeatures,
{ feature_code: code, bool_value: null, int_value: 0 },
]);
}
};
// Update integer feature value
const updateIntegerValue = (code: string, value: number) => {
onChange(
selectedFeatures.map((f) =>
f.feature_code === code ? { ...f, int_value: value } : f
)
);
};
const clearSearch = () => {
setSearchTerm('');
};
return (
<div className="space-y-6">
{/* Search Box */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search features..."
className="w-full pl-10 pr-10 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
/>
{searchTerm && (
<button
type="button"
onClick={clearSearch}
aria-label="Clear search"
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* No Results Message */}
{hasNoResults && (
<div className="p-8 text-center text-gray-500 dark:text-gray-400 border border-dashed border-gray-300 dark:border-gray-600 rounded-lg">
<Search className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p>No features found matching "{searchTerm}"</p>
</div>
)}
{/* Boolean Features (Capabilities) */}
{filteredBooleanFeatures.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3 flex items-center gap-2">
<Check className="w-4 h-4" /> Capabilities
</h4>
<div className={`grid ${compact ? 'grid-cols-1' : 'grid-cols-2'} gap-2`}>
{filteredBooleanFeatures.map((feature) => {
const selected = isSelected(feature.code);
return (
<label
key={feature.id}
className={`flex items-start gap-3 p-3 border rounded-lg cursor-pointer transition-colors ${
selected
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'
}`}
>
<input
type="checkbox"
checked={selected}
onChange={() => toggleBooleanFeature(feature.code)}
aria-label={feature.name}
className="mt-0.5 rounded border-gray-300 dark:border-gray-600"
/>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-gray-900 dark:text-white">
{feature.name}
</span>
{feature.description && (
<span className="text-xs text-gray-500 dark:text-gray-400 block mt-0.5">
{feature.description}
</span>
)}
</div>
</label>
);
})}
</div>
</div>
)}
{/* Integer Features (Limits & Quotas) */}
{filteredIntegerFeatures.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3 flex items-center gap-2">
<Sliders className="w-4 h-4" /> Limits & Quotas
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
Set to 0 for unlimited. Uncheck to exclude from plan.
</p>
<div className={`grid ${compact ? 'grid-cols-1' : 'grid-cols-2'} gap-3`}>
{filteredIntegerFeatures.map((feature) => {
const selectedFeature = getSelectedFeature(feature.code);
const selected = !!selectedFeature;
return (
<div
key={feature.id}
className={`flex items-center gap-3 p-3 border rounded-lg ${
selected
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700'
}`}
>
<label className="flex items-center gap-3 flex-1 min-w-0 cursor-pointer">
<input
type="checkbox"
checked={selected}
onChange={() => toggleIntegerFeature(feature.code)}
aria-label={feature.name}
className="rounded border-gray-300 dark:border-gray-600"
/>
<span className="text-sm font-medium text-gray-900 dark:text-white truncate flex-1 min-w-0">
{feature.name}
</span>
</label>
{selected && (
<input
type="number"
min="0"
value={selectedFeature?.int_value || 0}
onChange={(e) =>
updateIntegerValue(feature.code, parseInt(e.target.value) || 0)
}
aria-label={`${feature.name} limit value`}
className="w-20 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="0"
/>
)}
</div>
);
})}
</div>
</div>
)}
{/* Empty State */}
{!searchTerm && features.length === 0 && (
<div className="p-8 text-center text-gray-500 dark:text-gray-400 border border-dashed border-gray-300 dark:border-gray-600 rounded-lg">
<Sliders className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p>No features defined yet.</p>
<p className="text-xs mt-1">Add features in the Features Library tab first.</p>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,766 @@
/**
* PlanDetailPanel Component
*
* Detail view for a selected plan or add-on, shown in the main panel
* of the master-detail layout.
*/
import React, { useState } from 'react';
import {
Package,
Pencil,
Copy,
Trash2,
DollarSign,
Users,
Check,
AlertTriangle,
ExternalLink,
ChevronDown,
ChevronRight,
Plus,
Archive,
} from 'lucide-react';
import { Badge, Alert, Modal, ModalFooter } from '../../components/ui';
import {
usePlanVersionSubscribers,
useDeletePlan,
useDeletePlanVersion,
useMarkVersionLegacy,
useForceUpdatePlanVersion,
formatCentsToDollars,
type PlanWithVersions,
type PlanVersion,
type AddOnProduct,
} from '../../hooks/useBillingAdmin';
import { useCurrentUser } from '../../hooks/useAuth';
// =============================================================================
// Types
// =============================================================================
interface PlanDetailPanelProps {
plan: PlanWithVersions | null;
addon: AddOnProduct | null;
onEdit: () => void;
onDuplicate: () => void;
onCreateVersion: () => void;
onEditVersion: (version: PlanVersion) => void;
}
// =============================================================================
// Component
// =============================================================================
export const PlanDetailPanel: React.FC<PlanDetailPanelProps> = ({
plan,
addon,
onEdit,
onDuplicate,
onCreateVersion,
onEditVersion,
}) => {
const [expandedSections, setExpandedSections] = useState<Set<string>>(
new Set(['overview', 'pricing', 'features'])
);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState('');
const [showForcePushModal, setShowForcePushModal] = useState(false);
const [forcePushConfirmText, setForcePushConfirmText] = useState('');
const [forcePushError, setForcePushError] = useState<string | null>(null);
const [forcePushSuccess, setForcePushSuccess] = useState<string | null>(null);
const { data: currentUser } = useCurrentUser();
const isSuperuser = currentUser?.is_superuser ?? false;
const deletePlanMutation = useDeletePlan();
const deleteVersionMutation = useDeletePlanVersion();
const markLegacyMutation = useMarkVersionLegacy();
const forceUpdateMutation = useForceUpdatePlanVersion();
if (!plan && !addon) {
return (
<div className="flex-1 flex items-center justify-center text-gray-500 dark:text-gray-400">
<div className="text-center">
<Package className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>Select a plan or add-on from the catalog</p>
</div>
</div>
);
}
const toggleSection = (section: string) => {
setExpandedSections((prev) => {
const next = new Set(prev);
if (next.has(section)) {
next.delete(section);
} else {
next.add(section);
}
return next;
});
};
const activeVersion = plan?.active_version;
const handleDelete = async () => {
if (!plan) return;
const expectedText = `DELETE ${plan.code}`;
if (deleteConfirmText !== expectedText) {
return;
}
try {
await deletePlanMutation.mutateAsync(plan.id);
setShowDeleteConfirm(false);
setDeleteConfirmText('');
} catch (error) {
console.error('Failed to delete plan:', error);
}
};
const handleForcePush = async () => {
if (!plan || !activeVersion) return;
const expectedText = `FORCE PUSH ${plan.code}`;
if (forcePushConfirmText !== expectedText) {
setForcePushError('Please type the confirmation text exactly.');
return;
}
setForcePushError(null);
try {
const result = await forceUpdateMutation.mutateAsync({
id: activeVersion.id,
confirm: true,
// Pass current version data to ensure it's updated in place
name: activeVersion.name,
});
if ('version' in result) {
setForcePushSuccess(
`Successfully pushed changes to ${result.affected_count} subscriber(s).`
);
setTimeout(() => {
setShowForcePushModal(false);
setForcePushConfirmText('');
setForcePushSuccess(null);
}, 2000);
}
} catch (error: any) {
const errorMessage = error.response?.data?.detail || error.message || 'Failed to force push';
setForcePushError(errorMessage);
}
};
// Render Plan Detail
if (plan) {
return (
<div className="flex-1 overflow-y-auto">
{/* Header */}
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-6 z-10">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-3 mb-2">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
{plan.name}
</h2>
<span className="px-2 py-1 text-sm font-medium bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded">
{plan.code}
</span>
{!plan.is_active && <Badge variant="warning">Inactive</Badge>}
</div>
{plan.description && (
<p className="text-gray-600 dark:text-gray-400">{plan.description}</p>
)}
{activeVersion && (
<div className="flex items-center gap-4 mt-3 text-sm text-gray-500 dark:text-gray-400">
<span className="flex items-center gap-1">
<DollarSign className="w-4 h-4" />
{activeVersion.price_monthly_cents === 0
? 'Free'
: `$${formatCentsToDollars(activeVersion.price_monthly_cents)}/mo`}
</span>
<span className="flex items-center gap-1">
<Users className="w-4 h-4" />
{plan.total_subscribers} subscriber{plan.total_subscribers !== 1 ? 's' : ''}
</span>
<span>Version {activeVersion.version}</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={onEdit}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
>
<Pencil className="w-4 h-4" />
Edit
</button>
<button
onClick={onDuplicate}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
>
<Copy className="w-4 h-4" />
Duplicate
</button>
<button
onClick={onCreateVersion}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700"
>
<Plus className="w-4 h-4" />
New Version
</button>
</div>
</div>
</div>
<div className="p-6 space-y-6">
{/* Overview Section */}
<CollapsibleSection
title="Overview"
isExpanded={expandedSections.has('overview')}
onToggle={() => toggleSection('overview')}
>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-gray-500 dark:text-gray-400">Plan Code</label>
<p className="font-medium text-gray-900 dark:text-white">{plan.code}</p>
</div>
<div>
<label className="text-sm text-gray-500 dark:text-gray-400">Display Order</label>
<p className="font-medium text-gray-900 dark:text-white">{plan.display_order}</p>
</div>
<div>
<label className="text-sm text-gray-500 dark:text-gray-400">Status</label>
<p className="font-medium text-gray-900 dark:text-white">
{plan.is_active ? 'Active' : 'Inactive'}
</p>
</div>
<div>
<label className="text-sm text-gray-500 dark:text-gray-400">Total Subscribers</label>
<p className="font-medium text-gray-900 dark:text-white">{plan.total_subscribers}</p>
</div>
</div>
</CollapsibleSection>
{/* Pricing Section */}
{activeVersion && (
<CollapsibleSection
title="Pricing"
isExpanded={expandedSections.has('pricing')}
onToggle={() => toggleSection('pricing')}
>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="text-sm text-gray-500 dark:text-gray-400">Monthly</label>
<p className="text-xl font-bold text-gray-900 dark:text-white">
${formatCentsToDollars(activeVersion.price_monthly_cents)}
</p>
</div>
<div>
<label className="text-sm text-gray-500 dark:text-gray-400">Yearly</label>
<p className="text-xl font-bold text-gray-900 dark:text-white">
${formatCentsToDollars(activeVersion.price_yearly_cents)}
</p>
{activeVersion.price_yearly_cents > 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400">
${(activeVersion.price_yearly_cents / 12 / 100).toFixed(2)}/mo
</p>
)}
</div>
<div>
<label className="text-sm text-gray-500 dark:text-gray-400">Trial</label>
<p className="text-xl font-bold text-gray-900 dark:text-white">
{activeVersion.trial_days} days
</p>
</div>
</div>
</CollapsibleSection>
)}
{/* Transaction Fees Section */}
{activeVersion && (
<CollapsibleSection
title="Transaction Fees"
isExpanded={expandedSections.has('fees')}
onToggle={() => toggleSection('fees')}
>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-gray-500 dark:text-gray-400">Percentage</label>
<p className="font-medium text-gray-900 dark:text-white">
{activeVersion.transaction_fee_percent}%
</p>
</div>
<div>
<label className="text-sm text-gray-500 dark:text-gray-400">Fixed Fee</label>
<p className="font-medium text-gray-900 dark:text-white">
${(activeVersion.transaction_fee_fixed_cents / 100).toFixed(2)}
</p>
</div>
</div>
</CollapsibleSection>
)}
{/* Features Section */}
{activeVersion && activeVersion.features.length > 0 && (
<CollapsibleSection
title={`Features (${activeVersion.features.length})`}
isExpanded={expandedSections.has('features')}
onToggle={() => toggleSection('features')}
>
<div className="grid grid-cols-2 gap-2">
{activeVersion.features.map((f) => (
<div
key={f.id}
className="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-700/50 rounded"
>
<Check className="w-4 h-4 text-green-500 flex-shrink-0" />
<span className="text-sm text-gray-900 dark:text-white">
{f.feature.name}
</span>
{f.int_value !== null && (
<span className="ml-auto text-sm text-gray-500 dark:text-gray-400">
{f.int_value === 0 ? 'Unlimited' : f.int_value}
</span>
)}
</div>
))}
</div>
</CollapsibleSection>
)}
{/* Stripe Section */}
{activeVersion &&
(activeVersion.stripe_product_id ||
activeVersion.stripe_price_id_monthly ||
activeVersion.stripe_price_id_yearly) && (
<CollapsibleSection
title="Stripe Integration"
isExpanded={expandedSections.has('stripe')}
onToggle={() => toggleSection('stripe')}
>
<div className="space-y-2">
{activeVersion.stripe_product_id && (
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500 dark:text-gray-400">Product:</span>
<code className="text-sm bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
{activeVersion.stripe_product_id}
</code>
</div>
)}
{activeVersion.stripe_price_id_monthly && (
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500 dark:text-gray-400">
Monthly Price:
</span>
<code className="text-sm bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
{activeVersion.stripe_price_id_monthly}
</code>
</div>
)}
{activeVersion.stripe_price_id_yearly && (
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500 dark:text-gray-400">
Yearly Price:
</span>
<code className="text-sm bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
{activeVersion.stripe_price_id_yearly}
</code>
</div>
)}
{!activeVersion.stripe_product_id && (
<Alert
variant="warning"
message="No Stripe Product ID configured. This plan cannot be purchased until Stripe is set up."
/>
)}
</div>
</CollapsibleSection>
)}
{/* Versions Section */}
<CollapsibleSection
title={`Versions (${plan.versions.length})`}
isExpanded={expandedSections.has('versions')}
onToggle={() => toggleSection('versions')}
>
<div className="space-y-2">
{plan.versions.map((version) => (
<VersionRow
key={version.id}
version={version}
isActive={!version.is_legacy}
onEdit={() => onEditVersion(version)}
onMarkLegacy={() => markLegacyMutation.mutate(version.id)}
onDelete={() => deleteVersionMutation.mutate(version.id)}
/>
))}
</div>
</CollapsibleSection>
{/* Danger Zone */}
<CollapsibleSection
title="Danger Zone"
isExpanded={expandedSections.has('danger')}
onToggle={() => toggleSection('danger')}
variant="danger"
>
<div className="space-y-4">
{/* Force Push to Subscribers - Superuser Only */}
{isSuperuser && activeVersion && plan.total_subscribers > 0 && (
<div className="p-4 border border-orange-200 dark:border-orange-800 rounded-lg bg-orange-50 dark:bg-orange-900/20">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-orange-600 dark:text-orange-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h4 className="font-medium text-orange-800 dark:text-orange-200 mb-1">
Force Push Changes to All Subscribers
</h4>
<p className="text-sm text-orange-700 dark:text-orange-300 mb-3">
This will modify the current plan version in place, immediately affecting
all {plan.total_subscribers} active subscriber(s). This bypasses grandfathering
and cannot be undone. Changes to pricing, features, and limits will take
effect immediately.
</p>
<button
onClick={() => {
setShowForcePushModal(true);
setForcePushError(null);
setForcePushSuccess(null);
setForcePushConfirmText('');
}}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-orange-600 rounded-lg hover:bg-orange-700"
>
<AlertTriangle className="w-4 h-4" />
Force Push to Subscribers
</button>
</div>
</div>
</div>
)}
{/* Delete Plan */}
<div className="p-4 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Deleting a plan is permanent and cannot be undone. Plans with active subscribers
cannot be deleted.
</p>
{plan.total_subscribers > 0 ? (
<Alert
variant="warning"
message={`This plan has ${plan.total_subscribers} active subscriber(s) and cannot be deleted.`}
/>
) : (
<button
onClick={() => setShowDeleteConfirm(true)}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700"
>
<Trash2 className="w-4 h-4" />
Delete Plan
</button>
)}
</div>
</div>
</CollapsibleSection>
</div>
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<Modal
isOpen
onClose={() => {
setShowDeleteConfirm(false);
setDeleteConfirmText('');
}}
title="Delete Plan"
size="sm"
>
<div className="space-y-4">
<Alert
variant="error"
message="This action cannot be undone. This will permanently delete the plan and all its versions."
/>
<p className="text-sm text-gray-600 dark:text-gray-400">
To confirm, type <strong>DELETE {plan.code}</strong> below:
</p>
<input
type="text"
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder={`DELETE ${plan.code}`}
/>
</div>
<ModalFooter
onCancel={() => {
setShowDeleteConfirm(false);
setDeleteConfirmText('');
}}
submitText="Delete Plan"
submitVariant="danger"
isDisabled={deleteConfirmText !== `DELETE ${plan.code}`}
isLoading={deletePlanMutation.isPending}
onSubmit={handleDelete}
/>
</Modal>
)}
{/* Force Push Confirmation Modal */}
{showForcePushModal && activeVersion && (
<Modal
isOpen
onClose={() => {
setShowForcePushModal(false);
setForcePushConfirmText('');
setForcePushError(null);
setForcePushSuccess(null);
}}
title="Force Push to All Subscribers"
size="md"
>
<div className="space-y-4">
{forcePushSuccess ? (
<Alert variant="success" message={forcePushSuccess} />
) : (
<>
<Alert
variant="error"
message={
<div>
<strong>DANGER: This action affects paying customers!</strong>
<ul className="mt-2 ml-4 list-disc text-sm">
<li>All {plan.total_subscribers} subscriber(s) will be affected immediately</li>
<li>Changes to pricing will apply to future billing cycles</li>
<li>Feature and limit changes take effect immediately</li>
<li>This bypasses grandfathering protection</li>
<li>This action cannot be undone</li>
</ul>
</div>
}
/>
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
<strong>Current version:</strong> v{activeVersion.version} - {activeVersion.name}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
<strong>Price:</strong> ${formatCentsToDollars(activeVersion.price_monthly_cents)}/mo
</p>
</div>
{forcePushError && (
<Alert variant="error" message={forcePushError} />
)}
<p className="text-sm text-gray-600 dark:text-gray-400">
To confirm this dangerous action, type <strong>FORCE PUSH {plan.code}</strong> below:
</p>
<input
type="text"
value={forcePushConfirmText}
onChange={(e) => setForcePushConfirmText(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder={`FORCE PUSH ${plan.code}`}
/>
</>
)}
</div>
{!forcePushSuccess && (
<ModalFooter
onCancel={() => {
setShowForcePushModal(false);
setForcePushConfirmText('');
setForcePushError(null);
}}
submitText="Force Push Changes"
submitVariant="danger"
isDisabled={forcePushConfirmText !== `FORCE PUSH ${plan.code}`}
isLoading={forceUpdateMutation.isPending}
onSubmit={handleForcePush}
/>
)}
</Modal>
)}
</div>
);
}
// Render Add-on Detail
if (addon) {
return (
<div className="flex-1 overflow-y-auto">
<div className="p-6">
<div className="flex items-start justify-between mb-6">
<div>
<div className="flex items-center gap-3 mb-2">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
{addon.name}
</h2>
<span className="px-2 py-1 text-sm font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 rounded">
{addon.code}
</span>
{!addon.is_active && <Badge variant="warning">Inactive</Badge>}
</div>
{addon.description && (
<p className="text-gray-600 dark:text-gray-400">{addon.description}</p>
)}
</div>
<button
onClick={onEdit}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
>
<Pencil className="w-4 h-4" />
Edit
</button>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<label className="text-sm text-gray-500 dark:text-gray-400">Monthly Price</label>
<p className="text-xl font-bold text-gray-900 dark:text-white">
${formatCentsToDollars(addon.price_monthly_cents)}
</p>
</div>
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<label className="text-sm text-gray-500 dark:text-gray-400">One-time Price</label>
<p className="text-xl font-bold text-gray-900 dark:text-white">
${formatCentsToDollars(addon.price_one_time_cents)}
</p>
</div>
</div>
</div>
</div>
);
}
return null;
};
// =============================================================================
// Collapsible Section
// =============================================================================
interface CollapsibleSectionProps {
title: string;
isExpanded: boolean;
onToggle: () => void;
children: React.ReactNode;
variant?: 'default' | 'danger';
}
const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
title,
isExpanded,
onToggle,
children,
variant = 'default',
}) => {
return (
<div
className={`border rounded-lg ${
variant === 'danger'
? 'border-red-200 dark:border-red-800'
: 'border-gray-200 dark:border-gray-700'
}`}
>
<button
onClick={onToggle}
className={`w-full flex items-center justify-between p-4 text-left ${
variant === 'danger'
? 'text-red-700 dark:text-red-400'
: 'text-gray-900 dark:text-white'
}`}
>
<span className="font-medium">{title}</span>
{isExpanded ? (
<ChevronDown className="w-5 h-5" />
) : (
<ChevronRight className="w-5 h-5" />
)}
</button>
{isExpanded && (
<div className="px-4 pb-4 border-t border-gray-200 dark:border-gray-700 pt-4">
{children}
</div>
)}
</div>
);
};
// =============================================================================
// Version Row
// =============================================================================
interface VersionRowProps {
version: PlanVersion;
isActive: boolean;
onEdit: () => void;
onMarkLegacy: () => void;
onDelete: () => void;
}
const VersionRow: React.FC<VersionRowProps> = ({
version,
isActive,
onEdit,
onMarkLegacy,
onDelete,
}) => {
return (
<div
className={`flex items-center justify-between p-3 rounded-lg ${
version.is_legacy
? 'bg-gray-50 dark:bg-gray-700/50'
: 'bg-blue-50 dark:bg-blue-900/20'
}`}
>
<div className="flex items-center gap-3">
<span className="font-medium text-gray-900 dark:text-white">v{version.version}</span>
<span className="text-gray-500 dark:text-gray-400">{version.name}</span>
{version.is_legacy && (
<span className="px-2 py-0.5 text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded">
Legacy
</span>
)}
{!version.is_public && !version.is_legacy && (
<span className="px-2 py-0.5 text-xs font-medium bg-gray-100 dark:bg-gray-600 text-gray-600 dark:text-gray-300 rounded">
Hidden
</span>
)}
<span className="text-sm text-gray-500 dark:text-gray-400">
{version.subscriber_count} subscriber{version.subscriber_count !== 1 ? 's' : ''}
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={onEdit}
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="Edit version"
>
<Pencil className="w-4 h-4" />
</button>
{!version.is_legacy && version.subscriber_count === 0 && (
<button
onClick={onMarkLegacy}
className="p-1 text-gray-400 hover:text-amber-600 dark:hover:text-amber-400"
title="Mark as legacy"
>
<Archive className="w-4 h-4" />
</button>
)}
{version.subscriber_count === 0 && (
<button
onClick={onDelete}
className="p-1 text-gray-400 hover:text-red-600 dark:hover:text-red-400"
title="Delete version"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,992 @@
/**
* PlanEditorWizard Component
*
* A multi-step wizard for creating or editing subscription plans.
* Replaces the large form in PlanModal with guided step-by-step editing.
*
* Steps:
* 1. Basics - Name, code, description, active status
* 2. Pricing - Monthly/yearly prices, trial days, transaction fees
* 3. Features - Feature picker for capabilities and limits
* 4. Display - Visibility, marketing features, Stripe integration
*/
import React, { useState, useMemo } from 'react';
import {
Package,
DollarSign,
Check,
Star,
Loader2,
ChevronLeft,
AlertTriangle,
} from 'lucide-react';
import { Modal, Alert } from '../../components/ui';
import { FeaturePicker } from './FeaturePicker';
import {
useFeatures,
useAddOnProducts,
useCreatePlan,
useCreatePlanVersion,
useUpdatePlan,
useUpdatePlanVersion,
useForceUpdatePlanVersion,
isForceUpdateConfirmRequired,
type PlanFeatureWrite,
} from '../../hooks/useBillingAdmin';
import { useCurrentUser } from '../../hooks/useAuth';
// =============================================================================
// Types
// =============================================================================
export interface PlanEditorWizardProps {
isOpen: boolean;
onClose: () => void;
mode: 'create' | 'edit';
initialData?: {
id?: number;
code?: string;
name?: string;
description?: string;
display_order?: number;
is_active?: boolean;
version?: {
id?: number;
name?: string;
price_monthly_cents?: number;
price_yearly_cents?: number;
transaction_fee_percent?: string | number;
transaction_fee_fixed_cents?: number;
trial_days?: number;
is_public?: boolean;
is_most_popular?: boolean;
show_price?: boolean;
marketing_features?: string[];
stripe_product_id?: string;
stripe_price_id_monthly?: string;
stripe_price_id_yearly?: string;
features?: Array<{
feature: { code: string };
bool_value: boolean | null;
int_value: number | null;
}>;
subscriber_count?: number;
};
};
}
type WizardStep = 'basics' | 'pricing' | 'features' | 'display';
interface WizardFormData {
// Plan fields
code: string;
name: string;
description: string;
display_order: number;
is_active: boolean;
// Version fields
version_name: string;
price_monthly_cents: number;
price_yearly_cents: number;
transaction_fee_percent: number;
transaction_fee_fixed_cents: number;
trial_days: number;
is_public: boolean;
is_most_popular: boolean;
show_price: boolean;
marketing_features: string[];
stripe_product_id: string;
stripe_price_id_monthly: string;
stripe_price_id_yearly: string;
selectedFeatures: PlanFeatureWrite[];
}
// =============================================================================
// Component
// =============================================================================
export const PlanEditorWizard: React.FC<PlanEditorWizardProps> = ({
isOpen,
onClose,
mode,
initialData,
}) => {
const { data: features, isLoading: featuresLoading } = useFeatures();
const { data: addons } = useAddOnProducts();
const { data: currentUser } = useCurrentUser();
const createPlanMutation = useCreatePlan();
const createVersionMutation = useCreatePlanVersion();
const updatePlanMutation = useUpdatePlan();
const updateVersionMutation = useUpdatePlanVersion();
const forceUpdateMutation = useForceUpdatePlanVersion();
const isNewPlan = mode === 'create';
const hasSubscribers = (initialData?.version?.subscriber_count ?? 0) > 0;
const isSuperuser = currentUser?.role === 'superuser';
// Force update state (for updating without creating new version)
const [showForceUpdateConfirm, setShowForceUpdateConfirm] = useState(false);
const [forceUpdateError, setForceUpdateError] = useState<string | null>(null);
const [currentStep, setCurrentStep] = useState<WizardStep>('basics');
const [newMarketingFeature, setNewMarketingFeature] = useState('');
// Form data
const [formData, setFormData] = useState<WizardFormData>(() => ({
// Plan fields
code: initialData?.code || '',
name: initialData?.name || '',
description: initialData?.description || '',
display_order: initialData?.display_order || 0,
is_active: initialData?.is_active ?? true,
// Version fields
version_name: initialData?.version?.name || '',
price_monthly_cents: initialData?.version?.price_monthly_cents || 0,
price_yearly_cents: initialData?.version?.price_yearly_cents || 0,
transaction_fee_percent:
typeof initialData?.version?.transaction_fee_percent === 'string'
? parseFloat(initialData.version.transaction_fee_percent)
: initialData?.version?.transaction_fee_percent || 4.0,
transaction_fee_fixed_cents: initialData?.version?.transaction_fee_fixed_cents || 40,
trial_days: initialData?.version?.trial_days || 14,
is_public: initialData?.version?.is_public ?? true,
is_most_popular: initialData?.version?.is_most_popular || false,
show_price: initialData?.version?.show_price ?? true,
marketing_features: initialData?.version?.marketing_features || [],
stripe_product_id: initialData?.version?.stripe_product_id || '',
stripe_price_id_monthly: initialData?.version?.stripe_price_id_monthly || '',
stripe_price_id_yearly: initialData?.version?.stripe_price_id_yearly || '',
selectedFeatures:
initialData?.version?.features?.map((f) => ({
feature_code: f.feature.code,
bool_value: f.bool_value,
int_value: f.int_value,
})) || [],
}));
// Validation errors
const [errors, setErrors] = useState<Record<string, string>>({});
// Wizard steps configuration
const steps: Array<{ id: WizardStep; label: string; icon: React.ElementType }> = [
{ id: 'basics', label: 'Basics', icon: Package },
{ id: 'pricing', label: 'Pricing', icon: DollarSign },
{ id: 'features', label: 'Features', icon: Check },
{ id: 'display', label: 'Display', icon: Star },
];
const currentStepIndex = steps.findIndex((s) => s.id === currentStep);
const isFirstStep = currentStepIndex === 0;
const isLastStep = currentStepIndex === steps.length - 1;
// Validation
const validateBasics = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.code.trim()) newErrors.code = 'Plan code is required';
if (!formData.name.trim()) newErrors.name = 'Plan name is required';
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const validatePricing = (): boolean => {
const newErrors: Record<string, string> = {};
if (formData.transaction_fee_percent < 0 || formData.transaction_fee_percent > 100) {
newErrors.transaction_fee_percent = 'Fee must be between 0 and 100';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// Navigation
const canProceed = useMemo(() => {
if (currentStep === 'basics') {
return formData.code.trim() !== '' && formData.name.trim() !== '';
}
return true;
}, [currentStep, formData.code, formData.name]);
const goNext = () => {
if (currentStep === 'basics' && !validateBasics()) return;
if (currentStep === 'pricing' && !validatePricing()) return;
if (!isLastStep) {
setCurrentStep(steps[currentStepIndex + 1].id);
}
};
const goPrev = () => {
if (!isFirstStep) {
setCurrentStep(steps[currentStepIndex - 1].id);
}
};
const goToStep = (stepId: WizardStep) => {
// Only allow navigating to visited steps or current step
const targetIndex = steps.findIndex((s) => s.id === stepId);
if (targetIndex <= currentStepIndex || canProceed) {
setCurrentStep(stepId);
}
};
// Form handlers
const updateCode = (value: string) => {
// Sanitize: lowercase, no spaces, only alphanumeric and hyphens/underscores
const sanitized = value.toLowerCase().replace(/[^a-z0-9_-]/g, '');
setFormData((prev) => ({ ...prev, code: sanitized }));
};
const addMarketingFeature = () => {
if (newMarketingFeature.trim()) {
setFormData((prev) => ({
...prev,
marketing_features: [...prev.marketing_features, newMarketingFeature.trim()],
}));
setNewMarketingFeature('');
}
};
const removeMarketingFeature = (index: number) => {
setFormData((prev) => ({
...prev,
marketing_features: prev.marketing_features.filter((_, i) => i !== index),
}));
};
// Submit
const handleSubmit = async () => {
if (!validateBasics() || !validatePricing()) return;
try {
if (isNewPlan) {
// Create Plan first
await createPlanMutation.mutateAsync({
code: formData.code,
name: formData.name,
description: formData.description,
display_order: formData.display_order,
is_active: formData.is_active,
});
// Create first version
await createVersionMutation.mutateAsync({
plan_code: formData.code,
name: formData.version_name || `${formData.name} v1`,
is_public: formData.is_public,
price_monthly_cents: formData.price_monthly_cents,
price_yearly_cents: formData.price_yearly_cents,
transaction_fee_percent: formData.transaction_fee_percent,
transaction_fee_fixed_cents: formData.transaction_fee_fixed_cents,
trial_days: formData.trial_days,
is_most_popular: formData.is_most_popular,
show_price: formData.show_price,
marketing_features: formData.marketing_features,
stripe_product_id: formData.stripe_product_id,
stripe_price_id_monthly: formData.stripe_price_id_monthly,
stripe_price_id_yearly: formData.stripe_price_id_yearly,
features: formData.selectedFeatures,
});
} else if (initialData?.id) {
// Update plan
await updatePlanMutation.mutateAsync({
id: initialData.id,
name: formData.name,
description: formData.description,
display_order: formData.display_order,
is_active: formData.is_active,
});
// Update version if exists
if (initialData?.version?.id) {
await updateVersionMutation.mutateAsync({
id: initialData.version.id,
name: formData.version_name,
is_public: formData.is_public,
price_monthly_cents: formData.price_monthly_cents,
price_yearly_cents: formData.price_yearly_cents,
transaction_fee_percent: formData.transaction_fee_percent,
transaction_fee_fixed_cents: formData.transaction_fee_fixed_cents,
trial_days: formData.trial_days,
is_most_popular: formData.is_most_popular,
show_price: formData.show_price,
marketing_features: formData.marketing_features,
stripe_product_id: formData.stripe_product_id,
stripe_price_id_monthly: formData.stripe_price_id_monthly,
stripe_price_id_yearly: formData.stripe_price_id_yearly,
features: formData.selectedFeatures,
});
}
}
onClose();
} catch (error) {
console.error('Failed to save plan:', error);
}
};
// Force update handler (updates existing version without creating new one)
const handleForceUpdate = async () => {
if (!initialData?.version?.id) return;
try {
setForceUpdateError(null);
// First call without confirm to get affected subscriber count
const response = await forceUpdateMutation.mutateAsync({
id: initialData.version.id,
name: formData.version_name,
is_public: formData.is_public,
price_monthly_cents: formData.price_monthly_cents,
price_yearly_cents: formData.price_yearly_cents,
transaction_fee_percent: formData.transaction_fee_percent,
transaction_fee_fixed_cents: formData.transaction_fee_fixed_cents,
trial_days: formData.trial_days,
is_most_popular: formData.is_most_popular,
show_price: formData.show_price,
marketing_features: formData.marketing_features,
stripe_product_id: formData.stripe_product_id,
stripe_price_id_monthly: formData.stripe_price_id_monthly,
stripe_price_id_yearly: formData.stripe_price_id_yearly,
features: formData.selectedFeatures,
confirm: true, // Confirm immediately since user already acknowledged
});
// If successful, close the modal
if (!isForceUpdateConfirmRequired(response)) {
onClose();
}
} catch (error) {
console.error('Failed to force update plan:', error);
setForceUpdateError('Failed to update plan. Please try again.');
}
};
const isLoading =
createPlanMutation.isPending ||
createVersionMutation.isPending ||
updatePlanMutation.isPending ||
updateVersionMutation.isPending ||
forceUpdateMutation.isPending;
// Derived values for display
const monthlyEquivalent = formData.price_yearly_cents > 0
? (formData.price_yearly_cents / 12 / 100).toFixed(2)
: null;
const transactionFeeExample = () => {
const percent = formData.transaction_fee_percent / 100;
const fixed = formData.transaction_fee_fixed_cents / 100;
const total = (100 * percent + fixed).toFixed(2);
return `On a $100 transaction: $${total} fee`;
};
// Fee validation warning
const feeError =
formData.transaction_fee_percent < 0 || formData.transaction_fee_percent > 100
? 'Fee must be between 0 and 100'
: null;
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={isNewPlan ? 'Create New Plan' : `Edit ${initialData?.name || 'Plan'}`}
size="4xl"
>
{/* Grandfathering Warning */}
{hasSubscribers && !showForceUpdateConfirm && (
<Alert
variant="warning"
className="mb-4"
message={
<>
This version has <strong>{initialData?.version?.subscriber_count}</strong> active
subscriber(s). Saving will create a new version (grandfathering). Existing subscribers
keep their current plan.
</>
}
/>
)}
{/* Force Update Confirmation Dialog */}
{showForceUpdateConfirm && (
<div className="mb-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<div className="flex items-start gap-3">
<AlertTriangle className="w-6 h-6 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h4 className="text-base font-semibold text-red-800 dark:text-red-200 mb-2">
Warning: This will affect existing customers
</h4>
<p className="text-sm text-red-700 dark:text-red-300 mb-3">
You are about to update this plan version <strong>in place</strong>. This will immediately
change the features and pricing for all <strong>{initialData?.version?.subscriber_count}</strong> existing
subscriber(s). This action cannot be undone.
</p>
<p className="text-sm text-red-700 dark:text-red-300 mb-4">
Only use this for correcting errors or minor adjustments. For significant changes,
use the standard save which creates a new version and grandfathers existing subscribers.
</p>
{forceUpdateError && (
<Alert variant="error" message={forceUpdateError} className="mb-3" />
)}
<div className="flex gap-3">
<button
type="button"
onClick={() => {
setShowForceUpdateConfirm(false);
setForceUpdateError(null);
}}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
>
Cancel
</button>
<button
type="button"
onClick={handleForceUpdate}
disabled={isLoading}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{forceUpdateMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Yes, Update All Subscribers
</button>
</div>
</div>
</div>
</div>
)}
{/* Step Indicator */}
<div className="flex items-center justify-center gap-2 mb-6 pb-4 border-b border-gray-200 dark:border-gray-700">
{steps.map((step, index) => {
const isActive = step.id === currentStep;
const isCompleted = index < currentStepIndex;
const StepIcon = step.icon;
return (
<React.Fragment key={step.id}>
{index > 0 && (
<div
className={`h-px w-8 ${
isCompleted ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'
}`}
/>
)}
<button
type="button"
onClick={() => goToStep(step.id)}
aria-label={step.label}
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
isActive
? 'bg-blue-600 text-white'
: isCompleted
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400'
: 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
}`}
>
<StepIcon className="w-4 h-4" />
<span className="hidden sm:inline">{step.label}</span>
</button>
</React.Fragment>
);
})}
</div>
<div className="flex gap-6">
{/* Main Form Area */}
<div className="flex-1 max-h-[60vh] overflow-y-auto">
{/* Step 1: Basics */}
{currentStep === 'basics' && (
<div className="space-y-4 p-1">
<div className="grid grid-cols-2 gap-4">
<div>
<label
htmlFor="plan-code"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Plan Code *
</label>
<input
id="plan-code"
type="text"
value={formData.code}
onChange={(e) => updateCode(e.target.value)}
required
disabled={!isNewPlan}
placeholder="e.g., starter, pro, enterprise"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:opacity-50"
/>
{errors.code && (
<p className="text-xs text-red-500 mt-1">{errors.code}</p>
)}
</div>
<div>
<label
htmlFor="plan-name"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Display Name *
</label>
<input
id="plan-name"
type="text"
value={formData.name}
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
required
placeholder="e.g., Starter, Professional, Enterprise"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
{errors.name && (
<p className="text-xs text-red-500 mt-1">{errors.name}</p>
)}
</div>
</div>
<div>
<label
htmlFor="plan-description"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Description
</label>
<textarea
id="plan-description"
value={formData.description}
onChange={(e) =>
setFormData((prev) => ({ ...prev, description: e.target.value }))
}
rows={2}
placeholder="Brief description of this plan..."
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div className="flex items-center gap-4 pt-2">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) =>
setFormData((prev) => ({ ...prev, is_active: e.target.checked }))
}
className="rounded border-gray-300 dark:border-gray-600"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">
Active (available for purchase)
</span>
</label>
</div>
</div>
)}
{/* Step 2: Pricing */}
{currentStep === 'pricing' && (
<div className="space-y-6 p-1">
{/* Subscription Pricing */}
<div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3 flex items-center gap-2">
<DollarSign className="w-4 h-4" /> Subscription Pricing
</h4>
<div className="grid grid-cols-3 gap-4">
<div>
<label
htmlFor="price-monthly"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Monthly Price ($)
</label>
<input
id="price-monthly"
type="number"
step="0.01"
min="0"
value={formData.price_monthly_cents / 100}
onChange={(e) =>
setFormData((prev) => ({
...prev,
price_monthly_cents: Math.round(parseFloat(e.target.value || '0') * 100),
}))
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label
htmlFor="price-yearly"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Yearly Price ($)
</label>
<input
id="price-yearly"
type="number"
step="0.01"
min="0"
value={formData.price_yearly_cents / 100}
onChange={(e) =>
setFormData((prev) => ({
...prev,
price_yearly_cents: Math.round(parseFloat(e.target.value || '0') * 100),
}))
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
{monthlyEquivalent && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
=${monthlyEquivalent}/mo equivalent
</p>
)}
</div>
<div>
<label
htmlFor="trial-days"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Trial Days
</label>
<input
id="trial-days"
type="number"
min="0"
value={formData.trial_days}
onChange={(e) =>
setFormData((prev) => ({
...prev,
trial_days: parseInt(e.target.value) || 0,
}))
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
</div>
</div>
{/* Transaction Fees */}
<div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
Transaction Fees
</h4>
<div className="grid grid-cols-2 gap-4">
<div>
<label
htmlFor="fee-percent"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Fee Percentage (%)
</label>
<input
id="fee-percent"
type="number"
step="0.1"
min="0"
max="100"
value={formData.transaction_fee_percent}
onChange={(e) =>
setFormData((prev) => ({
...prev,
transaction_fee_percent: parseFloat(e.target.value) || 0,
}))
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
{feeError && (
<p className="text-xs text-red-500 mt-1">{feeError}</p>
)}
</div>
<div>
<label
htmlFor="fee-fixed"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Fixed Fee (cents)
</label>
<input
id="fee-fixed"
type="number"
min="0"
value={formData.transaction_fee_fixed_cents}
onChange={(e) =>
setFormData((prev) => ({
...prev,
transaction_fee_fixed_cents: parseInt(e.target.value) || 0,
}))
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
{transactionFeeExample()}
</p>
</div>
</div>
)}
{/* Step 3: Features */}
{currentStep === 'features' && (
<div className="p-1">
{featuresLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
</div>
) : (
<FeaturePicker
features={features || []}
selectedFeatures={formData.selectedFeatures}
onChange={(selected) =>
setFormData((prev) => ({ ...prev, selectedFeatures: selected }))
}
/>
)}
</div>
)}
{/* Step 4: Display */}
{currentStep === 'display' && (
<div className="space-y-6 p-1">
{/* Visibility */}
<div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
Visibility Settings
</h4>
<div className="flex flex-wrap gap-6">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.is_public}
onChange={(e) =>
setFormData((prev) => ({ ...prev, is_public: e.target.checked }))
}
className="rounded border-gray-300"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">
Show on pricing page
</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.is_most_popular}
onChange={(e) =>
setFormData((prev) => ({ ...prev, is_most_popular: e.target.checked }))
}
className="rounded border-gray-300"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">
"Most Popular" badge
</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.show_price}
onChange={(e) =>
setFormData((prev) => ({ ...prev, show_price: e.target.checked }))
}
className="rounded border-gray-300"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Display price</span>
</label>
</div>
</div>
{/* Marketing Features */}
<div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
Marketing Feature List
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
Bullet points shown on pricing page. Separate from actual feature access.
</p>
<div className="space-y-2">
{formData.marketing_features.map((feature, index) => (
<div key={index} className="flex items-center gap-2">
<Check className="w-4 h-4 text-green-500 flex-shrink-0" />
<span className="flex-1 text-sm text-gray-700 dark:text-gray-300">
{feature}
</span>
<button
type="button"
onClick={() => removeMarketingFeature(index)}
className="text-gray-400 hover:text-red-500 p-1"
>
×
</button>
</div>
))}
<div className="flex gap-2">
<input
type="text"
value={newMarketingFeature}
onChange={(e) => setNewMarketingFeature(e.target.value)}
onKeyPress={(e) =>
e.key === 'Enter' && (e.preventDefault(), addMarketingFeature())
}
placeholder="e.g., Unlimited appointments"
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
/>
<button
type="button"
onClick={addMarketingFeature}
className="px-3 py-2 text-sm font-medium text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg"
>
Add
</button>
</div>
</div>
</div>
{/* Stripe */}
<div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
Stripe Integration
</h4>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Product ID
</label>
<input
type="text"
value={formData.stripe_product_id}
onChange={(e) =>
setFormData((prev) => ({ ...prev, stripe_product_id: e.target.value }))
}
placeholder="prod_..."
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Monthly Price ID
</label>
<input
type="text"
value={formData.stripe_price_id_monthly}
onChange={(e) =>
setFormData((prev) => ({
...prev,
stripe_price_id_monthly: e.target.value,
}))
}
placeholder="price_..."
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Yearly Price ID
</label>
<input
type="text"
value={formData.stripe_price_id_yearly}
onChange={(e) =>
setFormData((prev) => ({ ...prev, stripe_price_id_yearly: e.target.value }))
}
placeholder="price_..."
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
/>
</div>
</div>
</div>
</div>
)}
</div>
{/* Live Summary Panel */}
<div className="w-64 border-l border-gray-200 dark:border-gray-700 pl-6 hidden lg:block">
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-4">Plan Summary</h4>
<div className="space-y-3 text-sm">
<div>
<span className="text-gray-500 dark:text-gray-400">Name:</span>
<p className="font-medium text-gray-900 dark:text-white">
{formData.name || '(not set)'}
</p>
</div>
{formData.price_monthly_cents > 0 && (
<div>
<span className="text-gray-500 dark:text-gray-400">Price:</span>
<p className="font-medium text-gray-900 dark:text-white">
${(formData.price_monthly_cents / 100).toFixed(2)}/mo
</p>
</div>
)}
<div>
<span className="text-gray-500 dark:text-gray-400">Features:</span>
<p className="font-medium text-gray-900 dark:text-white">
{formData.selectedFeatures.length} feature
{formData.selectedFeatures.length !== 1 ? 's' : ''}
</p>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400">Status:</span>
<p className="font-medium text-gray-900 dark:text-white">
{formData.is_active ? 'Active' : 'Inactive'}
{formData.is_public ? ', Public' : ', Hidden'}
</p>
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="flex justify-between items-center pt-4 mt-4 border-t border-gray-200 dark:border-gray-700">
<div>
{!isFirstStep && !showForceUpdateConfirm && (
<button
type="button"
onClick={goPrev}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
>
<ChevronLeft className="w-4 h-4" />
Back
</button>
)}
</div>
{!showForceUpdateConfirm && (
<div className="flex gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
>
Cancel
</button>
{!isLastStep ? (
<button
type="button"
onClick={goNext}
disabled={!canProceed}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
) : (
<>
{/* Force Update button - only for superusers editing plans with subscribers */}
{hasSubscribers && isSuperuser && (
<button
type="button"
onClick={() => setShowForceUpdateConfirm(true)}
disabled={isLoading || !canProceed}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-red-600 dark:text-red-400 border border-red-300 dark:border-red-700 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50 disabled:cursor-not-allowed"
>
<AlertTriangle className="w-4 h-4" />
Update Without Versioning
</button>
)}
<button
type="button"
onClick={handleSubmit}
disabled={isLoading || !canProceed}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
{hasSubscribers ? 'Create New Version' : isNewPlan ? 'Create Plan' : 'Save Changes'}
</button>
</>
)}
</div>
)}
</div>
</Modal>
);
};

View File

@@ -0,0 +1,477 @@
/**
* Tests for CatalogListPanel Component
*
* TDD: Tests for the sidebar catalog list with search and filtering.
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CatalogListPanel, type CatalogItem } from '../CatalogListPanel';
// Sample plan data
const mockPlans: CatalogItem[] = [
{
id: 1,
type: 'plan',
code: 'free',
name: 'Free',
isActive: true,
isPublic: true,
isLegacy: false,
priceMonthly: 0,
priceYearly: 0,
subscriberCount: 50,
},
{
id: 2,
type: 'plan',
code: 'starter',
name: 'Starter',
isActive: true,
isPublic: true,
isLegacy: false,
priceMonthly: 2900,
priceYearly: 29000,
subscriberCount: 25,
},
{
id: 3,
type: 'plan',
code: 'pro',
name: 'Professional',
isActive: true,
isPublic: true,
isLegacy: false,
priceMonthly: 7900,
priceYearly: 79000,
subscriberCount: 10,
},
{
id: 4,
type: 'plan',
code: 'enterprise',
name: 'Enterprise',
isActive: true,
isPublic: false, // Hidden plan
isLegacy: false,
priceMonthly: 19900,
priceYearly: 199000,
subscriberCount: 3,
},
{
id: 5,
type: 'plan',
code: 'legacy_pro',
name: 'Pro (Legacy)',
isActive: false, // Inactive
isPublic: false,
isLegacy: true,
priceMonthly: 4900,
priceYearly: 49000,
subscriberCount: 15,
},
];
// Sample add-on data
const mockAddons: CatalogItem[] = [
{
id: 101,
type: 'addon',
code: 'sms_pack',
name: 'SMS Credits Pack',
isActive: true,
isPublic: true,
isLegacy: false,
priceMonthly: 500,
},
{
id: 102,
type: 'addon',
code: 'api_access',
name: 'API Access',
isActive: true,
isPublic: true,
isLegacy: false,
priceMonthly: 2000,
},
{
id: 103,
type: 'addon',
code: 'old_addon',
name: 'Deprecated Add-on',
isActive: false,
isPublic: false,
isLegacy: true,
priceMonthly: 1000,
},
];
const allItems = [...mockPlans, ...mockAddons];
describe('CatalogListPanel', () => {
const defaultProps = {
items: allItems,
selectedId: null,
onSelect: vi.fn(),
onCreatePlan: vi.fn(),
onCreateAddon: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('renders all items by default', () => {
render(<CatalogListPanel {...defaultProps} />);
// Should show all plans
expect(screen.getByText('Free')).toBeInTheDocument();
expect(screen.getByText('Starter')).toBeInTheDocument();
expect(screen.getByText('Professional')).toBeInTheDocument();
expect(screen.getByText('Enterprise')).toBeInTheDocument();
// Should show all addons
expect(screen.getByText('SMS Credits Pack')).toBeInTheDocument();
expect(screen.getByText('API Access')).toBeInTheDocument();
});
it('shows item code as badge', () => {
render(<CatalogListPanel {...defaultProps} />);
expect(screen.getByText('free')).toBeInTheDocument();
expect(screen.getByText('starter')).toBeInTheDocument();
expect(screen.getByText('sms_pack')).toBeInTheDocument();
});
it('shows price for items', () => {
render(<CatalogListPanel {...defaultProps} />);
// Free plan shows "Free/mo" - use getAllByText since "free" appears multiple times
const freeElements = screen.getAllByText(/free/i);
expect(freeElements.length).toBeGreaterThan(0);
// Starter plan shows $29.00/mo
expect(screen.getByText(/\$29/)).toBeInTheDocument();
});
it('shows type badges for plans and add-ons', () => {
render(<CatalogListPanel {...defaultProps} />);
// Should have Base Plan badges for plans
const baseBadges = screen.getAllByText(/base/i);
expect(baseBadges.length).toBeGreaterThan(0);
// Should have Add-on badges
const addonBadges = screen.getAllByText(/add-on/i);
expect(addonBadges.length).toBeGreaterThan(0);
});
it('shows status badges for inactive items', () => {
render(<CatalogListPanel {...defaultProps} />);
// Legacy plan should show inactive badge
const inactiveBadges = screen.getAllByText(/inactive/i);
expect(inactiveBadges.length).toBeGreaterThan(0);
});
it('shows legacy badge for legacy items', () => {
render(<CatalogListPanel {...defaultProps} />);
const legacyBadges = screen.getAllByText(/legacy/i);
expect(legacyBadges.length).toBeGreaterThan(0);
});
it('shows hidden badge for non-public items', () => {
render(<CatalogListPanel {...defaultProps} />);
const hiddenBadges = screen.getAllByText(/hidden/i);
expect(hiddenBadges.length).toBeGreaterThan(0);
});
});
describe('Type Filtering', () => {
it('filters to show only base plans', async () => {
const user = userEvent.setup();
render(<CatalogListPanel {...defaultProps} />);
// Click the "Base Plans" filter
const typeFilter = screen.getByRole('combobox', { name: /type/i });
await user.selectOptions(typeFilter, 'plan');
// Should show plans
expect(screen.getByText('Free')).toBeInTheDocument();
expect(screen.getByText('Starter')).toBeInTheDocument();
// Should NOT show addons
expect(screen.queryByText('SMS Credits Pack')).not.toBeInTheDocument();
});
it('filters to show only add-ons', async () => {
const user = userEvent.setup();
render(<CatalogListPanel {...defaultProps} />);
const typeFilter = screen.getByRole('combobox', { name: /type/i });
await user.selectOptions(typeFilter, 'addon');
// Should show addons
expect(screen.getByText('SMS Credits Pack')).toBeInTheDocument();
expect(screen.getByText('API Access')).toBeInTheDocument();
// Should NOT show plans
expect(screen.queryByText('Free')).not.toBeInTheDocument();
expect(screen.queryByText('Starter')).not.toBeInTheDocument();
});
it('shows all types when "All" is selected', async () => {
const user = userEvent.setup();
render(<CatalogListPanel {...defaultProps} />);
// First filter to plans only
const typeFilter = screen.getByRole('combobox', { name: /type/i });
await user.selectOptions(typeFilter, 'plan');
// Then select "All"
await user.selectOptions(typeFilter, 'all');
// Should show both
expect(screen.getByText('Free')).toBeInTheDocument();
expect(screen.getByText('SMS Credits Pack')).toBeInTheDocument();
});
});
describe('Status Filtering', () => {
it('filters to show only active items', async () => {
const user = userEvent.setup();
render(<CatalogListPanel {...defaultProps} />);
const statusFilter = screen.getByRole('combobox', { name: /status/i });
await user.selectOptions(statusFilter, 'active');
// Should show active items
expect(screen.getByText('Free')).toBeInTheDocument();
expect(screen.getByText('Starter')).toBeInTheDocument();
// Should NOT show inactive items
expect(screen.queryByText('Pro (Legacy)')).not.toBeInTheDocument();
expect(screen.queryByText('Deprecated Add-on')).not.toBeInTheDocument();
});
it('filters to show only inactive items', async () => {
const user = userEvent.setup();
render(<CatalogListPanel {...defaultProps} />);
const statusFilter = screen.getByRole('combobox', { name: /status/i });
await user.selectOptions(statusFilter, 'inactive');
// Should show inactive items
expect(screen.getByText('Pro (Legacy)')).toBeInTheDocument();
expect(screen.getByText('Deprecated Add-on')).toBeInTheDocument();
// Should NOT show active items
expect(screen.queryByText('Free')).not.toBeInTheDocument();
});
});
describe('Visibility Filtering', () => {
it('filters to show only public items', async () => {
const user = userEvent.setup();
render(<CatalogListPanel {...defaultProps} />);
const visibilityFilter = screen.getByRole('combobox', { name: /visibility/i });
await user.selectOptions(visibilityFilter, 'public');
// Should show public items
expect(screen.getByText('Free')).toBeInTheDocument();
expect(screen.getByText('Starter')).toBeInTheDocument();
// Should NOT show hidden items
expect(screen.queryByText('Enterprise')).not.toBeInTheDocument();
});
it('filters to show only hidden items', async () => {
const user = userEvent.setup();
render(<CatalogListPanel {...defaultProps} />);
const visibilityFilter = screen.getByRole('combobox', { name: /visibility/i });
await user.selectOptions(visibilityFilter, 'hidden');
// Should show hidden items
expect(screen.getByText('Enterprise')).toBeInTheDocument();
// Should NOT show public items
expect(screen.queryByText('Starter')).not.toBeInTheDocument();
});
});
describe('Legacy Filtering', () => {
it('filters to show only current (non-legacy) items', async () => {
const user = userEvent.setup();
render(<CatalogListPanel {...defaultProps} />);
const legacyFilter = screen.getByRole('combobox', { name: /legacy/i });
await user.selectOptions(legacyFilter, 'current');
// Should show current items
expect(screen.getByText('Free')).toBeInTheDocument();
// Should NOT show legacy items
expect(screen.queryByText('Pro (Legacy)')).not.toBeInTheDocument();
});
it('filters to show only legacy items', async () => {
const user = userEvent.setup();
render(<CatalogListPanel {...defaultProps} />);
const legacyFilter = screen.getByRole('combobox', { name: /legacy/i });
await user.selectOptions(legacyFilter, 'legacy');
// Should show legacy items
expect(screen.getByText('Pro (Legacy)')).toBeInTheDocument();
// Should NOT show current items
expect(screen.queryByText('Free')).not.toBeInTheDocument();
});
});
describe('Search Functionality', () => {
it('filters items by name when searching', async () => {
const user = userEvent.setup();
render(<CatalogListPanel {...defaultProps} />);
const searchInput = screen.getByPlaceholderText(/search/i);
await user.type(searchInput, 'starter');
// Should show Starter plan
expect(screen.getByText('Starter')).toBeInTheDocument();
// Should NOT show other items
expect(screen.queryByText('Free')).not.toBeInTheDocument();
expect(screen.queryByText('Professional')).not.toBeInTheDocument();
});
it('filters items by code when searching', async () => {
const user = userEvent.setup();
render(<CatalogListPanel {...defaultProps} />);
const searchInput = screen.getByPlaceholderText(/search/i);
await user.type(searchInput, 'sms_pack');
// Should show SMS Credits Pack
expect(screen.getByText('SMS Credits Pack')).toBeInTheDocument();
// Should NOT show other items
expect(screen.queryByText('Free')).not.toBeInTheDocument();
});
it('shows no results message when search has no matches', async () => {
const user = userEvent.setup();
render(<CatalogListPanel {...defaultProps} />);
const searchInput = screen.getByPlaceholderText(/search/i);
await user.type(searchInput, 'nonexistent');
expect(screen.getByText(/no items found/i)).toBeInTheDocument();
});
it('search is case-insensitive', async () => {
const user = userEvent.setup();
render(<CatalogListPanel {...defaultProps} />);
const searchInput = screen.getByPlaceholderText(/search/i);
await user.type(searchInput, 'STARTER');
expect(screen.getByText('Starter')).toBeInTheDocument();
});
});
describe('Selection', () => {
it('calls onSelect when an item is clicked', async () => {
const onSelect = vi.fn();
const user = userEvent.setup();
render(<CatalogListPanel {...defaultProps} onSelect={onSelect} />);
const starterItem = screen.getByText('Starter').closest('button');
await user.click(starterItem!);
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({
id: 2,
code: 'starter',
}));
});
it('highlights the selected item', () => {
render(<CatalogListPanel {...defaultProps} selectedId={2} />);
// The selected item should have a different style
const starterItem = screen.getByText('Starter').closest('button');
expect(starterItem).toHaveClass('bg-blue-50');
});
});
describe('Create Actions', () => {
it('calls onCreatePlan when "Create Plan" is clicked', async () => {
const onCreatePlan = vi.fn();
const user = userEvent.setup();
render(<CatalogListPanel {...defaultProps} onCreatePlan={onCreatePlan} />);
const createPlanButton = screen.getByRole('button', { name: /create plan/i });
await user.click(createPlanButton);
expect(onCreatePlan).toHaveBeenCalled();
});
it('calls onCreateAddon when "Create Add-on" is clicked', async () => {
const onCreateAddon = vi.fn();
const user = userEvent.setup();
render(<CatalogListPanel {...defaultProps} onCreateAddon={onCreateAddon} />);
const createAddonButton = screen.getByRole('button', { name: /create add-on/i });
await user.click(createAddonButton);
expect(onCreateAddon).toHaveBeenCalled();
});
});
describe('Combined Filters', () => {
it('combines type and status filters', async () => {
const user = userEvent.setup();
render(<CatalogListPanel {...defaultProps} />);
// Filter to inactive plans only
const typeFilter = screen.getByRole('combobox', { name: /type/i });
const statusFilter = screen.getByRole('combobox', { name: /status/i });
await user.selectOptions(typeFilter, 'plan');
await user.selectOptions(statusFilter, 'inactive');
// Should only show inactive plans
expect(screen.getByText('Pro (Legacy)')).toBeInTheDocument();
// Should NOT show active plans or addons
expect(screen.queryByText('Free')).not.toBeInTheDocument();
expect(screen.queryByText('Deprecated Add-on')).not.toBeInTheDocument();
});
it('combines search with filters', async () => {
const user = userEvent.setup();
render(<CatalogListPanel {...defaultProps} />);
// Filter to plans and search for "pro"
const typeFilter = screen.getByRole('combobox', { name: /type/i });
await user.selectOptions(typeFilter, 'plan');
const searchInput = screen.getByPlaceholderText(/search/i);
await user.type(searchInput, 'pro');
// Should show Professional and Pro (Legacy)
expect(screen.getByText('Professional')).toBeInTheDocument();
expect(screen.getByText('Pro (Legacy)')).toBeInTheDocument();
// Should NOT show other items
expect(screen.queryByText('Free')).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,327 @@
/**
* Tests for FeaturePicker Component
*
* TDD: These tests define the expected behavior of the FeaturePicker component.
*/
import React, { useState } from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FeaturePicker, FeaturePickerProps } from '../FeaturePicker';
import { FEATURE_CATALOG, BOOLEAN_FEATURES, INTEGER_FEATURES } from '../../featureCatalog';
import type { PlanFeatureWrite } from '../../../hooks/useBillingAdmin';
// Mock features from API (similar to what useFeatures() returns)
const mockApiFeatures = [
{ id: 1, code: 'sms_enabled', name: 'SMS Enabled', description: 'Allow SMS', feature_type: 'boolean' as const },
{ id: 2, code: 'email_enabled', name: 'Email Enabled', description: 'Allow email', feature_type: 'boolean' as const },
{ id: 3, code: 'max_users', name: 'Maximum Users', description: 'Max users limit', feature_type: 'integer' as const },
{ id: 4, code: 'max_resources', name: 'Maximum Resources', description: 'Max resources', feature_type: 'integer' as const },
{ id: 5, code: 'custom_feature', name: 'Custom Feature', description: 'Not in catalog', feature_type: 'boolean' as const },
];
/**
* Wrapper component that manages state for controlled FeaturePicker
*/
const StatefulFeaturePicker: React.FC<
Omit<FeaturePickerProps, 'selectedFeatures' | 'onChange'> & {
initialSelectedFeatures?: PlanFeatureWrite[];
onChangeCapture?: (features: PlanFeatureWrite[]) => void;
}
> = ({ initialSelectedFeatures = [], onChangeCapture, ...props }) => {
const [selectedFeatures, setSelectedFeatures] = useState<PlanFeatureWrite[]>(initialSelectedFeatures);
const handleChange = (features: PlanFeatureWrite[]) => {
setSelectedFeatures(features);
onChangeCapture?.(features);
};
return (
<FeaturePicker
{...props}
selectedFeatures={selectedFeatures}
onChange={handleChange}
/>
);
};
describe('FeaturePicker', () => {
const defaultProps = {
features: mockApiFeatures,
selectedFeatures: [],
onChange: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('renders boolean features in Capabilities section', () => {
render(<FeaturePicker {...defaultProps} />);
expect(screen.getByText('Capabilities')).toBeInTheDocument();
expect(screen.getByText('SMS Enabled')).toBeInTheDocument();
expect(screen.getByText('Email Enabled')).toBeInTheDocument();
});
it('renders integer features in Limits & Quotas section', () => {
render(<FeaturePicker {...defaultProps} />);
expect(screen.getByText('Limits & Quotas')).toBeInTheDocument();
expect(screen.getByText('Maximum Users')).toBeInTheDocument();
expect(screen.getByText('Maximum Resources')).toBeInTheDocument();
});
it('shows selected features as checked', () => {
const selectedFeatures = [
{ feature_code: 'sms_enabled', bool_value: true, int_value: null },
];
render(<FeaturePicker {...defaultProps} selectedFeatures={selectedFeatures} />);
const checkbox = screen.getByRole('checkbox', { name: /sms enabled/i });
expect(checkbox).toBeChecked();
});
it('shows integer values for selected integer features', () => {
const selectedFeatures = [
{ feature_code: 'max_users', bool_value: null, int_value: 50 },
];
render(<FeaturePicker {...defaultProps} selectedFeatures={selectedFeatures} />);
const input = screen.getByDisplayValue('50');
expect(input).toBeInTheDocument();
});
});
describe('Feature Selection', () => {
it('calls onChange when a boolean feature is selected', async () => {
const onChange = vi.fn();
render(<FeaturePicker {...defaultProps} onChange={onChange} />);
const checkbox = screen.getByRole('checkbox', { name: /sms enabled/i });
await userEvent.click(checkbox);
expect(onChange).toHaveBeenCalledWith([
{ feature_code: 'sms_enabled', bool_value: true, int_value: null },
]);
});
it('calls onChange when a boolean feature is deselected', async () => {
const selectedFeatures = [
{ feature_code: 'sms_enabled', bool_value: true, int_value: null },
];
const onChange = vi.fn();
render(
<FeaturePicker {...defaultProps} selectedFeatures={selectedFeatures} onChange={onChange} />
);
const checkbox = screen.getByRole('checkbox', { name: /sms enabled/i });
await userEvent.click(checkbox);
expect(onChange).toHaveBeenCalledWith([]);
});
it('calls onChange when an integer feature is selected with default value 0', async () => {
const onChange = vi.fn();
render(<FeaturePicker {...defaultProps} onChange={onChange} />);
const checkbox = screen.getByRole('checkbox', { name: /maximum users/i });
await userEvent.click(checkbox);
expect(onChange).toHaveBeenCalledWith([
{ feature_code: 'max_users', bool_value: null, int_value: 0 },
]);
});
it('calls onChange when integer value is updated', async () => {
const initialSelectedFeatures = [
{ feature_code: 'max_users', bool_value: null, int_value: 10 },
];
const onChangeCapture = vi.fn();
render(
<StatefulFeaturePicker
features={mockApiFeatures}
initialSelectedFeatures={initialSelectedFeatures}
onChangeCapture={onChangeCapture}
/>
);
const input = screen.getByDisplayValue('10');
await userEvent.clear(input);
await userEvent.type(input, '50');
// Should have been called multiple times as user types
expect(onChangeCapture).toHaveBeenCalled();
const lastCall = onChangeCapture.mock.calls[onChangeCapture.mock.calls.length - 1][0];
expect(lastCall).toContainEqual({ feature_code: 'max_users', bool_value: null, int_value: 50 });
});
});
describe('Canonical Catalog Validation', () => {
it('shows warning badge for features not in canonical catalog', () => {
render(<FeaturePicker {...defaultProps} />);
// custom_feature is not in the canonical catalog
const customFeatureRow = screen.getByText('Custom Feature').closest('label');
expect(customFeatureRow).toBeInTheDocument();
// Should show a warning indicator
const warningIndicator = within(customFeatureRow!).queryByTitle(/not in canonical catalog/i);
expect(warningIndicator).toBeInTheDocument();
});
it('does not show warning for canonical features', () => {
render(<FeaturePicker {...defaultProps} />);
// sms_enabled is in the canonical catalog
const smsFeatureRow = screen.getByText('SMS Enabled').closest('label');
expect(smsFeatureRow).toBeInTheDocument();
const warningIndicator = within(smsFeatureRow!).queryByTitle(/not in canonical catalog/i);
expect(warningIndicator).not.toBeInTheDocument();
});
});
describe('Search Functionality', () => {
it('filters features when search term is entered', async () => {
render(<FeaturePicker {...defaultProps} />);
const searchInput = screen.getByPlaceholderText(/search features/i);
await userEvent.type(searchInput, 'sms');
expect(screen.getByText('SMS Enabled')).toBeInTheDocument();
expect(screen.queryByText('Email Enabled')).not.toBeInTheDocument();
expect(screen.queryByText('Maximum Users')).not.toBeInTheDocument();
});
it('shows no results message when search has no matches', async () => {
render(<FeaturePicker {...defaultProps} />);
const searchInput = screen.getByPlaceholderText(/search features/i);
await userEvent.type(searchInput, 'nonexistent');
expect(screen.getByText(/no features found/i)).toBeInTheDocument();
});
it('clears search when clear button is clicked', async () => {
render(<FeaturePicker {...defaultProps} />);
const searchInput = screen.getByPlaceholderText(/search features/i);
await userEvent.type(searchInput, 'sms');
const clearButton = screen.getByRole('button', { name: /clear search/i });
await userEvent.click(clearButton);
expect(searchInput).toHaveValue('');
expect(screen.getByText('Email Enabled')).toBeInTheDocument();
});
});
describe('Payload Shape', () => {
it('produces correct payload shape for boolean features', async () => {
const onChange = vi.fn();
render(<FeaturePicker {...defaultProps} onChange={onChange} />);
await userEvent.click(screen.getByRole('checkbox', { name: /sms enabled/i }));
await userEvent.click(screen.getByRole('checkbox', { name: /email enabled/i }));
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0];
// Verify payload shape matches PlanFeatureWrite interface
expect(lastCall).toEqual(
expect.arrayContaining([
expect.objectContaining({
feature_code: expect.any(String),
bool_value: expect.any(Boolean),
int_value: null,
}),
])
);
});
it('produces correct payload shape for integer features', async () => {
const selectedFeatures = [
{ feature_code: 'max_users', bool_value: null, int_value: 25 },
];
const onChange = vi.fn();
render(
<FeaturePicker {...defaultProps} selectedFeatures={selectedFeatures} onChange={onChange} />
);
const input = screen.getByDisplayValue('25');
await userEvent.clear(input);
await userEvent.type(input, '100');
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0];
expect(lastCall).toEqual(
expect.arrayContaining([
expect.objectContaining({
feature_code: 'max_users',
bool_value: null,
int_value: expect.any(Number),
}),
])
);
});
it('produces correct payload shape for mixed selection', async () => {
const onChangeCapture = vi.fn();
render(
<StatefulFeaturePicker
features={mockApiFeatures}
onChangeCapture={onChangeCapture}
/>
);
// Select a boolean feature
await userEvent.click(screen.getByRole('checkbox', { name: /sms enabled/i }));
// Select an integer feature
await userEvent.click(screen.getByRole('checkbox', { name: /maximum users/i }));
const lastCall = onChangeCapture.mock.calls[onChangeCapture.mock.calls.length - 1][0];
expect(lastCall).toHaveLength(2);
expect(lastCall).toContainEqual({
feature_code: 'sms_enabled',
bool_value: true,
int_value: null,
});
expect(lastCall).toContainEqual({
feature_code: 'max_users',
bool_value: null,
int_value: 0,
});
});
});
describe('Accessibility', () => {
it('has accessible labels for all checkboxes', () => {
render(<FeaturePicker {...defaultProps} />);
// Each feature should have an accessible checkbox
mockApiFeatures.forEach((feature) => {
const checkbox = screen.getByRole('checkbox', { name: new RegExp(feature.name, 'i') });
expect(checkbox).toBeInTheDocument();
});
});
it('integer input has accessible label', () => {
const selectedFeatures = [
{ feature_code: 'max_users', bool_value: null, int_value: 10 },
];
render(<FeaturePicker {...defaultProps} selectedFeatures={selectedFeatures} />);
const input = screen.getByDisplayValue('10');
expect(input).toHaveAttribute('aria-label');
});
});
});

View File

@@ -0,0 +1,560 @@
/**
* Tests for PlanEditorWizard Component Validation
*
* TDD: These tests define the expected validation behavior.
*/
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 { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { PlanEditorWizard } from '../PlanEditorWizard';
// Create a fresh query client for each test
const createTestQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
// Wrapper with QueryClientProvider
const createWrapper = () => {
const queryClient = createTestQueryClient();
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
// Mock the hooks
vi.mock('../../../hooks/useBillingAdmin', () => ({
useFeatures: () => ({
data: [
{ id: 1, code: 'sms_enabled', name: 'SMS', feature_type: 'boolean' },
{ id: 2, code: 'max_users', name: 'Max Users', feature_type: 'integer' },
],
isLoading: false,
}),
useAddOnProducts: () => ({
data: [{ id: 1, code: 'addon1', name: 'Add-on 1', is_active: true }],
isLoading: false,
}),
useCreatePlan: () => ({
mutateAsync: vi.fn().mockResolvedValue({ id: 1, code: 'test' }),
isPending: false,
}),
useCreatePlanVersion: () => ({
mutateAsync: vi.fn().mockResolvedValue({ id: 1, version: 1 }),
isPending: false,
}),
useUpdatePlan: () => ({
mutateAsync: vi.fn().mockResolvedValue({ id: 1 }),
isPending: false,
}),
useUpdatePlanVersion: () => ({
mutateAsync: vi.fn().mockResolvedValue({ id: 1 }),
isPending: false,
}),
useForceUpdatePlanVersion: () => ({
mutateAsync: vi.fn().mockResolvedValue({ version: { id: 1 }, affected_count: 5 }),
isPending: false,
}),
isForceUpdateConfirmRequired: (response: unknown) =>
response !== null &&
typeof response === 'object' &&
'requires_confirm' in response &&
(response as { requires_confirm: boolean }).requires_confirm === true,
}));
// Mock useCurrentUser from useAuth
vi.mock('../../../hooks/useAuth', () => ({
useCurrentUser: () => ({
data: { id: 1, role: 'superuser', email: 'admin@test.com' },
isLoading: false,
}),
}));
describe('PlanEditorWizard', () => {
const defaultProps = {
isOpen: true,
onClose: vi.fn(),
mode: 'create' as const,
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('Basics Step Validation', () => {
it('requires plan name to proceed', async () => {
const user = userEvent.setup();
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
// Plan code is entered but name is empty
const codeInput = screen.getByLabelText(/plan code/i);
await user.type(codeInput, 'test_plan');
// Try to click Next
const nextButton = screen.getByRole('button', { name: /next/i });
expect(nextButton).toBeDisabled();
});
it('requires plan code to proceed', async () => {
const user = userEvent.setup();
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
// Name is entered but code is empty
const nameInput = screen.getByLabelText(/display name/i);
await user.type(nameInput, 'Test Plan');
// Next button should be disabled
const nextButton = screen.getByRole('button', { name: /next/i });
expect(nextButton).toBeDisabled();
});
it('allows proceeding when code and name are provided', async () => {
const user = userEvent.setup();
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
// Enter both code and name
const codeInput = screen.getByLabelText(/plan code/i);
const nameInput = screen.getByLabelText(/display name/i);
await user.type(codeInput, 'test_plan');
await user.type(nameInput, 'Test Plan');
// Next button should be enabled
const nextButton = screen.getByRole('button', { name: /next/i });
expect(nextButton).not.toBeDisabled();
});
it('sanitizes plan code to lowercase with no spaces', async () => {
const user = userEvent.setup();
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
const codeInput = screen.getByLabelText(/plan code/i);
await user.type(codeInput, 'My Test Plan');
// Should be sanitized
expect(codeInput).toHaveValue('mytestplan');
});
});
describe('Pricing Step Validation', () => {
const goToPricingStep = async () => {
const user = userEvent.setup();
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
// Fill basics
await user.type(screen.getByLabelText(/plan code/i), 'test');
await user.type(screen.getByLabelText(/display name/i), 'Test');
// Go to pricing step
await user.click(screen.getByRole('button', { name: /next/i }));
return user;
};
it('shows pricing step inputs', async () => {
await goToPricingStep();
expect(screen.getByLabelText(/monthly price/i)).toBeInTheDocument();
expect(screen.getByLabelText(/yearly price/i)).toBeInTheDocument();
});
it('does not allow negative monthly price', async () => {
const user = await goToPricingStep();
const monthlyInput = screen.getByLabelText(/monthly price/i);
await user.clear(monthlyInput);
await user.type(monthlyInput, '-50');
// Should show validation error or prevent input
// The input type="number" with min="0" should prevent negative values
expect(monthlyInput).toHaveAttribute('min', '0');
});
it('does not allow negative yearly price', async () => {
const user = await goToPricingStep();
const yearlyInput = screen.getByLabelText(/yearly price/i);
await user.clear(yearlyInput);
await user.type(yearlyInput, '-100');
// Should have min attribute set
expect(yearlyInput).toHaveAttribute('min', '0');
});
it('displays derived monthly equivalent for yearly price', async () => {
const user = await goToPricingStep();
const yearlyInput = screen.getByLabelText(/yearly price/i);
await user.clear(yearlyInput);
await user.type(yearlyInput, '120');
// Should show the monthly equivalent ($10/mo)
expect(screen.getByText(/\$10.*mo/i)).toBeInTheDocument();
});
});
describe('Transaction Fees Validation', () => {
const goToPricingStep = async () => {
const user = userEvent.setup();
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
// Fill basics and navigate
await user.type(screen.getByLabelText(/plan code/i), 'test');
await user.type(screen.getByLabelText(/display name/i), 'Test');
await user.click(screen.getByRole('button', { name: /next/i }));
return user;
};
it('validates fee percent is between 0 and 100', async () => {
const user = await goToPricingStep();
const feePercentInput = screen.getByLabelText(/fee percentage/i);
// Should have min and max attributes
expect(feePercentInput).toHaveAttribute('min', '0');
expect(feePercentInput).toHaveAttribute('max', '100');
});
it('does not allow fee percent over 100', async () => {
const user = await goToPricingStep();
const feePercentInput = screen.getByLabelText(/fee percentage/i);
await user.clear(feePercentInput);
await user.type(feePercentInput, '150');
// Should show validation warning
expect(screen.getByText(/must be between 0 and 100/i)).toBeInTheDocument();
});
it('does not allow negative fee percent', async () => {
const user = await goToPricingStep();
const feePercentInput = screen.getByLabelText(/fee percentage/i);
// Input has min="0" attribute to prevent negative values
expect(feePercentInput).toHaveAttribute('min', '0');
});
it('shows transaction fee example calculation', async () => {
const user = await goToPricingStep();
// Should show example like "On a $100 transaction: $4.40 fee"
expect(screen.getByText(/on a.*transaction/i)).toBeInTheDocument();
});
});
describe('Wizard Navigation', () => {
it('shows all wizard steps', () => {
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
// Should show step indicators (they have aria-label)
expect(screen.getByRole('button', { name: /basics/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /pricing/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /features/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /display/i })).toBeInTheDocument();
});
it('navigates back from pricing to basics', async () => {
const user = userEvent.setup();
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
// Fill basics and go to pricing
await user.type(screen.getByLabelText(/plan code/i), 'test');
await user.type(screen.getByLabelText(/display name/i), 'Test');
await user.click(screen.getByRole('button', { name: /next/i }));
// Should be on pricing step
expect(screen.getByLabelText(/monthly price/i)).toBeInTheDocument();
// Click back
await user.click(screen.getByRole('button', { name: /back/i }));
// Should be back on basics
expect(screen.getByLabelText(/plan code/i)).toBeInTheDocument();
});
it('allows clicking step indicators to navigate', async () => {
const user = userEvent.setup();
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
// Fill basics
await user.type(screen.getByLabelText(/plan code/i), 'test');
await user.type(screen.getByLabelText(/display name/i), 'Test');
// Click on Pricing step indicator
const pricingStep = screen.getByRole('button', { name: /pricing/i });
await user.click(pricingStep);
// Should navigate to pricing
expect(screen.getByLabelText(/monthly price/i)).toBeInTheDocument();
});
});
describe('Live Summary Panel', () => {
it('shows plan name in summary', async () => {
const user = userEvent.setup();
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
await user.type(screen.getByLabelText(/display name/i), 'My Amazing Plan');
// Summary should show the plan name
expect(screen.getByText('My Amazing Plan')).toBeInTheDocument();
});
it('shows price in summary after entering pricing', async () => {
const user = userEvent.setup();
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
// Fill basics
await user.type(screen.getByLabelText(/plan code/i), 'test');
await user.type(screen.getByLabelText(/display name/i), 'Test');
await user.click(screen.getByRole('button', { name: /next/i }));
// Enter price
const monthlyInput = screen.getByLabelText(/monthly price/i);
await user.clear(monthlyInput);
await user.type(monthlyInput, '29');
// Summary should show the price
expect(screen.getByText(/\$29/)).toBeInTheDocument();
});
it('shows selected features count in summary', async () => {
const user = userEvent.setup();
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
// Navigate to features step
await user.type(screen.getByLabelText(/plan code/i), 'test');
await user.type(screen.getByLabelText(/display name/i), 'Test');
await user.click(screen.getByRole('button', { name: /next/i })); // to pricing
await user.click(screen.getByRole('button', { name: /next/i })); // to features
// Select a feature
const smsCheckbox = screen.getByRole('checkbox', { name: /sms/i });
await user.click(smsCheckbox);
// Summary should show feature count
expect(screen.getByText(/1 feature/i)).toBeInTheDocument();
});
});
describe('Create Version Confirmation', () => {
it('shows grandfathering warning when editing version with subscribers', async () => {
render(
<PlanEditorWizard
isOpen={true}
onClose={vi.fn()}
mode="edit"
initialData={{
id: 1,
code: 'pro',
name: 'Pro',
version: {
id: 1,
subscriber_count: 5,
name: 'Pro v1',
},
}}
/>,
{ wrapper: createWrapper() }
);
// Should show warning about subscribers and grandfathering
expect(screen.getByText(/5/)).toBeInTheDocument();
expect(screen.getByText(/subscriber/i)).toBeInTheDocument();
expect(screen.getByText(/grandfathering/i)).toBeInTheDocument();
});
it('shows "Create New Version" confirmation for version with subscribers', async () => {
const user = userEvent.setup();
render(
<PlanEditorWizard
{...defaultProps}
mode="edit"
initialData={{
id: 1,
code: 'pro',
name: 'Pro',
version: {
id: 1,
subscriber_count: 5,
},
}}
/>,
{ wrapper: createWrapper() }
);
// Navigate to last step and try to save
// The save button should mention "Create New Version"
const saveButton = screen.queryByRole('button', { name: /create new version/i });
expect(saveButton || screen.getByText(/new version/i)).toBeInTheDocument();
});
});
describe('Form Submission', () => {
it('calls onClose after successful creation', async () => {
const onClose = vi.fn();
const user = userEvent.setup();
render(
<PlanEditorWizard {...defaultProps} onClose={onClose} />,
{ wrapper: createWrapper() }
);
// Fill all required fields
await user.type(screen.getByLabelText(/plan code/i), 'test');
await user.type(screen.getByLabelText(/display name/i), 'Test');
// Navigate through wizard
await user.click(screen.getByRole('button', { name: /next/i })); // pricing
await user.click(screen.getByRole('button', { name: /next/i })); // features
await user.click(screen.getByRole('button', { name: /next/i })); // display
// Submit
const createButton = screen.getByRole('button', { name: /create plan/i });
await user.click(createButton);
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
});
describe('Force Update (Superuser)', () => {
it('shows "Update Without Versioning" button for superuser editing plan with subscribers', async () => {
const user = userEvent.setup();
render(
<PlanEditorWizard
{...defaultProps}
mode="edit"
initialData={{
id: 1,
code: 'pro',
name: 'Pro',
version: {
id: 1,
subscriber_count: 5,
name: 'Pro v1',
},
}}
/>,
{ wrapper: createWrapper() }
);
// Navigate to last step
await user.click(screen.getByRole('button', { name: /pricing/i }));
await user.click(screen.getByRole('button', { name: /features/i }));
await user.click(screen.getByRole('button', { name: /display/i }));
// Should show "Update Without Versioning" button
expect(screen.getByRole('button', { name: /update without versioning/i })).toBeInTheDocument();
});
it('shows confirmation dialog when clicking "Update Without Versioning"', async () => {
const user = userEvent.setup();
render(
<PlanEditorWizard
{...defaultProps}
mode="edit"
initialData={{
id: 1,
code: 'pro',
name: 'Pro',
version: {
id: 1,
subscriber_count: 5,
name: 'Pro v1',
},
}}
/>,
{ wrapper: createWrapper() }
);
// Navigate to last step
await user.click(screen.getByRole('button', { name: /pricing/i }));
await user.click(screen.getByRole('button', { name: /features/i }));
await user.click(screen.getByRole('button', { name: /display/i }));
// Click the force update button
await user.click(screen.getByRole('button', { name: /update without versioning/i }));
// Should show confirmation dialog with warning
expect(screen.getByText(/warning: this will affect existing customers/i)).toBeInTheDocument();
expect(screen.getByText(/5/)).toBeInTheDocument(); // subscriber count
expect(screen.getByRole('button', { name: /yes, update all subscribers/i })).toBeInTheDocument();
});
it('can cancel force update confirmation', async () => {
const user = userEvent.setup();
render(
<PlanEditorWizard
{...defaultProps}
mode="edit"
initialData={{
id: 1,
code: 'pro',
name: 'Pro',
version: {
id: 1,
subscriber_count: 5,
name: 'Pro v1',
},
}}
/>,
{ wrapper: createWrapper() }
);
// Navigate to last step
await user.click(screen.getByRole('button', { name: /pricing/i }));
await user.click(screen.getByRole('button', { name: /features/i }));
await user.click(screen.getByRole('button', { name: /display/i }));
// Click the force update button
await user.click(screen.getByRole('button', { name: /update without versioning/i }));
// Click Cancel
const cancelButtons = screen.getAllByRole('button', { name: /cancel/i });
await user.click(cancelButtons[0]); // First cancel is in the confirmation dialog
// Confirmation dialog should be hidden, back to normal footer
expect(screen.queryByText(/warning: this will affect existing customers/i)).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /update without versioning/i })).toBeInTheDocument();
});
it('does not show "Update Without Versioning" for plans without subscribers', async () => {
const user = userEvent.setup();
render(
<PlanEditorWizard
{...defaultProps}
mode="edit"
initialData={{
id: 1,
code: 'pro',
name: 'Pro',
version: {
id: 1,
subscriber_count: 0, // No subscribers
name: 'Pro v1',
},
}}
/>,
{ wrapper: createWrapper() }
);
// Navigate to last step
await user.click(screen.getByRole('button', { name: /pricing/i }));
await user.click(screen.getByRole('button', { name: /features/i }));
await user.click(screen.getByRole('button', { name: /display/i }));
// Should NOT show "Update Without Versioning" button
expect(screen.queryByRole('button', { name: /update without versioning/i })).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,445 @@
/**
* Canonical Feature Catalog
*
* This file defines the canonical list of features available in the SmoothSchedule
* billing system. Features are organized by type (boolean vs integer) and include
* human-readable labels and descriptions.
*
* IMPORTANT: When adding new feature codes, add them here first to maintain a
* single source of truth. The FeaturePicker component uses this catalog to
* provide autocomplete and validation.
*
* Feature Types:
* - Boolean: On/off capabilities (e.g., sms_enabled, api_access)
* - Integer: Limit/quota features (e.g., max_users, max_resources)
*
* Usage:
* ```typescript
* import { FEATURE_CATALOG, getFeatureInfo, isCanonicalFeature } from '../billing/featureCatalog';
*
* // Get info about a feature
* const info = getFeatureInfo('max_users');
* // { code: 'max_users', name: 'Maximum Users', type: 'integer', ... }
*
* // Check if a feature is in the canonical catalog
* const isCanonical = isCanonicalFeature('custom_feature'); // false
* ```
*/
export type FeatureType = 'boolean' | 'integer';
export interface FeatureCatalogEntry {
code: string;
name: string;
description: string;
type: FeatureType;
category: FeatureCategory;
}
export type FeatureCategory =
| 'communication'
| 'limits'
| 'access'
| 'branding'
| 'support'
| 'integrations'
| 'security'
| 'scheduling';
// =============================================================================
// Boolean Features (Capabilities)
// =============================================================================
export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
// Communication
{
code: 'sms_enabled',
name: 'SMS Messaging',
description: 'Send SMS notifications and reminders to customers',
type: 'boolean',
category: 'communication',
},
{
code: 'masked_calling_enabled',
name: 'Masked Calling',
description: 'Make calls with masked caller ID for privacy',
type: 'boolean',
category: 'communication',
},
{
code: 'proxy_number_enabled',
name: 'Proxy Phone Numbers',
description: 'Use proxy phone numbers for customer communication',
type: 'boolean',
category: 'communication',
},
// Payments & Commerce
{
code: 'can_accept_payments',
name: 'Accept Payments',
description: 'Accept online payments via Stripe Connect',
type: 'boolean',
category: 'access',
},
{
code: 'can_use_pos',
name: 'Point of Sale',
description: 'Use Point of Sale (POS) system',
type: 'boolean',
category: 'access',
},
// Scheduling & Booking
{
code: 'recurring_appointments',
name: 'Recurring Appointments',
description: 'Schedule recurring appointments',
type: 'boolean',
category: 'scheduling',
},
{
code: 'group_bookings',
name: 'Group Bookings',
description: 'Allow multiple customers per appointment',
type: 'boolean',
category: 'scheduling',
},
{
code: 'waitlist',
name: 'Waitlist',
description: 'Enable waitlist for fully booked slots',
type: 'boolean',
category: 'scheduling',
},
{
code: 'can_add_video_conferencing',
name: 'Video Conferencing',
description: 'Add video conferencing to events',
type: 'boolean',
category: 'scheduling',
},
// Access & Features
{
code: 'api_access',
name: 'API Access',
description: 'Access the public API for integrations',
type: 'boolean',
category: 'access',
},
{
code: 'can_use_analytics',
name: 'Analytics Dashboard',
description: 'Access business analytics and reporting',
type: 'boolean',
category: 'access',
},
{
code: 'can_use_tasks',
name: 'Automated Tasks',
description: 'Create and run automated task workflows',
type: 'boolean',
category: 'access',
},
{
code: 'can_use_contracts',
name: 'Contracts & E-Signatures',
description: 'Create and manage e-signature contracts',
type: 'boolean',
category: 'access',
},
{
code: 'customer_portal',
name: 'Customer Portal',
description: 'Branded self-service portal for customers',
type: 'boolean',
category: 'access',
},
{
code: 'custom_fields',
name: 'Custom Fields',
description: 'Create custom data fields for resources and events',
type: 'boolean',
category: 'access',
},
{
code: 'can_export_data',
name: 'Data Export',
description: 'Export data (appointments, customers, etc.)',
type: 'boolean',
category: 'access',
},
{
code: 'can_use_mobile_app',
name: 'Mobile App',
description: 'Access the mobile app for field employees',
type: 'boolean',
category: 'access',
},
// Integrations
{
code: 'calendar_sync',
name: 'Calendar Sync',
description: 'Sync with Google Calendar, Outlook, etc.',
type: 'boolean',
category: 'integrations',
},
{
code: 'webhooks_enabled',
name: 'Webhooks',
description: 'Send webhook notifications for events',
type: 'boolean',
category: 'integrations',
},
{
code: 'can_use_plugins',
name: 'Plugin Integrations',
description: 'Use third-party plugin integrations',
type: 'boolean',
category: 'integrations',
},
{
code: 'can_create_plugins',
name: 'Create Plugins',
description: 'Create custom plugins for automation',
type: 'boolean',
category: 'integrations',
},
{
code: 'can_manage_oauth_credentials',
name: 'Manage OAuth',
description: 'Manage your own OAuth credentials',
type: 'boolean',
category: 'integrations',
},
// Branding
{
code: 'custom_branding',
name: 'Custom Branding',
description: 'Customize branding colors, logo, and styling',
type: 'boolean',
category: 'branding',
},
{
code: 'white_label',
name: 'White Label',
description: 'Remove SmoothSchedule branding completely',
type: 'boolean',
category: 'branding',
},
{
code: 'can_use_custom_domain',
name: 'Custom Domain',
description: 'Configure a custom domain for your booking page',
type: 'boolean',
category: 'branding',
},
// Support
{
code: 'priority_support',
name: 'Priority Support',
description: 'Get priority customer support response',
type: 'boolean',
category: 'support',
},
// Security & Compliance
{
code: 'can_require_2fa',
name: 'Require 2FA',
description: 'Require two-factor authentication for users',
type: 'boolean',
category: 'security',
},
{
code: 'sso_enabled',
name: 'Single Sign-On (SSO)',
description: 'Enable SSO authentication for team members',
type: 'boolean',
category: 'security',
},
{
code: 'can_delete_data',
name: 'Delete Data',
description: 'Permanently delete data',
type: 'boolean',
category: 'security',
},
{
code: 'can_download_logs',
name: 'Download Logs',
description: 'Download system logs',
type: 'boolean',
category: 'security',
},
];
// =============================================================================
// Integer Features (Limits & Quotas)
// =============================================================================
export const INTEGER_FEATURES: FeatureCatalogEntry[] = [
// User/Resource Limits
{
code: 'max_users',
name: 'Maximum Team Members',
description: 'Maximum number of team member accounts (0 = unlimited)',
type: 'integer',
category: 'limits',
},
{
code: 'max_resources',
name: 'Maximum Resources',
description: 'Maximum number of resources (staff, rooms, equipment)',
type: 'integer',
category: 'limits',
},
{
code: 'max_locations',
name: 'Location Limit',
description: 'Maximum number of business locations (0 = unlimited)',
type: 'integer',
category: 'limits',
},
{
code: 'max_services',
name: 'Maximum Services',
description: 'Maximum number of service types (0 = unlimited)',
type: 'integer',
category: 'limits',
},
{
code: 'max_customers',
name: 'Customer Limit',
description: 'Maximum number of customer records (0 = unlimited)',
type: 'integer',
category: 'limits',
},
{
code: 'max_event_types',
name: 'Max Event Types',
description: 'Maximum number of event types',
type: 'integer',
category: 'limits',
},
// Usage Limits
{
code: 'max_appointments_per_month',
name: 'Monthly Appointment Limit',
description: 'Maximum appointments per month (0 = unlimited)',
type: 'integer',
category: 'limits',
},
{
code: 'max_automated_tasks',
name: 'Automated Task Limit',
description: 'Maximum number of automated tasks (0 = unlimited)',
type: 'integer',
category: 'limits',
},
{
code: 'max_email_templates',
name: 'Email Template Limit',
description: 'Maximum number of custom email templates (0 = unlimited)',
type: 'integer',
category: 'limits',
},
{
code: 'max_calendars_connected',
name: 'Max Calendars',
description: 'Maximum number of external calendars connected',
type: 'integer',
category: 'limits',
},
// Technical Limits
{
code: 'storage_gb',
name: 'Storage (GB)',
description: 'File storage limit in gigabytes (0 = unlimited)',
type: 'integer',
category: 'limits',
},
{
code: 'max_api_requests_per_day',
name: 'Daily API Request Limit',
description: 'Maximum API requests per day (0 = unlimited)',
type: 'integer',
category: 'limits',
},
];
// =============================================================================
// Combined Catalog
// =============================================================================
export const FEATURE_CATALOG: FeatureCatalogEntry[] = [
...BOOLEAN_FEATURES,
...INTEGER_FEATURES,
];
// Create a lookup map for quick access
const featureMap = new Map<string, FeatureCatalogEntry>(
FEATURE_CATALOG.map((f) => [f.code, f])
);
// =============================================================================
// Helper Functions
// =============================================================================
/**
* Get feature information by code
*/
export const getFeatureInfo = (code: string): FeatureCatalogEntry | undefined => {
return featureMap.get(code);
};
/**
* Check if a feature code is in the canonical catalog
*/
export const isCanonicalFeature = (code: string): boolean => {
return featureMap.has(code);
};
/**
* Get all features by type
*/
export const getFeaturesByType = (type: FeatureType): FeatureCatalogEntry[] => {
return FEATURE_CATALOG.filter((f) => f.type === type);
};
/**
* Get all features by category
*/
export const getFeaturesByCategory = (category: FeatureCategory): FeatureCatalogEntry[] => {
return FEATURE_CATALOG.filter((f) => f.category === category);
};
/**
* Get all unique categories
*/
export const getAllCategories = (): FeatureCategory[] => {
return [...new Set(FEATURE_CATALOG.map((f) => f.category))];
};
/**
* Format category name for display
*/
export const formatCategoryName = (category: FeatureCategory): string => {
const names: Record<FeatureCategory, string> = {
communication: 'Communication',
limits: 'Limits & Quotas',
access: 'Access & Features',
branding: 'Branding & Customization',
support: 'Support',
integrations: 'Integrations',
security: 'Security & Compliance',
scheduling: 'Scheduling & Booking',
};
return names[category];
};

View File

@@ -0,0 +1,27 @@
/**
* Billing Module
*
* Components and utilities for the billing management system.
*
* Component Structure:
* - CatalogListPanel: Left sidebar with search/filter and item list
* - PlanDetailPanel: Main panel showing selected plan/addon details
* - PlanEditorWizard: Multi-step wizard for creating/editing plans
* - FeaturePicker: Feature selection UI for plans
*
* To add new feature codes:
* 1. Add the feature to featureCatalog.ts in BOOLEAN_FEATURES or INTEGER_FEATURES
* 2. Run migrations in backend if needed
* 3. Features in the catalog get validation and display benefits
*/
// Feature Catalog
export * from './featureCatalog';
// Components
export { FeaturePicker } from './components/FeaturePicker';
export { PlanEditorWizard } from './components/PlanEditorWizard';
export { CatalogListPanel } from './components/CatalogListPanel';
export { PlanDetailPanel } from './components/PlanDetailPanel';
export { AddOnEditorModal } from './components/AddOnEditorModal';
export type { CatalogItem } from './components/CatalogListPanel';

View File

@@ -1,8 +1,16 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import axios from '../api/client';
import { X, Calendar, Clock, RotateCw, Zap, CalendarDays, ChevronDown, ChevronUp } from 'lucide-react';
import { X, Calendar, Clock, RotateCw, Zap, CalendarDays } from 'lucide-react';
import toast from 'react-hot-toast';
import {
SCHEDULE_PRESETS,
TRIGGER_OPTIONS,
OFFSET_PRESETS,
getScheduleDescription,
getEventTimingDescription,
} from '../constants/schedulePresets';
import { ErrorMessage } from './ui';
interface PluginInstallation {
id: string;
@@ -14,11 +22,11 @@ interface PluginInstallation {
version: string;
author_name: string;
logo_url?: string;
template_variables: Record<string, any>;
template_variables: Record<string, unknown>;
scheduled_task?: number;
scheduled_task_name?: string;
installed_at: string;
config_values: Record<string, any>;
config_values: Record<string, unknown>;
has_update: boolean;
}
@@ -28,65 +36,6 @@ interface CreateTaskModalProps {
onSuccess: () => void;
}
// Schedule presets for visual selection
interface SchedulePreset {
id: string;
label: string;
description: string;
type: 'INTERVAL' | 'CRON';
interval_minutes?: number;
cron_expression?: string;
}
const SCHEDULE_PRESETS: SchedulePreset[] = [
// Interval-based
{ id: 'every_15min', label: 'Every 15 minutes', description: 'Runs 4 times per hour', type: 'INTERVAL', interval_minutes: 15 },
{ id: 'every_30min', label: 'Every 30 minutes', description: 'Runs twice per hour', type: 'INTERVAL', interval_minutes: 30 },
{ id: 'every_hour', label: 'Every hour', description: 'Runs 24 times per day', type: 'INTERVAL', interval_minutes: 60 },
{ id: 'every_2hours', label: 'Every 2 hours', description: 'Runs 12 times per day', type: 'INTERVAL', interval_minutes: 120 },
{ id: 'every_4hours', label: 'Every 4 hours', description: 'Runs 6 times per day', type: 'INTERVAL', interval_minutes: 240 },
{ id: 'every_6hours', label: 'Every 6 hours', description: 'Runs 4 times per day', type: 'INTERVAL', interval_minutes: 360 },
{ id: 'every_12hours', label: 'Twice daily', description: 'Runs at midnight and noon', type: 'INTERVAL', interval_minutes: 720 },
// Cron-based (specific times)
{ id: 'daily_midnight', label: 'Daily at midnight', description: 'Runs once per day at 12:00 AM', type: 'CRON', cron_expression: '0 0 * * *' },
{ id: 'daily_9am', label: 'Daily at 9 AM', description: 'Runs once per day at 9:00 AM', type: 'CRON', cron_expression: '0 9 * * *' },
{ id: 'daily_6pm', label: 'Daily at 6 PM', description: 'Runs once per day at 6:00 PM', type: 'CRON', cron_expression: '0 18 * * *' },
{ id: 'weekdays_9am', label: 'Weekdays at 9 AM', description: 'Mon-Fri at 9:00 AM', type: 'CRON', cron_expression: '0 9 * * 1-5' },
{ id: 'weekdays_6pm', label: 'Weekdays at 6 PM', description: 'Mon-Fri at 6:00 PM', type: 'CRON', cron_expression: '0 18 * * 1-5' },
{ id: 'weekly_sunday', label: 'Weekly on Sunday', description: 'Every Sunday at midnight', type: 'CRON', cron_expression: '0 0 * * 0' },
{ id: 'weekly_monday', label: 'Weekly on Monday', description: 'Every Monday at 9:00 AM', type: 'CRON', cron_expression: '0 9 * * 1' },
{ id: 'monthly_1st', label: 'Monthly on the 1st', description: 'First day of each month', type: 'CRON', cron_expression: '0 0 1 * *' },
];
// Event trigger options (same as EventAutomations component)
interface TriggerOption {
value: string;
label: string;
}
interface OffsetPreset {
value: number;
label: string;
}
const TRIGGER_OPTIONS: TriggerOption[] = [
{ value: 'before_start', label: 'Before Start' },
{ value: 'at_start', label: 'At Start' },
{ value: 'after_start', label: 'After Start' },
{ value: 'after_end', label: 'After End' },
{ value: 'on_complete', label: 'When Completed' },
{ value: 'on_cancel', label: 'When Canceled' },
];
const OFFSET_PRESETS: OffsetPreset[] = [
{ value: 0, label: 'Immediately' },
{ value: 5, label: '5 min' },
{ value: 10, label: '10 min' },
{ value: 15, label: '15 min' },
{ value: 30, label: '30 min' },
{ value: 60, label: '1 hour' },
];
// Task type: scheduled or event-based
type TaskType = 'scheduled' | 'event';
@@ -154,41 +103,16 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSu
setStep(2);
};
const getScheduleDescription = () => {
if (scheduleMode === 'onetime') {
if (runAtDate && runAtTime) {
return `Once on ${new Date(`${runAtDate}T${runAtTime}`).toLocaleString()}`;
}
return 'Select date and time';
}
if (scheduleMode === 'advanced') {
return `Custom: ${customCron}`;
}
const preset = SCHEDULE_PRESETS.find(p => p.id === selectedPreset);
return preset?.description || 'Select a schedule';
};
// Use shared helper functions from constants
const scheduleDescriptionText = getScheduleDescription(
scheduleMode,
selectedPreset,
runAtDate,
runAtTime,
customCron
);
const getEventTimingDescription = () => {
const trigger = TRIGGER_OPTIONS.find(t => t.value === selectedTrigger);
if (!trigger) return 'Select timing';
if (selectedTrigger === 'on_complete') return 'When event is completed';
if (selectedTrigger === 'on_cancel') return 'When event is canceled';
if (selectedOffset === 0) {
if (selectedTrigger === 'before_start') return 'At event start';
if (selectedTrigger === 'at_start') return 'At event start';
if (selectedTrigger === 'after_start') return 'At event start';
if (selectedTrigger === 'after_end') return 'At event end';
}
const offsetLabel = OFFSET_PRESETS.find(o => o.value === selectedOffset)?.label || `${selectedOffset} min`;
if (selectedTrigger === 'before_start') return `${offsetLabel} before event starts`;
if (selectedTrigger === 'at_start' || selectedTrigger === 'after_start') return `${offsetLabel} after event starts`;
if (selectedTrigger === 'after_end') return `${offsetLabel} after event ends`;
return trigger.label;
};
const eventTimingDescriptionText = getEventTimingDescription(selectedTrigger, selectedOffset);
const showOffset = !['on_complete', 'on_cancel'].includes(selectedTrigger);
@@ -543,7 +467,7 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSu
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-sm text-green-800 dark:text-green-200">
<strong>Schedule:</strong> {getScheduleDescription()}
<strong>Schedule:</strong> {scheduleDescriptionText}
</span>
</div>
</div>
@@ -657,7 +581,7 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSu
<div className="flex items-center gap-2">
<CalendarDays className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<span className="text-sm text-purple-800 dark:text-purple-200">
<strong>Runs:</strong> {getEventTimingDescription()}
<strong>Runs:</strong> {eventTimingDescriptionText}
</span>
</div>
</div>
@@ -665,11 +589,7 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSu
)}
{/* Error */}
{error && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
)}
{error && <ErrorMessage message={error} />}
</div>
)}
</div>

View File

@@ -0,0 +1,247 @@
/**
* FeatureGate Component
*
* Conditionally renders children based on entitlement checks.
* Used to show/hide features based on the business's subscription plan.
*/
import React from 'react';
import { useEntitlements } from '../hooks/useEntitlements';
// ============================================================================
// FeatureGate - For boolean feature checks
// ============================================================================
interface FeatureGateProps {
/**
* Single feature code to check
*/
feature?: string;
/**
* Multiple feature codes to check
*/
features?: string[];
/**
* If true, ALL features must be enabled. If false, ANY feature being enabled is sufficient.
* Default: true (all required)
*/
requireAll?: boolean;
/**
* Content to render when feature(s) are enabled
*/
children: React.ReactNode;
/**
* Content to render when feature(s) are NOT enabled
*/
fallback?: React.ReactNode;
/**
* Content to render while entitlements are loading
*/
loadingFallback?: React.ReactNode;
}
/**
* Conditionally render content based on feature entitlements.
*
* @example
* ```tsx
* // Single feature check
* <FeatureGate feature="can_use_sms_reminders">
* <SMSSettings />
* </FeatureGate>
*
* // With fallback
* <FeatureGate
* feature="can_use_sms_reminders"
* fallback={<UpgradePrompt feature="SMS Reminders" />}
* >
* <SMSSettings />
* </FeatureGate>
*
* // Multiple features (all required)
* <FeatureGate features={['can_use_plugins', 'can_use_tasks']}>
* <TaskScheduler />
* </FeatureGate>
*
* // Multiple features (any one)
* <FeatureGate features={['can_use_sms_reminders', 'can_use_webhooks']} requireAll={false}>
* <NotificationSettings />
* </FeatureGate>
* ```
*/
export const FeatureGate: React.FC<FeatureGateProps> = ({
feature,
features,
requireAll = true,
children,
fallback = null,
loadingFallback = null,
}) => {
const { hasFeature, isLoading } = useEntitlements();
// Show loading state if provided
if (isLoading) {
return <>{loadingFallback}</>;
}
// Determine which features to check
const featuresToCheck = features ?? (feature ? [feature] : []);
if (featuresToCheck.length === 0) {
// No features specified, render children
return <>{children}</>;
}
// Check features
const hasAccess = requireAll
? featuresToCheck.every((f) => hasFeature(f))
: featuresToCheck.some((f) => hasFeature(f));
if (hasAccess) {
return <>{children}</>;
}
return <>{fallback}</>;
};
// ============================================================================
// LimitGate - For integer limit checks
// ============================================================================
interface LimitGateProps {
/**
* The limit feature code to check (e.g., 'max_users')
*/
limit: string;
/**
* Current usage count
*/
currentUsage: number;
/**
* Content to render when under the limit
*/
children: React.ReactNode;
/**
* Content to render when at or over the limit
*/
fallback?: React.ReactNode;
/**
* Content to render while entitlements are loading
*/
loadingFallback?: React.ReactNode;
}
/**
* Conditionally render content based on usage limits.
*
* @example
* ```tsx
* <LimitGate
* limit="max_users"
* currentUsage={users.length}
* fallback={<UpgradePrompt message="You've reached your user limit" />}
* >
* <AddUserButton />
* </LimitGate>
* ```
*/
export const LimitGate: React.FC<LimitGateProps> = ({
limit,
currentUsage,
children,
fallback = null,
loadingFallback = null,
}) => {
const { getLimit, isLoading } = useEntitlements();
// Show loading state if provided
if (isLoading) {
return <>{loadingFallback}</>;
}
const maxLimit = getLimit(limit);
// If limit is null, treat as unlimited
if (maxLimit === null) {
return <>{children}</>;
}
// Check if under limit
if (currentUsage < maxLimit) {
return <>{children}</>;
}
return <>{fallback}</>;
};
// ============================================================================
// Helper Components
// ============================================================================
interface UpgradePromptProps {
/**
* Feature name to display
*/
feature?: string;
/**
* Custom message
*/
message?: string;
/**
* Upgrade URL (defaults to /settings/billing)
*/
upgradeUrl?: string;
}
/**
* Default upgrade prompt component.
* Can be used as a fallback in FeatureGate/LimitGate.
*/
export const UpgradePrompt: React.FC<UpgradePromptProps> = ({
feature,
message,
upgradeUrl = '/settings/billing',
}) => {
const displayMessage =
message || (feature ? `Upgrade your plan to access ${feature}` : 'Upgrade your plan to access this feature');
return (
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<div className="flex items-center gap-2">
<svg
className="w-5 h-5 text-yellow-600 dark:text-yellow-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<span className="text-yellow-800 dark:text-yellow-200 font-medium">{displayMessage}</span>
</div>
<a
href={upgradeUrl}
className="mt-2 inline-block text-sm text-yellow-700 dark:text-yellow-300 hover:underline"
>
View upgrade options &rarr;
</a>
</div>
);
};
export default FeatureGate;

View File

@@ -0,0 +1,134 @@
/**
* LocationSelector Component
*
* A reusable dropdown for selecting a business location.
* Hidden when only one location exists.
*/
import React from 'react';
import { useLocations } from '../hooks/useLocations';
import { FormSelect, SelectOption } from './ui/FormSelect';
import { Location } from '../types';
interface LocationSelectorProps {
/** Currently selected location ID */
value?: number | null;
/** Callback when location is selected */
onChange: (locationId: number | null) => void;
/** Label for the selector */
label?: string;
/** Error message */
error?: string;
/** Hint text */
hint?: string;
/** Placeholder text */
placeholder?: string;
/** Whether the field is required */
required?: boolean;
/** Whether to include inactive locations */
includeInactive?: boolean;
/** Whether the selector is disabled */
disabled?: boolean;
/** Force show even with single location (for admin purposes) */
forceShow?: boolean;
/** Container class name */
className?: string;
}
/**
* LocationSelector - Dropdown for selecting a business location
*
* Automatically hides when:
* - Only one active location exists (unless forceShow is true)
* - Locations are still loading
*
* The component auto-selects the only location when there's just one.
*/
export const LocationSelector: React.FC<LocationSelectorProps> = ({
value,
onChange,
label = 'Location',
error,
hint,
placeholder = 'Select a location',
required = false,
includeInactive = false,
disabled = false,
forceShow = false,
className = '',
}) => {
const { data: locations, isLoading, isError } = useLocations({ includeInactive });
// Don't render if loading or error
if (isLoading || isError) {
return null;
}
// Filter to only active locations if not including inactive
const availableLocations = locations ?? [];
// Hide if only one location (unless forceShow)
if (availableLocations.length <= 1 && !forceShow) {
return null;
}
// Build options from locations
const options: SelectOption<string>[] = availableLocations.map((loc: Location) => ({
value: String(loc.id),
label: loc.is_primary
? `${loc.name} (Primary)`
: loc.is_active
? loc.name
: `${loc.name} (Inactive)`,
disabled: !loc.is_active && !includeInactive,
}));
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const selectedValue = e.target.value;
onChange(selectedValue ? Number(selectedValue) : null);
};
return (
<FormSelect
label={label}
value={value ? String(value) : ''}
onChange={handleChange}
options={options}
error={error}
hint={hint}
placeholder={placeholder}
required={required}
disabled={disabled}
containerClassName={className}
/>
);
};
/**
* Hook to determine if location selector should be shown
*/
export const useShouldShowLocationSelector = (includeInactive = false): boolean => {
const { data: locations, isLoading } = useLocations({ includeInactive });
if (isLoading) return false;
return (locations?.length ?? 0) > 1;
};
/**
* Hook to auto-select location when only one exists
*/
export const useAutoSelectLocation = (
currentValue: number | null | undefined,
onChange: (locationId: number | null) => void
) => {
const { data: locations } = useLocations();
React.useEffect(() => {
// Auto-select if only one location and no value selected
if (locations?.length === 1 && !currentValue) {
onChange(locations[0].id);
}
}, [locations, currentValue, onChange]);
};
export default LocationSelector;

View File

@@ -59,7 +59,7 @@ const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ variant = '
// Handle time-off request notifications - navigate to time blocks page
// Includes both new requests and modified requests that need re-approval
if (notification.data?.type === 'time_off_request' || notification.data?.type === 'time_off_request_modified') {
navigate('/time-blocks');
navigate('/dashboard/time-blocks');
setIsOpen(false);
return;
}
@@ -224,7 +224,7 @@ const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ variant = '
</button>
<button
onClick={() => {
navigate('/notifications');
navigate('/dashboard/notifications');
setIsOpen(false);
}}
className="text-xs text-brand-600 hover:text-brand-700 dark:text-brand-400 font-medium"

View File

@@ -235,7 +235,7 @@ const PaymentSettingsSection: React.FC<PaymentSettingsSectionProps> = ({ busines
</ul>
<div className="flex flex-wrap gap-3">
<button
onClick={() => navigate('/settings/billing')}
onClick={() => navigate('/dashboard/settings/billing')}
className="flex items-center gap-2 px-5 py-2.5 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors"
>
<ArrowUpRight size={16} />

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useLocation } from 'react-router-dom';
import { LayoutDashboard, Building2, MessageSquare, Settings, Users, Shield, HelpCircle, Code, Mail } from 'lucide-react';
import { LayoutDashboard, Building2, MessageSquare, Settings, Users, Shield, HelpCircle, Code, Mail, CreditCard } from 'lucide-react';
import { User } from '../types';
import SmoothScheduleLogo from './SmoothScheduleLogo';
@@ -75,6 +75,10 @@ const PlatformSidebar: React.FC<PlatformSidebarProps> = ({ user, isCollapsed, to
<Shield size={18} className="shrink-0" />
{!isCollapsed && <span>{t('nav.staff')}</span>}
</Link>
<Link to="/platform/billing" className={getNavClass('/platform/billing')} title="Billing Management">
<CreditCard size={18} className="shrink-0" />
{!isCollapsed && <span>Billing</span>}
</Link>
<Link to="/platform/settings" className={getNavClass('/platform/settings')} title={t('nav.platformSettings')}>
<Settings size={18} className="shrink-0" />
{!isCollapsed && <span>{t('nav.platformSettings')}</span>}

View File

@@ -244,7 +244,7 @@ const QuotaOverageModal: React.FC<QuotaOverageModalProps> = ({ overages, onDismi
{t('quota.modal.dismissButton', 'Remind Me Later')}
</button>
<Link
to="/settings/quota"
to="/dashboard/settings/quota"
onClick={handleDismiss}
className="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
>

View File

@@ -78,7 +78,7 @@ const QuotaWarningBanner: React.FC<QuotaWarningBannerProps> = ({ overages, onDis
</div>
<div className="flex items-center gap-2">
<Link
to="/settings/quota"
to="/dashboard/settings/quota"
className={`inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
isCritical || isUrgent
? 'bg-white/20 hover:bg-white/30 text-white'

View File

@@ -0,0 +1,921 @@
/**
* Unit tests for Sidebar component
*
* Tests cover:
* - Component rendering
* - Resources list display
* - Pending appointments list
* - Empty state handling
* - Drag source setup with @dnd-kit
* - Scrolling reference setup
* - Multi-lane resource badges
* - Archive drop zone display
* - Internationalization (i18n)
*/
import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest';
import { render, screen, within } from '@testing-library/react';
import { DndContext } from '@dnd-kit/core';
import React from 'react';
import Sidebar, { PendingAppointment, ResourceLayout } from '../Sidebar';
// Setup proper mocks for @dnd-kit
beforeAll(() => {
// Mock IntersectionObserver properly as a constructor
class IntersectionObserverMock {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
constructor() {
return this;
}
}
global.IntersectionObserver = IntersectionObserverMock as any;
// Mock ResizeObserver properly as a constructor
class ResizeObserverMock {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
constructor() {
return this;
}
}
global.ResizeObserver = ResizeObserverMock as any;
});
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'scheduler.resources': 'Resources',
'scheduler.resource': 'Resource',
'scheduler.lanes': 'lanes',
'scheduler.pendingRequests': 'Pending Requests',
'scheduler.noPendingRequests': 'No pending requests',
'scheduler.dropToArchive': 'Drop here to archive',
'scheduler.min': 'min',
};
return translations[key] || key;
},
}),
}));
// Helper function to create a wrapper with DndContext
const createDndWrapper = () => {
return ({ children }: { children: React.ReactNode }) => (
<DndContext>{children}</DndContext>
);
};
describe('Sidebar', () => {
const mockScrollRef = { current: null } as React.RefObject<HTMLDivElement>;
const mockResourceLayouts: ResourceLayout[] = [
{
resourceId: 1,
resourceName: 'Dr. Smith',
height: 100,
laneCount: 1,
},
{
resourceId: 2,
resourceName: 'Conference Room A',
height: 120,
laneCount: 2,
},
{
resourceId: 3,
resourceName: 'Equipment Bay',
height: 100,
laneCount: 3,
},
];
const mockPendingAppointments: PendingAppointment[] = [
{
id: 1,
customerName: 'John Doe',
serviceName: 'Consultation',
durationMinutes: 30,
},
{
id: 2,
customerName: 'Jane Smith',
serviceName: 'Follow-up',
durationMinutes: 15,
},
{
id: 3,
customerName: 'Bob Johnson',
serviceName: 'Initial Assessment',
durationMinutes: 60,
},
];
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('should render the sidebar container', () => {
const { container } = render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const sidebar = container.firstChild as HTMLElement;
expect(sidebar).toBeInTheDocument();
expect(sidebar).toHaveClass('flex', 'flex-col', 'bg-white');
});
it('should render with fixed width of 250px', () => {
const { container } = render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const sidebar = container.firstChild as HTMLElement;
expect(sidebar).toHaveStyle({ width: '250px' });
});
it('should render resources header', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const header = screen.getByText('Resources');
expect(header).toBeInTheDocument();
});
it('should render pending requests section', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const pendingHeader = screen.getByText(/Pending Requests/);
expect(pendingHeader).toBeInTheDocument();
});
it('should render archive drop zone', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const dropZone = screen.getByText('Drop here to archive');
expect(dropZone).toBeInTheDocument();
});
});
describe('Resources List', () => {
it('should render all resources from resourceLayouts', () => {
render(
<Sidebar
resourceLayouts={mockResourceLayouts}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.getByText('Dr. Smith')).toBeInTheDocument();
expect(screen.getByText('Conference Room A')).toBeInTheDocument();
expect(screen.getByText('Equipment Bay')).toBeInTheDocument();
});
it('should apply correct height to each resource row', () => {
render(
<Sidebar
resourceLayouts={mockResourceLayouts}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
// The height style is on the resource row container (3 levels up from the text)
const drSmith = screen.getByText('Dr. Smith').closest('[style*="height"]');
const confRoom = screen.getByText('Conference Room A').closest('[style*="height"]');
expect(drSmith).toHaveStyle({ height: '100px' });
expect(confRoom).toHaveStyle({ height: '120px' });
});
it('should display "Resource" label for each resource', () => {
render(
<Sidebar
resourceLayouts={mockResourceLayouts}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const resourceLabels = screen.getAllByText('Resource');
expect(resourceLabels.length).toBeGreaterThan(0);
});
it('should render grip icons for resources', () => {
const { container } = render(
<Sidebar
resourceLayouts={mockResourceLayouts}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const gripIcons = container.querySelectorAll('svg');
expect(gripIcons.length).toBeGreaterThan(0);
});
it('should not render lane count badge for single-lane resources', () => {
render(
<Sidebar
resourceLayouts={[mockResourceLayouts[0]]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.queryByText(/lanes/)).not.toBeInTheDocument();
});
it('should render lane count badge for multi-lane resources', () => {
render(
<Sidebar
resourceLayouts={[mockResourceLayouts[1]]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.getByText('2 lanes')).toBeInTheDocument();
});
it('should render all multi-lane badges correctly', () => {
render(
<Sidebar
resourceLayouts={mockResourceLayouts}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.getByText('2 lanes')).toBeInTheDocument();
expect(screen.getByText('3 lanes')).toBeInTheDocument();
});
it('should apply correct styling to multi-lane badges', () => {
render(
<Sidebar
resourceLayouts={[mockResourceLayouts[1]]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const badge = screen.getByText('2 lanes');
expect(badge).toHaveClass('text-blue-600', 'bg-blue-50');
});
it('should attach scroll ref to resource list container', () => {
const testRef = React.createRef<HTMLDivElement>();
render(
<Sidebar
resourceLayouts={mockResourceLayouts}
pendingAppointments={[]}
scrollRef={testRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(testRef.current).toBeInstanceOf(HTMLDivElement);
});
it('should render empty resources list when no resources provided', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.queryByText('Dr. Smith')).not.toBeInTheDocument();
});
});
describe('Pending Appointments List', () => {
it('should render all pending appointments', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={mockPendingAppointments}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
expect(screen.getByText('Bob Johnson')).toBeInTheDocument();
});
it('should display customer names correctly', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={mockPendingAppointments}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
mockPendingAppointments.forEach((apt) => {
expect(screen.getByText(apt.customerName)).toBeInTheDocument();
});
});
it('should display service names correctly', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={mockPendingAppointments}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.getByText('Consultation')).toBeInTheDocument();
expect(screen.getByText('Follow-up')).toBeInTheDocument();
expect(screen.getByText('Initial Assessment')).toBeInTheDocument();
});
it('should display duration in minutes for each appointment', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={mockPendingAppointments}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.getByText('30 min')).toBeInTheDocument();
expect(screen.getByText('15 min')).toBeInTheDocument();
expect(screen.getByText('60 min')).toBeInTheDocument();
});
it('should display clock icon for each appointment', () => {
const { container } = render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={mockPendingAppointments}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
// Clock icons are SVGs
const clockIcons = container.querySelectorAll('svg');
expect(clockIcons.length).toBeGreaterThan(0);
});
it('should display grip vertical icon for drag handle', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[mockPendingAppointments[0]]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
// Navigate up to the draggable container which has the svg
const appointment = screen.getByText('John Doe').closest('.cursor-grab');
const svg = appointment?.querySelector('svg');
expect(svg).toBeInTheDocument();
});
it('should show appointment count in header', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={mockPendingAppointments}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.getByText(/Pending Requests \(3\)/)).toBeInTheDocument();
});
it('should update count when appointments change', () => {
const { rerender } = render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={mockPendingAppointments}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.getByText(/Pending Requests \(3\)/)).toBeInTheDocument();
rerender(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[mockPendingAppointments[0]]}
scrollRef={mockScrollRef}
/>
);
expect(screen.getByText(/Pending Requests \(1\)/)).toBeInTheDocument();
});
});
describe('Empty State', () => {
it('should display empty message when no pending appointments', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.getByText('No pending requests')).toBeInTheDocument();
});
it('should show count of 0 in header when empty', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.getByText(/Pending Requests \(0\)/)).toBeInTheDocument();
});
it('should apply italic styling to empty message', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const emptyMessage = screen.getByText('No pending requests');
expect(emptyMessage).toHaveClass('italic');
});
it('should not render appointment items when empty', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.queryByText('John Doe')).not.toBeInTheDocument();
expect(screen.queryByText('Jane Smith')).not.toBeInTheDocument();
});
});
describe('Drag and Drop Setup', () => {
it('should setup draggable for each pending appointment', () => {
const { container } = render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={mockPendingAppointments}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
// Each appointment should have drag cursor classes
const appointments = container.querySelectorAll('[class*="cursor-grab"]');
expect(appointments.length).toBe(mockPendingAppointments.length);
});
it('should apply cursor-grab class to draggable items', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[mockPendingAppointments[0]]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
// Use the specific class selector since .closest('div') returns the inner div
const appointmentCard = screen.getByText('John Doe').closest('.cursor-grab');
expect(appointmentCard).toBeInTheDocument();
});
it('should apply active cursor-grabbing class to draggable items', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[mockPendingAppointments[0]]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
// Verify the draggable container has the active:cursor-grabbing class
const appointmentCard = screen.getByText('John Doe').closest('[class*="active:cursor-grabbing"]');
expect(appointmentCard).toBeInTheDocument();
});
it('should render pending items with orange left border', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[mockPendingAppointments[0]]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
// Use the specific class selector
const appointmentCard = screen.getByText('John Doe').closest('.border-l-orange-400');
expect(appointmentCard).toBeInTheDocument();
});
it('should apply shadow on hover for draggable items', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[mockPendingAppointments[0]]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
// Use the specific class selector
const appointmentCard = screen.getByText('John Doe').closest('[class*="hover:shadow-md"]');
expect(appointmentCard).toBeInTheDocument();
});
});
describe('Archive Drop Zone', () => {
it('should render drop zone with trash icon', () => {
const { container } = render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const dropZone = screen.getByText('Drop here to archive').parentElement;
const trashIcon = dropZone?.querySelector('svg');
expect(trashIcon).toBeInTheDocument();
});
it('should apply dashed border to drop zone', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const dropZone = screen.getByText('Drop here to archive').parentElement;
expect(dropZone).toHaveClass('border-dashed');
});
it('should apply opacity to drop zone container', () => {
const { container } = render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const dropZoneContainer = screen
.getByText('Drop here to archive')
.closest('.opacity-50');
expect(dropZoneContainer).toBeInTheDocument();
});
});
describe('Layout and Styling', () => {
it('should apply fixed height to resources header', () => {
const { container } = render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
// The height style is on the header div itself
const header = screen.getByText('Resources').closest('[style*="height"]');
expect(header).toHaveStyle({ height: '48px' });
});
it('should apply fixed height to pending requests section', () => {
const { container } = render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const pendingSection = screen
.getByText(/Pending Requests/)
.closest('.h-80');
expect(pendingSection).toBeInTheDocument();
});
it('should have overflow-hidden on resource list', () => {
const { container } = render(
<Sidebar
resourceLayouts={mockResourceLayouts}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const resourceList = container.querySelector('.overflow-hidden');
expect(resourceList).toBeInTheDocument();
});
it('should have overflow-y-auto on pending appointments list', () => {
const { container } = render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={mockPendingAppointments}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const pendingList = container.querySelector('.overflow-y-auto');
expect(pendingList).toBeInTheDocument();
});
it('should apply border-right to sidebar', () => {
const { container } = render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const sidebar = container.firstChild as HTMLElement;
expect(sidebar).toHaveClass('border-r');
});
it('should apply shadow to sidebar', () => {
const { container } = render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const sidebar = container.firstChild as HTMLElement;
expect(sidebar).toHaveClass('shadow-lg');
});
it('should have dark mode classes', () => {
const { container } = render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const sidebar = container.firstChild as HTMLElement;
expect(sidebar).toHaveClass('dark:bg-gray-800');
expect(sidebar).toHaveClass('dark:border-gray-700');
});
});
describe('Internationalization', () => {
it('should use translation for resources header', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.getByText('Resources')).toBeInTheDocument();
});
it('should use translation for pending requests header', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.getByText(/Pending Requests/)).toBeInTheDocument();
});
it('should use translation for empty state message', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.getByText('No pending requests')).toBeInTheDocument();
});
it('should use translation for drop zone text', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.getByText('Drop here to archive')).toBeInTheDocument();
});
it('should use translation for duration units', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={[mockPendingAppointments[0]]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.getByText('30 min')).toBeInTheDocument();
});
it('should use translation for resource label', () => {
render(
<Sidebar
resourceLayouts={[mockResourceLayouts[0]]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.getByText('Resource')).toBeInTheDocument();
});
it('should use translation for lanes label', () => {
render(
<Sidebar
resourceLayouts={[mockResourceLayouts[1]]}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.getByText('2 lanes')).toBeInTheDocument();
});
});
describe('Integration', () => {
it('should render correctly with all props together', () => {
render(
<Sidebar
resourceLayouts={mockResourceLayouts}
pendingAppointments={mockPendingAppointments}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
// Verify resources
expect(screen.getByText('Dr. Smith')).toBeInTheDocument();
expect(screen.getByText('Conference Room A')).toBeInTheDocument();
expect(screen.getByText('Equipment Bay')).toBeInTheDocument();
// Verify pending appointments
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
expect(screen.getByText('Bob Johnson')).toBeInTheDocument();
// Verify count
expect(screen.getByText(/Pending Requests \(3\)/)).toBeInTheDocument();
// Verify archive drop zone
expect(screen.getByText('Drop here to archive')).toBeInTheDocument();
});
it('should handle empty resources with full pending appointments', () => {
render(
<Sidebar
resourceLayouts={[]}
pendingAppointments={mockPendingAppointments}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.queryByText('Dr. Smith')).not.toBeInTheDocument();
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText(/Pending Requests \(3\)/)).toBeInTheDocument();
});
it('should handle full resources with empty pending appointments', () => {
render(
<Sidebar
resourceLayouts={mockResourceLayouts}
pendingAppointments={[]}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
expect(screen.getByText('Dr. Smith')).toBeInTheDocument();
expect(screen.getByText('No pending requests')).toBeInTheDocument();
expect(screen.getByText(/Pending Requests \(0\)/)).toBeInTheDocument();
});
it('should maintain structure with resources and pending sections', () => {
const { container } = render(
<Sidebar
resourceLayouts={mockResourceLayouts}
pendingAppointments={mockPendingAppointments}
scrollRef={mockScrollRef}
/>,
{ wrapper: createDndWrapper() }
);
const sidebar = container.firstChild as HTMLElement;
// Should have header, resources list, and pending section
const sections = sidebar.querySelectorAll(
'.border-b, .border-t, .flex-col'
);
expect(sections.length).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,750 @@
/**
* Comprehensive unit tests for Timeline component
*
* Tests cover:
* - Component rendering
* - Time slots display for different view modes (day, week, month)
* - Resource rows display with proper heights
* - Events positioned correctly on timeline
* - Current time indicator visibility and position
* - Date navigation controls
* - View mode switching
* - Zoom functionality
* - Drag and drop interactions
* - Scroll synchronization between sidebar and timeline
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import Timeline from '../Timeline';
import * as apiClient from '../../../api/client';
// Mock modules
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string) => fallback || key,
}),
}));
vi.mock('../../../api/client', () => ({
default: {
get: vi.fn(),
},
}));
// Mock DnD Kit - simplified for testing
vi.mock('@dnd-kit/core', () => ({
DndContext: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
useSensor: vi.fn(),
useSensors: vi.fn(() => []),
PointerSensor: vi.fn(),
useDroppable: vi.fn(() => ({
setNodeRef: vi.fn(),
isOver: false,
})),
useDraggable: vi.fn(() => ({
attributes: {},
listeners: {},
setNodeRef: vi.fn(),
isDragging: false,
})),
DragOverlay: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
// Mock child components
vi.mock('../../Timeline/TimelineRow', () => ({
default: ({ resourceId, events, height }: any) => (
<div
data-testid={`timeline-row-${resourceId}`}
data-event-count={events.length}
style={{ height }}
>
{events.map((event: any) => (
<div key={event.id} data-testid={`event-${event.id}`}>
{event.title}
</div>
))}
</div>
),
}));
vi.mock('../../Timeline/CurrentTimeIndicator', () => ({
default: ({ startTime, hourWidth }: any) => (
<div
id="current-time-indicator"
data-testid="current-time-indicator"
data-start-time={startTime.toISOString()}
data-hour-width={hourWidth}
/>
),
}));
vi.mock('../Sidebar', () => ({
default: ({ resourceLayouts, pendingAppointments }: any) => (
<div data-testid="sidebar">
<div data-testid="resource-count">{resourceLayouts.length}</div>
<div data-testid="pending-count">{pendingAppointments.length}</div>
</div>
),
}));
// Test data
const mockResources = [
{ id: 1, name: 'Resource 1', type: 'STAFF' },
{ id: 2, name: 'Resource 2', type: 'ROOM' },
{ id: 3, name: 'Resource 3', type: 'EQUIPMENT' },
];
const mockAppointments = [
{
id: 1,
resource: 1,
customer: 101,
service: 201,
customer_name: 'John Doe',
service_name: 'Haircut',
start_time: new Date('2025-12-07T10:00:00').toISOString(),
end_time: new Date('2025-12-07T11:00:00').toISOString(),
status: 'CONFIRMED' as const,
is_paid: false,
},
{
id: 2,
resource: 1,
customer: 102,
service: 202,
customer_name: 'Jane Smith',
service_name: 'Coloring',
start_time: new Date('2025-12-07T11:30:00').toISOString(),
end_time: new Date('2025-12-07T13:00:00').toISOString(),
status: 'CONFIRMED' as const,
is_paid: true,
},
{
id: 3,
resource: undefined, // Pending appointment - no resource assigned
customer: 103,
service: 203,
customer_name: 'Bob Johnson',
service_name: 'Massage',
start_time: new Date('2025-12-07T14:00:00').toISOString(),
end_time: new Date('2025-12-07T15:00:00').toISOString(),
status: 'PENDING' as const,
is_paid: false,
},
];
// Test wrapper with Query Client
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('Timeline Component', () => {
let mockGet: any;
beforeEach(() => {
vi.clearAllMocks();
mockGet = vi.mocked(apiClient.default.get);
// Default API responses
mockGet.mockImplementation((url: string) => {
if (url === '/resources/') {
return Promise.resolve({ data: mockResources });
}
if (url === '/appointments/') {
return Promise.resolve({ data: mockAppointments });
}
return Promise.reject(new Error('Unknown endpoint'));
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('Component Rendering', () => {
it('should render the timeline component', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
});
});
it('should display header bar with controls', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByTitle('Previous')).toBeInTheDocument();
expect(screen.getByTitle('Next')).toBeInTheDocument();
expect(screen.getByText('+ New Appointment')).toBeInTheDocument();
});
});
it('should fetch resources from API', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(mockGet).toHaveBeenCalledWith('/resources/');
});
});
it('should fetch appointments from API', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(mockGet).toHaveBeenCalledWith('/appointments/');
});
});
});
describe('Time Slots Rendering', () => {
it('should render 24 hour slots in day view', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
// Check for some time labels
expect(screen.getByText('12 AM')).toBeInTheDocument();
expect(screen.getByText('6 AM')).toBeInTheDocument();
expect(screen.getByText('12 PM')).toBeInTheDocument();
expect(screen.getByText('6 PM')).toBeInTheDocument();
});
});
it('should render all 24 hours with correct spacing in day view', async () => {
const { container } = render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
const headerRow = container.querySelector('.sticky.top-0');
expect(headerRow).toBeInTheDocument();
// Should have 24 time slots
const timeSlots = headerRow?.querySelectorAll('[style*="width"]');
expect(timeSlots?.length).toBeGreaterThan(0);
});
});
it('should render day headers in week view', async () => {
const user = userEvent.setup();
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('day')).toBeInTheDocument();
});
const weekButton = screen.getByRole('button', { name: /week/i });
await user.click(weekButton);
await waitFor(() => {
// Week view should show day names
const container = screen.getByRole('button', { name: /week/i }).closest('div')?.parentElement?.parentElement?.parentElement;
expect(container).toBeInTheDocument();
});
});
it('should display date range label for current view', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
// Should show day view date format
const dateLabel = screen.getByText(/December/i);
expect(dateLabel).toBeInTheDocument();
});
});
});
describe('Resource Rows Display', () => {
it('should render resource rows for all resources', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByTestId('timeline-row-1')).toBeInTheDocument();
expect(screen.getByTestId('timeline-row-2')).toBeInTheDocument();
expect(screen.getByTestId('timeline-row-3')).toBeInTheDocument();
});
});
it('should display correct number of resources in sidebar', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
const resourceCount = screen.getByTestId('resource-count');
expect(resourceCount).toHaveTextContent('3');
});
});
it('should calculate row heights based on event lanes', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
const row1 = screen.getByTestId('timeline-row-1');
// Row 1 has 2 events, should have calculated height
expect(row1).toHaveAttribute('style');
});
});
it('should handle resources with no events', async () => {
mockGet.mockImplementation((url: string) => {
if (url === '/resources/') {
return Promise.resolve({ data: mockResources });
}
if (url === '/appointments/') {
return Promise.resolve({ data: [] });
}
return Promise.reject(new Error('Unknown endpoint'));
});
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByTestId('timeline-row-1')).toBeInTheDocument();
expect(screen.getByTestId('timeline-row-1')).toHaveAttribute('data-event-count', '0');
});
});
});
describe('Events Positioning', () => {
it('should render events on their assigned resources', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
const row1 = screen.getByTestId('timeline-row-1');
expect(row1).toHaveAttribute('data-event-count', '2');
});
});
it('should display event titles correctly', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
});
});
it('should filter events by resource', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
const row1 = screen.getByTestId('timeline-row-1');
const row2 = screen.getByTestId('timeline-row-2');
expect(row1).toHaveAttribute('data-event-count', '2');
expect(row2).toHaveAttribute('data-event-count', '0');
});
});
it('should handle overlapping events with lane calculation', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
// Both events are on resource 1, should be in timeline
expect(screen.getByTestId('event-1')).toBeInTheDocument();
expect(screen.getByTestId('event-2')).toBeInTheDocument();
});
});
});
describe('Current Time Indicator', () => {
it('should render current time indicator', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByTestId('current-time-indicator')).toBeInTheDocument();
});
});
it('should pass correct props to current time indicator', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
const indicator = screen.getByTestId('current-time-indicator');
expect(indicator).toHaveAttribute('data-start-time');
expect(indicator).toHaveAttribute('data-hour-width');
});
});
it('should have correct id for auto-scroll', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
const indicator = screen.getByTestId('current-time-indicator');
expect(indicator).toHaveAttribute('id', 'current-time-indicator');
});
});
});
describe('Date Navigation', () => {
it('should have previous and next navigation buttons', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByTitle('Previous')).toBeInTheDocument();
expect(screen.getByTitle('Next')).toBeInTheDocument();
});
});
it('should navigate to previous day when clicking previous button', async () => {
const user = userEvent.setup();
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByTitle('Previous')).toBeInTheDocument();
});
const previousButton = screen.getByTitle('Previous');
await user.click(previousButton);
// Date should change (we can't easily test exact date without exposing state)
expect(previousButton).toBeInTheDocument();
});
it('should navigate to next day when clicking next button', async () => {
const user = userEvent.setup();
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByTitle('Next')).toBeInTheDocument();
});
const nextButton = screen.getByTitle('Next');
await user.click(nextButton);
expect(nextButton).toBeInTheDocument();
});
it('should display current date range', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
// Should show a date with calendar icon
const dateDisplay = screen.getByText(/2025/);
expect(dateDisplay).toBeInTheDocument();
});
});
});
describe('View Mode Switching', () => {
it('should render view mode buttons (day, week, month)', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByRole('button', { name: /day/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /week/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /month/i })).toBeInTheDocument();
});
});
it('should highlight active view mode (day by default)', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
const dayButton = screen.getByRole('button', { name: /day/i });
expect(dayButton).toHaveClass('bg-blue-500');
});
});
it('should switch to week view when clicking week button', async () => {
const user = userEvent.setup();
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByRole('button', { name: /week/i })).toBeInTheDocument();
});
const weekButton = screen.getByRole('button', { name: /week/i });
await user.click(weekButton);
await waitFor(() => {
expect(weekButton).toHaveClass('bg-blue-500');
});
});
it('should switch to month view when clicking month button', async () => {
const user = userEvent.setup();
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByRole('button', { name: /month/i })).toBeInTheDocument();
});
const monthButton = screen.getByRole('button', { name: /month/i });
await user.click(monthButton);
await waitFor(() => {
expect(monthButton).toHaveClass('bg-blue-500');
});
});
it('should only have one active view mode at a time', async () => {
const user = userEvent.setup();
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByRole('button', { name: /week/i })).toBeInTheDocument();
});
const weekButton = screen.getByRole('button', { name: /week/i });
await user.click(weekButton);
await waitFor(() => {
const dayButton = screen.getByRole('button', { name: /day/i });
expect(weekButton).toHaveClass('bg-blue-500');
expect(dayButton).not.toHaveClass('bg-blue-500');
});
});
});
describe('Zoom Functionality', () => {
it('should render zoom in and zoom out buttons', async () => {
const { container } = render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
// Look for Zoom label and buttons
expect(screen.getByText('Zoom')).toBeInTheDocument();
});
// Zoom buttons are rendered via Lucide icons
const zoomSection = screen.getByText('Zoom').parentElement;
expect(zoomSection).toBeInTheDocument();
});
it('should increase zoom when clicking zoom in button', async () => {
const user = userEvent.setup();
const { container } = render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('Zoom')).toBeInTheDocument();
});
// Find zoom in button (second button after Zoom label)
const zoomSection = screen.getByText('Zoom').parentElement;
const buttons = zoomSection?.querySelectorAll('button');
const zoomInButton = buttons?.[1];
if (zoomInButton) {
await user.click(zoomInButton);
// Component should still be rendered
expect(screen.getByText('Zoom')).toBeInTheDocument();
}
});
it('should decrease zoom when clicking zoom out button', async () => {
const user = userEvent.setup();
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('Zoom')).toBeInTheDocument();
});
const zoomSection = screen.getByText('Zoom').parentElement;
const buttons = zoomSection?.querySelectorAll('button');
const zoomOutButton = buttons?.[0];
if (zoomOutButton) {
await user.click(zoomOutButton);
expect(screen.getByText('Zoom')).toBeInTheDocument();
}
});
});
describe('Pending Appointments', () => {
it('should display pending appointments in sidebar', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
const pendingCount = screen.getByTestId('pending-count');
expect(pendingCount).toHaveTextContent('1');
});
});
it('should filter pending appointments from events', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
// Should not render pending appointment as event
expect(screen.queryByText('Bob Johnson')).not.toBeInTheDocument();
});
});
});
describe('Accessibility', () => {
it('should have accessible button labels', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByRole('button', { name: /day/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /week/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /month/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /new appointment/i })).toBeInTheDocument();
});
});
it('should have title attributes on navigation buttons', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByTitle('Previous')).toBeInTheDocument();
expect(screen.getByTitle('Next')).toBeInTheDocument();
});
});
});
describe('Undo/Redo Controls', () => {
it('should render undo and redo buttons', async () => {
const { container } = render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
// Undo/redo buttons exist but are disabled
const buttons = container.querySelectorAll('button[disabled]');
expect(buttons.length).toBeGreaterThan(0);
});
});
it('should have undo and redo buttons disabled by default', async () => {
const { container } = render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
const disabledButtons = container.querySelectorAll('button[disabled]');
expect(disabledButtons.length).toBeGreaterThanOrEqual(2);
});
});
});
describe('Error Handling', () => {
it('should handle API errors gracefully for resources', async () => {
mockGet.mockImplementation((url: string) => {
if (url === '/resources/') {
return Promise.reject(new Error('Network error'));
}
if (url === '/appointments/') {
return Promise.resolve({ data: [] });
}
return Promise.reject(new Error('Unknown endpoint'));
});
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
// Should still render even with error
expect(screen.getByText('+ New Appointment')).toBeInTheDocument();
});
});
it('should handle API errors gracefully for appointments', async () => {
mockGet.mockImplementation((url: string) => {
if (url === '/resources/') {
return Promise.resolve({ data: mockResources });
}
if (url === '/appointments/') {
return Promise.reject(new Error('Network error'));
}
return Promise.reject(new Error('Unknown endpoint'));
});
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('+ New Appointment')).toBeInTheDocument();
});
});
it('should handle empty resources array', async () => {
mockGet.mockImplementation((url: string) => {
if (url === '/resources/') {
return Promise.resolve({ data: [] });
}
if (url === '/appointments/') {
return Promise.resolve({ data: [] });
}
return Promise.reject(new Error('Unknown endpoint'));
});
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
const resourceCount = screen.getByTestId('resource-count');
expect(resourceCount).toHaveTextContent('0');
});
});
it('should handle empty appointments array', async () => {
mockGet.mockImplementation((url: string) => {
if (url === '/resources/') {
return Promise.resolve({ data: mockResources });
}
if (url === '/appointments/') {
return Promise.resolve({ data: [] });
}
return Promise.reject(new Error('Unknown endpoint'));
});
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
const pendingCount = screen.getByTestId('pending-count');
expect(pendingCount).toHaveTextContent('0');
});
});
});
describe('Dark Mode Support', () => {
it('should apply dark mode classes', async () => {
const { container } = render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
const mainContainer = container.querySelector('.bg-white');
expect(mainContainer).toHaveClass('dark:bg-gray-900');
});
});
it('should apply dark mode to header', async () => {
const { container } = render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
const header = container.querySelector('.border-b');
expect(header).toHaveClass('dark:bg-gray-800');
});
});
});
describe('Integration', () => {
it('should render complete timeline with all features', async () => {
render(<Timeline />, { wrapper: createWrapper() });
await waitFor(() => {
// Header controls
expect(screen.getByTitle('Previous')).toBeInTheDocument();
expect(screen.getByTitle('Next')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /day/i })).toBeInTheDocument();
expect(screen.getByText('Zoom')).toBeInTheDocument();
expect(screen.getByText('+ New Appointment')).toBeInTheDocument();
// Sidebar
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
// Current time indicator
expect(screen.getByTestId('current-time-indicator')).toBeInTheDocument();
// Resources
expect(screen.getByTestId('resource-count')).toHaveTextContent('3');
// Events
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
});
});
});

View File

@@ -17,11 +17,14 @@ import {
Plug,
FileSignature,
CalendarOff,
LayoutTemplate,
MapPin,
} from 'lucide-react';
import { Business, User } from '../types';
import { useLogout } from '../hooks/useAuth';
import { usePlanFeatures } from '../hooks/usePlanFeatures';
import SmoothScheduleLogo from './SmoothScheduleLogo';
import UnfinishedBadge from './ui/UnfinishedBadge';
import {
SidebarSection,
SidebarItem,
@@ -46,6 +49,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
const isStaff = role === 'staff';
const canViewSettings = role === 'owner';
const canViewTickets = role === 'owner' || role === 'manager' || (role === 'staff' && user.can_access_tickets);
const canSendMessages = user.can_send_messages === true;
const handleSignOut = () => {
logoutMutation.mutate();
@@ -105,7 +109,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
{/* Core Features - Always visible */}
<SidebarSection isCollapsed={isCollapsed}>
<SidebarItem
to="/"
to="/dashboard"
icon={LayoutDashboard}
label={t('nav.dashboard')}
isCollapsed={isCollapsed}
@@ -113,7 +117,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
/>
{!isStaff && (
<SidebarItem
to="/scheduler"
to="/dashboard/scheduler"
icon={CalendarDays}
label={t('nav.scheduler')}
isCollapsed={isCollapsed}
@@ -121,16 +125,17 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
)}
{!isStaff && (
<SidebarItem
to="/tasks"
to="/dashboard/tasks"
icon={Clock}
label={t('nav.tasks', 'Tasks')}
isCollapsed={isCollapsed}
locked={!canUse('plugins') || !canUse('tasks')}
badgeElement={<UnfinishedBadge />}
/>
)}
{isStaff && (
<SidebarItem
to="/my-schedule"
to="/dashboard/my-schedule"
icon={CalendarDays}
label={t('nav.mySchedule', 'My Schedule')}
isCollapsed={isCollapsed}
@@ -138,7 +143,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
)}
{(role === 'staff' || role === 'resource') && (
<SidebarItem
to="/my-availability"
to="/dashboard/my-availability"
icon={CalendarOff}
label={t('nav.myAvailability', 'My Availability')}
isCollapsed={isCollapsed}
@@ -150,19 +155,27 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
{canViewManagementPages && (
<SidebarSection title={t('nav.sections.manage', 'Manage')} isCollapsed={isCollapsed}>
<SidebarItem
to="/customers"
to="/dashboard/site-editor"
icon={LayoutTemplate}
label={t('nav.siteBuilder', 'Site Builder')}
isCollapsed={isCollapsed}
badgeElement={<UnfinishedBadge />}
/>
<SidebarItem
to="/dashboard/customers"
icon={Users}
label={t('nav.customers')}
isCollapsed={isCollapsed}
badgeElement={<UnfinishedBadge />}
/>
<SidebarItem
to="/services"
to="/dashboard/services"
icon={Briefcase}
label={t('nav.services', 'Services')}
isCollapsed={isCollapsed}
/>
<SidebarItem
to="/resources"
to="/dashboard/resources"
icon={ClipboardList}
label={t('nav.resources')}
isCollapsed={isCollapsed}
@@ -170,36 +183,45 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
{canViewAdminPages && (
<>
<SidebarItem
to="/staff"
to="/dashboard/staff"
icon={Users}
label={t('nav.staff')}
isCollapsed={isCollapsed}
badgeElement={<UnfinishedBadge />}
/>
{canUse('contracts') && (
<SidebarItem
to="/contracts"
to="/dashboard/contracts"
icon={FileSignature}
label={t('nav.contracts', 'Contracts')}
isCollapsed={isCollapsed}
badgeElement={<UnfinishedBadge />}
/>
)}
<SidebarItem
to="/time-blocks"
to="/dashboard/time-blocks"
icon={CalendarOff}
label={t('nav.timeBlocks', 'Time Blocks')}
isCollapsed={isCollapsed}
/>
<SidebarItem
to="/dashboard/locations"
icon={MapPin}
label={t('nav.locations', 'Locations')}
isCollapsed={isCollapsed}
locked={!canUse('multi_location')}
/>
</>
)}
</SidebarSection>
)}
{/* Communicate Section - Tickets + Messages */}
{(canViewTickets || canViewAdminPages) && (
{(canViewTickets || canSendMessages) && (
<SidebarSection title={t('nav.sections.communicate', 'Communicate')} isCollapsed={isCollapsed}>
{canViewAdminPages && (
{canSendMessages && (
<SidebarItem
to="/messages"
to="/dashboard/messages"
icon={MessageSquare}
label={t('nav.messages')}
isCollapsed={isCollapsed}
@@ -207,7 +229,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
)}
{canViewTickets && (
<SidebarItem
to="/tickets"
to="/dashboard/tickets"
icon={Ticket}
label={t('nav.tickets')}
isCollapsed={isCollapsed}
@@ -220,7 +242,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
{canViewAdminPages && (
<SidebarSection title={t('nav.sections.money', 'Money')} isCollapsed={isCollapsed}>
<SidebarItem
to="/payments"
to="/dashboard/payments"
icon={CreditCard}
label={t('nav.payments')}
isCollapsed={isCollapsed}
@@ -233,11 +255,12 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
{canViewAdminPages && (
<SidebarSection title={t('nav.sections.extend', 'Extend')} isCollapsed={isCollapsed}>
<SidebarItem
to="/plugins/my-plugins"
to="/dashboard/plugins/my-plugins"
icon={Plug}
label={t('nav.plugins', 'Plugins')}
isCollapsed={isCollapsed}
locked={!canUse('plugins')}
badgeElement={<UnfinishedBadge />}
/>
</SidebarSection>
)}
@@ -248,14 +271,14 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
<SidebarSection isCollapsed={isCollapsed}>
{canViewSettings && (
<SidebarItem
to="/settings"
to="/dashboard/settings"
icon={Settings}
label={t('nav.businessSettings')}
isCollapsed={isCollapsed}
/>
)}
<SidebarItem
to="/help"
to="/dashboard/help"
icon={HelpCircle}
label={t('nav.helpDocs', 'Help & Docs')}
isCollapsed={isCollapsed}

View File

@@ -68,6 +68,15 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
defaultValue: false,
roles: ['manager'],
},
{
key: 'can_send_messages',
labelKey: 'staff.canSendMessages',
labelDefault: 'Can send broadcast messages',
hintKey: 'staff.canSendMessagesHint',
hintDefault: 'Send messages to groups of staff and customers',
defaultValue: true,
roles: ['manager'],
},
// Staff-only permissions
{
key: 'can_view_all_schedules',

View File

@@ -28,7 +28,7 @@ const TrialBanner: React.FC<TrialBannerProps> = ({ business }) => {
const trialEndDate = business.trialEnd ? new Date(business.trialEnd).toLocaleDateString() : '';
const handleUpgrade = () => {
navigate('/upgrade');
navigate('/dashboard/upgrade');
};
const handleDismiss = () => {

View File

@@ -51,7 +51,7 @@ const BannerPrompt: React.FC<{ feature: FeatureKey; showDescription: boolean }>
</p>
)}
<Link
to="/settings/billing"
to="/dashboard/settings/billing"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-amber-500 to-orange-500 text-white font-medium hover:from-amber-600 hover:to-orange-600 transition-all shadow-md hover:shadow-lg"
>
<Crown className="w-4 h-4" />
@@ -97,7 +97,7 @@ const OverlayPrompt: React.FC<{
{FEATURE_DESCRIPTIONS[feature]}
</p>
<Link
to="/settings/billing"
to="/dashboard/settings/billing"
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg bg-gradient-to-r from-amber-500 to-orange-500 text-white font-medium hover:from-amber-600 hover:to-orange-600 transition-all shadow-md hover:shadow-lg"
>
<Crown className="w-5 h-5" />

View File

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

View File

@@ -0,0 +1,114 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import ConfirmationModal from '../ConfirmationModal';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
describe('ConfirmationModal', () => {
const defaultProps = {
isOpen: true,
onClose: vi.fn(),
onConfirm: vi.fn(),
title: 'Test Title',
message: 'Test message',
};
beforeEach(() => {
vi.clearAllMocks();
});
it('returns null when not open', () => {
const { container } = render(
<ConfirmationModal {...defaultProps} isOpen={false} />
);
expect(container.firstChild).toBeNull();
});
it('renders title when open', () => {
render(<ConfirmationModal {...defaultProps} />);
expect(screen.getByText('Test Title')).toBeInTheDocument();
});
it('renders message when open', () => {
render(<ConfirmationModal {...defaultProps} />);
expect(screen.getByText('Test message')).toBeInTheDocument();
});
it('renders message as ReactNode', () => {
render(
<ConfirmationModal
{...defaultProps}
message={<span data-testid="custom-message">Custom content</span>}
/>
);
expect(screen.getByTestId('custom-message')).toBeInTheDocument();
});
it('calls onClose when close button is clicked', () => {
render(<ConfirmationModal {...defaultProps} />);
const buttons = screen.getAllByRole('button');
fireEvent.click(buttons[0]);
expect(defaultProps.onClose).toHaveBeenCalled();
});
it('calls onClose when cancel button is clicked', () => {
render(<ConfirmationModal {...defaultProps} />);
fireEvent.click(screen.getByText('common.cancel'));
expect(defaultProps.onClose).toHaveBeenCalled();
});
it('calls onConfirm when confirm button is clicked', () => {
render(<ConfirmationModal {...defaultProps} />);
fireEvent.click(screen.getByText('common.confirm'));
expect(defaultProps.onConfirm).toHaveBeenCalled();
});
it('uses custom confirm text', () => {
render(<ConfirmationModal {...defaultProps} confirmText="Yes, delete" />);
expect(screen.getByText('Yes, delete')).toBeInTheDocument();
});
it('uses custom cancel text', () => {
render(<ConfirmationModal {...defaultProps} cancelText="No, keep" />);
expect(screen.getByText('No, keep')).toBeInTheDocument();
});
it('renders info variant', () => {
render(<ConfirmationModal {...defaultProps} variant="info" />);
expect(screen.getByText('Test Title')).toBeInTheDocument();
});
it('renders warning variant', () => {
render(<ConfirmationModal {...defaultProps} variant="warning" />);
expect(screen.getByText('Test Title')).toBeInTheDocument();
});
it('renders danger variant', () => {
render(<ConfirmationModal {...defaultProps} variant="danger" />);
expect(screen.getByText('Test Title')).toBeInTheDocument();
});
it('renders success variant', () => {
render(<ConfirmationModal {...defaultProps} variant="success" />);
expect(screen.getByText('Test Title')).toBeInTheDocument();
});
it('disables buttons when loading', () => {
render(<ConfirmationModal {...defaultProps} isLoading={true} />);
const buttons = screen.getAllByRole('button');
buttons.forEach((button) => {
expect(button).toBeDisabled();
});
});
it('shows spinner when loading', () => {
render(<ConfirmationModal {...defaultProps} isLoading={true} />);
const spinner = document.querySelector('.animate-spin');
expect(spinner).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,83 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import EmailTemplateSelector from '../EmailTemplateSelector';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, defaultValue?: string) => defaultValue || key,
}),
}));
// Mock API client
vi.mock('../../api/client', () => ({
default: {
get: vi.fn(() => Promise.resolve({ data: [] })),
},
}));
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('EmailTemplateSelector', () => {
it('renders select element', () => {
render(
<EmailTemplateSelector value={undefined} onChange={() => {}} />,
{ wrapper: createWrapper() }
);
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
it('shows placeholder text after loading', async () => {
render(
<EmailTemplateSelector
value={undefined}
onChange={() => {}}
placeholder="Select a template"
/>,
{ wrapper: createWrapper() }
);
// Wait for loading to finish and placeholder to appear
await screen.findByText('Select a template');
});
it('is disabled when disabled prop is true', () => {
render(
<EmailTemplateSelector value={undefined} onChange={() => {}} disabled />,
{ wrapper: createWrapper() }
);
expect(screen.getByRole('combobox')).toBeDisabled();
});
it('applies custom className', () => {
const { container } = render(
<EmailTemplateSelector
value={undefined}
onChange={() => {}}
className="custom-class"
/>,
{ wrapper: createWrapper() }
);
expect(container.firstChild).toHaveClass('custom-class');
});
it('shows empty state message when no templates', async () => {
render(
<EmailTemplateSelector value={undefined} onChange={() => {}} />,
{ wrapper: createWrapper() }
);
// Wait for loading to finish
await screen.findByText('No email templates yet.');
});
});

View File

@@ -0,0 +1,270 @@
/**
* Tests for FeatureGate component
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import React from 'react';
import { FeatureGate, LimitGate } from '../FeatureGate';
import * as useEntitlementsModule from '../../hooks/useEntitlements';
// Mock the useEntitlements hook
vi.mock('../../hooks/useEntitlements', () => ({
useEntitlements: vi.fn(),
}));
describe('FeatureGate', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders children when feature is enabled', () => {
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
entitlements: { can_use_sms_reminders: true },
isLoading: false,
hasFeature: (key: string) => key === 'can_use_sms_reminders',
getLimit: () => null,
refetch: vi.fn(),
});
render(
<FeatureGate feature="can_use_sms_reminders">
<div>SMS Feature Content</div>
</FeatureGate>
);
expect(screen.getByText('SMS Feature Content')).toBeInTheDocument();
});
it('does not render children when feature is disabled', () => {
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
entitlements: { can_use_sms_reminders: false },
isLoading: false,
hasFeature: () => false,
getLimit: () => null,
refetch: vi.fn(),
});
render(
<FeatureGate feature="can_use_sms_reminders">
<div>SMS Feature Content</div>
</FeatureGate>
);
expect(screen.queryByText('SMS Feature Content')).not.toBeInTheDocument();
});
it('renders fallback when feature is disabled', () => {
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
entitlements: { can_use_sms_reminders: false },
isLoading: false,
hasFeature: () => false,
getLimit: () => null,
refetch: vi.fn(),
});
render(
<FeatureGate
feature="can_use_sms_reminders"
fallback={<div>Upgrade to access SMS</div>}
>
<div>SMS Feature Content</div>
</FeatureGate>
);
expect(screen.queryByText('SMS Feature Content')).not.toBeInTheDocument();
expect(screen.getByText('Upgrade to access SMS')).toBeInTheDocument();
});
it('renders nothing while loading', () => {
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
entitlements: {},
isLoading: true,
hasFeature: () => false,
getLimit: () => null,
refetch: vi.fn(),
});
render(
<FeatureGate feature="can_use_sms_reminders">
<div>SMS Feature Content</div>
</FeatureGate>
);
expect(screen.queryByText('SMS Feature Content')).not.toBeInTheDocument();
});
it('renders loading component when provided and loading', () => {
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
entitlements: {},
isLoading: true,
hasFeature: () => false,
getLimit: () => null,
refetch: vi.fn(),
});
render(
<FeatureGate
feature="can_use_sms_reminders"
loadingFallback={<div>Loading...</div>}
>
<div>SMS Feature Content</div>
</FeatureGate>
);
expect(screen.getByText('Loading...')).toBeInTheDocument();
expect(screen.queryByText('SMS Feature Content')).not.toBeInTheDocument();
});
it('checks multiple features with requireAll=true', () => {
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
entitlements: {
can_use_sms_reminders: true,
can_use_mobile_app: false,
},
isLoading: false,
hasFeature: (key: string) => key === 'can_use_sms_reminders',
getLimit: () => null,
refetch: vi.fn(),
});
render(
<FeatureGate
features={['can_use_sms_reminders', 'can_use_mobile_app']}
requireAll={true}
>
<div>Multi Feature Content</div>
</FeatureGate>
);
// Should not render because mobile_app is disabled
expect(screen.queryByText('Multi Feature Content')).not.toBeInTheDocument();
});
it('checks multiple features with requireAll=false (any)', () => {
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
entitlements: {
can_use_sms_reminders: true,
can_use_mobile_app: false,
},
isLoading: false,
hasFeature: (key: string) => key === 'can_use_sms_reminders',
getLimit: () => null,
refetch: vi.fn(),
});
render(
<FeatureGate
features={['can_use_sms_reminders', 'can_use_mobile_app']}
requireAll={false}
>
<div>Multi Feature Content</div>
</FeatureGate>
);
// Should render because at least one (sms) is enabled
expect(screen.getByText('Multi Feature Content')).toBeInTheDocument();
});
});
describe('LimitGate', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders children when under limit', () => {
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
entitlements: { max_users: 10 },
isLoading: false,
hasFeature: () => false,
getLimit: (key: string) => (key === 'max_users' ? 10 : null),
refetch: vi.fn(),
});
render(
<LimitGate limit="max_users" currentUsage={5}>
<div>Under Limit Content</div>
</LimitGate>
);
expect(screen.getByText('Under Limit Content')).toBeInTheDocument();
});
it('does not render children when at limit', () => {
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
entitlements: { max_users: 10 },
isLoading: false,
hasFeature: () => false,
getLimit: (key: string) => (key === 'max_users' ? 10 : null),
refetch: vi.fn(),
});
render(
<LimitGate limit="max_users" currentUsage={10}>
<div>Under Limit Content</div>
</LimitGate>
);
expect(screen.queryByText('Under Limit Content')).not.toBeInTheDocument();
});
it('does not render children when over limit', () => {
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
entitlements: { max_users: 10 },
isLoading: false,
hasFeature: () => false,
getLimit: (key: string) => (key === 'max_users' ? 10 : null),
refetch: vi.fn(),
});
render(
<LimitGate limit="max_users" currentUsage={15}>
<div>Under Limit Content</div>
</LimitGate>
);
expect(screen.queryByText('Under Limit Content')).not.toBeInTheDocument();
});
it('renders fallback when over limit', () => {
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
entitlements: { max_users: 10 },
isLoading: false,
hasFeature: () => false,
getLimit: (key: string) => (key === 'max_users' ? 10 : null),
refetch: vi.fn(),
});
render(
<LimitGate
limit="max_users"
currentUsage={15}
fallback={<div>Upgrade for more users</div>}
>
<div>Under Limit Content</div>
</LimitGate>
);
expect(screen.queryByText('Under Limit Content')).not.toBeInTheDocument();
expect(screen.getByText('Upgrade for more users')).toBeInTheDocument();
});
it('renders children when limit is null (unlimited)', () => {
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
entitlements: {},
isLoading: false,
hasFeature: () => false,
getLimit: () => null,
refetch: vi.fn(),
});
render(
<LimitGate limit="max_users" currentUsage={1000}>
<div>Unlimited Content</div>
</LimitGate>
);
// When limit is null, treat as unlimited
expect(screen.getByText('Unlimited Content')).toBeInTheDocument();
});
});

View File

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

View File

@@ -0,0 +1,57 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import HelpButton from '../HelpButton';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, defaultValue?: string) => defaultValue || key,
}),
}));
describe('HelpButton', () => {
const renderHelpButton = (props: { helpPath: string; className?: string }) => {
return render(
<BrowserRouter>
<HelpButton {...props} />
</BrowserRouter>
);
};
it('renders help link', () => {
renderHelpButton({ helpPath: '/help/dashboard' });
const link = screen.getByRole('link');
expect(link).toBeInTheDocument();
});
it('has correct href', () => {
renderHelpButton({ helpPath: '/help/dashboard' });
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/help/dashboard');
});
it('renders help text', () => {
renderHelpButton({ helpPath: '/help/test' });
expect(screen.getByText('Help')).toBeInTheDocument();
});
it('has title attribute', () => {
renderHelpButton({ helpPath: '/help/test' });
const link = screen.getByRole('link');
expect(link).toHaveAttribute('title', 'Help');
});
it('applies custom className', () => {
renderHelpButton({ helpPath: '/help/test', className: 'custom-class' });
const link = screen.getByRole('link');
expect(link).toHaveClass('custom-class');
});
it('has default styles', () => {
renderHelpButton({ helpPath: '/help/test' });
const link = screen.getByRole('link');
expect(link).toHaveClass('inline-flex');
expect(link).toHaveClass('items-center');
});
});

View File

@@ -0,0 +1,93 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import LanguageSelector from '../LanguageSelector';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
i18n: {
language: 'en',
changeLanguage: vi.fn(),
},
}),
}));
// Mock i18n module
vi.mock('../../i18n', () => ({
supportedLanguages: [
{ code: 'en', name: 'English', flag: '🇺🇸' },
{ code: 'es', name: 'Español', flag: '🇪🇸' },
{ code: 'fr', name: 'Français', flag: '🇫🇷' },
],
}));
describe('LanguageSelector', () => {
describe('dropdown variant', () => {
it('renders dropdown button', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
});
it('shows current language flag by default', () => {
render(<LanguageSelector />);
expect(screen.getByText('🇺🇸')).toBeInTheDocument();
});
it('shows current language name on larger screens', () => {
render(<LanguageSelector />);
expect(screen.getByText('English')).toBeInTheDocument();
});
it('opens dropdown on click', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
it('shows all languages when open', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(screen.getByText('Español')).toBeInTheDocument();
expect(screen.getByText('Français')).toBeInTheDocument();
});
it('hides flag when showFlag is false', () => {
render(<LanguageSelector showFlag={false} />);
expect(screen.queryByText('🇺🇸')).not.toBeInTheDocument();
});
it('applies custom className', () => {
const { container } = render(<LanguageSelector className="custom-class" />);
expect(container.firstChild).toHaveClass('custom-class');
});
});
describe('inline variant', () => {
it('renders all language buttons', () => {
render(<LanguageSelector variant="inline" />);
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBe(3);
});
it('renders language names', () => {
render(<LanguageSelector variant="inline" />);
expect(screen.getByText(/English/)).toBeInTheDocument();
expect(screen.getByText(/Español/)).toBeInTheDocument();
expect(screen.getByText(/Français/)).toBeInTheDocument();
});
it('highlights current language', () => {
render(<LanguageSelector variant="inline" />);
const englishButton = screen.getByText(/English/).closest('button');
expect(englishButton).toHaveClass('bg-brand-600');
});
it('shows flags by default', () => {
render(<LanguageSelector variant="inline" />);
expect(screen.getByText(/🇺🇸/)).toBeInTheDocument();
});
});
});

View File

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

View File

@@ -0,0 +1,68 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import MasqueradeBanner from '../MasqueradeBanner';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { name?: string }) => {
if (options?.name) return `${key} ${options.name}`;
return key;
},
}),
}));
describe('MasqueradeBanner', () => {
const defaultProps = {
effectiveUser: { id: '1', name: 'John Doe', email: 'john@test.com', role: 'staff' as const },
originalUser: { id: '2', name: 'Admin User', email: 'admin@test.com', role: 'superuser' as const },
previousUser: null,
onStop: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
it('renders effective user name', () => {
render(<MasqueradeBanner {...defaultProps} />);
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
it('renders effective user role', () => {
render(<MasqueradeBanner {...defaultProps} />);
// The role is split across elements: "(" + "staff" + ")"
expect(screen.getByText(/staff/)).toBeInTheDocument();
});
it('renders original user info', () => {
render(<MasqueradeBanner {...defaultProps} />);
expect(screen.getByText(/Admin User/)).toBeInTheDocument();
});
it('calls onStop when button is clicked', () => {
render(<MasqueradeBanner {...defaultProps} />);
const stopButton = screen.getByRole('button');
fireEvent.click(stopButton);
expect(defaultProps.onStop).toHaveBeenCalled();
});
it('shows return to previous user text when previousUser exists', () => {
const propsWithPrevious = {
...defaultProps,
previousUser: { id: '3', name: 'Manager', email: 'manager@test.com', role: 'manager' as const },
};
render(<MasqueradeBanner {...propsWithPrevious} />);
expect(screen.getByText(/platform.masquerade.returnTo/)).toBeInTheDocument();
});
it('shows stop masquerading text when no previousUser', () => {
render(<MasqueradeBanner {...defaultProps} />);
expect(screen.getByText('platform.masquerade.stopMasquerading')).toBeInTheDocument();
});
it('renders with masquerading label', () => {
render(<MasqueradeBanner {...defaultProps} />);
expect(screen.getByText(/platform.masquerade.masqueradingAs/)).toBeInTheDocument();
});
});

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,714 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BrowserRouter, MemoryRouter } from 'react-router-dom';
import PlatformSidebar from '../PlatformSidebar';
import { User } from '../../types';
// Mock the i18next module
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string) => {
const translations: Record<string, string> = {
'nav.platformDashboard': 'Platform Dashboard',
'nav.dashboard': 'Dashboard',
'nav.businesses': 'Businesses',
'nav.users': 'Users',
'nav.support': 'Support',
'nav.staff': 'Staff',
'nav.platformSettings': 'Platform Settings',
'nav.help': 'Help',
'nav.apiDocs': 'API Docs',
};
return translations[key] || fallback || key;
},
}),
}));
// Mock the SmoothScheduleLogo component
vi.mock('../SmoothScheduleLogo', () => ({
default: ({ className }: { className?: string }) => (
<div data-testid="smooth-schedule-logo" className={className}>Logo</div>
),
}));
describe('PlatformSidebar', () => {
const mockSuperuser: User = {
id: '1',
name: 'Super User',
email: 'super@example.com',
role: 'superuser',
};
const mockPlatformManager: User = {
id: '2',
name: 'Platform Manager',
email: 'manager@example.com',
role: 'platform_manager',
};
const mockPlatformSupport: User = {
id: '3',
name: 'Platform Support',
email: 'support@example.com',
role: 'platform_support',
};
const mockToggleCollapse = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('renders the sidebar with logo and user role', () => {
render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
expect(screen.getByTestId('smooth-schedule-logo')).toBeInTheDocument();
expect(screen.getByText('Smooth Schedule')).toBeInTheDocument();
expect(screen.getByText('superuser')).toBeInTheDocument();
});
it('renders all navigation links for superuser', () => {
render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
// Operations section
expect(screen.getByText('Operations')).toBeInTheDocument();
expect(screen.getByText('Dashboard')).toBeInTheDocument();
expect(screen.getByText('Businesses')).toBeInTheDocument();
expect(screen.getByText('Users')).toBeInTheDocument();
expect(screen.getByText('Support')).toBeInTheDocument();
expect(screen.getAllByText('Email Addresses')[0]).toBeInTheDocument();
// System section (superuser only)
expect(screen.getByText('System')).toBeInTheDocument();
expect(screen.getByText('Staff')).toBeInTheDocument();
expect(screen.getByText('Platform Settings')).toBeInTheDocument();
// Help section
expect(screen.getByText('Help')).toBeInTheDocument();
expect(screen.getAllByText('Email Settings')[0]).toBeInTheDocument();
expect(screen.getByText('API Docs')).toBeInTheDocument();
});
it('hides system section for platform manager', () => {
render(
<BrowserRouter>
<PlatformSidebar
user={mockPlatformManager}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
// Operations section visible
expect(screen.getByText('Dashboard')).toBeInTheDocument();
expect(screen.getByText('Businesses')).toBeInTheDocument();
// System section not visible
expect(screen.queryByText('System')).not.toBeInTheDocument();
expect(screen.queryByText('Staff')).not.toBeInTheDocument();
expect(screen.queryByText('Platform Settings')).not.toBeInTheDocument();
});
it('hides system section and dashboard for platform support', () => {
render(
<BrowserRouter>
<PlatformSidebar
user={mockPlatformSupport}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
// Dashboard not visible for support
expect(screen.queryByText('Dashboard')).not.toBeInTheDocument();
// Operations section visible
expect(screen.getByText('Businesses')).toBeInTheDocument();
expect(screen.getByText('Users')).toBeInTheDocument();
// System section not visible
expect(screen.queryByText('System')).not.toBeInTheDocument();
expect(screen.queryByText('Staff')).not.toBeInTheDocument();
});
it('displays role with underscores replaced by spaces', () => {
render(
<BrowserRouter>
<PlatformSidebar
user={mockPlatformManager}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
expect(screen.getByText('platform manager')).toBeInTheDocument();
});
});
describe('Collapsed State', () => {
it('hides text labels when collapsed', () => {
render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={true}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
// Logo should be visible
expect(screen.getByTestId('smooth-schedule-logo')).toBeInTheDocument();
// Text should be hidden
expect(screen.queryByText('Smooth Schedule')).not.toBeInTheDocument();
expect(screen.queryByText('superuser')).not.toBeInTheDocument();
// Section headers should show abbreviated versions
expect(screen.getByText('Ops')).toBeInTheDocument();
expect(screen.getByText('Sys')).toBeInTheDocument();
});
it('shows full section names when expanded', () => {
render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
expect(screen.getByText('Operations')).toBeInTheDocument();
expect(screen.getByText('System')).toBeInTheDocument();
expect(screen.queryByText('Ops')).not.toBeInTheDocument();
expect(screen.queryByText('Sys')).not.toBeInTheDocument();
});
it('applies correct width classes based on collapsed state', () => {
const { container, rerender } = render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
const sidebar = container.firstChild as HTMLElement;
expect(sidebar).toHaveClass('w-64');
expect(sidebar).not.toHaveClass('w-20');
rerender(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={true}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
expect(sidebar).toHaveClass('w-20');
expect(sidebar).not.toHaveClass('w-64');
});
});
describe('Toggle Collapse Button', () => {
it('calls toggleCollapse when clicked', async () => {
const user = userEvent.setup();
render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
const toggleButton = screen.getByRole('button', { name: /collapse sidebar/i });
await user.click(toggleButton);
expect(mockToggleCollapse).toHaveBeenCalledTimes(1);
});
it('has correct aria-label when collapsed', () => {
render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={true}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
expect(screen.getByRole('button', { name: /expand sidebar/i })).toBeInTheDocument();
});
it('has correct aria-label when expanded', () => {
render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
expect(screen.getByRole('button', { name: /collapse sidebar/i })).toBeInTheDocument();
});
});
describe('Active Link Highlighting', () => {
it('highlights the active link based on current path', () => {
render(
<MemoryRouter initialEntries={['/platform/businesses']}>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</MemoryRouter>
);
const businessesLink = screen.getByRole('link', { name: /businesses/i });
const usersLink = screen.getByRole('link', { name: /^users$/i });
// Active link should have active classes
expect(businessesLink).toHaveClass('bg-gray-700', 'text-white');
expect(businessesLink).not.toHaveClass('text-gray-400');
// Inactive link should have inactive classes
expect(usersLink).toHaveClass('text-gray-400');
expect(usersLink).not.toHaveClass('bg-gray-700');
});
it('highlights dashboard link when on dashboard route', () => {
render(
<MemoryRouter initialEntries={['/platform/dashboard']}>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</MemoryRouter>
);
const dashboardLink = screen.getByRole('link', { name: /dashboard/i });
expect(dashboardLink).toHaveClass('bg-gray-700', 'text-white');
});
it('highlights link for nested routes', () => {
render(
<MemoryRouter initialEntries={['/platform/businesses/123']}>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</MemoryRouter>
);
const businessesLink = screen.getByRole('link', { name: /businesses/i });
expect(businessesLink).toHaveClass('bg-gray-700', 'text-white');
});
it('highlights staff link when on staff route', () => {
render(
<MemoryRouter initialEntries={['/platform/staff']}>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</MemoryRouter>
);
const staffLink = screen.getByRole('link', { name: /staff/i });
expect(staffLink).toHaveClass('bg-gray-700', 'text-white');
});
it('highlights help link when on help route', () => {
render(
<MemoryRouter initialEntries={['/help/api']}>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</MemoryRouter>
);
const apiDocsLink = screen.getByRole('link', { name: /api docs/i });
expect(apiDocsLink).toHaveClass('bg-gray-700', 'text-white');
});
});
describe('Navigation Links', () => {
it('has correct href attributes for all links', () => {
render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('href', '/platform/dashboard');
expect(screen.getByRole('link', { name: /businesses/i })).toHaveAttribute('href', '/platform/businesses');
expect(screen.getByRole('link', { name: /^users$/i })).toHaveAttribute('href', '/platform/users');
expect(screen.getByRole('link', { name: /support/i })).toHaveAttribute('href', '/platform/support');
expect(screen.getByRole('link', { name: /staff/i })).toHaveAttribute('href', '/platform/staff');
expect(screen.getByRole('link', { name: /platform settings/i })).toHaveAttribute('href', '/platform/settings');
expect(screen.getByRole('link', { name: /api docs/i })).toHaveAttribute('href', '/help/api');
});
it('shows title attributes on links for accessibility', () => {
render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('title', 'Platform Dashboard');
expect(screen.getByRole('link', { name: /businesses/i })).toHaveAttribute('title', 'Businesses');
expect(screen.getByRole('link', { name: /^users$/i })).toHaveAttribute('title', 'Users');
});
});
describe('Icons', () => {
it('renders lucide-react icons for all navigation items', () => {
const { container } = render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
// Check that SVG icons are present (lucide-react renders as SVG)
const svgs = container.querySelectorAll('svg');
// Should have: logo + icons for each nav item
expect(svgs.length).toBeGreaterThanOrEqual(10);
});
it('keeps icons visible when collapsed', () => {
const { container } = render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={true}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
// Icons should still be present when collapsed
const svgs = container.querySelectorAll('svg');
expect(svgs.length).toBeGreaterThanOrEqual(10);
});
});
describe('Responsive Design', () => {
it('applies flex column layout', () => {
const { container } = render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
const sidebar = container.firstChild as HTMLElement;
expect(sidebar).toHaveClass('flex', 'flex-col', 'h-full');
});
it('applies dark theme colors', () => {
const { container } = render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
const sidebar = container.firstChild as HTMLElement;
expect(sidebar).toHaveClass('bg-gray-900', 'text-white');
});
it('has transition classes for smooth collapse animation', () => {
const { container } = render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
const sidebar = container.firstChild as HTMLElement;
expect(sidebar).toHaveClass('transition-all', 'duration-300');
});
});
describe('Role-Based Access Control', () => {
it('shows dashboard for superuser and platform_manager only', () => {
const { rerender } = render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
expect(screen.queryByText('Dashboard')).toBeInTheDocument();
rerender(
<BrowserRouter>
<PlatformSidebar
user={mockPlatformManager}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
expect(screen.queryByText('Dashboard')).toBeInTheDocument();
rerender(
<BrowserRouter>
<PlatformSidebar
user={mockPlatformSupport}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
expect(screen.queryByText('Dashboard')).not.toBeInTheDocument();
});
it('shows system section only for superuser', () => {
const { rerender } = render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
expect(screen.queryByText('System')).toBeInTheDocument();
expect(screen.queryByText('Staff')).toBeInTheDocument();
rerender(
<BrowserRouter>
<PlatformSidebar
user={mockPlatformManager}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
expect(screen.queryByText('System')).not.toBeInTheDocument();
expect(screen.queryByText('Staff')).not.toBeInTheDocument();
rerender(
<BrowserRouter>
<PlatformSidebar
user={mockPlatformSupport}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
expect(screen.queryByText('System')).not.toBeInTheDocument();
});
it('always shows common operations links for all roles', () => {
const roles: User[] = [mockSuperuser, mockPlatformManager, mockPlatformSupport];
roles.forEach((user) => {
const { unmount } = render(
<BrowserRouter>
<PlatformSidebar
user={user}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
expect(screen.getByText('Businesses')).toBeInTheDocument();
expect(screen.getByText('Users')).toBeInTheDocument();
expect(screen.getByText('Support')).toBeInTheDocument();
unmount();
});
});
});
describe('Accessibility', () => {
it('has semantic HTML structure with nav element', () => {
const { container } = render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
const nav = container.querySelector('nav');
expect(nav).toBeInTheDocument();
});
it('provides proper button label for keyboard users', () => {
render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
const button = screen.getByRole('button', { name: /collapse sidebar/i });
expect(button).toHaveAccessibleName();
});
it('all links have accessible names', () => {
render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
const links = screen.getAllByRole('link');
links.forEach((link) => {
expect(link).toHaveAccessibleName();
});
});
it('maintains focus visibility for keyboard navigation', () => {
const { container } = render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
const button = screen.getByRole('button', { name: /collapse sidebar/i });
expect(button).toHaveClass('focus:outline-none');
});
});
describe('Edge Cases', () => {
it('handles user with empty name gracefully', () => {
const userWithoutName: User = {
...mockSuperuser,
name: '',
};
render(
<BrowserRouter>
<PlatformSidebar
user={userWithoutName}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
// Should still render without crashing
expect(screen.getByTestId('smooth-schedule-logo')).toBeInTheDocument();
});
it('handles missing translation gracefully', () => {
// Translation mock should return the key if translation is missing
render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
// Should render without errors even with missing translations
expect(screen.getByTestId('smooth-schedule-logo')).toBeInTheDocument();
});
it('handles rapid collapse/expand toggling', async () => {
const user = userEvent.setup();
render(
<BrowserRouter>
<PlatformSidebar
user={mockSuperuser}
isCollapsed={false}
toggleCollapse={mockToggleCollapse}
/>
</BrowserRouter>
);
const button = screen.getByRole('button', { name: /collapse sidebar/i });
// Rapidly click multiple times
await user.click(button);
await user.click(button);
await user.click(button);
expect(mockToggleCollapse).toHaveBeenCalledTimes(3);
});
});
});

View File

@@ -0,0 +1,453 @@
/**
* Unit tests for Portal component
*
* Tests the Portal component which uses ReactDOM.createPortal to render
* children outside the parent DOM hierarchy. This is useful for modals,
* tooltips, and other UI elements that need to escape parent stacking contexts.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import Portal from '../Portal';
describe('Portal', () => {
afterEach(() => {
// Clean up any rendered components
cleanup();
});
describe('Basic Rendering', () => {
it('should render children', () => {
render(
<Portal>
<div data-testid="portal-content">Portal Content</div>
</Portal>
);
expect(screen.getByTestId('portal-content')).toBeInTheDocument();
expect(screen.getByText('Portal Content')).toBeInTheDocument();
});
it('should render text content', () => {
render(<Portal>Simple text content</Portal>);
expect(screen.getByText('Simple text content')).toBeInTheDocument();
});
it('should render complex JSX children', () => {
render(
<Portal>
<div>
<h1>Title</h1>
<p>Description</p>
<button>Click me</button>
</div>
</Portal>
);
expect(screen.getByRole('heading', { name: 'Title' })).toBeInTheDocument();
expect(screen.getByText('Description')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
});
});
describe('Portal Behavior', () => {
it('should render content to document.body', () => {
const { container } = render(
<div id="root">
<Portal>
<div data-testid="portal-content">Portal Content</div>
</Portal>
</div>
);
const portalContent = screen.getByTestId('portal-content');
// Portal content should NOT be inside the container
expect(container.contains(portalContent)).toBe(false);
// Portal content SHOULD be inside document.body
expect(document.body.contains(portalContent)).toBe(true);
});
it('should escape parent DOM hierarchy', () => {
const { container } = render(
<div id="parent" style={{ position: 'relative', zIndex: 1 }}>
<div id="child">
<Portal>
<div data-testid="portal-content">Escaped Content</div>
</Portal>
</div>
</div>
);
const portalContent = screen.getByTestId('portal-content');
const parent = container.querySelector('#parent');
// Portal content should not be inside parent
expect(parent?.contains(portalContent)).toBe(false);
// Portal content should be direct child of body
expect(portalContent.parentElement).toBe(document.body);
});
});
describe('Multiple Children', () => {
it('should render multiple children', () => {
render(
<Portal>
<div data-testid="child-1">First child</div>
<div data-testid="child-2">Second child</div>
<div data-testid="child-3">Third child</div>
</Portal>
);
expect(screen.getByTestId('child-1')).toBeInTheDocument();
expect(screen.getByTestId('child-2')).toBeInTheDocument();
expect(screen.getByTestId('child-3')).toBeInTheDocument();
});
it('should render an array of children', () => {
const items = ['Item 1', 'Item 2', 'Item 3'];
render(
<Portal>
{items.map((item, index) => (
<div key={index} data-testid={`item-${index}`}>
{item}
</div>
))}
</Portal>
);
items.forEach((item, index) => {
expect(screen.getByTestId(`item-${index}`)).toBeInTheDocument();
expect(screen.getByText(item)).toBeInTheDocument();
});
});
it('should render nested components', () => {
const NestedComponent = () => (
<div data-testid="nested">
<span>Nested Component</span>
</div>
);
render(
<Portal>
<NestedComponent />
<div>Other content</div>
</Portal>
);
expect(screen.getByTestId('nested')).toBeInTheDocument();
expect(screen.getByText('Nested Component')).toBeInTheDocument();
expect(screen.getByText('Other content')).toBeInTheDocument();
});
});
describe('Mounting Behavior', () => {
it('should not render before component is mounted', () => {
// This test verifies the internal mounting state
const { rerender } = render(
<Portal>
<div data-testid="portal-content">Content</div>
</Portal>
);
// After initial render, content should be present
expect(screen.getByTestId('portal-content')).toBeInTheDocument();
// Re-render should still show content
rerender(
<Portal>
<div data-testid="portal-content">Updated Content</div>
</Portal>
);
expect(screen.getByText('Updated Content')).toBeInTheDocument();
});
});
describe('Multiple Portals', () => {
it('should support multiple portal instances', () => {
render(
<div>
<Portal>
<div data-testid="portal-1">Portal 1</div>
</Portal>
<Portal>
<div data-testid="portal-2">Portal 2</div>
</Portal>
<Portal>
<div data-testid="portal-3">Portal 3</div>
</Portal>
</div>
);
expect(screen.getByTestId('portal-1')).toBeInTheDocument();
expect(screen.getByTestId('portal-2')).toBeInTheDocument();
expect(screen.getByTestId('portal-3')).toBeInTheDocument();
// All portals should be in document.body
expect(document.body.contains(screen.getByTestId('portal-1'))).toBe(true);
expect(document.body.contains(screen.getByTestId('portal-2'))).toBe(true);
expect(document.body.contains(screen.getByTestId('portal-3'))).toBe(true);
});
it('should keep portals separate from each other', () => {
render(
<div>
<Portal>
<div data-testid="portal-1">
<span data-testid="content-1">Content 1</span>
</div>
</Portal>
<Portal>
<div data-testid="portal-2">
<span data-testid="content-2">Content 2</span>
</div>
</Portal>
</div>
);
const portal1 = screen.getByTestId('portal-1');
const portal2 = screen.getByTestId('portal-2');
const content1 = screen.getByTestId('content-1');
const content2 = screen.getByTestId('content-2');
// Each portal should contain only its own content
expect(portal1.contains(content1)).toBe(true);
expect(portal1.contains(content2)).toBe(false);
expect(portal2.contains(content2)).toBe(true);
expect(portal2.contains(content1)).toBe(false);
});
});
describe('Cleanup', () => {
it('should remove content from body when unmounted', () => {
const { unmount } = render(
<Portal>
<div data-testid="portal-content">Temporary Content</div>
</Portal>
);
// Content should exist initially
expect(screen.getByTestId('portal-content')).toBeInTheDocument();
// Unmount the component
unmount();
// Content should be removed from DOM
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument();
});
it('should clean up multiple portals on unmount', () => {
const { unmount } = render(
<div>
<Portal>
<div data-testid="portal-1">Portal 1</div>
</Portal>
<Portal>
<div data-testid="portal-2">Portal 2</div>
</Portal>
</div>
);
expect(screen.getByTestId('portal-1')).toBeInTheDocument();
expect(screen.getByTestId('portal-2')).toBeInTheDocument();
unmount();
expect(screen.queryByTestId('portal-1')).not.toBeInTheDocument();
expect(screen.queryByTestId('portal-2')).not.toBeInTheDocument();
});
});
describe('Re-rendering', () => {
it('should update content on re-render', () => {
const { rerender } = render(
<Portal>
<div data-testid="portal-content">Initial Content</div>
</Portal>
);
expect(screen.getByText('Initial Content')).toBeInTheDocument();
rerender(
<Portal>
<div data-testid="portal-content">Updated Content</div>
</Portal>
);
expect(screen.getByText('Updated Content')).toBeInTheDocument();
expect(screen.queryByText('Initial Content')).not.toBeInTheDocument();
});
it('should handle prop changes', () => {
const TestComponent = ({ message }: { message: string }) => (
<Portal>
<div data-testid="message">{message}</div>
</Portal>
);
const { rerender } = render(<TestComponent message="First message" />);
expect(screen.getByText('First message')).toBeInTheDocument();
rerender(<TestComponent message="Second message" />);
expect(screen.getByText('Second message')).toBeInTheDocument();
expect(screen.queryByText('First message')).not.toBeInTheDocument();
});
});
describe('Edge Cases', () => {
it('should handle empty children', () => {
render(<Portal>{null}</Portal>);
// Should not throw error
expect(document.body).toBeInTheDocument();
});
it('should handle undefined children', () => {
render(<Portal>{undefined}</Portal>);
// Should not throw error
expect(document.body).toBeInTheDocument();
});
it('should handle boolean children', () => {
render(
<Portal>
{false && <div>Should not render</div>}
{true && <div data-testid="should-render">Should render</div>}
</Portal>
);
expect(screen.queryByText('Should not render')).not.toBeInTheDocument();
expect(screen.getByTestId('should-render')).toBeInTheDocument();
});
it('should handle conditional rendering', () => {
const { rerender } = render(
<Portal>
{false && <div data-testid="conditional">Conditional Content</div>}
</Portal>
);
expect(screen.queryByTestId('conditional')).not.toBeInTheDocument();
rerender(
<Portal>
{true && <div data-testid="conditional">Conditional Content</div>}
</Portal>
);
expect(screen.getByTestId('conditional')).toBeInTheDocument();
});
});
describe('Integration with Parent Components', () => {
it('should work inside modals', () => {
const Modal = ({ children }: { children: React.ReactNode }) => (
<div className="modal" data-testid="modal">
<Portal>{children}</Portal>
</div>
);
const { container } = render(
<Modal>
<div data-testid="modal-content">Modal Content</div>
</Modal>
);
const modalContent = screen.getByTestId('modal-content');
const modal = container.querySelector('[data-testid="modal"]');
// Modal content should not be inside modal container
expect(modal?.contains(modalContent)).toBe(false);
// Modal content should be in document.body
expect(document.body.contains(modalContent)).toBe(true);
});
it('should preserve event handlers', () => {
let clicked = false;
const handleClick = () => {
clicked = true;
};
render(
<Portal>
<button data-testid="button" onClick={handleClick}>
Click me
</button>
</Portal>
);
const button = screen.getByTestId('button');
button.click();
expect(clicked).toBe(true);
});
it('should preserve CSS classes and styles', () => {
render(
<Portal>
<div
data-testid="styled-content"
className="custom-class"
style={{ color: 'red', fontSize: '16px' }}
>
Styled Content
</div>
</Portal>
);
const styledContent = screen.getByTestId('styled-content');
expect(styledContent).toHaveClass('custom-class');
// Check styles individually - color may be normalized to rgb()
expect(styledContent.style.color).toBeTruthy();
expect(styledContent.style.fontSize).toBe('16px');
});
});
describe('Accessibility', () => {
it('should maintain ARIA attributes', () => {
render(
<Portal>
<div
data-testid="aria-content"
role="dialog"
aria-label="Test Dialog"
aria-describedby="description"
>
<div id="description">Dialog description</div>
</div>
</Portal>
);
const content = screen.getByTestId('aria-content');
expect(content).toHaveAttribute('role', 'dialog');
expect(content).toHaveAttribute('aria-label', 'Test Dialog');
expect(content).toHaveAttribute('aria-describedby', 'description');
});
it('should support semantic HTML inside portal', () => {
render(
<Portal>
<dialog open data-testid="dialog">
<h2>Dialog Title</h2>
<p>Dialog content</p>
</dialog>
</Portal>
);
expect(screen.getByTestId('dialog')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Dialog Title' })).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,681 @@
/**
* Unit tests for QuotaWarningBanner component
*
* Tests cover:
* - Rendering based on quota overage state
* - Critical, urgent, and warning severity levels
* - Display of correct percentage and usage information
* - Multiple overages display
* - Manage Quota button/link functionality
* - Dismiss button functionality
* - Date formatting
* - Internationalization (i18n)
* - Accessibility attributes
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BrowserRouter } from 'react-router-dom';
import React from 'react';
import QuotaWarningBanner from '../QuotaWarningBanner';
import { QuotaOverage } from '../../api/auth';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback: string, options?: Record<string, unknown>) => {
// Handle interpolation for dynamic values
if (options) {
let result = fallback;
Object.entries(options).forEach(([key, value]) => {
result = result.replace(`{{${key}}}`, String(value));
});
return result;
}
return fallback;
},
}),
}));
// Test wrapper with Router
const createWrapper = () => {
return ({ children }: { children: React.ReactNode }) => (
<BrowserRouter>{children}</BrowserRouter>
);
};
// Test data factories
const createMockOverage = (overrides?: Partial<QuotaOverage>): QuotaOverage => ({
id: 1,
quota_type: 'resources',
display_name: 'Resources',
current_usage: 15,
allowed_limit: 10,
overage_amount: 5,
days_remaining: 14,
grace_period_ends_at: '2025-12-21T00:00:00Z',
...overrides,
});
describe('QuotaWarningBanner', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering Conditions', () => {
it('should not render when overages array is empty', () => {
const { container } = render(
<QuotaWarningBanner overages={[]} />,
{ wrapper: createWrapper() }
);
expect(container).toBeEmptyDOMElement();
});
it('should not render when overages is null', () => {
const { container } = render(
<QuotaWarningBanner overages={null as any} />,
{ wrapper: createWrapper() }
);
expect(container).toBeEmptyDOMElement();
});
it('should not render when overages is undefined', () => {
const { container } = render(
<QuotaWarningBanner overages={undefined as any} />,
{ wrapper: createWrapper() }
);
expect(container).toBeEmptyDOMElement();
});
it('should render when quota is near limit (warning state)', () => {
const overages = [createMockOverage({ days_remaining: 14 })];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
expect(screen.getByText(/quota exceeded/i)).toBeInTheDocument();
});
it('should render when quota is critical (1 day remaining)', () => {
const overages = [createMockOverage({ days_remaining: 1 })];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
expect(screen.getByText(/urgent.*automatic archiving tomorrow/i)).toBeInTheDocument();
});
it('should render when quota is urgent (7 days remaining)', () => {
const overages = [createMockOverage({ days_remaining: 7 })];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
expect(screen.getByText(/action required.*7 days left/i)).toBeInTheDocument();
});
});
describe('Severity Levels and Styling', () => {
it('should apply warning styles for normal overages (>7 days)', () => {
const overages = [createMockOverage({ days_remaining: 14 })];
const { container } = render(
<QuotaWarningBanner overages={overages} />,
{ wrapper: createWrapper() }
);
const banner = container.querySelector('div[class*="bg-amber-100"]');
expect(banner).toBeInTheDocument();
});
it('should apply urgent styles for 7 days or less', () => {
const overages = [createMockOverage({ days_remaining: 7 })];
const { container } = render(
<QuotaWarningBanner overages={overages} />,
{ wrapper: createWrapper() }
);
const banner = container.querySelector('div[class*="bg-amber-500"]');
expect(banner).toBeInTheDocument();
});
it('should apply critical styles for 1 day or less', () => {
const overages = [createMockOverage({ days_remaining: 1 })];
const { container } = render(
<QuotaWarningBanner overages={overages} />,
{ wrapper: createWrapper() }
);
const banner = container.querySelector('div[class*="bg-red-600"]');
expect(banner).toBeInTheDocument();
});
it('should apply critical styles for 0 days remaining', () => {
const overages = [createMockOverage({ days_remaining: 0 })];
const { container } = render(
<QuotaWarningBanner overages={overages} />,
{ wrapper: createWrapper() }
);
const banner = container.querySelector('div[class*="bg-red-600"]');
expect(banner).toBeInTheDocument();
});
});
describe('Usage and Percentage Display', () => {
it('should display correct overage amount', () => {
const overages = [
createMockOverage({
overage_amount: 5,
display_name: 'Resources',
}),
];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
expect(screen.getByText(/you have 5 Resources over your plan limit/i)).toBeInTheDocument();
});
it('should display current usage and limit in multi-overage list', () => {
const overages = [
createMockOverage({
id: 1,
current_usage: 15,
allowed_limit: 10,
display_name: 'Staff Members',
}),
createMockOverage({
id: 2,
current_usage: 20,
allowed_limit: 15,
display_name: 'Resources',
}),
];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
// Usage/limit is shown in the "All overages" list when there are multiple
expect(screen.getByText(/Staff Members: 15\/10/)).toBeInTheDocument();
expect(screen.getByText(/Resources: 20\/15/)).toBeInTheDocument();
});
it('should display quota type name', () => {
const overages = [
createMockOverage({
display_name: 'Calendar Events',
overage_amount: 100,
}),
];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
expect(screen.getByText(/you have 100 Calendar Events over your plan limit/i)).toBeInTheDocument();
});
it('should format and display grace period end date', () => {
const overages = [
createMockOverage({
grace_period_ends_at: '2025-12-25T00:00:00Z',
}),
];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
// Date formatting will depend on locale, but should contain the date components
const detailsText = screen.getByText(/grace period ends/i);
expect(detailsText).toBeInTheDocument();
});
});
describe('Multiple Overages', () => {
it('should display most urgent overage in main message', () => {
const overages = [
createMockOverage({ id: 1, days_remaining: 14, display_name: 'Resources' }),
createMockOverage({ id: 2, days_remaining: 3, display_name: 'Staff Members' }),
createMockOverage({ id: 3, days_remaining: 7, display_name: 'Events' }),
];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
// Should show the most urgent (3 days)
expect(screen.getByText(/action required.*3 days left/i)).toBeInTheDocument();
});
it('should show additional overages section when multiple overages exist', () => {
const overages = [
createMockOverage({ id: 1, days_remaining: 14, display_name: 'Resources', current_usage: 15, allowed_limit: 10, overage_amount: 5 }),
createMockOverage({ id: 2, days_remaining: 7, display_name: 'Staff', current_usage: 8, allowed_limit: 5, overage_amount: 3 }),
];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
expect(screen.getByText(/all overages:/i)).toBeInTheDocument();
});
it('should list all overages with details in the additional section', () => {
const overages = [
createMockOverage({
id: 1,
display_name: 'Resources',
current_usage: 15,
allowed_limit: 10,
overage_amount: 5,
days_remaining: 14,
}),
createMockOverage({
id: 2,
display_name: 'Staff',
current_usage: 8,
allowed_limit: 5,
overage_amount: 3,
days_remaining: 7,
}),
];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
expect(screen.getByText(/Resources: 15\/10/)).toBeInTheDocument();
expect(screen.getByText(/over by 5/)).toBeInTheDocument();
expect(screen.getByText(/Staff: 8\/5/)).toBeInTheDocument();
expect(screen.getByText(/over by 3/)).toBeInTheDocument();
});
it('should not show additional overages section for single overage', () => {
const overages = [createMockOverage()];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
expect(screen.queryByText(/all overages:/i)).not.toBeInTheDocument();
});
it('should display "expires today" for 0 days remaining in overage list', () => {
const overages = [
createMockOverage({ id: 1, days_remaining: 14 }),
createMockOverage({ id: 2, days_remaining: 0, display_name: 'Critical Item' }),
];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
expect(screen.getByText(/expires today!/i)).toBeInTheDocument();
});
});
describe('Manage Quota Button', () => {
it('should render Manage Quota link', () => {
const overages = [createMockOverage()];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link', { name: /manage quota/i });
expect(link).toBeInTheDocument();
});
it('should link to settings/quota page', () => {
const overages = [createMockOverage()];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link', { name: /manage quota/i });
expect(link).toHaveAttribute('href', '/settings/quota');
});
it('should display external link icon', () => {
const overages = [createMockOverage()];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link', { name: /manage quota/i });
const icon = link.querySelector('svg');
expect(icon).toBeInTheDocument();
});
it('should apply warning button styles for normal overages', () => {
const overages = [createMockOverage({ days_remaining: 14 })];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link', { name: /manage quota/i });
expect(link).toHaveClass('bg-amber-600');
});
it('should apply urgent button styles for urgent/critical overages', () => {
const overages = [createMockOverage({ days_remaining: 7 })];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link', { name: /manage quota/i });
expect(link).toHaveClass('bg-white/20');
});
});
describe('Dismiss Button', () => {
it('should render dismiss button when onDismiss prop is provided', () => {
const overages = [createMockOverage()];
const onDismiss = vi.fn();
render(<QuotaWarningBanner overages={overages} onDismiss={onDismiss} />, {
wrapper: createWrapper(),
});
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
expect(dismissButton).toBeInTheDocument();
});
it('should not render dismiss button when onDismiss prop is not provided', () => {
const overages = [createMockOverage()];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
const dismissButton = screen.queryByRole('button', { name: /dismiss/i });
expect(dismissButton).not.toBeInTheDocument();
});
it('should call onDismiss when dismiss button is clicked', async () => {
const user = userEvent.setup();
const overages = [createMockOverage()];
const onDismiss = vi.fn();
render(<QuotaWarningBanner overages={overages} onDismiss={onDismiss} />, {
wrapper: createWrapper(),
});
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
await user.click(dismissButton);
expect(onDismiss).toHaveBeenCalledTimes(1);
});
it('should display X icon in dismiss button', () => {
const overages = [createMockOverage()];
const onDismiss = vi.fn();
render(<QuotaWarningBanner overages={overages} onDismiss={onDismiss} />, {
wrapper: createWrapper(),
});
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
const icon = dismissButton.querySelector('svg');
expect(icon).toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('should have alert icon with appropriate styling', () => {
const overages = [createMockOverage()];
const { container } = render(
<QuotaWarningBanner overages={overages} />,
{ wrapper: createWrapper() }
);
// AlertTriangle icon should be present
const icon = container.querySelector('svg');
expect(icon).toBeInTheDocument();
});
it('should have accessible label for dismiss button', () => {
const overages = [createMockOverage()];
const onDismiss = vi.fn();
render(<QuotaWarningBanner overages={overages} onDismiss={onDismiss} />, {
wrapper: createWrapper(),
});
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
expect(dismissButton).toHaveAttribute('aria-label', 'Dismiss');
});
it('should use semantic HTML structure', () => {
const overages = [createMockOverage({ days_remaining: 14 })];
const { container } = render(
<QuotaWarningBanner overages={overages} />,
{ wrapper: createWrapper() }
);
// Should have proper div structure
expect(container.querySelector('div')).toBeInTheDocument();
});
it('should have accessible link for Manage Quota', () => {
const overages = [createMockOverage()];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link', { name: /manage quota/i });
expect(link).toBeInTheDocument();
expect(link.tagName).toBe('A');
});
});
describe('Message Priority', () => {
it('should show critical message for 1 day remaining', () => {
const overages = [createMockOverage({ days_remaining: 1 })];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
expect(screen.getByText(/urgent.*automatic archiving tomorrow/i)).toBeInTheDocument();
});
it('should show urgent message for 2-7 days remaining', () => {
const overages = [createMockOverage({ days_remaining: 5 })];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
expect(screen.getByText(/action required.*5 days left/i)).toBeInTheDocument();
});
it('should show warning message for more than 7 days remaining', () => {
const overages = [createMockOverage({ days_remaining: 10 })];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
expect(screen.getByText(/quota exceeded for 1 item/i)).toBeInTheDocument();
});
it('should show count of overages in warning message', () => {
const overages = [
createMockOverage({ id: 1, days_remaining: 14 }),
createMockOverage({ id: 2, days_remaining: 10 }),
createMockOverage({ id: 3, days_remaining: 12 }),
];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
expect(screen.getByText(/quota exceeded for 3 item/i)).toBeInTheDocument();
});
});
describe('Integration', () => {
it('should render complete banner with all elements', () => {
const overages = [
createMockOverage({
id: 1,
quota_type: 'resources',
display_name: 'Resources',
current_usage: 15,
allowed_limit: 10,
overage_amount: 5,
days_remaining: 7,
grace_period_ends_at: '2025-12-21T00:00:00Z',
}),
];
const onDismiss = vi.fn();
render(<QuotaWarningBanner overages={overages} onDismiss={onDismiss} />, {
wrapper: createWrapper(),
});
// Check main message
expect(screen.getByText(/action required.*7 days left/i)).toBeInTheDocument();
// Check details
expect(screen.getByText(/you have 5 Resources over your plan limit/i)).toBeInTheDocument();
// Check Manage Quota link
const link = screen.getByRole('link', { name: /manage quota/i });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', '/settings/quota');
// Check dismiss button
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
expect(dismissButton).toBeInTheDocument();
// Check icons are present (via SVG elements)
const { container } = render(<QuotaWarningBanner overages={overages} onDismiss={onDismiss} />, {
wrapper: createWrapper(),
});
const icons = container.querySelectorAll('svg');
expect(icons.length).toBeGreaterThan(0);
});
it('should handle complex multi-overage scenario', async () => {
const user = userEvent.setup();
const overages = [
createMockOverage({
id: 1,
display_name: 'Resources',
current_usage: 15,
allowed_limit: 10,
overage_amount: 5,
days_remaining: 14,
}),
createMockOverage({
id: 2,
display_name: 'Staff Members',
current_usage: 12,
allowed_limit: 8,
overage_amount: 4,
days_remaining: 2,
}),
createMockOverage({
id: 3,
display_name: 'Calendar Events',
current_usage: 500,
allowed_limit: 400,
overage_amount: 100,
days_remaining: 7,
}),
];
const onDismiss = vi.fn();
render(<QuotaWarningBanner overages={overages} onDismiss={onDismiss} />, {
wrapper: createWrapper(),
});
// Should show most urgent (2 days)
expect(screen.getByText(/action required.*2 days left/i)).toBeInTheDocument();
// Should show all overages section
expect(screen.getByText(/all overages:/i)).toBeInTheDocument();
expect(screen.getByText(/Resources: 15\/10/)).toBeInTheDocument();
expect(screen.getByText(/Staff Members: 12\/8/)).toBeInTheDocument();
expect(screen.getByText(/Calendar Events: 500\/400/)).toBeInTheDocument();
// Should be able to dismiss
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
await user.click(dismissButton);
expect(onDismiss).toHaveBeenCalledTimes(1);
});
});
describe('Edge Cases', () => {
it('should handle negative days remaining', () => {
const overages = [createMockOverage({ days_remaining: -1 })];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
// Should treat as critical (0 or less)
const { container } = render(
<QuotaWarningBanner overages={overages} />,
{ wrapper: createWrapper() }
);
const banner = container.querySelector('div[class*="bg-red-600"]');
expect(banner).toBeInTheDocument();
});
it('should handle very large overage amounts', () => {
const overages = [
createMockOverage({
overage_amount: 999999,
display_name: 'Events',
}),
];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
expect(screen.getByText(/you have 999999 Events over your plan limit/i)).toBeInTheDocument();
});
it('should handle zero overage amount', () => {
const overages = [
createMockOverage({
overage_amount: 0,
current_usage: 10,
allowed_limit: 10,
}),
];
render(<QuotaWarningBanner overages={overages} />, {
wrapper: createWrapper(),
});
expect(screen.getByText(/you have 0 Resources over your plan limit/i)).toBeInTheDocument();
});
});
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,716 @@
/**
* Unit tests for TopBar component
*
* Tests the top navigation bar that appears at the top of the application.
* Covers:
* - Rendering of all UI elements (search, theme toggle, notifications, etc.)
* - Menu button for mobile view
* - Theme toggle functionality
* - User profile dropdown integration
* - Language selector integration
* - Notification dropdown integration
* - Sandbox toggle integration
* - Search input
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import TopBar from '../TopBar';
import { User } from '../../types';
// Mock child components
vi.mock('../UserProfileDropdown', () => ({
default: ({ user }: { user: User }) => (
<div data-testid="user-profile-dropdown">User: {user.email}</div>
),
}));
vi.mock('../LanguageSelector', () => ({
default: () => <div data-testid="language-selector">Language Selector</div>,
}));
vi.mock('../NotificationDropdown', () => ({
default: ({ onTicketClick }: { onTicketClick?: (id: string) => void }) => (
<div data-testid="notification-dropdown">Notifications</div>
),
}));
vi.mock('../SandboxToggle', () => ({
default: ({ isSandbox, sandboxEnabled, onToggle, isToggling }: any) => (
<div data-testid="sandbox-toggle">
Sandbox: {isSandbox ? 'On' : 'Off'}
<button onClick={onToggle} disabled={isToggling}>
Toggle Sandbox
</button>
</div>
),
}));
// Mock SandboxContext
const mockUseSandbox = vi.fn();
vi.mock('../../contexts/SandboxContext', () => ({
useSandbox: () => mockUseSandbox(),
}));
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'common.search': 'Search...',
};
return translations[key] || key;
},
}),
}));
// Test data factory for User objects
const createMockUser = (overrides?: Partial<User>): User => ({
id: '1',
email: 'test@example.com',
firstName: 'Test',
lastName: 'User',
role: 'owner',
phone: '+1234567890',
preferences: {
email: true,
sms: false,
in_app: true,
},
twoFactorEnabled: false,
profilePictureUrl: undefined,
...overrides,
});
// Wrapper component that provides router context
const renderWithRouter = (ui: React.ReactElement) => {
return render(<BrowserRouter>{ui}</BrowserRouter>);
};
describe('TopBar', () => {
const mockToggleTheme = vi.fn();
const mockOnMenuClick = vi.fn();
const mockOnTicketClick = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
mockUseSandbox.mockReturnValue({
isSandbox: false,
sandboxEnabled: true,
toggleSandbox: vi.fn(),
isToggling: false,
});
});
describe('Rendering', () => {
it('should render the top bar with all main elements', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
expect(screen.getByTestId('user-profile-dropdown')).toBeInTheDocument();
expect(screen.getByTestId('language-selector')).toBeInTheDocument();
expect(screen.getByTestId('notification-dropdown')).toBeInTheDocument();
expect(screen.getByTestId('sandbox-toggle')).toBeInTheDocument();
});
it('should render search input on desktop', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
const searchInput = screen.getByPlaceholderText('Search...');
expect(searchInput).toBeInTheDocument();
expect(searchInput).toHaveClass('w-full');
});
it('should render mobile menu button', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
const menuButton = screen.getByLabelText('Open sidebar');
expect(menuButton).toBeInTheDocument();
});
it('should pass user to UserProfileDropdown', () => {
const user = createMockUser({ email: 'john@example.com' });
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
expect(screen.getByText('User: john@example.com')).toBeInTheDocument();
});
it('should render with dark mode styles when isDarkMode is true', () => {
const user = createMockUser();
const { container } = renderWithRouter(
<TopBar
user={user}
isDarkMode={true}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
const header = container.querySelector('header');
expect(header).toHaveClass('dark:bg-gray-800', 'dark:border-gray-700');
});
});
describe('Theme Toggle', () => {
it('should render moon icon when in light mode', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
// The button should exist
const buttons = screen.getAllByRole('button');
const themeButton = buttons.find(btn =>
btn.className.includes('text-gray-400')
);
expect(themeButton).toBeInTheDocument();
});
it('should render sun icon when in dark mode', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={true}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
// The button should exist
const buttons = screen.getAllByRole('button');
const themeButton = buttons.find(btn =>
btn.className.includes('text-gray-400')
);
expect(themeButton).toBeInTheDocument();
});
it('should call toggleTheme when theme button is clicked', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
// Find the theme toggle button by finding buttons, then clicking the one with the theme classes
const buttons = screen.getAllByRole('button');
// The theme button is the one with the hover styles and not the menu button
const themeButton = buttons.find(btn =>
btn.className.includes('text-gray-400') &&
btn.className.includes('hover:text-gray-600') &&
!btn.getAttribute('aria-label')
);
expect(themeButton).toBeTruthy();
if (themeButton) {
fireEvent.click(themeButton);
expect(mockToggleTheme).toHaveBeenCalledTimes(1);
}
});
});
describe('Mobile Menu Button', () => {
it('should render menu button with correct aria-label', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
const menuButton = screen.getByLabelText('Open sidebar');
expect(menuButton).toHaveAttribute('aria-label', 'Open sidebar');
});
it('should call onMenuClick when menu button is clicked', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
const menuButton = screen.getByLabelText('Open sidebar');
fireEvent.click(menuButton);
expect(mockOnMenuClick).toHaveBeenCalledTimes(1);
});
it('should have mobile-only classes on menu button', () => {
const user = createMockUser();
const { container } = renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
const menuButton = screen.getByLabelText('Open sidebar');
expect(menuButton).toHaveClass('md:hidden');
});
});
describe('Search Input', () => {
it('should render search input with correct placeholder', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
const searchInput = screen.getByPlaceholderText('Search...');
expect(searchInput).toHaveAttribute('type', 'text');
});
it('should have search icon', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
// Search icon should be present
const searchInput = screen.getByPlaceholderText('Search...');
expect(searchInput.parentElement?.querySelector('span')).toBeInTheDocument();
});
it('should allow typing in search input', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
const searchInput = screen.getByPlaceholderText('Search...') as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: 'test query' } });
expect(searchInput.value).toBe('test query');
});
it('should have focus styles on search input', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
const searchInput = screen.getByPlaceholderText('Search...');
expect(searchInput).toHaveClass('focus:outline-none', 'focus:border-brand-500');
});
});
describe('Sandbox Integration', () => {
it('should render SandboxToggle component', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
expect(screen.getByTestId('sandbox-toggle')).toBeInTheDocument();
});
it('should pass sandbox state to SandboxToggle', () => {
const user = createMockUser();
mockUseSandbox.mockReturnValue({
isSandbox: true,
sandboxEnabled: true,
toggleSandbox: vi.fn(),
isToggling: false,
});
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
expect(screen.getByText(/Sandbox: On/i)).toBeInTheDocument();
});
it('should handle sandbox toggle being disabled', () => {
const user = createMockUser();
mockUseSandbox.mockReturnValue({
isSandbox: false,
sandboxEnabled: false,
toggleSandbox: vi.fn(),
isToggling: false,
});
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
expect(screen.getByTestId('sandbox-toggle')).toBeInTheDocument();
});
});
describe('Notification Integration', () => {
it('should render NotificationDropdown', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
expect(screen.getByTestId('notification-dropdown')).toBeInTheDocument();
});
it('should pass onTicketClick to NotificationDropdown when provided', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
onTicketClick={mockOnTicketClick}
/>
);
expect(screen.getByTestId('notification-dropdown')).toBeInTheDocument();
});
it('should work without onTicketClick prop', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
expect(screen.getByTestId('notification-dropdown')).toBeInTheDocument();
});
});
describe('Language Selector Integration', () => {
it('should render LanguageSelector', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
expect(screen.getByTestId('language-selector')).toBeInTheDocument();
});
});
describe('Different User Roles', () => {
it('should render for owner role', () => {
const user = createMockUser({ role: 'owner' });
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
expect(screen.getByTestId('user-profile-dropdown')).toBeInTheDocument();
});
it('should render for manager role', () => {
const user = createMockUser({ role: 'manager' });
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
expect(screen.getByTestId('user-profile-dropdown')).toBeInTheDocument();
});
it('should render for staff role', () => {
const user = createMockUser({ role: 'staff' });
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
expect(screen.getByTestId('user-profile-dropdown')).toBeInTheDocument();
});
it('should render for platform roles', () => {
const user = createMockUser({ role: 'platform_manager' });
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
expect(screen.getByTestId('user-profile-dropdown')).toBeInTheDocument();
});
});
describe('Layout and Styling', () => {
it('should have fixed height', () => {
const user = createMockUser();
const { container } = renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
const header = container.querySelector('header');
expect(header).toHaveClass('h-16');
});
it('should have border at bottom', () => {
const user = createMockUser();
const { container } = renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
const header = container.querySelector('header');
expect(header).toHaveClass('border-b');
});
it('should use flexbox layout', () => {
const user = createMockUser();
const { container } = renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
const header = container.querySelector('header');
expect(header).toHaveClass('flex', 'items-center', 'justify-between');
});
it('should have responsive padding', () => {
const user = createMockUser();
const { container } = renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
const header = container.querySelector('header');
expect(header).toHaveClass('px-4', 'sm:px-8');
});
});
describe('Accessibility', () => {
it('should have semantic header element', () => {
const user = createMockUser();
const { container } = renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
expect(container.querySelector('header')).toBeInTheDocument();
});
it('should have proper button roles', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBeGreaterThan(0);
});
it('should have focus styles on interactive elements', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
const menuButton = screen.getByLabelText('Open sidebar');
expect(menuButton).toHaveClass('focus:outline-none', 'focus:ring-2');
});
});
describe('Responsive Behavior', () => {
it('should hide search on mobile', () => {
const user = createMockUser();
const { container } = renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
// Search container is a relative div with hidden md:block classes
const searchContainer = container.querySelector('.hidden.md\\:block');
expect(searchContainer).toBeInTheDocument();
});
it('should show menu button only on mobile', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
const menuButton = screen.getByLabelText('Open sidebar');
expect(menuButton).toHaveClass('md:hidden');
});
});
});

View File

@@ -0,0 +1,737 @@
/**
* Unit tests for TrialBanner component
*
* Tests the trial status banner that appears at the top of the business layout.
* Covers:
* - Rendering with different days remaining
* - Urgent state (3 days or less)
* - Upgrade button navigation
* - Dismiss functionality
* - Hidden states (dismissed, not active, no days left)
* - Trial end date formatting
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, within } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import TrialBanner from '../TrialBanner';
import { Business } from '../../types';
// Mock react-router-dom's useNavigate
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
...actual,
useNavigate: () => mockNavigate,
};
});
// Mock i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, params?: Record<string, unknown>) => {
// Simulate translation behavior
const translations: Record<string, string> = {
'trial.banner.title': 'Trial Active',
'trial.banner.daysLeft': `${params?.days} days left in trial`,
'trial.banner.expiresOn': `Trial expires on ${params?.date}`,
'trial.banner.upgradeNow': 'Upgrade Now',
'trial.banner.dismiss': 'Dismiss',
};
return translations[key] || key;
},
}),
}));
// Test data factory for Business objects
const createMockBusiness = (overrides?: Partial<Business>): Business => ({
id: '1',
name: 'Test Business',
subdomain: 'testbiz',
primaryColor: '#3B82F6',
secondaryColor: '#1E40AF',
whitelabelEnabled: false,
paymentsEnabled: true,
requirePaymentMethodToBook: false,
cancellationWindowHours: 24,
lateCancellationFeePercent: 50,
isTrialActive: true,
daysLeftInTrial: 10,
trialEnd: '2025-12-17T23:59:59Z',
...overrides,
});
// Wrapper component that provides router context
const renderWithRouter = (ui: React.ReactElement) => {
return render(<BrowserRouter>{ui}</BrowserRouter>);
};
describe('TrialBanner', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('should render banner with trial information when trial is active', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
trialEnd: '2025-12-17T23:59:59Z',
});
renderWithRouter(<TrialBanner business={business} />);
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
expect(screen.getByText(/10 days left in trial/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /upgrade now/i })).toBeInTheDocument();
});
it('should display the trial end date', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 5,
trialEnd: '2025-12-17T00:00:00Z',
});
renderWithRouter(<TrialBanner business={business} />);
// Check that the date is displayed (format may vary by locale)
expect(screen.getByText(/trial expires on/i)).toBeInTheDocument();
});
it('should render Sparkles icon when more than 3 days left', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 7,
});
const { container } = renderWithRouter(<TrialBanner business={business} />);
// The Sparkles icon should be rendered (not the Clock icon)
// Check for the non-urgent styling
const banner = container.querySelector('.bg-gradient-to-r.from-blue-600');
expect(banner).toBeInTheDocument();
});
it('should render Clock icon with pulse animation when 3 days or less left', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 3,
});
const { container } = renderWithRouter(<TrialBanner business={business} />);
// Check for urgent styling
const banner = container.querySelector('.bg-gradient-to-r.from-red-500');
expect(banner).toBeInTheDocument();
// Check for pulse animation on the icon
const pulsingIcon = container.querySelector('.animate-pulse');
expect(pulsingIcon).toBeInTheDocument();
});
it('should render Upgrade Now button with arrow icon', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
renderWithRouter(<TrialBanner business={business} />);
const upgradeButton = screen.getByRole('button', { name: /upgrade now/i });
expect(upgradeButton).toBeInTheDocument();
expect(upgradeButton).toHaveClass('bg-white', 'text-blue-600');
});
it('should render dismiss button with aria-label', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
renderWithRouter(<TrialBanner business={business} />);
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
expect(dismissButton).toBeInTheDocument();
expect(dismissButton).toHaveAttribute('aria-label', 'Dismiss');
});
});
describe('Urgent State (3 days or less)', () => {
it('should apply urgent styling when 3 days left', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 3,
});
const { container } = renderWithRouter(<TrialBanner business={business} />);
const banner = container.querySelector('.bg-gradient-to-r.from-red-500.to-orange-500');
expect(banner).toBeInTheDocument();
});
it('should apply urgent styling when 2 days left', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 2,
});
const { container } = renderWithRouter(<TrialBanner business={business} />);
const banner = container.querySelector('.bg-gradient-to-r.from-red-500.to-orange-500');
expect(banner).toBeInTheDocument();
});
it('should apply urgent styling when 1 day left', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 1,
});
const { container } = renderWithRouter(<TrialBanner business={business} />);
expect(screen.getByText(/1 days left in trial/i)).toBeInTheDocument();
const banner = container.querySelector('.bg-gradient-to-r.from-red-500.to-orange-500');
expect(banner).toBeInTheDocument();
});
it('should NOT apply urgent styling when 4 days left', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 4,
});
const { container } = renderWithRouter(<TrialBanner business={business} />);
const banner = container.querySelector('.bg-gradient-to-r.from-blue-600.to-blue-500');
expect(banner).toBeInTheDocument();
expect(container.querySelector('.from-red-500')).not.toBeInTheDocument();
});
});
describe('User Interactions', () => {
it('should navigate to /upgrade when Upgrade Now button is clicked', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
renderWithRouter(<TrialBanner business={business} />);
const upgradeButton = screen.getByRole('button', { name: /upgrade now/i });
fireEvent.click(upgradeButton);
expect(mockNavigate).toHaveBeenCalledWith('/upgrade');
expect(mockNavigate).toHaveBeenCalledTimes(1);
});
it('should hide banner when dismiss button is clicked', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
renderWithRouter(<TrialBanner business={business} />);
// Banner should be visible initially
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
// Click dismiss button
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
fireEvent.click(dismissButton);
// Banner should be hidden
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
});
it('should keep banner hidden after dismissing even when multiple clicks', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
renderWithRouter(<TrialBanner business={business} />);
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
fireEvent.click(dismissButton);
// Banner should remain hidden
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
});
});
describe('Hidden States', () => {
it('should not render when trial is not active', () => {
const business = createMockBusiness({
isTrialActive: false,
daysLeftInTrial: 10,
});
renderWithRouter(<TrialBanner business={business} />);
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
});
it('should not render when daysLeftInTrial is undefined', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: undefined,
});
renderWithRouter(<TrialBanner business={business} />);
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
});
it('should not render when daysLeftInTrial is 0', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 0,
});
renderWithRouter(<TrialBanner business={business} />);
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
});
it('should not render when daysLeftInTrial is null', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: null as unknown as number,
});
renderWithRouter(<TrialBanner business={business} />);
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
});
it('should not render when already dismissed', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
renderWithRouter(<TrialBanner business={business} />);
// Dismiss the banner
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
fireEvent.click(dismissButton);
// Banner should not be visible
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
});
});
describe('Edge Cases', () => {
it('should handle missing trialEnd date gracefully', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 5,
trialEnd: undefined,
});
renderWithRouter(<TrialBanner business={business} />);
// Banner should still render
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
expect(screen.getByText(/5 days left in trial/i)).toBeInTheDocument();
});
it('should handle invalid trialEnd date gracefully', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 5,
trialEnd: 'invalid-date',
});
renderWithRouter(<TrialBanner business={business} />);
// Banner should still render despite invalid date
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
});
it('should display correct styling for boundary case of exactly 3 days', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 3,
});
const { container } = renderWithRouter(<TrialBanner business={business} />);
// Should use urgent styling at exactly 3 days
const banner = container.querySelector('.bg-gradient-to-r.from-red-500');
expect(banner).toBeInTheDocument();
});
it('should handle very large number of days remaining', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 999,
});
renderWithRouter(<TrialBanner business={business} />);
expect(screen.getByText(/999 days left in trial/i)).toBeInTheDocument();
// Should use non-urgent styling
const { container } = render(<TrialBanner business={business} />, { wrapper: BrowserRouter });
const banner = container.querySelector('.bg-gradient-to-r.from-blue-600');
expect(banner).toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('should have proper button roles and labels', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
renderWithRouter(<TrialBanner business={business} />);
const upgradeButton = screen.getByRole('button', { name: /upgrade now/i });
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
expect(upgradeButton).toBeInTheDocument();
expect(dismissButton).toBeInTheDocument();
expect(dismissButton).toHaveAttribute('aria-label');
});
it('should have readable text content for screen readers', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 7,
trialEnd: '2025-12-24T23:59:59Z',
});
renderWithRouter(<TrialBanner business={business} />);
// All important text should be accessible
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
expect(screen.getByText(/7 days left in trial/i)).toBeInTheDocument();
expect(screen.getByText(/trial expires on/i)).toBeInTheDocument();
});
});
describe('Responsive Behavior', () => {
it('should render trial end date with hidden class for small screens', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
trialEnd: '2025-12-17T23:59:59Z',
});
const { container } = renderWithRouter(<TrialBanner business={business} />);
// The trial end date paragraph should have 'hidden sm:block' classes
const endDateElement = container.querySelector('.hidden.sm\\:block');
expect(endDateElement).toBeInTheDocument();
});
it('should render all key elements in the banner', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
const { container } = renderWithRouter(<TrialBanner business={business} />);
// Icon container
const iconContainer = container.querySelector('.p-2.rounded-full');
expect(iconContainer).toBeInTheDocument();
// Buttons container
const buttonsContainer = screen.getByRole('button', { name: /upgrade now/i }).parentElement;
expect(buttonsContainer).toBeInTheDocument();
});
});
describe('Component Integration', () => {
it('should work with different business configurations', () => {
const businesses = [
createMockBusiness({ daysLeftInTrial: 1, isTrialActive: true }),
createMockBusiness({ daysLeftInTrial: 7, isTrialActive: true }),
createMockBusiness({ daysLeftInTrial: 14, isTrialActive: true }),
];
businesses.forEach((business) => {
const { unmount } = renderWithRouter(<TrialBanner business={business} />);
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
unmount();
});
});
it('should maintain state across re-renders when not dismissed', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
const { rerender } = renderWithRouter(<TrialBanner business={business} />);
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
// Re-render with updated days
const updatedBusiness = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 9,
});
rerender(
<BrowserRouter>
<TrialBanner business={updatedBusiness} />
</BrowserRouter>
);
expect(screen.getByText(/9 days left in trial/i)).toBeInTheDocument();
});
it('should reset dismissed state on component unmount and remount', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
const { unmount } = renderWithRouter(<TrialBanner business={business} />);
// Dismiss the banner
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
fireEvent.click(dismissButton);
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
// Unmount and remount
unmount();
renderWithRouter(<TrialBanner business={business} />);
// Banner should reappear (dismissed state is not persisted)
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
});
});
describe('Additional Edge Cases', () => {
it('should handle negative days left gracefully', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: -5,
});
renderWithRouter(<TrialBanner business={business} />);
// Should still render (backend shouldn't send this, but defensive coding)
expect(screen.getByText(/-5 days left in trial/i)).toBeInTheDocument();
});
it('should handle fractional days by rounding', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 5.7 as number,
});
renderWithRouter(<TrialBanner business={business} />);
// Should display with the value received
expect(screen.getByText(/5.7 days left in trial/i)).toBeInTheDocument();
});
it('should transition from urgent to non-urgent styling on update', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 3,
});
const { container, rerender } = renderWithRouter(<TrialBanner business={business} />);
// Initially urgent
expect(container.querySelector('.from-red-500')).toBeInTheDocument();
// Update to non-urgent
const updatedBusiness = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
rerender(
<BrowserRouter>
<TrialBanner business={updatedBusiness} />
</BrowserRouter>
);
// Should now be non-urgent
expect(container.querySelector('.from-blue-600')).toBeInTheDocument();
expect(container.querySelector('.from-red-500')).not.toBeInTheDocument();
});
it('should handle business without name gracefully', () => {
const business = createMockBusiness({
name: '',
isTrialActive: true,
daysLeftInTrial: 10,
});
renderWithRouter(<TrialBanner business={business} />);
// Should still render the banner
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
});
it('should handle switching from active to inactive trial', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 5,
});
const { rerender } = renderWithRouter(<TrialBanner business={business} />);
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
// Update to inactive
const updatedBusiness = createMockBusiness({
isTrialActive: false,
daysLeftInTrial: 5,
});
rerender(
<BrowserRouter>
<TrialBanner business={updatedBusiness} />
</BrowserRouter>
);
// Should no longer render
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
});
});
describe('Button Interactions', () => {
it('should prevent multiple rapid clicks on upgrade button', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
renderWithRouter(<TrialBanner business={business} />);
const upgradeButton = screen.getByRole('button', { name: /upgrade now/i });
// Rapid clicks
fireEvent.click(upgradeButton);
fireEvent.click(upgradeButton);
fireEvent.click(upgradeButton);
// Navigate should still only be called once per click (no debouncing in component)
expect(mockNavigate).toHaveBeenCalledTimes(3);
});
it('should not interfere with other buttons after dismiss', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
renderWithRouter(<TrialBanner business={business} />);
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
fireEvent.click(dismissButton);
// Banner is gone
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
// Upgrade button should also be gone
expect(screen.queryByRole('button', { name: /upgrade now/i })).not.toBeInTheDocument();
});
});
describe('Visual States', () => {
it('should have shadow and proper background for visibility', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
const { container } = renderWithRouter(<TrialBanner business={business} />);
const banner = container.querySelector('.shadow-md');
expect(banner).toBeInTheDocument();
});
it('should have gradient background for visual appeal', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
const { container } = renderWithRouter(<TrialBanner business={business} />);
const gradient = container.querySelector('.bg-gradient-to-r');
expect(gradient).toBeInTheDocument();
});
it('should show hover states on interactive elements', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
renderWithRouter(<TrialBanner business={business} />);
const upgradeButton = screen.getByRole('button', { name: /upgrade now/i });
expect(upgradeButton).toHaveClass('hover:bg-blue-50');
});
it('should have appropriate spacing and padding', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
const { container } = renderWithRouter(<TrialBanner business={business} />);
// Check for padding classes
const contentContainer = container.querySelector('.py-3');
expect(contentContainer).toBeInTheDocument();
});
});
describe('Icon Rendering', () => {
it('should render icons with proper size', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
const { container } = renderWithRouter(<TrialBanner business={business} />);
// Icons should have consistent size classes
const iconContainer = container.querySelector('.rounded-full');
expect(iconContainer).toBeInTheDocument();
});
it('should show different icons for urgent vs non-urgent states', () => {
const nonUrgentBusiness = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
const { container: container1, unmount } = renderWithRouter(
<TrialBanner business={nonUrgentBusiness} />
);
// Non-urgent should not have pulse animation
expect(container1.querySelector('.animate-pulse')).not.toBeInTheDocument();
unmount();
const urgentBusiness = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 2,
});
const { container: container2 } = renderWithRouter(
<TrialBanner business={urgentBusiness} />
);
// Urgent should have pulse animation
expect(container2.querySelector('.animate-pulse')).toBeInTheDocument();
});
});
});

View File

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

View File

@@ -0,0 +1,361 @@
import React, { useState } from 'react';
import { Mail, Lock, User as UserIcon, ArrowRight, Shield } from 'lucide-react';
import toast from 'react-hot-toast';
import api from '../../api/client';
export interface User {
id: string | number;
name: string;
email: string;
}
interface AuthSectionProps {
onLogin: (user: User) => void;
}
export const AuthSection: React.FC<AuthSectionProps> = ({ onLogin }) => {
const [isLogin, setIsLogin] = useState(true);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [loading, setLoading] = useState(false);
// Email verification states
const [needsVerification, setNeedsVerification] = useState(false);
const [verificationCode, setVerificationCode] = useState('');
const [verifyingCode, setVerifyingCode] = useState(false);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const response = await api.post('/auth/login/', {
username: email,
password: password
});
const user: User = {
id: response.data.user.id,
email: response.data.user.email,
name: response.data.user.full_name || response.data.user.email,
};
toast.success('Welcome back!');
onLogin(user);
} catch (error: any) {
toast.error(error?.response?.data?.detail || 'Login failed');
} finally {
setLoading(false);
}
};
const handleSignup = async (e: React.FormEvent) => {
e.preventDefault();
// Validate passwords match
if (password !== confirmPassword) {
toast.error('Passwords do not match');
return;
}
// Validate password length
if (password.length < 8) {
toast.error('Password must be at least 8 characters');
return;
}
setLoading(true);
try {
// Send verification email
await api.post('/auth/send-verification/', {
email: email,
first_name: firstName,
last_name: lastName
});
toast.success('Verification code sent to your email!');
setNeedsVerification(true);
} catch (error: any) {
toast.error(error?.response?.data?.detail || 'Failed to send verification code');
} finally {
setLoading(false);
}
};
const handleVerifyCode = async (e: React.FormEvent) => {
e.preventDefault();
setVerifyingCode(true);
try {
// Verify code and create account
const response = await api.post('/auth/verify-and-register/', {
email: email,
first_name: firstName,
last_name: lastName,
password: password,
verification_code: verificationCode
});
const user: User = {
id: response.data.user.id,
email: response.data.user.email,
name: response.data.user.full_name || response.data.user.name,
};
toast.success('Account created successfully!');
onLogin(user);
} catch (error: any) {
toast.error(error?.response?.data?.detail || 'Verification failed');
} finally {
setVerifyingCode(false);
}
};
const handleResendCode = async () => {
setLoading(true);
try {
await api.post('/auth/send-verification/', {
email: email,
first_name: firstName,
last_name: lastName
});
toast.success('New code sent!');
} catch (error: any) {
toast.error('Failed to resend code');
} finally {
setLoading(false);
}
};
const handleSubmit = (e: React.FormEvent) => {
if (isLogin) {
handleLogin(e);
} else {
handleSignup(e);
}
};
// Show verification step for new customers
if (needsVerification && !isLogin) {
return (
<div className="max-w-md mx-auto">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-indigo-100 dark:bg-indigo-900/50 rounded-full flex items-center justify-center mx-auto mb-4">
<Shield className="w-8 h-8 text-indigo-600 dark:text-indigo-400" />
</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Verify Your Email</h2>
<p className="text-gray-500 dark:text-gray-400 mt-2">
We've sent a 6-digit code to <span className="font-medium text-gray-900 dark:text-white">{email}</span>
</p>
</div>
<div className="bg-white dark:bg-gray-800 p-8 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700">
<form onSubmit={handleVerifyCode} className="space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Verification Code
</label>
<input
type="text"
required
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
className="block w-full px-4 py-3 text-center text-2xl font-mono tracking-widest border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
placeholder="000000"
maxLength={6}
autoFocus
/>
</div>
<button
type="submit"
disabled={verifyingCode || verificationCode.length !== 6}
className="w-full flex justify-center items-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800 disabled:opacity-70 disabled:cursor-not-allowed transition-all"
>
{verifyingCode ? (
<span className="animate-pulse">Verifying...</span>
) : (
<>
Verify & Continue
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</button>
</form>
<div className="mt-6 text-center space-y-2">
<button
type="button"
onClick={handleResendCode}
disabled={loading}
className="text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-500 dark:hover:text-indigo-300 disabled:opacity-50"
>
Resend Code
</button>
<div>
<button
type="button"
onClick={() => {
setNeedsVerification(false);
setVerificationCode('');
}}
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
>
Change email address
</button>
</div>
</div>
</div>
</div>
);
}
return (
<div className="max-w-md mx-auto">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
{isLogin ? 'Welcome Back' : 'Create Account'}
</h2>
<p className="text-gray-500 dark:text-gray-400 mt-2">
{isLogin
? 'Sign in to access your bookings and history.'
: 'Join us to book your first premium service.'}
</p>
</div>
<div className="bg-white dark:bg-gray-800 p-8 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700">
<form onSubmit={handleSubmit} className="space-y-5">
{!isLogin && (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">First Name</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<UserIcon className="h-5 w-5 text-gray-400 dark:text-gray-500" />
</div>
<input
type="text"
required={!isLogin}
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
className="block w-full pl-10 pr-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
placeholder="John"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Last Name</label>
<input
type="text"
required={!isLogin}
value={lastName}
onChange={(e) => setLastName(e.target.value)}
className="block w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
placeholder="Doe"
/>
</div>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email Address</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Mail className="h-5 w-5 text-gray-400 dark:text-gray-500" />
</div>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="block w-full pl-10 pr-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
placeholder="you@example.com"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Password</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400 dark:text-gray-500" />
</div>
<input
type="password"
required
minLength={isLogin ? undefined : 8}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="block w-full pl-10 pr-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
placeholder="••••••••"
/>
</div>
{!isLogin && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">Must be at least 8 characters</p>
)}
</div>
{!isLogin && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Confirm Password</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400 dark:text-gray-500" />
</div>
<input
type="password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className={`block w-full pl-10 pr-3 py-2.5 border rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-indigo-500 focus:border-indigo-500 transition-colors ${
confirmPassword && password !== confirmPassword
? 'border-red-300 dark:border-red-500'
: 'border-gray-300 dark:border-gray-600'
}`}
placeholder="••••••••"
/>
</div>
{confirmPassword && password !== confirmPassword && (
<p className="mt-1 text-xs text-red-500">Passwords do not match</p>
)}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full flex justify-center items-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800 disabled:opacity-70 disabled:cursor-not-allowed transition-all"
>
{loading ? (
<span className="animate-pulse">Processing...</span>
) : (
<>
{isLogin ? 'Sign In' : 'Create Account'}
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</button>
</form>
<div className="mt-6 text-center">
<button
type="button"
onClick={() => {
setIsLogin(!isLogin);
setConfirmPassword('');
setFirstName('');
setLastName('');
}}
className="text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-500 dark:hover:text-indigo-300"
>
{isLogin ? "Don't have an account? Sign up" : 'Already have an account? Sign in'}
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,69 @@
import React, { useState } from 'react';
import { usePublicServices, useCreateBooking } from '../../hooks/useBooking';
import { Loader2 } from 'lucide-react';
interface BookingWidgetProps {
headline?: string;
subheading?: string;
accentColor?: string;
buttonLabel?: string;
}
export const BookingWidget: React.FC<BookingWidgetProps> = ({
headline = "Book Appointment",
subheading = "Select a service",
accentColor = "#2563eb",
buttonLabel = "Book Now"
}) => {
const { data: services, isLoading } = usePublicServices();
const createBooking = useCreateBooking();
const [selectedService, setSelectedService] = useState<any>(null);
if (isLoading) return <div className="flex justify-center"><Loader2 className="animate-spin" /></div>;
const handleBook = async () => {
if (!selectedService) return;
try {
await createBooking.mutateAsync({ service_id: selectedService.id });
alert("Booking created (stub)!");
} catch (e) {
console.error(e);
alert("Error creating booking");
}
};
return (
<div className="booking-widget p-6 bg-white dark:bg-gray-800 rounded-lg shadow-md dark:shadow-gray-900/30 max-w-md mx-auto text-left border border-gray-100 dark:border-gray-700">
<h2 className="text-2xl font-bold mb-2 text-indigo-600 dark:text-indigo-400">{headline}</h2>
<p className="text-gray-600 dark:text-gray-300 mb-6">{subheading}</p>
<div className="space-y-4 mb-6">
{services?.length === 0 && <p className="text-gray-600 dark:text-gray-400">No services available.</p>}
{services?.map((service: any) => (
<div
key={service.id}
className={`p-4 border rounded-lg cursor-pointer transition-all ${
selectedService?.id === service.id
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20 dark:border-indigo-400'
: 'border-gray-200 dark:border-gray-700 hover:border-indigo-300 dark:hover:border-indigo-600 bg-white dark:bg-gray-700/50'
}`}
onClick={() => setSelectedService(service)}
>
<h3 className="font-semibold text-gray-900 dark:text-white">{service.name}</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">{service.duration} min - ${(service.price_cents / 100).toFixed(2)}</p>
</div>
))}
</div>
<button
onClick={handleBook}
disabled={!selectedService}
className="w-full py-3 px-4 rounded-lg bg-indigo-600 dark:bg-indigo-500 text-white font-semibold disabled:opacity-50 hover:bg-indigo-700 dark:hover:bg-indigo-600 transition-all shadow-sm hover:shadow-md"
>
{buttonLabel}
</button>
</div>
);
};
export default BookingWidget;

View File

@@ -0,0 +1,113 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { CheckCircle, Calendar, MapPin, ArrowRight } from 'lucide-react';
import { PublicService } from '../../hooks/useBooking';
import { User } from './AuthSection';
interface BookingState {
step: number;
service: PublicService | null;
date: Date | null;
timeSlot: string | null;
user: User | null;
paymentMethod: string | null;
}
interface ConfirmationProps {
booking: BookingState;
}
export const Confirmation: React.FC<ConfirmationProps> = ({ booking }) => {
const navigate = useNavigate();
if (!booking.service || !booking.date || !booking.timeSlot) return null;
// Generate a pseudo-random booking reference based on timestamp
const bookingRef = `BK-${Date.now().toString().slice(-6)}`;
return (
<div className="text-center max-w-2xl mx-auto py-10">
<div className="mb-6 flex justify-center">
<div className="h-24 w-24 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center">
<CheckCircle className="h-12 w-12 text-green-600 dark:text-green-400" />
</div>
</div>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">Booking Confirmed!</h2>
<p className="text-lg text-gray-600 dark:text-gray-300 mb-8">
Thank you, {booking.user?.name}. Your appointment has been successfully scheduled.
</p>
<div className="bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden text-left">
<div className="bg-gray-50 dark:bg-gray-700 px-6 py-4 border-b border-gray-200 dark:border-gray-600">
<h3 className="font-semibold text-gray-900 dark:text-white">Booking Details</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider mt-1">Ref: #{bookingRef}</p>
</div>
<div className="p-6 space-y-4">
<div className="flex items-start">
<div className="h-12 w-12 rounded-lg bg-indigo-100 dark:bg-indigo-900/50 flex items-center justify-center flex-shrink-0 mr-4">
{booking.service.photos && booking.service.photos.length > 0 ? (
<img src={booking.service.photos[0]} className="h-12 w-12 rounded-lg object-cover" alt="" />
) : (
<div className="h-12 w-12 rounded-lg bg-indigo-200 dark:bg-indigo-800" />
)}
</div>
<div>
<h4 className="font-medium text-gray-900 dark:text-white">{booking.service.name}</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">{booking.service.duration} minutes</p>
</div>
<div className="ml-auto text-right">
<p className="font-medium text-gray-900 dark:text-white">${(booking.service.price_cents / 100).toFixed(2)}</p>
{booking.service.deposit_amount_cents && booking.service.deposit_amount_cents > 0 && (
<p className="text-xs text-green-600 dark:text-green-400 font-medium">Deposit Paid</p>
)}
</div>
</div>
<div className="border-t border-gray-100 dark:border-gray-700 pt-4 flex flex-col sm:flex-row sm:justify-between gap-4">
<div className="flex items-center text-gray-700 dark:text-gray-300">
<Calendar className="w-5 h-5 mr-3 text-indigo-500 dark:text-indigo-400" />
<div>
<p className="text-sm font-medium">Date & Time</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{booking.date.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })} at {booking.timeSlot}
</p>
</div>
</div>
<div className="flex items-center text-gray-700 dark:text-gray-300">
<MapPin className="w-5 h-5 mr-3 text-indigo-500 dark:text-indigo-400" />
<div>
<p className="text-sm font-medium">Location</p>
<p className="text-sm text-gray-500 dark:text-gray-400">See confirmation email</p>
</div>
</div>
</div>
</div>
</div>
<p className="mt-6 text-sm text-gray-500 dark:text-gray-400">
A confirmation email has been sent to {booking.user?.email}.
</p>
<div className="mt-8 flex justify-center space-x-4">
<button
onClick={() => navigate('/')}
className="flex items-center px-6 py-3 bg-indigo-600 dark:bg-indigo-500 text-white rounded-lg hover:bg-indigo-700 dark:hover:bg-indigo-600 transition-colors shadow-lg"
>
Done
<ArrowRight className="w-4 h-4 ml-2" />
</button>
<button
onClick={() => {
// Clear booking state and start fresh
sessionStorage.removeItem('booking_state');
navigate('/book');
}}
className="px-6 py-3 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
Book Another
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,276 @@
import React, { useMemo } from 'react';
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Loader2, XCircle } from 'lucide-react';
import { usePublicAvailability, usePublicBusinessHours } from '../../hooks/useBooking';
import { formatTimeForDisplay, getTimezoneAbbreviation, getUserTimezone } from '../../utils/dateUtils';
interface DateTimeSelectionProps {
serviceId?: number;
selectedDate: Date | null;
selectedTimeSlot: string | null;
onDateChange: (date: Date) => void;
onTimeChange: (time: string) => void;
}
export const DateTimeSelection: React.FC<DateTimeSelectionProps> = ({
serviceId,
selectedDate,
selectedTimeSlot,
onDateChange,
onTimeChange
}) => {
const today = new Date();
const [currentMonth, setCurrentMonth] = React.useState(today.getMonth());
const [currentYear, setCurrentYear] = React.useState(today.getFullYear());
// Calculate date range for business hours query (current month view)
const { startDate, endDate } = useMemo(() => {
const start = new Date(currentYear, currentMonth, 1);
const end = new Date(currentYear, currentMonth + 1, 0);
return {
startDate: `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, '0')}-01`,
endDate: `${end.getFullYear()}-${String(end.getMonth() + 1).padStart(2, '0')}-${String(end.getDate()).padStart(2, '0')}`
};
}, [currentMonth, currentYear]);
// Fetch business hours for the month
const { data: businessHours, isLoading: businessHoursLoading } = usePublicBusinessHours(startDate, endDate);
// Create a map of dates to their open status
const openDaysMap = useMemo(() => {
const map = new Map<string, boolean>();
if (businessHours?.dates) {
businessHours.dates.forEach(day => {
map.set(day.date, day.is_open);
});
}
return map;
}, [businessHours]);
// Format selected date for API query (YYYY-MM-DD)
const dateString = selectedDate
? `${selectedDate.getFullYear()}-${String(selectedDate.getMonth() + 1).padStart(2, '0')}-${String(selectedDate.getDate()).padStart(2, '0')}`
: undefined;
// Fetch availability when both serviceId and date are set
const { data: availability, isLoading: availabilityLoading, isError, error } = usePublicAvailability(serviceId, dateString);
const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate();
const firstDayOfMonth = new Date(currentYear, currentMonth, 1).getDay();
const handlePrevMonth = () => {
if (currentMonth === 0) {
setCurrentMonth(11);
setCurrentYear(currentYear - 1);
} else {
setCurrentMonth(currentMonth - 1);
}
};
const handleNextMonth = () => {
if (currentMonth === 11) {
setCurrentMonth(0);
setCurrentYear(currentYear + 1);
} else {
setCurrentMonth(currentMonth + 1);
}
};
const days = Array.from({ length: daysInMonth }, (_, i) => i + 1);
const monthName = new Date(currentYear, currentMonth).toLocaleString('default', { month: 'long' });
const isSelected = (day: number) => {
return selectedDate?.getDate() === day &&
selectedDate?.getMonth() === currentMonth &&
selectedDate?.getFullYear() === currentYear;
};
const isPast = (day: number) => {
const d = new Date(currentYear, currentMonth, day);
const now = new Date();
now.setHours(0, 0, 0, 0);
return d < now;
};
const isClosed = (day: number) => {
const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
// If we have business hours data, use it. Otherwise default to open (except past dates)
if (openDaysMap.size > 0) {
return openDaysMap.get(dateStr) === false;
}
return false;
};
const isDisabled = (day: number) => {
return isPast(day) || isClosed(day);
};
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Calendar Section */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 shadow-sm">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
<CalendarIcon className="w-5 h-5 mr-2 text-indigo-600 dark:text-indigo-400" />
Select Date
</h3>
<div className="flex space-x-2">
<button onClick={handlePrevMonth} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full text-gray-600 dark:text-gray-400">
<ChevronLeft className="w-5 h-5" />
</button>
<span className="font-medium text-gray-900 dark:text-white w-32 text-center">
{monthName} {currentYear}
</span>
<button onClick={handleNextMonth} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full text-gray-600 dark:text-gray-400">
<ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
<div className="grid grid-cols-7 gap-2 mb-2 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
<div>Sun</div><div>Mon</div><div>Tue</div><div>Wed</div><div>Thu</div><div>Fri</div><div>Sat</div>
</div>
{businessHoursLoading ? (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-6 h-6 animate-spin text-indigo-600 dark:text-indigo-400" />
</div>
) : (
<div className="grid grid-cols-7 gap-2">
{Array.from({ length: firstDayOfMonth }).map((_, i) => (
<div key={`empty-${i}`} />
))}
{days.map((day) => {
const past = isPast(day);
const closed = isClosed(day);
const disabled = isDisabled(day);
const selected = isSelected(day);
return (
<button
key={day}
disabled={disabled}
onClick={() => {
const newDate = new Date(currentYear, currentMonth, day);
onDateChange(newDate);
}}
className={`
h-10 w-10 rounded-full flex items-center justify-center text-sm font-medium transition-all relative
${selected
? 'bg-indigo-600 dark:bg-indigo-500 text-white shadow-md'
: closed
? 'bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed'
: past
? 'text-gray-300 dark:text-gray-600 cursor-not-allowed'
: 'text-gray-700 dark:text-gray-200 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 hover:text-indigo-600 dark:hover:text-indigo-400'
}
`}
title={closed ? 'Business closed' : past ? 'Past date' : undefined}
>
{day}
</button>
);
})}
</div>
)}
{/* Legend */}
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700 flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-gray-100 dark:bg-gray-700"></div>
<span>Closed</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-indigo-600 dark:bg-indigo-500"></div>
<span>Selected</span>
</div>
</div>
</div>
{/* Time Slots Section */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 shadow-sm flex flex-col">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-6">Available Time Slots</h3>
{!selectedDate ? (
<div className="flex-1 flex items-center justify-center text-gray-400 dark:text-gray-500 italic">
Please select a date first
</div>
) : availabilityLoading ? (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin text-indigo-600 dark:text-indigo-400" />
</div>
) : isError ? (
<div className="flex-1 flex flex-col items-center justify-center text-red-500 dark:text-red-400">
<XCircle className="w-12 h-12 mb-3" />
<p className="font-medium">Failed to load availability</p>
<p className="text-sm mt-1 text-gray-500 dark:text-gray-400">
{error instanceof Error ? error.message : 'Please try again'}
</p>
</div>
) : availability?.is_open === false ? (
<div className="flex-1 flex flex-col items-center justify-center text-gray-400 dark:text-gray-500">
<XCircle className="w-12 h-12 mb-3 text-gray-300 dark:text-gray-600" />
<p className="font-medium">Business Closed</p>
<p className="text-sm mt-1">Please select another date</p>
</div>
) : availability?.slots && availability.slots.length > 0 ? (
<>
{(() => {
// Determine which timezone to display based on business settings
const displayTimezone = availability.timezone_display_mode === 'viewer'
? getUserTimezone()
: availability.business_timezone || getUserTimezone();
const tzAbbrev = getTimezoneAbbreviation(displayTimezone);
return (
<>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
{availability.business_hours && (
<>Business hours: {availability.business_hours.start} - {availability.business_hours.end} </>
)}
Times shown in {tzAbbrev}
</p>
<div className="grid grid-cols-2 gap-3">
{availability.slots.map((slot) => {
// Format time in the appropriate timezone
const displayTime = formatTimeForDisplay(
slot.time,
availability.timezone_display_mode === 'viewer' ? null : availability.business_timezone
);
return (
<button
key={slot.time}
disabled={!slot.available}
onClick={() => onTimeChange(displayTime)}
className={`
py-3 px-4 rounded-lg text-sm font-medium border transition-all duration-200
${!slot.available
? 'bg-gray-50 dark:bg-gray-700 text-gray-400 dark:text-gray-500 border-gray-100 dark:border-gray-600 cursor-not-allowed'
: selectedTimeSlot === displayTime
? 'bg-indigo-600 dark:bg-indigo-500 text-white border-indigo-600 dark:border-indigo-500 shadow-sm'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 border-gray-200 dark:border-gray-600 hover:border-indigo-500 dark:hover:border-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-400'
}
`}
>
{displayTime}
{!slot.available && <span className="block text-[10px] font-normal">Booked</span>}
</button>
);
})}
</div>
</>
);
})()}
</>
) : !serviceId ? (
<div className="flex-1 flex items-center justify-center text-gray-400 dark:text-gray-500 italic">
Please select a service first
</div>
) : (
<div className="flex-1 flex items-center justify-center text-gray-400 dark:text-gray-500 italic">
No available time slots for this date
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,134 @@
import React, { useState, useRef, useEffect } from 'react';
import { MessageCircle, X, Send, Sparkles } from 'lucide-react';
import { BookingState, ChatMessage } from './types';
// TODO: Implement Gemini service
const sendMessageToGemini = async (message: string, bookingState: BookingState): Promise<string> => {
// Mock implementation - replace with actual Gemini API call
return "I'm here to help you book your appointment. Please use the booking form above.";
};
interface GeminiChatProps {
currentBookingState: BookingState;
}
export const GeminiChat: React.FC<GeminiChatProps> = ({ currentBookingState }) => {
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>([
{ role: 'model', text: 'Hi! I can help you choose a service or answer questions about booking.' }
]);
const [inputText, setInputText] = useState('');
const [isLoading, setIsLoading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages, isOpen]);
const handleSend = async () => {
if (!inputText.trim() || isLoading) return;
const userMsg: ChatMessage = { role: 'user', text: inputText };
setMessages(prev => [...prev, userMsg]);
setInputText('');
setIsLoading(true);
try {
const responseText = await sendMessageToGemini(inputText, messages, currentBookingState);
setMessages(prev => [...prev, { role: 'model', text: responseText }]);
} catch (error) {
setMessages(prev => [...prev, { role: 'model', text: "Sorry, I'm having trouble connecting." }]);
} finally {
setIsLoading(false);
}
};
return (
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-end">
{/* Chat Window */}
{isOpen && (
<div className="bg-white w-80 sm:w-96 h-[500px] rounded-2xl shadow-2xl border border-gray-200 flex flex-col overflow-hidden mb-4 animate-in slide-in-from-bottom-10 fade-in duration-200">
<div className="bg-indigo-600 p-4 flex justify-between items-center text-white">
<div className="flex items-center space-x-2">
<Sparkles className="w-4 h-4" />
<span className="font-semibold">Lumina Assistant</span>
</div>
<button onClick={() => setIsOpen(false)} className="hover:bg-indigo-500 rounded-full p-1 transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-gray-50 scrollbar-hide">
{messages.map((msg, idx) => (
<div
key={idx}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`
max-w-[80%] px-4 py-2 rounded-2xl text-sm
${msg.role === 'user'
? 'bg-indigo-600 text-white rounded-br-none'
: 'bg-white text-gray-800 border border-gray-200 shadow-sm rounded-bl-none'
}
`}
>
{msg.text}
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="bg-white px-4 py-2 rounded-2xl rounded-bl-none border border-gray-200 shadow-sm">
<div className="flex space-x-1">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{animationDelay: '0.1s'}}></div>
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{animationDelay: '0.2s'}}></div>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="p-3 bg-white border-t border-gray-100">
<form
onSubmit={(e) => { e.preventDefault(); handleSend(); }}
className="flex items-center gap-2"
>
<input
type="text"
value={inputText}
onChange={(e) => setInputText(e.target.value)}
placeholder="Ask about services..."
className="flex-1 px-4 py-2 rounded-full border border-gray-300 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 text-sm"
/>
<button
type="submit"
disabled={isLoading || !inputText.trim()}
className="p-2 bg-indigo-600 text-white rounded-full hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Send className="w-4 h-4" />
</button>
</form>
</div>
</div>
)}
{/* Toggle Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className={`
p-4 rounded-full shadow-xl transition-all duration-300 flex items-center justify-center
${isOpen ? 'bg-gray-800 rotate-90 scale-0' : 'bg-indigo-600 hover:bg-indigo-700 scale-100'}
`}
style={{display: isOpen ? 'none' : 'flex'}}
>
<MessageCircle className="w-6 h-6 text-white" />
</button>
</div>
);
};

View File

@@ -0,0 +1,159 @@
import React, { useState } from 'react';
import { PublicService } from '../../hooks/useBooking';
import { CreditCard, ShieldCheck, Lock } from 'lucide-react';
interface PaymentSectionProps {
service: PublicService;
onPaymentComplete: () => void;
}
export const PaymentSection: React.FC<PaymentSectionProps> = ({ service, onPaymentComplete }) => {
const [processing, setProcessing] = useState(false);
const [cardNumber, setCardNumber] = useState('');
const [expiry, setExpiry] = useState('');
const [cvc, setCvc] = useState('');
// Convert cents to dollars
const price = service.price_cents / 100;
const deposit = (service.deposit_amount_cents || 0) / 100;
// Auto-format card number
const handleCardInput = (e: React.ChangeEvent<HTMLInputElement>) => {
let val = e.target.value.replace(/\D/g, '');
val = val.substring(0, 16);
val = val.replace(/(\d{4})/g, '$1 ').trim();
setCardNumber(val);
};
const handlePayment = (e: React.FormEvent) => {
e.preventDefault();
setProcessing(true);
// Simulate Stripe Payment Intent & Processing
setTimeout(() => {
setProcessing(false);
onPaymentComplete();
}, 2000);
};
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Payment Details Column */}
<div className="lg:col-span-2 space-y-6">
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
<CreditCard className="w-5 h-5 mr-2 text-indigo-600 dark:text-indigo-400" />
Card Details
</h3>
<div className="flex space-x-2">
{/* Mock Card Icons */}
<div className="h-6 w-10 bg-gray-200 dark:bg-gray-600 rounded"></div>
<div className="h-6 w-10 bg-gray-200 dark:bg-gray-600 rounded"></div>
<div className="h-6 w-10 bg-gray-200 dark:bg-gray-600 rounded"></div>
</div>
</div>
<form id="payment-form" onSubmit={handlePayment} className="space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Card Number</label>
<input
type="text"
required
value={cardNumber}
onChange={handleCardInput}
placeholder="0000 0000 0000 0000"
className="block w-full px-3 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-indigo-500 focus:border-indigo-500 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 font-mono"
/>
</div>
<div className="grid grid-cols-2 gap-5">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Expiry Date</label>
<input
type="text"
required
value={expiry}
onChange={(e) => setExpiry(e.target.value)}
placeholder="MM / YY"
className="block w-full px-3 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-indigo-500 focus:border-indigo-500 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 font-mono"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">CVC</label>
<div className="relative">
<input
type="text"
required
value={cvc}
onChange={(e) => setCvc(e.target.value)}
placeholder="123"
className="block w-full px-3 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-indigo-500 focus:border-indigo-500 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 font-mono"
/>
<Lock className="w-4 h-4 text-gray-400 dark:text-gray-500 absolute right-3 top-3.5" />
</div>
</div>
</div>
<div className="mt-4 flex items-start p-4 bg-indigo-50 dark:bg-indigo-900/30 rounded-lg">
<ShieldCheck className="w-5 h-5 text-indigo-600 dark:text-indigo-400 mt-0.5 mr-3 flex-shrink-0" />
<p className="text-sm text-indigo-800 dark:text-indigo-200">
Your payment is secure. We use Stripe to process your payment. {deposit > 0 ? <>A deposit of <strong>${deposit.toFixed(2)}</strong> will be charged now.</> : <>Full payment will be collected at your appointment.</>}
</p>
</div>
</form>
</div>
</div>
{/* Summary Column */}
<div className="lg:col-span-1">
<div className="bg-gray-50 dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 sticky top-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Payment Summary</h3>
<div className="space-y-3 text-sm">
<div className="flex justify-between text-gray-600 dark:text-gray-400">
<span>Service Total</span>
<span>${price.toFixed(2)}</span>
</div>
<div className="flex justify-between text-gray-600 dark:text-gray-400">
<span>Tax (Estimated)</span>
<span>$0.00</span>
</div>
<div className="border-t border-gray-200 dark:border-gray-600 my-2 pt-2"></div>
<div className="flex justify-between items-center text-lg font-bold text-gray-900 dark:text-white">
<span>Total</span>
<span>${price.toFixed(2)}</span>
</div>
</div>
{deposit > 0 ? (
<div className="mt-6 bg-white dark:bg-gray-700 p-4 rounded-lg border border-gray-200 dark:border-gray-600 shadow-sm">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium text-gray-900 dark:text-white">Due Now (Deposit)</span>
<span className="text-lg font-bold text-indigo-600 dark:text-indigo-400">${deposit.toFixed(2)}</span>
</div>
<div className="flex justify-between items-center text-sm text-gray-500 dark:text-gray-400">
<span>Due at appointment</span>
<span>${(price - deposit).toFixed(2)}</span>
</div>
</div>
) : (
<div className="mt-6 bg-white dark:bg-gray-700 p-4 rounded-lg border border-gray-200 dark:border-gray-600 shadow-sm">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-gray-900 dark:text-white">Due at appointment</span>
<span className="text-lg font-bold text-indigo-600 dark:text-indigo-400">${price.toFixed(2)}</span>
</div>
</div>
)}
<button
type="submit"
form="payment-form"
disabled={processing}
className="w-full mt-6 py-3 px-4 bg-indigo-600 dark:bg-indigo-500 text-white rounded-lg font-semibold shadow-md hover:bg-indigo-700 dark:hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 disabled:opacity-75 disabled:cursor-not-allowed transition-all"
>
{processing ? 'Processing...' : deposit > 0 ? `Pay $${deposit.toFixed(2)} Deposit` : 'Confirm Booking'}
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,114 @@
import React from 'react';
import { Clock, DollarSign, Loader2 } from 'lucide-react';
import { usePublicServices, usePublicBusinessInfo, PublicService } from '../../hooks/useBooking';
interface ServiceSelectionProps {
selectedService: PublicService | null;
onSelect: (service: PublicService) => void;
}
export const ServiceSelection: React.FC<ServiceSelectionProps> = ({ selectedService, onSelect }) => {
const { data: services, isLoading: servicesLoading } = usePublicServices();
const { data: businessInfo, isLoading: businessLoading } = usePublicBusinessInfo();
const isLoading = servicesLoading || businessLoading;
if (isLoading) {
return (
<div className="flex justify-center items-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-indigo-600 dark:text-indigo-400" />
</div>
);
}
const heading = businessInfo?.service_selection_heading || 'Choose your experience';
const subheading = businessInfo?.service_selection_subheading || 'Select a service to begin your booking.';
// Get first photo as image, or use a placeholder
const getServiceImage = (service: PublicService): string | null => {
if (service.photos && service.photos.length > 0) {
return service.photos[0];
}
return null;
};
// Format price from cents to dollars
const formatPrice = (cents: number): string => {
return (cents / 100).toFixed(2);
};
return (
<div className="space-y-6">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{heading}</h2>
<p className="text-gray-500 dark:text-gray-400 mt-2">{subheading}</p>
</div>
{(!services || services.length === 0) && (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No services available at this time.
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{services?.map((service) => {
const image = getServiceImage(service);
const hasImage = !!image;
return (
<div
key={service.id}
onClick={() => onSelect(service)}
className={`
relative overflow-hidden rounded-xl border-2 transition-all duration-200 cursor-pointer group
${selectedService?.id === service.id
? 'border-indigo-600 dark:border-indigo-400 bg-indigo-50/50 dark:bg-indigo-900/20 ring-2 ring-indigo-600 dark:ring-indigo-400 ring-offset-2 dark:ring-offset-gray-900'
: 'border-gray-200 dark:border-gray-700 hover:border-indigo-300 dark:hover:border-indigo-600 hover:shadow-lg bg-white dark:bg-gray-800'}
`}
>
<div className="flex h-full min-h-[140px]">
{hasImage && (
<div className="w-1/3 bg-gray-100 dark:bg-gray-700 relative">
<img
src={image}
alt={service.name}
className="absolute inset-0 w-full h-full object-cover"
/>
</div>
)}
<div className={`${hasImage ? 'w-2/3' : 'w-full'} p-5 flex flex-col justify-between`}>
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{service.name}
</h3>
{service.description && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
{service.description}
</p>
)}
</div>
<div className="mt-4 flex items-center justify-between text-sm">
<div className="flex items-center text-gray-600 dark:text-gray-400">
<Clock className="w-4 h-4 mr-1.5" />
{service.duration} mins
</div>
<div className="flex items-center font-semibold text-gray-900 dark:text-white">
<DollarSign className="w-4 h-4" />
{formatPrice(service.price_cents)}
</div>
</div>
{service.deposit_amount_cents && service.deposit_amount_cents > 0 && (
<div className="mt-2 text-xs text-indigo-600 dark:text-indigo-400 font-medium">
Deposit required: ${formatPrice(service.deposit_amount_cents)}
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
);
};

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { Check } from 'lucide-react';
interface StepsProps {
currentStep: number;
}
const steps = [
{ id: 1, name: 'Service' },
{ id: 2, name: 'Date & Time' },
{ id: 3, name: 'Account' },
{ id: 4, name: 'Payment' },
{ id: 5, name: 'Done' },
];
export const Steps: React.FC<StepsProps> = ({ currentStep }) => {
return (
<nav aria-label="Progress">
<ol role="list" className="flex items-center">
{steps.map((step, stepIdx) => (
<li key={step.name} className={`${stepIdx !== steps.length - 1 ? 'pr-8 sm:pr-20' : ''} relative`}>
{step.id < currentStep ? (
<>
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="h-0.5 w-full bg-indigo-600 dark:bg-indigo-500" />
</div>
<a href="#" className="relative flex h-8 w-8 items-center justify-center rounded-full bg-indigo-600 dark:bg-indigo-500 hover:bg-indigo-700 dark:hover:bg-indigo-600">
<Check className="h-5 w-5 text-white" aria-hidden="true" />
<span className="sr-only">{step.name}</span>
</a>
</>
) : step.id === currentStep ? (
<>
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="h-0.5 w-full bg-gray-200 dark:bg-gray-700" />
</div>
<a href="#" className="relative flex h-8 w-8 items-center justify-center rounded-full border-2 border-indigo-600 dark:border-indigo-400 bg-white dark:bg-gray-800" aria-current="step">
<span className="h-2.5 w-2.5 rounded-full bg-indigo-600 dark:bg-indigo-400" aria-hidden="true" />
<span className="sr-only">{step.name}</span>
</a>
</>
) : (
<>
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="h-0.5 w-full bg-gray-200 dark:bg-gray-700" />
</div>
<a href="#" className="group relative flex h-8 w-8 items-center justify-center rounded-full border-2 border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 hover:border-gray-400 dark:hover:border-gray-500">
<span className="h-2.5 w-2.5 rounded-full bg-transparent group-hover:bg-gray-300 dark:group-hover:bg-gray-600" aria-hidden="true" />
<span className="sr-only">{step.name}</span>
</a>
</>
)}
<div className="absolute -bottom-6 left-1/2 transform -translate-x-1/2 w-max text-xs font-medium text-gray-500 dark:text-gray-400">
{step.name}
</div>
</li>
))}
</ol>
</nav>
);
};

View File

@@ -0,0 +1,61 @@
import { Service, TimeSlot } from './types';
// Mock services for booking flow
// TODO: In production, these should be fetched from the API
export const SERVICES: Service[] = [
{
id: 's1',
name: 'Rejuvenating Facial',
description: 'A 60-minute deep cleansing and hydrating facial treatment.',
durationMin: 60,
price: 120,
deposit: 30,
category: 'Skincare',
image: 'https://picsum.photos/400/300?random=1'
},
{
id: 's2',
name: 'Deep Tissue Massage',
description: 'Therapeutic massage focusing on realigning deeper layers of muscles.',
durationMin: 90,
price: 150,
deposit: 50,
category: 'Massage',
image: 'https://picsum.photos/400/300?random=2'
},
{
id: 's3',
name: 'Executive Haircut',
description: 'Precision haircut with wash, style, and hot towel finish.',
durationMin: 45,
price: 65,
deposit: 15,
category: 'Hair',
image: 'https://picsum.photos/400/300?random=3'
},
{
id: 's4',
name: 'Full Body Scrub',
description: 'Exfoliating treatment to remove dead skin cells and improve circulation.',
durationMin: 60,
price: 110,
deposit: 25,
category: 'Body',
image: 'https://picsum.photos/400/300?random=4'
}
];
// Mock time slots
// TODO: In production, these should be fetched from the availability API
export const TIME_SLOTS: TimeSlot[] = [
{ id: 't1', time: '09:00 AM', available: true },
{ id: 't2', time: '10:00 AM', available: true },
{ id: 't3', time: '11:00 AM', available: false },
{ id: 't4', time: '01:00 PM', available: true },
{ id: 't5', time: '02:00 PM', available: true },
{ id: 't6', time: '03:00 PM', available: true },
{ id: 't7', time: '04:00 PM', available: false },
{ id: 't8', time: '05:00 PM', available: true },
];
export const APP_NAME = "SmoothSchedule";

View File

@@ -0,0 +1,36 @@
export interface Service {
id: string;
name: string;
description: string;
durationMin: number;
price: number;
deposit: number;
image: string;
category: string;
}
export interface User {
id: string;
name: string;
email: string;
}
export interface TimeSlot {
id: string;
time: string; // "09:00 AM"
available: boolean;
}
export interface BookingState {
step: number;
service: Service | null;
date: Date | null;
timeSlot: string | null;
user: User | null;
paymentMethod: string | null;
}
export interface ChatMessage {
role: 'user' | 'model';
text: string;
}

View File

@@ -83,7 +83,7 @@ const OpenTicketsWidget: React.FC<OpenTicketsWidgetProps> = ({
openTickets.slice(0, 5).map((ticket) => (
<Link
key={ticket.id}
to="/tickets"
to="/dashboard/tickets"
className={`block p-3 rounded-lg ${getPriorityBg(ticket.priority, ticket.isOverdue)} hover:opacity-80 transition-opacity`}
>
<div className="flex items-start justify-between gap-2">
@@ -110,7 +110,7 @@ const OpenTicketsWidget: React.FC<OpenTicketsWidgetProps> = ({
{openTickets.length > 5 && (
<Link
to="/tickets"
to="/dashboard/tickets"
className="mt-3 text-sm text-brand-600 dark:text-brand-400 hover:underline text-center"
>
View all {openTickets.length} tickets

View File

@@ -0,0 +1,895 @@
/**
* Unit tests for ChartWidget component
*
* Tests cover:
* - Chart container rendering
* - Title display
* - Bar chart rendering
* - Line chart rendering
* - Data visualization
* - Custom colors
* - Value prefixes
* - Edit mode with drag handle and remove button
* - Tooltip formatting
* - Responsive container
* - Accessibility
*/
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 ChartWidget from '../ChartWidget';
// Mock Recharts components to avoid rendering issues in tests
vi.mock('recharts', () => ({
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
<div data-testid="responsive-container">{children}</div>
),
BarChart: ({ data, children }: { data: any[]; children: React.ReactNode }) => (
<div data-testid="bar-chart" data-chart-data={JSON.stringify(data)}>
{children}
</div>
),
LineChart: ({ data, children }: { data: any[]; children: React.ReactNode }) => (
<div data-testid="line-chart" data-chart-data={JSON.stringify(data)}>
{children}
</div>
),
Bar: ({ dataKey, fill }: { dataKey: string; fill: string }) => (
<div data-testid="bar" data-key={dataKey} data-fill={fill} />
),
Line: ({ dataKey, stroke }: { dataKey: string; stroke: string }) => (
<div data-testid="line" data-key={dataKey} data-stroke={stroke} />
),
XAxis: ({ dataKey }: { dataKey: string }) => (
<div data-testid="x-axis" data-key={dataKey} />
),
YAxis: () => <div data-testid="y-axis" />,
CartesianGrid: () => <div data-testid="cartesian-grid" />,
Tooltip: () => <div data-testid="tooltip" />,
}));
describe('ChartWidget', () => {
const mockChartData = [
{ name: 'Mon', value: 100 },
{ name: 'Tue', value: 150 },
{ name: 'Wed', value: 120 },
{ name: 'Thu', value: 180 },
{ name: 'Fri', value: 200 },
];
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('should render the component', () => {
render(
<ChartWidget
title="Revenue Chart"
data={mockChartData}
type="bar"
/>
);
expect(screen.getByText('Revenue Chart')).toBeInTheDocument();
});
it('should render chart container', () => {
render(
<ChartWidget
title="Revenue Chart"
data={mockChartData}
type="bar"
/>
);
const container = screen.getByTestId('responsive-container');
expect(container).toBeInTheDocument();
});
it('should render with different titles', () => {
const { rerender } = render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
expect(screen.getByText('Revenue')).toBeInTheDocument();
rerender(
<ChartWidget
title="Appointments"
data={mockChartData}
type="bar"
/>
);
expect(screen.getByText('Appointments')).toBeInTheDocument();
});
it('should render with empty data array', () => {
render(
<ChartWidget
title="Empty Chart"
data={[]}
type="bar"
/>
);
expect(screen.getByText('Empty Chart')).toBeInTheDocument();
expect(screen.getByTestId('bar-chart')).toBeInTheDocument();
});
});
describe('Title', () => {
it('should display title with correct styling', () => {
render(
<ChartWidget
title="Weekly Revenue"
data={mockChartData}
type="bar"
/>
);
const title = screen.getByText('Weekly Revenue');
expect(title).toBeInTheDocument();
expect(title).toHaveClass('text-lg', 'font-semibold', 'text-gray-900');
});
it('should apply dark mode styles to title', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
const title = screen.getByText('Revenue');
expect(title).toHaveClass('dark:text-white');
});
it('should handle long titles', () => {
const longTitle = 'Very Long Chart Title That Should Still Display Properly Without Breaking Layout';
render(
<ChartWidget
title={longTitle}
data={mockChartData}
type="bar"
/>
);
expect(screen.getByText(longTitle)).toBeInTheDocument();
});
});
describe('Bar Chart', () => {
it('should render bar chart when type is "bar"', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
expect(screen.getByTestId('bar-chart')).toBeInTheDocument();
expect(screen.queryByTestId('line-chart')).not.toBeInTheDocument();
});
it('should pass data to bar chart', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
const barChart = screen.getByTestId('bar-chart');
const chartData = JSON.parse(barChart.getAttribute('data-chart-data') || '[]');
expect(chartData).toEqual(mockChartData);
});
it('should render bar with correct dataKey', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
const bar = screen.getByTestId('bar');
expect(bar).toHaveAttribute('data-key', 'value');
});
it('should render bar with default color', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
const bar = screen.getByTestId('bar');
expect(bar).toHaveAttribute('data-fill', '#3b82f6');
});
it('should render bar with custom color', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
color="#10b981"
/>
);
const bar = screen.getByTestId('bar');
expect(bar).toHaveAttribute('data-fill', '#10b981');
});
it('should render CartesianGrid for bar chart', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
expect(screen.getByTestId('cartesian-grid')).toBeInTheDocument();
});
it('should render XAxis with name dataKey', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
const xAxis = screen.getByTestId('x-axis');
expect(xAxis).toHaveAttribute('data-key', 'name');
});
it('should render YAxis', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
expect(screen.getByTestId('y-axis')).toBeInTheDocument();
});
it('should render Tooltip', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
expect(screen.getByTestId('tooltip')).toBeInTheDocument();
});
});
describe('Line Chart', () => {
it('should render line chart when type is "line"', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="line"
/>
);
expect(screen.getByTestId('line-chart')).toBeInTheDocument();
expect(screen.queryByTestId('bar-chart')).not.toBeInTheDocument();
});
it('should pass data to line chart', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="line"
/>
);
const lineChart = screen.getByTestId('line-chart');
const chartData = JSON.parse(lineChart.getAttribute('data-chart-data') || '[]');
expect(chartData).toEqual(mockChartData);
});
it('should render line with correct dataKey', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="line"
/>
);
const line = screen.getByTestId('line');
expect(line).toHaveAttribute('data-key', 'value');
});
it('should render line with default color', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="line"
/>
);
const line = screen.getByTestId('line');
expect(line).toHaveAttribute('data-stroke', '#3b82f6');
});
it('should render line with custom color', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="line"
color="#ef4444"
/>
);
const line = screen.getByTestId('line');
expect(line).toHaveAttribute('data-stroke', '#ef4444');
});
it('should render CartesianGrid for line chart', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="line"
/>
);
expect(screen.getByTestId('cartesian-grid')).toBeInTheDocument();
});
it('should switch between chart types', () => {
const { rerender } = render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
expect(screen.getByTestId('bar-chart')).toBeInTheDocument();
rerender(
<ChartWidget
title="Revenue"
data={mockChartData}
type="line"
/>
);
expect(screen.getByTestId('line-chart')).toBeInTheDocument();
expect(screen.queryByTestId('bar-chart')).not.toBeInTheDocument();
});
});
describe('Value Prefix', () => {
it('should use empty prefix by default', () => {
render(
<ChartWidget
title="Appointments"
data={mockChartData}
type="bar"
/>
);
// Component renders successfully without prefix
expect(screen.getByText('Appointments')).toBeInTheDocument();
});
it('should accept custom value prefix', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
valuePrefix="$"
/>
);
// Component renders successfully with prefix
expect(screen.getByText('Revenue')).toBeInTheDocument();
});
it('should accept different prefixes', () => {
const { rerender } = render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
valuePrefix="$"
/>
);
expect(screen.getByText('Revenue')).toBeInTheDocument();
rerender(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
valuePrefix="€"
/>
);
expect(screen.getByText('Revenue')).toBeInTheDocument();
});
});
describe('Edit Mode', () => {
it('should not show edit controls when isEditing is false', () => {
const { container } = render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
isEditing={false}
/>
);
const dragHandle = container.querySelector('.drag-handle');
expect(dragHandle).not.toBeInTheDocument();
});
it('should show drag handle when in edit mode', () => {
const { container } = render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
isEditing={true}
/>
);
const dragHandle = container.querySelector('.drag-handle');
expect(dragHandle).toBeInTheDocument();
});
it('should show remove button when in edit mode', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
isEditing={true}
onRemove={vi.fn()}
/>
);
const removeButton = screen.getByRole('button');
expect(removeButton).toBeInTheDocument();
});
it('should call onRemove when remove button is clicked', async () => {
const user = userEvent.setup();
const handleRemove = vi.fn();
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
isEditing={true}
onRemove={handleRemove}
/>
);
const removeButton = screen.getByRole('button');
await user.click(removeButton);
expect(handleRemove).toHaveBeenCalledTimes(1);
});
it('should apply padding to title when in edit mode', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
isEditing={true}
/>
);
const title = screen.getByText('Revenue');
expect(title).toHaveClass('pl-5');
});
it('should not apply padding to title when not in edit mode', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
isEditing={false}
/>
);
const title = screen.getByText('Revenue');
expect(title).not.toHaveClass('pl-5');
});
it('should have grab cursor on drag handle', () => {
const { container } = render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
isEditing={true}
/>
);
const dragHandle = container.querySelector('.drag-handle');
expect(dragHandle).toHaveClass('cursor-grab', 'active:cursor-grabbing');
});
});
describe('Responsive Container', () => {
it('should render ResponsiveContainer', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
expect(screen.getByTestId('responsive-container')).toBeInTheDocument();
});
it('should wrap chart in responsive container', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
const container = screen.getByTestId('responsive-container');
const barChart = screen.getByTestId('bar-chart');
expect(container).toContainElement(barChart);
});
it('should have flex layout for proper sizing', () => {
const { container } = render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
const widget = container.firstChild;
expect(widget).toHaveClass('flex', 'flex-col');
});
});
describe('Styling', () => {
it('should apply container styles', () => {
const { container } = render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
const widget = container.firstChild;
expect(widget).toHaveClass(
'h-full',
'p-4',
'bg-white',
'rounded-xl',
'border',
'border-gray-200',
'shadow-sm',
'relative',
'group'
);
});
it('should apply dark mode styles', () => {
const { container } = render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
const widget = container.firstChild;
expect(widget).toHaveClass('dark:bg-gray-800', 'dark:border-gray-700');
});
it('should have proper spacing for title', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
const title = screen.getByText('Revenue');
expect(title).toHaveClass('mb-4');
});
it('should use flex-1 for chart container', () => {
const { container } = render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
const chartContainer = container.querySelector('.flex-1');
expect(chartContainer).toBeInTheDocument();
expect(chartContainer).toHaveClass('min-h-0');
});
});
describe('Data Handling', () => {
it('should handle single data point', () => {
const singlePoint = [{ name: 'Mon', value: 100 }];
render(
<ChartWidget
title="Revenue"
data={singlePoint}
type="bar"
/>
);
const barChart = screen.getByTestId('bar-chart');
const chartData = JSON.parse(barChart.getAttribute('data-chart-data') || '[]');
expect(chartData).toEqual(singlePoint);
});
it('should handle large datasets', () => {
const largeData = Array.from({ length: 100 }, (_, i) => ({
name: `Day ${i + 1}`,
value: Math.random() * 1000,
}));
render(
<ChartWidget
title="Revenue"
data={largeData}
type="line"
/>
);
const lineChart = screen.getByTestId('line-chart');
const chartData = JSON.parse(lineChart.getAttribute('data-chart-data') || '[]');
expect(chartData).toHaveLength(100);
});
it('should handle zero values', () => {
const zeroData = [
{ name: 'Mon', value: 0 },
{ name: 'Tue', value: 0 },
];
render(
<ChartWidget
title="Revenue"
data={zeroData}
type="bar"
/>
);
const barChart = screen.getByTestId('bar-chart');
const chartData = JSON.parse(barChart.getAttribute('data-chart-data') || '[]');
expect(chartData).toEqual(zeroData);
});
it('should handle negative values', () => {
const negativeData = [
{ name: 'Mon', value: -50 },
{ name: 'Tue', value: 100 },
{ name: 'Wed', value: -30 },
];
render(
<ChartWidget
title="Profit/Loss"
data={negativeData}
type="line"
/>
);
const lineChart = screen.getByTestId('line-chart');
const chartData = JSON.parse(lineChart.getAttribute('data-chart-data') || '[]');
expect(chartData).toEqual(negativeData);
});
});
describe('Accessibility', () => {
it('should have semantic heading for title', () => {
render(
<ChartWidget
title="Revenue Chart"
data={mockChartData}
type="bar"
/>
);
const heading = screen.getByRole('heading', { level: 3 });
expect(heading).toHaveTextContent('Revenue Chart');
});
it('should be keyboard accessible in edit mode', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
isEditing={true}
onRemove={vi.fn()}
/>
);
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
});
it('should have proper color contrast', () => {
render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
/>
);
const title = screen.getByText('Revenue');
expect(title).toHaveClass('text-gray-900');
});
});
describe('Integration', () => {
it('should render correctly with all props', () => {
const handleRemove = vi.fn();
render(
<ChartWidget
title="Weekly Revenue"
data={mockChartData}
type="bar"
color="#10b981"
valuePrefix="$"
isEditing={true}
onRemove={handleRemove}
/>
);
expect(screen.getByText('Weekly Revenue')).toBeInTheDocument();
expect(screen.getByTestId('bar-chart')).toBeInTheDocument();
expect(screen.getByTestId('bar')).toHaveAttribute('data-fill', '#10b981');
expect(screen.getByRole('button')).toBeInTheDocument();
});
it('should work with minimal props', () => {
render(
<ChartWidget
title="Simple Chart"
data={mockChartData}
type="bar"
/>
);
expect(screen.getByText('Simple Chart')).toBeInTheDocument();
expect(screen.getByTestId('bar-chart')).toBeInTheDocument();
});
it('should maintain layout with varying data lengths', () => {
const shortData = [{ name: 'A', value: 1 }];
const { rerender } = render(
<ChartWidget
title="Data"
data={shortData}
type="bar"
/>
);
expect(screen.getByText('Data')).toBeInTheDocument();
const longData = Array.from({ length: 50 }, (_, i) => ({
name: `Item ${i}`,
value: i * 10,
}));
rerender(
<ChartWidget
title="Data"
data={longData}
type="bar"
/>
);
expect(screen.getByText('Data')).toBeInTheDocument();
});
it('should support different color schemes', () => {
const colors = ['#3b82f6', '#10b981', '#ef4444', '#f59e0b', '#8b5cf6'];
const { rerender } = render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
color={colors[0]}
/>
);
colors.forEach((color) => {
rerender(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
color={color}
/>
);
const bar = screen.getByTestId('bar');
expect(bar).toHaveAttribute('data-fill', color);
});
});
it('should handle rapid data updates', () => {
const { rerender } = render(
<ChartWidget
title="Live Data"
data={mockChartData}
type="line"
/>
);
for (let i = 0; i < 10; i++) {
const newData = mockChartData.map((item) => ({
...item,
value: item.value + Math.random() * 50,
}));
rerender(
<ChartWidget
title="Live Data"
data={newData}
type="line"
/>
);
expect(screen.getByText('Live Data')).toBeInTheDocument();
}
});
});
});

View File

@@ -0,0 +1,702 @@
/**
* Unit tests for MetricWidget component
*
* Tests cover:
* - Component rendering with title and value
* - Growth/trend indicators (positive, negative, neutral)
* - Change percentage formatting
* - Weekly and monthly metrics display
* - Icon rendering
* - Edit mode with drag handle and remove button
* - Internationalization (i18n)
* - Accessibility
*/
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 MetricWidget from '../MetricWidget';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'dashboard.weekLabel': 'Week:',
'dashboard.monthLabel': 'Month:',
};
return translations[key] || key;
},
}),
}));
describe('MetricWidget', () => {
const mockGrowthData = {
weekly: { value: 100, change: 5.5 },
monthly: { value: 400, change: -2.3 },
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('should render the component', () => {
render(
<MetricWidget
title="Total Revenue"
value="$12,345"
growth={mockGrowthData}
/>
);
expect(screen.getByText('Total Revenue')).toBeInTheDocument();
});
it('should render title correctly', () => {
render(
<MetricWidget
title="Total Customers"
value={150}
growth={mockGrowthData}
/>
);
const title = screen.getByText('Total Customers');
expect(title).toBeInTheDocument();
expect(title).toHaveClass('text-sm', 'font-medium', 'text-gray-500');
});
it('should render numeric value', () => {
render(
<MetricWidget
title="Total Appointments"
value={42}
growth={mockGrowthData}
/>
);
const value = screen.getByText('42');
expect(value).toBeInTheDocument();
expect(value).toHaveClass('text-2xl', 'font-bold', 'text-gray-900');
});
it('should render string value', () => {
render(
<MetricWidget
title="Revenue"
value="$25,000"
growth={mockGrowthData}
/>
);
const value = screen.getByText('$25,000');
expect(value).toBeInTheDocument();
});
it('should render with custom icon', () => {
const CustomIcon = () => <span data-testid="custom-icon">💰</span>;
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
icon={<CustomIcon />}
/>
);
expect(screen.getByTestId('custom-icon')).toBeInTheDocument();
});
it('should render without icon', () => {
const { container } = render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
const iconContainer = container.querySelector('.text-brand-500');
expect(iconContainer).not.toBeInTheDocument();
});
});
describe('Trend Indicators', () => {
describe('Positive Change', () => {
it('should show positive trend icon for weekly growth', () => {
const positiveGrowth = {
weekly: { value: 100, change: 10.5 },
monthly: { value: 400, change: 0 },
};
const { container } = render(
<MetricWidget
title="Revenue"
value="$1000"
growth={positiveGrowth}
/>
);
const changeText = screen.getByText('+10.5%');
expect(changeText).toBeInTheDocument();
// Check for TrendingUp icon (lucide-react renders as SVG)
const svgs = container.querySelectorAll('svg');
expect(svgs.length).toBeGreaterThan(0);
});
it('should apply positive change styling', () => {
const positiveGrowth = {
weekly: { value: 100, change: 15 },
monthly: { value: 400, change: 0 },
};
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={positiveGrowth}
/>
);
const changeElement = screen.getByText('+15.0%').closest('span');
expect(changeElement).toHaveClass('text-green-700', 'bg-green-50');
});
it('should format positive change with plus sign', () => {
const positiveGrowth = {
weekly: { value: 100, change: 7.8 },
monthly: { value: 400, change: 3.2 },
};
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={positiveGrowth}
/>
);
expect(screen.getByText('+7.8%')).toBeInTheDocument();
expect(screen.getByText('+3.2%')).toBeInTheDocument();
});
});
describe('Negative Change', () => {
it('should show negative trend icon for monthly growth', () => {
const negativeGrowth = {
weekly: { value: 100, change: 0 },
monthly: { value: 400, change: -5.5 },
};
const { container } = render(
<MetricWidget
title="Revenue"
value="$1000"
growth={negativeGrowth}
/>
);
const changeText = screen.getByText('-5.5%');
expect(changeText).toBeInTheDocument();
// Check for TrendingDown icon
const svgs = container.querySelectorAll('svg');
expect(svgs.length).toBeGreaterThan(0);
});
it('should apply negative change styling', () => {
const negativeGrowth = {
weekly: { value: 100, change: -12.3 },
monthly: { value: 400, change: 0 },
};
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={negativeGrowth}
/>
);
const changeElement = screen.getByText('-12.3%').closest('span');
expect(changeElement).toHaveClass('text-red-700', 'bg-red-50');
});
it('should format negative change without extra minus sign', () => {
const negativeGrowth = {
weekly: { value: 100, change: -8.9 },
monthly: { value: 400, change: -15.2 },
};
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={negativeGrowth}
/>
);
expect(screen.getByText('-8.9%')).toBeInTheDocument();
expect(screen.getByText('-15.2%')).toBeInTheDocument();
});
});
describe('Zero Change', () => {
it('should show neutral trend icon for zero change', () => {
const zeroGrowth = {
weekly: { value: 100, change: 0 },
monthly: { value: 400, change: 0 },
};
const { container } = render(
<MetricWidget
title="Revenue"
value="$1000"
growth={zeroGrowth}
/>
);
const changeTexts = screen.getAllByText('0%');
expect(changeTexts).toHaveLength(2);
// Check for Minus icon
const svgs = container.querySelectorAll('svg');
expect(svgs.length).toBeGreaterThan(0);
});
it('should apply neutral change styling', () => {
const zeroGrowth = {
weekly: { value: 100, change: 0 },
monthly: { value: 400, change: 0 },
};
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={zeroGrowth}
/>
);
const changeElements = screen.getAllByText('0%');
changeElements.forEach((element) => {
const spanElement = element.closest('span');
expect(spanElement).toHaveClass('text-gray-700', 'bg-gray-50');
});
});
it('should format zero change as 0%', () => {
const zeroGrowth = {
weekly: { value: 100, change: 0 },
monthly: { value: 400, change: 0 },
};
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={zeroGrowth}
/>
);
const changeTexts = screen.getAllByText('0%');
expect(changeTexts).toHaveLength(2);
});
});
});
describe('Weekly and Monthly Metrics', () => {
it('should display weekly label', () => {
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
expect(screen.getByText('Week:')).toBeInTheDocument();
});
it('should display monthly label', () => {
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
expect(screen.getByText('Month:')).toBeInTheDocument();
});
it('should display weekly change percentage', () => {
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
expect(screen.getByText('+5.5%')).toBeInTheDocument();
});
it('should display monthly change percentage', () => {
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
expect(screen.getByText('-2.3%')).toBeInTheDocument();
});
it('should handle different weekly and monthly trends', () => {
const mixedGrowth = {
weekly: { value: 100, change: 12.5 },
monthly: { value: 400, change: -8.2 },
};
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mixedGrowth}
/>
);
expect(screen.getByText('+12.5%')).toBeInTheDocument();
expect(screen.getByText('-8.2%')).toBeInTheDocument();
});
it('should format change values to one decimal place', () => {
const preciseGrowth = {
weekly: { value: 100, change: 5.456 },
monthly: { value: 400, change: -3.789 },
};
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={preciseGrowth}
/>
);
expect(screen.getByText('+5.5%')).toBeInTheDocument();
expect(screen.getByText('-3.8%')).toBeInTheDocument();
});
});
describe('Edit Mode', () => {
it('should not show edit controls when isEditing is false', () => {
const { container } = render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
isEditing={false}
/>
);
const dragHandle = container.querySelector('.drag-handle');
expect(dragHandle).not.toBeInTheDocument();
});
it('should show drag handle when in edit mode', () => {
const { container } = render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
isEditing={true}
/>
);
const dragHandle = container.querySelector('.drag-handle');
expect(dragHandle).toBeInTheDocument();
});
it('should show remove button when in edit mode', () => {
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
isEditing={true}
onRemove={vi.fn()}
/>
);
const removeButton = screen.getByRole('button');
expect(removeButton).toBeInTheDocument();
});
it('should call onRemove when remove button is clicked', async () => {
const user = userEvent.setup();
const handleRemove = vi.fn();
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
isEditing={true}
onRemove={handleRemove}
/>
);
const removeButton = screen.getByRole('button');
await user.click(removeButton);
expect(handleRemove).toHaveBeenCalledTimes(1);
});
it('should apply padding when in edit mode', () => {
const { container } = render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
isEditing={true}
/>
);
const contentContainer = container.querySelector('.pl-5');
expect(contentContainer).toBeInTheDocument();
});
it('should not apply padding when not in edit mode', () => {
const { container } = render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
isEditing={false}
/>
);
const contentContainer = container.querySelector('.pl-5');
expect(contentContainer).not.toBeInTheDocument();
});
});
describe('Styling', () => {
it('should apply container styles', () => {
const { container } = render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
const widget = container.firstChild;
expect(widget).toHaveClass(
'h-full',
'p-4',
'bg-white',
'rounded-xl',
'border',
'border-gray-200',
'shadow-sm',
'relative',
'group'
);
});
it('should apply dark mode styles', () => {
const { container } = render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
const widget = container.firstChild;
expect(widget).toHaveClass('dark:bg-gray-800', 'dark:border-gray-700');
});
it('should apply trend badge styles', () => {
const { container } = render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
const badges = container.querySelectorAll('.rounded-full');
expect(badges.length).toBeGreaterThan(0);
});
});
describe('Accessibility', () => {
it('should have semantic HTML structure', () => {
const { container } = render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
const paragraphs = container.querySelectorAll('p');
const divs = container.querySelectorAll('div');
expect(paragraphs.length).toBeGreaterThan(0);
expect(divs.length).toBeGreaterThan(0);
});
it('should have readable text contrast', () => {
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
const title = screen.getByText('Revenue');
expect(title).toHaveClass('text-gray-500');
});
it('should make remove button accessible when in edit mode', () => {
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
isEditing={true}
onRemove={vi.fn()}
/>
);
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
});
});
describe('Internationalization', () => {
it('should use translation for week label', () => {
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
expect(screen.getByText('Week:')).toBeInTheDocument();
});
it('should use translation for month label', () => {
render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
expect(screen.getByText('Month:')).toBeInTheDocument();
});
});
describe('Integration', () => {
it('should render correctly with all props', () => {
const CustomIcon = () => <span data-testid="icon">📊</span>;
const handleRemove = vi.fn();
const fullGrowth = {
weekly: { value: 150, change: 10 },
monthly: { value: 600, change: -5 },
};
render(
<MetricWidget
title="Total Revenue"
value="$15,000"
growth={fullGrowth}
icon={<CustomIcon />}
isEditing={true}
onRemove={handleRemove}
/>
);
expect(screen.getByText('Total Revenue')).toBeInTheDocument();
expect(screen.getByText('$15,000')).toBeInTheDocument();
expect(screen.getByTestId('icon')).toBeInTheDocument();
expect(screen.getByText('+10.0%')).toBeInTheDocument();
expect(screen.getByText('-5.0%')).toBeInTheDocument();
expect(screen.getByRole('button')).toBeInTheDocument();
});
it('should handle edge case values', () => {
const edgeCaseGrowth = {
weekly: { value: 0, change: 0 },
monthly: { value: 1000000, change: 99.9 },
};
render(
<MetricWidget
title="Edge Case"
value={0}
growth={edgeCaseGrowth}
/>
);
expect(screen.getByText('Edge Case')).toBeInTheDocument();
expect(screen.getByText('0')).toBeInTheDocument();
expect(screen.getByText('0%')).toBeInTheDocument();
expect(screen.getByText('+99.9%')).toBeInTheDocument();
});
it('should maintain layout with long titles', () => {
render(
<MetricWidget
title="Very Long Metric Title That Should Still Display Properly"
value="$1000"
growth={mockGrowthData}
/>
);
const title = screen.getByText('Very Long Metric Title That Should Still Display Properly');
expect(title).toBeInTheDocument();
expect(title).toHaveClass('text-sm');
});
it('should handle large numeric values', () => {
render(
<MetricWidget
title="Revenue"
value="$1,234,567,890"
growth={mockGrowthData}
/>
);
expect(screen.getByText('$1,234,567,890')).toBeInTheDocument();
});
it('should display multiple trend indicators simultaneously', () => {
const { container } = render(
<MetricWidget
title="Revenue"
value="$1000"
growth={mockGrowthData}
/>
);
// Should have trend indicators for both weekly and monthly
const trendBadges = container.querySelectorAll('.rounded-full');
expect(trendBadges.length).toBeGreaterThanOrEqual(2);
});
});
});

View File

@@ -0,0 +1,242 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { Check, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
usePublicPlans,
formatPrice,
PublicPlanVersion,
} from '../../hooks/usePublicPlans';
interface DynamicPricingCardsProps {
className?: string;
}
const DynamicPricingCards: React.FC<DynamicPricingCardsProps> = ({ className = '' }) => {
const { t } = useTranslation();
const { data: plans, isLoading, error } = usePublicPlans();
const [billingPeriod, setBillingPeriod] = useState<'monthly' | 'annual'>('monthly');
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-brand-600" />
</div>
);
}
if (error || !plans) {
return (
<div className="text-center py-20 text-gray-500 dark:text-gray-400">
{t('marketing.pricing.loadError', 'Unable to load pricing. Please try again later.')}
</div>
);
}
// Sort plans by display_order
const sortedPlans = [...plans].sort(
(a, b) => a.plan.display_order - b.plan.display_order
);
return (
<div className={className}>
{/* Billing Toggle */}
<div className="flex justify-center mb-12">
<div className="bg-gray-100 dark:bg-gray-800 p-1 rounded-lg inline-flex">
<button
onClick={() => setBillingPeriod('monthly')}
className={`px-6 py-2 rounded-md text-sm font-medium transition-colors ${
billingPeriod === 'monthly'
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
{t('marketing.pricing.monthly', 'Monthly')}
</button>
<button
onClick={() => setBillingPeriod('annual')}
className={`px-6 py-2 rounded-md text-sm font-medium transition-colors ${
billingPeriod === 'annual'
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
{t('marketing.pricing.annual', 'Annual')}
<span className="ml-2 text-xs text-green-600 dark:text-green-400 font-semibold">
{t('marketing.pricing.savePercent', 'Save ~17%')}
</span>
</button>
</div>
</div>
{/* Plans Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-6 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{sortedPlans.map((planVersion) => (
<PlanCard
key={planVersion.id}
planVersion={planVersion}
billingPeriod={billingPeriod}
/>
))}
</div>
</div>
);
};
interface PlanCardProps {
planVersion: PublicPlanVersion;
billingPeriod: 'monthly' | 'annual';
}
const PlanCard: React.FC<PlanCardProps> = ({ planVersion, billingPeriod }) => {
const { t } = useTranslation();
const { plan, is_most_popular, show_price, marketing_features, trial_days } = planVersion;
const price =
billingPeriod === 'annual'
? planVersion.price_yearly_cents
: planVersion.price_monthly_cents;
const isEnterprise = !show_price || plan.code === 'enterprise';
const isFree = price === 0 && plan.code === 'free';
// Determine CTA
const ctaLink = isEnterprise ? '/contact' : `/signup?plan=${plan.code}`;
const ctaText = isEnterprise
? t('marketing.pricing.contactSales', 'Contact Sales')
: isFree
? t('marketing.pricing.getStartedFree', 'Get Started Free')
: t('marketing.pricing.startTrial', 'Start Free Trial');
if (is_most_popular) {
return (
<div className="relative flex flex-col p-6 bg-brand-600 rounded-2xl shadow-xl shadow-brand-600/20 transform lg:scale-105 z-10">
{/* Most Popular Badge */}
<div className="absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-1 bg-brand-500 text-white text-xs font-semibold rounded-full whitespace-nowrap">
{t('marketing.pricing.mostPopular', 'Most Popular')}
</div>
{/* Header */}
<div className="mb-4">
<h3 className="text-lg font-bold text-white mb-1">{plan.name}</h3>
<p className="text-brand-100 text-sm">{plan.description}</p>
</div>
{/* Price */}
<div className="mb-4">
{isEnterprise ? (
<span className="text-3xl font-bold text-white">
{t('marketing.pricing.custom', 'Custom')}
</span>
) : (
<>
<span className="text-4xl font-bold text-white">
{formatPrice(price)}
</span>
<span className="text-brand-200 ml-1 text-sm">
{billingPeriod === 'annual'
? t('marketing.pricing.perYear', '/year')
: t('marketing.pricing.perMonth', '/month')}
</span>
</>
)}
{trial_days > 0 && !isFree && (
<div className="mt-1 text-xs text-brand-100">
{t('marketing.pricing.trialDays', '{{days}}-day free trial', {
days: trial_days,
})}
</div>
)}
</div>
{/* Features */}
<ul className="flex-1 space-y-2 mb-6">
{marketing_features.map((feature, index) => (
<li key={index} className="flex items-start gap-2">
<Check className="h-4 w-4 text-brand-200 flex-shrink-0 mt-0.5" />
<span className="text-white text-sm">{feature}</span>
</li>
))}
</ul>
{/* CTA */}
<Link
to={ctaLink}
className="block w-full py-3 px-4 text-center text-sm font-semibold text-brand-600 bg-white rounded-xl hover:bg-brand-50 transition-colors"
>
{ctaText}
</Link>
</div>
);
}
return (
<div className="relative flex flex-col p-6 bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 shadow-sm">
{/* Header */}
<div className="mb-4">
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-1">
{plan.name}
</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm">
{plan.description}
</p>
</div>
{/* Price */}
<div className="mb-4">
{isEnterprise ? (
<span className="text-3xl font-bold text-gray-900 dark:text-white">
{t('marketing.pricing.custom', 'Custom')}
</span>
) : (
<>
<span className="text-4xl font-bold text-gray-900 dark:text-white">
{formatPrice(price)}
</span>
<span className="text-gray-500 dark:text-gray-400 ml-1 text-sm">
{billingPeriod === 'annual'
? t('marketing.pricing.perYear', '/year')
: t('marketing.pricing.perMonth', '/month')}
</span>
</>
)}
{trial_days > 0 && !isFree && (
<div className="mt-1 text-xs text-brand-600 dark:text-brand-400">
{t('marketing.pricing.trialDays', '{{days}}-day free trial', {
days: trial_days,
})}
</div>
)}
{isFree && (
<div className="mt-1 text-xs text-green-600 dark:text-green-400">
{t('marketing.pricing.freeForever', 'Free forever')}
</div>
)}
</div>
{/* Features */}
<ul className="flex-1 space-y-2 mb-6">
{marketing_features.map((feature, index) => (
<li key={index} className="flex items-start gap-2">
<Check className="h-4 w-4 text-brand-600 dark:text-brand-400 flex-shrink-0 mt-0.5" />
<span className="text-gray-700 dark:text-gray-300 text-sm">{feature}</span>
</li>
))}
</ul>
{/* CTA */}
<Link
to={ctaLink}
className={`block w-full py-3 px-4 text-center text-sm font-semibold rounded-xl transition-colors ${
isFree
? 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white hover:bg-gray-200 dark:hover:bg-gray-600'
: 'bg-brand-50 dark:bg-brand-900/30 text-brand-600 hover:bg-brand-100 dark:hover:bg-brand-900/50'
}`}
>
{ctaText}
</Link>
</div>
);
};
export default DynamicPricingCards;

View File

@@ -0,0 +1,251 @@
import React from 'react';
import { Check, X, Minus, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
usePublicPlans,
PublicPlanVersion,
getPlanFeatureValue,
formatLimit,
} from '../../hooks/usePublicPlans';
// Feature categories for the comparison table
const FEATURE_CATEGORIES = [
{
key: 'limits',
features: [
{ code: 'max_users', label: 'Team members' },
{ code: 'max_resources', label: 'Resources' },
{ code: 'max_locations', label: 'Locations' },
{ code: 'max_services', label: 'Services' },
{ code: 'max_customers', label: 'Customers' },
{ code: 'max_appointments_per_month', label: 'Appointments/month' },
],
},
{
key: 'communication',
features: [
{ code: 'email_enabled', label: 'Email notifications' },
{ code: 'max_email_per_month', label: 'Emails/month' },
{ code: 'sms_enabled', label: 'SMS reminders' },
{ code: 'max_sms_per_month', label: 'SMS/month' },
{ code: 'masked_calling_enabled', label: 'Masked calling' },
],
},
{
key: 'booking',
features: [
{ code: 'online_booking', label: 'Online booking' },
{ code: 'recurring_appointments', label: 'Recurring appointments' },
{ code: 'payment_processing', label: 'Accept payments' },
{ code: 'mobile_app_access', label: 'Mobile app' },
],
},
{
key: 'integrations',
features: [
{ code: 'integrations_enabled', label: 'Third-party integrations' },
{ code: 'api_access', label: 'API access' },
{ code: 'max_api_calls_per_day', label: 'API calls/day' },
],
},
{
key: 'branding',
features: [
{ code: 'custom_domain', label: 'Custom domain' },
{ code: 'custom_branding', label: 'Custom branding' },
{ code: 'remove_branding', label: 'Remove "Powered by"' },
{ code: 'white_label', label: 'White label' },
],
},
{
key: 'enterprise',
features: [
{ code: 'multi_location', label: 'Multi-location management' },
{ code: 'team_permissions', label: 'Team permissions' },
{ code: 'audit_logs', label: 'Audit logs' },
{ code: 'advanced_reporting', label: 'Advanced analytics' },
],
},
{
key: 'support',
features: [
{ code: 'priority_support', label: 'Priority support' },
{ code: 'dedicated_account_manager', label: 'Dedicated account manager' },
{ code: 'sla_guarantee', label: 'SLA guarantee' },
],
},
{
key: 'storage',
features: [
{ code: 'max_storage_mb', label: 'File storage' },
],
},
];
interface FeatureComparisonTableProps {
className?: string;
}
const FeatureComparisonTable: React.FC<FeatureComparisonTableProps> = ({
className = '',
}) => {
const { t } = useTranslation();
const { data: plans, isLoading, error } = usePublicPlans();
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-brand-600" />
</div>
);
}
if (error || !plans || plans.length === 0) {
return null;
}
// Sort plans by display_order
const sortedPlans = [...plans].sort(
(a, b) => a.plan.display_order - b.plan.display_order
);
return (
<div className={`overflow-x-auto ${className}`}>
<table className="w-full min-w-[800px]">
{/* Header */}
<thead>
<tr>
<th className="text-left py-4 px-4 text-sm font-medium text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700 w-64">
{t('marketing.pricing.featureComparison.features', 'Features')}
</th>
{sortedPlans.map((planVersion) => (
<th
key={planVersion.id}
className={`text-center py-4 px-4 text-sm font-semibold border-b border-gray-200 dark:border-gray-700 ${
planVersion.is_most_popular
? 'text-brand-600 dark:text-brand-400 bg-brand-50 dark:bg-brand-900/20'
: 'text-gray-900 dark:text-white'
}`}
>
{planVersion.plan.name}
</th>
))}
</tr>
</thead>
<tbody>
{FEATURE_CATEGORIES.map((category) => (
<React.Fragment key={category.key}>
{/* Category Header */}
<tr>
<td
colSpan={sortedPlans.length + 1}
className="py-3 px-4 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50"
>
{t(
`marketing.pricing.featureComparison.categories.${category.key}`,
category.key.charAt(0).toUpperCase() + category.key.slice(1)
)}
</td>
</tr>
{/* Features */}
{category.features.map((feature) => (
<tr
key={feature.code}
className="border-b border-gray-100 dark:border-gray-800"
>
<td className="py-3 px-4 text-sm text-gray-700 dark:text-gray-300">
{t(
`marketing.pricing.featureComparison.features.${feature.code}`,
feature.label
)}
</td>
{sortedPlans.map((planVersion) => (
<td
key={`${planVersion.id}-${feature.code}`}
className={`py-3 px-4 text-center ${
planVersion.is_most_popular
? 'bg-brand-50/50 dark:bg-brand-900/10'
: ''
}`}
>
<FeatureValue
planVersion={planVersion}
featureCode={feature.code}
/>
</td>
))}
</tr>
))}
</React.Fragment>
))}
</tbody>
</table>
</div>
);
};
interface FeatureValueProps {
planVersion: PublicPlanVersion;
featureCode: string;
}
const FeatureValue: React.FC<FeatureValueProps> = ({
planVersion,
featureCode,
}) => {
const value = getPlanFeatureValue(planVersion, featureCode);
// Handle null/undefined - feature not set
if (value === null || value === undefined) {
return (
<X className="w-5 h-5 text-gray-300 dark:text-gray-600 mx-auto" />
);
}
// Boolean feature
if (typeof value === 'boolean') {
return value ? (
<Check className="w-5 h-5 text-green-500 mx-auto" />
) : (
<X className="w-5 h-5 text-gray-300 dark:text-gray-600 mx-auto" />
);
}
// Integer feature (limit)
if (typeof value === 'number') {
// Special handling for storage (convert MB to GB if > 1000)
if (featureCode === 'max_storage_mb') {
if (value === 0) {
return (
<span className="text-sm font-medium text-gray-900 dark:text-white">
Unlimited
</span>
);
}
if (value >= 1000) {
return (
<span className="text-sm font-medium text-gray-900 dark:text-white">
{(value / 1000).toFixed(0)} GB
</span>
);
}
return (
<span className="text-sm font-medium text-gray-900 dark:text-white">
{value} MB
</span>
);
}
// Regular limit display
return (
<span className="text-sm font-medium text-gray-900 dark:text-white">
{formatLimit(value)}
</span>
);
}
// Fallback
return <Minus className="w-5 h-5 text-gray-300 dark:text-gray-600 mx-auto" />;
};
export default FeatureComparisonTable;

View File

@@ -0,0 +1,533 @@
/**
* Unit tests for CTASection component
*
* Tests cover:
* - Component rendering in both variants (default and minimal)
* - CTA text rendering
* - Button/link presence and navigation
* - Click navigation behavior
* - Icon display
* - Internationalization (i18n)
* - Accessibility
* - Styling variations
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import userEvent from '@testing-library/user-event';
import React from 'react';
import CTASection from '../CTASection';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'marketing.cta.ready': 'Ready to get started?',
'marketing.cta.readySubtitle': 'Join thousands of businesses already using SmoothSchedule.',
'marketing.cta.startFree': 'Get Started Free',
'marketing.cta.talkToSales': 'Talk to Sales',
'marketing.cta.noCredit': 'No credit card required',
};
return translations[key] || key;
},
}),
}));
// Test wrapper with Router
const createWrapper = () => {
return ({ children }: { children: React.ReactNode }) => (
<BrowserRouter>{children}</BrowserRouter>
);
};
describe('CTASection', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('Default Variant', () => {
describe('Rendering', () => {
it('should render the CTA section', () => {
render(<CTASection />, { wrapper: createWrapper() });
const heading = screen.getByRole('heading', { name: /ready to get started/i });
expect(heading).toBeInTheDocument();
});
it('should render CTA text elements', () => {
render(<CTASection />, { wrapper: createWrapper() });
// Main heading
const heading = screen.getByRole('heading', { name: /ready to get started/i });
expect(heading).toBeInTheDocument();
// Subtitle
const subtitle = screen.getByText(/join thousands of businesses/i);
expect(subtitle).toBeInTheDocument();
// No credit card required
const disclaimer = screen.getByText(/no credit card required/i);
expect(disclaimer).toBeInTheDocument();
});
it('should render with correct text hierarchy', () => {
render(<CTASection />, { wrapper: createWrapper() });
const heading = screen.getByRole('heading', { name: /ready to get started/i });
expect(heading.tagName).toBe('H2');
});
});
describe('Button/Link Presence', () => {
it('should render the signup button', () => {
render(<CTASection />, { wrapper: createWrapper() });
const signupButton = screen.getByRole('link', { name: /get started free/i });
expect(signupButton).toBeInTheDocument();
});
it('should render the talk to sales button', () => {
render(<CTASection />, { wrapper: createWrapper() });
const salesButton = screen.getByRole('link', { name: /talk to sales/i });
expect(salesButton).toBeInTheDocument();
});
it('should render both CTA buttons', () => {
render(<CTASection />, { wrapper: createWrapper() });
const links = screen.getAllByRole('link');
expect(links).toHaveLength(2);
});
});
describe('Navigation', () => {
it('should have correct href for signup button', () => {
render(<CTASection />, { wrapper: createWrapper() });
const signupButton = screen.getByRole('link', { name: /get started free/i });
expect(signupButton).toHaveAttribute('href', '/signup');
});
it('should have correct href for sales button', () => {
render(<CTASection />, { wrapper: createWrapper() });
const salesButton = screen.getByRole('link', { name: /talk to sales/i });
expect(salesButton).toHaveAttribute('href', '/contact');
});
it('should navigate when signup button is clicked', async () => {
const user = userEvent.setup();
render(<CTASection />, { wrapper: createWrapper() });
const signupButton = screen.getByRole('link', { name: /get started free/i });
// Click should not throw error
await expect(user.click(signupButton)).resolves.not.toThrow();
});
it('should navigate when sales button is clicked', async () => {
const user = userEvent.setup();
render(<CTASection />, { wrapper: createWrapper() });
const salesButton = screen.getByRole('link', { name: /talk to sales/i });
// Click should not throw error
await expect(user.click(salesButton)).resolves.not.toThrow();
});
});
describe('Icon Display', () => {
it('should display ArrowRight icon on signup button', () => {
render(<CTASection />, { wrapper: createWrapper() });
const signupButton = screen.getByRole('link', { name: /get started free/i });
const icon = signupButton.querySelector('svg');
expect(icon).toBeInTheDocument();
});
it('should have correct icon size', () => {
render(<CTASection />, { wrapper: createWrapper() });
const signupButton = screen.getByRole('link', { name: /get started free/i });
const icon = signupButton.querySelector('svg');
expect(icon).toHaveClass('h-5', 'w-5');
});
});
describe('Styling', () => {
it('should apply gradient background', () => {
const { container } = render(<CTASection />, { wrapper: createWrapper() });
const section = container.querySelector('section');
expect(section).toHaveClass('bg-gradient-to-br', 'from-brand-600', 'to-brand-700');
});
it('should apply correct padding', () => {
const { container } = render(<CTASection />, { wrapper: createWrapper() });
const section = container.querySelector('section');
expect(section).toHaveClass('py-20', 'lg:py-28');
});
it('should style signup button as primary CTA', () => {
render(<CTASection />, { wrapper: createWrapper() });
const signupButton = screen.getByRole('link', { name: /get started free/i });
expect(signupButton).toHaveClass('bg-white', 'text-brand-600');
expect(signupButton).toHaveClass('hover:bg-brand-50');
});
it('should style sales button as secondary CTA', () => {
render(<CTASection />, { wrapper: createWrapper() });
const salesButton = screen.getByRole('link', { name: /talk to sales/i });
expect(salesButton).toHaveClass('bg-white/10', 'text-white');
expect(salesButton).toHaveClass('hover:bg-white/20');
});
it('should have responsive button layout', () => {
const { container } = render(<CTASection />, { wrapper: createWrapper() });
const buttonContainer = container.querySelector('.flex.flex-col.sm\\:flex-row');
expect(buttonContainer).toBeInTheDocument();
});
it('should apply shadow to signup button', () => {
render(<CTASection />, { wrapper: createWrapper() });
const signupButton = screen.getByRole('link', { name: /get started free/i });
expect(signupButton).toHaveClass('shadow-lg', 'shadow-black/10');
});
});
describe('Background Pattern', () => {
it('should render decorative background elements', () => {
const { container } = render(<CTASection />, { wrapper: createWrapper() });
const backgroundPattern = container.querySelector('.absolute.inset-0');
expect(backgroundPattern).toBeInTheDocument();
});
});
});
describe('Minimal Variant', () => {
describe('Rendering', () => {
it('should render the minimal CTA section', () => {
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
const heading = screen.getByRole('heading', { name: /ready to get started/i });
expect(heading).toBeInTheDocument();
});
it('should render CTA text in minimal variant', () => {
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
const heading = screen.getByRole('heading', { name: /ready to get started/i });
expect(heading).toBeInTheDocument();
const subtitle = screen.getByText(/join thousands of businesses/i);
expect(subtitle).toBeInTheDocument();
});
it('should only render one button in minimal variant', () => {
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
const links = screen.getAllByRole('link');
expect(links).toHaveLength(1);
});
});
describe('Button/Link Presence', () => {
it('should render only the signup button', () => {
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
const signupButton = screen.getByRole('link', { name: /get started free/i });
expect(signupButton).toBeInTheDocument();
});
it('should not render the sales button', () => {
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
const salesButton = screen.queryByRole('link', { name: /talk to sales/i });
expect(salesButton).not.toBeInTheDocument();
});
it('should not render the disclaimer text', () => {
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
const disclaimer = screen.queryByText(/no credit card required/i);
expect(disclaimer).not.toBeInTheDocument();
});
});
describe('Navigation', () => {
it('should have correct href for signup button', () => {
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
const signupButton = screen.getByRole('link', { name: /get started free/i });
expect(signupButton).toHaveAttribute('href', '/signup');
});
it('should navigate when button is clicked', async () => {
const user = userEvent.setup();
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
const signupButton = screen.getByRole('link', { name: /get started free/i });
// Click should not throw error
await expect(user.click(signupButton)).resolves.not.toThrow();
});
});
describe('Icon Display', () => {
it('should display ArrowRight icon', () => {
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
const signupButton = screen.getByRole('link', { name: /get started free/i });
const icon = signupButton.querySelector('svg');
expect(icon).toBeInTheDocument();
});
it('should have correct icon size', () => {
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
const signupButton = screen.getByRole('link', { name: /get started free/i });
const icon = signupButton.querySelector('svg');
expect(icon).toHaveClass('h-5', 'w-5');
});
});
describe('Styling', () => {
it('should apply white background', () => {
const { container } = render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
const section = container.querySelector('section');
expect(section).toHaveClass('bg-white', 'dark:bg-gray-900');
});
it('should apply minimal padding', () => {
const { container } = render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
const section = container.querySelector('section');
expect(section).toHaveClass('py-16');
});
it('should use brand colors for button', () => {
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
const signupButton = screen.getByRole('link', { name: /get started free/i });
expect(signupButton).toHaveClass('bg-brand-600', 'text-white');
expect(signupButton).toHaveClass('hover:bg-brand-700');
});
it('should have smaller heading size', () => {
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
const heading = screen.getByRole('heading', { name: /ready to get started/i });
expect(heading).toHaveClass('text-2xl', 'sm:text-3xl');
});
it('should not have gradient background', () => {
const { container } = render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
const section = container.querySelector('section');
expect(section).not.toHaveClass('bg-gradient-to-br');
});
});
});
describe('Variant Comparison', () => {
it('should render different layouts for different variants', () => {
const { container: defaultContainer } = render(<CTASection />, { wrapper: createWrapper() });
const { container: minimalContainer } = render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
const defaultSection = defaultContainer.querySelector('section');
const minimalSection = minimalContainer.querySelector('section');
expect(defaultSection?.className).not.toEqual(minimalSection?.className);
});
it('should use default variant when no variant prop provided', () => {
render(<CTASection />, { wrapper: createWrapper() });
// Check for elements unique to default variant
const salesButton = screen.queryByRole('link', { name: /talk to sales/i });
expect(salesButton).toBeInTheDocument();
});
it('should switch variants correctly', () => {
const { rerender } = render(<CTASection />, { wrapper: createWrapper() });
// Should have 2 buttons in default
let links = screen.getAllByRole('link');
expect(links).toHaveLength(2);
rerender(<CTASection variant="minimal" />);
// Should have 1 button in minimal
links = screen.getAllByRole('link');
expect(links).toHaveLength(1);
});
});
describe('Internationalization', () => {
it('should use translation for heading', () => {
render(<CTASection />, { wrapper: createWrapper() });
const heading = screen.getByText('Ready to get started?');
expect(heading).toBeInTheDocument();
});
it('should use translation for subtitle', () => {
render(<CTASection />, { wrapper: createWrapper() });
const subtitle = screen.getByText('Join thousands of businesses already using SmoothSchedule.');
expect(subtitle).toBeInTheDocument();
});
it('should use translation for button text', () => {
render(<CTASection />, { wrapper: createWrapper() });
const signupButton = screen.getByRole('link', { name: /get started free/i });
expect(signupButton).toHaveTextContent('Get Started Free');
});
it('should use translation for sales button text', () => {
render(<CTASection />, { wrapper: createWrapper() });
const salesButton = screen.getByRole('link', { name: /talk to sales/i });
expect(salesButton).toHaveTextContent('Talk to Sales');
});
it('should use translation for disclaimer', () => {
render(<CTASection />, { wrapper: createWrapper() });
const disclaimer = screen.getByText('No credit card required');
expect(disclaimer).toBeInTheDocument();
});
it('should translate all text in minimal variant', () => {
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
expect(screen.getByText('Ready to get started?')).toBeInTheDocument();
expect(screen.getByText('Join thousands of businesses already using SmoothSchedule.')).toBeInTheDocument();
expect(screen.getByRole('link', { name: /get started free/i })).toHaveTextContent('Get Started Free');
});
});
describe('Accessibility', () => {
it('should have semantic section element', () => {
const { container } = render(<CTASection />, { wrapper: createWrapper() });
const section = container.querySelector('section');
expect(section).toBeInTheDocument();
});
it('should have heading hierarchy', () => {
render(<CTASection />, { wrapper: createWrapper() });
const heading = screen.getByRole('heading', { level: 2 });
expect(heading).toBeInTheDocument();
});
it('should have keyboard accessible links', () => {
render(<CTASection />, { wrapper: createWrapper() });
const signupButton = screen.getByRole('link', { name: /get started free/i });
const salesButton = screen.getByRole('link', { name: /talk to sales/i });
expect(signupButton.tagName).toBe('A');
expect(salesButton.tagName).toBe('A');
});
it('should have descriptive link text', () => {
render(<CTASection />, { wrapper: createWrapper() });
const signupButton = screen.getByRole('link', { name: /get started free/i });
const salesButton = screen.getByRole('link', { name: /talk to sales/i });
expect(signupButton).toHaveAccessibleName();
expect(salesButton).toHaveAccessibleName();
});
it('should maintain accessibility in minimal variant', () => {
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
const heading = screen.getByRole('heading', { level: 2 });
const signupButton = screen.getByRole('link', { name: /get started free/i });
expect(heading).toBeInTheDocument();
expect(signupButton).toHaveAccessibleName();
});
});
describe('Responsive Design', () => {
it('should have responsive heading sizes', () => {
render(<CTASection />, { wrapper: createWrapper() });
const heading = screen.getByRole('heading', { name: /ready to get started/i });
expect(heading).toHaveClass('text-3xl', 'sm:text-4xl', 'lg:text-5xl');
});
it('should have responsive subtitle size', () => {
render(<CTASection />, { wrapper: createWrapper() });
const subtitle = screen.getByText(/join thousands of businesses/i);
expect(subtitle).toHaveClass('text-lg', 'sm:text-xl');
});
it('should have responsive button layout', () => {
render(<CTASection />, { wrapper: createWrapper() });
const signupButton = screen.getByRole('link', { name: /get started free/i });
expect(signupButton).toHaveClass('w-full', 'sm:w-auto');
});
it('should have responsive padding in minimal variant', () => {
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
const heading = screen.getByRole('heading', { name: /ready to get started/i });
expect(heading).toHaveClass('text-2xl', 'sm:text-3xl');
});
});
describe('Integration', () => {
it('should render correctly with default variant', () => {
render(<CTASection />, { wrapper: createWrapper() });
expect(screen.getByRole('heading', { name: /ready to get started/i })).toBeInTheDocument();
expect(screen.getByText(/join thousands of businesses/i)).toBeInTheDocument();
expect(screen.getByRole('link', { name: /get started free/i })).toHaveAttribute('href', '/signup');
expect(screen.getByRole('link', { name: /talk to sales/i })).toHaveAttribute('href', '/contact');
expect(screen.getByText(/no credit card required/i)).toBeInTheDocument();
});
it('should render correctly with minimal variant', () => {
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
expect(screen.getByRole('heading', { name: /ready to get started/i })).toBeInTheDocument();
expect(screen.getByText(/join thousands of businesses/i)).toBeInTheDocument();
expect(screen.getByRole('link', { name: /get started free/i })).toHaveAttribute('href', '/signup');
expect(screen.queryByRole('link', { name: /talk to sales/i })).not.toBeInTheDocument();
expect(screen.queryByText(/no credit card required/i)).not.toBeInTheDocument();
});
it('should maintain structure with all elements in place', () => {
const { container } = render(<CTASection />, { wrapper: createWrapper() });
const section = container.querySelector('section');
const heading = screen.getByRole('heading');
const subtitle = screen.getByText(/join thousands/i);
const buttons = screen.getAllByRole('link');
expect(section).toContainElement(heading);
expect(section).toContainElement(subtitle);
buttons.forEach(button => {
expect(section).toContainElement(button);
});
});
});
});

View File

@@ -0,0 +1,366 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
import CodeBlock from '../CodeBlock';
describe('CodeBlock', () => {
// Mock clipboard API
const originalClipboard = navigator.clipboard;
const mockWriteText = vi.fn();
beforeEach(() => {
Object.assign(navigator, {
clipboard: {
writeText: mockWriteText,
},
});
vi.useFakeTimers();
});
afterEach(() => {
Object.assign(navigator, {
clipboard: originalClipboard,
});
vi.restoreAllMocks();
vi.useRealTimers();
});
describe('Rendering', () => {
it('renders code content correctly', () => {
const code = 'print("Hello, World!")';
const { container } = render(<CodeBlock code={code} />);
// Check that the code content is rendered (text is within code element)
const codeElement = container.querySelector('code');
expect(codeElement?.textContent).toContain('print(');
// Due to string splitting in regex, checking for function call
expect(container.querySelector('.text-blue-400')?.textContent).toContain('print(');
});
it('renders multi-line code with line numbers', () => {
const code = 'line 1\nline 2\nline 3';
render(<CodeBlock code={code} />);
// Check line numbers
expect(screen.getByText('1')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
// Check content
expect(screen.getByText(/line 1/)).toBeInTheDocument();
expect(screen.getByText(/line 2/)).toBeInTheDocument();
expect(screen.getByText(/line 3/)).toBeInTheDocument();
});
it('renders terminal-style dots', () => {
render(<CodeBlock code="test code" />);
const container = screen.getByRole('button', { name: /copy code/i }).closest('div');
expect(container).toBeInTheDocument();
// Check for the presence of the terminal-style dots container
const dotsContainer = container?.querySelector('.flex.gap-1\\.5');
expect(dotsContainer).toBeInTheDocument();
expect(dotsContainer?.children).toHaveLength(3);
});
});
describe('Language and Filename', () => {
it('applies default language class when no language specified', () => {
const code = 'test code';
render(<CodeBlock code={code} />);
const codeElement = screen.getByText(/test code/).closest('code');
expect(codeElement).toHaveClass('language-python');
});
it('applies custom language class when specified', () => {
const code = 'const x = 1;';
render(<CodeBlock code={code} language="javascript" />);
const codeElement = screen.getByText(/const x = 1/).closest('code');
expect(codeElement).toHaveClass('language-javascript');
});
it('displays filename when provided', () => {
const code = 'test code';
const filename = 'example.py';
render(<CodeBlock code={code} filename={filename} />);
expect(screen.getByText(filename)).toBeInTheDocument();
});
it('does not display filename when not provided', () => {
const code = 'test code';
render(<CodeBlock code={code} />);
// The filename element should not exist in the DOM
const filenameElement = screen.queryByText(/\.py$/);
expect(filenameElement).not.toBeInTheDocument();
});
});
describe('Copy Functionality', () => {
it('renders copy button', () => {
render(<CodeBlock code="test code" />);
const copyButton = screen.getByRole('button', { name: /copy code/i });
expect(copyButton).toBeInTheDocument();
});
it('copies code to clipboard when copy button is clicked', async () => {
const code = 'print("Copy me!")';
mockWriteText.mockResolvedValue(undefined);
render(<CodeBlock code={code} />);
const copyButton = screen.getByRole('button', { name: /copy code/i });
fireEvent.click(copyButton);
expect(mockWriteText).toHaveBeenCalledWith(code);
});
it('shows check icon after successful copy', async () => {
const code = 'test code';
mockWriteText.mockResolvedValue(undefined);
const { container } = render(<CodeBlock code={code} />);
const copyButton = screen.getByRole('button', { name: /copy code/i });
// Initially should show Copy icon
let copyIcon = copyButton.querySelector('svg');
expect(copyIcon).toBeInTheDocument();
// Click to copy
fireEvent.click(copyButton);
// Should immediately show Check icon (synchronous state update)
const checkIcon = container.querySelector('.text-green-400');
expect(checkIcon).toBeInTheDocument();
});
it('reverts to copy icon after 2 seconds', async () => {
const code = 'test code';
mockWriteText.mockResolvedValue(undefined);
const { container } = render(<CodeBlock code={code} />);
const copyButton = screen.getByRole('button', { name: /copy code/i });
// Click to copy
await act(async () => {
fireEvent.click(copyButton);
});
// Should show Check icon
let checkIcon = container.querySelector('.text-green-400');
expect(checkIcon).toBeInTheDocument();
// Fast-forward 2 seconds using act to wrap state updates
await act(async () => {
vi.advanceTimersByTime(2000);
});
// Should revert to Copy icon (check icon should be gone)
checkIcon = container.querySelector('.text-green-400');
expect(checkIcon).not.toBeInTheDocument();
});
});
describe('Syntax Highlighting', () => {
it('highlights Python comments', () => {
const code = '# This is a comment';
render(<CodeBlock code={code} language="python" />);
const commentElement = screen.getByText(/This is a comment/);
expect(commentElement).toBeInTheDocument();
expect(commentElement).toHaveClass('text-gray-500');
});
it('highlights JavaScript comments', () => {
const code = '// This is a comment';
render(<CodeBlock code={code} language="javascript" />);
const commentElement = screen.getByText(/This is a comment/);
expect(commentElement).toBeInTheDocument();
expect(commentElement).toHaveClass('text-gray-500');
});
it('highlights string literals', () => {
const code = 'print("Hello World")';
const { container } = render(<CodeBlock code={code} />);
const stringElements = container.querySelectorAll('.text-green-400');
expect(stringElements.length).toBeGreaterThan(0);
});
it('highlights Python keywords', () => {
const code = 'def my_function():';
const { container } = render(<CodeBlock code={code} language="python" />);
const keywordElements = container.querySelectorAll('.text-purple-400');
expect(keywordElements.length).toBeGreaterThan(0);
});
it('highlights function calls', () => {
const code = 'print("test")';
const { container } = render(<CodeBlock code={code} />);
const functionElements = container.querySelectorAll('.text-blue-400');
expect(functionElements.length).toBeGreaterThan(0);
});
it('highlights multiple keywords in a line', () => {
const code = 'if True return None';
const { container } = render(<CodeBlock code={code} />);
const keywordElements = container.querySelectorAll('.text-purple-400');
// Should highlight 'if', 'True', 'return', and 'None'
expect(keywordElements.length).toBeGreaterThanOrEqual(3);
});
it('does not highlight non-keyword words', () => {
const code = 'my_variable = 42';
render(<CodeBlock code={code} />);
const codeText = screen.getByText(/my_variable/);
expect(codeText).toBeInTheDocument();
});
});
describe('Complex Code Examples', () => {
it('handles Python code with multiple syntax elements', () => {
const code = `def greet(name):
# Print a greeting
return "Hello, " + name`;
render(<CodeBlock code={code} language="python" />);
// Check that all lines are rendered
expect(screen.getByText(/def/)).toBeInTheDocument();
expect(screen.getByText(/Print a greeting/)).toBeInTheDocument();
expect(screen.getByText(/return/)).toBeInTheDocument();
});
it('handles JavaScript code', () => {
const code = `const greeting = "Hello";
// Log the greeting
console.log(greeting);`;
render(<CodeBlock code={code} language="javascript" />);
expect(screen.getByText(/const greeting =/)).toBeInTheDocument();
expect(screen.getByText(/Log the greeting/)).toBeInTheDocument();
expect(screen.getByText(/console.log/)).toBeInTheDocument();
});
it('preserves indentation and whitespace', () => {
const code = `def test():
if True:
return 1`;
const { container } = render(<CodeBlock code={code} />);
// Check for whitespace-pre class which preserves whitespace
const codeLines = container.querySelectorAll('.whitespace-pre');
expect(codeLines.length).toBeGreaterThan(0);
});
});
describe('Edge Cases', () => {
it('handles empty code string', () => {
render(<CodeBlock code="" />);
const copyButton = screen.getByRole('button', { name: /copy code/i });
expect(copyButton).toBeInTheDocument();
});
it('handles code with only whitespace', () => {
const code = ' \n \n ';
render(<CodeBlock code={code} />);
// Should still render line numbers
expect(screen.getByText('1')).toBeInTheDocument();
});
it('handles very long single line', () => {
const code = 'x = ' + 'a'.repeat(1000);
render(<CodeBlock code={code} />);
expect(screen.getByText('1')).toBeInTheDocument();
});
it('handles special characters in code', () => {
const code = 'const regex = /[a-z]+/g;';
render(<CodeBlock code={code} language="javascript" />);
expect(screen.getByText(/regex/)).toBeInTheDocument();
});
it('handles quotes within strings', () => {
const code = 'const msg = "test message";';
const { container } = render(<CodeBlock code={code} language="javascript" />);
// Code should be rendered
expect(container.querySelector('code')).toBeInTheDocument();
// Should have string highlighting
expect(container.querySelectorAll('.text-green-400').length).toBeGreaterThan(0);
});
});
describe('Accessibility', () => {
it('has accessible copy button with title', () => {
render(<CodeBlock code="test" />);
const copyButton = screen.getByRole('button', { name: /copy code/i });
expect(copyButton).toHaveAttribute('title', 'Copy code');
});
it('uses semantic HTML elements', () => {
const { container } = render(<CodeBlock code="test" />);
const preElement = container.querySelector('pre');
const codeElement = container.querySelector('code');
expect(preElement).toBeInTheDocument();
expect(codeElement).toBeInTheDocument();
});
it('line numbers are not selectable', () => {
const { container } = render(<CodeBlock code="line 1\nline 2" />);
const lineNumbers = container.querySelectorAll('.select-none');
expect(lineNumbers.length).toBeGreaterThan(0);
});
});
describe('Styling', () => {
it('applies dark theme styling', () => {
const { container } = render(<CodeBlock code="test" />);
const mainContainer = container.querySelector('.bg-gray-900');
expect(mainContainer).toBeInTheDocument();
});
it('applies proper border and shadow', () => {
const { container } = render(<CodeBlock code="test" />);
const mainContainer = container.querySelector('.border-gray-800.shadow-2xl');
expect(mainContainer).toBeInTheDocument();
});
it('applies monospace font to code', () => {
const { container } = render(<CodeBlock code="test" />);
const preElement = container.querySelector('pre.font-mono');
expect(preElement).toBeInTheDocument();
});
it('applies correct text colors', () => {
const { container } = render(<CodeBlock code="test" />);
const codeText = container.querySelector('.text-gray-300');
expect(codeText).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,431 @@
/**
* Unit tests for FAQAccordion component
*
* Tests the FAQ accordion functionality including:
* - Rendering questions and answers
* - Expanding and collapsing items
* - Single-item accordion behavior (only one open at a time)
* - Accessibility attributes
*/
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import FAQAccordion from '../FAQAccordion';
// Test data
const mockFAQItems = [
{
question: 'What is SmoothSchedule?',
answer: 'SmoothSchedule is a comprehensive scheduling platform for businesses.',
},
{
question: 'How much does it cost?',
answer: 'We offer flexible pricing plans starting at $29/month.',
},
{
question: 'Can I try it for free?',
answer: 'Yes! We offer a 14-day free trial with no credit card required.',
},
];
describe('FAQAccordion', () => {
describe('Rendering', () => {
it('should render all questions', () => {
render(<FAQAccordion items={mockFAQItems} />);
expect(screen.getByText('What is SmoothSchedule?')).toBeInTheDocument();
expect(screen.getByText('How much does it cost?')).toBeInTheDocument();
expect(screen.getByText('Can I try it for free?')).toBeInTheDocument();
});
it('should render first item as expanded by default', () => {
render(<FAQAccordion items={mockFAQItems} />);
// First answer should be visible
expect(
screen.getByText('SmoothSchedule is a comprehensive scheduling platform for businesses.')
).toBeInTheDocument();
// Other answers should not be visible
expect(
screen.queryByText('We offer flexible pricing plans starting at $29/month.')
).toBeInTheDocument();
expect(
screen.queryByText('Yes! We offer a 14-day free trial with no credit card required.')
).toBeInTheDocument();
});
it('should render with empty items array', () => {
const { container } = render(<FAQAccordion items={[]} />);
// Should render the container but no items
expect(container.querySelector('.space-y-4')).toBeInTheDocument();
expect(container.querySelectorAll('button')).toHaveLength(0);
});
it('should render with single item', () => {
const singleItem = [mockFAQItems[0]];
render(<FAQAccordion items={singleItem} />);
expect(screen.getByText('What is SmoothSchedule?')).toBeInTheDocument();
expect(
screen.getByText('SmoothSchedule is a comprehensive scheduling platform for businesses.')
).toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('should have aria-expanded attribute on buttons', () => {
render(<FAQAccordion items={mockFAQItems} />);
const buttons = screen.getAllByRole('button');
// First button should be expanded (default)
expect(buttons[0]).toHaveAttribute('aria-expanded', 'true');
// Other buttons should be collapsed
expect(buttons[1]).toHaveAttribute('aria-expanded', 'false');
expect(buttons[2]).toHaveAttribute('aria-expanded', 'false');
});
it('should update aria-expanded when item is toggled', () => {
render(<FAQAccordion items={mockFAQItems} />);
const buttons = screen.getAllByRole('button');
const secondButton = buttons[1];
// Initially collapsed
expect(secondButton).toHaveAttribute('aria-expanded', 'false');
// Click to expand
fireEvent.click(secondButton);
// Now expanded
expect(secondButton).toHaveAttribute('aria-expanded', 'true');
});
it('should have proper button semantics', () => {
render(<FAQAccordion items={mockFAQItems} />);
const buttons = screen.getAllByRole('button');
buttons.forEach((button) => {
// Each button should have text content
expect(button.textContent).toBeTruthy();
// Each button should be clickable
expect(button).toBeEnabled();
});
});
});
describe('Expand/Collapse Behavior', () => {
it('should expand answer when question is clicked', () => {
render(<FAQAccordion items={mockFAQItems} />);
const secondQuestion = screen.getByText('How much does it cost?');
// Answer should be in the document but potentially hidden
const answer = screen.getByText('We offer flexible pricing plans starting at $29/month.');
const answerContainer = answer.closest('.overflow-hidden');
// Initially collapsed (max-h-0)
expect(answerContainer).toHaveClass('max-h-0');
// Click to expand
fireEvent.click(secondQuestion);
// Now expanded (max-h-96)
expect(answerContainer).toHaveClass('max-h-96');
});
it('should collapse answer when clicking expanded question', () => {
render(<FAQAccordion items={mockFAQItems} />);
const firstQuestion = screen.getByText('What is SmoothSchedule?');
const answer = screen.getByText(
'SmoothSchedule is a comprehensive scheduling platform for businesses.'
);
const answerContainer = answer.closest('.overflow-hidden');
// Initially expanded (first item is open by default)
expect(answerContainer).toHaveClass('max-h-96');
// Click to collapse
fireEvent.click(firstQuestion);
// Now collapsed
expect(answerContainer).toHaveClass('max-h-0');
});
it('should collapse answer when clicking it again (toggle)', () => {
render(<FAQAccordion items={mockFAQItems} />);
const secondQuestion = screen.getByText('How much does it cost?');
const answer = screen.getByText('We offer flexible pricing plans starting at $29/month.');
const answerContainer = answer.closest('.overflow-hidden');
// Initially collapsed
expect(answerContainer).toHaveClass('max-h-0');
// Click to expand
fireEvent.click(secondQuestion);
expect(answerContainer).toHaveClass('max-h-96');
// Click again to collapse
fireEvent.click(secondQuestion);
expect(answerContainer).toHaveClass('max-h-0');
});
});
describe('Single Item Accordion Behavior', () => {
it('should only allow one item to be expanded at a time', () => {
render(<FAQAccordion items={mockFAQItems} />);
const firstQuestion = screen.getByText('What is SmoothSchedule?');
const secondQuestion = screen.getByText('How much does it cost?');
const thirdQuestion = screen.getByText('Can I try it for free?');
const firstAnswer = screen.getByText(
'SmoothSchedule is a comprehensive scheduling platform for businesses.'
);
const secondAnswer = screen.getByText(
'We offer flexible pricing plans starting at $29/month.'
);
const thirdAnswer = screen.getByText(
'Yes! We offer a 14-day free trial with no credit card required.'
);
// Initially, first item is expanded
expect(firstAnswer.closest('.overflow-hidden')).toHaveClass('max-h-96');
expect(secondAnswer.closest('.overflow-hidden')).toHaveClass('max-h-0');
expect(thirdAnswer.closest('.overflow-hidden')).toHaveClass('max-h-0');
// Click second question
fireEvent.click(secondQuestion);
// Now only second item is expanded
expect(firstAnswer.closest('.overflow-hidden')).toHaveClass('max-h-0');
expect(secondAnswer.closest('.overflow-hidden')).toHaveClass('max-h-96');
expect(thirdAnswer.closest('.overflow-hidden')).toHaveClass('max-h-0');
// Click third question
fireEvent.click(thirdQuestion);
// Now only third item is expanded
expect(firstAnswer.closest('.overflow-hidden')).toHaveClass('max-h-0');
expect(secondAnswer.closest('.overflow-hidden')).toHaveClass('max-h-0');
expect(thirdAnswer.closest('.overflow-hidden')).toHaveClass('max-h-96');
// Click first question
fireEvent.click(firstQuestion);
// Back to first item expanded
expect(firstAnswer.closest('.overflow-hidden')).toHaveClass('max-h-96');
expect(secondAnswer.closest('.overflow-hidden')).toHaveClass('max-h-0');
expect(thirdAnswer.closest('.overflow-hidden')).toHaveClass('max-h-0');
});
it('should close the currently open item when opening another', () => {
render(<FAQAccordion items={mockFAQItems} />);
const buttons = screen.getAllByRole('button');
// First button is expanded by default
expect(buttons[0]).toHaveAttribute('aria-expanded', 'true');
expect(buttons[1]).toHaveAttribute('aria-expanded', 'false');
// Click second button
fireEvent.click(buttons[1]);
// First button should now be collapsed, second expanded
expect(buttons[0]).toHaveAttribute('aria-expanded', 'false');
expect(buttons[1]).toHaveAttribute('aria-expanded', 'true');
});
it('should allow collapsing all items by clicking the open one', () => {
render(<FAQAccordion items={mockFAQItems} />);
const firstQuestion = screen.getByText('What is SmoothSchedule?');
const buttons = screen.getAllByRole('button');
// Initially first item is expanded
expect(buttons[0]).toHaveAttribute('aria-expanded', 'true');
// Click to collapse
fireEvent.click(firstQuestion);
// All items should be collapsed
buttons.forEach((button) => {
expect(button).toHaveAttribute('aria-expanded', 'false');
});
});
});
describe('Chevron Icon Rotation', () => {
it('should rotate chevron icon when item is expanded', () => {
render(<FAQAccordion items={mockFAQItems} />);
const buttons = screen.getAllByRole('button');
const firstButton = buttons[0];
const secondButton = buttons[1];
// First item is expanded, so chevron should be rotated
const firstChevron = firstButton.querySelector('svg');
expect(firstChevron).toHaveClass('rotate-180');
// Second item is collapsed, so chevron should not be rotated
const secondChevron = secondButton.querySelector('svg');
expect(secondChevron).not.toHaveClass('rotate-180');
// Click second button
fireEvent.click(secondButton);
// Now second chevron should be rotated, first should not
expect(firstChevron).not.toHaveClass('rotate-180');
expect(secondChevron).toHaveClass('rotate-180');
});
it('should toggle chevron rotation when item is clicked multiple times', () => {
render(<FAQAccordion items={mockFAQItems} />);
const firstButton = screen.getAllByRole('button')[0];
const chevron = firstButton.querySelector('svg');
// Initially rotated (first item is expanded)
expect(chevron).toHaveClass('rotate-180');
// Click to collapse
fireEvent.click(firstButton);
expect(chevron).not.toHaveClass('rotate-180');
// Click to expand
fireEvent.click(firstButton);
expect(chevron).toHaveClass('rotate-180');
// Click to collapse again
fireEvent.click(firstButton);
expect(chevron).not.toHaveClass('rotate-180');
});
});
describe('Edge Cases', () => {
it('should handle items with long text content', () => {
const longTextItems = [
{
question: 'This is a very long question that might wrap to multiple lines in the UI?',
answer:
'This is a very long answer with lots of text. ' +
'It contains multiple sentences and provides detailed information. ' +
'The accordion should handle this gracefully without breaking the layout. ' +
'Users should be able to read all of this content when the item is expanded.',
},
];
render(<FAQAccordion items={longTextItems} />);
expect(
screen.getByText('This is a very long question that might wrap to multiple lines in the UI?')
).toBeInTheDocument();
const answer = screen.getByText(/This is a very long answer with lots of text/);
expect(answer).toBeInTheDocument();
});
it('should handle items with special characters', () => {
const specialCharItems = [
{
question: 'What about <special> & "characters"?',
answer: 'We support all UTF-8 characters: é, ñ, 中文, 日本語!',
},
];
render(<FAQAccordion items={specialCharItems} />);
expect(screen.getByText('What about <special> & "characters"?')).toBeInTheDocument();
expect(screen.getByText('We support all UTF-8 characters: é, ñ, 中文, 日本語!')).toBeInTheDocument();
});
it('should handle rapid clicking without breaking', () => {
render(<FAQAccordion items={mockFAQItems} />);
const buttons = screen.getAllByRole('button');
// Rapidly click different buttons
fireEvent.click(buttons[0]);
fireEvent.click(buttons[1]);
fireEvent.click(buttons[2]);
fireEvent.click(buttons[0]);
fireEvent.click(buttons[1]);
// Should still be functional - second button should be expanded
expect(buttons[1]).toHaveAttribute('aria-expanded', 'true');
expect(buttons[0]).toHaveAttribute('aria-expanded', 'false');
expect(buttons[2]).toHaveAttribute('aria-expanded', 'false');
});
it('should handle clicking on the same item multiple times', () => {
render(<FAQAccordion items={mockFAQItems} />);
const firstButton = screen.getAllByRole('button')[0];
// Initially expanded
expect(firstButton).toHaveAttribute('aria-expanded', 'true');
// Click multiple times
fireEvent.click(firstButton);
expect(firstButton).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(firstButton);
expect(firstButton).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(firstButton);
expect(firstButton).toHaveAttribute('aria-expanded', 'false');
});
});
describe('Visual States', () => {
it('should apply correct CSS classes for expanded state', () => {
render(<FAQAccordion items={mockFAQItems} />);
const firstAnswer = screen.getByText(
'SmoothSchedule is a comprehensive scheduling platform for businesses.'
);
const answerContainer = firstAnswer.closest('.overflow-hidden');
// Expanded state should have max-h-96
expect(answerContainer).toHaveClass('max-h-96');
expect(answerContainer).toHaveClass('transition-all');
expect(answerContainer).toHaveClass('duration-200');
});
it('should apply correct CSS classes for collapsed state', () => {
render(<FAQAccordion items={mockFAQItems} />);
const secondAnswer = screen.getByText('We offer flexible pricing plans starting at $29/month.');
const answerContainer = secondAnswer.closest('.overflow-hidden');
// Collapsed state should have max-h-0
expect(answerContainer).toHaveClass('max-h-0');
expect(answerContainer).toHaveClass('overflow-hidden');
});
it('should have proper container structure', () => {
const { container } = render(<FAQAccordion items={mockFAQItems} />);
// Root container should have space-y-4
const rootDiv = container.querySelector('.space-y-4');
expect(rootDiv).toBeInTheDocument();
// Each item should have proper styling
const itemContainers = container.querySelectorAll('.bg-white');
expect(itemContainers).toHaveLength(mockFAQItems.length);
itemContainers.forEach((item) => {
expect(item).toHaveClass('rounded-xl');
expect(item).toHaveClass('border');
expect(item).toHaveClass('overflow-hidden');
});
});
});
});

Some files were not shown because too many files have changed in this diff Show More