All checks were successful
CI / changes (pull_request) Successful in 9s
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m19s
Deep-review PR #2580 нашёл два блокера в предыдущем фиксе (#2576): C1 — city_hint не участвовал в гейте локальных ЕКБ-only тиров (geoportal/cadastral, `use_local_ekb` в geocode() и Tier 1 в suggest()). Явный city_hint="Нижний Тагил" для "Ленина 1" всё равно попадал на ЕКБ-only базы, получал "точный" ЕКБ-хит и при этом city_ambiguous=False (хинт ведь был) — то есть система теперь ложно-уверенно утверждала неверный город. Фикс: city_hint участвует в той же проверке _names_non_ekb_city (гейт/ gazetteer #2582 не трогаю — только добавляю вход). C2 — снятие суффикса ", Екатеринбург" в _nominatim_suggest для случая "город неизвестен" регрессило часть реальных ЕКБ-адресов: без текстовой подсказки о городе Nominatim иногда предпочитает street-level матч в соседнем городе-спутнике (эмпирика ревьюера: "Победы 20" без суффикса → Верхняя Пышма вместо ЖК "Парк Победы" в Екатеринбурге). Решение — dual-query: bare (честный, без города) И ЕКБ-suffixed запросы объединяются (не заменяют друг друга), оба честных кандидата остаются в подсказках, пользователь выбирает сам. Extra round-trip только для последнего fallback-тира (cadastral/DaData/Yandex уже не сработали) — не задевает основной трафик. Заодно (🟠, дешёвая правка): city_hint прокинут в _nominatim_lookup — с тех пор как Yandex-ключ недействителен (#2585), это единственный живой внешний провайдер, и его tie-break (предпочитает tight-ЕКБ bbox) без города не различает одноимённые улицы внутри региона. 4 новых теста (C1×2, C2×2 + dedupe) — проверено что все 4 падают на коде до этого коммита через git stash (только geocoder.py, тесты оставлены).
584 lines
27 KiB
Python
584 lines
27 KiB
Python
"""Тесты #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
|