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