""" Test utilities and helpers following Django styleguide patterns. Provides reusable testing patterns and assertion helpers. """ from datetime import date, datetime from typing import Any from django.contrib.auth import get_user_model from django.test import TestCase from rest_framework import status from rest_framework.test import APITestCase 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: type | None = None, contains_fields: list[str] | None = 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: str | None = None, message_contains: str | None = 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: int | None = None, has_next: bool | None = None, has_previous: bool | None = 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 asin, cos, radians, sin, 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."""