From 0a135896128c3bb46c0eb3c4d4a338aff853e658 Mon Sep 17 00:00:00 2001 From: Max Ronzhin Date: Mon, 10 Aug 2026 16:08:56 +0300 Subject: [PATCH] =?UTF-8?q?fix(tests):=20=D0=B8=D0=B7=D0=BE=D0=BB=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D1=82=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D1=8B=20=D0=BE=D1=82=20instance=5Fsettings=20=D0=B2=20=D0=BE?= =?UTF-8?q?=D0=B1=D1=89=D0=B5=D0=B9=20dev-=D0=91=D0=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Тесты читали ту же 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 — единый список управляемых ключей вместо локальной копии в тестовом файле. --- backend/services/instance_settings.py | 20 +++++++++ backend/tests/conftest.py | 34 +++++++++++++- backend/tests/test_chat_ws.py | 13 +++++- backend/tests/test_hand_queue_ws.py | 10 ++++- backend/tests/test_instance_settings.py | 59 +++---------------------- 5 files changed, 80 insertions(+), 56 deletions(-) diff --git a/backend/services/instance_settings.py b/backend/services/instance_settings.py index afb45a4..cf3df90 100644 --- a/backend/services/instance_settings.py +++ b/backend/services/instance_settings.py @@ -69,6 +69,26 @@ BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = ( `scripts/apply_preset_settings.py`, чтобы не дублировать список строковых имён ключей `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_SUMMARY_RECIPIENTS_VALUE = {"mode": "all"} _DEFAULT_DISPLAY_TIMEZONE_VALUE = {"tz": "Europe/Moscow"} diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index ae4cca8..71684d0 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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: diff --git a/backend/tests/test_chat_ws.py b/backend/tests/test_chat_ws.py index 8a4116e..45bf021 100644 --- a/backend/tests/test_chat_ws.py +++ b/backend/tests/test_chat_ws.py @@ -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 + + # --- Хелперы --------------------------------------------------------------- diff --git a/backend/tests/test_hand_queue_ws.py b/backend/tests/test_hand_queue_ws.py index 1755085..a12a478 100644 --- a/backend/tests/test_hand_queue_ws.py +++ b/backend/tests/test_hand_queue_ws.py @@ -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) ------------------------------------ diff --git a/backend/tests/test_instance_settings.py b/backend/tests/test_instance_settings.py index 8438224..fc8a9c4 100644 --- a/backend/tests/test_instance_settings.py +++ b/backend/tests/test_instance_settings.py @@ -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: