692 lines
27 KiB
Python
692 lines
27 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
|
||
10. Service-level: db.commit() вызывается в create/update/delete (#261 regression guard)
|
||
|
||
Стратегия mock: сервисные функции патчим через unittest.mock.patch,
|
||
DB — через dependency_overrides (аналогично test_analyze_inline_weights.py).
|
||
Service commit tests (#261): MagicMock db + assert db.commit.assert_called_once().
|
||
"""
|
||
|
||
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 CustomPoiCreate, CustomPoiOut, CustomPoiUpdate
|
||
|
||
# ── Константы ─────────────────────────────────────────────────────────────────
|
||
|
||
_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": "Октябрьский",
|
||
"ekb_reference_median": 120000,
|
||
"quarter_median_12m": None,
|
||
"quarter_deals_count": 0,
|
||
"dist_to_center": 1500.0,
|
||
}
|
||
)
|
||
# Центроид УМЫШЛЕННО далеко (>15км) от центра ЕКБ (56.838011, 60.597474) —
|
||
# тогда center_bonus == 0.0 (parcels.py:~1594 порог <15км) и итоговый score
|
||
# изолирует ТОЛЬКО вклад custom-POI. ~29км на север (Δlat≈+0.26°).
|
||
centroid_row = _make_mapping_analyze({"lat": 57.10, "lon": 60.597})
|
||
|
||
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.get_air_quality_cached", return_value=None),
|
||
patch("app.api.v1.parcels.get_weather_cached", return_value=None),
|
||
patch("app.api.v1.parcels.get_seasonal_weather_cached", 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)
|
||
|
||
# Центроид >15км от центра ЕКБ → center_bonus = 0.0, поэтому итоговый score
|
||
# == вклад custom-POI (1.0). Так тест изолирует custom-contribution.
|
||
assert body["score"] == pytest.approx(1.0, 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)
|
||
# Центроид >15км от центра ЕКБ → center_bonus = 0.0, поэтому итоговый score
|
||
# == вклад custom-POI (-1.5). score_final НЕ клампится снизу (parcels.py:2410).
|
||
assert body["score"] == pytest.approx(-1.5, abs=0.1)
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
_stop_analyze_patches()
|
||
|
||
|
||
# ── Service commit regression tests (#261) ────────────────────────────────────
|
||
#
|
||
# Проверяем что service-функции вызывают db.commit() после каждой мутации.
|
||
# Если db.commit() убрать из сервиса — тест упадёт на assert_called_once().
|
||
#
|
||
# Паттерн "два сеанса": write_db отдельный от последующего чтения.
|
||
# Эмулирует независимый SELECT после HTTP request close:
|
||
# get_db.finally → db.close() без commit → rollback pending tx,
|
||
# поэтому commit должен быть явным внутри каждой service-функции.
|
||
|
||
|
||
def _make_row_data(
|
||
poi_id: int = 1,
|
||
user_id: str = _SESSION,
|
||
name: str = "Тест",
|
||
weight: float = 1.5,
|
||
lon: float = 60.605,
|
||
lat: float = 56.838,
|
||
) -> dict[str, Any]:
|
||
return {
|
||
"id": poi_id,
|
||
"user_id": user_id,
|
||
"parcel_cad": None,
|
||
"name": name,
|
||
"category": "park",
|
||
"weight": weight,
|
||
"lon": lon,
|
||
"lat": lat,
|
||
"notes": None,
|
||
"created_at": _TS,
|
||
"updated_at": _TS,
|
||
}
|
||
|
||
|
||
def _make_db_with_row(row_data: dict[str, Any] | None) -> MagicMock:
|
||
"""Вернуть MagicMock-сессию где execute().mappings().first() → row_data mapping."""
|
||
db = MagicMock()
|
||
row_mock: MagicMock | None = None
|
||
if row_data is not None:
|
||
row_mock = MagicMock()
|
||
row_mock.__getitem__ = lambda self, k: row_data[k]
|
||
row_mock.get = lambda k, default=None: row_data.get(k, default)
|
||
|
||
mapping_mock = MagicMock()
|
||
mapping_mock.first.return_value = row_mock
|
||
|
||
exec_mock = MagicMock()
|
||
exec_mock.mappings.return_value = mapping_mock
|
||
exec_mock.first.return_value = row_mock # для DELETE RETURNING id (не mappings)
|
||
|
||
db.execute.return_value = exec_mock
|
||
return db
|
||
|
||
|
||
def test_service_create_custom_poi_commits() -> None:
|
||
"""create_custom_poi вызывает db.commit() — regression guard #261.
|
||
|
||
Если commit убрать: row существует только в рамках незакоммиченной транзакции,
|
||
при db.close() (get_db.finally) соединение вернётся в пул без commit → rollback.
|
||
"""
|
||
from app.services.site_finder.custom_pois import create_custom_poi
|
||
|
||
db = _make_db_with_row(_make_row_data())
|
||
payload = CustomPoiCreate(name="Тест", weight=1.5, lon=60.605, lat=56.838)
|
||
|
||
result = create_custom_poi(db, _SESSION, payload)
|
||
|
||
db.commit.assert_called_once()
|
||
assert result.id == 1
|
||
assert result.user_id == _SESSION
|
||
assert result.weight == pytest.approx(1.5)
|
||
|
||
|
||
def test_service_delete_custom_poi_commits_when_found() -> None:
|
||
"""delete_custom_poi вызывает db.commit() когда запись найдена — regression guard #261."""
|
||
from app.services.site_finder.custom_pois import delete_custom_poi
|
||
|
||
db = _make_db_with_row(_make_row_data())
|
||
db.execute.return_value.first.return_value = MagicMock() # RETURNING id truthy
|
||
|
||
deleted = delete_custom_poi(db, poi_id=1, user_id=_SESSION)
|
||
|
||
db.commit.assert_called_once()
|
||
assert deleted is True
|
||
|
||
|
||
def test_service_delete_custom_poi_no_commit_when_not_found() -> None:
|
||
"""delete_custom_poi НЕ вызывает db.commit() если запись не найдена."""
|
||
from app.services.site_finder.custom_pois import delete_custom_poi
|
||
|
||
db = _make_db_with_row(None)
|
||
db.execute.return_value.first.return_value = None # RETURNING id пусто
|
||
|
||
deleted = delete_custom_poi(db, poi_id=999, user_id=_SESSION)
|
||
|
||
db.commit.assert_not_called()
|
||
assert deleted is False
|
||
|
||
|
||
def test_service_update_custom_poi_commits_when_fields_given() -> None:
|
||
"""update_custom_poi вызывает db.commit() при наличии полей для обновления — regression #261."""
|
||
from app.services.site_finder.custom_pois import update_custom_poi
|
||
|
||
db = _make_db_with_row(_make_row_data(weight=2.0))
|
||
payload = CustomPoiUpdate(weight=2.0)
|
||
|
||
result = update_custom_poi(db, poi_id=1, user_id=_SESSION, payload=payload)
|
||
|
||
db.commit.assert_called_once()
|
||
assert result is not None
|
||
|
||
|
||
def test_service_update_custom_poi_no_commit_when_payload_empty() -> None:
|
||
"""update_custom_poi НЕ вызывает db.commit() если payload не содержит изменяемых полей."""
|
||
from app.services.site_finder.custom_pois import update_custom_poi
|
||
|
||
db = _make_db_with_row(_make_row_data())
|
||
# Все поля None → sets = ["updated_at = NOW()"], len == 1 → UPDATE не выполняется
|
||
payload = CustomPoiUpdate()
|
||
|
||
update_custom_poi(db, poi_id=1, user_id=_SESSION, payload=payload)
|
||
|
||
db.commit.assert_not_called()
|
||
|
||
|
||
def test_service_update_custom_poi_no_commit_when_not_found() -> None:
|
||
"""update_custom_poi НЕ вызывает db.commit() если POI не найдена."""
|
||
from app.services.site_finder.custom_pois import update_custom_poi
|
||
|
||
db = _make_db_with_row(None) # get_custom_poi вернёт None (POI не найдена)
|
||
payload = CustomPoiUpdate(weight=3.0)
|
||
|
||
result = update_custom_poi(db, poi_id=999, user_id=_SESSION, payload=payload)
|
||
|
||
db.commit.assert_not_called()
|
||
assert result is None
|
||
|
||
|
||
# ── SAVEPOINT regression (#2464 cluster A) ─────────────────────────────────────
|
||
#
|
||
# get_overlaps_for_scoring: db shared с caller (POST .../analyze, Depends(get_db)).
|
||
# Раньше db.execute-сбой ловился в bare ``except Exception as e`` без
|
||
# SAVEPOINT/rollback — на реальном Postgres это отравляет db shared с caller:
|
||
# следующий db.execute на этой же сессии (остальные шаги analyze_parcel) падает
|
||
# "current transaction is aborted". Фикс — db.execute обёрнут в
|
||
# ``with db.begin_nested():``. MagicMock не эмулирует реальный aborted-transaction
|
||
# Postgres — тесты проверяют (1) graceful [] fallback при сбое, (2) что
|
||
# begin_nested() реально вызван, (3) что db остаётся usable для следующего вызова
|
||
# на той же mock-сессии.
|
||
|
||
|
||
def _overlap_row(data: dict[str, Any]) -> MagicMock:
|
||
m = MagicMock()
|
||
m.__getitem__ = lambda self, k: data[k]
|
||
return m
|
||
|
||
|
||
def test_get_overlaps_for_scoring_returns_shaped_rows() -> None:
|
||
"""Happy-path: db.execute успешен → список dict с ожидаемыми ключами."""
|
||
from app.services.site_finder.custom_pois import get_overlaps_for_scoring
|
||
|
||
row = _overlap_row(
|
||
{
|
||
"id": 42,
|
||
"name": "Мой парк",
|
||
"category": "park",
|
||
"weight": 2.0,
|
||
"lon": 60.607,
|
||
"lat": 56.840,
|
||
"distance_m": 500.0,
|
||
}
|
||
)
|
||
db = MagicMock()
|
||
db.execute.return_value.mappings.return_value.all.return_value = [row]
|
||
|
||
out = get_overlaps_for_scoring(db, "POINT(60.6 56.8)", _SESSION, parcel_cad="66:41:0:1")
|
||
|
||
assert out == [
|
||
{
|
||
"id": 42,
|
||
"name": "Мой парк",
|
||
"category": "park",
|
||
"weight": 2.0,
|
||
"lon": 60.607,
|
||
"lat": 56.840,
|
||
"distance_m": 500.0,
|
||
}
|
||
]
|
||
|
||
|
||
def test_get_overlaps_for_scoring_uses_begin_nested_savepoint() -> None:
|
||
"""Фикс: db.execute обёрнут в db.begin_nested(), не голый вызов."""
|
||
from app.services.site_finder.custom_pois import get_overlaps_for_scoring
|
||
|
||
db = MagicMock()
|
||
db.execute.return_value.mappings.return_value.all.return_value = []
|
||
|
||
get_overlaps_for_scoring(db, "POINT(60.6 56.8)", _SESSION)
|
||
|
||
assert db.begin_nested.call_count == 1
|
||
|
||
|
||
def test_get_overlaps_for_scoring_query_failure_degrades_to_empty_list() -> None:
|
||
"""Сбой db.execute → [] (graceful), не крашит analyze_parcel."""
|
||
from app.services.site_finder.custom_pois import get_overlaps_for_scoring
|
||
|
||
db = MagicMock()
|
||
db.execute.side_effect = RuntimeError("simulated DB failure")
|
||
|
||
assert get_overlaps_for_scoring(db, "POINT(60.6 56.8)", _SESSION) == []
|
||
|
||
|
||
def test_get_overlaps_for_scoring_session_usable_after_failure() -> None:
|
||
"""db.execute сбоит один раз, но db остаётся usable для следующего вызова на
|
||
этой же сессии (имитирует custom-POI overlaps → остальные шаги analyze_parcel
|
||
на одной shared Session)."""
|
||
from app.services.site_finder.custom_pois import get_overlaps_for_scoring
|
||
|
||
db = MagicMock()
|
||
db.execute.side_effect = RuntimeError("boom")
|
||
assert get_overlaps_for_scoring(db, "POINT(60.6 56.8)", _SESSION) == []
|
||
|
||
db.execute.side_effect = None
|
||
next_result = MagicMock()
|
||
next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
|
||
db.execute.return_value = next_result
|
||
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
|