# ================================================================ # 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())