gendesign/backend/tests/services/test_weather_cache.py
bot-backend be70cacf18
All checks were successful
CI / changes (push) Successful in 7s
CI / changes (pull_request) Successful in 9s
CI / frontend-tests (push) Successful in 1m4s
CI / frontend-tests (pull_request) Successful in 57s
CI / openapi-codegen-check (push) Successful in 2m7s
CI / openapi-codegen-check (pull_request) Successful in 1m41s
CI / backend-tests (push) Successful in 8m59s
CI / backend-tests (pull_request) Successful in 8m58s
test: выровнять weather_cache/nspd_geo тесты под #1377/#1336 (CI red→green)
backend-tests CI упал на 4 стейл-тестах — код автофиксов обновили, тесты нет:
- test_weather_cache: фикстура _make_air_response отдавала hourly-форму, а код
  (#1377) теперь читает current-bucket → current.time пуст → None. Фикстура → current.
- test_nspd_geo: 2 теста стерегли ошибочный ST_Transform(3857→4326), который #1336
  убрал (геометрия уже WGS84, иначе Null Island). Ассерты через _strip_sql_comments
  (как сиблинг parcel-тест) → ST_SetSRID(...,4326) без ST_Transform. Переименованы.

Локально: 38 passed (оба файла).
2026-06-16 08:35:14 +03:00

451 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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