Files
RAG/backend/tests/unit/test_documents_api.py
YoVinchen ecdb10c37a
Some checks failed
verify / verify (push) Has been cancelled
Make the governed RAG evidence path executable end to end
Separate local parsing from model indexing, bind review decisions to immutable manifests, persist vectors behind active profiles, and expose retrieval, chat, evaluation, and document workflows through the React workbench.

Constraint: Live Bailian authentication currently fails for all three configured capabilities

Rejected: Direct upload-to-embedding flow | bypasses local review and manifest binding

Confidence: high

Scope-risk: broad

Directive: Keep private-data deployment blocked until authentication, RBAC, and separate database roles land

Tested: make verify; fresh and replay Docker document smoke; worker recovery smoke; frozen synthetic evaluation; migration 0003-0004 roundtrip

Not-tested: Successful live Bailian calls, OCR, real multi-user authorization
2026-07-13 05:58:11 +08:00

396 lines
14 KiB
Python

from __future__ import annotations
import hashlib
import uuid
from collections.abc import AsyncIterable
from dataclasses import dataclass, field, replace
from datetime import UTC, datetime
from pathlib import Path
import httpx
import pytest
from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError
from app.adapters.local_storage import (
LocalStorageError,
StorageErrorCode,
StoredUpload,
)
from app.api.v1.documents import (
get_document_actor,
get_documents_repository,
get_upload_storage,
router,
)
from app.core.config import Settings, get_settings
from app.core.demo_identity import (
ACCESS_SCOPE_ID,
BAILIAN_ACCESS_SCOPE_ID,
BAILIAN_KNOWLEDGE_BASE_ID,
KNOWLEDGE_BASE_ID,
)
from app.core.problems import (
ApiProblem,
api_problem_handler,
request_validation_problem_handler,
)
from app.core.request_context import trace_request
from app.persistence.documents import (
CompletedUpload,
DocumentActor,
DocumentDetail,
DocumentListPage,
DocumentSummary,
DocumentUpload,
ReviewBundle,
SafeJob,
)
NOW = datetime(2026, 7, 13, 8, 0, tzinfo=UTC)
TRACE_ID = "10000000-0000-0000-0000-000000000001"
UPLOAD_ID = uuid.UUID("20000000-0000-0000-0000-000000000002")
STORAGE_KEY = uuid.UUID("30000000-0000-0000-0000-000000000003")
DOCUMENT_ID = uuid.UUID("40000000-0000-0000-0000-000000000004")
JOB_ID = uuid.UUID("50000000-0000-0000-0000-000000000005")
IDEMPOTENCY_KEY = "60000000-0000-0000-0000-000000000006"
CONTENT = b"# Synthetic\n\nA governed geological document."
CONTENT_SHA = hashlib.sha256(CONTENT).hexdigest()
def _upload(status: str = "CREATED") -> DocumentUpload:
completed = status == "COMPLETED"
stored = status in {"STORED", "COMPLETED"}
return DocumentUpload(
id=UPLOAD_ID,
filename="synthetic.md",
declared_mime_type="text/markdown",
expected_size=len(CONTENT),
expected_sha256=CONTENT_SHA,
storage_key=STORAGE_KEY,
actual_size=len(CONTENT) if stored else None,
actual_sha256=CONTENT_SHA if stored else None,
status=status,
document_id=DOCUMENT_ID if completed else None,
parse_job_id=JOB_ID if completed else None,
created_at=NOW,
updated_at=NOW,
completed_at=NOW if completed else None,
)
def _document() -> DocumentSummary:
return DocumentSummary(
id=DOCUMENT_ID,
filename="synthetic.md",
mime_type="text/markdown",
raw_sha256=CONTENT_SHA,
status="QUARANTINED_LOCAL_REVIEW",
active_version_id=None,
created_at=NOW,
updated_at=NOW,
)
def _job() -> SafeJob:
return SafeJob(
id=JOB_ID,
job_type="PARSE_DOCUMENT",
stage="PENDING",
status="QUEUED",
progress=0,
attempt=0,
max_attempts=3,
last_error_code=None,
created_at=NOW,
updated_at=NOW,
finished_at=None,
)
@dataclass
class StubRepository:
upload: DocumentUpload = field(default_factory=_upload)
created: bool = True
create_calls: list[dict[str, object]] = field(default_factory=list)
mark_calls: list[dict[str, object]] = field(default_factory=list)
def create_upload(self, **kwargs: object) -> tuple[DocumentUpload, bool]:
self.create_calls.append(kwargs)
return self.upload, self.created
def get_upload(self, actor: DocumentActor, upload_id: uuid.UUID) -> DocumentUpload | None:
assert actor.knowledge_base_id == KNOWLEDGE_BASE_ID
assert actor.access_scope_id == ACCESS_SCOPE_ID
return self.upload if upload_id == UPLOAD_ID else None
def mark_upload_stored(self, **kwargs: object) -> DocumentUpload:
self.mark_calls.append(kwargs)
self.upload = replace(
self.upload,
status="STORED",
actual_size=len(CONTENT),
actual_sha256=CONTENT_SHA,
)
return self.upload
def complete_upload(self, **kwargs: object) -> CompletedUpload:
self.upload = _upload("COMPLETED")
return CompletedUpload(self.upload, _document(), _job())
def get_job(self, actor: DocumentActor, job_id: uuid.UUID) -> SafeJob | None:
assert actor.access_scope_id == ACCESS_SCOPE_ID
return _job() if job_id == JOB_ID else None
def list_documents(
self, actor: DocumentActor, *, cursor: uuid.UUID | None, limit: int
) -> DocumentListPage:
assert actor.access_scope_id == ACCESS_SCOPE_ID
assert cursor is None and limit == 20
return DocumentListPage((_document(),), None)
def get_document(self, actor: DocumentActor, document_id: uuid.UUID) -> DocumentDetail | None:
assert actor.access_scope_id == ACCESS_SCOPE_ID
if document_id != DOCUMENT_ID:
return None
return DocumentDetail(_document(), 0, 0, 0, 0)
def get_review_bundle(
self,
actor: DocumentActor,
document_id: uuid.UUID,
*,
after_ordinal: int,
limit: int,
) -> ReviewBundle | None:
assert actor.access_scope_id == ACCESS_SCOPE_ID
assert after_ordinal == -1 and limit == 50
if document_id != DOCUMENT_ID:
return None
return ReviewBundle(_document(), None, (), (), (), None)
@dataclass
class StubStorage:
error: StorageErrorCode | None = None
received: bytes = b""
async def store(
self,
*,
storage_key: uuid.UUID,
chunks: AsyncIterable[bytes],
expected_size: int,
expected_sha256: str,
) -> StoredUpload:
assert storage_key == STORAGE_KEY
value = bytearray()
async for chunk in chunks:
value.extend(chunk)
self.received = bytes(value)
if self.error is not None:
raise LocalStorageError(self.error)
assert expected_size == len(CONTENT)
assert expected_sha256 == CONTENT_SHA
return StoredUpload(STORAGE_KEY, len(CONTENT), CONTENT_SHA)
def _app(repository: StubRepository, storage: StubStorage, tmp_path: Path) -> FastAPI:
app = FastAPI()
app.middleware("http")(trace_request)
app.add_exception_handler(ApiProblem, api_problem_handler) # type: ignore[arg-type]
app.add_exception_handler(
RequestValidationError,
request_validation_problem_handler, # type: ignore[arg-type]
)
app.include_router(router)
app.dependency_overrides[get_documents_repository] = lambda: repository
app.dependency_overrides[get_upload_storage] = lambda: storage
app.dependency_overrides[get_settings] = lambda: Settings(
upload_root=tmp_path / "uploads",
max_upload_mb=1,
)
return app
def _declaration() -> dict[str, object]:
return {
"filename": "synthetic.md",
"declared_mime_type": "text/markdown",
"expected_size": len(CONTENT),
"expected_sha256": CONTENT_SHA,
}
def test_document_namespace_is_server_configured() -> None:
offline_actor = get_document_actor(Settings(document_namespace_mode="fake"))
bailian_actor = get_document_actor(Settings(document_namespace_mode="bailian"))
assert offline_actor.knowledge_base_id == KNOWLEDGE_BASE_ID
assert offline_actor.access_scope_id == ACCESS_SCOPE_ID
assert bailian_actor.knowledge_base_id == BAILIAN_KNOWLEDGE_BASE_ID
assert bailian_actor.access_scope_id == BAILIAN_ACCESS_SCOPE_ID
@pytest.mark.asyncio
async def test_create_is_idempotent_and_scope_is_server_owned(tmp_path: Path) -> None:
repository = StubRepository(created=False)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=_app(repository, StubStorage(), tmp_path)),
base_url="http://test",
) as client:
response = await client.post(
"/api/v1/document-uploads",
headers={"Idempotency-Key": IDEMPOTENCY_KEY, "x-request-id": TRACE_ID},
json=_declaration(),
)
assert response.status_code == 201
assert response.json()["id"] == str(UPLOAD_ID)
assert response.json()["replayed"] is True
call = repository.create_calls[0]
actor = call["actor"]
assert isinstance(actor, DocumentActor)
assert actor.knowledge_base_id == KNOWLEDGE_BASE_ID
assert actor.access_scope_id == ACCESS_SCOPE_ID
assert len(str(call["idempotency_key_hash"])) == 64
assert IDEMPOTENCY_KEY not in str(call["idempotency_key_hash"])
assert "storage_key" not in response.text
assert "access_scope" not in response.text
@pytest.mark.asyncio
@pytest.mark.parametrize("field", ["access_scope_id", "knowledge_base_id", "storage_key"])
async def test_client_cannot_select_scope_or_storage_fields(field: str, tmp_path: Path) -> None:
repository = StubRepository()
body = _declaration() | {field: str(uuid.uuid4())}
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=_app(repository, StubStorage(), tmp_path)),
base_url="http://test",
) as client:
response = await client.post(
"/api/v1/document-uploads",
headers={"Idempotency-Key": IDEMPOTENCY_KEY},
json=body,
)
assert response.status_code == 422
assert response.headers["content-type"].startswith("application/problem+json")
assert response.json()["code"] == "REQUEST_VALIDATION_FAILED"
assert "input" not in response.text
assert repository.create_calls == []
@pytest.mark.asyncio
async def test_invalid_idempotency_key_is_problem_json_without_echo(tmp_path: Path) -> None:
repository = StubRepository()
secret = "sk-" + "A" * 24
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=_app(repository, StubStorage(), tmp_path)),
base_url="http://test",
) as client:
response = await client.post(
"/api/v1/document-uploads",
headers={"Idempotency-Key": secret},
json=_declaration(),
)
assert response.status_code == 422
assert response.headers["content-type"].startswith("application/problem+json")
assert response.json()["code"] == "IDEMPOTENCY_KEY_INVALID"
assert secret not in response.text
@pytest.mark.asyncio
async def test_content_stream_is_stored_then_short_transaction_marks_it(tmp_path: Path) -> None:
repository = StubRepository()
storage = StubStorage()
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=_app(repository, storage, tmp_path)),
base_url="http://test",
) as client:
response = await client.put(
f"/api/v1/document-uploads/{UPLOAD_ID}/content",
headers={"Content-Type": "application/octet-stream", "x-request-id": TRACE_ID},
content=CONTENT,
)
assert response.status_code == 200
assert response.json()["status"] == "STORED"
assert storage.received == CONTENT
assert repository.mark_calls[0]["actual_sha256"] == CONTENT_SHA
assert repository.mark_calls[0]["actual_size"] == len(CONTENT)
assert "storage_key" not in response.text
@pytest.mark.asyncio
async def test_hash_failure_is_sanitized_and_does_not_mark_stored(tmp_path: Path) -> None:
repository = StubRepository()
storage = StubStorage(error=StorageErrorCode.HASH_MISMATCH)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=_app(repository, storage, tmp_path)),
base_url="http://test",
) as client:
response = await client.put(
f"/api/v1/document-uploads/{UPLOAD_ID}/content",
headers={"Content-Type": "application/octet-stream"},
content=CONTENT,
)
assert response.status_code == 422
assert response.json()["code"] == "UPLOAD_HASH_MISMATCH"
assert repository.mark_calls == []
assert CONTENT.decode() not in response.text
@pytest.mark.asyncio
async def test_complete_enqueues_parse_job_and_public_status_is_safe(tmp_path: Path) -> None:
repository = StubRepository(upload=_upload("STORED"))
app = _app(repository, StubStorage(), tmp_path)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://test"
) as client:
completed = await client.post(f"/api/v1/document-uploads/{UPLOAD_ID}/complete")
job = await client.get(f"/api/v1/document-jobs/{JOB_ID}")
documents = await client.get("/api/v1/documents")
detail = await client.get(f"/api/v1/documents/{DOCUMENT_ID}")
bundle = await client.get(f"/api/v1/documents/{DOCUMENT_ID}/review-bundle")
assert completed.status_code == 202
assert completed.json()["job"]["job_type"] == "PARSE_DOCUMENT"
assert completed.json()["job"]["status"] == "QUEUED"
assert job.status_code == 200
assert documents.json()["items"][0]["id"] == str(DOCUMENT_ID)
assert detail.json()["version_count"] == 0
assert bundle.json()["version"] is None
for response in (completed, job, documents, detail, bundle):
assert "lease_token" not in response.text
assert "lease_owner" not in response.text
assert "storage_key" not in response.text
assert "/data/uploads" not in response.text
def test_openapi_has_stable_operations_and_binary_content_contract(tmp_path: Path) -> None:
schema = _app(StubRepository(), StubStorage(), tmp_path).openapi()
assert schema["paths"]["/api/v1/document-uploads"]["post"]["operationId"] == (
"createDocumentUpload"
)
assert (
schema["paths"]["/api/v1/document-uploads/{upload_id}/content"]["put"]["operationId"]
== "storeDocumentUploadContent"
)
assert (
"application/octet-stream"
in schema["paths"]["/api/v1/document-uploads/{upload_id}/content"]["put"]["requestBody"][
"content"
]
)
assert (
schema["paths"]["/api/v1/document-uploads/{upload_id}/complete"]["post"]["operationId"]
== "completeDocumentUpload"
)
request_schema = schema["components"]["schemas"]["CreateDocumentUploadRequest"]
assert request_schema["additionalProperties"] is False
assert "access_scope_id" not in request_schema["properties"]