From 49b85ab1d60c1847e89de47abc72c7c392a3b6d6 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Fri, 12 Jun 2026 22:42:32 +0500 Subject: [PATCH] =?UTF-8?q?perf(site-finder):=20air-quality=20TTL-=D0=BA?= =?UTF-8?q?=D1=8D=D1=88=20+=20neighbors=5Fsummary=202=E2=86=921=20SQL=20(#?= =?UTF-8?q?1130=20Phase=20B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase B продолжает hot-path latency cut'ы analyze_parcel поверх Phase A (PR #1194): 1. get_air_quality_cached в weather_cache (третий TTL-слот рядом с forecast/climate): - hit-TTL 1h (hourly bucket Open-Meteo), negative-TTL 5min. - timeout 5s → 2s. - single-flight под per-cache threading.Lock. - Снимает 0.26с на cache-hit и до 5с при DNS-fail (тот же антипаттерн, что был у forecast/climate до Phase A). 2. _neighbors_summary: 2 SQL → 1 statement. - WITH neighbors AS (... LIMIT 30), overlaps AS (... LIMIT 5) - + COALESCE(json_agg(row_to_json(...)), '[]'::json) в основном SELECT. - Один round-trip вместо двух (~-47 ms на каждый analyze). - Семантика идентична: те же ST_DWithin(geom, point, 100m) для neighbors, ST_Intersects + ST_Area для overlaps. Anti-fakes guards (_COST_PER_M2_MIN/MAX, float() try/except, >50 m² overlap) сохранены. Формат возвращаемых dict не меняется (frontend контракт). 5 новых юнит-тестов в test_weather_cache.py (TestAirQualityCache: hot-hit, negative, empty-hourly, separate-from-weather, ttl-expires). 7 файлов API-тестов обновили patch-цель _fetch_air_quality_sync → get_air_quality_cached. Refs #1130 --- backend/app/api/v1/parcels.py | 148 ++++++++---------- backend/app/services/weather_cache.py | 80 +++++++++- .../api/v1/test_analyze_inline_weights.py | 2 +- .../tests/api/v1/test_analyze_market_price.py | 2 +- .../tests/api/v1/test_analyze_parcel_meta.py | 2 +- .../api/v1/test_analyze_recent_permits.py | 2 +- backend/tests/api/v1/test_custom_pois.py | 2 +- backend/tests/api/v1/test_parcels_forecast.py | 2 +- .../test_run_history_and_response_contract.py | 2 +- backend/tests/services/test_weather_cache.py | 113 ++++++++++++- 10 files changed, 253 insertions(+), 102 deletions(-) diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 3c21ff7b..0c5c1d0a 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -88,6 +88,7 @@ from app.services.site_finder.weight_profiles import ( resolve_weights as _resolve_weights, ) from app.services.weather_cache import ( + get_air_quality_cached, get_seasonal_weather_cached, get_weather_cached, ) @@ -117,40 +118,6 @@ def _wind_label(deg: float) -> str: return rose[idx] -def _fetch_air_quality_sync(lat: float, lon: float) -> dict | None: - """Синхронный запрос к Open-Meteo Air Quality API. - - Возвращает данные текущего часа (первый элемент hourly). None если API - недоступен или вернул неожиданный формат. - """ - try: - with httpx.Client(timeout=5) as c: - r = c.get( - "https://air-quality-api.open-meteo.com/v1/air-quality", - params={ - "latitude": lat, - "longitude": lon, - "hourly": "pm2_5,pm10,nitrogen_dioxide", - "forecast_days": 1, - }, - ) - r.raise_for_status() - data = r.json() - hourly = data.get("hourly", {}) - if not hourly.get("time"): - return None - return { - "pm2_5": hourly["pm2_5"][0] if hourly.get("pm2_5") else None, - "pm10": hourly["pm10"][0] if hourly.get("pm10") else None, - "no2": hourly["nitrogen_dioxide"][0] if hourly.get("nitrogen_dioxide") else None, - "ts": hourly["time"][0], - "source": "open-meteo", - } - except Exception as e: - logger.warning("air quality fetch failed: %s", e) - return None - - # Координаты центра ЕКБ — Площадь 1905 года EKB_CENTER_LAT: float = 56.838011 EKB_CENTER_LON: float = 60.597474 @@ -621,37 +588,74 @@ def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str список соседей для UI + флаг has_existing_buildings (overlap >50 м²). Использует GIST на cad_buildings.geom (уже создан в schema). + + PR #1130 Phase B: соседи (LIMIT 30) и overlap-check (LIMIT 5) объединены в + ОДИН SQL-statement через две CTE + scalar-агрегацию в `json_agg`. Это срезает + один сетевой round-trip (~47ms) на каждый analyze — сами вычисления не + меняются. Формат возвращаемого dict идентичен прежнему. """ try: - neighbor_rows = ( + row = ( db.execute( text(""" - SELECT cad_num, - building_name, - floors, - year_built, - cost_value, - area, - readable_address, - ST_Distance( - b.geom::geography, - ST_GeomFromText(:wkt, 4326)::geography - ) AS distance_m - FROM cad_buildings b - WHERE ST_DWithin( - b.geom::geography, - ST_GeomFromText(:wkt, 4326)::geography, - 100 - ) - AND b.cad_num != :our_cad - ORDER BY distance_m ASC - LIMIT 30 + WITH neighbors AS ( + SELECT cad_num, + building_name, + floors, + year_built, + cost_value, + area, + readable_address, + ST_Distance( + b.geom::geography, + ST_GeomFromText(:wkt, 4326)::geography + ) AS distance_m + FROM cad_buildings b + WHERE ST_DWithin( + b.geom::geography, + ST_GeomFromText(:wkt, 4326)::geography, + 100 + ) + AND b.cad_num != :our_cad + ORDER BY distance_m ASC + LIMIT 30 + ), + overlaps AS ( + SELECT cad_num, + building_name, + floors, + readable_address, + ST_Area( + ST_Intersection( + ST_Transform(b.geom, 32641), + ST_Transform(ST_GeomFromText(:wkt, 4326), 32641) + ) + ) AS overlap_m2 + FROM cad_buildings b + WHERE ST_Intersects(b.geom, ST_GeomFromText(:wkt, 4326)) + AND b.cad_num != :our_cad + ORDER BY overlap_m2 DESC NULLS LAST + LIMIT 5 + ) + SELECT + COALESCE( + (SELECT json_agg(row_to_json(n) ORDER BY n.distance_m ASC) + FROM neighbors n), + '[]'::json + ) AS neighbors, + COALESCE( + (SELECT json_agg(row_to_json(o) ORDER BY o.overlap_m2 DESC NULLS LAST) + FROM overlaps o), + '[]'::json + ) AS overlaps """), {"wkt": geom_wkt, "our_cad": our_cad_num}, ) .mappings() - .all() + .first() ) + neighbor_rows: list[dict[str, Any]] = list(row["neighbors"]) if row else [] + overlap_row: list[dict[str, Any]] = list(row["overlaps"]) if row else [] except Exception as e: logger.warning("neighbors query failed: %s", e) return {"data_available": False, "note": f"neighbors query failed: {e}"} @@ -683,35 +687,6 @@ def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str # Overlap check — что-то построено непосредственно на нашем участке. # Если хоть один building пересекается с площадью >50 м² — hard warn. - try: - overlap_row = ( - db.execute( - text(""" - SELECT cad_num, - building_name, - floors, - readable_address, - ST_Area( - ST_Intersection( - ST_Transform(b.geom, 32641), - ST_Transform(ST_GeomFromText(:wkt, 4326), 32641) - ) - ) AS overlap_m2 - FROM cad_buildings b - WHERE ST_Intersects(b.geom, ST_GeomFromText(:wkt, 4326)) - AND b.cad_num != :our_cad - ORDER BY overlap_m2 DESC NULLS LAST - LIMIT 5 - """), - {"wkt": geom_wkt, "our_cad": our_cad_num}, - ) - .mappings() - .all() - ) - except Exception as e: - logger.warning("overlap check failed: %s", e) - overlap_row = [] - overlap_buildings = [ { "cad_num": o["cad_num"], @@ -1822,7 +1797,8 @@ def analyze_parcel( noise_level = "шумно" # 8) Air quality — Open-Meteo (best-effort, null при недоступности) - air_q = _fetch_air_quality_sync(centroid_lat, centroid_lon) + # PR #1130 Phase B: TTL-кэш по округлённым coord'ам (см. app/services/weather_cache.py). + air_q = get_air_quality_cached(centroid_lat, centroid_lon) # 9) Weather — Open-Meteo 7-day forecast (best-effort, null при недоступности) # PR #1130 Phase A: TTL-кэш по округлённым coord'ам (см. app/services/weather_cache.py). diff --git a/backend/app/services/weather_cache.py b/backend/app/services/weather_cache.py index 001f2524..f5cf9bf7 100644 --- a/backend/app/services/weather_cache.py +++ b/backend/app/services/weather_cache.py @@ -1,10 +1,11 @@ -"""TTL-кэш для Open-Meteo (PR #1130 Phase A — Site Finder analyze latency). +"""TTL-кэш для Open-Meteo (PR #1130 Phase A+B — Site Finder analyze latency). -ПРОБЛЕМА: `analyze_parcel` в hot-path делал ДВА внешних HTTP-вызова к Open-Meteo на -каждый запрос (forecast + climate normals), без кэша. На проде backend в private network -с restricted egress — DNS до `*.open-meteo.com` фейлится, каждый replay висит полный -timeout (5s + 15s = до 20с лишних на analyze). Профиль cProfile показал 2.38с в hot-path -даже при частично успешном резолве. +ПРОБЛЕМА: `analyze_parcel` в hot-path делал ТРИ внешних HTTP-вызова к Open-Meteo на +каждый запрос (forecast + climate normals + air-quality), без кэша. На проде backend в +private network с restricted egress — DNS до `*.open-meteo.com` фейлится, каждый replay +висит полный timeout (5s + 15s + 5s = до 25с лишних на analyze). Профиль cProfile показал +~0.26с air-quality в успешном hit и +5с при DNS-fail (тот же антипаттерн, что был у +forecast/climate до Phase A). РЕШЕНИЕ: in-process TTL-кэш по округлённым до ~1 км координатам. @@ -13,6 +14,9 @@ timeout (5s + 15s = до 20с лишних на analyze). Профиль cProfil `timeout` на каждый analyze, но восстановление подхватывалось через ~5 минут. • Climate normals (`get_seasonal_weather_cached`) — 30-летние сезонные нормали, обновляются ~раз в год. TTL hit: 7 суток; TTL negative: 5 минут. + • Air quality (`get_air_quality_cached`) — текущий час pm2_5/pm10/no2. + TTL hit: 1 час (фронт-API API hourly bucket, обновлять чаще forecast); + TTL negative: 5 минут. Ключ — `(round(lat, 2), round(lon, 2))`: округление до сотых ≈ 1 км, ЕКБ-wide один cad ≈ один ключ. Soft-cap 256 записей per-cache (eviction первого попавшегося) — защита от @@ -46,13 +50,15 @@ logger = logging.getLogger(__name__) # TTL в секундах (см. модульный docstring). _WEATHER_TTL_S: float = 6 * 3600 # forecast — 6 часов _SEASONAL_TTL_S: float = 7 * 86400 # climate normals — 7 суток -_NEGATIVE_TTL_S: float = 300 # failed/timeout/exception — 5 минут оба кэша +_AIR_TTL_S: float = 1 * 3600 # air quality — 1 час (текущий час, обновлять чаще forecast) +_NEGATIVE_TTL_S: float = 300 # failed/timeout/exception — 5 минут все кэши -# Тайм-ауты httpx сокращены против оригинальных (5s/15s) — open-meteo обычно отвечает +# Тайм-ауты httpx сокращены против оригинальных (5s/15s/5s) — open-meteo обычно отвечает # за 200-600ms; при DNS-fail лучше быстрее упасть в negative-cache, чем держать worker # 15с в hot-path. _WEATHER_TIMEOUT_S: float = 2.0 _SEASONAL_TIMEOUT_S: float = 3.0 +_AIR_TIMEOUT_S: float = 2.0 # Soft-cap на размер каждой таблицы (округлённых координат для одного города мало). _MAX_ENTRIES: int = 256 @@ -66,6 +72,9 @@ _FORECAST_LOCK = threading.Lock() _CLIMATE_CACHE: dict[tuple[float, float], _CacheEntry] = {} _CLIMATE_LOCK = threading.Lock() +_AIR_CACHE: dict[tuple[float, float], _CacheEntry] = {} +_AIR_LOCK = threading.Lock() + def _now() -> float: """Монотонный таймер (тесты подменяют этот helper, а не сам `time`).""" @@ -276,3 +285,58 @@ def get_seasonal_weather_cached(lat: float, lon: float) -> dict[str, Any] | None _evict_one_if_full(_CLIMATE_CACHE) _CLIMATE_CACHE[key] = (value, _now() + ttl) return value + + +def _fetch_air_quality_remote(lat: float, lon: float) -> dict[str, Any] | None: + """Open-Meteo Air Quality API — pm2_5/pm10/no2 текущего часа. + + Перенесено из `app.api.v1.parcels._fetch_air_quality_sync` без изменения формата + возвращаемого dict (фронт зависит). Любое исключение → logger.warning + None + (caller обернёт None в negative-cache на короткий TTL). + """ + try: + with httpx.Client(timeout=_AIR_TIMEOUT_S) as c: + r = c.get( + "https://air-quality-api.open-meteo.com/v1/air-quality", + params={ + "latitude": lat, + "longitude": lon, + "hourly": "pm2_5,pm10,nitrogen_dioxide", + "forecast_days": 1, + }, + ) + r.raise_for_status() + data = r.json() + hourly = data.get("hourly", {}) + if not hourly.get("time"): + return None + return { + "pm2_5": hourly["pm2_5"][0] if hourly.get("pm2_5") else None, + "pm10": hourly["pm10"][0] if hourly.get("pm10") else None, + "no2": hourly["nitrogen_dioxide"][0] if hourly.get("nitrogen_dioxide") else None, + "ts": hourly["time"][0], + "source": "open-meteo", + } + except Exception as e: + logger.warning("air quality fetch failed: %s", e) + return None + + +def get_air_quality_cached(lat: float, lon: float) -> dict[str, Any] | None: + """Open-Meteo Air Quality API (pm2_5/pm10/no2 текущего часа) с TTL-кэшем. + + Hit (успех): кэш 1 час (hourly bucket API — обновлять чаще forecast). Negative + (None при ошибке): кэш 5 минут. Ключ — координаты, округлённые до 0.01° (~1 км). + На любой Exception от httpx/json возвращает None и кэширует None под коротким TTL. + """ + key = _round_key(lat, lon) + now = _now() + with _AIR_LOCK: + entry = _AIR_CACHE.get(key) + if entry is not None and entry[1] > now: + return entry[0] + value = _fetch_air_quality_remote(lat, lon) + ttl = _AIR_TTL_S if value is not None else _NEGATIVE_TTL_S + _evict_one_if_full(_AIR_CACHE) + _AIR_CACHE[key] = (value, _now() + ttl) + return value diff --git a/backend/tests/api/v1/test_analyze_inline_weights.py b/backend/tests/api/v1/test_analyze_inline_weights.py index 510978c4..e94f8491 100644 --- a/backend/tests/api/v1/test_analyze_inline_weights.py +++ b/backend/tests/api/v1/test_analyze_inline_weights.py @@ -127,7 +127,7 @@ def _override_db(db: MagicMock): # Патчим тяжёлые внешние вызовы (weather / velocity / nspd-dump), # чтобы тесты не зависели от сети и не требовали полного mock DB. _PATCHES = [ - patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None), + patch("app.api.v1.parcels.get_air_quality_cached", return_value=None), patch("app.api.v1.parcels.get_weather_cached", return_value=None), patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None), patch( diff --git a/backend/tests/api/v1/test_analyze_market_price.py b/backend/tests/api/v1/test_analyze_market_price.py index 3043bbdd..f01176cb 100644 --- a/backend/tests/api/v1/test_analyze_market_price.py +++ b/backend/tests/api/v1/test_analyze_market_price.py @@ -126,7 +126,7 @@ def _override_db(db: MagicMock): _PATCHES = [ - patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None), + patch("app.api.v1.parcels.get_air_quality_cached", return_value=None), patch("app.api.v1.parcels.get_weather_cached", return_value=None), patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None), patch( diff --git a/backend/tests/api/v1/test_analyze_parcel_meta.py b/backend/tests/api/v1/test_analyze_parcel_meta.py index 2b4b86ba..3991da1b 100644 --- a/backend/tests/api/v1/test_analyze_parcel_meta.py +++ b/backend/tests/api/v1/test_analyze_parcel_meta.py @@ -130,7 +130,7 @@ def _override_db(db: MagicMock): _PATCHES = [ - patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None), + patch("app.api.v1.parcels.get_air_quality_cached", return_value=None), patch("app.api.v1.parcels.get_weather_cached", return_value=None), patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None), patch( diff --git a/backend/tests/api/v1/test_analyze_recent_permits.py b/backend/tests/api/v1/test_analyze_recent_permits.py index de13651f..4bbef033 100644 --- a/backend/tests/api/v1/test_analyze_recent_permits.py +++ b/backend/tests/api/v1/test_analyze_recent_permits.py @@ -115,7 +115,7 @@ def _override_db(db: MagicMock): _PATCHES = [ - patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None), + patch("app.api.v1.parcels.get_air_quality_cached", return_value=None), patch("app.api.v1.parcels.get_weather_cached", return_value=None), patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None), patch( diff --git a/backend/tests/api/v1/test_custom_pois.py b/backend/tests/api/v1/test_custom_pois.py index e212e341..151728f0 100644 --- a/backend/tests/api/v1/test_custom_pois.py +++ b/backend/tests/api/v1/test_custom_pois.py @@ -325,7 +325,7 @@ def _override_db(db: MagicMock): _ANALYZE_PATCHES = [ - patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None), + patch("app.api.v1.parcels.get_air_quality_cached", return_value=None), patch("app.api.v1.parcels.get_weather_cached", return_value=None), patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None), patch( diff --git a/backend/tests/api/v1/test_parcels_forecast.py b/backend/tests/api/v1/test_parcels_forecast.py index e446d2ce..ec07b74f 100644 --- a/backend/tests/api/v1/test_parcels_forecast.py +++ b/backend/tests/api/v1/test_parcels_forecast.py @@ -95,7 +95,7 @@ def _override_db(db: MagicMock): # Тяжёлые внешние сервисы — заглушки (как в test_analyze_market_price.py). _PATCHES = [ - patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None), + patch("app.api.v1.parcels.get_air_quality_cached", return_value=None), patch("app.api.v1.parcels.get_weather_cached", return_value=None), patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None), patch( diff --git a/backend/tests/api/v1/test_run_history_and_response_contract.py b/backend/tests/api/v1/test_run_history_and_response_contract.py index f2998abc..06a5b6f3 100644 --- a/backend/tests/api/v1/test_run_history_and_response_contract.py +++ b/backend/tests/api/v1/test_run_history_and_response_contract.py @@ -91,7 +91,7 @@ def _override_db(db: MagicMock): _HEAVY_PATCHES = [ - patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None), + patch("app.api.v1.parcels.get_air_quality_cached", return_value=None), patch("app.api.v1.parcels.get_weather_cached", return_value=None), patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None), patch( diff --git a/backend/tests/services/test_weather_cache.py b/backend/tests/services/test_weather_cache.py index f4d9e91d..6bb6e0bc 100644 --- a/backend/tests/services/test_weather_cache.py +++ b/backend/tests/services/test_weather_cache.py @@ -40,12 +40,14 @@ from app.services import weather_cache @pytest.fixture(autouse=True) def _reset_caches() -> Iterator[None]: - """Чистить обе TTL-таблицы до и после каждого теста (state живёт в модуле).""" + """Чистить все 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]: @@ -96,6 +98,18 @@ def _make_httpx_response(payload: dict[str, Any]) -> MagicMock: return resp +def _make_air_response() -> dict[str, Any]: + """Минимальный валидный JSON от Open-Meteo Air Quality API (24 часа forecast).""" + return { + "hourly": { + "time": ["2026-06-12T00:00", "2026-06-12T01:00"], + "pm2_5": [12.5, 13.0], + "pm10": [25.0, 26.0], + "nitrogen_dioxide": [15.0, 16.0], + } + } + + # ────────────────────────────────────────────────────────────────────────────── # 1. Hot-cache HIT (главный смысл PR'а — снять повторные запросы). # ────────────────────────────────────────────────────────────────────────────── @@ -338,3 +352,100 @@ class TestExpiresAfterTtl: 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