Ilo/apps/enrollments/tests.py
2026-05-02 20:01:30 +03:30

525 lines
22 KiB
Python

from django.contrib.auth import get_user_model
from django.test import TestCase
from rest_framework.test import APIClient
from datetime import timedelta
from django.utils import timezone
from apps.accounts.models import Role, TeacherProfile
from apps.announcements.models import Announcement
from apps.courses.models import (
Course,
CourseStatus,
Lesson,
LessonKind,
LessonStatus,
Section,
)
from apps.enrollments.models import (
ActivityEvent,
ActivityKind,
EngagementStatus,
Enrollment,
LessonProgress,
)
from apps.payments.models import Gateway, Order, OrderStatus
User = get_user_model()
def _teacher(phone="09120000001", approved=True):
u = User.objects.create_user(phone_number=phone, full_name="Teacher", role=Role.TEACHER)
TeacherProfile.objects.create(user=u, is_approved=approved)
return u
def _student(phone="09120000099", name="Student"):
return User.objects.create_user(phone_number=phone, full_name=name, role=Role.STUDENT)
def _course_with_lessons(instructor, *, n_lessons=3, published=True):
course = Course.objects.create(
instructor=instructor, title="C", tagline="T", description="D",
level="beginner", language="fa",
)
if published:
course.publish()
sec = Section.objects.create(course=course, title="S1", order=0)
lessons = []
for i in range(n_lessons):
l = Lesson.objects.create(
section=sec,
title=f"L{i+1}",
kind=LessonKind.VIDEO,
video_url=f"https://e.com/{i}",
status=LessonStatus.PUBLISHED,
order=i,
)
lessons.append(l)
return course, sec, lessons
# ======================================================================== ENROLL
class EnrollAPITests(TestCase):
def setUp(self):
self.client = APIClient()
self.teacher = _teacher()
self.student = _student()
self.course, _, _ = _course_with_lessons(self.teacher)
def test_anon_blocked(self):
r = self.client.post(f"/api/courses/{self.course.slug}/enroll/")
self.assertEqual(r.status_code, 401)
def test_teacher_cannot_enroll(self):
self.client.force_authenticate(self.teacher)
r = self.client.post(f"/api/courses/{self.course.slug}/enroll/")
self.assertEqual(r.status_code, 403)
def test_student_enrolls_201_then_200(self):
self.client.force_authenticate(self.student)
r1 = self.client.post(f"/api/courses/{self.course.slug}/enroll/")
self.assertEqual(r1.status_code, 201, r1.content)
self.assertTrue(Enrollment.objects.filter(student=self.student, course=self.course).exists())
r2 = self.client.post(f"/api/courses/{self.course.slug}/enroll/")
self.assertEqual(r2.status_code, 200) # idempotent
self.assertEqual(Enrollment.objects.filter(student=self.student, course=self.course).count(), 1)
def test_enroll_404_for_unpublished(self):
draft, _, _ = _course_with_lessons(self.teacher, published=False)
self.client.force_authenticate(self.student)
r = self.client.post(f"/api/courses/{draft.slug}/enroll/")
self.assertEqual(r.status_code, 404)
# ======================================================================== MY COURSES
class MyCoursesAPITests(TestCase):
def setUp(self):
self.client = APIClient()
self.teacher = _teacher()
self.student = _student()
def test_my_courses_lists_progress_and_next_lesson(self):
course, sec, lessons = _course_with_lessons(self.teacher, n_lessons=4)
e = Enrollment.objects.create(student=self.student, course=course)
# complete the first 2 lessons
from django.utils import timezone
for l in lessons[:2]:
LessonProgress.objects.create(
enrollment=e, lesson=l, completed_at=timezone.now()
)
self.client.force_authenticate(self.student)
r = self.client.get("/api/me/courses/")
self.assertEqual(r.status_code, 200, r.content)
body = r.json()
self.assertEqual(len(body), 1)
row = body[0]
self.assertEqual(row["progress_percent"], 50)
self.assertEqual(row["completed_lessons"], 2)
self.assertEqual(row["total_lessons"], 4)
self.assertEqual(row["last_lesson_id"], lessons[1].id)
self.assertEqual(row["next_lesson_id"], lessons[2].id)
class MyCourseDetailAPITests(TestCase):
def setUp(self):
self.client = APIClient()
self.teacher = _teacher()
self.student = _student()
self.course, self.sec, self.lessons = _course_with_lessons(self.teacher, n_lessons=2)
def test_403_when_not_enrolled(self):
self.client.force_authenticate(self.student)
r = self.client.get(f"/api/me/courses/{self.course.slug}/")
self.assertEqual(r.status_code, 403)
def test_full_content_unlocked_when_enrolled(self):
Enrollment.objects.create(student=self.student, course=self.course)
self.client.force_authenticate(self.student)
r = self.client.get(f"/api/me/courses/{self.course.slug}/")
self.assertEqual(r.status_code, 200, r.content)
body = r.json()
self.assertEqual(len(body["sections"]), 1)
lessons = body["sections"][0]["lessons"]
# both lessons present, with full video_url (no preview gating)
urls = [l["video_url"] for l in lessons]
self.assertEqual(urls, ["https://e.com/0", "https://e.com/1"])
# enrollment block
self.assertEqual(body["enrollment"]["progress_percent"], 0)
# ======================================================================== PROGRESS
class LessonProgressAPITests(TestCase):
def setUp(self):
self.client = APIClient()
self.teacher = _teacher()
self.student = _student()
self.course, _, self.lessons = _course_with_lessons(self.teacher, n_lessons=3)
self.enrollment = Enrollment.objects.create(student=self.student, course=self.course)
self.client.force_authenticate(self.student)
def test_complete_lesson(self):
l = self.lessons[0]
r = self.client.post(f"/api/me/lessons/{l.id}/complete/")
self.assertEqual(r.status_code, 200, r.content)
body = r.json()
self.assertIsNotNone(body["completed_at"])
self.assertEqual(body["progress_percent"], 33)
self.assertFalse(body["course_completed"])
self.assertTrue(
LessonProgress.objects.filter(enrollment=self.enrollment, lesson=l).exists()
)
def test_complete_idempotent(self):
l = self.lessons[0]
self.client.post(f"/api/me/lessons/{l.id}/complete/")
first_completed = LessonProgress.objects.get(enrollment=self.enrollment, lesson=l).completed_at
self.client.post(f"/api/me/lessons/{l.id}/complete/")
second = LessonProgress.objects.get(enrollment=self.enrollment, lesson=l).completed_at
self.assertEqual(first_completed, second)
self.assertEqual(
LessonProgress.objects.filter(enrollment=self.enrollment).count(), 1
)
def test_completing_all_marks_enrollment_completed(self):
for l in self.lessons:
self.client.post(f"/api/me/lessons/{l.id}/complete/")
self.enrollment.refresh_from_db()
self.assertIsNotNone(self.enrollment.completed_at)
def test_uncomplete_clears_enrollment_completion(self):
for l in self.lessons:
self.client.post(f"/api/me/lessons/{l.id}/complete/")
self.enrollment.refresh_from_db()
self.assertIsNotNone(self.enrollment.completed_at)
# un-complete one
r = self.client.post(f"/api/me/lessons/{self.lessons[0].id}/uncomplete/")
self.assertEqual(r.status_code, 200)
self.enrollment.refresh_from_db()
self.assertIsNone(self.enrollment.completed_at)
def test_progress_for_lesson_in_course_user_is_not_enrolled_in(self):
other_teacher = _teacher(phone="09120000050")
other_course, _, other_lessons = _course_with_lessons(other_teacher)
r = self.client.post(f"/api/me/lessons/{other_lessons[0].id}/complete/")
self.assertEqual(r.status_code, 403)
# ======================================================================== INSTRUCTOR ENROLLMENTS
class InstructorEnrollmentsTests(TestCase):
def test_instructor_sees_their_courses_enrollments(self):
client = APIClient()
teacher = _teacher()
s1 = _student(phone="09120000091")
s2 = _student(phone="09120000092")
course, _, _ = _course_with_lessons(teacher)
Enrollment.objects.create(student=s1, course=course)
Enrollment.objects.create(student=s2, course=course)
client.force_authenticate(teacher)
r = client.get(f"/api/instructor/courses/{course.id}/enrollments/")
self.assertEqual(r.status_code, 200, r.content)
self.assertEqual(len(r.json()), 2)
def test_instructor_cannot_see_others_enrollments(self):
client = APIClient()
owner = _teacher(phone="09120000010")
intruder = _teacher(phone="09120000011")
course, _, _ = _course_with_lessons(owner)
Enrollment.objects.create(student=_student(), course=course)
client.force_authenticate(intruder)
r = client.get(f"/api/instructor/courses/{course.id}/enrollments/")
self.assertEqual(r.status_code, 403)
# ======================================================================== ANNOUNCEMENTS
class AnnouncementAPITests(TestCase):
def setUp(self):
self.client = APIClient()
self.teacher = _teacher()
self.student = _student()
self.course, _, _ = _course_with_lessons(self.teacher)
def test_instructor_creates_announcement(self):
self.client.force_authenticate(self.teacher)
r = self.client.post(
"/api/instructor/announcements/",
{"course": self.course.id, "body": "Welcome!"},
format="json",
)
self.assertEqual(r.status_code, 201, r.content)
a = Announcement.objects.get(course=self.course)
self.assertEqual(a.posted_by, self.teacher)
def test_instructor_cannot_post_on_others_course(self):
intruder = _teacher(phone="09120000050")
self.client.force_authenticate(intruder)
r = self.client.post(
"/api/instructor/announcements/",
{"course": self.course.id, "body": "Sneaky"},
format="json",
)
self.assertEqual(r.status_code, 403)
def test_enrolled_student_reads_announcements(self):
Enrollment.objects.create(student=self.student, course=self.course)
Announcement.objects.create(course=self.course, body="Hello", posted_by=self.teacher)
self.client.force_authenticate(self.student)
r = self.client.get(f"/api/me/courses/{self.course.slug}/announcements/")
self.assertEqual(r.status_code, 200, r.content)
body = r.json()
self.assertEqual(len(body), 1)
self.assertEqual(body[0]["body"], "Hello")
def test_non_enrolled_student_blocked(self):
Announcement.objects.create(course=self.course, body="Hello", posted_by=self.teacher)
self.client.force_authenticate(self.student)
r = self.client.get(f"/api/me/courses/{self.course.slug}/announcements/")
self.assertEqual(r.status_code, 403)
# ============================================================ ENGAGEMENT STATUS
class EngagementStatusTests(TestCase):
def setUp(self):
self.teacher = _teacher()
self.course, _, _ = _course_with_lessons(self.teacher)
def _enroll(self, student, completed=False):
e = Enrollment.objects.create(student=student, course=self.course)
if completed:
e.completed_at = timezone.now()
e.save()
return e
def test_completed_overrides_activity(self):
s = _student(phone="09120000010")
e = self._enroll(s, completed=True)
self.assertEqual(e.engagement_status(), EngagementStatus.COMPLETED.value)
def test_active_when_recent_activity(self):
s = _student(phone="09120000011")
s.last_active_at = timezone.now() - timedelta(days=2)
s.save()
e = self._enroll(s)
self.assertEqual(e.engagement_status(), EngagementStatus.ACTIVE.value)
def test_at_risk_after_7_days(self):
s = _student(phone="09120000012")
s.last_active_at = timezone.now() - timedelta(days=10)
s.save()
e = self._enroll(s)
self.assertEqual(e.engagement_status(), EngagementStatus.AT_RISK.value)
def test_inactive_after_14_days(self):
s = _student(phone="09120000013")
s.last_active_at = timezone.now() - timedelta(days=20)
s.save()
e = self._enroll(s)
self.assertEqual(e.engagement_status(), EngagementStatus.INACTIVE.value)
def test_null_last_active_treated_as_active(self):
s = _student(phone="09120000014")
e = self._enroll(s)
self.assertEqual(e.engagement_status(), EngagementStatus.ACTIVE.value)
# ============================================================ ACTIVITY EVENTS
class ActivityEventRecordingTests(TestCase):
def setUp(self):
self.client = APIClient()
self.teacher = _teacher()
self.student = _student()
self.course, _, self.lessons = _course_with_lessons(self.teacher, n_lessons=2)
def test_free_enrollment_records_event(self):
# the course was created with paid_course default, but _course_with_lessons doesn't set price
# so it's free
self.client.force_authenticate(self.student)
r = self.client.post(f"/api/courses/{self.course.slug}/enroll/")
self.assertEqual(r.status_code, 201)
events = ActivityEvent.objects.filter(course=self.course, kind=ActivityKind.ENROLLED.value)
self.assertEqual(events.count(), 1)
self.assertEqual(events.first().payload["via"], "free")
def test_lesson_completion_records_event(self):
Enrollment.objects.create(student=self.student, course=self.course)
self.client.force_authenticate(self.student)
r = self.client.post(f"/api/me/lessons/{self.lessons[0].id}/complete/")
self.assertEqual(r.status_code, 200)
e = ActivityEvent.objects.filter(kind=ActivityKind.LESSON_COMPLETED.value).first()
self.assertIsNotNone(e)
self.assertEqual(e.payload["lesson_id"], self.lessons[0].id)
def test_completing_all_lessons_records_course_completed(self):
Enrollment.objects.create(student=self.student, course=self.course)
self.client.force_authenticate(self.student)
for l in self.lessons:
self.client.post(f"/api/me/lessons/{l.id}/complete/")
completed_events = ActivityEvent.objects.filter(
kind=ActivityKind.COURSE_COMPLETED.value
)
self.assertEqual(completed_events.count(), 1)
def test_announcement_post_records_event(self):
self.client.force_authenticate(self.teacher)
self.client.post(
"/api/instructor/announcements/",
{"course": self.course.id, "body": "Welcome"},
format="json",
)
e = ActivityEvent.objects.filter(kind=ActivityKind.ANNOUNCEMENT_POSTED.value).first()
self.assertIsNotNone(e)
self.assertIn("Welcome", e.payload["preview"])
def test_paid_checkout_records_enrolled_and_paid(self):
from django.test import override_settings
paid = Course.objects.create(
instructor=self.teacher, title="Paid", tagline="T", description="D",
level="beginner", language="fa", price_toman=1000,
)
paid.publish()
with override_settings(PAYMENT_BACKEND="apps.payments.services.gateway.ConsolePaymentBackend"):
self.client.force_authenticate(self.student)
co = self.client.post(f"/api/courses/{paid.slug}/checkout/").json()
self.client.get(
f"/api/payments/callback/console/?order_id={co['order_id']}&Status=OK&Authority=CONSOLE-{co['order_id']}"
)
kinds = list(
ActivityEvent.objects.filter(course=paid).values_list("kind", flat=True)
)
self.assertIn(ActivityKind.ENROLLED.value, kinds)
self.assertIn(ActivityKind.PAID.value, kinds)
# ============================================================ ANALYTICS ENDPOINTS
class InstructorAnalyticsTests(TestCase):
def setUp(self):
self.client = APIClient()
self.teacher = _teacher()
self.student = _student()
self.course, _, self.lessons = _course_with_lessons(self.teacher, n_lessons=4)
def test_kpis_blocked_for_non_teacher(self):
self.client.force_authenticate(self.student)
r = self.client.get("/api/instructor/kpis/")
self.assertEqual(r.status_code, 403)
def test_kpis_returns_four_cards(self):
self.client.force_authenticate(self.teacher)
r = self.client.get("/api/instructor/kpis/")
self.assertEqual(r.status_code, 200, r.content)
body = r.json()
ids = [c["id"] for c in body]
self.assertEqual(set(ids), {"revenue", "active_students", "avg_progress", "satisfaction"})
for card in body:
self.assertIn("value", card)
self.assertIn("spark", card)
self.assertEqual(len(card["spark"]), 12)
def test_kpi_revenue_reflects_paid_orders(self):
# Create a paid order this month
Order.objects.create(
student=self.student, course=self.course,
amount_toman=1_000_000, ilo_fee_toman=150_000, instructor_share_toman=850_000,
commission_percent_snapshot=15, gateway=Gateway.CONSOLE,
status=OrderStatus.PAID, paid_at=timezone.now(),
)
self.client.force_authenticate(self.teacher)
r = self.client.get("/api/instructor/kpis/")
revenue_card = next(c for c in r.json() if c["id"] == "revenue")
self.assertEqual(revenue_card["value"], 850_000)
def test_kpi_avg_progress_from_enrollments(self):
# one enrollment with 50% progress
e = Enrollment.objects.create(student=self.student, course=self.course)
for l in self.lessons[:2]:
LessonProgress.objects.create(
enrollment=e, lesson=l, completed_at=timezone.now()
)
self.client.force_authenticate(self.teacher)
r = self.client.get("/api/instructor/kpis/")
avg_card = next(c for c in r.json() if c["id"] == "avg_progress")
self.assertEqual(avg_card["value"], 50)
def test_monthly_revenue_returns_12_months(self):
self.client.force_authenticate(self.teacher)
r = self.client.get("/api/instructor/revenue/monthly/")
self.assertEqual(r.status_code, 200, r.content)
body = r.json()
self.assertEqual(len(body), 12)
for row in body:
self.assertIn("month", row)
self.assertIn("revenue_toman", row)
self.assertIn("orders_count", row)
def test_activity_feed_lists_events_for_my_courses_only(self):
# make 2 events for my course
from apps.enrollments.services.events import record_event
record_event(course=self.course, actor=self.student, kind=ActivityKind.ENROLLED.value)
record_event(course=self.course, actor=self.student, kind=ActivityKind.LESSON_COMPLETED.value)
# make 1 event on someone else's course
other_t = _teacher(phone="09120000050")
other_course, _, _ = _course_with_lessons(other_t)
record_event(course=other_course, actor=self.student, kind=ActivityKind.ENROLLED.value)
self.client.force_authenticate(self.teacher)
r = self.client.get("/api/instructor/activity/")
self.assertEqual(r.status_code, 200)
results = r.json()["results"]
self.assertEqual(len(results), 2)
self.assertTrue(all(row["course"] == self.course.id for row in results))
def test_activity_feed_filter_by_kind(self):
from apps.enrollments.services.events import record_event
record_event(course=self.course, actor=self.student, kind=ActivityKind.ENROLLED.value)
record_event(course=self.course, actor=self.student, kind=ActivityKind.LESSON_COMPLETED.value)
self.client.force_authenticate(self.teacher)
r = self.client.get("/api/instructor/activity/?kind=enrolled")
results = r.json()["results"]
self.assertEqual(len(results), 1)
self.assertEqual(results[0]["kind"], "enrolled")
# ============================================================ ENGAGEMENT FILTER
class CourseEnrollmentsCRMTests(TestCase):
def test_engagement_status_in_payload_and_filter(self):
client = APIClient()
teacher = _teacher()
course, _, _ = _course_with_lessons(teacher)
# an active student
a = _student(phone="09120000091")
a.last_active_at = timezone.now()
a.save()
Enrollment.objects.create(student=a, course=course)
# an at-risk student
r_student = _student(phone="09120000092")
r_student.last_active_at = timezone.now() - timedelta(days=10)
r_student.save()
Enrollment.objects.create(student=r_student, course=course)
client.force_authenticate(teacher)
r = client.get(f"/api/instructor/courses/{course.id}/enrollments/")
self.assertEqual(r.status_code, 200)
rows = r.json()
statuses = sorted(row["engagement_status"] for row in rows)
self.assertEqual(statuses, ["active", "at_risk"])
r = client.get(f"/api/instructor/courses/{course.id}/enrollments/?engagement_status=at_risk")
rows = r.json()
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["student_id"], r_student.id)