"""Unit tests for Settings configuration and validation. Tests cover: - Environment variable parsing - Default values application - Settings validation - CORS origins parsing - Secret generation - Configuration properties """ import os from unittest.mock import patch import pytest from pydantic import ValidationError from src.config.settings import Settings, settings class TestSettingsDefaults: """Tests for default settings values.""" def test_jwt_secret_key_generated(self): """Test that JWT secret key is auto-generated if not provided.""" s = Settings() assert s.jwt_secret_key is not None assert len(s.jwt_secret_key) > 0 def test_jwt_secret_key_unique(self): """Test that each Settings instance gets unique JWT key.""" s1 = Settings() s2 = Settings() assert s1.jwt_secret_key != s2.jwt_secret_key def test_password_salt_default(self): """Test default password salt.""" s = Settings() assert s.password_salt == "default-salt" def test_master_password_hash_default_none(self): """Test master password hash defaults to None.""" s = Settings() assert s.master_password_hash is None def test_master_password_default_none(self): """Test master password defaults to None.""" s = Settings() assert s.master_password is None def test_token_expiry_hours_default(self): """Test default token expiry hours.""" s = Settings() assert s.token_expiry_hours == 24 def test_anime_directory_default(self): """Test anime directory defaults to empty string.""" s = Settings() assert s.anime_directory == "" def test_log_level_default(self): """Test default log level.""" s = Settings() assert s.log_level == "INFO" def test_database_url_default(self): """Test default database URL.""" s = Settings() assert s.database_url == "sqlite:///./data/aniworld.db" def test_cors_origins_default(self): """Test default CORS origins.""" s = Settings() assert s.cors_origins == "http://localhost:3000" def test_api_rate_limit_default(self): """Test default API rate limit.""" s = Settings() assert s.api_rate_limit == 100 def test_default_provider_default(self): """Test default provider.""" s = Settings() assert s.default_provider == "aniworld.to" def test_provider_timeout_default(self): """Test default provider timeout.""" s = Settings() assert s.provider_timeout == 30 def test_retry_attempts_default(self): """Test default retry attempts.""" s = Settings() assert s.retry_attempts == 3 class TestNFOSettingsDefaults: """Tests for NFO-related settings defaults.""" def test_tmdb_api_key_default_none(self): """Test TMDB API key defaults to None.""" s = Settings() assert s.tmdb_api_key is None def test_nfo_auto_create_default(self): """Test NFO auto-create defaults to False.""" s = Settings() assert s.nfo_auto_create is False def test_nfo_update_on_scan_default(self): """Test NFO update on scan defaults to False.""" s = Settings() assert s.nfo_update_on_scan is False def test_nfo_download_poster_default(self): """Test NFO download poster defaults to True.""" s = Settings() assert s.nfo_download_poster is True def test_nfo_download_logo_default(self): """Test NFO download logo defaults to True.""" s = Settings() assert s.nfo_download_logo is True def test_nfo_download_fanart_default(self): """Test NFO download fanart defaults to True.""" s = Settings() assert s.nfo_download_fanart is True def test_nfo_image_size_default(self): """Test NFO image size defaults to 'original'.""" s = Settings() assert s.nfo_image_size == "original" def test_nfo_prefer_fsk_rating_default(self): """Test NFO prefer FSK rating defaults to True.""" s = Settings() assert s.nfo_prefer_fsk_rating is True class TestEnvironmentVariableParsing: """Tests for environment variable parsing.""" def test_jwt_secret_key_from_env(self): """Test loading JWT secret key from environment.""" with patch.dict(os.environ, {"JWT_SECRET_KEY": "test-secret-key"}): s = Settings() assert s.jwt_secret_key == "test-secret-key" def test_password_salt_from_env(self): """Test loading password salt from environment.""" with patch.dict(os.environ, {"PASSWORD_SALT": "custom-salt"}): s = Settings() assert s.password_salt == "custom-salt" def test_master_password_hash_from_env(self): """Test loading master password hash from environment.""" with patch.dict(os.environ, {"MASTER_PASSWORD_HASH": "hash123"}): s = Settings() assert s.master_password_hash == "hash123" def test_master_password_from_env(self): """Test loading master password from environment.""" with patch.dict(os.environ, {"MASTER_PASSWORD": "dev-password"}): s = Settings() assert s.master_password == "dev-password" def test_token_expiry_hours_from_env(self): """Test loading token expiry from environment.""" with patch.dict(os.environ, {"SESSION_TIMEOUT_HOURS": "48"}): s = Settings() assert s.token_expiry_hours == 48 def test_anime_directory_from_env(self): """Test loading anime directory from environment.""" with patch.dict(os.environ, {"ANIME_DIRECTORY": "/media/anime"}): s = Settings() assert s.anime_directory == "/media/anime" def test_log_level_from_env(self): """Test loading log level from environment.""" with patch.dict(os.environ, {"LOG_LEVEL": "DEBUG"}): s = Settings() assert s.log_level == "DEBUG" def test_database_url_from_env(self): """Test loading database URL from environment.""" with patch.dict(os.environ, {"DATABASE_URL": "postgresql://localhost/aniworld"}): s = Settings() assert s.database_url == "postgresql://localhost/aniworld" def test_cors_origins_from_env(self): """Test loading CORS origins from environment.""" with patch.dict(os.environ, {"CORS_ORIGINS": "http://example.com"}): s = Settings() assert s.cors_origins == "http://example.com" def test_api_rate_limit_from_env(self): """Test loading API rate limit from environment.""" with patch.dict(os.environ, {"API_RATE_LIMIT": "200"}): s = Settings() assert s.api_rate_limit == 200 def test_default_provider_from_env(self): """Test loading default provider from environment.""" with patch.dict(os.environ, {"DEFAULT_PROVIDER": "custom.provider"}): s = Settings() assert s.default_provider == "custom.provider" def test_provider_timeout_from_env(self): """Test loading provider timeout from environment.""" with patch.dict(os.environ, {"PROVIDER_TIMEOUT": "60"}): s = Settings() assert s.provider_timeout == 60 def test_retry_attempts_from_env(self): """Test loading retry attempts from environment.""" with patch.dict(os.environ, {"RETRY_ATTEMPTS": "5"}): s = Settings() assert s.retry_attempts == 5 def test_tmdb_api_key_from_env(self): """Test loading TMDB API key from environment.""" with patch.dict(os.environ, {"TMDB_API_KEY": "tmdb-key-123"}): s = Settings() assert s.tmdb_api_key == "tmdb-key-123" class TestNFOEnvironmentVariables: """Tests for NFO settings from environment.""" def test_nfo_auto_create_from_env_true(self): """Test loading NFO auto create as true from environment.""" with patch.dict(os.environ, {"NFO_AUTO_CREATE": "true"}): s = Settings() assert s.nfo_auto_create is True def test_nfo_auto_create_from_env_false(self): """Test loading NFO auto create as false from environment.""" with patch.dict(os.environ, {"NFO_AUTO_CREATE": "false"}): s = Settings() assert s.nfo_auto_create is False def test_nfo_update_on_scan_from_env(self): """Test loading NFO update on scan from environment.""" with patch.dict(os.environ, {"NFO_UPDATE_ON_SCAN": "true"}): s = Settings() assert s.nfo_update_on_scan is True def test_nfo_download_poster_from_env_false(self): """Test loading NFO download poster as false.""" with patch.dict(os.environ, {"NFO_DOWNLOAD_POSTER": "false"}): s = Settings() assert s.nfo_download_poster is False def test_nfo_download_logo_from_env_false(self): """Test loading NFO download logo as false.""" with patch.dict(os.environ, {"NFO_DOWNLOAD_LOGO": "false"}): s = Settings() assert s.nfo_download_logo is False def test_nfo_download_fanart_from_env_false(self): """Test loading NFO download fanart as false.""" with patch.dict(os.environ, {"NFO_DOWNLOAD_FANART": "false"}): s = Settings() assert s.nfo_download_fanart is False def test_nfo_image_size_from_env(self): """Test loading NFO image size from environment.""" with patch.dict(os.environ, {"NFO_IMAGE_SIZE": "w500"}): s = Settings() assert s.nfo_image_size == "w500" def test_nfo_prefer_fsk_rating_from_env_false(self): """Test loading NFO prefer FSK rating as false.""" with patch.dict(os.environ, {"NFO_PREFER_FSK_RATING": "false"}): s = Settings() assert s.nfo_prefer_fsk_rating is False class TestAllowedOriginsProperty: """Tests for allowed_origins property.""" def test_allowed_origins_single(self): """Test parsing single CORS origin.""" with patch.dict(os.environ, {"CORS_ORIGINS": "http://localhost:3000"}): s = Settings() origins = s.allowed_origins assert origins == ["http://localhost:3000"] def test_allowed_origins_multiple(self): """Test parsing multiple CORS origins.""" with patch.dict(os.environ, {"CORS_ORIGINS": "http://localhost:3000,http://example.com"}): s = Settings() origins = s.allowed_origins assert len(origins) == 2 assert "http://localhost:3000" in origins assert "http://example.com" in origins def test_allowed_origins_with_spaces(self): """Test parsing CORS origins with extra spaces.""" with patch.dict(os.environ, {"CORS_ORIGINS": "http://localhost:3000 , http://example.com "}): s = Settings() origins = s.allowed_origins assert len(origins) == 2 assert "http://localhost:3000" in origins assert "http://example.com" in origins def test_allowed_origins_wildcard_safe_fallback(self): """Test that wildcard falls back to safe defaults.""" with patch.dict(os.environ, {"CORS_ORIGINS": "*"}): s = Settings() origins = s.allowed_origins assert "http://localhost:3000" in origins assert "http://localhost:8000" in origins # Should not allow all origins assert "*" not in origins def test_allowed_origins_empty_string(self): """Test parsing empty CORS origins.""" with patch.dict(os.environ, {"CORS_ORIGINS": ""}): s = Settings() origins = s.allowed_origins assert origins == [] def test_allowed_origins_whitespace_only(self): """Test parsing whitespace-only CORS origins.""" with patch.dict(os.environ, {"CORS_ORIGINS": " "}): s = Settings() origins = s.allowed_origins assert origins == [] def test_allowed_origins_filters_empty_items(self): """Test that empty items are filtered from comma-separated list.""" with patch.dict(os.environ, {"CORS_ORIGINS": "http://localhost:3000,,http://example.com,"}): s = Settings() origins = s.allowed_origins assert len(origins) == 2 assert "http://localhost:3000" in origins assert "http://example.com" in origins class TestSettingsValidation: """Tests for settings validation.""" def test_invalid_token_expiry_hours_type(self): """Test that invalid token expiry type raises validation error.""" with patch.dict(os.environ, {"SESSION_TIMEOUT_HOURS": "not-a-number"}): with pytest.raises(ValidationError): Settings() def test_invalid_api_rate_limit_type(self): """Test that invalid rate limit type raises validation error.""" with patch.dict(os.environ, {"API_RATE_LIMIT": "not-a-number"}): with pytest.raises(ValidationError): Settings() def test_invalid_provider_timeout_type(self): """Test that invalid timeout type raises validation error.""" with patch.dict(os.environ, {"PROVIDER_TIMEOUT": "not-a-number"}): with pytest.raises(ValidationError): Settings() def test_invalid_retry_attempts_type(self): """Test that invalid retry attempts type raises validation error.""" with patch.dict(os.environ, {"RETRY_ATTEMPTS": "not-a-number"}): with pytest.raises(ValidationError): Settings() def test_invalid_boolean_value(self): """Test that invalid boolean raises validation error.""" with patch.dict(os.environ, {"NFO_AUTO_CREATE": "not-a-boolean"}): with pytest.raises(ValidationError): Settings() class TestGlobalSettingsInstance: """Tests for global settings instance.""" def test_settings_instance_exists(self): """Test that global settings instance is created.""" assert settings is not None assert isinstance(settings, Settings) def test_settings_has_jwt_secret_key(self): """Test that global settings has JWT secret key.""" assert settings.jwt_secret_key is not None assert len(settings.jwt_secret_key) > 0 def test_settings_has_default_database_url(self): """Test that global settings has default database URL.""" # Will be default unless overridden assert settings.database_url is not None class TestSettingsExtraIgnored: """Tests for extra fields handling.""" def test_unknown_env_vars_ignored(self): """Test that unknown environment variables are ignored.""" with patch.dict(os.environ, {"UNKNOWN_SETTING": "value", "JWT_SECRET_KEY": "test-key"}): s = Settings() assert s.jwt_secret_key == "test-key" # Should not raise error for unknown setting assert not hasattr(s, "UNKNOWN_SETTING") assert not hasattr(s, "unknown_setting") class TestSettingsEdgeCases: """Tests for edge cases in settings.""" def test_numeric_string_values(self): """Test that numeric strings are correctly parsed.""" with patch.dict(os.environ, { "API_RATE_LIMIT": "250", "PROVIDER_TIMEOUT": "45", "RETRY_ATTEMPTS": "7" }): s = Settings() assert s.api_rate_limit == 250 assert s.provider_timeout == 45 assert s.retry_attempts == 7 def test_boolean_string_variations(self): """Test different boolean string representations.""" # Test true variations with patch.dict(os.environ, {"NFO_AUTO_CREATE": "1"}): s = Settings() assert s.nfo_auto_create is True with patch.dict(os.environ, {"NFO_AUTO_CREATE": "yes"}): s = Settings() assert s.nfo_auto_create is True # Test false variations with patch.dict(os.environ, {"NFO_AUTO_CREATE": "0"}): s = Settings() assert s.nfo_auto_create is False with patch.dict(os.environ, {"NFO_AUTO_CREATE": "no"}): s = Settings() assert s.nfo_auto_create is False def test_empty_anime_directory_valid(self): """Test that empty anime directory is valid.""" with patch.dict(os.environ, {"ANIME_DIRECTORY": ""}): s = Settings() assert s.anime_directory == "" def test_path_with_spaces(self): """Test that paths with spaces are preserved.""" with patch.dict(os.environ, {"ANIME_DIRECTORY": "/media/my anime"}): s = Settings() assert s.anime_directory == "/media/my anime" def test_url_formats(self): """Test various database URL formats.""" urls = [ "sqlite:///./data/aniworld.db", "postgresql://user:pass@localhost/db", "mysql://localhost:3306/aniworld", ] for url in urls: with patch.dict(os.environ, {"DATABASE_URL": url}): s = Settings() assert s.database_url == url def test_log_level_case_insensitive(self): """Test that log level accepts different cases.""" levels = ["DEBUG", "debug", "Info", "WARNING", "ERROR"] for level in levels: with patch.dict(os.environ, {"LOG_LEVEL": level}): s = Settings() assert s.log_level == level class TestSecurityConsiderations: """Tests for security-related settings behavior.""" def test_jwt_secret_is_different_each_time(self): """Test that JWT secret is not predictable.""" secrets_generated = set() for _ in range(10): s = Settings() secrets_generated.add(s.jwt_secret_key) # All should be unique assert len(secrets_generated) == 10 def test_master_password_warning_documented(self): """Test that master_password field has warning documentation.""" # Check that the field metadata includes warning field_info = Settings.model_fields["master_password"] description = field_info.description assert description is not None assert "DEVELOPMENT ONLY" in description assert "NEVER" in description.upper() def test_both_password_fields_can_be_none(self): """Test that both password fields being None is valid.""" s = Settings() assert s.master_password is None assert s.master_password_hash is None # This is valid - password will be set during setup