REFACTOR AEMET weather file

This commit is contained in:
Urtzi Alfaro
2025-07-24 16:07:58 +02:00
parent e3cb711206
commit 37dce4886e
8 changed files with 2582 additions and 380 deletions

View File

@@ -114,8 +114,7 @@ async def get_weather_history(
end_date: date = Query(..., description="End date"), end_date: date = Query(..., description="End date"),
latitude: float = Query(..., description="Latitude"), latitude: float = Query(..., description="Latitude"),
longitude: float = Query(..., description="Longitude"), longitude: float = Query(..., description="Longitude"),
tenant_id: str = Depends(get_current_tenant_id_dep), tenant_id: str = Depends(get_current_tenant_id_dep)
current_user: Dict[str, Any] = Depends(get_current_user_dep),
): ):
"""Get historical weather data""" """Get historical weather data"""
try: try:

File diff suppressed because it is too large Load Diff

View File

@@ -22,7 +22,7 @@ aio-pika==9.3.1
# HTTP client # HTTP client
httpx==0.25.2 httpx==0.25.2
# Data processing (UPDATED - Added openpyxl and xlrd) # Data processing
pandas==2.1.3 pandas==2.1.3
numpy==1.25.2 numpy==1.25.2
openpyxl==3.1.2 # For Excel (.xlsx) files openpyxl==3.1.2 # For Excel (.xlsx) files
@@ -43,5 +43,10 @@ bcrypt==4.1.2
pytest==7.4.3 pytest==7.4.3
pytest-asyncio==0.21.1 pytest-asyncio==0.21.1
pytest-cov==4.1.0 pytest-cov==4.1.0
pytest-mock==3.12.0
pytest-xdist==3.5.0
pytest-timeout==2.2.0
psutil==5.9.8
# Cartographic projections and coordinate transformations library
pyproj==3.4.0 pyproj==3.4.0

View File

