hermes-agent/tests/tools/test_tts_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

236 lines
9.4 KiB
Python

"""Regression tests for #17140.
TTS provider tools must resolve API keys from ``~/.hermes/.env`` (via
``hermes_cli.config.get_env_value``) and not only from ``os.environ`` —
otherwise users who keep their keys in the dotenv file see "API key not set"
errors even though the key is configured. Same class of bug as #15914 (auth)
already addressed for ``agent/credential_pool`` and ``hermes_cli/auth``.
"""
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture(autouse=True)
def isolate_env(monkeypatch):
"""Strip every TTS-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 (
"ELEVENLABS_API_KEY",
"XAI_API_KEY",
"XAI_BASE_URL",
"MINIMAX_API_KEY",
"MISTRAL_API_KEY",
"GEMINI_API_KEY",
"GEMINI_BASE_URL",
"GOOGLE_API_KEY",
):
monkeypatch.delenv(key, raising=False)
class TestDotenvFallbackPerProvider:
"""For each affected provider, when only ``~/.hermes/.env`` carries the
key, the provider must find it. These per-provider tests model that
dotenv-backed lookup by mocking ``tools.tts_tool.get_env_value`` directly;
the separate regression-guard tests cover the lower-level
``hermes_cli.config.load_env`` integration. Before the fix, ``os.getenv``
returned ``None`` and the provider raised
``ValueError("X_API_KEY not set")``.
"""
def test_elevenlabs_reads_dotenv_key(self, tmp_path):
from tools import tts_tool
with patch.object(tts_tool, "get_env_value", return_value="el-dotenv-key"), \
patch.object(tts_tool, "_import_elevenlabs") as mock_import:
mock_client = MagicMock()
mock_client.text_to_speech.convert.return_value = iter([b"audio"])
mock_import.return_value = MagicMock(return_value=mock_client)
output = str(tmp_path / "out.mp3")
tts_tool._generate_elevenlabs("hi", output, {})
mock_import.return_value.assert_called_once_with(api_key="el-dotenv-key")
def test_xai_reads_dotenv_key(self, tmp_path):
"""xAI TTS now resolves credentials through ``tools.xai_http``; the
dotenv fallback contract from #17140 is preserved by patching the
resolver's ``get_env_value`` rather than ``tts_tool.get_env_value``.
"""
from tools import tts_tool
from tools import xai_http
captured: dict = {}
def fake_post(url, **kwargs):
captured["url"] = url
captured["headers"] = kwargs.get("headers", {})
response = MagicMock()
response.content = b"audio"
response.raise_for_status = MagicMock()
return response
with patch.object(xai_http, "get_env_value", return_value="xai-dotenv-key"), \
patch("requests.post", side_effect=fake_post):
tts_tool._generate_xai_tts("hi", str(tmp_path / "out.mp3"), {})
assert captured["headers"]["Authorization"] == "Bearer xai-dotenv-key"
def test_gemini_reads_dotenv_key(self, tmp_path):
from tools import tts_tool
captured: dict = {}
def fake_post(url, **kwargs):
captured["params"] = kwargs.get("params", {})
response = MagicMock()
response.status_code = 200
response.json.return_value = {
"candidates": [
{
"content": {
"parts": [
{
"inlineData": {
"data": "AAAA",
"mimeType": "audio/L16;codec=pcm;rate=24000",
}
}
]
}
}
]
}
response.raise_for_status = MagicMock()
return response
# GEMINI_API_KEY hits the first branch; GOOGLE_API_KEY would only be
# consulted if the first returned None. Use a side-effect-style mock
# to verify the lookup order matches the production code.
seen_lookups: list = []
def fake_get_env_value(key):
seen_lookups.append(key)
if key == "GEMINI_API_KEY":
return "gemini-dotenv-key"
return None
with patch.object(tts_tool, "get_env_value", side_effect=fake_get_env_value), \
patch("requests.post", side_effect=fake_post):
tts_tool._generate_gemini_tts("hi", str(tmp_path / "out.wav"), {})
assert "GEMINI_API_KEY" in seen_lookups
assert captured["params"]["key"] == "gemini-dotenv-key"
class TestRegressionGuard:
"""Goal-backward proof that the old behaviour ('only check ``os.environ``')
breaks reading from a dotenv-only key, and the new behaviour fixes it.
Implemented as an end-to-end probe that patches
``hermes_cli.config.load_env`` to simulate ``~/.hermes/.env`` carrying the
key while ``os.environ`` does not.
"""
def test_import_after_config_env_patch_uses_restored_dotenv_loader(self, tmp_path, monkeypatch):
"""Importing TTS 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 tts_tool
monkeypatch.delenv("MINIMAX_API_KEY", raising=False)
with pytest.MonkeyPatch.context() as mp:
mp.setattr(config_mod, "get_env_value", lambda name: "")
tts_tool = importlib.reload(tts_tool)
try:
captured: dict = {}
def fake_post(url, **kwargs):
captured["headers"] = kwargs.get("headers", {})
response = MagicMock()
response.json.return_value = {
"data": {"audio": b"\x00".hex()},
"base_resp": {"status_code": 0},
}
response.raise_for_status = MagicMock()
return response
with patch(
"hermes_cli.config.load_env",
return_value={"MINIMAX_API_KEY": "dotenv-secret"},
), patch("requests.post", side_effect=fake_post):
tts_tool._generate_minimax_tts(
"hi", str(tmp_path / "out.mp3"), {}
)
assert captured["headers"]["Authorization"] == "Bearer dotenv-secret"
finally:
importlib.reload(tts_tool)
def test_minimax_missing_when_only_in_dotenv_before_fix(self, tmp_path, monkeypatch):
from tools import tts_tool
monkeypatch.delenv("MINIMAX_API_KEY", raising=False)
# Simulate ~/.hermes/.env carrying the key (load_env returns the dict
# that get_env_value falls back to). The pre-fix ``os.getenv`` call
# ignores this entirely and raises ValueError.
with patch(
"hermes_cli.config.load_env",
return_value={"MINIMAX_API_KEY": "dotenv-secret"},
):
# Sanity-check: 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("MINIMAX_API_KEY") == "dotenv-secret"
# And the production code path now consumes the resolved value
# instead of raising "MINIMAX_API_KEY not set".
captured: dict = {}
def fake_post(url, **kwargs):
captured["headers"] = kwargs.get("headers", {})
response = MagicMock()
response.json.return_value = {
"data": {"audio": b"\x00".hex()},
"base_resp": {"status_code": 0},
}
response.raise_for_status = MagicMock()
return response
with patch("requests.post", side_effect=fake_post):
tts_tool._generate_minimax_tts(
"hi", str(tmp_path / "out.mp3"), {}
)
assert captured["headers"]["Authorization"] == "Bearer dotenv-secret"
def test_check_tts_requirements_sees_dotenv_minimax(self, monkeypatch):
"""``check_tts_requirements`` is the gate that decides whether
``/voice on`` is even offered. If it only checked ``os.environ`` it
would say "no provider available" for users who keep MINIMAX_API_KEY
in ``~/.hermes/.env``, even though the dispatcher would later succeed.
"""
from tools import tts_tool
monkeypatch.delenv("MINIMAX_API_KEY", raising=False)
with patch(
"hermes_cli.config.load_env",
return_value={"MINIMAX_API_KEY": "dotenv-secret"},
), patch.object(
tts_tool, "_load_tts_config", return_value={"provider": "minimax"}
), patch.object(tts_tool, "_import_edge_tts", side_effect=ImportError), \
patch.object(tts_tool, "_import_elevenlabs", side_effect=ImportError), \
patch.object(tts_tool, "_import_openai_client", side_effect=ImportError), \
patch.object(tts_tool, "_check_neutts_available", return_value=False), \
patch.object(tts_tool, "_check_kittentts_available", return_value=False):
assert tts_tool.check_tts_requirements() is True