from datetime import datetime, timezone from unittest.mock import Mock, patch from arcade_core.errors import UpstreamError, UpstreamRateLimitError from arcade_tdk.providers.microsoft.error_adapter import MicrosoftGraphErrorAdapter class TestMicrosoftGraphErrorAdapter: """Test the Microsoft Graph error adapter functionality.""" def setup_method(self): self.adapter = MicrosoftGraphErrorAdapter() def _create_mock_api_error( self, status_code=500, message=None, code=None, inner_error=None, url=None, headers=None ): """ Create a mock APIError following Microsoft Graph error structure: { "error": { "code": "string", "message": "string", "innerError": { "code": "string", "request-id": "string", "date": "string" } } } """ mock_error = Mock() mock_error.__class__.__name__ = "APIError" # Mock response mock_response = Mock() mock_response.status_code = status_code if url: mock_response.url = url if headers: mock_response.headers = headers else: mock_response.headers = {} mock_error.response = mock_response # Mock error details (always present in Microsoft Graph errors) mock_error_details = Mock() mock_error_details.message = message or "Unknown error" mock_error_details.code = code or "UnknownError" mock_error_details.inner_error = inner_error # Can be None mock_error.error = mock_error_details return mock_error def test_adapter_slug(self): """Test that the adapter has the correct slug.""" assert MicrosoftGraphErrorAdapter.slug == "_microsoft_graph" def test_sanitize_uri_removes_query_params(self): """Test URI sanitization removes query parameters.""" uri = "https://graph.microsoft.com/v1.0/me/messages?$select=id,subject&$top=10" result = self.adapter._sanitize_uri(uri) assert result == "https://graph.microsoft.com/v1.0/me/messages" def test_sanitize_uri_removes_fragments(self): """Test URI sanitization removes fragments.""" uri = "https://graph.microsoft.com/v1.0/users/me#profile" result = self.adapter._sanitize_uri(uri) assert result == "https://graph.microsoft.com/v1.0/users/me" def test_sanitize_uri_handles_trailing_slashes(self): """Test URI sanitization handles trailing slashes.""" uri = "https://graph.microsoft.com///v1.0/me/calendars///" result = self.adapter._sanitize_uri(uri) assert result == "https://graph.microsoft.com/v1.0/me/calendars" def test_parse_retry_after_with_seconds(self): """Test parsing retry-after header with seconds value.""" mock_error = self._create_mock_api_error(headers={"Retry-After": "120"}) result = self.adapter._get_retry_after_milliseconds(mock_error) assert result == 120_000 def test_parse_retry_after_with_lowercase_header(self): """Test parsing retry-after header with lowercase key.""" mock_error = self._create_mock_api_error(headers={"retry-after": "60"}) result = self.adapter._get_retry_after_milliseconds(mock_error) assert result == 60_000 def test_parse_retry_after_with_date_format(self): """Test parsing retry-after header with absolute date format.""" future_date = "Mon, 01 Jan 2029 12:00:00 GMT" mock_error = self._create_mock_api_error(headers={"Retry-After": future_date}) with patch("arcade_tdk.providers.microsoft.error_adapter.datetime") as mock_datetime: parsed_date = datetime(2029, 1, 1, 12, 0, 0, tzinfo=timezone.utc) mock_datetime.strptime.return_value = parsed_date # Mock datetime.now() to return a time before the parsed date current_time = datetime(2029, 1, 1, 11, 58, 0, tzinfo=timezone.utc) mock_datetime.now.return_value = current_time mock_datetime.timezone = timezone result = self.adapter._get_retry_after_milliseconds(mock_error) assert result == 120_000 # 2 minute diff def test_parse_retry_after_no_headers(self): """Test parsing retry-after when no headers are present.""" mock_error = self._create_mock_api_error(headers={}) result = self.adapter._get_retry_after_milliseconds(mock_error) assert result == 1_000 def test_parse_retry_after_no_response_attribute(self): """Test parsing retry-after when error has no response attribute.""" mock_error = Mock() mock_error.__class__.__name__ = "APIError" # No response attribute result = self.adapter._get_retry_after_milliseconds(mock_error) assert result == 1_000 # defaults to 1 second def test_parse_retry_after_invalid_date(self): """Test parsing retry-after with invalid date format falls back to default.""" mock_error = self._create_mock_api_error(headers={"Retry-After": "invalid-date"}) result = self.adapter._get_retry_after_milliseconds(mock_error) assert result == 1_000 def test_extract_error_details_standard_structure(self): """Test extracting error details from standard Microsoft Graph error structure.""" mock_error = self._create_mock_api_error( message="The request is invalid", code="invalidRequest" ) user_message, developer_message = self.adapter._extract_error_details(mock_error) assert user_message == "Upstream Microsoft Graph API error: The request is invalid" assert developer_message == "Microsoft Graph error code: invalidRequest" def test_extract_error_details_with_complete_inner_error(self): """Test extracting error details with complete inner error structure.""" # Create mock inner error with all Microsoft Graph fields mock_inner_error = Mock() mock_inner_error.code = "invalidSyntax" setattr(mock_inner_error, "request-id", "12345-67890") mock_inner_error.date = "2025-09-22T16:08:56Z" # Explicitly set client_request_id to None to avoid Mock object mock_inner_error.client_request_id = None mock_error = self._create_mock_api_error( message="The request is invalid", code="invalidRequest", inner_error=mock_inner_error, ) user_message, developer_message = self.adapter._extract_error_details(mock_error) assert user_message == "Upstream Microsoft Graph API error: The request is invalid" assert "Microsoft Graph error code: invalidRequest" in developer_message assert ( "Inner error: code: invalidSyntax, request-id: 12345-67890, date: 2025-09-22T16:08:56Z" in developer_message ) def test_extract_error_details_with_partial_inner_error(self): """Test extracting error details with partial inner error (only some fields).""" # Create mock inner error with only code and request-id mock_inner_error = Mock() mock_inner_error.code = "tokenExpired" setattr(mock_inner_error, "request-id", "67890-12345") mock_inner_error.date = None mock_error = self._create_mock_api_error( message="Access is denied", code="unauthorized", inner_error=mock_inner_error, ) user_message, developer_message = self.adapter._extract_error_details(mock_error) assert user_message == "Upstream Microsoft Graph API error: Access is denied" assert "Microsoft Graph error code: unauthorized" in developer_message assert "Inner error: code: tokenExpired, request-id: 67890-12345" in developer_message assert "date:" not in developer_message def test_extract_error_details_without_inner_error(self): """Test extracting error details when innerError field is missing.""" mock_error = self._create_mock_api_error( message="The resource could not be found", code="notFound", inner_error=None, ) user_message, developer_message = self.adapter._extract_error_details(mock_error) assert user_message == "Upstream Microsoft Graph API error: The resource could not be found" assert developer_message == "Microsoft Graph error code: notFound" assert "Inner error:" not in developer_message def test_extract_error_details_with_empty_inner_error_fields(self): """Test extracting error details when inner error has empty/None fields.""" # Create mock inner error with empty fields mock_inner_error = Mock() mock_inner_error.code = None setattr(mock_inner_error, "request-id", None) mock_inner_error.date = None # Explicitly set all other fields to None mock_inner_error.request_id = None mock_inner_error.client_request_id = None mock_error = self._create_mock_api_error( message="Service temporarily unavailable", code="serviceUnavailable", inner_error=mock_inner_error, ) user_message, developer_message = self.adapter._extract_error_details(mock_error) assert user_message == "Upstream Microsoft Graph API error: Service temporarily unavailable" assert developer_message == "Microsoft Graph error code: serviceUnavailable" assert "Inner error:" not in developer_message def test_extract_error_details_exact_microsoft_graph_structure(self): """Test with exact Microsoft Graph API error structure from documentation.""" # Create mock inner error matching exact structure from docs mock_inner_error = Mock() mock_inner_error.code = "invalidRange" setattr(mock_inner_error, "request-id", "request-id") mock_inner_error.date = "date-time" # Explicitly set client_request_id to None to avoid Mock object mock_inner_error.client_request_id = None mock_error = self._create_mock_api_error( message="Uploaded fragment overlaps with existing data.", code="badRequest", inner_error=mock_inner_error, ) user_message, developer_message = self.adapter._extract_error_details(mock_error) assert ( user_message == "Upstream Microsoft Graph API error: Uploaded fragment overlaps with existing data." ) assert "Microsoft Graph error code: badRequest" in developer_message assert ( "Inner error: code: invalidRange, request-id: request-id, date: date-time" in developer_message ) def test_map_api_error_basic(self): """Test mapping basic API error with standard Microsoft Graph structure.""" mock_error = self._create_mock_api_error( status_code=404, message="The resource could not be found", code="itemNotFound", url="https://graph.microsoft.com/v1.0/me/messages/missing", ) result = self.adapter._map_api_error(mock_error) assert isinstance(result, UpstreamError) assert not isinstance(result, UpstreamRateLimitError) assert result.status_code == 404 assert ( result.message == "Upstream Microsoft Graph API error: The resource could not be found" ) assert result.developer_message == "Microsoft Graph error code: itemNotFound" assert result.extra["service"] == "_microsoft_graph" assert result.extra["endpoint"] == "https://graph.microsoft.com/v1.0/me/messages/missing" assert result.extra["error_code"] == "itemNotFound" def test_map_api_error_rate_limit_429(self): """Test mapping 429 rate limit error with Microsoft Graph structure.""" mock_error = self._create_mock_api_error( status_code=429, message="Rate limit has been exceeded.", code="tooManyRequests", url="https://graph.microsoft.com/v1.0/me/messages", headers={"Retry-After": "30"}, ) result = self.adapter._map_api_error(mock_error) assert isinstance(result, UpstreamRateLimitError) assert result.retry_after_ms == 30_000 assert result.message == "Upstream Microsoft Graph API error: Rate limit has been exceeded." assert result.developer_message == "Microsoft Graph error code: tooManyRequests" assert result.extra["service"] == "_microsoft_graph" assert result.extra["endpoint"] == "https://graph.microsoft.com/v1.0/me/messages" assert result.extra["error_code"] == "tooManyRequests" def test_map_api_error_rate_limit_503_with_too_many_requests(self): """Test mapping 503 error with TooManyRequests code.""" mock_error = self._create_mock_api_error( status_code=503, message="Service temporarily unavailable due to high load.", code="TooManyRequests", headers={"Retry-After": "60"}, ) result = self.adapter._map_api_error(mock_error) assert isinstance(result, UpstreamRateLimitError) assert result.retry_after_ms == 60_000 assert ( result.message == "Upstream Microsoft Graph API error: Service temporarily unavailable due to high load." ) def test_map_api_error_rate_limit_503_with_service_unavailable(self): """Test mapping 503 error with ServiceUnavailable code.""" mock_error = self._create_mock_api_error( status_code=503, message="Service unavailable", code="ServiceUnavailable", headers={"Retry-After": "120"}, ) result = self.adapter._map_api_error(mock_error) assert isinstance(result, UpstreamRateLimitError) assert result.retry_after_ms == 120_000 def test_map_api_error_503_without_rate_limit_code(self): """Test mapping 503 error without rate limit specific code.""" mock_error = self._create_mock_api_error( status_code=503, message="Service unavailable", code="InternalServerError" ) result = self.adapter._map_api_error(mock_error) assert isinstance(result, UpstreamError) assert not isinstance(result, UpstreamRateLimitError) assert result.status_code == 503 def test_map_api_error_default_status_code(self): """Test mapping API error with default status code.""" mock_error = self._create_mock_api_error(message="Unknown error", code="UnknownError") # Set response to None to test default status code mock_error.response = None result = self.adapter._map_api_error(mock_error) assert isinstance(result, UpstreamError) assert result.status_code == 500 # Default def test_map_api_error_missing_attributes(self): """Test mapping API error without url and error code attributes.""" mock_error = self._create_mock_api_error( status_code=400, message="Bad request", code="BadRequest", # Code is always present in Microsoft Graph errors # No url ) # Remove URL to test missing endpoint mock_error.response.url = None result = self.adapter._map_api_error(mock_error) assert isinstance(result, UpstreamError) assert result.status_code == 400 assert result.extra["service"] == "_microsoft_graph" assert "endpoint" not in result.extra assert result.extra["error_code"] == "BadRequest" def test_handle_api_errors_with_api_error(self): """Test handling APIError exceptions.""" # Create mock APIError class class MockAPIError: pass mock_msgraph = Mock() mock_msgraph.APIError = MockAPIError mock_error = MockAPIError() # Add the expected attributes mock_error.response = Mock() mock_error.response.status_code = 401 mock_error.response.url = "https://graph.microsoft.com/v1.0/me" mock_error.response.headers = {} mock_error.error = Mock() mock_error.error.message = "Unauthorized" mock_error.error.code = "Unauthorized" mock_error.error.inner_error = None result = self.adapter._handle_api_errors(mock_error, mock_msgraph) assert isinstance(result, UpstreamError) assert result.status_code == 401 assert result.message == "Upstream Microsoft Graph API error: Unauthorized" def test_handle_api_errors_non_api_error(self): """Test handling non-APIError exceptions returns None.""" # Create mock APIError class class MockAPIError: pass mock_msgraph = Mock() mock_msgraph.APIError = MockAPIError mock_error = ValueError("Not an API error") result = self.adapter._handle_api_errors(mock_error, mock_msgraph) assert result is None def test_handle_api_errors_wrong_class_name(self): """Test handling exception with wrong class name returns None.""" # Create mock APIError class class MockAPIError: pass mock_msgraph = Mock() mock_msgraph.APIError = MockAPIError mock_error = Mock() mock_error.__class__.__name__ = "SomeOtherError" result = self.adapter._handle_api_errors(mock_error, mock_msgraph) assert result is None def test_from_exception_kiota_not_installed(self, caplog): """Test handling when kiota-abstractions is not installed.""" with ( patch("arcade_tdk.providers.microsoft.error_adapter.logger") as mock_logger, patch.dict("sys.modules", {"kiota_abstractions.api_error": None}), patch( "builtins.__import__", side_effect=ImportError("No module named 'kiota_abstractions'"), ), ): mock_exc = Exception("test exception") result = self.adapter.from_exception(mock_exc) assert result is None mock_logger.info.assert_called_once() warning_message = mock_logger.info.call_args[0][0] assert "'kiota-abstractions' is not installed" in warning_message assert "_microsoft_graph" in warning_message def test_from_exception_api_error_handling(self): """Test full from_exception flow with API error.""" # Create mock api_error module with APIError class class MockAPIError: def __init__(self): # Initialize with the same structure as _create_mock_api_error self.response = Mock() self.response.status_code = 403 self.response.url = "https://graph.microsoft.com/v1.0/me/messages" self.response.headers = {} self.error = Mock() self.error.message = "Forbidden" self.error.code = "Forbidden" self.error.inner_error = None mock_api_error_module = Mock() mock_api_error_module.APIError = MockAPIError # Create parent module mock mock_kiota_module = Mock() mock_kiota_module.api_error = mock_api_error_module # Create the mock error as an actual instance of MockAPIError mock_error = MockAPIError() with patch.dict( "sys.modules", { "kiota_abstractions": mock_kiota_module, "kiota_abstractions.api_error": mock_api_error_module, }, ): result = self.adapter.from_exception(mock_error) assert isinstance(result, UpstreamError) assert result.status_code == 403 assert result.message == "Upstream Microsoft Graph API error: Forbidden" def test_from_exception_fallback_for_unhandled_msgraph_error(self): """Test fallback handling for unhandled Microsoft Graph errors.""" # Create an unhandled Microsoft Graph error class MockUnhandledError(Exception): pass mock_error = MockUnhandledError("Some unhandled Microsoft Graph error") mock_error.__module__ = "msgraph.generated.models" # Create mock APIError class class MockAPIError: pass # Create mock msgraph module mock_msgraph = Mock() mock_msgraph.APIError = MockAPIError # Create parent module mock mock_kiota_module = Mock() mock_kiota_module.api_error = mock_msgraph with patch.dict( "sys.modules", { "msgraph": Mock(), "kiota_abstractions": mock_kiota_module, "kiota_abstractions.api_error": mock_msgraph, }, ): result = self.adapter.from_exception(mock_error) assert isinstance(result, UpstreamError) assert result.status_code == 500 assert result.message == "Upstream Microsoft Graph error: unhandled MockUnhandledError." assert result.developer_message == "Some unhandled Microsoft Graph error" assert result.extra["service"] == "_microsoft_graph" assert result.extra["error_type"] == "MockUnhandledError" def test_from_exception_fallback_with_msgraph_core_module(self): """Test fallback handling for errors from msgraph_core module.""" class MockCoreError(Exception): pass mock_error = MockCoreError("Core error") mock_error.__module__ = "msgraph_core.requests" # Create mock APIError class class MockAPIError: pass # Create mock msgraph module mock_msgraph = Mock() mock_msgraph.APIError = MockAPIError # Create parent module mock mock_kiota_module = Mock() mock_kiota_module.api_error = mock_msgraph with patch.dict( "sys.modules", { "msgraph": Mock(), "kiota_abstractions": mock_kiota_module, "kiota_abstractions.api_error": mock_msgraph, }, ): result = self.adapter.from_exception(mock_error) assert isinstance(result, UpstreamError) assert result.status_code == 500 assert result.message == "Upstream Microsoft Graph error: unhandled MockCoreError." assert result.developer_message == "Core error" def test_from_exception_non_msgraph_error(self): """Test handling non-Microsoft Graph errors returns None.""" # Create a non-Microsoft Graph error mock_error = ValueError("Not a Microsoft Graph error") mock_error.__module__ = "builtins" # Create mock APIError class class MockAPIError: pass # Create mock msgraph module mock_msgraph = Mock() mock_msgraph.APIError = MockAPIError # Create parent module mock mock_kiota_module = Mock() mock_kiota_module.api_error = mock_msgraph with patch.dict( "sys.modules", { "msgraph": Mock(), "kiota_abstractions": mock_kiota_module, "kiota_abstractions.api_error": mock_msgraph, }, ): result = self.adapter.from_exception(mock_error) assert result is None def test_from_exception_error_without_module(self): """Test handling error without __module__ attribute.""" mock_error = Exception("Error without module") if hasattr(mock_error, "__module__"): del mock_error.__module__ # Create mock APIError class class MockAPIError: pass # Create mock msgraph module mock_msgraph = Mock() mock_msgraph.APIError = MockAPIError # Create parent module mock mock_kiota_module = Mock() mock_kiota_module.api_error = mock_msgraph with patch.dict( "sys.modules", { "msgraph": Mock(), "kiota_abstractions": mock_kiota_module, "kiota_abstractions.api_error": mock_msgraph, }, ): result = self.adapter.from_exception(mock_error) assert result is None def test_from_exception_rate_limit_integration(self): """Test full integration with rate limit error.""" # Create mock api_error module with APIError class class MockAPIError: def __init__(self): # Initialize with the same structure as _create_mock_api_error self.response = Mock() self.response.status_code = 429 self.response.url = "https://graph.microsoft.com/v1.0/me/messages" self.response.headers = {"Retry-After": "300"} self.error = Mock() self.error.message = "Rate limit exceeded" self.error.code = "TooManyRequests" self.error.inner_error = None mock_api_error_module = Mock() mock_api_error_module.APIError = MockAPIError # Create parent module mock mock_kiota_module = Mock() mock_kiota_module.api_error = mock_api_error_module # Create the mock error as an actual instance of MockAPIError mock_error = MockAPIError() with patch.dict( "sys.modules", { "kiota_abstractions": mock_kiota_module, "kiota_abstractions.api_error": mock_api_error_module, }, ): result = self.adapter.from_exception(mock_error) assert isinstance(result, UpstreamRateLimitError) assert result.retry_after_ms == 300_000 assert result.message == "Upstream Microsoft Graph API error: Rate limit exceeded" assert result.extra["service"] == "_microsoft_graph" assert result.extra["error_code"] == "TooManyRequests" def test_from_exception_complex_error_details(self): """Test handling error with complex nested error details.""" # Create mock api_error module with APIError class class MockAPIError: def __init__(self): # Create mock inner error with proper Mock structure mock_inner_error = Mock() mock_inner_error.code = "InvalidSyntax" setattr(mock_inner_error, "request-id", "12345-67890") mock_inner_error.date = "2025-09-22T16:08:56Z" # Initialize with the same structure as _create_mock_api_error self.response = Mock() self.response.status_code = 400 self.response.url = "https://graph.microsoft.com/v1.0/me/messages" self.response.headers = {} self.error = Mock() self.error.message = "Invalid request syntax" self.error.code = "BadRequest" self.error.inner_error = mock_inner_error mock_api_error_module = Mock() mock_api_error_module.APIError = MockAPIError # Create parent module mock mock_kiota_module = Mock() mock_kiota_module.api_error = mock_api_error_module # Create the mock error as an actual instance of MockAPIError mock_error = MockAPIError() with patch.dict( "sys.modules", { "kiota_abstractions": mock_kiota_module, "kiota_abstractions.api_error": mock_api_error_module, }, ): result = self.adapter.from_exception(mock_error) assert isinstance(result, UpstreamError) assert result.status_code == 400 assert result.message == "Upstream Microsoft Graph API error: Invalid request syntax" assert "Microsoft Graph error code: BadRequest" in result.developer_message assert "Inner error:" in result.developer_message