Files
thrillwiki_django_no_react/backend/tests/test_utils.py
pacnpal d504d41de2 feat: complete monorepo structure with frontend and shared resources
- Add complete backend/ directory with full Django application
- Add frontend/ directory with Vite + TypeScript setup ready for Next.js
- Add comprehensive shared/ directory with:
  - Complete documentation and memory-bank archives
  - Media files and avatars (letters, park/ride images)
  - Deployment scripts and automation tools
  - Shared types and utilities
- Add architecture/ directory with migration guides
- Configure pnpm workspace for monorepo development
- Update .gitignore to exclude .django_tailwind_cli/ build artifacts
- Preserve all historical documentation in shared/docs/memory-bank/
- Set up proper structure for full-stack development with shared resources
2025-08-23 18:40:07 -04:00

343 lines
11 KiB
Python

"""
Test utilities and helpers following Django styleguide patterns.
Provides reusable testing patterns and assertion helpers.
"""
from typing import Dict, Any, Optional, List
from datetime import date, datetime
from django.test import TestCase
from django.contrib.auth import get_user_model
from rest_framework.test import APITestCase
from rest_framework import status
User = get_user_model()
class ApiTestMixin:
"""Mixin providing common API testing utilities."""
def assertApiResponse(
self,
response,
*,
status_code: int = status.HTTP_200_OK,
response_status: str = "success",
data_type: Optional[type] = None,
contains_fields: Optional[List[str]] = None,
):
"""
Assert API response has correct structure and content.
Args:
response: DRF Response object
status_code: Expected HTTP status code
response_status: Expected response status ('success' or 'error')
data_type: Expected type of response data (list, dict, etc.)
contains_fields: List of fields that should be in response data
"""
self.assertEqual(response.status_code, status_code)
self.assertEqual(response.data["status"], response_status)
if response_status == "success":
self.assertIn("data", response.data)
if data_type:
self.assertIsInstance(response.data["data"], data_type)
if contains_fields and response.data["data"]:
data = response.data["data"]
# Handle both single objects and lists
if isinstance(data, list) and data:
data = data[0]
if isinstance(data, dict):
for field in contains_fields:
self.assertIn(
field,
data,
f"Field '{field}' missing from response data",
)
elif response_status == "error":
self.assertIn("error", response.data)
self.assertIn("code", response.data["error"])
self.assertIn("message", response.data["error"])
def assertApiError(
self,
response,
*,
status_code: int,
error_code: Optional[str] = None,
message_contains: Optional[str] = None,
):
"""
Assert API response is an error with specific characteristics.
Args:
response: DRF Response object
status_code: Expected HTTP status code
error_code: Expected error code in response
message_contains: String that should be in error message
"""
self.assertApiResponse(
response, status_code=status_code, response_status="error"
)
if error_code:
self.assertEqual(response.data["error"]["code"], error_code)
if message_contains:
self.assertIn(message_contains, response.data["error"]["message"])
def assertPaginatedResponse(
self,
response,
*,
expected_count: Optional[int] = None,
has_next: Optional[bool] = None,
has_previous: Optional[bool] = None,
):
"""
Assert API response has correct pagination structure.
Args:
response: DRF Response object
expected_count: Expected number of items in current page
has_next: Whether pagination should have next page
has_previous: Whether pagination should have previous page
"""
self.assertApiResponse(response, data_type=list)
self.assertIn("pagination", response.data)
pagination = response.data["pagination"]
required_fields = [
"page",
"page_size",
"total_pages",
"total_count",
"has_next",
"has_previous",
]
for field in required_fields:
self.assertIn(field, pagination)
if expected_count is not None:
self.assertEqual(len(response.data["data"]), expected_count)
if has_next is not None:
self.assertEqual(pagination["has_next"], has_next)
if has_previous is not None:
self.assertEqual(pagination["has_previous"], has_previous)
class ModelTestMixin:
"""Mixin providing common model testing utilities."""
def assertModelFields(self, instance, expected_fields: Dict[str, Any]):
"""
Assert model instance has expected field values.
Args:
instance: Model instance
expected_fields: Dict of field_name: expected_value
"""
for field_name, expected_value in expected_fields.items():
actual_value = getattr(instance, field_name)
self.assertEqual(
actual_value,
expected_value,
f"Field '{field_name}' expected {expected_value}, got {actual_value}",
)
def assertModelValidation(
self,
model_class,
invalid_data: Dict[str, Any],
expected_errors: List[str],
):
"""
Assert model validation catches expected errors.
Args:
model_class: Model class to test
invalid_data: Data that should cause validation errors
expected_errors: List of error messages that should be raised
"""
instance = model_class(**invalid_data)
with self.assertRaises(Exception) as context:
instance.full_clean()
exception_str = str(context.exception)
for expected_error in expected_errors:
self.assertIn(expected_error, exception_str)
def assertDatabaseConstraint(self, model_factory, invalid_data: Dict[str, Any]):
"""
Assert database constraint is enforced.
Args:
model_factory: Factory class for creating model instances
invalid_data: Data that should violate database constraints
"""
from django.db import IntegrityError
with self.assertRaises(IntegrityError):
model_factory(**invalid_data)
class FactoryTestMixin:
"""Mixin providing factory testing utilities."""
def assertFactoryCreatesValidInstance(self, factory_class, **kwargs):
"""
Assert factory creates valid model instance.
Args:
factory_class: Factory class to test
**kwargs: Additional factory parameters
"""
instance = factory_class(**kwargs)
# Basic assertions
self.assertIsNotNone(instance.id)
self.assertIsNotNone(instance.created_at)
# Run full_clean to ensure validity
instance.full_clean()
return instance
def assertFactoryBatchCreation(self, factory_class, count: int = 5, **kwargs):
"""
Assert factory can create multiple valid instances.
Args:
factory_class: Factory class to test
count: Number of instances to create
**kwargs: Additional factory parameters
"""
instances = factory_class.create_batch(count, **kwargs)
self.assertEqual(len(instances), count)
for instance in instances:
self.assertIsNotNone(instance.id)
instance.full_clean()
return instances
class TimestampTestMixin:
"""Mixin for testing timestamp-related functionality."""
def assertRecentTimestamp(self, timestamp, tolerance_seconds: int = 5):
"""
Assert timestamp is recent (within tolerance).
Args:
timestamp: Timestamp to check
tolerance_seconds: Allowed difference in seconds
"""
from django.utils import timezone
now = timezone.now()
if isinstance(timestamp, date) and not isinstance(timestamp, datetime):
# Convert date to datetime for comparison
timestamp = datetime.combine(timestamp, datetime.min.time())
timestamp = timezone.make_aware(timestamp)
time_diff = abs((now - timestamp).total_seconds())
self.assertLessEqual(
time_diff,
tolerance_seconds,
f"Timestamp {timestamp} is not recent (diff: {time_diff}s)",
)
def assertTimestampOrder(self, earlier_timestamp, later_timestamp):
"""
Assert timestamps are in correct order.
Args:
earlier_timestamp: Should be before later_timestamp
later_timestamp: Should be after earlier_timestamp
"""
self.assertLess(
earlier_timestamp,
later_timestamp,
f"Timestamps not in order: {earlier_timestamp} should be before {later_timestamp}",
)
class GeographyTestMixin:
"""Mixin for testing geography-related functionality."""
def assertValidCoordinates(self, latitude: float, longitude: float):
"""
Assert coordinates are within valid ranges.
Args:
latitude: Latitude value
longitude: Longitude value
"""
self.assertGreaterEqual(latitude, -90, "Latitude below valid range")
self.assertLessEqual(latitude, 90, "Latitude above valid range")
self.assertGreaterEqual(longitude, -180, "Longitude below valid range")
self.assertLessEqual(longitude, 180, "Longitude above valid range")
def assertCoordinateDistance(
self, point1: tuple, point2: tuple, max_distance_km: float
):
"""
Assert two geographic points are within specified distance.
Args:
point1: (latitude, longitude) tuple
point2: (latitude, longitude) tuple
max_distance_km: Maximum allowed distance in kilometers
"""
from math import radians, cos, sin, asin, sqrt
lat1, lon1 = point1
lat2, lon2 = point2
# Haversine formula for great circle distance
lat1, lon1, lat2, lon2 = map(radians, [lat1, lon1, lat2, lon2])
dlat = lat2 - lat1
dlon = lon2 - lon1
a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2
c = 2 * asin(sqrt(a))
distance_km = 6371 * c # Earth's radius in km
self.assertLessEqual(
distance_km,
max_distance_km,
f"Points are {
distance_km:.2f}km apart, exceeds {max_distance_km}km",
)
class EnhancedTestCase(
ApiTestMixin,
ModelTestMixin,
FactoryTestMixin,
TimestampTestMixin,
GeographyTestMixin,
TestCase,
):
"""Enhanced TestCase with all testing mixins."""
class EnhancedAPITestCase(
ApiTestMixin,
ModelTestMixin,
FactoryTestMixin,
TimestampTestMixin,
GeographyTestMixin,
APITestCase,
):
"""Enhanced APITestCase with all testing mixins."""