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