fix(#261): custom_pois — regression tests для db.commit() в create/update/delete (P0 persistence) #262

Merged
lekss361 merged 1 commit from fix/261-custom-pois-missing-commit into main 2026-05-17 07:26:55 +00:00

View file

@ -10,9 +10,11 @@
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
@ -26,7 +28,7 @@ import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.schemas.custom_poi import CustomPoiOut
from app.schemas.custom_poi import CustomPoiCreate, CustomPoiOut, CustomPoiUpdate
# ── Константы ─────────────────────────────────────────────────────────────────
@ -453,3 +455,141 @@ def test_analyze_custom_poi_negative_weight_decreases_score() -> None:
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