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
225 lines
8 KiB
Python
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
|