"""Тесты для bulk_harvest.py и grid_geometry.py. Использует mock DB (через unittest.mock) и mock NSPDBulkClient. Не требует реального PostgreSQL или NSPD сети. """ from __future__ import annotations from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest from app.schemas.nspd_bulk import NSPDBulkFeature, QuarterSnapshot from app.services.cadastre.bulk_harvest import ( CAT_ENK, CAT_QUARTER_STATS, CAT_ZOUIT, _derive_quarter_cad, _safe_int, _safe_numeric, upsert_features, upsert_quarter_geom_from_feature, ) from app.services.cadastre.grid_geometry import ( generate_grid_click_points, quarter_bbox_3857, ) # ── Фикстуры ───────────────────────────────────────────────────────────────── def _make_parcel_feature(cad_num: str = "66:41:0303161:1") -> NSPDBulkFeature: """Создать NSPDBulkFeature для ЗУ (category_id=36368).""" return NSPDBulkFeature.model_validate( { "id": 1, "type": "Feature", "geometry": { "type": "Polygon", "coordinates": [ [ [6090000, 7590000], [6090100, 7590000], [6090100, 7590100], [6090000, 7590100], [6090000, 7590000], ] ], }, "properties": { "category": 36368, "options": { "cad_num": cad_num, "quarter_cad_number": "66:41:0303161", "land_record_area": 1234.56, "cost_value": 5000000.0, "readable_address": "г Екатеринбург, ул Тестовая", "status": "Учтённый", "ownership_type": "частная", }, }, } ) def _make_building_feature(cad_num: str = "66:41:0303161:1:1") -> NSPDBulkFeature: """Создать NSPDBulkFeature для здания (category_id=36369).""" return NSPDBulkFeature.model_validate( { "id": 2, "type": "Feature", "geometry": { "type": "Polygon", "coordinates": [ [ [6090000, 7590000], [6090050, 7590000], [6090050, 7590050], [6090000, 7590050], [6090000, 7590000], ] ], }, "properties": { "category": 36369, "options": { "cad_num": cad_num, "quarter_cad_number": "66:41:0303161", "purpose": "Жилое", "floors": "9", "area": 5000.0, "cost_value": 20000000.0, "readable_address": "г Екатеринбург, ул Тестовая, д 1", }, }, } ) def _make_zouit_feature(reg_numb: str = "66-0.1-1.1-1234") -> NSPDBulkFeature: """Создать NSPDBulkFeature для ЗОУИТ (category_id=36940).""" return NSPDBulkFeature.model_validate( { "id": 3, "type": "Feature", "geometry": { "type": "MultiPolygon", "coordinates": [ [ [ [6090000, 7590000], [6091000, 7590000], [6091000, 7591000], [6090000, 7591000], [6090000, 7590000], ] ] ], }, "properties": { "category": 36940, "categoryName": "ЗОУИТ", "options": { "reg_numb_border": reg_numb, "type_zone": "Охранная зона ЛЭП", "registration_date": "2020-01-01", }, }, } ) # ── Вспомогательные функции ─────────────────────────────────────────────────── def test_derive_quarter_cad_4_segments() -> None: assert _derive_quarter_cad("66:41:0303161:1") == "66:41:0303161" def test_derive_quarter_cad_5_segments() -> None: assert _derive_quarter_cad("66:41:0303161:1:1") == "66:41:0303161" def test_derive_quarter_cad_3_segments_unchanged() -> None: assert _derive_quarter_cad("66:41:0303161") == "66:41:0303161" def test_safe_numeric_valid() -> None: assert _safe_numeric("1234.56") == pytest.approx(1234.56) assert _safe_numeric(100) == pytest.approx(100.0) def test_safe_numeric_none_returns_none() -> None: assert _safe_numeric(None) is None def test_safe_numeric_invalid_returns_none() -> None: assert _safe_numeric("не число") is None def test_safe_int_valid() -> None: assert _safe_int("9") == 9 assert _safe_int(5.7) == 5 def test_safe_int_none_returns_none() -> None: assert _safe_int(None) is None def test_parse_nspd_date_handles_dd_mm_yyyy() -> None: """NSPD Сооружения/ОНС/ЕНК отдают даты в DD.MM.YYYY → конвертируем в ISO.""" from app.services.cadastre.bulk_harvest import _parse_nspd_date assert _parse_nspd_date("13.03.2023") == "2023-03-13" assert _parse_nspd_date("24.02.2022") == "2022-02-24" def test_parse_nspd_date_passes_through_iso() -> None: """ISO формат (YYYY-MM-DD) уже валидный — pass через.""" from app.services.cadastre.bulk_harvest import _parse_nspd_date assert _parse_nspd_date("2023-03-13") == "2023-03-13" assert _parse_nspd_date("2010-08-12 00:00:00") == "2010-08-12" def test_parse_nspd_date_none_and_invalid() -> None: """None / empty string / garbage → None.""" from app.services.cadastre.bulk_harvest import _parse_nspd_date assert _parse_nspd_date(None) is None assert _parse_nspd_date("") is None assert _parse_nspd_date("nonsense") is None assert _parse_nspd_date(123) is None # not str def test_parse_nspd_date_validates_month_day_ranges() -> None: """Invalid month/day (13.03 / 32.13 / etc) → None — не пропускать в SQL.""" from app.services.cadastre.bulk_harvest import _parse_nspd_date # Невалидный месяц (13) в ISO формате assert _parse_nspd_date("2023-13-45") is None # Невалидный день в DD.MM.YYYY assert _parse_nspd_date("32.13.2023") is None # Невалидный month assert _parse_nspd_date("31.02.2023") is None # Feb 31 # ── Grid geometry ──────────────────────────────────────────────────────────── def test_generate_grid_click_points_count() -> None: """15×15 grid → 225 точек.""" bbox = (6090000.0, 7590000.0, 6090750.0, 7590750.0) points = generate_grid_click_points(bbox, grid_size=15) assert len(points) == 225 def test_generate_grid_click_points_bbox_coverage() -> None: """Все sub-bbox покрывают исходный bbox без перекрытий.""" bbox = (0.0, 0.0, 1500.0, 1500.0) points = generate_grid_click_points(bbox, grid_size=3) # 3×3 = 9 ячеек assert len(points) == 9 # Первая ячейка начинается с 0,0 first_bbox, first_click = points[0] assert first_bbox[0] == pytest.approx(0.0) assert first_bbox[1] == pytest.approx(0.0) # click_xy в центре тайла (256, 256) для tile_size=512 assert first_click == (256, 256) def test_generate_grid_click_points_custom_grid() -> None: bbox = (0.0, 0.0, 1000.0, 1000.0) points = generate_grid_click_points(bbox, grid_size=5, tile_size=256) assert len(points) == 25 # Каждая ячейка имеет правильный размер 200м×200м cell_bbox, _ = points[0] assert cell_bbox[2] - cell_bbox[0] == pytest.approx(200.0) assert cell_bbox[3] - cell_bbox[1] == pytest.approx(200.0) # ── quarter_bbox_3857 sanity check ─────────────────────────────────────────── def _mock_db_with_bbox( xmin: float | None, ymin: float | None, xmax: float | None, ymax: float | None ) -> MagicMock: """Mock SQLAlchemy session.execute().mappings().first() возвращает заданный bbox.""" mock_db = MagicMock() row = None if xmin is None else {"xmin": xmin, "ymin": ymin, "xmax": xmax, "ymax": ymax} mock_db.execute.return_value.mappings.return_value.first.return_value = row return mock_db def test_quarter_bbox_3857_healthy_geom() -> None: """Real ЕКБ-квартал ~750м — должен пройти sanity check.""" db = _mock_db_with_bbox(6735845.0, 8329000.0, 6736595.0, 8329750.0) bbox = quarter_bbox_3857(db, "66:41:0303161") assert bbox is not None assert bbox == (6735845.0, 8329000.0, 6736595.0, 8329750.0) def test_quarter_bbox_3857_skips_broken_micro_geom() -> None: """width < 100m — broken micro-precision geom (72% rows в cad_quarters_geom).""" # width=60.7m — реальный broken quarter 66:41:0101005 после ST_Transform db = _mock_db_with_bbox(60.0, 60.0, 120.7, 120.7) assert quarter_bbox_3857(db, "66:41:0101005") is None def test_quarter_bbox_3857_skips_oversized_geom() -> None: """width > 10000m — broken/regional geom (не городской квартал).""" db = _mock_db_with_bbox(0.0, 0.0, 15000.0, 15000.0) assert quarter_bbox_3857(db, "66:41:9999999") is None def test_quarter_bbox_3857_skips_zero_height() -> None: """height < 100m — симметрия по другой оси.""" db = _mock_db_with_bbox(0.0, 0.0, 500.0, 50.0) assert quarter_bbox_3857(db, "66:41:0000001") is None def test_quarter_bbox_3857_returns_none_when_quarter_missing() -> None: """Квартал не найден в cad_quarters_geom → None (xmin IS NULL).""" db = _mock_db_with_bbox(None, None, None, None) assert quarter_bbox_3857(db, "66:99:0000000") is None # ── upsert_features routing ────────────────────────────────────────────────── def test_upsert_features_routes_parcels_and_buildings() -> None: """upsert_features должен вызывать upsert_parcel и upsert_building.""" parcel = _make_parcel_feature() building = _make_building_feature() db = MagicMock() db.execute = MagicMock(return_value=MagicMock()) db.begin_nested = MagicMock(return_value=MagicMock(__enter__=MagicMock(), __exit__=MagicMock())) with ( patch("app.services.cadastre.bulk_harvest.upsert_parcel") as mock_parcel, patch("app.services.cadastre.bulk_harvest.upsert_building") as mock_building, ): counts = upsert_features(db, [parcel, building], source="search") mock_parcel.assert_called_once_with(db, parcel, source="search") mock_building.assert_called_once_with(db, building, source="search") assert counts["parcels"] == 1 assert counts["buildings"] == 1 def test_upsert_features_routes_zouit() -> None: """ЗОУИТ (36940) маршрутируется в upsert_zouit.""" zouit = _make_zouit_feature() db = MagicMock() with patch("app.services.cadastre.bulk_harvest.upsert_zouit") as mock_zouit: counts = upsert_features(db, [zouit], source="search") mock_zouit.assert_called_once() assert counts["zouit"] == 1 def test_upsert_features_unknown_category_counts_as_skipped() -> None: """Неизвестный category_id → skipped, не raise.""" unknown_feature = NSPDBulkFeature.model_validate( { "type": "Feature", "geometry": None, "properties": {"category": 99999, "options": {}}, } ) db = MagicMock() counts = upsert_features(db, [unknown_feature], source="search") assert counts["skipped"] == 1 def test_upsert_features_quarter_stats_not_counted() -> None: """category_id=36381 (quarter stats) — не считается ни в одну таблицу.""" stats_feature = NSPDBulkFeature.model_validate( { "type": "Feature", "geometry": None, "properties": {"category": 36381, "options": {"cnt_land": 5}}, } ) db = MagicMock() counts = upsert_features(db, [stats_feature], source="search") # Все счётчики должны быть 0, skipped тоже (36381 — особый случай) assert counts["parcels"] == 0 assert counts["buildings"] == 0 assert counts["skipped"] == 0 # ── upsert_parcel SQL params ───────────────────────────────────────────────── def test_upsert_parcel_uses_cast_not_double_colon() -> None: """Проверить что SQL использует CAST(:x AS type) а не :x::type (SQLAlchemy ловушка). Используем inspect самого SQL через db.execute calls. """ from app.services.cadastre.bulk_harvest import upsert_parcel db = MagicMock() executed_sqls: list[str] = [] def capture_execute(stmt: Any, params: Any = None) -> MagicMock: executed_sqls.append(str(stmt)) return MagicMock() db.execute = capture_execute feature = _make_parcel_feature() upsert_parcel(db, feature, source="search") assert len(executed_sqls) == 1 sql = executed_sqls[0] # Проверяем что нет `:x::type` паттерна (только CAST(:x AS ...)) assert "::jsonb" not in sql, "SQL содержит ::jsonb — нужно CAST(:x AS jsonb)" assert "::date" not in sql, "SQL содержит ::date — нужно CAST(:x AS date)" assert "CAST(:raw_props AS jsonb)" in sql def test_upsert_parcel_sql_uses_st_multi_for_multipolygon_schema() -> None: """cad_parcels.geom = MULTIPOLYGON (migration 93) — SQL должен ST_Multi() оборачивать transform, чтобы Polygon coerce'ился в MultiPolygon (NSPD возвращает оба варианта). """ from app.services.cadastre.bulk_harvest import upsert_parcel db = MagicMock() executed_sqls: list[str] = [] db.execute = lambda stmt, params=None: (executed_sqls.append(str(stmt)) or MagicMock()) upsert_parcel(db, _make_parcel_feature(), source="search") assert len(executed_sqls) == 1 sql = executed_sqls[0] assert "ST_Multi(" in sql, "SQL должен содержать ST_Multi для MultiPolygon schema" # ── harvest_quarter integration (async, mocked) ─────────────────────────────── @pytest.mark.asyncio async def test_harvest_quarter_does_not_early_exit_on_shared_phase_done() -> None: """harvest_quarter НЕ должен бейлиться если другой worker записал phase=done в shared `cadastre_jobs.phase_state`. Раньше тут был early-exit, который вызывал 95% workers пропускать работу (race condition между parallel Celery tasks одного job). Idempotency обеспечивается ON CONFLICT DO UPDATE в upsert_*, поэтому даже если task re-enqueued после shared phase=done, work должна выполниться. """ from app.services.cadastre.bulk_harvest import harvest_quarter snapshot = QuarterSnapshot( quarter="66:41:0303161", fetched_at="2026-05-15T10:00:00+00:00", features=[], meta_counts={}, ) db = MagicMock() # Симулируем shared phase_state с phase=done от ДРУГОГО quarter db.execute = MagicMock( return_value=MagicMock( mappings=MagicMock( return_value=MagicMock( first=MagicMock( return_value={"phase_state": {"phase": "done", "quarter": "66:41:9999999"}} ) ) ) ) ) db.commit = MagicMock() client = AsyncMock() client.search_by_quarter = AsyncMock(return_value=snapshot) with patch("app.services.cadastre.bulk_harvest.upsert_features") as mock_upsert: mock_upsert.return_value = { "parcels": 0, "buildings": 0, "constructions": 0, "oncs": 0, "enks": 0, "zouit": 0, "skipped": 0, } result = await harvest_quarter( db=db, client=client, quarter="66:41:0303161", job_id=1, update_progress=lambda s: None, ) # search_by_quarter ДОЛЖЕН быть вызван несмотря на shared phase=done client.search_by_quarter.assert_awaited_once_with("66:41:0303161") assert result.snapshot_requests == 1 assert result.phase_state == {"phase": "done", "quarter": "66:41:0303161"} @pytest.mark.asyncio async def test_harvest_quarter_calls_upsert_features() -> None: """harvest_quarter Phase 1: search_by_quarter → upsert_features.""" from app.services.cadastre.bulk_harvest import harvest_quarter parcel = _make_parcel_feature() snapshot = QuarterSnapshot( quarter="66:41:0303161", fetched_at="2026-05-15T10:00:00+00:00", features=[parcel], meta_counts={36368: 5, 36369: 3}, # нет overflow ) db = MagicMock() # phase_state = None → начинаем с нуля db.execute = MagicMock( return_value=MagicMock( mappings=MagicMock( return_value=MagicMock(first=MagicMock(return_value={"phase_state": None})) ) ) ) db.commit = MagicMock() client = AsyncMock() client.search_by_quarter = AsyncMock(return_value=snapshot) progress_states: list[dict[str, Any]] = [] with patch("app.services.cadastre.bulk_harvest.upsert_features") as mock_upsert: mock_upsert.return_value = { "parcels": 1, "buildings": 0, "constructions": 0, "oncs": 0, "enks": 0, "zouit": 0, "skipped": 0, } result = await harvest_quarter( db=db, client=client, quarter="66:41:0303161", job_id=1, update_progress=lambda s: progress_states.append(s), ) client.search_by_quarter.assert_awaited_once_with("66:41:0303161") mock_upsert.assert_called() assert result.parcels_upserted == 1 # Phase 1 = 1 NSPD search call → snapshot_requests=1 assert result.snapshot_requests == 1 assert result.grid_walk_requests == 0 assert result.total_requests == 1 # Проверить что последний progress = done assert progress_states[-1]["phase"] == "done" def test_harvest_result_total_requests_property() -> None: """HarvestResult.total_requests = snapshot_requests + grid_walk_requests.""" from app.services.cadastre.bulk_harvest import HarvestResult r = HarvestResult(quarter="66:41:0303161", snapshot_requests=3, grid_walk_requests=225) assert r.total_requests == 228 empty = HarvestResult(quarter="66:41:0303161") assert empty.total_requests == 0 # ── Dedup тест для grid-walk ───────────────────────────────────────────────── @pytest.mark.asyncio async def test_grid_walk_dedup_same_cad_num() -> None: """Если wms_feature_info возвращает одинаковый cad_num дважды — 1 upsert.""" from app.services.cadastre.bulk_harvest import _grid_walk_category parcel = _make_parcel_feature("66:41:0303161:1") # Одинаковый feature для каждой grid-точки db = MagicMock() db.execute = MagicMock( return_value=MagicMock( mappings=MagicMock( return_value=MagicMock( first=MagicMock( return_value={ "xmin": 6090000.0, "ymin": 7590000.0, "xmax": 6090750.0, "ymax": 7590750.0, } ) ) ) ) ) db.begin_nested = MagicMock( return_value=MagicMock(__enter__=MagicMock(return_value=None), __exit__=MagicMock()) ) db.commit = MagicMock() client = AsyncMock() # Каждый wms_feature_info вызов возвращает один и тот же парцел client.wms_feature_info = AsyncMock(return_value=[parcel]) with patch("app.services.cadastre.bulk_harvest.quarter_bbox_3857") as mock_bbox: mock_bbox.return_value = (6090000.0, 7590000.0, 6090750.0, 7590750.0) with patch("app.services.cadastre.bulk_harvest.upsert_features") as mock_upsert: mock_upsert.return_value = { "parcels": 1, "buildings": 0, "constructions": 0, "oncs": 0, "enks": 0, "zouit": 0, "skipped": 0, } # Используем grid_size=2 для скорости теста (4 ячейки) upserted, requests = await _grid_walk_category( db=db, client=client, quarter="66:41:0303161", layer_id=36368, grid_size=2, ) # Несмотря на 4 запроса, upsert_features вызывается 1 раз (дедуп по cad_num) assert mock_upsert.call_count == 1 assert upserted == 1 assert requests == 4 # 2×2 grid # ── ЗОУИТ dedup по (reg_numb_border, category_id) ─────────────────────────── def test_zouit_dedup_different_category_ids() -> None: """Один reg_numb_border с разными category_id → 2 записи в upsert.""" zouit_1 = NSPDBulkFeature.model_validate( { "type": "Feature", "geometry": None, "properties": { "category": 36940, "categoryName": "ЗОУИТ 1", "options": {"reg_numb_border": "66-0.1-1.1-1234", "type_zone": "тип А"}, }, } ) zouit_2 = NSPDBulkFeature.model_validate( { "type": "Feature", "geometry": None, "properties": { "category": 469039, "categoryName": "ЗОУИТ 2", "options": {"reg_numb_border": "66-0.1-1.1-1234", "type_zone": "тип Б"}, }, } ) db = MagicMock() calls: list[Any] = [] db.execute = MagicMock( side_effect=lambda stmt, params=None: calls.append(params) or MagicMock() ) with patch("app.services.cadastre.bulk_harvest.upsert_zouit") as mock_zouit: counts = upsert_features(db, [zouit_1, zouit_2], source="search") # Обе записи маршрутируются в upsert_zouit assert mock_zouit.call_count == 2 assert counts["zouit"] == 2 # ── per-category snapshot (Phase 1.5) ──────────────────────────────────────── @pytest.mark.asyncio async def test_harvest_quarter_calls_per_cat_probe_for_zouit_when_meta_nonzero() -> None: """Phase 1.5: если в meta snapshot есть ЗОУИТ (36940), делаем per-cat probe.""" from app.services.cadastre.bulk_harvest import harvest_quarter zouit_cat = next(iter(CAT_ZOUIT)) # один из ЗОУИТ categoryId # Snapshot Phase 1: 1 парцел + meta с ЗОУИТ parcel = _make_parcel_feature() zouit_feature = _make_zouit_feature() base_snapshot = QuarterSnapshot( quarter="66:41:0303161", fetched_at="2026-05-15T10:00:00+00:00", features=[parcel], meta_counts={36368: 2, zouit_cat: 5}, # ЗОУИТ totalCount=5 > 0 ) # Per-category probe snapshot для ЗОУИТ cat_snapshot = QuarterSnapshot( quarter="66:41:0303161", fetched_at="2026-05-15T10:00:01+00:00", features=[zouit_feature], meta_counts={zouit_cat: 5}, ) db = MagicMock() db.execute = MagicMock( return_value=MagicMock( mappings=MagicMock( return_value=MagicMock(first=MagicMock(return_value={"phase_state": None})) ) ) ) db.commit = MagicMock() client = AsyncMock() # Первый вызов (без category_id) → базовый snapshot # Последующие — per-category probes client.search_by_quarter = AsyncMock(side_effect=[base_snapshot, cat_snapshot]) with patch("app.services.cadastre.bulk_harvest.upsert_features") as mock_upsert: mock_upsert.return_value = { "parcels": 0, "buildings": 0, "constructions": 0, "oncs": 0, "enks": 0, "zouit": 1, "skipped": 0, } result = await harvest_quarter( db=db, client=client, quarter="66:41:0303161", job_id=1, update_progress=lambda s: None, ) # search_by_quarter должен быть вызван минимум дважды: # 1) без category_id (базовый) # 2) с category_id=zouit_cat (per-cat probe) assert client.search_by_quarter.await_count >= 2 call_args_list = client.search_by_quarter.await_args_list # Первый вызов без category_id first_call_kwargs = call_args_list[0].kwargs assert first_call_kwargs.get("category_id") is None # Один из последующих вызовов с category_id ∈ CAT_ZOUIT per_cat_calls = [c for c in call_args_list[1:] if c.kwargs.get("category_id") in CAT_ZOUIT] assert len(per_cat_calls) >= 1, "Ожидался per-cat probe для ЗОУИТ" assert result.zouit_upserted >= 1 @pytest.mark.asyncio async def test_harvest_quarter_skips_per_cat_probe_when_meta_zero() -> None: """Phase 1.5: если meta не содержит ЗОУИТ/ЕНК — per-cat probe не делается.""" from app.services.cadastre.bulk_harvest import harvest_quarter parcel = _make_parcel_feature() # Snapshot без ЗОУИТ/ЕНК в meta (только ЗУ и Здания) base_snapshot = QuarterSnapshot( quarter="66:41:0303161", fetched_at="2026-05-15T10:00:00+00:00", features=[parcel], meta_counts={36368: 2, 36369: 3}, # нет ЗОУИТ / ЕНК ) db = MagicMock() db.execute = MagicMock( return_value=MagicMock( mappings=MagicMock( return_value=MagicMock(first=MagicMock(return_value={"phase_state": None})) ) ) ) db.commit = MagicMock() client = AsyncMock() client.search_by_quarter = AsyncMock(return_value=base_snapshot) with patch("app.services.cadastre.bulk_harvest.upsert_features") as mock_upsert: mock_upsert.return_value = { "parcels": 1, "buildings": 0, "constructions": 0, "oncs": 0, "enks": 0, "zouit": 0, "skipped": 0, } await harvest_quarter( db=db, client=client, quarter="66:41:0303161", job_id=1, update_progress=lambda s: None, ) # Только один вызов search_by_quarter (базовый), никаких per-cat probe assert client.search_by_quarter.await_count == 1 call_kwargs = client.search_by_quarter.await_args_list[0].kwargs assert call_kwargs.get("category_id") is None @pytest.mark.asyncio async def test_harvest_quarter_per_cat_probe_enk_called_when_meta_nonzero() -> None: """Phase 1.5: если в meta есть ЕНК (39663) — вызываем per-cat probe с category_id=39663.""" from app.services.cadastre.bulk_harvest import harvest_quarter parcel = _make_parcel_feature() base_snapshot = QuarterSnapshot( quarter="66:41:0303161", fetched_at="2026-05-15T10:00:00+00:00", features=[parcel], meta_counts={36368: 2, CAT_ENK: 3}, # ЕНК в meta ) enk_snapshot = QuarterSnapshot( quarter="66:41:0303161", fetched_at="2026-05-15T10:00:01+00:00", features=[], meta_counts={CAT_ENK: 3}, ) db = MagicMock() db.execute = MagicMock( return_value=MagicMock( mappings=MagicMock( return_value=MagicMock(first=MagicMock(return_value={"phase_state": None})) ) ) ) db.commit = MagicMock() client = AsyncMock() client.search_by_quarter = AsyncMock(side_effect=[base_snapshot, enk_snapshot]) with patch("app.services.cadastre.bulk_harvest.upsert_features") as mock_upsert: mock_upsert.return_value = { "parcels": 1, "buildings": 0, "constructions": 0, "oncs": 0, "enks": 0, "zouit": 0, "skipped": 0, } await harvest_quarter( db=db, client=client, quarter="66:41:0303161", job_id=1, update_progress=lambda s: None, ) # Ожидаем вызов с category_id=CAT_ENK per_cat_enk_calls = [ c for c in client.search_by_quarter.await_args_list if c.kwargs.get("category_id") == CAT_ENK ] assert len(per_cat_enk_calls) >= 1, "Ожидался per-cat probe с category_id=39663 (ENK)" # ── auto-heal cad_quarters_geom ────────────────────────────────────────────── def _make_quarter_stats_feature( geom_type: str = "Polygon", geom_coords: list[Any] | None = None, ) -> NSPDBulkFeature: """Создать NSPDBulkFeature с category_id=36381 (квартальная статистика).""" if geom_coords is None: # 200m × 200m полигон в EPSG:3857 — здоровый квартал geom_coords = [ [ [6090000.0, 7590000.0], [6090200.0, 7590000.0], [6090200.0, 7590200.0], [6090000.0, 7590200.0], [6090000.0, 7590000.0], ] ] return NSPDBulkFeature.model_validate( { "id": 99, "type": "Feature", "geometry": {"type": geom_type, "coordinates": geom_coords}, "properties": { "category": CAT_QUARTER_STATS, "options": { "cnt_land": 5, "cnt_oks": 3, }, }, } ) def _mock_db_execute_scalar(scalar_value: Any) -> MagicMock: """Mock db.execute().scalar() возвращает заданное значение.""" mock_db = MagicMock() mock_db.execute.return_value.scalar.return_value = scalar_value return mock_db def test_upsert_quarter_geom_skips_invalid_geometry_type() -> None: """Point / LineString / None geometry → False (skip без DB call).""" db = MagicMock() # Нет geometry вообще feature_no_geom = NSPDBulkFeature.model_validate( {"type": "Feature", "geometry": None, "properties": {"category": CAT_QUARTER_STATS}} ) assert upsert_quarter_geom_from_feature(db, "66:41:0303161", feature_no_geom) is False db.execute.assert_not_called() # Point geometry feature_point = NSPDBulkFeature.model_validate( { "type": "Feature", "geometry": {"type": "Point", "coordinates": [6090000.0, 7590000.0]}, "properties": {"category": CAT_QUARTER_STATS}, } ) assert upsert_quarter_geom_from_feature(db, "66:41:0303161", feature_point) is False db.execute.assert_not_called() # LineString geometry feature_line = NSPDBulkFeature.model_validate( { "type": "Feature", "geometry": { "type": "LineString", "coordinates": [[6090000.0, 7590000.0], [6090200.0, 7590200.0]], }, "properties": {"category": CAT_QUARTER_STATS}, } ) assert upsert_quarter_geom_from_feature(db, "66:41:0303161", feature_line) is False db.execute.assert_not_called() def test_upsert_quarter_geom_returns_false_when_sql_updates_nothing() -> None: """Если UPDATE не затронул строк (скипнул SQL sanity check) → scalar()=None → False. Симулирует случай: новая geom тоже broken (<100m) ИЛИ existing geom уже здорова. В обоих случаях RETURNING не вернёт строку, scalar() = None. """ db = _mock_db_execute_scalar(None) # RETURNING ничего не вернул feature = _make_quarter_stats_feature() # здоровая feature result = upsert_quarter_geom_from_feature(db, "66:41:0303161", feature) assert result is False db.execute.assert_called_once() # Убедиться что был передан корректный quarter параметр call_params = db.execute.call_args[0][1] assert call_params["quarter"] == "66:41:0303161" assert "geom" in call_params assert call_params["min_w"] == 100.0 assert call_params["max_w"] == 10000.0 def test_upsert_quarter_geom_returns_true_when_sql_updates_row() -> None: """Если UPDATE обновил строку (existing broken + new healthy) → scalar()=cad_number → True.""" db = _mock_db_execute_scalar("66:41:0303161") # RETURNING вернул cad_number feature = _make_quarter_stats_feature() # здоровая feature 200m×200m result = upsert_quarter_geom_from_feature(db, "66:41:0303161", feature) assert result is True db.execute.assert_called_once() def test_upsert_quarter_geom_multipolygon_accepted() -> None: """MultiPolygon тип тоже принимается (не только Polygon).""" db = _mock_db_execute_scalar("66:41:0303161") # MultiPolygon coords: список из одного полигона mp_coords = [ [ [ [6090000.0, 7590000.0], [6090200.0, 7590000.0], [6090200.0, 7590200.0], [6090000.0, 7590200.0], [6090000.0, 7590000.0], ] ] ] feature = _make_quarter_stats_feature(geom_type="MultiPolygon", geom_coords=mp_coords) result = upsert_quarter_geom_from_feature(db, "66:41:0303161", feature) assert result is True db.execute.assert_called_once() def test_upsert_quarter_geom_sql_uses_cast_not_double_colon() -> None: """SQL использует CAST(:x AS text) не :x::text (SQLAlchemy psycopg v3 trap).""" executed_sqls: list[str] = [] db = MagicMock() db.execute.side_effect = lambda stmt, params=None: ( executed_sqls.append(str(stmt)) or MagicMock(scalar=MagicMock(return_value=None)) ) feature = _make_quarter_stats_feature() upsert_quarter_geom_from_feature(db, "66:41:0303161", feature) assert len(executed_sqls) == 1 sql = executed_sqls[0] assert "::jsonb" not in sql assert "::text" not in sql assert "CAST(:geom AS text)" in sql def test_upsert_parcel_skips_geom_for_point_geometry() -> None: """NSPD изредка возвращает Point для ЗУ — cad_parcels.geom strict Polygon, INSERT fail'нул бы. Фильтр на Python level → geom=NULL, raw_props сохранён. """ from app.services.cadastre.bulk_harvest import upsert_parcel point_parcel = NSPDBulkFeature.model_validate( { "id": 99, "type": "Feature", "geometry": {"type": "Point", "coordinates": [6753892.91, 7747664.31]}, "properties": { "category": 36368, "options": { "cad_num": "66:41:0104002:18", "quarter_cad_number": "66:41:0104002", "declared_area": 200.0, "readable_address": "г Екатеринбург, ул Тестовая", }, }, } ) db = MagicMock() upsert_parcel(db, point_parcel, source="search") # db.execute должен быть вызван (parcel НЕ skipped, только geom=NULL) db.execute.assert_called_once() call_params = db.execute.call_args[0][1] assert call_params["cad_num"] == "66:41:0104002:18" # geom параметр должен быть None assert call_params["geom"] is None def test_upsert_parcel_keeps_polygon_geometry() -> None: """Polygon geometry для ЗУ — обычный happy path.""" from app.services.cadastre.bulk_harvest import upsert_parcel parcel = _make_parcel_feature() # Polygon db = MagicMock() upsert_parcel(db, parcel, source="search") db.execute.assert_called_once() call_params = db.execute.call_args[0][1] assert call_params["geom"] is not None assert '"type": "Polygon"' in call_params["geom"] def test_upsert_zouit_skips_geom_for_linestring() -> None: """ЗОУИТ schema MultiPolygon — LineString не валиден, geom=NULL.""" from app.services.cadastre.bulk_harvest import upsert_zouit line_zouit = NSPDBulkFeature.model_validate( { "id": 1, "type": "Feature", "geometry": { "type": "LineString", "coordinates": [[6090000, 7590000], [6090100, 7590100]], }, "properties": { "category": 36940, "options": { "reg_numb_border": "66-0.1-2.1-99", "type_zone": "Linear", "registration_date": "2020-01-01", }, }, } ) db = MagicMock() upsert_zouit(db, line_zouit, source="search") db.execute.assert_called_once() call_params = db.execute.call_args[0][1] assert call_params["geom"] is None def test_upsert_quarter_geom_custom_width_bounds() -> None: """min_width_m и max_width_m пробрасываются как параметры SQL (не hardcoded).""" db = _mock_db_execute_scalar(None) feature = _make_quarter_stats_feature() upsert_quarter_geom_from_feature( db, "66:41:0303161", feature, min_width_m=50.0, max_width_m=5000.0 ) call_params = db.execute.call_args[0][1] assert call_params["min_w"] == 50.0 assert call_params["max_w"] == 5000.0 @pytest.mark.asyncio async def test_harvest_quarter_geom_heal_failure_does_not_propagate() -> None: """Phase 4: если upsert_quarter_geom_from_feature бросает (malformed GeoJSON из NSPD → ST_GeomFromGeoJSON exception), harvest_quarter должен: 1. Залогировать warning 2. Rollback'нуть SAVEPOINT через begin_nested 3. ПРОДОЛЖИТЬ — НЕ propagate exception 4. Сохранить upsert_quarter_stats (успешный до geom failure) """ from app.services.cadastre.bulk_harvest import harvest_quarter stats_feature = _make_quarter_stats_feature() snapshot = QuarterSnapshot( quarter="66:41:0303161", fetched_at="2026-05-15T10:00:00+00:00", features=[stats_feature], meta_counts={CAT_QUARTER_STATS: 1}, ) db = MagicMock() db.execute = MagicMock( return_value=MagicMock( mappings=MagicMock( return_value=MagicMock(first=MagicMock(return_value={"phase_state": None})) ) ) ) db.commit = MagicMock() db.begin_nested = MagicMock() # context manager mock — exit без error client = AsyncMock() client.search_by_quarter = AsyncMock(return_value=snapshot) with ( patch("app.services.cadastre.bulk_harvest.upsert_features") as mock_upsert, patch("app.services.cadastre.bulk_harvest.upsert_quarter_stats") as mock_stats, patch("app.services.cadastre.bulk_harvest.upsert_quarter_geom_from_feature") as mock_heal, ): mock_upsert.return_value = { "parcels": 0, "buildings": 0, "constructions": 0, "oncs": 0, "enks": 0, "zouit": 0, "skipped": 0, } mock_heal.side_effect = Exception("ST_GeomFromGeoJSON: invalid polygon") # Это не должно raise result = await harvest_quarter( db=db, client=client, quarter="66:41:0303161", job_id=1, update_progress=lambda s: None, ) # Stats был вызван (Phase 4 продолжил после geom failure) mock_stats.assert_called_once() mock_heal.assert_called_once() # Phase=done — harvest завершился штатно assert result.phase_state == {"phase": "done", "quarter": "66:41:0303161"} # ── Fix I: heartbeat в grid-walk loop ──────────────────────────────────────── @pytest.mark.asyncio async def test_grid_walk_emits_heartbeat_callbacks() -> None: """_grid_walk_category должен вызывать update_progress каждые heartbeat_every cells. 225 cells (15×15) / 50 = 4 heartbeats minimum (idx=50, 100, 150, 200). """ from app.services.cadastre.bulk_harvest import _grid_walk_category # Mock db: quarter_bbox_3857 returns valid bbox db = MagicMock() db.execute.return_value.mappings.return_value.first.return_value = { "xmin": 6735845.0, "ymin": 8329000.0, "xmax": 6736595.0, "ymax": 8329750.0, } # Mock client: wms_feature_info returns empty list (no upserts to worry about) client = AsyncMock() client.wms_feature_info = AsyncMock(return_value=[]) progress_states: list[dict[str, Any]] = [] _upserted, requests = await _grid_walk_category( db=db, client=client, quarter="66:41:0303161", layer_id=36368, update_progress=lambda s: progress_states.append(s), heartbeat_every=50, ) # 225 cells / 50 = 4 heartbeats at idx=50/100/150/200 assert len(progress_states) == 4 assert all("grid_cells_done" in s for s in progress_states) assert progress_states[0]["grid_cells_done"] == 50 assert progress_states[-1]["grid_cells_done"] == 200 assert progress_states[0]["quarter"] == "66:41:0303161" # 225 WMS calls (request counter) assert requests == 225 @pytest.mark.asyncio async def test_grid_walk_no_heartbeat_when_callback_none() -> None: """Если update_progress=None, не должны падать (callback optional).""" from app.services.cadastre.bulk_harvest import _grid_walk_category db = MagicMock() db.execute.return_value.mappings.return_value.first.return_value = { "xmin": 6735845.0, "ymin": 8329000.0, "xmax": 6736595.0, "ymax": 8329750.0, } client = AsyncMock() client.wms_feature_info = AsyncMock(return_value=[]) # Не передаём update_progress — должно не падать _upserted, requests = await _grid_walk_category( db=db, client=client, quarter="66:41:0303161", layer_id=36368, ) assert requests == 225