feat(room): поднятие руки и очередь для организатора
Транспорт — существующий аутентифицированный WS чата (api/chat.py), а не отдельный эндпоинт: сервер уже держит это соединение на каждого участника (обоснование — докстринг chat_websocket и useChat.ts). Состояние очереди — Redis (services/hand_queue.py), не Postgres: это эфемерное состояние звонка, а не история, и два процесса uvicorn делают наивную память одного процесса недостаточной. HSETNX даёт идемпотентное «поднять» (повторный клик не переставляет в конец очереди), снапшот шлётся всем участникам при любом изменении — организатор, зашедший позже, сразу видит актуальную картину. Опустить чужую руку может организатор (решение оператора) — проверка через conference.owner_id, не через identity клиента. Участник, вышедший из комнаты LiveKit (webhook participant_left), теряет место в очереди автоматически; переподключение WS чата место не сбрасывает (Redis не привязан к жизни соединения). room_finished чистит очередь целиком — она не должна пережить завершение звонка. Побочный эффект транспортного решения: поднять руку нельзя, если чат выключен настройкой инстанса (WS вообще не открывается) — принятый компромисс ради переиспользования уже готового канала. UI: кнопка «Рука» в тулбаре (у всех, бейдж — общий счётчик), бейдж на плитке говорящего (видно всем), панель «Очередь» организатору (HandQueuePanel). Кнопка «Рука» и панель «Очередь» намеренно НЕ прячутся в мобильную шторку настроек, в отличие от «Вида», — поднятие руки посреди разговора требует кнопки под рукой, а не в два клика вглубь настроек. Этим же коммитом (файлы разделяемые с задачей B2, RoomParticipantTile.tsx/ useChat.ts/RoomStage.tsx/RoomPage.tsx/room.css) — проброс conferenceId и каркас forced_mute-обработки, без которых кнопки принудительного мьюта не скомпилировались бы; сама реализация мьюта — следующим коммитом.
This commit is contained in:
@@ -85,11 +85,19 @@ def _guest_token(conference: Conference, guest: GuestAccess) -> str:
|
||||
|
||||
|
||||
async def _connect_and_auth(session: ASGIWebSocketSession, token: str) -> dict[str, Any]:
|
||||
"""Подключиться, аутентифицироваться и вернуть первое сообщение (`history`)."""
|
||||
"""Подключиться, аутентифицироваться и вернуть первое сообщение (`history`).
|
||||
|
||||
После `history` сервер сразу шлёт снапшот очереди поднятых рук
|
||||
(`{"type":"hand_queue",...}`, задача B1) — здесь он молча вычитывается
|
||||
и отбрасывается, чтобы не путать существующие тесты чата, которым он
|
||||
не интересен (см. `tests/test_hand_queue_ws.py` для тестов самой очереди).
|
||||
"""
|
||||
accept = await session.connect()
|
||||
assert accept["type"] == "websocket.accept"
|
||||
await session.send_json({"type": "auth", "token": token})
|
||||
return await session.receive_json()
|
||||
history = await session.receive_json()
|
||||
await session.receive_json()
|
||||
return history
|
||||
|
||||
|
||||
# --- Основной сценарий: обмен сообщениями + история -------------------------
|
||||
|
||||
272
backend/tests/test_hand_queue_ws.py
Normal file
272
backend/tests/test_hand_queue_ws.py
Normal file
@@ -0,0 +1,272 @@
|
||||
"""Тесты очереди поднятых рук поверх WS комнаты (`WS /api/v1/conferences/{id}/chat`, задача B1).
|
||||
|
||||
Протокол и аутентификация — общие с чатом (`api/chat.py`), поэтому структура
|
||||
тестов и хелперы намеренно зеркалят `tests/test_chat_ws.py`.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.security import hash_password
|
||||
from models.conference import Conference
|
||||
from models.guest import GuestAccess
|
||||
from models.user import User
|
||||
from services.conference_ids import generate_number, generate_slug
|
||||
from services.livekit_tokens import create_room_access_token
|
||||
from tests.conftest import ASGIWebSocketSession
|
||||
|
||||
WSFactory = Callable[[str], ASGIWebSocketSession]
|
||||
|
||||
|
||||
# --- Хелперы (см. tests/test_chat_ws.py) ------------------------------------
|
||||
|
||||
|
||||
async def _make_user(session: AsyncSession, *, name: str = "Hand 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, *, owner_id: uuid.UUID | None = None, status: str = "active"
|
||||
) -> Conference:
|
||||
conference = Conference(
|
||||
number=generate_number(),
|
||||
slug=generate_slug(),
|
||||
title="Hand Queue Test",
|
||||
status=status,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
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_auth_and_queue(
|
||||
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})
|
||||
await session.receive_json() # history — не интересен этим тестам
|
||||
return await session.receive_json()
|
||||
|
||||
|
||||
def _identities(queue_frame: dict[str, Any]) -> list[str]:
|
||||
return [entry["identity"] for entry in queue_frame["queue"]]
|
||||
|
||||
|
||||
# --- Поднять/опустить свою руку -----------------------------------------------
|
||||
|
||||
|
||||
async def test_raise_and_lower_own_hand_broadcasts_to_everyone(
|
||||
db_session: AsyncSession, ws_client: WSFactory
|
||||
) -> None:
|
||||
conference = await _make_conference(db_session)
|
||||
alice = await _make_user(db_session, name="Alice")
|
||||
bob = await _make_user(db_session, name="Bob")
|
||||
await db_session.commit()
|
||||
|
||||
path = _chat_path(conference.id)
|
||||
ws1 = ws_client(path)
|
||||
await _connect_auth_and_queue(ws1, _user_token(conference, alice))
|
||||
ws2 = ws_client(path)
|
||||
initial2 = await _connect_auth_and_queue(ws2, _user_token(conference, bob))
|
||||
assert initial2 == {"type": "hand_queue", "queue": []}
|
||||
|
||||
await ws1.send_json({"type": "raise_hand"})
|
||||
|
||||
queue1 = await ws1.receive_json()
|
||||
assert _identities(queue1) == [str(alice.id)]
|
||||
assert queue1["queue"][0]["name"] == "Alice"
|
||||
assert queue1["queue"][0]["raised_at"].endswith("Z")
|
||||
|
||||
queue2 = await ws2.receive_json()
|
||||
assert queue2 == queue1
|
||||
|
||||
await ws1.send_json({"type": "lower_hand"})
|
||||
queue1_after = await ws1.receive_json()
|
||||
assert queue1_after == {"type": "hand_queue", "queue": []}
|
||||
queue2_after = await ws2.receive_json()
|
||||
assert queue2_after == queue1_after
|
||||
|
||||
|
||||
async def test_raise_hand_order_is_preserved(
|
||||
db_session: AsyncSession, ws_client: WSFactory
|
||||
) -> None:
|
||||
"""Порядок в очереди — по времени поднятия, не по алфавиту/подключению."""
|
||||
conference = await _make_conference(db_session)
|
||||
alice = await _make_user(db_session, name="Alice")
|
||||
bob = await _make_user(db_session, name="Bob")
|
||||
await db_session.commit()
|
||||
|
||||
path = _chat_path(conference.id)
|
||||
ws1 = ws_client(path)
|
||||
await _connect_auth_and_queue(ws1, _user_token(conference, alice))
|
||||
ws2 = ws_client(path)
|
||||
await _connect_auth_and_queue(ws2, _user_token(conference, bob))
|
||||
|
||||
# Боб поднимает руку ПЕРВЫМ, хотя подключился вторым — он и должен
|
||||
# оказаться первым в очереди.
|
||||
await ws2.send_json({"type": "raise_hand"})
|
||||
await ws2.receive_json()
|
||||
await ws1.receive_json()
|
||||
|
||||
await ws1.send_json({"type": "raise_hand"})
|
||||
queue = await ws1.receive_json()
|
||||
|
||||
assert _identities(queue) == [str(bob.id), str(alice.id)]
|
||||
|
||||
|
||||
async def test_re_raising_hand_does_not_move_position(
|
||||
db_session: AsyncSession, ws_client: WSFactory
|
||||
) -> None:
|
||||
"""Повторное поднятие уже поднятой руки — идемпотентно, место в очереди не меняется."""
|
||||
conference = await _make_conference(db_session)
|
||||
alice = await _make_user(db_session, name="Alice")
|
||||
bob = await _make_user(db_session, name="Bob")
|
||||
await db_session.commit()
|
||||
|
||||
path = _chat_path(conference.id)
|
||||
ws1 = ws_client(path)
|
||||
await _connect_auth_and_queue(ws1, _user_token(conference, alice))
|
||||
ws2 = ws_client(path)
|
||||
await _connect_auth_and_queue(ws2, _user_token(conference, bob))
|
||||
|
||||
await ws1.send_json({"type": "raise_hand"})
|
||||
first = await ws1.receive_json()
|
||||
await ws2.receive_json()
|
||||
|
||||
await ws2.send_json({"type": "raise_hand"})
|
||||
await ws2.receive_json()
|
||||
await ws1.receive_json()
|
||||
|
||||
# Алиса (уже в очереди первой) поднимает руку ещё раз.
|
||||
await ws1.send_json({"type": "raise_hand"})
|
||||
repeated = await ws1.receive_json()
|
||||
await ws2.receive_json()
|
||||
|
||||
assert _identities(repeated) == [str(alice.id), str(bob.id)]
|
||||
assert repeated["queue"][0]["raised_at"] == first["queue"][0]["raised_at"]
|
||||
|
||||
|
||||
async def test_guest_can_raise_hand(db_session: AsyncSession, ws_client: WSFactory) -> None:
|
||||
conference = await _make_conference(db_session)
|
||||
guest = await _make_guest(db_session, conference, name="Guest Carl")
|
||||
await db_session.commit()
|
||||
|
||||
ws = ws_client(_chat_path(conference.id))
|
||||
await _connect_auth_and_queue(ws, _guest_token(conference, guest))
|
||||
|
||||
await ws.send_json({"type": "raise_hand"})
|
||||
queue = await ws.receive_json()
|
||||
assert _identities(queue) == [f"guest:{guest.id}"]
|
||||
assert queue["queue"][0]["name"] == "Guest Carl"
|
||||
|
||||
|
||||
# --- Права организатора -------------------------------------------------------
|
||||
|
||||
|
||||
async def test_non_organizer_cannot_lower_someone_elses_hand(
|
||||
db_session: AsyncSession, ws_client: WSFactory
|
||||
) -> None:
|
||||
owner = await _make_user(db_session, name="Owner")
|
||||
conference = await _make_conference(db_session, owner_id=owner.id)
|
||||
alice = await _make_user(db_session, name="Alice")
|
||||
bob = await _make_user(db_session, name="Bob")
|
||||
await db_session.commit()
|
||||
|
||||
path = _chat_path(conference.id)
|
||||
ws1 = ws_client(path)
|
||||
await _connect_auth_and_queue(ws1, _user_token(conference, alice))
|
||||
ws2 = ws_client(path)
|
||||
await _connect_auth_and_queue(ws2, _user_token(conference, bob))
|
||||
|
||||
await ws1.send_json({"type": "raise_hand"})
|
||||
await ws1.receive_json()
|
||||
await ws2.receive_json()
|
||||
|
||||
# Боб (обычный участник, не организатор) пытается опустить руку Алисы.
|
||||
await ws2.send_json({"type": "lower_hand", "identity": str(alice.id)})
|
||||
error = await ws2.receive_json()
|
||||
assert error == {"type": "error", "code": "forbidden"}
|
||||
|
||||
|
||||
async def test_organizer_can_lower_someone_elses_hand(
|
||||
db_session: AsyncSession, ws_client: WSFactory
|
||||
) -> None:
|
||||
owner = await _make_user(db_session, name="Owner")
|
||||
conference = await _make_conference(db_session, owner_id=owner.id)
|
||||
alice = await _make_user(db_session, name="Alice")
|
||||
await db_session.commit()
|
||||
|
||||
path = _chat_path(conference.id)
|
||||
ws_alice = ws_client(path)
|
||||
await _connect_auth_and_queue(ws_alice, _user_token(conference, alice))
|
||||
ws_owner = ws_client(path)
|
||||
await _connect_auth_and_queue(ws_owner, _user_token(conference, owner))
|
||||
|
||||
await ws_alice.send_json({"type": "raise_hand"})
|
||||
await ws_alice.receive_json()
|
||||
await ws_owner.receive_json()
|
||||
|
||||
await ws_owner.send_json({"type": "lower_hand", "identity": str(alice.id)})
|
||||
queue_owner = await ws_owner.receive_json()
|
||||
queue_alice = await ws_alice.receive_json()
|
||||
|
||||
assert queue_owner == {"type": "hand_queue", "queue": []}
|
||||
assert queue_alice == queue_owner
|
||||
|
||||
|
||||
async def test_organizer_joining_late_sees_already_raised_hands(
|
||||
db_session: AsyncSession, ws_client: WSFactory
|
||||
) -> None:
|
||||
"""Организатор зашёл позже, когда руки уже подняты, — видит актуальную очередь сразу."""
|
||||
owner = await _make_user(db_session, name="Owner")
|
||||
conference = await _make_conference(db_session, owner_id=owner.id)
|
||||
alice = await _make_user(db_session, name="Alice")
|
||||
await db_session.commit()
|
||||
|
||||
path = _chat_path(conference.id)
|
||||
ws_alice = ws_client(path)
|
||||
await _connect_auth_and_queue(ws_alice, _user_token(conference, alice))
|
||||
await ws_alice.send_json({"type": "raise_hand"})
|
||||
await ws_alice.receive_json()
|
||||
|
||||
ws_owner = ws_client(path)
|
||||
initial_queue = await _connect_auth_and_queue(ws_owner, _user_token(conference, owner))
|
||||
|
||||
assert _identities(initial_queue) == [str(alice.id)]
|
||||
@@ -34,6 +34,7 @@ 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
|
||||
|
||||
@@ -217,6 +218,53 @@ async def test_full_cycle_joined_left_finished(
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user