docling-studio/document-parser/tests/test_document_chunks_api.py
Pier-Jean Malandrino 70972e807a feat(#256): add /api/documents/{id}/chunks routes + ChunkService
The frontend was wired against /api/documents/{id}/chunks/* (canonical
doc-centric chunkset) but the backend never exposed those routes — the
chunk tab in the doc workspace 404'd. The domain entities (Chunk,
ChunkEdit, ChunkPush) and persistence repos already existed since #205;
what was missing was the service + API layer that connects them.

ChunkService owns all canonical chunkset invariants (sequence ordering,
soft-delete + audit log atomicity) and shares the chunker port with
AnalysisService so chunking strategy stays a single implementation.

AnalysisService grew a duck-typed promoter hook that copies the chunks
of the first successful analysis into the canonical chunkset. The hook
is idempotent so subsequent ad-hoc analyses (Studio / OCR Debug) never
overwrite hand-edited state.

Routes added (all additive, /api/documents prefix):
  GET    /{id}/chunks
  POST   /{id}/chunks
  PATCH  /{id}/chunks/{chunkId}
  DELETE /{id}/chunks/{chunkId}
  POST   /{id}/chunks/{chunkId}/split
  POST   /{id}/chunks/merge
  POST   /{id}/rechunk
  GET    /{id}/tree
  GET    /{id}/diff?store=...
  POST   /{id}/chunks/push
2026-05-07 11:09:04 +02:00

225 lines
8 KiB
Python

"""Integration tests for the /api/documents/{id}/chunks router (#256)."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi.testclient import TestClient
from domain.models import Chunk
from main import app
from services.chunk_service import (
ChunkConflictError,
ChunkNotFoundError,
ChunkValidationError,
DocumentNotFoundError,
)
@pytest.fixture
def client():
return TestClient(app, raise_server_exceptions=False)
@pytest.fixture
def mock_service(client):
svc = MagicMock()
original = getattr(app.state, "chunk_service", None)
app.state.chunk_service = svc
yield svc
app.state.chunk_service = original
def _chunk(*, id="c-1", doc_id="d-1", sequence=0, text="hi") -> Chunk:
return Chunk(id=id, document_id=doc_id, sequence=sequence, text=text)
class TestListChunks:
def test_200(self, client, mock_service):
mock_service.list_chunks = AsyncMock(return_value=[_chunk(id="c1"), _chunk(id="c2")])
resp = client.get("/api/documents/d-1/chunks")
assert resp.status_code == 200
data = resp.json()
assert [c["id"] for c in data] == ["c1", "c2"]
# camelCase serialization
assert "docId" in data[0]
assert "sourcePage" in data[0]
def test_404_when_doc_missing(self, client, mock_service):
mock_service.list_chunks = AsyncMock(side_effect=DocumentNotFoundError("nope"))
resp = client.get("/api/documents/no-such/chunks")
assert resp.status_code == 404
class TestAddChunk:
def test_201(self, client, mock_service):
mock_service.add_chunk = AsyncMock(return_value=_chunk(id="new"))
resp = client.post("/api/documents/d-1/chunks", json={"text": "hello"})
assert resp.status_code == 201
assert resp.json()["id"] == "new"
mock_service.add_chunk.assert_awaited_once_with("d-1", text="hello", after_id=None)
def test_400_on_empty_body(self, client, mock_service):
resp = client.post("/api/documents/d-1/chunks", json={"text": ""})
assert resp.status_code == 400
def test_passes_after_id(self, client, mock_service):
mock_service.add_chunk = AsyncMock(return_value=_chunk(id="new"))
client.post(
"/api/documents/d-1/chunks",
json={"text": "x", "afterId": "c-prev"},
)
mock_service.add_chunk.assert_awaited_once_with("d-1", text="x", after_id="c-prev")
class TestUpdateChunk:
def test_200(self, client, mock_service):
mock_service.update_chunk = AsyncMock(return_value=_chunk(text="new"))
resp = client.patch(
"/api/documents/d-1/chunks/c-1",
json={"text": "new"},
)
assert resp.status_code == 200
assert resp.json()["text"] == "new"
def test_400_when_no_field_set(self, client, mock_service):
resp = client.patch("/api/documents/d-1/chunks/c-1", json={})
assert resp.status_code == 400
def test_404_when_chunk_missing(self, client, mock_service):
mock_service.update_chunk = AsyncMock(side_effect=ChunkNotFoundError("nope"))
resp = client.patch("/api/documents/d-1/chunks/c-1", json={"text": "x"})
assert resp.status_code == 404
def test_title_maps_to_first_heading(self, client, mock_service):
mock_service.update_chunk = AsyncMock(return_value=_chunk())
client.patch("/api/documents/d-1/chunks/c-1", json={"title": "Section A"})
mock_service.update_chunk.assert_awaited_once_with(
"d-1", "c-1", text=None, headings=["Section A"]
)
class TestDeleteChunk:
def test_204(self, client, mock_service):
mock_service.delete_chunk = AsyncMock(return_value=None)
resp = client.delete("/api/documents/d-1/chunks/c-1")
assert resp.status_code == 204
def test_404(self, client, mock_service):
mock_service.delete_chunk = AsyncMock(side_effect=ChunkNotFoundError("nope"))
resp = client.delete("/api/documents/d-1/chunks/c-1")
assert resp.status_code == 404
class TestSplit:
def test_200(self, client, mock_service):
mock_service.split_chunk = AsyncMock(
return_value=[_chunk(id="head"), _chunk(id="tail", sequence=1)]
)
resp = client.post(
"/api/documents/d-1/chunks/c-1/split",
json={"cursorOffset": 3},
)
assert resp.status_code == 200
assert [c["id"] for c in resp.json()] == ["head", "tail"]
def test_400_on_invalid_offset(self, client, mock_service):
mock_service.split_chunk = AsyncMock(side_effect=ChunkValidationError("oor"))
resp = client.post(
"/api/documents/d-1/chunks/c-1/split",
json={"cursorOffset": 999},
)
assert resp.status_code == 400
class TestMerge:
def test_200(self, client, mock_service):
mock_service.merge_chunks = AsyncMock(return_value=_chunk(id="merged"))
resp = client.post(
"/api/documents/d-1/chunks/merge",
json={"ids": ["a", "b"]},
)
assert resp.status_code == 200
assert resp.json()["id"] == "merged"
def test_409_on_non_contiguous(self, client, mock_service):
mock_service.merge_chunks = AsyncMock(side_effect=ChunkConflictError("nope"))
resp = client.post(
"/api/documents/d-1/chunks/merge",
json={"ids": ["a", "z"]},
)
assert resp.status_code == 409
class TestRechunk:
def test_200_no_options(self, client, mock_service):
mock_service.rechunk_document = AsyncMock(return_value=[_chunk(id="r1")])
resp = client.post("/api/documents/d-1/rechunk", json={})
assert resp.status_code == 200
mock_service.rechunk_document.assert_awaited_once_with("d-1", None)
def test_200_with_options(self, client, mock_service):
mock_service.rechunk_document = AsyncMock(return_value=[])
resp = client.post(
"/api/documents/d-1/rechunk",
json={"chunkingOptions": {"chunkerType": "hybrid", "maxTokens": 256}},
)
assert resp.status_code == 200
call_args = mock_service.rechunk_document.await_args
assert call_args.args[0] == "d-1"
passed_opts = call_args.args[1]
# snake_case dump from Pydantic ChunkingOptionsRequest
assert passed_opts["chunker_type"] == "hybrid"
assert passed_opts["max_tokens"] == 256
class TestTree:
def test_200_empty(self, client, mock_service):
mock_service.get_tree = AsyncMock(return_value=[])
resp = client.get("/api/documents/d-1/tree")
assert resp.status_code == 200
assert resp.json() == []
def test_200_groups(self, client, mock_service):
mock_service.get_tree = AsyncMock(
return_value=[
{"ref": "#group/title", "type": "group", "label": "Titles", "children": []}
]
)
resp = client.get("/api/documents/d-1/tree")
assert resp.status_code == 200
data = resp.json()
assert data[0]["label"] == "Titles"
class TestDiff:
def test_200(self, client, mock_service):
mock_service.diff_against_store = AsyncMock(
return_value=[
{"chunkId": "c1", "status": "added", "textDiff": None},
]
)
resp = client.get("/api/documents/d-1/diff?store=mystore")
assert resp.status_code == 200
assert resp.json()[0]["chunkId"] == "c1"
def test_400_when_store_missing(self, client, mock_service):
resp = client.get("/api/documents/d-1/diff")
# FastAPI auto-422 on missing required query
assert resp.status_code in (400, 422)
class TestPush:
def test_200(self, client, mock_service):
mock_service.push_to_store = AsyncMock(
return_value={"jobId": "p1", "summary": {"embeds": 3, "tokens": 30}}
)
resp = client.post(
"/api/documents/d-1/chunks/push",
json={"store": "mystore"},
)
assert resp.status_code == 200
body = resp.json()
assert body["jobId"] == "p1"
assert body["summary"]["embeds"] == 3