gendesign/backend/tests/services/test_zone_regulation_backfill.py
Light1YT 2ff34b1500
Some checks failed
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Failing after 1m48s
CI / backend-tests (pull_request) Successful in 9m56s
feat(site-finder): backfill ЕКБ зон-регламента — разблокировать видимость финмодели (#1881)
financial_estimate (эпик #1881) фаерит только при разрезолвленном НСПД-регламенте
(max_far), а он резолвится ЛЕНИВО при анализе участка → zone_regulation_cache всего
33 зоны → у большинства участков financial_estimate=None («—» в кокпите). Backfill
проактивно кэширует ВСЕ террзоны ЕКБ (~100-120) → ЛЮБОй участок в известной зоне
сразу получает регламент и финмодель.

- backfill_ekb_zone_regulations (zone_regulation.py): WFS-перечисление террзон ЕКБ
  (features_in_bbox 'territorial_zone') → dedup по urban_index (предпочёт фичу с
  геометрией) → representative_point (внутри полигона, не centroid) → zone_regulation_at
  → upsert. ИДЕМПОТЕНТНО (cached skip перед fetch), per-zone try/except (один сбой не
  рушит batch), rate_delay вежливость к геопорталу, limit для частичного прогона.
- Celery task backfill_zone_regulations + регистрация в celery_app include +
  admin POST /api/v1/admin/scrape/zone-regulations/backfill (паттерн objective sync-our).
- Ленивый refresh_zone_regulations / get_or_fetch / upsert НЕ тронуты (additive путь).
- +9 тестов (dedup, idempotency cached-skip, per-zone error isolation, centroid-inside,
  limit). mypy/ruff clean.

Прод-прогон backfill — отдельный шаг после deploy (как Objective sync), не на deploy.
Code-review поймал незарегистрированный task (был бы NotRegistered) — исправлено.

Refs #1881
2026-06-24 02:11:41 +05:00

199 lines
8.1 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'ы, ничего не пишет."""
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"}