feat(tradein): бесплатная проба покрытия POST /coverage (#2894) #2909

Merged
lekss361 merged 3 commits from feat/tradein-coverage-probe into main 2026-08-15 18:28:09 +00:00
4 changed files with 1013 additions and 1 deletions

View file

@ -9,8 +9,9 @@ import asyncio
import calendar
import json
import logging
import math
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 +26,8 @@ from app.schemas.trade_in import (
AnalogLot,
AvitoImvSummary,
CianPriceChangeStats,
CoverageProbeInput,
CoverageProbeResponse,
DkpCorridor,
HouseAnalyticsKpi,
HouseAnalyticsResponse,
@ -2549,3 +2552,268 @@ 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)
# MAJOR-2 (независимый ревью #2894): days_on_market на проде заполнена практически
# только у yandex (avito/cian/domklik — 0 заполнено) — возраст известен у меньшинства
# когорты, и на тонких когортах "медиана" считалась по 1-2 объявлениям. Ниже порога
# n_with_age медиану не отдаём (null) — не продуктовое решение, а честность при
# заведомо шумной статистике по единичным точкам.
COVERAGE_MIN_AGE_SAMPLES = 5
# 15% свежих yandex-строк имеют days_on_market > 365 (максимум 4261) — это почти
# наверняка мёртвое/забытое объявление, которое никто не снял с публикации, а не
# сигнал о реальном времени экспозиции рынка. Отбрасываем как выброс из медианы.
COVERAGE_MAX_AGE_DAYS = 365
# Списки городов и пороги — константа РЯДОМ С РУЧКОЙ (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},
}
# Повторная проверка ручки #2894 (2026-08): город раньше резолвился модой
# `listings.city` найденной когорты — оказалось, что `listings.city` это город
# СВИП-контекста скрейпера (миграция 196 — колонка заполняется тем городом,
# который скрейпер обходил, не геокодом самого объявления). Замер на проде:
# в радиусе 1000 м вокруг Берёзовского 90/90 строк имеют city='Екатеринбург';
# вокруг Ревды 74/74 — city='Первоуральск'. Следствие: продавец в Берёзовском
# видел на лэндинге «Екатеринбург», а сами COVERAGE_GREEN/YELLOW_CITIES для
# городов-спутников были НЕДОСТИЖИМЫ (в БД нет ни одной строки с их city).
# Фикс — детерминированный резолв по координатам ЗАПРОСА (никакого участия
# клиента, никакой моды когорты): ближайший центроид города из списка ниже,
# если он в пределах COVERAGE_CITY_MATCH_RADIUS_KM.
#
# Координаты — константа РЯДОМ С РУЧКОЙ, не таблица в БД: единственный
# существующий кандидат на "готовый реестр городов" — это
# frontend/src/lib/city-registry.ts (OBLAST_CITIES) и backend
# geocoder.py::SVERDLOVSK_OBLAST_CITIES — оба хранят ТОЛЬКО текстовые лейблы
# (city_hint для геокодера), без координат. Заводить миграцию + таблицу ради
# статичного справочника из 8 географических центров населённых пунктов —
# оверинжиниринг; координаты (WGS84, общедоступные центры НП) живут здесь же,
# рядом с порогами, которые они резолвят.
COVERAGE_CITY_MATCH_RADIUS_KM = 25.0 # дальше — город не определён (not_covered)
_CITY_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
"Екатеринбург": (56.8389, 60.6057),
"Верхняя Пышма": (56.9789, 60.5636),
"Берёзовский": (56.9096, 60.8034),
"Среднеуральск": (56.9848, 60.4759),
"Нижний Тагил": (57.9099, 59.9819),
"Каменск-Уральский": (56.4110, 61.9243),
"Первоуральск": (56.9083, 59.9483),
"Ревда": (56.7986, 59.9298),
}
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Расстояние по большому кругу (км), радиус Земли 6371 км."""
r_earth_km = 6371.0
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlambda = math.radians(lon2 - lon1)
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
return 2 * r_earth_km * math.asin(math.sqrt(a))
def _resolve_coverage_city(lat: float, lon: float) -> tuple[str, int, bool]:
"""Резолвит (display_city, threshold, is_supported) для пробы покрытия — ПО КООРДИНАТАМ.
Город = ближайший центроид из `_CITY_CENTROIDS_DEG`, если расстояние до него
< `COVERAGE_CITY_MATCH_RADIUS_KM`; иначе город не определён. Детерминированно
и без участия клиента см. комментарий над `_CITY_CENTROIDS_DEG` про то,
почему `listings.city` (мода когорты) и `city_hint` (клиентский вход) сюда
больше НЕ допускаются в качестве источника истины.
"""
nearest_city: str | None = None
nearest_km = math.inf
for city, (clat, clon) in _CITY_CENTROIDS_DEG.items():
distance_km = _haversine_km(lat, lon, clat, clon)
if distance_km < nearest_km:
nearest_km = distance_km
nearest_city = city
if nearest_city is None or nearest_km > COVERAGE_CITY_MATCH_RADIUS_KM:
return "", 0, False
display, threshold = _COVERAGE_CITY_THRESHOLDS[_fold_city(nearest_city)]
return display, threshold, True
@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.
MAJOR-1 fix (независимый ревью #2894): когорта пробы обязана быть
ПОДМНОЖЕСТВОМ когорты платного эстиматора, не шире её иначе проба честно
отвечает "ok" там, где платный расчёт увидит 0. Три предиката ниже тот же
канон, что estimator._COMMON_WHERE (app/services/estimator.py:5441/5460) и
inline-копия Tier W (estimator.py:5910/5916/5932, radius-тир, откуда реально
берутся аналоги на 1000 м): guard новостроек, geo_precision != 'city'
(#769 Part E — city-centroid листинги без реального адреса), price_rub > 0.
В ответе НЕТ ни одной цены см. CoverageProbeResponse docstring.
MAJOR-2 (независимый ревью #2894): days_on_market на проде фактически
заполнена только у ОДНОГО источника (yandex) это ограничение данных, а
не продуктовое решение. n_with_age в ответе честно считает, по скольким
объявлениям взята медиана; ниже COVERAGE_MIN_AGE_SAMPLES null (см. поле
в ответе). Значения > COVERAGE_MAX_AGE_DAYS (почти наверняка мёртвое
объявление) в расчёт медианы не берутся.
#oblast (2026-08): house_placement_history.exposure_days — реальная (не
цензурированная) экспозиция history-строк НЕ используется здесь: это
house-level архив (join по house_id, не привязан к текущей radius/rooms/
area когорте один-в-один), а не активные листинги в подобранном радиусе;
сведение двух разных когорт усложнило бы «один дешёвый SQL» без выигрыша
в честности (у нас и так честное имя поля age активного объявления, не
срок продажи). См. openQuestions PR #2894 при ревью.
Повторная проверка ручки (2026-08): город больше НЕ берётся из моды
`listings.city` найденной когорты и НЕ зависит от `payload.city_hint`
оба источника ненадёжны (см. комментарий над `_CITY_CENTROIDS_DEG`).
Город резолвится детерминированно по `payload.lat/lon` через
`_resolve_coverage_city` `city_hint` в payload остаётся только
информационным полем (см. `CoverageProbeInput.city_hint`), на результат
не влияет.
"""
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
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
)
-- MAJOR-1: sync с estimator._COMMON_WHERE (5441) / Tier W (5916)
AND price_rub > 0
-- MAJOR-1: sync с estimator._COMMON_WHERE (5460) / Tier W (5932)
-- guard новостроек, NULL = legacy вторичка до м.011
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
-- MAJOR-1: sync с estimator Tier W (5910/5945-5948, #769 Part E) —
-- исключает city-centroid листинги без реального адреса;
-- IS DISTINCT FROM пропускает NULL (неизвестная точность)
AND (geo_precision IS DISTINCT FROM 'city')
)
SELECT
count(*) AS n_listings,
count(*) FILTER (
WHERE days_on_market IS NOT NULL
AND days_on_market <= :max_age_days
) AS n_with_age,
percentile_cont(0.5) WITHIN GROUP (ORDER BY days_on_market)
FILTER (
WHERE days_on_market IS NOT NULL
AND days_on_market <= :max_age_days
) AS median_age_days
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,
"max_age_days": COVERAGE_MAX_AGE_DAYS,
},
)
.mappings()
.fetchone()
)
n_listings = int(row["n_listings"]) if row else 0
n_with_age = int(row["n_with_age"]) if row and row["n_with_age"] is not None else 0
median_age = (
round(row["median_age_days"])
if row is not None
and row["median_age_days"] is not None
and n_with_age >= COVERAGE_MIN_AGE_SAMPLES
else None
)
city, threshold, supported = _resolve_coverage_city(payload.lat, payload.lon)
if not supported or n_listings == 0:
status: Literal["ok", "thin", "not_covered"] = "not_covered"
# Nit-fix (повторная проверка #2894): threshold неприменим при
# not_covered — см. CoverageProbeResponse.threshold docstring. Раньше
# поддерживаемый (по координатам) город с пустой когортой отдавал
# реальный порог (8/12) вместе с not_covered — противоречило докстрингу.
threshold = 0
elif n_listings >= threshold:
status = "ok"
else:
status = "thin"
logger.info(
"coverage probe rooms=%d area=%.1f city=%r status=%s n=%d n_with_age=%d",
payload.rooms,
payload.area_m2,
city,
status,
n_listings,
n_with_age,
)
return CoverageProbeResponse(
status=status,
n_listings=n_listings,
median_listing_age_days=median_age,
n_with_age=n_with_age,
radius_m=COVERAGE_RADIUS_M,
city=city,
threshold=threshold,
)

