"""Tests for exceptions and error handling. Comprehensive tests covering: - Exception classes and their attributes - Exception inheritance hierarchy - Error message formatting - Rate limit info in exceptions - Configuration validation errors """ from __future__ import annotations import pytest from fastapi_traffic import BackendError, ConfigurationError, RateLimitExceeded from fastapi_traffic.core.config import RateLimitConfig from fastapi_traffic.core.models import RateLimitInfo from fastapi_traffic.exceptions import FastAPITrafficError class TestExceptionHierarchy: """Tests for exception class hierarchy.""" def test_all_exceptions_inherit_from_base(self) -> None: """Test that all exceptions inherit from FastAPITrafficError.""" assert issubclass(RateLimitExceeded, FastAPITrafficError) assert issubclass(BackendError, FastAPITrafficError) assert issubclass(ConfigurationError, FastAPITrafficError) def test_base_exception_inherits_from_exception(self) -> None: """Test that base exception inherits from Exception.""" assert issubclass(FastAPITrafficError, Exception) def test_exceptions_are_catchable_as_base(self) -> None: """Test that all exceptions can be caught as base type.""" try: raise RateLimitExceeded("test") except FastAPITrafficError: pass try: raise BackendError("test") except FastAPITrafficError: pass try: raise ConfigurationError("test") except FastAPITrafficError: pass class TestRateLimitExceeded: """Tests for RateLimitExceeded exception.""" def test_default_message(self) -> None: """Test default error message.""" exc = RateLimitExceeded() assert exc.message == "Rate limit exceeded" assert str(exc) == "Rate limit exceeded" def test_custom_message(self) -> None: """Test custom error message.""" exc = RateLimitExceeded("Custom rate limit message") assert exc.message == "Custom rate limit message" assert str(exc) == "Custom rate limit message" def test_retry_after_attribute(self) -> None: """Test retry_after attribute.""" exc = RateLimitExceeded("test", retry_after=30.5) assert exc.retry_after == 30.5 def test_retry_after_none_by_default(self) -> None: """Test retry_after is None by default.""" exc = RateLimitExceeded("test") assert exc.retry_after is None def test_limit_info_attribute(self) -> None: """Test limit_info attribute.""" info = RateLimitInfo( limit=100, remaining=0, reset_at=1234567890.0, retry_after=30.0, ) exc = RateLimitExceeded("test", limit_info=info) assert exc.limit_info is not None assert exc.limit_info.limit == 100 assert exc.limit_info.remaining == 0 def test_limit_info_none_by_default(self) -> None: """Test limit_info is None by default.""" exc = RateLimitExceeded("test") assert exc.limit_info is None def test_full_exception_construction(self) -> None: """Test constructing exception with all attributes.""" info = RateLimitInfo( limit=50, remaining=0, reset_at=1234567890.0, retry_after=15.0, window_size=60.0, ) exc = RateLimitExceeded( "API rate limit exceeded", retry_after=15.0, limit_info=info, ) assert exc.message == "API rate limit exceeded" assert exc.retry_after == 15.0 assert exc.limit_info is not None assert exc.limit_info.window_size == 60.0 class TestBackendError: """Tests for BackendError exception.""" def test_default_message(self) -> None: """Test default error message.""" exc = BackendError() assert exc.message == "Backend operation failed" def test_custom_message(self) -> None: """Test custom error message.""" exc = BackendError("Redis connection failed") assert exc.message == "Redis connection failed" def test_original_error_attribute(self) -> None: """Test original_error attribute.""" original = ValueError("Connection refused") exc = BackendError("Failed to connect", original_error=original) assert exc.original_error is original assert isinstance(exc.original_error, ValueError) def test_original_error_none_by_default(self) -> None: """Test original_error is None by default.""" exc = BackendError("test") assert exc.original_error is None def test_chained_exception_handling(self) -> None: """Test that original error can be used for chaining.""" original = ConnectionError("Network unreachable") exc = BackendError("Backend unavailable", original_error=original) assert exc.original_error is not None assert str(exc.original_error) == "Network unreachable" class TestConfigurationError: """Tests for ConfigurationError exception.""" def test_basic_construction(self) -> None: """Test basic exception construction.""" exc = ConfigurationError("Invalid configuration") assert str(exc) == "Invalid configuration" def test_inherits_from_base(self) -> None: """Test inheritance from base exception.""" exc = ConfigurationError("test") assert isinstance(exc, FastAPITrafficError) assert isinstance(exc, Exception) class TestRateLimitConfigValidation: """Tests for RateLimitConfig validation errors.""" def test_negative_limit_raises_error(self) -> None: """Test that negative limit raises ValueError.""" with pytest.raises(ValueError, match="limit must be positive"): RateLimitConfig(limit=-1, window_size=60.0) def test_zero_limit_raises_error(self) -> None: """Test that zero limit raises ValueError.""" with pytest.raises(ValueError, match="limit must be positive"): RateLimitConfig(limit=0, window_size=60.0) def test_negative_window_size_raises_error(self) -> None: """Test that negative window_size raises ValueError.""" with pytest.raises(ValueError, match="window_size must be positive"): RateLimitConfig(limit=100, window_size=-1.0) def test_zero_window_size_raises_error(self) -> None: """Test that zero window_size raises ValueError.""" with pytest.raises(ValueError, match="window_size must be positive"): RateLimitConfig(limit=100, window_size=0.0) def test_negative_cost_raises_error(self) -> None: """Test that negative cost raises ValueError.""" with pytest.raises(ValueError, match="cost must be positive"): RateLimitConfig(limit=100, window_size=60.0, cost=-1) def test_zero_cost_raises_error(self) -> None: """Test that zero cost raises ValueError.""" with pytest.raises(ValueError, match="cost must be positive"): RateLimitConfig(limit=100, window_size=60.0, cost=0) def test_valid_config_does_not_raise(self) -> None: """Test that valid configuration does not raise.""" config = RateLimitConfig(limit=100, window_size=60.0, cost=1) assert config.limit == 100 assert config.window_size == 60.0 assert config.cost == 1 class TestRateLimitInfo: """Tests for RateLimitInfo model.""" def test_to_headers_basic(self) -> None: """Test basic header generation.""" info = RateLimitInfo( limit=100, remaining=50, reset_at=1234567890.0, ) headers = info.to_headers() assert headers["X-RateLimit-Limit"] == "100" assert headers["X-RateLimit-Remaining"] == "50" assert headers["X-RateLimit-Reset"] == "1234567890" def test_to_headers_with_retry_after(self) -> None: """Test header generation with retry_after.""" info = RateLimitInfo( limit=100, remaining=0, reset_at=1234567890.0, retry_after=30.0, ) headers = info.to_headers() assert "Retry-After" in headers assert headers["Retry-After"] == "30" def test_to_headers_without_retry_after(self) -> None: """Test header generation without retry_after.""" info = RateLimitInfo( limit=100, remaining=50, reset_at=1234567890.0, ) headers = info.to_headers() assert "Retry-After" not in headers def test_remaining_cannot_be_negative_in_headers(self) -> None: """Test that remaining is clamped to 0 in headers.""" info = RateLimitInfo( limit=100, remaining=-5, reset_at=1234567890.0, ) headers = info.to_headers() assert headers["X-RateLimit-Remaining"] == "0" def test_frozen_dataclass(self) -> None: """Test that RateLimitInfo is immutable.""" info = RateLimitInfo( limit=100, remaining=50, reset_at=1234567890.0, ) with pytest.raises(AttributeError): info.limit = 200 # type: ignore[misc] def test_default_window_size(self) -> None: """Test default window_size value.""" info = RateLimitInfo( limit=100, remaining=50, reset_at=1234567890.0, ) assert info.window_size == 60.0