Первоначальная версия VidConf

This commit is contained in:
2026-07-23 01:04:01 +03:00
commit 896455381a
335 changed files with 61527 additions and 0 deletions

View File

@@ -0,0 +1,652 @@
"""Тесты хранилища настроек инстанса (`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,
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",
)
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",
}
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_domain 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_domain is None
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_domain="not a domain"))
cfg = await service.get()
assert cfg.registration_email_domain is None
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_domain="@Corp.RU "
)
)
assert cfg.registration_email_domain_enabled is True
assert cfg.registration_email_domain == "corp.ru"
reloaded = await service.get()
assert reloaded.registration_email_domain == "corp.ru"
async def test_registration_email_domain_can_be_disabled_keeping_stored_domain(
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_domain="acme.io"
)
)
cfg = await service.update(SettingsUpdateIn(registration_email_domain_enabled=False))
assert cfg.registration_email_domain_enabled is False
assert cfg.registration_email_domain == "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_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()