Настройка «Эталон mail-домена» теперь хранит список доменов вместо
одного — email при регистрации принимается, если совпадает с любым из
них. Старое значение в БД ({"domain": str|None}) читается прозрачно
(обратная совместимость без Alembic-миграции) и переписывается в новую
форму ({"domains": [...]}) при первом же сохранении настроек. В админке
добавление/удаление доменов — списком чипов; на экране регистрации
подсказка о несовпадении домена перечисляет все эталонные варианты.
841 lines
36 KiB
Python
841 lines
36 KiB
Python
"""Тесты хранилища настроек инстанса (`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",
|
||
"ai_level",
|
||
"summary_recipients",
|
||
"display_timezone",
|
||
"registration_team_choice",
|
||
"registration_email_domain",
|
||
"contact_email",
|
||
)
|
||
|
||
|
||
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",
|
||
"ai_level",
|
||
"summary_recipients",
|
||
"display_timezone",
|
||
"registration_team_choice",
|
||
"registration_email_domain",
|
||
"contact_email",
|
||
}
|
||
cfg = await service.get()
|
||
assert cfg.transcriber.provider == "faster_whisper_cpu"
|
||
assert cfg.ai_level == "min"
|
||
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
|
||
|
||
|
||
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"
|
||
|
||
|
||
@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_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()
|