fix(tests): изолировать тесты от instance_settings в общей dev-БД

Тесты читали ту же instance_settings, что и dev-стенд: выключение
chat/hand_queue в админке роняло пачку тестов, не связанных с самим
переключением (наступила сессия 0.0.28). Фикстура clean_instance_settings
(явная, не autouse — DELETE в savepoint держит блокировку строки до
конца внешней транзакции теста, автовключение на тестах с отдельными
подключениями к БД дало саморазблокировку) удаляет управляемые ключи
перед тестом, чтение конфигурации падает на дефолты pydantic-моделей.
Подключена в test_chat_ws.py и test_hand_queue_ws.py.

MANAGED_KEYS в services/instance_settings.py — единый список управляемых
ключей вместо локальной копии в тестовом файле.
This commit is contained in:
2026-08-10 16:08:56 +03:00
parent 6554fa242b
commit 0a13589612
5 changed files with 80 additions and 56 deletions

View File

@@ -69,6 +69,26 @@ BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = (
`scripts/apply_preset_settings.py`, чтобы не дублировать список строковых `scripts/apply_preset_settings.py`, чтобы не дублировать список строковых
имён ключей `instance_settings`.""" имён ключей `instance_settings`."""
MANAGED_KEYS: tuple[str, ...] = (
_KEY_TRANSCRIBER,
_KEY_SUMMARIZER,
_KEY_CHAT,
_KEY_HAND_QUEUE,
_KEY_AI_LEVEL,
_KEY_SUMMARY_RECIPIENTS,
_KEY_DISPLAY_TIMEZONE,
_KEY_REGISTRATION_TEAM_CHOICE,
_KEY_REGISTRATION_EMAIL_DOMAIN,
_KEY_CONTACT_EMAIL,
_KEY_MEDIA_LIMITS,
_KEY_CONSENT_POLICY,
_KEY_DEVICE_CHECK,
_KEY_VIRTUAL_BACKGROUND,
)
"""Все ключи, которыми управляет `InstanceSettingsService` — единый источник истины
для тестовой изоляции от состояния `instance_settings` в общей dev-БД
(`tests/conftest.py::clean_instance_settings`)."""
_DEFAULT_AI_LEVEL_VALUE = {"level": "min"} _DEFAULT_AI_LEVEL_VALUE = {"level": "min"}
_DEFAULT_SUMMARY_RECIPIENTS_VALUE = {"mode": "all"} _DEFAULT_SUMMARY_RECIPIENTS_VALUE = {"mode": "all"}
_DEFAULT_DISPLAY_TIMEZONE_VALUE = {"tz": "Europe/Moscow"} _DEFAULT_DISPLAY_TIMEZONE_VALUE = {"tz": "Europe/Moscow"}

View File

@@ -21,10 +21,11 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession
from starlette.types import Message, Scope from starlette.types import Message, Scope
from core.db import engine, get_session from core.db import engine, get_metrics_session, get_session
from core.redis import redis_client from core.redis import redis_client
from main import create_app from main import create_app
from models.instance_setting import InstanceSetting from models.instance_setting import InstanceSetting
from services.instance_settings import MANAGED_KEYS
@pytest_asyncio.fixture(autouse=True) @pytest_asyncio.fixture(autouse=True)
@@ -88,6 +89,37 @@ async def _load_committed_instance_settings() -> dict[str, Any]:
return {key: value for key, value in result.all()} return {key: value for key, value in result.all()}
@pytest_asyncio.fixture
async def clean_instance_settings(db_session: AsyncSession) -> AsyncGenerator[None, None]:
"""Изолировать тест от текущего состояния управляемых ключей `instance_settings` в
общей dev-БД (тоггл чата, поднятия руки и т.п. — живые настройки разработчика, а не
тестовые данные).
Удаляет строки `MANAGED_KEYS` внутри savepoint-транзакции теста (`db_session`) —
последующее чтение конфигурации (`InstanceSettingsService.get`/`load_effective_config`)
падает на дефолты pydantic-моделей (например, `ChatConfig.enabled == True`), одинаковые
независимо от того, что реально сохранено в dev-БД в момент прогона. Savepoint
откатывается в `db_connection` по завершении теста — восстанавливать исходные строки
вручную не нужно, в отличие от `_preserve_instance_settings` (та фикстура страхует от
записи МИМО savepoint, эта — от чтения ИЗ него состояния, унаследованного от dev-БД).
НЕ autouse и намеренно: `DELETE` внутри savepoint держит Postgres-блокировку на
строке до конца ВНЕШНЕЙ транзакции теста (`db_connection`, откатывается только в
teardown) — savepoint её не освобождает раньше срока. Тесты, которые параллельно
внутри СЕБЯ же пишут в те же ключи через ОТДЕЛЬНОЕ реальное подключение
(`async_session_maker`/`engine.connect()` — см. `test_transcription_disabled_setting_
stops_run_pipeline`, `clean_bootstrap_managed_keys`), заблокировались бы сами на себе,
если бы эта фикстура применялась к ним автоматически (наступили при первой попытке
сделать её autouse — само-дедлок, тест висел до ручного убийства процесса). Поэтому
запрашивать явно, только в тестах, где именно ОНА обеспечивает изоляцию (WS чата/
очереди рук и т.п.), а не там, где тест сам управляет состоянием через реальные
коммиты.
"""
await db_session.execute(delete(InstanceSetting).where(InstanceSetting.key.in_(MANAGED_KEYS)))
await db_session.commit()
yield
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def db_connection() -> AsyncGenerator[AsyncConnection, None]: async def db_connection() -> AsyncGenerator[AsyncConnection, None]:
async with engine.connect() as connection: async with engine.connect() as connection:

View File

@@ -7,13 +7,14 @@ websocket-тестов поверх нашей savepoint-сессии БД (см
""" """
import uuid import uuid
from collections.abc import Callable from collections.abc import AsyncGenerator, Callable
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
import httpx import httpx
import jwt import jwt
import pytest import pytest
import pytest_asyncio
from pydantic import ValidationError from pydantic import ValidationError
from sqlalchemy import select, text from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -37,6 +38,16 @@ from tests.conftest import ASGIWebSocketSession
WSFactory = Callable[[str], ASGIWebSocketSession] WSFactory = Callable[[str], ASGIWebSocketSession]
@pytest_asyncio.fixture(autouse=True)
async def _isolated_instance_settings(clean_instance_settings: None) -> AsyncGenerator[None, None]:
"""Autouse только в этом модуле — изолирует тесты от состояния `chat`/`hand_queue`
(и остальных управляемых ключей) в общей dev-БД (см. `tests.conftest.clean_instance_settings`,
почему не сделана глобально autouse). Безопасно именно здесь: ни один тест файла не
открывает отдельного подключения к `instance_settings` — только `db_session`/`client`.
"""
yield
# --- Хелперы --------------------------------------------------------------- # --- Хелперы ---------------------------------------------------------------

View File

@@ -5,10 +5,11 @@
""" """
import uuid import uuid
from collections.abc import Callable from collections.abc import AsyncGenerator, Callable
from typing import Any from typing import Any
import httpx import httpx
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from core.security import create_access_token, hash_password from core.security import create_access_token, hash_password
@@ -23,6 +24,13 @@ from tests.conftest import ASGIWebSocketSession
WSFactory = Callable[[str], ASGIWebSocketSession] WSFactory = Callable[[str], ASGIWebSocketSession]
@pytest_asyncio.fixture(autouse=True)
async def _isolated_instance_settings(clean_instance_settings: None) -> AsyncGenerator[None, None]:
"""Autouse только в этом модуле — см. `tests.test_chat_ws._isolated_instance_settings`
и `tests.conftest.clean_instance_settings` (почему не глобальный autouse)."""
yield
# --- Хелперы (см. tests/test_chat_ws.py) ------------------------------------ # --- Хелперы (см. tests/test_chat_ws.py) ------------------------------------

View File

@@ -12,15 +12,12 @@
видеть реально закоммиченную строку (см. docstring `test_pipeline.py`), видеть реально закоммиченную строку (см. docstring `test_pipeline.py`),
поэтому там запись делается через отдельное подключение к `core.db.engine`. поэтому там запись делается через отдельное подключение к `core.db.engine`.
Тесты, которым нужен «чистый стол» по управляемым ключам `instance_settings` «Чистый стол» по управляемым ключам `instance_settings` обеспечивает общая (НЕ
(бутстрап дефолтов, дефолтные значения после патча), используют фикстуру autouse — см. её докстринг про само-дедлок с тестами на реальных подключениях)
`clean_instance_settings` — она сохраняет текущие строки этих ключей в фикстура `tests.conftest.clean_instance_settings` — тесты, которым нужен чистый
рамках транзакции теста и восстанавливает их после (не `TRUNCATE`): в общей стол, запрашивают её явно параметром. Тест с `run_pipeline_async` пишет через
dev-БД эти строки могут быть легитимными данными разработчика, тест не отдельное подключение (мимо savepoint) и её не запрашивает — там сохранение/
должен от них зависеть, но и не должен их безвозвратно стирать. Тест с восстановление сделано вручную через реальный коннекшн, см. его докстринг.
`run_pipeline_async` не может использовать эту фикстуру (пишет через
отдельное подключение) — там то же сохранение/восстановление сделано вручную
через реальный коннекшн.
""" """
import uuid import uuid
@@ -61,24 +58,6 @@ from workers.tasks.pipeline import run_pipeline_async
PLUGINS_YAML = "../config/plugins.yaml" PLUGINS_YAML = "../config/plugins.yaml"
NOW = datetime.now(UTC) NOW = datetime.now(UTC)
# Все ключи, которыми управляет `InstanceSettingsService` (см. `_KEY_*` там же).
_MANAGED_KEYS = (
"transcriber",
"summarizer",
"chat",
"hand_queue",
"ai_level",
"summary_recipients",
"display_timezone",
"registration_team_choice",
"registration_email_domain",
"contact_email",
"media_limits",
"consent_policy",
"device_check",
"virtual_background",
)
class _FakeTask: class _FakeTask:
"""Минимальная заглушка bound-задачи Celery (см. `test_pipeline.py`).""" """Минимальная заглушка bound-задачи Celery (см. `test_pipeline.py`)."""
@@ -87,32 +66,6 @@ class _FakeTask:
self.retry = MagicMock() self.retry = MagicMock()
@pytest_asyncio.fixture
async def clean_instance_settings(db_session: AsyncSession) -> AsyncGenerator[None, None]:
"""Изолировать тест от уже существующих строк управляемых ключей `instance_settings`.
Сохраняет текущие значения (если есть) в рамках `db_session` (savepoint,
никогда не коммитится в реальную БД — см. `conftest.py`), удаляет их,
отдаёт управление тесту, затем восстанавливает исходные значения —
точечно, только эти ключи, не `TRUNCATE`.
"""
result = await db_session.execute(
select(InstanceSetting).where(InstanceSetting.key.in_(_MANAGED_KEYS))
)
saved: dict[str, Any] = {row.key: row.value for row in result.scalars().all()}
await db_session.execute(delete(InstanceSetting).where(InstanceSetting.key.in_(_MANAGED_KEYS)))
await db_session.commit()
try:
yield
finally:
await db_session.execute(
delete(InstanceSetting).where(InstanceSetting.key.in_(_MANAGED_KEYS))
)
for key, value in saved.items():
db_session.add(InstanceSetting(key=key, value=value))
await db_session.commit()
async def test_ensure_bootstrapped_imports_yaml_defaults( async def test_ensure_bootstrapped_imports_yaml_defaults(
db_session: AsyncSession, clean_instance_settings: None db_session: AsyncSession, clean_instance_settings: None
) -> None: ) -> None: