from datetime import timedelta from rest_framework import status from rest_framework.test import APITestCase, APIClient from django.utils import timezone from labs.billing.user import INITIAL_FREE_CREDITS_AMOUNT from labs.models import ( User, Exam, ExamVariant, ExamSection, ExamQuestion, ExamAttempt, UserExamRedemption, CreditTransaction, ) from labs.exams.attempt import ExamAttempt as DomainExamAttempt class ExamRedeemFlowTests(APITestCase): def setUp(self): self.client = APIClient() self.user = User.objects.create_user(email="test@test.com") self.client.force_login(self.user) def test_redeem_success(self): exam = Exam.objects.create(code="exam_paid", credits=1, published=True) resp = self.client.post(f"/api/exams/{exam.id}/redeem") self.assertEqual(resp.status_code, status.HTTP_204_NO_CONTENT) self.assertEqual( UserExamRedemption.objects.filter( user=self.user, exam=exam, exam_attempt__isnull=True, ).count(), 1, ) self.assertEqual( CreditTransaction.objects.filter( user=self.user, transaction_type=CreditTransaction.TransactionType.REDEEM_EXAM, ).count(), 1, ) self.user.credit_summary.refresh_from_db() self.assertEqual( self.user.credit_summary.free_credits, INITIAL_FREE_CREDITS_AMOUNT - 1, ) def test_redeem_fails_when_insufficient_credits(self): exam = Exam.objects.create( code="exam_expensive", credits=INITIAL_FREE_CREDITS_AMOUNT + 1, published=True, ) resp = self.client.post(f"/api/exams/{exam.id}/redeem") self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual( resp.data["non_field_errors"], ["You don't have enough credits to redeem this exam."], ) self.user.credit_summary.refresh_from_db() self.assertEqual(self.user.credit_summary.free_credits, INITIAL_FREE_CREDITS_AMOUNT) self.assertEqual(self.user.credit_summary.paid_credits, 0) self.assertEqual(UserExamRedemption.objects.filter(user=self.user).count(), 0) self.assertEqual( CreditTransaction.objects.filter( user=self.user, transaction_type=CreditTransaction.TransactionType.REDEEM_EXAM, ).count(), 0, ) def test_redeem_then_start_consumes_redemption(self): exam = Exam.objects.create(code="exam_001", credits=1, published=True) variant = ExamVariant.objects.create( exam=exam, language_code="en", title="Exam 001 (EN)", duration_seconds=3600, pass_score=0, total_score=0, ) resp = self.client.post(f"/api/exams/{exam.id}/redeem") self.assertEqual(resp.status_code, status.HTTP_204_NO_CONTENT) redemption = UserExamRedemption.objects.get( user=self.user, exam=exam, exam_attempt__isnull=True, ) self.assertIsNone(redemption.exam_attempt_id) resp = self.client.post("/api/exams/attempts", data={"variant_id": variant.id}) self.assertEqual(resp.status_code, status.HTTP_200_OK) attempt_id = resp.data["id"] redemption.refresh_from_db() self.assertEqual(redemption.exam_attempt_id, attempt_id) def test_start_without_redeem_fails(self): exam = Exam.objects.create(code="exam_001", credits=1, published=True) variant = ExamVariant.objects.create( exam=exam, language_code="en", title="Exam 001 (EN)", duration_seconds=3600, pass_score=0, total_score=0, ) resp = self.client.post("/api/exams/attempts", data={"variant_id": variant.id}) self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual( resp.data["non_field_errors"], ["Redeem with credits to start this exam."], ) self.assertEqual(ExamAttempt.objects.filter(user=self.user).count(), 0) def test_redeem_disallowed_when_redemption_not_required(self): exam = Exam.objects.create(code="exam_free", credits=0, published=True) resp = self.client.post(f"/api/exams/{exam.id}/redeem") self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual( resp.data["non_field_errors"], ["You can start this exam now without redeeming."], ) def test_redeem_employee_only_forbidden_for_non_employee(self): exam = Exam.objects.create( code="employee_paid", credits=1, employee_only=True, published=True ) resp = self.client.post(f"/api/exams/{exam.id}/redeem") self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(resp.data["detail"], "This exam is for employees only.") self.assertEqual(UserExamRedemption.objects.filter(user=self.user, exam=exam).count(), 0) class ExamAnswerValidationTests(APITestCase): def setUp(self): self.client = APIClient() self.user = User.objects.create_user(email="test-answer@test.com") self.client.force_login(self.user) self.exam = Exam.objects.create(code="exam_answer", credits=0) self.variant = ExamVariant.objects.create( exam=self.exam, language_code="en", title="Exam Answer (EN)", duration_seconds=3600, pass_score=0, total_score=0, ) self.section = ExamSection.objects.create( variant=self.variant, title="Section 1", section_type=ExamSection.SectionType.QUESTION, content={}, display_order=0, ) attempt = DomainExamAttempt.create( user=self.user, exam=self.exam, variant=self.variant ) self.attempt_id = attempt.db_exam_attempt.id self.section_id = self.section.id def _url(self, question_id: int) -> str: return ( f"/api/exams/attempts/{self.attempt_id}/sections/" f"{self.section_id}/questions/{question_id}" ) def test_answer_must_be_list(self): q = ExamQuestion.objects.create( variant=self.variant, section=self.section, score=1, question_type=ExamQuestion.QuestionType.SINGLE_CHOICE, stem="Q1", content=[{"id": "A", "text": "A"}, {"id": "B", "text": "B"}], answer=["A"], display_order=0, ) resp = self.client.put(self._url(q.id), data={"answer": "A"}, format="json") self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn("answer", resp.data) def test_answer_items_must_be_scalar(self): q = ExamQuestion.objects.create( variant=self.variant, section=self.section, score=1, question_type=ExamQuestion.QuestionType.SINGLE_CHOICE, stem="Q1", content=[{"id": "A", "text": "A"}, {"id": "B", "text": "B"}], answer=["A"], display_order=0, ) resp = self.client.put( self._url(q.id), data={"answer": [{"id": "A"}]}, format="json" ) self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn("answer", resp.data) def test_answer_items_rejects_bool(self): q = ExamQuestion.objects.create( variant=self.variant, section=self.section, score=1, question_type=ExamQuestion.QuestionType.SINGLE_CHOICE, stem="Q1", content=[{"id": "A", "text": "A"}, {"id": "B", "text": "B"}], answer=["A"], display_order=0, ) resp = self.client.put(self._url(q.id), data={"answer": [True]}, format="json") self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(resp.data["answer"], ["Answer items must be integers or strings."]) def test_duplicate_answers_rejected(self): q = ExamQuestion.objects.create( variant=self.variant, section=self.section, score=1, question_type=ExamQuestion.QuestionType.SINGLE_CHOICE, stem="Q1", content=[{"id": "A", "text": "A"}, {"id": "B", "text": "B"}], answer=["A"], display_order=0, ) resp = self.client.put( self._url(q.id), data={"answer": ["A", "A"]}, format="json" ) self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(resp.data["answer"], ["Duplicate answers are not allowed."]) def test_invalid_option_id_rejected(self): q = ExamQuestion.objects.create( variant=self.variant, section=self.section, score=1, question_type=ExamQuestion.QuestionType.MULTIPLE_CHOICE, stem="Q1", content=[{"id": "A", "text": "A"}, {"id": "B", "text": "B"}], answer=["A"], display_order=0, ) resp = self.client.put(self._url(q.id), data={"answer": ["Z"]}, format="json") self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn("answer", resp.data) def test_single_choice_allows_at_most_one(self): q = ExamQuestion.objects.create( variant=self.variant, section=self.section, score=1, question_type=ExamQuestion.QuestionType.SINGLE_CHOICE, stem="Q1", content=[{"id": "A", "text": "A"}, {"id": "B", "text": "B"}], answer=["A"], display_order=0, ) resp = self.client.put( self._url(q.id), data={"answer": ["A", "B"]}, format="json" ) self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn("answer", resp.data) def test_multiple_choice_allows_multiple(self): q = ExamQuestion.objects.create( variant=self.variant, section=self.section, score=1, question_type=ExamQuestion.QuestionType.MULTIPLE_CHOICE, stem="Q1", content=[ {"id": "A", "text": "A"}, {"id": "B", "text": "B"}, {"id": "C", "text": "C"}, ], answer=["A", "C"], display_order=0, ) resp = self.client.put( self._url(q.id), data={"answer": ["A", "C"]}, format="json" ) self.assertEqual(resp.status_code, status.HTTP_204_NO_CONTENT) def test_empty_answer_clears_is_answered(self): q = ExamQuestion.objects.create( variant=self.variant, section=self.section, score=1, question_type=ExamQuestion.QuestionType.SINGLE_CHOICE, stem="Q1", content=[{"id": "A", "text": "A"}, {"id": "B", "text": "B"}], answer=["A"], display_order=0, ) resp = self.client.put(self._url(q.id), data={"answer": []}, format="json") self.assertEqual(resp.status_code, status.HTTP_204_NO_CONTENT) from labs.models import ExamAttemptAnswer a = ExamAttemptAnswer.objects.get( attempt_id=self.attempt_id, attempt_section__section_id=self.section_id, question=q ) self.assertEqual(a.answer, []) self.assertFalse(a.is_answered) def test_question_not_found_returns_404(self): resp = self.client.put(self._url(9999999), data={"answer": []}, format="json") self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)