From ad190be233131ef9fb3d8e13ff6ac565de551c4d Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 12 Aug 2026 12:48:18 +0000 Subject: [PATCH] =?UTF-8?q?fix(tradein/estimate):=20=D0=BE=D1=82=D0=B4?= =?UTF-8?q?=D0=B0=D0=B2=D0=B0=D1=82=D1=8C=20=D1=84=D0=B0=D0=BA=D1=82=D0=B8?= =?UTF-8?q?=D1=87=D0=B5=D1=81=D0=BA=D0=B8=D0=B9=20=D1=80=D0=B0=D0=B4=D0=B8?= =?UTF-8?q?=D1=83=D1=81=20=D0=BF=D0=BE=D0=B4=D0=B1=D0=BE=D1=80=D0=B0,=20?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B5=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB?= =?UTF-8?q?=D1=8F=20(#2835)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/api/v1/trade_in.py | 17 +- tradein-mvp/backend/app/schemas/trade_in.py | 27 +- tradein-mvp/backend/app/services/estimator.py | 81 +++++- .../backend/tests/test_estimate_idor.py | 4 + .../backend/tests/test_estimate_revival.py | 4 + .../test_estimator_search_radius_2632.py | 250 ++++++++++++++++++ 6 files changed, 368 insertions(+), 15 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_estimator_search_radius_2632.py diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index 2926e7e6..45d1a561 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -556,11 +556,22 @@ def get_estimate( _qc_geo_to_precision, _resolve_target_city, _source_counts, + rehydrate_search_radius_m, ) analogs = [AnalogLot(**a) for a in (row.analogs or [])] actual_deals = [AnalogLot(**a) for a in (row.actual_deals or [])] + # #2632: search_radius_m колонкой не персистится — восстанавливаем его из + # того, что персистится (подпись каскада «радиус расширен до N м», иначе + # размах сохранённых аналогов). Без этого GET отдавал null, фронт падал на + # превью-радиус 1 км и рисовал круг, за которым лежат его же пины (прод + # 2026-08-11: 10 из 10 аналогов вне круга, самый дальний — 4381 м). + persisted_relaxations = list(getattr(row, "relaxations", None) or []) + search_radius_m = rehydrate_search_radius_m( + persisted_relaxations, [a.distance_m for a in analogs] + ) + # #2043 (BE-1): CV / счётчики источников на rehydrate — best-effort из # сохранённых analogs (top-N, усечённо: полная выборка не персистится). На # свежей оценке (POST) считаются по полной выборке; здесь — по тому, что есть @@ -689,8 +700,12 @@ def get_estimate( # getattr defensive: старые in-memory test doubles / любая строка без # этих колонок (не должно случаться после миграции) деградируют в # дефолт схемы (ok / []), а не падают AttributeError. - relaxations=list(getattr(row, "relaxations", None) or []), + relaxations=persisted_relaxations, reliability=getattr(row, "reliability", None) or "ok", + # #2632: фактический радиус подбора (реконструкция выше). requested_radius_m + # осознанно НЕ заполняем — payload.radius_m не персистится, и подставить + # сюда дефолт значило бы выдать догадку за то, что просил пользователь. + search_radius_m=search_radius_m, ) diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index b7cb3556..923a7607 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -275,15 +275,26 @@ class AggregatedEstimate(BaseModel): # НЕ удаляет/заменяет confidence_explanation (фронт fallback'ает на него). analog_tier: Literal["same_building", "micro_radius", "district", "city"] | None = None # search_radius_m — фактический радиус (метры), по которому реально отбирались - # listings-аналоги (estimator.py: base_radius_m/fallback_radius_m, #2632). Может - # ОТЛИЧАТЬСЯ от TradeInEstimateInput.radius_m (выбор пользователя в дропдауне): - # сервер молча расширяет 1 км → 2 км при нехватке аналогов (см. - # confidence_explanation "расширили радиус до 2 км"). Фронт рисует круг на карте - # по ЭТОМУ полю (не по своему выбору) — иначе карта врёт о реально - # использованном радиусе. None на GET-rehydrate (не персистится, старые записи) - # и у _empty_estimate (поиск аналогов не выполнялся) — фронт в этом случае - # fallback'ает на выбор пользователя. + # listings-аналоги (estimator.py, #2632). Может ОТЛИЧАТЬСЯ от requested_radius_m: + # при нехватке аналогов сервер расширяет поиск сам (1 км → 2 км, дальше каскад + # #oblast-F до 3/5 км — только когда пользователь НЕ зафиксировал радиус явно, + # контракт #2044). Фронт рисует круг на карте по ЭТОМУ полю (не по своему + # выбору) — иначе карта врёт о реально использованном радиусе. + # На GET-rehydrate колонки под него нет, поэтому значение ВОССТАНАВЛИВАЕТСЯ + # (estimator.rehydrate_search_radius_m): из persisted-подписи каскада + # «радиус расширен до N м» (точное значение, строки с 2026-08-10), иначе из + # размаха сохранённых аналогов, но не меньше DEFAULT_RADIUS_M. None — у + # _empty_estimate (поиск не выполнялся) и у старых строк без расстояний; + # фронт тогда fallback'ает на выбор пользователя, как раньше. search_radius_m: int | None = None + # requested_radius_m — радиус, с которого поиск НАЧАЛСЯ: явный выбор + # пользователя (TradeInEstimateInput.radius_m) либо DEFAULT_RADIUS_M, если он + # выбрал «Авто». Отдаётся рядом с фактическим, чтобы ответ нёс ОБЕ величины — + # что просили и что получилось — и потребителю не приходилось выводить + # расхождение из своего локального состояния. None на GET-rehydrate: + # radius_m не персистится, а угадывать «просили 1 км» за пользователя — + # ровно та подмена входа результатом, которую чинит это поле. + requested_radius_m: int | None = None # ── #2002: премиальный дом (флаг, НЕ ценовой сигнал) ── # premium_building — целевой дом признан премиальным. Источник — curated overlay # `premium_buildings_curated` (data/sql/142, AI/human-выверенный класс + false- diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 5e3ef0a3..406860ff 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -203,6 +203,57 @@ LISTINGS_FRESH_DAYS_RELAXED = 60 # #oblast-F (b): LISTINGS_FRESH_DAYS 14 → 60 RELAX_RADIUS_STEP1_M = 3000 # #oblast-F (d.1): max(текущий search_radius_m, 3000) RELAX_RADIUS_STEP2_M = 5000 # #oblast-F (d.2): финальный максимум +# #2632: подпись радиусного ослабления — ЕДИНСТВЕННОЕ место, где число +# фактического радиуса попадает в persisted-состояние (колонка relaxations). +# Отсюда же его читает GET-rehydrate (radius_from_relaxations) — иначе строка и +# парсер разъедутся, и карта опять начнёт рисовать не тот круг. +RADIUS_RELAXATION_PREFIX = "радиус расширен до " + + +def radius_relaxation_label(radius_m: int) -> str: + """RU-подпись шага (d) каскада #oblast-F для AggregatedEstimate.relaxations.""" + return f"{RADIUS_RELAXATION_PREFIX}{radius_m} м" + + +def radius_from_relaxations(relaxations: list[str] | None) -> int | None: + """Обратный разбор подписи: фактический радиус (м) или None, если его нет. + + Нужен на GET-rehydrate: сам search_radius_m колонкой не персистится, но + подпись каскада — да (миграция 255), и в ней лежит ровно то число, по + которому реально отбирались аналоги. + """ + for label in relaxations or []: + if label.startswith(RADIUS_RELAXATION_PREFIX): + digits = label[len(RADIUS_RELAXATION_PREFIX) :].removesuffix(" м").strip() + if digits.isdigit(): + return int(digits) + return None + + +def rehydrate_search_radius_m( + relaxations: list[str] | None, analog_distances_m: list[int | None] +) -> int | None: + """Фактический радиус подбора для СОХРАНЁННОЙ оценки (#2632). + + Колонки под search_radius_m нет, поэтому восстанавливаем из того, что + персистится: + 1) подпись каскада «радиус расширен до N м» — точное значение, то же, + что вернул POST (строки, созданные с 2026-08-10, миграция 255); + 2) иначе — размах сохранённых аналогов, но не меньше DEFAULT_RADIUS_M: + круг обязан вмещать пины, которые сам же и показывает (тот же приём + уже применён во v2 SourcesMap — там радиус круга = max distance_m). + None — расстояний нет вовсе (старые/anchor-строки): вызывающий честно + падает обратно на выбор пользователя, как и до этой правки. + """ + labelled = radius_from_relaxations(relaxations) + if labelled is not None: + return labelled + known = [d for d in analog_distances_m if d is not None] + if not known: + return None + return max(DEFAULT_RADIUS_M, max(known)) + + # #794: СберИндекс time-adjustment of frozen Rosreestr ДКП deals. # Rosreestr deals freeze ~2026-01; the sber monthly index re-bases a stale deal's ppm² # to the latest available month. Region fixed to Свердловская обл. (tradein MVP = ЕКБ). @@ -2630,6 +2681,10 @@ def _price_from_inputs( dadata_coarse: bool, geo: GeocodeResult, dadata_qc_geo: int | None, + # #2632: фактический радиус подбора (после каскада #oblast-F) — чтобы + # объяснение называло тот радиус, по которому реально искали. None → + # прежнее поведение (FALLBACK_RADIUS_M) для оффлайн-вызывающих (бэктест). + search_radius_m: int | None = None, ) -> PricingResult: """Deterministic pricing orchestration — pure, synchronous, zero I/O. @@ -2795,6 +2850,7 @@ def _price_from_inputs( fallback_used, area_widened, listings=listings_clean, + radius_used_m=search_radius_m, ) # #oblast-E/#oblast-F: honest low-reliability note — ONLY for case (ii) of # the gate above (real thin median kept, no usable deals corridor to cede @@ -4000,7 +4056,7 @@ async def estimate_quality( if area_widened: relaxations.append("площадь ±25%") if search_radius_m > base_radius_m: - relaxations.append(f"радиус расширен до {search_radius_m} м") + relaxations.append(radius_relaxation_label(search_radius_m)) # ── PRE-FETCH: dkp_raw (hoisted before _price_from_inputs) ────────────── # #1795: ДКП-коридор фетчим ДО вызова _price_from_inputs, чтобы @@ -4192,6 +4248,7 @@ async def estimate_quality( dadata_coarse=dadata_coarse, geo=geo, dadata_qc_geo=dadata.qc_geo if dadata else None, + search_radius_m=search_radius_m, ) # Unpack pricing result @@ -4631,10 +4688,11 @@ async def estimate_quality( metro_nearest=(dadata.metro if dadata and dadata.metro else []), address_precision=_qc_geo_to_precision(dadata.qc_geo if dadata else None), analog_tier=api_analog_tier, # type: ignore[arg-type] - # #2632: фактический радиус отбора listings-аналогов (см. search_radius_m - # def выше) — может отличаться от payload.radius_m (выбор пользователя), - # когда сервер сам расширил поиск. None только у _empty_estimate (поиск - # аналогов вообще не выполнялся). + # #2632: обе величины рядом — с чего поиск начали (выбор пользователя или + # DEFAULT_RADIUS_M) и на чём закончили. Расходятся, когда сервер сам + # расширил поиск; подменять одно другим нельзя — это ровно тот дефект + # метки, что чинили в #2811. None у _empty_estimate (поиск не выполнялся). + requested_radius_m=base_radius_m, search_radius_m=search_radius_m, premium_building=premium_building, premium_building_median_ppm2=premium_building_median_ppm2, @@ -6668,6 +6726,11 @@ def _cap_confidence_by_reliability(confidence: str, reliability: str) -> str: return confidence +def _radius_ru(radius_m: int) -> str: + """«2 км» / «5 км» / «1500 м» — целые километры словом, остальное метрами.""" + return f"{radius_m // 1000} км" if radius_m % 1000 == 0 else f"{radius_m} м" + + def _compute_confidence( n_analogs: int, median_ppm2: float, @@ -6676,6 +6739,7 @@ def _compute_confidence( fallback_radius_used: bool, area_widened: bool = False, listings: list[dict] | None = None, + radius_used_m: int | None = None, ) -> tuple[str, str]: """Confidence + explanation string. @@ -6709,7 +6773,12 @@ def _compute_confidence( iqr_pct = iqr / median_ppm2 if median_ppm2 > 0 else 1.0 notes = [] if fallback_radius_used: - notes.append("расширили радиус до 2 км") + # #2632: НАЗЫВАЕМ фактический радиус. До этого здесь было прибито «2 км» + # — константа старого 1км→2км fallback'а, а каскад #oblast-F уходит до + # 3/5 км: текст утверждал 2 км там, где поиск реально шёл по 5 км + # (прод, 2026-08-11: relaxations «радиус расширен до 5000 м», самый + # дальний аналог 4381 м, объяснение — «расширили радиус до 2 км»). + notes.append(f"расширили радиус до {_radius_ru(radius_used_m or FALLBACK_RADIUS_M)}") if area_widened: notes.append("расширили допуск по площади до ±25%") fallback_note = f" ({', '.join(notes)} из-за нехватки данных)" if notes else "" diff --git a/tradein-mvp/backend/tests/test_estimate_idor.py b/tradein-mvp/backend/tests/test_estimate_idor.py index 790fc31f..099d39d0 100644 --- a/tradein-mvp/backend/tests/test_estimate_idor.py +++ b/tradein-mvp/backend/tests/test_estimate_idor.py @@ -163,6 +163,10 @@ def _stub_precision_and_pdf(): # #2087 (M1): GET-rehydrate derives canonical sources_used via the shared # helper. Empty analogs + no valuation flags → [] (real behaviour). _canonical_sources=lambda *a, **k: [], + # #2632: GET-rehydrate реконструирует фактический радиус подбора. У этих + # фикстур нет ни подписи каскада, ни расстояний → None и есть настоящее + # поведение (см. tests/test_estimator_search_radius_2632.py). + rehydrate_search_radius_m=lambda *a, **k: None, ) real_estimator = sys.modules.get("app.services.estimator") sys.modules["app.services.estimator"] = estimator_stub # type: ignore[assignment] diff --git a/tradein-mvp/backend/tests/test_estimate_revival.py b/tradein-mvp/backend/tests/test_estimate_revival.py index e1758d29..d41f2e4d 100644 --- a/tradein-mvp/backend/tests/test_estimate_revival.py +++ b/tradein-mvp/backend/tests/test_estimate_revival.py @@ -273,6 +273,10 @@ def _estimator_stub(): _cv_from_ppm2=lambda *a, **k: None, _source_counts=lambda *a, **k: {}, _canonical_sources=lambda *a, **k: [], + # #2632: GET-rehydrate реконструирует фактический радиус подбора. У строк + # этой фикстуры analogs пусты → None и есть настоящее поведение + # (см. tests/test_estimator_search_radius_2632.py). + rehydrate_search_radius_m=lambda *a, **k: None, estimate_quality=_default_estimate_quality, ) sys.modules["app.services.estimator"] = stub # type: ignore[assignment] diff --git a/tradein-mvp/backend/tests/test_estimator_search_radius_2632.py b/tradein-mvp/backend/tests/test_estimator_search_radius_2632.py new file mode 100644 index 00000000..5b824132 --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_search_radius_2632.py @@ -0,0 +1,250 @@ +"""#2632 — ответ несёт ФАКТИЧЕСКИЙ радиус подбора, а не выбор пользователя. + +Прод, 2026-08-11 (оценка 4c20518f, ул. Советская 51): сохранённая строка несёт +`relaxations = ["радиус расширен до 5000 м"]`, все 10 сохранённых аналогов лежат +дальше километра (самый дальний — 4381 м), а `GET /estimate/{id}` отдавал +`search_radius_m: null` → фронт падал на превью-радиус 1 км и рисовал круг, за +которым остаются его же пины. Там же вторая ложь: `confidence_explanation` +утверждал «расширили радиус до 2 км» — константа старого 1км→2км fallback'а, +хотя каскад #oblast-F ушёл до 5 км. + +Тесты падают на коде до этой правки: helper'ов не существует (ImportError), +а объяснение прибито к «2 км». +""" + +from __future__ import annotations + +import os +import sys +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from unittest.mock import MagicMock + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +_wp_mock = MagicMock() +sys.modules.setdefault("weasyprint", _wp_mock) +sys.modules.setdefault("weasyprint.CSS", _wp_mock) +sys.modules.setdefault("weasyprint.HTML", _wp_mock) + +import pytest # noqa: E402 +from fastapi import FastAPI # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +from app.services.estimator import ( # noqa: E402 + DEFAULT_RADIUS_M, + _compute_confidence, + radius_from_relaxations, + radius_relaxation_label, + rehydrate_search_radius_m, +) + +_ESTIMATE_ID = "4c20518f-7d5d-4f9a-9dde-8f65d5ea2202" + + +# ── подпись каскада ↔ разбор: единственный носитель числа в persisted-состоянии ── + + +def test_radius_label_roundtrips() -> None: + assert radius_from_relaxations([radius_relaxation_label(5000)]) == 5000 + assert radius_from_relaxations(["учтены студии", radius_relaxation_label(2000)]) == 2000 + # прод-строка дословно + assert radius_from_relaxations(["радиус расширен до 5000 м"]) == 5000 + + +def test_radius_label_absent_or_malformed_gives_none() -> None: + assert radius_from_relaxations(None) is None + assert radius_from_relaxations([]) is None + assert radius_from_relaxations(["учтены новостройки", "площадь ±25%"]) is None + assert radius_from_relaxations(["радиус расширен до пяти км"]) is None + + +# ── реконструкция радиуса для сохранённой оценки ────────────────────────────── + + +def test_rehydrate_prefers_persisted_label_over_analog_extent() -> None: + """Подпись — точное значение (то же, что вернул POST); размах — лишь оценка снизу.""" + assert rehydrate_search_radius_m(["радиус расширен до 5000 м"], [4381, 1200, None]) == 5000 + + +def test_rehydrate_falls_back_to_analog_extent() -> None: + """Строки до миграции 255: подписи нет, но круг обязан вмещать свои же пины.""" + assert rehydrate_search_radius_m([], [800, 2428, None]) == 2428 + + +def test_rehydrate_never_below_default_radius() -> None: + """Все аналоги рядом → поиск всё равно шёл по базовому радиусу, не по 300 м.""" + assert rehydrate_search_radius_m([], [120, 300]) == DEFAULT_RADIUS_M + + +def test_rehydrate_without_distances_stays_none() -> None: + """Нечего утверждать — фронт честно падает на выбор пользователя (как раньше).""" + assert rehydrate_search_radius_m([], []) is None + assert rehydrate_search_radius_m(None, [None, None]) is None + + +# ── объяснение называет тот радиус, по которому реально искали ──────────────── + + +def _confidence(radius_used_m: int | None) -> str: + _, explanation = _compute_confidence( + 12, + 100_000.0, + 95_000.0, + 105_000.0, + True, # fallback_radius_used + False, + listings=[{"address": f"дом {i}"} for i in range(12)], + radius_used_m=radius_used_m, + ) + return explanation + + +def test_explanation_names_actual_radius_not_hardcoded_2km() -> None: + text = _confidence(5000) + assert "5 км" in text + assert "2 км" not in text + + +def test_explanation_keeps_2km_wording_when_that_is_the_truth() -> None: + assert "2 км" in _confidence(2000) + # не-круглый радиус не притворяется километрами + assert "1500 м" in _confidence(1500) + + +# ── GET /estimate/{id}: сохранённая оценка отдаёт фактический радиус ────────── + + +def _prod_shaped_row() -> SimpleNamespace: + """Форма прод-строки 4c20518f (расширение до 5 км, аналоги дальше километра).""" + analog = { + "address": "Екатеринбург, ул. Советская, 51", + "area_m2": 44.0, + "rooms": 2, + "floor": 3, + "total_floors": 9, + "price_rub": 6_000_000, + "price_per_m2": 136_000, + "listing_date": None, + "days_on_market": None, + "source": "cian", + "distance_m": 4381, + } + return SimpleNamespace( + id=_ESTIMATE_ID, + median_price=6_000_000, + range_low=5_500_000, + range_high=6_500_000, + median_price_per_m2=136_000, + confidence="low", + confidence_explanation="Найдено 42 аналогов", + n_analogs=42, + analogs=[analog, {**analog, "distance_m": 1200}], + actual_deals=[], + sources_used=["cian"], + data_freshness_minutes=15, + expires_at=datetime.now(tz=UTC) + timedelta(hours=12), + retain_until=None, + address="Свердловская область, г. Екатеринбург, ул. Советская, д. 51", + lat=56.8519, + lon=60.6122, + area_m2=44.0, + rooms=2, + floor=3, + total_floors=9, + year_built=1975, + house_type=None, + repair_state=None, + has_balcony=None, + canonical_address=None, + house_cadnum=None, + house_fias_id=None, + dadata_qc_geo=None, + dadata_metro=[], + expected_sold_price=None, + expected_sold_range_low=None, + expected_sold_range_high=None, + expected_sold_per_m2=None, + asking_to_sold_ratio=None, + ratio_basis=None, + created_by="praktika", + created_at=datetime.now(tz=UTC), + relaxations=["радиус расширен до 5000 м"], + reliability="low", + ) + + +@pytest.fixture() +def _light_estimator_stub(): + """Подменяет тяжёлые POST-only helper'ы estimator'а, оставляя РЕАЛЬНУЮ реконструкцию. + + Тот же приём, что в test_estimate_idor / test_estimate_revival (не тащить + геокодер/скрейперы в API-тест), но `rehydrate_search_radius_m` берётся + настоящий — его поведение и проверяем. + """ + real_estimator = sys.modules.get("app.services.estimator") + stub = SimpleNamespace( + _qc_geo_to_precision=lambda _qc: None, + _fetch_price_trend=lambda *a, **k: None, + _fetch_dkp_corridor=lambda *a, **k: None, + _fetch_house_imv_anchor=lambda *a, **k: None, + _resolve_target_city=lambda *a, **k: None, + _cv_from_ppm2=lambda *a, **k: None, + _source_counts=lambda *a, **k: {}, + _canonical_sources=lambda *a, **k: ["cian"], + rehydrate_search_radius_m=rehydrate_search_radius_m, + ) + sys.modules["app.services.estimator"] = stub # type: ignore[assignment] + yield stub + if real_estimator is not None: + sys.modules["app.services.estimator"] = real_estimator + else: + sys.modules.pop("app.services.estimator", None) + + +@pytest.fixture(autouse=True) +def _restore_get_role(): + from app.core import auth as auth_mod + + original = auth_mod.get_role + yield + auth_mod.get_role = original + + +def test_get_estimate_returns_actual_search_radius(_light_estimator_stub: SimpleNamespace) -> None: + """Прод-репро: круг на реоткрытой оценке рисовался по 1 км при поиске в 5 км.""" + from app.api.v1 import trade_in as trade_in_module + from app.core import auth as auth_mod + from app.core.db import get_db + + app = FastAPI() + app.include_router(trade_in_module.router, prefix="/api/v1/trade-in") + + row = _prod_shaped_row() + db = MagicMock() + + def _execute(clause, params=None, *_a, **_k): + sql = getattr(clause, "text", str(clause)) + result = MagicMock() + result.fetchone.return_value = row if "SELECT id, median_price" in sql else None + return result + + db.execute.side_effect = _execute + + def _override_db(): + yield db + + app.dependency_overrides[get_db] = _override_db + auth_mod.get_role = lambda _u: "pilot" # type: ignore[assignment] + + resp = TestClient(app).get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "praktika"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["search_radius_m"] == 5000 + # круг обязан вмещать пины, которые сам же показывает + assert body["search_radius_m"] >= max(a["distance_m"] for a in body["analogs"]) + # вход не выдаём за результат: radius_m не персистится — «просили» неизвестно + assert body["requested_radius_m"] is None