diff --git a/tests/test_air_quality.py b/tests/test_air_quality.py index 37ed3867..8da3b106 100644 --- a/tests/test_air_quality.py +++ b/tests/test_air_quality.py @@ -1,7 +1,8 @@ import httpx import pytest -from weather_briefing.air_quality import AirQualityError, AQICNProvider, air_quality_to_document +from weather_briefing.air_quality import AirQualityError, AQICNProvider, air_quality_to_document, health_guidance +from weather_briefing.reference_data import ReferenceDataError async def test_aqicn_provider_labels_aqi_standard_without_converting_pm25() -> None: @@ -133,3 +134,210 @@ async def test_aqicn_rejects_missing_data_key() -> None: token="token", base_url="https://api.example.invalid", ).fetch(0, 0, "UTC") + + +async def test_aqicn_observed_at_returns_none_for_non_dict_time() -> None: + async with httpx.AsyncClient( + transport=httpx.MockTransport( + lambda _: httpx.Response( + 200, + json={ + "status": "ok", + "data": { + "aqi": 42, + "time": "not-a-dict", + "city": { + "name": "Test", + "url": "https://example.invalid/", + }, + "iaqi": {}, + }, + }, + ) + ) + ) as client: + snapshot = await AQICNProvider( + client, + token="token", + base_url="https://api.example.invalid", + ).fetch(0, 0, "UTC") + + assert snapshot.observed_at is None + + +async def test_aqicn_observed_at_returns_none_for_empty_time_string() -> None: + async with httpx.AsyncClient( + transport=httpx.MockTransport( + lambda _: httpx.Response( + 200, + json={ + "status": "ok", + "data": { + "aqi": 42, + "time": {"s": " ", "iso": " "}, + "city": { + "name": "Test", + "url": "https://example.invalid/", + }, + "iaqi": {}, + }, + }, + ) + ) + ) as client: + snapshot = await AQICNProvider( + client, + token="token", + base_url="https://api.example.invalid", + ).fetch(0, 0, "UTC") + + assert snapshot.observed_at is None + + +def test_health_guidance_unbounded_band_required(monkeypatch) -> None: + from weather_briefing.air_quality import _guidance_bands + + _guidance_bands.cache_clear() + monkeypatch.setattr( + "weather_briefing.air_quality.reference_value", + lambda filename, *path: [ + {"maximum_aqi": "10", "category": "bad", "guidance": "do not go out"}, + ], + ) + with pytest.raises(ReferenceDataError, match="must end with an unbounded band"): + _guidance_bands() + + _guidance_bands.cache_clear() + + +def test_air_quality_guidance_bands_must_be_non_empty_list(monkeypatch) -> None: + from weather_briefing.air_quality import _guidance_bands + + _guidance_bands.cache_clear() + monkeypatch.setattr( + "weather_briefing.air_quality.reference_value", + lambda filename, *path: [], + ) + with pytest.raises(ReferenceDataError, match="non-empty list"): + _guidance_bands() + + _guidance_bands.cache_clear() + + +def test_air_quality_guidance_bands_must_be_list(monkeypatch) -> None: + from weather_briefing.air_quality import _guidance_bands + + _guidance_bands.cache_clear() + monkeypatch.setattr( + "weather_briefing.air_quality.reference_value", + lambda filename, *path: "not-a-list", + ) + with pytest.raises(ReferenceDataError, match="non-empty list"): + _guidance_bands() + + _guidance_bands.cache_clear() + + +def test_air_quality_guidance_invalid_band_entry(monkeypatch) -> None: + from weather_briefing.air_quality import _guidance_bands + + _guidance_bands.cache_clear() + monkeypatch.setattr( + "weather_briefing.air_quality.reference_value", + lambda filename, *path: [ + {"maximum_aqi": "10", "category": "bad"}, + {"maximum_aqi": None, "category": "good", "guidance": "ok"}, + ], + ) + with pytest.raises(ReferenceDataError, match="Invalid air quality guidance band"): + _guidance_bands() + + _guidance_bands.cache_clear() + + +def test_air_quality_guidance_bounded_last_band(monkeypatch) -> None: + from weather_briefing.air_quality import _guidance_bands + + _guidance_bands.cache_clear() + monkeypatch.setattr( + "weather_briefing.air_quality.reference_value", + lambda filename, *path: [ + {"maximum_aqi": "10", "category": "bad", "guidance": "avoid"}, + {"maximum_aqi": "20", "category": "worse", "guidance": "stay inside"}, + ], + ) + with pytest.raises(ReferenceDataError, match="must end with an unbounded band"): + _guidance_bands() + + _guidance_bands.cache_clear() + + +def test_air_quality_guidance_middle_none_bounded(monkeypatch) -> None: + from weather_briefing.air_quality import _guidance_bands + + _guidance_bands.cache_clear() + monkeypatch.setattr( + "weather_briefing.air_quality.reference_value", + lambda filename, *path: [ + {"maximum_aqi": None, "category": "bad", "guidance": "avoid"}, + {"maximum_aqi": "20", "category": "worse", "guidance": "stay inside"}, + {"maximum_aqi": None, "category": "worst", "guidance": "hide"}, + ], + ) + with pytest.raises(ReferenceDataError, match="must end with an unbounded band"): + _guidance_bands() + + _guidance_bands.cache_clear() + + +def test_air_quality_guidance_non_unique_bounds(monkeypatch) -> None: + from weather_briefing.air_quality import _guidance_bands + + _guidance_bands.cache_clear() + monkeypatch.setattr( + "weather_briefing.air_quality.reference_value", + lambda filename, *path: [ + {"maximum_aqi": "10", "category": "bad", "guidance": "avoid"}, + {"maximum_aqi": "10", "category": "worse", "guidance": "stay inside"}, + {"maximum_aqi": None, "category": "worst", "guidance": "hide"}, + ], + ) + with pytest.raises(ReferenceDataError, match="must be unique, increasing"): + _guidance_bands() + + _guidance_bands.cache_clear() + + +def test_air_quality_guidance_negative_bound(monkeypatch) -> None: + from weather_briefing.air_quality import _guidance_bands + + _guidance_bands.cache_clear() + monkeypatch.setattr( + "weather_briefing.air_quality.reference_value", + lambda filename, *path: [ + {"maximum_aqi": "-5", "category": "bad", "guidance": "avoid"}, + {"maximum_aqi": None, "category": "worst", "guidance": "hide"}, + ], + ) + with pytest.raises(ReferenceDataError, match="must be unique, increasing, and non-negative"): + _guidance_bands() + + _guidance_bands.cache_clear() + + +def test_health_guidance_uses_unbounded_last_band(monkeypatch) -> None: + from weather_briefing.air_quality import _guidance_bands + + _guidance_bands.cache_clear() + monkeypatch.setattr( + "weather_briefing.air_quality.reference_value", + lambda filename, *path: [ + {"maximum_aqi": "50", "category": "优", "guidance": "Good"}, + {"maximum_aqi": None, "category": "差", "guidance": "Bad"}, + ], + ) + category, guidance = health_guidance(999) + assert category == "差" + assert guidance == "Bad" + + _guidance_bands.cache_clear() diff --git a/tests/test_cli.py b/tests/test_cli.py index f73eb3f7..7219bf94 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,18 +1,30 @@ +import base64 import logging from pathlib import Path +from types import SimpleNamespace import pendulum import pytest from weather_briefing.cli import ( _LOGGER, + WEATHER_PROVIDER_BUILDERS, + _aqicn_provider, + _build_open_meteo, + _build_qweather, + _build_weather_provider, _configure_logging, + _delivery_provider, _hour_in_cron, _in_schedule, + _llm_provider, _location_state_path, _parse_run_time, _precision_reduction_notice, + _qweather_is_configured, + _weather_context_provider, build_parser, + daemon, main, run, ) @@ -438,3 +450,454 @@ async def fake_service_run(kind: str, n: object) -> str: logging.root.handlers.clear() logging.root.handlers.extend(original_root_handlers) logging.root.setLevel(original_root_level) + + +async def test_run_sends_alert_for_precision_reduced_location(monkeypatch, capsys) -> None: + from types import SimpleNamespace + from unittest.mock import patch + + tz = pendulum.timezone("Asia/Shanghai") + now = pendulum.datetime(2026, 7, 14, 8, tz=tz) + location = ResolvedLocation( + "test", + "Test City", + 39.9, + 116.3, + "CN", + "Beijing", + tz.name, + True, + precision_reduced=True, + matched_name="Matched City", + ) + settings = _make_fake_settings(debug=False, publisher="stdout", locations=(location,)) + + alerts: list[tuple[str, str]] = [] + + class AlertDelivery: + async def publish_alert(self, title: str, body: str) -> None: + alerts.append((title, body)) + + monkeypatch.setattr("weather_briefing.cli._parse_run_time", lambda v, t: now) + monkeypatch.setattr("weather_briefing.cli._in_schedule", lambda k, n, s: True) + monkeypatch.setattr("weather_briefing.cli._delivery_provider", lambda s, c: AlertDelivery()) + monkeypatch.setattr("weather_briefing.cli._llm_provider", lambda s, c: None) + monkeypatch.setattr("weather_briefing.cli._weather_context_provider", lambda s, c, loc: None) + monkeypatch.setattr("weather_briefing.cli.httpx.AsyncClient", lambda **kw: _FakeAsyncClient()) + + class FakeResolver: + async def resolve_with_metadata(self, loc: object) -> object: + return SimpleNamespace(location=loc, from_cache=False) + + monkeypatch.setattr("weather_briefing.cli.CachedLocationResolver", lambda *a, **kw: FakeResolver()) + + class FakeState: + def __enter__(self) -> "FakeState": + return self + + def __exit__(self, *args: object) -> None: + pass + + monkeypatch.setattr("weather_briefing.cli.SQLiteStateStore", lambda p: FakeState()) + + async def fake_service_run(kind: str, n: object) -> str: + return "published body" + + monkeypatch.setattr("weather_briefing.cli.BriefingService", lambda *a, **kw: SimpleNamespace(run=fake_service_run)) + + original_handlers = _LOGGER.handlers[:] + original_level = _LOGGER.level + original_propagate = _LOGGER.propagate + original_root_handlers = logging.root.handlers[:] + original_root_level = logging.root.level + try: + _LOGGER.handlers.clear() + logging.root.handlers.clear() + + with patch.object(Settings, "from_env", classmethod(lambda cls: settings)): + await run("hourly", enforce_window=False) + + finally: + _LOGGER.handlers.clear() + _LOGGER.handlers.extend(original_handlers) + _LOGGER.setLevel(original_level) + _LOGGER.propagate = original_propagate + logging.root.handlers.clear() + logging.root.handlers.extend(original_root_handlers) + logging.root.setLevel(original_root_level) + + assert len(alerts) == 1 + assert "位置匹配需要确认" in alerts[0][0] + + +async def test_run_logs_skipped_when_no_content(monkeypatch, capsys) -> None: + from types import SimpleNamespace + from unittest.mock import patch + + tz = pendulum.timezone("Asia/Shanghai") + now = pendulum.datetime(2026, 7, 14, 8, tz=tz) + location = ResolvedLocation("test", "Test City", 39.9, 116.3, "CN", "Beijing", tz.name, True) + settings = _make_fake_settings(debug=False, publisher="stdout", locations=(location,)) + + monkeypatch.setattr("weather_briefing.cli._parse_run_time", lambda v, t: now) + monkeypatch.setattr("weather_briefing.cli._in_schedule", lambda k, n, s: True) + monkeypatch.setattr("weather_briefing.cli._delivery_provider", lambda s, c: None) + monkeypatch.setattr("weather_briefing.cli._llm_provider", lambda s, c: None) + monkeypatch.setattr("weather_briefing.cli._weather_context_provider", lambda s, c, loc: None) + monkeypatch.setattr("weather_briefing.cli.httpx.AsyncClient", lambda **kw: _FakeAsyncClient()) + + class FakeResolver: + async def resolve_with_metadata(self, loc: object) -> object: + return SimpleNamespace(location=loc, from_cache=True) + + monkeypatch.setattr("weather_briefing.cli.CachedLocationResolver", lambda *a, **kw: FakeResolver()) + + class FakeState: + def __enter__(self) -> "FakeState": + return self + + def __exit__(self, *args: object) -> None: + pass + + monkeypatch.setattr("weather_briefing.cli.SQLiteStateStore", lambda p: FakeState()) + + async def fake_service_run(kind: str, n: object) -> str | None: + return None + + monkeypatch.setattr("weather_briefing.cli.BriefingService", lambda *a, **kw: SimpleNamespace(run=fake_service_run)) + + original_handlers = _LOGGER.handlers[:] + original_level = _LOGGER.level + original_propagate = _LOGGER.propagate + original_root_handlers = logging.root.handlers[:] + original_root_level = logging.root.level + try: + _LOGGER.handlers.clear() + logging.root.handlers.clear() + + with patch.object(Settings, "from_env", classmethod(lambda cls: settings)): + await run("hourly", enforce_window=False) + + stderr = capsys.readouterr().err + assert "briefing skipped (no content)" in stderr + finally: + _LOGGER.handlers.clear() + _LOGGER.handlers.extend(original_handlers) + _LOGGER.setLevel(original_level) + _LOGGER.propagate = original_propagate + logging.root.handlers.clear() + logging.root.handlers.extend(original_root_handlers) + logging.root.setLevel(original_root_level) + + +class TestLLMProvider: + def test_deepseek_with_custom_base_url(self) -> None: + settings = _make_fake_settings( + llm_provider="deepseek", + llm_base_url="https://custom.example.invalid", + ) + provider = _llm_provider(settings, _FakeAsyncClient()) + assert provider._base_url == "https://custom.example.invalid" + + def test_deepseek_without_base_url(self) -> None: + settings = _make_fake_settings(llm_provider="deepseek", llm_base_url=None) + provider = _llm_provider(settings, _FakeAsyncClient()) + assert provider._base_url == "https://api.deepseek.com" + + def test_openai_compatible_missing_base_url(self) -> None: + settings = _make_fake_settings( + llm_provider="openai-compatible", + llm_base_url=None, + ) + with pytest.raises(ValueError, match="LLM_BASE_URL"): + _llm_provider(settings, _FakeAsyncClient()) + + def test_openai_compatible_with_base_url(self) -> None: + settings = _make_fake_settings( + llm_provider="openai-compatible", + llm_base_url="https://compatible.example.invalid/v1", + ) + provider = _llm_provider(settings, _FakeAsyncClient()) + assert provider._base_url == "https://compatible.example.invalid/v1" + + def test_unsupported_provider(self) -> None: + settings = _make_fake_settings(llm_provider="unsupported") + with pytest.raises(ValueError, match="Unsupported LLM provider"): + _llm_provider(settings, _FakeAsyncClient()) + + +class TestDeliveryProvider: + def test_stdout(self) -> None: + settings = _make_fake_settings(publisher="stdout") + provider = _delivery_provider(settings, _FakeAsyncClient()) + assert provider.renderer is not None + assert provider.publisher is not None + + def test_telegram_missing_config(self) -> None: + settings = _make_fake_settings(publisher="telegram", telegram_bot_token=None) + with pytest.raises(ValueError, match="TELEGRAM_BOT_TOKEN"): + _delivery_provider(settings, _FakeAsyncClient()) + + def test_telegram_with_config(self) -> None: + settings = _make_fake_settings( + publisher="telegram", + telegram_bot_token="test-token", + telegram_chat_id="test-chat", + ) + provider = _delivery_provider(settings, _FakeAsyncClient()) + assert provider.single_message_limit == 4096 + + def test_unsupported_publisher(self) -> None: + settings = _make_fake_settings(publisher="unsupported") + with pytest.raises(ValueError, match="Unsupported publisher"): + _delivery_provider(settings, _FakeAsyncClient()) + + +class TestWeatherContextProvider: + def test_qweather_not_configured_skips_when_auto(self) -> None: + settings = _make_fake_settings( + weather_providers=None, + qweather_project_id=None, + qweather_credential_id=None, + qweather_private_key=None, + qweather_base_url=None, + ) + location = ResolvedLocation("test", "Test", 39.9, 116.3, "CN", "Beijing", "Asia/Shanghai", True) + provider = _weather_context_provider(settings, _FakeAsyncClient(), location) + assert provider is not None + + def test_qweather_explicit_not_configured_raises(self) -> None: + settings = _make_fake_settings( + weather_providers=("qweather",), + qweather_project_id=None, + qweather_credential_id=None, + qweather_private_key=None, + qweather_base_url=None, + ) + location = ResolvedLocation("test", "Test", 39.9, 116.3, "CN", "Beijing", "Asia/Shanghai", True) + with pytest.raises(ValueError, match="JWT configuration"): + _weather_context_provider(settings, _FakeAsyncClient(), location) + + def test_single_provider_bypasses_fallback(self) -> None: + settings = _make_fake_settings( + weather_providers=None, + ) + location = ResolvedLocation("test", "Test", 40.7, -74.0, "US", "NY", "America/New_York", False) + provider = _weather_context_provider(settings, _FakeAsyncClient(), location) + assert provider is not None + + def test_qweather_configured(self) -> None: + key = b"fake-private-key-content" + settings = _make_fake_settings( + weather_providers=("qweather",), + qweather_project_id="project", + qweather_credential_id="credential", + qweather_private_key=base64.b64encode(key).decode(), + qweather_base_url="https://qweather.example.invalid", + ) + location = ResolvedLocation("test", "Test", 39.9, 116.3, "CN", "Beijing", "Asia/Shanghai", True) + provider = _weather_context_provider(settings, _FakeAsyncClient(), location) + assert provider is not None + + +def test_no_weather_provider_available(monkeypatch) -> None: + monkeypatch.setattr( + "weather_briefing.cli.weather_providers_for", + lambda *_: ("qweather",), + ) + settings = _make_fake_settings( + weather_providers=None, + qweather_project_id=None, + qweather_credential_id=None, + qweather_private_key=None, + qweather_base_url=None, + ) + location = ResolvedLocation("test", "Test", 39.9, 116.3, "CN", "Beijing", "Asia/Shanghai", True) + with pytest.raises(ValueError, match="No configured weather provider"): + _weather_context_provider(settings, _FakeAsyncClient(), location) + + +def test_qweather_is_configured_all_fields() -> None: + settings = _make_fake_settings( + qweather_project_id="p", + qweather_credential_id="c", + qweather_private_key="k", + qweather_base_url="https://example.invalid", + ) + assert _qweather_is_configured(settings) + + +def test_qweather_is_configured_missing_field() -> None: + settings = _make_fake_settings( + qweather_project_id=None, + qweather_credential_id="c", + qweather_private_key="k", + qweather_base_url="https://example.invalid", + ) + assert not _qweather_is_configured(settings) + + +def test_build_weather_provider_unsupported() -> None: + settings = _make_fake_settings() + with pytest.raises(ValueError, match="Unsupported weather provider"): + _build_weather_provider("unknown", settings, _FakeAsyncClient()) + + +def test_build_open_meteo_returns_provider() -> None: + settings = _make_fake_settings() + provider = _build_open_meteo(settings, _FakeAsyncClient()) + assert provider is not None + + +def test_build_qweather_returns_provider() -> None: + import base64 as b64 + + settings = _make_fake_settings( + qweather_project_id="project", + qweather_credential_id="credential", + qweather_private_key=b64.b64encode(b"fake-private-key-content").decode(), + qweather_base_url="https://qweather.example.invalid", + ) + provider = _build_qweather(settings, _FakeAsyncClient()) + assert provider is not None + + +def test_build_qweather_missing_config_raises() -> None: + settings = _make_fake_settings(qweather_project_id=None) + with pytest.raises(ValueError, match="QWeather provider requires"): + _build_qweather(settings, _FakeAsyncClient()) + + +def test_aqicn_provider_returns_none_when_no_token() -> None: + settings = _make_fake_settings(aqicn_api_token=None) + assert _aqicn_provider(settings, _FakeAsyncClient()) is None + + +def test_aqicn_provider_returns_instance_when_token_set() -> None: + settings = _make_fake_settings(aqicn_api_token="test-token") + provider = _aqicn_provider(settings, _FakeAsyncClient()) + assert provider is not None + + +def test_parse_run_time_returns_now_when_value_is_none(monkeypatch) -> None: + tz = pendulum.timezone("Asia/Shanghai") + result = _parse_run_time(None, tz) + assert result.timezone_name == "Asia/Shanghai" + + +def test_weather_provider_builders_contains_expected_keys() -> None: + assert set(WEATHER_PROVIDER_BUILDERS) == {"qweather", "open-meteo"} + + +async def test_daemon_runs_initial_briefing_when_run_now(monkeypatch) -> None: + import base64 as b64 + from unittest.mock import patch + + calls: list[tuple[str, bool, str | None]] = [] + + async def fake_run(kind: str, enforce_window: bool, at: str | None = None) -> None: + calls.append((kind, enforce_window, at)) + + class FakeEvent: + async def wait(self) -> None: + pass + + monkeypatch.setattr("weather_briefing.cli.run", fake_run) + monkeypatch.setattr("weather_briefing.cli._configure_logging", lambda *, debug: None) + monkeypatch.setattr( + "weather_briefing.cli.AsyncIOScheduler", + lambda **kw: SimpleNamespace( + add_job=lambda *a, **kw: None, + start=lambda: None, + ), + ) + monkeypatch.setattr("weather_briefing.cli.asyncio.Event", FakeEvent) + + settings = _make_fake_settings( + qweather_project_id="p", + qweather_credential_id="c", + qweather_private_key=b64.b64encode(b"fake-private-key-content").decode(), + qweather_base_url="https://example.invalid", + ) + + with patch.object(Settings, "from_env", classmethod(lambda cls: settings)): + await daemon(run_now=True) + + assert calls == [("hourly", False, None)] + + +async def test_daemon_skips_initial_briefing_without_run_now(monkeypatch) -> None: + from unittest.mock import patch + + calls: list[tuple[str, bool, str | None]] = [] + + async def fake_run(kind: str, enforce_window: bool, at: str | None = None) -> None: + calls.append((kind, enforce_window, at)) + + class FakeEvent: + async def wait(self) -> None: + pass + + monkeypatch.setattr("weather_briefing.cli.run", fake_run) + monkeypatch.setattr("weather_briefing.cli._configure_logging", lambda *, debug: None) + monkeypatch.setattr( + "weather_briefing.cli.AsyncIOScheduler", + lambda **kw: SimpleNamespace( + add_job=lambda *a, **kw: None, + start=lambda: None, + ), + ) + monkeypatch.setattr("weather_briefing.cli.asyncio.Event", FakeEvent) + + settings = _make_fake_settings() + + with patch.object(Settings, "from_env", classmethod(lambda cls: settings)): + await daemon(run_now=False) + + assert calls == [] + + +def test_main_calls_daemon_correctly(monkeypatch) -> None: + + calls: list[bool] = [] + + async def fake_daemon(run_now: bool = False) -> None: + calls.append(run_now) + + monkeypatch.setattr("weather_briefing.cli.load_dotenv", lambda *, override: True) + monkeypatch.setattr("weather_briefing.cli._configure_logging", lambda *, debug: None) + monkeypatch.setattr("weather_briefing.cli.daemon", fake_daemon) + monkeypatch.setattr("sys.argv", ["weather-briefing", "daemon"]) + + main() + assert calls == [False] + + +def test_main_calls_daemon_run_now(monkeypatch) -> None: + + calls: list[bool] = [] + + async def fake_daemon(run_now: bool = False) -> None: + calls.append(run_now) + + monkeypatch.setattr("weather_briefing.cli.load_dotenv", lambda *, override: True) + monkeypatch.setattr("weather_briefing.cli._configure_logging", lambda *, debug: None) + monkeypatch.setattr("weather_briefing.cli.daemon", fake_daemon) + monkeypatch.setattr("sys.argv", ["weather-briefing", "daemon", "--run-now"]) + main() + assert calls == [True] + + +def test_main_calls_run_correctly(monkeypatch) -> None: + calls: list[tuple[str, bool, str | None]] = [] + + async def fake_run(kind: str, enforce_window: bool, at: str | None = None) -> None: + calls.append((kind, enforce_window, at)) + + monkeypatch.setattr("weather_briefing.cli.load_dotenv", lambda *, override: True) + monkeypatch.setattr("weather_briefing.cli._configure_logging", lambda *, debug: None) + monkeypatch.setattr("weather_briefing.cli.run", fake_run) + monkeypatch.setattr("sys.argv", ["weather-briefing", "run", "daily", "--at", "2026-07-14T08:00:00+08:00"]) + + main() + assert calls == [("daily", False, "2026-07-14T08:00:00+08:00")] diff --git a/tests/test_config.py b/tests/test_config.py index 88437129..a49a13fe 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -463,3 +463,16 @@ def test_rss_sources_file_not_array_raises_error(self, monkeypatch, tmp_path: Pa with pytest.raises(ConfigurationError, match="must be a JSON array"): Settings.from_env() + + def test_non_numeric_coordinates_raise_error(self, monkeypatch, tmp_path: Path) -> None: + _required_environment(monkeypatch) + location_file = tmp_path / "locations.json" + location_file.write_text( + '[{"id":"beijing","name":"Beijing","latitude":"abc","longitude":"def"}]', + encoding="utf-8", + ) + monkeypatch.setenv("BRIEFING_LOCATIONS_FILE", str(location_file)) + monkeypatch.setenv("RSS_SOURCES_FILE", str(tmp_path / "rss-sources.json")) + + with pytest.raises(ConfigurationError, match="coordinates must be numbers"): + Settings.from_env() diff --git a/tests/test_geocoding.py b/tests/test_geocoding.py index 5b4da075..068b091e 100644 --- a/tests/test_geocoding.py +++ b/tests/test_geocoding.py @@ -1,4 +1,5 @@ from pathlib import Path +from types import SimpleNamespace import httpx import pytest @@ -10,10 +11,12 @@ NominatimGeocodingProvider, OpenMeteoGeocodingProvider, PrecisionReducingGeocodingProvider, + _mainland_china_rules, + _specific_location_name, possibly_mainland_china, ) from weather_briefing.models import LocationSpec, ResolvedLocation -from weather_briefing.reference_data import reference_value +from weather_briefing.reference_data import ReferenceDataError, reference_value async def test_open_meteo_geocoder_resolves_coordinates_and_country() -> None: @@ -305,3 +308,329 @@ async def test_nominatim_handles_invalid_response_structure() -> None: ) as client: with pytest.raises(GeocodingError, match="response validation failed"): await NominatimGeocodingProvider(client, user_agent="test").geocode(LocationSpec("test", "test")) + + +async def test_open_meteo_passes_api_key_when_provided() -> None: + handler_requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + handler_requests.append(request) + return httpx.Response( + 200, + json={ + "results": [ + { + "name": "Test City", + "latitude": 51.5, + "longitude": -0.1, + "country_code": "GB", + "admin1": "England", + "timezone": "Europe/London", + } + ] + }, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + result = await OpenMeteoGeocodingProvider(client, api_key="test-api-key").geocode( + LocationSpec("test", "Test City") + ) + + assert handler_requests[0].url.params["apikey"] == "test-api-key" + assert result.is_mainland_china is False + + +async def test_open_meteo_handles_empty_results() -> None: + async with httpx.AsyncClient( + transport=httpx.MockTransport(lambda _: httpx.Response(200, json={"results": []})) + ) as client: + with pytest.raises(GeocodingError, match="No geocoding result"): + await OpenMeteoGeocodingProvider(client).geocode(LocationSpec("test", "Test")) + + +async def test_open_meteo_handles_results_not_a_list() -> None: + async with httpx.AsyncClient( + transport=httpx.MockTransport(lambda _: httpx.Response(200, json={"results": "not-a-list"})) + ) as client: + with pytest.raises(GeocodingError, match="No geocoding result"): + await OpenMeteoGeocodingProvider(client).geocode(LocationSpec("test", "Test")) + + +async def test_open_meteo_handles_no_matching_result() -> None: + async with httpx.AsyncClient( + transport=httpx.MockTransport( + lambda _: httpx.Response( + 200, + json={ + "results": [ + { + "name": "Other", + "latitude": 0, + "longitude": 0, + "country_code": "XX", + } + ] + }, + ) + ) + ) as client: + with pytest.raises(GeocodingError, match="No matching geocoding result"): + await OpenMeteoGeocodingProvider(client).geocode(LocationSpec("test", "Test City")) + + +async def test_open_meteo_handles_http_error() -> None: + async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _: httpx.Response(500))) as client: + with pytest.raises(GeocodingError, match="Geocoding request or response validation failed"): + await OpenMeteoGeocodingProvider(client).geocode(LocationSpec("test", "Test")) + + +async def test_nominatim_queries_with_removable_terms_retry_on_mismatch() -> None: + queries_received: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + queries_received.append(str(request.url.params["q"])) + q = str(request.url.params["q"]) + if q == "中国Test地区": + return httpx.Response( + 200, + json=[ + { + "lat": "51.5", + "lon": "-0.1", + "display_name": "Test City, England, GB", + "address": {"country_code": "gb", "state": "England"}, + } + ], + ) + return httpx.Response( + 200, + json=[ + { + "lat": "40.0", + "lon": "120.0", + "display_name": "Some other location", + "address": {}, + } + ], + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + result = await NominatimGeocodingProvider(client, user_agent="test").geocode( + LocationSpec("test", "中国Test地区") + ) + + assert len(queries_received) == 2 + assert result.latitude == 51.5 + + +async def test_nominatim_handles_results_not_a_list() -> None: + async with httpx.AsyncClient( + transport=httpx.MockTransport(lambda _: httpx.Response(200, json="not-a-list")) + ) as client: + with pytest.raises(GeocodingError, match="No Nominatim result"): + await NominatimGeocodingProvider(client, user_agent="test").geocode(LocationSpec("test", "Test")) + + +async def test_nominatim_handles_http_error() -> None: + async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _: httpx.Response(500))) as client: + with pytest.raises(GeocodingError, match="Nominatim request failed"): + await NominatimGeocodingProvider(client, user_agent="test").geocode(LocationSpec("test", "Test")) + + +async def test_nominatim_handles_no_matching_results() -> None: + async with httpx.AsyncClient( + transport=httpx.MockTransport( + lambda _: httpx.Response( + 200, + json=[ + { + "lat": "1", + "lon": "2", + "display_name": "nothing related to query", + "address": {}, + } + ], + ) + ) + ) as client: + with pytest.raises(GeocodingError, match="No Nominatim result"): + await NominatimGeocodingProvider(client, user_agent="test").geocode(LocationSpec("test", "Test")) + + +async def test_nominatim_rate_limits_consecutive_requests(monkeypatch) -> None: + sleep_calls: list[float] = [] + monotonic_values = iter((100.0, 100.0, 100.25, 100.25)) + + async def fake_sleep(delay: float) -> None: + sleep_calls.append(delay) + + monkeypatch.setattr("weather_briefing.geocoding.asyncio.sleep", fake_sleep) + monkeypatch.setattr( + "weather_briefing.geocoding.time", + SimpleNamespace(monotonic=lambda: next(monotonic_values)), + ) + + calls = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + return httpx.Response( + 200, + json=[ + { + "lat": "51.5", + "lon": "-0.1", + "display_name": "Test City, England, GB", + "address": {"country_code": "gb", "state": "England"}, + } + ], + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + provider = NominatimGeocodingProvider(client, user_agent="test") + await provider.geocode(LocationSpec("test", "Test City")) + await provider.geocode(LocationSpec("test", "Test City")) + + assert calls == 2 + assert len(sleep_calls) == 1 + assert sleep_calls[0] == pytest.approx(0.75) + + +async def test_precision_reducing_provider_exhausts_all_candidates() -> None: + calls: list[str] = [] + + class FailingGeocoder: + async def geocode(self, location: LocationSpec) -> ResolvedLocation: + calls.append(location.name) + raise GeocodingError("no match") + + with pytest.raises(GeocodingError, match="No geocoder could resolve location at a safe precision"): + await PrecisionReducingGeocodingProvider(FailingGeocoder()).geocode( + LocationSpec("test", "中国北京市西城区中南海1号") + ) + + assert calls == ["中国北京市西城区中南海1号", "中国北京市西城区中南海"] + + +async def test_precision_reducing_provider_continues_after_geocoding_error() -> None: + calls: list[str] = [] + + class PartialGeocoder: + async def geocode(self, location: LocationSpec) -> ResolvedLocation: + calls.append(location.name) + if "1号" in location.name: + raise GeocodingError("no building match") + return ResolvedLocation( + location.id, + location.name, + 39.9, + 116.3, + "CN", + "北京市", + "Asia/Shanghai", + True, + ) + + result = await PrecisionReducingGeocodingProvider(PartialGeocoder()).geocode( + LocationSpec("test", "中国北京市西城区中南海1号") + ) + + assert result.precision_reduced is True + assert len(calls) == 2 + + +async def test_cached_resolver_rejects_non_dict_cached_value(tmp_path: Path) -> None: + cache_path = tmp_path / "geocoding.json" + cache_path.write_text('{"test:Test":"not-a-dict"}', encoding="utf-8") + + class FailingProvider: + async def geocode(self, location: LocationSpec) -> ResolvedLocation: + raise GeocodingError("test failure") + + resolver = CachedLocationResolver( + FallbackGeocodingProvider(FailingProvider()), + cache_path, + ) + + with pytest.raises(GeocodingError, match="Invalid cached geocoding record"): + await resolver.resolve(LocationSpec("test", "Test")) + + +async def test_cached_resolver_handles_writes_to_new_directory(tmp_path: Path) -> None: + class RecordingGeocoder: + async def geocode(self, location: LocationSpec) -> ResolvedLocation: + return ResolvedLocation( + location.id, + location.name, + 1.0, + 2.0, + None, + None, + None, + False, + ) + + cache_dir = tmp_path / "nested" / "path" + resolver = CachedLocationResolver(RecordingGeocoder(), cache_dir / "geocoding.json") + result = await resolver.resolve(LocationSpec("test", "Test")) + + assert result.latitude == 1.0 + assert (cache_dir / "geocoding.json").exists() + + +def test_mainland_china_rules_rejects_invalid_latitude_bounds(monkeypatch) -> None: + _mainland_china_rules.cache_clear() + + def fake_value(filename: str, *path: str) -> object: + if "latitude" in path: + return {"minimum": "100", "maximum": "50"} + if "longitude" in path: + return {"minimum": "73", "maximum": "136"} + raise AssertionError(f"Unexpected call: {filename} {path}") + + monkeypatch.setattr("weather_briefing.geocoding.reference_value", fake_value) + monkeypatch.setattr( + "weather_briefing.geocoding.reference_string_tuple", + lambda *_: (), + ) + with pytest.raises(ReferenceDataError, match="latitude"): + _mainland_china_rules() + + +def test_mainland_china_rules_rejects_invalid_longitude_bounds(monkeypatch) -> None: + _mainland_china_rules.cache_clear() + + def fake_value(filename: str, *path: str) -> object: + if "latitude" in path: + return {"minimum": "18", "maximum": "54"} + if "longitude" in path: + return {"minimum": "200", "maximum": "250"} + raise AssertionError(f"Unexpected call: {filename} {path}") + + monkeypatch.setattr("weather_briefing.geocoding.reference_value", fake_value) + monkeypatch.setattr( + "weather_briefing.geocoding.reference_string_tuple", + lambda *_: (), + ) + with pytest.raises(ReferenceDataError, match="longitude"): + _mainland_china_rules() + + +def test_mainland_china_rules_handles_corrupt_reference_data(monkeypatch) -> None: + _mainland_china_rules.cache_clear() + monkeypatch.setattr( + "weather_briefing.geocoding.reference_value", + lambda *_: (_ for _ in ()).throw(KeyError("missing")), + ) + with pytest.raises(ReferenceDataError, match="mainland China geography"): + _mainland_china_rules() + + +def test_specific_location_name_rejects_non_string_suffix(monkeypatch) -> None: + monkeypatch.setattr( + "weather_briefing.geocoding.reference_value", + lambda *_: 123, + ) + with pytest.raises(ReferenceDataError, match="suffix characters must be a string"): + _specific_location_name("北京") diff --git a/tests/test_reference_data.py b/tests/test_reference_data.py index 1f4f612e..2c675593 100644 --- a/tests/test_reference_data.py +++ b/tests/test_reference_data.py @@ -49,3 +49,24 @@ def test_reference_value_rejects_missing_path() -> None: def test_reference_string_tuple_rejects_non_list_value() -> None: with pytest.raises(ReferenceDataError, match="non-empty string list"): reference_string_tuple("geography.json", "mainland_china_service_bounds") + + +def test_load_reference_data_rejects_non_dict_root(monkeypatch) -> None: + from weather_briefing.reference_data import load_reference_data + + class FakeResource: + def joinpath(self, filename): + return self + + def read_text(self, encoding=None): + return "42" + + monkeypatch.setattr( + "weather_briefing.reference_data.resources.files", + lambda package: FakeResource(), + ) + + load_reference_data.cache_clear() + + with pytest.raises(ReferenceDataError, match="must be an object"): + load_reference_data("test.json") diff --git a/tests/test_service.py b/tests/test_service.py index 2ed85cf1..293b1952 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -546,3 +546,94 @@ async def test_llm_retry_on_validation_failure(tmp_path: Path) -> None: assert body is not None assert llm.attempts == 2 + + +async def test_briefing_exceeding_character_limit_is_rejected(tmp_path: Path) -> None: + timezone = pendulum.timezone("Asia/Shanghai") + now = pendulum.datetime(2026, 7, 13, 9, tz=timezone) + settings = SimpleNamespace( + timezone=timezone, + feeds=(), + context_sources=(), + task_failure_threshold=3, + rss_stale_hours=24, + warning_retention_hours=12, + history_hours=48, + briefing_max_characters=10, + llm_max_attempts=1, + ) + + class LongLLM: + async def summarize(self, system_prompt: str, payload: dict[str, object]) -> dict[str, object]: + return { + "headline": "A" * 100, + "overview": "B" * 100, + "conclusions": [], + "active_warnings": [], + "resolved_warning_ids": [], + "advice": [], + "disaster_tracking": [], + } + + publisher = RecordingPublisher() + delivery = DeliveryProvider(PlainTextRenderer(), publisher) + + with SQLiteStateStore(tmp_path / "long.sqlite3") as state: + service = BriefingService( + cast(Any, settings), + _location(), + state, + cast(Any, EmptyRSSSource()), + cast(Any, EmptyContextSource()), + LongLLM(), + delivery, + delivery, + StaticWeatherContextProvider(), + ) + with pytest.raises(LLMError, match="validation failed"): + await service.run("hourly", now) + + +async def test_is_forecast_article_returns_false_for_unknown_feed(tmp_path: Path) -> None: + timezone = pendulum.timezone("Asia/Shanghai") + now = pendulum.datetime(2026, 7, 13, 8, tz=timezone) + article = Article( + id="article-id", + source_id="unknown-feed", + source_name="Unknown", + title="Some content", + url="https://example.invalid/a", + published_at=now.subtract(days=1), + content="content", + ) + settings = SimpleNamespace( + timezone=timezone, + feeds=(FeedConfig("known-feed", "Known", "https://example.invalid/rss"),), + context_sources=(), + task_failure_threshold=3, + rss_stale_hours=24, + warning_retention_hours=12, + history_hours=48, + briefing_max_characters=3500, + llm_max_attempts=1, + ) + publisher = RecordingPublisher() + delivery = DeliveryProvider(PlainTextRenderer(), publisher) + llm = RecordingLLM() + + with SQLiteStateStore(tmp_path / "unknown.sqlite3") as state: + service = BriefingService( + cast(Any, settings), + _location(), + state, + cast(Any, StaticRSSSource(article)), + cast(Any, EmptyContextSource()), + llm, + delivery, + delivery, + ) + body = await service.run("daily", now) + + assert body is None + assert llm.payload is None + assert publisher.messages == [] diff --git a/tests/test_time_utils.py b/tests/test_time_utils.py index 0283ca5b..a322a7ee 100644 --- a/tests/test_time_utils.py +++ b/tests/test_time_utils.py @@ -128,3 +128,21 @@ def test_datetime_timezone_specifier_rejects_naive_value() -> None: naive = pendulum.naive(2026, 7, 13, 8) with pytest.raises(ValueError, match="explicit timezone"): datetime_timezone_specifier(naive, context="test") + + +def test_parse_aware_datetime_rejects_non_datetime_result(monkeypatch) -> None: + monkeypatch.setattr( + "weather_briefing.time_utils.pendulum.parse", + lambda value, **_: pendulum.date(2026, 7, 13), + ) + with pytest.raises(ValueError, match="must include a date and time"): + parse_aware_datetime("2026-07-13T08:00:00Z", context="test") + + +def test_parse_datetime_with_default_timezone_rejects_non_datetime_result(monkeypatch) -> None: + monkeypatch.setattr( + "weather_briefing.time_utils.pendulum.parse", + lambda value, **_: pendulum.date(2026, 7, 13), + ) + with pytest.raises(ValueError, match="must include a date and time"): + parse_datetime_with_default_timezone("2026-07-13T08:00:00", "Asia/Shanghai", context="test") diff --git a/tests/test_weather_context.py b/tests/test_weather_context.py index 9f6f9a9f..21326643 100644 --- a/tests/test_weather_context.py +++ b/tests/test_weather_context.py @@ -551,3 +551,733 @@ async def fetch(self, latitude: float, longitude: float, timezone: str) -> AirQu with pytest.raises(WeatherContextError, match="AQICN fallback failed"): await provider.fetch(1, 2) + + +async def test_qweather_rejects_non_success_indices_status() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/v7/weather/3d": + return httpx.Response( + 200, + json={ + "code": "200", + "updateTime": "2026-07-13T08:00", + "daily": [ + { + "fxDate": "2026-07-13", + "textDay": "晴", + "textNight": "晴", + "tempMin": "20", + "tempMax": "30", + "windDirDay": "南风", + "windScaleDay": "3-4", + "humidity": "60", + "precip": "0.0", + } + ], + }, + ) + if request.url.path == "/v7/indices/1d": + return httpx.Response(200, json={"code": "400"}) + raise AssertionError(f"Unexpected request: {request.url}") + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + with pytest.raises(WeatherContextError, match="non-success indices status"): + await QWeatherProvider( + client, + authenticator=StaticAuthenticator(), + base_url="https://api.example.invalid", + ).fetch(1, 2) + + +async def test_qweather_rejects_http_error() -> None: + async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _: httpx.Response(500))) as client: + with pytest.raises(WeatherContextError, match="request or response validation failed"): + await QWeatherProvider( + client, + authenticator=StaticAuthenticator(), + base_url="https://api.example.invalid", + ).fetch(1, 2) + + +async def test_qweather_rejects_jwt_error(monkeypatch) -> None: + class FailingAuthenticator: + def authorization_header(self) -> str: + raise jwt.PyJWTError("test jwt failure") + + async with httpx.AsyncClient() as client: + with pytest.raises(WeatherContextError, match="request or response validation failed"): + await QWeatherProvider( + client, + authenticator=FailingAuthenticator(), + base_url="https://api.example.invalid", + ).fetch(1, 2) + + +async def test_open_meteo_passes_api_key() -> None: + handler_requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + handler_requests.append(request) + return httpx.Response( + 200, + json={ + "timezone": "UTC", + "current": {"time": "2026-07-13T08:00"}, + "daily": { + "time": ["2026-07-13"], + "weather_code": [1], + "temperature_2m_max": [30], + "temperature_2m_min": [20], + "apparent_temperature_max": [31], + "apparent_temperature_min": [21], + "precipitation_sum": [0], + "precipitation_probability_max": [10], + "wind_speed_10m_max": [10], + "wind_gusts_10m_max": [15], + "wind_direction_10m_dominant": [90], + "uv_index_max": [5], + }, + }, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + snapshot = await OpenMeteoProvider(client, api_key="test-api-key").fetch(1, 2) + + assert snapshot.source_id == "weather:open-meteo" + assert handler_requests[0].url.params["apikey"] == "test-api-key" + + +async def test_open_meteo_rejects_http_error() -> None: + async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _: httpx.Response(500))) as client: + with pytest.raises(WeatherContextError, match="request or response validation failed"): + await OpenMeteoProvider(client).fetch(1, 2) + + +async def test_open_meteo_air_quality_passes_api_key() -> None: + air_requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/v1/forecast": + return httpx.Response( + 200, + json={ + "timezone": "UTC", + "current": {"time": "2026-07-13T08:00"}, + "daily": { + "time": ["2026-07-13"], + "weather_code": [1], + "temperature_2m_max": [30], + "temperature_2m_min": [20], + "apparent_temperature_max": [31], + "apparent_temperature_min": [21], + "precipitation_sum": [0], + "precipitation_probability_max": [10], + "wind_speed_10m_max": [10], + "wind_gusts_10m_max": [15], + "wind_direction_10m_dominant": [90], + "uv_index_max": [5], + }, + }, + ) + air_requests.append(request) + return httpx.Response( + 200, + json={ + "timezone": "UTC", + "current": { + "time": "2026-07-13T08:00", + "us_aqi": 42, + "us_aqi_pm2_5": 35, + "pm2_5": 9.5, + }, + }, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + snapshot = await OpenMeteoProvider(client, api_key="test-api-key").fetch(1, 2) + + assert snapshot.air_quality is not None + assert air_requests[0].url.params["apikey"] == "test-api-key" + + +async def test_snapshot_to_documents_without_air_quality() -> None: + snapshot = WeatherContextSnapshot( + source_id="weather:test", + source_name="Test", + source_url="https://example.invalid/", + observed_at=pendulum.datetime(2026, 7, 13, 8, tz="UTC"), + weather_forecast=("forecast",), + ) + + documents = snapshot_to_documents(snapshot) + + assert [doc.id for doc in documents] == ["weather:test"] + + +async def test_qweather_air_quality_parses_invalid_indexes_gracefully() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/v7/weather/3d": + return httpx.Response( + 200, + json={ + "code": "200", + "updateTime": "2026-07-13T08:00", + "fxLink": "https://www.qweather.com/", + "daily": [ + { + "fxDate": "2026-07-13", + "textDay": "晴", + "textNight": "晴", + "tempMin": "20", + "tempMax": "30", + "windDirDay": "南风", + "windScaleDay": "3-4", + "humidity": "60", + "precip": "0.0", + } + ], + }, + ) + if request.url.path == "/v7/indices/1d": + return httpx.Response(200, json={"code": "200", "daily": []}) + return httpx.Response( + 200, + json={"indexes": [{"code": "cn-mee", "aqi": 50}], "pollutants": []}, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + snapshot = await QWeatherProvider( + client, + authenticator=StaticAuthenticator(), + base_url="https://api.example.invalid", + ).fetch(1, 2) + + assert snapshot.air_quality is None + + +async def test_qweather_air_quality_handles_missing_pollutant_code() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/v7/weather/3d": + return httpx.Response( + 200, + json={ + "code": "200", + "updateTime": "2026-07-13T08:00", + "fxLink": "https://www.qweather.com/", + "daily": [ + { + "fxDate": "2026-07-13", + "textDay": "晴", + "textNight": "晴", + "tempMin": "20", + "tempMax": "30", + "windDirDay": "南风", + "windScaleDay": "3-4", + "humidity": "60", + "precip": "0.0", + } + ], + }, + ) + if request.url.path == "/v7/indices/1d": + return httpx.Response(200, json={"code": "200", "daily": []}) + return httpx.Response( + 200, + json={ + "metadata": {"attributions": ["https://developer.qweather.com/attribution.html"]}, + "indexes": [ + { + "code": "cn-mee", + "aqi": 68, + "aqiDisplay": "68", + "category": "良", + "health": {"advice": {"generalPopulation": "ok"}}, + } + ], + "pollutants": [ + { + "code": "pm2p5", + "concentration": {"value": 10.0, "unit": "μg/m3"}, + "subIndexes": [], + } + ], + }, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + snapshot = await QWeatherProvider( + client, + authenticator=StaticAuthenticator(), + base_url="https://api.example.invalid", + ).fetch(1, 2) + + assert snapshot.air_quality is not None + assert snapshot.air_quality.pm25_aqi is None + + +async def test_qweather_air_quality_parse_failure_due_to_non_dict_indexes() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/v7/weather/3d": + return httpx.Response( + 200, + json={ + "code": "200", + "updateTime": "2026-07-13T08:00", + "fxLink": "https://www.qweather.com/", + "daily": [ + { + "fxDate": "2026-07-13", + "textDay": "晴", + "textNight": "晴", + "tempMin": "20", + "tempMax": "30", + "windDirDay": "南风", + "windScaleDay": "3-4", + "humidity": "60", + "precip": "0.0", + } + ], + }, + ) + if request.url.path == "/v7/indices/1d": + return httpx.Response(200, json={"code": "200", "daily": []}) + return httpx.Response( + 200, + json={ + "indexes": "not-a-list", + "pollutants": [], + }, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + snapshot = await QWeatherProvider( + client, + authenticator=StaticAuthenticator(), + base_url="https://api.example.invalid", + ).fetch(1, 2) + + assert snapshot.air_quality is None + + +async def test_qweather_lifestyle_handles_non_dict_items() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/v7/weather/3d": + return httpx.Response( + 200, + json={ + "code": "200", + "updateTime": "2026-07-13T08:00", + "daily": [ + { + "fxDate": "2026-07-13", + "textDay": "晴", + "textNight": "晴", + "tempMin": "20", + "tempMax": "30", + "windDirDay": "南风", + "windScaleDay": "3-4", + "humidity": "60", + "precip": "0.0", + } + ], + }, + ) + if request.url.path == "/v7/indices/1d": + return httpx.Response(200, json={"code": "200", "daily": ["not-a-dict"]}) + raise AssertionError(f"Unexpected request: {request.url}") + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + with pytest.raises(WeatherContextError, match="request or response validation failed"): + await QWeatherProvider( + client, + authenticator=StaticAuthenticator(), + base_url="https://api.example.invalid", + ).fetch(1, 2) + + +async def test_qweather_forecast_handles_non_dict_items() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/v7/weather/3d": + return httpx.Response( + 200, + json={ + "code": "200", + "updateTime": "2026-07-13T08:00", + "daily": [ + { + "fxDate": "2026-07-13", + "textDay": "晴", + "textNight": "晴", + "tempMin": "20", + "tempMax": "30", + "windDirDay": "南风", + "windScaleDay": "3-4", + "humidity": "60", + "precip": "0.0", + }, + "not-a-dict", + ], + }, + ) + if request.url.path == "/v7/indices/1d": + return httpx.Response(200, json={"code": "200", "daily": []}) + raise AssertionError(f"Unexpected request: {request.url}") + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + with pytest.raises(WeatherContextError, match="request or response validation failed"): + await QWeatherProvider( + client, + authenticator=StaticAuthenticator(), + base_url="https://api.example.invalid", + ).fetch(1, 2) + + +async def test_qweather_air_quality_handles_non_list_pollutants() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/v7/weather/3d": + return httpx.Response( + 200, + json={ + "code": "200", + "updateTime": "2026-07-13T08:00", + "fxLink": "https://www.qweather.com/", + "daily": [ + { + "fxDate": "2026-07-13", + "textDay": "晴", + "textNight": "晴", + "tempMin": "20", + "tempMax": "30", + "windDirDay": "南风", + "windScaleDay": "3-4", + "humidity": "60", + "precip": "0.0", + } + ], + }, + ) + if request.url.path == "/v7/indices/1d": + return httpx.Response(200, json={"code": "200", "daily": []}) + return httpx.Response( + 200, + json={ + "metadata": {"attributions": ["https://developer.qweather.com/attribution.html"]}, + "indexes": [ + { + "code": "cn-mee", + "aqi": 68, + "aqiDisplay": "68", + "category": "良", + "health": {"advice": {"generalPopulation": "ok"}}, + } + ], + "pollutants": "not-a-list", + }, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + snapshot = await QWeatherProvider( + client, + authenticator=StaticAuthenticator(), + base_url="https://api.example.invalid", + ).fetch(1, 2) + + assert snapshot.air_quality is None + + +async def test_qweather_air_quality_handles_missing_pm2p5_pollutant() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/v7/weather/3d": + return httpx.Response( + 200, + json={ + "code": "200", + "updateTime": "2026-07-13T08:00", + "fxLink": "https://www.qweather.com/", + "daily": [ + { + "fxDate": "2026-07-13", + "textDay": "晴", + "textNight": "晴", + "tempMin": "20", + "tempMax": "30", + "windDirDay": "南风", + "windScaleDay": "3-4", + "humidity": "60", + "precip": "0.0", + } + ], + }, + ) + if request.url.path == "/v7/indices/1d": + return httpx.Response(200, json={"code": "200", "daily": []}) + return httpx.Response( + 200, + json={ + "metadata": {"attributions": ["https://developer.qweather.com/attribution.html"]}, + "indexes": [ + { + "code": "cn-mee", + "aqi": 68, + "aqiDisplay": "68", + "category": "良", + "health": {"advice": {"generalPopulation": "ok"}}, + } + ], + "pollutants": [ + { + "code": "no2", + "concentration": {"value": 10.0, "unit": "μg/m3"}, + } + ], + }, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + snapshot = await QWeatherProvider( + client, + authenticator=StaticAuthenticator(), + base_url="https://api.example.invalid", + ).fetch(1, 2) + + assert snapshot.air_quality is None + + +async def test_qweather_air_quality_handles_non_list_subindexes() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/v7/weather/3d": + return httpx.Response( + 200, + json={ + "code": "200", + "updateTime": "2026-07-13T08:00", + "fxLink": "https://www.qweather.com/", + "daily": [ + { + "fxDate": "2026-07-13", + "textDay": "晴", + "textNight": "晴", + "tempMin": "20", + "tempMax": "30", + "windDirDay": "南风", + "windScaleDay": "3-4", + "humidity": "60", + "precip": "0.0", + } + ], + }, + ) + if request.url.path == "/v7/indices/1d": + return httpx.Response(200, json={"code": "200", "daily": []}) + return httpx.Response( + 200, + json={ + "metadata": {"attributions": ["https://developer.qweather.com/attribution.html"]}, + "indexes": [ + { + "code": "cn-mee", + "aqi": 68, + "aqiDisplay": "68", + "category": "良", + "health": {"advice": {"generalPopulation": "ok"}}, + } + ], + "pollutants": [ + { + "code": "pm2p5", + "concentration": {"value": 22.0, "unit": "μg/m3"}, + "subIndexes": "not-a-list", + } + ], + }, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + snapshot = await QWeatherProvider( + client, + authenticator=StaticAuthenticator(), + base_url="https://api.example.invalid", + ).fetch(1, 2) + + assert snapshot.air_quality is not None + assert snapshot.air_quality.pm25_aqi is None + + +async def test_qweather_air_quality_subindex_code_not_matching_standard() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/v7/weather/3d": + return httpx.Response( + 200, + json={ + "code": "200", + "updateTime": "2026-07-13T08:00", + "fxLink": "https://www.qweather.com/", + "daily": [ + { + "fxDate": "2026-07-13", + "textDay": "晴", + "textNight": "晴", + "tempMin": "20", + "tempMax": "30", + "windDirDay": "南风", + "windScaleDay": "3-4", + "humidity": "60", + "precip": "0.0", + } + ], + }, + ) + if request.url.path == "/v7/indices/1d": + return httpx.Response(200, json={"code": "200", "daily": []}) + return httpx.Response( + 200, + json={ + "metadata": {"attributions": ["https://developer.qweather.com/attribution.html"]}, + "indexes": [ + { + "code": "cn-mee", + "aqi": 68, + "aqiDisplay": "68", + "category": "良", + "health": {"advice": {"generalPopulation": "ok"}}, + } + ], + "pollutants": [ + { + "code": "pm2p5", + "concentration": {"value": 22.0, "unit": "μg/m3"}, + "subIndexes": [{"code": "cn-mep", "aqi": 70}], + } + ], + }, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + snapshot = await QWeatherProvider( + client, + authenticator=StaticAuthenticator(), + base_url="https://api.example.invalid", + ).fetch(1, 2) + + assert snapshot.air_quality is not None + assert snapshot.air_quality.pm25_aqi is None + + +async def test_qweather_air_quality_handles_non_dict_metadata() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/v7/weather/3d": + return httpx.Response( + 200, + json={ + "code": "200", + "updateTime": "2026-07-13T08:00", + "fxLink": "https://www.qweather.com/", + "daily": [ + { + "fxDate": "2026-07-13", + "textDay": "晴", + "textNight": "晴", + "tempMin": "20", + "tempMax": "30", + "windDirDay": "南风", + "windScaleDay": "3-4", + "humidity": "60", + "precip": "0.0", + } + ], + }, + ) + if request.url.path == "/v7/indices/1d": + return httpx.Response(200, json={"code": "200", "daily": []}) + return httpx.Response( + 200, + json={ + "metadata": "not-a-dict", + "indexes": [ + { + "code": "cn-mee", + "aqi": 68, + "aqiDisplay": "68", + "category": "良", + "health": {"advice": {"generalPopulation": "ok"}}, + } + ], + "pollutants": [ + { + "code": "pm2p5", + "concentration": {"value": 22.0, "unit": "μg/m3"}, + "subIndexes": [{"code": "cn-mee", "aqi": 68}], + } + ], + }, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + snapshot = await QWeatherProvider( + client, + authenticator=StaticAuthenticator(), + base_url="https://api.example.invalid", + ).fetch(1, 2) + + assert snapshot.air_quality is not None + assert snapshot.air_quality.pm25_aqi == 68 + + +async def test_qweather_air_quality_handles_empty_attributions() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/v7/weather/3d": + return httpx.Response( + 200, + json={ + "code": "200", + "updateTime": "2026-07-13T08:00", + "fxLink": "https://www.qweather.com/", + "daily": [ + { + "fxDate": "2026-07-13", + "textDay": "晴", + "textNight": "晴", + "tempMin": "20", + "tempMax": "30", + "windDirDay": "南风", + "windScaleDay": "3-4", + "humidity": "60", + "precip": "0.0", + } + ], + }, + ) + if request.url.path == "/v7/indices/1d": + return httpx.Response(200, json={"code": "200", "daily": []}) + return httpx.Response( + 200, + json={ + "metadata": {"attributions": []}, + "indexes": [ + { + "code": "cn-mee", + "aqi": 68, + "aqiDisplay": "68", + "category": "良", + "health": {"advice": {"generalPopulation": "ok"}}, + } + ], + "pollutants": [ + { + "code": "pm2p5", + "concentration": {"value": 22.0, "unit": "μg/m3"}, + "subIndexes": [{"code": "cn-mee", "aqi": 68}], + } + ], + }, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + snapshot = await QWeatherProvider( + client, + authenticator=StaticAuthenticator(), + base_url="https://api.example.invalid", + ).fetch(1, 2) + + assert snapshot.air_quality is not None + assert snapshot.air_quality.pm25_aqi == 68