Первоначальная версия VidConf
This commit is contained in:
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
233
backend/tests/conftest.py
Normal file
233
backend/tests/conftest.py
Normal 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()
|
||||
16
backend/tests/fixtures/livekit/egress_ended.json
vendored
Normal file
16
backend/tests/fixtures/livekit/egress_ended.json
vendored
Normal 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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
13
backend/tests/fixtures/livekit/egress_ended_failed.json
vendored
Normal file
13
backend/tests/fixtures/livekit/egress_ended_failed.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
14
backend/tests/fixtures/livekit/participant_joined.json
vendored
Normal file
14
backend/tests/fixtures/livekit/participant_joined.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
14
backend/tests/fixtures/livekit/participant_left.json
vendored
Normal file
14
backend/tests/fixtures/livekit/participant_left.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
9
backend/tests/fixtures/livekit/room_finished.json
vendored
Normal file
9
backend/tests/fixtures/livekit/room_finished.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"event": "room_finished",
|
||||
"id": "{event_id}",
|
||||
"createdAt": "1720000003",
|
||||
"room": {
|
||||
"sid": "RM_test_finished",
|
||||
"name": "{room_name}"
|
||||
}
|
||||
}
|
||||
9
backend/tests/fixtures/livekit/room_started.json
vendored
Normal file
9
backend/tests/fixtures/livekit/room_started.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"event": "room_started",
|
||||
"id": "{event_id}",
|
||||
"createdAt": "1720000000",
|
||||
"room": {
|
||||
"sid": "RM_test_started",
|
||||
"name": "{room_name}"
|
||||
}
|
||||
}
|
||||
19
backend/tests/fixtures/livekit/track_published.json
vendored
Normal file
19
backend/tests/fixtures/livekit/track_published.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
19
backend/tests/fixtures/livekit/track_published_video.json
vendored
Normal file
19
backend/tests/fixtures/livekit/track_published_video.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
608
backend/tests/test_admin_api.py
Normal file
608
backend/tests/test_admin_api.py
Normal 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
|
||||
299
backend/tests/test_admin_teams.py
Normal file
299
backend/tests/test_admin_teams.py
Normal 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
|
||||
183
backend/tests/test_ai_levels.py
Normal file
183
backend/tests/test_ai_levels.py
Normal 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 (пресеты 1–4), `.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
434
backend/tests/test_auth.py
Normal 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
|
||||
125
backend/tests/test_build_phrases.py
Normal file
125
backend/tests/test_build_phrases.py
Normal 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:
|
||||
"""Пример A–B–A из §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"),
|
||||
]
|
||||
51
backend/tests/test_celery_routes.py
Normal file
51
backend/tests/test_celery_routes.py
Normal 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"
|
||||
526
backend/tests/test_chat_ws.py
Normal file
526
backend/tests/test_chat_ws.py
Normal 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"
|
||||
176
backend/tests/test_chunking.py
Normal file
176
backend/tests/test_chunking.py
Normal 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)
|
||||
92
backend/tests/test_conference_invitees.py
Normal file
92
backend/tests/test_conference_invitees.py
Normal 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() # не должно бросить — разные конференции
|
||||
450
backend/tests/test_conference_service.py
Normal file
450
backend/tests/test_conference_service.py
Normal 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
|
||||
1202
backend/tests/test_conferences_api.py
Normal file
1202
backend/tests/test_conferences_api.py
Normal file
File diff suppressed because it is too large
Load Diff
38
backend/tests/test_health.py
Normal file
38
backend/tests/test_health.py
Normal 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
267
backend/tests/test_ics.py
Normal 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)
|
||||
652
backend/tests/test_instance_settings.py
Normal file
652
backend/tests/test_instance_settings.py
Normal 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()
|
||||
443
backend/tests/test_invitations_task.py
Normal file
443
backend/tests/test_invitations_task.py
Normal 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) # не должно бросить
|
||||
669
backend/tests/test_livekit_webhook.py
Normal file
669
backend/tests/test_livekit_webhook.py
Normal 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"
|
||||
206
backend/tests/test_llm_client.py
Normal file
206
backend/tests/test_llm_client.py
Normal 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
|
||||
523
backend/tests/test_maintenance.py
Normal file
523
backend/tests/test_maintenance.py
Normal 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) == []
|
||||
123
backend/tests/test_metrics_api.py
Normal file
123
backend/tests/test_metrics_api.py
Normal 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
|
||||
448
backend/tests/test_notify_task.py
Normal file
448
backend/tests/test_notify_task.py
Normal 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()
|
||||
559
backend/tests/test_pipeline.py
Normal file
559
backend/tests/test_pipeline.py
Normal 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
|
||||
189
backend/tests/test_plugins_factory.py
Normal file
189
backend/tests/test_plugins_factory.py
Normal 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
|
||||
243
backend/tests/test_qwen_local.py
Normal file
243
backend/tests/test_qwen_local.py
Normal 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
|
||||
86
backend/tests/test_rbac.py
Normal file
86
backend/tests/test_rbac.py
Normal 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
|
||||
296
backend/tests/test_recurrence.py
Normal file
296
backend/tests/test_recurrence.py
Normal 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)
|
||||
170
backend/tests/test_smtp_backend.py
Normal file
170
backend/tests/test_smtp_backend.py
Normal 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,
|
||||
}
|
||||
518
backend/tests/test_summarize_task.py
Normal file
518
backend/tests/test_summarize_task.py
Normal 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()
|
||||
48
backend/tests/test_summarizer_transcript.py
Normal file
48
backend/tests/test_summarizer_transcript.py
Normal 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] начало"
|
||||
92
backend/tests/test_task_producers.py
Normal file
92
backend/tests/test_task_producers.py
Normal 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
|
||||
56
backend/tests/test_teams_api.py
Normal file
56
backend/tests/test_teams_api.py
Normal 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
|
||||
54
backend/tests/test_tokens.py
Normal file
54
backend/tests/test_tokens.py
Normal 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("ещё один вызов") # не должно повторно пытаться грузить/падать
|
||||
395
backend/tests/test_users_api.py
Normal file
395
backend/tests/test_users_api.py
Normal 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
|
||||
Reference in New Issue
Block a user