Backend /api/v1/geocode/reverse теперь возвращает snapped_lat/snapped_lon центра matched здания (от Yandex Point.pos / Nominatim response.lat-lon / cadastral row) + precision. Раньше endpoint echo'ил input lat/lon — адрес resolved'ился до canonical «ул. Малышева, 125», но marker оставался где кликнул user (часто в проезде / дворе). Backend: - reverse_geocode: str | None → ReverseGeocodeResult | None (address + snapped_lat/snapped_lon + precision + provider). Provider fallback Cadastral FDW → Yandex (если key) → Nominatim. Yandex /reverse дёргаем с kind=house чтобы сразу получить здание. - /api/v1/geocode/reverse: новый Pydantic ReverseResponse model. - snap_precision_useful() helper для frontend logic parity. - Legacy _cadastral_reverse_sync сохранён для backward compat. Frontend MapPicker: - После reverse — если precision in (exact, number, cadastral) и snapped >5m от клика → marker.setLatLng + map.panTo с animate - precision=street/range/locality → marker остаётся где кликнули (не врём что нашли точное здание). - Hint «Точка дома по Яндексу» под address с aria-live="polite" (var(--fg-tertiary) — token compliance). - haversineMeters helper для distance check (6371000m Earth radius). Tests: 37 backend pass, ruff + tsc clean. Code-reviewer LGTM, 1 nit applied inline (hex → var(--fg-tertiary)). Closes #582 (Phase 5).
228 lines
8.2 KiB
Python
228 lines
8.2 KiB
Python
"""Tests for /api/v1/geocode/reverse — snapped coords + precision in response.
|
||
|
||
Bug context (issue #582 Phase 5):
|
||
До этого PR'а endpoint echo'ил input lat/lon в ответе. После reverse-геокодинга
|
||
адрес resolved'ился до canonical здания (ул. Малышева, 125), но marker на фронте
|
||
оставался где user кликнул — иногда в проезде / дворе.
|
||
|
||
Fix: backend теперь возвращает snapped_lat/snapped_lon (центр matched здания
|
||
от Yandex/Nominatim/cadastral) + precision, фронт двигает marker если precision
|
||
in (exact, number, cadastral).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
# DATABASE_URL required by config before any app import.
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
# WeasyPrint stub — not installed in CI without GTK.
|
||
_wp_mock = MagicMock()
|
||
sys.modules.setdefault("weasyprint", _wp_mock)
|
||
|
||
|
||
import pytest # noqa: E402
|
||
from fastapi import FastAPI # noqa: E402
|
||
from fastapi.testclient import TestClient # noqa: E402
|
||
|
||
from app.api.v1 import geocode as geocode_module # noqa: E402
|
||
from app.core.db import get_db # noqa: E402
|
||
from app.services.geocoder import ( # noqa: E402
|
||
ReverseGeocodeResult,
|
||
snap_precision_useful,
|
||
)
|
||
|
||
|
||
@pytest.fixture
|
||
def app() -> FastAPI:
|
||
application = FastAPI()
|
||
application.include_router(geocode_module.router, prefix="/api/v1/geocode")
|
||
application.dependency_overrides[get_db] = lambda: MagicMock()
|
||
return application
|
||
|
||
|
||
# ── Endpoint response shape ──────────────────────────────────────────────────
|
||
|
||
def test_reverse_endpoint_returns_snapped_fields(app: FastAPI) -> None:
|
||
"""Endpoint should include address + lat/lon (echo) + snapped_lat/snapped_lon + precision."""
|
||
client = TestClient(app)
|
||
fake = ReverseGeocodeResult(
|
||
address="улица Малышева, 51, Екатеринбург",
|
||
snapped_lat=56.838004,
|
||
snapped_lon=60.586155,
|
||
precision="exact",
|
||
provider="yandex",
|
||
)
|
||
with patch(
|
||
"app.api.v1.geocode.reverse_geocode",
|
||
new_callable=AsyncMock,
|
||
return_value=fake,
|
||
):
|
||
r = client.get("/api/v1/geocode/reverse?lat=56.8381&lon=60.5860")
|
||
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
# Echo input
|
||
assert body["lat"] == 56.8381
|
||
assert body["lon"] == 60.5860
|
||
# Snap from provider
|
||
assert body["address"] == "улица Малышева, 51, Екатеринбург"
|
||
assert body["snapped_lat"] == 56.838004
|
||
assert body["snapped_lon"] == 60.586155
|
||
assert body["precision"] == "exact"
|
||
assert body["provider"] == "yandex"
|
||
|
||
|
||
def test_reverse_endpoint_404_when_no_address(app: FastAPI) -> None:
|
||
"""Provider returned None → endpoint должен дать 404, не 500."""
|
||
client = TestClient(app)
|
||
with patch(
|
||
"app.api.v1.geocode.reverse_geocode",
|
||
new_callable=AsyncMock,
|
||
return_value=None,
|
||
):
|
||
r = client.get("/api/v1/geocode/reverse?lat=56.0&lon=60.0")
|
||
assert r.status_code == 404
|
||
|
||
|
||
def test_reverse_endpoint_street_precision_does_not_lose_snap_fields(app: FastAPI) -> None:
|
||
"""Для precision=street snapped_lat/lon всё равно присутствуют (могут == input)."""
|
||
client = TestClient(app)
|
||
fake = ReverseGeocodeResult(
|
||
address="улица Малышева, Екатеринбург",
|
||
snapped_lat=56.84,
|
||
snapped_lon=60.61,
|
||
precision="street",
|
||
provider="nominatim",
|
||
)
|
||
with patch(
|
||
"app.api.v1.geocode.reverse_geocode",
|
||
new_callable=AsyncMock,
|
||
return_value=fake,
|
||
):
|
||
r = client.get("/api/v1/geocode/reverse?lat=56.84&lon=60.61")
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert "snapped_lat" in body
|
||
assert "snapped_lon" in body
|
||
assert body["precision"] == "street"
|
||
|
||
|
||
# ── snap_precision_useful helper ─────────────────────────────────────────────
|
||
|
||
def test_snap_precision_useful_exact_and_number() -> None:
|
||
assert snap_precision_useful("exact") is True
|
||
assert snap_precision_useful("number") is True
|
||
|
||
|
||
def test_snap_precision_useful_rejects_street_and_other() -> None:
|
||
assert snap_precision_useful("street") is False
|
||
assert snap_precision_useful("range") is False
|
||
assert snap_precision_useful("near") is False
|
||
assert snap_precision_useful("locality") is False
|
||
assert snap_precision_useful("other") is False
|
||
assert snap_precision_useful("") is False
|
||
|
||
|
||
# ── Yandex reverse parsing ───────────────────────────────────────────────────
|
||
|
||
async def test_yandex_reverse_parses_snapped_point_and_precision() -> None:
|
||
"""`_yandex_reverse` извлекает Point.pos (lon lat) и precision из metaDataProperty."""
|
||
import httpx
|
||
|
||
from app.services.geocoder import _yandex_reverse
|
||
|
||
sample = {
|
||
"response": {
|
||
"GeoObjectCollection": {
|
||
"featureMember": [
|
||
{
|
||
"GeoObject": {
|
||
"metaDataProperty": {
|
||
"GeocoderMetaData": {
|
||
"precision": "exact",
|
||
"text": (
|
||
"Россия, Свердловская область, "
|
||
"Екатеринбург, улица Малышева, 51"
|
||
),
|
||
"kind": "house",
|
||
}
|
||
},
|
||
"name": "улица Малышева, 51",
|
||
"Point": {"pos": "60.586155 56.838004"},
|
||
}
|
||
}
|
||
]
|
||
}
|
||
}
|
||
}
|
||
|
||
class _FakeResp:
|
||
status_code = 200
|
||
|
||
def raise_for_status(self) -> None:
|
||
return None
|
||
|
||
def json(self) -> dict:
|
||
return sample
|
||
|
||
class _FakeClient:
|
||
async def __aenter__(self) -> _FakeClient:
|
||
return self
|
||
|
||
async def __aexit__(self, *_: object) -> None:
|
||
return None
|
||
|
||
async def get(self, *_: object, **__: object) -> _FakeResp:
|
||
return _FakeResp()
|
||
|
||
with patch.object(httpx, "AsyncClient", lambda *a, **kw: _FakeClient()):
|
||
result = await _yandex_reverse(56.8381, 60.5860, api_key="fake")
|
||
|
||
assert result is not None
|
||
# Yandex pos формат: "lon lat" → snapped_lat=56.838004, snapped_lon=60.586155
|
||
assert abs(result.snapped_lat - 56.838004) < 1e-6
|
||
assert abs(result.snapped_lon - 60.586155) < 1e-6
|
||
assert result.precision == "exact"
|
||
assert result.provider == "yandex"
|
||
# Address text должен быть очищен от "Россия, Свердловская область"
|
||
assert "Россия" not in result.address
|
||
assert "Свердловская область" not in result.address
|
||
assert "Малышева" in result.address
|
||
assert "51" in result.address
|
||
|
||
|
||
async def test_yandex_reverse_returns_none_on_empty_results() -> None:
|
||
"""Empty featureMember → None."""
|
||
import httpx
|
||
|
||
from app.services.geocoder import _yandex_reverse
|
||
|
||
sample = {"response": {"GeoObjectCollection": {"featureMember": []}}}
|
||
|
||
class _FakeResp:
|
||
status_code = 200
|
||
|
||
def raise_for_status(self) -> None:
|
||
return None
|
||
|
||
def json(self) -> dict:
|
||
return sample
|
||
|
||
class _FakeClient:
|
||
async def __aenter__(self) -> _FakeClient:
|
||
return self
|
||
|
||
async def __aexit__(self, *_: object) -> None:
|
||
return None
|
||
|
||
async def get(self, *_: object, **__: object) -> _FakeResp:
|
||
return _FakeResp()
|
||
|
||
with patch.object(httpx, "AsyncClient", lambda *a, **kw: _FakeClient()):
|
||
result = await _yandex_reverse(56.0, 60.0, api_key="fake")
|
||
|
||
assert result is None
|