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
2495 lines
88 KiB
Python
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"
|