""" Comprehensive tests for SettingsManager. Tests cover: - Thread safety mechanisms - Settings locking behavior - get_setting functionality with various scenarios - set_setting operations - Import/export functionality - Version management - Static helper methods """ import os import threading import pytest from unittest.mock import MagicMock, patch, PropertyMock from local_deep_research.settings.manager import ( SettingsManager, get_typed_setting_value, check_env_setting, is_valid_setting_key, parse_boolean, _parse_number, _parse_multiselect, _filter_setting_columns, ) class TestSettingsManagerThreadSafety: """Tests for thread safety mechanisms in SettingsManager.""" @pytest.fixture(autouse=True) def clean_env(self): """Clean environment before each test.""" original_env = { k: v for k, v in os.environ.items() if k.startswith("LDR_") } for key in list(os.environ.keys()): if key.startswith("LDR_"): os.environ.pop(key, None) yield for key in list(os.environ.keys()): if key.startswith("LDR_"): os.environ.pop(key, None) for key, value in original_env.items(): os.environ[key] = value def test_check_thread_safety_same_thread_passes(self): """Test that thread safety check passes when used in creation thread.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 manager = SettingsManager(db_session=mock_session) # Should not raise when used in same thread manager._check_thread_safety() def test_check_thread_safety_different_thread_raises(self): """Test that thread safety check raises RuntimeError when used across threads.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 manager = SettingsManager(db_session=mock_session) exception_raised = None def use_in_different_thread(): nonlocal exception_raised try: manager._check_thread_safety() except RuntimeError as e: exception_raised = e thread = threading.Thread(target=use_in_different_thread) thread.start() thread.join() assert exception_raised is not None assert "thread-safe" in str(exception_raised).lower() def test_check_thread_safety_no_session_skips_check(self): """Test that thread safety check is skipped without DB session.""" manager = SettingsManager(db_session=None) # Should not raise even if called from different thread # because there's no db_session def use_in_different_thread(): manager._check_thread_safety() # Should not raise thread = threading.Thread(target=use_in_different_thread) thread.start() thread.join() def test_settings_manager_thread_id_tracking(self): """Test that SettingsManager tracks creation thread ID.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 manager = SettingsManager(db_session=mock_session) assert hasattr(manager, "_creation_thread_id") assert manager._creation_thread_id == threading.get_ident() def test_concurrent_access_from_multiple_threads(self): """Test that concurrent access from multiple threads raises errors.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 manager = SettingsManager(db_session=mock_session) errors = [] def access_from_thread(): try: manager._check_thread_safety() except RuntimeError as e: errors.append(e) threads = [ threading.Thread(target=access_from_thread) for _ in range(3) ] for t in threads: t.start() for t in threads: t.join() # All 3 threads should have raised errors assert len(errors) == 3 class TestSettingsManagerLocking: """Tests for settings locking behavior.""" @pytest.fixture(autouse=True) def clean_env(self): """Clean environment before each test.""" original_env = { k: v for k, v in os.environ.items() if k.startswith("LDR_") } for key in list(os.environ.keys()): if key.startswith("LDR_"): os.environ.pop(key, None) yield for key in list(os.environ.keys()): if key.startswith("LDR_"): os.environ.pop(key, None) for key, value in original_env.items(): os.environ[key] = value def test_settings_locked_property_returns_false_when_unlocked(self): """Test settings_locked returns False by default.""" manager = SettingsManager(db_session=None) # Manually set the private attribute to test manager._SettingsManager__settings_locked = False assert manager.settings_locked is False def test_settings_locked_property_returns_true_when_locked(self): """Test settings_locked returns True when app.lock_settings is True.""" manager = SettingsManager(db_session=None) # Manually set the private attribute manager._SettingsManager__settings_locked = True assert manager.settings_locked is True def test_settings_locked_cached_after_first_check(self): """Test that settings_locked value is cached after first evaluation.""" manager = SettingsManager(db_session=None) # Initially None assert manager._SettingsManager__settings_locked is None # After accessing, should be set with patch.object(manager, "get_setting", return_value=False): _ = manager.settings_locked # Now should be cached assert manager._SettingsManager__settings_locked is False def test_set_setting_blocked_when_locked(self): """Test that set_setting returns False when settings are locked.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 manager = SettingsManager(db_session=mock_session) manager._SettingsManager__settings_locked = True result = manager.set_setting("test.key", "value") assert result is False def test_create_or_update_setting_blocked_when_locked(self): """Test that create_or_update_setting returns None when settings are locked.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 manager = SettingsManager(db_session=mock_session) manager._SettingsManager__settings_locked = True result = manager.create_or_update_setting( {"key": "test", "value": "val"} ) assert result is None def test_settings_locked_exception_handling(self): """Test that settings_locked returns False on error.""" manager = SettingsManager(db_session=None) # Force an exception during get_setting with patch.object( manager, "get_setting", side_effect=Exception("Test error") ): # Reset to force re-evaluation manager._SettingsManager__settings_locked = None result = manager.settings_locked assert result is False class TestSettingsManagerGetSetting: """Tests for get_setting functionality.""" @pytest.fixture(autouse=True) def clean_env(self): """Clean environment before each test.""" original_env = { k: v for k, v in os.environ.items() if k.startswith("LDR_") } for key in list(os.environ.keys()): if key.startswith("LDR_"): os.environ.pop(key, None) yield for key in list(os.environ.keys()): if key.startswith("LDR_"): os.environ.pop(key, None) for key, value in original_env.items(): os.environ[key] = value def test_get_setting_returns_default_when_not_found(self): """Test that get_setting returns default when key not found.""" manager = SettingsManager(db_session=None) result = manager.get_setting("nonexistent.key", default="fallback") assert result == "fallback" def test_get_setting_env_override_takes_priority(self): """Test that environment variable overrides DB value.""" os.environ["LDR_APP_DEBUG"] = "true" mock_session = MagicMock() mock_setting = MagicMock() mock_setting.key = "app.debug" mock_setting.value = False mock_setting.ui_element = "checkbox" mock_session.query.return_value.count.return_value = 1 mock_session.query.return_value.filter.return_value.all.return_value = [ mock_setting ] manager = SettingsManager(db_session=mock_session) result = manager.get_setting("app.debug", check_env=True) # Environment variable should override assert result is True def test_get_setting_env_only_setting_from_env(self): """Test that env-only settings are read from environment.""" os.environ["LDR_TESTING_TEST_MODE"] = "true" manager = SettingsManager(db_session=None) result = manager.get_setting("testing.test_mode") assert result is True def test_get_setting_nested_key_pattern(self): """Test that nested key pattern returns dict of settings.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 # Mock multiple settings matching pattern mock_settings = [ MagicMock(key="llm.provider", value="openai", ui_element="select"), MagicMock(key="llm.temperature", value=0.7, ui_element="number"), ] mock_session.query.return_value.filter.return_value.all.return_value = ( mock_settings ) manager = SettingsManager(db_session=mock_session) result = manager.get_setting("llm") assert isinstance(result, dict) assert "provider" in result assert "temperature" in result def test_get_setting_exact_key_match(self): """Test that exact key match returns single value.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 mock_setting = MagicMock() mock_setting.key = "app.debug" mock_setting.value = True mock_setting.ui_element = "checkbox" mock_session.query.return_value.filter.return_value.all.return_value = [ mock_setting ] manager = SettingsManager(db_session=mock_session) result = manager.get_setting("app.debug") assert result is True def test_get_setting_with_empty_string_default(self): """Test get_setting with empty string as default.""" manager = SettingsManager(db_session=None) result = manager.get_setting("nonexistent.key", default="") assert result == "" def test_get_setting_with_none_default(self): """Test get_setting with None as default.""" manager = SettingsManager(db_session=None) result = manager.get_setting("nonexistent.key", default=None) assert result is None def test_get_setting_check_env_false_ignores_env(self): """get_setting with check_env=False ignores env var and returns DB value.""" os.environ["LDR_APP_DEBUG"] = "true" mock_session = MagicMock() mock_setting = MagicMock() mock_setting.key = "app.debug" mock_setting.value = False mock_setting.ui_element = "checkbox" mock_session.query.return_value.count.return_value = 1 mock_session.query.return_value.filter.return_value.all.return_value = [ mock_setting ] manager = SettingsManager(db_session=mock_session) result = manager.get_setting("app.debug", check_env=False) # Should return DB value (False), NOT the env var "true" assert result is False def test_get_setting_sqlalchemy_error_handling(self): """Test that SQLAlchemy errors are handled and return default.""" from sqlalchemy.exc import SQLAlchemyError mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 mock_session.query.return_value.filter.return_value.all.side_effect = ( SQLAlchemyError("DB error") ) manager = SettingsManager(db_session=mock_session) result = manager.get_setting("app.debug", default="fallback") assert result == "fallback" def test_get_setting_auto_initializes_empty_db(self): """Test that _ensure_settings_initialized is called for empty DB.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 0 with patch.object( SettingsManager, "load_from_defaults_file" ) as mock_load: SettingsManager(db_session=mock_session) mock_load.assert_called_once() class TestSettingsManagerSetSetting: """Tests for set_setting functionality.""" @pytest.fixture(autouse=True) def clean_env(self): """Clean environment before each test.""" original_env = { k: v for k, v in os.environ.items() if k.startswith("LDR_") } for key in list(os.environ.keys()): if key.startswith("LDR_"): os.environ.pop(key, None) yield for key in list(os.environ.keys()): if key.startswith("LDR_"): os.environ.pop(key, None) for key, value in original_env.items(): os.environ[key] = value def test_set_setting_creates_new_setting(self): """Test that set_setting creates new setting when not exists.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 mock_session.query.return_value.filter.return_value.first.return_value = None manager = SettingsManager(db_session=mock_session) manager._SettingsManager__settings_locked = False with patch.object(manager, "_emit_settings_changed"): result = manager.set_setting("new.key", "new_value") assert result is True mock_session.add.assert_called_once() def test_set_setting_updates_existing_setting(self): """Test that set_setting updates existing setting.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 mock_setting = MagicMock() mock_setting.editable = True mock_session.query.return_value.filter.return_value.first.return_value = mock_setting manager = SettingsManager(db_session=mock_session) manager._SettingsManager__settings_locked = False with patch.object(manager, "_emit_settings_changed"): result = manager.set_setting("existing.key", "updated_value") assert result is True assert mock_setting.value == "updated_value" def test_set_setting_preserves_type(self): """Test that set_setting preserves the type of the value.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 mock_setting = MagicMock() mock_setting.editable = True mock_session.query.return_value.filter.return_value.first.return_value = mock_setting manager = SettingsManager(db_session=mock_session) manager._SettingsManager__settings_locked = False with patch.object(manager, "_emit_settings_changed"): manager.set_setting("test.int", 42) assert mock_setting.value == 42 def test_set_setting_self_heals_chat_prefix_type_and_category(self): """A legacy chat.* row written before the chat dispatch landed could have type=APP and a stale (or NULL) category. set_setting() now re-points BOTH type and category to the inferred values for the prefix; verify this happens on update. """ from local_deep_research.database.models import SettingType mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 mock_setting = MagicMock() mock_setting.editable = True mock_setting.type = SettingType.APP mock_setting.category = None mock_session.query.return_value.filter.return_value.first.return_value = mock_setting manager = SettingsManager(db_session=mock_session) manager._SettingsManager__settings_locked = False with patch.object(manager, "_emit_settings_changed"): result = manager.set_setting("chat.title_llm_timeout_seconds", 30) assert result is True assert mock_setting.type == SettingType.CHAT assert mock_setting.category == "chat" def test_set_setting_emits_websocket_event(self): """Test that set_setting emits WebSocket event on commit.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 mock_session.query.return_value.filter.return_value.first.return_value = None manager = SettingsManager(db_session=mock_session) manager._SettingsManager__settings_locked = False with patch.object(manager, "_emit_settings_changed") as mock_emit: manager.set_setting("test.key", "value", commit=True) mock_emit.assert_called_once_with(["test.key"]) def test_set_setting_rollback_on_error(self): """Test that set_setting rolls back on error.""" from sqlalchemy.exc import SQLAlchemyError mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 mock_session.query.return_value.filter.return_value.first.side_effect = SQLAlchemyError() manager = SettingsManager(db_session=mock_session) manager._SettingsManager__settings_locked = False result = manager.set_setting("test.key", "value") assert result is False mock_session.rollback.assert_called_once() def test_set_setting_no_db_session_returns_false(self): """Test that set_setting returns False without DB session.""" manager = SettingsManager(db_session=None) result = manager.set_setting("test.key", "value") assert result is False def test_set_setting_non_editable_returns_false(self): """Test that set_setting returns False for non-editable settings.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 mock_setting = MagicMock() mock_setting.editable = False mock_session.query.return_value.filter.return_value.first.return_value = mock_setting manager = SettingsManager(db_session=mock_session) manager._SettingsManager__settings_locked = False result = manager.set_setting("readonly.key", "value") assert result is False class TestSettingsManagerImportExport: """Tests for import/export functionality.""" @pytest.fixture(autouse=True) def clean_env(self): """Clean environment before each test.""" original_env = { k: v for k, v in os.environ.items() if k.startswith("LDR_") } for key in list(os.environ.keys()): if key.startswith("LDR_"): os.environ.pop(key, None) yield for key in list(os.environ.keys()): if key.startswith("LDR_"): os.environ.pop(key, None) for key, value in original_env.items(): os.environ[key] = value def test_import_settings_with_overwrite_true(self): """Test that import_settings overwrites existing values when overwrite=True.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 manager = SettingsManager(db_session=mock_session) with patch.object(manager, "get_setting", return_value="old_value"): with patch.object(manager, "delete_setting"): with patch.object(manager, "_emit_settings_changed"): manager.import_settings( {"test.key": {"value": "new_value", "type": "APP"}}, overwrite=True, ) # Should have added the new value (delete + add) mock_session.add.assert_called() def test_import_settings_with_overwrite_false(self): """Test that import_settings preserves existing values when overwrite=False.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 manager = SettingsManager(db_session=mock_session) with patch.object( manager, "get_setting", return_value="existing_value" ): with patch.object(manager, "delete_setting"): with patch.object(manager, "_emit_settings_changed"): manager.import_settings( {"test.key": {"value": "new_value", "type": "APP"}}, overwrite=False, ) # The value should be preserved (existing_value) mock_session.add.assert_called() def test_import_settings_with_delete_extra_true(self): """Test that import_settings deletes extra settings when delete_extra=True.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 manager = SettingsManager(db_session=mock_session) # Mock get_all_settings to return extra key extra_settings = { "test.key": {"value": "v1"}, "extra.key": {"value": "v2"}, } with patch.object(manager, "get_setting", return_value=None): with patch.object(manager, "delete_setting") as mock_delete: with patch.object( manager, "get_all_settings", return_value=extra_settings ): with patch.object(manager, "_emit_settings_changed"): manager.import_settings( {"test.key": {"value": "v1", "type": "APP"}}, delete_extra=True, ) # Should delete the extra.key delete_calls = [ call for call in mock_delete.call_args_list if call[0][0] == "extra.key" ] assert len(delete_calls) > 0 def test_import_settings_type_detection_from_key(self): """Test that import_settings detects type from key prefix.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 manager = SettingsManager(db_session=mock_session) with patch.object(manager, "get_setting", return_value=None): with patch.object(manager, "delete_setting"): with patch.object(manager, "_emit_settings_changed"): manager.import_settings( { "llm.test": {"value": "v1", "type": "LLM"}, "search.test": {"value": "v2", "type": "SEARCH"}, "report.test": {"value": "v3", "type": "REPORT"}, } ) # All should be added assert mock_session.add.call_count == 3 def test_get_all_settings_merges_defaults(self): """Test that get_all_settings merges defaults with DB values.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 mock_session.query.return_value.all.return_value = [] manager = SettingsManager(db_session=mock_session) # Mock default_settings with patch.object( SettingsManager, "default_settings", new_callable=PropertyMock, return_value={"default.key": {"value": "default"}}, ): result = manager.get_all_settings() assert "default.key" in result def test_get_all_settings_marks_env_non_editable(self): """Test that settings overridden by env vars are marked non-editable.""" os.environ["LDR_APP_DEBUG"] = "true" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 mock_setting = MagicMock() mock_setting.key = "app.debug" mock_setting.value = False mock_setting.type = MagicMock(name="APP") mock_setting.name = "Debug" mock_setting.description = "Debug mode" mock_setting.category = "app" mock_setting.ui_element = "checkbox" mock_setting.options = None mock_setting.min_value = None mock_setting.max_value = None mock_setting.step = None mock_setting.visible = True mock_setting.editable = True mock_session.query.return_value.all.return_value = [mock_setting] manager = SettingsManager(db_session=mock_session) manager._SettingsManager__settings_locked = False with patch.object( SettingsManager, "default_settings", new_callable=PropertyMock, return_value={}, ): result = manager.get_all_settings() assert result["app.debug"]["editable"] is False def test_get_all_settings_locked_marks_all_non_editable(self): """get_all_settings with settings_locked=True marks all settings as non-editable.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 mock_setting = MagicMock() mock_setting.key = "app.debug" mock_setting.value = True mock_setting.type = MagicMock(name="APP") mock_setting.name = "Debug" mock_setting.description = "Debug mode" mock_setting.category = "app" mock_setting.ui_element = "checkbox" mock_setting.options = None mock_setting.min_value = None mock_setting.max_value = None mock_setting.step = None mock_setting.visible = True mock_setting.editable = True # Originally editable mock_session.query.return_value.all.return_value = [mock_setting] manager = SettingsManager(db_session=mock_session) manager._SettingsManager__settings_locked = True # Lock settings with patch.object( SettingsManager, "default_settings", new_callable=PropertyMock, return_value={}, ): result = manager.get_all_settings() # Even though the DB setting is editable=True, the lock overrides it assert result["app.debug"]["editable"] is False def test_get_settings_snapshot_flat_dict(self): """Test that get_settings_snapshot returns flat key-value dict.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 manager = SettingsManager(db_session=mock_session) with patch.object( manager, "get_all_settings", return_value={ "key1": {"value": "v1"}, "key2": {"value": 42}, }, ): result = manager.get_settings_snapshot() assert result == {"key1": "v1", "key2": 42} def test_load_from_defaults_file(self): """Test that load_from_defaults_file calls import_settings.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 manager = SettingsManager(db_session=mock_session) with patch.object(manager, "import_settings") as mock_import: with patch.object( SettingsManager, "default_settings", new_callable=PropertyMock, return_value={"test": {"value": "v"}}, ): manager.load_from_defaults_file() mock_import.assert_called_once() class TestSettingsManagerVersioning: """Tests for version management.""" @pytest.fixture(autouse=True) def clean_env(self): """Clean environment before each test.""" original_env = { k: v for k, v in os.environ.items() if k.startswith("LDR_") } yield for key, value in original_env.items(): os.environ[key] = value def test_db_version_matches_package_true(self): """Test db_version_matches_package returns True when versions match.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 manager = SettingsManager(db_session=mock_session) from local_deep_research.__version__ import __version__ as pkg_version with patch.object(manager, "get_setting", return_value=pkg_version): result = manager.db_version_matches_package() assert result is True def test_db_version_matches_package_false(self): """Test db_version_matches_package returns False when versions differ.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 manager = SettingsManager(db_session=mock_session) with patch.object(manager, "get_setting", return_value="0.0.0"): result = manager.db_version_matches_package() assert result is False def test_update_db_version(self): """Test that update_db_version saves package version.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 manager = SettingsManager(db_session=mock_session) with patch.object(manager, "delete_setting"): manager.update_db_version() mock_session.add.assert_called_once() mock_session.commit.assert_called_once() def test_update_db_version_commit_false(self): """update_db_version(commit=False) must stage the version row but NOT call session.commit() — callers that bundle writes into a single atomic transaction rely on this. """ mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 manager = SettingsManager(db_session=mock_session) with patch.object(manager, "delete_setting"): manager.update_db_version(commit=False) mock_session.add.assert_called_once() mock_session.commit.assert_not_called() class TestSettingsManagerStaticMethods: """Tests for static helper methods.""" def test_get_bootstrap_env_vars(self): """Test get_bootstrap_env_vars returns bootstrap variables.""" result = SettingsManager.get_bootstrap_env_vars() assert isinstance(result, dict) assert "LDR_BOOTSTRAP_ENCRYPTION_KEY" in result assert "LDR_BOOTSTRAP_DATA_DIR" in result def test_is_bootstrap_env_var_true(self): """Test is_bootstrap_env_var returns True for bootstrap vars.""" assert SettingsManager.is_bootstrap_env_var( "LDR_BOOTSTRAP_ENCRYPTION_KEY" ) assert SettingsManager.is_bootstrap_env_var( "LDR_DB_CONFIG_CACHE_SIZE_MB" ) def test_is_bootstrap_env_var_false(self): """Test is_bootstrap_env_var returns False for non-bootstrap vars.""" assert not SettingsManager.is_bootstrap_env_var("LDR_TESTING_TEST_MODE") assert not SettingsManager.is_bootstrap_env_var("RANDOM_VAR") def test_is_env_only_setting_true(self): """Test is_env_only_setting returns True for env-only settings.""" assert SettingsManager.is_env_only_setting("testing.test_mode") assert SettingsManager.is_env_only_setting("bootstrap.encryption_key") def test_is_env_only_setting_false(self): """Test is_env_only_setting returns False for DB settings.""" assert not SettingsManager.is_env_only_setting("app.debug") assert not SettingsManager.is_env_only_setting("llm.provider") def test_get_env_var_for_setting(self): """Test get_env_var_for_setting returns correct env var name.""" assert ( SettingsManager.get_env_var_for_setting("app.host") == "LDR_APP_HOST" ) assert ( SettingsManager.get_env_var_for_setting("llm.provider") == "LDR_LLM_PROVIDER" ) def test_get_setting_key_for_env_var(self): """Test get_setting_key_for_env_var returns correct setting key.""" assert ( SettingsManager.get_setting_key_for_env_var("LDR_APP_HOST") == "app.host" ) assert ( SettingsManager.get_setting_key_for_env_var("LDR_LLM_PROVIDER") == "llm.provider" ) def test_get_setting_key_for_env_var_non_ldr(self): """Test get_setting_key_for_env_var returns None for non-LDR vars.""" assert SettingsManager.get_setting_key_for_env_var("PATH") is None assert SettingsManager.get_setting_key_for_env_var("HOME") is None class TestHelperFunctions: """Tests for module-level helper functions.""" @pytest.fixture(autouse=True) def clean_env(self): """Clean environment before each test.""" original_env = { k: v for k, v in os.environ.items() if k.startswith("LDR_") } for key in list(os.environ.keys()): if key.startswith("LDR_"): os.environ.pop(key, None) yield for key in list(os.environ.keys()): if key.startswith("LDR_"): os.environ.pop(key, None) for key, value in original_env.items(): os.environ[key] = value def test_parse_number_int(self): """Test _parse_number returns int for whole numbers.""" assert _parse_number("42") == 42 assert isinstance(_parse_number("42"), int) def test_parse_number_float(self): """Test _parse_number returns float for decimals.""" assert _parse_number("3.14") == 3.14 assert isinstance(_parse_number("3.14"), float) def test_parse_number_float_as_int(self): """Test _parse_number returns int for float with .0.""" assert _parse_number("42.0") == 42 assert isinstance(_parse_number("42.0"), int) def test_check_env_setting_returns_value(self): """Test check_env_setting returns env var value.""" os.environ["LDR_APP_DEBUG"] = "true" result = check_env_setting("app.debug") assert result == "true" def test_check_env_setting_returns_none_when_not_set(self): """Test check_env_setting returns None when not set.""" result = check_env_setting("nonexistent.key") assert result is None def test_parse_boolean_empty_string(self): """parse_boolean returns False for empty string.""" assert parse_boolean("") is False def test_parse_boolean_integer(self): """parse_boolean converts integers using Python bool().""" assert parse_boolean(1) is True assert parse_boolean(0) is False def test_parse_boolean_whitespace(self): """parse_boolean treats whitespace-only string as falsy (stripped to empty).""" assert parse_boolean(" ") is False def test_get_typed_setting_value_unknown_ui_element(self): """Test get_typed_setting_value returns default for unknown UI element.""" result = get_typed_setting_value( key="test", value="val", ui_element="unknown_element", default="fallback", ) assert result == "fallback" def test_get_typed_setting_value_json_passthrough(self): """Test get_typed_setting_value passes JSON through unchanged.""" json_value = {"key": "value", "list": [1, 2, 3]} result = get_typed_setting_value( key="test", value=json_value, ui_element="json", default=None ) assert result == json_value def test_get_typed_setting_value_invalid_number(self): """Test get_typed_setting_value returns default for invalid number.""" result = get_typed_setting_value( key="test", value="not_a_number", ui_element="number", default=99 ) assert result == 99 def test_get_typed_setting_value_select_returns_string(self): """Test get_typed_setting_value returns string for select.""" result = get_typed_setting_value( key="test", value="option1", ui_element="select", default=None ) assert result == "option1" assert isinstance(result, str) class TestDeleteSetting: """Tests for delete_setting functionality.""" def test_delete_setting_success(self): """Test that delete_setting returns True on success.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 mock_session.query.return_value.filter.return_value.delete.return_value = 1 manager = SettingsManager(db_session=mock_session) result = manager.delete_setting("test.key") assert result is True mock_session.commit.assert_called() def test_delete_setting_not_found(self): """Test that delete_setting returns False when key not found.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 mock_session.query.return_value.filter.return_value.delete.return_value = 0 manager = SettingsManager(db_session=mock_session) result = manager.delete_setting("nonexistent.key") assert result is False def test_delete_setting_no_session(self): """Test that delete_setting returns False without DB session.""" manager = SettingsManager(db_session=None) result = manager.delete_setting("test.key") assert result is False def test_delete_setting_rollback_on_error(self): """Test that delete_setting rolls back on error.""" from sqlalchemy.exc import SQLAlchemyError mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 mock_session.query.return_value.filter.return_value.delete.side_effect = SQLAlchemyError() manager = SettingsManager(db_session=mock_session) result = manager.delete_setting("test.key") assert result is False mock_session.rollback.assert_called_once() class TestGetSettingEnvFallbackWhenNotInDb: """Tests for Bug 1 fix: get_setting() checks env vars when setting not in DB.""" @pytest.fixture(autouse=True) def clean_env(self): """Clean environment before each test.""" original_env = { k: v for k, v in os.environ.items() if k.startswith("LDR_") } for key in list(os.environ.keys()): if key.startswith("LDR_"): os.environ.pop(key, None) yield for key in list(os.environ.keys()): if key.startswith("LDR_"): os.environ.pop(key, None) for key, value in original_env.items(): os.environ[key] = value def test_env_var_number_when_not_in_db(self): """Env var override returns typed value when setting not in DB. Bug 1: Previously, get_setting() would return the raw default without checking env vars when the setting was not in the database. """ os.environ["LDR_LLM_TEMPERATURE"] = "0.5" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 # Setting not found in DB mock_session.query.return_value.filter.return_value.all.return_value = [] manager = SettingsManager(db_session=mock_session) result = manager.get_setting("llm.temperature", 0.7, check_env=True) # Should pick up the env var value, type-converted via ui_element # The exact type depends on whether llm.temperature is in default_settings assert result is not None # Should not be the raw default 0.7 assert result != 0.7 or str(result) == "0.5" or result == 0.5 def test_env_var_checkbox_when_not_in_db(self): """Env var override for checkbox returns typed bool when setting not in DB.""" os.environ["LDR_APP_DEBUG"] = "true" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 # Setting not found in DB mock_session.query.return_value.filter.return_value.all.return_value = [] manager = SettingsManager(db_session=mock_session) result = manager.get_setting("app.debug", False, check_env=True) # Should pick up the env var, not return the default False assert result is not False def test_default_when_not_in_db_and_no_env(self): """Returns default when setting not in DB and no env var set.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 mock_session.query.return_value.filter.return_value.all.return_value = [] manager = SettingsManager(db_session=mock_session) result = manager.get_setting( "nonexistent.setting", "my_default", check_env=True ) assert result == "my_default" class TestGetAllSettingsEnvTypeConversion: """Tests for Bug 2 fix: get_all_settings() type-converts env overrides.""" @pytest.fixture(autouse=True) def clean_env(self): """Clean environment before each test.""" original_env = { k: v for k, v in os.environ.items() if k.startswith("LDR_") } for key in list(os.environ.keys()): if key.startswith("LDR_"): os.environ.pop(key, None) yield for key in list(os.environ.keys()): if key.startswith("LDR_"): os.environ.pop(key, None) for key, value in original_env.items(): os.environ[key] = value def test_env_override_boolean_is_converted(self): """Env override 'true' is converted to bool True, not stored as string. Bug 2: Previously, get_all_settings() stored the raw string from check_env_setting() without type conversion. "true" stayed as "true" instead of becoming True. """ os.environ["LDR_APP_DEBUG"] = "true" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 mock_setting = MagicMock() mock_setting.key = "app.debug" mock_setting.value = False mock_setting.type = MagicMock(name="APP") mock_setting.name = "Debug" mock_setting.description = "Debug mode" mock_setting.category = "app" mock_setting.ui_element = "checkbox" mock_setting.options = None mock_setting.min_value = None mock_setting.max_value = None mock_setting.step = None mock_setting.visible = True mock_setting.editable = True mock_session.query.return_value.all.return_value = [mock_setting] manager = SettingsManager(db_session=mock_session) manager._SettingsManager__settings_locked = False with patch.object( SettingsManager, "default_settings", new_callable=PropertyMock, return_value={}, ): result = manager.get_all_settings() # The value should be typed (bool True), not raw string "true" assert result["app.debug"]["value"] is True assert result["app.debug"]["editable"] is False def test_env_override_number_is_converted(self): """Env override '8080' is converted to int 8080, not stored as string. Bug 2: Previously, get_all_settings() would store "8080" as the raw string instead of converting to the proper type. """ os.environ["LDR_APP_PORT"] = "8080" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 mock_setting = MagicMock() mock_setting.key = "app.port" mock_setting.value = 5000 mock_setting.type = MagicMock(name="APP") mock_setting.name = "Port" mock_setting.description = "Server port" mock_setting.category = "app" mock_setting.ui_element = "number" mock_setting.options = None mock_setting.min_value = None mock_setting.max_value = None mock_setting.step = None mock_setting.visible = True mock_setting.editable = True mock_session.query.return_value.all.return_value = [mock_setting] manager = SettingsManager(db_session=mock_session) manager._SettingsManager__settings_locked = False with patch.object( SettingsManager, "default_settings", new_callable=PropertyMock, return_value={}, ): result = manager.get_all_settings() # The value should be typed (int 8080), not raw string "8080" assert result["app.port"]["value"] == 8080 assert isinstance(result["app.port"]["value"], int) assert result["app.port"]["editable"] is False def test_env_override_text_is_string(self): """Env override for text setting stays as string.""" os.environ["LDR_APP_HOST"] = "example.com" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 mock_setting = MagicMock() mock_setting.key = "app.host" mock_setting.value = "localhost" mock_setting.type = MagicMock(name="APP") mock_setting.name = "Host" mock_setting.description = "Server host" mock_setting.category = "app" mock_setting.ui_element = "text" mock_setting.options = None mock_setting.min_value = None mock_setting.max_value = None mock_setting.step = None mock_setting.visible = True mock_setting.editable = True mock_session.query.return_value.all.return_value = [mock_setting] manager = SettingsManager(db_session=mock_session) manager._SettingsManager__settings_locked = False with patch.object( SettingsManager, "default_settings", new_callable=PropertyMock, return_value={}, ): result = manager.get_all_settings() assert result["app.host"]["value"] == "example.com" assert isinstance(result["app.host"]["value"], str) assert result["app.host"]["editable"] is False class TestGetBoolSettingMethod: """Tests for get_bool_setting() method on the unified SettingsManager.""" def test_get_bool_setting_exists(self): """Verify get_bool_setting method exists on SettingsManager.""" manager = SettingsManager(db_session=None) assert hasattr(manager, "get_bool_setting") assert callable(manager.get_bool_setting) def test_get_bool_setting_returns_bool(self): """get_bool_setting returns a boolean type.""" manager = SettingsManager(db_session=None) with patch.object(manager, "get_setting", return_value="true"): result = manager.get_bool_setting("test.key") assert result is True assert isinstance(result, bool) def test_get_bool_setting_converts_false_string(self): """get_bool_setting converts 'false' to False.""" manager = SettingsManager(db_session=None) with patch.object(manager, "get_setting", return_value="false"): result = manager.get_bool_setting("test.key") assert result is False def test_get_bool_setting_default(self): """get_bool_setting returns default when setting not found.""" manager = SettingsManager(db_session=None) with patch.object(manager, "get_setting", return_value=None): result = manager.get_bool_setting("test.key", default=True) assert result is True def test_get_bool_setting_integer_zero(self): """get_bool_setting converts integer 0 to False.""" manager = SettingsManager(db_session=None) with patch.object(manager, "get_setting", return_value=0): result = manager.get_bool_setting("test.key") assert result is False assert isinstance(result, bool) def test_get_bool_setting_integer_one(self): """get_bool_setting converts integer 1 to True.""" manager = SettingsManager(db_session=None) with patch.object(manager, "get_setting", return_value=1): result = manager.get_bool_setting("test.key") assert result is True assert isinstance(result, bool) class TestCreateOrUpdateSetting: """Tests for create_or_update_setting method.""" @pytest.fixture(autouse=True) def clean_env(self): """Clean environment before each test.""" original_env = { k: v for k, v in os.environ.items() if k.startswith("LDR_") } for key in list(os.environ.keys()): if key.startswith("LDR_"): os.environ.pop(key, None) yield for key in list(os.environ.keys()): if key.startswith("LDR_"): os.environ.pop(key, None) for key, value in original_env.items(): os.environ[key] = value def test_create_new_setting(self): """create_or_update_setting creates a new setting when none exists.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 # No existing setting found mock_session.query.return_value.filter.return_value.first.return_value = None manager = SettingsManager(db_session=mock_session) manager._SettingsManager__settings_locked = False setting_dict = { "key": "app.new_feature", "value": "enabled", "type": "app", "name": "New Feature", } with patch.object(manager, "_emit_settings_changed"): result = manager.create_or_update_setting(setting_dict) assert result is not None mock_session.add.assert_called_once() mock_session.commit.assert_called_once() def test_update_existing_editable_setting(self): """create_or_update_setting updates fields of an existing editable setting.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 mock_db_setting = MagicMock() mock_db_setting.editable = True mock_session.query.return_value.filter.return_value.first.return_value = mock_db_setting manager = SettingsManager(db_session=mock_session) manager._SettingsManager__settings_locked = False setting_dict = { "key": "app.existing", "value": "updated", "type": "app", "name": "Existing Setting", } with patch.object(manager, "_emit_settings_changed"): result = manager.create_or_update_setting(setting_dict) assert result is mock_db_setting assert mock_db_setting.value == "updated" assert mock_db_setting.name == "Existing Setting" # Should NOT call session.add for update (only for create) mock_session.add.assert_not_called() def test_update_non_editable_returns_none(self): """Bug 3 fix: create_or_update_setting returns None for non-editable settings.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 mock_db_setting = MagicMock() mock_db_setting.editable = False mock_session.query.return_value.filter.return_value.first.return_value = mock_db_setting manager = SettingsManager(db_session=mock_session) manager._SettingsManager__settings_locked = False setting_dict = { "key": "app.locked_setting", "value": "should_not_update", "type": "app", "name": "Locked", } result = manager.create_or_update_setting(setting_dict) assert result is None def test_create_from_dict_llm_type(self): """Dict with llm. key prefix uses LLMSetting model.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 mock_session.query.return_value.filter.return_value.first.return_value = None manager = SettingsManager(db_session=mock_session) manager._SettingsManager__settings_locked = False setting_dict = { "key": "llm.temperature", "value": 0.7, "name": "Temperature", } with patch.object(manager, "_emit_settings_changed"): result = manager.create_or_update_setting(setting_dict) assert result is not None mock_session.add.assert_called_once() def test_create_from_dict_search_type(self): """Dict with search. key prefix uses SearchSetting model.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 mock_session.query.return_value.filter.return_value.first.return_value = None manager = SettingsManager(db_session=mock_session) manager._SettingsManager__settings_locked = False setting_dict = { "key": "search.max_results", "value": 10, "name": "Max Results", } with patch.object(manager, "_emit_settings_changed"): result = manager.create_or_update_setting(setting_dict) assert result is not None mock_session.add.assert_called_once() def test_create_from_dict_report_type(self): """Dict with report. key prefix uses ReportSetting model.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 mock_session.query.return_value.filter.return_value.first.return_value = None manager = SettingsManager(db_session=mock_session) manager._SettingsManager__settings_locked = False setting_dict = { "key": "report.format", "value": "pdf", "name": "Report Format", } with patch.object(manager, "_emit_settings_changed"): result = manager.create_or_update_setting(setting_dict) assert result is not None mock_session.add.assert_called_once() def test_no_db_session_returns_none(self): """create_or_update_setting returns None without a DB session.""" manager = SettingsManager(db_session=None) result = manager.create_or_update_setting( {"key": "test", "value": "val", "type": "app", "name": "Test"} ) assert result is None def test_sqlalchemy_error_rolls_back(self): """create_or_update_setting rolls back on SQLAlchemy error.""" from sqlalchemy.exc import SQLAlchemyError mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 mock_session.query.return_value.filter.return_value.first.side_effect = SQLAlchemyError( "DB error" ) manager = SettingsManager(db_session=mock_session) manager._SettingsManager__settings_locked = False setting_dict = { "key": "app.broken", "value": "val", "type": "app", "name": "Broken", } result = manager.create_or_update_setting(setting_dict) assert result is None mock_session.rollback.assert_called_once() class TestDefaultSettingsProperty: """Tests for the default_settings property.""" def test_loads_multiple_json_files(self): """default_settings loads and merges multiple JSON files.""" import tempfile from pathlib import Path with tempfile.TemporaryDirectory() as tmpdir: # Create two JSON files file1 = Path(tmpdir) / "settings1.json" file1.write_text( '{"app.debug": {"value": true, "ui_element": "checkbox"}}' ) file2 = Path(tmpdir) / "settings2.json" file2.write_text( '{"llm.model": {"value": "gpt-4", "ui_element": "select"}}' ) manager = SettingsManager(db_session=None) with patch( "local_deep_research.settings.manager.defaults.__file__", str(Path(tmpdir) / "__init__.py"), ): result = manager.default_settings assert "app.debug" in result assert "llm.model" in result assert result["app.debug"]["value"] is True assert result["llm.model"]["value"] == "gpt-4" def test_handles_json_decode_error(self): """default_settings skips files with invalid JSON and loads the rest.""" import tempfile from pathlib import Path with tempfile.TemporaryDirectory() as tmpdir: good_file = Path(tmpdir) / "good.json" good_file.write_text('{"app.host": {"value": "localhost"}}') bad_file = Path(tmpdir) / "bad.json" bad_file.write_text("{invalid json!!!") manager = SettingsManager(db_session=None) with patch( "local_deep_research.settings.manager.defaults.__file__", str(Path(tmpdir) / "__init__.py"), ): result = manager.default_settings assert "app.host" in result assert result["app.host"]["value"] == "localhost" def test_key_conflicts_logged(self): """default_settings warns when keys conflict between files.""" import tempfile from pathlib import Path with tempfile.TemporaryDirectory() as tmpdir: # Both files define the same key - second (alphabetically) wins file_a = Path(tmpdir) / "a_settings.json" file_a.write_text('{"app.debug": {"value": false}}') file_b = Path(tmpdir) / "b_settings.json" file_b.write_text('{"app.debug": {"value": true}}') manager = SettingsManager(db_session=None) with patch( "local_deep_research.settings.manager.defaults.__file__", str(Path(tmpdir) / "__init__.py"), ): result = manager.default_settings # Second file (alphabetically sorted) wins assert result["app.debug"]["value"] is True def test_empty_defaults_returns_empty(self): """default_settings returns empty dict when no JSON files exist.""" import tempfile from pathlib import Path with tempfile.TemporaryDirectory() as tmpdir: manager = SettingsManager(db_session=None) with patch( "local_deep_research.settings.manager.defaults.__file__", str(Path(tmpdir) / "__init__.py"), ): result = manager.default_settings assert result == {} class TestEnsureSettingsInitialized: """Tests for _ensure_settings_initialized behavior.""" @pytest.fixture(autouse=True) def clean_env(self): """Clean environment before each test.""" original_env = { k: v for k, v in os.environ.items() if k.startswith("LDR_") } for key in list(os.environ.keys()): if key.startswith("LDR_"): os.environ.pop(key, None) yield for key in list(os.environ.keys()): if key.startswith("LDR_"): os.environ.pop(key, None) for key, value in original_env.items(): os.environ[key] = value def test_skips_load_when_db_has_settings(self): """_ensure_settings_initialized does NOT load defaults when DB already has settings.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 42 with patch.object( SettingsManager, "load_from_defaults_file" ) as mock_load: SettingsManager(db_session=mock_session) mock_load.assert_not_called() def test_loads_defaults_when_db_empty(self): """_ensure_settings_initialized loads defaults when DB has 0 settings.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 0 with patch.object( SettingsManager, "load_from_defaults_file" ) as mock_load: SettingsManager(db_session=mock_session) mock_load.assert_called_once() class TestNewUiElementTypes: """Tests for new UI element types: textarea, multiselect, range.""" def test_textarea_returns_string(self): """get_typed_setting_value with textarea returns string.""" result = get_typed_setting_value( key="app.custom_css", value="body { color: red; }", ui_element="textarea", ) assert result == "body { color: red; }" assert isinstance(result, str) def test_multiselect_passthrough(self): """get_typed_setting_value with multiselect passes through the value.""" result = get_typed_setting_value( key="search.engines", value=["google", "bing"], ui_element="multiselect", ) assert result == ["google", "bing"] def test_range_returns_number(self): """get_typed_setting_value with range parses number like number element.""" result = get_typed_setting_value( key="llm.temperature", value="3.14", ui_element="range", ) assert result == 3.14 assert isinstance(result, float) def test_range_returns_int_for_whole_number(self): """get_typed_setting_value with range returns int for whole numbers.""" result = get_typed_setting_value( key="search.max_results", value="10", ui_element="range", ) assert result == 10 assert isinstance(result, int) class TestEmitSettingsChanged: """Tests for _emit_settings_changed error resilience.""" @pytest.fixture(autouse=True) def clean_env(self): """Clean environment before each test.""" original_env = { k: v for k, v in os.environ.items() if k.startswith("LDR_") } for key in list(os.environ.keys()): if key.startswith("LDR_"): os.environ.pop(key, None) yield for key in list(os.environ.keys()): if key.startswith("LDR_"): os.environ.pop(key, None) for key, value in original_env.items(): os.environ[key] = value def test_emit_handles_socket_not_initialized(self): """_emit_settings_changed handles SocketIOService raising ValueError.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 manager = SettingsManager(db_session=mock_session) with patch( "local_deep_research.settings.manager.SocketIOService", side_effect=ValueError("SocketIO not initialized"), create=True, ): # Should not raise manager._emit_settings_changed(["test.key"]) def test_emit_handles_import_error(self): """_emit_settings_changed handles import failure gracefully.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 manager = SettingsManager(db_session=mock_session) with patch( "local_deep_research.settings.manager.SocketIOService", side_effect=ImportError("No module"), create=True, ): # Should not raise - caught by the outer except Exception manager._emit_settings_changed(["test.key"]) def test_emit_handles_generic_exception(self): """_emit_settings_changed handles any exception without breaking settings save.""" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 manager = SettingsManager(db_session=mock_session) with patch( "local_deep_research.settings.manager.SocketIOService", side_effect=RuntimeError("Unexpected error"), create=True, ): # Should not raise - the method catches all exceptions manager._emit_settings_changed(["test.key"]) class TestParseMultiselect: """Tests for _parse_multiselect type conversion.""" def test_list_passthrough(self): """Lists from SQLAlchemy JSON column pass through unchanged.""" value = ["markdown", "latex", "quarto"] assert _parse_multiselect(value) == ["markdown", "latex", "quarto"] def test_empty_list_passthrough(self): """Empty list passes through unchanged.""" assert _parse_multiselect([]) == [] def test_json_array_string(self): """JSON array strings (from env vars) are parsed correctly.""" assert _parse_multiselect('["markdown", "latex"]') == [ "markdown", "latex", ] def test_comma_separated_string(self): """Comma-separated strings (from env vars) are parsed correctly.""" assert _parse_multiselect("markdown,latex,quarto") == [ "markdown", "latex", "quarto", ] def test_comma_separated_with_spaces(self): """Comma-separated strings with spaces are trimmed.""" assert _parse_multiselect("markdown, latex, quarto") == [ "markdown", "latex", "quarto", ] def test_single_value_string(self): """Single value string returns a one-element list.""" assert _parse_multiselect("markdown") == ["markdown"] def test_empty_string(self): """Empty string returns empty list.""" assert _parse_multiselect("") == [] def test_invalid_json_falls_back_to_comma_split(self): """Invalid JSON starting with [ falls back to comma splitting.""" assert _parse_multiselect("[broken,json") == ["[broken", "json"] def test_whitespace_only_items_filtered(self): """Whitespace-only items between commas are filtered out.""" assert _parse_multiselect("markdown,,latex, ,quarto") == [ "markdown", "latex", "quarto", ] class TestMultiselectEnvVarOverride: """Tests for multiselect env var overrides through get_typed_setting_value.""" @pytest.fixture(autouse=True) def clean_env(self): """Clean environment before each test.""" original_env = { k: v for k, v in os.environ.items() if k.startswith("LDR_") } for key in list(os.environ.keys()): if key.startswith("LDR_"): os.environ.pop(key, None) yield for key in list(os.environ.keys()): if key.startswith("LDR_"): os.environ.pop(key, None) for key, value in original_env.items(): os.environ[key] = value def test_multiselect_env_var_json_array(self): """Multiselect env var with JSON array is parsed to list.""" os.environ["LDR_REPORT_EXPORT_FORMATS"] = '["markdown", "latex"]' result = get_typed_setting_value( "report.export_formats", None, "multiselect", default=["markdown"], ) assert result == ["markdown", "latex"] def test_multiselect_env_var_comma_separated(self): """Multiselect env var with comma-separated values is parsed to list.""" os.environ["LDR_REPORT_EXPORT_FORMATS"] = "markdown,latex,quarto" result = get_typed_setting_value( "report.export_formats", None, "multiselect", default=["markdown"], ) assert result == ["markdown", "latex", "quarto"] def test_multiselect_db_value_list(self): """Multiselect DB value (already a list) passes through.""" result = get_typed_setting_value( "report.export_formats", ["markdown", "latex"], "multiselect", default=[], check_env=False, ) assert result == ["markdown", "latex"] class TestEnvVarWithoutDefaultsWarning: """Tests for warning when env var override has no type information.""" @pytest.fixture(autouse=True) def clean_env(self): """Clean environment before each test.""" original_env = { k: v for k, v in os.environ.items() if k.startswith("LDR_") } for key in list(os.environ.keys()): if key.startswith("LDR_"): os.environ.pop(key, None) yield for key in list(os.environ.keys()): if key.startswith("LDR_"): os.environ.pop(key, None) for key, value in original_env.items(): os.environ[key] = value def test_env_var_without_defaults_logs_warning(self): """Env var override for unknown setting logs a warning.""" os.environ["LDR_UNKNOWN_SETTING"] = "some_value" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 # No DB results for this key mock_session.query.return_value.filter.return_value.all.return_value = [] manager = SettingsManager(db_session=mock_session) with patch( "local_deep_research.settings.manager.logger" ) as mock_logger: result = manager.get_setting("unknown.setting", "default_val") assert result == "some_value" mock_logger.warning.assert_called() warning_msg = mock_logger.warning.call_args[0][0] assert "not in defaults" in warning_msg def test_env_var_with_defaults_no_warning(self): """Env var override for known setting does not log a warning.""" os.environ["LDR_SOME_KNOWN_KEY"] = "overridden" mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 # No DB results mock_session.query.return_value.filter.return_value.all.return_value = [] manager = SettingsManager(db_session=mock_session) with ( patch.object( type(manager), "default_settings", new_callable=PropertyMock, return_value={ "some.known.key": { "ui_element": "text", "value": "original", } }, ), patch("local_deep_research.settings.manager.logger") as mock_logger, ): result = manager.get_setting("some.known.key", "default_val") # Should return the typed value, not log a "not in defaults" warning assert result == "overridden" for call in mock_logger.warning.call_args_list: assert "not in defaults" not in call[0][0] class TestGetAllSettingsDbErrorPropagation: """get_all_settings must propagate DB errors instead of silently returning defaults-only results (issue #2079).""" @pytest.fixture(autouse=True) def clean_env(self): original_env = { k: v for k, v in os.environ.items() if k.startswith("LDR_") } for key in list(os.environ.keys()): if key.startswith("LDR_"): os.environ.pop(key, None) yield for key in list(os.environ.keys()): if key.startswith("LDR_"): os.environ.pop(key, None) for key, value in original_env.items(): os.environ[key] = value def test_db_error_returns_defaults_gracefully(self): """SQLAlchemyError from __query_settings is caught; defaults are returned.""" from sqlalchemy.exc import SQLAlchemyError mock_session = MagicMock() mock_session.query.return_value.count.return_value = 1 mock_session.query.return_value.all.side_effect = SQLAlchemyError( "connection lost" ) manager = SettingsManager(db_session=mock_session) with patch.object( type(manager), "default_settings", new_callable=PropertyMock, return_value={"app.key": {"value": "default"}}, ): # get_all_settings gracefully degrades: catches DB errors, # logs them, and returns defaults only result = manager.get_all_settings() assert isinstance(result, dict) assert "app.key" in result class TestFilterSettingColumns: """Tests for _filter_setting_columns column filtering helper.""" def test_preserves_valid_columns(self): """Valid keys like value, name, ui_element, type pass through unchanged.""" data = { "value": "test_val", "name": "Test Name", "ui_element": "text", "type": "APP", } result = _filter_setting_columns(data) assert result == data def test_filters_out_invalid_columns(self): """Unknown keys like future_flag, internal_id are removed.""" data = {"future_flag": True, "internal_id": 999} result = _filter_setting_columns(data) assert result == {} def test_mixed_valid_and_invalid_keys(self): """Only valid keys kept, invalid ones dropped, values preserved.""" data = { "key": "app.debug", "value": True, "name": "Debug", "unknown_field": "should_be_removed", "another_bad": 42, } result = _filter_setting_columns(data) assert result == {"key": "app.debug", "value": True, "name": "Debug"} def test_empty_dict(self): """Empty dict returns empty dict.""" assert _filter_setting_columns({}) == {} def test_all_invalid_keys(self): """Dict with only unknown keys returns empty dict.""" data = {"foo": 1, "bar": 2, "baz": 3} assert _filter_setting_columns(data) == {} def test_preserves_none_values(self): """Valid keys with None values are kept (not confused with invalid).""" data = {"value": None, "description": None, "options": None} result = _filter_setting_columns(data) assert result == data assert all(v is None for v in result.values()) def test_preserves_various_value_types(self): """Handles str, bool, int, float, list, dict, None values correctly.""" data = { "key": "test.key", "value": 3.14, "name": "Test", "visible": True, "options": ["a", "b"], "description": None, "min_value": 0, "max_value": 100, } result = _filter_setting_columns(data) assert result == data def test_does_not_mutate_input(self): """Original dict is unchanged after filtering.""" data = { "key": "test", "value": "val", "unknown_extra": "should_drop", } original = data.copy() _filter_setting_columns(data) assert data == original def test_valid_columns_match_setting_model(self): """Sanity check: the function recognizes all known Setting model columns.""" expected_columns = { "id", "key", "value", "type", "name", "description", "category", "ui_element", "options", "min_value", "max_value", "step", "visible", "editable", "env_var", "created_at", "updated_at", } # Build a dict with all expected columns as keys data = {col: f"test_{col}" for col in expected_columns} result = _filter_setting_columns(data) # All expected columns should survive filtering assert set(result.keys()) == expected_columns class TestSettingsKeyValidation: """Write-side guard against malformed keys. A trailing-dot key (e.g. ``local_search_chunk_size.``) makes ``get_setting`` return a ``{"": value}`` wrapper dict that the UI renders as ``[object Object]`` (#4840). PR #4852 fixed the READ path to tolerate such rows; these tests cover the WRITE path so new ones can't be created. """ @pytest.fixture(autouse=True) def clean_env(self): """Strip LDR_* env vars so e.g. LDR_APP_LOCK_SETTINGS can't lock the manager and make the reject tests pass for the wrong reason (or break the well-formed-key sanity test). Matches the sibling classes.""" original_env = { k: v for k, v in os.environ.items() if k.startswith("LDR_") } for key in list(os.environ.keys()): if key.startswith("LDR_"): os.environ.pop(key, None) yield for key in list(os.environ.keys()): if key.startswith("LDR_"): os.environ.pop(key, None) for key, value in original_env.items(): os.environ[key] = value @pytest.fixture def session(self): from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from local_deep_research.database.models import Base engine = create_engine("sqlite:///:memory:") Base.metadata.create_all(engine) Session = sessionmaker(bind=engine) session = Session() yield session session.close() engine.dispose() def test_is_valid_setting_key_accepts_wellformed(self): for key in ( "llm", "llm.provider", "search.max_results", "local_search_chunk_size", "embeddings.ollama.url", "app.debug", ): assert is_valid_setting_key(key) is True, key def test_is_valid_setting_key_rejects_malformed(self): for key in ( "", " ", "llm.", # trailing dot — the #4840 shape ".llm", # leading dot "llm..provider", # empty segment "llm.provider ", # trailing whitespace " llm.provider", # leading whitespace "llm. provider", # whitespace around a dot "llm.pro vider", # whitespace inside a segment "llm.\txyz", # tab None, 42, ): assert is_valid_setting_key(key) is False, key def test_set_setting_rejects_malformed_new_key(self, session): from local_deep_research.database.models import Setting manager = SettingsManager(db_session=session) result = manager.set_setting( "local_search_chunk_size.", 1000, commit=False ) assert result is False assert ( session.query(Setting) .filter(Setting.key == "local_search_chunk_size.") .first() is None ) def test_set_setting_still_creates_valid_new_key(self, session): """Sanity: the guard doesn't block well-formed new keys.""" from local_deep_research.database.models import Setting manager = SettingsManager(db_session=session) result = manager.set_setting("search.good_key", 5, commit=False) assert result is True assert ( session.query(Setting) .filter(Setting.key == "search.good_key") .first() is not None ) def test_create_or_update_setting_rejects_malformed_new_key(self, session): from local_deep_research.database.models import Setting manager = SettingsManager(db_session=session) result = manager.create_or_update_setting( { "key": "local_search_chunk_size.", "value": "x", "name": "Malformed", "ui_element": "text", }, commit=False, ) assert result is None assert ( session.query(Setting) .filter(Setting.key == "local_search_chunk_size.") .first() is None ) def test_create_or_update_setting_still_creates_valid_new_key( self, session ): """Positive control for the BaseSetting→Setting create path, so the reject test above can't pass merely because creation is broken.""" from local_deep_research.database.models import Setting manager = SettingsManager(db_session=session) result = manager.create_or_update_setting( { "key": "local_search_chunk_size", "value": "1000", "name": "Chunk size", "ui_element": "number", }, commit=False, ) assert result is not None assert ( session.query(Setting) .filter(Setting.key == "local_search_chunk_size") .first() is not None ) def test_import_settings_skips_malformed_keys(self, session): from local_deep_research.database.models import Setting manager = SettingsManager(db_session=session) manager.import_settings( { "local_search_chunk_size.": { "value": "bad", "name": "Bad", "ui_element": "text", "type": "SEARCH", }, "search.good_key": { "value": "ok", "name": "Good", "ui_element": "text", "type": "SEARCH", }, }, commit=False, ) # The malformed key was skipped; the well-formed one was imported. assert ( session.query(Setting) .filter(Setting.key == "local_search_chunk_size.") .first() is None ) assert ( session.query(Setting) .filter(Setting.key == "search.good_key") .first() is not None ) def test_existing_malformed_row_stays_updatable(self, session): """The guard is create-only: a pre-existing malformed row (e.g. a legacy ``llm.`` key) must stay updatable so users who already have one aren't wedged — only *creating* new malformed keys is blocked.""" from local_deep_research.database.models import Setting # Seed a malformed row directly, bypassing the guard. session.add( Setting( key="llm.", name="Legacy dot", value="old", ui_element="text", type="LLM", editable=True, ) ) session.commit() manager = SettingsManager(db_session=session) # set_setting hits the UPDATE branch (row exists), not the create guard. assert manager.set_setting("llm.", "new", commit=False) is True assert ( session.query(Setting).filter(Setting.key == "llm.").first().value == "new" ) # create_or_update_setting on the existing row updates too (not rejected). updated = manager.create_or_update_setting( {"key": "llm.", "value": "newer", "name": "Legacy dot"}, commit=False, ) assert updated is not None assert ( session.query(Setting).filter(Setting.key == "llm.").first().value == "newer" ) class TestSettingsManagerPrefixQueriesRealDB: """Integration tests for SettingsManager querying prefix keys using a real SQLite DB.""" @pytest.fixture(autouse=True) def clean_env(self): """Strip LDR_* env vars so e.g. LDR_LLM_PROVIDER can't override the seeded values and flake the positive assertions.""" original_env = { k: v for k, v in os.environ.items() if k.startswith("LDR_") } for key in list(os.environ.keys()): if key.startswith("LDR_"): os.environ.pop(key, None) yield for key in list(os.environ.keys()): if key.startswith("LDR_"): os.environ.pop(key, None) for key, value in original_env.items(): os.environ[key] = value @pytest.fixture def session(self): from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from local_deep_research.database.models import Base engine = create_engine("sqlite:///:memory:") Base.metadata.create_all(engine) Session = sessionmaker(bind=engine) session = Session() yield session session.close() engine.dispose() def test_get_setting_nested_key_prefix_matching(self, session): """Test that get_setting prefix query matches exact and subkeys correctly.""" from local_deep_research.database.models import Setting # Add a mix of settings: # 1. Matches prefix: llm.provider, llm.temperature # 2. Key ending in dot: llm. (malformed duplicate row) # 3. Completely unrelated: app.debug session.add_all( [ Setting( key="llm.provider", name="LLM Provider", value="openai", ui_element="select", type="SEARCH", ), Setting( key="llm.temperature", name="LLM Temperature", value="0.7", ui_element="number", type="SEARCH", ), Setting( key="llm.", name="LLM dot", value="should_be_ignored", ui_element="text", type="SEARCH", ), Setting( key="app.debug", name="App Debug", value="true", ui_element="checkbox", type="APP", ), ] ) session.commit() manager = SettingsManager(db_session=session) # Retrieve settings under "llm" namespace result = manager.get_setting("llm") assert isinstance(result, dict) assert result.get("provider") == "openai" assert result.get("temperature") == 0.7 # The key "llm." should NOT match as a subkey of "llm" (i.e. we should not have an empty string key in result) assert "" not in result def test_malformed_subkey_rows_dont_wrap_a_leaf_read(self, session): """A pathological pre-existing malformed row (``foo..`` / ``foo. ``) must not turn a leaf read into a wrapper dict rendered as ``[object Object]``. #4852 excludes only the exact single-trailing-dot row; this covers the residual double-dot / dot-space shapes (#4840).""" from local_deep_research.database.models import Setting session.add_all( [ Setting( key="local_search_chunk_size", name="Chunk size", value="1000", ui_element="number", type="SEARCH", ), # Double-dot: slips past #4852's `!= "foo."` exclusion. Setting( key="local_search_chunk_size..", name="Malformed dd", value="junk", ui_element="text", type="SEARCH", ), # Dot-space: also slips past #4852. Setting( key="local_search_chunk_size. ", name="Malformed ds", value="junk", ui_element="text", type="SEARCH", ), ] ) session.commit() manager = SettingsManager(db_session=session) result = manager.get_setting("local_search_chunk_size") # Resolves to the scalar value, not a `{".": ...}` wrapper dict. assert not isinstance(result, dict) assert result == 1000 def test_prefix_read_with_only_malformed_rows_returns_default( self, session ): """A prefix whose only matching rows are malformed resolves to the default — mirrors test_only_malformed_child_returns_default on the snapshot path.""" from local_deep_research.database.models import Setting session.add( Setting( key="orphan..", name="Malformed only", value="junk", ui_element="text", type="SEARCH", ) ) session.commit() manager = SettingsManager(db_session=session) assert manager.get_setting("orphan", default="fallback") == "fallback" def test_direct_read_of_malformed_key_still_works(self, session): """The read filter's exact-match clause keeps a malformed row readable by its own key (legacy rows stay accessible, just invisible to prefix reads).""" from local_deep_research.database.models import Setting session.add( Setting( key="legacy. ", name="Malformed direct", value="direct", ui_element="text", type="SEARCH", ) ) session.commit() manager = SettingsManager(db_session=session) assert manager.get_setting("legacy. ") == "direct"