"""Тесты для POST /api/v1/parcels/{cad_num}/competitors (Issue #112). Mock-based — не требуют живой БД. Паттерн mock DB: аналогично test_admin_cadastre.py — dependency_overrides[get_db]. """ from __future__ import annotations from unittest.mock import MagicMock import pytest from fastapi.testclient import TestClient from app.main import app # ── Фабрики mock-строк ──────────────────────────────────────────────────────── def _coord_row(lat: float = 56.838, lon: float = 60.605) -> MagicMock: """Строка центроида участка.""" r = MagicMock() r.__getitem__ = lambda self, k: {"lat": lat, "lon": lon}[k] return r def _obj_row( obj_id: int = 1, distance_m: float = 400.0, site_status: str = "Строящиеся", obj_class: str | None = "comfort", velocity: float = 5.0, flat_count: int | None = 200, ) -> MagicMock: r = MagicMock() r.__getitem__ = lambda self, k: { "obj_id": obj_id, "comm_name": f"ЖК-{obj_id}", "dev_name": "TestDev", "obj_class": obj_class, "latitude": 56.838 + distance_m / 1_000_000, "longitude": 60.605 + distance_m / 1_000_000, "flat_count": flat_count, "site_status": site_status, "distance_m": distance_m, "velocity_per_month": velocity, }[k] return r def _price_row(obj_id: int, price: float) -> MagicMock: r = MagicMock() r.__getitem__ = lambda self, k: {"obj_id": obj_id, "avg_price_per_m2": price}[k] return r def _sold_row(obj_id: int, flats_sold: int) -> MagicMock: """Строка sold-count (_SOLD_COUNT_SQL, #1926). flats_sold теперь COUNT проданных лотов из objective_lots.contract_date (выровнено с MarketMetrics), а не COUNT по domrf_kn_flats.status — последний заполнен ~0.2% и давал массовый недосчёт. """ r = MagicMock() r.__getitem__ = lambda self, k: {"obj_id": obj_id, "flats_sold": flats_sold}[k] return r def _obj_price_row(obj_id: int, price: float) -> MagicMock: """Строка objective-fallback цены (_OBJECTIVE_PRICE_FALLBACK_SQL, OBJ-3 #307).""" r = MagicMock() r.__getitem__ = lambda self, k: {"obj_id": obj_id, "median_price_per_m2": price}[k] return r # ── Построение mock DB ──────────────────────────────────────────────────────── def _make_db( coord: MagicMock | None = None, obj_rows: list[MagicMock] | None = None, price_rows: list[MagicMock] | None = None, sold_rows: list[MagicMock] | None = None, obj_price_rows: list[MagicMock] | None = None, ) -> MagicMock: """Сконструировать mock Session. Порядок вызовов execute (#1926: добавлен отдельный sold-count запрос): 1. centroid query → coord 2. competitors query → obj_rows 3. domrf avg_price query → price_rows 4. sold-count query (_SOLD_COUNT_SQL, objective_lots.contract_date) → sold_rows (по умолчанию [] = sold неизвестен → flats_sold None, нейтральный stage) 5. objective price fallback (OBJ-3 #307) → obj_price_rows (вызывается ТОЛЬКО если у части obj_id нет domrf-цены — почти всегда, т.к. domrf на последнем snapshot пуст; по умолчанию [] = fallback пуст) """ db = MagicMock() results: list[MagicMock] = [] for rows, is_first in [ (coord, True), (obj_rows or [], False), (price_rows or [], False), (sold_rows or [], False), (obj_price_rows or [], False), ]: result = MagicMock() if is_first: # centroid → .mappings().first() result.mappings.return_value.first.return_value = rows else: result.mappings.return_value.all.return_value = rows results.append(result) db.execute.side_effect = results return db def _override_db(db: MagicMock): def _get_db_override(): yield db return _get_db_override # ── Тесты ───────────────────────────────────────────────────────────────────── def test_competitors_basic() -> None: """3 конкурента → корректная форма ответа, сортировка по distance.""" rows = [ _obj_row(obj_id=1, distance_m=200.0, velocity=4.0), _obj_row(obj_id=2, distance_m=500.0, velocity=6.0), _obj_row(obj_id=3, distance_m=900.0, velocity=2.0), ] db = _make_db(coord=_coord_row(), obj_rows=rows) from app.core.db import get_db app.dependency_overrides[get_db] = _override_db(db) try: client = TestClient(app) resp = client.post( "/api/v1/parcels/66:41:0303161:123/competitors", json={"radius_km": 1.0, "time_window": "last_quarter"}, ) assert resp.status_code == 200, resp.text body = resp.json() assert "competitors" in body assert "summary" in body assert len(body["competitors"]) == 3 # первый должен быть ближайшим assert body["competitors"][0]["obj_id"] == 1 assert body["competitors"][0]["distance_m"] == pytest.approx(200.0) # все поля присутствуют first = body["competitors"][0] for key in ( "obj_id", "comm_name", "dev_name", "obj_class", "distance_m", "lat", "lng", "stage", "flats_total", "flats_sold", "sold_pct", "velocity_per_month", "avg_price_per_m2", "is_active", ): assert key in first, f"missing key: {key}" finally: app.dependency_overrides.clear() def test_competitors_summary_calc() -> None: """summary: total_competitors, active_count, weighted_avg_velocity корректны. site_status — реальные русские значения как у домрф (#1213 fix). «Строящиеся» считаются активными, «Сданные» — нет. """ rows = [ _obj_row(obj_id=1, site_status="Строящиеся", velocity=10.0), _obj_row(obj_id=2, site_status="Строящиеся", velocity=6.0), _obj_row(obj_id=3, site_status="Сданные", velocity=2.0), ] db = _make_db(coord=_coord_row(), obj_rows=rows) from app.core.db import get_db app.dependency_overrides[get_db] = _override_db(db) try: client = TestClient(app) resp = client.post( "/api/v1/parcels/66:41:0303161:5/competitors", json={"radius_km": 1.0, "time_window": "last_quarter"}, ) assert resp.status_code == 200, resp.text summary = resp.json()["summary"] assert summary["total_competitors"] == 3 assert summary["active_count"] == 2 # sales + construction # equal flats_total (200 each) → size-weighted AVG == naive: (10+6+2)/3 = 6.0 assert summary["weighted_avg_velocity"] == pytest.approx(6.0) assert summary["radius_km"] == pytest.approx(1.0) assert summary["time_window"] == "last_quarter" finally: app.dependency_overrides.clear() def test_competitors_weighted_velocity_by_size() -> None: """weighted_avg_velocity взвешен по flats_total — крупный ЖК весит больше (#949).""" rows = [ _obj_row(obj_id=1, velocity=40.0, flat_count=500), _obj_row(obj_id=2, velocity=2.0, flat_count=20), ] db = _make_db(coord=_coord_row(), obj_rows=rows) from app.core.db import get_db app.dependency_overrides[get_db] = _override_db(db) try: client = TestClient(app) resp = client.post( "/api/v1/parcels/66:41:0303161:5/competitors", json={"radius_km": 1.0, "time_window": "last_quarter"}, ) assert resp.status_code == 200, resp.text wav = resp.json()["summary"]["weighted_avg_velocity"] # size-weighted = (40*500 + 2*20) / (500+20) = 20040/520 ≈ 38.54, # НЕ наивное среднее (40+2)/2 = 21.0 — доказывает взвешивание по flats_total. assert wav == pytest.approx(38.54, abs=0.05) assert wav != pytest.approx(21.0, abs=0.1) finally: app.dependency_overrides.clear() def test_competitors_exclude_obj_ids() -> None: """exclude_obj_ids исключает указанные ЖК из результата.""" rows = [ _obj_row(obj_id=1, distance_m=100.0), _obj_row(obj_id=2, distance_m=200.0), _obj_row(obj_id=3, distance_m=300.0), ] db = _make_db(coord=_coord_row(), obj_rows=rows) from app.core.db import get_db app.dependency_overrides[get_db] = _override_db(db) try: client = TestClient(app) resp = client.post( "/api/v1/parcels/66:41:0303161:5/competitors", json={"radius_km": 1.0, "time_window": "last_quarter", "exclude_obj_ids": [2]}, ) assert resp.status_code == 200, resp.text ids = [c["obj_id"] for c in resp.json()["competitors"]] assert 2 not in ids assert 1 in ids assert 3 in ids finally: app.dependency_overrides.clear() def test_competitors_obj_class_filter() -> None: """obj_class_filter=economy — SQL получает параметр; Python-сторона не ломается.""" rows = [_obj_row(obj_id=10, obj_class="economy")] db = _make_db(coord=_coord_row(), obj_rows=rows) from app.core.db import get_db app.dependency_overrides[get_db] = _override_db(db) try: client = TestClient(app) resp = client.post( "/api/v1/parcels/66:41:0303161:5/competitors", json={"radius_km": 1.0, "time_window": "last_quarter", "obj_class_filter": "economy"}, ) assert resp.status_code == 200, resp.text comps = resp.json()["competitors"] assert len(comps) == 1 assert comps[0]["obj_class"] == "economy" finally: app.dependency_overrides.clear() def test_competitors_time_window_velocity() -> None: """time_window влияет на velocity_per_month (last_month vs last_year).""" # Здесь проверяем, что endpoint принимает оба варианта без ошибок # и возвращает velocity из mock-строки (DB-расчёт мокирован). rows_month = [_obj_row(obj_id=1, velocity=12.0)] rows_year = [_obj_row(obj_id=1, velocity=3.0)] from app.core.db import get_db # last_month db_m = _make_db(coord=_coord_row(), obj_rows=rows_month) app.dependency_overrides[get_db] = _override_db(db_m) try: client = TestClient(app) resp_m = client.post( "/api/v1/parcels/66:41:0303161:5/competitors", json={"radius_km": 1.0, "time_window": "last_month"}, ) assert resp_m.status_code == 200, resp_m.text finally: app.dependency_overrides.clear() # last_year db_y = _make_db(coord=_coord_row(), obj_rows=rows_year) app.dependency_overrides[get_db] = _override_db(db_y) try: client = TestClient(app) resp_y = client.post( "/api/v1/parcels/66:41:0303161:5/competitors", json={"radius_km": 1.0, "time_window": "last_year"}, ) assert resp_y.status_code == 200, resp_y.text v_month = resp_m.json()["competitors"][0]["velocity_per_month"] v_year = resp_y.json()["competitors"][0]["velocity_per_month"] # month velocity выше чем year в нашем моке assert v_month > v_year finally: app.dependency_overrides.clear() def test_competitors_parcel_not_found_404() -> None: """Если центроид участка не найден → 404.""" db = _make_db(coord=None) # first() вернёт None from app.core.db import get_db app.dependency_overrides[get_db] = _override_db(db) try: client = TestClient(app) resp = client.post( "/api/v1/parcels/99:99:9999999:999/competitors", json={"radius_km": 1.0, "time_window": "last_quarter"}, ) assert resp.status_code == 404, resp.text finally: app.dependency_overrides.clear() def test_competitors_empty_radius() -> None: """Нет конкурентов в радиусе → пустой список + summary с нулями.""" db = _make_db(coord=_coord_row(), obj_rows=[]) from app.core.db import get_db app.dependency_overrides[get_db] = _override_db(db) try: client = TestClient(app) resp = client.post( "/api/v1/parcels/66:41:0303161:5/competitors", json={"radius_km": 0.1, "time_window": "last_quarter"}, ) assert resp.status_code == 200, resp.text body = resp.json() assert body["competitors"] == [] assert body["summary"]["total_competitors"] == 0 assert body["summary"]["active_count"] == 0 assert body["summary"]["weighted_avg_velocity"] == pytest.approx(0.0) finally: app.dependency_overrides.clear() def test_competitors_sold_pct_null() -> None: """sold_pct и flats_sold — None, когда sold-count запрос пуст (graceful). #1926: flats_sold берётся из objective_lots.contract_date (_SOLD_COUNT_SQL). Если у obj_id нет маппинга в objective_complex_mapping (coverage ~2.5%) → sold-count строки нет → flats_sold/sold_pct None, stage → нейтраль 0.5. """ rows = [_obj_row(obj_id=1, flat_count=200)] db = _make_db(coord=_coord_row(), obj_rows=rows) # sold_rows default [] → None from app.core.db import get_db app.dependency_overrides[get_db] = _override_db(db) try: client = TestClient(app) resp = client.post( "/api/v1/parcels/66:41:0303161:5/competitors", json={"radius_km": 1.0, "time_window": "last_quarter"}, ) assert resp.status_code == 200, resp.text comp = resp.json()["competitors"][0] assert comp["flats_sold"] is None assert comp["sold_pct"] is None assert comp["flats_total"] == 200 finally: app.dependency_overrides.clear() def test_competitors_flats_sold_from_objective_lots() -> None: """flats_sold/sold_pct заполняются из objective_lots (#1926). Регрессия аудита #1871/#1926: РАНЬШЕ flats_sold считался из domrf_kn_flats.status (LIKE '%прод%'), заполненного ~0.2% → массовый недосчёт и расхождение с MarketMetrics. Теперь источник И предикат — как в market_metrics._STOCK_SQL (is_sold OR contract_date OR status='продан', premise_kind='квартира'). Здесь sold-count запрос возвращает 120 проданных из 200 → sold_pct 60.0%. (SQL-структуру предиката/fan-out/premise_kind проверяют test_sold_count_sql_* ниже — мок тут SQL не исполняет.) """ rows = [_obj_row(obj_id=1, flat_count=200)] sold_rows = [_sold_row(obj_id=1, flats_sold=120)] db = _make_db(coord=_coord_row(), obj_rows=rows, sold_rows=sold_rows) from app.core.db import get_db app.dependency_overrides[get_db] = _override_db(db) try: client = TestClient(app) resp = client.post( "/api/v1/parcels/66:41:0303161:5/competitors", json={"radius_km": 1.0, "time_window": "last_quarter"}, ) assert resp.status_code == 200, resp.text comp = resp.json()["competitors"][0] assert comp["flats_sold"] == 120 assert comp["sold_pct"] == pytest.approx(60.0) assert comp["flats_total"] == 200 finally: app.dependency_overrides.clear() def test_competitors_avg_price_populated() -> None: """avg_price_per_m2 не None если domrf_kn_flats возвращает строки с ценой. Регрессионный тест для Issue #227: фильтр status='sold' давал 0 строк (поле status в domrf_kn_flats 99.8% NULL). После фикса — убран, AVG считается по всем квартирам с known price_per_m2. """ rows = [_obj_row(obj_id=1)] price_rows = [_price_row(obj_id=1, price=150_000.0)] db = _make_db(coord=_coord_row(), obj_rows=rows, price_rows=price_rows) from app.core.db import get_db app.dependency_overrides[get_db] = _override_db(db) try: client = TestClient(app) resp = client.post( "/api/v1/parcels/66:41:0303161:5/competitors", json={"radius_km": 1.0, "time_window": "last_quarter"}, ) assert resp.status_code == 200, resp.text comp = resp.json()["competitors"][0] assert comp["avg_price_per_m2"] == pytest.approx( 150_000.0 ), "avg_price_per_m2 должен быть не None — регрессия #227 status='sold' filter" # OBJ-3 #307: domrf-hit → price_source='domrf'. assert comp["price_source"] == "domrf" finally: app.dependency_overrides.clear() # ── OBJ-3 (#307) additive fallback из objective_lots ──────────────────────────── def test_competitors_objective_price_fallback_fills_gap() -> None: """domrf-цены НЕТ → objective_lots заполняет avg_price_per_m2 (OBJ-3 #307). Прод 2026-06: domrf_kn_flats.price_per_m2 на последнем snapshot = 0 строк, поэтому domrf даёт None почти всем. objective_lots (через objective_complex_mapping) покрывает ~80% — этот fallback восстанавливает цену. """ rows = [_obj_row(obj_id=1)] # domrf price пуст → fallback на objective median. obj_price_rows = [_obj_price_row(obj_id=1, price=136_000.0)] db = _make_db(coord=_coord_row(), obj_rows=rows, price_rows=[], obj_price_rows=obj_price_rows) from app.core.db import get_db app.dependency_overrides[get_db] = _override_db(db) try: client = TestClient(app) resp = client.post( "/api/v1/parcels/66:41:0303161:5/competitors", json={"radius_km": 1.0, "time_window": "last_quarter"}, ) assert resp.status_code == 200, resp.text comp = resp.json()["competitors"][0] assert comp["avg_price_per_m2"] == pytest.approx(136_000.0) assert comp["price_source"] == "objective" finally: app.dependency_overrides.clear() def test_competitors_domrf_price_wins_over_objective() -> None: """domrf-цена приоритетна: objective НЕ перетирает существующий domrf-вывод. Additive-контракт OBJ-3: где domrf есть → не трогаем; objective только пробелы. """ rows = [_obj_row(obj_id=1)] price_rows = [_price_row(obj_id=1, price=150_000.0)] # objective тоже вернул бы цену, но domrf уже есть → fallback её игнорирует. obj_price_rows = [_obj_price_row(obj_id=1, price=99_000.0)] db = _make_db( coord=_coord_row(), obj_rows=rows, price_rows=price_rows, obj_price_rows=obj_price_rows, ) from app.core.db import get_db app.dependency_overrides[get_db] = _override_db(db) try: client = TestClient(app) resp = client.post( "/api/v1/parcels/66:41:0303161:5/competitors", json={"radius_km": 1.0, "time_window": "last_quarter"}, ) assert resp.status_code == 200, resp.text comp = resp.json()["competitors"][0] assert comp["avg_price_per_m2"] == pytest.approx(150_000.0) assert comp["price_source"] == "domrf" finally: app.dependency_overrides.clear() def test_competitors_no_price_anywhere_is_none() -> None: """Ни domrf, ни objective → avg_price_per_m2 None, price_source None (graceful).""" rows = [_obj_row(obj_id=1)] db = _make_db(coord=_coord_row(), obj_rows=rows, price_rows=[], obj_price_rows=[]) from app.core.db import get_db app.dependency_overrides[get_db] = _override_db(db) try: client = TestClient(app) resp = client.post( "/api/v1/parcels/66:41:0303161:5/competitors", json={"radius_km": 1.0, "time_window": "last_quarter"}, ) assert resp.status_code == 200, resp.text comp = resp.json()["competitors"][0] assert comp["avg_price_per_m2"] is None assert comp["price_source"] is None finally: app.dependency_overrides.clear() def test_competitors_is_active_flag() -> None: """is_active=True для русского 'Строящиеся' (реальная prod-форма #1213), False для 'Сданные'/null/прочих.""" rows = [ _obj_row(obj_id=1, site_status="Строящиеся"), _obj_row(obj_id=2, site_status="Строящиеся"), _obj_row(obj_id=3, site_status="Сданные"), ] db = _make_db(coord=_coord_row(), obj_rows=rows) from app.core.db import get_db app.dependency_overrides[get_db] = _override_db(db) try: client = TestClient(app) resp = client.post( "/api/v1/parcels/66:41:0303161:5/competitors", json={"radius_km": 1.0, "time_window": "last_quarter"}, ) assert resp.status_code == 200, resp.text comps = {c["obj_id"]: c for c in resp.json()["competitors"]} assert comps[1]["is_active"] is True assert comps[2]["is_active"] is True assert comps[3]["is_active"] is False finally: app.dependency_overrides.clear() # ── _SOLD_COUNT_SQL структура (#1926 / code-review #1927) ─────────────────────── # Mock-тесты выше НЕ исполняют реальный SQL (db.execute замокан) → не ловят # fan-out маппинга, отсутствие premise_kind-фильтра и рассинхрон sold-предиката # с MarketMetrics. Эти проверки — статический guard на текст _SOLD_COUNT_SQL: # регресс к COUNT(*) / потеря premise_kind / усечение предиката упадёт здесь. # (Полноценный fan-out-тест с двумя mapping-строками на один obj_id требует # живой БД — он в tests/integration/test_phantom_columns.py, skip без TEST_DB.) def _sold_sql_text() -> str: from app.services.site_finder.competitors import _SOLD_COUNT_SQL return str(_SOLD_COUNT_SQL) def test_sold_count_sql_is_fanout_safe() -> None: """COUNT(DISTINCT objective_lot_id) — маппинг 1:N по obj_id не задваивает. objective_complex_mapping UNIQUE = (objective_complex_name, objective_group), НЕ domrf_obj_id → один obj_id может иметь несколько mapping-строк (то же имя, разный objective_group) → JOIN по project_name задвоил бы лоты. DISTINCT по objective_lot_id дедуплицирует на уровне лота. Регресс к COUNT(*) → fail. #1964: даже после перехода на v_objective_lots_latest (где objective_lot_id уникален → COUNT(DISTINCT)==COUNT(*) по физлотам) DISTINCT СОХРАНЯЕТСЯ: будущий fan-out маппинга задвоил бы строки view, и COUNT(*) перестал бы быть fan-out-safe. """ sql = _sold_sql_text() assert ( "COUNT(DISTINCT ol.objective_lot_id)" in sql ), "fan-out guard: маппинг не unique по domrf_obj_id — нужен COUNT(DISTINCT lot)" assert "COUNT(*)" not in sql, "COUNT(*) задвоит лоты при 1:N маппинге" def test_sold_count_sql_reads_physflat_view() -> None: """#1964: sold-count читает physflat-дедуп view, НЕ сырой objective_lots. Сырой objective_lots раздут ~2.91× (мульти objective_lot_id на один физлот через пере-листинги) → flats_sold завышался. Репойнт на v_objective_lots_latest даёт по строке на физический лот. Регресс к `FROM objective_lots` (без _latest) → fail. """ sql = _sold_sql_text() assert "v_objective_lots_latest" in sql, "должен читать physflat-дедуп view (#1964)" assert "JOIN objective_lots " not in sql, "не должен джойнить сырой objective_lots" def test_sold_count_sql_filters_premise_kind() -> None: """premise_kind-фильтр (как в MarketMetrics._STOCK_SQL) — без него проданные паркинги/кладовые/нежилое считались бы проданными квартирами (over-count).""" sql = _sold_sql_text() assert "ol.premise_kind = :premise_kind" in sql def test_sold_count_sql_predicate_matches_market_metrics() -> None: """sold-предикат побайтово совпадает с MarketMetrics: is_sold OR contract_date OR status='продан' (Объектив заполняет поля неконсистентно → берём их ИЛИ).""" sql = _sold_sql_text() assert "ol.is_sold IS TRUE" in sql assert "ol.contract_date IS NOT NULL" in sql assert "LOWER(COALESCE(ol.status, '')) = 'продан'" in sql