feat(tradein/coverage): бесплатная проба покрытия для лендинга МЕРА (#2894)
POST /api/v1/trade-in/coverage — до оплаты пользователь видит только n похожих объявлений в радиусе 1000м и медианный возраст листинга, без единой цены. Один SQL (радиус GIST + rooms + area ±15% + freshness 14д + тот же дедуп/cap- канон, что у estimator._fetch_analogs), ноль внешних вызовов, ноль записей. Пороги ok/thin/not_covered — константы рядом с ручкой (зелёные города >=8, жёлтые >=12, остальные всегда not_covered). Поле median_listing_age_days (не "срок продажи" — возраст активного объявления, цензурированная выборка). RBAC не тронут — путь остаётся закрытым, открытие анонимного периметра вынесено в #2895.
This commit is contained in:
parent
ce9353fd48
commit
d0105470f4
3 changed files with 480 additions and 1 deletions
|
|
@ -10,7 +10,7 @@ import calendar
|
|||
import json
|
||||
import logging
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from typing import Annotated, Any
|
||||
from typing import Annotated, Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Header, HTTPException, Request, Response, UploadFile
|
||||
|
|
@ -25,6 +25,8 @@ from app.schemas.trade_in import (
|
|||
AnalogLot,
|
||||
AvitoImvSummary,
|
||||
CianPriceChangeStats,
|
||||
CoverageProbeInput,
|
||||
CoverageProbeResponse,
|
||||
DkpCorridor,
|
||||
HouseAnalyticsKpi,
|
||||
HouseAnalyticsResponse,
|
||||
|
|
@ -2549,3 +2551,161 @@ def get_sales_vs_listings(
|
|||
data_quality="street_only" if total_deals > 0 else "no_data",
|
||||
pairs=pairs,
|
||||
)
|
||||
|
||||
|
||||
# ── Coverage probe (#2894) — бесплатный шаг лэндинга, ЦЕНЫ НЕТ ─────────────────
|
||||
# До оплаты человек видит, СКОЛЬКО похожих квартир продаётся рядом и КАК БЫСТРО
|
||||
# они уходят — ни одной рублёвой цифры (см. CoverageProbeResponse docstring).
|
||||
# Один SQL, ноль внешних вызовов, ноль записей — ручка дешёвая специально: её
|
||||
# планируется открыть анонимам отдельной задачей (#2895, со своим consent-
|
||||
# гейтом). RBAC здесь НЕ трогаем — путь остаётся закрытым (не в _PUBLIC_PATHS).
|
||||
# строго 1000м по ТЗ #2894 (НЕ DEFAULT_RADIUS_M эстиматора — тот допускает fallback до 2000)
|
||||
COVERAGE_RADIUS_M = 1000
|
||||
COVERAGE_AREA_TOLERANCE = 0.15 # ±15% площади
|
||||
COVERAGE_FRESH_DAYS = 14 # объявления не старше 14 дней (тот же канон, что LISTINGS_FRESH_DAYS)
|
||||
|
||||
# Списки городов и пороги — константа РЯДОМ С РУЧКОЙ (issue #2894 требование), не в БД.
|
||||
COVERAGE_GREEN_CITIES = ("Екатеринбург", "Верхняя Пышма", "Берёзовский", "Среднеуральск")
|
||||
COVERAGE_YELLOW_CITIES = ("Нижний Тагил", "Каменск-Уральский", "Первоуральск", "Ревда")
|
||||
COVERAGE_GREEN_MIN_N = 8
|
||||
COVERAGE_YELLOW_MIN_N = 12
|
||||
|
||||
|
||||
def _fold_city(name: str) -> str:
|
||||
"""ёЁ→еЕ + casefold — та же normalization-идиома, что для адресов (см. #1774)."""
|
||||
return name.strip().translate(str.maketrans("ёЁ", "ее")).casefold()
|
||||
|
||||
|
||||
_COVERAGE_CITY_THRESHOLDS: dict[str, tuple[str, int]] = {
|
||||
**{_fold_city(c): (c, COVERAGE_GREEN_MIN_N) for c in COVERAGE_GREEN_CITIES},
|
||||
**{_fold_city(c): (c, COVERAGE_YELLOW_MIN_N) for c in COVERAGE_YELLOW_CITIES},
|
||||
}
|
||||
|
||||
|
||||
def _resolve_coverage_city(city_hint: str | None, cohort_city: str | None) -> tuple[str, int, bool]:
|
||||
"""Резолвит (display_city, threshold, is_supported) для пробы покрытия.
|
||||
|
||||
Приоритет: явный city_hint фронта (тот же автокомплит, что заполняет
|
||||
TradeInEstimateInput.city_hint) > мода city найденной SQL-когорты
|
||||
(best-effort фолбэк, когда фронт его не передал). Город вне зелёного/
|
||||
жёлтого списка → threshold=0, is_supported=False — вызывающий обязан
|
||||
трактовать это как not_covered независимо от n_listings.
|
||||
"""
|
||||
candidate = (city_hint or cohort_city or "").strip()
|
||||
match = _COVERAGE_CITY_THRESHOLDS.get(_fold_city(candidate)) if candidate else None
|
||||
if match is not None:
|
||||
display, threshold = match
|
||||
return display, threshold, True
|
||||
return candidate, 0, False
|
||||
|
||||
|
||||
@router.post("/coverage", response_model=CoverageProbeResponse)
|
||||
def coverage_probe(
|
||||
payload: CoverageProbeInput,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> CoverageProbeResponse:
|
||||
"""Бесплатная проба покрытия (issue #2894) — сколько похожих квартир рядом.
|
||||
|
||||
Когорта — тот же дедуп/cap-канон, что radius-тиры в estimator._fetch_analogs
|
||||
(rn_dup по (source, source_id), rn_addr cap по адресу, реюз тех же
|
||||
приватных helper'ов эстиматора — импорт локальный, как и в остальных
|
||||
ручках этого файла, чтобы не тащить тяжёлый app.services.estimator
|
||||
в module-level import graph): ST_DWithin 1000м, rooms точное совпадение,
|
||||
area ±15%, scraped_at не старше 14 дней, is_active.
|
||||
|
||||
В ответе НЕТ ни одной цены — см. CoverageProbeResponse docstring.
|
||||
|
||||
#oblast (2026-08): house_placement_history.exposure_days — реальная (не
|
||||
цензурированная) экспозиция history-строк — НЕ используется здесь: это
|
||||
house-level архив (join по house_id, не привязан к текущей radius/rooms/
|
||||
area когорте один-в-один), а не активные листинги в подобранном радиусе;
|
||||
сведение двух разных когорт усложнило бы «один дешёвый SQL» без выигрыша
|
||||
в честности (у нас и так честное имя поля — age активного объявления, не
|
||||
срок продажи). См. openQuestions PR #2894 при ревью.
|
||||
"""
|
||||
from app.services.estimator import _RN_DUP_WINDOW, MAX_ANALOGS_PER_ADDRESS
|
||||
|
||||
area_min = payload.area_m2 * (1 - COVERAGE_AREA_TOLERANCE)
|
||||
area_max = payload.area_m2 * (1 + COVERAGE_AREA_TOLERANCE)
|
||||
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
f"""
|
||||
WITH base AS (
|
||||
SELECT
|
||||
city,
|
||||
days_on_market,
|
||||
row_number() OVER (
|
||||
PARTITION BY address ORDER BY scraped_at DESC
|
||||
) AS rn_addr,
|
||||
{_RN_DUP_WINDOW}
|
||||
FROM listings
|
||||
WHERE is_active = true
|
||||
AND rooms = :rooms
|
||||
AND area_m2 BETWEEN :area_min AND :area_max
|
||||
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
|
||||
AND ST_DWithin(
|
||||
geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius
|
||||
)
|
||||
)
|
||||
SELECT
|
||||
count(*) AS n_listings,
|
||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY days_on_market)
|
||||
AS median_age_days,
|
||||
mode() WITHIN GROUP (ORDER BY city)
|
||||
FILTER (WHERE city IS NOT NULL) AS cohort_city
|
||||
FROM base
|
||||
WHERE rn_addr <= :max_per_addr
|
||||
AND rn_dup = 1
|
||||
"""
|
||||
),
|
||||
{
|
||||
"rooms": payload.rooms,
|
||||
"area_min": area_min,
|
||||
"area_max": area_max,
|
||||
"fresh_days": COVERAGE_FRESH_DAYS,
|
||||
"lat": payload.lat,
|
||||
"lon": payload.lon,
|
||||
"radius": COVERAGE_RADIUS_M,
|
||||
"max_per_addr": MAX_ANALOGS_PER_ADDRESS,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.fetchone()
|
||||
)
|
||||
|
||||
n_listings = int(row["n_listings"]) if row else 0
|
||||
median_age = (
|
||||
round(row["median_age_days"])
|
||||
if row is not None and row["median_age_days"] is not None
|
||||
else None
|
||||
)
|
||||
cohort_city = row["cohort_city"] if row else None
|
||||
|
||||
city, threshold, supported = _resolve_coverage_city(payload.city_hint, cohort_city)
|
||||
|
||||
if not supported or n_listings == 0:
|
||||
status: Literal["ok", "thin", "not_covered"] = "not_covered"
|
||||
elif n_listings >= threshold:
|
||||
status = "ok"
|
||||
else:
|
||||
status = "thin"
|
||||
|
||||
logger.info(
|
||||
"coverage probe rooms=%d area=%.1f city=%r status=%s n=%d",
|
||||
payload.rooms,
|
||||
payload.area_m2,
|
||||
city,
|
||||
status,
|
||||
n_listings,
|
||||
)
|
||||
|
||||
return CoverageProbeResponse(
|
||||
status=status,
|
||||
n_listings=n_listings,
|
||||
median_listing_age_days=median_age,
|
||||
radius_m=COVERAGE_RADIUS_M,
|
||||
city=city,
|
||||
threshold=threshold,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -756,3 +756,51 @@ class LocationIndexResponse(BaseModel):
|
|||
radius_m: int
|
||||
nearby_poi: list[NearbyPoiOut]
|
||||
poi_status: str
|
||||
|
||||
|
||||
class CoverageProbeInput(BaseModel):
|
||||
"""Вход POST /api/v1/trade-in/coverage (issue #2894) — бесплатная проба покрытия.
|
||||
|
||||
lat/lon — координаты, уже разрезолвленные фронтом (тот же контракт, что
|
||||
TradeInEstimateInput.lat/lon — geocode делает фронт/автокомплит, эта ручка
|
||||
сама НИКОГО не геокодирует). city_hint — опционально, из того же
|
||||
автокомплита (см. TradeInEstimateInput.city_hint); без него город
|
||||
резолвится best-effort из моды city найденной когорты.
|
||||
"""
|
||||
|
||||
lat: float = Field(ge=-90, le=90)
|
||||
lon: float = Field(ge=-180, le=180)
|
||||
rooms: int = Field(ge=0, le=10) # 0 = студия
|
||||
area_m2: float = Field(gt=10, lt=500)
|
||||
city_hint: str | None = Field(default=None, max_length=100)
|
||||
|
||||
|
||||
class CoverageProbeResponse(BaseModel):
|
||||
"""Ответ POST /api/v1/trade-in/coverage.
|
||||
|
||||
НАМЕРЕННО без единой цены (ни медианы, ни диапазона, ни ₽/м²) — продуктовое
|
||||
правило issue #2894: бесплатный шаг доказывает, что похожие квартиры есть
|
||||
и как быстро они уходят, а саму цену продукт продаёт на платном шаге.
|
||||
|
||||
status:
|
||||
- "ok" — n_listings >= порога для этого города (зелёный/жёлтый список).
|
||||
- "thin" — когорта непустая, но n_listings < порога.
|
||||
- "not_covered" — город вне зелёного/жёлтого списка ИЛИ когорта пустая
|
||||
(n_listings == 0) — независимо от того, поддерживается город или нет.
|
||||
|
||||
median_listing_age_days — ЧЕСТНОЕ имя: возраст АКТИВНОГО объявления
|
||||
(days_on_market на текущий момент), а НЕ срок до продажи. Цензурированная
|
||||
выборка (активные объявления ещё висят) всегда завышена относительно
|
||||
реального времени экспозиции проданных — не путать со «сроком продажи».
|
||||
|
||||
threshold — n, начиная с которого статус переходит в "ok" для резолвленного
|
||||
города; 0, если город не входит ни в один список (порог неприменим —
|
||||
статус в этом случае всегда "not_covered" вне зависимости от n_listings).
|
||||
"""
|
||||
|
||||
status: Literal["ok", "thin", "not_covered"]
|
||||
n_listings: int
|
||||
median_listing_age_days: int | None
|
||||
radius_m: int
|
||||
city: str
|
||||
threshold: int
|
||||
|
|
|
|||
271
tradein-mvp/backend/tests/test_coverage_probe_endpoint.py
Normal file
271
tradein-mvp/backend/tests/test_coverage_probe_endpoint.py
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
"""Tests for POST /api/v1/trade-in/coverage (issue #2894).
|
||||
|
||||
Бесплатная проба покрытия для публичного лэндинга «МЕРА» — до оплаты человек
|
||||
видит, сколько похожих квартир продаётся рядом и как быстро они уходят, без
|
||||
единой рублёвой цифры в ответе. Covers:
|
||||
- пороги ok/thin/not_covered для зелёных/жёлтых/неподдерживаемых городов
|
||||
- пустая когорта (n=0) → not_covered даже в поддерживаемом городе
|
||||
- в ответе НЕТ ни одного price-подобного поля (падающий тест на регресс схемы)
|
||||
- city_hint приоритетнее моды city из когорты
|
||||
- median_listing_age_days — median(days_on_market), None при пустой когорте
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# psycopg v3 driver required; stub DATABASE_URL before any app import.
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
# WeasyPrint requires GTK — not present in CI/Windows. Stub before any app import
|
||||
# (trade_in.py imports generate_trade_in_pdf at module load).
|
||||
_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
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def trade_in_app() -> FastAPI:
|
||||
"""Minimal FastAPI app mounting only the trade-in router with DB overridden."""
|
||||
from app.api.v1 import trade_in as trade_in_module
|
||||
from app.core.db import get_db
|
||||
|
||||
application = FastAPI()
|
||||
application.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
|
||||
|
||||
def _override_db():
|
||||
yield MagicMock()
|
||||
|
||||
application.dependency_overrides[get_db] = _override_db
|
||||
return application
|
||||
|
||||
|
||||
def _db_mock_returning(row: dict | None) -> MagicMock:
|
||||
"""DB session mock — coverage_probe reads db.execute(...).mappings().fetchone()."""
|
||||
db = MagicMock()
|
||||
mapping_result = MagicMock()
|
||||
mapping_result.fetchone.return_value = row
|
||||
execute_result = MagicMock()
|
||||
execute_result.mappings.return_value = mapping_result
|
||||
db.execute.return_value = execute_result
|
||||
return db
|
||||
|
||||
|
||||
def _override(app: FastAPI, db: MagicMock) -> None:
|
||||
from app.core.db import get_db
|
||||
|
||||
app.dependency_overrides[get_db] = lambda: (yield db)
|
||||
|
||||
|
||||
_BASE_PAYLOAD = {"lat": 56.8384, "lon": 60.6057, "rooms": 2, "area_m2": 50.0}
|
||||
|
||||
|
||||
# ── Response schema: NO price anywhere (issue #2894 hard rule) ────────────────
|
||||
|
||||
_PRICE_LIKE_SUBSTRINGS = ("price", "cena", "цена", "rub", "₽", "cost")
|
||||
|
||||
|
||||
def test_coverage_response_has_no_price_fields(trade_in_app: FastAPI) -> None:
|
||||
"""Regression guard: response schema must never grow a price-shaped field."""
|
||||
from app.schemas.trade_in import CoverageProbeResponse
|
||||
|
||||
field_names = set(CoverageProbeResponse.model_fields.keys())
|
||||
offending = [f for f in field_names if any(sub in f.lower() for sub in _PRICE_LIKE_SUBSTRINGS)]
|
||||
assert not offending, f"CoverageProbeResponse must not carry price fields: {offending}"
|
||||
|
||||
|
||||
def test_coverage_actual_response_has_no_price_fields(trade_in_app: FastAPI) -> None:
|
||||
"""Same guard but on a live serialized response (belt-and-suspenders)."""
|
||||
db = _db_mock_returning(
|
||||
{"n_listings": 10, "median_age_days": 21.0, "cohort_city": "Екатеринбург"}
|
||||
)
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
offending = [k for k in data if any(sub in k.lower() for sub in _PRICE_LIKE_SUBSTRINGS)]
|
||||
assert not offending, f"response body must not carry price fields: {offending} in {data}"
|
||||
|
||||
|
||||
# ── Thresholds: green city ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_green_city_ok_at_threshold(trade_in_app: FastAPI) -> None:
|
||||
"""Екатеринбург (зелёный, порог 8) — n=8 ровно на границе → ok."""
|
||||
db = _db_mock_returning(
|
||||
{"n_listings": 8, "median_age_days": 15.0, "cohort_city": "Екатеринбург"}
|
||||
)
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["n_listings"] == 8
|
||||
assert data["threshold"] == 8
|
||||
assert data["city"] == "Екатеринбург"
|
||||
assert data["radius_m"] == 1000
|
||||
assert data["median_listing_age_days"] == 15
|
||||
|
||||
|
||||
def test_green_city_thin_below_threshold(trade_in_app: FastAPI) -> None:
|
||||
"""Екатеринбург, n=7 (< порог 8) → thin, не ok и не not_covered."""
|
||||
db = _db_mock_returning(
|
||||
{"n_listings": 7, "median_age_days": 10.0, "cohort_city": "Екатеринбург"}
|
||||
)
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||||
data = resp.json()
|
||||
assert data["status"] == "thin"
|
||||
assert data["n_listings"] == 7
|
||||
assert data["threshold"] == 8
|
||||
|
||||
|
||||
# ── Thresholds: yellow city ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_yellow_city_ok_at_threshold(trade_in_app: FastAPI) -> None:
|
||||
"""Нижний Тагил (жёлтый, порог 12) — n=12 → ok."""
|
||||
db = _db_mock_returning(
|
||||
{"n_listings": 12, "median_age_days": 30.0, "cohort_city": "Нижний Тагил"}
|
||||
)
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
resp = client.post(
|
||||
"/api/v1/trade-in/coverage",
|
||||
json={**_BASE_PAYLOAD, "city_hint": "Нижний Тагил"},
|
||||
)
|
||||
data = resp.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["threshold"] == 12
|
||||
assert data["city"] == "Нижний Тагил"
|
||||
|
||||
|
||||
def test_yellow_city_thin_below_threshold(trade_in_app: FastAPI) -> None:
|
||||
"""Ревда, n=11 (< порог 12) → thin."""
|
||||
db = _db_mock_returning({"n_listings": 11, "median_age_days": 40.0, "cohort_city": None})
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, "city_hint": "Ревда"})
|
||||
data = resp.json()
|
||||
assert data["status"] == "thin"
|
||||
assert data["threshold"] == 12
|
||||
|
||||
|
||||
# ── City outside both lists → always not_covered ────────────────────────────────
|
||||
|
||||
|
||||
def test_unsupported_city_not_covered_even_with_high_n(trade_in_app: FastAPI) -> None:
|
||||
"""Город вне списков → not_covered независимо от n_listings (даже n=500)."""
|
||||
db = _db_mock_returning({"n_listings": 500, "median_age_days": 5.0, "cohort_city": "Серов"})
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, "city_hint": "Серов"})
|
||||
data = resp.json()
|
||||
assert data["status"] == "not_covered"
|
||||
assert data["threshold"] == 0
|
||||
assert data["n_listings"] == 500 # честно отдаём счётчик, статус его игнорирует
|
||||
|
||||
|
||||
# ── Empty cohort ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_empty_cohort_supported_city_not_covered(trade_in_app: FastAPI) -> None:
|
||||
"""n=0 в поддерживаемом (зелёном) городе → not_covered, не thin — честнее."""
|
||||
db = _db_mock_returning({"n_listings": 0, "median_age_days": None, "cohort_city": None})
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
resp = client.post(
|
||||
"/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, "city_hint": "Екатеринбург"}
|
||||
)
|
||||
data = resp.json()
|
||||
assert data["status"] == "not_covered"
|
||||
assert data["n_listings"] == 0
|
||||
assert data["median_listing_age_days"] is None
|
||||
|
||||
|
||||
def test_empty_cohort_no_row_at_all(trade_in_app: FastAPI) -> None:
|
||||
"""DB возвращает None (defensive — count(*) агрегат всегда даёт строку, но
|
||||
coverage_probe обязан не падать, даже если mock/driver вернул пусто)."""
|
||||
db = _db_mock_returning(None)
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "not_covered"
|
||||
assert data["n_listings"] == 0
|
||||
assert data["median_listing_age_days"] is None
|
||||
|
||||
|
||||
# ── city_hint priority over cohort mode ─────────────────────────────────────────
|
||||
|
||||
|
||||
def test_city_hint_overrides_cohort_mode(trade_in_app: FastAPI) -> None:
|
||||
"""city_hint (фронт) побеждает cohort_city (SQL mode) при определении города."""
|
||||
db = _db_mock_returning({"n_listings": 9, "median_age_days": 12.0, "cohort_city": "Серов"})
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
resp = client.post(
|
||||
"/api/v1/trade-in/coverage",
|
||||
json={**_BASE_PAYLOAD, "city_hint": "Екатеринбург"},
|
||||
)
|
||||
data = resp.json()
|
||||
assert data["city"] == "Екатеринбург"
|
||||
assert data["status"] == "ok" # n=9 >= 8 (зелёный порог), не серовский not_covered
|
||||
|
||||
|
||||
def test_yo_fold_city_hint_matches(trade_in_app: FastAPI) -> None:
|
||||
"""«Березовский» без ё должен резолвиться в тот же зелёный порог, что «Берёзовский»."""
|
||||
db = _db_mock_returning({"n_listings": 8, "median_age_days": 5.0, "cohort_city": None})
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
resp = client.post(
|
||||
"/api/v1/trade-in/coverage",
|
||||
json={**_BASE_PAYLOAD, "city_hint": "березовский"},
|
||||
)
|
||||
data = resp.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["threshold"] == 8
|
||||
|
||||
|
||||
# ── DB dedup / cap params passed through ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_coverage_sql_uses_radius_1000_and_area_tolerance(trade_in_app: FastAPI) -> None:
|
||||
"""SQL params: radius=1000 (строго), area ±15%, rooms exact."""
|
||||
db = _db_mock_returning({"n_listings": 0, "median_age_days": None, "cohort_city": None})
|
||||
_override(trade_in_app, db)
|
||||
|
||||
client = TestClient(trade_in_app)
|
||||
client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||||
|
||||
assert db.execute.called
|
||||
call_args = db.execute.call_args
|
||||
params = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("parameters", {})
|
||||
assert params["radius"] == 1000
|
||||
assert params["rooms"] == 2
|
||||
assert params["area_min"] == pytest.approx(50.0 * 0.85)
|
||||
assert params["area_max"] == pytest.approx(50.0 * 1.15)
|
||||
assert params["fresh_days"] == 14
|
||||
Loading…
Add table
Reference in a new issue