"""Tests for AnalysisService — callbacks, concurrency, orchestration, and batching.""" from __future__ import annotations import asyncio import functools import json from unittest.mock import AsyncMock, MagicMock, patch import pytest from domain.models import AnalysisStatus from domain.services import extract_html_body, merge_results from domain.value_objects import ConversionResult, PageDetail from services.analysis_service import AnalysisConfig, AnalysisService, _count_pdf_pages def _make_service(**kwargs) -> AnalysisService: """Create an AnalysisService with mock repos for testing.""" defaults = { "converter": MagicMock(), "analysis_repo": MagicMock(), "document_repo": MagicMock(), } defaults.update(kwargs) return AnalysisService(**defaults) class TestOnTaskDone: """Bug #1: _on_task_done must call _mark_failed when the task raises.""" @pytest.mark.asyncio async def test_exception_marks_job_failed(self): """When a background task raises, the job should be marked FAILED.""" job_id = "job-123" service = _make_service() async def failing_task(): raise RuntimeError("unexpected failure") task = asyncio.create_task(failing_task()) await asyncio.sleep(0) with patch.object(service, "_mark_failed", new_callable=AsyncMock) as mock_mark: service._on_task_done(task, job_id=job_id) await asyncio.sleep(0) mock_mark.assert_called_once_with(job_id, "unexpected failure") @pytest.mark.asyncio async def test_exception_uses_classify_error(self): """_on_task_done should route exceptions through classify_error.""" job_id = "job-classify" service = _make_service() async def timeout_task(): raise TimeoutError("timeout exceeded while processing") task = asyncio.create_task(timeout_task()) await asyncio.sleep(0) with patch.object(service, "_mark_failed", new_callable=AsyncMock) as mock_mark: service._on_task_done(task, job_id=job_id) await asyncio.sleep(0) mock_mark.assert_called_once_with( job_id, "Processing took too long — try with fewer pages or simpler options" ) @pytest.mark.asyncio async def test_cancelled_task_marks_job_failed(self): """When a background task is cancelled, the job should be marked FAILED.""" job_id = "job-456" service = _make_service() async def slow_task(): await asyncio.sleep(999) import contextlib task = asyncio.create_task(slow_task()) task.cancel() with contextlib.suppress(asyncio.CancelledError): await task with patch.object(service, "_mark_failed", new_callable=AsyncMock) as mock_mark: service._on_task_done(task, job_id=job_id) await asyncio.sleep(0) mock_mark.assert_called_once_with(job_id, "Task was cancelled") @pytest.mark.asyncio async def test_successful_task_does_not_mark_failed(self): """When a background task succeeds, _mark_failed should not be called.""" job_id = "job-789" service = _make_service() async def ok_task(): return "done" task = asyncio.create_task(ok_task()) await task with patch.object(service, "_mark_failed", new_callable=AsyncMock) as mock_mark: service._on_task_done(task, job_id=job_id) await asyncio.sleep(0) mock_mark.assert_not_called() class TestAnalysisServiceCancellation: """Verify delete cancels running tasks.""" @pytest.mark.asyncio async def test_delete_cancels_running_task(self): """Deleting a job while running should cancel its task.""" mock_analysis_repo = MagicMock() mock_analysis_repo.delete = AsyncMock(return_value=True) service = _make_service(analysis_repo=mock_analysis_repo) blocker = asyncio.Event() async def slow_analysis(): await blocker.wait() task = asyncio.create_task(slow_analysis()) service._running_tasks["j1"] = task result = await service.delete("j1") assert result is True assert task.cancelling() or task.cancelled() assert "j1" not in service._running_tasks @pytest.mark.asyncio async def test_delete_completed_job_no_error(self): """Deleting a completed job should not raise even if no task tracked.""" mock_analysis_repo = MagicMock() mock_analysis_repo.delete = AsyncMock(return_value=True) service = _make_service(analysis_repo=mock_analysis_repo) result = await service.delete("j-gone") assert result is True @pytest.mark.asyncio async def test_task_cleaned_from_running_on_completion(self): """After a task completes, it should be removed from _running_tasks.""" service = _make_service() async def instant(): pass task = asyncio.create_task(instant()) service._running_tasks["j1"] = task task.add_done_callback(functools.partial(service._on_task_done, job_id="j1")) await asyncio.gather(task) assert "j1" not in service._running_tasks class TestAnalysisServiceConcurrency: """Verify that the semaphore limits concurrent analysis jobs.""" def test_semaphore_initialized_with_max_concurrent(self): service = _make_service(max_concurrent=5) assert service._semaphore._value == 5 def test_default_max_concurrent(self): service = _make_service() assert service._semaphore._value == 3 @pytest.mark.asyncio async def test_semaphore_limits_parallel_jobs(self): """Only max_concurrent jobs should run in parallel; others must wait.""" call_order: list[str] = [] blocker = asyncio.Event() service = _make_service(max_concurrent=1) async def fake_inner(self, *args, **kwargs): call_order.append("start") await blocker.wait() call_order.append("end") with patch.object(AnalysisService, "_run_analysis_inner", fake_inner): t1 = asyncio.create_task(service._run_analysis("j1", "/f", "f.pdf")) t2 = asyncio.create_task(service._run_analysis("j2", "/f", "f.pdf")) await asyncio.sleep(0.05) # With max_concurrent=1, only one task should have started assert call_order.count("start") == 1 blocker.set() await asyncio.gather(t1, t2) # Both should have completed assert call_order.count("start") == 2 assert call_order.count("end") == 2 # --------------------------------------------------------------------------- # Batch helper tests # --------------------------------------------------------------------------- class TestCountPdfPages: def test_valid_pdf(self, tmp_path): """A real (minimal) PDF should return its page count.""" import pypdfium2 as pdfium pdf = pdfium.PdfDocument.new() pdf.new_page(612, 792) path = tmp_path / "test.pdf" pdf.save(str(path)) pdf.close() assert _count_pdf_pages(str(path)) == 1 def test_non_pdf_file(self, tmp_path): """A non-PDF file should return 0.""" path = tmp_path / "test.docx" path.write_bytes(b"PK\x03\x04 not a pdf") assert _count_pdf_pages(str(path)) == 0 def test_nonexistent_file(self): """A nonexistent file should return 0.""" assert _count_pdf_pages("/no/such/file.pdf") == 0 def test_empty_file(self, tmp_path): """An empty file should return 0.""" path = tmp_path / "empty.pdf" path.write_bytes(b"") assert _count_pdf_pages(str(path)) == 0 class TestExtractHtmlBody: def test_extracts_body(self): html = '

