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