feat(tradein): бесплатная проба покрытия POST /coverage (#2894) #2909
4 changed files with 1013 additions and 1 deletions
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
656
tradein-mvp/backend/tests/test_coverage_probe_endpoint.py
Normal file
656
tradein-mvp/backend/tests/test_coverage_probe_endpoint.py
Normal 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»)"
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue