gendesign/backend/tests/services/test_zone_regulation_backfill.py
bot-backend 39dd63333e
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 3m29s
Deploy / build-worker (push) Successful in 4m48s
Deploy / deploy (push) Successful in 1m43s
fix(site_finder): SAVEPOINT-isolate DB errors across cluster A (#2464) (#2465)
2026-07-07 12:50:11 +00:00

212 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Тесты 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"}