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)