212 lines
8.7 KiB
Python
212 lines
8.7 KiB
Python
"""Тесты 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'ы/rollback'и, ничего не пишет.
|
||
|
||
rollback() — owned-session (Celery-таск backfill_zone_regulations.py владеет
|
||
SessionLocal() целиком) fallback на per-zone сбое (#2464 cluster A finding 6).
|
||
"""
|
||
|
||
def __init__(self) -> None:
|
||
self.commits = 0
|
||
self.rollbacks = 0
|
||
|
||
def commit(self) -> None:
|
||
self.commits += 1
|
||
|
||
def rollback(self) -> None:
|
||
self.rollbacks += 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 {}
|
||
)
|
||
|
||
db = _DB()
|
||
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 # дошли до обеих зон
|
||
# #2464 cluster A finding 6: сбойная зона откатывает СВОЮ (owned) сессию —
|
||
# иначе следующая итерация цикла поймала бы "current transaction is aborted".
|
||
assert db.rollbacks == 1
|
||
assert db.commits == 1 # только вторая (успешная) зона закоммитилась
|
||
|
||
|
||
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"}
|