"""Unit tests для quarter_dump_lookup helper (Sprint 1.1 item #4). Mock-based — без реальной БД и Celery. Проверяет: - derive_quarter_cad: 3/4/5-сегмент → правильный quarter - get_quarter_dump_data: отсутствующий дамп → triggers harvest, available=False - get_quarter_dump_data: устаревший дамп → stale=True, triggers harvest - get_quarter_dump_data: свежий дамп с territorial_zones → nspd_zoning populated - get_quarter_dump_data: свежий дамп с 2 zouit → nspd_zouit_overlaps length=2 - get_quarter_dump_data: parcel_wkt=None → metadata only, no spatial queries - make_empty_result: mutation-safety — каждый вызов возвращает независимый dict - early-exit: layer_counts == 0 → spatial query не вызывается """ from __future__ import annotations from datetime import UTC, datetime, timedelta from typing import Any from unittest.mock import MagicMock, patch from app.services.site_finder.quarter_dump_lookup import ( EMPTY_DUMP_RESULT, _get_cad_zouit_overlaps, derive_quarter_cad, get_quarter_dump_data, make_empty_result, ) # ── derive_quarter_cad ──────────────────────────────────────────────────────── def test_derive_quarter_cad_3seg() -> None: """3-сегментный номер — квартал сам себе.""" assert derive_quarter_cad("66:41:0204016") == "66:41:0204016" def test_derive_quarter_cad_4seg() -> None: """4-сегментный участок → 3-сегментный квартал.""" assert derive_quarter_cad("66:41:0204016:10") == "66:41:0204016" def test_derive_quarter_cad_5seg() -> None: """5-сегментное здание → 3-сегментный квартал.""" assert derive_quarter_cad("66:41:0204016:10:1") == "66:41:0204016" def test_derive_quarter_cad_invalid() -> None: """Невалидный формат → None.""" assert derive_quarter_cad("66:41") is None assert derive_quarter_cad("abc:def:ghi") is None assert derive_quarter_cad("") is None def test_derive_quarter_cad_strips_whitespace() -> None: """Пробелы обрезаются.""" assert derive_quarter_cad(" 66:41:0204016 ") == "66:41:0204016" # ── get_quarter_dump_data — no row ──────────────────────────────────────────── def _make_db_mock(row: Any = None) -> MagicMock: """Создаёт mock Session.execute().first() с заданным результатом.""" db = MagicMock() execute_result = MagicMock() execute_result.first.return_value = row db.execute.return_value = execute_result return db def test_get_quarter_dump_no_row() -> None: """Дамп отсутствует в БД → harvest triggered, available=False.""" db = _make_db_mock(row=None) with patch( "app.services.site_finder.quarter_dump_lookup._trigger_harvest", return_value=True, ) as mock_trigger: result = get_quarter_dump_data(db, "66:41:0204016:10", parcel_wkt="POLYGON ((...))") mock_trigger.assert_called_once_with("66:41:0204016") assert result["nspd_dump"]["available"] is False assert result["nspd_dump"]["harvest_triggered"] is True assert result["nspd_dump"]["stale"] is False assert result["nspd_zoning"] is None assert result["nspd_zouit_overlaps"] == [] assert result["nspd_engineering_nearby"] == [] # ── get_quarter_dump_data — stale ───────────────────────────────────────────── def test_get_quarter_dump_stale() -> None: """Дамп старше 180 дней → stale=True, harvest triggered, available=False.""" old_date = datetime.now(UTC) - timedelta(days=200) # row: (quarter_cad, fetched_at_utc, total_features, harvest_error, # territorial_zones_count, zouit_count, engineering_count, # risks_count, opportunity_count, red_lines_count) db = _make_db_mock(row=("66:41:0204016", old_date, 50, None, 5, 2, 1, 0, 0, 0)) with patch( "app.services.site_finder.quarter_dump_lookup._trigger_harvest", return_value=True, ) as mock_trigger: result = get_quarter_dump_data(db, "66:41:0204016", parcel_wkt="POLYGON ((...))") mock_trigger.assert_called_once_with("66:41:0204016") assert result["nspd_dump"]["available"] is False assert result["nspd_dump"]["stale"] is True assert result["nspd_dump"]["harvest_triggered"] is True assert result["nspd_dump"]["total_features"] == 50 def test_get_quarter_dump_with_harvest_error() -> None: """Дамп с harvest_error → harvest_triggered=True, available=False.""" fresh_date = datetime.now(UTC) - timedelta(days=10) db = _make_db_mock(row=("66:41:0204016", fresh_date, 0, "WAF 429", 0, 0, 0, 0, 0, 0)) with patch( "app.services.site_finder.quarter_dump_lookup._trigger_harvest", return_value=True, ) as mock_trigger: result = get_quarter_dump_data(db, "66:41:0204016", parcel_wkt=None) mock_trigger.assert_called_once_with("66:41:0204016") assert result["nspd_dump"]["available"] is False assert result["nspd_dump"]["harvest_triggered"] is True # ── get_quarter_dump_data — no parcel_wkt ───────────────────────────────────── def test_get_quarter_dump_no_parcel_geometry() -> None: """parcel_wkt=None → metadata only, нет spatial queries.""" fresh_date = datetime.now(UTC) - timedelta(days=5) db = _make_db_mock(row=("66:41:0204016", fresh_date, 120, None, 10, 3, 2, 0, 0, 0)) result = get_quarter_dump_data(db, "66:41:0204016", parcel_wkt=None) # execute вызывался только один раз (для чтения строки дампа) assert db.execute.call_count == 1 assert result["nspd_dump"]["available"] is True assert result["nspd_dump"]["stale"] is False assert result["nspd_zoning"] is None assert result["nspd_zouit_overlaps"] == [] assert result["nspd_engineering_nearby"] == [] # ── get_quarter_dump_data — fresh dump with zoning ─────────────────────────── def _make_db_mock_with_spatial( dump_row: Any, zoning_row: Any = None, zouit_rows: list[Any] | None = None, engineering_rows: list[Any] | None = None, ) -> MagicMock: """Mock DB с разными результатами для разных запросов. Порядок вызовов db.execute жёстко привязан к реализации get_quarter_dump_data: 0. dump lookup → .first() 1. zoning → .first() (пропускается если territorial_zones_count == 0) 2. zouit → .fetchall() (пропускается если zouit_count == 0) 3. engineering → .fetchall() (пропускается если engineering_count == 0) Хрупко при реорганизации порядка запросов в production-коде. При изменении порядка в get_quarter_dump_data — обновить индексы call_count здесь. dump_row должен быть 10-элементным tuple: (quarter_cad, fetched_at_utc, total_features, harvest_error, territorial_zones_count, zouit_count, engineering_count, risks_count, opportunity_count, red_lines_count). """ db = MagicMock() call_count = 0 def side_effect(*args: Any, **kwargs: Any) -> MagicMock: nonlocal call_count result_mock = MagicMock() if call_count == 0: # dump lookup result_mock.first.return_value = dump_row elif call_count == 1: # zoning result_mock.first.return_value = zoning_row elif call_count == 2: # zouit result_mock.fetchall.return_value = zouit_rows or [] elif call_count == 3: # engineering result_mock.fetchall.return_value = engineering_rows or [] call_count += 1 return result_mock db.execute.side_effect = side_effect return db def test_get_quarter_dump_fresh_zoning() -> None: """Свежий дамп с territorial_zone → nspd_zoning populated. Использует РЕАЛЬНУЮ форму props (verified на prod): зона несёт только рег-номер границы, продублированный в descr/label/externalKey/options. """ fresh_date = datetime.now(UTC) - timedelta(days=30) dump_row = ("66:41:0204016", fresh_date, 100, None, 5, 0, 0, 0, 0, 0) # zone_props JSONB в виде dict (SQLAlchemy вернёт dict) — реальная форма НСПД. zone_props: dict[str, Any] = { "descr": "66:41-7.14", "label": "66:41-7.14", "options": {"reg_numb_border": "66:41-7.14"}, "category": 472819, "externalKey": "66:41-7.14", "subcategory": 1, } zoning_row_mock = MagicMock() zoning_row_mock.__getitem__ = lambda self, i: zone_props if i == 0 else None db = _make_db_mock_with_spatial( dump_row=dump_row, zoning_row=zoning_row_mock, zouit_rows=[], engineering_rows=[], ) wkt = "POLYGON ((60.6 56.8, 60.7 56.8, 60.7 56.9, 60.6 56.9, 60.6 56.8))" result = get_quarter_dump_data(db, "66:41:0204016", parcel_wkt=wkt) assert result["nspd_dump"]["available"] is True assert result["nspd_zoning"] is not None # Регресс #fix nspd-zoning-zone-code-keys: оба поля больше не None. assert result["nspd_zoning"]["zone_code"] == "66:41-7.14" assert result["nspd_zoning"]["zone_name"] == "66:41-7.14" assert result["nspd_zoning"]["source"] == "nspd-quarter-dump" # raw_props сохраняет полный объект для дебага. assert result["nspd_zoning"]["raw_props"]["options"]["reg_numb_border"] == "66:41-7.14" def test_get_quarter_dump_zoning_reads_real_nspd_keys() -> None: """Регресс: zone_code/zone_name резолвятся из РЕАЛЬНЫХ ключей дампа. До фикла _get_zoning читал несуществующие top-level reg_numb_border / zone_code / type_zone / name → оба поля были None на КАЖДОМ участке. Здесь проверяем три варианта формы и nested-fallback на options. """ fresh_date = datetime.now(UTC) - timedelta(days=10) def run(zone_props: dict[str, Any]) -> dict[str, Any] | None: dump_row = ("66:41:0702048", fresh_date, 80, None, 2, 0, 0, 0, 0, 0) zoning_row_mock = MagicMock() zoning_row_mock.__getitem__ = lambda self, i: zone_props if i == 0 else None db = _make_db_mock_with_spatial( dump_row=dump_row, zoning_row=zoning_row_mock, zouit_rows=[], engineering_rows=[], ) result = get_quarter_dump_data( db, "66:41:0702048", parcel_wkt="POLYGON ((60.6 56.8, 60.7 56.8, 60.7 56.9, 60.6 56.8))" ) return result["nspd_zoning"] # 1. Полная реальная форма — берём top-level descr. full = run( { "descr": "66:41-7.19", "label": "66:41-7.19", "options": {"reg_numb_border": "66:41-7.19"}, "externalKey": "66:41-7.19", "subcategory": 3, } ) assert full is not None assert full["zone_code"] == "66:41-7.19" assert full["zone_name"] == "66:41-7.19" # 2. Top-level descr/label отсутствуют — narrow safely на options.reg_numb_border. nested_only = run({"options": {"reg_numb_border": "66:01-7.114"}, "category": 472819}) assert nested_only is not None assert nested_only["zone_code"] == "66:01-7.114" assert nested_only["zone_name"] == "66:01-7.114" # 3. options не-dict / отсутствует и нет descr — graceful, не KeyError, zone_code=None. empty = run({"category": 472819, "subcategory": 6}) assert empty is not None assert empty["zone_code"] is None assert empty["zone_name"] is None def test_get_quarter_dump_fresh_zoning_no_match() -> None: """Свежий дамп, centroid вне всех territorial_zones → nspd_zoning=None.""" fresh_date = datetime.now(UTC) - timedelta(days=30) dump_row = ("66:41:0204016", fresh_date, 100, None, 3, 0, 0, 0, 0, 0) db = _make_db_mock_with_spatial( dump_row=dump_row, zoning_row=None, zouit_rows=[], engineering_rows=[], ) result = get_quarter_dump_data(db, "66:41:0204016", parcel_wkt="POLYGON ((...))") assert result["nspd_zoning"] is None assert result["nspd_dump"]["available"] is True # ── get_quarter_dump_data — fresh dump with zouit overlaps ──────────────────── def test_get_quarter_dump_fresh_zouit_overlaps() -> None: """Свежий дамп с 2 zouit → nspd_zouit_overlaps length=2.""" fresh_date = datetime.now(UTC) - timedelta(days=10) # territorial_zones_count > 0 чтобы все 3 spatial запроса вызывались # (иначе call_count в mock смещается и zouit попадает не в тот слот) dump_row = ("66:41:0204016", fresh_date, 200, None, 1, 5, 0, 0, 0, 0) props1: dict[str, Any] = {"name": "ОКН Дом купца", "subcategory": "okn"} props2: dict[str, Any] = {"name": "ОЗ газопровода", "subcategory": "engineering"} def make_row(layer: str, props: dict[str, Any]) -> MagicMock: r = MagicMock() r.__getitem__ = lambda self, i: layer if i == 0 else props return r zouit_rows = [make_row("zouit_okn", props1), make_row("zouit_engineering", props2)] db = _make_db_mock_with_spatial( dump_row=dump_row, zoning_row=None, zouit_rows=zouit_rows, engineering_rows=[], ) result = get_quarter_dump_data(db, "66:41:0204016", parcel_wkt="POLYGON ((...))") assert len(result["nspd_zouit_overlaps"]) == 2 keys = {r["group_key"] for r in result["nspd_zouit_overlaps"]} assert "okn" in keys assert "engineering" in keys # ── get_quarter_dump_data — integration of nspd_dump in response ────────────── def test_get_quarter_dump_includes_nspd_fields() -> None: """Базовая проверка что все 4 nspd-ключа присутствуют в возвращаемом dict.""" fresh_date = datetime.now(UTC) - timedelta(days=1) dump_row = ("66:41:0204016", fresh_date, 55, None, 2, 1, 0, 0, 0, 0) db = _make_db_mock_with_spatial( dump_row=dump_row, zoning_row=None, zouit_rows=[], engineering_rows=[], ) result = get_quarter_dump_data(db, "66:41:0204016:5", parcel_wkt="POINT (60.6 56.8)") assert "nspd_zoning" in result assert "nspd_zouit_overlaps" in result assert "nspd_engineering_nearby" in result assert "nspd_dump" in result dump = result["nspd_dump"] assert "available" in dump assert "fetched_at_utc" in dump assert "stale" in dump assert "harvest_triggered" in dump assert "total_features" in dump # ── _get_cad_zouit_overlaps — geom_geojson field (#255) ────────────────────── def _make_zouit_row( type_zone: str, category_name: str, name: str, reg_numb_border: str | None, zouit_id: int, geom_geojson: str | None, coverage_pct: float | None = None, ) -> tuple[Any, ...]: """Строка cad_zouit в порядке SELECT _get_cad_zouit_overlaps. (type_zone, category_name, name_by_doc, reg_numb_border, id, geom_geojson, coverage_pct) — coverage_pct добавлен area-gate'ом (доля участка под ЗОУИТ). """ return ( type_zone, category_name, name, reg_numb_border, zouit_id, geom_geojson, coverage_pct, ) def test_cad_zouit_overlaps_includes_geom_geojson() -> None: """#255: каждый overlap содержит geom_geojson (string из ST_AsGeoJSON).""" geojson_str = ( '{"type":"Polygon","coordinates":' "[[[60.6,56.8],[60.7,56.8],[60.7,56.9],[60.6,56.8]]]}" ) rows = [ _make_zouit_row( "Охранная зона тепловой сети", "ЗОУИТ general", "Охранная зона тепловой сети с кад. № 66:41:0204016:57", "66:41:0204016:57", 123, geojson_str, ) ] db = MagicMock() execute_result = MagicMock() execute_result.fetchall.return_value = rows db.execute.return_value = execute_result result = _get_cad_zouit_overlaps(db, "POLYGON ((60.6 56.8, 60.7 56.8, 60.7 56.9, 60.6 56.8))") assert len(result) == 1 overlap = result[0] assert "geom_geojson" in overlap, "overlap должен нести geom_geojson для ZouitLayer" assert overlap["geom_geojson"] == geojson_str assert overlap["source"] == "cad_zouit" assert overlap["type_zone"] == "Охранная зона тепловой сети" assert overlap["raw_props"]["zouit_id"] == 123 def test_cad_zouit_overlaps_geom_geojson_nullable() -> None: """geom_geojson=None (битая/NULL geometry) не валит маппинг — поле = None.""" rows = [ _make_zouit_row("СЗЗ", "иные", "Санитарно-защитная зона", None, 7, None), ] db = MagicMock() execute_result = MagicMock() execute_result.fetchall.return_value = rows db.execute.return_value = execute_result result = _get_cad_zouit_overlaps(db, "POLYGON ((...))") assert len(result) == 1 assert result[0]["geom_geojson"] is None def test_cad_zouit_overlaps_query_failure_returns_empty() -> None: """Сбой запроса (например, отсутствие cad_zouit) → graceful [] (не пробрасываем).""" db = MagicMock() db.execute.side_effect = RuntimeError("relation cad_zouit does not exist") result = _get_cad_zouit_overlaps(db, "POLYGON ((...))") assert result == [] # ── make_empty_result — mutation safety ───────────────────────────────────── def testmake_empty_result_returns_independent_copies() -> None: """Каждый вызов make_empty_result() возвращает независимый dict. Мутация nspd_dump в одном результате не должна влиять на другой и на модульный sentinel EMPTY_DUMP_RESULT. """ a = make_empty_result() b = make_empty_result() # Вложенный dict — независимый объект assert a["nspd_dump"] is not b["nspd_dump"] assert a["nspd_dump"] is not EMPTY_DUMP_RESULT["nspd_dump"] # Мутируем один — другой не трогается a["nspd_dump"]["harvest_triggered"] = True a["nspd_zouit_overlaps"].append({"group_key": "test"}) assert b["nspd_dump"]["harvest_triggered"] is False assert b["nspd_zouit_overlaps"] == [] assert EMPTY_DUMP_RESULT["nspd_dump"]["harvest_triggered"] is False assert EMPTY_DUMP_RESULT["nspd_zouit_overlaps"] == [] def testmake_empty_result_overrides() -> None: """make_empty_result принимает override-аргументы.""" r = make_empty_result(harvest_triggered=True, stale=True, total_features=42) assert r["nspd_dump"]["harvest_triggered"] is True assert r["nspd_dump"]["stale"] is True assert r["nspd_dump"]["total_features"] == 42 assert r["nspd_dump"]["available"] is False # ── early-exit when layer_counts == 0 ──────────────────────────────────────── def test_early_exit_all_counts_zero_no_spatial_queries() -> None: """Если все denormalized counts == 0 — spatial queries не вызываются.""" fresh_date = datetime.now(UTC) - timedelta(days=5) # territorial_zones_count=0, zouit_count=0, engineering_count=0 dump_row = ("66:41:0204016", fresh_date, 50, None, 0, 0, 0, 0, 0, 0) db = _make_db_mock_with_spatial(dump_row=dump_row) result = get_quarter_dump_data( db, "66:41:0204016", parcel_wkt="POLYGON ((60.6 56.8, 60.7 56.8, 60.7 56.9, 60.6 56.8))" ) # 2 execute-вызова: чтение dump row (0) + cad_zouit fallback (#232) — при # zouit_count==0 _get_zouit_overlaps не early-exit'ит, а делает fallback на # cad_zouit. Остальные spatial helpers (zoning/engineering/risk/opportunity/ # red_lines) пропущены по своим нулевым счётчикам. assert db.execute.call_count == 2 assert result["nspd_zoning"] is None assert result["nspd_zouit_overlaps"] == [] assert result["nspd_engineering_nearby"] == [] assert result["nspd_dump"]["available"] is True def test_early_exit_partial_counts() -> None: """territorial_zones_count > 0, zouit_count == 0 → zoning query вызывается, zouit и engineering — нет.""" fresh_date = datetime.now(UTC) - timedelta(days=5) dump_row = ("66:41:0204016", fresh_date, 30, None, 3, 0, 0, 0, 0, 0) # Реальная форма props НСПД (см. test_get_quarter_dump_zoning_reads_real_nspd_keys). zone_props: dict[str, Any] = { "descr": "66:41-7.2", "label": "66:41-7.2", "options": {"reg_numb_border": "66:41-7.2"}, } zoning_row_mock = MagicMock() zoning_row_mock.__getitem__ = lambda self, i: zone_props if i == 0 else None db = _make_db_mock_with_spatial( dump_row=dump_row, zoning_row=zoning_row_mock, zouit_rows=[], engineering_rows=[], ) result = get_quarter_dump_data( db, "66:41:0204016", parcel_wkt="POLYGON ((60.6 56.8, 60.7 56.8, 60.7 56.9, 60.6 56.8))" ) # dump lookup (0) + zoning (1) + cad_zouit fallback (2) = 3 вызова; # engineering/risk/opportunity/red_lines — пропущены по нулевым счётчикам. # zouit_count==0 → _get_zouit_overlaps делает fallback на cad_zouit (#232), # который возвращает [] (mock fetchall пуст). assert db.execute.call_count == 3 assert result["nspd_zoning"] is not None assert result["nspd_zoning"]["zone_code"] == "66:41-7.2" assert result["nspd_zouit_overlaps"] == [] assert result["nspd_engineering_nearby"] == []