gendesign/backend/tests/workers/tasks/test_okn_objects_sync.py
lekss361 50fcba1ca0
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 1m23s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m21s
Deploy / deploy (push) Successful in 1m15s
feat(sf): ingest ОКН-объектов ЕКБ из АИС ЕГРКН (точки+категория) → okn_objects (#1141)
Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
2026-06-07 12:51:18 +00:00

361 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Тесты воркера 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 [5558.5°N, 5863°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 вне [5863]
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 [5863]
→ _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 [5863]
inverted_lat = feature["geometry"]["coordinates"][1] # 60.61 → не в bbox [5558.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"