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
1684 lines
66 KiB
Python
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"]
|