"""Тесты для 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 # ── Построение mock DB ──────────────────────────────────────────────────────── def _make_db( coord: MagicMock | None = None, obj_rows: list[MagicMock] | None = None, price_rows: list[MagicMock] | None = None, ) -> MagicMock: """Сконструировать mock Session. Порядок вызовов execute: 1. centroid query → coord 2. competitors query → obj_rows 3. avg_price query → price_rows """ db = MagicMock() results: list[MagicMock] = [] for rows, is_first in [ (coord, True), (obj_rows or [], False), (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 (MVP: данные недоступны из domrf_kn_objects). Полный расчёт продаж требует JOIN с domrf_kn_flats COUNT WHERE status='sold' — отложен за пределы текущего PR. """ rows = [_obj_row(obj_id=1, flat_count=200)] 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 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_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" ) 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()