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) @patch("services.document_service.find_by_id", new_callable=AsyncMock)
def test_get_document(self, mock_find, client): def test_get_document(self, mock_find, client):
mock_find.return_value = Document( mock_find.return_value = Document(
id="d1", filename="test.pdf", content_type="application/pdf", id="d1",
file_size=2048, page_count=3, storage_path="/tmp/test", filename="test.pdf",
content_type="application/pdf",
file_size=2048,
page_count=3,
storage_path="/tmp/test",
) )
resp = client.get("/api/documents/d1") resp = client.get("/api/documents/d1")
@ -74,8 +78,10 @@ class TestDocumentEndpoints:
@patch("services.document_service.upload", new_callable=AsyncMock) @patch("services.document_service.upload", new_callable=AsyncMock)
def test_upload_document(self, mock_upload, client): def test_upload_document(self, mock_upload, client):
mock_upload.return_value = Document( mock_upload.return_value = Document(
id="new-1", filename="uploaded.pdf", id="new-1",
content_type="application/pdf", file_size=512, filename="uploaded.pdf",
content_type="application/pdf",
file_size=512,
storage_path="/tmp/uploaded", storage_path="/tmp/uploaded",
) )
@ -115,9 +121,11 @@ class TestDocumentEndpoints:
class TestAnalysisEndpoints: class TestAnalysisEndpoints:
def test_list_analyses(self, client, mock_analysis_service): def test_list_analyses(self, client, mock_analysis_service):
mock_analysis_service.find_all = AsyncMock(return_value=[ mock_analysis_service.find_all = AsyncMock(
AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf"), return_value=[
]) AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf"),
]
)
resp = client.get("/api/analyses") resp = client.get("/api/analyses")
assert resp.status_code == 200 assert resp.status_code == 200
@ -146,37 +154,52 @@ class TestAnalysisEndpoints:
assert resp.status_code == 404 assert resp.status_code == 404
def test_create_analysis(self, client, mock_analysis_service): def test_create_analysis(self, client, mock_analysis_service):
mock_analysis_service.create = AsyncMock(return_value=AnalysisJob( mock_analysis_service.create = AsyncMock(
id="j1", document_id="d1", document_filename="test.pdf", return_value=AnalysisJob(
)) id="j1",
document_id="d1",
document_filename="test.pdf",
)
)
resp = client.post("/api/analyses", json={"documentId": "d1"}) resp = client.post("/api/analyses", json={"documentId": "d1"})
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data["id"] == "j1" assert data["id"] == "j1"
assert data["documentId"] == "d1" 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): def test_create_analysis_with_pipeline_options(self, client, mock_analysis_service):
mock_analysis_service.create = AsyncMock(return_value=AnalysisJob( mock_analysis_service.create = AsyncMock(
id="j2", document_id="d1", document_filename="test.pdf", return_value=AnalysisJob(
)) id="j2",
document_id="d1",
document_filename="test.pdf",
)
)
resp = client.post("/api/analyses", json={ resp = client.post(
"documentId": "d1", "/api/analyses",
"pipelineOptions": { json={
"do_ocr": False, "documentId": "d1",
"do_table_structure": True, "pipelineOptions": {
"table_mode": "fast", "do_ocr": False,
"do_code_enrichment": True, "do_table_structure": True,
"do_formula_enrichment": False, "table_mode": "fast",
"do_picture_classification": False, "do_code_enrichment": True,
"do_picture_description": False, "do_formula_enrichment": False,
"generate_picture_images": True, "do_picture_classification": False,
"generate_page_images": False, "do_picture_description": False,
"images_scale": 2.0, "generate_picture_images": True,
} "generate_page_images": False,
}) "images_scale": 2.0,
},
},
)
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data["id"] == "j2" assert data["id"] == "j2"
@ -191,14 +214,17 @@ class TestAnalysisEndpoints:
def test_create_analysis_with_partial_pipeline_options(self, client, mock_analysis_service): def test_create_analysis_with_partial_pipeline_options(self, client, mock_analysis_service):
"""Pipeline options should use defaults for unspecified fields.""" """Pipeline options should use defaults for unspecified fields."""
mock_analysis_service.create = AsyncMock(return_value=AnalysisJob( mock_analysis_service.create = AsyncMock(
id="j3", document_id="d1", document_filename="test.pdf", return_value=AnalysisJob(
)) id="j3",
document_id="d1",
document_filename="test.pdf",
)
)
resp = client.post("/api/analyses", json={ resp = client.post(
"documentId": "d1", "/api/analyses", json={"documentId": "d1", "pipelineOptions": {"do_ocr": False}}
"pipelineOptions": {"do_ocr": False} )
})
assert resp.status_code == 200 assert resp.status_code == 200
opts = mock_analysis_service.create.call_args.kwargs["pipeline_options"] 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 # build_converter — verifies Docling pipeline options are wired correctly
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestBuildConverter: class TestBuildConverter:
"""Verify that build_converter produces a DocumentConverter with the right PdfPipelineOptions.""" """Verify that build_converter produces a DocumentConverter with the right PdfPipelineOptions."""
@ -101,18 +102,20 @@ class TestBuildConverter:
assert opts.images_scale == 2.0 assert opts.images_scale == 2.0
def test_all_options_combined(self): def test_all_options_combined(self):
conv = build_converter(ConversionOptions( conv = build_converter(
do_ocr=False, ConversionOptions(
do_table_structure=True, do_ocr=False,
table_mode="fast", do_table_structure=True,
do_code_enrichment=True, table_mode="fast",
do_formula_enrichment=True, do_code_enrichment=True,
do_picture_classification=True, do_formula_enrichment=True,
do_picture_description=True, do_picture_classification=True,
generate_picture_images=True, do_picture_description=True,
generate_page_images=True, generate_picture_images=True,
images_scale=1.5, generate_page_images=True,
)) images_scale=1.5,
)
)
opts = self._get_pipeline_options(conv) opts = self._get_pipeline_options(conv)
assert opts.do_ocr is False assert opts.do_ocr is False
assert opts.do_table_structure is True assert opts.do_table_structure is True
@ -130,6 +133,7 @@ class TestBuildConverter:
# convert_document — default vs custom converter routing # convert_document — default vs custom converter routing
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestConvertDocumentRouting: class TestConvertDocumentRouting:
"""Verify convert_document uses default converter for default opts, custom otherwise.""" """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.iterate_items.return_value = []
mock_result.document.export_to_markdown.return_value = "" mock_result.document.export_to_markdown.return_value = ""
mock_result.document.export_to_html.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_conv.convert.return_value = mock_result
mock_get_default.return_value = mock_conv mock_get_default.return_value = mock_conv
@ -159,6 +164,7 @@ class TestConvertDocumentRouting:
mock_result.document.iterate_items.return_value = [] mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.return_value = "" mock_result.document.export_to_markdown.return_value = ""
mock_result.document.export_to_html.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_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv mock_build.return_value = mock_conv
@ -176,6 +182,7 @@ class TestConvertDocumentRouting:
mock_result.document.iterate_items.return_value = [] mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.return_value = "" mock_result.document.export_to_markdown.return_value = ""
mock_result.document.export_to_html.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_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv mock_build.return_value = mock_conv
@ -193,6 +200,7 @@ class TestConvertDocumentRouting:
mock_result.document.iterate_items.return_value = [] mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.return_value = "" mock_result.document.export_to_markdown.return_value = ""
mock_result.document.export_to_html.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_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv mock_build.return_value = mock_conv
@ -210,6 +218,7 @@ class TestConvertDocumentRouting:
mock_result.document.iterate_items.return_value = [] mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.return_value = "" mock_result.document.export_to_markdown.return_value = ""
mock_result.document.export_to_html.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_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv mock_build.return_value = mock_conv
@ -226,6 +235,7 @@ class TestConvertDocumentRouting:
mock_result.document.iterate_items.return_value = [] mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.return_value = "" mock_result.document.export_to_markdown.return_value = ""
mock_result.document.export_to_html.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_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv mock_build.return_value = mock_conv
@ -242,6 +252,7 @@ class TestConvertDocumentRouting:
mock_result.document.iterate_items.return_value = [] mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.return_value = "" mock_result.document.export_to_markdown.return_value = ""
mock_result.document.export_to_html.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_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv mock_build.return_value = mock_conv
@ -258,6 +269,7 @@ class TestConvertDocumentRouting:
mock_result.document.iterate_items.return_value = [] mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.return_value = "" mock_result.document.export_to_markdown.return_value = ""
mock_result.document.export_to_html.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_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv mock_build.return_value = mock_conv
@ -275,6 +287,7 @@ class TestConvertDocumentRouting:
mock_result.document.iterate_items.return_value = [] mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.return_value = "" mock_result.document.export_to_markdown.return_value = ""
mock_result.document.export_to_html.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_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv mock_build.return_value = mock_conv
@ -299,30 +312,37 @@ class TestConvertDocumentRouting:
# Service layer — pipeline options forwarding # Service layer — pipeline options forwarding
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestServiceForwardsPipelineOptions: class TestServiceForwardsPipelineOptions:
"""Verify analysis_service.create and _run_analysis forward pipeline options.""" """Verify analysis_service.create and _run_analysis forward pipeline options."""
@pytest.fixture @pytest.fixture
def mock_doc(self): def mock_doc(self):
from domain.models import Document from domain.models import Document
return Document(id="d1", filename="test.pdf", storage_path="/tmp/test.pdf") return Document(id="d1", filename="test.pdf", storage_path="/tmp/test.pdf")
@pytest.fixture @pytest.fixture
def mock_job(self): def mock_job(self):
from domain.models import AnalysisJob from domain.models import AnalysisJob
return AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf") return AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf")
@patch("services.analysis_service.document_repo") @patch("services.analysis_service.document_repo")
@patch("services.analysis_service.analysis_repo") @patch("services.analysis_service.analysis_repo")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_passes_pipeline_options_to_run( 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_doc_repo.find_by_id = AsyncMock(return_value=mock_doc)
mock_analysis_repo.insert = AsyncMock() mock_analysis_repo.insert = AsyncMock()
mock_converter = AsyncMock() mock_converter = AsyncMock()
from services.analysis_service import AnalysisService from services.analysis_service import AnalysisService
svc = AnalysisService(converter=mock_converter) svc = AnalysisService(converter=mock_converter)
opts = {"do_ocr": False, "table_mode": "fast"} opts = {"do_ocr": False, "table_mode": "fast"}
@ -335,13 +355,17 @@ class TestServiceForwardsPipelineOptions:
@patch("services.analysis_service.analysis_repo") @patch("services.analysis_service.analysis_repo")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_passes_none_when_no_options( 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_doc_repo.find_by_id = AsyncMock(return_value=mock_doc)
mock_analysis_repo.insert = AsyncMock() mock_analysis_repo.insert = AsyncMock()
mock_converter = AsyncMock() mock_converter = AsyncMock()
from services.analysis_service import AnalysisService from services.analysis_service import AnalysisService
svc = AnalysisService(converter=mock_converter) svc = AnalysisService(converter=mock_converter)
with patch("services.analysis_service.asyncio.create_task") as mock_task: with patch("services.analysis_service.asyncio.create_task") as mock_task:
@ -352,7 +376,10 @@ class TestServiceForwardsPipelineOptions:
@patch("services.analysis_service.document_repo") @patch("services.analysis_service.document_repo")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_run_analysis_forwards_options_to_convert( 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 from domain.value_objects import ConversionResult, PageDetail
@ -369,6 +396,7 @@ class TestServiceForwardsPipelineOptions:
) )
from services.analysis_service import AnalysisService from services.analysis_service import AnalysisService
svc = AnalysisService(converter=mock_converter) svc = AnalysisService(converter=mock_converter)
opts = { opts = {
@ -399,7 +427,10 @@ class TestServiceForwardsPipelineOptions:
@patch("services.analysis_service.document_repo") @patch("services.analysis_service.document_repo")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_run_analysis_uses_defaults_when_no_options( 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 from domain.value_objects import ConversionResult, PageDetail
@ -416,6 +447,7 @@ class TestServiceForwardsPipelineOptions:
) )
from services.analysis_service import AnalysisService from services.analysis_service import AnalysisService
svc = AnalysisService(converter=mock_converter) svc = AnalysisService(converter=mock_converter)
await svc._run_analysis("j1", "/tmp/test.pdf", "test.pdf", None) await svc._run_analysis("j1", "/tmp/test.pdf", "test.pdf", None)
@ -429,7 +461,10 @@ class TestServiceForwardsPipelineOptions:
@patch("services.analysis_service.document_repo") @patch("services.analysis_service.document_repo")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_run_analysis_marks_failed_on_error( 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.find_by_id = AsyncMock(return_value=mock_job)
mock_analysis_repo.update_status = AsyncMock() mock_analysis_repo.update_status = AsyncMock()
@ -438,6 +473,7 @@ class TestServiceForwardsPipelineOptions:
mock_converter.convert.side_effect = RuntimeError("Docling crashed") mock_converter.convert.side_effect = RuntimeError("Docling crashed")
from services.analysis_service import AnalysisService from services.analysis_service import AnalysisService
svc = AnalysisService(converter=mock_converter) svc = AnalysisService(converter=mock_converter)
await svc._run_analysis("j1", "/tmp/test.pdf", "test.pdf", {"do_ocr": False}) 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 # API endpoint — full request/response with pipeline options
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestAnalysisEndpointPipelineOptions: class TestAnalysisEndpointPipelineOptions:
"""Integration-level tests for the analysis creation endpoint with pipeline options.""" """Integration-level tests for the analysis creation endpoint with pipeline options."""
@ -461,6 +498,7 @@ class TestAnalysisEndpointPipelineOptions:
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from main import app from main import app
return TestClient(app, raise_server_exceptions=False) return TestClient(app, raise_server_exceptions=False)
@pytest.fixture @pytest.fixture
@ -468,6 +506,7 @@ class TestAnalysisEndpointPipelineOptions:
from unittest.mock import MagicMock from unittest.mock import MagicMock
from main import app from main import app
mock = MagicMock() mock = MagicMock()
original = getattr(app.state, "analysis_service", None) original = getattr(app.state, "analysis_service", None)
app.state.analysis_service = mock app.state.analysis_service = mock
@ -476,20 +515,25 @@ class TestAnalysisEndpointPipelineOptions:
def test_no_pipeline_options_sends_none(self, client, mock_svc): def test_no_pipeline_options_sends_none(self, client, mock_svc):
from domain.models import AnalysisJob from domain.models import AnalysisJob
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1")) mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
client.post("/api/analyses", json={"documentId": "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): def test_empty_pipeline_options_object_uses_defaults(self, client, mock_svc):
from domain.models import AnalysisJob from domain.models import AnalysisJob
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1")) mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
client.post("/api/analyses", json={ client.post(
"documentId": "d1", "/api/analyses",
"pipelineOptions": {}, json={
}) "documentId": "d1",
"pipelineOptions": {},
},
)
opts = mock_svc.create.call_args.kwargs["pipeline_options"] opts = mock_svc.create.call_args.kwargs["pipeline_options"]
assert opts["do_ocr"] is True assert opts["do_ocr"] is True
@ -501,12 +545,16 @@ class TestAnalysisEndpointPipelineOptions:
def test_partial_pipeline_options_merges_with_defaults(self, client, mock_svc): def test_partial_pipeline_options_merges_with_defaults(self, client, mock_svc):
from domain.models import AnalysisJob from domain.models import AnalysisJob
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1")) mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
client.post("/api/analyses", json={ client.post(
"documentId": "d1", "/api/analyses",
"pipelineOptions": {"do_ocr": False, "images_scale": 1.5}, json={
}) "documentId": "d1",
"pipelineOptions": {"do_ocr": False, "images_scale": 1.5},
},
)
opts = mock_svc.create.call_args.kwargs["pipeline_options"] opts = mock_svc.create.call_args.kwargs["pipeline_options"]
assert opts["do_ocr"] is False assert opts["do_ocr"] is False
@ -522,6 +570,7 @@ class TestAnalysisEndpointPipelineOptions:
def test_full_pipeline_options(self, client, mock_svc): def test_full_pipeline_options(self, client, mock_svc):
from domain.models import AnalysisJob from domain.models import AnalysisJob
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1")) mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
payload = { payload = {
@ -547,18 +596,25 @@ class TestAnalysisEndpointPipelineOptions:
assert opts == payload["pipelineOptions"] assert opts == payload["pipelineOptions"]
def test_invalid_pipeline_option_type_rejected(self, client, mock_svc): def test_invalid_pipeline_option_type_rejected(self, client, mock_svc):
resp = client.post("/api/analyses", json={ resp = client.post(
"documentId": "d1", "/api/analyses",
"pipelineOptions": {"do_ocr": "not-a-bool"}, json={
}) "documentId": "d1",
"pipelineOptions": {"do_ocr": "not-a-bool"},
},
)
assert resp.status_code == 422 assert resp.status_code == 422
def test_unknown_pipeline_option_ignored(self, client, mock_svc): def test_unknown_pipeline_option_ignored(self, client, mock_svc):
from domain.models import AnalysisJob from domain.models import AnalysisJob
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1")) mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
resp = client.post("/api/analyses", json={ resp = client.post(
"documentId": "d1", "/api/analyses",
"pipelineOptions": {"do_ocr": True, "unknown_field": True}, json={
}) "documentId": "d1",
"pipelineOptions": {"do_ocr": True, "unknown_field": True},
},
)
assert resp.status_code == 200 assert resp.status_code == 200

View file

@ -20,6 +20,7 @@ from infra.serve_converter import (
# Unit tests — form data building # Unit tests — form data building
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestBuildFormData: class TestBuildFormData:
def test_default_options(self): def test_default_options(self):
data = _build_form_data(ConversionOptions()) data = _build_form_data(ConversionOptions())
@ -36,7 +37,9 @@ class TestBuildFormData:
def test_custom_options(self): def test_custom_options(self):
opts = ConversionOptions( 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, generate_picture_images=True,
) )
data = _build_form_data(opts) data = _build_form_data(opts)
@ -50,6 +53,7 @@ class TestBuildFormData:
# Unit tests — response parsing # Unit tests — response parsing
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestParseResponse: class TestParseResponse:
def test_minimal_response(self): def test_minimal_response(self):
data = { data = {
@ -82,12 +86,34 @@ class TestParseResponse:
{ {
"label": "title", "label": "title",
"text": "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", "label": "paragraph",
"text": "Text", "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": [], "tables": [],
@ -112,7 +138,9 @@ class TestParseResponse:
"1": {"size": {"width": 612.0, "height": 792.0}}, "1": {"size": {"width": 612.0, "height": 792.0}},
"2": {"size": {"width": 595.0, "height": 842.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): def test_json_content_as_string(self):
json_doc = { json_doc = {
"pages": {"1": {"size": {"width": 612.0, "height": 792.0}}}, "pages": {"1": {"size": {"width": 612.0, "height": 792.0}}},
"texts": [], "tables": [], "pictures": [], "texts": [],
"tables": [],
"pictures": [],
} }
data = { data = {
"document": { "document": {
@ -171,10 +201,40 @@ class TestParseResponse:
"pages": {"1": {"size": {"width": 612.0, "height": 792.0}}}, "pages": {"1": {"size": {"width": 612.0, "height": 792.0}}},
"texts": [], "texts": [],
"tables": [ "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": [ "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 # Unit tests — bbox extraction
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestExtractBbox: class TestExtractBbox:
def test_topleft_passthrough(self): 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] assert bbox == [10, 20, 100, 50]
def test_bottomleft_conversion(self): def test_bottomleft_conversion(self):
# In BOTTOMLEFT: t (top of box) has higher y than b (bottom of box) # 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 # new_top = 792 - 772 = 20, new_bottom = 792 - 742 = 50
assert bbox == [10, 20, 100, 50] assert bbox == [10, 20, 100, 50]
@ -217,9 +282,11 @@ class TestExtractBbox:
# Unit tests — label mapping # Unit tests — label mapping
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestLabelMapping: class TestLabelMapping:
def test_known_labels(self): def test_known_labels(self):
from infra.serve_converter import _LABEL_MAP from infra.serve_converter import _LABEL_MAP
assert _LABEL_MAP["table"] == "table" assert _LABEL_MAP["table"] == "table"
assert _LABEL_MAP["picture"] == "picture" assert _LABEL_MAP["picture"] == "picture"
assert _LABEL_MAP["figure"] == "picture" assert _LABEL_MAP["figure"] == "picture"
@ -232,6 +299,7 @@ class TestLabelMapping:
def test_unknown_label_defaults_to_text(self): def test_unknown_label_defaults_to_text(self):
from infra.serve_converter import _LABEL_MAP from infra.serve_converter import _LABEL_MAP
assert _LABEL_MAP.get("unknown_thing", "text") == "text" assert _LABEL_MAP.get("unknown_thing", "text") == "text"
@ -239,6 +307,7 @@ class TestLabelMapping:
# Unit tests — ServeConverter # Unit tests — ServeConverter
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestServeConverter: class TestServeConverter:
def test_headers_with_api_key(self): def test_headers_with_api_key(self):
conv = ServeConverter(base_url="http://localhost:5001", api_key="secret") conv = ServeConverter(base_url="http://localhost:5001", api_key="secret")
@ -257,6 +326,7 @@ class TestServeConverter:
# Integration tests — HTTP calls (mocked) # Integration tests — HTTP calls (mocked)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestServeConverterConvert: class TestServeConverterConvert:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_successful_conversion(self, tmp_path): async def test_successful_conversion(self, tmp_path):
@ -270,7 +340,22 @@ class TestServeConverterConvert:
"json_content": { "json_content": {
"pages": {"1": {"size": {"width": 612.0, "height": 792.0}}}, "pages": {"1": {"size": {"width": 612.0, "height": 792.0}}},
"texts": [ "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": [], "tables": [],
"pictures": [], "pictures": [],
@ -312,7 +397,9 @@ class TestServeConverterConvert:
mock_response = MagicMock() mock_response = MagicMock()
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( 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() mock_client = AsyncMock()
@ -322,8 +409,10 @@ class TestServeConverterConvert:
conv = ServeConverter(base_url="http://localhost:5001") conv = ServeConverter(base_url="http://localhost:5001")
with patch("infra.serve_converter.httpx.AsyncClient", return_value=mock_client), \ with (
pytest.raises(httpx.HTTPStatusError): patch("infra.serve_converter.httpx.AsyncClient", return_value=mock_client),
pytest.raises(httpx.HTTPStatusError),
):
await conv.convert(str(test_file), ConversionOptions()) await conv.convert(str(test_file), ConversionOptions())
@pytest.mark.asyncio @pytest.mark.asyncio
@ -358,6 +447,7 @@ class TestServeConverterConvert:
# Integration — converter wiring in main.py # Integration — converter wiring in main.py
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestConverterWiring: class TestConverterWiring:
def test_local_engine_builds_local_converter(self): def test_local_engine_builds_local_converter(self):
from infra.local_converter import LocalConverter from infra.local_converter import LocalConverter
@ -365,14 +455,19 @@ class TestConverterWiring:
with patch("main.settings", Settings(conversion_engine="local")): with patch("main.settings", Settings(conversion_engine="local")):
from main import _build_converter from main import _build_converter
converter = _build_converter() converter = _build_converter()
assert isinstance(converter, LocalConverter) assert isinstance(converter, LocalConverter)
def test_remote_engine_builds_serve_converter(self): def test_remote_engine_builds_serve_converter(self):
from infra.settings import Settings 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 from main import _build_converter
converter = _build_converter() converter = _build_converter()
assert isinstance(converter, ServeConverter) assert isinstance(converter, ServeConverter)
assert converter._base_url == "http://serve:5001" assert converter._base_url == "http://serve:5001"
@ -380,8 +475,16 @@ class TestConverterWiring:
def test_remote_engine_passes_api_key(self): def test_remote_engine_passes_api_key(self):
from infra.settings import Settings 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 from main import _build_converter
converter = _build_converter() converter = _build_converter()
assert isinstance(converter, ServeConverter) assert isinstance(converter, ServeConverter)
assert converter._api_key == "my-key" assert converter._api_key == "my-key"