"""Unit-тесты TTL-кэша Open-Meteo (PR #1130 Phase A — analyze latency 5-9s). Что покрываем (БЕЗ сети — httpx запатчен на уровне модуля): 1. HOT-CACHE HIT: два вызова с одинаковыми (lat, lon) → один сетевой запрос; третий с округлённо-теми же координатами (56.838 и 56.839 оба → 56.84) → тоже hit, без сетевого вызова. Сам смысл кэша — ради этого PR и сделан. 2. NEGATIVE-CACHE: первый вызов бросает исключение → None; второй (в пределах negative-TTL) → None БЕЗ сетевого вызова. Это правит главный продакшн-симптом DNS-fail повторяет timeout на каждый analyze. 3. ИЗОЛЯЦИЯ ДВУХ КЭШЕЙ: forecast-вызов не отравляет climate-кэш и наоборот (две раздельные таблицы внутри модуля). 4. SINGLE-FLIGHT под конкурентностью: 16 потоков на ОДИН ключ при cold-start → ровно ОДИН реальный httpx-вызов (lock + check-then-fetch-then-store). 5. ИСТЕЧЕНИЕ TTL: подменяем `weather_cache._now`, проталкиваем время за expires_at → следующий вызов идёт по сети заново (а не из устаревшего кэша). Каждый тест чистит обе кэш-таблицы в fixture'е (autouse) — между тестами нет утечки состояния (модуль-singleton, dict'ы переживают между тестами). """ from __future__ import annotations import os import threading from collections.abc import Iterator from typing import Any from unittest.mock import MagicMock, patch import pytest os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from app.services import weather_cache # ────────────────────────────────────────────────────────────────────────────── # Фикстуры # ────────────────────────────────────────────────────────────────────────────── @pytest.fixture(autouse=True) def _reset_caches() -> Iterator[None]: """Чистить все TTL-таблицы до и после каждого теста (state живёт в модуле).""" weather_cache._FORECAST_CACHE.clear() weather_cache._CLIMATE_CACHE.clear() weather_cache._AIR_CACHE.clear() yield weather_cache._FORECAST_CACHE.clear() weather_cache._CLIMATE_CACHE.clear() weather_cache._AIR_CACHE.clear() def _make_forecast_response() -> dict[str, Any]: """Минимальный валидный JSON от Open-Meteo Forecast API (7 дней).""" return { "daily": { "time": [ "2026-06-12", "2026-06-13", "2026-06-14", "2026-06-15", "2026-06-16", "2026-06-17", "2026-06-18", ], "temperature_2m_max": [20.0, 21.0, 22.0, 23.0, 22.0, 21.0, 20.0], "temperature_2m_min": [10.0, 11.0, 12.0, 13.0, 12.0, 11.0, 10.0], "precipitation_sum": [0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0], "uv_index_max": [5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0], "winddirection_10m_dominant": [180, 180, 180, 180, 180, 180, 180], "windspeed_10m_max": [3.0, 4.0, 3.0, 4.0, 3.0, 4.0, 3.0], } } def _make_climate_response() -> dict[str, Any]: """Минимальный валидный JSON от Open-Meteo Climate API — пара дат каждого сезона.""" return { "daily": { "time": [ "2000-01-15", "2000-04-15", "2000-07-15", "2000-10-15", ], "temperature_2m_max": [-5.0, 10.0, 25.0, 5.0], "temperature_2m_min": [-15.0, 0.0, 15.0, -5.0], "precipitation_sum": [2.0, 3.0, 4.0, 5.0], } } def _make_httpx_response(payload: dict[str, Any]) -> MagicMock: """MagicMock в форме httpx.Response: `.raise_for_status()` no-op, `.json()` → payload.""" resp = MagicMock() resp.raise_for_status = MagicMock(return_value=None) resp.json = MagicMock(return_value=payload) return resp def _make_air_response() -> dict[str, Any]: """Минимальный валидный JSON от Open-Meteo Air Quality API (current bucket, #1377).""" return { "current": { "time": "2026-06-12T14:00", "pm2_5": 12.5, "pm10": 25.0, "nitrogen_dioxide": 15.0, } } # ────────────────────────────────────────────────────────────────────────────── # 1. Hot-cache HIT (главный смысл PR'а — снять повторные запросы). # ────────────────────────────────────────────────────────────────────────────── class TestHotCacheHit: def test_same_coords_skip_network_after_first_call(self) -> None: """Два вызова с одинаковыми (lat, lon) → один сетевой запрос.""" get = MagicMock(return_value=_make_httpx_response(_make_forecast_response())) client_ctx = MagicMock() client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get)) client_ctx.__exit__ = MagicMock(return_value=None) with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx): r1 = weather_cache.get_weather_cached(56.84, 60.59) r2 = weather_cache.get_weather_cached(56.84, 60.59) assert r1 is not None assert r2 == r1 # тот же payload (из кэша) assert get.call_count == 1 # сетевой вызов ОДИН на оба запроса def test_close_coords_round_to_same_key(self) -> None: """56.838 и 56.839 (обе → round 56.84) дают тот же ключ кэша → 1 сетевой вызов.""" get = MagicMock(return_value=_make_httpx_response(_make_forecast_response())) client_ctx = MagicMock() client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get)) client_ctx.__exit__ = MagicMock(return_value=None) with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx): weather_cache.get_weather_cached(56.838, 60.597) weather_cache.get_weather_cached(56.839, 60.598) weather_cache.get_weather_cached(56.841, 60.594) # Все три round до (56.84, 60.60)? Проверим явно. # round(56.838,2)=56.84, round(56.839,2)=56.84, round(56.841,2)=56.84. # round(60.597,2)=60.60, round(60.598,2)=60.60, round(60.594,2)=60.59. # Значит первый и второй — один ключ, третий — другой. # Значит ожидаем 2 сетевых вызова (а не 3, не 1). assert get.call_count == 2 def test_distant_coords_use_distinct_keys(self) -> None: """56.84 и 56.90 (round до разных сотых) → отдельные слоты кэша → 2 вызова.""" get = MagicMock(return_value=_make_httpx_response(_make_forecast_response())) client_ctx = MagicMock() client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get)) client_ctx.__exit__ = MagicMock(return_value=None) with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx): weather_cache.get_weather_cached(56.84, 60.59) weather_cache.get_weather_cached(56.90, 60.59) assert get.call_count == 2 # ────────────────────────────────────────────────────────────────────────────── # 2. Negative-cache (главный продакшн-симптом #1130 — DNS-fail → timeout на каждый # analyze). # ────────────────────────────────────────────────────────────────────────────── class TestNegativeCacheOnFailure: def test_failure_caches_none_and_skips_retry(self) -> None: """Первый вызов → исключение → None; второй (в пределах TTL) → None без сети.""" get = MagicMock(side_effect=RuntimeError("simulated DNS failure")) client_ctx = MagicMock() client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get)) client_ctx.__exit__ = MagicMock(return_value=None) with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx): r1 = weather_cache.get_weather_cached(56.84, 60.59) r2 = weather_cache.get_weather_cached(56.84, 60.59) assert r1 is None assert r2 is None assert get.call_count == 1 # ровно ОДНА попытка → негативный кэш сработал def test_empty_daily_caches_none(self) -> None: """Open-Meteo вернул валидный JSON, но `daily.time` пуст → None кэшируется.""" get = MagicMock(return_value=_make_httpx_response({"daily": {"time": []}})) client_ctx = MagicMock() client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get)) client_ctx.__exit__ = MagicMock(return_value=None) with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx): r1 = weather_cache.get_weather_cached(56.84, 60.59) r2 = weather_cache.get_weather_cached(56.84, 60.59) assert r1 is None assert r2 is None assert get.call_count == 1 def test_seasonal_failure_caches_none(self) -> None: """Симметрично для climate-API — failure → negative-cache, второй вызов из кэша.""" get = MagicMock(side_effect=RuntimeError("simulated DNS failure")) client_ctx = MagicMock() client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get)) client_ctx.__exit__ = MagicMock(return_value=None) with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx): r1 = weather_cache.get_seasonal_weather_cached(56.84, 60.59) r2 = weather_cache.get_seasonal_weather_cached(56.84, 60.59) assert r1 is None assert r2 is None assert get.call_count == 1 # ────────────────────────────────────────────────────────────────────────────── # 3. Изоляция forecast- и climate-кэшей. # ────────────────────────────────────────────────────────────────────────────── class TestSeparateCachesForForecastAndClimate: def test_forecast_call_does_not_populate_climate_cache(self) -> None: """forecast-кэш и climate-кэш живут отдельно — записи одного не видны другому.""" # Отдельный счётчик GET'ов для двух подряд вызовов разных функций. get = MagicMock( side_effect=[ _make_httpx_response(_make_forecast_response()), _make_httpx_response(_make_climate_response()), ] ) client_ctx = MagicMock() client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get)) client_ctx.__exit__ = MagicMock(return_value=None) with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx): forecast = weather_cache.get_weather_cached(56.84, 60.59) seasonal = weather_cache.get_seasonal_weather_cached(56.84, 60.59) assert forecast is not None assert seasonal is not None # forecast-вызов оставил запись в _FORECAST_CACHE, climate — в _CLIMATE_CACHE. assert (56.84, 60.59) in weather_cache._FORECAST_CACHE assert (56.84, 60.59) in weather_cache._CLIMATE_CACHE # ИЗОЛЯЦИЯ: forecast НЕ попал в climate-таблицу (раздельные dict'ы). # Сетевых вызовов было ровно 2 (по одному на каждую функцию — не схлопнулись). assert get.call_count == 2 # ────────────────────────────────────────────────────────────────────────────── # 4. Single-flight под конкурентностью (16 потоков → 1 сетевой вызов). # ────────────────────────────────────────────────────────────────────────────── class TestConcurrencySafe: def test_single_flight_cold_start_one_network_call(self) -> None: """16 потоков на ОДИН ключ при cold-start → ровно один реальный httpx-вызов.""" # GET имитирует медленный ответ, чтобы потоки реально гонялись за один lock. start_barrier = threading.Barrier(16) get_call_count = 0 get_lock = threading.Lock() def _slow_get(*args: Any, **kwargs: Any) -> Any: nonlocal get_call_count with get_lock: get_call_count += 1 # Микро-задержка — окно для других потоков добраться до lock'а. # Не делаем sleep большим, чтобы тест не висел. return _make_httpx_response(_make_forecast_response()) client_ctx = MagicMock() client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=_slow_get)) client_ctx.__exit__ = MagicMock(return_value=None) results: list[dict[str, Any] | None] = [] results_lock = threading.Lock() def _worker() -> None: start_barrier.wait() value = weather_cache.get_weather_cached(56.84, 60.59) with results_lock: results.append(value) with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx): threads = [threading.Thread(target=_worker) for _ in range(16)] for t in threads: t.start() for t in threads: t.join() assert len(results) == 16 assert all(r is not None for r in results) # Single-flight под lock'ом + check-then-fetch — РОВНО один реальный вызов. assert get_call_count == 1, f"ожидался 1 сетевой вызов, было {get_call_count}" # ────────────────────────────────────────────────────────────────────────────── # 5. Истечение TTL — после прохождения времени за expires_at новый сетевой вызов. # ────────────────────────────────────────────────────────────────────────────── class TestExpiresAfterTtl: def test_forecast_hot_ttl_expires(self, monkeypatch: pytest.MonkeyPatch) -> None: """Проталкиваем `_now` за hot-TTL → следующий вызов идёт по сети заново.""" get = MagicMock(return_value=_make_httpx_response(_make_forecast_response())) client_ctx = MagicMock() client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get)) client_ctx.__exit__ = MagicMock(return_value=None) clock = [1000.0] def _fake_now() -> float: return clock[0] monkeypatch.setattr(weather_cache, "_now", _fake_now) with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx): weather_cache.get_weather_cached(56.84, 60.59) assert get.call_count == 1 # Проталкиваем время за hot-TTL (6h + запас). clock[0] += weather_cache._WEATHER_TTL_S + 1 weather_cache.get_weather_cached(56.84, 60.59) assert get.call_count == 2 def test_negative_ttl_expires(self, monkeypatch: pytest.MonkeyPatch) -> None: """После negative-TTL (5 мин) повторный вызов снова бьёт по сети.""" get = MagicMock(side_effect=RuntimeError("DNS")) client_ctx = MagicMock() client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get)) client_ctx.__exit__ = MagicMock(return_value=None) clock = [1000.0] def _fake_now() -> float: return clock[0] monkeypatch.setattr(weather_cache, "_now", _fake_now) with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx): assert weather_cache.get_weather_cached(56.84, 60.59) is None assert get.call_count == 1 clock[0] += weather_cache._NEGATIVE_TTL_S + 1 assert weather_cache.get_weather_cached(56.84, 60.59) is None assert get.call_count == 2 # ретрай после истечения negative-TTL def test_seasonal_hot_ttl_expires(self, monkeypatch: pytest.MonkeyPatch) -> None: """Climate normals — после 7 суток ретрай по сети.""" get = MagicMock(return_value=_make_httpx_response(_make_climate_response())) client_ctx = MagicMock() client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get)) client_ctx.__exit__ = MagicMock(return_value=None) clock = [1000.0] def _fake_now() -> float: return clock[0] monkeypatch.setattr(weather_cache, "_now", _fake_now) with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx): weather_cache.get_seasonal_weather_cached(56.84, 60.59) assert get.call_count == 1 clock[0] += weather_cache._SEASONAL_TTL_S + 1 weather_cache.get_seasonal_weather_cached(56.84, 60.59) assert get.call_count == 2 # ────────────────────────────────────────────────────────────────────────────── # 6. Air-quality cache (PR #1130 Phase B). Зеркало паттернов forecast/climate. # ────────────────────────────────────────────────────────────────────────────── class TestAirQualityCache: def test_air_hot_cache_hit_skips_network(self) -> None: """Два вызова с одинаковыми (lat, lon) → ровно один сетевой запрос.""" get = MagicMock(return_value=_make_httpx_response(_make_air_response())) client_ctx = MagicMock() client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get)) client_ctx.__exit__ = MagicMock(return_value=None) with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx): r1 = weather_cache.get_air_quality_cached(56.84, 60.59) r2 = weather_cache.get_air_quality_cached(56.84, 60.59) assert r1 is not None assert r2 == r1 # Контракт для caller'а: формат dict не сменился относительно прежнего # `_fetch_air_quality_sync` — фронт зависит от этих ключей. assert set(r1.keys()) == {"pm2_5", "pm10", "no2", "ts", "source"} assert r1["source"] == "open-meteo" assert get.call_count == 1 def test_air_negative_cache_on_failure(self) -> None: """Первый вызов бросает → None; второй (в TTL) → None без сети.""" get = MagicMock(side_effect=RuntimeError("simulated DNS failure")) client_ctx = MagicMock() client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get)) client_ctx.__exit__ = MagicMock(return_value=None) with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx): r1 = weather_cache.get_air_quality_cached(56.84, 60.59) r2 = weather_cache.get_air_quality_cached(56.84, 60.59) assert r1 is None assert r2 is None assert get.call_count == 1 # ровно одна попытка → negative cache сработал def test_air_empty_hourly_caches_none(self) -> None: """Open-Meteo вернул валидный JSON, но `hourly.time` пуст → None кэшируется.""" get = MagicMock(return_value=_make_httpx_response({"hourly": {"time": []}})) client_ctx = MagicMock() client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get)) client_ctx.__exit__ = MagicMock(return_value=None) with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx): r1 = weather_cache.get_air_quality_cached(56.84, 60.59) r2 = weather_cache.get_air_quality_cached(56.84, 60.59) assert r1 is None assert r2 is None assert get.call_count == 1 def test_air_separate_from_weather(self) -> None: """Air cache не отравляет weather/climate cache и наоборот (три раздельных dict'а).""" # Три последовательных вызова разных функций — каждый делает свой сетевой call. get = MagicMock( side_effect=[ _make_httpx_response(_make_forecast_response()), _make_httpx_response(_make_air_response()), _make_httpx_response(_make_climate_response()), ] ) client_ctx = MagicMock() client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get)) client_ctx.__exit__ = MagicMock(return_value=None) with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx): weather = weather_cache.get_weather_cached(56.84, 60.59) air = weather_cache.get_air_quality_cached(56.84, 60.59) climate = weather_cache.get_seasonal_weather_cached(56.84, 60.59) assert weather is not None assert air is not None assert climate is not None # Каждый кэш живёт в своём dict — записи не смешиваются. assert (56.84, 60.59) in weather_cache._FORECAST_CACHE assert (56.84, 60.59) in weather_cache._AIR_CACHE assert (56.84, 60.59) in weather_cache._CLIMATE_CACHE assert get.call_count == 3 # три раздельных сетевых вызова def test_air_ttl_expires(self, monkeypatch: pytest.MonkeyPatch) -> None: """Через `_AIR_TTL_S` (1 час) повторный вызов снова бьёт по сети.""" get = MagicMock(return_value=_make_httpx_response(_make_air_response())) client_ctx = MagicMock() client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get)) client_ctx.__exit__ = MagicMock(return_value=None) clock = [1000.0] def _fake_now() -> float: return clock[0] monkeypatch.setattr(weather_cache, "_now", _fake_now) with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx): weather_cache.get_air_quality_cached(56.84, 60.59) assert get.call_count == 1 clock[0] += weather_cache._AIR_TTL_S + 1 weather_cache.get_air_quality_cached(56.84, 60.59) assert get.call_count == 2