202 lines
5.2 KiB
Python
202 lines
5.2 KiB
Python
"""Tests for the exception hierarchy and global exception handlers."""
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.exceptions import (
|
|
AppError,
|
|
AuthenticationError,
|
|
AuthorizationError,
|
|
DuplicateError,
|
|
NotFoundError,
|
|
ProviderError,
|
|
RateLimitError,
|
|
ValidationError,
|
|
)
|
|
from app.middleware import register_exception_handlers
|
|
from app.schemas.common import APIEnvelope
|
|
|
|
|
|
# ── Exception hierarchy tests ──
|
|
|
|
|
|
def test_app_error_defaults():
|
|
err = AppError()
|
|
assert err.status_code == 500
|
|
assert err.message == "Internal server error"
|
|
assert str(err) == "Internal server error"
|
|
|
|
|
|
def test_app_error_custom_message():
|
|
err = AppError("something broke")
|
|
assert err.message == "something broke"
|
|
assert str(err) == "something broke"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"cls,code,default_msg",
|
|
[
|
|
(ValidationError, 400, "Validation error"),
|
|
(NotFoundError, 404, "Resource not found"),
|
|
(DuplicateError, 409, "Resource already exists"),
|
|
(AuthenticationError, 401, "Authentication required"),
|
|
(AuthorizationError, 403, "Insufficient permissions"),
|
|
(ProviderError, 502, "Market data provider unavailable"),
|
|
(RateLimitError, 429, "Rate limited"),
|
|
],
|
|
)
|
|
def test_subclass_defaults(cls, code, default_msg):
|
|
err = cls()
|
|
assert err.status_code == code
|
|
assert err.message == default_msg
|
|
|
|
|
|
def test_subclass_custom_message():
|
|
err = NotFoundError("Ticker not found: AAPL")
|
|
assert err.status_code == 404
|
|
assert err.message == "Ticker not found: AAPL"
|
|
|
|
|
|
def test_all_subclasses_are_app_errors():
|
|
for cls in (
|
|
ValidationError,
|
|
NotFoundError,
|
|
DuplicateError,
|
|
AuthenticationError,
|
|
AuthorizationError,
|
|
ProviderError,
|
|
RateLimitError,
|
|
):
|
|
assert issubclass(cls, AppError)
|
|
|
|
|
|
# ── APIEnvelope schema tests ──
|
|
|
|
|
|
def test_envelope_success():
|
|
env = APIEnvelope(status="success", data={"id": 1})
|
|
assert env.status == "success"
|
|
assert env.data == {"id": 1}
|
|
assert env.error is None
|
|
|
|
|
|
def test_envelope_error():
|
|
env = APIEnvelope(status="error", error="bad request")
|
|
assert env.status == "error"
|
|
assert env.data is None
|
|
assert env.error == "bad request"
|
|
|
|
|
|
# ── Middleware integration tests ──
|
|
|
|
|
|
def _make_app() -> FastAPI:
|
|
"""Create a minimal FastAPI app with exception handlers and test routes."""
|
|
app = FastAPI()
|
|
register_exception_handlers(app)
|
|
|
|
@app.get("/raise-not-found")
|
|
async def _raise_not_found():
|
|
raise NotFoundError("Ticker not found: XYZ")
|
|
|
|
@app.get("/raise-validation")
|
|
async def _raise_validation():
|
|
raise ValidationError("high < low")
|
|
|
|
@app.get("/raise-duplicate")
|
|
async def _raise_duplicate():
|
|
raise DuplicateError("Ticker already exists: AAPL")
|
|
|
|
@app.get("/raise-auth")
|
|
async def _raise_auth():
|
|
raise AuthenticationError()
|
|
|
|
@app.get("/raise-authz")
|
|
async def _raise_authz():
|
|
raise AuthorizationError()
|
|
|
|
@app.get("/raise-provider")
|
|
async def _raise_provider():
|
|
raise ProviderError()
|
|
|
|
@app.get("/raise-rate-limit")
|
|
async def _raise_rate_limit():
|
|
raise RateLimitError("Rate limited. Ingested 42 records. Resume available.")
|
|
|
|
@app.get("/raise-unhandled")
|
|
async def _raise_unhandled():
|
|
raise RuntimeError("unexpected")
|
|
|
|
return app
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
return TestClient(_make_app())
|
|
|
|
|
|
def test_middleware_not_found(client):
|
|
resp = client.get("/raise-not-found")
|
|
assert resp.status_code == 404
|
|
body = resp.json()
|
|
assert body["status"] == "error"
|
|
assert body["data"] is None
|
|
assert body["error"] == "Ticker not found: XYZ"
|
|
|
|
|
|
def test_middleware_validation(client):
|
|
resp = client.get("/raise-validation")
|
|
assert resp.status_code == 400
|
|
body = resp.json()
|
|
assert body["status"] == "error"
|
|
assert body["error"] == "high < low"
|
|
|
|
|
|
def test_middleware_duplicate(client):
|
|
resp = client.get("/raise-duplicate")
|
|
assert resp.status_code == 409
|
|
body = resp.json()
|
|
assert body["status"] == "error"
|
|
assert "already exists" in body["error"]
|
|
|
|
|
|
def test_middleware_authentication(client):
|
|
resp = client.get("/raise-auth")
|
|
assert resp.status_code == 401
|
|
body = resp.json()
|
|
assert body["status"] == "error"
|
|
|
|
|
|
def test_middleware_authorization(client):
|
|
resp = client.get("/raise-authz")
|
|
assert resp.status_code == 403
|
|
body = resp.json()
|
|
assert body["status"] == "error"
|
|
|
|
|
|
def test_middleware_provider_error(client):
|
|
resp = client.get("/raise-provider")
|
|
assert resp.status_code == 502
|
|
body = resp.json()
|
|
assert body["status"] == "error"
|
|
|
|
|
|
def test_middleware_rate_limit(client):
|
|
resp = client.get("/raise-rate-limit")
|
|
assert resp.status_code == 429
|
|
body = resp.json()
|
|
assert body["status"] == "error"
|
|
assert "42 records" in body["error"]
|
|
|
|
|
|
def test_middleware_unhandled_exception():
|
|
app = _make_app()
|
|
with TestClient(app, raise_server_exceptions=False) as c:
|
|
resp = c.get("/raise-unhandled")
|
|
assert resp.status_code == 500
|
|
body = resp.json()
|
|
assert body["status"] == "error"
|
|
assert body["data"] is None
|
|
assert body["error"] == "Internal server error"
|