Files
vidconf/backend/tests/test_instance_settings.py
Max Ronzhin 4f82ebe17a
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
feat(auth): согласие на обработку персональных данных при регистрации
Отключаемый модуль (instance_settings.consent_policy): галочка + ссылка на
публичную страницу регламента на форме регистрации, редактируемый в админке
текст с типовым шаблоном по умолчанию (плейсхолдеры под организацию, не
проходил юридическую проверку), версия текста растёт при каждой правке.
Факт согласия хранится в users (consent_version, consent_given_at) — второй
эшелон проверки на сервере, как и для отключаемых модулей ранее. Дефолт
(выключено) сохраняет поведение существующих инсталляций, у уже
зарегистрированных пользователей согласие не запрашивается.
2026-08-04 22:20:00 +03:00

956 lines
42 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Тесты хранилища настроек инстанса (`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,
InvalidConsentPolicyError,
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",
"consent_policy",
)
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",
"consent_policy",
}
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
# Согласие на обработку персональных данных выключено по умолчанию
# (дефолт сохраняет поведение существующих инсталляций), но дефолтный
# текст-шаблон уже на месте — публичная страница регламента осмысленна
# даже при выключенном модуле.
assert cfg.consent_required is False
assert cfg.consent_policy_text != ""
assert cfg.consent_policy_version == 1
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_consent_policy_toggle_without_text_change_keeps_version(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Включение флага без правки текста не увеличивает версию."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
baseline = await service.get()
assert baseline.consent_required is False
cfg = await service.update(SettingsUpdateIn(consent_required=True))
assert cfg.consent_required is True
assert cfg.consent_policy_version == baseline.consent_policy_version
reloaded = await service.get()
assert reloaded.consent_required is True
assert reloaded.consent_policy_version == baseline.consent_policy_version
async def test_consent_policy_text_change_bumps_version(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Правка текста регламента увеличивает версию — иначе «версия согласия» в БД бессмысленна."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
baseline = await service.get()
cfg = await service.update(SettingsUpdateIn(consent_policy_text="Новый текст регламента"))
assert cfg.consent_policy_text == "Новый текст регламента"
assert cfg.consent_policy_version == baseline.consent_policy_version + 1
# Повторное сохранение ТОГО ЖЕ текста версию больше не двигает.
cfg2 = await service.update(SettingsUpdateIn(consent_policy_text="Новый текст регламента"))
assert cfg2.consent_policy_version == cfg.consent_policy_version
reloaded = await service.get()
assert reloaded.consent_policy_version == cfg.consent_policy_version
async def test_consent_policy_enable_with_empty_text_rejected(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Нельзя включить обязательное согласие, если текст регламента пуст."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
with pytest.raises(InvalidConsentPolicyError):
await service.update(
SettingsUpdateIn(consent_required=True, consent_policy_text=" ")
)
cfg = await service.get()
assert cfg.consent_required is False
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()