"""Тесты для POST /api/v1/parcels/{cad_num}/best-layouts (Issue #113 Phase 2.1). Mock-based — не требуют живой БД. Паттерн mock DB: аналогично test_parcel_competitors.py — dependency_overrides[get_db]. Порядок вызовов в get_best_layouts (Fix SF-01 — inline velocity): db.scalar() → MAX(snapshot_date) (только когда vel_rows non-empty) db.execute() calls: 1. _PARCEL_CENTROID_SQL → .mappings().first() 2. _COMPETITORS_IN_RADIUS_SQL → .mappings().all() 3. _INLINE_VELOCITY_SQL → .mappings().all() 4. _SUPPLY_BATCH_SQL → .mappings().all() (пропускается если latest_snap is None) """ from __future__ import annotations import datetime as dt from unittest.mock import MagicMock import pytest from fastapi.testclient import TestClient from app.main import app # ── Фабрики mock-строк ──────────────────────────────────────────────────────── CAD_NUM = "66:41:0303161:123" _TODAY = dt.date.today() def _coord_row(lon: float = 60.6, lat: float = 56.85) -> MagicMock: """Центроид участка (EPSG:4326 lon/lat).""" r = MagicMock() r.__getitem__ = lambda self, k: {"center_lon": lon, "center_lat": lat}[k] return r def _obj_id_row(obj_id: int) -> MagicMock: """Строка obj_id из _COMPETITORS_IN_RADIUS_SQL.""" r = MagicMock() r.__getitem__ = lambda self, k: {"obj_id": obj_id}[k] return r def _vel_row( room_bucket: str = "2", deals_window: float = 48.0, avg_area: float = 55.0, avg_price_rub: float | None = 120000.0, obj_ids: list[int] | None = None, window_start: dt.date | None = None, window_end: dt.date | None = None, ) -> MagicMock: """Строка из _INLINE_VELOCITY_SQL (Fix SF-01: deals_window за честный интервал).""" oids = obj_ids if obj_ids is not None else [1] ws = window_start or _TODAY - dt.timedelta(days=90) we = window_end or _TODAY r = MagicMock() r.__getitem__ = lambda self, k: { "room_bucket": room_bucket, "deals_window": deals_window, "avg_area_m2": avg_area, "avg_price_per_m2_rub": avg_price_rub, "competitor_obj_ids": oids, "competitor_count": len(oids), "window_start": ws, "window_end": we, }[k] return r def _supply_row(rb: str, ab: str, units: int) -> MagicMock: """Строка из _SUPPLY_BATCH_SQL.""" r = MagicMock() r.__getitem__ = lambda self, k: {"rb": rb, "ab": ab, "units": units}[k] return r # ── Построение mock DB ──────────────────────────────────────────────────────── def _make_db( coord: MagicMock | None = None, id_rows: list[MagicMock] | None = None, vel_rows: list[MagicMock] | None = None, supply_rows: list[MagicMock] | None = None, latest_snap: dt.date | None = None, ) -> MagicMock: """Сконструировать mock Session. db.scalar() возвращает latest_snap (MAX snapshot_date) — вызывается перед supply. Порядок db.execute(): 1. centroid → .mappings().first() 2. competitors-in-radius → .mappings().all() 3. velocity → .mappings().all() 4. supply → .mappings().all() (только если latest_snap is not None) """ db = MagicMock() # db.scalar — pre-computed MAX(snapshot_date) для supply query db.scalar.return_value = latest_snap if latest_snap is not None else _TODAY results: list[MagicMock] = [] # 1: centroid r0 = MagicMock() r0.mappings.return_value.first.return_value = coord results.append(r0) # 2: competitors-in-radius r1 = MagicMock() r1.mappings.return_value.all.return_value = id_rows or [] results.append(r1) # 3: velocity (only queried if id_rows non-empty) r2 = MagicMock() r2.mappings.return_value.all.return_value = vel_rows or [] results.append(r2) # 4: supply r3 = MagicMock() r3.mappings.return_value.all.return_value = supply_rows or [] results.append(r3) db.execute.side_effect = results return db def _override_db(db: MagicMock): def _get_db_override(): yield db return _get_db_override def _post(client: TestClient, cad: str = CAD_NUM, **body_kwargs) -> dict: payload = {"radius_km": 1.0, "time_window": "last_quarter", **body_kwargs} resp = client.post(f"/api/v1/parcels/{cad}/best-layouts", json=payload) return resp # ── Тесты ───────────────────────────────────────────────────────────────────── def test_parcel_not_found_404() -> None: """Если центроид не найден → 404.""" db = _make_db(coord=None) from app.core.db import get_db app.dependency_overrides[get_db] = _override_db(db) try: resp = _post(TestClient(app), cad="99:99:9999999:999") assert resp.status_code == 404, resp.text finally: app.dependency_overrides.clear() def test_empty_competitor_set_returns_low_confidence() -> None: """Нет конкурентов в радиусе → пустые top_layouts + confidence=low.""" db = _make_db(coord=_coord_row(), id_rows=[]) from app.core.db import get_db app.dependency_overrides[get_db] = _override_db(db) try: resp = _post(TestClient(app)) assert resp.status_code == 200, resp.text body = resp.json() assert body["top_layouts"] == [] assert body["data_quality"]["confidence"] == "low" assert body["data_quality"]["objects_total_in_radius"] == 0 rec = body["recommendation_for_tz"] assert rec["based_on_obj_count"] == 0 assert rec["based_on_total_deals"] == 0 assert rec["mix"] == [] finally: app.dependency_overrides.clear() def test_three_obj_ids_ranking_and_pct_sum_100() -> None: """3 obj_id, 3 room_buckets — ranking по velocity, sum pct = 100. last_quarter (3 мес): velocity = deals_window / 3.0 studio: 9/3=3.0, 1: 24/3=8.0, 2: 48/3=16.0 → rank1="2" """ id_rows = [_obj_id_row(1), _obj_id_row(2), _obj_id_row(3)] vel_rows = [ _vel_row("studio", deals_window=9.0, avg_area=26.0, obj_ids=[1]), _vel_row("1", deals_window=24.0, avg_area=40.0, obj_ids=[2]), _vel_row("2", deals_window=48.0, avg_area=55.0, obj_ids=[3]), ] supply_rows = [ _supply_row("studio", "<25", 20), _supply_row("1", "40-60", 60), _supply_row("2", "40-60", 80), ] db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows, supply_rows=supply_rows) from app.core.db import get_db app.dependency_overrides[get_db] = _override_db(db) try: resp = _post(TestClient(app), time_window="last_quarter") assert resp.status_code == 200, resp.text body = resp.json() top = body["top_layouts"] assert len(top) == 3 # rank 1 = самая высокая velocity (2-комн: 48/3=16.0 per month) assert top[0]["rank"] == 1 assert top[0]["room_bucket"] == "2" # все ранги уникальны assert sorted(t["rank"] for t in top) == [1, 2, 3] # sum pct = 100 mix = body["recommendation_for_tz"]["mix"] assert sum(m["pct"] for m in mix) == 100 finally: app.dependency_overrides.clear() def test_exclude_competitor_obj_ids_filter() -> None: """exclude_competitor_obj_ids исключает obj_id: при all excluded → пустой ответ.""" # Если после исключения obj_id_list пуст → _empty_response → top_layouts=[] id_rows = [_obj_id_row(20)] # единственный конкурент db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=[]) from app.core.db import get_db app.dependency_overrides[get_db] = _override_db(db) try: resp = _post(TestClient(app), exclude_competitor_obj_ids=[20]) assert resp.status_code == 200, resp.text body = resp.json() # После исключения obj_id=20 список пуст → пустой ответ assert body["top_layouts"] == [] assert body["data_quality"]["confidence"] == "low" # objects_total_in_radius = 1 (до исключения) assert body["data_quality"]["objects_total_in_radius"] == 1 finally: app.dependency_overrides.clear() def test_min_velocity_per_month_filters_low_rows() -> None: """min_velocity_per_month=5 → строки с velocity<5 не попадают в top_layouts. last_quarter (3 мес): studio=6/3=2.0 < 5.0 (убран), 1=30/3=10.0 > 5.0 (остаётся). """ id_rows = [_obj_id_row(1), _obj_id_row(2)] vel_rows = [ _vel_row("studio", deals_window=6.0, obj_ids=[1]), _vel_row("1", deals_window=30.0, obj_ids=[2]), ] db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows) from app.core.db import get_db app.dependency_overrides[get_db] = _override_db(db) try: resp = _post(TestClient(app), min_velocity_per_month=5.0) assert resp.status_code == 200, resp.text body = resp.json() top = body["top_layouts"] assert len(top) == 1 assert top[0]["room_bucket"] == "1" assert top[0]["velocity_per_month"] == pytest.approx(10.0) finally: app.dependency_overrides.clear() def test_time_window_velocity_scaling() -> None: """last_month vs last_year дают разный velocity_per_month для одних deals.""" # sum_deals=24 → last_month: 24/24=1.0, last_year: 24/2=12.0 id_rows = [_obj_id_row(1)] vel_rows_fixed = [_vel_row("2", sum_deals=24.0, obj_ids=[1])] from app.core.db import get_db # last_month db_m = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows_fixed) app.dependency_overrides[get_db] = _override_db(db_m) try: resp_m = _post(TestClient(app), time_window="last_month") assert resp_m.status_code == 200, resp_m.text v_month = resp_m.json()["top_layouts"][0]["velocity_per_month"] finally: app.dependency_overrides.clear() # last_year db_y = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows_fixed) app.dependency_overrides[get_db] = _override_db(db_y) try: resp_y = _post(TestClient(app), time_window="last_year") assert resp_y.status_code == 200, resp_y.text v_year = resp_y.json()["top_layouts"][0]["velocity_per_month"] finally: app.dependency_overrides.clear() # last_year velocity должна быть выше (делитель меньше: 2 vs 24) assert v_year > v_month assert v_month == pytest.approx(1.0) assert v_year == pytest.approx(12.0) def test_obj_class_filter_passes_through() -> None: """obj_class_filter передаётся в SQL — endpoint не ломается, возвращает 200.""" db = _make_db( coord=_coord_row(), id_rows=[_obj_id_row(5)], vel_rows=[_vel_row("2", obj_ids=[5])], supply_rows=[], ) from app.core.db import get_db app.dependency_overrides[get_db] = _override_db(db) try: resp = _post(TestClient(app), obj_class_filter="comfort") assert resp.status_code == 200, resp.text body = resp.json() assert len(body["top_layouts"]) > 0 finally: app.dependency_overrides.clear() def test_mv_empty_for_competitors_returns_empty_top_layouts() -> None: """Конкуренты есть в радиусе, но MV пустой → top_layouts=[], data_quality.confidence=low.""" id_rows = [_obj_id_row(1), _obj_id_row(2)] db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=[]) from app.core.db import get_db app.dependency_overrides[get_db] = _override_db(db) try: resp = _post(TestClient(app)) assert resp.status_code == 200, resp.text body = resp.json() assert body["top_layouts"] == [] dq = body["data_quality"] assert dq["objects_total_in_radius"] == 2 assert dq["objects_with_velocity_data"] == 0 assert dq["confidence"] == "low" finally: app.dependency_overrides.clear() def test_target_total_flats_fills_abs_units() -> None: """target_total_flats=100 → abs_units заполнен в mix, sum примерно = 100.""" id_rows = [_obj_id_row(1), _obj_id_row(2)] vel_rows = [ _vel_row("1", sum_deals=60.0, obj_ids=[1]), _vel_row("2", sum_deals=40.0, obj_ids=[2]), ] db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows) from app.core.db import get_db app.dependency_overrides[get_db] = _override_db(db) try: resp = _post(TestClient(app), target_total_flats=100) assert resp.status_code == 200, resp.text mix = resp.json()["recommendation_for_tz"]["mix"] # все abs_units заполнены for m in mix: assert m["abs_units"] is not None # сумма abs_units близка к 100 (round-off ±1) total_abs = sum(m["abs_units"] for m in mix) assert 98 <= total_abs <= 102 finally: app.dependency_overrides.clear() def test_sold_pct_clamped_at_100_and_is_oversold_flag() -> None: """raw sold_pct > 100 → returned sold_pct_of_supply=100.0, is_oversold=True.""" id_rows = [_obj_id_row(1)] # sum_deals=199, supply=100 → raw = 199% (несопоставимые окна) vel_rows = [_vel_row("2", sum_deals=199.0, obj_ids=[1])] supply_rows = [_supply_row("2", "40-60", 100)] db = _make_db( coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows, supply_rows=supply_rows, latest_snap=dt.date.today(), ) from app.core.db import get_db app.dependency_overrides[get_db] = _override_db(db) try: resp = _post(TestClient(app)) assert resp.status_code == 200, resp.text body = resp.json() top = body["top_layouts"] assert len(top) == 1 row = top[0] assert row["sold_pct_of_supply"] == 100.0, "sold_pct_of_supply должен быть clamped до 100" assert row["is_oversold"] is True, "is_oversold должен быть True когда raw > 100" finally: app.dependency_overrides.clear() def test_sold_pct_below_100_is_not_oversold() -> None: """raw sold_pct <= 100 → sold_pct_of_supply возвращается as-is, is_oversold=False.""" id_rows = [_obj_id_row(1)] # sum_deals=50, supply=100 → raw = 50% vel_rows = [_vel_row("1", sum_deals=50.0, obj_ids=[1])] supply_rows = [_supply_row("1", "40-60", 100)] db = _make_db( coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows, supply_rows=supply_rows, latest_snap=dt.date.today(), ) from app.core.db import get_db app.dependency_overrides[get_db] = _override_db(db) try: resp = _post(TestClient(app)) assert resp.status_code == 200, resp.text body = resp.json() top = body["top_layouts"] assert len(top) == 1 row = top[0] assert row["sold_pct_of_supply"] == pytest.approx(50.0) assert row["is_oversold"] is False finally: app.dependency_overrides.clear() def test_filter_competitor_obj_ids_applied() -> None: """filter_competitor_obj_ids=[1] оставляет только obj_id=1.""" id_rows = [_obj_id_row(1), _obj_id_row(2), _obj_id_row(3)] # После фильтрации остаётся только obj_id=1, velocity запрос получит [1] vel_rows = [_vel_row("2", sum_deals=24.0, obj_ids=[1])] db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows) from app.core.db import get_db app.dependency_overrides[get_db] = _override_db(db) try: resp = _post(TestClient(app), filter_competitor_obj_ids=[1]) assert resp.status_code == 200, resp.text body = resp.json() top = body["top_layouts"] assert len(top) >= 1 # competitor_obj_ids должен содержать только 1 for row in top: for oid in row["competitor_obj_ids"]: assert oid == 1 finally: app.dependency_overrides.clear()