gendesign/tradein-mvp/backend/tests/test_geocode_reverse_api.py
Light1YT 42ab74e2e8
All checks were successful
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-backend (push) Successful in 48s
Deploy Trade-In / build-frontend (push) Successful in 1m44s
Deploy Trade-In / deploy (push) Successful in 39s
feat(tradein): MapPicker snap marker — Phase 5 of #582 (#594)
2026-05-27 09:30:17 +00:00

228 lines
8.2 KiB
Python
Raw Permalink 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