/ tests / tools / test_tool_backend_helpers.py
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"