Files
vidconf/backend/tests/test_conferences_api.py
Max Ronzhin 173d384f06
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
feat(admin): рычаги нагрузки медиа — потолок качества публикации и лимит плиток
instance_settings.media_limits (publish_quality_cap: off/720p/360p/180p,
stage_max_tiles: 4/9/16/25) — новая вкладка «Нагрузка» в админке, дефолты
(off/25) сохраняют текущее поведение существующих инсталляций.

Настройка отдаётся не только GET /admin/settings, но и в join-ответе
(JoinOut) — участнику нужно иметь её на руках ДО публикации трека, а
/admin/settings доступен только администратору.

Потолок качества применяется через RoomOptions.publishDefaults
(videoEncoding + videoSimulcastLayers на пресетах VideoPresets LiveKit) —
режет битрейт верхнего слоя симулкаста, реальное разрешение WebRTC
подстраивает сам. Лимит плиток — фильтрация STAGE_GRID_LAYOUTS по
columns*rows в StageGrid, лишние участники уходят на страницу пагинации
вместо подписки.

Значение приезжает в joinState вместе с токеном ДО первого рендера
LiveKitRoom (RoomPage не рендерит его, пока joinState не заполнен целиком),
поэтому смена настройки не переподключает уже вошедшего участника —
roomOptions пересчитывается по стабильной ссылке на joinState, которая
после подключения не меняется.

Проверено вживую на локальном стенде (docker compose --profile media):
сохранение/персист настроек, join отдаёт актуальные значения, уже
подключённый участник не разрывается при смене настройки в другом окне.
2026-08-02 19:53:23 +03:00

