"""Тесты хранилища настроек инстанса (`services.instance_settings`). Бутстрап из yaml однократен (повторный вызов не перетирает правки администратора); fallback воркера на yaml при пустой таблице; `transcription_enabled=false` в БД → следующий `run_pipeline` останавливается после чтения конфигурации без ошибок; частичный PUT. Тесты сервисного уровня (`db_session`, savepoint-транзакция — не видна другим подключениям, никогда не коммитится в реальную БД, см. `conftest.py`) используются везде, кроме интеграционного теста с `run_pipeline_async` — та задача открывает собственное подключение (`workers.db.open_session`) и должна видеть реально закоммиченную строку (см. docstring `test_pipeline.py`), поэтому там запись делается через отдельное подключение к `core.db.engine`. Тесты, которым нужен «чистый стол» по управляемым ключам `instance_settings` (бутстрап дефолтов, дефолтные значения после патча), используют фикстуру `clean_instance_settings` — она сохраняет текущие строки этих ключей в рамках транзакции теста и восстанавливает их после (не `TRUNCATE`): в общей dev-БД эти строки могут быть легитимными данными разработчика, тест не должен от них зависеть, но и не должен их безвозвратно стирать. Тест с `run_pipeline_async` не может использовать эту фикстуру (пишет через отдельное подключение) — там то же сохранение/восстановление сделано вручную через реальный коннекшн. """ import uuid from collections.abc import AsyncGenerator from datetime import UTC, datetime, timedelta from typing import Any from unittest.mock import MagicMock import pytest import pytest_asyncio from sqlalchemy import delete, select, text from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession from core.config import get_settings from core.db import async_session_maker, engine from core.plugins.config import InstanceConfig, load_plugins_config from models.instance_setting import InstanceSetting from scripts import apply_preset_settings as apply_preset_settings_module from services.ai_levels import detect_ai_levels from services.ai_tiers import TIERS from services.conference_ids import generate_number, generate_slug from services.instance_settings import ( BOOTSTRAP_MANAGED_KEYS, BootstrapOverrides, InstanceSettingsService, InvalidAiLevelError, InvalidContactEmailError, InvalidEmailDomainError, InvalidTimezoneError, SettingsUpdateIn, load_effective_config, ) from workers.tasks import pipeline as pipeline_module 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", ) class _FakeTask: """Минимальная заглушка bound-задачи Celery (см. `test_pipeline.py`).""" def __init__(self) -> None: 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: service = InstanceSettingsService(db_session) await service.ensure_bootstrapped(PLUGINS_YAML) rows = (await db_session.execute(select(InstanceSetting))).scalars().all() keys = {row.key for row in rows} assert keys == { "transcriber", "summarizer", "chat", "hand_queue", "ai_level", "summary_recipients", "display_timezone", "registration_team_choice", "registration_email_domain", "contact_email", "media_limits", } cfg = await service.get() assert cfg.transcriber.provider == "faster_whisper_cpu" assert cfg.ai_level == "min" # Дефолт обязан сохранять поведение существующих инсталляций — модуль # «поднятие руки» был доступен всегда, тоггл включён по умолчанию. assert cfg.hand_queue.enabled is True assert cfg.summary_recipients == "all" assert cfg.display_timezone == "Europe/Moscow" assert cfg.registration_team_choice is False assert cfg.registration_email_domain_enabled is False assert cfg.registration_email_domains == [] assert cfg.contact_email_enabled is False assert cfg.contact_email is None # Дефолты сохраняют текущее (до появления настройки) поведение — # без ограничения качества и с максимумом сетки, равным фактическому # потолку `StageGrid` (5×5). assert cfg.media_limits.publish_quality_cap == "off" assert cfg.media_limits.stage_max_tiles == 25 async def test_ensure_bootstrapped_is_idempotent_and_keeps_admin_edits( db_session: AsyncSession, clean_instance_settings: None ) -> None: service = InstanceSettingsService(db_session) await service.ensure_bootstrapped(PLUGINS_YAML) await service.update(SettingsUpdateIn(display_timezone="Asia/Yekaterinburg")) # Повторный бутстрап (например, рестарт backend) не должен перетереть # уже сделанную администратором правку — `ON CONFLICT DO NOTHING`. await service.ensure_bootstrapped(PLUGINS_YAML) cfg = await service.get() assert cfg.display_timezone == "Asia/Yekaterinburg" async def test_update_hand_queue_enabled( db_session: AsyncSession, clean_instance_settings: None ) -> None: service = InstanceSettingsService(db_session) await service.ensure_bootstrapped(PLUGINS_YAML) cfg = await service.update(SettingsUpdateIn(hand_queue_enabled=False)) assert cfg.hand_queue.enabled is False cfg = await service.get() assert cfg.hand_queue.enabled is False cfg = await service.update(SettingsUpdateIn(hand_queue_enabled=True)) assert cfg.hand_queue.enabled is True @pytest.mark.parametrize( ("preset", "chat_enabled", "ai_enabled", "ai_level"), [ (1, False, False, "min"), (2, True, False, "min"), (3, True, True, "min"), (4, True, True, "medium"), (5, True, True, "max"), ], ) async def test_ensure_bootstrapped_applies_installer_preset_matrix( db_session: AsyncSession, clean_instance_settings: None, preset: int, chat_enabled: bool, ai_enabled: bool, ai_level: str, ) -> None: """Матрица «пресет → настройки»: чистая БД бутстрапится настройками пресета, а не всегда-включёнными дефолтами `plugins.yaml`.""" service = InstanceSettingsService(db_session) overrides = BootstrapOverrides( chat_enabled=chat_enabled, ai_enabled=ai_enabled, ai_level=ai_level ) await service.ensure_bootstrapped(PLUGINS_YAML, overrides) cfg = await service.get() assert cfg.chat.enabled is chat_enabled, f"пресет {preset}: chat.enabled" assert cfg.transcriber.enabled is ai_enabled, f"пресет {preset}: transcriber.enabled" assert cfg.summarizer.enabled is ai_enabled, f"пресет {preset}: summarizer.enabled" assert cfg.ai_level == ai_level, f"пресет {preset}: ai_level" async def test_ensure_bootstrapped_overrides_do_not_touch_existing_rows( db_session: AsyncSession, clean_instance_settings: None ) -> None: """Повторный запуск инсталлятора (другой пресет) на живой инсталляции не перетирает уже существующие строки — только чистая БД получает overrides (`ON CONFLICT DO NOTHING` сохраняется, см. `ensure_bootstrapped`).""" service = InstanceSettingsService(db_session) await service.ensure_bootstrapped(PLUGINS_YAML) # дефолты plugins.yaml — чат включён await service.update(SettingsUpdateIn(chat_enabled=False)) # ручная правка администратора preset_1_overrides = BootstrapOverrides(chat_enabled=True, ai_enabled=False, ai_level="min") await service.ensure_bootstrapped(PLUGINS_YAML, preset_1_overrides) cfg = await service.get() assert cfg.chat.enabled is False async def test_load_effective_config_falls_back_to_yaml_when_table_empty( db_session: AsyncSession, clean_instance_settings: None ) -> None: """Таблица `instance_settings` пуста (воркер стартовал раньше бутстрапа backend).""" cfg = await load_effective_config(db_session) assert isinstance(cfg, InstanceConfig) assert cfg.transcriber.provider == "faster_whisper_cpu" assert cfg.ai_level == "min" async def test_load_effective_config_applies_tier_override_for_medium_and_max( db_session: AsyncSession, clean_instance_settings: None ) -> None: """При `ai_level` `medium`/`max` `load_effective_config` подставляет провайдера/модель из `TIERS[level]` (ADR-004), а не дефолты `plugins.yaml`. Запись `ai_level` — напрямую в `db_session` (не через `service.update`, которая отклонила бы недоступный на этой машине уровень) — здесь проверяется чтение/подстановка, а не бизнес-правило доступности (см. `test_ai_levels.py`, `test_update_rejects_unavailable_ai_level`).""" service = InstanceSettingsService(db_session) await service.ensure_bootstrapped(PLUGINS_YAML) stmt = ( pg_insert(InstanceSetting) .values(key="ai_level", value={"level": "medium"}) .on_conflict_do_update(index_elements=["key"], set_={"value": {"level": "medium"}}) ) await db_session.execute(stmt) await db_session.commit() cfg = await load_effective_config(db_session) assert cfg.transcriber.provider == TIERS["medium"].transcriber.provider assert cfg.transcriber.model == TIERS["medium"].transcriber.model assert cfg.summarizer.provider == "qwen_local" assert cfg.summarizer.model == TIERS["medium"].summarizer.model assert ( cfg.summarizer.options["max_tokens_reduce"] == TIERS["medium"].summarizer.options["max_tokens_reduce"] ) async def test_load_effective_config_min_level_keeps_yaml_defaults( db_session: AsyncSession, clean_instance_settings: None ) -> None: """`ai_level=min` (дефолт бутстрапа) не подменяется `TIERS` — используется конфигурация из `plugins.yaml`/правок администратора как раньше.""" service = InstanceSettingsService(db_session) await service.ensure_bootstrapped(PLUGINS_YAML) cfg = await load_effective_config(db_session) assert cfg.transcriber.provider == "faster_whisper_cpu" assert cfg.summarizer.provider == "null" # дефолт plugins.yaml вне подмены async def test_load_effective_config_tier_override_preserves_enabled_flag( db_session: AsyncSession, clean_instance_settings: None ) -> None: """Подмена `TIERS` не включает повторно транскрибацию/суммаризацию, отключённую администратором (`transcription_enabled=false`) — флаг `enabled` сохраняется.""" service = InstanceSettingsService(db_session) await service.ensure_bootstrapped(PLUGINS_YAML) await service.update(SettingsUpdateIn(transcription_enabled=False)) stmt = ( pg_insert(InstanceSetting) .values(key="ai_level", value={"level": "max"}) .on_conflict_do_update(index_elements=["key"], set_={"value": {"level": "max"}}) ) await db_session.execute(stmt) await db_session.commit() cfg = await load_effective_config(db_session) assert cfg.transcriber.provider == TIERS["max"].transcriber.provider assert cfg.transcriber.enabled is False assert cfg.summarizer.enabled is False async def test_partial_update_changes_only_requested_fields( db_session: AsyncSession, clean_instance_settings: None ) -> None: service = InstanceSettingsService(db_session) await service.ensure_bootstrapped(PLUGINS_YAML) cfg = await service.update(SettingsUpdateIn(chat_enabled=False)) assert cfg.chat.enabled is False # Остальные поля не тронуты дефолтами бутстрапа. assert cfg.transcriber.enabled is True assert cfg.summarizer.enabled is True assert cfg.ai_level == "min" assert cfg.summary_recipients == "all" assert cfg.display_timezone == "Europe/Moscow" # Изменение сохранилось в БД (не только в возвращённом объекте). reloaded = await service.get() assert reloaded.chat.enabled is False async def test_registration_team_choice_toggle( db_session: AsyncSession, clean_instance_settings: None ) -> None: """`registration_team_choice` — дефолт `False`, `PUT`-патч переключает и сохраняется.""" service = InstanceSettingsService(db_session) await service.ensure_bootstrapped(PLUGINS_YAML) assert (await service.get()).registration_team_choice is False cfg = await service.update(SettingsUpdateIn(registration_team_choice=True)) assert cfg.registration_team_choice is True reloaded = await service.get() assert reloaded.registration_team_choice is True async def test_registration_email_domain_enable_without_domain_rejected( db_session: AsyncSession, clean_instance_settings: None ) -> None: """Включение верификации без доменов (ни в патче, ни ранее сохранённых) → 400.""" service = InstanceSettingsService(db_session) await service.ensure_bootstrapped(PLUGINS_YAML) with pytest.raises(InvalidEmailDomainError): await service.update(SettingsUpdateIn(registration_email_domain_enabled=True)) cfg = await service.get() assert cfg.registration_email_domain_enabled is False assert cfg.registration_email_domains == [] async def test_registration_email_domain_enable_with_empty_list_rejected( db_session: AsyncSession, clean_instance_settings: None ) -> None: """Пустой список доменов при включении верификации — та же ошибка, что и отсутствие поля (не молчаливое отключение проверки).""" service = InstanceSettingsService(db_session) await service.ensure_bootstrapped(PLUGINS_YAML) with pytest.raises(InvalidEmailDomainError): await service.update( SettingsUpdateIn(registration_email_domain_enabled=True, registration_email_domains=[]) ) cfg = await service.get() assert cfg.registration_email_domain_enabled is False async def test_registration_email_domain_rejects_invalid_pattern( db_session: AsyncSession, clean_instance_settings: None ) -> None: service = InstanceSettingsService(db_session) await service.ensure_bootstrapped(PLUGINS_YAML) with pytest.raises(InvalidEmailDomainError): await service.update(SettingsUpdateIn(registration_email_domains=["not a domain"])) cfg = await service.get() assert cfg.registration_email_domains == [] async def test_registration_email_domain_normalizes_input( db_session: AsyncSession, clean_instance_settings: None ) -> None: """`@Corp.RU ` нормализуется в `corp.ru` (strip, убрать «@», lower); пустые строки отбрасываются, дубликаты схлопываются.""" service = InstanceSettingsService(db_session) await service.ensure_bootstrapped(PLUGINS_YAML) cfg = await service.update( SettingsUpdateIn( registration_email_domain_enabled=True, registration_email_domains=["@Corp.RU ", "", "corp.ru", "Acme.IO"], ) ) assert cfg.registration_email_domain_enabled is True assert cfg.registration_email_domains == ["corp.ru", "acme.io"] reloaded = await service.get() assert reloaded.registration_email_domains == ["corp.ru", "acme.io"] async def test_registration_email_domain_can_be_disabled_keeping_stored_domains( db_session: AsyncSession, clean_instance_settings: None ) -> None: """Выключение верификации без передачи доменов не требует их и не роняет валидацию.""" service = InstanceSettingsService(db_session) await service.ensure_bootstrapped(PLUGINS_YAML) await service.update( SettingsUpdateIn( registration_email_domain_enabled=True, registration_email_domains=["acme.io"] ) ) cfg = await service.update(SettingsUpdateIn(registration_email_domain_enabled=False)) assert cfg.registration_email_domain_enabled is False assert cfg.registration_email_domains == ["acme.io"] async def test_registration_email_domain_reads_legacy_single_domain_shape( db_session: AsyncSession, clean_instance_settings: None ) -> None: """До многодоменной поддержки в БД хранилась форма `{"domain": str|None}` (один домен, без миграции на проде) — чтение должно разворачивать её в список из одного элемента (см. `_extract_email_domains`).""" service = InstanceSettingsService(db_session) await service.ensure_bootstrapped(PLUGINS_YAML) stmt = ( pg_insert(InstanceSetting) .values( key="registration_email_domain", value={"enabled": True, "domain": "legacy.example"}, ) .on_conflict_do_update( index_elements=["key"], set_={"value": {"enabled": True, "domain": "legacy.example"}}, ) ) await db_session.execute(stmt) await db_session.commit() cfg = await service.get() assert cfg.registration_email_domain_enabled is True assert cfg.registration_email_domains == ["legacy.example"] async def test_registration_email_domain_rewrites_legacy_shape_on_update( db_session: AsyncSession, clean_instance_settings: None ) -> None: """Первое же сохранение после чтения старой формы переписывает строку в новую (`domains: [...]`), а не оставляет legacy `domain` рядом.""" service = InstanceSettingsService(db_session) await service.ensure_bootstrapped(PLUGINS_YAML) stmt = ( pg_insert(InstanceSetting) .values( key="registration_email_domain", value={"enabled": True, "domain": "legacy.example"}, ) .on_conflict_do_update( index_elements=["key"], set_={"value": {"enabled": True, "domain": "legacy.example"}}, ) ) await db_session.execute(stmt) await db_session.commit() await service.update(SettingsUpdateIn(registration_email_domains=["new.example"])) row = await db_session.get(InstanceSetting, "registration_email_domain") assert row is not None assert row.value == {"enabled": True, "domains": ["new.example"]} async def test_contact_email_enable_without_email_rejected( db_session: AsyncSession, clean_instance_settings: None ) -> None: """Включение контактного адреса без email (ни в патче, ни ранее сохранённого) → 400.""" service = InstanceSettingsService(db_session) await service.ensure_bootstrapped(PLUGINS_YAML) with pytest.raises(InvalidContactEmailError): await service.update(SettingsUpdateIn(contact_email_enabled=True)) cfg = await service.get() assert cfg.contact_email_enabled is False assert cfg.contact_email is None async def test_contact_email_rejects_invalid_format( db_session: AsyncSession, clean_instance_settings: None ) -> None: service = InstanceSettingsService(db_session) await service.ensure_bootstrapped(PLUGINS_YAML) with pytest.raises(InvalidContactEmailError): await service.update(SettingsUpdateIn(contact_email="not an email")) cfg = await service.get() assert cfg.contact_email is None async def test_contact_email_normalizes_input( db_session: AsyncSession, clean_instance_settings: None ) -> None: """` Contact@VidConf.RU ` нормализуется в `contact@vidconf.ru` (strip, lower).""" service = InstanceSettingsService(db_session) await service.ensure_bootstrapped(PLUGINS_YAML) cfg = await service.update( SettingsUpdateIn(contact_email_enabled=True, contact_email=" Contact@VidConf.RU ") ) assert cfg.contact_email_enabled is True assert cfg.contact_email == "contact@vidconf.ru" reloaded = await service.get() assert reloaded.contact_email == "contact@vidconf.ru" async def test_contact_email_can_be_disabled_keeping_stored_email( db_session: AsyncSession, clean_instance_settings: None ) -> None: """Выключение контактного адреса без передачи email не требует его и не роняет валидацию.""" service = InstanceSettingsService(db_session) await service.ensure_bootstrapped(PLUGINS_YAML) await service.update( SettingsUpdateIn(contact_email_enabled=True, contact_email="contact@acme.io") ) cfg = await service.update(SettingsUpdateIn(contact_email_enabled=False)) assert cfg.contact_email_enabled is False assert cfg.contact_email == "contact@acme.io" async def test_transcription_enabled_flag_toggles_both_transcriber_and_summarizer( db_session: AsyncSession, clean_instance_settings: None ) -> None: service = InstanceSettingsService(db_session) await service.ensure_bootstrapped(PLUGINS_YAML) cfg = await service.update(SettingsUpdateIn(transcription_enabled=False)) assert cfg.transcriber.enabled is False assert cfg.summarizer.enabled is False async def test_media_limits_partial_update_keeps_untouched_field( db_session: AsyncSession, clean_instance_settings: None ) -> None: """Патч только одного поля `media_limits` не сбрасывает соседнее — оба поля живут в одной строке JSON, `update()` обязан подставлять текущее значение несменённого поля, а не дефолт модели.""" service = InstanceSettingsService(db_session) await service.ensure_bootstrapped(PLUGINS_YAML) cfg = await service.update(SettingsUpdateIn(publish_quality_cap="360p")) assert cfg.media_limits.publish_quality_cap == "360p" assert cfg.media_limits.stage_max_tiles == 25 # дефолт не тронут cfg = await service.update(SettingsUpdateIn(stage_max_tiles=9)) assert cfg.media_limits.stage_max_tiles == 9 assert cfg.media_limits.publish_quality_cap == "360p" # предыдущая правка сохранилась reloaded = await service.get() assert reloaded.media_limits.publish_quality_cap == "360p" assert reloaded.media_limits.stage_max_tiles == 9 async def test_update_rejects_unavailable_ai_level( db_session: AsyncSession, clean_instance_settings: None ) -> None: service = InstanceSettingsService(db_session) await service.ensure_bootstrapped(PLUGINS_YAML) assert not detect_ai_levels(await service.get())[1].available # "medium" недоступен with pytest.raises(InvalidAiLevelError): await service.update(SettingsUpdateIn(ai_level="medium")) # Настройка не изменилась. cfg = await service.get() assert cfg.ai_level == "min" async def test_update_unrelated_setting_passes_when_current_ai_level_unavailable( db_session: AsyncSession, clean_instance_settings: None ) -> None: """Баг: фронт всегда отправляет текущий `ai_level` вместе с любой другой правкой (см. `AdminSettingsTab.handleSave`) — если уже сохранённый уровень стал недоступен (слабое железо/модель не докачана), это не должно блокировать сохранение несвязанной настройки. Валидация нужна только при фактической смене уровня (см. docstring `update`).""" service = InstanceSettingsService(db_session) await service.ensure_bootstrapped(PLUGINS_YAML) stmt = ( pg_insert(InstanceSetting) .values(key="ai_level", value={"level": "medium"}) .on_conflict_do_update(index_elements=["key"], set_={"value": {"level": "medium"}}) ) await db_session.execute(stmt) await db_session.commit() assert not detect_ai_levels(await service.get())[1].available # "medium" недоступен # ai_level в патче совпадает с уже сохранённым — не должно проверяться. cfg = await service.update( SettingsUpdateIn(ai_level="medium", registration_team_choice=True) ) assert cfg.ai_level == "medium" assert cfg.registration_team_choice is True async def test_update_still_rejects_switching_to_unavailable_ai_level( db_session: AsyncSession, clean_instance_settings: None ) -> None: """Фактическая попытка ПЕРЕКЛЮЧИТЬ уровень на недоступный по-прежнему отклоняется — даже если AI (транскрибация/суммаризация) выключен: иначе администратор молча сохранит нерабочую конфигурацию, которая проявится только при последующем включении AI.""" service = InstanceSettingsService(db_session) await service.ensure_bootstrapped(PLUGINS_YAML) await service.update(SettingsUpdateIn(transcription_enabled=False)) with pytest.raises(InvalidAiLevelError): await service.update(SettingsUpdateIn(ai_level="medium")) cfg = await service.get() assert cfg.ai_level == "min" async def test_update_rejects_invalid_timezone( db_session: AsyncSession, clean_instance_settings: None ) -> None: service = InstanceSettingsService(db_session) await service.ensure_bootstrapped(PLUGINS_YAML) with pytest.raises(InvalidTimezoneError): await service.update(SettingsUpdateIn(display_timezone="Not/A_Timezone")) cfg = await service.get() assert cfg.display_timezone == "Europe/Moscow" async def test_transcription_disabled_setting_stops_run_pipeline( monkeypatch: pytest.MonkeyPatch, ) -> None: """`transcription_enabled=false`, закоммиченный в БД → `run_pipeline_async` без явного `plugins_config` читает эффективную конфигурацию сам и останавливается сразу, не трогая треки/статус, без исключений. Строка `instance_settings.transcriber` пишется через отдельное реальное подключение (не через `db_session` — см. докстринг модуля), поэтому её исходное значение (если было — в общей dev-БД это может быть легитимными данными разработчика) сохраняется до записи и точечно восстанавливается в `finally`, а не удаляется безусловно. """ conference_id = uuid.uuid4() session_id = uuid.uuid4() number = generate_number() slug = generate_slug() t_start = NOW - timedelta(minutes=10) async with engine.connect() as conn: await conn.execute( text( "INSERT INTO conferences (id, number, slug, title, status, is_pinned) " "VALUES (:id, :number, :slug, 'Effective config test', 'ended', false)" ), {"id": conference_id, "number": number, "slug": slug}, ) await conn.execute( text( "INSERT INTO conference_sessions (id, conference_id, title, t_start, t_end) " "VALUES (:id, :conference_id, 'Effective config session', :t_start, :t_end)" ), { "id": session_id, "conference_id": conference_id, "t_start": t_start, "t_end": NOW, }, ) await conn.commit() disabled_transcriber = { "enabled": False, "provider": "null", "model": None, "language": "ru", "options": {}, } async with async_session_maker() as setup_session: original_row = await setup_session.get(InstanceSetting, "transcriber") original_value: dict[str, Any] | None = ( None if original_row is None else dict(original_row.value) ) stmt = ( pg_insert(InstanceSetting) .values(key="transcriber", value=disabled_transcriber) .on_conflict_do_update(index_elements=["key"], set_={"value": disabled_transcriber}) ) await setup_session.execute(stmt) await setup_session.commit() mock_factory = MagicMock() monkeypatch.setattr(pipeline_module, "create_transcriber", mock_factory) try: await run_pipeline_async(_FakeTask(), session_id) mock_factory.assert_not_called() async with engine.connect() as conn: result = await conn.execute( text("SELECT pipeline_status FROM conference_sessions WHERE id = :id"), {"id": session_id}, ) assert result.scalar_one() == "recording" finally: async with engine.connect() as conn: await conn.execute( text("DELETE FROM conference_sessions WHERE conference_id = :id"), {"id": conference_id}, ) await conn.execute( text("DELETE FROM conferences WHERE id = :id"), {"id": conference_id} ) await conn.commit() async with async_session_maker() as restore_session: if original_value is None: await restore_session.execute( delete(InstanceSetting).where(InstanceSetting.key == "transcriber") ) else: restore_stmt = ( pg_insert(InstanceSetting) .values(key="transcriber", value=original_value) .on_conflict_do_update(index_elements=["key"], set_={"value": original_value}) ) await restore_session.execute(restore_stmt) await restore_session.commit() # --- scripts.apply_preset_settings (принудительное обновление живой БД) ----------------- # # `apply_preset_settings` открывает СОБСТВЕННУЮ сессию (`async_session_maker`, реальные # коммиты) — как и `run_pipeline_async` выше, не видит savepoint `db_session` теста и не # виден ей. Снимок 4 управляемых ключей снимается/восстанавливается отдельным реальным # подключением (тот же паттерн, что `test_transcription_disabled_setting_stops_run_pipeline`). @pytest_asyncio.fixture async def clean_bootstrap_managed_keys() -> AsyncGenerator[None, None]: """Сохранить и точечно восстановить 4 ключа `BOOTSTRAP_MANAGED_KEYS` вокруг теста `scripts.apply_preset_settings` (пишет реальными коммитами мимо savepoint `db_session`).""" async with async_session_maker() as session: result = await session.execute( select(InstanceSetting).where(InstanceSetting.key.in_(BOOTSTRAP_MANAGED_KEYS)) ) saved: dict[str, Any] = {row.key: row.value for row in result.scalars().all()} await session.execute( delete(InstanceSetting).where(InstanceSetting.key.in_(BOOTSTRAP_MANAGED_KEYS)) ) await session.commit() try: yield finally: async with async_session_maker() as session: await session.execute( delete(InstanceSetting).where(InstanceSetting.key.in_(BOOTSTRAP_MANAGED_KEYS)) ) for key, value in saved.items(): session.add(InstanceSetting(key=key, value=value)) await session.commit() def _set_bootstrap_env( monkeypatch: pytest.MonkeyPatch, *, chat_enabled: bool, ai_enabled: bool, ai_level: str ) -> None: monkeypatch.setenv("BOOTSTRAP_CHAT_ENABLED", str(chat_enabled).lower()) monkeypatch.setenv("BOOTSTRAP_TRANSCRIPTION_ENABLED", str(ai_enabled).lower()) monkeypatch.setenv("BOOTSTRAP_AI_LEVEL", ai_level) get_settings.cache_clear() async def _load_managed_rows() -> dict[str, Any]: async with async_session_maker() as session: result = await session.execute( select(InstanceSetting).where(InstanceSetting.key.in_(BOOTSTRAP_MANAGED_KEYS)) ) return {row.key: row.value for row in result.scalars().all()} async def test_apply_preset_settings_writes_missing_keys_without_force( monkeypatch: pytest.MonkeyPatch, clean_bootstrap_managed_keys: None ) -> None: """Без строк в БД (все 4 ключа «отсутствуют») `--force` не нужен — пишутся все 4.""" _set_bootstrap_env(monkeypatch, chat_enabled=False, ai_enabled=False, ai_level="min") try: written = await apply_preset_settings_module.apply_preset_settings(force=False) assert set(written) == set(BOOTSTRAP_MANAGED_KEYS) rows = await _load_managed_rows() assert rows["chat"]["enabled"] is False assert rows["transcriber"]["enabled"] is False assert rows["summarizer"]["enabled"] is False assert rows["ai_level"]["level"] == "min" finally: get_settings.cache_clear() async def test_apply_preset_settings_without_force_keeps_existing_keys( monkeypatch: pytest.MonkeyPatch, clean_bootstrap_managed_keys: None ) -> None: """Живая инсталляция (строки уже есть, возможно с ручными правками) — без `--force` ничего не перезаписывается, хотя переменные окружения просят другой пресет.""" plugins = load_plugins_config(PLUGINS_YAML) async with async_session_maker() as session: session.add(InstanceSetting(key="chat", value={"enabled": False})) session.add( InstanceSetting(key="transcriber", value=plugins.transcriber.model_dump(mode="json")) ) session.add( InstanceSetting(key="summarizer", value=plugins.summarizer.model_dump(mode="json")) ) session.add(InstanceSetting(key="ai_level", value={"level": "min"})) await session.commit() _set_bootstrap_env(monkeypatch, chat_enabled=True, ai_enabled=True, ai_level="max") try: written = await apply_preset_settings_module.apply_preset_settings(force=False) assert written == [] rows = await _load_managed_rows() assert rows["chat"]["enabled"] is False # ручная правка не тронута assert rows["ai_level"]["level"] == "min" finally: get_settings.cache_clear() async def test_apply_preset_settings_with_force_overwrites_existing_keys( monkeypatch: pytest.MonkeyPatch, clean_bootstrap_managed_keys: None ) -> None: """`--force` (подтверждение в опроснике install.sh) перезаписывает все 4 ключа.""" plugins = load_plugins_config(PLUGINS_YAML) async with async_session_maker() as session: session.add(InstanceSetting(key="chat", value={"enabled": False})) session.add( InstanceSetting(key="transcriber", value=plugins.transcriber.model_dump(mode="json")) ) session.add( InstanceSetting(key="summarizer", value=plugins.summarizer.model_dump(mode="json")) ) session.add(InstanceSetting(key="ai_level", value={"level": "max"})) await session.commit() _set_bootstrap_env(monkeypatch, chat_enabled=True, ai_enabled=False, ai_level="min") try: written = await apply_preset_settings_module.apply_preset_settings(force=True) assert set(written) == set(BOOTSTRAP_MANAGED_KEYS) rows = await _load_managed_rows() assert rows["chat"]["enabled"] is True assert rows["transcriber"]["enabled"] is False assert rows["summarizer"]["enabled"] is False assert rows["ai_level"]["level"] == "min" finally: get_settings.cache_clear()