Files
bakery-ia/services/data/tests/conftest.py

653 lines
22 KiB
Python
Raw Normal View History

2025-07-18 11:51:43 +02:00
# ================================================================
2025-07-24 16:07:58 +02:00
# services/data/tests/conftest.py - AEMET Test Configuration
2025-07-18 11:51:43 +02:00
# ================================================================
2025-07-24 16:07:58 +02:00
"""
Test configuration and fixtures for AEMET weather API client tests
Provides shared fixtures, mock data, and test utilities
"""
2025-07-18 11:51:43 +02:00
import pytest
2025-07-24 16:07:58 +02:00
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"