Browse Source
online class tests added : sanity tests for online class webhooks added and checked
master
online class tests added : sanity tests for online class webhooks added and checked
master
4 changed files with 651 additions and 1 deletions
-
517apps/course/management/commands/smoke_test_online_class_webhooks.py
-
4apps/course/serializers/online.py
-
3apps/course/views/live_session.py
-
128docs/online_class_webhook_smoke_test.md
@ -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, |
||||
|
) |
||||
@ -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. |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue