"""#2464 кластер A: job_settings не должен отравлять ЧУЖУЮ сессию. `get_all`/`get_one` глотают ошибку БД и возвращают fallback. Сессию им отдаёт вызывающий — admin-ручка, `beat_schedule`, либо `get_setting_value` из `cadastre_fetch`/`nspd_geo`. На Postgres упавший `db.execute` оставляет транзакцию в aborted-состоянии, и ВСЕ последующие запросы этой же сессии падают с «current transaction is aborted, commands ignored until end of transaction block» — падает не тот, кто виноват. Голый `db.rollback()` здесь запрещён: он снёс бы незакоммиченную работу вызывающего. Правильное средство — SAVEPOINT вокруг самого execute (`developer_attribution.py:152`, тот же кластер). О МОКЕ ────── Мок ниже ВОСПРОИЗВОДИТ семантику Postgres: после упавшего execute сессия помечается aborted и дальнейшие запросы падают, пока откат SAVEPOINT её не восстановит. Это существенно. Обычный «мок с пустым begin_nested» (см. tests/test_saturation.py) такую проверку не даёт: у него нет aborted-состояния, поэтому второй execute проходит в любом случае — и тест зелёный независимо от того, есть SAVEPOINT в коде или нет. Проверка, которую нельзя уронить, сняв защиту, защиты не проверяет. """ from __future__ import annotations from contextlib import contextmanager from typing import Any import pytest from app.services.job_settings import get_all, get_one class AbortedTransactionError(RuntimeError): """Аналог psycopg InFailedSqlTransaction.""" class _PostgresLikeDb: """Сессия с aborted-состоянием и настоящей семантикой SAVEPOINT.""" def __init__(self, *, fail_first: bool = True) -> None: self.calls = 0 self.aborted = False self._fail_first = fail_first self._savepoint_depth = 0 @contextmanager def begin_nested(self): # type: ignore[no-untyped-def] self._savepoint_depth += 1 try: yield except Exception: # Откат SAVEPOINT: снимаем aborted, внешняя транзакция цела. self.aborted = False raise finally: self._savepoint_depth -= 1 def execute(self, *_args: Any, **_kwargs: Any) -> Any: if self.aborted: raise AbortedTransactionError( "current transaction is aborted, commands ignored until end of " "transaction block" ) self.calls += 1 if self.calls == 1 and self._fail_first: # Ошибка внутри транзакции переводит её в aborted. self.aborted = True raise RuntimeError("simulated DB failure") return _Result() # Строка в форме, которую ждёт _row_to_dict: одиннадцать колонок SELECT'а. _DB_ROW: dict[str, Any] = { "job_type": "scrape_kn", "enabled": True, "queue_name": "celery", "cron_schedule": "0 3 * * *", "rate_ms": 1000, "max_retries": 3, "max_concurrency": 1, "extra_config": {"marker": "из-БД"}, "updated_at": None, "updated_by": None, "description": "тестовая строка", } class _Result: def mappings(self) -> _Result: return self def all(self) -> list[dict[str, Any]]: return [_DB_ROW] def first(self) -> dict[str, Any]: return _DB_ROW def test_get_all_leaves_the_caller_session_usable() -> None: """После сбоя внутри get_all следующий запрос вызывающего должен пройти.""" db = _PostgresLikeDb() rows = get_all(db) assert rows, "fallback не вернулся — сломано само graceful-поведение" # Это и есть проверка: на origin/main здесь AbortedTransaction. assert db.execute("SELECT 1").mappings().first() == _DB_ROW def test_get_one_leaves_the_caller_session_usable() -> None: db = _PostgresLikeDb() row = get_one("scrape_kn", db) assert row, "fallback не вернулся" assert db.execute("SELECT 1").mappings().first() == _DB_ROW def test_mock_actually_poisons_without_a_savepoint() -> None: """Контроль на сам мок: без SAVEPOINT он ОБЯЗАН отравляться. Без этой проверки тесты выше были бы зелёными по построению — ровно та ловушка, из-за которой существующий мок в test_saturation.py ничего не проверяет. """ db = _PostgresLikeDb() with pytest.raises(RuntimeError): db.execute("boom") # первый вызов падает и переводит в aborted with pytest.raises(AbortedTransactionError): db.execute("SELECT 1") def test_healthy_session_is_not_disturbed() -> None: """Контроль: без сбоя поведение прежнее — данные из БД, а не fallback.""" db = _PostgresLikeDb(fail_first=False) rows = get_all(db) assert len(rows) == 1 assert rows[0]["job_type"] == "scrape_kn" assert rows[0]["extra_config"] == {"marker": "из-БД"}, "вернулся fallback вместо данных БД"