1372 lines
49 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/conferences`: создание, «мои», календарь, резолв,
вход (пользователь/гость), пароль закрытых, правки, rate limit.
"""
import json
import uuid
from collections.abc import Generator
from datetime import UTC, date, datetime, timedelta
from pathlib import Path
import httpx
import jwt
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from core.config import get_settings
from core.security import create_access_token, hash_password
from models.conference import Conference
from models.guest import GuestAccess
from models.invitee import ConferenceInvitee
from models.user import User
from services.conference_ids import generate_number, generate_slug
FUTURE = datetime.now(UTC) + timedelta(days=3)
def _iso(dt: datetime) -> str:
return dt.isoformat()
async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
user = User(
email=f"{uuid.uuid4()}@example.com",
name_user="Conference Tester",
password_hash=await 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,
status: str = "active",
is_pinned: bool = False,
is_closed: bool = False,
password: str | None = None,
ended_at: datetime | None = None,
scheduled_at: datetime | None = None,
duration_minutes: int | None = None,
recurrence: dict[str, object] | None = None,
) -> Conference:
conference = Conference(
number=generate_number(),
slug=generate_slug(),
owner_id=owner_id,
status=status,
is_pinned=is_pinned,
is_closed=is_closed,
password_hash=await hash_password(password) if password else None,
ended_at=ended_at,
scheduled_at=scheduled_at,
duration_minutes=duration_minutes,
)
if recurrence is not None:
conference.recurrence = recurrence
session.add(conference)
await session.flush()
return conference
def _weekly_recurrence() -> dict[str, object]:
"""Правило еженедельного повторения — для регрессионных тестов «Моих конференций»."""
return {
"type": "weekly",
"weekdays": [0],
"anchor_date": "2026-08-03",
"time_local": "09:00",
"timezone": "UTC",
"duration_minutes": 30,
}
# --- Создание -----------------------------------------------------------------
async def test_create_instant_conference_returns_active_with_join(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
response = await client.post(
"/api/v1/conferences", json={"title": "Standup"}, headers=_auth_headers(user)
)
assert response.status_code == 201, response.text
body = response.json()
assert body["status"] == "active"
assert len(body["number"]) == 9
assert body["number"].isdigit()
assert body["slug"]
assert body["join"] is not None
assert body["join"]["conference_id"] == body["id"]
assert body["join"]["room_name"] == body["slug"]
assert body["join"]["token"]
# Рычаги нагрузки медиа — дефолты без ограничения (существующие
# инсталляции не должны получить внезапно ухудшенное качество).
assert body["join"]["publish_quality_cap"] == "off"
assert body["join"]["stage_max_tiles"] == 25
async def test_create_instant_conference_join_metadata_contains_owner_avatar_url(
client: httpx.AsyncClient, db_session: AsyncSession, media_root: Path
) -> None:
"""Мгновенное создание: владелец сразу входит — токен из `join` должен нести его аватар."""
user = await _make_user(db_session)
avatar_path = f"avatars/{user.id}.png"
(media_root / "avatars").mkdir(parents=True, exist_ok=True)
(media_root / avatar_path).write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 16)
user.avatar_path = avatar_path
await db_session.commit()
response = await client.post(
"/api/v1/conferences", json={"title": "Standup"}, headers=_auth_headers(user)
)
assert response.status_code == 201, response.text
payload = _decode_livekit_token(response.json()["join"]["token"])
metadata = json.loads(str(payload["metadata"]))
assert metadata["avatar_url"].startswith(f"/media/avatars/{user.id}.png?v=")
async def test_create_scheduled_conference_returns_scheduled_without_join(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
response = await client.post(
"/api/v1/conferences",
json={"title": "Planning", "scheduled_at": _iso(FUTURE), "duration_minutes": 30},
headers=_auth_headers(user),
)
assert response.status_code == 201, response.text
body = response.json()
assert body["status"] == "scheduled"
assert body["join"] is None
async def test_create_closed_conference_without_password_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/conferences", json={"is_closed": True}, headers=_auth_headers(user)
)
assert response.status_code == 422
async def test_create_recurrence_without_pinned_returns_422(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
recurrence = {
"type": "weekly",
"weekdays": [0, 2, 4],
"anchor_date": "2026-01-05",
"time_local": "10:00",
"timezone": "Europe/Moscow",
"duration_minutes": 45,
}
response = await client.post(
"/api/v1/conferences",
json={"is_pinned": False, "recurrence": recurrence},
headers=_auth_headers(user),
)
assert response.status_code == 422
async def test_create_scheduled_in_the_past_returns_422(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
past = datetime.now(UTC) - timedelta(hours=1)
response = await client.post(
"/api/v1/conferences",
json={"title": "Past", "scheduled_at": _iso(past)},
headers=_auth_headers(user),
)
assert response.status_code == 422
# --- «Мои конференции» ---------------------------------------------------------
async def test_pinned_conference_visible_in_my_conferences_immediately(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
created = await client.post(
"/api/v1/conferences",
json={"title": "Weekly sync", "is_pinned": True},
headers=_auth_headers(user),
)
assert created.status_code == 201
conference_id = created.json()["id"]
my_response = await client.get("/api/v1/conferences/my", headers=_auth_headers(user))
assert my_response.status_code == 200
ids = [c["id"] for c in my_response.json()]
assert conference_id in ids
async def test_non_pinned_instant_conference_not_in_my_conferences(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
created = await client.post(
"/api/v1/conferences", json={"title": "Ad hoc"}, headers=_auth_headers(user)
)
conference_id = created.json()["id"]
my_response = await client.get("/api/v1/conferences/my", headers=_auth_headers(user))
assert conference_id not in [c["id"] for c in my_response.json()]
# --- Регрессия: «Мои конференции» не должны быть пустыми ---------------------------------------
async def test_admin_my_conferences_shows_pinned_recurring_and_upcoming_one_off(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Сценарий из бага: admin с закреплённой повторяющейся + разовой предстоящей видит обе."""
admin = await _make_user(db_session, role="admin")
pinned_recurring = await _make_conference(
db_session,
owner_id=admin.id,
status="scheduled",
is_pinned=True,
recurrence=_weekly_recurrence(),
)
upcoming_one_off = await _make_conference(
db_session, owner_id=admin.id, status="scheduled", scheduled_at=FUTURE
)
await db_session.commit()
response = await client.get("/api/v1/conferences/my", headers=_auth_headers(admin))
assert response.status_code == 200, response.text
ids = {c["id"] for c in response.json()}
assert str(pinned_recurring.id) in ids
assert str(upcoming_one_off.id) in ids
async def test_regular_user_my_conferences_shows_pinned_recurring_and_upcoming_one_off(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Тот же сценарий под обычным `user` — баг мог быть ролевым."""
user = await _make_user(db_session, role="user")
pinned_recurring = await _make_conference(
db_session,
owner_id=user.id,
status="scheduled",
is_pinned=True,
recurrence=_weekly_recurrence(),
)
upcoming_one_off = await _make_conference(
db_session, owner_id=user.id, status="scheduled", scheduled_at=FUTURE
)
await db_session.commit()
response = await client.get("/api/v1/conferences/my", headers=_auth_headers(user))
assert response.status_code == 200, response.text
ids = {c["id"] for c in response.json()}
assert str(pinned_recurring.id) in ids
assert str(upcoming_one_off.id) in ids
async def test_my_conferences_excludes_others_and_ended(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Чужие конференции и завершённые разовые в выдачу владельца не попадают."""
owner = await _make_user(db_session)
stranger = await _make_user(db_session)
mine_pinned = await _make_conference(
db_session, owner_id=owner.id, status="scheduled", is_pinned=True
)
mine_upcoming = await _make_conference(
db_session, owner_id=owner.id, status="scheduled", scheduled_at=FUTURE
)
mine_ended_one_off = await _make_conference(
db_session,
owner_id=owner.id,
status="ended",
scheduled_at=datetime.now(UTC) - timedelta(days=1),
ended_at=datetime.now(UTC),
)
strangers_pinned = await _make_conference(
db_session, owner_id=stranger.id, status="scheduled", is_pinned=True
)
await db_session.commit()
response = await client.get("/api/v1/conferences/my", headers=_auth_headers(owner))
assert response.status_code == 200, response.text
ids = {c["id"] for c in response.json()}
assert str(mine_pinned.id) in ids
assert str(mine_upcoming.id) in ids
assert str(mine_ended_one_off.id) not in ids
assert str(strangers_pinned.id) not in ids
# --- Видимость приглашённого в /my и /calendar (решение 2026-07-20 поверх ADR-003) ---------
async def test_invitee_by_user_id_sees_pinned_and_upcoming_in_my_conferences(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
owner = await _make_user(db_session)
invitee = await _make_user(db_session)
await db_session.commit()
pinned = await _make_conference(
db_session, owner_id=owner.id, status="scheduled", is_pinned=True
)
upcoming = await _make_conference(
db_session, owner_id=owner.id, status="scheduled", scheduled_at=FUTURE
)
not_invited = await _make_conference(
db_session, owner_id=owner.id, status="scheduled", is_pinned=True
)
db_session.add(ConferenceInvitee(conference_id=pinned.id, user_id=invitee.id))
db_session.add(ConferenceInvitee(conference_id=upcoming.id, user_id=invitee.id))
await db_session.commit()
response = await client.get("/api/v1/conferences/my", headers=_auth_headers(invitee))
assert response.status_code == 200, response.text
items = {c["id"]: c for c in response.json()}
assert str(pinned.id) in items
assert str(upcoming.id) in items
assert str(not_invited.id) not in items
assert items[str(pinned.id)]["is_owner"] is False
assert items[str(pinned.id)]["organizer_name"] == owner.name_user
async def test_invitee_by_email_case_insensitive_sees_conference_in_my_conferences(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Внешнее приглашение на email, под которым человек зарегистрирован (иной регистр)."""
owner = await _make_user(db_session)
invitee = await _make_user(db_session)
await db_session.commit()
pinned = await _make_conference(
db_session, owner_id=owner.id, status="scheduled", is_pinned=True
)
db_session.add(ConferenceInvitee(conference_id=pinned.id, email=invitee.email.upper()))
await db_session.commit()
response = await client.get("/api/v1/conferences/my", headers=_auth_headers(invitee))
assert response.status_code == 200, response.text
ids = {c["id"] for c in response.json()}
assert str(pinned.id) in ids
async def test_invitee_sees_occurrences_in_calendar(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
owner = await _make_user(db_session)
invitee = await _make_user(db_session)
await db_session.commit()
anchor = date(2026, 8, 3) # понедельник
recurrence = {
"type": "weekly",
"weekdays": [0],
"anchor_date": anchor.isoformat(),
"time_local": "09:00",
"timezone": "UTC",
"duration_minutes": 30,
}
conference = await _make_conference(
db_session, owner_id=owner.id, status="scheduled", is_pinned=True, recurrence=recurrence
)
db_session.add(ConferenceInvitee(conference_id=conference.id, user_id=invitee.id))
await db_session.commit()
t_from = datetime(2026, 8, 1, tzinfo=UTC)
t_to = datetime(2026, 8, 15, tzinfo=UTC)
response = await client.get(
"/api/v1/conferences/calendar",
params={"from": _iso(t_from), "to": _iso(t_to)},
headers=_auth_headers(invitee),
)
assert response.status_code == 200, response.text
occurrences = response.json()
assert len(occurrences) == 2
assert all(o["conference_id"] == str(conference.id) for o in occurrences)
async def test_invitee_by_user_id_gets_detail_with_participants(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
owner = await _make_user(db_session)
invitee = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id, status="scheduled")
db_session.add(ConferenceInvitee(conference_id=conference.id, user_id=invitee.id))
await db_session.commit()
response = await client.get(
f"/api/v1/conferences/{conference.id}", headers=_auth_headers(invitee)
)
assert response.status_code == 200, response.text
body = response.json()
assert body["is_owner"] is False
assert body["organizer_name"] == owner.name_user
user_ids = {p["user_id"] for p in body["participants"] if p["user_id"] is not None}
assert str(invitee.id) in user_ids
async def test_invitee_by_user_id_patch_returns_403(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Видимость в списках/detail не расширяет права на правку/удаление (403 как раньше)."""
owner = await _make_user(db_session)
invitee = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id, status="scheduled")
db_session.add(ConferenceInvitee(conference_id=conference.id, user_id=invitee.id))
await db_session.commit()
patch_response = await client.patch(
f"/api/v1/conferences/{conference.id}",
json={"title": "Hijack"},
headers=_auth_headers(invitee),
)
assert patch_response.status_code == 403
assert patch_response.json()["detail"] == "not_owner"
delete_response = await client.delete(
f"/api/v1/conferences/{conference.id}", headers=_auth_headers(invitee)
)
assert delete_response.status_code == 403
assert delete_response.json()["detail"] == "not_owner"
# --- Календарь ------------------------------------------------------------------
async def test_calendar_expands_recurrence_occurrences(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
anchor = date(2026, 8, 3) # понедельник
recurrence = {
"type": "weekly",
"weekdays": [0],
"anchor_date": anchor.isoformat(),
"time_local": "09:00",
"timezone": "UTC",
"duration_minutes": 30,
}
created = await client.post(
"/api/v1/conferences",
json={"title": "Weekly", "is_pinned": True, "recurrence": recurrence},
headers=_auth_headers(user),
)
assert created.status_code == 201
t_from = datetime(2026, 8, 1, tzinfo=UTC)
t_to = datetime(2026, 8, 15, tzinfo=UTC)
response = await client.get(
"/api/v1/conferences/calendar",
params={"from": _iso(t_from), "to": _iso(t_to)},
headers=_auth_headers(user),
)
assert response.status_code == 200
occurrences = response.json()
assert len(occurrences) == 2 # 3 и 10 августа 2026
async def test_calendar_range_too_wide_returns_422(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
t_from = datetime.now(UTC)
t_to = t_from + timedelta(days=100)
response = await client.get(
"/api/v1/conferences/calendar",
params={"from": _iso(t_from), "to": _iso(t_to)},
headers=_auth_headers(user),
)
assert response.status_code == 422
# --- Резолв (slug/номер) ---------------------------------------------------------
async def test_resolve_by_slug_returns_200(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
created = await client.post(
"/api/v1/conferences", json={"title": "Findable"}, headers=_auth_headers(user)
)
slug = created.json()["slug"]
response = await client.get("/api/v1/conferences/resolve", params={"q": slug})
assert response.status_code == 200
body = response.json()
assert body["title"] == "Findable"
assert body["requires_password"] is False
async def test_resolve_by_number_ignores_spaces(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
created = await client.post(
"/api/v1/conferences", json={"title": "Numbered"}, headers=_auth_headers(user)
)
number = created.json()["number"]
grouped = f"{number[:3]} {number[3:6]} {number[6:]}"
response = await client.get("/api/v1/conferences/resolve", params={"q": grouped})
assert response.status_code == 200
assert response.json()["title"] == "Numbered"
async def test_resolve_ended_conference_returns_minimal_response(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""ADR-001, п.4 (уточнение резолва): для ended — только id/title/status, без пароля."""
conference = await _make_conference(
db_session, status="ended", is_closed=True, password="secret1", ended_at=datetime.now(UTC)
)
await db_session.commit()
response = await client.get("/api/v1/conferences/resolve", params={"q": conference.slug})
assert response.status_code == 200
body = response.json()
assert body["id"] == str(conference.id)
assert body["status"] == "ended"
assert body.get("is_closed") is None
assert body.get("requires_password") is None
async def test_resolve_unknown_returns_uniform_404(client: httpx.AsyncClient) -> None:
response = await client.get(
"/api/v1/conferences/resolve", params={"q": "does-not-exist-at-all"}
)
assert response.status_code == 404
assert response.json()["detail"] == "not_found"
def _ip_headers() -> dict[str, str]:
"""Уникальный `X-Real-IP` на каждый тест.
Счётчики rate limit живут в Redis 60 секунд и общие для всего инстанса,
поэтому без изоляции тесты влияли бы друг на друга через остаточные ключи.
Заодно это проверяет, что заголовок вообще читается: раньше ключ строился
по `request.client.host`, то есть по адресу nginx, одинаковому для всех.
"""
return {"X-Real-IP": f"198.51.100.{uuid.uuid4().int % 250 + 1}-{uuid.uuid4().hex[:8]}"}
async def test_resolve_misses_are_rate_limited(client: httpx.AsyncClient) -> None:
"""Перебор номера конференции упирается в жёсткий лимит промахов (ADR-001, п.4)."""
headers = _ip_headers()
for _ in range(10):
response = await client.get(
"/api/v1/conferences/resolve", params={"q": "irrelevant-query"}, headers=headers
)
assert response.status_code == 404
limited = await client.get(
"/api/v1/conferences/resolve", params={"q": "irrelevant-query"}, headers=headers
)
assert limited.status_code == 429
async def test_successful_resolves_are_not_limited_by_miss_counter(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Вся конференция может открыть ссылку одновременно (регресс теста 31.07.2026).
Прежняя схема считала любые запросы с лимитом 10/мин, и одиннадцатый
участник получал 429 — фронтенд показывал «Не удалось найти конференцию»
для существующей и активной конференции.
"""
conference = await _make_conference(db_session)
await db_session.commit()
headers = _ip_headers()
for _ in range(50):
response = await client.get(
"/api/v1/conferences/resolve", params={"q": conference.slug}, headers=headers
)
assert response.status_code == 200, response.text
async def test_rate_limit_is_per_client_ip(client: httpx.AsyncClient) -> None:
"""Счётчик привязан к адресу клиента, а не к адресу nginx.
Исчерпав лимит промахов с одного адреса, с другого по-прежнему можно
работать. До исправления ключ был общим на весь инстанс.
"""
first, second = _ip_headers(), _ip_headers()
for _ in range(11):
await client.get(
"/api/v1/conferences/resolve", params={"q": "no-such-conference"}, headers=first
)
exhausted = await client.get(
"/api/v1/conferences/resolve", params={"q": "no-such-conference"}, headers=first
)
assert exhausted.status_code == 429
other = await client.get(
"/api/v1/conferences/resolve", params={"q": "no-such-conference"}, headers=second
)
assert other.status_code == 404, "лимит одного клиента не должен задевать другого"
# --- Вход зарегистрированным пользователем ---------------------------------------
async def test_join_ended_conference_returns_410(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
conference = await _make_conference(db_session, status="ended", ended_at=datetime.now(UTC))
await db_session.commit()
response = await client.post(
f"/api/v1/conferences/{conference.id}/join", headers=_auth_headers(user)
)
assert response.status_code == 410
assert response.json()["detail"] == "conference_ended"
async def test_join_closed_conference_without_password_returns_403(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
owner = await _make_user(db_session)
stranger = await _make_user(db_session)
conference = await _make_conference(
db_session, owner_id=owner.id, is_closed=True, password="secret1"
)
await db_session.commit()
response = await client.post(
f"/api/v1/conferences/{conference.id}/join", headers=_auth_headers(stranger)
)
assert response.status_code == 403
assert response.json()["detail"] == "password_required"
async def test_join_closed_conference_wrong_password_returns_403(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
owner = await _make_user(db_session)
stranger = await _make_user(db_session)
conference = await _make_conference(
db_session, owner_id=owner.id, is_closed=True, password="secret1"
)
await db_session.commit()
response = await client.post(
f"/api/v1/conferences/{conference.id}/join",
json={"password": "wrong-password"},
headers=_auth_headers(stranger),
)
assert response.status_code == 403
assert response.json()["detail"] == "invalid_password"
async def test_join_closed_conference_correct_password_returns_200(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
owner = await _make_user(db_session)
stranger = await _make_user(db_session)
conference = await _make_conference(
db_session, owner_id=owner.id, is_closed=True, password="secret1"
)
await db_session.commit()
response = await client.post(
f"/api/v1/conferences/{conference.id}/join",
json={"password": "secret1"},
headers=_auth_headers(stranger),
)
assert response.status_code == 200
assert response.json()["room_name"] == conference.slug
# --- Аватар в метаданных LiveKit-токена -----------------------------------------
@pytest.fixture
def media_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[Path, None, None]:
"""Изолированный `MEDIA_ROOT` на время теста (см. тот же приём в `test_users_api.py`)."""
monkeypatch.setenv("MEDIA_ROOT", str(tmp_path))
get_settings.cache_clear()
yield tmp_path
get_settings.cache_clear()
def _decode_livekit_token(token: str) -> dict[str, object]:
"""Декодировать JWT LiveKit-токена (тот же секрет, что использует сервис выдачи)."""
return jwt.decode(token, get_settings().livekit_api_secret, algorithms=["HS256"])
async def test_join_metadata_contains_avatar_url_for_user_with_avatar(
client: httpx.AsyncClient, db_session: AsyncSession, media_root: Path
) -> None:
user = await _make_user(db_session)
avatar_path = f"avatars/{user.id}.png"
(media_root / "avatars").mkdir(parents=True, exist_ok=True)
(media_root / avatar_path).write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 16)
user.avatar_path = avatar_path
conference = await _make_conference(db_session)
await db_session.commit()
response = await client.post(
f"/api/v1/conferences/{conference.id}/join", headers=_auth_headers(user)
)
assert response.status_code == 200, response.text
payload = _decode_livekit_token(response.json()["token"])
metadata = json.loads(str(payload["metadata"]))
assert metadata["avatar_url"].startswith(f"/media/avatars/{user.id}.png?v=")
async def test_join_metadata_absent_for_user_without_avatar(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
conference = await _make_conference(db_session)
await db_session.commit()
response = await client.post(
f"/api/v1/conferences/{conference.id}/join", headers=_auth_headers(user)
)
assert response.status_code == 200, response.text
payload = _decode_livekit_token(response.json()["token"])
assert "metadata" not in payload
async def test_join_metadata_contains_is_organizer_for_owner(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
owner = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id)
await db_session.commit()
response = await client.post(
f"/api/v1/conferences/{conference.id}/join", headers=_auth_headers(owner)
)
assert response.status_code == 200, response.text
payload = _decode_livekit_token(response.json()["token"])
metadata = json.loads(str(payload["metadata"]))
assert metadata["is_organizer"] is True
async def test_join_metadata_absent_is_organizer_for_non_owner(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
owner = await _make_user(db_session)
other = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id)
await db_session.commit()
response = await client.post(
f"/api/v1/conferences/{conference.id}/join", headers=_auth_headers(other)
)
assert response.status_code == 200, response.text
payload = _decode_livekit_token(response.json()["token"])
assert "metadata" not in payload
async def test_guest_join_metadata_is_absent(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
conference = await _make_conference(db_session)
await db_session.commit()
response = await client.post(
f"/api/v1/conferences/{conference.id}/guest-join",
json={"display_name": "Guest Bob"},
)
assert response.status_code == 200, response.text
payload = _decode_livekit_token(response.json()["token"])
assert "metadata" not in payload
# --- Гостевой вход --------------------------------------------------------------
async def test_guest_join_creates_guest_access_and_returns_join(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
conference = await _make_conference(db_session)
await db_session.commit()
response = await client.post(
f"/api/v1/conferences/{conference.id}/guest-join",
json={"display_name": "Guest Alice", "email": "alice-guest@example.com"},
)
assert response.status_code == 200, response.text
body = response.json()
assert body["room_name"] == conference.slug
assert body["token"]
guests = (
await db_session.scalars(
select(GuestAccess).where(GuestAccess.conference_id == conference.id)
)
).all()
assert len(guests) == 1
assert guests[0].display_name == "Guest Alice"
assert guests[0].email == "alice-guest@example.com"
async def test_guest_join_reflects_admin_configured_media_limits(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Настройки, сохранённые администратором в `PUT /admin/settings`, доезжают
до гостя в join-ответе ДО входа в комнату — публичный путь доставки
(см. `services/conference_access.py::build_join`), гость админку не видит."""
admin = await _make_user(db_session, role="admin")
conference = await _make_conference(db_session)
await db_session.commit()
settings_response = await client.put(
"/api/v1/admin/settings",
json={"publish_quality_cap": "180p", "stage_max_tiles": 4},
headers=_auth_headers(admin),
)
assert settings_response.status_code == 200, settings_response.text
response = await client.post(
f"/api/v1/conferences/{conference.id}/guest-join",
json={"display_name": "Guest Bob"},
)
assert response.status_code == 200, response.text
body = response.json()
assert body["publish_quality_cap"] == "180p"
assert body["stage_max_tiles"] == 4
async def test_guest_join_without_email_is_allowed(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
conference = await _make_conference(db_session)
await db_session.commit()
response = await client.post(
f"/api/v1/conferences/{conference.id}/guest-join",
json={"display_name": "Guest No-Email"},
)
assert response.status_code == 200
async def test_guest_join_closed_conference_without_password_returns_403(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
conference = await _make_conference(db_session, is_closed=True, password="secret1")
await db_session.commit()
response = await client.post(
f"/api/v1/conferences/{conference.id}/guest-join", json={"display_name": "Guest Bob"}
)
assert response.status_code == 403
assert response.json()["detail"] == "password_required"
async def test_guest_join_closed_conference_with_correct_password_returns_200(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
conference = await _make_conference(db_session, is_closed=True, password="secret1")
await db_session.commit()
response = await client.post(
f"/api/v1/conferences/{conference.id}/guest-join",
json={"display_name": "Guest Bob", "password": "secret1"},
)
assert response.status_code == 200
async def test_guest_join_ended_conference_returns_410(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
conference = await _make_conference(db_session, status="ended", ended_at=datetime.now(UTC))
await db_session.commit()
response = await client.post(
f"/api/v1/conferences/{conference.id}/guest-join", json={"display_name": "Guest Late"}
)
assert response.status_code == 410
assert response.json()["detail"] == "conference_ended"
async def test_guest_join_allows_a_whole_conference_to_enter(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Успешные гостевые входы не упираются в лимит промахов.
На нагрузочном тесте 31.07.2026 конференцию из семи десятков человек не
пускало внутрь именно это ограничение — счётчик не различал легитимный
массовый вход и перебор.
"""
conference = await _make_conference(db_session)
await db_session.commit()
headers = _ip_headers()
for i in range(30):
response = await client.post(
f"/api/v1/conferences/{conference.id}/guest-join",
json={"display_name": f"Guest {i}"},
headers=headers,
)
assert response.status_code == 200, response.text
async def test_guest_join_misses_are_rate_limited(client: httpx.AsyncClient) -> None:
"""Перебор идентификатора конференции по-прежнему упирается в лимит."""
headers = _ip_headers()
missing_id = uuid.uuid4()
for _ in range(10):
response = await client.post(
f"/api/v1/conferences/{missing_id}/guest-join",
json={"display_name": "Bruteforce"},
headers=headers,
)
assert response.status_code == 404
limited = await client.post(
f"/api/v1/conferences/{missing_id}/guest-join",
json={"display_name": "Bruteforce"},
headers=headers,
)
assert limited.status_code == 429
async def test_guest_join_wrong_password_is_rate_limited(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Подбор пароля закрытой конференции считается тем же жёстким счётчиком."""
conference = await _make_conference(db_session, is_closed=True, password="right-password")
await db_session.commit()
headers = _ip_headers()
for _ in range(10):
response = await client.post(
f"/api/v1/conferences/{conference.id}/guest-join",
json={"display_name": "Guesser", "password": "wrong"},
headers=headers,
)
assert response.status_code == 403
limited = await client.post(
f"/api/v1/conferences/{conference.id}/guest-join",
json={"display_name": "Guesser", "password": "wrong"},
headers=headers,
)
assert limited.status_code == 429
# --- Правки/удаление -------------------------------------------------------------
async def test_patch_conference_by_owner_returns_200(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
owner = await _make_user(db_session)
await db_session.commit()
created = await client.post(
"/api/v1/conferences",
json={"title": "Original", "is_pinned": True},
headers=_auth_headers(owner),
)
conference_id = created.json()["id"]
response = await client.patch(
f"/api/v1/conferences/{conference_id}",
json={"title": "Renamed"},
headers=_auth_headers(owner),
)
assert response.status_code == 200
assert response.json()["title"] == "Renamed"
async def test_patch_conference_by_stranger_returns_403(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
owner = await _make_user(db_session)
stranger = await _make_user(db_session)
await db_session.commit()
created = await client.post(
"/api/v1/conferences",
json={"title": "Mine", "is_pinned": True},
headers=_auth_headers(owner),
)
conference_id = created.json()["id"]
response = await client.patch(
f"/api/v1/conferences/{conference_id}",
json={"title": "Hijack"},
headers=_auth_headers(stranger),
)
assert response.status_code == 403
assert response.json()["detail"] == "not_owner"
async def test_patch_unpin_conference_with_recurrence_clears_it(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Регрессия: открепление не должно требовать явного `recurrence: null`."""
owner = await _make_user(db_session)
await db_session.commit()
recurrence = {
"type": "weekly",
"weekdays": [0],
"anchor_date": "2026-08-03",
"time_local": "09:00",
"timezone": "UTC",
"duration_minutes": 30,
}
created = await client.post(
"/api/v1/conferences",
json={"title": "Recurring", "is_pinned": True, "recurrence": recurrence},
headers=_auth_headers(owner),
)
assert created.status_code == 201
assert created.json()["recurrence"] is not None
conference_id = created.json()["id"]
response = await client.patch(
f"/api/v1/conferences/{conference_id}",
json={"is_pinned": False},
headers=_auth_headers(owner),
)
assert response.status_code == 200, response.text
body = response.json()
assert body["is_pinned"] is False
assert body["recurrence"] is None
# Проверяем, что в БД именно SQL NULL (а не JSON `null`), иначе следующий
# PATCH/повторное чтение сломается на CHECK-constraint.
stored = await db_session.scalar(
select(Conference.recurrence).where(Conference.id == uuid.UUID(conference_id))
)
assert stored is None
async def test_patch_explicit_recurrence_null_clears_it_and_keeps_pinned(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Явный `recurrence: null` при `is_pinned=true` очищает правило, но не открепляет."""
owner = await _make_user(db_session)
await db_session.commit()
recurrence = {
"type": "weekly",
"weekdays": [0],
"anchor_date": "2026-08-03",
"time_local": "09:00",
"timezone": "UTC",
"duration_minutes": 30,
}
created = await client.post(
"/api/v1/conferences",
json={"title": "Recurring", "is_pinned": True, "recurrence": recurrence},
headers=_auth_headers(owner),
)
conference_id = created.json()["id"]
response = await client.patch(
f"/api/v1/conferences/{conference_id}",
json={"recurrence": None},
headers=_auth_headers(owner),
)
assert response.status_code == 200, response.text
body = response.json()
assert body["is_pinned"] is True
assert body["recurrence"] is None
stored = await db_session.scalar(
select(Conference.recurrence).where(Conference.id == uuid.UUID(conference_id))
)
assert stored is None
async def test_delete_scheduled_conference_by_owner_returns_204(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
owner = await _make_user(db_session)
await db_session.commit()
created = await client.post(
"/api/v1/conferences",
json={"title": "To delete", "scheduled_at": _iso(FUTURE)},
headers=_auth_headers(owner),
)
conference_id = created.json()["id"]
response = await client.delete(
f"/api/v1/conferences/{conference_id}", headers=_auth_headers(owner)
)
assert response.status_code == 204
async def test_delete_conference_by_stranger_returns_403(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Матрица прав: удалить/отменить конференцию может только организатор."""
owner = await _make_user(db_session)
stranger = 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/conferences/{conference.id}", headers=_auth_headers(stranger)
)
assert response.status_code == 403
assert response.json()["detail"] == "not_owner"
async def test_delete_active_conference_returns_409(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
owner = await _make_user(db_session)
await db_session.commit()
created = await client.post(
"/api/v1/conferences", json={"title": "Active"}, headers=_auth_headers(owner)
)
conference_id = created.json()["id"]
response = await client.delete(
f"/api/v1/conferences/{conference_id}", headers=_auth_headers(owner)
)
assert response.status_code == 409
assert response.json()["detail"] == "conference_active"
async def test_delete_conference_by_admin_returns_204(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
owner = await _make_user(db_session)
admin = await _make_user(db_session, role="admin")
conference = await _make_conference(db_session, owner_id=owner.id, status="scheduled")
await db_session.commit()
response = await client.delete(
f"/api/v1/conferences/{conference.id}", headers=_auth_headers(admin)
)
assert response.status_code == 204
async def test_patch_conference_without_token_returns_401(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
conference = await _make_conference(db_session, status="scheduled")
await db_session.commit()
response = await client.patch(f"/api/v1/conferences/{conference.id}", json={"title": "x"})
assert response.status_code == 401
async def test_delete_conference_without_token_returns_401(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
conference = await _make_conference(db_session, status="scheduled")
await db_session.commit()
response = await client.delete(f"/api/v1/conferences/{conference.id}")
assert response.status_code == 401
# --- Детальная карточка (GET /{id}) и участники (ADR-003) -----------
async def test_get_conference_detail_by_owner_returns_participants(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
owner = await _make_user(db_session)
invitee = await _make_user(db_session)
await db_session.commit()
created = await client.post(
"/api/v1/conferences",
json={
"title": "With participants",
"scheduled_at": _iso(FUTURE),
"participants": [{"user_id": str(invitee.id)}, {"email": "external@example.com"}],
},
headers=_auth_headers(owner),
)
assert created.status_code == 201, created.text
conference_id = created.json()["id"]
response = await client.get(
f"/api/v1/conferences/{conference_id}", headers=_auth_headers(owner)
)
assert response.status_code == 200, response.text
body = response.json()
assert body["owner_id"] == str(owner.id)
assert body["is_owner"] is True
assert body["organizer_name"] == owner.name_user
user_ids = {p["user_id"] for p in body["participants"] if p["user_id"] is not None}
emails = {p["email"] for p in body["participants"] if p["email"] is not None}
assert str(owner.id) in user_ids
assert str(invitee.id) in user_ids
assert "external@example.com" in emails
organizer_entries = [p for p in body["participants"] if p["is_organizer"]]
assert len(organizer_entries) == 1
assert organizer_entries[0]["user_id"] == str(owner.id)
async def test_get_conference_detail_by_stranger_returns_403(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
owner = await _make_user(db_session)
stranger = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id, status="scheduled")
await db_session.commit()
response = await client.get(
f"/api/v1/conferences/{conference.id}", headers=_auth_headers(stranger)
)
assert response.status_code == 403
assert response.json()["detail"] == "not_owner"
async def test_get_conference_detail_unknown_id_returns_404(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
owner = await _make_user(db_session)
await db_session.commit()
response = await client.get(f"/api/v1/conferences/{uuid.uuid4()}", headers=_auth_headers(owner))
assert response.status_code == 404
async def test_get_conference_detail_without_token_returns_401(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
conference = await _make_conference(db_session, status="scheduled")
await db_session.commit()
response = await client.get(f"/api/v1/conferences/{conference.id}")
assert response.status_code == 401
async def test_create_with_unknown_participant_user_id_returns_422(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
owner = await _make_user(db_session)
await db_session.commit()
response = await client.post(
"/api/v1/conferences",
json={
"title": "Bad invitee",
"scheduled_at": _iso(FUTURE),
"participants": [{"user_id": str(uuid.uuid4())}],
},
headers=_auth_headers(owner),
)
assert response.status_code == 422
assert response.json()["detail"] == "invitee_user_not_found"
async def test_invitee_both_user_id_and_email_returns_422(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
owner = await _make_user(db_session)
await db_session.commit()
response = await client.post(
"/api/v1/conferences",
json={
"title": "Ambiguous invitee",
"participants": [{"user_id": str(owner.id), "email": "x@example.com"}],
},
headers=_auth_headers(owner),
)
assert response.status_code == 422
async def test_patch_replaces_participant_set_and_returns_it(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
owner = await _make_user(db_session)
first_invitee = await _make_user(db_session)
second_invitee = await _make_user(db_session)
await db_session.commit()
created = await client.post(
"/api/v1/conferences",
json={
"title": "Roster",
"scheduled_at": _iso(FUTURE),
"participants": [{"user_id": str(first_invitee.id)}],
},
headers=_auth_headers(owner),
)
conference_id = created.json()["id"]
response = await client.patch(
f"/api/v1/conferences/{conference_id}",
json={"participants": [{"user_id": str(second_invitee.id)}]},
headers=_auth_headers(owner),
)
assert response.status_code == 200, response.text
user_ids = {p["user_id"] for p in response.json()["participants"] if p["user_id"] is not None}
assert user_ids == {str(owner.id), str(second_invitee.id)}
async def test_my_conferences_list_does_not_include_participants(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
owner = await _make_user(db_session)
invitee = await _make_user(db_session)
await db_session.commit()
created = await client.post(
"/api/v1/conferences",
json={
"title": "Pinned with roster",
"is_pinned": True,
"participants": [{"user_id": str(invitee.id)}],
},
headers=_auth_headers(owner),
)
conference_id = created.json()["id"]
my_response = await client.get("/api/v1/conferences/my", headers=_auth_headers(owner))
item = next(c for c in my_response.json() if c["id"] == conference_id)
assert item["participants"] == []
assert item["is_owner"] is True
assert item["organizer_name"] == owner.name_user