@@ -1,18 +1,653 @@
# ================================================================ # ================================================================
# services/data/tests/conftest.py # services/data/tests/conftest.py - AEMET Test Configuration
# ================================================================ # ================================================================
"""Test configuration for data service""" """
Test configuration and fixtures for AEMET weather API client tests
Provides shared fixtures, mock data, and test utilities
"""
import pytest import pytest
import pytest_asyncio import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker from datetime import datetime, timedelta
from sqlalchemy.pool import StaticPool from unittest.mock import Mock, AsyncMock, patch
from fastapi.testclient import TestClient from typing import Dict, List, Any, Generator
import uuid import os
from datetime import datetime
from app.main import app # Import the classes we're testing
from app.core.database import Base, get_db from app.external.aemet import (
from app.models.sales import SalesData AEMETClient,
from app.models.weather import WeatherData, WeatherForecast WeatherDataParser,
from app.models.traffic import TrafficData 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"

View File

@@ -1,16 +1,44 @@
[tool:pytest] [tool:pytest]
# pytest.ini - Configuration for async testing # pytest.ini - Configuration file for AEMET tests
asyncio_mode = auto
addopts = -v --tb=short --capture=no # Minimum version requirements
minversion = 6.0
# Add options
addopts =
-ra
--strict-markers
--strict-config
--disable-warnings
--tb=short
-v
# Test discovery
testpaths = tests testpaths = tests
python_files = test_*.py python_files = test_*.py
python_classes = Test* python_classes = Test*
python_functions = test_* python_functions = test_*
# Async support
asyncio_mode = auto
# Markers
markers = markers =
asyncio: mark test as async unit: Unit tests
slow: mark test as slow integration: Integration tests
integration: mark test as integration test api: API tests
performance: Performance tests
slow: Slow tests
asyncio: Async tests
# Logging
log_cli = true
log_cli_level = INFO
log_cli_format = %(asctime)s [%(levelname)8s] %(name)s: %(message)s
log_cli_date_format = %Y-%m-%d %H:%M:%S
# Filtering
filterwarnings = filterwarnings =
ignore::DeprecationWarning ignore::DeprecationWarning
ignore::PendingDeprecationWarning ignore::PendingDeprecationWarning
ignore::PydanticDeprecatedSince20 ignore::PytestUnhandledCoroutineWarning

View File

@@ -0,0 +1,677 @@
# ================================================================
# services/data/tests/test_aemet.py
# ================================================================
"""
Comprehensive test suite for AEMET weather API client
Following the same patterns as test_madrid_opendata.py
"""
import pytest
import asyncio
from datetime import datetime, timedelta
from unittest.mock import Mock, patch, AsyncMock
import math
from typing import Dict, List, Any
from app.external.aemet import (
AEMETClient,
WeatherDataParser,
SyntheticWeatherGenerator,
LocationService,
AEMETConstants,
WeatherSource,
WeatherStation,
GeographicBounds
)
# Configure pytest-asyncio
pytestmark = pytest.mark.asyncio
class TestAEMETClient:
"""Main test class for AEMET API client functionality"""
@pytest.fixture
def client(self):
"""Create AEMET client instance for testing"""
return AEMETClient()
@pytest.fixture
def madrid_coords(self):
"""Standard Madrid coordinates for testing"""
return (40.4168, -3.7038) # Madrid city center
@pytest.fixture
def mock_aemet_response(self):
"""Mock AEMET API response structure"""
return {
"datos": "https://opendata.aemet.es/opendata/sh/12345",
"metadatos": "https://opendata.aemet.es/opendata/sh/metadata"
}
@pytest.fixture
def mock_weather_data(self):
"""Mock current weather data from AEMET"""
return {
"ta": 18.5, # Temperature
"prec": 0.0, # Precipitation
"hr": 65.0, # Humidity
"vv": 12.0, # Wind speed
"pres": 1015.2, # Pressure
"descripcion": "Despejado"
}
@pytest.fixture
def mock_forecast_data(self):
"""Mock forecast data from AEMET"""
return [{
"prediccion": {
"dia": [
{
"fecha": "2025-07-25T00:00:00",
"temperatura": {
"maxima": 28,
"minima": 15
},
"probPrecipitacion": [
{"value": 10, "periodo": "00-24"}
],
"viento": [
{"velocidad": [15], "direccion": ["N"]}
]
},
{
"fecha": "2025-07-26T00:00:00",
"temperatura": {
"maxima": 30,
"minima": 17
},
"probPrecipitacion": [
{"value": 5, "periodo": "00-24"}
],
"viento": [
{"velocidad": [10], "direccion": ["NE"]}
]
}
]
}
}]
@pytest.fixture
def mock_historical_data(self):
"""Mock historical weather data from AEMET"""
return [
{
"fecha": "2025-07-20",
"tmax": 25.2,
"tmin": 14.8,
"prec": 0.0,
"hr": 58,
"velmedia": 8.5,
"presMax": 1018.5,
"presMin": 1012.3
},
{
"fecha": "2025-07-21",
"tmax": 27.1,
"tmin": 16.2,
"prec": 2.5,
"hr": 72,
"velmedia": 12.0,
"presMax": 1015.8,
"presMin": 1010.1
}
]
# ================================================================
# CURRENT WEATHER TESTS
# ================================================================
async def test_get_current_weather_success(self, client, madrid_coords, mock_aemet_response, mock_weather_data):
"""Test successful current weather retrieval"""
lat, lon = madrid_coords
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 = mock_aemet_response
mock_fetch.return_value = [mock_weather_data]
result = await client.get_current_weather(lat, lon)
# Validate result structure
assert result is not None, "Should return weather data"
assert isinstance(result, dict), "Result should be a dictionary"
# Check required fields
required_fields = ['date', 'temperature', 'precipitation', 'humidity', 'wind_speed', 'pressure', 'description', 'source']
for field in required_fields:
assert field in result, f"Missing required field: {field}"
# Validate data types and ranges
assert isinstance(result['temperature'], float), "Temperature should be float"
assert -20 <= result['temperature'] <= 50, "Temperature should be reasonable"
assert isinstance(result['precipitation'], float), "Precipitation should be float"
assert result['precipitation'] >= 0, "Precipitation should be non-negative"
assert 0 <= result['humidity'] <= 100, "Humidity should be percentage"
assert result['wind_speed'] >= 0, "Wind speed should be non-negative"
assert result['pressure'] > 900, "Pressure should be reasonable"
assert result['source'] == WeatherSource.AEMET.value, "Source should be AEMET"
print(f"✅ Current weather test passed - Temp: {result['temperature']}°C, Source: {result['source']}")
async def test_get_current_weather_fallback_to_synthetic(self, client, madrid_coords):
"""Test fallback to synthetic data when AEMET API fails"""
lat, lon = madrid_coords
with patch.object(client, '_get', new_callable=AsyncMock) as mock_get:
mock_get.return_value = None # Simulate API failure
result = await client.get_current_weather(lat, lon)
assert result is not None, "Should return synthetic data"
assert result['source'] == WeatherSource.SYNTHETIC.value, "Should use synthetic source"
assert isinstance(result['temperature'], float), "Temperature should be float"
print(f"✅ Synthetic fallback test passed - Source: {result['source']}")
async def test_get_current_weather_invalid_coordinates(self, client):
"""Test current weather with invalid coordinates"""
invalid_coords = [
(200, 200), # Out of range
(-200, -200), # Out of range
(0, 0), # Not in Madrid area
]
for lat, lon in invalid_coords:
result = await client.get_current_weather(lat, lon)
# Should still return data (synthetic)
assert result is not None, f"Should handle invalid coords ({lat}, {lon})"
assert result['source'] == WeatherSource.SYNTHETIC.value, "Should use synthetic for invalid coords"
print(f"✅ Invalid coordinates test passed")
# ================================================================
# FORECAST TESTS
# ================================================================
async def test_get_forecast_success(self, client, madrid_coords, mock_aemet_response, mock_forecast_data):
"""Test successful weather forecast retrieval"""
lat, lon = madrid_coords
days = 7
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 = mock_aemet_response
mock_fetch.return_value = mock_forecast_data
result = await client.get_forecast(lat, lon, days)
# Validate result structure
assert isinstance(result, list), "Result should be a list"
assert len(result) == days, f"Should return {days} forecast days"
# Check first forecast day
if result:
forecast_day = result[0]
required_fields = ['forecast_date', 'generated_at', 'temperature', 'precipitation', 'humidity', 'wind_speed', 'description', 'source']
for field in required_fields:
assert field in forecast_day, f"Missing required field: {field}"
# Validate data types
assert isinstance(forecast_day['forecast_date'], datetime), "Forecast date should be datetime"
assert isinstance(forecast_day['temperature'], (int, float)), "Temperature should be numeric"
assert isinstance(forecast_day['precipitation'], (int, float)), "Precipitation should be numeric"
assert forecast_day['source'] in [WeatherSource.AEMET.value, WeatherSource.SYNTHETIC.value], "Valid source"
print(f"✅ Forecast test passed - {len(result)} days, Source: {forecast_day['source']}")
async def test_get_forecast_different_durations(self, client, madrid_coords):
"""Test forecast for different time durations"""
lat, lon = madrid_coords
test_durations = [1, 3, 7, 14]
for days in test_durations:
result = await client.get_forecast(lat, lon, days)
assert isinstance(result, list), f"Result should be list for {days} days"
assert len(result) == days, f"Should return exactly {days} forecast days"
# Check date progression
if len(result) > 1:
for i in range(1, len(result)):
date_diff = result[i]['forecast_date'] - result[i-1]['forecast_date']
assert date_diff.days == 1, "Forecast dates should be consecutive days"
print(f"✅ Multiple duration forecast test passed")
async def test_get_forecast_fallback_to_synthetic(self, client, madrid_coords):
"""Test forecast fallback to synthetic data"""
lat, lon = madrid_coords
with patch.object(client.location_service, 'get_municipality_code') as mock_municipality:
mock_municipality.return_value = None # No municipality found
result = await client.get_forecast(lat, lon, 7)
assert isinstance(result, list), "Should return synthetic forecast"
assert len(result) == 7, "Should return 7 days"
assert all(day['source'] == WeatherSource.SYNTHETIC.value for day in result), "All should be synthetic"
print(f"✅ Forecast synthetic fallback test passed")
# ================================================================
# HISTORICAL WEATHER TESTS
# ================================================================
async def test_get_historical_weather_success(self, client, madrid_coords, mock_aemet_response, mock_historical_data):
"""Test successful historical weather retrieval"""
lat, lon = madrid_coords
end_date = datetime.now()
start_date = end_date - timedelta(days=7)
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 = mock_aemet_response
mock_fetch.return_value = mock_historical_data
result = await client.get_historical_weather(lat, lon, start_date, end_date)
# Validate result structure
assert isinstance(result, list), "Result should be a list"
assert len(result) > 0, "Should return historical data"
# Check first historical record
if result:
record = result[0]
required_fields = ['date', 'temperature', 'precipitation', 'humidity', 'wind_speed', 'pressure', 'description', 'source']
for field in required_fields:
assert field in record, f"Missing required field: {field}"
# Validate data types and ranges
assert isinstance(record['date'], datetime), "Date should be datetime"
assert isinstance(record['temperature'], (int, float, type(None))), "Temperature should be numeric or None"
if record['temperature']:
assert -30 <= record['temperature'] <= 50, "Temperature should be reasonable"
assert record['precipitation'] >= 0, "Precipitation should be non-negative"
assert record['source'] == WeatherSource.AEMET.value, "Source should be AEMET"
print(f"✅ Historical weather test passed - {len(result)} records, Source: {record['source']}")
async def test_get_historical_weather_date_ranges(self, client, madrid_coords):
"""Test historical weather with different date ranges"""
lat, lon = madrid_coords
end_date = datetime.now()
test_ranges = [
1, # 1 day
7, # 1 week
30, # 1 month
90, # 3 months
]
for days in test_ranges:
start_date = end_date - timedelta(days=days)
result = await client.get_historical_weather(lat, lon, start_date, end_date)
assert isinstance(result, list), f"Result should be list for {days} days"
# Note: Actual count may vary due to chunking and data availability
assert len(result) >= 0, f"Should return non-negative count for {days} days"
if result:
# Check date ordering
dates = [r['date'] for r in result if 'date' in r]
if len(dates) > 1:
assert dates == sorted(dates), "Historical dates should be in chronological order"
print(f"✅ Historical date ranges test passed")
async def test_get_historical_weather_chunking(self, client, madrid_coords):
"""Test historical weather data chunking for large date ranges"""
lat, lon = madrid_coords
end_date = datetime.now()
start_date = end_date - timedelta(days=65) # More than 30 days to trigger chunking
with patch.object(client, '_fetch_historical_chunk', new_callable=AsyncMock) as mock_chunk:
mock_chunk.return_value = [] # Empty chunks
result = await client.get_historical_weather(lat, lon, start_date, end_date)
# Should have called chunking at least twice (65 days > 30 day limit)
assert mock_chunk.call_count >= 2, "Should chunk large date ranges"
print(f"✅ Historical chunking test passed - {mock_chunk.call_count} chunks")
# ================================================================
# COMPONENT TESTS
# ================================================================
@pytest.mark.skip_asyncio
def test_weather_data_parser(self):
"""Test WeatherDataParser functionality"""
parser = WeatherDataParser()
# Test safe_float
assert parser.safe_float("15.5", 0.0) == 15.5
assert parser.safe_float(None, 10.0) == 10.0
assert parser.safe_float("invalid", 5.0) == 5.0
assert parser.safe_float(20) == 20.0
# Test extract_temperature_value
assert parser.extract_temperature_value(25.5) == 25.5
assert parser.extract_temperature_value("20.0") == 20.0
assert parser.extract_temperature_value({"valor": 18.5}) == 18.5
assert parser.extract_temperature_value([{"valor": 22.0}]) == 22.0
assert parser.extract_temperature_value(None) is None
# Test generate_weather_description
assert "Lluvioso" in parser.generate_weather_description(20, 6.0, 60)
assert "Nuboso con lluvia" in parser.generate_weather_description(20, 1.0, 60)
assert "Nuboso" in parser.generate_weather_description(20, 0, 85)
assert "Soleado y cálido" in parser.generate_weather_description(30, 0, 60)
assert "Frío" in parser.generate_weather_description(2, 0, 60)
print(f"✅ WeatherDataParser tests passed")
@pytest.mark.skip_asyncio
def test_synthetic_weather_generator(self):
"""Test SyntheticWeatherGenerator functionality"""
generator = SyntheticWeatherGenerator()
# Test current weather generation
current = generator.generate_current_weather()
assert isinstance(current, dict), "Should return dictionary"
assert 'temperature' in current, "Should have temperature"
assert 'precipitation' in current, "Should have precipitation"
assert current['source'] == WeatherSource.SYNTHETIC.value, "Should be synthetic source"
assert isinstance(current['date'], datetime), "Should have datetime"
# Test forecast generation
forecast = generator.generate_forecast_sync(5)
assert isinstance(forecast, list), "Should return list"
assert len(forecast) == 5, "Should return requested days"
assert all('forecast_date' in day for day in forecast), "All days should have forecast_date"
assert all(day['source'] == WeatherSource.SYNTHETIC.value for day in forecast), "All should be synthetic"
# Test historical generation
end_date = datetime.now()
start_date = end_date - timedelta(days=7)
historical = generator.generate_historical_data(start_date, end_date)
assert isinstance(historical, list), "Should return list"
assert len(historical) == 8, "Should return 8 days (inclusive)"
assert all('date' in day for day in historical), "All days should have date"
assert all(day['source'] == WeatherSource.SYNTHETIC.value for day in historical), "All should be synthetic"
print(f"✅ SyntheticWeatherGenerator tests passed")
@pytest.mark.skip_asyncio
def test_location_service(self):
"""Test LocationService functionality"""
# Test distance calculation
madrid_center = (40.4168, -3.7038)
madrid_north = (40.4677, -3.5552)
distance = LocationService.calculate_distance(
madrid_center[0], madrid_center[1],
madrid_north[0], madrid_north[1]
)
assert isinstance(distance, float), "Distance should be float"
assert 0 < distance < 50, "Distance should be reasonable for Madrid area"
# Test nearest station finding
station_id = LocationService.find_nearest_station(madrid_center[0], madrid_center[1])
assert station_id is not None, "Should find a station"
assert station_id in [station.id for station in AEMETConstants.MADRID_STATIONS], "Should be valid station"
# Test municipality code
municipality = LocationService.get_municipality_code(madrid_center[0], madrid_center[1])
assert municipality == AEMETConstants.MADRID_MUNICIPALITY_CODE, "Should return Madrid code"
# Test outside Madrid
outside_madrid = LocationService.get_municipality_code(41.0, -4.0) # Outside bounds
assert outside_madrid is None, "Should return None for outside Madrid"
print(f"✅ LocationService tests passed")
@pytest.mark.skip_asyncio
def test_constants_and_enums(self):
"""Test constants and enum definitions"""
# Test WeatherSource enum
assert WeatherSource.AEMET.value == "aemet"
assert WeatherSource.SYNTHETIC.value == "synthetic"
assert WeatherSource.DEFAULT.value == "default"
# Test GeographicBounds
bounds = AEMETConstants.MADRID_BOUNDS
assert bounds.contains(40.4168, -3.7038), "Should contain Madrid center"
assert not bounds.contains(41.0, -4.0), "Should not contain coordinates outside Madrid"
# Test WeatherStation
station = AEMETConstants.MADRID_STATIONS[0]
assert isinstance(station, WeatherStation), "Should be WeatherStation instance"
assert station.id is not None, "Station should have ID"
assert station.name is not None, "Station should have name"
print(f"✅ Constants and enums tests passed")
# ================================================================
# ERROR HANDLING TESTS
# ================================================================
async def test_api_error_handling(self, client, madrid_coords):
"""Test handling of various API errors"""
lat, lon = madrid_coords
# Test network error
with patch.object(client, '_get', side_effect=Exception("Network error")):
result = await client.get_current_weather(lat, lon)
assert result['source'] == WeatherSource.SYNTHETIC.value, "Should fallback on network error"
# Test invalid API response
with patch.object(client, '_get', new_callable=AsyncMock) as mock_get:
mock_get.return_value = {"error": "Invalid API key"}
result = await client.get_current_weather(lat, lon)
assert result['source'] == WeatherSource.SYNTHETIC.value, "Should fallback on API error"
# Test malformed 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 = {"datos": "http://example.com"}
mock_fetch.return_value = [{"invalid": "data"}] # Missing expected fields
result = await client.get_current_weather(lat, lon)
assert result is not None, "Should handle malformed data gracefully"
print(f"✅ API error handling tests passed")
async def test_timeout_handling(self, client, madrid_coords):
"""Test timeout handling"""
lat, lon = madrid_coords
with patch.object(client, '_get', side_effect=asyncio.TimeoutError("Request timeout")):
result = await client.get_current_weather(lat, lon)
assert result['source'] == WeatherSource.SYNTHETIC.value, "Should fallback on timeout"
print(f"✅ Timeout handling test passed")
# ================================================================
# PERFORMANCE TESTS
# ================================================================
async def test_performance_current_weather(self, client, madrid_coords):
"""Test current weather performance"""
lat, lon = madrid_coords
start_time = datetime.now()
result = await client.get_current_weather(lat, lon)
execution_time = (datetime.now() - start_time).total_seconds() * 1000
assert result is not None, "Should return weather data"
assert execution_time < 5000, "Should execute within 5 seconds"
print(f"✅ Current weather performance test passed - {execution_time:.0f}ms")
async def test_performance_forecast(self, client, madrid_coords):
"""Test forecast performance"""
lat, lon = madrid_coords
start_time = datetime.now()
result = await client.get_forecast(lat, lon, 7)
execution_time = (datetime.now() - start_time).total_seconds() * 1000
assert isinstance(result, list), "Should return forecast list"
assert len(result) == 7, "Should return 7 days"
assert execution_time < 5000, "Should execute within 5 seconds"
print(f"✅ Forecast performance test passed - {execution_time:.0f}ms")
async def test_performance_historical(self, client, madrid_coords):
"""Test historical weather performance"""
lat, lon = madrid_coords
end_date = datetime.now()
start_date = end_date - timedelta(days=7)
start_time = datetime.now()
result = await client.get_historical_weather(lat, lon, start_date, end_date)
execution_time = (datetime.now() - start_time).total_seconds() * 1000
assert isinstance(result, list), "Should return historical list"
assert execution_time < 10000, "Should execute within 10 seconds (allowing for API calls)"
print(f"✅ Historical performance test passed - {execution_time:.0f}ms")
# ================================================================
# INTEGRATION TESTS
# ================================================================
async def test_real_aemet_api_access(self, client, madrid_coords):
"""Test actual AEMET API access (if API key is available)"""
lat, lon = madrid_coords
try:
# Test current weather
current_result = await client.get_current_weather(lat, lon)
assert current_result is not None, "Should get current weather"
if current_result['source'] == WeatherSource.AEMET.value:
print(f"🎉 SUCCESS: Got real AEMET current weather data!")
print(f" Temperature: {current_result['temperature']}°C")
print(f" Description: {current_result['description']}")
else:
print(f" Got synthetic current weather (API key may not be configured)")
# Test forecast
forecast_result = await client.get_forecast(lat, lon, 3)
assert len(forecast_result) == 3, "Should get 3-day forecast"
if forecast_result[0]['source'] == WeatherSource.AEMET.value:
print(f"🎉 SUCCESS: Got real AEMET forecast data!")
print(f" Tomorrow: {forecast_result[1]['temperature']}°C - {forecast_result[1]['description']}")
else:
print(f" Got synthetic forecast (API key may not be configured)")
# Test historical (last week)
end_date = datetime.now()
start_date = end_date - timedelta(days=7)
historical_result = await client.get_historical_weather(lat, lon, start_date, end_date)
assert isinstance(historical_result, list), "Should get historical data"
real_historical = [r for r in historical_result if r['source'] == WeatherSource.AEMET.value]
if real_historical:
print(f"🎉 SUCCESS: Got real AEMET historical data!")
print(f" Records: {len(real_historical)} real + {len(historical_result) - len(real_historical)} synthetic")
else:
print(f" Got synthetic historical data (API limitations or key issues)")
print(f"✅ Real AEMET API integration test completed")
except Exception as e:
print(f"⚠️ AEMET API integration test failed: {e}")
# This is acceptable if API key is not configured
async def test_data_consistency(self, client, madrid_coords):
"""Test data consistency across different methods"""
lat, lon = madrid_coords
# Get current weather
current = await client.get_current_weather(lat, lon)
# Get today's forecast
forecast = await client.get_forecast(lat, lon, 1)
today_forecast = forecast[0] if forecast else None
if current and today_forecast:
# Temperature should be somewhat consistent
temp_diff = abs(current['temperature'] - today_forecast['temperature'])
assert temp_diff < 15, "Current and forecast temperature should be reasonably consistent"
# Both should use same source type preference
if current['source'] == WeatherSource.AEMET.value:
assert today_forecast['source'] == WeatherSource.AEMET.value, "Should use consistent data sources"
print(f"✅ Data consistency test passed")
# ================================================================
# STANDALONE TEST FUNCTIONS
# ================================================================
async def run_manual_test():
"""Manual test function that can be run directly"""
print("="*60)
print("AEMET WEATHER CLIENT TEST - JULY 2025")
print("="*60)
client = AEMETClient()
madrid_lat, madrid_lon = 40.4168, -3.7038 # Madrid center
print(f"\n=== Testing Madrid Weather ({madrid_lat}, {madrid_lon}) ===")
# Test current weather
print(f"\n1. Testing Current Weather...")
current = await client.get_current_weather(madrid_lat, madrid_lon)
if current:
print(f" Temperature: {current['temperature']}°C")
print(f" Description: {current['description']}")
print(f" Humidity: {current['humidity']}%")
print(f" Wind: {current['wind_speed']} km/h")
print(f" Source: {current['source']}")
# Test forecast
print(f"\n2. Testing 7-Day Forecast...")
forecast = await client.get_forecast(madrid_lat, madrid_lon, 7)
if forecast:
print(f" Forecast days: {len(forecast)}")
print(f" Tomorrow: {forecast[1]['temperature']}°C - {forecast[1]['description']}")
print(f" Source: {forecast[0]['source']}")
# Test historical
print(f"\n3. Testing Historical Weather (last 7 days)...")
end_date = datetime.now()
start_date = end_date - timedelta(days=7)
historical = await client.get_historical_weather(madrid_lat, madrid_lon, start_date, end_date)
if historical:
print(f" Historical records: {len(historical)}")
if historical:
real_count = len([r for r in historical if r['source'] == WeatherSource.AEMET.value])
synthetic_count = len(historical) - real_count
print(f" Real data: {real_count}, Synthetic: {synthetic_count}")
print(f"\n✅ Manual test completed!")
if __name__ == "__main__":
# If run directly, execute manual test
asyncio.run(run_manual_test())

View File

@@ -0,0 +1,594 @@
# ================================================================
# services/data/tests/test_aemet_edge_cases.py
# ================================================================
"""
Edge cases and integration tests for AEMET weather API client
Covers boundary conditions, error scenarios, and complex integrations
"""
import pytest
import asyncio
from datetime import datetime, timedelta
from unittest.mock import Mock, patch, AsyncMock
import json
from typing import Dict, List, Any
from app.external.aemet import (
AEMETClient,
WeatherDataParser,
SyntheticWeatherGenerator,
LocationService,
AEMETConstants,
WeatherSource
)
# Configure pytest-asyncio
pytestmark = pytest.mark.asyncio
class TestAEMETEdgeCases:
"""Test edge cases and boundary conditions"""
async def test_extreme_coordinates(self, aemet_client):
"""Test handling of extreme coordinate values"""
extreme_coords = [
(90, 180), # North pole, antimeridian
(-90, -180), # South pole, antimeridian
(0, 0), # Null island
(40.5, -180), # Valid latitude, extreme longitude
(90, -3.7), # Extreme latitude, Madrid longitude
]
for lat, lon in extreme_coords:
result = await aemet_client.get_current_weather(lat, lon)
assert result is not None, f"Should handle extreme coords ({lat}, {lon})"
assert result['source'] == WeatherSource.SYNTHETIC.value, "Should fallback to synthetic for extreme coords"
assert isinstance(result['temperature'], (int, float)), "Should have valid temperature"
async def test_boundary_date_ranges(self, aemet_client, madrid_coords):
"""Test boundary conditions for date ranges"""
lat, lon = madrid_coords
now = datetime.now()
# Test same start and end date
result = await aemet_client.get_historical_weather(lat, lon, now, now)
assert isinstance(result, list), "Should return list for same-day request"
# Test reverse date range (end before start)
start_date = now
end_date = now - timedelta(days=1)
result = await aemet_client.get_historical_weather(lat, lon, start_date, end_date)
assert isinstance(result, list), "Should handle reverse date range gracefully"
# Test extremely large date range
start_date = now - timedelta(days=1000)
end_date = now
result = await aemet_client.get_historical_weather(lat, lon, start_date, end_date)
assert isinstance(result, list), "Should handle very large date ranges"
async def test_forecast_edge_durations(self, aemet_client, madrid_coords):
"""Test forecast with edge case durations"""
lat, lon = madrid_coords
edge_durations = [0, 1, 30, 365, -1, 1000]
for days in edge_durations:
try:
result = await aemet_client.get_forecast(lat, lon, days)
if days <= 0:
assert len(result) == 0 or result is None, f"Should handle non-positive days ({days})"
elif days > 100:
# Should handle gracefully, possibly with synthetic data
assert isinstance(result, list), f"Should handle large day count ({days})"
else:
assert len(result) == days, f"Should return {days} forecast days"
except Exception as e:
# Some edge cases might raise exceptions, which is acceptable
print(f" Days={days} raised exception: {e}")
def test_parser_edge_cases(self, weather_parser):
"""Test weather data parser with edge case inputs"""
# Test with None values
result = weather_parser.safe_float(None, 10.0)
assert result == 10.0, "Should return default for None"
# Test with empty strings
result = weather_parser.safe_float("", 5.0)
assert result == 5.0, "Should return default for empty string"
# Test with extreme values
result = weather_parser.safe_float("999999.99", 0.0)
assert result == 999999.99, "Should handle large numbers"
result = weather_parser.safe_float("-999.99", 0.0)
assert result == -999.99, "Should handle negative numbers"
# Test temperature extraction edge cases
assert weather_parser.extract_temperature_value([]) is None, "Should handle empty list"
assert weather_parser.extract_temperature_value({}) is None, "Should handle empty dict"
assert weather_parser.extract_temperature_value("invalid") is None, "Should handle invalid string"
def test_synthetic_generator_edge_cases(self, synthetic_generator):
"""Test synthetic weather generator edge cases"""
# Test with extreme date ranges
end_date = datetime.now()
start_date = end_date - timedelta(days=1000)
result = synthetic_generator.generate_historical_data(start_date, end_date)
assert isinstance(result, list), "Should handle large date ranges"
assert len(result) == 1001, "Should generate correct number of days"
# Test forecast with zero days
result = synthetic_generator.generate_forecast_sync(0)
assert result == [], "Should return empty list for zero days"
# Test forecast with large number of days
result = synthetic_generator.generate_forecast_sync(1000)
assert len(result) == 1000, "Should handle large forecast ranges"
def test_location_service_edge_cases(self):
"""Test location service edge cases"""
# Test distance calculation with same points
distance = LocationService.calculate_distance(40.4, -3.7, 40.4, -3.7)
assert distance == 0.0, "Distance between same points should be zero"
# Test distance calculation with antipodal points
distance = LocationService.calculate_distance(40.4, -3.7, -40.4, 176.3)
assert distance > 15000, "Antipodal points should be far apart"
# Test station finding with no stations (if list were empty)
with patch.object(AEMETConstants, 'MADRID_STATIONS', []):
station = LocationService.find_nearest_station(40.4, -3.7)
assert station is None, "Should return None when no stations available"
class TestAEMETDataIntegrity:
"""Test data integrity and consistency"""
async def test_data_type_consistency(self, aemet_client, madrid_coords):
"""Test that data types are consistent across calls"""
lat, lon = madrid_coords
# Get current weather multiple times
results = []
for _ in range(3):
result = await aemet_client.get_current_weather(lat, lon)
results.append(result)
# Check that field types are consistent
if all(r is not None for r in results):
for field in ['temperature', 'precipitation', 'humidity', 'wind_speed', 'pressure']:
types = [type(r[field]) for r in results if field in r]
if types:
first_type = types[0]
assert all(t == first_type for t in types), f"Inconsistent types for {field}: {types}"
async def test_temperature_consistency(self, aemet_client, madrid_coords):
"""Test temperature consistency between different data sources"""
lat, lon = madrid_coords
# Get current weather and today's forecast
current = await aemet_client.get_current_weather(lat, lon)
forecast = await aemet_client.get_forecast(lat, lon, 1)
if current and forecast and len(forecast) > 0:
current_temp = current['temperature']
forecast_temp = forecast[0]['temperature']
# Temperatures should be reasonably close (within 15°C)
temp_diff = abs(current_temp - forecast_temp)
assert temp_diff < 15, f"Temperature difference too large: current={current_temp}°C, forecast={forecast_temp}°C"
async def test_source_consistency(self, aemet_client, madrid_coords):
"""Test that data source is consistent within same time period"""
lat, lon = madrid_coords
# Get multiple current weather readings
current1 = await aemet_client.get_current_weather(lat, lon)
current2 = await aemet_client.get_current_weather(lat, lon)
if current1 and current2:
# Should use same source type (both real or both synthetic)
assert current1['source'] == current2['source'], "Should use consistent data source"
def test_historical_data_ordering(self, weather_parser, mock_historical_data):
"""Test that historical data is properly ordered"""
parsed_data = weather_parser.parse_historical_data(mock_historical_data)
if len(parsed_data) > 1:
dates = [record['date'] for record in parsed_data]
assert dates == sorted(dates), "Historical data should be chronologically ordered"
def test_forecast_date_progression(self, weather_parser, mock_forecast_data):
"""Test that forecast dates progress correctly"""
parsed_forecast = weather_parser.parse_forecast_data(mock_forecast_data, 7)
if len(parsed_forecast) > 1:
for i in range(1, len(parsed_forecast)):
prev_date = parsed_forecast[i-1]['forecast_date']
curr_date = parsed_forecast[i]['forecast_date']
diff = (curr_date - prev_date).days
assert diff == 1, f"Forecast dates should be consecutive days, got {diff} day difference"
class TestAEMETErrorRecovery:
"""Test error recovery and resilience"""
async def test_network_interruption_recovery(self, aemet_client, madrid_coords):
"""Test recovery from network interruptions"""
lat, lon = madrid_coords
# Mock intermittent network failures
call_count = 0
async def mock_get_with_failures(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count <= 2: # Fail first two calls
raise Exception("Network timeout")
else:
return {"datos": "http://example.com/data"}
with patch.object(aemet_client, '_get', side_effect=mock_get_with_failures):
result = await aemet_client.get_current_weather(lat, lon)
# Should eventually succeed or fallback to synthetic
assert result is not None, "Should recover from network failures"
assert result['source'] in [WeatherSource.AEMET.value, WeatherSource.SYNTHETIC.value]
async def test_partial_data_recovery(self, aemet_client, madrid_coords, weather_parser):
"""Test recovery from partial/corrupted data"""
lat, lon = madrid_coords
# Mock corrupted historical data (some records missing fields)
corrupted_data = [
{"fecha": "2025-07-20", "tmax": 25.2}, # Missing tmin and other fields
{"fecha": "2025-07-21"}, # Only has date
{"tmax": 27.0, "tmin": 15.0}, # Missing date
{"fecha": "2025-07-22", "tmax": 23.0, "tmin": 14.0, "prec": 0.0} # Complete record
]
parsed_data = weather_parser.parse_historical_data(corrupted_data)
# Should only return valid records and handle corrupted ones gracefully
assert isinstance(parsed_data, list), "Should return list even with corrupted data"
valid_records = [r for r in parsed_data if 'date' in r and r['date'] is not None]
assert len(valid_records) >= 1, "Should salvage at least some valid records"
async def test_malformed_json_recovery(self, aemet_client, madrid_coords):
"""Test recovery from malformed JSON responses"""
lat, lon = madrid_coords
# Mock malformed responses
malformed_responses = [
None,
"",
"invalid json",
{"incomplete": "response"},
{"datos": None},
{"datos": ""},
]
for response in malformed_responses:
with patch.object(aemet_client, '_get', new_callable=AsyncMock, return_value=response):
result = await aemet_client.get_current_weather(lat, lon)
assert result is not None, f"Should handle malformed response: {response}"
assert result['source'] == WeatherSource.SYNTHETIC.value, "Should fallback to synthetic"
async def test_api_rate_limiting_recovery(self, aemet_client, madrid_coords):
"""Test recovery from API rate limiting"""
lat, lon = madrid_coords
# Mock rate limiting responses
rate_limit_response = {
"descripcion": "Demasiadas peticiones",
"estado": 429
}
with patch.object(aemet_client, '_get', new_callable=AsyncMock, return_value=rate_limit_response):
result = await aemet_client.get_current_weather(lat, lon)
assert result is not None, "Should handle rate limiting"
assert result['source'] == WeatherSource.SYNTHETIC.value, "Should fallback to synthetic on rate limit"
class TestAEMETPerformanceAndScaling:
"""Test performance characteristics and scaling behavior"""
async def test_concurrent_requests_performance(self, aemet_client, madrid_coords):
"""Test performance with concurrent requests"""
lat, lon = madrid_coords
# Create multiple concurrent requests
tasks = []
for i in range(10):
task = aemet_client.get_current_weather(lat, lon)
tasks.append(task)
start_time = datetime.now()
results = await asyncio.gather(*tasks, return_exceptions=True)
execution_time = (datetime.now() - start_time).total_seconds() * 1000
# Check that most requests succeeded
successful_results = [r for r in results if isinstance(r, dict) and 'temperature' in r]
assert len(successful_results) >= 8, "Most concurrent requests should succeed"
# Should complete in reasonable time (allowing for potential API rate limiting)
assert execution_time < 15000, f"Concurrent requests took too long: {execution_time:.0f}ms"
print(f"✅ Concurrent requests test - {len(successful_results)}/10 succeeded in {execution_time:.0f}ms")
async def test_memory_usage_with_large_datasets(self, aemet_client, madrid_coords):
"""Test memory usage with large historical datasets"""
lat, lon = madrid_coords
# Request large historical dataset
end_date = datetime.now()
start_date = end_date - timedelta(days=90) # 3 months
import psutil
import os
# Get initial memory usage
process = psutil.Process(os.getpid())
initial_memory = process.memory_info().rss / 1024 / 1024 # MB
result = await aemet_client.get_historical_weather(lat, lon, start_date, end_date)
# Get final memory usage
final_memory = process.memory_info().rss / 1024 / 1024 # MB
memory_increase = final_memory - initial_memory
assert isinstance(result, list), "Should return historical data"
# Memory increase should be reasonable (less than 100MB for 90 days)
assert memory_increase < 100, f"Memory usage increased too much: {memory_increase:.1f}MB"
print(f"✅ Memory usage test - {len(result)} records, +{memory_increase:.1f}MB")
async def test_caching_behavior(self, aemet_client, madrid_coords):
"""Test caching behavior and performance improvement"""
lat, lon = madrid_coords
# First request (cold)
start_time = datetime.now()
result1 = await aemet_client.get_current_weather(lat, lon)
first_call_time = (datetime.now() - start_time).total_seconds() * 1000
# Second request (potentially cached)
start_time = datetime.now()
result2 = await aemet_client.get_current_weather(lat, lon)
second_call_time = (datetime.now() - start_time).total_seconds() * 1000
assert result1 is not None, "First call should succeed"
assert result2 is not None, "Second call should succeed"
# Both should return valid data
assert 'temperature' in result1, "First result should have temperature"
assert 'temperature' in result2, "Second result should have temperature"
print(f"✅ Caching test - First call: {first_call_time:.0f}ms, Second call: {second_call_time:.0f}ms")
class TestAEMETIntegrationScenarios:
"""Test realistic integration scenarios"""
async def test_daily_weather_workflow(self, aemet_client, madrid_coords):
"""Test a complete daily weather workflow"""
lat, lon = madrid_coords
# Simulate a daily weather check workflow
workflow_results = {}
# Step 1: Get current conditions
current = await aemet_client.get_current_weather(lat, lon)
workflow_results['current'] = current
assert current is not None, "Should get current weather"
# Step 2: Get today's forecast
forecast = await aemet_client.get_forecast(lat, lon, 1)
workflow_results['forecast'] = forecast
assert len(forecast) == 1, "Should get today's forecast"
# Step 3: Get week ahead forecast
week_forecast = await aemet_client.get_forecast(lat, lon, 7)
workflow_results['week_forecast'] = week_forecast
assert len(week_forecast) == 7, "Should get 7-day forecast"
# Step 4: Get last week's actual weather for comparison
end_date = datetime.now() - timedelta(days=1)
start_date = end_date - timedelta(days=7)
historical = await aemet_client.get_historical_weather(lat, lon, start_date, end_date)
workflow_results['historical'] = historical
assert isinstance(historical, list), "Should get historical data"
# Validate workflow consistency
all_sources = set()
if current: all_sources.add(current['source'])
if forecast: all_sources.add(forecast[0]['source'])
if week_forecast: all_sources.add(week_forecast[0]['source'])
if historical: all_sources.update([h['source'] for h in historical])
print(f"✅ Daily workflow test - Sources used: {', '.join(all_sources)}")
return workflow_results
async def test_weather_alerting_scenario(self, aemet_client, madrid_coords):
"""Test weather alerting scenario"""
lat, lon = madrid_coords
# Get forecast for potential alerts
forecast = await aemet_client.get_forecast(lat, lon, 3)
alerts = []
for day in forecast:
# Check for extreme temperatures
if day['temperature'] > 35:
alerts.append(f"High temperature alert: {day['temperature']}°C on {day['forecast_date'].date()}")
elif day['temperature'] < -5:
alerts.append(f"Low temperature alert: {day['temperature']}°C on {day['forecast_date'].date()}")
# Check for high precipitation
if day['precipitation'] > 20:
alerts.append(f"Heavy rain alert: {day['precipitation']}mm on {day['forecast_date'].date()}")
# Alerts should be properly formatted
for alert in alerts:
assert isinstance(alert, str), "Alert should be string"
assert "alert" in alert.lower(), "Alert should contain 'alert'"
print(f"✅ Weather alerting test - {len(alerts)} alerts generated")
return alerts
async def test_historical_analysis_scenario(self, aemet_client, madrid_coords):
"""Test historical weather analysis scenario"""
lat, lon = madrid_coords
# Get historical data for analysis
end_date = datetime.now()
start_date = end_date - timedelta(days=30)
historical = await aemet_client.get_historical_weather(lat, lon, start_date, end_date)
if historical:
# Calculate statistics
temperatures = [h['temperature'] for h in historical if h['temperature'] is not None]
precipitations = [h['precipitation'] for h in historical if h['precipitation'] is not None]
if temperatures:
avg_temp = sum(temperatures) / len(temperatures)
max_temp = max(temperatures)
min_temp = min(temperatures)
# Validate statistics
assert min_temp <= avg_temp <= max_temp, "Temperature statistics should be logical"
assert -20 <= min_temp <= 50, "Min temperature should be reasonable"
assert -20 <= max_temp <= 50, "Max temperature should be reasonable"
if precipitations:
total_precip = sum(precipitations)
rainy_days = len([p for p in precipitations if p > 0.1])
# Validate precipitation statistics
assert total_precip >= 0, "Total precipitation should be non-negative"
assert 0 <= rainy_days <= len(precipitations), "Rainy days should be reasonable"
print(f"✅ Historical analysis test - {len(historical)} records analyzed")
return {
'record_count': len(historical),
'avg_temp': avg_temp if temperatures else None,
'temp_range': (min_temp, max_temp) if temperatures else None,
'total_precip': total_precip if precipitations else None,
'rainy_days': rainy_days if precipitations else None
}
return {}
class TestAEMETRegressionTests:
"""Regression tests for previously fixed issues"""
async def test_timezone_handling_regression(self, aemet_client, madrid_coords):
"""Regression test for timezone handling issues"""
lat, lon = madrid_coords
# Get current weather and forecast
current = await aemet_client.get_current_weather(lat, lon)
forecast = await aemet_client.get_forecast(lat, lon, 2)
if current:
# Current weather date should be recent (within last hour)
now = datetime.now()
time_diff = abs((now - current['date']).total_seconds())
assert time_diff < 3600, "Current weather timestamp should be recent"
if forecast:
# Forecast dates should be in the future
now = datetime.now().date()
for day in forecast:
forecast_date = day['forecast_date'].date()
assert forecast_date >= now, f"Forecast date {forecast_date} should be today or future"
async def test_data_type_conversion_regression(self, weather_parser):
"""Regression test for data type conversion issues"""
# Test cases that previously caused issues
test_cases = [
("25.5", 25.5), # String to float
(25, 25.0), # Int to float
("", None), # Empty string
("invalid", None), # Invalid string
(None, None), # None input
]
for input_val, expected in test_cases:
result = weather_parser.safe_float(input_val, None)
if expected is None:
assert result is None, f"Expected None for input {input_val}, got {result}"
else:
assert result == expected, f"Expected {expected} for input {input_val}, got {result}"
def test_empty_data_handling_regression(self, weather_parser):
"""Regression test for empty data handling"""
# Empty lists and dictionaries should be handled gracefully
empty_data_cases = [
[],
[{}],
[{"invalid": "data"}],
None,
]
for empty_data in empty_data_cases:
result = weather_parser.parse_historical_data(empty_data if empty_data is not None else [])
assert isinstance(result, list), f"Should return list for empty data: {empty_data}"
# May be empty or have some synthetic data, but should not crash
# ================================================================
# STANDALONE TEST RUNNER FOR EDGE CASES
# ================================================================
async def run_edge_case_tests():
"""Run edge case tests manually"""
print("="*60)
print("AEMET EDGE CASE TESTS")
print("="*60)
client = AEMETClient()
parser = WeatherDataParser()
generator = SyntheticWeatherGenerator()
madrid_coords = (40.4168, -3.7038)
print(f"\n1. Testing extreme coordinates...")
extreme_result = await client.get_current_weather(90, 180)
print(f" Extreme coords result: {extreme_result['source']} source")
print(f"\n2. Testing parser edge cases...")
parser_tests = [
parser.safe_float(None, 10.0),
parser.safe_float("invalid", 5.0),
parser.extract_temperature_value([]),
]
print(f" Parser edge cases passed: {len(parser_tests)}")
print(f"\n3. Testing synthetic generator extremes...")
large_forecast = generator.generate_forecast_sync(100)
print(f" Generated {len(large_forecast)} forecast days")
print(f"\n4. Testing concurrent requests...")
tasks = [client.get_current_weather(*madrid_coords) for _ in range(5)]
concurrent_results = await asyncio.gather(*tasks, return_exceptions=True)
successful = len([r for r in concurrent_results if isinstance(r, dict)])
print(f" Concurrent requests: {successful}/5 successful")
print(f"\n✅ Edge case tests completed!")
if __name__ == "__main__":
asyncio.run(run_edge_case_tests())

View File

@@ -487,7 +487,7 @@ class TrainingService:
params["end_date"] = request.end_date.isoformat() params["end_date"] = request.end_date.isoformat()
response = await client.get( response = await client.get(
f"{settings.DATA_SERVICE_URL}/api/weather", f"{settings.DATA_SERVICE_URL}/weather/history",
params=params, params=params,
timeout=30.0 timeout=30.0
) )
@@ -515,7 +515,7 @@ class TrainingService:
params["end_date"] = request.end_date.isoformat() params["end_date"] = request.end_date.isoformat()
response = await client.get( response = await client.get(
f"{settings.DATA_SERVICE_URL}/api/traffic", f"{settings.DATA_SERVICE_URL}/traffic/historical",
params=params, params=params,
timeout=30.0 timeout=30.0
) )