Files
vidconf/backend/tests/test_livekit_webhook.py
Max Ronzhin 84b7f807f7 fix(auth): проверка пароля больше не блокирует весь backend
На нагрузочном тесте 31.07.2026 около 70 человек заходили одновременно.
Вход развалился: p95 `/api/v1/auth/token` — 7.28 с, p95 `guest-join` —
7.06 с, в БД 33 соединения `idle in transaction` при ОДНОМ активном
запросе. Люди попадали внутрь с пятой-десятой попытки, часть не попала
вовсе. Медиа при этом работало штатно: 30 участников с 27 камерами в
следующем окне прошли без единого лага.

Причина — argon2 считался синхронно внутри async-обработчика. Замер на
боевом сервере: 95–155 мс на одну проверку, и всё это время event loop
процесса стоит целиком. Транзакция БД к тому моменту уже открыта
(`get_by_email` сделал SELECT), поэтому соединение висело без работы, пул
из 40 выбирался, и отказы получали совершенно посторонние ручки — включая
вход в конференцию, где никакого пароля не проверялось.

Что изменилось:
- `hash_password`/`verify_password` стали асинхронными и считаются в пуле
  потоков (`asyncio.to_thread`). argon2-cffi освобождает GIL, поэтому
  проверки идут по-настоящему параллельно;
- параметры argon2id заменены с дефолтов библиотеки (t=3, m=64 МБ, p=4) на
  рекомендацию OWASP (t=2, m=19 МБ, p=1): 95 мс → 42 мс. Отдельно важен
  `parallelism`: при p=4 одна проверка пароля занимала все четыре ядра
  сервера — те же, на которых работает LiveKit;
- добавлен `needs_rehash`: существующие хэши проверяются как прежде
  (параметры зашиты в саму строку) и лениво перевыпускаются при первом
  успешном входе.

Расчёт по замерам: пачка из 70 логинов — 6.7–10.9 с блокировки против
~0.36 с без неё.

Тесты: event loop продолжает тикать во время проверки; 8 параллельных
проверок укладываются заметно быстрее восьми последовательных; хэш со
старыми параметрами принимается и перевыпускается при входе.
2026-08-01 23:19:52 +03:00

