Files
training-software/training/tests/test_groupcall_integration.py
Paperclip CTO dafbfa2bc4 feat(TRA-372): live groupcall backend — WebRTC signaling + session control
- Add Django Channels 4 + channels-redis to requirements and INSTALLED_APPS
- Upgrade ASGI config to ProtocolTypeRouter with JWT-authenticated WebSocket routing
- Add JWTAuthMiddleware for WebSocket token auth via query param
- Add CallSession, CallParticipant, CallEvent models with migration 0004
- MeetingCallConsumer: join/leave, P2P SDP/ICE relay via per-user groups,
  instructor mute/unmute/kick with DB audit and WS broadcast
- REST endpoints: GET/POST/DELETE /meetings/{id}/call/ (session lifecycle),
  POST /moderate/ (mute/unmute/kick), POST /screen-share/, GET /events/ (audit log)
- IsMeetingModerator permission (accepts training:signoff or meeting:moderate)
- Services: get_or_create_call_session, end_call_session, apply_moderation_action,
  toggle_screen_share with full CallEvent audit trail
- Integration tests covering REST lifecycle, moderation, and WebSocket signaling

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-18 14:12:48 +02:00

470 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,
CallSessionStatus,
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