diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index b0b4be85..71402948 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -2564,6 +2564,17 @@ 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 = ("Нижний Тагил", "Каменск-Уральский", "Первоуральск", "Ревда") @@ -2585,18 +2596,24 @@ _COVERAGE_CITY_THRESHOLDS: dict[str, tuple[str, int]] = { def _resolve_coverage_city(city_hint: str | None, cohort_city: str | None) -> tuple[str, int, bool]: """Резолвит (display_city, threshold, is_supported) для пробы покрытия. - Приоритет: явный city_hint фронта (тот же автокомплит, что заполняет - TradeInEstimateInput.city_hint) > мода city найденной SQL-когорты - (best-effort фолбэк, когда фронт его не передал). Город вне зелёного/ - жёлтого списка → threshold=0, is_supported=False — вызывающий обязан - трактовать это как not_covered независимо от n_listings. + MINOR fix (независимый ревью #2894): city_hint — это НЕ проверенный вход, + клиент им управляет напрямую (lat/lon в Серове + city_hint='Екатеринбург' + раньше давал threshold=8 и status='ok' — клиент выбирал себе порог). Источник + истины — мода city найденной SQL-когорты (то, что реально лежит в БД рядом с + переданными lat/lon); city_hint используется ТОЛЬКО как фолбэк, когда когорта + пуста (cohort_city is None) — в этом случае n_listings тоже 0, и caller всё + равно форсирует status="not_covered" независимо от threshold/supported, так + что подмена клиентом порога здесь не даёт эффекта. + Эхо произвольной клиентской строки в поле city убрано: candidate, не нашедший + совпадения в зелёном/жёлтом списке, отдаётся как "" (не supported), а не как + сырой ввод. """ - candidate = (city_hint or cohort_city or "").strip() + candidate = (cohort_city or city_hint or "").strip() match = _COVERAGE_CITY_THRESHOLDS.get(_fold_city(candidate)) if candidate else None if match is not None: display, threshold = match return display, threshold, True - return candidate, 0, False + return "", 0, False @router.post("/coverage", response_model=CoverageProbeResponse) @@ -2613,8 +2630,23 @@ def coverage_probe( в 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/ @@ -2648,11 +2680,27 @@ def coverage_probe( 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) - AS median_age_days, + FILTER ( + WHERE days_on_market IS NOT NULL + AND days_on_market <= :max_age_days + ) AS median_age_days, mode() WITHIN GROUP (ORDER BY city) FILTER (WHERE city IS NOT NULL) AS cohort_city FROM base @@ -2669,6 +2717,7 @@ def coverage_probe( "lon": payload.lon, "radius": COVERAGE_RADIUS_M, "max_per_addr": MAX_ANALOGS_PER_ADDRESS, + "max_age_days": COVERAGE_MAX_AGE_DAYS, }, ) .mappings() @@ -2676,9 +2725,12 @@ def coverage_probe( ) 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 + if row is not None + and row["median_age_days"] is not None + and n_with_age >= COVERAGE_MIN_AGE_SAMPLES else None ) cohort_city = row["cohort_city"] if row else None @@ -2693,18 +2745,20 @@ def coverage_probe( status = "thin" logger.info( - "coverage probe rooms=%d area=%.1f city=%r status=%s n=%d", + "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, diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index d8fc5c9b..4686b3b5 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -792,6 +792,16 @@ class CoverageProbeResponse(BaseModel): (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, если город не входит ни в один список (порог неприменим — @@ -801,6 +811,7 @@ class CoverageProbeResponse(BaseModel): 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 diff --git a/tradein-mvp/backend/tests/skip_allowlist.txt b/tradein-mvp/backend/tests/skip_allowlist.txt index fe555229..fb780107 100644 --- a/tradein-mvp/backend/tests/skip_allowlist.txt +++ b/tradein-mvp/backend/tests/skip_allowlist.txt @@ -69,3 +69,9 @@ 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()`. +# Проверяет, что 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 diff --git a/tradein-mvp/backend/tests/test_coverage_probe_endpoint.py b/tradein-mvp/backend/tests/test_coverage_probe_endpoint.py index 42445e28..1b6abdc3 100644 --- a/tradein-mvp/backend/tests/test_coverage_probe_endpoint.py +++ b/tradein-mvp/backend/tests/test_coverage_probe_endpoint.py @@ -6,8 +6,13 @@ - пороги ok/thin/not_covered для зелёных/жёлтых/неподдерживаемых городов - пустая когорта (n=0) → not_covered даже в поддерживаемом городе - в ответе НЕТ ни одного price-подобного поля (падающий тест на регресс схемы) - - city_hint приоритетнее моды city из когорты - - median_listing_age_days — median(days_on_market), None при пустой когорте + - 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) не тянут медиану + - MINOR: когортный город (мода) побеждает city_hint при расхождении — клиент + не управляет порогом; неизвестный город не эхуется сырой строкой """ from __future__ import annotations @@ -49,6 +54,25 @@ def trade_in_app() -> FastAPI: return application +def _row( + n_listings: int, + median_age_days: float | None, + cohort_city: str | None, + n_with_age: int | None = None, +) -> dict: + """Строка, которую coverage_probe читает через db.execute(...).mappings().fetchone(). + + n_with_age по умолчанию = n_listings, если не задан явно (большинство старых + тестов не проверяют MAJOR-2 отдельно — сохраняем их поведение). + """ + return { + "n_listings": n_listings, + "median_age_days": median_age_days, + "cohort_city": cohort_city, + "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() @@ -85,9 +109,7 @@ def test_coverage_response_has_no_price_fields(trade_in_app: FastAPI) -> None: 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( - {"n_listings": 10, "median_age_days": 21.0, "cohort_city": "Екатеринбург"} - ) + db = _db_mock_returning(_row(10, 21.0, "Екатеринбург")) _override(trade_in_app, db) client = TestClient(trade_in_app) @@ -103,9 +125,7 @@ def test_coverage_actual_response_has_no_price_fields(trade_in_app: FastAPI) -> def test_green_city_ok_at_threshold(trade_in_app: FastAPI) -> None: """Екатеринбург (зелёный, порог 8) — n=8 ровно на границе → ok.""" - db = _db_mock_returning( - {"n_listings": 8, "median_age_days": 15.0, "cohort_city": "Екатеринбург"} - ) + db = _db_mock_returning(_row(8, 15.0, "Екатеринбург")) _override(trade_in_app, db) client = TestClient(trade_in_app) @@ -118,13 +138,12 @@ def test_green_city_ok_at_threshold(trade_in_app: FastAPI) -> None: 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( - {"n_listings": 7, "median_age_days": 10.0, "cohort_city": "Екатеринбург"} - ) + db = _db_mock_returning(_row(7, 10.0, "Екатеринбург")) _override(trade_in_app, db) client = TestClient(trade_in_app) @@ -139,10 +158,8 @@ def test_green_city_thin_below_threshold(trade_in_app: FastAPI) -> None: def test_yellow_city_ok_at_threshold(trade_in_app: FastAPI) -> None: - """Нижний Тагил (жёлтый, порог 12) — n=12 → ok.""" - db = _db_mock_returning( - {"n_listings": 12, "median_age_days": 30.0, "cohort_city": "Нижний Тагил"} - ) + """Нижний Тагил (жёлтый, порог 12) — n=12 → ok. cohort_city совпадает с hint.""" + db = _db_mock_returning(_row(12, 30.0, "Нижний Тагил")) _override(trade_in_app, db) client = TestClient(trade_in_app) @@ -157,8 +174,8 @@ def test_yellow_city_ok_at_threshold(trade_in_app: FastAPI) -> None: def test_yellow_city_thin_below_threshold(trade_in_app: FastAPI) -> None: - """Ревда, n=11 (< порог 12) → thin.""" - db = _db_mock_returning({"n_listings": 11, "median_age_days": 40.0, "cohort_city": None}) + """Ревда, n=11 (< порог 12) → thin. Когорта пуста по городу → используем hint.""" + db = _db_mock_returning(_row(11, 40.0, None)) _override(trade_in_app, db) client = TestClient(trade_in_app) @@ -173,7 +190,7 @@ def test_yellow_city_thin_below_threshold(trade_in_app: FastAPI) -> None: def test_unsupported_city_not_covered_even_with_high_n(trade_in_app: FastAPI) -> None: """Город вне списков → not_covered независимо от n_listings (даже n=500).""" - db = _db_mock_returning({"n_listings": 500, "median_age_days": 5.0, "cohort_city": "Серов"}) + db = _db_mock_returning(_row(500, 5.0, "Серов")) _override(trade_in_app, db) client = TestClient(trade_in_app) @@ -182,6 +199,7 @@ def test_unsupported_city_not_covered_even_with_high_n(trade_in_app: FastAPI) -> assert data["status"] == "not_covered" assert data["threshold"] == 0 assert data["n_listings"] == 500 # честно отдаём счётчик, статус его игнорирует + assert data["city"] == "" # MINOR: неизвестный город не эхуется сырой строкой # ── Empty cohort ────────────────────────────────────────────────────────────── @@ -189,7 +207,7 @@ def test_unsupported_city_not_covered_even_with_high_n(trade_in_app: FastAPI) -> def test_empty_cohort_supported_city_not_covered(trade_in_app: FastAPI) -> None: """n=0 в поддерживаемом (зелёном) городе → not_covered, не thin — честнее.""" - db = _db_mock_returning({"n_listings": 0, "median_age_days": None, "cohort_city": None}) + db = _db_mock_returning(_row(0, None, None, n_with_age=0)) _override(trade_in_app, db) client = TestClient(trade_in_app) @@ -200,6 +218,7 @@ def test_empty_cohort_supported_city_not_covered(trade_in_app: FastAPI) -> None: assert data["status"] == "not_covered" assert data["n_listings"] == 0 assert data["median_listing_age_days"] is None + assert data["n_with_age"] == 0 def test_empty_cohort_no_row_at_all(trade_in_app: FastAPI) -> None: @@ -215,14 +234,37 @@ def test_empty_cohort_no_row_at_all(trade_in_app: FastAPI) -> None: assert data["status"] == "not_covered" assert data["n_listings"] == 0 assert data["median_listing_age_days"] is None + assert data["n_with_age"] == 0 -# ── city_hint priority over cohort mode ───────────────────────────────────────── +# ── MINOR: cohort mode (реальные данные из БД) побеждает city_hint ────────────── -def test_city_hint_overrides_cohort_mode(trade_in_app: FastAPI) -> None: - """city_hint (фронт) побеждает cohort_city (SQL mode) при определении города.""" - db = _db_mock_returning({"n_listings": 9, "median_age_days": 12.0, "cohort_city": "Серов"}) +def test_cohort_mode_overrides_city_hint_on_mismatch(trade_in_app: FastAPI) -> None: + """lat/lon в Серове + city_hint='Екатеринбург' — клиент не управляет порогом. + + Когорта реально нашлась в Серове (cohort_city="Серов", город вне списков) — + ответ обязан игнорировать спуфленный hint и не выдавать зелёный threshold=8. + Регресс на прод-инцидент из независимого ревью #2894. + """ + db = _db_mock_returning(_row(9, 12.0, "Серов")) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + resp = client.post( + "/api/v1/trade-in/coverage", + json={**_BASE_PAYLOAD, "city_hint": "Екатеринбург"}, + ) + data = resp.json() + assert data["city"] != "Екатеринбург" + assert data["status"] == "not_covered" # Серов вне зелёного/жёлтого списка + assert data["threshold"] == 0 + + +def test_city_hint_used_only_as_fallback_for_empty_cohort(trade_in_app: FastAPI) -> None: + """Когорта пуста (cohort_city=None) — hint используется как фолбэк для display, + но status всё равно not_covered (n_listings=0), так что подмена без эффекта.""" + db = _db_mock_returning(_row(0, None, None, n_with_age=0)) _override(trade_in_app, db) client = TestClient(trade_in_app) @@ -232,12 +274,12 @@ def test_city_hint_overrides_cohort_mode(trade_in_app: FastAPI) -> None: ) data = resp.json() assert data["city"] == "Екатеринбург" - assert data["status"] == "ok" # n=9 >= 8 (зелёный порог), не серовский not_covered + assert data["status"] == "not_covered" def test_yo_fold_city_hint_matches(trade_in_app: FastAPI) -> None: """«Березовский» без ё должен резолвиться в тот же зелёный порог, что «Берёзовский».""" - db = _db_mock_returning({"n_listings": 8, "median_age_days": 5.0, "cohort_city": None}) + db = _db_mock_returning(_row(8, 5.0, None, n_with_age=8)) _override(trade_in_app, db) client = TestClient(trade_in_app) @@ -250,12 +292,64 @@ def test_yo_fold_city_hint_matches(trade_in_app: FastAPI) -> None: assert data["threshold"] == 8 +# ── 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.""" + from app.api.v1.trade_in import COVERAGE_MAX_AGE_DAYS + + assert COVERAGE_MAX_AGE_DAYS == 365 + db = _db_mock_returning(_row(0, None, 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]) + assert "days_on_market <= :max_age_days" in sql_text + + # ── 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({"n_listings": 0, "median_age_days": None, "cohort_city": None}) + db = _db_mock_returning(_row(0, None, None, n_with_age=0)) _override(trade_in_app, db) client = TestClient(trade_in_app) @@ -269,3 +363,140 @@ def test_coverage_sql_uses_radius_1000_and_area_tolerance(trade_in_app: FastAPI) 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, 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, 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, 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 + + +# ── MAJOR-1 (real-DB variant): предикаты реально фильтруют, не только в тексте ── +# +# Опциональный тест против настоящего Postgres (тот же паттерн self-skip, что +# test_gar_flats_loader.py::_live_session) — вставляет novostroyki-строку и +# строку с geo_precision='city' в когорту и проверяет, что они физически НЕ +# посчитаны. Требует TEST_DATABASE_URL/DATABASE_URL, указывающий на реальную +# Postgres+PostGIS БД (не дефолтный localhost:5432/test-заглушку) — иначе skip. + + +def _live_session(): # type: ignore[no-untyped-def] + try: + from sqlalchemy import create_engine + from sqlalchemy import text as sa_text + from sqlalchemy.orm import sessionmaker + + dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "") + if not dsn or "localhost:5432/test" in dsn: + return None + engine = create_engine(dsn, future=True) + conn = engine.connect() + conn.execute(sa_text("SELECT 1")) + conn.close() + return sessionmaker(bind=engine, future=True)() + except Exception: + return None + + +# Координаты вне Свердловской обл. (реальные данные там ~56-60/58-64) — изолируют +# тестовую когорту от прод-данных без нужды в COMMIT/rollback гимнастики поверх +# чужой транзакции. +_LIVE_LAT, _LIVE_LON = 1.111, 2.222 + + +@pytest.mark.skipif(_live_session() is None, reason="нет доступной Postgres test-БД") +def test_major1_cohort_excludes_novostroyki_and_city_precision_live() -> None: + 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() + assert db is not None + try: + 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)" + ) + finally: + db.rollback() + db.close()