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

506 lines
20 KiB
Python

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)