mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 11:31:07 -05:00
342 lines
11 KiB
Python
342 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."""
|