# ================================================================ # services/data/tests/conftest.py - AEMET Test Configuration # ================================================================ """ Test configuration and fixtures for AEMET weather API client tests Provides shared fixtures, mock data, and test utilities """ import pytest import asyncio from datetime import datetime, timedelta from unittest.mock import Mock, AsyncMock, patch from typing import Dict, List, Any, Generator import os # Import the classes we're testing from app.external.aemet import ( AEMETClient, WeatherDataParser, SyntheticWeatherGenerator, LocationService, WeatherSource ) # ================================================================ # PYTEST CONFIGURATION # ================================================================ @pytest.fixture(scope="session") def event_loop(): """Create an instance of the default event loop for the test session.""" loop = asyncio.get_event_loop_policy().new_event_loop() yield loop loop.close() # ================================================================ # CLIENT AND SERVICE FIXTURES # ================================================================ @pytest.fixture def aemet_client(): """Create AEMET client instance for testing""" return AEMETClient() @pytest.fixture def weather_parser(): """Create WeatherDataParser instance for testing""" return WeatherDataParser() @pytest.fixture def synthetic_generator(): """Create SyntheticWeatherGenerator instance for testing""" return SyntheticWeatherGenerator() @pytest.fixture def location_service(): """Create LocationService instance for testing""" return LocationService() # ================================================================ # COORDINATE AND LOCATION FIXTURES # ================================================================ @pytest.fixture def madrid_coords(): """Standard Madrid coordinates for testing""" return (40.4168, -3.7038) # Madrid city center @pytest.fixture def madrid_coords_variants(): """Various Madrid area coordinates for testing""" return { "center": (40.4168, -3.7038), # Madrid center "north": (40.4677, -3.5552), # Madrid north (near station) "south": (40.2987, -3.7216), # Madrid south (near station) "east": (40.4200, -3.6500), # Madrid east "west": (40.4100, -3.7500), # Madrid west } @pytest.fixture def invalid_coords(): """Invalid coordinates for error testing""" return [ (200, 200), # Out of range (-200, -200), # Out of range (0, 0), # Not in Madrid area (50, 10), # Europe but not Madrid (None, None), # None values ] # ================================================================ # DATE AND TIME FIXTURES # ================================================================ @pytest.fixture def test_dates(): """Standard date ranges for testing""" now = datetime.now() return { "now": now, "yesterday": now - timedelta(days=1), "last_week": now - timedelta(days=7), "last_month": now - timedelta(days=30), "last_quarter": now - timedelta(days=90), "one_year_ago": now - timedelta(days=365), } @pytest.fixture def historical_date_ranges(): """Historical date ranges for testing""" end_date = datetime.now() return { "one_day": { "start": end_date - timedelta(days=1), "end": end_date, "expected_days": 1 }, "one_week": { "start": end_date - timedelta(days=7), "end": end_date, "expected_days": 7 }, "one_month": { "start": end_date - timedelta(days=30), "end": end_date, "expected_days": 30 }, "large_range": { "start": end_date - timedelta(days=65), "end": end_date, "expected_days": 65 } } # ================================================================ # MOCK API RESPONSE FIXTURES # ================================================================ @pytest.fixture def mock_aemet_api_response(): """Mock AEMET API initial response structure""" return { "datos": "https://opendata.aemet.es/opendata/sh/12345abcdef", "metadatos": "https://opendata.aemet.es/opendata/sh/metadata123" } @pytest.fixture def mock_aemet_error_response(): """Mock AEMET API error response""" return { "descripcion": "Error en la petición", "estado": 404 } # ================================================================ # WEATHER DATA FIXTURES # ================================================================ @pytest.fixture def mock_current_weather_data(): """Mock current weather data from AEMET API""" return { "idema": "3195", # Station ID "ubi": "MADRID", # Location "fint": "2025-07-24T14:00:00", # Observation time "ta": 18.5, # Temperature (°C) "tamin": 12.3, # Min temperature "tamax": 25.7, # Max temperature "hr": 65.0, # Humidity (%) "prec": 0.0, # Precipitation (mm) "vv": 12.0, # Wind speed (km/h) "dv": 180, # Wind direction (degrees) "pres": 1015.2, # Pressure (hPa) "presMax": 1018.5, # Max pressure "presMin": 1012.1, # Min pressure "descripcion": "Despejado" # Description } @pytest.fixture def mock_forecast_data(): """Mock forecast data from AEMET API""" return [{ "origen": { "productor": "Agencia Estatal de Meteorología - AEMET" }, "elaborado": "2025-07-24T12:00:00UTC", "nombre": "Madrid", "provincia": "Madrid", "prediccion": { "dia": [ { "fecha": "2025-07-25T00:00:00", "temperatura": { "maxima": 28, "minima": 15, "dato": [ {"value": 15, "hora": 6}, {"value": 28, "hora": 15} ] }, "sensTermica": { "maxima": 30, "minima": 16 }, "humedadRelativa": { "maxima": 85, "minima": 45, "dato": [ {"value": 85, "hora": 6}, {"value": 45, "hora": 15} ] }, "probPrecipitacion": [ {"value": 10, "periodo": "00-24"} ], "viento": [ { "direccion": ["N"], "velocidad": [15], "periodo": "00-24" } ], "estadoCielo": [ { "value": "11", "descripcion": "Despejado", "periodo": "00-24" } ] }, { "fecha": "2025-07-26T00:00:00", "temperatura": { "maxima": 30, "minima": 17 }, "probPrecipitacion": [ {"value": 5, "periodo": "00-24"} ], "viento": [ { "direccion": ["NE"], "velocidad": [10], "periodo": "00-24" } ] } ] } }] @pytest.fixture def mock_historical_data(): """Mock historical weather data from AEMET API""" return [ { "indicativo": "3195", "nombre": "MADRID", "fecha": "2025-07-20", "tmax": 25.2, "horatmax": "1530", "tmin": 14.8, "horatmin": "0630", "tmed": 20.0, "prec": 0.0, "racha": 25.0, "horaracha": "1445", "sol": 8.5, "presMax": 1018.5, "horaPresMax": "1000", "presMin": 1012.3, "horaPresMin": "1700", "hr": 58, "velmedia": 8.5, "dir": "180" }, { "indicativo": "3195", "nombre": "MADRID", "fecha": "2025-07-21", "tmax": 27.1, "horatmax": "1615", "tmin": 16.2, "horatmin": "0700", "tmed": 21.6, "prec": 2.5, "racha": 30.0, "horaracha": "1330", "sol": 6.2, "presMax": 1015.8, "horaPresMax": "0930", "presMin": 1010.1, "horaPresMin": "1800", "hr": 72, "velmedia": 12.0, "dir": "225" }, { "indicativo": "3195", "nombre": "MADRID", "fecha": "2025-07-22", "tmax": 23.8, "horatmax": "1500", "tmin": 13.5, "horatmin": "0615", "tmed": 18.7, "prec": 0.2, "racha": 22.0, "horaracha": "1200", "sol": 7.8, "presMax": 1020.2, "horaPresMax": "1100", "presMin": 1014.7, "horaPresMin": "1900", "hr": 63, "velmedia": 9.2, "dir": "270" } ] # ================================================================ # EXPECTED RESULT FIXTURES # ================================================================ @pytest.fixture def expected_current_weather_structure(): """Expected structure for current weather results""" return { "required_fields": [ "date", "temperature", "precipitation", "humidity", "wind_speed", "pressure", "description", "source" ], "field_types": { "date": datetime, "temperature": (int, float), "precipitation": (int, float), "humidity": (int, float), "wind_speed": (int, float), "pressure": (int, float), "description": str, "source": str }, "valid_ranges": { "temperature": (-30, 50), "precipitation": (0, 200), "humidity": (0, 100), "wind_speed": (0, 200), "pressure": (900, 1100) } } @pytest.fixture def expected_forecast_structure(): """Expected structure for forecast results""" return { "required_fields": [ "forecast_date", "generated_at", "temperature", "precipitation", "humidity", "wind_speed", "description", "source" ], "field_types": { "forecast_date": datetime, "generated_at": datetime, "temperature": (int, float), "precipitation": (int, float), "humidity": (int, float), "wind_speed": (int, float), "description": str, "source": str } } @pytest.fixture def expected_historical_structure(): """Expected structure for historical weather results""" return { "required_fields": [ "date", "temperature", "precipitation", "humidity", "wind_speed", "pressure", "description", "source" ], "field_types": { "date": datetime, "temperature": (int, float, type(None)), "precipitation": (int, float), "humidity": (int, float, type(None)), "wind_speed": (int, float, type(None)), "pressure": (int, float, type(None)), "description": str, "source": str } } # ================================================================ # MOCK AND PATCH FIXTURES # ================================================================ @pytest.fixture def mock_successful_api_calls(): """Mock successful AEMET API calls""" def _mock_api_calls(client, response_data, fetch_data): with patch.object(client, '_get', new_callable=AsyncMock) as mock_get, \ patch.object(client, '_fetch_from_url', new_callable=AsyncMock) as mock_fetch: mock_get.return_value = response_data mock_fetch.return_value = fetch_data return mock_get, mock_fetch return _mock_api_calls @pytest.fixture def mock_failed_api_calls(): """Mock failed AEMET API calls""" def _mock_failed_calls(client, error_type="network"): if error_type == "network": return patch.object(client, '_get', side_effect=Exception("Network error")) elif error_type == "timeout": return patch.object(client, '_get', side_effect=asyncio.TimeoutError("Request timeout")) elif error_type == "invalid_response": return patch.object(client, '_get', new_callable=AsyncMock, return_value=None) else: return patch.object(client, '_get', new_callable=AsyncMock, return_value={"error": "API error"}) return _mock_failed_calls # ================================================================ # VALIDATION HELPER FIXTURES # ================================================================ @pytest.fixture def weather_data_validator(): """Weather data validation helper functions""" def validate_weather_record(record: Dict[str, Any], expected_structure: Dict[str, Any]) -> None: """Validate a weather record against expected structure""" # Check required fields for field in expected_structure["required_fields"]: assert field in record, f"Missing required field: {field}" # Check field types for field, expected_type in expected_structure["field_types"].items(): if field in record and record[field] is not None: assert isinstance(record[field], expected_type), f"Field {field} has wrong type: {type(record[field])}" # Check valid ranges where applicable if "valid_ranges" in expected_structure: for field, (min_val, max_val) in expected_structure["valid_ranges"].items(): if field in record and record[field] is not None: value = record[field] assert min_val <= value <= max_val, f"Field {field} value {value} outside valid range [{min_val}, {max_val}]" def validate_weather_list(records: List[Dict[str, Any]], expected_structure: Dict[str, Any]) -> None: """Validate a list of weather records""" assert isinstance(records, list), "Records should be a list" for i, record in enumerate(records): try: validate_weather_record(record, expected_structure) except AssertionError as e: raise AssertionError(f"Record {i} validation failed: {e}") def validate_date_sequence(records: List[Dict[str, Any]], date_field: str = "date") -> None: """Validate that dates in records are in chronological order""" dates = [r[date_field] for r in records if date_field in r and r[date_field] is not None] if len(dates) > 1: assert dates == sorted(dates), "Dates should be in chronological order" return { "validate_record": validate_weather_record, "validate_list": validate_weather_list, "validate_dates": validate_date_sequence } # ================================================================ # PERFORMANCE TESTING FIXTURES # ================================================================ @pytest.fixture def performance_tracker(): """Performance tracking utilities for tests""" class PerformanceTracker: def __init__(self): self.start_time = None self.measurements = {} def start(self, operation_name: str = "default"): self.start_time = datetime.now() self.operation_name = operation_name def stop(self) -> float: if self.start_time: duration = (datetime.now() - self.start_time).total_seconds() * 1000 self.measurements[self.operation_name] = duration return duration return 0.0 def assert_performance(self, max_duration_ms: float, operation_name: str = "default"): duration = self.measurements.get(operation_name, float('inf')) assert duration <= max_duration_ms, f"Operation {operation_name} took {duration:.0f}ms, expected <= {max_duration_ms}ms" return PerformanceTracker() # ================================================================ # INTEGRATION TEST FIXTURES # ================================================================ @pytest.fixture def integration_test_config(): """Configuration for integration tests""" return { "api_timeout_ms": 5000, "max_retries": 3, "test_api_key": os.getenv("AEMET_API_KEY_TEST", ""), "skip_real_api_tests": os.getenv("SKIP_REAL_API_TESTS", "false").lower() == "true", "madrid_test_coords": (40.4168, -3.7038), "performance_thresholds": { "current_weather_ms": 5000, "forecast_ms": 5000, "historical_ms": 10000 } } # ================================================================ # TEST REPORTING FIXTURES # ================================================================ @pytest.fixture def test_reporter(): """Test reporting utilities""" class TestReporter: def __init__(self): self.results = [] def log_success(self, test_name: str, details: str = ""): message = f"✅ {test_name}" if details: message += f" - {details}" print(message) self.results.append({"test": test_name, "status": "PASS", "details": details}) def log_failure(self, test_name: str, error: str = ""): message = f"❌ {test_name}" if error: message += f" - {error}" print(message) self.results.append({"test": test_name, "status": "FAIL", "error": error}) def log_info(self, test_name: str, info: str = ""): message = f"ℹ️ {test_name}" if info: message += f" - {info}" print(message) self.results.append({"test": test_name, "status": "INFO", "info": info}) def summary(self): passed = len([r for r in self.results if r["status"] == "PASS"]) failed = len([r for r in self.results if r["status"] == "FAIL"]) print(f"\n📊 Test Summary: {passed} passed, {failed} failed") return passed, failed return TestReporter() # ================================================================ # CLEANUP FIXTURES # ================================================================ @pytest.fixture(autouse=True) def cleanup_after_test(): """Automatic cleanup after each test""" yield # Add any cleanup logic here # For example, clearing caches, resetting global state, etc. pass # ================================================================ # HELPER FUNCTIONS # ================================================================ def assert_weather_data_structure(data: Dict[str, Any], data_type: str = "current"): """Assert that weather data has the correct structure""" if data_type == "current": required_fields = ["date", "temperature", "precipitation", "humidity", "wind_speed", "pressure", "description", "source"] elif data_type == "forecast": required_fields = ["forecast_date", "generated_at", "temperature", "precipitation", "humidity", "wind_speed", "description", "source"] elif data_type == "historical": required_fields = ["date", "temperature", "precipitation", "humidity", "wind_speed", "pressure", "description", "source"] else: raise ValueError(f"Unknown data type: {data_type}") for field in required_fields: assert field in data, f"Missing required field: {field}" # Validate source valid_sources = [WeatherSource.AEMET.value, WeatherSource.SYNTHETIC.value, WeatherSource.DEFAULT.value] assert data["source"] in valid_sources, f"Invalid source: {data['source']}" def assert_forecast_list_structure(forecast_list: List[Dict[str, Any]], expected_days: int): """Assert that forecast list has correct structure""" assert isinstance(forecast_list, list), "Forecast should be a list" assert len(forecast_list) == expected_days, f"Expected {expected_days} forecast days, got {len(forecast_list)}" for i, day in enumerate(forecast_list): assert_weather_data_structure(day, "forecast") # Check date progression if len(forecast_list) > 1: for i in range(1, len(forecast_list)): prev_date = forecast_list[i-1]["forecast_date"] curr_date = forecast_list[i]["forecast_date"] date_diff = (curr_date - prev_date).days assert date_diff == 1, f"Forecast dates should be consecutive, got {date_diff} day difference" def assert_historical_list_structure(historical_list: List[Dict[str, Any]]): """Assert that historical list has correct structure""" assert isinstance(historical_list, list), "Historical data should be a list" for i, record in enumerate(historical_list): assert_weather_data_structure(record, "historical") # Check date ordering dates = [r["date"] for r in historical_list if "date" in r] if len(dates) > 1: assert dates == sorted(dates), "Historical dates should be in chronological order"