506 lines
20 KiB
Python
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)
|