mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 14:31:08 -05:00
419 lines
17 KiB
Python
419 lines
17 KiB
Python
"""
|
|
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)
|