469 lines
17 KiB
Python
469 lines
17 KiB
Python
"""
|
|
Integration tests for TRA-372: Live Groupcall Backend.
|
|
|
|
Covers:
|
|
- REST: start/get/end call session
|
|
- REST: moderation actions (mute, unmute, kick)
|
|
- REST: call event audit log
|
|
- WebSocket: connect/disconnect, P2P signaling relay, instructor moderation
|
|
"""
|
|
|
|
import json
|
|
import uuid
|
|
|
|
import pytest
|
|
from channels.db import database_sync_to_async
|
|
from channels.testing import WebsocketCommunicator
|
|
from django.utils import timezone
|
|
from rest_framework.test import APIClient
|
|
|
|
from accounts.tests.factories import AccountUserFactory, RoleFactory, UserRoleBindingFactory
|
|
from training.models import (
|
|
CallEvent,
|
|
CallEventType,
|
|
CallParticipant,
|
|
CallParticipantStatus,
|
|
CallSession,
|
|
Meeting,
|
|
MeetingParticipant,
|
|
)
|
|
|
|
pytestmark = pytest.mark.django_db(transaction=True)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def make_trainer():
|
|
user = AccountUserFactory()
|
|
role = RoleFactory(slug="trainer", name="Trainer")
|
|
UserRoleBindingFactory(user=user, role=role)
|
|
return user
|
|
|
|
|
|
def make_meeting(title="Test Meeting"):
|
|
return Meeting.objects.create(title=title, starts_at=timezone.now())
|
|
|
|
|
|
def add_participant(meeting, user):
|
|
return MeetingParticipant.objects.create(meeting=meeting, user=user)
|
|
|
|
|
|
def trainer_client(trainer):
|
|
c = APIClient()
|
|
c.force_authenticate(user=trainer)
|
|
return c
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# REST: call session lifecycle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCallSessionREST:
|
|
def test_trainer_can_start_session(self):
|
|
trainer = make_trainer()
|
|
meeting = make_meeting()
|
|
client = trainer_client(trainer)
|
|
|
|
resp = client.post(f"/api/v1/training/meetings/{meeting.id}/call/")
|
|
assert resp.status_code == 201
|
|
data = resp.json()
|
|
assert data["status"] == "active"
|
|
assert data["meeting"] == str(meeting.id)
|
|
|
|
def test_non_trainer_cannot_start_session(self):
|
|
learner = AccountUserFactory()
|
|
meeting = make_meeting()
|
|
add_participant(meeting, learner)
|
|
client = APIClient()
|
|
client.force_authenticate(user=learner)
|
|
|
|
resp = client.post(f"/api/v1/training/meetings/{meeting.id}/call/")
|
|
assert resp.status_code == 403
|
|
|
|
def test_start_session_idempotent(self):
|
|
trainer = make_trainer()
|
|
meeting = make_meeting()
|
|
client = trainer_client(trainer)
|
|
|
|
resp1 = client.post(f"/api/v1/training/meetings/{meeting.id}/call/")
|
|
resp2 = client.post(f"/api/v1/training/meetings/{meeting.id}/call/")
|
|
assert resp1.status_code == 201
|
|
assert resp2.status_code == 201
|
|
assert resp1.json()["id"] == resp2.json()["id"]
|
|
|
|
def test_get_session_returns_state(self):
|
|
trainer = make_trainer()
|
|
meeting = make_meeting()
|
|
client = trainer_client(trainer)
|
|
client.post(f"/api/v1/training/meetings/{meeting.id}/call/")
|
|
|
|
resp = client.get(f"/api/v1/training/meetings/{meeting.id}/call/")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "active"
|
|
|
|
def test_get_nonexistent_session_returns_404(self):
|
|
trainer = make_trainer()
|
|
meeting = make_meeting()
|
|
client = trainer_client(trainer)
|
|
|
|
resp = client.get(f"/api/v1/training/meetings/{meeting.id}/call/")
|
|
assert resp.status_code == 404
|
|
|
|
def test_trainer_can_end_session(self):
|
|
trainer = make_trainer()
|
|
meeting = make_meeting()
|
|
client = trainer_client(trainer)
|
|
client.post(f"/api/v1/training/meetings/{meeting.id}/call/")
|
|
|
|
resp = client.delete(f"/api/v1/training/meetings/{meeting.id}/call/")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "ended"
|
|
|
|
def test_ended_session_cannot_be_ended_again(self):
|
|
trainer = make_trainer()
|
|
meeting = make_meeting()
|
|
client = trainer_client(trainer)
|
|
client.post(f"/api/v1/training/meetings/{meeting.id}/call/")
|
|
client.delete(f"/api/v1/training/meetings/{meeting.id}/call/")
|
|
|
|
resp = client.delete(f"/api/v1/training/meetings/{meeting.id}/call/")
|
|
assert resp.status_code == 409
|
|
|
|
def test_end_session_sets_participants_left(self):
|
|
trainer = make_trainer()
|
|
learner = AccountUserFactory()
|
|
meeting = make_meeting()
|
|
add_participant(meeting, learner)
|
|
client = trainer_client(trainer)
|
|
resp = client.post(f"/api/v1/training/meetings/{meeting.id}/call/")
|
|
session = CallSession.objects.get(pk=resp.json()["id"])
|
|
CallParticipant.objects.create(session=session, user=learner, status=CallParticipantStatus.JOINED)
|
|
|
|
client.delete(f"/api/v1/training/meetings/{meeting.id}/call/")
|
|
|
|
assert not CallParticipant.objects.filter(
|
|
session=session, status=CallParticipantStatus.JOINED
|
|
).exists()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# REST: moderation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCallModerationREST:
|
|
def _setup(self):
|
|
trainer = make_trainer()
|
|
learner = AccountUserFactory()
|
|
meeting = make_meeting()
|
|
add_participant(meeting, learner)
|
|
client = trainer_client(trainer)
|
|
resp = client.post(f"/api/v1/training/meetings/{meeting.id}/call/")
|
|
session = CallSession.objects.get(pk=resp.json()["id"])
|
|
CallParticipant.objects.create(session=session, user=learner, status=CallParticipantStatus.JOINED)
|
|
return trainer, learner, meeting, session, client
|
|
|
|
def test_trainer_can_mute_participant(self):
|
|
trainer, learner, meeting, session, client = self._setup()
|
|
|
|
resp = client.post(
|
|
f"/api/v1/training/meetings/{meeting.id}/call/moderate/",
|
|
{"action": "mute", "user_id": str(learner.id)},
|
|
format="json",
|
|
)
|
|
assert resp.status_code == 200
|
|
assert CallParticipant.objects.get(session=session, user=learner).is_muted is True
|
|
assert CallEvent.objects.filter(session=session, event_type=CallEventType.MUTE).exists()
|
|
|
|
def test_trainer_can_unmute_participant(self):
|
|
trainer, learner, meeting, session, client = self._setup()
|
|
CallParticipant.objects.filter(session=session, user=learner).update(is_muted=True)
|
|
|
|
resp = client.post(
|
|
f"/api/v1/training/meetings/{meeting.id}/call/moderate/",
|
|
{"action": "unmute", "user_id": str(learner.id)},
|
|
format="json",
|
|
)
|
|
assert resp.status_code == 200
|
|
assert CallParticipant.objects.get(session=session, user=learner).is_muted is False
|
|
|
|
def test_trainer_can_kick_participant(self):
|
|
trainer, learner, meeting, session, client = self._setup()
|
|
|
|
resp = client.post(
|
|
f"/api/v1/training/meetings/{meeting.id}/call/moderate/",
|
|
{"action": "kick", "user_id": str(learner.id)},
|
|
format="json",
|
|
)
|
|
assert resp.status_code == 200
|
|
assert CallParticipant.objects.get(session=session, user=learner).status == CallParticipantStatus.LEFT
|
|
assert CallEvent.objects.filter(session=session, event_type=CallEventType.KICK).exists()
|
|
|
|
def test_non_trainer_cannot_moderate(self):
|
|
trainer, learner, meeting, session, _ = self._setup()
|
|
other = AccountUserFactory()
|
|
client = APIClient()
|
|
client.force_authenticate(user=other)
|
|
|
|
resp = client.post(
|
|
f"/api/v1/training/meetings/{meeting.id}/call/moderate/",
|
|
{"action": "mute", "user_id": str(learner.id)},
|
|
format="json",
|
|
)
|
|
assert resp.status_code == 403
|
|
|
|
def test_moderate_unknown_user_returns_400(self):
|
|
trainer, learner, meeting, session, client = self._setup()
|
|
|
|
resp = client.post(
|
|
f"/api/v1/training/meetings/{meeting.id}/call/moderate/",
|
|
{"action": "mute", "user_id": str(uuid.uuid4())},
|
|
format="json",
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# REST: audit events
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCallEventsREST:
|
|
def test_trainer_can_retrieve_audit_events(self):
|
|
trainer = make_trainer()
|
|
learner = AccountUserFactory()
|
|
meeting = make_meeting()
|
|
add_participant(meeting, learner)
|
|
client = trainer_client(trainer)
|
|
client.post(f"/api/v1/training/meetings/{meeting.id}/call/")
|
|
|
|
resp = client.get(f"/api/v1/training/meetings/{meeting.id}/call/events/")
|
|
assert resp.status_code == 200
|
|
assert isinstance(resp.json(), list)
|
|
|
|
def test_non_trainer_cannot_retrieve_audit_events(self):
|
|
trainer = make_trainer()
|
|
learner = AccountUserFactory()
|
|
meeting = make_meeting()
|
|
add_participant(meeting, learner)
|
|
trainer_client(trainer).post(f"/api/v1/training/meetings/{meeting.id}/call/")
|
|
|
|
client = APIClient()
|
|
client.force_authenticate(user=learner)
|
|
resp = client.get(f"/api/v1/training/meetings/{meeting.id}/call/events/")
|
|
assert resp.status_code == 403
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WebSocket: signaling and moderation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestMeetingCallConsumer:
|
|
async def _make_app(self):
|
|
from config.asgi import application
|
|
return application
|
|
|
|
async def _connect(self, app, user, meeting_id):
|
|
from rest_framework_simplejwt.tokens import AccessToken
|
|
token = str(AccessToken.for_user(user))
|
|
url = f"/ws/meetings/{meeting_id}/call/?token={token}"
|
|
comm = WebsocketCommunicator(app, url)
|
|
connected, _ = await comm.connect()
|
|
return comm, connected
|
|
|
|
@database_sync_to_async
|
|
def _create_fixtures(self):
|
|
trainer = make_trainer()
|
|
learner = AccountUserFactory()
|
|
meeting = make_meeting()
|
|
add_participant(meeting, learner)
|
|
return trainer, learner, meeting
|
|
|
|
async def test_unauthenticated_connection_rejected(self):
|
|
app = await self._make_app()
|
|
meeting = await database_sync_to_async(make_meeting)()
|
|
comm = WebsocketCommunicator(app, f"/ws/meetings/{meeting.id}/call/")
|
|
connected, code = await comm.connect()
|
|
assert not connected
|
|
assert code == 4001
|
|
await comm.disconnect()
|
|
|
|
async def test_non_participant_connection_rejected(self):
|
|
app = await self._make_app()
|
|
stranger = await database_sync_to_async(AccountUserFactory)()
|
|
meeting = await database_sync_to_async(make_meeting)()
|
|
|
|
comm, connected = await self._connect(app, stranger, meeting.id)
|
|
assert not connected
|
|
await comm.disconnect()
|
|
|
|
async def test_participant_can_connect_and_receives_session_state(self):
|
|
app = await self._make_app()
|
|
trainer, learner, meeting = await self._create_fixtures()
|
|
|
|
comm, connected = await self._connect(app, learner, meeting.id)
|
|
assert connected
|
|
|
|
msg = json.loads(await comm.receive_from())
|
|
# first message from self is own join broadcast
|
|
# second message is session_state
|
|
if msg["type"] == "participant_joined":
|
|
msg = json.loads(await comm.receive_from())
|
|
assert msg["type"] == "session_state"
|
|
assert "participants" in msg
|
|
|
|
await comm.disconnect()
|
|
|
|
async def test_participant_join_broadcast_to_group(self):
|
|
app = await self._make_app()
|
|
trainer, learner, meeting = await self._create_fixtures()
|
|
|
|
learner2 = await database_sync_to_async(AccountUserFactory)()
|
|
await database_sync_to_async(add_participant)(meeting, learner2)
|
|
|
|
comm1, _ = await self._connect(app, learner, meeting.id)
|
|
# drain initial messages for comm1
|
|
await comm1.receive_from() # own join broadcast
|
|
await comm1.receive_from() # session_state
|
|
|
|
comm2, _ = await self._connect(app, learner2, meeting.id)
|
|
|
|
# comm1 should receive a participant_joined event for learner2
|
|
msg = json.loads(await comm1.receive_from())
|
|
assert msg["type"] == "participant_joined"
|
|
assert msg["user_id"] == str(learner2.id)
|
|
|
|
await comm1.disconnect()
|
|
await comm2.disconnect()
|
|
|
|
async def test_signaling_relay_offer_forwarded_to_target(self):
|
|
app = await self._make_app()
|
|
trainer, learner, meeting = await self._create_fixtures()
|
|
learner2 = await database_sync_to_async(AccountUserFactory)()
|
|
await database_sync_to_async(add_participant)(meeting, learner2)
|
|
|
|
comm1, _ = await self._connect(app, learner, meeting.id)
|
|
comm2, _ = await self._connect(app, learner2, meeting.id)
|
|
|
|
# drain setup messages
|
|
for _ in range(3):
|
|
await comm1.receive_from()
|
|
for _ in range(2):
|
|
await comm2.receive_from()
|
|
|
|
await comm1.send_to(text_data=json.dumps({
|
|
"type": "offer",
|
|
"to": str(learner2.id),
|
|
"sdp": {"type": "offer", "sdp": "v=0..."},
|
|
}))
|
|
|
|
msg = json.loads(await comm2.receive_from())
|
|
assert msg["type"] == "offer"
|
|
assert msg["from"] == str(learner.id)
|
|
assert msg["sdp"]["type"] == "offer"
|
|
|
|
await comm1.disconnect()
|
|
await comm2.disconnect()
|
|
|
|
async def test_instructor_mute_broadcasts_to_group(self):
|
|
app = await self._make_app()
|
|
trainer, learner, meeting = await self._create_fixtures()
|
|
|
|
comm_trainer, _ = await self._connect(app, trainer, meeting.id)
|
|
comm_learner, _ = await self._connect(app, learner, meeting.id)
|
|
|
|
# drain setup messages
|
|
for _ in range(3):
|
|
await comm_trainer.receive_from()
|
|
for _ in range(2):
|
|
await comm_learner.receive_from()
|
|
|
|
await comm_trainer.send_to(text_data=json.dumps({
|
|
"type": "mute",
|
|
"user_id": str(learner.id),
|
|
}))
|
|
|
|
msg = json.loads(await comm_learner.receive_from())
|
|
assert msg["type"] == "muted"
|
|
assert msg["user_id"] == str(learner.id)
|
|
|
|
await comm_trainer.disconnect()
|
|
await comm_learner.disconnect()
|
|
|
|
async def test_instructor_kick_closes_target_connection(self):
|
|
app = await self._make_app()
|
|
trainer, learner, meeting = await self._create_fixtures()
|
|
|
|
comm_trainer, _ = await self._connect(app, trainer, meeting.id)
|
|
comm_learner, _ = await self._connect(app, learner, meeting.id)
|
|
|
|
# drain setup messages
|
|
for _ in range(3):
|
|
await comm_trainer.receive_from()
|
|
for _ in range(2):
|
|
await comm_learner.receive_from()
|
|
|
|
await comm_trainer.send_to(text_data=json.dumps({
|
|
"type": "kick",
|
|
"user_id": str(learner.id),
|
|
}))
|
|
|
|
msg = json.loads(await comm_learner.receive_from())
|
|
assert msg["type"] == "kicked"
|
|
assert msg["user_id"] == str(learner.id)
|
|
|
|
await comm_trainer.disconnect()
|
|
await comm_learner.disconnect()
|
|
|
|
async def test_non_instructor_mute_rejected(self):
|
|
app = await self._make_app()
|
|
trainer, learner, meeting = await self._create_fixtures()
|
|
learner2 = await database_sync_to_async(AccountUserFactory)()
|
|
await database_sync_to_async(add_participant)(meeting, learner2)
|
|
|
|
comm1, _ = await self._connect(app, learner, meeting.id)
|
|
# drain
|
|
for _ in range(2):
|
|
await comm1.receive_from()
|
|
|
|
await comm1.send_to(text_data=json.dumps({
|
|
"type": "mute",
|
|
"user_id": str(learner2.id),
|
|
}))
|
|
|
|
msg = json.loads(await comm1.receive_from())
|
|
assert msg["type"] == "error"
|
|
assert msg["code"] == "not_authorized"
|
|
|
|
await comm1.disconnect()
|
|
|
|
async def test_disconnect_records_leave_event(self):
|
|
app = await self._make_app()
|
|
trainer, learner, meeting = await self._create_fixtures()
|
|
|
|
comm, connected = await self._connect(app, learner, meeting.id)
|
|
# drain
|
|
for _ in range(2):
|
|
await comm.receive_from()
|
|
|
|
session = await database_sync_to_async(
|
|
lambda: meeting.call_session
|
|
)()
|
|
await comm.disconnect()
|
|
|
|
left = await database_sync_to_async(
|
|
lambda: CallParticipant.objects.get(session=session, user=learner).status
|
|
)()
|
|
assert left == CallParticipantStatus.LEFT
|
|
|
|
event_exists = await database_sync_to_async(
|
|
lambda: CallEvent.objects.filter(session=session, event_type=CallEventType.LEAVE).exists()
|
|
)()
|
|
assert event_exists
|