Compare commits
5 Commits
8440ac945a
...
67ce2c433c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67ce2c433c | ||
|
|
1391374d45 | ||
|
|
b9e90e6f46 | ||
|
|
1af79cc019 | ||
|
|
156cc2676d |
52
CLAUDE.md
52
CLAUDE.md
@@ -178,25 +178,51 @@ toUTC(date) // For API requests
|
|||||||
formatForDisplay(utcString, businessTimezone) // For displaying
|
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 |
|
| App | Location | Purpose |
|
||||||
|-----|----------|---------|
|
|-----|----------|---------|
|
||||||
| `schedule` | `smoothschedule/smoothschedule/schedule/` | Resources, Events, Services |
|
| `core` | `identity/core/` | Tenant, Domain, PermissionGrant, middleware, mixins |
|
||||||
| `users` | `smoothschedule/smoothschedule/users/` | Authentication, User model |
|
| `users` | `identity/users/` | User model, authentication, MFA |
|
||||||
| `tenants` | `smoothschedule/smoothschedule/tenants/` | Multi-tenancy (Business model) |
|
|
||||||
| `core` | `smoothschedule/core/` | Shared mixins, permissions, middleware |
|
### Scheduling Domain
|
||||||
| `payments` | `smoothschedule/payments/` | Stripe integration, subscriptions |
|
| App | Location | Purpose |
|
||||||
| `platform_admin` | `smoothschedule/platform_admin/` | Platform administration |
|
|-----|----------|---------|
|
||||||
|
| `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
|
## 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
|
### Permission Classes
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from core.mixins import DenyStaffWritePermission, DenyStaffAllAccessPermission, DenyStaffListPermission
|
from smoothschedule.identity.core.mixins import DenyStaffWritePermission, DenyStaffAllAccessPermission, DenyStaffListPermission
|
||||||
|
|
||||||
class MyViewSet(ModelViewSet):
|
class MyViewSet(ModelViewSet):
|
||||||
# Block write operations for staff (GET allowed)
|
# Block write operations for staff (GET allowed)
|
||||||
@@ -236,7 +262,7 @@ docker compose -f docker-compose.local.yml exec django python manage.py shell
|
|||||||
```
|
```
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from smoothschedule.users.models import User
|
from smoothschedule.identity.users.models import User
|
||||||
|
|
||||||
# Find the staff member
|
# Find the staff member
|
||||||
staff = User.objects.get(email='john@example.com')
|
staff = User.objects.get(email='john@example.com')
|
||||||
@@ -263,7 +289,7 @@ Then grant via: `staff.permissions['can_manage_equipment'] = True`
|
|||||||
### QuerySet Mixins
|
### QuerySet Mixins
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from core.mixins import TenantFilteredQuerySetMixin, UserTenantFilteredMixin
|
from smoothschedule.identity.core.mixins import TenantFilteredQuerySetMixin, UserTenantFilteredMixin
|
||||||
|
|
||||||
# For tenant-scoped models (automatic django-tenants filtering)
|
# For tenant-scoped models (automatic django-tenants filtering)
|
||||||
class ResourceViewSet(TenantFilteredQuerySetMixin, ModelViewSet):
|
class ResourceViewSet(TenantFilteredQuerySetMixin, ModelViewSet):
|
||||||
@@ -282,7 +308,7 @@ class CustomerViewSet(UserTenantFilteredMixin, ModelViewSet):
|
|||||||
### Feature Permission Mixins
|
### Feature Permission Mixins
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from core.mixins import PluginFeatureRequiredMixin, TaskFeatureRequiredMixin
|
from smoothschedule.identity.core.mixins import PluginFeatureRequiredMixin, TaskFeatureRequiredMixin
|
||||||
|
|
||||||
# Checks can_use_plugins feature on list/retrieve/create
|
# Checks can_use_plugins feature on list/retrieve/create
|
||||||
class PluginViewSet(PluginFeatureRequiredMixin, ModelViewSet):
|
class PluginViewSet(PluginFeatureRequiredMixin, ModelViewSet):
|
||||||
@@ -297,7 +323,7 @@ class ScheduledTaskViewSet(TaskFeatureRequiredMixin, TenantFilteredQuerySetMixin
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
from rest_framework.views import APIView
|
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()
|
# Optional tenant - use self.get_tenant()
|
||||||
class MyView(TenantAPIView, APIView):
|
class MyView(TenantAPIView, APIView):
|
||||||
|
|||||||
383
PLAN_APP_REORGANIZATION.md
Normal file
383
PLAN_APP_REORGANIZATION.md
Normal 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)
|
||||||
@@ -289,7 +289,7 @@ docker compose -f docker-compose.local.yml exec django python manage.py migrate
|
|||||||
from django.test import TestCase, RequestFactory
|
from django.test import TestCase, RequestFactory
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
from core.models import Tenant
|
from core.models import Tenant
|
||||||
from smoothschedule.users.models import User
|
from smoothschedule.identity.users.models import User
|
||||||
|
|
||||||
class CalendarSyncTests(APITestCase):
|
class CalendarSyncTests(APITestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|||||||
@@ -95,23 +95,38 @@ smoothschedule/
|
|||||||
│ └── traefik/
|
│ └── traefik/
|
||||||
```
|
```
|
||||||
|
|
||||||
### Django Apps
|
### Django Apps (Domain-Based Organization)
|
||||||
|
|
||||||
```
|
```
|
||||||
smoothschedule/smoothschedule/
|
smoothschedule/smoothschedule/
|
||||||
├── users/ # User management, authentication
|
├── identity/ # Identity Domain
|
||||||
│ ├── models.py # User model with roles
|
│ ├── core/ # Tenant, Domain, middleware, mixins
|
||||||
│ ├── api_views.py # Auth endpoints, user API
|
│ │ ├── models.py # Tenant, Domain, PermissionGrant
|
||||||
│ └── migrations/
|
│ │ ├── middleware.py # TenantHeader, Sandbox, Masquerade
|
||||||
├── schedule/ # Core scheduling functionality
|
│ │ └── mixins.py # Base classes for views/viewsets
|
||||||
│ ├── models.py # Resource, Event, Service, Participant
|
│ └── users/ # User management, authentication
|
||||||
│ ├── serializers.py # DRF serializers
|
│ ├── models.py # User model with roles
|
||||||
│ ├── views.py # ViewSets for API
|
│ ├── api_views.py # Auth endpoints
|
||||||
│ ├── services.py # AvailabilityService
|
│ └── mfa_api_views.py # MFA endpoints
|
||||||
│ └── migrations/
|
├── scheduling/ # Scheduling Domain
|
||||||
├── tenants/ # Multi-tenancy (Business/Tenant models)
|
│ ├── schedule/ # Core scheduling functionality
|
||||||
│ ├── models.py # Tenant, Domain models
|
│ │ ├── models.py # Resource, Event, Service, Participant
|
||||||
│ └── migrations/
|
│ │ ├── serializers.py # DRF serializers
|
||||||
|
│ │ ├── views.py # ViewSets for API
|
||||||
|
│ │ └── services.py # AvailabilityService
|
||||||
|
│ ├── contracts/ # E-signature system
|
||||||
|
│ └── analytics/ # Business analytics
|
||||||
|
├── communication/ # Communication Domain
|
||||||
|
│ ├── notifications/ # Notification system
|
||||||
|
│ ├── credits/ # SMS/calling credits
|
||||||
|
│ ├── mobile/ # Field employee mobile app
|
||||||
|
│ └── messaging/ # Email templates
|
||||||
|
├── commerce/ # Commerce Domain
|
||||||
|
│ ├── payments/ # Stripe Connect integration
|
||||||
|
│ └── tickets/ # Support tickets
|
||||||
|
└── platform/ # Platform Domain
|
||||||
|
├── admin/ # Platform administration
|
||||||
|
└── api/ # Public API v1
|
||||||
```
|
```
|
||||||
|
|
||||||
### API Endpoints
|
### API Endpoints
|
||||||
@@ -160,16 +175,239 @@ You're trying to run Python directly instead of through Docker. Use `docker comp
|
|||||||
|
|
||||||
## Key Models
|
## Key Models
|
||||||
|
|
||||||
### Resource (schedule/models.py)
|
### Resource (scheduling/schedule/models.py)
|
||||||
- `name`, `type` (STAFF/ROOM/EQUIPMENT)
|
- `name`, `type` (STAFF/ROOM/EQUIPMENT)
|
||||||
- `max_concurrent_events` - concurrency limit (1=exclusive, >1=multilane, 0=unlimited)
|
- `max_concurrent_events` - concurrency limit (1=exclusive, >1=multilane, 0=unlimited)
|
||||||
- `saved_lane_count` - remembers lane count when multilane disabled
|
- `saved_lane_count` - remembers lane count when multilane disabled
|
||||||
- `buffer_duration` - time between events
|
- `buffer_duration` - time between events
|
||||||
|
|
||||||
### Event (schedule/models.py)
|
### Event (scheduling/schedule/models.py)
|
||||||
- `title`, `start_time`, `end_time`, `status`
|
- `title`, `start_time`, `end_time`, `status`
|
||||||
- Links to resources/customers via `Participant` model
|
- Links to resources/customers via `Participant` model
|
||||||
|
|
||||||
### User (users/models.py)
|
### User (identity/users/models.py)
|
||||||
- Roles: `superuser`, `platform_manager`, `platform_support`, `owner`, `manager`, `staff`, `resource`, `customer`
|
- Roles: `superuser`, `platform_manager`, `platform_support`, `owner`, `manager`, `staff`, `resource`, `customer`
|
||||||
- `business_subdomain` - which tenant they belong to
|
- `business_subdomain` - which tenant they belong to
|
||||||
|
|
||||||
|
### Tenant (identity/core/models.py)
|
||||||
|
- `name`, `subdomain`, `schema_name`
|
||||||
|
- Multi-tenancy via django-tenants
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
|
||||||
|
### Testing Philosophy
|
||||||
|
|
||||||
|
Follow the **Testing Pyramid**:
|
||||||
|
```
|
||||||
|
/\
|
||||||
|
/E2E\ <- Few (real browser, full stack) - AVOID
|
||||||
|
/------\
|
||||||
|
/Integr- \ <- Some (real DB, verify ORM/queries)
|
||||||
|
/---ation--\
|
||||||
|
/ Unit \ <- Many (mocked, fast, isolated) - PREFER
|
||||||
|
/--------------\
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** Django-tenants requires full PostgreSQL migrations per test, making DB-based tests extremely slow (~30-60 seconds each). Unit tests with mocks run in milliseconds.
|
||||||
|
|
||||||
|
### Unit Tests (Preferred)
|
||||||
|
|
||||||
|
Write fast, isolated unit tests using mocks. **Do NOT use `@pytest.mark.django_db`** for unit tests.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# GOOD: Fast unit test with mocks
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
def test_event_service_creates_event():
|
||||||
|
# Arrange - mock dependencies
|
||||||
|
mock_repo = Mock()
|
||||||
|
mock_repo.save.return_value = Mock(id=1, title="Test Event")
|
||||||
|
|
||||||
|
mock_tenant = Mock()
|
||||||
|
mock_tenant.can_use_feature.return_value = True
|
||||||
|
|
||||||
|
service = EventService(repository=mock_repo, tenant=mock_tenant)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = service.create_event(title="Test Event", start_time=datetime.now())
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
mock_repo.save.assert_called_once()
|
||||||
|
assert result.title == "Test Event"
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_service_denies_without_permission():
|
||||||
|
mock_tenant = Mock()
|
||||||
|
mock_tenant.can_use_feature.return_value = False
|
||||||
|
|
||||||
|
service = EventService(repository=Mock(), tenant=mock_tenant)
|
||||||
|
|
||||||
|
with pytest.raises(PermissionDenied):
|
||||||
|
service.create_event(title="Test")
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# BAD: Slow test hitting real database
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_event_creation(tenant, user):
|
||||||
|
# This takes 30+ seconds due to tenant schema setup!
|
||||||
|
event = Event.objects.create(tenant=tenant, title="Test")
|
||||||
|
assert event.id is not None
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Serializers
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_event_serializer_validates_end_after_start():
|
||||||
|
data = {
|
||||||
|
'title': 'Test',
|
||||||
|
'start_time': '2024-01-01T10:00:00Z',
|
||||||
|
'end_time': '2024-01-01T09:00:00Z', # Before start!
|
||||||
|
}
|
||||||
|
serializer = EventSerializer(data=data)
|
||||||
|
|
||||||
|
assert not serializer.is_valid()
|
||||||
|
assert 'end_time' in serializer.errors
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Views/ViewSets
|
||||||
|
|
||||||
|
Use `APIRequestFactory` with mocked authentication:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from rest_framework.test import APIRequestFactory
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
def test_resource_list_returns_filtered_resources():
|
||||||
|
factory = APIRequestFactory()
|
||||||
|
request = factory.get('/api/resources/')
|
||||||
|
|
||||||
|
# Mock the user and tenant
|
||||||
|
request.user = Mock(is_authenticated=True, role='manager')
|
||||||
|
request.tenant = Mock(id=1)
|
||||||
|
|
||||||
|
# Mock the queryset
|
||||||
|
with patch.object(ResourceViewSet, 'get_queryset') as mock_qs:
|
||||||
|
mock_qs.return_value = [Mock(id=1, name='Room A')]
|
||||||
|
|
||||||
|
view = ResourceViewSet.as_view({'get': 'list'})
|
||||||
|
response = view(request)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Tests (Use Sparingly)
|
||||||
|
|
||||||
|
Only use `@pytest.mark.django_db` when you MUST verify:
|
||||||
|
- Complex ORM queries with joins/aggregations
|
||||||
|
- Database constraints and triggers
|
||||||
|
- Migration correctness
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Integration test - use only when necessary
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestEventQueryIntegration:
|
||||||
|
"""Verify complex queries work correctly with real DB."""
|
||||||
|
|
||||||
|
def test_overlapping_events_query(self, tenant_with_events):
|
||||||
|
# This tests the actual SQL query behavior
|
||||||
|
overlapping = Event.objects.filter_overlapping(
|
||||||
|
start=datetime(2024, 1, 1, 10),
|
||||||
|
end=datetime(2024, 1, 1, 11)
|
||||||
|
)
|
||||||
|
assert overlapping.count() == 2
|
||||||
|
```
|
||||||
|
|
||||||
|
### Writing Testable Code
|
||||||
|
|
||||||
|
Design code for testability using dependency injection:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# BAD: Hard to test - direct imports and global state
|
||||||
|
class EventService:
|
||||||
|
def create_event(self, data):
|
||||||
|
tenant = get_current_tenant() # Global state!
|
||||||
|
event = Event.objects.create(**data) # Direct ORM call
|
||||||
|
send_notification(event) # Direct import
|
||||||
|
return event
|
||||||
|
|
||||||
|
# GOOD: Easy to test - dependencies injected
|
||||||
|
class EventService:
|
||||||
|
def __init__(self, repository, tenant, notifier):
|
||||||
|
self.repository = repository
|
||||||
|
self.tenant = tenant
|
||||||
|
self.notifier = notifier
|
||||||
|
|
||||||
|
def create_event(self, data):
|
||||||
|
if not self.tenant.can_use_feature('events'):
|
||||||
|
raise PermissionDenied()
|
||||||
|
event = self.repository.save(Event(**data))
|
||||||
|
self.notifier.send(event)
|
||||||
|
return event
|
||||||
|
```
|
||||||
|
|
||||||
|
### Repository Pattern
|
||||||
|
|
||||||
|
Abstract database access for easier mocking:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In repositories.py
|
||||||
|
class EventRepository:
|
||||||
|
def save(self, event):
|
||||||
|
event.save()
|
||||||
|
return event
|
||||||
|
|
||||||
|
def get_by_id(self, event_id):
|
||||||
|
return Event.objects.get(id=event_id)
|
||||||
|
|
||||||
|
def filter_by_date_range(self, start, end):
|
||||||
|
return Event.objects.filter(start_time__gte=start, end_time__lte=end)
|
||||||
|
|
||||||
|
# In tests - mock the repository
|
||||||
|
def test_something():
|
||||||
|
mock_repo = Mock(spec=EventRepository)
|
||||||
|
mock_repo.get_by_id.return_value = Mock(id=1, title="Test")
|
||||||
|
# ... test with mock_repo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test File Structure
|
||||||
|
|
||||||
|
Each app should have:
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── tests/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── test_services.py # Unit tests for business logic
|
||||||
|
│ ├── test_serializers.py # Serializer validation tests
|
||||||
|
│ ├── test_views.py # View/ViewSet tests (mocked)
|
||||||
|
│ └── test_integration.py # DB tests (sparingly)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
docker compose -f docker-compose.local.yml exec django pytest
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
docker compose -f docker-compose.local.yml exec django pytest smoothschedule/scheduling/schedule/tests/test_services.py
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
docker compose -f docker-compose.local.yml exec django coverage run -m pytest
|
||||||
|
docker compose -f docker-compose.local.yml exec django coverage report
|
||||||
|
|
||||||
|
# Run only unit tests (fast)
|
||||||
|
docker compose -f docker-compose.local.yml exec django pytest -m "not django_db"
|
||||||
|
|
||||||
|
# Run only integration tests
|
||||||
|
docker compose -f docker-compose.local.yml exec django pytest -m django_db
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Testing Rules
|
||||||
|
|
||||||
|
1. **Default to mocks** - Only hit DB when testing query logic
|
||||||
|
2. **One assertion focus** - Each test verifies one behavior
|
||||||
|
3. **Descriptive names** - `test_create_event_raises_when_tenant_lacks_permission`
|
||||||
|
4. **Arrange-Act-Assert** - Clear test structure
|
||||||
|
5. **No test interdependence** - Tests must run in any order
|
||||||
|
6. **Fast feedback** - If a test takes >1 second, it probably shouldn't hit the DB
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ docker-compose -f docker-compose.local.yml run --rm django python manage.py migr
|
|||||||
In Django shell or admin, create users with different roles:
|
In Django shell or admin, create users with different roles:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from smoothschedule.users.models import User
|
from smoothschedule.identity.users.models import User
|
||||||
from core.models import Tenant
|
from core.models import Tenant
|
||||||
|
|
||||||
# Get the tenant
|
# Get the tenant
|
||||||
|
|||||||
@@ -1,316 +0,0 @@
|
|||||||
"""
|
|
||||||
Analytics API Tests
|
|
||||||
|
|
||||||
Tests for permission gating and endpoint functionality.
|
|
||||||
"""
|
|
||||||
import pytest
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.utils import timezone
|
|
||||||
from rest_framework.test import APIClient
|
|
||||||
from rest_framework.authtoken.models import Token
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from core.models import Tenant
|
|
||||||
from schedule.models import Event, Resource, Service
|
|
||||||
from platform_admin.models import SubscriptionPlan
|
|
||||||
|
|
||||||
User = get_user_model()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
class TestAnalyticsPermissions:
|
|
||||||
"""Test permission gating for analytics endpoints"""
|
|
||||||
|
|
||||||
def setup_method(self):
|
|
||||||
"""Setup test data"""
|
|
||||||
self.client = APIClient()
|
|
||||||
|
|
||||||
# Create a tenant
|
|
||||||
self.tenant = Tenant.objects.create(
|
|
||||||
name="Test Business",
|
|
||||||
schema_name="test_business"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create a user for this tenant
|
|
||||||
self.user = User.objects.create_user(
|
|
||||||
email="test@example.com",
|
|
||||||
password="testpass123",
|
|
||||||
role=User.Role.TENANT_OWNER,
|
|
||||||
tenant=self.tenant
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create auth token
|
|
||||||
self.token = Token.objects.create(user=self.user)
|
|
||||||
|
|
||||||
# Create subscription plan with advanced_analytics permission
|
|
||||||
self.plan_with_analytics = SubscriptionPlan.objects.create(
|
|
||||||
name="Professional",
|
|
||||||
business_tier="PROFESSIONAL",
|
|
||||||
permissions={"advanced_analytics": True}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create subscription plan WITHOUT advanced_analytics permission
|
|
||||||
self.plan_without_analytics = SubscriptionPlan.objects.create(
|
|
||||||
name="Starter",
|
|
||||||
business_tier="STARTER",
|
|
||||||
permissions={}
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_analytics_requires_authentication(self):
|
|
||||||
"""Test that analytics endpoints require authentication"""
|
|
||||||
response = self.client.get("/api/analytics/analytics/dashboard/")
|
|
||||||
assert response.status_code == 401
|
|
||||||
assert "Authentication credentials were not provided" in str(response.data)
|
|
||||||
|
|
||||||
def test_analytics_denied_without_permission(self):
|
|
||||||
"""Test that analytics is denied without advanced_analytics permission"""
|
|
||||||
# Assign plan without permission
|
|
||||||
self.tenant.subscription_plan = self.plan_without_analytics
|
|
||||||
self.tenant.save()
|
|
||||||
|
|
||||||
self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}")
|
|
||||||
response = self.client.get("/api/analytics/analytics/dashboard/")
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert "Advanced Analytics" in str(response.data)
|
|
||||||
assert "upgrade your subscription" in str(response.data).lower()
|
|
||||||
|
|
||||||
def test_analytics_allowed_with_permission(self):
|
|
||||||
"""Test that analytics is allowed with advanced_analytics permission"""
|
|
||||||
# Assign plan with permission
|
|
||||||
self.tenant.subscription_plan = self.plan_with_analytics
|
|
||||||
self.tenant.save()
|
|
||||||
|
|
||||||
self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}")
|
|
||||||
response = self.client.get("/api/analytics/analytics/dashboard/")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "total_appointments_this_month" in response.data
|
|
||||||
|
|
||||||
def test_dashboard_endpoint_structure(self):
|
|
||||||
"""Test dashboard endpoint returns correct data structure"""
|
|
||||||
# Setup permission
|
|
||||||
self.tenant.subscription_plan = self.plan_with_analytics
|
|
||||||
self.tenant.save()
|
|
||||||
|
|
||||||
self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}")
|
|
||||||
response = self.client.get("/api/analytics/analytics/dashboard/")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
# Check required fields
|
|
||||||
required_fields = [
|
|
||||||
'total_appointments_this_month',
|
|
||||||
'total_appointments_all_time',
|
|
||||||
'active_resources_count',
|
|
||||||
'active_services_count',
|
|
||||||
'upcoming_appointments_count',
|
|
||||||
'average_appointment_duration_minutes',
|
|
||||||
'peak_booking_day',
|
|
||||||
'peak_booking_hour',
|
|
||||||
'period'
|
|
||||||
]
|
|
||||||
|
|
||||||
for field in required_fields:
|
|
||||||
assert field in response.data, f"Missing field: {field}"
|
|
||||||
|
|
||||||
def test_appointments_endpoint_with_filters(self):
|
|
||||||
"""Test appointments endpoint with query parameters"""
|
|
||||||
self.tenant.subscription_plan = self.plan_with_analytics
|
|
||||||
self.tenant.save()
|
|
||||||
|
|
||||||
# Create test service and resource
|
|
||||||
service = Service.objects.create(
|
|
||||||
name="Haircut",
|
|
||||||
business=self.tenant
|
|
||||||
)
|
|
||||||
|
|
||||||
resource = Resource.objects.create(
|
|
||||||
name="Chair 1",
|
|
||||||
business=self.tenant
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create a test appointment
|
|
||||||
now = timezone.now()
|
|
||||||
Event.objects.create(
|
|
||||||
title="Test Appointment",
|
|
||||||
start_time=now,
|
|
||||||
end_time=now + timedelta(hours=1),
|
|
||||||
status="confirmed",
|
|
||||||
service=service,
|
|
||||||
business=self.tenant
|
|
||||||
)
|
|
||||||
|
|
||||||
self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}")
|
|
||||||
|
|
||||||
# Test without filters
|
|
||||||
response = self.client.get("/api/analytics/analytics/appointments/")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.data['total'] >= 1
|
|
||||||
|
|
||||||
# Test with days filter
|
|
||||||
response = self.client.get("/api/analytics/analytics/appointments/?days=7")
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
# Test with service filter
|
|
||||||
response = self.client.get(f"/api/analytics/analytics/appointments/?service_id={service.id}")
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
def test_revenue_requires_payments_permission(self):
|
|
||||||
"""Test that revenue analytics requires both permissions"""
|
|
||||||
self.tenant.subscription_plan = self.plan_with_analytics
|
|
||||||
self.tenant.save()
|
|
||||||
|
|
||||||
self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}")
|
|
||||||
response = self.client.get("/api/analytics/analytics/revenue/")
|
|
||||||
|
|
||||||
# Should be denied because tenant doesn't have can_accept_payments
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert "Payment analytics not available" in str(response.data)
|
|
||||||
|
|
||||||
def test_multiple_permission_check(self):
|
|
||||||
"""Test that both IsAuthenticated and HasFeaturePermission are checked"""
|
|
||||||
self.tenant.subscription_plan = self.plan_with_analytics
|
|
||||||
self.tenant.save()
|
|
||||||
|
|
||||||
# No auth token = 401
|
|
||||||
response = self.client.get("/api/analytics/analytics/dashboard/")
|
|
||||||
assert response.status_code == 401
|
|
||||||
|
|
||||||
# With auth but no permission = 403
|
|
||||||
self.tenant.subscription_plan = self.plan_without_analytics
|
|
||||||
self.tenant.save()
|
|
||||||
|
|
||||||
self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}")
|
|
||||||
response = self.client.get("/api/analytics/analytics/dashboard/")
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
class TestAnalyticsData:
|
|
||||||
"""Test analytics data calculation"""
|
|
||||||
|
|
||||||
def setup_method(self):
|
|
||||||
"""Setup test data"""
|
|
||||||
self.client = APIClient()
|
|
||||||
|
|
||||||
self.tenant = Tenant.objects.create(
|
|
||||||
name="Test Business",
|
|
||||||
schema_name="test_business"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.user = User.objects.create_user(
|
|
||||||
email="test@example.com",
|
|
||||||
password="testpass123",
|
|
||||||
role=User.Role.TENANT_OWNER,
|
|
||||||
tenant=self.tenant
|
|
||||||
)
|
|
||||||
|
|
||||||
self.token = Token.objects.create(user=self.user)
|
|
||||||
|
|
||||||
self.plan = SubscriptionPlan.objects.create(
|
|
||||||
name="Professional",
|
|
||||||
business_tier="PROFESSIONAL",
|
|
||||||
permissions={"advanced_analytics": True}
|
|
||||||
)
|
|
||||||
|
|
||||||
self.tenant.subscription_plan = self.plan
|
|
||||||
self.tenant.save()
|
|
||||||
|
|
||||||
self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}")
|
|
||||||
|
|
||||||
def test_dashboard_counts_appointments_correctly(self):
|
|
||||||
"""Test that dashboard counts appointments accurately"""
|
|
||||||
now = timezone.now()
|
|
||||||
|
|
||||||
# Create appointments in current month
|
|
||||||
for i in range(5):
|
|
||||||
Event.objects.create(
|
|
||||||
title=f"Appointment {i}",
|
|
||||||
start_time=now + timedelta(hours=i),
|
|
||||||
end_time=now + timedelta(hours=i+1),
|
|
||||||
status="confirmed",
|
|
||||||
business=self.tenant
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create appointment in previous month
|
|
||||||
last_month = now - timedelta(days=40)
|
|
||||||
Event.objects.create(
|
|
||||||
title="Old Appointment",
|
|
||||||
start_time=last_month,
|
|
||||||
end_time=last_month + timedelta(hours=1),
|
|
||||||
status="confirmed",
|
|
||||||
business=self.tenant
|
|
||||||
)
|
|
||||||
|
|
||||||
response = self.client.get("/api/analytics/analytics/dashboard/")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.data['total_appointments_this_month'] == 5
|
|
||||||
assert response.data['total_appointments_all_time'] == 6
|
|
||||||
|
|
||||||
def test_appointments_counts_by_status(self):
|
|
||||||
"""Test that appointments are counted by status"""
|
|
||||||
now = timezone.now()
|
|
||||||
|
|
||||||
# Create appointments with different statuses
|
|
||||||
Event.objects.create(
|
|
||||||
title="Confirmed",
|
|
||||||
start_time=now,
|
|
||||||
end_time=now + timedelta(hours=1),
|
|
||||||
status="confirmed",
|
|
||||||
business=self.tenant
|
|
||||||
)
|
|
||||||
|
|
||||||
Event.objects.create(
|
|
||||||
title="Cancelled",
|
|
||||||
start_time=now,
|
|
||||||
end_time=now + timedelta(hours=1),
|
|
||||||
status="cancelled",
|
|
||||||
business=self.tenant
|
|
||||||
)
|
|
||||||
|
|
||||||
Event.objects.create(
|
|
||||||
title="No Show",
|
|
||||||
start_time=now,
|
|
||||||
end_time=now + timedelta(hours=1),
|
|
||||||
status="no_show",
|
|
||||||
business=self.tenant
|
|
||||||
)
|
|
||||||
|
|
||||||
response = self.client.get("/api/analytics/analytics/appointments/")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.data['by_status']['confirmed'] == 1
|
|
||||||
assert response.data['by_status']['cancelled'] == 1
|
|
||||||
assert response.data['by_status']['no_show'] == 1
|
|
||||||
assert response.data['total'] == 3
|
|
||||||
|
|
||||||
def test_cancellation_rate_calculation(self):
|
|
||||||
"""Test cancellation rate is calculated correctly"""
|
|
||||||
now = timezone.now()
|
|
||||||
|
|
||||||
# Create 100 total appointments: 80 confirmed, 20 cancelled
|
|
||||||
for i in range(80):
|
|
||||||
Event.objects.create(
|
|
||||||
title=f"Confirmed {i}",
|
|
||||||
start_time=now,
|
|
||||||
end_time=now + timedelta(hours=1),
|
|
||||||
status="confirmed",
|
|
||||||
business=self.tenant
|
|
||||||
)
|
|
||||||
|
|
||||||
for i in range(20):
|
|
||||||
Event.objects.create(
|
|
||||||
title=f"Cancelled {i}",
|
|
||||||
start_time=now,
|
|
||||||
end_time=now + timedelta(hours=1),
|
|
||||||
status="cancelled",
|
|
||||||
business=self.tenant
|
|
||||||
)
|
|
||||||
|
|
||||||
response = self.client.get("/api/analytics/analytics/appointments/")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
# 20 cancelled / 100 total = 20%
|
|
||||||
assert response.data['cancellation_rate_percent'] == 20.0
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
# Create your tests here.
|
|
||||||
@@ -2,7 +2,7 @@ from django.conf import settings
|
|||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
from rest_framework.routers import SimpleRouter
|
from rest_framework.routers import SimpleRouter
|
||||||
|
|
||||||
from smoothschedule.users.api.views import UserViewSet
|
from smoothschedule.identity.users.api.views import UserViewSet
|
||||||
|
|
||||||
router = DefaultRouter() if settings.DEBUG else SimpleRouter()
|
router = DefaultRouter() if settings.DEBUG else SimpleRouter()
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ django_asgi_app = get_asgi_application()
|
|||||||
from channels.auth import AuthMiddlewareStack
|
from channels.auth import AuthMiddlewareStack
|
||||||
from channels.routing import ProtocolTypeRouter, URLRouter
|
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||||
|
|
||||||
from tickets import routing as tickets_routing
|
from smoothschedule.commerce.tickets import routing as tickets_routing
|
||||||
from schedule import routing as schedule_routing
|
from smoothschedule.scheduling.schedule import routing as schedule_routing
|
||||||
from tickets.middleware import TokenAuthMiddleware
|
from smoothschedule.commerce.tickets.middleware import TokenAuthMiddleware
|
||||||
|
|
||||||
|
|
||||||
application = ProtocolTypeRouter(
|
application = ProtocolTypeRouter(
|
||||||
|
|||||||
@@ -97,17 +97,28 @@ THIRD_PARTY_APPS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
LOCAL_APPS = [
|
LOCAL_APPS = [
|
||||||
"smoothschedule.users",
|
# Identity Domain
|
||||||
"core",
|
"smoothschedule.identity.users",
|
||||||
"schedule",
|
"smoothschedule.identity.core",
|
||||||
"analytics",
|
|
||||||
"payments",
|
# Scheduling Domain
|
||||||
"platform_admin.apps.PlatformAdminConfig",
|
"smoothschedule.scheduling.schedule",
|
||||||
"notifications", # New: Generic notification app
|
"smoothschedule.scheduling.contracts",
|
||||||
"tickets", # New: Support tickets app
|
"smoothschedule.scheduling.analytics",
|
||||||
"smoothschedule.comms_credits", # Communication credits and SMS/calling
|
|
||||||
"smoothschedule.field_mobile", # Field employee mobile app
|
# Communication Domain
|
||||||
# Your stuff: custom apps go here
|
"smoothschedule.communication.notifications",
|
||||||
|
"smoothschedule.communication.credits", # SMS/calling credits (was comms_credits)
|
||||||
|
"smoothschedule.communication.mobile", # Field employee app (was field_mobile)
|
||||||
|
"smoothschedule.communication.messaging", # Twilio conversations (was communication)
|
||||||
|
|
||||||
|
# Commerce Domain
|
||||||
|
"smoothschedule.commerce.payments",
|
||||||
|
"smoothschedule.commerce.tickets",
|
||||||
|
|
||||||
|
# Platform Domain
|
||||||
|
"smoothschedule.platform.admin", # Platform settings (was platform_admin)
|
||||||
|
"smoothschedule.platform.api", # Public API v1 (was public_api)
|
||||||
]
|
]
|
||||||
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
|
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
|
||||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||||
@@ -183,7 +194,7 @@ TEMPLATES = [
|
|||||||
"django.template.context_processors.media",
|
"django.template.context_processors.media",
|
||||||
"django.template.context_processors.static",
|
"django.template.context_processors.static",
|
||||||
"django.template.context_processors.tz",
|
"django.template.context_processors.tz",
|
||||||
"smoothschedule.users.context_processors.allauth_settings",
|
"smoothschedule.identity.users.context_processors.allauth_settings",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -318,13 +329,13 @@ ACCOUNT_SIGNUP_FIELDS = ["email*", "username*", "password1*", "password2*"]
|
|||||||
# https://docs.allauth.org/en/latest/account/configuration.html
|
# https://docs.allauth.org/en/latest/account/configuration.html
|
||||||
ACCOUNT_EMAIL_VERIFICATION = "mandatory"
|
ACCOUNT_EMAIL_VERIFICATION = "mandatory"
|
||||||
# https://docs.allauth.org/en/latest/account/configuration.html
|
# https://docs.allauth.org/en/latest/account/configuration.html
|
||||||
ACCOUNT_ADAPTER = "smoothschedule.users.adapters.AccountAdapter"
|
ACCOUNT_ADAPTER = "smoothschedule.identity.users.adapters.AccountAdapter"
|
||||||
# https://docs.allauth.org/en/latest/account/forms.html
|
# https://docs.allauth.org/en/latest/account/forms.html
|
||||||
ACCOUNT_FORMS = {"signup": "smoothschedule.users.forms.UserSignupForm"}
|
ACCOUNT_FORMS = {"signup": "smoothschedule.identity.users.forms.UserSignupForm"}
|
||||||
# https://docs.allauth.org/en/latest/socialaccount/configuration.html
|
# https://docs.allauth.org/en/latest/socialaccount/configuration.html
|
||||||
SOCIALACCOUNT_ADAPTER = "smoothschedule.users.adapters.SocialAccountAdapter"
|
SOCIALACCOUNT_ADAPTER = "smoothschedule.identity.users.adapters.SocialAccountAdapter"
|
||||||
# https://docs.allauth.org/en/latest/socialaccount/configuration.html
|
# https://docs.allauth.org/en/latest/socialaccount/configuration.html
|
||||||
SOCIALACCOUNT_FORMS = {"signup": "smoothschedule.users.forms.UserSocialSignupForm"}
|
SOCIALACCOUNT_FORMS = {"signup": "smoothschedule.identity.users.forms.UserSocialSignupForm"}
|
||||||
|
|
||||||
# django-rest-framework
|
# django-rest-framework
|
||||||
# -------------------------------------------------------------------------------
|
# -------------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -13,10 +13,16 @@ from .base import INSTALLED_APPS, MIDDLEWARE, DATABASES, LOGGING, env
|
|||||||
# Shared apps - Available to all tenants (stored in 'public' schema)
|
# Shared apps - Available to all tenants (stored in 'public' schema)
|
||||||
SHARED_APPS = [
|
SHARED_APPS = [
|
||||||
'django_tenants', # Must be first
|
'django_tenants', # Must be first
|
||||||
'core', # Core models (Tenant, Domain, PermissionGrant)
|
|
||||||
'platform_admin.apps.PlatformAdminConfig', # Platform management (TenantInvitation, etc.)
|
|
||||||
|
|
||||||
# Django built-ins (must be in shared
|
# Identity Domain (shared)
|
||||||
|
'smoothschedule.identity.core', # Core models (Tenant, Domain, PermissionGrant)
|
||||||
|
'smoothschedule.identity.users', # Users app (shared across tenants)
|
||||||
|
|
||||||
|
# Platform Domain (shared)
|
||||||
|
'smoothschedule.platform.admin', # Platform management (TenantInvitation, etc.)
|
||||||
|
'smoothschedule.platform.api', # Public API v1 for third-party integrations
|
||||||
|
|
||||||
|
# Django built-ins (must be in shared)
|
||||||
'django.contrib.contenttypes',
|
'django.contrib.contenttypes',
|
||||||
'django.contrib.auth',
|
'django.contrib.auth',
|
||||||
'django.contrib.sessions',
|
'django.contrib.sessions',
|
||||||
@@ -25,15 +31,12 @@ SHARED_APPS = [
|
|||||||
'django.contrib.staticfiles',
|
'django.contrib.staticfiles',
|
||||||
'django.contrib.admin',
|
'django.contrib.admin',
|
||||||
|
|
||||||
# Users app (shared across tenants)
|
|
||||||
'smoothschedule.users',
|
|
||||||
|
|
||||||
# Third-party apps that should be shared
|
# Third-party apps that should be shared
|
||||||
'rest_framework',
|
'rest_framework',
|
||||||
'rest_framework.authtoken',
|
'rest_framework.authtoken',
|
||||||
'corsheaders',
|
'corsheaders',
|
||||||
'drf_spectacular',
|
'drf_spectacular',
|
||||||
'channels', # WebSockets
|
'channels', # WebSockets
|
||||||
'allauth',
|
'allauth',
|
||||||
'allauth.account',
|
'allauth.account',
|
||||||
'allauth.mfa',
|
'allauth.mfa',
|
||||||
@@ -45,23 +48,26 @@ SHARED_APPS = [
|
|||||||
'crispy_bootstrap5',
|
'crispy_bootstrap5',
|
||||||
'csp',
|
'csp',
|
||||||
'djstripe', # Stripe integration
|
'djstripe', # Stripe integration
|
||||||
'tickets', # Ticket system - shared for platform support access
|
|
||||||
'notifications', # Notification system - shared for platform to notify tenants
|
# Commerce Domain (shared for platform support)
|
||||||
'smoothschedule.public_api', # Public API v1 for third-party integrations
|
'smoothschedule.commerce.tickets', # Ticket system - shared for platform support access
|
||||||
'smoothschedule.comms_credits', # Communication credits (SMS/calling) - shared for billing
|
|
||||||
'smoothschedule.field_mobile', # Field employee mobile app - shared for location tracking
|
# Communication Domain (shared)
|
||||||
|
'smoothschedule.communication.notifications', # Notification system - shared for platform
|
||||||
|
'smoothschedule.communication.credits', # Communication credits (SMS/calling) - shared for billing
|
||||||
|
'smoothschedule.communication.mobile', # Field employee mobile app - shared for location tracking
|
||||||
]
|
]
|
||||||
|
|
||||||
# Tenant-specific apps - Each tenant gets isolated data in their own schema
|
# Tenant-specific apps - Each tenant gets isolated data in their own schema
|
||||||
TENANT_APPS = [
|
TENANT_APPS = [
|
||||||
'django.contrib.contenttypes', # Needed for tenant schemas
|
'django.contrib.contenttypes', # Needed for tenant schemas
|
||||||
'schedule', # Resource scheduling with configurable concurrency
|
|
||||||
'payments', # Stripe Connect payments bridge
|
# Scheduling Domain (tenant-isolated)
|
||||||
'contracts', # Contract/e-signature system
|
'smoothschedule.scheduling.schedule', # Resource scheduling with configurable concurrency
|
||||||
# Add your tenant-scoped business logic apps here:
|
'smoothschedule.scheduling.contracts', # Contract/e-signature system
|
||||||
# 'appointments',
|
|
||||||
# 'customers',
|
# Commerce Domain (tenant-isolated)
|
||||||
# 'analytics',
|
'smoothschedule.commerce.payments', # Stripe Connect payments bridge
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -96,7 +102,7 @@ MIDDLEWARE = [
|
|||||||
|
|
||||||
# 1. Tenant resolution
|
# 1. Tenant resolution
|
||||||
'django_tenants.middleware.main.TenantMainMiddleware',
|
'django_tenants.middleware.main.TenantMainMiddleware',
|
||||||
'core.middleware.TenantHeaderMiddleware', # Support tenant switching via header
|
'smoothschedule.identity.core.middleware.TenantHeaderMiddleware', # Support tenant switching via header
|
||||||
|
|
||||||
# 2. Security middleware
|
# 2. Security middleware
|
||||||
'django.middleware.security.SecurityMiddleware',
|
'django.middleware.security.SecurityMiddleware',
|
||||||
@@ -108,7 +114,7 @@ MIDDLEWARE = [
|
|||||||
|
|
||||||
# 4. Sandbox mode - switches to sandbox schema if requested
|
# 4. Sandbox mode - switches to sandbox schema if requested
|
||||||
# MUST come after TenantMainMiddleware and SessionMiddleware
|
# MUST come after TenantMainMiddleware and SessionMiddleware
|
||||||
'core.middleware.SandboxModeMiddleware',
|
'smoothschedule.identity.core.middleware.SandboxModeMiddleware',
|
||||||
'django.middleware.locale.LocaleMiddleware',
|
'django.middleware.locale.LocaleMiddleware',
|
||||||
'django.middleware.common.CommonMiddleware',
|
'django.middleware.common.CommonMiddleware',
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
@@ -120,7 +126,7 @@ MIDDLEWARE = [
|
|||||||
'hijack.middleware.HijackUserMiddleware',
|
'hijack.middleware.HijackUserMiddleware',
|
||||||
|
|
||||||
# 6. MASQUERADE AUDIT - MUST come AFTER HijackUserMiddleware
|
# 6. MASQUERADE AUDIT - MUST come AFTER HijackUserMiddleware
|
||||||
'core.middleware.MasqueradeAuditMiddleware',
|
'smoothschedule.identity.core.middleware.MasqueradeAuditMiddleware',
|
||||||
|
|
||||||
# 7. Messages, Clickjacking, and Allauth
|
# 7. Messages, Clickjacking, and Allauth
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
@@ -176,7 +182,7 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||||||
# HIJACK (MASQUERADING) CONFIGURATION
|
# HIJACK (MASQUERADING) CONFIGURATION
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
HIJACK_AUTHORIZATION_CHECK = 'core.permissions.can_hijack'
|
HIJACK_AUTHORIZATION_CHECK = 'smoothschedule.identity.core.permissions.can_hijack'
|
||||||
HIJACK_DISPLAY_ADMIN_BUTTON = True
|
HIJACK_DISPLAY_ADMIN_BUTTON = True
|
||||||
HIJACK_USE_BOOTSTRAP = True
|
HIJACK_USE_BOOTSTRAP = True
|
||||||
HIJACK_ALLOW_GET_REQUESTS = False # Security: require POST
|
HIJACK_ALLOW_GET_REQUESTS = False # Security: require POST
|
||||||
|
|||||||
@@ -2,9 +2,8 @@
|
|||||||
With these settings, tests run faster.
|
With these settings, tests run faster.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .base import * # noqa: F403
|
from .multitenancy import * # noqa: F403
|
||||||
from .base import TEMPLATES
|
from .multitenancy import TEMPLATES, env
|
||||||
from .base import env
|
|
||||||
|
|
||||||
# GENERAL
|
# GENERAL
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
@@ -19,6 +18,8 @@ TEST_RUNNER = "django.test.runner.DiscoverRunner"
|
|||||||
# PASSWORDS
|
# PASSWORDS
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
# https://docs.djangoproject.com/en/dev/ref/settings/#password-hashers
|
# https://docs.djangoproject.com/en/dev/ref/settings/#password-hashers
|
||||||
|
# Use fast password hasher for tests (bcrypt is intentionally slow)
|
||||||
|
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
|
||||||
|
|
||||||
# EMAIL
|
# EMAIL
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
@@ -35,3 +36,27 @@ TEMPLATES[0]["OPTIONS"]["debug"] = True # type: ignore[index]
|
|||||||
MEDIA_URL = "http://media.testserver/"
|
MEDIA_URL = "http://media.testserver/"
|
||||||
# Your stuff...
|
# Your stuff...
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# CHANNELS
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# Use in-memory channel layer for tests (no Redis needed)
|
||||||
|
CHANNEL_LAYERS = {
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "channels.layers.InMemoryChannelLayer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# CELERY
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# Run tasks synchronously in tests
|
||||||
|
CELERY_TASK_ALWAYS_EAGER = True
|
||||||
|
CELERY_TASK_EAGER_PROPAGATES = True
|
||||||
|
|
||||||
|
# CACHES
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# Use local memory cache for tests
|
||||||
|
CACHES = {
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,33 +10,33 @@ from drf_spectacular.views import SpectacularAPIView
|
|||||||
from drf_spectacular.views import SpectacularSwaggerView
|
from drf_spectacular.views import SpectacularSwaggerView
|
||||||
from rest_framework.authtoken.views import obtain_auth_token
|
from rest_framework.authtoken.views import obtain_auth_token
|
||||||
|
|
||||||
from smoothschedule.users.api_views import (
|
from smoothschedule.identity.users.api_views import (
|
||||||
login_view, current_user_view, logout_view, send_verification_email, verify_email,
|
login_view, current_user_view, logout_view, send_verification_email, verify_email,
|
||||||
hijack_acquire_view, hijack_release_view,
|
hijack_acquire_view, hijack_release_view,
|
||||||
staff_invitations_view, cancel_invitation_view, resend_invitation_view,
|
staff_invitations_view, cancel_invitation_view, resend_invitation_view,
|
||||||
invitation_details_view, accept_invitation_view, decline_invitation_view,
|
invitation_details_view, accept_invitation_view, decline_invitation_view,
|
||||||
check_subdomain_view, signup_view
|
check_subdomain_view, signup_view
|
||||||
)
|
)
|
||||||
from smoothschedule.users.mfa_api_views import (
|
from smoothschedule.identity.users.mfa_api_views import (
|
||||||
mfa_status, send_phone_verification, verify_phone, enable_sms_mfa,
|
mfa_status, send_phone_verification, verify_phone, enable_sms_mfa,
|
||||||
setup_totp, verify_totp_setup, generate_backup_codes, backup_codes_status,
|
setup_totp, verify_totp_setup, generate_backup_codes, backup_codes_status,
|
||||||
disable_mfa, mfa_login_send_code, mfa_login_verify,
|
disable_mfa, mfa_login_send_code, mfa_login_verify,
|
||||||
list_trusted_devices, revoke_trusted_device, revoke_all_trusted_devices
|
list_trusted_devices, revoke_trusted_device, revoke_all_trusted_devices
|
||||||
)
|
)
|
||||||
from schedule.api_views import (
|
from smoothschedule.scheduling.schedule.api_views import (
|
||||||
current_business_view, update_business_view,
|
current_business_view, update_business_view,
|
||||||
oauth_settings_view, oauth_credentials_view,
|
oauth_settings_view, oauth_credentials_view,
|
||||||
custom_domains_view, custom_domain_detail_view,
|
custom_domains_view, custom_domain_detail_view,
|
||||||
custom_domain_verify_view, custom_domain_set_primary_view,
|
custom_domain_verify_view, custom_domain_set_primary_view,
|
||||||
sandbox_status_view, sandbox_toggle_view, sandbox_reset_view
|
sandbox_status_view, sandbox_toggle_view, sandbox_reset_view
|
||||||
)
|
)
|
||||||
from core.email_autoconfig import (
|
from smoothschedule.identity.core.email_autoconfig import (
|
||||||
MozillaAutoconfigView,
|
MozillaAutoconfigView,
|
||||||
MicrosoftAutodiscoverView,
|
MicrosoftAutodiscoverView,
|
||||||
AppleConfigProfileView,
|
AppleConfigProfileView,
|
||||||
WellKnownAutoconfigView,
|
WellKnownAutoconfigView,
|
||||||
)
|
)
|
||||||
from core.api_views import (
|
from smoothschedule.identity.core.api_views import (
|
||||||
quota_status_view,
|
quota_status_view,
|
||||||
quota_resources_view,
|
quota_resources_view,
|
||||||
quota_archive_view,
|
quota_archive_view,
|
||||||
@@ -48,7 +48,7 @@ urlpatterns = [
|
|||||||
# Django Admin, use {% url 'admin:index' %}
|
# Django Admin, use {% url 'admin:index' %}
|
||||||
path(settings.ADMIN_URL, admin.site.urls),
|
path(settings.ADMIN_URL, admin.site.urls),
|
||||||
# User management
|
# User management
|
||||||
path("users/", include("smoothschedule.users.urls", namespace="users")),
|
path("users/", include("smoothschedule.identity.users.urls", namespace="users")),
|
||||||
path("accounts/", include("allauth.urls")),
|
path("accounts/", include("allauth.urls")),
|
||||||
# Django Hijack (masquerade) - for admin interface
|
# Django Hijack (masquerade) - for admin interface
|
||||||
path("hijack/", include("hijack.urls")),
|
path("hijack/", include("hijack.urls")),
|
||||||
@@ -78,28 +78,28 @@ urlpatterns += [
|
|||||||
# Stripe Webhooks (dj-stripe built-in handler)
|
# Stripe Webhooks (dj-stripe built-in handler)
|
||||||
path("stripe/", include("djstripe.urls", namespace="djstripe")),
|
path("stripe/", include("djstripe.urls", namespace="djstripe")),
|
||||||
# Public API v1 (for third-party integrations)
|
# Public API v1 (for third-party integrations)
|
||||||
path("v1/", include("smoothschedule.public_api.urls", namespace="public_api")),
|
path("v1/", include("smoothschedule.platform.api.urls", namespace="public_api")),
|
||||||
# Schedule API (internal)
|
# Schedule API (internal)
|
||||||
path("", include("schedule.urls")),
|
path("", include("smoothschedule.scheduling.schedule.urls")),
|
||||||
# Analytics API
|
# Analytics API
|
||||||
path("", include("analytics.urls")),
|
path("", include("smoothschedule.scheduling.analytics.urls")),
|
||||||
# Payments API
|
# Payments API
|
||||||
path("payments/", include("payments.urls")),
|
path("payments/", include("smoothschedule.commerce.payments.urls")),
|
||||||
# Contracts API
|
# Contracts API
|
||||||
path("contracts/", include("contracts.urls")),
|
path("contracts/", include("smoothschedule.scheduling.contracts.urls")),
|
||||||
# Communication Credits API
|
# Communication Credits API
|
||||||
path("communication-credits/", include("smoothschedule.comms_credits.urls", namespace="comms_credits")),
|
path("communication-credits/", include("smoothschedule.communication.credits.urls", namespace="comms_credits")),
|
||||||
# Field Mobile API (for field employee mobile app)
|
# Field Mobile API (for field employee mobile app)
|
||||||
path("mobile/", include("smoothschedule.field_mobile.urls", namespace="field_mobile")),
|
path("mobile/", include("smoothschedule.communication.mobile.urls", namespace="field_mobile")),
|
||||||
# Tickets API
|
# Tickets API
|
||||||
path("tickets/", include("tickets.urls")),
|
path("tickets/", include("smoothschedule.commerce.tickets.urls")),
|
||||||
# Notifications API
|
# Notifications API
|
||||||
path("notifications/", include("notifications.urls")),
|
path("notifications/", include("smoothschedule.communication.notifications.urls")),
|
||||||
# Platform API
|
# Platform API
|
||||||
path("platform/", include("platform_admin.urls", namespace="platform")),
|
path("platform/", include("smoothschedule.platform.admin.urls", namespace="platform")),
|
||||||
# OAuth Email Integration API
|
# OAuth Email Integration API
|
||||||
path("oauth/", include("core.oauth_urls", namespace="oauth")),
|
path("oauth/", include("smoothschedule.identity.core.oauth_urls", namespace="oauth")),
|
||||||
path("auth/oauth/", include("core.oauth_urls", namespace="auth_oauth")),
|
path("auth/oauth/", include("smoothschedule.identity.core.oauth_urls", namespace="auth_oauth")),
|
||||||
# Auth API
|
# Auth API
|
||||||
path("auth-token/", csrf_exempt(obtain_auth_token), name="obtain_auth_token"),
|
path("auth-token/", csrf_exempt(obtain_auth_token), name="obtain_auth_token"),
|
||||||
path("auth/signup/check-subdomain/", check_subdomain_view, name="check_subdomain"),
|
path("auth/signup/check-subdomain/", check_subdomain_view, name="check_subdomain"),
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
# Create your tests here.
|
|
||||||
@@ -6,7 +6,7 @@ import django
|
|||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local")
|
||||||
django.setup()
|
django.setup()
|
||||||
|
|
||||||
from smoothschedule.users.models import User
|
from smoothschedule.identity.users.models import User
|
||||||
from rest_framework.authtoken.models import Token
|
from rest_framework.authtoken.models import Token
|
||||||
|
|
||||||
# Create or get a superuser with platform admin role
|
# Create or get a superuser with platform admin role
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Create a default tenant for local development
|
Create a default tenant for local development
|
||||||
"""
|
"""
|
||||||
from core.models import Tenant, Domain
|
from smoothschedule.identity.core.models import Tenant, Domain
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import os
|
|||||||
import django
|
import django
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django_tenants.utils import tenant_context
|
from django_tenants.utils import tenant_context
|
||||||
from core.models import Tenant
|
from smoothschedule.identity.core.models import Tenant
|
||||||
|
|
||||||
# Setup Django
|
# Setup Django
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local")
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
# Create your tests here.
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
# Create your tests here.
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for Data Export API
|
|
||||||
|
|
||||||
Run with:
|
|
||||||
docker compose -f docker-compose.local.yml exec django python manage.py test schedule.test_export
|
|
||||||
"""
|
|
||||||
from django.test import TestCase, Client
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.utils import timezone
|
|
||||||
from datetime import timedelta
|
|
||||||
from core.models import Tenant, Domain
|
|
||||||
from schedule.models import Event, Resource, Service
|
|
||||||
from smoothschedule.users.models import User as CustomUser
|
|
||||||
|
|
||||||
User = get_user_model()
|
|
||||||
|
|
||||||
|
|
||||||
class DataExportAPITestCase(TestCase):
|
|
||||||
"""Test suite for data export API endpoints"""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
"""Set up test fixtures"""
|
|
||||||
# Create tenant with export permission
|
|
||||||
self.tenant = Tenant.objects.create(
|
|
||||||
name="Test Business",
|
|
||||||
schema_name="test_business",
|
|
||||||
can_export_data=True, # Enable export permission
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create domain for tenant
|
|
||||||
self.domain = Domain.objects.create(
|
|
||||||
tenant=self.tenant,
|
|
||||||
domain="test.lvh.me",
|
|
||||||
is_primary=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create test user (owner)
|
|
||||||
self.user = CustomUser.objects.create_user(
|
|
||||||
username="testowner",
|
|
||||||
email="owner@test.com",
|
|
||||||
password="testpass123",
|
|
||||||
role=CustomUser.Role.TENANT_OWNER,
|
|
||||||
tenant=self.tenant
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create test customer
|
|
||||||
self.customer = CustomUser.objects.create_user(
|
|
||||||
username="customer1",
|
|
||||||
email="customer@test.com",
|
|
||||||
first_name="John",
|
|
||||||
last_name="Doe",
|
|
||||||
role=CustomUser.Role.CUSTOMER,
|
|
||||||
tenant=self.tenant
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create test resource
|
|
||||||
self.resource = Resource.objects.create(
|
|
||||||
name="Test Resource",
|
|
||||||
type=Resource.Type.STAFF,
|
|
||||||
max_concurrent_events=1
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create test service
|
|
||||||
self.service = Service.objects.create(
|
|
||||||
name="Test Service",
|
|
||||||
description="Test service description",
|
|
||||||
duration=60,
|
|
||||||
price=50.00
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create test event
|
|
||||||
now = timezone.now()
|
|
||||||
self.event = Event.objects.create(
|
|
||||||
title="Test Appointment",
|
|
||||||
start_time=now,
|
|
||||||
end_time=now + timedelta(hours=1),
|
|
||||||
status=Event.Status.SCHEDULED,
|
|
||||||
notes="Test notes",
|
|
||||||
created_by=self.user
|
|
||||||
)
|
|
||||||
|
|
||||||
# Set up authenticated client
|
|
||||||
self.client = Client()
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
|
|
||||||
def test_appointments_export_json(self):
|
|
||||||
"""Test exporting appointments in JSON format"""
|
|
||||||
response = self.client.get('/export/appointments/?format=json')
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertIn('application/json', response['Content-Type'])
|
|
||||||
|
|
||||||
# Check response structure
|
|
||||||
data = response.json()
|
|
||||||
self.assertIn('count', data)
|
|
||||||
self.assertIn('data', data)
|
|
||||||
self.assertIn('exported_at', data)
|
|
||||||
self.assertIn('filters', data)
|
|
||||||
|
|
||||||
# Verify data
|
|
||||||
self.assertEqual(data['count'], 1)
|
|
||||||
self.assertEqual(len(data['data']), 1)
|
|
||||||
|
|
||||||
appointment = data['data'][0]
|
|
||||||
self.assertEqual(appointment['title'], 'Test Appointment')
|
|
||||||
self.assertEqual(appointment['status'], 'SCHEDULED')
|
|
||||||
|
|
||||||
def test_appointments_export_csv(self):
|
|
||||||
"""Test exporting appointments in CSV format"""
|
|
||||||
response = self.client.get('/export/appointments/?format=csv')
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertIn('text/csv', response['Content-Type'])
|
|
||||||
self.assertIn('attachment', response['Content-Disposition'])
|
|
||||||
|
|
||||||
# Check CSV content
|
|
||||||
content = response.content.decode('utf-8')
|
|
||||||
self.assertIn('id,title,start_time', content)
|
|
||||||
self.assertIn('Test Appointment', content)
|
|
||||||
|
|
||||||
def test_customers_export_json(self):
|
|
||||||
"""Test exporting customers in JSON format"""
|
|
||||||
response = self.client.get('/export/customers/?format=json')
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
self.assertEqual(data['count'], 1)
|
|
||||||
customer = data['data'][0]
|
|
||||||
self.assertEqual(customer['email'], 'customer@test.com')
|
|
||||||
self.assertEqual(customer['first_name'], 'John')
|
|
||||||
self.assertEqual(customer['last_name'], 'Doe')
|
|
||||||
|
|
||||||
def test_customers_export_csv(self):
|
|
||||||
"""Test exporting customers in CSV format"""
|
|
||||||
response = self.client.get('/export/customers/?format=csv')
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertIn('text/csv', response['Content-Type'])
|
|
||||||
|
|
||||||
content = response.content.decode('utf-8')
|
|
||||||
self.assertIn('customer@test.com', content)
|
|
||||||
self.assertIn('John', content)
|
|
||||||
|
|
||||||
def test_resources_export_json(self):
|
|
||||||
"""Test exporting resources in JSON format"""
|
|
||||||
response = self.client.get('/export/resources/?format=json')
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
self.assertEqual(data['count'], 1)
|
|
||||||
resource = data['data'][0]
|
|
||||||
self.assertEqual(resource['name'], 'Test Resource')
|
|
||||||
self.assertEqual(resource['type'], 'STAFF')
|
|
||||||
|
|
||||||
def test_services_export_json(self):
|
|
||||||
"""Test exporting services in JSON format"""
|
|
||||||
response = self.client.get('/export/services/?format=json')
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
self.assertEqual(data['count'], 1)
|
|
||||||
service = data['data'][0]
|
|
||||||
self.assertEqual(service['name'], 'Test Service')
|
|
||||||
self.assertEqual(service['duration'], 60)
|
|
||||||
self.assertEqual(service['price'], '50.00')
|
|
||||||
|
|
||||||
def test_date_range_filter(self):
|
|
||||||
"""Test filtering appointments by date range"""
|
|
||||||
# Create appointment in the past
|
|
||||||
past_time = timezone.now() - timedelta(days=30)
|
|
||||||
Event.objects.create(
|
|
||||||
title="Past Appointment",
|
|
||||||
start_time=past_time,
|
|
||||||
end_time=past_time + timedelta(hours=1),
|
|
||||||
status=Event.Status.COMPLETED,
|
|
||||||
created_by=self.user
|
|
||||||
)
|
|
||||||
|
|
||||||
# Filter for recent appointments only
|
|
||||||
start_date = (timezone.now() - timedelta(days=7)).isoformat()
|
|
||||||
response = self.client.get(f'/export/appointments/?format=json&start_date={start_date}')
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
# Should only get the recent appointment, not the past one
|
|
||||||
self.assertEqual(data['count'], 1)
|
|
||||||
self.assertEqual(data['data'][0]['title'], 'Test Appointment')
|
|
||||||
|
|
||||||
def test_no_permission_denied(self):
|
|
||||||
"""Test that export fails when tenant doesn't have permission"""
|
|
||||||
# Disable export permission
|
|
||||||
self.tenant.can_export_data = False
|
|
||||||
self.tenant.save()
|
|
||||||
|
|
||||||
response = self.client.get('/export/appointments/?format=json')
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 403)
|
|
||||||
self.assertIn('not available', response.json()['detail'])
|
|
||||||
|
|
||||||
def test_unauthenticated_denied(self):
|
|
||||||
"""Test that unauthenticated requests are denied"""
|
|
||||||
client = Client() # Not authenticated
|
|
||||||
response = client.get('/export/appointments/?format=json')
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 401)
|
|
||||||
self.assertIn('Authentication', response.json()['detail'])
|
|
||||||
|
|
||||||
def test_active_filter(self):
|
|
||||||
"""Test filtering by active status"""
|
|
||||||
# Create inactive service
|
|
||||||
Service.objects.create(
|
|
||||||
name="Inactive Service",
|
|
||||||
duration=30,
|
|
||||||
price=25.00,
|
|
||||||
is_active=False
|
|
||||||
)
|
|
||||||
|
|
||||||
# Export only active services
|
|
||||||
response = self.client.get('/export/services/?format=json&is_active=true')
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
# Should only get the active service
|
|
||||||
self.assertEqual(data['count'], 1)
|
|
||||||
self.assertEqual(data['data'][0]['name'], 'Test Service')
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
# Create your tests here.
|
|
||||||
@@ -1,380 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for Calendar Sync Feature Permission
|
|
||||||
|
|
||||||
Tests the can_use_calendar_sync permission checking throughout the calendar sync system.
|
|
||||||
Includes tests for:
|
|
||||||
- Permission denied when feature is disabled
|
|
||||||
- Permission granted when feature is enabled
|
|
||||||
- OAuth view permission checks
|
|
||||||
- Calendar sync view permission checks
|
|
||||||
"""
|
|
||||||
|
|
||||||
from django.test import TestCase
|
|
||||||
from rest_framework.test import APITestCase, APIClient
|
|
||||||
from rest_framework import status
|
|
||||||
from core.models import Tenant, OAuthCredential
|
|
||||||
from smoothschedule.users.models import User
|
|
||||||
|
|
||||||
|
|
||||||
class CalendarSyncPermissionTests(APITestCase):
|
|
||||||
"""
|
|
||||||
Test suite for calendar sync feature permissions.
|
|
||||||
|
|
||||||
Verifies that the can_use_calendar_sync permission is properly enforced
|
|
||||||
across all calendar sync operations.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
"""Set up test fixtures"""
|
|
||||||
# Create a tenant without calendar sync enabled
|
|
||||||
self.tenant = Tenant.objects.create(
|
|
||||||
schema_name='test_tenant',
|
|
||||||
name='Test Tenant',
|
|
||||||
can_use_calendar_sync=False
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create a user in this tenant
|
|
||||||
self.user = User.objects.create_user(
|
|
||||||
email='user@test.com',
|
|
||||||
password='testpass123',
|
|
||||||
tenant=self.tenant
|
|
||||||
)
|
|
||||||
|
|
||||||
# Initialize API client
|
|
||||||
self.client = APIClient()
|
|
||||||
|
|
||||||
def test_calendar_status_without_permission(self):
|
|
||||||
"""
|
|
||||||
Test that users without can_use_calendar_sync cannot access calendar status.
|
|
||||||
|
|
||||||
Expected: 403 Forbidden with upgrade message
|
|
||||||
"""
|
|
||||||
self.client.force_authenticate(user=self.user)
|
|
||||||
|
|
||||||
response = self.client.get('/api/calendar/status/')
|
|
||||||
|
|
||||||
# Should be able to check status (it's informational)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertFalse(response.data['can_use_calendar_sync'])
|
|
||||||
self.assertEqual(response.data['total_connected'], 0)
|
|
||||||
|
|
||||||
def test_calendar_list_without_permission(self):
|
|
||||||
"""
|
|
||||||
Test that users without can_use_calendar_sync cannot list calendars.
|
|
||||||
|
|
||||||
Expected: 403 Forbidden
|
|
||||||
"""
|
|
||||||
self.client.force_authenticate(user=self.user)
|
|
||||||
|
|
||||||
response = self.client.get('/api/calendar/list/')
|
|
||||||
|
|
||||||
# Should return 403 Forbidden
|
|
||||||
self.assertEqual(response.status_code, 403)
|
|
||||||
self.assertIn('upgrade', response.data['error'].lower())
|
|
||||||
|
|
||||||
def test_calendar_sync_without_permission(self):
|
|
||||||
"""
|
|
||||||
Test that users without can_use_calendar_sync cannot sync calendars.
|
|
||||||
|
|
||||||
Expected: 403 Forbidden
|
|
||||||
"""
|
|
||||||
self.client.force_authenticate(user=self.user)
|
|
||||||
|
|
||||||
response = self.client.post(
|
|
||||||
'/api/calendar/sync/',
|
|
||||||
{'credential_id': 1},
|
|
||||||
format='json'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should return 403 Forbidden
|
|
||||||
self.assertEqual(response.status_code, 403)
|
|
||||||
self.assertIn('upgrade', response.data['error'].lower())
|
|
||||||
|
|
||||||
def test_calendar_disconnect_without_permission(self):
|
|
||||||
"""
|
|
||||||
Test that users without can_use_calendar_sync cannot disconnect calendars.
|
|
||||||
|
|
||||||
Expected: 403 Forbidden
|
|
||||||
"""
|
|
||||||
self.client.force_authenticate(user=self.user)
|
|
||||||
|
|
||||||
response = self.client.delete(
|
|
||||||
'/api/calendar/disconnect/',
|
|
||||||
{'credential_id': 1},
|
|
||||||
format='json'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should return 403 Forbidden
|
|
||||||
self.assertEqual(response.status_code, 403)
|
|
||||||
self.assertIn('upgrade', response.data['error'].lower())
|
|
||||||
|
|
||||||
def test_oauth_calendar_initiate_without_permission(self):
|
|
||||||
"""
|
|
||||||
Test that OAuth calendar initiation checks permission.
|
|
||||||
|
|
||||||
Expected: 403 Forbidden when trying to initiate calendar OAuth
|
|
||||||
"""
|
|
||||||
self.client.force_authenticate(user=self.user)
|
|
||||||
|
|
||||||
response = self.client.post(
|
|
||||||
'/api/oauth/google/initiate/',
|
|
||||||
{'purpose': 'calendar'},
|
|
||||||
format='json'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should return 403 Forbidden for calendar purpose
|
|
||||||
self.assertEqual(response.status_code, 403)
|
|
||||||
self.assertIn('Calendar Sync', response.data['error'])
|
|
||||||
|
|
||||||
def test_oauth_email_initiate_without_permission(self):
|
|
||||||
"""
|
|
||||||
Test that OAuth email initiation does NOT require calendar sync permission.
|
|
||||||
|
|
||||||
Note: Email integration may have different permission checks,
|
|
||||||
this test documents that calendar and email are separate.
|
|
||||||
"""
|
|
||||||
self.client.force_authenticate(user=self.user)
|
|
||||||
|
|
||||||
# Email purpose should be allowed without calendar sync permission
|
|
||||||
# (assuming different permission for email)
|
|
||||||
response = self.client.post(
|
|
||||||
'/api/oauth/google/initiate/',
|
|
||||||
{'purpose': 'email'},
|
|
||||||
format='json'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should not be blocked by calendar sync permission
|
|
||||||
# (Response may be 400 if OAuth not configured, but not 403 for this reason)
|
|
||||||
self.assertNotEqual(response.status_code, 403)
|
|
||||||
|
|
||||||
def test_calendar_list_with_permission(self):
|
|
||||||
"""
|
|
||||||
Test that users WITH can_use_calendar_sync can list calendars.
|
|
||||||
|
|
||||||
Expected: 200 OK with empty calendar list
|
|
||||||
"""
|
|
||||||
# Enable calendar sync for tenant
|
|
||||||
self.tenant.can_use_calendar_sync = True
|
|
||||||
self.tenant.save()
|
|
||||||
|
|
||||||
self.client.force_authenticate(user=self.user)
|
|
||||||
|
|
||||||
response = self.client.get('/api/calendar/list/')
|
|
||||||
|
|
||||||
# Should return 200 OK
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertTrue(response.data['success'])
|
|
||||||
self.assertEqual(response.data['calendars'], [])
|
|
||||||
|
|
||||||
def test_calendar_with_connected_credential(self):
|
|
||||||
"""
|
|
||||||
Test calendar list with an actual OAuth credential.
|
|
||||||
|
|
||||||
Expected: 200 OK with credential in the list
|
|
||||||
"""
|
|
||||||
# Enable calendar sync
|
|
||||||
self.tenant.can_use_calendar_sync = True
|
|
||||||
self.tenant.save()
|
|
||||||
|
|
||||||
# Create a calendar OAuth credential
|
|
||||||
credential = OAuthCredential.objects.create(
|
|
||||||
tenant=self.tenant,
|
|
||||||
provider='google',
|
|
||||||
purpose='calendar',
|
|
||||||
email='user@gmail.com',
|
|
||||||
access_token='fake_token_123',
|
|
||||||
refresh_token='fake_refresh_123',
|
|
||||||
is_valid=True,
|
|
||||||
authorized_by=self.user,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.client.force_authenticate(user=self.user)
|
|
||||||
|
|
||||||
response = self.client.get('/api/calendar/list/')
|
|
||||||
|
|
||||||
# Should return 200 OK with the credential
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertTrue(response.data['success'])
|
|
||||||
self.assertEqual(len(response.data['calendars']), 1)
|
|
||||||
|
|
||||||
calendar = response.data['calendars'][0]
|
|
||||||
self.assertEqual(calendar['email'], 'user@gmail.com')
|
|
||||||
self.assertEqual(calendar['provider'], 'Google')
|
|
||||||
self.assertTrue(calendar['is_valid'])
|
|
||||||
|
|
||||||
def test_calendar_status_with_permission(self):
|
|
||||||
"""
|
|
||||||
Test calendar status check when permission is granted.
|
|
||||||
|
|
||||||
Expected: 200 OK with feature enabled
|
|
||||||
"""
|
|
||||||
# Enable calendar sync
|
|
||||||
self.tenant.can_use_calendar_sync = True
|
|
||||||
self.tenant.save()
|
|
||||||
|
|
||||||
self.client.force_authenticate(user=self.user)
|
|
||||||
|
|
||||||
response = self.client.get('/api/calendar/status/')
|
|
||||||
|
|
||||||
# Should return 200 OK with feature enabled
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertTrue(response.data['success'])
|
|
||||||
self.assertTrue(response.data['can_use_calendar_sync'])
|
|
||||||
self.assertTrue(response.data['feature_enabled'])
|
|
||||||
|
|
||||||
def test_unauthenticated_calendar_access(self):
|
|
||||||
"""
|
|
||||||
Test that unauthenticated users cannot access calendar endpoints.
|
|
||||||
|
|
||||||
Expected: 401 Unauthorized
|
|
||||||
"""
|
|
||||||
# Don't authenticate
|
|
||||||
|
|
||||||
response = self.client.get('/api/calendar/list/')
|
|
||||||
|
|
||||||
# Should return 401 Unauthorized
|
|
||||||
self.assertEqual(response.status_code, 401)
|
|
||||||
|
|
||||||
def test_tenant_has_feature_method(self):
|
|
||||||
"""
|
|
||||||
Test the Tenant.has_feature() method for calendar sync.
|
|
||||||
|
|
||||||
Expected: Method returns correct boolean based on field
|
|
||||||
"""
|
|
||||||
# Initially disabled
|
|
||||||
self.assertFalse(self.tenant.has_feature('can_use_calendar_sync'))
|
|
||||||
|
|
||||||
# Enable it
|
|
||||||
self.tenant.can_use_calendar_sync = True
|
|
||||||
self.tenant.save()
|
|
||||||
|
|
||||||
# Check again
|
|
||||||
self.assertTrue(self.tenant.has_feature('can_use_calendar_sync'))
|
|
||||||
|
|
||||||
|
|
||||||
class CalendarSyncIntegrationTests(APITestCase):
|
|
||||||
"""
|
|
||||||
Integration tests for calendar sync with permission checks.
|
|
||||||
|
|
||||||
Tests realistic workflows of connecting and syncing calendars.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
"""Set up test fixtures"""
|
|
||||||
# Create a tenant WITH calendar sync enabled
|
|
||||||
self.tenant = Tenant.objects.create(
|
|
||||||
schema_name='pro_tenant',
|
|
||||||
name='Professional Tenant',
|
|
||||||
can_use_calendar_sync=True # Premium feature enabled
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create a user
|
|
||||||
self.user = User.objects.create_user(
|
|
||||||
email='pro@example.com',
|
|
||||||
password='testpass123',
|
|
||||||
tenant=self.tenant
|
|
||||||
)
|
|
||||||
|
|
||||||
self.client = APIClient()
|
|
||||||
self.client.force_authenticate(user=self.user)
|
|
||||||
|
|
||||||
def test_full_calendar_workflow(self):
|
|
||||||
"""
|
|
||||||
Test complete workflow: Check status -> List -> Add -> Sync -> Remove
|
|
||||||
|
|
||||||
Expected: All steps succeed with permission checks passing
|
|
||||||
"""
|
|
||||||
# Step 1: Check status
|
|
||||||
response = self.client.get('/api/calendar/status/')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertTrue(response.data['can_use_calendar_sync'])
|
|
||||||
|
|
||||||
# Step 2: List calendars (empty initially)
|
|
||||||
response = self.client.get('/api/calendar/list/')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertEqual(len(response.data['calendars']), 0)
|
|
||||||
|
|
||||||
# Step 3: Create credential (simulating OAuth completion)
|
|
||||||
credential = OAuthCredential.objects.create(
|
|
||||||
tenant=self.tenant,
|
|
||||||
provider='google',
|
|
||||||
purpose='calendar',
|
|
||||||
email='calendar@gmail.com',
|
|
||||||
access_token='token_123',
|
|
||||||
is_valid=True,
|
|
||||||
authorized_by=self.user,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Step 4: List again (should see the credential)
|
|
||||||
response = self.client.get('/api/calendar/list/')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertEqual(len(response.data['calendars']), 1)
|
|
||||||
|
|
||||||
# Step 5: Sync from the calendar
|
|
||||||
response = self.client.post(
|
|
||||||
'/api/calendar/sync/',
|
|
||||||
{
|
|
||||||
'credential_id': credential.id,
|
|
||||||
'calendar_id': 'primary',
|
|
||||||
'start_date': '2025-01-01',
|
|
||||||
'end_date': '2025-12-31',
|
|
||||||
},
|
|
||||||
format='json'
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertTrue(response.data['success'])
|
|
||||||
|
|
||||||
# Step 6: Disconnect the calendar
|
|
||||||
response = self.client.delete(
|
|
||||||
'/api/calendar/disconnect/',
|
|
||||||
{'credential_id': credential.id},
|
|
||||||
format='json'
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertTrue(response.data['success'])
|
|
||||||
|
|
||||||
# Step 7: Verify it's deleted
|
|
||||||
response = self.client.get('/api/calendar/list/')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertEqual(len(response.data['calendars']), 0)
|
|
||||||
|
|
||||||
|
|
||||||
class TenantPermissionModelTests(TestCase):
|
|
||||||
"""
|
|
||||||
Unit tests for the Tenant model's calendar sync permission field.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_tenant_can_use_calendar_sync_default(self):
|
|
||||||
"""Test that can_use_calendar_sync defaults to False"""
|
|
||||||
tenant = Tenant.objects.create(
|
|
||||||
schema_name='test',
|
|
||||||
name='Test'
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertFalse(tenant.can_use_calendar_sync)
|
|
||||||
|
|
||||||
def test_tenant_can_use_calendar_sync_enable(self):
|
|
||||||
"""Test enabling calendar sync on a tenant"""
|
|
||||||
tenant = Tenant.objects.create(
|
|
||||||
schema_name='test',
|
|
||||||
name='Test',
|
|
||||||
can_use_calendar_sync=False
|
|
||||||
)
|
|
||||||
|
|
||||||
tenant.can_use_calendar_sync = True
|
|
||||||
tenant.save()
|
|
||||||
|
|
||||||
refreshed = Tenant.objects.get(pk=tenant.pk)
|
|
||||||
self.assertTrue(refreshed.can_use_calendar_sync)
|
|
||||||
|
|
||||||
def test_has_feature_with_other_permissions(self):
|
|
||||||
"""Test that has_feature correctly checks other permissions too"""
|
|
||||||
tenant = Tenant.objects.create(
|
|
||||||
schema_name='test',
|
|
||||||
name='Test',
|
|
||||||
can_use_calendar_sync=True,
|
|
||||||
can_use_webhooks=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertTrue(tenant.has_feature('can_use_calendar_sync'))
|
|
||||||
self.assertFalse(tenant.has_feature('can_use_webhooks'))
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
Script to ensure production domain exists in the database.
|
Script to ensure production domain exists in the database.
|
||||||
Run with: python manage.py shell < scripts/ensure_production_domain.py
|
Run with: python manage.py shell < scripts/ensure_production_domain.py
|
||||||
"""
|
"""
|
||||||
from core.models import Tenant, Domain
|
from smoothschedule.identity.core.models import Tenant, Domain
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
def ensure_production_domain():
|
def ensure_production_domain():
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ from django.apps import AppConfig
|
|||||||
|
|
||||||
class PaymentsConfig(AppConfig):
|
class PaymentsConfig(AppConfig):
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
name = 'payments'
|
name = 'smoothschedule.commerce.payments'
|
||||||
|
label = 'payments'
|
||||||
@@ -92,6 +92,7 @@ class TransactionLink(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
app_label = 'payments'
|
||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['status', 'created_at']),
|
models.Index(fields=['status', 'created_at']),
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for StripeService.
|
||||||
|
|
||||||
|
Tests the Stripe Connect integration logic with mocks to avoid API calls.
|
||||||
|
"""
|
||||||
|
from decimal import Decimal
|
||||||
|
from unittest.mock import Mock, patch, MagicMock
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from smoothschedule.commerce.payments.services import StripeService, get_stripe_service_for_tenant
|
||||||
|
|
||||||
|
|
||||||
|
class TestStripeServiceInit:
|
||||||
|
"""Test StripeService initialization."""
|
||||||
|
|
||||||
|
@patch('smoothschedule.commerce.payments.services.stripe')
|
||||||
|
def test_init_sets_api_key(self, mock_stripe):
|
||||||
|
"""Test that initialization sets the Stripe API key."""
|
||||||
|
# Arrange
|
||||||
|
mock_tenant = Mock()
|
||||||
|
mock_tenant.stripe_connect_id = 'acct_test123'
|
||||||
|
|
||||||
|
# Act
|
||||||
|
with patch('smoothschedule.commerce.payments.services.settings') as mock_settings:
|
||||||
|
mock_settings.STRIPE_SECRET_KEY = 'sk_test_xxx'
|
||||||
|
service = StripeService(mock_tenant)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert service.tenant == mock_tenant
|
||||||
|
assert mock_stripe.api_key == 'sk_test_xxx'
|
||||||
|
|
||||||
|
|
||||||
|
class TestStripeServiceFactory:
|
||||||
|
"""Test the factory function."""
|
||||||
|
|
||||||
|
def test_factory_raises_without_connect_id(self):
|
||||||
|
"""Test that factory raises error if tenant has no Stripe Connect ID."""
|
||||||
|
# Arrange
|
||||||
|
mock_tenant = Mock()
|
||||||
|
mock_tenant.name = 'Test Business'
|
||||||
|
mock_tenant.stripe_connect_id = None
|
||||||
|
|
||||||
|
# Act & Assert
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
get_stripe_service_for_tenant(mock_tenant)
|
||||||
|
|
||||||
|
assert "does not have a Stripe Connect account" in str(exc_info.value)
|
||||||
|
|
||||||
|
@patch('smoothschedule.commerce.payments.services.stripe')
|
||||||
|
def test_factory_returns_service_with_connect_id(self, mock_stripe):
|
||||||
|
"""Test that factory returns StripeService when tenant has Connect ID."""
|
||||||
|
# Arrange
|
||||||
|
mock_tenant = Mock()
|
||||||
|
mock_tenant.stripe_connect_id = 'acct_test123'
|
||||||
|
|
||||||
|
# Act
|
||||||
|
with patch('smoothschedule.commerce.payments.services.settings'):
|
||||||
|
service = get_stripe_service_for_tenant(mock_tenant)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert isinstance(service, StripeService)
|
||||||
|
assert service.tenant == mock_tenant
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreatePaymentIntent:
|
||||||
|
"""Test payment intent creation."""
|
||||||
|
|
||||||
|
@patch('smoothschedule.commerce.payments.services.TransactionLink')
|
||||||
|
@patch('smoothschedule.commerce.payments.services.stripe')
|
||||||
|
def test_create_payment_intent_uses_stripe_account(
|
||||||
|
self, mock_stripe, mock_transaction_link
|
||||||
|
):
|
||||||
|
"""Test that payment intent uses stripe_account header."""
|
||||||
|
# Arrange
|
||||||
|
mock_tenant = Mock()
|
||||||
|
mock_tenant.stripe_connect_id = 'acct_test123'
|
||||||
|
mock_tenant.id = 1
|
||||||
|
mock_tenant.name = 'Test Business'
|
||||||
|
mock_tenant.currency = 'USD'
|
||||||
|
|
||||||
|
mock_event = Mock()
|
||||||
|
mock_event.id = 100
|
||||||
|
mock_event.title = 'Test Appointment'
|
||||||
|
|
||||||
|
mock_pi = Mock()
|
||||||
|
mock_pi.id = 'pi_test123'
|
||||||
|
mock_pi.currency = 'usd'
|
||||||
|
mock_stripe.PaymentIntent.create.return_value = mock_pi
|
||||||
|
|
||||||
|
mock_tx = Mock()
|
||||||
|
mock_transaction_link.objects.create.return_value = mock_tx
|
||||||
|
mock_transaction_link.Status.PENDING = 'PENDING'
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.payments.services.settings'):
|
||||||
|
service = StripeService(mock_tenant)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
amount = Decimal('100.00')
|
||||||
|
pi, tx = service.create_payment_intent(mock_event, amount)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
mock_stripe.PaymentIntent.create.assert_called_once()
|
||||||
|
call_kwargs = mock_stripe.PaymentIntent.create.call_args.kwargs
|
||||||
|
|
||||||
|
# CRITICAL: Verify stripe_account header is set
|
||||||
|
assert call_kwargs['stripe_account'] == 'acct_test123'
|
||||||
|
|
||||||
|
# Verify amount in cents
|
||||||
|
assert call_kwargs['amount'] == 10000 # $100 = 10000 cents
|
||||||
|
|
||||||
|
# Verify application fee is calculated (5% default)
|
||||||
|
assert call_kwargs['application_fee_amount'] == 500 # 5% of 10000
|
||||||
|
|
||||||
|
@patch('smoothschedule.commerce.payments.services.TransactionLink')
|
||||||
|
@patch('smoothschedule.commerce.payments.services.stripe')
|
||||||
|
def test_create_payment_intent_custom_fee(
|
||||||
|
self, mock_stripe, mock_transaction_link
|
||||||
|
):
|
||||||
|
"""Test payment intent with custom application fee."""
|
||||||
|
# Arrange
|
||||||
|
mock_tenant = Mock()
|
||||||
|
mock_tenant.stripe_connect_id = 'acct_test123'
|
||||||
|
mock_tenant.id = 1
|
||||||
|
mock_tenant.name = 'Test Business'
|
||||||
|
mock_tenant.currency = 'USD'
|
||||||
|
|
||||||
|
mock_event = Mock()
|
||||||
|
mock_event.id = 100
|
||||||
|
mock_event.title = 'Test Appointment'
|
||||||
|
|
||||||
|
mock_pi = Mock()
|
||||||
|
mock_pi.id = 'pi_test123'
|
||||||
|
mock_pi.currency = 'usd'
|
||||||
|
mock_stripe.PaymentIntent.create.return_value = mock_pi
|
||||||
|
|
||||||
|
mock_tx = Mock()
|
||||||
|
mock_transaction_link.objects.create.return_value = mock_tx
|
||||||
|
mock_transaction_link.Status.PENDING = 'PENDING'
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.payments.services.settings'):
|
||||||
|
service = StripeService(mock_tenant)
|
||||||
|
|
||||||
|
# Act - 10% fee instead of default 5%
|
||||||
|
amount = Decimal('100.00')
|
||||||
|
pi, tx = service.create_payment_intent(
|
||||||
|
mock_event, amount, application_fee_percent=Decimal('10.0')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
call_kwargs = mock_stripe.PaymentIntent.create.call_args.kwargs
|
||||||
|
assert call_kwargs['application_fee_amount'] == 1000 # 10% of 10000
|
||||||
|
|
||||||
|
@patch('smoothschedule.commerce.payments.services.TransactionLink')
|
||||||
|
@patch('smoothschedule.commerce.payments.services.stripe')
|
||||||
|
def test_create_payment_intent_includes_metadata(
|
||||||
|
self, mock_stripe, mock_transaction_link
|
||||||
|
):
|
||||||
|
"""Test that payment intent includes proper metadata."""
|
||||||
|
# Arrange
|
||||||
|
mock_tenant = Mock()
|
||||||
|
mock_tenant.stripe_connect_id = 'acct_test123'
|
||||||
|
mock_tenant.id = 42
|
||||||
|
mock_tenant.name = 'My Business'
|
||||||
|
mock_tenant.currency = 'EUR'
|
||||||
|
|
||||||
|
mock_event = Mock()
|
||||||
|
mock_event.id = 99
|
||||||
|
mock_event.title = 'Premium Service'
|
||||||
|
|
||||||
|
mock_pi = Mock()
|
||||||
|
mock_pi.id = 'pi_test123'
|
||||||
|
mock_pi.currency = 'eur'
|
||||||
|
mock_stripe.PaymentIntent.create.return_value = mock_pi
|
||||||
|
|
||||||
|
mock_tx = Mock()
|
||||||
|
mock_transaction_link.objects.create.return_value = mock_tx
|
||||||
|
mock_transaction_link.Status.PENDING = 'PENDING'
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.payments.services.settings'):
|
||||||
|
service = StripeService(mock_tenant)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
pi, tx = service.create_payment_intent(
|
||||||
|
mock_event, Decimal('50.00'), locale='es'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
call_kwargs = mock_stripe.PaymentIntent.create.call_args.kwargs
|
||||||
|
metadata = call_kwargs['metadata']
|
||||||
|
|
||||||
|
assert metadata['event_id'] == 99
|
||||||
|
assert metadata['event_title'] == 'Premium Service'
|
||||||
|
assert metadata['tenant_id'] == 42
|
||||||
|
assert metadata['tenant_name'] == 'My Business'
|
||||||
|
assert metadata['locale'] == 'es'
|
||||||
|
|
||||||
|
|
||||||
|
class TestRefundPayment:
|
||||||
|
"""Test refund functionality."""
|
||||||
|
|
||||||
|
@patch('smoothschedule.commerce.payments.services.stripe')
|
||||||
|
def test_refund_uses_stripe_account(self, mock_stripe):
|
||||||
|
"""Test that refund uses stripe_account header."""
|
||||||
|
# Arrange
|
||||||
|
mock_tenant = Mock()
|
||||||
|
mock_tenant.stripe_connect_id = 'acct_test123'
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.payments.services.settings'):
|
||||||
|
service = StripeService(mock_tenant)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
service.refund_payment('pi_test123')
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
mock_stripe.Refund.create.assert_called_once()
|
||||||
|
call_kwargs = mock_stripe.Refund.create.call_args.kwargs
|
||||||
|
assert call_kwargs['stripe_account'] == 'acct_test123'
|
||||||
|
assert call_kwargs['payment_intent'] == 'pi_test123'
|
||||||
|
|
||||||
|
@patch('smoothschedule.commerce.payments.services.stripe')
|
||||||
|
def test_partial_refund_converts_amount(self, mock_stripe):
|
||||||
|
"""Test that partial refund amount is converted to cents."""
|
||||||
|
# Arrange
|
||||||
|
mock_tenant = Mock()
|
||||||
|
mock_tenant.stripe_connect_id = 'acct_test123'
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.payments.services.settings'):
|
||||||
|
service = StripeService(mock_tenant)
|
||||||
|
|
||||||
|
# Act - $25 partial refund
|
||||||
|
service.refund_payment('pi_test123', amount=Decimal('25.00'))
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
call_kwargs = mock_stripe.Refund.create.call_args.kwargs
|
||||||
|
assert call_kwargs['amount'] == 2500 # Converted to cents
|
||||||
|
|
||||||
|
|
||||||
|
class TestPaymentMethods:
|
||||||
|
"""Test payment method operations."""
|
||||||
|
|
||||||
|
@patch('smoothschedule.commerce.payments.services.stripe')
|
||||||
|
def test_list_payment_methods_uses_stripe_account(self, mock_stripe):
|
||||||
|
"""Test that listing payment methods uses stripe_account header."""
|
||||||
|
# Arrange
|
||||||
|
mock_tenant = Mock()
|
||||||
|
mock_tenant.stripe_connect_id = 'acct_test123'
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.payments.services.settings'):
|
||||||
|
service = StripeService(mock_tenant)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
service.list_payment_methods('cus_test123')
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
mock_stripe.PaymentMethod.list.assert_called_once()
|
||||||
|
call_kwargs = mock_stripe.PaymentMethod.list.call_args.kwargs
|
||||||
|
assert call_kwargs['stripe_account'] == 'acct_test123'
|
||||||
|
assert call_kwargs['customer'] == 'cus_test123'
|
||||||
|
|
||||||
|
@patch('smoothschedule.commerce.payments.services.stripe')
|
||||||
|
def test_detach_payment_method_uses_stripe_account(self, mock_stripe):
|
||||||
|
"""Test that detaching payment method uses stripe_account header."""
|
||||||
|
# Arrange
|
||||||
|
mock_tenant = Mock()
|
||||||
|
mock_tenant.stripe_connect_id = 'acct_test123'
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.payments.services.settings'):
|
||||||
|
service = StripeService(mock_tenant)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
service.detach_payment_method('pm_test123')
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
mock_stripe.PaymentMethod.detach.assert_called_once_with(
|
||||||
|
'pm_test123',
|
||||||
|
stripe_account='acct_test123'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCustomerOperations:
|
||||||
|
"""Test customer creation and retrieval."""
|
||||||
|
|
||||||
|
@patch('smoothschedule.commerce.payments.services.stripe')
|
||||||
|
def test_create_customer_on_connected_account(self, mock_stripe):
|
||||||
|
"""Test that new customers are created on connected account."""
|
||||||
|
# Arrange
|
||||||
|
mock_tenant = Mock()
|
||||||
|
mock_tenant.stripe_connect_id = 'acct_test123'
|
||||||
|
mock_tenant.id = 1
|
||||||
|
|
||||||
|
mock_user = Mock()
|
||||||
|
mock_user.email = 'test@example.com'
|
||||||
|
mock_user.full_name = 'John Doe'
|
||||||
|
mock_user.username = 'johndoe'
|
||||||
|
mock_user.id = 42
|
||||||
|
mock_user.stripe_customer_id = None
|
||||||
|
|
||||||
|
mock_customer = Mock()
|
||||||
|
mock_customer.id = 'cus_new123'
|
||||||
|
mock_stripe.Customer.create.return_value = mock_customer
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.payments.services.settings'):
|
||||||
|
service = StripeService(mock_tenant)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
customer_id = service.create_or_get_customer(mock_user)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert customer_id == 'cus_new123'
|
||||||
|
mock_stripe.Customer.create.assert_called_once()
|
||||||
|
call_kwargs = mock_stripe.Customer.create.call_args.kwargs
|
||||||
|
assert call_kwargs['stripe_account'] == 'acct_test123'
|
||||||
|
assert call_kwargs['email'] == 'test@example.com'
|
||||||
|
|
||||||
|
# Verify user was updated
|
||||||
|
mock_user.save.assert_called_once()
|
||||||
|
|
||||||
|
@patch('smoothschedule.commerce.payments.services.stripe')
|
||||||
|
def test_get_existing_customer(self, mock_stripe):
|
||||||
|
"""Test that existing customer ID is returned without creating new."""
|
||||||
|
# Arrange
|
||||||
|
mock_tenant = Mock()
|
||||||
|
mock_tenant.stripe_connect_id = 'acct_test123'
|
||||||
|
|
||||||
|
mock_user = Mock()
|
||||||
|
mock_user.stripe_customer_id = 'cus_existing123'
|
||||||
|
|
||||||
|
mock_customer = Mock()
|
||||||
|
mock_stripe.Customer.retrieve.return_value = mock_customer
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.payments.services.settings'):
|
||||||
|
service = StripeService(mock_tenant)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
customer_id = service.create_or_get_customer(mock_user)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert customer_id == 'cus_existing123'
|
||||||
|
mock_stripe.Customer.create.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestTerminalOperations:
|
||||||
|
"""Test Stripe Terminal operations."""
|
||||||
|
|
||||||
|
@patch('smoothschedule.commerce.payments.services.stripe')
|
||||||
|
def test_get_terminal_token_uses_stripe_account(self, mock_stripe):
|
||||||
|
"""Test that terminal token uses stripe_account header."""
|
||||||
|
# Arrange
|
||||||
|
mock_tenant = Mock()
|
||||||
|
mock_tenant.stripe_connect_id = 'acct_test123'
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.payments.services.settings'):
|
||||||
|
service = StripeService(mock_tenant)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
service.get_terminal_token()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
mock_stripe.terminal.ConnectionToken.create.assert_called_once_with(
|
||||||
|
stripe_account='acct_test123'
|
||||||
|
)
|
||||||
2113
smoothschedule/smoothschedule/commerce/payments/tests/test_views.py
Normal file
2113
smoothschedule/smoothschedule/commerce/payments/tests/test_views.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,13 +10,13 @@ from rest_framework.views import APIView
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from core.permissions import HasFeaturePermission
|
from smoothschedule.identity.core.permissions import HasFeaturePermission
|
||||||
from core.mixins import TenantAPIView, TenantRequiredAPIView
|
from smoothschedule.identity.core.mixins import TenantAPIView, TenantRequiredAPIView
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from .services import get_stripe_service_for_tenant
|
from .services import get_stripe_service_for_tenant
|
||||||
from .models import TransactionLink
|
from .models import TransactionLink
|
||||||
from schedule.models import Event
|
from smoothschedule.scheduling.schedule.models import Event
|
||||||
from platform_admin.models import SubscriptionPlan
|
from smoothschedule.platform.admin.models import SubscriptionPlan
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -1532,8 +1532,8 @@ class CustomerBillingView(APIView):
|
|||||||
def get(self, request):
|
def get(self, request):
|
||||||
"""Get customer billing data."""
|
"""Get customer billing data."""
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from schedule.models import Participant
|
from smoothschedule.scheduling.schedule.models import Participant
|
||||||
from smoothschedule.users.models import User
|
from smoothschedule.identity.users.models import User
|
||||||
|
|
||||||
user = request.user
|
user = request.user
|
||||||
|
|
||||||
@@ -1653,7 +1653,7 @@ class CustomerPaymentMethodsView(APIView):
|
|||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
"""Get customer's saved payment methods from Stripe."""
|
"""Get customer's saved payment methods from Stripe."""
|
||||||
from smoothschedule.users.models import User
|
from smoothschedule.identity.users.models import User
|
||||||
|
|
||||||
user = request.user
|
user = request.user
|
||||||
|
|
||||||
@@ -1726,7 +1726,7 @@ class CustomerSetupIntentView(APIView):
|
|||||||
"""Create a SetupIntent for the customer."""
|
"""Create a SetupIntent for the customer."""
|
||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
from smoothschedule.users.models import User
|
from smoothschedule.identity.users.models import User
|
||||||
|
|
||||||
user = request.user
|
user = request.user
|
||||||
tenant = request.tenant
|
tenant = request.tenant
|
||||||
@@ -1841,7 +1841,7 @@ class CustomerPaymentMethodDeleteView(APIView):
|
|||||||
|
|
||||||
def delete(self, request, payment_method_id):
|
def delete(self, request, payment_method_id):
|
||||||
"""Delete a payment method."""
|
"""Delete a payment method."""
|
||||||
from smoothschedule.users.models import User
|
from smoothschedule.identity.users.models import User
|
||||||
|
|
||||||
user = request.user
|
user = request.user
|
||||||
|
|
||||||
@@ -1904,7 +1904,7 @@ class CustomerPaymentMethodDefaultView(APIView):
|
|||||||
|
|
||||||
def post(self, request, payment_method_id):
|
def post(self, request, payment_method_id):
|
||||||
"""Set payment method as default."""
|
"""Set payment method as default."""
|
||||||
from smoothschedule.users.models import User
|
from smoothschedule.identity.users.models import User
|
||||||
|
|
||||||
user = request.user
|
user = request.user
|
||||||
|
|
||||||
@@ -1989,8 +1989,8 @@ class SetFinalPriceView(APIView):
|
|||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from schedule.models import Participant
|
from smoothschedule.scheduling.schedule.models import Participant
|
||||||
from smoothschedule.users.models import User
|
from smoothschedule.identity.users.models import User
|
||||||
|
|
||||||
final_price = request.data.get('final_price')
|
final_price = request.data.get('final_price')
|
||||||
charge_now = request.data.get('charge_now', True)
|
charge_now = request.data.get('charge_now', True)
|
||||||
@@ -7,7 +7,7 @@ from django.dispatch import receiver
|
|||||||
from djstripe import signals
|
from djstripe import signals
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from .models import TransactionLink
|
from .models import TransactionLink
|
||||||
from schedule.models import Event
|
from smoothschedule.scheduling.schedule.models import Event
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -3,7 +3,8 @@ from django.apps import AppConfig
|
|||||||
|
|
||||||
class TicketsConfig(AppConfig):
|
class TicketsConfig(AppConfig):
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
name = 'tickets'
|
name = 'smoothschedule.commerce.tickets'
|
||||||
|
label = 'tickets'
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
import tickets.signals # noqa
|
import smoothschedule.commerce.tickets.signals # noqa
|
||||||
@@ -2,7 +2,7 @@ import json
|
|||||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||||
from asgiref.sync import sync_to_async
|
from asgiref.sync import sync_to_async
|
||||||
|
|
||||||
from smoothschedule.users.models import User
|
from smoothschedule.identity.users.models import User
|
||||||
from .models import Ticket, TicketComment
|
from .models import Ticket, TicketComment
|
||||||
from .serializers import TicketSerializer, TicketCommentSerializer # Import your serializers
|
from .serializers import TicketSerializer, TicketCommentSerializer # Import your serializers
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ def get_default_platform_email():
|
|||||||
Returns None if no default is configured.
|
Returns None if no default is configured.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from platform_admin.models import PlatformEmailAddress
|
from smoothschedule.platform.admin.models import PlatformEmailAddress
|
||||||
return PlatformEmailAddress.objects.filter(
|
return PlatformEmailAddress.objects.filter(
|
||||||
is_default=True,
|
is_default=True,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
@@ -75,7 +75,7 @@ class TicketEmailService:
|
|||||||
Returns None if template not found.
|
Returns None if template not found.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from schedule.models import EmailTemplate
|
from smoothschedule.scheduling.schedule.models import EmailTemplate
|
||||||
return EmailTemplate.objects.filter(
|
return EmailTemplate.objects.filter(
|
||||||
name=template_name,
|
name=template_name,
|
||||||
scope=EmailTemplate.Scope.BUSINESS
|
scope=EmailTemplate.Scope.BUSINESS
|
||||||
@@ -37,7 +37,7 @@ from .models import (
|
|||||||
TicketEmailAddress,
|
TicketEmailAddress,
|
||||||
IncomingTicketEmail
|
IncomingTicketEmail
|
||||||
)
|
)
|
||||||
from smoothschedule.users.models import User
|
from smoothschedule.identity.users.models import User
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -713,7 +713,7 @@ class PlatformEmailReceiver:
|
|||||||
|
|
||||||
def __init__(self, email_address):
|
def __init__(self, email_address):
|
||||||
"""Initialize with a PlatformEmailAddress instance."""
|
"""Initialize with a PlatformEmailAddress instance."""
|
||||||
from platform_admin.models import PlatformEmailAddress
|
from smoothschedule.platform.admin.models import PlatformEmailAddress
|
||||||
self.email_address = email_address
|
self.email_address = email_address
|
||||||
self.connection = None
|
self.connection = None
|
||||||
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from core.models import Tenant
|
from smoothschedule.identity.core.models import Tenant
|
||||||
from smoothschedule.users.models import User
|
from smoothschedule.identity.users.models import User
|
||||||
|
|
||||||
|
|
||||||
class Ticket(models.Model):
|
class Ticket(models.Model):
|
||||||
@@ -160,6 +160,7 @@ class Ticket(models.Model):
|
|||||||
resolved_at = models.DateTimeField(null=True, blank=True)
|
resolved_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
app_label = 'tickets'
|
||||||
ordering = ['-priority', '-created_at']
|
ordering = ['-priority', '-created_at']
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['tenant', 'status']),
|
models.Index(fields=['tenant', 'status']),
|
||||||
@@ -247,6 +248,7 @@ class TicketTemplate(models.Model):
|
|||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
app_label = 'tickets'
|
||||||
ordering = ['ticket_type', 'name']
|
ordering = ['ticket_type', 'name']
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@@ -285,6 +287,7 @@ class CannedResponse(models.Model):
|
|||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
app_label = 'tickets'
|
||||||
ordering = ['-use_count', 'title']
|
ordering = ['-use_count', 'title']
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@@ -349,6 +352,7 @@ class TicketComment(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
app_label = 'tickets'
|
||||||
ordering = ['created_at']
|
ordering = ['created_at']
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -495,6 +499,7 @@ class IncomingTicketEmail(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
app_label = 'tickets'
|
||||||
ordering = ['-received_at']
|
ordering = ['-received_at']
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['message_id']),
|
models.Index(fields=['message_id']),
|
||||||
@@ -640,6 +645,7 @@ class TicketEmailAddress(models.Model):
|
|||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
app_label = 'tickets'
|
||||||
ordering = ['-is_default', 'display_name']
|
ordering = ['-is_default', 'display_name']
|
||||||
unique_together = [['tenant', 'email_address']]
|
unique_together = [['tenant', 'email_address']]
|
||||||
indexes = [
|
indexes = [
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from .models import Ticket, TicketComment, TicketTemplate, CannedResponse, IncomingTicketEmail, TicketEmailAddress
|
from .models import Ticket, TicketComment, TicketTemplate, CannedResponse, IncomingTicketEmail, TicketEmailAddress
|
||||||
from smoothschedule.users.models import User
|
from smoothschedule.identity.users.models import User
|
||||||
from core.models import Tenant
|
from smoothschedule.identity.core.models import Tenant
|
||||||
|
|
||||||
class TicketCommentSerializer(serializers.ModelSerializer):
|
class TicketCommentSerializer(serializers.ModelSerializer):
|
||||||
author_email = serializers.ReadOnlyField(source='author.email')
|
author_email = serializers.ReadOnlyField(source='author.email')
|
||||||
@@ -8,7 +8,7 @@ from channels.layers import get_channel_layer
|
|||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
|
|
||||||
from .models import Ticket, TicketComment
|
from .models import Ticket, TicketComment
|
||||||
from smoothschedule.users.models import User
|
from smoothschedule.identity.users.models import User
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ def is_notifications_available():
|
|||||||
global _notifications_available
|
global _notifications_available
|
||||||
if _notifications_available is None:
|
if _notifications_available is None:
|
||||||
try:
|
try:
|
||||||
from notifications.models import Notification
|
from smoothschedule.communication.notifications.models import Notification
|
||||||
# Check if the table exists by doing a simple query
|
# Check if the table exists by doing a simple query
|
||||||
Notification.objects.exists()
|
Notification.objects.exists()
|
||||||
_notifications_available = True
|
_notifications_available = True
|
||||||
@@ -60,7 +60,7 @@ def create_notification(recipient, actor, verb, action_object, target, data):
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from notifications.models import Notification
|
from smoothschedule.communication.notifications.models import Notification
|
||||||
Notification.objects.create(
|
Notification.objects.create(
|
||||||
recipient=recipient,
|
recipient=recipient,
|
||||||
actor=actor,
|
actor=actor,
|
||||||
@@ -33,7 +33,7 @@ def fetch_incoming_emails(self):
|
|||||||
"""
|
"""
|
||||||
from .email_receiver import TicketEmailReceiver, PlatformEmailReceiver
|
from .email_receiver import TicketEmailReceiver, PlatformEmailReceiver
|
||||||
from .models import TicketEmailAddress
|
from .models import TicketEmailAddress
|
||||||
from platform_admin.models import PlatformEmailAddress
|
from smoothschedule.platform.admin.models import PlatformEmailAddress
|
||||||
|
|
||||||
total_processed = 0
|
total_processed = 0
|
||||||
results = []
|
results = []
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,233 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for Ticket serializers.
|
||||||
|
|
||||||
|
Tests serializer validation logic without hitting the database.
|
||||||
|
"""
|
||||||
|
from unittest.mock import Mock, patch, MagicMock
|
||||||
|
from rest_framework.test import APIRequestFactory
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from smoothschedule.commerce.tickets.serializers import (
|
||||||
|
TicketSerializer,
|
||||||
|
TicketListSerializer,
|
||||||
|
TicketCommentSerializer,
|
||||||
|
TicketTemplateSerializer,
|
||||||
|
CannedResponseSerializer,
|
||||||
|
TicketEmailAddressSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTicketSerializerValidation:
|
||||||
|
"""Test TicketSerializer validation logic."""
|
||||||
|
|
||||||
|
def test_read_only_fields_not_writable(self):
|
||||||
|
"""Test that read-only fields are not included in writable fields."""
|
||||||
|
serializer = TicketSerializer()
|
||||||
|
writable_fields = [
|
||||||
|
f for f in serializer.fields
|
||||||
|
if not serializer.fields[f].read_only
|
||||||
|
]
|
||||||
|
|
||||||
|
# These should NOT be writable
|
||||||
|
assert 'id' not in writable_fields
|
||||||
|
assert 'creator' not in writable_fields
|
||||||
|
assert 'creator_email' not in writable_fields
|
||||||
|
assert 'is_overdue' not in writable_fields
|
||||||
|
assert 'created_at' not in writable_fields
|
||||||
|
assert 'comments' not in writable_fields
|
||||||
|
|
||||||
|
def test_writable_fields_present(self):
|
||||||
|
"""Test that writable fields are present."""
|
||||||
|
serializer = TicketSerializer()
|
||||||
|
writable_fields = [
|
||||||
|
f for f in serializer.fields
|
||||||
|
if not serializer.fields[f].read_only
|
||||||
|
]
|
||||||
|
|
||||||
|
# These should be writable
|
||||||
|
assert 'subject' in writable_fields
|
||||||
|
assert 'description' in writable_fields
|
||||||
|
assert 'priority' in writable_fields
|
||||||
|
assert 'status' in writable_fields
|
||||||
|
assert 'assignee' in writable_fields
|
||||||
|
|
||||||
|
|
||||||
|
class TestTicketSerializerCreate:
|
||||||
|
"""Test TicketSerializer create logic."""
|
||||||
|
|
||||||
|
def test_create_sets_creator_from_request(self):
|
||||||
|
"""Test that create sets creator from authenticated user."""
|
||||||
|
# Arrange
|
||||||
|
factory = APIRequestFactory()
|
||||||
|
request = factory.post('/tickets/')
|
||||||
|
request.user = Mock(is_authenticated=True, tenant=Mock(id=1))
|
||||||
|
|
||||||
|
serializer = TicketSerializer(context={'request': request})
|
||||||
|
|
||||||
|
# Patch the Ticket model
|
||||||
|
with patch.object(TicketSerializer, 'create') as mock_create:
|
||||||
|
mock_create.return_value = Mock()
|
||||||
|
|
||||||
|
validated_data = {
|
||||||
|
'subject': 'Test Ticket',
|
||||||
|
'description': 'Test description',
|
||||||
|
'ticket_type': 'PLATFORM',
|
||||||
|
}
|
||||||
|
|
||||||
|
# The actual create method should set creator
|
||||||
|
# This test verifies the serializer has the context it needs
|
||||||
|
assert serializer.context['request'].user.is_authenticated
|
||||||
|
|
||||||
|
def test_create_requires_tenant_for_non_platform_tickets(self):
|
||||||
|
"""Test that non-platform tickets require tenant."""
|
||||||
|
factory = APIRequestFactory()
|
||||||
|
request = factory.post('/tickets/')
|
||||||
|
# User without tenant
|
||||||
|
request.user = Mock(is_authenticated=True, tenant=None)
|
||||||
|
|
||||||
|
serializer = TicketSerializer(
|
||||||
|
data={
|
||||||
|
'subject': 'Test Ticket',
|
||||||
|
'description': 'Test description',
|
||||||
|
'ticket_type': 'SUPPORT', # Non-platform ticket
|
||||||
|
},
|
||||||
|
context={'request': request}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validation should pass at field level but fail in create
|
||||||
|
# (The actual validation happens in create method)
|
||||||
|
assert 'subject' in serializer.fields
|
||||||
|
|
||||||
|
|
||||||
|
class TestTicketSerializerUpdate:
|
||||||
|
"""Test TicketSerializer update logic."""
|
||||||
|
|
||||||
|
def test_update_removes_tenant_from_data(self):
|
||||||
|
"""Test that update prevents changing tenant."""
|
||||||
|
# Arrange
|
||||||
|
mock_instance = Mock()
|
||||||
|
mock_instance.tenant = Mock(id=1)
|
||||||
|
mock_instance.creator = Mock(id=1)
|
||||||
|
|
||||||
|
factory = APIRequestFactory()
|
||||||
|
request = factory.patch('/tickets/1/')
|
||||||
|
request.user = Mock(is_authenticated=True)
|
||||||
|
|
||||||
|
serializer = TicketSerializer(
|
||||||
|
instance=mock_instance,
|
||||||
|
context={'request': request}
|
||||||
|
)
|
||||||
|
|
||||||
|
# The update method should strip tenant and creator
|
||||||
|
with patch.object(TicketSerializer, 'update') as mock_update:
|
||||||
|
validated_data = {
|
||||||
|
'subject': 'Updated Subject',
|
||||||
|
'tenant': Mock(id=2), # Trying to change tenant
|
||||||
|
'creator': Mock(id=2), # Trying to change creator
|
||||||
|
}
|
||||||
|
|
||||||
|
# Simulate calling update
|
||||||
|
# In real usage, tenant and creator would be stripped
|
||||||
|
assert 'subject' in serializer.fields
|
||||||
|
|
||||||
|
|
||||||
|
class TestTicketListSerializer:
|
||||||
|
"""Test TicketListSerializer."""
|
||||||
|
|
||||||
|
def test_excludes_comments(self):
|
||||||
|
"""Test that list serializer excludes comments for performance."""
|
||||||
|
serializer = TicketListSerializer()
|
||||||
|
assert 'comments' not in serializer.fields
|
||||||
|
|
||||||
|
|
||||||
|
class TestTicketCommentSerializer:
|
||||||
|
"""Test TicketCommentSerializer."""
|
||||||
|
|
||||||
|
def test_all_fields_read_only_except_comment_text(self):
|
||||||
|
"""Test that most fields are read-only."""
|
||||||
|
serializer = TicketCommentSerializer()
|
||||||
|
|
||||||
|
# comment_text should be writable
|
||||||
|
assert not serializer.fields['comment_text'].read_only
|
||||||
|
|
||||||
|
# These should be read-only
|
||||||
|
assert serializer.fields['id'].read_only
|
||||||
|
assert serializer.fields['ticket'].read_only
|
||||||
|
assert serializer.fields['author'].read_only
|
||||||
|
assert serializer.fields['created_at'].read_only
|
||||||
|
|
||||||
|
|
||||||
|
class TestTicketTemplateSerializer:
|
||||||
|
"""Test TicketTemplateSerializer."""
|
||||||
|
|
||||||
|
def test_read_only_fields(self):
|
||||||
|
"""Test that correct fields are read-only."""
|
||||||
|
serializer = TicketTemplateSerializer()
|
||||||
|
|
||||||
|
assert serializer.fields['id'].read_only
|
||||||
|
assert serializer.fields['created_at'].read_only
|
||||||
|
|
||||||
|
def test_writable_fields(self):
|
||||||
|
"""Test that correct fields are writable."""
|
||||||
|
serializer = TicketTemplateSerializer()
|
||||||
|
writable = [f for f in serializer.fields if not serializer.fields[f].read_only]
|
||||||
|
|
||||||
|
assert 'name' in writable
|
||||||
|
assert 'description' in writable
|
||||||
|
assert 'ticket_type' in writable
|
||||||
|
assert 'subject_template' in writable
|
||||||
|
|
||||||
|
|
||||||
|
class TestCannedResponseSerializer:
|
||||||
|
"""Test CannedResponseSerializer."""
|
||||||
|
|
||||||
|
def test_read_only_fields(self):
|
||||||
|
"""Test that correct fields are read-only."""
|
||||||
|
serializer = CannedResponseSerializer()
|
||||||
|
|
||||||
|
assert serializer.fields['id'].read_only
|
||||||
|
assert serializer.fields['use_count'].read_only
|
||||||
|
assert serializer.fields['created_by'].read_only
|
||||||
|
assert serializer.fields['created_at'].read_only
|
||||||
|
|
||||||
|
def test_writable_fields(self):
|
||||||
|
"""Test that correct fields are writable."""
|
||||||
|
serializer = CannedResponseSerializer()
|
||||||
|
writable = [f for f in serializer.fields if not serializer.fields[f].read_only]
|
||||||
|
|
||||||
|
assert 'title' in writable
|
||||||
|
assert 'content' in writable
|
||||||
|
assert 'category' in writable
|
||||||
|
assert 'is_active' in writable
|
||||||
|
|
||||||
|
|
||||||
|
class TestTicketEmailAddressSerializer:
|
||||||
|
"""Test TicketEmailAddressSerializer."""
|
||||||
|
|
||||||
|
def test_password_fields_write_only(self):
|
||||||
|
"""Test that password fields are write-only."""
|
||||||
|
serializer = TicketEmailAddressSerializer()
|
||||||
|
|
||||||
|
# Passwords should be write-only (not exposed in responses)
|
||||||
|
assert serializer.fields['imap_password'].write_only
|
||||||
|
assert serializer.fields['smtp_password'].write_only
|
||||||
|
|
||||||
|
def test_computed_fields_read_only(self):
|
||||||
|
"""Test that computed fields are read-only."""
|
||||||
|
serializer = TicketEmailAddressSerializer()
|
||||||
|
|
||||||
|
assert serializer.fields['is_imap_configured'].read_only
|
||||||
|
assert serializer.fields['is_smtp_configured'].read_only
|
||||||
|
assert serializer.fields['is_fully_configured'].read_only
|
||||||
|
|
||||||
|
def test_writable_configuration_fields(self):
|
||||||
|
"""Test that configuration fields are writable."""
|
||||||
|
serializer = TicketEmailAddressSerializer()
|
||||||
|
writable = [f for f in serializer.fields if not serializer.fields[f].read_only]
|
||||||
|
|
||||||
|
assert 'display_name' in writable
|
||||||
|
assert 'email_address' in writable
|
||||||
|
assert 'imap_host' in writable
|
||||||
|
assert 'imap_port' in writable
|
||||||
|
assert 'smtp_host' in writable
|
||||||
|
assert 'smtp_port' in writable
|
||||||
@@ -0,0 +1,940 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for ticket signals.
|
||||||
|
|
||||||
|
Tests signal handlers, helper functions, and notification logic without database access.
|
||||||
|
Following the testing pyramid: fast, isolated unit tests using mocks.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from unittest.mock import Mock, patch, MagicMock, call
|
||||||
|
from django.db.models.signals import post_save, pre_save
|
||||||
|
from django.test import override_settings
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from smoothschedule.commerce.tickets.models import Ticket, TicketComment
|
||||||
|
from smoothschedule.commerce.tickets import signals
|
||||||
|
from smoothschedule.identity.users.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsNotificationsAvailable:
|
||||||
|
"""Test the is_notifications_available() helper function."""
|
||||||
|
|
||||||
|
def test_returns_cached_true_value(self):
|
||||||
|
"""Notification availability check should return cached True value."""
|
||||||
|
# Set the cache to True
|
||||||
|
signals._notifications_available = True
|
||||||
|
|
||||||
|
result = signals.is_notifications_available()
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
def test_returns_cached_false_value(self):
|
||||||
|
"""Notification availability check should return cached False value."""
|
||||||
|
signals._notifications_available = False
|
||||||
|
|
||||||
|
result = signals.is_notifications_available()
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_function_is_callable(self):
|
||||||
|
"""Should be a callable function."""
|
||||||
|
assert callable(signals.is_notifications_available)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSendWebsocketNotification:
|
||||||
|
"""Test the send_websocket_notification() helper function."""
|
||||||
|
|
||||||
|
def test_sends_notification_successfully(self):
|
||||||
|
"""Should send websocket notification via channel layer."""
|
||||||
|
mock_channel_layer = MagicMock()
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.get_channel_layer', return_value=mock_channel_layer):
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.async_to_sync') as mock_async:
|
||||||
|
signals.send_websocket_notification(
|
||||||
|
"user_123",
|
||||||
|
{"type": "test", "message": "Hello"}
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_async.assert_called_once()
|
||||||
|
# Verify the correct arguments were passed
|
||||||
|
call_args = mock_async.call_args[0]
|
||||||
|
assert call_args[0] == mock_channel_layer.group_send
|
||||||
|
|
||||||
|
def test_handles_missing_channel_layer(self, caplog):
|
||||||
|
"""Should log warning when channel layer is not configured."""
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.get_channel_layer', return_value=None):
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
signals.send_websocket_notification("user_123", {"type": "test"})
|
||||||
|
|
||||||
|
assert "Channel layer not configured" in caplog.text
|
||||||
|
|
||||||
|
def test_handles_exception_gracefully(self, caplog):
|
||||||
|
"""Should log error and not raise when websocket send fails."""
|
||||||
|
mock_channel_layer = MagicMock()
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.get_channel_layer', return_value=mock_channel_layer):
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.async_to_sync', side_effect=Exception("Connection error")):
|
||||||
|
with caplog.at_level(logging.ERROR):
|
||||||
|
# Should not raise
|
||||||
|
signals.send_websocket_notification("user_123", {"type": "test"})
|
||||||
|
|
||||||
|
assert "Failed to send WebSocket notification" in caplog.text
|
||||||
|
assert "user_123" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateNotification:
|
||||||
|
"""Test the create_notification() helper function."""
|
||||||
|
|
||||||
|
def test_skips_when_notifications_unavailable(self, caplog):
|
||||||
|
"""Should skip notification creation when app is unavailable."""
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.is_notifications_available', return_value=False):
|
||||||
|
with caplog.at_level(logging.DEBUG):
|
||||||
|
signals.create_notification(
|
||||||
|
recipient=Mock(),
|
||||||
|
actor=Mock(),
|
||||||
|
verb="test",
|
||||||
|
action_object=Mock(),
|
||||||
|
target=Mock(),
|
||||||
|
data={}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "notifications app not available" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetPlatformSupportTeam:
|
||||||
|
"""Test the get_platform_support_team() helper function."""
|
||||||
|
|
||||||
|
def test_returns_platform_team_members(self):
|
||||||
|
"""Should return users with platform roles."""
|
||||||
|
mock_queryset = Mock()
|
||||||
|
mock_filtered = Mock()
|
||||||
|
mock_queryset.filter.return_value = mock_filtered
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.User.objects', mock_queryset):
|
||||||
|
result = signals.get_platform_support_team()
|
||||||
|
|
||||||
|
# Verify correct filter was applied
|
||||||
|
mock_queryset.filter.assert_called_once_with(
|
||||||
|
role__in=[User.Role.PLATFORM_SUPPORT, User.Role.PLATFORM_MANAGER, User.Role.SUPERUSER],
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
assert result == mock_filtered
|
||||||
|
|
||||||
|
def test_handles_exception_gracefully(self, caplog):
|
||||||
|
"""Should return empty queryset and log error on exception."""
|
||||||
|
mock_queryset = Mock()
|
||||||
|
mock_queryset.filter.side_effect = Exception("DB error")
|
||||||
|
mock_queryset.none.return_value = Mock()
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.User.objects', mock_queryset):
|
||||||
|
with caplog.at_level(logging.ERROR):
|
||||||
|
result = signals.get_platform_support_team()
|
||||||
|
|
||||||
|
assert "Failed to fetch platform support team" in caplog.text
|
||||||
|
mock_queryset.none.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetTenantManagers:
|
||||||
|
"""Test the get_tenant_managers() helper function."""
|
||||||
|
|
||||||
|
def test_returns_tenant_managers(self):
|
||||||
|
"""Should return owners and managers for a tenant."""
|
||||||
|
mock_tenant = Mock(id=1)
|
||||||
|
mock_queryset = Mock()
|
||||||
|
mock_filtered = Mock()
|
||||||
|
mock_queryset.filter.return_value = mock_filtered
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.User.objects', mock_queryset):
|
||||||
|
result = signals.get_tenant_managers(mock_tenant)
|
||||||
|
|
||||||
|
mock_queryset.filter.assert_called_once_with(
|
||||||
|
tenant=mock_tenant,
|
||||||
|
role__in=[User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER],
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
assert result == mock_filtered
|
||||||
|
|
||||||
|
def test_returns_empty_queryset_when_no_tenant(self):
|
||||||
|
"""Should return empty queryset when tenant is None."""
|
||||||
|
mock_queryset = Mock()
|
||||||
|
mock_queryset.none.return_value = Mock()
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.User.objects', mock_queryset):
|
||||||
|
result = signals.get_tenant_managers(None)
|
||||||
|
|
||||||
|
mock_queryset.none.assert_called_once()
|
||||||
|
|
||||||
|
def test_handles_exception_gracefully(self, caplog):
|
||||||
|
"""Should return empty queryset and log error on exception."""
|
||||||
|
mock_tenant = Mock(id=1)
|
||||||
|
mock_queryset = Mock()
|
||||||
|
mock_queryset.filter.side_effect = Exception("DB error")
|
||||||
|
mock_queryset.none.return_value = Mock()
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.User.objects', mock_queryset):
|
||||||
|
with caplog.at_level(logging.ERROR):
|
||||||
|
result = signals.get_tenant_managers(mock_tenant)
|
||||||
|
|
||||||
|
assert "Failed to fetch tenant managers" in caplog.text
|
||||||
|
mock_queryset.none.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class TestTicketPreSaveHandler:
|
||||||
|
"""Test the ticket_pre_save_handler signal receiver."""
|
||||||
|
|
||||||
|
def test_ignores_new_tickets(self):
|
||||||
|
"""Should not store state for new tickets (no pk)."""
|
||||||
|
mock_ticket = Mock(pk=None)
|
||||||
|
|
||||||
|
signals._ticket_pre_save_state.clear()
|
||||||
|
|
||||||
|
signals.ticket_pre_save_handler(sender=Ticket, instance=mock_ticket)
|
||||||
|
|
||||||
|
assert len(signals._ticket_pre_save_state) == 0
|
||||||
|
|
||||||
|
def test_handles_does_not_exist_gracefully(self):
|
||||||
|
"""Should handle DoesNotExist exception gracefully."""
|
||||||
|
mock_ticket = Mock(pk=999)
|
||||||
|
|
||||||
|
signals._ticket_pre_save_state.clear()
|
||||||
|
|
||||||
|
with patch.object(Ticket.objects, 'get', side_effect=Ticket.DoesNotExist):
|
||||||
|
# Should not raise
|
||||||
|
signals.ticket_pre_save_handler(sender=Ticket, instance=mock_ticket)
|
||||||
|
|
||||||
|
assert 999 not in signals._ticket_pre_save_state
|
||||||
|
|
||||||
|
|
||||||
|
class TestTicketNotificationHandler:
|
||||||
|
"""Test the ticket_notification_handler signal receiver."""
|
||||||
|
|
||||||
|
def test_calls_handle_ticket_creation_for_new_tickets(self):
|
||||||
|
"""Should delegate to _handle_ticket_creation for created tickets."""
|
||||||
|
mock_ticket = Mock(id=1)
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals._handle_ticket_creation') as mock_handle:
|
||||||
|
signals.ticket_notification_handler(sender=Ticket, instance=mock_ticket, created=True)
|
||||||
|
|
||||||
|
mock_handle.assert_called_once_with(mock_ticket)
|
||||||
|
|
||||||
|
def test_calls_handle_ticket_update_for_existing_tickets(self):
|
||||||
|
"""Should delegate to _handle_ticket_update for updated tickets."""
|
||||||
|
mock_ticket = Mock(id=1)
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals._handle_ticket_update') as mock_handle:
|
||||||
|
signals.ticket_notification_handler(sender=Ticket, instance=mock_ticket, created=False)
|
||||||
|
|
||||||
|
mock_handle.assert_called_once_with(mock_ticket)
|
||||||
|
|
||||||
|
def test_handles_exception_gracefully(self, caplog):
|
||||||
|
"""Should log error and not raise on exception."""
|
||||||
|
mock_ticket = Mock(id=1)
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals._handle_ticket_creation', side_effect=Exception("Error")):
|
||||||
|
with caplog.at_level(logging.ERROR):
|
||||||
|
# Should not raise
|
||||||
|
signals.ticket_notification_handler(sender=Ticket, instance=mock_ticket, created=True)
|
||||||
|
|
||||||
|
assert "Error in ticket_notification_handler" in caplog.text
|
||||||
|
assert "ticket 1" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestSendTicketEmailNotification:
|
||||||
|
"""Test the _send_ticket_email_notification helper function."""
|
||||||
|
|
||||||
|
@override_settings(TICKET_EMAIL_NOTIFICATIONS_ENABLED=False)
|
||||||
|
def test_skips_when_disabled_in_settings(self):
|
||||||
|
"""Should not send emails when disabled in settings."""
|
||||||
|
mock_ticket = Mock(id=1)
|
||||||
|
|
||||||
|
# The function should return early without importing
|
||||||
|
signals._send_ticket_email_notification('assigned', mock_ticket)
|
||||||
|
# No exception means it returned early
|
||||||
|
|
||||||
|
def test_handles_exception(self, caplog):
|
||||||
|
"""Should log error on exception."""
|
||||||
|
mock_ticket = Mock(id=1)
|
||||||
|
|
||||||
|
with patch.object(signals, '_send_ticket_email_notification') as mock_send:
|
||||||
|
# Test the error logging by calling the original and mocking the import to fail
|
||||||
|
pass # The original function handles exceptions internally
|
||||||
|
|
||||||
|
|
||||||
|
class TestSendCommentEmailNotification:
|
||||||
|
"""Test the _send_comment_email_notification helper function."""
|
||||||
|
|
||||||
|
@override_settings(TICKET_EMAIL_NOTIFICATIONS_ENABLED=False)
|
||||||
|
def test_skips_when_disabled_in_settings(self):
|
||||||
|
"""Should not send emails when disabled in settings."""
|
||||||
|
mock_ticket = Mock(id=1)
|
||||||
|
mock_comment = Mock(is_internal=False)
|
||||||
|
|
||||||
|
# Should return early without error
|
||||||
|
signals._send_comment_email_notification(mock_ticket, mock_comment)
|
||||||
|
|
||||||
|
def test_skips_internal_comments(self):
|
||||||
|
"""Should not send emails for internal comments."""
|
||||||
|
mock_ticket = Mock(id=1)
|
||||||
|
mock_comment = Mock(is_internal=True)
|
||||||
|
|
||||||
|
# Should return early without error
|
||||||
|
signals._send_comment_email_notification(mock_ticket, mock_comment)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHandleTicketCreation:
|
||||||
|
"""Test the _handle_ticket_creation helper function."""
|
||||||
|
|
||||||
|
def test_sends_assigned_email_when_assignee_exists(self):
|
||||||
|
"""Should send assignment email when ticket created with assignee."""
|
||||||
|
mock_ticket = Mock(
|
||||||
|
id=1,
|
||||||
|
assignee_id=5,
|
||||||
|
ticket_type=Ticket.TicketType.CUSTOMER,
|
||||||
|
creator=Mock(full_name="John Doe"),
|
||||||
|
tenant=Mock(id=1),
|
||||||
|
subject="Test",
|
||||||
|
priority="high",
|
||||||
|
category="bug"
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals._send_ticket_email_notification') as mock_email:
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.get_tenant_managers', return_value=[]):
|
||||||
|
signals._handle_ticket_creation(mock_ticket)
|
||||||
|
|
||||||
|
mock_email.assert_called_once_with('assigned', mock_ticket)
|
||||||
|
|
||||||
|
def test_notifies_platform_team_for_platform_tickets(self):
|
||||||
|
"""Should notify platform support team for platform tickets."""
|
||||||
|
mock_creator = Mock(full_name="John Doe")
|
||||||
|
mock_ticket = Mock(
|
||||||
|
id=1,
|
||||||
|
assignee_id=None,
|
||||||
|
ticket_type=Ticket.TicketType.PLATFORM,
|
||||||
|
creator=mock_creator,
|
||||||
|
subject="Platform Issue",
|
||||||
|
priority="high",
|
||||||
|
category="bug"
|
||||||
|
)
|
||||||
|
mock_support = Mock(id=10)
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.get_platform_support_team', return_value=[mock_support]):
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.create_notification') as mock_notify:
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification') as mock_ws:
|
||||||
|
signals._handle_ticket_creation(mock_ticket)
|
||||||
|
|
||||||
|
# Verify notification created
|
||||||
|
mock_notify.assert_called_once_with(
|
||||||
|
recipient=mock_support,
|
||||||
|
actor=mock_creator,
|
||||||
|
verb=f"New platform support ticket #1: 'Platform Issue'",
|
||||||
|
action_object=mock_ticket,
|
||||||
|
target=mock_ticket,
|
||||||
|
data={
|
||||||
|
'ticket_id': 1,
|
||||||
|
'subject': "Platform Issue",
|
||||||
|
'priority': "high",
|
||||||
|
'category': "bug"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify websocket sent
|
||||||
|
mock_ws.assert_called_once()
|
||||||
|
|
||||||
|
def test_notifies_tenant_managers_for_customer_tickets(self):
|
||||||
|
"""Should notify tenant managers for customer tickets."""
|
||||||
|
mock_creator = Mock(full_name="Customer")
|
||||||
|
mock_tenant = Mock(id=1)
|
||||||
|
mock_ticket = Mock(
|
||||||
|
id=2,
|
||||||
|
assignee_id=None,
|
||||||
|
ticket_type=Ticket.TicketType.CUSTOMER,
|
||||||
|
creator=mock_creator,
|
||||||
|
tenant=mock_tenant,
|
||||||
|
subject="Help needed",
|
||||||
|
priority="normal",
|
||||||
|
category="question"
|
||||||
|
)
|
||||||
|
mock_ticket.get_ticket_type_display.return_value = "Customer"
|
||||||
|
mock_manager = Mock(id=20)
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.get_tenant_managers', return_value=[mock_manager]):
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.create_notification') as mock_notify:
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification') as mock_ws:
|
||||||
|
signals._handle_ticket_creation(mock_ticket)
|
||||||
|
|
||||||
|
# Verify notification created
|
||||||
|
mock_notify.assert_called_once()
|
||||||
|
call_kwargs = mock_notify.call_args[1]
|
||||||
|
assert call_kwargs['recipient'] == mock_manager
|
||||||
|
assert "customer ticket" in call_kwargs['verb'].lower()
|
||||||
|
|
||||||
|
def test_handles_creator_without_full_name(self):
|
||||||
|
"""Should use 'Someone' when creator has no full_name."""
|
||||||
|
mock_ticket = Mock(
|
||||||
|
id=1,
|
||||||
|
assignee_id=None,
|
||||||
|
ticket_type=Ticket.TicketType.PLATFORM,
|
||||||
|
creator=None,
|
||||||
|
subject="Test",
|
||||||
|
priority="high",
|
||||||
|
category="bug"
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.get_platform_support_team', return_value=[]):
|
||||||
|
# Should not raise
|
||||||
|
signals._handle_ticket_creation(mock_ticket)
|
||||||
|
|
||||||
|
def test_handles_exception_gracefully(self, caplog):
|
||||||
|
"""Should log error and not raise on exception."""
|
||||||
|
mock_ticket = Mock(id=1)
|
||||||
|
mock_ticket.ticket_type = Ticket.TicketType.PLATFORM
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.get_platform_support_team', side_effect=Exception("Error")):
|
||||||
|
with caplog.at_level(logging.ERROR):
|
||||||
|
# Should not raise
|
||||||
|
signals._handle_ticket_creation(mock_ticket)
|
||||||
|
|
||||||
|
assert "Error handling ticket creation" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestHandleTicketUpdate:
|
||||||
|
"""Test the _handle_ticket_update helper function."""
|
||||||
|
|
||||||
|
def test_sends_assigned_email_when_assignee_changes(self):
|
||||||
|
"""Should send assignment email when assignee changes."""
|
||||||
|
mock_ticket = Mock(
|
||||||
|
pk=1,
|
||||||
|
id=1,
|
||||||
|
assignee_id=5,
|
||||||
|
assignee=Mock(id=5),
|
||||||
|
status=Ticket.Status.OPEN,
|
||||||
|
ticket_type=Ticket.TicketType.CUSTOMER,
|
||||||
|
creator=Mock(id=2),
|
||||||
|
tenant=Mock(id=1),
|
||||||
|
subject="Test"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set pre-save state with different assignee
|
||||||
|
signals._ticket_pre_save_state[1] = {
|
||||||
|
'assignee_id': 3,
|
||||||
|
'status': Ticket.Status.OPEN
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals._send_ticket_email_notification') as mock_email:
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.get_tenant_managers', return_value=[]):
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification'):
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.create_notification'):
|
||||||
|
signals._handle_ticket_update(mock_ticket)
|
||||||
|
|
||||||
|
mock_email.assert_called_with('assigned', mock_ticket)
|
||||||
|
|
||||||
|
def test_sends_resolved_email_when_status_becomes_resolved(self):
|
||||||
|
"""Should send resolved email when status changes to RESOLVED."""
|
||||||
|
mock_ticket = Mock(
|
||||||
|
pk=1,
|
||||||
|
id=1,
|
||||||
|
assignee_id=None,
|
||||||
|
assignee=None,
|
||||||
|
status=Ticket.Status.RESOLVED,
|
||||||
|
ticket_type=Ticket.TicketType.CUSTOMER,
|
||||||
|
creator=Mock(id=2),
|
||||||
|
tenant=Mock(id=1),
|
||||||
|
subject="Test"
|
||||||
|
)
|
||||||
|
|
||||||
|
signals._ticket_pre_save_state[1] = {
|
||||||
|
'assignee_id': None,
|
||||||
|
'status': Ticket.Status.OPEN
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals._send_ticket_email_notification') as mock_email:
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.get_tenant_managers', return_value=[]):
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification'):
|
||||||
|
signals._handle_ticket_update(mock_ticket)
|
||||||
|
|
||||||
|
mock_email.assert_called_with('resolved', mock_ticket)
|
||||||
|
|
||||||
|
def test_sends_resolved_email_when_status_becomes_closed(self):
|
||||||
|
"""Should send resolved email when status changes to CLOSED."""
|
||||||
|
mock_ticket = Mock(
|
||||||
|
pk=1,
|
||||||
|
id=1,
|
||||||
|
assignee_id=None,
|
||||||
|
assignee=None,
|
||||||
|
status=Ticket.Status.CLOSED,
|
||||||
|
ticket_type=Ticket.TicketType.CUSTOMER,
|
||||||
|
creator=Mock(id=2),
|
||||||
|
tenant=Mock(id=1),
|
||||||
|
subject="Test"
|
||||||
|
)
|
||||||
|
|
||||||
|
signals._ticket_pre_save_state[1] = {
|
||||||
|
'assignee_id': None,
|
||||||
|
'status': Ticket.Status.OPEN
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals._send_ticket_email_notification') as mock_email:
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.get_tenant_managers', return_value=[]):
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification'):
|
||||||
|
signals._handle_ticket_update(mock_ticket)
|
||||||
|
|
||||||
|
mock_email.assert_called_with('resolved', mock_ticket)
|
||||||
|
|
||||||
|
def test_sends_status_changed_email_for_other_changes(self):
|
||||||
|
"""Should send status changed email for non-resolved status changes."""
|
||||||
|
mock_ticket = Mock(
|
||||||
|
pk=1,
|
||||||
|
id=1,
|
||||||
|
assignee_id=None,
|
||||||
|
assignee=None,
|
||||||
|
status=Ticket.Status.IN_PROGRESS,
|
||||||
|
ticket_type=Ticket.TicketType.CUSTOMER,
|
||||||
|
creator=Mock(id=2),
|
||||||
|
tenant=Mock(id=1),
|
||||||
|
subject="Test"
|
||||||
|
)
|
||||||
|
|
||||||
|
signals._ticket_pre_save_state[1] = {
|
||||||
|
'assignee_id': None,
|
||||||
|
'status': Ticket.Status.OPEN
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals._send_ticket_email_notification') as mock_email:
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.get_tenant_managers', return_value=[]):
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification'):
|
||||||
|
signals._handle_ticket_update(mock_ticket)
|
||||||
|
|
||||||
|
mock_email.assert_called_with(
|
||||||
|
'status_changed',
|
||||||
|
mock_ticket,
|
||||||
|
old_status=Ticket.Status.OPEN
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_sends_websocket_to_platform_team_for_platform_tickets(self):
|
||||||
|
"""Should send websocket notifications to platform team."""
|
||||||
|
mock_ticket = Mock(
|
||||||
|
pk=1,
|
||||||
|
id=1,
|
||||||
|
assignee_id=None,
|
||||||
|
assignee=None,
|
||||||
|
status=Ticket.Status.OPEN,
|
||||||
|
ticket_type=Ticket.TicketType.PLATFORM,
|
||||||
|
creator=Mock(id=2),
|
||||||
|
tenant=None,
|
||||||
|
subject="Platform Issue",
|
||||||
|
priority="high"
|
||||||
|
)
|
||||||
|
mock_support = Mock(id=10)
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.get_platform_support_team', return_value=[mock_support]):
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification') as mock_ws:
|
||||||
|
signals._handle_ticket_update(mock_ticket)
|
||||||
|
|
||||||
|
# Should send to platform team member
|
||||||
|
calls = mock_ws.call_args_list
|
||||||
|
assert any("user_10" in str(call) for call in calls)
|
||||||
|
|
||||||
|
def test_sends_websocket_to_tenant_managers(self):
|
||||||
|
"""Should send websocket notifications to tenant managers."""
|
||||||
|
mock_ticket = Mock(
|
||||||
|
pk=1,
|
||||||
|
id=1,
|
||||||
|
assignee_id=None,
|
||||||
|
assignee=None,
|
||||||
|
status=Ticket.Status.OPEN,
|
||||||
|
ticket_type=Ticket.TicketType.CUSTOMER,
|
||||||
|
creator=Mock(id=2),
|
||||||
|
tenant=Mock(id=1),
|
||||||
|
subject="Customer Issue",
|
||||||
|
priority="normal"
|
||||||
|
)
|
||||||
|
mock_manager = Mock(id=20)
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.get_tenant_managers', return_value=[mock_manager]):
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification') as mock_ws:
|
||||||
|
signals._handle_ticket_update(mock_ticket)
|
||||||
|
|
||||||
|
# Should send to tenant manager
|
||||||
|
calls = mock_ws.call_args_list
|
||||||
|
assert any("user_20" in str(call) for call in calls)
|
||||||
|
|
||||||
|
def test_notifies_assignee(self):
|
||||||
|
"""Should create notification and send websocket to assignee."""
|
||||||
|
mock_assignee = Mock(id=5)
|
||||||
|
mock_ticket = Mock(
|
||||||
|
pk=1,
|
||||||
|
id=1,
|
||||||
|
assignee_id=5,
|
||||||
|
assignee=mock_assignee,
|
||||||
|
status=Ticket.Status.OPEN,
|
||||||
|
ticket_type=Ticket.TicketType.CUSTOMER,
|
||||||
|
creator=Mock(id=2),
|
||||||
|
tenant=Mock(id=1),
|
||||||
|
subject="Test"
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.create_notification') as mock_notify:
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification') as mock_ws:
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.get_tenant_managers', return_value=[]):
|
||||||
|
signals._handle_ticket_update(mock_ticket)
|
||||||
|
|
||||||
|
# Verify notification created for assignee
|
||||||
|
mock_notify.assert_called_once()
|
||||||
|
call_kwargs = mock_notify.call_args[1]
|
||||||
|
assert call_kwargs['recipient'] == mock_assignee
|
||||||
|
|
||||||
|
# Verify websocket sent to assignee
|
||||||
|
calls = mock_ws.call_args_list
|
||||||
|
assert any("user_5" in str(call) for call in calls)
|
||||||
|
|
||||||
|
def test_handles_no_pre_save_state(self):
|
||||||
|
"""Should handle update when no pre-save state exists."""
|
||||||
|
mock_ticket = Mock(
|
||||||
|
pk=999,
|
||||||
|
id=999,
|
||||||
|
assignee_id=None,
|
||||||
|
assignee=None,
|
||||||
|
status=Ticket.Status.OPEN,
|
||||||
|
ticket_type=Ticket.TicketType.CUSTOMER,
|
||||||
|
creator=Mock(id=2),
|
||||||
|
tenant=Mock(id=1),
|
||||||
|
subject="Test",
|
||||||
|
priority="normal"
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification'):
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.get_tenant_managers', return_value=[]):
|
||||||
|
# Should not raise
|
||||||
|
signals._handle_ticket_update(mock_ticket)
|
||||||
|
|
||||||
|
def test_handles_exception_gracefully(self, caplog):
|
||||||
|
"""Should log error and not raise on exception."""
|
||||||
|
mock_ticket = Mock(pk=1, id=1)
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification', side_effect=Exception("Error")):
|
||||||
|
with caplog.at_level(logging.ERROR):
|
||||||
|
# Should not raise
|
||||||
|
signals._handle_ticket_update(mock_ticket)
|
||||||
|
|
||||||
|
assert "Error handling ticket update" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommentNotificationHandler:
|
||||||
|
"""Test the comment_notification_handler signal receiver."""
|
||||||
|
|
||||||
|
def test_ignores_comment_updates(self):
|
||||||
|
"""Should not process comment updates, only creation."""
|
||||||
|
mock_comment = Mock(id=1)
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals._send_comment_email_notification') as mock_email:
|
||||||
|
signals.comment_notification_handler(sender=TicketComment, instance=mock_comment, created=False)
|
||||||
|
|
||||||
|
mock_email.assert_not_called()
|
||||||
|
|
||||||
|
def test_sends_email_notification_on_creation(self):
|
||||||
|
"""Should send email notification when comment is created."""
|
||||||
|
mock_ticket = Mock(id=1, creator=Mock(id=2), first_response_at=None)
|
||||||
|
mock_author = Mock(id=3, full_name="Support Agent")
|
||||||
|
mock_comment = Mock(id=1, ticket=mock_ticket, author=mock_author)
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals._send_comment_email_notification') as mock_email:
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.create_notification'):
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification'):
|
||||||
|
signals.comment_notification_handler(sender=TicketComment, instance=mock_comment, created=True)
|
||||||
|
|
||||||
|
mock_email.assert_called_once_with(mock_ticket, mock_comment)
|
||||||
|
|
||||||
|
def test_sets_first_response_at_when_not_creator(self, caplog):
|
||||||
|
"""Should set first_response_at when comment is from non-creator."""
|
||||||
|
mock_creator = Mock(id=2)
|
||||||
|
mock_author = Mock(id=3, full_name="Support")
|
||||||
|
mock_ticket = Mock(
|
||||||
|
id=1,
|
||||||
|
creator=mock_creator,
|
||||||
|
first_response_at=None,
|
||||||
|
save=Mock()
|
||||||
|
)
|
||||||
|
mock_comment = Mock(id=1, ticket=mock_ticket, author=mock_author)
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals._send_comment_email_notification'):
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.timezone.now', return_value=Mock()):
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.create_notification'):
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification'):
|
||||||
|
with caplog.at_level(logging.INFO):
|
||||||
|
signals.comment_notification_handler(sender=TicketComment, instance=mock_comment, created=True)
|
||||||
|
|
||||||
|
# Verify first_response_at was set
|
||||||
|
assert mock_ticket.first_response_at is not None
|
||||||
|
mock_ticket.save.assert_called_once_with(update_fields=['first_response_at'])
|
||||||
|
assert "Set first_response_at for ticket 1" in caplog.text
|
||||||
|
|
||||||
|
def test_does_not_set_first_response_at_for_creator_comment(self):
|
||||||
|
"""Should not set first_response_at when creator comments."""
|
||||||
|
mock_creator = Mock(id=2)
|
||||||
|
mock_ticket = Mock(
|
||||||
|
id=1,
|
||||||
|
creator=mock_creator,
|
||||||
|
first_response_at=None,
|
||||||
|
save=Mock()
|
||||||
|
)
|
||||||
|
mock_comment = Mock(id=1, ticket=mock_ticket, author=mock_creator)
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals._send_comment_email_notification'):
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.create_notification'):
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification'):
|
||||||
|
signals.comment_notification_handler(sender=TicketComment, instance=mock_comment, created=True)
|
||||||
|
|
||||||
|
# Verify first_response_at was NOT set
|
||||||
|
mock_ticket.save.assert_not_called()
|
||||||
|
|
||||||
|
def test_does_not_overwrite_existing_first_response_at(self):
|
||||||
|
"""Should not overwrite first_response_at if already set."""
|
||||||
|
mock_creator = Mock(id=2)
|
||||||
|
mock_author = Mock(id=3)
|
||||||
|
existing_time = Mock()
|
||||||
|
mock_ticket = Mock(
|
||||||
|
id=1,
|
||||||
|
creator=mock_creator,
|
||||||
|
first_response_at=existing_time,
|
||||||
|
save=Mock()
|
||||||
|
)
|
||||||
|
mock_comment = Mock(id=1, ticket=mock_ticket, author=mock_author)
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals._send_comment_email_notification'):
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.create_notification'):
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification'):
|
||||||
|
signals.comment_notification_handler(sender=TicketComment, instance=mock_comment, created=True)
|
||||||
|
|
||||||
|
# Verify first_response_at was NOT changed
|
||||||
|
assert mock_ticket.first_response_at == existing_time
|
||||||
|
mock_ticket.save.assert_not_called()
|
||||||
|
|
||||||
|
def test_notifies_ticket_creator(self):
|
||||||
|
"""Should notify ticket creator about new comment."""
|
||||||
|
mock_creator = Mock(id=2)
|
||||||
|
mock_author = Mock(id=3, full_name="Support Agent")
|
||||||
|
mock_ticket = Mock(
|
||||||
|
id=1,
|
||||||
|
creator=mock_creator,
|
||||||
|
assignee=None,
|
||||||
|
first_response_at=Mock(),
|
||||||
|
subject="Help"
|
||||||
|
)
|
||||||
|
mock_comment = Mock(id=10, ticket=mock_ticket, author=mock_author)
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals._send_comment_email_notification'):
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.create_notification') as mock_notify:
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification') as mock_ws:
|
||||||
|
signals.comment_notification_handler(sender=TicketComment, instance=mock_comment, created=True)
|
||||||
|
|
||||||
|
# Verify notification created for creator
|
||||||
|
mock_notify.assert_called()
|
||||||
|
call_kwargs = mock_notify.call_args[1]
|
||||||
|
assert call_kwargs['recipient'] == mock_creator
|
||||||
|
assert "New comment on your ticket" in call_kwargs['verb']
|
||||||
|
|
||||||
|
# Verify websocket sent to creator
|
||||||
|
calls = mock_ws.call_args_list
|
||||||
|
assert any("user_2" in str(call) for call in calls)
|
||||||
|
|
||||||
|
def test_does_not_notify_creator_if_they_are_author(self):
|
||||||
|
"""Should not notify creator if they authored the comment."""
|
||||||
|
mock_creator = Mock(id=2)
|
||||||
|
mock_ticket = Mock(
|
||||||
|
id=1,
|
||||||
|
creator=mock_creator,
|
||||||
|
assignee=None,
|
||||||
|
first_response_at=Mock(),
|
||||||
|
subject="Help"
|
||||||
|
)
|
||||||
|
mock_comment = Mock(id=10, ticket=mock_ticket, author=mock_creator)
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals._send_comment_email_notification'):
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.create_notification') as mock_notify:
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification'):
|
||||||
|
signals.comment_notification_handler(sender=TicketComment, instance=mock_comment, created=True)
|
||||||
|
|
||||||
|
# Verify notification NOT created
|
||||||
|
mock_notify.assert_not_called()
|
||||||
|
|
||||||
|
def test_notifies_assignee(self):
|
||||||
|
"""Should notify assignee about new comment."""
|
||||||
|
mock_creator = Mock(id=2)
|
||||||
|
mock_assignee = Mock(id=5)
|
||||||
|
mock_author = Mock(id=3, full_name="Customer")
|
||||||
|
mock_ticket = Mock(
|
||||||
|
id=1,
|
||||||
|
creator=mock_creator,
|
||||||
|
assignee=mock_assignee,
|
||||||
|
first_response_at=Mock(),
|
||||||
|
subject="Issue"
|
||||||
|
)
|
||||||
|
mock_comment = Mock(id=10, ticket=mock_ticket, author=mock_author)
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals._send_comment_email_notification'):
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.create_notification') as mock_notify:
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification') as mock_ws:
|
||||||
|
signals.comment_notification_handler(sender=TicketComment, instance=mock_comment, created=True)
|
||||||
|
|
||||||
|
# Should be called twice: once for creator, once for assignee
|
||||||
|
assert mock_notify.call_count == 2
|
||||||
|
|
||||||
|
# Verify assignee notification
|
||||||
|
calls = [call[1] for call in mock_notify.call_args_list]
|
||||||
|
assignee_call = [c for c in calls if c['recipient'] == mock_assignee][0]
|
||||||
|
assert "you are assigned to" in assignee_call['verb']
|
||||||
|
|
||||||
|
def test_does_not_notify_assignee_if_they_are_author(self):
|
||||||
|
"""Should not notify assignee if they authored the comment."""
|
||||||
|
mock_creator = Mock(id=2)
|
||||||
|
mock_assignee = Mock(id=5)
|
||||||
|
mock_ticket = Mock(
|
||||||
|
id=1,
|
||||||
|
creator=mock_creator,
|
||||||
|
assignee=mock_assignee,
|
||||||
|
first_response_at=Mock(),
|
||||||
|
subject="Issue"
|
||||||
|
)
|
||||||
|
mock_comment = Mock(id=10, ticket=mock_ticket, author=mock_assignee)
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals._send_comment_email_notification'):
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.create_notification') as mock_notify:
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification'):
|
||||||
|
signals.comment_notification_handler(sender=TicketComment, instance=mock_comment, created=True)
|
||||||
|
|
||||||
|
# Should only notify creator (not assignee)
|
||||||
|
assert mock_notify.call_count == 1
|
||||||
|
call_kwargs = mock_notify.call_args[1]
|
||||||
|
assert call_kwargs['recipient'] == mock_creator
|
||||||
|
|
||||||
|
def test_does_not_notify_assignee_if_they_are_also_creator(self):
|
||||||
|
"""Should not notify assignee if they are also the creator."""
|
||||||
|
mock_user = Mock(id=2) # Same user is both creator and assignee
|
||||||
|
mock_author = Mock(id=3)
|
||||||
|
mock_ticket = Mock(
|
||||||
|
id=1,
|
||||||
|
creator=mock_user,
|
||||||
|
assignee=mock_user,
|
||||||
|
first_response_at=Mock(),
|
||||||
|
subject="Issue"
|
||||||
|
)
|
||||||
|
mock_comment = Mock(id=10, ticket=mock_ticket, author=mock_author)
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals._send_comment_email_notification'):
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.create_notification') as mock_notify:
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification'):
|
||||||
|
signals.comment_notification_handler(sender=TicketComment, instance=mock_comment, created=True)
|
||||||
|
|
||||||
|
# Should only notify once (as creator, not as assignee)
|
||||||
|
assert mock_notify.call_count == 1
|
||||||
|
|
||||||
|
def test_handles_exception_gracefully(self, caplog):
|
||||||
|
"""Should log error and not raise on exception."""
|
||||||
|
mock_comment = Mock(id=1)
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals._send_comment_email_notification', side_effect=Exception("Error")):
|
||||||
|
with caplog.at_level(logging.ERROR):
|
||||||
|
# Should not raise
|
||||||
|
signals.comment_notification_handler(sender=TicketComment, instance=mock_comment, created=True)
|
||||||
|
|
||||||
|
assert "Error in comment_notification_handler" in caplog.text
|
||||||
|
assert "comment 1" in caplog.text
|
||||||
|
|
||||||
|
def test_handles_author_without_full_name(self):
|
||||||
|
"""Should use 'Someone' when author has no full_name."""
|
||||||
|
mock_ticket = Mock(
|
||||||
|
id=1,
|
||||||
|
creator=Mock(id=2),
|
||||||
|
assignee=None,
|
||||||
|
first_response_at=Mock(),
|
||||||
|
subject="Test"
|
||||||
|
)
|
||||||
|
mock_comment = Mock(id=10, ticket=mock_ticket, author=None)
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals._send_comment_email_notification'):
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.create_notification'):
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification'):
|
||||||
|
# Should not raise
|
||||||
|
signals.comment_notification_handler(sender=TicketComment, instance=mock_comment, created=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSignalRegistration:
|
||||||
|
"""Test that signals are properly registered."""
|
||||||
|
|
||||||
|
def test_ticket_pre_save_handler_is_registered(self):
|
||||||
|
"""Should verify ticket_pre_save_handler is connected to pre_save signal."""
|
||||||
|
assert callable(signals.ticket_pre_save_handler)
|
||||||
|
|
||||||
|
# Verify it accepts the correct parameters by calling it
|
||||||
|
mock_ticket = Mock(pk=None)
|
||||||
|
try:
|
||||||
|
signals.ticket_pre_save_handler(sender=Ticket, instance=mock_ticket)
|
||||||
|
assert True
|
||||||
|
except TypeError as e:
|
||||||
|
pytest.fail(f"Handler has incorrect signature: {e}")
|
||||||
|
|
||||||
|
def test_ticket_notification_handler_is_registered(self):
|
||||||
|
"""Should verify ticket_notification_handler is connected to post_save signal."""
|
||||||
|
assert callable(signals.ticket_notification_handler)
|
||||||
|
|
||||||
|
# Verify it accepts the correct parameters
|
||||||
|
mock_ticket = Mock(id=1, ticket_type=Ticket.TicketType.CUSTOMER)
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals._handle_ticket_creation'):
|
||||||
|
try:
|
||||||
|
signals.ticket_notification_handler(sender=Ticket, instance=mock_ticket, created=True)
|
||||||
|
assert True
|
||||||
|
except TypeError as e:
|
||||||
|
pytest.fail(f"Handler has incorrect signature: {e}")
|
||||||
|
|
||||||
|
def test_comment_notification_handler_is_registered(self):
|
||||||
|
"""Should verify comment_notification_handler is connected to post_save signal."""
|
||||||
|
assert callable(signals.comment_notification_handler)
|
||||||
|
|
||||||
|
# Verify it accepts the correct parameters
|
||||||
|
try:
|
||||||
|
signals.comment_notification_handler(sender=TicketComment, instance=Mock(id=1), created=False)
|
||||||
|
assert True
|
||||||
|
except TypeError as e:
|
||||||
|
pytest.fail(f"Handler has incorrect signature: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
class TestSignalHandlerSignatures:
|
||||||
|
"""Test that signal handlers have correct signatures."""
|
||||||
|
|
||||||
|
def test_ticket_pre_save_handler_signature(self):
|
||||||
|
"""Should accept correct parameters for pre_save signal."""
|
||||||
|
# Should not raise TypeError
|
||||||
|
signals.ticket_pre_save_handler(
|
||||||
|
sender=Ticket,
|
||||||
|
instance=Mock(pk=None),
|
||||||
|
raw=False,
|
||||||
|
using='default',
|
||||||
|
update_fields=None
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_ticket_notification_handler_signature(self):
|
||||||
|
"""Should accept correct parameters for post_save signal."""
|
||||||
|
mock_ticket = Mock(id=1, ticket_type=Ticket.TicketType.CUSTOMER)
|
||||||
|
|
||||||
|
with patch('smoothschedule.commerce.tickets.signals._handle_ticket_creation'):
|
||||||
|
# Should not raise TypeError
|
||||||
|
signals.ticket_notification_handler(
|
||||||
|
sender=Ticket,
|
||||||
|
instance=mock_ticket,
|
||||||
|
created=True,
|
||||||
|
raw=False,
|
||||||
|
using='default',
|
||||||
|
update_fields=None
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_comment_notification_handler_signature(self):
|
||||||
|
"""Should accept correct parameters for post_save signal."""
|
||||||
|
# Should not raise TypeError (testing signature, not functionality)
|
||||||
|
signals.comment_notification_handler(
|
||||||
|
sender=TicketComment,
|
||||||
|
instance=Mock(id=1),
|
||||||
|
created=False,
|
||||||
|
raw=False,
|
||||||
|
using='default',
|
||||||
|
update_fields=None
|
||||||
|
)
|
||||||
1199
smoothschedule/smoothschedule/commerce/tickets/tests/test_views.py
Normal file
1199
smoothschedule/smoothschedule/commerce/tickets/tests/test_views.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,8 +7,8 @@ from rest_framework.views import APIView
|
|||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||||
|
|
||||||
from core.models import Tenant
|
from smoothschedule.identity.core.models import Tenant
|
||||||
from smoothschedule.users.models import User
|
from smoothschedule.identity.users.models import User
|
||||||
from .models import Ticket, TicketComment, TicketTemplate, CannedResponse, IncomingTicketEmail, TicketEmailAddress
|
from .models import Ticket, TicketComment, TicketTemplate, CannedResponse, IncomingTicketEmail, TicketEmailAddress
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
TicketSerializer, TicketListSerializer, TicketCommentSerializer,
|
TicketSerializer, TicketListSerializer, TicketCommentSerializer,
|
||||||
@@ -804,7 +804,7 @@ class TicketEmailAddressViewSet(viewsets.ModelViewSet):
|
|||||||
# Business users see only their own email addresses
|
# Business users see only their own email addresses
|
||||||
if hasattr(user, 'tenant') and user.tenant:
|
if hasattr(user, 'tenant') and user.tenant:
|
||||||
# Only owners and managers can view/manage email addresses
|
# Only owners and managers can view/manage email addresses
|
||||||
if user.role in [User.Role.OWNER, User.Role.MANAGER]:
|
if user.role in [User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER]:
|
||||||
return TicketEmailAddress.objects.filter(tenant=user.tenant)
|
return TicketEmailAddress.objects.filter(tenant=user.tenant)
|
||||||
|
|
||||||
return TicketEmailAddress.objects.none()
|
return TicketEmailAddress.objects.none()
|
||||||
@@ -941,7 +941,7 @@ class RefreshTicketEmailsView(APIView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
from .email_receiver import PlatformEmailReceiver
|
from .email_receiver import PlatformEmailReceiver
|
||||||
from platform_admin.models import PlatformEmailAddress
|
from smoothschedule.platform.admin.models import PlatformEmailAddress
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
total_processed = 0
|
total_processed = 0
|
||||||
@@ -3,5 +3,6 @@ from django.apps import AppConfig
|
|||||||
|
|
||||||
class CommsCreditsConfig(AppConfig):
|
class CommsCreditsConfig(AppConfig):
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
name = 'smoothschedule.comms_credits'
|
name = 'smoothschedule.communication.credits'
|
||||||
|
label = 'comms_credits'
|
||||||
verbose_name = 'Communication Credits'
|
verbose_name = 'Communication Credits'
|
||||||
@@ -109,6 +109,7 @@ class CommunicationCredits(models.Model):
|
|||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
app_label = 'comms_credits'
|
||||||
verbose_name = 'Communication Credits'
|
verbose_name = 'Communication Credits'
|
||||||
verbose_name_plural = 'Communication Credits'
|
verbose_name_plural = 'Communication Credits'
|
||||||
|
|
||||||
@@ -210,7 +211,7 @@ class CommunicationCredits(models.Model):
|
|||||||
|
|
||||||
def _send_low_balance_warning(self):
|
def _send_low_balance_warning(self):
|
||||||
"""Send low balance warning email."""
|
"""Send low balance warning email."""
|
||||||
from smoothschedule.comms_credits.tasks import send_low_balance_warning
|
from smoothschedule.communication.credits.tasks import send_low_balance_warning
|
||||||
send_low_balance_warning.delay(self.id)
|
send_low_balance_warning.delay(self.id)
|
||||||
|
|
||||||
self.low_balance_warning_sent = True
|
self.low_balance_warning_sent = True
|
||||||
@@ -219,7 +220,7 @@ class CommunicationCredits(models.Model):
|
|||||||
|
|
||||||
def _trigger_auto_reload(self):
|
def _trigger_auto_reload(self):
|
||||||
"""Trigger auto-reload of credits."""
|
"""Trigger auto-reload of credits."""
|
||||||
from smoothschedule.comms_credits.tasks import process_auto_reload
|
from smoothschedule.communication.credits.tasks import process_auto_reload
|
||||||
process_auto_reload.delay(self.id)
|
process_auto_reload.delay(self.id)
|
||||||
|
|
||||||
|
|
||||||
@@ -291,6 +292,7 @@ class CreditTransaction(models.Model):
|
|||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
app_label = 'comms_credits'
|
||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['credits', '-created_at']),
|
models.Index(fields=['credits', '-created_at']),
|
||||||
@@ -383,6 +385,7 @@ class ProxyPhoneNumber(models.Model):
|
|||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
app_label = 'comms_credits'
|
||||||
ordering = ['phone_number']
|
ordering = ['phone_number']
|
||||||
verbose_name = 'Proxy Phone Number'
|
verbose_name = 'Proxy Phone Number'
|
||||||
verbose_name_plural = 'Proxy Phone Numbers'
|
verbose_name_plural = 'Proxy Phone Numbers'
|
||||||
@@ -495,6 +498,7 @@ class MaskedSession(models.Model):
|
|||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
app_label = 'comms_credits'
|
||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['tenant', 'status']),
|
models.Index(fields=['tenant', 'status']),
|
||||||
@@ -20,7 +20,7 @@ def sync_twilio_usage_all_tenants():
|
|||||||
2. Calculate charges with markup
|
2. Calculate charges with markup
|
||||||
3. Deduct from tenant credits
|
3. Deduct from tenant credits
|
||||||
"""
|
"""
|
||||||
from core.models import Tenant
|
from smoothschedule.identity.core.models import Tenant
|
||||||
|
|
||||||
tenants = Tenant.objects.exclude(twilio_subaccount_sid='')
|
tenants = Tenant.objects.exclude(twilio_subaccount_sid='')
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ def sync_twilio_usage_for_tenant(tenant_id):
|
|||||||
|
|
||||||
Fetches usage from Twilio API and deducts from credits.
|
Fetches usage from Twilio API and deducts from credits.
|
||||||
"""
|
"""
|
||||||
from core.models import Tenant
|
from smoothschedule.identity.core.models import Tenant
|
||||||
from .models import CommunicationCredits
|
from .models import CommunicationCredits
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -219,7 +219,7 @@ def process_auto_reload(credits_id):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Get Stripe API key from platform settings
|
# Get Stripe API key from platform settings
|
||||||
from platform_admin.models import PlatformSettings
|
from smoothschedule.platform.admin.models import PlatformSettings
|
||||||
platform_settings = PlatformSettings.get_instance()
|
platform_settings = PlatformSettings.get_instance()
|
||||||
stripe.api_key = platform_settings.get_stripe_secret_key()
|
stripe.api_key = platform_settings.get_stripe_secret_key()
|
||||||
|
|
||||||
@@ -373,7 +373,7 @@ def create_twilio_subaccount(tenant_id):
|
|||||||
|
|
||||||
Called when SMS/calling is first enabled for a tenant.
|
Called when SMS/calling is first enabled for a tenant.
|
||||||
"""
|
"""
|
||||||
from core.models import Tenant
|
from smoothschedule.identity.core.models import Tenant
|
||||||
from twilio.rest import Client
|
from twilio.rest import Client
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -0,0 +1,716 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for Communication Credits models.
|
||||||
|
|
||||||
|
These tests use mocks to avoid database dependencies and run quickly.
|
||||||
|
They test model methods, properties, and business logic.
|
||||||
|
"""
|
||||||
|
from unittest.mock import Mock, patch, MagicMock, call
|
||||||
|
from datetime import datetime, timedelta, timezone as dt_timezone
|
||||||
|
from django.utils import timezone
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommunicationCreditsModel:
|
||||||
|
"""Tests for CommunicationCredits model."""
|
||||||
|
|
||||||
|
def test_str_representation(self):
|
||||||
|
"""Test string representation shows tenant and balance."""
|
||||||
|
from smoothschedule.communication.credits.models import CommunicationCredits
|
||||||
|
|
||||||
|
mock_tenant = Mock()
|
||||||
|
mock_tenant.name = "Test Business"
|
||||||
|
|
||||||
|
# Create instance and set attributes directly
|
||||||
|
credits = Mock(spec=CommunicationCredits)
|
||||||
|
credits.tenant = mock_tenant
|
||||||
|
credits.balance_cents = 1500
|
||||||
|
|
||||||
|
# Call the real __str__ method
|
||||||
|
result = CommunicationCredits.__str__(credits)
|
||||||
|
|
||||||
|
assert result == "Test Business - $15.00"
|
||||||
|
|
||||||
|
def test_balance_property_converts_cents_to_dollars(self):
|
||||||
|
"""Test balance property returns dollars."""
|
||||||
|
from smoothschedule.communication.credits.models import CommunicationCredits
|
||||||
|
|
||||||
|
credits = Mock(spec=CommunicationCredits)
|
||||||
|
credits.balance_cents = 2500
|
||||||
|
|
||||||
|
# Call the real property getter
|
||||||
|
result = CommunicationCredits.balance.fget(credits)
|
||||||
|
|
||||||
|
assert result == 25.0
|
||||||
|
|
||||||
|
def test_balance_property_handles_zero(self):
|
||||||
|
"""Test balance property handles zero balance."""
|
||||||
|
from smoothschedule.communication.credits.models import CommunicationCredits
|
||||||
|
|
||||||
|
credits = Mock(spec=CommunicationCredits)
|
||||||
|
credits.balance_cents = 0
|
||||||
|
|
||||||
|
result = CommunicationCredits.balance.fget(credits)
|
||||||
|
|
||||||
|
assert result == 0.0
|
||||||
|
|
||||||
|
def test_auto_reload_threshold_property(self):
|
||||||
|
"""Test auto reload threshold converts cents to dollars."""
|
||||||
|
from smoothschedule.communication.credits.models import CommunicationCredits
|
||||||
|
|
||||||
|
credits = Mock(spec=CommunicationCredits)
|
||||||
|
credits.auto_reload_threshold_cents = 1000
|
||||||
|
|
||||||
|
result = CommunicationCredits.auto_reload_threshold.fget(credits)
|
||||||
|
|
||||||
|
assert result == 10.0
|
||||||
|
|
||||||
|
def test_auto_reload_amount_property(self):
|
||||||
|
"""Test auto reload amount converts cents to dollars."""
|
||||||
|
from smoothschedule.communication.credits.models import CommunicationCredits
|
||||||
|
|
||||||
|
credits = Mock(spec=CommunicationCredits)
|
||||||
|
credits.auto_reload_amount_cents = 2500
|
||||||
|
|
||||||
|
result = CommunicationCredits.auto_reload_amount.fget(credits)
|
||||||
|
|
||||||
|
assert result == 25.0
|
||||||
|
|
||||||
|
def test_deduct_success_returns_transaction(self):
|
||||||
|
"""Test deduct with sufficient balance returns transaction."""
|
||||||
|
from smoothschedule.communication.credits.models import CommunicationCredits
|
||||||
|
|
||||||
|
credits = Mock(spec=CommunicationCredits)
|
||||||
|
credits.balance_cents = 5000
|
||||||
|
credits.total_spent_cents = 0
|
||||||
|
credits.save = Mock()
|
||||||
|
credits._check_thresholds = Mock()
|
||||||
|
|
||||||
|
with patch('smoothschedule.communication.credits.models.CreditTransaction') as mock_tx:
|
||||||
|
mock_transaction = Mock(id=1)
|
||||||
|
mock_tx.objects.create.return_value = mock_transaction
|
||||||
|
|
||||||
|
# Call the real deduct method
|
||||||
|
result = CommunicationCredits.deduct(
|
||||||
|
credits,
|
||||||
|
amount_cents=1000,
|
||||||
|
description="Test charge",
|
||||||
|
reference_type="sms",
|
||||||
|
reference_id="SM123"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify balance updated
|
||||||
|
assert credits.balance_cents == 4000
|
||||||
|
assert credits.total_spent_cents == 1000
|
||||||
|
|
||||||
|
# Verify save called
|
||||||
|
credits.save.assert_called_once_with(
|
||||||
|
update_fields=['balance_cents', 'total_spent_cents', 'updated_at']
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify transaction created
|
||||||
|
mock_tx.objects.create.assert_called_once_with(
|
||||||
|
credits=credits,
|
||||||
|
amount_cents=-1000,
|
||||||
|
balance_after_cents=4000,
|
||||||
|
transaction_type='usage',
|
||||||
|
description="Test charge",
|
||||||
|
reference_type="sms",
|
||||||
|
reference_id="SM123"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify thresholds checked
|
||||||
|
credits._check_thresholds.assert_called_once()
|
||||||
|
|
||||||
|
assert result == mock_transaction
|
||||||
|
|
||||||
|
def test_deduct_insufficient_balance_returns_none(self):
|
||||||
|
"""Test deduct with insufficient balance returns None."""
|
||||||
|
from smoothschedule.communication.credits.models import CommunicationCredits
|
||||||
|
|
||||||
|
credits = Mock(spec=CommunicationCredits)
|
||||||
|
credits.balance_cents = 500
|
||||||
|
credits.save = Mock()
|
||||||
|
|
||||||
|
result = CommunicationCredits.deduct(
|
||||||
|
credits,
|
||||||
|
amount_cents=1000,
|
||||||
|
description="Test charge"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify no changes made
|
||||||
|
assert credits.balance_cents == 500
|
||||||
|
credits.save.assert_not_called()
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_deduct_handles_none_references(self):
|
||||||
|
"""Test deduct handles None reference_type and reference_id."""
|
||||||
|
from smoothschedule.communication.credits.models import CommunicationCredits
|
||||||
|
|
||||||
|
credits = Mock(spec=CommunicationCredits)
|
||||||
|
credits.balance_cents = 5000
|
||||||
|
credits.total_spent_cents = 0
|
||||||
|
credits.save = Mock()
|
||||||
|
credits._check_thresholds = Mock()
|
||||||
|
|
||||||
|
with patch('smoothschedule.communication.credits.models.CreditTransaction') as mock_tx:
|
||||||
|
mock_tx.objects.create.return_value = Mock(id=1)
|
||||||
|
|
||||||
|
CommunicationCredits.deduct(
|
||||||
|
credits,
|
||||||
|
amount_cents=1000,
|
||||||
|
description="Test"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify empty strings used for None values
|
||||||
|
call_kwargs = mock_tx.objects.create.call_args[1]
|
||||||
|
assert call_kwargs['reference_type'] == ''
|
||||||
|
assert call_kwargs['reference_id'] == ''
|
||||||
|
|
||||||
|
def test_add_credits_updates_balance_and_total(self):
|
||||||
|
"""Test add_credits updates balance and total loaded."""
|
||||||
|
from smoothschedule.communication.credits.models import CommunicationCredits
|
||||||
|
|
||||||
|
credits = Mock(spec=CommunicationCredits)
|
||||||
|
credits.balance_cents = 1000
|
||||||
|
credits.total_loaded_cents = 5000
|
||||||
|
credits.low_balance_warning_sent = True
|
||||||
|
credits.save = Mock()
|
||||||
|
|
||||||
|
with patch('smoothschedule.communication.credits.models.CreditTransaction') as mock_tx:
|
||||||
|
CommunicationCredits.add_credits(
|
||||||
|
credits,
|
||||||
|
amount_cents=2500,
|
||||||
|
transaction_type='manual',
|
||||||
|
stripe_charge_id='ch_123',
|
||||||
|
description="Top-up"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify balance and totals updated
|
||||||
|
assert credits.balance_cents == 3500
|
||||||
|
assert credits.total_loaded_cents == 7500
|
||||||
|
assert credits.low_balance_warning_sent is False
|
||||||
|
|
||||||
|
# Verify save called
|
||||||
|
credits.save.assert_called_once()
|
||||||
|
|
||||||
|
# Verify transaction created
|
||||||
|
mock_tx.objects.create.assert_called_once_with(
|
||||||
|
credits=credits,
|
||||||
|
amount_cents=2500,
|
||||||
|
balance_after_cents=3500,
|
||||||
|
transaction_type='manual',
|
||||||
|
description="Top-up",
|
||||||
|
stripe_charge_id='ch_123'
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_add_credits_uses_default_description(self):
|
||||||
|
"""Test add_credits uses default description when not provided."""
|
||||||
|
from smoothschedule.communication.credits.models import CommunicationCredits
|
||||||
|
|
||||||
|
credits = Mock(spec=CommunicationCredits)
|
||||||
|
credits.balance_cents = 1000
|
||||||
|
credits.total_loaded_cents = 0
|
||||||
|
credits.low_balance_warning_sent = False
|
||||||
|
credits.save = Mock()
|
||||||
|
|
||||||
|
with patch('smoothschedule.communication.credits.models.CreditTransaction') as mock_tx:
|
||||||
|
CommunicationCredits.add_credits(
|
||||||
|
credits,
|
||||||
|
amount_cents=2500,
|
||||||
|
transaction_type='auto_reload'
|
||||||
|
)
|
||||||
|
|
||||||
|
call_kwargs = mock_tx.objects.create.call_args[1]
|
||||||
|
assert call_kwargs['description'] == "Credits added (auto_reload)"
|
||||||
|
|
||||||
|
def test_check_thresholds_sends_warning_when_below_threshold(self):
|
||||||
|
"""Test _check_thresholds sends warning when below threshold."""
|
||||||
|
from smoothschedule.communication.credits.models import CommunicationCredits
|
||||||
|
|
||||||
|
credits = Mock(spec=CommunicationCredits)
|
||||||
|
credits.balance_cents = 400
|
||||||
|
credits.low_balance_warning_cents = 500
|
||||||
|
credits.low_balance_warning_sent = False
|
||||||
|
credits.auto_reload_enabled = False # Disable auto-reload for this test
|
||||||
|
credits._send_low_balance_warning = Mock()
|
||||||
|
credits._trigger_auto_reload = Mock()
|
||||||
|
|
||||||
|
CommunicationCredits._check_thresholds(credits)
|
||||||
|
|
||||||
|
credits._send_low_balance_warning.assert_called_once()
|
||||||
|
|
||||||
|
def test_check_thresholds_skips_warning_if_already_sent(self):
|
||||||
|
"""Test _check_thresholds skips warning if already sent."""
|
||||||
|
from smoothschedule.communication.credits.models import CommunicationCredits
|
||||||
|
|
||||||
|
credits = Mock(spec=CommunicationCredits)
|
||||||
|
credits.balance_cents = 400
|
||||||
|
credits.low_balance_warning_cents = 500
|
||||||
|
credits.low_balance_warning_sent = True
|
||||||
|
credits.auto_reload_enabled = False # Disable auto-reload for this test
|
||||||
|
credits._send_low_balance_warning = Mock()
|
||||||
|
|
||||||
|
CommunicationCredits._check_thresholds(credits)
|
||||||
|
|
||||||
|
credits._send_low_balance_warning.assert_not_called()
|
||||||
|
|
||||||
|
def test_check_thresholds_triggers_auto_reload_when_enabled(self):
|
||||||
|
"""Test _check_thresholds triggers auto reload when conditions met."""
|
||||||
|
from smoothschedule.communication.credits.models import CommunicationCredits
|
||||||
|
|
||||||
|
credits = Mock(spec=CommunicationCredits)
|
||||||
|
credits.balance_cents = 800
|
||||||
|
credits.low_balance_warning_cents = 100
|
||||||
|
credits.low_balance_warning_sent = False
|
||||||
|
credits.auto_reload_enabled = True
|
||||||
|
credits.auto_reload_threshold_cents = 1000
|
||||||
|
credits.stripe_payment_method_id = 'pm_123'
|
||||||
|
credits._send_low_balance_warning = Mock()
|
||||||
|
credits._trigger_auto_reload = Mock()
|
||||||
|
|
||||||
|
CommunicationCredits._check_thresholds(credits)
|
||||||
|
|
||||||
|
credits._trigger_auto_reload.assert_called_once()
|
||||||
|
|
||||||
|
def test_check_thresholds_skips_auto_reload_when_disabled(self):
|
||||||
|
"""Test _check_thresholds skips auto reload when disabled."""
|
||||||
|
from smoothschedule.communication.credits.models import CommunicationCredits
|
||||||
|
|
||||||
|
credits = Mock(spec=CommunicationCredits)
|
||||||
|
credits.balance_cents = 800
|
||||||
|
credits.low_balance_warning_cents = 100
|
||||||
|
credits.auto_reload_enabled = False
|
||||||
|
credits.auto_reload_threshold_cents = 1000
|
||||||
|
credits.stripe_payment_method_id = 'pm_123'
|
||||||
|
credits._trigger_auto_reload = Mock()
|
||||||
|
|
||||||
|
CommunicationCredits._check_thresholds(credits)
|
||||||
|
|
||||||
|
credits._trigger_auto_reload.assert_not_called()
|
||||||
|
|
||||||
|
def test_check_thresholds_skips_auto_reload_without_payment_method(self):
|
||||||
|
"""Test _check_thresholds skips auto reload without payment method."""
|
||||||
|
from smoothschedule.communication.credits.models import CommunicationCredits
|
||||||
|
|
||||||
|
credits = Mock(spec=CommunicationCredits)
|
||||||
|
credits.balance_cents = 800
|
||||||
|
credits.low_balance_warning_cents = 100
|
||||||
|
credits.auto_reload_enabled = True
|
||||||
|
credits.auto_reload_threshold_cents = 1000
|
||||||
|
credits.stripe_payment_method_id = ''
|
||||||
|
credits._trigger_auto_reload = Mock()
|
||||||
|
|
||||||
|
CommunicationCredits._check_thresholds(credits)
|
||||||
|
|
||||||
|
credits._trigger_auto_reload.assert_not_called()
|
||||||
|
|
||||||
|
def test_send_low_balance_warning_triggers_task(self):
|
||||||
|
"""Test _send_low_balance_warning triggers Celery task."""
|
||||||
|
from smoothschedule.communication.credits.models import CommunicationCredits
|
||||||
|
|
||||||
|
credits = Mock(spec=CommunicationCredits)
|
||||||
|
credits.id = 42
|
||||||
|
credits.save = Mock()
|
||||||
|
|
||||||
|
with patch('smoothschedule.communication.credits.tasks.send_low_balance_warning') as mock_task, \
|
||||||
|
patch('smoothschedule.communication.credits.models.timezone.now') as mock_now:
|
||||||
|
|
||||||
|
mock_now.return_value = datetime(2024, 1, 1, 12, 0, 0, tzinfo=dt_timezone.utc)
|
||||||
|
|
||||||
|
CommunicationCredits._send_low_balance_warning(credits)
|
||||||
|
|
||||||
|
# Verify task triggered
|
||||||
|
mock_task.delay.assert_called_once_with(42)
|
||||||
|
|
||||||
|
# Verify save called with correct update_fields
|
||||||
|
credits.save.assert_called_once_with(
|
||||||
|
update_fields=['low_balance_warning_sent', 'low_balance_warning_sent_at']
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_trigger_auto_reload_triggers_task(self):
|
||||||
|
"""Test _trigger_auto_reload triggers Celery task."""
|
||||||
|
from smoothschedule.communication.credits.models import CommunicationCredits
|
||||||
|
|
||||||
|
credits = Mock() # Don't use spec here as we only need the id
|
||||||
|
credits.id = 42
|
||||||
|
|
||||||
|
with patch('smoothschedule.communication.credits.tasks.process_auto_reload') as mock_task:
|
||||||
|
CommunicationCredits._trigger_auto_reload(credits)
|
||||||
|
mock_task.delay.assert_called_once_with(42)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreditTransactionModel:
|
||||||
|
"""Tests for CreditTransaction model."""
|
||||||
|
|
||||||
|
def test_str_representation_positive_amount(self):
|
||||||
|
"""Test string representation for credit (positive amount)."""
|
||||||
|
from smoothschedule.communication.credits.models import CreditTransaction
|
||||||
|
|
||||||
|
transaction = Mock(spec=CreditTransaction)
|
||||||
|
transaction.amount_cents = 2500
|
||||||
|
transaction.description = "Manual top-up"
|
||||||
|
|
||||||
|
result = CreditTransaction.__str__(transaction)
|
||||||
|
|
||||||
|
assert result == "+$25.00 - Manual top-up"
|
||||||
|
|
||||||
|
def test_str_representation_negative_amount(self):
|
||||||
|
"""Test string representation for debit (negative amount)."""
|
||||||
|
from smoothschedule.communication.credits.models import CreditTransaction
|
||||||
|
|
||||||
|
transaction = Mock(spec=CreditTransaction)
|
||||||
|
transaction.amount_cents = -1500
|
||||||
|
transaction.description = "SMS charge"
|
||||||
|
|
||||||
|
result = CreditTransaction.__str__(transaction)
|
||||||
|
|
||||||
|
# Negative amounts display as "$-15.00" because the sign is part of the number
|
||||||
|
assert result == "$-15.00 - SMS charge"
|
||||||
|
|
||||||
|
def test_str_representation_zero_amount(self):
|
||||||
|
"""Test string representation for zero amount."""
|
||||||
|
from smoothschedule.communication.credits.models import CreditTransaction
|
||||||
|
|
||||||
|
transaction = Mock(spec=CreditTransaction)
|
||||||
|
transaction.amount_cents = 0
|
||||||
|
transaction.description = "Test"
|
||||||
|
|
||||||
|
result = CreditTransaction.__str__(transaction)
|
||||||
|
|
||||||
|
# Zero is neither positive nor negative, so no sign
|
||||||
|
assert result == "$0.00 - Test"
|
||||||
|
|
||||||
|
def test_amount_property_converts_cents_to_dollars(self):
|
||||||
|
"""Test amount property returns dollars."""
|
||||||
|
from smoothschedule.communication.credits.models import CreditTransaction
|
||||||
|
|
||||||
|
transaction = Mock(spec=CreditTransaction)
|
||||||
|
transaction.amount_cents = 3500
|
||||||
|
|
||||||
|
result = CreditTransaction.amount.fget(transaction)
|
||||||
|
|
||||||
|
assert result == 35.0
|
||||||
|
|
||||||
|
def test_amount_property_handles_negative(self):
|
||||||
|
"""Test amount property handles negative amounts."""
|
||||||
|
from smoothschedule.communication.credits.models import CreditTransaction
|
||||||
|
|
||||||
|
transaction = Mock(spec=CreditTransaction)
|
||||||
|
transaction.amount_cents = -1250
|
||||||
|
|
||||||
|
result = CreditTransaction.amount.fget(transaction)
|
||||||
|
|
||||||
|
assert result == -12.5
|
||||||
|
|
||||||
|
def test_transaction_type_choices(self):
|
||||||
|
"""Test TransactionType choices are defined correctly."""
|
||||||
|
from smoothschedule.communication.credits.models import CreditTransaction
|
||||||
|
|
||||||
|
assert CreditTransaction.TransactionType.MANUAL == 'manual'
|
||||||
|
assert CreditTransaction.TransactionType.AUTO_RELOAD == 'auto_reload'
|
||||||
|
assert CreditTransaction.TransactionType.USAGE == 'usage'
|
||||||
|
assert CreditTransaction.TransactionType.REFUND == 'refund'
|
||||||
|
assert CreditTransaction.TransactionType.ADJUSTMENT == 'adjustment'
|
||||||
|
assert CreditTransaction.TransactionType.PROMO == 'promo'
|
||||||
|
|
||||||
|
def test_transaction_type_labels(self):
|
||||||
|
"""Test TransactionType labels are human-readable."""
|
||||||
|
from smoothschedule.communication.credits.models import CreditTransaction
|
||||||
|
|
||||||
|
choices_dict = dict(CreditTransaction.TransactionType.choices)
|
||||||
|
assert choices_dict['manual'] == 'Manual Top-up'
|
||||||
|
assert choices_dict['auto_reload'] == 'Auto Reload'
|
||||||
|
assert choices_dict['usage'] == 'Usage'
|
||||||
|
assert choices_dict['refund'] == 'Refund'
|
||||||
|
assert choices_dict['adjustment'] == 'Adjustment'
|
||||||
|
assert choices_dict['promo'] == 'Promotional Credit'
|
||||||
|
|
||||||
|
|
||||||
|
class TestProxyPhoneNumberModel:
|
||||||
|
"""Tests for ProxyPhoneNumber model."""
|
||||||
|
|
||||||
|
def test_str_representation_without_tenant(self):
|
||||||
|
"""Test string representation for unassigned number."""
|
||||||
|
from smoothschedule.communication.credits.models import ProxyPhoneNumber
|
||||||
|
|
||||||
|
number = Mock(spec=ProxyPhoneNumber)
|
||||||
|
number.phone_number = '+15551234567'
|
||||||
|
number.assigned_tenant = None
|
||||||
|
|
||||||
|
result = ProxyPhoneNumber.__str__(number)
|
||||||
|
|
||||||
|
assert result == "+15551234567"
|
||||||
|
|
||||||
|
def test_str_representation_with_tenant(self):
|
||||||
|
"""Test string representation for assigned number."""
|
||||||
|
from smoothschedule.communication.credits.models import ProxyPhoneNumber
|
||||||
|
|
||||||
|
mock_tenant = Mock()
|
||||||
|
mock_tenant.name = "Test Business"
|
||||||
|
|
||||||
|
number = Mock(spec=ProxyPhoneNumber)
|
||||||
|
number.phone_number = '+15551234567'
|
||||||
|
number.assigned_tenant = mock_tenant
|
||||||
|
|
||||||
|
result = ProxyPhoneNumber.__str__(number)
|
||||||
|
|
||||||
|
assert result == "+15551234567 (Test Business)"
|
||||||
|
|
||||||
|
def test_assign_to_tenant_success(self):
|
||||||
|
"""Test assign_to_tenant with valid permissions."""
|
||||||
|
from smoothschedule.communication.credits.models import ProxyPhoneNumber
|
||||||
|
|
||||||
|
mock_tenant = Mock()
|
||||||
|
mock_tenant.has_feature.return_value = True
|
||||||
|
|
||||||
|
number = Mock() # Don't use spec to allow attribute assignment
|
||||||
|
number.save = Mock()
|
||||||
|
|
||||||
|
with patch('django.utils.timezone.now') as mock_now:
|
||||||
|
mock_now.return_value = datetime(2024, 1, 1, 12, 0, 0, tzinfo=dt_timezone.utc)
|
||||||
|
|
||||||
|
ProxyPhoneNumber.assign_to_tenant(number, mock_tenant)
|
||||||
|
|
||||||
|
# Verify save called with correct update_fields
|
||||||
|
number.save.assert_called_once_with(
|
||||||
|
update_fields=['assigned_tenant', 'assigned_at', 'status', 'updated_at']
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_assign_to_tenant_without_permission(self):
|
||||||
|
"""Test assign_to_tenant raises PermissionDenied without feature."""
|
||||||
|
from rest_framework.exceptions import PermissionDenied
|
||||||
|
from smoothschedule.communication.credits.models import ProxyPhoneNumber
|
||||||
|
|
||||||
|
mock_tenant = Mock()
|
||||||
|
mock_tenant.has_feature.return_value = False
|
||||||
|
|
||||||
|
number = Mock(spec=ProxyPhoneNumber)
|
||||||
|
|
||||||
|
with pytest.raises(PermissionDenied) as exc_info:
|
||||||
|
ProxyPhoneNumber.assign_to_tenant(number, mock_tenant)
|
||||||
|
|
||||||
|
assert "Masked Calling" in str(exc_info.value)
|
||||||
|
assert "upgrade" in str(exc_info.value).lower()
|
||||||
|
|
||||||
|
def test_release_clears_assignment(self):
|
||||||
|
"""Test release returns number to pool."""
|
||||||
|
from smoothschedule.communication.credits.models import ProxyPhoneNumber
|
||||||
|
|
||||||
|
number = Mock() # Don't use spec to allow attribute assignment
|
||||||
|
number.save = Mock()
|
||||||
|
|
||||||
|
ProxyPhoneNumber.release(number)
|
||||||
|
|
||||||
|
# Verify save called with correct update_fields
|
||||||
|
number.save.assert_called_once_with(
|
||||||
|
update_fields=['assigned_tenant', 'assigned_at', 'status', 'updated_at']
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_status_choices(self):
|
||||||
|
"""Test Status choices are defined correctly."""
|
||||||
|
from smoothschedule.communication.credits.models import ProxyPhoneNumber
|
||||||
|
|
||||||
|
assert ProxyPhoneNumber.Status.AVAILABLE == 'available'
|
||||||
|
assert ProxyPhoneNumber.Status.ASSIGNED == 'assigned'
|
||||||
|
assert ProxyPhoneNumber.Status.RESERVED == 'reserved'
|
||||||
|
assert ProxyPhoneNumber.Status.INACTIVE == 'inactive'
|
||||||
|
|
||||||
|
|
||||||
|
class TestMaskedSessionModel:
|
||||||
|
"""Tests for MaskedSession model."""
|
||||||
|
|
||||||
|
def test_str_representation(self):
|
||||||
|
"""Test string representation shows session info."""
|
||||||
|
from smoothschedule.communication.credits.models import MaskedSession
|
||||||
|
|
||||||
|
session = Mock(spec=MaskedSession)
|
||||||
|
session.id = 42
|
||||||
|
session.customer_phone = '+15551111111'
|
||||||
|
session.staff_phone = '+15552222222'
|
||||||
|
|
||||||
|
result = MaskedSession.__str__(session)
|
||||||
|
|
||||||
|
assert result == "Session 42: +15551111111 <-> +15552222222"
|
||||||
|
|
||||||
|
def test_is_active_returns_true_for_active_unexpired_session(self):
|
||||||
|
"""Test is_active returns True when status active and not expired."""
|
||||||
|
from smoothschedule.communication.credits.models import MaskedSession
|
||||||
|
|
||||||
|
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=dt_timezone.utc)
|
||||||
|
future_time = now + timedelta(hours=1)
|
||||||
|
|
||||||
|
session = Mock() # Don't use spec to allow simpler attribute access
|
||||||
|
session.status = MaskedSession.Status.ACTIVE # Use the actual enum
|
||||||
|
session.Status = MaskedSession.Status # Make Status available on the instance
|
||||||
|
session.expires_at = future_time
|
||||||
|
|
||||||
|
with patch('smoothschedule.communication.credits.models.timezone.now') as mock_now:
|
||||||
|
mock_now.return_value = now
|
||||||
|
result = MaskedSession.is_active(session)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
def test_is_active_returns_false_for_closed_session(self):
|
||||||
|
"""Test is_active returns False when status is closed."""
|
||||||
|
from smoothschedule.communication.credits.models import MaskedSession
|
||||||
|
|
||||||
|
future_time = timezone.now() + timedelta(hours=1)
|
||||||
|
session = Mock(spec=MaskedSession)
|
||||||
|
session.status = MaskedSession.Status.CLOSED
|
||||||
|
session.expires_at = future_time
|
||||||
|
|
||||||
|
result = MaskedSession.is_active(session)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_is_active_returns_false_for_expired_session(self):
|
||||||
|
"""Test is_active returns False when session is expired."""
|
||||||
|
from smoothschedule.communication.credits.models import MaskedSession
|
||||||
|
|
||||||
|
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=dt_timezone.utc)
|
||||||
|
past_time = now - timedelta(hours=1)
|
||||||
|
|
||||||
|
session = Mock(spec=MaskedSession)
|
||||||
|
session.status = MaskedSession.Status.ACTIVE
|
||||||
|
session.expires_at = past_time
|
||||||
|
|
||||||
|
with patch('django.utils.timezone.now') as mock_now:
|
||||||
|
mock_now.return_value = now
|
||||||
|
result = MaskedSession.is_active(session)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_close_updates_status_and_timestamp(self):
|
||||||
|
"""Test close sets status and closed_at."""
|
||||||
|
from smoothschedule.communication.credits.models import MaskedSession
|
||||||
|
|
||||||
|
mock_proxy_number = Mock()
|
||||||
|
mock_proxy_number.status = 'available'
|
||||||
|
|
||||||
|
session = Mock() # Don't use spec to allow attribute assignment
|
||||||
|
session.proxy_number = mock_proxy_number
|
||||||
|
session.save = Mock()
|
||||||
|
|
||||||
|
with patch('smoothschedule.communication.credits.models.timezone.now') as mock_now:
|
||||||
|
mock_now.return_value = datetime(2024, 1, 1, 12, 0, 0, tzinfo=dt_timezone.utc)
|
||||||
|
|
||||||
|
MaskedSession.close(session)
|
||||||
|
|
||||||
|
# Verify save called with correct update_fields
|
||||||
|
session.save.assert_called_once_with(
|
||||||
|
update_fields=['status', 'closed_at', 'updated_at']
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_close_releases_reserved_proxy_number(self):
|
||||||
|
"""Test close releases proxy number if it was reserved."""
|
||||||
|
from smoothschedule.communication.credits.models import MaskedSession, ProxyPhoneNumber
|
||||||
|
|
||||||
|
mock_proxy_number = Mock()
|
||||||
|
mock_proxy_number.status = ProxyPhoneNumber.Status.RESERVED
|
||||||
|
mock_proxy_number.save = Mock()
|
||||||
|
|
||||||
|
session = Mock(spec=MaskedSession)
|
||||||
|
session.proxy_number = mock_proxy_number
|
||||||
|
session.save = Mock()
|
||||||
|
|
||||||
|
with patch('django.utils.timezone.now'):
|
||||||
|
MaskedSession.close(session)
|
||||||
|
|
||||||
|
# Verify proxy number released
|
||||||
|
assert mock_proxy_number.status == ProxyPhoneNumber.Status.AVAILABLE
|
||||||
|
mock_proxy_number.save.assert_called_once()
|
||||||
|
|
||||||
|
def test_close_does_not_release_assigned_proxy_number(self):
|
||||||
|
"""Test close does not release assigned proxy number."""
|
||||||
|
from smoothschedule.communication.credits.models import MaskedSession, ProxyPhoneNumber
|
||||||
|
|
||||||
|
mock_proxy_number = Mock()
|
||||||
|
mock_proxy_number.status = ProxyPhoneNumber.Status.ASSIGNED
|
||||||
|
mock_proxy_number.save = Mock()
|
||||||
|
|
||||||
|
session = Mock(spec=MaskedSession)
|
||||||
|
session.proxy_number = mock_proxy_number
|
||||||
|
session.save = Mock()
|
||||||
|
|
||||||
|
with patch('django.utils.timezone.now'):
|
||||||
|
MaskedSession.close(session)
|
||||||
|
|
||||||
|
# Verify proxy number NOT changed
|
||||||
|
assert mock_proxy_number.status == ProxyPhoneNumber.Status.ASSIGNED
|
||||||
|
mock_proxy_number.save.assert_not_called()
|
||||||
|
|
||||||
|
def test_get_destination_for_caller_customer_to_staff(self):
|
||||||
|
"""Test get_destination_for_caller routes customer to staff."""
|
||||||
|
from smoothschedule.communication.credits.models import MaskedSession
|
||||||
|
|
||||||
|
session = Mock(spec=MaskedSession)
|
||||||
|
session.customer_phone = '+15551111111'
|
||||||
|
session.staff_phone = '+15552222222'
|
||||||
|
|
||||||
|
result = MaskedSession.get_destination_for_caller(session, '+15551111111')
|
||||||
|
|
||||||
|
assert result == '+15552222222'
|
||||||
|
|
||||||
|
def test_get_destination_for_caller_staff_to_customer(self):
|
||||||
|
"""Test get_destination_for_caller routes staff to customer."""
|
||||||
|
from smoothschedule.communication.credits.models import MaskedSession
|
||||||
|
|
||||||
|
session = Mock(spec=MaskedSession)
|
||||||
|
session.customer_phone = '+15551111111'
|
||||||
|
session.staff_phone = '+15552222222'
|
||||||
|
|
||||||
|
result = MaskedSession.get_destination_for_caller(session, '+15552222222')
|
||||||
|
|
||||||
|
assert result == '+15551111111'
|
||||||
|
|
||||||
|
def test_get_destination_for_caller_unknown_caller(self):
|
||||||
|
"""Test get_destination_for_caller returns None for unknown caller."""
|
||||||
|
from smoothschedule.communication.credits.models import MaskedSession
|
||||||
|
|
||||||
|
session = Mock(spec=MaskedSession)
|
||||||
|
session.customer_phone = '+15551111111'
|
||||||
|
session.staff_phone = '+15552222222'
|
||||||
|
|
||||||
|
result = MaskedSession.get_destination_for_caller(session, '+15559999999')
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_get_destination_for_caller_normalizes_phone_numbers(self):
|
||||||
|
"""Test get_destination_for_caller normalizes phone formats."""
|
||||||
|
from smoothschedule.communication.credits.models import MaskedSession
|
||||||
|
|
||||||
|
session = Mock(spec=MaskedSession)
|
||||||
|
session.customer_phone = '+15551111111'
|
||||||
|
session.staff_phone = '+15552222222'
|
||||||
|
|
||||||
|
# Test with spaces
|
||||||
|
result = MaskedSession.get_destination_for_caller(session, '+1 555 111 1111')
|
||||||
|
assert result == '+15552222222'
|
||||||
|
|
||||||
|
# Test with dashes
|
||||||
|
result = MaskedSession.get_destination_for_caller(session, '+1-555-111-1111')
|
||||||
|
assert result == '+15552222222'
|
||||||
|
|
||||||
|
# Test without country code
|
||||||
|
result = MaskedSession.get_destination_for_caller(session, '5551111111')
|
||||||
|
assert result == '+15552222222'
|
||||||
|
|
||||||
|
def test_get_destination_for_caller_handles_partial_matches(self):
|
||||||
|
"""Test get_destination_for_caller handles partial number matches."""
|
||||||
|
from smoothschedule.communication.credits.models import MaskedSession
|
||||||
|
|
||||||
|
session = Mock(spec=MaskedSession)
|
||||||
|
session.customer_phone = '5551111111'
|
||||||
|
session.staff_phone = '5552222222'
|
||||||
|
|
||||||
|
# Full number with country code should still match
|
||||||
|
result = MaskedSession.get_destination_for_caller(session, '+15551111111')
|
||||||
|
assert result == '5552222222'
|
||||||
|
|
||||||
|
def test_status_choices(self):
|
||||||
|
"""Test Status choices are defined correctly."""
|
||||||
|
from smoothschedule.communication.credits.models import MaskedSession
|
||||||
|
|
||||||
|
assert MaskedSession.Status.ACTIVE == 'active'
|
||||||
|
assert MaskedSession.Status.CLOSED == 'closed'
|
||||||
|
assert MaskedSession.Status.EXPIRED == 'expired'
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -3,4 +3,5 @@ from django.apps import AppConfig
|
|||||||
|
|
||||||
class CommunicationConfig(AppConfig):
|
class CommunicationConfig(AppConfig):
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
name = 'communication'
|
name = 'smoothschedule.communication.messaging'
|
||||||
|
label = 'communication'
|
||||||
@@ -83,6 +83,7 @@ class CommunicationSession(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
app_label = 'communication'
|
||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['is_active', 'created_at']),
|
models.Index(fields=['is_active', 'created_at']),
|
||||||
@@ -57,7 +57,7 @@ class TwilioService:
|
|||||||
PermissionError: If tenant doesn't have masked calling feature
|
PermissionError: If tenant doesn't have masked calling feature
|
||||||
"""
|
"""
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
from core.models import Tenant
|
from smoothschedule.identity.core.models import Tenant
|
||||||
from rest_framework.exceptions import PermissionDenied
|
from rest_framework.exceptions import PermissionDenied
|
||||||
|
|
||||||
# Check feature permission
|
# Check feature permission
|
||||||
@@ -3,7 +3,7 @@ from django.apps import AppConfig
|
|||||||
|
|
||||||
class FieldMobileConfig(AppConfig):
|
class FieldMobileConfig(AppConfig):
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
name = 'smoothschedule.field_mobile'
|
name = 'smoothschedule.communication.mobile'
|
||||||
label = 'field_mobile'
|
label = 'field_mobile'
|
||||||
verbose_name = 'Field Mobile App'
|
verbose_name = 'Field Mobile App'
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user