- SQL migration 101_user_custom_pois.sql: table with GEOGRAPHY(POINT) + GIST index - Pydantic schemas: CustomPoiCreate / CustomPoiUpdate / CustomPoiOut - Service custom_pois.py: CRUD + get_overlaps_for_scoring (1km radius, psycopg v3 CAST) - API /api/v1/custom-pois: POST(201)/GET/PATCH/DELETE, X-Session-Id header auth - Scoring in analyze_parcel: custom POI block after OSM POI loop, decay weight * max(0, 1 - dist/1000), custom_poi_score_items in response - Tests: CRUD + validation (weight/lon/lat 422) + scoring integration (mock-based) - Vault: Schema_User_Custom_Pois.md + Endpoint_Parcel_Analyze v3.9 section
455 lines
17 KiB
Python
455 lines
17 KiB
Python
"""Тесты для 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()
|