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:
@@ -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"}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
# --- Хелперы ---------------------------------------------------------------
|
# --- Хелперы ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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) ------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user