138 lines
6.8 KiB
Python
138 lines
6.8 KiB
Python
"""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"}]
|