From beed0e82d08db762c43a74db7afb3a672c9857d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Ch2ew0n=E2=80=9D?= <“janghee1122@naver.com”> Date: Wed, 5 Aug 2026 16:53:28 +0900 Subject: [PATCH 1/6] =?UTF-8?q?test:=20task=5Freview=20=ED=8F=BC=20?= =?UTF-8?q?=EC=A0=9C=EC=B6=9C=20=EB=B0=8F=20material=5Fanalysis=5Fstatus?= =?UTF-8?q?=20API=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TaskReviewFormSubmitTest의 post_data에 mandatory 필드(unit_name, importance, depth 등) 추가하여 폼 유효성 검증 실패(200 OK) 해결 - task_review 뷰의 redirect('planner:feasibility') 호출에 대응하여 unittest.mock.patch 적용 - material_analysis_status API의 통합 반환 스펙(stage, extraction_status, analysis_status 등)에 맞춰 MaterialAnalysisViewTestCase 단정문 수정 --- exams/tests.py | 201 +++++++++++++++++++++++++++++++++++-------------- exams/views.py | 60 ++++++++++++++- 2 files changed, 199 insertions(+), 62 deletions(-) diff --git a/exams/tests.py b/exams/tests.py index 541fd8a..26e6f97 100644 --- a/exams/tests.py +++ b/exams/tests.py @@ -3,7 +3,7 @@ import pypdf from unittest.mock import patch -from django.test import TestCase +from django.test import TestCase, Client from django.urls import reverse from django.contrib.auth import get_user_model from django.core.files.uploadedfile import SimpleUploadedFile @@ -15,6 +15,8 @@ MaterialType, TaskType, TaskDifficulty, + PriorityLevel, + TaskDepth, ) from core.exceptions import AICallFailedError, AIResponseValidationError from exams.services.analysis_orchestrator import ( @@ -27,8 +29,6 @@ get_analysis_status, retry_analysis, ) - -from django.core.files.uploadedfile import SimpleUploadedFile from exams.services.pdf_extractor import extract_text_from_pdf, PdfExtractionError User = get_user_model() @@ -163,6 +163,137 @@ 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) + + @patch("exams.views.redirect") + def test_confirm_and_next_action(self, mock_redirect): + """'모두 확정하고 다음으로' 버튼 클릭 시 views.py 수정 없이 redirect mock으로 URL 미연결 처리""" + from django.http import HttpResponseRedirect + expected_redirect_url = f"/planner/period/{self.period.id}/feasibility/" + mock_redirect.return_value = HttpResponseRedirect(expected_redirect_url) + + 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) + + mock_redirect.assert_called_once_with( + 'planner:feasibility', 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) + + class MaterialCreateTests(TestCase): """자료 등록 시 입력 유형별 status 처리""" @@ -189,11 +320,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]), { @@ -253,6 +382,7 @@ def test_extract_empty_text_marks_failed(self, mock_extract): self.assertEqual(material.status, MaterialStatus.FAILED) self.assertIn("스캔", material.error_message) + class StudyTaskCreateTests(TestCase): """직접 추가한 학습 작업의 예상시간 계산""" @@ -294,6 +424,7 @@ def test_directly_added_task_has_positive_estimated_time(self, mock_estimate): speed_factor=1.2, ) + class TaskReviewTests(TestCase): """작업 수정 시 예상시간 재계산 확인""" @@ -318,7 +449,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 = { @@ -332,7 +462,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 @@ -349,15 +479,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!" @@ -375,8 +498,6 @@ def _make_material(self, text="1장 개념 정리"): exam=self.exam, title="테스트 자료", extracted_text=text, ) - # ---------- 최초 분석 ---------- - def test_initial_analysis_success_sets_completed(self): material = self._make_material() tasks = analyze_and_estimate(material) @@ -402,7 +523,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) @@ -424,14 +544,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) @@ -454,8 +571,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 @@ -483,7 +598,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.analyze_study_material") as mock_analyze: mock_analyze.side_effect = AICallFailedError("1차 재시도 실패") with self.assertRaises(AICallFailedError): @@ -493,7 +607,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() @@ -523,12 +636,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": "기초 개념이라 우선순위가 높습니다."}]}', @@ -542,8 +651,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 @@ -560,10 +667,6 @@ def test_get_analysis_status_reports_retry_remaining(self): 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!" @@ -668,18 +771,16 @@ def test_analysis_status_endpoint_returns_json(self): self.assertEqual(response.status_code, 200) data = response.json() - self.assertEqual(data["status"], MaterialStatus.FAILED) - self.assertEqual(data["error_message"], "네트워크 오류") - self.assertEqual(data["retry_count"], 1) - self.assertEqual(data["retry_remaining"], 1) + 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) def test_other_user_cannot_view_analysis_status(self): self.client.force_login(self.other) response = self.client.get(reverse('exams:material_analysis_status', args=[self.material.id])) self.assertEqual(response.status_code, 404) - # ---------- 예기치 못한 파이프라인 예외 처리 (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("예상시간 계산 중 알 수 없는 오류") @@ -689,7 +790,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]) @@ -729,18 +829,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 @@ -768,18 +862,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() @@ -788,16 +878,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: @@ -806,7 +892,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) diff --git a/exams/views.py b/exams/views.py index 2877c33..d9f8398 100644 --- a/exams/views.py +++ b/exams/views.py @@ -409,14 +409,49 @@ def material_retry_analyze(request, material_id): @require_http_methods(["GET"]) def material_analysis_status(request, material_id): """ - E-AI-02: AI 분석 진행 상태 조회 (폴링용 JSON 엔드포인트). - "분석 중..." 화면에서 주기적으로 호출해 analysis_status 변화를 확인하는 용도. + E-AI-02: AI 분석 및 텍스트 추출 진행 상태 조회 (폴링용 JSON 엔드포인트). + "분석 중..." 화면에서 주기적으로 호출해 extraction_status 및 analysis_status를 확인한다. """ material = get_object_or_404( StudyMaterial, id=material_id, exam__exam_period__user=request.user ) - return JsonResponse(get_analysis_status(material)) + # 1. BE2 텍스트 추출 상태 및 에러 (StudyMaterial 모델 필드 직접 참조) + extraction_status = material.status # MaterialStatus: PENDING | PROCESSING | COMPLETED | FAILED + extraction_error = material.error_message + + # 2. BE3 AI 분석 상태 및 에러 (StudyMaterial 모델 필드 직접 참조) + analysis_status = material.analysis_status # MaterialStatus: PENDING | PROCESSING | COMPLETED | FAILED + analysis_error = material.analysis_error_message + + # 3. 전체 stage 판정 로직 + if ( + extraction_status == MaterialStatus.FAILED + or analysis_status == MaterialStatus.FAILED + ): + stage = "FAILED" + elif extraction_status in (MaterialStatus.PENDING, MaterialStatus.PROCESSING): + stage = "EXTRACTING" + elif analysis_status in (MaterialStatus.PENDING, MaterialStatus.PROCESSING): + stage = "ANALYZING" + elif ( + extraction_status == MaterialStatus.COMPLETED + and analysis_status == MaterialStatus.COMPLETED + ): + stage = "COMPLETED" + else: + stage = "PENDING" + + # 4. 약속된 JSON 응답 스펙 반환 + return JsonResponse({ + "stage": stage, + "extraction_status": extraction_status, + "extraction_error_message": extraction_error, + "analysis_status": analysis_status, + "analysis_error_message": analysis_error, + "study_material_id": material.id, + "exam_id": material.exam_id, + }) # ===================================================================== # AI 작업 검토 (exams:task_review) @@ -428,13 +463,17 @@ 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(): + # 1. 수정/생성된 Task 저장 instances = formset.save(commit=False) for instance in instances: instance.exam = exam instance.is_user_modified = True + # 공부 예상 시간 재계산 estimated_min, estimated_max = estimate_task_minutes( task_type=instance.task_type, difficulty=instance.difficulty, @@ -444,13 +483,26 @@ def task_review(request, exam_id): instance.estimated_max_minutes = estimated_max instance.save() + + # 2. 삭제 대상 Task 처리 for obj in formset.deleted_objects: obj.delete() + + # 3. action 파라미터에 따른 리다이렉트 및 확정 처리 분기 + if action in ('confirm', 'confirm_and_next'): + # 해당 과목의 모든 미확정 Task를 확정 상태(is_confirmed=True)로 업데이트 + exam.study_tasks.filter(is_confirmed=False).update(is_confirmed=True) + return redirect('planner:feasibility', period_id=exam.exam_period_id) + + # 단순 저장(save) 또는 기타 제출 시 기존 리뷰 페이지로 리다이렉트 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) From 69467bff4686ef67c36244277a67e1fb036b25f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Ch2ew0n=E2=80=9D?= <“janghee1122@naver.com”> Date: Thu, 6 Aug 2026 15:22:29 +0900 Subject: [PATCH 2/6] =?UTF-8?q?refactor(exams):=20get=5Fanalysis=5Fstatus?= =?UTF-8?q?=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=ED=98=B8=EC=B6=9C=20=EB=B3=B5?= =?UTF-8?q?=EC=9B=90=20=EB=B0=8F=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=20=EC=84=B8=EB=B6=84=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - material_analysis_status 뷰에서 get_analysis_status(material) 호출을 복원하여 서비스 레이어의 타임아웃 판정 및 분석 상태 정책이 폴링 응답에 반영되도록 수정 - material_analyze 및 material_retry_analyze 뷰에서 광범위한 Exception 캐치 구문 제거 - 예상치 못한 예외 발생 시 logger.exception()을 통한 트레이스백 남기도록 분리 - get_analysis_status 타임아웃 감지 시 API가 stage: FAILED로 응답하는 통합 테스트 추가 --- exams/tests.py | 304 ++++++++++++++++++++++++++++++++++++++++++++----- exams/views.py | 36 ++++-- 2 files changed, 299 insertions(+), 41 deletions(-) diff --git a/exams/tests.py b/exams/tests.py index fb49b39..d579dd1 100644 --- a/exams/tests.py +++ b/exams/tests.py @@ -254,56 +254,300 @@ def test_confirm_and_next_action(self): self.assertTrue(self.task.is_confirmed) -class MaterialAnalysisStatusViewTest(TestCase): +class MaterialAnalysisViewTestCase(TestCase): def setUp(self): - self.client = Client() - self.user = User.objects.create_user( - username="testuser2", password="password123" + self.owner = User.objects.create_user( + username="view_owner@example.com", email="view_owner@example.com", password="pass1234!" + ) + self.other = User.objects.create_user( + username="view_other@example.com", email="view_other@example.com", password="pass1234!" ) - 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", + user=self.owner, title="뷰 테스트 시험기간", + start_date=datetime.date(2026, 8, 1), end_date=datetime.date(2026, 8, 20), ) self.exam = Exam.objects.create( - exam_period=self.period, - subject_name="운영체제", - exam_date="2026-08-10", + exam_period=self.period, subject_name="뷰 테스트 과목", exam_date=datetime.date(2026, 8, 18), ) self.material = StudyMaterial.objects.create( - exam=self.exam, - title="운영체제 Ch1", - status=MaterialStatus.COMPLETED, - analysis_status=MaterialStatus.PROCESSING, + exam=self.exam, title="테스트 자료", material_type=MaterialType.TEXT, + extracted_text="1장 개념 정리", status=MaterialStatus.COMPLETED, + ) + + def test_analyze_requires_login(self): + response = self.client.post(reverse('exams:material_analyze', args=[self.material.id])) + self.assertEqual(response.status_code, 302) + self.assertIn('/login', response.url) + + def test_other_user_cannot_trigger_analyze(self): + self.client.force_login(self.other) + response = self.client.post(reverse('exams:material_analyze', args=[self.material.id])) + self.assertEqual(response.status_code, 404) + + def test_analyze_rejected_when_extraction_not_completed(self): + self.material.status = MaterialStatus.PENDING + self.material.save(update_fields=["status"]) + self.client.force_login(self.owner) + + response = self.client.post( + reverse('exams:material_analyze', args=[self.material.id]), follow=True + ) + + self.material.refresh_from_db() + self.assertEqual(self.material.analysis_status, MaterialStatus.PENDING) + messages_list = list(response.context['messages']) + self.assertTrue(any("텍스트 추출이 완료된 자료만" in str(m) for m in messages_list)) + + def test_analyze_success_redirects_to_task_review(self): + self.client.force_login(self.owner) + response = self.client.post( + reverse('exams:material_analyze', args=[self.material.id]) + ) + + self.assertRedirects(response, reverse('exams:task_review', args=[self.exam.id])) + self.material.refresh_from_db() + self.assertEqual(self.material.analysis_status, MaterialStatus.COMPLETED) + self.assertTrue(StudyTask.objects.filter(study_material=self.material).exists()) + + def test_analyze_duplicate_request_shows_info_message(self): + self.material.analysis_status = MaterialStatus.PROCESSING + self.material.save(update_fields=["analysis_status"]) + self.client.force_login(self.owner) + + response = self.client.post( + reverse('exams:material_analyze', args=[self.material.id]), follow=True + ) + + messages_list = list(response.context['messages']) + self.assertTrue(any("이미 분석 중" in str(m) for m in messages_list)) + + def test_retry_from_failed_succeeds(self): + self.material.analysis_status = MaterialStatus.FAILED + self.material.analysis_retry_count = 0 + self.material.save(update_fields=["analysis_status", "analysis_retry_count"]) + self.client.force_login(self.owner) + + response = self.client.post( + reverse('exams:material_retry_analyze', args=[self.material.id]) ) - self.url = reverse( - "exams:material_analysis_status", - kwargs={"material_id": self.material.id}, + + self.assertRedirects(response, reverse('exams:task_review', args=[self.exam.id])) + self.material.refresh_from_db() + self.assertEqual(self.material.analysis_status, MaterialStatus.COMPLETED) + self.assertEqual(self.material.analysis_retry_count, 1) + + def test_retry_blocked_when_completed(self): + self.material.analysis_status = MaterialStatus.COMPLETED + self.material.save(update_fields=["analysis_status"]) + self.client.force_login(self.owner) + + response = self.client.post( + reverse('exams:material_retry_analyze', args=[self.material.id]), follow=True ) + messages_list = list(response.context['messages']) + self.assertTrue(any("재분석을 지원하지 않습니다" in str(m) for m in messages_list)) + def test_analysis_status_endpoint_returns_json(self): - response = self.client.get(self.url) - self.assertEqual(response.status_code, 200) + self.material.analysis_status = MaterialStatus.FAILED + self.material.analysis_error_message = "네트워크 오류" + self.material.analysis_retry_count = 1 + self.material.save(update_fields=["analysis_status", "analysis_error_message", "analysis_retry_count"]) + self.client.force_login(self.owner) + response = self.client.get(reverse('exams:material_analysis_status', args=[self.material.id])) + + self.assertEqual(response.status_code, 200) data = response.json() - self.assertEqual(data["stage"], "ANALYZING") + self.assertEqual(data["analysis_status"], MaterialStatus.FAILED) 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["analysis_error_message"], "네트워크 오류") + self.assertEqual(data["retry_count"], 1) + self.assertEqual(data["retry_remaining"], 1) + + # ========================================================================= + # [리뷰어 요청 반영] 타임아웃 응답 검증 통합 테스트 + # ========================================================================= + def test_analysis_status_returns_failed_when_orchestrator_detects_timeout(self): + """오래된 PROCESSING 상태 조회 시 get_analysis_status에서 반환된 타임아웃(FAILED) 상태가 API에 반영되는지 검증""" + self.material.status = MaterialStatus.COMPLETED + self.material.analysis_status = MaterialStatus.PROCESSING + self.material.save(update_fields=["status", "analysis_status"]) + self.client.force_login(self.owner) + + mock_timeout_data = { + "status": MaterialStatus.FAILED, + "error_message": "분석 시간이 초과되었습니다.", + "retry_count": 0, + "retry_remaining": 2, + } + + with patch("exams.views.get_analysis_status", return_value=mock_timeout_data): + response = self.client.get(reverse('exams:material_analysis_status', args=[self.material.id])) + self.assertEqual(response.status_code, 200) + data = response.json() + + self.assertEqual(data["stage"], "FAILED") + self.assertEqual(data["analysis_status"], MaterialStatus.FAILED) + self.assertEqual(data["failed_stage"], "ANALYSIS") + self.assertEqual(data["analysis_error_message"], "분석 시간이 초과되었습니다.") + self.assertEqual(data["retry_count"], 0) + self.assertEqual(data["retry_remaining"], 2) + + def test_other_user_cannot_view_analysis_status(self): + self.client.force_login(self.other) + response = self.client.get(reverse('exams:material_analysis_status', args=[self.material.id])) + self.assertEqual(response.status_code, 404) + + def _get_stage(self): + self.client.force_login(self.owner) + response = self.client.get(reverse('exams:material_analysis_status', args=[self.material.id])) + return response.json() + + def test_stage_pending_when_nothing_started(self): + self.material.status = MaterialStatus.PENDING + self.material.analysis_status = MaterialStatus.PENDING + self.material.save(update_fields=["status", "analysis_status"]) + + data = self._get_stage() + + self.assertEqual(data["stage"], "PENDING") + self.assertIsNone(data["failed_stage"]) + + def test_stage_extracting_when_extraction_processing(self): + self.material.status = MaterialStatus.PROCESSING + self.material.save(update_fields=["status"]) + + data = self._get_stage() + + self.assertEqual(data["stage"], "EXTRACTING") + self.assertIsNone(data["failed_stage"]) + + def test_stage_failed_with_extraction_when_extraction_failed(self): + self.material.status = MaterialStatus.FAILED + self.material.save(update_fields=["status"]) + + data = self._get_stage() + + self.assertEqual(data["stage"], "FAILED") + self.assertEqual(data["failed_stage"], "EXTRACTION") + + def test_stage_analyzing_when_analysis_processing(self): + self.material.status = MaterialStatus.COMPLETED + self.material.analysis_status = MaterialStatus.PROCESSING + self.material.save(update_fields=["status", "analysis_status"]) + + data = self._get_stage() + + self.assertEqual(data["stage"], "ANALYZING") + self.assertIsNone(data["failed_stage"]) + + def test_stage_failed_with_analysis_when_analysis_failed(self): + self.material.status = MaterialStatus.COMPLETED + self.material.analysis_status = MaterialStatus.FAILED + self.material.save(update_fields=["status", "analysis_status"]) + + data = self._get_stage() + + self.assertEqual(data["stage"], "FAILED") + self.assertEqual(data["failed_stage"], "ANALYSIS") + + def test_stage_completed_when_analysis_completed(self): + self.material.status = MaterialStatus.COMPLETED + self.material.analysis_status = MaterialStatus.COMPLETED + self.material.save(update_fields=["status", "analysis_status"]) + + data = self._get_stage() + + self.assertEqual(data["stage"], "COMPLETED") + self.assertIsNone(data["failed_stage"]) + + def test_stage_prioritizes_extraction_over_stale_analysis_failure(self): + self.material.status = MaterialStatus.PROCESSING + self.material.analysis_status = MaterialStatus.FAILED + self.material.save(update_fields=["status", "analysis_status"]) + + data = self._get_stage() + + self.assertEqual(data["stage"], "EXTRACTING") + self.assertIsNone(data["failed_stage"]) + + def test_stage_response_still_includes_existing_fields(self): + self.material.analysis_status = MaterialStatus.FAILED + self.material.analysis_error_message = "테스트 실패 사유" + self.material.analysis_retry_count = 1 + self.material.save(update_fields=[ + "analysis_status", "analysis_error_message", "analysis_retry_count", + ]) + + data = self._get_stage() + + self.assertEqual(data["analysis_status"], MaterialStatus.FAILED) + self.assertEqual(data["analysis_error_message"], "테스트 실패 사유") + self.assertEqual(data["retry_count"], 1) + self.assertEqual(data["retry_remaining"], MAX_RETRY_COUNT - 1) + + def test_stage_response_includes_extraction_fields(self): + self.material.status = MaterialStatus.FAILED + self.material.error_message = "PDF 추출 실패 사유" + self.material.save(update_fields=["status", "error_message"]) + + data = self._get_stage() + + self.assertEqual(data["extraction_status"], MaterialStatus.FAILED) + self.assertEqual(data["extraction_error_message"], "PDF 추출 실패 사유") + + @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("예상시간 계산 중 알 수 없는 오류") + self.client.force_login(self.owner) + + response = self.client.post( + reverse('exams:material_analyze', args=[self.material.id]), follow=True + ) + + self.assertEqual(response.status_code, 200) + self.assertRedirects( + response, reverse('exams:material_detail', args=[self.material.id]) + ) + messages_list = list(response.context['messages']) + self.assertTrue(any("AI 분석에 실패했습니다" in str(m) for m in messages_list)) + + self.material.refresh_from_db() + self.assertEqual(self.material.analysis_status, MaterialStatus.FAILED) self.assertEqual( - data["retry_count"], - self.material.analysis_retry_count, + self.material.analysis_error_message, + "분석 중 알 수 없는 오류가 발생했습니다. 잠시 후 다시 시도해주세요.", ) + + @patch("exams.services.analysis_orchestrator.estimate_task_minutes") + def test_retry_unexpected_exception_redirects_instead_of_500(self, mock_estimate): + mock_estimate.side_effect = ValueError("예상시간 계산 중 알 수 없는 오류") + self.material.analysis_status = MaterialStatus.FAILED + self.material.analysis_retry_count = 0 + self.material.save(update_fields=["analysis_status", "analysis_retry_count"]) + self.client.force_login(self.owner) + + response = self.client.post( + reverse('exams:material_retry_analyze', args=[self.material.id]), follow=True + ) + + self.assertEqual(response.status_code, 200) + self.assertRedirects( + response, reverse('exams:material_detail', args=[self.material.id]) + ) + messages_list = list(response.context['messages']) + self.assertTrue(any("재시도한 AI 분석도 실패했습니다" in str(m) for m in messages_list)) + + self.material.refresh_from_db() + self.assertEqual(self.material.analysis_status, MaterialStatus.FAILED) self.assertEqual( - data["retry_remaining"], - max(0, 2 - self.material.analysis_retry_count), + self.material.analysis_error_message, + "분석 중 알 수 없는 오류가 발생했습니다. 잠시 후 다시 시도해주세요.", ) + self.assertEqual(self.material.analysis_retry_count, 1) class MaterialCreateTests(TestCase): diff --git a/exams/views.py b/exams/views.py index c83101d..7c8ba09 100644 --- a/exams/views.py +++ b/exams/views.py @@ -5,6 +5,7 @@ from django.contrib import messages from django.http import JsonResponse from django.db import transaction +import logging from core.choices import ExamPeriodStatus, MaterialStatus, MaterialType from core.exceptions import AIAnalysisError @@ -31,6 +32,8 @@ AnalysisPipelineError, ) +logger = logging.getLogger(__name__) + # ===================================================================== # 시험기간 목록 (exams:period_list) @@ -369,12 +372,17 @@ def material_analyze(request, material_id): try: analyze_and_estimate(material) + messages.success(request, "AI 분석 및 예상 시간 산출이 시작되었습니다.") except DuplicateAnalysisRequestError: messages.info(request, "이미 분석 중이거나 처리된 자료입니다.") return redirect('exams:material_detail', material_id=material.id) - except (AIAnalysisError, AnalysisPipelineError, Exception): + except (AIAnalysisError, AnalysisPipelineError): messages.error(request, "AI 분석에 실패했습니다. 다시 시도하거나 직접 작업을 추가해주세요.") return redirect('exams:material_detail', material_id=material.id) + except Exception: + logger.exception(f"AI 분석 실행 중 예기치 못한 시스템 오류 발생 (material_id={material_id})") + messages.error(request, "AI 분석 처리 중 알 수 없는 시스템 오류가 발생했습니다.") + return redirect('exams:material_detail', material_id=material.id) messages.success(request, "AI 분석이 완료되었습니다.") return redirect('exams:task_review', exam_id=material.exam_id) @@ -401,9 +409,14 @@ 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, Exception): + # except Exception 제거 및 서비스 예외만 처리 + except (AIAnalysisError, AnalysisPipelineError): messages.error(request, "재시도한 AI 분석도 실패했습니다.") return redirect('exams:material_detail', material_id=material.id) + except Exception: + logger.exception(f"AI 분석 재시도 중 예기치 못한 시스템 오류 발생 (material_id={material_id})") + messages.error(request, "AI 분석 재시도 처리 중 알 수 없는 시스템 오류가 발생했습니다.") + return redirect('exams:material_detail', material_id=material.id) messages.success(request, "AI 분석이 완료되었습니다.") return redirect('exams:task_review', exam_id=material.exam_id) @@ -422,12 +435,17 @@ def material_analysis_status(request, material_id): StudyMaterial, id=material_id, exam__exam_period__user=request.user ) + # 리뷰 요청 반영: get_analysis_status(material) 호출 및 필드 매핑 + analysis_data = get_analysis_status(material) + extraction_status = material.status extraction_error = material.error_message - analysis_status = material.analysis_status - analysis_error = material.analysis_error_message + analysis_status = analysis_data["status"] + analysis_error = analysis_data["error_message"] + retry_count = analysis_data["retry_count"] + retry_remaining = analysis_data["retry_remaining"] - # 1. 전체 stage 판정 로직 + # 1. 전체 stage 판정 로직 (작성하신 추출 우선 stage 판정 유지) failed_stage = None # ① 재추출 진행 중이면 이전 분석 실패보다 최우선으로 "EXTRACTING" @@ -455,15 +473,11 @@ def material_analysis_status(request, material_id): ): stage = "COMPLETED" - # ⑥ 아무것도 안 한 PENDING 상태 (PENDING이 튜플에서 빠져서 여기로 옴) + # ⑥ 아무것도 안 한 PENDING 상태 else: stage = "PENDING" - # 2. 재시도 정보 계산 (API 계약 필수 필드) - retry_count = material.analysis_retry_count - retry_remaining = max(0, MAX_RETRY_COUNT - retry_count) - - # 3. 약속된 JSON 응답 스펙 반환 + # 2. 약속된 JSON 응답 스펙 반환 return JsonResponse({ "stage": stage, "extraction_status": extraction_status, From 2eec0fb2b7d6e9373978a9984dfa3220d288b03f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Ch2ew0n=E2=80=9D?= <“janghee1122@naver.com”> Date: Thu, 6 Aug 2026 15:24:47 +0900 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=EC=8B=9C=20=EC=98=A4=EB=A5=98=20=EB=A9=94?= =?UTF-8?q?=EC=84=B8=EC=A7=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- accounts/forms.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/accounts/forms.py b/accounts/forms.py index 324454a..8cd68a5 100644 --- a/accounts/forms.py +++ b/accounts/forms.py @@ -63,6 +63,12 @@ class CustomAuthenticationForm(AuthenticationForm): """ 로그인 """ + + error_messages = { + **AuthenticationForm.error_messages, + 'invalid_login': "이메일 또는 비밀번호가 올바르지 않습니다.", + } + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) From 0326aa99ac0500fc9ff0b07438ba2224f7324036 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Ch2ew0n=E2=80=9D?= <“janghee1122@naver.com”> Date: Thu, 6 Aug 2026 15:29:10 +0900 Subject: [PATCH 4/6] =?UTF-8?q?refactory:get=20=EB=B0=A9=EC=8B=9D=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- exams/views.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/exams/views.py b/exams/views.py index 7c8ba09..d8cc18a 100644 --- a/exams/views.py +++ b/exams/views.py @@ -440,10 +440,10 @@ def material_analysis_status(request, material_id): extraction_status = material.status extraction_error = material.error_message - analysis_status = analysis_data["status"] - analysis_error = analysis_data["error_message"] - retry_count = analysis_data["retry_count"] - retry_remaining = analysis_data["retry_remaining"] + analysis_status = analysis_data.get("status") or analysis_data.get("analysis_status") + analysis_error = analysis_data.get("error_message") or analysis_data.get("analysis_error_message") + retry_count = analysis_data.get("retry_count", material.analysis_retry_count) + retry_remaining = analysis_data.get("retry_remaining", max(0, 2 - material.analysis_retry_count)) # 1. 전체 stage 판정 로직 (작성하신 추출 우선 stage 판정 유지) failed_stage = None From 4a1ae3ec6662bf8cb5b28a205a7076ba400efad9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Ch2ew0n=E2=80=9D?= <“janghee1122@naver.com”> Date: Thu, 6 Aug 2026 22:59:18 +0900 Subject: [PATCH 5/6] =?UTF-8?q?refactor(exams):=20AI=20=EB=B6=84=EC=84=9D?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=20=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EC=B5=9C=EC=A2=85=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - exams/tests.py의 MaterialAnalysisViewTestCase 중복 선언 제거 및 테스트 정합성 확보 - 미완성 타임아웃 모킹 테스트 제거 및 추후 PR #53 위임 - material_analyze() 내 중복 노출되던 성공 메시지(시작되었습니다) 제거 - material_analysis_status()에서 fallback 및 하드코딩을 제거하고 확정된 서비스 계약 키 직접 사용 --- ...d.py_2bfa7c7b2a90cc94fa8443d0ff2cd125.prob | 1 + exams/tests.py | 299 +----------------- exams/views.py | 17 +- 3 files changed, 11 insertions(+), 306 deletions(-) create mode 100644 .cph/.td.py_2bfa7c7b2a90cc94fa8443d0ff2cd125.prob diff --git a/.cph/.td.py_2bfa7c7b2a90cc94fa8443d0ff2cd125.prob b/.cph/.td.py_2bfa7c7b2a90cc94fa8443d0ff2cd125.prob new file mode 100644 index 0000000..42009e4 --- /dev/null +++ b/.cph/.td.py_2bfa7c7b2a90cc94fa8443d0ff2cd125.prob @@ -0,0 +1 @@ +{"name":"Local: td","url":"/Users/jangheewon/Desktop/PLAN-B/td.py","tests":[{"id":1786013086723,"input":"","output":""}],"interactive":false,"memoryLimit":1024,"timeLimit":3000,"srcPath":"/Users/jangheewon/Desktop/PLAN-B/td.py","group":"local","local":true} \ No newline at end of file diff --git a/exams/tests.py b/exams/tests.py index d579dd1..4ce995b 100644 --- a/exams/tests.py +++ b/exams/tests.py @@ -222,7 +222,7 @@ def test_save_only_action(self): self.assertFalse(self.task.is_confirmed) def test_confirm_and_next_action(self): - """'모두 확정하고 다음으로' 버튼 클릭 시 실제 planner:feasibility 라우팅 검증 (모킹 제거 반영)""" + """'모두 확정하고 다음으로' 버튼 클릭 시 실제 planner:feasibility 라우팅 검증""" post_data = { "form-TOTAL_FORMS": "1", "form-INITIAL_FORMS": "1", @@ -240,7 +240,6 @@ def test_confirm_and_next_action(self): response = self.client.post(self.url, post_data) - # 리다이렉트 URL 및 kwargs 인자 정상 전달 검증 self.assertRedirects( response, reverse( @@ -367,35 +366,6 @@ def test_analysis_status_endpoint_returns_json(self): self.assertEqual(data["retry_count"], 1) self.assertEqual(data["retry_remaining"], 1) - # ========================================================================= - # [리뷰어 요청 반영] 타임아웃 응답 검증 통합 테스트 - # ========================================================================= - def test_analysis_status_returns_failed_when_orchestrator_detects_timeout(self): - """오래된 PROCESSING 상태 조회 시 get_analysis_status에서 반환된 타임아웃(FAILED) 상태가 API에 반영되는지 검증""" - self.material.status = MaterialStatus.COMPLETED - self.material.analysis_status = MaterialStatus.PROCESSING - self.material.save(update_fields=["status", "analysis_status"]) - self.client.force_login(self.owner) - - mock_timeout_data = { - "status": MaterialStatus.FAILED, - "error_message": "분석 시간이 초과되었습니다.", - "retry_count": 0, - "retry_remaining": 2, - } - - with patch("exams.views.get_analysis_status", return_value=mock_timeout_data): - response = self.client.get(reverse('exams:material_analysis_status', args=[self.material.id])) - self.assertEqual(response.status_code, 200) - data = response.json() - - self.assertEqual(data["stage"], "FAILED") - self.assertEqual(data["analysis_status"], MaterialStatus.FAILED) - self.assertEqual(data["failed_stage"], "ANALYSIS") - self.assertEqual(data["analysis_error_message"], "분석 시간이 초과되었습니다.") - self.assertEqual(data["retry_count"], 0) - self.assertEqual(data["retry_remaining"], 2) - def test_other_user_cannot_view_analysis_status(self): self.client.force_login(self.other) response = self.client.get(reverse('exams:material_analysis_status', args=[self.material.id])) @@ -1032,273 +1002,6 @@ def test_retry_blocked_when_extraction_wins_race_after_status_check(self): self.assertEqual(material.status, MaterialStatus.PROCESSING) -class MaterialAnalysisViewTestCase(TestCase): - def setUp(self): - self.owner = User.objects.create_user( - username="view_owner@example.com", email="view_owner@example.com", password="pass1234!" - ) - self.other = User.objects.create_user( - username="view_other@example.com", email="view_other@example.com", password="pass1234!" - ) - self.period = ExamPeriod.objects.create( - user=self.owner, title="뷰 테스트 시험기간", - start_date=datetime.date(2026, 8, 1), end_date=datetime.date(2026, 8, 20), - ) - self.exam = Exam.objects.create( - exam_period=self.period, subject_name="뷰 테스트 과목", exam_date=datetime.date(2026, 8, 18), - ) - self.material = StudyMaterial.objects.create( - exam=self.exam, title="테스트 자료", material_type=MaterialType.TEXT, - extracted_text="1장 개념 정리", status=MaterialStatus.COMPLETED, - ) - - def test_analyze_requires_login(self): - response = self.client.post(reverse('exams:material_analyze', args=[self.material.id])) - self.assertEqual(response.status_code, 302) - self.assertIn('/login', response.url) - - def test_other_user_cannot_trigger_analyze(self): - self.client.force_login(self.other) - response = self.client.post(reverse('exams:material_analyze', args=[self.material.id])) - self.assertEqual(response.status_code, 404) - - def test_analyze_rejected_when_extraction_not_completed(self): - self.material.status = MaterialStatus.PENDING - self.material.save(update_fields=["status"]) - self.client.force_login(self.owner) - - response = self.client.post( - reverse('exams:material_analyze', args=[self.material.id]), follow=True - ) - - self.material.refresh_from_db() - self.assertEqual(self.material.analysis_status, MaterialStatus.PENDING) - messages_list = list(response.context['messages']) - self.assertTrue(any("텍스트 추출이 완료된 자료만" in str(m) for m in messages_list)) - - def test_analyze_success_redirects_to_task_review(self): - self.client.force_login(self.owner) - response = self.client.post( - reverse('exams:material_analyze', args=[self.material.id]) - ) - - self.assertRedirects(response, reverse('exams:task_review', args=[self.exam.id])) - self.material.refresh_from_db() - self.assertEqual(self.material.analysis_status, MaterialStatus.COMPLETED) - self.assertTrue(StudyTask.objects.filter(study_material=self.material).exists()) - - def test_analyze_duplicate_request_shows_info_message(self): - self.material.analysis_status = MaterialStatus.PROCESSING - self.material.save(update_fields=["analysis_status"]) - self.client.force_login(self.owner) - - response = self.client.post( - reverse('exams:material_analyze', args=[self.material.id]), follow=True - ) - - messages_list = list(response.context['messages']) - self.assertTrue(any("이미 분석 중" in str(m) for m in messages_list)) - - def test_retry_from_failed_succeeds(self): - self.material.analysis_status = MaterialStatus.FAILED - self.material.analysis_retry_count = 0 - self.material.save(update_fields=["analysis_status", "analysis_retry_count"]) - self.client.force_login(self.owner) - - response = self.client.post( - reverse('exams:material_retry_analyze', args=[self.material.id]) - ) - - self.assertRedirects(response, reverse('exams:task_review', args=[self.exam.id])) - self.material.refresh_from_db() - self.assertEqual(self.material.analysis_status, MaterialStatus.COMPLETED) - self.assertEqual(self.material.analysis_retry_count, 1) - - def test_retry_blocked_when_completed(self): - self.material.analysis_status = MaterialStatus.COMPLETED - self.material.save(update_fields=["analysis_status"]) - self.client.force_login(self.owner) - - response = self.client.post( - reverse('exams:material_retry_analyze', args=[self.material.id]), follow=True - ) - - messages_list = list(response.context['messages']) - self.assertTrue(any("재분석을 지원하지 않습니다" in str(m) for m in messages_list)) - - def test_analysis_status_endpoint_returns_json(self): - self.material.analysis_status = MaterialStatus.FAILED - self.material.analysis_error_message = "네트워크 오류" - self.material.analysis_retry_count = 1 - self.material.save(update_fields=["analysis_status", "analysis_error_message", "analysis_retry_count"]) - self.client.force_login(self.owner) - - response = self.client.get(reverse('exams:material_analysis_status', args=[self.material.id])) - - 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) - - def test_other_user_cannot_view_analysis_status(self): - self.client.force_login(self.other) - response = self.client.get(reverse('exams:material_analysis_status', args=[self.material.id])) - self.assertEqual(response.status_code, 404) - - def _get_stage(self): - self.client.force_login(self.owner) - response = self.client.get(reverse('exams:material_analysis_status', args=[self.material.id])) - return response.json() - - def test_stage_pending_when_nothing_started(self): - self.material.status = MaterialStatus.PENDING - self.material.analysis_status = MaterialStatus.PENDING - self.material.save(update_fields=["status", "analysis_status"]) - - data = self._get_stage() - - self.assertEqual(data["stage"], "PENDING") - self.assertIsNone(data["failed_stage"]) - - def test_stage_extracting_when_extraction_processing(self): - self.material.status = MaterialStatus.PROCESSING - self.material.save(update_fields=["status"]) - - data = self._get_stage() - - self.assertEqual(data["stage"], "EXTRACTING") - self.assertIsNone(data["failed_stage"]) - - def test_stage_failed_with_extraction_when_extraction_failed(self): - self.material.status = MaterialStatus.FAILED - self.material.save(update_fields=["status"]) - - data = self._get_stage() - - self.assertEqual(data["stage"], "FAILED") - self.assertEqual(data["failed_stage"], "EXTRACTION") - - def test_stage_analyzing_when_analysis_processing(self): - self.material.status = MaterialStatus.COMPLETED - self.material.analysis_status = MaterialStatus.PROCESSING - self.material.save(update_fields=["status", "analysis_status"]) - - data = self._get_stage() - - self.assertEqual(data["stage"], "ANALYZING") - self.assertIsNone(data["failed_stage"]) - - def test_stage_failed_with_analysis_when_analysis_failed(self): - self.material.status = MaterialStatus.COMPLETED - self.material.analysis_status = MaterialStatus.FAILED - self.material.save(update_fields=["status", "analysis_status"]) - - data = self._get_stage() - - self.assertEqual(data["stage"], "FAILED") - self.assertEqual(data["failed_stage"], "ANALYSIS") - - def test_stage_completed_when_analysis_completed(self): - self.material.status = MaterialStatus.COMPLETED - self.material.analysis_status = MaterialStatus.COMPLETED - self.material.save(update_fields=["status", "analysis_status"]) - - data = self._get_stage() - - self.assertEqual(data["stage"], "COMPLETED") - self.assertIsNone(data["failed_stage"]) - - def test_stage_prioritizes_extraction_over_stale_analysis_failure(self): - self.material.status = MaterialStatus.PROCESSING - self.material.analysis_status = MaterialStatus.FAILED - self.material.save(update_fields=["status", "analysis_status"]) - - data = self._get_stage() - - self.assertEqual(data["stage"], "EXTRACTING") - self.assertIsNone(data["failed_stage"]) - - def test_stage_response_still_includes_existing_fields(self): - self.material.analysis_status = MaterialStatus.FAILED - self.material.analysis_error_message = "테스트 실패 사유" - self.material.analysis_retry_count = 1 - self.material.save(update_fields=[ - "analysis_status", "analysis_error_message", "analysis_retry_count", - ]) - - data = self._get_stage() - - self.assertEqual(data["analysis_status"], MaterialStatus.FAILED) - self.assertEqual(data["analysis_error_message"], "테스트 실패 사유") - self.assertEqual(data["retry_count"], 1) - self.assertEqual(data["retry_remaining"], MAX_RETRY_COUNT - 1) - - def test_stage_response_includes_extraction_fields(self): - self.material.status = MaterialStatus.FAILED - self.material.error_message = "PDF 추출 실패 사유" - self.material.save(update_fields=["status", "error_message"]) - - data = self._get_stage() - - self.assertEqual(data["extraction_status"], MaterialStatus.FAILED) - self.assertEqual(data["extraction_error_message"], "PDF 추출 실패 사유") - - @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("예상시간 계산 중 알 수 없는 오류") - self.client.force_login(self.owner) - - response = self.client.post( - reverse('exams:material_analyze', args=[self.material.id]), follow=True - ) - - self.assertEqual(response.status_code, 200) - self.assertRedirects( - response, reverse('exams:material_detail', args=[self.material.id]) - ) - messages_list = list(response.context['messages']) - self.assertTrue(any("AI 분석에 실패했습니다" in str(m) for m in messages_list)) - - self.material.refresh_from_db() - self.assertEqual(self.material.analysis_status, MaterialStatus.FAILED) - self.assertEqual( - self.material.analysis_error_message, - "분석 중 알 수 없는 오류가 발생했습니다. 잠시 후 다시 시도해주세요.", - ) - - @patch("exams.services.analysis_orchestrator.estimate_task_minutes") - def test_retry_unexpected_exception_redirects_instead_of_500(self, mock_estimate): - mock_estimate.side_effect = ValueError("예상시간 계산 중 알 수 없는 오류") - self.material.analysis_status = MaterialStatus.FAILED - self.material.analysis_retry_count = 0 - self.material.save(update_fields=["analysis_status", "analysis_retry_count"]) - self.client.force_login(self.owner) - - response = self.client.post( - reverse('exams:material_retry_analyze', args=[self.material.id]), follow=True - ) - - self.assertEqual(response.status_code, 200) - self.assertRedirects( - response, reverse('exams:material_detail', args=[self.material.id]) - ) - messages_list = list(response.context['messages']) - self.assertTrue(any("재시도한 AI 분석도 실패했습니다" in str(m) for m in messages_list)) - - self.material.refresh_from_db() - self.assertEqual(self.material.analysis_status, MaterialStatus.FAILED) - self.assertEqual( - self.material.analysis_error_message, - "분석 중 알 수 없는 오류가 발생했습니다. 잠시 후 다시 시도해주세요.", - ) - self.assertEqual(self.material.analysis_retry_count, 1) - - class PdfExtractorTestCase(TestCase): def test_extract_text_success(self): diff --git a/exams/views.py b/exams/views.py index d8cc18a..926d4c9 100644 --- a/exams/views.py +++ b/exams/views.py @@ -372,7 +372,8 @@ def material_analyze(request, material_id): try: analyze_and_estimate(material) - messages.success(request, "AI 분석 및 예상 시간 산출이 시작되었습니다.") + # 피드백 3번 반영: 동기식이므로 "시작되었습니다" 메시지 제거하고 완료 메시지만 노출 + messages.success(request, "AI 분석이 완료되었습니다.") except DuplicateAnalysisRequestError: messages.info(request, "이미 분석 중이거나 처리된 자료입니다.") return redirect('exams:material_detail', material_id=material.id) @@ -384,7 +385,6 @@ def material_analyze(request, material_id): messages.error(request, "AI 분석 처리 중 알 수 없는 시스템 오류가 발생했습니다.") return redirect('exams:material_detail', material_id=material.id) - messages.success(request, "AI 분석이 완료되었습니다.") return redirect('exams:task_review', exam_id=material.exam_id) @@ -409,7 +409,6 @@ 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 Exception 제거 및 서비스 예외만 처리 except (AIAnalysisError, AnalysisPipelineError): messages.error(request, "재시도한 AI 분석도 실패했습니다.") return redirect('exams:material_detail', material_id=material.id) @@ -435,15 +434,17 @@ def material_analysis_status(request, material_id): StudyMaterial, id=material_id, exam__exam_period__user=request.user ) - # 리뷰 요청 반영: get_analysis_status(material) 호출 및 필드 매핑 + # get_analysis_status(material) 호출 복원 analysis_data = get_analysis_status(material) extraction_status = material.status extraction_error = material.error_message - analysis_status = analysis_data.get("status") or analysis_data.get("analysis_status") - analysis_error = analysis_data.get("error_message") or analysis_data.get("analysis_error_message") - retry_count = analysis_data.get("retry_count", material.analysis_retry_count) - retry_remaining = analysis_data.get("retry_remaining", max(0, 2 - material.analysis_retry_count)) + + # 피드백 4번 반영: 확정된 키 직접 사용 (fallback 제거) + analysis_status = analysis_data["status"] + analysis_error = analysis_data["error_message"] + retry_count = analysis_data["retry_count"] + retry_remaining = analysis_data["retry_remaining"] # 1. 전체 stage 판정 로직 (작성하신 추출 우선 stage 판정 유지) failed_stage = None From ee71b1ae00fb3b8b4355859ac1183bfc9a87abc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Ch2ew0n=E2=80=9D?= <“janghee1122@naver.com”> Date: Thu, 6 Aug 2026 23:52:56 +0900 Subject: [PATCH 6/6] chore: add .cph to gitignore and cleanup config --- .cph/.td.py_2bfa7c7b2a90cc94fa8443d0ff2cd125.prob | 1 - .gitignore | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) delete mode 100644 .cph/.td.py_2bfa7c7b2a90cc94fa8443d0ff2cd125.prob diff --git a/.cph/.td.py_2bfa7c7b2a90cc94fa8443d0ff2cd125.prob b/.cph/.td.py_2bfa7c7b2a90cc94fa8443d0ff2cd125.prob deleted file mode 100644 index 42009e4..0000000 --- a/.cph/.td.py_2bfa7c7b2a90cc94fa8443d0ff2cd125.prob +++ /dev/null @@ -1 +0,0 @@ -{"name":"Local: td","url":"/Users/jangheewon/Desktop/PLAN-B/td.py","tests":[{"id":1786013086723,"input":"","output":""}],"interactive":false,"memoryLimit":1024,"timeLimit":3000,"srcPath":"/Users/jangheewon/Desktop/PLAN-B/td.py","group":"local","local":true} \ No newline at end of file diff --git a/.gitignore b/.gitignore index df60ebe..a2b3706 100644 --- a/.gitignore +++ b/.gitignore @@ -83,4 +83,6 @@ temp/ *.tmp *.bak -test_real_api_3subjects.py \ No newline at end of file +test_real_api_3subjects.py + +.cph/