fix(#261): custom_pois — regression tests для db.commit() в create/update/delete (P0 persistence) #262
1 changed files with 141 additions and 1 deletions
|
|
@ -10,9 +10,11 @@
|
||||||
7. Scoring integration: custom POI в 500м с weight=+2 увеличивает score
|
7. Scoring integration: custom POI в 500м с weight=+2 увеличивает score
|
||||||
8. No X-Session-Id → auto-generated UUID в response header
|
8. No X-Session-Id → auto-generated UUID в response header
|
||||||
9. Scoring absent when no X-Session-Id 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,
|
Стратегия mock: сервисные функции патчим через unittest.mock.patch,
|
||||||
DB — через dependency_overrides (аналогично test_analyze_inline_weights.py).
|
DB — через dependency_overrides (аналогично test_analyze_inline_weights.py).
|
||||||
|
Service commit tests (#261): MagicMock db + assert db.commit.assert_called_once().
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -26,7 +28,7 @@ import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app.main import app
|
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:
|
finally:
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
_stop_analyze_patches()
|
_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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue