gendesign/tradein-mvp/backend/tests/test_coverage_probe_endpoint.py
bot-backend 73c4c487ed
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
fix(mera/b2c): житель Серова получал «вы вне области», а подсказки игнорировали выбранный город
Два дефекта, найденных прогоном сценария глазами посетителя на живом домене.

## 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-фильтр БЭКЕНДОВОГО лэйна: сверку списков
делает бэкендовый тест, и без этой строки правка одного лишь дропдауна её бы
не запускала — то есть ровно тот путь, которым списки и разошлись.
2026-08-16 13:03:19 +03:00

665 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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»)"
)