hermes-agent/tests/hermes_cli/test_whatsapp_cloud_setup.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

212 lines
8 KiB
Python

"""Tests for the WhatsApp Cloud API setup wizard.
Covers:
- Field-shape validators (catch the #1 setup mistake — phone number in
the Phone Number ID field — plus the OpenAI / Slack / GitHub token
paste-by-mistake cases)
- Wizard end-to-end flow with mocked stdin/stdout — verifies each step
writes the expected env var, validation errors block invalid input,
optional fields can be skipped, and the SETUP COMPLETE block prints
the post-setup tunnel + Meta-dashboard instructions the user needs
(the wizard can't smoke-test reachability itself because the gateway
isn't running yet during setup).
"""
from __future__ import annotations
import io
import os
from contextlib import redirect_stdout
from pathlib import Path
import pytest
from hermes_cli.setup_whatsapp_cloud import (
_validate_phone_number_id,
_validate_waba_id,
_validate_app_id,
_validate_app_secret,
_validate_access_token,
run_whatsapp_cloud_setup,
)
# ---------------------------------------------------------------------------
# Validator tests — the cheap, exhaustive coverage layer
# ---------------------------------------------------------------------------
class TestPhoneNumberIdValidator:
def test_accepts_real_meta_phone_number_id(self):
ok, _ = _validate_phone_number_id("7794189252778687")
assert ok
def test_rejects_actual_phone_number_with_helpful_message(self):
"""The #1 setup trap — pasting the phone number instead of the ID."""
ok, reason = _validate_phone_number_id("15556422442")
assert not ok
assert "phone number" in reason.lower()
assert "Phone number ID" in reason # tells them where to look
class TestAccessTokenValidator:
def test_rejects_empty(self):
ok, reason = _validate_access_token("")
assert not ok
assert "required" in reason.lower()
def test_rejects_github_token_with_helpful_message(self):
ok, reason = _validate_access_token("ghp_abcdefghijklmnop")
assert not ok
assert "GitHub" in reason
class TestAppSecretValidator:
def test_rejects_wrong_length(self):
ok, reason = _validate_app_secret("0123456789abcdef") # 16 chars
assert not ok
assert "32" in reason
def test_rejects_non_hex(self):
ok, reason = _validate_app_secret("zzzz56789abcdef0123456789abcdezz")
assert not ok
assert "hex" in reason.lower()
class TestWabaIdValidator:
def test_rejects_non_numeric(self):
ok, _ = _validate_waba_id("abc-def")
assert not ok
# ---------------------------------------------------------------------------
# End-to-end wizard flow
# ---------------------------------------------------------------------------
@pytest.fixture
def isolated_home(tmp_path, monkeypatch):
"""Redirect HERMES_HOME so save_env_value writes into a temp .env."""
home = tmp_path / "home"
hermes = home / ".hermes"
hermes.mkdir(parents=True)
monkeypatch.setattr(Path, "home", lambda: home)
monkeypatch.setenv("HERMES_HOME", str(hermes))
for key in list(os.environ):
if key.startswith("WHATSAPP_CLOUD_"):
monkeypatch.delenv(key, raising=False)
return hermes
def _env_value(hermes_home: Path, key: str) -> str | None:
env_file = hermes_home / ".env"
if not env_file.exists():
return None
for line in env_file.read_text().splitlines():
if "=" not in line:
continue
k, _, v = line.partition("=")
if k.strip() == key:
return v.strip().strip('"').strip("'")
return None
class TestWizardFlow:
def test_happy_path_minimal(self, isolated_home, monkeypatch):
"""Provide only the required fields; skip optional steps."""
inputs = iter([
"", # press Enter to continue
"7794189252778687", # Phone Number ID
"EAA" + "x" * 200, # Access Token
"0123456789abcdef0123456789abcdef", # App Secret
"", # App ID — skip
"", # WABA ID — skip
"15551234567", # Allowed users
])
monkeypatch.setattr("builtins.input", lambda *a, **kw: next(inputs))
buf = io.StringIO()
with redirect_stdout(buf):
rc = run_whatsapp_cloud_setup()
assert rc == 0
out = buf.getvalue()
assert "SETUP COMPLETE" in out
# Required fields written
assert _env_value(isolated_home, "WHATSAPP_CLOUD_PHONE_NUMBER_ID") == "7794189252778687"
assert _env_value(isolated_home, "WHATSAPP_CLOUD_ACCESS_TOKEN").startswith("EAA")
assert len(_env_value(isolated_home, "WHATSAPP_CLOUD_APP_SECRET")) == 32
assert _env_value(isolated_home, "WHATSAPP_CLOUD_ALLOWED_USERS") == "15551234567"
# Verify token auto-generated
assert _env_value(isolated_home, "WHATSAPP_CLOUD_VERIFY_TOKEN")
# Optional fields stayed unset
assert _env_value(isolated_home, "WHATSAPP_CLOUD_APP_ID") is None
assert _env_value(isolated_home, "WHATSAPP_CLOUD_WABA_ID") is None
def test_verify_token_is_auto_generated(self, isolated_home, monkeypatch):
"""The verify token is one of the few things the user shouldn't
have to invent. Wizard generates a strong random one."""
inputs = iter([
"", # continue
"7794189252778687", # Phone ID
"EAA" + "x" * 200, # Token
"0123456789abcdef0123456789abcdef", # App Secret
"", # App ID — skip
"", # WABA ID — skip
"15551234567", # Allowed users
])
monkeypatch.setattr("builtins.input", lambda *a, **kw: next(inputs))
buf = io.StringIO()
with redirect_stdout(buf):
run_whatsapp_cloud_setup()
verify_token = _env_value(isolated_home, "WHATSAPP_CLOUD_VERIFY_TOKEN")
assert verify_token is not None
# secrets.token_urlsafe(32) produces ~43 chars (base64-of-32-bytes)
assert len(verify_token) >= 32
# Should also be echoed to user output so they can paste into Meta
assert verify_token in buf.getvalue()
# =========================================================================
# Profile polish block (SETUP COMPLETE → optional WhatsApp profile setup)
# =========================================================================
class TestProfilePolishGuidance:
"""The wizard can't set the bot's WhatsApp display name or profile
picture via the API — those go through Meta's Business Manager UI.
Verify that the SETUP COMPLETE block points the user at the right
place rather than leaving them to figure it out on their own."""
def test_polish_block_deeplinks_when_waba_id_known(
self, isolated_home, monkeypatch
):
"""If the user gave us the WABA ID earlier in the wizard, the
Business Manager URL should pre-select their account."""
waba = "987654321098765"
inputs = iter([
"",
"7794189252778687",
"EAA" + "x" * 200,
"0123456789abcdef0123456789abcdef",
"", # App ID — skip
waba, # WABA ID — provided
"15551234567",
])
monkeypatch.setattr("builtins.input", lambda *a, **kw: next(inputs))
buf = io.StringIO()
with redirect_stdout(buf):
run_whatsapp_cloud_setup()
out = buf.getvalue()
# Deep-linked URL with the user's WABA pre-selected
assert f"waba_id={waba}" in out
# Without WABA, we tell the user they'll need to pick their account
assert "select your WhatsApp Business Account" not in out