project initialization
Some checks failed
System Monitoring / Health Checks (push) Has been cancelled
System Monitoring / Performance Monitoring (push) Has been cancelled
System Monitoring / Database Monitoring (push) Has been cancelled
System Monitoring / Cache Monitoring (push) Has been cancelled
System Monitoring / Log Monitoring (push) Has been cancelled
System Monitoring / Resource Monitoring (push) Has been cancelled
System Monitoring / Uptime Monitoring (push) Has been cancelled
System Monitoring / Backup Monitoring (push) Has been cancelled
System Monitoring / Security Monitoring (push) Has been cancelled
System Monitoring / Monitoring Dashboard (push) Has been cancelled
System Monitoring / Alerting (push) Has been cancelled
Security Scanning / Dependency Scanning (push) Has been cancelled
Security Scanning / Code Security Scanning (push) Has been cancelled
Security Scanning / Secrets Scanning (push) Has been cancelled
Security Scanning / Container Security Scanning (push) Has been cancelled
Security Scanning / Compliance Checking (push) Has been cancelled
Security Scanning / Security Dashboard (push) Has been cancelled
Security Scanning / Security Remediation (push) Has been cancelled
Some checks failed
System Monitoring / Health Checks (push) Has been cancelled
System Monitoring / Performance Monitoring (push) Has been cancelled
System Monitoring / Database Monitoring (push) Has been cancelled
System Monitoring / Cache Monitoring (push) Has been cancelled
System Monitoring / Log Monitoring (push) Has been cancelled
System Monitoring / Resource Monitoring (push) Has been cancelled
System Monitoring / Uptime Monitoring (push) Has been cancelled
System Monitoring / Backup Monitoring (push) Has been cancelled
System Monitoring / Security Monitoring (push) Has been cancelled
System Monitoring / Monitoring Dashboard (push) Has been cancelled
System Monitoring / Alerting (push) Has been cancelled
Security Scanning / Dependency Scanning (push) Has been cancelled
Security Scanning / Code Security Scanning (push) Has been cancelled
Security Scanning / Secrets Scanning (push) Has been cancelled
Security Scanning / Container Security Scanning (push) Has been cancelled
Security Scanning / Compliance Checking (push) Has been cancelled
Security Scanning / Security Dashboard (push) Has been cancelled
Security Scanning / Security Remediation (push) Has been cancelled
This commit is contained in:
115
backend/tests/contract/test_auth_login.py
Normal file
115
backend/tests/contract/test_auth_login.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
Contract test for POST /auth/login endpoint.
|
||||
This test MUST fail before implementation.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
import json
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class AuthLoginContractTest(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.login_url = '/api/v1/auth/login/'
|
||||
|
||||
# Create test user
|
||||
self.user_data = {
|
||||
'email': 'test@example.com',
|
||||
'password': 'testpass123',
|
||||
'first_name': 'Test',
|
||||
'last_name': 'User'
|
||||
}
|
||||
|
||||
def test_login_success(self):
|
||||
"""Test successful login with valid credentials."""
|
||||
response = self.client.post(
|
||||
self.login_url,
|
||||
data=json.dumps(self.user_data),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
# This should fail before implementation
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
assert 'access_token' in data
|
||||
assert 'refresh_token' in data
|
||||
assert 'user' in data
|
||||
|
||||
user_data = data['user']
|
||||
assert user_data['email'] == self.user_data['email']
|
||||
assert user_data['first_name'] == self.user_data['first_name']
|
||||
assert user_data['last_name'] == self.user_data['last_name']
|
||||
|
||||
def test_login_invalid_credentials(self):
|
||||
"""Test login failure with invalid credentials."""
|
||||
invalid_data = self.user_data.copy()
|
||||
invalid_data['password'] = 'wrongpassword'
|
||||
|
||||
response = self.client.post(
|
||||
self.login_url,
|
||||
data=json.dumps(invalid_data),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
def test_login_missing_email(self):
|
||||
"""Test login failure with missing email."""
|
||||
incomplete_data = {
|
||||
'password': self.user_data['password']
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
self.login_url,
|
||||
data=json.dumps(incomplete_data),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_login_missing_password(self):
|
||||
"""Test login failure with missing password."""
|
||||
incomplete_data = {
|
||||
'email': self.user_data['email']
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
self.login_url,
|
||||
data=json.dumps(incomplete_data),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_login_invalid_content_type(self):
|
||||
"""Test login failure with invalid content type."""
|
||||
response = self.client.post(
|
||||
self.login_url,
|
||||
data=json.dumps(self.user_data)
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_415_UNSUPPORTED_MEDIA_TYPE
|
||||
|
||||
def test_login_tenant_specific(self):
|
||||
"""Test login with tenant-specific URL."""
|
||||
# This test will check multi-tenant authentication
|
||||
tenant_login_url = '/api/v1/auth/login/'
|
||||
|
||||
response = self.client.post(
|
||||
tenant_login_url,
|
||||
data=json.dumps(self.user_data),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
# Should return tenant-specific information
|
||||
if response.status_code == status.HTTP_200_OK:
|
||||
data = response.json()
|
||||
assert 'tenant' in data
|
||||
78
backend/tests/contract/test_auth_logout.py
Normal file
78
backend/tests/contract/test_auth_logout.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
Contract test for POST /auth/logout endpoint.
|
||||
This test MUST fail before implementation.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
import json
|
||||
|
||||
|
||||
class AuthLogoutContractTest(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.logout_url = '/api/v1/auth/logout/'
|
||||
|
||||
# Mock authentication token
|
||||
self.auth_header = {'HTTP_AUTHORIZATION': 'Bearer mock_token'}
|
||||
|
||||
def test_logout_success(self):
|
||||
"""Test successful logout with valid token."""
|
||||
response = self.client.post(
|
||||
self.logout_url,
|
||||
**self.auth_header
|
||||
)
|
||||
|
||||
# This should fail before implementation
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
assert 'message' in data
|
||||
assert data['message'] == 'Successfully logged out'
|
||||
|
||||
def test_logout_no_token(self):
|
||||
"""Test logout failure without authentication token."""
|
||||
response = self.client.post(self.logout_url)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
def test_logout_invalid_token(self):
|
||||
"""Test logout failure with invalid token."""
|
||||
invalid_auth_header = {'HTTP_AUTHORIZATION': 'Bearer invalid_token'}
|
||||
|
||||
response = self.client.post(
|
||||
self.logout_url,
|
||||
**invalid_auth_header
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
def test_logout_expired_token(self):
|
||||
"""Test logout failure with expired token."""
|
||||
expired_auth_header = {'HTTP_AUTHORIZATION': 'Bearer expired_token'}
|
||||
|
||||
response = self.client.post(
|
||||
self.logout_url,
|
||||
**expired_auth_header
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
def test_logout_token_blacklisting(self):
|
||||
"""Test that logout token is blacklisted."""
|
||||
# This test verifies that the token is added to blacklist
|
||||
response = self.client.post(
|
||||
self.logout_url,
|
||||
**self.auth_header
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_200_OK:
|
||||
# Token should be blacklisted and cannot be used again
|
||||
second_response = self.client.post(
|
||||
self.logout_url,
|
||||
**self.auth_header
|
||||
)
|
||||
assert second_response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
108
backend/tests/contract/test_auth_refresh.py
Normal file
108
backend/tests/contract/test_auth_refresh.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
Contract test for POST /auth/refresh endpoint.
|
||||
This test MUST fail before implementation.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
import json
|
||||
|
||||
|
||||
class AuthRefreshContractTest(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.refresh_url = '/api/v1/auth/refresh/'
|
||||
|
||||
# Mock refresh token
|
||||
self.refresh_data = {
|
||||
'refresh_token': 'mock_refresh_token'
|
||||
}
|
||||
|
||||
def test_refresh_success(self):
|
||||
"""Test successful token refresh with valid refresh token."""
|
||||
response = self.client.post(
|
||||
self.refresh_url,
|
||||
data=json.dumps(self.refresh_data),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
# This should fail before implementation
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
assert 'access_token' in data
|
||||
assert 'refresh_token' in data
|
||||
|
||||
# New refresh token should be different (rotation enabled)
|
||||
assert data['refresh_token'] != self.refresh_data['refresh_token']
|
||||
|
||||
def test_refresh_invalid_token(self):
|
||||
"""Test refresh failure with invalid refresh token."""
|
||||
invalid_data = {
|
||||
'refresh_token': 'invalid_refresh_token'
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
self.refresh_url,
|
||||
data=json.dumps(invalid_data),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
def test_refresh_missing_token(self):
|
||||
"""Test refresh failure with missing refresh token."""
|
||||
incomplete_data = {}
|
||||
|
||||
response = self.client.post(
|
||||
self.refresh_url,
|
||||
data=json.dumps(incomplete_data),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_refresh_blacklisted_token(self):
|
||||
"""Test refresh failure with blacklisted token."""
|
||||
blacklisted_data = {
|
||||
'refresh_token': 'blacklisted_refresh_token'
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
self.refresh_url,
|
||||
data=json.dumps(blacklisted_data),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
def test_refresh_expired_token(self):
|
||||
"""Test refresh failure with expired refresh token."""
|
||||
expired_data = {
|
||||
'refresh_token': 'expired_refresh_token'
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
self.refresh_url,
|
||||
data=json.dumps(expired_data),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
def test_refresh_tenant_isolation(self):
|
||||
"""Test that refresh token respects tenant isolation."""
|
||||
# This test ensures refresh tokens are tenant-specific
|
||||
response = self.client.post(
|
||||
self.refresh_url,
|
||||
data=json.dumps(self.refresh_data),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_200_OK:
|
||||
data = response.json()
|
||||
# Tenant information should be included in token
|
||||
assert 'tenant_id' in data or 'tenant_slug' in data
|
||||
336
backend/tests/contract/test_healthcare_appointments_get.py
Normal file
336
backend/tests/contract/test_healthcare_appointments_get.py
Normal file
@@ -0,0 +1,336 @@
|
||||
"""
|
||||
Contract test for GET /healthcare/appointments endpoint.
|
||||
This test MUST fail before implementation.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
import json
|
||||
|
||||
|
||||
class HealthcareAppointmentsGetContractTest(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.appointments_url = '/api/v1/healthcare/appointments/'
|
||||
|
||||
# Tenant authentication header
|
||||
self.tenant_auth = {'HTTP_AUTHORIZATION': 'Bearer tenant_token'}
|
||||
|
||||
def test_get_appointments_success(self):
|
||||
"""Test successful retrieval of appointments list."""
|
||||
response = self.client.get(
|
||||
self.appointments_url,
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
# This should fail before implementation
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
assert 'appointments' in data
|
||||
assert isinstance(data['appointments'], list)
|
||||
|
||||
# Check pagination structure
|
||||
assert 'pagination' in data
|
||||
pagination = data['pagination']
|
||||
assert 'page' in pagination
|
||||
assert 'limit' in pagination
|
||||
assert 'total' in pagination
|
||||
assert 'pages' in pagination
|
||||
|
||||
def test_get_appointments_unauthorized(self):
|
||||
"""Test appointments list retrieval without authentication."""
|
||||
response = self.client.get(self.appointments_url)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
def test_get_appointments_with_pagination(self):
|
||||
"""Test appointments list retrieval with pagination parameters."""
|
||||
params = {
|
||||
'page': 2,
|
||||
'limit': 20
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.appointments_url,
|
||||
data=params,
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
assert data['pagination']['page'] == 2
|
||||
assert data['pagination']['limit'] == 20
|
||||
|
||||
def test_get_appointments_with_search(self):
|
||||
"""Test appointments list retrieval with search parameter."""
|
||||
params = {
|
||||
'search': 'ahmad'
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.appointments_url,
|
||||
data=params,
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
# All returned appointments should match search criteria
|
||||
for appointment in data['appointments']:
|
||||
search_match = (
|
||||
'ahmad' in appointment['patient_name'].lower() or
|
||||
'ahmad' in appointment['doctor_name'].lower() or
|
||||
'ahmad' in appointment['notes'].lower()
|
||||
)
|
||||
assert search_match
|
||||
|
||||
def test_get_appointments_filter_by_date_range(self):
|
||||
"""Test appointments list retrieval filtered by date range."""
|
||||
params = {
|
||||
'start_date': '2024-01-01',
|
||||
'end_date': '2024-01-31'
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.appointments_url,
|
||||
data=params,
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
# All returned appointments should be within the date range
|
||||
for appointment in data['appointments']:
|
||||
appointment_date = appointment['appointment_datetime'].split('T')[0]
|
||||
assert '2024-01-01' <= appointment_date <= '2024-01-31'
|
||||
|
||||
def test_get_appointments_filter_by_status(self):
|
||||
"""Test appointments list retrieval filtered by status."""
|
||||
params = {
|
||||
'status': 'CONFIRMED'
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.appointments_url,
|
||||
data=params,
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
# All returned appointments should have the specified status
|
||||
for appointment in data['appointments']:
|
||||
assert appointment['status'] == 'CONFIRMED'
|
||||
|
||||
def test_get_appointments_filter_by_doctor(self):
|
||||
"""Test appointments list retrieval filtered by doctor."""
|
||||
params = {
|
||||
'doctor_id': 'doctor-001'
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.appointments_url,
|
||||
data=params,
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
# All returned appointments should be with the specified doctor
|
||||
for appointment in data['appointments']:
|
||||
assert appointment['doctor_id'] == 'doctor-001'
|
||||
|
||||
def test_get_appointments_filter_by_patient(self):
|
||||
"""Test appointments list retrieval filtered by patient."""
|
||||
params = {
|
||||
'patient_id': 'patient-001'
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.appointments_url,
|
||||
data=params,
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
# All returned appointments should be for the specified patient
|
||||
for appointment in data['appointments']:
|
||||
assert appointment['patient_id'] == 'patient-001'
|
||||
|
||||
def test_get_appointments_data_structure(self):
|
||||
"""Test that appointment data structure matches the contract."""
|
||||
response = self.client.get(
|
||||
self.appointments_url,
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_200_OK and len(response.json()['appointments']) > 0:
|
||||
appointment = response.json()['appointments'][0]
|
||||
|
||||
# Required fields according to contract
|
||||
required_fields = [
|
||||
'id', 'patient_id', 'patient_name', 'doctor_id', 'doctor_name',
|
||||
'appointment_datetime', 'duration', 'status', 'type',
|
||||
'reason', 'notes', 'tenant_id', 'created_at', 'updated_at'
|
||||
]
|
||||
|
||||
for field in required_fields:
|
||||
assert field in appointment
|
||||
|
||||
# Field types and enums
|
||||
assert isinstance(appointment['id'], str)
|
||||
assert isinstance(appointment['patient_id'], str)
|
||||
assert isinstance(appointment['patient_name'], str)
|
||||
assert isinstance(appointment['doctor_id'], str)
|
||||
assert isinstance(appointment['doctor_name'], str)
|
||||
assert isinstance(appointment['appointment_datetime'], str)
|
||||
assert isinstance(appointment['duration'], int)
|
||||
assert appointment['status'] in ['SCHEDULED', 'CONFIRMED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED', 'NO_SHOW']
|
||||
assert appointment['type'] in ['CONSULTATION', 'FOLLOW_UP', 'PROCEDURE', 'EMERGENCY', 'CHECKUP']
|
||||
|
||||
def test_get_appointments_with_patient_details(self):
|
||||
"""Test that appointment data includes patient details."""
|
||||
response = self.client.get(
|
||||
self.appointments_url,
|
||||
data={'include_patient_details': 'true'},
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_200_OK and len(response.json()['appointments']) > 0:
|
||||
appointment = response.json()['appointments'][0]
|
||||
|
||||
# Should include patient details
|
||||
assert 'patient_details' in appointment
|
||||
patient_details = appointment['patient_details']
|
||||
|
||||
# Patient details should include relevant fields
|
||||
expected_patient_fields = ['ic_number', 'phone', 'email', 'age', 'gender']
|
||||
for field in expected_patient_fields:
|
||||
assert field in patient_details
|
||||
|
||||
def test_get_appointments_with_doctor_details(self):
|
||||
"""Test that appointment data includes doctor details."""
|
||||
response = self.client.get(
|
||||
self.appointments_url,
|
||||
data={'include_doctor_details': 'true'},
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_200_OK and len(response.json()['appointments']) > 0:
|
||||
appointment = response.json()['appointments'][0]
|
||||
|
||||
# Should include doctor details
|
||||
assert 'doctor_details' in appointment
|
||||
doctor_details = appointment['doctor_details']
|
||||
|
||||
# Doctor details should include relevant fields
|
||||
expected_doctor_fields = ['specialization', 'license_number', 'department']
|
||||
for field in expected_doctor_fields:
|
||||
assert field in doctor_details
|
||||
|
||||
def test_get_appointments_sorting(self):
|
||||
"""Test appointments list retrieval with sorting."""
|
||||
params = {
|
||||
'sort_by': 'appointment_datetime',
|
||||
'sort_order': 'asc'
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.appointments_url,
|
||||
data=params,
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
# Appointments should be sorted by datetime in ascending order
|
||||
appointment_datetimes = [appointment['appointment_datetime'] for appointment in data['appointments']]
|
||||
assert appointment_datetimes == sorted(appointment_datetimes)
|
||||
|
||||
def test_get_appointments_tenant_isolation(self):
|
||||
"""Test that appointments are isolated by tenant."""
|
||||
response = self.client.get(
|
||||
self.appointments_url,
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_200_OK:
|
||||
data = response.json()
|
||||
# All returned appointments should belong to the authenticated tenant
|
||||
for appointment in data['appointments']:
|
||||
assert 'tenant_id' in appointment
|
||||
# This will be validated once implementation exists
|
||||
|
||||
def test_get_appointments_upcoming_only(self):
|
||||
"""Test appointments list retrieval for upcoming appointments only."""
|
||||
params = {
|
||||
'upcoming_only': 'true'
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.appointments_url,
|
||||
data=params,
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
# All returned appointments should be in the future
|
||||
# This will be validated once implementation exists
|
||||
pass
|
||||
|
||||
def test_get_appointments_with_reminders(self):
|
||||
"""Test that appointment data includes reminder information."""
|
||||
response = self.client.get(
|
||||
self.appointments_url,
|
||||
data={'include_reminders': 'true'},
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_200_OK and len(response.json()['appointments']) > 0:
|
||||
appointment = response.json()['appointments'][0]
|
||||
|
||||
# Should include reminder information
|
||||
assert 'reminders' in appointment
|
||||
reminders = appointment['reminders']
|
||||
|
||||
# Should be a list
|
||||
assert isinstance(reminders, list)
|
||||
|
||||
if len(reminders) > 0:
|
||||
reminder = reminders[0]
|
||||
expected_reminder_fields = ['type', 'sent_at', 'status']
|
||||
for field in expected_reminder_fields:
|
||||
assert field in reminder
|
||||
|
||||
def test_get_appointments_with_virtual_info(self):
|
||||
"""Test that appointment data includes virtual consultation information."""
|
||||
response = self.client.get(
|
||||
self.appointments_url,
|
||||
data={'include_virtual_info': 'true'},
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_200_OK and len(response.json()['appointments']) > 0:
|
||||
appointment = response.json()['appointments'][0]
|
||||
|
||||
# Should include virtual consultation info if applicable
|
||||
if appointment.get('is_virtual', False):
|
||||
assert 'virtual_consultation' in appointment
|
||||
virtual_info = appointment['virtual_consultation']
|
||||
expected_virtual_fields = ['platform', 'link', 'instructions']
|
||||
for field in expected_virtual_fields:
|
||||
assert field in virtual_info
|
||||
392
backend/tests/contract/test_healthcare_appointments_post.py
Normal file
392
backend/tests/contract/test_healthcare_appointments_post.py
Normal file
@@ -0,0 +1,392 @@
|
||||
"""
|
||||
Contract test for POST /healthcare/appointments endpoint.
|
||||
This test MUST fail before implementation.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
import json
|
||||
|
||||
|
||||
class HealthcareAppointmentsPostContractTest(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.appointments_url = '/api/v1/healthcare/appointments/'
|
||||
|
||||
# Tenant authentication header
|
||||
self.tenant_auth = {'HTTP_AUTHORIZATION': 'Bearer tenant_token'}
|
||||
|
||||
# Valid appointment data
|
||||
self.appointment_data = {
|
||||
'patient_id': 'patient-001',
|
||||
'doctor_id': 'doctor-001',
|
||||
'appointment_datetime': '2024-02-15T14:30:00+08:00',
|
||||
'duration': 30,
|
||||
'type': 'CONSULTATION',
|
||||
'reason': 'Regular checkup for diabetes management',
|
||||
'notes': 'Patient reports occasional dizziness. Need to review medication dosage.',
|
||||
'priority': 'NORMAL',
|
||||
'is_virtual': False,
|
||||
'location': {
|
||||
'room': 'Consultation Room A',
|
||||
'floor': '2nd Floor',
|
||||
'building': 'Main Medical Center'
|
||||
},
|
||||
'reminders': [
|
||||
{
|
||||
'type': 'SMS',
|
||||
'time_before': 1440, # 24 hours
|
||||
'message': 'Reminder: Your appointment is tomorrow at 2:30 PM'
|
||||
},
|
||||
{
|
||||
'type': 'EMAIL',
|
||||
'time_before': 60, # 1 hour
|
||||
'message': 'Your appointment is in 1 hour'
|
||||
}
|
||||
],
|
||||
'follow_up': {
|
||||
'required': True,
|
||||
'interval_days': 30,
|
||||
'notes': 'Follow up to check medication effectiveness'
|
||||
}
|
||||
}
|
||||
|
||||
def test_create_appointment_success(self):
|
||||
"""Test successful appointment creation."""
|
||||
response = self.client.post(
|
||||
self.appointments_url,
|
||||
data=json.dumps(self.appointment_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
# This should fail before implementation
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
data = response.json()
|
||||
assert 'id' in data
|
||||
assert data['patient_id'] == self.appointment_data['patient_id']
|
||||
assert data['doctor_id'] == self.appointment_data['doctor_id']
|
||||
assert data['appointment_datetime'] == self.appointment_data['appointment_datetime']
|
||||
assert data['duration'] == self.appointment_data['duration']
|
||||
assert data['type'] == self.appointment_data['type']
|
||||
assert data['reason'] == self.appointment_data['reason']
|
||||
assert data['status'] == 'SCHEDULED' # Default status
|
||||
|
||||
# Should have timestamps
|
||||
assert 'created_at' in data
|
||||
assert 'updated_at' in data
|
||||
|
||||
# Should have tenant_id from context
|
||||
assert 'tenant_id' in data
|
||||
|
||||
# Should include location information
|
||||
assert 'location' in data
|
||||
assert data['location']['room'] == self.appointment_data['location']['room']
|
||||
|
||||
def test_create_appointment_unauthorized(self):
|
||||
"""Test appointment creation without authentication."""
|
||||
response = self.client.post(
|
||||
self.appointments_url,
|
||||
data=json.dumps(self.appointment_data),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
def test_create_appointment_missing_required_fields(self):
|
||||
"""Test appointment creation with missing required fields."""
|
||||
incomplete_data = self.appointment_data.copy()
|
||||
del incomplete_data['patient_id']
|
||||
|
||||
response = self.client.post(
|
||||
self.appointments_url,
|
||||
data=json.dumps(incomplete_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
data = response.json()
|
||||
assert 'patient_id' in data.get('errors', {})
|
||||
|
||||
def test_create_appointment_invalid_datetime(self):
|
||||
"""Test appointment creation with invalid datetime format."""
|
||||
invalid_data = self.appointment_data.copy()
|
||||
invalid_data['appointment_datetime'] = 'invalid-datetime-format'
|
||||
|
||||
response = self.client.post(
|
||||
self.appointments_url,
|
||||
data=json.dumps(invalid_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_appointment_past_datetime(self):
|
||||
"""Test appointment creation with past datetime."""
|
||||
invalid_data = self.appointment_data.copy()
|
||||
invalid_data['appointment_datetime'] = '2020-01-01T10:00:00+08:00' # Past date
|
||||
|
||||
response = self.client.post(
|
||||
self.appointments_url,
|
||||
data=json.dumps(invalid_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_appointment_invalid_type(self):
|
||||
"""Test appointment creation with invalid type."""
|
||||
invalid_data = self.appointment_data.copy()
|
||||
invalid_data['type'] = 'INVALID_TYPE'
|
||||
|
||||
response = self.client.post(
|
||||
self.appointments_url,
|
||||
data=json.dumps(invalid_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_appointment_negative_duration(self):
|
||||
"""Test appointment creation with negative duration."""
|
||||
invalid_data = self.appointment_data.copy()
|
||||
invalid_data['duration'] = -30
|
||||
|
||||
response = self.client.post(
|
||||
self.appointments_url,
|
||||
data=json.dumps(invalid_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_appointment_doctor_availability_conflict(self):
|
||||
"""Test appointment creation with doctor availability conflict."""
|
||||
# First request should succeed (if implemented)
|
||||
first_response = self.client.post(
|
||||
self.appointments_url,
|
||||
data=json.dumps(self.appointment_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if first_response.status_code == status.HTTP_201_CREATED:
|
||||
# Second request with same doctor and overlapping time should fail
|
||||
conflicting_data = self.appointment_data.copy()
|
||||
conflicting_data['patient_id'] = 'patient-002' # Different patient
|
||||
conflicting_data['appointment_datetime'] = '2024-02-15T14:45:00+08:00' # Overlapping time
|
||||
|
||||
second_response = self.client.post(
|
||||
self.appointments_url,
|
||||
data=json.dumps(conflicting_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
assert second_response.status_code == status.HTTP_409_CONFLICT
|
||||
|
||||
def test_create_appointment_virtual_consultation(self):
|
||||
"""Test appointment creation with virtual consultation."""
|
||||
virtual_data = self.appointment_data.copy()
|
||||
virtual_data['is_virtual'] = True
|
||||
virtual_data['virtual_consultation'] = {
|
||||
'platform': 'ZOOM',
|
||||
'link': 'https://zoom.us/j/123456789',
|
||||
'instructions': 'Please join 5 minutes early. Test your audio and video.',
|
||||
'meeting_id': '123456789',
|
||||
'password': 'health2024'
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
self.appointments_url,
|
||||
data=json.dumps(virtual_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
data = response.json()
|
||||
assert data['is_virtual'] is True
|
||||
assert 'virtual_consultation' in data
|
||||
virtual_info = data['virtual_consultation']
|
||||
assert virtual_info['platform'] == 'ZOOM'
|
||||
|
||||
def test_create_appointment_emergency(self):
|
||||
"""Test emergency appointment creation."""
|
||||
emergency_data = self.appointment_data.copy()
|
||||
emergency_data['type'] = 'EMERGENCY'
|
||||
emergency_data['priority'] = 'URGENT'
|
||||
emergency_data['reason'] = 'Chest pain and shortness of breath'
|
||||
|
||||
response = self.client.post(
|
||||
self.appointments_url,
|
||||
data=json.dumps(emergency_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
data = response.json()
|
||||
assert data['type'] == 'EMERGENCY'
|
||||
assert data['priority'] == 'URGENT'
|
||||
|
||||
def test_create_appointment_with_attachments(self):
|
||||
"""Test appointment creation with attachments."""
|
||||
attachment_data = self.appointment_data.copy()
|
||||
attachment_data['attachments'] = [
|
||||
{
|
||||
'type': 'MEDICAL_REPORT',
|
||||
'name': 'Blood Test Results.pdf',
|
||||
'url': 'https://storage.example.com/blood-test-123.pdf',
|
||||
'uploaded_at': '2024-02-10T10:00:00Z'
|
||||
},
|
||||
{
|
||||
'type': 'PRESCRIPTION',
|
||||
'name': 'Previous Prescription.jpg',
|
||||
'url': 'https://storage.example.com/prescription-456.jpg',
|
||||
'uploaded_at': '2024-02-08T14:30:00Z'
|
||||
}
|
||||
]
|
||||
|
||||
response = self.client.post(
|
||||
self.appointments_url,
|
||||
data=json.dumps(attachment_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
data = response.json()
|
||||
assert 'attachments' in data
|
||||
assert len(data['attachments']) == 2
|
||||
assert data['attachments'][0]['type'] == 'MEDICAL_REPORT'
|
||||
|
||||
def test_create_appointment_insurance_verification(self):
|
||||
"""Test appointment creation with insurance verification."""
|
||||
insurance_data = self.appointment_data.copy()
|
||||
insurance_data['insurance'] = {
|
||||
'provider': 'Malaysia National Insurance',
|
||||
'policy_number': 'MNI-123456789',
|
||||
'verification_required': True,
|
||||
'pre_authorization_code': 'PA-2024-001'
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
self.appointments_url,
|
||||
data=json.dumps(insurance_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
data = response.json()
|
||||
assert 'insurance' in data
|
||||
assert data['insurance']['verification_required'] is True
|
||||
|
||||
def test_create_appointment_with_cancellation_policy(self):
|
||||
"""Test appointment creation with cancellation policy."""
|
||||
policy_data = self.appointment_data.copy()
|
||||
policy_data['cancellation_policy'] = {
|
||||
'can_cancel_until': '2024-02-14T14:30:00+08:00', # 24 hours before
|
||||
'cancellation_fee': 50.00,
|
||||
'fee_applies_after': '2024-02-14T14:30:00+08:00'
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
self.appointments_url,
|
||||
data=json.dumps(policy_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
data = response.json()
|
||||
assert 'cancellation_policy' in data
|
||||
|
||||
def test_create_appointment_malformed_reminders(self):
|
||||
"""Test appointment creation with malformed reminders JSON."""
|
||||
invalid_data = self.appointment_data.copy()
|
||||
invalid_data['reminders'] = 'invalid reminders format'
|
||||
|
||||
response = self.client.post(
|
||||
self.appointments_url,
|
||||
data=json.dumps(invalid_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_appointment_tenant_isolation(self):
|
||||
"""Test that appointment creation respects tenant isolation."""
|
||||
response = self.client.post(
|
||||
self.appointments_url,
|
||||
data=json.dumps(self.appointment_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
data = response.json()
|
||||
# Appointment should be created in the authenticated tenant's context
|
||||
assert 'tenant_id' in data
|
||||
# This will be validated once implementation exists
|
||||
|
||||
def test_create_appointment_scheduling_validation(self):
|
||||
"""Test that appointment creation validates business hours and scheduling rules."""
|
||||
# Test with off-hours appointment
|
||||
off_hours_data = self.appointment_data.copy()
|
||||
off_hours_data['appointment_datetime'] = '2024-02-15T22:00:00+08:00' # 10 PM
|
||||
|
||||
response = self.client.post(
|
||||
self.appointments_url,
|
||||
data=json.dumps(off_hours_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
# This should fail if clinic hours are enforced
|
||||
# This will be validated once implementation exists
|
||||
if response.status_code == status.HTTP_400_BAD_REQUEST:
|
||||
pass # Expected behavior
|
||||
elif response.status_code == status.HTTP_201_CREATED:
|
||||
pass # Also acceptable if 24/7 appointments are allowed
|
||||
|
||||
def test_create_appointment_with_consent(self):
|
||||
"""Test appointment creation with patient consent."""
|
||||
consent_data = self.appointment_data.copy()
|
||||
consent_data['consents'] = [
|
||||
{
|
||||
'type': 'TREATMENT',
|
||||
'given_at': '2024-02-10T10:00:00Z',
|
||||
'expires_at': None,
|
||||
'scope': 'This appointment only'
|
||||
},
|
||||
{
|
||||
'type': 'TELEMEDICINE',
|
||||
'given_at': '2024-02-10T10:00:00Z',
|
||||
'expires_at': '2024-02-15T16:30:00Z',
|
||||
'scope': 'Virtual consultation if needed'
|
||||
}
|
||||
]
|
||||
|
||||
response = self.client.post(
|
||||
self.appointments_url,
|
||||
data=json.dumps(consent_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
data = response.json()
|
||||
assert 'consents' in data
|
||||
assert len(data['consents']) == 2
|
||||
326
backend/tests/contract/test_healthcare_patients_get.py
Normal file
326
backend/tests/contract/test_healthcare_patients_get.py
Normal file
@@ -0,0 +1,326 @@
|
||||
"""
|
||||
Contract test for GET /healthcare/patients endpoint.
|
||||
This test MUST fail before implementation.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
import json
|
||||
|
||||
|
||||
class HealthcarePatientsGetContractTest(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.patients_url = '/api/v1/healthcare/patients/'
|
||||
|
||||
# Tenant authentication header
|
||||
self.tenant_auth = {'HTTP_AUTHORIZATION': 'Bearer tenant_token'}
|
||||
|
||||
def test_get_patients_success(self):
|
||||
"""Test successful retrieval of patients list."""
|
||||
response = self.client.get(
|
||||
self.patients_url,
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
# This should fail before implementation
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
assert 'patients' in data
|
||||
assert isinstance(data['patients'], list)
|
||||
|
||||
# Check pagination structure
|
||||
assert 'pagination' in data
|
||||
pagination = data['pagination']
|
||||
assert 'page' in pagination
|
||||
assert 'limit' in pagination
|
||||
assert 'total' in pagination
|
||||
assert 'pages' in pagination
|
||||
|
||||
def test_get_patients_unauthorized(self):
|
||||
"""Test patients list retrieval without authentication."""
|
||||
response = self.client.get(self.patients_url)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
def test_get_patients_with_pagination(self):
|
||||
"""Test patients list retrieval with pagination parameters."""
|
||||
params = {
|
||||
'page': 2,
|
||||
'limit': 15
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.patients_url,
|
||||
data=params,
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
assert data['pagination']['page'] == 2
|
||||
assert data['pagination']['limit'] == 15
|
||||
|
||||
def test_get_patients_with_search(self):
|
||||
"""Test patients list retrieval with search parameter."""
|
||||
params = {
|
||||
'search': 'tan'
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.patients_url,
|
||||
data=params,
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
# All returned patients should match search criteria
|
||||
for patient in data['patients']:
|
||||
search_match = (
|
||||
'tan' in patient['name'].lower() or
|
||||
'tan' in patient['ic_number'].lower() or
|
||||
'tan' in patient['email'].lower()
|
||||
)
|
||||
assert search_match
|
||||
|
||||
def test_get_patients_filter_by_gender(self):
|
||||
"""Test patients list retrieval filtered by gender."""
|
||||
params = {
|
||||
'gender': 'FEMALE'
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.patients_url,
|
||||
data=params,
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
# All returned patients should have the specified gender
|
||||
for patient in data['patients']:
|
||||
assert patient['gender'] == 'FEMALE'
|
||||
|
||||
def test_get_patients_filter_by_status(self):
|
||||
"""Test patients list retrieval filtered by status."""
|
||||
params = {
|
||||
'status': 'ACTIVE'
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.patients_url,
|
||||
data=params,
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
# All returned patients should have the specified status
|
||||
for patient in data['patients']:
|
||||
assert patient['status'] == 'ACTIVE'
|
||||
|
||||
def test_get_patients_filter_by_age_range(self):
|
||||
"""Test patients list retrieval filtered by age range."""
|
||||
params = {
|
||||
'min_age': 25,
|
||||
'max_age': 65
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.patients_url,
|
||||
data=params,
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
# All returned patients should be within the age range
|
||||
for patient in data['patients']:
|
||||
assert 25 <= patient['age'] <= 65
|
||||
|
||||
def test_get_patients_data_structure(self):
|
||||
"""Test that patient data structure matches the contract."""
|
||||
response = self.client.get(
|
||||
self.patients_url,
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_200_OK and len(response.json()['patients']) > 0:
|
||||
patient = response.json()['patients'][0]
|
||||
|
||||
# Required fields according to contract
|
||||
required_fields = [
|
||||
'id', 'ic_number', 'name', 'gender', 'date_of_birth',
|
||||
'age', 'phone', 'email', 'address', 'blood_type',
|
||||
'allergies', 'medications', 'status', 'tenant_id',
|
||||
'created_at', 'updated_at'
|
||||
]
|
||||
|
||||
for field in required_fields:
|
||||
assert field in patient
|
||||
|
||||
# Field types and enums
|
||||
assert isinstance(patient['id'], str)
|
||||
assert isinstance(patient['ic_number'], str)
|
||||
assert isinstance(patient['name'], str)
|
||||
assert patient['gender'] in ['MALE', 'FEMALE', 'OTHER']
|
||||
assert isinstance(patient['date_of_birth'], str)
|
||||
assert isinstance(patient['age'], int)
|
||||
assert isinstance(patient['phone'], str)
|
||||
assert isinstance(patient['email'], str)
|
||||
assert patient['blood_type'] in ['A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-', 'UNKNOWN']
|
||||
assert patient['status'] in ['ACTIVE', 'INACTIVE', 'DECEASED']
|
||||
|
||||
def test_get_patients_with_medical_history(self):
|
||||
"""Test that patient data includes medical history."""
|
||||
response = self.client.get(
|
||||
self.patients_url,
|
||||
data={'include_medical_history': 'true'},
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_200_OK and len(response.json()['patients']) > 0:
|
||||
patient = response.json()['patients'][0]
|
||||
|
||||
# Should include medical history
|
||||
assert 'medical_history' in patient
|
||||
medical_history = patient['medical_history']
|
||||
|
||||
# Medical history should include relevant fields
|
||||
expected_medical_fields = ['conditions', 'surgeries', 'family_history', 'immunizations']
|
||||
for field in expected_medical_fields:
|
||||
assert field in medical_history
|
||||
|
||||
def test_get_patients_with_emergency_contacts(self):
|
||||
"""Test that patient data includes emergency contacts."""
|
||||
response = self.client.get(
|
||||
self.patients_url,
|
||||
data={'include_emergency_contacts': 'true'},
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_200_OK and len(response.json()['patients']) > 0:
|
||||
patient = response.json()['patients'][0]
|
||||
|
||||
# Should include emergency contacts
|
||||
assert 'emergency_contacts' in patient
|
||||
emergency_contacts = patient['emergency_contacts']
|
||||
|
||||
# Should be a list
|
||||
assert isinstance(emergency_contacts, list)
|
||||
|
||||
if len(emergency_contacts) > 0:
|
||||
contact = emergency_contacts[0]
|
||||
expected_contact_fields = ['name', 'relationship', 'phone', 'email']
|
||||
for field in expected_contact_fields:
|
||||
assert field in contact
|
||||
|
||||
def test_get_patients_with_insurance_info(self):
|
||||
"""Test that patient data includes insurance information."""
|
||||
response = self.client.get(
|
||||
self.patients_url,
|
||||
data={'include_insurance': 'true'},
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_200_OK and len(response.json()['patients']) > 0:
|
||||
patient = response.json()['patients'][0]
|
||||
|
||||
# Should include insurance information
|
||||
assert 'insurance' in patient
|
||||
insurance = patient['insurance']
|
||||
|
||||
# Insurance should include relevant fields
|
||||
expected_insurance_fields = ['provider', 'policy_number', 'coverage_details', 'expiry_date']
|
||||
for field in expected_insurance_fields:
|
||||
assert field in insurance
|
||||
|
||||
def test_get_patients_sorting(self):
|
||||
"""Test patients list retrieval with sorting."""
|
||||
params = {
|
||||
'sort_by': 'name',
|
||||
'sort_order': 'asc'
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.patients_url,
|
||||
data=params,
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
# Patients should be sorted by name in ascending order
|
||||
patient_names = [patient['name'] for patient in data['patients']]
|
||||
assert patient_names == sorted(patient_names)
|
||||
|
||||
def test_get_patients_tenant_isolation(self):
|
||||
"""Test that patients are isolated by tenant."""
|
||||
response = self.client.get(
|
||||
self.patients_url,
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_200_OK:
|
||||
data = response.json()
|
||||
# All returned patients should belong to the authenticated tenant
|
||||
for patient in data['patients']:
|
||||
assert 'tenant_id' in patient
|
||||
# This will be validated once implementation exists
|
||||
|
||||
def test_get_patients_with_visit_history(self):
|
||||
"""Test that patient data includes visit history."""
|
||||
response = self.client.get(
|
||||
self.patients_url,
|
||||
data={'include_visits': 'true'},
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_200_OK and len(response.json()['patients']) > 0:
|
||||
patient = response.json()['patients'][0]
|
||||
|
||||
# Should include visit history
|
||||
assert 'visit_history' in patient
|
||||
visit_history = patient['visit_history']
|
||||
|
||||
# Should be a list
|
||||
assert isinstance(visit_history, list)
|
||||
|
||||
if len(visit_history) > 0:
|
||||
visit = visit_history[0]
|
||||
expected_visit_fields = ['date', 'doctor_id', 'diagnosis', 'treatment', 'notes']
|
||||
for field in expected_visit_fields:
|
||||
assert field in visit
|
||||
|
||||
def test_get_patients_data_compliance(self):
|
||||
"""Test that patient data complies with healthcare data protection."""
|
||||
response = self.client.get(
|
||||
self.patients_url,
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_200_OK and len(response.json()['patients']) > 0:
|
||||
patient = response.json()['patients'][0]
|
||||
|
||||
# Sensitive medical data should be properly handled
|
||||
# Allergies and medications should be present for healthcare compliance
|
||||
assert 'allergies' in patient
|
||||
assert 'medications' in patient
|
||||
|
||||
# Address should be structured for privacy compliance
|
||||
assert 'address' in patient
|
||||
address = patient['address']
|
||||
expected_address_fields = ['street', 'city', 'state', 'postal_code', 'country']
|
||||
for field in expected_address_fields:
|
||||
assert field in address
|
||||
362
backend/tests/contract/test_healthcare_patients_post.py
Normal file
362
backend/tests/contract/test_healthcare_patients_post.py
Normal file
@@ -0,0 +1,362 @@
|
||||
"""
|
||||
Contract test for POST /healthcare/patients endpoint.
|
||||
This test MUST fail before implementation.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
import json
|
||||
|
||||
|
||||
class HealthcarePatientsPostContractTest(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.patients_url = '/api/v1/healthcare/patients/'
|
||||
|
||||
# Tenant authentication header
|
||||
self.tenant_auth = {'HTTP_AUTHORIZATION': 'Bearer tenant_token'}
|
||||
|
||||
# Valid patient data
|
||||
self.patient_data = {
|
||||
'ic_number': '900101-10-1234',
|
||||
'name': 'Ahmad bin Hassan',
|
||||
'gender': 'MALE',
|
||||
'date_of_birth': '1990-01-01',
|
||||
'phone': '+60123456789',
|
||||
'email': 'ahmad.hassan@example.com',
|
||||
'address': {
|
||||
'street': '123 Jalan Healthcare',
|
||||
'city': 'Kuala Lumpur',
|
||||
'state': 'Wilayah Persekutuan',
|
||||
'postal_code': '50400',
|
||||
'country': 'Malaysia'
|
||||
},
|
||||
'blood_type': 'O+',
|
||||
'allergies': ['Penicillin', 'Peanuts'],
|
||||
'medications': ['Metformin 500mg', 'Lisinopril 10mg'],
|
||||
'emergency_contacts': [
|
||||
{
|
||||
'name': 'Siti binti Ibrahim',
|
||||
'relationship': 'Spouse',
|
||||
'phone': '+60198765432',
|
||||
'email': 'siti.ibrahim@example.com'
|
||||
}
|
||||
],
|
||||
'insurance': {
|
||||
'provider': 'Malaysia National Insurance',
|
||||
'policy_number': 'MNI-123456789',
|
||||
'coverage_details': 'Full coverage',
|
||||
'expiry_date': '2024-12-31'
|
||||
},
|
||||
'medical_history': {
|
||||
'conditions': ['Type 2 Diabetes', 'Hypertension'],
|
||||
'surgeries': ['Appendectomy (2015)'],
|
||||
'family_history': ['Diabetes (paternal)', 'Hypertension (maternal)'],
|
||||
'immunizations': ['COVID-19 Vaccine (2023)', 'Flu Vaccine (2023)']
|
||||
}
|
||||
}
|
||||
|
||||
def test_create_patient_success(self):
|
||||
"""Test successful patient creation."""
|
||||
response = self.client.post(
|
||||
self.patients_url,
|
||||
data=json.dumps(self.patient_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
# This should fail before implementation
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
data = response.json()
|
||||
assert 'id' in data
|
||||
assert data['ic_number'] == self.patient_data['ic_number']
|
||||
assert data['name'] == self.patient_data['name']
|
||||
assert data['gender'] == self.patient_data['gender']
|
||||
assert data['date_of_birth'] == self.patient_data['date_of_birth']
|
||||
assert data['age'] == 34 # Calculated from DOB
|
||||
assert data['status'] == 'ACTIVE' # Default status
|
||||
|
||||
# Should have timestamps
|
||||
assert 'created_at' in data
|
||||
assert 'updated_at' in data
|
||||
|
||||
# Should have tenant_id from context
|
||||
assert 'tenant_id' in data
|
||||
|
||||
# Should include medical information
|
||||
assert data['blood_type'] == self.patient_data['blood_type']
|
||||
assert data['allergies'] == self.patient_data['allergies']
|
||||
assert data['medications'] == self.patient_data['medications']
|
||||
|
||||
def test_create_patient_unauthorized(self):
|
||||
"""Test patient creation without authentication."""
|
||||
response = self.client.post(
|
||||
self.patients_url,
|
||||
data=json.dumps(self.patient_data),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
def test_create_patient_missing_required_fields(self):
|
||||
"""Test patient creation with missing required fields."""
|
||||
incomplete_data = self.patient_data.copy()
|
||||
del incomplete_data['ic_number']
|
||||
|
||||
response = self.client.post(
|
||||
self.patients_url,
|
||||
data=json.dumps(incomplete_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
data = response.json()
|
||||
assert 'ic_number' in data.get('errors', {})
|
||||
|
||||
def test_create_patient_invalid_ic_number(self):
|
||||
"""Test patient creation with invalid IC number format."""
|
||||
invalid_data = self.patient_data.copy()
|
||||
invalid_data['ic_number'] = 'invalid-ic-format'
|
||||
|
||||
response = self.client.post(
|
||||
self.patients_url,
|
||||
data=json.dumps(invalid_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_patient_invalid_gender(self):
|
||||
"""Test patient creation with invalid gender."""
|
||||
invalid_data = self.patient_data.copy()
|
||||
invalid_data['gender'] = 'INVALID_GENDER'
|
||||
|
||||
response = self.client.post(
|
||||
self.patients_url,
|
||||
data=json.dumps(invalid_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_patient_invalid_blood_type(self):
|
||||
"""Test patient creation with invalid blood type."""
|
||||
invalid_data = self.patient_data.copy()
|
||||
invalid_data['blood_type'] = 'INVALID_BLOOD_TYPE'
|
||||
|
||||
response = self.client.post(
|
||||
self.patients_url,
|
||||
data=json.dumps(invalid_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_patient_future_birth_date(self):
|
||||
"""Test patient creation with future birth date."""
|
||||
invalid_data = self.patient_data.copy()
|
||||
invalid_data['date_of_birth'] = '2050-01-01' # Future date
|
||||
|
||||
response = self.client.post(
|
||||
self.patients_url,
|
||||
data=json.dumps(invalid_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_patient_duplicate_ic_number(self):
|
||||
"""Test patient creation with duplicate IC number."""
|
||||
# First request should succeed (if implemented)
|
||||
first_response = self.client.post(
|
||||
self.patients_url,
|
||||
data=json.dumps(self.patient_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if first_response.status_code == status.HTTP_201_CREATED:
|
||||
# Second request with same IC number should fail
|
||||
second_response = self.client.post(
|
||||
self.patients_url,
|
||||
data=json.dumps(self.patient_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
assert second_response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_patient_with_minimal_data(self):
|
||||
"""Test patient creation with minimal required data."""
|
||||
minimal_data = {
|
||||
'ic_number': '950505-05-5678',
|
||||
'name': 'Lee Mei Lin',
|
||||
'gender': 'FEMALE',
|
||||
'date_of_birth': '1995-05-05'
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
self.patients_url,
|
||||
data=json.dumps(minimal_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
data = response.json()
|
||||
assert data['ic_number'] == minimal_data['ic_number']
|
||||
assert data['name'] == minimal_data['name']
|
||||
# Optional fields should have default values
|
||||
assert data['blood_type'] == 'UNKNOWN'
|
||||
assert data['allergies'] == []
|
||||
|
||||
def test_create_patient_invalid_email(self):
|
||||
"""Test patient creation with invalid email format."""
|
||||
invalid_data = self.patient_data.copy()
|
||||
invalid_data['email'] = 'invalid-email-format'
|
||||
|
||||
response = self.client.post(
|
||||
self.patients_url,
|
||||
data=json.dumps(invalid_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_patient_malformed_address(self):
|
||||
"""Test patient creation with malformed address JSON."""
|
||||
invalid_data = self.patient_data.copy()
|
||||
invalid_data['address'] = 'invalid address format'
|
||||
|
||||
response = self.client.post(
|
||||
self.patients_url,
|
||||
data=json.dumps(invalid_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_patient_missing_address_fields(self):
|
||||
"""Test patient creation with missing address fields."""
|
||||
invalid_data = self.patient_data.copy()
|
||||
invalid_data['address'] = {'street': '123 Street'} # Missing required fields
|
||||
|
||||
response = self.client.post(
|
||||
self.patients_url,
|
||||
data=json.dumps(invalid_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_patient_invalid_emergency_contact(self):
|
||||
"""Test patient creation with invalid emergency contact."""
|
||||
invalid_data = self.patient_data.copy()
|
||||
invalid_data['emergency_contacts'] = [
|
||||
{
|
||||
'name': 'Emergency Contact',
|
||||
# Missing required relationship and phone
|
||||
}
|
||||
]
|
||||
|
||||
response = self.client.post(
|
||||
self.patients_url,
|
||||
data=json.dumps(invalid_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_patient_age_calculation(self):
|
||||
"""Test that age is calculated correctly from date of birth."""
|
||||
response = self.client.post(
|
||||
self.patients_url,
|
||||
data=json.dumps(self.patient_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
data = response.json()
|
||||
# Age should be calculated based on current date and birth date
|
||||
# This will be validated once implementation exists
|
||||
assert isinstance(data['age'], int)
|
||||
assert data['age'] > 0
|
||||
|
||||
def test_create_patient_tenant_isolation(self):
|
||||
"""Test that patient creation respects tenant isolation."""
|
||||
response = self.client.post(
|
||||
self.patients_url,
|
||||
data=json.dumps(self.patient_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
data = response.json()
|
||||
# Patient should be created in the authenticated tenant's context
|
||||
assert 'tenant_id' in data
|
||||
# This will be validated once implementation exists
|
||||
|
||||
def test_create_patient_data_privacy_compliance(self):
|
||||
"""Test that patient creation handles sensitive data according to PDPA."""
|
||||
response = self.client.post(
|
||||
self.patients_url,
|
||||
data=json.dumps(self.patient_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
data = response.json()
|
||||
# Sensitive medical data should be stored and handled properly
|
||||
assert 'allergies' in data
|
||||
assert 'medications' in data
|
||||
assert 'medical_history' in data
|
||||
|
||||
# IC number should be handled with special care for privacy
|
||||
assert data['ic_number'] == self.patient_data['ic_number']
|
||||
|
||||
def test_create_patient_with_consent_info(self):
|
||||
"""Test patient creation with consent information."""
|
||||
consent_data = self.patient_data.copy()
|
||||
consent_data['consents'] = [
|
||||
{
|
||||
'type': 'TREATMENT',
|
||||
'given_at': '2024-01-15T10:00:00Z',
|
||||
'expires_at': '2025-01-15T10:00:00Z',
|
||||
'notes': 'Consent for general treatment'
|
||||
},
|
||||
{
|
||||
'type': 'DATA_SHARING',
|
||||
'given_at': '2024-01-15T10:00:00Z',
|
||||
'expires_at': None,
|
||||
'notes': 'Consent to share data with insurance provider'
|
||||
}
|
||||
]
|
||||
|
||||
response = self.client.post(
|
||||
self.patients_url,
|
||||
data=json.dumps(consent_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
data = response.json()
|
||||
assert 'consents' in data
|
||||
assert len(data['consents']) == 2
|
||||
assert data['consents'][0]['type'] == 'TREATMENT'
|
||||
280
backend/tests/contract/test_modules_get.py
Normal file
280
backend/tests/contract/test_modules_get.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
Contract test for GET /modules endpoint.
|
||||
This test MUST fail before implementation.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
import json
|
||||
|
||||
|
||||
class ModulesGetContractTest(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.modules_url = '/api/v1/modules/'
|
||||
|
||||
# Admin authentication header
|
||||
self.admin_auth = {'HTTP_AUTHORIZATION': 'Bearer admin_token'}
|
||||
|
||||
# Tenant admin authentication header
|
||||
self.tenant_admin_auth = {'HTTP_AUTHORIZATION': 'Bearer tenant_admin_token'}
|
||||
|
||||
def test_get_modules_success_admin(self):
|
||||
"""Test successful retrieval of modules list by admin."""
|
||||
response = self.client.get(
|
||||
self.modules_url,
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
# This should fail before implementation
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
assert 'modules' in data
|
||||
assert isinstance(data['modules'], list)
|
||||
|
||||
# Should return all available modules
|
||||
expected_modules = ['retail', 'healthcare', 'education', 'logistics', 'beauty']
|
||||
returned_modules = [module['key'] for module in data['modules']]
|
||||
for expected_module in expected_modules:
|
||||
assert expected_module in returned_modules
|
||||
|
||||
def test_get_modules_success_tenant_admin(self):
|
||||
"""Test successful retrieval of modules list by tenant admin."""
|
||||
response = self.client.get(
|
||||
self.modules_url,
|
||||
**self.tenant_admin_auth
|
||||
)
|
||||
|
||||
# This should fail before implementation
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
assert 'modules' in data
|
||||
assert isinstance(data['modules'], list)
|
||||
|
||||
# Tenant admin should see modules available to their subscription
|
||||
# This will be validated once implementation exists
|
||||
|
||||
def test_get_modules_unauthorized(self):
|
||||
"""Test modules list retrieval without authentication."""
|
||||
response = self.client.get(self.modules_url)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
def test_get_modules_filter_by_category(self):
|
||||
"""Test modules list retrieval filtered by category."""
|
||||
params = {
|
||||
'category': 'core'
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.modules_url,
|
||||
data=params,
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
# All returned modules should have the specified category
|
||||
for module in data['modules']:
|
||||
assert module['category'] == 'core'
|
||||
|
||||
def test_get_modules_filter_by_status(self):
|
||||
"""Test modules list retrieval filtered by status."""
|
||||
params = {
|
||||
'status': 'ACTIVE'
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.modules_url,
|
||||
data=params,
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
# All returned modules should have the specified status
|
||||
for module in data['modules']:
|
||||
assert module['status'] == 'ACTIVE'
|
||||
|
||||
def test_get_modules_filter_by_tenant(self):
|
||||
"""Test modules list retrieval filtered by tenant subscription."""
|
||||
params = {
|
||||
'tenant_id': 'test-tenant-id',
|
||||
'only_subscribed': 'true'
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.modules_url,
|
||||
data=params,
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
# All returned modules should be subscribed by the tenant
|
||||
# This will be validated once implementation exists
|
||||
|
||||
def test_get_modules_with_details(self):
|
||||
"""Test modules list retrieval with detailed information."""
|
||||
params = {
|
||||
'include_details': 'true'
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.modules_url,
|
||||
data=params,
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
if len(data['modules']) > 0:
|
||||
module = data['modules'][0]
|
||||
|
||||
# Should include detailed information
|
||||
assert 'description' in module
|
||||
assert 'features' in module
|
||||
assert 'pricing' in module
|
||||
assert 'requirements' in module
|
||||
|
||||
# Features should be a list
|
||||
assert isinstance(module['features'], list)
|
||||
|
||||
# Pricing should include relevant information
|
||||
pricing = module['pricing']
|
||||
assert 'base_price' in pricing
|
||||
assert 'currency' in pricing
|
||||
assert 'billing_cycle' in pricing
|
||||
|
||||
def test_get_modules_tenant_specific_view(self):
|
||||
"""Test that tenant admin sees modules available to their subscription."""
|
||||
response = self.client.get(
|
||||
self.modules_url,
|
||||
**self.tenant_admin_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_200_OK:
|
||||
data = response.json()
|
||||
# Tenant admin should see which modules are available and which are subscribed
|
||||
# This will be validated once implementation exists
|
||||
pass
|
||||
|
||||
def test_get_modules_data_structure(self):
|
||||
"""Test that module data structure matches the contract."""
|
||||
response = self.client.get(
|
||||
self.modules_url,
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_200_OK and len(response.json()['modules']) > 0:
|
||||
module = response.json()['modules'][0]
|
||||
|
||||
# Required fields according to contract
|
||||
required_fields = [
|
||||
'id', 'key', 'name', 'category', 'status',
|
||||
'version', 'created_at', 'updated_at'
|
||||
]
|
||||
|
||||
for field in required_fields:
|
||||
assert field in module
|
||||
|
||||
# Field types and enums
|
||||
assert isinstance(module['id'], str)
|
||||
assert isinstance(module['key'], str)
|
||||
assert isinstance(module['name'], str)
|
||||
assert module['category'] in ['core', 'industry', 'integration']
|
||||
assert module['status'] in ['ACTIVE', 'INACTIVE', 'DEPRECATED']
|
||||
assert isinstance(module['version'], str)
|
||||
|
||||
def test_get_modules_with_compatibility_info(self):
|
||||
"""Test that module data includes compatibility information."""
|
||||
response = self.client.get(
|
||||
self.modules_url,
|
||||
data={'include_compatibility': 'true'},
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_200_OK and len(response.json()['modules']) > 0:
|
||||
module = response.json()['modules'][0]
|
||||
|
||||
# Should include compatibility information
|
||||
assert 'compatibility' in module
|
||||
compatibility = module['compatibility']
|
||||
|
||||
# Compatibility should include relevant fields
|
||||
expected_compatibility_fields = ['min_platform_version', 'required_modules', 'conflicts']
|
||||
for field in expected_compatibility_fields:
|
||||
assert field in compatibility
|
||||
|
||||
def test_get_modules_with_usage_stats(self):
|
||||
"""Test that module data includes usage statistics for admin."""
|
||||
response = self.client.get(
|
||||
self.modules_url,
|
||||
data={'include_stats': 'true'},
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_200_OK and len(response.json()['modules']) > 0:
|
||||
module = response.json()['modules'][0]
|
||||
|
||||
# Should include usage statistics
|
||||
assert 'usage_stats' in module
|
||||
stats = module['usage_stats']
|
||||
|
||||
# Stats should include relevant fields
|
||||
expected_stats_fields = ['active_tenants', 'total_users', 'api_calls_today', 'storage_used']
|
||||
for field in expected_stats_fields:
|
||||
assert field in stats
|
||||
|
||||
def test_get_modules_search(self):
|
||||
"""Test modules list retrieval with search functionality."""
|
||||
params = {
|
||||
'search': 'retail'
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.modules_url,
|
||||
data=params,
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
# All returned modules should match search criteria
|
||||
for module in data['modules']:
|
||||
search_match = (
|
||||
'retail' in module['name'].lower() or
|
||||
'retail' in module['key'].lower() or
|
||||
'retail' in module['description'].lower()
|
||||
)
|
||||
assert search_match
|
||||
|
||||
def test_get_modules_sorting(self):
|
||||
"""Test modules list retrieval with sorting."""
|
||||
params = {
|
||||
'sort_by': 'name',
|
||||
'sort_order': 'asc'
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.modules_url,
|
||||
data=params,
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
# Modules should be sorted by name in ascending order
|
||||
module_names = [module['name'] for module in data['modules']]
|
||||
assert module_names == sorted(module_names)
|
||||
273
backend/tests/contract/test_retail_products_get.py
Normal file
273
backend/tests/contract/test_retail_products_get.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
Contract test for GET /retail/products endpoint.
|
||||
This test MUST fail before implementation.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
import json
|
||||
|
||||
|
||||
class RetailProductsGetContractTest(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.products_url = '/api/v1/retail/products/'
|
||||
|
||||
# Tenant authentication header
|
||||
self.tenant_auth = {'HTTP_AUTHORIZATION': 'Bearer tenant_token'}
|
||||
|
||||
def test_get_products_success(self):
|
||||
"""Test successful retrieval of products list."""
|
||||
response = self.client.get(
|
||||
self.products_url,
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
# This should fail before implementation
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
assert 'products' in data
|
||||
assert isinstance(data['products'], list)
|
||||
|
||||
# Check pagination structure
|
||||
assert 'pagination' in data
|
||||
pagination = data['pagination']
|
||||
assert 'page' in pagination
|
||||
assert 'limit' in pagination
|
||||
assert 'total' in pagination
|
||||
assert 'pages' in pagination
|
||||
|
||||
def test_get_products_unauthorized(self):
|
||||
"""Test products list retrieval without authentication."""
|
||||
response = self.client.get(self.products_url)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
def test_get_products_with_pagination(self):
|
||||
"""Test products list retrieval with pagination parameters."""
|
||||
params = {
|
||||
'page': 2,
|
||||
'limit': 20
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.products_url,
|
||||
data=params,
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
assert data['pagination']['page'] == 2
|
||||
assert data['pagination']['limit'] == 20
|
||||
|
||||
def test_get_products_with_search(self):
|
||||
"""Test products list retrieval with search parameter."""
|
||||
params = {
|
||||
'search': 'laptop'
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.products_url,
|
||||
data=params,
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
# All returned products should match search criteria
|
||||
for product in data['products']:
|
||||
search_match = (
|
||||
'laptop' in product['name'].lower() or
|
||||
'laptop' in product['description'].lower() or
|
||||
'laptop' in product['sku'].lower()
|
||||
)
|
||||
assert search_match
|
||||
|
||||
def test_get_products_filter_by_category(self):
|
||||
"""Test products list retrieval filtered by category."""
|
||||
params = {
|
||||
'category': 'ELECTRONICS'
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.products_url,
|
||||
data=params,
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
# All returned products should have the specified category
|
||||
for product in data['products']:
|
||||
assert product['category'] == 'ELECTRONICS'
|
||||
|
||||
def test_get_products_filter_by_status(self):
|
||||
"""Test products list retrieval filtered by status."""
|
||||
params = {
|
||||
'status': 'ACTIVE'
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.products_url,
|
||||
data=params,
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
# All returned products should have the specified status
|
||||
for product in data['products']:
|
||||
assert product['status'] == 'ACTIVE'
|
||||
|
||||
def test_get_products_filter_by_price_range(self):
|
||||
"""Test products list retrieval filtered by price range."""
|
||||
params = {
|
||||
'min_price': 100,
|
||||
'max_price': 1000
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.products_url,
|
||||
data=params,
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
# All returned products should be within the price range
|
||||
for product in data['products']:
|
||||
assert 100 <= product['price'] <= 1000
|
||||
|
||||
def test_get_products_filter_by_stock(self):
|
||||
"""Test products list retrieval filtered by stock availability."""
|
||||
params = {
|
||||
'in_stock': 'true'
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.products_url,
|
||||
data=params,
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
# All returned products should have stock available
|
||||
for product in data['products']:
|
||||
assert product['stock_quantity'] > 0
|
||||
|
||||
def test_get_products_data_structure(self):
|
||||
"""Test that product data structure matches the contract."""
|
||||
response = self.client.get(
|
||||
self.products_url,
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_200_OK and len(response.json()['products']) > 0:
|
||||
product = response.json()['products'][0]
|
||||
|
||||
# Required fields according to contract
|
||||
required_fields = [
|
||||
'id', 'sku', 'name', 'description', 'category',
|
||||
'price', 'cost', 'stock_quantity', 'status',
|
||||
'tenant_id', 'created_at', 'updated_at'
|
||||
]
|
||||
|
||||
for field in required_fields:
|
||||
assert field in product
|
||||
|
||||
# Field types and enums
|
||||
assert isinstance(product['id'], str)
|
||||
assert isinstance(product['sku'], str)
|
||||
assert isinstance(product['name'], str)
|
||||
assert isinstance(product['description'], str)
|
||||
assert product['category'] in ['ELECTRONICS', 'CLOTHING', 'FOOD', 'BOOKS', 'OTHER']
|
||||
assert isinstance(product['price'], (int, float))
|
||||
assert isinstance(product['cost'], (int, float))
|
||||
assert isinstance(product['stock_quantity'], int)
|
||||
assert product['status'] in ['ACTIVE', 'INACTIVE', 'DISCONTINUED']
|
||||
|
||||
def test_get_products_with_inventory_info(self):
|
||||
"""Test that product data includes inventory information."""
|
||||
response = self.client.get(
|
||||
self.products_url,
|
||||
data={'include_inventory': 'true'},
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_200_OK and len(response.json()['products']) > 0:
|
||||
product = response.json()['products'][0]
|
||||
|
||||
# Should include inventory information
|
||||
assert 'inventory' in product
|
||||
inventory = product['inventory']
|
||||
|
||||
# Inventory should include relevant fields
|
||||
expected_inventory_fields = ['location', 'reorder_point', 'supplier', 'last_restocked']
|
||||
for field in expected_inventory_fields:
|
||||
assert field in inventory
|
||||
|
||||
def test_get_products_with_pricing_info(self):
|
||||
"""Test that product data includes detailed pricing information."""
|
||||
response = self.client.get(
|
||||
self.products_url,
|
||||
data={'include_pricing': 'true'},
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_200_OK and len(response.json()['products']) > 0:
|
||||
product = response.json()['products'][0]
|
||||
|
||||
# Should include detailed pricing information
|
||||
assert 'pricing' in product
|
||||
pricing = product['pricing']
|
||||
|
||||
# Pricing should include relevant fields
|
||||
expected_pricing_fields = ['base_price', 'tax_rate', 'discount_price', 'currency']
|
||||
for field in expected_pricing_fields:
|
||||
assert field in pricing
|
||||
|
||||
def test_get_products_sorting(self):
|
||||
"""Test products list retrieval with sorting."""
|
||||
params = {
|
||||
'sort_by': 'name',
|
||||
'sort_order': 'asc'
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.products_url,
|
||||
data=params,
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
# Products should be sorted by name in ascending order
|
||||
product_names = [product['name'] for product in data['products']]
|
||||
assert product_names == sorted(product_names)
|
||||
|
||||
def test_get_products_tenant_isolation(self):
|
||||
"""Test that products are isolated by tenant."""
|
||||
response = self.client.get(
|
||||
self.products_url,
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_200_OK:
|
||||
data = response.json()
|
||||
# All returned products should belong to the authenticated tenant
|
||||
for product in data['products']:
|
||||
assert 'tenant_id' in product
|
||||
# This will be validated once implementation exists
|
||||
314
backend/tests/contract/test_retail_products_post.py
Normal file
314
backend/tests/contract/test_retail_products_post.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""
|
||||
Contract test for POST /retail/products endpoint.
|
||||
This test MUST fail before implementation.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
import json
|
||||
|
||||
|
||||
class RetailProductsPostContractTest(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.products_url = '/api/v1/retail/products/'
|
||||
|
||||
# Tenant authentication header
|
||||
self.tenant_auth = {'HTTP_AUTHORIZATION': 'Bearer tenant_token'}
|
||||
|
||||
# Valid product data
|
||||
self.product_data = {
|
||||
'sku': 'LPT-001',
|
||||
'name': 'Laptop Pro 15"',
|
||||
'description': 'High-performance laptop for business use',
|
||||
'category': 'ELECTRONICS',
|
||||
'price': 2499.99,
|
||||
'cost': 1800.00,
|
||||
'stock_quantity': 50,
|
||||
'barcode': '1234567890123',
|
||||
'brand': 'TechBrand',
|
||||
'model': 'PRO-15-2024',
|
||||
'weight': 2.5,
|
||||
'dimensions': {
|
||||
'length': 35.5,
|
||||
'width': 25.0,
|
||||
'height': 2.0,
|
||||
'unit': 'cm'
|
||||
},
|
||||
'tax_rate': 6.0,
|
||||
'inventory': {
|
||||
'location': 'Warehouse A',
|
||||
'reorder_point': 10,
|
||||
'supplier': 'TechSupplier Inc.',
|
||||
'lead_time_days': 7
|
||||
},
|
||||
'attributes': {
|
||||
'color': 'Space Gray',
|
||||
'processor': 'Intel i7',
|
||||
'ram': '16GB',
|
||||
'storage': '512GB SSD'
|
||||
}
|
||||
}
|
||||
|
||||
def test_create_product_success(self):
|
||||
"""Test successful product creation."""
|
||||
response = self.client.post(
|
||||
self.products_url,
|
||||
data=json.dumps(self.product_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
# This should fail before implementation
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
data = response.json()
|
||||
assert 'id' in data
|
||||
assert data['sku'] == self.product_data['sku']
|
||||
assert data['name'] == self.product_data['name']
|
||||
assert data['description'] == self.product_data['description']
|
||||
assert data['category'] == self.product_data['category']
|
||||
assert data['price'] == self.product_data['price']
|
||||
assert data['cost'] == self.product_data['cost']
|
||||
assert data['stock_quantity'] == self.product_data['stock_quantity']
|
||||
assert data['status'] == 'ACTIVE' # Default status
|
||||
|
||||
# Should have timestamps
|
||||
assert 'created_at' in data
|
||||
assert 'updated_at' in data
|
||||
|
||||
# Should have tenant_id from context
|
||||
assert 'tenant_id' in data
|
||||
|
||||
# Should include attributes
|
||||
assert 'attributes' in data
|
||||
assert data['attributes']['color'] == self.product_data['attributes']['color']
|
||||
|
||||
def test_create_product_unauthorized(self):
|
||||
"""Test product creation without authentication."""
|
||||
response = self.client.post(
|
||||
self.products_url,
|
||||
data=json.dumps(self.product_data),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
def test_create_product_missing_required_fields(self):
|
||||
"""Test product creation with missing required fields."""
|
||||
incomplete_data = self.product_data.copy()
|
||||
del incomplete_data['name']
|
||||
|
||||
response = self.client.post(
|
||||
self.products_url,
|
||||
data=json.dumps(incomplete_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
data = response.json()
|
||||
assert 'name' in data.get('errors', {})
|
||||
|
||||
def test_create_product_invalid_category(self):
|
||||
"""Test product creation with invalid category."""
|
||||
invalid_data = self.product_data.copy()
|
||||
invalid_data['category'] = 'INVALID_CATEGORY'
|
||||
|
||||
response = self.client.post(
|
||||
self.products_url,
|
||||
data=json.dumps(invalid_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_product_negative_price(self):
|
||||
"""Test product creation with negative price."""
|
||||
invalid_data = self.product_data.copy()
|
||||
invalid_data['price'] = -100
|
||||
|
||||
response = self.client.post(
|
||||
self.products_url,
|
||||
data=json.dumps(invalid_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_product_negative_stock(self):
|
||||
"""Test product creation with negative stock quantity."""
|
||||
invalid_data = self.product_data.copy()
|
||||
invalid_data['stock_quantity'] = -10
|
||||
|
||||
response = self.client.post(
|
||||
self.products_url,
|
||||
data=json.dumps(invalid_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_product_duplicate_sku(self):
|
||||
"""Test product creation with duplicate SKU."""
|
||||
# First request should succeed (if implemented)
|
||||
first_response = self.client.post(
|
||||
self.products_url,
|
||||
data=json.dumps(self.product_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if first_response.status_code == status.HTTP_201_CREATED:
|
||||
# Second request with same SKU should fail
|
||||
second_response = self.client.post(
|
||||
self.products_url,
|
||||
data=json.dumps(self.product_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
assert second_response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_product_with_image_urls(self):
|
||||
"""Test product creation with image URLs."""
|
||||
image_data = self.product_data.copy()
|
||||
image_data['images'] = [
|
||||
'https://example.com/image1.jpg',
|
||||
'https://example.com/image2.jpg'
|
||||
]
|
||||
|
||||
response = self.client.post(
|
||||
self.products_url,
|
||||
data=json.dumps(image_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
data = response.json()
|
||||
assert 'images' in data
|
||||
assert len(data['images']) == 2
|
||||
assert data['images'][0] == image_data['images'][0]
|
||||
|
||||
def test_create_product_with_variants(self):
|
||||
"""Test product creation with variants."""
|
||||
variants_data = self.product_data.copy()
|
||||
variants_data['variants'] = [
|
||||
{
|
||||
'sku': 'LPT-001-BLACK',
|
||||
'name': 'Laptop Pro 15" - Black',
|
||||
'attributes': {'color': 'Black'},
|
||||
'price_adjustment': 0,
|
||||
'stock_quantity': 25
|
||||
},
|
||||
{
|
||||
'sku': 'LPT-001-WHITE',
|
||||
'name': 'Laptop Pro 15" - White',
|
||||
'attributes': {'color': 'White'},
|
||||
'price_adjustment': 50,
|
||||
'stock_quantity': 15
|
||||
}
|
||||
]
|
||||
|
||||
response = self.client.post(
|
||||
self.products_url,
|
||||
data=json.dumps(variants_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
data = response.json()
|
||||
assert 'variants' in data
|
||||
assert len(data['variants']) == 2
|
||||
assert data['variants'][0]['sku'] == 'LPT-001-BLACK'
|
||||
|
||||
def test_create_product_malformed_dimensions(self):
|
||||
"""Test product creation with malformed dimensions JSON."""
|
||||
invalid_data = self.product_data.copy()
|
||||
invalid_data['dimensions'] = 'invalid dimensions format'
|
||||
|
||||
response = self.client.post(
|
||||
self.products_url,
|
||||
data=json.dumps(invalid_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_product_missing_dimension_fields(self):
|
||||
"""Test product creation with missing dimension fields."""
|
||||
invalid_data = self.product_data.copy()
|
||||
invalid_data['dimensions'] = {'length': 35.5} # Missing width, height, unit
|
||||
|
||||
response = self.client.post(
|
||||
self.products_url,
|
||||
data=json.dumps(invalid_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_product_tenant_isolation(self):
|
||||
"""Test that product creation respects tenant isolation."""
|
||||
response = self.client.post(
|
||||
self.products_url,
|
||||
data=json.dumps(self.product_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
data = response.json()
|
||||
# Product should be created in the authenticated tenant's context
|
||||
assert 'tenant_id' in data
|
||||
# This will be validated once implementation exists
|
||||
|
||||
def test_create_product_with_tags(self):
|
||||
"""Test product creation with tags."""
|
||||
tags_data = self.product_data.copy()
|
||||
tags_data['tags'] = ['laptop', 'business', 'electronics', 'premium']
|
||||
|
||||
response = self.client.post(
|
||||
self.products_url,
|
||||
data=json.dumps(tags_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
data = response.json()
|
||||
assert 'tags' in data
|
||||
assert len(data['tags']) == 4
|
||||
assert 'laptop' in data['tags']
|
||||
|
||||
def test_create_product_with_bulk_pricing(self):
|
||||
"""Test product creation with bulk pricing tiers."""
|
||||
bulk_data = self.product_data.copy()
|
||||
bulk_data['bulk_pricing'] = [
|
||||
{'min_quantity': 5, 'price': 2399.99},
|
||||
{'min_quantity': 10, 'price': 2299.99},
|
||||
{'min_quantity': 25, 'price': 2199.99}
|
||||
]
|
||||
|
||||
response = self.client.post(
|
||||
self.products_url,
|
||||
data=json.dumps(bulk_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
data = response.json()
|
||||
assert 'bulk_pricing' in data
|
||||
assert len(data['bulk_pricing']) == 3
|
||||
assert data['bulk_pricing'][0]['min_quantity'] == 5
|
||||
388
backend/tests/contract/test_retail_sales_post.py
Normal file
388
backend/tests/contract/test_retail_sales_post.py
Normal file
@@ -0,0 +1,388 @@
|
||||
"""
|
||||
Contract test for POST /retail/sales endpoint.
|
||||
This test MUST fail before implementation.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
import json
|
||||
|
||||
|
||||
class RetailSalesPostContractTest(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.sales_url = '/api/v1/retail/sales/'
|
||||
|
||||
# Tenant authentication header
|
||||
self.tenant_auth = {'HTTP_AUTHORIZATION': 'Bearer tenant_token'}
|
||||
|
||||
# Valid sale data
|
||||
self.sale_data = {
|
||||
'customer': {
|
||||
'name': 'John Doe',
|
||||
'email': 'john.doe@example.com',
|
||||
'phone': '+60123456789',
|
||||
'address': {
|
||||
'street': '123 Customer Street',
|
||||
'city': 'Kuala Lumpur',
|
||||
'state': 'Wilayah Persekutuan',
|
||||
'postal_code': '50000',
|
||||
'country': 'Malaysia'
|
||||
}
|
||||
},
|
||||
'items': [
|
||||
{
|
||||
'product_id': 'product-001',
|
||||
'sku': 'LPT-001',
|
||||
'quantity': 2,
|
||||
'unit_price': 2499.99,
|
||||
'discount_percentage': 5.0,
|
||||
'tax_rate': 6.0
|
||||
},
|
||||
{
|
||||
'product_id': 'product-002',
|
||||
'sku': 'MOU-001',
|
||||
'quantity': 1,
|
||||
'unit_price': 99.99,
|
||||
'discount_percentage': 0.0,
|
||||
'tax_rate': 6.0
|
||||
}
|
||||
],
|
||||
'payment': {
|
||||
'method': 'CASH',
|
||||
'amount_paid': 5300.00,
|
||||
'reference_number': 'CASH-001'
|
||||
},
|
||||
'discount': {
|
||||
'type': 'percentage',
|
||||
'value': 2.0,
|
||||
'reason': 'Loyalty discount'
|
||||
},
|
||||
'notes': 'Customer requested expedited delivery',
|
||||
'sales_channel': 'IN_STORE',
|
||||
'staff_id': 'staff-001'
|
||||
}
|
||||
|
||||
def test_create_sale_success(self):
|
||||
"""Test successful sale creation."""
|
||||
response = self.client.post(
|
||||
self.sales_url,
|
||||
data=json.dumps(self.sale_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
# This should fail before implementation
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
data = response.json()
|
||||
assert 'id' in data
|
||||
assert data['status'] == 'COMPLETED'
|
||||
assert 'customer' in data
|
||||
assert 'items' in data
|
||||
assert 'payment' in data
|
||||
assert 'totals' in data
|
||||
|
||||
# Check customer information
|
||||
customer = data['customer']
|
||||
assert customer['name'] == self.sale_data['customer']['name']
|
||||
assert customer['email'] == self.sale_data['customer']['email']
|
||||
|
||||
# Check items
|
||||
items = data['items']
|
||||
assert len(items) == 2
|
||||
assert items[0]['product_id'] == self.sale_data['items'][0]['product_id']
|
||||
assert items[0]['quantity'] == self.sale_data['items'][0]['quantity']
|
||||
|
||||
# Check payment
|
||||
payment = data['payment']
|
||||
assert payment['method'] == self.sale_data['payment']['method']
|
||||
assert payment['amount_paid'] == self.sale_data['payment']['amount_paid']
|
||||
|
||||
# Check totals
|
||||
totals = data['totals']
|
||||
assert 'subtotal' in totals
|
||||
assert 'discount_amount' in totals
|
||||
assert 'tax_amount' in totals
|
||||
assert 'total_amount' in totals
|
||||
|
||||
# Should have timestamps
|
||||
assert 'created_at' in data
|
||||
assert 'updated_at' in data
|
||||
|
||||
# Should have tenant_id from context
|
||||
assert 'tenant_id' in data
|
||||
|
||||
def test_create_sale_unauthorized(self):
|
||||
"""Test sale creation without authentication."""
|
||||
response = self.client.post(
|
||||
self.sales_url,
|
||||
data=json.dumps(self.sale_data),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
def test_create_sale_missing_required_fields(self):
|
||||
"""Test sale creation with missing required fields."""
|
||||
incomplete_data = self.sale_data.copy()
|
||||
del incomplete_data['customer']
|
||||
|
||||
response = self.client.post(
|
||||
self.sales_url,
|
||||
data=json.dumps(incomplete_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
data = response.json()
|
||||
assert 'customer' in data.get('errors', {})
|
||||
|
||||
def test_create_sale_empty_items(self):
|
||||
"""Test sale creation with empty items list."""
|
||||
invalid_data = self.sale_data.copy()
|
||||
invalid_data['items'] = []
|
||||
|
||||
response = self.client.post(
|
||||
self.sales_url,
|
||||
data=json.dumps(invalid_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_sale_invalid_payment_method(self):
|
||||
"""Test sale creation with invalid payment method."""
|
||||
invalid_data = self.sale_data.copy()
|
||||
invalid_data['payment']['method'] = 'INVALID_METHOD'
|
||||
|
||||
response = self.client.post(
|
||||
self.sales_url,
|
||||
data=json.dumps(invalid_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_sale_insufficient_payment(self):
|
||||
"""Test sale creation with insufficient payment amount."""
|
||||
invalid_data = self.sale_data.copy()
|
||||
invalid_data['payment']['amount_paid'] = 100.00 # Much less than total
|
||||
|
||||
response = self.client.post(
|
||||
self.sales_url,
|
||||
data=json.dumps(invalid_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_sale_negative_quantity(self):
|
||||
"""Test sale creation with negative quantity."""
|
||||
invalid_data = self.sale_data.copy()
|
||||
invalid_data['items'][0]['quantity'] = -1
|
||||
|
||||
response = self.client.post(
|
||||
self.sales_url,
|
||||
data=json.dumps(invalid_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_sale_invalid_discount_percentage(self):
|
||||
"""Test sale creation with invalid discount percentage."""
|
||||
invalid_data = self.sale_data.copy()
|
||||
invalid_data['items'][0]['discount_percentage'] = 150.0 # Over 100%
|
||||
|
||||
response = self.client.post(
|
||||
self.sales_url,
|
||||
data=json.dumps(invalid_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_sale_with_installment(self):
|
||||
"""Test sale creation with installment payment."""
|
||||
installment_data = self.sale_data.copy()
|
||||
installment_data['payment']['method'] = 'INSTALLMENT'
|
||||
installment_data['payment']['installment_plan'] = {
|
||||
'down_payment': 1000.00,
|
||||
'number_of_installments': 12,
|
||||
'installment_amount': 358.33,
|
||||
'first_installment_date': '2024-02-01'
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
self.sales_url,
|
||||
data=json.dumps(installment_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
data = response.json()
|
||||
assert data['status'] == 'COMPLETED'
|
||||
assert 'installment_plan' in data['payment']
|
||||
|
||||
def test_create_sale_with_multiple_payments(self):
|
||||
"""Test sale creation with multiple payment methods."""
|
||||
multi_payment_data = self.sale_data.copy()
|
||||
multi_payment_data['payment'] = [
|
||||
{
|
||||
'method': 'CASH',
|
||||
'amount_paid': 2000.00,
|
||||
'reference_number': 'CASH-001'
|
||||
},
|
||||
{
|
||||
'method': 'CARD',
|
||||
'amount_paid': 3300.00,
|
||||
'reference_number': 'CARD-001',
|
||||
'card_last4': '4242'
|
||||
}
|
||||
]
|
||||
|
||||
response = self.client.post(
|
||||
self.sales_url,
|
||||
data=json.dumps(multi_payment_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
data = response.json()
|
||||
assert len(data['payment']) == 2
|
||||
assert data['payment'][0]['method'] == 'CASH'
|
||||
|
||||
def test_create_sale_with_loyalty_points(self):
|
||||
"""Test sale creation with loyalty points redemption."""
|
||||
loyalty_data = self.sale_data.copy()
|
||||
loyalty_data['loyalty'] = {
|
||||
'points_used': 1000,
|
||||
'points_value': 100.00,
|
||||
'customer_id': 'customer-001'
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
self.sales_url,
|
||||
data=json.dumps(loyalty_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
data = response.json()
|
||||
assert 'loyalty' in data
|
||||
assert data['loyalty']['points_used'] == 1000
|
||||
|
||||
def test_create_sale_with_delivery_info(self):
|
||||
"""Test sale creation with delivery information."""
|
||||
delivery_data = self.sale_data.copy()
|
||||
delivery_data['delivery'] = {
|
||||
'method': 'DELIVERY',
|
||||
'address': self.sale_data['customer']['address'],
|
||||
'scheduled_date': '2024-01-20',
|
||||
'delivery_fee': 50.00,
|
||||
'notes': 'Leave at front door'
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
self.sales_url,
|
||||
data=json.dumps(delivery_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
data = response.json()
|
||||
assert 'delivery' in data
|
||||
assert data['delivery']['method'] == 'DELIVERY'
|
||||
|
||||
def test_create_sale_with_exchange_items(self):
|
||||
"""Test sale creation with item exchange."""
|
||||
exchange_data = self.sale_data.copy()
|
||||
exchange_data['exchange'] = {
|
||||
'items': [
|
||||
{
|
||||
'product_id': 'old-product-001',
|
||||
'sku': 'OLD-001',
|
||||
'condition': 'GOOD',
|
||||
'exchange_value': 500.00
|
||||
}
|
||||
],
|
||||
'total_exchange_value': 500.00
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
self.sales_url,
|
||||
data=json.dumps(exchange_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
data = response.json()
|
||||
assert 'exchange' in data
|
||||
assert len(data['exchange']['items']) == 1
|
||||
|
||||
def test_create_sale_tax_calculation(self):
|
||||
"""Test that tax calculation is correct."""
|
||||
response = self.client.post(
|
||||
self.sales_url,
|
||||
data=json.dumps(self.sale_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
data = response.json()
|
||||
totals = data['totals']
|
||||
|
||||
# Verify tax calculation (6% GST on subtotal after discounts)
|
||||
# This will be validated once implementation exists
|
||||
assert 'tax_amount' in totals
|
||||
assert totals['tax_amount'] >= 0
|
||||
|
||||
def test_create_sale_tenant_isolation(self):
|
||||
"""Test that sale creation respects tenant isolation."""
|
||||
response = self.client.post(
|
||||
self.sales_url,
|
||||
data=json.dumps(self.sale_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
data = response.json()
|
||||
# Sale should be created in the authenticated tenant's context
|
||||
assert 'tenant_id' in data
|
||||
# This will be validated once implementation exists
|
||||
|
||||
def test_create_sale_inventory_validation(self):
|
||||
"""Test that sale creation validates inventory availability."""
|
||||
response = self.client.post(
|
||||
self.sales_url,
|
||||
data=json.dumps(self.sale_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_auth
|
||||
)
|
||||
|
||||
# This test will ensure that the system checks if sufficient stock is available
|
||||
# The test should pass if implementation exists and validates inventory
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
# Success means inventory was available
|
||||
pass
|
||||
elif response.status_code == status.HTTP_400_BAD_REQUEST:
|
||||
# This could also be valid if inventory validation fails
|
||||
pass
|
||||
224
backend/tests/contract/test_subscriptions_get.py
Normal file
224
backend/tests/contract/test_subscriptions_get.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""
|
||||
Contract test for GET /subscriptions endpoint.
|
||||
This test MUST fail before implementation.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
import json
|
||||
|
||||
|
||||
class SubscriptionsGetContractTest(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.subscriptions_url = '/api/v1/subscriptions/'
|
||||
|
||||
# Admin authentication header
|
||||
self.admin_auth = {'HTTP_AUTHORIZATION': 'Bearer admin_token'}
|
||||
|
||||
# Tenant admin authentication header
|
||||
self.tenant_admin_auth = {'HTTP_AUTHORIZATION': 'Bearer tenant_admin_token'}
|
||||
|
||||
def test_get_subscriptions_success_admin(self):
|
||||
"""Test successful retrieval of subscriptions list by admin."""
|
||||
response = self.client.get(
|
||||
self.subscriptions_url,
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
# This should fail before implementation
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
assert 'subscriptions' in data
|
||||
assert isinstance(data['subscriptions'], list)
|
||||
|
||||
# Check pagination structure
|
||||
assert 'pagination' in data
|
||||
pagination = data['pagination']
|
||||
assert 'page' in pagination
|
||||
assert 'limit' in pagination
|
||||
assert 'total' in pagination
|
||||
assert 'pages' in pagination
|
||||
|
||||
def test_get_subscriptions_success_tenant_admin(self):
|
||||
"""Test successful retrieval of subscriptions list by tenant admin."""
|
||||
response = self.client.get(
|
||||
self.subscriptions_url,
|
||||
**self.tenant_admin_auth
|
||||
)
|
||||
|
||||
# This should fail before implementation
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
assert 'subscriptions' in data
|
||||
assert isinstance(data['subscriptions'], list)
|
||||
|
||||
# Tenant admin should only see subscriptions from their tenant
|
||||
# This will be validated once implementation exists
|
||||
|
||||
def test_get_subscriptions_unauthorized(self):
|
||||
"""Test subscriptions list retrieval without authentication."""
|
||||
response = self.client.get(self.subscriptions_url)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
def test_get_subscriptions_with_pagination(self):
|
||||
"""Test subscriptions list retrieval with pagination parameters."""
|
||||
params = {
|
||||
'page': 2,
|
||||
'limit': 10
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.subscriptions_url,
|
||||
data=params,
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
assert data['pagination']['page'] == 2
|
||||
assert data['pagination']['limit'] == 10
|
||||
|
||||
def test_get_subscriptions_filter_by_status(self):
|
||||
"""Test subscriptions list retrieval filtered by status."""
|
||||
params = {
|
||||
'status': 'ACTIVE'
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.subscriptions_url,
|
||||
data=params,
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
# All returned subscriptions should have the specified status
|
||||
for subscription in data['subscriptions']:
|
||||
assert subscription['status'] == 'ACTIVE'
|
||||
|
||||
def test_get_subscriptions_filter_by_plan(self):
|
||||
"""Test subscriptions list retrieval filtered by plan."""
|
||||
params = {
|
||||
'plan': 'GROWTH'
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.subscriptions_url,
|
||||
data=params,
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
# All returned subscriptions should have the specified plan
|
||||
for subscription in data['subscriptions']:
|
||||
assert subscription['plan'] == 'GROWTH'
|
||||
|
||||
def test_get_subscriptions_filter_by_tenant(self):
|
||||
"""Test subscriptions list retrieval filtered by tenant."""
|
||||
params = {
|
||||
'tenant_id': 'test-tenant-id'
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.subscriptions_url,
|
||||
data=params,
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
# All returned subscriptions should belong to the specified tenant
|
||||
for subscription in data['subscriptions']:
|
||||
assert subscription['tenant_id'] == 'test-tenant-id'
|
||||
|
||||
def test_get_subscriptions_tenant_isolation(self):
|
||||
"""Test that tenant admin can only see subscriptions from their tenant."""
|
||||
# This test verifies tenant isolation for subscription data
|
||||
response = self.client.get(
|
||||
self.subscriptions_url,
|
||||
**self.tenant_admin_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_200_OK:
|
||||
data = response.json()
|
||||
# For tenant users, all returned subscriptions should belong to their tenant
|
||||
# This will be validated once implementation exists
|
||||
pass
|
||||
|
||||
def test_get_subscriptions_data_structure(self):
|
||||
"""Test that subscription data structure matches the contract."""
|
||||
response = self.client.get(
|
||||
self.subscriptions_url,
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_200_OK and len(response.json()['subscriptions']) > 0:
|
||||
subscription = response.json()['subscriptions'][0]
|
||||
|
||||
# Required fields according to contract
|
||||
required_fields = [
|
||||
'id', 'tenant_id', 'plan', 'status', 'pricing_model',
|
||||
'billing_cycle', 'current_period_start', 'current_period_end',
|
||||
'trial_end', 'created_at', 'updated_at'
|
||||
]
|
||||
|
||||
for field in required_fields:
|
||||
assert field in subscription
|
||||
|
||||
# Field types and enums
|
||||
assert isinstance(subscription['id'], str)
|
||||
assert isinstance(subscription['tenant_id'], str)
|
||||
assert subscription['plan'] in ['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']
|
||||
assert subscription['status'] in ['TRIAL', 'ACTIVE', 'PAST_DUE', 'CANCELLED', 'EXPIRED']
|
||||
assert subscription['pricing_model'] in ['SUBSCRIPTION', 'PERPETUAL']
|
||||
assert subscription['billing_cycle'] in ['MONTHLY', 'QUARTERLY', 'YEARLY']
|
||||
|
||||
def test_get_subscriptions_with_usage_data(self):
|
||||
"""Test that subscription data includes usage information."""
|
||||
response = self.client.get(
|
||||
self.subscriptions_url,
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_200_OK and len(response.json()['subscriptions']) > 0:
|
||||
subscription = response.json()['subscriptions'][0]
|
||||
|
||||
# Should include usage metrics
|
||||
assert 'usage' in subscription
|
||||
usage = subscription['usage']
|
||||
|
||||
# Usage should include relevant metrics
|
||||
expected_usage_fields = ['users_count', 'storage_used', 'api_calls']
|
||||
for field in expected_usage_fields:
|
||||
assert field in usage
|
||||
|
||||
def test_get_subscriptions_with_billing_info(self):
|
||||
"""Test that subscription data includes billing information."""
|
||||
response = self.client.get(
|
||||
self.subscriptions_url,
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_200_OK and len(response.json()['subscriptions']) > 0:
|
||||
subscription = response.json()['subscriptions'][0]
|
||||
|
||||
# Should include billing information
|
||||
assert 'billing' in subscription
|
||||
billing = subscription['billing']
|
||||
|
||||
# Billing should include relevant fields
|
||||
expected_billing_fields = ['next_billing_date', 'amount', 'currency', 'payment_method']
|
||||
for field in expected_billing_fields:
|
||||
assert field in billing
|
||||
264
backend/tests/contract/test_subscriptions_post.py
Normal file
264
backend/tests/contract/test_subscriptions_post.py
Normal file
@@ -0,0 +1,264 @@
|
||||
"""
|
||||
Contract test for POST /subscriptions endpoint.
|
||||
This test MUST fail before implementation.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
import json
|
||||
|
||||
|
||||
class SubscriptionsPostContractTest(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.subscriptions_url = '/api/v1/subscriptions/'
|
||||
|
||||
# Admin authentication header
|
||||
self.admin_auth = {'HTTP_AUTHORIZATION': 'Bearer admin_token'}
|
||||
|
||||
# Tenant admin authentication header
|
||||
self.tenant_admin_auth = {'HTTP_AUTHORIZATION': 'Bearer tenant_admin_token'}
|
||||
|
||||
# Valid subscription data
|
||||
self.subscription_data = {
|
||||
'tenant_id': 'test-tenant-id',
|
||||
'plan': 'GROWTH',
|
||||
'pricing_model': 'SUBSCRIPTION',
|
||||
'billing_cycle': 'MONTHLY',
|
||||
'payment_method': {
|
||||
'type': 'CARD',
|
||||
'card_last4': '4242',
|
||||
'expiry_month': 12,
|
||||
'expiry_year': 2025,
|
||||
'brand': 'visa'
|
||||
},
|
||||
'modules': ['retail', 'inventory'],
|
||||
'trial_days': 14,
|
||||
'notes': 'Subscription for retail business'
|
||||
}
|
||||
|
||||
def test_create_subscription_success_admin(self):
|
||||
"""Test successful subscription creation by admin."""
|
||||
response = self.client.post(
|
||||
self.subscriptions_url,
|
||||
data=json.dumps(self.subscription_data),
|
||||
content_type='application/json',
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
# This should fail before implementation
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
data = response.json()
|
||||
assert 'id' in data
|
||||
assert data['tenant_id'] == self.subscription_data['tenant_id']
|
||||
assert data['plan'] == self.subscription_data['plan']
|
||||
assert data['pricing_model'] == self.subscription_data['pricing_model']
|
||||
assert data['billing_cycle'] == self.subscription_data['billing_cycle']
|
||||
assert data['status'] == 'TRIAL' # Default status with trial_days
|
||||
|
||||
# Should have timestamps
|
||||
assert 'created_at' in data
|
||||
assert 'updated_at' in data
|
||||
|
||||
# Should have billing period information
|
||||
assert 'current_period_start' in data
|
||||
assert 'current_period_end' in data
|
||||
assert 'trial_end' in data
|
||||
|
||||
# Should include modules
|
||||
assert 'modules' in data
|
||||
assert data['modules'] == self.subscription_data['modules']
|
||||
|
||||
def test_create_subscription_success_tenant_admin(self):
|
||||
"""Test successful subscription creation by tenant admin."""
|
||||
# Tenant admin creates subscription for their own tenant
|
||||
tenant_subscription_data = self.subscription_data.copy()
|
||||
del tenant_subscription_data['tenant_id'] # Should be inferred from context
|
||||
|
||||
response = self.client.post(
|
||||
self.subscriptions_url,
|
||||
data=json.dumps(tenant_subscription_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_admin_auth
|
||||
)
|
||||
|
||||
# This should fail before implementation
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
data = response.json()
|
||||
assert 'id' in data
|
||||
assert data['plan'] == tenant_subscription_data['plan']
|
||||
assert data['tenant_id'] # Should be auto-populated
|
||||
|
||||
def test_create_subscription_unauthorized(self):
|
||||
"""Test subscription creation without authentication."""
|
||||
response = self.client.post(
|
||||
self.subscriptions_url,
|
||||
data=json.dumps(self.subscription_data),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
def test_create_subscription_forbidden(self):
|
||||
"""Test subscription creation by regular user (no permissions)."""
|
||||
user_auth = {'HTTP_AUTHORIZATION': 'Bearer user_token'}
|
||||
|
||||
response = self.client.post(
|
||||
self.subscriptions_url,
|
||||
data=json.dumps(self.subscription_data),
|
||||
content_type='application/json',
|
||||
**user_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_create_subscription_missing_required_fields(self):
|
||||
"""Test subscription creation with missing required fields."""
|
||||
incomplete_data = self.subscription_data.copy()
|
||||
del incomplete_data['plan']
|
||||
|
||||
response = self.client.post(
|
||||
self.subscriptions_url,
|
||||
data=json.dumps(incomplete_data),
|
||||
content_type='application/json',
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
data = response.json()
|
||||
assert 'plan' in data.get('errors', {})
|
||||
|
||||
def test_create_subscription_invalid_plan(self):
|
||||
"""Test subscription creation with invalid plan."""
|
||||
invalid_data = self.subscription_data.copy()
|
||||
invalid_data['plan'] = 'INVALID_PLAN'
|
||||
|
||||
response = self.client.post(
|
||||
self.subscriptions_url,
|
||||
data=json.dumps(invalid_data),
|
||||
content_type='application/json',
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_subscription_invalid_billing_cycle(self):
|
||||
"""Test subscription creation with invalid billing cycle."""
|
||||
invalid_data = self.subscription_data.copy()
|
||||
invalid_data['billing_cycle'] = 'INVALID_CYCLE'
|
||||
|
||||
response = self.client.post(
|
||||
self.subscriptions_url,
|
||||
data=json.dumps(incomplete_data),
|
||||
content_type='application/json',
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_subscription_duplicate_tenant(self):
|
||||
"""Test subscription creation with duplicate tenant."""
|
||||
# First request should succeed (if implemented)
|
||||
first_response = self.client.post(
|
||||
self.subscriptions_url,
|
||||
data=json.dumps(self.subscription_data),
|
||||
content_type='application/json',
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
if first_response.status_code == status.HTTP_201_CREATED:
|
||||
# Second request with same tenant should fail
|
||||
second_response = self.client.post(
|
||||
self.subscriptions_url,
|
||||
data=json.dumps(self.subscription_data),
|
||||
content_type='application/json',
|
||||
**self.admin_auth
|
||||
)
|
||||
assert second_response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_subscription_without_trial(self):
|
||||
"""Test subscription creation without trial period."""
|
||||
no_trial_data = self.subscription_data.copy()
|
||||
del no_trial_data['trial_days']
|
||||
|
||||
response = self.client.post(
|
||||
self.subscriptions_url,
|
||||
data=json.dumps(no_trial_data),
|
||||
content_type='application/json',
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
data = response.json()
|
||||
# Should be active immediately without trial
|
||||
assert data['status'] == 'ACTIVE'
|
||||
assert 'trial_end' not in data or data['trial_end'] is None
|
||||
|
||||
def test_create_subscription_with_invalid_modules(self):
|
||||
"""Test subscription creation with invalid modules."""
|
||||
invalid_data = self.subscription_data.copy()
|
||||
invalid_data['modules'] = ['invalid_module']
|
||||
|
||||
response = self.client.post(
|
||||
self.subscriptions_url,
|
||||
data=json.dumps(invalid_data),
|
||||
content_type='application/json',
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_subscription_tenant_admin_cross_tenant(self):
|
||||
"""Test that tenant admin cannot create subscription for other tenant."""
|
||||
# Tenant admin trying to create subscription for different tenant
|
||||
response = self.client.post(
|
||||
self.subscriptions_url,
|
||||
data=json.dumps(self.subscription_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_admin_auth
|
||||
)
|
||||
|
||||
# Should fail because tenant_id doesn't match their tenant
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_create_subscription_payment_method_validation(self):
|
||||
"""Test subscription creation with invalid payment method."""
|
||||
invalid_data = self.subscription_data.copy()
|
||||
invalid_data['payment_method'] = {
|
||||
'type': 'CARD',
|
||||
'card_last4': '4242',
|
||||
# Missing required expiry fields
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
self.subscriptions_url,
|
||||
data=json.dumps(invalid_data),
|
||||
content_type='application/json',
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_subscription_with_promo_code(self):
|
||||
"""Test subscription creation with promo code."""
|
||||
promo_data = self.subscription_data.copy()
|
||||
promo_data['promo_code'] = 'WELCOME20'
|
||||
|
||||
response = self.client.post(
|
||||
self.subscriptions_url,
|
||||
data=json.dumps(promo_data),
|
||||
content_type='application/json',
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
data = response.json()
|
||||
# Should include discount information
|
||||
assert 'discount' in data
|
||||
assert data['promo_code'] == 'WELCOME20'
|
||||
145
backend/tests/contract/test_tenants_get.py
Normal file
145
backend/tests/contract/test_tenants_get.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Contract test for GET /tenants endpoint.
|
||||
This test MUST fail before implementation.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
import json
|
||||
|
||||
|
||||
class TenantsGetContractTest(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.tenants_url = '/api/v1/tenants/'
|
||||
|
||||
# Admin authentication header
|
||||
self.admin_auth = {'HTTP_AUTHORIZATION': 'Bearer admin_token'}
|
||||
|
||||
def test_get_tenants_success(self):
|
||||
"""Test successful retrieval of tenants list."""
|
||||
response = self.client.get(
|
||||
self.tenants_url,
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
# This should fail before implementation
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
assert 'tenants' in data
|
||||
assert isinstance(data['tenants'], list)
|
||||
|
||||
# Check pagination structure
|
||||
assert 'pagination' in data
|
||||
pagination = data['pagination']
|
||||
assert 'page' in pagination
|
||||
assert 'limit' in pagination
|
||||
assert 'total' in pagination
|
||||
assert 'pages' in pagination
|
||||
|
||||
def test_get_tenants_unauthorized(self):
|
||||
"""Test tenants list retrieval without authentication."""
|
||||
response = self.client.get(self.tenants_url)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
def test_get_tenants_forbidden(self):
|
||||
"""Test tenants list retrieval by non-admin user."""
|
||||
non_admin_auth = {'HTTP_AUTHORIZATION': 'Bearer user_token'}
|
||||
|
||||
response = self.client.get(
|
||||
self.tenants_url,
|
||||
**non_admin_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_get_tenants_with_pagination(self):
|
||||
"""Test tenants list retrieval with pagination parameters."""
|
||||
params = {
|
||||
'page': 2,
|
||||
'limit': 10
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.tenants_url,
|
||||
data=params,
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
assert data['pagination']['page'] == 2
|
||||
assert data['pagination']['limit'] == 10
|
||||
|
||||
def test_get_tenants_with_search(self):
|
||||
"""Test tenants list retrieval with search parameter."""
|
||||
params = {
|
||||
'search': 'test'
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.tenants_url,
|
||||
data=params,
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
# All returned tenants should match search criteria
|
||||
for tenant in data['tenants']:
|
||||
assert 'test' in tenant['name'].lower()
|
||||
|
||||
def test_get_tenants_filter_by_status(self):
|
||||
"""Test tenants list retrieval filtered by status."""
|
||||
params = {
|
||||
'status': 'ACTIVE'
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.tenants_url,
|
||||
data=params,
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
# All returned tenants should have the specified status
|
||||
for tenant in data['tenants']:
|
||||
assert tenant['status'] == 'ACTIVE'
|
||||
|
||||
def test_get_tenants_data_structure(self):
|
||||
"""Test that tenant data structure matches the contract."""
|
||||
response = self.client.get(
|
||||
self.tenants_url,
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_200_OK and len(response.json()['tenants']) > 0:
|
||||
tenant = response.json()['tenants'][0]
|
||||
|
||||
# Required fields according to contract
|
||||
required_fields = [
|
||||
'id', 'name', 'slug', 'email', 'phone', 'business_type',
|
||||
'subscription_plan', 'pricing_model', 'status', 'created_at'
|
||||
]
|
||||
|
||||
for field in required_fields:
|
||||
assert field in tenant
|
||||
|
||||
# Field types
|
||||
assert isinstance(tenant['id'], str)
|
||||
assert isinstance(tenant['name'], str)
|
||||
assert isinstance(tenant['slug'], str)
|
||||
assert isinstance(tenant['email'], str)
|
||||
assert tenant['business_type'] in ['RETAIL', 'HEALTHCARE', 'EDUCATION', 'LOGISTICS', 'BEAUTY']
|
||||
assert tenant['subscription_plan'] in ['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']
|
||||
assert tenant['pricing_model'] in ['SUBSCRIPTION', 'PERPETUAL']
|
||||
assert tenant['status'] in ['PENDING', 'ACTIVE', 'SUSPENDED', 'TERMINATED']
|
||||
182
backend/tests/contract/test_tenants_post.py
Normal file
182
backend/tests/contract/test_tenants_post.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
Contract test for POST /tenants endpoint.
|
||||
This test MUST fail before implementation.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
import json
|
||||
|
||||
|
||||
class TenantsPostContractTest(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.tenants_url = '/api/v1/tenants/'
|
||||
|
||||
# Admin authentication header
|
||||
self.admin_auth = {'HTTP_AUTHORIZATION': 'Bearer admin_token'}
|
||||
|
||||
# Valid tenant data
|
||||
self.tenant_data = {
|
||||
'name': 'Test Business Sdn Bhd',
|
||||
'email': 'business@test.com',
|
||||
'phone': '+60123456789',
|
||||
'address': {
|
||||
'street': '123 Business Street',
|
||||
'city': 'Kuala Lumpur',
|
||||
'state': 'Wilayah Persekutuan',
|
||||
'postal_code': '50000',
|
||||
'country': 'Malaysia'
|
||||
},
|
||||
'business_type': 'RETAIL',
|
||||
'subscription_plan': 'STARTER',
|
||||
'pricing_model': 'SUBSCRIPTION'
|
||||
}
|
||||
|
||||
def test_create_tenant_success(self):
|
||||
"""Test successful tenant creation."""
|
||||
response = self.client.post(
|
||||
self.tenants_url,
|
||||
data=json.dumps(self.tenant_data),
|
||||
content_type='application/json',
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
# This should fail before implementation
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
data = response.json()
|
||||
assert 'id' in data
|
||||
assert data['name'] == self.tenant_data['name']
|
||||
assert data['email'] == self.tenant_data['email']
|
||||
assert data['business_type'] == self.tenant_data['business_type']
|
||||
assert data['subscription_plan'] == self.tenant_data['subscription_plan']
|
||||
assert data['pricing_model'] == self.tenant_data['pricing_model']
|
||||
assert data['status'] == 'PENDING' # Default status
|
||||
|
||||
# Should have generated slug
|
||||
assert 'slug' in data
|
||||
assert data['slug'] == 'test-business-sdn-bhd'
|
||||
|
||||
# Should have timestamps
|
||||
assert 'created_at' in data
|
||||
assert 'updated_at' in data
|
||||
|
||||
def test_create_tenant_unauthorized(self):
|
||||
"""Test tenant creation without authentication."""
|
||||
response = self.client.post(
|
||||
self.tenants_url,
|
||||
data=json.dumps(self.tenant_data),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
def test_create_tenant_forbidden(self):
|
||||
"""Test tenant creation by non-admin user."""
|
||||
non_admin_auth = {'HTTP_AUTHORIZATION': 'Bearer user_token'}
|
||||
|
||||
response = self.client.post(
|
||||
self.tenants_url,
|
||||
data=json.dumps(self.tenant_data),
|
||||
content_type='application/json',
|
||||
**non_admin_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_create_tenant_missing_required_fields(self):
|
||||
"""Test tenant creation with missing required fields."""
|
||||
incomplete_data = self.tenant_data.copy()
|
||||
del incomplete_data['name']
|
||||
|
||||
response = self.client.post(
|
||||
self.tenants_url,
|
||||
data=json.dumps(incomplete_data),
|
||||
content_type='application/json',
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
data = response.json()
|
||||
assert 'name' in data.get('errors', {})
|
||||
|
||||
def test_create_tenant_invalid_email(self):
|
||||
"""Test tenant creation with invalid email format."""
|
||||
invalid_data = self.tenant_data.copy()
|
||||
invalid_data['email'] = 'invalid-email'
|
||||
|
||||
response = self.client.post(
|
||||
self.tenants_url,
|
||||
data=json.dumps(invalid_data),
|
||||
content_type='application/json',
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_tenant_invalid_business_type(self):
|
||||
"""Test tenant creation with invalid business type."""
|
||||
invalid_data = self.tenant_data.copy()
|
||||
invalid_data['business_type'] = 'INVALID_TYPE'
|
||||
|
||||
response = self.client.post(
|
||||
self.tenants_url,
|
||||
data=json.dumps(invalid_data),
|
||||
content_type='application/json',
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_tenant_duplicate_email(self):
|
||||
"""Test tenant creation with duplicate email."""
|
||||
# First request should succeed (if implemented)
|
||||
first_response = self.client.post(
|
||||
self.tenants_url,
|
||||
data=json.dumps(self.tenant_data),
|
||||
content_type='application/json',
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
if first_response.status_code == status.HTTP_201_CREATED:
|
||||
# Second request with same email should fail
|
||||
second_response = self.client.post(
|
||||
self.tenants_url,
|
||||
data=json.dumps(self.tenant_data),
|
||||
content_type='application/json',
|
||||
**self.admin_auth
|
||||
)
|
||||
assert second_response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_tenant_invalid_address(self):
|
||||
"""Test tenant creation with invalid address format."""
|
||||
invalid_data = self.tenant_data.copy()
|
||||
invalid_data['address'] = 'invalid address format'
|
||||
|
||||
response = self.client.post(
|
||||
self.tenants_url,
|
||||
data=json.dumps(invalid_data),
|
||||
content_type='application/json',
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_tenant_malformed_address(self):
|
||||
"""Test tenant creation with malformed address JSON."""
|
||||
invalid_data = self.tenant_data.copy()
|
||||
invalid_data['address'] = {'street': '123 Street'} # Missing required fields
|
||||
|
||||
response = self.client.post(
|
||||
self.tenants_url,
|
||||
data=json.dumps(invalid_data),
|
||||
content_type='application/json',
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
185
backend/tests/contract/test_users_get.py
Normal file
185
backend/tests/contract/test_users_get.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Contract test for GET /users endpoint.
|
||||
This test MUST fail before implementation.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
import json
|
||||
|
||||
|
||||
class UsersGetContractTest(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.users_url = '/api/v1/users/'
|
||||
|
||||
# Admin authentication header
|
||||
self.admin_auth = {'HTTP_AUTHORIZATION': 'Bearer admin_token'}
|
||||
|
||||
# Regular user authentication header
|
||||
self.user_auth = {'HTTP_AUTHORIZATION': 'Bearer user_token'}
|
||||
|
||||
def test_get_users_success_admin(self):
|
||||
"""Test successful retrieval of users list by admin."""
|
||||
response = self.client.get(
|
||||
self.users_url,
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
# This should fail before implementation
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
assert 'users' in data
|
||||
assert isinstance(data['users'], list)
|
||||
|
||||
# Check pagination structure
|
||||
assert 'pagination' in data
|
||||
pagination = data['pagination']
|
||||
assert 'page' in pagination
|
||||
assert 'limit' in pagination
|
||||
assert 'total' in pagination
|
||||
assert 'pages' in pagination
|
||||
|
||||
def test_get_users_success_tenant_admin(self):
|
||||
"""Test successful retrieval of users list by tenant admin."""
|
||||
response = self.client.get(
|
||||
self.users_url,
|
||||
**self.user_auth
|
||||
)
|
||||
|
||||
# This should fail before implementation
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
assert 'users' in data
|
||||
assert isinstance(data['users'], list)
|
||||
|
||||
# Tenant admin should only see users from their tenant
|
||||
# This will be validated once implementation exists
|
||||
|
||||
def test_get_users_unauthorized(self):
|
||||
"""Test users list retrieval without authentication."""
|
||||
response = self.client.get(self.users_url)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
def test_get_users_with_pagination(self):
|
||||
"""Test users list retrieval with pagination parameters."""
|
||||
params = {
|
||||
'page': 2,
|
||||
'limit': 10
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.users_url,
|
||||
data=params,
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
assert data['pagination']['page'] == 2
|
||||
assert data['pagination']['limit'] == 10
|
||||
|
||||
def test_get_users_with_search(self):
|
||||
"""Test users list retrieval with search parameter."""
|
||||
params = {
|
||||
'search': 'john'
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.users_url,
|
||||
data=params,
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
# All returned users should match search criteria
|
||||
for user in data['users']:
|
||||
assert 'john' in user['name'].lower() or 'john' in user['email'].lower()
|
||||
|
||||
def test_get_users_filter_by_role(self):
|
||||
"""Test users list retrieval filtered by role."""
|
||||
params = {
|
||||
'role': 'TENANT_ADMIN'
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.users_url,
|
||||
data=params,
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
# All returned users should have the specified role
|
||||
for user in data['users']:
|
||||
assert user['role'] == 'TENANT_ADMIN'
|
||||
|
||||
def test_get_users_filter_by_status(self):
|
||||
"""Test users list retrieval filtered by status."""
|
||||
params = {
|
||||
'status': 'ACTIVE'
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
self.users_url,
|
||||
data=params,
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
# All returned users should have the specified status
|
||||
for user in data['users']:
|
||||
assert user['status'] == 'ACTIVE'
|
||||
|
||||
def test_get_users_tenant_isolation(self):
|
||||
"""Test that tenant admin can only see users from their tenant."""
|
||||
# This test verifies tenant isolation for user data
|
||||
response = self.client.get(
|
||||
self.users_url,
|
||||
**self.user_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_200_OK:
|
||||
data = response.json()
|
||||
# For tenant users, all returned users should belong to their tenant
|
||||
# This will be validated once implementation exists
|
||||
pass
|
||||
|
||||
def test_get_users_data_structure(self):
|
||||
"""Test that user data structure matches the contract."""
|
||||
response = self.client.get(
|
||||
self.users_url,
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_200_OK and len(response.json()['users']) > 0:
|
||||
user = response.json()['users'][0]
|
||||
|
||||
# Required fields according to contract
|
||||
required_fields = [
|
||||
'id', 'email', 'name', 'role', 'status',
|
||||
'tenant_id', 'created_at', 'last_login'
|
||||
]
|
||||
|
||||
for field in required_fields:
|
||||
assert field in user
|
||||
|
||||
# Field types and enums
|
||||
assert isinstance(user['id'], str)
|
||||
assert isinstance(user['email'], str)
|
||||
assert isinstance(user['name'], str)
|
||||
assert user['role'] in ['SUPER_ADMIN', 'TENANT_ADMIN', 'MANAGER', 'STAFF', 'VIEWER']
|
||||
assert user['status'] in ['ACTIVE', 'INACTIVE', 'PENDING', 'SUSPENDED']
|
||||
assert isinstance(user['tenant_id'], str)
|
||||
251
backend/tests/contract/test_users_post.py
Normal file
251
backend/tests/contract/test_users_post.py
Normal file
@@ -0,0 +1,251 @@
|
||||
"""
|
||||
Contract test for POST /users endpoint.
|
||||
This test MUST fail before implementation.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
import json
|
||||
|
||||
|
||||
class UsersPostContractTest(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.users_url = '/api/v1/users/'
|
||||
|
||||
# Admin authentication header
|
||||
self.admin_auth = {'HTTP_AUTHORIZATION': 'Bearer admin_token'}
|
||||
|
||||
# Tenant admin authentication header
|
||||
self.tenant_admin_auth = {'HTTP_AUTHORIZATION': 'Bearer tenant_admin_token'}
|
||||
|
||||
# Valid user data
|
||||
self.user_data = {
|
||||
'email': 'john.doe@example.com',
|
||||
'name': 'John Doe',
|
||||
'password': 'SecurePassword123!',
|
||||
'role': 'STAFF',
|
||||
'department': 'Operations',
|
||||
'phone': '+60123456789',
|
||||
'profile': {
|
||||
'position': 'Manager',
|
||||
'skills': ['leadership', 'operations'],
|
||||
'experience_years': 5
|
||||
}
|
||||
}
|
||||
|
||||
def test_create_user_success_admin(self):
|
||||
"""Test successful user creation by super admin."""
|
||||
response = self.client.post(
|
||||
self.users_url,
|
||||
data=json.dumps(self.user_data),
|
||||
content_type='application/json',
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
# This should fail before implementation
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
data = response.json()
|
||||
assert 'id' in data
|
||||
assert data['email'] == self.user_data['email']
|
||||
assert data['name'] == self.user_data['name']
|
||||
assert data['role'] == self.user_data['role']
|
||||
assert data['status'] == 'PENDING' # Default status for new users
|
||||
assert data['department'] == self.user_data['department']
|
||||
assert data['phone'] == self.user_data['phone']
|
||||
|
||||
# Should have generated tenant_id from context
|
||||
assert 'tenant_id' in data
|
||||
|
||||
# Should have timestamps
|
||||
assert 'created_at' in data
|
||||
assert 'updated_at' in data
|
||||
|
||||
# Password should not be returned
|
||||
assert 'password' not in data
|
||||
|
||||
def test_create_user_success_tenant_admin(self):
|
||||
"""Test successful user creation by tenant admin."""
|
||||
response = self.client.post(
|
||||
self.users_url,
|
||||
data=json.dumps(self.user_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_admin_auth
|
||||
)
|
||||
|
||||
# This should fail before implementation
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
data = response.json()
|
||||
assert 'id' in data
|
||||
assert data['email'] == self.user_data['email']
|
||||
assert data['name'] == self.user_data['name']
|
||||
|
||||
# Tenant admin cannot create SUPER_ADMIN users
|
||||
# This will be validated once implementation exists
|
||||
|
||||
def test_create_user_unauthorized(self):
|
||||
"""Test user creation without authentication."""
|
||||
response = self.client.post(
|
||||
self.users_url,
|
||||
data=json.dumps(self.user_data),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
def test_create_user_forbidden(self):
|
||||
"""Test user creation by regular user (no permissions)."""
|
||||
user_auth = {'HTTP_AUTHORIZATION': 'Bearer user_token'}
|
||||
|
||||
response = self.client.post(
|
||||
self.users_url,
|
||||
data=json.dumps(self.user_data),
|
||||
content_type='application/json',
|
||||
**user_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_create_user_missing_required_fields(self):
|
||||
"""Test user creation with missing required fields."""
|
||||
incomplete_data = self.user_data.copy()
|
||||
del incomplete_data['email']
|
||||
|
||||
response = self.client.post(
|
||||
self.users_url,
|
||||
data=json.dumps(incomplete_data),
|
||||
content_type='application/json',
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
data = response.json()
|
||||
assert 'email' in data.get('errors', {})
|
||||
|
||||
def test_create_user_invalid_email(self):
|
||||
"""Test user creation with invalid email format."""
|
||||
invalid_data = self.user_data.copy()
|
||||
invalid_data['email'] = 'invalid-email'
|
||||
|
||||
response = self.client.post(
|
||||
self.users_url,
|
||||
data=json.dumps(invalid_data),
|
||||
content_type='application/json',
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_user_weak_password(self):
|
||||
"""Test user creation with weak password."""
|
||||
invalid_data = self.user_data.copy()
|
||||
invalid_data['password'] = '123'
|
||||
|
||||
response = self.client.post(
|
||||
self.users_url,
|
||||
data=json.dumps(invalid_data),
|
||||
content_type='application/json',
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_user_invalid_role(self):
|
||||
"""Test user creation with invalid role."""
|
||||
invalid_data = self.user_data.copy()
|
||||
invalid_data['role'] = 'INVALID_ROLE'
|
||||
|
||||
response = self.client.post(
|
||||
self.users_url,
|
||||
data=json.dumps(invalid_data),
|
||||
content_type='application/json',
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_user_duplicate_email(self):
|
||||
"""Test user creation with duplicate email."""
|
||||
# First request should succeed (if implemented)
|
||||
first_response = self.client.post(
|
||||
self.users_url,
|
||||
data=json.dumps(self.user_data),
|
||||
content_type='application/json',
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
if first_response.status_code == status.HTTP_201_CREATED:
|
||||
# Second request with same email should fail
|
||||
second_response = self.client.post(
|
||||
self.users_url,
|
||||
data=json.dumps(self.user_data),
|
||||
content_type='application/json',
|
||||
**self.admin_auth
|
||||
)
|
||||
assert second_response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_user_tenant_admin_cannot_create_super_admin(self):
|
||||
"""Test that tenant admin cannot create super admin users."""
|
||||
super_admin_data = self.user_data.copy()
|
||||
super_admin_data['role'] = 'SUPER_ADMIN'
|
||||
|
||||
response = self.client.post(
|
||||
self.users_url,
|
||||
data=json.dumps(super_admin_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_admin_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_create_user_with_profile_data(self):
|
||||
"""Test user creation with profile information."""
|
||||
response = self.client.post(
|
||||
self.users_url,
|
||||
data=json.dumps(self.user_data),
|
||||
content_type='application/json',
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
data = response.json()
|
||||
assert 'profile' in data
|
||||
profile = data['profile']
|
||||
assert profile['position'] == self.user_data['profile']['position']
|
||||
assert profile['skills'] == self.user_data['profile']['skills']
|
||||
assert profile['experience_years'] == self.user_data['profile']['experience_years']
|
||||
|
||||
def test_create_user_malformed_profile(self):
|
||||
"""Test user creation with malformed profile JSON."""
|
||||
invalid_data = self.user_data.copy()
|
||||
invalid_data['profile'] = 'invalid profile format'
|
||||
|
||||
response = self.client.post(
|
||||
self.users_url,
|
||||
data=json.dumps(invalid_data),
|
||||
content_type='application/json',
|
||||
**self.admin_auth
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_create_user_tenant_isolation(self):
|
||||
"""Test that user creation respects tenant isolation."""
|
||||
response = self.client.post(
|
||||
self.users_url,
|
||||
data=json.dumps(self.user_data),
|
||||
content_type='application/json',
|
||||
**self.tenant_admin_auth
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
data = response.json()
|
||||
# User should be created in the tenant admin's tenant
|
||||
# This will be validated once implementation exists
|
||||
assert 'tenant_id' in data
|
||||
Reference in New Issue
Block a user