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.
This commit is contained in:
Pier-Jean Malandrino 2026-04-02 12:05:36 +02:00
parent a9517d38eb
commit 05de0cc774
4 changed files with 562 additions and 85 deletions

View file

@ -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"]

View file

@ -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="<h1>Title</h1>",
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="<h1>Title</h1>",
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

View file

@ -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

View file

@ -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"