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