from datetime import timedelta from unittest.mock import MagicMock, patch from django.test import TestCase from django.utils import timezone from labs.exams.attempt import ExamAttempt as DomainExamAttempt, get_in_progress_exam_attempt from labs.exams.exceptions import ExamAttemptExpiredError, ExamAttemptNotInProgressError from labs.models import ExamAttemptSection from labs.tests.exams._helpers import ( create_attempt, create_exam_with_variant, create_question, create_section, create_user, ) class TestExamAttemptLifecycle(TestCase): def test_create_sets_attempt_fields_and_sections(self): user = create_user(email="attempt-create@example.com") exam, variant = create_exam_with_variant(duration_seconds=600, pass_score=1) create_section( variant=variant, section_type=0, display_order=0, title="Instruction", content={"text": "Read this"}, ) create_section( variant=variant, section_type=1, display_order=1, title="Questions", content={}, ) fixed_now = timezone.now() with patch("labs.exams.attempt.timezone.now", return_value=fixed_now): attempt = DomainExamAttempt.create(user=user, exam=exam, variant=variant) db_attempt = attempt.db_exam_attempt self.assertEqual(db_attempt.status, db_attempt.Status.IN_PROGRESS) self.assertEqual(db_attempt.started_at, fixed_now) self.assertEqual(db_attempt.expires_at, fixed_now + timedelta(seconds=600)) self.assertIsNone(db_attempt.submitted_at) sections = list( ExamAttemptSection.objects.filter(attempt=db_attempt).order_by("display_order") ) self.assertEqual(len(sections), 2) self.assertEqual(sections[0].status, sections[0].Status.AVAILABLE) self.assertEqual(sections[0].started_at, fixed_now) self.assertIsNone(sections[1].started_at) self.assertEqual(sections[1].status, sections[1].Status.LOCKED) def test_ensure_in_progress_rejects_completed_attempt(self): user = create_user(email="ensure-completed@example.com") exam, variant = create_exam_with_variant(duration_seconds=600) attempt = create_attempt(user=user, exam=exam, variant=variant) attempt.db_exam_attempt.status = attempt.db_exam_attempt.Status.COMPLETED attempt.db_exam_attempt.save(update_fields=["status"]) with self.assertRaises(ExamAttemptNotInProgressError): DomainExamAttempt(attempt.db_exam_attempt).ensure_in_progress() def test_ensure_in_progress_rejects_expired_attempt(self): user = create_user(email="ensure-expired@example.com") exam, variant = create_exam_with_variant(duration_seconds=600) 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"]) with self.assertRaises(ExamAttemptExpiredError): DomainExamAttempt(attempt.db_exam_attempt).ensure_in_progress() def test_get_in_progress_exam_attempt_ignores_expired_in_progress_attempt(self): user = create_user(email="expired-in-progress@example.com") exam, variant = create_exam_with_variant(duration_seconds=600, pass_score=1) attempt = create_attempt(user=user, exam=exam, variant=variant) fixed_now = timezone.now() attempt.db_exam_attempt.expires_at = fixed_now - timedelta(seconds=1) attempt.db_exam_attempt.save(update_fields=["expires_at"]) with patch("labs.exams.attempt.timezone.now", return_value=fixed_now): self.assertIsNone(get_in_progress_exam_attempt(user)) def test_submit_section_unlocks_next_and_submits_exam_at_end(self): user = create_user(email="submit-section@example.com") exam, variant = create_exam_with_variant(duration_seconds=600, pass_score=1) instruction = create_section( variant=variant, section_type=0, display_order=0, title="Instruction", content={"text": "Read this"}, ) questions = create_section( variant=variant, section_type=1, display_order=1, title="Questions", content={}, ) q = create_question( variant=variant, section=questions, question_type=0, 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) db_attempt = attempt.db_exam_attempt sections = list( ExamAttemptSection.objects.select_related("section") .filter(attempt=db_attempt) .order_by("display_order") ) self.assertEqual([s.section_id for s in sections], [instruction.id, questions.id]) domain_attempt = DomainExamAttempt(db_attempt) first_handler = domain_attempt.get_section_handler(sections[0]) domain_attempt.submit_section(first_handler) sections[0].refresh_from_db() sections[1].refresh_from_db() self.assertEqual(sections[0].status, sections[0].Status.COMPLETED) self.assertIsNotNone(sections[0].completed_at) self.assertEqual(sections[1].status, sections[1].Status.AVAILABLE) self.assertIsNotNone(sections[1].started_at) question_handler = domain_attempt.get_section_handler(sections[1]) question_handler.submit_answer(q.id, ["A"]) domain_attempt.submit_section(question_handler) db_attempt.refresh_from_db() self.assertIsNotNone(db_attempt.submitted_at) self.assertEqual(db_attempt.status, db_attempt.Status.COMPLETED) self.assertEqual(db_attempt.score, 1) self.assertTrue(db_attempt.is_passed) def test_submit_exam_sets_expired_status_when_time_passed(self): user = create_user(email="submit-expired@example.com") exam, variant = create_exam_with_variant(duration_seconds=10, pass_score=1) questions = create_section( variant=variant, section_type=1, display_order=0, title="Questions", content={}, ) create_question( variant=variant, section=questions, question_type=0, 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"]) domain_attempt = DomainExamAttempt(attempt.db_exam_attempt) domain_attempt.submit_exam() 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) def test_submit_exam_certificate_success_writes_result_detail(self): user = create_user(email="cert-success@example.com") exam, variant = create_exam_with_variant( duration_seconds=600, pass_score=1, certificate_short_name="CERT_X", ) questions = create_section( variant=variant, section_type=1, display_order=0, title="Questions", content={}, ) q = create_question( variant=variant, section=questions, question_type=0, 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_section = ExamAttemptSection.objects.select_related("section").get( attempt=attempt.db_exam_attempt ) handler = DomainExamAttempt(attempt.db_exam_attempt).get_section_handler(attempt_section) handler.submit_answer(q.id, ["A"]) issued_at = timezone.now() issuance = MagicMock() issuance.cert_number = "0000-1111-2222-3333" issuance.cert_type.name = "Cert Name" issuance.issued_at = issued_at with patch( "labs.certificate.issue.issue_certificate", return_value=issuance ) as mock_issue: DomainExamAttempt(attempt.db_exam_attempt).submit_exam() mock_issue.assert_called_once_with(user=user, cert_short_name="CERT_X") attempt.db_exam_attempt.refresh_from_db() cert = attempt.db_exam_attempt.result_detail.get("certificate") self.assertIsNotNone(cert) self.assertEqual(cert["cert_number"], "0000-1111-2222-3333") self.assertEqual(cert["cert_name"], "Cert Name") self.assertEqual(cert["cert_short_name"], "CERT_X") self.assertEqual(cert["issued_at"], issued_at.isoformat()) def test_submit_exam_certificate_failure_does_not_break_submit(self): user = create_user(email="cert-failure@example.com") exam, variant = create_exam_with_variant( duration_seconds=600, pass_score=1, certificate_short_name="CERT_FAIL", ) questions = create_section( variant=variant, section_type=1, display_order=0, title="Questions", content={}, ) q = create_question( variant=variant, section=questions, question_type=0, 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_section = ExamAttemptSection.objects.select_related("section").get( attempt=attempt.db_exam_attempt ) handler = DomainExamAttempt(attempt.db_exam_attempt).get_section_handler(attempt_section) handler.submit_answer(q.id, ["A"]) with patch( "labs.certificate.issue.issue_certificate", side_effect=Exception("boom") ): DomainExamAttempt(attempt.db_exam_attempt).submit_exam() attempt.db_exam_attempt.refresh_from_db() self.assertTrue(attempt.db_exam_attempt.is_passed) self.assertIsNone(attempt.db_exam_attempt.result_detail.get("certificate")) def test_submit_exam_is_idempotent_and_issues_certificate_only_once(self): user = create_user(email="cert-idempotent@example.com") exam, variant = create_exam_with_variant( duration_seconds=600, pass_score=1, certificate_short_name="CERT_IDEM", ) questions = create_section( variant=variant, section_type=1, display_order=0, title="Questions", content={}, ) q = create_question( variant=variant, section=questions, question_type=0, 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_section = ExamAttemptSection.objects.select_related("section").get( attempt=attempt.db_exam_attempt ) handler = DomainExamAttempt(attempt.db_exam_attempt).get_section_handler(attempt_section) handler.submit_answer(q.id, ["A"]) issued_at = timezone.now() issuance = MagicMock() issuance.cert_number = "1111-2222-3333-4444" issuance.cert_type.name = "Cert Name" issuance.issued_at = issued_at with patch( "labs.certificate.issue.issue_certificate", return_value=issuance ) as mock_issue: DomainExamAttempt(attempt.db_exam_attempt).submit_exam() attempt.db_exam_attempt.refresh_from_db() first_submitted_at = attempt.db_exam_attempt.submitted_at first_certificate = attempt.db_exam_attempt.result_detail.get("certificate") DomainExamAttempt(attempt.db_exam_attempt).submit_exam() mock_issue.assert_called_once_with(user=user, cert_short_name="CERT_IDEM") attempt.db_exam_attempt.refresh_from_db() self.assertEqual(attempt.db_exam_attempt.submitted_at, first_submitted_at) self.assertEqual( attempt.db_exam_attempt.result_detail.get("certificate"), first_certificate )