You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
296 lines
13 KiB
296 lines
13 KiB
from django.test import TestCase
|
|
from django.contrib.auth import get_user_model
|
|
from rest_framework.test import APIClient
|
|
from rest_framework import status
|
|
|
|
from apps.profiles.models import Institution
|
|
from apps.lms.models import (
|
|
Course,
|
|
CourseModule,
|
|
Lesson,
|
|
QuizQuestion,
|
|
CourseEnrollment,
|
|
LessonProgress,
|
|
DigitalCertificate,
|
|
CourseCategory,
|
|
CourseLevel,
|
|
CourseLanguage,
|
|
)
|
|
|
|
User = get_user_model()
|
|
|
|
|
|
class Phase8LMSAPITests(TestCase):
|
|
"""
|
|
Automated test suite for Phase 8:
|
|
- Course Catalog & Multi-Facet Filtering
|
|
- Course Creation & Detail Retrieval (with syllabus)
|
|
- Course Enrollment Flow
|
|
- Lesson Details & Content
|
|
- Lesson Completion & Progress Recalculation
|
|
- Interactive Quiz Submission & Grading
|
|
- Automatic Certificate Generation on 100% Course Completion
|
|
- Public QR-Code Certificate Verification
|
|
- User Certificates Listing
|
|
"""
|
|
|
|
def setUp(self):
|
|
self.client = APIClient()
|
|
|
|
# Users
|
|
self.admin_user = User.objects.create_user(
|
|
email="[email protected]",
|
|
password="AdminPassword123!",
|
|
fullname="Dr. Mohammad Razavi",
|
|
user_type=User.UserType.SUPER_ADMIN,
|
|
is_staff=True,
|
|
)
|
|
self.student_user = User.objects.create_user(
|
|
email="[email protected]",
|
|
password="StudentPassword123!",
|
|
fullname="Ali Rezaei",
|
|
user_type=User.UserType.STUDENT,
|
|
)
|
|
|
|
# Partner Institution
|
|
self.institution = Institution.objects.create(
|
|
name="Razavi International Islamic Academy",
|
|
country="Iran",
|
|
city="Mashhad",
|
|
type=Institution.InstitutionType.INSTITUTE,
|
|
)
|
|
|
|
# Create Course
|
|
self.course = Course.objects.create(
|
|
title="Strategic Cultural Diplomacy in the Muslim World",
|
|
category=CourseCategory.CULTURAL_DIPLOMACY,
|
|
language=CourseLanguage.EN,
|
|
level=CourseLevel.INTERMEDIATE,
|
|
instructor_name="Prof. Seyed Hassan",
|
|
instructor_title="Senior Scholar in International Relations",
|
|
institution=self.institution,
|
|
organization_name="Razavi Academy",
|
|
description="Comprehensive curriculum on international communication, interfaith diplomacy, and center leadership.",
|
|
duration_hours=12.5,
|
|
rating=4.9,
|
|
reviews_count=24,
|
|
is_published=True,
|
|
is_featured=True,
|
|
created_by=self.admin_user,
|
|
)
|
|
|
|
# Module 1
|
|
self.module1 = CourseModule.objects.create(
|
|
course=self.course,
|
|
title="Foundations of Cultural Dialogue",
|
|
description="Historical and theological principles of dialogue.",
|
|
order=0,
|
|
)
|
|
# Lesson 1 (Reading)
|
|
self.lesson1 = Lesson.objects.create(
|
|
module=self.module1,
|
|
title="Historical Overview of Razavi Discourse",
|
|
lesson_type=Lesson.LessonType.READING,
|
|
duration_minutes=25,
|
|
content="# Razavi Discourse\n\nPrinciples of rational dialogue with scholars of other traditions.",
|
|
order=0,
|
|
is_preview=True,
|
|
)
|
|
# Lesson 2 (Quiz)
|
|
self.lesson2 = Lesson.objects.create(
|
|
module=self.module1,
|
|
title="Module 1 Knowledge Assessment",
|
|
lesson_type=Lesson.LessonType.QUIZ,
|
|
duration_minutes=15,
|
|
order=1,
|
|
is_preview=False,
|
|
)
|
|
# Quiz Questions for Lesson 2
|
|
self.q1 = QuizQuestion.objects.create(
|
|
lesson=self.lesson2,
|
|
question_text="What was a core characteristic of Imam Reza's (AS) debates in Merv?",
|
|
options=[
|
|
"Use of shared textual references and rational evidence",
|
|
"Dismissal of opposing perspectives without debate",
|
|
"Limiting discussions exclusively to one language",
|
|
"Avoiding dialogue with other faith traditions"
|
|
],
|
|
correct_option_index=0,
|
|
explanation="The Imam engaged scholars using their own scriptures and universal rational arguments.",
|
|
order=0,
|
|
)
|
|
self.q2 = QuizQuestion.objects.create(
|
|
lesson=self.lesson2,
|
|
question_text="In modern cultural diplomacy, what is primary objective of inter-center networking?",
|
|
options=[
|
|
"Encouraging bilateral isolationism",
|
|
"Collaborative synergy, knowledge sharing, and mutual support",
|
|
"Eliminating local community autonomy",
|
|
"Replacing all regional cultural expressions"
|
|
],
|
|
correct_option_index=1,
|
|
explanation="Synergy and cooperation empower global communities while preserving local identity.",
|
|
order=1,
|
|
)
|
|
|
|
def test_course_list_and_filters(self):
|
|
# 1. General list
|
|
response = self.client.get("/api/v1/lms/courses/")
|
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
self.assertEqual(len(response.data), 1)
|
|
self.assertEqual(response.data[0]["title"], self.course.title)
|
|
self.assertEqual(response.data[0]["total_lessons"], 2)
|
|
|
|
# 2. Filter by category
|
|
res_cat = self.client.get(f"/api/v1/lms/courses/?category={CourseCategory.CULTURAL_DIPLOMACY}")
|
|
self.assertEqual(res_cat.status_code, status.HTTP_200_OK)
|
|
self.assertEqual(len(res_cat.data), 1)
|
|
|
|
# 3. Filter by non-matching category
|
|
res_empty = self.client.get(f"/api/v1/lms/courses/?category={CourseCategory.SPECIALIZED_LANGUAGES}")
|
|
self.assertEqual(res_empty.status_code, status.HTTP_200_OK)
|
|
self.assertEqual(len(res_empty.data), 0)
|
|
|
|
# 4. Search query
|
|
res_search = self.client.get("/api/v1/lms/courses/?search=Diplomacy")
|
|
self.assertEqual(res_search.status_code, status.HTTP_200_OK)
|
|
self.assertEqual(len(res_search.data), 1)
|
|
|
|
def test_course_create_authorized(self):
|
|
self.client.force_authenticate(user=self.admin_user)
|
|
payload = {
|
|
"title": "Modern Mosque & Center Administration",
|
|
"category": "center_management",
|
|
"language": "en",
|
|
"level": "introductory",
|
|
"instructor_name": "Dr. Ali Reza",
|
|
"description": "Organizational governance, financial sustainability, and youth engagement programs.",
|
|
"duration_hours": 8.0,
|
|
"organization_name": "Razavi Academy",
|
|
"is_published": True,
|
|
"is_featured": False,
|
|
}
|
|
response = self.client.post("/api/v1/lms/courses/", payload, format="json")
|
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
|
self.assertEqual(response.data["title"], "Modern Mosque & Center Administration")
|
|
self.assertTrue(Course.objects.filter(title="Modern Mosque & Center Administration").exists())
|
|
|
|
def test_course_detail_retrieval(self):
|
|
response = self.client.get(f"/api/v1/lms/courses/{self.course.slug}/")
|
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
self.assertEqual(response.data["id"], self.course.id)
|
|
self.assertEqual(len(response.data["modules"]), 1)
|
|
self.assertEqual(response.data["modules"][0]["title"], "Foundations of Cultural Dialogue")
|
|
self.assertEqual(len(response.data["modules"][0]["lessons"]), 2)
|
|
self.assertFalse(response.data["is_enrolled"])
|
|
|
|
def test_course_enrollment_flow(self):
|
|
self.client.force_authenticate(user=self.student_user)
|
|
|
|
# 1. Enroll
|
|
response = self.client.post(f"/api/v1/lms/courses/{self.course.id}/enroll/")
|
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
|
self.assertEqual(response.data["course_id"], self.course.id)
|
|
self.assertEqual(response.data["progress_percentage"], 0)
|
|
self.assertTrue(CourseEnrollment.objects.filter(user=self.student_user, course=self.course).exists())
|
|
|
|
# 2. Duplicate enroll returns 200
|
|
response_dup = self.client.post(f"/api/v1/lms/courses/{self.course.id}/enroll/")
|
|
self.assertEqual(response_dup.status_code, status.HTTP_200_OK)
|
|
|
|
# 3. Check course detail shows is_enrolled=True
|
|
detail_res = self.client.get(f"/api/v1/lms/courses/{self.course.id}/")
|
|
self.assertEqual(detail_res.status_code, status.HTTP_200_OK)
|
|
self.assertTrue(detail_res.data["is_enrolled"])
|
|
|
|
def test_lesson_detail_view(self):
|
|
# Unauthenticated student view of preview lesson
|
|
response = self.client.get(f"/api/v1/lms/lessons/{self.lesson1.id}/")
|
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
self.assertEqual(response.data["title"], self.lesson1.title)
|
|
self.assertIn("# Razavi Discourse", response.data["content"])
|
|
|
|
# Quiz lesson view
|
|
res_quiz = self.client.get(f"/api/v1/lms/lessons/{self.lesson2.id}/")
|
|
self.assertEqual(res_quiz.status_code, status.HTTP_200_OK)
|
|
self.assertEqual(len(res_quiz.data["quiz_questions"]), 2)
|
|
# Student view shouldn't leak correct_option_index
|
|
self.assertNotIn("correct_option_index", res_quiz.data["quiz_questions"][0])
|
|
|
|
def test_lesson_completion_and_progress_recalculation(self):
|
|
self.client.force_authenticate(user=self.student_user)
|
|
|
|
# Complete Lesson 1
|
|
response = self.client.post(f"/api/v1/lms/lessons/{self.lesson1.id}/complete/")
|
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
self.assertTrue(response.data["is_completed"])
|
|
# Course has 2 lessons -> 1 completed = 50%
|
|
self.assertEqual(response.data["course_progress_percentage"], 50)
|
|
|
|
# Verify progress endpoint
|
|
prog_res = self.client.get(f"/api/v1/lms/courses/{self.course.id}/progress/")
|
|
self.assertEqual(prog_res.status_code, status.HTTP_200_OK)
|
|
self.assertEqual(prog_res.data["progress_percentage"], 50)
|
|
self.assertEqual(prog_res.data["completed_lessons_count"], 1)
|
|
self.assertEqual(prog_res.data["total_lessons_count"], 2)
|
|
self.assertFalse(prog_res.data["is_completed"])
|
|
|
|
def test_quiz_submission_scoring_and_certificate_issuance(self):
|
|
self.client.force_authenticate(user=self.student_user)
|
|
|
|
# 1. Complete Lesson 1 first (50%)
|
|
self.client.post(f"/api/v1/lms/lessons/{self.lesson1.id}/complete/")
|
|
|
|
# 2. Submit FAILING quiz (1 wrong, 1 correct -> 50% < 70%)
|
|
fail_payload = {
|
|
"answers": [
|
|
{"question_id": self.q1.id, "selected_option": 0}, # Correct
|
|
{"question_id": self.q2.id, "selected_option": 0}, # Wrong
|
|
]
|
|
}
|
|
fail_res = self.client.post(f"/api/v1/lms/lessons/{self.lesson2.id}/submit-quiz/", fail_payload, format="json")
|
|
self.assertEqual(fail_res.status_code, status.HTTP_200_OK)
|
|
self.assertEqual(fail_res.data["score"], 50.0)
|
|
self.assertFalse(fail_res.data["passed"])
|
|
self.assertFalse(fail_res.data["is_lesson_completed"])
|
|
self.assertEqual(fail_res.data["course_progress_percentage"], 50)
|
|
|
|
# 3. Submit PASSING quiz (2 correct -> 100% >= 70%)
|
|
pass_payload = {
|
|
"answers": [
|
|
{"question_id": self.q1.id, "selected_option": 0}, # Correct
|
|
{"question_id": self.q2.id, "selected_option": 1}, # Correct
|
|
]
|
|
}
|
|
pass_res = self.client.post(f"/api/v1/lms/lessons/{self.lesson2.id}/submit-quiz/", pass_payload, format="json")
|
|
self.assertEqual(pass_res.status_code, status.HTTP_200_OK)
|
|
self.assertEqual(pass_res.data["score"], 100.0)
|
|
self.assertTrue(pass_res.data["passed"])
|
|
self.assertTrue(pass_res.data["is_lesson_completed"])
|
|
# All 2 lessons completed -> 100% course progress
|
|
self.assertEqual(pass_res.data["course_progress_percentage"], 100)
|
|
self.assertIsNotNone(pass_res.data["certificate_code"])
|
|
|
|
cert_code = pass_res.data["certificate_code"]
|
|
self.assertTrue(cert_code.startswith("CERT-RAZAVI-"))
|
|
|
|
# 4. Check DigitalCertificate model persisted
|
|
cert = DigitalCertificate.objects.get(certificate_code=cert_code)
|
|
self.assertEqual(cert.user, self.student_user)
|
|
self.assertEqual(cert.course, self.course)
|
|
self.assertEqual(cert.final_score, 100.0)
|
|
|
|
# 5. Verify via Public Verification Endpoint (No auth required)
|
|
anon_client = APIClient()
|
|
verify_res = anon_client.get(f"/api/v1/lms/certificates/{cert_code}/")
|
|
self.assertEqual(verify_res.status_code, status.HTTP_200_OK)
|
|
self.assertTrue(verify_res.data["is_valid"])
|
|
self.assertEqual(verify_res.data["recipient_name"], "Ali Rezaei")
|
|
self.assertEqual(verify_res.data["course_title"], self.course.title)
|
|
self.assertEqual(verify_res.data["final_score"], 100.0)
|
|
|
|
# 6. Verify via User Certificates Endpoint
|
|
my_certs_res = self.client.get("/api/v1/lms/my-certificates/")
|
|
self.assertEqual(my_certs_res.status_code, status.HTTP_200_OK)
|
|
self.assertEqual(len(my_certs_res.data), 1)
|
|
self.assertEqual(my_certs_res.data[0]["certificate_code"], cert_code)
|