"""Тесты #2590 — постфактум-инвариант «назван чужой топоним + результат внутри ЕКБ». Гейты #2582/#2589 стоят НА ВХОДЕ и решают, пускать ли ЕКБ-only локальные реестры. Внешние провайдеры ими не покрыты: «реж, ленина» уходит в Nominatim/Yandex, тот не находит Режа и отдаёт улицу Ленина в Екатеринбурге — с координатами ЕКБ, пометкой «точно» и записью в кэш на 90 дней. Замер на проде (`geocode_cache`, 9514 строк, 2026-08-12): **1251 запись** подпадает под инвариант — 1245 `provider=yandex`, 6 `nominatim`, 0 у локальных тиров; 365 разных населённых пунктов. Инвариант проверяется ПОСЛЕ ответа провайдера, потому что на входе «Реж» (город) и «Малышева» (улица) структурно неразличимы — оба «слово до запятой». После ответа различимы: провайдер сам пишет, какой НП он использовал. Адреса и full_address в тестах — живые строки из прод-кэша (не выдуманные). """ from __future__ import annotations import contextlib 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 ( GeocodeResult, GeocodeSuggestion, _city_substituted, _claimed_locality, geocode, ) # ── _claimed_locality: какой НП назван в тексте ────────────────────────────── @pytest.mark.parametrize( "address,expected", [ # НП назван явно ("реж, ленина", "реж"), ("арамиль, красноармейская ул, 118дк1", "арамиль"), ("пос. кедровка, советская ул.,5", "кедровка"), # тип «пос.» отброшен, имя осталось # Уровни выше НП пропускаются, НП находится дальше ("свердловская обл., г.о. рефтинский", "рефтинский"), ("россия, свердловская область, реж, ленина 5", "реж"), # НП НЕ назван — основной трафик формы оценки, инвариант не применяется ("малышева 30", None), ("ул. титова/монтерская/смоленская, стр. 2", None), ("улица декабристов, 16-18литж · р-н октябрьский", None), ("мкр-н широкая речка, ул. анатолия муранова,18", None), # Перекрёсток: второй уличный сегмент НЕ должен читаться как топоним ("малышева, мопра", "малышева"), ], ) def test_claimed_locality(address: str, expected: str | None) -> None: assert _claimed_locality(address) == expected # ── _city_substituted: подмена vs честный ответ ────────────────────────────── def _res(full_address: str, lat: float, lon: float) -> GeocodeResult: return GeocodeResult( lat=lat, lon=lon, full_address=full_address, provider="nominatim", confidence="exact" ) @pytest.mark.parametrize( "address,full_address,lat,lon", [ # Живые записи прод-кэша из тела #2590 (все provider=yandex) ( "реж, ленина", "Россия, Свердловская область, Екатеринбург, улица Ленина", 56.83953, 60.61244, ), ( "реж, краснофлотцев", "Россия, Свердловская область, Екатеринбург, улица Краснофлотцев", 56.88996, 60.63184, ), ( "арамиль, заводская", "Россия, Свердловская область, Екатеринбург, Заводская улица", 56.83043, 60.55170, ), ( "свердловская обл., г.о. рефтинский", "Россия, Свердловская область, Екатеринбург", 56.83744, 60.59764, ), # Города вне списка 37 и без структурной приставки — дыра, оставленная #2589 ( "талица, кузнецова", "Россия, Свердловская область, Екатеринбург, улица Кузнецова", 56.84226, 60.64557, ), ( "верхотурье, герцена", "Россия, Свердловская область, Екатеринбург, улица Герцена", 56.744397, 60.677299, ), # Посёлок В ЧЕРТЕ ЕКБ, но улица подменена: настоящая Кедровка в 20 км от # улицы Советской, куда провайдер поставил точку (живая запись прода). ( "пос. кедровка, советская ул.,5", "Россия, Свердловская область, Екатеринбург, Советская улица, 5", 56.8506, 60.6282, ), ], ) def test_substitution_detected(address: str, full_address: str, lat: float, lon: float) -> None: assert _city_substituted(address, _res(full_address, lat, lon)) is True @pytest.mark.parametrize( "address,full_address,lat,lon", [ # Посёлок в черте ЕКБ, ответ ЧЕСТНЫЙ — имя дожило до full_address. # Тот же вход, что и в подмене выше: разделяет их не география, а # сохранность топонима (граница из #2777 — посёлки не ловятся # «городским» признаком, и здесь ловить их и не нужно). ( "пос. кедровка, советская ул.,5", "5, Советская улица, Кедровка, Орджоникидзевский район, Екатеринбург", 56.9902, 60.7166, ), ( "шувакиш, ленина 4", "4, улица Ленина, Шувакиш, Железнодорожный район, Екатеринбург", 56.9295, 60.4938, ), # НП не назван вообще — основной трафик формы, ЕКБ как рабочая гипотеза ( "малышева 30", "Россия, Свердловская область, Екатеринбург, улица Малышева, 30", 56.8389, 60.6057, ), # Явный Екатеринбург в адресе — не подмена по определению ("екатеринбург, ул. репина, 99", "Репина, 99", 56.8207, 60.5686), ("г екатеринбург, ул сурикова, д 47", "Сурикова, 47", 56.8156, 60.5943), # Честный ответ по другому городу области: провайдер назвал НП, координаты # в его собственном bbox — под инвариант не подпадает ("нижний тагил, ленина 1", "1, проспект Ленина, Нижний Тагил", 57.9101, 59.9813), # Ответ внутри ЕКБ-bbox, но провайдер назвал ДРУГОЙ НП (Берёзовский лежит # внутри щедрого EKB_BBOX_TIGHT) — «внутри Екатеринбурга» не подтверждено ( "режевской, ленина", "Россия, Свердловская область, Берёзовский, улица Ленина", 56.9088, 60.8107, ), # Перекрёсток двух улиц: провайдер взял первую — потеря второй улицы не # является подменой города ( "малышева, мопра", "Россия, Свердловская область, Екатеринбург, улица Малышева", 56.8389, 60.6057, ), # Тот же НП, но за пределами ЕКБ — инвариант молчит ("реж, ленина", "Россия, Свердловская область, Реж, улица Ленина", 57.3722, 61.3897), ], ) def test_no_substitution(address: str, full_address: str, lat: float, lon: float) -> None: assert _city_substituted(address, _res(full_address, lat, lon)) is False # ── geocode(): сквозной эффект ─────────────────────────────────────────────── @contextlib.contextmanager def _tiers(*, cached=None, nominatim=None, geoportal=None): with contextlib.ExitStack() as stack: stack.enter_context(patch("app.services.geocoder._cache_get", return_value=cached)) stack.enter_context(patch("app.services.geocoder._cache_put")) stack.enter_context( patch("app.services.geocoder._geoportal_house_match", return_value=geoportal) ) 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(return_value=nominatim)) ) stack.enter_context(patch("app.services.geocoder.asyncio.sleep", new=AsyncMock())) yield async def test_geocode_downgrades_substituted_result() -> None: """«Реж, Ленина 5» → провайдер отдал улицу Ленина в ЕКБ с confidence=exact. Координаты остаются (честная деградация, не отказ), но confidence падает до `locality` — а это уже рабочий контракт: `estimator._geocode_is_coarse` включает #693 coarse-downgrade, `tasks.geocode_missing` ставит листингу `geo_precision='city'`, что исключает его из пула аналогов. """ db = MagicMock() substituted = GeocodeResult( lat=56.83953, lon=60.61244, full_address="Россия, Свердловская область, Екатеринбург, улица Ленина, 5", provider="nominatim", confidence="exact", ) with _tiers(nominatim=substituted): result = await geocode("Реж, Ленина 5", db) assert result is not None assert result.confidence == "locality" assert result.city_ambiguous is True assert result.lat == pytest.approx(56.83953) # координаты НЕ выброшены async def test_geocode_poisoned_cache_hit_downgraded() -> None: """Отравленная запись кэша (их 1251 на проде) продолжает отдаваться, но уже НЕ как точная — без единого DELETE в `geocode_cache`.""" db = MagicMock() poisoned = GeocodeResult( lat=56.83744, lon=60.59764, full_address="Россия, Свердловская область, Екатеринбург", provider="cache", confidence="exact", ) with _tiers(cached=poisoned): result = await geocode("Арамиль, Красноармейская ул, 118дк1", db) assert result is not None assert result.confidence == "locality" assert result.city_ambiguous is True async def test_geocode_ekb_address_unaffected() -> None: """Контроль: реальный ЕКБ-адрес через локальный тир (full_address без города) остаётся `exact` — инвариант не трогает основной трафик.""" db = MagicMock() hit = GeocodeSuggestion( label="Малышева, 30", full_address="Малышева, 30", lat=56.8389, lon=60.6057, kind="house" ) with _tiers(geoportal=hit): result = await geocode("Екатеринбург, Малышева 30", db) assert result is not None assert result.confidence == "exact" assert result.city_ambiguous is False async def test_geocode_settlement_inside_ekb_kept_exact() -> None: """Посёлок в городской черте (Кедровка) с ЧЕСТНЫМ ответом провайдера не отбраковывается — граница, о которой предупреждает #2777.""" db = MagicMock() honest = GeocodeResult( lat=56.9902, lon=60.7166, full_address="5, Советская улица, Кедровка, Орджоникидзевский район, Екатеринбург", provider="nominatim", confidence="exact", ) with _tiers(nominatim=honest): result = await geocode("пос. Кедровка, Советская ул., 5", db) assert result is not None assert result.confidence == "exact"