"""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 """ 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 ( derive_quarter_cad, get_quarter_dump_data, ) # ── 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) db = _make_db_mock(row=("66:41:0204016", old_date, 50, 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", 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")) 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)) 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 с разными результатами для разных запросов. Порядок вызовов: 1. read dump row 2. zoning (first()) 3. zouit (fetchall()) 4. engineering (fetchall()) """ 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.""" fresh_date = datetime.now(UTC) - timedelta(days=30) dump_row = ("66:41:0204016", fresh_date, 100, None) # zone_props JSONB в виде dict (SQLAlchemy вернёт dict) zone_props: dict[str, Any] = { "reg_numb_border": "Ж-3", "type_zone": "Зона жилой застройки средней этажности", } 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 assert result["nspd_zoning"]["zone_code"] == "Ж-3" assert result["nspd_zoning"]["zone_name"] == "Зона жилой застройки средней этажности" assert result["nspd_zoning"]["source"] == "nspd-quarter-dump" 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) 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) dump_row = ("66:41:0204016", fresh_date, 200, None) 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) 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