"""Тесты #2576 — geocoder больше НЕ подставляет "Екатеринбург" молча. Проблема (issue #2576 / эпик расширения на область): `_yandex_lookup`, `_yandex_suggest`, `_nominatim_suggest` при отсутствии маркера города/области в самом адресе всегда молча подставляли "Екатеринбург" — житель Нижнего Тагила, вводя «Ленина, 1», получал уверенно неверную цену по екатеринбургской улице Ленина (обе улицы называются одинаково). Покрывают: - `_resolve_city_for_geocode` — приоритет: маркер в адресе > `city_hint` > None. - `_yandex_lookup` — без города НЕ получает "Екатеринбург, "-префикс и bias смещён на всю область (не форсит ЕКБ-центр); с `city_hint` — префикс из hint'а; с явным "Екатеринбург" в адресе — поведение НЕ изменилось (как раньше). - `_yandex_suggest` (Tier 1 bounded) — то же самое, плюс bias/rspn. - `_nominatim_suggest` — то же самое (суффикс города, не префикс). - `geocode()` — `city_ambiguous=True` когда город не указан ни в адресе, ни в `city_hint`; `False` когда указан явно (текстом или через `city_hint`). - Cache-ключ (`_cache_key`) — разные `city_hint` для одного текста адреса НЕ делят одну запись кэша (regression test на cache poisoning). """ from __future__ import annotations import contextlib import os from unittest.mock import AsyncMock, MagicMock, patch os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db") import httpx import pytest from app.services.geocoder import ( EKB_BBOX, OBLAST66_VIEWBOX, GeocodeResult, GeocodeSuggestion, _cache_key, _nominatim_suggest, _resolve_city_for_geocode, _yandex_lookup, _yandex_suggest, geocode, suggest, ) # ── _resolve_city_for_geocode ──────────────────────────────────────────────── @pytest.mark.parametrize( "address,city_hint,expected", [ # Ни маркер, ни hint — раньше здесь молча подставлялся "Екатеринбург". ("Ленина, 1", None, (None, False)), ("Ленина, 1", "", (None, False)), ("Ленина, 1", " ", (None, False)), # city_hint передан явно вызывающим кодом. ("Ленина, 1", "Нижний Тагил", ("Нижний Тагил", True)), # Маркер уже в адресе — hint игнорируется (marker имеет приоритет). ("Нижний Тагил, Ленина, 1", "Серов", (None, True)), ("Екатеринбург, Малышева 30", None, (None, True)), ("Екатеринбург, Малышева 30", "Серов", (None, True)), ], ) def test_resolve_city_for_geocode( address: str, city_hint: str | None, expected: tuple[str | None, bool] ) -> None: assert _resolve_city_for_geocode(address, city_hint) == expected # ── _cache_key — cache poisoning между городами ────────────────────────────── def test_cache_key_without_hint_unchanged() -> None: """Без city_hint формат ключа НЕ меняется — backward-compat с накопленным кэшем.""" assert _cache_key("ленина, 1", None) == "ленина, 1" assert _cache_key("ленина, 1", "") == "ленина, 1" def test_cache_key_different_cities_do_not_collide() -> None: """#2576: разные города для одного текста адреса — разные cache-ключи.""" key_tagil = _cache_key("ленина, 1", "Нижний Тагил") key_ekb = _cache_key("ленина, 1", "Екатеринбург") key_none = _cache_key("ленина, 1", None) assert key_tagil != key_ekb assert key_tagil != key_none assert key_ekb != key_none def test_cache_key_hint_normalized() -> None: """city_hint нормализуется (case/whitespace) — не создаёт лишних ключей.""" assert _cache_key("ленина, 1", "Нижний Тагил") == _cache_key("ленина, 1", "нижний тагил ") # ── _yandex_lookup — query string + bias ───────────────────────────────────── _REAL_ASYNC_CLIENT = httpx.AsyncClient def _yandex_client_factory(transport: httpx.MockTransport): def factory(*_: object, **__: object) -> httpx.AsyncClient: return _REAL_ASYNC_CLIENT(transport=transport) return factory def _empty_yandex_payload() -> dict: return {"response": {"GeoObjectCollection": {"featureMember": []}}} async def test_yandex_lookup_no_city_no_prefix_and_oblast_bias() -> None: """#2576: без города в адресе/hint — Yandex-запрос БЕЗ "Екатеринбург, "-префикса, bias смещён на всю область (не форсит ЕКБ-центр по умолчанию).""" captured: dict[str, str | None] = {} def handler(request: httpx.Request) -> httpx.Response: captured["geocode"] = request.url.params.get("geocode") captured["ll"] = request.url.params.get("ll") captured["spn"] = request.url.params.get("spn") return httpx.Response(200, json=_empty_yandex_payload()) transport = httpx.MockTransport(handler) with patch("app.services.geocoder.httpx.AsyncClient", _yandex_client_factory(transport)): result = await _yandex_lookup("Ленина, 1", "fake-key") assert result is None # пустой featureMember assert captured["geocode"] == "Ленина, 1" assert "Екатеринбург" not in (captured["geocode"] or "") assert captured["ll"] == OBLAST66_VIEWBOX["ll"] assert captured["spn"] == OBLAST66_VIEWBOX["spn"] async def test_yandex_lookup_city_hint_prefix() -> None: """city_hint="Нижний Тагил" → запрос получает префикс из hint'а, не "Екатеринбург".""" captured: dict[str, str | None] = {} def handler(request: httpx.Request) -> httpx.Response: captured["geocode"] = request.url.params.get("geocode") captured["ll"] = request.url.params.get("ll") return httpx.Response(200, json=_empty_yandex_payload()) transport = httpx.MockTransport(handler) with patch("app.services.geocoder.httpx.AsyncClient", _yandex_client_factory(transport)): await _yandex_lookup("Ленина, 1", "fake-key", city_hint="Нижний Тагил") assert captured["geocode"] == "Нижний Тагил, Ленина, 1" # Тагил — не ЕКБ-контекст → bias не форсит ЕКБ-центр. assert captured["ll"] == OBLAST66_VIEWBOX["ll"] async def test_yandex_lookup_explicit_ekaterinburg_unchanged() -> None: """Явное "Екатеринбург" в адресе → поведение НЕ изменилось (как до фикса).""" captured: dict[str, str | None] = {} def handler(request: httpx.Request) -> httpx.Response: captured["geocode"] = request.url.params.get("geocode") captured["ll"] = request.url.params.get("ll") return httpx.Response(200, json=_empty_yandex_payload()) transport = httpx.MockTransport(handler) with patch("app.services.geocoder.httpx.AsyncClient", _yandex_client_factory(transport)): await _yandex_lookup("Екатеринбург, Малышева 30", "fake-key") assert captured["geocode"] == "Екатеринбург, Малышева 30" assert captured["ll"] == EKB_BBOX["ll"] # ── _yandex_suggest (Tier 1 bounded) ───────────────────────────────────────── async def test_yandex_suggest_no_city_uses_oblast_bounded() -> None: """#2576: автокомплит без города — bounded по ВСЕЙ области, без city-префикса (раньше Tier 1 всегда форсил bounded-ЕКБ с "Екатеринбург, ").""" calls: list[tuple[str, bool, dict[str, str] | None]] = [] async def fake_request(client, api_key, query, limit, bounded, bias=None): calls.append((query, bounded, bias)) return [] with patch( "app.services.geocoder._yandex_geocode_request", new=AsyncMock(side_effect=fake_request) ): result = await _yandex_suggest("Ленина, 1", "fake-key") assert result == [] assert calls, "expected at least one Yandex request" first_query, first_bounded, first_bias = calls[0] assert first_query == "Ленина, 1" assert "Екатеринбург" not in first_query assert first_bounded is True assert first_bias == OBLAST66_VIEWBOX async def test_yandex_suggest_city_hint_prefix_bounded() -> None: calls: list[tuple[str, bool, dict[str, str] | None]] = [] async def fake_request(client, api_key, query, limit, bounded, bias=None): calls.append((query, bounded, bias)) return [] with patch( "app.services.geocoder._yandex_geocode_request", new=AsyncMock(side_effect=fake_request) ): await _yandex_suggest("Ленина, 1", "fake-key", city_hint="Нижний Тагил") first_query, _, first_bias = calls[0] assert first_query == "Нижний Тагил, Ленина, 1" assert first_bias == OBLAST66_VIEWBOX async def test_yandex_suggest_explicit_ekb_unchanged() -> None: calls: list[tuple[str, bool, dict[str, str] | None]] = [] async def fake_request(client, api_key, query, limit, bounded, bias=None): calls.append((query, bounded, bias)) return [] with patch( "app.services.geocoder._yandex_geocode_request", new=AsyncMock(side_effect=fake_request) ): await _yandex_suggest("Екатеринбург, Малышева 30", "fake-key") first_query, _, first_bias = calls[0] assert first_query == "Екатеринбург, Малышева 30" assert first_bias == EKB_BBOX # ── _nominatim_suggest ─────────────────────────────────────────────────────── async def test_nominatim_suggest_no_city_dual_query_both_variants_sent() -> None: """#2580 (C2): без города — Nominatim получает ОБА запроса: bare (честный, без города) И ЕКБ-suffixed (majority-трафик). Не подмена одним вариантом — объединение (см. `test_nominatim_suggest_pobedy20_ekb_result_not_lost` ниже — чистое удаление суффикса теряло реальные ЕКБ-адреса). """ calls: list[str] = [] async def fake_query_multi(client, query, limit): calls.append(query) return [] with ( patch( "app.services.geocoder._nominatim_query_multi", new=AsyncMock(side_effect=fake_query_multi), ), patch("app.services.geocoder.asyncio.sleep", new=AsyncMock()), ): result = await _nominatim_suggest("Ленина, 1") assert result == [] assert "Ленина, 1" in calls # bare — честный, без города assert "Ленина, 1, Екатеринбург" in calls # ЕКБ-вариант — не потерян async def test_nominatim_suggest_city_hint_suffix() -> None: calls: list[str] = [] async def fake_query_multi(client, query, limit): calls.append(query) return [] with patch( "app.services.geocoder._nominatim_query_multi", new=AsyncMock(side_effect=fake_query_multi) ): await _nominatim_suggest("Ленина, 1", city_hint="Нижний Тагил") assert calls[0] == "Ленина, 1, Нижний Тагил" async def test_nominatim_suggest_explicit_ekb_unchanged() -> None: calls: list[str] = [] async def fake_query_multi(client, query, limit): calls.append(query) return [] with patch( "app.services.geocoder._nominatim_query_multi", new=AsyncMock(side_effect=fake_query_multi) ): await _nominatim_suggest("Екатеринбург, Малышева 30") assert calls[0] == "Екатеринбург, Малышева 30" async def test_nominatim_suggest_pobedy20_ekb_result_not_lost() -> None: """#2580 (C2) regression — "Победы 20" (реальный кейс с прода, подтверждён ревьюером): без города ЕКБ-кандидат ('Парк Победы' ЖК, Екатеринбург) должен остаться в подсказках, НЕ потеряться в пользу похожего street-level матча в Верхней Пышме. Симулирует реальные координаты: 'Победы 20, Екатеринбург' → 56.899, 60.579 (ЖК "Парк Победы", Екатеринбург) 'Победы 20' → 56.964, 60.610 (ул. Победы, Верхняя Пышма) """ ekb_item = { "place_id": 1001, "lat": "56.899", "lon": "60.579", "display_name": 'ЖК "Парк Победы", Орджоникидзевский район, Екатеринбург', "address": {"road": "Победы", "house_number": "20", "suburb": "Орджоникидзевский район"}, } pyshma_item = { "place_id": 1002, "lat": "56.964", "lon": "60.610", "display_name": "улица Победы, 20, Верхняя Пышма", "address": {"road": "улица Победы", "house_number": "20"}, } async def fake_query_multi(client, query, limit): if query.endswith(", Екатеринбург"): return [ekb_item] return [pyshma_item] with ( patch( "app.services.geocoder._nominatim_query_multi", new=AsyncMock(side_effect=fake_query_multi), ), patch("app.services.geocoder.asyncio.sleep", new=AsyncMock()), ): result = await _nominatim_suggest("Победы 20") assert result, "ожидались подсказки" ekb_hits = [r for r in result if r.lat == pytest.approx(56.899)] assert ekb_hits, "ЕКБ-кандидат ('Парк Победы') должен остаться в подсказках, не потеряться" # ЕКБ-кандидат идёт первым (majority-трафик — привычный порядок для основных пользователей). assert result[0].lat == pytest.approx(56.899) # Верхняя Пышма тоже осталась в списке — honest alternative, не подменена. pyshma_hits = [r for r in result if r.lat == pytest.approx(56.964)] assert pyshma_hits, "не-ЕКБ кандидат тоже должен остаться (объединение, не замена)" async def test_nominatim_suggest_dedupe_across_dual_query() -> None: """Если bare и ЕКБ-suffixed запросы возвращают ОДИН и тот же item (по place_id) — он не дублируется в итоговом списке подсказок.""" same_item = { "place_id": 42, "lat": "56.838", "lon": "60.605", "display_name": "ул. Малышева, 30, Екатеринбург", "address": {"road": "ул. Малышева", "house_number": "30"}, } async def fake_query_multi(client, query, limit): return [same_item] with ( patch( "app.services.geocoder._nominatim_query_multi", new=AsyncMock(side_effect=fake_query_multi), ), patch("app.services.geocoder.asyncio.sleep", new=AsyncMock()), ): result = await _nominatim_suggest("Малышева 30") assert len(result) == 1, "одинаковый place_id из обоих запросов не должен дублироваться" # ── geocode() — city_ambiguous flag ────────────────────────────────────────── def _geocode_patches(yandex_result: GeocodeResult | None): return ( patch("app.services.geocoder._cache_get", return_value=None), patch("app.services.geocoder._cache_put"), patch("app.services.geocoder._geoportal_house_match", return_value=None), patch("app.services.geocoder._cadastral_house_match", return_value=None), patch("app.services.geocoder._cadastral_forward_sync", return_value=[]), patch("app.services.geocoder._yandex_lookup", new=AsyncMock(return_value=yandex_result)), ) # ── C1 (#2580) — city_hint должен доходить до локальных ЕКБ-only тиров ────── async def test_geocode_city_hint_non_ekb_skips_local_ekb_tiers() -> None: """#2580 (C1): city_hint="Нижний Тагил" должен ЗАПРЕТИТЬ geoportal/cadastral (ЕКБ-only базы) — иначе они возвращают "точный" ЕКБ-хит для улицы, которая совпадает по названию, а `city_ambiguous=False` (хинт был!) делает такой неверный результат ложно-уверенным. Мок geoportal нарочно возвращает ЕКБ-хит (как в проде) — фикс должен НЕ дать ему сработать вообще. """ db = MagicMock() ekb_hit = GeocodeSuggestion( label="Ленина, 1, Екатеринбург", full_address="Ленина, 1, Екатеринбург", lat=56.83788, lon=60.58018, kind="house", ) tagil_result = GeocodeResult( lat=57.905, lon=59.950, full_address="Ленина, 1, Нижний Тагил", provider="yandex" ) with patch("app.services.geocoder.settings") as mock_settings: mock_settings.yandex_geocoder_api_key = "fake" with contextlib.ExitStack() as stack: stack.enter_context(patch("app.services.geocoder._cache_get", return_value=None)) stack.enter_context(patch("app.services.geocoder._cache_put")) geoportal_mock = stack.enter_context( patch("app.services.geocoder._geoportal_house_match", return_value=ekb_hit) ) cadastral_mock = stack.enter_context( patch("app.services.geocoder._cadastral_house_match", return_value=ekb_hit) ) stack.enter_context( patch("app.services.geocoder._cadastral_forward_sync", return_value=[]) ) stack.enter_context( patch( "app.services.geocoder._yandex_lookup", new=AsyncMock(return_value=tagil_result), ) ) result = await geocode("Ленина, 1", db, city_hint="Нижний Тагил") geoportal_mock.assert_not_called() cadastral_mock.assert_not_called() assert result is not None assert result.lat == pytest.approx(57.905) # Тагил, НЕ подставленный ЕКБ-хит (56.838) assert result.lat != pytest.approx(56.83788) async def test_geocode_real_ekb_address_still_uses_local_tiers() -> None: """Сквозной кейс: реальный ЕКБ-адрес БЕЗ city_hint по-прежнему резолвится через локальный geoportal-тир (ЕКБ-путь не деградировал после C1-фикса).""" db = MagicMock() ekb_hit = GeocodeSuggestion( label="Малышева, 30, Екатеринбург", full_address="Малышева, 30, Екатеринбург", lat=56.8389, lon=60.6057, kind="house", ) with contextlib.ExitStack() as stack: stack.enter_context(patch("app.services.geocoder._cache_get", return_value=None)) stack.enter_context(patch("app.services.geocoder._cache_put")) geoportal_mock = stack.enter_context( patch("app.services.geocoder._geoportal_house_match", return_value=ekb_hit) ) result = await geocode("Малышева, 30", db) geoportal_mock.assert_called_once() assert result is not None assert result.lat == pytest.approx(56.8389) assert result.confidence == "exact" assert result.city_ambiguous is True # город не указан — честный флаг async def test_suggest_city_hint_non_ekb_skips_cadastral_tier1() -> None: """#2580 (C1): suggest(city_hint="Нижний Тагил") — Tier 1 (кадастр ЕКБ) НЕ должен вызываться (раньше был мёртвым параметром для этого тира).""" db = MagicMock() with contextlib.ExitStack() as stack: house_mock = stack.enter_context(patch("app.services.geocoder._cadastral_house_match")) forward_mock = stack.enter_context(patch("app.services.geocoder._cadastral_forward_sync")) mock_settings = stack.enter_context(patch("app.services.geocoder.settings")) mock_settings.dadata_api_token = None mock_settings.yandex_geocoder_api_key = None stack.enter_context( patch("app.services.geocoder._nominatim_suggest", new=AsyncMock(return_value=[])) ) await suggest("Ленина, 1", db=db, city_hint="Нижний Тагил") house_mock.assert_not_called() forward_mock.assert_not_called() async def test_geocode_city_ambiguous_true_when_no_city_known() -> None: """Ни адрес, ни city_hint не называют город → city_ambiguous=True.""" db = MagicMock() yandex_result = GeocodeResult(lat=56.838, lon=60.605, full_address="что-то", provider="yandex") with patch("app.services.geocoder.settings") as mock_settings: mock_settings.yandex_geocoder_api_key = "fake" with contextlib.ExitStack() as stack: for cm in _geocode_patches(yandex_result): stack.enter_context(cm) result = await geocode("Малышева, 30", db) assert result is not None assert result.city_ambiguous is True async def test_geocode_city_ambiguous_false_when_marker_present() -> None: """Явный "Екатеринбург" в адресе → город указан пользователем → city_ambiguous=False.""" db = MagicMock() yandex_result = GeocodeResult( lat=56.838, lon=60.605, full_address="Екатеринбург, Малышева, 30", provider="yandex" ) with patch("app.services.geocoder.settings") as mock_settings: mock_settings.yandex_geocoder_api_key = "fake" with contextlib.ExitStack() as stack: for cm in _geocode_patches(yandex_result): stack.enter_context(cm) result = await geocode("Екатеринбург, Малышева, 30", db) assert result is not None assert result.city_ambiguous is False async def test_geocode_city_ambiguous_false_when_city_hint_given() -> None: """city_hint передан вызывающим кодом → город указан → city_ambiguous=False.""" db = MagicMock() yandex_result = GeocodeResult( lat=57.905, lon=59.950, full_address="Нижний Тагил, Ленина, 1", provider="yandex" ) with patch("app.services.geocoder.settings") as mock_settings: mock_settings.yandex_geocoder_api_key = "fake" with contextlib.ExitStack() as stack: for cm in _geocode_patches(yandex_result): stack.enter_context(cm) result = await geocode("Ленина, 1", db, city_hint="Нижний Тагил") assert result is not None assert result.city_ambiguous is False # ── geocode() — cache не смешивает города ──────────────────────────────────── async def test_geocode_cache_does_not_mix_cities() -> None: """#2576 regression: два города для одного текста адреса не делят cache-запись. Без city_hint-aware ключа второй вызов (Тагил) читал бы уже закэшированный (первым вызовом, ЕКБ) результат — координаты ЕКБ вместо Тагила. """ store: dict[str, GeocodeResult] = {} def fake_cache_get(db, addr_norm): return store.get(addr_norm) def fake_cache_put(db, addr_norm, result): store[addr_norm] = result async def fake_yandex_lookup(address, api_key, city_hint=None): if city_hint == "Нижний Тагил": return GeocodeResult( lat=57.905, lon=59.950, full_address="Нижний Тагил, Ленина, 1", provider="yandex" ) return GeocodeResult( lat=56.838, lon=60.605, full_address="Екатеринбург, Ленина, 1", provider="yandex" ) db = MagicMock() with patch("app.services.geocoder.settings") as mock_settings: mock_settings.yandex_geocoder_api_key = "fake" with contextlib.ExitStack() as stack: stack.enter_context( patch("app.services.geocoder._cache_get", side_effect=fake_cache_get) ) stack.enter_context( patch("app.services.geocoder._cache_put", side_effect=fake_cache_put) ) stack.enter_context( patch("app.services.geocoder._geoportal_house_match", return_value=None) ) stack.enter_context( patch("app.services.geocoder._cadastral_house_match", return_value=None) ) stack.enter_context( patch("app.services.geocoder._cadastral_forward_sync", return_value=[]) ) stack.enter_context( patch( "app.services.geocoder._yandex_lookup", new=AsyncMock(side_effect=fake_yandex_lookup), ) ) r_ekb = await geocode("Ленина, 1", db, city_hint="Екатеринбург") r_tagil = await geocode("Ленина, 1", db, city_hint="Нижний Тагил") # Повторный запрос ЕКБ — должен снова попасть в СВОЙ кэш (не Тагила). r_ekb_again = await geocode("Ленина, 1", db, city_hint="Екатеринбург") assert r_ekb is not None and r_tagil is not None and r_ekb_again is not None assert r_ekb.lat == pytest.approx(56.838) assert r_tagil.lat == pytest.approx(57.905) assert r_ekb_again.lat == pytest.approx(56.838) assert r_ekb.lat != r_tagil.lat # Два разных ключа реально осели в fake-store (не перезаписали друг друга). assert len(store) == 2