""" 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."""