gendesign/backend/tests/services/test_ird_analyze.py
bot-backend b24de7d3cc
All checks were successful
CI / changes (push) Successful in 6s
CI / changes (pull_request) Successful in 5s
CI / backend-tests (push) Successful in 6m23s
CI / backend-tests (pull_request) Successful in 6m22s
perf(sf): concurrent geoportal-вызовы в ИРД-блоке analyze (#1102)
2026-06-07 13:43:17 +03:00

193 lines
8.3 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.

"""Тесты build_ird_analyze_block (#1067 D9b analyze-wiring, #1102 concurrent geoportal).
DB/сеть не дёргаются: мокаем parcel_ird_overlaps + get_or_fetch_zone_regulation (module-level)
и передаём fake EKBGeoportalClient. Проверяем сборку блока, split opportunity_*, graceful-деградацию
каждого источника, skip geoportal при отсутствии centroid, и корректность конкурентного исполнения.
"""
from __future__ import annotations
import threading
from typing import Any
from app.services.site_finder import ird_analyze
class _Feat:
def __init__(self, properties: dict[str, Any]) -> None:
self.properties = properties
class _FakeClient:
def __init__(self, *, fz: list[_Feat], krt: list[_Feat], boom: bool = False) -> None:
self._fz = fz
self._krt = krt
self._boom = boom
self.calls: list[tuple[str, float, float]] = []
def features_at_point(self, layer: str, lon: float, lat: float) -> list[_Feat]:
self.calls.append((layer, lon, lat))
if self._boom:
raise RuntimeError("geoportal down")
return self._fz if layer == "functional_zone" else self._krt
_OVERLAY = {
"ird_overlaps": [
{"layer_kind": "zouit_okn", "reg_numb_border": "66:41-6.1"},
{"layer_kind": "opportunity_auction", "reg_numb_border": "66:41:1:10"},
{"layer_kind": "opportunity_krt", "reg_numb_border": None},
],
"ird_by_kind": {"zouit_okn": 1, "opportunity_auction": 1, "opportunity_krt": 1},
}
_PLANNING = [{"project_type": "ppt", "doc_status_name": "действующий", "full_name": "ПАГЕ №1"}]
def _patch_overlay(monkeypatch: Any, overlay: dict[str, Any]) -> None:
monkeypatch.setattr(ird_analyze, "parcel_ird_overlaps", lambda db, wkt: overlay)
monkeypatch.setattr(ird_analyze, "parcel_planning_overlaps", lambda db, wkt: list(_PLANNING))
def test_full_block_assembly(monkeypatch: Any) -> None:
"""Все источники → overlaps + split opportunity + КРТ + регламент.
functional_zone всегда None: слой пуст в геопортале ЕКБ (0 фич, 2026-06) — WFS не дёргается.
"""
_patch_overlay(monkeypatch, _OVERLAY)
monkeypatch.setattr(
ird_analyze,
"get_or_fetch_zone_regulation",
lambda db, lon, lat, client=None: {"zone_index": "Ц-1"},
)
client = _FakeClient(fz=[_Feat({"name": "Ж-зона"})], krt=[_Feat({"num_oks_krt": "КРТ-7"})])
block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", 60.6, 56.8, client=client)
assert len(block["ird_overlaps"]) == 3
assert block["ird_by_kind"]["opportunity_auction"] == 1
assert [o["layer_kind"] for o in block["opportunity_overlaps"]] == [
"opportunity_auction",
"opportunity_krt",
]
# functional_zone WFS выключен (слой пуст в геопортале ЕКБ, #1058) — всегда None
assert block["functional_zone"] is None
assert "functional_zone" not in [layer for layer, _, _ in client.calls]
assert block["krt"] == [{"num_oks_krt": "КРТ-7"}]
assert block["zone_regulation"] == {"zone_index": "Ц-1"}
assert block["planning_projects"] == _PLANNING
def test_geoportal_failure_is_graceful(monkeypatch: Any) -> None:
"""Сбой геопортала → функц.зона None, КРТ [], регламент None; overlaps уцелели."""
_patch_overlay(monkeypatch, _OVERLAY)
def _boom(*a: Any, **kw: Any) -> Any:
raise RuntimeError("zone reg down")
monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", _boom)
client = _FakeClient(fz=[], krt=[], boom=True)
block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", 60.6, 56.8, client=client)
assert len(block["ird_overlaps"]) == 3 # DB-overlay не задет
assert block["functional_zone"] is None
assert block["krt"] == []
assert block["zone_regulation"] is None
def test_no_centroid_skips_geoportal(monkeypatch: Any) -> None:
"""lon/lat None → только DB-overlay, geoportal не зовётся (client.calls пуст)."""
_patch_overlay(monkeypatch, _OVERLAY)
client = _FakeClient(fz=[_Feat({"x": 1})], krt=[])
block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", None, None, client=client)
assert client.calls == []
assert block["functional_zone"] is None
assert block["krt"] == []
assert block["zone_regulation"] is None
assert len(block["ird_overlaps"]) == 3
def test_empty_overlay(monkeypatch: Any) -> None:
"""Пустой overlay → пустые списки, без KeyError."""
_patch_overlay(monkeypatch, {"ird_overlaps": [], "ird_by_kind": {}})
client = _FakeClient(fz=[], krt=[])
monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", lambda *a, **kw: None)
block = ird_analyze.build_ird_analyze_block(object(), None, None, None, client=client)
assert block["ird_overlaps"] == []
assert block["opportunity_overlaps"] == []
assert block["ird_by_kind"] == {}
# ---------------------------------------------------------------------------
# Concurrency tests (#1102)
# ---------------------------------------------------------------------------
def test_concurrent_both_calls_succeed(monkeypatch: Any) -> None:
"""krt и zone_regulation оба вызываются и попадают в block (конкурентный путь)."""
_patch_overlay(monkeypatch, _OVERLAY)
zone_reg_calls: list[int] = []
def _fake_zone_reg(db: Any, lon: float, lat: float, client: Any = None) -> dict[str, Any]:
zone_reg_calls.append(1)
return {"zone_index": "Ж-3"}
monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", _fake_zone_reg)
client = _FakeClient(fz=[], krt=[_Feat({"num_oks_krt": "КРТ-99"})])
block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", 60.6, 56.8, client=client)
assert block["krt"] == [{"num_oks_krt": "КРТ-99"}]
assert block["zone_regulation"] == {"zone_index": "Ж-3"}
assert len(zone_reg_calls) == 1
# krt layer was requested
krt_calls = [layer for layer, _, _ in client.calls if layer == "krt"]
assert len(krt_calls) == 1
def test_concurrent_krt_exception_graceful(monkeypatch: Any) -> None:
"""Исключение в krt (через ThreadPoolExecutor) → block['krt']==[] и zone_regulation заполнен."""
_patch_overlay(monkeypatch, _OVERLAY)
monkeypatch.setattr(
ird_analyze,
"get_or_fetch_zone_regulation",
lambda db, lon, lat, client=None: {"zone_index": "Б-1"},
)
# boom=True → features_at_point бросает RuntimeError → _krt_at ловит и возвращает []
client = _FakeClient(fz=[], krt=[], boom=True)
block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", 60.6, 56.8, client=client)
assert block["krt"] == []
assert block["zone_regulation"] == {"zone_index": "Б-1"}
assert len(block["ird_overlaps"]) == 3
def test_session_not_shared_between_threads(monkeypatch: Any) -> None:
"""Session не шарится между потоками: zone_regulation вызывается в основном потоке.
Проверяем через threading.current_thread(): zone_reg видит тот же поток, что и вызывающий
(основной), а не поток из ThreadPoolExecutor (который обрабатывает krt_future).
"""
_patch_overlay(monkeypatch, _OVERLAY)
main_thread = threading.current_thread()
zone_reg_thread: list[threading.Thread] = []
def _fake_zone_reg(db: Any, lon: float, lat: float, client: Any = None) -> dict[str, Any]:
zone_reg_thread.append(threading.current_thread())
return {"zone_index": "Ц-2"}
monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", _fake_zone_reg)
client = _FakeClient(fz=[], krt=[_Feat({"a": 1})])
ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", 60.6, 56.8, client=client)
assert len(zone_reg_thread) == 1
assert (
zone_reg_thread[0] is main_thread
), "zone_regulation должен исполняться в основном потоке, а не в фоновом (Session safety)"