mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 07:31:07 -05:00
update
This commit is contained in:
341
tests/test_utils.py
Normal file
341
tests/test_utils.py
Normal file
@@ -0,0 +1,341 @@
|
||||
"""
|
||||
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."""
|
||||
Reference in New Issue
Block a user