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"