"""Тесты для quarter_dump_lookup.py — risk zones + generic layer extraction + TIER 4. Покрывает: - _extract_features_by_layer: filter by prefix - _get_risk_zones / extract through get_quarter_dump_data: intersect, no-intersect, no-risks-in-dump - _get_opportunity_parcels: auction/scheme/free/future/oopt layers, distance sort, empty - _get_red_lines: intersecting, nearby-only, empty, early-exit - EMPTY_DUMP_RESULT / make_empty_result: new keys присутствуют Не требует реального PostgreSQL — мокает db.execute(). """ from __future__ import annotations from typing import Any from unittest.mock import MagicMock import pytest from app.services.site_finder.quarter_dump_lookup import ( EMPTY_DUMP_RESULT, _extract_features_by_layer, _get_cad_zouit_overlaps, _get_opportunity_parcels, _get_red_lines, _get_risk_zones, derive_quarter_cad, get_quarter_dump_data, make_empty_result, ) # ── Fixtures & helpers ──────────────────────────────────────────────────────── def _make_feature(layer: str, geom: dict[str, Any] | None = None) -> dict[str, Any]: """Минимальный feature dict в формате features_json.""" return { "layer": layer, "feature_id": "test-id", "geometry": geom or {"type": "Polygon", "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 0]]]}, "properties": {"name": f"test-{layer}", "type_zone": f"zone-{layer}"}, } # ── _extract_features_by_layer ──────────────────────────────────────────────── def test_extract_features_by_layer_prefix_match() -> None: """Возвращает только features с нужным prefix.""" features = [ _make_feature("risk_flooding"), _make_feature("risk_burns"), _make_feature("zouit_engineering"), _make_feature("parcels"), _make_feature("risk_landslide"), ] result = _extract_features_by_layer(features, "risk_") assert len(result) == 3 layers = {f["layer"] for f in result} assert layers == {"risk_flooding", "risk_burns", "risk_landslide"} def test_extract_features_by_layer_zouit_prefix() -> None: """Работает для zouit_ prefix — generic re-use.""" features = [ _make_feature("zouit_engineering"), _make_feature("zouit_okn"), _make_feature("risk_flooding"), ] result = _extract_features_by_layer(features, "zouit_") assert len(result) == 2 assert all(f["layer"].startswith("zouit_") for f in result) def test_extract_features_by_layer_empty_list() -> None: result = _extract_features_by_layer([], "risk_") assert result == [] def test_extract_features_by_layer_no_match() -> None: features = [_make_feature("parcels"), _make_feature("buildings")] result = _extract_features_by_layer(features, "risk_") assert result == [] def test_extract_features_by_layer_non_string_layer() -> None: """Gracefully skips features with non-string layer.""" features: list[dict[str, Any]] = [ {"layer": None, "feature_id": "x", "geometry": None, "properties": {}}, _make_feature("risk_flooding"), ] result = _extract_features_by_layer(features, "risk_") assert len(result) == 1 assert result[0]["layer"] == "risk_flooding" # ── _get_risk_zones ─────────────────────────────────────────────────────────── def _make_mock_db_with_rows(rows: list[tuple[Any, ...]]) -> MagicMock: """Мок Session где execute().fetchall() возвращает rows.""" db = MagicMock() mock_result = MagicMock() mock_result.fetchall.return_value = rows db.execute.return_value = mock_result return db def test_get_risk_zones_intersects() -> None: """Три risk features → три записи в результате.""" rows: list[tuple[Any, ...]] = [ # (layer, props, geom_wkt, intersection_area_sqm) ( "risk_flooding", {"type_zone": "Затопление 1%"}, "POLYGON((0 0,1 0,1 1,0 0))", 1234.5, ), ( "risk_burns", {"name": "Гарь"}, "POLYGON((0 0,2 0,2 2,0 0))", 500.0, ), ( "risk_landslide", {}, "POLYGON((0 0,3 0,3 3,0 0))", 999.9, ), ] db = _make_mock_db_with_rows(rows) result = _get_risk_zones(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))") assert len(result) == 3 flooding = next(r for r in result if r["layer"] == "risk_flooding") assert flooding["subtype"] == "Затопление 1%" assert flooding["intersection_area_sqm"] == 1234.5 assert flooding["geom_wkt"] == "POLYGON((0 0,1 0,1 1,0 0))" burns = next(r for r in result if r["layer"] == "risk_burns") assert burns["subtype"] == "Гарь" landslide = next(r for r in result if r["layer"] == "risk_landslide") # No properties name/type_zone → falls back to _RISK_SUBTYPE_LABELS assert landslide["subtype"] == "Обвально-осыпные процессы" def test_get_risk_zones_no_intersect() -> None: """Пустой fetchall → пустой список.""" db = _make_mock_db_with_rows([]) result = _get_risk_zones(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))") assert result == [] def test_get_risk_zones_no_risks_in_dump() -> None: """risks_count == 0 → early exit, db.execute не вызывается.""" db = MagicMock() layer_counts = {"risks_count": 0} result = _get_risk_zones(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))", layer_counts) assert result == [] db.execute.assert_not_called() def test_get_risk_zones_db_exception_returns_empty() -> None: """DB exception → пустой список (не propagate).""" db = MagicMock() db.execute.side_effect = Exception("DB connection lost") result = _get_risk_zones(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))") assert result == [] def test_get_risk_zones_null_area() -> None: """NULL intersection_area_sqm → intersection_area_sqm=None в результате.""" rows = [("risk_erosion_water", {}, "POINT(0 0)", None)] db = _make_mock_db_with_rows(rows) result = _get_risk_zones(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))") assert len(result) == 1 assert result[0]["intersection_area_sqm"] is None # ── make_empty_result includes nspd_risk_zones ──────────────────────────────── def test_make_empty_result_has_risk_zones_key() -> None: """make_empty_result возвращает nspd_risk_zones: [].""" result = make_empty_result() assert "nspd_risk_zones" in result assert result["nspd_risk_zones"] == [] # ── _get_opportunity_parcels ───────────────────────────────────────────────── def test_get_opportunity_parcels_finds_auction() -> None: """3 features (1 auction + 2 other types) — все возвращаются, auction в нужном layer.""" rows: list[tuple[Any, ...]] = [ # (layer, props, geom_wkt, distance_m) ( "opportunity_auction_parcels", {"cad_num": "66:41:0204016:101"}, "POLYGON((0 0,1 0,1 1,0 0))", 50.0, ), ( "opportunity_scheme_parcels", {"cadastral_number": "66:41:0204016:102"}, "POLYGON((1 1,2 1,2 2,1 1))", 150.0, ), ( "opportunity_oopt", {}, "POLYGON((2 2,3 2,3 3,2 2))", 300.0, ), ] db = _make_mock_db_with_rows(rows) result = _get_opportunity_parcels(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))") assert len(result) == 3 auction = next(r for r in result if r["layer"] == "auction_parcels") assert auction["cad_num"] == "66:41:0204016:101" assert auction["distance_m"] == 50.0 scheme = next(r for r in result if r["layer"] == "scheme_parcels") assert scheme["cad_num"] == "66:41:0204016:102" oopt = next(r for r in result if r["layer"] == "oopt") assert oopt["cad_num"] is None assert oopt["distance_m"] == 300.0 def test_get_opportunity_parcels_distance_sort() -> None: """Результаты отсортированы по distance_m ASC (SQL ORDER BY передаётся DB).""" rows: list[tuple[Any, ...]] = [ # Порядок: ближайший первым (SQL уже сортирует — тест проверяет что порядок сохраняется) ( "opportunity_free_parcels", {"cad_num": "66:41:0204016:10"}, "POINT(0 0)", 10.0, ), ( "opportunity_auction_parcels", {"cad_num": "66:41:0204016:20"}, "POINT(1 1)", 200.0, ), ( "opportunity_future_parcels", {}, "POINT(2 2)", 450.0, ), ] db = _make_mock_db_with_rows(rows) result = _get_opportunity_parcels(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))") assert len(result) == 3 # Порядок из DB (mock возвращает в порядке rows) — ближайший первый assert result[0]["distance_m"] == 10.0 assert result[1]["distance_m"] == 200.0 assert result[2]["distance_m"] == 450.0 def test_get_opportunity_parcels_empty() -> None: """Нет opportunity features — пустой список.""" db = _make_mock_db_with_rows([]) result = _get_opportunity_parcels(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))") assert result == [] def test_get_opportunity_parcels_early_exit() -> None: """opportunity_count == 0 → early exit, db.execute не вызывается.""" db = MagicMock() layer_counts = {"opportunity_count": 0} result = _get_opportunity_parcels( db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))", layer_counts ) assert result == [] db.execute.assert_not_called() def test_get_opportunity_parcels_db_exception_returns_empty() -> None: """DB exception → пустой список (не propagate).""" db = MagicMock() db.execute.side_effect = Exception("connection lost") result = _get_opportunity_parcels(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))") assert result == [] # ── _get_red_lines ──────────────────────────────────────────────────────────── def test_get_red_lines_intersects() -> None: """Red line intersecting parcel → intersection_length_m filled, distance_m=None.""" rows: list[tuple[Any, ...]] = [ # (geom_wkt, does_intersect, intersection_length_m, distance_m) ( "LINESTRING(0 0, 1 1)", True, 45.3, # длина пересечения в метрах 0.0, # intersecting → distance_m becomes None in result ), ] db = _make_mock_db_with_rows(rows) result = _get_red_lines(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))") assert len(result) == 1 r = result[0] assert r["geom_wkt"] == "LINESTRING(0 0, 1 1)" assert r["intersection_length_m"] == 45.3 assert r["distance_m"] is None # null when intersecting def test_get_red_lines_nearby_only() -> None: """Red line nearby only (не intersect) → distance_m filled, intersection_length_m=None.""" rows: list[tuple[Any, ...]] = [ # (geom_wkt, does_intersect, intersection_length_m, distance_m) ( "LINESTRING(10 10, 20 20)", False, 0.0, # нет пересечения 85.5, ), ] db = _make_mock_db_with_rows(rows) result = _get_red_lines(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))") assert len(result) == 1 r = result[0] assert r["intersection_length_m"] is None assert r["distance_m"] == 85.5 def test_get_red_lines_db_exception_returns_empty() -> None: """DB exception → пустой список (не propagate).""" db = MagicMock() db.execute.side_effect = Exception("DB connection lost") result = _get_red_lines(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))") assert result == [] def test_get_red_lines_empty() -> None: """Нет red lines features — пустой список.""" db = _make_mock_db_with_rows([]) result = _get_red_lines(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))") assert result == [] def test_get_red_lines_early_exit() -> None: """red_lines_count == 0 → early exit, db.execute не вызывается.""" db = MagicMock() layer_counts = {"red_lines_count": 0} result = _get_red_lines(db, "66:41:0204016", "POLYGON((0 0,1 0,1 1,0 0))", layer_counts) assert result == [] db.execute.assert_not_called() # ── EMPTY_DUMP_RESULT / make_empty_result includes new TIER 4 keys ──────────── def test_empty_dump_result_has_opportunity_key() -> None: """EMPTY_DUMP_RESULT содержит nspd_opportunity_parcels: [].""" assert "nspd_opportunity_parcels" in EMPTY_DUMP_RESULT assert EMPTY_DUMP_RESULT["nspd_opportunity_parcels"] == [] def test_empty_dump_result_has_red_lines_key() -> None: """EMPTY_DUMP_RESULT содержит nspd_red_lines: [].""" assert "nspd_red_lines" in EMPTY_DUMP_RESULT assert EMPTY_DUMP_RESULT["nspd_red_lines"] == [] def test_make_empty_result_has_tier4_keys() -> None: """make_empty_result() возвращает nspd_opportunity_parcels и nspd_red_lines.""" result = make_empty_result() assert "nspd_opportunity_parcels" in result assert result["nspd_opportunity_parcels"] == [] assert "nspd_red_lines" in result assert result["nspd_red_lines"] == [] # ── Issue #234: harvest_eta_seconds + SETNX dedupe ──────────────────────────── def test_empty_dump_result_has_harvest_eta_seconds_key() -> None: """EMPTY_DUMP_RESULT.nspd_dump содержит harvest_eta_seconds (issue #234).""" assert "harvest_eta_seconds" in EMPTY_DUMP_RESULT["nspd_dump"] assert EMPTY_DUMP_RESULT["nspd_dump"]["harvest_eta_seconds"] is None def test_make_empty_result_passes_harvest_eta_seconds() -> None: """make_empty_result(harvest_eta_seconds=60) → пробрасывает в nspd_dump.""" result = make_empty_result(harvest_triggered=True, harvest_eta_seconds=60) assert result["nspd_dump"]["harvest_eta_seconds"] == 60 assert result["nspd_dump"]["harvest_triggered"] is True def test_make_empty_result_default_eta_is_none() -> None: """Если harvest_eta_seconds не передан — поле None (для available=False случая).""" result = make_empty_result() assert result["nspd_dump"]["harvest_eta_seconds"] is None def test_acquire_harvest_lock_succeeds_first_call(monkeypatch: pytest.MonkeyPatch) -> None: """SETNX lock: первый вызов получает lock (True), второй — нет (False). Issue #234: burst N concurrent analyze на один свежетриггеренный квартал дедуплицируется через Redis SETNX. Мокаем redis.Redis.from_url. """ from app.services.site_finder import quarter_dump_lookup as qdl # Mock redis: первый client.set возвращает True, второй — None (key existed). calls = {"n": 0} class _MockRedis: def set(self, key: str, value: str, *, nx: bool, ex: int) -> bool | None: calls["n"] += 1 return True if calls["n"] == 1 else None monkeypatch.setattr( "redis.Redis.from_url", lambda _url: _MockRedis(), ) assert qdl._acquire_harvest_lock("66:41:0204016") is True assert qdl._acquire_harvest_lock("66:41:0204016") is False assert calls["n"] == 2 def test_acquire_harvest_lock_redis_unavailable_returns_false( monkeypatch: pytest.MonkeyPatch, ) -> None: """Redis недоступен → _acquire_harvest_lock возвращает False (graceful). Лучше не запустить дубль, чем нагрузить WAF при transient redis-fail. """ from app.services.site_finder import quarter_dump_lookup as qdl def _boom(_url: str) -> None: raise ConnectionError("redis down") monkeypatch.setattr("redis.Redis.from_url", _boom) assert qdl._acquire_harvest_lock("66:41:0204016") is False # ── derive_quarter_cad (smoke tests) ────────────────────────────────────────── @pytest.mark.parametrize( "cad,expected", [ ("66:41:0204016", "66:41:0204016"), ("66:41:0204016:10", "66:41:0204016"), ("66:41:0204016:10:1", "66:41:0204016"), ("invalid", None), ("66:41", None), ], ) def test_derive_quarter_cad(cad: str, expected: str | None) -> None: assert derive_quarter_cad(cad) == expected # ── #243: cad_zouit fallback when dump absent ───────────────────────────────── def _make_mock_db_no_dump_with_cad_zouit( cad_zouit_rows: list[tuple[Any, ...]], ) -> MagicMock: """Мок Session: nspd_quarter_dumps.first() → None; cad_zouit.fetchall() → rows.""" db = MagicMock() first_result = MagicMock() first_result.first.return_value = None # нет дампа fetchall_result = MagicMock() fetchall_result.fetchall.return_value = cad_zouit_rows # Первый вызов execute (SELECT nspd_quarter_dumps) → first()=None # Второй вызов execute (SELECT cad_zouit) → fetchall()=rows db.execute.side_effect = [first_result, fetchall_result] return db def test_cad_zouit_fallback_fires_when_no_dump() -> None: """#243: cad_zouit fallback срабатывает когда nspd_quarter_dumps пуст глобально. Ожидаем: nspd_zouit_overlaps непустой, source='cad_zouit'. """ cad_zouit_rows: list[tuple[Any, ...]] = [ # (type_zone, category_name, name_by_doc, reg_numb_border, id, geom_geojson, coverage_pct) ( "Охранная зона трубопровода", "Трубопровод", "ГВС ул. Ленина", "66-66-00/123", 42, '{"type":"Polygon","coordinates":[[[60,56],[61,56],[61,57],[60,56]]]}', 0.15, ), ] db = _make_mock_db_no_dump_with_cad_zouit(cad_zouit_rows) wkt = "POLYGON((60 56,61 56,61 57,60 57,60 56))" result = get_quarter_dump_data(db, "66:41:0603016:194", wkt) overlaps: list[dict[str, Any]] = result["nspd_zouit_overlaps"] assert len(overlaps) == 1 assert overlaps[0]["source"] == "cad_zouit" assert overlaps[0]["type_zone"] == "Охранная зона трубопровода" assert overlaps[0]["name"] == "ГВС ул. Ленина" assert overlaps[0]["coverage_pct"] == 0.15 # dump.available остаётся False (нет дампа) assert result["nspd_dump"]["available"] is False def test_cad_zouit_fallback_no_dump_no_parcel_wkt() -> None: """#243: dump=None + parcel_wkt=None → overlaps пустой, не вызываем cad_zouit.""" db = MagicMock() first_result = MagicMock() first_result.first.return_value = None db.execute.return_value = first_result result = get_quarter_dump_data(db, "66:41:0603016:194", None) assert result["nspd_zouit_overlaps"] == [] # cad_zouit SELECT не должен вызываться (только один execute для dump lookup) assert db.execute.call_count == 1 def test_cad_zouit_fallback_empty_when_no_overlaps() -> None: """#243: dump=None + cad_zouit возвращает 0 строк → nspd_zouit_overlaps пуст.""" db = _make_mock_db_no_dump_with_cad_zouit([]) wkt = "POLYGON((60 56,61 56,61 57,60 57,60 56))" result = get_quarter_dump_data(db, "66:41:0603016:194", wkt) assert result["nspd_zouit_overlaps"] == [] def test_get_cad_zouit_overlaps_returns_correct_schema() -> None: """_get_cad_zouit_overlaps: поля group_key/layer/source/type_zone корректны.""" rows: list[tuple[Any, ...]] = [ # (type_zone, category_name, name_by_doc, reg_numb_border, id, geom_geojson, coverage_pct) ( "Охранная зона ЛЭП 110кВ", "Электроснабжение", "ВЛ-110", "66-123", 7, '{"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,0]]]}', 0.42, ), ("СЗЗ промышленного объекта", "Санитарная", "Завод", "66-456", 8, None, None), ] db = _make_mock_db_with_rows(rows) result = _get_cad_zouit_overlaps(db, "POLYGON((0 0,1 0,1 1,0 0))") assert len(result) == 2 lep = result[0] assert lep["group_key"] == "cad_zouit" assert lep["source"] == "cad_zouit" assert lep["type_zone"] == "Охранная зона ЛЭП 110кВ" assert lep["subcategory"] is None # всегда NULL в cad_zouit assert lep["name"] == "ВЛ-110" assert lep["raw_props"]["zouit_id"] == 7 # area-gate: coverage_pct проброшен (0..1) и нормализован assert lep["coverage_pct"] == 0.42 # #255: geom_geojson проброшен для ZouitLayer (string из ST_AsGeoJSON) assert "geom_geojson" in lep assert lep["geom_geojson"].startswith('{"type":"Polygon"') assert result[1]["geom_geojson"] is None # NULL geom не валит маппинг assert result[1]["coverage_pct"] is None # NULL coverage (делитель 0) → None def test_nspd_path_used_when_zouit_count_nonzero() -> None: """#243 backward compat: когда dump существует с zouit_count>0 — NSPD path, не cad_zouit. Мок: row.zouit_count=3, fetchall возвращает NSPD features. Проверяем source='nspd-quarter-dump' (не cad_zouit). """ # Строка дампа: все поля через MagicMock from datetime import UTC, datetime dump_row = MagicMock() dump_row.__getitem__ = lambda self, i: [ "66:41:0603016", # quarter_cad datetime(2026, 1, 1, tzinfo=UTC), # fetched_at_utc (свежий) 100, # total_features None, # harvest_error 1, # territorial_zones_count 3, # zouit_count > 0 → NSPD path 0, # engineering_count 0, # risks_count 0, # opportunity_count 0, # red_lines_count ][i] nspd_zouit_rows: list[tuple[Any, ...]] = [ # (layer, props, coverage_pct) ("zouit_engineering", {"name": "ЛЭП", "subcategory": 17}, 0.33), ] db = MagicMock() first_result = MagicMock() first_result.first.return_value = dump_row # Последующие вызовы execute (spatial queries) — возвращаем пустые кроме zouit zouit_result = MagicMock() zouit_result.fetchall.return_value = nspd_zouit_rows empty_result = MagicMock() empty_result.fetchall.return_value = [] empty_result.first.return_value = None # [0]=dumps lookup, [1]=zoning(first), [2]=zouit(fetchall), [3+]=others db.execute.side_effect = [ first_result, empty_result, # _get_zoning first() zouit_result, # _get_zouit_overlaps fetchall() empty_result, # _get_engineering_nearby fetchall() empty_result, # _get_risk_zones fetchall() empty_result, # _get_opportunity_parcels fetchall() empty_result, # _get_red_lines fetchall() ] result = get_quarter_dump_data( db, "66:41:0603016:194", "POLYGON((60 56,61 56,61 57,60 57,60 56))" ) overlaps: list[dict[str, Any]] = result["nspd_zouit_overlaps"] assert len(overlaps) == 1 assert overlaps[0]["source"] == "nspd-quarter-dump" # area-gate: coverage_pct проброшен из SQL (r[2]) и нормализован assert overlaps[0]["coverage_pct"] == 0.33 # dump.available=True т.к. свежий дамп assert result["nspd_dump"]["available"] is True