from datetime import timedelta from django.contrib.auth.models import AnonymousUser from django.test import TestCase from django.utils import timezone from labs.billing.user import INITIAL_FREE_CREDITS_AMOUNT from labs.exams.exam import Exam as DomainExam from labs.exams.exceptions import ExamRedeemError, ExamStartError from labs.models import CreditTransaction, ExamAttempt, UserExamRedemption from labs.tests.exams._helpers import create_exam_with_variant, create_user class TestExamRules(TestCase): def test_can_start_requires_login(self): exam, _variant = create_exam_with_variant(credits=0) result = DomainExam(exam, AnonymousUser()).can_start() self.assertFalse(result.eligible) self.assertEqual(result.message, "You must be logged in to start an exam.") def test_can_start_disallowed_when_already_passed(self): user = create_user(email="passed@example.com") exam, variant = create_exam_with_variant(credits=0) now = timezone.now() ExamAttempt.objects.create( user=user, exam=exam, variant=variant, language_code="en", started_at=now - timedelta(minutes=1), submitted_at=now, expires_at=now + timedelta(minutes=59), status=ExamAttempt.Status.COMPLETED, score=1, is_passed=True, ) result = DomainExam(exam, user).can_start() self.assertFalse(result.eligible) self.assertEqual(result.message, "You have already passed this exam.") def test_can_start_disallowed_when_active_attempt_exists(self): user = create_user(email="active@example.com") blocking_exam, blocking_variant = create_exam_with_variant(code="blocking", credits=0) now = timezone.now() ExamAttempt.objects.create( user=user, exam=blocking_exam, variant=blocking_variant, language_code="en", started_at=now - timedelta(minutes=1), expires_at=now + timedelta(minutes=59), status=ExamAttempt.Status.IN_PROGRESS, ) exam, _variant = create_exam_with_variant(code="target", credits=0) result = DomainExam(exam, user).can_start() self.assertFalse(result.eligible) self.assertEqual( result.message, "You already have an active exam in progress, please finish it first.", ) def test_can_start_shows_requires_redemption_when_active_attempt_exists(self): """User with active exam should still see correct requires_redemption status.""" user = create_user(email="active-paid@example.com") blocking_exam, blocking_variant = create_exam_with_variant(code="blocking", credits=0) now = timezone.now() ExamAttempt.objects.create( user=user, exam=blocking_exam, variant=blocking_variant, language_code="en", started_at=now - timedelta(minutes=1), expires_at=now + timedelta(minutes=59), status=ExamAttempt.Status.IN_PROGRESS, ) # Target exam costs credits and user has no redemption exam, _variant = create_exam_with_variant(code="target-paid", credits=10) result = DomainExam(exam, user).can_start() self.assertFalse(result.eligible) self.assertTrue(result.requires_redemption) self.assertEqual( result.message, "You already have an active exam in progress, please finish it first.", ) def test_can_start_shows_no_redemption_needed_when_active_attempt_exists_but_redeemed(self): """User with active exam and existing redemption should see requires_redemption=False.""" user = create_user(email="active-redeemed@example.com") blocking_exam, blocking_variant = create_exam_with_variant(code="blocking", credits=0) now = timezone.now() ExamAttempt.objects.create( user=user, exam=blocking_exam, variant=blocking_variant, language_code="en", started_at=now - timedelta(minutes=1), expires_at=now + timedelta(minutes=59), status=ExamAttempt.Status.IN_PROGRESS, ) # Target exam costs credits but user has already redeemed exam, _variant = create_exam_with_variant(code="target-redeemed", credits=10) UserExamRedemption.objects.create( user=user, exam=exam, redeem_detail={"credits": 10}, ) result = DomainExam(exam, user).can_start() self.assertFalse(result.eligible) self.assertFalse(result.requires_redemption) self.assertEqual( result.message, "You already have an active exam in progress, please finish it first.", ) def test_can_start_pingcap_employee_bypasses_employee_only_and_credits(self): user = create_user(email="employee@pingcap.com") exam, _variant = create_exam_with_variant(credits=10, employee_only=True) result = DomainExam(exam, user).can_start() self.assertTrue(result.eligible) self.assertFalse(result.requires_redemption) def test_can_start_employee_only_disallowed_for_non_employee(self): user = create_user(email="non-employee@example.com") exam, _variant = create_exam_with_variant(employee_only=True) result = DomainExam(exam, user).can_start() self.assertFalse(result.eligible) self.assertEqual(result.message, "This exam is for PingCAP employees only.") def test_can_start_employee_only_paid_exam_no_redemption_for_non_employee(self): """Non-employee should not see requires_redemption for employee-only paid exam.""" user = create_user(email="non-employee-paid@example.com") exam, _variant = create_exam_with_variant(credits=10, employee_only=True) result = DomainExam(exam, user).can_start() self.assertFalse(result.eligible) self.assertFalse(result.requires_redemption) self.assertEqual(result.message, "This exam is for PingCAP employees only.") def test_can_start_paid_exam_requires_redemption(self): user = create_user(email="paid@example.com") exam, _variant = create_exam_with_variant(credits=1) result = DomainExam(exam, user).can_start() self.assertFalse(result.eligible) self.assertTrue(result.requires_redemption) self.assertEqual(result.message, "Redeem with credits to start this exam.") def test_can_start_paid_exam_allowed_with_unused_redemption(self): user = create_user(email="redeemed@example.com") exam, _variant = create_exam_with_variant(credits=1) UserExamRedemption.objects.create( user=user, exam=exam, redeem_detail={"credits": 1}, ) result = DomainExam(exam, user).can_start() self.assertTrue(result.eligible) self.assertFalse(result.requires_redemption) def test_redeem_success_deducts_credits(self): user = create_user(email="redeem-success@example.com") exam, _variant = create_exam_with_variant(credits=1) DomainExam(exam, user).redeem() self.assertEqual( UserExamRedemption.objects.filter( user=user, exam=exam, exam_attempt__isnull=True ).count(), 1, ) self.assertEqual( CreditTransaction.objects.filter( user=user, transaction_type=CreditTransaction.TransactionType.REDEEM_EXAM, ).count(), 1, ) user.credit_summary.refresh_from_db() self.assertEqual( user.credit_summary.free_credits, INITIAL_FREE_CREDITS_AMOUNT - 1, ) def test_redeem_fails_when_insufficient_credits(self): user = create_user(email="redeem-insufficient@example.com") exam, _variant = create_exam_with_variant( credits=INITIAL_FREE_CREDITS_AMOUNT + 1 ) with self.assertRaises(ExamRedeemError) as ctx: DomainExam(exam, user).redeem() self.assertEqual( str(ctx.exception), "You don't have enough credits to redeem this exam.", ) self.assertEqual(UserExamRedemption.objects.filter(user=user, exam=exam).count(), 0) self.assertEqual( CreditTransaction.objects.filter( user=user, transaction_type=CreditTransaction.TransactionType.REDEEM_EXAM, ).count(), 0, ) user.credit_summary.refresh_from_db() self.assertEqual(user.credit_summary.free_credits, INITIAL_FREE_CREDITS_AMOUNT) def test_start_exam_requires_redemption_for_paid_exam(self): user = create_user(email="start-paid@example.com") exam, variant = create_exam_with_variant(credits=1) with self.assertRaises(ExamStartError) as ctx: DomainExam(exam, user).start_exam(variant) self.assertEqual(str(ctx.exception), "Redeem with credits to start this exam.") def test_start_exam_binds_redemption(self): user = create_user(email="start-bind@example.com") exam, variant = create_exam_with_variant(credits=1) redemption = UserExamRedemption.objects.create( user=user, exam=exam, redeem_detail={"credits": 1}, ) attempt = DomainExam(exam, user).start_exam(variant) redemption.refresh_from_db() self.assertEqual(redemption.exam_attempt_id, attempt.db_exam_attempt.id) def test_start_exam_twice_fails(self): user = create_user(email="start-twice@example.com") exam, variant = create_exam_with_variant(credits=0) DomainExam(exam, user).start_exam(variant) with self.assertRaises(ExamStartError) as ctx: DomainExam(exam, user).start_exam(variant) self.assertEqual( str(ctx.exception), "You already have an active exam in progress, please finish it first.", )