"""SAVEPOINT-регрессия для connection_capacity_lookup.py (#2464 cluster A finding 2). gas/heat/gas-outlet query helpers (_query_gas_city_grs, _query_gas_outlet_counts, _query_gas_outlet_points, _query_heat_latest) когда-то ловили db.execute-сбой в bare ``except Exception`` без SAVEPOINT/rollback — на реальном Postgres это отравляло db shared с caller (GET .../connection-capacity, Depends(get_db)): следующий db.execute в этом же запросе падал с "current transaction is aborted". Сиблинг ``_query_nearby_network_zones`` (в этом же файле) уже был правильным (db.begin_nested()) — эти тесты проверяют, что остальные функции приведены в соответствие. MagicMock-сессия не эмулирует реальный aborted-transaction Postgres — тесты проверяют (1) graceful fallback при сбое, (2) что begin_nested() реально вызван вокруг db.execute (SAVEPOINT-обёртка присутствует в коде), (3) что db остаётся usable для следующего вызова на том же mock-сессии (call_count растёт, ошибка не проглатывает control flow). """ from __future__ import annotations import os from unittest.mock import MagicMock os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from app.services.site_finder.connection_capacity_lookup import ( _query_gas_city_grs, _query_gas_outlet_counts, _query_gas_outlet_points, _query_heat_latest, ) _WKT = "POLYGON((60.8 56.7, 60.9 56.7, 60.9 56.8, 60.8 56.8, 60.8 56.7))" def _mock_db_raising_once(final_rows: list[dict] | None = None) -> MagicMock: """db.execute кидает на 1-м вызове, на 2-м успешно отдаёт final_rows.""" db = MagicMock() ok_result = MagicMock() ok_result.mappings.return_value.all.return_value = final_rows or [] ok_result.mappings.return_value.first.return_value = (final_rows or [None])[0] db.execute.side_effect = [RuntimeError("simulated DB failure"), ok_result] return db class TestGasCityGrsSavepoint: def test_query_failure_degrades_gracefully(self) -> None: db = MagicMock() db.execute.side_effect = RuntimeError("gas_grs_capacity недоступна") out = _query_gas_city_grs(db) assert out["city_grs"] == [] assert out["total_free_th_m3_h"] is None def test_uses_begin_nested_savepoint(self) -> None: """Фикс: query обёрнут в db.begin_nested(), не голый db.execute.""" db = MagicMock() db.execute.return_value.mappings.return_value.all.return_value = [] db.execute.return_value.mappings.return_value.first.return_value = None _query_gas_city_grs(db) # 2 execute на функцию: _query_gas_outlet_counts (city-level счётчики) + # сам gas_grs_capacity query — оба теперь под begin_nested. assert db.begin_nested.call_count >= 1 def test_session_usable_after_failure(self) -> None: """db.execute сбоит один раз (city_grs), но db остаётся usable для следующего (не связанного) вызова на этой же сессии.""" db = _mock_db_raising_once() # outlet_counts вызывается ПЕРВЫМ внутри _query_gas_city_grs — заставим его # тоже отдать пустой результат нормально, а основной query — кинуть. db.execute.side_effect = [ MagicMock( mappings=MagicMock(return_value=MagicMock(first=lambda: None)) ), # outlet_counts RuntimeError("main query failed"), # gas_grs_capacity query ] out = _query_gas_city_grs(db) assert out["city_grs"] == [] # Следующий вызов на том же db.execute отрабатывает (session не отравлена). next_result = MagicMock() next_result.mappings.return_value.all.return_value = [{"marker": "ok"}] db.execute.side_effect = None db.execute.return_value = next_result assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}] class TestGasOutletCountsSavepoint: def test_query_failure_degrades_to_zeros(self) -> None: db = MagicMock() db.execute.side_effect = RuntimeError("gas_grs_outlet_points недоступна") out = _query_gas_outlet_counts(db) assert out == {"outlets_total": 0, "outlets_deficit": 0, "outlets_needs_calc": 0} def test_uses_begin_nested_savepoint(self) -> None: db = MagicMock() db.execute.return_value.mappings.return_value.first.return_value = None _query_gas_outlet_counts(db) assert db.begin_nested.call_count == 1 class TestGasOutletPointsSavepoint: def test_query_failure_degrades_to_empty_list(self) -> None: db = MagicMock() db.execute.side_effect = RuntimeError("gas_grs_outlet_points (радиус) недоступна") assert _query_gas_outlet_points(db, _WKT) == [] def test_uses_begin_nested_savepoint(self) -> None: db = MagicMock() db.execute.return_value.mappings.return_value.all.return_value = [] _query_gas_outlet_points(db, _WKT) assert db.begin_nested.call_count == 1 class TestHeatLatestSavepoint: def test_query_failure_degrades_gracefully(self) -> None: db = MagicMock() db.execute.side_effect = RuntimeError("heat_system_reserves недоступна") out = _query_heat_latest(db) assert out == {"systems": [], "total_reserve_gcal_h": None} def test_uses_begin_nested_savepoint(self) -> None: db = MagicMock() db.execute.return_value.mappings.return_value.all.return_value = [] _query_heat_latest(db) assert db.begin_nested.call_count == 1 def test_session_usable_after_failure(self) -> None: """execute сбоит один раз, db остаётся usable для следующего вызова — имитирует heat → gas_outlet_points → nearby_network_zones в get_connection_capacity() на одной shared Session.""" db = MagicMock() db.execute.side_effect = RuntimeError("boom") out = _query_heat_latest(db) assert out["systems"] == [] db.execute.side_effect = None ok_result = MagicMock() ok_result.mappings.return_value.all.return_value = [{"marker": "next-query-ok"}] db.execute.return_value = ok_result assert db.execute("SELECT 1").mappings().all() == [{"marker": "next-query-ok"}]