All checks were successful
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m6s
CI Trade-In / backend-tests (pull_request) Successful in 4m50s
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Successful in 48s
Два дефекта, найденных прогоном сценария глазами посетителя на живом домене. ## 1. Город предлагали выбрать, но отвечать по нему не умели Дропдаун на сайте (`OBLAST_CITIES`, city-registry.ts) и списки покрытия (`COVERAGE_GREEN/YELLOW_CITIES`, trade_in.py) — одно множество, записанное в двух местах. Они разошлись в обе стороны: предлагали, но не отвечали: Серов отвечали, но не предлагали: Берёзовский, Среднеуральск, Ревда Житель Серова выбирал СВОЙ город из НАШЕГО дропдауна и получал: «Этот адрес вне области, по которой мы собираем данные. Сейчас это Свердловская область: Екатеринбург целиком и ещё несколько городов вокруг.» Про город в той же самой области. Серов при этом покрыт данными: 363 активных объявления в радиусе 15 км, все свежие (замер по проде). Поэтому добавлен в жёлтый тир, а не убран из дропдаунa; три недостающих города добавлены на фронт. Шапка city-registry.ts этот риск прямо предсказывала — «перед добавлением 7-го города сверить оба списка вручную, теста на это пока нет». Теперь тест есть: бэкендовый сьют читает TS-реестр и требует РАВЕНСТВА множеств. Плюс проверка, что у каждого города с порогом есть центроид, — иначе порог мёртвый, город по координатам не резолвится. ## 2. Подсказки не слушались выбранного города `city_hint` доезжает до геокодера, но на выдачу не влияет: его смотрит только екатеринбургский кадастровый тир (как признак «речь не про ЕКБ, тир пропускаем»), а DaData-тир ограничен регионом целиком и хинта не принимает. Замер: выбран Серов, введено «Ленина 1» → первой подсказкой «Невьянский р-н, пгт Верх-Нейвинский». Человек выбирает верхний вариант и считает чужой дом — ровно баг #2576, ради которого город и спрашивают. Публичная ручка теперь подставляет город в саму строку запроса. Проверено на проде: «Серов Ленина 1» даёт серовскую выдачу целиком. Для Екатеринбурга подстановка безвредна — три разных адреса дали тот же результат с префиксом и без, поэтому правило одно на все города, без исключения для основного трафика. Чинится в публичной ручке, а не в геокодере: там от `city_hint` зависит поведение закрытого контура (`target_city_ambiguous`). ## Фикстура теста `_FAR_AWAY_CITY` стояла в 21 км от центра Серова и работала как «далеко от всех» лишь потому, что Серов не был поддержан. Переехала в Тавду — 271 км до ближайшего центроида. ## Мутации убрать Серов из покрытия (состояние прода) → падает сверка списков не подставлять город в строку → падает проверка ручки откат → 21 passed Плюс backend 75 passed, vitest 56 passed, tsc, lint, build, isolation guard. `city-registry.ts` добавлен в paths-фильтр БЭКЕНДОВОГО лэйна: сверку списков делает бэкендовый тест, и без этой строки правка одного лишь дропдауна её бы не запускала — то есть ровно тот путь, которым списки и разошлись.
665 lines
33 KiB
Python
665 lines
33 KiB
Python
"""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 центроидов.
|
||
# Тавда: ближайший из центроидов (Каменск-Уральский) в 271 км, то есть точка
|
||
# заведомо вне 25-км радиуса любого поддержанного города — при этом всё ещё
|
||
# Свердловская область, как и задумано тестами ниже.
|
||
#
|
||
# Раньше здесь стояла точка 59.6047/60.1970 — окрестности Серова, в 21 км от
|
||
# его центра. Она работала как «далеко от всех» лишь потому, что Серов не был
|
||
# поддержан: город предлагался в дропдауне на сайте, но отсутствовал в списках
|
||
# покрытия, и его житель получал «этот адрес вне области». Серов добавлен
|
||
# 16.08.2026, и фикстура переехала туда, где действительно далеко.
|
||
_FAR_AWAY_CITY = {"lat": 58.0424, "lon": 65.2711}
|
||
|
||
|
||
# ── 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 км от всех центроидов → 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»)"
|
||
)
|