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
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 (оба файла).
451 lines
25 KiB
Python
451 lines
25 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. 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
|