"""Tests for issue #572: onboarding must not fire or overwrite config for providers not in the quick-setup list (minimax-cn, deepseek, xai, etc.). Root cause: _provider_api_key_present() only knew about the four providers in _SUPPORTED_PROVIDER_SETUPS. For any other provider it returned False, causing chat_ready=False, which made the wizard fire even when the user was fully configured. The second part of the fix ensures _saveOnboardingProviderSetup() in the frontend also skips the POST when current_is_oauth is set. Covers: 1. _provider_api_key_present returns True for minimax-cn when MINIMAX_CN_API_KEY is in env (via hermes_cli.auth.get_auth_status) 2. _status_from_runtime gives chat_ready=True for minimax-cn with a key set 3. get_onboarding_status returns completed=True for a fully-configured unsupported provider when config.yaml exists 4. The hermes_cli import failure path is safe (falls back gracefully) """ from __future__ import annotations import os import pathlib import sys import types from unittest import mock import pytest def _inject_hermes_cli_auth(get_auth_status_return): """Inject a minimal hermes_cli.auth stub into sys.modules. CI doesn't install hermes_cli (it's a separate package). Tests that exercise the hermes_cli fallback path must inject the module themselves rather than relying on mock.patch('hermes_cli.auth.get_auth_status') which fails with ModuleNotFoundError when the module isn't installed. """ mock_auth = types.ModuleType("hermes_cli.auth") mock_auth.get_auth_status = mock.MagicMock(return_value=get_auth_status_return) mock_hermes_cli = types.ModuleType("hermes_cli") return mock.patch.dict(sys.modules, { "hermes_cli": mock_hermes_cli, "hermes_cli.auth": mock_auth, }) # --------------------------------------------------------------------------- # Helper # --------------------------------------------------------------------------- def _call_provider_api_key_present(provider: str, cfg: dict = None, env_values: dict = None): from api.onboarding import _provider_api_key_present return _provider_api_key_present(provider, cfg or {}, env_values or {}) # --------------------------------------------------------------------------- # 1. _provider_api_key_present via hermes_cli fallback # --------------------------------------------------------------------------- class TestProviderApiKeyPresentFallback: def test_minimax_cn_logged_in_returns_true(self): """minimax-cn: if hermes_cli.auth.get_auth_status returns logged_in, must be True.""" with mock.patch("api.onboarding._SUPPORTED_PROVIDER_SETUPS", { "openrouter": {}, "anthropic": {}, "openai": {}, "custom": {} }): with _inject_hermes_cli_auth({"logged_in": True}): result = _call_provider_api_key_present("minimax-cn") assert result is True def test_unsupported_provider_logged_out_returns_false(self): """Unsupported provider with no key → False, no crash.""" with mock.patch("api.onboarding._SUPPORTED_PROVIDER_SETUPS", { "openrouter": {}, "anthropic": {}, "openai": {}, "custom": {} }): with _inject_hermes_cli_auth({"logged_in": False}): result = _call_provider_api_key_present("deepseek") assert result is False def test_hermes_cli_import_failure_is_safe(self): """If hermes_cli is unavailable, falls back silently to False.""" import builtins real_import = builtins.__import__ def _block_hermes_cli(name, *args, **kwargs): if name.startswith("hermes_cli"): raise ImportError("hermes_cli not available") return real_import(name, *args, **kwargs) with mock.patch("api.onboarding._SUPPORTED_PROVIDER_SETUPS", { "openrouter": {}, "anthropic": {}, "openai": {}, "custom": {} }): with mock.patch("builtins.__import__", side_effect=_block_hermes_cli): result = _call_provider_api_key_present("minimax-cn") assert result is False # safe fallback def test_supported_provider_still_works_without_fallback(self): """openrouter with env key must still succeed via the original path.""" from api.onboarding import _provider_api_key_present, _SUPPORTED_PROVIDER_SETUPS env_values = {"OPENROUTER_API_KEY": "sk-test"} result = _provider_api_key_present("openrouter", {}, env_values) assert result is True def test_inline_api_key_in_cfg_still_works(self): """model.api_key in config.yaml must be recognized for any provider.""" cfg = {"model": {"provider": "minimax-cn", "default": "MiniMax-M2.7", "api_key": "key123"}} result = _call_provider_api_key_present("minimax-cn", cfg) assert result is True # --------------------------------------------------------------------------- # 2. _status_from_runtime: unsupported provider with key → chat_ready=True # --------------------------------------------------------------------------- class TestStatusFromRuntimeUnsupportedProvider: def _run(self, provider: str, model: str, api_key_present: bool, oauth_present: bool = False): from api.onboarding import _status_from_runtime cfg = {"model": {"provider": provider, "default": model}} with ( mock.patch("api.onboarding._HERMES_FOUND", True), mock.patch("api.onboarding._load_env_file", return_value={}), mock.patch("api.onboarding._get_active_hermes_home", return_value=pathlib.Path("/tmp")), mock.patch("api.onboarding._provider_api_key_present", return_value=api_key_present), mock.patch("api.onboarding._provider_oauth_authenticated", return_value=oauth_present), ): return _status_from_runtime(cfg, True) def test_minimax_cn_with_key_gives_chat_ready(self): """minimax-cn + api key present → chat_ready must be True.""" result = self._run("minimax-cn", "MiniMax-M2.7", api_key_present=True) assert result["chat_ready"] is True, f"Expected chat_ready=True, got: {result}" assert result["provider_ready"] is True assert result["setup_state"] == "ready" def test_deepseek_with_key_gives_chat_ready(self): """deepseek + api key → chat_ready.""" result = self._run("deepseek", "deepseek-chat", api_key_present=True) assert result["chat_ready"] is True def test_unsupported_provider_no_key_no_oauth_gives_not_ready(self): """No key, no oauth → provider_ready=False.""" result = self._run("minimax-cn", "MiniMax-M2.7", api_key_present=False, oauth_present=False) assert result["chat_ready"] is False assert result["provider_ready"] is False def test_oauth_provider_still_works_via_oauth_path(self): """openai-codex (OAuth) with no api_key but oauth present → ready.""" result = self._run("openai-codex", "codex-model", api_key_present=False, oauth_present=True) assert result["chat_ready"] is True # --------------------------------------------------------------------------- # 3. get_onboarding_status: minimax-cn fully configured → completed=True # --------------------------------------------------------------------------- class TestOnboardingStatusUnsupportedProvider: def _make_status(self, chat_ready: bool, provider: str = "minimax-cn"): import api.onboarding as mod fake_config_path = pathlib.Path("/tmp/_test_572_config.yaml") cfg = {"model": {"provider": provider, "default": "MiniMax-M2.7"}} runtime = { "chat_ready": chat_ready, "provider_configured": True, "provider_ready": chat_ready, "setup_state": "ready" if chat_ready else "provider_incomplete", "provider_note": "test", "current_provider": provider, "current_model": "MiniMax-M2.7", "current_base_url": None, "env_path": "/tmp/.env", } with ( mock.patch.object(mod, "load_settings", return_value={}), mock.patch.object(mod, "get_config", return_value=cfg), mock.patch.object(mod, "verify_hermes_imports", return_value=(True, [], {})), mock.patch.object(mod, "_status_from_runtime", return_value=runtime), mock.patch.object(mod, "load_workspaces", return_value=[]), mock.patch.object(mod, "get_last_workspace", return_value=None), mock.patch.object(mod, "get_available_models", return_value=[]), mock.patch.object(mod, "_get_config_path", return_value=fake_config_path), mock.patch.object(pathlib.Path, "exists", return_value=True), ): return mod.get_onboarding_status() def test_minimax_cn_chat_ready_skips_wizard(self): """minimax-cn + chat_ready=True + config.yaml exists → wizard must NOT fire.""" result = self._make_status(chat_ready=True) assert result["completed"] is True, ( "Wizard fired for minimax-cn user with valid config! " "config.yaml + chat_ready=True must auto-complete onboarding regardless of provider." ) def test_minimax_cn_not_ready_shows_wizard(self): """minimax-cn + chat_ready=False → wizard fires so user can fix it.""" result = self._make_status(chat_ready=False) assert result["completed"] is False def test_current_is_oauth_set_for_unsupported_provider(self): """setup.current_is_oauth must be True for minimax-cn (not in quick-setup list).""" result = self._make_status(chat_ready=True) assert result["setup"]["current_is_oauth"] is True, ( "current_is_oauth should be True for providers not in _SUPPORTED_PROVIDER_SETUPS" )