diff --git a/exams/tests.py b/exams/tests.py index d9e0216..fb49b39 100644 --- a/exams/tests.py +++ b/exams/tests.py @@ -2,9 +2,8 @@ import io import pypdf from unittest.mock import patch - from django.db import connection -from django.test import TestCase, TransactionTestCase, override_settings +from django.test import TestCase, TransactionTestCase, override_settings, Client from django.urls import reverse from django.contrib.auth import get_user_model from django.core.files.uploadedfile import SimpleUploadedFile @@ -16,6 +15,8 @@ MaterialType, TaskType, TaskDifficulty, + PriorityLevel, + TaskDepth, ) from core.exceptions import AICallFailedError, AIResponseValidationError from exams.services.analysis_orchestrator import ( @@ -29,7 +30,6 @@ retry_analysis, ) -from django.core.files.uploadedfile import SimpleUploadedFile from exams.services import task_extractor from exams.services.pdf_extractor import extract_text_from_pdf, PdfExtractionError @@ -165,6 +165,147 @@ def test_exam_str_representation(self): self.assertEqual(str(exam), f"공학수학 ({exam.exam_date})") +class TaskReviewFormSubmitTest(TestCase): + def setUp(self): + self.client = Client() + self.user = User.objects.create_user( + username="testuser", password="password123" + ) + self.client.force_login(self.user) + + self.period = ExamPeriod.objects.create( + user=self.user, + title="2026 2학기 중간고사", + start_date="2026-08-01", + end_date="2026-08-15", + ) + self.exam = Exam.objects.create( + exam_period=self.period, + subject_name="자료구조", + exam_date="2026-08-10", + speed_factor=1.0, + ) + self.task = StudyTask.objects.create( + exam=self.exam, + title="기존 제목", + task_type=TaskType.CONCEPT, + importance=PriorityLevel.MEDIUM, + depth=TaskDepth.BASIC, + difficulty=TaskDifficulty.NORMAL, + is_confirmed=False, + ) + self.url = reverse("exams:task_review", kwargs={"exam_id": self.exam.id}) + + def test_save_only_action(self): + """'수정사항 저장' 버튼(action=save) 클릭 시 DB 수정 후 리뷰 페이지로 리다이렉트 검증""" + post_data = { + "form-TOTAL_FORMS": "1", + "form-INITIAL_FORMS": "1", + "form-MIN_NUM_FORMS": "0", + "form-MAX_NUM_FORMS": "1000", + "form-0-id": self.task.id, + "form-0-unit_name": "1장 개념", + "form-0-title": "수정된 제목 (저장만)", + "form-0-task_type": TaskType.CONCEPT, + "form-0-importance": PriorityLevel.MEDIUM, + "form-0-depth": TaskDepth.BASIC, + "form-0-difficulty": TaskDifficulty.HARD, + "action": "save", + } + + response = self.client.post(self.url, post_data) + self.assertRedirects(response, self.url) + + self.task.refresh_from_db() + self.assertEqual(self.task.title, "수정된 제목 (저장만)") + self.assertEqual(self.task.difficulty, TaskDifficulty.HARD) + self.assertFalse(self.task.is_confirmed) + + def test_confirm_and_next_action(self): + """'모두 확정하고 다음으로' 버튼 클릭 시 실제 planner:feasibility 라우팅 검증 (모킹 제거 반영)""" + post_data = { + "form-TOTAL_FORMS": "1", + "form-INITIAL_FORMS": "1", + "form-MIN_NUM_FORMS": "0", + "form-MAX_NUM_FORMS": "1000", + "form-0-id": self.task.id, + "form-0-unit_name": "1장 개념", + "form-0-title": "수정된 제목 (저장+확정)", + "form-0-task_type": TaskType.CONCEPT, + "form-0-importance": PriorityLevel.HIGH, + "form-0-depth": TaskDepth.CORE, + "form-0-difficulty": TaskDifficulty.EASY, + "action": "confirm_and_next", + } + + response = self.client.post(self.url, post_data) + + # 리다이렉트 URL 및 kwargs 인자 정상 전달 검증 + self.assertRedirects( + response, + reverse( + "planner:feasibility", + kwargs={"period_id": self.period.id}, + ), + ) + + self.task.refresh_from_db() + self.assertEqual(self.task.title, "수정된 제목 (저장+확정)") + self.assertTrue(self.task.is_confirmed) + + +class MaterialAnalysisStatusViewTest(TestCase): + def setUp(self): + self.client = Client() + self.user = User.objects.create_user( + username="testuser2", password="password123" + ) + self.client.force_login(self.user) + + self.period = ExamPeriod.objects.create( + user=self.user, + title="시험기간", + start_date="2026-08-01", + end_date="2026-08-15", + ) + self.exam = Exam.objects.create( + exam_period=self.period, + subject_name="운영체제", + exam_date="2026-08-10", + ) + self.material = StudyMaterial.objects.create( + exam=self.exam, + title="운영체제 Ch1", + status=MaterialStatus.COMPLETED, + analysis_status=MaterialStatus.PROCESSING, + ) + self.url = reverse( + "exams:material_analysis_status", + kwargs={"material_id": self.material.id}, + ) + + def test_analysis_status_endpoint_returns_json(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + + data = response.json() + self.assertEqual(data["stage"], "ANALYZING") + self.assertEqual(data["extraction_status"], MaterialStatus.COMPLETED) + self.assertEqual(data["analysis_status"], MaterialStatus.PROCESSING) + self.assertEqual(data["study_material_id"], self.material.id) + self.assertEqual(data["exam_id"], self.exam.id) + + # retry_count 및 retry_remaining 검증 복원 + self.assertEqual( + data["retry_count"], + self.material.analysis_retry_count, + ) + self.assertEqual( + data["retry_remaining"], + max(0, 2 - self.material.analysis_retry_count), + ) + + class MaterialCreateTests(TestCase): """자료 등록 시 입력 유형별 status 처리""" @@ -191,11 +332,9 @@ def test_text_material_status_is_completed(self): self.assertEqual(response.status_code, 302) material = StudyMaterial.objects.get(exam=self.exam) self.assertEqual(material.status, MaterialStatus.COMPLETED) - # AI 분석 상태는 건드리지 않고 기본값(PENDING) 유지 self.assertEqual(material.analysis_status, MaterialStatus.PENDING) def test_pdf_material_status_stays_pending_until_extracted(self): - from django.core.files.uploadedfile import SimpleUploadedFile pdf_file = SimpleUploadedFile("dummy.pdf", b"%PDF-1.4 dummy content", content_type="application/pdf") response = self.client.post(reverse('exams:material_create', args=[self.exam.id]), { @@ -257,11 +396,6 @@ def test_extract_empty_text_marks_failed(self, mock_extract): @patch('exams.views.extract_text_from_pdf') def test_extract_success_resets_stale_analysis_state(self, mock_extract): - """ - 이전 텍스트 기준으로 FAILED였던 AI 분석 상태가, 재추출 성공(=새 텍스트로 - 교체) 후에는 PENDING/재시도횟수 0으로 초기화되어야 한다 (새 텍스트니까 - 최초 분석부터 다시 시작할 수 있어야 함). - """ mock_extract.return_value = "새로 추출된 텍스트입니다." material = self._make_pdf_material() material.status = MaterialStatus.COMPLETED @@ -284,12 +418,6 @@ def test_extract_success_resets_stale_analysis_state(self, mock_extract): @patch('exams.views.extract_text_from_pdf') def test_extract_success_keeps_analysis_state_when_text_unchanged(self, mock_extract): - """ - 리뷰 반영: 재추출 결과가 기존 텍스트와 완전히 같다면(예: 같은 PDF를 실수로 - 다시 업로드), AI 분석 상태를 초기화하면 안 된다. 무조건 초기화하면 재시도 - 횟수를 이미 다 쓴 자료도 같은 PDF를 다시 추출하는 것만으로 - analysis_retry_count가 0으로 리셋되어 재시도 제한을 우회할 수 있다. - """ mock_extract.return_value = "변하지 않는 텍스트입니다." material = self._make_pdf_material() material.status = MaterialStatus.COMPLETED @@ -312,10 +440,6 @@ def test_extract_success_keeps_analysis_state_when_text_unchanged(self, mock_ext @patch('exams.views.extract_text_from_pdf') def test_extract_blocked_when_analysis_processing(self, mock_extract): - """ - AI 분석이 진행 중인 자료는 재추출하면 안 된다 - 어느 텍스트 기준으로 - 분석 중인지 꼬일 수 있기 때문 (analysis_orchestrator 쪽 재검증과 짝을 이룸). - """ material = self._make_pdf_material() material.status = MaterialStatus.COMPLETED material.extracted_text = "기존 추출 텍스트" @@ -334,7 +458,6 @@ def test_extract_blocked_when_analysis_processing(self, mock_extract): @patch('exams.views.extract_text_from_pdf') def test_extract_blocked_when_analysis_completed(self, mock_extract): - """AI 분석이 이미 끝난 자료도 재추출하면 안 된다 (결과가 옛 텍스트 기준이 됨).""" material = self._make_pdf_material() material.status = MaterialStatus.COMPLETED material.extracted_text = "기존 추출 텍스트" @@ -351,6 +474,7 @@ def test_extract_blocked_when_analysis_completed(self, mock_extract): messages_list = list(response.context['messages']) self.assertTrue(any("이미 AI 분석이 완료된" in str(m) for m in messages_list)) + class StudyTaskCreateTests(TestCase): """직접 추가한 학습 작업의 예상시간 계산""" @@ -392,6 +516,7 @@ def test_directly_added_task_has_positive_estimated_time(self, mock_estimate): speed_factor=1.2, ) + class TaskReviewTests(TestCase): """작업 수정 시 예상시간 재계산 확인""" @@ -416,7 +541,6 @@ def setUp(self): @patch('exams.views.estimate_task_minutes') def test_task_update_recalculates_estimated_time(self, mock_estimate): - # difficulty를 EASY→HARD로 바꾸면 더 큰 값이 반환된다고 가정 mock_estimate.return_value = (80, 120) management_form_data = { @@ -430,7 +554,7 @@ def test_task_update_recalculates_estimated_time(self, mock_estimate): 'form-0-task_type': TaskType.CONCEPT, 'form-0-importance': 'medium', 'form-0-depth': 'basic', - 'form-0-difficulty': TaskDifficulty.HARD, # EASY → HARD로 수정 + 'form-0-difficulty': TaskDifficulty.HARD, } response = self.client.post( reverse('exams:task_review', args=[self.exam.id]), management_form_data @@ -447,15 +571,8 @@ def test_task_update_recalculates_estimated_time(self, mock_estimate): speed_factor=1.0, ) -class AnalysisOrchestratorTestCase(TestCase): - """ - analysis_orchestrator.py 리뷰 확정 사항 검증: - - 상태 필드 분리(status/error_message vs analysis_status/analysis_error_message) - - 최초 분석/재시도 상태 전이, 재시도 횟수 제한(최대 2회) - - 파이프라인 전체 예외 처리 및 롤백 - - 빈 결과(0개) 실패 처리 - """ +class AnalysisOrchestratorTestCase(TestCase): def setUp(self): self.user = User.objects.create_user( username="orch_tester@example.com", email="orch_tester@example.com", password="pass1234!" @@ -474,8 +591,6 @@ def _make_material(self, text="1장 개념 정리"): status=MaterialStatus.COMPLETED, ) - # ---------- 최초 분석 ---------- - def test_initial_analysis_success_sets_completed(self): material = self._make_material() tasks = analyze_and_estimate(material) @@ -501,7 +616,6 @@ def test_initial_analysis_does_not_touch_extraction_status_fields(self): analyze_and_estimate(material) material.refresh_from_db() - # 텍스트 추출 상태(status/error_message)는 AI 분석과 무관하게 그대로 유지돼야 한다 self.assertEqual(material.status, MaterialStatus.COMPLETED) self.assertIsNone(material.error_message) @@ -523,14 +637,11 @@ def test_time_estimation_failure_sets_failed_and_rolls_back(self, mock_estimate) mock_estimate.side_effect = ValueError("예상시간 계산 중 알 수 없는 오류") material = self._make_material() - # 예기치 못한 예외(ValueError)는 AnalysisPipelineError로 변환되어 발생한다 - # (View가 AIAnalysisError/AnalysisPipelineError만 알면 되도록 하기 위함) with self.assertRaises(AnalysisPipelineError): analyze_and_estimate(material) material.refresh_from_db() self.assertEqual(material.analysis_status, MaterialStatus.FAILED) - # 예기치 못한 예외는 상세 내용을 그대로 노출하지 않고 일반 문구로 저장한다 self.assertNotIn("예상시간 계산 중 알 수 없는 오류", material.analysis_error_message or "") self.assertEqual(StudyTask.objects.filter(study_material=material).count(), 0) @@ -553,8 +664,6 @@ def test_duplicate_request_rejected_while_processing(self): with self.assertRaises(DuplicateAnalysisRequestError): analyze_and_estimate(material) - # ---------- 재시도 ---------- - def test_retry_rejected_when_completed(self): material = self._make_material() material.analysis_status = MaterialStatus.COMPLETED @@ -582,7 +691,6 @@ def test_second_retry_succeeds_after_first_retry_fails(self): material.analysis_retry_count = 0 material.save(update_fields=["analysis_status", "analysis_retry_count"]) - # 1차 재시도는 실패시킨다 with patch("exams.services.analysis_orchestrator.fetch_extracted_tasks") as mock_analyze: mock_analyze.side_effect = AICallFailedError("1차 재시도 실패") with self.assertRaises(AICallFailedError): @@ -592,7 +700,6 @@ def test_second_retry_succeeds_after_first_retry_fails(self): self.assertEqual(material.analysis_retry_count, 1) self.assertEqual(material.analysis_status, MaterialStatus.FAILED) - # 2차 재시도는 mock 모드 기본 흐름 그대로 성공시킨다 tasks = retry_analysis(material) material.refresh_from_db() @@ -622,12 +729,8 @@ def test_retry_increments_retry_count_exactly_once(self): @patch("exams.services.task_extractor._call_ai") def test_internal_self_correction_retry_does_not_affect_user_retry_count(self, mock_call_ai): - """ - task_extractor 내부의 JSON 검증 self-correction 재요청(1회 실패 후 성공)이 - analysis_retry_count에는 영향을 주지 않아야 한다. - """ mock_call_ai.side_effect = [ - "이건 유효하지 않은 JSON 입니다", # 1차 응답: 검증 실패 -> 내부 self-correction 유발 + "이건 유효하지 않은 JSON 입니다", '{"tasks": [{"unit_name": "1장", "title": "개념 읽기", "task_type": "concept", ' '"importance": "high", "depth": "core", "difficulty": "normal", ' '"ai_reason": "기초 개념이라 우선순위가 높습니다."}]}', @@ -641,8 +744,6 @@ def test_internal_self_correction_retry_does_not_affect_user_retry_count(self, m self.assertEqual(material.analysis_status, MaterialStatus.COMPLETED) self.assertEqual(material.analysis_retry_count, 0) - # ---------- 상태 조회 ---------- - def test_get_analysis_status_reports_retry_remaining(self): material = self._make_material() material.analysis_status = MaterialStatus.FAILED @@ -657,31 +758,20 @@ def test_get_analysis_status_reports_retry_remaining(self): self.assertEqual(result["retry_count"], 1) self.assertEqual(result["retry_remaining"], MAX_RETRY_COUNT - 1) - # ---------- 추출 시작과 분석 시작의 경쟁 상태 (리뷰 반영) ---------- - def test_initial_analysis_blocked_when_extraction_wins_race_after_status_check(self): - """ - View가 material.status==COMPLETED를 확인한 시점(이 material 객체는 메모리에 - COMPLETED로 남아있음) 직후, DB에서는 PDF 재추출이 먼저 status=PROCESSING을 - 차지했다고 가정한다. _start_processing()은 인메모리 값이 아니라 DB를 다시 - 조건부로 확인하므로, 이 경우 분석 시작 자체가 원자적으로 실패해야 한다. - """ material = self._make_material() - self.assertEqual(material.status, MaterialStatus.COMPLETED) # View가 이미 확인한 상태 + self.assertEqual(material.status, MaterialStatus.COMPLETED) - # PDF 재추출이 먼저 DB에서 PROCESSING을 차지했다고 가정 - # (material 인메모리 객체는 건드리지 않아 "직후" 시점을 그대로 재현) StudyMaterial.objects.filter(pk=material.pk).update(status=MaterialStatus.PROCESSING) with self.assertRaises(DuplicateAnalysisRequestError): analyze_and_estimate(material) material.refresh_from_db() - self.assertEqual(material.analysis_status, MaterialStatus.PENDING) # 시작조차 안 됨 - self.assertEqual(material.status, MaterialStatus.PROCESSING) # 추출 상태는 그대로 + self.assertEqual(material.analysis_status, MaterialStatus.PENDING) + self.assertEqual(material.status, MaterialStatus.PROCESSING) def test_retry_blocked_when_extraction_wins_race_after_status_check(self): - """재시도 경로에서도 동일한 경쟁 상태가 원자적으로 막혀야 한다.""" material = self._make_material() material.analysis_status = MaterialStatus.FAILED material.analysis_retry_count = 0 @@ -694,15 +784,11 @@ def test_retry_blocked_when_extraction_wins_race_after_status_check(self): material.refresh_from_db() self.assertEqual(material.analysis_status, MaterialStatus.FAILED) - self.assertEqual(material.analysis_retry_count, 0) # 증가 안 함 + self.assertEqual(material.analysis_retry_count, 0) self.assertEqual(material.status, MaterialStatus.PROCESSING) class MaterialAnalysisViewTestCase(TestCase): - """ - AI 분석 관련 View(material_analyze/material_retry_analyze/material_analysis_status) 검증. - """ - def setUp(self): self.owner = User.objects.create_user( username="view_owner@example.com", email="view_owner@example.com", password="pass1234!" @@ -808,6 +894,9 @@ def test_analysis_status_endpoint_returns_json(self): self.assertEqual(response.status_code, 200) data = response.json() self.assertEqual(data["analysis_status"], MaterialStatus.FAILED) + self.assertEqual(data["extraction_status"], MaterialStatus.COMPLETED) + self.assertEqual(data["study_material_id"], self.material.id) + self.assertEqual(data["exam_id"], self.exam.id) self.assertEqual(data["analysis_error_message"], "네트워크 오류") self.assertEqual(data["retry_count"], 1) self.assertEqual(data["retry_remaining"], 1) @@ -817,8 +906,6 @@ def test_other_user_cannot_view_analysis_status(self): response = self.client.get(reverse('exams:material_analysis_status', args=[self.material.id])) self.assertEqual(response.status_code, 404) - # ---------- stage/failed_stage 조합 (추출 상태 + 분석 상태 통합) ---------- - def _get_stage(self): self.client.force_login(self.owner) response = self.client.get(reverse('exams:material_analysis_status', args=[self.material.id])) @@ -883,11 +970,6 @@ def test_stage_completed_when_analysis_completed(self): self.assertIsNone(data["failed_stage"]) def test_stage_prioritizes_extraction_over_stale_analysis_failure(self): - """ - 재추출 중(status=PROCESSING)인데 이전 분석 실패 기록(analysis_status=FAILED)이 - 같이 남아있는 경우, 추출 상태를 우선해서 EXTRACTING으로 보여줘야 한다 - (이전 분석 실패가 잘못 노출되면 안 됨). - """ self.material.status = MaterialStatus.PROCESSING self.material.analysis_status = MaterialStatus.FAILED self.material.save(update_fields=["status", "analysis_status"]) @@ -898,8 +980,6 @@ def test_stage_prioritizes_extraction_over_stale_analysis_failure(self): self.assertIsNone(data["failed_stage"]) def test_stage_response_still_includes_existing_fields(self): - """stage/failed_stage 추가가 retry_count/retry_remaining, 그리고 - analysis_status/analysis_error_message 값을 안 건드리는지 확인.""" self.material.analysis_status = MaterialStatus.FAILED self.material.analysis_error_message = "테스트 실패 사유" self.material.analysis_retry_count = 1 @@ -915,8 +995,6 @@ def test_stage_response_still_includes_existing_fields(self): self.assertEqual(data["retry_remaining"], MAX_RETRY_COUNT - 1) def test_stage_response_includes_extraction_fields(self): - """extraction_status/extraction_error_message가 material.status/ - error_message 값을 그대로 반영하는지 확인 (BE2와 합의한 필드명).""" self.material.status = MaterialStatus.FAILED self.material.error_message = "PDF 추출 실패 사유" self.material.save(update_fields=["status", "error_message"]) @@ -926,8 +1004,6 @@ def test_stage_response_includes_extraction_fields(self): self.assertEqual(data["extraction_status"], MaterialStatus.FAILED) self.assertEqual(data["extraction_error_message"], "PDF 추출 실패 사유") - # ---------- 예기치 못한 파이프라인 예외 처리 (PR #33 리뷰 반영) ---------- - @patch("exams.services.analysis_orchestrator.estimate_task_minutes") def test_analyze_unexpected_exception_redirects_instead_of_500(self, mock_estimate): mock_estimate.side_effect = ValueError("예상시간 계산 중 알 수 없는 오류") @@ -937,7 +1013,6 @@ def test_analyze_unexpected_exception_redirects_instead_of_500(self, mock_estima reverse('exams:material_analyze', args=[self.material.id]), follow=True ) - # 500이 아니라 자료 상세 화면으로 정상 리다이렉트되어야 한다 self.assertEqual(response.status_code, 200) self.assertRedirects( response, reverse('exams:material_detail', args=[self.material.id]) @@ -977,18 +1052,12 @@ def test_retry_unexpected_exception_redirects_instead_of_500(self, mock_estimate self.material.analysis_error_message, "분석 중 알 수 없는 오류가 발생했습니다. 잠시 후 다시 시도해주세요.", ) - # 재시도 자체는 시작됐으므로 retry_count는 증가한 상태로 남아야 한다 self.assertEqual(self.material.analysis_retry_count, 1) class PdfExtractorTestCase(TestCase): def test_extract_text_success(self): - """[3번] 정상적인 텍스트 PDF에서 텍스트가 올바르게 추출되는지 검증""" - # pypdf를 사용하여 텍스트가 포함된 PDF 메모리 상에 동적 생성 - # 1페이지짜리 샘플 PDF 세팅 (텍스트 포함) - # Note: pypdf로 텍스트 오브젝트 직접 주입이 안 될 수 있어 표준 Stream 방식을 사용하거나 - # ReportLab 등이 없는 환경을 고려한 기본 텍스트 포함 1페이지 생성 raw_pdf_data = b"""%PDF-1.4 1 0 obj <> endobj 2 0 obj <> endobj @@ -1016,18 +1085,14 @@ def test_extract_text_success(self): dummy_file = SimpleUploadedFile("valid_sample.pdf", raw_pdf_data, content_type="application/pdf") - # 텍스트 추출 실행 extracted_text = extract_text_from_pdf(dummy_file) - # 검증 self.assertIn("Hello Plan B PDF Text Extraction", extracted_text) def test_extract_text_encrypted_with_empty_password(self): - """[4번] 빈 비밀번호("")로 해제 가능한 암호화 PDF 처리 검증""" writer = pypdf.PdfWriter() page = writer.add_blank_page(width=100, height=100) - # 빈 비밀번호("")로 읽기 암호화 설정 writer.encrypt(user_password="", owner_password="") pdf_buffer = io.BytesIO() @@ -1036,16 +1101,12 @@ def test_extract_text_encrypted_with_empty_password(self): dummy_file = SimpleUploadedFile("encrypted_empty_pass.pdf", pdf_buffer.read(), content_type="application/pdf") - # 빈 비밀번호 해제 시도 후 텍스트 추출 동작 시도 (내용이 없으므로 빈 PDF 예외 혹은 정상 통과 확인) - # 빈 페이지이므로 PdfExtractionError("PDF에서 텍스트를 추출할 수 없습니다...")가 발생해야 decrypt("") 단계를 무사히 통과한 것임 with self.assertRaises(PdfExtractionError) as context: extract_text_from_pdf(dummy_file) - # "암호화된 PDF 파일은 지원하지 않습니다"가 아닌, decrypt 통과 후 "텍스트를 추출할 수 없습니다" 메시지가 나와야 성공! self.assertIn("PDF에서 텍스트를 추출할 수 없습니다", str(context.exception)) def test_extract_text_from_invalid_pdf(self): - """손상되었거나 일반 텍스트 파일 입력 시 예외 검증""" dummy_file = SimpleUploadedFile("invalid.pdf", b"Not a PDF content", content_type="application/pdf") with self.assertRaises(PdfExtractionError) as context: @@ -1054,7 +1115,6 @@ def test_extract_text_from_invalid_pdf(self): self.assertIn("올바른 PDF 형식이 아니거나 손상된 파일입니다", str(context.exception)) def test_extract_text_from_empty_pdf_or_image(self): - """텍스트 레이어가 없는 빈/스캔 PDF일 때 예외 처리 검증""" writer = pypdf.PdfWriter() writer.add_blank_page(width=100, height=100) @@ -1069,20 +1129,8 @@ def test_extract_text_from_empty_pdf_or_image(self): self.assertIn("PDF에서 텍스트를 추출할 수 없습니다", str(context.exception)) -class AITransactionIsolationTestCase(TransactionTestCase): - """ - 이슈: task_extractor.analyze_study_material()가 통째로 @transaction.atomic이라, - 그 안에서 벌어지는 AI 네트워크 호출이 DB 트랜잭션을 물고 있는 채로 실행되던 문제. - - fetch_extracted_tasks()(네트워크, 트랜잭션 없음)와 save_extracted_tasks()/ - _save_tasks_with_estimates()(DB 쓰기, 짧은 트랜잭션)로 분리한 뒤, - AI 호출 시점에 실제로 열려있는 DB 트랜잭션이 없는지 직접 검증한다. - - TestCase가 아니라 TransactionTestCase를 쓰는 이유: 일반 TestCase는 테스트 - 하나하나를 자체적으로 큰 트랜잭션으로 감싸서 롤백하기 때문에, 그 안에서는 - connection.in_atomic_block이 항상 True로 나와 이 검증 자체가 무의미해진다. - """ +class AITransactionIsolationTestCase(TransactionTestCase): def setUp(self): self.user = User.objects.create_user( username="tx_tester@example.com", email="tx_tester@example.com", password="pass1234!" @@ -1104,9 +1152,6 @@ def test_call_ai_runs_without_open_transaction(self): observed_in_atomic_block = [] def spy_call_ai(prompt): - # 이 테스트의 목적은 트랜잭션 유무 확인이지 실제 AI 응답 확인이 아니므로, - # 테스트 실행 환경에서 AI_MOCK_MODE가 어쩌다 False로 덮어써져도 실제 - # Gemini API를 호출하지 않도록 고정 응답을 직접 반환한다. observed_in_atomic_block.append(connection.in_atomic_block) return task_extractor._MOCK_RESPONSE @@ -1121,10 +1166,6 @@ def spy_call_ai(prompt): @override_settings(AI_MOCK_MODE=True) def test_studytask_creation_still_rolls_back_on_db_failure(self): - """ - 네트워크 호출을 트랜잭션 밖으로 뺐어도, DB 저장 단계 자체의 원자성은 - 여전히 보장되어야 한다 (StudyTask 생성 + 예상시간 반영이 한 단위로 롤백). - """ with patch("exams.services.analysis_orchestrator.estimate_task_minutes") as mock_estimate: mock_estimate.side_effect = ValueError("예상시간 계산 중 알 수 없는 오류") with self.assertRaises(AnalysisPipelineError): @@ -1134,12 +1175,7 @@ def test_studytask_creation_still_rolls_back_on_db_failure(self): @override_settings(AI_MOCK_MODE=True) def test_stale_extracted_text_discards_result(self): - """ - AI 호출 시작 이후 저장 시점 사이에 extracted_text가 바뀌면(예: 다른 요청이 - PDF를 재추출), 그 사이 받은 AI 결과는 저장하지 않고 버려야 한다. - """ def fake_fetch(exam, extracted_text): - # AI 응답을 기다리는 동안 다른 요청이 텍스트를 바꿔치기했다고 가정 StudyMaterial.objects.filter(pk=self.material.pk).update( extracted_text="다른 요청이 재추출한 새 텍스트" ) @@ -1154,21 +1190,13 @@ def fake_fetch(exam, extracted_text): self.assertEqual(StudyTask.objects.filter(study_material=self.material).count(), 0) self.material.refresh_from_db() self.assertEqual(self.material.analysis_status, MaterialStatus.FAILED) - # 텍스트 자체는 다른 요청이 바꾼 값 그대로 남아있어야 한다 (이 실행이 덮어쓰면 안 됨) self.assertEqual(self.material.extracted_text, "다른 요청이 재추출한 새 텍스트") @override_settings(AI_MOCK_MODE=True) def test_stale_when_extraction_reprocessing_even_if_text_unchanged(self): - """ - PDF 재추출이 "시작"만 되고(status=PROCESSING) 아직 extracted_text 자체는 - 안 바뀐 시점에도, 저장을 포기해야 한다 (텍스트 비교만으로는 이 시점을 - 걸러낼 수 없어서 status도 별도로 확인해야 하는 케이스). - """ original_text = self.material.extracted_text def fake_fetch(exam, extracted_text): - # 재추출이 막 시작됐다고 가정: status만 PROCESSING으로 바뀌고 - # extracted_text는 아직 원래 값 그대로인 상태 StudyMaterial.objects.filter(pk=self.material.pk).update( status=MaterialStatus.PROCESSING ) @@ -1183,17 +1211,11 @@ def fake_fetch(exam, extracted_text): self.assertEqual(StudyTask.objects.filter(study_material=self.material).count(), 0) self.material.refresh_from_db() self.assertEqual(self.material.analysis_status, MaterialStatus.FAILED) - # 텍스트 자체는 그대로였다는 것도 재확인 (이게 이 테스트의 핵심 포인트) self.assertEqual(self.material.extracted_text, original_text) @override_settings(AI_MOCK_MODE=True) def test_speed_factor_uses_latest_value_at_save_time(self): - """ - 예상시간 계산은 AI 호출 전에 로드해둔 오래된 exam 객체가 아니라, 저장 - 시점에 다시 조회한 최신 speed_factor를 사용해야 한다. - """ def fake_fetch(exam, extracted_text): - # AI 응답을 기다리는 동안 progress 기록으로 speed_factor가 갱신됐다고 가정 Exam.objects.filter(pk=self.exam.pk).update(speed_factor=2.0) return task_extractor.fetch_extracted_tasks(exam, extracted_text) @@ -1208,18 +1230,13 @@ def fake_fetch(exam, extracted_text): expected_min, expected_max = estimate_task_minutes( task_type=first_task.task_type, difficulty=first_task.difficulty, - speed_factor=2.0, # 저장 시점의 최신값 + speed_factor=2.0, ) self.assertEqual(first_task.estimated_min_minutes, expected_min) self.assertEqual(first_task.estimated_max_minutes, expected_max) @override_settings(AI_MOCK_MODE=True) def test_existing_unconfirmed_task_preserved_when_save_rolls_back(self): - """ - save_extracted_tasks()는 기존 미확정 작업을 삭제하고 새로 만드는데, 그 - 직후(예상시간 계산) 실패로 트랜잭션이 롤백되면 기존 작업 삭제도 함께 - 되돌려져서 그대로 남아있어야 한다. - """ old_task = StudyTask.objects.create( exam=self.exam, study_material=self.material, title="기존 작업", task_type="concept", importance="medium", depth="basic", diff --git a/exams/views.py b/exams/views.py index e297f43..c83101d 100644 --- a/exams/views.py +++ b/exams/views.py @@ -4,6 +4,7 @@ from django.views.decorators.http import require_http_methods from django.contrib import messages from django.http import JsonResponse +from django.db import transaction from core.choices import ExamPeriodStatus, MaterialStatus, MaterialType from core.exceptions import AIAnalysisError @@ -23,12 +24,12 @@ analyze_and_estimate, retry_analysis, get_analysis_status, + MAX_RETRY_COUNT, DuplicateAnalysisRequestError, AnalysisNotSupportedError, RetryLimitExceededError, AnalysisPipelineError, ) -from django.db import transaction # ===================================================================== @@ -61,7 +62,7 @@ def period_create(request): """ 생성 전용. 수정은 period_update가 따로 담당. - active 시험기간 1개 제한 (MVP 정책) - - 생성 성공 시 '시험기간 상세'로 이동 (표의 연동화면 기준) + - 생성 성공 시 '시험기간 상세'로 이동 - start_date~end_date 범위의 AvailableTime을 0분으로 미리 채워둠 """ if request.method == 'POST': @@ -125,6 +126,7 @@ def period_update(request, period_id): return render(request, 'exams/period_form.html', {'form': form, 'period': period}) + # ===================================================================== # 시험기간 삭제 (exams:period_delete) # ===================================================================== @@ -165,7 +167,7 @@ def subject_create(request, period_id): exam = form.save(commit=False) exam.exam_period = period exam.save() - return redirect('exams:period_detail', period_id=period.id) # 표: 연동화면=시험기간 상세 + return redirect('exams:period_detail', period_id=period.id) else: form = ExamForm(exam_period=period) @@ -212,10 +214,6 @@ def subject_delete(request, period_id, exam_id): @login_required @require_http_methods(["GET", "POST"]) def available_time_update(request, period_id): - """ - period_create/update에서 이미 0분으로 AvailableTime을 생성해두므로 - 여기서는 값만 채우는 '수정' 개념 (extra=0 formset으로 충분) - """ period = get_object_or_404(ExamPeriod, id=period_id, user=request.user) queryset = AvailableTime.objects.filter(exam_period=period).order_by('date') @@ -226,7 +224,7 @@ def available_time_update(request, period_id): for instance in instances: instance.exam_period = period instance.save() - return redirect('exams:period_detail', period_id=period.id) # 표: 연동화면=시험기간 상세 + return redirect('exams:period_detail', period_id=period.id) else: formset = AvailableTimeFormSet(queryset=queryset) @@ -255,6 +253,7 @@ def material_create(request, exam_id): return render(request, 'exams/material_form.html', {'form': form, 'exam': exam}) + # ===================================================================== # 자료 상세 (exams:material_detail) # ===================================================================== @@ -272,16 +271,9 @@ def material_detail(request, material_id): @login_required @require_http_methods(["POST"]) def material_extract(request, material_id): - """ - D-MAT-03: PDF 텍스트 추출 - - status: PENDING/FAILED → PROCESSING → COMPLETED(+extracted_text) / FAILED(+error_message) - """ material = get_object_or_404( StudyMaterial, id=material_id, exam__exam_period__user=request.user ) - # 재추출 성공 후 "텍스트가 실제로 바뀌었는지" 판단하는 기준값. 아래에서 - # material.status/analysis_status 등을 refresh_from_db()로 갱신해도 - # extracted_text 필드는 그 refresh 대상에 포함하지 않으므로 이 시점 값 그대로 유지된다. previous_extracted_text = material.extracted_text if material.material_type != MaterialType.PDF: @@ -292,13 +284,6 @@ def material_extract(request, material_id): messages.error(request, "첨부된 PDF 파일이 없습니다.") return redirect('exams:material_detail', material_id=material.id) - # 아래 세 조건 중 하나라도 걸리면 재추출을 막는다. - # - status(추출 상태)가 이미 PROCESSING (다른 요청이 추출 중) - # - analysis_status가 PROCESSING (AI 분석 진행 중 - 어느 텍스트 기준인지 꼬임) - # - analysis_status가 COMPLETED (이미 이 텍스트 기준으로 분석 결과가 있음) - # 조회 후 파이썬에서 검사하는 방식은 동시 요청 사이의 경쟁 상태가 남으므로, - # 조건부 UPDATE 하나로 "확인 + PROCESSING 전이"를 원자적으로 처리한다 - # (analysis_orchestrator._save_tasks_with_estimates()의 재검증과 짝을 이루는 방어). updated_count = StudyMaterial.objects.filter(pk=material.pk).exclude( status=MaterialStatus.PROCESSING ).exclude( @@ -340,16 +325,12 @@ def material_extract(request, material_id): material.status = MaterialStatus.COMPLETED material.extracted_text = extracted material.error_message = None - # 재추출 성공은 텍스트가 바뀌었을 수도, 완전히 같을 수도 있다 (예: 사용자가 - # 같은 PDF를 실수로 다시 업로드). 텍스트가 실제로 바뀐 경우에만 "새로운 분석 - # 대상"으로 보고 AI 분석 상태(특히 FAILED 사유, 재시도 횟수)를 초기화한다. - # 리뷰 반영: 텍스트가 동일한데도 무조건 초기화하면, 재시도 2회를 이미 다 쓴 - # 자료도 같은 PDF를 다시 추출하는 것만으로 retry_count가 0으로 리셋되어 - # 재시도 횟수 제한을 우회할 수 있었다. + if extracted != previous_extracted_text: material.analysis_status = MaterialStatus.PENDING material.analysis_error_message = None material.analysis_retry_count = 0 + material.save(update_fields=[ 'status', 'extracted_text', 'error_message', 'analysis_status', 'analysis_error_message', 'analysis_retry_count', @@ -371,20 +352,13 @@ def material_delete(request, material_id): messages.success(request, "학습자료가 삭제되었습니다.") return redirect('exams:period_detail', period_id=period_id) + # ===================================================================== # AI 분석 실행 (exams:material_analyze) - E-AI-01 # ===================================================================== @login_required @require_http_methods(["POST"]) def material_analyze(request, material_id): - """ - E-AI-01: AI 분석 요청. - analysis_status: PENDING -> PROCESSING -> COMPLETED/FAILED - - 텍스트 추출(status)이 아직 COMPLETED가 아니면 (PDF 추출 전, 실패 등) - 분석 자체를 시작하지 않는다 - 추출 상태와 분석 상태는 별개 필드지만, - 추출이 안 끝난 자료를 분석할 수는 없기 때문. - """ material = get_object_or_404( StudyMaterial, id=material_id, exam__exam_period__user=request.user ) @@ -398,8 +372,7 @@ def material_analyze(request, material_id): except DuplicateAnalysisRequestError: messages.info(request, "이미 분석 중이거나 처리된 자료입니다.") return redirect('exams:material_detail', material_id=material.id) - except (AIAnalysisError, AnalysisPipelineError): - # 실패 사유는 이미 material.analysis_error_message에 저장돼 있음 + except (AIAnalysisError, AnalysisPipelineError, Exception): messages.error(request, "AI 분석에 실패했습니다. 다시 시도하거나 직접 작업을 추가해주세요.") return redirect('exams:material_detail', material_id=material.id) @@ -413,10 +386,6 @@ def material_analyze(request, material_id): @login_required @require_http_methods(["POST"]) def material_retry_analyze(request, material_id): - """ - E-AI-03: AI 분석 재시도. FAILED 상태 + 재시도 횟수(2회) 남아있을 때만 허용. - 조건에 안 맞으면 analysis_orchestrator가 던지는 예외를 그대로 사용자 메시지로 변환한다. - """ material = get_object_or_404( StudyMaterial, id=material_id, exam__exam_period__user=request.user ) @@ -432,7 +401,7 @@ def material_retry_analyze(request, material_id): except RetryLimitExceededError as e: messages.error(request, str(e)) return redirect('exams:material_detail', material_id=material.id) - except (AIAnalysisError, AnalysisPipelineError): + except (AIAnalysisError, AnalysisPipelineError, Exception): messages.error(request, "재시도한 AI 분석도 실패했습니다.") return redirect('exams:material_detail', material_id=material.id) @@ -448,73 +417,64 @@ def material_retry_analyze(request, material_id): def material_analysis_status(request, material_id): """ E-AI-02: AI 분석 및 텍스트 추출 진행 상태 조회 (폴링용 JSON 엔드포인트). - "분석 중..." 화면에서 주기적으로 호출해 상태 변화를 확인하는 용도. - - 프론트가 material_analyze()를 순차 자동 호출하는 방식으로 가면서, 성공/실패 - 판단을 이 엔드포인트 하나로만 하기로 확정했다. 텍스트 추출 상태 - (material.status/error_message, BE2 담당 필드)와 AI 분석 상태 - (material.analysis_status/analysis_error_message, 이 파일 담당)를 하나의 - 스키마로 합쳐서 내려준다 (BE2와 필드명·구조 합의 완료). - - 최종 응답 스키마: - { - "stage": "PENDING | EXTRACTING | ANALYZING | COMPLETED | FAILED", - "extraction_status": "pending | processing | completed | failed", - "extraction_error_message": str | None, - "analysis_status": "pending | processing | completed | failed", - "analysis_error_message": str | None, - "failed_stage": "EXTRACTION | ANALYSIS" | None, - "retry_count": int, - "retry_remaining": int, - } - extraction_status/analysis_status는 StudyMaterial 모델 필드 값을 그대로 내려서 - 소문자다 (MaterialStatus TextChoices 자체가 소문자). stage/failed_stage는 이 - 엔드포인트가 새로 만드는 값이라 대문자로 통일했다. - - stage 우선순위가 "추출 상태 먼저, 분석 상태 나중"인 이유: material_extract()의 - 원자적 방어(analysis_status가 PROCESSING/COMPLETED면 재추출 자체가 막힘) 덕분에, - material.status가 PROCESSING/FAILED로 남아있다는 건 "지금 추출(재추출 포함) - 작업이 진행/실패한 것"이 확정적으로 최신 상황이라는 뜻이다. 그래서 이 경우엔 - analysis_status에 남아있는 이전 분석 기록(예: 재추출 전의 예전 실패 사유)보다 - 추출 상태를 우선해서 보여준다 - 순서를 반대로 하면(분석 실패를 먼저 체크하면), - 재추출이 한창 진행 중인데도 stage가 잘못 FAILED로 나오는 문제가 생긴다. - - failed_stage: stage가 "FAILED"일 때, 추출 단계에서 실패한 건지("EXTRACTION") - 분석 단계에서 실패한 건지("ANALYSIS") 구분해서 알려준다. 둘 다 아니면 None. """ material = get_object_or_404( StudyMaterial, id=material_id, exam__exam_period__user=request.user ) - analysis_data = get_analysis_status(material) # 기존 서비스 함수, 시그니처 안 바뀜 - if material.status == MaterialStatus.FAILED: + extraction_status = material.status + extraction_error = material.error_message + analysis_status = material.analysis_status + analysis_error = material.analysis_error_message + + # 1. 전체 stage 판정 로직 + failed_stage = None + + # ① 재추출 진행 중이면 이전 분석 실패보다 최우선으로 "EXTRACTING" + if extraction_status == MaterialStatus.PROCESSING: + stage = "EXTRACTING" + + # ② 추출 자체가 실패한 경우 + elif extraction_status == MaterialStatus.FAILED: stage = "FAILED" failed_stage = "EXTRACTION" - elif material.status == MaterialStatus.PROCESSING: - stage = "EXTRACTING" - failed_stage = None - elif analysis_data["status"] == MaterialStatus.PROCESSING: + + # ③ 분석 진행 중인 경우 + elif analysis_status == MaterialStatus.PROCESSING: stage = "ANALYZING" - failed_stage = None - elif analysis_data["status"] == MaterialStatus.FAILED: + + # ④ 분석이 실패한 경우 + elif analysis_status == MaterialStatus.FAILED: stage = "FAILED" failed_stage = "ANALYSIS" - elif analysis_data["status"] == MaterialStatus.COMPLETED: + + # ⑤ 둘 다 완료된 경우 + elif ( + extraction_status == MaterialStatus.COMPLETED + and analysis_status == MaterialStatus.COMPLETED + ): stage = "COMPLETED" - failed_stage = None + + # ⑥ 아무것도 안 한 PENDING 상태 (PENDING이 튜플에서 빠져서 여기로 옴) else: stage = "PENDING" - failed_stage = None + # 2. 재시도 정보 계산 (API 계약 필수 필드) + retry_count = material.analysis_retry_count + retry_remaining = max(0, MAX_RETRY_COUNT - retry_count) + + # 3. 약속된 JSON 응답 스펙 반환 return JsonResponse({ "stage": stage, - "extraction_status": material.status, - "extraction_error_message": material.error_message, - "analysis_status": analysis_data["status"], - "analysis_error_message": analysis_data["error_message"], + "extraction_status": extraction_status, + "extraction_error_message": extraction_error, + "analysis_status": analysis_status, + "analysis_error_message": analysis_error, "failed_stage": failed_stage, - "retry_count": analysis_data["retry_count"], - "retry_remaining": analysis_data["retry_remaining"], + "retry_count": retry_count, + "retry_remaining": retry_remaining, + "study_material_id": material.id, + "exam_id": material.exam_id, }) @@ -528,7 +488,9 @@ def task_review(request, exam_id): queryset = StudyTask.objects.filter(exam=exam) if request.method == 'POST': + action = request.POST.get('action') formset = StudyTaskFormSet(request.POST, queryset=queryset) + if formset.is_valid(): instances = formset.save(commit=False) for instance in instances: @@ -544,13 +506,23 @@ def task_review(request, exam_id): instance.estimated_max_minutes = estimated_max instance.save() + for obj in formset.deleted_objects: obj.delete() + + if action in ('confirm', 'confirm_and_next'): + exam.study_tasks.filter(is_confirmed=False).update(is_confirmed=True) + return redirect('planner:feasibility', period_id=exam.exam_period_id) + return redirect('exams:task_review', exam_id=exam.id) else: formset = StudyTaskFormSet(queryset=queryset) - return render(request, 'exams/task_review.html', {'formset': formset, 'exam': exam}) + return render(request, 'exams/task_review.html', { + 'formset': formset, + 'exam': exam, + }) + # ===================================================================== # 학습 작업 직접 추가 (exams:task_create)