View file

@ -756,3 +756,77 @@ 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 делает фронт/автокомплит, эта ручка
сама НИКОГО не геокодирует). Город (и, соответственно, порог ok/thin) для
ответа резолвится ИСКЛЮЧИТЕЛЬНО из lat/lon см.
`app.api.v1.trade_in._resolve_coverage_city`.
city_hint ИНФОРМАЦИОННОЕ поле, на результат НЕ влияет (повторная проверка
#2894, 2026-08). Раньше оно участвовало в резолве города как фолбэк —
убрано вместе с модой `listings.city`: оба источника ненадёжны (`city_hint`
непроверенный клиентский вход, `listings.city` город свип-контекста
скрейпера, не адреса объявления, см. комментарий в trade_in.py). Поле
оставлено в схеме, потому что фронт его уже шлёт в других ручках того же
автокомплита (см. TradeInEstimateInput.city_hint) принимаем и молча
игнорируем, чтобы не ронять запрос лишней 422.
"""
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 на текущий момент), а НЕ срок до продажи. Цензурированная
выборка (активные объявления ещё висят) всегда завышена относительно
реального времени экспозиции проданных не путать со «сроком продажи».
ОГРАНИЧЕНИЕ ДАННЫХ (не продуктовое решение, см. coverage_probe docstring):
days_on_market на проде заполнена практически только у источника yandex
возраст известен у меньшинства строк когорты. n_with_age ниже честный
счётчик, по скольким объявлениям посчитана медиана; при n_with_age < порога
(COVERAGE_MIN_AGE_SAMPLES) median_listing_age_days принудительно null.
n_with_age сколько объявлений когорты реально имеют известный
(non-null, не-выброс) days_on_market и вошли в расчёт медианы. Фронт
обязан иметь возможность не показывать median_listing_age_days при
маленьком n_with_age цифра "медиана" по 1-2 объявлениям не медиана.
threshold n, начиная с которого статус переходит в "ok" для резолвленного
города; 0 всегда, когда status == "not_covered" (порог неприменим ни для
города вне зелёного/жёлтого списка, ни для поддерживаемого города с пустой
когортой), НЕ только для неподдерживаемого города.
city резолвится ИСКЛЮЧИТЕЛЬНО из lat/lon запроса (ближайший центроид из
зелёного/жёлтого списка в пределах `COVERAGE_CITY_MATCH_RADIUS_KM`), не из
`city_hint` и не из моды `listings.city` найденной когорты см.
`app.api.v1.trade_in._resolve_coverage_city`.
"""
status: Literal["ok", "thin", "not_covered"]
n_listings: int
median_listing_age_days: int | None
n_with_age: int
radius_m: int
city: str
threshold: int

View file

@ -69,3 +69,17 @@ tests/test_2764_ban_kind_no_default.py::test_real_default_ban_kind_survives_the_
tests/test_house_imv_retry_stuck.py::test_explicit_only_status_still_takes_exhausted_houses
tests/test_house_imv_retry_stuck.py::test_stuck_transient_house_returns_to_the_queue_by_itself
tests/test_house_imv_retry_stuck.py::test_transient_attempts_counter_only_counts_transient
# MAJOR-1 fix, coverage probe (#2894, независимый ревью) — тот же `live_session` fixture
# (self-skip через `_live_db_available()`, живёт только при реальном Postgres DSN).
# Проверяет, что novostroyki-строка / geo_precision='city'-строка / price_rub=0-строка
# физически не попадают в когорту (не только SQL-текст, который проверяется отдельным
# статическим тестом test_cohort_sql_excludes_* в этом же файле, идущим на обоих лэйнах).
tests/test_coverage_probe_endpoint.py::test_major1_cohort_excludes_novostroyki_and_city_precision_live
# MAJOR-2 поведенческий пин (повторная проверка #2894) — та же `live_session` fixture.
# Ловит мутацию «убрать FILTER у percentile_cont, оставив у count(*)», которую
# текстовый тест test_max_age_outlier_days_passed_to_sql пропускал (подстрока
# `days_on_market <= :max_age_days` встречается в SQL дважды). На мок-лэйне
# (deploy-tradein.yml, DSN-заглушка) реальной БД нет — self-skip.
tests/test_coverage_probe_endpoint.py::test_max_age_outlier_excluded_from_median_live

View file

@ -0,0 +1,656 @@
"""Tests for POST /api/v1/trade-in/coverage (issue #2894).
Бесплатная проба покрытия для публичного лэндинга «МЕРА» до оплаты человек
видит, сколько похожих квартир продаётся рядом и как быстро они уходят, без
единой рублёвой цифры в ответе. Covers:
- пороги ok/thin/not_covered для зелёных/жёлтых/неподдерживаемых городов
- пустая когорта (n=0) not_covered даже в поддерживаемом городе; threshold
принудительно 0 в этом случае (nit-fix, повторная проверка #2894)
- в ответе НЕТ ни одного price-подобного поля (падающий тест на регресс схемы)
- MAJOR-1 (независимый ревью #2894): когорта пробы — sync с
estimator._COMMON_WHERE / Tier W (novostroyki guard, geo_precision != 'city',
price_rub > 0), не шире когорты платного эстиматора
- MAJOR-2: median_listing_age_days честно null при тонкой n_with_age выборке,
выбросы (> COVERAGE_MAX_AGE_DAYS) не тянут медиану запинено ЖИВЫМ SQL
(см. test_max_age_outlier_excluded_from_median_live), не только подстрокой
- Повторная проверка #2894 (2026-08): город резолвится ИСКЛЮЧИТЕЛЬНО по
lat/lon (ближайший центроид), НЕ по моде `listings.city` (город
свип-контекста скрейпера, не адреса объявления) и НЕ по `city_hint`
(непроверенный клиентский вход) см. app.api.v1.trade_in._resolve_coverage_city
"""
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 _row(
n_listings: int,
median_age_days: float | None,
n_with_age: int | None = None,
) -> dict:
"""Строка, которую coverage_probe читает через db.execute(...).mappings().fetchone().
n_with_age по умолчанию = n_listings, если не задан явно (большинство старых
тестов не проверяют MAJOR-2 отдельно сохраняем их поведение).
Повторная проверка #2894: строка больше не несёт cohort_city — город
резолвится по lat/lon запроса, не по SQL-агрегату (см. модуль-докстринг).
"""
return {
"n_listings": n_listings,
"median_age_days": median_age_days,
"n_with_age": n_with_age if n_with_age is not None else n_listings,
}
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)
# Екатеринбург — совпадает (с точностью до сотен метров) с центроидом
# _CITY_CENTROIDS_DEG["Екатеринбург"], поэтому дефолтный payload детерминированно
# резолвится в зелёный город без доп. настройки координат в каждом тесте.
_BASE_PAYLOAD = {"lat": 56.8384, "lon": 60.6057, "rooms": 2, "area_m2": 50.0}
# Координаты других городов из COVERAGE_GREEN/YELLOW_CITIES (те же значения, что
# _CITY_CENTROIDS_DEG в trade_in.py) — используются, когда тесту нужен НЕ ЕКБ.
_NIZHNY_TAGIL = {"lat": 57.9099, "lon": 59.9819}
_REVDA = {"lat": 56.7986, "lon": 59.9298}
_BEREZOVSKY = {"lat": 56.9096, "lon": 60.8034}
# Реальные координаты Серова — ближайший поддерживаемый центроид (Нижний Тагил)
# в ~190 км, далеко за пределами COVERAGE_CITY_MATCH_RADIUS_KM=25 — гарантированно
# "город не определён", без совпадения ни с одним из 8 центроидов.
_FAR_AWAY_CITY = {"lat": 59.6047, "lon": 60.1970}
# ── 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(_row(10, 21.0))
_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(_row(8, 15.0))
_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
assert data["n_with_age"] == 8
def test_green_city_thin_below_threshold(trade_in_app: FastAPI) -> None:
"""Екатеринбург, n=7 (< порог 8) → thin, не ok и не not_covered."""
db = _db_mock_returning(_row(7, 10.0))
_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. Город резолвится из lat/lon."""
db = _db_mock_returning(_row(12, 30.0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_NIZHNY_TAGIL})
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(_row(11, 40.0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_REVDA})
data = resp.json()
assert data["status"] == "thin"
assert data["threshold"] == 12
# ── City outside all centroids → always not_covered ─────────────────────────────
def test_unsupported_city_not_covered_even_with_high_n(trade_in_app: FastAPI) -> None:
"""Точка вне 25-км радиуса всех центроидов → not_covered независимо от n_listings
(даже n=500)."""
db = _db_mock_returning(_row(500, 5.0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_FAR_AWAY_CITY})
data = resp.json()
assert data["status"] == "not_covered"
assert data["threshold"] == 0
assert data["n_listings"] == 500 # честно отдаём счётчик, статус его игнорирует
assert data["city"] == "" # город не определён — не эхуется сырой строкой
# ── Empty cohort ──────────────────────────────────────────────────────────────
def test_empty_cohort_supported_city_not_covered(trade_in_app: FastAPI) -> None:
"""n=0 в поддерживаемом (зелёном) городе → not_covered, не thin — честнее.
Nit-fix (повторная проверка #2894): threshold обязан быть 0, а не реальным
порогом города (8) при not_covered threshold "неприменим" по докстрингу
CoverageProbeResponse, независимо от ПРИЧИНЫ not_covered.
"""
db = _db_mock_returning(_row(0, None, n_with_age=0))
_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"] == "not_covered"
assert data["n_listings"] == 0
assert data["median_listing_age_days"] is None
assert data["n_with_age"] == 0
assert data["city"] == "Екатеринбург" # город резолвится по координатам всегда
assert data["threshold"] == 0 # nit: не 8, хотя город поддерживаемый
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
assert data["n_with_age"] == 0
assert data["threshold"] == 0
# ── Город резолвится ТОЛЬКО по координатам — не по listings.city, не по city_hint ──
def test_city_resolved_from_coordinates_not_cohort_mode(trade_in_app: FastAPI) -> None:
"""Точка в Берёзовском → city='Берёзовский' (а не 'Екатеринбург').
Регресс на прод-замер (повторная проверка #2894): в радиусе 1000м вокруг
Берёзовского 90/90 строк listings имеют city='Екатеринбург' (город
свип-контекста скрейпера, миграция 196) старая логика (мода когорты)
отдала бы 'Екатеринбург'. Ручка больше НЕ читает cohort city из SQL вовсе.
"""
db = _db_mock_returning(_row(8, 5.0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_BEREZOVSKY})
data = resp.json()
assert data["city"] == "Берёзовский"
assert data["status"] == "ok"
assert data["threshold"] == 8
def test_far_from_all_centroids_not_covered(trade_in_app: FastAPI) -> None:
"""Точка за пределами 25 км от всех 8 центроидов → not_covered, city=""."""
db = _db_mock_returning(_row(0, None, n_with_age=0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_FAR_AWAY_CITY})
data = resp.json()
assert data["status"] == "not_covered"
assert data["city"] == ""
assert data["threshold"] == 0
def test_city_hint_does_not_change_threshold_or_status(trade_in_app: FastAPI) -> None:
"""city_hint — чисто информационное поле (повторная проверка #2894): точка в
Берёзовском + city_hint='Екатеринбург' обязана резолвиться в Берёзовский
(threshold=8, зелёный порог оба города зелёные, поэтому дополнительно
проверяем n=8 ok именно для Берёзовского, а не подмену клиентом города).
"""
db_with_hint = _db_mock_returning(_row(8, 5.0))
_override(trade_in_app, db_with_hint)
client = TestClient(trade_in_app)
resp_with_hint = client.post(
"/api/v1/trade-in/coverage",
json={**_BASE_PAYLOAD, **_BEREZOVSKY, "city_hint": "Екатеринбург"},
)
db_without_hint = _db_mock_returning(_row(8, 5.0))
_override(trade_in_app, db_without_hint)
resp_without_hint = client.post(
"/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_BEREZOVSKY}
)
data_with, data_without = resp_with_hint.json(), resp_without_hint.json()
assert data_with["city"] == data_without["city"] == "Берёзовский"
assert data_with["threshold"] == data_without["threshold"] == 8
assert data_with["status"] == data_without["status"] == "ok"
# ── MAJOR-2: median age — n_with_age threshold + outlier clamp ─────────────────
def test_median_age_null_below_min_age_samples(trade_in_app: FastAPI) -> None:
"""n_with_age=2 (< COVERAGE_MIN_AGE_SAMPLES=5) → median_listing_age_days null,
даже если SQL посчитал percentile "медиана" по 1-2 объявлениям не медиана."""
from app.api.v1.trade_in import COVERAGE_MIN_AGE_SAMPLES
assert COVERAGE_MIN_AGE_SAMPLES == 5
db = _db_mock_returning(_row(20, 40.0, n_with_age=2))
_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["n_listings"] == 20 # когорта покрытия не урезается возрастным фильтром
assert data["n_with_age"] == 2
assert data["median_listing_age_days"] is None
def test_median_age_present_at_min_age_samples_threshold(trade_in_app: FastAPI) -> None:
"""n_with_age=5 (== порог) → median_listing_age_days отдаётся."""
db = _db_mock_returning(_row(20, 40.0, n_with_age=5))
_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["n_with_age"] == 5
assert data["median_listing_age_days"] == 40
def test_max_age_outlier_days_passed_to_sql(trade_in_app: FastAPI) -> None:
"""COVERAGE_MAX_AGE_DAYS=365 передаётся в SQL как параметр — выбросы (мёртвые
объявления) отсекаются percentile_cont FILTER на стороне БД, не в Python.
Слабая (текстовая) проверка подстрока встречается в SQL ДВАЖДЫ (count и
percentile_cont), поэтому `assert "..." in sql_text` одна ловит только
"убрали оба FILTER", не "убрали один из двух". Реальный поведенческий пин
test_max_age_outlier_excluded_from_median_live ниже (живой Postgres).
"""
from app.api.v1.trade_in import COVERAGE_MAX_AGE_DAYS
assert COVERAGE_MAX_AGE_DAYS == 365
db = _db_mock_returning(_row(0, None, n_with_age=0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
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["max_age_days"] == 365
sql_text = str(call_args[0][0])
# count==2: и в count(*) FILTER, и в percentile_cont(...) FILTER — обе нужны,
# чтобы n_with_age и median_listing_age_days считались по ОДНОМУ и тому же
# предикату (иначе честный n_with_age маскирует нечестный медианный расчёт).
assert sql_text.count("days_on_market <= :max_age_days") == 2
# ── 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(_row(0, None, n_with_age=0))
_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
# ── MAJOR-1: cohort predicates — sync с estimator._COMMON_WHERE / Tier W ────────
#
# Прямая регрессия из независимого ревью #2894: без этих трёх предикатов проба
# отвечает "ok" в точках, где платный эстиматор (radius Tier W, тот же 1000м)
# реально видит 0 — потому что вся когорта состоит из новостроек / city-centroid
# листингов, которые estimator._COMMON_WHERE / Tier W уже отсекают. Тест ловит
# случайное удаление ЛЮБОГО из трёх предикатов на уровне сгенерированного SQL —
# без живой БД, как и остальные тесты этого файла (см. test_gar_flats_loader.py
# для опционального real-Postgres-варианта аналогичной проверки в этом репо).
def test_cohort_sql_excludes_novostroyki(trade_in_app: FastAPI) -> None:
"""Guard новостроек — sync с estimator._COMMON_WHERE (5460) / Tier W (5932)."""
db = _db_mock_returning(_row(0, None, n_with_age=0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
sql_text = str(db.execute.call_args[0][0])
assert "listing_segment IS NULL OR listing_segment = 'vtorichka'" in sql_text
def test_cohort_sql_excludes_city_precision_geocodes(trade_in_app: FastAPI) -> None:
"""geo_precision != 'city' — sync с estimator Tier W (5910/5945-5948, #769 Part E)."""
db = _db_mock_returning(_row(0, None, n_with_age=0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
sql_text = str(db.execute.call_args[0][0])
assert "geo_precision IS DISTINCT FROM 'city'" in sql_text
def test_cohort_sql_excludes_zero_price(trade_in_app: FastAPI) -> None:
"""price_rub > 0 — sync с estimator._COMMON_WHERE (5441) / Tier W (5916)."""
db = _db_mock_returning(_row(0, None, n_with_age=0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
sql_text = str(db.execute.call_args[0][0])
assert "price_rub > 0" in sql_text
# ── Live-DB tests (self-skip без реальной Postgres+PostGIS) ────────────────────
#
# Опциональные тесты против настоящего Postgres (тот же паттерн self-skip, что
# test_gar_flats_loader.py::_live_session) — требуют TEST_DATABASE_URL/
# DATABASE_URL, указывающий на реальную БД (не дефолтный localhost:5432/test-
# заглушку); иначе skip. В CI (ci-tradein.yml) этот DSN всегда живой Postgres+
# PostGIS-контейнер.
#
# Fix (повторная проверка #2894): раньше `_live_session()` вызывался И в
# `pytest.mark.skipif(...)` (на этапе СБОРА тестов — соединение открывалось и
# никогда не закрывалось, при реальном DSN это утечка на КАЖДЫЙ импорт файла),
# И повторно внутри тела единственного live-теста. Теперь доступность БД
# проверяется отдельной дешёвой функцией с явным закрытием соединения
# (`_live_db_available`), а сама Session выдаётся pytest-фикстурой
# (`live_session`) с гарантированным close() в finally, а не ручным вызовом.
def _live_db_available() -> bool:
"""Дешёвая проверка доступности live-Postgres — соединение открывается и
СРАЗУ закрывается (`with engine.connect()`), никакого висящего ORM Session.
Используется только в `pytest.mark.skipif(...)`, который вычисляется на
этапе сбора тестов до фикстур.
"""
try:
from sqlalchemy import create_engine
from sqlalchemy import text as sa_text
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
if not dsn or "localhost:5432/test" in dsn:
return False
engine = create_engine(dsn, future=True)
try:
with engine.connect() as conn:
conn.execute(sa_text("SELECT 1"))
return True
finally:
engine.dispose()
except Exception:
return False
@pytest.fixture()
def live_session(): # type: ignore[no-untyped-def]
"""Session для live-Postgres тестов — гарантированно закрывается после теста
(rollback + close + dispose в finally), в отличие от прежнего ручного вызова
`_live_session()` внутри тела каждого теста."""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
engine = create_engine(dsn, future=True)
session_factory = sessionmaker(bind=engine, future=True)
session = session_factory()
try:
yield session
finally:
session.rollback()
session.close()
engine.dispose()
# Координаты вне Свердловской обл. (реальные данные там ~56-60/58-64) — изолируют
# тестовую когорту от прод-данных без нужды в COMMIT/rollback гимнастики поверх
# чужой транзакции.
_LIVE_LAT, _LIVE_LON = 1.111, 2.222
@pytest.mark.skipif(not _live_db_available(), reason="нет доступной Postgres test-БД")
def test_major1_cohort_excludes_novostroyki_and_city_precision_live(live_session) -> None: # type: ignore[no-untyped-def]
from sqlalchemy import text as sa_text
from app.api.v1.trade_in import coverage_probe
from app.schemas.trade_in import CoverageProbeInput
db = live_session
rows = [
# (source_url suffix, listing_segment, geo_precision, price_rub) — все
# остальные поля общие: rooms=2, area_m2=50, is_active, scraped_at=NOW().
("ok-vtorichka", None, None, 5_000_000), # counted
("bad-novostroyka", "novostroyki", None, 5_000_000), # excluded
("bad-city-precision", None, "city", 5_000_000), # excluded
("bad-zero-price", None, None, 0), # excluded
]
for suffix, segment, geo_precision, price in rows:
url = f"https://test.invalid/coverage-major1-{suffix}"
db.execute(
sa_text(
"""
INSERT INTO listings
(source, source_url, source_id, dedup_hash, address, lat, lon,
rooms, area_m2, price_rub, is_active, scraped_at,
listing_segment, geo_precision)
VALUES
('test', :url, :url, :url, 'test addr', :lat, :lon,
2, 50.0, :price, true, NOW(), :segment, :geo_precision)
"""
),
{
"url": url,
"lat": _LIVE_LAT,
"lon": _LIVE_LON,
"price": price,
"segment": segment,
"geo_precision": geo_precision,
},
)
result = coverage_probe(
CoverageProbeInput(lat=_LIVE_LAT, lon=_LIVE_LON, rooms=2, area_m2=50.0), db
)
# Только первая (ok-vtorichka) строка должна попадать в когорту —
# каждая следующая вставка не должна сдвигать счётчик.
assert result.n_listings == 1, (
f"predicate regression: n_listings={result.n_listings} after inserting "
f"{suffix!r} (segment={segment!r} geo_precision={geo_precision!r} "
f"price={price}) — expected still 1 (only ok-vtorichka counted)"
)
@pytest.mark.skipif(not _live_db_available(), reason="нет доступной Postgres test-БД")
def test_max_age_outlier_excluded_from_median_live(live_session) -> None: # type: ignore[no-untyped-def]
"""MAJOR-2 поведенческий пин (повторная проверка #2894).
Текстовый тест (test_max_age_outlier_days_passed_to_sql) проверял, что
подстрока `days_on_market <= :max_age_days` встречается в SQL но она там
ДВАЖДЫ (count и percentile_cont), и мутация «убрать FILTER у
percentile_cont, оставив у count» проходила зелёной: n_with_age (из count)
оставался честным, а percentile_cont без FILTER считал медиану по ВСЕМ
days_on_market, включая выбросы.
Вставляет когорту из 5 "нормальных" объявлений (days_on_market
4/6/8/10/12, честная медиана 8) и один выброс (days_on_market=4000,
> COVERAGE_MAX_AGE_DAYS=365). Проверяет, что после вставки выброса
n_with_age и median_listing_age_days НЕ меняются (выброс попадает только
в n_listings) с правильными двумя FILTER это так; без FILTER у
percentile_cont медиана сдвинулась бы 8 9 (percentile_cont(0.5) по
[4,6,8,10,12,4000] = среднее 3-го и 4-го отсортированных значений = 9).
"""
from sqlalchemy import text as sa_text
from app.api.v1.trade_in import coverage_probe
from app.schemas.trade_in import CoverageProbeInput
db = live_session
normal_ages = [4, 6, 8, 10, 12]
for i, age in enumerate(normal_ages):
url = f"https://test.invalid/coverage-major2-normal-{i}"
db.execute(
sa_text(
"""
INSERT INTO listings
(source, source_url, source_id, dedup_hash, address, lat, lon,
rooms, area_m2, price_rub, is_active, scraped_at, days_on_market)
VALUES
('test', :url, :url, :url, :addr, :lat, :lon,
2, 50.0, 5000000, true, NOW(), :age)
"""
),
{
"url": url,
"addr": f"test addr coverage-major2-{i}",
"lat": _LIVE_LAT,
"lon": _LIVE_LON,
"age": age,
},
)
result = coverage_probe(
CoverageProbeInput(lat=_LIVE_LAT, lon=_LIVE_LON, rooms=2, area_m2=50.0), db
)
assert result.n_listings == 5
assert result.n_with_age == 5
assert result.median_listing_age_days == 8
outlier_url = "https://test.invalid/coverage-major2-outlier"
db.execute(
sa_text(
"""
INSERT INTO listings
(source, source_url, source_id, dedup_hash, address, lat, lon,
rooms, area_m2, price_rub, is_active, scraped_at, days_on_market)
VALUES
('test', :url, :url, :url, 'test addr coverage-major2-outlier', :lat, :lon,
2, 50.0, 5000000, true, NOW(), 4000)
"""
),
{"url": outlier_url, "lat": _LIVE_LAT, "lon": _LIVE_LON},
)
result_with_outlier = coverage_probe(
CoverageProbeInput(lat=_LIVE_LAT, lon=_LIVE_LON, rooms=2, area_m2=50.0), db
)
assert result_with_outlier.n_listings == 6 # выброс всё же попадает в n_listings
assert result_with_outlier.n_with_age == 5, (
f"MAJOR-2 regression: outlier (days_on_market=4000 > MAX=365) leaked into "
f"n_with_age={result_with_outlier.n_with_age} — count(*) FILTER пропал/сломан"
)
assert result_with_outlier.median_listing_age_days == 8, (
f"MAJOR-2 regression: median_listing_age_days="
f"{result_with_outlier.median_listing_age_days} shifted by outlier — "
f"percentile_cont(...) FILTER пропал (мутация «убрать FILTER у "
f"percentile_cont, оставив у count»)"
)