All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m21s
CI / backend-tests (pull_request) Successful in 6m16s
361 lines
14 KiB
Python
361 lines
14 KiB
Python
"""Тесты воркера 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"
|