chore(tradein/geocoder): удалить Яндекс-геокодер (#2593)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
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 2m28s

Убирает ядро Yandex Geocoder (forward/reverse/suggest lookups + region-check
+ bias-хелперы + EKB_BBOX dict) из app/services/geocoder.py — Yandex demo-key
исчерпан, Nominatim/DaData/локальные ЕКБ-тиры (geoportal/cadastral) остаются
единственными живыми провайдерами. Цепочка тиров после удаления: кэш →
геопортал ЕКБ → кадастр (house-match) → кадастр (raw) → Nominatim; в
подсказках дополнительно DaData.

НЕ затронуто (намеренно): Yandex.Недвижимость как источник объявлений
(source='yandex', yandex_city_sweep*, providers/yandex/serp.py:geocoderAddress),
Avito geocoder (providers/avito/imv.py:_geocode), EKB_BBOX_TIGHT/WIDE,
_nominatim_region_ok, scripts/*_yandex_reverse.py и их тесты, tests/fixtures/
yandex_geocode_sample.json (всё ещё используется test_audit_address_mismatch.py).

_SNAP_PRECISIONS оставлен с "exact" (недостижимо без Yandex-tier, но дёшево
хранить — parity с frontend MapPicker.tsx SNAP_PRECISIONS и не ломает
test_snap_precision_useful_exact_and_number).
This commit is contained in:
bot-backend 2026-07-31 21:22:09 +03:00
parent 57fe26d3f3
commit 3d075632a9
12 changed files with 168 additions and 1013 deletions

View file

@ -124,11 +124,11 @@ class ReverseResponse(BaseModel):
precision: str = Field( precision: str = Field(
..., ...,
description=( description=(
"Yandex-style: exact/number/street/range/near/locality/other/cadastral. " "exact/number/street/range/near/locality/other/cadastral. "
"Фронт двигает marker только если exact/number/cadastral." "Фронт двигает marker только если exact/number/cadastral."
), ),
) )
provider: str = Field(..., description="cadastral | yandex | nominatim") provider: str = Field(..., description="cadastral | nominatim")
@router.get("/reverse", response_model=ReverseResponse) @router.get("/reverse", response_model=ReverseResponse)

View file

@ -71,11 +71,6 @@ class Settings(BaseSettings):
default=300, validation_alias="LOGIN_RATE_LIMIT_WINDOW_S" default=300, validation_alias="LOGIN_RATE_LIMIT_WINDOW_S"
) )
# Geocoder. Env var name `YANDEX_GEOCODER_API_KEY` — consistent с scripts/
# backfill_house_coords.py + audit_address_mismatch.py + main backend
# OpenRouteService_API_KEY pattern. Renamed from YANDEX_GEOCODER_KEY (PR F).
yandex_geocoder_api_key: str | None = None # 25K req/day free после регистрации
yandex_suggest_key: str | None = None # для frontend autocomplete (proxy через backend)
# для User-Agent в Nominatim (Nominatim Usage Policy) # для User-Agent в Nominatim (Nominatim Usage Policy)
contact_email: str = "erginrajpopxbe@outlook.com" contact_email: str = "erginrajpopxbe@outlook.com"

View file

@ -8,6 +8,7 @@ This helper:
- applies idempotent CREATE or ALTER mapping on every backend startup so - applies idempotent CREATE or ALTER mapping on every backend startup so
password rotation through .env.runtime is picked up after restart. password rotation through .env.runtime is picked up after restart.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
@ -37,7 +38,7 @@ def ensure_fdw_user_mapping(db: Session) -> None:
logger.warning( logger.warning(
"GENDESIGN_FDW_PASSWORD not set — skipping FDW user mapping " "GENDESIGN_FDW_PASSWORD not set — skipping FDW user mapping "
"(gendesign_cad_buildings queries will fail; cadastral lookups will " "(gendesign_cad_buildings queries will fail; cadastral lookups will "
"fall back to Yandex/Nominatim)" "fall back to Nominatim)"
) )
return return
@ -62,16 +63,20 @@ def ensure_fdw_user_mapping(db: Session) -> None:
).first() ).first()
if exists is None: if exists is None:
db.execute(text( db.execute(
f"CREATE USER MAPPING FOR CURRENT_USER SERVER gendesign_remote " text(
f"OPTIONS (user 'tradein_fdw_reader', password '{password}')" f"CREATE USER MAPPING FOR CURRENT_USER SERVER gendesign_remote "
)) f"OPTIONS (user 'tradein_fdw_reader', password '{password}')"
)
)
logger.info("created FDW user mapping for gendesign_remote") logger.info("created FDW user mapping for gendesign_remote")
else: else:
db.execute(text( db.execute(
f"ALTER USER MAPPING FOR CURRENT_USER SERVER gendesign_remote " text(
f"OPTIONS (SET password '{password}')" f"ALTER USER MAPPING FOR CURRENT_USER SERVER gendesign_remote "
)) f"OPTIONS (SET password '{password}')"
)
)
logger.info("refreshed FDW user mapping password for gendesign_remote") logger.info("refreshed FDW user mapping password for gendesign_remote")
try: try:

View file

@ -3201,12 +3201,13 @@ async def estimate_quality(
Returns: Returns:
AggregatedEstimate с estimate_id, медианой, диапазоном, аналогами. AggregatedEstimate с estimate_id, медианой, диапазоном, аналогами.
""" """
# 1. Geocode (#654: time-budgeted — Yandex/Nominatim retry chain can stack # 1. Geocode (#654: time-budgeted — Nominatim retry chain can stack
# multiple network round-trips + 1s Nominatim rate-limit sleeps). # multiple network round-trips + 1s Nominatim rate-limit sleeps).
geo: GeocodeResult | None = None geo: GeocodeResult | None = None
# Variant A: trust client-provided coords (resolved by autocomplete/map) when present # Variant A: trust client-provided coords (resolved by autocomplete/map) when present
# and inside the oblast bbox — skips the geocode() chain that fails on DaData-format # and inside the oblast bbox — skips the geocode() chain that fails on DaData-format
# addresses with the Yandex key dead. Out-of-bbox / partial → ignore, geocode normally. # addresses (#2593: Yandex Geocoder, the previous fallback for those, removed).
# Out-of-bbox / partial → ignore, geocode normally.
# (oblast C2): was tight EKB-only bbox (60.40-60.85 / 56.65-56.95) — widened to # (oblast C2): was tight EKB-only bbox (60.40-60.85 / 56.65-56.95) — widened to
# geocoder.is_within_oblast66_bbox (region 66) so client-coords from oblast towns also # geocoder.is_within_oblast66_bbox (region 66) so client-coords from oblast towns also
# get this perf fast-path instead of always paying the geocode() round-trip. Perf-only, # get this perf fast-path instead of always paying the geocode() round-trip. Perf-only,

View file

@ -2,7 +2,7 @@
Стратегия: Стратегия:
- Cache lookup в `geocode_cache` (Postgres) TTL 90 дней - Cache lookup в `geocode_cache` (Postgres) TTL 90 дней
- Cache miss Yandex Geocoder (если есть key) fallback Nominatim - Cache miss локальные ЕКБ-тиры (геопортал/кадастр) fallback Nominatim
- Результат сохраняется в кэш для последующих вызовов - Результат сохраняется в кэш для последующих вызовов
Используется в: Используется в:
@ -36,7 +36,7 @@ class GeocodeResult:
lat: float lat: float
lon: float lon: float
full_address: str full_address: str
provider: Literal["nominatim", "yandex", "cache"] provider: Literal["nominatim", "cache"]
confidence: Literal["exact", "approximate", "locality"] = "approximate" confidence: Literal["exact", "approximate", "locality"] = "approximate"
# #2576: True если город НЕ был указан пользователем (ни в тексте адреса, ни # #2576: True если город НЕ был указан пользователем (ни в тексте адреса, ни
# через `city_hint`) — т.е. итоговый город результата определил провайдер # через `city_hint`) — т.е. итоговый город результата определил провайдер
@ -88,9 +88,10 @@ def is_within_ekb_bbox_wide(lat: float, lon: float) -> bool:
# bbox = (lat_min, lat_max, lon_min, lon_max). # bbox = (lat_min, lat_max, lon_min, lon_max).
OBLAST66_BBOX = (55.8, 62.2, 56.7, 66.6) OBLAST66_BBOX = (55.8, 62.2, 56.7, 66.6)
# Nominatim/Yandex viewport для областного (не только ЕКБ) поиска — тот же # Nominatim viewport для областного (не только ЕКБ) поиска — покрывает всю
# формат что EKB_BBOX ниже, но покрывает всю область. viewbox — Nominatim # область. viewbox — Nominatim-формат (lon1,lat1,lon2,lat2, NW→SE); ll/spn —
# (lon1,lat1,lon2,lat2, NW→SE); ll/spn — Yandex центр+охват (lon,lat). # legacy-поля того же словаря (были общим форматом с удалённым #2593 Yandex
# Geocoder-тиром), не используются Nominatim-кодом напрямую.
OBLAST66_VIEWBOX = { OBLAST66_VIEWBOX = {
"viewbox": "56.7,62.2,66.6,55.8", "viewbox": "56.7,62.2,66.6,55.8",
"ll": "61.65,59.0", "ll": "61.65,59.0",
@ -104,10 +105,9 @@ def is_within_oblast66_bbox(lat: float, lon: float) -> bool:
# Крупные города области (кроме ЕКБ) — НЕ исчерпывающий список (в области ~30 # Крупные города области (кроме ЕКБ) — НЕ исчерпывающий список (в области ~30
# городов), покрывает основные региональные центры. Используется чтобы: # городов), покрывает основные региональные центры. Используется чтобы не
# (a) не навязывать "Екатеринбург, " в запрос, если пользователь уже указал # навязывать "Екатеринбург, " в запрос, если пользователь уже указал другой
# другой город региона (геокодер forward/suggest prefix-логика); # город региона (геокодер forward/suggest prefix-логика).
# (b) как known-locality set в _yandex_reverse при перестановке "улица, город".
SVERDLOVSK_OBLAST_CITIES = frozenset( SVERDLOVSK_OBLAST_CITIES = frozenset(
{ {
"екатеринбург", "екатеринбург",
@ -198,8 +198,8 @@ def _has_oblast_marker(text_lower: str) -> bool:
def _resolve_city_for_geocode(address: str, city_hint: str | None) -> tuple[str | None, bool]: def _resolve_city_for_geocode(address: str, city_hint: str | None) -> tuple[str | None, bool]:
"""Определяет, какой город подставлять в запрос внешнему провайдеру (Yandex/ """Определяет, какой город подставлять в запрос внешнему провайдеру
Nominatim), когда сам текст адреса города не называет. (Nominatim), когда сам текст адреса города не называет.
Приоритет: Приоритет:
1. Адрес уже содержит маркер города/области региона 66 (`_has_oblast_marker`) 1. Адрес уже содержит маркер города/области региона 66 (`_has_oblast_marker`)
@ -211,8 +211,8 @@ def _resolve_city_for_geocode(address: str, city_hint: str | None) -> tuple[str
для жителей других городов области это давало уверенно неверную цену для жителей других городов области это давало уверенно неверную цену
(«Ленина, 1» в Нижнем Тагиле снапалось на екатеринбургскую улицу Ленина, («Ленина, 1» в Нижнем Тагиле снапалось на екатеринбургскую улицу Ленина,
обе улицы называются одинаково). Теперь НЕ подставляем никакой город обе улицы называются одинаково). Теперь НЕ подставляем никакой город
провайдер ищет по OBLAST66 viewbox/bbox (см. `_yandex_bias`, провайдер ищет по OBLAST66 viewbox/bbox (см. `OBLAST66_VIEWBOX`), без
`OBLAST66_VIEWBOX`), без привязки к конкретному городу. Возвращает привязки к конкретному городу. Возвращает
(None, False) второй элемент False сигнализирует, что город (None, False) второй элемент False сигнализирует, что город
пользователь НЕ указывал (источник `GeocodeResult.city_ambiguous`). пользователь НЕ указывал (источник `GeocodeResult.city_ambiguous`).
@ -227,23 +227,6 @@ def _resolve_city_for_geocode(address: str, city_hint: str | None) -> tuple[str
return None, False return None, False
def _yandex_bias(address: str, city_hint: str | None) -> dict[str, str]:
"""ll/spn soft-bias для Yandex Geocoder.
ЕКБ-центр (`EKB_BBOX`) ТОЛЬКО если контекст однозначно про Екатеринбург
(явное слово в адресе либо `city_hint`). Иначе центр всей области
(`OBLAST66_VIEWBOX`): раньше bias всегда указывал на ЕКБ независимо от
того, назвал ли пользователь город (#2576) — молчаливый перекос в пользу
ЕКБ даже без текстового префикса "Екатеринбург, ".
"""
normalized = " ".join(address.lower().split())
if _EKATERINBURG_RE.search(normalized):
return EKB_BBOX
if city_hint and _EKATERINBURG_RE.search(" ".join(city_hint.lower().split())):
return EKB_BBOX
return OBLAST66_VIEWBOX
# Города региона 66 КРОМЕ Екатеринбурга — используется чтобы отсечь EKB-only # Города региона 66 КРОМЕ Екатеринбурга — используется чтобы отсечь EKB-only
# локальные тиры (geoportal/cadastral, см. `geocode()`) от адреса другого # локальные тиры (geoportal/cadastral, см. `geocode()`) от адреса другого
# города области. re.escape на элементах SVERDLOVSK_OBLAST_CITIES-{ekb}. # города области. re.escape на элементах SVERDLOVSK_OBLAST_CITIES-{ekb}.
@ -585,8 +568,8 @@ async def _nominatim_lookup(address: str, city_hint: str | None = None) -> Geoco
(предпочитает tight-ЕКБ bbox), который для одноимённых улиц ВНУТРИ региона (предпочитает tight-ЕКБ bbox), который для одноимённых улиц ВНУТРИ региона
(напр. "Ленина" и в Екатеринбурге, и в с. Свердловское) не различает город. (напр. "Ленина" и в Екатеринбурге, и в с. Свердловское) не различает город.
Эмпирически подтверждено: "Ленина 1" без города случайное село внутри Эмпирически подтверждено: "Ленина 1" без города случайное село внутри
области; "Нижний Тагил, Ленина 1" корректно резолвится. Раз Yandex-ключ области; "Нижний Тагил, Ленина 1" корректно резолвится. Nominatim
сейчас недействителен (#2585), это единственный живой внешний провайдер единственный живой внешний провайдер (#2593: Yandex Geocoder удалён)
city_hint должен реально влиять на его результат, не только на кэш-ключ. city_hint должен реально влиять на его результат, не только на кэш-ключ.
""" """
headers = { headers = {
@ -625,140 +608,7 @@ async def _nominatim_lookup(address: str, city_hint: str | None = None) -> Geoco
) )
def _yandex_region_ok(geo_object: dict) -> bool | None:
"""Кросс-чек региона по Yandex `AdministrativeAreaName`.
True/False если Yandex вернул поле и оно однозначно про/не про Свердловскую
область. None если поля нет / оно не строка тогда accept-логика падает
обратно на bbox. Ловит Тюмень/Шадринск/Кунгур/Снежинск они внутри
генерального OBLAST66_BBOX (специально щедрого), но их регион явно другой.
"""
try:
admin_area = (
geo_object.get("metaDataProperty", {})
.get("GeocoderMetaData", {})
.get("AddressDetails", {})
.get("Country", {})
.get("AdministrativeArea", {})
.get("AdministrativeAreaName")
)
except AttributeError:
return None
if not isinstance(admin_area, str) or not admin_area:
return None
return "свердловск" in admin_area.lower()
# ── Provider: Yandex Geocoder (требует key, лучшее покрытие РФ) ─────────────
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
async def _yandex_lookup(
address: str, api_key: str, city_hint: str | None = None
) -> GeocodeResult | None:
"""Yandex Geocoder — 25K req/day free для самопод, лучше РФ.
Docs: https://yandex.ru/dev/maps/geocoder/doc/desc/concepts/input_params.html
Запрашиваем с ll+spn (центр ЕКБ, если контекст ЕКБ, иначе центр всей
области см. `_yandex_bias`) для приоритизации местных результатов, но
БЕЗ rspn чтобы fuzzy matching работал при опечатках.
"""
# Город в запрос подставляем ТОЛЬКО если он известен (адрес уже называет
# город/область региона 66, либо явный `city_hint`) — раньше (#2576) сюда
# молча подставлялся "Екатеринбург" при отсутствии обоих, что давало
# уверенно неверную цену жителям других городов области.
city, _ = _resolve_city_for_geocode(address, city_hint)
geocode_query = f"{city}, {address}" if city else address
bias = _yandex_bias(address, city_hint)
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
"https://geocode-maps.yandex.ru/1.x/",
params={
"apikey": api_key,
"geocode": geocode_query,
"format": "json",
"results": 5, # берем top-5, отфильтруем по ЕКБ bbox ниже
"lang": "ru_RU",
"ll": bias["ll"],
"spn": bias["spn"],
},
)
response.raise_for_status()
data = response.json()
members = data.get("response", {}).get("GeoObjectCollection", {}).get("featureMember", [])
if not members:
return None
# Фильтруем top-5 по bbox области (region 66) — игнорируем Челябинск/Уфу/Москву
# при опечатке, но принимаем легитимные Нижний Тагил/Серов/etc. Two-pass
# tie-break: tight-ЕКБ приоритетнее (byte-identical для ЕКБ-запросов, даже
# если Yandex ранжировал его не первым), иначе первый oblast-bbox кандидат.
# Region cross-check (AdministrativeAreaName) отсекает кандидатов ЯВНО из
# другого региона (Тюмень и т.п. попадают в генеральный bbox координатно).
best = None
oblast_fallback = None
for m in members:
obj = m.get("GeoObject", {})
try:
lon_str, lat_str = obj["Point"]["pos"].split()
lat_f, lon_f = float(lat_str), float(lon_str)
except Exception:
continue
if _yandex_region_ok(obj) is False:
continue # регион явно не Свердловская область — не рассматриваем
if is_within_ekb_bbox(lat_f, lon_f):
best = obj
break
if oblast_fallback is None and is_within_oblast66_bbox(lat_f, lon_f):
oblast_fallback = obj
if best is None:
best = oblast_fallback
if best is None:
# Никто из top-5 не попал ни в tight, ни в oblast bbox (или все явно
# отсеяны по региону) → берём первый «как есть», НО только если его
# регион не опровергнут явно — иначе весь смысл region cross-check
# теряется на garbage-query, где top-1 — гарантированно не тот регион
# (например, Тюмень).
first_obj = members[0]["GeoObject"]
if _yandex_region_ok(first_obj) is not False:
best = first_obj
if best is None:
return None
lon_str, lat_str = best["Point"]["pos"].split()
precision_raw = (
best.get("metaDataProperty", {}).get("GeocoderMetaData", {}).get("precision", "other")
)
confidence_map = {
"exact": "exact",
"number": "exact",
"near": "approximate",
"range": "approximate",
"street": "approximate",
}
return GeocodeResult(
lat=float(lat_str),
lon=float(lon_str),
full_address=best.get("metaDataProperty", {})
.get("GeocoderMetaData", {})
.get("text", address),
provider="yandex",
confidence=confidence_map.get(precision_raw, "approximate"),
)
# ── Suggest (автокомплит) ─────────────────────────────────────────────────── # ── Suggest (автокомплит) ───────────────────────────────────────────────────
# ЕКБ bounding box (приблизительно): юг 56.65, запад 60.40, север 56.95, восток 60.85
# Это ЕКБ fast-path (Yandex rspn=1 bounded tier + soft-bias ll/spn). Для областного
# охвата (region 66) — см. OBLAST66_VIEWBOX выше.
EKB_BBOX = {
"viewbox": "60.40,56.95,60.85,56.65", # Nominatim format: lon1,lat1,lon2,lat2 (NW,SE)
"ll": "60.605,56.838", # Yandex center (lon,lat)
"spn": "0.45,0.30", # Yandex span (lon,lat)
}
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@ -769,46 +619,14 @@ class GeocodeSuggestion:
lon: float lon: float
kind: str # 'house' / 'street' / 'locality' kind: str # 'house' / 'street' / 'locality'
# ГАР OBJECTGUID (ФИАС) дома — заполняется ТОЛЬКО для house-level кандидатов # ГАР OBJECTGUID (ФИАС) дома — заполняется ТОЛЬКО для house-level кандидатов
# DaData-тира (fias_level 8/9). У Yandex/Nominatim/cadastral-тиров и у # DaData-тира (fias_level 8/9). У Nominatim/cadastral-тиров и у
# street/locality-кандидатов остаётся None (нет стабильного house-fias). # street/locality-кандидатов остаётся None (нет стабильного house-fias).
fias_id: str | None = None fias_id: str | None = None
def _parse_yandex_members(members: list[dict]) -> list[GeocodeSuggestion]:
"""Yandex geocode_members → list[GeocodeSuggestion]. Чистим описание от мусора."""
out: list[GeocodeSuggestion] = []
for m in members:
obj = m.get("GeoObject", {})
try:
lon_str, lat_str = obj["Point"]["pos"].split()
meta = obj.get("metaDataProperty", {}).get("GeocoderMetaData", {})
kind = meta.get("kind", "other")
full = meta.get("text", obj.get("name", ""))
name = obj.get("name", full)
desc = obj.get("description", "")
desc_parts = [
p.strip()
for p in desc.split(",")
if p.strip() and p.strip() not in {"Россия", "Свердловская область"}
]
label = name if not desc_parts else f"{name} · {', '.join(desc_parts)}"
out.append(
GeocodeSuggestion(
label=label,
full_address=full,
lat=float(lat_str),
lon=float(lon_str),
kind=kind,
)
)
except Exception:
continue
return out
# ── DaData suggest wrapper ────────────────────────────────────────────────── # ── DaData suggest wrapper ──────────────────────────────────────────────────
# Маппинг DaData kind → GeocodeSuggestion.kind (внутри geocoder используется # Маппинг DaData kind → GeocodeSuggestion.kind (внутри geocoder используется
# 'locality' вместо 'city' — consistent с Yandex/Nominatim ветками). # 'locality' вместо 'city' — consistent с Nominatim-веткой).
_DADATA_KIND_MAP = {"house": "house", "street": "street", "city": "locality"} _DADATA_KIND_MAP = {"house": "house", "street": "street", "city": "locality"}
@ -847,101 +665,6 @@ async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]
return out return out
async def _yandex_geocode_request(
client: httpx.AsyncClient,
api_key: str,
query: str,
limit: int,
bounded: bool,
bias: dict[str, str] | None = None,
) -> list[dict]:
"""Single Yandex Geocoder request — bounded=True → строго внутри `bias` bbox через rspn=1.
`bias` ll/spn (`EKB_BBOX` или `OBLAST66_VIEWBOX`). По умолчанию `EKB_BBOX`
(backward-compat для вызовов без явного bias).
"""
b = bias or EKB_BBOX
params: dict[str, str] = {
"apikey": api_key,
"geocode": query,
"format": "json",
"results": str(limit),
"lang": "ru_RU",
"ll": b["ll"],
"spn": b["spn"],
}
if bounded:
params["rspn"] = "1"
response = await client.get("https://geocode-maps.yandex.ru/1.x/", params=params)
response.raise_for_status()
data = response.json()
return data.get("response", {}).get("GeoObjectCollection", {}).get("featureMember", [])
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4))
async def _yandex_suggest(
query: str, api_key: str, limit: int = 8, city_hint: str | None = None
) -> list[GeocodeSuggestion]:
"""Yandex Geocoder с авто-fallback на typo-tolerant режим.
Tier 1: bounded (rspn=1) быстрый путь. Bounded на ЕКБ, если контекст
однозначно про ЕКБ (текст адреса/`city_hint`), иначе bounded на ВСЮ область
(`OBLAST66_VIEWBOX`) раньше (#2576) Tier 1/2 всегда форсили bounded-ЕКБ
с "Екатеринбург, "-префиксом даже когда пользователь не называл город, из-за
чего автокомплит для жителей других городов области либо не находил ничего,
либо подсовывал ЕКБ-варианты вместо нужного города.
Tier 2: bounded на typo-variants (удвоение согласных), тот же bias.
Tier 3: без rspn fuzzy по всей стране, фильтр результатов по bbox области
(region 66) ловит легитимные Нижний Тагил/Серов/etc, которые Tier 1/2
(bounded) структурно вернуть не могут при неверном bias.
"""
city, _ = _resolve_city_for_geocode(query, city_hint)
prefixed_query = f"{city}, {query}" if city else query
bias = _yandex_bias(query, city_hint)
async with httpx.AsyncClient(timeout=8.0) as client:
# Tier 1: strict bounded на оригинал
members = await _yandex_geocode_request(
client,
api_key,
prefixed_query,
limit,
bounded=True,
bias=bias,
)
results = _parse_yandex_members(members)
if results:
return results
# Tier 2: bounded на typo-варианты (тот же bias)
for variant in _typo_variants(query, limit=4):
variant_city, _ = _resolve_city_for_geocode(variant, city_hint)
variant_query = f"{variant_city}, {variant}" if variant_city else variant
members = await _yandex_geocode_request(
client,
api_key,
variant_query,
limit,
bounded=True,
bias=bias,
)
results = _parse_yandex_members(members)
if results:
return results
# Tier 3: без rspn — даём fuzzy сделать своё дело, фильтр по bbox области
members = await _yandex_geocode_request(
client,
api_key,
prefixed_query,
limit,
bounded=False,
bias=bias,
)
results = _parse_yandex_members(members)
in_oblast = [r for r in results if is_within_oblast66_bbox(r.lat, r.lon)]
return in_oblast
async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: int) -> list[dict]: async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: int) -> list[dict]:
"""Один Nominatim search с фильтром по bbox области (region 66). Возвращает up to N items.""" """Один Nominatim search с фильтром по bbox области (region 66). Возвращает up to N items."""
response = await client.get( response = await client.get(
@ -1024,7 +747,8 @@ async def _nominatim_query_city_aware(
async def _nominatim_suggest( async def _nominatim_suggest(
query: str, limit: int = 8, city_hint: str | None = None query: str, limit: int = 8, city_hint: str | None = None
) -> list[GeocodeSuggestion]: ) -> list[GeocodeSuggestion]:
"""Nominatim в режиме suggest. С typo-fallback (для случаев когда Yandex недоступен). """Nominatim в режиме suggest. С typo-fallback (для случаев когда оригинальный
запрос ничего не находит).
Суффикс города навязывается, только если он известен: адрес уже называет Суффикс города навязывается, только если он известен: адрес уже называет
город/область региона 66, либо передан явный `city_hint`. Если город город/область региона 66, либо передан явный `city_hint`. Если город
@ -1384,16 +1108,17 @@ async def suggest(
query: str, db: Session | None = None, limit: int = 8, city_hint: str | None = None query: str, db: Session | None = None, limit: int = 8, city_hint: str | None = None
) -> list[GeocodeSuggestion]: ) -> list[GeocodeSuggestion]:
"""Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик, """Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик,
остаётся быстрым fast-path). Cadastral FDW DaData Yandex Nominatim []. остаётся быстрым fast-path). Cadastral FDW DaData Nominatim [].
db: если передан cadastral lookup через gendesign_cad_buildings (первый tier). db: если передан cadastral lookup через gendesign_cad_buildings (первый tier).
city_hint: город, если известен вызывающему коду (#2576) — прокидывается в city_hint: город, если известен вызывающему коду (#2576) — прокидывается в
Yandex/Nominatim тиры, чтобы НЕ подставлять "Екатеринбург" молча, когда Nominatim-тир, чтобы НЕ подставлять "Екатеринбург" молча, когда
пользователь его не называл. Опционально, backward-compatible (None пользователь его не называл. Опционально, backward-compatible (None
прежнее поведение минус молчаливый EKB-дефолт, см. `_resolve_city_for_geocode`). прежнее поведение минус молчаливый EKB-дефолт, см. `_resolve_city_for_geocode`).
DaData /suggest (PR Q2) token-only, 10k/день, заменяет Yandex который DaData /suggest (PR Q2) token-only, 10k/день, лучший fit для РФ-адресов
заблокирован (1k/день demo limit исчерпан). DaData region-constraint уже (#2593: Yandex Geocoder, который был primary external provider до DaData,
охватывает всю область (не только ЕКБ) city_hint ей не нужен. удалён). DaData region-constraint уже охватывает всю область (не только
ЕКБ) city_hint ей не нужен.
Без кэша (дешёво, провайдеры толерируют автокомплит-запросы). Без кэша (дешёво, провайдеры толерируют автокомплит-запросы).
""" """
if not query or len(query.strip()) < 2: if not query or len(query.strip()) < 2:
@ -1405,7 +1130,7 @@ async def suggest(
# незнакомый — структурный маркер (`_names_unrecognized_locality`), ИЛИ # незнакомый — структурный маркер (`_names_unrecognized_locality`), ИЛИ
# `city_hint` называет город региона 66, отличный от ЕКБ (#2580 C1 — # `city_hint` называет город региона 66, отличный от ЕКБ (#2580 C1 —
# иначе хинт мёртвый параметр для этого тира, см. `_ekb_local_tiers_allowed` # иначе хинт мёртвый параметр для этого тира, см. `_ekb_local_tiers_allowed`
# и `geocode()` ниже — тот же гейт). Внешние тиры (2/3/4 ниже) не гейтим — # и `geocode()` ниже — тот же гейт). Внешние тиры (2/3 ниже) не гейтим —
# они уже oblast-aware. # они уже oblast-aware.
if db is not None and _ekb_local_tiers_allowed(query, city_hint): if db is not None and _ekb_local_tiers_allowed(query, city_hint):
# 1a. Anchored house-match: парсим street+house → точный матч по дом-маркеру. # 1a. Anchored house-match: парсим street+house → точный матч по дом-маркеру.
@ -1422,28 +1147,16 @@ async def suggest(
return cad_results return cad_results
# Tier 2: DaData /suggest — token-only (X-Secret не нужен), 10k/день free, # Tier 2: DaData /suggest — token-only (X-Secret не нужен), 10k/день free,
# лучший fit для РФ адресов. Заменил Yandex как primary external provider # лучший fit для РФ адресов.
# после того как demo-key Yandex (1k/день) был исчерпан.
if settings.dadata_api_token: if settings.dadata_api_token:
try: try:
dadata_results = await _dadata_suggest(query, limit) dadata_results = await _dadata_suggest(query, limit)
if dadata_results: if dadata_results:
return dadata_results return dadata_results
except Exception: except Exception:
logger.exception("dadata suggest failed, falling back to yandex") logger.exception("dadata suggest failed, falling back to nominatim")
# Tier 3: Yandex (legacy — оставляем как fallback, если key есть) # Tier 3: Nominatim (последний fallback — OSM, без ключа)
if settings.yandex_geocoder_api_key:
try:
results = await _yandex_suggest(
query, settings.yandex_geocoder_api_key, limit, city_hint=city_hint
)
if results:
return results
except Exception:
logger.exception("yandex suggest failed, falling back to nominatim")
# Tier 4: Nominatim (последний fallback — OSM, без ключа)
try: try:
return await _nominatim_suggest(query, limit, city_hint=city_hint) return await _nominatim_suggest(query, limit, city_hint=city_hint)
except Exception: except Exception:
@ -1453,15 +1166,15 @@ async def suggest(
# ── Public API ─────────────────────────────────────────────────────────────── # ── Public API ───────────────────────────────────────────────────────────────
async def geocode(address: str, db: Session, city_hint: str | None = None) -> GeocodeResult | None: async def geocode(address: str, db: Session, city_hint: str | None = None) -> GeocodeResult | None:
"""Геокодинг с кэшем. Cadastral FDW → Yandex → Nominatim → None. """Геокодинг с кэшем. Cadastral FDW → Nominatim → None.
Args: Args:
address: пользовательский ввод (может быть грязным нормализуем). address: пользовательский ввод (может быть грязным нормализуем).
db: сессия Postgres для cache lookup/write и cadastral FDW lookup. db: сессия Postgres для cache lookup/write и cadastral FDW lookup.
city_hint: город, если известен вызывающему коду (#2576) — например city_hint: город, если известен вызывающему коду (#2576) — например
выбран пользователем на предыдущем шаге UI. Опциональный, не выбран пользователем на предыдущем шаге UI. Опциональный, не
ломает существующий контракт. Прокидывается в Yandex/Nominatim ломает существующий контракт. Прокидывается в Nominatim-тир
внешние тиры вместо молчаливой подстановки "Екатеринбург" и вместо молчаливой подстановки "Екатеринбург" и
участвует в cache-ключе (см. `_cache_key`), чтобы ответы для участвует в cache-ключе (см. `_cache_key`), чтобы ответы для
разных городов по одному и тому же тексту адреса не перезатирали разных городов по одному и тому же тексту адреса не перезатирали
друг друга. друг друга.
@ -1575,42 +1288,7 @@ async def geocode(address: str, db: Session, city_hint: str | None = None) -> Ge
) )
return result return result
# 3. Yandex (если есть key) с typo-fallback # 3. Nominatim fallback
if settings.yandex_geocoder_api_key:
try:
result = await _yandex_lookup(address, settings.yandex_geocoder_api_key, city_hint)
# Если результат вне области (region 66) — пробуем typo-варианты
in_oblast = result is not None and is_within_oblast66_bbox(result.lat, result.lon)
if result is not None and in_oblast:
result = replace(result, city_ambiguous=city_ambiguous)
await asyncio.to_thread(_cache_put, db, addr_norm, result)
logger.info("geocode yandex: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon)
return result
# Tier 2: typo-variants
for variant in _typo_variants(address, limit=4):
try:
result = await _yandex_lookup(
variant, settings.yandex_geocoder_api_key, city_hint
)
except Exception:
continue
if result is None:
continue
if is_within_oblast66_bbox(result.lat, result.lon):
result = replace(result, city_ambiguous=city_ambiguous)
await asyncio.to_thread(_cache_put, db, addr_norm, result)
logger.info(
"geocode yandex typo-fixed: %s%s → (%.5f, %.5f)",
addr_norm,
variant,
result.lat,
result.lon,
)
return result
except Exception:
logger.exception("yandex geocoder failed, falling back to nominatim")
# 4. Nominatim fallback
try: try:
result = await _nominatim_lookup(address, city_hint) result = await _nominatim_lookup(address, city_hint)
if result is not None: if result is not None:
@ -1628,9 +1306,14 @@ async def geocode(address: str, db: Session, city_hint: str | None = None) -> Ge
# ── Reverse: координаты → адрес (для map-picker'а) ────────────────────────── # ── Reverse: координаты → адрес (для map-picker'а) ──────────────────────────
# Precision levels which we treat as "снап к зданию имеет смысл": # Precision levels which we treat as "снап к зданию имеет смысл":
# - exact — точный матч на здание (Yandex) # - exact — точный матч на здание. #2593: единственный живой источник
# (`_yandex_reverse`) удалён — значение сейчас недостижимо ни одним из
# оставшихся тиров (cadastral/Nominatim), но оставлено в множестве:
# дёшево держать (frozen set membership-check, не enum), сохраняет parity
# с фронтовым `SNAP_PRECISIONS` (`MapPicker.tsx`, тот же литерал), и не
# ломает `test_snap_precision_useful_exact_and_number`.
# - number — найден дом с номером (то что нам надо для квартирного оценщика) # - number — найден дом с номером (то что нам надо для квартирного оценщика)
# - cadastral — Cadastral FDW row (то же по точности что Yandex "number") # - cadastral — Cadastral FDW row (та же точность, что и "number")
# Остальные (street/range/near/locality/other) → не снапаем, marker остаётся на клике. # Остальные (street/range/near/locality/other) → не снапаем, marker остаётся на клике.
_SNAP_PRECISIONS = {"exact", "number", "cadastral"} _SNAP_PRECISIONS = {"exact", "number", "cadastral"}
@ -1643,14 +1326,14 @@ class ReverseGeocodeResult:
- `snapped_lat` координата центра здания если provider дал её, - `snapped_lat` координата центра здания если provider дал её,
иначе echo `lat` входной точки (для precision=street/locality). иначе echo `lat` входной точки (для precision=street/locality).
- `snapped_lon` то же. - `snapped_lon` то же.
- `precision` yandex-style: `exact`/`number`/`street`/`range`/`near`/ - `precision` `exact`/`number`/`street`/`range`/`near`/`locality`/
`locality`/`other`/`cadastral`. Используется фронтом чтобы `other`/`cadastral`. Используется фронтом чтобы
решить двигать marker (exact/number) или нет. решить двигать marker (exact/number) или нет.
- `provider` кто дал результат (`yandex`/`nominatim`/`cadastral`). - `provider` кто дал результат (`nominatim`/`cadastral`).
Фронт MapPicker'а после клика смотрит на precision: если `exact`/`number` Фронт MapPicker'а после клика смотрит на precision: если `exact`/`number`
и snapped >5m от click point пересаживает marker на snapped point и snapped >5m от click point пересаживает marker на snapped point
(чтобы пользователь видел центр дома по Яндексу, а не свой клик во дворе). (чтобы пользователь видел центр дома, а не свой клик во дворе).
Для остальных precision marker остаётся где кликнули не врём что нашли Для остальных precision marker остаётся где кликнули не врём что нашли
точное здание. точное здание.
""" """
@ -1659,7 +1342,7 @@ class ReverseGeocodeResult:
snapped_lat: float snapped_lat: float
snapped_lon: float snapped_lon: float
precision: str precision: str
provider: Literal["yandex", "nominatim", "cadastral"] provider: Literal["nominatim", "cadastral"]
def _format_reverse_address(addr: dict) -> str | None: def _format_reverse_address(addr: dict) -> str | None:
@ -1686,77 +1369,6 @@ def _format_reverse_address(addr: dict) -> str | None:
return ", ".join(parts) return ", ".join(parts)
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
async def _yandex_reverse(lat: float, lon: float, api_key: str) -> ReverseGeocodeResult | None:
"""Yandex Geocoder /reverse — возвращает snapped Point.pos здания + precision.
Docs: https://yandex.ru/dev/maps/geocoder/doc/desc/concepts/input_params.html
Параметр `geocode` принимает `lon,lat` (важно обратный порядок!).
"""
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
"https://geocode-maps.yandex.ru/1.x/",
params={
"apikey": api_key,
"geocode": f"{lon},{lat}",
"format": "json",
"results": "1",
"kind": "house", # просим именно здание (house), не улицу
"lang": "ru_RU",
},
)
response.raise_for_status()
data = response.json()
members = data.get("response", {}).get("GeoObjectCollection", {}).get("featureMember", [])
if not members:
return None
obj = members[0].get("GeoObject", {})
try:
lon_str, lat_str = obj["Point"]["pos"].split()
snapped_lat = float(lat_str)
snapped_lon = float(lon_str)
except (KeyError, ValueError):
return None
meta = obj.get("metaDataProperty", {}).get("GeocoderMetaData", {})
precision = str(meta.get("precision", "other"))
address_text = str(meta.get("text") or obj.get("name") or "")
# Yandex address text начинается с «Россия, Свердловская область, …» — режем prefix,
# оставляем «улица, дом, город» для consistency с Nominatim/cadastral.
if address_text:
# «Россия, Свердловская область, Екатеринбург, улица Малышева, 51»
# → «улица Малышева, 51, Екатеринбург» (drop country/oblast, swap city/street)
parts = [p.strip() for p in address_text.split(",") if p.strip()]
filtered = [
p
for p in parts
if p not in {"Россия", "Свердловская область"} and not p.startswith("городской округ")
]
# Найдём locality (Екатеринбург / Нижний Тагил / Берёзовский / …) и переставим
# в конец — любой город региона 66, не только ЕКБ (case-insensitive match).
locality = None
rest: list[str] = []
for p in filtered:
if locality is None and p.lower() in SVERDLOVSK_OBLAST_CITIES:
locality = p
else:
rest.append(p)
if locality and rest:
address_text = ", ".join([*rest, locality])
else:
address_text = ", ".join(filtered)
if not address_text:
return None
return ReverseGeocodeResult(
address=address_text,
snapped_lat=snapped_lat,
snapped_lon=snapped_lon,
precision=precision,
provider="yandex",
)
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8)) @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
async def _nominatim_reverse(lat: float, lon: float) -> ReverseGeocodeResult | None: async def _nominatim_reverse(lat: float, lon: float) -> ReverseGeocodeResult | None:
"""Nominatim /reverse → ReverseGeocodeResult с snapped coords из item.lat/lon. """Nominatim /reverse → ReverseGeocodeResult с snapped coords из item.lat/lon.
@ -1856,7 +1468,7 @@ def _cadastral_reverse_sync_full(
async def reverse_geocode( async def reverse_geocode(
lat: float, lon: float, db: Session | None = None lat: float, lon: float, db: Session | None = None
) -> ReverseGeocodeResult | None: ) -> ReverseGeocodeResult | None:
"""Cadastral FDW → Yandex (если key) → Nominatim. Возвращает snapped coords. """Cadastral FDW → Nominatim. Возвращает snapped coords.
Возвращает None если ни один источник не дал адрес. Endpoint Возвращает None если ни один источник не дал адрес. Endpoint
api/v1/geocode/reverse сам выкинет 404. НЕ даёт выйти HTTPStatusError api/v1/geocode/reverse сам выкинет 404. НЕ даёт выйти HTTPStatusError
@ -1881,16 +1493,7 @@ async def reverse_geocode(
provider="cadastral", provider="cadastral",
) )
# 2. Yandex — основной источник snap'а (его Point.pos = центр здания) # 2. Nominatim fallback (wrap to prevent 500 on ban/rate-limit)
if settings.yandex_geocoder_api_key:
try:
result = await _yandex_reverse(lat, lon, settings.yandex_geocoder_api_key)
if result is not None:
return result
except Exception:
logger.exception("yandex reverse failed for (%.5f, %.5f)", lat, lon)
# 3. Nominatim fallback (wrap to prevent 500 on ban/rate-limit)
try: try:
return await _nominatim_reverse(lat, lon) return await _nominatim_reverse(lat, lon)
except Exception: except Exception:

View file

@ -6,7 +6,7 @@
wired into in-app scheduler, window 06:00-09:00 UTC. wired into in-app scheduler, window 06:00-09:00 UTC.
Pattern: dedup по address (1 unique address 1 geocode call UPDATE all listings). Pattern: dedup по address (1 unique address 1 geocode call UPDATE all listings).
Rate limit: Nominatim 1 req/sec. Yandex 25K/day если YANDEX_GEOCODER_API_KEY set. Rate limit: Nominatim 1 req/sec (#2593: Yandex Geocoder tier удалён из geocoder).
Отличие от /admin/geocode-missing (per-ID): Отличие от /admin/geocode-missing (per-ID):
- Этот модуль группирует по address меньше API calls (dedup). - Этот модуль группирует по address меньше API calls (dedup).

View file

@ -176,24 +176,18 @@ def _nom_result(address: str) -> ReverseGeocodeResult:
async def test_reverse_geocode_prefers_cadastral_over_nominatim() -> None: async def test_reverse_geocode_prefers_cadastral_over_nominatim() -> None:
"""Cadastral returns address → Yandex/Nominatim never called.""" """Cadastral returns address → Nominatim never called."""
db = MagicMock() db = MagicMock()
with ( with (
patch( patch(
"app.services.geocoder._cadastral_reverse_sync_full", "app.services.geocoder._cadastral_reverse_sync_full",
return_value=("ул. Малышева, 30, Екатеринбург", 56.8385, 60.6052), return_value=("ул. Малышева, 30, Екатеринбург", 56.8385, 60.6052),
) as mock_cad, ) as mock_cad,
patch("app.services.geocoder.settings") as mock_settings,
patch(
"app.services.geocoder._yandex_reverse",
new_callable=AsyncMock,
) as mock_yandex,
patch( patch(
"app.services.geocoder._nominatim_reverse", "app.services.geocoder._nominatim_reverse",
new_callable=AsyncMock, new_callable=AsyncMock,
) as mock_nom, ) as mock_nom,
): ):
mock_settings.yandex_geocoder_api_key = "fake-key"
result = await reverse_geocode(56.838, 60.605, db=db) result = await reverse_geocode(56.838, 60.605, db=db)
assert result is not None assert result is not None
@ -203,41 +197,6 @@ async def test_reverse_geocode_prefers_cadastral_over_nominatim() -> None:
assert result.precision == "number" assert result.precision == "number"
assert result.provider == "cadastral" assert result.provider == "cadastral"
mock_cad.assert_called_once_with(db, 56.838, 60.605) mock_cad.assert_called_once_with(db, 56.838, 60.605)
mock_yandex.assert_not_called()
mock_nom.assert_not_called()
async def test_reverse_geocode_uses_yandex_when_cadastral_empty() -> None:
"""Cadastral None + Yandex key set → Yandex called, Nominatim skipped."""
db = MagicMock()
yandex_result = ReverseGeocodeResult(
address="улица Малышева, 51, Екатеринбург",
snapped_lat=56.838004,
snapped_lon=60.586155,
precision="exact",
provider="yandex",
)
with (
patch("app.services.geocoder._cadastral_reverse_sync_full", return_value=None),
patch("app.services.geocoder.settings") as mock_settings,
patch(
"app.services.geocoder._yandex_reverse",
new_callable=AsyncMock,
return_value=yandex_result,
) as mock_yandex,
patch(
"app.services.geocoder._nominatim_reverse",
new_callable=AsyncMock,
) as mock_nom,
):
mock_settings.yandex_geocoder_api_key = "fake-key"
result = await reverse_geocode(56.838, 60.586, db=db)
assert result is not None
assert result.provider == "yandex"
assert result.precision == "exact"
assert result.snapped_lat == 56.838004
mock_yandex.assert_called_once()
mock_nom.assert_not_called() mock_nom.assert_not_called()
@ -263,23 +222,16 @@ async def test_reverse_geocode_falls_back_to_nominatim_when_cadastral_none() ->
async def test_reverse_geocode_returns_none_when_all_providers_fail() -> None: async def test_reverse_geocode_returns_none_when_all_providers_fail() -> None:
"""Cadastral + Yandex + Nominatim — все падают → returns None, no exception.""" """Cadastral + Nominatim — все падают → returns None, no exception."""
db = MagicMock() db = MagicMock()
with ( with (
patch("app.services.geocoder._cadastral_reverse_sync_full", return_value=None), patch("app.services.geocoder._cadastral_reverse_sync_full", return_value=None),
patch("app.services.geocoder.settings") as mock_settings,
patch(
"app.services.geocoder._yandex_reverse",
new_callable=AsyncMock,
side_effect=RuntimeError("Yandex 429 rate-limited"),
),
patch( patch(
"app.services.geocoder._nominatim_reverse", "app.services.geocoder._nominatim_reverse",
new_callable=AsyncMock, new_callable=AsyncMock,
side_effect=RuntimeError("Nominatim 403 Forbidden — IP banned"), side_effect=RuntimeError("Nominatim 403 Forbidden — IP banned"),
), ),
): ):
mock_settings.yandex_geocoder_api_key = "fake-key"
result = await reverse_geocode(56.838, 60.605, db=db) result = await reverse_geocode(56.838, 60.605, db=db)
# Must return None, not raise # Must return None, not raise
@ -287,19 +239,17 @@ async def test_reverse_geocode_returns_none_when_all_providers_fail() -> None:
async def test_reverse_geocode_without_db_skips_cadastral() -> None: async def test_reverse_geocode_without_db_skips_cadastral() -> None:
"""When db=None, cadastral tier is skipped entirely → goes straight to Yandex/Nominatim.""" """When db=None, cadastral tier is skipped entirely → goes straight to Nominatim."""
with ( with (
patch( patch(
"app.services.geocoder._cadastral_reverse_sync_full", "app.services.geocoder._cadastral_reverse_sync_full",
) as mock_cad, ) as mock_cad,
patch("app.services.geocoder.settings") as mock_settings,
patch( patch(
"app.services.geocoder._nominatim_reverse", "app.services.geocoder._nominatim_reverse",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=_nom_result("ул. Ленина, 1, Екатеринбург"), return_value=_nom_result("ул. Ленина, 1, Екатеринбург"),
), ),
): ):
mock_settings.yandex_geocoder_api_key = None
result = await reverse_geocode(56.838, 60.605, db=None) result = await reverse_geocode(56.838, 60.605, db=None)
mock_cad.assert_not_called() mock_cad.assert_not_called()
@ -307,36 +257,11 @@ async def test_reverse_geocode_without_db_skips_cadastral() -> None:
assert result.address == "ул. Ленина, 1, Екатеринбург" assert result.address == "ул. Ленина, 1, Екатеринбург"
async def test_reverse_geocode_falls_through_yandex_to_nominatim_on_exception() -> None:
"""Yandex raises → Nominatim still tried."""
db = MagicMock()
with (
patch("app.services.geocoder._cadastral_reverse_sync_full", return_value=None),
patch("app.services.geocoder.settings") as mock_settings,
patch(
"app.services.geocoder._yandex_reverse",
new_callable=AsyncMock,
side_effect=RuntimeError("Yandex 500"),
),
patch(
"app.services.geocoder._nominatim_reverse",
new_callable=AsyncMock,
return_value=_nom_result("ул. Дублёр, 1"),
) as mock_nom,
):
mock_settings.yandex_geocoder_api_key = "fake-key"
result = await reverse_geocode(56.838, 60.605, db=db)
assert result is not None
assert result.provider == "nominatim"
mock_nom.assert_called_once()
# ── geocode: cadastral as first tier ───────────────────────────────────────── # ── geocode: cadastral as first tier ─────────────────────────────────────────
async def test_geocode_uses_cadastral_before_yandex() -> None: async def test_geocode_uses_cadastral_before_nominatim() -> None:
"""Cadastral FDW returns suggestion → geocode returns it without hitting Yandex.""" """Cadastral FDW returns suggestion → geocode returns it without hitting Nominatim."""
db = MagicMock() db = MagicMock()
# Cache miss # Cache miss
db.execute.side_effect = [ db.execute.side_effect = [
@ -362,7 +287,7 @@ async def test_geocode_uses_cadastral_before_yandex() -> None:
return_value=[cad_suggestion], return_value=[cad_suggestion],
) as mock_cad, ) as mock_cad,
patch("app.services.geocoder._cache_put"), patch("app.services.geocoder._cache_put"),
patch("app.services.geocoder._yandex_lookup", new_callable=AsyncMock) as mock_yandex, patch("app.services.geocoder._nominatim_lookup", new_callable=AsyncMock) as mock_nominatim,
): ):
result = await geocode("Хохрякова 81", db) result = await geocode("Хохрякова 81", db)
@ -371,14 +296,14 @@ async def test_geocode_uses_cadastral_before_yandex() -> None:
assert result.lon == 60.610 assert result.lon == 60.610
assert result.confidence == "exact" assert result.confidence == "exact"
mock_cad.assert_called_once() mock_cad.assert_called_once()
mock_yandex.assert_not_called() mock_nominatim.assert_not_called()
# ── suggest: cadastral as first tier ───────────────────────────────────────── # ── suggest: cadastral as first tier ─────────────────────────────────────────
async def test_suggest_uses_cadastral_before_yandex() -> None: async def test_suggest_uses_cadastral_before_nominatim() -> None:
"""Cadastral FDW returns results → suggest returns them without hitting Yandex.""" """Cadastral FDW returns results → suggest returns them without hitting Nominatim."""
db = MagicMock() db = MagicMock()
cad_suggestions = [ cad_suggestions = [
GeocodeSuggestion( GeocodeSuggestion(
@ -396,53 +321,20 @@ async def test_suggest_uses_cadastral_before_yandex() -> None:
return_value=cad_suggestions, return_value=cad_suggestions,
) as mock_cad, ) as mock_cad,
patch( patch(
"app.services.geocoder._yandex_suggest", "app.services.geocoder._nominatim_suggest",
new_callable=AsyncMock, new_callable=AsyncMock,
) as mock_yandex, ) as mock_nominatim,
): ):
results = await suggest("Малышева", db=db, limit=8) results = await suggest("Малышева", db=db, limit=8)
assert len(results) == 1 assert len(results) == 1
assert results[0].full_address == "ул. Малышева, 30, Екатеринбург" assert results[0].full_address == "ул. Малышева, 30, Екатеринбург"
mock_cad.assert_called_once() mock_cad.assert_called_once()
mock_yandex.assert_not_called() mock_nominatim.assert_not_called()
async def test_suggest_falls_back_to_yandex_when_cadastral_empty() -> None: async def test_suggest_uses_dadata_before_nominatim_when_cadastral_empty() -> None:
"""Cadastral returns [] → DaData not configured → falls through to Yandex.""" """Cadastral returns [] → DaData tier returns results → Nominatim never called (PR Q2)."""
db = MagicMock()
yandex_result = GeocodeSuggestion(
label="Малышева · Октябрьский",
full_address="улица Малышева, Екатеринбург",
lat=56.838,
lon=60.605,
kind="street",
)
with (
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]),
patch("app.services.geocoder.settings") as mock_settings,
patch(
"app.services.geocoder._dadata_suggest",
new_callable=AsyncMock,
return_value=[], # DaData empty → falls through
),
patch(
"app.services.geocoder._yandex_suggest",
new_callable=AsyncMock,
return_value=[yandex_result],
) as mock_yandex,
):
mock_settings.dadata_api_token = "fake-dadata-token" # gate to call _dadata_suggest
mock_settings.yandex_geocoder_api_key = "fake-key"
results = await suggest("Малышева", db=db, limit=8)
assert len(results) == 1
mock_yandex.assert_called_once()
async def test_suggest_uses_dadata_before_yandex_when_cadastral_empty() -> None:
"""Cadastral returns [] → DaData tier returns results → Yandex never called (PR Q2)."""
db = MagicMock() db = MagicMock()
dadata_result = GeocodeSuggestion( dadata_result = GeocodeSuggestion(
label="г Екатеринбург, ул Малышева, д 30", label="г Екатеринбург, ул Малышева, д 30",
@ -461,18 +353,17 @@ async def test_suggest_uses_dadata_before_yandex_when_cadastral_empty() -> None:
return_value=[dadata_result], return_value=[dadata_result],
) as mock_dadata, ) as mock_dadata,
patch( patch(
"app.services.geocoder._yandex_suggest", "app.services.geocoder._nominatim_suggest",
new_callable=AsyncMock, new_callable=AsyncMock,
) as mock_yandex, ) as mock_nominatim,
): ):
mock_settings.dadata_api_token = "fake-dadata-token" mock_settings.dadata_api_token = "fake-dadata-token"
mock_settings.yandex_geocoder_api_key = "fake-yandex-key"
results = await suggest("Малышева", db=db, limit=8) results = await suggest("Малышева", db=db, limit=8)
assert len(results) == 1 assert len(results) == 1
assert results[0].lat == 56.838011 assert results[0].lat == 56.838011
mock_dadata.assert_called_once() mock_dadata.assert_called_once()
mock_yandex.assert_not_called() mock_nominatim.assert_not_called()
# ── ensure_fdw_user_mapping: SQL injection / whitelist guards ───────────────── # ── ensure_fdw_user_mapping: SQL injection / whitelist guards ─────────────────

View file

@ -260,7 +260,6 @@ async def test_geocode_uses_geoportal_first() -> None:
patch("app.services.geocoder._cadastral_house_match") as mock_cad, patch("app.services.geocoder._cadastral_house_match") as mock_cad,
patch("app.services.geocoder._cadastral_forward_sync") as mock_forward, patch("app.services.geocoder._cadastral_forward_sync") as mock_forward,
patch("app.services.geocoder._cache_put"), patch("app.services.geocoder._cache_put"),
patch("app.services.geocoder._yandex_lookup", new_callable=AsyncMock) as mock_yandex,
): ):
result = await geocode("Космонавтов 7б", db) result = await geocode("Космонавтов 7б", db)
@ -273,7 +272,6 @@ async def test_geocode_uses_geoportal_first() -> None:
# geoportal hit short-circuits everything downstream # geoportal hit short-circuits everything downstream
mock_cad.assert_not_called() mock_cad.assert_not_called()
mock_forward.assert_not_called() mock_forward.assert_not_called()
mock_yandex.assert_not_called()
async def test_geocode_falls_through_to_cadastral_when_geoportal_misses() -> None: async def test_geocode_falls_through_to_cadastral_when_geoportal_misses() -> None:
@ -295,7 +293,6 @@ async def test_geocode_falls_through_to_cadastral_when_geoportal_misses() -> Non
patch("app.services.geocoder._cadastral_house_match", return_value=cad_hit) as mock_cad, patch("app.services.geocoder._cadastral_house_match", return_value=cad_hit) as mock_cad,
patch("app.services.geocoder._cadastral_forward_sync") as mock_forward, patch("app.services.geocoder._cadastral_forward_sync") as mock_forward,
patch("app.services.geocoder._cache_put"), patch("app.services.geocoder._cache_put"),
patch("app.services.geocoder._yandex_lookup", new_callable=AsyncMock) as mock_yandex,
): ):
result = await geocode("Серова 27", db) result = await geocode("Серова 27", db)
@ -304,7 +301,6 @@ async def test_geocode_falls_through_to_cadastral_when_geoportal_misses() -> Non
mock_geo.assert_called_once() mock_geo.assert_called_once()
mock_cad.assert_called_once() mock_cad.assert_called_once()
mock_forward.assert_not_called() mock_forward.assert_not_called()
mock_yandex.assert_not_called()
async def test_geocode_skips_geoportal_when_parse_fails() -> None: async def test_geocode_skips_geoportal_when_parse_fails() -> None:
@ -319,14 +315,12 @@ async def test_geocode_skips_geoportal_when_parse_fails() -> None:
patch("app.services.geocoder._cadastral_house_match") as mock_cad, patch("app.services.geocoder._cadastral_house_match") as mock_cad,
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]) as mock_forward, patch("app.services.geocoder._cadastral_forward_sync", return_value=[]) as mock_forward,
patch("app.services.geocoder._cache_put"), patch("app.services.geocoder._cache_put"),
patch("app.services.geocoder.settings") as mock_settings,
patch( patch(
"app.services.geocoder._nominatim_lookup", "app.services.geocoder._nominatim_lookup",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=None, return_value=None,
), ),
): ):
mock_settings.yandex_geocoder_api_key = None
result = await geocode("полный мусор без дома", db) result = await geocode("полный мусор без дома", db)
assert result is None assert result is None

View file

@ -6,7 +6,7 @@ Bug context (issue #582 Phase 5):
оставался где user кликнул иногда в проезде / дворе. оставался где user кликнул иногда в проезде / дворе.
Fix: backend теперь возвращает snapped_lat/snapped_lon (центр matched здания Fix: backend теперь возвращает snapped_lat/snapped_lon (центр matched здания
от Yandex/Nominatim/cadastral) + precision, фронт двигает marker если precision от Nominatim/cadastral) + precision, фронт двигает marker если precision
in (exact, number, cadastral). in (exact, number, cadastral).
""" """
@ -46,6 +46,7 @@ def app() -> FastAPI:
# ── Endpoint response shape ────────────────────────────────────────────────── # ── Endpoint response shape ──────────────────────────────────────────────────
def test_reverse_endpoint_returns_snapped_fields(app: FastAPI) -> None: def test_reverse_endpoint_returns_snapped_fields(app: FastAPI) -> None:
"""Endpoint should include address + lat/lon (echo) + snapped_lat/snapped_lon + precision.""" """Endpoint should include address + lat/lon (echo) + snapped_lat/snapped_lon + precision."""
client = TestClient(app) client = TestClient(app)
@ -54,7 +55,7 @@ def test_reverse_endpoint_returns_snapped_fields(app: FastAPI) -> None:
snapped_lat=56.838004, snapped_lat=56.838004,
snapped_lon=60.586155, snapped_lon=60.586155,
precision="exact", precision="exact",
provider="yandex", provider="cadastral",
) )
with patch( with patch(
"app.api.v1.geocode.reverse_geocode", "app.api.v1.geocode.reverse_geocode",
@ -73,7 +74,7 @@ def test_reverse_endpoint_returns_snapped_fields(app: FastAPI) -> None:
assert body["snapped_lat"] == 56.838004 assert body["snapped_lat"] == 56.838004
assert body["snapped_lon"] == 60.586155 assert body["snapped_lon"] == 60.586155
assert body["precision"] == "exact" assert body["precision"] == "exact"
assert body["provider"] == "yandex" assert body["provider"] == "cadastral"
def test_reverse_endpoint_404_when_no_address(app: FastAPI) -> None: def test_reverse_endpoint_404_when_no_address(app: FastAPI) -> None:
@ -113,6 +114,7 @@ def test_reverse_endpoint_street_precision_does_not_lose_snap_fields(app: FastAP
# ── snap_precision_useful helper ───────────────────────────────────────────── # ── snap_precision_useful helper ─────────────────────────────────────────────
def test_snap_precision_useful_exact_and_number() -> None: def test_snap_precision_useful_exact_and_number() -> None:
assert snap_precision_useful("exact") is True assert snap_precision_useful("exact") is True
assert snap_precision_useful("number") is True assert snap_precision_useful("number") is True
@ -125,104 +127,3 @@ def test_snap_precision_useful_rejects_street_and_other() -> None:
assert snap_precision_useful("locality") is False assert snap_precision_useful("locality") is False
assert snap_precision_useful("other") is False assert snap_precision_useful("other") is False
assert snap_precision_useful("") 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

View file

@ -8,12 +8,10 @@
инвариант OBLAST66 TIGHT, дальние города области, известное ограничение (Тюмень инвариант OBLAST66 TIGHT, дальние города области, известное ограничение (Тюмень
внутри bbox координатно компенсируется region cross-check на accept-сайтах); внутри bbox координатно компенсируется region cross-check на accept-сайтах);
- _has_oblast_marker word-boundary matching (не substring); - _has_oblast_marker word-boundary matching (не substring);
- _nominatim_query / _yandex_lookup accept-сайты two-pass tie-break (tight ЕКБ - _nominatim_query accept-сайт two-pass tie-break (tight ЕКБ приоритетнее) +
приоритетнее) + region cross-check (отсекает Тюмень и т.п. даже внутри bbox). region cross-check (отсекает Тюмень и т.п. даже внутри bbox).
""" """
from unittest.mock import patch
import httpx import httpx
import pytest import pytest
@ -27,7 +25,6 @@ from app.services.geocoder import (
_names_non_ekb_city, _names_non_ekb_city,
_names_unrecognized_locality, _names_unrecognized_locality,
_nominatim_query, _nominatim_query,
_yandex_lookup,
is_within_ekb_bbox, is_within_ekb_bbox,
is_within_ekb_bbox_wide, is_within_ekb_bbox_wide,
is_within_oblast66_bbox, is_within_oblast66_bbox,
@ -145,9 +142,9 @@ def test_oblast66_bbox_admits_tyumen_by_design() -> None:
Это ЗНАЕМОЕ и намеренное ограничение самого bbox-хелпера (дешевле Это ЗНАЕМОЕ и намеренное ограничение самого bbox-хелпера (дешевле
false-positive у границы, чем false-negative на корректном адресе области). false-positive у границы, чем false-negative на корректном адресе области).
Реальное отсечение Тюмени происходит на accept-сайтах через region Реальное отсечение Тюмени происходит на accept-сайте через region
cross-check см. test_nominatim_query_rejects_out_of_region_only_candidate / cross-check см. test_nominatim_query_rejects_out_of_region_only_candidate
test_yandex_lookup_rejects_out_of_region_only_candidate ниже. ниже.
""" """
assert is_within_oblast66_bbox(57.1530, 65.5343) is True assert is_within_oblast66_bbox(57.1530, 65.5343) is True
@ -419,66 +416,3 @@ async def test_nominatim_query_accepts_oblast_wide_when_region_absent() -> None:
_REAL_ASYNC_CLIENT = httpx.AsyncClient _REAL_ASYNC_CLIENT = httpx.AsyncClient
def _yandex_client_factory(transport: httpx.MockTransport):
"""Drop-in replacement для httpx.AsyncClient внутри geocoder._yandex_lookup."""
def factory(*_: object, **__: object) -> httpx.AsyncClient:
return _REAL_ASYNC_CLIENT(transport=transport)
return factory
def _yandex_geo_object(lat: str, lon: str, admin_area: str | None) -> dict:
meta: dict = {"precision": "exact", "text": "тест"}
if admin_area is not None:
meta["AddressDetails"] = {
"Country": {"AdministrativeArea": {"AdministrativeAreaName": admin_area}}
}
return {
"GeoObject": {
"Point": {"pos": f"{lon} {lat}"},
"metaDataProperty": {"GeocoderMetaData": meta},
}
}
def _yandex_payload(members: list[dict]) -> dict:
return {"response": {"GeoObjectCollection": {"featureMember": members}}}
async def test_yandex_lookup_prefers_tight_ekb_over_oblast_rank0() -> None:
"""Тот же tie-break сценарий, что и для Nominatim, но для Yandex top-5."""
members = [
_yandex_geo_object("59.6297", "60.5541", "Свердловская область"), # Серов, rank0
_yandex_geo_object("56.838", "60.605", "Свердловская область"), # ЕКБ tight, rank1
]
payload = _yandex_payload(members)
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=payload)
transport = httpx.MockTransport(handler)
with patch("app.services.geocoder.httpx.AsyncClient", _yandex_client_factory(transport)):
result = await _yandex_lookup("Серова 27", "fake-key")
assert result is not None
assert result.lat == pytest.approx(56.838)
assert result.lon == pytest.approx(60.605)
async def test_yandex_lookup_rejects_out_of_region_only_candidate() -> None:
"""Единственный кандидат — Тюмень (bbox admits координатно, регион другой) →
region cross-check отсекает даже в ultimate as-is fallback None."""
members = [_yandex_geo_object("57.1522", "65.5272", "Тюменская область")]
payload = _yandex_payload(members)
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=payload)
transport = httpx.MockTransport(handler)
with patch("app.services.geocoder.httpx.AsyncClient", _yandex_client_factory(transport)):
result = await _yandex_lookup("какой-то мусорный адрес", "fake-key")
assert result is None

View file

@ -188,7 +188,6 @@ async def test_geocode_uses_house_match_before_legacy_forward() -> None:
"app.services.geocoder._cadastral_forward_sync", "app.services.geocoder._cadastral_forward_sync",
) as mock_forward, ) as mock_forward,
patch("app.services.geocoder._cache_put"), patch("app.services.geocoder._cache_put"),
patch("app.services.geocoder._yandex_lookup", new_callable=AsyncMock) as mock_yandex,
): ):
result = await geocode("Серова 27", db) result = await geocode("Серова 27", db)
@ -199,7 +198,6 @@ async def test_geocode_uses_house_match_before_legacy_forward() -> None:
mock_house.assert_called_once() mock_house.assert_called_once()
# house-match hit → legacy raw-ILIKE forward never invoked # house-match hit → legacy raw-ILIKE forward never invoked
mock_forward.assert_not_called() mock_forward.assert_not_called()
mock_yandex.assert_not_called()
async def test_geocode_falls_back_to_legacy_forward_when_house_match_misses() -> None: async def test_geocode_falls_back_to_legacy_forward_when_house_match_misses() -> None:
@ -225,7 +223,6 @@ async def test_geocode_falls_back_to_legacy_forward_when_house_match_misses() ->
return_value=[legacy_hit], return_value=[legacy_hit],
) as mock_forward, ) as mock_forward,
patch("app.services.geocoder._cache_put"), patch("app.services.geocoder._cache_put"),
patch("app.services.geocoder._yandex_lookup", new_callable=AsyncMock) as mock_yandex,
): ):
result = await geocode("Серова 27", db) result = await geocode("Серова 27", db)
@ -233,7 +230,6 @@ async def test_geocode_falls_back_to_legacy_forward_when_house_match_misses() ->
assert result.lat == 56.84 assert result.lat == 56.84
mock_house.assert_called_once() mock_house.assert_called_once()
mock_forward.assert_called_once() mock_forward.assert_called_once()
mock_yandex.assert_not_called()
async def test_geocode_skips_house_match_when_parse_fails() -> None: async def test_geocode_skips_house_match_when_parse_fails() -> None:
@ -250,14 +246,12 @@ async def test_geocode_skips_house_match_when_parse_fails() -> None:
return_value=[], return_value=[],
) as mock_forward, ) as mock_forward,
patch("app.services.geocoder._cache_put"), patch("app.services.geocoder._cache_put"),
patch("app.services.geocoder.settings") as mock_settings,
patch( patch(
"app.services.geocoder._nominatim_lookup", "app.services.geocoder._nominatim_lookup",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=None, return_value=None,
), ),
): ):
mock_settings.yandex_geocoder_api_key = None
result = await geocode("полный мусор без дома", db) result = await geocode("полный мусор без дома", db)
assert result is None assert result is None
@ -290,14 +284,12 @@ async def test_geocode_skips_ekb_local_tiers_for_non_ekb_city() -> None:
patch("app.services.geocoder._cadastral_house_match") as mock_house, patch("app.services.geocoder._cadastral_house_match") as mock_house,
patch("app.services.geocoder._cadastral_forward_sync") as mock_forward, patch("app.services.geocoder._cadastral_forward_sync") as mock_forward,
patch("app.services.geocoder._cache_put"), patch("app.services.geocoder._cache_put"),
patch("app.services.geocoder.settings") as mock_settings,
patch( patch(
"app.services.geocoder._nominatim_lookup", "app.services.geocoder._nominatim_lookup",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=nominatim_result, return_value=nominatim_result,
) as mock_nominatim, ) as mock_nominatim,
): ):
mock_settings.yandex_geocoder_api_key = None
result = await geocode("Нижний Тагил, проспект Ленина, 1", db) result = await geocode("Нижний Тагил, проспект Ленина, 1", db)
assert result is not None assert result is not None
@ -353,14 +345,12 @@ async def test_geocode_skips_ekb_local_tiers_for_unrecognized_locality(
patch("app.services.geocoder._cadastral_house_match") as mock_house, patch("app.services.geocoder._cadastral_house_match") as mock_house,
patch("app.services.geocoder._cadastral_forward_sync") as mock_forward, patch("app.services.geocoder._cadastral_forward_sync") as mock_forward,
patch("app.services.geocoder._cache_put"), patch("app.services.geocoder._cache_put"),
patch("app.services.geocoder.settings") as mock_settings,
patch( patch(
"app.services.geocoder._nominatim_lookup", "app.services.geocoder._nominatim_lookup",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=nominatim_result, return_value=nominatim_result,
) as mock_nominatim, ) as mock_nominatim,
): ):
mock_settings.yandex_geocoder_api_key = None
result = await geocode(address, db) result = await geocode(address, db)
assert result is not None, label assert result is not None, label
@ -462,14 +452,12 @@ async def test_geocode_city_hint_unknown_city_skips_local_ekb_tiers() -> None:
) as mock_geoportal, ) as mock_geoportal,
patch("app.services.geocoder._cadastral_house_match", return_value=ekb_hit) as mock_house, patch("app.services.geocoder._cadastral_house_match", return_value=ekb_hit) as mock_house,
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]), patch("app.services.geocoder._cadastral_forward_sync", return_value=[]),
patch("app.services.geocoder.settings") as mock_settings,
patch( patch(
"app.services.geocoder._nominatim_lookup", "app.services.geocoder._nominatim_lookup",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=rezh_result, return_value=rezh_result,
), ),
): ):
mock_settings.yandex_geocoder_api_key = None
result = await geocode("Ленина, 1", db, city_hint="Реж") result = await geocode("Ленина, 1", db, city_hint="Реж")
mock_geoportal.assert_not_called() mock_geoportal.assert_not_called()
@ -501,7 +489,6 @@ async def test_suggest_uses_house_match_before_legacy_forward() -> None:
patch( patch(
"app.services.geocoder._cadastral_forward_sync", "app.services.geocoder._cadastral_forward_sync",
) as mock_forward, ) as mock_forward,
patch("app.services.geocoder._yandex_suggest", new_callable=AsyncMock) as mock_yandex,
): ):
results = await suggest("Серова 27", db=db, limit=8) results = await suggest("Серова 27", db=db, limit=8)
@ -509,7 +496,6 @@ async def test_suggest_uses_house_match_before_legacy_forward() -> None:
assert results[0].lat == 56.81188 assert results[0].lat == 56.81188
mock_house.assert_called_once() mock_house.assert_called_once()
mock_forward.assert_not_called() mock_forward.assert_not_called()
mock_yandex.assert_not_called()
async def test_suggest_falls_back_to_legacy_forward_when_house_match_misses() -> None: async def test_suggest_falls_back_to_legacy_forward_when_house_match_misses() -> None:
@ -534,7 +520,6 @@ async def test_suggest_falls_back_to_legacy_forward_when_house_match_misses() ->
"app.services.geocoder._cadastral_forward_sync", "app.services.geocoder._cadastral_forward_sync",
return_value=legacy, return_value=legacy,
) as mock_forward, ) as mock_forward,
patch("app.services.geocoder._yandex_suggest", new_callable=AsyncMock) as mock_yandex,
): ):
results = await suggest("Серова 27", db=db, limit=8) results = await suggest("Серова 27", db=db, limit=8)
@ -542,7 +527,6 @@ async def test_suggest_falls_back_to_legacy_forward_when_house_match_misses() ->
assert results[0].lat == 56.84 assert results[0].lat == 56.84
mock_house.assert_called_once() mock_house.assert_called_once()
mock_forward.assert_called_once() mock_forward.assert_called_once()
mock_yandex.assert_not_called()
async def test_suggest_skips_ekb_local_tier_for_non_ekb_city() -> None: async def test_suggest_skips_ekb_local_tier_for_non_ekb_city() -> None:
@ -571,7 +555,6 @@ async def test_suggest_skips_ekb_local_tier_for_non_ekb_city() -> None:
) as mock_nominatim, ) as mock_nominatim,
): ):
mock_settings.dadata_api_token = None mock_settings.dadata_api_token = None
mock_settings.yandex_geocoder_api_key = None
results = await suggest("Нижний Тагил, проспект Ленина, 1", db=db, limit=8) results = await suggest("Нижний Тагил, проспект Ленина, 1", db=db, limit=8)
assert len(results) == 1 assert len(results) == 1
@ -615,7 +598,6 @@ async def test_suggest_skips_ekb_local_tier_for_unrecognized_locality(
) as mock_nominatim, ) as mock_nominatim,
): ):
mock_settings.dadata_api_token = None mock_settings.dadata_api_token = None
mock_settings.yandex_geocoder_api_key = None
results = await suggest(query, db=db, limit=8) results = await suggest(query, db=db, limit=8)
assert len(results) == 1, label assert len(results) == 1, label

View file

@ -1,18 +1,20 @@
"""Тесты #2576 — geocoder больше НЕ подставляет "Екатеринбург" молча. """Тесты #2576 — geocoder больше НЕ подставляет "Екатеринбург" молча.
Проблема (issue #2576 / эпик расширения на область): `_yandex_lookup`, Проблема (issue #2576 / эпик расширения на область): `_nominatim_lookup`,
`_yandex_suggest`, `_nominatim_suggest` при отсутствии маркера города/области в `_nominatim_suggest` при отсутствии маркера города/области в самом адресе
самом адресе всегда молча подставляли "Екатеринбург" житель Нижнего Тагила, всегда молча подставляли "Екатеринбург" житель Нижнего Тагила, вводя
вводя «Ленина, 1», получал уверенно неверную цену по екатеринбургской улице «Ленина, 1», получал уверенно неверную цену по екатеринбургской улице Ленина
Ленина (обе улицы называются одинаково). (обе улицы называются одинаково).
#2593: Yandex Geocoder (`_yandex_lookup`/`_yandex_suggest`) удалён из geocoder —
их тесты здесь удалены вместе с ним; покрытие тех же сценариев (bias/prefix
без молчаливого "Екатеринбург") осталось на `_nominatim_lookup`.
Покрывают: Покрывают:
- `_resolve_city_for_geocode` приоритет: маркер в адресе > `city_hint` > None. - `_resolve_city_for_geocode` приоритет: маркер в адресе > `city_hint` > None.
- `_yandex_lookup` без города НЕ получает "Екатеринбург, "-префикс и bias - `_nominatim_suggest` без города НЕ форсит city-суффикс молчаливо (dual-query);
смещён на всю область (не форсит ЕКБ-центр); с `city_hint` префикс из hint'а; с `city_hint` суффикс из hint'а; с явным "Екатеринбург" в адресе — поведение
с явным "Екатеринбург" в адресе поведение НЕ изменилось (как раньше). НЕ изменилось (как раньше).
- `_yandex_suggest` (Tier 1 bounded) то же самое, плюс bias/rspn.
- `_nominatim_suggest` то же самое (суффикс города, не префикс).
- `geocode()` `city_ambiguous=True` когда город не указан ни в адресе, ни в - `geocode()` `city_ambiguous=True` когда город не указан ни в адресе, ни в
`city_hint`; `False` когда указан явно (текстом или через `city_hint`). `city_hint`; `False` когда указан явно (текстом или через `city_hint`).
- Cache-ключ (`_cache_key`) разные `city_hint` для одного текста адреса НЕ - Cache-ключ (`_cache_key`) разные `city_hint` для одного текста адреса НЕ
@ -27,19 +29,14 @@ from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db") os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db")
import httpx
import pytest import pytest
from app.services.geocoder import ( from app.services.geocoder import (
EKB_BBOX,
OBLAST66_VIEWBOX,
GeocodeResult, GeocodeResult,
GeocodeSuggestion, GeocodeSuggestion,
_cache_key, _cache_key,
_nominatim_suggest, _nominatim_suggest,
_resolve_city_for_geocode, _resolve_city_for_geocode,
_yandex_lookup,
_yandex_suggest,
geocode, geocode,
suggest, suggest,
) )
@ -93,139 +90,6 @@ def test_cache_key_hint_normalized() -> None:
assert _cache_key("ленина, 1", "Нижний Тагил") == _cache_key("ленина, 1", "нижний тагил ") 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 ─────────────────────────────────────────────────────── # ── _nominatim_suggest ───────────────────────────────────────────────────────
@ -363,14 +227,16 @@ async def test_nominatim_suggest_dedupe_across_dual_query() -> None:
# ── geocode() — city_ambiguous flag ────────────────────────────────────────── # ── geocode() — city_ambiguous flag ──────────────────────────────────────────
def _geocode_patches(yandex_result: GeocodeResult | None): def _geocode_patches(nominatim_result: GeocodeResult | None):
return ( return (
patch("app.services.geocoder._cache_get", return_value=None), patch("app.services.geocoder._cache_get", return_value=None),
patch("app.services.geocoder._cache_put"), patch("app.services.geocoder._cache_put"),
patch("app.services.geocoder._geoportal_house_match", return_value=None), 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_house_match", return_value=None),
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]), patch("app.services.geocoder._cadastral_forward_sync", return_value=[]),
patch("app.services.geocoder._yandex_lookup", new=AsyncMock(return_value=yandex_result)), patch(
"app.services.geocoder._nominatim_lookup", new=AsyncMock(return_value=nominatim_result)
),
) )
@ -393,29 +259,25 @@ async def test_geocode_city_hint_non_ekb_skips_local_ekb_tiers() -> None:
kind="house", kind="house",
) )
tagil_result = GeocodeResult( tagil_result = GeocodeResult(
lat=57.905, lon=59.950, full_address="Ленина, 1, Нижний Тагил", provider="yandex" lat=57.905, lon=59.950, full_address="Ленина, 1, Нижний Тагил", provider="nominatim"
) )
with patch("app.services.geocoder.settings") as mock_settings: with contextlib.ExitStack() as stack:
mock_settings.yandex_geocoder_api_key = "fake" stack.enter_context(patch("app.services.geocoder._cache_get", return_value=None))
with contextlib.ExitStack() as stack: stack.enter_context(patch("app.services.geocoder._cache_put"))
stack.enter_context(patch("app.services.geocoder._cache_get", return_value=None)) geoportal_mock = stack.enter_context(
stack.enter_context(patch("app.services.geocoder._cache_put")) patch("app.services.geocoder._geoportal_house_match", return_value=ekb_hit)
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._nominatim_lookup",
new=AsyncMock(return_value=tagil_result),
) )
cadastral_mock = stack.enter_context( )
patch("app.services.geocoder._cadastral_house_match", return_value=ekb_hit) result = await geocode("Ленина, 1", db, city_hint="Нижний Тагил")
)
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() geoportal_mock.assert_not_called()
cadastral_mock.assert_not_called() cadastral_mock.assert_not_called()
@ -459,7 +321,6 @@ async def test_suggest_city_hint_non_ekb_skips_cadastral_tier1() -> None:
forward_mock = stack.enter_context(patch("app.services.geocoder._cadastral_forward_sync")) forward_mock = stack.enter_context(patch("app.services.geocoder._cadastral_forward_sync"))
mock_settings = stack.enter_context(patch("app.services.geocoder.settings")) mock_settings = stack.enter_context(patch("app.services.geocoder.settings"))
mock_settings.dadata_api_token = None mock_settings.dadata_api_token = None
mock_settings.yandex_geocoder_api_key = None
stack.enter_context( stack.enter_context(
patch("app.services.geocoder._nominatim_suggest", new=AsyncMock(return_value=[])) patch("app.services.geocoder._nominatim_suggest", new=AsyncMock(return_value=[]))
) )
@ -472,13 +333,13 @@ async def test_suggest_city_hint_non_ekb_skips_cadastral_tier1() -> None:
async def test_geocode_city_ambiguous_true_when_no_city_known() -> None: async def test_geocode_city_ambiguous_true_when_no_city_known() -> None:
"""Ни адрес, ни city_hint не называют город → city_ambiguous=True.""" """Ни адрес, ни city_hint не называют город → city_ambiguous=True."""
db = MagicMock() db = MagicMock()
yandex_result = GeocodeResult(lat=56.838, lon=60.605, full_address="что-то", provider="yandex") nominatim_result = GeocodeResult(
with patch("app.services.geocoder.settings") as mock_settings: lat=56.838, lon=60.605, full_address="что-то", provider="nominatim"
mock_settings.yandex_geocoder_api_key = "fake" )
with contextlib.ExitStack() as stack: with contextlib.ExitStack() as stack:
for cm in _geocode_patches(yandex_result): for cm in _geocode_patches(nominatim_result):
stack.enter_context(cm) stack.enter_context(cm)
result = await geocode("Малышева, 30", db) result = await geocode("Малышева, 30", db)
assert result is not None assert result is not None
assert result.city_ambiguous is True assert result.city_ambiguous is True
@ -487,15 +348,13 @@ async def test_geocode_city_ambiguous_true_when_no_city_known() -> None:
async def test_geocode_city_ambiguous_false_when_marker_present() -> None: async def test_geocode_city_ambiguous_false_when_marker_present() -> None:
"""Явный "Екатеринбург" в адресе → город указан пользователем → city_ambiguous=False.""" """Явный "Екатеринбург" в адресе → город указан пользователем → city_ambiguous=False."""
db = MagicMock() db = MagicMock()
yandex_result = GeocodeResult( nominatim_result = GeocodeResult(
lat=56.838, lon=60.605, full_address="Екатеринбург, Малышева, 30", provider="yandex" lat=56.838, lon=60.605, full_address="Екатеринбург, Малышева, 30", provider="nominatim"
) )
with patch("app.services.geocoder.settings") as mock_settings: with contextlib.ExitStack() as stack:
mock_settings.yandex_geocoder_api_key = "fake" for cm in _geocode_patches(nominatim_result):
with contextlib.ExitStack() as stack: stack.enter_context(cm)
for cm in _geocode_patches(yandex_result): result = await geocode("Екатеринбург, Малышева, 30", db)
stack.enter_context(cm)
result = await geocode("Екатеринбург, Малышева, 30", db)
assert result is not None assert result is not None
assert result.city_ambiguous is False assert result.city_ambiguous is False
@ -504,15 +363,13 @@ async def test_geocode_city_ambiguous_false_when_marker_present() -> None:
async def test_geocode_city_ambiguous_false_when_city_hint_given() -> None: async def test_geocode_city_ambiguous_false_when_city_hint_given() -> None:
"""city_hint передан вызывающим кодом → город указан → city_ambiguous=False.""" """city_hint передан вызывающим кодом → город указан → city_ambiguous=False."""
db = MagicMock() db = MagicMock()
yandex_result = GeocodeResult( nominatim_result = GeocodeResult(
lat=57.905, lon=59.950, full_address="Нижний Тагил, Ленина, 1", provider="yandex" lat=57.905, lon=59.950, full_address="Нижний Тагил, Ленина, 1", provider="nominatim"
) )
with patch("app.services.geocoder.settings") as mock_settings: with contextlib.ExitStack() as stack:
mock_settings.yandex_geocoder_api_key = "fake" for cm in _geocode_patches(nominatim_result):
with contextlib.ExitStack() as stack: stack.enter_context(cm)
for cm in _geocode_patches(yandex_result): result = await geocode("Ленина, 1", db, city_hint="Нижний Тагил")
stack.enter_context(cm)
result = await geocode("Ленина, 1", db, city_hint="Нижний Тагил")
assert result is not None assert result is not None
assert result.city_ambiguous is False assert result.city_ambiguous is False
@ -535,45 +392,37 @@ async def test_geocode_cache_does_not_mix_cities() -> None:
def fake_cache_put(db, addr_norm, result): def fake_cache_put(db, addr_norm, result):
store[addr_norm] = result store[addr_norm] = result
async def fake_yandex_lookup(address, api_key, city_hint=None): async def fake_nominatim_lookup(address, city_hint=None):
if city_hint == "Нижний Тагил": if city_hint == "Нижний Тагил":
return GeocodeResult( return GeocodeResult(
lat=57.905, lon=59.950, full_address="Нижний Тагил, Ленина, 1", provider="yandex" lat=57.905, lon=59.950, full_address="Нижний Тагил, Ленина, 1", provider="nominatim"
) )
return GeocodeResult( return GeocodeResult(
lat=56.838, lon=60.605, full_address="Екатеринбург, Ленина, 1", provider="yandex" lat=56.838, lon=60.605, full_address="Екатеринбург, Ленина, 1", provider="nominatim"
) )
db = MagicMock() db = MagicMock()
with patch("app.services.geocoder.settings") as mock_settings: with contextlib.ExitStack() as stack:
mock_settings.yandex_geocoder_api_key = "fake" stack.enter_context(patch("app.services.geocoder._cache_get", side_effect=fake_cache_get))
with contextlib.ExitStack() as stack: stack.enter_context(patch("app.services.geocoder._cache_put", side_effect=fake_cache_put))
stack.enter_context( stack.enter_context(
patch("app.services.geocoder._cache_get", side_effect=fake_cache_get) patch("app.services.geocoder._geoportal_house_match", return_value=None)
) )
stack.enter_context( stack.enter_context(
patch("app.services.geocoder._cache_put", side_effect=fake_cache_put) patch("app.services.geocoder._cadastral_house_match", return_value=None)
) )
stack.enter_context( stack.enter_context(patch("app.services.geocoder._cadastral_forward_sync", return_value=[]))
patch("app.services.geocoder._geoportal_house_match", return_value=None) stack.enter_context(
) patch(
stack.enter_context( "app.services.geocoder._nominatim_lookup",
patch("app.services.geocoder._cadastral_house_match", return_value=None) new=AsyncMock(side_effect=fake_nominatim_lookup),
)
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_ekb = await geocode("Ленина, 1", db, city_hint="Екатеринбург")
r_tagil = await geocode("Ленина, 1", db, city_hint="Нижний Тагил") r_tagil = await geocode("Ленина, 1", db, city_hint="Нижний Тагил")
# Повторный запрос ЕКБ — должен снова попасть в СВОЙ кэш (не Тагила). # Повторный запрос ЕКБ — должен снова попасть в СВОЙ кэш (не Тагила).
r_ekb_again = 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 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_ekb.lat == pytest.approx(56.838)