Раньше HandQueueMenu.tsx рендерился только организатору — теперь очередь видит любой участник, но опустить чужую руку по-прежнему может только организатор (сервер это уже проверял, менял только фронт). Кнопка «Опустить» показывается у записи, только если это своя рука или пользователь — организатор. Модуль «поднятие руки» (кнопка «Рука» + очередь целиком) — отключаемый в админке (instance_settings.hand_queue, дефолт enabled=true, как у chat_enabled). Настройка едет участнику в JoinOut ещё до входа в комнату; выключенный модуль гасит кнопки и на фронте, и на бэке — raise_hand/lower_hand отклоняются кодом hand_queue_disabled, если модуль выключен, даже если у клиента на руках старый JoinOut.
338 lines
13 KiB
Python
338 lines
13 KiB
Python
"""Тесты очереди поднятых рук поверх 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
|
||
|
||
import httpx
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from core.security import create_access_token, 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.instance_settings import InstanceSettingsService, SettingsUpdateIn
|
||
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=await 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)]
|
||
|
||
|
||
# --- Отключаемый модуль (`instance_settings.hand_queue`) ---------------------
|
||
|
||
|
||
async def test_hand_queue_disabled_rejects_raise_and_lower(
|
||
db_session: AsyncSession, ws_client: WSFactory
|
||
) -> None:
|
||
"""Выключенный модуль — вторая линия защиты сверх фронта: `raise_hand`/
|
||
`lower_hand` отклоняются кодом `hand_queue_disabled`, очередь не меняется."""
|
||
conference = await _make_conference(db_session)
|
||
alice = await _make_user(db_session, name="Alice")
|
||
await db_session.commit()
|
||
|
||
await InstanceSettingsService(db_session).update(SettingsUpdateIn(hand_queue_enabled=False))
|
||
|
||
ws = ws_client(_chat_path(conference.id))
|
||
await _connect_auth_and_queue(ws, _user_token(conference, alice))
|
||
|
||
await ws.send_json({"type": "raise_hand"})
|
||
error = await ws.receive_json()
|
||
assert error == {"type": "error", "code": "hand_queue_disabled"}
|
||
|
||
await ws.send_json({"type": "lower_hand"})
|
||
error = await ws.receive_json()
|
||
assert error == {"type": "error", "code": "hand_queue_disabled"}
|
||
|
||
|
||
async def test_join_out_reflects_hand_queue_enabled_toggle(
|
||
client: httpx.AsyncClient, db_session: AsyncSession
|
||
) -> None:
|
||
user = await _make_user(db_session, name="Toggle Tester")
|
||
await db_session.commit()
|
||
headers = {"Authorization": f"Bearer {create_access_token(user.id, user.role)}"}
|
||
|
||
response = await client.post(
|
||
"/api/v1/conferences", json={"title": "Standup"}, headers=headers
|
||
)
|
||
assert response.status_code == 201, response.text
|
||
assert response.json()["join"]["hand_queue_enabled"] is True
|
||
|
||
await InstanceSettingsService(db_session).update(SettingsUpdateIn(hand_queue_enabled=False))
|
||
|
||
response = await client.post(
|
||
"/api/v1/conferences", json={"title": "Standup 2"}, headers=headers
|
||
)
|
||
assert response.status_code == 201, response.text
|
||
assert response.json()["join"]["hand_queue_enabled"] is False
|
||
|
||
|
||
async def test_guest_join_out_reflects_hand_queue_enabled(
|
||
client: httpx.AsyncClient, db_session: AsyncSession
|
||
) -> None:
|
||
conference = await _make_conference(db_session)
|
||
await db_session.commit()
|
||
|
||
await InstanceSettingsService(db_session).update(SettingsUpdateIn(hand_queue_enabled=False))
|
||
|
||
response = await client.post(
|
||
f"/api/v1/conferences/{conference.id}/guest-join", json={"display_name": "Dave"}
|
||
)
|
||
assert response.status_code == 200, response.text
|
||
assert response.json()["hand_queue_enabled"] is False
|