gendesign/tradein-mvp/backend/tests/test_3051_suggest_region_scope.py
bot-backend ef82a707fc
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
fix(mera): московский city_hint больше не уходит молча в регион 66
Публичный `/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 чистый.
2026-09-12 13:35:41 +03:00

232 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""#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