From 05de0cc77408a37e499aec3f5a004475d41c215e Mon Sep 17 00:00:00 2001 From: Pier-Jean Malandrino Date: Thu, 2 Apr 2026 12:05:36 +0200 Subject: [PATCH] Add chunking tests and update existing test assertions 16 new chunking tests (domain, schemas, API endpoints). Update existing tests for chunkingOptions parameter and document_json mock. --- document-parser/tests/test_api_endpoints.py | 98 +++--- document-parser/tests/test_chunking.py | 292 ++++++++++++++++++ .../tests/test_pipeline_options.py | 124 ++++++-- document-parser/tests/test_serve_converter.py | 133 +++++++- 4 files changed, 562 insertions(+), 85 deletions(-) create mode 100644 document-parser/tests/test_chunking.py diff --git a/document-parser/tests/test_api_endpoints.py b/document-parser/tests/test_api_endpoints.py index 173dd30..2f34518 100644 --- a/document-parser/tests/test_api_endpoints.py +++ b/document-parser/tests/test_api_endpoints.py @@ -53,8 +53,12 @@ class TestDocumentEndpoints: @patch("services.document_service.find_by_id", new_callable=AsyncMock) def test_get_document(self, mock_find, client): mock_find.return_value = Document( - id="d1", filename="test.pdf", content_type="application/pdf", - file_size=2048, page_count=3, storage_path="/tmp/test", + id="d1", + filename="test.pdf", + content_type="application/pdf", + file_size=2048, + page_count=3, + storage_path="/tmp/test", ) resp = client.get("/api/documents/d1") @@ -74,8 +78,10 @@ class TestDocumentEndpoints: @patch("services.document_service.upload", new_callable=AsyncMock) def test_upload_document(self, mock_upload, client): mock_upload.return_value = Document( - id="new-1", filename="uploaded.pdf", - content_type="application/pdf", file_size=512, + id="new-1", + filename="uploaded.pdf", + content_type="application/pdf", + file_size=512, storage_path="/tmp/uploaded", ) @@ -115,9 +121,11 @@ class TestDocumentEndpoints: class TestAnalysisEndpoints: def test_list_analyses(self, client, mock_analysis_service): - mock_analysis_service.find_all = AsyncMock(return_value=[ - AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf"), - ]) + mock_analysis_service.find_all = AsyncMock( + return_value=[ + AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf"), + ] + ) resp = client.get("/api/analyses") assert resp.status_code == 200 @@ -146,37 +154,52 @@ class TestAnalysisEndpoints: assert resp.status_code == 404 def test_create_analysis(self, client, mock_analysis_service): - mock_analysis_service.create = AsyncMock(return_value=AnalysisJob( - id="j1", document_id="d1", document_filename="test.pdf", - )) + mock_analysis_service.create = AsyncMock( + return_value=AnalysisJob( + id="j1", + document_id="d1", + document_filename="test.pdf", + ) + ) resp = client.post("/api/analyses", json={"documentId": "d1"}) assert resp.status_code == 200 data = resp.json() assert data["id"] == "j1" assert data["documentId"] == "d1" - mock_analysis_service.create.assert_called_once_with("d1", pipeline_options=None) + mock_analysis_service.create.assert_called_once_with( + "d1", + pipeline_options=None, + chunking_options=None, + ) def test_create_analysis_with_pipeline_options(self, client, mock_analysis_service): - mock_analysis_service.create = AsyncMock(return_value=AnalysisJob( - id="j2", document_id="d1", document_filename="test.pdf", - )) + mock_analysis_service.create = AsyncMock( + return_value=AnalysisJob( + id="j2", + document_id="d1", + document_filename="test.pdf", + ) + ) - resp = client.post("/api/analyses", json={ - "documentId": "d1", - "pipelineOptions": { - "do_ocr": False, - "do_table_structure": True, - "table_mode": "fast", - "do_code_enrichment": True, - "do_formula_enrichment": False, - "do_picture_classification": False, - "do_picture_description": False, - "generate_picture_images": True, - "generate_page_images": False, - "images_scale": 2.0, - } - }) + resp = client.post( + "/api/analyses", + json={ + "documentId": "d1", + "pipelineOptions": { + "do_ocr": False, + "do_table_structure": True, + "table_mode": "fast", + "do_code_enrichment": True, + "do_formula_enrichment": False, + "do_picture_classification": False, + "do_picture_description": False, + "generate_picture_images": True, + "generate_page_images": False, + "images_scale": 2.0, + }, + }, + ) assert resp.status_code == 200 data = resp.json() assert data["id"] == "j2" @@ -191,14 +214,17 @@ class TestAnalysisEndpoints: def test_create_analysis_with_partial_pipeline_options(self, client, mock_analysis_service): """Pipeline options should use defaults for unspecified fields.""" - mock_analysis_service.create = AsyncMock(return_value=AnalysisJob( - id="j3", document_id="d1", document_filename="test.pdf", - )) + mock_analysis_service.create = AsyncMock( + return_value=AnalysisJob( + id="j3", + document_id="d1", + document_filename="test.pdf", + ) + ) - resp = client.post("/api/analyses", json={ - "documentId": "d1", - "pipelineOptions": {"do_ocr": False} - }) + resp = client.post( + "/api/analyses", json={"documentId": "d1", "pipelineOptions": {"do_ocr": False}} + ) assert resp.status_code == 200 opts = mock_analysis_service.create.call_args.kwargs["pipeline_options"] diff --git a/document-parser/tests/test_chunking.py b/document-parser/tests/test_chunking.py new file mode 100644 index 0000000..b429d35 --- /dev/null +++ b/document-parser/tests/test_chunking.py @@ -0,0 +1,292 @@ +"""Tests for chunking feature — domain, schemas, service, and API endpoints.""" + +from __future__ import annotations + +import json +from dataclasses import asdict +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi.testclient import TestClient + +from api.schemas import ChunkingOptionsRequest, ChunkResponse, RechunkRequest +from domain.models import AnalysisJob, AnalysisStatus +from domain.value_objects import ChunkingOptions, ChunkResult +from main import app + +# --------------------------------------------------------------------------- +# Domain: value objects +# --------------------------------------------------------------------------- + + +class TestChunkingOptions: + def test_defaults(self): + opts = ChunkingOptions() + assert opts.chunker_type == "hybrid" + assert opts.max_tokens == 512 + assert opts.merge_peers is True + assert opts.repeat_table_header is True + + def test_custom_values(self): + opts = ChunkingOptions(chunker_type="hierarchical", max_tokens=256, merge_peers=False) + assert opts.chunker_type == "hierarchical" + assert opts.max_tokens == 256 + assert opts.merge_peers is False + + def test_is_default(self): + assert ChunkingOptions().is_default() + assert not ChunkingOptions(max_tokens=256).is_default() + + +class TestChunkResult: + def test_defaults(self): + chunk = ChunkResult(text="hello") + assert chunk.text == "hello" + assert chunk.headings == [] + assert chunk.source_page is None + assert chunk.token_count == 0 + + def test_full_values(self): + chunk = ChunkResult( + text="content", + headings=["Title", "Section"], + source_page=3, + token_count=42, + ) + assert chunk.headings == ["Title", "Section"] + assert chunk.source_page == 3 + assert chunk.token_count == 42 + + def test_serializable(self): + chunk = ChunkResult(text="x", headings=["h1"], source_page=1, token_count=10) + data = asdict(chunk) + assert data == {"text": "x", "headings": ["h1"], "source_page": 1, "token_count": 10} + + +# --------------------------------------------------------------------------- +# Domain: AnalysisJob with chunking fields +# --------------------------------------------------------------------------- + + +class TestAnalysisJobChunking: + def test_default_chunking_fields(self): + job = AnalysisJob() + assert job.document_json is None + assert job.chunks_json is None + + def test_mark_completed_with_chunks(self): + job = AnalysisJob() + job.mark_running() + job.mark_completed( + markdown="# Title", + html="

