Files
wehub-resource-sync 7a0da7932b
Backwards Compatibility / Verify Encryption Constants (push) Waiting to run
Backwards Compatibility / PyPI Version Compatibility (push) Waiting to run
Backwards Compatibility / Database Migration Tests (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Blocked by required conditions
Docker Tests (Consolidated) / detect-changes (push) Waiting to run
Docker Tests (Consolidated) / Build Test Image (push) Waiting to run
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Blocked by required conditions
Docker Tests (Consolidated) / Accessibility Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Unit Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Example Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / Production Image Smoke Test (push) Blocked by required conditions
Docker Tests (Consolidated) / Infrastructure Tests (push) Blocked by required conditions
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Waiting to run
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

2495 lines
88 KiB
Python

"""
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"