Files
AareDAQ/tests/unit/daq/test_server_exception_handler.py
T
2026-05-22 18:06:01 +02:00

308 lines
12 KiB
Python

"""Tests for the collapsed server exception handlers (Phase 2 of the
exception-handling redesign).
Per plan §2, server-side handling is now four handlers + HTTPException +
bare-Exception fallback. Each emits the unified response body from §3:
{"critical", "code", "exception_class", "message", "context"}
"""
from __future__ import annotations
import json
import pytest
from fastapi import FastAPI, HTTPException
from starlette.requests import Request
from starlette.responses import JSONResponse
from unittest.mock import MagicMock
from aare.daq.server_exception_handler import register_exception_handlers
from aare.common.error_codes import AareErrorCode, AuthErrorCode
from aare.common.exception_handler import (
AareException,
AutomationError,
AareUserError,
AareAuthError,
MountingFailed,
UnmountingFailed,
LoopCenteringFailed,
TellCommunicationError,
CriticalTellException,
WarningTellException,
SmargonCommunicationError,
AareDBCommunicationError,
AuthenticationException,
UserRightsException,
ManualMountException,
SampleException,
)
@pytest.fixture
def app():
app = FastAPI()
register_exception_handlers(app)
return app
@pytest.fixture
def mock_request():
return MagicMock(spec=Request)
# Helper: pull JSON body out of a Starlette JSONResponse
def _body(response: JSONResponse) -> dict:
return json.loads(response.body)
# ---------------------------------------------------------------------------
# AutomationError handler -- 503 if critical, 422 otherwise
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_automation_error_not_critical_returns_422(app, mock_request):
# MountingFailed has class default critical=False in Phase 1
exc = MountingFailed("mount failed")
handler = app.exception_handlers[AutomationError]
response = await handler(mock_request, exc)
assert response.status_code == 422
body = _body(response)
assert body["critical"] is False
assert body["code"] == "MOUNTING_FAILED"
assert body["exception_class"] == "MountingFailed"
assert body["message"] == "mount failed"
assert body["context"] == {}
@pytest.mark.asyncio
async def test_automation_error_critical_via_instance_flag_returns_503(app, mock_request):
# Instance-level override: critical=True
exc = MountingFailed("threshold breached", critical=True)
handler = app.exception_handlers[AutomationError]
response = await handler(mock_request, exc)
assert response.status_code == 503
body = _body(response)
assert body["critical"] is True
@pytest.mark.asyncio
async def test_automation_error_context_contains_endpoint_and_operation(app, mock_request):
exc = TellCommunicationError("timeout", endpoint="/state", operation="GET",
base_url="http://tell:8000")
handler = app.exception_handlers[AutomationError]
response = await handler(mock_request, exc)
body = _body(response)
assert body["code"] == "TELL_COMMUNICATION_ERROR"
assert body["context"]["endpoint"] == "/state"
assert body["context"]["operation"] == "GET"
assert body["context"]["base_url"] == "http://tell:8000"
@pytest.mark.asyncio
async def test_automation_error_excludes_critical_and_headers_from_context(app, mock_request):
# critical kwarg should not appear in context (it has its own field)
exc = MountingFailed("x", critical=True)
handler = app.exception_handlers[AutomationError]
response = await handler(mock_request, exc)
body = _body(response)
assert "critical" not in body["context"]
assert "headers" not in body["context"]
# ---------------------------------------------------------------------------
# AareUserError handler -- always 400
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_user_error_returns_400(app, mock_request):
exc = ManualMountException("user must intervene")
handler = app.exception_handlers[AareUserError]
response = await handler(mock_request, exc)
assert response.status_code == 400
body = _body(response)
assert body["critical"] is False
assert body["code"] == "MANUAL_MOUNT_EXCEPTION"
@pytest.mark.asyncio
async def test_sample_exception_returns_400_as_user_error(app, mock_request):
# SampleException parented under AareUserError (see Phase 1 note in plan)
exc = SampleException("Sample SAR0035 not in dewar")
handler = app.exception_handlers[AareUserError]
response = await handler(mock_request, exc)
assert response.status_code == 400
body = _body(response)
assert body["code"] == "SAMPLE_EXCEPTION"
# ---------------------------------------------------------------------------
# AareAuthError handler -- 401 / 403, code from instance.code
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_authentication_exception_returns_401_with_auth_code(app, mock_request):
exc = AuthenticationException("Bad token", code=AuthErrorCode.INVALID_TOKEN)
handler = app.exception_handlers[AareAuthError]
response = await handler(mock_request, exc)
assert response.status_code == 401
body = _body(response)
assert body["critical"] is False
# auth handler surfaces the instance code, not the class-derived one
assert body["code"] == "INVALID_TOKEN"
assert body["exception_class"] == "AuthenticationException"
@pytest.mark.asyncio
async def test_user_rights_exception_returns_403(app, mock_request):
exc = UserRightsException("Not a staff member", code=AuthErrorCode.NOT_STAFF)
handler = app.exception_handlers[AareAuthError]
response = await handler(mock_request, exc)
assert response.status_code == 403
body = _body(response)
assert body["code"] == "NOT_STAFF"
@pytest.mark.asyncio
async def test_authentication_exception_preserves_explicit_status(app, mock_request):
# AuthenticationException can be constructed with a custom status_code
exc = AuthenticationException("Forbidden auth path", status_code=403,
code=AuthErrorCode.FORBIDDEN)
handler = app.exception_handlers[AareAuthError]
response = await handler(mock_request, exc)
assert response.status_code == 403
# ---------------------------------------------------------------------------
# HTTPException handler -- pass-through status, new shape
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_http_exception_handler_string_detail(app, mock_request):
exc = HTTPException(status_code=418, detail="I'm a teapot")
handler = app.exception_handlers[HTTPException]
response = await handler(mock_request, exc)
assert response.status_code == 418
body = _body(response)
assert body["code"] == "HTTP_ERROR"
assert body["message"] == "I'm a teapot"
assert body["critical"] is False # 418 < 500
assert body["exception_class"] == "HTTPException"
@pytest.mark.asyncio
async def test_http_exception_handler_dict_detail(app, mock_request):
exc = HTTPException(status_code=400, detail={"code": "CUSTOM", "message": "Msg", "field": "foo"})
handler = app.exception_handlers[HTTPException]
response = await handler(mock_request, exc)
assert response.status_code == 400
body = _body(response)
assert body["code"] == "CUSTOM"
assert body["message"] == "Msg"
assert body["context"] == {"field": "foo"}
@pytest.mark.asyncio
async def test_http_exception_5xx_is_critical(app, mock_request):
exc = HTTPException(status_code=503, detail="downstream gone")
handler = app.exception_handlers[HTTPException]
response = await handler(mock_request, exc)
body = _body(response)
assert body["critical"] is True
# ---------------------------------------------------------------------------
# Bare Exception fallback -- 500, critical=True
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_unhandled_exception_is_critical_500(app, mock_request):
exc = ValueError("oops")
handler = app.exception_handlers[Exception]
response = await handler(mock_request, exc)
assert response.status_code == 500
body = _body(response)
assert body["critical"] is True
assert body["code"] == "INTERNAL_ERROR"
assert body["exception_class"] == "ValueError"
assert body["message"] == "oops"
@pytest.mark.asyncio
async def test_unhandled_exception_empty_message_falls_back_to_class_name(app, mock_request):
exc = ValueError()
handler = app.exception_handlers[Exception]
response = await handler(mock_request, exc)
body = _body(response)
assert body["message"] == "ValueError"
# ---------------------------------------------------------------------------
# Handler count -- the design contract is 4 + HTTPException + Exception
# ---------------------------------------------------------------------------
def test_only_four_root_handlers_plus_fallbacks(app):
"""Plan §2: collapse the ~25 per-class handlers down to 4 roots, plus the
HTTPException pass-through and the bare-Exception fallback.
FastAPI pre-registers handlers for RequestValidationError /
WebSocketRequestValidationError -- those are framework-level and not
counted toward our handler budget. We assert (a) our expected handlers
are present and (b) no per-class AareException handlers remain.
"""
from aare.common.exception_handler import (
MountingFailed, UnmountingFailed, TellCommunicationError,
LoopCenteringFailed, CriticalTellException, WarningTellException,
SmargonCommunicationError, AareDBCommunicationError,
)
registered = set(app.exception_handlers.keys())
expected = {AutomationError, AareUserError, AareAuthError,
HTTPException, Exception}
assert expected.issubset(registered), (
f"Missing required handlers: {expected - registered}"
)
# No leftover per-class handlers from the old design
forbidden = {MountingFailed, UnmountingFailed, TellCommunicationError,
LoopCenteringFailed, CriticalTellException,
WarningTellException, SmargonCommunicationError,
AareDBCommunicationError}
leftover = forbidden & registered
assert not leftover, f"Per-class handlers must be removed: {leftover}"
# ---------------------------------------------------------------------------
# Response body shape is uniform across handlers
# ---------------------------------------------------------------------------
REQUIRED_BODY_KEYS = {"critical", "code", "exception_class", "message", "context"}
@pytest.mark.asyncio
async def test_response_body_shape_uniform(app, mock_request):
cases: list[tuple[type, Exception]] = [
(AutomationError, MountingFailed("x")),
(AutomationError, LoopCenteringFailed("y")),
(AutomationError, SmargonCommunicationError("z", operation="GET", endpoint="/e")),
(AutomationError, AareDBCommunicationError("db", critical=True)),
(AareUserError, ManualMountException("m")),
(AareUserError, SampleException("s")),
(AareAuthError, AuthenticationException("a")),
(AareAuthError, UserRightsException("u")),
(Exception, RuntimeError("r")),
(HTTPException, HTTPException(status_code=404, detail="not found")),
]
for handler_key, exc in cases:
handler = app.exception_handlers[handler_key]
response = await handler(mock_request, exc)
body = _body(response)
assert REQUIRED_BODY_KEYS == set(body.keys()), (
f"Body keys mismatch for {type(exc).__name__}: got {set(body.keys())}"
)
assert isinstance(body["critical"], bool)
assert isinstance(body["code"], str) and body["code"]
assert isinstance(body["exception_class"], str) and body["exception_class"]
assert isinstance(body["message"], str) and body["message"]
assert isinstance(body["context"], dict)