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