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