7a0da7932b
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
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
255 lines
9.0 KiB
Python
255 lines
9.0 KiB
Python
"""Tests for thread_settings module."""
|
|
|
|
import threading
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from local_deep_research.config.thread_settings import (
|
|
NoSettingsContextError,
|
|
clear_settings_context,
|
|
get_setting_from_snapshot,
|
|
get_settings_context,
|
|
set_settings_context,
|
|
settings_context,
|
|
_thread_local,
|
|
)
|
|
|
|
|
|
class TestNoSettingsContextError:
|
|
"""Tests for NoSettingsContextError exception."""
|
|
|
|
def test_is_exception(self):
|
|
"""Should be an Exception subclass."""
|
|
assert issubclass(NoSettingsContextError, Exception)
|
|
|
|
def test_can_be_raised_with_message(self):
|
|
"""Should accept error message."""
|
|
with pytest.raises(NoSettingsContextError) as exc_info:
|
|
raise NoSettingsContextError("test message")
|
|
assert "test message" in str(exc_info.value)
|
|
|
|
|
|
class TestSetSettingsContext:
|
|
"""Tests for set_settings_context function."""
|
|
|
|
def test_sets_context_on_thread_local(self, clean_thread_local):
|
|
"""Should set settings_context on thread local."""
|
|
context = MagicMock()
|
|
set_settings_context(context)
|
|
assert _thread_local.settings_context is context
|
|
|
|
def test_overwrites_previous_context(self, clean_thread_local):
|
|
"""Should overwrite previous context."""
|
|
context1 = MagicMock()
|
|
context2 = MagicMock()
|
|
set_settings_context(context1)
|
|
set_settings_context(context2)
|
|
assert _thread_local.settings_context is context2
|
|
|
|
|
|
class TestGetSettingsContext:
|
|
"""Tests for get_settings_context function."""
|
|
|
|
def test_returns_context_when_set(self, clean_thread_local):
|
|
"""Should return context when set."""
|
|
context = MagicMock()
|
|
set_settings_context(context)
|
|
assert get_settings_context() is context
|
|
|
|
def test_returns_none_when_not_set(self, clean_thread_local):
|
|
"""Should return None when no context set."""
|
|
assert get_settings_context() is None
|
|
|
|
|
|
class TestGetSettingFromSnapshot:
|
|
"""Tests for get_setting_from_snapshot function."""
|
|
|
|
def test_returns_value_from_snapshot(self, clean_thread_local):
|
|
"""Should return value from settings_snapshot."""
|
|
snapshot = {"test.key": "test_value"}
|
|
result = get_setting_from_snapshot(
|
|
"test.key", settings_snapshot=snapshot
|
|
)
|
|
assert result == "test_value"
|
|
|
|
def test_handles_full_format_value(self, clean_thread_local):
|
|
"""Should extract value from full format dict."""
|
|
snapshot = {
|
|
"test.key": {"value": "extracted_value", "ui_element": "text"}
|
|
}
|
|
result = get_setting_from_snapshot(
|
|
"test.key", settings_snapshot=snapshot
|
|
)
|
|
assert result == "extracted_value"
|
|
|
|
def test_returns_default_when_key_not_found(self, clean_thread_local):
|
|
"""Should return default when key not in snapshot."""
|
|
snapshot = {"other.key": "value"}
|
|
result = get_setting_from_snapshot(
|
|
"test.key", default="default_val", settings_snapshot=snapshot
|
|
)
|
|
assert result == "default_val"
|
|
|
|
def test_builds_dict_from_child_keys(self, clean_thread_local):
|
|
"""Should build dict from child keys."""
|
|
snapshot = {
|
|
"parent.child1": "value1",
|
|
"parent.child2": "value2",
|
|
}
|
|
result = get_setting_from_snapshot("parent", settings_snapshot=snapshot)
|
|
assert result == {"child1": "value1", "child2": "value2"}
|
|
|
|
def test_extracts_value_from_child_key_full_format(
|
|
self, clean_thread_local
|
|
):
|
|
"""Should extract value from child keys with full format."""
|
|
snapshot = {
|
|
"parent.child": {"value": "extracted", "ui_element": "text"},
|
|
}
|
|
result = get_setting_from_snapshot("parent", settings_snapshot=snapshot)
|
|
assert result == {"child": "extracted"}
|
|
|
|
def test_uses_thread_context_when_no_snapshot(
|
|
self, clean_thread_local, mock_settings_context
|
|
):
|
|
"""Should use thread-local context when no snapshot."""
|
|
set_settings_context(mock_settings_context)
|
|
mock_settings_context.get_setting.return_value = "context_value"
|
|
result = get_setting_from_snapshot("test.key")
|
|
assert result == "context_value"
|
|
mock_settings_context.get_setting.assert_called_with("test.key", None)
|
|
|
|
def test_extracts_value_from_context_dict(
|
|
self, clean_thread_local, mock_settings_context
|
|
):
|
|
"""Should extract value from context dict format."""
|
|
set_settings_context(mock_settings_context)
|
|
mock_settings_context.get_setting.return_value = {
|
|
"value": "extracted_value"
|
|
}
|
|
result = get_setting_from_snapshot("test.key")
|
|
assert result == "extracted_value"
|
|
|
|
def test_raises_when_no_context_and_no_default(self, clean_thread_local):
|
|
"""Should raise NoSettingsContextError when no context and no default."""
|
|
with pytest.raises(NoSettingsContextError) as exc_info:
|
|
get_setting_from_snapshot("test.key")
|
|
assert "test.key" in str(exc_info.value)
|
|
|
|
def test_returns_default_instead_of_raising(self, clean_thread_local):
|
|
"""Should return default instead of raising when provided."""
|
|
result = get_setting_from_snapshot("test.key", default="my_default")
|
|
assert result == "my_default"
|
|
|
|
def test_returns_none_when_snapshot_value_is_none(self, clean_thread_local):
|
|
"""Key present in snapshot with value None should return None,
|
|
not raise NoSettingsContextError. Regression for #4208: the
|
|
OpenAI embeddings test endpoint blew up here because
|
|
embeddings.openai.dimensions defaults to JSON null."""
|
|
snapshot = {
|
|
"embeddings.openai.dimensions": {
|
|
"value": None,
|
|
"ui_element": "number",
|
|
}
|
|
}
|
|
result = get_setting_from_snapshot(
|
|
"embeddings.openai.dimensions",
|
|
default=None,
|
|
settings_snapshot=snapshot,
|
|
)
|
|
assert result is None
|
|
|
|
def test_returns_none_when_simplified_snapshot_value_is_none(
|
|
self, clean_thread_local
|
|
):
|
|
"""Simplified-format snapshot (raw value, not {value: ...} dict)
|
|
with explicit None should also return None, not raise."""
|
|
snapshot = {"some.key": None}
|
|
result = get_setting_from_snapshot(
|
|
"some.key", default=None, settings_snapshot=snapshot
|
|
)
|
|
assert result is None
|
|
|
|
|
|
class TestClearSettingsContext:
|
|
"""Tests for clear_settings_context function."""
|
|
|
|
def test_clears_existing_context(self, clean_thread_local):
|
|
"""Should clear context when set."""
|
|
set_settings_context(MagicMock())
|
|
clear_settings_context()
|
|
assert get_settings_context() is None
|
|
|
|
def test_noop_when_no_context(self, clean_thread_local):
|
|
"""Should not raise when no context is set."""
|
|
clear_settings_context()
|
|
|
|
|
|
class TestSettingsContextManager:
|
|
"""Tests for settings_context context manager."""
|
|
|
|
def test_sets_and_clears_context(self, clean_thread_local):
|
|
"""Context should be set inside and cleared after the block."""
|
|
ctx = MagicMock()
|
|
with settings_context(ctx):
|
|
assert get_settings_context() is ctx
|
|
|
|
assert get_settings_context() is None
|
|
|
|
def test_clears_on_exception(self, clean_thread_local):
|
|
"""Context should be cleared even when an exception occurs."""
|
|
ctx = MagicMock()
|
|
with pytest.raises(ValueError):
|
|
with settings_context(ctx):
|
|
raise ValueError("boom")
|
|
|
|
assert get_settings_context() is None
|
|
|
|
def test_works_in_thread(self, clean_thread_local):
|
|
"""Context manager should work correctly in a separate thread."""
|
|
results = []
|
|
|
|
def worker():
|
|
ctx = MagicMock()
|
|
ctx.name = "thread-ctx"
|
|
with settings_context(ctx):
|
|
results.append(get_settings_context().name)
|
|
results.append(get_settings_context())
|
|
|
|
t = threading.Thread(target=worker)
|
|
t.start()
|
|
t.join()
|
|
|
|
assert results[0] == "thread-ctx"
|
|
assert results[1] is None
|
|
|
|
|
|
class TestThreadIsolation:
|
|
"""Tests for thread isolation of settings context."""
|
|
|
|
def test_contexts_isolated_between_threads(self, clean_thread_local):
|
|
"""Should isolate contexts between threads."""
|
|
results = {}
|
|
|
|
def thread_func(name, context_value):
|
|
context = MagicMock()
|
|
context.get_setting.return_value = context_value
|
|
set_settings_context(context)
|
|
# Small delay to ensure overlap
|
|
import time
|
|
|
|
time.sleep(0.01)
|
|
results[name] = get_settings_context().get_setting("key", None)
|
|
|
|
thread1 = threading.Thread(target=thread_func, args=("t1", "value1"))
|
|
thread2 = threading.Thread(target=thread_func, args=("t2", "value2"))
|
|
|
|
thread1.start()
|
|
thread2.start()
|
|
thread1.join()
|
|
thread2.join()
|
|
|
|
assert results["t1"] == "value1"
|
|
assert results["t2"] == "value2"
|