Первоначальная версия 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

233
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,233 @@
"""Общие fixtures для интеграционных тестов, запускаемых против реального экземпляра Postgres
из `deploy/docker-compose.yml`.
Каждый тест запускается внутри внешней транзакции, которая никогда не коммитится; ORM
`AsyncSession` присоединяется к ней с помощью `join_transaction_mode="create_savepoint"` так,
что даже вызовы `session.commit()` (которые вызывают IntegrityError при нарушении
constraint) влияют только на savepoint и полностью отменяются внешним rollback в teardown.
"""
import asyncio
import json
from collections.abc import AsyncGenerator
from typing import Any, cast
import httpx
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport
from sqlalchemy import delete, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession
from starlette.types import Message, Scope
from core.db import engine, get_session
from core.redis import redis_client
from main import create_app
from models.instance_setting import InstanceSetting
@pytest_asyncio.fixture(autouse=True)
async def _reset_rate_limits() -> AsyncGenerator[None, None]:
"""Сбросить счётчики rate limit (Redis реальный, общий на все тесты) до и после теста.
Без этого параллельные/последовательные тесты публичных эндпоинтов
(`resolve`, `guest-join`) делили бы один и тот же счётчик по IP тестового
клиента и мешали друг другу (см. `core/rate_limit.py`).
"""
await _delete_rate_limit_keys()
yield
await _delete_rate_limit_keys()
async def _delete_rate_limit_keys() -> None:
keys = [key async for key in redis_client.scan_iter(match="rate_limit:*")]
if keys:
await redis_client.delete(*keys)
@pytest_asyncio.fixture(autouse=True)
async def _preserve_instance_settings() -> AsyncGenerator[None, None]:
"""Гарантировать, что тест не оставляет следов в `instance_settings` общей dev-БД.
Строки `instance_settings` — живые настройки dev-инстанса (тоггл чата,
домен регистрации и т.п.), а не тестовые данные: закоммиченная тестом
правка молча меняет поведение dev-окружения (реально воспроизводилось —
после прогонов тестов чат в dev-инстансе оказался выключен).
Снимок закоммиченного состояния снимается отдельным подключением (мимо
savepoint-транзакции теста, см. докстринг модуля), после теста таблица
приводится к снимку: появившиеся ключи удаляются, изменённые и пропавшие —
восстанавливаются. Для корректных savepoint-тестов это no-op ценой одного
SELECT — страховка на случай любой записи мимо savepoint-сессии.
"""
before = await _load_committed_instance_settings()
yield
after = await _load_committed_instance_settings()
if after == before:
return
async with engine.connect() as connection:
extra_keys = after.keys() - before.keys()
if extra_keys:
await connection.execute(
delete(InstanceSetting).where(InstanceSetting.key.in_(extra_keys))
)
for key, value in before.items():
if after.get(key) != value:
await connection.execute(
pg_insert(InstanceSetting)
.values(key=key, value=value)
.on_conflict_do_update(index_elements=["key"], set_={"value": value})
)
await connection.commit()
async def _load_committed_instance_settings() -> dict[str, Any]:
"""Прочитать закоммиченные строки `instance_settings` отдельным подключением."""
async with engine.connect() as connection:
result = await connection.execute(select(InstanceSetting.key, InstanceSetting.value))
return {key: value for key, value in result.all()}
@pytest_asyncio.fixture
async def db_connection() -> AsyncGenerator[AsyncConnection, None]:
async with engine.connect() as connection:
trans = await connection.begin()
try:
yield connection
finally:
await trans.rollback()
@pytest_asyncio.fixture
async def db_session(db_connection: AsyncConnection) -> AsyncGenerator[AsyncSession, None]:
session = AsyncSession(
bind=db_connection,
join_transaction_mode="create_savepoint",
expire_on_commit=False,
)
try:
yield session
finally:
await session.close()
@pytest_asyncio.fixture
async def app(db_session: AsyncSession) -> AsyncGenerator[FastAPI, None]:
"""Экземпляр FastAPI-приложения с `get_session`, подменённым на тестовую (savepoint) сессию."""
application = create_app()
async def _override_get_session() -> AsyncGenerator[AsyncSession, None]:
yield db_session
application.dependency_overrides[get_session] = _override_get_session
yield application
@pytest_asyncio.fixture
async def client(app: FastAPI) -> AsyncGenerator[httpx.AsyncClient, None]:
"""Асинхронный HTTP-клиент поверх приложения.
База `https://test` (а не `http://`) нужна, чтобы httpx сохранял в своём
cookie jar httpOnly Secure cookie с refresh-токеном между запросами.
"""
transport = ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as ac:
yield ac
# Таймаут ожидания ответа приложения в WS-тестах — на порядок больше
# `AUTH_TIMEOUT_SECONDS` эндпоинта не нужен, реальный ответ приходит мгновенно.
_RECEIVE_TIMEOUT_S = 5.0
class ASGIWebSocketSession:
"""Минимальный in-process ASGI websocket-клиент для тестов.
`httpx.AsyncClient`/`ASGITransport` не поддерживают websocket-соединения,
а `starlette.testclient.TestClient` гоняет ASGI-приложение в отдельном
потоке со своим event loop — тестовая `db_session` (см. выше) привязана к
`AsyncConnection` ТЕКУЩЕГО loop, и обращение к ней из другого loop роняет
asyncpg `RuntimeError: Future attached to a different loop`. Этот класс
драйвит `app(scope, receive, send)` напрямую в текущем event loop через
пару `asyncio.Queue`, эмулируя протокол ASGI websocket (см.
`starlette/websockets.py`).
"""
def __init__(self, app: FastAPI, path: str) -> None:
self._to_app: asyncio.Queue[Message] = asyncio.Queue()
self._from_app: asyncio.Queue[Message] = asyncio.Queue()
scope: Scope = {
"type": "websocket",
"asgi": {"version": "3.0", "spec_version": "2.3"},
"http_version": "1.1",
"scheme": "ws",
"path": path,
"raw_path": path.encode(),
"query_string": b"",
"headers": [],
"client": ("test-client", 12345),
"server": ("test-server", 80),
"subprotocols": [],
}
self._task = asyncio.create_task(app(scope, self._receive, self._send))
async def _receive(self) -> Message:
return await self._to_app.get()
async def _send(self, message: Message) -> None:
await self._from_app.put(message)
async def connect(self) -> Message:
"""Отправить `websocket.connect` и дождаться ответа (`accept`/`close`)."""
await self._to_app.put({"type": "websocket.connect"})
return await self._receive_from_app()
async def send_json(self, data: dict[str, Any]) -> None:
await self._to_app.put({"type": "websocket.receive", "text": json.dumps(data)})
async def receive_json(self) -> dict[str, Any]:
message = await self._receive_from_app()
assert message["type"] == "websocket.send", message
return cast(dict[str, Any], json.loads(message["text"]))
async def receive_close(self) -> int:
message = await self._receive_from_app()
assert message["type"] == "websocket.close", message
return int(message["code"])
async def _receive_from_app(self) -> Message:
# Фиксированный таймаут ожидания ответа приложения — тестовый
# хелпер не даёт вызывающей стороне переопределить его параметром
# (см. ASYNC109: явный параметр `timeout` у async-функции — плохая
# практика, вместо этого таймаут задаётся здесь одним местом).
async with asyncio.timeout(_RECEIVE_TIMEOUT_S):
return await self._from_app.get()
async def aclose(self) -> None:
"""Эмулировать разрыв соединения клиентом и дождаться завершения ASGI-приложения."""
if not self._task.done():
await self._to_app.put({"type": "websocket.disconnect", "code": 1000})
try:
await asyncio.wait_for(self._task, timeout=5.0)
except Exception: # noqa: BLE001 — best-effort teardown в тестах
self._task.cancel()
@pytest_asyncio.fixture
async def ws_client(app: FastAPI) -> AsyncGenerator[Any, None]:
"""Фабрика in-process WS-клиентов (`ASGIWebSocketSession`) поверх текущего `app`.
Возвращает callable `(path) -> ASGIWebSocketSession`; открытые сессии
закрываются автоматически по завершении теста.
"""
sessions: list[ASGIWebSocketSession] = []
def _factory(path: str) -> ASGIWebSocketSession:
session = ASGIWebSocketSession(app, path)
sessions.append(session)
return session
yield _factory
for session in sessions:
await session.aclose()

View File

@@ -0,0 +1,16 @@
{
"event": "egress_ended",
"id": "{event_id}",
"createdAt": "1720000004",
"egressInfo": {
"egressId": "{egress_id}",
"roomName": "{room_name}",
"status": "EGRESS_COMPLETE",
"startedAt": "1720000002000000000",
"endedAt": "1720000004000000000",
"file": {
"filename": "{file_path}",
"location": "{file_path}"
}
}
}

View File

@@ -0,0 +1,13 @@
{
"event": "egress_ended",
"id": "{event_id}",
"createdAt": "1720000004",
"egressInfo": {
"egressId": "{egress_id}",
"roomName": "{room_name}",
"status": "EGRESS_FAILED",
"startedAt": "1720000002000000000",
"endedAt": "1720000004000000000",
"error": "pipeline failure"
}
}

View File

@@ -0,0 +1,14 @@
{
"event": "participant_joined",
"id": "{event_id}",
"createdAt": "1720000001",
"room": {
"sid": "RM_test_joined",
"name": "{room_name}"
},
"participant": {
"sid": "PA_test",
"identity": "{identity}",
"name": "Test Participant"
}
}

View File

@@ -0,0 +1,14 @@
{
"event": "participant_left",
"id": "{event_id}",
"createdAt": "1720000002",
"room": {
"sid": "RM_test_left",
"name": "{room_name}"
},
"participant": {
"sid": "PA_test",
"identity": "{identity}",
"name": "Test Participant"
}
}

View File

@@ -0,0 +1,9 @@
{
"event": "room_finished",
"id": "{event_id}",
"createdAt": "1720000003",
"room": {
"sid": "RM_test_finished",
"name": "{room_name}"
}
}

View File

@@ -0,0 +1,9 @@
{
"event": "room_started",
"id": "{event_id}",
"createdAt": "1720000000",
"room": {
"sid": "RM_test_started",
"name": "{room_name}"
}
}

View File

@@ -0,0 +1,19 @@
{
"event": "track_published",
"id": "{event_id}",
"createdAt": "1720000002",
"room": {
"sid": "RM_test_track",
"name": "{room_name}"
},
"participant": {
"sid": "PA_test",
"identity": "{identity}",
"name": "Test Participant"
},
"track": {
"sid": "{track_sid}",
"type": "AUDIO",
"source": "MICROPHONE"
}
}

View File

@@ -0,0 +1,19 @@
{
"event": "track_published",
"id": "{event_id}",
"createdAt": "1720000002",
"room": {
"sid": "RM_test_track",
"name": "{room_name}"
},
"participant": {
"sid": "PA_test",
"identity": "{identity}",
"name": "Test Participant"
},
"track": {
"sid": "{track_sid}",
"type": "VIDEO",
"source": "CAMERA"
}
}

View File

