gendesign/tradein-mvp/backend/tests/test_geocode_reverse_api.py
Light1YT 74026ef1d9 feat(tradein): MapPicker snap marker на canonical building point (PR H, #582 Phase 5)
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).
2026-05-27 14:29:51 +05:00

228 lines
8.2 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.

"""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