939 lines
36 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Интеграционные тесты `/api/v1/livekit/webhook`: полный цикл, дедуп, fallback,
невалидная подпись, пропуск невалидного identity, гостевой identity, жизненный
цикл закреплённой/незакреплённой конференции — на фикстурах payload'ов LiveKit.
"""
import asyncio
import base64
import hashlib
import json
import uuid
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from datetime import UTC, datetime, timedelta
from pathlib import Path
from unittest.mock import AsyncMock, Mock
import httpx
import jwt
import pytest
from google.protobuf.json_format import ParseDict
from livekit.protocol.webhook import WebhookEvent
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
import services.egress as egress_module
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 import hand_queue
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")
def _parse_fixture_event(name: str, **placeholders: str) -> WebhookEvent:
"""Разобрать фикстуру в `WebhookEvent` — для тестов диспатчера без HTTP-слоя."""
return ParseDict(json.loads(_load_fixture(name, **placeholders)), WebhookEvent())
async def _set_transcriber_enabled(session: AsyncSession, *, enabled: bool) -> None:
"""Выставить `instance_settings.transcriber.enabled`.
Пишется тем же `db_session` (savepoint), что и обработчик webhook (подмена
`get_session` в фикстуре `app`) — видна обработчику без реального коммита
в dev-БД. Тесты, которым важен `track_published`, обязаны выставлять флаг
ЯВНО: значение в dev-БД непредсказуемо, а обработчик с версии 0.0.12
выходит на выключенной транскрибации раньше всех остальных проверок.
"""
value: dict[str, object] = {
"enabled": enabled,
"provider": "faster_whisper_cpu" if enabled else "null",
"model": "small" if enabled else None,
"language": "ru",
"options": {},
}
await session.execute(
pg_insert(InstanceSetting)
.values(key="transcriber", value=value)
.on_conflict_do_update(index_elements=["key"], set_={"value": value})
)
def _use_test_session_in_background(
monkeypatch: pytest.MonkeyPatch, db_session: AsyncSession
) -> None:
"""Заставить фоновую задачу egress работать с тестовой (savepoint) сессией.
`run_track_egress` намеренно берёт СВОЮ сессию (`async_session_maker`):
в бою сессия запроса к моменту фоновой задачи уже закрыта. В тестах такое
подключение шло бы мимо откатываемой транзакции и не увидело бы ни
конференции, ни участника — поэтому подменяем фабрику на тестовую сессию.
"""
@asynccontextmanager
async def _maker() -> AsyncGenerator[AsyncSession, None]:
yield db_session
monkeypatch.setattr(egress_module, "async_session_maker", _maker)
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=await 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_participant_left_removes_raised_hand_from_queue(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Задача B1: участник с поднятой рукой вышел из конференции — рука исчезает из очереди."""
conference = await _make_conference(db_session, generate_slug())
user = await _make_user(db_session, "webhook-hand-1@example.com")
await db_session.commit()
identity = str(user.id)
await hand_queue.raise_hand(conference.id, identity=identity, name=user.name_user)
assert [e.identity for e in await hand_queue.snapshot(conference.id)] == [identity]
left = _load_fixture(
"participant_left.json",
event_id=f"evt-{uuid.uuid4()}",
room_name=conference.slug,
identity=identity,
)
resp = await _post_webhook(client, left)
assert resp.status_code == 200
assert await hand_queue.snapshot(conference.id) == []
async def test_room_finished_clears_hand_queue(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Задача B1: очередь поднятых рук — состояние звонка, не переживает его завершение."""
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
await hand_queue.raise_hand(conference.id, identity="guest:leftover", name="Leftover Guest")
assert len(await hand_queue.snapshot(conference.id)) == 1
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
assert await hand_queue.snapshot(conference.id) == []
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(egress_module, "start_track_egress", mock_start)
_use_test_session_in_background(monkeypatch, db_session)
await _set_transcriber_enabled(db_session, enabled=True)
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(egress_module, "start_track_egress", mock_start)
_use_test_session_in_background(monkeypatch, db_session)
await _set_transcriber_enabled(db_session, enabled=True)
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_skips_egress_when_transcription_disabled(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Транскрибация выключена → egress не дёргается вовсе (релиз 0.0.12).
Симметрично guard'у в `_on_room_finished`. Без него на инстансе без профиля
`transcribe` (egress-контейнера в деплое нет) каждый микрофонный трек
превращался в заведомо безнадёжный вызов длиной в таймаут psrpc LiveKit —
2025 секунд внутри открытой транзакции вебхука. На нагрузочном тесте
28.07.2026 это выгребало пул соединений и роняло вход в конференцию в 500.
"""
mock_start = AsyncMock()
monkeypatch.setattr(egress_module, "start_track_egress", mock_start)
_use_test_session_in_background(monkeypatch, db_session)
await _set_transcriber_enabled(db_session, enabled=False)
conference = await _make_conference(db_session, generate_slug())
user = await _make_user(db_session, "webhook-track-transcriber-off@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_transcriber_off"
published = _load_fixture(
"track_published.json",
event_id=f"evt-{uuid.uuid4()}",
room_name=conference.slug,
identity=str(user.id),
track_sid=track_sid,
)
assert (await _post_webhook(client, published)).status_code == 200
mock_start.assert_not_awaited()
tracks = (
await db_session.scalars(
select(SessionAudioTrack).where(SessionAudioTrack.track_sid == track_sid)
)
).all()
assert tracks == []
async def test_track_published_does_not_call_egress_inside_transaction(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Запуск egress уходит в фон, а не выполняется внутри обработчика (релиз 0.0.12).
Проверяется не время ответа (в тестах ASGI-транспорт дожидается фоновых
задач), а сама суть: пока открыта транзакция вебхука, сетевого вызова не
происходит — обработчик только планирует задачу. Именно это разгружает пул
соединений: до правки вызов жил внутри транзакции и держал соединение
2025 секунд, когда egress-сервиса в деплое нет.
"""
mock_start = AsyncMock(
return_value=EgressStartResult(egress_id="EG_bg", started_at=datetime.now(UTC))
)
monkeypatch.setattr(egress_module, "start_track_egress", mock_start)
scheduled: list[tuple[object, dict[str, object]]] = []
def _schedule(func: object, **kwargs: object) -> None:
scheduled.append((func, kwargs))
await _set_transcriber_enabled(db_session, enabled=True)
conference = await _make_conference(db_session, generate_slug())
user = await _make_user(db_session, "webhook-track-background@example.com")
await db_session.commit()
dispatcher = webhook_handlers_module.WebhookDispatcher(db_session, schedule=_schedule)
joined_event = _parse_fixture_event(
"participant_joined.json",
event_id=f"evt-{uuid.uuid4()}",
room_name=conference.slug,
identity=str(user.id),
)
await dispatcher.dispatch(joined_event)
track_sid = "TR_background"
published_event = _parse_fixture_event(
"track_published.json",
event_id=f"evt-{uuid.uuid4()}",
room_name=conference.slug,
identity=str(user.id),
track_sid=track_sid,
)
await dispatcher.dispatch(published_event)
# Сеть не тронута: обработчик только запланировал задачу.
mock_start.assert_not_awaited()
assert len(scheduled) == 1
func, kwargs = scheduled[0]
assert func is egress_module.run_track_egress
assert kwargs["room_name"] == conference.slug
assert kwargs["track_sid"] == track_sid
session_record = await db_session.scalar(
select(ConferenceSession).where(ConferenceSession.conference_id == conference.id)
)
assert session_record is not None
assert kwargs["session_id"] == session_record.id
# А вот запущенная задача действительно ходит в egress и пишет строку.
_use_test_session_in_background(monkeypatch, db_session)
await egress_module.run_track_egress(**kwargs) # type: ignore[arg-type]
mock_start.assert_awaited_once()
track_row = await db_session.scalar(
select(SessionAudioTrack).where(SessionAudioTrack.track_sid == track_sid)
)
assert track_row is not None
assert track_row.egress_id == "EG_bg"
async def test_start_track_egress_gives_up_on_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
"""Запуск egress не ждёт дольше `egress_start_timeout_s` (релиз 0.0.12).
Когда egress-воркера нет, LiveKit держит вызов до собственного таймаута
psrpc — на тесте 28.07.2026 это было 2025 секунд на каждый микрофонный
трек. Живой egress отвечает за доли секунды, ждать столько незачем.
"""
settings = get_settings()
monkeypatch.setattr(settings, "egress_start_timeout_s", 0.05, raising=False)
closed = False
class _HangingEgress:
async def start_track_egress(self, _request: object) -> object:
await asyncio.sleep(5)
raise AssertionError("вызов должен был прерваться по таймауту")
class _HangingApi:
def __init__(self, *_args: object, **_kwargs: object) -> None:
self.egress = _HangingEgress()
async def aclose(self) -> None:
nonlocal closed
closed = True
# Строковая форма: `api` в `services.egress` — реэкспорт из livekit SDK,
# обращение к нему атрибутом mypy считает неявным экспортом.
monkeypatch.setattr("services.egress.api.LiveKitAPI", _HangingApi)
with pytest.raises(TimeoutError):
await egress_module.start_track_egress("room", "TR_hang", "/recordings/x.ogg")
# Клиент закрывается и на неуспешном пути — иначе утекали бы соединения.
assert closed is True
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(egress_module, "start_track_egress", mock_start)
_use_test_session_in_background(monkeypatch, db_session)
await _set_transcriber_enabled(db_session, enabled=True)
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(egress_module, "start_track_egress", mock_start)
_use_test_session_in_background(monkeypatch, db_session)
await _set_transcriber_enabled(db_session, enabled=True)
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' на ошибке."""
_use_test_session_in_background(monkeypatch, db_session)
await _set_transcriber_enabled(db_session, enabled=True)
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(
egress_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(
egress_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)
# Флаг выставляется явно: `_on_room_finished` ставит задачу в очередь только
# при включённой транскрибации, а состояние `instance_settings` в dev-БД
# непредсказуемо (тест падал, если в базе оставалось `enabled: false`).
await _set_transcriber_enabled(db_session, enabled=True)
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"