Title

", + pages_json="[]", + document_json='{"name": "doc"}', + chunks_json='[{"text": "chunk1"}]', + ) + assert job.status == AnalysisStatus.COMPLETED + assert job.document_json == '{"name": "doc"}' + assert job.chunks_json == '[{"text": "chunk1"}]' + + def test_mark_completed_without_chunks(self): + job = AnalysisJob() + job.mark_running() + job.mark_completed(markdown="md", html="html", pages_json="[]") + assert job.document_json is None + assert job.chunks_json is None + + +# --------------------------------------------------------------------------- +# Schemas +# --------------------------------------------------------------------------- + + +class TestChunkingOptionsRequest: + def test_defaults(self): + opts = ChunkingOptionsRequest() + assert opts.chunker_type == "hybrid" + assert opts.max_tokens == 512 + assert opts.merge_peers is True + assert opts.repeat_table_header is True + + def test_custom_values(self): + opts = ChunkingOptionsRequest(chunker_type="hierarchical", max_tokens=1024) + assert opts.chunker_type == "hierarchical" + assert opts.max_tokens == 1024 + + def test_invalid_chunker_type(self): + with pytest.raises(ValueError, match="chunker_type"): + ChunkingOptionsRequest(chunker_type="invalid") + + def test_max_tokens_too_low(self): + with pytest.raises(ValueError, match="max_tokens"): + ChunkingOptionsRequest(max_tokens=10) + + def test_max_tokens_too_high(self): + with pytest.raises(ValueError, match="max_tokens"): + ChunkingOptionsRequest(max_tokens=10000) + + def test_boundary_max_tokens(self): + opts_low = ChunkingOptionsRequest(max_tokens=64) + assert opts_low.max_tokens == 64 + opts_high = ChunkingOptionsRequest(max_tokens=8192) + assert opts_high.max_tokens == 8192 + + +class TestChunkResponse: + def test_serializes_to_camel_case(self): + resp = ChunkResponse(text="hello", headings=["H1"], source_page=1, token_count=5) + data = resp.model_dump(by_alias=True) + assert "sourcePage" in data + assert "tokenCount" in data + assert data["text"] == "hello" + + +class TestRechunkRequest: + def test_parses(self): + req = RechunkRequest(chunkingOptions=ChunkingOptionsRequest(max_tokens=256)) + assert req.chunkingOptions.max_tokens == 256 + + +# --------------------------------------------------------------------------- +# API endpoints +# --------------------------------------------------------------------------- + + +@pytest.fixture +def client(): + return TestClient(app, raise_server_exceptions=False) + + +@pytest.fixture +def mock_analysis_service(client): + mock_svc = MagicMock() + original = getattr(app.state, "analysis_service", None) + app.state.analysis_service = mock_svc + yield mock_svc + app.state.analysis_service = original + + +class TestCreateAnalysisWithChunking: + def test_create_with_chunking_options(self, client, mock_analysis_service): + mock_analysis_service.create = AsyncMock( + return_value=AnalysisJob( + id="j1", + document_id="d1", + document_filename="test.pdf", + ) + ) + + resp = client.post( + "/api/analyses", + json={ + "documentId": "d1", + "chunkingOptions": { + "chunker_type": "hybrid", + "max_tokens": 256, + "merge_peers": False, + }, + }, + ) + assert resp.status_code == 200 + + call_kwargs = mock_analysis_service.create.call_args + chunking = call_kwargs.kwargs["chunking_options"] + assert chunking["chunker_type"] == "hybrid" + assert chunking["max_tokens"] == 256 + assert chunking["merge_peers"] is False + + def test_create_without_chunking_options(self, client, mock_analysis_service): + mock_analysis_service.create = AsyncMock( + return_value=AnalysisJob( + id="j1", + document_id="d1", + document_filename="test.pdf", + ) + ) + + resp = client.post("/api/analyses", json={"documentId": "d1"}) + assert resp.status_code == 200 + + call_kwargs = mock_analysis_service.create.call_args + assert call_kwargs.kwargs["chunking_options"] is None + + def test_response_includes_chunking_fields(self, client, mock_analysis_service): + job = AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf") + job.mark_running() + job.mark_completed( + markdown="# Title", + html="

Title

", + pages_json="[]", + document_json='{"name": "doc"}', + chunks_json=json.dumps([asdict(ChunkResult(text="chunk1", token_count=5))]), + ) + mock_analysis_service.find_by_id = AsyncMock(return_value=job) + + resp = client.get("/api/analyses/j1") + assert resp.status_code == 200 + data = resp.json() + assert data["hasDocumentJson"] is True + assert data["chunksJson"] is not None + chunks = json.loads(data["chunksJson"]) + assert len(chunks) == 1 + assert chunks[0]["text"] == "chunk1" + + +class TestRechunkEndpoint: + def test_rechunk_success(self, client, mock_analysis_service): + mock_analysis_service.rechunk = AsyncMock( + return_value=[ + ChunkResult(text="chunk1", headings=["H1"], source_page=1, token_count=10), + ChunkResult(text="chunk2", headings=["H1", "H2"], source_page=2, token_count=20), + ] + ) + + resp = client.post( + "/api/analyses/j1/rechunk", + json={ + "chunkingOptions": {"chunker_type": "hybrid", "max_tokens": 128}, + }, + ) + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 2 + assert data[0]["text"] == "chunk1" + assert data[0]["sourcePage"] == 1 + assert data[0]["tokenCount"] == 10 + assert data[1]["headings"] == ["H1", "H2"] + + def test_rechunk_not_completed(self, client, mock_analysis_service): + mock_analysis_service.rechunk = AsyncMock( + side_effect=ValueError("Analysis is not completed: j1"), + ) + + resp = client.post( + "/api/analyses/j1/rechunk", + json={ + "chunkingOptions": {"chunker_type": "hybrid"}, + }, + ) + assert resp.status_code == 400 + + def test_rechunk_no_document_json(self, client, mock_analysis_service): + mock_analysis_service.rechunk = AsyncMock( + side_effect=ValueError("No document data available for re-chunking: j1"), + ) + + resp = client.post( + "/api/analyses/j1/rechunk", + json={ + "chunkingOptions": {"chunker_type": "hierarchical"}, + }, + ) + assert resp.status_code == 400 + + def test_rechunk_invalid_chunker_type(self, client, mock_analysis_service): + resp = client.post( + "/api/analyses/j1/rechunk", + json={ + "chunkingOptions": {"chunker_type": "invalid"}, + }, + ) + assert resp.status_code == 422 diff --git a/document-parser/tests/test_pipeline_options.py b/document-parser/tests/test_pipeline_options.py index f402fdc..19ea577 100644 --- a/document-parser/tests/test_pipeline_options.py +++ b/document-parser/tests/test_pipeline_options.py @@ -23,6 +23,7 @@ from infra.local_converter import ( # build_converter — verifies Docling pipeline options are wired correctly # --------------------------------------------------------------------------- + class TestBuildConverter: """Verify that build_converter produces a DocumentConverter with the right PdfPipelineOptions.""" @@ -101,18 +102,20 @@ class TestBuildConverter: assert opts.images_scale == 2.0 def test_all_options_combined(self): - conv = build_converter(ConversionOptions( - do_ocr=False, - do_table_structure=True, - table_mode="fast", - do_code_enrichment=True, - do_formula_enrichment=True, - do_picture_classification=True, - do_picture_description=True, - generate_picture_images=True, - generate_page_images=True, - images_scale=1.5, - )) + conv = build_converter( + ConversionOptions( + do_ocr=False, + do_table_structure=True, + table_mode="fast", + do_code_enrichment=True, + do_formula_enrichment=True, + do_picture_classification=True, + do_picture_description=True, + generate_picture_images=True, + generate_page_images=True, + images_scale=1.5, + ) + ) opts = self._get_pipeline_options(conv) assert opts.do_ocr is False assert opts.do_table_structure is True @@ -130,6 +133,7 @@ class TestBuildConverter: # convert_document — default vs custom converter routing # --------------------------------------------------------------------------- + class TestConvertDocumentRouting: """Verify convert_document uses default converter for default opts, custom otherwise.""" @@ -142,6 +146,7 @@ class TestConvertDocumentRouting: 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 = {} mock_conv.convert.return_value = mock_result mock_get_default.return_value = mock_conv @@ -159,6 +164,7 @@ class TestConvertDocumentRouting: 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 = {} mock_conv.convert.return_value = mock_result mock_build.return_value = mock_conv @@ -176,6 +182,7 @@ class TestConvertDocumentRouting: 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 = {} mock_conv.convert.return_value = mock_result mock_build.return_value = mock_conv @@ -193,6 +200,7 @@ class TestConvertDocumentRouting: 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 = {} mock_conv.convert.return_value = mock_result mock_build.return_value = mock_conv @@ -210,6 +218,7 @@ class TestConvertDocumentRouting: 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 = {} mock_conv.convert.return_value = mock_result mock_build.return_value = mock_conv @@ -226,6 +235,7 @@ class TestConvertDocumentRouting: 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 = {} mock_conv.convert.return_value = mock_result mock_build.return_value = mock_conv @@ -242,6 +252,7 @@ class TestConvertDocumentRouting: 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 = {} mock_conv.convert.return_value = mock_result mock_build.return_value = mock_conv @@ -258,6 +269,7 @@ class TestConvertDocumentRouting: 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 = {} mock_conv.convert.return_value = mock_result mock_build.return_value = mock_conv @@ -275,6 +287,7 @@ class TestConvertDocumentRouting: 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 = {} mock_conv.convert.return_value = mock_result mock_build.return_value = mock_conv @@ -299,30 +312,37 @@ class TestConvertDocumentRouting: # Service layer — pipeline options forwarding # --------------------------------------------------------------------------- + class TestServiceForwardsPipelineOptions: """Verify analysis_service.create and _run_analysis forward pipeline options.""" @pytest.fixture def mock_doc(self): from domain.models import Document + return Document(id="d1", filename="test.pdf", storage_path="/tmp/test.pdf") @pytest.fixture def mock_job(self): from domain.models import AnalysisJob + return AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf") @patch("services.analysis_service.document_repo") @patch("services.analysis_service.analysis_repo") @pytest.mark.asyncio async def test_create_passes_pipeline_options_to_run( - self, mock_analysis_repo, mock_doc_repo, mock_doc, + self, + mock_analysis_repo, + mock_doc_repo, + mock_doc, ): mock_doc_repo.find_by_id = AsyncMock(return_value=mock_doc) mock_analysis_repo.insert = AsyncMock() mock_converter = AsyncMock() from services.analysis_service import AnalysisService + svc = AnalysisService(converter=mock_converter) opts = {"do_ocr": False, "table_mode": "fast"} @@ -335,13 +355,17 @@ class TestServiceForwardsPipelineOptions: @patch("services.analysis_service.analysis_repo") @pytest.mark.asyncio async def test_create_passes_none_when_no_options( - self, mock_analysis_repo, mock_doc_repo, mock_doc, + self, + mock_analysis_repo, + mock_doc_repo, + mock_doc, ): mock_doc_repo.find_by_id = AsyncMock(return_value=mock_doc) mock_analysis_repo.insert = AsyncMock() mock_converter = AsyncMock() from services.analysis_service import AnalysisService + svc = AnalysisService(converter=mock_converter) with patch("services.analysis_service.asyncio.create_task") as mock_task: @@ -352,7 +376,10 @@ class TestServiceForwardsPipelineOptions: @patch("services.analysis_service.document_repo") @pytest.mark.asyncio async def test_run_analysis_forwards_options_to_convert( - self, mock_doc_repo, mock_analysis_repo, mock_job, + self, + mock_doc_repo, + mock_analysis_repo, + mock_job, ): from domain.value_objects import ConversionResult, PageDetail @@ -369,6 +396,7 @@ class TestServiceForwardsPipelineOptions: ) from services.analysis_service import AnalysisService + svc = AnalysisService(converter=mock_converter) opts = { @@ -399,7 +427,10 @@ class TestServiceForwardsPipelineOptions: @patch("services.analysis_service.document_repo") @pytest.mark.asyncio async def test_run_analysis_uses_defaults_when_no_options( - self, mock_doc_repo, mock_analysis_repo, mock_job, + self, + mock_doc_repo, + mock_analysis_repo, + mock_job, ): from domain.value_objects import ConversionResult, PageDetail @@ -416,6 +447,7 @@ class TestServiceForwardsPipelineOptions: ) from services.analysis_service import AnalysisService + svc = AnalysisService(converter=mock_converter) await svc._run_analysis("j1", "/tmp/test.pdf", "test.pdf", None) @@ -429,7 +461,10 @@ class TestServiceForwardsPipelineOptions: @patch("services.analysis_service.document_repo") @pytest.mark.asyncio async def test_run_analysis_marks_failed_on_error( - self, mock_doc_repo, mock_analysis_repo, mock_job, + 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() @@ -438,6 +473,7 @@ class TestServiceForwardsPipelineOptions: mock_converter.convert.side_effect = RuntimeError("Docling crashed") from services.analysis_service import AnalysisService + svc = AnalysisService(converter=mock_converter) await svc._run_analysis("j1", "/tmp/test.pdf", "test.pdf", {"do_ocr": False}) @@ -453,6 +489,7 @@ class TestServiceForwardsPipelineOptions: # API endpoint — full request/response with pipeline options # --------------------------------------------------------------------------- + class TestAnalysisEndpointPipelineOptions: """Integration-level tests for the analysis creation endpoint with pipeline options.""" @@ -461,6 +498,7 @@ class TestAnalysisEndpointPipelineOptions: from fastapi.testclient import TestClient from main import app + return TestClient(app, raise_server_exceptions=False) @pytest.fixture @@ -468,6 +506,7 @@ class TestAnalysisEndpointPipelineOptions: from unittest.mock import MagicMock from main import app + mock = MagicMock() original = getattr(app.state, "analysis_service", None) app.state.analysis_service = mock @@ -476,20 +515,25 @@ class TestAnalysisEndpointPipelineOptions: def test_no_pipeline_options_sends_none(self, client, mock_svc): from domain.models import AnalysisJob + mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1")) client.post("/api/analyses", json={"documentId": "d1"}) - mock_svc.create.assert_called_once_with("d1", pipeline_options=None) + mock_svc.create.assert_called_once_with("d1", pipeline_options=None, chunking_options=None) def test_empty_pipeline_options_object_uses_defaults(self, client, mock_svc): from domain.models import AnalysisJob + mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1")) - client.post("/api/analyses", json={ - "documentId": "d1", - "pipelineOptions": {}, - }) + client.post( + "/api/analyses", + json={ + "documentId": "d1", + "pipelineOptions": {}, + }, + ) opts = mock_svc.create.call_args.kwargs["pipeline_options"] assert opts["do_ocr"] is True @@ -501,12 +545,16 @@ class TestAnalysisEndpointPipelineOptions: def test_partial_pipeline_options_merges_with_defaults(self, client, mock_svc): from domain.models import AnalysisJob + mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1")) - client.post("/api/analyses", json={ - "documentId": "d1", - "pipelineOptions": {"do_ocr": False, "images_scale": 1.5}, - }) + client.post( + "/api/analyses", + json={ + "documentId": "d1", + "pipelineOptions": {"do_ocr": False, "images_scale": 1.5}, + }, + ) opts = mock_svc.create.call_args.kwargs["pipeline_options"] assert opts["do_ocr"] is False @@ -522,6 +570,7 @@ class TestAnalysisEndpointPipelineOptions: def test_full_pipeline_options(self, client, mock_svc): from domain.models import AnalysisJob + mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1")) payload = { @@ -547,18 +596,25 @@ class TestAnalysisEndpointPipelineOptions: assert opts == payload["pipelineOptions"] def test_invalid_pipeline_option_type_rejected(self, client, mock_svc): - resp = client.post("/api/analyses", json={ - "documentId": "d1", - "pipelineOptions": {"do_ocr": "not-a-bool"}, - }) + resp = client.post( + "/api/analyses", + json={ + "documentId": "d1", + "pipelineOptions": {"do_ocr": "not-a-bool"}, + }, + ) assert resp.status_code == 422 def test_unknown_pipeline_option_ignored(self, client, mock_svc): from domain.models import AnalysisJob + mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1")) - resp = client.post("/api/analyses", json={ - "documentId": "d1", - "pipelineOptions": {"do_ocr": True, "unknown_field": True}, - }) + resp = client.post( + "/api/analyses", + json={ + "documentId": "d1", + "pipelineOptions": {"do_ocr": True, "unknown_field": True}, + }, + ) assert resp.status_code == 200 diff --git a/document-parser/tests/test_serve_converter.py b/document-parser/tests/test_serve_converter.py index 89490b3..dd4d4ab 100644 --- a/document-parser/tests/test_serve_converter.py +++ b/document-parser/tests/test_serve_converter.py @@ -20,6 +20,7 @@ from infra.serve_converter import ( # Unit tests — form data building # --------------------------------------------------------------------------- + class TestBuildFormData: def test_default_options(self): data = _build_form_data(ConversionOptions()) @@ -36,7 +37,9 @@ class TestBuildFormData: def test_custom_options(self): opts = ConversionOptions( - do_ocr=False, table_mode="fast", images_scale=2.0, + do_ocr=False, + table_mode="fast", + images_scale=2.0, generate_picture_images=True, ) data = _build_form_data(opts) @@ -50,6 +53,7 @@ class TestBuildFormData: # Unit tests — response parsing # --------------------------------------------------------------------------- + class TestParseResponse: def test_minimal_response(self): data = { @@ -82,12 +86,34 @@ class TestParseResponse: { "label": "title", "text": "Title", - "prov": [{"page_no": 1, "bbox": {"l": 10, "t": 20, "r": 200, "b": 40, "coord_origin": "TOPLEFT"}}], + "prov": [ + { + "page_no": 1, + "bbox": { + "l": 10, + "t": 20, + "r": 200, + "b": 40, + "coord_origin": "TOPLEFT", + }, + } + ], }, { "label": "paragraph", "text": "Text", - "prov": [{"page_no": 1, "bbox": {"l": 10, "t": 50, "r": 200, "b": 70, "coord_origin": "TOPLEFT"}}], + "prov": [ + { + "page_no": 1, + "bbox": { + "l": 10, + "t": 50, + "r": 200, + "b": 70, + "coord_origin": "TOPLEFT", + }, + } + ], }, ], "tables": [], @@ -112,7 +138,9 @@ class TestParseResponse: "1": {"size": {"width": 612.0, "height": 792.0}}, "2": {"size": {"width": 595.0, "height": 842.0}}, }, - "texts": [], "tables": [], "pictures": [], + "texts": [], + "tables": [], + "pictures": [], }, } } @@ -135,7 +163,9 @@ class TestParseResponse: def test_json_content_as_string(self): json_doc = { "pages": {"1": {"size": {"width": 612.0, "height": 792.0}}}, - "texts": [], "tables": [], "pictures": [], + "texts": [], + "tables": [], + "pictures": [], } data = { "document": { @@ -171,10 +201,40 @@ class TestParseResponse: "pages": {"1": {"size": {"width": 612.0, "height": 792.0}}}, "texts": [], "tables": [ - {"label": "table", "text": "", "prov": [{"page_no": 1, "bbox": {"l": 10, "t": 10, "r": 300, "b": 200, "coord_origin": "TOPLEFT"}}]}, + { + "label": "table", + "text": "", + "prov": [ + { + "page_no": 1, + "bbox": { + "l": 10, + "t": 10, + "r": 300, + "b": 200, + "coord_origin": "TOPLEFT", + }, + } + ], + }, ], "pictures": [ - {"label": "picture", "text": "", "prov": [{"page_no": 1, "bbox": {"l": 50, "t": 300, "r": 250, "b": 500, "coord_origin": "TOPLEFT"}}]}, + { + "label": "picture", + "text": "", + "prov": [ + { + "page_no": 1, + "bbox": { + "l": 50, + "t": 300, + "r": 250, + "b": 500, + "coord_origin": "TOPLEFT", + }, + } + ], + }, ], }, } @@ -189,14 +249,19 @@ class TestParseResponse: # Unit tests — bbox extraction # --------------------------------------------------------------------------- + class TestExtractBbox: def test_topleft_passthrough(self): - bbox = _extract_bbox({"l": 10, "t": 20, "r": 100, "b": 50, "coord_origin": "TOPLEFT"}, 792.0) + bbox = _extract_bbox( + {"l": 10, "t": 20, "r": 100, "b": 50, "coord_origin": "TOPLEFT"}, 792.0 + ) assert bbox == [10, 20, 100, 50] def test_bottomleft_conversion(self): # In BOTTOMLEFT: t (top of box) has higher y than b (bottom of box) - bbox = _extract_bbox({"l": 10, "t": 772, "r": 100, "b": 742, "coord_origin": "BOTTOMLEFT"}, 792.0) + bbox = _extract_bbox( + {"l": 10, "t": 772, "r": 100, "b": 742, "coord_origin": "BOTTOMLEFT"}, 792.0 + ) # new_top = 792 - 772 = 20, new_bottom = 792 - 742 = 50 assert bbox == [10, 20, 100, 50] @@ -217,9 +282,11 @@ class TestExtractBbox: # Unit tests — label mapping # --------------------------------------------------------------------------- + class TestLabelMapping: def test_known_labels(self): from infra.serve_converter import _LABEL_MAP + assert _LABEL_MAP["table"] == "table" assert _LABEL_MAP["picture"] == "picture" assert _LABEL_MAP["figure"] == "picture" @@ -232,6 +299,7 @@ class TestLabelMapping: def test_unknown_label_defaults_to_text(self): from infra.serve_converter import _LABEL_MAP + assert _LABEL_MAP.get("unknown_thing", "text") == "text" @@ -239,6 +307,7 @@ class TestLabelMapping: # Unit tests — ServeConverter # --------------------------------------------------------------------------- + class TestServeConverter: def test_headers_with_api_key(self): conv = ServeConverter(base_url="http://localhost:5001", api_key="secret") @@ -257,6 +326,7 @@ class TestServeConverter: # Integration tests — HTTP calls (mocked) # --------------------------------------------------------------------------- + class TestServeConverterConvert: @pytest.mark.asyncio async def test_successful_conversion(self, tmp_path): @@ -270,7 +340,22 @@ class TestServeConverterConvert: "json_content": { "pages": {"1": {"size": {"width": 612.0, "height": 792.0}}}, "texts": [ - {"label": "title", "text": "Converted", "prov": [{"page_no": 1, "bbox": {"l": 10, "t": 20, "r": 200, "b": 40, "coord_origin": "TOPLEFT"}}]}, + { + "label": "title", + "text": "Converted", + "prov": [ + { + "page_no": 1, + "bbox": { + "l": 10, + "t": 20, + "r": 200, + "b": 40, + "coord_origin": "TOPLEFT", + }, + } + ], + }, ], "tables": [], "pictures": [], @@ -312,7 +397,9 @@ class TestServeConverterConvert: mock_response = MagicMock() mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( - "Server Error", request=MagicMock(), response=MagicMock(status_code=500), + "Server Error", + request=MagicMock(), + response=MagicMock(status_code=500), ) mock_client = AsyncMock() @@ -322,8 +409,10 @@ class TestServeConverterConvert: conv = ServeConverter(base_url="http://localhost:5001") - with patch("infra.serve_converter.httpx.AsyncClient", return_value=mock_client), \ - pytest.raises(httpx.HTTPStatusError): + with ( + patch("infra.serve_converter.httpx.AsyncClient", return_value=mock_client), + pytest.raises(httpx.HTTPStatusError), + ): await conv.convert(str(test_file), ConversionOptions()) @pytest.mark.asyncio @@ -358,6 +447,7 @@ class TestServeConverterConvert: # Integration — converter wiring in main.py # --------------------------------------------------------------------------- + class TestConverterWiring: def test_local_engine_builds_local_converter(self): from infra.local_converter import LocalConverter @@ -365,14 +455,19 @@ class TestConverterWiring: with patch("main.settings", Settings(conversion_engine="local")): from main import _build_converter + converter = _build_converter() assert isinstance(converter, LocalConverter) def test_remote_engine_builds_serve_converter(self): from infra.settings import Settings - with patch("main.settings", Settings(conversion_engine="remote", docling_serve_url="http://serve:5001")): + with patch( + "main.settings", + Settings(conversion_engine="remote", docling_serve_url="http://serve:5001"), + ): from main import _build_converter + converter = _build_converter() assert isinstance(converter, ServeConverter) assert converter._base_url == "http://serve:5001" @@ -380,8 +475,16 @@ class TestConverterWiring: def test_remote_engine_passes_api_key(self): from infra.settings import Settings - with patch("main.settings", Settings(conversion_engine="remote", docling_serve_url="http://serve:5001", docling_serve_api_key="my-key")): + with patch( + "main.settings", + Settings( + conversion_engine="remote", + docling_serve_url="http://serve:5001", + docling_serve_api_key="my-key", + ), + ): from main import _build_converter + converter = _build_converter() assert isinstance(converter, ServeConverter) assert converter._api_key == "my-key"