"""Тесты `_nominatim_lookup` — city реально доходит до исходящего HTTP-запроса. #2593 (часть 3): Yandex Geocoder полностью удалён из проекта, вместе с ним ушли `_yandex_reverse.py` + `tests/test_audit_address_mismatch.py` + `tests/test_backfill_house_coords.py` — они были единственной проверкой, что `city`/`city_hint` реально передаётся во внешний геокодер, а не только влияет на cache-ключ (см. `tests/test_geocoder_city_hint.py`, который мокает `_nominatim_lookup`/`_nominatim_suggest` целиком и потому не видит их внутренности). Nominatim теперь единственный живой внешний провайдер (`_nominatim_lookup` docstring, `app/services/geocoder.py`) — этот файл закрывает получившуюся дыру: мокает HTTP-транспорт (`httpx.MockTransport`, паттерн из `test_geocoder_bbox.py` / `tests/services/test_dadata.py`) и проверяет параметр `q` реального исходящего GET-запроса к `nominatim.openstreetmap.org/search`. """ from __future__ import annotations import os os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from unittest.mock import patch import httpx from app.services.geocoder import _nominatim_lookup # EKB-центр (Плотинка) — внутри tight EKB bbox, `_nominatim_query` его примет # без похода во второй (typo-variant) тир. _SAMPLE_ITEM = { "lat": "56.838", "lon": "60.605", "class": "building", "display_name": "ул. Малышева, 30, Екатеринбург", "address": {"state": "Свердловская область"}, } # Snapshot реального httpx.AsyncClient ДО patch'а — фабрика ниже использует именно # его с подменённым transport (паттерн tests/services/test_dadata.py: избегает # recursion, если бы `httpx.AsyncClient` патчился поверх самого себя). _REAL_ASYNC_CLIENT = httpx.AsyncClient def _async_client_factory(transport: httpx.MockTransport): def factory(*_: object, **__: object) -> httpx.AsyncClient: return _REAL_ASYNC_CLIENT(transport=transport) return factory def _capturing_transport(captured_q: list[str]) -> httpx.MockTransport: def handler(request: httpx.Request) -> httpx.Response: captured_q.append(request.url.params.get("q", "")) return httpx.Response(200, json=[_SAMPLE_ITEM]) return httpx.MockTransport(handler) async def test_nominatim_lookup_sends_city_hint_in_query_param() -> None: """city_hint="Нижний Тагил" должен попасть в q= реального GET-запроса. Регрессия, о которой явно предупреждает docstring `_nominatim_lookup` (#2580 C): city_hint обязан влиять на сам запрос к провайдеру, не только на cache-ключ. """ captured_q: list[str] = [] transport = _capturing_transport(captured_q) with patch("app.services.geocoder.httpx.AsyncClient", _async_client_factory(transport)): result = await _nominatim_lookup("Ленина, 1", city_hint="Нижний Тагил") assert captured_q, "запрос к Nominatim не был отправлен" assert captured_q[0] == "Нижний Тагил, Ленина, 1" assert result is not None assert result.provider == "nominatim" async def test_nominatim_lookup_no_city_sends_bare_address() -> None: """Без city_hint и без маркера города в тексте — q= остаётся bare-адресом. Guard против регрессии в молчаливый дефолт на конкретный город (#2576/#2593) — до фикса #2576 сюда молча подставлялся "Екатеринбург". """ captured_q: list[str] = [] transport = _capturing_transport(captured_q) with patch("app.services.geocoder.httpx.AsyncClient", _async_client_factory(transport)): result = await _nominatim_lookup("Малышева, 30") assert captured_q == ["Малышева, 30"] assert result is not None