gendesign/backend/tests/services/test_2464a_job_settings_savepoint.py
bot-backend 14818f0303
All checks were successful
CI Trade-In / changes (pull_request) Successful in 15s
CI / changes (pull_request) Successful in 15s
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 2m56s
CI Trade-In / backend-tests (pull_request) Successful in 5m25s
CI / backend-tests (pull_request) Successful in 18m18s
chore(format): нормализация под ruff 0.15.20 — 161 файл, только формат (#2864)
Почему: файлы в main были отформатированы старым ruff (хук 0.7.4), а после
#3021 хук, CI и `uv run ruff format` — на 0.15.20. Без нормализации каждый
коммит, касающийся одного из этих 161 файлов, тащил бы посторонние хунки
на нетронутых строках (длинные assert-сообщения и т.п.).

Что: `ruff format` 0.15.20 по backend/ и tradein-mvp/backend/; ничего,
кроме форматирования (958+/1034−). `ruff check` 0.15.20 зелёный на обоих
проектах, повторный `format --check` — 1001 файл без изменений
(идемпотентно), compileall чистый.

Refs #2864
2026-08-21 16:39:28 +05:00

139 lines
5.7 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.

"""#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 вместо данных БД"