@@ -0,0 +1,608 @@
"""Интеграционные тесты `/api/v1/admin/*` («Админ-API», блок C).
Покрывает: 403 не-админу на все эндпоинты; CRUD конференций (реюз
`ConferenceService`); PATCH пользователя (роль/блокировка), запрет
самоизменения (409); заблокированный пользователь получает 401 на защищённых
эндпоинтах; ручная рассылка приглашений ставит задачу (мок продюсера);
`PUT /admin/settings` с недоступным уровнем AI → 400; частичный PUT настроек.
"""
import uuid
from datetime import UTC, datetime, timedelta
from pathlib import Path
from unittest.mock import MagicMock
import httpx
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
import api.admin as admin_module
from core.config import get_settings
from core.security import create_access_token, hash_password
from models.conference import Conference
from models.team import Team
from models.user import User
from services import ai_levels
from services.conference_ids import generate_number, generate_slug
FUTURE = datetime.now(UTC) + timedelta(days=3)
async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
user = User(
email=f"{uuid.uuid4()}@example.com",
name_user="Admin API Tester",
password_hash=hash_password("password123"),
email_verified=True,
role=role,
)
session.add(user)
await session.flush()
return user
def _auth_headers(user: User) -> dict[str, str]:
token = create_access_token(user.id, user.role)
return {"Authorization": f"Bearer {token}"}
async def _make_conference(
session: AsyncSession, *, owner_id: uuid.UUID | None = None, **overrides: object
) -> Conference:
defaults: dict[str, object] = {
"number": generate_number(),
"slug": generate_slug(),
"owner_id": owner_id,
"status": "scheduled",
"scheduled_at": FUTURE,
}
defaults.update(overrides)
conference = Conference(**defaults)
session.add(conference)
await session.flush()
return conference
# --- 403 не-админу -----------------------------------------------------------------
async def test_all_admin_endpoints_forbidden_for_non_admin(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=user.id)
other_user = await _make_user(db_session)
await db_session.commit()
headers = _auth_headers(user)
requests = [
("GET", "/api/v1/admin/conferences", None),
("PATCH", f"/api/v1/admin/conferences/{conference.id}", {"title": "x"}),
("DELETE", f"/api/v1/admin/conferences/{conference.id}", None),
("POST", f"/api/v1/admin/conferences/{conference.id}/invitations", {}),
("GET", "/api/v1/admin/users", None),
("GET", f"/api/v1/admin/users/{other_user.id}", None),
("PATCH", f"/api/v1/admin/users/{other_user.id}", {"role": "admin"}),
("GET", "/api/v1/admin/settings", None),
("PUT", "/api/v1/admin/settings", {}),
]
for method, path, body in requests:
response = await client.request(method, path, json=body, headers=headers)
assert response.status_code == 403, f"{method} {path} -> {response.status_code}"
async def test_admin_endpoints_require_authentication(client: httpx.AsyncClient) -> None:
response = await client.get("/api/v1/admin/conferences")
assert response.status_code == 401
# --- Конференции ---------------------------------------------------------------------
async def test_list_conferences_returns_all(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
owner = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id, title="Sync")
await db_session.commit()
response = await client.get("/api/v1/admin/conferences", headers=_auth_headers(admin))
assert response.status_code == 200, response.text
body = response.json()
ids = [item["id"] for item in body["items"]]
assert str(conference.id) in ids
assert body["total"] >= 1
item = next(item for item in body["items"] if item["id"] == str(conference.id))
assert item["owner_name"] == owner.name_user
assert item["owner_email"] == owner.email
async def test_list_conferences_owner_fields_are_null_without_owner(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
conference = await _make_conference(db_session, owner_id=None, title="Ownerless")
await db_session.commit()
response = await client.get("/api/v1/admin/conferences", headers=_auth_headers(admin))
assert response.status_code == 200, response.text
item = next(item for item in response.json()["items"] if item["id"] == str(conference.id))
assert item["owner_name"] is None
assert item["owner_email"] is None
async def test_list_conferences_filters_by_status_and_query(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
owner = await _make_user(db_session)
await _make_conference(db_session, owner_id=owner.id, title="Findable Sync", status="scheduled")
await _make_conference(db_session, owner_id=owner.id, title="Other", status="ended")
await db_session.commit()
response = await client.get(
"/api/v1/admin/conferences",
params={"status": "scheduled", "q": "Findable"},
headers=_auth_headers(admin),
)
assert response.status_code == 200
titles = [item["title"] for item in response.json()["items"]]
assert titles == ["Findable Sync"]
async def test_patch_conference_by_admin_returns_200(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
owner = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id, title="Original")
await db_session.commit()
response = await client.patch(
f"/api/v1/admin/conferences/{conference.id}",
json={"title": "Renamed by admin"},
headers=_auth_headers(admin),
)
assert response.status_code == 200, response.text
body = response.json()
assert body["title"] == "Renamed by admin"
assert body["owner_name"] == owner.name_user
assert body["owner_email"] == owner.email
async def test_patch_unknown_conference_returns_404(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.patch(
f"/api/v1/admin/conferences/{uuid.uuid4()}",
json={"title": "x"},
headers=_auth_headers(admin),
)
assert response.status_code == 404
async def test_delete_conference_by_admin_returns_204(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
owner = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id, status="scheduled")
await db_session.commit()
response = await client.delete(
f"/api/v1/admin/conferences/{conference.id}", headers=_auth_headers(admin)
)
assert response.status_code == 204
async def test_delete_active_conference_by_admin_returns_409(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
owner = await _make_user(db_session)
conference = await _make_conference(
db_session, owner_id=owner.id, status="active", scheduled_at=None
)
await db_session.commit()
response = await client.delete(
f"/api/v1/admin/conferences/{conference.id}", headers=_auth_headers(admin)
)
assert response.status_code == 409
async def test_send_invitations_enqueues_task(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
admin = await _make_user(db_session, role="admin")
owner = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id)
await db_session.commit()
mock_enqueue = MagicMock()
monkeypatch.setattr(admin_module, "enqueue_invitations", mock_enqueue)
response = await client.post(
f"/api/v1/admin/conferences/{conference.id}/invitations",
json={"emails": ["custom@example.com"]},
headers=_auth_headers(admin),
)
assert response.status_code == 202, response.text
mock_enqueue.assert_called_once_with(conference.id, emails=["custom@example.com"])
async def test_send_invitations_unknown_conference_returns_404(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
monkeypatch.setattr(admin_module, "enqueue_invitations", MagicMock())
response = await client.post(
f"/api/v1/admin/conferences/{uuid.uuid4()}/invitations",
json={},
headers=_auth_headers(admin),
)
assert response.status_code == 404
# --- Создание пользователя администратором ---------------------------------------------
async def test_create_user_returns_201_and_login_works(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
email = f"{uuid.uuid4()}@example.com"
response = await client.post(
"/api/v1/admin/users",
json={"name_user": "Новый пользователь", "email": email, "password": "password123"},
headers=_auth_headers(admin),
)
assert response.status_code == 201, response.text
body = response.json()
assert body["email"] == email
assert body["name_user"] == "Новый пользователь"
assert body["role"] == "user"
assert body["email_verified"] is True
login = await client.post(
"/api/v1/auth/token", data={"username": email, "password": "password123"}
)
assert login.status_code == 200, login.text
assert "access_token" in login.json()
async def test_create_user_with_team_id(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
team = Team(name=f"Team {uuid.uuid4()}")
db_session.add(team)
await db_session.flush()
await db_session.commit()
email = f"{uuid.uuid4()}@example.com"
response = await client.post(
"/api/v1/admin/users",
json={
"name_user": "С командой",
"email": email,
"password": "password123",
"team_id": str(team.id),
},
headers=_auth_headers(admin),
)
assert response.status_code == 201, response.text
assert response.json()["team_id"] == str(team.id)
async def test_create_user_unknown_team_returns_404(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.post(
"/api/v1/admin/users",
json={
"name_user": "Без команды",
"email": f"{uuid.uuid4()}@example.com",
"password": "password123",
"team_id": str(uuid.uuid4()),
},
headers=_auth_headers(admin),
)
assert response.status_code == 404
assert response.json()["detail"] == "team_not_found"
async def test_create_user_duplicate_email_returns_409(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
existing = await _make_user(db_session)
await db_session.commit()
response = await client.post(
"/api/v1/admin/users",
json={
"name_user": "Дубль",
"email": existing.email,
"password": "password123",
},
headers=_auth_headers(admin),
)
assert response.status_code == 409
assert response.json()["detail"] == "email_already_registered"
async def test_create_user_forbidden_for_non_admin(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
response = await client.post(
"/api/v1/admin/users",
json={
"name_user": "Not allowed",
"email": f"{uuid.uuid4()}@example.com",
"password": "password123",
},
headers=_auth_headers(user),
)
assert response.status_code == 403
# --- Пользователи ----------------------------------------------------------------------
async def test_list_users_returns_all(client: httpx.AsyncClient, db_session: AsyncSession) -> None:
admin = await _make_user(db_session, role="admin")
other = await _make_user(db_session)
await db_session.commit()
response = await client.get("/api/v1/admin/users", headers=_auth_headers(admin))
assert response.status_code == 200
items = response.json()["items"]
ids = [item["id"] for item in items]
assert str(admin.id) in ids
assert str(other.id) in ids
assert all("email_verified" in item for item in items)
async def test_patch_user_role_and_block(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
target = await _make_user(db_session)
await db_session.commit()
response = await client.patch(
f"/api/v1/admin/users/{target.id}",
json={"role": "admin", "is_blocked": True},
headers=_auth_headers(admin),
)
assert response.status_code == 200, response.text
body = response.json()
assert body["role"] == "admin"
assert body["is_blocked"] is True
assert body["email_verified"] is True
async def test_get_user_returns_profile_card(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Карточка профиля из таблицы пользователей — те же поля, что `/users/me`."""
admin = await _make_user(db_session, role="admin")
target = await _make_user(db_session)
await db_session.commit()
response = await client.get(f"/api/v1/admin/users/{target.id}", headers=_auth_headers(admin))
assert response.status_code == 200, response.text
body = response.json()
assert body["id"] == str(target.id)
assert body["name_user"] == target.name_user
assert body["avatar_url"] is None
assert body["team_name"] is None
async def test_get_unknown_user_returns_404(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.get(f"/api/v1/admin/users/{uuid.uuid4()}", headers=_auth_headers(admin))
assert response.status_code == 404
async def test_patch_user_updates_name(client: httpx.AsyncClient, db_session: AsyncSession) -> None:
admin = await _make_user(db_session, role="admin")
target = await _make_user(db_session)
await db_session.commit()
response = await client.patch(
f"/api/v1/admin/users/{target.id}",
json={"name_user": "Renamed By Admin"},
headers=_auth_headers(admin),
)
assert response.status_code == 200, response.text
assert response.json()["name_user"] == "Renamed By Admin"
async def test_upload_user_avatar_by_admin(
client: httpx.AsyncClient,
db_session: AsyncSession,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("MEDIA_ROOT", str(tmp_path))
get_settings.cache_clear()
try:
admin = await _make_user(db_session, role="admin")
target = await _make_user(db_session)
await db_session.commit()
response = await client.post(
f"/api/v1/admin/users/{target.id}/avatar",
headers=_auth_headers(admin),
files={"file": ("avatar.png", b"\x89PNG\r\n\x1a\n" + b"\x00" * 32, "image/png")},
)
assert response.status_code == 200, response.text
assert response.json()["avatar_url"] is not None
finally:
get_settings.cache_clear()
async def test_upload_user_avatar_forbidden_for_non_admin(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
target = await _make_user(db_session)
await db_session.commit()
response = await client.post(
f"/api/v1/admin/users/{target.id}/avatar",
headers=_auth_headers(user),
files={"file": ("avatar.png", b"\x89PNG\r\n\x1a\n" + b"\x00" * 32, "image/png")},
)
assert response.status_code == 403
async def test_patch_self_returns_409(client: httpx.AsyncClient, db_session: AsyncSession) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.patch(
f"/api/v1/admin/users/{admin.id}",
json={"is_blocked": True},
headers=_auth_headers(admin),
)
assert response.status_code == 409
async def test_blocked_user_gets_401_on_protected_endpoint(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
target = await _make_user(db_session)
await db_session.commit()
target_headers = _auth_headers(target)
block_response = await client.patch(
f"/api/v1/admin/users/{target.id}",
json={"is_blocked": True},
headers=_auth_headers(admin),
)
assert block_response.status_code == 200
response = await client.get("/api/v1/users/me", headers=target_headers)
assert response.status_code == 401
async def test_patch_unknown_user_returns_404(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.patch(
f"/api/v1/admin/users/{uuid.uuid4()}",
json={"is_blocked": True},
headers=_auth_headers(admin),
)
assert response.status_code == 404
# --- Настройки --------------------------------------------------------------------------
async def test_get_settings_includes_ai_levels(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Плумбинг `detect_ai_levels` через API — не тест самого детекта (см.
`test_ai_levels.py`): железо теста-«пресета 3» смоделировано env-переменными
(RAM хватает на `min`, но не на `medium`; GPU нет — `max` недоступен), файлы
моделей считаются скачанными (изолируем тест от реального диска)."""
monkeypatch.setattr(ai_levels, "_model_downloaded", lambda _path: True)
monkeypatch.setattr(admin_module, "transcription_queue_served", lambda: False)
monkeypatch.setenv("HW_RAM_MB", "16384")
monkeypatch.delenv("HW_GPU_NAME", raising=False)
get_settings.cache_clear()
try:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.get("/api/v1/admin/settings", headers=_auth_headers(admin))
assert response.status_code == 200, response.text
body = response.json()
levels = {item["level"]: item["available"] for item in body["ai_levels"]}
assert levels["min"] is True
assert levels["medium"] is False
assert levels["max"] is False
finally:
get_settings.cache_clear()
async def test_get_settings_transcription_queue_served_reflects_worker_presence(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""`transcription_queue_served`
в ответе `GET /admin/settings` для обоих исходов детекта воркера (замокан
на уровне сервисной функции — сам детект Celery inspect покрыт
`test_task_producers.py`)."""
admin = await _make_user(db_session, role="admin")
await db_session.commit()
monkeypatch.setattr(admin_module, "transcription_queue_served", lambda: True)
served = await client.get("/api/v1/admin/settings", headers=_auth_headers(admin))
assert served.status_code == 200, served.text
assert served.json()["transcription_queue_served"] is True
monkeypatch.setattr(admin_module, "transcription_queue_served", lambda: False)
not_served = await client.get("/api/v1/admin/settings", headers=_auth_headers(admin))
assert not_served.status_code == 200, not_served.text
assert not_served.json()["transcription_queue_served"] is False
async def test_put_settings_partial_update(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(admin_module, "transcription_queue_served", lambda: False)
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.put(
"/api/v1/admin/settings",
json={"chat_enabled": False, "display_timezone": "Asia/Yekaterinburg"},
headers=_auth_headers(admin),
)
assert response.status_code == 200, response.text
body = response.json()
assert body["chat_enabled"] is False
assert body["display_timezone"] == "Asia/Yekaterinburg"
assert body["ai_level"] == "min"
assert body["transcription_queue_served"] is False
async def test_put_settings_unavailable_ai_level_returns_400(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.put(
"/api/v1/admin/settings",
json={"ai_level": "medium"},
headers=_auth_headers(admin),
)
assert response.status_code == 400

View File

@@ -0,0 +1,299 @@
"""Интеграционные тесты справочника команд `/api/v1/admin/teams` и привязки `users.team_id`.
Покрывает: CRUD команд, 409 на дубль имени, 403 не-админу на новые
эндпоинты, назначение/снятие команды пользователю (в т.ч. явный `null`),
404 на несуществующую команду, обнуление `team_id` при удалении команды,
разрешённое самоизменение `team_id` (в отличие от `role`/`is_blocked`).
"""
import uuid
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from core.security import create_access_token, hash_password
from models.team import Team
from models.user import User
async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
user = User(
email=f"{uuid.uuid4()}@example.com",
name_user="Team API Tester",
password_hash=hash_password("password123"),
email_verified=True,
role=role,
)
session.add(user)
await session.flush()
return user
async def _make_team(session: AsyncSession, *, name: str | None = None) -> Team:
team = Team(name=name or f"Team {uuid.uuid4()}")
session.add(team)
await session.flush()
return team
def _auth_headers(user: User) -> dict[str, str]:
token = create_access_token(user.id, user.role)
return {"Authorization": f"Bearer {token}"}
# --- 403 не-админу -------------------------------------------------------------------
async def test_team_endpoints_forbidden_for_non_admin(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
team = await _make_team(db_session)
await db_session.commit()
headers = _auth_headers(user)
requests = [
("GET", "/api/v1/admin/teams", None),
("POST", "/api/v1/admin/teams", {"name": "X"}),
("PATCH", f"/api/v1/admin/teams/{team.id}", {"name": "Y"}),
("DELETE", f"/api/v1/admin/teams/{team.id}", None),
]
for method, path, body in requests:
response = await client.request(method, path, json=body, headers=headers)
assert response.status_code == 403, f"{method} {path} -> {response.status_code}"
# --- CRUD команд ---------------------------------------------------------------------
async def test_create_and_list_teams_sorted_by_name(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Список отсортирован по названию; сравниваем взаимный порядок и наличие
своих (уникальных по суффиксу) команд, а не полный список — в общей
dev-БД могут быть и другие, легитимные команды разработчика."""
admin = await _make_user(db_session, role="admin")
suffix = uuid.uuid4().hex[:8]
name_alpha, name_beta, name_zeta = f"Alpha-{suffix}", f"Beta-{suffix}", f"Zeta-{suffix}"
await _make_team(db_session, name=name_zeta)
await _make_team(db_session, name=name_alpha)
await db_session.commit()
response = await client.post(
"/api/v1/admin/teams", json={"name": name_beta}, headers=_auth_headers(admin)
)
assert response.status_code == 201, response.text
created = response.json()
assert created["name"] == name_beta
assert "id" in created and "created_at" in created
list_response = await client.get("/api/v1/admin/teams", headers=_auth_headers(admin))
assert list_response.status_code == 200
body = list_response.json()
names = [item["name"] for item in body["items"]]
assert names == sorted(names)
assert name_alpha in names and name_beta in names and name_zeta in names
assert names.index(name_alpha) < names.index(name_beta) < names.index(name_zeta)
assert body["total"] == len(body["items"])
async def test_create_team_strips_name(client: httpx.AsyncClient, db_session: AsyncSession) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.post(
"/api/v1/admin/teams", json={"name": " Padded "}, headers=_auth_headers(admin)
)
assert response.status_code == 201, response.text
assert response.json()["name"] == "Padded"
async def test_create_team_duplicate_name_returns_409(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
name = f"Duplicate {uuid.uuid4()}"
await _make_team(db_session, name=name)
await db_session.commit()
response = await client.post(
"/api/v1/admin/teams", json={"name": name}, headers=_auth_headers(admin)
)
assert response.status_code == 409
async def test_update_team_renames(client: httpx.AsyncClient, db_session: AsyncSession) -> None:
admin = await _make_user(db_session, role="admin")
team = await _make_team(db_session, name=f"Old Name {uuid.uuid4()}")
await db_session.commit()
new_name = f"New Name {uuid.uuid4()}"
response = await client.patch(
f"/api/v1/admin/teams/{team.id}", json={"name": new_name}, headers=_auth_headers(admin)
)
assert response.status_code == 200, response.text
assert response.json()["name"] == new_name
async def test_update_team_duplicate_name_returns_409(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
name_a = f"Team A {uuid.uuid4()}"
team_a = await _make_team(db_session, name=name_a)
team_b = await _make_team(db_session, name=f"Team B {uuid.uuid4()}")
await db_session.commit()
response = await client.patch(
f"/api/v1/admin/teams/{team_b.id}",
json={"name": name_a},
headers=_auth_headers(admin),
)
assert response.status_code == 409
assert team_a.name == name_a
async def test_update_team_unknown_returns_404(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.patch(
f"/api/v1/admin/teams/{uuid.uuid4()}", json={"name": "X"}, headers=_auth_headers(admin)
)
assert response.status_code == 404
async def test_delete_team_unknown_returns_404(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.delete(
f"/api/v1/admin/teams/{uuid.uuid4()}", headers=_auth_headers(admin)
)
assert response.status_code == 404
async def test_delete_team_clears_user_team_id(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
team = await _make_team(db_session)
member = await _make_user(db_session)
member.team_id = team.id
await db_session.commit()
response = await client.delete(f"/api/v1/admin/teams/{team.id}", headers=_auth_headers(admin))
assert response.status_code == 204
await db_session.refresh(member)
assert member.team_id is None
# --- Привязка пользователя к команде ---------------------------------------------------
async def test_patch_user_assigns_team(client: httpx.AsyncClient, db_session: AsyncSession) -> None:
admin = await _make_user(db_session, role="admin")
target = await _make_user(db_session)
team = await _make_team(db_session)
await db_session.commit()
response = await client.patch(
f"/api/v1/admin/users/{target.id}",
json={"team_id": str(team.id)},
headers=_auth_headers(admin),
)
assert response.status_code == 200, response.text
assert response.json()["team_id"] == str(team.id)
async def test_patch_user_clears_team_with_explicit_null(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
team = await _make_team(db_session)
target = await _make_user(db_session)
target.team_id = team.id
await db_session.commit()
response = await client.patch(
f"/api/v1/admin/users/{target.id}",
json={"team_id": None},
headers=_auth_headers(admin),
)
assert response.status_code == 200, response.text
assert response.json()["team_id"] is None
async def test_patch_user_missing_team_field_keeps_current_team(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
team = await _make_team(db_session)
target = await _make_user(db_session)
target.team_id = team.id
await db_session.commit()
response = await client.patch(
f"/api/v1/admin/users/{target.id}",
json={"role": "admin"},
headers=_auth_headers(admin),
)
assert response.status_code == 200, response.text
assert response.json()["team_id"] == str(team.id)
async def test_patch_user_unknown_team_returns_404(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
target = await _make_user(db_session)
await db_session.commit()
response = await client.patch(
f"/api/v1/admin/users/{target.id}",
json={"team_id": str(uuid.uuid4())},
headers=_auth_headers(admin),
)
assert response.status_code == 404
async def test_admin_can_change_own_team_id(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
team = await _make_team(db_session)
await db_session.commit()
response = await client.patch(
f"/api/v1/admin/users/{admin.id}",
json={"team_id": str(team.id)},
headers=_auth_headers(admin),
)
assert response.status_code == 200, response.text
assert response.json()["team_id"] == str(team.id)
async def test_admin_still_forbidden_to_change_own_role_and_block(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.patch(
f"/api/v1/admin/users/{admin.id}",
json={"is_blocked": True},
headers=_auth_headers(admin),
)
assert response.status_code == 409
response = await client.patch(
f"/api/v1/admin/users/{admin.id}",
json={"role": "user"},
headers=_auth_headers(admin),
)
assert response.status_code == 409

View File

@@ -0,0 +1,183 @@
"""Тесты доступности уровней AI (`services.ai_levels.detect_ai_levels`).
Детект без GPU → `max` недоступен с причиной; `medium` доступен или
недоступен по RAM/моделям; ровно три уровня в ответе.
Реальное железо/файлы моделей недетерминированы на машине разработчика —
`HW_*` подставляются через `monkeypatch.setenv` + `get_settings.cache_clear()`
(см. `test_admin_api.py::test_upload_user_avatar_by_admin` за образцом),
факт «модель скачана» — через monkeypatch `services.ai_levels._model_downloaded`.
"""
from collections.abc import Iterator
import pytest
from core.config import Settings, get_settings
from core.plugins.config import ChatConfig, InstanceConfig, SummarizerConfig, TranscriberConfig
from services import ai_levels
from services.ai_levels import detect_ai_levels
def _cfg() -> InstanceConfig:
return InstanceConfig(
transcriber=TranscriberConfig(), summarizer=SummarizerConfig(), chat=ChatConfig()
)
@pytest.fixture(autouse=True)
def _clear_hw_env(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
"""Гарантировать «чистое» железо (без HW_*) на старте каждого теста и сброс кэша
`get_settings` после — тесты этого модуля не должны зависеть друг от друга
или от `.env` окружения разработчика."""
for var in ("HW_CPUS", "HW_RAM_MB", "HW_GPU_NAME", "HW_VRAM_MB"):
monkeypatch.delenv(var, raising=False)
get_settings.cache_clear()
try:
yield
finally:
get_settings.cache_clear()
@pytest.fixture
def _all_models_downloaded(monkeypatch: pytest.MonkeyPatch) -> None:
"""Считать все модели всех уровней скачанными (изолирует тест от реального диска)."""
monkeypatch.setattr(ai_levels, "_model_downloaded", lambda _path: True)
@pytest.fixture
def _no_models_downloaded(monkeypatch: pytest.MonkeyPatch) -> None:
"""Считать, что ни одна модель не скачана."""
monkeypatch.setattr(ai_levels, "_model_downloaded", lambda _path: False)
def test_settings_treats_empty_hw_env_strings_as_none(monkeypatch: pytest.MonkeyPatch) -> None:
"""`docker-compose`/`env_file` подставляют `KEY=` из `.env` как пустую строку, не как
отсутствие переменной: `install.sh` пишет `HW_VRAM_MB=` пустым на любой машине без
NVIDIA GPU (пресеты 14), `.env.example` — все четыре `HW_*` пустыми по умолчанию.
Без нормализации пустой строки в `None` `Settings()` падает `ValidationError` уже на
импорте (`main.py`, `workers/celery_app.py`) — регрессионный тест."""
monkeypatch.setenv("HW_CPUS", "")
monkeypatch.setenv("HW_RAM_MB", "")
monkeypatch.setenv("HW_GPU_NAME", "")
monkeypatch.setenv("HW_VRAM_MB", "")
settings = Settings()
assert settings.hw_cpus is None
assert settings.hw_ram_mb is None
assert settings.hw_gpu_name is None
assert settings.hw_vram_mb is None
def test_detect_ai_levels_returns_exactly_three_levels() -> None:
statuses = detect_ai_levels(_cfg())
assert {status.level for status in statuses} == {"min", "medium", "max"}
def test_min_available_with_enough_ram_and_models(
monkeypatch: pytest.MonkeyPatch, _all_models_downloaded: None
) -> None:
monkeypatch.setenv("HW_RAM_MB", "16384")
get_settings.cache_clear()
statuses = {status.level: status for status in detect_ai_levels(_cfg())}
assert statuses["min"].available is True
assert statuses["min"].reason is None
def test_min_unavailable_when_model_not_downloaded(_no_models_downloaded: None) -> None:
statuses = {status.level: status for status in detect_ai_levels(_cfg())}
assert statuses["min"].available is False
reason = statuses["min"].reason
assert reason is not None
assert "не скачана" in reason
assert "пресетом 3" in reason
def test_medium_unavailable_with_insufficient_ram(
monkeypatch: pytest.MonkeyPatch, _all_models_downloaded: None
) -> None:
monkeypatch.setenv("HW_RAM_MB", "16384") # хватает на min (16 ГБ), мало для medium (32 ГБ)
get_settings.cache_clear()
statuses = {status.level: status for status in detect_ai_levels(_cfg())}
assert statuses["medium"].available is False
reason = statuses["medium"].reason
assert reason is not None
assert "недостаточно RAM: нужно 32 ГБ" in reason
def test_medium_available_with_enough_ram_and_models_without_gpu(
monkeypatch: pytest.MonkeyPatch, _all_models_downloaded: None
) -> None:
"""`medium` не требует GPU (ADR-004: GPU опционален) — доступен на чистом CPU-железе."""
monkeypatch.setenv("HW_RAM_MB", "32768")
get_settings.cache_clear()
statuses = {status.level: status for status in detect_ai_levels(_cfg())}
assert statuses["medium"].available is True
assert statuses["medium"].reason is None
def test_medium_unavailable_when_model_not_downloaded(
monkeypatch: pytest.MonkeyPatch, _no_models_downloaded: None
) -> None:
monkeypatch.setenv("HW_RAM_MB", "32768")
get_settings.cache_clear()
statuses = {status.level: status for status in detect_ai_levels(_cfg())}
assert statuses["medium"].available is False
reason = statuses["medium"].reason
assert reason is not None
assert "не скачана" in reason
assert "пресетом 4" in reason
def test_max_unavailable_without_gpu(
monkeypatch: pytest.MonkeyPatch, _all_models_downloaded: None
) -> None:
monkeypatch.setenv("HW_RAM_MB", "65536")
get_settings.cache_clear()
statuses = {status.level: status for status in detect_ai_levels(_cfg())}
assert statuses["max"].available is False
reason = statuses["max"].reason
assert reason is not None
assert "требуется GPU NVIDIA ≥16 ГБ VRAM, не обнаружен" in reason
def test_max_unavailable_with_insufficient_vram(
monkeypatch: pytest.MonkeyPatch, _all_models_downloaded: None
) -> None:
monkeypatch.setenv("HW_RAM_MB", "65536")
monkeypatch.setenv("HW_GPU_NAME", "NVIDIA RTX 3060")
monkeypatch.setenv("HW_VRAM_MB", "12288")
get_settings.cache_clear()
statuses = {status.level: status for status in detect_ai_levels(_cfg())}
assert statuses["max"].available is False
reason = statuses["max"].reason
assert reason is not None
assert "обнаружено только 12 ГБ" in reason
def test_max_available_with_gpu_ram_and_models(
monkeypatch: pytest.MonkeyPatch, _all_models_downloaded: None
) -> None:
monkeypatch.setenv("HW_RAM_MB", "65536")
monkeypatch.setenv("HW_GPU_NAME", "NVIDIA RTX 4090")
monkeypatch.setenv("HW_VRAM_MB", "24576")
get_settings.cache_clear()
statuses = {status.level: status for status in detect_ai_levels(_cfg())}
assert statuses["max"].available is True
assert statuses["max"].reason is None

434
backend/tests/test_auth.py Normal file
View File

@@ -0,0 +1,434 @@
"""Интеграционные тесты для `/api/v1/auth`: полный цикл регистрация → вход,
ротация/reuse refresh-токена, закрытые эндпоинты без JWT.
"""
import re
import uuid
from collections.abc import AsyncGenerator, Sequence
from typing import Annotated
import httpx
import pytest_asyncio
from fastapi import Depends, FastAPI
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.auth import get_auth_service
from core.db import get_session
from core.redis import redis_client
from models.team import Team
from models.user import User
from services.auth import AuthService
from services.email import EmailAttachment
from services.instance_settings import InstanceSettingsService, SettingsUpdateIn
REFRESH_COOKIE = "refresh_token"
@pytest_asyncio.fixture(autouse=True)
async def _reset_registration_gating(db_session: AsyncSession) -> None:
"""Сбросить перед каждым тестом настройки, влияющие на публичную регистрацию.
`registration_team_choice`/`registration_email_domain_enabled` — общая
dev-БД, строки могут быть легитимными данными разработчика (включены
вручную через админку) — тесты, которым это не важно, не должны зависеть
от текущего состояния общей БД. Тесты, которым важно обратное, переключают
настройку сами внутри тела теста уже после этого сброса (та же
транзакция — `db_session` никогда не коммитится в реальную БД, см.
`conftest.py`).
"""
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(registration_team_choice=False, registration_email_domain_enabled=False)
)
await db_session.commit()
class _CapturingEmailBackend:
"""Тестовый `EmailBackend`: сохраняет отправленные письма в памяти вместо лога."""
def __init__(self) -> None:
self.sent: list[tuple[str, str, str]] = []
async def send(
self,
*,
to: str,
subject: str,
body: str,
html_body: str | None = None,
attachments: Sequence[EmailAttachment] = (),
) -> None:
self.sent.append((to, subject, body))
def _extract_verification_token(email_body: str) -> str:
match = re.search(r"token=([\w\-]+)", email_body)
assert match is not None, f"токен подтверждения не найден в письме: {email_body!r}"
return match.group(1)
@pytest_asyncio.fixture
async def email_backend(app: FastAPI) -> AsyncGenerator[_CapturingEmailBackend, None]:
"""Подменить email-бэкенд `AuthService` на перехватывающий (для чтения ссылки verify).
`app` — свежий экземпляр на каждый тест (фикстура function-scoped), поэтому
override не нужно откатывать вручную.
"""
backend = _CapturingEmailBackend()
async def _get_auth_service_override(
session: Annotated[AsyncSession, Depends(get_session)],
) -> AuthService:
return AuthService(session=session, redis=redis_client, email_backend=backend)
app.dependency_overrides[get_auth_service] = _get_auth_service_override
yield backend
async def _register_and_verify(
client: httpx.AsyncClient,
email_backend: _CapturingEmailBackend,
*,
email: str = "alice@example.com",
name_user: str = "Alice",
password: str = "supersecret1",
) -> None:
response = await client.post(
"/api/v1/auth/register",
json={"email": email, "name_user": name_user, "password": password},
)
assert response.status_code == 201, response.text
assert email_backend.sent, "письмо с подтверждением не было отправлено"
_, _, body = email_backend.sent[-1]
token = _extract_verification_token(body)
verify_response = await client.post("/api/v1/auth/verify-email", json={"token": token})
assert verify_response.status_code == 204, verify_response.text
async def test_full_cycle_register_verify_login_me(
client: httpx.AsyncClient, email_backend: _CapturingEmailBackend
) -> None:
"""Регистрация → подтверждение email → вход → GET /users/me с access-токеном."""
await _register_and_verify(client, email_backend)
login_response = await client.post(
"/api/v1/auth/token",
data={"username": "alice@example.com", "password": "supersecret1"},
)
assert login_response.status_code == 200, login_response.text
body = login_response.json()
assert body["token_type"] == "bearer"
access_token = body["access_token"]
assert REFRESH_COOKIE in login_response.cookies
me_response = await client.get(
"/api/v1/users/me", headers={"Authorization": f"Bearer {access_token}"}
)
assert me_response.status_code == 200
me_body = me_response.json()
assert me_body["email"] == "alice@example.com"
assert me_body["name_user"] == "Alice"
assert me_body["role"] == "user"
async def test_register_duplicate_email_returns_409(
client: httpx.AsyncClient, email_backend: _CapturingEmailBackend
) -> None:
await _register_and_verify(client, email_backend, email="dup@example.com")
response = await client.post(
"/api/v1/auth/register",
json={"email": "dup@example.com", "name_user": "Dup", "password": "supersecret1"},
)
assert response.status_code == 409
async def test_login_without_verification_returns_403(
client: httpx.AsyncClient, email_backend: _CapturingEmailBackend
) -> None:
register_response = await client.post(
"/api/v1/auth/register",
json={"email": "bob@example.com", "name_user": "Bob", "password": "supersecret1"},
)
assert register_response.status_code == 201
login_response = await client.post(
"/api/v1/auth/token",
data={"username": "bob@example.com", "password": "supersecret1"},
)
assert login_response.status_code == 403
async def test_verify_email_with_invalid_token_returns_400(
client: httpx.AsyncClient, email_backend: _CapturingEmailBackend
) -> None:
response = await client.post("/api/v1/auth/verify-email", json={"token": "does-not-exist"})
assert response.status_code == 400
async def test_protected_endpoint_without_jwt_returns_401(client: httpx.AsyncClient) -> None:
response = await client.get("/api/v1/users/me")
assert response.status_code == 401
async def test_refresh_rotates_token_and_rejects_reuse(
client: httpx.AsyncClient, email_backend: _CapturingEmailBackend
) -> None:
await _register_and_verify(client, email_backend, email="carol@example.com")
login_response = await client.post(
"/api/v1/auth/token",
data={"username": "carol@example.com", "password": "supersecret1"},
)
assert login_response.status_code == 200
old_refresh_token = client.cookies.get(REFRESH_COOKIE)
assert old_refresh_token is not None
refresh_response = await client.post("/api/v1/auth/refresh")
assert refresh_response.status_code == 200
new_refresh_token = client.cookies.get(REFRESH_COOKIE)
assert new_refresh_token is not None
assert new_refresh_token != old_refresh_token
# Повторное предъявление уже потраченного (старого) refresh-токена — reuse.
client.cookies.set(REFRESH_COOKIE, old_refresh_token)
reuse_response = await client.post("/api/v1/auth/refresh")
assert reuse_response.status_code == 401
async def test_refresh_without_cookie_returns_401(client: httpx.AsyncClient) -> None:
response = await client.post("/api/v1/auth/refresh")
assert response.status_code == 401
async def test_logout_revokes_refresh_token(
client: httpx.AsyncClient, email_backend: _CapturingEmailBackend
) -> None:
await _register_and_verify(client, email_backend, email="dave@example.com")
login_response = await client.post(
"/api/v1/auth/token",
data={"username": "dave@example.com", "password": "supersecret1"},
)
assert login_response.status_code == 200
logout_response = await client.post("/api/v1/auth/logout")
assert logout_response.status_code == 204
refresh_response = await client.post("/api/v1/auth/refresh")
assert refresh_response.status_code == 401
# --- Выбор команды при регистрации ---------------------------------------------
async def test_registration_options_disabled_by_default(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Настройка `registration_team_choice` выключена (см. `_reset_registration_gating`)
— команды не отдаются."""
db_session.add(Team(name=f"Zeta Team {uuid.uuid4()}"))
await db_session.commit()
response = await client.get("/api/v1/auth/registration-options")
assert response.status_code == 200, response.text
body = response.json()
assert body["team_choice_enabled"] is False
assert body["teams"] == []
assert body["email_domain"] is None
async def test_registration_options_enabled_returns_teams_sorted_by_name(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Список команд отсортирован по названию; проверяем взаимный порядок своих команд
(не полное равенство списка — в общей dev-БД могут быть и другие команды)."""
suffix = uuid.uuid4().hex[:8]
name_alpha, name_zeta = f"Alpha-{suffix}", f"Zeta-{suffix}"
db_session.add_all([Team(name=name_zeta), Team(name=name_alpha)])
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(registration_team_choice=True)
)
await db_session.commit()
response = await client.get("/api/v1/auth/registration-options")
assert response.status_code == 200, response.text
body = response.json()
assert body["team_choice_enabled"] is True
names = [team["name"] for team in body["teams"]]
assert names == sorted(names)
assert name_alpha in names and name_zeta in names
assert names.index(name_alpha) < names.index(name_zeta)
async def test_register_without_team_id_still_works(
client: httpx.AsyncClient, email_backend: _CapturingEmailBackend
) -> None:
response = await client.post(
"/api/v1/auth/register",
json={"email": "no-team@example.com", "name_user": "No Team", "password": "supersecret1"},
)
assert response.status_code == 201, response.text
async def test_register_with_team_id_when_enabled_assigns_team(
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
) -> None:
team = Team(name=f"Engineering {uuid.uuid4()}")
db_session.add(team)
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(registration_team_choice=True)
)
await db_session.commit()
response = await client.post(
"/api/v1/auth/register",
json={
"email": "with-team@example.com",
"name_user": "With Team",
"password": "supersecret1",
"team_id": str(team.id),
},
)
assert response.status_code == 201, response.text
result = await db_session.execute(select(User).where(User.email == "with-team@example.com"))
created = result.scalar_one()
assert created.team_id == team.id
async def test_register_with_team_id_when_disabled_returns_400(
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
) -> None:
# `registration_team_choice` выключена `_reset_registration_gating`.
team = Team(name=f"Disabled Choice Team {uuid.uuid4()}")
db_session.add(team)
await db_session.commit()
response = await client.post(
"/api/v1/auth/register",
json={
"email": "team-disabled@example.com",
"name_user": "Team Disabled",
"password": "supersecret1",
"team_id": str(team.id),
},
)
assert response.status_code == 400
async def test_register_with_unknown_team_id_returns_400(
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
) -> None:
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(registration_team_choice=True)
)
await db_session.commit()
response = await client.post(
"/api/v1/auth/register",
json={
"email": "unknown-team@example.com",
"name_user": "Unknown Team",
"password": "supersecret1",
"team_id": "00000000-0000-0000-0000-000000000000",
},
)
assert response.status_code == 400
# --- Верификация регистрирующихся по домену email -------------------------------------
async def test_registration_options_returns_email_domain_when_enabled(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""При включённой верификации домена `registration-options` отдаёт эталонный домен."""
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(
registration_email_domain_enabled=True, registration_email_domain="example.com"
)
)
await db_session.commit()
response = await client.get("/api/v1/auth/registration-options")
assert response.status_code == 200, response.text
assert response.json()["email_domain"] == "example.com"
async def test_registration_options_email_domain_null_when_disabled(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Выключенная верификация домена — `email_domain` всегда `null`, даже если домен сохранён."""
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(
registration_email_domain_enabled=True, registration_email_domain="example.com"
)
)
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(registration_email_domain_enabled=False)
)
await db_session.commit()
response = await client.get("/api/v1/auth/registration-options")
assert response.status_code == 200, response.text
assert response.json()["email_domain"] is None
async def test_register_with_foreign_domain_when_verification_enabled_returns_400(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(
registration_email_domain_enabled=True, registration_email_domain="example.com"
)
)
await db_session.commit()
response = await client.post(
"/api/v1/auth/register",
json={
"email": "alice@other.com",
"name_user": "Alice",
"password": "supersecret1",
},
)
assert response.status_code == 400
assert response.json()["detail"] == "invalid_email_domain"
async def test_register_with_matching_domain_case_insensitive_succeeds(
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
) -> None:
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(
registration_email_domain_enabled=True, registration_email_domain="example.com"
)
)
await db_session.commit()
response = await client.post(
"/api/v1/auth/register",
json={
"email": "bob@EXAMPLE.COM",
"name_user": "Bob",
"password": "supersecret1",
},
)
assert response.status_code == 201, response.text
async def test_register_any_domain_allowed_when_verification_disabled(
client: httpx.AsyncClient, email_backend: _CapturingEmailBackend
) -> None:
"""Выключенная верификация (`_reset_registration_gating`) — любой домен email
проходит регистрацию."""
response = await client.post(
"/api/v1/auth/register",
json={
"email": "carol@anything.example",
"name_user": "Carol",
"password": "supersecret1",
},
)
assert response.status_code == 201, response.text

View File

@@ -0,0 +1,125 @@
"""Тесты чистой функции реконструкции фраз `build_phrases` (ТЗ §1.3).
Синтетические таймлайны без БД/сети — алгоритм тестируется в изоляции (TDD).
"""
import uuid
from core.plugins.transcriber import Segment
from workers.transcription.phrases import (
INTERJECTION_THRESHOLD_S,
PhraseDraft,
build_phrases,
)
ALICE = uuid.uuid4()
BOB = uuid.uuid4()
def test_single_speaker_multiple_segments_merge_into_one_phrase() -> None:
"""Несколько сегментов одного спикера → одна фраза с границами min start / max end."""
segments = {ALICE: [Segment(0.0, 2.0, "a"), Segment(3.0, 5.0, "b")]}
offsets = {ALICE: 0.0}
result = build_phrases(segments, offsets)
assert result == [PhraseDraft(participant_id=ALICE, start=0.0, end=5.0, text="a b")]
def test_alice_bob_alice_example_from_spec() -> None:
"""Пример ABA из §1.3: три фразы в порядке t_start."""
segments = {
ALICE: [Segment(0.0, 3.0, "alice1"), Segment(6.0, 9.0, "alice2")],
BOB: [Segment(3.0, 6.0, "bob1")],
}
offsets = {ALICE: 0.0, BOB: 0.0}
result = build_phrases(segments, offsets)
assert result == [
PhraseDraft(participant_id=ALICE, start=0.0, end=3.0, text="alice1"),
PhraseDraft(participant_id=BOB, start=3.0, end=6.0, text="bob1"),
PhraseDraft(participant_id=ALICE, start=6.0, end=9.0, text="alice2"),
]
def test_long_overlap_keeps_both_phrases_with_intersecting_intervals() -> None:
"""Перекрытие речи ≥ порога → сохраняются обе фразы с пересекающимися интервалами."""
segments = {
ALICE: [Segment(0.0, 10.0, "alice-long")],
BOB: [Segment(3.0, 8.0, "bob-overlap")],
}
offsets = {ALICE: 0.0, BOB: 0.0}
assert 8.0 - 3.0 >= INTERJECTION_THRESHOLD_S
result = build_phrases(segments, offsets)
assert result == [
PhraseDraft(participant_id=ALICE, start=0.0, end=10.0, text="alice-long"),
PhraseDraft(participant_id=BOB, start=3.0, end=8.0, text="bob-overlap"),
]
# интервалы действительно пересекаются
alice_phrase, bob_phrase = result
assert bob_phrase.start < alice_phrase.end
assert bob_phrase.end > alice_phrase.start
def test_short_interjection_does_not_break_monologue_but_is_kept_separately() -> None:
"""Короткое «угу» (<1.5с) внутри чужого монолога: монолог одной фразой, вставка — отдельной."""
segments = {
ALICE: [Segment(0.0, 2.0, "one"), Segment(2.5, 5.0, "two")],
BOB: [Segment(2.1, 2.3, "угу")],
}
offsets = {ALICE: 0.0, BOB: 0.0}
assert 2.3 - 2.1 < INTERJECTION_THRESHOLD_S
result = build_phrases(segments, offsets)
assert result == [
PhraseDraft(participant_id=ALICE, start=0.0, end=5.0, text="one two"),
PhraseDraft(participant_id=BOB, start=2.1, end=2.3, text="угу"),
]
def test_silence_gap_does_not_break_single_speaker_phrase() -> None:
"""Тишина (большой зазор) между сегментами одного спикера без чужой речи фразу не рвёт."""
segments = {ALICE: [Segment(0.0, 2.0, "a"), Segment(20.0, 22.0, "b")]}
offsets = {ALICE: 0.0}
result = build_phrases(segments, offsets)
assert result == [PhraseDraft(participant_id=ALICE, start=0.0, end=22.0, text="a b")]
def test_empty_input_returns_empty_list() -> None:
"""Пустой вход → пустой список фраз."""
assert build_phrases({}, {}) == []
def test_participant_with_no_segments_contributes_nothing() -> None:
"""Участник без сегментов не создаёт фраз, но не ломает построение остальных."""
segments = {ALICE: [Segment(0.0, 2.0, "a")], BOB: []}
offsets = {ALICE: 0.0, BOB: 0.0}
result = build_phrases(segments, offsets)
assert result == [PhraseDraft(participant_id=ALICE, start=0.0, end=2.0, text="a")]
def test_track_offsets_shift_timeline() -> None:
"""Смещения треков (`track_offsets`) корректно сдвигают таймлайн сегментов."""
segments = {
ALICE: [Segment(0.0, 2.0, "alice-early-on-track")],
BOB: [Segment(0.0, 2.0, "bob-early-on-track")],
}
# трек Боба стартовал позже (offset больше) → на общем таймлайне сеанса
# его сегмент оказывается ПОСЛЕ сегмента Алисы, хотя на своих треках
# оба сегмента начинались с нуля.
offsets = {ALICE: 10.0, BOB: 20.0}
result = build_phrases(segments, offsets)
assert result == [
PhraseDraft(participant_id=ALICE, start=10.0, end=12.0, text="alice-early-on-track"),
PhraseDraft(participant_id=BOB, start=20.0, end=22.0, text="bob-early-on-track"),
]

View File

@@ -0,0 +1,51 @@
"""Тесты маршрутизации задач Celery по очередям.
Проверяем не сами паттерны `task_routes` (это конфигурация), а результат их
резолвинга Celery-роутером `app.amqp.router` для КОНКРЕТНЫХ имён задач,
которые реально ставятся в очередь (`workers/tasks/dispatch.py`,
`workers/tasks/maintenance.py`, `backend/services/pipeline_producer.py`,
`backend/services/invitations_producer.py`) — регрессия на опечатку в
паттерне (например, `workers.tasks.notify.*` вместо `workers.tasks.notify`)
будет поймана здесь, а не только при ручном прогоне в проде. Не требует
подключения к брокеру: `Router.route` — чистая функция над `task_routes`.
"""
from workers.celery_app import app
def _resolved_queue(task_name: str) -> str:
"""Имя очереди, в которую Celery-роутер направит задачу `task_name`."""
route = app.amqp.router.route({}, task_name)
queue = route["queue"]
return str(queue.name) if hasattr(queue, "name") else str(queue)
def test_run_pipeline_routes_to_transcription_queue() -> None:
"""`run_pipeline` — выделенная очередь `transcription` (только `worker-transcriber`)."""
assert _resolved_queue("workers.tasks.pipeline.run_pipeline") == "transcription"
def test_summarize_session_routes_to_summarize_queue() -> None:
"""`summarize_session` — очередь `summarize`."""
assert _resolved_queue("workers.tasks.summarize.summarize_session") == "summarize"
def test_notify_session_routes_to_notify_queue() -> None:
"""`notify_session` — очередь `notify`."""
assert _resolved_queue("workers.tasks.notify.notify_session") == "notify"
def test_send_invitations_routes_to_notify_queue() -> None:
"""`send_invitations` (рассылка .ics-приглашений) — тоже очередь `notify`."""
assert _resolved_queue("workers.tasks.invitations.send_invitations") == "notify"
def test_maintenance_tasks_route_to_default_celery_queue() -> None:
"""Задачи обслуживания без явного маршрута остаются на дефолтной очереди `celery`.
Базовый `worker` слушает `celery,summarize,notify` (`docs/deploy/scaling.md`) —
маршрутизация обслуживающих задач намеренно не переопределяется.
"""
assert _resolved_queue("workers.tasks.maintenance.cleanup_conferences") == "celery"
assert _resolved_queue("workers.tasks.maintenance.recover_stuck_summaries") == "celery"
assert _resolved_queue("workers.tasks.maintenance.recover_stuck_notifications") == "celery"

View File

@@ -0,0 +1,526 @@
"""Тесты WS-чата конференции (`WS /api/v1/conferences/{id}/chat`).
Интеграционные тесты используют `ws_client` — in-process ASGI websocket
клиент (см. `tests/conftest.py::ASGIWebSocketSession`), т.к. ни
`httpx.AsyncClient`, ни `starlette.testclient.TestClient` не подходят для
websocket-тестов поверх нашей savepoint-сессии БД (см. докстринг класса).
"""
import uuid
from collections.abc import Callable
from datetime import UTC, datetime
from typing import Any
import httpx
import jwt
import pytest
from pydantic import ValidationError
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from core.redis import redis_client
from core.security import create_access_token, hash_password
from models.chat import ChatMessage
from models.conference import Conference
from models.guest import GuestAccess
from models.session import ConferenceSession
from models.user import User
from repositories.chat import ChatMessageRepository
from repositories.conferences import ConferenceSessionRepository
from schemas.chat import ChatMessageIn, ChatMessageOut
from services.chat import ChatService, chat_channel
from services.conference_ids import generate_number, generate_slug
from services.instance_settings import InstanceSettingsService, SettingsUpdateIn
from services.livekit_tokens import create_room_access_token
from tests.conftest import ASGIWebSocketSession
WSFactory = Callable[[str], ASGIWebSocketSession]
# --- Хелперы ---------------------------------------------------------------
async def _make_user(session: AsyncSession, *, name: str = "Chat Tester") -> User:
user = User(
email=f"{uuid.uuid4()}@example.com",
name_user=name,
password_hash=hash_password("password123"),
email_verified=True,
)
session.add(user)
await session.flush()
return user
async def _make_conference(session: AsyncSession, *, status: str = "active") -> Conference:
conference = Conference(
number=generate_number(), slug=generate_slug(), title="Chat Test", status=status
)
session.add(conference)
await session.flush()
return conference
async def _make_guest(session: AsyncSession, conference: Conference, *, name: str) -> GuestAccess:
guest = GuestAccess(conference_id=conference.id, display_name=name)
session.add(guest)
await session.flush()
return guest
def _chat_path(conference_id: uuid.UUID) -> str:
return f"/api/v1/conferences/{conference_id}/chat"
def _user_token(conference: Conference, user: User) -> str:
return create_room_access_token(
room_name=conference.slug, identity=str(user.id), name=user.name_user
)
def _guest_token(conference: Conference, guest: GuestAccess) -> str:
return create_room_access_token(
room_name=conference.slug, identity=f"guest:{guest.id}", name=guest.display_name
)
async def _connect_and_auth(session: ASGIWebSocketSession, token: str) -> dict[str, Any]:
"""Подключиться, аутентифицироваться и вернуть первое сообщение (`history`)."""
accept = await session.connect()
assert accept["type"] == "websocket.accept"
await session.send_json({"type": "auth", "token": token})
return await session.receive_json()
# --- Основной сценарий: обмен сообщениями + история -------------------------
async def test_two_clients_exchange_messages_and_history_on_reconnect(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
conference = await _make_conference(db_session)
user1 = await _make_user(db_session, name="Alice")
user2 = await _make_user(db_session, name="Bob")
await db_session.commit()
token1 = _user_token(conference, user1)
token2 = _user_token(conference, user2)
path = _chat_path(conference.id)
ws1 = ws_client(path)
history1 = await _connect_and_auth(ws1, token1)
assert history1 == {"type": "history", "messages": []}
ws2 = ws_client(path)
history2 = await _connect_and_auth(ws2, token2)
assert history2 == {"type": "history", "messages": []}
await ws1.send_json({"type": "message", "text": "hello from alice"})
echo = await ws1.receive_json()
assert echo["type"] == "message"
assert echo["message"]["text"] == "hello from alice"
assert echo["message"]["author_name"] == "Alice"
assert echo["message"]["is_guest"] is False
assert echo["message"]["author_id"] == str(user1.id)
assert echo["message"]["created_at"].endswith("Z")
broadcast = await ws2.receive_json()
assert broadcast == echo
await ws1.aclose()
await ws2.aclose()
ws3 = ws_client(path)
history3 = await _connect_and_auth(ws3, _user_token(conference, user1))
assert len(history3["messages"]) == 1
assert history3["messages"][0]["text"] == "hello from alice"
# --- Гонка history vs pubsub subscribe --------------------------------------
async def test_subscribe_happens_before_history_query(
db_session: AsyncSession, ws_client: WSFactory, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Regression: подписка на pub/sub должна происходить ДО SELECT истории.
Иначе сообщение, опубликованное другим клиентом в этом окне, теряется
для подключающегося клиента.
"""
conference = await _make_conference(db_session)
user = await _make_user(db_session)
await db_session.commit()
call_order: list[str] = []
original_history = ChatService.history
async def _tracked_history(self: ChatService, conference: Conference) -> list[ChatMessageOut]:
call_order.append("history")
return await original_history(self, conference)
monkeypatch.setattr(ChatService, "history", _tracked_history)
import redis.asyncio.client as redis_client_module
original_subscribe = redis_client_module.PubSub.subscribe
async def _tracked_subscribe(self: Any, *args: Any, **kwargs: Any) -> Any:
call_order.append("subscribe")
return await original_subscribe(self, *args, **kwargs)
monkeypatch.setattr(redis_client_module.PubSub, "subscribe", _tracked_subscribe)
token = _user_token(conference, user)
ws = ws_client(_chat_path(conference.id))
await _connect_and_auth(ws, token)
assert call_order == ["subscribe", "history"]
async def test_no_duplicate_when_message_already_in_history(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
"""Сообщение, уже попавшее в history, не должно продублироваться через pub/sub.
Имитирует стык гонки: то же сообщение (тот же `id`) публикуется в канал
уже ПОСЛЕ того, как клиент получил его в `history` — дедуп по `id`
(`_pump_pubsub_to_websocket`) должен его отфильтровать.
"""
conference = await _make_conference(db_session)
user = await _make_user(db_session, name="Eve")
await db_session.commit()
session_record = await ConferenceSessionRepository(db_session).create(
conference_id=conference.id, title=conference.title, t_start=datetime.now(UTC)
)
existing = await ChatMessageRepository(db_session).add(
session_id=session_record.id,
user_id=user.id,
guest_access_id=None,
author_name="Eve",
text="already there",
)
await db_session.commit()
token = _user_token(conference, user)
ws = ws_client(_chat_path(conference.id))
history = await _connect_and_auth(ws, token)
assert len(history["messages"]) == 1
assert history["messages"][0]["id"] == existing.id
duplicate_payload = ChatMessageOut(
id=existing.id,
author_id=str(user.id),
author_name="Eve",
is_guest=False,
text="already there",
created_at=existing.created_at,
)
await redis_client.publish(chat_channel(conference.id), duplicate_payload.model_dump_json())
# Настоящее новое сообщение отправляем следом — оно должно дойти БЕЗ
# опережающего дубликата (если бы дедуп не работал, первым пришёл бы
# повтор "already there").
await ws.send_json({"type": "message", "text": "genuinely new"})
received = await ws.receive_json()
assert received["type"] == "message"
assert received["message"]["text"] == "genuinely new"
# --- Auth: коды закрытия ----------------------------------------------------
async def test_no_auth_message_closes_4401(db_session: AsyncSession, ws_client: WSFactory) -> None:
conference = await _make_conference(db_session)
await db_session.commit()
ws = ws_client(_chat_path(conference.id))
accept = await ws.connect()
assert accept["type"] == "websocket.accept"
# Первым сообщением шлём НЕ auth (нарушение протокола) — трактуется как отсутствие auth.
await ws.send_json({"type": "message", "text": "too early"})
code = await ws.receive_close()
assert code == 4401
async def test_garbage_token_closes_4401(db_session: AsyncSession, ws_client: WSFactory) -> None:
conference = await _make_conference(db_session)
await db_session.commit()
ws = ws_client(_chat_path(conference.id))
await ws.connect()
await ws.send_json({"type": "auth", "token": "not-a-jwt-at-all"})
code = await ws.receive_close()
assert code == 4401
async def test_non_livekit_jwt_closes_4401(db_session: AsyncSession, ws_client: WSFactory) -> None:
conference = await _make_conference(db_session)
await db_session.commit()
# Синтаксически валидный JWT, но подписан не тем секретом (не LiveKit).
foreign_token = jwt.encode(
{"sub": "someone"}, "wrong-secret-not-livekit-at-all-32-bytes", algorithm="HS256"
)
ws = ws_client(_chat_path(conference.id))
await ws.connect()
await ws.send_json({"type": "auth", "token": foreign_token})
code = await ws.receive_close()
assert code == 4401
async def test_wrong_room_token_closes_4403(db_session: AsyncSession, ws_client: WSFactory) -> None:
conference_a = await _make_conference(db_session)
conference_b = await _make_conference(db_session)
user = await _make_user(db_session)
await db_session.commit()
# Токен выдан для конференции A, подключаемся к B.
token_for_a = _user_token(conference_a, user)
ws = ws_client(_chat_path(conference_b.id))
await ws.connect()
await ws.send_json({"type": "auth", "token": token_for_a})
code = await ws.receive_close()
assert code == 4403
async def test_chat_disabled_closes_4404_without_restart(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
conference = await _make_conference(db_session)
user = await _make_user(db_session)
await db_session.commit()
token = _user_token(conference, user)
path = _chat_path(conference.id)
# До выключения тоггла подключение работает штатно.
ws1 = ws_client(path)
history = await _connect_and_auth(ws1, token)
assert history["type"] == "history"
await ws1.aclose()
await InstanceSettingsService(db_session).update(SettingsUpdateIn(chat_enabled=False))
# Тоггл действует немедленно, без рестарта backend — следующее подключение отклоняется.
ws2 = ws_client(path)
await ws2.connect()
await ws2.send_json({"type": "auth", "token": token})
code = await ws2.receive_close()
assert code == 4404
async def test_conference_not_found_closes_4404(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
conference = await _make_conference(db_session)
user = await _make_user(db_session)
await db_session.commit()
# Токен валиден (комната существует), но подключаемся по чужому/несуществующему id.
token = _user_token(conference, user)
ws = ws_client(_chat_path(uuid.uuid4()))
await ws.connect()
await ws.send_json({"type": "auth", "token": token})
code = await ws.receive_close()
assert code == 4404
# --- Сообщение после room_finished ------------------------------------------
async def test_message_after_conference_ended_rejected_without_phantom_session(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
"""LiveKit-токен (TTL 6 часов) может пережить конференцию — сообщение отклоняется.
Сценарий: клиент подключился, пока конференция была активна; сессия
пайплайна закрывается (`t_end` проставлен, как это делает webhook
`room_finished`), а статус конференции переводится в `ended` СЫРЫМ SQL
(в обход ORM identity map — имитация того, что вебхук работает в ДРУГОЙ
сессии/процессе и `expire_on_commit=False` не даёт уже загруженному
объекту `conference` увидеть новый статус). Отправленное после этого
сообщение должно быть отклонено (close 4404), новая "фантомная" открытая
сессия — НЕ создана, сообщение — НЕ сохранено.
"""
conference = await _make_conference(db_session, status="active")
user = await _make_user(db_session)
await db_session.commit()
token = _user_token(conference, user)
ws = ws_client(_chat_path(conference.id))
await _connect_and_auth(ws, token)
sessions_repo = ConferenceSessionRepository(db_session)
session_record = await sessions_repo.get_open_by_conference(conference.id)
if session_record is None:
session_record = await sessions_repo.create(
conference_id=conference.id, title=conference.title, t_start=datetime.now(UTC)
)
await sessions_repo.close(session_record, t_end=datetime.now(UTC))
# Сырой UPDATE — намеренно в обход ORM, чтобы не обновить закэшированный
# в текущей сессии Python-объект `conference` (имитация другой сессии/процесса).
await db_session.execute(
text("UPDATE conferences SET status = 'ended' WHERE id = :id"), {"id": conference.id}
)
await db_session.commit()
await ws.send_json({"type": "message", "text": "too late"})
code = await ws.receive_close()
assert code == 4404
sessions = (
(
await db_session.execute(
select(ConferenceSession).where(ConferenceSession.conference_id == conference.id)
)
)
.scalars()
.all()
)
assert len(sessions) == 1
assert sessions[0].id == session_record.id
assert sessions[0].t_end is not None
stray = (
await db_session.execute(select(ChatMessage).where(ChatMessage.text == "too late"))
).scalar_one_or_none()
assert stray is None
async def test_message_before_room_started_creates_session_for_active_conference(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
"""Легитимный случай: первое сообщение до webhook `room_started`.
Открытой сессии ещё нет, но конференция активна (не `ended`) — создание
новой сессии допустимо (в отличие от случая, когда конференция уже
завершена).
"""
conference = await _make_conference(db_session, status="active")
user = await _make_user(db_session)
await db_session.commit()
token = _user_token(conference, user)
ws = ws_client(_chat_path(conference.id))
await _connect_and_auth(ws, token)
await ws.send_json({"type": "message", "text": "before room_started"})
echo = await ws.receive_json()
assert echo["message"]["text"] == "before room_started"
sessions = (
(
await db_session.execute(
select(ConferenceSession).where(ConferenceSession.conference_id == conference.id)
)
)
.scalars()
.all()
)
assert len(sessions) == 1
assert sessions[0].t_end is None
# --- Гость -------------------------------------------------------------------
async def test_guest_message_persists_guest_access_id_and_author_name(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
conference = await _make_conference(db_session)
guest = await _make_guest(db_session, conference, name="Guest Carol")
await db_session.commit()
token = _guest_token(conference, guest)
ws = ws_client(_chat_path(conference.id))
await _connect_and_auth(ws, token)
await ws.send_json({"type": "message", "text": "hi from guest"})
echo = await ws.receive_json()
assert echo["message"]["author_name"] == "Guest Carol"
assert echo["message"]["is_guest"] is True
assert echo["message"]["author_id"] == str(guest.id)
await ws.aclose()
row = (
await db_session.execute(select(ChatMessage).where(ChatMessage.text == "hi from guest"))
).scalar_one()
assert row.guest_access_id == guest.id
assert row.user_id is None
assert row.author_name == "Guest Carol"
# --- REST: JoinOut.chat_enabled ----------------------------------------------
async def test_join_out_reflects_chat_enabled_toggle(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
headers = {"Authorization": f"Bearer {create_access_token(user.id, user.role)}"}
response = await client.post("/api/v1/conferences", json={"title": "Standup"}, headers=headers)
assert response.status_code == 201, response.text
assert response.json()["join"]["chat_enabled"] is True
await InstanceSettingsService(db_session).update(SettingsUpdateIn(chat_enabled=False))
response = await client.post(
"/api/v1/conferences", json={"title": "Standup 2"}, headers=headers
)
assert response.status_code == 201, response.text
assert response.json()["join"]["chat_enabled"] is False
async def test_guest_join_out_reflects_chat_enabled(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
conference = await _make_conference(db_session)
await db_session.commit()
await InstanceSettingsService(db_session).update(SettingsUpdateIn(chat_enabled=False))
response = await client.post(
f"/api/v1/conferences/{conference.id}/guest-join", json={"display_name": "Dave"}
)
assert response.status_code == 200, response.text
assert response.json()["chat_enabled"] is False
# --- Юнит: схема протокола ----------------------------------------------------
def test_chat_message_in_rejects_empty_text_after_strip() -> None:
with pytest.raises(ValidationError):
ChatMessageIn(type="message", text=" ")
def test_chat_message_in_rejects_too_long_text() -> None:
with pytest.raises(ValidationError):
ChatMessageIn(type="message", text="a" * 2001)
def test_chat_message_in_strips_surrounding_whitespace() -> None:
parsed = ChatMessageIn(type="message", text=" hello ")
assert parsed.text == "hello"
def test_chat_message_out_serializes_created_at_as_utc_z() -> None:
out = ChatMessageOut(
id=1,
author_id="abc",
author_name="Alice",
is_guest=False,
text="hi",
created_at=datetime(2026, 7, 18, 12, 0, 0, tzinfo=UTC),
)
dumped = out.model_dump(mode="json")
assert dumped["created_at"] == "2026-07-18T12:00:00Z"

View File

@@ -0,0 +1,176 @@
"""Тесты чистой функции чанкинга транскрипта `chunk_transcript` (ТЗ §1.3).
Синтетические транскрипты (строки `[Имя MM:SS] текст`) без сети/БД/токенизатора
— подсчёт токенов в тестах подменяется фейковым callable (TDD).
"""
from core.summarization.chunking import chunk_transcript
def _word_count(text: str) -> int:
"""Фейковый подсчёт токенов для тестов: число слов (детерминированно и
без сторонних зависимостей — реальный `QwenTokenCounter` тестируется
отдельно)."""
return len(text.split())
def _line(speaker: str, offset_s: float, text: str) -> str:
"""Собрать строку-фразу транскрипта в формате `[Имя MM:SS] текст`."""
minutes, seconds = divmod(int(offset_s), 60)
return f"[{speaker} {minutes:02d}:{seconds:02d}] {text}"
def test_empty_transcript_returns_empty_list() -> None:
"""Пустой транскрипт → пустой список чанков."""
assert chunk_transcript("", _word_count) == []
def test_blank_transcript_returns_empty_list() -> None:
"""Транскрипт из одних пустых строк — тоже пустой вход."""
assert chunk_transcript("\n\n \n", _word_count) == []
def test_short_meeting_fits_in_single_chunk() -> None:
"""Короткая встреча (мало времени и токенов) → один чанк со всеми фразами."""
lines = [
_line("Аня", 0, "привет всем"),
_line("Боря", 3, "привет как дела"),
_line("Аня", 8, "всё хорошо начинаем"),
]
transcript = "\n".join(lines)
chunks = chunk_transcript(
transcript, _word_count, max_chunk_tokens=8000, target_chunk_minutes=20
)
assert chunks == [transcript]
def test_long_meeting_splits_into_several_chunks_respecting_token_limit() -> None:
"""Длинная встреча: несколько чанков, ни один не превышает max_chunk_tokens,
все исходные фразы сохранены без потерь, порядок не нарушен."""
lines = []
for i in range(40):
speaker = "Аня" if i % 2 == 0 else "Боря"
text = " ".join(["слово"] * 20) # по 20 токенов на фразу
lines.append(_line(speaker, i * 5, text)) # фразы каждые 5 секунд
transcript = "\n".join(lines)
chunks = chunk_transcript(
transcript, _word_count, max_chunk_tokens=100, target_chunk_minutes=20
)
assert len(chunks) > 1
for chunk in chunks:
assert _word_count(chunk) <= 100
# все исходные фразы присутствуют ровно один раз, порядок сохранён
reconstructed = [line for chunk in chunks for line in chunk.splitlines()]
assert reconstructed == lines
def test_chunk_closes_on_target_minutes_even_with_tokens_to_spare() -> None:
"""Чанк закрывается по достижении target_chunk_minutes, даже если лимит
токенов ещё далеко не исчерпан; граница — строго на границе фразы."""
lines = [_line("Аня" if i % 2 == 0 else "Боря", i * 5, "слово") for i in range(20)]
transcript = "\n".join(lines)
chunks = chunk_transcript(
transcript, _word_count, max_chunk_tokens=100_000, target_chunk_minutes=1
)
assert len(chunks) > 1
# каждая строка каждого чанка — целая исходная фраза, ничего не обрезано
all_lines_in_chunks = [line for chunk in chunks for line in chunk.splitlines()]
assert all_lines_in_chunks == lines
def test_hh_mm_ss_label_offset_triggers_boundary_across_hour_gap() -> None:
"""Метка `[Имя ЧЧ:MM:SS]` парсится корректно: часовой разрыв между двумя
фразами закрывает чанк по времени при малом target_chunk_minutes."""
transcript = "\n".join(
[
"[Аня 00:00] начало встречи",
"[Аня 1:00:05] через час — подведём итоги",
]
)
chunks = chunk_transcript(
transcript, _word_count, max_chunk_tokens=8000, target_chunk_minutes=1
)
assert len(chunks) == 2
assert chunks[0] == "[Аня 00:00] начало встречи"
assert chunks[1] == "[Аня 1:00:05] через час — подведём итоги"
def test_mm_ss_label_under_hour_kept_in_one_chunk_when_within_target() -> None:
"""Метка `[Имя MM:SS]` (до часа): фразы укладываются в target_chunk_minutes
и попадают в один чанк."""
transcript = "\n".join(
[
"[Аня 00:00] первая фраза",
"[Боря 00:30] вторая фраза",
]
)
chunks = chunk_transcript(
transcript, _word_count, max_chunk_tokens=8000, target_chunk_minutes=20
)
assert chunks == [transcript]
def test_monologue_exceeding_token_limit_splits_by_sentences_with_repeated_label() -> None:
"""Монолог одной фразой (40 минут) больше max_chunk_tokens: делится по
предложениям, каждая часть — отдельный чанк с повторённой меткой спикера,
ни одна часть не превышает лимит, предложения не разорваны."""
sentences = [f"Пункт номер {i} с некоторыми словами тут." for i in range(30)]
monologue_text = " ".join(sentences)
transcript = _line("Иван", 0, monologue_text)
chunks = chunk_transcript(transcript, _word_count, max_chunk_tokens=20, target_chunk_minutes=20)
assert len(chunks) > 1
for chunk in chunks:
assert chunk.startswith("[Иван 00:00]")
assert _word_count(chunk) <= 20
# ни одно предложение не потеряно и не разорвано пополам
recombined_text = " ".join(chunk.removeprefix("[Иван 00:00] ") for chunk in chunks)
for sentence in sentences:
assert sentence in recombined_text
def test_monologue_alone_still_produces_single_chunk_when_within_limit() -> None:
"""Одна длинная фраза, которая тем не менее укладывается в лимит токенов,
не должна делиться по предложениям — она атомарна."""
transcript = _line("Иван", 0, "Это не очень длинный монолог. Всего два предложения.")
chunks = chunk_transcript(
transcript, _word_count, max_chunk_tokens=8000, target_chunk_minutes=20
)
assert chunks == [transcript]
def test_normal_phrases_are_never_split_across_or_within_chunks() -> None:
"""В обычном случае (без аварийного монолога) каждая исходная строка-фраза
целиком попадает ровно в один чанк — ни разрывов внутри строки, ни
дублирования между чанками."""
lines = [
_line("Аня", 0, "первая фраза без разрывов"),
_line("Боря", 2, "вторая фраза целиком"),
_line("Аня", 500, "третья фраза после большой паузы"),
_line("Боря", 505, "четвёртая фраза"),
]
transcript = "\n".join(lines)
chunks = chunk_transcript(
transcript, _word_count, max_chunk_tokens=8000, target_chunk_minutes=5
)
all_lines_in_chunks = [line for chunk in chunks for line in chunk.splitlines()]
assert all_lines_in_chunks == lines
# каждая фраза встречается ровно в одном чанке
assert len(all_lines_in_chunks) == len(lines)

View File

@@ -0,0 +1,92 @@
"""Юнит-тесты модели `ConferenceInvitee`: CHECK «ровно одна identity» и частичные
UNIQUE-индексы (ADR-003, `docs/architecture/adr/003-conference-invitees.md`).
"""
import uuid
import pytest
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from core.security import hash_password
from models.conference import Conference
from models.invitee import ConferenceInvitee
from models.user import User
from services.conference_ids import generate_number, generate_slug
async def _make_user(session: AsyncSession) -> User:
user = User(
email=f"{uuid.uuid4()}@example.com",
name_user="Invitee Tester",
password_hash=hash_password("password123"),
email_verified=True,
)
session.add(user)
await session.flush()
return user
async def _make_conference(
session: AsyncSession, *, owner_id: uuid.UUID | None = None
) -> Conference:
conference = Conference(
number=generate_number(), slug=generate_slug(), owner_id=owner_id, status="scheduled"
)
session.add(conference)
await session.flush()
return conference
async def test_neither_user_id_nor_email_violates_check(db_session: AsyncSession) -> None:
conference = await _make_conference(db_session)
db_session.add(ConferenceInvitee(conference_id=conference.id))
with pytest.raises(IntegrityError):
await db_session.commit()
async def test_both_user_id_and_email_violates_check(db_session: AsyncSession) -> None:
conference = await _make_conference(db_session)
invitee_user = await _make_user(db_session)
await db_session.commit()
db_session.add(
ConferenceInvitee(
conference_id=conference.id, user_id=invitee_user.id, email="x@example.com"
)
)
with pytest.raises(IntegrityError):
await db_session.commit()
async def test_duplicate_user_id_on_same_conference_violates_unique(
db_session: AsyncSession,
) -> None:
conference = await _make_conference(db_session)
invitee_user = await _make_user(db_session)
db_session.add(ConferenceInvitee(conference_id=conference.id, user_id=invitee_user.id))
await db_session.commit()
db_session.add(ConferenceInvitee(conference_id=conference.id, user_id=invitee_user.id))
with pytest.raises(IntegrityError):
await db_session.commit()
async def test_duplicate_email_case_insensitive_violates_unique(db_session: AsyncSession) -> None:
conference = await _make_conference(db_session)
unique_email = f"guest-{uuid.uuid4()}@example.com"
db_session.add(ConferenceInvitee(conference_id=conference.id, email=unique_email))
await db_session.commit()
db_session.add(ConferenceInvitee(conference_id=conference.id, email=unique_email.upper()))
with pytest.raises(IntegrityError):
await db_session.commit()
async def test_same_email_on_different_conferences_is_allowed(db_session: AsyncSession) -> None:
conference_a = await _make_conference(db_session)
conference_b = await _make_conference(db_session)
shared_email = f"shared-{uuid.uuid4()}@example.com"
db_session.add(ConferenceInvitee(conference_id=conference_a.id, email=shared_email))
db_session.add(ConferenceInvitee(conference_id=conference_b.id, email=shared_email))
await db_session.commit() # не должно бросить — разные конференции

View File

@@ -0,0 +1,450 @@
"""Юнит-тесты `services/conferences.py`: retry при коллизии номера/slug, резолв,
постановка рассылки .ics-приглашений и инкремент `ics_sequence`.
"""
import uuid
from datetime import UTC, datetime, timedelta
from unittest.mock import MagicMock
import pytest
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
import services.conferences as conferences_module
from core.security import hash_password
from models.conference import Conference
from models.user import User
from schemas.conferences import ConferenceCreateIn, ConferenceUpdateIn, InviteeIn
from services.conference_ids import generate_number, generate_slug
from services.conferences import ConferenceService, InviteeUserNotFoundError
FUTURE = datetime.now(UTC) + timedelta(days=3)
async def _make_user(session: AsyncSession, *, name: str = "Service Tester") -> User:
user = User(
email=f"{uuid.uuid4()}@example.com",
name_user=name,
password_hash=hash_password("password123"),
email_verified=True,
)
session.add(user)
await session.flush()
return user
async def test_create_retries_on_number_collision_and_succeeds(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Первая попытка сталкивается с занятым номером, вторая — успевает с новым."""
owner = await _make_user(db_session)
existing = Conference(number="123456789", slug=generate_slug(), status="active")
db_session.add(existing)
await db_session.commit()
fresh_number = generate_number()
while fresh_number == "123456789":
fresh_number = generate_number()
attempts = iter(["123456789", fresh_number])
monkeypatch.setattr(conferences_module, "generate_number", lambda: next(attempts))
service = ConferenceService(db_session)
conference, join = await service.create(owner=owner, data=ConferenceCreateIn())
assert conference.number == fresh_number
assert join is not None
async def test_create_gives_up_after_max_attempts(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""При постоянной коллизии номера и slug после 5 попыток исключение всплывает."""
owner = await _make_user(db_session)
existing = Conference(number="223456789", slug="always-colliding-slug", status="active")
db_session.add(existing)
await db_session.commit()
attempt_count = 0
def _always_colliding_number() -> str:
nonlocal attempt_count
attempt_count += 1
return "223456789"
monkeypatch.setattr(conferences_module, "generate_number", _always_colliding_number)
monkeypatch.setattr(conferences_module, "generate_slug", lambda: "always-colliding-slug")
service = ConferenceService(db_session)
with pytest.raises(IntegrityError):
await service.create(owner=owner, data=ConferenceCreateIn())
assert attempt_count == conferences_module.MAX_ID_GENERATION_ATTEMPTS
async def test_resolve_returns_none_for_unknown_query(db_session: AsyncSession) -> None:
service = ConferenceService(db_session)
assert await service.resolve("nothing-like-this-exists") is None
async def test_resolve_by_number_strips_internal_spaces(db_session: AsyncSession) -> None:
conference = Conference(number=generate_number(), slug=generate_slug(), status="active")
db_session.add(conference)
await db_session.commit()
service = ConferenceService(db_session)
number = conference.number
grouped = f"{number[:3]} {number[3:6]} {number[6:]}"
resolved = await service.resolve(grouped)
assert resolved is not None
assert resolved.id == conference.id
# --- Постановка `send_invitations` + `ics_sequence` ----------
async def test_create_instant_conference_does_not_enqueue_invitations(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
owner = await _make_user(db_session)
await db_session.commit()
mock_enqueue = MagicMock()
monkeypatch.setattr(conferences_module, "enqueue_invitations", mock_enqueue)
service = ConferenceService(db_session)
await service.create(owner=owner, data=ConferenceCreateIn())
mock_enqueue.assert_not_called()
async def test_create_scheduled_conference_enqueues_invitations(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
owner = await _make_user(db_session)
await db_session.commit()
mock_enqueue = MagicMock()
monkeypatch.setattr(conferences_module, "enqueue_invitations", mock_enqueue)
service = ConferenceService(db_session)
conference, _ = await service.create(owner=owner, data=ConferenceCreateIn(scheduled_at=FUTURE))
mock_enqueue.assert_called_once_with(conference.id)
async def test_create_with_summary_recipients_override_is_persisted(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
owner = await _make_user(db_session)
await db_session.commit()
monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())
service = ConferenceService(db_session)
conference, _ = await service.create(
owner=owner,
data=ConferenceCreateIn(scheduled_at=FUTURE, summary_recipients="owner"),
)
assert conference.summary_recipients == "owner"
assert service.to_out(conference).summary_recipients == "owner"
async def test_update_schedule_change_increments_sequence_and_enqueues(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
owner = await _make_user(db_session)
await db_session.commit()
monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())
service = ConferenceService(db_session)
conference, _ = await service.create(
owner=owner, data=ConferenceCreateIn(scheduled_at=FUTURE, duration_minutes=30)
)
assert conference.ics_sequence == 0
mock_enqueue = MagicMock()
monkeypatch.setattr(conferences_module, "enqueue_invitations", mock_enqueue)
new_time = FUTURE + timedelta(hours=1)
updated = await service.update(
conference.id, actor=owner, data=ConferenceUpdateIn(scheduled_at=new_time)
)
assert updated.ics_sequence == 1
mock_enqueue.assert_called_once_with(conference.id)
async def test_update_unrelated_field_does_not_touch_sequence(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
owner = await _make_user(db_session)
await db_session.commit()
monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())
service = ConferenceService(db_session)
conference, _ = await service.create(
owner=owner, data=ConferenceCreateIn(scheduled_at=FUTURE, password=None)
)
mock_enqueue = MagicMock()
monkeypatch.setattr(conferences_module, "enqueue_invitations", mock_enqueue)
updated = await service.update(
conference.id, actor=owner, data=ConferenceUpdateIn(is_closed=True, password="secret1")
)
assert updated.ics_sequence == 0
mock_enqueue.assert_not_called()
async def test_update_summary_recipients_explicit_null_resets_override(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
owner = await _make_user(db_session)
await db_session.commit()
monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())
service = ConferenceService(db_session)
conference, _ = await service.create(
owner=owner,
data=ConferenceCreateIn(scheduled_at=FUTURE, summary_recipients="owner"),
)
assert conference.summary_recipients == "owner"
updated = await service.update(
conference.id, actor=owner, data=ConferenceUpdateIn(summary_recipients=None)
)
assert updated.summary_recipients is None
# --- Постановка рассылки не должна ронять запрос при недоступности брокера ------------
async def test_create_survives_enqueue_invitations_failure(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Конференция уже закоммичена — сбой постановки .ics-рассылки (Redis недоступен)
не должен ронять `create` 500-й; компенсация — ручная рассылка из админки."""
owner = await _make_user(db_session)
await db_session.commit()
monkeypatch.setattr(
conferences_module,
"enqueue_invitations",
MagicMock(side_effect=ConnectionError("брокер недоступен")),
)
service = ConferenceService(db_session)
conference, join = await service.create(
owner=owner, data=ConferenceCreateIn(scheduled_at=FUTURE)
)
assert join is None
assert conference.id is not None
reloaded = await ConferenceService(db_session).resolve(conference.number)
assert reloaded is not None
assert reloaded.id == conference.id
async def test_update_survives_enqueue_invitations_failure(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Аналогично для `update`: правка расписания сохраняется и `ics_sequence`
растёт, даже если постановку рассылки не удалось поставить в очередь."""
owner = await _make_user(db_session)
await db_session.commit()
monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())
service = ConferenceService(db_session)
conference, _ = await service.create(
owner=owner, data=ConferenceCreateIn(scheduled_at=FUTURE, duration_minutes=30)
)
monkeypatch.setattr(
conferences_module,
"enqueue_invitations",
MagicMock(side_effect=ConnectionError("брокер недоступен")),
)
new_time = FUTURE + timedelta(hours=1)
updated = await service.update(
conference.id, actor=owner, data=ConferenceUpdateIn(scheduled_at=new_time)
)
assert updated.ics_sequence == 1
assert updated.scheduled_at == new_time
# --- Участники (ADR-003) ---------------------------------------
async def test_create_participants_dedupes_owner_and_lists_organizer_first(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Организатор всегда первый в `participants` и неудаляем — передача его же
(по `user_id` и по email) в списке молча дедуплицируется (ADR-003, п.2)."""
owner = await _make_user(db_session, name="Owner Tester")
external_invitee = await _make_user(db_session, name="Invited Colleague")
await db_session.commit()
monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())
service = ConferenceService(db_session)
conference, _ = await service.create(
owner=owner,
data=ConferenceCreateIn(
scheduled_at=FUTURE,
participants=[
InviteeIn(user_id=owner.id), # дубль владельца — дедуп
InviteeIn(email=owner.email.upper()), # email владельца — тоже дедуп
InviteeIn(user_id=external_invitee.id),
],
),
)
out = await service.to_detail_out(conference, viewer=owner)
assert [p.user_id for p in out.participants] == [owner.id, external_invitee.id]
assert out.participants[0].is_organizer is True
assert out.participants[1].is_organizer is False
assert out.participants[1].name == "Invited Colleague"
async def test_create_with_unknown_participant_user_id_raises(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
owner = await _make_user(db_session)
await db_session.commit()
monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())
unknown_user_id = uuid.uuid4()
service = ConferenceService(db_session)
with pytest.raises(InviteeUserNotFoundError):
await service.create(
owner=owner,
data=ConferenceCreateIn(
scheduled_at=FUTURE, participants=[InviteeIn(user_id=unknown_user_id)]
),
)
async def test_update_replaces_participant_set(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
owner = await _make_user(db_session)
first_invitee = await _make_user(db_session, name="First Invitee")
second_invitee = await _make_user(db_session, name="Second Invitee")
await db_session.commit()
monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())
service = ConferenceService(db_session)
conference, _ = await service.create(
owner=owner,
data=ConferenceCreateIn(
scheduled_at=FUTURE, participants=[InviteeIn(user_id=first_invitee.id)]
),
)
updated = await service.update(
conference.id,
actor=owner,
data=ConferenceUpdateIn(participants=[InviteeIn(user_id=second_invitee.id)]),
)
out = await service.to_detail_out(updated, viewer=owner)
participant_ids = {p.user_id for p in out.participants}
assert participant_ids == {owner.id, second_invitee.id}
assert first_invitee.id not in participant_ids
async def test_update_participants_none_does_not_change_existing_set(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""`participants=None` в PATCH — «не менять» (ADR-003, п.3)."""
owner = await _make_user(db_session)
invitee = await _make_user(db_session, name="Untouched Invitee")
await db_session.commit()
monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())
service = ConferenceService(db_session)
conference, _ = await service.create(
owner=owner,
data=ConferenceCreateIn(scheduled_at=FUTURE, participants=[InviteeIn(user_id=invitee.id)]),
)
updated = await service.update(
conference.id, actor=owner, data=ConferenceUpdateIn(title="Renamed")
)
out = await service.to_detail_out(updated, viewer=owner)
assert invitee.id in {p.user_id for p in out.participants}
async def test_update_participants_only_change_enqueues_without_incrementing_sequence(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Изменение ТОЛЬКО состава: рассылка ставится, `ics_sequence` не растёт (анти-спам)."""
owner = await _make_user(db_session)
invitee = await _make_user(db_session, name="New Invitee")
await db_session.commit()
monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())
service = ConferenceService(db_session)
conference, _ = await service.create(
owner=owner, data=ConferenceCreateIn(scheduled_at=FUTURE, duration_minutes=30)
)
assert conference.ics_sequence == 0
mock_enqueue = MagicMock()
monkeypatch.setattr(conferences_module, "enqueue_invitations", mock_enqueue)
updated = await service.update(
conference.id,
actor=owner,
data=ConferenceUpdateIn(participants=[InviteeIn(user_id=invitee.id)]),
)
assert updated.ics_sequence == 0
mock_enqueue.assert_called_once_with(conference.id)
async def test_update_same_participant_set_does_not_enqueue(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Повторная передача ТОГО ЖЕ состава — фактических изменений нет, рассылка не ставится."""
owner = await _make_user(db_session)
invitee = await _make_user(db_session, name="Same Invitee")
await db_session.commit()
monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())
service = ConferenceService(db_session)
conference, _ = await service.create(
owner=owner,
data=ConferenceCreateIn(scheduled_at=FUTURE, participants=[InviteeIn(user_id=invitee.id)]),
)
mock_enqueue = MagicMock()
monkeypatch.setattr(conferences_module, "enqueue_invitations", mock_enqueue)
await service.update(
conference.id,
actor=owner,
data=ConferenceUpdateIn(participants=[InviteeIn(user_id=invitee.id)]),
)
mock_enqueue.assert_not_called()
async def test_list_my_does_not_populate_participants(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""`/my` не раздувает состав — `participants` пуст (ADR-003, п.5)."""
owner = await _make_user(db_session)
invitee = await _make_user(db_session, name="Should Not Appear")
await db_session.commit()
monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())
service = ConferenceService(db_session)
conference, _ = await service.create(
owner=owner,
data=ConferenceCreateIn(is_pinned=True, participants=[InviteeIn(user_id=invitee.id)]),
)
my_list = await service.list_my(owner=owner)
item = next(c for c in my_list if c.id == conference.id)
assert item.participants == []
assert item.owner_id == owner.id
assert item.is_owner is True
assert item.organizer_name == owner.name_user

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
"""Тесты для GET /api/health."""
from collections.abc import AsyncGenerator
from unittest.mock import AsyncMock, patch
import httpx
from httpx import ASGITransport
from core.db import get_session
from main import create_app
class _FakeSession:
async def execute(self, *args: object, **kwargs: object) -> None:
return None
async def _fake_get_session() -> AsyncGenerator[_FakeSession, None]:
yield _FakeSession()
async def test_health_ok() -> None:
app = create_app()
app.dependency_overrides[get_session] = _fake_get_session
with patch("api.health.redis_client.ping", new=AsyncMock(return_value=True)):
transport = ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/health")
assert response.status_code == 200
body = response.json()
assert body["status"] == "ok"
assert body["db"] is True
assert body["redis"] is True
# Версия инстанса — значение из Settings, не фиксируем точную
# строку здесь (совпадает с дефолтом Settings.vidconf_version в dev).
assert "version" in body

267
backend/tests/test_ics.py Normal file
View File

@@ -0,0 +1,267 @@
"""Тесты `services/ics.py::build_invite` («.ics», блок C).
Пишутся ДО закрепления поведения в реализации (TDD) — покрывают парсимость
результата, соответствие VTIMEZONE и TZID DTSTART, RRULE всех 4 типов
повторения, рост SEQUENCE и корректность DTSTART повторяющейся серии.
"""
import uuid
from datetime import UTC, datetime
from typing import Any
import pytest
from icalendar import Calendar
from models.conference import Conference
from services.ics import ConferenceHasNoScheduleError, build_invite
from services.recurrence import RecurrenceRule, expand_occurrences
def _prop(component: Any, name: str) -> Any:
"""Снять широкий `Union` типов свойств `icalendar` при статической проверке mypy.
`Component.__getitem__` типизирован как union из полутора десятков
возможных типов значений (`vText`/`vRecur`/`vDDDTypes`/...) — конкретный
тип известен только в рантайме по имени ключа; без этой обёртки пришлось
бы городить `# type: ignore` на каждой строке доступа к свойству события.
"""
return component[name]
def _conference(**overrides: object) -> Conference:
defaults: dict[str, object] = {
"id": uuid.uuid4(),
"number": "123456789",
"slug": "test-slug-1",
"title": "Синк по проекту",
"owner_id": None,
"status": "scheduled",
"is_pinned": False,
"is_closed": False,
"scheduled_at": datetime(2026, 8, 3, 10, 0, tzinfo=UTC),
"duration_minutes": 45,
"ics_sequence": 0,
}
defaults.update(overrides)
return Conference(**defaults)
def _recurrence(**overrides: object) -> dict[str, object]:
defaults: dict[str, object] = {
"type": "weekly",
"weekdays": [0, 2],
"anchor_date": "2026-08-03",
"time_local": "09:00",
"timezone": "Europe/Moscow",
"duration_minutes": 30,
}
defaults.update(overrides)
return defaults
class TestBuildInviteOneOff:
def test_result_parses_back_with_icalendar(self) -> None:
conference = _conference()
raw = build_invite(
conference,
organizer_email="owner@example.com",
join_url="https://vidconf.example/j/test-slug-1",
display_timezone="Europe/Moscow",
)
parsed = Calendar.from_ical(raw)
assert parsed.get("method") == "REQUEST"
def test_dtstart_uses_display_timezone(self) -> None:
conference = _conference()
raw = build_invite(
conference,
organizer_email=None,
join_url="https://vidconf.example/j/test-slug-1",
display_timezone="Europe/Moscow",
)
parsed = Calendar.from_ical(raw)
event = parsed.walk("VEVENT")[0]
dtstart = _prop(event, "dtstart")
assert str(dtstart.params["TZID"]) == "Europe/Moscow"
assert dtstart.dt.hour == 13 # 10:00 UTC -> 13:00 MSK (UTC+3)
def test_vtimezone_matches_dtstart_tzid(self) -> None:
conference = _conference()
raw = build_invite(
conference,
organizer_email=None,
join_url="https://vidconf.example/j/test-slug-1",
display_timezone="Europe/Moscow",
)
parsed = Calendar.from_ical(raw)
event = parsed.walk("VEVENT")[0]
tzid = str(_prop(event, "dtstart").params["TZID"])
vtimezones = parsed.walk("VTIMEZONE")
assert any(str(_prop(vt, "tzid")) == tzid for vt in vtimezones)
def test_uid_is_stable_and_sequence_matches_conference(self) -> None:
conference = _conference(ics_sequence=2)
raw = build_invite(
conference,
organizer_email=None,
join_url="https://vidconf.example/j/test-slug-1",
display_timezone="Europe/Moscow",
)
event = Calendar.from_ical(raw).walk("VEVENT")[0]
assert str(_prop(event, "uid")) == f"{conference.id}@vidconf"
assert int(_prop(event, "sequence")) == 2
def test_sequence_grows_when_ics_sequence_increments(self) -> None:
conference = _conference(ics_sequence=0)
raw_before = build_invite(
conference,
organizer_email=None,
join_url="https://vidconf.example/j/test-slug-1",
display_timezone="Europe/Moscow",
)
conference.ics_sequence = 1
raw_after = build_invite(
conference,
organizer_email=None,
join_url="https://vidconf.example/j/test-slug-1",
display_timezone="Europe/Moscow",
)
seq_before = int(_prop(Calendar.from_ical(raw_before).walk("VEVENT")[0], "sequence"))
seq_after = int(_prop(Calendar.from_ical(raw_after).walk("VEVENT")[0], "sequence"))
assert seq_after > seq_before
def test_conference_without_schedule_raises(self) -> None:
conference = _conference(scheduled_at=None)
with pytest.raises(ConferenceHasNoScheduleError):
build_invite(
conference,
organizer_email=None,
join_url="https://vidconf.example/j/test-slug-1",
display_timezone="Europe/Moscow",
)
class TestBuildInviteRecurring:
def test_weekly_rrule_byday(self) -> None:
conference = _conference(
scheduled_at=None, recurrence=_recurrence(type="weekly", weekdays=[0, 2])
)
raw = build_invite(
conference,
organizer_email=None,
join_url="https://vidconf.example/j/test-slug-1",
display_timezone="Europe/Moscow",
)
event = Calendar.from_ical(raw).walk("VEVENT")[0]
rrule = _prop(event, "rrule")
assert rrule["FREQ"] == ["WEEKLY"]
assert rrule["BYDAY"] == ["MO", "WE"]
def test_biweekly_rrule_interval_two(self) -> None:
conference = _conference(
scheduled_at=None,
recurrence=_recurrence(type="biweekly", weekdays=[1], anchor_date="2026-08-04"),
)
raw = build_invite(
conference,
organizer_email=None,
join_url="https://vidconf.example/j/test-slug-1",
display_timezone="Europe/Moscow",
)
event = Calendar.from_ical(raw).walk("VEVENT")[0]
rrule = _prop(event, "rrule")
assert rrule["FREQ"] == ["WEEKLY"]
assert rrule["INTERVAL"] == [2]
assert rrule["BYDAY"] == ["TU"]
assert rrule["WKST"] == ["MO"]
def test_monthly_rrule_bymonthday(self) -> None:
conference = _conference(
scheduled_at=None,
recurrence=_recurrence(
type="monthly", weekdays=[], day_of_month=15, anchor_date="2026-08-15"
),
)
raw = build_invite(
conference,
organizer_email=None,
join_url="https://vidconf.example/j/test-slug-1",
display_timezone="Europe/Moscow",
)
event = Calendar.from_ical(raw).walk("VEVENT")[0]
rrule = _prop(event, "rrule")
assert rrule["FREQ"] == ["MONTHLY"]
assert rrule["BYMONTHDAY"] == [15]
def test_monthly_rrule_31_maps_to_last_day(self) -> None:
conference = _conference(
scheduled_at=None,
recurrence=_recurrence(
type="monthly", weekdays=[], day_of_month=31, anchor_date="2026-08-31"
),
)
raw = build_invite(
conference,
organizer_email=None,
join_url="https://vidconf.example/j/test-slug-1",
display_timezone="Europe/Moscow",
)
event = Calendar.from_ical(raw).walk("VEVENT")[0]
assert _prop(event, "rrule")["BYMONTHDAY"] == [-1]
def test_every_n_days_rrule_daily_interval(self) -> None:
conference = _conference(
scheduled_at=None,
recurrence=_recurrence(
type="every_n_days", weekdays=[], interval_days=3, anchor_date="2026-08-03"
),
)
raw = build_invite(
conference,
organizer_email=None,
join_url="https://vidconf.example/j/test-slug-1",
display_timezone="Europe/Moscow",
)
event = Calendar.from_ical(raw).walk("VEVENT")[0]
rrule = _prop(event, "rrule")
assert rrule["FREQ"] == ["DAILY"]
assert rrule["INTERVAL"] == [3]
def test_dtstart_is_first_occurrence_from_expand_occurrences(self) -> None:
recurrence = _recurrence(type="weekly", weekdays=[0, 2], anchor_date="2026-08-03")
conference = _conference(scheduled_at=None, recurrence=recurrence)
raw = build_invite(
conference,
organizer_email=None,
join_url="https://vidconf.example/j/test-slug-1",
display_timezone="Europe/Moscow",
)
event = Calendar.from_ical(raw).walk("VEVENT")[0]
dtstart = _prop(event, "dtstart").dt
rule = RecurrenceRule.model_validate(recurrence)
t_from = datetime(2026, 8, 3, tzinfo=UTC)
t_to = datetime(2026, 9, 1, tzinfo=UTC)
expected_first = expand_occurrences(rule, t_from, t_to)[0]
expected_local = expected_first.astimezone(dtstart.tzinfo)
assert dtstart == expected_local
assert str(_prop(event, "dtstart").params["TZID"]) == "Europe/Moscow"
def test_recurring_vtimezone_matches_rule_timezone(self) -> None:
conference = _conference(
scheduled_at=None,
recurrence=_recurrence(type="weekly", weekdays=[0], timezone="America/New_York"),
)
raw = build_invite(
conference,
organizer_email=None,
join_url="https://vidconf.example/j/test-slug-1",
display_timezone="Europe/Moscow",
)
parsed = Calendar.from_ical(raw)
event = parsed.walk("VEVENT")[0]
tzid = str(_prop(event, "dtstart").params["TZID"])
assert tzid == "America/New_York"
vtimezones = parsed.walk("VTIMEZONE")
assert any(str(_prop(vt, "tzid")) == tzid for vt in vtimezones)

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()

View File

@@ -0,0 +1,443 @@
"""Тесты Celery-задачи рассылки .ics-приглашений (`workers.tasks.invitations`).
Как и `test_pipeline.py`/`test_summarize_task.py`, не использует
savepoint-фикстуру `db_session`: `send_invitations_async` открывает
собственную сессию с отдельным engine (`workers/db.py::open_session`),
которая не видит незакоммиченные изменения другой сессии. Тестовые данные
заводятся и коммитятся напрямую через `core.db.engine`; email-бэкенд —
фейковый (без реального SMTP/console-лога).
"""
import uuid
from collections.abc import AsyncGenerator
from datetime import UTC, datetime, timedelta
from typing import Any
from unittest.mock import MagicMock
import pytest
from celery.exceptions import MaxRetriesExceededError
from sqlalchemy import text
from core.db import engine
from services.conference_ids import generate_number, generate_slug
from services.email import EmailAttachment, EmailSendError
from workers.tasks import invitations as invitations_module
from workers.tasks.invitations import send_invitations_async
NOW = datetime.now(UTC)
FUTURE = NOW + timedelta(days=3)
class _Request:
def __init__(self, retries: int = 0) -> None:
self.retries = retries
class _FakeTask:
def __init__(self, retries: int = 0) -> None:
self.request = _Request(retries)
self.retry = MagicMock()
class _ExhaustedRetryTask:
def __init__(self) -> None:
self.request = _Request(0)
self.retry_calls = 0
def retry(self, countdown: int | None = None) -> None:
self.retry_calls += 1
raise MaxRetriesExceededError("исчерпаны попытки рассылки приглашений")
class _FakeEmailBackend:
"""Фейковый `EmailBackend`: запоминает вызовы, опционально бросает `EmailSendError`."""
def __init__(self, *, fail_for: set[str] | None = None, retryable: bool = True) -> None:
self.sent: list[dict[str, Any]] = []
self._fail_for = fail_for or set()
self._retryable = retryable
async def send(
self,
*,
to: str,
subject: str,
body: str,
html_body: str | None = None,
attachments: tuple[EmailAttachment, ...] = (),
) -> None:
if to in self._fail_for:
raise EmailSendError(f"сбой отправки {to}", retryable=self._retryable)
self.sent.append({"to": to, "subject": subject, "attachments": attachments})
class _Fixture:
"""Id тестовой конференции/владельца/сеанса/участников."""
def __init__(self) -> None:
self.owner_id = uuid.uuid4()
self.conference_id = uuid.uuid4()
self.session_id = uuid.uuid4()
self.user_id = uuid.uuid4()
self.guest_with_email_id = uuid.uuid4()
self.guest_without_email_id = uuid.uuid4()
self.number = generate_number()
self.slug = generate_slug()
@pytest.fixture
async def fx() -> AsyncGenerator[_Fixture, None]:
f = _Fixture()
async with engine.connect() as conn:
await conn.execute(
text(
"INSERT INTO users (id, email, name_user, password_hash) "
"VALUES (:id, :email, 'Owner Tester', 'x')"
),
{"id": f.owner_id, "email": f"invitations-owner-{f.owner_id}@example.com"},
)
await conn.execute(
text(
"INSERT INTO users (id, email, name_user, password_hash) "
"VALUES (:id, :email, 'Participant Tester', 'x')"
),
{"id": f.user_id, "email": f"invitations-participant-{f.user_id}@example.com"},
)
await conn.execute(
text(
"INSERT INTO conferences "
"(id, number, slug, title, status, is_pinned, owner_id, scheduled_at, "
"duration_minutes) "
"VALUES (:id, :number, :slug, 'Invitations test', 'scheduled', true, "
":owner_id, :scheduled_at, 30)"
),
{
"id": f.conference_id,
"number": f.number,
"slug": f.slug,
"owner_id": f.owner_id,
"scheduled_at": FUTURE,
},
)
await conn.execute(
text(
"INSERT INTO guest_access (id, conference_id, display_name, email) "
"VALUES (:id, :conference_id, 'Guest With Email', :email)"
),
{
"id": f.guest_with_email_id,
"conference_id": f.conference_id,
"email": f"invitations-guest-{f.guest_with_email_id}@example.com",
},
)
await conn.execute(
text(
"INSERT INTO guest_access (id, conference_id, display_name) "
"VALUES (:id, :conference_id, 'Guest Without Email')"
),
{"id": f.guest_without_email_id, "conference_id": f.conference_id},
)
await conn.execute(
text(
"INSERT INTO conference_sessions (id, conference_id, title, t_start, t_end) "
"VALUES (:id, :conference_id, 'Past session', :t_start, :t_end)"
),
{
"id": f.session_id,
"conference_id": f.conference_id,
"t_start": NOW - timedelta(days=7),
"t_end": NOW - timedelta(days=7) + timedelta(minutes=30),
},
)
await conn.execute(
text(
"INSERT INTO conference_participants (id, session_id, user_id, joined_at) "
"VALUES (:id, :session_id, :user_id, :joined_at)"
),
{
"id": uuid.uuid4(),
"session_id": f.session_id,
"user_id": f.user_id,
"joined_at": NOW,
},
)
await conn.execute(
text(
"INSERT INTO conference_participants (id, session_id, guest_id, joined_at) "
"VALUES (:id, :session_id, :guest_id, :joined_at)"
),
{
"id": uuid.uuid4(),
"session_id": f.session_id,
"guest_id": f.guest_with_email_id,
"joined_at": NOW,
},
)
await conn.execute(
text(
"INSERT INTO conference_participants (id, session_id, guest_id, joined_at) "
"VALUES (:id, :session_id, :guest_id, :joined_at)"
),
{
"id": uuid.uuid4(),
"session_id": f.session_id,
"guest_id": f.guest_without_email_id,
"joined_at": NOW,
},
)
await conn.commit()
yield f
async with engine.connect() as conn:
await conn.execute(
text("DELETE FROM email_deliveries WHERE conference_id = :id"),
{"id": f.conference_id},
)
await conn.execute(
text("DELETE FROM conference_participants WHERE session_id = :id"),
{"id": f.session_id},
)
await conn.execute(
text("DELETE FROM conference_sessions WHERE conference_id = :id"),
{"id": f.conference_id},
)
await conn.execute(
text("DELETE FROM guest_access WHERE conference_id = :id"), {"id": f.conference_id}
)
await conn.execute(text("DELETE FROM conferences WHERE id = :id"), {"id": f.conference_id})
await conn.execute(
text("DELETE FROM users WHERE id IN (:owner_id, :user_id)"),
{"owner_id": f.owner_id, "user_id": f.user_id},
)
await conn.commit()
async def _deliveries(conference_id: uuid.UUID) -> list[str]:
async with engine.connect() as conn:
result = await conn.execute(
text(
"SELECT recipient_email FROM email_deliveries "
"WHERE conference_id = :id AND kind = 'invitation'"
),
{"id": conference_id},
)
return [row[0] for row in result.all()]
async def test_unknown_conference_is_noop(monkeypatch: pytest.MonkeyPatch) -> None:
mock_backend = _FakeEmailBackend()
monkeypatch.setattr(invitations_module, "create_email_backend", lambda settings: mock_backend)
await send_invitations_async(_FakeTask(), uuid.uuid4())
assert mock_backend.sent == []
async def test_conference_without_schedule_is_noop(monkeypatch: pytest.MonkeyPatch) -> None:
conference_id = uuid.uuid4()
async with engine.connect() as conn:
await conn.execute(
text(
"INSERT INTO conferences (id, number, slug, title, status, is_pinned) "
"VALUES (:id, :number, :slug, 'No schedule', 'active', true)"
),
{"id": conference_id, "number": generate_number(), "slug": generate_slug()},
)
await conn.commit()
mock_backend = _FakeEmailBackend()
monkeypatch.setattr(invitations_module, "create_email_backend", lambda settings: mock_backend)
try:
await send_invitations_async(_FakeTask(), conference_id)
assert mock_backend.sent == []
finally:
async with engine.connect() as conn:
await conn.execute(
text("DELETE FROM conferences WHERE id = :id"), {"id": conference_id}
)
await conn.commit()
async def test_non_pinned_conference_sends_to_owner_and_invitees(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Приглашённые (`conference_invitees`, ADR-003) получают письмо независимо от `is_pinned`."""
conference_id = uuid.uuid4()
owner_id = uuid.uuid4()
invitee_user_id = uuid.uuid4()
external_email = f"external-invitee-{uuid.uuid4()}@example.com"
async with engine.connect() as conn:
await conn.execute(
text(
"INSERT INTO users (id, email, name_user, password_hash) "
"VALUES (:id, :email, 'Owner Tester', 'x')"
),
{"id": owner_id, "email": f"invitations-owner-{owner_id}@example.com"},
)
await conn.execute(
text(
"INSERT INTO users (id, email, name_user, password_hash) "
"VALUES (:id, :email, 'Invitee Tester', 'x')"
),
{"id": invitee_user_id, "email": f"invitations-invitee-{invitee_user_id}@example.com"},
)
await conn.execute(
text(
"INSERT INTO conferences "
"(id, number, slug, title, status, is_pinned, owner_id, scheduled_at, "
"duration_minutes) "
"VALUES (:id, :number, :slug, 'Roster test', 'scheduled', false, :owner_id, "
":scheduled_at, 30)"
),
{
"id": conference_id,
"number": generate_number(),
"slug": generate_slug(),
"owner_id": owner_id,
"scheduled_at": FUTURE,
},
)
await conn.execute(
text(
"INSERT INTO conference_invitees (id, conference_id, user_id) "
"VALUES (:id, :conference_id, :user_id)"
),
{"id": uuid.uuid4(), "conference_id": conference_id, "user_id": invitee_user_id},
)
await conn.execute(
text(
"INSERT INTO conference_invitees (id, conference_id, email) "
"VALUES (:id, :conference_id, :email)"
),
{"id": uuid.uuid4(), "conference_id": conference_id, "email": external_email},
)
await conn.commit()
mock_backend = _FakeEmailBackend()
monkeypatch.setattr(invitations_module, "create_email_backend", lambda settings: mock_backend)
try:
await send_invitations_async(_FakeTask(), conference_id)
sent_to = {call["to"] for call in mock_backend.sent}
assert sent_to == {
f"invitations-owner-{owner_id}@example.com".lower(),
f"invitations-invitee-{invitee_user_id}@example.com".lower(),
external_email.lower(),
}
finally:
async with engine.connect() as conn:
await conn.execute(
text("DELETE FROM email_deliveries WHERE conference_id = :id"),
{"id": conference_id},
)
await conn.execute(
text("DELETE FROM conference_invitees WHERE conference_id = :id"),
{"id": conference_id},
)
await conn.execute(
text("DELETE FROM conferences WHERE id = :id"), {"id": conference_id}
)
await conn.execute(
text("DELETE FROM users WHERE id IN (:owner_id, :invitee_user_id)"),
{"owner_id": owner_id, "invitee_user_id": invitee_user_id},
)
await conn.commit()
async def test_pinned_conference_sends_to_owner_and_past_participants_with_email(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
mock_backend = _FakeEmailBackend()
monkeypatch.setattr(invitations_module, "create_email_backend", lambda settings: mock_backend)
await send_invitations_async(_FakeTask(), fx.conference_id)
sent_to = {call["to"] for call in mock_backend.sent}
assert sent_to == {
f"invitations-owner-{fx.owner_id}@example.com".lower(),
f"invitations-participant-{fx.user_id}@example.com".lower(),
f"invitations-guest-{fx.guest_with_email_id}@example.com".lower(),
}
# Каждое отправленное письмо несёт вложение `invite.ics`.
assert all(call["attachments"][0].filename == "invite.ics" for call in mock_backend.sent)
delivered = set(await _deliveries(fx.conference_id))
assert delivered == sent_to
async def test_explicit_emails_override_default_recipients(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
mock_backend = _FakeEmailBackend()
monkeypatch.setattr(invitations_module, "create_email_backend", lambda settings: mock_backend)
await send_invitations_async(_FakeTask(), fx.conference_id, emails=["Custom@Example.com"])
assert [call["to"] for call in mock_backend.sent] == ["custom@example.com"]
async def test_no_recipients_is_noop(monkeypatch: pytest.MonkeyPatch) -> None:
conference_id = uuid.uuid4()
async with engine.connect() as conn:
await conn.execute(
text(
"INSERT INTO conferences (id, number, slug, title, status, is_pinned, "
"scheduled_at) VALUES (:id, :number, :slug, 'No owner', 'scheduled', false, "
":scheduled_at)"
),
{
"id": conference_id,
"number": generate_number(),
"slug": generate_slug(),
"scheduled_at": FUTURE,
},
)
await conn.commit()
mock_backend = _FakeEmailBackend()
monkeypatch.setattr(invitations_module, "create_email_backend", lambda settings: mock_backend)
try:
await send_invitations_async(_FakeTask(), conference_id)
assert mock_backend.sent == []
finally:
async with engine.connect() as conn:
await conn.execute(
text("DELETE FROM conferences WHERE id = :id"), {"id": conference_id}
)
await conn.commit()
async def test_non_retryable_failure_skips_recipient_but_delivers_others(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
failing = f"invitations-owner-{fx.owner_id}@example.com".lower()
mock_backend = _FakeEmailBackend(fail_for={failing}, retryable=False)
monkeypatch.setattr(invitations_module, "create_email_backend", lambda settings: mock_backend)
await send_invitations_async(_FakeTask(), fx.conference_id)
delivered = set(await _deliveries(fx.conference_id))
assert failing not in delivered
assert len(delivered) == 2
async def test_retryable_failure_calls_task_retry(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
failing = f"invitations-owner-{fx.owner_id}@example.com".lower()
mock_backend = _FakeEmailBackend(fail_for={failing}, retryable=True)
monkeypatch.setattr(invitations_module, "create_email_backend", lambda settings: mock_backend)
task = _FakeTask()
await send_invitations_async(task, fx.conference_id)
task.retry.assert_called_once()
async def test_retryable_failure_exhausted_logs_and_returns(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
failing = f"invitations-owner-{fx.owner_id}@example.com".lower()
mock_backend = _FakeEmailBackend(fail_for={failing}, retryable=True)
monkeypatch.setattr(invitations_module, "create_email_backend", lambda settings: mock_backend)
await send_invitations_async(_ExhaustedRetryTask(), fx.conference_id) # не должно бросить

View File

@@ -0,0 +1,669 @@
"""Интеграционные тесты `/api/v1/livekit/webhook`: полный цикл, дедуп, fallback,
невалидная подпись, пропуск невалидного identity, гостевой identity, жизненный
цикл закреплённой/незакреплённой конференции — на фикстурах payload'ов LiveKit.
"""
import base64
import hashlib
import uuid
from datetime import UTC, datetime, timedelta
from pathlib import Path
from unittest.mock import AsyncMock, Mock
import httpx
import jwt
import pytest
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
import services.webhook_handlers as webhook_handlers_module
from core.config import get_settings
from core.security import hash_password
from models.audio_track import SessionAudioTrack
from models.conference import Conference
from models.guest import GuestAccess
from models.instance_setting import InstanceSetting
from models.participant import ConferenceParticipant
from models.session import ConferenceSession
from models.user import User
from services.conference_ids import generate_number, generate_slug
from services.egress import EgressStartResult
FIXTURES_DIR = Path(__file__).parent / "fixtures" / "livekit"
WEBHOOK_URL = "/api/v1/livekit/webhook"
def _load_fixture(name: str, **placeholders: str) -> bytes:
"""Загрузить JSON-фикстуру webhook-события, подставив плейсхолдеры `{name}`."""
raw = (FIXTURES_DIR / name).read_text()
for key, value in placeholders.items():
raw = raw.replace(f"{{{key}}}", value)
return raw.encode()
def _sign(body: bytes) -> str:
"""Подписать тело webhook-запроса так же, как это делает LiveKit server."""
settings = get_settings()
sha256_b64 = base64.b64encode(hashlib.sha256(body).digest()).decode()
payload = {
"iss": settings.livekit_api_key,
"sha256": sha256_b64,
"exp": datetime.now(UTC) + timedelta(minutes=5),
}
return jwt.encode(payload, settings.livekit_api_secret, algorithm="HS256")
async def _post_webhook(client: httpx.AsyncClient, body: bytes) -> httpx.Response:
return await client.post(
WEBHOOK_URL,
content=body,
headers={"Authorization": _sign(body), "Content-Type": "application/json"},
)
async def _make_conference(
session: AsyncSession, slug: str, *, is_pinned: bool = False
) -> Conference:
conference = Conference(
number=generate_number(), slug=slug, title=slug, is_pinned=is_pinned, status="scheduled"
)
session.add(conference)
await session.flush()
return conference
async def _make_user(session: AsyncSession, email: str) -> User:
user = User(
email=email,
name_user="Participant",
password_hash=hash_password("password123"),
email_verified=True,
)
session.add(user)
await session.flush()
return user
async def _make_guest(session: AsyncSession, conference: Conference) -> GuestAccess:
guest = GuestAccess(
conference_id=conference.id, display_name="Guest Tester", email="guest-track@example.com"
)
session.add(guest)
await session.flush()
return guest
async def test_full_cycle_joined_left_finished(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
conference = await _make_conference(db_session, generate_slug())
user = await _make_user(db_session, "webhook-user-1@example.com")
await db_session.commit()
started = _load_fixture(
"room_started.json", event_id=f"evt-{uuid.uuid4()}", room_name=conference.slug
)
resp = await _post_webhook(client, started)
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
await db_session.refresh(conference)
assert conference.status == "active"
session_record = await db_session.scalar(
select(ConferenceSession).where(
ConferenceSession.conference_id == conference.id, ConferenceSession.t_end.is_(None)
)
)
assert session_record is not None
assert session_record.t_start is not None
joined = _load_fixture(
"participant_joined.json",
event_id=f"evt-{uuid.uuid4()}",
room_name=conference.slug,
identity=str(user.id),
)
resp = await _post_webhook(client, joined)
assert resp.status_code == 200
participant = await db_session.scalar(
select(ConferenceParticipant).where(
ConferenceParticipant.session_id == session_record.id,
ConferenceParticipant.user_id == user.id,
)
)
assert participant is not None
assert participant.joined_at is not None
assert participant.left_at is None
assert participant.guest_id is None
left = _load_fixture(
"participant_left.json",
event_id=f"evt-{uuid.uuid4()}",
room_name=conference.slug,
identity=str(user.id),
)
resp = await _post_webhook(client, left)
assert resp.status_code == 200
await db_session.refresh(participant)
assert participant.left_at is not None
finished = _load_fixture(
"room_finished.json", event_id=f"evt-{uuid.uuid4()}", room_name=conference.slug
)
resp = await _post_webhook(client, finished)
assert resp.status_code == 200
await db_session.refresh(session_record)
assert session_record.t_end is not None
await db_session.refresh(conference)
assert conference.status == "ended"
assert conference.ended_at is not None
async def test_pinned_conference_returns_to_scheduled_on_finish(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
conference = await _make_conference(db_session, generate_slug(), is_pinned=True)
await db_session.commit()
started = _load_fixture(
"room_started.json", event_id=f"evt-{uuid.uuid4()}", room_name=conference.slug
)
assert (await _post_webhook(client, started)).status_code == 200
finished = _load_fixture(
"room_finished.json", event_id=f"evt-{uuid.uuid4()}", room_name=conference.slug
)
resp = await _post_webhook(client, finished)
assert resp.status_code == 200
await db_session.refresh(conference)
assert conference.status == "scheduled"
assert conference.ended_at is None
async def test_guest_identity_creates_participant_with_guest_id(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
conference = await _make_conference(db_session, generate_slug())
guest = GuestAccess(
conference_id=conference.id, display_name="Guest Tester", email="guest@example.com"
)
db_session.add(guest)
await db_session.commit()
joined = _load_fixture(
"participant_joined.json",
event_id=f"evt-{uuid.uuid4()}",
room_name=conference.slug,
identity=f"guest:{guest.id}",
)
resp = await _post_webhook(client, joined)
assert resp.status_code == 200
session_record = await db_session.scalar(
select(ConferenceSession).where(ConferenceSession.conference_id == conference.id)
)
assert session_record is not None
participant = await db_session.scalar(
select(ConferenceParticipant).where(
ConferenceParticipant.session_id == session_record.id,
ConferenceParticipant.guest_id == guest.id,
)
)
assert participant is not None
assert participant.user_id is None
async def test_duplicate_event_is_deduplicated(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
conference = await _make_conference(db_session, generate_slug())
await db_session.commit()
event_id = f"evt-{uuid.uuid4()}"
body = _load_fixture("room_started.json", event_id=event_id, room_name=conference.slug)
first = await _post_webhook(client, body)
assert first.status_code == 200
assert first.json()["status"] == "ok"
second = await _post_webhook(client, body)
assert second.status_code == 200
assert second.json()["status"] == "duplicate"
sessions = (
await db_session.scalars(
select(ConferenceSession).where(ConferenceSession.conference_id == conference.id)
)
).all()
assert len(sessions) == 1
async def test_missing_room_started_falls_back_on_participant_joined(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
conference = await _make_conference(db_session, generate_slug())
user = await _make_user(db_session, "webhook-user-fallback@example.com")
await db_session.commit()
joined = _load_fixture(
"participant_joined.json",
event_id=f"evt-{uuid.uuid4()}",
room_name=conference.slug,
identity=str(user.id),
)
resp = await _post_webhook(client, joined)
assert resp.status_code == 200
session_record = await db_session.scalar(
select(ConferenceSession).where(
ConferenceSession.conference_id == conference.id, ConferenceSession.t_end.is_(None)
)
)
assert session_record is not None
participant = await db_session.scalar(
select(ConferenceParticipant).where(
ConferenceParticipant.session_id == session_record.id,
ConferenceParticipant.user_id == user.id,
)
)
assert participant is not None
async def test_invalid_signature_returns_401(client: httpx.AsyncClient) -> None:
body = _load_fixture(
"room_started.json", event_id=f"evt-{uuid.uuid4()}", room_name="does-not-matter"
)
resp = await client.post(
WEBHOOK_URL,
content=body,
headers={"Authorization": "not-a-valid-jwt", "Content-Type": "application/json"},
)
assert resp.status_code == 401
async def test_unknown_identity_is_skipped_without_error(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
conference = await _make_conference(db_session, generate_slug())
await db_session.commit()
joined = _load_fixture(
"participant_joined.json",
event_id=f"evt-{uuid.uuid4()}",
room_name=conference.slug,
identity="not-a-uuid",
)
resp = await _post_webhook(client, joined)
assert resp.status_code == 200
# Невалидный identity парсится раньше get-or-create сеанса, поэтому для
# этой конференции не должно появиться ни сеанса, ни участника.
session_record = await db_session.scalar(
select(ConferenceSession).where(ConferenceSession.conference_id == conference.id)
)
assert session_record is None
async def test_track_published_by_guest_starts_egress_and_creates_track_row(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""№8 плана: гость (`identity="guest:<uuid>"`) публикует аудиотрек — AC-1."""
started_at = datetime.now(UTC)
mock_start = AsyncMock(
return_value=EgressStartResult(egress_id="EG_guest_track", started_at=started_at)
)
monkeypatch.setattr(webhook_handlers_module, "start_track_egress", mock_start)
conference = await _make_conference(db_session, generate_slug())
guest = await _make_guest(db_session, conference)
await db_session.commit()
joined = _load_fixture(
"participant_joined.json",
event_id=f"evt-{uuid.uuid4()}",
room_name=conference.slug,
identity=f"guest:{guest.id}",
)
assert (await _post_webhook(client, joined)).status_code == 200
session_record = await db_session.scalar(
select(ConferenceSession).where(ConferenceSession.conference_id == conference.id)
)
assert session_record is not None
participant = await db_session.scalar(
select(ConferenceParticipant).where(
ConferenceParticipant.session_id == session_record.id,
ConferenceParticipant.guest_id == guest.id,
)
)
assert participant is not None
track_sid = f"TR_{uuid.uuid4().hex[:8]}"
published = _load_fixture(
"track_published.json",
event_id=f"evt-{uuid.uuid4()}",
room_name=conference.slug,
identity=f"guest:{guest.id}",
track_sid=track_sid,
)
resp = await _post_webhook(client, published)
assert resp.status_code == 200
settings = get_settings()
expected_filepath = (
f"{settings.recordings_dir}/{session_record.id}/{participant.id}_{track_sid}.ogg"
)
mock_start.assert_awaited_once_with(conference.slug, track_sid, expected_filepath)
track_row = await db_session.scalar(
select(SessionAudioTrack).where(
SessionAudioTrack.session_id == session_record.id,
SessionAudioTrack.track_sid == track_sid,
)
)
assert track_row is not None
assert track_row.participant_id == participant.id
assert participant.guest_id == guest.id
assert participant.user_id is None
assert track_row.egress_id == "EG_guest_track"
assert track_row.file_path == expected_filepath
assert track_row.status == "recording"
async def test_track_published_survives_egress_unavailable(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Недоступность egress не должна ронять webhook (блок D): 200 + warning, без строки трека."""
mock_start = AsyncMock(side_effect=RuntimeError("egress service unavailable"))
monkeypatch.setattr(webhook_handlers_module, "start_track_egress", mock_start)
conference = await _make_conference(db_session, generate_slug())
user = await _make_user(db_session, "webhook-track-egress-down@example.com")
await db_session.commit()
joined = _load_fixture(
"participant_joined.json",
event_id=f"evt-{uuid.uuid4()}",
room_name=conference.slug,
identity=str(user.id),
)
assert (await _post_webhook(client, joined)).status_code == 200
track_sid = "TR_egress_down"
published = _load_fixture(
"track_published.json",
event_id=f"evt-{uuid.uuid4()}",
room_name=conference.slug,
identity=str(user.id),
track_sid=track_sid,
)
resp = await _post_webhook(client, published)
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
mock_start.assert_awaited_once()
tracks = (
await db_session.scalars(
select(SessionAudioTrack).where(SessionAudioTrack.track_sid == track_sid)
)
).all()
assert tracks == []
async def test_track_published_video_is_noop(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""№9 плана (часть 1): video-трек — no-op, egress не запускается."""
mock_start = AsyncMock()
monkeypatch.setattr(webhook_handlers_module, "start_track_egress", mock_start)
conference = await _make_conference(db_session, generate_slug())
user = await _make_user(db_session, "webhook-track-video@example.com")
await db_session.commit()
joined = _load_fixture(
"participant_joined.json",
event_id=f"evt-{uuid.uuid4()}",
room_name=conference.slug,
identity=str(user.id),
)
assert (await _post_webhook(client, joined)).status_code == 200
published = _load_fixture(
"track_published_video.json",
event_id=f"evt-{uuid.uuid4()}",
room_name=conference.slug,
identity=str(user.id),
track_sid="TR_video",
)
resp = await _post_webhook(client, published)
assert resp.status_code == 200
mock_start.assert_not_awaited()
tracks = (
await db_session.scalars(
select(SessionAudioTrack).where(SessionAudioTrack.track_sid == "TR_video")
)
).all()
assert tracks == []
async def test_track_published_repeated_webhook_creates_single_row(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""№9 плана (часть 2): повторный `track_published` того же трека → одна строка."""
mock_start = AsyncMock(
return_value=EgressStartResult(egress_id="EG_repeat", started_at=datetime.now(UTC))
)
monkeypatch.setattr(webhook_handlers_module, "start_track_egress", mock_start)
conference = await _make_conference(db_session, generate_slug())
user = await _make_user(db_session, "webhook-track-repeat@example.com")
await db_session.commit()
joined = _load_fixture(
"participant_joined.json",
event_id=f"evt-{uuid.uuid4()}",
room_name=conference.slug,
identity=str(user.id),
)
assert (await _post_webhook(client, joined)).status_code == 200
track_sid = "TR_repeat"
for _ in range(2):
published = _load_fixture(
"track_published.json",
event_id=f"evt-{uuid.uuid4()}",
room_name=conference.slug,
identity=str(user.id),
track_sid=track_sid,
)
resp = await _post_webhook(client, published)
assert resp.status_code == 200
session_record = await db_session.scalar(
select(ConferenceSession).where(ConferenceSession.conference_id == conference.id)
)
assert session_record is not None
tracks = (
await db_session.scalars(
select(SessionAudioTrack).where(
SessionAudioTrack.session_id == session_record.id,
SessionAudioTrack.track_sid == track_sid,
)
)
).all()
assert len(tracks) == 1
mock_start.assert_awaited_once()
async def test_egress_ended_finalizes_track_success_and_failure(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""№10 плана (часть 1): `egress_ended` — 'recorded' на успехе, 'failed' на ошибке."""
conference = await _make_conference(db_session, generate_slug())
user = await _make_user(db_session, "webhook-egress-ended@example.com")
await db_session.commit()
joined = _load_fixture(
"participant_joined.json",
event_id=f"evt-{uuid.uuid4()}",
room_name=conference.slug,
identity=str(user.id),
)
assert (await _post_webhook(client, joined)).status_code == 200
session_record = await db_session.scalar(
select(ConferenceSession).where(ConferenceSession.conference_id == conference.id)
)
assert session_record is not None
# Успешная запись.
ok_started = datetime.now(UTC)
monkeypatch.setattr(
webhook_handlers_module,
"start_track_egress",
AsyncMock(return_value=EgressStartResult(egress_id="EG_ok", started_at=ok_started)),
)
published_ok = _load_fixture(
"track_published.json",
event_id=f"evt-{uuid.uuid4()}",
room_name=conference.slug,
identity=str(user.id),
track_sid="TR_ok",
)
assert (await _post_webhook(client, published_ok)).status_code == 200
ended_ok = _load_fixture(
"egress_ended.json",
event_id=f"evt-{uuid.uuid4()}",
room_name=conference.slug,
egress_id="EG_ok",
file_path=f"/recordings/{session_record.id}/track-ok.ogg",
)
assert (await _post_webhook(client, ended_ok)).status_code == 200
ok_track = await db_session.scalar(
select(SessionAudioTrack).where(SessionAudioTrack.egress_id == "EG_ok")
)
assert ok_track is not None
assert ok_track.status == "recorded"
assert ok_track.ended_at is not None
assert ok_track.file_path == f"/recordings/{session_record.id}/track-ok.ogg"
# Ошибка записи.
monkeypatch.setattr(
webhook_handlers_module,
"start_track_egress",
AsyncMock(
return_value=EgressStartResult(egress_id="EG_fail", started_at=datetime.now(UTC))
),
)
published_fail = _load_fixture(
"track_published.json",
event_id=f"evt-{uuid.uuid4()}",
room_name=conference.slug,
identity=str(user.id),
track_sid="TR_fail",
)
assert (await _post_webhook(client, published_fail)).status_code == 200
ended_failed = _load_fixture(
"egress_ended_failed.json",
event_id=f"evt-{uuid.uuid4()}",
room_name=conference.slug,
egress_id="EG_fail",
)
assert (await _post_webhook(client, ended_failed)).status_code == 200
failed_track = await db_session.scalar(
select(SessionAudioTrack).where(SessionAudioTrack.egress_id == "EG_fail")
)
assert failed_track is not None
assert failed_track.status == "failed"
assert failed_track.ended_at is not None
async def test_room_finished_enqueues_pipeline(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""№10 плана (часть 2): `room_finished` ставит `run_pipeline` в очередь (мок)."""
mock_enqueue = Mock()
monkeypatch.setattr(webhook_handlers_module, "enqueue_pipeline", mock_enqueue)
conference = await _make_conference(db_session, generate_slug())
await db_session.commit()
started = _load_fixture(
"room_started.json", event_id=f"evt-{uuid.uuid4()}", room_name=conference.slug
)
assert (await _post_webhook(client, started)).status_code == 200
session_record = await db_session.scalar(
select(ConferenceSession).where(ConferenceSession.conference_id == conference.id)
)
assert session_record is not None
finished = _load_fixture(
"room_finished.json", event_id=f"evt-{uuid.uuid4()}", room_name=conference.slug
)
assert (await _post_webhook(client, finished)).status_code == 200
mock_enqueue.assert_called_once_with(session_record.id)
async def test_room_finished_skips_enqueue_when_transcription_disabled(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Guard от зависания сеанса: транскрибация
выключена в настройках инстанса (пресеты 1/2 инсталлятора — без AI) →
`room_finished` НЕ ставит `run_pipeline` в очередь `transcription` (некому
обслужить), сеанс сразу переводится в терминальный `pipeline_status='notified'`
вместо зависания в `recording` навсегда."""
mock_enqueue = Mock()
monkeypatch.setattr(webhook_handlers_module, "enqueue_pipeline", mock_enqueue)
# Настройка `instance_settings.transcriber` пишется тем же `db_session`
# (savepoint), что и обработчик webhook (подмена `get_session` в фикстуре
# `app`) — видна обработчику без реального коммита в dev-БД.
disabled_transcriber = {
"enabled": False,
"provider": "null",
"model": None,
"language": "ru",
"options": {},
}
stmt = (
pg_insert(InstanceSetting)
.values(key="transcriber", value=disabled_transcriber)
.on_conflict_do_update(index_elements=["key"], set_={"value": disabled_transcriber})
)
await db_session.execute(stmt)
conference = await _make_conference(db_session, generate_slug())
await db_session.commit()
started = _load_fixture(
"room_started.json", event_id=f"evt-{uuid.uuid4()}", room_name=conference.slug
)
assert (await _post_webhook(client, started)).status_code == 200
session_record = await db_session.scalar(
select(ConferenceSession).where(ConferenceSession.conference_id == conference.id)
)
assert session_record is not None
finished = _load_fixture(
"room_finished.json", event_id=f"evt-{uuid.uuid4()}", room_name=conference.slug
)
assert (await _post_webhook(client, finished)).status_code == 200
mock_enqueue.assert_not_called()
await db_session.refresh(session_record)
assert session_record.pipeline_status == "notified"

View File

@@ -0,0 +1,206 @@
"""Тесты `OpenAICompatClient` на мок-транспорте `httpx.MockTransport` (без сети).
Покрывает: успешный запрос, retry после 5xx и после сетевой ошибки → успех,
исчерпание попыток → `LlmUnavailableError`, открытие circuit breaker после
серии неудач (следующий вызов падает без HTTP-запроса) и его закрытие после
`breaker_cooldown_s`, а также явное закрытие базового `httpx.Client`
(`close()` и поддержка контекстного менеджера).
"""
import time
from collections.abc import Callable
import httpx
import pytest
from core.summarization import llm_client as llm_client_module
from core.summarization.llm_client import LlmUnavailableError, OpenAICompatClient
@pytest.fixture(autouse=True)
def _fast_backoff(monkeypatch: pytest.MonkeyPatch) -> None:
"""Свести паузы retry к нулю, чтобы тесты не ждали реальный backoff."""
monkeypatch.setattr(llm_client_module, "_INITIAL_BACKOFF_S", 0.0)
def _make_client(
handler: Callable[[httpx.Request], httpx.Response], **kwargs: object
) -> OpenAICompatClient:
transport = httpx.MockTransport(handler)
return OpenAICompatClient(
base_url="http://llm.test/v1",
model="qwen2.5-3b-instruct-q4_k_m",
transport=transport,
**kwargs, # type: ignore[arg-type]
)
def _ok_response(content: str = "готовое резюме") -> httpx.Response:
return httpx.Response(
200,
json={"choices": [{"message": {"content": content}}]},
)
def test_complete_returns_content_on_success() -> None:
calls: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(request)
assert request.url == "http://llm.test/v1/chat/completions"
body = request.read()
assert b'"role": "user"' in body or b'"role":"user"' in body
return _ok_response("успех")
client = _make_client(handler)
result = client.complete("промпт")
assert result == "успех"
assert len(calls) == 1
def test_complete_uses_per_call_max_tokens_override() -> None:
"""`max_tokens` явного вызова `complete()` перекрывает дефолт конструктора клиента
(ADR-004: разные лимиты map/reduce у `QwenLocal`, один и тот же клиент)."""
import json
captured: list[int] = []
def handler(request: httpx.Request) -> httpx.Response:
body = json.loads(request.read())
captured.append(body["max_tokens"])
return _ok_response("ок")
client = _make_client(handler, max_tokens=1024)
client.complete("промпт map", max_tokens=1024)
client.complete("промпт reduce", max_tokens=2048)
client.complete("промпт без переопределения")
assert captured == [1024, 2048, 1024]
def test_complete_retries_after_5xx_then_succeeds() -> None:
attempts = {"count": 0}
def handler(request: httpx.Request) -> httpx.Response:
attempts["count"] += 1
if attempts["count"] < 3:
return httpx.Response(503)
return _ok_response("успех после 503")
client = _make_client(handler, max_attempts=3)
result = client.complete("промпт")
assert result == "успех после 503"
assert attempts["count"] == 3
def test_complete_retries_after_network_error_then_succeeds() -> None:
attempts = {"count": 0}
def handler(request: httpx.Request) -> httpx.Response:
attempts["count"] += 1
if attempts["count"] == 1:
raise httpx.ConnectError("соединение разорвано", request=request)
return _ok_response("успех после обрыва сети")
client = _make_client(handler, max_attempts=3)
result = client.complete("промпт")
assert result == "успех после обрыва сети"
assert attempts["count"] == 2
def test_complete_raises_llm_unavailable_after_exhausting_attempts() -> None:
attempts = {"count": 0}
def handler(request: httpx.Request) -> httpx.Response:
attempts["count"] += 1
return httpx.Response(500)
client = _make_client(handler, max_attempts=3, breaker_threshold=100)
with pytest.raises(LlmUnavailableError):
client.complete("промпт")
assert attempts["count"] == 3
def test_breaker_opens_after_threshold_and_blocks_without_http_call() -> None:
attempts = {"count": 0}
def handler(request: httpx.Request) -> httpx.Response:
attempts["count"] += 1
return httpx.Response(500)
client = _make_client(handler, max_attempts=1, breaker_threshold=2, breaker_cooldown_s=60.0)
with pytest.raises(LlmUnavailableError):
client.complete("промпт 1")
with pytest.raises(LlmUnavailableError):
client.complete("промпт 2")
assert attempts["count"] == 2 # обе неудачи дошли до сервера — порог достигнут
with pytest.raises(LlmUnavailableError):
client.complete("промпт 3")
assert attempts["count"] == 2 # третий вызов не сделал HTTP-запрос — breaker открыт
def test_breaker_closes_after_cooldown_elapses(monkeypatch: pytest.MonkeyPatch) -> None:
attempts = {"count": 0}
def handler(request: httpx.Request) -> httpx.Response:
attempts["count"] += 1
if attempts["count"] <= 2:
return httpx.Response(500)
return _ok_response("успех после закрытия breaker")
client = _make_client(handler, max_attempts=1, breaker_threshold=2, breaker_cooldown_s=10.0)
with pytest.raises(LlmUnavailableError):
client.complete("промпт 1")
with pytest.raises(LlmUnavailableError):
client.complete("промпт 2")
assert attempts["count"] == 2
# Имитируем истечение окна отказа сдвигом монотонных часов вперёд (модуль
# `time` — общий синглтон интерпретатора, поэтому патчим его напрямую:
# изменение видно и внутри `core.summarization.llm_client`).
real_monotonic = time.monotonic()
monkeypatch.setattr(time, "monotonic", lambda: real_monotonic + 11.0)
result = client.complete("промпт 3")
assert result == "успех после закрытия breaker"
def test_close_closes_underlying_http_client() -> None:
"""`close()` закрывает базовый `httpx.Client` (освобождает пул соединений)."""
def handler(request: httpx.Request) -> httpx.Response:
return _ok_response()
client = _make_client(handler)
client.close()
assert client._client.is_closed
def test_context_manager_closes_client_on_exit() -> None:
"""`OpenAICompatClient` работает как контекстный менеджер, закрывающий клиент на выходе."""
def handler(request: httpx.Request) -> httpx.Response:
return _ok_response("успех в контексте")
with _make_client(handler) as client:
assert client.complete("промпт") == "успех в контексте"
assert not client._client.is_closed
assert client._client.is_closed

View File

@@ -0,0 +1,523 @@
"""Тесты обслуживания конференций (`workers.tasks.maintenance.cleanup_conferences_async`).
Как и старый `test_auto_release.py`, не использует savepoint-фикстуру
`db_session`: `cleanup_conferences_async` открывает собственную сессию с
отдельным engine (`workers/db.py::open_session`), которая не видит
незакоммиченные изменения другой сессии. Тестовые данные заводятся и
коммитятся напрямую через `core.db.engine`, очистка — вручную по завершении
теста. Время передаётся параметром `now` — без freezegun.
"""
import uuid
from collections.abc import AsyncGenerator
from datetime import UTC, datetime, timedelta
from typing import cast
from unittest.mock import AsyncMock, MagicMock
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncConnection
from core.db import engine
from core.plugins.config import ChatConfig, InstanceConfig, SummarizerConfig, TranscriberConfig
from services.conference_ids import generate_number, generate_slug
from workers.tasks import maintenance as maintenance_module
NOW = datetime.now(UTC)
class _Fixture:
"""Id тестовой конференции + (опционально) её сеанса/участника."""
def __init__(self) -> None:
self.conference_id = uuid.uuid4()
self.session_id = uuid.uuid4()
self.user_id = uuid.uuid4()
self.number = generate_number()
self.slug = generate_slug()
@pytest.fixture
async def fx() -> AsyncGenerator[_Fixture, None]:
f = _Fixture()
async with engine.connect() as conn:
await conn.execute(
text(
"INSERT INTO users (id, email, name_user, password_hash) "
"VALUES (:id, :email, 'Maintenance Tester', 'x')"
),
{"id": f.user_id, "email": f"maintenance-{f.user_id}@example.com"},
)
await conn.commit()
yield f
async with engine.connect() as conn:
await conn.execute(
text("DELETE FROM conference_participants WHERE session_id = :id"),
{"id": f.session_id},
)
await conn.execute(
text("DELETE FROM conference_sessions WHERE conference_id = :id"),
{"id": f.conference_id},
)
await conn.execute(text("DELETE FROM conferences WHERE id = :id"), {"id": f.conference_id})
await conn.execute(text("DELETE FROM users WHERE id = :id"), {"id": f.user_id})
await conn.commit()
async def _insert_conference(
conn: AsyncConnection,
fx: _Fixture,
*,
is_pinned: bool,
status: str,
scheduled_at: datetime | None = None,
duration_minutes: int | None = None,
) -> None:
await conn.execute(
text(
"INSERT INTO conferences "
"(id, number, slug, title, status, is_pinned, scheduled_at, duration_minutes) "
"VALUES (:id, :number, :slug, 'Maintenance test', :status, :is_pinned, "
":scheduled_at, :duration_minutes)"
),
{
"id": fx.conference_id,
"number": fx.number,
"slug": fx.slug,
"status": status,
"is_pinned": is_pinned,
"scheduled_at": scheduled_at,
"duration_minutes": duration_minutes,
},
)
async def _insert_session(
conn: AsyncConnection, fx: _Fixture, *, t_start: datetime, t_end: datetime | None = None
) -> None:
await conn.execute(
text(
"INSERT INTO conference_sessions (id, conference_id, title, t_start, t_end) "
"VALUES (:id, :conference_id, 'Maintenance session', :t_start, :t_end)"
),
{
"id": fx.session_id,
"conference_id": fx.conference_id,
"t_start": t_start,
"t_end": t_end,
},
)
async def _insert_summarizing_session(
conn: AsyncConnection,
fx: _Fixture,
*,
t_end: datetime,
summary_data: str | None = None,
) -> None:
"""Сеанс на шаге суммаризации — для тестов `recover_stuck_summaries_async`."""
await conn.execute(
text(
"INSERT INTO conference_sessions "
"(id, conference_id, title, t_start, t_end, pipeline_status, summary_data) "
"VALUES (:id, :conference_id, 'Recovery session', :t_start, :t_end, "
"'summarizing', :summary_data)"
),
{
"id": fx.session_id,
"conference_id": fx.conference_id,
"t_start": t_end - timedelta(minutes=30),
"t_end": t_end,
"summary_data": summary_data,
},
)
async def _insert_active_participant(conn: AsyncConnection, fx: _Fixture) -> None:
await conn.execute(
text(
"INSERT INTO conference_participants (id, session_id, user_id, joined_at) "
"VALUES (:id, :session_id, :user_id, :joined_at)"
),
{"id": uuid.uuid4(), "session_id": fx.session_id, "user_id": fx.user_id, "joined_at": NOW},
)
async def _fetch_conference(fx: _Fixture) -> dict[str, object]:
async with engine.connect() as conn:
result = await conn.execute(
text("SELECT status, ended_at FROM conferences WHERE id = :id"),
{"id": fx.conference_id},
)
row = result.mappings().one()
return dict(row)
async def _fetch_session_t_end(fx: _Fixture) -> datetime | None:
async with engine.connect() as conn:
result = await conn.execute(
text("SELECT t_end FROM conference_sessions WHERE id = :id"), {"id": fx.session_id}
)
return cast("datetime | None", result.scalar_one())
def _sent_tasks_for_session(mock_send_task: MagicMock, fx: _Fixture) -> list[str]:
"""Имена задач, отправленных `send_task` именно для тестового сеанса `fx`.
Recovery-задачи сканируют ВСЮ таблицу `conference_sessions`, а общая
dev-БД может легитимно содержать чужие зависшие сеансы (реальные данные
разработчика) — глобальные `assert_called_once`/`assert_not_called`
от них флэкают. Проверяем только вызовы с id нашего сеанса.
"""
session_id = str(fx.session_id)
return [
c.args[0] for c in mock_send_task.call_args_list if c.kwargs.get("args") == [session_id]
]
def _summarizer_cfg(*, enabled: bool = True) -> InstanceConfig:
"""Конфиг для тестов `recover_stuck_summaries_async` — интересует
только `summarizer.enabled`."""
return InstanceConfig(
transcriber=TranscriberConfig(),
summarizer=SummarizerConfig(enabled=enabled, provider="fake"),
chat=ChatConfig(),
)
async def test_idle_session_of_unpinned_conference_closes_and_ends_conference(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
mock_delete = AsyncMock()
monkeypatch.setattr(maintenance_module, "delete_livekit_room", mock_delete)
async with engine.connect() as conn:
await _insert_conference(conn, fx, is_pinned=False, status="active")
await _insert_session(conn, fx, t_start=NOW - timedelta(minutes=15))
await conn.commit()
await maintenance_module.cleanup_conferences_async(NOW)
assert await _fetch_session_t_end(fx) is not None
conference = await _fetch_conference(fx)
assert conference["status"] == "ended"
assert conference["ended_at"] is not None
mock_delete.assert_awaited_once_with(fx.slug)
async def test_idle_session_of_pinned_conference_returns_to_scheduled(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
mock_delete = AsyncMock()
monkeypatch.setattr(maintenance_module, "delete_livekit_room", mock_delete)
async with engine.connect() as conn:
await _insert_conference(conn, fx, is_pinned=True, status="active")
await _insert_session(conn, fx, t_start=NOW - timedelta(minutes=15))
await conn.commit()
await maintenance_module.cleanup_conferences_async(NOW)
assert await _fetch_session_t_end(fx) is not None
conference = await _fetch_conference(fx)
assert conference["status"] == "scheduled"
assert conference["ended_at"] is None
async def test_session_with_active_participant_is_not_touched(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
mock_delete = AsyncMock()
monkeypatch.setattr(maintenance_module, "delete_livekit_room", mock_delete)
async with engine.connect() as conn:
await _insert_conference(conn, fx, is_pinned=False, status="active")
await _insert_session(conn, fx, t_start=NOW - timedelta(minutes=15))
await _insert_active_participant(conn, fx)
await conn.commit()
await maintenance_module.cleanup_conferences_async(NOW)
assert await _fetch_session_t_end(fx) is None
mock_delete.assert_not_awaited()
async def test_fresh_session_is_not_touched(fx: _Fixture, monkeypatch: pytest.MonkeyPatch) -> None:
mock_delete = AsyncMock()
monkeypatch.setattr(maintenance_module, "delete_livekit_room", mock_delete)
async with engine.connect() as conn:
await _insert_conference(conn, fx, is_pinned=False, status="active")
await _insert_session(conn, fx, t_start=NOW)
await conn.commit()
await maintenance_module.cleanup_conferences_async(NOW)
assert await _fetch_session_t_end(fx) is None
mock_delete.assert_not_awaited()
async def test_repeated_run_is_a_noop(fx: _Fixture, monkeypatch: pytest.MonkeyPatch) -> None:
mock_delete = AsyncMock()
monkeypatch.setattr(maintenance_module, "delete_livekit_room", mock_delete)
async with engine.connect() as conn:
await _insert_conference(conn, fx, is_pinned=False, status="active")
await _insert_session(conn, fx, t_start=NOW - timedelta(minutes=15))
await conn.commit()
await maintenance_module.cleanup_conferences_async(NOW)
first_t_end = await _fetch_session_t_end(fx)
assert first_t_end is not None
await maintenance_module.cleanup_conferences_async(NOW + timedelta(minutes=5))
second_t_end = await _fetch_session_t_end(fx)
assert second_t_end == first_t_end
mock_delete.assert_awaited_once()
async def test_expired_unpinned_scheduled_without_session_becomes_ended(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
mock_delete = AsyncMock()
monkeypatch.setattr(maintenance_module, "delete_livekit_room", mock_delete)
async with engine.connect() as conn:
await _insert_conference(
conn,
fx,
is_pinned=False,
status="scheduled",
scheduled_at=NOW - timedelta(hours=2),
duration_minutes=30,
)
await conn.commit()
await maintenance_module.cleanup_conferences_async(NOW)
conference = await _fetch_conference(fx)
assert conference["status"] == "ended"
assert conference["ended_at"] is not None
async def test_scheduled_conference_within_grace_is_not_touched(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
mock_delete = AsyncMock()
monkeypatch.setattr(maintenance_module, "delete_livekit_room", mock_delete)
async with engine.connect() as conn:
await _insert_conference(
conn,
fx,
is_pinned=False,
status="scheduled",
scheduled_at=NOW - timedelta(minutes=10),
duration_minutes=None,
)
await conn.commit()
await maintenance_module.cleanup_conferences_async(NOW)
conference = await _fetch_conference(fx)
assert conference["status"] == "scheduled"
async def test_pinned_expired_scheduled_conference_is_not_touched(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
mock_delete = AsyncMock()
monkeypatch.setattr(maintenance_module, "delete_livekit_room", mock_delete)
async with engine.connect() as conn:
await _insert_conference(
conn,
fx,
is_pinned=True,
status="scheduled",
scheduled_at=NOW - timedelta(hours=5),
duration_minutes=30,
)
await conn.commit()
await maintenance_module.cleanup_conferences_async(NOW)
conference = await _fetch_conference(fx)
assert conference["status"] == "scheduled"
async def test_expired_scheduled_conference_with_session_is_not_touched(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
mock_delete = AsyncMock()
monkeypatch.setattr(maintenance_module, "delete_livekit_room", mock_delete)
async with engine.connect() as conn:
await _insert_conference(
conn,
fx,
is_pinned=False,
status="scheduled",
scheduled_at=NOW - timedelta(hours=5),
duration_minutes=30,
)
# Сеанс уже был (и закрыт) — конференция не должна считаться "без сеансов".
await _insert_session(
conn, fx, t_start=NOW - timedelta(hours=5), t_end=NOW - timedelta(hours=4)
)
await conn.commit()
await maintenance_module.cleanup_conferences_async(NOW)
conference = await _fetch_conference(fx)
assert conference["status"] == "scheduled"
async def test_recover_stuck_summaries_resends_task_for_hung_session(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Сеанс завис в 'summarizing' без summary_data дольше порога — задача переставлена."""
mock_send_task = MagicMock()
monkeypatch.setattr(maintenance_module.app, "send_task", mock_send_task)
async with engine.connect() as conn:
await _insert_conference(conn, fx, is_pinned=False, status="ended")
await _insert_summarizing_session(
conn,
fx,
t_end=NOW - maintenance_module.STUCK_SUMMARIZING_THRESHOLD - timedelta(minutes=1),
)
await conn.commit()
await maintenance_module.recover_stuck_summaries_async(NOW, plugins_config=_summarizer_cfg())
assert _sent_tasks_for_session(mock_send_task, fx) == [
"workers.tasks.summarize.summarize_session"
]
async def test_recover_stuck_summaries_ignores_fresh_session(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Сеанс моложе порога (недавно ушёл в summarizing) — трогать рано, no-op."""
mock_send_task = MagicMock()
monkeypatch.setattr(maintenance_module.app, "send_task", mock_send_task)
async with engine.connect() as conn:
await _insert_conference(conn, fx, is_pinned=False, status="ended")
await _insert_summarizing_session(conn, fx, t_end=NOW - timedelta(minutes=5))
await conn.commit()
await maintenance_module.recover_stuck_summaries_async(NOW, plugins_config=_summarizer_cfg())
assert _sent_tasks_for_session(mock_send_task, fx) == []
async def test_recover_stuck_summaries_ignores_session_with_summary_already_present(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Сеанс уже готов к уведомлению (summary_data заполнен) — recovery его не трогает."""
mock_send_task = MagicMock()
monkeypatch.setattr(maintenance_module.app, "send_task", mock_send_task)
async with engine.connect() as conn:
await _insert_conference(conn, fx, is_pinned=False, status="ended")
await _insert_summarizing_session(
conn,
fx,
t_end=NOW - maintenance_module.STUCK_SUMMARIZING_THRESHOLD - timedelta(minutes=1),
summary_data="Уже готовое саммари",
)
await conn.commit()
await maintenance_module.recover_stuck_summaries_async(NOW, plugins_config=_summarizer_cfg())
assert _sent_tasks_for_session(mock_send_task, fx) == []
async def test_recover_stuck_summaries_noop_when_summarizer_disabled(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""`summarizer.enabled=false` — recovery выходит сразу, не читая БД и не слишком часто
впустую рассылая задачу (иначе слала бы её каждые 5 минут без толку)."""
mock_send_task = MagicMock()
monkeypatch.setattr(maintenance_module.app, "send_task", mock_send_task)
async with engine.connect() as conn:
await _insert_conference(conn, fx, is_pinned=False, status="ended")
await _insert_summarizing_session(
conn,
fx,
t_end=NOW - maintenance_module.STUCK_SUMMARIZING_THRESHOLD - timedelta(minutes=1),
)
await conn.commit()
await maintenance_module.recover_stuck_summaries_async(
NOW, plugins_config=_summarizer_cfg(enabled=False)
)
mock_send_task.assert_not_called()
async def test_recover_stuck_notifications_resends_task_for_hung_session(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Саммари готово, но сеанс завис в 'summarizing' дольше порога — notify_session
переставлена."""
mock_send_task = MagicMock()
monkeypatch.setattr(maintenance_module.app, "send_task", mock_send_task)
async with engine.connect() as conn:
await _insert_conference(conn, fx, is_pinned=False, status="ended")
await _insert_summarizing_session(
conn,
fx,
t_end=NOW - maintenance_module.STUCK_NOTIFYING_THRESHOLD - timedelta(minutes=1),
summary_data="Готовое саммари",
)
await conn.commit()
await maintenance_module.recover_stuck_notifications_async(NOW)
assert _sent_tasks_for_session(mock_send_task, fx) == ["workers.tasks.notify.notify_session"]
async def test_recover_stuck_notifications_ignores_fresh_session(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Сеанс моложе порога (саммари только что готово) — трогать рано, no-op."""
mock_send_task = MagicMock()
monkeypatch.setattr(maintenance_module.app, "send_task", mock_send_task)
async with engine.connect() as conn:
await _insert_conference(conn, fx, is_pinned=False, status="ended")
await _insert_summarizing_session(
conn, fx, t_end=NOW - timedelta(minutes=5), summary_data="Готовое саммари"
)
await conn.commit()
await maintenance_module.recover_stuck_notifications_async(NOW)
assert _sent_tasks_for_session(mock_send_task, fx) == []
async def test_recover_stuck_notifications_ignores_session_without_summary(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Саммари ещё не готово (summary_data NULL) — это забота recover_stuck_summaries, не notify."""
mock_send_task = MagicMock()
monkeypatch.setattr(maintenance_module.app, "send_task", mock_send_task)
async with engine.connect() as conn:
await _insert_conference(conn, fx, is_pinned=False, status="ended")
await _insert_summarizing_session(
conn,
fx,
t_end=NOW - maintenance_module.STUCK_NOTIFYING_THRESHOLD - timedelta(minutes=1),
)
await conn.commit()
await maintenance_module.recover_stuck_notifications_async(NOW)
assert _sent_tasks_for_session(mock_send_task, fx) == []

View File

@@ -0,0 +1,123 @@
"""Тесты `GET /metrics`.
Правила общей dev-БД: проверяем факт наличия метрики/дельту,
а не абсолютные значения — dev-инстанс уже содержит "живые" сеансы и
запросы, накопленные другими тестами/ручным использованием. Метрика
глубины очереди Redis проверяется на throwaway-ключе (не на реальных
`transcription`/`summarize`/`notify`/`celery`) — запись фиктивной "задачи" в
настоящую очередь рисковала бы её подхватом реальным Celery-воркером
dev-стенда.
"""
import uuid
from collections.abc import Iterable
from datetime import UTC, datetime
import httpx
import pytest
from prometheus_client.parser import text_string_to_metric_families
from prometheus_client.samples import Sample
from sqlalchemy.ext.asyncio import AsyncSession
from api import metrics as metrics_module
from core.redis import redis_client
from models.conference import Conference
from models.session import ConferenceSession
from services.conference_ids import generate_number, generate_slug
def _samples(body: str, family_name: str) -> list[Sample]:
"""Все сэмплы семейства метрик `family_name` из тела ответа `/metrics`."""
result: list[Sample] = []
for family in text_string_to_metric_families(body):
if family.name == family_name:
result.extend(family.samples)
return result
def _sample_value(samples: Iterable[Sample], *, suffix: str, **labels: str) -> float | None:
"""Значение сэмпла (`name` заканчивается на `suffix`) с точным совпадением меток."""
for sample in samples:
if sample.name.endswith(suffix) and all(
sample.labels.get(k) == v for k, v in labels.items()
):
return sample.value
return None
async def test_metrics_endpoint_returns_prometheus_exposition_format(
client: httpx.AsyncClient,
) -> None:
"""`/metrics` без авторизации отдаёт текстовый формат Prometheus с нашими метриками."""
response = await client.get("/metrics")
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/plain")
families = {family.name for family in text_string_to_metric_families(response.text)}
assert "vidconf_http_request_duration_seconds" in families
assert "vidconf_pipeline_sessions" in families
assert "vidconf_celery_queue_depth" in families
async def test_metrics_records_http_latency_by_route(client: httpx.AsyncClient) -> None:
"""Латентность известного маршрута (`GET /api/health`) попадает в histogram с этим `path`."""
await client.get("/api/health")
response = await client.get("/metrics")
samples = _samples(response.text, "vidconf_http_request_duration_seconds")
count = _sample_value(samples, suffix="_count", method="GET", path="/api/health")
assert count is not None and count >= 1
async def test_metrics_pipeline_sessions_gauge_reflects_new_session(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Добавление сеанса в статусе `failed` увеличивает gauge для этого статуса ровно на 1."""
before_response = await client.get("/metrics")
before = _sample_value(
_samples(before_response.text, "vidconf_pipeline_sessions"),
suffix="vidconf_pipeline_sessions",
status="failed",
)
assert before is not None
conference = Conference(number=generate_number(), slug=generate_slug(), title="Metrics Test")
db_session.add(conference)
await db_session.flush()
db_session.add(
ConferenceSession(
conference_id=conference.id,
title="Metrics Test",
t_start=datetime.now(UTC),
pipeline_status="failed",
)
)
await db_session.flush()
after_response = await client.get("/metrics")
after = _sample_value(
_samples(after_response.text, "vidconf_pipeline_sessions"),
suffix="vidconf_pipeline_sessions",
status="failed",
)
assert after == before + 1
async def test_metrics_celery_queue_depth_gauge(
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Gauge глубины очереди отражает реальный `LLEN` отслеживаемого списка Redis."""
throwaway_queue = f"test-queue-{uuid.uuid4()}"
monkeypatch.setattr(metrics_module, "CELERY_QUEUES", (throwaway_queue,))
await redis_client.rpush(throwaway_queue, "a", "b", "c")
try:
response = await client.get("/metrics")
finally:
await redis_client.delete(throwaway_queue)
value = _sample_value(
_samples(response.text, "vidconf_celery_queue_depth"),
suffix="vidconf_celery_queue_depth",
queue=throwaway_queue,
)
assert value == 3

View File

@@ -0,0 +1,448 @@
"""Тесты Celery-задачи уведомления (`workers.tasks.notify.notify_session_async`).
Как и `test_summarize_task.py`, не использует savepoint-фикстуру `db_session`:
`notify_session_async` открывает собственную сессию с отдельным engine
(`workers/db.py::open_session`), которая не видит незакоммиченные изменения
другой сессии. Тестовые данные заводятся и коммитятся напрямую через
`core.db.engine`; email-транспорт — фейковый `EmailBackend` за контрактом
`services.email.EmailBackend` (без реального SMTP/console).
"""
import uuid
from collections.abc import AsyncGenerator
from datetime import UTC, datetime, timedelta
from typing import Any, cast
from unittest.mock import MagicMock
import pytest
from celery.exceptions import MaxRetriesExceededError
from sqlalchemy import text
from core.db import engine
from core.plugins.config import ChatConfig, InstanceConfig, SummarizerConfig, TranscriberConfig
from services.conference_ids import generate_number, generate_slug
from services.email import EmailSendError
from workers.tasks import notify as notify_module
from workers.tasks.notify import notify_session_async
NOW = datetime.now(UTC)
class _Request:
"""Заглушка `celery.Task.request`: нужен только счётчик уже сделанных попыток."""
def __init__(self, retries: int = 0) -> None:
self.retries = retries
class _FakeTask:
"""Заглушка bound-задачи Celery: фиксирует вызовы `retry`, не бросает исключение."""
def __init__(self, retries: int = 0) -> None:
self.request = _Request(retries)
self.retry = MagicMock()
class _ExhaustedRetryTask:
"""Заглушка bound-задачи: `retry` всегда бросает `MaxRetriesExceededError`."""
def __init__(self, retries: int = 0) -> None:
self.request = _Request(retries)
self.retry_calls = 0
def retry(self, countdown: int | None = None) -> None:
self.retry_calls += 1
raise MaxRetriesExceededError("исчерпаны попытки уведомления")
class _FakeEmailBackend:
"""Заглушка `EmailBackend`: запоминает успешные отправки, может «отказывать» части адресов."""
def __init__(self, *, fail_for: set[str] | None = None, retryable: bool = True) -> None:
self.sent: list[str] = []
self._fail_for = fail_for or set()
self._retryable = retryable
async def send(
self,
*,
to: str,
subject: str,
body: str,
html_body: str | None = None,
attachments: Any = (),
) -> None:
if to in self._fail_for:
raise EmailSendError(f"сбой отправки для {to}", retryable=self._retryable)
self.sent.append(to)
class _Fixture:
"""Id тестовой конференции/сеанса/участников с уже готовым summary_data."""
def __init__(self) -> None:
self.conference_id = uuid.uuid4()
self.session_id = uuid.uuid4()
self.owner_id = uuid.uuid4()
self.user_id = uuid.uuid4()
self.guest_with_email_id = uuid.uuid4()
self.guest_no_email_id = uuid.uuid4()
self.user_participant_id = uuid.uuid4()
self.guest_with_email_participant_id = uuid.uuid4()
self.guest_no_email_participant_id = uuid.uuid4()
self.number = generate_number()
self.slug = generate_slug()
self.t_start = NOW - timedelta(minutes=30)
self.t_end = NOW
self.owner_email = f"owner-{self.owner_id}@example.com"
self.user_email = f"user-{self.user_id}@example.com"
self.guest_email = f"guest-{self.guest_with_email_id}@example.com"
@pytest.fixture
async def fx() -> AsyncGenerator[_Fixture, None]:
f = _Fixture()
async with engine.connect() as conn:
await conn.execute(
text(
"INSERT INTO users (id, email, name_user, password_hash) "
"VALUES (:id, :email, 'Владелец Тестов', 'x')"
),
{"id": f.owner_id, "email": f.owner_email},
)
await conn.execute(
text(
"INSERT INTO users (id, email, name_user, password_hash) "
"VALUES (:id, :email, 'Участник Тестов', 'x')"
),
{"id": f.user_id, "email": f.user_email},
)
await conn.execute(
text(
"INSERT INTO conferences (id, number, slug, title, status, is_pinned, owner_id) "
"VALUES (:id, :number, :slug, 'Notify test', 'ended', false, :owner_id)"
),
{"id": f.conference_id, "number": f.number, "slug": f.slug, "owner_id": f.owner_id},
)
await conn.execute(
text(
"INSERT INTO guest_access (id, conference_id, display_name, email) "
"VALUES (:id, :conference_id, 'Гость С Почтой', :email)"
),
{
"id": f.guest_with_email_id,
"conference_id": f.conference_id,
"email": f.guest_email,
},
)
await conn.execute(
text(
"INSERT INTO guest_access (id, conference_id, display_name, email) "
"VALUES (:id, :conference_id, 'Гость Без Почты', NULL)"
),
{"id": f.guest_no_email_id, "conference_id": f.conference_id},
)
await conn.execute(
text(
"INSERT INTO conference_sessions "
"(id, conference_id, title, t_start, t_end, pipeline_status, summary_data) "
"VALUES (:id, :conference_id, 'Notify session', :t_start, :t_end, "
"'summarizing', 'Готовое саммари встречи')"
),
{
"id": f.session_id,
"conference_id": f.conference_id,
"t_start": f.t_start,
"t_end": f.t_end,
},
)
await conn.execute(
text(
"INSERT INTO conference_participants (id, session_id, user_id, joined_at, left_at) "
"VALUES (:id, :session_id, :user_id, :joined_at, :left_at)"
),
{
"id": f.user_participant_id,
"session_id": f.session_id,
"user_id": f.user_id,
"joined_at": f.t_start,
"left_at": f.t_end,
},
)
await conn.execute(
text(
"INSERT INTO conference_participants "
"(id, session_id, guest_id, joined_at, left_at) "
"VALUES (:id, :session_id, :guest_id, :joined_at, :left_at)"
),
{
"id": f.guest_with_email_participant_id,
"session_id": f.session_id,
"guest_id": f.guest_with_email_id,
"joined_at": f.t_start,
"left_at": f.t_end,
},
)
await conn.execute(
text(
"INSERT INTO conference_participants "
"(id, session_id, guest_id, joined_at, left_at) "
"VALUES (:id, :session_id, :guest_id, :joined_at, :left_at)"
),
{
"id": f.guest_no_email_participant_id,
"session_id": f.session_id,
"guest_id": f.guest_no_email_id,
"joined_at": f.t_start,
"left_at": f.t_end,
},
)
await conn.commit()
yield f
async with engine.connect() as conn:
await conn.execute(
text("DELETE FROM email_deliveries WHERE session_id = :id"), {"id": f.session_id}
)
await conn.execute(
text("DELETE FROM conference_participants WHERE session_id = :id"),
{"id": f.session_id},
)
await conn.execute(
text("DELETE FROM conference_sessions WHERE conference_id = :id"),
{"id": f.conference_id},
)
await conn.execute(
text("DELETE FROM guest_access WHERE conference_id = :id"), {"id": f.conference_id}
)
await conn.execute(text("DELETE FROM conferences WHERE id = :id"), {"id": f.conference_id})
await conn.execute(text("DELETE FROM users WHERE id = :id"), {"id": f.user_id})
await conn.execute(text("DELETE FROM users WHERE id = :id"), {"id": f.owner_id})
await conn.commit()
def _cfg(*, summary_recipients: str = "all") -> InstanceConfig:
return InstanceConfig(
transcriber=TranscriberConfig(),
summarizer=SummarizerConfig(),
chat=ChatConfig(),
summary_recipients=cast("Any", summary_recipients),
)
async def _fetch_session_status(session_id: uuid.UUID) -> str:
async with engine.connect() as conn:
result = await conn.execute(
text("SELECT pipeline_status FROM conference_sessions WHERE id = :id"),
{"id": session_id},
)
return cast("str", result.scalar_one())
async def _fetch_delivered_emails(session_id: uuid.UUID) -> set[str]:
async with engine.connect() as conn:
result = await conn.execute(
text(
"SELECT recipient_email FROM email_deliveries "
"WHERE session_id = :id AND kind = 'summary'"
),
{"id": session_id},
)
return {row[0] for row in result.all()}
async def _set_conference_summary_recipients(conference_id: uuid.UUID, value: str | None) -> None:
async with engine.connect() as conn:
await conn.execute(
text("UPDATE conferences SET summary_recipients = :value WHERE id = :id"),
{"id": conference_id, "value": value},
)
await conn.commit()
async def test_notify_session_mode_all_sends_to_participants_with_email(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Режим «всем»: пользователь и гость с email получают письмо, гость без email — нет."""
backend = _FakeEmailBackend()
monkeypatch.setattr(notify_module, "create_email_backend", lambda settings: backend)
await notify_session_async(
_FakeTask(), fx.session_id, plugins_config=_cfg(summary_recipients="all")
)
assert set(backend.sent) == {fx.user_email, fx.guest_email}
assert await _fetch_session_status(fx.session_id) == "notified"
assert await _fetch_delivered_emails(fx.session_id) == {fx.user_email, fx.guest_email}
async def test_notify_session_mode_owner_sends_only_to_owner(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Режим «только организатору» (дефолт инстанса) — письмо получает только владелец."""
backend = _FakeEmailBackend()
monkeypatch.setattr(notify_module, "create_email_backend", lambda settings: backend)
await notify_session_async(
_FakeTask(), fx.session_id, plugins_config=_cfg(summary_recipients="owner")
)
assert backend.sent == [fx.owner_email]
assert await _fetch_session_status(fx.session_id) == "notified"
async def test_notify_session_conference_override_wins_over_instance_default(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Переопределение `conference.summary_recipients` сильнее дефолта инстанса."""
backend = _FakeEmailBackend()
monkeypatch.setattr(notify_module, "create_email_backend", lambda settings: backend)
await _set_conference_summary_recipients(fx.conference_id, "owner")
await notify_session_async(
_FakeTask(), fx.session_id, plugins_config=_cfg(summary_recipients="all")
)
assert backend.sent == [fx.owner_email]
async def test_notify_session_repeated_run_does_not_duplicate(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Повторный запуск после успешной рассылки — email_deliveries не дублируются, писем не шлём."""
first_backend = _FakeEmailBackend()
monkeypatch.setattr(notify_module, "create_email_backend", lambda settings: first_backend)
await notify_session_async(_FakeTask(), fx.session_id, plugins_config=_cfg())
assert await _fetch_session_status(fx.session_id) == "notified"
# Второй прогон подаётся уже другим сеансом (pipeline_status уже notified) — guard
# должен остановить его до всякой попытки отправки; проверим и явный сценарий
# "уже отправлено part of pending", переставив статус обратно как это делает
# recover_stuck_notifications при гонке с уже отработавшей задачей.
async with engine.connect() as conn:
await conn.execute(
text("UPDATE conference_sessions SET pipeline_status = 'summarizing' WHERE id = :id"),
{"id": fx.session_id},
)
await conn.commit()
second_backend = _FakeEmailBackend()
monkeypatch.setattr(notify_module, "create_email_backend", lambda settings: second_backend)
await notify_session_async(_FakeTask(), fx.session_id, plugins_config=_cfg())
assert second_backend.sent == []
assert await _fetch_session_status(fx.session_id) == "notified"
assert await _fetch_delivered_emails(fx.session_id) == {fx.user_email, fx.guest_email}
async def test_notify_session_partial_failure_retry_delivers_only_undelivered(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Частичный сбой SMTP → retry; повторный запуск дошлёт только недоставленным."""
failing_backend = _FakeEmailBackend(fail_for={fx.guest_email}, retryable=True)
monkeypatch.setattr(notify_module, "create_email_backend", lambda settings: failing_backend)
task = _FakeTask(retries=0)
await notify_session_async(task, fx.session_id, plugins_config=_cfg())
task.retry.assert_called_once_with(countdown=60)
# Получатель, отправленный до сбоя, уже зафиксирован — статус пока не notified.
assert await _fetch_session_status(fx.session_id) == "summarizing"
delivered_before_retry = await _fetch_delivered_emails(fx.session_id)
assert fx.user_email in delivered_before_retry
assert fx.guest_email not in delivered_before_retry
# Повторная доставка задачи celery-ретраем (acks_late) — второй прогон уже
# без временного сбоя, дошлёт только недоставленного получателя.
recovering_backend = _FakeEmailBackend()
monkeypatch.setattr(notify_module, "create_email_backend", lambda settings: recovering_backend)
second_task = _FakeTask(retries=1)
await notify_session_async(second_task, fx.session_id, plugins_config=_cfg())
second_task.retry.assert_not_called()
assert recovering_backend.sent == [fx.guest_email]
assert await _fetch_session_status(fx.session_id) == "notified"
assert await _fetch_delivered_emails(fx.session_id) == {fx.user_email, fx.guest_email}
async def test_notify_session_marks_failed_when_retries_exhausted(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Исчерпание retry (SMTP постоянно недоступен) → pipeline_status='failed'."""
backend = _FakeEmailBackend(fail_for={fx.user_email, fx.guest_email}, retryable=True)
monkeypatch.setattr(notify_module, "create_email_backend", lambda settings: backend)
task = _ExhaustedRetryTask(retries=5)
await notify_session_async(task, fx.session_id, plugins_config=_cfg())
assert task.retry_calls == 1
assert await _fetch_session_status(fx.session_id) == "failed"
async def test_notify_session_no_recipients_marks_notified_immediately(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Режим «организатору», но у конференции нет владельца — получателей нет, сразу notified."""
backend = _FakeEmailBackend()
monkeypatch.setattr(notify_module, "create_email_backend", lambda settings: backend)
async with engine.connect() as conn:
await conn.execute(
text("UPDATE conferences SET owner_id = NULL WHERE id = :id"),
{"id": fx.conference_id},
)
await conn.commit()
await notify_session_async(
_FakeTask(), fx.session_id, plugins_config=_cfg(summary_recipients="owner")
)
assert backend.sent == []
assert await _fetch_session_status(fx.session_id) == "notified"
async def test_notify_session_noop_when_session_not_found(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Guard №1: сеанс не найден — no-op, send не вызывается."""
mock_factory = MagicMock()
monkeypatch.setattr(notify_module, "create_email_backend", mock_factory)
await notify_session_async(_FakeTask(), uuid.uuid4(), plugins_config=_cfg())
mock_factory.assert_not_called()
async def test_notify_session_noop_when_not_on_notify_step(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Guard №2: pipeline_status не 'summarizing' — no-op."""
mock_factory = MagicMock()
monkeypatch.setattr(notify_module, "create_email_backend", mock_factory)
async with engine.connect() as conn:
await conn.execute(
text("UPDATE conference_sessions SET pipeline_status = 'notified' WHERE id = :id"),
{"id": fx.session_id},
)
await conn.commit()
await notify_session_async(_FakeTask(), fx.session_id, plugins_config=_cfg())
mock_factory.assert_not_called()
async def test_notify_session_noop_when_summary_not_ready(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Guard №3: summary_data ещё NULL — no-op."""
mock_factory = MagicMock()
monkeypatch.setattr(notify_module, "create_email_backend", mock_factory)
async with engine.connect() as conn:
await conn.execute(
text("UPDATE conference_sessions SET summary_data = NULL WHERE id = :id"),
{"id": fx.session_id},
)
await conn.commit()
await notify_session_async(_FakeTask(), fx.session_id, plugins_config=_cfg())
mock_factory.assert_not_called()

View File

@@ -0,0 +1,559 @@
"""Тесты оркестрации AI-пайплайна (`workers.tasks.pipeline.run_pipeline_async`).
Как и `test_maintenance.py`, не использует savepoint-фикстуру `db_session`:
`run_pipeline_async` открывает собственную сессию с отдельным engine
(`workers/db.py::open_session`), которая не видит незакоммиченные изменения
другой сессии. Тестовые данные заводятся и коммитятся напрямую через
`core.db.engine`, транскрайбер — фейк за контрактом `Transcriber` (без
faster-whisper), файлы треков — реальные (пустые) в `tmp_path`.
"""
import uuid
from collections.abc import AsyncGenerator
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import cast
from unittest.mock import MagicMock
import pytest
from celery.exceptions import MaxRetriesExceededError
from kombu.exceptions import OperationalError as KombuOperationalError
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncConnection
from core.db import engine
from core.plugins.config import ChatConfig, InstanceConfig, SummarizerConfig, TranscriberConfig
from core.plugins.transcriber import Segment
from services.conference_ids import generate_number, generate_slug
from workers.tasks import dispatch as dispatch_module
from workers.tasks import pipeline as pipeline_module
from workers.tasks.pipeline import run_pipeline_async
NOW = datetime.now(UTC)
class _FakeTranscriber:
"""Заглушка `Transcriber`: канонические сегменты по пути файла + счётчик вызовов.
`fail_once_for` — пути, для которых первый вызов бросает исключение
(симуляция падения воркера посреди транскрибации, тест №12), повторный
вызов для того же пути уже успешен.
"""
def __init__(
self, canned: dict[str, list[Segment]], fail_once_for: set[str] | None = None
) -> None:
self._canned = canned
self._fail_once_for = set(fail_once_for or set())
self.calls: list[str] = []
def transcribe(self, audio_path: str, language: str = "ru") -> list[Segment]:
self.calls.append(audio_path)
if audio_path in self._fail_once_for:
self._fail_once_for.discard(audio_path)
raise RuntimeError("симуляция падения воркера посреди транскрибации")
return self._canned[audio_path]
class _FakeTask:
"""Заглушка bound-задачи Celery: фиксирует вызовы `retry`, не бросает исключение.
В реальном Celery `Task.retry()` сам бросает `Retry`/`MaxRetriesExceededError`
и не возвращает управление — здесь для теста №14 нужен именно факт вызова.
"""
def __init__(self) -> None:
self.retry = MagicMock()
class _ExhaustedRetryTask:
"""Заглушка bound-задачи: `retry` всегда бросает `MaxRetriesExceededError`.
Имитирует исчерпание попыток ожидания egress (реальный `Task.retry()`
бросает именно это исключение, когда `max_retries` уже выбраны).
"""
def __init__(self) -> None:
self.retry_calls = 0
def retry(self, countdown: int | None = None) -> None:
self.retry_calls += 1
raise MaxRetriesExceededError("исчерпаны попытки ожидания egress")
class _Fixture:
"""Id тестовой конференции/сеанса/участников + пути файлов треков."""
def __init__(self, tmp_path: Path) -> None:
self.conference_id = uuid.uuid4()
self.session_id = uuid.uuid4()
self.user_id = uuid.uuid4()
self.guest_id = uuid.uuid4()
self.user_participant_id = uuid.uuid4()
self.guest_participant_id = uuid.uuid4()
self.number = generate_number()
self.slug = generate_slug()
self.t_start = NOW - timedelta(minutes=30)
self.t_end = NOW
self.track1_path = str(tmp_path / "track1.ogg")
self.track2_path = str(tmp_path / "track2.ogg")
# Реальные (пустые) файлы — `run_pipeline_async` проверяет их наличие
# перед вызовом транскрайбера; запись — синхронно в конструкторе, не
# в async-фикстуре (ASYNC240: pathlib в async-функции).
Path(self.track1_path).write_bytes(b"")
Path(self.track2_path).write_bytes(b"")
@pytest.fixture
async def fx(tmp_path: Path) -> AsyncGenerator[_Fixture, None]:
f = _Fixture(tmp_path)
async with engine.connect() as conn:
await conn.execute(
text(
"INSERT INTO users (id, email, name_user, password_hash) "
"VALUES (:id, :email, 'Pipeline Tester', 'x')"
),
{"id": f.user_id, "email": f"pipeline-{f.user_id}@example.com"},
)
await conn.execute(
text(
"INSERT INTO conferences (id, number, slug, title, status, is_pinned) "
"VALUES (:id, :number, :slug, 'Pipeline test', 'ended', false)"
),
{"id": f.conference_id, "number": f.number, "slug": f.slug},
)
await conn.execute(
text(
"INSERT INTO guest_access (id, conference_id, display_name) "
"VALUES (:id, :conference_id, 'Guest Tester')"
),
{"id": f.guest_id, "conference_id": f.conference_id},
)
await conn.execute(
text(
"INSERT INTO conference_sessions (id, conference_id, title, t_start, t_end) "
"VALUES (:id, :conference_id, 'Pipeline session', :t_start, :t_end)"
),
{
"id": f.session_id,
"conference_id": f.conference_id,
"t_start": f.t_start,
"t_end": f.t_end,
},
)
await conn.execute(
text(
"INSERT INTO conference_participants (id, session_id, user_id, joined_at, left_at) "
"VALUES (:id, :session_id, :user_id, :joined_at, :left_at)"
),
{
"id": f.user_participant_id,
"session_id": f.session_id,
"user_id": f.user_id,
"joined_at": f.t_start,
"left_at": f.t_end,
},
)
await conn.execute(
text(
"INSERT INTO conference_participants "
"(id, session_id, guest_id, joined_at, left_at) "
"VALUES (:id, :session_id, :guest_id, :joined_at, :left_at)"
),
{
"id": f.guest_participant_id,
"session_id": f.session_id,
"guest_id": f.guest_id,
"joined_at": f.t_start,
"left_at": f.t_end,
},
)
await conn.commit()
yield f
async with engine.connect() as conn:
await conn.execute(text("DELETE FROM phrases WHERE session_id = :id"), {"id": f.session_id})
await conn.execute(
text("DELETE FROM session_audio_tracks WHERE session_id = :id"), {"id": f.session_id}
)
await conn.execute(
text("DELETE FROM conference_participants WHERE session_id = :id"), {"id": f.session_id}
)
await conn.execute(
text("DELETE FROM conference_sessions WHERE conference_id = :id"),
{"id": f.conference_id},
)
await conn.execute(
text("DELETE FROM guest_access WHERE conference_id = :id"), {"id": f.conference_id}
)
await conn.execute(text("DELETE FROM conferences WHERE id = :id"), {"id": f.conference_id})
await conn.execute(text("DELETE FROM users WHERE id = :id"), {"id": f.user_id})
await conn.commit()
async def _insert_track(
conn: AsyncConnection,
*,
session_id: uuid.UUID,
participant_id: uuid.UUID,
track_sid: str,
file_path: str,
status: str,
started_at: datetime,
) -> None:
await conn.execute(
text(
"INSERT INTO session_audio_tracks "
"(id, session_id, participant_id, track_sid, egress_id, file_path, status, started_at) "
"VALUES (:id, :session_id, :participant_id, :track_sid, :egress_id, :file_path, "
":status, :started_at)"
),
{
"id": uuid.uuid4(),
"session_id": session_id,
"participant_id": participant_id,
"track_sid": track_sid,
"egress_id": f"EG_{track_sid}",
"file_path": file_path,
"status": status,
"started_at": started_at,
},
)
def _cfg(*, enabled: bool = True) -> InstanceConfig:
"""Конфиг для тестов: реальный `Transcriber` не создаётся — провайдер
подменяется через monkeypatch `pipeline_module.create_transcriber`."""
return InstanceConfig(
transcriber=TranscriberConfig(enabled=enabled, provider="fake", language="ru"),
summarizer=SummarizerConfig(),
chat=ChatConfig(),
)
async def _fetch_session_status(session_id: uuid.UUID) -> str:
async with engine.connect() as conn:
result = await conn.execute(
text("SELECT pipeline_status FROM conference_sessions WHERE id = :id"),
{"id": session_id},
)
return cast("str", result.scalar_one())
async def _fetch_track_status(session_id: uuid.UUID, track_sid: str) -> str:
async with engine.connect() as conn:
result = await conn.execute(
text(
"SELECT status FROM session_audio_tracks "
"WHERE session_id = :session_id AND track_sid = :track_sid"
),
{"session_id": session_id, "track_sid": track_sid},
)
return cast("str", result.scalar_one())
async def _fetch_phrases(session_id: uuid.UUID) -> list[dict[str, object]]:
async with engine.connect() as conn:
result = await conn.execute(
text(
"SELECT participant_id, data, t_start, t_end FROM phrases "
"WHERE session_id = :id ORDER BY t_start"
),
{"id": session_id},
)
return [dict(row) for row in result.mappings().all()]
async def test_run_pipeline_reconstructs_phrases_for_user_and_guest(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""№11: сеанс с user- и гость-участником, оба трека 'recorded'
phrases + status=summarizing."""
async with engine.connect() as conn:
await _insert_track(
conn,
session_id=fx.session_id,
participant_id=fx.user_participant_id,
track_sid="TR_USER",
file_path=fx.track1_path,
status="recorded",
started_at=fx.t_start + timedelta(seconds=5),
)
await _insert_track(
conn,
session_id=fx.session_id,
participant_id=fx.guest_participant_id,
track_sid="TR_GUEST",
file_path=fx.track2_path,
status="recorded",
started_at=fx.t_start + timedelta(seconds=10),
)
await conn.commit()
fake = _FakeTranscriber(
canned={
fx.track1_path: [Segment(0.0, 2.0, "привет")],
fx.track2_path: [Segment(0.0, 2.0, "привет в ответ")],
}
)
monkeypatch.setattr(pipeline_module, "create_transcriber", lambda cfg: fake)
mock_send_task = MagicMock()
monkeypatch.setattr(pipeline_module.app, "send_task", mock_send_task)
await run_pipeline_async(_FakeTask(), fx.session_id, plugins_config=_cfg())
assert await _fetch_session_status(fx.session_id) == "summarizing"
mock_send_task.assert_called_once_with(
"workers.tasks.summarize.summarize_session", args=[str(fx.session_id)]
)
phrases = await _fetch_phrases(fx.session_id)
assert len(phrases) == 2
assert phrases[0]["participant_id"] == fx.user_participant_id
assert phrases[0]["data"] == "привет"
assert phrases[0]["t_start"] == fx.t_start + timedelta(seconds=5)
assert phrases[0]["t_end"] == fx.t_start + timedelta(seconds=7)
assert phrases[1]["participant_id"] == fx.guest_participant_id
assert phrases[1]["data"] == "привет в ответ"
assert phrases[1]["t_start"] == fx.t_start + timedelta(seconds=10)
assert phrases[1]["t_end"] == fx.t_start + timedelta(seconds=12)
async def test_run_pipeline_is_idempotent_after_mid_run_crash(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""№12: падение на треке №2 → повтор транскрибирует только его, без дублей phrases."""
async with engine.connect() as conn:
await _insert_track(
conn,
session_id=fx.session_id,
participant_id=fx.user_participant_id,
track_sid="TR_USER",
file_path=fx.track1_path,
status="recorded",
started_at=fx.t_start + timedelta(seconds=5),
)
await _insert_track(
conn,
session_id=fx.session_id,
participant_id=fx.guest_participant_id,
track_sid="TR_GUEST",
file_path=fx.track2_path,
status="recorded",
started_at=fx.t_start + timedelta(seconds=10),
)
await conn.commit()
fake = _FakeTranscriber(
canned={
fx.track1_path: [Segment(0.0, 2.0, "привет")],
fx.track2_path: [Segment(0.0, 2.0, "привет в ответ")],
},
fail_once_for={fx.track2_path},
)
monkeypatch.setattr(pipeline_module, "create_transcriber", lambda cfg: fake)
monkeypatch.setattr(pipeline_module.app, "send_task", MagicMock())
with pytest.raises(RuntimeError):
await run_pipeline_async(_FakeTask(), fx.session_id, plugins_config=_cfg())
assert fake.calls == [fx.track1_path, fx.track2_path]
assert await _fetch_session_status(fx.session_id) == "transcribing"
assert await _fetch_phrases(fx.session_id) == []
await run_pipeline_async(_FakeTask(), fx.session_id, plugins_config=_cfg())
assert fake.calls == [fx.track1_path, fx.track2_path, fx.track2_path]
assert await _fetch_session_status(fx.session_id) == "summarizing"
phrases = await _fetch_phrases(fx.session_id)
assert len(phrases) == 2
async def test_run_pipeline_noop_when_transcriber_disabled(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""№13: `transcriber.enabled=false` → no-op, status остаётся 'recording'."""
async with engine.connect() as conn:
await _insert_track(
conn,
session_id=fx.session_id,
participant_id=fx.user_participant_id,
track_sid="TR_USER",
file_path=fx.track1_path,
status="recorded",
started_at=fx.t_start + timedelta(seconds=5),
)
await conn.commit()
mock_factory = MagicMock()
monkeypatch.setattr(pipeline_module, "create_transcriber", mock_factory)
await run_pipeline_async(_FakeTask(), fx.session_id, plugins_config=_cfg(enabled=False))
mock_factory.assert_not_called()
assert await _fetch_session_status(fx.session_id) == "recording"
assert await _fetch_phrases(fx.session_id) == []
async def test_run_pipeline_retries_while_tracks_still_recording(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""№14: трек ещё 'recording' → `task.retry` вызван, транскрайбер не запускался."""
async with engine.connect() as conn:
await _insert_track(
conn,
session_id=fx.session_id,
participant_id=fx.user_participant_id,
track_sid="TR_USER",
file_path=fx.track1_path,
status="recording",
started_at=fx.t_start + timedelta(seconds=5),
)
await conn.commit()
mock_factory = MagicMock()
monkeypatch.setattr(pipeline_module, "create_transcriber", mock_factory)
task = _FakeTask()
await run_pipeline_async(task, fx.session_id, plugins_config=_cfg())
task.retry.assert_called_once_with(countdown=pipeline_module.RETRY_COUNTDOWN_S)
mock_factory.assert_not_called()
assert await _fetch_session_status(fx.session_id) == "recording"
async def test_run_pipeline_marks_hung_track_failed_when_retries_exhausted(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Исчерпание retry (`MaxRetriesExceededError`): зависший 'recording' трек
помечается 'failed', обработка продолжается с остальными треками —
трек №2 транскрибируется, пайплайн доходит до 'summarizing'."""
async with engine.connect() as conn:
await _insert_track(
conn,
session_id=fx.session_id,
participant_id=fx.user_participant_id,
track_sid="TR_USER_HUNG",
file_path=fx.track1_path,
status="recording",
started_at=fx.t_start + timedelta(seconds=5),
)
await _insert_track(
conn,
session_id=fx.session_id,
participant_id=fx.guest_participant_id,
track_sid="TR_GUEST",
file_path=fx.track2_path,
status="recorded",
started_at=fx.t_start + timedelta(seconds=10),
)
await conn.commit()
fake = _FakeTranscriber(canned={fx.track2_path: [Segment(0.0, 2.0, "привет в ответ")]})
monkeypatch.setattr(pipeline_module, "create_transcriber", lambda cfg: fake)
monkeypatch.setattr(pipeline_module.app, "send_task", MagicMock())
task = _ExhaustedRetryTask()
await run_pipeline_async(task, fx.session_id, plugins_config=_cfg())
assert task.retry_calls == 1
assert await _fetch_track_status(fx.session_id, "TR_USER_HUNG") == "failed"
assert await _fetch_track_status(fx.session_id, "TR_GUEST") == "transcribed"
assert fake.calls == [fx.track2_path]
assert await _fetch_session_status(fx.session_id) == "summarizing"
phrases = await _fetch_phrases(fx.session_id)
assert len(phrases) == 1
assert phrases[0]["participant_id"] == fx.guest_participant_id
async def test_run_pipeline_fails_when_all_tracks_failed(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""№15: все треки 'failed' → pipeline_status='failed'."""
async with engine.connect() as conn:
await _insert_track(
conn,
session_id=fx.session_id,
participant_id=fx.user_participant_id,
track_sid="TR_USER",
file_path=fx.track1_path,
status="failed",
started_at=fx.t_start + timedelta(seconds=5),
)
await _insert_track(
conn,
session_id=fx.session_id,
participant_id=fx.guest_participant_id,
track_sid="TR_GUEST",
file_path=fx.track2_path,
status="failed",
started_at=fx.t_start + timedelta(seconds=10),
)
await conn.commit()
fake = _FakeTranscriber(canned={})
monkeypatch.setattr(pipeline_module, "create_transcriber", lambda cfg: fake)
await run_pipeline_async(_FakeTask(), fx.session_id, plugins_config=_cfg())
assert await _fetch_session_status(fx.session_id) == "failed"
assert fake.calls == []
async def test_send_summarize_task_retries_then_succeeds_on_broker_failure(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Постановка `summarize_session` (защита от сбоя брокера): первая попытка бросает
`kombu.exceptions.OperationalError`, вторая — успешна; `run_pipeline` не падает."""
async with engine.connect() as conn:
await _insert_track(
conn,
session_id=fx.session_id,
participant_id=fx.user_participant_id,
track_sid="TR_USER",
file_path=fx.track1_path,
status="recorded",
started_at=fx.t_start + timedelta(seconds=5),
)
await conn.commit()
fake = _FakeTranscriber(canned={fx.track1_path: [Segment(0.0, 2.0, "привет")]})
monkeypatch.setattr(pipeline_module, "create_transcriber", lambda cfg: fake)
monkeypatch.setattr(dispatch_module, "SEND_TASK_BACKOFF_S", 0.0)
mock_send_task = MagicMock(side_effect=[KombuOperationalError("брокер недоступен"), None])
monkeypatch.setattr(pipeline_module.app, "send_task", mock_send_task)
await run_pipeline_async(_FakeTask(), fx.session_id, plugins_config=_cfg())
assert mock_send_task.call_count == 2
assert await _fetch_session_status(fx.session_id) == "summarizing"
async def test_send_summarize_task_exhausted_retries_does_not_raise(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Все попытки постановки `summarize_session` исчерпаны (брокер недоступен постоянно):
`run_pipeline` не бросает исключение, фразы и статус 'summarizing' сохранены —
восстановление ложится на `recover_stuck_summaries` (maintenance)."""
async with engine.connect() as conn:
await _insert_track(
conn,
session_id=fx.session_id,
participant_id=fx.user_participant_id,
track_sid="TR_USER",
file_path=fx.track1_path,
status="recorded",
started_at=fx.t_start + timedelta(seconds=5),
)
await conn.commit()
fake = _FakeTranscriber(canned={fx.track1_path: [Segment(0.0, 2.0, "привет")]})
monkeypatch.setattr(pipeline_module, "create_transcriber", lambda cfg: fake)
monkeypatch.setattr(dispatch_module, "SEND_TASK_BACKOFF_S", 0.0)
mock_send_task = MagicMock(side_effect=KombuOperationalError("брокер недоступен"))
monkeypatch.setattr(pipeline_module.app, "send_task", mock_send_task)
await run_pipeline_async(_FakeTask(), fx.session_id, plugins_config=_cfg())
assert mock_send_task.call_count == dispatch_module.SEND_TASK_MAX_ATTEMPTS
assert await _fetch_session_status(fx.session_id) == "summarizing"
assert len(await _fetch_phrases(fx.session_id)) == 1

View File

@@ -0,0 +1,189 @@
"""Тесты для загрузчика конфига плагинов и factory."""
import sys
import types
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import pytest
from core.plugins.config import PluginsConfig, TranscriberConfig, load_plugins_config
from core.plugins.factory import UnknownProviderError, create_summarizer, create_transcriber
from core.plugins.faster_whisper import FasterWhisperCPU, FasterWhisperGPU
from core.plugins.null import NullSummarizer
PLUGINS_YAML = Path(__file__).parent.parent.parent / "config" / "plugins.yaml"
def test_load_plugins_config_from_yaml() -> None:
config = load_plugins_config(PLUGINS_YAML)
assert isinstance(config, PluginsConfig)
assert config.transcriber.enabled is True
assert config.transcriber.provider == "faster_whisper_cpu"
assert config.transcriber.model == "small"
assert config.transcriber.language == "ru"
assert config.summarizer.provider == "null"
assert config.summarizer.chunk_minutes == 20
assert config.chat.enabled is True
def test_factory_instantiates_faster_whisper_transcriber_from_config() -> None:
config = load_plugins_config(PLUGINS_YAML)
transcriber = create_transcriber(config.transcriber)
assert isinstance(transcriber, FasterWhisperCPU)
assert transcriber.model_name == "small"
assert transcriber.language == "ru"
# Путь — КОНТРАКТ с backend/services/ai_tiers.py (WHISPER_MODELS_ROOT,
# ADR-004): модель уровня хранится в подкаталоге по имени модели,
# не в плоском корне тома (см. комментарий в config/plugins.yaml).
assert transcriber.download_root == "/models/whisper/small"
def test_factory_instantiates_null_summarizer() -> None:
config = load_plugins_config(PLUGINS_YAML)
summarizer = create_summarizer(config.summarizer)
assert isinstance(summarizer, NullSummarizer)
assert summarizer.summarize("some transcript") == ""
def test_factory_unknown_transcriber_provider_raises() -> None:
config = load_plugins_config(PLUGINS_YAML)
config.transcriber.provider = "does-not-exist"
with pytest.raises(UnknownProviderError):
create_transcriber(config.transcriber)
def test_factory_unknown_summarizer_provider_raises() -> None:
config = load_plugins_config(PLUGINS_YAML)
config.summarizer.provider = "does-not-exist"
with pytest.raises(UnknownProviderError):
create_summarizer(config.summarizer)
def test_factory_creates_faster_whisper_without_importing_it(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""`create_transcriber` для `faster_whisper_cpu` не импортирует пакет `faster_whisper`.
Ленивость: тяжёлая зависимость грузится только внутри `transcribe()`, а не
при инстанцировании плагина фабрикой (важно для API-процесса).
"""
monkeypatch.delitem(sys.modules, "faster_whisper", raising=False)
config = load_plugins_config(PLUGINS_YAML)
transcriber = create_transcriber(config.transcriber)
assert isinstance(transcriber, FasterWhisperCPU)
assert "faster_whisper" not in sys.modules
@dataclass
class _FakeRawSegment:
"""Сегмент, возвращаемый заглушкой `WhisperModel.transcribe`."""
start: float
end: float
text: str
class _FakeWhisperModel:
"""Заглушка `faster_whisper.WhisperModel`, фиксирующая переданные аргументы."""
last_init_kwargs: dict[str, Any] | None = None
last_transcribe_kwargs: dict[str, Any] | None = None
def __init__(self, model_size_or_path: str, **kwargs: Any) -> None:
self.model_size_or_path = model_size_or_path
_FakeWhisperModel.last_init_kwargs = kwargs
def transcribe(self, audio_path: str, **kwargs: Any) -> tuple[list[_FakeRawSegment], object]:
_FakeWhisperModel.last_transcribe_kwargs = kwargs
segments = [
_FakeRawSegment(0.0, 2.0, "длинный сегмент"),
_FakeRawSegment(2.0, 2.2, "коротыш"), # 0.2с < 0.3с — отбрасывается
_FakeRawSegment(2.2, 2.6, "ровно на границе"), # 0.4с — остаётся
]
return segments, object()
def test_faster_whisper_transcribe_forwards_params_and_filters_short_segments(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Юнит `FasterWhisperCPU` с замоканным `WhisperModel`: прокидывание
language/vad_filter/vad_parameters и отбрасывание сегментов короче 0.3с.
"""
fake_module = types.ModuleType("faster_whisper")
fake_module.WhisperModel = _FakeWhisperModel # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "faster_whisper", fake_module)
monkeypatch.setattr(FasterWhisperCPU, "_model", None)
transcriber = FasterWhisperCPU(model="small", language="ru", download_root="/models/whisper")
result = transcriber.transcribe("audio.ogg", language="ru")
assert [segment.text for segment in result] == ["длинный сегмент", "ровно на границе"]
assert _FakeWhisperModel.last_init_kwargs == {
"device": "cpu",
"compute_type": "int8",
"download_root": "/models/whisper",
}
assert _FakeWhisperModel.last_transcribe_kwargs == {
"language": "ru",
"vad_filter": True,
"vad_parameters": {"min_silence_duration_ms": 500},
}
def test_factory_instantiates_faster_whisper_gpu_transcriber() -> None:
"""`create_transcriber` для `faster_whisper_gpu` (уровни `medium`/`max`, ADR-004)."""
config = TranscriberConfig(
provider="faster_whisper_gpu",
model="large-v3",
options={"download_root": "/models/whisper/large-v3", "compute_type": "float16"},
)
transcriber = create_transcriber(config)
assert isinstance(transcriber, FasterWhisperGPU)
assert transcriber.model_name == "large-v3"
assert transcriber.download_root == "/models/whisper/large-v3"
assert transcriber.compute_type == "float16"
assert transcriber.device == "cuda"
def test_faster_whisper_gpu_transcribe_forwards_cuda_device_and_compute_type(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""GPU-плагин прокидывает `device="cuda"`/`compute_type` в `WhisperModel`."""
fake_module = types.ModuleType("faster_whisper")
fake_module.WhisperModel = _FakeWhisperModel # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "faster_whisper", fake_module)
monkeypatch.setattr(FasterWhisperGPU, "_model", None)
transcriber = FasterWhisperGPU(model="large-v3", download_root="/models/whisper/large-v3")
result = transcriber.transcribe("audio.ogg", language="ru")
assert [segment.text for segment in result] == ["длинный сегмент", "ровно на границе"]
assert _FakeWhisperModel.last_init_kwargs == {
"device": "cuda",
"compute_type": "float16",
"download_root": "/models/whisper/large-v3",
}
def test_cpu_and_gpu_transcribers_have_independent_model_singletons(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""CPU- и GPU-плагины не делят синглтон `_model` — у каждого подкласса свой атрибут."""
monkeypatch.setattr(FasterWhisperCPU, "_model", "cpu-sentinel")
monkeypatch.setattr(FasterWhisperGPU, "_model", None)
assert FasterWhisperCPU._model == "cpu-sentinel"
assert FasterWhisperGPU._model is None

View File

@@ -0,0 +1,243 @@
"""Тесты плагина `QwenLocal` (map-reduce) с мок-LLM-клиентом (без сети/токенизатора).
`QwenLocal._get_client()` подменяется фейковым клиентом с методом `complete`,
поэтому чанкер и подстановка промптов проверяются изолированно от
`OpenAICompatClient`/`httpx`. Подсчёт токенов подменяется простым фейком
(число слов), чтобы детерминированно управлять группировкой в reduce.
"""
from pathlib import Path
from typing import Any
import pytest
from core.plugins.qwen_local import QwenLocal
PROMPTS_DIR = Path(__file__).parent.parent.parent / "workers" / "summarizer" / "prompts"
class _FakeLlmClient:
"""Фейковый LLM-клиент: запоминает все промпты и возвращает по очереди ответы."""
def __init__(self, responses: list[str] | None = None) -> None:
self.prompts: list[str] = []
self.max_tokens_seen: list[int | None] = []
self._responses = responses
self.closed = False
def complete(self, prompt: str, *, max_tokens: int | None = None) -> str:
self.prompts.append(prompt)
self.max_tokens_seen.append(max_tokens)
if self._responses is not None:
return self._responses[len(self.prompts) - 1]
return f"резюме №{len(self.prompts)}"
def close(self) -> None:
"""Заглушка `OpenAICompatClient.close()` — фиксирует факт закрытия."""
self.closed = True
def _make_plugin(fake_client: _FakeLlmClient, count_tokens: Any = None, **kwargs: Any) -> QwenLocal:
plugin = QwenLocal(prompts_dir=str(PROMPTS_DIR), **kwargs)
plugin._client = fake_client # type: ignore[assignment]
if count_tokens is not None:
plugin._count_tokens = count_tokens
return plugin
def _word_count(text: str) -> int:
return len(text.split())
def test_empty_transcript_returns_empty_string_without_llm_calls() -> None:
"""Пустой транскрипт → "" без единого обращения к LLM."""
fake_client = _FakeLlmClient()
plugin = _make_plugin(fake_client, count_tokens=_word_count)
result = plugin.summarize("")
assert result == ""
assert fake_client.prompts == []
def test_single_chunk_returns_map_result_without_reduce_call() -> None:
"""Транскрипт, помещающийся в один чанк, → результат map без reduce-вызова."""
fake_client = _FakeLlmClient(responses=["итоговое резюме одного чанка"])
plugin = _make_plugin(fake_client, count_tokens=_word_count, chunk_minutes=20)
transcript = "[Аня 00:00] привет всем\n[Боря 00:03] привет как дела"
result = plugin.summarize(transcript)
assert result == "итоговое резюме одного чанка"
assert len(fake_client.prompts) == 1
assert "привет всем" in fake_client.prompts[0]
assert "привет как дела" in fake_client.prompts[0]
# промпт собран из утверждённого map-шаблона (проверяем характерный заголовок)
assert "## Ключевые тезисы" in fake_client.prompts[0]
def test_multiple_chunks_are_mapped_then_reduced_once() -> None:
"""Несколько чанков → map каждого чанка, затем один reduce-вызов над частичными резюме."""
fake_client = _FakeLlmClient(
responses=["частичное резюме A", "частичное резюме B", "итоговое резюме"]
)
plugin = _make_plugin(fake_client, count_tokens=_word_count, chunk_minutes=1)
lines = [f"[Аня {minute:02d}:00] " + " ".join(["слово"] * 5) for minute in range(0, 4, 2)]
# Раздвигаем фразы на разные "чанки" по времени (chunk_minutes=1 закрывает
# чанк уже на второй фразе, т.к. между ними больше минуты).
transcript = "\n".join(lines)
result = plugin.summarize(transcript)
assert result == "итоговое резюме"
assert len(fake_client.prompts) == 3
map_prompts, reduce_prompt = fake_client.prompts[:2], fake_client.prompts[2]
assert all("## Ключевые тезисы" in p for p in map_prompts)
assert "частичное резюме A" in reduce_prompt
assert "частичное резюме B" in reduce_prompt
assert "Частичные резюме:" in reduce_prompt
def test_hierarchical_reduce_when_partial_summaries_exceed_token_budget() -> None:
"""Много крупных частичных резюме не влезают в бюджет одного reduce → иерархический reduce."""
# 10 чанков ⇒ 10 map-вызовов; каждое частичное резюме — 1000 "токенов"
# (по фейковому счётчику слов), бюджет reduce — 6000 → одной группой не
# свести, нужна как минимум одна промежуточная стадия.
big_word = "слово " * 1000
class _BigFakeLlmClient(_FakeLlmClient):
def complete(self, prompt: str, *, max_tokens: int | None = None) -> str:
self.prompts.append(prompt)
self.max_tokens_seen.append(max_tokens)
call_no = len(self.prompts)
if "Транскрипт:" in prompt:
return big_word.strip()
return f"промежуточное резюме {call_no}"
fake_client = _BigFakeLlmClient()
plugin = _make_plugin(fake_client, count_tokens=_word_count, chunk_minutes=1)
lines = [f"[Аня {minute:02d}:00] реплика номер {minute}" for minute in range(0, 40, 2)]
transcript = "\n".join(lines)
result = plugin.summarize(transcript)
map_calls = [p for p in fake_client.prompts if "Транскрипт:" in p]
reduce_calls = [p for p in fake_client.prompts if "Частичные резюме:" in p]
assert len(map_calls) == 20 # по фразе на чанк (chunk_minutes=1, реплики каждые 2 мин)
assert len(reduce_calls) >= 2 # хотя бы одна промежуточная группа + финальный reduce
assert result.startswith("промежуточное резюме")
def test_map_prompt_uses_approved_template_placeholder() -> None:
"""Плейсхолдер `{transcript_chunk}` подставляется через .replace, не .format."""
fake_client = _FakeLlmClient(responses=["резюме"])
plugin = _make_plugin(fake_client, count_tokens=_word_count)
transcript = "[Аня 00:00] текст с фигурными скобками {как тут} и всё равно ок"
result = plugin.summarize(transcript)
assert result == "резюме"
assert "текст с фигурными скобками {как тут} и всё равно ок" in fake_client.prompts[0]
def test_map_and_reduce_use_different_max_tokens() -> None:
"""map и reduce вызываются с разными `max_tokens` (раздельные per-tier лимиты, ADR-004)."""
fake_client = _FakeLlmClient(
responses=["частичное резюме A", "частичное резюме B", "итоговое резюме"]
)
plugin = _make_plugin(
fake_client,
count_tokens=_word_count,
chunk_minutes=1,
max_tokens_map=1024,
max_tokens_reduce=2048,
)
lines = [f"[Аня {minute:02d}:00] " + " ".join(["слово"] * 5) for minute in range(0, 4, 2)]
transcript = "\n".join(lines)
plugin.summarize(transcript)
assert fake_client.max_tokens_seen == [1024, 1024, 2048]
def test_max_tokens_map_and_reduce_default_to_max_tokens_when_not_set() -> None:
"""Без явных `max_tokens_map`/`max_tokens_reduce` оба используют общий `max_tokens`
(обратная совместимость с конфигурацией)."""
fake_client = _FakeLlmClient(responses=["резюме"])
plugin = _make_plugin(fake_client, count_tokens=_word_count, max_tokens=777)
plugin.summarize("[Аня 00:00] короткая фраза")
assert fake_client.max_tokens_seen == [777]
assert plugin.max_tokens_map == 777
assert plugin.max_tokens_reduce == 777
def test_summarize_closes_llm_client_after_use() -> None:
"""Плагин закрывает HTTP-клиент LLM по завершении `summarize` (освобождение пула соединений)."""
fake_client = _FakeLlmClient(responses=["резюме"])
plugin = _make_plugin(fake_client, count_tokens=_word_count)
plugin.summarize("[Аня 00:00] короткая фраза")
assert fake_client.closed is True
assert plugin._client is None
def test_reduce_forces_pairwise_progress_when_summaries_exceed_budget_individually() -> None:
"""Если каждое частичное резюме само превышает бюджет reduce (например, из-за
неудачно большого `max_tokens` в конфиге), группировка по бюджету не сокращает
список — plugin обязан гарантировать прогресс (принудительное попарное
объединение), а не зависать в бесконечном цикле."""
class _HugeFakeLlmClient(_FakeLlmClient):
def complete(self, prompt: str, *, max_tokens: int | None = None) -> str:
self.prompts.append(prompt)
self.max_tokens_seen.append(max_tokens)
call_no = len(self.prompts)
# Защита от регресса: если бы `_reduce` зациклился без прогресса,
# число вызовов росло бы неограниченно — превращаем зависание
# в быстрый явный отказ теста вместо вечного цикла.
if call_no > 50:
raise AssertionError("нет прогресса в _reduce — похоже на бесконечный цикл")
if "Транскрипт:" in prompt:
return f"огромное резюме {call_no}"
return f"свод {call_no}"
fake_client = _HugeFakeLlmClient()
# Фейковый счётчик токенов всегда возвращает значение >= бюджета reduce —
# ни одно частичное резюме "не помещается", группировка по бюджету
# вырождается в синглтоны на каждом уровне.
plugin = _make_plugin(fake_client, count_tokens=lambda _text: 7000, chunk_minutes=1)
lines = [f"[Аня {minute:02d}:00] реплика {minute}" for minute in range(0, 16, 2)]
transcript = "\n".join(lines)
result = plugin.summarize(transcript)
assert result.startswith("свод")
reduce_calls = [p for p in fake_client.prompts if "Частичные резюме:" in p]
assert len(reduce_calls) >= 1
def test_prompt_templates_are_loaded_lazily_and_cached(monkeypatch: pytest.MonkeyPatch) -> None:
"""Файл промпта читается с диска один раз (лениво) и переиспользуется при повторных вызовах."""
read_calls = {"count": 0}
original_read_text = Path.read_text
def counting_read_text(self: Path, *args: Any, **kwargs: Any) -> str:
read_calls["count"] += 1
result: str = original_read_text(self, *args, **kwargs)
return result
monkeypatch.setattr(Path, "read_text", counting_read_text)
plugin = QwenLocal(prompts_dir=str(PROMPTS_DIR))
plugin._map_prompt_template()
plugin._map_prompt_template()
plugin._reduce_prompt_template()
plugin._reduce_prompt_template()
assert read_calls["count"] == 2 # по одному чтению на файл: map и reduce

View File

@@ -0,0 +1,86 @@
"""Тесты RBAC-зависимостей `api/deps.py`: user / guest / admin.
Зависимости вызываются напрямую (минуя HTTP), т.к. отдельного
admin-only эндпоинта нет; `get_current_user` дополнительно
покрыт HTTP-тестом в `test_auth.py` (закрытый эндпоинт без JWT → 401).
"""
import uuid
import pytest
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from api.deps import get_current_user, get_current_user_optional, require_admin
from core.security import create_access_token, hash_password
from models.user import User
async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
user = User(
email=f"{uuid.uuid4()}@example.com",
name_user="Test User",
password_hash=hash_password("password123"),
role=role,
email_verified=True,
)
session.add(user)
await session.flush()
return user
async def test_get_current_user_optional_returns_none_without_token(
db_session: AsyncSession,
) -> None:
"""Отсутствие токена трактуется как guest (`None`), без ошибки."""
assert await get_current_user_optional(None, db_session) is None
async def test_get_current_user_raises_401_without_token(db_session: AsyncSession) -> None:
with pytest.raises(HTTPException) as exc_info:
await get_current_user(None, db_session)
assert exc_info.value.status_code == 401
async def test_get_current_user_rejects_garbage_token(db_session: AsyncSession) -> None:
with pytest.raises(HTTPException) as exc_info:
await get_current_user("not-a-jwt", db_session)
assert exc_info.value.status_code == 401
async def test_get_current_user_resolves_valid_access_token(db_session: AsyncSession) -> None:
user = await _make_user(db_session)
token = create_access_token(user.id, user.role)
resolved = await get_current_user(token, db_session)
assert resolved.id == user.id
async def test_get_current_user_optional_resolves_valid_access_token(
db_session: AsyncSession,
) -> None:
user = await _make_user(db_session)
token = create_access_token(user.id, user.role)
resolved = await get_current_user_optional(token, db_session)
assert resolved is not None
assert resolved.id == user.id
async def test_require_admin_passes_for_admin(db_session: AsyncSession) -> None:
admin = await _make_user(db_session, role="admin")
resolved = await require_admin(admin)
assert resolved.id == admin.id
async def test_require_admin_forbidden_for_regular_user(db_session: AsyncSession) -> None:
user = await _make_user(db_session, role="user")
with pytest.raises(HTTPException) as exc_info:
await require_admin(user)
assert exc_info.value.status_code == 403

View File

@@ -0,0 +1,296 @@
"""Тесты recurrence-ядра (ADR-001, п.3): `RecurrenceRule` и `expand_occurrences`.
Пишутся ДО реализации `services/recurrence.py` (TDD). Покрывают все 4 типа
повторения, клэмп 31-го числа, переход года, пустой диапазон, включительность
границ диапазона и корректность конвертации локального времени в UTC.
"""
from datetime import UTC, datetime, timedelta
import pytest
from pydantic import ValidationError
from services.recurrence import RecurrenceRule, expand_occurrences
def _rule(**overrides: object) -> RecurrenceRule:
"""Собрать валидное правило с разумными умолчаниями, переопределяя нужные поля."""
defaults: dict[str, object] = {
"type": "weekly",
"weekdays": [0],
"anchor_date": datetime(2026, 1, 5).date(), # понедельник
"time_local": "10:00",
"timezone": "UTC",
"duration_minutes": 60,
}
defaults.update(overrides)
return RecurrenceRule.model_validate(defaults)
class TestRecurrenceRuleValidation:
"""Валидация Pydantic-модели правила."""
def test_weekly_requires_weekdays(self) -> None:
with pytest.raises(ValidationError):
_rule(type="weekly", weekdays=[])
def test_monthly_requires_day_of_month(self) -> None:
with pytest.raises(ValidationError):
_rule(type="monthly", day_of_month=None)
def test_every_n_days_requires_interval_days(self) -> None:
with pytest.raises(ValidationError):
_rule(type="every_n_days", interval_days=None)
def test_weekday_out_of_range_rejected(self) -> None:
with pytest.raises(ValidationError):
_rule(weekdays=[7])
def test_day_of_month_out_of_range_rejected(self) -> None:
with pytest.raises(ValidationError):
_rule(type="monthly", day_of_month=32)
def test_interval_days_must_be_positive(self) -> None:
with pytest.raises(ValidationError):
_rule(type="every_n_days", interval_days=0)
def test_invalid_time_format_rejected(self) -> None:
with pytest.raises(ValidationError):
_rule(time_local="25:99")
def test_invalid_timezone_rejected(self) -> None:
with pytest.raises(ValidationError):
_rule(timezone="Not/AZone")
class TestExpandOccurrencesWeekly:
"""type='weekly': вхождение на каждой указанной неделе для всех weekdays."""
def test_multiple_weekdays_in_one_week(self) -> None:
# Пн(0)/Ср(2)/Пт(4), 10:00 UTC, неделя с 2026-01-05 (пн) по 2026-01-11 (вс)
rule = _rule(type="weekly", weekdays=[0, 2, 4])
occurrences = expand_occurrences(
rule,
datetime(2026, 1, 5, tzinfo=UTC),
datetime(2026, 1, 11, 23, 59, tzinfo=UTC),
)
assert occurrences == [
datetime(2026, 1, 5, 10, 0, tzinfo=UTC),
datetime(2026, 1, 7, 10, 0, tzinfo=UTC),
datetime(2026, 1, 9, 10, 0, tzinfo=UTC),
]
def test_repeats_every_week(self) -> None:
rule = _rule(type="weekly", weekdays=[0])
occurrences = expand_occurrences(
rule,
datetime(2026, 1, 1, tzinfo=UTC),
datetime(2026, 1, 31, tzinfo=UTC),
)
# Понедельники января 2026: 5, 12, 19, 26
assert [d.day for d in occurrences] == [5, 12, 19, 26]
assert all(d.hour == 10 for d in occurrences)
class TestExpandOccurrencesBiweekly:
"""type='biweekly': чётность недель считается от `anchor_date`."""
def test_every_other_week_from_anchor(self) -> None:
# anchor 2026-01-05 (пн) — неделя-0 включена, неделя-1 (12-е) исключена,
# неделя-2 (19-е) включена и т.д.
rule = _rule(type="biweekly", weekdays=[0], anchor_date=datetime(2026, 1, 5).date())
occurrences = expand_occurrences(
rule,
datetime(2026, 1, 1, tzinfo=UTC),
datetime(2026, 2, 28, tzinfo=UTC),
)
assert [d.day for d in occurrences[:3]] == [5, 19, 2] # 2 февраля
def test_anchor_not_matching_weekday_still_defines_parity(self) -> None:
# anchor в среду той же недели, что и понедельник 2026-01-05 —
# чётность определяется по началу недели anchor, а не по его weekday.
rule = _rule(type="biweekly", weekdays=[0], anchor_date=datetime(2026, 1, 7).date())
occurrences = expand_occurrences(
rule,
datetime(2026, 1, 1, tzinfo=UTC),
datetime(2026, 1, 31, tzinfo=UTC),
)
assert [d.day for d in occurrences] == [5, 19]
class TestExpandOccurrencesMonthly:
"""type='monthly': day_of_month=31 клэмпится к последнему дню короткого месяца."""
def test_31_clamped_in_short_months(self) -> None:
rule = _rule(type="monthly", weekdays=[], day_of_month=31)
occurrences = expand_occurrences(
rule,
datetime(2026, 1, 1, tzinfo=UTC),
datetime(2026, 4, 30, 23, 59, tzinfo=UTC),
)
# 2026 — невисокосный год: январь(31), февраль(28), март(31), апрель(30)
assert [(d.month, d.day) for d in occurrences] == [
(1, 31),
(2, 28),
(3, 31),
(4, 30),
]
def test_31_clamped_to_29_in_leap_february(self) -> None:
rule = _rule(type="monthly", weekdays=[], day_of_month=31)
occurrences = expand_occurrences(
rule,
datetime(2024, 2, 1, tzinfo=UTC),
datetime(2024, 2, 29, 23, 59, tzinfo=UTC),
)
assert [(d.month, d.day) for d in occurrences] == [(2, 29)]
def test_regular_day_of_month(self) -> None:
rule = _rule(type="monthly", weekdays=[], day_of_month=15)
occurrences = expand_occurrences(
rule,
datetime(2026, 1, 1, tzinfo=UTC),
datetime(2026, 3, 31, tzinfo=UTC),
)
assert [d.day for d in occurrences] == [15, 15, 15]
class TestExpandOccurrencesEveryNDays:
"""type='every_n_days': шаг фиксированного числа дней от `anchor_date`."""
def test_step_of_five_days(self) -> None:
rule = _rule(
type="every_n_days",
weekdays=[],
interval_days=5,
anchor_date=datetime(2026, 1, 1).date(),
)
occurrences = expand_occurrences(
rule,
datetime(2026, 1, 1, tzinfo=UTC),
datetime(2026, 1, 20, tzinfo=UTC),
)
assert [d.day for d in occurrences] == [1, 6, 11, 16]
def test_before_anchor_no_occurrences(self) -> None:
rule = _rule(
type="every_n_days",
weekdays=[],
interval_days=3,
anchor_date=datetime(2026, 1, 10).date(),
)
occurrences = expand_occurrences(
rule,
datetime(2026, 1, 1, tzinfo=UTC),
datetime(2026, 1, 9, 23, 59, tzinfo=UTC),
)
assert occurrences == []
class TestYearTransition:
"""Развёртка не должна ломаться на переходе декабрь -> январь."""
def test_weekly_across_year_boundary(self) -> None:
rule = _rule(type="weekly", weekdays=[3]) # четверг
occurrences = expand_occurrences(
rule,
datetime(2025, 12, 25, tzinfo=UTC),
datetime(2026, 1, 8, 23, 59, tzinfo=UTC),
)
assert [(d.year, d.month, d.day) for d in occurrences] == [
(2025, 12, 25),
(2026, 1, 1),
(2026, 1, 8),
]
def test_every_n_days_across_year_boundary(self) -> None:
rule = _rule(
type="every_n_days",
weekdays=[],
interval_days=10,
anchor_date=datetime(2025, 12, 20).date(),
)
occurrences = expand_occurrences(
rule,
datetime(2025, 12, 20, tzinfo=UTC),
datetime(2026, 1, 10, tzinfo=UTC),
)
assert [(d.year, d.month, d.day) for d in occurrences] == [
(2025, 12, 20),
(2025, 12, 30),
(2026, 1, 9),
]
class TestRangeEdgeCases:
"""Пустой диапазон и включительность границ."""
def test_empty_range_when_from_after_to(self) -> None:
rule = _rule(type="weekly", weekdays=[0, 1, 2, 3, 4, 5, 6])
occurrences = expand_occurrences(
rule,
datetime(2026, 1, 10, tzinfo=UTC),
datetime(2026, 1, 1, tzinfo=UTC),
)
assert occurrences == []
def test_boundaries_are_inclusive(self) -> None:
# Вхождение ровно в момент t_from и ровно в момент t_to должно попасть
# в результат (обе границы включительны).
rule = _rule(type="weekly", weekdays=[0], time_local="10:00", timezone="UTC")
t_from = datetime(2026, 1, 5, 10, 0, tzinfo=UTC)
t_to = datetime(2026, 1, 12, 10, 0, tzinfo=UTC)
occurrences = expand_occurrences(rule, t_from, t_to)
assert occurrences[0] == t_from
assert occurrences[-1] == t_to
def test_one_minute_outside_boundaries_excluded(self) -> None:
rule = _rule(type="weekly", weekdays=[0], time_local="10:00", timezone="UTC")
t_from = datetime(2026, 1, 5, 10, 1, tzinfo=UTC)
t_to = datetime(2026, 1, 12, 9, 59, tzinfo=UTC)
occurrences = expand_occurrences(rule, t_from, t_to)
assert occurrences == []
class TestTimezoneOffset:
"""Правило хранит локальное время + IANA tz; развёртка возвращает aware UTC."""
def test_local_time_converted_to_utc(self) -> None:
# Europe/Moscow — постоянное смещение UTC+3 без перехода на летнее время.
rule = _rule(
type="weekly",
weekdays=[0],
time_local="10:00",
timezone="Europe/Moscow",
)
occurrences = expand_occurrences(
rule,
datetime(2026, 1, 5, tzinfo=UTC),
datetime(2026, 1, 5, 23, 59, tzinfo=UTC),
)
assert occurrences == [datetime(2026, 1, 5, 7, 0, tzinfo=UTC)]
def test_negative_offset_timezone(self) -> None:
# America/New_York в начале января — зимнее время, UTC-5.
rule = _rule(
type="weekly",
weekdays=[0],
time_local="09:00",
timezone="America/New_York",
)
occurrences = expand_occurrences(
rule,
datetime(2026, 1, 5, tzinfo=UTC),
datetime(2026, 1, 5, 23, 59, tzinfo=UTC),
)
assert occurrences == [datetime(2026, 1, 5, 14, 0, tzinfo=UTC)]
def test_result_timestamps_are_utc_aware(self) -> None:
rule = _rule(type="weekly", weekdays=[0], timezone="Europe/Moscow")
occurrences = expand_occurrences(
rule,
datetime(2026, 1, 1, tzinfo=UTC),
datetime(2026, 1, 31, tzinfo=UTC),
)
assert occurrences
assert all(d.tzinfo is not None and d.utcoffset() == timedelta(0) for d in occurrences)

View File

@@ -0,0 +1,170 @@
"""Тесты SMTP-бэкенда email (`services.email`).
Маппинг ошибок aiosmtplib в
`EmailSendError(retryable=...)`, вложение `.ics` в MIME, console-бэкенд по
умолчанию. `_env_file=None` в конструкторе `Settings` — тесты не должны
зависеть от содержимого реального `backend/.env` (изолированный конфиг).
"""
import logging
import aiosmtplib
import pytest
from core.config import Settings
from services.email import (
ConsoleEmailBackend,
EmailAttachment,
EmailSendError,
SmtpEmailBackend,
_build_message,
create_email_backend,
)
def test_create_email_backend_defaults_to_console() -> None:
backend = create_email_backend(Settings(_env_file=None))
assert isinstance(backend, ConsoleEmailBackend)
def test_create_email_backend_smtp() -> None:
backend = create_email_backend(
Settings(
_env_file=None,
email_backend="smtp",
smtp_host="smtp.example.com",
smtp_port=2525,
)
)
assert isinstance(backend, SmtpEmailBackend)
async def test_console_backend_logs_message(caplog: pytest.LogCaptureFixture) -> None:
backend = ConsoleEmailBackend()
with caplog.at_level(logging.INFO):
await backend.send(
to="user@example.com",
subject="Тестовая тема",
body="Тестовое тело письма",
attachments=[
EmailAttachment(filename="invite.ics", content=b"BEGIN", mime_type="text/calendar")
],
)
assert "user@example.com" in caplog.text
assert "invite.ics" in caplog.text
def test_build_message_includes_html_alternative_and_ics_attachment() -> None:
message = _build_message(
sender="VidConf <no-reply@vidconf.example>",
to="user@example.com",
subject="Саммари встречи",
body="Текстовая версия",
html_body="<p>HTML-версия</p>",
attachments=[
EmailAttachment(
filename="invite.ics",
content=b"BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n",
mime_type="text/calendar; method=REQUEST",
)
],
)
assert message["Subject"] == "Саммари встречи"
plain_part = message.get_body(preferencelist=("plain",))
assert plain_part is not None
assert "Текстовая версия" in plain_part.get_content()
html_part = message.get_body(preferencelist=("html",))
assert html_part is not None
assert "HTML-версия" in html_part.get_content()
attachments = list(message.iter_attachments())
assert len(attachments) == 1
assert attachments[0].get_filename() == "invite.ics"
assert attachments[0].get_content_type() == "text/calendar"
assert attachments[0].get_payload(decode=True) == b"BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"
def _backend() -> SmtpEmailBackend:
return SmtpEmailBackend(
hostname="smtp.example.com",
port=587,
username=None,
password=None,
start_tls=True,
use_tls=False,
timeout=30,
sender="VidConf <no-reply@vidconf.example>",
)
@pytest.mark.parametrize(
"error",
[
aiosmtplib.SMTPConnectError("не удалось подключиться"),
aiosmtplib.SMTPServerDisconnected("сервер оборвал соединение"),
aiosmtplib.SMTPTimeoutError("таймаут"),
aiosmtplib.SMTPAuthenticationError(535, "неверные учётные данные"),
],
)
async def test_smtp_backend_maps_transport_errors_to_retryable(
monkeypatch: pytest.MonkeyPatch, error: Exception
) -> None:
async def _raise(*args: object, **kwargs: object) -> None:
raise error
monkeypatch.setattr(aiosmtplib, "send", _raise)
backend = _backend()
with pytest.raises(EmailSendError) as excinfo:
await backend.send(to="user@example.com", subject="Тема", body="Тело")
assert excinfo.value.retryable is True
async def test_smtp_backend_maps_recipients_refused_to_non_retryable(
monkeypatch: pytest.MonkeyPatch,
) -> None:
refused = aiosmtplib.SMTPRecipientsRefused(
[aiosmtplib.SMTPRecipientRefused(550, "мусор", "user@example.com")]
)
async def _raise(*args: object, **kwargs: object) -> None:
raise refused
monkeypatch.setattr(aiosmtplib, "send", _raise)
backend = _backend()
with pytest.raises(EmailSendError) as excinfo:
await backend.send(to="user@example.com", subject="Тема", body="Тело")
assert excinfo.value.retryable is False
async def test_smtp_backend_send_success_calls_aiosmtplib(monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict[str, object] = {}
async def _fake_send(message: object, **kwargs: object) -> tuple[dict[str, object], str]:
captured["message"] = message
captured["kwargs"] = kwargs
return {}, "OK"
monkeypatch.setattr(aiosmtplib, "send", _fake_send)
backend = _backend()
await backend.send(to="user@example.com", subject="Тема", body="Тело")
assert captured["kwargs"] == {
"hostname": "smtp.example.com",
"port": 587,
"username": None,
"password": None,
"start_tls": True,
"use_tls": False,
"timeout": 30,
}

View File

@@ -0,0 +1,518 @@
"""Тесты Celery-задачи суммаризации (`workers.tasks.summarize.summarize_session_async`).
Как и `test_pipeline.py`, не использует savepoint-фикстуру `db_session`:
`summarize_session_async` открывает собственную сессию с отдельным engine
(`workers/db.py::open_session`), которая не видит незакоммиченные изменения
другой сессии. Тестовые данные заводятся и коммитятся напрямую через
`core.db.engine`, LLM — фейковый `Summarizer` за контрактом `create_summarizer`
(без реального `QwenLocal`/HTTP).
"""
import uuid
from collections.abc import AsyncGenerator
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import cast
from unittest.mock import MagicMock
import pytest
from celery.exceptions import MaxRetriesExceededError
from sqlalchemy import text
from core.db import engine
from core.plugins.config import ChatConfig, InstanceConfig, SummarizerConfig, TranscriberConfig
from core.plugins.transcriber import Segment
from core.summarization.llm_client import LlmUnavailableError
from services.conference_ids import generate_number, generate_slug
from workers.tasks import pipeline as pipeline_module
from workers.tasks import summarize as summarize_module
from workers.tasks.pipeline import run_pipeline_async
from workers.tasks.summarize import summarize_session_async
NOW = datetime.now(UTC)
class _Request:
"""Заглушка `celery.Task.request`: нужен только счётчик уже сделанных попыток."""
def __init__(self, retries: int = 0) -> None:
self.retries = retries
class _FakeTask:
"""Заглушка bound-задачи Celery: фиксирует вызовы `retry`, не бросает исключение."""
def __init__(self, retries: int = 0) -> None:
self.request = _Request(retries)
self.retry = MagicMock()
class _ExhaustedRetryTask:
"""Заглушка bound-задачи: `retry` всегда бросает `MaxRetriesExceededError`."""
def __init__(self, retries: int = 0) -> None:
self.request = _Request(retries)
self.retry_calls = 0
def retry(self, countdown: int | None = None) -> None:
self.retry_calls += 1
raise MaxRetriesExceededError("исчерпаны попытки суммаризации")
class _FakeSummarizer:
"""Фейковый `Summarizer`: запоминает транскрипт, опционально падает N раз подряд."""
provider = "fake"
def __init__(self, result: str = "Итоговое саммари", fail_times: int = 0) -> None:
self.result = result
self._fail_times = fail_times
self.calls = 0
self.transcript: str | None = None
def summarize(self, transcript: str) -> str:
self.calls += 1
self.transcript = transcript
if self._fail_times > 0:
self._fail_times -= 1
raise LlmUnavailableError("LLM недоступен (тест)")
return self.result
class _Fixture:
"""Id тестовой конференции/сеанса/участников с уже вставленными фразами."""
def __init__(self) -> None:
self.conference_id = uuid.uuid4()
self.session_id = uuid.uuid4()
self.user_id = uuid.uuid4()
self.guest_id = uuid.uuid4()
self.user_participant_id = uuid.uuid4()
self.guest_participant_id = uuid.uuid4()
self.number = generate_number()
self.slug = generate_slug()
self.t_start = NOW - timedelta(minutes=30)
self.t_end = NOW
@pytest.fixture
async def fx() -> AsyncGenerator[_Fixture, None]:
f = _Fixture()
async with engine.connect() as conn:
await conn.execute(
text(
"INSERT INTO users (id, email, name_user, password_hash) "
"VALUES (:id, :email, 'Иван Тестов', 'x')"
),
{"id": f.user_id, "email": f"summarize-{f.user_id}@example.com"},
)
await conn.execute(
text(
"INSERT INTO conferences (id, number, slug, title, status, is_pinned) "
"VALUES (:id, :number, :slug, 'Summarize test', 'ended', false)"
),
{"id": f.conference_id, "number": f.number, "slug": f.slug},
)
await conn.execute(
text(
"INSERT INTO guest_access (id, conference_id, display_name) "
"VALUES (:id, :conference_id, 'Гость Тестов')"
),
{"id": f.guest_id, "conference_id": f.conference_id},
)
await conn.execute(
text(
"INSERT INTO conference_sessions "
"(id, conference_id, title, t_start, t_end, pipeline_status) "
"VALUES (:id, :conference_id, 'Summarize session', :t_start, :t_end, "
"'summarizing')"
),
{
"id": f.session_id,
"conference_id": f.conference_id,
"t_start": f.t_start,
"t_end": f.t_end,
},
)
await conn.execute(
text(
"INSERT INTO conference_participants (id, session_id, user_id, joined_at, left_at) "
"VALUES (:id, :session_id, :user_id, :joined_at, :left_at)"
),
{
"id": f.user_participant_id,
"session_id": f.session_id,
"user_id": f.user_id,
"joined_at": f.t_start,
"left_at": f.t_end,
},
)
await conn.execute(
text(
"INSERT INTO conference_participants "
"(id, session_id, guest_id, joined_at, left_at) "
"VALUES (:id, :session_id, :guest_id, :joined_at, :left_at)"
),
{
"id": f.guest_participant_id,
"session_id": f.session_id,
"guest_id": f.guest_id,
"joined_at": f.t_start,
"left_at": f.t_end,
},
)
await conn.execute(
text(
"INSERT INTO phrases (participant_id, session_id, data, t_start, t_end) "
"VALUES (:participant_id, :session_id, 'Обсудили бюджет', :t_start, :t_end)"
),
{
"participant_id": f.user_participant_id,
"session_id": f.session_id,
"t_start": f.t_start + timedelta(seconds=5),
"t_end": f.t_start + timedelta(seconds=7),
},
)
await conn.execute(
text(
"INSERT INTO phrases (participant_id, session_id, data, t_start, t_end) "
"VALUES (:participant_id, :session_id, 'Утвердили сроки', :t_start, :t_end)"
),
{
"participant_id": f.guest_participant_id,
"session_id": f.session_id,
"t_start": f.t_start + timedelta(seconds=10),
"t_end": f.t_start + timedelta(seconds=12),
},
)
await conn.commit()
yield f
async with engine.connect() as conn:
await conn.execute(text("DELETE FROM phrases WHERE session_id = :id"), {"id": f.session_id})
await conn.execute(
text("DELETE FROM conference_participants WHERE session_id = :id"), {"id": f.session_id}
)
await conn.execute(
text("DELETE FROM conference_sessions WHERE conference_id = :id"),
{"id": f.conference_id},
)
await conn.execute(
text("DELETE FROM guest_access WHERE conference_id = :id"), {"id": f.conference_id}
)
await conn.execute(text("DELETE FROM conferences WHERE id = :id"), {"id": f.conference_id})
await conn.execute(text("DELETE FROM users WHERE id = :id"), {"id": f.user_id})
await conn.commit()
def _cfg(*, enabled: bool = True) -> InstanceConfig:
"""Конфиг для тестов: реальный `Summarizer` не создаётся — провайдер
подменяется через monkeypatch `summarize_module.create_summarizer`."""
return InstanceConfig(
transcriber=TranscriberConfig(),
summarizer=SummarizerConfig(enabled=enabled, provider="fake"),
chat=ChatConfig(),
)
async def _fetch_session(session_id: uuid.UUID) -> tuple[str, str | None]:
async with engine.connect() as conn:
result = await conn.execute(
text("SELECT pipeline_status, summary_data FROM conference_sessions WHERE id = :id"),
{"id": session_id},
)
row = result.one()
return cast("str", row[0]), cast("str | None", row[1])
async def test_summarize_session_fills_summary_data_and_keeps_summarizing(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""(a) phrases → summary_data заполнен, статус — 'summarizing', 'notified' не ставится
(его ставит отдельная задача `notify_session`, см. её постановку ниже)."""
fake = _FakeSummarizer(result="Итоговое саммари встречи")
monkeypatch.setattr(summarize_module, "create_summarizer", lambda cfg: fake)
mock_send_task = MagicMock()
monkeypatch.setattr(summarize_module.app, "send_task", mock_send_task)
await summarize_session_async(_FakeTask(), fx.session_id, plugins_config=_cfg())
status, summary = await _fetch_session(fx.session_id)
assert status == "summarizing"
assert summary == "Итоговое саммари встречи"
assert fake.calls == 1
mock_send_task.assert_called_once_with(
"workers.tasks.notify.notify_session", args=[str(fx.session_id)]
)
async def test_summarize_session_noop_when_summary_already_present(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""(b) повторный вызов при уже заполненном summary_data — no-op."""
monkeypatch.setattr(summarize_module.app, "send_task", MagicMock())
first = _FakeSummarizer(result="Первое саммари")
monkeypatch.setattr(summarize_module, "create_summarizer", lambda cfg: first)
await summarize_session_async(_FakeTask(), fx.session_id, plugins_config=_cfg())
second = _FakeSummarizer(result="Второе саммари (не должно попасть в БД)")
monkeypatch.setattr(summarize_module, "create_summarizer", lambda cfg: second)
await summarize_session_async(_FakeTask(), fx.session_id, plugins_config=_cfg())
status, summary = await _fetch_session(fx.session_id)
assert status == "summarizing"
assert summary == "Первое саммари"
assert second.calls == 0
async def test_summarize_session_noop_when_disabled(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""(c) summarizer.enabled=false → выход, summary_data NULL, статус не тронут."""
mock_factory = MagicMock()
monkeypatch.setattr(summarize_module, "create_summarizer", mock_factory)
await summarize_session_async(_FakeTask(), fx.session_id, plugins_config=_cfg(enabled=False))
mock_factory.assert_not_called()
status, summary = await _fetch_session(fx.session_id)
assert status == "summarizing"
assert summary is None
async def test_summarize_session_retries_on_llm_unavailable_then_succeeds(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""(d) LlmUnavailableError на первом вызове → retry без потери шага →
успех со второй попытки."""
monkeypatch.setattr(summarize_module.app, "send_task", MagicMock())
fake = _FakeSummarizer(result="Саммари после повтора", fail_times=1)
monkeypatch.setattr(summarize_module, "create_summarizer", lambda cfg: fake)
first_task = _FakeTask(retries=0)
await summarize_session_async(first_task, fx.session_id, plugins_config=_cfg())
first_task.retry.assert_called_once_with(countdown=60)
status, summary = await _fetch_session(fx.session_id)
assert status == "summarizing"
assert summary is None
assert fake.calls == 1
# Повторная доставка задачи celery-ретраем (acks_late) — шаг не потерян.
second_task = _FakeTask(retries=1)
await summarize_session_async(second_task, fx.session_id, plugins_config=_cfg())
second_task.retry.assert_not_called()
status, summary = await _fetch_session(fx.session_id)
assert status == "summarizing"
assert summary == "Саммари после повтора"
assert fake.calls == 2
async def test_summarize_session_marks_failed_when_retries_exhausted(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""(e) исчерпание retry → pipeline_status='failed'."""
fake = _FakeSummarizer(result="не важно", fail_times=999)
monkeypatch.setattr(summarize_module, "create_summarizer", lambda cfg: fake)
task = _ExhaustedRetryTask(retries=5)
await summarize_session_async(task, fx.session_id, plugins_config=_cfg())
assert task.retry_calls == 1
status, summary = await _fetch_session(fx.session_id)
assert status == "failed"
assert summary is None
async def test_summarize_session_transcript_includes_guest_display_name(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""(f) имена гостей (guest_access.display_name) попадают в транскрипт."""
monkeypatch.setattr(summarize_module.app, "send_task", MagicMock())
fake = _FakeSummarizer()
monkeypatch.setattr(summarize_module, "create_summarizer", lambda cfg: fake)
await summarize_session_async(_FakeTask(), fx.session_id, plugins_config=_cfg())
assert fake.transcript is not None
assert "Гость Тестов" in fake.transcript
assert "Иван Тестов" in fake.transcript
async def test_summarize_session_noop_when_no_phrases(monkeypatch: pytest.MonkeyPatch) -> None:
"""Guard №5: у сеанса нет фраз → warning-выход, summary_data остаётся NULL."""
f = _Fixture()
async with engine.connect() as conn:
await conn.execute(
text(
"INSERT INTO conferences (id, number, slug, title, status, is_pinned) "
"VALUES (:id, :number, :slug, 'No phrases', 'ended', false)"
),
{"id": f.conference_id, "number": f.number, "slug": f.slug},
)
await conn.execute(
text(
"INSERT INTO conference_sessions "
"(id, conference_id, title, t_start, t_end, pipeline_status) "
"VALUES (:id, :conference_id, 'No phrases session', :t_start, :t_end, "
"'summarizing')"
),
{
"id": f.session_id,
"conference_id": f.conference_id,
"t_start": f.t_start,
"t_end": f.t_end,
},
)
await conn.commit()
mock_factory = MagicMock()
monkeypatch.setattr(summarize_module, "create_summarizer", mock_factory)
try:
await summarize_session_async(_FakeTask(), f.session_id, plugins_config=_cfg())
mock_factory.assert_not_called()
status, summary = await _fetch_session(f.session_id)
assert status == "summarizing"
assert summary is None
finally:
async with engine.connect() as conn:
await conn.execute(
text("DELETE FROM conference_sessions WHERE conference_id = :id"),
{"id": f.conference_id},
)
await conn.execute(
text("DELETE FROM conferences WHERE id = :id"), {"id": f.conference_id}
)
await conn.commit()
class _FakeTranscriber:
"""Заглушка `Transcriber`: канонические сегменты по пути файла (см. `test_pipeline.py`)."""
def __init__(self, canned: dict[str, list[Segment]]) -> None:
self._canned = canned
self.calls: list[str] = []
def transcribe(self, audio_path: str, language: str = "ru") -> list[Segment]:
self.calls.append(audio_path)
return self._canned[audio_path]
def _touch(path: str) -> None:
"""Создать пустой файл трека (синхронно, вне тела async-теста — ASYNC240)."""
Path(path).write_bytes(b"")
async def test_run_pipeline_sends_summarize_task_after_summarizing(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""(g) run_pipeline отправляет `summarize_session` по имени после перехода
в 'summarizing'."""
f = _Fixture()
track_path = str(tmp_path / "track.ogg")
_touch(track_path)
async with engine.connect() as conn:
await conn.execute(
text(
"INSERT INTO users (id, email, name_user, password_hash) "
"VALUES (:id, :email, 'Иван Тестов', 'x')"
),
{"id": f.user_id, "email": f"pipeline-send-{f.user_id}@example.com"},
)
await conn.execute(
text(
"INSERT INTO conferences (id, number, slug, title, status, is_pinned) "
"VALUES (:id, :number, :slug, 'Send task test', 'ended', false)"
),
{"id": f.conference_id, "number": f.number, "slug": f.slug},
)
await conn.execute(
text(
"INSERT INTO conference_sessions (id, conference_id, title, t_start, t_end) "
"VALUES (:id, :conference_id, 'Send task session', :t_start, :t_end)"
),
{
"id": f.session_id,
"conference_id": f.conference_id,
"t_start": f.t_start,
"t_end": f.t_end,
},
)
await conn.execute(
text(
"INSERT INTO conference_participants (id, session_id, user_id, joined_at, left_at) "
"VALUES (:id, :session_id, :user_id, :joined_at, :left_at)"
),
{
"id": f.user_participant_id,
"session_id": f.session_id,
"user_id": f.user_id,
"joined_at": f.t_start,
"left_at": f.t_end,
},
)
await conn.execute(
text(
"INSERT INTO session_audio_tracks "
"(id, session_id, participant_id, track_sid, egress_id, "
"file_path, status, started_at) "
"VALUES (:id, :session_id, :participant_id, 'TR_USER', 'EG_TR_USER', "
":file_path, 'recorded', :started_at)"
),
{
"id": uuid.uuid4(),
"session_id": f.session_id,
"participant_id": f.user_participant_id,
"file_path": track_path,
"started_at": f.t_start + timedelta(seconds=5),
},
)
await conn.commit()
fake_transcriber = _FakeTranscriber(canned={track_path: [Segment(0.0, 2.0, "привет")]})
monkeypatch.setattr(pipeline_module, "create_transcriber", lambda cfg: fake_transcriber)
mock_send_task = MagicMock()
monkeypatch.setattr(pipeline_module.app, "send_task", mock_send_task)
try:
await run_pipeline_async(
_FakeTask(),
f.session_id,
plugins_config=InstanceConfig(
transcriber=TranscriberConfig(enabled=True, provider="fake", language="ru"),
summarizer=SummarizerConfig(),
chat=ChatConfig(),
),
)
mock_send_task.assert_called_once_with(
"workers.tasks.summarize.summarize_session", args=[str(f.session_id)]
)
status, _ = await _fetch_session(f.session_id)
assert status == "summarizing"
finally:
async with engine.connect() as conn:
await conn.execute(
text("DELETE FROM phrases WHERE session_id = :id"), {"id": f.session_id}
)
await conn.execute(
text("DELETE FROM session_audio_tracks WHERE session_id = :id"),
{"id": f.session_id},
)
await conn.execute(
text("DELETE FROM conference_participants WHERE session_id = :id"),
{"id": f.session_id},
)
await conn.execute(
text("DELETE FROM conference_sessions WHERE conference_id = :id"),
{"id": f.conference_id},
)
await conn.execute(
text("DELETE FROM conferences WHERE id = :id"), {"id": f.conference_id}
)
await conn.execute(text("DELETE FROM users WHERE id = :id"), {"id": f.user_id})
await conn.commit()

View File

@@ -0,0 +1,48 @@
"""Тесты чистой функции `build_transcript` (сборка строк-фраз для суммаризации)."""
from workers.summarizer.transcript import TranscriptLine, build_transcript
def test_empty_lines_produce_empty_transcript() -> None:
"""Пустой список фраз → пустая строка."""
assert build_transcript([]) == ""
def test_single_line_mm_ss_format_under_one_hour() -> None:
"""Смещение меньше часа форматируется как `MM:SS`."""
lines = [TranscriptLine(speaker="Аня", offset_s=65.0, text="привет всем")]
assert build_transcript(lines) == "[Аня 01:05] привет всем"
def test_offset_at_or_above_one_hour_uses_hh_mm_ss_format() -> None:
"""Смещение от часа и больше форматируется как `ЧЧ:MM:SS`."""
lines = [TranscriptLine(speaker="Боря", offset_s=3725.0, text="подведём итоги")]
assert build_transcript(lines) == "[Боря 1:02:05] подведём итоги"
def test_lines_are_sorted_by_offset_regardless_of_input_order() -> None:
"""Строки транскрипта сортируются по `offset_s`, даже если поданы вразнобой."""
lines = [
TranscriptLine(speaker="Боря", offset_s=10.0, text="второй"),
TranscriptLine(speaker="Аня", offset_s=0.0, text="первый"),
TranscriptLine(speaker="Боря", offset_s=20.0, text="третий"),
]
result = build_transcript(lines)
assert result == "\n".join(
[
"[Аня 00:00] первый",
"[Боря 00:10] второй",
"[Боря 00:20] третий",
]
)
def test_zero_offset_formats_as_zero_minutes_zero_seconds() -> None:
"""Нулевое смещение → `00:00`."""
lines = [TranscriptLine(speaker="Аня", offset_s=0.0, text="начало")]
assert build_transcript(lines) == "[Аня 00:00] начало"

View File

@@ -0,0 +1,92 @@
"""Тесты постановки задач backend-продюсерами `Celery.send_task`.
`pipeline_producer`/`invitations_producer` создают собственный (голый)
Celery-клиент без `task_routes` из `workers/celery_app.py` (backend не
импортирует пакет `workers`, см. докстрины модулей) — очередь этим клиентам
нужно передавать явным параметром `queue=` при каждой отправке, иначе задача
уйдёт в дефолтную очередь `celery`, а не в изолированную очередь семьи задач.
"""
import uuid
from unittest.mock import MagicMock
import pytest
from kombu.exceptions import OperationalError
from services import invitations_producer, pipeline_producer
@pytest.fixture(autouse=True)
def _fake_celery_client(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
"""Подменить `celery.Celery` фейком, чтобы не создавать реальное broker-подключение."""
instance = MagicMock()
factory = MagicMock(return_value=instance)
monkeypatch.setattr(pipeline_producer, "Celery", factory)
monkeypatch.setattr(invitations_producer, "Celery", factory)
return instance
def test_enqueue_pipeline_uses_transcription_queue(_fake_celery_client: MagicMock) -> None:
session_id = uuid.uuid4()
pipeline_producer.enqueue_pipeline(session_id)
_fake_celery_client.send_task.assert_called_once_with(
pipeline_producer.RUN_PIPELINE_TASK_NAME,
args=[str(session_id)],
queue="transcription",
)
def test_enqueue_invitations_uses_notify_queue(_fake_celery_client: MagicMock) -> None:
conference_id = uuid.uuid4()
invitations_producer.enqueue_invitations(conference_id, emails=["a@example.com"])
_fake_celery_client.send_task.assert_called_once_with(
invitations_producer.SEND_INVITATIONS_TASK_NAME,
args=[str(conference_id), ["a@example.com"]],
queue="notify",
)
def test_transcription_queue_served_true_when_worker_reports_queue(
_fake_celery_client: MagicMock,
) -> None:
"""`active_queues()` вернул хотя бы
одного воркера с очередью `transcription` среди прочих его очередей."""
_fake_celery_client.control.inspect.return_value.active_queues.return_value = {
"celery@worker1": [{"name": "notify"}, {"name": "transcription"}],
}
assert pipeline_producer.transcription_queue_served() is True
_fake_celery_client.control.inspect.assert_called_once_with(timeout=1.0)
def test_transcription_queue_served_false_when_no_worker_replies(
_fake_celery_client: MagicMock,
) -> None:
"""`active_queues()` вернул `None` (нет воркеров либо брокер недоступен) → `False`."""
_fake_celery_client.control.inspect.return_value.active_queues.return_value = None
assert pipeline_producer.transcription_queue_served() is False
def test_transcription_queue_served_false_when_worker_active_but_other_queue(
_fake_celery_client: MagicMock,
) -> None:
"""Воркер(ы) ответили, но никто не слушает именно `transcription` → `False`."""
_fake_celery_client.control.inspect.return_value.active_queues.return_value = {
"celery@worker1": [{"name": "notify"}],
}
assert pipeline_producer.transcription_queue_served() is False
def test_transcription_queue_served_false_when_broker_unavailable(
_fake_celery_client: MagicMock,
) -> None:
"""Недоступный брокер (Redis лежит) кидает `OperationalError`, а не
возвращает `None`, — по контракту («нет воркеров ИЛИ брокер недоступен → `False`»)
исключение не должно пробрасываться наружу (500 в `GET`/`PUT /admin/settings`)."""
_fake_celery_client.control.inspect.return_value.active_queues.side_effect = OperationalError(
"Error 61 connecting to localhost:6379. Connection refused."
)
assert pipeline_producer.transcription_queue_served() is False

View File

@@ -0,0 +1,56 @@
"""Интеграционные тесты `/api/v1/teams`: справочник команд для
выбора в профиле — доступен любому аутентифицированному пользователю (не гейтится
`registration_team_choice`, в отличие от `/auth/registration-options`; не требует
роли admin, в отличие от `/admin/teams`).
"""
import uuid
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from core.security import create_access_token, hash_password
from models.team import Team
from models.user import User
async def _make_user(session: AsyncSession) -> User:
user = User(
email=f"{uuid.uuid4()}@example.com",
name_user="Teams API Tester",
password_hash=hash_password("password123"),
email_verified=True,
)
session.add(user)
await session.flush()
return user
async def _make_team(session: AsyncSession) -> Team:
team = Team(name=f"Team {uuid.uuid4()}")
session.add(team)
await session.flush()
return team
def _auth_headers(user: User) -> dict[str, str]:
token = create_access_token(user.id, user.role)
return {"Authorization": f"Bearer {token}"}
async def test_regular_user_gets_full_team_list(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
team = await _make_team(db_session)
await db_session.commit()
response = await client.get("/api/v1/teams", headers=_auth_headers(user))
assert response.status_code == 200, response.text
item = next(i for i in response.json() if i["id"] == str(team.id))
assert item["name"] == team.name
async def test_list_teams_requires_authentication(client: httpx.AsyncClient) -> None:
response = await client.get("/api/v1/teams")
assert response.status_code == 401

View File

@@ -0,0 +1,54 @@
"""Тесты `QwenTokenCounter`: ленивая загрузка токенизатора и фолбэк-эвристика."""
import logging
from pathlib import Path
import pytest
from core.summarization.tokens import QwenTokenCounter
def test_no_tokenizer_path_falls_back_to_heuristic() -> None:
"""Без пути к токенизатору — эвристика `len(text) // 3`."""
counter = QwenTokenCounter(tokenizer_path=None)
assert counter("а" * 30) == 10
def test_missing_tokenizer_file_falls_back_to_heuristic(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Несуществующий файл токенизатора → фолбэк без падения, с warning в лог."""
missing_path = str(tmp_path / "does-not-exist" / "tokenizer.json")
counter = QwenTokenCounter(tokenizer_path=missing_path)
with caplog.at_level(logging.WARNING):
result = counter("абвгдеёжз" * 3)
assert result == len("абвгдеёжз" * 3) // 3
assert any("токенизатор" in record.message.lower() for record in caplog.records)
def test_corrupted_tokenizer_file_falls_back_to_heuristic(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Битый JSON токенизатора → фолбэк без падения, с warning в лог."""
bad_file = tmp_path / "tokenizer.json"
bad_file.write_text("это не валидный json токенизатора")
counter = QwenTokenCounter(tokenizer_path=str(bad_file))
with caplog.at_level(logging.WARNING):
result = counter("текст для проверки")
assert result == len("текст для проверки") // 3
assert any("токенизатор" in record.message.lower() for record in caplog.records)
def test_loading_is_lazy_and_cached() -> None:
"""Токенизатор не загружается при инстанцировании, попытка загрузки — один раз."""
counter = QwenTokenCounter(tokenizer_path=None)
assert counter._load_attempted is False
counter("тест")
assert counter._load_attempted is True
counter("ещё один вызов") # не должно повторно пытаться грузить/падать

View File

@@ -0,0 +1,395 @@
"""Интеграционные тесты `/api/v1/users`: список пользователей, профиль, аватар."""
import uuid
from collections.abc import Generator
from pathlib import Path
import httpx
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from core.config import get_settings
from core.security import create_access_token, hash_password
from models.team import Team
from models.user import User
# Минимальные валидные по магическим байтам содержимые (без полноценного
# декодирования — `services/avatars.py` проверяет только сигнатуру/размер).
_JPEG_BYTES = b"\xff\xd8\xff\xe0" + b"\x00" * 32
_PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 32
_WEBP_BYTES = b"RIFF\x00\x00\x00\x00WEBP" + b"\x00" * 32
async def _make_user(session: AsyncSession) -> User:
user = User(
email=f"{uuid.uuid4()}@example.com",
name_user="List Tester",
password_hash=hash_password("password123"),
email_verified=True,
)
session.add(user)
await session.flush()
return user
async def _make_team(session: AsyncSession) -> Team:
team = Team(name=f"Team {uuid.uuid4()}")
session.add(team)
await session.flush()
return team
def _auth_headers(user: User) -> dict[str, str]:
token = create_access_token(user.id, user.role)
return {"Authorization": f"Bearer {token}"}
@pytest.fixture
def media_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[Path, None, None]:
"""Подменить `MEDIA_ROOT` на изолированный `tmp_path` на время теста.
`get_settings()` кэширован (`lru_cache`) — без явного сброса кэша тест
продолжил бы видеть значение, закэшированное первым же вызовом в сессии
pytest. Перечисляется в сигнатуре теста ПЕРВЫМ параметром, чтобы pytest
инициализировал его до `client`/`app` (см. `main.py::create_app`,
монтирующий `StaticFiles` по значению `media_root` на момент вызова).
"""
monkeypatch.setenv("MEDIA_ROOT", str(tmp_path))
get_settings.cache_clear()
yield tmp_path
get_settings.cache_clear()
async def test_list_users_returns_id_and_display_name(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
requester = await _make_user(db_session)
other = await _make_user(db_session)
await db_session.commit()
response = await client.get("/api/v1/users", headers=_auth_headers(requester))
assert response.status_code == 200
body = response.json()
ids = {item["id"] for item in body}
assert str(requester.id) in ids
assert str(other.id) in ids
item = next(i for i in body if i["id"] == str(other.id))
assert item["display_name"] == other.name_user
async def test_list_users_requires_auth(client: httpx.AsyncClient) -> None:
response = await client.get("/api/v1/users")
assert response.status_code == 401
async def test_get_me_with_reserved_tld_email_does_not_500(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Регрессия: `UserOut.email` — `str`, а не `EmailStr` (иначе 500 `ResponseValidationError`).
`.local`/`.test` и т.п. — синтаксически валидные, но зарезервированные
special-use домены (RFC 6761); email-validator (`EmailStr`) их отклоняет,
хотя в БД такой email мог оказаться и до этого правила (например,
сид-админ `admin@vidconf.local` в старых окружениях).
"""
# Уникальный email (не сталкивается с реально засеянным admin@vidconf.local
# в дев-БД — тесты идут в общей БД под savepoint, а email уникален).
legacy_email = f"legacy-{uuid.uuid4()}@vidconf.local"
user = User(
email=legacy_email,
name_user="Legacy Admin",
password_hash=hash_password("password123"),
email_verified=True,
role="admin",
)
db_session.add(user)
await db_session.commit()
response = await client.get("/api/v1/users/me", headers=_auth_headers(user))
assert response.status_code == 200, response.text
body = response.json()
assert body["email"] == legacy_email
assert body["role"] == "admin"
# --- Профиль ------------------------------------------------------
async def test_get_me_returns_profile_fields(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
response = await client.get("/api/v1/users/me", headers=_auth_headers(user))
assert response.status_code == 200, response.text
body = response.json()
assert body["avatar_url"] is None
assert body["team_id"] is None
assert body["team_name"] is None
async def test_patch_me_updates_name_and_team_but_ignores_email(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
team = await _make_team(db_session)
await db_session.commit()
original_email = user.email
response = await client.patch(
"/api/v1/users/me",
json={"name_user": "Renamed Self", "team_id": str(team.id), "email": "new@example.com"},
headers=_auth_headers(user),
)
assert response.status_code == 200, response.text
body = response.json()
assert body["name_user"] == "Renamed Self"
assert body["team_id"] == str(team.id)
assert body["team_name"] == team.name
assert body["email"] == original_email # email — read-only, тело проигнорировано
async def test_patch_me_with_unknown_team_returns_404(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
response = await client.patch(
"/api/v1/users/me",
json={"team_id": str(uuid.uuid4())},
headers=_auth_headers(user),
)
assert response.status_code == 404
assert response.json()["detail"] == "team_not_found"
async def test_patch_me_explicit_null_team_clears_it(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
team = await _make_team(db_session)
await db_session.commit()
await client.patch(
"/api/v1/users/me", json={"team_id": str(team.id)}, headers=_auth_headers(user)
)
response = await client.patch(
"/api/v1/users/me", json={"team_id": None}, headers=_auth_headers(user)
)
assert response.status_code == 200, response.text
assert response.json()["team_id"] is None
# --- Аватар -------------------------------------------------------
async def test_upload_avatar_jpeg_happy_path(
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
response = await client.post(
"/api/v1/users/me/avatar",
headers=_auth_headers(user),
files={"file": ("avatar.jpg", _JPEG_BYTES, "image/jpeg")},
)
assert response.status_code == 200, response.text
body = response.json()
assert body["avatar_url"] is not None
assert body["avatar_url"].startswith(f"/media/avatars/{user.id}.jpg?v=")
assert (media_root / "avatars" / f"{user.id}.jpg").read_bytes() == _JPEG_BYTES
async def test_upload_avatar_png_happy_path(
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
response = await client.post(
"/api/v1/users/me/avatar",
headers=_auth_headers(user),
files={"file": ("avatar.png", _PNG_BYTES, "image/png")},
)
assert response.status_code == 200, response.text
assert (media_root / "avatars" / f"{user.id}.png").exists()
async def test_upload_avatar_replaces_previous_file_with_different_extension(
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
await client.post(
"/api/v1/users/me/avatar",
headers=_auth_headers(user),
files={"file": ("avatar.jpg", _JPEG_BYTES, "image/jpeg")},
)
response = await client.post(
"/api/v1/users/me/avatar",
headers=_auth_headers(user),
files={"file": ("avatar.webp", _WEBP_BYTES, "image/webp")},
)
assert response.status_code == 200, response.text
assert not (media_root / "avatars" / f"{user.id}.jpg").exists()
assert (media_root / "avatars" / f"{user.id}.webp").exists()
async def test_upload_avatar_spoofed_content_type_returns_415(
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""`Content-Type: image/png`, но байты — JPEG (подделка заголовка клиентом)."""
user = await _make_user(db_session)
await db_session.commit()
response = await client.post(
"/api/v1/users/me/avatar",
headers=_auth_headers(user),
files={"file": ("avatar.png", _JPEG_BYTES, "image/png")},
)
assert response.status_code == 415
assert response.json()["detail"] == "avatar_invalid_type"
async def test_upload_avatar_unsupported_content_type_returns_415(
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
response = await client.post(
"/api/v1/users/me/avatar",
headers=_auth_headers(user),
files={"file": ("avatar.txt", b"not-an-image", "text/plain")},
)
assert response.status_code == 415
async def test_upload_avatar_too_large_returns_413(
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
oversized = _JPEG_BYTES + b"\x00" * (2 * 1024 * 1024)
response = await client.post(
"/api/v1/users/me/avatar",
headers=_auth_headers(user),
files={"file": ("avatar.jpg", oversized, "image/jpeg")},
)
assert response.status_code == 413
assert response.json()["detail"] == "avatar_too_large"
async def test_delete_avatar_removes_file_and_clears_url(
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
await client.post(
"/api/v1/users/me/avatar",
headers=_auth_headers(user),
files={"file": ("avatar.jpg", _JPEG_BYTES, "image/jpeg")},
)
response = await client.delete("/api/v1/users/me/avatar", headers=_auth_headers(user))
assert response.status_code == 204
assert not (media_root / "avatars" / f"{user.id}.jpg").exists()
me = await client.get("/api/v1/users/me", headers=_auth_headers(user))
assert me.json()["avatar_url"] is None
# --- Смена пароля ---------------------------------------------------------------------
async def test_change_password_success_new_password_works_old_does_not(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
email = user.email
await db_session.commit()
response = await client.post(
"/api/v1/users/me/password",
json={"current_password": "password123", "new_password": "new-password456"},
headers=_auth_headers(user),
)
assert response.status_code == 204, response.text
new_login = await client.post(
"/api/v1/auth/token", data={"username": email, "password": "new-password456"}
)
assert new_login.status_code == 200, new_login.text
old_login = await client.post(
"/api/v1/auth/token", data={"username": email, "password": "password123"}
)
assert old_login.status_code == 401
async def test_change_password_wrong_current_returns_400(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
response = await client.post(
"/api/v1/users/me/password",
json={"current_password": "wrong-password", "new_password": "new-password456"},
headers=_auth_headers(user),
)
assert response.status_code == 400
assert response.json()["detail"] == "invalid_current_password"
async def test_change_password_too_short_returns_422(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
response = await client.post(
"/api/v1/users/me/password",
json={"current_password": "password123", "new_password": "short"},
headers=_auth_headers(user),
)
assert response.status_code == 422
async def test_change_password_requires_auth(client: httpx.AsyncClient) -> None:
response = await client.post(
"/api/v1/users/me/password",
json={"current_password": "a", "new_password": "new-password456"},
)
assert response.status_code == 401
# --- Пикер участников: поиск `q` + `avatar_url` -------------------
async def test_list_users_search_by_q_filters_by_name_or_email(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
requester = await _make_user(db_session)
unique_marker = str(uuid.uuid4())
match = User(
email=f"{unique_marker}@example.com",
name_user=f"Findable {unique_marker}",
password_hash=hash_password("password123"),
email_verified=True,
)
db_session.add(match)
await db_session.commit()
response = await client.get(
"/api/v1/users", params={"q": unique_marker}, headers=_auth_headers(requester)
)
assert response.status_code == 200, response.text
ids = {item["id"] for item in response.json()}
assert str(match.id) in ids
assert str(requester.id) not in ids