""" Contract Compliance Tests for ThrillWiki API These tests verify that API responses match frontend TypeScript interfaces exactly, preventing runtime errors and ensuring type safety. """ from django.test import TestCase, Client from rest_framework.test import APITestCase from apps.parks.services.hybrid_loader import smart_park_loader from apps.rides.services.hybrid_loader import SmartRideLoader from apps.api.v1.serializers.shared import ( validate_filter_metadata_contract, ensure_filter_option_format, ensure_range_format ) class FilterMetadataContractTests(TestCase): """Test that filter metadata follows the expected contract.""" def setUp(self): self.client = Client() def test_parks_filter_metadata_structure(self): """Test that parks filter metadata has correct structure.""" # Get filter metadata from the service metadata = smart_park_loader.get_filter_metadata() # Should have required top-level keys self.assertIn('categorical', metadata) self.assertIn('ranges', metadata) self.assertIn('total_count', metadata) # Categorical filters should be objects with value/label/count categorical = metadata['categorical'] self.assertIsInstance(categorical, dict) for filter_name, filter_options in categorical.items(): with self.subTest(filter_name=filter_name): self.assertIsInstance(filter_options, list, f"Filter '{filter_name}' should be a list") for i, option in enumerate(filter_options): with self.subTest(filter_name=filter_name, option_index=i): self.assertIsInstance(option, dict, f"Filter '{filter_name}' option {i} should be an object, not {type(option).__name__}") # Check required properties self.assertIn('value', option, f"Filter '{filter_name}' option {i} missing 'value' property") self.assertIn('label', option, f"Filter '{filter_name}' option {i} missing 'label' property") # Check types self.assertIsInstance(option['value'], str, f"Filter '{filter_name}' option {i} 'value' should be string") self.assertIsInstance(option['label'], str, f"Filter '{filter_name}' option {i} 'label' should be string") # Count is optional but should be int if present if 'count' in option and option['count'] is not None: self.assertIsInstance(option['count'], int, f"Filter '{filter_name}' option {i} 'count' should be int") def test_rides_filter_metadata_structure(self): """Test that rides filter metadata has correct structure.""" loader = SmartRideLoader() metadata = loader.get_filter_metadata() # Should have required top-level keys self.assertIn('categorical', metadata) self.assertIn('ranges', metadata) self.assertIn('total_count', metadata) # Categorical filters should be objects with value/label/count categorical = metadata['categorical'] self.assertIsInstance(categorical, dict) # Test specific categorical filters that were problematic critical_filters = ['categories', 'statuses', 'roller_coaster_types', 'track_materials'] for filter_name in critical_filters: if filter_name in categorical: with self.subTest(filter_name=filter_name): filter_options = categorical[filter_name] self.assertIsInstance(filter_options, list) for i, option in enumerate(filter_options): with self.subTest(filter_name=filter_name, option_index=i): self.assertIsInstance(option, dict, f"CRITICAL: Filter '{filter_name}' option {i} is {type(option).__name__} but should be dict") self.assertIn('value', option) self.assertIn('label', option) self.assertIn('count', option) def test_range_metadata_structure(self): """Test that range metadata has correct structure.""" # Test parks ranges parks_metadata = smart_park_loader.get_filter_metadata() ranges = parks_metadata['ranges'] for range_name, range_data in ranges.items(): with self.subTest(range_name=range_name): self.assertIsInstance(range_data, dict, f"Range '{range_name}' should be an object") # Check required properties self.assertIn('min', range_data) self.assertIn('max', range_data) self.assertIn('step', range_data) self.assertIn('unit', range_data) # Check types (min/max can be None) if range_data['min'] is not None: self.assertIsInstance(range_data['min'], (int, float)) if range_data['max'] is not None: self.assertIsInstance(range_data['max'], (int, float)) self.assertIsInstance(range_data['step'], (int, float)) # Unit can be None or string if range_data['unit'] is not None: self.assertIsInstance(range_data['unit'], str) class ContractValidationUtilityTests(TestCase): """Test contract validation utility functions.""" def test_validate_filter_metadata_contract_valid(self): """Test validation passes for valid filter metadata.""" valid_metadata = { 'categorical': { 'statuses': [ {'value': 'OPERATING', 'label': 'Operating', 'count': 5}, {'value': 'CLOSED_TEMP', 'label': 'Temporarily Closed', 'count': 2} ] }, 'ranges': { 'rating': { 'min': 1.0, 'max': 10.0, 'step': 0.1, 'unit': 'stars' } }, 'total_count': 100 } # Should not raise an exception validated = validate_filter_metadata_contract(valid_metadata) self.assertIsInstance(validated, dict) self.assertEqual(validated['total_count'], 100) def test_validate_filter_metadata_contract_invalid(self): """Test validation fails for invalid filter metadata.""" from rest_framework import serializers invalid_metadata = { 'categorical': { 'statuses': ['OPERATING', 'CLOSED_TEMP'] # Should be objects, not strings }, 'ranges': {}, 'total_count': 100 } # Should raise ValidationError with self.assertRaises(serializers.ValidationError): validate_filter_metadata_contract(invalid_metadata) def test_ensure_filter_option_format_strings(self): """Test converting string arrays to proper format.""" string_options = ['OPERATING', 'CLOSED_TEMP', 'UNDER_CONSTRUCTION'] formatted = ensure_filter_option_format(string_options) self.assertEqual(len(formatted), 3) for i, option in enumerate(formatted): self.assertIsInstance(option, dict) self.assertIn('value', option) self.assertIn('label', option) self.assertIn('count', option) self.assertIn('selected', option) self.assertEqual(option['value'], string_options[i]) self.assertEqual(option['label'], string_options[i]) self.assertIsNone(option['count']) self.assertFalse(option['selected']) def test_ensure_filter_option_format_tuples(self): """Test converting tuple arrays to proper format.""" tuple_options = [ ('OPERATING', 'Operating', 5), ('CLOSED_TEMP', 'Temporarily Closed', 2) ] formatted = ensure_filter_option_format(tuple_options) self.assertEqual(len(formatted), 2) self.assertEqual(formatted[0]['value'], 'OPERATING') self.assertEqual(formatted[0]['label'], 'Operating') self.assertEqual(formatted[0]['count'], 5) self.assertEqual(formatted[1]['value'], 'CLOSED_TEMP') self.assertEqual(formatted[1]['label'], 'Temporarily Closed') self.assertEqual(formatted[1]['count'], 2) def test_ensure_filter_option_format_dicts(self): """Test that properly formatted dicts pass through correctly.""" dict_options = [ {'value': 'OPERATING', 'label': 'Operating', 'count': 5}, {'value': 'CLOSED_TEMP', 'label': 'Temporarily Closed', 'count': 2} ] formatted = ensure_filter_option_format(dict_options) self.assertEqual(len(formatted), 2) self.assertEqual(formatted[0]['value'], 'OPERATING') self.assertEqual(formatted[0]['label'], 'Operating') self.assertEqual(formatted[0]['count'], 5) def test_ensure_range_format(self): """Test range format utility.""" range_data = { 'min': 1.0, 'max': 10.0, 'step': 0.5, 'unit': 'stars' } formatted = ensure_range_format(range_data) self.assertEqual(formatted['min'], 1.0) self.assertEqual(formatted['max'], 10.0) self.assertEqual(formatted['step'], 0.5) self.assertEqual(formatted['unit'], 'stars') def test_ensure_range_format_missing_step(self): """Test range format with missing step defaults to 1.0.""" range_data = { 'min': 1, 'max': 10 } formatted = ensure_range_format(range_data) self.assertEqual(formatted['step'], 1.0) self.assertIsNone(formatted['unit']) class APIEndpointContractTests(APITestCase): """Test actual API endpoints for contract compliance.""" def test_parks_hybrid_endpoint_contract(self): """Test parks hybrid endpoint returns proper contract.""" # This would require actual data in the database # For now, we'll test the structure pass def test_rides_hybrid_endpoint_contract(self): """Test rides hybrid endpoint returns proper contract.""" # This would require actual data in the database # For now, we'll test the structure pass class TypeScriptInterfaceComplianceTests(TestCase): """Test that responses match TypeScript interfaces exactly.""" def test_filter_option_interface_compliance(self): """Test FilterOption interface compliance.""" # TypeScript interface: # interface FilterOption { # value: string; # label: string; # count?: number; # selected?: boolean; # } option = { 'value': 'OPERATING', 'label': 'Operating', 'count': 5, 'selected': False } # All required fields present self.assertIn('value', option) self.assertIn('label', option) # Correct types self.assertIsInstance(option['value'], str) self.assertIsInstance(option['label'], str) # Optional fields have correct types if present if 'count' in option and option['count'] is not None: self.assertIsInstance(option['count'], int) if 'selected' in option: self.assertIsInstance(option['selected'], bool) def test_filter_range_interface_compliance(self): """Test FilterRange interface compliance.""" # TypeScript interface: # interface FilterRange { # min: number; # max: number; # step: number; # unit?: string; # } range_data = { 'min': 1.0, 'max': 10.0, 'step': 0.1, 'unit': 'stars' } # All required fields present self.assertIn('min', range_data) self.assertIn('max', range_data) self.assertIn('step', range_data) # Correct types (min/max can be null) if range_data['min'] is not None: self.assertIsInstance(range_data['min'], (int, float)) if range_data['max'] is not None: self.assertIsInstance(range_data['max'], (int, float)) self.assertIsInstance(range_data['step'], (int, float)) # Optional unit field if 'unit' in range_data and range_data['unit'] is not None: self.assertIsInstance(range_data['unit'], str) class RegressionTests(TestCase): """Regression tests for specific contract violations that were fixed.""" def test_categorical_filters_not_strings(self): """Regression test: Ensure categorical filters are never returned as strings.""" # This was the main issue - categorical filters were returned as: # ['OPERATING', 'CLOSED_TEMP'] instead of # [{'value': 'OPERATING', 'label': 'Operating', 'count': 5}, ...] # Test parks parks_metadata = smart_park_loader.get_filter_metadata() categorical = parks_metadata.get('categorical', {}) for filter_name, filter_options in categorical.items(): with self.subTest(filter_name=filter_name): self.assertIsInstance(filter_options, list) for i, option in enumerate(filter_options): with self.subTest(filter_name=filter_name, option_index=i): self.assertIsInstance(option, dict, f"REGRESSION: Filter '{filter_name}' option {i} is a {type(option).__name__} " f"but should be a dict. This causes frontend crashes!") # Must not be a string self.assertNotIsInstance(option, str, f"CRITICAL REGRESSION: Filter '{filter_name}' option {i} is a string '{option}' " f"but frontend expects object with value/label/count properties!") # Test rides rides_loader = SmartRideLoader() rides_metadata = rides_loader.get_filter_metadata() categorical = rides_metadata.get('categorical', {}) for filter_name, filter_options in categorical.items(): with self.subTest(filter_name=f"rides_{filter_name}"): self.assertIsInstance(filter_options, list) for i, option in enumerate(filter_options): with self.subTest(filter_name=f"rides_{filter_name}", option_index=i): self.assertIsInstance(option, dict, f"REGRESSION: Rides filter '{filter_name}' option {i} is a {type(option).__name__} " f"but should be a dict. This causes frontend crashes!") def test_ranges_have_step_and_unit(self): """Regression test: Ensure ranges have step and unit properties.""" # Frontend expects: { min: number, max: number, step: number, unit?: string } # Backend was sometimes missing step and unit parks_metadata = smart_park_loader.get_filter_metadata() ranges = parks_metadata.get('ranges', {}) for range_name, range_data in ranges.items(): with self.subTest(range_name=range_name): self.assertIn('step', range_data, f"Range '{range_name}' missing 'step' property required by frontend") self.assertIn('unit', range_data, f"Range '{range_name}' missing 'unit' property required by frontend") # Step should be a number self.assertIsInstance(range_data['step'], (int, float), f"Range '{range_name}' step should be a number") def test_no_undefined_values(self): """Regression test: Ensure no undefined values (should be null).""" # JavaScript undefined !== null, and TypeScript interfaces expect null parks_metadata = smart_park_loader.get_filter_metadata() def check_no_undefined(obj, path=""): if isinstance(obj, dict): for key, value in obj.items(): current_path = f"{path}.{key}" if path else key # Python None is fine (becomes null in JSON) # But we shouldn't have any undefined-like values check_no_undefined(value, current_path) elif isinstance(obj, list): for i, item in enumerate(obj): current_path = f"{path}[{i}]" check_no_undefined(item, current_path) # This will recursively check the entire metadata structure check_no_undefined(parks_metadata)