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

@@ -21,10 +21,11 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession
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 main import create_app
from models.instance_setting import InstanceSetting
from services.instance_settings import MANAGED_KEYS
@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()}
@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
async def db_connection() -> AsyncGenerator[AsyncConnection, None]:
async with engine.connect() as connection:

View File

@@ -7,13 +7,14 @@ websocket-тестов поверх нашей savepoint-сессии БД (см
"""
import uuid
from collections.abc import Callable
from collections.abc import AsyncGenerator, Callable
from datetime import UTC, datetime
from typing import Any
import httpx
import jwt
import pytest
import pytest_asyncio
from pydantic import ValidationError
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
@@ -37,6 +38,16 @@ from tests.conftest import 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
from collections.abc import Callable
from collections.abc import AsyncGenerator, Callable
from typing import Any
import httpx
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession
from core.security import create_access_token, hash_password
@@ -23,6 +24,13 @@ from tests.conftest import 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) ------------------------------------

View File

@@ -12,15 +12,12 @@
видеть реально закоммиченную строку (см. docstring `test_pipeline.py`),
поэтому там запись делается через отдельное подключение к `core.db.engine`.
Тесты, которым нужен «чистый стол» по управляемым ключам `instance_settings`
(бутстрап дефолтов, дефолтные значения после патча), используют фикстуру
`clean_instance_settings` — она сохраняет текущие строки этих ключей в
рамках транзакции теста и восстанавливает их после (не `TRUNCATE`): в общей
dev-БД эти строки могут быть легитимными данными разработчика, тест не
должен от них зависеть, но и не должен их безвозвратно стирать. Тест с
`run_pipeline_async` не может использовать эту фикстуру (пишет через
отдельное подключение) — там то же сохранение/восстановление сделано вручную
через реальный коннекшн.
«Чистый стол» по управляемым ключам `instance_settings` обеспечивает общая (НЕ
autouse — см. её докстринг про само-дедлок с тестами на реальных подключениях)
фикстура `tests.conftest.clean_instance_settings` — тесты, которым нужен чистый
стол, запрашивают её явно параметром. Тест с `run_pipeline_async` пишет через
отдельное подключение (мимо savepoint) и её не запрашивает — там сохранение/
восстановление сделано вручную через реальный коннекшн, см. его докстринг.
"""
import uuid
@@ -61,24 +58,6 @@ from workers.tasks.pipeline import run_pipeline_async
PLUGINS_YAML = "../config/plugins.yaml"
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:
"""Минимальная заглушка bound-задачи Celery (см. `test_pipeline.py`)."""
@@ -87,32 +66,6 @@ class _FakeTask:
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(
db_session: AsyncSession, clean_instance_settings: None
) -> None: