chore(tradein/geocoder): удалить Яндекс-геокодер (#2593) #2596
12 changed files with 168 additions and 1013 deletions
|
|
@ -124,11 +124,11 @@ class ReverseResponse(BaseModel):
|
|||
precision: str = Field(
|
||||
...,
|
||||
description=(
|
||||
"Yandex-style: exact/number/street/range/near/locality/other/cadastral. "
|
||||
"exact/number/street/range/near/locality/other/cadastral. "
|
||||
"Фронт двигает marker только если exact/number/cadastral."
|
||||
),
|
||||
)
|
||||
provider: str = Field(..., description="cadastral | yandex | nominatim")
|
||||
provider: str = Field(..., description="cadastral | nominatim")
|
||||
|
||||
|
||||
@router.get("/reverse", response_model=ReverseResponse)
|
||||
|
|
|
|||
|
|
@ -71,11 +71,6 @@ class Settings(BaseSettings):
|
|||
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)
|
||||
contact_email: str = "erginrajpopxbe@outlook.com"
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ This helper:
|
|||
- applies idempotent CREATE or ALTER mapping on every backend startup so
|
||||
password rotation through .env.runtime is picked up after restart.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
|
@ -37,7 +38,7 @@ def ensure_fdw_user_mapping(db: Session) -> None:
|
|||
logger.warning(
|
||||
"GENDESIGN_FDW_PASSWORD not set — skipping FDW user mapping "
|
||||
"(gendesign_cad_buildings queries will fail; cadastral lookups will "
|
||||
"fall back to Yandex/Nominatim)"
|
||||
"fall back to Nominatim)"
|
||||
)
|
||||
return
|
||||
|
||||
|
|
@ -62,16 +63,20 @@ def ensure_fdw_user_mapping(db: Session) -> None:
|
|||
).first()
|
||||
|
||||
if exists is None:
|
||||
db.execute(text(
|
||||
f"CREATE USER MAPPING FOR CURRENT_USER SERVER gendesign_remote "
|
||||
f"OPTIONS (user 'tradein_fdw_reader', password '{password}')"
|
||||
))
|
||||
db.execute(
|
||||
text(
|
||||
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")
|
||||
else:
|
||||
db.execute(text(
|
||||
f"ALTER USER MAPPING FOR CURRENT_USER SERVER gendesign_remote "
|
||||
f"OPTIONS (SET password '{password}')"
|
||||
))
|
||||
db.execute(
|
||||
text(
|
||||
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")
|
||||
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -3201,12 +3201,13 @@ async def estimate_quality(
|
|||
Returns:
|
||||
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).
|
||||
geo: GeocodeResult | None = None
|
||||
# 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
|
||||
# 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
|
||||
# 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,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Стратегия:
|
||||
- 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
|
||||
lon: float
|
||||
full_address: str
|
||||
provider: Literal["nominatim", "yandex", "cache"]
|
||||
provider: Literal["nominatim", "cache"]
|
||||
confidence: Literal["exact", "approximate", "locality"] = "approximate"
|
||||
# #2576: True если город НЕ был указан пользователем (ни в тексте адреса, ни
|
||||
# через `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).
|
||||
OBLAST66_BBOX = (55.8, 62.2, 56.7, 66.6)
|
||||
|
||||
# Nominatim/Yandex viewport для областного (не только ЕКБ) поиска — тот же
|
||||
# формат что EKB_BBOX ниже, но покрывает всю область. viewbox — Nominatim
|
||||
# (lon1,lat1,lon2,lat2, NW→SE); ll/spn — Yandex центр+охват (lon,lat).
|
||||
# Nominatim viewport для областного (не только ЕКБ) поиска — покрывает всю
|
||||
# область. viewbox — Nominatim-формат (lon1,lat1,lon2,lat2, NW→SE); ll/spn —
|
||||
# legacy-поля того же словаря (были общим форматом с удалённым #2593 Yandex
|
||||
# Geocoder-тиром), не используются Nominatim-кодом напрямую.
|
||||
OBLAST66_VIEWBOX = {
|
||||
"viewbox": "56.7,62.2,66.6,55.8",
|
||||
"ll": "61.65,59.0",
|
||||
|
|
@ -104,10 +105,9 @@ def is_within_oblast66_bbox(lat: float, lon: float) -> bool:
|
|||
|
||||
|
||||
# Крупные города области (кроме ЕКБ) — НЕ исчерпывающий список (в области ~30
|
||||
# городов), покрывает основные региональные центры. Используется чтобы:
|
||||
# (a) не навязывать "Екатеринбург, " в запрос, если пользователь уже указал
|
||||
# другой город региона (геокодер forward/suggest prefix-логика);
|
||||
# (b) как known-locality set в _yandex_reverse при перестановке "улица, город".
|
||||
# городов), покрывает основные региональные центры. Используется чтобы не
|
||||
# навязывать "Екатеринбург, " в запрос, если пользователь уже указал другой
|
||||
# город региона (геокодер forward/suggest prefix-логика).
|
||||
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]:
|
||||
"""Определяет, какой город подставлять в запрос внешнему провайдеру (Yandex/
|
||||
Nominatim), когда сам текст адреса города не называет.
|
||||
"""Определяет, какой город подставлять в запрос внешнему провайдеру
|
||||
(Nominatim), когда сам текст адреса города не называет.
|
||||
|
||||
Приоритет:
|
||||
1. Адрес уже содержит маркер города/области региона 66 (`_has_oblast_marker`)
|
||||
|
|
@ -211,8 +211,8 @@ def _resolve_city_for_geocode(address: str, city_hint: str | None) -> tuple[str
|
|||
— для жителей других городов области это давало уверенно неверную цену
|
||||
(«Ленина, 1» в Нижнем Тагиле снапалось на екатеринбургскую улицу Ленина,
|
||||
обе улицы называются одинаково). Теперь НЕ подставляем никакой город —
|
||||
провайдер ищет по OBLAST66 viewbox/bbox (см. `_yandex_bias`,
|
||||
`OBLAST66_VIEWBOX`), без привязки к конкретному городу. Возвращает
|
||||
провайдер ищет по OBLAST66 viewbox/bbox (см. `OBLAST66_VIEWBOX`), без
|
||||
привязки к конкретному городу. Возвращает
|
||||
(None, False) — второй элемент False сигнализирует, что город
|
||||
пользователь НЕ указывал (источник `GeocodeResult.city_ambiguous`).
|
||||
|
||||
|
|
@ -227,23 +227,6 @@ def _resolve_city_for_geocode(address: str, city_hint: str | None) -> tuple[str
|
|||
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
|
||||
# локальные тиры (geoportal/cadastral, см. `geocode()`) от адреса другого
|
||||
# города области. re.escape на элементах SVERDLOVSK_OBLAST_CITIES-{ekb}.
|
||||
|
|
@ -585,8 +568,8 @@ async def _nominatim_lookup(address: str, city_hint: str | None = None) -> Geoco
|
|||
(предпочитает tight-ЕКБ bbox), который для одноимённых улиц ВНУТРИ региона
|
||||
(напр. "Ленина" — и в Екатеринбурге, и в с. Свердловское) не различает город.
|
||||
Эмпирически подтверждено: "Ленина 1" без города → случайное село внутри
|
||||
области; "Нижний Тагил, Ленина 1" → корректно резолвится. Раз Yandex-ключ
|
||||
сейчас недействителен (#2585), это единственный живой внешний провайдер —
|
||||
области; "Нижний Тагил, Ленина 1" → корректно резолвится. Nominatim —
|
||||
единственный живой внешний провайдер (#2593: Yandex Geocoder удалён) —
|
||||
city_hint должен реально влиять на его результат, не только на кэш-ключ.
|
||||
"""
|
||||
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 (автокомплит) ───────────────────────────────────────────────────
|
||||
# ЕКБ 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)
|
||||
|
|
@ -769,46 +619,14 @@ class GeocodeSuggestion:
|
|||
lon: float
|
||||
kind: str # 'house' / 'street' / 'locality'
|
||||
# ГАР OBJECTGUID (ФИАС) дома — заполняется ТОЛЬКО для house-level кандидатов
|
||||
# DaData-тира (fias_level 8/9). У Yandex/Nominatim/cadastral-тиров и у
|
||||
# DaData-тира (fias_level 8/9). У Nominatim/cadastral-тиров и у
|
||||
# street/locality-кандидатов остаётся None (нет стабильного house-fias).
|
||||
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 kind → GeocodeSuggestion.kind (внутри geocoder используется
|
||||
# 'locality' вместо 'city' — consistent с Yandex/Nominatim ветками).
|
||||
# 'locality' вместо 'city' — consistent с Nominatim-веткой).
|
||||
_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
|
||||
|
||||
|
||||
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]:
|
||||
"""Один Nominatim search с фильтром по bbox области (region 66). Возвращает up to N items."""
|
||||
response = await client.get(
|
||||
|
|
@ -1024,7 +747,8 @@ async def _nominatim_query_city_aware(
|
|||
async def _nominatim_suggest(
|
||||
query: str, limit: int = 8, city_hint: str | None = None
|
||||
) -> list[GeocodeSuggestion]:
|
||||
"""Nominatim в режиме suggest. С typo-fallback (для случаев когда Yandex недоступен).
|
||||
"""Nominatim в режиме suggest. С typo-fallback (для случаев когда оригинальный
|
||||
запрос ничего не находит).
|
||||
|
||||
Суффикс города навязывается, только если он известен: адрес уже называет
|
||||
город/область региона 66, либо передан явный `city_hint`. Если город
|
||||
|
|
@ -1384,16 +1108,17 @@ async def suggest(
|
|||
query: str, db: Session | None = None, limit: int = 8, city_hint: str | None = None
|
||||
) -> list[GeocodeSuggestion]:
|
||||
"""Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик,
|
||||
остаётся быстрым fast-path). Cadastral FDW → DaData → Yandex → Nominatim → [].
|
||||
остаётся быстрым fast-path). Cadastral FDW → DaData → Nominatim → [].
|
||||
|
||||
db: если передан — cadastral lookup через gendesign_cad_buildings (первый tier).
|
||||
city_hint: город, если известен вызывающему коду (#2576) — прокидывается в
|
||||
Yandex/Nominatim тиры, чтобы НЕ подставлять "Екатеринбург" молча, когда
|
||||
Nominatim-тир, чтобы НЕ подставлять "Екатеринбург" молча, когда
|
||||
пользователь его не называл. Опционально, backward-compatible (None —
|
||||
прежнее поведение минус молчаливый EKB-дефолт, см. `_resolve_city_for_geocode`).
|
||||
DaData /suggest (PR Q2) — token-only, 10k/день, заменяет Yandex который
|
||||
заблокирован (1k/день demo limit исчерпан). DaData region-constraint уже
|
||||
охватывает всю область (не только ЕКБ) — city_hint ей не нужен.
|
||||
DaData /suggest (PR Q2) — token-only, 10k/день, лучший fit для РФ-адресов
|
||||
(#2593: Yandex Geocoder, который был primary external provider до DaData,
|
||||
удалён). DaData region-constraint уже охватывает всю область (не только
|
||||
ЕКБ) — city_hint ей не нужен.
|
||||
Без кэша (дешёво, провайдеры толерируют автокомплит-запросы).
|
||||
"""
|
||||
if not query or len(query.strip()) < 2:
|
||||
|
|
@ -1405,7 +1130,7 @@ async def suggest(
|
|||
# незнакомый — структурный маркер (`_names_unrecognized_locality`), ИЛИ
|
||||
# `city_hint` называет город региона 66, отличный от ЕКБ (#2580 C1 —
|
||||
# иначе хинт мёртвый параметр для этого тира, см. `_ekb_local_tiers_allowed`
|
||||
# и `geocode()` ниже — тот же гейт). Внешние тиры (2/3/4 ниже) не гейтим —
|
||||
# и `geocode()` ниже — тот же гейт). Внешние тиры (2/3 ниже) не гейтим —
|
||||
# они уже oblast-aware.
|
||||
if db is not None and _ekb_local_tiers_allowed(query, city_hint):
|
||||
# 1a. Anchored house-match: парсим street+house → точный матч по дом-маркеру.
|
||||
|
|
@ -1422,28 +1147,16 @@ async def suggest(
|
|||
return cad_results
|
||||
|
||||
# Tier 2: DaData /suggest — token-only (X-Secret не нужен), 10k/день free,
|
||||
# лучший fit для РФ адресов. Заменил Yandex как primary external provider
|
||||
# после того как demo-key Yandex (1k/день) был исчерпан.
|
||||
# лучший fit для РФ адресов.
|
||||
if settings.dadata_api_token:
|
||||
try:
|
||||
dadata_results = await _dadata_suggest(query, limit)
|
||||
if dadata_results:
|
||||
return dadata_results
|
||||
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 есть)
|
||||
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, без ключа)
|
||||
# Tier 3: Nominatim (последний fallback — OSM, без ключа)
|
||||
try:
|
||||
return await _nominatim_suggest(query, limit, city_hint=city_hint)
|
||||
except Exception:
|
||||
|
|
@ -1453,15 +1166,15 @@ async def suggest(
|
|||
|
||||
# ── Public API ───────────────────────────────────────────────────────────────
|
||||
async def geocode(address: str, db: Session, city_hint: str | None = None) -> GeocodeResult | None:
|
||||
"""Геокодинг с кэшем. Cadastral FDW → Yandex → Nominatim → None.
|
||||
"""Геокодинг с кэшем. Cadastral FDW → Nominatim → None.
|
||||
|
||||
Args:
|
||||
address: пользовательский ввод (может быть грязным — нормализуем).
|
||||
db: сессия Postgres для cache lookup/write и cadastral FDW lookup.
|
||||
city_hint: город, если известен вызывающему коду (#2576) — например
|
||||
выбран пользователем на предыдущем шаге UI. Опциональный, не
|
||||
ломает существующий контракт. Прокидывается в Yandex/Nominatim
|
||||
внешние тиры вместо молчаливой подстановки "Екатеринбург" и
|
||||
ломает существующий контракт. Прокидывается в Nominatim-тир
|
||||
вместо молчаливой подстановки "Екатеринбург" и
|
||||
участвует в cache-ключе (см. `_cache_key`), чтобы ответы для
|
||||
разных городов по одному и тому же тексту адреса не перезатирали
|
||||
друг друга.
|
||||
|
|
@ -1575,42 +1288,7 @@ async def geocode(address: str, db: Session, city_hint: str | None = None) -> Ge
|
|||
)
|
||||
return result
|
||||
|
||||
# 3. Yandex (если есть key) с typo-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
|
||||
# 3. Nominatim fallback
|
||||
try:
|
||||
result = await _nominatim_lookup(address, city_hint)
|
||||
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'а) ──────────────────────────
|
||||
# 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 — найден дом с номером (то что нам надо для квартирного оценщика)
|
||||
# - cadastral — Cadastral FDW row (то же по точности что Yandex "number")
|
||||
# - cadastral — Cadastral FDW row (та же точность, что и "number")
|
||||
# Остальные (street/range/near/locality/other) → не снапаем, marker остаётся на клике.
|
||||
_SNAP_PRECISIONS = {"exact", "number", "cadastral"}
|
||||
|
||||
|
|
@ -1643,14 +1326,14 @@ class ReverseGeocodeResult:
|
|||
- `snapped_lat` — координата центра здания если provider дал её,
|
||||
иначе echo `lat` входной точки (для precision=street/locality).
|
||||
- `snapped_lon` — то же.
|
||||
- `precision` — yandex-style: `exact`/`number`/`street`/`range`/`near`/
|
||||
`locality`/`other`/`cadastral`. Используется фронтом чтобы
|
||||
- `precision` — `exact`/`number`/`street`/`range`/`near`/`locality`/
|
||||
`other`/`cadastral`. Используется фронтом чтобы
|
||||
решить — двигать marker (exact/number) или нет.
|
||||
- `provider` — кто дал результат (`yandex`/`nominatim`/`cadastral`).
|
||||
- `provider` — кто дал результат (`nominatim`/`cadastral`).
|
||||
|
||||
Фронт MapPicker'а после клика смотрит на precision: если `exact`/`number`
|
||||
и snapped >5m от click point — пересаживает marker на snapped point
|
||||
(чтобы пользователь видел центр дома по Яндексу, а не свой клик во дворе).
|
||||
(чтобы пользователь видел центр дома, а не свой клик во дворе).
|
||||
Для остальных precision marker остаётся где кликнули — не врём что нашли
|
||||
точное здание.
|
||||
"""
|
||||
|
|
@ -1659,7 +1342,7 @@ class ReverseGeocodeResult:
|
|||
snapped_lat: float
|
||||
snapped_lon: float
|
||||
precision: str
|
||||
provider: Literal["yandex", "nominatim", "cadastral"]
|
||||
provider: Literal["nominatim", "cadastral"]
|
||||
|
||||
|
||||
def _format_reverse_address(addr: dict) -> str | None:
|
||||
|
|
@ -1686,77 +1369,6 @@ def _format_reverse_address(addr: dict) -> str | None:
|
|||
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))
|
||||
async def _nominatim_reverse(lat: float, lon: float) -> ReverseGeocodeResult | None:
|
||||
"""Nominatim /reverse → ReverseGeocodeResult с snapped coords из item.lat/lon.
|
||||
|
|
@ -1856,7 +1468,7 @@ def _cadastral_reverse_sync_full(
|
|||
async def reverse_geocode(
|
||||
lat: float, lon: float, db: Session | None = None
|
||||
) -> ReverseGeocodeResult | None:
|
||||
"""Cadastral FDW → Yandex (если key) → Nominatim. Возвращает snapped coords.
|
||||
"""Cadastral FDW → Nominatim. Возвращает snapped coords.
|
||||
|
||||
Возвращает None если ни один источник не дал адрес. Endpoint
|
||||
api/v1/geocode/reverse сам выкинет 404. НЕ даёт выйти HTTPStatusError
|
||||
|
|
@ -1881,16 +1493,7 @@ async def reverse_geocode(
|
|||
provider="cadastral",
|
||||
)
|
||||
|
||||
# 2. Yandex — основной источник snap'а (его Point.pos = центр здания)
|
||||
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)
|
||||
# 2. Nominatim fallback (wrap to prevent 500 on ban/rate-limit)
|
||||
try:
|
||||
return await _nominatim_reverse(lat, lon)
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
— wired into in-app scheduler, window 06:00-09:00 UTC.
|
||||
|
||||
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):
|
||||
- Этот модуль группирует по address → меньше API calls (dedup).
|
||||
|
|
|
|||
|
|
@ -176,24 +176,18 @@ def _nom_result(address: str) -> ReverseGeocodeResult:
|
|||
|
||||
|
||||
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()
|
||||
with (
|
||||
patch(
|
||||
"app.services.geocoder._cadastral_reverse_sync_full",
|
||||
return_value=("ул. Малышева, 30, Екатеринбург", 56.8385, 60.6052),
|
||||
) as mock_cad,
|
||||
patch("app.services.geocoder.settings") as mock_settings,
|
||||
patch(
|
||||
"app.services.geocoder._yandex_reverse",
|
||||
new_callable=AsyncMock,
|
||||
) 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.605, db=db)
|
||||
|
||||
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.provider == "cadastral"
|
||||
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()
|
||||
|
||||
|
||||
|
|
@ -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:
|
||||
"""Cadastral + Yandex + Nominatim — все падают → returns None, no exception."""
|
||||
"""Cadastral + Nominatim — все падают → returns None, no exception."""
|
||||
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 429 rate-limited"),
|
||||
),
|
||||
patch(
|
||||
"app.services.geocoder._nominatim_reverse",
|
||||
new_callable=AsyncMock,
|
||||
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)
|
||||
|
||||
# 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:
|
||||
"""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 (
|
||||
patch(
|
||||
"app.services.geocoder._cadastral_reverse_sync_full",
|
||||
) as mock_cad,
|
||||
patch("app.services.geocoder.settings") as mock_settings,
|
||||
patch(
|
||||
"app.services.geocoder._nominatim_reverse",
|
||||
new_callable=AsyncMock,
|
||||
return_value=_nom_result("ул. Ленина, 1, Екатеринбург"),
|
||||
),
|
||||
):
|
||||
mock_settings.yandex_geocoder_api_key = None
|
||||
result = await reverse_geocode(56.838, 60.605, db=None)
|
||||
|
||||
mock_cad.assert_not_called()
|
||||
|
|
@ -307,36 +257,11 @@ async def test_reverse_geocode_without_db_skips_cadastral() -> None:
|
|||
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 ─────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_geocode_uses_cadastral_before_yandex() -> None:
|
||||
"""Cadastral FDW returns suggestion → geocode returns it without hitting Yandex."""
|
||||
async def test_geocode_uses_cadastral_before_nominatim() -> None:
|
||||
"""Cadastral FDW returns suggestion → geocode returns it without hitting Nominatim."""
|
||||
db = MagicMock()
|
||||
# Cache miss
|
||||
db.execute.side_effect = [
|
||||
|
|
@ -362,7 +287,7 @@ async def test_geocode_uses_cadastral_before_yandex() -> None:
|
|||
return_value=[cad_suggestion],
|
||||
) as mock_cad,
|
||||
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)
|
||||
|
||||
|
|
@ -371,14 +296,14 @@ async def test_geocode_uses_cadastral_before_yandex() -> None:
|
|||
assert result.lon == 60.610
|
||||
assert result.confidence == "exact"
|
||||
mock_cad.assert_called_once()
|
||||
mock_yandex.assert_not_called()
|
||||
mock_nominatim.assert_not_called()
|
||||
|
||||
|
||||
# ── suggest: cadastral as first tier ─────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_suggest_uses_cadastral_before_yandex() -> None:
|
||||
"""Cadastral FDW returns results → suggest returns them without hitting Yandex."""
|
||||
async def test_suggest_uses_cadastral_before_nominatim() -> None:
|
||||
"""Cadastral FDW returns results → suggest returns them without hitting Nominatim."""
|
||||
db = MagicMock()
|
||||
cad_suggestions = [
|
||||
GeocodeSuggestion(
|
||||
|
|
@ -396,53 +321,20 @@ async def test_suggest_uses_cadastral_before_yandex() -> None:
|
|||
return_value=cad_suggestions,
|
||||
) as mock_cad,
|
||||
patch(
|
||||
"app.services.geocoder._yandex_suggest",
|
||||
"app.services.geocoder._nominatim_suggest",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_yandex,
|
||||
) as mock_nominatim,
|
||||
):
|
||||
results = await suggest("Малышева", db=db, limit=8)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].full_address == "ул. Малышева, 30, Екатеринбург"
|
||||
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:
|
||||
"""Cadastral returns [] → DaData not configured → falls through to Yandex."""
|
||||
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)."""
|
||||
async def test_suggest_uses_dadata_before_nominatim_when_cadastral_empty() -> None:
|
||||
"""Cadastral returns [] → DaData tier returns results → Nominatim never called (PR Q2)."""
|
||||
db = MagicMock()
|
||||
dadata_result = GeocodeSuggestion(
|
||||
label="г Екатеринбург, ул Малышева, д 30",
|
||||
|
|
@ -461,18 +353,17 @@ async def test_suggest_uses_dadata_before_yandex_when_cadastral_empty() -> None:
|
|||
return_value=[dadata_result],
|
||||
) as mock_dadata,
|
||||
patch(
|
||||
"app.services.geocoder._yandex_suggest",
|
||||
"app.services.geocoder._nominatim_suggest",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_yandex,
|
||||
) as mock_nominatim,
|
||||
):
|
||||
mock_settings.dadata_api_token = "fake-dadata-token"
|
||||
mock_settings.yandex_geocoder_api_key = "fake-yandex-key"
|
||||
results = await suggest("Малышева", db=db, limit=8)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].lat == 56.838011
|
||||
mock_dadata.assert_called_once()
|
||||
mock_yandex.assert_not_called()
|
||||
mock_nominatim.assert_not_called()
|
||||
|
||||
|
||||
# ── ensure_fdw_user_mapping: SQL injection / whitelist guards ─────────────────
|
||||
|
|
|
|||
|
|
@ -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_forward_sync") as mock_forward,
|
||||
patch("app.services.geocoder._cache_put"),
|
||||
patch("app.services.geocoder._yandex_lookup", new_callable=AsyncMock) as mock_yandex,
|
||||
):
|
||||
result = await geocode("Космонавтов 7б", db)
|
||||
|
||||
|
|
@ -273,7 +272,6 @@ async def test_geocode_uses_geoportal_first() -> None:
|
|||
# geoportal hit short-circuits everything downstream
|
||||
mock_cad.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:
|
||||
|
|
@ -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_forward_sync") as mock_forward,
|
||||
patch("app.services.geocoder._cache_put"),
|
||||
patch("app.services.geocoder._yandex_lookup", new_callable=AsyncMock) as mock_yandex,
|
||||
):
|
||||
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_cad.assert_called_once()
|
||||
mock_forward.assert_not_called()
|
||||
mock_yandex.assert_not_called()
|
||||
|
||||
|
||||
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_forward_sync", return_value=[]) as mock_forward,
|
||||
patch("app.services.geocoder._cache_put"),
|
||||
patch("app.services.geocoder.settings") as mock_settings,
|
||||
patch(
|
||||
"app.services.geocoder._nominatim_lookup",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
mock_settings.yandex_geocoder_api_key = None
|
||||
result = await geocode("полный мусор без дома", db)
|
||||
|
||||
assert result is None
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ Bug context (issue #582 Phase 5):
|
|||
оставался где user кликнул — иногда в проезде / дворе.
|
||||
|
||||
Fix: backend теперь возвращает snapped_lat/snapped_lon (центр matched здания
|
||||
от Yandex/Nominatim/cadastral) + precision, фронт двигает marker если precision
|
||||
от Nominatim/cadastral) + precision, фронт двигает marker если precision
|
||||
in (exact, number, cadastral).
|
||||
"""
|
||||
|
||||
|
|
@ -46,6 +46,7 @@ def app() -> FastAPI:
|
|||
|
||||
# ── Endpoint response shape ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_reverse_endpoint_returns_snapped_fields(app: FastAPI) -> None:
|
||||
"""Endpoint should include address + lat/lon (echo) + snapped_lat/snapped_lon + precision."""
|
||||
client = TestClient(app)
|
||||
|
|
@ -54,7 +55,7 @@ def test_reverse_endpoint_returns_snapped_fields(app: FastAPI) -> None:
|
|||
snapped_lat=56.838004,
|
||||
snapped_lon=60.586155,
|
||||
precision="exact",
|
||||
provider="yandex",
|
||||
provider="cadastral",
|
||||
)
|
||||
with patch(
|
||||
"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_lon"] == 60.586155
|
||||
assert body["precision"] == "exact"
|
||||
assert body["provider"] == "yandex"
|
||||
assert body["provider"] == "cadastral"
|
||||
|
||||
|
||||
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 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_snap_precision_useful_exact_and_number() -> None:
|
||||
assert snap_precision_useful("exact") 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("other") is False
|
||||
assert snap_precision_useful("") is False
|
||||
|
||||
|
||||
# ── Yandex reverse parsing ───────────────────────────────────────────────────
|
||||
|
||||
async def test_yandex_reverse_parses_snapped_point_and_precision() -> None:
|
||||
"""`_yandex_reverse` извлекает Point.pos (lon lat) и precision из metaDataProperty."""
|
||||
import httpx
|
||||
|
||||
from app.services.geocoder import _yandex_reverse
|
||||
|
||||
sample = {
|
||||
"response": {
|
||||
"GeoObjectCollection": {
|
||||
"featureMember": [
|
||||
{
|
||||
"GeoObject": {
|
||||
"metaDataProperty": {
|
||||
"GeocoderMetaData": {
|
||||
"precision": "exact",
|
||||
"text": (
|
||||
"Россия, Свердловская область, "
|
||||
"Екатеринбург, улица Малышева, 51"
|
||||
),
|
||||
"kind": "house",
|
||||
}
|
||||
},
|
||||
"name": "улица Малышева, 51",
|
||||
"Point": {"pos": "60.586155 56.838004"},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeResp:
|
||||
status_code = 200
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
def json(self) -> dict:
|
||||
return sample
|
||||
|
||||
class _FakeClient:
|
||||
async def __aenter__(self) -> _FakeClient:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_: object) -> None:
|
||||
return None
|
||||
|
||||
async def get(self, *_: object, **__: object) -> _FakeResp:
|
||||
return _FakeResp()
|
||||
|
||||
with patch.object(httpx, "AsyncClient", lambda *a, **kw: _FakeClient()):
|
||||
result = await _yandex_reverse(56.8381, 60.5860, api_key="fake")
|
||||
|
||||
assert result is not None
|
||||
# Yandex pos формат: "lon lat" → snapped_lat=56.838004, snapped_lon=60.586155
|
||||
assert abs(result.snapped_lat - 56.838004) < 1e-6
|
||||
assert abs(result.snapped_lon - 60.586155) < 1e-6
|
||||
assert result.precision == "exact"
|
||||
assert result.provider == "yandex"
|
||||
# Address text должен быть очищен от "Россия, Свердловская область"
|
||||
assert "Россия" not in result.address
|
||||
assert "Свердловская область" not in result.address
|
||||
assert "Малышева" in result.address
|
||||
assert "51" in result.address
|
||||
|
||||
|
||||
async def test_yandex_reverse_returns_none_on_empty_results() -> None:
|
||||
"""Empty featureMember → None."""
|
||||
import httpx
|
||||
|
||||
from app.services.geocoder import _yandex_reverse
|
||||
|
||||
sample = {"response": {"GeoObjectCollection": {"featureMember": []}}}
|
||||
|
||||
class _FakeResp:
|
||||
status_code = 200
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
def json(self) -> dict:
|
||||
return sample
|
||||
|
||||
class _FakeClient:
|
||||
async def __aenter__(self) -> _FakeClient:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_: object) -> None:
|
||||
return None
|
||||
|
||||
async def get(self, *_: object, **__: object) -> _FakeResp:
|
||||
return _FakeResp()
|
||||
|
||||
with patch.object(httpx, "AsyncClient", lambda *a, **kw: _FakeClient()):
|
||||
result = await _yandex_reverse(56.0, 60.0, api_key="fake")
|
||||
|
||||
assert result is None
|
||||
|
|
|
|||
|
|
@ -8,12 +8,10 @@
|
|||
инвариант OBLAST66 ⊇ TIGHT, дальние города области, известное ограничение (Тюмень
|
||||
внутри bbox координатно — компенсируется region cross-check на accept-сайтах);
|
||||
- _has_oblast_marker — word-boundary matching (не substring);
|
||||
- _nominatim_query / _yandex_lookup accept-сайты — two-pass tie-break (tight ЕКБ
|
||||
приоритетнее) + region cross-check (отсекает Тюмень и т.п. даже внутри bbox).
|
||||
- _nominatim_query accept-сайт — two-pass tie-break (tight ЕКБ приоритетнее) +
|
||||
region cross-check (отсекает Тюмень и т.п. даже внутри bbox).
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
|
|
@ -27,7 +25,6 @@ from app.services.geocoder import (
|
|||
_names_non_ekb_city,
|
||||
_names_unrecognized_locality,
|
||||
_nominatim_query,
|
||||
_yandex_lookup,
|
||||
is_within_ekb_bbox,
|
||||
is_within_ekb_bbox_wide,
|
||||
is_within_oblast66_bbox,
|
||||
|
|
@ -145,9 +142,9 @@ def test_oblast66_bbox_admits_tyumen_by_design() -> None:
|
|||
|
||||
Это ЗНАЕМОЕ и намеренное ограничение самого bbox-хелпера (дешевле
|
||||
false-positive у границы, чем false-negative на корректном адресе области).
|
||||
Реальное отсечение Тюмени происходит на accept-сайтах через region
|
||||
cross-check — см. test_nominatim_query_rejects_out_of_region_only_candidate /
|
||||
test_yandex_lookup_rejects_out_of_region_only_candidate ниже.
|
||||
Реальное отсечение Тюмени происходит на accept-сайте через region
|
||||
cross-check — см. test_nominatim_query_rejects_out_of_region_only_candidate
|
||||
ниже.
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -188,7 +188,6 @@ async def test_geocode_uses_house_match_before_legacy_forward() -> None:
|
|||
"app.services.geocoder._cadastral_forward_sync",
|
||||
) as mock_forward,
|
||||
patch("app.services.geocoder._cache_put"),
|
||||
patch("app.services.geocoder._yandex_lookup", new_callable=AsyncMock) as mock_yandex,
|
||||
):
|
||||
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()
|
||||
# house-match hit → legacy raw-ILIKE forward never invoked
|
||||
mock_forward.assert_not_called()
|
||||
mock_yandex.assert_not_called()
|
||||
|
||||
|
||||
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],
|
||||
) as mock_forward,
|
||||
patch("app.services.geocoder._cache_put"),
|
||||
patch("app.services.geocoder._yandex_lookup", new_callable=AsyncMock) as mock_yandex,
|
||||
):
|
||||
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
|
||||
mock_house.assert_called_once()
|
||||
mock_forward.assert_called_once()
|
||||
mock_yandex.assert_not_called()
|
||||
|
||||
|
||||
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=[],
|
||||
) as mock_forward,
|
||||
patch("app.services.geocoder._cache_put"),
|
||||
patch("app.services.geocoder.settings") as mock_settings,
|
||||
patch(
|
||||
"app.services.geocoder._nominatim_lookup",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
mock_settings.yandex_geocoder_api_key = None
|
||||
result = await geocode("полный мусор без дома", db)
|
||||
|
||||
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_forward_sync") as mock_forward,
|
||||
patch("app.services.geocoder._cache_put"),
|
||||
patch("app.services.geocoder.settings") as mock_settings,
|
||||
patch(
|
||||
"app.services.geocoder._nominatim_lookup",
|
||||
new_callable=AsyncMock,
|
||||
return_value=nominatim_result,
|
||||
) as mock_nominatim,
|
||||
):
|
||||
mock_settings.yandex_geocoder_api_key = None
|
||||
result = await geocode("Нижний Тагил, проспект Ленина, 1", db)
|
||||
|
||||
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_forward_sync") as mock_forward,
|
||||
patch("app.services.geocoder._cache_put"),
|
||||
patch("app.services.geocoder.settings") as mock_settings,
|
||||
patch(
|
||||
"app.services.geocoder._nominatim_lookup",
|
||||
new_callable=AsyncMock,
|
||||
return_value=nominatim_result,
|
||||
) as mock_nominatim,
|
||||
):
|
||||
mock_settings.yandex_geocoder_api_key = None
|
||||
result = await geocode(address, db)
|
||||
|
||||
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,
|
||||
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.settings") as mock_settings,
|
||||
patch(
|
||||
"app.services.geocoder._nominatim_lookup",
|
||||
new_callable=AsyncMock,
|
||||
return_value=rezh_result,
|
||||
),
|
||||
):
|
||||
mock_settings.yandex_geocoder_api_key = None
|
||||
result = await geocode("Ленина, 1", db, city_hint="Реж")
|
||||
|
||||
mock_geoportal.assert_not_called()
|
||||
|
|
@ -501,7 +489,6 @@ async def test_suggest_uses_house_match_before_legacy_forward() -> None:
|
|||
patch(
|
||||
"app.services.geocoder._cadastral_forward_sync",
|
||||
) as mock_forward,
|
||||
patch("app.services.geocoder._yandex_suggest", new_callable=AsyncMock) as mock_yandex,
|
||||
):
|
||||
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
|
||||
mock_house.assert_called_once()
|
||||
mock_forward.assert_not_called()
|
||||
mock_yandex.assert_not_called()
|
||||
|
||||
|
||||
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",
|
||||
return_value=legacy,
|
||||
) as mock_forward,
|
||||
patch("app.services.geocoder._yandex_suggest", new_callable=AsyncMock) as mock_yandex,
|
||||
):
|
||||
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
|
||||
mock_house.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:
|
||||
|
|
@ -571,7 +555,6 @@ async def test_suggest_skips_ekb_local_tier_for_non_ekb_city() -> None:
|
|||
) as mock_nominatim,
|
||||
):
|
||||
mock_settings.dadata_api_token = None
|
||||
mock_settings.yandex_geocoder_api_key = None
|
||||
results = await suggest("Нижний Тагил, проспект Ленина, 1", db=db, limit=8)
|
||||
|
||||
assert len(results) == 1
|
||||
|
|
@ -615,7 +598,6 @@ async def test_suggest_skips_ekb_local_tier_for_unrecognized_locality(
|
|||
) as mock_nominatim,
|
||||
):
|
||||
mock_settings.dadata_api_token = None
|
||||
mock_settings.yandex_geocoder_api_key = None
|
||||
results = await suggest(query, db=db, limit=8)
|
||||
|
||||
assert len(results) == 1, label
|
||||
|
|
|
|||
|
|
@ -1,18 +1,20 @@
|
|||
"""Тесты #2576 — geocoder больше НЕ подставляет "Екатеринбург" молча.
|
||||
|
||||
Проблема (issue #2576 / эпик расширения на область): `_yandex_lookup`,
|
||||
`_yandex_suggest`, `_nominatim_suggest` при отсутствии маркера города/области в
|
||||
самом адресе всегда молча подставляли "Екатеринбург" — житель Нижнего Тагила,
|
||||
вводя «Ленина, 1», получал уверенно неверную цену по екатеринбургской улице
|
||||
Ленина (обе улицы называются одинаково).
|
||||
Проблема (issue #2576 / эпик расширения на область): `_nominatim_lookup`,
|
||||
`_nominatim_suggest` при отсутствии маркера города/области в самом адресе
|
||||
всегда молча подставляли "Екатеринбург" — житель Нижнего Тагила, вводя
|
||||
«Ленина, 1», получал уверенно неверную цену по екатеринбургской улице Ленина
|
||||
(обе улицы называются одинаково).
|
||||
|
||||
#2593: Yandex Geocoder (`_yandex_lookup`/`_yandex_suggest`) удалён из geocoder —
|
||||
их тесты здесь удалены вместе с ним; покрытие тех же сценариев (bias/prefix
|
||||
без молчаливого "Екатеринбург") осталось на `_nominatim_lookup`.
|
||||
|
||||
Покрывают:
|
||||
- `_resolve_city_for_geocode` — приоритет: маркер в адресе > `city_hint` > None.
|
||||
- `_yandex_lookup` — без города НЕ получает "Екатеринбург, "-префикс и bias
|
||||
смещён на всю область (не форсит ЕКБ-центр); с `city_hint` — префикс из hint'а;
|
||||
с явным "Екатеринбург" в адресе — поведение НЕ изменилось (как раньше).
|
||||
- `_yandex_suggest` (Tier 1 bounded) — то же самое, плюс bias/rspn.
|
||||
- `_nominatim_suggest` — то же самое (суффикс города, не префикс).
|
||||
- `_nominatim_suggest` — без города НЕ форсит city-суффикс молчаливо (dual-query);
|
||||
с `city_hint` — суффикс из hint'а; с явным "Екатеринбург" в адресе — поведение
|
||||
НЕ изменилось (как раньше).
|
||||
- `geocode()` — `city_ambiguous=True` когда город не указан ни в адресе, ни в
|
||||
`city_hint`; `False` когда указан явно (текстом или через `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")
|
||||
|
||||
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,
|
||||
)
|
||||
|
|
@ -93,139 +90,6 @@ def test_cache_key_hint_normalized() -> None:
|
|||
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 ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -363,14 +227,16 @@ async def test_nominatim_suggest_dedupe_across_dual_query() -> None:
|
|||
# ── geocode() — city_ambiguous flag ──────────────────────────────────────────
|
||||
|
||||
|
||||
def _geocode_patches(yandex_result: GeocodeResult | None):
|
||||
def _geocode_patches(nominatim_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)),
|
||||
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",
|
||||
)
|
||||
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:
|
||||
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)
|
||||
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._nominatim_lookup",
|
||||
new=AsyncMock(return_value=tagil_result),
|
||||
)
|
||||
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="Нижний Тагил")
|
||||
)
|
||||
result = await geocode("Ленина, 1", db, city_hint="Нижний Тагил")
|
||||
|
||||
geoportal_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"))
|
||||
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=[]))
|
||||
)
|
||||
|
|
@ -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:
|
||||
"""Ни адрес, ни 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)
|
||||
nominatim_result = GeocodeResult(
|
||||
lat=56.838, lon=60.605, full_address="что-то", provider="nominatim"
|
||||
)
|
||||
with contextlib.ExitStack() as stack:
|
||||
for cm in _geocode_patches(nominatim_result):
|
||||
stack.enter_context(cm)
|
||||
result = await geocode("Малышева, 30", db)
|
||||
|
||||
assert result is not None
|
||||
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:
|
||||
"""Явный "Екатеринбург" в адресе → город указан пользователем → city_ambiguous=False."""
|
||||
db = MagicMock()
|
||||
yandex_result = GeocodeResult(
|
||||
lat=56.838, lon=60.605, full_address="Екатеринбург, Малышева, 30", provider="yandex"
|
||||
nominatim_result = GeocodeResult(
|
||||
lat=56.838, lon=60.605, full_address="Екатеринбург, Малышева, 30", provider="nominatim"
|
||||
)
|
||||
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)
|
||||
with contextlib.ExitStack() as stack:
|
||||
for cm in _geocode_patches(nominatim_result):
|
||||
stack.enter_context(cm)
|
||||
result = await geocode("Екатеринбург, Малышева, 30", db)
|
||||
|
||||
assert result is not None
|
||||
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:
|
||||
"""city_hint передан вызывающим кодом → город указан → city_ambiguous=False."""
|
||||
db = MagicMock()
|
||||
yandex_result = GeocodeResult(
|
||||
lat=57.905, lon=59.950, full_address="Нижний Тагил, Ленина, 1", provider="yandex"
|
||||
nominatim_result = GeocodeResult(
|
||||
lat=57.905, lon=59.950, full_address="Нижний Тагил, Ленина, 1", provider="nominatim"
|
||||
)
|
||||
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="Нижний Тагил")
|
||||
with contextlib.ExitStack() as stack:
|
||||
for cm in _geocode_patches(nominatim_result):
|
||||
stack.enter_context(cm)
|
||||
result = await geocode("Ленина, 1", db, city_hint="Нижний Тагил")
|
||||
|
||||
assert result is not None
|
||||
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):
|
||||
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 == "Нижний Тагил":
|
||||
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(
|
||||
lat=56.838, lon=60.605, full_address="Екатеринбург, Ленина, 1", provider="yandex"
|
||||
lat=56.838, lon=60.605, full_address="Екатеринбург, Ленина, 1", provider="nominatim"
|
||||
)
|
||||
|
||||
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),
|
||||
)
|
||||
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._nominatim_lookup",
|
||||
new=AsyncMock(side_effect=fake_nominatim_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="Екатеринбург")
|
||||
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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue