""" Tests for application settings management. """ import pytest from fastapi.testclient import TestClient from sqlalchemy.orm import Session from app.models import ApplicationSettings from app.utils.config_loader import convert_setting_value, load_settings_from_db from app.utils.settings_service import ( SETTING_METADATA, delete_setting_from_db, get_all_settings_from_db, get_setting_from_db, get_setting_metadata, get_settings_by_category, save_setting_to_db, validate_setting_value, ) @pytest.mark.unit class TestSettingsService: """Test settings service functions""" def test_save_and_get_setting(self, db_session: Session): """Test saving and retrieving a setting from database""" # Save a setting result = save_setting_to_db(db_session, "test_key", "test_value") assert result is True # Retrieve the setting value = get_setting_from_db(db_session, "test_key") assert value == "test_value" def test_update_existing_setting(self, db_session: Session): """Test updating an existing setting""" # Save initial value save_setting_to_db(db_session, "test_key", "initial_value") # Update the value result = save_setting_to_db(db_session, "test_key", "updated_value") assert result is True # Verify update value = get_setting_from_db(db_session, "test_key") assert value == "updated_value" def test_get_nonexistent_setting(self, db_session: Session): """Test retrieving a setting that doesn't exist""" value = get_setting_from_db(db_session, "nonexistent_key") assert value is None def test_get_all_settings(self, db_session: Session): """Test retrieving all settings from database""" # Save multiple settings save_setting_to_db(db_session, "key1", "value1") save_setting_to_db(db_session, "key2", "value2") save_setting_to_db(db_session, "key3", "value3") # Get all settings all_settings = get_all_settings_from_db(db_session) assert len(all_settings) == 3 assert all_settings["key1"] == "value1" assert all_settings["key2"] == "value2" assert all_settings["key3"] == "value3" def test_delete_setting(self, db_session: Session): """Test deleting a setting from database""" # Save a setting save_setting_to_db(db_session, "test_key", "test_value") # Delete the setting result = delete_setting_from_db(db_session, "test_key") assert result is True # Verify deletion value = get_setting_from_db(db_session, "test_key") assert value is None def test_delete_nonexistent_setting(self, db_session: Session): """Test deleting a setting that doesn't exist""" result = delete_setting_from_db(db_session, "nonexistent_key") assert result is False def test_validate_setting_value_boolean(self): """Test validation of boolean settings""" # Valid boolean values is_valid, error = validate_setting_value("debug", "true") assert is_valid is True assert error is None is_valid, error = validate_setting_value("debug", "false") assert is_valid is True # Invalid boolean value is_valid, error = validate_setting_value("debug", "maybe") assert is_valid is False assert "boolean" in error.lower() def test_validate_session_secret_length(self): """Test validation of session_secret minimum length""" # Too short is_valid, error = validate_setting_value("session_secret", "short") assert is_valid is False assert "32 characters" in error # Long enough long_secret = "a" * 32 is_valid, error = validate_setting_value("session_secret", long_secret) assert is_valid is True def test_get_setting_metadata(self): """Test retrieving setting metadata""" metadata = get_setting_metadata("database_url") assert metadata["category"] == "Core" assert metadata["type"] == "string" assert metadata["required"] is True assert metadata["restart_required"] is True # Test unknown setting metadata = get_setting_metadata("unknown_setting") assert metadata["category"] == "Other" def test_get_settings_by_category(self): """Test getting settings organized by category""" categories = get_settings_by_category() assert "Core" in categories assert "Authentication" in categories assert "AI Services" in categories assert "database_url" in categories["Core"] assert "auth_enabled" in categories["Authentication"] def test_setting_metadata_completeness(self): """Test that all major settings have metadata""" # Check that we have a good number of settings defined assert len(SETTING_METADATA) > 50, "Should have metadata for at least 50 settings" # Check critical settings are present critical_settings = [ "database_url", "redis_url", "workdir", "debug", "openai_api_key", "azure_ai_key", "auth_enabled", "session_secret", ] for setting in critical_settings: assert setting in SETTING_METADATA, f"Missing metadata for {setting}" def test_all_config_settings_have_metadata(self): """Test that every setting in config.py's Settings class has a SETTING_METADATA entry.""" from app.config import Settings # Get all annotated fields from the Settings class settings_fields = set(Settings.model_fields.keys()) metadata_keys = set(SETTING_METADATA.keys()) missing = settings_fields - metadata_keys assert not missing, ( f"The following config.py settings are missing from SETTING_METADATA " f"and will not appear on the settings page: {sorted(missing)}" ) def test_setting_metadata_has_required_fields(self): """Test that every SETTING_METADATA entry has the required keys.""" required_keys = {"category", "description", "type", "sensitive", "required", "restart_required"} for key, meta in SETTING_METADATA.items(): missing = required_keys - set(meta.keys()) assert not missing, f"SETTING_METADATA['{key}'] is missing keys: {missing}" def test_pdfa_settings_in_metadata(self): """Test that all 8 PDF/A settings from the issue are present in SETTING_METADATA.""" pdfa_settings = [ "enable_pdfa_conversion", "pdfa_format", "pdfa_upload_original", "pdfa_upload_processed", "pdfa_upload_folder", "google_drive_pdfa_folder_id", "pdfa_timestamp_enabled", "pdfa_timestamp_url", ] for setting in pdfa_settings: assert setting in SETTING_METADATA, f"PDF/A setting '{setting}' missing from SETTING_METADATA" def test_pdfa_format_has_dropdown_options(self): """Test that pdfa_format uses a dropdown with valid PDF/A format options.""" meta = SETTING_METADATA["pdfa_format"] assert "options" in meta, "pdfa_format should have dropdown options" assert set(meta["options"]) == {"1", "2", "3"}, "pdfa_format options should be 1, 2, 3" def test_slider_type_settings_have_range(self): """Test that slider-type settings have min, max, and step defined.""" for key, meta in SETTING_METADATA.items(): if meta.get("type") == "slider": assert "min" in meta, f"Slider setting '{key}' missing 'min'" assert "max" in meta, f"Slider setting '{key}' missing 'max'" assert "step" in meta, f"Slider setting '{key}' missing 'step'" def test_validate_slider_value(self): """Test validation of slider type values.""" # near_duplicate_threshold is a slider (0-1) is_valid, error = validate_setting_value("near_duplicate_threshold", "0.85") assert is_valid is True is_valid, error = validate_setting_value("near_duplicate_threshold", "1.5") assert is_valid is False assert "must be <= 1" in error is_valid, error = validate_setting_value("near_duplicate_threshold", "-0.1") assert is_valid is False assert "must be >= 0" in error is_valid, error = validate_setting_value("near_duplicate_threshold", "not_a_number") assert is_valid is False assert "must be a number" in error def test_s3_storage_class_has_dropdown_options(self): """Test that s3_storage_class uses a dropdown with valid options.""" meta = SETTING_METADATA["s3_storage_class"] assert "options" in meta, "s3_storage_class should have dropdown options" assert "STANDARD" in meta["options"] assert "GLACIER" in meta["options"] def test_s3_acl_has_dropdown_options(self): """Test that s3_acl uses a dropdown with valid options.""" meta = SETTING_METADATA["s3_acl"] assert "options" in meta, "s3_acl should have dropdown options" assert "private" in meta["options"] assert "public-read" in meta["options"] @pytest.mark.unit class TestConfigLoader: """Test configuration loader functions""" def test_convert_boolean_value(self): """Test converting string to boolean""" assert convert_setting_value("true", bool) is True assert convert_setting_value("false", bool) is False assert convert_setting_value("1", bool) is True assert convert_setting_value("0", bool) is False assert convert_setting_value("yes", bool) is True assert convert_setting_value("no", bool) is False def test_convert_integer_value(self): """Test converting string to integer""" assert convert_setting_value("42", int) == 42 assert convert_setting_value("0", int) == 0 assert convert_setting_value("-5", int) == -5 # Invalid integer assert convert_setting_value("not_a_number", int) == 0 def test_convert_string_value(self): """Test converting to string (default)""" assert convert_setting_value("hello", str) == "hello" assert convert_setting_value("123", str) == "123" def test_convert_none_value(self): """Test handling None values""" assert convert_setting_value(None, str) is None assert convert_setting_value(None, int) is None assert convert_setting_value(None, bool) is None def test_convert_list_value(self): """Test converting comma-separated string to list""" assert convert_setting_value("a,b,c", list) == ["a", "b", "c"] assert convert_setting_value("single", list) == ["single"] assert convert_setting_value("", list) == [] @pytest.mark.integration @pytest.mark.requires_db class TestSettingsAPI: """Test settings API endpoints""" def test_get_settings_requires_admin(self, client: TestClient): """Test that settings endpoint requires admin privileges""" # With AUTH_ENABLED=False in test environment, this test verifies # the admin check functionality. In production with AUTH_ENABLED=True, # both authentication and admin checks are enforced. response = client.get("/api/settings/") # Should return 403 (no admin session) or redirect # Note: Test environment has AUTH_ENABLED=False assert response.status_code in [200, 302, 403] def test_settings_page_structure(self, client: TestClient): """Test that settings page has expected structure""" # Verify the endpoint exists and returns expected status codes response = client.get("/settings", follow_redirects=False) # Should redirect or return 403 since no admin session assert response.status_code in [200, 302, 403] @pytest.mark.integration @pytest.mark.requires_db class TestSettingsPrecedence: """Test settings precedence (DB > env > defaults)""" def test_db_overrides_default(self, db_session: Session): """Test that database settings override default values""" # Create a minimal test settings object from pydantic_settings import BaseSettings class TestSettings(BaseSettings): test_value: str = "default" test_bool: bool = False class Config: env_file = None # Create settings with defaults test_settings = TestSettings() assert test_settings.test_value == "default" assert test_settings.test_bool is False # Save to database save_setting_to_db(db_session, "test_value", "from_database") save_setting_to_db(db_session, "test_bool", "true") # Load from database load_settings_from_db(test_settings, db_session) # Verify database values take precedence assert test_settings.test_value == "from_database" assert test_settings.test_bool is True def test_load_settings_handles_missing_db_settings(self, db_session: Session): """Test that loading settings works when no DB settings exist""" from pydantic_settings import BaseSettings class TestSettings(BaseSettings): test_value: str = "default" class Config: env_file = None test_settings = TestSettings() # Load from empty database - should not crash load_settings_from_db(test_settings, db_session) # Should still have default value assert test_settings.test_value == "default" @pytest.mark.unit class TestApplicationSettingsModel: """Test the ApplicationSettings database model""" def test_create_setting_record(self, db_session: Session): """Test creating an ApplicationSettings record""" setting = ApplicationSettings(key="test_key", value="test_value") db_session.add(setting) db_session.commit() # Retrieve and verify retrieved = db_session.query(ApplicationSettings).filter_by(key="test_key").first() assert retrieved is not None assert retrieved.key == "test_key" assert retrieved.value == "test_value" assert retrieved.created_at is not None assert retrieved.updated_at is not None def test_unique_key_constraint(self, db_session: Session): """Test that key field has unique constraint""" # Create first setting setting1 = ApplicationSettings(key="unique_key", value="value1") db_session.add(setting1) db_session.commit() # Try to create duplicate - should fail setting2 = ApplicationSettings(key="unique_key", value="value2") db_session.add(setting2) with pytest.raises(Exception): # SQLAlchemy will raise an exception db_session.commit() @pytest.mark.skipif( True, # Skip for all databases - timestamp update behavior varies reason="Timestamp update behavior varies by database backend", ) def test_update_timestamp(self, db_session: Session): """Test that updated_at timestamp is updated on modification""" import time # Create setting setting = ApplicationSettings(key="test_key", value="initial") db_session.add(setting) db_session.commit() initial_updated_at = setting.updated_at # Small delay to ensure timestamp difference time.sleep(0.1) # Update setting setting.value = "updated" db_session.commit() # Verify updated_at changed # Note: SQLite doesn't automatically update onupdate timestamps # This test is skipped as behavior varies by database backend assert setting.updated_at is not None