from django.contrib.auth import get_user_model from django.test import TestCase from rest_framework.test import APIClient from apps.accounts.models import Role, TeacherProfile from apps.courses.models import ( Category, Course, CourseFAQ, CourseHighlight, CourseStatus, Lesson, LessonKind, LessonStatus, Section, ) User = get_user_model() # --------------------------------------------------------------------------- helpers def _make_teacher(phone="09120000001", name="Teacher One", approved=False): u = User.objects.create_user(phone_number=phone, full_name=name, role=Role.TEACHER) TeacherProfile.objects.create(user=u, is_approved=approved) return u def _make_student(phone="09120000099", name="Student"): return User.objects.create_user(phone_number=phone, full_name=name, role=Role.STUDENT) def _auth(client, user): # tests bypass OTP and just attach the user via force_authenticate client.force_authenticate(user=user) def _course(instructor, **overrides): fields = { "title": "Course Title", "tagline": "Tagline", "description": "Description", "level": "beginner", "language": "fa", } fields.update(overrides) return Course.objects.create(instructor=instructor, **fields) # --------------------------------------------------------------------------- model tests class CourseModelTests(TestCase): def test_slug_auto_generated_on_create(self): t = _make_teacher() c = _course(t, title="Hello World") self.assertTrue(c.slug) self.assertNotIn(" ", c.slug) def test_slug_uniqueness_collision(self): t = _make_teacher() a = _course(t, title="Same Title") b = _course(t, title="Same Title") self.assertNotEqual(a.slug, b.slug) def test_publish_sets_status_and_published_at(self): t = _make_teacher() c = _course(t) self.assertEqual(c.status, CourseStatus.DRAFT) self.assertIsNone(c.published_at) c.publish() c.refresh_from_db() self.assertEqual(c.status, CourseStatus.PUBLISHED) self.assertIsNotNone(c.published_at) # --------------------------------------------------------------------------- public catalog class PublicCatalogTests(TestCase): def setUp(self): self.client = APIClient() self.t = _make_teacher(approved=True) self.cat = Category.objects.create(slug="design", name="Design", order=0) self.published = _course(self.t, title="Published", category=self.cat) self.published.publish() self.draft = _course(self.t, title="Draft", category=self.cat) def test_list_excludes_drafts(self): r = self.client.get("/api/courses/") self.assertEqual(r.status_code, 200) ids = [row["id"] for row in r.json()["results"]] self.assertIn(self.published.id, ids) self.assertNotIn(self.draft.id, ids) def test_filter_by_category_slug(self): other = Category.objects.create(slug="dev", name="Dev") c2 = _course(self.t, title="Other", category=other) c2.publish() r = self.client.get("/api/courses/?category=design") ids = [row["id"] for row in r.json()["results"]] self.assertIn(self.published.id, ids) self.assertNotIn(c2.id, ids) def test_search_by_title(self): r = self.client.get("/api/courses/?search=Publish") ids = [row["id"] for row in r.json()["results"]] self.assertIn(self.published.id, ids) def test_detail_404_for_draft(self): r = self.client.get(f"/api/courses/{self.draft.slug}/") self.assertEqual(r.status_code, 404) def test_detail_for_published(self): r = self.client.get(f"/api/courses/{self.published.slug}/") self.assertEqual(r.status_code, 200) body = r.json() self.assertEqual(body["id"], self.published.id) self.assertIn("sections", body) self.assertIn("highlights", body) self.assertIn("faqs", body) def test_category_includes_course_count(self): r = self.client.get("/api/categories/") self.assertEqual(r.status_code, 200) rows = r.json() design_row = next(c for c in rows if c["slug"] == "design") # Only 1 published course in `design` (`self.published`); `self.draft` is draft self.assertEqual(design_row["course_count"], 1) def test_course_list_includes_stats_and_badges(self): r = self.client.get("/api/courses/") row = next(c for c in r.json()["results"] if c["id"] == self.published.id) self.assertIn("lesson_count", row) self.assertIn("enrolled_count", row) self.assertIn("total_minutes_seconds", row) self.assertIn("is_new", row) self.assertIn("is_bestseller", row) self.assertIn("is_coming_soon", row) # Newly created course → is_new=True self.assertTrue(row["is_new"]) # No paid orders → is_bestseller=False self.assertFalse(row["is_bestseller"]) def test_course_list_ordering_by_popularity(self): from apps.payments.models import Gateway, Order, OrderStatus other = _course(self.t, title="Hot", category=self.cat) other.publish() # Create 12 paid orders for `other` to push it above the bestseller threshold (10) from django.contrib.auth import get_user_model u_model = get_user_model() for i in range(12): stu = u_model.objects.create_user( phone_number=f"0911000{i:04d}", full_name=f"S{i}", role="student" ) Order.objects.create( student=stu, course=other, amount_toman=1000, gateway=Gateway.CONSOLE, status=OrderStatus.PAID, ) r = self.client.get("/api/courses/?ordering=-popularity") results = r.json()["results"] ids = [c["id"] for c in results] self.assertEqual(ids[0], other.id) # `other` should now be marked bestseller hot = next(c for c in results if c["id"] == other.id) self.assertTrue(hot["is_bestseller"]) def test_lesson_content_gated_unless_preview(self): sec = Section.objects.create(course=self.published, title="S1", order=0) free = Lesson.objects.create( section=sec, title="Free preview", kind=LessonKind.VIDEO, video_url="https://example.com/v.mp4", status=LessonStatus.PUBLISHED, is_preview=True, is_downloadable=True, order=0, ) locked = Lesson.objects.create( section=sec, title="Locked", kind=LessonKind.VIDEO, video_url="https://example.com/secret.mp4", status=LessonStatus.PUBLISHED, is_preview=False, order=1, ) r = self.client.get(f"/api/courses/{self.published.slug}/") sections = r.json()["sections"] lessons = sections[0]["lessons"] free_payload = next(l for l in lessons if l["id"] == free.id) locked_payload = next(l for l in lessons if l["id"] == locked.id) self.assertEqual(free_payload["video_url"], "https://example.com/v.mp4") self.assertTrue(free_payload["is_downloadable"]) self.assertIsNone(locked_payload["video_url"]) self.assertFalse(locked_payload["is_downloadable"]) def test_public_detail_includes_about_instructor_card(self): # set bio + headline + verified tp = self.t.teacher_profile tp.bio = "10 years of UX design" tp.headline = "Senior Product Designer" tp.is_verified = True tp.years_experience = 10 tp.save() r = self.client.get(f"/api/courses/{self.published.slug}/") self.assertEqual(r.status_code, 200) instr = r.json()["instructor"] self.assertEqual(instr["id"], self.t.id) self.assertEqual(instr["headline"], "Senior Product Designer") self.assertEqual(instr["bio"], "10 years of UX design") self.assertTrue(instr["is_verified"]) self.assertEqual(instr["years_experience"], 10) # --------------------------------------------------------------------------- instructor CRUD class InstructorCourseAPITests(TestCase): def setUp(self): self.client = APIClient() self.t = _make_teacher(phone="09120000001", name="Teacher One", approved=False) self.t_approved = _make_teacher(phone="09120000002", name="Approved One", approved=True) self.student = _make_student() def test_anon_blocked(self): r = self.client.get("/api/instructor/courses/") self.assertEqual(r.status_code, 401) def test_student_forbidden(self): _auth(self.client, self.student) r = self.client.get("/api/instructor/courses/") self.assertEqual(r.status_code, 403) def test_teacher_creates_course(self): _auth(self.client, self.t) r = self.client.post( "/api/instructor/courses/", {"title": "New", "tagline": "T", "description": "D", "level": "beginner", "language": "fa"}, format="json", ) self.assertEqual(r.status_code, 201, r.content) self.assertEqual(Course.objects.filter(instructor=self.t).count(), 1) def test_teacher_only_lists_own_courses(self): a = _course(self.t, title="Mine") b = _course(self.t_approved, title="Theirs") _auth(self.client, self.t) r = self.client.get("/api/instructor/courses/") self.assertEqual(r.status_code, 200) ids = [row["id"] for row in r.json()["results"]] self.assertIn(a.id, ids) self.assertNotIn(b.id, ids) def test_publish_requires_approved_profile(self): c = _course(self.t) # not approved _auth(self.client, self.t) r = self.client.post(f"/api/instructor/courses/{c.id}/publish/") self.assertEqual(r.status_code, 403) c.refresh_from_db() self.assertEqual(c.status, CourseStatus.DRAFT) def test_publish_succeeds_when_approved(self): c = _course(self.t_approved) _auth(self.client, self.t_approved) r = self.client.post(f"/api/instructor/courses/{c.id}/publish/") self.assertEqual(r.status_code, 200, r.content) c.refresh_from_db() self.assertEqual(c.status, CourseStatus.PUBLISHED) def test_unpublish(self): c = _course(self.t_approved) c.publish() _auth(self.client, self.t_approved) r = self.client.post(f"/api/instructor/courses/{c.id}/unpublish/") self.assertEqual(r.status_code, 200) c.refresh_from_db() self.assertEqual(c.status, CourseStatus.DRAFT) def test_cannot_modify_others_course(self): c = _course(self.t_approved, title="His") _auth(self.client, self.t) r = self.client.patch( f"/api/instructor/courses/{c.id}/", {"title": "Hijacked"}, format="json" ) # not visible in queryset → 404 self.assertIn(r.status_code, (403, 404)) # --------------------------------------------------------------------------- section + lesson class InstructorSectionLessonAPITests(TestCase): def setUp(self): self.client = APIClient() self.t = _make_teacher(approved=True) self.course = _course(self.t) _auth(self.client, self.t) def test_create_section_in_owned_course(self): r = self.client.post( "/api/instructor/sections/", {"course": self.course.id, "title": "S1", "order": 0}, format="json", ) self.assertEqual(r.status_code, 201, r.content) def test_cannot_create_section_in_others_course(self): other = _make_teacher(phone="09120000050", approved=True) other_course = _course(other) r = self.client.post( "/api/instructor/sections/", {"course": other_course.id, "title": "S1", "order": 0}, format="json", ) self.assertEqual(r.status_code, 403) def test_create_video_lesson_validates_video_url(self): sec = Section.objects.create(course=self.course, title="S1") r = self.client.post( "/api/instructor/lessons/", {"section": sec.id, "title": "L1", "kind": "video", "order": 0}, format="json", ) self.assertEqual(r.status_code, 400) self.assertIn("video_url", r.json()) def test_create_text_lesson_requires_body(self): sec = Section.objects.create(course=self.course, title="S1") r = self.client.post( "/api/instructor/lessons/", {"section": sec.id, "title": "L1", "kind": "text", "order": 0}, format="json", ) self.assertEqual(r.status_code, 400) self.assertIn("body", r.json()) def test_create_video_lesson_ok(self): sec = Section.objects.create(course=self.course, title="S1") r = self.client.post( "/api/instructor/lessons/", { "section": sec.id, "title": "L1", "kind": "video", "video_url": "https://example.com/v.mp4", "order": 0, }, format="json", ) self.assertEqual(r.status_code, 201, r.content) def test_reorder_sections(self): s1 = Section.objects.create(course=self.course, title="A", order=0) s2 = Section.objects.create(course=self.course, title="B", order=1) s3 = Section.objects.create(course=self.course, title="C", order=2) r = self.client.post( f"/api/instructor/courses/{self.course.id}/sections/reorder/", {"ids": [s3.id, s1.id, s2.id]}, format="json", ) self.assertEqual(r.status_code, 204, r.content) s3.refresh_from_db(); s1.refresh_from_db(); s2.refresh_from_db() self.assertEqual(s3.order, 0) self.assertEqual(s1.order, 1) self.assertEqual(s2.order, 2) def test_reorder_rejects_foreign_section_ids(self): s1 = Section.objects.create(course=self.course, title="A", order=0) other = _make_teacher(phone="09120000050", approved=True) other_course = _course(other) foreign = Section.objects.create(course=other_course, title="X", order=0) r = self.client.post( f"/api/instructor/courses/{self.course.id}/sections/reorder/", {"ids": [s1.id, foreign.id]}, format="json", ) self.assertEqual(r.status_code, 400) def test_reorder_lessons(self): sec = Section.objects.create(course=self.course, title="S1") l1 = Lesson.objects.create( section=sec, title="A", kind=LessonKind.VIDEO, video_url="https://e.com/a", order=0, ) l2 = Lesson.objects.create( section=sec, title="B", kind=LessonKind.VIDEO, video_url="https://e.com/b", order=1, ) l3 = Lesson.objects.create( section=sec, title="C", kind=LessonKind.VIDEO, video_url="https://e.com/c", order=2, ) r = self.client.post( f"/api/instructor/sections/{sec.id}/lessons/reorder/", {"ids": [l3.id, l1.id, l2.id]}, format="json", ) self.assertEqual(r.status_code, 204, r.content) l1.refresh_from_db(); l2.refresh_from_db(); l3.refresh_from_db() self.assertEqual([l3.order, l1.order, l2.order], [0, 1, 2]) # --------------------------------------------------------------------------- highlights + FAQs class HighlightFAQAPITests(TestCase): def setUp(self): self.client = APIClient() self.t = _make_teacher(approved=True) self.course = _course(self.t) _auth(self.client, self.t) def test_create_highlight(self): r = self.client.post( "/api/instructor/highlights/", {"course": self.course.id, "text": "First highlight", "order": 0}, format="json", ) self.assertEqual(r.status_code, 201, r.content) self.assertEqual(self.course.highlights.count(), 1) def test_cannot_create_highlight_in_others_course(self): other = _make_teacher(phone="09120000050", approved=True) other_course = _course(other) r = self.client.post( "/api/instructor/highlights/", {"course": other_course.id, "text": "Stolen", "order": 0}, format="json", ) self.assertEqual(r.status_code, 403) def test_highlight_list_filtered_by_course(self): other_course = _course(self.t, title="Other") a = CourseHighlight.objects.create(course=self.course, text="X", order=0) b = CourseHighlight.objects.create(course=other_course, text="Y", order=0) r = self.client.get(f"/api/instructor/highlights/?course={self.course.id}") self.assertEqual(r.status_code, 200) ids = [row["id"] for row in r.json()["results"]] self.assertIn(a.id, ids) self.assertNotIn(b.id, ids) def test_create_faq(self): r = self.client.post( "/api/instructor/faqs/", {"course": self.course.id, "question": "Q?", "answer": "A.", "order": 0}, format="json", ) self.assertEqual(r.status_code, 201, r.content) self.assertEqual(self.course.faqs.count(), 1) def test_faq_appears_in_public_detail(self): self.course.publish() CourseFAQ.objects.create(course=self.course, question="Q", answer="A", order=0) unauth = APIClient() r = unauth.get(f"/api/courses/{self.course.slug}/") self.assertEqual(r.status_code, 200) faqs = r.json()["faqs"] self.assertEqual(len(faqs), 1) self.assertEqual(faqs[0]["question"], "Q") # --------------------------------------------------------------------------- instructor filters class InstructorFilterTests(TestCase): def setUp(self): self.client = APIClient() self.t = _make_teacher(approved=True) _auth(self.client, self.t) self.draft = _course(self.t, title="Draft One") self.pub = _course(self.t, title="Pub One") self.pub.publish() def test_instructor_courses_filter_by_status(self): r = self.client.get("/api/instructor/courses/?status=published") ids = [row["id"] for row in r.json()["results"]] self.assertIn(self.pub.id, ids) self.assertNotIn(self.draft.id, ids) r = self.client.get("/api/instructor/courses/?status=draft") ids = [row["id"] for row in r.json()["results"]] self.assertIn(self.draft.id, ids) self.assertNotIn(self.pub.id, ids) def test_instructor_sections_filter_by_course(self): s_a = Section.objects.create(course=self.draft, title="A") s_b = Section.objects.create(course=self.pub, title="B") r = self.client.get(f"/api/instructor/sections/?course={self.draft.id}") ids = [row["id"] for row in r.json()["results"]] self.assertIn(s_a.id, ids) self.assertNotIn(s_b.id, ids) def test_instructor_lessons_filter_by_section(self): sec_a = Section.objects.create(course=self.draft, title="A") sec_b = Section.objects.create(course=self.draft, title="B") la = Lesson.objects.create( section=sec_a, title="LA", kind=LessonKind.VIDEO, video_url="https://e.com/la", order=0, ) lb = Lesson.objects.create( section=sec_b, title="LB", kind=LessonKind.VIDEO, video_url="https://e.com/lb", order=0, ) r = self.client.get(f"/api/instructor/lessons/?section={sec_a.id}") ids = [row["id"] for row in r.json()["results"]] self.assertIn(la.id, ids) self.assertNotIn(lb.id, ids)