All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
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 5m31s
Публичный `/suggest` и кабинетный `/api/v1/geocode/suggest` всегда звали геокодер с `region_code=66`: публичная ручка регион не передавала вовсе, а у кабинетной он был обязательным параметром со значением по умолчанию. `city_hint="Москва"` на это не влиял — DaData и Nominatim получали свердловский hard-констрейнт и молча возвращали ПУСТО. С сайта и из кабинета московский адрес просто нельзя было ввести, хотя оценка, проба покрытия и реестр регионов Москву уже поддерживают. Добавлен `effective_region_code()`: явный `region_code` важнее вывода из `city_hint`, вывод идёт через существующий реестр `app.services.regions` (`REGIONS[77].cities` содержит «москва»), последний рубеж — прежний `DEFAULT_REGION_CODE`. Отдельного списка городов не заводим: разъехаться двум спискам — вопрос времени. Поведение сегодняшних клиентов не меняется байт-в-байт: без `city_hint` и с любым свердловским городом регион по-прежнему 66. Публичная схема принимает `region_code` на будущее — если фронт когда-нибудь начнёт его слать, он будет приоритетнее хинта; неизвестный регион как и раньше отдаёт 422 из геокодера, а не 500. Тесты: четыре инварианта на сам хелпер (нет хинта → 66; свердловский город → 66; Москва → 77; явный 66 поверх Москвы → 66) и по одному на каждую ручку — что вниз по потоку уезжает ожидаемый регион. Прежние тесты region-скоупа геокодера не тронуты. 189 passed в связанных файлах, ruff чистый.
232 lines
11 KiB
Python
232 lines
11 KiB
Python
"""#3051: `suggest()` умеет регион — и по умолчанию остаётся свердловским.
|
||
|
||
ПОЧЕМУ ЭТО ВООБЩЕ ТЕСТ. Оба внешних тира подсказок ограничены регионом
|
||
ЖЁСТКИМ фильтром, а не бустом: DaData `locations.region` и Nominatim
|
||
`viewbox`+`bounded=1`. Промах региона не даёт ни ошибки, ни warning'а от
|
||
провайдера — выдача схлопывается в ПУСТОЙ список, неотличимый от «такого
|
||
адреса нет». Ровно так московский адрес молча возвращал ноль подсказок при
|
||
свердловском констрейнте. Значит проверять надо не результат, а то, ЧТО
|
||
именно уходит провайдеру.
|
||
|
||
Второй, более важный предмет проверки — регресс-нейтральность: вызов без
|
||
`region_code` обязан слать провайдерам те же самые значения, что и до правки.
|
||
|
||
Сеть не дёргаем: тиры мокаются по образцу `test_geocoder_city_hint`.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db")
|
||
|
||
import pytest
|
||
|
||
from app.api.v1 import geocode as geocode_module
|
||
from app.api.v1.geocode import SuggestResponse, effective_region_code
|
||
from app.services.geocoder import (
|
||
OBLAST66_VIEWBOX,
|
||
SVERDLOVSK_OBLAST_REGION,
|
||
_dadata_suggest,
|
||
_nominatim_suggest,
|
||
_viewbox_for_region,
|
||
suggest,
|
||
)
|
||
from app.services.regions import REGIONS
|
||
|
||
pytestmark = pytest.mark.anyio
|
||
|
||
|
||
# ── DaData-тир: имя региона в hard-констрейнте ───────────────────────────────
|
||
|
||
|
||
async def test_dadata_suggest_default_region_unchanged() -> None:
|
||
"""Без region_code — прежняя константа «Свердловская» (БЕЗ типа)."""
|
||
mock = AsyncMock(return_value=[])
|
||
with patch("app.services.geocoder.dadata.suggest_addresses", new=mock):
|
||
assert await _dadata_suggest("Малышева 30", limit=5) == []
|
||
assert mock.await_args.kwargs["region"] == SVERDLOVSK_OBLAST_REGION
|
||
assert mock.await_args.kwargs["region"] == "Свердловская"
|
||
assert mock.await_args.kwargs["city"] is None
|
||
|
||
|
||
async def test_dadata_suggest_region_77_sends_moscow() -> None:
|
||
"""region_code=77 — в DaData уходит «Москва», а не свердловский констрейнт."""
|
||
mock = AsyncMock(return_value=[])
|
||
with patch("app.services.geocoder.dadata.suggest_addresses", new=mock):
|
||
assert await _dadata_suggest("Тверская 6", limit=5, region_code=77) == []
|
||
assert mock.await_args.kwargs["region"] == "Москва"
|
||
|
||
|
||
async def test_dadata_suggest_unknown_region_raises() -> None:
|
||
"""Регион вне реестра — явная ошибка, а не молчаливый пустой список."""
|
||
with patch("app.services.geocoder.dadata.suggest_addresses", new=AsyncMock(return_value=[])):
|
||
with pytest.raises(ValueError, match="unknown region_code"):
|
||
await _dadata_suggest("Ленина 1", limit=5, region_code=99)
|
||
|
||
|
||
# ── Nominatim-тир: рамка региона ─────────────────────────────────────────────
|
||
|
||
|
||
def test_viewbox_for_region_66_is_literal_constant() -> None:
|
||
"""Для 66 рамка — историческая константа, не пересчёт из bbox реестра."""
|
||
assert _viewbox_for_region(66) == OBLAST66_VIEWBOX["viewbox"]
|
||
|
||
|
||
def test_viewbox_for_region_77_covers_moscow() -> None:
|
||
"""Рамка 77 строится из bbox_region реестра: lon_min,lat_max,lon_max,lat_min."""
|
||
lat_min, lat_max, lon_min, lon_max = REGIONS[77].bbox_region
|
||
assert _viewbox_for_region(77) == f"{lon_min},{lat_max},{lon_max},{lat_min}"
|
||
assert _viewbox_for_region(77) != OBLAST66_VIEWBOX["viewbox"]
|
||
|
||
|
||
async def test_nominatim_suggest_default_viewbox_and_suffix_unchanged() -> None:
|
||
"""Дефолтный вызов: свердловская рамка + ЕКБ-суффикс dual-query (#2580 C2)."""
|
||
seen: list[tuple[str, str]] = []
|
||
|
||
async def fake_get(url, params=None, **_kw):
|
||
seen.append((params["q"], params["viewbox"]))
|
||
response = MagicMock()
|
||
response.json.return_value = []
|
||
response.raise_for_status.return_value = None
|
||
return response
|
||
|
||
with (
|
||
patch("httpx.AsyncClient.get", new=AsyncMock(side_effect=fake_get)),
|
||
patch("app.services.geocoder._nominatim_throttle", new=AsyncMock()),
|
||
):
|
||
await _nominatim_suggest("Ленина, 1", limit=5)
|
||
|
||
queries = [q for q, _ in seen]
|
||
assert "Ленина, 1, Екатеринбург" in queries
|
||
assert "Ленина, 1" in queries
|
||
assert {vb for _, vb in seen} == {OBLAST66_VIEWBOX["viewbox"]}
|
||
|
||
|
||
async def test_nominatim_suggest_region_77_sends_moscow_frame() -> None:
|
||
"""region_code=77: московская рамка и московский суффикс, ЕКБ не упоминается."""
|
||
seen: list[tuple[str, str]] = []
|
||
|
||
async def fake_get(url, params=None, **_kw):
|
||
seen.append((params["q"], params["viewbox"]))
|
||
response = MagicMock()
|
||
response.json.return_value = []
|
||
response.raise_for_status.return_value = None
|
||
return response
|
||
|
||
with (
|
||
patch("httpx.AsyncClient.get", new=AsyncMock(side_effect=fake_get)),
|
||
patch("app.services.geocoder._nominatim_throttle", new=AsyncMock()),
|
||
):
|
||
await _nominatim_suggest("Тверская, 6", limit=5, region_code=77)
|
||
|
||
queries = [q for q, _ in seen]
|
||
assert "Тверская, 6, Москва" in queries
|
||
assert not any("Екатеринбург" in q for q in queries)
|
||
assert {vb for _, vb in seen} == {_viewbox_for_region(77)}
|
||
|
||
|
||
# ── suggest(): прокидывание региона и гейт локальных ЕКБ-тиров ───────────────
|
||
|
||
|
||
async def test_suggest_passes_region_to_both_tiers() -> None:
|
||
"""region_code доезжает и до DaData, и до Nominatim-фолбэка."""
|
||
dadata_mock = AsyncMock(return_value=[])
|
||
nominatim_mock = AsyncMock(return_value=[])
|
||
with (
|
||
patch("app.services.geocoder._dadata_suggest", new=dadata_mock),
|
||
patch("app.services.geocoder._nominatim_suggest", new=nominatim_mock),
|
||
patch("app.services.geocoder.settings") as mock_settings,
|
||
):
|
||
mock_settings.dadata_api_token = "token"
|
||
await suggest("Тверская 6", db=None, limit=5, region_code=77)
|
||
|
||
assert dadata_mock.await_args.args[2] == 77
|
||
assert nominatim_mock.await_args.kwargs["region_code"] == 77
|
||
|
||
|
||
async def test_suggest_region_77_skips_cadastral_tier() -> None:
|
||
"""Кадастровый тир (ЕКБ-FDW) для 77 не зовётся вовсе — данных там нет."""
|
||
db = MagicMock()
|
||
with (
|
||
patch("app.services.geocoder._cadastral_house_match") as house_mock,
|
||
patch("app.services.geocoder._cadastral_forward_sync") as forward_mock,
|
||
patch("app.services.geocoder._nominatim_suggest", new=AsyncMock(return_value=[])),
|
||
patch("app.services.geocoder.settings") as mock_settings,
|
||
):
|
||
mock_settings.dadata_api_token = None
|
||
await suggest("Тверская 6", db=db, limit=5, region_code=77)
|
||
|
||
house_mock.assert_not_called()
|
||
forward_mock.assert_not_called()
|
||
|
||
|
||
async def test_suggest_default_still_uses_cadastral_tier() -> None:
|
||
"""Регресс-контроль: дефолтный (66) вызов кадастровый тир по-прежнему зовёт."""
|
||
db = MagicMock()
|
||
with (
|
||
patch("app.services.geocoder._cadastral_house_match", return_value=None) as house_mock,
|
||
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]) as forward_mock,
|
||
patch("app.services.geocoder._nominatim_suggest", new=AsyncMock(return_value=[])),
|
||
patch("app.services.geocoder.settings") as mock_settings,
|
||
):
|
||
mock_settings.dadata_api_token = None
|
||
await suggest("Малышева 30", db=db, limit=5)
|
||
|
||
assert house_mock.called or forward_mock.called
|
||
|
||
|
||
async def test_suggest_unknown_region_raises() -> None:
|
||
"""Неизвестный регион — ValueError до похода к провайдерам."""
|
||
with pytest.raises(ValueError, match="unknown region_code"):
|
||
await suggest("Ленина 1", db=None, limit=5, region_code=99)
|
||
|
||
|
||
# ── API-слой: effective_region_code и хендлер /suggest ──────────────────────
|
||
#
|
||
# Всё выше проверяет, что `suggest()` в geocoder.py УМЕЕТ регион. Ниже —
|
||
# СЛЕДУЮЩИЙ слой той же истории #3051: сам HTTP-хендлер `/suggest` (и его
|
||
# публичный прокси в mera.py) до сих пор звал `suggest()` с `region_code=66`
|
||
# по умолчанию БЕЗУСЛОВНО, даже когда `city_hint='Москва'` уже прямо говорил,
|
||
# какой регион нужен. Приоритет: явный `region_code` > вывод из `city_hint`
|
||
# (реестр `app.services.regions`) > 66. Инварианты ниже — на хелпере
|
||
# `effective_region_code`, который эту логику и вносит.
|
||
|
||
|
||
def test_effective_region_no_hint_no_explicit_defaults_to_66() -> None:
|
||
assert effective_region_code(None, None) == 66
|
||
|
||
|
||
def test_effective_region_sverdlovsk_city_hint_stays_66() -> None:
|
||
assert effective_region_code(None, "Серов") == 66
|
||
|
||
|
||
def test_effective_region_moscow_city_hint_infers_77() -> None:
|
||
assert effective_region_code(None, "Москва") == 77
|
||
|
||
|
||
def test_effective_region_explicit_wins_over_moscow_hint() -> None:
|
||
assert effective_region_code(66, "Москва") == 66
|
||
|
||
|
||
async def test_suggest_handler_resolves_region_from_moscow_hint() -> None:
|
||
"""`suggest_addresses` без явного `region_code` реально передаёт вниз 77,
|
||
а не только хелпер в изоляции."""
|
||
captured: dict[str, object] = {}
|
||
|
||
async def _fake_suggest(q, db=None, limit=8, city_hint=None, region_code=66):
|
||
captured["region_code"] = region_code
|
||
return []
|
||
|
||
with patch.object(geocode_module, "suggest", _fake_suggest):
|
||
result = await geocode_module.suggest_addresses(
|
||
q="Тверская 6",
|
||
limit=8,
|
||
db=MagicMock(),
|
||
city_hint="Москва",
|
||
region_code=None,
|
||
)
|
||
|
||
assert isinstance(result, SuggestResponse)
|
||
assert captured["region_code"] == 77
|