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

677 lines
30 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/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())