gendesign/backend/tests/support/pg_session.py
bot-backend 32ad2419ec
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m17s
CI / backend-tests (pull_request) Successful in 17m21s
test(ptica): снятие SAVEPOINT теперь роняет тест во всех местах кластера A (#2464)
Мутационный прогон 19.08 показал, что у трёх закрытых пунктов кластера снятие
`with db.begin_nested():` не роняет НИ ОДНОГО теста:

    saturation.py        снят SAVEPOINT -> 465 passed
    zone_regulation.py   снят SAVEPOINT -> 195 passed
    pat_lookup.py        снят SAVEPOINT ->  21 passed

Код там правильный — беззащитны регрессии. Причина общая: существующие проверки
построены на MagicMock, у которого нет aborted-состояния, поэтому второй execute
проходит при любом коде. Такая проверка ловит только проглоченное исключение.

Добавлен двойник tests/support/pg_session.py, воспроизводящий семантику Postgres:
упавший запрос переводит транзакцию в aborted; следующий execute падает с тем же
текстом, что отдаёт Postgres; выход из begin_nested с исключением = ROLLBACK TO
SAVEPOINT и снимает aborted.

Проверяется СВОЙСТВО «сессия вызывающего осталась рабочей», а не наличие вызова
begin_nested: второе зафиксировало бы реализацию, первое переживёт любой
корректный способ починки. Плюс два контроля на сам двойник — без них проверки
были бы зелёными по построению.

Заодно закрыт пробел в connection_capacity_lookup. Его собственный тест краснеет
только при снятии ВСЕХ пяти SAVEPOINT, потому что проверяет
`begin_nested.call_count >= 1` — «хоть один был», а не «этот запрос обёрнут».
Снятие одного из пяти (обычный случай при рефакторинге одной функции) проходило
незамеченным. Новые проверки адресные: по одной на функцию.

Прицел важен: первая версия теста для _query_gas_city_grs роняла ПЕРВЫЙ execute,
а он принадлежит _query_gas_outlet_counts, которую эта функция зовёт внутри себя —
то есть тест сторожил соседа. Исправлено на fail_on=(2,).

Проверено мутацией: снятие SAVEPOINT в любом из трёх модулей роняет ровно свою
проверку; в connection_capacity_lookup поштучное снятие каждого из пяти — тоже
(5 из 5, до правки 4 из 5).

pytest tests/services: 3070 passed, 14 skipped, rc=0
2026-08-19 20:41:42 +05:00

99 lines
4.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Сессия-двойник с настоящей семантикой aborted-транзакции Postgres.
Зачем отдельный помощник, а не MagicMock: у мока нет состояния «транзакция
оборвана», поэтому второй `execute` в тесте проходит независимо от того, обёрнут
первый в SAVEPOINT или нет. Проверка на отравление сессии на таком моке зелена по
построению — она может поймать только проглоченное исключение.
Замер 19.08.2026 (мутационный прогон по кластеру A эпика #2464): у трёх мест из
одиннадцати снятие SAVEPOINT не роняет НИ ОДНОГО теста — saturation (465 passed),
zone_regulation (195), pat_lookup (21). Код там правильный; беззащитны регрессии.
Семантика, которую двойник воспроизводит:
* упавший `execute` переводит транзакцию в aborted;
* любой следующий `execute` в aborted-состоянии падает с тем же текстом, что
отдаёт Postgres, — до конца транзакции;
* выход из `begin_nested()` с исключением = ROLLBACK TO SAVEPOINT: снимает
aborted, внешняя транзакция остаётся рабочей.
"""
from __future__ import annotations
from contextlib import contextmanager
from typing import Any
_ABORTED_MESSAGE = "current transaction is aborted, commands ignored until end of transaction block"
class AbortedTransactionError(RuntimeError):
"""Аналог psycopg InFailedSqlTransaction."""
class PostgresLikeSession:
"""Минимальная сессия: aborted-состояние + рабочий SAVEPOINT.
fail_on: номера вызовов `execute` (с единицы), которые должны упасть.
error: исключение, которым падать (важно — обработчики ловят конкретные типы
SQLAlchemy, а не голый Exception).
rows / row: что отдавать при успешном вызове.
"""
def __init__(
self,
*,
fail_on: tuple[int, ...] = (1,),
error: BaseException | None = None,
rows: list[Any] | None = None,
row: Any = None,
) -> None:
self.calls = 0
self.aborted = False
self.savepoints_entered = 0
self._fail_on = fail_on
self._error = error or RuntimeError("simulated DB failure")
self._rows = rows if rows is not None else []
self._row = row
@contextmanager
def begin_nested(self): # type: ignore[no-untyped-def]
self.savepoints_entered += 1
try:
yield
except BaseException:
# ROLLBACK TO SAVEPOINT — внешняя транзакция снова пригодна.
self.aborted = False
raise
def execute(self, *_args: Any, **_kwargs: Any) -> _Result:
if self.aborted:
raise AbortedTransactionError(_ABORTED_MESSAGE)
self.calls += 1
if self.calls in self._fail_on:
self.aborted = True
raise self._error
return _Result(self._rows, self._row)
# ── прочее, что могут дёрнуть обработчики ────────────────────────────────
def commit(self) -> None:
if self.aborted:
raise AbortedTransactionError(_ABORTED_MESSAGE)
def rollback(self) -> None:
self.aborted = False
class _Result:
def __init__(self, rows: list[Any], row: Any) -> None:
self._rows, self._row = rows, row
def mappings(self) -> _Result:
return self
def all(self) -> list[Any]:
return self._rows
def first(self) -> Any:
return self._row
def scalar(self) -> Any:
return self._row