"""Tests for standardized error response envelope format.""" from __future__ import annotations import pytest from fastapi import FastAPI, HTTPException from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from fastapi.testclient import TestClient from pydantic import BaseModel, Field from app.api_utils import envelope pytestmark = pytest.mark.unit def _build_test_app() -> FastAPI: """Build a minimal FastAPI app with the standard exception handlers.""" app = FastAPI() @app.exception_handler(HTTPException) async def http_exception_handler(request, exc): # type: ignore[no-untyped-def] return JSONResponse( status_code=exc.status_code, content=envelope(None, success=False, error=exc.detail), ) @app.exception_handler(RequestValidationError) async def validation_exception_handler(request, exc): # type: ignore[no-untyped-def] return JSONResponse( status_code=422, content=envelope(None, success=False, error=str(exc)), ) @app.exception_handler(Exception) async def general_exception_handler(request, exc): # type: ignore[no-untyped-def] return JSONResponse( status_code=500, content=envelope(None, success=False, error="Internal server error"), ) class ItemRequest(BaseModel): name: str = Field(..., min_length=1) count: int = Field(..., gt=0) @app.get("/items/{item_id}") def get_item(item_id: int) -> dict: if item_id == 0: raise HTTPException(status_code=400, detail="Invalid item ID") if item_id == 999: raise HTTPException(status_code=404, detail="Item not found") if item_id == 401: raise HTTPException(status_code=401, detail="Not authenticated") return envelope({"id": item_id, "name": "test"}) @app.post("/items") def create_item(item: ItemRequest) -> dict: return envelope({"id": 1, "name": item.name}) @app.get("/crash") def crash() -> dict: msg = "unexpected failure" raise RuntimeError(msg) return app class TestHttpExceptionEnvelope: """HTTPException responses use the standard envelope format.""" def test_400_returns_envelope(self) -> None: app = _build_test_app() with TestClient(app, raise_server_exceptions=False) as client: resp = client.get("/items/0") assert resp.status_code == 400 body = resp.json() assert body["success"] is False assert body["data"] is None assert body["error"] == "Invalid item ID" def test_404_returns_envelope(self) -> None: app = _build_test_app() with TestClient(app, raise_server_exceptions=False) as client: resp = client.get("/items/999") assert resp.status_code == 404 body = resp.json() assert body["success"] is False assert body["data"] is None assert body["error"] == "Item not found" def test_401_returns_envelope(self) -> None: app = _build_test_app() with TestClient(app, raise_server_exceptions=False) as client: resp = client.get("/items/401") assert resp.status_code == 401 body = resp.json() assert body["success"] is False assert body["data"] is None assert body["error"] == "Not authenticated" class TestValidationErrorEnvelope: """Validation errors return 422 with envelope format.""" def test_validation_error_returns_envelope(self) -> None: app = _build_test_app() with TestClient(app, raise_server_exceptions=False) as client: resp = client.post("/items", json={"name": "", "count": -1}) assert resp.status_code == 422 body = resp.json() assert body["success"] is False assert body["data"] is None assert isinstance(body["error"], str) assert len(body["error"]) > 0 class TestGeneralExceptionEnvelope: """Unhandled exceptions return 500 with safe envelope.""" def test_unhandled_exception_returns_500_envelope(self) -> None: app = _build_test_app() with TestClient(app, raise_server_exceptions=False) as client: resp = client.get("/crash") assert resp.status_code == 500 body = resp.json() assert body["success"] is False assert body["data"] is None assert body["error"] == "Internal server error" class TestSuccessResponseUnchanged: """Success responses still work normally.""" def test_success_returns_envelope(self) -> None: app = _build_test_app() with TestClient(app) as client: resp = client.get("/items/42") assert resp.status_code == 200 body = resp.json() assert body["success"] is True assert body["data"]["id"] == 42 assert body["error"] is None