Browse Source

online class tests added : sanity tests for online class webhooks added and checked

master
Mohsen Taba 1 month ago
parent
commit
82a3f619d5
  1. 517
      apps/course/management/commands/smoke_test_online_class_webhooks.py
  2. 4
      apps/course/serializers/online.py
  3. 3
      apps/course/views/live_session.py
  4. 128
      docs/online_class_webhook_smoke_test.md

517
apps/course/management/commands/smoke_test_online_class_webhooks.py

@ -0,0 +1,517 @@
import hashlib
import hmac
import json
import time
from typing import Callable, Dict, List, Optional, Tuple
import requests
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from apps.account.models import User
from apps.course.models import Course, CourseLiveSession, LiveSessionUser, Participant
class Command(BaseCommand):
help = (
"Run a real HTTP smoke test against the public PlugNMeet webhook endpoint "
"and verify the expected database side effects for online-class webhooks."
)
def add_arguments(self, parser):
parser.add_argument(
"--base-url",
dest="base_url",
default="",
help="Public backend base URL. Defaults to SITE_DOMAIN.",
)
parser.add_argument(
"--session-id",
dest="session_id",
type=int,
help="Use an existing active live session by database id.",
)
parser.add_argument(
"--course-id",
dest="course_id",
type=int,
help="Course id to use when creating a disposable smoke-test live session.",
)
parser.add_argument(
"--create-session",
action="store_true",
dest="create_session",
help="Create a disposable active live session for the smoke test.",
)
parser.add_argument(
"--participant-user-id",
dest="participant_user_id",
type=int,
help="Student user id to use for participant_joined/left checks.",
)
parser.add_argument(
"--moderator-user-id",
dest="moderator_user_id",
type=int,
help="Professor/admin user id to use for moderator join/leave checks.",
)
parser.add_argument(
"--final-event",
dest="final_event",
choices=["room_finished", "session_ended"],
default="room_finished",
help="Final closing webhook to send at the end of the flow.",
)
parser.add_argument(
"--skip-final-close",
action="store_true",
dest="skip_final_close",
help="Skip the final closing webhook. Useful if you only want join/leave checks.",
)
parser.add_argument(
"--timeout",
dest="timeout",
type=float,
default=10.0,
help="HTTP timeout in seconds for each webhook request.",
)
parser.add_argument(
"--include-invalid-signature",
action="store_true",
dest="include_invalid_signature",
help="Also verify that the endpoint rejects a bad signature with HTTP 403.",
)
def handle(self, *args, **options):
secret = getattr(settings, "PLUGNMEET_API_SECRET", "")
if not secret:
raise CommandError("PLUGNMEET_API_SECRET is not configured.")
webhook_url = self._build_webhook_url(options["base_url"])
session, created_session = self._resolve_session(
session_id=options.get("session_id"),
course_id=options.get("course_id"),
create_session=options.get("create_session", False),
)
moderator, participant = self._resolve_users(
session=session,
moderator_user_id=options.get("moderator_user_id"),
participant_user_id=options.get("participant_user_id"),
)
self.stdout.write(
self.style.WARNING(
f"Webhook smoke test target: {webhook_url}\n"
f"Session #{session.id} room_id={session.room_id}\n"
f"Moderator user_id={moderator.id} Participant user_id={participant.id}"
)
)
results: List[Tuple[str, bool, str]] = []
if options.get("include_invalid_signature"):
results.append(
self._run_invalid_signature_check(
webhook_url=webhook_url,
session=session,
timeout=options["timeout"],
)
)
try:
results.append(
self._run_step(
name="participant_joined(participant)",
webhook_url=webhook_url,
payload=self._participant_payload(
event="participant_joined",
room_id=session.room_id,
user=participant,
state="ACTIVE",
),
secret=secret,
timeout=options["timeout"],
expected_status=200,
verify=lambda: self._assert_live_session_user_state(
session_id=session.id,
user_id=participant.id,
expected_online=True,
expected_role="participant",
),
)
)
results.append(
self._run_step(
name="participant_joined(moderator)",
webhook_url=webhook_url,
payload=self._participant_payload(
event="participant_joined",
room_id=session.room_id,
user=moderator,
state="ACTIVE",
),
secret=secret,
timeout=options["timeout"],
expected_status=200,
verify=lambda: self._assert_moderator_join_state(
session_id=session.id,
user_id=moderator.id,
),
)
)
results.append(
self._run_step(
name="participant_left(moderator)",
webhook_url=webhook_url,
payload=self._participant_payload(
event="participant_left",
room_id=session.room_id,
user=moderator,
state="DISCONNECTED",
),
secret=secret,
timeout=options["timeout"],
expected_status=200,
verify=lambda: self._assert_moderator_leave_state(session_id=session.id),
)
)
results.append(
self._run_step(
name="participant_joined(moderator-again)",
webhook_url=webhook_url,
payload=self._participant_payload(
event="participant_joined",
room_id=session.room_id,
user=moderator,
state="ACTIVE",
),
secret=secret,
timeout=options["timeout"],
expected_status=200,
verify=lambda: self._assert_moderator_join_state(
session_id=session.id,
user_id=moderator.id,
),
)
)
results.append(
self._run_step(
name="participant_left(participant)",
webhook_url=webhook_url,
payload=self._participant_payload(
event="participant_left",
room_id=session.room_id,
user=participant,
state="DISCONNECTED",
),
secret=secret,
timeout=options["timeout"],
expected_status=200,
verify=lambda: self._assert_live_session_user_state(
session_id=session.id,
user_id=participant.id,
expected_online=False,
expected_role="participant",
),
)
)
if not options.get("skip_final_close"):
results.append(
self._run_step(
name=options["final_event"],
webhook_url=webhook_url,
payload=self._room_payload(
event=options["final_event"],
room_id=session.room_id,
),
secret=secret,
timeout=options["timeout"],
expected_status=200,
verify=lambda: self._assert_session_closed(session_id=session.id),
)
)
finally:
if created_session and options.get("skip_final_close"):
self._close_session_locally(session)
failed = [name for name, ok, _ in results if not ok]
for name, ok, details in results:
style = self.style.SUCCESS if ok else self.style.ERROR
prefix = "PASS" if ok else "FAIL"
self.stdout.write(style(f"[{prefix}] {name}: {details}"))
if failed:
raise CommandError(
"Webhook smoke test failed for: " + ", ".join(failed)
)
self.stdout.write(self.style.SUCCESS("All webhook smoke-test checks passed."))
def _build_webhook_url(self, base_url: str) -> str:
normalized = (base_url or getattr(settings, "SITE_DOMAIN", "")).strip().rstrip("/")
if not normalized:
raise CommandError(
"Unable to determine public base URL. Provide --base-url or configure SITE_DOMAIN."
)
if not normalized.startswith(("http://", "https://")):
normalized = f"https://{normalized}"
return f"{normalized}/api/courses/plugnmeet/webhook/"
def _resolve_session(
self,
*,
session_id: Optional[int],
course_id: Optional[int],
create_session: bool,
) -> Tuple[CourseLiveSession, bool]:
if session_id and create_session:
raise CommandError("Use either --session-id or --create-session with --course-id, not both.")
if session_id:
try:
session = CourseLiveSession.objects.select_related("course").get(
id=session_id,
ended_at__isnull=True,
)
except CourseLiveSession.DoesNotExist as exc:
raise CommandError(f"Active live session #{session_id} was not found.") from exc
return session, False
if create_session:
if not course_id:
raise CommandError("--course-id is required when using --create-session.")
try:
course = Course.objects.prefetch_related("professors", "participants").get(id=course_id)
except Course.DoesNotExist as exc:
raise CommandError(f"Course #{course_id} was not found.") from exc
room_id = f"smoke-room-{course.id}-{int(time.time())}"
next_session_number = course.live_sessions.count() + 1
session = CourseLiveSession.objects.create(
course=course,
room_id=room_id,
subject=f"Webhook Smoke Test {next_session_number}",
started_at=timezone.now(),
)
return session, True
raise CommandError(
"Provide --session-id for an existing active session, or use --create-session with --course-id."
)
def _resolve_users(
self,
*,
session: CourseLiveSession,
moderator_user_id: Optional[int],
participant_user_id: Optional[int],
) -> Tuple[User, User]:
if moderator_user_id:
moderator = self._get_user_or_error(moderator_user_id, "moderator")
else:
moderator = session.course.professors.first()
if not moderator:
raise CommandError(
f"Course #{session.course_id} has no professor. Pass --moderator-user-id explicitly."
)
if not moderator.can_manage_course(session.course):
raise CommandError(
f"User #{moderator.id} cannot manage course #{session.course_id}; cannot use as moderator."
)
if participant_user_id:
participant = self._get_user_or_error(participant_user_id, "participant")
else:
participant_relation = (
Participant.objects.select_related("student")
.filter(course=session.course, is_active=True)
.first()
)
if not participant_relation:
raise CommandError(
f"Course #{session.course_id} has no active participant. Pass --participant-user-id explicitly."
)
participant = participant_relation.student
if participant.can_manage_course(session.course):
raise CommandError(
f"User #{participant.id} can manage course #{session.course_id}; choose a non-moderator participant."
)
return moderator, participant
def _get_user_or_error(self, user_id: int, role_label: str) -> User:
try:
return User.objects.get(id=user_id)
except User.DoesNotExist as exc:
raise CommandError(f"{role_label.title()} user #{user_id} was not found.") from exc
def _run_invalid_signature_check(
self,
*,
webhook_url: str,
session: CourseLiveSession,
timeout: float,
) -> Tuple[str, bool, str]:
payload = self._room_payload(event="room_created", room_id=session.room_id)
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
response = requests.post(
webhook_url,
data=body,
headers={
"Content-Type": "application/webhook+json",
"Hash-Token": "invalid-signature",
},
timeout=timeout,
)
ok = response.status_code == 403
detail = f"expected 403, got {response.status_code}"
return ("invalid_signature", ok, detail)
def _run_step(
self,
*,
name: str,
webhook_url: str,
payload: Dict,
secret: str,
timeout: float,
expected_status: int,
verify: Callable[[], str],
) -> Tuple[str, bool, str]:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
signature = hmac.new(
secret.encode("utf-8"),
body,
hashlib.sha256,
).hexdigest()
response = requests.post(
webhook_url,
data=body,
headers={
"Content-Type": "application/webhook+json",
"Hash-Token": signature,
},
timeout=timeout,
)
if response.status_code != expected_status:
return (
name,
False,
f"expected HTTP {expected_status}, got {response.status_code}: {response.text[:300]}",
)
try:
verification_message = verify()
except AssertionError as exc:
return (name, False, str(exc))
return (name, True, verification_message)
def _participant_payload(self, *, event: str, room_id: str, user: User, state: str) -> Dict:
return {
"event": event,
"room": {
"name": room_id,
},
"participant": {
"identity": str(user.id),
"name": getattr(user, "fullname", None) or getattr(user, "email", str(user.id)),
"state": state,
},
}
def _room_payload(self, *, event: str, room_id: str) -> Dict:
return {
"event": event,
"room": {
"name": room_id,
},
}
def _assert_live_session_user_state(
self,
*,
session_id: int,
user_id: int,
expected_online: bool,
expected_role: str,
) -> str:
try:
session_user = LiveSessionUser.objects.get(session_id=session_id, user_id=user_id)
except LiveSessionUser.DoesNotExist as exc:
raise AssertionError(
f"LiveSessionUser missing for session={session_id} user={user_id}."
) from exc
if session_user.role != expected_role:
raise AssertionError(
f"Expected role={expected_role} for user={user_id}, got role={session_user.role}."
)
if session_user.is_online != expected_online:
raise AssertionError(
f"Expected is_online={expected_online} for user={user_id}, got {session_user.is_online}."
)
if expected_online and session_user.exited_at is not None:
raise AssertionError(f"Expected exited_at to be null for online user={user_id}.")
if not expected_online and session_user.exited_at is None:
raise AssertionError(f"Expected exited_at to be set for offline user={user_id}.")
return (
f"user={user_id} role={session_user.role} "
f"is_online={session_user.is_online}"
)
def _assert_moderator_join_state(self, *, session_id: int, user_id: int) -> str:
message = self._assert_live_session_user_state(
session_id=session_id,
user_id=user_id,
expected_online=True,
expected_role="moderator",
)
session = CourseLiveSession.objects.get(id=session_id)
if session.last_moderator_left_at is not None or session.auto_close_after_moderator_exit_at is not None:
raise AssertionError(
"Expected moderator rejoin to clear pending auto-close fields, but they are still set."
)
return f"{message}; auto-close fields cleared"
def _assert_moderator_leave_state(self, *, session_id: int) -> str:
session = CourseLiveSession.objects.get(id=session_id)
moderator_online = LiveSessionUser.objects.filter(
session_id=session_id,
role="moderator",
is_online=True,
).exists()
if moderator_online:
raise AssertionError("Expected no moderators online after moderator leave webhook.")
if session.last_moderator_left_at is None:
raise AssertionError("Expected last_moderator_left_at to be set after moderator leave.")
if session.auto_close_after_moderator_exit_at is None:
raise AssertionError("Expected auto_close_after_moderator_exit_at to be set after moderator leave.")
return "moderator offline; auto-close fields scheduled"
def _assert_session_closed(self, *, session_id: int) -> str:
session = CourseLiveSession.objects.get(id=session_id)
if session.ended_at is None:
raise AssertionError("Expected session.ended_at to be set after closing webhook.")
online_users = LiveSessionUser.objects.filter(session_id=session_id, is_online=True).count()
if online_users:
raise AssertionError(
f"Expected all session users to be offline after close; found {online_users} online."
)
return f"session ended_at={session.ended_at.isoformat()} and all users offline"
def _close_session_locally(self, session: CourseLiveSession) -> None:
now = timezone.now()
CourseLiveSession.objects.filter(id=session.id, ended_at__isnull=True).update(
ended_at=now,
updated_at=now,
)
LiveSessionUser.objects.filter(session=session, is_online=True).update(
is_online=False,
exited_at=now,
updated_at=now,
)

