"""Тесты воркера okn_objects_sync (#1066) — без живой сети. Покрывает: - upsert идемпотентность: два вызова с одними source_id → один insert. - дедуп source_id across проходов: category берётся из первого (более приоритетного). - _extract_coords: правильный порядок [lat, lon] → (lon, lat) для PostGIS. - _extract_coords: невалидные / выходящие за bbox координаты → None. - sync_okn_objects: мок client + мок DB, проверка UPSERT-вызовов. """ from __future__ import annotations from typing import Any from unittest.mock import MagicMock, patch from app.workers.tasks.okn_objects_sync import _extract_coords # ── _extract_coords ─────────────────────────────────────────────────────────── def _make_feature_coords(lat: float, lon: float) -> dict[str, Any]: """Фича ЕГРКН с coordinates=[lat, lon] (Яндекс-порядок).""" return { "id": "test", "geometry": {"type": "Point", "coordinates": [lat, lon]}, "properties": {}, } def test_extract_coords_ekb_center() -> None: """Центр ЕКБ (56.83N 60.61E): lat=coords[0], lon=coords[1] → (lon, lat) для MakePoint.""" feature = _make_feature_coords(lat=56.83, lon=60.61) result = _extract_coords(feature) assert result is not None lon, lat = result # MakePoint(lon, lat): lon=60.61°E (Свердл. обл.), lat=56.83°N (ЕКБ) assert abs(lon - 60.61) < 0.001, f"lon={lon} ≠ 60.61 — coord swap сломан" assert abs(lat - 56.83) < 0.001, f"lat={lat} ≠ 56.83 — coord swap сломан" def test_extract_coords_inside_ekb_bbox() -> None: """Любая точка в ЕКБ-bbox [55–58.5°N, 58–63°E] проходит санитарный фильтр.""" feature = _make_feature_coords(lat=56.50, lon=60.10) result = _extract_coords(feature) assert result is not None lon, lat = result assert 58.0 <= lon <= 63.0 assert 55.0 <= lat <= 58.5 def test_extract_coords_outside_bbox_returns_none() -> None: """Координаты вне Урал-bbox → None (некорректная/тестовая точка из ЕГРКН).""" # Москва (~55.75N 37.62E) — lon=37.62 вне [58–63] feature = _make_feature_coords(lat=55.75, lon=37.62) result = _extract_coords(feature) assert result is None def test_extract_coords_inverted_would_fail_bbox() -> None: """Тест критически важной coord swap логики. Если бы coords[0] трактовался как lon (а не lat), то для ЕКБ-точки (lat=56.83, lon=60.61): wrong_lon = 56.83 → вне bbox [58–63] → _extract_coords вернул бы None → баг обнаружен. Этот тест ловит регрессию если кто-то случайно поменяет местами coords[0]/[1]. """ # Проверяем что ПРАВИЛЬНАЯ точка ЕКБ проходит только при lat=coords[0], lon=coords[1] feature = _make_feature_coords(lat=56.83, lon=60.61) # Симулируем неверную трактовку: coords[0]=56.83 как lon inverted_lon = feature["geometry"]["coordinates"][0] # 56.83 → не в bbox [58–63] inverted_lat = feature["geometry"]["coordinates"][1] # 60.61 → не в bbox [55–58.5] inverted_inside = (55.0 <= inverted_lat <= 58.5) and (58.0 <= inverted_lon <= 63.0) assert ( not inverted_inside ), "Перепутанные координаты прошли bbox-фильтр — _extract_coords не обнаружит баг coord swap" # Проверяем что реальный _extract_coords возвращает КОРРЕКТНЫЕ (lon, lat) result = _extract_coords(feature) assert result is not None lon, _lat = result assert abs(lon - 60.61) < 0.001 def test_extract_coords_missing_geometry_returns_none() -> None: """Фича без geometry → None (graceful).""" feature: dict[str, Any] = {"id": "x", "geometry": None, "properties": {}} assert _extract_coords(feature) is None def test_extract_coords_missing_coordinates_returns_none() -> None: """Geometry без coordinates → None.""" feature: dict[str, Any] = { "id": "x", "geometry": {"type": "Point"}, "properties": {}, } assert _extract_coords(feature) is None def test_extract_coords_single_coordinate_returns_none() -> None: """Только одна координата (len<2) → None.""" feature: dict[str, Any] = { "id": "x", "geometry": {"type": "Point", "coordinates": [56.83]}, "properties": {}, } assert _extract_coords(feature) is None # ── sync_okn_objects: дедуп source_id ──────────────────────────────────────── def _make_feature_full( feature_id: str, lat: float = 56.83, lon: float = 60.61, ) -> dict[str, Any]: return { "id": feature_id, "geometry": {"type": "Point", "coordinates": [lat, lon]}, "properties": {"name": f"ОКН-{feature_id}"}, } def test_sync_okn_objects_dedup_source_id_across_passes() -> None: """Один source_id из нескольких проходов → category из первого прохода (federal wins).""" from app.workers.tasks.okn_objects_sync import sync_okn_objects # «001» встречается в проходе (Екатеринбург, 1) = federal # и в проходе (Екатеринбург, 2) = regional # category должна быть federal pass_results: dict[tuple[str, int], list[dict[str, Any]]] = { ("Екатеринбург", 1): [_make_feature_full("001")], ("Екатеринбург", 2): [_make_feature_full("001")], # дубль ("Екатеринбург", 4): [], ("Свердловская", 1): [], ("Свердловская", 2): [], ("Свердловская", 4): [], } def _mock_fetch(address: str, category_type: int) -> list[dict[str, Any]]: return pass_results.get((address, category_type), []) upserted_rows: list[dict[str, Any]] = [] mock_db = MagicMock() mock_db.__enter__ = MagicMock(return_value=mock_db) mock_db.__exit__ = MagicMock(return_value=False) 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, row: Any = None) -> MagicMock: if row: upserted_rows.append(dict(row)) return MagicMock() mock_db.execute = _capture_execute with ( patch("app.workers.tasks.okn_objects_sync.fetch_okn_points", side_effect=_mock_fetch), patch("app.workers.tasks.okn_objects_sync.SessionLocal", return_value=mock_db), ): result = sync_okn_objects() # Один объект, один upsert assert result == {"okn": 1} assert len(upserted_rows) == 1 assert upserted_rows[0]["source_id"] == "001" assert upserted_rows[0]["category"] == "federal" def test_sync_okn_objects_upsert_idempotent() -> None: """Два вызова sync_okn_objects с одними source_id → два upsert (ON CONFLICT DO UPDATE в БД). Тест проверяет что воркер передаёт все source_id для UPSERT при каждом прогоне — идемпотентность гарантирует SQL ON CONFLICT, не фильтрация в Python. """ from app.workers.tasks.okn_objects_sync import sync_okn_objects features = [_make_feature_full("001"), _make_feature_full("002")] pass_results: dict[tuple[str, int], list[dict[str, Any]]] = { ("Екатеринбург", 1): features, ("Екатеринбург", 2): [], ("Екатеринбург", 4): [], ("Свердловская", 1): [], ("Свердловская", 2): [], ("Свердловская", 4): [], } upsert_counts: list[int] = [] def _run_sync() -> dict[str, int]: upserted: list[dict[str, Any]] = [] mock_db = MagicMock() mock_db.__enter__ = MagicMock(return_value=mock_db) mock_db.__exit__ = MagicMock(return_value=False) mock_db.begin_nested.return_value.__enter__ = MagicMock(return_value=None) mock_db.begin_nested.return_value.__exit__ = MagicMock(return_value=False) def _exec(sql: Any, row: Any = None) -> MagicMock: if row: upserted.append(dict(row)) return MagicMock() mock_db.execute = _exec with ( patch( "app.workers.tasks.okn_objects_sync.fetch_okn_points", side_effect=lambda a, c: pass_results.get((a, c), []), ), patch("app.workers.tasks.okn_objects_sync.SessionLocal", return_value=mock_db), ): res = sync_okn_objects() upsert_counts.append(len(upserted)) return res r1 = _run_sync() r2 = _run_sync() assert r1 == {"okn": 2} assert r2 == {"okn": 2} # Оба прогона делают UPSERT (идемпотентность обеспечена SQL ON CONFLICT) assert upsert_counts == [2, 2] def test_sync_okn_objects_returns_total_unique_count() -> None: """sync_okn_objects возвращает {"okn": N} — число уникальных source_id после дедупа.""" from app.workers.tasks.okn_objects_sync import sync_okn_objects # 3 уникальных + 1 дубль = 3 upsert pass_results: dict[tuple[str, int], list[dict[str, Any]]] = { ("Екатеринбург", 1): [_make_feature_full("A"), _make_feature_full("B")], ("Екатеринбург", 2): [_make_feature_full("C"), _make_feature_full("A")], # A дубль ("Екатеринбург", 4): [], ("Свердловская", 1): [], ("Свердловская", 2): [], ("Свердловская", 4): [], } mock_db = MagicMock() mock_db.__enter__ = MagicMock(return_value=mock_db) mock_db.__exit__ = MagicMock(return_value=False) 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.okn_objects_sync.fetch_okn_points", side_effect=lambda a, c: pass_results.get((a, c), []), ), patch("app.workers.tasks.okn_objects_sync.SessionLocal", return_value=mock_db), ): result = sync_okn_objects() assert result == {"okn": 3} # ── parcel_okn_objects lookup ───────────────────────────────────────────────── def test_parcel_okn_objects_returns_nearby() -> None: """parcel_okn_objects: мок DB → список [{source_id, category, distance_m}].""" from app.services.site_finder.okn_lookup import parcel_okn_objects rows = [ ("ekb-001", "federal", 42), ("ekb-002", "regional", 95), ] mock_result = MagicMock() mock_result.fetchall.return_value = rows db = MagicMock() db.execute.return_value = mock_result result = parcel_okn_objects(db, "POLYGON((60 56,61 56,61 57,60 57,60 56))", radius_m=100) assert len(result) == 2 assert result[0] == {"source_id": "ekb-001", "category": "federal", "distance_m": 42} assert result[1] == {"source_id": "ekb-002", "category": "regional", "distance_m": 95} def test_parcel_okn_objects_empty_wkt_returns_empty() -> None: """Пустой parcel_wkt → пустой список без DB-запроса.""" from app.services.site_finder.okn_lookup import parcel_okn_objects db = MagicMock() result = parcel_okn_objects(db, None) assert result == [] db.execute.assert_not_called() def test_parcel_okn_objects_empty_string_wkt_returns_empty() -> None: """Пустая строка parcel_wkt → пустой список без DB-запроса.""" from app.services.site_finder.okn_lookup import parcel_okn_objects db = MagicMock() result = parcel_okn_objects(db, "") assert result == [] db.execute.assert_not_called() def test_parcel_okn_objects_no_table_returns_empty() -> None: """ProgrammingError (таблица не создана) → пустой список (graceful).""" from sqlalchemy.exc import ProgrammingError from app.services.site_finder.okn_lookup import parcel_okn_objects db = MagicMock() db.execute.side_effect = ProgrammingError("relation does not exist", None, None) result = parcel_okn_objects(db, "POLYGON((60 56,61 56,61 57,60 57,60 56))") assert result == [] def test_parcel_okn_objects_db_error_returns_empty() -> None: """OperationalError → пустой список (graceful).""" from sqlalchemy.exc import OperationalError from app.services.site_finder.okn_lookup import parcel_okn_objects db = MagicMock() db.execute.side_effect = OperationalError("connection lost", None, None) result = parcel_okn_objects(db, "POLYGON((60 56,61 56,61 57,60 57,60 56))") assert result == [] def test_parcel_okn_objects_no_results_returns_empty() -> None: """Нет объектов в радиусе → пустой список.""" from app.services.site_finder.okn_lookup import parcel_okn_objects mock_result = MagicMock() mock_result.fetchall.return_value = [] db = MagicMock() db.execute.return_value = mock_result result = parcel_okn_objects(db, "POLYGON((60 56,61 56,61 57,60 57,60 56))", radius_m=50) assert result == [] def test_parcel_okn_objects_distance_mapping() -> None: """distance_m берётся из колонки [2] (третий элемент row-кортежа).""" from app.services.site_finder.okn_lookup import parcel_okn_objects rows = [("id-x", "local", 7)] mock_result = MagicMock() mock_result.fetchall.return_value = rows db = MagicMock() db.execute.return_value = mock_result result = parcel_okn_objects(db, "POINT(60.61 56.83)", radius_m=200) assert result[0]["distance_m"] == 7 assert result[0]["source_id"] == "id-x" assert result[0]["category"] == "local"