first commit
This commit is contained in:
201
tests/unit/test_exceptions_and_middleware.py
Normal file
201
tests/unit/test_exceptions_and_middleware.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user