"""Тесты harvest_gknspecial_zones (#1063/#1064/#1065/#1066, #1067 GG-форсайт). Чистые тесты без живого геопортала / БД: • classify: full_name → корректный (layer_kind, source_layer_id) по каждому префиксу. • reg_numb_border regex: извлечение из full_name, мусор → None. • geom_data_id: 'portal_geo_gknspecial_zone.12345' → 12345; без точки/нечисло → None. • активность: zone_state_v != 'Действующий' → skip; поле отсутствует → не skip. • upsert idempotency: mock-клиент + mock-SessionLocal, два прогона → строки не дублируются. """ from __future__ import annotations import os from typing import Any from unittest.mock import MagicMock, patch os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from app.workers.tasks.gknspecial_harvest import ( _UNMATCHED_LAYER_KIND, _UNMATCHED_SOURCE_LAYER_ID, _classify_full_name, _extract_geom_data_id, _extract_reg_numb, harvest_gknspecial_zones, ) # ── classify ───────────────────────────────────────────────────────────────── def test_classify_servitude() -> None: cls = _classify_full_name("Зона публичного сервитута 66:41-6.100") assert cls == ("servitude", 990001) def test_classify_water_protection() -> None: assert _classify_full_name("Водоохранная зона р. Исеть") == ("water_protection", 990002) def test_classify_coastal_strip() -> None: assert _classify_full_name("Прибрежная защитная полоса р.Исеть") == ("coastal_strip", 990003) def test_classify_water_sanitary() -> None: assert _classify_full_name("Зона санитарной охраны ВЗУ") == ("water_sanitary", 990004) def test_classify_sanitary_protection() -> None: assert _classify_full_name("Санитарно-защитная зона завода") == ("sanitary_protection", 990005) def test_classify_okn_protection_zone() -> None: assert _classify_full_name("Зона охраны объекта культурного наследия") == ( "okn_protection_zone", 990006, ) def test_classify_zrz() -> None: assert _classify_full_name("Зона регулирования застройки") == ("zrz", 990007) def test_classify_airfield() -> None: assert _classify_full_name("Приаэродромная территория") == ("airfield", 990008) def test_classify_airfield_with_suffix() -> None: assert _classify_full_name("Приаэродромная территория аэропорта Кольцово") == ( "airfield", 990008, ) def test_classify_unmatched_returns_none() -> None: assert _classify_full_name("Неизвестная зона нового типа") is None def test_classify_empty_returns_none() -> None: assert _classify_full_name("") is None # ── reg_numb_border regex ───────────────────────────────────────────────────── def test_reg_numb_extracts_from_full_name() -> None: assert _extract_reg_numb("Водоохранная зона 66:41-6.4125 р. Исеть") == "66:41-6.4125" def test_reg_numb_extracts_with_multiple_digits() -> None: assert _extract_reg_numb("Зона охраны 66:01-12.300") == "66:01-12.300" def test_reg_numb_returns_none_for_garbage() -> None: assert _extract_reg_numb("Нет номера границы здесь") is None def test_reg_numb_returns_none_for_empty() -> None: assert _extract_reg_numb("") is None # ── geom_data_id ───────────────────────────────────────────────────────────── def test_geom_data_id_normal() -> None: assert _extract_geom_data_id("portal_geo_gknspecial_zone.12345") == 12345 def test_geom_data_id_large_number() -> None: assert _extract_geom_data_id("portal_geo_gknspecial_zone.9999999") == 9999999 def test_geom_data_id_no_dot() -> None: assert _extract_geom_data_id("portal_geo_gknspecial_zone") is None def test_geom_data_id_non_numeric() -> None: assert _extract_geom_data_id("portal_geo_gknspecial_zone.abc") is None def test_geom_data_id_none_input() -> None: assert _extract_geom_data_id(None) is None def test_geom_data_id_empty_string() -> None: assert _extract_geom_data_id("") is None # ── zone_state_v фильтр активности ─────────────────────────────────────────── def _make_feature( feature_id: str = "portal_geo_gknspecial_zone.1", full_name: str = "Водоохранная зона", zone_state_v: str | None = "Действующий", geom: dict[str, Any] | None = None, ) -> MagicMock: """Фейковый EKBFeature для тестов.""" from app.services.scrapers.ekb_geoportal_client import EKBFeature props: dict[str, Any] = {"full_name": full_name} if zone_state_v is not None: props["zone_state_v"] = zone_state_v if geom is None: geom = {"type": "Polygon", "coordinates": [[[60.5, 56.8], [60.6, 56.8], [60.5, 56.8]]]} return EKBFeature(feature_id=feature_id, geometry=geom, properties=props) def _run_task_with_feats(feats_per_prefix: list[list[Any]]) -> dict[str, int]: """Запустить harvest_gknspecial_zones с мок-клиентом и мок-БД. feats_per_prefix: список из 8 элементов (по числу _PREFIX_MAP), каждый — список EKBFeature. """ mock_db = MagicMock() mock_db.begin_nested.return_value.__enter__ = MagicMock(return_value=None) mock_db.begin_nested.return_value.__exit__ = MagicMock(return_value=False) # Счётчик вызовов features_by_cql → разные фичи для разных префиксов call_idx: list[int] = [0] def fake_features_by_cql(layer: str, cql_filter: str) -> list[Any]: idx = call_idx[0] call_idx[0] += 1 if idx < len(feats_per_prefix): return feats_per_prefix[idx] return [] with ( patch("app.workers.tasks.gknspecial_harvest.EKBGeoportalClient") as mock_client_cls, patch("app.workers.tasks.gknspecial_harvest.SessionLocal", return_value=mock_db), ): mock_client = MagicMock() mock_client.features_by_cql.side_effect = fake_features_by_cql mock_client_cls.return_value = mock_client return harvest_gknspecial_zones() def test_skip_archived_zone_state() -> None: """zone_state_v='Архивный' → фича не пишется.""" feat = _make_feature(zone_state_v="Архивный") # 8 префиксов: первый с архивной фичей, остальные пустые feats_per_prefix = [[feat]] + [[] for _ in range(7)] result = _run_task_with_feats(feats_per_prefix) assert result["features"] == 0 def test_include_feature_without_zone_state() -> None: """Нет поля zone_state_v → фича не скипается (неизвестное = активное).""" feat = _make_feature(zone_state_v=None) feats_per_prefix = [[feat]] + [[] for _ in range(7)] result = _run_task_with_feats(feats_per_prefix) assert result["features"] == 1 def test_include_active_zone_state() -> None: """zone_state_v='Действующий' → фича пишется.""" feat = _make_feature(zone_state_v="Действующий") feats_per_prefix = [[feat]] + [[] for _ in range(7)] result = _run_task_with_feats(feats_per_prefix) assert result["features"] == 1 # ── upsert idempotency ─────────────────────────────────────────────────────── def _run_single_feat_task(feat: Any, execute_calls: list[Any]) -> None: """Вспомогательная — один прогон harvest_gknspecial_zones с одной фичей в первом префиксе.""" feats_per_prefix = [[feat]] + [[] for _ in range(7)] call_idx: list[int] = [0] def fake_by_cql(layer: str, cql_filter: str) -> list[Any]: idx = call_idx[0] call_idx[0] += 1 return feats_per_prefix[idx] if idx < len(feats_per_prefix) else [] mock_db = MagicMock() mock_db.begin_nested.return_value.__enter__ = MagicMock(return_value=None) mock_db.begin_nested.return_value.__exit__ = MagicMock(return_value=False) mock_db.execute.side_effect = lambda *a, **kw: execute_calls.append(a) with ( patch("app.workers.tasks.gknspecial_harvest.EKBGeoportalClient") as mock_client_cls, patch("app.workers.tasks.gknspecial_harvest.SessionLocal", return_value=mock_db), ): mock_client = MagicMock() mock_client.features_by_cql.side_effect = fake_by_cql mock_client_cls.return_value = mock_client harvest_gknspecial_zones() def test_upsert_idempotency_no_duplicates() -> None: """Два прогона с одной и той же фичей → db.execute дважды, но ON CONFLICT — не дублируем.""" feat = _make_feature( feature_id="portal_geo_gknspecial_zone.777", full_name="Водоохранная зона 66:41-6.100", zone_state_v="Действующий", ) execute_calls: list[Any] = [] _run_single_feat_task(feat, execute_calls) _run_single_feat_task(feat, execute_calls) # Оба прогона должны были вызвать db.execute по одному разу (за каждый run по одной фиче). # ON CONFLICT в SQL — гарантирует что строка не дублируется на уровне БД. # Здесь проверяем: execute вызывался ровно дважды (1 фича × 2 прогона). assert len(execute_calls) == 2 def test_returns_types_and_features_counts() -> None: """Возврат {'types': N, 'features': M}.""" feat1 = _make_feature(feature_id="portal_geo_gknspecial_zone.1", full_name="Водоохранная зона") feat2 = _make_feature(feature_id="portal_geo_gknspecial_zone.2", full_name="Водоохранная зона") # 2 фичи в первом префиксе, остальные пустые feats_per_prefix = [[feat1, feat2]] + [[] for _ in range(7)] result = _run_task_with_feats(feats_per_prefix) # types = 8 (все 8 префиксов запрошены успешно) assert result["types"] == 8 assert result["features"] == 2 def test_per_prefix_commit_durability() -> None: """db.commit вызван N+1 раз: после каждого из 8 префиксов + финальный (#1116). Durability: WorkerLostError в середине прогона не теряет уже записанные префиксы. """ feat = _make_feature(zone_state_v="Действующий") # 8 префиксов: первый с фичей, остальные пустые feats_per_prefix: list[list[Any]] = [[feat]] + [[] for _ in range(7)] call_idx: list[int] = [0] def fake_by_cql(layer: str, cql_filter: str) -> list[Any]: idx = call_idx[0] call_idx[0] += 1 return feats_per_prefix[idx] if idx < len(feats_per_prefix) else [] mock_db = MagicMock() mock_db.begin_nested.return_value.__enter__ = MagicMock(return_value=None) mock_db.begin_nested.return_value.__exit__ = MagicMock(return_value=False) with ( patch("app.workers.tasks.gknspecial_harvest.EKBGeoportalClient") as mock_client_cls, patch("app.workers.tasks.gknspecial_harvest.SessionLocal", return_value=mock_db), ): mock_client = MagicMock() mock_client.features_by_cql.side_effect = fake_by_cql mock_client_cls.return_value = mock_client harvest_gknspecial_zones() # 8 per-prefix commit + 1 финальный (подстраховка) = 9 assert mock_db.commit.call_count == 9 def test_per_prefix_commit_skipped_on_wfs_failure() -> None: """Если WFS падает на префиксе — commit за этот префикс не делается (continue). Проверяем что commit'ов столько же, сколько успешно обработанных префиксов + 1 финальный. """ feat = _make_feature(zone_state_v="Действующий") # 8 префиксов: первый успех, второй WFS-fail (sentinel), остальные успех call_idx: list[int] = [0] def fake_by_cql(layer: str, cql_filter: str) -> list[Any]: idx = call_idx[0] call_idx[0] += 1 if idx == 1: raise RuntimeError("WFS timeout") return [feat] if idx == 0 else [] mock_db = MagicMock() mock_db.begin_nested.return_value.__enter__ = MagicMock(return_value=None) mock_db.begin_nested.return_value.__exit__ = MagicMock(return_value=False) with ( patch("app.workers.tasks.gknspecial_harvest.EKBGeoportalClient") as mock_client_cls, patch("app.workers.tasks.gknspecial_harvest.SessionLocal", return_value=mock_db), ): mock_client = MagicMock() mock_client.features_by_cql.side_effect = fake_by_cql mock_client_cls.return_value = mock_client harvest_gknspecial_zones() # 7 успешных префиксов × commit + 1 финальный = 8 (failed prefix через continue → нет commit) assert mock_db.commit.call_count == 8 def test_unmatched_feature_written_to_sentinel() -> None: """Фича с full_name не из _PREFIX_MAP → пишется в unmatched (source_layer_id=990099).""" feat = _make_feature( feature_id="portal_geo_gknspecial_zone.999", full_name="Неизвестная зона нового типа", zone_state_v=None, ) feats_per_prefix = [[feat]] + [[] for _ in range(7)] written_params: list[dict[str, Any]] = [] def make_db() -> MagicMock: mock_db = MagicMock() mock_db.begin_nested.return_value.__enter__ = MagicMock(return_value=None) mock_db.begin_nested.return_value.__exit__ = MagicMock(return_value=False) def capture_execute(sql: Any, params: dict[str, Any] | None = None) -> None: if params: written_params.append(params) mock_db.execute.side_effect = capture_execute return mock_db call_idx: list[int] = [0] def fake_by_cql(layer: str, cql_filter: str) -> list[Any]: idx = call_idx[0] call_idx[0] += 1 return feats_per_prefix[idx] if idx < len(feats_per_prefix) else [] with ( patch("app.workers.tasks.gknspecial_harvest.EKBGeoportalClient") as mock_client_cls, patch( "app.workers.tasks.gknspecial_harvest.SessionLocal", return_value=make_db(), ), ): mock_client = MagicMock() mock_client.features_by_cql.side_effect = fake_by_cql mock_client_cls.return_value = mock_client result = harvest_gknspecial_zones() assert result["features"] == 1 assert len(written_params) == 1 assert written_params[0]["source_layer_id"] == _UNMATCHED_SOURCE_LAYER_ID assert written_params[0]["layer_kind"] == _UNMATCHED_LAYER_KIND