"""Тесты backfill_ekb_zone_regulations (эпик #1881) — прогрев кэша регламента ВСЕХ терзон ЕКБ. Сеть/БД не дёргаются: фейковый EKBGeoportalClient + фейковая Session + monkeypatch ``upsert_zone_regulation``/``get_cached_zone_regulation`` (логику апсёрта/чтения не тестируем тут — она покрыта test_zone_regulation_extract). Проверяем: - дедуп по urban_index (fetch только уникальных индексов), - идемпотентность (cached skip → не фетчим), - per-zone error isolation (одна зона падает → failed++, batch продолжается), - limit, - представительную точку (внутри полигона). """ from __future__ import annotations from typing import Any import pytest from app.services.scrapers.ekb_geoportal_client import EKBFeature, ZoneRegulation from app.services.site_finder import zone_regulation as zr def _poly() -> dict[str, Any]: return {"type": "Polygon", "coordinates": [[[60, 56], [60.1, 56], [60.1, 56.1], [60, 56]]]} def _feat(idx: str, geometry: dict[str, Any] | None = None) -> EKBFeature: return EKBFeature( feature_id=idx, geometry=geometry if geometry is not None else _poly(), properties={"urban_index": idx}, ) def _reg(idx: str) -> ZoneRegulation: return ZoneRegulation( zone_index=idx, zone_full_name=f"{idx} зона", main_vri=[], conditional_vri=[], auxiliary_vri=[], limit_params=[], ) class _FakeClient: """Фейковый WFS-клиент: отдаёт заданные фичи, фиксирует резолвы. Все тестовые полигоны идентичны → ``zone_regulation_at`` не различает зоны по координатам; нам это и не нужно — проверяем счётчики/дедуп/число резолвов, а не маппинг точка→зона. """ def __init__(self, feats: list[EKBFeature]) -> None: self._feats = feats self.resolved: list[tuple[float, float]] = [] def features_in_bbox(self, layer: str, bbox: Any) -> list[EKBFeature]: return self._feats def zone_regulation_at(self, lon: float, lat: float) -> ZoneRegulation: self.resolved.append((lon, lat)) return _reg(f"Z-{len(self.resolved)}") class _DB: """Фейковая Session: считает commit'ы, ничего не пишет.""" def __init__(self) -> None: self.commits = 0 def commit(self) -> None: self.commits += 1 @pytest.fixture def _no_sleep(monkeypatch: Any) -> None: """Не спим в тестах (rate_delay).""" monkeypatch.setattr(zr.time, "sleep", lambda *_a, **_kw: None) def test_dedup_and_counts(monkeypatch: Any, _no_sleep: None) -> None: """3 фичи (2 уникальных индекса + 1 дубль) → fetch/upsert только 2 уникальных.""" feats = [_feat("Ц-1"), _feat("Ц-1"), _feat("Ж-2")] client = _FakeClient(feats) upserts: list[ZoneRegulation] = [] monkeypatch.setattr(zr, "get_cached_zone_regulation", lambda *a, **kw: None) monkeypatch.setattr( zr, "upsert_zone_regulation", lambda db, reg, **kw: upserts.append(reg) or {} ) db = _DB() result = zr.backfill_ekb_zone_regulations(db, client=client, rate_delay_s=0.0) # type: ignore[arg-type] assert result["enumerated_features"] == 3 assert result["unique_zones"] == 2 # дубль Ц-1 схлопнут assert result["cached_before"] == 0 assert result["fetched"] == 2 # только уникальные индексы assert result["upserted"] == 2 assert result["failed"] == 0 assert len(client.resolved) == 2 assert db.commits == 2 # commit per-upsert def test_idempotent_cached_skip(monkeypatch: Any, _no_sleep: None) -> None: """Если зона уже в кэше — НЕ фетчим (идемпотентность).""" feats = [_feat("Ц-1"), _feat("Ж-2")] client = _FakeClient(feats) # Ц-1 закэширована, Ж-2 нет. monkeypatch.setattr( zr, "get_cached_zone_regulation", lambda db, zone_index, **kw: {"zone_index": zone_index} if zone_index == "Ц-1" else None, ) upserts: list[ZoneRegulation] = [] monkeypatch.setattr( zr, "upsert_zone_regulation", lambda db, reg, **kw: upserts.append(reg) or {} ) result = zr.backfill_ekb_zone_regulations(_DB(), client=client, rate_delay_s=0.0) # type: ignore[arg-type] assert result["cached_before"] == 1 # Ц-1 пропущена assert result["fetched"] == 1 # только Ж-2 assert result["upserted"] == 1 assert len(client.resolved) == 1 # Ц-1 не дёргала геопортал def test_per_zone_error_isolation(monkeypatch: Any, _no_sleep: None) -> None: """Сбой fetch одной зоны → failed++, batch продолжается для остальных.""" # Клиент кидает на ПЕРВОЙ резолвленной зоне (по порядку), вторую отдаёт нормально. feats = [_feat("Ц-1"), _feat("Ж-2")] calls = {"n": 0} class _RaisingClient(_FakeClient): def zone_regulation_at(self, lon: float, lat: float) -> ZoneRegulation: self.resolved.append((lon, lat)) calls["n"] += 1 if calls["n"] == 1: raise RuntimeError("geoportal 500 on first zone") return _reg("Ж-2") client = _RaisingClient(feats) monkeypatch.setattr(zr, "get_cached_zone_regulation", lambda *a, **kw: None) upserts: list[ZoneRegulation] = [] monkeypatch.setattr( zr, "upsert_zone_regulation", lambda db, reg, **kw: upserts.append(reg) or {} ) result = zr.backfill_ekb_zone_regulations(_DB(), client=client, rate_delay_s=0.0) # type: ignore[arg-type] assert result["unique_zones"] == 2 assert result["failed"] == 1 # первая зона упала assert result["upserted"] == 1 # вторая прошла → batch не остановился assert len(client.resolved) == 2 # дошли до обеих зон def test_limit_caps_fetches(monkeypatch: Any, _no_sleep: None) -> None: """limit ограничивает число live-fetch'ей незакэшированных зон.""" feats = [_feat("Ц-1"), _feat("Ж-2"), _feat("Ж-3")] client = _FakeClient(feats) monkeypatch.setattr(zr, "get_cached_zone_regulation", lambda *a, **kw: None) upserts: list[ZoneRegulation] = [] monkeypatch.setattr( zr, "upsert_zone_regulation", lambda db, reg, **kw: upserts.append(reg) or {} ) result = zr.backfill_ekb_zone_regulations(_DB(), client=client, limit=2, rate_delay_s=0.0) # type: ignore[arg-type] assert result["unique_zones"] == 3 assert result["fetched"] == 2 # обрезано лимитом assert len(client.resolved) == 2 def test_representative_point_inside_polygon() -> None: pt = zr._representative_point(_poly()) assert pt is not None lon, lat = pt assert 60.0 <= lon <= 60.1 assert 56.0 <= lat <= 56.1 def test_representative_point_none_on_bad_geom() -> None: assert zr._representative_point(None) is None assert zr._representative_point({"type": "Polygon", "coordinates": "garbage"}) is None def test_dedup_prefers_feature_with_geometry() -> None: """Из двух фич одного индекса выбираем ту, у которой есть геометрия.""" feats = [_feat("Ц-1", geometry=None), _feat("Ц-1", geometry=_poly())] by_index = zr._dedup_zones_by_index(feats) assert set(by_index) == {"Ц-1"} assert by_index["Ц-1"].geometry is not None def test_blank_index_dropped() -> None: """Фичи с пустым/отсутствующим urban_index не попадают в дедуп.""" feats = [_feat("", geometry=_poly()), _feat("Ц-1")] by_index = zr._dedup_zones_by_index(feats) assert set(by_index) == {"Ц-1"}