diff --git a/backend/tests/api/v1/test_custom_pois.py b/backend/tests/api/v1/test_custom_pois.py index f0834e9b..a579ff47 100644 --- a/backend/tests/api/v1/test_custom_pois.py +++ b/backend/tests/api/v1/test_custom_pois.py @@ -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