4
apps/course/serializers/online.py

@ -43,6 +43,10 @@ class LiveSessionTokenSerializer(serializers.Serializer):
return value
class LiveSessionRecordingControlSerializer(serializers.Serializer):
action = serializers.ChoiceField(choices=["start", "stop"], required=True)
class LiveSessionRecordedFileSerializer(serializers.Serializer):
recorded_file = serializers.FileField(required=True)

3
apps/course/views/live_session.py

@ -16,7 +16,7 @@ import time
import jwt
from apps.course.models import Course, CourseLiveSession, Participant, LiveSessionRecording, LiveSessionUser
from apps.course.models.lesson import Lesson, CourseLesson
from apps.course.serializers import LiveSessionRoomCreateSerializer, LiveSessionTokenSerializer, LiveSessionRecordedFileSerializer, LiveSessionRecordingSerializer
from apps.course.serializers import LiveSessionRoomCreateSerializer, LiveSessionTokenSerializer, LiveSessionRecordedFileSerializer, LiveSessionRecordingSerializer, LiveSessionRecordingControlSerializer
from apps.course.services.plugnmeet import PlugNMeetClient, PlugNMeetError
from apps.course.services.web_egress import WebEgressClient, WebEgressError, stop_session_web_egress_if_active
from utils.exceptions import AppAPIException
@ -759,6 +759,7 @@ class CourseLiveSessionTokenAPIView(GenericAPIView):
class CourseLiveSessionRecordingAPIView(GenericAPIView):
permission_classes = [AllowAny]
authentication_classes = []
serializer_class = LiveSessionRecordingControlSerializer
@swagger_auto_schema(
operation_description="Get or control WebEgress recording state for the current room.",

128
docs/online_class_webhook_smoke_test.md

@ -0,0 +1,128 @@
# Online Class Webhook Smoke Test
This smoke test sends **real HTTP requests** to the public PlugNMeet webhook endpoint and then verifies the expected database side effects.
It is useful when you want to validate:
- Nginx/public routing to `/api/courses/plugnmeet/webhook/`
- signature verification with `PLUGNMEET_API_SECRET`
- Django webhook processing
- `CourseLiveSession` updates
- `LiveSessionUser` join/leave updates
- moderator auto-close scheduling
## Command
Run from the backend project:
```bash
python manage.py smoke_test_online_class_webhooks --help
```
## Recommended Safe Mode
Use a **disposable** live session instead of a real class session:
```bash
python manage.py smoke_test_online_class_webhooks \
--create-session \
--course-id 16 \
--base-url https://imamjavad.online \
--include-invalid-signature
```
What this does:
1. creates a temporary active `CourseLiveSession`
2. sends real webhook HTTP requests to the public endpoint
3. verifies DB side effects after each webhook
4. closes the temporary session at the end
## Use an Existing Active Session
If you want to test against a currently active session:
```bash
python manage.py smoke_test_online_class_webhooks \
--session-id 123 \
--base-url https://imamjavad.online
```
Be careful: the final closing webhook will close that session unless you pass `--skip-final-close`.
## Optional User Overrides
By default, the command auto-picks:
- moderator: first professor of the course
- participant: first active participant of the course
You can override them:
```bash
python manage.py smoke_test_online_class_webhooks \
--create-session \
--course-id 16 \
--moderator-user-id 279 \
--participant-user-id 315 \
--base-url https://imamjavad.online
```
## Webhooks Covered
The smoke test currently verifies this flow:
1. `participant_joined` for a student
2. `participant_joined` for a moderator
3. `participant_left` for the moderator
4. `participant_joined` for the moderator again
5. `participant_left` for the student
6. final close event: `room_finished` or `session_ended`
Optional:
- invalid signature should return `403`
## What Each Step Verifies
- participant join creates or updates `LiveSessionUser` with `is_online=True`
- moderator join creates a moderator `LiveSessionUser` and clears pending auto-close fields
- moderator leave sets `last_moderator_left_at` and `auto_close_after_moderator_exit_at`
- participant leave marks the participant offline
- final close sets `ended_at` and forces all session users offline
## Useful Flags
- `--include-invalid-signature`
- sends one bad-signature request and expects HTTP `403`
- `--skip-final-close`
- skips the final closing webhook
- `--final-event room_finished`
- default final close event
- `--final-event session_ended`
- use the alternate close event
- `--timeout 20`
- set HTTP timeout in seconds
## How To Read Results
The command prints one line per step:
- `[PASS] ...`
- `[FAIL] ...`
If any step fails, the command exits with a non-zero status.
## Recommended Production Usage
Prefer this pattern:
1. choose a dedicated test course
2. ensure the course has at least one professor and one active participant
3. run the command with `--create-session`
4. review:
- command output
- Django logs
- Nginx logs
This avoids mutating a real student-facing live class.
Loading…
Cancel
Save