All checks were successful
Deploy / changes (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 13s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-backend (push) Successful in 2m23s
Deploy Trade-In / test (push) Successful in 3m56s
Deploy / build-worker (push) Successful in 4m16s
Deploy Trade-In / build-backend (push) Successful in 1m19s
Deploy / deploy (push) Successful in 1m49s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 12s
Deploy Trade-In / deploy (push) Successful in 2m25s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
680 lines
39 KiB
Python
680 lines
39 KiB
Python
"""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. ШТОРМ НА COLD-START: 16 потоков на ОДИН ключ → сеть зовётся не больше раза на
|
||
поток, все получают одно и то же значение, и шторм заканчивается сложившимся
|
||
кэшем. Не «ровно один вызов»: single-flight'а тут нет и он снят сознательно
|
||
(#1370, см. сам тест).
|
||
5. ИСТЕЧЕНИЕ TTL: подменяем `weather_cache._now`, проталкиваем время за expires_at
|
||
→ следующий вызов идёт по сети заново (а не из устаревшего кэша).
|
||
|
||
Каждый тест чистит обе кэш-таблицы в fixture'е (autouse) — между тестами нет утечки
|
||
состояния (модуль-singleton, dict'ы переживают между тестами).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import threading
|
||
import time
|
||
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_cold_start_storm_bounded_and_cache_converges(self) -> None:
|
||
"""16 потоков на ОДИН ключ при cold-start: сеть зовут не больше раза на поток,
|
||
все получают одно и то же значение, и после шторма кэш отвечает без сети.
|
||
|
||
ЗДЕСЬ СТОЯЛО `get_call_count == 1` («single-flight под lock'ом»), и это
|
||
было требование, которого код НЕ выполняет и выполнять не собирается:
|
||
сетевой вызов вынесен ЗА lock сознательно (#1370 — иначе все analyze
|
||
сериализуются на время httpx-вызова даже для разных координат), а рядом с
|
||
ним написано, что cold-start на один ключ «может породить несколько
|
||
параллельных запросов… приемлемо». Тест зеленел не потому, что защита
|
||
работает, а потому что при GIL первый поток обычно успевал сложить
|
||
результат раньше остальных.
|
||
|
||
Замер 2026-08-07, 200 штормов подряд: при дефолтном
|
||
`sys.getswitchinterval()` 199 раз вышел 1 вызов и один раз 2 — те самые
|
||
~0.5%, которыми гейт красил ЧУЖИЕ PR-ы (#2781: «ожидался 1 сетевой вызов,
|
||
было 2» в диффе про парсер КРТ). При `setswitchinterval(1e-6)`, когда
|
||
потоки реально чередуются, больше одного вызова дали 197 штормов из 200,
|
||
и в 173 из них вызовов было все 16. То есть утверждение ложно почти
|
||
всегда, когда гонка вообще случается, — чинить надо было тест.
|
||
|
||
Менять КОД (per-key lock ради настоящего single-flight) сознательно НЕ
|
||
стали: поведение объявлено приемлемым в #1370 с обоснованием, лишние
|
||
запросы бывают только на cold-start одного ключа и они идемпотентны.
|
||
Понадобится — это отдельная задача с отдельным обоснованием, а не
|
||
побочный эффект правки теста.
|
||
|
||
`time.sleep` в ответе делает гонку НЕслучайной: все 16 успевают пройти
|
||
промах кэша до первой записи. Так тест мерит худший случай той самой
|
||
уступки, а не везение планировщика.
|
||
"""
|
||
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
|
||
time.sleep(0.05) # окно, в котором остальные потоки видят промах
|
||
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()
|
||
storm_calls = get_call_count
|
||
# Шторм закончился — кэш обязан отвечать сам. Патч ещё активен, так что
|
||
# поход в сеть был бы виден счётчиком, а не отказом коннекта.
|
||
after_storm = weather_cache.get_weather_cached(56.84, 60.59)
|
||
|
||
assert len(results) == 16
|
||
assert results[0] is not None
|
||
assert all(r == results[0] for r in results), "потоки увидели РАЗНЫЕ значения"
|
||
# Потолок — число участников: в сеть идут только промахнувшиеся, по разу
|
||
# каждый. Больше — значит кто-то фетчит повторно (retry-петля, потерянная
|
||
# запись в кэш); меньше единицы невозможно, кэш был пуст.
|
||
assert 1 <= storm_calls <= 16, f"сетевых вызовов {storm_calls} при 16 участниках"
|
||
# Ключ ОДИН на всех (last-write wins), и цена шторма платится один раз:
|
||
# следующий вызов идёт из кэша. Это и есть то, что #1370 обещает взамен
|
||
# снятого single-flight — без этого уступка превращается в дыру.
|
||
assert list(weather_cache._FORECAST_CACHE) == [weather_cache._round_key(56.84, 60.59)]
|
||
assert after_storm == results[0]
|
||
assert get_call_count == storm_calls, (
|
||
f"после шторма кэш обязан отвечать без сети, а вызовов стало "
|
||
f"{get_call_count} против {storm_calls}"
|
||
)
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# 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
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# 7. wind_d all-None → dominant_direction_deg/label must be None, not 0.0°
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _make_forecast_response_wind_all_none() -> dict[str, Any]:
|
||
"""Forecast где все winddirection_10m_dominant = null (Open-Meteo штатно возвращает null)."""
|
||
return {
|
||
"daily": {
|
||
"time": ["2026-06-12", "2026-06-13", "2026-06-14"],
|
||
"temperature_2m_max": [20.0, 21.0, 22.0],
|
||
"temperature_2m_min": [10.0, 11.0, 12.0],
|
||
"precipitation_sum": [0.0, 0.0, 0.0],
|
||
"uv_index_max": [5.0, 5.0, 5.0],
|
||
"winddirection_10m_dominant": [None, None, None], # все null
|
||
"windspeed_10m_max": [3.0, 4.0, 3.0],
|
||
}
|
||
}
|
||
|
||
|
||
def _make_forecast_response_wind_empty() -> dict[str, Any]:
|
||
"""Forecast где winddirection_10m_dominant отсутствует (ключ не пришёл)."""
|
||
return {
|
||
"daily": {
|
||
"time": ["2026-06-12", "2026-06-13", "2026-06-14"],
|
||
"temperature_2m_max": [20.0, 21.0, 22.0],
|
||
"temperature_2m_min": [10.0, 11.0, 12.0],
|
||
"precipitation_sum": [0.0, 0.0, 0.0],
|
||
"uv_index_max": [5.0, 5.0, 5.0],
|
||
# winddirection_10m_dominant отсутствует
|
||
"windspeed_10m_max": [3.0, 4.0, 3.0],
|
||
}
|
||
}
|
||
|
||
|
||
class TestWindDirectionAllNone:
|
||
"""Регрессия: когда нет валидных сэмплов wind_d, не должно возвращаться 0.0° (= север)."""
|
||
|
||
def _call_with_payload(self, payload: dict[str, Any]) -> dict[str, Any] | None:
|
||
"""Вспомогательный: патчим httpx, делаем один вызов get_weather_cached."""
|
||
get = MagicMock(return_value=_make_httpx_response(payload))
|
||
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):
|
||
return weather_cache.get_weather_cached(56.84, 60.59)
|
||
|
||
def test_all_none_wind_samples_gives_none_direction(self) -> None:
|
||
"""Все winddirection_10m_dominant = null → dominant_direction_deg/label = None, не 0.0."""
|
||
result = self._call_with_payload(_make_forecast_response_wind_all_none())
|
||
assert result is not None, "forecast должен вернуть dict (не None), даже без wind_d"
|
||
wind = result["wind"]
|
||
assert wind["dominant_direction_deg"] is None, (
|
||
f"ожидался None, получено {wind['dominant_direction_deg']!r} — "
|
||
"вероятно, fabricated 0.0° из-за atan2(0,0)"
|
||
)
|
||
assert wind["dominant_direction_label"] is None, (
|
||
f"ожидался None, получено {wind['dominant_direction_label']!r}"
|
||
)
|
||
|
||
def test_missing_wind_key_gives_none_direction(self) -> None:
|
||
"""winddirection_10m_dominant отсутствует в ответе → dominant_direction_deg/label = None."""
|
||
result = self._call_with_payload(_make_forecast_response_wind_empty())
|
||
assert result is not None
|
||
wind = result["wind"]
|
||
assert wind["dominant_direction_deg"] is None
|
||
assert wind["dominant_direction_label"] is None
|
||
|
||
def test_valid_wind_samples_still_compute(self) -> None:
|
||
"""Санитарный тест: валидные сэмплы ветра по-прежнему дают числовой результат."""
|
||
result = self._call_with_payload(_make_forecast_response())
|
||
assert result is not None
|
||
wind = result["wind"]
|
||
assert wind["dominant_direction_deg"] is not None
|
||
assert isinstance(wind["dominant_direction_deg"], int)
|
||
assert wind["dominant_direction_label"] is not None
|
||
assert isinstance(wind["dominant_direction_label"], str)
|
||
|
||
def test_mixed_none_and_valid_wind_samples_compute(self) -> None:
|
||
"""Часть сэмплов None, часть валидные → только валидные участвуют в circular mean."""
|
||
payload = {
|
||
"daily": {
|
||
"time": ["2026-06-12", "2026-06-13", "2026-06-14"],
|
||
"temperature_2m_max": [20.0, 21.0, 22.0],
|
||
"temperature_2m_min": [10.0, 11.0, 12.0],
|
||
"precipitation_sum": [0.0, 0.0, 0.0],
|
||
"uv_index_max": [5.0, 5.0, 5.0],
|
||
"winddirection_10m_dominant": [None, 90.0, None], # только 90°=Восток
|
||
"windspeed_10m_max": [3.0, 4.0, 3.0],
|
||
}
|
||
}
|
||
result = self._call_with_payload(payload)
|
||
assert result is not None
|
||
wind = result["wind"]
|
||
# Один сэмпл 90° → circular mean ровно 90° → rose[2] = "Восток"
|
||
assert wind["dominant_direction_deg"] == 90
|
||
assert wind["dominant_direction_label"] == "Восток"
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# 8. осадков нет в ответе → precipitation_* = None, а не 0 (#2464)
|
||
#
|
||
# Тот же класс, что раздел 7 выше (wind_d all-None → None, не 0.0°): «ноль» здесь
|
||
# был бы утверждением о погоде («сухо»), тогда как пустой ряд значит, что мы просто
|
||
# не знаем. Все шесть соседних агрегатов того же словаря при пустых данных дают None
|
||
# — осадки были единственным исключением, и именно они рисуются на фронте как
|
||
# измеренная величина (ptica-adapt заворачивал их в `real(...)` безусловно).
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _make_forecast_response_precip_all_none() -> dict[str, Any]:
|
||
"""Forecast, где все precipitation_sum = null (Open-Meteo так делает штатно)."""
|
||
return {
|
||
"daily": {
|
||
"time": ["2026-06-12", "2026-06-13", "2026-06-14"],
|
||
"temperature_2m_max": [20.0, 21.0, 22.0],
|
||
"temperature_2m_min": [10.0, 11.0, 12.0],
|
||
"precipitation_sum": [None, None, None],
|
||
"uv_index_max": [5.0, 5.0, 5.0],
|
||
"winddirection_10m_dominant": [180, 180, 180],
|
||
"windspeed_10m_max": [3.0, 4.0, 3.0],
|
||
}
|
||
}
|
||
|
||
|
||
def _make_forecast_response_precip_missing() -> dict[str, Any]:
|
||
"""Forecast без ключа precipitation_sum вовсе."""
|
||
return {
|
||
"daily": {
|
||
"time": ["2026-06-12", "2026-06-13", "2026-06-14"],
|
||
"temperature_2m_max": [20.0, 21.0, 22.0],
|
||
"temperature_2m_min": [10.0, 11.0, 12.0],
|
||
"uv_index_max": [5.0, 5.0, 5.0],
|
||
"winddirection_10m_dominant": [180, 180, 180],
|
||
"windspeed_10m_max": [3.0, 4.0, 3.0],
|
||
}
|
||
}
|
||
|
||
|
||
class TestPrecipitationUnknownIsNotZero:
|
||
"""Регрессия: без ряда осадков не должно возвращаться 0 (= «сухо»)."""
|
||
|
||
def _call_with_payload(self, payload: dict[str, Any]) -> dict[str, Any] | None:
|
||
get = MagicMock(return_value=_make_httpx_response(payload))
|
||
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):
|
||
return weather_cache.get_weather_cached(56.84, 60.59)
|
||
|
||
def test_all_none_precip_gives_none_not_zero(self) -> None:
|
||
result = self._call_with_payload(_make_forecast_response_precip_all_none())
|
||
|
||
assert result is not None, "ответ должен строиться и без ряда осадков"
|
||
assert result["precipitation_total_mm"] is None, (
|
||
f"ожидался None, получено {result['precipitation_total_mm']!r} — "
|
||
"ноль здесь читается как «осадков не будет»"
|
||
)
|
||
assert result["precipitation_days"] is None
|
||
|
||
def test_missing_precip_key_gives_none(self) -> None:
|
||
result = self._call_with_payload(_make_forecast_response_precip_missing())
|
||
|
||
assert result is not None
|
||
assert result["precipitation_total_mm"] is None
|
||
assert result["precipitation_days"] is None
|
||
|
||
def test_real_dry_week_is_still_a_measured_zero(self) -> None:
|
||
"""КОНТРОЛЬ: настоящая сухая неделя — это измеренный 0.0, а не None.
|
||
|
||
Без этой проверки правку можно было бы «сделать» так, что осадки всегда
|
||
None, и оба теста выше стали бы зелёными по неверной причине.
|
||
"""
|
||
payload = _make_forecast_response_precip_all_none()
|
||
payload["daily"]["precipitation_sum"] = [0.0, 0.0, 0.0]
|
||
|
||
result = self._call_with_payload(payload)
|
||
|
||
assert result is not None
|
||
assert result["precipitation_total_mm"] == 0.0
|
||
assert result["precipitation_days"] == 0
|