hermes-agent/tests/tools/test_transcription_dotenv_fallback.py
Teknium 39975613b1
test: prune wave 2 + speed fixes — 28,106 → 19,757 test functions, suite wall 315s → 294s
Second, deeper pass over tools/gateway/hermes_cli plus first pass over
the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker,
dashboard, conformance, monitoring, secret_sources, hermes_state,
providers). Same rubric as wave 1 (AGENTS.md test policy); security,
alternation/caching invariants, issue-number regressions, and E2E kept.

Real test-quality fixes found and rooted out along the way:
- tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls
  (DEFAULT_CONFIG smart-approval leaked in) — pinned approval
  mode=manual via autouse fixture: 17.4s → 0.4s.
- test_model_switch_custom_providers.py / test_user_providers_model_switch.py
  silently probed live provider catalogs (~2s/test) — stubbed
  cached_provider_model_ids/provider_model_ids/fetch_api_models.
- test_telegram_noise_filter.py: 15-platform copy-paste matrix over
  shared gateway.run logic → 3 representative platforms (55s → 3.9s).
- test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on
  MagicMock agents — interrupt.side_effect now clears _running_agents
  (22s → 1.0s).
- test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x
  (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps
  patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait
  5s → 0.5s.
- test_telegram_init_deadline.py: loop-block margin restored to 1.0s
  with rationale comment — the watchdog-dump assertion needs the loop
  blocked well past deadline+grace under parallel load (flaked once in
  the 40-worker verification run at a 0.2s margin).

Verification: full hermetic suite via scripts/run_tests.sh —
2,438 files, 21,718 tests passed, 0 failed, 293.9s wall.
Suite totals vs original baseline: 46,820 → 19,757 test functions
(−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
2026-07-29 13:39:40 -07:00

240 lines
9.6 KiB
Python

"""Regression tests for the transcription_tools variant of #17140.
Same class of bug as ``tools/tts_tool.py`` (fixed in PR #17163): the STT
provider call sites read API keys via ``os.getenv()``, which bypasses
``~/.hermes/.env`` entries. These tests confirm each STT provider now
consults ``get_env_value()`` and the provider auto-detect + explicit
selection gate (``_get_provider``) do the same.
"""
from unittest.mock import MagicMock, patch
import pytest
pytestmark = pytest.mark.usefixtures("disable_lazy_stt_install")
@pytest.fixture(autouse=True)
def isolate_env(monkeypatch):
"""Strip every STT-related env var so the test really exercises the
dotenv code path. If any of these survive into the test, the assertion
that ``get_env_value`` was consulted becomes meaningless because
``os.environ`` already satisfies the lookup.
"""
for key in (
"GROQ_API_KEY",
"MISTRAL_API_KEY",
"XAI_API_KEY",
"XAI_STT_BASE_URL",
"ELEVENLABS_API_KEY",
"ELEVENLABS_STT_BASE_URL",
):
monkeypatch.delenv(key, raising=False)
class TestProviderSelectionGate:
"""``_get_provider`` picks the STT backend. If it only consulted
``os.environ`` a user with keys in ``~/.hermes/.env`` would be told
"no STT available" even though the actual transcribe call would
succeed. The gate lives behind ``is_stt_enabled(stt_config)``, so
configure ``{"enabled": True, "provider": ...}`` for explicit tests.
"""
def test_import_after_config_env_patch_uses_restored_dotenv_loader(self):
"""Importing STT while hermes_cli.config.get_env_value is patched must
not freeze that temporary helper into this module forever.
"""
import importlib
import hermes_cli.config as config_mod
from tools import transcription_tools as tt
with pytest.MonkeyPatch.context() as mp:
mp.setattr(config_mod, "get_env_value", lambda name, default=None: "")
tt = importlib.reload(tt)
try:
with patch.object(tt, "_HAS_FASTER_WHISPER", False), \
patch.object(tt, "_HAS_OPENAI", True), \
patch.object(tt, "_has_local_command", return_value=False), \
patch("hermes_cli.config.load_env",
return_value={"GROQ_API_KEY": "dotenv-secret"}):
assert tt._get_provider({"enabled": True, "provider": "groq"}) == "groq"
finally:
importlib.reload(tt)
def test_xai_resolver_import_after_config_env_patch_uses_restored_dotenv_loader(self):
"""xAI HTTP auth must not cache a temporarily patched env helper."""
import importlib
import hermes_cli.config as config_mod
from tools import xai_http
with pytest.MonkeyPatch.context() as mp:
mp.setattr(config_mod, "get_env_value", lambda name, default=None: "")
xai_http = importlib.reload(xai_http)
try:
with patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
side_effect=RuntimeError("no oauth"),
), patch(
"hermes_cli.auth.resolve_xai_oauth_runtime_credentials",
return_value={},
), patch(
"hermes_cli.config.load_env",
return_value={"XAI_API_KEY": "dotenv-secret"},
):
creds = xai_http.resolve_xai_http_credentials()
finally:
importlib.reload(xai_http)
assert creds["api_key"] == "dotenv-secret"
def test_auto_detect_sees_dotenv_groq(self):
"""No local backend, no explicit provider — auto-detect should fall
through to Groq when its key lives in dotenv only. Before the fix
it would return 'none'."""
from tools import transcription_tools as tt
with patch.object(tt, "_HAS_FASTER_WHISPER", False), \
patch.object(tt, "_HAS_OPENAI", True), \
patch.object(tt, "_HAS_MISTRAL", False), \
patch.object(tt, "_has_local_command", return_value=False), \
patch.object(tt, "_has_openai_audio_backend", return_value=False), \
patch("hermes_cli.config.load_env",
return_value={"GROQ_API_KEY": "dotenv-secret"}):
# No "provider" key → explicit=False → auto-detect branch
assert tt._get_provider({"enabled": True}) == "groq"
class TestTranscribeCallSitesReadDotenv:
"""The actual transcribe functions must forward the dotenv-resolved
key into the provider SDK / HTTP call. We mock ``get_env_value`` and
capture what gets passed through."""
def test_transcribe_groq_forwards_dotenv_key(self):
from tools import transcription_tools as tt
seen_keys: list = []
class FakeOpenAIClient:
def __init__(self, *, api_key=None, base_url=None, timeout=None, max_retries=None):
seen_keys.append(api_key)
self.audio = MagicMock()
self.audio.transcriptions.create.return_value = "hello"
def close(self):
pass
fake_openai_module = MagicMock()
fake_openai_module.OpenAI = FakeOpenAIClient
fake_openai_module.APIError = Exception
fake_openai_module.APIConnectionError = Exception
fake_openai_module.APITimeoutError = Exception
with patch.object(tt, "get_env_value", return_value="groq-dotenv-key"), \
patch.object(tt, "_HAS_OPENAI", True), \
patch.dict("sys.modules", {"openai": fake_openai_module}), \
patch("builtins.open", MagicMock()):
result = tt._transcribe_groq("/tmp/fake.mp3", "whisper-large-v3-turbo")
assert result["success"] is True
assert seen_keys == ["groq-dotenv-key"]
def test_transcribe_xai_forwards_dotenv_key(self):
"""An explicit XAI_API_KEY must win over Grok subscription OAuth for STT."""
from tools import transcription_tools as tt
from tools import xai_http
captured: dict = {}
def fake_post(url, **kwargs):
captured["url"] = url
captured["headers"] = kwargs.get("headers", {})
response = MagicMock()
response.status_code = 200
response.raise_for_status = MagicMock()
response.json.return_value = {"text": "hello"}
return response
def fake_get_env_value(name, default=None):
if name == "XAI_API_KEY":
return "xai-dotenv-key"
return None
with patch.object(tt, "get_env_value", side_effect=fake_get_env_value), \
patch.object(xai_http, "resolve_xai_http_credentials", return_value={
"provider": "xai-oauth",
"api_key": "subscription-oauth-token",
"base_url": "https://api.x.ai/v1",
}), \
patch("requests.post", side_effect=fake_post), \
patch("builtins.open", MagicMock()):
result = tt._transcribe_xai("/tmp/fake.mp3", "grok-stt")
assert result["success"] is True
assert captured["headers"]["Authorization"] == "Bearer xai-dotenv-key"
def test_transcribe_elevenlabs_forwards_dotenv_key(self):
from tools import transcription_tools as tt
captured: dict = {}
def fake_post(url, **kwargs):
captured["url"] = url
captured["headers"] = kwargs.get("headers", {})
response = MagicMock()
response.status_code = 200
response.json.return_value = {"text": "hello"}
return response
def fake_get_env_value(name, default=None):
if name == "ELEVENLABS_API_KEY":
return "elevenlabs-dotenv-key"
return None
with patch.object(tt, "get_env_value", side_effect=fake_get_env_value), \
patch.object(tt, "_load_stt_config", return_value={}), \
patch("requests.post", side_effect=fake_post), \
patch("builtins.open", MagicMock()):
result = tt._transcribe_elevenlabs("/tmp/fake.mp3", "scribe_v2")
assert result["success"] is True
assert captured["headers"]["xi-api-key"] == "elevenlabs-dotenv-key"
class TestEndToEndRegressionGuard:
"""End-to-end probe: patch ``hermes_cli.config.load_env`` to simulate
``~/.hermes/.env`` carrying the key while ``os.environ`` does not.
Before the fix ``_transcribe_xai`` called ``os.getenv("XAI_API_KEY")``
directly and returned ``XAI_API_KEY not set``."""
def test_xai_key_only_in_dotenv_before_fix(self, monkeypatch):
from tools import transcription_tools as tt
monkeypatch.delenv("XAI_API_KEY", raising=False)
captured: dict = {}
def fake_post(url, **kwargs):
captured["headers"] = kwargs.get("headers", {})
response = MagicMock()
response.status_code = 200
response.raise_for_status = MagicMock()
response.json.return_value = {"text": "ok"}
return response
with patch("hermes_cli.config.load_env",
return_value={"XAI_API_KEY": "dotenv-secret"}):
# Sanity: get_env_value resolves through load_env when
# os.environ is empty.
from hermes_cli.config import get_env_value as live_get
assert live_get("XAI_API_KEY") == "dotenv-secret"
with patch("requests.post", side_effect=fake_post), \
patch("builtins.open", MagicMock()):
result = tt._transcribe_xai("/tmp/fake.mp3", "grok-stt")
assert result["success"] is True
assert captured["headers"]["Authorization"] == "Bearer dotenv-secret"