"""Тесты для custom POI CRUD + scoring integration (#254). Покрывает: 1. POST /api/v1/custom-pois → 201, correct response, X-Session-Id в response header 2. GET /api/v1/custom-pois → list для user (изоляция по user_id) 3. PATCH /api/v1/custom-pois/{id} → updated, 404 для чужой 4. DELETE /api/v1/custom-pois/{id} → 204, 404 для чужой 5. Validation: weight outside [-5,5] → 422; lon/lat outside range → 422 6. GET /api/v1/custom-pois?parcel_cad=... → filter works 7. Scoring integration: custom POI в 500м с weight=+2 увеличивает score 8. No X-Session-Id → auto-generated UUID в response header 9. Scoring absent when no X-Session-Id header Стратегия mock: сервисные функции патчим через unittest.mock.patch, DB — через dependency_overrides (аналогично test_analyze_inline_weights.py). """ from __future__ import annotations import uuid from datetime import UTC, datetime from typing import Any from unittest.mock import MagicMock, patch import pytest from fastapi.testclient import TestClient from app.main import app from app.schemas.custom_poi import CustomPoiOut # ── Константы ───────────────────────────────────────────────────────────────── _CAD = "66:41:0204016:10" _WKT = "POLYGON((60.6 56.838, 60.61 56.838, 60.61 56.845, 60.6 56.845, 60.6 56.838))" _GEOJSON = '{"type":"Polygon","coordinates":[[[60.6,56.838],[60.61,56.838]]]}' _SESSION = "test-session-abc123" _TS = datetime(2026, 5, 17, 10, 0, 0, tzinfo=UTC) # ── Helpers ──────────────────────────────────────────────────────────────────── def _make_poi_out( poi_id: int = 1, user_id: str = _SESSION, name: str = "Парк Маяковского", weight: float = 2.0, lon: float = 60.605, lat: float = 56.838, parcel_cad: str | None = None, ) -> CustomPoiOut: return CustomPoiOut( id=poi_id, user_id=user_id, parcel_cad=parcel_cad, name=name, category="park", weight=weight, lon=lon, lat=lat, notes=None, created_at=_TS, updated_at=_TS, ) def _make_mapping(data: dict[str, Any]) -> MagicMock: m = MagicMock() m.__getitem__ = lambda self, k: data[k] m.get = lambda k, default=None: data.get(k, default) return m # ── Tests: CRUD ──────────────────────────────────────────────────────────────── def test_create_poi_returns_201() -> None: """POST /custom-pois → 201 + корректный тело + X-Session-Id в header.""" expected = _make_poi_out() with patch("app.api.v1.custom_pois.create_custom_poi", return_value=expected) as mock_create: client = TestClient(app) resp = client.post( "/api/v1/custom-pois", json={ "name": "Парк Маяковского", "category": "park", "weight": 2.0, "lon": 60.605, "lat": 56.838, }, headers={"X-Session-Id": _SESSION}, ) assert resp.status_code == 201, resp.text body = resp.json() assert body["name"] == "Парк Маяковского" assert body["weight"] == pytest.approx(2.0) assert body["id"] == 1 assert resp.headers.get("x-session-id") == _SESSION mock_create.assert_called_once() def test_create_poi_auto_generates_session_id() -> None: """POST без X-Session-Id → 201 + авто-UUID в X-Session-Id header.""" expected = _make_poi_out(user_id="some-uuid") with patch("app.api.v1.custom_pois.create_custom_poi", return_value=expected): client = TestClient(app) resp = client.post( "/api/v1/custom-pois", json={"name": "Test", "weight": 1.0, "lon": 60.0, "lat": 56.0}, ) assert resp.status_code == 201, resp.text sid = resp.headers.get("x-session-id") assert sid is not None, "Ожидали X-Session-Id в response headers" # Должен быть валидным UUID try: uuid.UUID(sid) except ValueError: pytest.fail(f"X-Session-Id '{sid}' не является валидным UUID") def test_create_poi_weight_out_of_range_returns_422() -> None: """weight > 5 или < -5 → 422 от Pydantic (FastAPI body validation).""" client = TestClient(app) resp = client.post( "/api/v1/custom-pois", json={"name": "Test", "weight": 10.0, "lon": 60.0, "lat": 56.0}, headers={"X-Session-Id": _SESSION}, ) assert resp.status_code == 422, resp.text resp2 = client.post( "/api/v1/custom-pois", json={"name": "Test", "weight": -6.0, "lon": 60.0, "lat": 56.0}, headers={"X-Session-Id": _SESSION}, ) assert resp2.status_code == 422, resp2.text def test_create_poi_lon_out_of_range_returns_422() -> None: """lon > 180 → 422.""" client = TestClient(app) resp = client.post( "/api/v1/custom-pois", json={"name": "Test", "weight": 1.0, "lon": 200.0, "lat": 56.0}, headers={"X-Session-Id": _SESSION}, ) assert resp.status_code == 422, resp.text def test_create_poi_lat_out_of_range_returns_422() -> None: """lat > 90 → 422.""" client = TestClient(app) resp = client.post( "/api/v1/custom-pois", json={"name": "Test", "weight": 1.0, "lon": 60.0, "lat": 100.0}, headers={"X-Session-Id": _SESSION}, ) assert resp.status_code == 422, resp.text def test_list_pois_returns_user_items() -> None: """GET /custom-pois → список POI пользователя.""" pois = [_make_poi_out(1), _make_poi_out(2, name="Школа №5", weight=1.5)] with patch("app.api.v1.custom_pois.list_custom_pois", return_value=pois): client = TestClient(app) resp = client.get("/api/v1/custom-pois", headers={"X-Session-Id": _SESSION}) assert resp.status_code == 200, resp.text body = resp.json() assert len(body) == 2 assert body[0]["id"] == 1 assert body[1]["name"] == "Школа №5" def test_list_pois_filter_by_parcel_cad() -> None: """GET /custom-pois?parcel_cad=... → вызывает list_custom_pois с parcel_cad.""" with patch("app.api.v1.custom_pois.list_custom_pois", return_value=[]) as mock_list: client = TestClient(app) resp = client.get( f"/api/v1/custom-pois?parcel_cad={_CAD}", headers={"X-Session-Id": _SESSION}, ) assert resp.status_code == 200, resp.text # Проверяем что parcel_cad передан в сервис call_kwargs = mock_list.call_args assert call_kwargs[1].get("parcel_cad") == _CAD or ( len(call_kwargs[0]) >= 3 and call_kwargs[0][2] == _CAD ) def test_patch_poi_returns_updated() -> None: """PATCH /custom-pois/{id} → 200 с обновлёнными данными.""" updated = _make_poi_out(weight=3.0, name="Обновлённый парк") with patch("app.api.v1.custom_pois.update_custom_poi", return_value=updated): client = TestClient(app) resp = client.patch( "/api/v1/custom-pois/1", json={"weight": 3.0, "name": "Обновлённый парк"}, headers={"X-Session-Id": _SESSION}, ) assert resp.status_code == 200, resp.text body = resp.json() assert body["weight"] == pytest.approx(3.0) assert body["name"] == "Обновлённый парк" def test_patch_poi_not_found_returns_404() -> None: """PATCH для несуществующей/чужой POI → 404.""" with patch("app.api.v1.custom_pois.update_custom_poi", return_value=None): client = TestClient(app) resp = client.patch( "/api/v1/custom-pois/999", json={"weight": 1.0}, headers={"X-Session-Id": _SESSION}, ) assert resp.status_code == 404, resp.text def test_delete_poi_returns_204() -> None: """DELETE /custom-pois/{id} → 204 при успешном удалении.""" with patch("app.api.v1.custom_pois.delete_custom_poi", return_value=True): client = TestClient(app) resp = client.delete("/api/v1/custom-pois/1", headers={"X-Session-Id": _SESSION}) assert resp.status_code == 204, resp.text def test_delete_poi_not_found_returns_404() -> None: """DELETE для несуществующей POI → 404.""" with patch("app.api.v1.custom_pois.delete_custom_poi", return_value=False): client = TestClient(app) resp = client.delete("/api/v1/custom-pois/999", headers={"X-Session-Id": _SESSION}) assert resp.status_code == 404, resp.text # ── Tests: Scoring integration ───────────────────────────────────────────────── # Вспомогательный mock DB для analyze_parcel (аналогично test_analyze_inline_weights.py). # Порядок db.execute calls включает #254 custom POI via get_overlaps_for_scoring, # который вызывается уже после POI loop — ВНУТРИ analyze_parcel через импортированную # функцию. Мы патчим её через patch() — DB mock остаётся прежним. def _make_mapping_analyze(data: dict[str, Any]) -> MagicMock: m = MagicMock() m.__getitem__ = lambda self, k: data[k] m.get = lambda k, default=None: data.get(k, default) return m def _make_db_for_analyze() -> MagicMock: """Mock DB Session для analyze_parcel (без custom POI вызовов — они пропатчены).""" db = MagicMock() geom_row = _make_mapping_analyze( {"geom_geojson": _GEOJSON, "geom_wkb": None, "source": "cad_quarter"} ) wkt_row = _make_mapping_analyze({"wkt": _WKT}) district_row = _make_mapping_analyze( { "district_name": "Октябрьский", "median_price_per_m2": 120000, "dist_to_center": 1500.0, } ) centroid_row = _make_mapping_analyze({"lat": 56.84, "lon": 60.605}) call_idx = [0] responses: list[Any] = [ ("first", geom_row), # 0: geom UNION ALL ("first", wkt_row), # 1: WKT ("first", district_row), # 2: district ("all", []), # 3: POI rows ("all", []), # 4: competitor rows ("all", []), # 5: pipeline rows ("first", centroid_row), # 6: centroid ("all", []), # 7: noise rows ("all", []), # 8: hydrology rows ("all", []), # 9: utilities rows ("first", None), # 10: parcel_meta ("first", None), # 11: market trend ("first", None), # 12: zoning (begin_nested) ("all", []), # 13: success recommendation (begin_nested) ("first", None), # 14: market price (begin_nested) ("all", []), # 15: recent permits (begin_nested) ("scalar", 0), # 16: geotech_risk ("all", []), # 17: neighbors ("first", None), # 18: overlap ] def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock: idx = call_idx[0] call_idx[0] += 1 if idx >= len(responses): r = MagicMock() r.mappings.return_value.first.return_value = None r.mappings.return_value.all.return_value = [] r.scalar.return_value = 0 return r kind, data = responses[idx] r = MagicMock() r.mappings.return_value.first.return_value = data r.mappings.return_value.all.return_value = data if isinstance(data, list) else [] r.scalar.return_value = data if kind == "scalar" else 0 return r db.execute.side_effect = _execute_side_effect ctx = MagicMock() ctx.__enter__ = MagicMock(return_value=ctx) ctx.__exit__ = MagicMock(return_value=False) db.begin_nested.return_value = ctx return db def _override_db(db: MagicMock): def _get_db_override(): yield db return _get_db_override _ANALYZE_PATCHES = [ patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None), patch("app.api.v1.parcels._fetch_weather_sync", return_value=None), patch("app.api.v1.parcels._fetch_seasonal_weather_sync", return_value=None), patch( "app.api.v1.parcels.get_quarter_dump_data", return_value={ "nspd_zoning": None, "nspd_zouit_overlaps": [], "nspd_engineering_nearby": [], "nspd_dump": {"available": False, "stale": False, "harvest_triggered": False}, }, ), patch("app.api.v1.parcels.compute_velocity", return_value=None), patch("app.api.v1.parcels.compute_gate_verdict", return_value={"verdict": "unknown"}), ] def _start_analyze_patches() -> None: for p in _ANALYZE_PATCHES: p.start() def _stop_analyze_patches() -> None: for p in _ANALYZE_PATCHES: p.stop() def test_analyze_custom_poi_increases_score() -> None: """Custom POI с weight=+2 в 500м → score повышается, custom_poi_score_items непустой.""" from app.core.db import get_db # Симулируем custom POI в 500м _custom_overlap = { "id": 42, "name": "Мой парк", "category": "park", "weight": 2.0, "lon": 60.607, "lat": 56.840, "distance_m": 500.0, } # decay = 1 - 500/1000 = 0.5; contribution = 2.0 * 0.5 = 1.0 db = _make_db_for_analyze() app.dependency_overrides[get_db] = _override_db(db) _start_analyze_patches() try: with patch("app.api.v1.parcels._get_custom_poi_overlaps", return_value=[_custom_overlap]): client = TestClient(app) resp = client.post( f"/api/v1/parcels/{_CAD}/analyze", headers={"X-Session-Id": _SESSION}, ) assert resp.status_code == 200, resp.text body = resp.json() # custom_poi_score_items должен содержать нашу точку assert "custom_poi_score_items" in body items = body["custom_poi_score_items"] assert len(items) == 1 assert items[0]["label"] == "Мой парк" assert items[0]["weight"] == pytest.approx(2.0) assert items[0]["distance_m"] == 500 # contribution = 2.0 * 0.5 = 1.0 assert items[0]["contribution"] == pytest.approx(1.0, abs=0.01) # score должен учитывать contribution custom POI # (базовый score без POI = center_bonus из 1500м → 1.5; + custom 1.0 = 2.5) assert body["score"] == pytest.approx(2.5, abs=0.1) finally: app.dependency_overrides.clear() _stop_analyze_patches() def test_analyze_without_session_id_no_custom_poi() -> None: """POST /analyze без X-Session-Id → custom_poi_score_items пустой.""" from app.core.db import get_db db = _make_db_for_analyze() app.dependency_overrides[get_db] = _override_db(db) _start_analyze_patches() try: with patch("app.api.v1.parcels._get_custom_poi_overlaps", return_value=[]) as mock_overlaps: client = TestClient(app) resp = client.post(f"/api/v1/parcels/{_CAD}/analyze") assert resp.status_code == 200, resp.text body = resp.json() # Без session_id custom_poi_score_items пустой (не вызываем get_overlaps) assert body["custom_poi_score_items"] == [] mock_overlaps.assert_not_called() finally: app.dependency_overrides.clear() _stop_analyze_patches() def test_analyze_custom_poi_negative_weight_decreases_score() -> None: """Custom POI с weight=-3 в 500м → score уменьшается.""" from app.core.db import get_db _custom_overlap = { "id": 7, "name": "Промзона", "category": "industrial", "weight": -3.0, "lon": 60.607, "lat": 56.840, "distance_m": 500.0, } # contribution = -3.0 * 0.5 = -1.5 db = _make_db_for_analyze() app.dependency_overrides[get_db] = _override_db(db) _start_analyze_patches() try: with patch("app.api.v1.parcels._get_custom_poi_overlaps", return_value=[_custom_overlap]): client = TestClient(app) resp = client.post( f"/api/v1/parcels/{_CAD}/analyze", headers={"X-Session-Id": _SESSION}, ) assert resp.status_code == 200, resp.text body = resp.json() items = body["custom_poi_score_items"] assert len(items) == 1 assert items[0]["contribution"] == pytest.approx(-1.5, abs=0.01) # center_bonus = 1.5 (1500м), custom = -1.5 → итого ≈ 0 assert body["score"] == pytest.approx(0.0, abs=0.1) finally: app.dependency_overrides.clear() _stop_analyze_patches()