REFACTOR AEMET weather file
This commit is contained in:
@@ -114,8 +114,7 @@ async def get_weather_history(
|
||||
end_date: date = Query(..., description="End date"),
|
||||
latitude: float = Query(..., description="Latitude"),
|
||||
longitude: float = Query(..., description="Longitude"),
|
||||
tenant_id: str = Depends(get_current_tenant_id_dep),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
tenant_id: str = Depends(get_current_tenant_id_dep)
|
||||
):
|
||||
"""Get historical weather data"""
|
||||
try:
|
||||
|
||||
974
services/data/app/external/aemet.py
vendored
974
services/data/app/external/aemet.py
vendored
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,7 @@ aio-pika==9.3.1
|
||||
# HTTP client
|
||||
httpx==0.25.2
|
||||
|
||||
# Data processing (UPDATED - Added openpyxl and xlrd)
|
||||
# Data processing
|
||||
pandas==2.1.3
|
||||
numpy==1.25.2
|
||||
openpyxl==3.1.2 # For Excel (.xlsx) files
|
||||
@@ -43,5 +43,10 @@ bcrypt==4.1.2
|
||||
pytest==7.4.3
|
||||
pytest-asyncio==0.21.1
|
||||
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
|
||||
@@ -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_asyncio
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from fastapi.testclient import TestClient
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import Mock, AsyncMock, patch
|
||||
from typing import Dict, List, Any, Generator
|
||||
import os
|
||||
|
||||
from app.main import app
|
||||
from app.core.database import Base, get_db
|
||||
from app.models.sales import SalesData
|
||||
from app.models.weather import WeatherData, WeatherForecast
|
||||
from app.models.traffic import TrafficData
|
||||
# Import the classes we're testing
|
||||
from app.external.aemet import (
|
||||
AEMETClient,
|
||||
WeatherDataParser,
|
||||
SyntheticWeatherGenerator,
|
||||
LocationService,
|
||||
WeatherSource
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# PYTEST CONFIGURATION
|
||||
# ================================================================
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
"""Create an instance of the default event loop for the test session."""
|
||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
# ================================================================
|
||||
# CLIENT AND SERVICE FIXTURES
|
||||
# ================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def aemet_client():
|
||||
"""Create AEMET client instance for testing"""
|
||||
return AEMETClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def weather_parser():
|
||||
"""Create WeatherDataParser instance for testing"""
|
||||
return WeatherDataParser()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def synthetic_generator():
|
||||
"""Create SyntheticWeatherGenerator instance for testing"""
|
||||
return SyntheticWeatherGenerator()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def location_service():
|
||||
"""Create LocationService instance for testing"""
|
||||
return LocationService()
|
||||
|
||||
|
||||
# ================================================================
|
||||
# COORDINATE AND LOCATION FIXTURES
|
||||
# ================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def madrid_coords():
|
||||
"""Standard Madrid coordinates for testing"""
|
||||
return (40.4168, -3.7038) # Madrid city center
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def madrid_coords_variants():
|
||||
"""Various Madrid area coordinates for testing"""
|
||||
return {
|
||||
"center": (40.4168, -3.7038), # Madrid center
|
||||
"north": (40.4677, -3.5552), # Madrid north (near station)
|
||||
"south": (40.2987, -3.7216), # Madrid south (near station)
|
||||
"east": (40.4200, -3.6500), # Madrid east
|
||||
"west": (40.4100, -3.7500), # Madrid west
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def invalid_coords():
|
||||
"""Invalid coordinates for error testing"""
|
||||
return [
|
||||
(200, 200), # Out of range
|
||||
(-200, -200), # Out of range
|
||||
(0, 0), # Not in Madrid area
|
||||
(50, 10), # Europe but not Madrid
|
||||
(None, None), # None values
|
||||
]
|
||||
|
||||
|
||||
# ================================================================
|
||||
# DATE AND TIME FIXTURES
|
||||
# ================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def test_dates():
|
||||
"""Standard date ranges for testing"""
|
||||
now = datetime.now()
|
||||
return {
|
||||
"now": now,
|
||||
"yesterday": now - timedelta(days=1),
|
||||
"last_week": now - timedelta(days=7),
|
||||
"last_month": now - timedelta(days=30),
|
||||
"last_quarter": now - timedelta(days=90),
|
||||
"one_year_ago": now - timedelta(days=365),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def historical_date_ranges():
|
||||
"""Historical date ranges for testing"""
|
||||
end_date = datetime.now()
|
||||
return {
|
||||
"one_day": {
|
||||
"start": end_date - timedelta(days=1),
|
||||
"end": end_date,
|
||||
"expected_days": 1
|
||||
},
|
||||
"one_week": {
|
||||
"start": end_date - timedelta(days=7),
|
||||
"end": end_date,
|
||||
"expected_days": 7
|
||||
},
|
||||
"one_month": {
|
||||
"start": end_date - timedelta(days=30),
|
||||
"end": end_date,
|
||||
"expected_days": 30
|
||||
},
|
||||
"large_range": {
|
||||
"start": end_date - timedelta(days=65),
|
||||
"end": end_date,
|
||||
"expected_days": 65
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# MOCK API RESPONSE FIXTURES
|
||||
# ================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def mock_aemet_api_response():
|
||||
"""Mock AEMET API initial response structure"""
|
||||
return {
|
||||
"datos": "https://opendata.aemet.es/opendata/sh/12345abcdef",
|
||||
"metadatos": "https://opendata.aemet.es/opendata/sh/metadata123"
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_aemet_error_response():
|
||||
"""Mock AEMET API error response"""
|
||||
return {
|
||||
"descripcion": "Error en la petición",
|
||||
"estado": 404
|
||||
}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# WEATHER DATA FIXTURES
|
||||
# ================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def mock_current_weather_data():
|
||||
"""Mock current weather data from AEMET API"""
|
||||
return {
|
||||
"idema": "3195", # Station ID
|
||||
"ubi": "MADRID", # Location
|
||||
"fint": "2025-07-24T14:00:00", # Observation time
|
||||
"ta": 18.5, # Temperature (°C)
|
||||
"tamin": 12.3, # Min temperature
|
||||
"tamax": 25.7, # Max temperature
|
||||
"hr": 65.0, # Humidity (%)
|
||||
"prec": 0.0, # Precipitation (mm)
|
||||
"vv": 12.0, # Wind speed (km/h)
|
||||
"dv": 180, # Wind direction (degrees)
|
||||
"pres": 1015.2, # Pressure (hPa)
|
||||
"presMax": 1018.5, # Max pressure
|
||||
"presMin": 1012.1, # Min pressure
|
||||
"descripcion": "Despejado" # Description
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_forecast_data():
|
||||
"""Mock forecast data from AEMET API"""
|
||||
return [{
|
||||
"origen": {
|
||||
"productor": "Agencia Estatal de Meteorología - AEMET"
|
||||
},
|
||||
"elaborado": "2025-07-24T12:00:00UTC",
|
||||
"nombre": "Madrid",
|
||||
"provincia": "Madrid",
|
||||
"prediccion": {
|
||||
"dia": [
|
||||
{
|
||||
"fecha": "2025-07-25T00:00:00",
|
||||
"temperatura": {
|
||||
"maxima": 28,
|
||||
"minima": 15,
|
||||
"dato": [
|
||||
{"value": 15, "hora": 6},
|
||||
{"value": 28, "hora": 15}
|
||||
]
|
||||
},
|
||||
"sensTermica": {
|
||||
"maxima": 30,
|
||||
"minima": 16
|
||||
},
|
||||
"humedadRelativa": {
|
||||
"maxima": 85,
|
||||
"minima": 45,
|
||||
"dato": [
|
||||
{"value": 85, "hora": 6},
|
||||
{"value": 45, "hora": 15}
|
||||
]
|
||||
},
|
||||
"probPrecipitacion": [
|
||||
{"value": 10, "periodo": "00-24"}
|
||||
],
|
||||
"viento": [
|
||||
{
|
||||
"direccion": ["N"],
|
||||
"velocidad": [15],
|
||||
"periodo": "00-24"
|
||||
}
|
||||
],
|
||||
"estadoCielo": [
|
||||
{
|
||||
"value": "11",
|
||||
"descripcion": "Despejado",
|
||||
"periodo": "00-24"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"fecha": "2025-07-26T00:00:00",
|
||||
"temperatura": {
|
||||
"maxima": 30,
|
||||
"minima": 17
|
||||
},
|
||||
"probPrecipitacion": [
|
||||
{"value": 5, "periodo": "00-24"}
|
||||
],
|
||||
"viento": [
|
||||
{
|
||||
"direccion": ["NE"],
|
||||
"velocidad": [10],
|
||||
"periodo": "00-24"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_historical_data():
|
||||
"""Mock historical weather data from AEMET API"""
|
||||
return [
|
||||
{
|
||||
"indicativo": "3195",
|
||||
"nombre": "MADRID",
|
||||
"fecha": "2025-07-20",
|
||||
"tmax": 25.2,
|
||||
"horatmax": "1530",
|
||||
"tmin": 14.8,
|
||||
"horatmin": "0630",
|
||||
"tmed": 20.0,
|
||||
"prec": 0.0,
|
||||
"racha": 25.0,
|
||||
"horaracha": "1445",
|
||||
"sol": 8.5,
|
||||
"presMax": 1018.5,
|
||||
"horaPresMax": "1000",
|
||||
"presMin": 1012.3,
|
||||
"horaPresMin": "1700",
|
||||
"hr": 58,
|
||||
"velmedia": 8.5,
|
||||
"dir": "180"
|
||||
},
|
||||
{
|
||||
"indicativo": "3195",
|
||||
"nombre": "MADRID",
|
||||
"fecha": "2025-07-21",
|
||||
"tmax": 27.1,
|
||||
"horatmax": "1615",
|
||||
"tmin": 16.2,
|
||||
"horatmin": "0700",
|
||||
"tmed": 21.6,
|
||||
"prec": 2.5,
|
||||
"racha": 30.0,
|
||||
"horaracha": "1330",
|
||||
"sol": 6.2,
|
||||
"presMax": 1015.8,
|
||||
"horaPresMax": "0930",
|
||||
"presMin": 1010.1,
|
||||
"horaPresMin": "1800",
|
||||
"hr": 72,
|
||||
"velmedia": 12.0,
|
||||
"dir": "225"
|
||||
},
|
||||
{
|
||||
"indicativo": "3195",
|
||||
"nombre": "MADRID",
|
||||
"fecha": "2025-07-22",
|
||||
"tmax": 23.8,
|
||||
"horatmax": "1500",
|
||||
"tmin": 13.5,
|
||||
"horatmin": "0615",
|
||||
"tmed": 18.7,
|
||||
"prec": 0.2,
|
||||
"racha": 22.0,
|
||||
"horaracha": "1200",
|
||||
"sol": 7.8,
|
||||
"presMax": 1020.2,
|
||||
"horaPresMax": "1100",
|
||||
"presMin": 1014.7,
|
||||
"horaPresMin": "1900",
|
||||
"hr": 63,
|
||||
"velmedia": 9.2,
|
||||
"dir": "270"
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
# ================================================================
|
||||
# EXPECTED RESULT FIXTURES
|
||||
# ================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def expected_current_weather_structure():
|
||||
"""Expected structure for current weather results"""
|
||||
return {
|
||||
"required_fields": [
|
||||
"date", "temperature", "precipitation", "humidity",
|
||||
"wind_speed", "pressure", "description", "source"
|
||||
],
|
||||
"field_types": {
|
||||
"date": datetime,
|
||||
"temperature": (int, float),
|
||||
"precipitation": (int, float),
|
||||
"humidity": (int, float),
|
||||
"wind_speed": (int, float),
|
||||
"pressure": (int, float),
|
||||
"description": str,
|
||||
"source": str
|
||||
},
|
||||
"valid_ranges": {
|
||||
"temperature": (-30, 50),
|
||||
"precipitation": (0, 200),
|
||||
"humidity": (0, 100),
|
||||
"wind_speed": (0, 200),
|
||||
"pressure": (900, 1100)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def expected_forecast_structure():
|
||||
"""Expected structure for forecast results"""
|
||||
return {
|
||||
"required_fields": [
|
||||
"forecast_date", "generated_at", "temperature", "precipitation",
|
||||
"humidity", "wind_speed", "description", "source"
|
||||
],
|
||||
"field_types": {
|
||||
"forecast_date": datetime,
|
||||
"generated_at": datetime,
|
||||
"temperature": (int, float),
|
||||
"precipitation": (int, float),
|
||||
"humidity": (int, float),
|
||||
"wind_speed": (int, float),
|
||||
"description": str,
|
||||
"source": str
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def expected_historical_structure():
|
||||
"""Expected structure for historical weather results"""
|
||||
return {
|
||||
"required_fields": [
|
||||
"date", "temperature", "precipitation", "humidity",
|
||||
"wind_speed", "pressure", "description", "source"
|
||||
],
|
||||
"field_types": {
|
||||
"date": datetime,
|
||||
"temperature": (int, float, type(None)),
|
||||
"precipitation": (int, float),
|
||||
"humidity": (int, float, type(None)),
|
||||
"wind_speed": (int, float, type(None)),
|
||||
"pressure": (int, float, type(None)),
|
||||
"description": str,
|
||||
"source": str
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# MOCK AND PATCH FIXTURES
|
||||
# ================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def mock_successful_api_calls():
|
||||
"""Mock successful AEMET API calls"""
|
||||
def _mock_api_calls(client, response_data, fetch_data):
|
||||
with patch.object(client, '_get', new_callable=AsyncMock) as mock_get, \
|
||||
patch.object(client, '_fetch_from_url', new_callable=AsyncMock) as mock_fetch:
|
||||
|
||||
mock_get.return_value = response_data
|
||||
mock_fetch.return_value = fetch_data
|
||||
|
||||
return mock_get, mock_fetch
|
||||
|
||||
return _mock_api_calls
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_failed_api_calls():
|
||||
"""Mock failed AEMET API calls"""
|
||||
def _mock_failed_calls(client, error_type="network"):
|
||||
if error_type == "network":
|
||||
return patch.object(client, '_get', side_effect=Exception("Network error"))
|
||||
elif error_type == "timeout":
|
||||
return patch.object(client, '_get', side_effect=asyncio.TimeoutError("Request timeout"))
|
||||
elif error_type == "invalid_response":
|
||||
return patch.object(client, '_get', new_callable=AsyncMock, return_value=None)
|
||||
else:
|
||||
return patch.object(client, '_get', new_callable=AsyncMock, return_value={"error": "API error"})
|
||||
|
||||
return _mock_failed_calls
|
||||
|
||||
|
||||
# ================================================================
|
||||
# VALIDATION HELPER FIXTURES
|
||||
# ================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def weather_data_validator():
|
||||
"""Weather data validation helper functions"""
|
||||
|
||||
def validate_weather_record(record: Dict[str, Any], expected_structure: Dict[str, Any]) -> None:
|
||||
"""Validate a weather record against expected structure"""
|
||||
# Check required fields
|
||||
for field in expected_structure["required_fields"]:
|
||||
assert field in record, f"Missing required field: {field}"
|
||||
|
||||
# Check field types
|
||||
for field, expected_type in expected_structure["field_types"].items():
|
||||
if field in record and record[field] is not None:
|
||||
assert isinstance(record[field], expected_type), f"Field {field} has wrong type: {type(record[field])}"
|
||||
|
||||
# Check valid ranges where applicable
|
||||
if "valid_ranges" in expected_structure:
|
||||
for field, (min_val, max_val) in expected_structure["valid_ranges"].items():
|
||||
if field in record and record[field] is not None:
|
||||
value = record[field]
|
||||
assert min_val <= value <= max_val, f"Field {field} value {value} outside valid range [{min_val}, {max_val}]"
|
||||
|
||||
def validate_weather_list(records: List[Dict[str, Any]], expected_structure: Dict[str, Any]) -> None:
|
||||
"""Validate a list of weather records"""
|
||||
assert isinstance(records, list), "Records should be a list"
|
||||
|
||||
for i, record in enumerate(records):
|
||||
try:
|
||||
validate_weather_record(record, expected_structure)
|
||||
except AssertionError as e:
|
||||
raise AssertionError(f"Record {i} validation failed: {e}")
|
||||
|
||||
def validate_date_sequence(records: List[Dict[str, Any]], date_field: str = "date") -> None:
|
||||
"""Validate that dates in records are in chronological order"""
|
||||
dates = [r[date_field] for r in records if date_field in r and r[date_field] is not None]
|
||||
|
||||
if len(dates) > 1:
|
||||
assert dates == sorted(dates), "Dates should be in chronological order"
|
||||
|
||||
return {
|
||||
"validate_record": validate_weather_record,
|
||||
"validate_list": validate_weather_list,
|
||||
"validate_dates": validate_date_sequence
|
||||
}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# PERFORMANCE TESTING FIXTURES
|
||||
# ================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def performance_tracker():
|
||||
"""Performance tracking utilities for tests"""
|
||||
|
||||
class PerformanceTracker:
|
||||
def __init__(self):
|
||||
self.start_time = None
|
||||
self.measurements = {}
|
||||
|
||||
def start(self, operation_name: str = "default"):
|
||||
self.start_time = datetime.now()
|
||||
self.operation_name = operation_name
|
||||
|
||||
def stop(self) -> float:
|
||||
if self.start_time:
|
||||
duration = (datetime.now() - self.start_time).total_seconds() * 1000
|
||||
self.measurements[self.operation_name] = duration
|
||||
return duration
|
||||
return 0.0
|
||||
|
||||
def assert_performance(self, max_duration_ms: float, operation_name: str = "default"):
|
||||
duration = self.measurements.get(operation_name, float('inf'))
|
||||
assert duration <= max_duration_ms, f"Operation {operation_name} took {duration:.0f}ms, expected <= {max_duration_ms}ms"
|
||||
|
||||
return PerformanceTracker()
|
||||
|
||||
|
||||
# ================================================================
|
||||
# INTEGRATION TEST FIXTURES
|
||||
# ================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def integration_test_config():
|
||||
"""Configuration for integration tests"""
|
||||
return {
|
||||
"api_timeout_ms": 5000,
|
||||
"max_retries": 3,
|
||||
"test_api_key": os.getenv("AEMET_API_KEY_TEST", ""),
|
||||
"skip_real_api_tests": os.getenv("SKIP_REAL_API_TESTS", "false").lower() == "true",
|
||||
"madrid_test_coords": (40.4168, -3.7038),
|
||||
"performance_thresholds": {
|
||||
"current_weather_ms": 5000,
|
||||
"forecast_ms": 5000,
|
||||
"historical_ms": 10000
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# TEST REPORTING FIXTURES
|
||||
# ================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def test_reporter():
|
||||
"""Test reporting utilities"""
|
||||
|
||||
class TestReporter:
|
||||
def __init__(self):
|
||||
self.results = []
|
||||
|
||||
def log_success(self, test_name: str, details: str = ""):
|
||||
message = f"✅ {test_name}"
|
||||
if details:
|
||||
message += f" - {details}"
|
||||
print(message)
|
||||
self.results.append({"test": test_name, "status": "PASS", "details": details})
|
||||
|
||||
def log_failure(self, test_name: str, error: str = ""):
|
||||
message = f"❌ {test_name}"
|
||||
if error:
|
||||
message += f" - {error}"
|
||||
print(message)
|
||||
self.results.append({"test": test_name, "status": "FAIL", "error": error})
|
||||
|
||||
def log_info(self, test_name: str, info: str = ""):
|
||||
message = f"ℹ️ {test_name}"
|
||||
if info:
|
||||
message += f" - {info}"
|
||||
print(message)
|
||||
self.results.append({"test": test_name, "status": "INFO", "info": info})
|
||||
|
||||
def summary(self):
|
||||
passed = len([r for r in self.results if r["status"] == "PASS"])
|
||||
failed = len([r for r in self.results if r["status"] == "FAIL"])
|
||||
print(f"\n📊 Test Summary: {passed} passed, {failed} failed")
|
||||
return passed, failed
|
||||
|
||||
return TestReporter()
|
||||
|
||||
|
||||
# ================================================================
|
||||
# CLEANUP FIXTURES
|
||||
# ================================================================
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def cleanup_after_test():
|
||||
"""Automatic cleanup after each test"""
|
||||
yield
|
||||
# Add any cleanup logic here
|
||||
# For example, clearing caches, resetting global state, etc.
|
||||
pass
|
||||
|
||||
|
||||
# ================================================================
|
||||
# HELPER FUNCTIONS
|
||||
# ================================================================
|
||||
|
||||
def assert_weather_data_structure(data: Dict[str, Any], data_type: str = "current"):
|
||||
"""Assert that weather data has the correct structure"""
|
||||
if data_type == "current":
|
||||
required_fields = ["date", "temperature", "precipitation", "humidity", "wind_speed", "pressure", "description", "source"]
|
||||
elif data_type == "forecast":
|
||||
required_fields = ["forecast_date", "generated_at", "temperature", "precipitation", "humidity", "wind_speed", "description", "source"]
|
||||
elif data_type == "historical":
|
||||
required_fields = ["date", "temperature", "precipitation", "humidity", "wind_speed", "pressure", "description", "source"]
|
||||
else:
|
||||
raise ValueError(f"Unknown data type: {data_type}")
|
||||
|
||||
for field in required_fields:
|
||||
assert field in data, f"Missing required field: {field}"
|
||||
|
||||
# Validate source
|
||||
valid_sources = [WeatherSource.AEMET.value, WeatherSource.SYNTHETIC.value, WeatherSource.DEFAULT.value]
|
||||
assert data["source"] in valid_sources, f"Invalid source: {data['source']}"
|
||||
|
||||
|
||||
def assert_forecast_list_structure(forecast_list: List[Dict[str, Any]], expected_days: int):
|
||||
"""Assert that forecast list has correct structure"""
|
||||
assert isinstance(forecast_list, list), "Forecast should be a list"
|
||||
assert len(forecast_list) == expected_days, f"Expected {expected_days} forecast days, got {len(forecast_list)}"
|
||||
|
||||
for i, day in enumerate(forecast_list):
|
||||
assert_weather_data_structure(day, "forecast")
|
||||
|
||||
# Check date progression
|
||||
if len(forecast_list) > 1:
|
||||
for i in range(1, len(forecast_list)):
|
||||
prev_date = forecast_list[i-1]["forecast_date"]
|
||||
curr_date = forecast_list[i]["forecast_date"]
|
||||
date_diff = (curr_date - prev_date).days
|
||||
assert date_diff == 1, f"Forecast dates should be consecutive, got {date_diff} day difference"
|
||||
|
||||
|
||||
def assert_historical_list_structure(historical_list: List[Dict[str, Any]]):
|
||||
"""Assert that historical list has correct structure"""
|
||||
assert isinstance(historical_list, list), "Historical data should be a list"
|
||||
|
||||
for i, record in enumerate(historical_list):
|
||||
assert_weather_data_structure(record, "historical")
|
||||
|
||||
# Check date ordering
|
||||
dates = [r["date"] for r in historical_list if "date" in r]
|
||||
if len(dates) > 1:
|
||||
assert dates == sorted(dates), "Historical dates should be in chronological order"
|
||||
@@ -1,16 +1,44 @@
|
||||
[tool:pytest]
|
||||
# pytest.ini - Configuration for async testing
|
||||
asyncio_mode = auto
|
||||
addopts = -v --tb=short --capture=no
|
||||
# pytest.ini - Configuration file for AEMET tests
|
||||
|
||||
# Minimum version requirements
|
||||
minversion = 6.0
|
||||
|
||||
# Add options
|
||||
addopts =
|
||||
-ra
|
||||
--strict-markers
|
||||
--strict-config
|
||||
--disable-warnings
|
||||
--tb=short
|
||||
-v
|
||||
|
||||
# Test discovery
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
|
||||
# Async support
|
||||
asyncio_mode = auto
|
||||
|
||||
# Markers
|
||||
markers =
|
||||
asyncio: mark test as async
|
||||
slow: mark test as slow
|
||||
integration: mark test as integration test
|
||||
unit: Unit tests
|
||||
integration: Integration tests
|
||||
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 =
|
||||
ignore::DeprecationWarning
|
||||
ignore::PendingDeprecationWarning
|
||||
ignore::PydanticDeprecatedSince20
|
||||
ignore::PytestUnhandledCoroutineWarning
|
||||
677
services/data/tests/test_aemet.py
Normal file
677
services/data/tests/test_aemet.py
Normal 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())
|
||||
594
services/data/tests/test_aemet_edge_cases.py
Normal file
594
services/data/tests/test_aemet_edge_cases.py
Normal 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())
|
||||
Reference in New Issue
Block a user