6c9c7fe7f3
CI / integration tests (3.13) (push) Failing after 1s
Commit lint / pull request title (push) Has been skipped
Docs / links (push) Failing after 1s
CI / unit tests (3.13) (push) Failing after 1s
CI / lint (push) Failing after 1s
CI / integration tests (push) Failing after 1s
CI / package build (push) Failing after 1s
Commit lint / commit messages (push) Failing after 1s
CI / unit tests (push) Failing after 1s
289 lines
10 KiB
Python
289 lines
10 KiB
Python
"""Unit tests for per-type exception handlers registered via register_handlers().
|
|
|
|
Tests use a minimal FastAPI app with synthetic routes that raise specific
|
|
exceptions. The full handler suite is wired in via ``register_handlers()``.
|
|
|
|
White-box surfaces: none — assertions are purely on HTTP response shape.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
from everos.core.errors import (
|
|
DocumentNotFoundError,
|
|
DuplicateDocumentError,
|
|
EmbeddingServiceError,
|
|
ExtractionEmptyError,
|
|
FilterError,
|
|
LLMServiceError,
|
|
MultimodalNotEnabledError,
|
|
PathTraversalError,
|
|
StorageError,
|
|
UnsupportedModalityError,
|
|
)
|
|
from everos.entrypoints.api.exception_handlers import register_handlers
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixture: minimal app with one route per exception type
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_app() -> FastAPI:
|
|
"""Build a minimal FastAPI app with synthetic routes that raise each error."""
|
|
app = FastAPI()
|
|
register_handlers(app)
|
|
|
|
@app.get("/raise/not-found")
|
|
async def _not_found() -> None:
|
|
raise DocumentNotFoundError("doc_abc123")
|
|
|
|
@app.get("/raise/conflict")
|
|
async def _conflict() -> None:
|
|
raise DuplicateDocumentError("doc_abc123 already exists")
|
|
|
|
@app.get("/raise/extraction-empty")
|
|
async def _extraction_empty() -> None:
|
|
raise ExtractionEmptyError("extraction produced no output")
|
|
|
|
@app.get("/raise/filter-error")
|
|
async def _filter_error() -> None:
|
|
raise FilterError("unknown field: foo")
|
|
|
|
@app.get("/raise/path-traversal")
|
|
async def _path_traversal() -> None:
|
|
raise PathTraversalError("../etc/passwd")
|
|
|
|
@app.get("/raise/unsupported-modality")
|
|
async def _unsupported_modality() -> None:
|
|
raise UnsupportedModalityError("video not supported")
|
|
|
|
@app.get("/raise/multimodal-not-enabled")
|
|
async def _multimodal_not_enabled() -> None:
|
|
raise MultimodalNotEnabledError("multimodal extra not installed")
|
|
|
|
@app.get("/raise/storage")
|
|
async def _storage() -> None:
|
|
raise StorageError("disk full")
|
|
|
|
@app.get("/raise/embedding")
|
|
async def _embedding() -> None:
|
|
raise EmbeddingServiceError("embedding provider timeout")
|
|
|
|
@app.get("/raise/llm")
|
|
async def _llm() -> None:
|
|
raise LLMServiceError("LLM rate limit exceeded")
|
|
|
|
@app.get("/raise/runtime")
|
|
async def _runtime() -> None:
|
|
raise RuntimeError("oops")
|
|
|
|
@app.post("/raise/request-validation")
|
|
async def _request_validation(body: dict) -> None:
|
|
# Starlette raises RequestValidationError automatically when
|
|
# a JSON body is expected but not provided.
|
|
pass # pragma: no cover
|
|
|
|
return app
|
|
|
|
|
|
@pytest.fixture
|
|
async def client() -> AsyncClient:
|
|
"""Async test client against the minimal exception-handler app.
|
|
|
|
``raise_app_exceptions=False`` lets ServerErrorMiddleware send the 500
|
|
response before re-raising, so the test sees the JSON body rather than
|
|
the raw exception. In production an ASGI server (uvicorn) absorbs the
|
|
re-raise for its own logging; the client already received the response.
|
|
"""
|
|
app = _make_app()
|
|
transport = ASGITransport(app=app, raise_app_exceptions=False)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
|
yield c
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _assert_envelope(data: dict, *, path: str) -> None:
|
|
"""Assert the canonical envelope shape is present."""
|
|
assert "request_id" in data, "envelope must have request_id"
|
|
assert len(data["request_id"]) == 32, "request_id must be 32 hex chars"
|
|
error = data.get("error", {})
|
|
assert "code" in error
|
|
assert "message" in error
|
|
assert "timestamp" in error
|
|
assert "path" in error
|
|
assert error["path"] == path
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Status code + error.code tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_not_found_returns_404(client: AsyncClient) -> None:
|
|
"""DocumentNotFoundError → HTTP 404, code=NOT_FOUND."""
|
|
resp = await client.get("/raise/not-found")
|
|
assert resp.status_code == 404
|
|
data = resp.json()
|
|
assert data["error"]["code"] == "NOT_FOUND"
|
|
_assert_envelope(data, path="/raise/not-found")
|
|
|
|
|
|
async def test_conflict_returns_409(client: AsyncClient) -> None:
|
|
"""DuplicateDocumentError → HTTP 409, code=CONFLICT."""
|
|
resp = await client.get("/raise/conflict")
|
|
assert resp.status_code == 409
|
|
data = resp.json()
|
|
assert data["error"]["code"] == "CONFLICT"
|
|
_assert_envelope(data, path="/raise/conflict")
|
|
|
|
|
|
async def test_extraction_empty_returns_422(client: AsyncClient) -> None:
|
|
"""ExtractionEmptyError → HTTP 422, code=EXTRACTION_EMPTY."""
|
|
resp = await client.get("/raise/extraction-empty")
|
|
assert resp.status_code == 422
|
|
data = resp.json()
|
|
assert data["error"]["code"] == "EXTRACTION_EMPTY"
|
|
_assert_envelope(data, path="/raise/extraction-empty")
|
|
|
|
|
|
async def test_filter_error_returns_422(client: AsyncClient) -> None:
|
|
"""FilterError (subclass of InvalidInputError) → HTTP 422."""
|
|
resp = await client.get("/raise/filter-error")
|
|
assert resp.status_code == 422
|
|
data = resp.json()
|
|
assert data["error"]["code"] == "INVALID_INPUT"
|
|
_assert_envelope(data, path="/raise/filter-error")
|
|
|
|
|
|
async def test_path_traversal_returns_400(client: AsyncClient) -> None:
|
|
"""PathTraversalError → HTTP 400, code=BAD_REQUEST."""
|
|
resp = await client.get("/raise/path-traversal")
|
|
assert resp.status_code == 400
|
|
data = resp.json()
|
|
assert data["error"]["code"] == "BAD_REQUEST"
|
|
_assert_envelope(data, path="/raise/path-traversal")
|
|
|
|
|
|
async def test_unsupported_modality_returns_415(client: AsyncClient) -> None:
|
|
"""UnsupportedModalityError → HTTP 415, code=UNSUPPORTED_MEDIA_TYPE."""
|
|
resp = await client.get("/raise/unsupported-modality")
|
|
assert resp.status_code == 415
|
|
data = resp.json()
|
|
assert data["error"]["code"] == "UNSUPPORTED_FORMAT"
|
|
_assert_envelope(data, path="/raise/unsupported-modality")
|
|
|
|
|
|
async def test_multimodal_not_enabled_returns_503(client: AsyncClient) -> None:
|
|
"""MultimodalNotEnabledError (CapabilityError) → HTTP 503."""
|
|
resp = await client.get("/raise/multimodal-not-enabled")
|
|
assert resp.status_code == 503
|
|
data = resp.json()
|
|
assert data["error"]["code"] == "CAPABILITY_UNAVAILABLE"
|
|
_assert_envelope(data, path="/raise/multimodal-not-enabled")
|
|
|
|
|
|
async def test_storage_error_returns_503(client: AsyncClient) -> None:
|
|
"""StorageError → HTTP 503, code=SERVICE_UNAVAILABLE."""
|
|
resp = await client.get("/raise/storage")
|
|
assert resp.status_code == 503
|
|
data = resp.json()
|
|
assert data["error"]["code"] == "EXTERNAL_SERVICE_UNAVAILABLE"
|
|
_assert_envelope(data, path="/raise/storage")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# MRO dispatch tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_embedding_service_error_routes_to_503(client: AsyncClient) -> None:
|
|
"""EmbeddingServiceError (InfrastructureError subclass) → 503 via MRO."""
|
|
resp = await client.get("/raise/embedding")
|
|
assert resp.status_code == 503
|
|
data = resp.json()
|
|
assert data["error"]["code"] == "EXTERNAL_SERVICE_UNAVAILABLE"
|
|
_assert_envelope(data, path="/raise/embedding")
|
|
|
|
|
|
async def test_llm_service_error_routes_to_503(client: AsyncClient) -> None:
|
|
"""LLMServiceError (InfrastructureError subclass) → 503 via MRO."""
|
|
resp = await client.get("/raise/llm")
|
|
assert resp.status_code == 503
|
|
data = resp.json()
|
|
assert data["error"]["code"] == "EXTERNAL_SERVICE_UNAVAILABLE"
|
|
_assert_envelope(data, path="/raise/llm")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Unexpected exception test
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_unexpected_exception_returns_500(client: AsyncClient) -> None:
|
|
"""RuntimeError → HTTP 500, INTERNAL_ERROR, generic message (no leak)."""
|
|
resp = await client.get("/raise/runtime")
|
|
assert resp.status_code == 500
|
|
data = resp.json()
|
|
assert data["error"]["code"] == "INTERNAL_ERROR"
|
|
assert data["error"]["message"] == "Internal server error"
|
|
_assert_envelope(data, path="/raise/runtime")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# RequestValidationError test
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_request_validation_error_returns_422(client: AsyncClient) -> None:
|
|
"""RequestValidationError (FastAPI body parse failure) → 422 VALIDATION_ERROR."""
|
|
# POST with invalid JSON triggers RequestValidationError automatically.
|
|
resp = await client.post(
|
|
"/raise/request-validation",
|
|
content=b"not-json",
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
assert resp.status_code == 422
|
|
data = resp.json()
|
|
assert data["error"]["code"] == "INVALID_INPUT"
|
|
_assert_envelope(data, path="/raise/request-validation")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Envelope shape tests (request_id, timestamp, path)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_envelope_has_request_id_32_hex(client: AsyncClient) -> None:
|
|
"""request_id in envelope must be exactly 32 lowercase hex characters."""
|
|
resp = await client.get("/raise/not-found")
|
|
data = resp.json()
|
|
rid = data["request_id"]
|
|
assert len(rid) == 32
|
|
assert rid == rid.lower()
|
|
assert all(c in "0123456789abcdef" for c in rid)
|
|
|
|
|
|
async def test_envelope_has_iso_timestamp(client: AsyncClient) -> None:
|
|
"""error.timestamp must be a non-empty ISO 8601 string."""
|
|
resp = await client.get("/raise/storage")
|
|
data = resp.json()
|
|
ts = data["error"]["timestamp"]
|
|
assert isinstance(ts, str)
|
|
assert len(ts) > 0
|
|
# ISO 8601 basic sanity: contains 'T' separator
|
|
assert "T" in ts
|
|
|
|
|
|
async def test_envelope_path_matches_request(client: AsyncClient) -> None:
|
|
"""error.path must match the actual request path."""
|
|
resp = await client.get("/raise/conflict")
|
|
data = resp.json()
|
|
assert data["error"]["path"] == "/raise/conflict"
|