from __future__ import annotations import uuid from dataclasses import dataclass, field from datetime import UTC, datetime from pathlib import Path import httpx import pytest from fastapi import FastAPI from fastapi.exceptions import RequestValidationError from app.api.v1.documents import get_document_review_repository, router from app.core.demo_identity import ACCESS_SCOPE_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.document_review import ( DocumentReviewConflictError, DocumentReviewError, DocumentReviewNotFoundError, DocumentReviewResult, DocumentReviewStateError, ) from app.persistence.documents import DocumentActor, SafeJob DOCUMENT_ID = uuid.UUID("10000000-0000-0000-0000-000000000001") VERSION_ID = uuid.UUID("20000000-0000-0000-0000-000000000002") JOB_ID = uuid.UUID("30000000-0000-0000-0000-000000000003") TRACE_ID = uuid.UUID("40000000-0000-0000-0000-000000000004") MANIFEST = "a" * 64 PROFILE = "b" * 64 NOW = datetime(2026, 7, 13, 8, 0, tzinfo=UTC) def _job() -> SafeJob: return SafeJob( id=JOB_ID, job_type="EMBED_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, ) def _approved() -> DocumentReviewResult: return DocumentReviewResult( document_id=DOCUMENT_ID, document_version_id=VERSION_ID, decision="APPROVE", review_state="CLOUD_APPROVED", review_revision=1, outbound_manifest_sha256=MANIFEST, embedding_profile_hash=PROFILE, job=_job(), ) @dataclass class StubReviewRepository: result: DocumentReviewResult = field(default_factory=_approved) error: type[DocumentReviewError] | None = None calls: list[dict[str, object]] = field(default_factory=list) def apply_decision(self, **kwargs: object) -> DocumentReviewResult: self.calls.append(kwargs) if self.error is not None: raise self.error return self.result def _app(repository: StubReviewRepository) -> 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_document_review_repository] = lambda: repository return app def _approval() -> dict[str, object]: return { "decision": "APPROVE", "reason_code": "SYNTHETIC_REVIEW_APPROVED", "expected_revision": 0, "outbound_manifest_sha256": MANIFEST, } @pytest.mark.asyncio async def test_approval_is_manifest_bound_and_scope_is_server_owned(tmp_path: Path) -> None: del tmp_path repository = StubReviewRepository() async with httpx.AsyncClient( transport=httpx.ASGITransport(app=_app(repository)), base_url="http://test", ) as client: response = await client.post( f"/api/v1/documents/{DOCUMENT_ID}/review-decisions", headers={"x-request-id": str(TRACE_ID)}, json=_approval(), ) assert response.status_code == 202 assert response.json() == { "document_id": str(DOCUMENT_ID), "document_version_id": str(VERSION_ID), "decision": "APPROVE", "review_state": "CLOUD_APPROVED", "review_revision": 1, "outbound_manifest_sha256": MANIFEST, "embedding_profile_hash": PROFILE, "job": { "id": str(JOB_ID), "job_type": "EMBED_DOCUMENT", "stage": "PENDING", "status": "QUEUED", "progress": 0, "attempt": 0, "max_attempts": 3, "last_error_code": None, "created_at": NOW.isoformat().replace("+00:00", "Z"), "updated_at": NOW.isoformat().replace("+00:00", "Z"), "finished_at": None, }, } call = repository.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 call["outbound_manifest_sha256"] == MANIFEST assert call["trace_id"] == TRACE_ID @pytest.mark.asyncio @pytest.mark.parametrize( "body", [ { "decision": "APPROVE", "reason_code": "SYNTHETIC_REVIEW_APPROVED", "expected_revision": 0, }, { "decision": "REJECT", "reason_code": "SYNTHETIC_REVIEW_APPROVED", "expected_revision": 0, }, { "decision": "REJECT", "reason_code": "RIGHTS_NOT_VERIFIED", "expected_revision": 0, "outbound_manifest_sha256": MANIFEST, }, ], ) async def test_invalid_decision_contract_is_rejected_without_repository_call( body: dict[str, object], ) -> None: repository = StubReviewRepository() async with httpx.AsyncClient( transport=httpx.ASGITransport(app=_app(repository)), base_url="http://test", ) as client: response = await client.post( f"/api/v1/documents/{DOCUMENT_ID}/review-decisions", json=body, ) assert response.status_code == 422 assert response.json()["code"] == "REQUEST_VALIDATION_FAILED" assert "input" not in response.text assert repository.calls == [] @pytest.mark.asyncio @pytest.mark.parametrize( ("error", "expected_status", "expected_code"), [ (DocumentReviewNotFoundError, 404, "DOCUMENT_RESOURCE_NOT_FOUND"), (DocumentReviewConflictError, 412, "REVIEW_REVISION_CONFLICT"), (DocumentReviewStateError, 409, "REVIEW_STATE_CONFLICT"), (DocumentReviewError, 503, "DOCUMENT_PERSISTENCE_UNAVAILABLE"), ], ) async def test_review_failures_map_to_safe_problem_details( error: type[DocumentReviewError], expected_status: int, expected_code: str, ) -> None: repository = StubReviewRepository(error=error) async with httpx.AsyncClient( transport=httpx.ASGITransport(app=_app(repository)), base_url="http://test", ) as client: response = await client.post( f"/api/v1/documents/{DOCUMENT_ID}/review-decisions", json=_approval(), ) assert response.status_code == expected_status assert response.json()["code"] == expected_code assert MANIFEST not in response.text