Три куска, каждый нужен, чтобы поиск и оценка по Москве заработали end-to-end. 1. Импортёр msk_raw -> listings (app/tasks/msk_raw_import.py). Переиспользует штатный save_listings из кита: писатель уже параметризован регионом, свой не нужен. payload в msk_raw — сериализованный ScrapedLot один в один, так что импорт сводится к сборке модели и вызову писателя. Москва отбирается по префиксу административного округа в адресе, а не по bbox. Причина: адрес Циан не содержит города, а границы региона 77 захватывают ближний пояс области. Замер по проду: с округом 35 552, все внутри bbox 77; без округа внутри bbox 17 576 — это область. Отдельно отсекаются 212 карточек с адресом «Екатеринбург (Cian)», артефакт парсера. listing_segment ПЕРЕСЧИТЫВАЕТСЯ перед записью, а не копируется из payload. Кит ставит novostroyki по одному наличию offer.newbuilding.id. Замер по всем 60 464 карточкам: is_from_developer=true у НУЛЯ, false у 29 000, отсутствует у 31 464. Застройщик не продаёт ни одной карточки корпуса. В проде есть гвард (estimator.py): в аналоги идут строки только с listing_segment IS NULL или 'vtorichka' — копирование метки как есть выбросило бы 29 000 строк из подбора. Авито импортируется только с явным --allow-unfiltered: в его адресе нет ни города, ни округа, координат нет ни у одной из 50 335 карточек, отличить область от Москвы нечем. Прогон на проде: прочитано 60 464, записано 35 552, все с геометрией, 35 182 привязаны к дому, создано 10 960 домов. Повторный проход строки не дублирует — idempotency на dedup_hash, проверено. 2. Подсказки адреса стали региональными (geocoder.suggest, api/v1/geocode). Раньше suggest вообще не принимал регион: DaData звалась с жёстким region='Свердловская', Nominatim — с viewbox 66-го и bounded=1. Московский адрес давал ПУСТОЙ список молча, без ошибки; в коде это уже было описано как известный баг. Механику по регионам переиспользовали из geocode(), вторую не писали. Кадастровый тир для не-66 не зовётся: он на ЕКБ-данных. 3. Оценка перестала геокодировать Москву свердловским скоупом (estimator). geocode() звалась без региона, то есть с дефолтом 66, и московский адрес возвращал бы пустую оценку с причиной address_not_geocoded даже с рабочими подсказками. Регион запроса определяется по координатам через реестр, затем по city_hint, затем дефолт. Fast-path клиентских координат стал региононезависимым: OBLAST66_BBOX и REGIONS[66].bbox_region совпадают байт-в-байт, поэтому для 66 поведение прежнее, добавились координаты Москвы. Регресс-нейтральность по Свердловской области — главный критерий всех трёх кусков. Тесты: 1160 passed по затронутым областям. Известные ограничения. Границы 77 захватывают ближний пояс области, Химки резолвятся в Москву. У региона 77 нет ни одного тира обогащения, оценка поедет на аналогах и сделках. Ценовая полоса по Москве одна на весь город. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
181 lines
8.6 KiB
Python
181 lines
8.6 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.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)
|