"""Тесты `POST /api/v1/conferences/{id}/mute-participant` (задача B2). `mute_participant_track` (реальный вызов LiveKit `RoomServiceClient`) мокается на уровне `services.conferences` — тот же паттерн, что и `start_track_egress` в `tests/test_livekit_webhook.py`: сетевой вызов к LiveKit в тестах не нужен, важна только бизнес-логика (права, маршрутизация ошибок, broadcast). """ import asyncio import uuid from typing import Any from unittest.mock import AsyncMock import httpx import pytest from redis.asyncio.client import PubSub from sqlalchemy.ext.asyncio import AsyncSession import services.conferences as conferences_module from core.redis import redis_client from core.security import create_access_token, hash_password from models.conference import Conference from models.user import User from services.conference_ids import generate_number, generate_slug from services.hand_queue import hand_queue_channel from services.room_control import ParticipantNotInRoomError MUTE_URL = "{base}/mute-participant" async def _receive_within(pubsub: PubSub, *, max_wait: float) -> dict[str, Any] | None: """Дождаться СОДЕРЖАТЕЛЬНОГО сообщения канала в пределах `max_wait` секунд. `ignore_subscribe_messages=True` у `get_message` фильтрует служебное подтверждение подписки, но при этом всё равно может вернуть `None` для ЭТОГО конкретного вызова (см. `api/chat.py::_pump_pubsub_to_websocket`, ровно поэтому там `while True: ... if raw is None: continue`) — здесь тот же цикл, но с общим дедлайном вместо бесконечного ожидания. """ deadline = asyncio.get_event_loop().time() + max_wait while True: remaining = deadline - asyncio.get_event_loop().time() if remaining <= 0: return None raw: dict[str, Any] | None = await pubsub.get_message( ignore_subscribe_messages=True, timeout=remaining ) if raw is not None: return raw async def _make_user(session: AsyncSession, *, role: str = "user") -> User: user = User( email=f"{uuid.uuid4()}@example.com", name_user="Mute Tester", password_hash=hash_password("password123"), email_verified=True, role=role, ) session.add(user) await session.flush() return user async def _make_conference( session: AsyncSession, *, owner_id: uuid.UUID | None = None ) -> Conference: conference = Conference( number=generate_number(), slug=generate_slug(), title="Mute Test", status="active", owner_id=owner_id, ) session.add(conference) await session.flush() return conference def _auth_headers(user: User) -> dict[str, str]: return {"Authorization": f"Bearer {create_access_token(user.id, user.role)}"} def _url(conference_id: uuid.UUID) -> str: return MUTE_URL.format(base=f"/api/v1/conferences/{conference_id}") async def test_owner_can_mute_participant_and_broadcast_is_published( client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch ) -> None: owner = await _make_user(db_session) conference = await _make_conference(db_session, owner_id=owner.id) await db_session.commit() mock_mute = AsyncMock(return_value=True) monkeypatch.setattr(conferences_module, "mute_participant_track", mock_mute) pubsub = redis_client.pubsub() channel = hand_queue_channel(conference.id) await pubsub.subscribe(channel) try: response = await client.post( _url(conference.id), json={"identity": "some-identity", "source": "microphone"}, headers=_auth_headers(owner), ) assert response.status_code == 200, response.text assert response.json() == {"muted": True} mock_mute.assert_awaited_once_with( conference.slug, identity="some-identity", source="microphone" ) raw = await _receive_within(pubsub, max_wait=2) assert raw is not None assert raw["data"] == ( '{"type":"forced_mute","identity":"some-identity","source":"microphone"}' ) finally: await pubsub.unsubscribe(channel) await pubsub.aclose() # type: ignore[no-untyped-call] async def test_mute_already_off_does_not_broadcast( client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch ) -> None: """Трек не был опубликован (камера/мьют и так выключены, задача A1) — не ошибка. Ответ `muted=false` (искомое состояние уже достигнуто), без broadcast'а. """ owner = await _make_user(db_session) conference = await _make_conference(db_session, owner_id=owner.id) await db_session.commit() monkeypatch.setattr( conferences_module, "mute_participant_track", AsyncMock(return_value=False) ) pubsub = redis_client.pubsub() channel = hand_queue_channel(conference.id) await pubsub.subscribe(channel) try: response = await client.post( _url(conference.id), json={"identity": "some-identity", "source": "camera"}, headers=_auth_headers(owner), ) assert response.status_code == 200, response.text assert response.json() == {"muted": False} raw = await _receive_within(pubsub, max_wait=0.5) assert raw is None finally: await pubsub.unsubscribe(channel) await pubsub.aclose() # type: ignore[no-untyped-call] async def test_admin_can_mute_participant_of_someone_elses_conference( client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch ) -> 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) await db_session.commit() monkeypatch.setattr(conferences_module, "mute_participant_track", AsyncMock(return_value=True)) response = await client.post( _url(conference.id), json={"identity": "some-identity", "source": "microphone"}, headers=_auth_headers(admin), ) assert response.status_code == 200, response.text async def test_regular_participant_cannot_mute_someone_else( client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch ) -> 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() mock_mute = AsyncMock() monkeypatch.setattr(conferences_module, "mute_participant_track", mock_mute) response = await client.post( _url(conference.id), json={"identity": str(owner.id), "source": "microphone"}, headers=_auth_headers(other), ) assert response.status_code == 403 assert response.json()["detail"] == "not_owner" mock_mute.assert_not_awaited() async def test_mute_conference_not_found( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: user = await _make_user(db_session) await db_session.commit() response = await client.post( _url(uuid.uuid4()), json={"identity": "some-identity", "source": "microphone"}, headers=_auth_headers(user), ) assert response.status_code == 404 assert response.json()["detail"] == "conference_not_found" async def test_mute_participant_not_in_room_returns_404( client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch ) -> None: owner = await _make_user(db_session) conference = await _make_conference(db_session, owner_id=owner.id) await db_session.commit() monkeypatch.setattr( conferences_module, "mute_participant_track", AsyncMock(side_effect=ParticipantNotInRoomError()), ) response = await client.post( _url(conference.id), json={"identity": "ghost", "source": "microphone"}, headers=_auth_headers(owner), ) assert response.status_code == 404 assert response.json()["detail"] == "participant_not_in_room" async def test_mute_rejects_invalid_source( 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( _url(conference.id), json={"identity": "some-identity", "source": "screen_share"}, headers=_auth_headers(owner), ) assert response.status_code == 422