"""Тесты очереди поднятых рук поверх 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)]