Hello

' assert extract_html_body(html) == "

Hello

" def test_no_body_tag_returns_raw(self): html = "

No body tag

" assert extract_html_body(html) == html def test_empty_body(self): html = "" assert extract_html_body(html) == "" class TestMergeResults: def test_empty_list(self): result = merge_results([]) assert result.page_count == 0 assert result.content_markdown == "" assert result.pages == [] assert result.document_json is None def test_single_result_passthrough(self): r = ConversionResult( page_count=3, content_markdown="# Page 1", content_html="

Page 1

", pages=[PageDetail(page_number=1, width=612, height=792)], document_json='{"pages": {}}', ) merged = merge_results([r]) assert merged.page_count == 3 assert merged.content_markdown == "# Page 1" assert merged.pages == [PageDetail(page_number=1, width=612, height=792)] assert merged.document_json is None # intentionally dropped def test_merges_multiple_results(self): r1 = ConversionResult( page_count=2, content_markdown="# Batch 1", content_html="

B1

", pages=[ PageDetail(page_number=1, width=612, height=792), PageDetail(page_number=2, width=612, height=792), ], skipped_items=1, ) r2 = ConversionResult( page_count=2, content_markdown="# Batch 2", content_html="

B2

", pages=[ PageDetail(page_number=3, width=612, height=792), PageDetail(page_number=4, width=612, height=792), ], skipped_items=2, ) merged = merge_results([r1, r2]) assert merged.page_count == 4 assert merged.content_markdown == "# Batch 1\n\n# Batch 2" assert len(merged.pages) == 4 assert merged.pages[0].page_number == 1 assert merged.pages[3].page_number == 4 assert merged.skipped_items == 3 assert merged.document_json is None assert "

B1

" in merged.content_html assert "

B2

