Files
learningcircuit--local-deep…/tests/web/routes/test_settings_routes_coverage.py
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

1684 lines
66 KiB
Python

"""
Comprehensive coverage tests for settings_routes.py.
Targets uncovered code paths including:
- save_all_settings (complex branching: corrupted values, setting types, new settings)
- save_settings (POST fallback)
- api_get_all_settings (category filtering)
- api_get_db_setting / api_update_setting / api_delete_setting
- api_import_settings / api_get_categories / api_get_types / api_get_ui_elements
- fix_corrupted_settings
- api_get_available_models (cache, Ollama, OpenAI, Anthropic, auto-discovery)
- api_get_available_search_engines
- api_get/update search favorites, toggle favorite
- get_bulk_settings
- api_get_data_location
- api_test_notification_url
- check_ollama_status
- _get_engine_icon_and_category
- legacy redirect routes
- open_file_location
- inject_csrf_token
Note: rate-limiting endpoints (/api/rate-limiting/status, /reset, /cleanup)
are covered in the dedicated test_settings_routes_rate_limiting.py, not here,
to keep one canonical home per endpoint and avoid the duplicate-test drift that
previously occurred for /api/rate-limiting/cleanup (#4735).
"""
from unittest.mock import patch, MagicMock
import pytest
from ._settings_route_helpers import _make_setting
# ---------------------------------------------------------------------------
# _get_engine_icon_and_category (pure function, no Flask context needed)
# ---------------------------------------------------------------------------
class TestGetEngineIconAndCategory:
"""Tests for the _get_engine_icon_and_category helper."""
def _call(self, engine_data, engine_class=None):
from local_deep_research.web.routes.settings_routes import (
_get_engine_icon_and_category,
)
return _get_engine_icon_and_category(engine_data, engine_class)
def test_local_engine(self):
icon, cat = self._call({"is_local": True, "is_scientific": True})
assert cat == "Local RAG"
def test_scientific_engine(self):
icon, cat = self._call({"is_scientific": True})
assert cat == "Scientific"
def test_news_engine(self):
icon, cat = self._call({"is_news": True})
assert cat == "News"
def test_code_engine(self):
icon, cat = self._call({"is_code": True})
assert cat == "Code"
def test_generic_engine(self):
icon, cat = self._call({"is_generic": True})
assert cat == "Web Search"
def test_default_engine(self):
icon, cat = self._call({})
assert cat == "Search"
def test_engine_class_attributes(self):
cls = MagicMock()
cls.is_scientific = False
cls.is_generic = False
cls.is_local = False
cls.is_news = True
cls.is_code = False
icon, cat = self._call({}, engine_class=cls)
assert cat == "News"
def test_engine_class_code(self):
cls = MagicMock()
cls.is_scientific = False
cls.is_generic = False
cls.is_local = False
cls.is_news = False
cls.is_code = True
icon, cat = self._call({}, engine_class=cls)
assert cat == "Code"
def test_priority_local_over_scientific(self):
"""Local takes priority over scientific."""
icon, cat = self._call(
{"is_local": True, "is_scientific": True, "is_news": True}
)
assert cat == "Local RAG"
# =========================================================================
# Flask route tests — use the app/authenticated_client fixtures from conftest
# =========================================================================
SETTINGS_PREFIX = "/settings"
ROUTES_MODULE = "local_deep_research.web.routes.settings_routes"
DECORATOR_MODULE = "local_deep_research.web.utils.route_decorators"
class TestSettingsPage:
"""GET /settings/"""
def test_requires_auth(self, client):
resp = client.get(f"{SETTINGS_PREFIX}/")
assert resp.status_code in [302, 401]
@patch(f"{ROUTES_MODULE}.render_template_with_defaults", return_value="ok")
def test_returns_page(self, mock_render, authenticated_client):
resp = authenticated_client.get(f"{SETTINGS_PREFIX}/")
assert resp.status_code == 200
class TestLegacyRedirects:
"""Legacy redirect routes.
Note: research_routes also registers these same paths under /settings/,
so the settings_routes versions are shadowed in the Flask app.
We test the functions directly instead.
"""
def test_main_config_redirects(self, app):
from local_deep_research.web.routes.settings_routes import (
main_config_page,
)
with app.test_request_context():
from flask import session
session["username"] = "test"
resp = main_config_page()
assert resp.status_code == 302
def test_collections_config_redirects(self, app):
from local_deep_research.web.routes.settings_routes import (
collections_config_page,
)
with app.test_request_context():
from flask import session
session["username"] = "test"
resp = collections_config_page()
assert resp.status_code == 302
def test_api_keys_config_redirects(self, app):
from local_deep_research.web.routes.settings_routes import (
api_keys_config_page,
)
with app.test_request_context():
from flask import session
session["username"] = "test"
resp = api_keys_config_page()
assert resp.status_code == 302
def test_search_engines_config_redirects(self, app):
from local_deep_research.web.routes.settings_routes import (
search_engines_config_page,
)
with app.test_request_context():
from flask import session
session["username"] = "test"
resp = search_engines_config_page()
assert resp.status_code == 302
class TestOpenFileLocation:
"""POST /settings/open_file_location"""
def test_disabled(self, authenticated_client):
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/open_file_location"
)
assert resp.status_code == 403
data = resp.get_json()
assert data["status"] == "error"
class TestApiGetTypes:
"""GET /settings/api/types"""
def test_returns_types(self, authenticated_client):
resp = authenticated_client.get(f"{SETTINGS_PREFIX}/api/types")
assert resp.status_code == 200
data = resp.get_json()
assert "types" in data
class TestApiGetUiElements:
"""GET /settings/api/ui_elements"""
def test_returns_ui_elements(self, authenticated_client):
resp = authenticated_client.get(f"{SETTINGS_PREFIX}/api/ui_elements")
assert resp.status_code == 200
data = resp.get_json()
assert "ui_elements" in data
assert "text" in data["ui_elements"]
assert "checkbox" in data["ui_elements"]
class TestApiGetCategories:
"""GET /settings/api/categories"""
def test_returns_categories(self, authenticated_client):
resp = authenticated_client.get(f"{SETTINGS_PREFIX}/api/categories")
assert resp.status_code == 200
data = resp.get_json()
assert "categories" in data
class TestApiImportSettings:
"""POST /settings/api/import"""
def test_import_success(self, authenticated_client):
resp = authenticated_client.post(f"{SETTINGS_PREFIX}/api/import")
assert resp.status_code == 200
data = resp.get_json()
assert (
"imported" in data.get("message", "").lower()
or "success" in data.get("message", "").lower()
)
class TestResetToDefaults:
"""POST /settings/reset_to_defaults"""
def test_reset_success(self, authenticated_client):
resp = authenticated_client.post(f"{SETTINGS_PREFIX}/reset_to_defaults")
assert resp.status_code == 200
data = resp.get_json()
assert data["status"] == "success"
@patch(f"{DECORATOR_MODULE}.SettingsManager")
@patch(f"{DECORATOR_MODULE}.get_user_db_session")
def test_reset_error(self, mock_ctx, mock_sm, authenticated_client):
mock_ctx.side_effect = RuntimeError("db fail")
resp = authenticated_client.post(f"{SETTINGS_PREFIX}/reset_to_defaults")
assert resp.status_code == 500
assert resp.get_json()["error"] == "Database session unavailable"
class TestApiGetAllSettings:
"""GET /settings/api"""
def test_returns_settings(self, authenticated_client):
resp = authenticated_client.get(f"{SETTINGS_PREFIX}/api")
assert resp.status_code == 200
data = resp.get_json()
assert data["status"] == "success"
assert "settings" in data
def test_filter_by_category(self, authenticated_client):
resp = authenticated_client.get(
f"{SETTINGS_PREFIX}/api?category=llm_general"
)
assert resp.status_code == 200
data = resp.get_json()
assert data["status"] == "success"
class TestApiGetDbSetting:
"""GET /settings/api/<key>"""
def test_setting_found(self, authenticated_client):
resp = authenticated_client.get(f"{SETTINGS_PREFIX}/api/llm.model")
# May return 200 or 404 depending on whether setting exists in test db
assert resp.status_code in [200, 404]
def test_setting_not_found(self, authenticated_client):
resp = authenticated_client.get(
f"{SETTINGS_PREFIX}/api/nonexistent.key.xyz"
)
assert resp.status_code == 404
data = resp.get_json()
assert "error" in data
class TestApiUpdateSetting:
"""PUT /settings/api/<key>"""
def test_no_json_body_returns_400(self, authenticated_client):
resp = authenticated_client.put(
f"{SETTINGS_PREFIX}/api/llm.model",
data="not json",
content_type="text/plain",
)
assert resp.status_code == 400
def test_no_value_returns_400(self, authenticated_client):
resp = authenticated_client.put(
f"{SETTINGS_PREFIX}/api/llm.model",
json={"no_value_key": "x"},
)
assert resp.status_code == 400
def test_non_editable_setting_returns_403(self, authenticated_client):
"""Setting exists but is not editable."""
setting = _make_setting(key="locked.setting", editable=False)
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.first.return_value = setting
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.put(
f"{SETTINGS_PREFIX}/api/locked.setting",
json={"value": "new"},
)
assert resp.status_code == 403
@patch(f"{ROUTES_MODULE}.calculate_warnings", return_value=[])
@patch(f"{ROUTES_MODULE}.set_setting", return_value=True)
@patch(f"{ROUTES_MODULE}.coerce_setting_for_write", return_value="openai")
@patch(f"{ROUTES_MODULE}.validate_setting", return_value=(True, None))
def test_update_warning_affecting_key(
self,
mock_validate,
mock_coerce,
mock_set,
mock_warnings,
authenticated_client,
):
"""Updating a warning-affecting key includes warnings in response."""
setting = _make_setting(
key="llm.provider", value="ollama", editable=True
)
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.first.return_value = setting
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.put(
f"{SETTINGS_PREFIX}/api/llm.provider",
json={"value": "openai"},
)
assert resp.status_code == 200
data = resp.get_json()
assert "warnings" in data
@patch(f"{ROUTES_MODULE}.create_or_update_setting")
def test_create_new_setting_via_put(
self, mock_create, authenticated_client
):
"""PUT creates a new setting when key doesn't exist."""
mock_new = _make_setting(key="llm.new_setting", value="v")
mock_new.type = MagicMock()
mock_new.type.value = "app"
mock_create.return_value = mock_new
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.first.return_value = None
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.put(
f"{SETTINGS_PREFIX}/api/llm.new_setting",
json={"value": "hello", "type": "app"},
)
assert resp.status_code == 201
@patch(f"{ROUTES_MODULE}.create_or_update_setting", return_value=None)
def test_create_new_setting_fails(self, mock_create, authenticated_client):
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.first.return_value = None
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.put(
f"{SETTINGS_PREFIX}/api/llm.new_fail",
json={"value": "hello"},
)
assert resp.status_code == 500
@patch(f"{ROUTES_MODULE}.set_setting", return_value=False)
@patch(f"{ROUTES_MODULE}.coerce_setting_for_write", return_value="v")
@patch(f"{ROUTES_MODULE}.validate_setting", return_value=(True, None))
def test_update_fails_returns_500(
self, mock_v, mock_c, mock_s, authenticated_client
):
setting = _make_setting(key="test.x", editable=True)
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.first.return_value = setting
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.put(
f"{SETTINGS_PREFIX}/api/test.x",
json={"value": "v"},
)
assert resp.status_code == 500
@patch(f"{ROUTES_MODULE}.coerce_setting_for_write", return_value="bad")
@patch(
f"{ROUTES_MODULE}.validate_setting", return_value=(False, "bad value")
)
def test_validation_failure_returns_400(
self, mock_v, mock_c, authenticated_client
):
setting = _make_setting(key="test.x", editable=True)
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.first.return_value = setting
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.put(
f"{SETTINGS_PREFIX}/api/test.x",
json={"value": "bad"},
)
assert resp.status_code == 400
class TestApiDeleteSetting:
"""DELETE /settings/api/<key>"""
def test_not_found(self, authenticated_client):
resp = authenticated_client.delete(
f"{SETTINGS_PREFIX}/api/nonexistent.xyz.abc"
)
assert resp.status_code == 404
@patch(f"{DECORATOR_MODULE}.SettingsManager")
def test_non_editable(self, mock_sm, authenticated_client):
setting = _make_setting(key="locked", editable=False)
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.first.return_value = setting
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.delete(f"{SETTINGS_PREFIX}/api/locked")
assert resp.status_code == 403
@patch(f"{DECORATOR_MODULE}.SettingsManager")
def test_delete_success(self, mock_sm_cls, authenticated_client):
setting = _make_setting(key="del.me", editable=True)
mock_sm_instance = MagicMock()
mock_sm_instance.delete_setting.return_value = True
mock_sm_cls.return_value = mock_sm_instance
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.first.return_value = setting
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.delete(f"{SETTINGS_PREFIX}/api/del.me")
assert resp.status_code == 200
@patch(f"{DECORATOR_MODULE}.SettingsManager")
def test_delete_fails(self, mock_sm_cls, authenticated_client):
setting = _make_setting(key="del.fail", editable=True)
mock_sm_instance = MagicMock()
mock_sm_instance.delete_setting.return_value = False
mock_sm_cls.return_value = mock_sm_instance
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.first.return_value = setting
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.delete(
f"{SETTINGS_PREFIX}/api/del.fail"
)
assert resp.status_code == 500
class TestSaveAllSettings:
"""POST /settings/save_all_settings"""
def test_no_json_body(self, authenticated_client):
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/save_all_settings",
data="not json",
content_type="text/plain",
)
assert resp.status_code == 400
@patch(f"{ROUTES_MODULE}.set_setting", return_value=True)
@patch(
f"{ROUTES_MODULE}.coerce_setting_for_write",
side_effect=lambda key, value, ui_element: value,
)
@patch(f"{ROUTES_MODULE}.validate_setting", return_value=(True, None))
def test_update_existing_setting(
self, mock_v, mock_c, mock_s, authenticated_client
):
"""Update an existing editable setting."""
setting = _make_setting(
key="llm.temperature", value=0.7, ui_element="number", editable=True
)
setting.type = "llm"
all_settings_after = [setting]
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
# First call: fetch all settings; second call: return all for response
mock_session.query.return_value.all.side_effect = [
[setting], # all_db_settings
all_settings_after, # response settings
]
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/save_all_settings",
json={"llm.temperature": 0.5},
)
assert resp.status_code == 200
data = resp.get_json()
assert data["status"] == "success"
@patch(f"{ROUTES_MODULE}.set_setting", return_value=True)
@patch(
f"{ROUTES_MODULE}.coerce_setting_for_write",
side_effect=lambda key, value, ui_element: value,
)
@patch(f"{ROUTES_MODULE}.validate_setting", return_value=(True, None))
@patch(f"{ROUTES_MODULE}.parse_boolean", return_value=True)
def test_checkbox_conversion(
self, mock_pb, mock_v, mock_c, mock_s, authenticated_client
):
"""Checkbox string value gets converted to bool."""
setting = _make_setting(
key="search.safe_search",
value=False,
ui_element="checkbox",
editable=True,
)
setting.type = "search"
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_session.query.return_value.all.side_effect = [
[setting],
[setting],
]
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/save_all_settings",
json={"search.safe_search": "true"},
)
assert resp.status_code == 200
def test_corrupted_value_object_object(self, authenticated_client):
"""[object Object] gets corrected."""
setting = _make_setting(
key="llm.model", value="gpt-4", ui_element="text", editable=True
)
setting.type = "llm"
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_session.query.return_value.all.side_effect = [
[setting],
[setting],
]
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
with patch(
f"{ROUTES_MODULE}.coerce_setting_for_write",
return_value="gpt-3.5-turbo",
):
with patch(
f"{ROUTES_MODULE}.validate_setting",
return_value=(True, None),
):
with patch(
f"{ROUTES_MODULE}.set_setting", return_value=True
):
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/save_all_settings",
json={"llm.model": "[object Object]"},
)
assert resp.status_code == 200
def test_corrupted_report_value(self, authenticated_client):
"""Corrupted report value gets set to empty dict."""
setting = _make_setting(
key="report.structure", value={}, ui_element="json", editable=True
)
setting.type = "report"
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_session.query.return_value.all.side_effect = [
[setting],
[setting],
]
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
with patch(
f"{ROUTES_MODULE}.coerce_setting_for_write", return_value={}
):
with patch(
f"{ROUTES_MODULE}.validate_setting",
return_value=(True, None),
):
with patch(
f"{ROUTES_MODULE}.set_setting", return_value=True
):
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/save_all_settings",
json={"report.structure": "{}"},
)
assert resp.status_code == 200
@patch(f"{ROUTES_MODULE}.create_or_update_setting")
def test_create_new_setting(self, mock_create, authenticated_client):
"""Creating a new setting when key not in DB."""
mock_new = _make_setting(key="llm.new_param", value="v")
mock_new.type = "llm"
mock_create.return_value = mock_new
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_session.query.return_value.all.side_effect = [
[], # no existing settings
[], # response
]
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/save_all_settings",
json={"llm.new_param": "value"},
)
assert resp.status_code == 200
@patch(f"{ROUTES_MODULE}.create_or_update_setting", return_value=None)
def test_create_new_setting_fails(self, mock_create, authenticated_client):
"""Creating a new setting that fails produces validation error."""
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_session.query.return_value.all.side_effect = [
[],
[],
]
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/save_all_settings",
json={"llm.new_fail": "value"},
)
assert resp.status_code == 400
def test_non_editable_skipped(self, authenticated_client):
"""Non-editable settings are filtered out."""
setting = _make_setting(
key="app.locked", editable=False, ui_element="text"
)
setting.type = "app"
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_session.query.return_value.all.side_effect = [
[setting],
[setting],
]
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/save_all_settings",
json={"app.locked": "new_val"},
)
assert resp.status_code == 200
data = resp.get_json()
assert data["status"] == "success"
@patch(
f"{ROUTES_MODULE}.calculate_warnings", return_value=[{"msg": "warn"}]
)
@patch(f"{ROUTES_MODULE}.set_setting", return_value=True)
@patch(
f"{ROUTES_MODULE}.coerce_setting_for_write",
side_effect=lambda key, value, ui_element: value,
)
@patch(f"{ROUTES_MODULE}.validate_setting", return_value=(True, None))
def test_warning_affecting_keys(
self, mock_v, mock_c, mock_s, mock_w, authenticated_client
):
"""Updating warning-affecting key includes warnings."""
setting = _make_setting(
key="llm.provider", value="ollama", ui_element="text", editable=True
)
setting.type = "llm"
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_session.query.return_value.all.side_effect = [
[setting],
[setting],
]
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/save_all_settings",
json={"llm.provider": "openai"},
)
assert resp.status_code == 200
data = resp.get_json()
assert "warnings" in data
@pytest.mark.parametrize(
"egress_key",
[
"policy.egress_scope",
"llm.require_local_endpoint",
"embeddings.require_local",
],
)
@patch(
f"{ROUTES_MODULE}.calculate_warnings", return_value=[{"msg": "warn"}]
)
@patch(f"{ROUTES_MODULE}.set_setting", return_value=True)
@patch(
f"{ROUTES_MODULE}.coerce_setting_for_write",
side_effect=lambda key, value, ui_element: value,
)
@patch(f"{ROUTES_MODULE}.validate_setting", return_value=(True, None))
def test_egress_keys_trigger_warnings_in_bulk_path(
self, mock_v, mock_c, mock_s, mock_w, egress_key, authenticated_client
):
"""Regression test for #4463: the bulk ``save_all_settings`` path must
recalculate warnings when an egress-policy key changes.
These three keys were originally present only in
``api_update_setting``'s list, not the bulk path's — so a bulk save
that changed them silently skipped warning recalculation. Both paths
now share ``WARNING_AFFECTING_KEYS``; this pins the bulk behavior.
"""
setting = _make_setting(
key=egress_key, value="false", ui_element="text", editable=True
)
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_session.query.return_value.all.side_effect = [
[setting],
[setting],
]
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/save_all_settings",
json={egress_key: "true"},
)
assert resp.status_code == 200
data = resp.get_json()
assert "warnings" in data, (
f"bulk save of {egress_key!r} did not recalculate warnings — it "
f"must be in WARNING_AFFECTING_KEYS (regression of #4463)"
)
def test_exception_returns_500(self, authenticated_client):
"""Generic exception during session setup returns 500 JSON."""
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_session.query.side_effect = RuntimeError("boom")
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/save_all_settings",
json={"llm.model": "x"},
)
assert resp.status_code == 500
def test_setting_type_categorization(self, authenticated_client):
"""Test different key prefixes for setting type categorization."""
# search setting with parameters category
setting = _make_setting(
key="search.iterations", value=3, ui_element="number", editable=True
)
setting.type = "search"
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_session.query.return_value.all.side_effect = [
[setting],
[setting],
]
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
with patch(
f"{ROUTES_MODULE}.coerce_setting_for_write", return_value=5
):
with patch(
f"{ROUTES_MODULE}.validate_setting",
return_value=(True, None),
):
with patch(
f"{ROUTES_MODULE}.set_setting", return_value=True
):
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/save_all_settings",
json={"search.iterations": 5},
)
assert resp.status_code == 200
def test_corrupted_search_tool(self, authenticated_client):
"""Corrupted search.tool value gets corrected to 'searxng'."""
setting = _make_setting(
key="search.tool", value="searxng", ui_element="text", editable=True
)
setting.type = "search"
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_session.query.return_value.all.side_effect = [
[setting],
[setting],
]
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
with patch(
f"{ROUTES_MODULE}.coerce_setting_for_write",
return_value="searxng",
):
with patch(
f"{ROUTES_MODULE}.validate_setting",
return_value=(True, None),
):
with patch(
f"{ROUTES_MODULE}.set_setting", return_value=True
):
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/save_all_settings",
json={"search.tool": "[object Object]"},
)
assert resp.status_code == 200
def test_corrupted_app_theme(self, authenticated_client):
"""Corrupted app.theme gets corrected to 'dark'."""
setting = _make_setting(
key="app.theme", value="dark", ui_element="text", editable=True
)
setting.type = "app"
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_session.query.return_value.all.side_effect = [
[setting],
[setting],
]
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
with patch(
f"{ROUTES_MODULE}.coerce_setting_for_write", return_value="dark"
):
with patch(
f"{ROUTES_MODULE}.validate_setting",
return_value=(True, None),
):
with patch(
f"{ROUTES_MODULE}.set_setting", return_value=True
):
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/save_all_settings",
json={"app.theme": "{}"},
)
assert resp.status_code == 200
class TestSaveSettings:
"""POST /settings/save_settings (traditional form POST)."""
def test_blocked_keys_flash(self, authenticated_client):
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/save_settings",
data={"evil.module_path": "bad"},
)
assert resp.status_code == 302 # redirect
def test_successful_save(self, authenticated_client):
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/save_settings",
data={"llm.temperature": "0.5"},
)
assert resp.status_code == 302
def test_exception_flashes_error(self, authenticated_client):
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_ctx.side_effect = RuntimeError("db fail")
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/save_settings",
data={"llm.model": "gpt-4"},
)
assert resp.status_code == 500
assert resp.get_json()["error"] == "Database session unavailable"
class TestFixCorruptedSettings:
"""POST /settings/fix_corrupted_settings"""
def test_no_corruption(self, authenticated_client):
"""No corrupted or duplicate settings."""
setting = _make_setting(key="llm.model", value="gpt-4")
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
# duplicate_keys query
mock_session.query.return_value.group_by.return_value.having.return_value.all.return_value = []
# all_settings query
mock_session.query.return_value.all.return_value = [setting]
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/fix_corrupted_settings"
)
assert resp.status_code == 200
data = resp.get_json()
assert data["status"] == "success"
def test_fixes_corrupted_llm_model(self, authenticated_client):
"""Corrupted llm.model gets fixed to default."""
setting = _make_setting(key="llm.model", value="[object Object]")
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_session.query.return_value.group_by.return_value.having.return_value.all.return_value = []
mock_session.query.return_value.all.return_value = [setting]
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/fix_corrupted_settings"
)
assert resp.status_code == 200
data = resp.get_json()
assert "llm.model" in data["fixed_settings"]
def test_fixes_corrupted_search_settings(self, authenticated_client):
"""Corrupted search settings get fixed."""
settings = [
_make_setting(key="search.tool", value="null"),
_make_setting(key="search.max_results", value="undefined"),
_make_setting(key="search.region", value=None),
]
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_session.query.return_value.group_by.return_value.having.return_value.all.return_value = []
mock_session.query.return_value.all.return_value = settings
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/fix_corrupted_settings"
)
assert resp.status_code == 200
def test_fixes_corrupted_app_settings(self, authenticated_client):
"""Corrupted app settings get fixed."""
settings = [
_make_setting(key="app.theme", value="{}"),
_make_setting(key="app.host", value=None),
_make_setting(key="app.port", value="null"),
_make_setting(key="app.debug", value="undefined"),
]
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_session.query.return_value.group_by.return_value.having.return_value.all.return_value = []
mock_session.query.return_value.all.return_value = settings
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/fix_corrupted_settings"
)
assert resp.status_code == 200
def test_removes_duplicates(self, authenticated_client):
"""Duplicate settings get removed."""
dup_setting1 = _make_setting(key="llm.model", value="gpt-4")
dup_setting2 = _make_setting(key="llm.model", value="gpt-3.5")
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
# duplicate_keys query returns one duplicate key
mock_session.query.return_value.group_by.return_value.having.return_value.all.return_value = [
("llm.model",)
]
# Settings for duplicate key ordered by updated_at desc
mock_session.query.return_value.filter.return_value.order_by.return_value.all.return_value = [
dup_setting1,
dup_setting2,
]
# all_settings (no corruption)
mock_session.query.return_value.all.return_value = [dup_setting1]
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/fix_corrupted_settings"
)
assert resp.status_code == 200
def test_exception_returns_500(self, authenticated_client):
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_session.query.side_effect = RuntimeError("db fail")
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/fix_corrupted_settings"
)
assert resp.status_code == 500
class TestApiGetWarnings:
"""GET /settings/api/warnings"""
@patch(f"{ROUTES_MODULE}.calculate_warnings", return_value=[])
def test_success(self, mock_w, authenticated_client):
resp = authenticated_client.get(f"{SETTINGS_PREFIX}/api/warnings")
assert resp.status_code == 200
data = resp.get_json()
assert "warnings" in data
@patch(
f"{ROUTES_MODULE}.calculate_warnings", side_effect=RuntimeError("fail")
)
def test_error(self, mock_w, authenticated_client):
resp = authenticated_client.get(f"{SETTINGS_PREFIX}/api/warnings")
assert resp.status_code == 500
class TestCheckOllamaStatus:
"""GET /settings/api/ollama-status"""
@patch(f"{ROUTES_MODULE}.safe_get")
@patch(
f"{ROUTES_MODULE}._get_setting_from_session",
return_value="http://localhost:11434",
)
def test_running(self, mock_setting, mock_get, authenticated_client):
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {"version": "0.1.0"}
mock_get.return_value = mock_resp
resp = authenticated_client.get(f"{SETTINGS_PREFIX}/api/ollama-status")
assert resp.status_code == 200
data = resp.get_json()
assert data["running"] is True
@patch(f"{ROUTES_MODULE}.safe_get")
@patch(
f"{ROUTES_MODULE}._get_setting_from_session",
return_value="http://localhost:11434",
)
def test_not_running(self, mock_setting, mock_get, authenticated_client):
mock_resp = MagicMock()
mock_resp.status_code = 503
mock_get.return_value = mock_resp
resp = authenticated_client.get(f"{SETTINGS_PREFIX}/api/ollama-status")
assert resp.status_code == 200
data = resp.get_json()
assert data["running"] is False
@patch(f"{ROUTES_MODULE}.safe_get")
@patch(
f"{ROUTES_MODULE}._get_setting_from_session",
return_value="http://localhost:11434",
)
def test_connection_error(
self, mock_setting, mock_get, authenticated_client
):
import requests as req_lib
mock_get.side_effect = req_lib.exceptions.ConnectionError("refused")
resp = authenticated_client.get(f"{SETTINGS_PREFIX}/api/ollama-status")
assert resp.status_code == 200
data = resp.get_json()
assert data["running"] is False
class TestGetBulkSettings:
"""GET /settings/api/bulk"""
def test_default_keys(self, authenticated_client):
resp = authenticated_client.get(f"{SETTINGS_PREFIX}/api/bulk")
assert resp.status_code == 200
data = resp.get_json()
assert data["success"] is True
assert "settings" in data
def test_custom_keys(self, authenticated_client):
resp = authenticated_client.get(
f"{SETTINGS_PREFIX}/api/bulk?keys[]=llm.model&keys[]=search.tool"
)
assert resp.status_code == 200
data = resp.get_json()
assert data["success"] is True
@patch(
f"{ROUTES_MODULE}._get_setting_from_session",
side_effect=RuntimeError("boom"),
)
def test_individual_key_error(self, mock_get, authenticated_client):
resp = authenticated_client.get(
f"{SETTINGS_PREFIX}/api/bulk?keys[]=bad.key"
)
assert resp.status_code == 200
data = resp.get_json()
assert data["settings"]["bad.key"]["exists"] is False
assert "error" in data["settings"]["bad.key"]
class TestApiGetSearchFavorites:
"""GET /settings/api/search-favorites"""
def test_returns_favorites(self, authenticated_client):
resp = authenticated_client.get(
f"{SETTINGS_PREFIX}/api/search-favorites"
)
assert resp.status_code == 200
data = resp.get_json()
assert "favorites" in data
class TestApiUpdateSearchFavorites:
"""PUT /settings/api/search-favorites"""
def test_no_favorites_key(self, authenticated_client):
resp = authenticated_client.put(
f"{SETTINGS_PREFIX}/api/search-favorites",
json={"not_favorites": []},
)
assert resp.status_code == 400
def test_favorites_not_list(self, authenticated_client):
resp = authenticated_client.put(
f"{SETTINGS_PREFIX}/api/search-favorites",
json={"favorites": "not_a_list"},
)
assert resp.status_code == 400
@patch(f"{DECORATOR_MODULE}.SettingsManager")
def test_update_success(self, mock_sm_cls, authenticated_client):
mock_sm = MagicMock()
mock_sm.set_setting.return_value = True
mock_sm_cls.return_value = mock_sm
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.put(
f"{SETTINGS_PREFIX}/api/search-favorites",
json={"favorites": ["google", "bing"]},
)
assert resp.status_code == 200
@patch(f"{DECORATOR_MODULE}.SettingsManager")
def test_update_fails(self, mock_sm_cls, authenticated_client):
mock_sm = MagicMock()
mock_sm.set_setting.return_value = False
mock_sm_cls.return_value = mock_sm
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.put(
f"{SETTINGS_PREFIX}/api/search-favorites",
json={"favorites": ["google"]},
)
assert resp.status_code == 500
class TestApiToggleSearchFavorite:
"""POST /settings/api/search-favorites/toggle"""
def test_no_engine_id(self, authenticated_client):
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/api/search-favorites/toggle",
json={"not_engine_id": "x"},
)
assert resp.status_code == 400
@patch(f"{DECORATOR_MODULE}.SettingsManager")
def test_add_favorite(self, mock_sm_cls, authenticated_client):
mock_sm = MagicMock()
mock_sm.get_setting.return_value = []
mock_sm.set_setting.return_value = True
mock_sm_cls.return_value = mock_sm
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/api/search-favorites/toggle",
json={"engine_id": "google"},
)
assert resp.status_code == 200
data = resp.get_json()
assert data["is_favorite"] is True
@patch(f"{DECORATOR_MODULE}.SettingsManager")
def test_remove_favorite(self, mock_sm_cls, authenticated_client):
mock_sm = MagicMock()
mock_sm.get_setting.return_value = ["google"]
mock_sm.set_setting.return_value = True
mock_sm_cls.return_value = mock_sm
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/api/search-favorites/toggle",
json={"engine_id": "google"},
)
assert resp.status_code == 200
data = resp.get_json()
assert data["is_favorite"] is False
@patch(f"{DECORATOR_MODULE}.SettingsManager")
def test_toggle_fails(self, mock_sm_cls, authenticated_client):
mock_sm = MagicMock()
mock_sm.get_setting.return_value = []
mock_sm.set_setting.return_value = False
mock_sm_cls.return_value = mock_sm
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/api/search-favorites/toggle",
json={"engine_id": "google"},
)
assert resp.status_code == 500
@patch(f"{DECORATOR_MODULE}.SettingsManager")
def test_favorites_not_list_resets(self, mock_sm_cls, authenticated_client):
"""If favorites is not a list, it gets reset to empty list."""
mock_sm = MagicMock()
mock_sm.get_setting.return_value = "not_a_list"
mock_sm.set_setting.return_value = True
mock_sm_cls.return_value = mock_sm
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/api/search-favorites/toggle",
json={"engine_id": "google"},
)
assert resp.status_code == 200
data = resp.get_json()
assert data["is_favorite"] is True
class TestApiTestNotificationUrl:
"""POST /settings/api/notifications/test-url"""
def test_no_body(self, authenticated_client):
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/api/notifications/test-url",
json={},
)
assert resp.status_code == 400
@patch(f"{ROUTES_MODULE}.NotificationService", create=True)
def test_success(self, mock_ns_cls, authenticated_client):
mock_ns = MagicMock()
mock_ns.test_service.return_value = {
"success": True,
"message": "ok",
"error": "",
}
# Patch the actual import inside the function
with patch(
"local_deep_research.notifications.service.NotificationService",
return_value=mock_ns,
):
with patch(
f"{ROUTES_MODULE}.NotificationService", mock_ns_cls, create=True
):
# The function imports NotificationService inside, so we need to patch that import
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/api/notifications/test-url",
json={"service_url": "http://example.com"},
)
# Just check it doesn't crash - could be 200 or 500 depending on import path
assert resp.status_code in [200, 500]
class TestApiGetDataLocation:
"""GET /settings/api/data-location"""
@patch(f"{ROUTES_MODULE}.db_manager")
@patch(
f"{ROUTES_MODULE}.get_encrypted_database_path",
return_value="/tmp/test.db",
)
@patch(f"{ROUTES_MODULE}.get_data_directory", return_value="/tmp/data")
def test_success(
self, mock_dir, mock_db_path, mock_dbm, authenticated_client
):
mock_dbm.has_encryption = False
with patch(f"{DECORATOR_MODULE}.SettingsManager") as mock_sm_cls:
mock_sm = MagicMock()
mock_sm.get_setting.return_value = None
mock_sm_cls.return_value = mock_sm
with patch(f"{ROUTES_MODULE}.platform") as mock_platform:
mock_platform.system.return_value = "Linux"
resp = authenticated_client.get(
f"{SETTINGS_PREFIX}/api/data-location"
)
assert resp.status_code == 200
data = resp.get_json()
assert "data_directory" in data
assert data["security_notice"]["encrypted"] is False
@patch(f"{ROUTES_MODULE}.db_manager")
@patch(
f"{ROUTES_MODULE}.get_encrypted_database_path",
return_value="/tmp/test.db",
)
@patch(f"{ROUTES_MODULE}.get_data_directory", return_value="/tmp/data")
def test_with_encryption(
self, mock_dir, mock_db_path, mock_dbm, authenticated_client
):
mock_dbm.has_encryption = True
with patch(
"local_deep_research.settings.manager.SettingsManager"
) as mock_sm_cls:
mock_sm = MagicMock()
mock_sm.get_setting.return_value = "/custom/dir"
mock_sm_cls.return_value = mock_sm
with patch(f"{ROUTES_MODULE}.platform") as mock_platform:
mock_platform.system.return_value = "Darwin"
with patch(
"local_deep_research.database.sqlcipher_utils.get_sqlcipher_settings",
return_value={"cipher": "aes-256"},
):
resp = authenticated_client.get(
f"{SETTINGS_PREFIX}/api/data-location"
)
assert resp.status_code == 200
data = resp.get_json()
assert data["security_notice"]["encrypted"] is True
assert data["platform"] == "macOS"
# NOTE: /api/rate-limiting/cleanup is covered by the canonical DB-backed tests
# in test_settings_routes_rate_limiting.py::TestApiCleanupRateLimiting (auth,
# default/custom-window delete + commit, 500 path, and days validation). The
# duplicate class that used to live here patched the removed get_tracker() path
# (#4735) and drifted stale; removed to avoid two copies diverging again.
class TestSaveAllSettingsNewSettingTypes:
"""Test new setting creation with different value types in save_all_settings."""
@patch(f"{ROUTES_MODULE}.create_or_update_setting")
def test_new_bool_setting(self, mock_create, authenticated_client):
"""Bool value creates checkbox UI element."""
mock_new = _make_setting(key="app.new_flag")
mock_new.type = "app"
mock_create.return_value = mock_new
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_session.query.return_value.all.side_effect = [[], []]
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/save_all_settings",
json={"app.new_flag": True},
)
assert resp.status_code == 200
# Check that create was called with checkbox ui_element
call_args = mock_create.call_args[0][0]
assert call_args["ui_element"] == "checkbox"
@patch(f"{ROUTES_MODULE}.create_or_update_setting")
def test_new_number_setting(self, mock_create, authenticated_client):
"""Numeric value creates number UI element."""
mock_new = _make_setting(key="search.new_count")
mock_new.type = "search"
mock_create.return_value = mock_new
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_session.query.return_value.all.side_effect = [[], []]
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/save_all_settings",
json={"search.new_count": 42},
)
assert resp.status_code == 200
call_args = mock_create.call_args[0][0]
assert call_args["ui_element"] == "number"
@patch(f"{ROUTES_MODULE}.create_or_update_setting")
def test_new_dict_setting(self, mock_create, authenticated_client):
"""Dict value creates textarea UI element."""
mock_new = _make_setting(key="report.new_struct")
mock_new.type = "report"
mock_create.return_value = mock_new
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_session.query.return_value.all.side_effect = [[], []]
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/save_all_settings",
json={"report.new_struct": {"key": "val"}},
)
assert resp.status_code == 200
call_args = mock_create.call_args[0][0]
assert call_args["ui_element"] == "textarea"
@patch(f"{ROUTES_MODULE}.create_or_update_setting")
def test_new_database_setting(self, mock_create, authenticated_client):
"""Database-prefixed key gets correct type."""
mock_new = _make_setting(key="database.new_param")
mock_new.type = "database"
mock_create.return_value = mock_new
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_session.query.return_value.all.side_effect = [[], []]
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/save_all_settings",
json={"database.new_param": "value"},
)
assert resp.status_code == 200
class TestSaveAllSettingsSuccessMessages:
"""Test success message variations in save_all_settings."""
@patch(f"{ROUTES_MODULE}.set_setting", return_value=True)
@patch(
f"{ROUTES_MODULE}.coerce_setting_for_write",
side_effect=lambda key, value, ui_element: value,
)
@patch(f"{ROUTES_MODULE}.validate_setting", return_value=(True, None))
def test_single_bool_update_message(
self, mock_v, mock_c, mock_s, authenticated_client
):
"""Single boolean update uses enabled/disabled language."""
setting = _make_setting(
key="search.safe_search",
value=True,
ui_element="checkbox",
editable=True,
)
setting.type = "search"
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_session.query.return_value.all.side_effect = [
[setting],
[setting],
]
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/save_all_settings",
json={"search.safe_search": True},
)
assert resp.status_code == 200
data = resp.get_json()
msg = data["message"]
assert "enabled" in msg or "disabled" in msg or "updated" in msg
@patch(f"{ROUTES_MODULE}.set_setting", return_value=True)
@patch(
f"{ROUTES_MODULE}.coerce_setting_for_write",
side_effect=lambda key, value, ui_element: value,
)
@patch(f"{ROUTES_MODULE}.validate_setting", return_value=(True, None))
def test_multiple_updates_message(
self, mock_v, mock_c, mock_s, authenticated_client
):
"""Multiple updates use count message."""
s1 = _make_setting(
key="llm.model", value="gpt-4", ui_element="text", editable=True
)
s1.type = "llm"
s2 = _make_setting(
key="llm.temperature", value=0.7, ui_element="number", editable=True
)
s2.type = "llm"
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_session.query.return_value.all.side_effect = [
[s1, s2],
[s1, s2],
]
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/save_all_settings",
json={"llm.model": "gpt-3.5-turbo", "llm.temperature": 0.5},
)
assert resp.status_code == 200
data = resp.get_json()
assert "2 updated" in data["message"]
class TestSaveAllSettingsValidationErrors:
"""Test validation error paths in save_all_settings."""
@patch(f"{ROUTES_MODULE}.coerce_setting_for_write", return_value="bad")
@patch(
f"{ROUTES_MODULE}.validate_setting",
return_value=(False, "Value must be a number"),
)
def test_validation_error_returned(
self, mock_v, mock_c, authenticated_client
):
setting = _make_setting(
key="llm.temperature", value=0.7, ui_element="number", editable=True
)
setting.type = "llm"
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_session.query.return_value.all.return_value = [setting]
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/save_all_settings",
json={"llm.temperature": "bad"},
)
assert resp.status_code == 400
data = resp.get_json()
assert data["status"] == "error"
assert len(data["errors"]) == 1
class TestSaveAllSettingsSkipsEmptyKeys:
"""Test that empty/corrupted keys are skipped."""
@patch(f"{ROUTES_MODULE}.set_setting", return_value=True)
@patch(
f"{ROUTES_MODULE}.coerce_setting_for_write",
side_effect=lambda key, value, ui_element: value,
)
@patch(f"{ROUTES_MODULE}.validate_setting", return_value=(True, None))
def test_empty_key_skipped(
self, mock_v, mock_c, mock_s, authenticated_client
):
setting = _make_setting(
key="llm.model", value="gpt-4", ui_element="text", editable=True
)
setting.type = "llm"
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_session.query.return_value.all.side_effect = [
[setting],
[setting],
]
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/save_all_settings",
json={"": "value", "llm.model": "gpt-4"},
)
assert resp.status_code == 200
class TestFixCorruptedSettingsReportFallback:
"""Test report. key corruption fallback to empty dict."""
def test_report_key_no_default_gets_empty_dict(self, authenticated_client):
setting = _make_setting(key="report.unknown_key", value=None)
with patch(f"{DECORATOR_MODULE}.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_session.query.return_value.group_by.return_value.having.return_value.all.return_value = []
mock_session.query.return_value.all.return_value = [setting]
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
resp = authenticated_client.post(
f"{SETTINGS_PREFIX}/fix_corrupted_settings"
)
assert resp.status_code == 200
data = resp.get_json()
assert "report.unknown_key" in data["fixed_settings"]