"""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 from unittest.mock import MagicMock import pytest from aarecommon.errors.codes import AuthErrorCode from aarecommon.errors.exception_handler import ( AareAuthError, AareDBCommunicationError, AareUserError, AuthenticationException, AutomationError, CriticalTellException, LoopCenteringFailed, ManualMountException, MountingFailed, SampleException, SmargonCommunicationError, TellCommunicationError, UnmountingFailed, UserRightsException, WarningTellException, ) from fastapi import FastAPI, HTTPException from starlette.requests import Request from starlette.responses import JSONResponse from aare.daq.server_exception_handler import register_exception_handlers @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 aarecommon.errors.exception_handler import ( AareDBCommunicationError, LoopCenteringFailed, MountingFailed, SmargonCommunicationError, TellCommunicationError, ) 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_automation_error_logs_error_when_critical(app, mock_request, caplog): exc = MountingFailed("threshold breached", critical=True) handler = app.exception_handlers[AutomationError] caplog.clear() with caplog.at_level("DEBUG"): await handler(mock_request, exc) assert any(r.levelname == "ERROR" for r in caplog.records), caplog.records @pytest.mark.asyncio async def test_automation_error_logs_warning_when_not_critical(app, mock_request, caplog): exc = MountingFailed("benign") handler = app.exception_handlers[AutomationError] caplog.clear() with caplog.at_level("DEBUG"): await handler(mock_request, exc) assert any(r.levelname == "WARNING" for r in caplog.records), caplog.records assert not any(r.levelname == "ERROR" for r in caplog.records) @pytest.mark.asyncio async def test_user_error_logs_info(app, mock_request, caplog): exc = ManualMountException("user must intervene") handler = app.exception_handlers[AareUserError] caplog.clear() with caplog.at_level("DEBUG"): await handler(mock_request, exc) assert any(r.levelname == "INFO" for r in caplog.records), caplog.records @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)