"""Tests for the settings store, LLM client, and settings routes. All LLM calls are mocked — CI never touches the network. """ from unittest.mock import patch import pytest import app.llm as llm_module from app.llm import LLMResult, expand_query, is_configured from app.llm import ExpansionResult from app.models import AppSetting from app.settings_store import LLMConfig, get_app_settings, save_app_settings # Alias to avoid pytest collecting it as a test function _test_connection = llm_module.test_connection # --------------------------------------------------------------------------- # LLMConfig dataclass defaults # --------------------------------------------------------------------------- class TestLLMConfigDefaults: def test_default_values(self): cfg = LLMConfig() assert cfg.enabled is False assert cfg.base_url == "https://api.openai.com/v1" assert cfg.model == "" assert cfg.api_key == "" assert cfg.ai_search_enabled is False assert cfg.ai_search_extra_hints == "" # --------------------------------------------------------------------------- # settings_store: get_app_settings # --------------------------------------------------------------------------- class TestGetAppSettings: def test_returns_defaults_when_no_rows(self, db_session): cfg = get_app_settings(db_session) assert cfg.enabled is False assert cfg.base_url == "https://api.openai.com/v1" assert cfg.model == "" assert cfg.api_key == "" assert cfg.ai_search_enabled is False def test_reads_stored_values(self, db_session): db_session.add(AppSetting(key="llm_enabled", value="true")) db_session.add(AppSetting(key="llm_base_url", value="https://custom.api/v1")) db_session.add(AppSetting(key="llm_model", value="gpt-4o")) db_session.add(AppSetting(key="llm_api_key", value="sk-test-key")) db_session.add(AppSetting(key="ai_search_enabled", value="true")) db_session.commit() cfg = get_app_settings(db_session) assert cfg.enabled is True assert cfg.base_url == "https://custom.api/v1" assert cfg.model == "gpt-4o" assert cfg.api_key == "sk-test-key" assert cfg.ai_search_enabled is True def test_handles_null_value_as_default(self, db_session): db_session.add(AppSetting(key="llm_model", value=None)) db_session.commit() cfg = get_app_settings(db_session) assert cfg.model == "" # --------------------------------------------------------------------------- # settings_store: save_app_settings # --------------------------------------------------------------------------- class TestSaveAppSettings: def test_saves_new_settings(self, db_session): save_app_settings( db_session, enabled=True, base_url="https://my-api.com/v1", model="gpt-4o-mini", api_key="sk-new-key", ) cfg = get_app_settings(db_session) assert cfg.enabled is True assert cfg.base_url == "https://my-api.com/v1" assert cfg.model == "gpt-4o-mini" assert cfg.api_key == "sk-new-key" def test_updates_existing_settings(self, db_session): save_app_settings(db_session, enabled=True, model="old-model", api_key="key1") save_app_settings(db_session, model="new-model") cfg = get_app_settings(db_session) assert cfg.model == "new-model" # enabled was not passed in second save, so it stays unchanged assert cfg.enabled is True def test_api_key_none_preserves_old_key(self, db_session): save_app_settings(db_session, api_key="sk-original") save_app_settings(db_session, model="gpt-4o", api_key=None) cfg = get_app_settings(db_session) assert cfg.api_key == "sk-original" assert cfg.model == "gpt-4o" def test_api_key_empty_string_overwrites(self, db_session): save_app_settings(db_session, api_key="sk-original") save_app_settings(db_session, api_key="") cfg = get_app_settings(db_session) assert cfg.api_key == "" def test_partial_save_only_updates_specified_fields(self, db_session): save_app_settings(db_session, enabled=True, model="gpt-4o") save_app_settings(db_session, base_url="https://new.url/v1") cfg = get_app_settings(db_session) assert cfg.enabled is True assert cfg.model == "gpt-4o" assert cfg.base_url == "https://new.url/v1" # --------------------------------------------------------------------------- # is_configured # --------------------------------------------------------------------------- class TestIsConfigured: def test_false_when_disabled(self): cfg = LLMConfig(enabled=False, model="gpt-4o", api_key="sk-key") assert is_configured(cfg) is False def test_false_when_no_model(self): cfg = LLMConfig(enabled=True, model="", api_key="sk-key") assert is_configured(cfg) is False def test_false_when_no_api_key(self): cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="") assert is_configured(cfg) is False def test_true_when_all_set(self): cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key") assert is_configured(cfg) is True # --------------------------------------------------------------------------- # test_connection (mocked) # --------------------------------------------------------------------------- class TestTestConnection: def test_returns_failure_when_not_configured(self): cfg = LLMConfig(enabled=False) result = _test_connection(cfg) assert result.success is False assert "未配置" in result.message @patch("app.llm._call_chat_completion") def test_success_when_configured(self, mock_call): mock_call.return_value = {"choices": [{"message": {"content": "Hi"}}]} cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key") result = _test_connection(cfg) assert result.success is True assert "连接成功" in result.message assert "gpt-4o" in result.message @patch("app.llm._call_chat_completion") def test_handles_http_error(self, mock_call): import httpx mock_call.side_effect = httpx.HTTPStatusError( "401", request=httpx.Request("POST", "http://x"), response=httpx.Response(401), ) cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-bad") result = _test_connection(cfg) assert result.success is False assert "401" in result.message @patch("app.llm._call_chat_completion") def test_handles_connect_error(self, mock_call): import httpx mock_call.side_effect = httpx.ConnectError("refused") cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key") result = _test_connection(cfg) assert result.success is False assert "无法连接" in result.message @patch("app.llm._call_chat_completion") def test_handles_timeout(self, mock_call): import httpx mock_call.side_effect = httpx.TimeoutException("timeout") cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key") result = _test_connection(cfg) assert result.success is False assert "超时" in result.message # --------------------------------------------------------------------------- # expand_query (mocked) # --------------------------------------------------------------------------- class TestExpandQuery: def test_returns_empty_when_not_configured(self): cfg = LLMConfig(enabled=False) result = expand_query(cfg, "锅") assert result.terms == [] assert result.error is None @patch("app.llm._call_chat_completion") def test_expands_query_successfully(self, mock_call): mock_call.return_value = { "choices": [ {"message": {"content": '["平底锅","炒锅","锅具","厨房锅"]'}} ] } cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key") result = expand_query(cfg, "锅") assert "平底锅" in result.terms assert "炒锅" in result.terms assert result.error is None @patch("app.llm._call_chat_completion") def test_fallback_on_api_failure(self, mock_call): mock_call.side_effect = Exception("network down") cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key") result = expand_query(cfg, "锅") assert result.terms == [] assert result.error is not None @patch("app.llm._call_chat_completion") def test_fallback_on_empty_response(self, mock_call): mock_call.return_value = {"choices": [{"message": {"content": ""}}]} cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key") result = expand_query(cfg, "锅") assert result.terms == [] assert result.error is None # --------------------------------------------------------------------------- # Routes: GET /settings # --------------------------------------------------------------------------- class TestSettingsPage: def test_settings_page_returns_200(self, client): response = client.get("/settings") assert response.status_code == 200 def test_settings_page_has_form_elements(self, client): response = client.get("/settings") assert "设置" in response.text assert 'name="enabled"' in response.text assert 'name="base_url"' in response.text assert 'name="model"' in response.text assert 'name="api_key"' in response.text assert "保存设置" in response.text assert "测试连接" in response.text def test_settings_page_shows_nav_link(self, client): response = client.get("/boxes") assert "设置" in response.text assert 'href="/settings"' in response.text def test_settings_page_no_api_key_echoed(self, client, db_session): save_app_settings(db_session, api_key="sk-super-secret-key-12345") response = client.get("/settings") assert "sk-super-secret-key-12345" not in response.text assert "已配置" in response.text def test_settings_page_shows_placeholder_when_no_key(self, client): response = client.get("/settings") assert "输入 API Key" in response.text def test_settings_page_shows_default_base_url(self, client): response = client.get("/settings") assert "https://api.openai.com/v1" in response.text # --------------------------------------------------------------------------- # Routes: POST /settings # --------------------------------------------------------------------------- class TestSaveSettingsRoute: def test_save_settings_redirects(self, client): response = client.post( "/settings", data={ "enabled": "on", "base_url": "https://my-api.com/v1", "model": "gpt-4o-mini", "api_key": "sk-test-key", }, follow_redirects=False, ) assert response.status_code == 303 assert response.headers["location"] == "/settings" def test_saved_settings_persist(self, client, db_session): client.post( "/settings", data={ "enabled": "on", "base_url": "https://my-api.com/v1", "model": "gpt-4o-mini", "api_key": "sk-test-key", }, follow_redirects=False, ) cfg = get_app_settings(db_session) assert cfg.enabled is True assert cfg.base_url == "https://my-api.com/v1" assert cfg.model == "gpt-4o-mini" assert cfg.api_key == "sk-test-key" def test_save_with_blank_api_key_preserves_old(self, client, db_session): # First save with a key client.post( "/settings", data={"enabled": "on", "model": "gpt-4o", "api_key": "sk-original"}, follow_redirects=False, ) # Second save without key (blank) client.post( "/settings", data={"enabled": "on", "model": "gpt-4o", "api_key": ""}, follow_redirects=False, ) cfg = get_app_settings(db_session) assert cfg.api_key == "sk-original" def test_save_disabled_state(self, client, db_session): # First enable client.post( "/settings", data={"enabled": "on", "model": "gpt-4o", "api_key": "sk-key"}, follow_redirects=False, ) # Then disable (no 'enabled' checkbox) client.post( "/settings", data={"model": "gpt-4o", "api_key": ""}, follow_redirects=False, ) cfg = get_app_settings(db_session) assert cfg.enabled is False def test_save_settings_no_api_key_in_redirect_page(self, client): client.post( "/settings", data={"enabled": "on", "model": "gpt-4o", "api_key": "sk-secret-key"}, follow_redirects=False, ) response = client.get("/settings") assert "sk-secret-key" not in response.text def test_save_refuses_when_base_url_changes_and_key_blank(self, client, db_session): """P1 fix: if base_url changes and api_key is blank, refuse save with error.""" client.post( "/settings", data={ "enabled": "on", "base_url": "https://old-api.com/v1", "model": "gpt-4o", "api_key": "sk-old-key", }, follow_redirects=False, ) # Try saving with different base_url, no key response = client.post( "/settings", data={ "enabled": "on", "base_url": "https://new-api.com/v1", "model": "gpt-4o", "api_key": "", # blank + base_url changed → refuse }, ) assert response.status_code == 200 assert "请重新输入 API Key 后保存" in response.text # Old config should be unchanged — nothing was saved cfg = get_app_settings(db_session) assert cfg.base_url == "https://old-api.com/v1" assert cfg.api_key == "sk-old-key" def test_save_preserves_key_when_endpoint_unchanged_and_key_blank(self, client, db_session): """P1 fix: if endpoint is unchanged and api_key is blank, keep old key.""" client.post( "/settings", data={ "enabled": "on", "base_url": "https://api.openai.com/v1", "model": "gpt-4o", "api_key": "sk-original", }, follow_redirects=False, ) # Re-save same endpoint, blank key client.post( "/settings", data={ "enabled": "on", "base_url": "https://api.openai.com/v1", "model": "gpt-4o", "api_key": "", }, follow_redirects=False, ) cfg = get_app_settings(db_session) assert cfg.api_key == "sk-original" def test_save_preserves_key_when_only_model_changes_and_key_blank(self, client, db_session): """Model change alone should not clear the key — same base_url, different model.""" client.post( "/settings", data={ "enabled": "on", "base_url": "https://api.openai.com/v1", "model": "gpt-4o", "api_key": "sk-original", }, follow_redirects=False, ) # Change only model, leave api_key blank client.post( "/settings", data={ "enabled": "on", "base_url": "https://api.openai.com/v1", "model": "gpt-4o-mini", "api_key": "", }, follow_redirects=False, ) cfg = get_app_settings(db_session) assert cfg.model == "gpt-4o-mini" assert cfg.api_key == "sk-original" def test_save_includes_ai_search_enabled_checkbox(self, client, db_session): """Saving settings now also persists the ai_search_enabled checkbox.""" # Set ai_search_enabled to true first db_session.add(AppSetting(key="ai_search_enabled", value="true")) db_session.commit() # Save without the checkbox → ai_search_enabled is set to False client.post( "/settings", data={"enabled": "on", "model": "gpt-4o", "api_key": "sk-key"}, follow_redirects=False, ) cfg = get_app_settings(db_session) assert cfg.ai_search_enabled is False def test_save_preserves_ai_search_enabled_when_checked(self, client, db_session): """Saving settings with ai_search_enabled checked persists it.""" client.post( "/settings", data={ "enabled": "on", "model": "gpt-4o", "api_key": "sk-key", "ai_search_enabled": "on", }, follow_redirects=False, ) cfg = get_app_settings(db_session) assert cfg.ai_search_enabled is True # --------------------------------------------------------------------------- # Routes: POST /settings/test # --------------------------------------------------------------------------- class TestTestConnectionRoute: @patch("app.llm._call_chat_completion") def test_test_connection_success(self, mock_call, client): mock_call.return_value = {"choices": [{"message": {"content": "Hi"}}]} response = client.post( "/settings/test", data={ "enabled": "on", "base_url": "https://api.openai.com/v1", "model": "gpt-4o", "api_key": "sk-test", }, ) assert response.status_code == 200 assert "连接成功" in response.text assert "gpt-4o" in response.text @patch("app.llm._call_chat_completion") def test_test_connection_failure(self, mock_call, client): import httpx mock_call.side_effect = httpx.HTTPStatusError( "401", request=httpx.Request("POST", "http://x"), response=httpx.Response(401), ) response = client.post( "/settings/test", data={ "enabled": "on", "base_url": "https://api.openai.com/v1", "model": "gpt-4o", "api_key": "sk-bad", }, ) assert response.status_code == 200 assert "连接失败" in response.text assert "401" in response.text def test_test_connection_not_configured(self, client): response = client.post( "/settings/test", data={ "enabled": "", # not checked "base_url": "https://api.openai.com/v1", "model": "", "api_key": "", }, ) assert response.status_code == 200 assert "未配置" in response.text @patch("app.llm._call_chat_completion") def test_test_connection_uses_stored_key_when_endpoint_matches(self, mock_call, client, db_session): """When api_key is blank but base_url and model match saved config, the stored key should be used.""" mock_call.return_value = {"choices": [{"message": {"content": "Hi"}}]} # Store a config first save_app_settings( db_session, enabled=True, base_url="https://api.openai.com/v1", model="gpt-4o", api_key="sk-stored-key", ) response = client.post( "/settings/test", data={ "enabled": "on", "base_url": "https://api.openai.com/v1", "model": "gpt-4o", "api_key": "", # blank → use stored key (endpoint matches) }, ) assert response.status_code == 200 assert "连接成功" in response.text @patch("app.llm._call_chat_completion") def test_test_connection_uses_stored_key_when_only_model_changes(self, mock_call, client, db_session): """Model changes under the same base_url can reuse the stored key.""" captured = {} def fake_call(cfg, **kwargs): captured["base_url"] = cfg.base_url captured["model"] = cfg.model captured["api_key"] = cfg.api_key return {"choices": [{"message": {"content": "Hi"}}]} mock_call.side_effect = fake_call save_app_settings( db_session, enabled=True, base_url="https://api.openai.com/v1", model="gpt-4o", api_key="sk-stored-key", ) response = client.post( "/settings/test", data={ "enabled": "on", "base_url": "https://api.openai.com/v1", "model": "gpt-4o-mini", "api_key": "", }, ) assert response.status_code == 200 assert "连接成功" in response.text assert captured == { "base_url": "https://api.openai.com/v1", "model": "gpt-4o-mini", "api_key": "sk-stored-key", } def test_test_connection_refuses_stored_key_when_endpoint_changed(self, client, db_session): """When base_url changed and api_key is blank, refuse to test.""" save_app_settings( db_session, enabled=True, base_url="https://api.openai.com/v1", model="gpt-4o", api_key="sk-stored-key", ) response = client.post( "/settings/test", data={ "enabled": "on", "base_url": "https://attacker.example/v1", # different endpoint "model": "gpt-4o", "api_key": "", # blank → refuse }, ) assert response.status_code == 200 assert "请重新输入 API Key" in response.text def test_test_connection_result_shows_on_settings_page(self, client): """Test result is rendered on the same settings page.""" response = client.post( "/settings/test", data={ "enabled": "", "base_url": "https://api.openai.com/v1", "model": "", "api_key": "", }, ) assert response.status_code == 200 assert "设置" in response.text assert "保存设置" in response.text # --------------------------------------------------------------------------- # Graceful degradation: unconfigured LLM does not affect existing features # --------------------------------------------------------------------------- class TestGracefulDegradation: def test_boxes_page_works_without_llm_config(self, client): response = client.get("/boxes") assert response.status_code == 200 def test_search_page_works_without_llm_config(self, client): response = client.get("/search?q=test") assert response.status_code == 200 def test_crud_works_without_llm_config(self, client, db_session): from app.models import Box response = client.post( "/boxes", data={"name": "No LLM Box"}, follow_redirects=False, ) assert response.status_code == 303 assert db_session.query(Box).count() == 1