26 Commits

Author SHA1 Message Date
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
623 changed files with 132290 additions and 3913 deletions

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()

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'));
@@ -109,6 +110,8 @@ 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
// Settings pages
const SettingsLayout = React.lazy(() => import('./layouts/SettingsLayout'));
@@ -345,7 +348,7 @@ const AppContent: React.FC = () => {
return (
<Suspense fallback={<LoadingScreen />}>
<Routes>
<Route path="/" element={<TenantLandingPage subdomain={currentSubdomain} />} />
<Route path="/" element={<PublicPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
@@ -860,12 +863,19 @@ const AppContent: React.FC = () => {
/>
<Route
path="/messages"
element={
hasAccess(['owner', 'manager']) && user?.can_send_messages ? (
<Messages />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/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="/" />
)

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,212 @@
/**
* 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,
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;
};

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

@@ -0,0 +1,184 @@
/**
* 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;
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

@@ -33,6 +33,24 @@ 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;
}
export interface PlatformBusinessUpdate {
@@ -41,11 +59,38 @@ export interface PlatformBusinessUpdate {
subscription_tier?: string;
max_users?: number;
max_resources?: 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_process_refunds?: boolean;
can_create_packages?: boolean;
can_use_email_templates?: boolean;
can_customize_booking_page?: boolean;
advanced_reporting?: boolean;
priority_support?: boolean;
dedicated_support?: boolean;
sso_enabled?: boolean;
}
export interface PlatformBusinessCreate {

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,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

@@ -22,6 +22,7 @@ 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 +47,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();
@@ -126,6 +128,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
label={t('nav.tasks', 'Tasks')}
isCollapsed={isCollapsed}
locked={!canUse('plugins') || !canUse('tasks')}
badgeElement={<UnfinishedBadge />}
/>
)}
{isStaff && (
@@ -154,6 +157,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
icon={Users}
label={t('nav.customers')}
isCollapsed={isCollapsed}
badgeElement={<UnfinishedBadge />}
/>
<SidebarItem
to="/services"
@@ -174,6 +178,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
icon={Users}
label={t('nav.staff')}
isCollapsed={isCollapsed}
badgeElement={<UnfinishedBadge />}
/>
{canUse('contracts') && (
<SidebarItem
@@ -181,6 +186,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
icon={FileSignature}
label={t('nav.contracts', 'Contracts')}
isCollapsed={isCollapsed}
badgeElement={<UnfinishedBadge />}
/>
)}
<SidebarItem
@@ -195,9 +201,9 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
)}
{/* Communicate Section - Tickets + Messages */}
{(canViewTickets || canViewAdminPages) && (
{(canViewTickets || canSendMessages) && (
<SidebarSection title={t('nav.sections.communicate', 'Communicate')} isCollapsed={isCollapsed}>
{canViewAdminPages && (
{canSendMessages && (
<SidebarItem
to="/messages"
icon={MessageSquare}
@@ -238,6 +244,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
label={t('nav.plugins', 'Plugins')}
isCollapsed={isCollapsed}
locked={!canUse('plugins')}
badgeElement={<UnfinishedBadge />}
/>
</SidebarSection>
)}

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

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

View File

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

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

View File

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

View File

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

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,511 @@
/**
* 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();
});
});
});

View File

@@ -0,0 +1,66 @@
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 rounded-lg shadow-md max-w-md mx-auto text-left">
<h2 className="text-2xl font-bold mb-2" style={{ color: accentColor }}>{headline}</h2>
<p className="text-gray-600 mb-6">{subheading}</p>
<div className="space-y-4 mb-6">
{services?.length === 0 && <p>No services available.</p>}
{services?.map((service: any) => (
<div
key={service.id}
className={`p-4 border rounded cursor-pointer transition-colors ${selectedService?.id === service.id ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-blue-300'}`}
onClick={() => setSelectedService(service)}
>
<h3 className="font-semibold">{service.name}</h3>
<p className="text-sm text-gray-500">{service.duration} min - ${(service.price_cents / 100).toFixed(2)}</p>
</div>
))}
</div>
<button
onClick={handleBook}
disabled={!selectedService}
className="w-full py-2 px-4 rounded text-white font-medium disabled:opacity-50 transition-opacity"
style={{ backgroundColor: accentColor }}
>
{buttonLabel}
</button>
</div>
);
};
export default BookingWidget;

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,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');
});
});
});
});

View File

@@ -0,0 +1,688 @@
/**
* Unit tests for FeatureCard component
*
* Tests the FeatureCard marketing component including:
* - Basic rendering with title and description
* - Icon rendering with different colors
* - CSS classes and styling
* - Hover states and animations
* - Accessibility
*/
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Calendar, Clock, Users, CheckCircle, AlertCircle } from 'lucide-react';
import FeatureCard from '../FeatureCard';
describe('FeatureCard', () => {
describe('Basic Rendering', () => {
it('should render with title and description', () => {
render(
<FeatureCard
icon={Calendar}
title="Easy Scheduling"
description="Schedule appointments with ease using our intuitive calendar interface."
/>
);
expect(screen.getByText('Easy Scheduling')).toBeInTheDocument();
expect(
screen.getByText('Schedule appointments with ease using our intuitive calendar interface.')
).toBeInTheDocument();
});
it('should render with different content', () => {
render(
<FeatureCard
icon={Users}
title="Team Management"
description="Manage your team members and their availability efficiently."
/>
);
expect(screen.getByText('Team Management')).toBeInTheDocument();
expect(
screen.getByText('Manage your team members and their availability efficiently.')
).toBeInTheDocument();
});
it('should render with long description text', () => {
const longDescription =
'This is a very long description that contains multiple sentences. It should wrap properly and display all the content. Our feature card component is designed to handle various lengths of text gracefully.';
render(
<FeatureCard
icon={Clock}
title="Time Tracking"
description={longDescription}
/>
);
expect(screen.getByText(longDescription)).toBeInTheDocument();
});
it('should render with empty description', () => {
render(
<FeatureCard
icon={CheckCircle}
title="Success Tracking"
description=""
/>
);
expect(screen.getByText('Success Tracking')).toBeInTheDocument();
// Empty description should still render the paragraph element
const descriptionElement = screen.getByText('Success Tracking').parentElement?.querySelector('p');
expect(descriptionElement).toBeInTheDocument();
});
});
describe('Icon Rendering', () => {
it('should render the provided icon', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Calendar Feature"
description="Calendar description"
/>
);
// Check for SVG element (icons are rendered as SVG)
const svgElement = container.querySelector('svg');
expect(svgElement).toBeInTheDocument();
expect(svgElement).toHaveClass('h-6', 'w-6');
});
it('should render different icons correctly', () => {
const { container: container1 } = render(
<FeatureCard
icon={Calendar}
title="Feature 1"
description="Description 1"
/>
);
const { container: container2 } = render(
<FeatureCard
icon={Users}
title="Feature 2"
description="Description 2"
/>
);
// Both should have SVG elements
expect(container1.querySelector('svg')).toBeInTheDocument();
expect(container2.querySelector('svg')).toBeInTheDocument();
});
it('should apply correct icon size classes', () => {
const { container } = render(
<FeatureCard
icon={Clock}
title="Time Feature"
description="Time description"
/>
);
const svgElement = container.querySelector('svg');
expect(svgElement).toHaveClass('h-6');
expect(svgElement).toHaveClass('w-6');
});
});
describe('Icon Colors', () => {
it('should render with default brand color when no iconColor prop provided', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Default Color"
description="Uses brand color by default"
/>
);
const iconWrapper = container.querySelector('.inline-flex');
expect(iconWrapper).toHaveClass('bg-brand-100');
expect(iconWrapper).toHaveClass('dark:bg-brand-900/30');
expect(iconWrapper).toHaveClass('text-brand-600');
expect(iconWrapper).toHaveClass('dark:text-brand-400');
});
it('should render with brand color when explicitly set', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Brand Color"
description="Explicit brand color"
iconColor="brand"
/>
);
const iconWrapper = container.querySelector('.inline-flex');
expect(iconWrapper).toHaveClass('bg-brand-100');
expect(iconWrapper).toHaveClass('text-brand-600');
});
it('should render with green color', () => {
const { container } = render(
<FeatureCard
icon={CheckCircle}
title="Success Feature"
description="Green icon color"
iconColor="green"
/>
);
const iconWrapper = container.querySelector('.inline-flex');
expect(iconWrapper).toHaveClass('bg-green-100');
expect(iconWrapper).toHaveClass('dark:bg-green-900/30');
expect(iconWrapper).toHaveClass('text-green-600');
expect(iconWrapper).toHaveClass('dark:text-green-400');
});
it('should render with purple color', () => {
const { container } = render(
<FeatureCard
icon={Users}
title="Purple Feature"
description="Purple icon color"
iconColor="purple"
/>
);
const iconWrapper = container.querySelector('.inline-flex');
expect(iconWrapper).toHaveClass('bg-purple-100');
expect(iconWrapper).toHaveClass('text-purple-600');
});
it('should render with orange color', () => {
const { container } = render(
<FeatureCard
icon={AlertCircle}
title="Warning Feature"
description="Orange icon color"
iconColor="orange"
/>
);
const iconWrapper = container.querySelector('.inline-flex');
expect(iconWrapper).toHaveClass('bg-orange-100');
expect(iconWrapper).toHaveClass('text-orange-600');
});
it('should render with pink color', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Pink Feature"
description="Pink icon color"
iconColor="pink"
/>
);
const iconWrapper = container.querySelector('.inline-flex');
expect(iconWrapper).toHaveClass('bg-pink-100');
expect(iconWrapper).toHaveClass('text-pink-600');
});
it('should render with cyan color', () => {
const { container } = render(
<FeatureCard
icon={Clock}
title="Cyan Feature"
description="Cyan icon color"
iconColor="cyan"
/>
);
const iconWrapper = container.querySelector('.inline-flex');
expect(iconWrapper).toHaveClass('bg-cyan-100');
expect(iconWrapper).toHaveClass('text-cyan-600');
});
});
describe('Styling and CSS Classes', () => {
it('should apply base card styling classes', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Card Styling"
description="Testing base styles"
/>
);
const cardElement = container.firstChild as HTMLElement;
expect(cardElement).toHaveClass('group');
expect(cardElement).toHaveClass('p-6');
expect(cardElement).toHaveClass('bg-white');
expect(cardElement).toHaveClass('dark:bg-gray-800');
expect(cardElement).toHaveClass('rounded-2xl');
});
it('should apply border classes', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Border Test"
description="Testing border styles"
/>
);
const cardElement = container.firstChild as HTMLElement;
expect(cardElement).toHaveClass('border');
expect(cardElement).toHaveClass('border-gray-200');
expect(cardElement).toHaveClass('dark:border-gray-700');
});
it('should apply hover border classes', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Hover Border"
description="Testing hover border styles"
/>
);
const cardElement = container.firstChild as HTMLElement;
expect(cardElement).toHaveClass('hover:border-brand-300');
expect(cardElement).toHaveClass('dark:hover:border-brand-700');
});
it('should apply shadow classes', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Shadow Test"
description="Testing shadow styles"
/>
);
const cardElement = container.firstChild as HTMLElement;
expect(cardElement).toHaveClass('hover:shadow-lg');
expect(cardElement).toHaveClass('hover:shadow-brand-600/5');
});
it('should apply transition classes', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Transition Test"
description="Testing transition styles"
/>
);
const cardElement = container.firstChild as HTMLElement;
expect(cardElement).toHaveClass('transition-all');
expect(cardElement).toHaveClass('duration-300');
});
it('should apply icon wrapper styling', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Icon Wrapper"
description="Testing icon wrapper styles"
/>
);
const iconWrapper = container.querySelector('.inline-flex');
expect(iconWrapper).toHaveClass('p-3');
expect(iconWrapper).toHaveClass('rounded-xl');
expect(iconWrapper).toHaveClass('mb-4');
});
it('should apply title styling', () => {
render(
<FeatureCard
icon={Calendar}
title="Title Styling"
description="Testing title styles"
/>
);
const titleElement = screen.getByText('Title Styling');
expect(titleElement).toHaveClass('text-lg');
expect(titleElement).toHaveClass('font-semibold');
expect(titleElement).toHaveClass('text-gray-900');
expect(titleElement).toHaveClass('dark:text-white');
expect(titleElement).toHaveClass('mb-2');
});
it('should apply title hover classes', () => {
render(
<FeatureCard
icon={Calendar}
title="Hover Title"
description="Testing title hover styles"
/>
);
const titleElement = screen.getByText('Hover Title');
expect(titleElement).toHaveClass('group-hover:text-brand-600');
expect(titleElement).toHaveClass('dark:group-hover:text-brand-400');
expect(titleElement).toHaveClass('transition-colors');
});
it('should apply description styling', () => {
render(
<FeatureCard
icon={Calendar}
title="Description Style"
description="Testing description styles"
/>
);
const descriptionElement = screen.getByText('Testing description styles');
expect(descriptionElement).toHaveClass('text-gray-600');
expect(descriptionElement).toHaveClass('dark:text-gray-400');
expect(descriptionElement).toHaveClass('leading-relaxed');
});
});
describe('Hover and Animation States', () => {
it('should have group class for hover effects', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Group Hover"
description="Testing group hover functionality"
/>
);
const cardElement = container.firstChild as HTMLElement;
expect(cardElement).toHaveClass('group');
});
it('should support mouse hover interactions', async () => {
const user = userEvent.setup();
const { container } = render(
<FeatureCard
icon={Calendar}
title="Mouse Hover"
description="Testing mouse hover"
/>
);
const cardElement = container.firstChild as HTMLElement;
// Hovering should not cause errors
await user.hover(cardElement);
expect(cardElement).toBeInTheDocument();
// Unhovering should not cause errors
await user.unhover(cardElement);
expect(cardElement).toBeInTheDocument();
});
it('should maintain structure during hover', async () => {
const user = userEvent.setup();
render(
<FeatureCard
icon={Calendar}
title="Structure Test"
description="Testing structure during hover"
/>
);
const titleElement = screen.getByText('Structure Test');
const descriptionElement = screen.getByText('Testing structure during hover');
// Hover over the card
await user.hover(titleElement.closest('.group')!);
// Elements should still be present
expect(titleElement).toBeInTheDocument();
expect(descriptionElement).toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('should use semantic HTML heading for title', () => {
render(
<FeatureCard
icon={Calendar}
title="Semantic Title"
description="Testing semantic HTML"
/>
);
const titleElement = screen.getByText('Semantic Title');
expect(titleElement.tagName).toBe('H3');
});
it('should use paragraph element for description', () => {
render(
<FeatureCard
icon={Calendar}
title="Semantic Description"
description="Testing paragraph element"
/>
);
const descriptionElement = screen.getByText('Testing paragraph element');
expect(descriptionElement.tagName).toBe('P');
});
it('should maintain readable text contrast', () => {
render(
<FeatureCard
icon={Calendar}
title="Contrast Test"
description="Testing text contrast"
/>
);
const titleElement = screen.getByText('Contrast Test');
const descriptionElement = screen.getByText('Testing text contrast');
// Title should have dark text (gray-900)
expect(titleElement).toHaveClass('text-gray-900');
// Description should have readable gray
expect(descriptionElement).toHaveClass('text-gray-600');
});
it('should be keyboard accessible when used in interactive context', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Keyboard Test"
description="Testing keyboard accessibility"
/>
);
const cardElement = container.firstChild as HTMLElement;
// Card itself is not interactive, so it shouldn't have tabIndex
expect(cardElement).not.toHaveAttribute('tabIndex');
});
it('should support screen readers with proper text hierarchy', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Screen Reader Test"
description="This is a longer description that screen readers will announce."
/>
);
// Check that heading comes before paragraph in DOM order
const heading = container.querySelector('h3');
const paragraph = container.querySelector('p');
expect(heading).toBeInTheDocument();
expect(paragraph).toBeInTheDocument();
// Verify DOM order (heading should appear before paragraph)
const headingPosition = Array.from(container.querySelectorAll('*')).indexOf(heading!);
const paragraphPosition = Array.from(container.querySelectorAll('*')).indexOf(paragraph!);
expect(headingPosition).toBeLessThan(paragraphPosition);
});
});
describe('Dark Mode Support', () => {
it('should include dark mode classes for card background', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Dark Mode Card"
description="Testing dark mode"
/>
);
const cardElement = container.firstChild as HTMLElement;
expect(cardElement).toHaveClass('dark:bg-gray-800');
});
it('should include dark mode classes for borders', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Dark Mode Border"
description="Testing dark mode borders"
/>
);
const cardElement = container.firstChild as HTMLElement;
expect(cardElement).toHaveClass('dark:border-gray-700');
expect(cardElement).toHaveClass('dark:hover:border-brand-700');
});
it('should include dark mode classes for title text', () => {
render(
<FeatureCard
icon={Calendar}
title="Dark Mode Title"
description="Testing dark mode title"
/>
);
const titleElement = screen.getByText('Dark Mode Title');
expect(titleElement).toHaveClass('dark:text-white');
expect(titleElement).toHaveClass('dark:group-hover:text-brand-400');
});
it('should include dark mode classes for description text', () => {
render(
<FeatureCard
icon={Calendar}
title="Dark Mode Description"
description="Testing dark mode description"
/>
);
const descriptionElement = screen.getByText('Testing dark mode description');
expect(descriptionElement).toHaveClass('dark:text-gray-400');
});
it('should include dark mode classes for icon colors', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Dark Mode Icon"
description="Testing dark mode icon"
iconColor="green"
/>
);
const iconWrapper = container.querySelector('.inline-flex');
expect(iconWrapper).toHaveClass('dark:bg-green-900/30');
expect(iconWrapper).toHaveClass('dark:text-green-400');
});
});
describe('Component Props Validation', () => {
it('should handle all required props', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Required Props"
description="All required props provided"
/>
);
expect(container.firstChild).toBeInTheDocument();
expect(screen.getByText('Required Props')).toBeInTheDocument();
expect(screen.getByText('All required props provided')).toBeInTheDocument();
});
it('should handle optional iconColor prop', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Optional Props"
description="Optional iconColor provided"
iconColor="purple"
/>
);
const iconWrapper = container.querySelector('.inline-flex');
expect(iconWrapper).toHaveClass('bg-purple-100');
});
it('should render correctly with minimal props', () => {
const { container } = render(
<FeatureCard
icon={Calendar}
title="Min Props"
description="Minimal props"
/>
);
expect(container.firstChild).toBeInTheDocument();
});
});
describe('Edge Cases', () => {
it('should handle very long title text', () => {
const longTitle = 'This is a very long title that might wrap to multiple lines in the card';
render(
<FeatureCard
icon={Calendar}
title={longTitle}
description="Normal description"
/>
);
expect(screen.getByText(longTitle)).toBeInTheDocument();
});
it('should handle special characters in title', () => {
const specialTitle = 'Special <>&"\' Characters';
render(
<FeatureCard
icon={Calendar}
title={specialTitle}
description="Testing special chars"
/>
);
expect(screen.getByText(specialTitle)).toBeInTheDocument();
});
it('should handle special characters in description', () => {
const specialDescription = 'Description with <>&"\' special characters';
render(
<FeatureCard
icon={Calendar}
title="Special Chars"
description={specialDescription}
/>
);
expect(screen.getByText(specialDescription)).toBeInTheDocument();
});
it('should handle unicode characters', () => {
render(
<FeatureCard
icon={Calendar}
title="Unicode Test 你好 🎉"
description="Description with émojis and 中文"
/>
);
expect(screen.getByText("Unicode Test 你好 🎉")).toBeInTheDocument();
expect(screen.getByText("Description with émojis and 中文")).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,544 @@
/**
* Unit tests for Footer component
*
* Tests cover:
* - Component rendering with all sections
* - Footer navigation links (Product, Company, Legal)
* - Social media links
* - Copyright text with dynamic year
* - Brand logo and name
* - Link accessibility
* - Internationalization (i18n)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import React from 'react';
import Footer from '../Footer';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'marketing.nav.features': 'Features',
'marketing.nav.pricing': 'Pricing',
'marketing.nav.getStarted': 'Get Started',
'marketing.nav.about': 'About',
'marketing.nav.contact': 'Contact',
'marketing.footer.legal.privacy': 'Privacy Policy',
'marketing.footer.legal.terms': 'Terms of Service',
'marketing.footer.product.title': 'Product',
'marketing.footer.company.title': 'Company',
'marketing.footer.legal.title': 'Legal',
'marketing.footer.brandName': 'Smooth Schedule',
'marketing.description': 'The all-in-one scheduling platform for businesses of all sizes. Manage resources, staff, and bookings effortlessly.',
'marketing.footer.copyright': 'Smooth Schedule Inc. All rights reserved.',
};
return translations[key] || key;
},
}),
}));
// Mock SmoothScheduleLogo component
vi.mock('../../SmoothScheduleLogo', () => ({
default: ({ className }: { className?: string }) => (
<svg data-testid="smooth-schedule-logo" className={className}>
<path d="test" />
</svg>
),
}));
// Test wrapper with Router
const createWrapper = () => {
return ({ children }: { children: React.ReactNode }) => (
<BrowserRouter>{children}</BrowserRouter>
);
};
describe('Footer', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('should render the footer element', () => {
render(<Footer />, { wrapper: createWrapper() });
const footer = screen.getByRole('contentinfo');
expect(footer).toBeInTheDocument();
});
it('should render all main sections', () => {
render(<Footer />, { wrapper: createWrapper() });
expect(screen.getByText('Product')).toBeInTheDocument();
expect(screen.getByText('Company')).toBeInTheDocument();
expect(screen.getByText('Legal')).toBeInTheDocument();
});
it('should apply correct CSS classes for styling', () => {
render(<Footer />, { wrapper: createWrapper() });
const footer = screen.getByRole('contentinfo');
expect(footer).toHaveClass('bg-gray-50');
expect(footer).toHaveClass('dark:bg-gray-900');
expect(footer).toHaveClass('border-t');
expect(footer).toHaveClass('border-gray-200');
expect(footer).toHaveClass('dark:border-gray-800');
});
});
describe('Brand Section', () => {
it('should render the SmoothSchedule logo', () => {
render(<Footer />, { wrapper: createWrapper() });
const logo = screen.getByTestId('smooth-schedule-logo');
expect(logo).toBeInTheDocument();
});
it('should render brand name with translation', () => {
render(<Footer />, { wrapper: createWrapper() });
expect(screen.getByText('Smooth Schedule')).toBeInTheDocument();
});
it('should render brand description', () => {
render(<Footer />, { wrapper: createWrapper() });
expect(
screen.getByText(
'The all-in-one scheduling platform for businesses of all sizes. Manage resources, staff, and bookings effortlessly.'
)
).toBeInTheDocument();
});
it('should link logo to homepage', () => {
render(<Footer />, { wrapper: createWrapper() });
const logoLink = screen.getByRole('link', { name: /smooth schedule/i });
expect(logoLink).toHaveAttribute('href', '/');
});
});
describe('Product Links', () => {
it('should render Product section title', () => {
render(<Footer />, { wrapper: createWrapper() });
expect(screen.getByText('Product')).toBeInTheDocument();
});
it('should render Features link', () => {
render(<Footer />, { wrapper: createWrapper() });
const featuresLink = screen.getByRole('link', { name: 'Features' });
expect(featuresLink).toBeInTheDocument();
expect(featuresLink).toHaveAttribute('href', '/features');
});
it('should render Pricing link', () => {
render(<Footer />, { wrapper: createWrapper() });
const pricingLink = screen.getByRole('link', { name: 'Pricing' });
expect(pricingLink).toBeInTheDocument();
expect(pricingLink).toHaveAttribute('href', '/pricing');
});
it('should render Get Started link', () => {
render(<Footer />, { wrapper: createWrapper() });
const getStartedLink = screen.getByRole('link', { name: 'Get Started' });
expect(getStartedLink).toBeInTheDocument();
expect(getStartedLink).toHaveAttribute('href', '/signup');
});
it('should apply correct styling to product links', () => {
render(<Footer />, { wrapper: createWrapper() });
const featuresLink = screen.getByRole('link', { name: 'Features' });
expect(featuresLink).toHaveClass('text-sm');
expect(featuresLink).toHaveClass('text-gray-600');
expect(featuresLink).toHaveClass('dark:text-gray-400');
expect(featuresLink).toHaveClass('hover:text-brand-600');
expect(featuresLink).toHaveClass('dark:hover:text-brand-400');
expect(featuresLink).toHaveClass('transition-colors');
});
});
describe('Company Links', () => {
it('should render Company section title', () => {
render(<Footer />, { wrapper: createWrapper() });
expect(screen.getByText('Company')).toBeInTheDocument();
});
it('should render About link', () => {
render(<Footer />, { wrapper: createWrapper() });
const aboutLink = screen.getByRole('link', { name: 'About' });
expect(aboutLink).toBeInTheDocument();
expect(aboutLink).toHaveAttribute('href', '/about');
});
it('should render Contact link', () => {
render(<Footer />, { wrapper: createWrapper() });
const contactLink = screen.getByRole('link', { name: 'Contact' });
expect(contactLink).toBeInTheDocument();
expect(contactLink).toHaveAttribute('href', '/contact');
});
it('should apply correct styling to company links', () => {
render(<Footer />, { wrapper: createWrapper() });
const aboutLink = screen.getByRole('link', { name: 'About' });
expect(aboutLink).toHaveClass('text-sm');
expect(aboutLink).toHaveClass('text-gray-600');
expect(aboutLink).toHaveClass('dark:text-gray-400');
expect(aboutLink).toHaveClass('hover:text-brand-600');
expect(aboutLink).toHaveClass('dark:hover:text-brand-400');
expect(aboutLink).toHaveClass('transition-colors');
});
});
describe('Legal Links', () => {
it('should render Legal section title', () => {
render(<Footer />, { wrapper: createWrapper() });
expect(screen.getByText('Legal')).toBeInTheDocument();
});
it('should render Privacy Policy link', () => {
render(<Footer />, { wrapper: createWrapper() });
const privacyLink = screen.getByRole('link', { name: 'Privacy Policy' });
expect(privacyLink).toBeInTheDocument();
expect(privacyLink).toHaveAttribute('href', '/privacy');
});
it('should render Terms of Service link', () => {
render(<Footer />, { wrapper: createWrapper() });
const termsLink = screen.getByRole('link', { name: 'Terms of Service' });
expect(termsLink).toBeInTheDocument();
expect(termsLink).toHaveAttribute('href', '/terms');
});
it('should apply correct styling to legal links', () => {
render(<Footer />, { wrapper: createWrapper() });
const privacyLink = screen.getByRole('link', { name: 'Privacy Policy' });
expect(privacyLink).toHaveClass('text-sm');
expect(privacyLink).toHaveClass('text-gray-600');
expect(privacyLink).toHaveClass('dark:text-gray-400');
expect(privacyLink).toHaveClass('hover:text-brand-600');
expect(privacyLink).toHaveClass('dark:hover:text-brand-400');
expect(privacyLink).toHaveClass('transition-colors');
});
});
describe('Social Media Links', () => {
it('should render all social media links', () => {
render(<Footer />, { wrapper: createWrapper() });
expect(screen.getByLabelText('Twitter')).toBeInTheDocument();
expect(screen.getByLabelText('LinkedIn')).toBeInTheDocument();
expect(screen.getByLabelText('GitHub')).toBeInTheDocument();
expect(screen.getByLabelText('YouTube')).toBeInTheDocument();
});
it('should render Twitter link with correct href', () => {
render(<Footer />, { wrapper: createWrapper() });
const twitterLink = screen.getByLabelText('Twitter');
expect(twitterLink).toHaveAttribute('href', 'https://twitter.com/smoothschedule');
expect(twitterLink).toHaveAttribute('target', '_blank');
expect(twitterLink).toHaveAttribute('rel', 'noopener noreferrer');
});
it('should render LinkedIn link with correct href', () => {
render(<Footer />, { wrapper: createWrapper() });
const linkedinLink = screen.getByLabelText('LinkedIn');
expect(linkedinLink).toHaveAttribute('href', 'https://linkedin.com/company/smoothschedule');
expect(linkedinLink).toHaveAttribute('target', '_blank');
expect(linkedinLink).toHaveAttribute('rel', 'noopener noreferrer');
});
it('should render GitHub link with correct href', () => {
render(<Footer />, { wrapper: createWrapper() });
const githubLink = screen.getByLabelText('GitHub');
expect(githubLink).toHaveAttribute('href', 'https://github.com/smoothschedule');
expect(githubLink).toHaveAttribute('target', '_blank');
expect(githubLink).toHaveAttribute('rel', 'noopener noreferrer');
});
it('should render YouTube link with correct href', () => {
render(<Footer />, { wrapper: createWrapper() });
const youtubeLink = screen.getByLabelText('YouTube');
expect(youtubeLink).toHaveAttribute('href', 'https://youtube.com/@smoothschedule');
expect(youtubeLink).toHaveAttribute('target', '_blank');
expect(youtubeLink).toHaveAttribute('rel', 'noopener noreferrer');
});
it('should apply correct styling to social links', () => {
render(<Footer />, { wrapper: createWrapper() });
const twitterLink = screen.getByLabelText('Twitter');
expect(twitterLink).toHaveClass('p-2');
expect(twitterLink).toHaveClass('rounded-lg');
expect(twitterLink).toHaveClass('text-gray-500');
expect(twitterLink).toHaveClass('hover:text-brand-600');
expect(twitterLink).toHaveClass('dark:text-gray-400');
expect(twitterLink).toHaveClass('dark:hover:text-brand-400');
expect(twitterLink).toHaveClass('hover:bg-gray-100');
expect(twitterLink).toHaveClass('dark:hover:bg-gray-800');
expect(twitterLink).toHaveClass('transition-colors');
});
it('should render social media icons as SVGs', () => {
render(<Footer />, { wrapper: createWrapper() });
const twitterLink = screen.getByLabelText('Twitter');
const icon = twitterLink.querySelector('svg');
expect(icon).toBeInTheDocument();
expect(icon).toHaveClass('h-5', 'w-5');
});
});
describe('Copyright Section', () => {
it('should render copyright text', () => {
render(<Footer />, { wrapper: createWrapper() });
expect(
screen.getByText(/Smooth Schedule Inc. All rights reserved./i)
).toBeInTheDocument();
});
it('should display current year in copyright', () => {
render(<Footer />, { wrapper: createWrapper() });
const currentYear = new Date().getFullYear();
expect(screen.getByText(new RegExp(currentYear.toString()))).toBeInTheDocument();
});
it('should apply correct styling to copyright text', () => {
render(<Footer />, { wrapper: createWrapper() });
const copyrightElement = screen.getByText(
/Smooth Schedule Inc. All rights reserved./i
);
expect(copyrightElement).toHaveClass('text-sm');
expect(copyrightElement).toHaveClass('text-center');
expect(copyrightElement).toHaveClass('text-gray-500');
expect(copyrightElement).toHaveClass('dark:text-gray-400');
});
it('should have proper spacing from content', () => {
render(<Footer />, { wrapper: createWrapper() });
const copyrightElement = screen.getByText(
/Smooth Schedule Inc. All rights reserved./i
);
const parent = copyrightElement.parentElement;
expect(parent).toHaveClass('mt-12');
expect(parent).toHaveClass('pt-8');
expect(parent).toHaveClass('border-t');
expect(parent).toHaveClass('border-gray-200');
expect(parent).toHaveClass('dark:border-gray-800');
});
});
describe('Section Titles', () => {
it('should style section titles consistently', () => {
render(<Footer />, { wrapper: createWrapper() });
const productTitle = screen.getByText('Product');
expect(productTitle).toHaveClass('text-sm');
expect(productTitle).toHaveClass('font-semibold');
expect(productTitle).toHaveClass('text-gray-900');
expect(productTitle).toHaveClass('dark:text-white');
expect(productTitle).toHaveClass('uppercase');
expect(productTitle).toHaveClass('tracking-wider');
expect(productTitle).toHaveClass('mb-4');
});
it('should render all section titles with h3 tags', () => {
render(<Footer />, { wrapper: createWrapper() });
const titles = ['Product', 'Company', 'Legal'];
titles.forEach((title) => {
const element = screen.getByText(title);
expect(element.tagName).toBe('H3');
});
});
});
describe('Accessibility', () => {
it('should use semantic footer element', () => {
render(<Footer />, { wrapper: createWrapper() });
const footer = screen.getByRole('contentinfo');
expect(footer.tagName).toBe('FOOTER');
});
it('should have aria-label on social links', () => {
render(<Footer />, { wrapper: createWrapper() });
const socialLabels = ['Twitter', 'LinkedIn', 'GitHub', 'YouTube'];
socialLabels.forEach((label) => {
const link = screen.getByLabelText(label);
expect(link).toHaveAttribute('aria-label', label);
});
});
it('should have proper heading hierarchy', () => {
render(<Footer />, { wrapper: createWrapper() });
const headings = screen.getAllByRole('heading', { level: 3 });
expect(headings).toHaveLength(3);
expect(headings[0]).toHaveTextContent('Product');
expect(headings[1]).toHaveTextContent('Company');
expect(headings[2]).toHaveTextContent('Legal');
});
it('should have list structure for links', () => {
render(<Footer />, { wrapper: createWrapper() });
const lists = screen.getAllByRole('list');
expect(lists.length).toBeGreaterThanOrEqual(3);
});
it('should have keyboard-accessible links', () => {
render(<Footer />, { wrapper: createWrapper() });
const links = screen.getAllByRole('link');
links.forEach((link) => {
expect(link).toBeInTheDocument();
expect(link.tagName).toBe('A');
});
});
});
describe('Layout and Structure', () => {
it('should use grid layout for sections', () => {
render(<Footer />, { wrapper: createWrapper() });
const footer = screen.getByRole('contentinfo');
const gridContainer = footer.querySelector('.grid');
expect(gridContainer).toBeInTheDocument();
});
it('should have responsive grid classes', () => {
render(<Footer />, { wrapper: createWrapper() });
const footer = screen.getByRole('contentinfo');
const gridContainer = footer.querySelector('.grid');
expect(gridContainer).toHaveClass('grid-cols-2');
expect(gridContainer).toHaveClass('md:grid-cols-4');
expect(gridContainer).toHaveClass('gap-8');
expect(gridContainer).toHaveClass('lg:gap-12');
});
it('should have proper padding on container', () => {
render(<Footer />, { wrapper: createWrapper() });
const footer = screen.getByRole('contentinfo');
const container = footer.querySelector('.max-w-7xl');
expect(container).toHaveClass('max-w-7xl');
expect(container).toHaveClass('mx-auto');
expect(container).toHaveClass('px-4');
expect(container).toHaveClass('sm:px-6');
expect(container).toHaveClass('lg:px-8');
expect(container).toHaveClass('py-12');
expect(container).toHaveClass('lg:py-16');
});
});
describe('Internationalization', () => {
it('should use translations for all text content', () => {
render(<Footer />, { wrapper: createWrapper() });
// Product links
expect(screen.getByText('Features')).toBeInTheDocument();
expect(screen.getByText('Pricing')).toBeInTheDocument();
expect(screen.getByText('Get Started')).toBeInTheDocument();
// Company links
expect(screen.getByText('About')).toBeInTheDocument();
expect(screen.getByText('Contact')).toBeInTheDocument();
// Legal links
expect(screen.getByText('Privacy Policy')).toBeInTheDocument();
expect(screen.getByText('Terms of Service')).toBeInTheDocument();
// Section titles
expect(screen.getByText('Product')).toBeInTheDocument();
expect(screen.getByText('Company')).toBeInTheDocument();
expect(screen.getByText('Legal')).toBeInTheDocument();
// Brand and copyright
expect(screen.getByText('Smooth Schedule')).toBeInTheDocument();
expect(
screen.getByText(/Smooth Schedule Inc\. All rights reserved\./i)
).toBeInTheDocument();
});
});
describe('Integration', () => {
it('should render complete footer with all sections', () => {
render(<Footer />, { wrapper: createWrapper() });
// Brand section
expect(screen.getByTestId('smooth-schedule-logo')).toBeInTheDocument();
expect(screen.getByText('Smooth Schedule')).toBeInTheDocument();
// Navigation sections
expect(screen.getByText('Product')).toBeInTheDocument();
expect(screen.getByText('Company')).toBeInTheDocument();
expect(screen.getByText('Legal')).toBeInTheDocument();
// Social links
expect(screen.getByLabelText('Twitter')).toBeInTheDocument();
expect(screen.getByLabelText('LinkedIn')).toBeInTheDocument();
expect(screen.getByLabelText('GitHub')).toBeInTheDocument();
expect(screen.getByLabelText('YouTube')).toBeInTheDocument();
// Copyright
const currentYear = new Date().getFullYear();
expect(screen.getByText(new RegExp(currentYear.toString()))).toBeInTheDocument();
expect(
screen.getByText(/Smooth Schedule Inc\. All rights reserved\./i)
).toBeInTheDocument();
});
it('should have correct number of navigation links', () => {
render(<Footer />, { wrapper: createWrapper() });
const allLinks = screen.getAllByRole('link');
// 1 logo link + 3 product + 2 company + 2 legal + 4 social = 12 total
expect(allLinks).toHaveLength(12);
});
it('should maintain proper visual hierarchy', () => {
render(<Footer />, { wrapper: createWrapper() });
// Check that sections are in correct order
const footer = screen.getByRole('contentinfo');
const text = footer.textContent || '';
// Brand should come before sections
const brandIndex = text.indexOf('Smooth Schedule');
const productIndex = text.indexOf('Product');
const companyIndex = text.indexOf('Company');
const legalIndex = text.indexOf('Legal');
expect(brandIndex).toBeLessThan(productIndex);
expect(productIndex).toBeLessThan(companyIndex);
expect(companyIndex).toBeLessThan(legalIndex);
});
});
});

View File

@@ -0,0 +1,625 @@
/**
* Unit tests for Hero component
*
* Tests cover:
* - Component rendering with all elements
* - Headline and title rendering
* - Subheadline/description rendering
* - CTA buttons presence and functionality
* - Visual content and graphics rendering
* - Feature badges display
* - Responsive design elements
* - Accessibility attributes
* - Internationalization (i18n)
* - Background decorative elements
* - Statistics and metrics display
*/
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 Hero from '../Hero';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
// Return mock translations based on key
const translations: Record<string, string> = {
'marketing.hero.badge': 'New: Automation Marketplace',
'marketing.hero.title': 'The Operating System for',
'marketing.hero.titleHighlight': 'Service Businesses',
'marketing.hero.description': 'Orchestrate your entire operation with intelligent scheduling and powerful automation. No coding required.',
'marketing.hero.startFreeTrial': 'Start Free Trial',
'marketing.hero.watchDemo': 'Watch Demo',
'marketing.hero.noCreditCard': 'No credit card required',
'marketing.hero.freeTrial': '14-day free trial',
'marketing.hero.cancelAnytime': 'Cancel anytime',
'marketing.hero.visualContent.automatedSuccess': 'Automated Success',
'marketing.hero.visualContent.autopilot': 'Your business, running on autopilot.',
'marketing.hero.visualContent.revenue': 'Revenue',
'marketing.hero.visualContent.noShows': 'No-Shows',
'marketing.hero.visualContent.revenueOptimized': 'Revenue Optimized',
'marketing.hero.visualContent.thisWeek': '+$2,400 this week',
};
return translations[key] || key;
},
}),
}));
// Test wrapper with Router
const createWrapper = () => {
return ({ children }: { children: React.ReactNode }) => (
<BrowserRouter>{children}</BrowserRouter>
);
};
describe('Hero', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('Component Rendering', () => {
it('should render the hero section', () => {
render(<Hero />, { wrapper: createWrapper() });
const heroSection = screen.getByText(/The Operating System for/i).closest('div');
expect(heroSection).toBeInTheDocument();
});
it('should render without crashing', () => {
const { container } = render(<Hero />, { wrapper: createWrapper() });
expect(container).toBeTruthy();
});
it('should have proper semantic structure', () => {
render(<Hero />, { wrapper: createWrapper() });
// Should have h1 for main heading
const heading = screen.getByRole('heading', { level: 1 });
expect(heading).toBeInTheDocument();
});
});
describe('Headline and Title Rendering', () => {
it('should render main headline', () => {
render(<Hero />, { wrapper: createWrapper() });
const headline = screen.getByText(/The Operating System for/i);
expect(headline).toBeInTheDocument();
});
it('should render highlighted title text', () => {
render(<Hero />, { wrapper: createWrapper() });
const highlightedTitle = screen.getByText(/Service Businesses/i);
expect(highlightedTitle).toBeInTheDocument();
});
it('should render headline as h1 element', () => {
render(<Hero />, { wrapper: createWrapper() });
const heading = screen.getByRole('heading', { level: 1 });
expect(heading).toHaveTextContent(/The Operating System for/i);
expect(heading).toHaveTextContent(/Service Businesses/i);
});
it('should apply proper styling to headline', () => {
render(<Hero />, { wrapper: createWrapper() });
const heading = screen.getByRole('heading', { level: 1 });
expect(heading).toHaveClass('font-bold');
expect(heading).toHaveClass('tracking-tight');
});
it('should highlight title portion with brand color', () => {
render(<Hero />, { wrapper: createWrapper() });
const highlightedTitle = screen.getByText(/Service Businesses/i);
expect(highlightedTitle).toHaveClass('text-brand-600');
expect(highlightedTitle).toHaveClass('dark:text-brand-400');
});
});
describe('Subheadline/Description Rendering', () => {
it('should render description text', () => {
render(<Hero />, { wrapper: createWrapper() });
const description = screen.getByText(/Orchestrate your entire operation/i);
expect(description).toBeInTheDocument();
});
it('should render complete description', () => {
render(<Hero />, { wrapper: createWrapper() });
const description = screen.getByText(/intelligent scheduling and powerful automation/i);
expect(description).toBeInTheDocument();
});
it('should apply proper styling to description', () => {
render(<Hero />, { wrapper: createWrapper() });
const description = screen.getByText(/Orchestrate your entire operation/i);
expect(description.tagName).toBe('P');
expect(description).toHaveClass('text-lg');
});
});
describe('Badge Display', () => {
it('should render new feature badge', () => {
render(<Hero />, { wrapper: createWrapper() });
const badge = screen.getByText(/New: Automation Marketplace/i);
expect(badge).toBeInTheDocument();
});
it('should include animated pulse indicator', () => {
const { container } = render(<Hero />, { wrapper: createWrapper() });
const pulseElement = container.querySelector('.animate-pulse');
expect(pulseElement).toBeInTheDocument();
});
it('should apply badge styling', () => {
render(<Hero />, { wrapper: createWrapper() });
const badge = screen.getByText(/New: Automation Marketplace/i);
expect(badge).toHaveClass('text-sm');
expect(badge).toHaveClass('font-medium');
});
});
describe('CTA Buttons', () => {
it('should render Start Free Trial button', () => {
render(<Hero />, { wrapper: createWrapper() });
const ctaButton = screen.getByRole('link', { name: /Start Free Trial/i });
expect(ctaButton).toBeInTheDocument();
});
it('should render Watch Demo button', () => {
render(<Hero />, { wrapper: createWrapper() });
const demoButton = screen.getByRole('link', { name: /Watch Demo/i });
expect(demoButton).toBeInTheDocument();
});
it('should have correct href for Start Free Trial button', () => {
render(<Hero />, { wrapper: createWrapper() });
const ctaButton = screen.getByRole('link', { name: /Start Free Trial/i });
expect(ctaButton).toHaveAttribute('href', '/signup');
});
it('should have correct href for Watch Demo button', () => {
render(<Hero />, { wrapper: createWrapper() });
const demoButton = screen.getByRole('link', { name: /Watch Demo/i });
expect(demoButton).toHaveAttribute('href', '/features');
});
it('should render primary CTA with brand colors', () => {
render(<Hero />, { wrapper: createWrapper() });
const ctaButton = screen.getByRole('link', { name: /Start Free Trial/i });
expect(ctaButton).toHaveClass('bg-brand-600');
expect(ctaButton).toHaveClass('hover:bg-brand-700');
expect(ctaButton).toHaveClass('text-white');
});
it('should render secondary CTA with outline style', () => {
render(<Hero />, { wrapper: createWrapper() });
const demoButton = screen.getByRole('link', { name: /Watch Demo/i });
expect(demoButton).toHaveClass('border');
expect(demoButton).toHaveClass('border-gray-200');
});
it('should include ArrowRight icon in primary CTA', () => {
render(<Hero />, { wrapper: createWrapper() });
const ctaButton = screen.getByRole('link', { name: /Start Free Trial/i });
const icon = ctaButton.querySelector('svg');
expect(icon).toBeInTheDocument();
});
it('should include Play icon in secondary CTA', () => {
render(<Hero />, { wrapper: createWrapper() });
const demoButton = screen.getByRole('link', { name: /Watch Demo/i });
const icon = demoButton.querySelector('svg');
expect(icon).toBeInTheDocument();
});
it('should be clickable (keyboard accessible)', async () => {
const user = userEvent.setup();
render(<Hero />, { wrapper: createWrapper() });
const ctaButton = screen.getByRole('link', { name: /Start Free Trial/i });
// Should be focusable
await user.tab();
// Check if any link is focused (may not be the first due to badge)
expect(document.activeElement).toBeInstanceOf(HTMLElement);
});
});
describe('Feature Checkmarks', () => {
it('should display no credit card feature', () => {
render(<Hero />, { wrapper: createWrapper() });
const feature = screen.getByText(/No credit card required/i);
expect(feature).toBeInTheDocument();
});
it('should display free trial feature', () => {
render(<Hero />, { wrapper: createWrapper() });
const feature = screen.getByText(/14-day free trial/i);
expect(feature).toBeInTheDocument();
});
it('should display cancel anytime feature', () => {
render(<Hero />, { wrapper: createWrapper() });
const feature = screen.getByText(/Cancel anytime/i);
expect(feature).toBeInTheDocument();
});
it('should render CheckCircle2 icons for features', () => {
const { container } = render(<Hero />, { wrapper: createWrapper() });
// Should have multiple check circle icons
const checkIcons = container.querySelectorAll('svg');
expect(checkIcons.length).toBeGreaterThan(0);
});
});
describe('Visual Content and Graphics', () => {
it('should render visual content section', () => {
render(<Hero />, { wrapper: createWrapper() });
const visualHeading = screen.getByText(/Automated Success/i);
expect(visualHeading).toBeInTheDocument();
});
it('should render visual content description', () => {
render(<Hero />, { wrapper: createWrapper() });
const description = screen.getByText(/Your business, running on autopilot/i);
expect(description).toBeInTheDocument();
});
it('should render revenue metric', () => {
render(<Hero />, { wrapper: createWrapper() });
const revenueMetric = screen.getByText(/\+24%/i);
expect(revenueMetric).toBeInTheDocument();
});
it('should render no-shows metric', () => {
render(<Hero />, { wrapper: createWrapper() });
const noShowsMetric = screen.getByText(/-40%/i);
expect(noShowsMetric).toBeInTheDocument();
});
it('should render revenue label', () => {
render(<Hero />, { wrapper: createWrapper() });
const label = screen.getByText(/^Revenue$/i);
expect(label).toBeInTheDocument();
});
it('should render no-shows label', () => {
render(<Hero />, { wrapper: createWrapper() });
const label = screen.getByText(/^No-Shows$/i);
expect(label).toBeInTheDocument();
});
it('should have gradient background on visual content', () => {
const { container } = render(<Hero />, { wrapper: createWrapper() });
const gradientElement = container.querySelector('.bg-gradient-to-br');
expect(gradientElement).toBeInTheDocument();
});
it('should render visual content as h3', () => {
render(<Hero />, { wrapper: createWrapper() });
const heading = screen.getByRole('heading', { level: 3, name: /Automated Success/i });
expect(heading).toBeInTheDocument();
});
});
describe('Floating Badge', () => {
it('should render floating revenue badge', () => {
render(<Hero />, { wrapper: createWrapper() });
const badge = screen.getByText(/Revenue Optimized/i);
expect(badge).toBeInTheDocument();
});
it('should render weekly revenue amount', () => {
render(<Hero />, { wrapper: createWrapper() });
const amount = screen.getByText(/\+\$2,400 this week/i);
expect(amount).toBeInTheDocument();
});
it('should have bounce animation', () => {
const { container } = render(<Hero />, { wrapper: createWrapper() });
// Find element with animate-bounce-slow (custom animation class)
const badge = container.querySelector('.animate-bounce-slow');
expect(badge).toBeInTheDocument();
});
it('should include CheckCircle2 icon in badge', () => {
const { container } = render(<Hero />, { wrapper: createWrapper() });
// The badge has an SVG icon, check for its presence in the floating badge area
const badge = screen.getByText(/Revenue Optimized/i).parentElement?.parentElement;
const icon = badge?.querySelector('svg');
expect(icon).toBeInTheDocument();
});
});
describe('Responsive Design', () => {
it('should use grid layout for content', () => {
const { container } = render(<Hero />, { wrapper: createWrapper() });
const gridElement = container.querySelector('.grid');
expect(gridElement).toBeInTheDocument();
});
it('should have responsive grid columns', () => {
const { container } = render(<Hero />, { wrapper: createWrapper() });
const gridElement = container.querySelector('.lg\\:grid-cols-2');
expect(gridElement).toBeInTheDocument();
});
it('should have responsive text alignment', () => {
const { container } = render(<Hero />, { wrapper: createWrapper() });
// Text should be centered on mobile, left-aligned on larger screens
const textContainer = container.querySelector('.text-center.lg\\:text-left');
expect(textContainer).toBeInTheDocument();
});
it('should have responsive heading sizes', () => {
render(<Hero />, { wrapper: createWrapper() });
const heading = screen.getByRole('heading', { level: 1 });
expect(heading).toHaveClass('text-4xl');
expect(heading).toHaveClass('sm:text-5xl');
expect(heading).toHaveClass('lg:text-6xl');
});
it('should have responsive button layout', () => {
const { container } = render(<Hero />, { wrapper: createWrapper() });
const buttonContainer = container.querySelector('.flex-col.sm\\:flex-row');
expect(buttonContainer).toBeInTheDocument();
});
});
describe('Background Elements', () => {
it('should render decorative background elements', () => {
const { container } = render(<Hero />, { wrapper: createWrapper() });
// Should have blur effects
const blurElements = container.querySelectorAll('.blur-3xl');
expect(blurElements.length).toBeGreaterThan(0);
});
it('should have brand-colored background element', () => {
const { container } = render(<Hero />, { wrapper: createWrapper() });
const brandBg = container.querySelector('.bg-brand-500\\/10');
expect(brandBg).toBeInTheDocument();
});
it('should have purple background element', () => {
const { container } = render(<Hero />, { wrapper: createWrapper() });
const purpleBg = container.querySelector('.bg-purple-500\\/10');
expect(purpleBg).toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('should have accessible heading hierarchy', () => {
render(<Hero />, { wrapper: createWrapper() });
const h1 = screen.getByRole('heading', { level: 1 });
const h3 = screen.getByRole('heading', { level: 3 });
expect(h1).toBeInTheDocument();
expect(h3).toBeInTheDocument();
});
it('should have accessible link text', () => {
render(<Hero />, { wrapper: createWrapper() });
const primaryCTA = screen.getByRole('link', { name: /Start Free Trial/i });
const secondaryCTA = screen.getByRole('link', { name: /Watch Demo/i });
expect(primaryCTA).toHaveAccessibleName();
expect(secondaryCTA).toHaveAccessibleName();
});
it('should not use ambiguous link text', () => {
render(<Hero />, { wrapper: createWrapper() });
// Should not have links with text like "Click here" or "Read more"
const links = screen.getAllByRole('link');
links.forEach(link => {
expect(link.textContent).not.toMatch(/^click here$/i);
expect(link.textContent).not.toMatch(/^read more$/i);
});
});
});
describe('Internationalization', () => {
it('should use translations for badge text', () => {
render(<Hero />, { wrapper: createWrapper() });
const badge = screen.getByText(/New: Automation Marketplace/i);
expect(badge).toBeInTheDocument();
});
it('should use translations for main title', () => {
render(<Hero />, { wrapper: createWrapper() });
expect(screen.getByText(/The Operating System for/i)).toBeInTheDocument();
expect(screen.getByText(/Service Businesses/i)).toBeInTheDocument();
});
it('should use translations for description', () => {
render(<Hero />, { wrapper: createWrapper() });
const description = screen.getByText(/Orchestrate your entire operation/i);
expect(description).toBeInTheDocument();
});
it('should use translations for CTA buttons', () => {
render(<Hero />, { wrapper: createWrapper() });
expect(screen.getByRole('link', { name: /Start Free Trial/i })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Watch Demo/i })).toBeInTheDocument();
});
it('should use translations for features', () => {
render(<Hero />, { wrapper: createWrapper() });
expect(screen.getByText(/No credit card required/i)).toBeInTheDocument();
expect(screen.getByText(/14-day free trial/i)).toBeInTheDocument();
expect(screen.getByText(/Cancel anytime/i)).toBeInTheDocument();
});
it('should use translations for visual content', () => {
render(<Hero />, { wrapper: createWrapper() });
expect(screen.getByText(/Automated Success/i)).toBeInTheDocument();
expect(screen.getByText(/Your business, running on autopilot/i)).toBeInTheDocument();
expect(screen.getByText(/Revenue Optimized/i)).toBeInTheDocument();
});
});
describe('Dark Mode Support', () => {
it('should have dark mode classes for main container', () => {
const { container } = render(<Hero />, { wrapper: createWrapper() });
const mainContainer = container.querySelector('.dark\\:bg-gray-900');
expect(mainContainer).toBeInTheDocument();
});
it('should have dark mode classes for text elements', () => {
render(<Hero />, { wrapper: createWrapper() });
const heading = screen.getByRole('heading', { level: 1 });
expect(heading).toHaveClass('dark:text-white');
});
it('should have dark mode classes for description', () => {
render(<Hero />, { wrapper: createWrapper() });
const description = screen.getByText(/Orchestrate your entire operation/i);
expect(description).toHaveClass('dark:text-gray-400');
});
});
describe('Layout and Spacing', () => {
it('should have proper padding on container', () => {
const { container } = render(<Hero />, { wrapper: createWrapper() });
const mainSection = container.querySelector('.pt-16');
expect(mainSection).toBeInTheDocument();
});
it('should have responsive padding', () => {
const { container } = render(<Hero />, { wrapper: createWrapper() });
const section = container.querySelector('.lg\\:pt-24');
expect(section).toBeInTheDocument();
});
it('should have proper margins between elements', () => {
render(<Hero />, { wrapper: createWrapper() });
const heading = screen.getByRole('heading', { level: 1 });
expect(heading).toHaveClass('mb-6');
});
it('should constrain max width', () => {
const { container } = render(<Hero />, { wrapper: createWrapper() });
const constrainedContainer = container.querySelector('.max-w-7xl');
expect(constrainedContainer).toBeInTheDocument();
});
});
describe('Integration Tests', () => {
it('should render all major sections together', () => {
render(<Hero />, { wrapper: createWrapper() });
// Text content
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
expect(screen.getByText(/Orchestrate your entire operation/i)).toBeInTheDocument();
// CTAs
expect(screen.getByRole('link', { name: /Start Free Trial/i })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Watch Demo/i })).toBeInTheDocument();
// Features
expect(screen.getByText(/No credit card required/i)).toBeInTheDocument();
// Visual content
expect(screen.getByText(/Automated Success/i)).toBeInTheDocument();
expect(screen.getByText(/Revenue Optimized/i)).toBeInTheDocument();
});
it('should maintain proper component structure', () => {
const { container } = render(<Hero />, { wrapper: createWrapper() });
// Grid layout
const grid = container.querySelector('.grid');
expect(grid).toBeInTheDocument();
// Background elements
const backgrounds = container.querySelectorAll('.blur-3xl');
expect(backgrounds.length).toBeGreaterThan(0);
// Visual content area
const visualContent = screen.getByText(/Automated Success/i).closest('div');
expect(visualContent).toBeInTheDocument();
});
it('should have complete feature set displayed', () => {
render(<Hero />, { wrapper: createWrapper() });
const features = [
/No credit card required/i,
/14-day free trial/i,
/Cancel anytime/i,
];
features.forEach(feature => {
expect(screen.getByText(feature)).toBeInTheDocument();
});
});
it('should have complete metrics displayed', () => {
render(<Hero />, { wrapper: createWrapper() });
expect(screen.getByText(/\+24%/i)).toBeInTheDocument();
expect(screen.getByText(/-40%/i)).toBeInTheDocument();
expect(screen.getByText(/\+\$2,400 this week/i)).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,439 @@
/**
* Unit tests for HowItWorks component
*
* Tests cover:
* - Section title and subtitle rendering
* - All three steps are displayed
* - Step numbers (01, 02, 03) are present
* - Icons from lucide-react render correctly
* - Step titles and descriptions render
* - Connector lines between steps (desktop only)
* - Color theming for each step
* - Responsive grid layout
* - Accessibility
* - Internationalization (i18n)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import React from 'react';
import HowItWorks from '../HowItWorks';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'marketing.howItWorks.title': 'Get Started in Minutes',
'marketing.howItWorks.subtitle': 'Three simple steps to transform your scheduling',
'marketing.howItWorks.step1.title': 'Create Your Account',
'marketing.howItWorks.step1.description': 'Sign up for free and set up your business profile in minutes.',
'marketing.howItWorks.step2.title': 'Add Your Services',
'marketing.howItWorks.step2.description': 'Configure your services, pricing, and available resources.',
'marketing.howItWorks.step3.title': 'Start Booking',
'marketing.howItWorks.step3.description': 'Share your booking link and let customers schedule instantly.',
};
return translations[key] || key;
},
}),
}));
describe('HowItWorks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('Section Header', () => {
it('should render the section title', () => {
render(<HowItWorks />);
const title = screen.getByRole('heading', {
name: 'Get Started in Minutes',
level: 2,
});
expect(title).toBeInTheDocument();
});
it('should render the section subtitle', () => {
render(<HowItWorks />);
const subtitle = screen.getByText('Three simple steps to transform your scheduling');
expect(subtitle).toBeInTheDocument();
});
it('should apply correct styling to section title', () => {
render(<HowItWorks />);
const title = screen.getByRole('heading', { level: 2 });
expect(title).toHaveClass('text-3xl');
expect(title).toHaveClass('sm:text-4xl');
expect(title).toHaveClass('font-bold');
expect(title).toHaveClass('text-gray-900');
expect(title).toHaveClass('dark:text-white');
});
it('should apply correct styling to subtitle', () => {
render(<HowItWorks />);
const subtitle = screen.getByText('Three simple steps to transform your scheduling');
expect(subtitle).toHaveClass('text-lg');
expect(subtitle).toHaveClass('text-gray-600');
expect(subtitle).toHaveClass('dark:text-gray-400');
});
});
describe('Steps Display', () => {
it('should render all three steps', () => {
render(<HowItWorks />);
const step1 = screen.getByText('Create Your Account');
const step2 = screen.getByText('Add Your Services');
const step3 = screen.getByText('Start Booking');
expect(step1).toBeInTheDocument();
expect(step2).toBeInTheDocument();
expect(step3).toBeInTheDocument();
});
it('should render step descriptions', () => {
render(<HowItWorks />);
const desc1 = screen.getByText('Sign up for free and set up your business profile in minutes.');
const desc2 = screen.getByText('Configure your services, pricing, and available resources.');
const desc3 = screen.getByText('Share your booking link and let customers schedule instantly.');
expect(desc1).toBeInTheDocument();
expect(desc2).toBeInTheDocument();
expect(desc3).toBeInTheDocument();
});
it('should use heading level 3 for step titles', () => {
render(<HowItWorks />);
const stepHeadings = screen.getAllByRole('heading', { level: 3 });
expect(stepHeadings).toHaveLength(3);
expect(stepHeadings[0]).toHaveTextContent('Create Your Account');
expect(stepHeadings[1]).toHaveTextContent('Add Your Services');
expect(stepHeadings[2]).toHaveTextContent('Start Booking');
});
});
describe('Step Numbers', () => {
it('should display step number 01', () => {
render(<HowItWorks />);
const stepNumber = screen.getByText('01');
expect(stepNumber).toBeInTheDocument();
});
it('should display step number 02', () => {
render(<HowItWorks />);
const stepNumber = screen.getByText('02');
expect(stepNumber).toBeInTheDocument();
});
it('should display step number 03', () => {
render(<HowItWorks />);
const stepNumber = screen.getByText('03');
expect(stepNumber).toBeInTheDocument();
});
it('should apply correct styling to step numbers', () => {
render(<HowItWorks />);
const stepNumber = screen.getByText('01');
expect(stepNumber).toHaveClass('text-sm');
expect(stepNumber).toHaveClass('font-bold');
});
});
describe('Icons', () => {
it('should render SVG icons for all steps', () => {
const { container } = render(<HowItWorks />);
// Each step should have an icon (lucide-react renders as SVG)
const icons = container.querySelectorAll('svg');
expect(icons.length).toBeGreaterThanOrEqual(3);
});
it('should render icons with correct size classes', () => {
const { container } = render(<HowItWorks />);
const icons = container.querySelectorAll('svg');
icons.forEach((icon) => {
expect(icon).toHaveClass('h-8');
expect(icon).toHaveClass('w-8');
});
});
});
describe('Grid Layout', () => {
it('should render steps in a grid container', () => {
const { container } = render(<HowItWorks />);
const grid = container.querySelector('.grid');
expect(grid).toBeInTheDocument();
});
it('should apply responsive grid classes', () => {
const { container } = render(<HowItWorks />);
const grid = container.querySelector('.grid');
expect(grid).toHaveClass('md:grid-cols-3');
expect(grid).toHaveClass('gap-8');
expect(grid).toHaveClass('lg:gap-12');
});
});
describe('Card Styling', () => {
it('should render each step in a card', () => {
const { container } = render(<HowItWorks />);
const cards = container.querySelectorAll('.bg-white');
expect(cards.length).toBeGreaterThanOrEqual(3);
});
it('should apply card border and rounded corners', () => {
const { container } = render(<HowItWorks />);
const cards = container.querySelectorAll('.rounded-2xl');
expect(cards.length).toBeGreaterThanOrEqual(3);
});
});
describe('Color Themes', () => {
it('should apply brand color theme to step 1', () => {
const { container } = render(<HowItWorks />);
// Check for brand color classes
const brandElements = container.querySelectorAll('.text-brand-600, .bg-brand-100');
expect(brandElements.length).toBeGreaterThan(0);
});
it('should apply purple color theme to step 2', () => {
const { container } = render(<HowItWorks />);
// Check for purple color classes
const purpleElements = container.querySelectorAll('.text-purple-600, .bg-purple-100');
expect(purpleElements.length).toBeGreaterThan(0);
});
it('should apply green color theme to step 3', () => {
const { container } = render(<HowItWorks />);
// Check for green color classes
const greenElements = container.querySelectorAll('.text-green-600, .bg-green-100');
expect(greenElements.length).toBeGreaterThan(0);
});
});
describe('Connector Lines', () => {
it('should render connector lines between steps', () => {
const { container } = render(<HowItWorks />);
// Connector lines have absolute positioning and gradient
const connectors = container.querySelectorAll('.bg-gradient-to-r');
expect(connectors.length).toBeGreaterThanOrEqual(2);
});
it('should hide connector lines on mobile', () => {
const { container } = render(<HowItWorks />);
const connectors = container.querySelectorAll('.hidden.md\\:block');
// Should have 2 connector lines (between step 1-2 and 2-3)
expect(connectors.length).toBeGreaterThanOrEqual(2);
});
});
describe('Section Styling', () => {
it('should apply section background color', () => {
const { container } = render(<HowItWorks />);
const section = container.querySelector('section');
expect(section).toHaveClass('bg-gray-50');
expect(section).toHaveClass('dark:bg-gray-800/50');
});
it('should apply section padding', () => {
const { container } = render(<HowItWorks />);
const section = container.querySelector('section');
expect(section).toHaveClass('py-20');
expect(section).toHaveClass('lg:py-28');
});
it('should use max-width container', () => {
const { container } = render(<HowItWorks />);
const maxWidthContainer = container.querySelector('.max-w-7xl');
expect(maxWidthContainer).toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('should use semantic section element', () => {
const { container } = render(<HowItWorks />);
const section = container.querySelector('section');
expect(section).toBeInTheDocument();
});
it('should have proper heading hierarchy', () => {
render(<HowItWorks />);
// h2 for main title
const h2 = screen.getByRole('heading', { level: 2 });
expect(h2).toBeInTheDocument();
// h3 for step titles
const h3Elements = screen.getAllByRole('heading', { level: 3 });
expect(h3Elements).toHaveLength(3);
});
it('should have readable text content', () => {
render(<HowItWorks />);
const title = screen.getByText('Get Started in Minutes');
const subtitle = screen.getByText('Three simple steps to transform your scheduling');
expect(title).toBeVisible();
expect(subtitle).toBeVisible();
});
});
describe('Internationalization', () => {
it('should use translation for section title', () => {
render(<HowItWorks />);
const title = screen.getByText('Get Started in Minutes');
expect(title).toBeInTheDocument();
});
it('should use translation for section subtitle', () => {
render(<HowItWorks />);
const subtitle = screen.getByText('Three simple steps to transform your scheduling');
expect(subtitle).toBeInTheDocument();
});
it('should use translations for all step titles', () => {
render(<HowItWorks />);
expect(screen.getByText('Create Your Account')).toBeInTheDocument();
expect(screen.getByText('Add Your Services')).toBeInTheDocument();
expect(screen.getByText('Start Booking')).toBeInTheDocument();
});
it('should use translations for all step descriptions', () => {
render(<HowItWorks />);
expect(screen.getByText('Sign up for free and set up your business profile in minutes.')).toBeInTheDocument();
expect(screen.getByText('Configure your services, pricing, and available resources.')).toBeInTheDocument();
expect(screen.getByText('Share your booking link and let customers schedule instantly.')).toBeInTheDocument();
});
});
describe('Responsive Design', () => {
it('should apply responsive text sizing to title', () => {
render(<HowItWorks />);
const title = screen.getByRole('heading', { level: 2 });
expect(title).toHaveClass('text-3xl');
expect(title).toHaveClass('sm:text-4xl');
});
it('should apply responsive padding to section', () => {
const { container } = render(<HowItWorks />);
const section = container.querySelector('section');
expect(section).toHaveClass('py-20');
expect(section).toHaveClass('lg:py-28');
});
it('should apply responsive padding to container', () => {
const { container } = render(<HowItWorks />);
const containerDiv = container.querySelector('.max-w-7xl');
expect(containerDiv).toHaveClass('px-4');
expect(containerDiv).toHaveClass('sm:px-6');
expect(containerDiv).toHaveClass('lg:px-8');
});
});
describe('Dark Mode Support', () => {
it('should include dark mode classes for title', () => {
render(<HowItWorks />);
const title = screen.getByRole('heading', { level: 2 });
expect(title).toHaveClass('dark:text-white');
});
it('should include dark mode classes for subtitle', () => {
render(<HowItWorks />);
const subtitle = screen.getByText('Three simple steps to transform your scheduling');
expect(subtitle).toHaveClass('dark:text-gray-400');
});
it('should include dark mode classes for section background', () => {
const { container } = render(<HowItWorks />);
const section = container.querySelector('section');
expect(section).toHaveClass('dark:bg-gray-800/50');
});
it('should include dark mode classes for cards', () => {
const { container } = render(<HowItWorks />);
const cards = container.querySelectorAll('.dark\\:bg-gray-800');
expect(cards.length).toBeGreaterThanOrEqual(3);
});
});
describe('Integration', () => {
it('should render complete component with all elements', () => {
render(<HowItWorks />);
// Header
expect(screen.getByRole('heading', { level: 2, name: 'Get Started in Minutes' })).toBeInTheDocument();
expect(screen.getByText('Three simple steps to transform your scheduling')).toBeInTheDocument();
// All steps
expect(screen.getByText('01')).toBeInTheDocument();
expect(screen.getByText('02')).toBeInTheDocument();
expect(screen.getByText('03')).toBeInTheDocument();
// All titles
expect(screen.getByText('Create Your Account')).toBeInTheDocument();
expect(screen.getByText('Add Your Services')).toBeInTheDocument();
expect(screen.getByText('Start Booking')).toBeInTheDocument();
// All descriptions
expect(screen.getByText('Sign up for free and set up your business profile in minutes.')).toBeInTheDocument();
expect(screen.getByText('Configure your services, pricing, and available resources.')).toBeInTheDocument();
expect(screen.getByText('Share your booking link and let customers schedule instantly.')).toBeInTheDocument();
});
it('should maintain proper structure and layout', () => {
const { container } = render(<HowItWorks />);
// Section element
const section = container.querySelector('section');
expect(section).toBeInTheDocument();
// Container
const maxWidthContainer = section?.querySelector('.max-w-7xl');
expect(maxWidthContainer).toBeInTheDocument();
// Grid
const grid = maxWidthContainer?.querySelector('.grid');
expect(grid).toBeInTheDocument();
// Cards
const cards = grid?.querySelectorAll('.bg-white');
expect(cards?.length).toBe(3);
});
});
});

View File

@@ -0,0 +1,739 @@
/**
* Unit tests for Navbar component
*
* Tests cover:
* - Logo and brand rendering
* - Navigation links presence
* - Login/signup buttons
* - Mobile menu toggle functionality
* - Scroll behavior (background change on scroll)
* - Theme toggle functionality
* - User authentication states
* - Dashboard URL generation based on user role
* - Route change effects on mobile menu
* - Accessibility attributes
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { BrowserRouter, MemoryRouter } from 'react-router-dom';
import React from 'react';
import Navbar from '../Navbar';
import { User } from '../../../api/auth';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'marketing.nav.features': 'Features',
'marketing.nav.pricing': 'Pricing',
'marketing.nav.about': 'About',
'marketing.nav.contact': 'Contact',
'marketing.nav.login': 'Login',
'marketing.nav.getStarted': 'Get Started',
'marketing.nav.brandName': 'Smooth Schedule',
'marketing.nav.switchToLightMode': 'Switch to light mode',
'marketing.nav.switchToDarkMode': 'Switch to dark mode',
'marketing.nav.toggleMenu': 'Toggle menu',
};
return translations[key] || key;
},
}),
}));
// Mock SmoothScheduleLogo
vi.mock('../../SmoothScheduleLogo', () => ({
default: ({ className }: { className?: string }) => (
<div data-testid="smooth-schedule-logo" className={className}>Logo</div>
),
}));
// Mock LanguageSelector
vi.mock('../../LanguageSelector', () => ({
default: () => <div data-testid="language-selector">Language</div>,
}));
// Mock domain utilities
vi.mock('../../../utils/domain', () => ({
buildSubdomainUrl: (subdomain: string | null, path: string = '/') => {
if (subdomain) {
return `http://${subdomain}.lvh.me:5173${path}`;
}
return `http://lvh.me:5173${path}`;
},
}));
// Test wrapper with Router
const createWrapper = (initialRoute: string = '/') => {
return ({ children }: { children: React.ReactNode }) => (
<MemoryRouter initialEntries={[initialRoute]}>{children}</MemoryRouter>
);
};
describe('Navbar', () => {
const mockToggleTheme = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
// Reset window.scrollY before each test
Object.defineProperty(window, 'scrollY', {
writable: true,
configurable: true,
value: 0,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('Logo and Brand Rendering', () => {
it('should render the logo', () => {
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper(),
});
const logo = screen.getByTestId('smooth-schedule-logo');
expect(logo).toBeInTheDocument();
});
it('should render the brand name', () => {
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper(),
});
const brandName = screen.getByText('Smooth Schedule');
expect(brandName).toBeInTheDocument();
});
it('should have logo link pointing to home', () => {
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper(),
});
const logoLink = screen.getByRole('link', { name: /smooth schedule/i });
expect(logoLink).toHaveAttribute('href', '/');
});
it('should apply correct classes to logo link', () => {
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper(),
});
const logoLink = screen.getByRole('link', { name: /smooth schedule/i });
expect(logoLink).toHaveClass('flex', 'items-center', 'gap-2', 'group');
});
});
describe('Navigation Links', () => {
it('should render all navigation links on desktop', () => {
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper(),
});
expect(screen.getAllByText('Features')[0]).toBeInTheDocument();
expect(screen.getAllByText('Pricing')[0]).toBeInTheDocument();
expect(screen.getAllByText('About')[0]).toBeInTheDocument();
expect(screen.getAllByText('Contact')[0]).toBeInTheDocument();
});
it('should have correct href attributes for navigation links', () => {
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper(),
});
const featuresLinks = screen.getAllByRole('link', { name: 'Features' });
expect(featuresLinks[0]).toHaveAttribute('href', '/features');
const pricingLinks = screen.getAllByRole('link', { name: 'Pricing' });
expect(pricingLinks[0]).toHaveAttribute('href', '/pricing');
const aboutLinks = screen.getAllByRole('link', { name: 'About' });
expect(aboutLinks[0]).toHaveAttribute('href', '/about');
const contactLinks = screen.getAllByRole('link', { name: 'Contact' });
expect(contactLinks[0]).toHaveAttribute('href', '/contact');
});
it('should highlight active navigation link', () => {
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper('/features'),
});
const featuresLinks = screen.getAllByRole('link', { name: 'Features' });
const activeLink = featuresLinks[0];
expect(activeLink).toHaveClass('text-brand-600');
});
it('should not highlight inactive navigation links', () => {
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper('/features'),
});
const pricingLinks = screen.getAllByRole('link', { name: 'Pricing' });
const inactiveLink = pricingLinks[0];
expect(inactiveLink).toHaveClass('text-gray-600');
expect(inactiveLink).not.toHaveClass('text-brand-600');
});
});
describe('Login and Signup Buttons', () => {
it('should render login button when no user is provided', () => {
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper(),
});
const loginButtons = screen.getAllByText('Login');
expect(loginButtons.length).toBeGreaterThan(0);
});
it('should render login link with correct href when no user', () => {
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper(),
});
const loginLinks = screen.getAllByRole('link', { name: 'Login' });
expect(loginLinks[0]).toHaveAttribute('href', '/login');
});
it('should render signup button', () => {
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper(),
});
const signupButtons = screen.getAllByText('Get Started');
expect(signupButtons.length).toBeGreaterThan(0);
});
it('should render signup link with correct href', () => {
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper(),
});
const signupLinks = screen.getAllByRole('link', { name: 'Get Started' });
expect(signupLinks[0]).toHaveAttribute('href', '/signup');
});
it('should render dashboard link when user is authenticated', () => {
const mockUser: User = {
id: 1,
email: 'test@example.com',
username: 'testuser',
first_name: 'Test',
last_name: 'User',
role: 'owner',
business_subdomain: 'testbusiness',
is_active: true,
};
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} user={mockUser} />, {
wrapper: createWrapper(),
});
const loginLinks = screen.getAllByText('Login');
// Should still show "Login" text but as anchor tag to dashboard
expect(loginLinks.length).toBeGreaterThan(0);
});
it('should generate correct dashboard URL for platform users', () => {
const mockUser: User = {
id: 1,
email: 'admin@example.com',
username: 'admin',
first_name: 'Admin',
last_name: 'User',
role: 'superuser',
business_subdomain: null,
is_active: true,
};
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} user={mockUser} />, {
wrapper: createWrapper(),
});
const loginLinks = screen.getAllByText('Login');
const dashboardLink = loginLinks[0].closest('a');
expect(dashboardLink).toHaveAttribute('href', 'http://platform.lvh.me:5173/');
});
it('should generate correct dashboard URL for business users', () => {
const mockUser: User = {
id: 1,
email: 'owner@example.com',
username: 'owner',
first_name: 'Owner',
last_name: 'User',
role: 'owner',
business_subdomain: 'mybusiness',
is_active: true,
};
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} user={mockUser} />, {
wrapper: createWrapper(),
});
const loginLinks = screen.getAllByText('Login');
const dashboardLink = loginLinks[0].closest('a');
expect(dashboardLink).toHaveAttribute('href', 'http://mybusiness.lvh.me:5173/');
});
});
describe('Theme Toggle', () => {
it('should render theme toggle button', () => {
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper(),
});
const themeButton = screen.getByLabelText('Switch to dark mode');
expect(themeButton).toBeInTheDocument();
});
it('should call toggleTheme when theme button is clicked', () => {
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper(),
});
const themeButton = screen.getByLabelText('Switch to dark mode');
fireEvent.click(themeButton);
expect(mockToggleTheme).toHaveBeenCalledTimes(1);
});
it('should show moon icon in light mode', () => {
const { container } = render(
<Navbar darkMode={false} toggleTheme={mockToggleTheme} />,
{ wrapper: createWrapper() }
);
const themeButton = screen.getByLabelText('Switch to dark mode');
const svg = themeButton.querySelector('svg');
expect(svg).toBeInTheDocument();
});
it('should show sun icon in dark mode', () => {
const { container } = render(
<Navbar darkMode={true} toggleTheme={mockToggleTheme} />,
{ wrapper: createWrapper() }
);
const themeButton = screen.getByLabelText('Switch to light mode');
const svg = themeButton.querySelector('svg');
expect(svg).toBeInTheDocument();
});
it('should have correct aria-label in light mode', () => {
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper(),
});
const themeButton = screen.getByLabelText('Switch to dark mode');
expect(themeButton).toHaveAttribute('aria-label', 'Switch to dark mode');
});
it('should have correct aria-label in dark mode', () => {
render(<Navbar darkMode={true} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper(),
});
const themeButton = screen.getByLabelText('Switch to light mode');
expect(themeButton).toHaveAttribute('aria-label', 'Switch to light mode');
});
});
describe('Mobile Menu Toggle', () => {
it('should render mobile menu button', () => {
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper(),
});
const menuButton = screen.getByLabelText('Toggle menu');
expect(menuButton).toBeInTheDocument();
});
it('should show mobile menu when menu button is clicked', () => {
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper(),
});
const menuButton = screen.getByLabelText('Toggle menu');
fireEvent.click(menuButton);
// Mobile menu should be visible (max-h-96 instead of max-h-0)
const mobileMenuContainer = menuButton.closest('nav')?.querySelector('.lg\\:hidden.overflow-hidden');
expect(mobileMenuContainer).toBeInTheDocument();
});
it('should toggle mobile menu on multiple clicks', () => {
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper(),
});
const menuButton = screen.getByLabelText('Toggle menu');
// First click - open
fireEvent.click(menuButton);
let mobileMenuContainer = menuButton.closest('nav')?.querySelector('.lg\\:hidden.overflow-hidden');
expect(mobileMenuContainer).toHaveClass('max-h-96');
// Second click - close
fireEvent.click(menuButton);
mobileMenuContainer = menuButton.closest('nav')?.querySelector('.lg\\:hidden.overflow-hidden');
expect(mobileMenuContainer).toHaveClass('max-h-0');
});
it('should show Menu icon when menu is closed', () => {
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper(),
});
const menuButton = screen.getByLabelText('Toggle menu');
const svg = menuButton.querySelector('svg');
expect(svg).toBeInTheDocument();
});
it('should show X icon when menu is open', () => {
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper(),
});
const menuButton = screen.getByLabelText('Toggle menu');
fireEvent.click(menuButton);
const svg = menuButton.querySelector('svg');
expect(svg).toBeInTheDocument();
});
it('should render all navigation links in mobile menu', () => {
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper(),
});
const menuButton = screen.getByLabelText('Toggle menu');
fireEvent.click(menuButton);
// Each link appears twice (desktop + mobile)
expect(screen.getAllByText('Features')).toHaveLength(2);
expect(screen.getAllByText('Pricing')).toHaveLength(2);
expect(screen.getAllByText('About')).toHaveLength(2);
expect(screen.getAllByText('Contact')).toHaveLength(2);
});
it('should render language selector in mobile menu', () => {
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper(),
});
const menuButton = screen.getByLabelText('Toggle menu');
fireEvent.click(menuButton);
const languageSelectors = screen.getAllByTestId('language-selector');
// Should appear twice (desktop + mobile)
expect(languageSelectors).toHaveLength(2);
});
it('should close mobile menu on route change', () => {
// Test that clicking a navigation link closes the mobile menu
// In production, clicking a link triggers a route change which closes the menu via useEffect
// In tests with MemoryRouter, the route change happens and the useEffect fires
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper('/'),
});
const menuButton = screen.getByLabelText('Toggle menu');
fireEvent.click(menuButton);
// Verify menu is open
let mobileMenuContainer = menuButton.closest('nav')?.querySelector('.lg\\:hidden.overflow-hidden');
expect(mobileMenuContainer).toHaveClass('max-h-96');
// Click a navigation link - this triggers navigation to /features
// The useEffect with location.pathname dependency should close the menu
const featuresLink = screen.getAllByRole('link', { name: 'Features' })[1]; // Mobile menu link
fireEvent.click(featuresLink);
// After navigation, menu should be closed
mobileMenuContainer = menuButton.closest('nav')?.querySelector('.lg\\:hidden.overflow-hidden');
expect(mobileMenuContainer).toHaveClass('max-h-0');
});
});
describe('Scroll Behavior', () => {
it('should have transparent background when not scrolled', () => {
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper(),
});
const nav = screen.getByRole('navigation');
expect(nav).toHaveClass('bg-transparent');
});
it('should change background on scroll', async () => {
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper(),
});
const nav = screen.getByRole('navigation');
// Simulate scroll
Object.defineProperty(window, 'scrollY', { writable: true, value: 50 });
fireEvent.scroll(window);
await waitFor(() => {
expect(nav).toHaveClass('bg-white/80');
expect(nav).toHaveClass('backdrop-blur-lg');
expect(nav).toHaveClass('shadow-sm');
});
});
it('should remove background when scrolled back to top', async () => {
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper(),
});
const nav = screen.getByRole('navigation');
// Scroll down
Object.defineProperty(window, 'scrollY', { writable: true, value: 50 });
fireEvent.scroll(window);
await waitFor(() => {
expect(nav).toHaveClass('bg-white/80');
});
// Scroll back to top
Object.defineProperty(window, 'scrollY', { writable: true, value: 0 });
fireEvent.scroll(window);
await waitFor(() => {
expect(nav).toHaveClass('bg-transparent');
});
});
it('should clean up scroll event listener on unmount', () => {
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
const { unmount } = render(
<Navbar darkMode={false} toggleTheme={mockToggleTheme} />,
{ wrapper: createWrapper() }
);
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function));
});
});
describe('Accessibility', () => {
it('should have navigation role', () => {
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper(),
});
const nav = screen.getByRole('navigation');
expect(nav).toBeInTheDocument();
});
it('should have aria-label on theme toggle button', () => {
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper(),
});
const themeButton = screen.getByLabelText('Switch to dark mode');
expect(themeButton).toHaveAttribute('aria-label');
});
it('should have aria-label on mobile menu toggle button', () => {
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper(),
});
const menuButton = screen.getByLabelText('Toggle menu');
expect(menuButton).toHaveAttribute('aria-label');
});
it('should have semantic link elements for navigation', () => {
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper(),
});
const links = screen.getAllByRole('link');
expect(links.length).toBeGreaterThan(0);
});
});
describe('Language Selector', () => {
it('should render language selector on desktop', () => {
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper(),
});
const languageSelectors = screen.getAllByTestId('language-selector');
expect(languageSelectors.length).toBeGreaterThan(0);
});
it('should render language selector in mobile menu', () => {
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper(),
});
const menuButton = screen.getByLabelText('Toggle menu');
fireEvent.click(menuButton);
const languageSelectors = screen.getAllByTestId('language-selector');
expect(languageSelectors).toHaveLength(2); // Desktop + Mobile
});
});
describe('Styling and Layout', () => {
it('should have fixed positioning', () => {
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper(),
});
const nav = screen.getByRole('navigation');
expect(nav).toHaveClass('fixed', 'top-0', 'left-0', 'right-0', 'z-50');
});
it('should have transition classes for smooth animations', () => {
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper(),
});
const nav = screen.getByRole('navigation');
expect(nav).toHaveClass('transition-all', 'duration-300');
});
it('should have max-width container', () => {
const { container } = render(
<Navbar darkMode={false} toggleTheme={mockToggleTheme} />,
{ wrapper: createWrapper() }
);
const maxWidthContainer = container.querySelector('.max-w-7xl');
expect(maxWidthContainer).toBeInTheDocument();
});
it('should hide desktop nav on mobile screens', () => {
const { container } = render(
<Navbar darkMode={false} toggleTheme={mockToggleTheme} />,
{ wrapper: createWrapper() }
);
const desktopNav = container.querySelector('.hidden.lg\\:flex');
expect(desktopNav).toBeInTheDocument();
});
it('should hide mobile menu button on large screens', () => {
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper(),
});
const menuButton = screen.getByLabelText('Toggle menu');
expect(menuButton).toHaveClass('lg:hidden');
});
});
describe('Dark Mode Support', () => {
it('should apply dark mode classes when darkMode is true and scrolled', async () => {
render(<Navbar darkMode={true} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper(),
});
const nav = screen.getByRole('navigation');
// Simulate scroll to trigger background change
Object.defineProperty(window, 'scrollY', { writable: true, value: 50 });
fireEvent.scroll(window);
await waitFor(() => {
// The component uses dark: prefix for dark mode classes
expect(nav.className).toContain('dark:bg-gray-900/80');
});
});
it('should apply light mode classes when darkMode is false and scrolled', async () => {
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper(),
});
const nav = screen.getByRole('navigation');
// Simulate scroll to trigger background change
Object.defineProperty(window, 'scrollY', { writable: true, value: 50 });
fireEvent.scroll(window);
await waitFor(() => {
expect(nav.className).toContain('bg-white/80');
});
});
});
describe('User Role Based Dashboard Links', () => {
it('should link to platform dashboard for platform_manager', () => {
const mockUser: User = {
id: 1,
email: 'manager@example.com',
username: 'manager',
first_name: 'Manager',
last_name: 'User',
role: 'platform_manager',
business_subdomain: null,
is_active: true,
};
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} user={mockUser} />, {
wrapper: createWrapper(),
});
const loginLinks = screen.getAllByText('Login');
const dashboardLink = loginLinks[0].closest('a');
expect(dashboardLink).toHaveAttribute('href', 'http://platform.lvh.me:5173/');
});
it('should link to platform dashboard for platform_support', () => {
const mockUser: User = {
id: 1,
email: 'support@example.com',
username: 'support',
first_name: 'Support',
last_name: 'User',
role: 'platform_support',
business_subdomain: null,
is_active: true,
};
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} user={mockUser} />, {
wrapper: createWrapper(),
});
const loginLinks = screen.getAllByText('Login');
const dashboardLink = loginLinks[0].closest('a');
expect(dashboardLink).toHaveAttribute('href', 'http://platform.lvh.me:5173/');
});
it('should link to login when user has no subdomain', () => {
const mockUser: User = {
id: 1,
email: 'user@example.com',
username: 'user',
first_name: 'Regular',
last_name: 'User',
role: 'customer',
business_subdomain: null,
is_active: true,
};
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} user={mockUser} />, {
wrapper: createWrapper(),
});
const loginLinks = screen.getAllByText('Login');
const dashboardLink = loginLinks[0].closest('a');
// Falls back to /login when no business_subdomain
expect(dashboardLink).toHaveAttribute('href', '/login');
});
});
});

View File

@@ -0,0 +1,604 @@
/**
* Unit tests for PricingCard component
*
* Tests cover:
* - Plan name rendering
* - Price display (monthly, annual, custom)
* - Features list rendering
* - CTA button functionality
* - Popular/highlighted badge
* - Transaction fees
* - Trial information
* - Styling variations
* - Internationalization (i18n)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import React from 'react';
import PricingCard from '../PricingCard';
// Mock translation data
const mockTranslations: Record<string, any> = {
'marketing.pricing.mostPopular': 'Most Popular',
'marketing.pricing.perMonth': '/month',
'marketing.pricing.getStarted': 'Get Started',
'marketing.pricing.contactSales': 'Contact Sales',
'marketing.pricing.tiers.free.name': 'Free',
'marketing.pricing.tiers.free.description': 'Perfect for getting started',
'marketing.pricing.tiers.free.features': [
'Up to 2 resources',
'Basic scheduling',
'Customer management',
'Direct Stripe integration',
'Subdomain (business.smoothschedule.com)',
'Community support',
],
'marketing.pricing.tiers.free.transactionFee': '2.5% + $0.30 per transaction',
'marketing.pricing.tiers.free.trial': 'Free forever - no trial needed',
'marketing.pricing.tiers.professional.name': 'Professional',
'marketing.pricing.tiers.professional.description': 'For growing businesses',
'marketing.pricing.tiers.professional.features': [
'Up to 10 resources',
'Custom domain',
'Stripe Connect (lower fees)',
'White-label branding',
'Email reminders',
'Priority email support',
],
'marketing.pricing.tiers.professional.transactionFee': '1.5% + $0.25 per transaction',
'marketing.pricing.tiers.professional.trial': '14-day free trial',
'marketing.pricing.tiers.business.name': 'Business',
'marketing.pricing.tiers.business.description': 'Full power of the platform for serious operations.',
'marketing.pricing.tiers.business.features': [
'Unlimited Users',
'Unlimited Appointments',
'Unlimited Automations',
'Custom Python Scripts',
'Custom Domain (White-Label)',
'Dedicated Support',
'API Access',
],
'marketing.pricing.tiers.business.transactionFee': '1.0% + $0.20 per transaction',
'marketing.pricing.tiers.business.trial': '14-day free trial',
'marketing.pricing.tiers.enterprise.name': 'Enterprise',
'marketing.pricing.tiers.enterprise.description': 'For large organizations',
'marketing.pricing.tiers.enterprise.price': 'Custom',
'marketing.pricing.tiers.enterprise.features': [
'All Business features',
'Custom integrations',
'Dedicated success manager',
'SLA guarantees',
'Custom contracts',
'On-premise option',
],
'marketing.pricing.tiers.enterprise.transactionFee': 'Custom transaction fees',
'marketing.pricing.tiers.enterprise.trial': '14-day free trial',
};
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: any) => {
if (options?.returnObjects) {
return mockTranslations[key] || [];
}
return mockTranslations[key] || key;
},
}),
}));
// Test wrapper with Router
const createWrapper = () => {
return ({ children }: { children: React.ReactNode }) => (
<BrowserRouter>{children}</BrowserRouter>
);
};
describe('PricingCard', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('Plan Name Rendering', () => {
it('should render free tier name', () => {
render(<PricingCard tier="free" billingPeriod="monthly" />, {
wrapper: createWrapper(),
});
expect(screen.getByText('Free')).toBeInTheDocument();
});
it('should render professional tier name', () => {
render(<PricingCard tier="professional" billingPeriod="monthly" />, {
wrapper: createWrapper(),
});
expect(screen.getByText('Professional')).toBeInTheDocument();
});
it('should render business tier name', () => {
render(<PricingCard tier="business" billingPeriod="monthly" />, {
wrapper: createWrapper(),
});
expect(screen.getByText('Business')).toBeInTheDocument();
});
it('should render enterprise tier name', () => {
render(<PricingCard tier="enterprise" billingPeriod="monthly" />, {
wrapper: createWrapper(),
});
expect(screen.getByText('Enterprise')).toBeInTheDocument();
});
it('should render tier description', () => {
render(<PricingCard tier="professional" billingPeriod="monthly" />, {
wrapper: createWrapper(),
});
expect(screen.getByText('For growing businesses')).toBeInTheDocument();
});
});
describe('Price Display', () => {
describe('Monthly Billing', () => {
it('should display free tier price correctly', () => {
render(<PricingCard tier="free" billingPeriod="monthly" />, {
wrapper: createWrapper(),
});
expect(screen.getByText('$0')).toBeInTheDocument();
expect(screen.getByText('/month')).toBeInTheDocument();
});
it('should display professional tier monthly price', () => {
render(<PricingCard tier="professional" billingPeriod="monthly" />, {
wrapper: createWrapper(),
});
expect(screen.getByText('$29')).toBeInTheDocument();
expect(screen.getByText('/month')).toBeInTheDocument();
});
it('should display business tier monthly price', () => {
render(<PricingCard tier="business" billingPeriod="monthly" />, {
wrapper: createWrapper(),
});
expect(screen.getByText('$79')).toBeInTheDocument();
expect(screen.getByText('/month')).toBeInTheDocument();
});
});
describe('Annual Billing', () => {
it('should display professional tier annual price', () => {
render(<PricingCard tier="professional" billingPeriod="annual" />, {
wrapper: createWrapper(),
});
expect(screen.getByText('$290')).toBeInTheDocument();
expect(screen.getByText('/year')).toBeInTheDocument();
});
it('should display business tier annual price', () => {
render(<PricingCard tier="business" billingPeriod="annual" />, {
wrapper: createWrapper(),
});
expect(screen.getByText('$790')).toBeInTheDocument();
expect(screen.getByText('/year')).toBeInTheDocument();
});
it('should display free tier with annual billing', () => {
render(<PricingCard tier="free" billingPeriod="annual" />, {
wrapper: createWrapper(),
});
expect(screen.getByText('$0')).toBeInTheDocument();
expect(screen.getByText('/year')).toBeInTheDocument();
});
});
describe('Custom Pricing', () => {
it('should display custom price for enterprise tier', () => {
render(<PricingCard tier="enterprise" billingPeriod="monthly" />, {
wrapper: createWrapper(),
});
expect(screen.getByText('Custom')).toBeInTheDocument();
expect(screen.queryByText('$')).not.toBeInTheDocument();
});
it('should display custom price for enterprise tier with annual billing', () => {
render(<PricingCard tier="enterprise" billingPeriod="annual" />, {
wrapper: createWrapper(),
});
expect(screen.getByText('Custom')).toBeInTheDocument();
expect(screen.queryByText('/year')).not.toBeInTheDocument();
});
});
});
describe('Features List Rendering', () => {
it('should render all features for free tier', () => {
render(<PricingCard tier="free" billingPeriod="monthly" />, {
wrapper: createWrapper(),
});
expect(screen.getByText('Up to 2 resources')).toBeInTheDocument();
expect(screen.getByText('Basic scheduling')).toBeInTheDocument();
expect(screen.getByText('Customer management')).toBeInTheDocument();
expect(screen.getByText('Direct Stripe integration')).toBeInTheDocument();
expect(screen.getByText('Subdomain (business.smoothschedule.com)')).toBeInTheDocument();
expect(screen.getByText('Community support')).toBeInTheDocument();
});
it('should render all features for professional tier', () => {
render(<PricingCard tier="professional" billingPeriod="monthly" />, {
wrapper: createWrapper(),
});
expect(screen.getByText('Up to 10 resources')).toBeInTheDocument();
expect(screen.getByText('Custom domain')).toBeInTheDocument();
expect(screen.getByText('Stripe Connect (lower fees)')).toBeInTheDocument();
expect(screen.getByText('White-label branding')).toBeInTheDocument();
expect(screen.getByText('Email reminders')).toBeInTheDocument();
expect(screen.getByText('Priority email support')).toBeInTheDocument();
});
it('should render all features for enterprise tier', () => {
render(<PricingCard tier="enterprise" billingPeriod="monthly" />, {
wrapper: createWrapper(),
});
expect(screen.getByText('All Business features')).toBeInTheDocument();
expect(screen.getByText('Custom integrations')).toBeInTheDocument();
expect(screen.getByText('Dedicated success manager')).toBeInTheDocument();
expect(screen.getByText('SLA guarantees')).toBeInTheDocument();
expect(screen.getByText('Custom contracts')).toBeInTheDocument();
expect(screen.getByText('On-premise option')).toBeInTheDocument();
});
it('should render check icons for each feature', () => {
const { container } = render(
<PricingCard tier="professional" billingPeriod="monthly" />,
{ wrapper: createWrapper() }
);
const checkIcons = container.querySelectorAll('svg');
// Should have at least 6 check icons (one for each feature)
expect(checkIcons.length).toBeGreaterThanOrEqual(6);
});
});
describe('Transaction Fees', () => {
it('should display transaction fee for free tier', () => {
render(<PricingCard tier="free" billingPeriod="monthly" />, {
wrapper: createWrapper(),
});
expect(screen.getByText('2.5% + $0.30 per transaction')).toBeInTheDocument();
});
it('should display transaction fee for professional tier', () => {
render(<PricingCard tier="professional" billingPeriod="monthly" />, {
wrapper: createWrapper(),
});
expect(screen.getByText('1.5% + $0.25 per transaction')).toBeInTheDocument();
});
it('should display custom transaction fees for enterprise tier', () => {
render(<PricingCard tier="enterprise" billingPeriod="monthly" />, {
wrapper: createWrapper(),
});
expect(screen.getByText('Custom transaction fees')).toBeInTheDocument();
});
});
describe('Trial Information', () => {
it('should display trial information for free tier', () => {
render(<PricingCard tier="free" billingPeriod="monthly" />, {
wrapper: createWrapper(),
});
expect(screen.getByText('Free forever - no trial needed')).toBeInTheDocument();
});
it('should display trial information for professional tier', () => {
render(<PricingCard tier="professional" billingPeriod="monthly" />, {
wrapper: createWrapper(),
});
expect(screen.getByText('14-day free trial')).toBeInTheDocument();
});
it('should display trial information for enterprise tier', () => {
render(<PricingCard tier="enterprise" billingPeriod="monthly" />, {
wrapper: createWrapper(),
});
expect(screen.getByText('14-day free trial')).toBeInTheDocument();
});
});
describe('CTA Button', () => {
it('should render Get Started button for free tier', () => {
render(<PricingCard tier="free" billingPeriod="monthly" />, {
wrapper: createWrapper(),
});
const button = screen.getByRole('link', { name: /get started/i });
expect(button).toBeInTheDocument();
expect(button).toHaveAttribute('href', '/signup');
});
it('should render Get Started button for professional tier', () => {
render(<PricingCard tier="professional" billingPeriod="monthly" />, {
wrapper: createWrapper(),
});
const button = screen.getByRole('link', { name: /get started/i });
expect(button).toBeInTheDocument();
expect(button).toHaveAttribute('href', '/signup');
});
it('should render Get Started button for business tier', () => {
render(<PricingCard tier="business" billingPeriod="monthly" />, {
wrapper: createWrapper(),
});
const button = screen.getByRole('link', { name: /get started/i });
expect(button).toBeInTheDocument();
expect(button).toHaveAttribute('href', '/signup');
});
it('should render Contact Sales button for enterprise tier', () => {
render(<PricingCard tier="enterprise" billingPeriod="monthly" />, {
wrapper: createWrapper(),
});
const button = screen.getByRole('link', { name: /contact sales/i });
expect(button).toBeInTheDocument();
expect(button).toHaveAttribute('href', '/contact');
});
it('should render Contact Sales button for highlighted enterprise tier', () => {
render(<PricingCard tier="enterprise" billingPeriod="monthly" highlighted />, {
wrapper: createWrapper(),
});
const button = screen.getByRole('link', { name: /contact sales/i });
expect(button).toBeInTheDocument();
expect(button).toHaveAttribute('href', '/contact');
});
});
describe('Popular/Highlighted Badge', () => {
it('should not display badge when not highlighted', () => {
render(<PricingCard tier="professional" billingPeriod="monthly" />, {
wrapper: createWrapper(),
});
expect(screen.queryByText('Most Popular')).not.toBeInTheDocument();
});
it('should display Most Popular badge when highlighted', () => {
render(<PricingCard tier="professional" billingPeriod="monthly" highlighted />, {
wrapper: createWrapper(),
});
expect(screen.getByText('Most Popular')).toBeInTheDocument();
});
it('should display badge for any tier when highlighted', () => {
const { rerender } = render(
<PricingCard tier="free" billingPeriod="monthly" highlighted />,
{ wrapper: createWrapper() }
);
expect(screen.getByText('Most Popular')).toBeInTheDocument();
rerender(<PricingCard tier="business" billingPeriod="monthly" highlighted />);
expect(screen.getByText('Most Popular')).toBeInTheDocument();
rerender(<PricingCard tier="enterprise" billingPeriod="monthly" highlighted />);
expect(screen.getByText('Most Popular')).toBeInTheDocument();
});
});
describe('Styling Variations', () => {
it('should apply default styling for non-highlighted card', () => {
const { container } = render(
<PricingCard tier="free" billingPeriod="monthly" />,
{ wrapper: createWrapper() }
);
const card = container.firstChild as HTMLElement;
expect(card).toHaveClass('bg-white');
expect(card).toHaveClass('border-gray-200');
expect(card).not.toHaveClass('bg-brand-600');
});
it('should apply highlighted styling for highlighted card', () => {
const { container } = render(
<PricingCard tier="professional" billingPeriod="monthly" highlighted />,
{ wrapper: createWrapper() }
);
const card = container.firstChild as HTMLElement;
expect(card).toHaveClass('bg-brand-600');
expect(card).not.toHaveClass('bg-white');
});
it('should apply different button styles for highlighted card', () => {
render(<PricingCard tier="professional" billingPeriod="monthly" highlighted />, {
wrapper: createWrapper(),
});
const button = screen.getByRole('link', { name: /get started/i });
expect(button).toHaveClass('bg-white');
expect(button).toHaveClass('text-brand-600');
});
it('should apply different button styles for non-highlighted card', () => {
render(<PricingCard tier="professional" billingPeriod="monthly" />, {
wrapper: createWrapper(),
});
const button = screen.getByRole('link', { name: /get started/i });
expect(button).toHaveClass('bg-brand-50');
expect(button).toHaveClass('text-brand-600');
});
});
describe('Billing Period Switching', () => {
it('should switch from monthly to annual pricing', () => {
const { rerender } = render(
<PricingCard tier="professional" billingPeriod="monthly" />,
{ wrapper: createWrapper() }
);
expect(screen.getByText('$29')).toBeInTheDocument();
expect(screen.getByText('/month')).toBeInTheDocument();
rerender(<PricingCard tier="professional" billingPeriod="annual" />);
expect(screen.getByText('$290')).toBeInTheDocument();
expect(screen.getByText('/year')).toBeInTheDocument();
});
it('should maintain other props when billing period changes', () => {
const { rerender } = render(
<PricingCard tier="professional" billingPeriod="monthly" highlighted />,
{ wrapper: createWrapper() }
);
expect(screen.getByText('Most Popular')).toBeInTheDocument();
expect(screen.getByText('Professional')).toBeInTheDocument();
rerender(<PricingCard tier="professional" billingPeriod="annual" highlighted />);
expect(screen.getByText('Most Popular')).toBeInTheDocument();
expect(screen.getByText('Professional')).toBeInTheDocument();
});
});
describe('Integration', () => {
it('should render complete highlighted professional card', () => {
const { container } = render(
<PricingCard tier="professional" billingPeriod="monthly" highlighted />,
{ wrapper: createWrapper() }
);
// Badge
expect(screen.getByText('Most Popular')).toBeInTheDocument();
// Plan name and description
expect(screen.getByText('Professional')).toBeInTheDocument();
expect(screen.getByText('For growing businesses')).toBeInTheDocument();
// Price
expect(screen.getByText('$29')).toBeInTheDocument();
expect(screen.getByText('/month')).toBeInTheDocument();
// Trial info
expect(screen.getByText('14-day free trial')).toBeInTheDocument();
// Features (at least one)
expect(screen.getByText('Up to 10 resources')).toBeInTheDocument();
// Transaction fee
expect(screen.getByText('1.5% + $0.25 per transaction')).toBeInTheDocument();
// CTA
const button = screen.getByRole('link', { name: /get started/i });
expect(button).toBeInTheDocument();
// Styling
const card = container.firstChild as HTMLElement;
expect(card).toHaveClass('bg-brand-600');
});
it('should render complete non-highlighted enterprise card', () => {
render(<PricingCard tier="enterprise" billingPeriod="annual" />, {
wrapper: createWrapper(),
});
// No badge
expect(screen.queryByText('Most Popular')).not.toBeInTheDocument();
// Plan name and description
expect(screen.getByText('Enterprise')).toBeInTheDocument();
expect(screen.getByText('For large organizations')).toBeInTheDocument();
// Custom price
expect(screen.getByText('Custom')).toBeInTheDocument();
// Trial info
expect(screen.getByText('14-day free trial')).toBeInTheDocument();
// Features (at least one)
expect(screen.getByText('All Business features')).toBeInTheDocument();
// Transaction fee
expect(screen.getByText('Custom transaction fees')).toBeInTheDocument();
// CTA
const button = screen.getByRole('link', { name: /contact sales/i });
expect(button).toBeInTheDocument();
expect(button).toHaveAttribute('href', '/contact');
});
it('should render all card variations correctly', () => {
const tiers: Array<'free' | 'professional' | 'business' | 'enterprise'> = [
'free',
'professional',
'business',
'enterprise',
];
tiers.forEach((tier) => {
const { unmount } = render(
<PricingCard tier={tier} billingPeriod="monthly" />,
{ wrapper: createWrapper() }
);
// Each tier should have a CTA button
const button = screen.getByRole('link', {
name: tier === 'enterprise' ? /contact sales/i : /get started/i,
});
expect(button).toBeInTheDocument();
unmount();
});
});
});
describe('Accessibility', () => {
it('should have accessible link elements', () => {
render(<PricingCard tier="professional" billingPeriod="monthly" />, {
wrapper: createWrapper(),
});
const button = screen.getByRole('link', { name: /get started/i });
expect(button.tagName).toBe('A');
});
it('should maintain semantic structure', () => {
const { container } = render(
<PricingCard tier="professional" billingPeriod="monthly" />,
{ wrapper: createWrapper() }
);
// Should have heading elements
const heading = screen.getByText('Professional');
expect(heading.tagName).toBe('H3');
});
});
});

View File

@@ -0,0 +1,521 @@
/**
* Unit tests for PricingTable component
*
* Tests cover:
* - Component rendering
* - All pricing tiers display
* - Feature lists (included and not included)
* - Column headers and tier information
* - Popular badge display
* - CTA buttons and links
* - Accessibility attributes
* - Internationalization (i18n)
* - Responsive grid layout
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import React from 'react';
import PricingTable from '../PricingTable';
// Mock translation data matching the actual en.json structure
const mockTranslations: Record<string, string> = {
'marketing.pricing.tiers.starter.name': 'Starter',
'marketing.pricing.tiers.starter.description': 'Perfect for solo practitioners and small studios.',
'marketing.pricing.tiers.starter.cta': 'Start Free',
'marketing.pricing.tiers.starter.features.0': '1 User',
'marketing.pricing.tiers.starter.features.1': 'Unlimited Appointments',
'marketing.pricing.tiers.starter.features.2': '1 Active Automation',
'marketing.pricing.tiers.starter.features.3': 'Basic Reporting',
'marketing.pricing.tiers.starter.features.4': 'Email Support',
'marketing.pricing.tiers.starter.notIncluded.0': 'Custom Domain',
'marketing.pricing.tiers.starter.notIncluded.1': 'Python Scripting',
'marketing.pricing.tiers.starter.notIncluded.2': 'White-Labeling',
'marketing.pricing.tiers.starter.notIncluded.3': 'Priority Support',
'marketing.pricing.tiers.pro.name': 'Pro',
'marketing.pricing.tiers.pro.description': 'For growing businesses that need automation.',
'marketing.pricing.tiers.pro.cta': 'Start Trial',
'marketing.pricing.tiers.pro.features.0': '5 Users',
'marketing.pricing.tiers.pro.features.1': 'Unlimited Appointments',
'marketing.pricing.tiers.pro.features.2': '5 Active Automations',
'marketing.pricing.tiers.pro.features.3': 'Advanced Reporting',
'marketing.pricing.tiers.pro.features.4': 'Priority Email Support',
'marketing.pricing.tiers.pro.features.5': 'SMS Reminders',
'marketing.pricing.tiers.pro.notIncluded.0': 'Custom Domain',
'marketing.pricing.tiers.pro.notIncluded.1': 'Python Scripting',
'marketing.pricing.tiers.pro.notIncluded.2': 'White-Labeling',
'marketing.pricing.tiers.business.name': 'Business',
'marketing.pricing.tiers.business.description': 'Full power of the platform for serious operations.',
'marketing.pricing.tiers.business.cta': 'Contact Sales',
'marketing.pricing.tiers.business.features.0': 'Unlimited Users',
'marketing.pricing.tiers.business.features.1': 'Unlimited Appointments',
'marketing.pricing.tiers.business.features.2': 'Unlimited Automations',
'marketing.pricing.tiers.business.features.3': 'Custom Python Scripts',
'marketing.pricing.tiers.business.features.4': 'Custom Domain (White-Label)',
'marketing.pricing.tiers.business.features.5': 'Dedicated Support',
'marketing.pricing.tiers.business.features.6': 'API Access',
'marketing.pricing.perMonth': '/month',
'marketing.pricing.mostPopular': 'Most Popular',
'marketing.pricing.contactSales': 'Contact Sales',
};
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => mockTranslations[key] || key,
}),
}));
// Test wrapper with Router
const createWrapper = () => {
return ({ children }: { children: React.ReactNode }) => (
<BrowserRouter>{children}</BrowserRouter>
);
};
describe('PricingTable', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('should render the pricing table', () => {
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
const grid = container.querySelector('.grid');
expect(grid).toBeInTheDocument();
});
it('should render with grid layout classes', () => {
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
const grid = container.querySelector('.grid.md\\:grid-cols-3');
expect(grid).toBeInTheDocument();
});
it('should render with responsive spacing classes', () => {
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
const grid = container.querySelector('.max-w-7xl.mx-auto');
expect(grid).toBeInTheDocument();
});
});
describe('Pricing Tiers', () => {
it('should render all three pricing tiers', () => {
render(<PricingTable />, { wrapper: createWrapper() });
expect(screen.getByText('Starter')).toBeInTheDocument();
expect(screen.getByText('Pro')).toBeInTheDocument();
expect(screen.getByText('Business')).toBeInTheDocument();
});
it('should render tier names as headings', () => {
render(<PricingTable />, { wrapper: createWrapper() });
const starterHeading = screen.getByRole('heading', { name: 'Starter' });
const proHeading = screen.getByRole('heading', { name: 'Pro' });
const businessHeading = screen.getByRole('heading', { name: 'Business' });
expect(starterHeading).toBeInTheDocument();
expect(proHeading).toBeInTheDocument();
expect(businessHeading).toBeInTheDocument();
});
it('should render correct tier descriptions', () => {
render(<PricingTable />, { wrapper: createWrapper() });
expect(screen.getByText('Perfect for solo practitioners and small studios.')).toBeInTheDocument();
expect(screen.getByText('For growing businesses that need automation.')).toBeInTheDocument();
expect(screen.getByText('Full power of the platform for serious operations.')).toBeInTheDocument();
});
it('should render correct prices', () => {
render(<PricingTable />, { wrapper: createWrapper() });
expect(screen.getByText('$0')).toBeInTheDocument();
expect(screen.getByText('$29')).toBeInTheDocument();
expect(screen.getByText('$99')).toBeInTheDocument();
});
it('should render price periods', () => {
render(<PricingTable />, { wrapper: createWrapper() });
const periods = screen.getAllByText('/month');
expect(periods).toHaveLength(3);
});
});
describe('Popular Badge', () => {
it('should show "Most Popular" badge on Pro tier', () => {
render(<PricingTable />, { wrapper: createWrapper() });
const badge = screen.getByText('Most Popular');
expect(badge).toBeInTheDocument();
});
it('should only show one popular badge', () => {
render(<PricingTable />, { wrapper: createWrapper() });
const badges = screen.getAllByText('Most Popular');
expect(badges).toHaveLength(1);
});
it('should style the popular tier differently', () => {
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
const popularCard = container.querySelector('.border-brand-500.scale-105');
expect(popularCard).toBeInTheDocument();
});
});
describe('Feature Lists - Included Features', () => {
it('should render Starter tier features', () => {
render(<PricingTable />, { wrapper: createWrapper() });
expect(screen.getByText('1 User')).toBeInTheDocument();
// "Unlimited Appointments" appears in all tiers, so use getAllByText
expect(screen.getAllByText('Unlimited Appointments')[0]).toBeInTheDocument();
expect(screen.getByText('1 Active Automation')).toBeInTheDocument();
expect(screen.getByText('Basic Reporting')).toBeInTheDocument();
expect(screen.getByText('Email Support')).toBeInTheDocument();
});
it('should render Pro tier features', () => {
render(<PricingTable />, { wrapper: createWrapper() });
expect(screen.getByText('5 Users')).toBeInTheDocument();
expect(screen.getByText('5 Active Automations')).toBeInTheDocument();
expect(screen.getByText('Advanced Reporting')).toBeInTheDocument();
expect(screen.getByText('Priority Email Support')).toBeInTheDocument();
expect(screen.getByText('SMS Reminders')).toBeInTheDocument();
});
it('should render Business tier features', () => {
render(<PricingTable />, { wrapper: createWrapper() });
expect(screen.getByText('Unlimited Users')).toBeInTheDocument();
expect(screen.getByText('Unlimited Automations')).toBeInTheDocument();
expect(screen.getByText('Custom Python Scripts')).toBeInTheDocument();
expect(screen.getByText('Custom Domain (White-Label)')).toBeInTheDocument();
expect(screen.getByText('Dedicated Support')).toBeInTheDocument();
expect(screen.getByText('API Access')).toBeInTheDocument();
});
it('should render features with check icons', () => {
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
// Check icons are rendered as SVGs with lucide-react
const checkIcons = container.querySelectorAll('svg');
expect(checkIcons.length).toBeGreaterThan(0);
});
});
describe('Feature Lists - Not Included Features', () => {
it('should render Starter tier excluded features', () => {
render(<PricingTable />, { wrapper: createWrapper() });
// These features appear in multiple tiers, so use getAllByText
expect(screen.getAllByText('Custom Domain').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Python Scripting').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('White-Labeling').length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('Priority Support')).toBeInTheDocument();
});
it('should render Pro tier excluded features', () => {
render(<PricingTable />, { wrapper: createWrapper() });
// Pro tier has these excluded
const customDomains = screen.getAllByText('Custom Domain');
expect(customDomains.length).toBeGreaterThanOrEqual(1);
const pythonScripting = screen.getAllByText('Python Scripting');
expect(pythonScripting.length).toBeGreaterThanOrEqual(1);
const whiteLabeling = screen.getAllByText('White-Labeling');
expect(whiteLabeling.length).toBeGreaterThanOrEqual(1);
});
it('should not render excluded features for Business tier', () => {
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
// Business tier has empty notIncluded array
// All features should be included (no X icons in that column)
// We can't easily test the absence without more context
// But we verify the business tier is rendered
expect(screen.getByText('Business')).toBeInTheDocument();
// Count the number of X icons - should be less than total excluded features
const allListItems = container.querySelectorAll('li');
expect(allListItems.length).toBeGreaterThan(0);
});
it('should style excluded features differently', () => {
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
const excludedItems = container.querySelectorAll('li.opacity-50');
expect(excludedItems.length).toBeGreaterThan(0);
});
});
describe('CTA Buttons', () => {
it('should render CTA button for each tier', () => {
render(<PricingTable />, { wrapper: createWrapper() });
const startFreeBtn = screen.getByRole('link', { name: 'Start Free' });
const startTrialBtn = screen.getByRole('link', { name: 'Start Trial' });
const contactSalesBtn = screen.getByRole('link', { name: 'Contact Sales' });
expect(startFreeBtn).toBeInTheDocument();
expect(startTrialBtn).toBeInTheDocument();
expect(contactSalesBtn).toBeInTheDocument();
});
it('should have correct links for each tier', () => {
render(<PricingTable />, { wrapper: createWrapper() });
const startFreeBtn = screen.getByRole('link', { name: 'Start Free' });
const startTrialBtn = screen.getByRole('link', { name: 'Start Trial' });
const contactSalesBtn = screen.getByRole('link', { name: 'Contact Sales' });
expect(startFreeBtn).toHaveAttribute('href', '/signup');
expect(startTrialBtn).toHaveAttribute('href', '/signup?plan=pro');
expect(contactSalesBtn).toHaveAttribute('href', '/contact');
});
it('should style popular tier CTA button differently', () => {
render(<PricingTable />, { wrapper: createWrapper() });
const startTrialBtn = screen.getByRole('link', { name: 'Start Trial' });
expect(startTrialBtn).toHaveClass('bg-brand-600');
expect(startTrialBtn).toHaveClass('text-white');
expect(startTrialBtn).toHaveClass('hover:bg-brand-700');
});
it('should style non-popular tier CTA buttons consistently', () => {
render(<PricingTable />, { wrapper: createWrapper() });
const startFreeBtn = screen.getByRole('link', { name: 'Start Free' });
const contactSalesBtn = screen.getByRole('link', { name: 'Contact Sales' });
[startFreeBtn, contactSalesBtn].forEach(btn => {
expect(btn).toHaveClass('bg-gray-100');
expect(btn).toHaveClass('dark:bg-gray-700');
});
});
});
describe('Accessibility', () => {
it('should have proper heading hierarchy', () => {
render(<PricingTable />, { wrapper: createWrapper() });
const headings = screen.getAllByRole('heading');
expect(headings).toHaveLength(3); // One for each tier
});
it('should use semantic list elements for features', () => {
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
const lists = container.querySelectorAll('ul');
expect(lists.length).toBeGreaterThan(0);
});
it('should have accessible link elements for CTAs', () => {
render(<PricingTable />, { wrapper: createWrapper() });
const links = screen.getAllByRole('link');
expect(links).toHaveLength(3); // One CTA per tier
});
it('should maintain proper color contrast', () => {
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
const tierCards = container.querySelectorAll('.bg-white.dark\\:bg-gray-800');
expect(tierCards.length).toBeGreaterThan(0);
});
});
describe('Styling and Layout', () => {
it('should apply card styling to tier containers', () => {
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
const cards = container.querySelectorAll('.rounded-2xl.border');
expect(cards).toHaveLength(3);
});
it('should apply padding to tier cards', () => {
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
const cards = container.querySelectorAll('.p-8');
expect(cards).toHaveLength(3);
});
it('should use flex layout for card content', () => {
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
const flexContainers = container.querySelectorAll('.flex.flex-col');
expect(flexContainers.length).toBeGreaterThan(0);
});
it('should apply spacing between features', () => {
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
const featureLists = container.querySelectorAll('.space-y-4');
expect(featureLists.length).toBeGreaterThan(0);
});
it('should apply shadow effects appropriately', () => {
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
const shadowXl = container.querySelector('.shadow-xl');
expect(shadowXl).toBeInTheDocument(); // Popular tier
const shadowSm = container.querySelectorAll('.shadow-sm');
expect(shadowSm.length).toBeGreaterThan(0); // Other tiers
});
});
describe('Internationalization', () => {
it('should use translations for tier names', () => {
render(<PricingTable />, { wrapper: createWrapper() });
expect(screen.getByText('Starter')).toBeInTheDocument();
expect(screen.getByText('Pro')).toBeInTheDocument();
expect(screen.getByText('Business')).toBeInTheDocument();
});
it('should use translations for tier descriptions', () => {
render(<PricingTable />, { wrapper: createWrapper() });
expect(screen.getByText(/Perfect for solo practitioners/)).toBeInTheDocument();
expect(screen.getByText(/For growing businesses/)).toBeInTheDocument();
expect(screen.getByText(/Full power of the platform/)).toBeInTheDocument();
});
it('should use translations for feature text', () => {
render(<PricingTable />, { wrapper: createWrapper() });
// Sample some features to verify translations are used
// Use getAllByText for features that appear in multiple tiers
expect(screen.getAllByText('Unlimited Appointments').length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('Custom Python Scripts')).toBeInTheDocument();
expect(screen.getByText('SMS Reminders')).toBeInTheDocument();
});
it('should use translations for CTA buttons', () => {
render(<PricingTable />, { wrapper: createWrapper() });
expect(screen.getByRole('link', { name: 'Start Free' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Start Trial' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Contact Sales' })).toBeInTheDocument();
});
it('should use translations for price periods', () => {
render(<PricingTable />, { wrapper: createWrapper() });
const periods = screen.getAllByText('/month');
expect(periods).toHaveLength(3);
});
it('should use translations for popular badge', () => {
render(<PricingTable />, { wrapper: createWrapper() });
expect(screen.getByText('Most Popular')).toBeInTheDocument();
});
});
describe('Integration', () => {
it('should render complete pricing table with all elements', () => {
render(<PricingTable />, { wrapper: createWrapper() });
// Verify all major elements are present
expect(screen.getByText('Starter')).toBeInTheDocument();
expect(screen.getByText('Pro')).toBeInTheDocument();
expect(screen.getByText('Business')).toBeInTheDocument();
expect(screen.getByText('Most Popular')).toBeInTheDocument();
expect(screen.getByText('$0')).toBeInTheDocument();
expect(screen.getByText('$29')).toBeInTheDocument();
expect(screen.getByText('$99')).toBeInTheDocument();
expect(screen.getAllByRole('link')).toHaveLength(3);
});
it('should maintain proper structure with icons and text', () => {
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
const cards = container.querySelectorAll('.flex.flex-col');
expect(cards.length).toBeGreaterThan(0);
const icons = container.querySelectorAll('svg');
expect(icons.length).toBeGreaterThan(0);
const lists = container.querySelectorAll('ul');
expect(lists.length).toBeGreaterThan(0);
});
it('should work with React Router BrowserRouter', () => {
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
const links = container.querySelectorAll('a');
expect(links).toHaveLength(3);
links.forEach(link => {
expect(link).toBeInstanceOf(HTMLAnchorElement);
});
});
});
describe('Responsive Design', () => {
it('should use responsive grid classes', () => {
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
const grid = container.querySelector('.md\\:grid-cols-3');
expect(grid).toBeInTheDocument();
});
it('should have responsive padding', () => {
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
const responsivePadding = container.querySelector('.px-4.sm\\:px-6.lg\\:px-8');
expect(responsivePadding).toBeInTheDocument();
});
it('should use gap for spacing between cards', () => {
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
const gridWithGap = container.querySelector('.gap-8');
expect(gridWithGap).toBeInTheDocument();
});
});
describe('Dark Mode Support', () => {
it('should include dark mode classes for cards', () => {
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
const darkModeCards = container.querySelectorAll('.dark\\:bg-gray-800');
expect(darkModeCards.length).toBeGreaterThan(0);
});
it('should include dark mode classes for text', () => {
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
const darkModeText = container.querySelectorAll('.dark\\:text-white');
expect(darkModeText.length).toBeGreaterThan(0);
});
it('should include dark mode classes for borders', () => {
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
const darkModeBorders = container.querySelectorAll('.dark\\:border-gray-700');
expect(darkModeBorders.length).toBeGreaterThan(0);
});
it('should include dark mode classes for buttons', () => {
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
const darkModeButtons = container.querySelectorAll('.dark\\:bg-gray-700');
expect(darkModeButtons.length).toBeGreaterThan(0);
});
});
});

View File

@@ -47,6 +47,7 @@ interface SidebarItemProps {
exact?: boolean;
disabled?: boolean;
badge?: string | number;
badgeElement?: React.ReactNode;
variant?: 'default' | 'settings';
locked?: boolean;
}
@@ -62,6 +63,7 @@ export const SidebarItem: React.FC<SidebarItemProps> = ({
exact = false,
disabled = false,
badge,
badgeElement,
variant = 'default',
locked = false,
}) => {
@@ -97,8 +99,10 @@ export const SidebarItem: React.FC<SidebarItemProps> = ({
<div className={className} title={label}>
<Icon size={20} className="shrink-0" />
{!isCollapsed && <span className="flex-1">{label}</span>}
{badge && !isCollapsed && (
<span className="px-2 py-0.5 text-xs rounded-full bg-white/10">{badge}</span>
{(badge || badgeElement) && !isCollapsed && (
badgeElement || (
<span className="px-2 py-0.5 text-xs rounded-full bg-white/10">{badge}</span>
)
)}
</div>
);
@@ -113,10 +117,12 @@ export const SidebarItem: React.FC<SidebarItemProps> = ({
{locked && <Lock size={12} className="opacity-60" />}
</span>
)}
{badge && !isCollapsed && (
<span className="px-2 py-0.5 text-xs rounded-full bg-brand-100 text-brand-700 dark:bg-brand-900/50 dark:text-brand-400">
{badge}
</span>
{(badge || badgeElement) && !isCollapsed && (
badgeElement || (
<span className="px-2 py-0.5 text-xs rounded-full bg-brand-100 text-brand-700 dark:bg-brand-900/50 dark:text-brand-400">
{badge}
</span>
)
)}
</Link>
);
@@ -256,6 +262,7 @@ interface SettingsSidebarItemProps {
label: string;
description?: string;
locked?: boolean;
badgeElement?: React.ReactNode;
}
/**
@@ -267,6 +274,7 @@ export const SettingsSidebarItem: React.FC<SettingsSidebarItemProps> = ({
label,
description,
locked = false,
badgeElement,
}) => {
const location = useLocation();
const isActive = location.pathname === to || location.pathname.startsWith(to + '/');
@@ -289,6 +297,7 @@ export const SettingsSidebarItem: React.FC<SettingsSidebarItemProps> = ({
{locked && (
<Lock size={12} className="text-gray-400 dark:text-gray-500" />
)}
{badgeElement}
</div>
{description && (
<p className="text-xs text-gray-500 dark:text-gray-500 truncate">

View File

@@ -0,0 +1,531 @@
/**
* FeaturesPermissionsEditor
*
* A unified component for editing features and permissions.
* Used by both subscription plan editing (PlatformSettings) and
* individual business editing (BusinessEditModal).
*
* Supports two modes:
* - 'plan': For editing subscription plan permissions (uses plan-style keys)
* - 'business': For editing individual business permissions (uses tenant-style keys)
*/
import React from 'react';
import { Key } from 'lucide-react';
/**
* Permission definition with metadata
*/
interface PermissionDefinition {
key: string;
planKey?: string; // Key used in subscription plan permissions JSON
businessKey?: string; // Key used in tenant/business model fields
label: string;
description?: string;
category: PermissionCategory;
dependsOn?: string; // Key of permission this depends on
}
type PermissionCategory =
| 'payments'
| 'communication'
| 'customization'
| 'plugins'
| 'advanced'
| 'enterprise'
| 'scheduling';
/**
* All available permissions with their mappings
*/
export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
// Payments & Revenue
{
key: 'can_accept_payments',
planKey: 'can_accept_payments',
businessKey: 'can_accept_payments',
label: 'Online Payments',
description: 'Accept payments via Stripe Connect',
category: 'payments',
},
{
key: 'can_process_refunds',
planKey: 'can_process_refunds',
businessKey: 'can_process_refunds',
label: 'Process Refunds',
description: 'Issue refunds for payments',
category: 'payments',
},
{
key: 'can_create_packages',
planKey: 'can_create_packages',
businessKey: 'can_create_packages',
label: 'Service Packages',
description: 'Create and sell service packages',
category: 'payments',
},
{
key: 'can_use_pos',
planKey: 'can_use_pos',
businessKey: 'can_use_pos',
label: 'POS System',
description: 'Point of sale for in-person payments',
category: 'payments',
},
// Communication
{
key: 'sms_reminders',
planKey: 'sms_reminders',
businessKey: 'can_use_sms_reminders',
label: 'SMS Reminders',
description: 'Send SMS appointment reminders',
category: 'communication',
},
{
key: 'masked_calling',
planKey: 'can_use_masked_phone_numbers',
businessKey: 'can_use_masked_phone_numbers',
label: 'Masked Calling',
description: 'Use masked phone numbers for privacy',
category: 'communication',
},
{
key: 'email_templates',
planKey: 'can_use_email_templates',
businessKey: 'can_use_email_templates',
label: 'Email Templates',
description: 'Custom email templates for communications',
category: 'communication',
},
// Customization
{
key: 'custom_booking_page',
planKey: 'can_customize_booking_page',
businessKey: 'can_customize_booking_page',
label: 'Custom Booking Page',
description: 'Customize the public booking page',
category: 'customization',
},
{
key: 'custom_domain',
planKey: 'can_use_custom_domain',
businessKey: 'can_use_custom_domain',
label: 'Custom Domains',
description: 'Use your own domain for booking',
category: 'customization',
},
{
key: 'white_label',
planKey: 'can_white_label',
businessKey: 'can_white_label',
label: 'White Labelling',
description: 'Remove SmoothSchedule branding',
category: 'customization',
},
// Plugins & Automation
{
key: 'plugins',
planKey: 'can_use_plugins',
businessKey: 'can_use_plugins',
label: 'Use Plugins',
description: 'Install and use marketplace plugins',
category: 'plugins',
},
{
key: 'tasks',
planKey: 'can_use_tasks',
businessKey: 'can_use_tasks',
label: 'Scheduled Tasks',
description: 'Create automated scheduled tasks',
category: 'plugins',
dependsOn: 'plugins',
},
{
key: 'create_plugins',
planKey: 'can_create_plugins',
businessKey: 'can_create_plugins',
label: 'Create Plugins',
description: 'Build custom plugins',
category: 'plugins',
dependsOn: 'plugins',
},
// Advanced Features
{
key: 'api_access',
planKey: 'can_api_access',
businessKey: 'can_api_access',
label: 'API Access',
description: 'Access REST API for integrations',
category: 'advanced',
},
{
key: 'webhooks',
planKey: 'can_use_webhooks',
businessKey: 'can_use_webhooks',
label: 'Webhooks',
description: 'Receive webhook notifications',
category: 'advanced',
},
{
key: 'calendar_sync',
planKey: 'calendar_sync',
businessKey: 'can_use_calendar_sync',
label: 'Calendar Sync',
description: 'Sync with Google Calendar, etc.',
category: 'advanced',
},
{
key: 'export_data',
planKey: 'can_export_data',
businessKey: 'can_export_data',
label: 'Data Export',
description: 'Export data to CSV/Excel',
category: 'advanced',
},
{
key: 'video_conferencing',
planKey: 'video_conferencing',
businessKey: 'can_add_video_conferencing',
label: 'Video Conferencing',
description: 'Add video links to appointments',
category: 'advanced',
},
{
key: 'advanced_reporting',
planKey: 'advanced_reporting',
businessKey: 'advanced_reporting',
label: 'Advanced Analytics',
description: 'Detailed reporting and analytics',
category: 'advanced',
},
{
key: 'contracts',
planKey: 'contracts_enabled',
businessKey: 'can_use_contracts',
label: 'Contracts',
description: 'Create and manage e-signature contracts',
category: 'advanced',
},
{
key: 'mobile_app',
planKey: 'can_use_mobile_app',
businessKey: 'can_use_mobile_app',
label: 'Mobile App',
description: 'Access via mobile application',
category: 'advanced',
},
// Enterprise & Security
{
key: 'manage_oauth',
planKey: 'can_manage_oauth_credentials',
businessKey: 'can_manage_oauth_credentials',
label: 'Manage OAuth',
description: 'Configure custom OAuth credentials',
category: 'enterprise',
},
{
key: 'require_2fa',
planKey: 'can_require_2fa',
businessKey: 'can_require_2fa',
label: 'Require 2FA',
description: 'Enforce two-factor authentication',
category: 'enterprise',
},
{
key: 'sso_enabled',
planKey: 'sso_enabled',
businessKey: 'sso_enabled',
label: 'SSO / SAML',
description: 'Single sign-on integration',
category: 'enterprise',
},
{
key: 'priority_support',
planKey: 'priority_support',
businessKey: 'priority_support',
label: 'Priority Support',
description: 'Faster response times',
category: 'enterprise',
},
{
key: 'dedicated_support',
planKey: 'dedicated_support',
businessKey: 'dedicated_support',
label: 'Dedicated Support',
description: 'Dedicated account manager',
category: 'enterprise',
},
// Scheduling
{
key: 'repeated_events',
planKey: 'can_book_repeated_events',
businessKey: 'can_book_repeated_events',
label: 'Recurring Events',
description: 'Schedule recurring appointments',
category: 'scheduling',
},
];
/**
* Category metadata for display
*/
const CATEGORY_META: Record<PermissionCategory, { label: string; order: number }> = {
payments: { label: 'Payments & Revenue', order: 1 },
communication: { label: 'Communication', order: 2 },
customization: { label: 'Customization', order: 3 },
plugins: { label: 'Plugins & Automation', order: 4 },
advanced: { label: 'Advanced Features', order: 5 },
scheduling: { label: 'Scheduling', order: 6 },
enterprise: { label: 'Enterprise & Security', order: 7 },
};
export type EditorMode = 'plan' | 'business';
export interface FeaturesPermissionsEditorProps {
/**
* Mode determines which keys are used and which permissions are shown
*/
mode: EditorMode;
/**
* Current permission values
* For 'plan' mode: the permissions object from subscription plan
* For 'business' mode: flat object with tenant field names
*/
values: Record<string, boolean>;
/**
* Callback when a permission changes
*/
onChange: (key: string, value: boolean) => void;
/**
* Optional: Limit which categories to show
*/
categories?: PermissionCategory[];
/**
* Optional: Limit which permissions to show by key
*/
includeOnly?: string[];
/**
* Optional: Hide specific permissions
*/
exclude?: string[];
/**
* Number of columns in the grid (default: 3)
*/
columns?: 2 | 3 | 4;
/**
* Show section header
*/
showHeader?: boolean;
/**
* Custom header title
*/
headerTitle?: string;
/**
* Show descriptions under labels
*/
showDescriptions?: boolean;
}
/**
* Get the appropriate key for a permission based on mode
*/
export function getPermissionKey(def: PermissionDefinition, mode: EditorMode): string {
if (mode === 'plan') {
return def.planKey || def.key;
}
return def.businessKey || def.key;
}
/**
* Convert permissions from one mode to another
*/
export function convertPermissions(
values: Record<string, boolean>,
fromMode: EditorMode,
toMode: EditorMode
): Record<string, boolean> {
const result: Record<string, boolean> = {};
for (const def of PERMISSION_DEFINITIONS) {
const fromKey = getPermissionKey(def, fromMode);
const toKey = getPermissionKey(def, toMode);
if (fromKey in values) {
result[toKey] = values[fromKey];
}
}
return result;
}
/**
* Get permission value from values object
*/
function getPermissionValue(
values: Record<string, boolean>,
def: PermissionDefinition,
mode: EditorMode
): boolean {
const key = getPermissionKey(def, mode);
return values[key] ?? false;
}
/**
* Check if a dependent permission should be disabled
*/
function isDependencyDisabled(
values: Record<string, boolean>,
def: PermissionDefinition,
mode: EditorMode
): boolean {
if (!def.dependsOn) return false;
const parentDef = PERMISSION_DEFINITIONS.find(d => d.key === def.dependsOn);
if (!parentDef) return false;
return !getPermissionValue(values, parentDef, mode);
}
const FeaturesPermissionsEditor: React.FC<FeaturesPermissionsEditorProps> = ({
mode,
values,
onChange,
categories,
includeOnly,
exclude = [],
columns = 3,
showHeader = true,
headerTitle = 'Features & Permissions',
showDescriptions = false,
}) => {
// Filter permissions based on props
const filteredPermissions = PERMISSION_DEFINITIONS.filter(def => {
if (exclude.includes(def.key)) return false;
if (includeOnly && !includeOnly.includes(def.key)) return false;
if (categories && !categories.includes(def.category)) return false;
return true;
});
// Group by category
const groupedPermissions = filteredPermissions.reduce((acc, def) => {
if (!acc[def.category]) {
acc[def.category] = [];
}
acc[def.category].push(def);
return acc;
}, {} as Record<PermissionCategory, PermissionDefinition[]>);
// Sort categories by order
const sortedCategories = Object.keys(groupedPermissions).sort(
(a, b) => CATEGORY_META[a as PermissionCategory].order - CATEGORY_META[b as PermissionCategory].order
) as PermissionCategory[];
const handleChange = (def: PermissionDefinition, checked: boolean) => {
const key = getPermissionKey(def, mode);
onChange(key, checked);
// If disabling a parent permission, also disable dependents
if (!checked) {
const dependents = PERMISSION_DEFINITIONS.filter(d => d.dependsOn === def.key);
for (const dep of dependents) {
const depKey = getPermissionKey(dep, mode);
if (values[depKey]) {
onChange(depKey, false);
}
}
}
};
const gridCols = {
2: 'grid-cols-2',
3: 'grid-cols-3',
4: 'grid-cols-4',
};
return (
<div className="space-y-4">
{showHeader && (
<>
<h3 className="text-sm font-medium text-gray-900 dark:text-white flex items-center gap-2">
<Key size={16} className="text-purple-500" />
{headerTitle}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
Control which features are available.
</p>
</>
)}
{sortedCategories.map(category => (
<div key={category}>
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
{CATEGORY_META[category].label}
</h4>
<div className={`grid ${gridCols[columns]} gap-3`}>
{groupedPermissions[category].map(def => {
const isChecked = getPermissionValue(values, def, mode);
const isDisabled = isDependencyDisabled(values, def, mode);
const key = getPermissionKey(def, mode);
return (
<label
key={def.key}
className={`flex items-start gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg ${
isDisabled
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer'
}`}
>
<input
type="checkbox"
checked={isChecked}
onChange={(e) => handleChange(def, e.target.checked)}
disabled={isDisabled}
className="mt-0.5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 disabled:opacity-50"
/>
<div className="flex-1 min-w-0">
<span className="text-sm text-gray-700 dark:text-gray-300 block">
{def.label}
</span>
{showDescriptions && def.description && (
<span className="text-xs text-gray-500 dark:text-gray-400 block mt-0.5">
{def.description}
</span>
)}
</div>
</label>
);
})}
</div>
{/* Show dependency hint for plugins category */}
{category === 'plugins' && !getPermissionValue(
values,
PERMISSION_DEFINITIONS.find(d => d.key === 'plugins')!,
mode
) && (
<p className="text-xs text-amber-600 dark:text-amber-400 mt-2">
Enable "Use Plugins" to allow Scheduled Tasks and Create Plugins
</p>
)}
</div>
))}
</div>
);
};
export default FeaturesPermissionsEditor;

View File

@@ -0,0 +1,148 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
Clock,
MapPin,
User,
Calendar,
CheckCircle2,
AlertCircle
} from 'lucide-react';
import { Service, Business } from '../../types';
import Card from '../ui/Card';
import Badge from '../ui/Badge';
interface CustomerPreviewProps {
service: Service | null; // Null when creating new
business: Business;
previewData?: Partial<Service>; // Live form data
}
export const CustomerPreview: React.FC<CustomerPreviewProps> = ({
service,
business,
previewData
}) => {
const { t } = useTranslation();
// Merge existing service data with live form preview
const data = {
...service,
...previewData,
price: previewData?.price ?? service?.price ?? 0,
name: previewData?.name || service?.name || 'New Service',
description: previewData?.description || service?.description || 'Service description will appear here...',
durationMinutes: previewData?.durationMinutes ?? service?.durationMinutes ?? 30,
};
const formatPrice = (price: number | string) => {
const numPrice = typeof price === 'string' ? parseFloat(price) : price;
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(numPrice);
};
const formatDuration = (minutes: number) => {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hours > 0) return `${hours}h ${mins > 0 ? `${mins}m` : ''}`;
return `${mins}m`;
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Customer Preview
</h3>
<Badge variant="info" size="sm">Live Preview</Badge>
</div>
{/* Booking Page Card Simulation */}
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 overflow-hidden transform transition-all hover:scale-[1.02]">
{/* Cover Image Placeholder */}
<div
className="h-32 w-full bg-cover bg-center relative"
style={{
background: `linear-gradient(135deg, var(--color-brand-600, ${business.primaryColor}), var(--color-brand-400, ${business.secondaryColor}))`,
opacity: 0.9
}}
>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-white/20 font-bold text-4xl select-none">
{data.name.charAt(0)}
</span>
</div>
</div>
<div className="p-6">
<div className="flex justify-between items-start gap-4 mb-4">
<div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white leading-tight mb-1">
{data.name}
</h2>
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<Clock size={14} />
<span>{formatDuration(data.durationMinutes)}</span>
<span></span>
<span>{data.category?.name || 'General'}</span>
</div>
</div>
<div className="text-right">
<div className="text-lg font-bold text-brand-600 dark:text-brand-400">
{data.variable_pricing ? (
'Variable'
) : (
formatPrice(data.price)
)}
</div>
{data.deposit_amount && data.deposit_amount > 0 && (
<div className="text-xs text-gray-500">
{formatPrice(data.deposit_amount)} deposit
</div>
)}
</div>
</div>
<p className="text-gray-600 dark:text-gray-300 text-sm leading-relaxed mb-6">
{data.description}
</p>
<div className="space-y-3 pt-4 border-t border-gray-100 dark:border-gray-700">
<div className="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-300">
<div className="p-1.5 rounded-full bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400">
<CheckCircle2 size={14} />
</div>
<span>Online booking available</span>
</div>
{(data.resource_ids?.length || 0) > 0 && !data.all_resources && (
<div className="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-300">
<div className="p-1.5 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">
<User size={14} />
</div>
<span>Specific staff only</span>
</div>
)}
</div>
<div className="mt-6">
<button className="w-full py-2.5 px-4 bg-brand-600 hover:bg-brand-700 text-white font-medium rounded-xl transition-colors shadow-sm shadow-brand-200 dark:shadow-none">
Book Now
</button>
</div>
</div>
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 flex gap-3 items-start">
<AlertCircle size={20} className="text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" />
<p className="text-sm text-blue-800 dark:text-blue-300">
This is how your service will appear to customers on your booking page.
</p>
</div>
</div>
);
};
export default CustomerPreview;

View File

@@ -0,0 +1,153 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
Users,
Search,
Check,
X,
AlertCircle
} from 'lucide-react';
import { Resource } from '../../types';
import FormInput from '../ui/FormInput';
import Badge from '../ui/Badge';
interface ResourceSelectorProps {
resources: Resource[];
selectedIds: string[];
allSelected: boolean;
onChange: (ids: string[], all: boolean) => void;
}
export const ResourceSelector: React.FC<ResourceSelectorProps> = ({
resources,
selectedIds,
allSelected,
onChange
}) => {
const { t } = useTranslation();
const [search, setSearch] = React.useState('');
const filteredResources = resources.filter(r =>
r.name.toLowerCase().includes(search.toLowerCase())
);
const handleToggle = (id: string) => {
if (allSelected) {
// If switching from All to Specific, start with just this one selected?
// Or keep all others selected?
// Better UX: "All" is a special mode. If you uncheck one, you enter "Specific" mode with all-minus-one selected.
// But we don't have all IDs readily available without mapping.
// Let's assume typical toggle logic.
// Actually, if "All" is true, we should probably toggle it OFF and select just this ID?
// Or select all EXCEPT this ID?
// Let's simplify: Toggle "All Staff" switch separately.
return;
}
const newIds = selectedIds.includes(id)
? selectedIds.filter(i => i !== id)
: [...selectedIds, id];
onChange(newIds, false);
};
const handleAllToggle = () => {
if (!allSelected) {
onChange([], true);
} else {
onChange([], false); // Clear selection or keep? Let's clear for now.
}
};
return (
<div className="space-y-4">
{/* Header / All Toggle */}
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="p-2 bg-brand-100 dark:bg-brand-900/30 rounded-lg text-brand-600 dark:text-brand-400">
<Users size={20} />
</div>
<div>
<h4 className="font-medium text-gray-900 dark:text-white">All Staff Available</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Automatically include current and future staff
</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={allSelected}
onChange={handleAllToggle}
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-300 dark:peer-focus:ring-brand-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-brand-600"></div>
</label>
</div>
{!allSelected && (
<div className="border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden animate-in slide-in-from-top-2 duration-200">
<div className="p-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={16} />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search staff..."
className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:ring-2 focus:ring-brand-500 outline-none"
/>
</div>
</div>
<div className="max-h-60 overflow-y-auto p-2 bg-white dark:bg-gray-800 space-y-1">
{filteredResources.length === 0 ? (
<div className="p-4 text-center text-gray-500 dark:text-gray-400 text-sm">
No staff found matching "{search}"
</div>
) : (
filteredResources.map(resource => (
<button
key={resource.id}
type="button"
onClick={() => handleToggle(resource.id)}
className={`w-full flex items-center justify-between p-3 rounded-lg text-left transition-colors ${
selectedIds.includes(resource.id)
? 'bg-brand-50 dark:bg-brand-900/20 text-brand-900 dark:text-brand-100'
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50 text-gray-700 dark:text-gray-300'
}`}
>
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium ${
selectedIds.includes(resource.id)
? 'bg-brand-200 dark:bg-brand-800 text-brand-700 dark:text-brand-300'
: 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
}`}>
{resource.name.charAt(0)}
</div>
<span>{resource.name}</span>
</div>
{selectedIds.includes(resource.id) && (
<Check size={18} className="text-brand-600 dark:text-brand-400" />
)}
</button>
))
)}
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-800/50 border-t border-gray-200 dark:border-gray-700 text-xs text-gray-500 dark:text-gray-400 flex justify-between">
<span>{selectedIds.length} staff selected</span>
{selectedIds.length === 0 && (
<span className="text-amber-600 dark:text-amber-400 flex items-center gap-1">
<AlertCircle size={12} /> At least one required
</span>
)}
</div>
</div>
)}
</div>
);
};
export default ResourceSelector;

View File

@@ -0,0 +1,131 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
Clock,
Users,
MoreVertical,
Pencil,
Trash2,
GripVertical,
DollarSign,
AlertCircle
} from 'lucide-react';
import { Service } from '../../types';
import Badge from '../ui/Badge';
import Card from '../ui/Card';
interface ServiceListItemProps {
service: Service;
onEdit: (service: Service) => void;
onDelete: (service: Service) => void;
dragHandleProps?: any;
}
export const ServiceListItem: React.FC<ServiceListItemProps> = ({
service,
onEdit,
onDelete,
dragHandleProps
}) => {
const { t } = useTranslation();
const formatPrice = (price: number | string) => {
const numPrice = typeof price === 'string' ? parseFloat(price) : price;
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(numPrice);
};
const formatDuration = (minutes: number) => {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hours > 0) {
return `${hours}h ${mins > 0 ? `${mins}m` : ''}`;
}
return `${mins}m`;
};
return (
<div className="group relative flex items-center gap-4 bg-white dark:bg-gray-800 p-4 rounded-xl border border-gray-200 dark:border-gray-700 hover:border-brand-300 dark:hover:border-brand-700 hover:shadow-md transition-all duration-200">
{/* Drag Handle */}
<div
{...dragHandleProps}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 cursor-grab active:cursor-grabbing p-1"
>
<GripVertical size={20} />
</div>
{/* Content */}
<div className="flex-1 min-w-0 grid grid-cols-1 sm:grid-cols-4 gap-4 items-center">
{/* Name & Description */}
<div className="sm:col-span-2">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white truncate">
{service.name}
</h3>
{service.category && (
<Badge variant="default" size="sm" className="hidden sm:inline-flex">
{service.category.name}
</Badge>
)}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-1">
{service.description || 'No description provided'}
</p>
</div>
{/* Stats */}
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
<div className="flex items-center gap-1.5 bg-gray-50 dark:bg-gray-700/50 px-2.5 py-1 rounded-md">
<Clock size={16} className="text-brand-500" />
<span className="font-medium">{formatDuration(service.durationMinutes)}</span>
</div>
<div className="flex items-center gap-1.5 bg-gray-50 dark:bg-gray-700/50 px-2.5 py-1 rounded-md">
<DollarSign size={16} className="text-green-500" />
<span className="font-medium">
{service.variable_pricing ? (
<span className="italic">Variable</span>
) : (
formatPrice(service.price)
)}
</span>
</div>
</div>
{/* Meta */}
<div className="hidden sm:flex items-center justify-end gap-3 text-xs text-gray-500">
{service.all_resources ? (
<span className="flex items-center gap-1" title="Available to all staff">
<Users size={14} /> All Staff
</span>
) : (
<span className="flex items-center gap-1" title="Restricted to specific staff">
<Users size={14} /> {service.resource_ids?.length || 0} Staff
</span>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 pl-4 border-l border-gray-100 dark:border-gray-700">
<button
onClick={() => onEdit(service)}
className="p-2 text-gray-400 hover:text-brand-600 hover:bg-brand-50 dark:hover:bg-brand-900/30 rounded-lg transition-colors"
title={t('common.edit', 'Edit')}
>
<Pencil size={18} />
</button>
<button
onClick={() => onDelete(service)}
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-colors"
title={t('common.delete', 'Delete')}
>
<Trash2 size={18} />
</button>
</div>
</div>
);
};
export default ServiceListItem;

View File

@@ -0,0 +1,83 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import ServiceListItem from '../ServiceListItem';
import { Service } from '../../../types';
// Mock dependencies
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string, val: string) => val || key }),
}));
// Mock Lucide icons
vi.mock('lucide-react', () => ({
Clock: () => <span data-testid="icon-clock" />,
Users: () => <span data-testid="icon-users" />,
MoreVertical: () => <span data-testid="icon-more" />,
Pencil: () => <span data-testid="icon-pencil" />,
Trash2: () => <span data-testid="icon-trash" />,
GripVertical: () => <span data-testid="icon-grip" />,
DollarSign: () => <span data-testid="icon-dollar" />,
AlertCircle: () => <span data-testid="icon-alert" />,
}));
const mockService: Service = {
id: '1',
name: 'Test Service',
description: 'Test Description',
durationMinutes: 60,
price: 50,
variable_pricing: false,
all_resources: true,
resource_ids: [],
category: { id: 'cat1', name: 'Category 1' }
} as any; // Cast to avoid strict type checks on missing optional fields
describe('ServiceListItem', () => {
it('renders service details correctly', () => {
render(
<ServiceListItem
service={mockService}
onEdit={vi.fn()}
onDelete={vi.fn()}
/>
);
expect(screen.getByText('Test Service')).toBeInTheDocument();
expect(screen.getByText('Test Description')).toBeInTheDocument();
expect(screen.getByText('Category 1')).toBeInTheDocument();
expect(screen.getByText('1h')).toBeInTheDocument(); // 60 mins
expect(screen.getByText('$50.00')).toBeInTheDocument();
});
it('renders variable pricing correctly', () => {
const variableService = { ...mockService, variable_pricing: true };
render(
<ServiceListItem
service={variableService}
onEdit={vi.fn()}
onDelete={vi.fn()}
/>
);
expect(screen.getByText('Variable')).toBeInTheDocument();
});
it('triggers action callbacks', () => {
const onEdit = vi.fn();
const onDelete = vi.fn();
render(
<ServiceListItem
service={mockService}
onEdit={onEdit}
onDelete={onDelete}
/>
);
fireEvent.click(screen.getByTitle('Edit'));
expect(onEdit).toHaveBeenCalledWith(mockService);
fireEvent.click(screen.getByTitle('Delete'));
expect(onDelete).toHaveBeenCalledWith(mockService);
});
});

View File

@@ -0,0 +1,106 @@
import React from 'react';
import { AlertCircle, CheckCircle, Info, AlertTriangle, X } from 'lucide-react';
type AlertVariant = 'error' | 'success' | 'warning' | 'info';
interface AlertProps {
variant: AlertVariant;
message: string | React.ReactNode;
title?: string;
onDismiss?: () => void;
className?: string;
/** Compact mode for inline alerts */
compact?: boolean;
}
const variantConfig: Record<AlertVariant, {
icon: React.ReactNode;
containerClass: string;
textClass: string;
titleClass: string;
}> = {
error: {
icon: <AlertCircle size={20} />,
containerClass: 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800',
textClass: 'text-red-800 dark:text-red-200',
titleClass: 'text-red-900 dark:text-red-100',
},
success: {
icon: <CheckCircle size={20} />,
containerClass: 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800',
textClass: 'text-green-800 dark:text-green-200',
titleClass: 'text-green-900 dark:text-green-100',
},
warning: {
icon: <AlertTriangle size={20} />,
containerClass: 'bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800',
textClass: 'text-amber-800 dark:text-amber-200',
titleClass: 'text-amber-900 dark:text-amber-100',
},
info: {
icon: <Info size={20} />,
containerClass: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800',
textClass: 'text-blue-800 dark:text-blue-200',
titleClass: 'text-blue-900 dark:text-blue-100',
},
};
export const Alert: React.FC<AlertProps> = ({
variant,
message,
title,
onDismiss,
className = '',
compact = false,
}) => {
const config = variantConfig[variant];
return (
<div
className={`${compact ? 'p-2' : 'p-3'} border rounded-lg ${config.containerClass} ${className}`}
role="alert"
>
<div className="flex items-start gap-3">
<span className={`flex-shrink-0 ${config.textClass}`}>{config.icon}</span>
<div className="flex-1 min-w-0">
{title && (
<p className={`font-medium ${config.titleClass} ${compact ? 'text-sm' : ''}`}>
{title}
</p>
)}
<div className={`${compact ? 'text-xs' : 'text-sm'} ${config.textClass} ${title ? 'mt-1' : ''}`}>
{typeof message === 'string' ? <p>{message}</p> : message}
</div>
</div>
{onDismiss && (
<button
onClick={onDismiss}
className={`flex-shrink-0 p-1 rounded hover:bg-black/5 dark:hover:bg-white/5 transition-colors ${config.textClass}`}
aria-label="Dismiss"
>
<X size={16} />
</button>
)}
</div>
</div>
);
};
/** Convenience components */
export const ErrorMessage: React.FC<Omit<AlertProps, 'variant'>> = (props) => (
<Alert variant="error" {...props} />
);
export const SuccessMessage: React.FC<Omit<AlertProps, 'variant'>> = (props) => (
<Alert variant="success" {...props} />
);
export const WarningMessage: React.FC<Omit<AlertProps, 'variant'>> = (props) => (
<Alert variant="warning" {...props} />
);
export const InfoMessage: React.FC<Omit<AlertProps, 'variant'>> = (props) => (
<Alert variant="info" {...props} />
);
export default Alert;

View File

@@ -0,0 +1,61 @@
import React from 'react';
type BadgeVariant = 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
type BadgeSize = 'sm' | 'md' | 'lg';
interface BadgeProps {
children: React.ReactNode;
variant?: BadgeVariant;
size?: BadgeSize;
/** Rounded pill style */
pill?: boolean;
/** Dot indicator before text */
dot?: boolean;
className?: string;
}
const variantClasses: Record<BadgeVariant, string> = {
default: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200',
primary: 'bg-brand-100 dark:bg-brand-900/30 text-brand-800 dark:text-brand-200',
success: 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200',
warning: 'bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-200',
danger: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200',
info: 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200',
};
const dotColors: Record<BadgeVariant, string> = {
default: 'bg-gray-400',
primary: 'bg-brand-500',
success: 'bg-green-500',
warning: 'bg-amber-500',
danger: 'bg-red-500',
info: 'bg-blue-500',
};
const sizeClasses: Record<BadgeSize, string> = {
sm: 'px-1.5 py-0.5 text-xs',
md: 'px-2 py-1 text-xs',
lg: 'px-2.5 py-1.5 text-sm',
};
export const Badge: React.FC<BadgeProps> = ({
children,
variant = 'default',
size = 'md',
pill = false,
dot = false,
className = '',
}) => {
const roundedClass = pill ? 'rounded-full' : 'rounded';
return (
<span
className={`inline-flex items-center gap-1.5 font-medium ${roundedClass} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
>
{dot && <span className={`w-1.5 h-1.5 rounded-full ${dotColors[variant]}`} />}
{children}
</span>
);
};
export default Badge;

View File

@@ -0,0 +1,108 @@
import React, { forwardRef } from 'react';
import { Loader2 } from 'lucide-react';
type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'success' | 'warning';
type ButtonSize = 'xs' | 'sm' | 'md' | 'lg';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
isLoading?: boolean;
loadingText?: string;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
fullWidth?: boolean;
}
const variantClasses: Record<ButtonVariant, string> = {
primary: 'bg-brand-600 hover:bg-brand-700 text-white border-transparent',
secondary: 'bg-gray-600 hover:bg-gray-700 text-white border-transparent',
outline: 'bg-transparent hover:bg-gray-50 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600',
ghost: 'bg-transparent hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 border-transparent',
danger: 'bg-red-600 hover:bg-red-700 text-white border-transparent',
success: 'bg-green-600 hover:bg-green-700 text-white border-transparent',
warning: 'bg-amber-600 hover:bg-amber-700 text-white border-transparent',
};
const sizeClasses: Record<ButtonSize, string> = {
xs: 'px-2 py-1 text-xs',
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-5 py-2.5 text-base',
};
const iconSizes: Record<ButtonSize, string> = {
xs: 'h-3 w-3',
sm: 'h-4 w-4',
md: 'h-4 w-4',
lg: 'h-5 w-5',
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
variant = 'primary',
size = 'md',
isLoading = false,
loadingText,
leftIcon,
rightIcon,
fullWidth = false,
disabled,
children,
className = '',
...props
},
ref
) => {
const isDisabled = disabled || isLoading;
return (
<button
ref={ref}
disabled={isDisabled}
className={`
inline-flex items-center justify-center gap-2
font-medium rounded-lg border
transition-colors
disabled:opacity-50 disabled:cursor-not-allowed
${variantClasses[variant]}
${sizeClasses[size]}
${fullWidth ? 'w-full' : ''}
${className}
`}
{...props}
>
{isLoading ? (
<>
<Loader2 className={`animate-spin ${iconSizes[size]}`} />
{loadingText || children}
</>
) : (
<>
{leftIcon && <span className={iconSizes[size]}>{leftIcon}</span>}
{children}
{rightIcon && <span className={iconSizes[size]}>{rightIcon}</span>}
</>
)}
</button>
);
}
);
Button.displayName = 'Button';
/** Convenience component for submit buttons */
export const SubmitButton: React.FC<Omit<ButtonProps, 'type'> & { submitText?: string }> = ({
isLoading,
submitText = 'Save',
loadingText = 'Saving...',
children,
...props
}) => (
<Button type="submit" isLoading={isLoading} loadingText={loadingText} {...props}>
{children || submitText}
</Button>
);
export default Button;

View File

@@ -0,0 +1,88 @@
import React from 'react';
interface CardProps {
children: React.ReactNode;
className?: string;
/** Card padding */
padding?: 'none' | 'sm' | 'md' | 'lg';
/** Show border */
bordered?: boolean;
/** Hover effect */
hoverable?: boolean;
/** Click handler */
onClick?: () => void;
}
interface CardHeaderProps {
children: React.ReactNode;
className?: string;
/** Action buttons for the header */
actions?: React.ReactNode;
}
interface CardBodyProps {
children: React.ReactNode;
className?: string;
}
interface CardFooterProps {
children: React.ReactNode;
className?: string;
}
const paddingClasses = {
none: '',
sm: 'p-3',
md: 'p-4',
lg: 'p-6',
};
export const Card: React.FC<CardProps> = ({
children,
className = '',
padding = 'md',
bordered = true,
hoverable = false,
onClick,
}) => {
const baseClasses = 'bg-white dark:bg-gray-800 rounded-lg shadow-sm';
const borderClass = bordered ? 'border border-gray-200 dark:border-gray-700' : '';
const hoverClass = hoverable
? 'hover:shadow-md hover:border-gray-300 dark:hover:border-gray-600 transition-all cursor-pointer'
: '';
const paddingClass = paddingClasses[padding];
return (
<div
className={`${baseClasses} ${borderClass} ${hoverClass} ${paddingClass} ${className}`}
onClick={onClick}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
>
{children}
</div>
);
};
export const CardHeader: React.FC<CardHeaderProps> = ({
children,
className = '',
actions,
}) => (
<div className={`flex items-center justify-between pb-4 border-b border-gray-200 dark:border-gray-700 ${className}`}>
<div className="font-semibold text-gray-900 dark:text-white">{children}</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
);
export const CardBody: React.FC<CardBodyProps> = ({ children, className = '' }) => (
<div className={`py-4 ${className}`}>{children}</div>
);
export const CardFooter: React.FC<CardFooterProps> = ({ children, className = '' }) => (
<div className={`pt-4 border-t border-gray-200 dark:border-gray-700 ${className}`}>
{children}
</div>
);
export default Card;

View File

@@ -0,0 +1,187 @@
import React, { useState, useRef } from 'react';
interface CurrencyInputProps {
value: number; // Value in cents (integer)
onChange: (cents: number) => void;
disabled?: boolean;
required?: boolean;
placeholder?: string;
className?: string;
min?: number;
max?: number;
}
/**
* ATM-style currency input where digits are entered as cents.
* As more digits are entered, they shift from cents to dollars.
* Only accepts integer values (digits 0-9).
*
* Example: typing "1234" displays "$12.34"
* - Type "1" → $0.01
* - Type "2" → $0.12
* - Type "3" → $1.23
* - Type "4" → $12.34
*/
const CurrencyInput: React.FC<CurrencyInputProps> = ({
value,
onChange,
disabled = false,
required = false,
placeholder = '$0.00',
className = '',
min,
max,
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const [isFocused, setIsFocused] = useState(false);
// Ensure value is always an integer
const safeValue = Math.floor(Math.abs(value)) || 0;
// Format cents as dollars string (e.g., 1234 → "$12.34")
const formatCentsAsDollars = (cents: number): string => {
if (cents === 0 && !isFocused) return '';
const dollars = cents / 100;
return `$${dollars.toFixed(2)}`;
};
const displayValue = safeValue > 0 || isFocused ? formatCentsAsDollars(safeValue) : '';
// Process a new digit being added
const addDigit = (digit: number) => {
let newValue = safeValue * 10 + digit;
// Enforce max if specified
if (max !== undefined && newValue > max) {
newValue = max;
}
onChange(newValue);
};
// Remove the last digit
const removeDigit = () => {
const newValue = Math.floor(safeValue / 10);
onChange(newValue);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
// Allow navigation keys without preventing default
if (
e.key === 'Tab' ||
e.key === 'Escape' ||
e.key === 'Enter' ||
e.key === 'ArrowLeft' ||
e.key === 'ArrowRight' ||
e.key === 'Home' ||
e.key === 'End'
) {
return;
}
// Handle backspace/delete
if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault();
removeDigit();
return;
}
// Only allow digits 0-9
if (/^[0-9]$/.test(e.key)) {
e.preventDefault();
addDigit(parseInt(e.key, 10));
return;
}
// Block everything else
e.preventDefault();
};
// Catch input from mobile keyboards, IME, voice input, etc.
const handleBeforeInput = (e: React.FormEvent<HTMLInputElement>) => {
const inputEvent = e.nativeEvent as InputEvent;
const data = inputEvent.data;
// Always prevent default - we handle all input ourselves
e.preventDefault();
if (!data) return;
// Extract only digits from the input
const digits = data.replace(/\D/g, '');
// Add each digit one at a time
for (const char of digits) {
addDigit(parseInt(char, 10));
}
};
const handleFocus = () => {
setIsFocused(true);
};
const handleBlur = () => {
setIsFocused(false);
// Enforce min on blur if specified
if (min !== undefined && safeValue < min && safeValue > 0) {
onChange(min);
}
};
// Handle paste - extract digits only
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
e.preventDefault();
const pastedText = e.clipboardData.getData('text');
const digits = pastedText.replace(/\D/g, '');
if (digits) {
let newValue = parseInt(digits, 10);
if (max !== undefined && newValue > max) {
newValue = max;
}
onChange(newValue);
}
};
// Handle drop - extract digits only
const handleDrop = (e: React.DragEvent<HTMLInputElement>) => {
e.preventDefault();
const droppedText = e.dataTransfer.getData('text');
const digits = droppedText.replace(/\D/g, '');
if (digits) {
let newValue = parseInt(digits, 10);
if (max !== undefined && newValue > max) {
newValue = max;
}
onChange(newValue);
}
};
return (
<input
ref={inputRef}
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={displayValue}
onKeyDown={handleKeyDown}
onBeforeInput={handleBeforeInput}
onFocus={handleFocus}
onBlur={handleBlur}
onPaste={handlePaste}
onDrop={handleDrop}
onChange={() => {}} // Controlled via onKeyDown/onBeforeInput
disabled={disabled}
required={required}
placeholder={placeholder}
className={className}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
/>
);
};
export default CurrencyInput;

View File

@@ -0,0 +1,37 @@
import React from 'react';
import { Inbox } from 'lucide-react';
interface EmptyStateProps {
icon?: React.ReactNode;
title: string;
description?: string;
action?: React.ReactNode;
className?: string;
}
export const EmptyState: React.FC<EmptyStateProps> = ({
icon,
title,
description,
action,
className = '',
}) => {
return (
<div className={`text-center py-12 px-4 ${className}`}>
<div className="flex justify-center mb-4">
{icon || <Inbox className="h-12 w-12 text-gray-400" />}
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
{title}
</h3>
{description && (
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-sm mx-auto mb-4">
{description}
</p>
)}
{action && <div className="mt-4">{action}</div>}
</div>
);
};
export default EmptyState;

View File

@@ -0,0 +1,81 @@
import React from 'react';
import CurrencyInput from './CurrencyInput';
interface FormCurrencyInputProps {
label?: string;
error?: string;
hint?: string;
value: number;
onChange: (cents: number) => void;
disabled?: boolean;
required?: boolean;
placeholder?: string;
min?: number;
max?: number;
/** Container class name */
containerClassName?: string;
/** Input class name */
className?: string;
}
/**
* Form wrapper for CurrencyInput that adds label, error, and hint support.
* Uses the ATM-style currency input where digits are entered as cents.
*/
export const FormCurrencyInput: React.FC<FormCurrencyInputProps> = ({
label,
error,
hint,
value,
onChange,
disabled = false,
required = false,
placeholder = '$0.00',
min,
max,
containerClassName = '',
className = '',
}) => {
const baseInputClasses =
'w-full px-3 py-2 border rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors';
const stateClasses = error
? 'border-red-500 dark:border-red-500 focus:ring-red-500 focus:border-red-500'
: 'border-gray-300 dark:border-gray-600';
const disabledClasses = disabled
? 'bg-gray-100 dark:bg-gray-800 cursor-not-allowed opacity-60'
: '';
return (
<div className={containerClassName}>
{label && (
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<CurrencyInput
value={value}
onChange={onChange}
disabled={disabled}
required={required}
placeholder={placeholder}
min={min}
max={max}
className={`${baseInputClasses} ${stateClasses} ${disabledClasses} ${className}`}
/>
{error && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{error}</p>
)}
{hint && !error && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{hint}</p>
)}
</div>
);
};
export default FormCurrencyInput;

View File

@@ -0,0 +1,104 @@
import React, { forwardRef } from 'react';
interface FormInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> {
label?: string;
error?: string;
hint?: string;
/** Size variant */
inputSize?: 'sm' | 'md' | 'lg';
/** Full width */
fullWidth?: boolean;
/** Icon to display on the left */
leftIcon?: React.ReactNode;
/** Icon to display on the right */
rightIcon?: React.ReactNode;
/** Container class name */
containerClassName?: string;
}
const sizeClasses = {
sm: 'px-2 py-1 text-sm',
md: 'px-3 py-2',
lg: 'px-4 py-3 text-lg',
};
export const FormInput = forwardRef<HTMLInputElement, FormInputProps>(
(
{
label,
error,
hint,
inputSize = 'md',
fullWidth = true,
leftIcon,
rightIcon,
containerClassName = '',
className = '',
id,
...props
},
ref
) => {
const inputId = id || props.name || `input-${Math.random().toString(36).substr(2, 9)}`;
const baseClasses =
'border rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors';
const stateClasses = error
? 'border-red-500 dark:border-red-500 focus:ring-red-500 focus:border-red-500'
: 'border-gray-300 dark:border-gray-600';
const disabledClasses = props.disabled
? 'bg-gray-100 dark:bg-gray-800 cursor-not-allowed opacity-60'
: '';
const widthClass = fullWidth ? 'w-full' : '';
return (
<div className={`${containerClassName}`}>
{label && (
<label
htmlFor={inputId}
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
{label}
{props.required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<div className={`relative ${widthClass}`}>
{leftIcon && (
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
{leftIcon}
</div>
)}
<input
ref={ref}
id={inputId}
className={`${baseClasses} ${stateClasses} ${disabledClasses} ${sizeClasses[inputSize]} ${widthClass} ${leftIcon ? 'pl-10' : ''} ${rightIcon ? 'pr-10' : ''} ${className}`}
{...props}
/>
{rightIcon && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400">
{rightIcon}
</div>
)}
</div>
{error && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{error}</p>
)}
{hint && !error && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{hint}</p>
)}
</div>
);
}
);
FormInput.displayName = 'FormInput';
export default FormInput;

View File

@@ -0,0 +1,115 @@
import React, { forwardRef } from 'react';
export interface SelectOption<T = string> {
value: T;
label: string;
disabled?: boolean;
}
interface FormSelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'size'> {
label?: string;
error?: string;
hint?: string;
options: SelectOption[];
/** Size variant */
selectSize?: 'sm' | 'md' | 'lg';
/** Full width */
fullWidth?: boolean;
/** Placeholder option */
placeholder?: string;
/** Container class name */
containerClassName?: string;
}
const sizeClasses = {
sm: 'px-2 py-1 text-sm',
md: 'px-3 py-2',
lg: 'px-4 py-3 text-lg',
};
export const FormSelect = forwardRef<HTMLSelectElement, FormSelectProps>(
(
{
label,
error,
hint,
options,
selectSize = 'md',
fullWidth = true,
placeholder,
containerClassName = '',
className = '',
id,
...props
},
ref
) => {
const selectId = id || props.name || `select-${Math.random().toString(36).substr(2, 9)}`;
const baseClasses =
'border rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors appearance-none cursor-pointer';
const stateClasses = error
? 'border-red-500 dark:border-red-500 focus:ring-red-500 focus:border-red-500'
: 'border-gray-300 dark:border-gray-600';
const disabledClasses = props.disabled
? 'bg-gray-100 dark:bg-gray-800 cursor-not-allowed opacity-60'
: '';
const widthClass = fullWidth ? 'w-full' : '';
return (
<div className={`${containerClassName}`}>
{label && (
<label
htmlFor={selectId}
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
{label}
{props.required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<div className={`relative ${widthClass}`}>
<select
ref={ref}
id={selectId}
className={`${baseClasses} ${stateClasses} ${disabledClasses} ${sizeClasses[selectSize]} ${widthClass} pr-10 ${className}`}
{...props}
>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{options.map((option) => (
<option key={String(option.value)} value={String(option.value)} disabled={option.disabled}>
{option.label}
</option>
))}
</select>
{/* Custom dropdown arrow */}
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-gray-400">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
{error && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{error}</p>
)}
{hint && !error && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{hint}</p>
)}
</div>
);
}
);
FormSelect.displayName = 'FormSelect';
export default FormSelect;

View File

@@ -0,0 +1,94 @@
import React, { forwardRef } from 'react';
interface FormTextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
error?: string;
hint?: string;
/** Full width */
fullWidth?: boolean;
/** Container class name */
containerClassName?: string;
/** Show character count */
showCharCount?: boolean;
/** Max characters for count display */
maxChars?: number;
}
export const FormTextarea = forwardRef<HTMLTextAreaElement, FormTextareaProps>(
(
{
label,
error,
hint,
fullWidth = true,
containerClassName = '',
className = '',
id,
showCharCount = false,
maxChars,
value,
...props
},
ref
) => {
const textareaId = id || props.name || `textarea-${Math.random().toString(36).substr(2, 9)}`;
const charCount = typeof value === 'string' ? value.length : 0;
const baseClasses =
'px-3 py-2 border rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors resize-y';
const stateClasses = error
? 'border-red-500 dark:border-red-500 focus:ring-red-500 focus:border-red-500'
: 'border-gray-300 dark:border-gray-600';
const disabledClasses = props.disabled
? 'bg-gray-100 dark:bg-gray-800 cursor-not-allowed opacity-60'
: '';
const widthClass = fullWidth ? 'w-full' : '';
return (
<div className={`${containerClassName}`}>
{label && (
<label
htmlFor={textareaId}
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
{label}
{props.required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<textarea
ref={ref}
id={textareaId}
value={value}
className={`${baseClasses} ${stateClasses} ${disabledClasses} ${widthClass} ${className}`}
{...props}
/>
<div className="flex justify-between items-center mt-1">
<div>
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
)}
{hint && !error && (
<p className="text-sm text-gray-500 dark:text-gray-400">{hint}</p>
)}
</div>
{showCharCount && (
<p className={`text-sm ${maxChars && charCount > maxChars ? 'text-red-500' : 'text-gray-500 dark:text-gray-400'}`}>
{charCount}{maxChars ? `/${maxChars}` : ''}
</p>
)}
</div>
</div>
);
}
);
FormTextarea.displayName = 'FormTextarea';
export default FormTextarea;

View File

@@ -0,0 +1,74 @@
import React from 'react';
import { Loader2 } from 'lucide-react';
interface LoadingSpinnerProps {
/** Size of the spinner */
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
/** Color of the spinner */
color?: 'default' | 'white' | 'brand' | 'blue';
/** Optional label to display below spinner */
label?: string;
/** Center spinner in container */
centered?: boolean;
/** Additional class name */
className?: string;
}
const sizeClasses = {
xs: 'h-3 w-3',
sm: 'h-4 w-4',
md: 'h-6 w-6',
lg: 'h-8 w-8',
xl: 'h-12 w-12',
};
const colorClasses = {
default: 'text-gray-500 dark:text-gray-400',
white: 'text-white',
brand: 'text-brand-600 dark:text-brand-400',
blue: 'text-blue-600 dark:text-blue-400',
};
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
size = 'md',
color = 'default',
label,
centered = false,
className = '',
}) => {
const spinner = (
<div className={`flex flex-col items-center gap-2 ${className}`}>
<Loader2 className={`animate-spin ${sizeClasses[size]} ${colorClasses[color]}`} />
{label && (
<span className={`text-sm ${colorClasses[color]}`}>{label}</span>
)}
</div>
);
if (centered) {
return (
<div className="flex items-center justify-center py-12">
{spinner}
</div>
);
}
return spinner;
};
/** Full page loading state */
export const PageLoading: React.FC<{ label?: string }> = ({ label = 'Loading...' }) => (
<div className="flex items-center justify-center min-h-[400px]">
<LoadingSpinner size="lg" color="brand" label={label} />
</div>
);
/** Inline loading indicator */
export const InlineLoading: React.FC<{ label?: string }> = ({ label }) => (
<span className="inline-flex items-center gap-2">
<LoadingSpinner size="sm" />
{label && <span className="text-sm text-gray-500 dark:text-gray-400">{label}</span>}
</span>
);
export default LoadingSpinner;

View File

@@ -0,0 +1,132 @@
import React, { useEffect, useCallback } from 'react';
import { X } from 'lucide-react';
import { createPortal } from 'react-dom';
export type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | 'full';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string | React.ReactNode;
children: React.ReactNode;
footer?: React.ReactNode;
size?: ModalSize;
showCloseButton?: boolean;
closeOnOverlayClick?: boolean;
closeOnEscape?: boolean;
className?: string;
contentClassName?: string;
/** If true, prevents body scroll when modal is open */
preventScroll?: boolean;
}
const sizeClasses: Record<ModalSize, string> = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
'3xl': 'max-w-3xl',
'4xl': 'max-w-4xl',
'5xl': 'max-w-5xl',
'6xl': 'max-w-6xl',
full: 'max-w-full mx-4',
};
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
footer,
size = 'md',
showCloseButton = true,
closeOnOverlayClick = true,
closeOnEscape = true,
className = '',
contentClassName = '',
preventScroll = true,
}) => {
// Handle escape key
const handleEscape = useCallback(
(e: KeyboardEvent) => {
if (closeOnEscape && e.key === 'Escape') {
onClose();
}
},
[closeOnEscape, onClose]
);
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleEscape);
if (preventScroll) {
document.body.style.overflow = 'hidden';
}
}
return () => {
document.removeEventListener('keydown', handleEscape);
if (preventScroll) {
document.body.style.overflow = '';
}
};
}, [isOpen, handleEscape, preventScroll]);
if (!isOpen) return null;
const handleOverlayClick = (e: React.MouseEvent) => {
if (closeOnOverlayClick && e.target === e.currentTarget) {
onClose();
}
};
const modalContent = (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50 backdrop-blur-sm"
onClick={handleOverlayClick}
>
<div
className={`bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full ${sizeClasses[size]} max-h-[90vh] flex flex-col ${className}`}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
{(title || showCloseButton) && (
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
{title && (
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{title}
</h3>
)}
{showCloseButton && (
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors ml-auto"
aria-label="Close modal"
>
<X size={20} className="text-gray-500 dark:text-gray-400" />
</button>
)}
</div>
)}
{/* Content */}
<div className={`flex-1 overflow-y-auto p-6 ${contentClassName}`}>
{children}
</div>
{/* Footer */}
{footer && (
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-700">
{footer}
</div>
)}
</div>
</div>
);
// Use portal to render modal at document body level
return createPortal(modalContent, document.body);
};
export default Modal;

View File

@@ -0,0 +1,87 @@
import React from 'react';
import { Loader2 } from 'lucide-react';
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'success' | 'warning';
interface ModalFooterProps {
onCancel?: () => void;
onSubmit?: () => void;
onBack?: () => void;
submitText?: string;
cancelText?: string;
backText?: string;
isLoading?: boolean;
isDisabled?: boolean;
showBackButton?: boolean;
submitVariant?: ButtonVariant;
/** Custom content to render instead of default buttons */
children?: React.ReactNode;
/** Additional class names */
className?: string;
}
const variantClasses: Record<ButtonVariant, string> = {
primary: 'bg-blue-600 hover:bg-blue-700 text-white',
secondary: 'bg-gray-600 hover:bg-gray-700 text-white',
danger: 'bg-red-600 hover:bg-red-700 text-white',
success: 'bg-green-600 hover:bg-green-700 text-white',
warning: 'bg-amber-600 hover:bg-amber-700 text-white',
};
export const ModalFooter: React.FC<ModalFooterProps> = ({
onCancel,
onSubmit,
onBack,
submitText = 'Save',
cancelText = 'Cancel',
backText = 'Back',
isLoading = false,
isDisabled = false,
showBackButton = false,
submitVariant = 'primary',
children,
className = '',
}) => {
if (children) {
return <div className={`flex items-center gap-3 ${className}`}>{children}</div>;
}
return (
<div className={`flex items-center gap-3 ${className}`}>
{showBackButton && onBack && (
<button
onClick={onBack}
disabled={isLoading}
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors disabled:opacity-50"
>
{backText}
</button>
)}
<div className="flex-1" />
{onCancel && (
<button
onClick={onCancel}
disabled={isLoading}
className="px-4 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
>
{cancelText}
</button>
)}
{onSubmit && (
<button
onClick={onSubmit}
disabled={isLoading || isDisabled}
className={`px-4 py-2 rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 ${variantClasses[submitVariant]}`}
>
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
{submitText}
</button>
)}
</div>
);
};
export default ModalFooter;

View File

@@ -0,0 +1,118 @@
import React from 'react';
import { Check } from 'lucide-react';
interface Step {
id: string | number;
label: string;
description?: string;
}
interface StepIndicatorProps {
steps: Step[];
currentStep: number;
/** Color for completed/active steps */
color?: 'blue' | 'brand' | 'green' | 'purple';
/** Show connector lines between steps */
showConnectors?: boolean;
/** Additional class name */
className?: string;
}
const colorClasses = {
blue: {
active: 'bg-blue-600 text-white',
completed: 'bg-blue-600 text-white',
pending: 'bg-gray-200 dark:bg-gray-700 text-gray-500',
textActive: 'text-blue-600 dark:text-blue-400',
textPending: 'text-gray-400',
connector: 'bg-blue-600',
connectorPending: 'bg-gray-200 dark:bg-gray-700',
},
brand: {
active: 'bg-brand-600 text-white',
completed: 'bg-brand-600 text-white',
pending: 'bg-gray-200 dark:bg-gray-700 text-gray-500',
textActive: 'text-brand-600 dark:text-brand-400',
textPending: 'text-gray-400',
connector: 'bg-brand-600',
connectorPending: 'bg-gray-200 dark:bg-gray-700',
},
green: {
active: 'bg-green-600 text-white',
completed: 'bg-green-600 text-white',
pending: 'bg-gray-200 dark:bg-gray-700 text-gray-500',
textActive: 'text-green-600 dark:text-green-400',
textPending: 'text-gray-400',
connector: 'bg-green-600',
connectorPending: 'bg-gray-200 dark:bg-gray-700',
},
purple: {
active: 'bg-purple-600 text-white',
completed: 'bg-purple-600 text-white',
pending: 'bg-gray-200 dark:bg-gray-700 text-gray-500',
textActive: 'text-purple-600 dark:text-purple-400',
textPending: 'text-gray-400',
connector: 'bg-purple-600',
connectorPending: 'bg-gray-200 dark:bg-gray-700',
},
};
export const StepIndicator: React.FC<StepIndicatorProps> = ({
steps,
currentStep,
color = 'blue',
showConnectors = true,
className = '',
}) => {
const colors = colorClasses[color];
return (
<div className={`flex items-center justify-center ${className}`}>
{steps.map((step, index) => {
const stepNumber = index + 1;
const isCompleted = stepNumber < currentStep;
const isActive = stepNumber === currentStep;
const isPending = stepNumber > currentStep;
return (
<React.Fragment key={step.id}>
<div className="flex items-center gap-2">
{/* Step circle */}
<div
className={`w-8 h-8 rounded-full flex items-center justify-center font-medium text-sm transition-colors ${
isCompleted
? colors.completed
: isActive
? colors.active
: colors.pending
}`}
>
{isCompleted ? <Check size={16} /> : stepNumber}
</div>
{/* Step label */}
<span
className={`font-medium text-sm ${
isActive || isCompleted ? colors.textActive : colors.textPending
}`}
>
{step.label}
</span>
</div>
{/* Connector */}
{showConnectors && index < steps.length - 1 && (
<div
className={`w-16 h-0.5 mx-4 ${
stepNumber < currentStep ? colors.connector : colors.connectorPending
}`}
/>
)}
</React.Fragment>
);
})}
</div>
);
};
export default StepIndicator;

View File

@@ -0,0 +1,150 @@
import React from 'react';
interface Tab {
id: string;
label: string | React.ReactNode;
icon?: React.ReactNode;
disabled?: boolean;
}
interface TabGroupProps {
tabs: Tab[];
activeTab: string;
onChange: (tabId: string) => void;
/** Visual variant */
variant?: 'default' | 'pills' | 'underline';
/** Size variant */
size?: 'sm' | 'md' | 'lg';
/** Full width tabs */
fullWidth?: boolean;
/** Additional class name */
className?: string;
/** Color for active state */
activeColor?: 'blue' | 'purple' | 'green' | 'brand';
}
const sizeClasses = {
sm: 'px-3 py-1.5 text-xs',
md: 'px-4 py-2 text-sm',
lg: 'px-5 py-2.5 text-base',
};
const activeColorClasses = {
blue: {
active: 'bg-blue-600 text-white',
pills: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300',
underline: 'border-blue-600 text-blue-600 dark:text-blue-400',
},
purple: {
active: 'bg-purple-600 text-white',
pills: 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300',
underline: 'border-purple-600 text-purple-600 dark:text-purple-400',
},
green: {
active: 'bg-green-600 text-white',
pills: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300',
underline: 'border-green-600 text-green-600 dark:text-green-400',
},
brand: {
active: 'bg-brand-600 text-white',
pills: 'bg-brand-100 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300',
underline: 'border-brand-600 text-brand-600 dark:text-brand-400',
},
};
export const TabGroup: React.FC<TabGroupProps> = ({
tabs,
activeTab,
onChange,
variant = 'default',
size = 'md',
fullWidth = true,
className = '',
activeColor = 'blue',
}) => {
const colorClasses = activeColorClasses[activeColor];
if (variant === 'underline') {
return (
<div className={`flex border-b border-gray-200 dark:border-gray-700 ${className}`}>
{tabs.map((tab) => {
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => !tab.disabled && onChange(tab.id)}
disabled={tab.disabled}
className={`${sizeClasses[size]} font-medium border-b-2 -mb-px transition-colors ${fullWidth ? 'flex-1' : ''} ${
isActive
? colorClasses.underline
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300'
} ${tab.disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<span className="flex items-center justify-center gap-2">
{tab.icon}
{tab.label}
</span>
</button>
);
})}
</div>
);
}
if (variant === 'pills') {
return (
<div className={`flex gap-2 ${className}`}>
{tabs.map((tab) => {
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => !tab.disabled && onChange(tab.id)}
disabled={tab.disabled}
className={`${sizeClasses[size]} font-medium rounded-full transition-colors ${fullWidth ? 'flex-1' : ''} ${
isActive
? colorClasses.pills
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
} ${tab.disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<span className="flex items-center justify-center gap-2">
{tab.icon}
{tab.label}
</span>
</button>
);
})}
</div>
);
}
// Default variant - segmented control style
return (
<div
className={`flex border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden ${className}`}
>
{tabs.map((tab) => {
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => !tab.disabled && onChange(tab.id)}
disabled={tab.disabled}
className={`${sizeClasses[size]} font-medium transition-colors ${fullWidth ? 'flex-1' : ''} ${
isActive
? colorClasses.active
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
} ${tab.disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<span className="flex items-center justify-center gap-2">
{tab.icon}
{tab.label}
</span>
</button>
);
})}
</div>
);
};
export default TabGroup;

View File

@@ -0,0 +1,12 @@
import React from 'react';
import Badge from './Badge';
export const UnfinishedBadge: React.FC = () => {
return (
<Badge variant="warning" size="sm" pill>
WIP
</Badge>
);
};
export default UnfinishedBadge;

View File

@@ -0,0 +1,28 @@
// Modal components
export { Modal, type ModalSize } from './Modal';
export { ModalFooter } from './ModalFooter';
// Form components
export { FormInput } from './FormInput';
export { FormSelect, type SelectOption } from './FormSelect';
export { FormTextarea } from './FormTextarea';
export { FormCurrencyInput } from './FormCurrencyInput';
export { default as CurrencyInput } from './CurrencyInput';
// Button components
export { Button, SubmitButton } from './Button';
// Alert/Message components
export { Alert, ErrorMessage, SuccessMessage, WarningMessage, InfoMessage } from './Alert';
// Navigation components
export { TabGroup } from './TabGroup';
export { StepIndicator } from './StepIndicator';
// Loading components
export { LoadingSpinner, PageLoading, InlineLoading } from './LoadingSpinner';
// Layout components
export { Card, CardHeader, CardBody, CardFooter } from './Card';
export { EmptyState } from './EmptyState';
export { Badge } from './Badge';

View File

@@ -0,0 +1,211 @@
/**
* Schedule presets for scheduled tasks and event automations.
* Shared between CreateTaskModal and EditTaskModal.
*/
export interface SchedulePreset {
id: string;
label: string;
description: string;
type: 'INTERVAL' | 'CRON';
interval_minutes?: number;
cron_expression?: string;
}
export 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 for event automations */
export interface TriggerOption {
value: string;
label: string;
}
export 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' },
];
/** Offset presets for event automations */
export interface OffsetPreset {
value: number;
label: string;
}
export 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' },
];
/**
* Get a schedule preset by ID
*/
export const getSchedulePreset = (id: string): SchedulePreset | undefined => {
return SCHEDULE_PRESETS.find((preset) => preset.id === id);
};
/**
* Get schedule description for display
*/
export const getScheduleDescription = (
scheduleMode: 'preset' | 'onetime' | 'advanced',
selectedPreset: string,
runAtDate?: string,
runAtTime?: string,
customCron?: string
): string => {
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 || '0 0 * * *'}`;
}
const preset = getSchedulePreset(selectedPreset);
return preset?.description || 'Select a schedule';
};
/**
* Get event timing description for display
*/
export const getEventTimingDescription = (
selectedTrigger: string,
selectedOffset: number
): string => {
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 (['before_start', 'at_start', 'after_start'].includes(selectedTrigger)) {
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 (['at_start', 'after_start'].includes(selectedTrigger)) return `${offsetLabel} after event starts`;
if (selectedTrigger === 'after_end') return `${offsetLabel} after event ends`;
return trigger.label;
};

View File

@@ -0,0 +1,581 @@
/**
* Unit tests for SandboxContext
*
* Tests the sandbox context provider and hook including:
* - Default values when used outside provider
* - Providing sandbox status from hooks
* - Toggle functionality
* - Loading and pending states
* - localStorage synchronization
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// Mock the sandbox hooks
vi.mock('../../hooks/useSandbox', () => ({
useSandboxStatus: vi.fn(),
useToggleSandbox: vi.fn(),
}));
import { SandboxProvider, useSandbox } from '../SandboxContext';
import { useSandboxStatus, useToggleSandbox } from '../../hooks/useSandbox';
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => {
store[key] = value.toString();
},
removeItem: (key: string) => {
delete store[key];
},
clear: () => {
store = {};
},
};
})();
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
});
// Test wrapper with QueryClient
const createWrapper = (queryClient: QueryClient) => {
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
<SandboxProvider>{children}</SandboxProvider>
</QueryClientProvider>
);
};
describe('SandboxContext', () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
});
vi.clearAllMocks();
localStorageMock.clear();
});
afterEach(() => {
queryClient.clear();
localStorageMock.clear();
});
describe('useSandbox hook', () => {
it('should return default values when used outside provider', () => {
const { result } = renderHook(() => useSandbox());
expect(result.current).toEqual({
isSandbox: false,
sandboxEnabled: false,
isLoading: false,
toggleSandbox: expect.any(Function),
isToggling: false,
});
});
it('should allow calling toggleSandbox without error when outside provider', async () => {
const { result } = renderHook(() => useSandbox());
// Should not throw an error
await expect(result.current.toggleSandbox(true)).resolves.toBeUndefined();
});
});
describe('SandboxProvider', () => {
describe('sandbox status', () => {
it('should provide sandbox status from hook when sandbox is disabled', async () => {
const mockStatusData = {
sandbox_mode: false,
sandbox_enabled: false,
};
vi.mocked(useSandboxStatus).mockReturnValue({
data: mockStatusData,
isLoading: false,
isSuccess: true,
isError: false,
error: null,
} as any);
vi.mocked(useToggleSandbox).mockReturnValue({
mutateAsync: vi.fn(),
isPending: false,
} as any);
const { result } = renderHook(() => useSandbox(), {
wrapper: createWrapper(queryClient),
});
expect(result.current.isSandbox).toBe(false);
expect(result.current.sandboxEnabled).toBe(false);
expect(result.current.isLoading).toBe(false);
});
it('should provide sandbox status when sandbox is enabled and active', async () => {
const mockStatusData = {
sandbox_mode: true,
sandbox_enabled: true,
};
vi.mocked(useSandboxStatus).mockReturnValue({
data: mockStatusData,
isLoading: false,
isSuccess: true,
isError: false,
error: null,
} as any);
vi.mocked(useToggleSandbox).mockReturnValue({
mutateAsync: vi.fn(),
isPending: false,
} as any);
const { result } = renderHook(() => useSandbox(), {
wrapper: createWrapper(queryClient),
});
expect(result.current.isSandbox).toBe(true);
expect(result.current.sandboxEnabled).toBe(true);
expect(result.current.isLoading).toBe(false);
});
it('should provide sandbox status when sandbox is enabled but not active', async () => {
const mockStatusData = {
sandbox_mode: false,
sandbox_enabled: true,
};
vi.mocked(useSandboxStatus).mockReturnValue({
data: mockStatusData,
isLoading: false,
isSuccess: true,
isError: false,
error: null,
} as any);
vi.mocked(useToggleSandbox).mockReturnValue({
mutateAsync: vi.fn(),
isPending: false,
} as any);
const { result } = renderHook(() => useSandbox(), {
wrapper: createWrapper(queryClient),
});
expect(result.current.isSandbox).toBe(false);
expect(result.current.sandboxEnabled).toBe(true);
expect(result.current.isLoading).toBe(false);
});
it('should handle loading state', () => {
vi.mocked(useSandboxStatus).mockReturnValue({
data: undefined,
isLoading: true,
isSuccess: false,
isError: false,
error: null,
} as any);
vi.mocked(useToggleSandbox).mockReturnValue({
mutateAsync: vi.fn(),
isPending: false,
} as any);
const { result } = renderHook(() => useSandbox(), {
wrapper: createWrapper(queryClient),
});
expect(result.current.isLoading).toBe(true);
expect(result.current.isSandbox).toBe(false);
expect(result.current.sandboxEnabled).toBe(false);
});
it('should default to false when data is undefined', () => {
vi.mocked(useSandboxStatus).mockReturnValue({
data: undefined,
isLoading: false,
isSuccess: false,
isError: false,
error: null,
} as any);
vi.mocked(useToggleSandbox).mockReturnValue({
mutateAsync: vi.fn(),
isPending: false,
} as any);
const { result } = renderHook(() => useSandbox(), {
wrapper: createWrapper(queryClient),
});
expect(result.current.isSandbox).toBe(false);
expect(result.current.sandboxEnabled).toBe(false);
});
});
describe('toggleSandbox function', () => {
it('should provide toggleSandbox function that calls mutation', async () => {
const mockMutateAsync = vi.fn().mockResolvedValue({ sandbox_mode: true });
vi.mocked(useSandboxStatus).mockReturnValue({
data: { sandbox_mode: false, sandbox_enabled: true },
isLoading: false,
isSuccess: true,
isError: false,
error: null,
} as any);
vi.mocked(useToggleSandbox).mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: false,
} as any);
const { result } = renderHook(() => useSandbox(), {
wrapper: createWrapper(queryClient),
});
await result.current.toggleSandbox(true);
expect(mockMutateAsync).toHaveBeenCalledWith(true);
expect(mockMutateAsync).toHaveBeenCalledTimes(1);
});
it('should call mutation with false to disable sandbox', async () => {
const mockMutateAsync = vi.fn().mockResolvedValue({ sandbox_mode: false });
vi.mocked(useSandboxStatus).mockReturnValue({
data: { sandbox_mode: true, sandbox_enabled: true },
isLoading: false,
isSuccess: true,
isError: false,
error: null,
} as any);
vi.mocked(useToggleSandbox).mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: false,
} as any);
const { result } = renderHook(() => useSandbox(), {
wrapper: createWrapper(queryClient),
});
await result.current.toggleSandbox(false);
expect(mockMutateAsync).toHaveBeenCalledWith(false);
});
it('should propagate errors from mutation', async () => {
const mockError = new Error('Failed to toggle sandbox');
const mockMutateAsync = vi.fn().mockRejectedValue(mockError);
vi.mocked(useSandboxStatus).mockReturnValue({
data: { sandbox_mode: false, sandbox_enabled: true },
isLoading: false,
isSuccess: true,
isError: false,
error: null,
} as any);
vi.mocked(useToggleSandbox).mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: false,
} as any);
const { result } = renderHook(() => useSandbox(), {
wrapper: createWrapper(queryClient),
});
await expect(result.current.toggleSandbox(true)).rejects.toThrow('Failed to toggle sandbox');
});
});
describe('isToggling state', () => {
it('should reflect mutation pending state as false', () => {
vi.mocked(useSandboxStatus).mockReturnValue({
data: { sandbox_mode: false, sandbox_enabled: true },
isLoading: false,
isSuccess: true,
isError: false,
error: null,
} as any);
vi.mocked(useToggleSandbox).mockReturnValue({
mutateAsync: vi.fn(),
isPending: false,
} as any);
const { result } = renderHook(() => useSandbox(), {
wrapper: createWrapper(queryClient),
});
expect(result.current.isToggling).toBe(false);
});
it('should reflect mutation pending state as true', () => {
vi.mocked(useSandboxStatus).mockReturnValue({
data: { sandbox_mode: false, sandbox_enabled: true },
isLoading: false,
isSuccess: true,
isError: false,
error: null,
} as any);
vi.mocked(useToggleSandbox).mockReturnValue({
mutateAsync: vi.fn(),
isPending: true,
} as any);
const { result } = renderHook(() => useSandbox(), {
wrapper: createWrapper(queryClient),
});
expect(result.current.isToggling).toBe(true);
});
});
describe('localStorage synchronization', () => {
beforeEach(() => {
localStorageMock.clear();
});
it('should update localStorage when sandbox_mode is true', async () => {
vi.mocked(useSandboxStatus).mockReturnValue({
data: { sandbox_mode: true, sandbox_enabled: true },
isLoading: false,
isSuccess: true,
isError: false,
error: null,
} as any);
vi.mocked(useToggleSandbox).mockReturnValue({
mutateAsync: vi.fn(),
isPending: false,
} as any);
renderHook(() => useSandbox(), {
wrapper: createWrapper(queryClient),
});
await waitFor(() => {
expect(localStorageMock.getItem('sandbox_mode')).toBe('true');
});
});
it('should update localStorage when sandbox_mode is false', async () => {
vi.mocked(useSandboxStatus).mockReturnValue({
data: { sandbox_mode: false, sandbox_enabled: true },
isLoading: false,
isSuccess: true,
isError: false,
error: null,
} as any);
vi.mocked(useToggleSandbox).mockReturnValue({
mutateAsync: vi.fn(),
isPending: false,
} as any);
renderHook(() => useSandbox(), {
wrapper: createWrapper(queryClient),
});
await waitFor(() => {
expect(localStorageMock.getItem('sandbox_mode')).toBe('false');
});
});
it('should update localStorage when status changes from false to true', async () => {
// First render with sandbox_mode = false
vi.mocked(useSandboxStatus).mockReturnValue({
data: { sandbox_mode: false, sandbox_enabled: true },
isLoading: false,
isSuccess: true,
isError: false,
error: null,
} as any);
vi.mocked(useToggleSandbox).mockReturnValue({
mutateAsync: vi.fn(),
isPending: false,
} as any);
const { unmount } = renderHook(() => useSandbox(), {
wrapper: createWrapper(queryClient),
});
await waitFor(() => {
expect(localStorageMock.getItem('sandbox_mode')).toBe('false');
});
unmount();
// Re-render with sandbox_mode = true
vi.mocked(useSandboxStatus).mockReturnValue({
data: { sandbox_mode: true, sandbox_enabled: true },
isLoading: false,
isSuccess: true,
isError: false,
error: null,
} as any);
renderHook(() => useSandbox(), {
wrapper: createWrapper(queryClient),
});
await waitFor(() => {
expect(localStorageMock.getItem('sandbox_mode')).toBe('true');
});
});
it('should not update localStorage when sandbox_mode is undefined', async () => {
vi.mocked(useSandboxStatus).mockReturnValue({
data: undefined,
isLoading: true,
isSuccess: false,
isError: false,
error: null,
} as any);
vi.mocked(useToggleSandbox).mockReturnValue({
mutateAsync: vi.fn(),
isPending: false,
} as any);
renderHook(() => useSandbox(), {
wrapper: createWrapper(queryClient),
});
// Wait a bit to ensure effect had time to run
await new Promise(resolve => setTimeout(resolve, 100));
expect(localStorageMock.getItem('sandbox_mode')).toBeNull();
});
it('should not update localStorage when status data is partial', async () => {
vi.mocked(useSandboxStatus).mockReturnValue({
data: { sandbox_enabled: true } as any,
isLoading: false,
isSuccess: true,
isError: false,
error: null,
} as any);
vi.mocked(useToggleSandbox).mockReturnValue({
mutateAsync: vi.fn(),
isPending: false,
} as any);
renderHook(() => useSandbox(), {
wrapper: createWrapper(queryClient),
});
// Wait a bit to ensure effect had time to run
await new Promise(resolve => setTimeout(resolve, 100));
expect(localStorageMock.getItem('sandbox_mode')).toBeNull();
});
});
describe('integration scenarios', () => {
it('should handle complete toggle workflow', async () => {
const mockMutateAsync = vi.fn().mockResolvedValue({ sandbox_mode: true });
vi.mocked(useSandboxStatus).mockReturnValue({
data: { sandbox_mode: false, sandbox_enabled: true },
isLoading: false,
isSuccess: true,
isError: false,
error: null,
} as any);
vi.mocked(useToggleSandbox).mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: false,
} as any);
const { result } = renderHook(() => useSandbox(), {
wrapper: createWrapper(queryClient),
});
// Initial state
expect(result.current.isSandbox).toBe(false);
expect(result.current.isToggling).toBe(false);
expect(localStorageMock.getItem('sandbox_mode')).toBe('false');
// Toggle sandbox
await result.current.toggleSandbox(true);
expect(mockMutateAsync).toHaveBeenCalledWith(true);
});
it('should handle disabled sandbox feature', () => {
vi.mocked(useSandboxStatus).mockReturnValue({
data: { sandbox_mode: false, sandbox_enabled: false },
isLoading: false,
isSuccess: true,
isError: false,
error: null,
} as any);
vi.mocked(useToggleSandbox).mockReturnValue({
mutateAsync: vi.fn(),
isPending: false,
} as any);
const { result } = renderHook(() => useSandbox(), {
wrapper: createWrapper(queryClient),
});
expect(result.current.isSandbox).toBe(false);
expect(result.current.sandboxEnabled).toBe(false);
});
it('should handle multiple rapid toggle calls', async () => {
const mockMutateAsync = vi.fn()
.mockResolvedValueOnce({ sandbox_mode: true })
.mockResolvedValueOnce({ sandbox_mode: false })
.mockResolvedValueOnce({ sandbox_mode: true });
vi.mocked(useSandboxStatus).mockReturnValue({
data: { sandbox_mode: false, sandbox_enabled: true },
isLoading: false,
isSuccess: true,
isError: false,
error: null,
} as any);
vi.mocked(useToggleSandbox).mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: false,
} as any);
const { result } = renderHook(() => useSandbox(), {
wrapper: createWrapper(queryClient),
});
// Multiple rapid calls
await Promise.all([
result.current.toggleSandbox(true),
result.current.toggleSandbox(false),
result.current.toggleSandbox(true),
]);
expect(mockMutateAsync).toHaveBeenCalledTimes(3);
});
});
});
});

View File

@@ -0,0 +1,769 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
// Mock apiClient
vi.mock('../../api/client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
patch: vi.fn(),
},
}));
import {
useApiTokens,
useCreateApiToken,
useRevokeApiToken,
useUpdateApiToken,
useTestTokensForDocs,
API_SCOPES,
SCOPE_PRESETS,
} from '../useApiTokens';
import type {
APIToken,
APITokenCreateResponse,
CreateTokenData,
TestTokenForDocs,
APIScope,
} from '../useApiTokens';
import apiClient from '../../api/client';
// Create a wrapper with QueryClientProvider
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return React.createElement(
QueryClientProvider,
{ client: queryClient },
children
);
};
};
// Mock data
const mockApiToken: APIToken = {
id: 'token-123',
name: 'Test Token',
key_prefix: 'ss_test',
scopes: ['services:read', 'bookings:write'],
is_active: true,
is_sandbox: false,
created_at: '2024-01-01T00:00:00Z',
last_used_at: '2024-01-15T12:30:00Z',
expires_at: null,
created_by: {
id: 1,
username: 'testuser',
full_name: 'Test User',
},
};
const mockApiTokenCreateResponse: APITokenCreateResponse = {
...mockApiToken,
key: 'ss_test_1234567890abcdef',
};
const mockTestToken: TestTokenForDocs = {
id: 'test-token-123',
name: 'Test Token for Docs',
key_prefix: 'ss_test',
created_at: '2024-01-01T00:00:00Z',
};
describe('useApiTokens hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useApiTokens', () => {
it('fetches API tokens successfully', async () => {
const mockTokens = [mockApiToken];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTokens });
const { result } = renderHook(() => useApiTokens(), {
wrapper: createWrapper(),
});
expect(result.current.isLoading).toBe(true);
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual(mockTokens);
expect(apiClient.get).toHaveBeenCalledWith('/v1/tokens/');
});
it('handles empty token list', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const { result } = renderHook(() => useApiTokens(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual([]);
});
it('handles fetch error', async () => {
const mockError = new Error('Failed to fetch tokens');
vi.mocked(apiClient.get).mockRejectedValue(mockError);
const { result } = renderHook(() => useApiTokens(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(mockError);
});
it('returns multiple tokens correctly', async () => {
const mockTokens = [
mockApiToken,
{
...mockApiToken,
id: 'token-456',
name: 'Production Token',
is_sandbox: false,
},
{
...mockApiToken,
id: 'token-789',
name: 'Sandbox Token',
is_sandbox: true,
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTokens });
const { result } = renderHook(() => useApiTokens(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toHaveLength(3);
expect(result.current.data).toEqual(mockTokens);
});
});
describe('useCreateApiToken', () => {
it('creates API token successfully', async () => {
vi.mocked(apiClient.post).mockResolvedValue({ data: mockApiTokenCreateResponse });
const { result } = renderHook(() => useCreateApiToken(), {
wrapper: createWrapper(),
});
const createData: CreateTokenData = {
name: 'Test Token',
scopes: ['services:read', 'bookings:write'],
};
let response: APITokenCreateResponse | undefined;
await act(async () => {
response = await result.current.mutateAsync(createData);
});
expect(apiClient.post).toHaveBeenCalledWith('/v1/tokens/', createData);
expect(response).toEqual(mockApiTokenCreateResponse);
expect(response?.key).toBe('ss_test_1234567890abcdef');
});
it('creates token with expiration date', async () => {
const expiresAt = '2024-12-31T23:59:59Z';
const tokenWithExpiry = {
...mockApiTokenCreateResponse,
expires_at: expiresAt,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: tokenWithExpiry });
const { result } = renderHook(() => useCreateApiToken(), {
wrapper: createWrapper(),
});
const createData: CreateTokenData = {
name: 'Expiring Token',
scopes: ['services:read'],
expires_at: expiresAt,
};
let response: APITokenCreateResponse | undefined;
await act(async () => {
response = await result.current.mutateAsync(createData);
});
expect(apiClient.post).toHaveBeenCalledWith('/v1/tokens/', createData);
expect(response?.expires_at).toBe(expiresAt);
});
it('creates sandbox token', async () => {
const sandboxToken = {
...mockApiTokenCreateResponse,
is_sandbox: true,
key_prefix: 'ss_test',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: sandboxToken });
const { result } = renderHook(() => useCreateApiToken(), {
wrapper: createWrapper(),
});
const createData: CreateTokenData = {
name: 'Sandbox Token',
scopes: ['services:read'],
is_sandbox: true,
};
let response: APITokenCreateResponse | undefined;
await act(async () => {
response = await result.current.mutateAsync(createData);
});
expect(apiClient.post).toHaveBeenCalledWith('/v1/tokens/', createData);
expect(response?.is_sandbox).toBe(true);
});
it('invalidates token list after successful creation', async () => {
vi.mocked(apiClient.post).mockResolvedValue({ data: mockApiTokenCreateResponse });
vi.mocked(apiClient.get).mockResolvedValue({ data: [mockApiToken] });
const wrapper = createWrapper();
const { result: tokenListResult } = renderHook(() => useApiTokens(), { wrapper });
const { result: createResult } = renderHook(() => useCreateApiToken(), { wrapper });
// Wait for initial fetch
await waitFor(() => {
expect(tokenListResult.current.isSuccess).toBe(true);
});
const initialCallCount = vi.mocked(apiClient.get).mock.calls.length;
// Create new token
await act(async () => {
await createResult.current.mutateAsync({
name: 'New Token',
scopes: ['services:read'],
});
});
// Wait for refetch
await waitFor(() => {
expect(vi.mocked(apiClient.get).mock.calls.length).toBeGreaterThan(initialCallCount);
});
});
it('handles creation error', async () => {
const mockError = new Error('Failed to create token');
vi.mocked(apiClient.post).mockRejectedValue(mockError);
const { result } = renderHook(() => useCreateApiToken(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync({
name: 'Test Token',
scopes: ['services:read'],
});
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(mockError);
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
});
it('creates token with all available scopes', async () => {
const allScopesToken = {
...mockApiTokenCreateResponse,
scopes: API_SCOPES.map(s => s.value),
};
vi.mocked(apiClient.post).mockResolvedValue({ data: allScopesToken });
const { result } = renderHook(() => useCreateApiToken(), {
wrapper: createWrapper(),
});
const createData: CreateTokenData = {
name: 'Full Access Token',
scopes: API_SCOPES.map(s => s.value),
};
let response: APITokenCreateResponse | undefined;
await act(async () => {
response = await result.current.mutateAsync(createData);
});
expect(response?.scopes).toHaveLength(API_SCOPES.length);
});
});
describe('useRevokeApiToken', () => {
it('revokes API token successfully', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
const { result } = renderHook(() => useRevokeApiToken(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync('token-123');
});
expect(apiClient.delete).toHaveBeenCalledWith('/v1/tokens/token-123/');
});
it('invalidates token list after successful revocation', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
vi.mocked(apiClient.get).mockResolvedValue({ data: [mockApiToken] });
const wrapper = createWrapper();
const { result: tokenListResult } = renderHook(() => useApiTokens(), { wrapper });
const { result: revokeResult } = renderHook(() => useRevokeApiToken(), { wrapper });
// Wait for initial fetch
await waitFor(() => {
expect(tokenListResult.current.isSuccess).toBe(true);
});
const initialCallCount = vi.mocked(apiClient.get).mock.calls.length;
// Revoke token
await act(async () => {
await revokeResult.current.mutateAsync('token-123');
});
// Wait for refetch
await waitFor(() => {
expect(vi.mocked(apiClient.get).mock.calls.length).toBeGreaterThan(initialCallCount);
});
});
it('handles revocation error', async () => {
const mockError = new Error('Failed to revoke token');
vi.mocked(apiClient.delete).mockRejectedValue(mockError);
const { result } = renderHook(() => useRevokeApiToken(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync('token-123');
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(mockError);
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
});
});
describe('useUpdateApiToken', () => {
it('updates API token successfully', async () => {
const updatedToken = {
...mockApiToken,
name: 'Updated Token Name',
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: updatedToken });
const { result } = renderHook(() => useUpdateApiToken(), {
wrapper: createWrapper(),
});
let response: APIToken | undefined;
await act(async () => {
response = await result.current.mutateAsync({
tokenId: 'token-123',
data: { name: 'Updated Token Name' },
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/v1/tokens/token-123/', {
name: 'Updated Token Name',
});
expect(response?.name).toBe('Updated Token Name');
});
it('updates token scopes', async () => {
const updatedToken = {
...mockApiToken,
scopes: ['services:read', 'bookings:read', 'customers:read'],
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: updatedToken });
const { result } = renderHook(() => useUpdateApiToken(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
tokenId: 'token-123',
data: { scopes: ['services:read', 'bookings:read', 'customers:read'] },
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/v1/tokens/token-123/', {
scopes: ['services:read', 'bookings:read', 'customers:read'],
});
});
it('deactivates token', async () => {
const deactivatedToken = {
...mockApiToken,
is_active: false,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: deactivatedToken });
const { result } = renderHook(() => useUpdateApiToken(), {
wrapper: createWrapper(),
});
let response: APIToken | undefined;
await act(async () => {
response = await result.current.mutateAsync({
tokenId: 'token-123',
data: { is_active: false },
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/v1/tokens/token-123/', {
is_active: false,
});
expect(response?.is_active).toBe(false);
});
it('updates token expiration', async () => {
const newExpiry = '2025-12-31T23:59:59Z';
const updatedToken = {
...mockApiToken,
expires_at: newExpiry,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: updatedToken });
const { result } = renderHook(() => useUpdateApiToken(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
tokenId: 'token-123',
data: { expires_at: newExpiry },
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/v1/tokens/token-123/', {
expires_at: newExpiry,
});
});
it('invalidates token list after successful update', async () => {
const updatedToken = { ...mockApiToken, name: 'Updated' };
vi.mocked(apiClient.patch).mockResolvedValue({ data: updatedToken });
vi.mocked(apiClient.get).mockResolvedValue({ data: [mockApiToken] });
const wrapper = createWrapper();
const { result: tokenListResult } = renderHook(() => useApiTokens(), { wrapper });
const { result: updateResult } = renderHook(() => useUpdateApiToken(), { wrapper });
// Wait for initial fetch
await waitFor(() => {
expect(tokenListResult.current.isSuccess).toBe(true);
});
const initialCallCount = vi.mocked(apiClient.get).mock.calls.length;
// Update token
await act(async () => {
await updateResult.current.mutateAsync({
tokenId: 'token-123',
data: { name: 'Updated' },
});
});
// Wait for refetch
await waitFor(() => {
expect(vi.mocked(apiClient.get).mock.calls.length).toBeGreaterThan(initialCallCount);
});
});
it('handles update error', async () => {
const mockError = new Error('Failed to update token');
vi.mocked(apiClient.patch).mockRejectedValue(mockError);
const { result } = renderHook(() => useUpdateApiToken(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync({
tokenId: 'token-123',
data: { name: 'Updated' },
});
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(mockError);
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
});
it('updates multiple fields at once', async () => {
const updatedToken = {
...mockApiToken,
name: 'Updated Token',
scopes: ['services:read', 'bookings:read'],
expires_at: '2025-12-31T23:59:59Z',
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: updatedToken });
const { result } = renderHook(() => useUpdateApiToken(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
tokenId: 'token-123',
data: {
name: 'Updated Token',
scopes: ['services:read', 'bookings:read'],
expires_at: '2025-12-31T23:59:59Z',
},
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/v1/tokens/token-123/', {
name: 'Updated Token',
scopes: ['services:read', 'bookings:read'],
expires_at: '2025-12-31T23:59:59Z',
});
});
});
describe('useTestTokensForDocs', () => {
it('fetches test tokens successfully', async () => {
const mockTestTokens = [mockTestToken];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTestTokens });
const { result } = renderHook(() => useTestTokensForDocs(), {
wrapper: createWrapper(),
});
expect(result.current.isLoading).toBe(true);
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual(mockTestTokens);
expect(apiClient.get).toHaveBeenCalledWith('/v1/tokens/test-tokens/');
});
it('handles empty test token list', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const { result } = renderHook(() => useTestTokensForDocs(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual([]);
});
it('handles fetch error', async () => {
const mockError = new Error('Failed to fetch test tokens');
vi.mocked(apiClient.get).mockRejectedValue(mockError);
const { result } = renderHook(() => useTestTokensForDocs(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(mockError);
});
it('returns multiple test tokens', async () => {
const mockTestTokens = [
mockTestToken,
{
...mockTestToken,
id: 'test-token-456',
name: 'Another Test Token',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTestTokens });
const { result } = renderHook(() => useTestTokensForDocs(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toHaveLength(2);
});
it('uses staleTime for caching', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [mockTestToken] });
const wrapper = createWrapper();
const { result: result1 } = renderHook(() => useTestTokensForDocs(), { wrapper });
await waitFor(() => {
expect(result1.current.isSuccess).toBe(true);
});
// Render hook again - should use cached data
const { result: result2 } = renderHook(() => useTestTokensForDocs(), { wrapper });
expect(result2.current.data).toEqual([mockTestToken]);
// Should only call API once due to staleTime cache
expect(vi.mocked(apiClient.get).mock.calls.length).toBe(1);
});
});
describe('API_SCOPES constant', () => {
it('contains expected scopes', () => {
expect(API_SCOPES).toBeDefined();
expect(Array.isArray(API_SCOPES)).toBe(true);
expect(API_SCOPES.length).toBeGreaterThan(0);
});
it('has correct structure for each scope', () => {
API_SCOPES.forEach((scope: APIScope) => {
expect(scope).toHaveProperty('value');
expect(scope).toHaveProperty('label');
expect(scope).toHaveProperty('description');
expect(typeof scope.value).toBe('string');
expect(typeof scope.label).toBe('string');
expect(typeof scope.description).toBe('string');
});
});
it('contains essential scopes', () => {
const scopeValues = API_SCOPES.map(s => s.value);
expect(scopeValues).toContain('services:read');
expect(scopeValues).toContain('bookings:read');
expect(scopeValues).toContain('bookings:write');
expect(scopeValues).toContain('customers:read');
expect(scopeValues).toContain('customers:write');
});
});
describe('SCOPE_PRESETS constant', () => {
it('contains expected presets', () => {
expect(SCOPE_PRESETS).toBeDefined();
expect(SCOPE_PRESETS).toHaveProperty('booking_widget');
expect(SCOPE_PRESETS).toHaveProperty('read_only');
expect(SCOPE_PRESETS).toHaveProperty('full_access');
});
it('booking_widget preset has correct structure', () => {
const preset = SCOPE_PRESETS.booking_widget;
expect(preset).toHaveProperty('label');
expect(preset).toHaveProperty('description');
expect(preset).toHaveProperty('scopes');
expect(Array.isArray(preset.scopes)).toBe(true);
expect(preset.scopes).toContain('services:read');
expect(preset.scopes).toContain('bookings:write');
});
it('read_only preset contains only read scopes', () => {
const preset = SCOPE_PRESETS.read_only;
expect(preset.scopes.every(scope => scope.includes(':read'))).toBe(true);
});
it('full_access preset contains all scopes', () => {
const preset = SCOPE_PRESETS.full_access;
expect(preset.scopes).toHaveLength(API_SCOPES.length);
expect(preset.scopes).toEqual(API_SCOPES.map(s => s.value));
});
});
describe('TypeScript types', () => {
it('APIToken type includes all required fields', () => {
const token: APIToken = mockApiToken;
expect(token.id).toBeDefined();
expect(token.name).toBeDefined();
expect(token.key_prefix).toBeDefined();
expect(token.scopes).toBeDefined();
expect(token.is_active).toBeDefined();
expect(token.is_sandbox).toBeDefined();
expect(token.created_at).toBeDefined();
});
it('APITokenCreateResponse extends APIToken with key', () => {
const createResponse: APITokenCreateResponse = mockApiTokenCreateResponse;
expect(createResponse.key).toBeDefined();
expect(createResponse.id).toBeDefined();
expect(createResponse.name).toBeDefined();
});
it('CreateTokenData has correct structure', () => {
const createData: CreateTokenData = {
name: 'Test',
scopes: ['services:read'],
};
expect(createData.name).toBe('Test');
expect(createData.scopes).toEqual(['services:read']);
});
it('TestTokenForDocs has minimal fields', () => {
const testToken: TestTokenForDocs = mockTestToken;
expect(testToken.id).toBeDefined();
expect(testToken.name).toBeDefined();
expect(testToken.key_prefix).toBeDefined();
expect(testToken.created_at).toBeDefined();
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,637 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
// Mock dependencies
vi.mock('../../api/auth', () => ({
login: vi.fn(),
logout: vi.fn(),
getCurrentUser: vi.fn(),
masquerade: vi.fn(),
stopMasquerade: vi.fn(),
}));
vi.mock('../../utils/cookies', () => ({
getCookie: vi.fn(),
setCookie: vi.fn(),
deleteCookie: vi.fn(),
}));
vi.mock('../../utils/domain', () => ({
getBaseDomain: vi.fn(() => 'lvh.me'),
buildSubdomainUrl: vi.fn((subdomain, path) => `http://${subdomain}.lvh.me:5173${path || '/'}`),
}));
import {
useAuth,
useCurrentUser,
useLogin,
useLogout,
useIsAuthenticated,
useMasquerade,
useStopMasquerade,
} from '../useAuth';
import * as authApi from '../../api/auth';
import * as cookies from '../../utils/cookies';
// Create a wrapper with QueryClientProvider
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return React.createElement(
QueryClientProvider,
{ client: queryClient },
children
);
};
};
describe('useAuth hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
});
describe('useAuth', () => {
it('provides setTokens function', () => {
const { result } = renderHook(() => useAuth(), {
wrapper: createWrapper(),
});
expect(result.current.setTokens).toBeDefined();
expect(typeof result.current.setTokens).toBe('function');
});
it('setTokens calls setCookie for both tokens', () => {
const { result } = renderHook(() => useAuth(), {
wrapper: createWrapper(),
});
result.current.setTokens('access-123', 'refresh-456');
expect(cookies.setCookie).toHaveBeenCalledWith('access_token', 'access-123', 7);
expect(cookies.setCookie).toHaveBeenCalledWith('refresh_token', 'refresh-456', 7);
});
});
describe('useCurrentUser', () => {
it('returns null when no token exists', async () => {
vi.mocked(cookies.getCookie).mockReturnValue(null);
const { result } = renderHook(() => useCurrentUser(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.data).toBeNull();
expect(authApi.getCurrentUser).not.toHaveBeenCalled();
});
it('fetches user when token exists', async () => {
const mockUser = {
id: 1,
email: 'test@example.com',
role: 'owner',
first_name: 'Test',
last_name: 'User',
};
vi.mocked(cookies.getCookie).mockReturnValue('valid-token');
vi.mocked(authApi.getCurrentUser).mockResolvedValue(mockUser as authApi.User);
const { result } = renderHook(() => useCurrentUser(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.data).toEqual(mockUser);
expect(authApi.getCurrentUser).toHaveBeenCalled();
});
it('returns null when getCurrentUser fails', async () => {
vi.mocked(cookies.getCookie).mockReturnValue('invalid-token');
vi.mocked(authApi.getCurrentUser).mockRejectedValue(new Error('Unauthorized'));
const { result } = renderHook(() => useCurrentUser(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.data).toBeNull();
});
});
describe('useLogin', () => {
it('stores tokens in cookies on success', async () => {
const mockResponse = {
access: 'access-token',
refresh: 'refresh-token',
user: {
id: 1,
email: 'test@example.com',
role: 'owner',
first_name: 'Test',
last_name: 'User',
},
};
vi.mocked(authApi.login).mockResolvedValue(mockResponse as authApi.LoginResponse);
const { result } = renderHook(() => useLogin(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
email: 'test@example.com',
password: 'password123',
});
});
expect(cookies.setCookie).toHaveBeenCalledWith('access_token', 'access-token', 7);
expect(cookies.setCookie).toHaveBeenCalledWith('refresh_token', 'refresh-token', 7);
});
it('clears masquerade stack on login', async () => {
window.localStorage.setItem('masquerade_stack', JSON.stringify([{ user_pk: 1 }]));
const mockResponse = {
access: 'access-token',
refresh: 'refresh-token',
user: {
id: 1,
email: 'test@example.com',
role: 'owner',
},
};
vi.mocked(authApi.login).mockResolvedValue(mockResponse as authApi.LoginResponse);
const { result } = renderHook(() => useLogin(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
email: 'test@example.com',
password: 'password123',
});
});
// After login, masquerade_stack should be removed
expect(window.localStorage.getItem('masquerade_stack')).toBeFalsy();
});
});
describe('useLogout', () => {
it('clears tokens and masquerade stack', async () => {
window.localStorage.setItem('masquerade_stack', JSON.stringify([{ user_pk: 1 }]));
vi.mocked(authApi.logout).mockResolvedValue(undefined);
// Mock window.location
const originalLocation = window.location;
Object.defineProperty(window, 'location', {
value: { ...originalLocation, href: '', protocol: 'http:', port: '5173' },
writable: true,
});
const { result } = renderHook(() => useLogout(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync();
});
expect(cookies.deleteCookie).toHaveBeenCalledWith('access_token');
expect(cookies.deleteCookie).toHaveBeenCalledWith('refresh_token');
// After logout, masquerade_stack should be removed
expect(window.localStorage.getItem('masquerade_stack')).toBeFalsy();
// Restore window.location
Object.defineProperty(window, 'location', {
value: originalLocation,
writable: true,
});
});
});
describe('useIsAuthenticated', () => {
it('returns false when no user', async () => {
vi.mocked(cookies.getCookie).mockReturnValue(null);
const { result } = renderHook(() => useIsAuthenticated(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current).toBe(false);
});
});
it('returns true when user exists', async () => {
const mockUser = {
id: 1,
email: 'test@example.com',
role: 'owner',
};
vi.mocked(cookies.getCookie).mockReturnValue('valid-token');
vi.mocked(authApi.getCurrentUser).mockResolvedValue(mockUser as authApi.User);
const { result } = renderHook(() => useIsAuthenticated(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current).toBe(true);
});
});
});
describe('useMasquerade', () => {
let originalLocation: Location;
let originalFetch: typeof fetch;
beforeEach(() => {
originalLocation = window.location;
originalFetch = global.fetch;
// Mock window.location
Object.defineProperty(window, 'location', {
value: {
...originalLocation,
href: 'http://platform.lvh.me:5173/',
hostname: 'platform.lvh.me',
protocol: 'http:',
port: '5173',
reload: vi.fn(),
},
writable: true,
});
// Mock fetch for logout API call
global.fetch = vi.fn().mockResolvedValue({ ok: true });
});
afterEach(() => {
Object.defineProperty(window, 'location', {
value: originalLocation,
writable: true,
});
global.fetch = originalFetch;
});
it('calls masquerade API with user_pk and current stack', async () => {
const mockResponse = {
access: 'new-access-token',
refresh: 'new-refresh-token',
user: {
id: 2,
email: 'staff@example.com',
role: 'staff',
business_subdomain: 'demo',
},
masquerade_stack: [{ user_pk: 1, access: 'original-access', refresh: 'original-refresh' }],
};
vi.mocked(authApi.masquerade).mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useMasquerade(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync(2);
});
expect(authApi.masquerade).toHaveBeenCalledWith(2, []);
});
it('passes existing masquerade stack to API', async () => {
const existingStack = [{ user_pk: 1, access: 'old-access', refresh: 'old-refresh' }];
localStorage.setItem('masquerade_stack', JSON.stringify(existingStack));
const mockResponse = {
access: 'new-access-token',
refresh: 'new-refresh-token',
user: {
id: 3,
email: 'staff@example.com',
role: 'staff',
business_subdomain: 'demo',
},
masquerade_stack: [...existingStack, { user_pk: 2, access: 'mid-access', refresh: 'mid-refresh' }],
};
vi.mocked(authApi.masquerade).mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useMasquerade(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync(3);
});
expect(authApi.masquerade).toHaveBeenCalledWith(3, existingStack);
});
it('stores masquerade stack in localStorage on success', async () => {
const mockStack = [{ user_pk: 1, access: 'original-access', refresh: 'original-refresh' }];
const mockResponse = {
access: 'new-access-token',
refresh: 'new-refresh-token',
user: {
id: 2,
email: 'staff@example.com',
role: 'staff',
business_subdomain: 'demo',
},
masquerade_stack: mockStack,
};
vi.mocked(authApi.masquerade).mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useMasquerade(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync(2);
});
expect(localStorage.getItem('masquerade_stack')).toEqual(JSON.stringify(mockStack));
});
it('redirects to platform subdomain for platform users', async () => {
// Set current hostname to something else to trigger redirect
Object.defineProperty(window, 'location', {
value: {
...window.location,
hostname: 'demo.lvh.me', // Different from platform
href: 'http://demo.lvh.me:5173/',
},
writable: true,
});
const mockResponse = {
access: 'new-access-token',
refresh: 'new-refresh-token',
user: {
id: 1,
email: 'admin@example.com',
role: 'superuser',
},
masquerade_stack: [],
};
vi.mocked(authApi.masquerade).mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useMasquerade(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync(1);
});
// Should have called fetch to clear session
expect(global.fetch).toHaveBeenCalled();
});
it('sets cookies when no redirect is needed', async () => {
// Set current hostname to match the target
Object.defineProperty(window, 'location', {
value: {
hostname: 'demo.lvh.me',
href: 'http://demo.lvh.me:5173/',
protocol: 'http:',
port: '5173',
reload: vi.fn(),
},
writable: true,
});
const mockResponse = {
access: 'new-access-token',
refresh: 'new-refresh-token',
user: {
id: 2,
email: 'staff@example.com',
role: 'staff',
business_subdomain: 'demo',
},
masquerade_stack: [],
};
vi.mocked(authApi.masquerade).mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useMasquerade(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync(2);
});
expect(cookies.setCookie).toHaveBeenCalledWith('access_token', 'new-access-token', 7);
expect(cookies.setCookie).toHaveBeenCalledWith('refresh_token', 'new-refresh-token', 7);
});
});
describe('useStopMasquerade', () => {
let originalLocation: Location;
let originalFetch: typeof fetch;
beforeEach(() => {
originalLocation = window.location;
originalFetch = global.fetch;
Object.defineProperty(window, 'location', {
value: {
...originalLocation,
href: 'http://demo.lvh.me:5173/',
hostname: 'demo.lvh.me',
protocol: 'http:',
port: '5173',
reload: vi.fn(),
},
writable: true,
});
global.fetch = vi.fn().mockResolvedValue({ ok: true });
});
afterEach(() => {
Object.defineProperty(window, 'location', {
value: originalLocation,
writable: true,
});
global.fetch = originalFetch;
});
it('throws error when no masquerade stack exists', async () => {
localStorage.removeItem('masquerade_stack');
const { result } = renderHook(() => useStopMasquerade(), {
wrapper: createWrapper(),
});
let error: Error | undefined;
await act(async () => {
try {
await result.current.mutateAsync();
} catch (e) {
error = e as Error;
}
});
expect(error?.message).toBe('No masquerading session to stop');
});
it('calls stopMasquerade API with current stack', async () => {
const existingStack = [{ user_pk: 1, access: 'original-access', refresh: 'original-refresh' }];
localStorage.setItem('masquerade_stack', JSON.stringify(existingStack));
const mockResponse = {
access: 'restored-access-token',
refresh: 'restored-refresh-token',
user: {
id: 1,
email: 'admin@example.com',
role: 'superuser',
},
masquerade_stack: [],
};
vi.mocked(authApi.stopMasquerade).mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useStopMasquerade(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync();
});
expect(authApi.stopMasquerade).toHaveBeenCalledWith(existingStack);
});
it('clears masquerade stack when returning to original user', async () => {
const existingStack = [{ user_pk: 1, access: 'original-access', refresh: 'original-refresh' }];
localStorage.setItem('masquerade_stack', JSON.stringify(existingStack));
const mockResponse = {
access: 'restored-access-token',
refresh: 'restored-refresh-token',
user: {
id: 1,
email: 'admin@example.com',
role: 'superuser',
},
masquerade_stack: [], // Empty stack means back to original
};
vi.mocked(authApi.stopMasquerade).mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useStopMasquerade(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync();
});
expect(localStorage.getItem('masquerade_stack')).toBeNull();
});
it('keeps stack when still masquerading after stop', async () => {
const deepStack = [
{ user_pk: 1, access: 'level1-access', refresh: 'level1-refresh' },
{ user_pk: 2, access: 'level2-access', refresh: 'level2-refresh' },
];
localStorage.setItem('masquerade_stack', JSON.stringify(deepStack));
const remainingStack = [{ user_pk: 1, access: 'level1-access', refresh: 'level1-refresh' }];
const mockResponse = {
access: 'level2-access-token',
refresh: 'level2-refresh-token',
user: {
id: 2,
email: 'manager@example.com',
role: 'manager',
business_subdomain: 'demo',
},
masquerade_stack: remainingStack,
};
vi.mocked(authApi.stopMasquerade).mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useStopMasquerade(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync();
});
expect(localStorage.getItem('masquerade_stack')).toEqual(JSON.stringify(remainingStack));
});
it('sets cookies when no redirect is needed', async () => {
const existingStack = [{ user_pk: 1, access: 'original-access', refresh: 'original-refresh' }];
localStorage.setItem('masquerade_stack', JSON.stringify(existingStack));
// Set hostname to match target subdomain
Object.defineProperty(window, 'location', {
value: {
hostname: 'demo.lvh.me',
href: 'http://demo.lvh.me:5173/',
protocol: 'http:',
port: '5173',
reload: vi.fn(),
},
writable: true,
});
const mockResponse = {
access: 'restored-access-token',
refresh: 'restored-refresh-token',
user: {
id: 2,
email: 'owner@example.com',
role: 'owner',
business_subdomain: 'demo',
},
masquerade_stack: [],
};
vi.mocked(authApi.stopMasquerade).mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useStopMasquerade(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync();
});
expect(cookies.setCookie).toHaveBeenCalledWith('access_token', 'restored-access-token', 7);
expect(cookies.setCookie).toHaveBeenCalledWith('refresh_token', 'restored-refresh-token', 7);
});
});
});

View File

@@ -0,0 +1,349 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
// Mock dependencies
vi.mock('../../api/client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
},
}));
vi.mock('../../utils/cookies', () => ({
getCookie: vi.fn(),
}));
import { useCurrentBusiness, useUpdateBusiness, useBusinessUsers, useResources, useCreateResource } from '../useBusiness';
import apiClient from '../../api/client';
import { getCookie } from '../../utils/cookies';
// Create wrapper
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return React.createElement(QueryClientProvider, { client: queryClient }, children);
};
};
describe('useBusiness hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useCurrentBusiness', () => {
it('returns null when no token exists', async () => {
vi.mocked(getCookie).mockReturnValue(null);
const { result } = renderHook(() => useCurrentBusiness(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.data).toBeNull();
expect(apiClient.get).not.toHaveBeenCalled();
});
it('fetches business and transforms data', async () => {
vi.mocked(getCookie).mockReturnValue('valid-token');
const mockBusiness = {
id: 1,
name: 'Test Business',
subdomain: 'test',
primary_color: '#FF0000',
secondary_color: '#00FF00',
logo_url: 'https://example.com/logo.png',
timezone: 'America/Denver',
timezone_display_mode: 'business',
tier: 'professional',
status: 'active',
created_at: '2024-01-01T00:00:00Z',
payments_enabled: true,
plan_permissions: {
sms_reminders: true,
api_access: true,
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBusiness });
const { result } = renderHook(() => useCurrentBusiness(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(apiClient.get).toHaveBeenCalledWith('/business/current/');
expect(result.current.data).toEqual(expect.objectContaining({
id: '1',
name: 'Test Business',
subdomain: 'test',
primaryColor: '#FF0000',
secondaryColor: '#00FF00',
logoUrl: 'https://example.com/logo.png',
timezone: 'America/Denver',
plan: 'professional',
paymentsEnabled: true,
}));
});
it('uses default values for missing fields', async () => {
vi.mocked(getCookie).mockReturnValue('valid-token');
const mockBusiness = {
id: 1,
name: 'Minimal Business',
subdomain: 'min',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBusiness });
const { result } = renderHook(() => useCurrentBusiness(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.primaryColor).toBe('#3B82F6');
expect(result.current.data?.secondaryColor).toBe('#1E40AF');
expect(result.current.data?.logoDisplayMode).toBe('text-only');
expect(result.current.data?.timezone).toBe('America/New_York');
expect(result.current.data?.paymentsEnabled).toBe(false);
});
});
describe('useUpdateBusiness', () => {
it('maps frontend fields to backend fields', async () => {
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
const { result } = renderHook(() => useUpdateBusiness(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
name: 'Updated Name',
primaryColor: '#123456',
secondaryColor: '#654321',
timezone: 'America/Los_Angeles',
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/business/current/update/', {
name: 'Updated Name',
primary_color: '#123456',
secondary_color: '#654321',
timezone: 'America/Los_Angeles',
});
});
it('handles logo fields', async () => {
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
const { result } = renderHook(() => useUpdateBusiness(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
logoUrl: 'https://new-logo.com/logo.png',
emailLogoUrl: 'https://new-logo.com/email.png',
logoDisplayMode: 'logo-only',
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/business/current/update/', {
logo_url: 'https://new-logo.com/logo.png',
email_logo_url: 'https://new-logo.com/email.png',
logo_display_mode: 'logo-only',
});
});
it('handles booking-related settings', async () => {
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
const { result } = renderHook(() => useUpdateBusiness(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
resourcesCanReschedule: true,
requirePaymentMethodToBook: true,
cancellationWindowHours: 24,
lateCancellationFeePercent: 50,
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/business/current/update/', {
resources_can_reschedule: true,
require_payment_method_to_book: true,
cancellation_window_hours: 24,
late_cancellation_fee_percent: 50,
});
});
it('handles website and dashboard content', async () => {
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
const { result } = renderHook(() => useUpdateBusiness(), {
wrapper: createWrapper(),
});
const websitePages = { home: { title: 'Welcome' } };
const dashboardContent = [{ type: 'text', content: 'Hello' }];
await act(async () => {
await result.current.mutateAsync({
websitePages,
customerDashboardContent: dashboardContent,
initialSetupComplete: true,
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/business/current/update/', {
website_pages: websitePages,
customer_dashboard_content: dashboardContent,
initial_setup_complete: true,
});
});
});
describe('useBusinessUsers', () => {
it('fetches staff users', async () => {
const mockUsers = [
{ id: 1, name: 'Staff 1' },
{ id: 2, name: 'Staff 2' },
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockUsers });
const { result } = renderHook(() => useBusinessUsers(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(apiClient.get).toHaveBeenCalledWith('/staff/');
expect(result.current.data).toEqual(mockUsers);
});
});
describe('useResources', () => {
it('fetches resources', async () => {
const mockResources = [
{ id: 1, name: 'Resource 1', type: 'equipment' },
{ id: 2, name: 'Resource 2', type: 'room' },
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResources });
const { result } = renderHook(() => useResources(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(apiClient.get).toHaveBeenCalledWith('/resources/');
expect(result.current.data).toEqual(mockResources);
});
it('handles empty resources list', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const { result } = renderHook(() => useResources(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual([]);
});
it('handles fetch error', async () => {
vi.mocked(apiClient.get).mockRejectedValue(new Error('Network error'));
const { result } = renderHook(() => useResources(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toBeDefined();
});
});
describe('useCreateResource', () => {
it('creates a resource', async () => {
const mockResource = { id: 3, name: 'New Resource', type: 'equipment' };
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResource });
const { result } = renderHook(() => useCreateResource(), {
wrapper: createWrapper(),
});
await act(async () => {
const data = await result.current.mutateAsync({ name: 'New Resource', type: 'equipment' });
expect(data).toEqual(mockResource);
});
expect(apiClient.post).toHaveBeenCalledWith('/resources/', {
name: 'New Resource',
type: 'equipment',
});
});
it('creates a resource with user_id', async () => {
const mockResource = { id: 4, name: 'Staff Resource', type: 'staff', user_id: 'user-123' };
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResource });
const { result } = renderHook(() => useCreateResource(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({ name: 'Staff Resource', type: 'staff', user_id: 'user-123' });
});
expect(apiClient.post).toHaveBeenCalledWith('/resources/', {
name: 'Staff Resource',
type: 'staff',
user_id: 'user-123',
});
});
it('handles creation error', async () => {
vi.mocked(apiClient.post).mockRejectedValue(new Error('Validation failed'));
const { result } = renderHook(() => useCreateResource(), {
wrapper: createWrapper(),
});
await expect(
act(async () => {
await result.current.mutateAsync({ name: '', type: 'equipment' });
})
).rejects.toThrow('Validation failed');
});
});
});

View File

@@ -0,0 +1,729 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
// Mock the business API
vi.mock('../../api/business', () => ({
getBusinessOAuthSettings: vi.fn(),
updateBusinessOAuthSettings: vi.fn(),
}));
import {
useBusinessOAuthSettings,
useUpdateBusinessOAuthSettings,
} from '../useBusinessOAuth';
import * as businessApi from '../../api/business';
// Create wrapper for React Query
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return React.createElement(QueryClientProvider, { client: queryClient }, children);
};
};
describe('useBusinessOAuth hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useBusinessOAuthSettings', () => {
it('fetches business OAuth settings successfully', async () => {
const mockResponse = {
settings: {
enabledProviders: ['google', 'microsoft'],
allowRegistration: true,
autoLinkByEmail: false,
useCustomCredentials: false,
},
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',
},
],
};
vi.mocked(businessApi.getBusinessOAuthSettings).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useBusinessOAuthSettings(), {
wrapper: createWrapper(),
});
// Initially loading
expect(result.current.isLoading).toBe(true);
// Wait for success
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(businessApi.getBusinessOAuthSettings).toHaveBeenCalledTimes(1);
expect(result.current.data).toEqual(mockResponse);
expect(result.current.data?.settings.enabledProviders).toHaveLength(2);
expect(result.current.data?.availableProviders).toHaveLength(2);
});
it('handles empty enabled providers', async () => {
const mockResponse = {
settings: {
enabledProviders: [],
allowRegistration: false,
autoLinkByEmail: false,
useCustomCredentials: false,
},
availableProviders: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Sign in with Google',
},
],
};
vi.mocked(businessApi.getBusinessOAuthSettings).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useBusinessOAuthSettings(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.settings.enabledProviders).toEqual([]);
expect(result.current.data?.availableProviders).toHaveLength(1);
});
it('handles custom credentials enabled', async () => {
const mockResponse = {
settings: {
enabledProviders: ['google'],
allowRegistration: true,
autoLinkByEmail: true,
useCustomCredentials: true,
},
availableProviders: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Sign in with Google',
},
],
};
vi.mocked(businessApi.getBusinessOAuthSettings).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useBusinessOAuthSettings(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.settings.useCustomCredentials).toBe(true);
expect(result.current.data?.settings.autoLinkByEmail).toBe(true);
});
it('handles API error gracefully', async () => {
const mockError = new Error('Failed to fetch OAuth settings');
vi.mocked(businessApi.getBusinessOAuthSettings).mockRejectedValue(mockError);
const { result } = renderHook(() => useBusinessOAuthSettings(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(mockError);
expect(result.current.data).toBeUndefined();
});
it('does not retry on failure', async () => {
vi.mocked(businessApi.getBusinessOAuthSettings).mockRejectedValue(
new Error('404 Not Found')
);
const { result } = renderHook(() => useBusinessOAuthSettings(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
// Should be called only once (no retries)
expect(businessApi.getBusinessOAuthSettings).toHaveBeenCalledTimes(1);
});
it('caches data with 5 minute stale time', async () => {
const mockResponse = {
settings: {
enabledProviders: ['google'],
allowRegistration: true,
autoLinkByEmail: false,
useCustomCredentials: false,
},
availableProviders: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Sign in with Google',
},
],
};
vi.mocked(businessApi.getBusinessOAuthSettings).mockResolvedValue(mockResponse);
const { result, rerender } = renderHook(() => useBusinessOAuthSettings(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
// Rerender should use cached data (within stale time)
rerender();
// Should still only be called once
expect(businessApi.getBusinessOAuthSettings).toHaveBeenCalledTimes(1);
});
});
describe('useUpdateBusinessOAuthSettings', () => {
it('updates enabled providers successfully', async () => {
const mockResponse = {
settings: {
enabledProviders: ['google', 'microsoft', 'github'],
allowRegistration: true,
autoLinkByEmail: false,
useCustomCredentials: false,
},
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',
},
{
id: 'github',
name: 'GitHub',
icon: 'github-icon',
description: 'Sign in with GitHub',
},
],
};
vi.mocked(businessApi.updateBusinessOAuthSettings).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useUpdateBusinessOAuthSettings(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
enabledProviders: ['google', 'microsoft', 'github'],
});
});
expect(businessApi.updateBusinessOAuthSettings).toHaveBeenCalledWith({
enabledProviders: ['google', 'microsoft', 'github'],
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
});
it('updates allowRegistration flag', async () => {
const mockResponse = {
settings: {
enabledProviders: ['google'],
allowRegistration: false,
autoLinkByEmail: false,
useCustomCredentials: false,
},
availableProviders: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Sign in with Google',
},
],
};
vi.mocked(businessApi.updateBusinessOAuthSettings).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useUpdateBusinessOAuthSettings(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
allowRegistration: false,
});
});
expect(businessApi.updateBusinessOAuthSettings).toHaveBeenCalledWith({
allowRegistration: false,
});
});
it('updates autoLinkByEmail flag', async () => {
const mockResponse = {
settings: {
enabledProviders: ['google'],
allowRegistration: true,
autoLinkByEmail: true,
useCustomCredentials: false,
},
availableProviders: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Sign in with Google',
},
],
};
vi.mocked(businessApi.updateBusinessOAuthSettings).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useUpdateBusinessOAuthSettings(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
autoLinkByEmail: true,
});
});
expect(businessApi.updateBusinessOAuthSettings).toHaveBeenCalledWith({
autoLinkByEmail: true,
});
});
it('updates useCustomCredentials flag', async () => {
const mockResponse = {
settings: {
enabledProviders: ['google'],
allowRegistration: true,
autoLinkByEmail: false,
useCustomCredentials: true,
},
availableProviders: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Sign in with Google',
},
],
};
vi.mocked(businessApi.updateBusinessOAuthSettings).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useUpdateBusinessOAuthSettings(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
useCustomCredentials: true,
});
});
expect(businessApi.updateBusinessOAuthSettings).toHaveBeenCalledWith({
useCustomCredentials: true,
});
});
it('updates multiple settings at once', async () => {
const mockResponse = {
settings: {
enabledProviders: ['google', 'microsoft'],
allowRegistration: false,
autoLinkByEmail: true,
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',
},
],
};
vi.mocked(businessApi.updateBusinessOAuthSettings).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useUpdateBusinessOAuthSettings(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
enabledProviders: ['google', 'microsoft'],
allowRegistration: false,
autoLinkByEmail: true,
useCustomCredentials: true,
});
});
expect(businessApi.updateBusinessOAuthSettings).toHaveBeenCalledWith({
enabledProviders: ['google', 'microsoft'],
allowRegistration: false,
autoLinkByEmail: true,
useCustomCredentials: true,
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.settings.enabledProviders).toHaveLength(2);
expect(result.current.data?.settings.allowRegistration).toBe(false);
expect(result.current.data?.settings.autoLinkByEmail).toBe(true);
expect(result.current.data?.settings.useCustomCredentials).toBe(true);
});
it('updates query cache on success', async () => {
const mockResponse = {
settings: {
enabledProviders: ['google'],
allowRegistration: true,
autoLinkByEmail: false,
useCustomCredentials: false,
},
availableProviders: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Sign in with Google',
},
],
};
vi.mocked(businessApi.updateBusinessOAuthSettings).mockResolvedValue(mockResponse);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
const { result } = renderHook(() => useUpdateBusinessOAuthSettings(), {
wrapper,
});
await act(async () => {
await result.current.mutateAsync({
enabledProviders: ['google'],
});
});
// Verify cache was updated
const cachedData = queryClient.getQueryData(['businessOAuthSettings']);
expect(cachedData).toEqual(mockResponse);
});
it('handles update error gracefully', async () => {
const mockError = new Error('Failed to update settings');
vi.mocked(businessApi.updateBusinessOAuthSettings).mockRejectedValue(mockError);
const { result } = renderHook(() => useUpdateBusinessOAuthSettings(), {
wrapper: createWrapper(),
});
let caughtError: any = null;
await act(async () => {
try {
await result.current.mutateAsync({
allowRegistration: true,
});
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(mockError);
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(mockError);
});
it('handles partial update with only enabledProviders', async () => {
const mockResponse = {
settings: {
enabledProviders: ['github'],
allowRegistration: true,
autoLinkByEmail: false,
useCustomCredentials: false,
},
availableProviders: [
{
id: 'github',
name: 'GitHub',
icon: 'github-icon',
description: 'Sign in with GitHub',
},
],
};
vi.mocked(businessApi.updateBusinessOAuthSettings).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useUpdateBusinessOAuthSettings(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
enabledProviders: ['github'],
});
});
expect(businessApi.updateBusinessOAuthSettings).toHaveBeenCalledWith({
enabledProviders: ['github'],
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.settings.enabledProviders).toEqual(['github']);
});
it('handles empty enabled providers array', async () => {
const mockResponse = {
settings: {
enabledProviders: [],
allowRegistration: true,
autoLinkByEmail: false,
useCustomCredentials: false,
},
availableProviders: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Sign in with Google',
},
],
};
vi.mocked(businessApi.updateBusinessOAuthSettings).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useUpdateBusinessOAuthSettings(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
enabledProviders: [],
});
});
expect(businessApi.updateBusinessOAuthSettings).toHaveBeenCalledWith({
enabledProviders: [],
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.settings.enabledProviders).toEqual([]);
});
it('preserves availableProviders from backend response', async () => {
const mockResponse = {
settings: {
enabledProviders: ['google'],
allowRegistration: true,
autoLinkByEmail: false,
useCustomCredentials: false,
},
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',
},
{
id: 'github',
name: 'GitHub',
icon: 'github-icon',
description: 'Sign in with GitHub',
},
],
};
vi.mocked(businessApi.updateBusinessOAuthSettings).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useUpdateBusinessOAuthSettings(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
enabledProviders: ['google'],
});
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.availableProviders).toHaveLength(3);
expect(result.current.data?.availableProviders.map(p => p.id)).toEqual([
'google',
'microsoft',
'github',
]);
});
});
describe('integration tests', () => {
it('fetches settings then updates them', async () => {
const initialResponse = {
settings: {
enabledProviders: ['google'],
allowRegistration: true,
autoLinkByEmail: false,
useCustomCredentials: false,
},
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',
},
],
};
const updatedResponse = {
settings: {
enabledProviders: ['google', 'microsoft'],
allowRegistration: true,
autoLinkByEmail: true,
useCustomCredentials: false,
},
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',
},
],
};
vi.mocked(businessApi.getBusinessOAuthSettings).mockResolvedValue(initialResponse);
vi.mocked(businessApi.updateBusinessOAuthSettings).mockResolvedValue(updatedResponse);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
// Fetch initial settings
const { result: fetchResult } = renderHook(() => useBusinessOAuthSettings(), {
wrapper,
});
await waitFor(() => {
expect(fetchResult.current.isSuccess).toBe(true);
});
expect(fetchResult.current.data?.settings.enabledProviders).toEqual(['google']);
expect(fetchResult.current.data?.settings.autoLinkByEmail).toBe(false);
// Update settings
const { result: updateResult } = renderHook(() => useUpdateBusinessOAuthSettings(), {
wrapper,
});
await act(async () => {
await updateResult.current.mutateAsync({
enabledProviders: ['google', 'microsoft'],
autoLinkByEmail: true,
});
});
// Verify cache was updated
const cachedData = queryClient.getQueryData(['businessOAuthSettings']);
expect(cachedData).toEqual(updatedResponse);
});
});
});

View File

@@ -0,0 +1,921 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
// Mock the business API
vi.mock('../../api/business', () => ({
getBusinessOAuthCredentials: vi.fn(),
updateBusinessOAuthCredentials: vi.fn(),
}));
import {
useBusinessOAuthCredentials,
useUpdateBusinessOAuthCredentials,
} from '../useBusinessOAuthCredentials';
import * as businessApi from '../../api/business';
// Create wrapper for React Query
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return React.createElement(QueryClientProvider, { client: queryClient }, children);
};
};
describe('useBusinessOAuthCredentials hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useBusinessOAuthCredentials', () => {
it('fetches business OAuth credentials successfully', async () => {
const mockResponse = {
credentials: {
google: {
client_id: 'google-client-id-123',
client_secret: 'google-client-secret-456',
has_secret: true,
},
microsoft: {
client_id: 'microsoft-client-id-789',
client_secret: 'microsoft-client-secret-012',
has_secret: true,
},
},
useCustomCredentials: true,
};
vi.mocked(businessApi.getBusinessOAuthCredentials).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
// Initially loading
expect(result.current.isLoading).toBe(true);
// Wait for success
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(businessApi.getBusinessOAuthCredentials).toHaveBeenCalledTimes(1);
expect(result.current.data).toEqual(mockResponse);
expect(result.current.data?.credentials.google.client_id).toBe('google-client-id-123');
expect(result.current.data?.credentials.google.has_secret).toBe(true);
expect(result.current.data?.credentials.microsoft.client_id).toBe('microsoft-client-id-789');
expect(result.current.data?.useCustomCredentials).toBe(true);
});
it('handles empty credentials', async () => {
const mockResponse = {
credentials: {},
useCustomCredentials: false,
};
vi.mocked(businessApi.getBusinessOAuthCredentials).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.credentials).toEqual({});
expect(result.current.data?.useCustomCredentials).toBe(false);
});
it('handles credentials with has_secret false', async () => {
const mockResponse = {
credentials: {
google: {
client_id: 'google-client-id-123',
client_secret: '',
has_secret: false,
},
},
useCustomCredentials: true,
};
vi.mocked(businessApi.getBusinessOAuthCredentials).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.credentials.google.has_secret).toBe(false);
expect(result.current.data?.credentials.google.client_secret).toBe('');
});
it('handles multiple providers with mixed credential states', async () => {
const mockResponse = {
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,
},
github: {
client_id: '',
client_secret: '',
has_secret: false,
},
},
useCustomCredentials: true,
};
vi.mocked(businessApi.getBusinessOAuthCredentials).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(Object.keys(result.current.data?.credentials || {})).toHaveLength(3);
expect(result.current.data?.credentials.google.has_secret).toBe(true);
expect(result.current.data?.credentials.microsoft.has_secret).toBe(false);
expect(result.current.data?.credentials.github.has_secret).toBe(false);
});
it('handles API error gracefully', async () => {
const mockError = new Error('Failed to fetch OAuth credentials');
vi.mocked(businessApi.getBusinessOAuthCredentials).mockRejectedValue(mockError);
const { result } = renderHook(() => useBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(mockError);
expect(result.current.data).toBeUndefined();
});
it('does not retry on failure (404)', async () => {
vi.mocked(businessApi.getBusinessOAuthCredentials).mockRejectedValue(
new Error('404 Not Found')
);
const { result } = renderHook(() => useBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
// Should be called only once (no retries)
expect(businessApi.getBusinessOAuthCredentials).toHaveBeenCalledTimes(1);
});
it('caches data with 5 minute stale time', async () => {
const mockResponse = {
credentials: {
google: {
client_id: 'google-client-id',
client_secret: 'google-secret',
has_secret: true,
},
},
useCustomCredentials: true,
};
vi.mocked(businessApi.getBusinessOAuthCredentials).mockResolvedValue(mockResponse);
const { result, rerender } = renderHook(() => useBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
// Rerender should use cached data (within stale time)
rerender();
// Should still only be called once
expect(businessApi.getBusinessOAuthCredentials).toHaveBeenCalledTimes(1);
});
it('handles 401 unauthorized error', async () => {
const mockError = new Error('401 Unauthorized');
vi.mocked(businessApi.getBusinessOAuthCredentials).mockRejectedValue(mockError);
const { result } = renderHook(() => useBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(mockError);
});
it('handles network error', async () => {
const mockError = new Error('Network Error');
vi.mocked(businessApi.getBusinessOAuthCredentials).mockRejectedValue(mockError);
const { result } = renderHook(() => useBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(mockError);
});
});
describe('useUpdateBusinessOAuthCredentials', () => {
it('updates credentials for a single provider successfully', async () => {
const mockResponse = {
credentials: {
google: {
client_id: 'new-google-client-id',
client_secret: 'new-google-secret',
has_secret: true,
},
},
useCustomCredentials: true,
};
vi.mocked(businessApi.updateBusinessOAuthCredentials).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useUpdateBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
credentials: {
google: {
client_id: 'new-google-client-id',
client_secret: 'new-google-secret',
},
},
});
});
expect(businessApi.updateBusinessOAuthCredentials).toHaveBeenCalledWith({
credentials: {
google: {
client_id: 'new-google-client-id',
client_secret: 'new-google-secret',
},
},
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.credentials.google.client_id).toBe('new-google-client-id');
expect(result.current.data?.credentials.google.has_secret).toBe(true);
});
it('updates credentials for multiple providers', async () => {
const mockResponse = {
credentials: {
google: {
client_id: 'google-id',
client_secret: 'google-secret',
has_secret: true,
},
microsoft: {
client_id: 'microsoft-id',
client_secret: 'microsoft-secret',
has_secret: true,
},
},
useCustomCredentials: true,
};
vi.mocked(businessApi.updateBusinessOAuthCredentials).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useUpdateBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
credentials: {
google: {
client_id: 'google-id',
client_secret: 'google-secret',
},
microsoft: {
client_id: 'microsoft-id',
client_secret: 'microsoft-secret',
},
},
});
});
expect(businessApi.updateBusinessOAuthCredentials).toHaveBeenCalledWith({
credentials: {
google: {
client_id: 'google-id',
client_secret: 'google-secret',
},
microsoft: {
client_id: 'microsoft-id',
client_secret: 'microsoft-secret',
},
},
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(Object.keys(result.current.data?.credentials || {})).toHaveLength(2);
});
it('updates only client_id without client_secret', async () => {
const mockResponse = {
credentials: {
google: {
client_id: 'updated-google-id',
client_secret: 'existing-secret',
has_secret: true,
},
},
useCustomCredentials: true,
};
vi.mocked(businessApi.updateBusinessOAuthCredentials).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useUpdateBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
credentials: {
google: {
client_id: 'updated-google-id',
},
},
});
});
expect(businessApi.updateBusinessOAuthCredentials).toHaveBeenCalledWith({
credentials: {
google: {
client_id: 'updated-google-id',
},
},
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
});
it('updates only client_secret without client_id', async () => {
const mockResponse = {
credentials: {
google: {
client_id: 'existing-google-id',
client_secret: 'new-secret',
has_secret: true,
},
},
useCustomCredentials: true,
};
vi.mocked(businessApi.updateBusinessOAuthCredentials).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useUpdateBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
credentials: {
google: {
client_secret: 'new-secret',
},
},
});
});
expect(businessApi.updateBusinessOAuthCredentials).toHaveBeenCalledWith({
credentials: {
google: {
client_secret: 'new-secret',
},
},
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
});
it('updates useCustomCredentials flag only', async () => {
const mockResponse = {
credentials: {
google: {
client_id: 'google-id',
client_secret: 'google-secret',
has_secret: true,
},
},
useCustomCredentials: false,
};
vi.mocked(businessApi.updateBusinessOAuthCredentials).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useUpdateBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
useCustomCredentials: false,
});
});
expect(businessApi.updateBusinessOAuthCredentials).toHaveBeenCalledWith({
useCustomCredentials: false,
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.useCustomCredentials).toBe(false);
});
it('updates both credentials and useCustomCredentials flag', async () => {
const mockResponse = {
credentials: {
google: {
client_id: 'custom-google-id',
client_secret: 'custom-google-secret',
has_secret: true,
},
},
useCustomCredentials: true,
};
vi.mocked(businessApi.updateBusinessOAuthCredentials).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useUpdateBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
credentials: {
google: {
client_id: 'custom-google-id',
client_secret: 'custom-google-secret',
},
},
useCustomCredentials: true,
});
});
expect(businessApi.updateBusinessOAuthCredentials).toHaveBeenCalledWith({
credentials: {
google: {
client_id: 'custom-google-id',
client_secret: 'custom-google-secret',
},
},
useCustomCredentials: true,
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.useCustomCredentials).toBe(true);
expect(result.current.data?.credentials.google.has_secret).toBe(true);
});
it('updates query cache on success', async () => {
const mockResponse = {
credentials: {
google: {
client_id: 'google-id',
client_secret: 'google-secret',
has_secret: true,
},
},
useCustomCredentials: true,
};
vi.mocked(businessApi.updateBusinessOAuthCredentials).mockResolvedValue(mockResponse);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
const { result } = renderHook(() => useUpdateBusinessOAuthCredentials(), {
wrapper,
});
await act(async () => {
await result.current.mutateAsync({
credentials: {
google: {
client_id: 'google-id',
client_secret: 'google-secret',
},
},
});
});
// Verify cache was updated
const cachedData = queryClient.getQueryData(['businessOAuthCredentials']);
expect(cachedData).toEqual(mockResponse);
});
it('handles update error gracefully', async () => {
const mockError = new Error('Failed to update credentials');
vi.mocked(businessApi.updateBusinessOAuthCredentials).mockRejectedValue(mockError);
const { result } = renderHook(() => useUpdateBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
let caughtError: any = null;
await act(async () => {
try {
await result.current.mutateAsync({
credentials: {
google: {
client_id: 'test-id',
},
},
});
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(mockError);
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(mockError);
});
it('handles validation error from API', async () => {
const mockError = new Error('Invalid client_id format');
vi.mocked(businessApi.updateBusinessOAuthCredentials).mockRejectedValue(mockError);
const { result } = renderHook(() => useUpdateBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
let caughtError: any = null;
await act(async () => {
try {
await result.current.mutateAsync({
credentials: {
google: {
client_id: 'invalid-format',
},
},
});
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(mockError);
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
});
it('handles clearing credentials by passing empty values', async () => {
const mockResponse = {
credentials: {
google: {
client_id: '',
client_secret: '',
has_secret: false,
},
},
useCustomCredentials: false,
};
vi.mocked(businessApi.updateBusinessOAuthCredentials).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useUpdateBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
credentials: {
google: {
client_id: '',
client_secret: '',
},
},
useCustomCredentials: false,
});
});
expect(businessApi.updateBusinessOAuthCredentials).toHaveBeenCalledWith({
credentials: {
google: {
client_id: '',
client_secret: '',
},
},
useCustomCredentials: false,
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.credentials.google.has_secret).toBe(false);
expect(result.current.data?.useCustomCredentials).toBe(false);
});
it('handles permission error (403)', async () => {
const mockError = new Error('403 Forbidden - Insufficient permissions');
vi.mocked(businessApi.updateBusinessOAuthCredentials).mockRejectedValue(mockError);
const { result } = renderHook(() => useUpdateBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
let caughtError: any = null;
await act(async () => {
try {
await result.current.mutateAsync({
useCustomCredentials: true,
});
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(mockError);
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
});
it('preserves backend response structure with has_secret flags', async () => {
const mockResponse = {
credentials: {
google: {
client_id: 'google-id',
client_secret: 'google-secret',
has_secret: true,
},
microsoft: {
client_id: 'microsoft-id',
client_secret: '',
has_secret: false,
},
},
useCustomCredentials: true,
};
vi.mocked(businessApi.updateBusinessOAuthCredentials).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useUpdateBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
credentials: {
google: {
client_id: 'google-id',
client_secret: 'google-secret',
},
microsoft: {
client_id: 'microsoft-id',
},
},
});
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.credentials.google.has_secret).toBe(true);
expect(result.current.data?.credentials.microsoft.has_secret).toBe(false);
expect(result.current.data?.credentials.microsoft.client_secret).toBe('');
});
});
describe('integration tests', () => {
it('fetches credentials then updates them', async () => {
const initialResponse = {
credentials: {
google: {
client_id: 'initial-google-id',
client_secret: 'initial-google-secret',
has_secret: true,
},
},
useCustomCredentials: true,
};
const updatedResponse = {
credentials: {
google: {
client_id: 'updated-google-id',
client_secret: 'updated-google-secret',
has_secret: true,
},
microsoft: {
client_id: 'new-microsoft-id',
client_secret: 'new-microsoft-secret',
has_secret: true,
},
},
useCustomCredentials: true,
};
vi.mocked(businessApi.getBusinessOAuthCredentials).mockResolvedValue(initialResponse);
vi.mocked(businessApi.updateBusinessOAuthCredentials).mockResolvedValue(updatedResponse);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
// Fetch initial credentials
const { result: fetchResult } = renderHook(() => useBusinessOAuthCredentials(), {
wrapper,
});
await waitFor(() => {
expect(fetchResult.current.isSuccess).toBe(true);
});
expect(fetchResult.current.data?.credentials.google.client_id).toBe('initial-google-id');
expect(Object.keys(fetchResult.current.data?.credentials || {})).toHaveLength(1);
// Update credentials
const { result: updateResult } = renderHook(() => useUpdateBusinessOAuthCredentials(), {
wrapper,
});
await act(async () => {
await updateResult.current.mutateAsync({
credentials: {
google: {
client_id: 'updated-google-id',
client_secret: 'updated-google-secret',
},
microsoft: {
client_id: 'new-microsoft-id',
client_secret: 'new-microsoft-secret',
},
},
});
});
// Verify cache was updated
const cachedData = queryClient.getQueryData(['businessOAuthCredentials']);
expect(cachedData).toEqual(updatedResponse);
expect((cachedData as any).credentials.google.client_id).toBe('updated-google-id');
expect((cachedData as any).credentials.microsoft.client_id).toBe('new-microsoft-id');
});
it('toggles custom credentials on and off', async () => {
const initialResponse = {
credentials: {
google: {
client_id: 'google-id',
client_secret: 'google-secret',
has_secret: true,
},
},
useCustomCredentials: true,
};
const toggledOffResponse = {
credentials: {
google: {
client_id: 'google-id',
client_secret: 'google-secret',
has_secret: true,
},
},
useCustomCredentials: false,
};
const toggledOnResponse = {
credentials: {
google: {
client_id: 'google-id',
client_secret: 'google-secret',
has_secret: true,
},
},
useCustomCredentials: true,
};
vi.mocked(businessApi.getBusinessOAuthCredentials).mockResolvedValue(initialResponse);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
// Fetch initial state
const { result: fetchResult } = renderHook(() => useBusinessOAuthCredentials(), {
wrapper,
});
await waitFor(() => {
expect(fetchResult.current.isSuccess).toBe(true);
});
expect(fetchResult.current.data?.useCustomCredentials).toBe(true);
// Toggle off
const { result: updateResult } = renderHook(() => useUpdateBusinessOAuthCredentials(), {
wrapper,
});
vi.mocked(businessApi.updateBusinessOAuthCredentials).mockResolvedValue(toggledOffResponse);
await act(async () => {
await updateResult.current.mutateAsync({
useCustomCredentials: false,
});
});
let cachedData = queryClient.getQueryData(['businessOAuthCredentials']);
expect((cachedData as any).useCustomCredentials).toBe(false);
// Toggle back on
vi.mocked(businessApi.updateBusinessOAuthCredentials).mockResolvedValue(toggledOnResponse);
await act(async () => {
await updateResult.current.mutateAsync({
useCustomCredentials: true,
});
});
cachedData = queryClient.getQueryData(['businessOAuthCredentials']);
expect((cachedData as any).useCustomCredentials).toBe(true);
});
});
});

View File

@@ -0,0 +1,942 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import apiClient from '../../api/client';
import {
useCommunicationCredits,
useCreditTransactions,
useUpdateCreditsSettings,
useAddCredits,
useCreatePaymentIntent,
useConfirmPayment,
useSetupPaymentMethod,
useSavePaymentMethod,
useCommunicationUsageStats,
usePhoneNumbers,
useSearchPhoneNumbers,
usePurchasePhoneNumber,
useReleasePhoneNumber,
useChangePhoneNumber,
CommunicationCredits,
CreditTransaction,
ProxyPhoneNumber,
AvailablePhoneNumber,
} from '../useCommunicationCredits';
// Mock the API client
vi.mock('../../api/client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
describe('useCommunicationCredits', () => {
let queryClient: QueryClient;
let wrapper: React.FC<{ children: React.ReactNode }>;
const mockCredits: CommunicationCredits = {
id: 1,
balance_cents: 50000,
auto_reload_enabled: true,
auto_reload_threshold_cents: 10000,
auto_reload_amount_cents: 50000,
low_balance_warning_cents: 20000,
low_balance_warning_sent: false,
stripe_payment_method_id: 'pm_test123',
last_twilio_sync_at: '2025-12-07T10:00:00Z',
total_loaded_cents: 100000,
total_spent_cents: 50000,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
};
const mockTransactions: CreditTransaction[] = [
{
id: 1,
amount_cents: 50000,
balance_after_cents: 50000,
transaction_type: 'manual',
description: 'Manual credit purchase',
reference_type: 'payment_intent',
reference_id: 'pi_test123',
stripe_charge_id: 'ch_test123',
created_at: '2025-12-07T10:00:00Z',
},
{
id: 2,
amount_cents: -1000,
balance_after_cents: 49000,
transaction_type: 'usage',
description: 'SMS to +15551234567',
reference_type: 'sms_message',
reference_id: 'msg_123',
stripe_charge_id: '',
created_at: '2025-12-07T11:00:00Z',
},
];
const mockPhoneNumber: ProxyPhoneNumber = {
id: 1,
phone_number: '+15551234567',
friendly_name: 'Main Office Line',
status: 'assigned',
monthly_fee_cents: 100,
capabilities: {
voice: true,
sms: true,
mms: true,
},
assigned_at: '2025-12-01T00:00:00Z',
last_billed_at: '2025-12-01T00:00:00Z',
};
const mockAvailableNumber: AvailablePhoneNumber = {
phone_number: '+15559876543',
friendly_name: '(555) 987-6543',
locality: 'New York',
region: 'NY',
postal_code: '10001',
capabilities: {
voice: true,
sms: true,
mms: true,
},
monthly_cost_cents: 100,
};
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
wrapper = ({ children }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
vi.clearAllMocks();
});
afterEach(() => {
queryClient.clear();
});
describe('useCommunicationCredits', () => {
it('should fetch communication credits successfully', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockCredits });
const { result } = renderHook(() => useCommunicationCredits(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.get).toHaveBeenCalledWith('/communication-credits/');
expect(result.current.data).toEqual(mockCredits);
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBeNull();
});
it('should handle fetch errors', async () => {
const mockError = new Error('Failed to fetch credits');
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
const { result } = renderHook(() => useCommunicationCredits(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.data).toBeUndefined();
expect(result.current.error).toEqual(mockError);
});
it('should use correct query key', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockCredits });
const { result } = renderHook(() => useCommunicationCredits(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
const cachedData = queryClient.getQueryData(['communicationCredits']);
expect(cachedData).toEqual(mockCredits);
});
it('should have staleTime of 30 seconds', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockCredits });
const { result } = renderHook(() => useCommunicationCredits(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
const queryState = queryClient.getQueryState(['communicationCredits']);
expect(queryState?.dataUpdatedAt).toBeDefined();
});
});
describe('useCreditTransactions', () => {
it('should fetch credit transactions with pagination', async () => {
const mockResponse = {
results: mockTransactions,
count: 2,
next: null,
previous: null,
};
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockResponse });
const { result } = renderHook(() => useCreditTransactions(1, 20), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.get).toHaveBeenCalledWith('/communication-credits/transactions/', {
params: { page: 1, limit: 20 },
});
expect(result.current.data).toEqual(mockResponse);
});
it('should support custom page and limit', async () => {
const mockResponse = {
results: [mockTransactions[0]],
count: 10,
next: 'http://api.example.com/page=3',
previous: 'http://api.example.com/page=1',
};
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockResponse });
const { result } = renderHook(() => useCreditTransactions(2, 10), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.get).toHaveBeenCalledWith('/communication-credits/transactions/', {
params: { page: 2, limit: 10 },
});
});
it('should handle fetch errors', async () => {
const mockError = new Error('Failed to fetch transactions');
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
const { result } = renderHook(() => useCreditTransactions(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toEqual(mockError);
});
});
describe('useUpdateCreditsSettings', () => {
it('should update credit settings successfully', async () => {
const updatedCredits = {
...mockCredits,
auto_reload_enabled: false,
auto_reload_threshold_cents: 5000,
};
vi.mocked(apiClient.patch).mockResolvedValueOnce({ data: updatedCredits });
const { result } = renderHook(() => useUpdateCreditsSettings(), { wrapper });
result.current.mutate({
auto_reload_enabled: false,
auto_reload_threshold_cents: 5000,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.patch).toHaveBeenCalledWith('/communication-credits/settings/', {
auto_reload_enabled: false,
auto_reload_threshold_cents: 5000,
});
expect(result.current.data).toEqual(updatedCredits);
});
it('should update query cache on success', async () => {
const updatedCredits = { ...mockCredits, auto_reload_enabled: false };
queryClient.setQueryData(['communicationCredits'], mockCredits);
vi.mocked(apiClient.patch).mockResolvedValueOnce({ data: updatedCredits });
const { result } = renderHook(() => useUpdateCreditsSettings(), { wrapper });
result.current.mutate({ auto_reload_enabled: false });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
const cachedData = queryClient.getQueryData(['communicationCredits']);
expect(cachedData).toEqual(updatedCredits);
});
it('should handle update errors', async () => {
const mockError = new Error('Failed to update settings');
vi.mocked(apiClient.patch).mockRejectedValueOnce(mockError);
const { result } = renderHook(() => useUpdateCreditsSettings(), { wrapper });
result.current.mutate({ auto_reload_enabled: false });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toEqual(mockError);
});
});
describe('useAddCredits', () => {
it('should add credits successfully', async () => {
const mockResponse = {
success: true,
balance_cents: 100000,
transaction_id: 123,
};
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: mockResponse });
const { result } = renderHook(() => useAddCredits(), { wrapper });
result.current.mutate({
amount_cents: 50000,
payment_method_id: 'pm_test123',
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.post).toHaveBeenCalledWith('/communication-credits/add/', {
amount_cents: 50000,
payment_method_id: 'pm_test123',
});
expect(result.current.data).toEqual(mockResponse);
});
it('should invalidate credits and transactions queries on success', async () => {
const mockResponse = { success: true, balance_cents: 100000 };
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: mockResponse });
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const { result } = renderHook(() => useAddCredits(), { wrapper });
result.current.mutate({ amount_cents: 50000 });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['communicationCredits'] });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['creditTransactions'] });
});
it('should handle add credits errors', async () => {
const mockError = new Error('Payment failed');
vi.mocked(apiClient.post).mockRejectedValueOnce(mockError);
const { result } = renderHook(() => useAddCredits(), { wrapper });
result.current.mutate({ amount_cents: 50000 });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toEqual(mockError);
});
});
describe('useCreatePaymentIntent', () => {
it('should create payment intent successfully', async () => {
const mockResponse = {
client_secret: 'pi_test_secret',
payment_intent_id: 'pi_test123',
};
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: mockResponse });
const { result } = renderHook(() => useCreatePaymentIntent(), { wrapper });
result.current.mutate(50000);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.post).toHaveBeenCalledWith('/communication-credits/create-payment-intent/', {
amount_cents: 50000,
});
expect(result.current.data).toEqual(mockResponse);
});
it('should handle payment intent creation errors', async () => {
const mockError = new Error('Failed to create payment intent');
vi.mocked(apiClient.post).mockRejectedValueOnce(mockError);
const { result } = renderHook(() => useCreatePaymentIntent(), { wrapper });
result.current.mutate(50000);
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toEqual(mockError);
});
});
describe('useConfirmPayment', () => {
it('should confirm payment successfully', async () => {
const mockResponse = {
success: true,
balance_cents: 100000,
transaction_id: 123,
};
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: mockResponse });
const { result } = renderHook(() => useConfirmPayment(), { wrapper });
result.current.mutate({
payment_intent_id: 'pi_test123',
save_payment_method: true,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.post).toHaveBeenCalledWith('/communication-credits/confirm-payment/', {
payment_intent_id: 'pi_test123',
save_payment_method: true,
});
expect(result.current.data).toEqual(mockResponse);
});
it('should invalidate credits and transactions queries on success', async () => {
const mockResponse = { success: true };
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: mockResponse });
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const { result } = renderHook(() => useConfirmPayment(), { wrapper });
result.current.mutate({ payment_intent_id: 'pi_test123' });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['communicationCredits'] });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['creditTransactions'] });
});
it('should handle confirmation errors', async () => {
const mockError = new Error('Payment confirmation failed');
vi.mocked(apiClient.post).mockRejectedValueOnce(mockError);
const { result } = renderHook(() => useConfirmPayment(), { wrapper });
result.current.mutate({ payment_intent_id: 'pi_test123' });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toEqual(mockError);
});
});
describe('useSetupPaymentMethod', () => {
it('should setup payment method successfully', async () => {
const mockResponse = {
client_secret: 'seti_test_secret',
};
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: mockResponse });
const { result } = renderHook(() => useSetupPaymentMethod(), { wrapper });
result.current.mutate();
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.post).toHaveBeenCalledWith('/communication-credits/setup-payment-method/');
expect(result.current.data).toEqual(mockResponse);
});
it('should handle setup errors', async () => {
const mockError = new Error('Failed to setup payment method');
vi.mocked(apiClient.post).mockRejectedValueOnce(mockError);
const { result } = renderHook(() => useSetupPaymentMethod(), { wrapper });
result.current.mutate();
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toEqual(mockError);
});
});
describe('useSavePaymentMethod', () => {
it('should save payment method successfully', async () => {
const mockResponse = {
success: true,
payment_method_id: 'pm_test123',
};
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: mockResponse });
const { result } = renderHook(() => useSavePaymentMethod(), { wrapper });
result.current.mutate('pm_test123');
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.post).toHaveBeenCalledWith('/communication-credits/save-payment-method/', {
payment_method_id: 'pm_test123',
});
expect(result.current.data).toEqual(mockResponse);
});
it('should invalidate credits query on success', async () => {
const mockResponse = { success: true };
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: mockResponse });
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const { result } = renderHook(() => useSavePaymentMethod(), { wrapper });
result.current.mutate('pm_test123');
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['communicationCredits'] });
});
it('should handle save errors', async () => {
const mockError = new Error('Failed to save payment method');
vi.mocked(apiClient.post).mockRejectedValueOnce(mockError);
const { result } = renderHook(() => useSavePaymentMethod(), { wrapper });
result.current.mutate('pm_test123');
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toEqual(mockError);
});
});
describe('useCommunicationUsageStats', () => {
it('should fetch usage stats successfully', async () => {
const mockStats = {
sms_sent_this_month: 150,
voice_minutes_this_month: 45.5,
proxy_numbers_active: 2,
estimated_cost_cents: 2500,
};
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockStats });
const { result } = renderHook(() => useCommunicationUsageStats(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.get).toHaveBeenCalledWith('/communication-credits/usage-stats/');
expect(result.current.data).toEqual(mockStats);
});
it('should handle fetch errors', async () => {
const mockError = new Error('Failed to fetch stats');
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
const { result } = renderHook(() => useCommunicationUsageStats(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toEqual(mockError);
});
it('should use correct query key', async () => {
const mockStats = {
sms_sent_this_month: 150,
voice_minutes_this_month: 45.5,
proxy_numbers_active: 2,
estimated_cost_cents: 2500,
};
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockStats });
const { result } = renderHook(() => useCommunicationUsageStats(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
const cachedData = queryClient.getQueryData(['communicationUsageStats']);
expect(cachedData).toEqual(mockStats);
});
});
describe('usePhoneNumbers', () => {
it('should fetch phone numbers successfully', async () => {
const mockResponse = {
numbers: [mockPhoneNumber],
count: 1,
};
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockResponse });
const { result } = renderHook(() => usePhoneNumbers(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.get).toHaveBeenCalledWith('/communication-credits/phone-numbers/');
expect(result.current.data).toEqual(mockResponse);
});
it('should handle fetch errors', async () => {
const mockError = new Error('Failed to fetch phone numbers');
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
const { result } = renderHook(() => usePhoneNumbers(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toEqual(mockError);
});
});
describe('useSearchPhoneNumbers', () => {
it('should search phone numbers successfully', async () => {
const mockResponse = {
numbers: [mockAvailableNumber],
count: 1,
};
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockResponse });
const { result } = renderHook(() => useSearchPhoneNumbers(), { wrapper });
result.current.mutate({
area_code: '555',
country: 'US',
limit: 10,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.get).toHaveBeenCalledWith('/communication-credits/phone-numbers/search/', {
params: {
area_code: '555',
country: 'US',
limit: 10,
},
});
expect(result.current.data).toEqual(mockResponse);
});
it('should support contains parameter', async () => {
const mockResponse = {
numbers: [mockAvailableNumber],
count: 1,
};
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockResponse });
const { result } = renderHook(() => useSearchPhoneNumbers(), { wrapper });
result.current.mutate({
contains: '123',
country: 'US',
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.get).toHaveBeenCalledWith('/communication-credits/phone-numbers/search/', {
params: {
contains: '123',
country: 'US',
},
});
});
it('should handle search errors', async () => {
const mockError = new Error('Search failed');
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
const { result } = renderHook(() => useSearchPhoneNumbers(), { wrapper });
result.current.mutate({ area_code: '555' });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toEqual(mockError);
});
});
describe('usePurchasePhoneNumber', () => {
it('should purchase phone number successfully', async () => {
const mockResponse = {
success: true,
phone_number: mockPhoneNumber,
balance_cents: 49900,
};
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: mockResponse });
const { result } = renderHook(() => usePurchasePhoneNumber(), { wrapper });
result.current.mutate({
phone_number: '+15551234567',
friendly_name: 'Main Office Line',
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.post).toHaveBeenCalledWith('/communication-credits/phone-numbers/purchase/', {
phone_number: '+15551234567',
friendly_name: 'Main Office Line',
});
expect(result.current.data).toEqual(mockResponse);
});
it('should invalidate queries on success', async () => {
const mockResponse = {
success: true,
phone_number: mockPhoneNumber,
balance_cents: 49900,
};
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: mockResponse });
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const { result } = renderHook(() => usePurchasePhoneNumber(), { wrapper });
result.current.mutate({ phone_number: '+15551234567' });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['phoneNumbers'] });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['communicationCredits'] });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['creditTransactions'] });
});
it('should handle purchase errors', async () => {
const mockError = new Error('Insufficient credits');
vi.mocked(apiClient.post).mockRejectedValueOnce(mockError);
const { result } = renderHook(() => usePurchasePhoneNumber(), { wrapper });
result.current.mutate({ phone_number: '+15551234567' });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toEqual(mockError);
});
});
describe('useReleasePhoneNumber', () => {
it('should release phone number successfully', async () => {
const mockResponse = {
success: true,
message: 'Phone number released successfully',
};
vi.mocked(apiClient.delete).mockResolvedValueOnce({ data: mockResponse });
const { result } = renderHook(() => useReleasePhoneNumber(), { wrapper });
result.current.mutate(1);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.delete).toHaveBeenCalledWith('/communication-credits/phone-numbers/1/');
expect(result.current.data).toEqual(mockResponse);
});
it('should invalidate queries on success', async () => {
const mockResponse = {
success: true,
message: 'Phone number released successfully',
};
vi.mocked(apiClient.delete).mockResolvedValueOnce({ data: mockResponse });
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const { result } = renderHook(() => useReleasePhoneNumber(), { wrapper });
result.current.mutate(1);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['phoneNumbers'] });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['communicationUsageStats'] });
});
it('should handle release errors', async () => {
const mockError = new Error('Failed to release phone number');
vi.mocked(apiClient.delete).mockRejectedValueOnce(mockError);
const { result } = renderHook(() => useReleasePhoneNumber(), { wrapper });
result.current.mutate(1);
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toEqual(mockError);
});
});
describe('useChangePhoneNumber', () => {
it('should change phone number successfully', async () => {
const newPhoneNumber = {
...mockPhoneNumber,
phone_number: '+15559876543',
friendly_name: 'Updated Office Line',
};
const mockResponse = {
success: true,
phone_number: newPhoneNumber,
balance_cents: 49900,
};
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: mockResponse });
const { result } = renderHook(() => useChangePhoneNumber(), { wrapper });
result.current.mutate({
numberId: 1,
new_phone_number: '+15559876543',
friendly_name: 'Updated Office Line',
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.post).toHaveBeenCalledWith('/communication-credits/phone-numbers/1/change/', {
new_phone_number: '+15559876543',
friendly_name: 'Updated Office Line',
});
expect(result.current.data).toEqual(mockResponse);
});
it('should invalidate queries on success', async () => {
const mockResponse = {
success: true,
phone_number: mockPhoneNumber,
balance_cents: 49900,
};
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: mockResponse });
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const { result } = renderHook(() => useChangePhoneNumber(), { wrapper });
result.current.mutate({
numberId: 1,
new_phone_number: '+15559876543',
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['phoneNumbers'] });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['communicationCredits'] });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['creditTransactions'] });
});
it('should handle change errors', async () => {
const mockError = new Error('Failed to change phone number');
vi.mocked(apiClient.post).mockRejectedValueOnce(mockError);
const { result } = renderHook(() => useChangePhoneNumber(), { wrapper });
result.current.mutate({
numberId: 1,
new_phone_number: '+15559876543',
});
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toEqual(mockError);
});
it('should exclude numberId from request body', async () => {
const mockResponse = {
success: true,
phone_number: mockPhoneNumber,
balance_cents: 49900,
};
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: mockResponse });
const { result } = renderHook(() => useChangePhoneNumber(), { wrapper });
result.current.mutate({
numberId: 1,
new_phone_number: '+15559876543',
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
// Verify numberId is NOT in the request body
expect(apiClient.post).toHaveBeenCalledWith('/communication-credits/phone-numbers/1/change/', {
new_phone_number: '+15559876543',
});
});
});
describe('Integration Tests', () => {
it('should update credits after adding credits', async () => {
const initialCredits = mockCredits;
const updatedCredits = { ...mockCredits, balance_cents: 100000 };
// Initial fetch
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: initialCredits });
const { result: creditsResult } = renderHook(() => useCommunicationCredits(), { wrapper });
await waitFor(() => expect(creditsResult.current.isSuccess).toBe(true));
expect(creditsResult.current.data?.balance_cents).toBe(50000);
// Add credits
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: { success: true } });
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: updatedCredits });
const { result: addResult } = renderHook(() => useAddCredits(), { wrapper });
addResult.current.mutate({ amount_cents: 50000 });
await waitFor(() => expect(addResult.current.isSuccess).toBe(true));
// Refetch credits
await creditsResult.current.refetch();
expect(creditsResult.current.data?.balance_cents).toBe(100000);
});
it('should update phone numbers list after purchasing', async () => {
const initialResponse = { numbers: [], count: 0 };
const updatedResponse = { numbers: [mockPhoneNumber], count: 1 };
// Initial fetch
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: initialResponse });
const { result: numbersResult } = renderHook(() => usePhoneNumbers(), { wrapper });
await waitFor(() => expect(numbersResult.current.isSuccess).toBe(true));
expect(numbersResult.current.data?.count).toBe(0);
// Purchase number
vi.mocked(apiClient.post).mockResolvedValueOnce({
data: {
success: true,
phone_number: mockPhoneNumber,
balance_cents: 49900,
},
});
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: updatedResponse });
const { result: purchaseResult } = renderHook(() => usePurchasePhoneNumber(), { wrapper });
purchaseResult.current.mutate({ phone_number: '+15551234567' });
await waitFor(() => expect(purchaseResult.current.isSuccess).toBe(true));
// Refetch numbers
await numbersResult.current.refetch();
expect(numbersResult.current.data?.count).toBe(1);
expect(numbersResult.current.data?.numbers[0]).toEqual(mockPhoneNumber);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,664 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
// Mock the customDomains API
vi.mock('../../api/customDomains', () => ({
getCustomDomains: vi.fn(),
addCustomDomain: vi.fn(),
deleteCustomDomain: vi.fn(),
verifyCustomDomain: vi.fn(),
setPrimaryDomain: vi.fn(),
}));
import {
useCustomDomains,
useAddCustomDomain,
useDeleteCustomDomain,
useVerifyCustomDomain,
useSetPrimaryDomain,
} from '../useCustomDomains';
import * as customDomainsApi from '../../api/customDomains';
// Create wrapper with fresh QueryClient for each test
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return React.createElement(QueryClientProvider, { client: queryClient }, children);
};
};
// Mock data
const mockCustomDomain = {
id: 1,
domain: 'example.com',
is_verified: true,
ssl_provisioned: true,
is_primary: true,
verification_token: 'abc123',
dns_txt_record: 'smoothschedule-verify=abc123',
dns_txt_record_name: '_smoothschedule',
created_at: '2024-01-01T00:00:00Z',
verified_at: '2024-01-01T12:00:00Z',
};
const mockUnverifiedDomain = {
id: 2,
domain: 'test.com',
is_verified: false,
ssl_provisioned: false,
is_primary: false,
verification_token: 'xyz789',
dns_txt_record: 'smoothschedule-verify=xyz789',
dns_txt_record_name: '_smoothschedule',
created_at: '2024-01-02T00:00:00Z',
};
const mockCustomDomains = [mockCustomDomain, mockUnverifiedDomain];
describe('useCustomDomains hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ============================================
// Query Hook - useCustomDomains
// ============================================
describe('useCustomDomains', () => {
it('fetches all custom domains successfully', async () => {
vi.mocked(customDomainsApi.getCustomDomains).mockResolvedValue(mockCustomDomains);
const { result } = renderHook(() => useCustomDomains(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(customDomainsApi.getCustomDomains).toHaveBeenCalledTimes(1);
expect(result.current.data).toEqual(mockCustomDomains);
expect(result.current.data).toHaveLength(2);
});
it('returns empty array when no domains exist', async () => {
vi.mocked(customDomainsApi.getCustomDomains).mockResolvedValue([]);
const { result } = renderHook(() => useCustomDomains(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual([]);
expect(result.current.data).toHaveLength(0);
});
it('handles fetch errors without retrying', async () => {
vi.mocked(customDomainsApi.getCustomDomains).mockRejectedValue(
new Error('Failed to fetch domains')
);
const { result } = renderHook(() => useCustomDomains(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(new Error('Failed to fetch domains'));
expect(customDomainsApi.getCustomDomains).toHaveBeenCalledTimes(1); // No retry
});
it('uses staleTime of 5 minutes', async () => {
vi.mocked(customDomainsApi.getCustomDomains).mockResolvedValue(mockCustomDomains);
const { result } = renderHook(() => useCustomDomains(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.dataUpdatedAt).toBeGreaterThan(0);
});
it('handles 404 errors gracefully', async () => {
const notFoundError = new Error('Not found');
vi.mocked(customDomainsApi.getCustomDomains).mockRejectedValue(notFoundError);
const { result } = renderHook(() => useCustomDomains(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(notFoundError);
expect(customDomainsApi.getCustomDomains).toHaveBeenCalledTimes(1);
});
});
// ============================================
// Mutation Hook - useAddCustomDomain
// ============================================
describe('useAddCustomDomain', () => {
it('adds a new custom domain successfully', async () => {
const newDomain = { ...mockUnverifiedDomain, domain: 'newdomain.com' };
vi.mocked(customDomainsApi.addCustomDomain).mockResolvedValue(newDomain);
const { result } = renderHook(() => useAddCustomDomain(), {
wrapper: createWrapper(),
});
let addedDomain;
await act(async () => {
addedDomain = await result.current.mutateAsync('newdomain.com');
});
expect(customDomainsApi.addCustomDomain).toHaveBeenCalledWith(
'newdomain.com',
expect.anything()
);
expect(addedDomain).toEqual(newDomain);
});
it('invalidates customDomains query on success', async () => {
const newDomain = { ...mockUnverifiedDomain, domain: 'another.com' };
vi.mocked(customDomainsApi.getCustomDomains)
.mockResolvedValueOnce(mockCustomDomains)
.mockResolvedValueOnce([...mockCustomDomains, newDomain]);
vi.mocked(customDomainsApi.addCustomDomain).mockResolvedValue(newDomain);
const wrapper = createWrapper();
// First fetch custom domains
const { result: domainsResult } = renderHook(() => useCustomDomains(), {
wrapper,
});
await waitFor(() => {
expect(domainsResult.current.isSuccess).toBe(true);
});
expect(domainsResult.current.data).toHaveLength(2);
// Add a new domain
const { result: addResult } = renderHook(() => useAddCustomDomain(), {
wrapper,
});
await act(async () => {
await addResult.current.mutateAsync('another.com');
});
// Verify customDomains was invalidated and refetched
await waitFor(() => {
expect(customDomainsApi.getCustomDomains).toHaveBeenCalledTimes(2);
});
});
it('handles add domain errors', async () => {
vi.mocked(customDomainsApi.addCustomDomain).mockRejectedValue(
new Error('Domain already exists')
);
const { result } = renderHook(() => useAddCustomDomain(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync('example.com');
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(new Error('Domain already exists'));
});
it('handles domain with uppercase and whitespace', async () => {
const newDomain = { ...mockUnverifiedDomain, domain: 'test.com' };
vi.mocked(customDomainsApi.addCustomDomain).mockResolvedValue(newDomain);
const { result } = renderHook(() => useAddCustomDomain(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync(' TEST.COM ');
});
// API should normalize the domain
expect(customDomainsApi.addCustomDomain).toHaveBeenCalledWith(
' TEST.COM ',
expect.anything()
);
});
});
// ============================================
// Mutation Hook - useDeleteCustomDomain
// ============================================
describe('useDeleteCustomDomain', () => {
it('deletes a custom domain successfully', async () => {
vi.mocked(customDomainsApi.deleteCustomDomain).mockResolvedValue(undefined);
const { result } = renderHook(() => useDeleteCustomDomain(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync(2);
});
expect(customDomainsApi.deleteCustomDomain).toHaveBeenCalledWith(2, expect.anything());
});
it('invalidates customDomains query on success', async () => {
vi.mocked(customDomainsApi.getCustomDomains)
.mockResolvedValueOnce(mockCustomDomains)
.mockResolvedValueOnce([mockCustomDomain]); // After delete
vi.mocked(customDomainsApi.deleteCustomDomain).mockResolvedValue(undefined);
const wrapper = createWrapper();
// First fetch custom domains
const { result: domainsResult } = renderHook(() => useCustomDomains(), {
wrapper,
});
await waitFor(() => {
expect(domainsResult.current.isSuccess).toBe(true);
});
expect(domainsResult.current.data).toHaveLength(2);
// Delete a domain
const { result: deleteResult } = renderHook(() => useDeleteCustomDomain(), {
wrapper,
});
await act(async () => {
await deleteResult.current.mutateAsync(2);
});
// Verify customDomains was invalidated and refetched
await waitFor(() => {
expect(customDomainsApi.getCustomDomains).toHaveBeenCalledTimes(2);
});
});
it('handles delete domain errors', async () => {
vi.mocked(customDomainsApi.deleteCustomDomain).mockRejectedValue(
new Error('Cannot delete primary domain')
);
const { result } = renderHook(() => useDeleteCustomDomain(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync(1);
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(new Error('Cannot delete primary domain'));
});
it('handles 404 errors for non-existent domains', async () => {
vi.mocked(customDomainsApi.deleteCustomDomain).mockRejectedValue(
new Error('Domain not found')
);
const { result } = renderHook(() => useDeleteCustomDomain(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync(999);
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(new Error('Domain not found'));
});
});
// ============================================
// Mutation Hook - useVerifyCustomDomain
// ============================================
describe('useVerifyCustomDomain', () => {
it('verifies a custom domain successfully', async () => {
const verifyResponse = { verified: true, message: 'Domain verified successfully' };
vi.mocked(customDomainsApi.verifyCustomDomain).mockResolvedValue(verifyResponse);
const { result } = renderHook(() => useVerifyCustomDomain(), {
wrapper: createWrapper(),
});
let verifyResult;
await act(async () => {
verifyResult = await result.current.mutateAsync(2);
});
expect(customDomainsApi.verifyCustomDomain).toHaveBeenCalledWith(2, expect.anything());
expect(verifyResult).toEqual(verifyResponse);
});
it('returns failure when verification fails', async () => {
const verifyResponse = {
verified: false,
message: 'TXT record not found. Please add the DNS record and try again.',
};
vi.mocked(customDomainsApi.verifyCustomDomain).mockResolvedValue(verifyResponse);
const { result } = renderHook(() => useVerifyCustomDomain(), {
wrapper: createWrapper(),
});
let verifyResult;
await act(async () => {
verifyResult = await result.current.mutateAsync(2);
});
expect(verifyResult).toEqual(verifyResponse);
expect(verifyResult?.verified).toBe(false);
});
it('invalidates customDomains query on success', async () => {
const verifyResponse = { verified: true, message: 'Domain verified' };
const verifiedDomain = { ...mockUnverifiedDomain, is_verified: true };
vi.mocked(customDomainsApi.getCustomDomains)
.mockResolvedValueOnce(mockCustomDomains)
.mockResolvedValueOnce([mockCustomDomain, verifiedDomain]);
vi.mocked(customDomainsApi.verifyCustomDomain).mockResolvedValue(verifyResponse);
const wrapper = createWrapper();
// First fetch custom domains
const { result: domainsResult } = renderHook(() => useCustomDomains(), {
wrapper,
});
await waitFor(() => {
expect(domainsResult.current.isSuccess).toBe(true);
});
// Verify a domain
const { result: verifyResult } = renderHook(() => useVerifyCustomDomain(), {
wrapper,
});
await act(async () => {
await verifyResult.current.mutateAsync(2);
});
// Verify customDomains was invalidated and refetched
await waitFor(() => {
expect(customDomainsApi.getCustomDomains).toHaveBeenCalledTimes(2);
});
});
it('handles verification errors', async () => {
vi.mocked(customDomainsApi.verifyCustomDomain).mockRejectedValue(
new Error('Verification service unavailable')
);
const { result } = renderHook(() => useVerifyCustomDomain(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync(2);
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(new Error('Verification service unavailable'));
});
it('invalidates even on failed verification (not error)', async () => {
const verifyResponse = { verified: false, message: 'TXT record not found' };
vi.mocked(customDomainsApi.getCustomDomains).mockResolvedValue(mockCustomDomains);
vi.mocked(customDomainsApi.verifyCustomDomain).mockResolvedValue(verifyResponse);
const wrapper = createWrapper();
const { result: domainsResult } = renderHook(() => useCustomDomains(), {
wrapper,
});
await waitFor(() => {
expect(domainsResult.current.isSuccess).toBe(true);
});
const { result: verifyResult } = renderHook(() => useVerifyCustomDomain(), {
wrapper,
});
await act(async () => {
await verifyResult.current.mutateAsync(2);
});
// Should still invalidate even though verified=false
await waitFor(() => {
expect(customDomainsApi.getCustomDomains).toHaveBeenCalledTimes(2);
});
});
});
// ============================================
// Mutation Hook - useSetPrimaryDomain
// ============================================
describe('useSetPrimaryDomain', () => {
it('sets a domain as primary successfully', async () => {
const updatedDomain = { ...mockUnverifiedDomain, is_primary: true };
vi.mocked(customDomainsApi.setPrimaryDomain).mockResolvedValue(updatedDomain);
const { result } = renderHook(() => useSetPrimaryDomain(), {
wrapper: createWrapper(),
});
let primaryDomain;
await act(async () => {
primaryDomain = await result.current.mutateAsync(2);
});
expect(customDomainsApi.setPrimaryDomain).toHaveBeenCalledWith(2, expect.anything());
expect(primaryDomain).toEqual(updatedDomain);
expect(primaryDomain?.is_primary).toBe(true);
});
it('invalidates customDomains query on success', async () => {
const updatedPrimaryDomain = { ...mockUnverifiedDomain, is_primary: true };
const oldPrimaryDomain = { ...mockCustomDomain, is_primary: false };
vi.mocked(customDomainsApi.getCustomDomains)
.mockResolvedValueOnce(mockCustomDomains)
.mockResolvedValueOnce([oldPrimaryDomain, updatedPrimaryDomain]);
vi.mocked(customDomainsApi.setPrimaryDomain).mockResolvedValue(updatedPrimaryDomain);
const wrapper = createWrapper();
// First fetch custom domains
const { result: domainsResult } = renderHook(() => useCustomDomains(), {
wrapper,
});
await waitFor(() => {
expect(domainsResult.current.isSuccess).toBe(true);
});
// Set new primary domain
const { result: setPrimaryResult } = renderHook(() => useSetPrimaryDomain(), {
wrapper,
});
await act(async () => {
await setPrimaryResult.current.mutateAsync(2);
});
// Verify customDomains was invalidated and refetched
await waitFor(() => {
expect(customDomainsApi.getCustomDomains).toHaveBeenCalledTimes(2);
});
});
it('handles set primary domain errors', async () => {
vi.mocked(customDomainsApi.setPrimaryDomain).mockRejectedValue(
new Error('Domain must be verified before setting as primary')
);
const { result } = renderHook(() => useSetPrimaryDomain(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync(2);
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(
new Error('Domain must be verified before setting as primary')
);
});
it('handles non-existent domain errors', async () => {
vi.mocked(customDomainsApi.setPrimaryDomain).mockRejectedValue(
new Error('Domain not found')
);
const { result } = renderHook(() => useSetPrimaryDomain(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync(999);
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(new Error('Domain not found'));
});
});
// ============================================
// Integration Tests
// ============================================
describe('Integration - Query invalidation', () => {
it('all mutations invalidate the customDomains query', async () => {
const wrapper = createWrapper();
vi.mocked(customDomainsApi.getCustomDomains).mockResolvedValue(mockCustomDomains);
vi.mocked(customDomainsApi.addCustomDomain).mockResolvedValue(mockUnverifiedDomain);
vi.mocked(customDomainsApi.deleteCustomDomain).mockResolvedValue(undefined);
vi.mocked(customDomainsApi.verifyCustomDomain).mockResolvedValue({
verified: true,
message: 'Success',
});
vi.mocked(customDomainsApi.setPrimaryDomain).mockResolvedValue(mockCustomDomain);
// Initial fetch
const { result: queryResult } = renderHook(() => useCustomDomains(), {
wrapper,
});
await waitFor(() => {
expect(queryResult.current.isSuccess).toBe(true);
});
expect(customDomainsApi.getCustomDomains).toHaveBeenCalledTimes(1);
// Test add mutation
const { result: addResult } = renderHook(() => useAddCustomDomain(), {
wrapper,
});
await act(async () => {
await addResult.current.mutateAsync('new.com');
});
await waitFor(() => {
expect(customDomainsApi.getCustomDomains).toHaveBeenCalledTimes(2);
});
// Test delete mutation
const { result: deleteResult } = renderHook(() => useDeleteCustomDomain(), {
wrapper,
});
await act(async () => {
await deleteResult.current.mutateAsync(1);
});
await waitFor(() => {
expect(customDomainsApi.getCustomDomains).toHaveBeenCalledTimes(3);
});
// Test verify mutation
const { result: verifyResult } = renderHook(() => useVerifyCustomDomain(), {
wrapper,
});
await act(async () => {
await verifyResult.current.mutateAsync(2);
});
await waitFor(() => {
expect(customDomainsApi.getCustomDomains).toHaveBeenCalledTimes(4);
});
// Test setPrimary mutation
const { result: setPrimaryResult } = renderHook(() => useSetPrimaryDomain(), {
wrapper,
});
await act(async () => {
await setPrimaryResult.current.mutateAsync(2);
});
await waitFor(() => {
expect(customDomainsApi.getCustomDomains).toHaveBeenCalledTimes(5);
});
});
});
});

View File

@@ -0,0 +1,687 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
// Mock the API client
vi.mock('../../api/client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}));
import {
useCustomerBilling,
useCustomerPaymentMethods,
useCreateSetupIntent,
useDeletePaymentMethod,
useSetDefaultPaymentMethod,
} from '../useCustomerBilling';
import apiClient from '../../api/client';
// Create wrapper with fresh QueryClient for each test
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return React.createElement(QueryClientProvider, { client: queryClient }, children);
};
};
describe('useCustomerBilling hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useCustomerBilling', () => {
it('fetches customer billing data successfully', async () => {
const mockBillingData = {
outstanding: [
{
id: 1,
title: 'Haircut Appointment',
service_name: 'Basic Haircut',
amount: 5000,
amount_display: '$50.00',
status: 'confirmed',
start_time: '2025-12-08T10:00:00Z',
end_time: '2025-12-08T10:30:00Z',
payment_status: 'unpaid' as const,
payment_intent_id: null,
},
{
id: 2,
title: 'Massage Session',
service_name: 'Deep Tissue Massage',
amount: 8000,
amount_display: '$80.00',
status: 'confirmed',
start_time: '2025-12-09T14:00:00Z',
end_time: '2025-12-09T15:00:00Z',
payment_status: 'pending' as const,
payment_intent_id: 'pi_123456',
},
],
payment_history: [
{
id: 1,
event_id: 100,
event_title: 'Haircut - John Doe',
service_name: 'Premium Haircut',
amount: 7500,
amount_display: '$75.00',
currency: 'usd',
status: 'succeeded',
payment_intent_id: 'pi_completed_123',
created_at: '2025-12-01T10:00:00Z',
completed_at: '2025-12-01T10:05:00Z',
event_date: '2025-12-01T14:00:00Z',
},
{
id: 2,
event_id: 101,
event_title: 'Spa Treatment',
service_name: 'Facial Treatment',
amount: 12000,
amount_display: '$120.00',
currency: 'usd',
status: 'succeeded',
payment_intent_id: 'pi_completed_456',
created_at: '2025-11-28T09:00:00Z',
completed_at: '2025-11-28T09:02:00Z',
event_date: '2025-11-28T15:30:00Z',
},
],
summary: {
total_spent: 19500,
total_spent_display: '$195.00',
total_outstanding: 13000,
total_outstanding_display: '$130.00',
payment_count: 2,
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBillingData } as any);
const { result } = renderHook(() => useCustomerBilling(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(apiClient.get).toHaveBeenCalledWith('/payments/customer/billing/');
expect(apiClient.get).toHaveBeenCalledTimes(1);
expect(result.current.data).toEqual(mockBillingData);
});
it('handles empty billing data', async () => {
const mockEmptyData = {
outstanding: [],
payment_history: [],
summary: {
total_spent: 0,
total_spent_display: '$0.00',
total_outstanding: 0,
total_outstanding_display: '$0.00',
payment_count: 0,
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockEmptyData } as any);
const { result } = renderHook(() => useCustomerBilling(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.outstanding).toHaveLength(0);
expect(result.current.data?.payment_history).toHaveLength(0);
expect(result.current.data?.summary.payment_count).toBe(0);
});
it('handles API errors gracefully', async () => {
const mockError = new Error('Failed to fetch billing data');
vi.mocked(apiClient.get).mockRejectedValue(mockError);
const { result } = renderHook(() => useCustomerBilling(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(mockError);
});
it('uses 30 second staleTime', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: { outstanding: [], payment_history: [], summary: {} } } as any);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
renderHook(() => useCustomerBilling(), { wrapper });
await waitFor(() => {
const queryState = queryClient.getQueryState(['customerBilling']);
expect(queryState).toBeDefined();
});
const queryState = queryClient.getQueryState(['customerBilling']);
expect(queryState?.dataUpdatedAt).toBeDefined();
});
it('does not retry on failure', async () => {
vi.mocked(apiClient.get).mockRejectedValue(new Error('Network error'));
const { result } = renderHook(() => useCustomerBilling(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
// Should only be called once (no retries)
expect(apiClient.get).toHaveBeenCalledTimes(1);
});
});
describe('useCustomerPaymentMethods', () => {
it('fetches payment methods successfully', async () => {
const mockPaymentMethods = {
payment_methods: [
{
id: 'pm_123456',
type: 'card',
brand: 'visa',
last4: '4242',
exp_month: 12,
exp_year: 2025,
is_default: true,
},
{
id: 'pm_789012',
type: 'card',
brand: 'mastercard',
last4: '5555',
exp_month: 6,
exp_year: 2026,
is_default: false,
},
],
has_stripe_customer: true,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockPaymentMethods } as any);
const { result } = renderHook(() => useCustomerPaymentMethods(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(apiClient.get).toHaveBeenCalledWith('/payments/customer/payment-methods/');
expect(apiClient.get).toHaveBeenCalledTimes(1);
expect(result.current.data).toEqual(mockPaymentMethods);
expect(result.current.data?.payment_methods).toHaveLength(2);
});
it('handles no payment methods', async () => {
const mockNoPaymentMethods = {
payment_methods: [],
has_stripe_customer: false,
message: 'No payment methods found',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockNoPaymentMethods } as any);
const { result } = renderHook(() => useCustomerPaymentMethods(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.payment_methods).toHaveLength(0);
expect(result.current.data?.has_stripe_customer).toBe(false);
expect(result.current.data?.message).toBe('No payment methods found');
});
it('handles API errors gracefully', async () => {
const mockError = new Error('Failed to fetch payment methods');
vi.mocked(apiClient.get).mockRejectedValue(mockError);
const { result } = renderHook(() => useCustomerPaymentMethods(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(mockError);
});
it('uses 60 second staleTime', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: { payment_methods: [], has_stripe_customer: false } } as any);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
renderHook(() => useCustomerPaymentMethods(), { wrapper });
await waitFor(() => {
const queryState = queryClient.getQueryState(['customerPaymentMethods']);
expect(queryState).toBeDefined();
});
const queryState = queryClient.getQueryState(['customerPaymentMethods']);
expect(queryState?.dataUpdatedAt).toBeDefined();
});
it('does not retry on failure', async () => {
vi.mocked(apiClient.get).mockRejectedValue(new Error('Network error'));
const { result } = renderHook(() => useCustomerPaymentMethods(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
// Should only be called once (no retries)
expect(apiClient.get).toHaveBeenCalledTimes(1);
});
});
describe('useCreateSetupIntent', () => {
it('creates setup intent successfully', async () => {
const mockSetupIntent = {
client_secret: 'seti_123_secret_456',
setup_intent_id: 'seti_123456',
customer_id: 'cus_789012',
stripe_account: '',
publishable_key: 'pk_test_123456',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockSetupIntent } as any);
const { result } = renderHook(() => useCreateSetupIntent(), {
wrapper: createWrapper(),
});
await act(async () => {
const response = await result.current.mutateAsync();
expect(response).toEqual(mockSetupIntent);
});
expect(apiClient.post).toHaveBeenCalledWith('/payments/customer/setup-intent/');
expect(apiClient.post).toHaveBeenCalledTimes(1);
});
it('creates setup intent with connected account', async () => {
const mockSetupIntent = {
client_secret: 'seti_123_secret_789',
setup_intent_id: 'seti_789012',
customer_id: 'cus_345678',
stripe_account: 'acct_connect_123',
publishable_key: undefined,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockSetupIntent } as any);
const { result } = renderHook(() => useCreateSetupIntent(), {
wrapper: createWrapper(),
});
let response;
await act(async () => {
response = await result.current.mutateAsync();
});
expect(response).toEqual(mockSetupIntent);
expect(response.stripe_account).toBe('acct_connect_123');
});
it('handles setup intent creation errors', async () => {
const mockError = new Error('Failed to create setup intent');
vi.mocked(apiClient.post).mockRejectedValue(mockError);
const { result } = renderHook(() => useCreateSetupIntent(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync();
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(mockError);
});
it('tracks mutation loading state', async () => {
vi.mocked(apiClient.post).mockImplementation(
() =>
new Promise((resolve) =>
setTimeout(() => resolve({ data: { client_secret: 'seti_test' } }), 50)
)
);
const { result } = renderHook(() => useCreateSetupIntent(), {
wrapper: createWrapper(),
});
expect(result.current.isPending).toBe(false);
const promise = act(async () => {
await result.current.mutateAsync();
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
await promise;
});
});
describe('useDeletePaymentMethod', () => {
it('deletes payment method successfully', async () => {
const mockDeleteResponse = {
success: true,
message: 'Payment method deleted successfully',
};
vi.mocked(apiClient.delete).mockResolvedValue({ data: mockDeleteResponse } as any);
const { result } = renderHook(() => useDeletePaymentMethod(), {
wrapper: createWrapper(),
});
await act(async () => {
const response = await result.current.mutateAsync('pm_123456');
expect(response).toEqual(mockDeleteResponse);
});
expect(apiClient.delete).toHaveBeenCalledWith('/payments/customer/payment-methods/pm_123456/');
expect(apiClient.delete).toHaveBeenCalledTimes(1);
});
it('invalidates payment methods query on success', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({
data: { success: true, message: 'Deleted' },
} as any);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries');
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
const { result } = renderHook(() => useDeletePaymentMethod(), { wrapper });
await act(async () => {
await result.current.mutateAsync('pm_123456');
});
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['customerPaymentMethods'] });
});
it('handles delete errors gracefully', async () => {
const mockError = new Error('Cannot delete default payment method');
vi.mocked(apiClient.delete).mockRejectedValue(mockError);
const { result } = renderHook(() => useDeletePaymentMethod(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync('pm_123456');
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(mockError);
});
it('does not invalidate queries on error', async () => {
vi.mocked(apiClient.delete).mockRejectedValue(new Error('Delete failed'));
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries');
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
const { result } = renderHook(() => useDeletePaymentMethod(), { wrapper });
await act(async () => {
try {
await result.current.mutateAsync('pm_123456');
} catch {
// Expected to fail
}
});
expect(invalidateQueriesSpy).not.toHaveBeenCalled();
});
it('handles multiple payment method deletions', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({
data: { success: true, message: 'Deleted' },
} as any);
const { result } = renderHook(() => useDeletePaymentMethod(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync('pm_111111');
});
await act(async () => {
await result.current.mutateAsync('pm_222222');
});
expect(apiClient.delete).toHaveBeenCalledTimes(2);
expect(apiClient.delete).toHaveBeenNthCalledWith(1, '/payments/customer/payment-methods/pm_111111/');
expect(apiClient.delete).toHaveBeenNthCalledWith(2, '/payments/customer/payment-methods/pm_222222/');
});
});
describe('useSetDefaultPaymentMethod', () => {
it('sets default payment method successfully', async () => {
const mockSetDefaultResponse = {
success: true,
message: 'Default payment method updated',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockSetDefaultResponse } as any);
const { result } = renderHook(() => useSetDefaultPaymentMethod(), {
wrapper: createWrapper(),
});
await act(async () => {
const response = await result.current.mutateAsync('pm_789012');
expect(response).toEqual(mockSetDefaultResponse);
});
expect(apiClient.post).toHaveBeenCalledWith('/payments/customer/payment-methods/pm_789012/default/');
expect(apiClient.post).toHaveBeenCalledTimes(1);
});
it('invalidates payment methods query on success', async () => {
vi.mocked(apiClient.post).mockResolvedValue({
data: { success: true, message: 'Updated' },
} as any);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries');
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
const { result } = renderHook(() => useSetDefaultPaymentMethod(), { wrapper });
await act(async () => {
await result.current.mutateAsync('pm_789012');
});
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['customerPaymentMethods'] });
});
it('handles set default errors gracefully', async () => {
const mockError = new Error('Payment method not found');
vi.mocked(apiClient.post).mockRejectedValue(mockError);
const { result } = renderHook(() => useSetDefaultPaymentMethod(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync('pm_invalid');
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(mockError);
});
it('does not invalidate queries on error', async () => {
vi.mocked(apiClient.post).mockRejectedValue(new Error('Update failed'));
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries');
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
const { result } = renderHook(() => useSetDefaultPaymentMethod(), { wrapper });
await act(async () => {
try {
await result.current.mutateAsync('pm_123456');
} catch {
// Expected to fail
}
});
expect(invalidateQueriesSpy).not.toHaveBeenCalled();
});
it('handles switching default between payment methods', async () => {
vi.mocked(apiClient.post).mockResolvedValue({
data: { success: true, message: 'Updated' },
} as any);
const { result } = renderHook(() => useSetDefaultPaymentMethod(), {
wrapper: createWrapper(),
});
// Set first method as default
await act(async () => {
await result.current.mutateAsync('pm_111111');
});
// Switch to second method as default
await act(async () => {
await result.current.mutateAsync('pm_222222');
});
expect(apiClient.post).toHaveBeenCalledTimes(2);
expect(apiClient.post).toHaveBeenNthCalledWith(1, '/payments/customer/payment-methods/pm_111111/default/');
expect(apiClient.post).toHaveBeenNthCalledWith(2, '/payments/customer/payment-methods/pm_222222/default/');
});
it('tracks mutation loading state', async () => {
vi.mocked(apiClient.post).mockImplementation(
() =>
new Promise((resolve) =>
setTimeout(() => resolve({ data: { success: true, message: 'Updated' } }), 50)
)
);
const { result } = renderHook(() => useSetDefaultPaymentMethod(), {
wrapper: createWrapper(),
});
expect(result.current.isPending).toBe(false);
const promise = act(async () => {
await result.current.mutateAsync('pm_123456');
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
await promise;
});
});
});

View File

@@ -0,0 +1,224 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
// Mock apiClient
vi.mock('../../api/client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
import {
useCustomers,
useCreateCustomer,
useUpdateCustomer,
useDeleteCustomer,
} from '../useCustomers';
import apiClient from '../../api/client';
// Create wrapper
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return React.createElement(QueryClientProvider, { client: queryClient }, children);
};
};
describe('useCustomers hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useCustomers', () => {
it('fetches customers and transforms data', async () => {
const mockCustomers = [
{
id: 1,
name: 'John Doe',
email: 'john@example.com',
phone: '555-1234',
total_spend: '150.00',
status: 'Active',
user_id: 10,
},
{
id: 2,
user: { name: 'Jane Smith', email: 'jane@example.com' },
phone: '',
total_spend: '0',
status: 'Inactive',
user: 20,
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockCustomers });
const { result } = renderHook(() => useCustomers(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(apiClient.get).toHaveBeenCalledWith('/customers/?');
expect(result.current.data).toHaveLength(2);
expect(result.current.data?.[0]).toEqual(expect.objectContaining({
id: '1',
name: 'John Doe',
email: 'john@example.com',
totalSpend: 150,
status: 'Active',
}));
});
it('applies status filter', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
renderHook(() => useCustomers({ status: 'Active' }), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(apiClient.get).toHaveBeenCalledWith('/customers/?status=Active');
});
});
it('applies search filter', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
renderHook(() => useCustomers({ search: 'john' }), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(apiClient.get).toHaveBeenCalledWith('/customers/?search=john');
});
});
it('applies multiple filters', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
renderHook(() => useCustomers({ status: 'Blocked', search: 'test' }), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(apiClient.get).toHaveBeenCalledWith('/customers/?status=Blocked&search=test');
});
});
it('handles customers with last_visit date', async () => {
const mockCustomers = [
{
id: 1,
name: 'Customer',
email: 'c@example.com',
total_spend: '0',
last_visit: '2024-01-15T10:00:00Z',
user_id: 1,
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockCustomers });
const { result } = renderHook(() => useCustomers(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.[0].lastVisit).toBeInstanceOf(Date);
});
});
describe('useCreateCustomer', () => {
it('creates customer with field mapping', async () => {
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1 } });
const { result } = renderHook(() => useCreateCustomer(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
userId: '5',
phone: '555-9999',
city: 'Denver',
state: 'CO',
zip: '80202',
status: 'Active',
});
});
expect(apiClient.post).toHaveBeenCalledWith('/customers/', {
user: 5,
phone: '555-9999',
city: 'Denver',
state: 'CO',
zip: '80202',
status: 'Active',
avatar_url: undefined,
tags: undefined,
});
});
});
describe('useUpdateCustomer', () => {
it('updates customer with mapped fields', async () => {
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
const { result } = renderHook(() => useUpdateCustomer(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
id: '1',
updates: {
phone: '555-0000',
status: 'Blocked',
tags: ['vip'],
},
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/customers/1/', {
phone: '555-0000',
city: undefined,
state: undefined,
zip: undefined,
status: 'Blocked',
avatar_url: undefined,
tags: ['vip'],
});
});
});
describe('useDeleteCustomer', () => {
it('deletes customer by id', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
const { result } = renderHook(() => useDeleteCustomer(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync('7');
});
expect(apiClient.delete).toHaveBeenCalledWith('/customers/7/');
});
});
});

View File

@@ -0,0 +1,958 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
// Mock the domains API
vi.mock('../../api/domains', () => ({
searchDomains: vi.fn(),
getDomainPrices: vi.fn(),
registerDomain: vi.fn(),
getRegisteredDomains: vi.fn(),
getDomainRegistration: vi.fn(),
updateNameservers: vi.fn(),
toggleAutoRenew: vi.fn(),
renewDomain: vi.fn(),
syncDomain: vi.fn(),
getSearchHistory: vi.fn(),
}));
import {
useDomainSearch,
useDomainPrices,
useRegisterDomain,
useRegisteredDomains,
useDomainRegistration,
useUpdateNameservers,
useToggleAutoRenew,
useRenewDomain,
useSyncDomain,
useSearchHistory,
} from '../useDomains';
import * as domainsApi from '../../api/domains';
// Create wrapper with fresh QueryClient for each test
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return React.createElement(QueryClientProvider, { client: queryClient }, children);
};
};
// Mock data
const mockDomainAvailability = [
{
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,
},
];
const mockDomainPrices = [
{ tld: '.com', registration: 12.99, renewal: 12.99, transfer: 12.99 },
{ tld: '.net', registration: 14.99, renewal: 14.99, transfer: 14.99 },
{ tld: '.org', registration: 13.99, renewal: 13.99, transfer: 13.99 },
];
const mockDomainRegistration = {
id: 1,
domain: 'example.com',
status: 'active' as const,
registered_at: '2024-01-01T00:00:00Z',
expires_at: '2025-01-01T00:00:00Z',
auto_renew: true,
whois_privacy: true,
purchase_price: 12.99,
renewal_price: 12.99,
nameservers: ['ns1.smoothschedule.com', 'ns2.smoothschedule.com'],
days_until_expiry: 365,
is_expiring_soon: false,
created_at: '2024-01-01T00:00:00Z',
registrant_first_name: 'John',
registrant_last_name: 'Doe',
registrant_email: 'john@example.com',
};
const mockRegisteredDomains = [
mockDomainRegistration,
{
...mockDomainRegistration,
id: 2,
domain: 'another.com',
auto_renew: false,
},
];
const mockSearchHistory = [
{
id: 1,
searched_domain: 'example.com',
was_available: true,
price: 12.99,
searched_at: '2024-01-01T00:00:00Z',
},
{
id: 2,
searched_domain: 'taken.com',
was_available: false,
price: null,
searched_at: '2024-01-02T00:00:00Z',
},
];
const mockRegisterRequest = {
domain: 'example.com',
years: 1,
whois_privacy: true,
auto_renew: true,
nameservers: ['ns1.smoothschedule.com', 'ns2.smoothschedule.com'],
contact: {
first_name: 'John',
last_name: 'Doe',
email: 'john@example.com',
phone: '+1234567890',
address: '123 Main St',
city: 'New York',
state: 'NY',
zip_code: '10001',
country: 'US',
},
auto_configure: true,
};
describe('useDomains hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ============================================
// Search & Pricing
// ============================================
describe('useDomainSearch', () => {
it('searches for domain availability', async () => {
vi.mocked(domainsApi.searchDomains).mockResolvedValue(mockDomainAvailability);
const { result } = renderHook(() => useDomainSearch(), {
wrapper: createWrapper(),
});
let searchData;
await act(async () => {
searchData = await result.current.mutateAsync({
query: 'example',
tlds: ['.com', '.net'],
});
});
expect(domainsApi.searchDomains).toHaveBeenCalledWith('example', ['.com', '.net']);
expect(searchData).toEqual(mockDomainAvailability);
});
it('uses default TLDs when not provided', async () => {
vi.mocked(domainsApi.searchDomains).mockResolvedValue(mockDomainAvailability);
const { result } = renderHook(() => useDomainSearch(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({ query: 'example' });
});
expect(domainsApi.searchDomains).toHaveBeenCalledWith('example', undefined);
});
it('invalidates search history on successful search', async () => {
vi.mocked(domainsApi.searchDomains).mockResolvedValue(mockDomainAvailability);
vi.mocked(domainsApi.getSearchHistory).mockResolvedValue(mockSearchHistory);
const wrapper = createWrapper();
// First render the search history hook
const { result: historyResult } = renderHook(() => useSearchHistory(), {
wrapper,
});
await waitFor(() => {
expect(historyResult.current.isSuccess).toBe(true);
});
// Now perform a search which should invalidate history
const { result: searchResult } = renderHook(() => useDomainSearch(), {
wrapper,
});
await act(async () => {
await searchResult.current.mutateAsync({ query: 'example' });
});
// Verify search history was called again (invalidated and refetched)
await waitFor(() => {
expect(domainsApi.getSearchHistory).toHaveBeenCalledTimes(2);
});
});
it('handles search errors', async () => {
vi.mocked(domainsApi.searchDomains).mockRejectedValue(new Error('Search failed'));
const { result } = renderHook(() => useDomainSearch(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync({ query: 'example' });
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(new Error('Search failed'));
});
});
describe('useDomainPrices', () => {
it('fetches domain prices', async () => {
vi.mocked(domainsApi.getDomainPrices).mockResolvedValue(mockDomainPrices);
const { result } = renderHook(() => useDomainPrices(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(domainsApi.getDomainPrices).toHaveBeenCalledTimes(1);
expect(result.current.data).toEqual(mockDomainPrices);
});
it('uses staleTime of 5 minutes', async () => {
vi.mocked(domainsApi.getDomainPrices).mockResolvedValue(mockDomainPrices);
const { result } = renderHook(() => useDomainPrices(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
// Verify staleTime is configured
expect(result.current.dataUpdatedAt).toBeGreaterThan(0);
});
it('handles price fetch errors', async () => {
vi.mocked(domainsApi.getDomainPrices).mockRejectedValue(new Error('Price fetch failed'));
const { result } = renderHook(() => useDomainPrices(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(new Error('Price fetch failed'));
});
});
// ============================================
// Registration
// ============================================
describe('useRegisterDomain', () => {
it('registers a new domain', async () => {
vi.mocked(domainsApi.registerDomain).mockResolvedValue(mockDomainRegistration);
const { result } = renderHook(() => useRegisterDomain(), {
wrapper: createWrapper(),
});
let registrationData;
await act(async () => {
registrationData = await result.current.mutateAsync(mockRegisterRequest);
});
expect(domainsApi.registerDomain).toHaveBeenCalledWith(mockRegisterRequest);
expect(registrationData).toEqual(mockDomainRegistration);
});
it('invalidates registrations and customDomains on success', async () => {
vi.mocked(domainsApi.registerDomain).mockResolvedValue(mockDomainRegistration);
vi.mocked(domainsApi.getRegisteredDomains).mockResolvedValue(mockRegisteredDomains);
const wrapper = createWrapper();
// First render registered domains
const { result: domainsResult } = renderHook(() => useRegisteredDomains(), {
wrapper,
});
await waitFor(() => {
expect(domainsResult.current.isSuccess).toBe(true);
});
// Register a new domain
const { result: registerResult } = renderHook(() => useRegisterDomain(), {
wrapper,
});
await act(async () => {
await registerResult.current.mutateAsync(mockRegisterRequest);
});
// Verify registrations were invalidated and refetched
await waitFor(() => {
expect(domainsApi.getRegisteredDomains).toHaveBeenCalledTimes(2);
});
});
it('handles registration errors', async () => {
vi.mocked(domainsApi.registerDomain).mockRejectedValue(
new Error('Registration failed')
);
const { result } = renderHook(() => useRegisterDomain(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync(mockRegisterRequest);
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(new Error('Registration failed'));
});
});
describe('useRegisteredDomains', () => {
it('fetches all registered domains', async () => {
vi.mocked(domainsApi.getRegisteredDomains).mockResolvedValue(mockRegisteredDomains);
const { result } = renderHook(() => useRegisteredDomains(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(domainsApi.getRegisteredDomains).toHaveBeenCalledTimes(1);
expect(result.current.data).toEqual(mockRegisteredDomains);
expect(result.current.data).toHaveLength(2);
});
it('uses staleTime of 30 seconds', async () => {
vi.mocked(domainsApi.getRegisteredDomains).mockResolvedValue(mockRegisteredDomains);
const { result } = renderHook(() => useRegisteredDomains(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.dataUpdatedAt).toBeGreaterThan(0);
});
it('handles fetch errors', async () => {
vi.mocked(domainsApi.getRegisteredDomains).mockRejectedValue(
new Error('Fetch failed')
);
const { result } = renderHook(() => useRegisteredDomains(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(new Error('Fetch failed'));
});
});
describe('useDomainRegistration', () => {
it('fetches single domain registration by id', async () => {
vi.mocked(domainsApi.getDomainRegistration).mockResolvedValue(mockDomainRegistration);
const { result } = renderHook(() => useDomainRegistration(1), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(domainsApi.getDomainRegistration).toHaveBeenCalledWith(1);
expect(result.current.data).toEqual(mockDomainRegistration);
});
it('does not fetch when id is 0', async () => {
const { result } = renderHook(() => useDomainRegistration(0), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(domainsApi.getDomainRegistration).not.toHaveBeenCalled();
expect(result.current.data).toBeUndefined();
});
it('does not fetch when id is null/undefined', async () => {
const { result } = renderHook(() => useDomainRegistration(null as any), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(domainsApi.getDomainRegistration).not.toHaveBeenCalled();
});
it('handles fetch errors', async () => {
vi.mocked(domainsApi.getDomainRegistration).mockRejectedValue(
new Error('Domain not found')
);
const { result } = renderHook(() => useDomainRegistration(999), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(new Error('Domain not found'));
});
});
// ============================================
// Domain Management
// ============================================
describe('useUpdateNameservers', () => {
it('updates nameservers for a domain', async () => {
const updatedDomain = {
...mockDomainRegistration,
nameservers: ['ns1.custom.com', 'ns2.custom.com'],
};
vi.mocked(domainsApi.updateNameservers).mockResolvedValue(updatedDomain);
const { result } = renderHook(() => useUpdateNameservers(), {
wrapper: createWrapper(),
});
let updateData;
await act(async () => {
updateData = await result.current.mutateAsync({
id: 1,
nameservers: ['ns1.custom.com', 'ns2.custom.com'],
});
});
expect(domainsApi.updateNameservers).toHaveBeenCalledWith(1, [
'ns1.custom.com',
'ns2.custom.com',
]);
expect(updateData).toEqual(updatedDomain);
});
it('updates cache optimistically with setQueryData', async () => {
const updatedDomain = {
...mockDomainRegistration,
nameservers: ['ns1.new.com', 'ns2.new.com'],
};
vi.mocked(domainsApi.getDomainRegistration).mockResolvedValue(mockDomainRegistration);
vi.mocked(domainsApi.updateNameservers).mockResolvedValue(updatedDomain);
vi.mocked(domainsApi.getRegisteredDomains).mockResolvedValue(mockRegisteredDomains);
const wrapper = createWrapper();
// First fetch the domain
const { result: domainResult } = renderHook(() => useDomainRegistration(1), {
wrapper,
});
await waitFor(() => {
expect(domainResult.current.isSuccess).toBe(true);
});
expect(domainResult.current.data?.nameservers).toEqual([
'ns1.smoothschedule.com',
'ns2.smoothschedule.com',
]);
// Now update nameservers
const { result: updateResult } = renderHook(() => useUpdateNameservers(), {
wrapper,
});
let updateData;
await act(async () => {
updateData = await updateResult.current.mutateAsync({
id: 1,
nameservers: ['ns1.new.com', 'ns2.new.com'],
});
});
// Verify the mutation returned updated data
expect(updateData).toEqual(updatedDomain);
// Refetch to get updated cache
await act(async () => {
await domainResult.current.refetch();
});
});
it('invalidates registrations list', async () => {
vi.mocked(domainsApi.updateNameservers).mockResolvedValue(mockDomainRegistration);
vi.mocked(domainsApi.getRegisteredDomains).mockResolvedValue(mockRegisteredDomains);
const wrapper = createWrapper();
// Fetch registrations first
const { result: domainsResult } = renderHook(() => useRegisteredDomains(), {
wrapper,
});
await waitFor(() => {
expect(domainsResult.current.isSuccess).toBe(true);
});
// Update nameservers
const { result: updateResult } = renderHook(() => useUpdateNameservers(), {
wrapper,
});
await act(async () => {
await updateResult.current.mutateAsync({
id: 1,
nameservers: ['ns1.new.com'],
});
});
// Registrations should be refetched
await waitFor(() => {
expect(domainsApi.getRegisteredDomains).toHaveBeenCalledTimes(2);
});
});
it('handles update errors', async () => {
vi.mocked(domainsApi.updateNameservers).mockRejectedValue(
new Error('Update failed')
);
const { result } = renderHook(() => useUpdateNameservers(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync({ id: 1, nameservers: ['ns1.new.com'] });
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(new Error('Update failed'));
});
});
describe('useToggleAutoRenew', () => {
it('toggles auto-renewal on', async () => {
const updatedDomain = { ...mockDomainRegistration, auto_renew: true };
vi.mocked(domainsApi.toggleAutoRenew).mockResolvedValue(updatedDomain);
const { result } = renderHook(() => useToggleAutoRenew(), {
wrapper: createWrapper(),
});
let toggleData;
await act(async () => {
toggleData = await result.current.mutateAsync({ id: 1, autoRenew: true });
});
expect(domainsApi.toggleAutoRenew).toHaveBeenCalledWith(1, true);
expect(toggleData?.auto_renew).toBe(true);
});
it('toggles auto-renewal off', async () => {
const updatedDomain = { ...mockDomainRegistration, auto_renew: false };
vi.mocked(domainsApi.toggleAutoRenew).mockResolvedValue(updatedDomain);
const { result } = renderHook(() => useToggleAutoRenew(), {
wrapper: createWrapper(),
});
let toggleData;
await act(async () => {
toggleData = await result.current.mutateAsync({ id: 1, autoRenew: false });
});
expect(domainsApi.toggleAutoRenew).toHaveBeenCalledWith(1, false);
expect(toggleData?.auto_renew).toBe(false);
});
it('updates cache with setQueryData', async () => {
const updatedDomain = { ...mockDomainRegistration, auto_renew: false };
vi.mocked(domainsApi.getDomainRegistration).mockResolvedValue(mockDomainRegistration);
vi.mocked(domainsApi.toggleAutoRenew).mockResolvedValue(updatedDomain);
vi.mocked(domainsApi.getRegisteredDomains).mockResolvedValue(mockRegisteredDomains);
const wrapper = createWrapper();
// Fetch domain first
const { result: domainResult } = renderHook(() => useDomainRegistration(1), {
wrapper,
});
await waitFor(() => {
expect(domainResult.current.isSuccess).toBe(true);
});
expect(domainResult.current.data?.auto_renew).toBe(true);
// Toggle auto-renew
const { result: toggleResult } = renderHook(() => useToggleAutoRenew(), {
wrapper,
});
let toggleData;
await act(async () => {
toggleData = await toggleResult.current.mutateAsync({ id: 1, autoRenew: false });
});
// Verify mutation returned updated data
expect(toggleData?.auto_renew).toBe(false);
// Refetch to verify cache updated
await act(async () => {
await domainResult.current.refetch();
});
});
it('handles toggle errors', async () => {
vi.mocked(domainsApi.toggleAutoRenew).mockRejectedValue(
new Error('Toggle failed')
);
const { result } = renderHook(() => useToggleAutoRenew(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync({ id: 1, autoRenew: false });
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(new Error('Toggle failed'));
});
});
describe('useRenewDomain', () => {
it('renews domain for 1 year by default', async () => {
const renewedDomain = {
...mockDomainRegistration,
expires_at: '2026-01-01T00:00:00Z',
days_until_expiry: 730,
};
vi.mocked(domainsApi.renewDomain).mockResolvedValue(renewedDomain);
const { result } = renderHook(() => useRenewDomain(), {
wrapper: createWrapper(),
});
let renewData;
await act(async () => {
renewData = await result.current.mutateAsync({ id: 1 });
});
expect(domainsApi.renewDomain).toHaveBeenCalledWith(1, undefined);
expect(renewData).toEqual(renewedDomain);
});
it('renews domain for specified years', async () => {
const renewedDomain = {
...mockDomainRegistration,
expires_at: '2027-01-01T00:00:00Z',
days_until_expiry: 1095,
};
vi.mocked(domainsApi.renewDomain).mockResolvedValue(renewedDomain);
const { result } = renderHook(() => useRenewDomain(), {
wrapper: createWrapper(),
});
let renewData;
await act(async () => {
renewData = await result.current.mutateAsync({ id: 1, years: 3 });
});
expect(domainsApi.renewDomain).toHaveBeenCalledWith(1, 3);
expect(renewData).toEqual(renewedDomain);
});
it('updates cache with renewed domain data', async () => {
const renewedDomain = {
...mockDomainRegistration,
expires_at: '2026-01-01T00:00:00Z',
};
vi.mocked(domainsApi.getDomainRegistration).mockResolvedValue(mockDomainRegistration);
vi.mocked(domainsApi.renewDomain).mockResolvedValue(renewedDomain);
vi.mocked(domainsApi.getRegisteredDomains).mockResolvedValue(mockRegisteredDomains);
const wrapper = createWrapper();
const { result: domainResult } = renderHook(() => useDomainRegistration(1), {
wrapper,
});
await waitFor(() => {
expect(domainResult.current.data?.expires_at).toBe('2025-01-01T00:00:00Z');
});
const { result: renewResult } = renderHook(() => useRenewDomain(), {
wrapper,
});
let renewData;
await act(async () => {
renewData = await renewResult.current.mutateAsync({ id: 1, years: 1 });
});
// Verify mutation returned updated data
expect(renewData?.expires_at).toBe('2026-01-01T00:00:00Z');
// Refetch to verify cache updated
await act(async () => {
await domainResult.current.refetch();
});
});
it('handles renewal errors', async () => {
vi.mocked(domainsApi.renewDomain).mockRejectedValue(new Error('Renewal failed'));
const { result } = renderHook(() => useRenewDomain(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync({ id: 1 });
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(new Error('Renewal failed'));
});
});
describe('useSyncDomain', () => {
it('syncs domain info from NameSilo', async () => {
const syncedDomain = {
...mockDomainRegistration,
expires_at: '2025-06-01T00:00:00Z',
days_until_expiry: 515,
};
vi.mocked(domainsApi.syncDomain).mockResolvedValue(syncedDomain);
const { result } = renderHook(() => useSyncDomain(), {
wrapper: createWrapper(),
});
let syncData;
await act(async () => {
syncData = await result.current.mutateAsync(1);
});
expect(domainsApi.syncDomain).toHaveBeenCalledWith(1);
expect(syncData).toEqual(syncedDomain);
});
it('updates cache with synced data', async () => {
const syncedDomain = {
...mockDomainRegistration,
status: 'active' as const,
expires_at: '2025-12-31T00:00:00Z',
};
vi.mocked(domainsApi.getDomainRegistration).mockResolvedValue(mockDomainRegistration);
vi.mocked(domainsApi.syncDomain).mockResolvedValue(syncedDomain);
vi.mocked(domainsApi.getRegisteredDomains).mockResolvedValue(mockRegisteredDomains);
const wrapper = createWrapper();
const { result: domainResult } = renderHook(() => useDomainRegistration(1), {
wrapper,
});
await waitFor(() => {
expect(domainResult.current.isSuccess).toBe(true);
});
expect(domainResult.current.data?.expires_at).toBe('2025-01-01T00:00:00Z');
const { result: syncResult } = renderHook(() => useSyncDomain(), {
wrapper,
});
let syncData;
await act(async () => {
syncData = await syncResult.current.mutateAsync(1);
});
// Verify mutation returned updated data
expect(syncData?.expires_at).toBe('2025-12-31T00:00:00Z');
// Refetch to verify cache updated
await act(async () => {
await domainResult.current.refetch();
});
});
it('invalidates registrations list after sync', async () => {
vi.mocked(domainsApi.syncDomain).mockResolvedValue(mockDomainRegistration);
vi.mocked(domainsApi.getRegisteredDomains).mockResolvedValue(mockRegisteredDomains);
const wrapper = createWrapper();
const { result: domainsResult } = renderHook(() => useRegisteredDomains(), {
wrapper,
});
await waitFor(() => {
expect(domainsResult.current.isSuccess).toBe(true);
});
const { result: syncResult } = renderHook(() => useSyncDomain(), {
wrapper,
});
await act(async () => {
await syncResult.current.mutateAsync(1);
});
await waitFor(() => {
expect(domainsApi.getRegisteredDomains).toHaveBeenCalledTimes(2);
});
});
it('handles sync errors', async () => {
vi.mocked(domainsApi.syncDomain).mockRejectedValue(new Error('Sync failed'));
const { result } = renderHook(() => useSyncDomain(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync(1);
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(new Error('Sync failed'));
});
});
// ============================================
// History
// ============================================
describe('useSearchHistory', () => {
it('fetches search history', async () => {
vi.mocked(domainsApi.getSearchHistory).mockResolvedValue(mockSearchHistory);
const { result } = renderHook(() => useSearchHistory(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(domainsApi.getSearchHistory).toHaveBeenCalledTimes(1);
expect(result.current.data).toEqual(mockSearchHistory);
expect(result.current.data).toHaveLength(2);
});
it('uses staleTime of 1 minute', async () => {
vi.mocked(domainsApi.getSearchHistory).mockResolvedValue(mockSearchHistory);
const { result } = renderHook(() => useSearchHistory(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.dataUpdatedAt).toBeGreaterThan(0);
});
it('handles empty search history', async () => {
vi.mocked(domainsApi.getSearchHistory).mockResolvedValue([]);
const { result } = renderHook(() => useSearchHistory(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual([]);
});
it('handles fetch errors', async () => {
vi.mocked(domainsApi.getSearchHistory).mockRejectedValue(
new Error('History fetch failed')
);
const { result } = renderHook(() => useSearchHistory(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(new Error('History fetch failed'));
});
});
});

View File

@@ -0,0 +1,184 @@
/**
* Tests for useEntitlements hook
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import { useEntitlements } from '../useEntitlements';
import * as billingApi from '../../api/billing';
// Mock the billing API
vi.mock('../../api/billing', () => ({
getEntitlements: vi.fn(),
getCurrentSubscription: vi.fn(),
}));
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('useEntitlements', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('fetches and returns entitlements', async () => {
const mockEntitlements = {
can_use_sms_reminders: true,
can_use_mobile_app: false,
max_users: 10,
};
vi.mocked(billingApi.getEntitlements).mockResolvedValueOnce(mockEntitlements);
const { result } = renderHook(() => useEntitlements(), {
wrapper: createWrapper(),
});
// Initially loading
expect(result.current.isLoading).toBe(true);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.entitlements).toEqual(mockEntitlements);
});
it('hasFeature returns true for enabled boolean features', async () => {
const mockEntitlements = {
can_use_sms_reminders: true,
can_use_mobile_app: false,
};
vi.mocked(billingApi.getEntitlements).mockResolvedValueOnce(mockEntitlements);
const { result } = renderHook(() => useEntitlements(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hasFeature('can_use_sms_reminders')).toBe(true);
expect(result.current.hasFeature('can_use_mobile_app')).toBe(false);
});
it('hasFeature returns false for non-existent features', async () => {
const mockEntitlements = {
can_use_sms_reminders: true,
};
vi.mocked(billingApi.getEntitlements).mockResolvedValueOnce(mockEntitlements);
const { result } = renderHook(() => useEntitlements(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hasFeature('nonexistent_feature')).toBe(false);
});
it('getLimit returns integer value for limit features', async () => {
const mockEntitlements = {
max_users: 10,
max_resources: 25,
};
vi.mocked(billingApi.getEntitlements).mockResolvedValueOnce(mockEntitlements);
const { result } = renderHook(() => useEntitlements(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.getLimit('max_users')).toBe(10);
expect(result.current.getLimit('max_resources')).toBe(25);
});
it('getLimit returns null for non-existent limits', async () => {
const mockEntitlements = {
max_users: 10,
};
vi.mocked(billingApi.getEntitlements).mockResolvedValueOnce(mockEntitlements);
const { result } = renderHook(() => useEntitlements(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.getLimit('nonexistent_limit')).toBeNull();
});
it('getLimit returns null for boolean features', async () => {
const mockEntitlements = {
can_use_sms_reminders: true,
};
vi.mocked(billingApi.getEntitlements).mockResolvedValueOnce(mockEntitlements);
const { result } = renderHook(() => useEntitlements(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
// Boolean features should not be returned as limits
expect(result.current.getLimit('can_use_sms_reminders')).toBeNull();
});
it('returns loading state initially', () => {
vi.mocked(billingApi.getEntitlements).mockImplementation(
() => new Promise(() => {}) // Never resolves
);
const { result } = renderHook(() => useEntitlements(), {
wrapper: createWrapper(),
});
expect(result.current.isLoading).toBe(true);
expect(result.current.entitlements).toEqual({});
});
it('returns empty entitlements when API returns empty', async () => {
// When getEntitlements encounters an error, it returns {} (see billing.ts)
// So we test that behavior by having the mock return {}
vi.mocked(billingApi.getEntitlements).mockResolvedValueOnce({});
const { result } = renderHook(() => useEntitlements(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.entitlements).toEqual({});
expect(result.current.hasFeature('any_feature')).toBe(false);
expect(result.current.getLimit('any_limit')).toBeNull();
});
});

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