from datetime import timedelta from unittest.mock import patch from django.test import TestCase from django.utils import timezone from labs.tasks.exam import auto_submit_expired_exam_attempts from labs.models import ExamAttempt as DBExamAttempt from labs.models import ExamAttemptSection, ExamSection, ExamQuestion from labs.tests.exams._helpers import ( create_attempt, create_exam_with_variant, create_question, create_section, create_user, ) class TestExamAutoSubmitTask(TestCase): def test_auto_submit_only_processes_expired_unsubmitted_attempts(self): user = create_user(email="auto-submit@example.com") exam, variant = create_exam_with_variant(duration_seconds=10, pass_score=1) section = create_section( variant=variant, section_type=ExamSection.SectionType.QUESTION, display_order=0, title="Questions", content={}, ) q = create_question( variant=variant, section=section, question_type=ExamQuestion.QuestionType.SINGLE_CHOICE, display_order=0, score=1, options=[{"id": "A", "text": "A"}, {"id": "B", "text": "B"}], answer=["A"], ) attempt = create_attempt(user=user, exam=exam, variant=variant) attempt.db_exam_attempt.expires_at = timezone.now() - timedelta(seconds=1) attempt.db_exam_attempt.save(update_fields=["expires_at"]) attempt_section = ExamAttemptSection.objects.select_related("section").get( attempt=attempt.db_exam_attempt, section=section ) handler = attempt.get_section_handler(attempt_section) handler.submit_answer(q.id, ["A"]) auto_submit_expired_exam_attempts() attempt.db_exam_attempt.refresh_from_db() self.assertIsNotNone(attempt.db_exam_attempt.submitted_at) self.assertEqual(attempt.db_exam_attempt.status, attempt.db_exam_attempt.Status.EXPIRED) self.assertEqual(attempt.db_exam_attempt.score, 1) def test_auto_submit_is_idempotent(self): user = create_user(email="auto-submit-idem@example.com") exam, variant = create_exam_with_variant(duration_seconds=10, pass_score=1) attempt = create_attempt(user=user, exam=exam, variant=variant) attempt.db_exam_attempt.expires_at = timezone.now() - timedelta(seconds=1) attempt.db_exam_attempt.save(update_fields=["expires_at"]) auto_submit_expired_exam_attempts() attempt.db_exam_attempt.refresh_from_db() first_submitted_at = attempt.db_exam_attempt.submitted_at auto_submit_expired_exam_attempts() attempt.db_exam_attempt.refresh_from_db() self.assertEqual(attempt.db_exam_attempt.submitted_at, first_submitted_at) def test_auto_submit_skips_non_expired_attempts(self): user = create_user(email="auto-submit-skip@example.com") exam, variant = create_exam_with_variant(duration_seconds=10, pass_score=1) attempt = create_attempt(user=user, exam=exam, variant=variant) attempt.db_exam_attempt.expires_at = timezone.now() + timedelta(seconds=60) attempt.db_exam_attempt.save(update_fields=["expires_at"]) auto_submit_expired_exam_attempts() attempt.db_exam_attempt.refresh_from_db() self.assertIsNone(attempt.db_exam_attempt.submitted_at) def test_auto_submit_respects_batch_size_limit(self): user = create_user(email="auto-submit-batch@example.com") exam, variant = create_exam_with_variant(duration_seconds=10, pass_score=1) attempts = [] expired_at = timezone.now() - timedelta(minutes=5) for _ in range(3): attempt = create_attempt(user=user, exam=exam, variant=variant) attempt.db_exam_attempt.expires_at = expired_at attempt.db_exam_attempt.save(update_fields=["expires_at"]) attempts.append(attempt.db_exam_attempt) attempt_ids = sorted(a.id for a in attempts) first_batch_ids = set(attempt_ids[:2]) last_id = attempt_ids[2] with patch("labs.tasks.exam.AUTO_SUBMIT_EXPIRED_EXAM_ATTEMPTS_BATCH_SIZE", 2): auto_submit_expired_exam_attempts() submitted_ids = set( DBExamAttempt.objects.filter(id__in=attempt_ids, submitted_at__isnull=False) .values_list("id", flat=True) ) self.assertEqual(submitted_ids, first_batch_ids) self.assertTrue( DBExamAttempt.objects.filter(id=last_id, submitted_at__isnull=True).exists() ) def test_auto_submit_skips_attempt_submitted_after_selection(self): user = create_user(email="auto-submit-race@example.com") exam, variant = create_exam_with_variant(duration_seconds=10, pass_score=1) a1 = create_attempt(user=user, exam=exam, variant=variant) a2 = create_attempt(user=user, exam=exam, variant=variant) expired_at = timezone.now() - timedelta(minutes=5) for attempt in (a1, a2): attempt.db_exam_attempt.expires_at = expired_at attempt.db_exam_attempt.save(update_fields=["expires_at"]) first_id, second_id = sorted([a1.db_exam_attempt.id, a2.db_exam_attempt.id]) called_attempt_ids: list[int] = [] from labs.exams.attempt import ExamAttempt as DomainExamAttempt original_submit_exam = DomainExamAttempt.submit_exam def submit_exam_with_race(self: DomainExamAttempt) -> None: called_attempt_ids.append(self.db_exam_attempt.id) if self.db_exam_attempt.id == first_id: DBExamAttempt.objects.filter(id=second_id).update( submitted_at=timezone.now() ) return original_submit_exam(self) with patch("labs.tasks.exam.ExamAttempt.submit_exam", new=submit_exam_with_race): auto_submit_expired_exam_attempts() self.assertEqual(called_attempt_ids, [first_id])