"""Tests for configuration loader.""" from __future__ import annotations import json import tempfile from pathlib import Path from typing import TYPE_CHECKING import pytest from fastapi_traffic.core.algorithms import Algorithm from fastapi_traffic.core.config import GlobalConfig from fastapi_traffic.core.config_loader import ( ConfigLoader, load_global_config, load_global_config_from_env, load_rate_limit_config, load_rate_limit_config_from_env, ) from fastapi_traffic.exceptions import ConfigurationError if TYPE_CHECKING: from collections.abc import Generator @pytest.fixture def temp_dir() -> Generator[Path, None, None]: """Create a temporary directory for test files.""" with tempfile.TemporaryDirectory() as tmpdir: yield Path(tmpdir) @pytest.fixture def loader() -> ConfigLoader: """Create a ConfigLoader instance.""" return ConfigLoader() class TestConfigLoaderEnv: """Tests for loading configuration from environment variables.""" def test_load_rate_limit_config_from_env(self, loader: ConfigLoader) -> None: """Test loading RateLimitConfig from environment variables.""" env_vars = { "FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT": "100", "FASTAPI_TRAFFIC_RATE_LIMIT_WINDOW_SIZE": "60.0", "FASTAPI_TRAFFIC_RATE_LIMIT_ALGORITHM": "token_bucket", "FASTAPI_TRAFFIC_RATE_LIMIT_KEY_PREFIX": "test", "FASTAPI_TRAFFIC_RATE_LIMIT_INCLUDE_HEADERS": "true", "FASTAPI_TRAFFIC_RATE_LIMIT_STATUS_CODE": "429", "FASTAPI_TRAFFIC_RATE_LIMIT_SKIP_ON_ERROR": "false", "FASTAPI_TRAFFIC_RATE_LIMIT_COST": "1", } config = loader.load_rate_limit_config_from_env(env_vars) assert config.limit == 100 assert config.window_size == 60.0 assert config.algorithm == Algorithm.TOKEN_BUCKET assert config.key_prefix == "test" assert config.include_headers is True assert config.status_code == 429 assert config.skip_on_error is False assert config.cost == 1 def test_load_rate_limit_config_from_env_with_burst_size( self, loader: ConfigLoader ) -> None: """Test loading RateLimitConfig with burst_size.""" env_vars = { "FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT": "50", "FASTAPI_TRAFFIC_RATE_LIMIT_BURST_SIZE": "100", } config = loader.load_rate_limit_config_from_env(env_vars) assert config.limit == 50 assert config.burst_size == 100 def test_load_rate_limit_config_from_env_missing_limit( self, loader: ConfigLoader ) -> None: """Test that missing 'limit' field raises ConfigurationError.""" env_vars = { "FASTAPI_TRAFFIC_RATE_LIMIT_WINDOW_SIZE": "60.0", } with pytest.raises(ConfigurationError, match="Required field 'limit'"): loader.load_rate_limit_config_from_env(env_vars) def test_load_rate_limit_config_from_env_with_overrides( self, loader: ConfigLoader ) -> None: """Test loading with overrides.""" env_vars = { "FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT": "100", } config = loader.load_rate_limit_config_from_env( env_vars, window_size=120.0, error_message="Custom error" ) assert config.limit == 100 assert config.window_size == 120.0 assert config.error_message == "Custom error" def test_load_global_config_from_env(self, loader: ConfigLoader) -> None: """Test loading GlobalConfig from environment variables.""" env_vars = { "FASTAPI_TRAFFIC_GLOBAL_ENABLED": "true", "FASTAPI_TRAFFIC_GLOBAL_DEFAULT_LIMIT": "200", "FASTAPI_TRAFFIC_GLOBAL_DEFAULT_WINDOW_SIZE": "120.0", "FASTAPI_TRAFFIC_GLOBAL_DEFAULT_ALGORITHM": "fixed_window", "FASTAPI_TRAFFIC_GLOBAL_KEY_PREFIX": "global_test", "FASTAPI_TRAFFIC_GLOBAL_INCLUDE_HEADERS": "false", "FASTAPI_TRAFFIC_GLOBAL_STATUS_CODE": "503", "FASTAPI_TRAFFIC_GLOBAL_SKIP_ON_ERROR": "true", "FASTAPI_TRAFFIC_GLOBAL_HEADERS_PREFIX": "X-Custom", } config = loader.load_global_config_from_env(env_vars) assert config.enabled is True assert config.default_limit == 200 assert config.default_window_size == 120.0 assert config.default_algorithm == Algorithm.FIXED_WINDOW assert config.key_prefix == "global_test" assert config.include_headers is False assert config.status_code == 503 assert config.skip_on_error is True assert config.headers_prefix == "X-Custom" def test_load_global_config_from_env_with_sets(self, loader: ConfigLoader) -> None: """Test loading GlobalConfig with set fields.""" env_vars = { "FASTAPI_TRAFFIC_GLOBAL_EXEMPT_IPS": "127.0.0.1, 192.168.1.1, 10.0.0.1", "FASTAPI_TRAFFIC_GLOBAL_EXEMPT_PATHS": "/health, /metrics", } config = loader.load_global_config_from_env(env_vars) assert config.exempt_ips == {"127.0.0.1", "192.168.1.1", "10.0.0.1"} assert config.exempt_paths == {"/health", "/metrics"} def test_load_global_config_from_env_empty_sets( self, loader: ConfigLoader ) -> None: """Test loading GlobalConfig with empty set fields.""" env_vars = { "FASTAPI_TRAFFIC_GLOBAL_EXEMPT_IPS": "", "FASTAPI_TRAFFIC_GLOBAL_EXEMPT_PATHS": "", } config = loader.load_global_config_from_env(env_vars) assert config.exempt_ips == set() assert config.exempt_paths == set() def test_load_global_config_defaults(self, loader: ConfigLoader) -> None: """Test that GlobalConfig uses defaults when no env vars set.""" config = loader.load_global_config_from_env({}) assert config.enabled is True assert config.default_limit == 100 assert config.default_window_size == 60.0 class TestConfigLoaderDotenv: """Tests for loading configuration from .env files.""" def test_load_rate_limit_config_from_dotenv( self, loader: ConfigLoader, temp_dir: Path ) -> None: """Test loading RateLimitConfig from .env file.""" env_file = temp_dir / ".env" env_file.write_text( """ # Rate limit configuration FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT=150 FASTAPI_TRAFFIC_RATE_LIMIT_WINDOW_SIZE=30.0 FASTAPI_TRAFFIC_RATE_LIMIT_ALGORITHM=sliding_window FASTAPI_TRAFFIC_RATE_LIMIT_KEY_PREFIX=api """ ) config = loader.load_rate_limit_config_from_dotenv(env_file) assert config.limit == 150 assert config.window_size == 30.0 assert config.algorithm == Algorithm.SLIDING_WINDOW assert config.key_prefix == "api" def test_load_rate_limit_config_from_dotenv_with_quotes( self, loader: ConfigLoader, temp_dir: Path ) -> None: """Test loading config with quoted values.""" env_file = temp_dir / ".env" env_file.write_text( """ FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT=100 FASTAPI_TRAFFIC_RATE_LIMIT_ERROR_MESSAGE="Custom error message" FASTAPI_TRAFFIC_RATE_LIMIT_KEY_PREFIX='quoted_prefix' """ ) config = loader.load_rate_limit_config_from_dotenv(env_file) assert config.limit == 100 assert config.error_message == "Custom error message" assert config.key_prefix == "quoted_prefix" def test_load_global_config_from_dotenv( self, loader: ConfigLoader, temp_dir: Path ) -> None: """Test loading GlobalConfig from .env file.""" env_file = temp_dir / ".env" env_file.write_text( """ FASTAPI_TRAFFIC_GLOBAL_ENABLED=false FASTAPI_TRAFFIC_GLOBAL_DEFAULT_LIMIT=500 FASTAPI_TRAFFIC_GLOBAL_EXEMPT_IPS=10.0.0.1,10.0.0.2 """ ) config = loader.load_global_config_from_dotenv(env_file) assert config.enabled is False assert config.default_limit == 500 assert config.exempt_ips == {"10.0.0.1", "10.0.0.2"} def test_load_from_dotenv_file_not_found( self, loader: ConfigLoader, temp_dir: Path ) -> None: """Test that missing file raises ConfigurationError.""" with pytest.raises(ConfigurationError, match="not found"): loader.load_rate_limit_config_from_dotenv(temp_dir / "nonexistent.env") def test_load_from_dotenv_invalid_line( self, loader: ConfigLoader, temp_dir: Path ) -> None: """Test that invalid line format raises ConfigurationError.""" env_file = temp_dir / ".env" env_file.write_text( """ FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT=100 invalid line without equals """ ) with pytest.raises(ConfigurationError, match="missing '='"): loader.load_rate_limit_config_from_dotenv(env_file) class TestConfigLoaderJson: """Tests for loading configuration from JSON files.""" def test_load_rate_limit_config_from_json( self, loader: ConfigLoader, temp_dir: Path ) -> None: """Test loading RateLimitConfig from JSON file.""" json_file = temp_dir / "config.json" config_data = { "limit": 200, "window_size": 45.0, "algorithm": "leaky_bucket", "key_prefix": "json_test", "include_headers": False, "status_code": 429, "cost": 2, } json_file.write_text(json.dumps(config_data)) config = loader.load_rate_limit_config_from_json(json_file) assert config.limit == 200 assert config.window_size == 45.0 assert config.algorithm == Algorithm.LEAKY_BUCKET assert config.key_prefix == "json_test" assert config.include_headers is False assert config.status_code == 429 assert config.cost == 2 def test_load_rate_limit_config_from_json_with_int_window( self, loader: ConfigLoader, temp_dir: Path ) -> None: """Test that integer window_size is converted to float.""" json_file = temp_dir / "config.json" config_data = {"limit": 100, "window_size": 60} json_file.write_text(json.dumps(config_data)) config = loader.load_rate_limit_config_from_json(json_file) assert config.window_size == 60.0 assert isinstance(config.window_size, float) def test_load_global_config_from_json( self, loader: ConfigLoader, temp_dir: Path ) -> None: """Test loading GlobalConfig from JSON file.""" json_file = temp_dir / "config.json" config_data = { "enabled": True, "default_limit": 1000, "default_window_size": 300.0, "default_algorithm": "sliding_window_counter", "exempt_ips": ["127.0.0.1", "::1"], "exempt_paths": ["/health", "/ready", "/metrics"], } json_file.write_text(json.dumps(config_data)) config = loader.load_global_config_from_json(json_file) assert config.enabled is True assert config.default_limit == 1000 assert config.default_window_size == 300.0 assert config.default_algorithm == Algorithm.SLIDING_WINDOW_COUNTER assert config.exempt_ips == {"127.0.0.1", "::1"} assert config.exempt_paths == {"/health", "/ready", "/metrics"} def test_load_from_json_file_not_found( self, loader: ConfigLoader, temp_dir: Path ) -> None: """Test that missing file raises ConfigurationError.""" with pytest.raises(ConfigurationError, match="not found"): loader.load_rate_limit_config_from_json(temp_dir / "nonexistent.json") def test_load_from_json_invalid_json( self, loader: ConfigLoader, temp_dir: Path ) -> None: """Test that invalid JSON raises ConfigurationError.""" json_file = temp_dir / "config.json" json_file.write_text("{ invalid json }") with pytest.raises(ConfigurationError, match="Invalid JSON"): loader.load_rate_limit_config_from_json(json_file) def test_load_from_json_non_object_root( self, loader: ConfigLoader, temp_dir: Path ) -> None: """Test that non-object JSON root raises ConfigurationError.""" json_file = temp_dir / "config.json" json_file.write_text("[1, 2, 3]") with pytest.raises(ConfigurationError, match="JSON root must be an object"): loader.load_rate_limit_config_from_json(json_file) def test_load_from_json_missing_limit( self, loader: ConfigLoader, temp_dir: Path ) -> None: """Test that missing 'limit' field raises ConfigurationError.""" json_file = temp_dir / "config.json" config_data = {"window_size": 60.0} json_file.write_text(json.dumps(config_data)) with pytest.raises(ConfigurationError, match="Required field 'limit'"): loader.load_rate_limit_config_from_json(json_file) class TestConfigLoaderValidation: """Tests for configuration validation.""" def test_invalid_algorithm_value(self, loader: ConfigLoader) -> None: """Test that invalid algorithm raises ConfigurationError.""" env_vars = { "FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT": "100", "FASTAPI_TRAFFIC_RATE_LIMIT_ALGORITHM": "invalid_algorithm", } with pytest.raises(ConfigurationError, match="Cannot parse value"): loader.load_rate_limit_config_from_env(env_vars) def test_invalid_int_value(self, loader: ConfigLoader) -> None: """Test that invalid integer raises ConfigurationError.""" env_vars = { "FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT": "not_a_number", } with pytest.raises(ConfigurationError, match="Cannot parse value"): loader.load_rate_limit_config_from_env(env_vars) def test_invalid_float_value(self, loader: ConfigLoader) -> None: """Test that invalid float raises ConfigurationError.""" env_vars = { "FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT": "100", "FASTAPI_TRAFFIC_RATE_LIMIT_WINDOW_SIZE": "not_a_float", } with pytest.raises(ConfigurationError, match="Cannot parse value"): loader.load_rate_limit_config_from_env(env_vars) def test_unknown_field(self, loader: ConfigLoader, temp_dir: Path) -> None: """Test that unknown field raises ConfigurationError.""" json_file = temp_dir / "config.json" config_data = {"limit": 100, "unknown_field": "value"} json_file.write_text(json.dumps(config_data)) with pytest.raises(ConfigurationError, match="Unknown configuration field"): loader.load_rate_limit_config_from_json(json_file) def test_non_loadable_field(self, loader: ConfigLoader, temp_dir: Path) -> None: """Test that non-loadable field raises ConfigurationError.""" json_file = temp_dir / "config.json" config_data = {"limit": 100, "key_extractor": "some_function"} json_file.write_text(json.dumps(config_data)) with pytest.raises(ConfigurationError, match="cannot be loaded"): loader.load_rate_limit_config_from_json(json_file) def test_invalid_type_in_json(self, loader: ConfigLoader, temp_dir: Path) -> None: """Test that invalid type in JSON raises ConfigurationError.""" json_file = temp_dir / "config.json" config_data = {"limit": "not_an_int"} json_file.write_text(json.dumps(config_data)) with pytest.raises(ConfigurationError, match="Cannot parse value"): loader.load_rate_limit_config_from_json(json_file) def test_bool_parsing_variations(self, loader: ConfigLoader) -> None: """Test various boolean string representations.""" for true_val in ["true", "True", "TRUE", "1", "yes", "Yes", "on", "ON"]: env_vars = { "FASTAPI_TRAFFIC_GLOBAL_ENABLED": true_val, } config = loader.load_global_config_from_env(env_vars) assert config.enabled is True, f"Failed for value: {true_val}" for false_val in ["false", "False", "FALSE", "0", "no", "No", "off", "OFF"]: env_vars = { "FASTAPI_TRAFFIC_GLOBAL_ENABLED": false_val, } config = loader.load_global_config_from_env(env_vars) assert config.enabled is False, f"Failed for value: {false_val}" class TestConvenienceFunctions: """Tests for convenience functions.""" def test_load_rate_limit_config_json(self, temp_dir: Path) -> None: """Test load_rate_limit_config with JSON file.""" json_file = temp_dir / "config.json" config_data = {"limit": 100} json_file.write_text(json.dumps(config_data)) config = load_rate_limit_config(json_file) assert config.limit == 100 def test_load_rate_limit_config_dotenv(self, temp_dir: Path) -> None: """Test load_rate_limit_config with .env file.""" env_file = temp_dir / ".env" env_file.write_text("FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT=200") config = load_rate_limit_config(env_file) assert config.limit == 200 def test_load_rate_limit_config_env_suffix(self, temp_dir: Path) -> None: """Test load_rate_limit_config with .env suffix.""" env_file = temp_dir / "custom.env" env_file.write_text("FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT=300") config = load_rate_limit_config(env_file) assert config.limit == 300 def test_load_global_config_json(self, temp_dir: Path) -> None: """Test load_global_config with JSON file.""" json_file = temp_dir / "config.json" config_data = {"default_limit": 500} json_file.write_text(json.dumps(config_data)) config = load_global_config(json_file) assert config.default_limit == 500 def test_load_global_config_dotenv(self, temp_dir: Path) -> None: """Test load_global_config with .env file.""" env_file = temp_dir / ".env" env_file.write_text("FASTAPI_TRAFFIC_GLOBAL_DEFAULT_LIMIT=600") config = load_global_config(env_file) assert config.default_limit == 600 def test_load_config_unknown_format(self, temp_dir: Path) -> None: """Test that unknown file format raises ConfigurationError.""" unknown_file = temp_dir / "config.yaml" unknown_file.write_text("limit: 100") with pytest.raises(ConfigurationError, match="Unknown configuration file"): load_rate_limit_config(unknown_file) def test_load_rate_limit_config_from_env_function(self) -> None: """Test load_rate_limit_config_from_env convenience function.""" # This will use defaults since no env vars are set # We need to provide the limit as an override config = load_rate_limit_config_from_env(limit=100) assert config.limit == 100 def test_load_global_config_from_env_function(self) -> None: """Test load_global_config_from_env convenience function.""" config = load_global_config_from_env() assert isinstance(config, GlobalConfig) assert config.enabled is True class TestCustomEnvPrefix: """Tests for custom environment variable prefix.""" def test_custom_prefix(self) -> None: """Test loading with custom environment prefix.""" loader = ConfigLoader(env_prefix="CUSTOM_") env_vars = { "CUSTOM_RATE_LIMIT_LIMIT": "100", "CUSTOM_RATE_LIMIT_WINDOW_SIZE": "30.0", } config = loader.load_rate_limit_config_from_env(env_vars) assert config.limit == 100 assert config.window_size == 30.0 def test_custom_prefix_global(self) -> None: """Test loading GlobalConfig with custom prefix.""" loader = ConfigLoader(env_prefix="MY_APP_") env_vars = { "MY_APP_GLOBAL_ENABLED": "false", "MY_APP_GLOBAL_DEFAULT_LIMIT": "250", } config = loader.load_global_config_from_env(env_vars) assert config.enabled is False assert config.default_limit == 250 class TestAllAlgorithms: """Tests for all algorithm types.""" @pytest.mark.parametrize( "algorithm_str,expected", [ ("token_bucket", Algorithm.TOKEN_BUCKET), ("sliding_window", Algorithm.SLIDING_WINDOW), ("fixed_window", Algorithm.FIXED_WINDOW), ("leaky_bucket", Algorithm.LEAKY_BUCKET), ("sliding_window_counter", Algorithm.SLIDING_WINDOW_COUNTER), ("TOKEN_BUCKET", Algorithm.TOKEN_BUCKET), ("SLIDING_WINDOW", Algorithm.SLIDING_WINDOW), ], ) def test_algorithm_parsing( self, loader: ConfigLoader, algorithm_str: str, expected: Algorithm ) -> None: """Test that all algorithm values are parsed correctly.""" env_vars = { "FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT": "100", "FASTAPI_TRAFFIC_RATE_LIMIT_ALGORITHM": algorithm_str, } config = loader.load_rate_limit_config_from_env(env_vars) assert config.algorithm == expected class TestDataclassValidation: """Tests that dataclass validation still works after loading.""" def test_invalid_limit_value(self, loader: ConfigLoader, temp_dir: Path) -> None: """Test that invalid limit value is caught by dataclass validation.""" json_file = temp_dir / "config.json" config_data = {"limit": 0} json_file.write_text(json.dumps(config_data)) with pytest.raises(ValueError, match="limit must be positive"): loader.load_rate_limit_config_from_json(json_file) def test_invalid_window_size_value( self, loader: ConfigLoader, temp_dir: Path ) -> None: """Test that invalid window_size is caught by dataclass validation.""" json_file = temp_dir / "config.json" config_data = {"limit": 100, "window_size": -1.0} json_file.write_text(json.dumps(config_data)) with pytest.raises(ValueError, match="window_size must be positive"): loader.load_rate_limit_config_from_json(json_file) def test_invalid_cost_value(self, loader: ConfigLoader, temp_dir: Path) -> None: """Test that invalid cost is caught by dataclass validation.""" json_file = temp_dir / "config.json" config_data = {"limit": 100, "cost": 0} json_file.write_text(json.dumps(config_data)) with pytest.raises(ValueError, match="cost must be positive"): loader.load_rate_limit_config_from_json(json_file)