test_tool_backend_helpers.py
1 """Unit tests for tools/tool_backend_helpers.py. 2 3 Tests cover: 4 - managed_nous_tools_enabled() subscription-based gate 5 - normalize_browser_cloud_provider() coercion 6 - coerce_modal_mode() / normalize_modal_mode() validation 7 - has_direct_modal_credentials() detection 8 - resolve_modal_backend_state() backend selection matrix 9 - resolve_openai_audio_api_key() priority chain 10 """ 11 12 from __future__ import annotations 13 14 from pathlib import Path 15 from unittest.mock import patch 16 17 import pytest 18 19 from tools.tool_backend_helpers import ( 20 coerce_modal_mode, 21 has_direct_modal_credentials, 22 managed_nous_tools_enabled, 23 normalize_browser_cloud_provider, 24 normalize_modal_mode, 25 prefers_gateway, 26 resolve_modal_backend_state, 27 resolve_openai_audio_api_key, 28 ) 29 30 31 def _raise_import(): 32 raise ImportError("simulated missing module") 33 34 35 # --------------------------------------------------------------------------- 36 # managed_nous_tools_enabled 37 # --------------------------------------------------------------------------- 38 class TestManagedNousToolsEnabled: 39 """Subscription-based gate: True for paid Nous subscribers.""" 40 41 def test_disabled_when_not_logged_in(self, monkeypatch): 42 monkeypatch.setattr( 43 "hermes_cli.auth.get_nous_auth_status", 44 lambda: {}, 45 ) 46 assert managed_nous_tools_enabled() is False 47 48 def test_disabled_for_free_tier(self, monkeypatch): 49 monkeypatch.setattr( 50 "hermes_cli.auth.get_nous_auth_status", 51 lambda: {"logged_in": True}, 52 ) 53 monkeypatch.setattr( 54 "hermes_cli.models.check_nous_free_tier", 55 lambda: True, 56 ) 57 assert managed_nous_tools_enabled() is False 58 59 def test_enabled_for_paid_subscriber(self, monkeypatch): 60 monkeypatch.setattr( 61 "hermes_cli.auth.get_nous_auth_status", 62 lambda: {"logged_in": True}, 63 ) 64 monkeypatch.setattr( 65 "hermes_cli.models.check_nous_free_tier", 66 lambda: False, 67 ) 68 assert managed_nous_tools_enabled() is True 69 70 def test_returns_false_on_exception(self, monkeypatch): 71 """Should never crash — returns False on any exception.""" 72 monkeypatch.setattr( 73 "hermes_cli.auth.get_nous_auth_status", 74 _raise_import, 75 ) 76 assert managed_nous_tools_enabled() is False 77 78 79 # --------------------------------------------------------------------------- 80 # normalize_browser_cloud_provider 81 # --------------------------------------------------------------------------- 82 class TestNormalizeBrowserCloudProvider: 83 """Coerce arbitrary input to a lowercase browser provider key.""" 84 85 def test_none_returns_default(self): 86 assert normalize_browser_cloud_provider(None) == "local" 87 88 def test_empty_string_returns_default(self): 89 assert normalize_browser_cloud_provider("") == "local" 90 91 def test_whitespace_only_returns_default(self): 92 assert normalize_browser_cloud_provider(" ") == "local" 93 94 def test_known_provider_normalized(self): 95 assert normalize_browser_cloud_provider("BrowserBase") == "browserbase" 96 97 def test_strips_whitespace(self): 98 assert normalize_browser_cloud_provider(" Local ") == "local" 99 100 def test_integer_coerced(self): 101 result = normalize_browser_cloud_provider(42) 102 assert isinstance(result, str) 103 assert result == "42" 104 105 106 # --------------------------------------------------------------------------- 107 # coerce_modal_mode / normalize_modal_mode 108 # --------------------------------------------------------------------------- 109 class TestCoerceModalMode: 110 """Validate and coerce the requested modal execution mode.""" 111 112 @pytest.mark.parametrize("value", ["auto", "direct", "managed"]) 113 def test_valid_modes_passthrough(self, value): 114 assert coerce_modal_mode(value) == value 115 116 def test_none_returns_auto(self): 117 assert coerce_modal_mode(None) == "auto" 118 119 def test_empty_string_returns_auto(self): 120 assert coerce_modal_mode("") == "auto" 121 122 def test_whitespace_only_returns_auto(self): 123 assert coerce_modal_mode(" ") == "auto" 124 125 def test_uppercase_normalized(self): 126 assert coerce_modal_mode("DIRECT") == "direct" 127 128 def test_mixed_case_normalized(self): 129 assert coerce_modal_mode("Managed") == "managed" 130 131 def test_invalid_mode_falls_back_to_auto(self): 132 assert coerce_modal_mode("invalid") == "auto" 133 assert coerce_modal_mode("cloud") == "auto" 134 135 def test_strips_whitespace(self): 136 assert coerce_modal_mode(" managed ") == "managed" 137 138 139 class TestNormalizeModalMode: 140 """normalize_modal_mode is an alias for coerce_modal_mode.""" 141 142 def test_delegates_to_coerce(self): 143 assert normalize_modal_mode("direct") == coerce_modal_mode("direct") 144 assert normalize_modal_mode(None) == coerce_modal_mode(None) 145 assert normalize_modal_mode("bogus") == coerce_modal_mode("bogus") 146 147 148 # --------------------------------------------------------------------------- 149 # has_direct_modal_credentials 150 # --------------------------------------------------------------------------- 151 class TestHasDirectModalCredentials: 152 """Detect Modal credentials via env vars or config file.""" 153 154 def test_no_env_no_file(self, monkeypatch, tmp_path): 155 monkeypatch.delenv("MODAL_TOKEN_ID", raising=False) 156 monkeypatch.delenv("MODAL_TOKEN_SECRET", raising=False) 157 with patch.object(Path, "home", return_value=tmp_path): 158 assert has_direct_modal_credentials() is False 159 160 def test_both_env_vars_set(self, monkeypatch, tmp_path): 161 monkeypatch.setenv("MODAL_TOKEN_ID", "id-123") 162 monkeypatch.setenv("MODAL_TOKEN_SECRET", "sec-456") 163 with patch.object(Path, "home", return_value=tmp_path): 164 assert has_direct_modal_credentials() is True 165 166 def test_only_token_id_not_enough(self, monkeypatch, tmp_path): 167 monkeypatch.setenv("MODAL_TOKEN_ID", "id-123") 168 monkeypatch.delenv("MODAL_TOKEN_SECRET", raising=False) 169 with patch.object(Path, "home", return_value=tmp_path): 170 assert has_direct_modal_credentials() is False 171 172 def test_only_token_secret_not_enough(self, monkeypatch, tmp_path): 173 monkeypatch.delenv("MODAL_TOKEN_ID", raising=False) 174 monkeypatch.setenv("MODAL_TOKEN_SECRET", "sec-456") 175 with patch.object(Path, "home", return_value=tmp_path): 176 assert has_direct_modal_credentials() is False 177 178 def test_config_file_present(self, monkeypatch, tmp_path): 179 monkeypatch.delenv("MODAL_TOKEN_ID", raising=False) 180 monkeypatch.delenv("MODAL_TOKEN_SECRET", raising=False) 181 (tmp_path / ".modal.toml").touch() 182 with patch.object(Path, "home", return_value=tmp_path): 183 assert has_direct_modal_credentials() is True 184 185 def test_env_vars_take_priority_over_file(self, monkeypatch, tmp_path): 186 monkeypatch.setenv("MODAL_TOKEN_ID", "id-123") 187 monkeypatch.setenv("MODAL_TOKEN_SECRET", "sec-456") 188 (tmp_path / ".modal.toml").touch() 189 with patch.object(Path, "home", return_value=tmp_path): 190 assert has_direct_modal_credentials() is True 191 192 193 # --------------------------------------------------------------------------- 194 # prefers_gateway 195 # --------------------------------------------------------------------------- 196 class TestPrefersGateway: 197 """Honor bool-ish config values for tool gateway routing.""" 198 199 def test_returns_false_for_quoted_false(self, monkeypatch): 200 monkeypatch.setattr( 201 "hermes_cli.config.load_config", 202 lambda: {"web": {"use_gateway": "false"}}, 203 ) 204 assert prefers_gateway("web") is False 205 206 def test_returns_true_for_quoted_true(self, monkeypatch): 207 monkeypatch.setattr( 208 "hermes_cli.config.load_config", 209 lambda: {"web": {"use_gateway": "true"}}, 210 ) 211 assert prefers_gateway("web") is True 212 213 214 # --------------------------------------------------------------------------- 215 # resolve_modal_backend_state 216 # --------------------------------------------------------------------------- 217 class TestResolveModalBackendState: 218 """Full matrix of direct vs managed Modal backend selection.""" 219 220 @staticmethod 221 def _resolve(monkeypatch, mode, *, has_direct, managed_ready, nous_enabled=False): 222 """Helper to call resolve_modal_backend_state with feature flag control.""" 223 monkeypatch.setattr( 224 "tools.tool_backend_helpers.managed_nous_tools_enabled", 225 lambda: nous_enabled, 226 ) 227 return resolve_modal_backend_state( 228 mode, has_direct=has_direct, managed_ready=managed_ready 229 ) 230 231 # --- auto mode --- 232 233 def test_auto_prefers_managed_when_available(self, monkeypatch): 234 result = self._resolve(monkeypatch, "auto", has_direct=True, managed_ready=True, nous_enabled=True) 235 assert result["selected_backend"] == "managed" 236 237 def test_auto_falls_back_to_direct(self, monkeypatch): 238 result = self._resolve(monkeypatch, "auto", has_direct=True, managed_ready=False, nous_enabled=True) 239 assert result["selected_backend"] == "direct" 240 241 def test_auto_no_backends_available(self, monkeypatch): 242 result = self._resolve(monkeypatch, "auto", has_direct=False, managed_ready=False) 243 assert result["selected_backend"] is None 244 245 def test_auto_managed_ready_but_nous_disabled(self, monkeypatch): 246 result = self._resolve(monkeypatch, "auto", has_direct=True, managed_ready=True, nous_enabled=False) 247 assert result["selected_backend"] == "direct" 248 249 def test_auto_nothing_when_only_managed_and_nous_disabled(self, monkeypatch): 250 result = self._resolve(monkeypatch, "auto", has_direct=False, managed_ready=True, nous_enabled=False) 251 assert result["selected_backend"] is None 252 253 # --- direct mode --- 254 255 def test_direct_selects_direct_when_available(self, monkeypatch): 256 result = self._resolve(monkeypatch, "direct", has_direct=True, managed_ready=True, nous_enabled=True) 257 assert result["selected_backend"] == "direct" 258 259 def test_direct_none_when_no_credentials(self, monkeypatch): 260 result = self._resolve(monkeypatch, "direct", has_direct=False, managed_ready=True, nous_enabled=True) 261 assert result["selected_backend"] is None 262 263 # --- managed mode --- 264 265 def test_managed_selects_managed_when_ready_and_enabled(self, monkeypatch): 266 result = self._resolve(monkeypatch, "managed", has_direct=True, managed_ready=True, nous_enabled=True) 267 assert result["selected_backend"] == "managed" 268 269 def test_managed_none_when_not_ready(self, monkeypatch): 270 result = self._resolve(monkeypatch, "managed", has_direct=True, managed_ready=False, nous_enabled=True) 271 assert result["selected_backend"] is None 272 273 def test_managed_blocked_when_nous_disabled(self, monkeypatch): 274 result = self._resolve(monkeypatch, "managed", has_direct=True, managed_ready=True, nous_enabled=False) 275 assert result["selected_backend"] is None 276 assert result["managed_mode_blocked"] is True 277 278 # --- return structure --- 279 280 def test_return_dict_keys(self, monkeypatch): 281 result = self._resolve(monkeypatch, "auto", has_direct=True, managed_ready=False) 282 expected_keys = { 283 "requested_mode", 284 "mode", 285 "has_direct", 286 "managed_ready", 287 "managed_mode_blocked", 288 "selected_backend", 289 } 290 assert set(result.keys()) == expected_keys 291 292 def test_passthrough_flags(self, monkeypatch): 293 result = self._resolve(monkeypatch, "direct", has_direct=True, managed_ready=False) 294 assert result["requested_mode"] == "direct" 295 assert result["mode"] == "direct" 296 assert result["has_direct"] is True 297 assert result["managed_ready"] is False 298 299 # --- invalid mode falls back to auto --- 300 301 def test_invalid_mode_treated_as_auto(self, monkeypatch): 302 result = self._resolve(monkeypatch, "bogus", has_direct=True, managed_ready=False) 303 assert result["requested_mode"] == "auto" 304 assert result["mode"] == "auto" 305 306 307 # --------------------------------------------------------------------------- 308 # resolve_openai_audio_api_key 309 # --------------------------------------------------------------------------- 310 class TestResolveOpenaiAudioApiKey: 311 """Priority: VOICE_TOOLS_OPENAI_KEY > OPENAI_API_KEY.""" 312 313 def test_voice_key_preferred(self, monkeypatch): 314 monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "voice-key") 315 monkeypatch.setenv("OPENAI_API_KEY", "general-key") 316 assert resolve_openai_audio_api_key() == "voice-key" 317 318 def test_falls_back_to_openai_key(self, monkeypatch): 319 monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False) 320 monkeypatch.setenv("OPENAI_API_KEY", "general-key") 321 assert resolve_openai_audio_api_key() == "general-key" 322 323 def test_empty_voice_key_falls_back(self, monkeypatch): 324 monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "") 325 monkeypatch.setenv("OPENAI_API_KEY", "general-key") 326 assert resolve_openai_audio_api_key() == "general-key" 327 328 def test_no_keys_returns_empty(self, monkeypatch): 329 monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False) 330 monkeypatch.delenv("OPENAI_API_KEY", raising=False) 331 assert resolve_openai_audio_api_key() == "" 332 333 def test_strips_whitespace(self, monkeypatch): 334 monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", " voice-key ") 335 monkeypatch.delenv("OPENAI_API_KEY", raising=False) 336 assert resolve_openai_audio_api_key() == "voice-key"