init commit
This commit is contained in:
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Test suite for weather alerts."""
|
||||
319
tests/test_rule_engine.py
Normal file
319
tests/test_rule_engine.py
Normal file
@@ -0,0 +1,319 @@
|
||||
"""Tests for the rule engine."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.alerts import (
|
||||
AlertRules,
|
||||
AlertType,
|
||||
PrecipitationRule,
|
||||
SevereWeatherRule,
|
||||
TemperatureRule,
|
||||
WindRule,
|
||||
)
|
||||
from app.models.weather import HourlyForecast, WeatherAlert, WeatherForecast
|
||||
from app.services.rule_engine import RuleEngine
|
||||
|
||||
|
||||
def make_hourly_forecast(
|
||||
temp: float = 70,
|
||||
precip_prob: float = 0,
|
||||
wind_speed: float = 5,
|
||||
wind_gust: float = 10,
|
||||
hour_offset: int = 0,
|
||||
) -> HourlyForecast:
|
||||
"""Create a test HourlyForecast."""
|
||||
base_epoch = int(datetime.now().timestamp()) + (hour_offset * 3600)
|
||||
return HourlyForecast(
|
||||
datetime_str=datetime.fromtimestamp(base_epoch).strftime("%H:%M:%S"),
|
||||
datetime_epoch=base_epoch,
|
||||
temp=temp,
|
||||
feelslike=temp,
|
||||
humidity=50,
|
||||
precip=0,
|
||||
precip_prob=precip_prob,
|
||||
snow=0,
|
||||
snow_depth=0,
|
||||
wind_speed=wind_speed,
|
||||
wind_gust=wind_gust,
|
||||
wind_dir=180,
|
||||
pressure=30,
|
||||
visibility=10,
|
||||
cloud_cover=20,
|
||||
uv_index=5,
|
||||
conditions="Clear",
|
||||
icon="clear-day",
|
||||
)
|
||||
|
||||
|
||||
class TestTemperatureRules:
|
||||
"""Test temperature alert rules."""
|
||||
|
||||
def test_low_temperature_triggers_alert(self) -> None:
|
||||
"""Test that temperature below threshold triggers alert."""
|
||||
rules = AlertRules(
|
||||
temperature=TemperatureRule(enabled=True, below=32, above=100),
|
||||
precipitation=PrecipitationRule(enabled=False),
|
||||
wind=WindRule(enabled=False),
|
||||
severe_weather=SevereWeatherRule(enabled=False),
|
||||
)
|
||||
engine = RuleEngine(rules)
|
||||
|
||||
forecast = WeatherForecast(
|
||||
location="test",
|
||||
resolved_address="Test Location",
|
||||
timezone="America/Chicago",
|
||||
hourly_forecasts=[make_hourly_forecast(temp=28, hour_offset=1)],
|
||||
alerts=[],
|
||||
)
|
||||
|
||||
triggered = engine.evaluate(forecast)
|
||||
|
||||
assert len(triggered) == 1
|
||||
assert triggered[0].alert_type == AlertType.TEMPERATURE_LOW
|
||||
assert triggered[0].value == 28
|
||||
assert triggered[0].threshold == 32
|
||||
|
||||
def test_high_temperature_triggers_alert(self) -> None:
|
||||
"""Test that temperature above threshold triggers alert."""
|
||||
rules = AlertRules(
|
||||
temperature=TemperatureRule(enabled=True, below=32, above=100),
|
||||
precipitation=PrecipitationRule(enabled=False),
|
||||
wind=WindRule(enabled=False),
|
||||
severe_weather=SevereWeatherRule(enabled=False),
|
||||
)
|
||||
engine = RuleEngine(rules)
|
||||
|
||||
forecast = WeatherForecast(
|
||||
location="test",
|
||||
resolved_address="Test Location",
|
||||
timezone="America/Chicago",
|
||||
hourly_forecasts=[make_hourly_forecast(temp=105, hour_offset=1)],
|
||||
alerts=[],
|
||||
)
|
||||
|
||||
triggered = engine.evaluate(forecast)
|
||||
|
||||
assert len(triggered) == 1
|
||||
assert triggered[0].alert_type == AlertType.TEMPERATURE_HIGH
|
||||
assert triggered[0].value == 105
|
||||
|
||||
def test_normal_temperature_no_alert(self) -> None:
|
||||
"""Test that normal temperature doesn't trigger alert."""
|
||||
rules = AlertRules(
|
||||
temperature=TemperatureRule(enabled=True, below=32, above=100),
|
||||
precipitation=PrecipitationRule(enabled=False),
|
||||
wind=WindRule(enabled=False),
|
||||
severe_weather=SevereWeatherRule(enabled=False),
|
||||
)
|
||||
engine = RuleEngine(rules)
|
||||
|
||||
forecast = WeatherForecast(
|
||||
location="test",
|
||||
resolved_address="Test Location",
|
||||
timezone="America/Chicago",
|
||||
hourly_forecasts=[make_hourly_forecast(temp=70, hour_offset=1)],
|
||||
alerts=[],
|
||||
)
|
||||
|
||||
triggered = engine.evaluate(forecast)
|
||||
|
||||
assert len(triggered) == 0
|
||||
|
||||
def test_disabled_rule_no_alert(self) -> None:
|
||||
"""Test that disabled rules don't trigger alerts."""
|
||||
rules = AlertRules(
|
||||
temperature=TemperatureRule(enabled=False, below=32, above=100),
|
||||
precipitation=PrecipitationRule(enabled=False),
|
||||
wind=WindRule(enabled=False),
|
||||
severe_weather=SevereWeatherRule(enabled=False),
|
||||
)
|
||||
engine = RuleEngine(rules)
|
||||
|
||||
forecast = WeatherForecast(
|
||||
location="test",
|
||||
resolved_address="Test Location",
|
||||
timezone="America/Chicago",
|
||||
hourly_forecasts=[make_hourly_forecast(temp=20, hour_offset=1)],
|
||||
alerts=[],
|
||||
)
|
||||
|
||||
triggered = engine.evaluate(forecast)
|
||||
|
||||
assert len(triggered) == 0
|
||||
|
||||
|
||||
class TestPrecipitationRules:
|
||||
"""Test precipitation alert rules."""
|
||||
|
||||
def test_high_precipitation_triggers_alert(self) -> None:
|
||||
"""Test that high precipitation probability triggers alert."""
|
||||
rules = AlertRules(
|
||||
temperature=TemperatureRule(enabled=False),
|
||||
precipitation=PrecipitationRule(enabled=True, probability_above=70),
|
||||
wind=WindRule(enabled=False),
|
||||
severe_weather=SevereWeatherRule(enabled=False),
|
||||
)
|
||||
engine = RuleEngine(rules)
|
||||
|
||||
forecast = WeatherForecast(
|
||||
location="test",
|
||||
resolved_address="Test Location",
|
||||
timezone="America/Chicago",
|
||||
hourly_forecasts=[make_hourly_forecast(precip_prob=85, hour_offset=1)],
|
||||
alerts=[],
|
||||
)
|
||||
|
||||
triggered = engine.evaluate(forecast)
|
||||
|
||||
assert len(triggered) == 1
|
||||
assert triggered[0].alert_type == AlertType.PRECIPITATION
|
||||
assert triggered[0].value == 85
|
||||
|
||||
|
||||
class TestWindRules:
|
||||
"""Test wind alert rules."""
|
||||
|
||||
def test_high_wind_speed_triggers_alert(self) -> None:
|
||||
"""Test that high wind speed triggers alert."""
|
||||
rules = AlertRules(
|
||||
temperature=TemperatureRule(enabled=False),
|
||||
precipitation=PrecipitationRule(enabled=False),
|
||||
wind=WindRule(enabled=True, speed_above=25, gust_above=40),
|
||||
severe_weather=SevereWeatherRule(enabled=False),
|
||||
)
|
||||
engine = RuleEngine(rules)
|
||||
|
||||
forecast = WeatherForecast(
|
||||
location="test",
|
||||
resolved_address="Test Location",
|
||||
timezone="America/Chicago",
|
||||
hourly_forecasts=[make_hourly_forecast(wind_speed=30, hour_offset=1)],
|
||||
alerts=[],
|
||||
)
|
||||
|
||||
triggered = engine.evaluate(forecast)
|
||||
|
||||
assert len(triggered) == 1
|
||||
assert triggered[0].alert_type == AlertType.WIND_SPEED
|
||||
|
||||
def test_high_wind_gust_triggers_alert(self) -> None:
|
||||
"""Test that high wind gust triggers alert."""
|
||||
rules = AlertRules(
|
||||
temperature=TemperatureRule(enabled=False),
|
||||
precipitation=PrecipitationRule(enabled=False),
|
||||
wind=WindRule(enabled=True, speed_above=25, gust_above=40),
|
||||
severe_weather=SevereWeatherRule(enabled=False),
|
||||
)
|
||||
engine = RuleEngine(rules)
|
||||
|
||||
forecast = WeatherForecast(
|
||||
location="test",
|
||||
resolved_address="Test Location",
|
||||
timezone="America/Chicago",
|
||||
hourly_forecasts=[make_hourly_forecast(wind_gust=50, hour_offset=1)],
|
||||
alerts=[],
|
||||
)
|
||||
|
||||
triggered = engine.evaluate(forecast)
|
||||
|
||||
assert len(triggered) == 1
|
||||
assert triggered[0].alert_type == AlertType.WIND_GUST
|
||||
|
||||
def test_both_wind_conditions_trigger_two_alerts(self) -> None:
|
||||
"""Test that high wind speed and gust trigger separate alerts."""
|
||||
rules = AlertRules(
|
||||
temperature=TemperatureRule(enabled=False),
|
||||
precipitation=PrecipitationRule(enabled=False),
|
||||
wind=WindRule(enabled=True, speed_above=25, gust_above=40),
|
||||
severe_weather=SevereWeatherRule(enabled=False),
|
||||
)
|
||||
engine = RuleEngine(rules)
|
||||
|
||||
forecast = WeatherForecast(
|
||||
location="test",
|
||||
resolved_address="Test Location",
|
||||
timezone="America/Chicago",
|
||||
hourly_forecasts=[
|
||||
make_hourly_forecast(wind_speed=30, wind_gust=50, hour_offset=1)
|
||||
],
|
||||
alerts=[],
|
||||
)
|
||||
|
||||
triggered = engine.evaluate(forecast)
|
||||
|
||||
assert len(triggered) == 2
|
||||
alert_types = {t.alert_type for t in triggered}
|
||||
assert AlertType.WIND_SPEED in alert_types
|
||||
assert AlertType.WIND_GUST in alert_types
|
||||
|
||||
|
||||
class TestSevereWeatherRules:
|
||||
"""Test severe weather alert forwarding."""
|
||||
|
||||
def test_severe_weather_alert_forwarded(self) -> None:
|
||||
"""Test that severe weather alerts from API are forwarded."""
|
||||
rules = AlertRules(
|
||||
temperature=TemperatureRule(enabled=False),
|
||||
precipitation=PrecipitationRule(enabled=False),
|
||||
wind=WindRule(enabled=False),
|
||||
severe_weather=SevereWeatherRule(enabled=True),
|
||||
)
|
||||
engine = RuleEngine(rules)
|
||||
|
||||
api_alert = WeatherAlert(
|
||||
event="Tornado Warning",
|
||||
headline="Tornado Warning in effect",
|
||||
description="A tornado has been spotted in the area.",
|
||||
onset="2024-01-15T12:00:00",
|
||||
ends="2024-01-15T14:00:00",
|
||||
id="NWS-123",
|
||||
language="en",
|
||||
link="https://weather.gov/alerts",
|
||||
)
|
||||
|
||||
forecast = WeatherForecast(
|
||||
location="test",
|
||||
resolved_address="Test Location",
|
||||
timezone="America/Chicago",
|
||||
hourly_forecasts=[],
|
||||
alerts=[api_alert],
|
||||
)
|
||||
|
||||
triggered = engine.evaluate(forecast)
|
||||
|
||||
assert len(triggered) == 1
|
||||
assert triggered[0].alert_type == AlertType.SEVERE_WEATHER
|
||||
assert "Tornado Warning" in triggered[0].title
|
||||
|
||||
|
||||
class TestMultipleHours:
|
||||
"""Test evaluation across multiple forecast hours."""
|
||||
|
||||
def test_multiple_hours_can_trigger_alerts(self) -> None:
|
||||
"""Test that alerts can trigger for different hours."""
|
||||
rules = AlertRules(
|
||||
temperature=TemperatureRule(enabled=True, below=32, above=100),
|
||||
precipitation=PrecipitationRule(enabled=False),
|
||||
wind=WindRule(enabled=False),
|
||||
severe_weather=SevereWeatherRule(enabled=False),
|
||||
)
|
||||
engine = RuleEngine(rules)
|
||||
|
||||
forecast = WeatherForecast(
|
||||
location="test",
|
||||
resolved_address="Test Location",
|
||||
timezone="America/Chicago",
|
||||
hourly_forecasts=[
|
||||
make_hourly_forecast(temp=28, hour_offset=1),
|
||||
make_hourly_forecast(temp=70, hour_offset=2),
|
||||
make_hourly_forecast(temp=25, hour_offset=3),
|
||||
],
|
||||
alerts=[],
|
||||
)
|
||||
|
||||
triggered = engine.evaluate(forecast)
|
||||
|
||||
assert len(triggered) == 2
|
||||
assert all(t.alert_type == AlertType.TEMPERATURE_LOW for t in triggered)
|
||||
175
tests/test_state_manager.py
Normal file
175
tests/test_state_manager.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""Tests for the state manager."""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.alerts import AlertType, TriggeredAlert
|
||||
from app.services.state_manager import StateManager
|
||||
|
||||
|
||||
def make_triggered_alert(
|
||||
alert_type: AlertType = AlertType.TEMPERATURE_LOW,
|
||||
forecast_hour: str = "2024-01-15-12",
|
||||
) -> TriggeredAlert:
|
||||
"""Create a test triggered alert."""
|
||||
return TriggeredAlert(
|
||||
alert_type=alert_type,
|
||||
title="Test Alert",
|
||||
message="Test message",
|
||||
forecast_hour=forecast_hour,
|
||||
value=25.0,
|
||||
threshold=32.0,
|
||||
)
|
||||
|
||||
|
||||
class TestStateManager:
|
||||
"""Test state manager functionality."""
|
||||
|
||||
def test_load_creates_empty_state_if_no_file(self, tmp_path: Path) -> None:
|
||||
"""Test that loading from nonexistent file creates empty state."""
|
||||
state_file = tmp_path / "state.json"
|
||||
manager = StateManager(str(state_file))
|
||||
|
||||
state = manager.load()
|
||||
|
||||
assert len(state.sent_alerts) == 0
|
||||
|
||||
def test_save_creates_file(self, tmp_path: Path) -> None:
|
||||
"""Test that saving creates the state file."""
|
||||
state_file = tmp_path / "subdir" / "state.json"
|
||||
manager = StateManager(str(state_file))
|
||||
|
||||
alert = make_triggered_alert()
|
||||
manager.record_sent(alert)
|
||||
manager.save()
|
||||
|
||||
assert state_file.exists()
|
||||
|
||||
def test_save_and_load_round_trip(self, tmp_path: Path) -> None:
|
||||
"""Test that state survives save/load cycle."""
|
||||
state_file = tmp_path / "state.json"
|
||||
manager = StateManager(str(state_file))
|
||||
|
||||
alert = make_triggered_alert()
|
||||
manager.record_sent(alert)
|
||||
manager.save()
|
||||
|
||||
# Create new manager and load
|
||||
manager2 = StateManager(str(state_file))
|
||||
state = manager2.load()
|
||||
|
||||
assert len(state.sent_alerts) == 1
|
||||
assert alert.dedup_key in state.sent_alerts
|
||||
|
||||
def test_filter_duplicates_removes_known_alerts(self, tmp_path: Path) -> None:
|
||||
"""Test that known alerts are filtered out."""
|
||||
state_file = tmp_path / "state.json"
|
||||
manager = StateManager(str(state_file))
|
||||
|
||||
# Record an alert as sent
|
||||
alert1 = make_triggered_alert(forecast_hour="2024-01-15-12")
|
||||
manager.record_sent(alert1)
|
||||
|
||||
# Try to send the same alert again plus a new one
|
||||
alert2 = make_triggered_alert(forecast_hour="2024-01-15-12") # Duplicate
|
||||
alert3 = make_triggered_alert(forecast_hour="2024-01-15-13") # New
|
||||
|
||||
filtered = manager.filter_duplicates([alert2, alert3])
|
||||
|
||||
assert len(filtered) == 1
|
||||
assert filtered[0].forecast_hour == "2024-01-15-13"
|
||||
|
||||
def test_different_alert_types_not_duplicates(self, tmp_path: Path) -> None:
|
||||
"""Test that different alert types for same hour aren't duplicates."""
|
||||
state_file = tmp_path / "state.json"
|
||||
manager = StateManager(str(state_file))
|
||||
|
||||
alert1 = make_triggered_alert(
|
||||
alert_type=AlertType.TEMPERATURE_LOW, forecast_hour="2024-01-15-12"
|
||||
)
|
||||
manager.record_sent(alert1)
|
||||
|
||||
alert2 = make_triggered_alert(
|
||||
alert_type=AlertType.PRECIPITATION, forecast_hour="2024-01-15-12"
|
||||
)
|
||||
|
||||
filtered = manager.filter_duplicates([alert2])
|
||||
|
||||
assert len(filtered) == 1
|
||||
|
||||
def test_purge_old_records(self, tmp_path: Path) -> None:
|
||||
"""Test that old records are purged."""
|
||||
state_file = tmp_path / "state.json"
|
||||
manager = StateManager(str(state_file), dedup_window_hours=24)
|
||||
|
||||
# Manually create an old record
|
||||
alert = make_triggered_alert()
|
||||
manager.state.sent_alerts[alert.dedup_key] = manager.state.sent_alerts.get(
|
||||
alert.dedup_key
|
||||
) or type(
|
||||
"SentAlertRecord",
|
||||
(),
|
||||
{
|
||||
"dedup_key": alert.dedup_key,
|
||||
"alert_type": alert.alert_type.value,
|
||||
"sent_at": datetime.now() - timedelta(hours=48),
|
||||
"forecast_hour": alert.forecast_hour,
|
||||
"to_dict": lambda self: {
|
||||
"dedup_key": self.dedup_key,
|
||||
"alert_type": self.alert_type,
|
||||
"sent_at": self.sent_at.isoformat(),
|
||||
"forecast_hour": self.forecast_hour,
|
||||
},
|
||||
},
|
||||
)()
|
||||
|
||||
# Use the actual model
|
||||
from app.models.state import SentAlertRecord
|
||||
|
||||
manager.state.sent_alerts[alert.dedup_key] = SentAlertRecord(
|
||||
dedup_key=alert.dedup_key,
|
||||
alert_type=alert.alert_type.value,
|
||||
sent_at=datetime.now() - timedelta(hours=48),
|
||||
forecast_hour=alert.forecast_hour,
|
||||
)
|
||||
|
||||
purged = manager.purge_old_records()
|
||||
|
||||
assert purged == 1
|
||||
assert len(manager.state.sent_alerts) == 0
|
||||
|
||||
def test_atomic_write_creates_temp_file(self, tmp_path: Path) -> None:
|
||||
"""Test that atomic write doesn't leave temp files on success."""
|
||||
state_file = tmp_path / "state.json"
|
||||
manager = StateManager(str(state_file))
|
||||
|
||||
alert = make_triggered_alert()
|
||||
manager.record_sent(alert)
|
||||
manager.save()
|
||||
|
||||
# Check no temp files remain
|
||||
temp_files = list(tmp_path.glob("state_*.tmp"))
|
||||
assert len(temp_files) == 0
|
||||
|
||||
def test_load_handles_corrupt_json(self, tmp_path: Path) -> None:
|
||||
"""Test that corrupt JSON is handled gracefully."""
|
||||
state_file = tmp_path / "state.json"
|
||||
state_file.write_text("not valid json {{{")
|
||||
|
||||
manager = StateManager(str(state_file))
|
||||
state = manager.load()
|
||||
|
||||
# Should return empty state
|
||||
assert len(state.sent_alerts) == 0
|
||||
|
||||
def test_dedup_key_format(self) -> None:
|
||||
"""Test that dedup key has expected format."""
|
||||
alert = make_triggered_alert(
|
||||
alert_type=AlertType.TEMPERATURE_LOW, forecast_hour="2024-01-15-12"
|
||||
)
|
||||
|
||||
assert alert.dedup_key == "temperature_low:2024-01-15-12"
|
||||
175
tests/test_weather_service.py
Normal file
175
tests/test_weather_service.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""Tests for the weather service."""
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
|
||||
from app.services.weather_service import WeatherService, WeatherServiceError
|
||||
from app.utils.http_client import HttpClient
|
||||
|
||||
|
||||
SAMPLE_API_RESPONSE = {
|
||||
"address": "viola,tn",
|
||||
"resolvedAddress": "Viola, TN, United States",
|
||||
"timezone": "America/Chicago",
|
||||
"days": [
|
||||
{
|
||||
"datetime": "2024-01-15",
|
||||
"hours": [
|
||||
{
|
||||
"datetime": "12:00:00",
|
||||
"datetimeEpoch": 2000000000, # Future timestamp
|
||||
"temp": 45.0,
|
||||
"feelslike": 42.0,
|
||||
"humidity": 65.0,
|
||||
"precip": 0.0,
|
||||
"precipprob": 20.0,
|
||||
"snow": 0.0,
|
||||
"snowdepth": 0.0,
|
||||
"windspeed": 10.0,
|
||||
"windgust": 15.0,
|
||||
"winddir": 180.0,
|
||||
"pressure": 30.1,
|
||||
"visibility": 10.0,
|
||||
"cloudcover": 50.0,
|
||||
"uvindex": 3,
|
||||
"conditions": "Partially cloudy",
|
||||
"icon": "partly-cloudy-day",
|
||||
},
|
||||
{
|
||||
"datetime": "13:00:00",
|
||||
"datetimeEpoch": 2000003600,
|
||||
"temp": 48.0,
|
||||
"feelslike": 45.0,
|
||||
"humidity": 60.0,
|
||||
"precip": 0.0,
|
||||
"precipprob": 15.0,
|
||||
"snow": 0.0,
|
||||
"snowdepth": 0.0,
|
||||
"windspeed": 12.0,
|
||||
"windgust": 18.0,
|
||||
"winddir": 185.0,
|
||||
"pressure": 30.0,
|
||||
"visibility": 10.0,
|
||||
"cloudcover": 45.0,
|
||||
"uvindex": 4,
|
||||
"conditions": "Partially cloudy",
|
||||
"icon": "partly-cloudy-day",
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
"alerts": [
|
||||
{
|
||||
"event": "Wind Advisory",
|
||||
"headline": "Wind Advisory in effect until 6 PM",
|
||||
"description": "Gusty winds expected throughout the day.",
|
||||
"onset": "2024-01-15T08:00:00",
|
||||
"ends": "2024-01-15T18:00:00",
|
||||
"id": "NWS-456",
|
||||
"language": "en",
|
||||
"link": "https://weather.gov/alerts/456",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class TestWeatherService:
|
||||
"""Test weather service functionality."""
|
||||
|
||||
def test_requires_api_key(self) -> None:
|
||||
"""Test that service requires an API key."""
|
||||
with pytest.raises(WeatherServiceError, match="API key is required"):
|
||||
WeatherService(api_key="")
|
||||
|
||||
@responses.activate
|
||||
def test_get_forecast_success(self) -> None:
|
||||
"""Test successful forecast retrieval."""
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline/viola%2Ctn",
|
||||
json=SAMPLE_API_RESPONSE,
|
||||
status=200,
|
||||
)
|
||||
|
||||
service = WeatherService(api_key="test-key")
|
||||
forecast = service.get_forecast("viola,tn", hours_ahead=24)
|
||||
|
||||
assert forecast.location == "viola,tn"
|
||||
assert forecast.resolved_address == "Viola, TN, United States"
|
||||
assert len(forecast.hourly_forecasts) == 2
|
||||
assert len(forecast.alerts) == 1
|
||||
assert forecast.alerts[0].event == "Wind Advisory"
|
||||
|
||||
@responses.activate
|
||||
def test_get_forecast_parses_hourly_data(self) -> None:
|
||||
"""Test that hourly forecast data is correctly parsed."""
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline/viola%2Ctn",
|
||||
json=SAMPLE_API_RESPONSE,
|
||||
status=200,
|
||||
)
|
||||
|
||||
service = WeatherService(api_key="test-key")
|
||||
forecast = service.get_forecast("viola,tn")
|
||||
|
||||
hour = forecast.hourly_forecasts[0]
|
||||
assert hour.temp == 45.0
|
||||
assert hour.feelslike == 42.0
|
||||
assert hour.humidity == 65.0
|
||||
assert hour.precip_prob == 20.0
|
||||
assert hour.wind_speed == 10.0
|
||||
assert hour.wind_gust == 15.0
|
||||
assert hour.conditions == "Partially cloudy"
|
||||
|
||||
@responses.activate
|
||||
def test_get_forecast_handles_api_error(self) -> None:
|
||||
"""Test handling of API errors."""
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline/viola%2Ctn",
|
||||
json={"error": "Invalid API key"},
|
||||
status=401,
|
||||
)
|
||||
|
||||
service = WeatherService(api_key="bad-key")
|
||||
|
||||
with pytest.raises(WeatherServiceError, match="Failed to fetch forecast"):
|
||||
service.get_forecast("viola,tn")
|
||||
|
||||
@responses.activate
|
||||
def test_get_forecast_handles_invalid_json(self) -> None:
|
||||
"""Test handling of invalid JSON response."""
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline/viola%2Ctn",
|
||||
body="not json",
|
||||
status=200,
|
||||
content_type="text/plain",
|
||||
)
|
||||
|
||||
service = WeatherService(api_key="test-key")
|
||||
|
||||
with pytest.raises(WeatherServiceError, match="Invalid JSON"):
|
||||
service.get_forecast("viola,tn")
|
||||
|
||||
@responses.activate
|
||||
def test_location_encoding(self) -> None:
|
||||
"""Test that location is properly URL encoded."""
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline/Nashville%2C%20TN",
|
||||
json={
|
||||
"address": "Nashville, TN",
|
||||
"resolvedAddress": "Nashville, TN, United States",
|
||||
"timezone": "America/Chicago",
|
||||
"days": [],
|
||||
"alerts": [],
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
|
||||
service = WeatherService(api_key="test-key")
|
||||
forecast = service.get_forecast("Nashville, TN")
|
||||
|
||||
assert forecast.resolved_address == "Nashville, TN, United States"
|
||||
Reference in New Issue
Block a user