diff --git a/document-parser/tests/test_robustness.py b/document-parser/tests/test_robustness.py new file mode 100644 index 0000000..9e33b29 --- /dev/null +++ b/document-parser/tests/test_robustness.py @@ -0,0 +1,377 @@ +"""Robustness tests — verify pipeline failure scenarios and protective mechanisms. + +Tests cover: document_timeout (C1), lock timeout (C2), convert limits (C3), +default table_mode (H1), converter reset on failure (H5), error classification (M1). +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from domain.models import AnalysisJob +from domain.value_objects import ConversionOptions, ConversionResult, PageDetail +from infra.settings import Settings +from services.analysis_service import AnalysisService, _classify_error + +# --------------------------------------------------------------------------- +# M1 — _classify_error: user-friendly error messages +# --------------------------------------------------------------------------- + + +class TestClassifyError: + """Verify _classify_error maps exceptions to user-friendly messages.""" + + def test_cxx_compiler_error(self): + exc = RuntimeError("InvalidCxxCompiler: No working C++ compiler found") + assert "Missing C++ compiler" in _classify_error(exc) + + def test_no_working_compiler(self): + exc = RuntimeError("no working c++ compiler found in torch") + assert "Missing C++ compiler" in _classify_error(exc) + + def test_out_of_memory(self): + exc = MemoryError("Out of memory allocating tensor") + assert "Out of memory" in _classify_error(exc) + + def test_oom_shorthand(self): + exc = RuntimeError("OOM during inference on page 5") + assert "Out of memory" in _classify_error(exc) + + def test_lock_timeout(self): + exc = TimeoutError("Could not acquire converter lock within 300s") + assert "Server busy" in _classify_error(exc) + + def test_pipeline_failed(self): + exc = RuntimeError("Pipeline StandardPdfPipeline failed on page 3") + assert "Document processing failed" in _classify_error(exc) + + def test_timeout_generic(self): + exc = TimeoutError("timeout exceeded while processing") + assert "Processing took too long" in _classify_error(exc) + + def test_unknown_short_error(self): + exc = ValueError("something weird happened") + assert _classify_error(exc) == "something weird happened" + + def test_unknown_long_error_truncated(self): + long_msg = "x" * 300 + exc = ValueError(long_msg) + result = _classify_error(exc) + assert len(result) <= 201 + assert result.endswith("…") + + +# --------------------------------------------------------------------------- +# H1 — default table_mode injection +# --------------------------------------------------------------------------- + + +class TestDefaultTableMode: + """Verify _run_analysis_inner injects settings.default_table_mode.""" + + @pytest.fixture + def mock_job(self): + return AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf") + + def _make_result(self) -> ConversionResult: + return ConversionResult( + page_count=1, + content_markdown="# Test", + content_html="

Test

", + pages=[PageDetail(page_number=1, width=612.0, height=792.0)], + ) + + @patch("services.analysis_service.analysis_repo") + @patch("services.analysis_service.document_repo") + @pytest.mark.asyncio + async def test_default_table_mode_injected_when_missing( + self, mock_doc_repo, mock_analysis_repo, mock_job + ): + mock_analysis_repo.find_by_id = AsyncMock(return_value=mock_job) + mock_analysis_repo.update_status = AsyncMock() + mock_doc_repo.update_page_count = AsyncMock() + + mock_converter = AsyncMock() + mock_converter.convert.return_value = self._make_result() + + svc = AnalysisService(converter=mock_converter) + await svc._run_analysis("j1", "/tmp/test.pdf", "test.pdf", {}) + + opts = mock_converter.convert.call_args[0][1] + assert opts.table_mode == "accurate" + + @patch("services.analysis_service.analysis_repo") + @patch("services.analysis_service.document_repo") + @pytest.mark.asyncio + async def test_default_table_mode_injected_when_none( + self, mock_doc_repo, mock_analysis_repo, mock_job + ): + mock_analysis_repo.find_by_id = AsyncMock(return_value=mock_job) + mock_analysis_repo.update_status = AsyncMock() + mock_doc_repo.update_page_count = AsyncMock() + + mock_converter = AsyncMock() + mock_converter.convert.return_value = self._make_result() + + svc = AnalysisService(converter=mock_converter) + await svc._run_analysis("j1", "/tmp/test.pdf", "test.pdf", None) + + opts = mock_converter.convert.call_args[0][1] + assert opts.table_mode == "accurate" + + @patch("services.analysis_service.analysis_repo") + @patch("services.analysis_service.document_repo") + @pytest.mark.asyncio + async def test_user_table_mode_preserved(self, mock_doc_repo, mock_analysis_repo, mock_job): + mock_analysis_repo.find_by_id = AsyncMock(return_value=mock_job) + mock_analysis_repo.update_status = AsyncMock() + mock_doc_repo.update_page_count = AsyncMock() + + mock_converter = AsyncMock() + mock_converter.convert.return_value = self._make_result() + + svc = AnalysisService(converter=mock_converter) + await svc._run_analysis("j1", "/tmp/test.pdf", "test.pdf", {"table_mode": "fast"}) + + opts = mock_converter.convert.call_args[0][1] + assert opts.table_mode == "fast" + + @patch("services.analysis_service.settings", Settings(default_table_mode="fast")) + @patch("services.analysis_service.analysis_repo") + @patch("services.analysis_service.document_repo") + @pytest.mark.asyncio + async def test_custom_default_from_settings(self, mock_doc_repo, mock_analysis_repo, mock_job): + mock_analysis_repo.find_by_id = AsyncMock(return_value=mock_job) + mock_analysis_repo.update_status = AsyncMock() + mock_doc_repo.update_page_count = AsyncMock() + + mock_converter = AsyncMock() + mock_converter.convert.return_value = self._make_result() + + svc = AnalysisService(converter=mock_converter) + await svc._run_analysis("j1", "/tmp/test.pdf", "test.pdf", {}) + + opts = mock_converter.convert.call_args[0][1] + assert opts.table_mode == "fast" + + +# --------------------------------------------------------------------------- +# Docling-dependent tests — skip if docling is not installed +# --------------------------------------------------------------------------- + +docling = pytest.importorskip("docling", reason="docling library not installed") + +from docling.datamodel.base_models import InputFormat # noqa: E402 + +from infra.local_converter import ( # noqa: E402 + _build_docling_converter as build_converter, +) +from infra.local_converter import ( # noqa: E402 + _convert_sync as convert_sync, +) +from infra.local_converter import ( # noqa: E402 + _get_default_converter as get_default_converter, +) + +# --------------------------------------------------------------------------- +# C1 — document_timeout in PdfPipelineOptions +# --------------------------------------------------------------------------- + + +class TestDocumentTimeout: + """Verify document_timeout from settings is wired into PdfPipelineOptions.""" + + def _get_pipeline_options(self, converter): + fmt_opt = converter.format_to_options[InputFormat.PDF] + return fmt_opt.pipeline_options + + def test_document_timeout_from_settings(self): + conv = build_converter(ConversionOptions()) + opts = self._get_pipeline_options(conv) + assert opts.document_timeout == 120.0 + + @patch("infra.local_converter.settings", Settings(document_timeout=45.0)) + def test_custom_document_timeout(self): + conv = build_converter(ConversionOptions()) + opts = self._get_pipeline_options(conv) + assert opts.document_timeout == 45.0 + + +# --------------------------------------------------------------------------- +# C2 — converter lock timeout +# --------------------------------------------------------------------------- + + +def _mock_docling_result(): + """Create a minimal mock that satisfies _convert_sync's result processing.""" + mock_result = MagicMock() + mock_result.document.pages = {} + mock_result.document.iterate_items.return_value = [] + mock_result.document.export_to_markdown.return_value = "" + mock_result.document.export_to_html.return_value = "" + mock_result.document.export_to_dict.return_value = {} + return mock_result + + +class TestConverterLockTimeout: + """Verify _convert_sync raises TimeoutError when lock cannot be acquired.""" + + @patch("infra.local_converter._converter_lock") + def test_lock_timeout_raises(self, mock_lock): + mock_lock.acquire.return_value = False + + with pytest.raises(TimeoutError, match="Could not acquire converter lock"): + convert_sync("/tmp/test.pdf", ConversionOptions()) + + mock_lock.release.assert_not_called() + + @patch("infra.local_converter._select_converter") + @patch("infra.local_converter._converter_lock") + def test_lock_released_on_success(self, mock_lock, mock_select): + mock_lock.acquire.return_value = True + mock_conv = MagicMock() + mock_conv.convert.return_value = _mock_docling_result() + mock_select.return_value = mock_conv + + convert_sync("/tmp/test.pdf", ConversionOptions()) + + mock_lock.release.assert_called_once() + + @patch("infra.local_converter._select_converter") + @patch("infra.local_converter._converter_lock") + def test_lock_released_on_error(self, mock_lock, mock_select): + mock_lock.acquire.return_value = True + mock_conv = MagicMock() + mock_conv.convert.side_effect = RuntimeError("boom") + mock_select.return_value = mock_conv + + with pytest.raises(RuntimeError, match="boom"): + convert_sync("/tmp/test.pdf", ConversionOptions()) + + mock_lock.release.assert_called_once() + + +# --------------------------------------------------------------------------- +# C3 — max_num_pages and max_file_size forwarding +# --------------------------------------------------------------------------- + + +class TestConvertSyncLimits: + """Verify _convert_sync forwards limit settings to conv.convert().""" + + @patch("infra.local_converter._select_converter") + @patch("infra.local_converter._converter_lock") + @patch("infra.local_converter.settings", Settings(max_page_count=10, max_file_size=0)) + def test_max_page_count_forwarded(self, mock_lock, mock_select): + mock_lock.acquire.return_value = True + mock_conv = MagicMock() + mock_conv.convert.return_value = _mock_docling_result() + mock_select.return_value = mock_conv + + convert_sync("/tmp/test.pdf", ConversionOptions()) + + kwargs = mock_conv.convert.call_args.kwargs + assert kwargs["max_num_pages"] == 10 + assert "max_file_size" not in kwargs + + @patch("infra.local_converter._select_converter") + @patch("infra.local_converter._converter_lock") + @patch("infra.local_converter.settings", Settings(max_page_count=0, max_file_size=5_000_000)) + def test_max_file_size_forwarded(self, mock_lock, mock_select): + mock_lock.acquire.return_value = True + mock_conv = MagicMock() + mock_conv.convert.return_value = _mock_docling_result() + mock_select.return_value = mock_conv + + convert_sync("/tmp/test.pdf", ConversionOptions()) + + kwargs = mock_conv.convert.call_args.kwargs + assert kwargs["max_file_size"] == 5_000_000 + assert "max_num_pages" not in kwargs + + @patch("infra.local_converter._select_converter") + @patch("infra.local_converter._converter_lock") + @patch( + "infra.local_converter.settings", + Settings(max_page_count=20, max_file_size=10_000_000), + ) + def test_both_limits_forwarded(self, mock_lock, mock_select): + mock_lock.acquire.return_value = True + mock_conv = MagicMock() + mock_conv.convert.return_value = _mock_docling_result() + mock_select.return_value = mock_conv + + convert_sync("/tmp/test.pdf", ConversionOptions()) + + kwargs = mock_conv.convert.call_args.kwargs + assert kwargs["max_num_pages"] == 20 + assert kwargs["max_file_size"] == 10_000_000 + + @patch("infra.local_converter._select_converter") + @patch("infra.local_converter._converter_lock") + @patch("infra.local_converter.settings", Settings(max_page_count=0, max_file_size=0)) + def test_zero_limits_not_forwarded(self, mock_lock, mock_select): + mock_lock.acquire.return_value = True + mock_conv = MagicMock() + mock_conv.convert.return_value = _mock_docling_result() + mock_select.return_value = mock_conv + + convert_sync("/tmp/test.pdf", ConversionOptions()) + + kwargs = mock_conv.convert.call_args.kwargs + assert "max_num_pages" not in kwargs + assert "max_file_size" not in kwargs + + +# --------------------------------------------------------------------------- +# H5 — _default_converter reset on init failure +# --------------------------------------------------------------------------- + + +class TestGetDefaultConverterReset: + """Verify _get_default_converter resets on failure and retries on next call.""" + + @pytest.fixture(autouse=True) + def reset_default_converter(self): + import infra.local_converter as mod + + original = mod._default_converter + mod._default_converter = None + yield + mod._default_converter = original + + @patch("infra.local_converter._build_docling_converter") + def test_init_failure_resets_to_none(self, mock_build): + import infra.local_converter as mod + + mock_build.side_effect = RuntimeError("torch not found") + + with pytest.raises(RuntimeError, match="torch not found"): + get_default_converter() + + assert mod._default_converter is None + + @patch("infra.local_converter._build_docling_converter") + def test_retry_after_failure_succeeds(self, mock_build): + mock_conv = MagicMock() + mock_build.side_effect = [RuntimeError("torch not found"), mock_conv] + + with pytest.raises(RuntimeError): + get_default_converter() + + result = get_default_converter() + assert result is mock_conv + assert mock_build.call_count == 2 + + @patch("infra.local_converter._build_docling_converter") + def test_success_caches_converter(self, mock_build): + mock_conv = MagicMock() + mock_build.return_value = mock_conv + + result1 = get_default_converter() + result2 = get_default_converter() + + assert result1 is result2 is mock_conv + mock_build.assert_called_once()