" in merged.content_html # Valid HTML structure assert merged.content_html.startswith("") assert "" in merged.content_html # --------------------------------------------------------------------------- # Batched conversion integration tests # --------------------------------------------------------------------------- class TestBatchedConversion: @pytest.mark.asyncio async def test_batched_conversion_produces_merged_result(self): """When batch_page_size is set and document exceeds it, results are merged.""" converter = AsyncMock() # Simulate 2 batches: pages 1-5 and 6-8 converter.convert.side_effect = [ ConversionResult( page_count=5, content_markdown="# Batch 1", content_html="

B1

", pages=[PageDetail(page_number=i, width=612, height=792) for i in range(1, 6)], ), ConversionResult( page_count=3, content_markdown="# Batch 2", content_html="

B2

", pages=[PageDetail(page_number=i, width=612, height=792) for i in range(6, 9)], ), ] mock_analysis_repo = MagicMock() mock_analysis_repo.find_by_id = AsyncMock(return_value=MagicMock()) mock_analysis_repo.update_progress = AsyncMock() service = _make_service( converter=converter, analysis_repo=mock_analysis_repo, conversion_timeout=60, ) result = await service._run_batched_conversion( "job-1", "/fake.pdf", MagicMock(), total_pages=8, batch_size=5, ) assert result is not None assert result.page_count == 8 assert len(result.pages) == 8 assert result.document_json is None assert converter.convert.call_count == 2 # Verify page_range was passed correctly call1_kwargs = converter.convert.call_args_list[0].kwargs call2_kwargs = converter.convert.call_args_list[1].kwargs assert call1_kwargs["page_range"] == (1, 5) assert call2_kwargs["page_range"] == (6, 8) @pytest.mark.asyncio async def test_batch_failure_raises_with_enriched_message(self): """If a batch fails, RuntimeError is raised with batch info.""" converter = AsyncMock() converter.convert.side_effect = [ ConversionResult( page_count=5, content_markdown="ok", content_html="ok", pages=[PageDetail(page_number=i, width=612, height=792) for i in range(1, 6)], ), RuntimeError("OOM"), ] mock_analysis_repo = MagicMock() mock_analysis_repo.find_by_id = AsyncMock(return_value=MagicMock()) mock_analysis_repo.update_progress = AsyncMock() service = _make_service( converter=converter, analysis_repo=mock_analysis_repo, conversion_timeout=60, ) with pytest.raises(RuntimeError, match=r"Batch 2/2 \(pages 6-8\) failed: OOM"): await service._run_batched_conversion( "job-fail", "/fake.pdf", MagicMock(), total_pages=8, batch_size=5, ) @pytest.mark.asyncio async def test_progress_preserved_through_full_analysis_flow(self): """Progress written during batches must survive the final update_status. Regression: _run_analysis_inner used to re-read the job from DB at the start, then call update_status(job) at the end — overwriting progress_current/progress_total with None because the in-memory object was stale. The fix re-reads the job before mark_completed. """ from domain.models import AnalysisJob, AnalysisStatus converter = AsyncMock() converter.convert.side_effect = [ ConversionResult( page_count=5, content_markdown="# B1", content_html="

B1

", pages=[PageDetail(page_number=i, width=612, height=792) for i in range(1, 6)], ), ConversionResult( page_count=3, content_markdown="# B2", content_html="

B2

", pages=[PageDetail(page_number=i, width=612, height=792) for i in range(6, 9)], ), ] # Simulated DB state: find_by_id is called 4 times: # 1) start of _run_analysis_inner (initial load) # 2) batch 1 mid-flight deletion check # 3) batch 2 mid-flight deletion check # 4) re-read before mark_completed (must carry progress) initial_job = AnalysisJob( id="job-progress", document_id="doc-1", status=AnalysisStatus.PENDING, ) batch_check_job = AnalysisJob( id="job-progress", document_id="doc-1", status=AnalysisStatus.RUNNING, ) refreshed_job = AnalysisJob( id="job-progress", document_id="doc-1", status=AnalysisStatus.RUNNING, progress_current=8, progress_total=8, ) saved_jobs: list[AnalysisJob] = [] async def capture_update_status(job): saved_jobs.append(job) mock_analysis_repo = MagicMock() mock_analysis_repo.find_by_id = AsyncMock( side_effect=[initial_job, batch_check_job, batch_check_job, refreshed_job] ) mock_analysis_repo.update_status = AsyncMock(side_effect=capture_update_status) mock_analysis_repo.update_progress = AsyncMock() mock_document_repo = MagicMock() mock_document_repo.update_page_count = AsyncMock() config = AnalysisConfig(default_table_mode="accurate", batch_page_size=5) service = AnalysisService( converter=converter, analysis_repo=mock_analysis_repo, document_repo=mock_document_repo, conversion_timeout=60, config=config, ) with patch("services.analysis_service._count_pdf_pages", return_value=8): await service._run_analysis_inner("job-progress", "/fake.pdf", "fake.pdf") # The last update_status call is the COMPLETED one completed_job = saved_jobs[-1] assert completed_job.status == AnalysisStatus.COMPLETED assert completed_job.progress_current == 8, ( "progress_current must be preserved (was the bug: got None)" ) assert completed_job.progress_total == 8, ( "progress_total must be preserved (was the bug: got None)" ) @pytest.mark.asyncio async def test_job_deleted_mid_batch_returns_none(self): """If the job is deleted between batches, conversion aborts with None.""" converter = AsyncMock() converter.convert.return_value = ConversionResult( page_count=5, content_markdown="ok", content_html="ok", pages=[PageDetail(page_number=i, width=612, height=792) for i in range(1, 6)], ) mock_analysis_repo = MagicMock() # First check returns job, second returns None (deleted) mock_analysis_repo.find_by_id = AsyncMock(side_effect=[MagicMock(), None]) mock_analysis_repo.update_progress = AsyncMock() service = _make_service( converter=converter, analysis_repo=mock_analysis_repo, conversion_timeout=60, ) result = await service._run_batched_conversion( "job-del", "/fake.pdf", MagicMock(), total_pages=10, batch_size=5, ) assert result is None # Only first batch should have been converted assert converter.convert.call_count == 1 class TestUpdateChunkText: """Tests for AnalysisService.update_chunk_text.""" @pytest.mark.asyncio async def test_update_chunk_text_success(self): chunks = [ {"text": "original", "headings": [], "sourcePage": 1, "tokenCount": 5, "bboxes": []}, {"text": "second", "headings": [], "sourcePage": 2, "tokenCount": 3, "bboxes": []}, ] job = MagicMock() job.status = AnalysisStatus.COMPLETED job.chunks_json = json.dumps(chunks) repo = MagicMock() repo.find_by_id = AsyncMock(return_value=job) repo.update_chunks = AsyncMock(return_value=True) service = _make_service(analysis_repo=repo) result = await service.update_chunk_text("j1", 0, "updated text") assert result[0]["text"] == "updated text" assert result[0]["modified"] is True assert result[1]["text"] == "second" assert result[1].get("modified", False) is False repo.update_chunks.assert_called_once() @pytest.mark.asyncio async def test_update_chunk_text_not_found(self): repo = MagicMock() repo.find_by_id = AsyncMock(return_value=None) service = _make_service(analysis_repo=repo) with pytest.raises(ValueError, match="Analysis not found"): await service.update_chunk_text("missing", 0, "text") @pytest.mark.asyncio async def test_update_chunk_text_not_completed(self): job = MagicMock() job.status = AnalysisStatus.RUNNING repo = MagicMock() repo.find_by_id = AsyncMock(return_value=job) service = _make_service(analysis_repo=repo) with pytest.raises(ValueError, match="not completed"): await service.update_chunk_text("j1", 0, "text") @pytest.mark.asyncio async def test_update_chunk_text_index_out_of_range(self): chunks = [ {"text": "only one", "headings": [], "sourcePage": 1, "tokenCount": 5, "bboxes": []} ] job = MagicMock() job.status = AnalysisStatus.COMPLETED job.chunks_json = json.dumps(chunks) repo = MagicMock() repo.find_by_id = AsyncMock(return_value=job) service = _make_service(analysis_repo=repo) with pytest.raises(ValueError, match="out of range"): await service.update_chunk_text("j1", 5, "text") @pytest.mark.asyncio async def test_update_chunk_text_no_chunks(self): job = MagicMock() job.status = AnalysisStatus.COMPLETED job.chunks_json = None repo = MagicMock() repo.find_by_id = AsyncMock(return_value=job) service = _make_service(analysis_repo=repo) with pytest.raises(ValueError, match="No chunks available"): await service.update_chunk_text("j1", 0, "text") class TestDeleteChunk: """Tests for AnalysisService.delete_chunk.""" @pytest.mark.asyncio async def test_delete_chunk_success(self): chunks = [ {"text": "chunk1", "headings": [], "sourcePage": 1, "tokenCount": 5, "bboxes": []}, {"text": "chunk2", "headings": [], "sourcePage": 2, "tokenCount": 3, "bboxes": []}, ] job = MagicMock() job.status = AnalysisStatus.COMPLETED job.chunks_json = json.dumps(chunks) repo = MagicMock() repo.find_by_id = AsyncMock(return_value=job) repo.update_chunks = AsyncMock(return_value=True) service = _make_service(analysis_repo=repo) result = await service.delete_chunk("j1", 0) assert result[0]["deleted"] is True assert result[1].get("deleted", False) is False repo.update_chunks.assert_called_once() @pytest.mark.asyncio async def test_delete_chunk_not_found(self): repo = MagicMock() repo.find_by_id = AsyncMock(return_value=None) service = _make_service(analysis_repo=repo) with pytest.raises(ValueError, match="Analysis not found"): await service.delete_chunk("missing", 0) @pytest.mark.asyncio async def test_delete_chunk_index_out_of_range(self): chunks = [{"text": "only", "headings": [], "sourcePage": 1, "tokenCount": 5, "bboxes": []}] job = MagicMock() job.status = AnalysisStatus.COMPLETED job.chunks_json = json.dumps(chunks) repo = MagicMock() repo.find_by_id = AsyncMock(return_value=job) service = _make_service(analysis_repo=repo) with pytest.raises(ValueError, match="out of range"): await service.delete_chunk("j1", 5) @pytest.mark.asyncio async def test_delete_chunk_no_chunks(self): job = MagicMock() job.status = AnalysisStatus.COMPLETED job.chunks_json = None repo = MagicMock() repo.find_by_id = AsyncMock(return_value=job) service = _make_service(analysis_repo=repo) with pytest.raises(ValueError, match="No chunks available"): await service.delete_chunk("j1", 0)