Files
bakery-ia/services/data/tests/conftest.py
2025-07-24 16:07:58 +02:00

653 lines
22 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ================================================================
# 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"