"""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 # ── Москва: сетка из 67 центроидов, одно display-имя, жёлтый порог ─────────── # Кремль — исторический центр; в сетке ему соответствует точка «Тверской». _MOSCOW_CENTER = {"lat": 55.7558, "lon": 37.6173} # Ватутинки, ТиНАО: ~32 км от Кремля. Точка существует, чтобы ловить дальнюю # от центра часть Новой Москвы, а не центральные ячейки сетки. _NEW_MOSCOW = {"lat": 55.5008, "lon": 37.3800} # Зеленоград: эксклав в ~37 км от Кремля и в ~46 км от ТиНАО — без собственных # точек сетки был бы not_covered. _ZELENOGRAD = {"lat": 55.9825, "lon": 37.1814} # Контрольные точки ВНУТРИ МКАД (замер 11.09.2026). До перехода на сетку # половина из них проигрывала подмосковному центроиду и отдавала «город не # определён» — это и был дефект, ради которого сетка заведена. Проверяем # ПОВЕДЕНИЕ резолвера на координатах, а не содержимое константы. _INSIDE_MKAD = { "Новогиреево": (55.7520, 37.8140), "Некрасовка": (55.7020, 37.9200), "Тушино": (55.8267, 37.4358), "Северное Бутово": (55.5680, 37.5700), "Солнцево": (55.6350, 37.3900), "Ясенево": (55.6060, 37.5330), "Митино": (55.8450, 37.3630), "Марьино": (55.6500, 37.7440), "Измайлово": (55.7890, 37.7510), "Бирюлёво Восточное": (55.5900, 37.6800), "Печатники": (55.6900, 37.7300), "Куркино": (55.8925, 37.3810), } # Владимирская область, ~180 км от Кремля и ~200 км от ТиНАО: «посреди поля» # уже с московской стороны страны — новые центроиды не должны были расширить # покрытие вглубь ЦФО. _FAR_FROM_MOSCOW = {"lat": 55.5, "lon": 40.5} def test_moscow_center_ok_at_yellow_threshold(trade_in_app: FastAPI) -> None: """Центр Москвы → city='Москва', порог 12 (жёлтый тир), n=12 → ok.""" db = _db_mock_returning(_row(12, 20.0)) _override(trade_in_app, db) client = TestClient(trade_in_app) resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_MOSCOW_CENTER}) assert resp.status_code == 200, resp.text data = resp.json() assert data["city"] == "Москва" assert data["threshold"] == 12 assert data["status"] == "ok" def test_moscow_is_yellow_not_green(trade_in_app: FastAPI) -> None: """n=8 в Москве — это thin, а не ok: тир жёлтый (замер 10.09.2026, медиана когорты 14 против 37 у зелёного Екатеринбурга, доля когорт >= 12 — 0.57).""" db = _db_mock_returning(_row(8, 20.0)) _override(trade_in_app, db) client = TestClient(trade_in_app) resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_MOSCOW_CENTER}) data = resp.json() assert data["status"] == "thin" assert data["threshold"] == 12 def test_new_moscow_resolves_to_display_name_moscow(trade_in_app: FastAPI) -> None: """ТиНАО ловится своей точкой сетки, но наружу уходит «Москва». Ключ центроида («Москва (Ватутинки, НАО)») наружу не просачивается — иначе повторился бы баг Берёзовского: человек видит в ответе чужое название. """ db = _db_mock_returning(_row(12, 25.0)) _override(trade_in_app, db) client = TestClient(trade_in_app) resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_NEW_MOSCOW}) data = resp.json() assert data["city"] == "Москва" assert data["threshold"] == 12 assert data["status"] == "ok" def test_zelenograd_resolves_to_display_name_moscow(trade_in_app: FastAPI) -> None: """Зеленоград — свои точки сетки, тоже с display-именем «Москва».""" db = _db_mock_returning(_row(12, 25.0)) _override(trade_in_app, db) client = TestClient(trade_in_app) resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_ZELENOGRAD}) data = resp.json() assert data["city"] == "Москва" assert data["threshold"] == 12 def test_inside_mkad_points_resolve_to_moscow(trade_in_app: FastAPI) -> None: """Все контрольные точки внутри МКАД → «Москва» с жёлтым порогом 12. Регрессия на дефект «сетки нет»: с тремя центроидами Митино, Марьино, Печатники, Некрасовка и Новогиреево проигрывали подмосковным точкам (Химки, Реутов, Развилка) и отдавали «город не определён». """ from app.api.v1.trade_in import _resolve_coverage_city for name, (lat, lon) in _INSIDE_MKAD.items(): assert _resolve_coverage_city(lat, lon) == ("Москва", 12, True), ( f"{name} внутри МКАД, но резолвится не в Москву" ) def test_inside_mkad_points_answer_through_the_endpoint(trade_in_app: FastAPI) -> None: """Те же точки через ручку: 200, city='Москва', threshold=12.""" db = _db_mock_returning(_row(12, 20.0)) _override(trade_in_app, db) client = TestClient(trade_in_app) for name, (lat, lon) in _INSIDE_MKAD.items(): resp = client.post( "/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, "lat": lat, "lon": lon} ) assert resp.status_code == 200, resp.text data = resp.json() assert data["city"] == "Москва", name assert data["threshold"] == 12, name assert data["status"] == "ok", name def test_every_moscow_grid_point_resolves_to_moscow() -> None: """В каждой точке сетки резолвер отдаёт «Москву» — мёртвых точек нет. Точка, у которой ближайшим оказался подмосковный центроид, была бы мёртвой строкой: её кластер объявлений всё равно ушёл бы в «город не определён». """ from app.api.v1.trade_in import _MOSCOW_GRID_DEG, _resolve_coverage_city assert len(_MOSCOW_GRID_DEG) == 67 for key, (lat, lon) in _MOSCOW_GRID_DEG.items(): assert _resolve_coverage_city(lat, lon) == ("Москва", 12, True), key def test_far_from_moscow_centroids_still_not_covered(trade_in_app: FastAPI) -> None: """Точка посреди поля в 180 км от Москвы → not_covered, city="".""" db = _db_mock_returning(_row(40, 30.0)) _override(trade_in_app, db) client = TestClient(trade_in_app) resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_FAR_FROM_MOSCOW}) data = resp.json() assert data["status"] == "not_covered" assert data["city"] == "" assert data["threshold"] == 0 def test_every_centroid_key_has_a_threshold() -> None: """Центроид без порога — это KeyError и 500 в ручке, а не «город не найден». Инвариант появился вместе с московскими центроидами: ключ центроида и display-имя города теперь разные вещи, и промах в одном из трёх ключей молча превратился бы в падение ручки на реальном московском адресе. """ from app.api.v1.trade_in import ( _CITY_CENTROIDS_DEG, _COVERAGE_CITY_THRESHOLDS, _fold_city, ) for key in _CITY_CENTROIDS_DEG: assert _fold_city(key) in _COVERAGE_CITY_THRESHOLDS, ( f"центроид {key} не имеет порога — резолв по координатам упадёт с KeyError" ) def test_all_moscow_centroids_share_one_display_and_threshold() -> None: """Все 56 ключей сетки → одно имя «Москва» и один порог 12. Проверяем не список координат, а результат резолва в каждой точке сетки: ключ («Москва (Митино)» и т.п.) наружу просочиться не должен. """ from app.api.v1.trade_in import ( _CITY_CENTROIDS_DEG, _COVERAGE_CITY_THRESHOLDS, COVERAGE_MOSCOW_CENTROID_KEYS, COVERAGE_MOSCOW_MIN_N, _fold_city, _resolve_coverage_city, ) assert COVERAGE_MOSCOW_MIN_N == 12 assert len(COVERAGE_MOSCOW_CENTROID_KEYS) == 67 assert len(set(COVERAGE_MOSCOW_CENTROID_KEYS)) == 67, "ключи сетки должны быть уникальны" for key in COVERAGE_MOSCOW_CENTROID_KEYS: assert _COVERAGE_CITY_THRESHOLDS[_fold_city(key)] == ("Москва", 12) lat, lon = _CITY_CENTROIDS_DEG[key] assert _resolve_coverage_city(lat, lon) == ("Москва", 12, True) def test_sverdlovsk_resolution_untouched_by_moscow_centroids( monkeypatch: pytest.MonkeyPatch, ) -> None: """Свердловская область не изменилась ни на байт после добавления Москвы. Сравниваем резолв на полной таблице центроидов с резолвом на той же таблице БЕЗ московских ключей — по всем свердловским центроидам, по нескольким точкам между ними и по заведомо далёкой точке. """ from app.api.v1 import trade_in as trade_in_module full = trade_in_module._CITY_CENTROIDS_DEG moscow_keys = { trade_in_module._fold_city(k) for k in trade_in_module.COVERAGE_MOSCOW_CENTROID_KEYS } without_moscow = { k: v for k, v in full.items() if trade_in_module._fold_city(k) not in moscow_keys } assert len(without_moscow) == len(full) - 67 points = [ *without_moscow.values(), (56.75, 60.70), # южная окраина Екатеринбурга (56.95, 60.65), # между ЕКБ и Верхней Пышмой (57.35, 60.20), # между Тагилом и ЕКБ — вне радиуса обоих (58.0424, 65.2711), # Тавда, далеко от всего ] after = [trade_in_module._resolve_coverage_city(lat, lon) for lat, lon in points] monkeypatch.setattr(trade_in_module, "_CITY_CENTROIDS_DEG", without_moscow) before = [trade_in_module._resolve_coverage_city(lat, lon) for lat, lon in points] assert after == before # ── Подмосковье: отрицательные центроиды забирают область у Москвы ──────────── # Крупные подмосковные города, лежавшие внутри 25 км от московских центроидов # (замер на проде 10.09.2026). До правки каждый из них резолвился в «Москву». _MOSCOW_OBLAST_CITIES = { "Химки": (55.8894, 37.4450), "Мытищи": (55.9116, 37.7308), "Люберцы": (55.6767, 37.8931), "Балашиха": (55.7969, 37.9386), "Красногорск": (55.8317, 37.3300), "Одинцово": (55.6789, 37.2639), "Подольск": (55.4312, 37.5450), "Реутов": (55.7614, 37.8564), "Королёв": (55.9142, 37.8256), "Долгопрудный": (55.9386, 37.5100), "Видное": (55.5519, 37.7133), "Железнодорожный": (55.7444, 38.0128), "Лобня": (56.0100, 37.4750), "Дзержинский": (55.6294, 37.8500), "Котельники": (55.6553, 37.8619), "Истра": (55.9167, 36.8667), } def test_moscow_oblast_cities_resolve_to_no_city() -> None: """Подмосковье → «город не определён»: данных по области мы не собираем.""" from app.api.v1.trade_in import _resolve_coverage_city for name, (lat, lon) in _MOSCOW_OBLAST_CITIES.items(): assert _resolve_coverage_city(lat, lon) == ("", 0, False), ( f"{name} — Московская область, но резолвится в город" ) def test_every_negative_centroid_resolves_to_no_city() -> None: """Каждая точка списка отрицательных центроидов — не город. Проверяем ВЕСЬ список, а не выборку: точка, случайно оказавшаяся ближе к московскому центроиду, чем к себе самой, была бы мёртвой строкой. """ from app.api.v1.trade_in import ( _COVERAGE_NEGATIVE_CENTROIDS_DEG, _resolve_coverage_city, ) assert len(_COVERAGE_NEGATIVE_CENTROIDS_DEG) == 32 for name, (lat, lon) in _COVERAGE_NEGATIVE_CENTROIDS_DEG.items(): assert _resolve_coverage_city(lat, lon) == ("", 0, False), name def test_negative_centroids_have_no_threshold_and_no_key_collision() -> None: """Отрицательные центроиды не должны попасть в таблицу порогов. И не должны пересекаться по ключу с настоящими городами — иначе один словарь молча перекрыл бы другой в конкурсе ближайшего. """ from app.api.v1.trade_in import ( _CITY_CENTROIDS_DEG, _COVERAGE_CITY_THRESHOLDS, _COVERAGE_NEGATIVE_CENTROIDS_DEG, _fold_city, ) negative = {_fold_city(k) for k in _COVERAGE_NEGATIVE_CENTROIDS_DEG} positive = {_fold_city(k) for k in _CITY_CENTROIDS_DEG} assert negative & positive == set() assert negative & set(_COVERAGE_CITY_THRESHOLDS) == set() def test_moscow_itself_survives_negative_centroids(trade_in_app: FastAPI) -> None: """Опорные московские точки по-прежнему дают «Москву» и жёлтый порог.""" from app.api.v1.trade_in import _resolve_coverage_city for point in (_MOSCOW_CENTER, _NEW_MOSCOW, _ZELENOGRAD): assert _resolve_coverage_city(point["lat"], point["lon"]) == ("Москва", 12, True) db = _db_mock_returning(_row(12, 20.0)) _override(trade_in_app, db) client = TestClient(trade_in_app) for point in (_MOSCOW_CENTER, _NEW_MOSCOW, _ZELENOGRAD): resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **point}) assert resp.status_code == 200, resp.text assert resp.json()["city"] == "Москва" def test_moscow_oblast_city_does_not_500_on_the_endpoint(trade_in_app: FastAPI) -> None: """Химки через ручку: 200 и not_covered, а не 500 от KeyError по порогу.""" db = _db_mock_returning(_row(40, 30.0)) _override(trade_in_app, db) client = TestClient(trade_in_app) lat, lon = _MOSCOW_OBLAST_CITIES["Химки"] resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, "lat": lat, "lon": lon}) assert resp.status_code == 200, resp.text data = resp.json() assert data["status"] == "not_covered" assert data["city"] == "" assert data["threshold"] == 0 def test_nearest_centroid_without_threshold_does_not_break_the_endpoint( trade_in_app: FastAPI, monkeypatch: pytest.MonkeyPatch, ) -> None: """Ближайший центроид без порога → «город не определён», а не 500. Раньше порог брался прямым индексированием `_COVERAGE_CITY_THRESHOLDS`, и любой центроид без порога (отрицательный или просто забытый при добавлении города) ронял ручку на живом адресе. """ from app.api.v1 import trade_in as trade_in_module monkeypatch.setattr( trade_in_module, "_CITY_CENTROIDS_DEG", { **trade_in_module._CITY_CENTROIDS_DEG, "Город Без Порога": (_BASE_PAYLOAD["lat"], _BASE_PAYLOAD["lon"]), }, ) assert trade_in_module._resolve_coverage_city(_BASE_PAYLOAD["lat"], _BASE_PAYLOAD["lon"]) == ( "", 0, False, ) db = _db_mock_returning(_row(40, 30.0)) _override(trade_in_app, db) resp = TestClient(trade_in_app).post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD) assert resp.status_code == 200, resp.text assert resp.json()["status"] == "not_covered" assert resp.json()["city"] == "" def test_sverdlovsk_resolution_untouched_by_negative_centroids( monkeypatch: pytest.MonkeyPatch, ) -> None: """Свердловская область не изменилась ни на байт от подмосковных точек. Сравниваем резолв на полной таблице с резолвом БЕЗ отрицательных центроидов — по всем свердловским центроидам, точкам между ними и заведомо далёкой точке. Плюс прямая проверка расстояний: ближайшая подмосковная точка к любому свердловскому центроиду дальше, чем сам центроид (0 км). """ from app.api.v1 import trade_in as trade_in_module sverdlovsk = { k: v for k, v in trade_in_module._CITY_CENTROIDS_DEG.items() if trade_in_module._fold_city(k) not in { trade_in_module._fold_city(m) for m in trade_in_module.COVERAGE_MOSCOW_CENTROID_KEYS } } assert len(sverdlovsk) == 9 points = [ *sverdlovsk.values(), (56.75, 60.70), # южная окраина Екатеринбурга (56.95, 60.65), # между ЕКБ и Верхней Пышмой (57.35, 60.20), # между Тагилом и ЕКБ — вне радиуса обоих (58.0424, 65.2711), # Тавда, далеко от всего ] after = [trade_in_module._resolve_coverage_city(lat, lon) for lat, lon in points] monkeypatch.setattr(trade_in_module, "_COVERAGE_NEGATIVE_CENTROIDS_DEG", {}) before = [trade_in_module._resolve_coverage_city(lat, lon) for lat, lon in points] assert after == before negatives = dict(_MOSCOW_OBLAST_CITIES) for city, (clat, clon) in sverdlovsk.items(): nearest_negative_km = min( trade_in_module._haversine_km(clat, clon, nlat, nlon) for nlat, nlon in negatives.values() ) assert nearest_negative_km > trade_in_module.COVERAGE_CITY_MATCH_RADIUS_KM, ( f"подмосковная точка в {nearest_negative_km:.0f} км от {city}" ) # ── Радиус как свойство центроида: контрольные наборы замера 11.09.2026 ────── # Московский контрольный набор (29 адресов). Не выборка «для красоты»: сюда # входят все четыре точки, которые в замере оказались худшими по запасу до # сетки (Рублёво 4.05 км, Внуково 3.64, Медведково 2.78, Бибирево 2.75), # анклавы (Зеленоград, Восточный, Куркино, Капотня, Новокосино) и по одному # адресу с каждой стороны МКАД. Проверяется ПОВЕДЕНИЕ резолвера на # координатах, а не содержимое констант. _MOSCOW_CONTROLS = { "Тверской": (55.7558, 37.6173), "Рублёво": (55.7530, 37.3680), "Внуково": (55.6270, 37.2860), "Северное Медведково": (55.8880, 37.6480), "Бибирево": (55.8930, 37.5900), "Капотня": (55.6390, 37.7950), "Новокосино": (55.7400, 37.8600), "Куркино": (55.8925, 37.3810), "Восточный": (55.8130, 37.8600), "Ховрино": (55.8730, 37.4870), "Северное Тушино": (55.8560, 37.4390), "Митино": (55.8450, 37.3630), "Зеленоград": (55.9825, 37.1814), "Ватутинки (НАО)": (55.5008, 37.3800), "Некрасовка": (55.7020, 37.9200), "Новогиреево": (55.7520, 37.8140), "Ивановское": (55.7750, 37.8330), "Жулебино": (55.6900, 37.8550), "Марьино": (55.6500, 37.7440), "Братеево": (55.6350, 37.7400), "Орехово-Борисово Южное": (55.6000, 37.7250), "Бирюлёво Восточное": (55.5900, 37.6800), "Северное Бутово": (55.5680, 37.5700), "Ясенево": (55.6060, 37.5330), "Солнцево": (55.6350, 37.3900), "Можайский": (55.7050, 37.4000), "Строгино": (55.8020, 37.4020), "Северный": (55.9250, 37.5450), "Троицк (ТАО)": (55.4853, 37.3050), } # Областной контрольный набор (32 города). 16 из них имеют собственный # отрицательный центроид и выигрывают им; остальные 16 держатся только # радиусом московских точек — включая Наро-Фоминск (9.95 км до ближайшей # точки сетки), Кубинку (17.2) и Чехов (22.3), которые при глобальных 25 км # резолвились как «Москва». Это и есть дефект #1. _MOSCOW_OBLAST_CONTROLS = { **_MOSCOW_OBLAST_CITIES, "Наро-Фоминск": (55.3869, 36.7331), "Кубинка": (55.5794, 36.7000), "Чехов": (55.1477, 37.4770), "Голицыно": (55.6167, 36.9833), "Лыткарино": (55.5833, 37.9000), "Домодедово": (55.4400, 37.7594), "Жуковский": (55.5952, 38.1200), "Щёлково": (55.9200, 38.0000), "Пушкино": (56.0100, 37.8500), "Звенигород": (55.7300, 36.8500), "Апрелевка": (55.5500, 37.0700), "Селятино": (55.5081, 36.9825), "Немчиновка": (55.7050, 37.3450), "Развилка": (55.5842, 37.7392), "Томилино": (55.6528, 37.9472), "Барвиха": (55.7333, 37.2333), } # Точки, добавленные замером 11.09.2026: жилые районы, целиком проигрывавшие # конкурс подмосковной точке. Проверяются по координатам из самого модуля, # ключ здесь — только адрес строки. _ADDED_GRID_KEYS = ( "Москва (Восточный)", "Москва (Левобережный)", "Москва (Орехово-Борисово Южное)", "Москва (Северный)", "Москва (Митино-запад)", "Москва (Ивановское)", "Москва (Жулебино)", "Москва (Можайский-запад)", "Москва (Новокосино)", "Москва (Капотня)", "Москва (Куркино)", "Москва (Некрасовка-восток)", ) # Артефакт геокода, выброшенный из сетки: адрес «ВАО, р-н Восточный, 4», # посаженный геокодером на 22 км мимо настоящего Восточного и раздававший # имя «Москва» Пушкино в 3.06 км. _PHANTOM_EXCLAVE = (56.0087, 37.7960) def _legacy_resolve(lat: float, lon: float, radius_km: float = 25.0) -> tuple[str, int, bool]: """Прежний резолвер: тот же конкурс ближайшего, но ОДИН глобальный радиус. Нужен, чтобы тесты сравнивали ПОВЕДЕНИЕ, а не пересказывали константы: им проверяется и что дефект #1 действительно существовал, и что Свердловская область от перехода на радиус-свойство не сдвинулась. """ from app.api.v1 import trade_in as trade_in_module nearest, nearest_km = None, float("inf") for city, (clat, clon) in ( *trade_in_module._CITY_CENTROIDS_DEG.items(), *trade_in_module._COVERAGE_NEGATIVE_CENTROIDS_DEG.items(), ): distance_km = trade_in_module._haversine_km(lat, lon, clat, clon) if distance_km < nearest_km: nearest_km, nearest = distance_km, city if nearest is None or nearest_km > radius_km: return "", 0, False entry = trade_in_module._COVERAGE_CITY_THRESHOLDS.get(trade_in_module._fold_city(nearest)) return ("", 0, False) if entry is None else (entry[0], entry[1], True) def test_whole_moscow_control_set_resolves_to_moscow() -> None: """Все 29 московских контролей → «Москва», жёлтый порог 12.""" from app.api.v1.trade_in import _resolve_coverage_city assert len(_MOSCOW_CONTROLS) == 29 failed = { name: _resolve_coverage_city(lat, lon) for name, (lat, lon) in _MOSCOW_CONTROLS.items() if _resolve_coverage_city(lat, lon) != ("Москва", 12, True) } assert failed == {} def test_whole_moscow_control_set_answers_through_the_endpoint(trade_in_app: FastAPI) -> None: """Те же 29 адресов через ручку: 200, city='Москва', threshold=12.""" db = _db_mock_returning(_row(12, 20.0)) _override(trade_in_app, db) client = TestClient(trade_in_app) for name, (lat, lon) in _MOSCOW_CONTROLS.items(): resp = client.post( "/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, "lat": lat, "lon": lon} ) assert resp.status_code == 200, resp.text assert resp.json()["city"] == "Москва", name assert resp.json()["threshold"] == 12, name def test_whole_moscow_oblast_control_set_resolves_to_no_city(trade_in_app: FastAPI) -> None: """Все 32 областных контроля → «город не определён», и ручка отдаёт 200.""" from app.api.v1.trade_in import _resolve_coverage_city assert len(_MOSCOW_OBLAST_CONTROLS) == 32 failed = { name: _resolve_coverage_city(lat, lon) for name, (lat, lon) in _MOSCOW_OBLAST_CONTROLS.items() if _resolve_coverage_city(lat, lon) != ("", 0, False) } assert failed == {} db = _db_mock_returning(_row(40, 30.0)) _override(trade_in_app, db) client = TestClient(trade_in_app) for name, (lat, lon) in _MOSCOW_OBLAST_CONTROLS.items(): resp = client.post( "/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, "lat": lat, "lon": lon} ) assert resp.status_code == 200, resp.text assert resp.json()["status"] == "not_covered", name assert resp.json()["city"] == "", name def test_deep_oblast_towns_were_moscow_under_one_global_radius() -> None: """Дефект #1 предметно: Наро-Фоминск, Кубинка, Чехов. С одним глобальным радиусом 25 км круги плотной сетки складывались, и все трое получали «Москву», хотя до ближайшей точки сетки им 9.95, 17.2 и 22.3 км. С радиусом-свойством (8 км у московских точек) — «не определён». """ from app.api.v1.trade_in import _resolve_coverage_city for name in ("Наро-Фоминск", "Кубинка", "Чехов"): lat, lon = _MOSCOW_OBLAST_CONTROLS[name] assert _legacy_resolve(lat, lon) == ("Москва", 12, True), ( f"{name}: тест перестал воспроизводить дефект, сравнивать не с чем" ) assert _resolve_coverage_city(lat, lon) == ("", 0, False), name def test_radius_is_a_property_of_the_centroid_not_a_global_constant() -> None: """Одно и то же расстояние даёт РАЗНЫЙ ответ в Москве и в Свердловской. Наро-Фоминск в 9.95 км от ближайшей точки московской сетки — «не определён». Точка в 20 км южнее центра Екатеринбурга (то есть ДАЛЬШЕ) — «Екатеринбург». Глобальной константой такое поведение невыразимо. """ from app.api.v1 import trade_in as trade_in_module lat, lon = _MOSCOW_OBLAST_CONTROLS["Наро-Фоминск"] km_to_grid = min( trade_in_module._haversine_km(lat, lon, glat, glon) for glat, glon in trade_in_module._MOSCOW_GRID_DEG.values() ) assert 8.0 < km_to_grid < 25.0, f"{km_to_grid:.2f} км — контроль потерял смысл" assert trade_in_module._resolve_coverage_city(lat, lon) == ("", 0, False) ekb_lat, ekb_lon = trade_in_module._CITY_CENTROIDS_DEG["Екатеринбург"] south_lat = ekb_lat - 20.0 / 111.19 km_to_ekb = trade_in_module._haversine_km(south_lat, ekb_lon, ekb_lat, ekb_lon) assert km_to_ekb > km_to_grid, "свердловская проверка должна быть ДАЛЬШЕ московской" assert trade_in_module._resolve_coverage_city(south_lat, ekb_lon) == ("Екатеринбург", 8, True) def test_sverdlovsk_resolution_identical_to_one_global_radius() -> None: """Свердловская область байт-в-байт: 25 км ей никто не менял. Сравнение поведения, а не констант: густая решётка по всей области плюс кольца по 24.5 и 25.5 км вокруг каждого свердловского центра (там, где граница радиуса и проходит) — ответ нового резолвера обязан совпасть с ответом прежнего, у которого радиус был один глобальный. """ import math from app.api.v1 import trade_in as trade_in_module points: list[tuple[float, float]] = [] lat = 55.0 while lat <= 60.5: lon = 57.0 while lon <= 66.0: points.append((round(lat, 4), round(lon, 4))) lon += 0.25 lat += 0.25 moscow_keys = { trade_in_module._fold_city(k) for k in trade_in_module.COVERAGE_MOSCOW_CENTROID_KEYS } sverdlovsk = { k: v for k, v in trade_in_module._CITY_CENTROIDS_DEG.items() if trade_in_module._fold_city(k) not in moscow_keys } assert len(sverdlovsk) == 9 for clat, clon in sverdlovsk.values(): for km in (24.5, 25.5): dlat = km / 111.19 dlon = km / (111.19 * math.cos(math.radians(clat))) points += [ (clat + dlat, clon), (clat - dlat, clon), (clat, clon + dlon), (clat, clon - dlon), ] mismatched = [ (lat, lon) for lat, lon in points if trade_in_module._resolve_coverage_city(lat, lon) != _legacy_resolve(lat, lon) ] assert mismatched == [], f"{len(mismatched)} точек Свердловской изменили резолв" assert len(points) > 800 def test_phantom_exclave_no_longer_gives_moscow_to_pushkino( monkeypatch: pytest.MonkeyPatch, ) -> None: """Точка-призрак выброшена, и без неё Пушкино перестало быть «Москвой». Возвращаем призрак в таблицу — Пушкино снова резолвится в «Москву»: это доказывает, что дефект чинит именно выброс точки, а не что-нибудь ещё. """ from app.api.v1 import trade_in as trade_in_module pushkino = _MOSCOW_OBLAST_CONTROLS["Пушкино"] assert trade_in_module._resolve_coverage_city(*_PHANTOM_EXCLAVE) != ("Москва", 12, True) assert trade_in_module._resolve_coverage_city(*pushkino) == ("", 0, False) ghost_key = "Москва (Восточный, эксклав)" monkeypatch.setattr( trade_in_module, "_CITY_CENTROIDS_DEG", {**trade_in_module._CITY_CENTROIDS_DEG, ghost_key: _PHANTOM_EXCLAVE}, ) # Возвращаем призрак целиком, как он жил: координаты, порог «Москвы» # и московский радиус — иначе проверялась бы не сетка, а таблица порогов. monkeypatch.setattr( trade_in_module, "_COVERAGE_CITY_THRESHOLDS", { **trade_in_module._COVERAGE_CITY_THRESHOLDS, trade_in_module._fold_city(ghost_key): ( trade_in_module.COVERAGE_MOSCOW_DISPLAY, trade_in_module.COVERAGE_MOSCOW_MIN_N, ), }, ) monkeypatch.setattr( trade_in_module, "_CENTROID_RADIUS_KM", { **trade_in_module._CENTROID_RADIUS_KM, trade_in_module._fold_city(ghost_key): trade_in_module.COVERAGE_MOSCOW_MATCH_RADIUS_KM, }, ) assert trade_in_module._resolve_coverage_city(*pushkino) == ("Москва", 12, True) def test_added_grid_points_return_districts_lost_to_negative_centroids( monkeypatch: pytest.MonkeyPatch, ) -> None: """Каждая добавленная точка что-то возвращает: без неё район теряется. Для всех 12 точек: в самой точке резолвер отдаёт «Москву», а если убрать её из таблицы — «город не определён» (район уходит подмосковному центру). Мёртвых строк в добавке нет. """ from app.api.v1 import trade_in as trade_in_module full = trade_in_module._CITY_CENTROIDS_DEG for key in _ADDED_GRID_KEYS: lat, lon = full[key] assert trade_in_module._resolve_coverage_city(lat, lon) == ("Москва", 12, True), key monkeypatch.setattr( trade_in_module, "_CITY_CENTROIDS_DEG", {k: v for k, v in full.items() if k != key}, ) assert trade_in_module._resolve_coverage_city(lat, lon) == ("", 0, False), ( f"{key} ничего не возвращает — строка мёртвая" ) monkeypatch.setattr(trade_in_module, "_CITY_CENTROIDS_DEG", full) def test_negative_centroid_radius_is_unbounded_and_endpoint_survives_it( trade_in_app: FastAPI, ) -> None: """У отрицательной точки радиус бесконечный, и ручка на этом не падает. Отрицательный центроид действует ровно там, где он ближайший, и на любом расстоянии означает «город не определён»: точка в глухом углу области, где ближайший центр — подмосковный и дальше 25 км, отдаёт not_covered и 200, а не 500 по отсутствующему порогу. """ from app.api.v1 import trade_in as trade_in_module far_lat, far_lon = 55.30, 38.75 nearest_km, nearest_name = min( (trade_in_module._haversine_km(far_lat, far_lon, clat, clon), name) for name, (clat, clon) in ( *trade_in_module._CITY_CENTROIDS_DEG.items(), *trade_in_module._COVERAGE_NEGATIVE_CENTROIDS_DEG.items(), ) ) assert nearest_name in trade_in_module._COVERAGE_NEGATIVE_CENTROIDS_DEG assert nearest_km > trade_in_module.COVERAGE_CITY_MATCH_RADIUS_KM assert trade_in_module._centroid_radius_km(nearest_name) == float("inf") assert trade_in_module._resolve_coverage_city(far_lat, far_lon) == ("", 0, False) db = _db_mock_returning(_row(40, 30.0)) _override(trade_in_app, db) resp = TestClient(trade_in_app).post( "/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, "lat": far_lat, "lon": far_lon} ) assert resp.status_code == 200, resp.text assert resp.json()["status"] == "not_covered" assert resp.json()["city"] == "" 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»)" )