hermes-agent/tests/gateway/test_whatsapp_cloud.py
Teknium 6b81590c55
test: prune low-value tests suite-wide (wave 1) — 46,820 → 28,106 test functions
Systematic prune per AGENTS.md test policy, one pass over every major
test tree (gateway, hermes_cli, tools, agent, run_agent, plugins, cli,
cron, tui_gateway, honcho/openviking, root-level):

- DELETE: source-reading tests (read_text/getsource on prod files),
  change-detector tests (exact catalog counts, model-name snapshots,
  config version literals), mock-echo tests (assert a mock returns what
  it was told), assertion-free/trivial tests, near-duplicate
  parametrizations (boundaries + one representative kept), async/sync
  twin duplicates, cosmetic within-file variations.
- KEEP (mandatory): security/redaction/approval guards, message-role
  alternation invariants, prompt-caching/deterministic-call-id
  invariants, issue-number regression tests (deduped), E2E tests.
- 6 test files deleted outright (script-style/no-assert or fully
  redundant); conftest.py, fakes/, fixtures/ untouched.
- tests/acp/conftest.py added: autouse fixture stubs the live
  models.dev/GitHub/Copilot/Anthropic inventory fetches that ACP server
  tests performed on every session create — test_server.py 147s → 3.4s,
  and the tests are now genuinely hermetic.
- Sleep-based slowness shrunk where safe (codex_ttfb_watchdog,
  compression_concurrent_fork, etc.); no wall-clock assertion tightened.

Verification: full hermetic suite via scripts/run_tests.sh —
2439 files, 31,130 tests passed, 0 failed, 0 flaky retries, 315s wall
(baseline: 583s wall, 13,564s subprocess CPU).
2026-07-29 13:10:23 -07:00

1402 lines
52 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for the WhatsApp Cloud API adapter (Phase 2).
Covers the outbound Graph API send path and the inbound verify-token
handshake. The webhook POST path is currently a stub (Phase 3 will add
signature verification + dispatch); we just confirm it accepts a body
and returns 200 here.
All tests are fixture-driven — no live network. httpx is patched so the
adapter never reaches graph.facebook.com, and the aiohttp server is
exercised with synthetic ``Request`` objects.
"""
from __future__ import annotations
import asyncio
import json
from unittest.mock import AsyncMock, MagicMock
import pytest
from gateway.config import Platform
@pytest.fixture(autouse=True)
def _whatsapp_open_optin(monkeypatch):
"""Opt into WhatsApp allow-all for the file's dispatch-mechanics tests.
The adapter now fails closed on ``dm_policy: open`` unless
``WHATSAPP_ALLOW_ALL_USERS`` / ``GATEWAY_ALLOW_ALL_USERS`` is set
(SECURITY.md 2.6). These tests set ``_dm_policy = "open"`` as a stand-in
for "process this DM" while exercising unrelated dispatch mechanics, so
grant the opt-in here. Tests that specifically assert the gate override
this within their own body.
"""
monkeypatch.setenv("WHATSAPP_ALLOW_ALL_USERS", "true")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_adapter(**overrides):
"""Build a WhatsAppCloudAdapter with test attributes (bypass __init__).
Mirrors the pattern in tests/gateway/test_whatsapp_*.py.
"""
from gateway.platforms.whatsapp_cloud import WhatsAppCloudAdapter
adapter = WhatsAppCloudAdapter.__new__(WhatsAppCloudAdapter)
adapter.platform = Platform.WHATSAPP_CLOUD
adapter.config = MagicMock()
adapter.config.extra = {}
# Cloud-API-specific attributes
adapter._phone_number_id = overrides.pop("phone_number_id", "1234567890")
adapter._access_token = overrides.pop("access_token", "test-token")
adapter._app_id = overrides.pop("app_id", "")
adapter._app_secret = overrides.pop("app_secret", "")
adapter._waba_id = overrides.pop("waba_id", "")
adapter._verify_token = overrides.pop("verify_token", "")
adapter._webhook_host = "127.0.0.1"
adapter._webhook_port = 8090
adapter._webhook_path = "/whatsapp/webhook"
adapter._health_path = "/health"
adapter._api_version = overrides.pop("api_version", "v20.0")
adapter._runner = None
adapter._http_client = None
# Behavior-mixin contract
adapter._reply_prefix = None
adapter._dm_policy = "open"
adapter._allow_from = set()
adapter._group_policy = "open"
adapter._group_allow_from = set()
adapter._mention_patterns = []
# Webhook dispatch state (Phase 3)
from collections import OrderedDict
adapter._seen_wamids = OrderedDict()
adapter._duplicate_count = 0
adapter._accepted_count = 0
adapter._rejected_signature_count = 0
# Phase 4 state — one-shot warnings.
adapter._warned_no_ffmpeg = False
# Phase 10 state — per-chat latest inbound wamid (for typing/read).
adapter._last_inbound_wamid_by_chat = {}
# Phase 9 state — interactive-button correlation dicts.
adapter._clarify_state = {}
adapter._exec_approval_state = {}
adapter._slash_confirm_state = {}
# BasePlatformAdapter contract — minimum to keep send/lifecycle happy
adapter._running = True
adapter._message_handler = None
adapter._fatal_error_code = None
adapter._fatal_error_message = None
adapter._fatal_error_retryable = True
adapter._fatal_error_handler = None
adapter._active_sessions = {}
adapter._pending_messages = {}
adapter._background_tasks = set()
adapter._auto_tts_disabled_chats = set()
# Apply any leftover overrides directly
for key, value in overrides.items():
setattr(adapter, key, value)
return adapter
@pytest.fixture
def authorized_interactive_env(monkeypatch):
"""``dm_policy: open`` requires an explicit allow-all opt-in on main."""
monkeypatch.setenv("WHATSAPP_ALLOW_ALL_USERS", "true")
def _mock_httpx_response(status_code: int, json_body: dict):
"""Build an httpx-Response-like mock the adapter's ``send`` will accept."""
resp = MagicMock()
resp.status_code = status_code
resp.json = MagicMock(return_value=json_body)
resp.text = json.dumps(json_body)
return resp
# ---------------------------------------------------------------------------
# Outbound send via Graph API
# ---------------------------------------------------------------------------
class TestSendText:
"""Outbound text-message path."""
@pytest.mark.asyncio
async def test_send_includes_bearer_auth(self):
adapter = _make_adapter(access_token="my-secret-token")
adapter._http_client = MagicMock()
adapter._http_client.post = AsyncMock(
return_value=_mock_httpx_response(
200, {"messages": [{"id": "wamid.abc"}]}
)
)
await adapter.send("15551234567", "hi")
headers = adapter._http_client.post.call_args.kwargs["headers"]
assert headers["Authorization"] == "Bearer my-secret-token"
assert headers["Content-Type"] == "application/json"
@pytest.mark.asyncio
async def test_send_payload_shape(self):
adapter = _make_adapter()
adapter._http_client = MagicMock()
adapter._http_client.post = AsyncMock(
return_value=_mock_httpx_response(
200, {"messages": [{"id": "wamid.abc"}]}
)
)
await adapter.send("15551234567", "hello world")
payload = adapter._http_client.post.call_args.kwargs["json"]
assert payload["messaging_product"] == "whatsapp"
assert payload["recipient_type"] == "individual"
assert payload["to"] == "15551234567"
assert payload["type"] == "text"
assert payload["text"]["body"] == "hello world"
assert payload["text"]["preview_url"] is True
@pytest.mark.asyncio
async def test_send_long_message_chunked(self):
"""Messages over the chunk limit are split into multiple POSTs."""
adapter = _make_adapter()
adapter._http_client = MagicMock()
adapter._http_client.post = AsyncMock(
return_value=_mock_httpx_response(
200, {"messages": [{"id": "wamid.x"}]}
)
)
# MAX_MESSAGE_LENGTH = 4096 from the mixin. 8500 chars forces 2+ chunks.
long_text = "a" * 8500
await adapter.send("15551234567", long_text)
# At least 2 POST calls
assert adapter._http_client.post.call_count >= 2
# Second call should NOT have context (only first chunk gets reply_to)
first_call = adapter._http_client.post.call_args_list[0]
second_call = adapter._http_client.post.call_args_list[1]
# No reply_to passed → no context anywhere, but verify structure anyway
assert "context" not in second_call.kwargs["json"]
@pytest.mark.asyncio
async def test_send_graph_error_returns_failure(self):
adapter = _make_adapter()
adapter._http_client = MagicMock()
adapter._http_client.post = AsyncMock(
return_value=_mock_httpx_response(
400,
{
"error": {
"message": "Invalid parameter",
"type": "OAuthException",
"code": 100,
"fbtrace_id": "abc",
}
},
)
)
result = await adapter.send("15551234567", "hi")
assert result.success is False
assert "graph error 100" in result.error
assert "Invalid parameter" in result.error
# ---------------------------------------------------------------------------
# Inbound webhook verify (GET) handshake
# ---------------------------------------------------------------------------
def _verify_request(query: dict):
"""Build a minimal aiohttp.web.Request stub for verify tests."""
request = MagicMock()
request.query = query
return request
class TestWebhookVerify:
"""GET <webhook>?hub.mode=...&hub.verify_token=...&hub.challenge=..."""
@pytest.mark.asyncio
async def test_verify_rejects_non_ascii_token_without_raising(self):
"""A non-ASCII verify_token (raw query param) must be rejected with
403, not crash the handler: hmac.compare_digest raises TypeError on a
str containing non-ASCII characters."""
adapter = _make_adapter(verify_token="shared-secret-123")
request = _verify_request({
"hub.mode": "subscribe",
"hub.verify_token": "ské-not-the-secret",
"hub.challenge": "abc-12345",
})
response = await adapter._handle_verify(request)
assert response.status == 403
@pytest.mark.asyncio
async def test_verify_refuses_when_token_unconfigured(self):
"""An empty verify_token must NOT match an empty incoming token —
otherwise an attacker who guesses the misconfiguration could
subscribe their own webhook URL.
"""
adapter = _make_adapter(verify_token="")
request = _verify_request({
"hub.mode": "subscribe",
"hub.verify_token": "",
"hub.challenge": "abc",
})
response = await adapter._handle_verify(request)
assert response.status == 503 # service refuses to perform handshake
# ---------------------------------------------------------------------------
# Inbound webhook POST — signature verification + dispatch (Phase 3)
# ---------------------------------------------------------------------------
import hashlib
import hmac as _hmac_lib
def _sign(secret: str, body: bytes) -> str:
"""Compute the X-Hub-Signature-256 header value Meta would send."""
digest = _hmac_lib.new(
secret.encode("utf-8"), body, hashlib.sha256
).hexdigest()
return f"sha256={digest}"
class _FakeRequestContent:
def __init__(self, body: bytes):
self.body = body
self.read_sizes: list[int] = []
async def readexactly(self, size: int) -> bytes:
self.read_sizes.append(size)
if len(self.body) < size:
raise asyncio.IncompleteReadError(self.body, size)
return self.body[:size]
def _post_request(body: bytes, headers: dict | None = None):
"""Build a minimal aiohttp.web.Request stub for POST tests."""
request = MagicMock()
request.content = _FakeRequestContent(body)
request.headers = headers or {}
return request
# A realistic Meta inbound text-message payload, modelled on the
# get-started docs sample.
_SAMPLE_INBOUND_TEXT_PAYLOAD = {
"object": "whatsapp_business_account",
"entry": [
{
"id": "215589313241560883",
"changes": [
{
"field": "messages",
"value": {
"messaging_product": "whatsapp",
"metadata": {
"display_phone_number": "15551797781",
"phone_number_id": "7794189252778687",
},
"contacts": [
{
"profile": {"name": "Jessica Laverdetman"},
"wa_id": "13557825698",
}
],
"messages": [
{
"from": "13557825698",
"id": "wamid.HBgLMTM1NTc4MjU2OTgVAGHAYWYET688aASGNTI1QzZFQjhEMDk2QQA=",
"timestamp": "1758254144",
"text": {"body": "Hi!"},
"type": "text",
}
],
},
}
],
}
],
}
class TestWebhookSignature:
"""X-Hub-Signature-256 HMAC verification."""
@pytest.mark.asyncio
async def test_valid_signature_accepted(self):
adapter = _make_adapter(app_secret="signing-key-123")
# Patch the dispatcher to a no-op so we don't depend on
# MessageEvent construction here (covered separately).
adapter._dispatch_payload = AsyncMock()
body = b'{"object":"whatsapp_business_account","entry":[]}'
request = _post_request(body, {"X-Hub-Signature-256": _sign("signing-key-123", body)})
response = await adapter._handle_webhook(request)
assert response.status == 200
adapter._dispatch_payload.assert_called_once()
@pytest.mark.asyncio
async def test_tampered_body_rejected(self):
adapter = _make_adapter(app_secret="signing-key-123")
adapter._dispatch_payload = AsyncMock()
original = b'{"object":"whatsapp_business_account"}'
tampered = b'{"object":"evil_payload"}'
sig_for_original = _sign("signing-key-123", original)
request = _post_request(tampered, {"X-Hub-Signature-256": sig_for_original})
response = await adapter._handle_webhook(request)
assert response.status == 401
adapter._dispatch_payload.assert_not_called()
assert adapter._rejected_signature_count == 1
@pytest.mark.asyncio
async def test_unconfigured_app_secret_refuses_503(self):
"""Don't quietly accept webhooks when we can't authenticate them."""
adapter = _make_adapter(app_secret="")
adapter._dispatch_payload = AsyncMock()
body = b'{"object":"whatsapp_business_account"}'
request = _post_request(body, {"X-Hub-Signature-256": "sha256=deadbeef"})
response = await adapter._handle_webhook(request)
assert response.status == 503
adapter._dispatch_payload.assert_not_called()
class TestWebhookReplay:
"""wamid dedup — Meta retries failed deliveries up to 7 days."""
def test_dedup_cache_evicts_oldest(self):
from gateway.platforms.whatsapp_cloud import WAMID_DEDUP_CACHE_SIZE
adapter = _make_adapter()
# Fill the cache plus 5 extra
for i in range(WAMID_DEDUP_CACHE_SIZE + 5):
assert adapter._dedup_wamid(f"wamid_{i}") is True
assert len(adapter._seen_wamids) == WAMID_DEDUP_CACHE_SIZE
# The first 5 should have been evicted
assert "wamid_0" not in adapter._seen_wamids
assert "wamid_4" not in adapter._seen_wamids
assert "wamid_5" in adapter._seen_wamids
assert f"wamid_{WAMID_DEDUP_CACHE_SIZE + 4}" in adapter._seen_wamids
class TestWebhookDispatch:
"""End-to-end dispatch from a verified payload to handle_message."""
@pytest.mark.asyncio
async def test_text_message_dispatched_with_event_shape(self):
adapter = _make_adapter(app_secret="key")
captured = []
async def _capture(event):
captured.append(event)
adapter.handle_message = _capture
body = json.dumps(_SAMPLE_INBOUND_TEXT_PAYLOAD).encode("utf-8")
sig = _sign("key", body)
request = _post_request(body, {"X-Hub-Signature-256": sig})
response = await adapter._handle_webhook(request)
assert response.status == 200
assert len(captured) == 1
event = captured[0]
assert event.text == "Hi!"
assert event.message_id == (
"wamid.HBgLMTM1NTc4MjU2OTgVAGHAYWYET688aASGNTI1QzZFQjhEMDk2QQA="
)
assert event.source.platform == Platform.WHATSAPP_CLOUD
assert event.source.chat_id == "13557825698"
assert event.source.user_name == "Jessica Laverdetman"
assert event.source.chat_type == "dm"
@pytest.mark.asyncio
async def test_dispatch_filters_via_mixin_gating(self):
adapter = _make_adapter(app_secret="key")
adapter._dm_policy = "disabled" # block all DMs
adapter.handle_message = AsyncMock()
body = json.dumps(_SAMPLE_INBOUND_TEXT_PAYLOAD).encode("utf-8")
sig = _sign("key", body)
response = await adapter._handle_webhook(
_post_request(body, {"X-Hub-Signature-256": sig})
)
assert response.status == 200
adapter.handle_message.assert_not_called()
# Gated messages don't increment the accepted counter
assert adapter._accepted_count == 0
@pytest.mark.asyncio
async def test_dispatch_handles_button_reply(self):
adapter = _make_adapter(app_secret="key")
captured = []
async def _capture(event):
captured.append(event)
adapter.handle_message = _capture
payload = {
"object": "whatsapp_business_account",
"entry": [
{
"id": "x",
"changes": [
{
"field": "messages",
"value": {
"messaging_product": "whatsapp",
"metadata": {"phone_number_id": "1"},
"contacts": [
{"profile": {"name": "U"}, "wa_id": "1555"}
],
"messages": [
{
"from": "1555",
"id": "wamid.button1",
"timestamp": "0",
"type": "interactive",
"interactive": {
"type": "button_reply",
"button_reply": {
"id": "yes",
"title": "Yes please",
},
},
}
],
},
}
],
}
],
}
body = json.dumps(payload).encode("utf-8")
sig = _sign("key", body)
response = await adapter._handle_webhook(
_post_request(body, {"X-Hub-Signature-256": sig})
)
assert response.status == 200
assert len(captured) == 1
assert captured[0].text == "Yes please"
# ---------------------------------------------------------------------------
# Health endpoint
# ---------------------------------------------------------------------------
class TestHealth:
@pytest.mark.asyncio
async def test_health_flags_missing_secrets(self):
adapter = _make_adapter(verify_token="", app_secret="")
request = MagicMock()
response = await adapter._handle_health(request)
body = json.loads(response.text)
assert body["verify_token_configured"] is False
assert body["app_secret_configured"] is False
# ---------------------------------------------------------------------------
# Mixin contract — gating still works on the cloud adapter
# ---------------------------------------------------------------------------
class TestMixinInherited:
"""Sanity-check: the Cloud adapter inherits the same gating behavior
as the Baileys adapter via WhatsAppBehaviorMixin.
"""
def test_should_process_message_dm_open(self):
adapter = _make_adapter()
adapter._dm_policy = "open"
assert adapter._should_process_message({
"chatId": "15551234567@c.us",
"senderId": "15551234567@c.us",
"isGroup": False,
"body": "hi",
}) is True
# ---------------------------------------------------------------------------
# Outbound media — link mode + upload mode (Phase 4)
# ---------------------------------------------------------------------------
import os as _os
import tempfile as _tempfile
from unittest.mock import patch as _patch
def _mock_upload_response(media_id: str = "media_abc123"):
"""Graph /media POST response shape."""
resp = MagicMock()
resp.status_code = 200
resp.json = MagicMock(return_value={"id": media_id})
resp.text = json.dumps({"id": media_id})
return resp
def _mock_message_response(wamid: str = "wamid.outbound1"):
"""Graph /messages POST response shape."""
resp = MagicMock()
resp.status_code = 200
resp.json = MagicMock(return_value={"messages": [{"id": wamid}]})
resp.text = json.dumps({"messages": [{"id": wamid}]})
return resp
def _tmpfile(suffix: str = ".jpg", content: bytes = b"\xff\xd8\xff\xe0") -> str:
"""Write a small temp file and return its path. Caller cleans up."""
fd, path = _tempfile.mkstemp(suffix=suffix)
with _os.fdopen(fd, "wb") as fh:
fh.write(content)
return path
class TestSendImage:
"""send_image — public URL takes the link path; local file uploads first."""
@pytest.mark.asyncio
async def test_send_image_local_path_uploads_then_sends(self):
adapter = _make_adapter()
adapter._http_client = MagicMock()
adapter._http_client.post = AsyncMock(side_effect=[
_mock_upload_response("media_uploaded_id"),
_mock_message_response(),
])
path = _tmpfile(".jpg")
try:
result = await adapter.send_image_file("15551234567", path)
assert result.success is True
assert adapter._http_client.post.call_count == 2
upload_url = adapter._http_client.post.call_args_list[0].args[0]
send_url = adapter._http_client.post.call_args_list[1].args[0]
assert upload_url.endswith("/media")
assert send_url.endswith("/messages")
send_payload = adapter._http_client.post.call_args_list[1].kwargs["json"]
assert send_payload["image"] == {"id": "media_uploaded_id"}
finally:
_os.unlink(path)
@pytest.mark.asyncio
async def test_send_image_upload_failure_returns_failure(self):
adapter = _make_adapter()
# First call (upload) fails with a Graph error
upload_fail = MagicMock()
upload_fail.status_code = 400
upload_fail.json = MagicMock(return_value={
"error": {"code": 100, "message": "Bad media"}
})
upload_fail.text = '{"error":{"code":100,"message":"Bad media"}}'
adapter._http_client = MagicMock()
adapter._http_client.post = AsyncMock(return_value=upload_fail)
path = _tmpfile(".jpg")
try:
result = await adapter.send_image_file("15551234567", path)
assert result.success is False
assert "graph error 100" in result.error
# Only the upload call — never reached /messages
assert adapter._http_client.post.call_count == 1
finally:
_os.unlink(path)
class TestSendVideo:
@pytest.mark.asyncio
async def test_send_video_link_mode(self):
adapter = _make_adapter()
adapter._http_client = MagicMock()
adapter._http_client.post = AsyncMock(return_value=_mock_message_response())
await adapter.send_video("15551234567", "https://cdn.example.com/v.mp4", caption="clip")
payload = adapter._http_client.post.call_args.kwargs["json"]
assert payload["type"] == "video"
assert payload["video"]["link"] == "https://cdn.example.com/v.mp4"
assert payload["video"]["caption"] == "clip"
class TestSendMethodsAcceptBaseClassKwargs:
"""Regression: every send_* method must absorb ``metadata=`` (and any
other future kwargs) without raising TypeError.
base.BasePlatformAdapter.send_multiple_images and friends pass
``metadata=...`` to send_image; if a subclass forgets ``**kwargs``,
the agent crashes mid-send_multiple_images instead of just sending
the image. This test guards against that for every Cloud send_*
surface.
"""
@pytest.mark.asyncio
async def test_send_image_file_accepts_metadata(self):
adapter = _make_adapter()
adapter._http_client = MagicMock()
adapter._http_client.post = AsyncMock(side_effect=[
_mock_upload_response(),
_mock_message_response(),
])
path = _tmpfile(".jpg")
try:
result = await adapter.send_image_file(
"15551234567", path, metadata={"x": 1},
)
assert result.success is True
finally:
_os.unlink(path)
@pytest.mark.asyncio
async def test_send_document_accepts_metadata(self):
adapter = _make_adapter()
adapter._http_client = MagicMock()
adapter._http_client.post = AsyncMock(side_effect=[
_mock_upload_response(),
_mock_message_response(),
])
path = _tmpfile(".pdf", content=b"%PDF")
try:
result = await adapter.send_document(
"15551234567", path, metadata={"x": 1},
)
assert result.success is True
finally:
_os.unlink(path)
class TestSendDocument:
@pytest.mark.asyncio
async def test_send_document_filename_attached(self):
adapter = _make_adapter()
adapter._http_client = MagicMock()
adapter._http_client.post = AsyncMock(side_effect=[
_mock_upload_response("doc_id"),
_mock_message_response(),
])
path = _tmpfile(".pdf", content=b"%PDF-1.4 ...")
try:
await adapter.send_document(
"15551234567", path, caption="Q3 report",
file_name="report.pdf",
)
send_payload = adapter._http_client.post.call_args_list[1].kwargs["json"]
assert send_payload["type"] == "document"
assert send_payload["document"]["id"] == "doc_id"
assert send_payload["document"]["caption"] == "Q3 report"
assert send_payload["document"]["filename"] == "report.pdf"
finally:
_os.unlink(path)
class TestSendVoice:
"""MP3 voice with ffmpeg present -> opus; without ffmpeg -> MP3 fallback."""
@pytest.mark.asyncio
async def test_send_voice_ffmpeg_present_uses_opus(self):
adapter = _make_adapter()
adapter._http_client = MagicMock()
adapter._http_client.post = AsyncMock(side_effect=[
_mock_upload_response("voice_id"),
_mock_message_response(),
])
# Pretend ffmpeg conversion succeeded by returning a fake opus path.
opus_path = _tmpfile(".ogg", content=b"OggS")
adapter._convert_to_opus = AsyncMock(return_value=opus_path)
mp3_path = _tmpfile(".mp3", content=b"ID3")
try:
result = await adapter.send_voice("15551234567", mp3_path)
assert result.success is True
# Conversion was invoked with the original MP3
uploaded_path = adapter._convert_to_opus.call_args.args[0]
assert uploaded_path == mp3_path
send_payload = adapter._http_client.post.call_args_list[1].kwargs["json"]
assert send_payload["type"] == "audio"
finally:
_os.unlink(mp3_path)
if _os.path.exists(opus_path):
_os.unlink(opus_path)
# ---------------------------------------------------------------------------
# Inbound media — Graph two-step download (Phase 4)
# ---------------------------------------------------------------------------
class TestDownloadMedia:
"""Two-step Graph media download: meta -> temp URL -> bytes."""
@pytest.mark.asyncio
async def test_two_step_download_writes_cache_file(self, tmp_path):
from gateway.platforms import whatsapp_cloud as wac
adapter = _make_adapter()
adapter._http_client = MagicMock()
# Step 1 — metadata returns temp URL + mime
meta_resp = MagicMock(status_code=200)
meta_resp.json = MagicMock(return_value={
"url": "https://lookaside.fbsbx.com/whatsapp/m/...",
"mime_type": "image/jpeg",
"sha256": "abc",
"file_size": 12345,
"id": "media_xyz",
"messaging_product": "whatsapp",
})
# Step 2 — bytes
blob_resp = MagicMock(status_code=200, content=b"\xff\xd8\xff\xe0jpegdata")
adapter._http_client.get = AsyncMock(side_effect=[meta_resp, blob_resp])
with _patch.object(wac, "_INBOUND_MEDIA_CACHE", tmp_path):
local_path, mime = await adapter._download_media_to_cache("media_xyz")
assert mime == "image/jpeg"
assert local_path is not None
assert _os.path.exists(local_path)
assert _os.path.basename(local_path).startswith("media_xyz")
assert _os.path.basename(local_path).endswith(".jpg")
with open(local_path, "rb") as fh:
assert fh.read() == b"\xff\xd8\xff\xe0jpegdata"
@pytest.mark.asyncio
async def test_metadata_failure_returns_none(self):
adapter = _make_adapter()
adapter._http_client = MagicMock()
meta_fail = MagicMock(status_code=404)
meta_fail.json = MagicMock(return_value={"error": {"code": 100}})
adapter._http_client.get = AsyncMock(return_value=meta_fail)
local_path, mime = await adapter._download_media_to_cache("missing")
assert local_path is None and mime is None
class TestInboundMediaDispatch:
"""End-to-end: webhook with image_id -> adapter downloads -> MessageEvent.media_urls populated."""
@pytest.mark.asyncio
async def test_inbound_text_document_injected_into_body(self, tmp_path):
"""A .txt document should have its content prepended to the body."""
from gateway.platforms import whatsapp_cloud as wac
adapter = _make_adapter(app_secret="key")
captured: list = []
async def _capture(event):
captured.append(event)
adapter.handle_message = _capture
text_content = b"hello\nthis is the file\n"
meta_resp = MagicMock(status_code=200)
meta_resp.json = MagicMock(return_value={
"url": "https://lookaside.fbsbx.com/whatsapp/m/doc",
"mime_type": "text/plain",
})
blob_resp = MagicMock(status_code=200, content=text_content)
adapter._http_client = MagicMock()
adapter._http_client.get = AsyncMock(side_effect=[meta_resp, blob_resp])
payload = {
"object": "whatsapp_business_account",
"entry": [{
"id": "x",
"changes": [{
"field": "messages",
"value": {
"messaging_product": "whatsapp",
"metadata": {"phone_number_id": "1"},
"contacts": [{"profile": {"name": "U"}, "wa_id": "1555"}],
"messages": [{
"from": "1555",
"id": "wamid.doc1",
"timestamp": "0",
"type": "document",
"document": {
"id": "media_doc_abc",
"mime_type": "text/plain",
"filename": "notes.txt",
},
}],
},
}],
}],
}
body = json.dumps(payload).encode("utf-8")
sig = _sign("key", body)
with _patch.object(wac, "_INBOUND_MEDIA_CACHE", tmp_path):
await adapter._handle_webhook(
_post_request(body, {"X-Hub-Signature-256": sig})
)
assert len(captured) == 1
event = captured[0]
assert "hello\nthis is the file" in event.text
assert "[Content of" in event.text
# File still available in media_urls for the agent's other tools
assert len(event.media_urls) == 1
# ---------------------------------------------------------------------------
# Group-shaped message guard
# ---------------------------------------------------------------------------
class TestGroupMessageGuard:
"""Cloud API group support is deferred to v2 (Meta capability-tier
gated, different payload shape than DMs). If Meta delivers a
group-shaped message — identifiable by a populated ``chat`` field
on the message object — the adapter should refuse cleanly rather
than silently treating the sender's wa_id as the chat_id (which
would route the bot's reply back to the sender as a DM, not the
group)."""
@pytest.mark.asyncio
async def test_group_shaped_message_dropped_with_warning(self, caplog):
adapter = _make_adapter()
adapter.handle_message = AsyncMock()
raw = {
"from": "15551234567",
"id": "wamid.group1",
"timestamp": "0",
"type": "text",
"text": {"body": "hi from a group"},
"chat": "120363012345678901@g.us", # presence of `chat` = group
}
with caplog.at_level("WARNING"):
event = await adapter._build_message_event_from_cloud(
raw, {"15551234567": "Alice"}, {}
)
assert event is None
# Warning surfaced so the operator knows group messages are being dropped
assert any(
"group-shaped" in rec.message
for rec in caplog.records
)
# Defensive: handler not invoked
adapter.handle_message.assert_not_called()
# =========================================================================
# Phase 9 — Interactive button messages (clarify / approval / slash-confirm)
# =========================================================================
#
# These tests cover the four hooks the gateway uses for richer UX on
# platforms that support interactive buttons:
# - send_clarify (mid-conversation multi-choice question)
# - send_exec_approval (dangerous-command Y/N gate)
# - send_slash_confirm (3-button slash-command preview)
# - _dispatch_interactive_reply (inbound side: route button taps to
# the right resolver)
# Telegram and Discord have the same hooks; we mirror their callback-id
# format (cl:, appr:, sc:) so the gateway's existing degrade-to-text
# fallback works transparently.
class TestSendClarifyButtons:
"""``send_clarify`` outbound — picks button vs list mode by choice count."""
@pytest.mark.asyncio
async def test_three_choices_uses_button_mode(self):
"""13 choices → interactive.type=button (inline pills)."""
adapter = _make_adapter()
adapter._http_client = MagicMock()
adapter._http_client.post = AsyncMock(
return_value=_mock_httpx_response(200, {"messages": [{"id": "wamid.q1"}]})
)
result = await adapter.send_clarify(
chat_id="15551234567",
question="Pick one",
choices=["Alpha", "Bravo", "Charlie"],
clarify_id="abc123",
session_key="sess-1",
)
assert result.success
payload = adapter._http_client.post.call_args.kwargs["json"]
assert payload["type"] == "interactive"
assert payload["interactive"]["type"] == "button"
buttons = payload["interactive"]["action"]["buttons"]
assert len(buttons) == 3
assert [b["reply"]["title"] for b in buttons] == ["1", "2", "3"]
assert buttons[0]["reply"]["id"] == "cl:abc123:0"
assert buttons[2]["reply"]["id"] == "cl:abc123:2"
body_text = payload["interactive"]["body"]["text"]
assert "Alpha" in body_text and "Bravo" in body_text and "Charlie" in body_text
assert adapter._clarify_state["abc123"] == "sess-1"
class TestSendExecApprovalButtons:
"""``send_exec_approval`` outbound — 2-button Approve/Deny gate."""
@pytest.mark.asyncio
async def test_approval_renders_two_buttons(self):
adapter = _make_adapter()
adapter._http_client = MagicMock()
adapter._http_client.post = AsyncMock(
return_value=_mock_httpx_response(200, {"messages": [{"id": "wamid.a1"}]})
)
result = await adapter.send_exec_approval(
chat_id="15551234567",
command="rm -rf /tmp/foo",
session_key="sess-app-1",
description="cleanup script",
)
assert result.success
payload = adapter._http_client.post.call_args.kwargs["json"]
assert payload["interactive"]["type"] == "button"
buttons = payload["interactive"]["action"]["buttons"]
assert len(buttons) == 2
assert "Approve" in buttons[0]["reply"]["title"]
assert "Deny" in buttons[1]["reply"]["title"]
approve_id = buttons[0]["reply"]["id"]
deny_id = buttons[1]["reply"]["id"]
assert approve_id.startswith("appr:") and approve_id.endswith(":approve")
assert deny_id.startswith("appr:") and deny_id.endswith(":deny")
approval_id = approve_id.split(":")[1]
assert deny_id.split(":")[1] == approval_id
body = payload["interactive"]["body"]["text"]
assert "rm -rf /tmp/foo" in body
assert "cleanup script" in body
assert adapter._exec_approval_state[approval_id] == "sess-app-1"
class TestSendSlashConfirmButtons:
"""``send_slash_confirm`` outbound — 3-button Once/Always/Cancel."""
@pytest.mark.asyncio
async def test_three_buttons_with_ids(self):
adapter = _make_adapter()
adapter._http_client = MagicMock()
adapter._http_client.post = AsyncMock(
return_value=_mock_httpx_response(200, {"messages": [{"id": "wamid.s1"}]})
)
result = await adapter.send_slash_confirm(
chat_id="15551234567",
title="Reload MCP",
message="This will restart all MCP servers.",
session_key="sess-sc-1",
confirm_id="cf-9",
)
assert result.success
payload = adapter._http_client.post.call_args.kwargs["json"]
assert payload["interactive"]["type"] == "button"
buttons = payload["interactive"]["action"]["buttons"]
ids = [b["reply"]["id"] for b in buttons]
assert ids == ["sc:once:cf-9", "sc:always:cf-9", "sc:cancel:cf-9"]
assert adapter._slash_confirm_state["cf-9"] == "sess-sc-1"
@pytest.mark.usefixtures("authorized_interactive_env")
class TestDispatchInteractiveReplyClarify:
"""Inbound side: button-tap → clarify resolver."""
@pytest.mark.asyncio
async def test_clarify_other_button_keeps_state_and_prompts(self, monkeypatch):
"""Picking 'Other' should NOT resolve — it should flip the
clarify entry into text-capture mode (via mark_awaiting_text)
AND keep the state mapping so the gateway's text-intercept can
resolve the next typed message. Without the flip,
``get_pending_for_session`` wouldn't return the entry and the
user's next message would collide with the still-blocked agent
thread, producing an "Interrupting current task" loop."""
adapter = _make_adapter()
adapter._clarify_state["q1"] = "sess-1"
adapter._http_client = MagicMock()
adapter._http_client.post = AsyncMock(
return_value=_mock_httpx_response(200, {"messages": [{"id": "x"}]})
)
flipped_ids = []
monkeypatch.setattr(
"tools.clarify_gateway.mark_awaiting_text",
lambda cid: flipped_ids.append(cid) or True,
)
raw = {
"from": "15551234567",
"type": "interactive",
"interactive": {
"type": "list_reply",
"list_reply": {"id": "cl:q1:other", "title": "Other"},
},
}
handled = await adapter._dispatch_interactive_reply(raw, {})
assert handled is True
# State stays so text-intercept can resolve the next message
assert adapter._clarify_state.get("q1") == "sess-1"
# mark_awaiting_text was called with the right clarify_id
assert flipped_ids == ["q1"]
# Follow-up "type your answer" prompt was sent
adapter._http_client.post.assert_called_once()
@pytest.mark.asyncio
async def test_stale_clarify_tap_falls_back_to_text(self):
"""No state entry → return False so caller treats it as text."""
adapter = _make_adapter() # _clarify_state is empty
raw = {
"from": "15551234567",
"type": "interactive",
"interactive": {
"type": "button_reply",
"button_reply": {"id": "cl:ghost:0", "title": "1"},
},
}
handled = await adapter._dispatch_interactive_reply(raw, {})
assert handled is False
@pytest.mark.usefixtures("authorized_interactive_env")
class TestDispatchInteractiveReplyApproval:
"""Inbound side: approval-tap → resolve_gateway_approval."""
@pytest.mark.asyncio
async def test_approve_tap_calls_resolver_and_confirms(self, monkeypatch):
adapter = _make_adapter()
adapter._exec_approval_state["app1"] = "sess-app-1"
adapter._http_client = MagicMock()
adapter._http_client.post = AsyncMock(
return_value=_mock_httpx_response(200, {"messages": [{"id": "x"}]})
)
calls = []
monkeypatch.setattr(
"tools.approval.resolve_gateway_approval",
lambda session_key, choice: calls.append((session_key, choice)) or 1,
)
raw = {
"from": "15551234567",
"type": "interactive",
"interactive": {
"type": "button_reply",
"button_reply": {"id": "appr:app1:approve", "title": "Approve"},
},
}
handled = await adapter._dispatch_interactive_reply(raw, {})
assert handled is True
assert calls == [("sess-app-1", "approve")]
assert "app1" not in adapter._exec_approval_state
confirm_payload = adapter._http_client.post.call_args.kwargs["json"]
assert confirm_payload["type"] == "text"
assert "Approved" in confirm_payload["text"]["body"]
@pytest.mark.usefixtures("authorized_interactive_env")
class TestDispatchInteractiveReplySlashConfirm:
"""Inbound side: slash-confirm-tap → tools.slash_confirm.resolve."""
@pytest.mark.asyncio
async def test_once_tap_calls_resolver(self, monkeypatch):
adapter = _make_adapter()
adapter._slash_confirm_state["cf-9"] = "sess-sc-1"
adapter._http_client = MagicMock()
adapter._http_client.post = AsyncMock(
return_value=_mock_httpx_response(200, {"messages": [{"id": "x"}]})
)
captured = {}
async def fake_resolve(session_key, confirm_id, choice):
captured.update(
session_key=session_key, confirm_id=confirm_id, choice=choice
)
return "MCP reloaded."
import tools.slash_confirm as _sc
monkeypatch.setattr(_sc, "resolve", fake_resolve)
raw = {
"from": "15551234567",
"type": "interactive",
"interactive": {
"type": "button_reply",
"button_reply": {"id": "sc:once:cf-9", "title": "Approve Once"},
},
}
handled = await adapter._dispatch_interactive_reply(raw, {})
assert handled is True
assert captured == {
"session_key": "sess-sc-1",
"confirm_id": "cf-9",
"choice": "once",
}
reply_payload = adapter._http_client.post.call_args.kwargs["json"]
assert "MCP reloaded" in reply_payload["text"]["body"]
class TestDispatchInteractiveReplyAuthorization:
"""Interactive taps must honor the same DM allowlist as text intake."""
@pytest.mark.asyncio
async def test_approval_tap_allowed_when_sender_allowlisted(self, monkeypatch):
adapter = _make_adapter(
_dm_policy="allowlist",
_allow_from={"15551234567"},
)
adapter._exec_approval_state["app1"] = "sess-app-1"
adapter._http_client = MagicMock()
adapter._http_client.post = AsyncMock(
return_value=_mock_httpx_response(200, {"messages": [{"id": "x"}]})
)
calls = []
monkeypatch.setattr(
"tools.approval.resolve_gateway_approval",
lambda session_key, choice: calls.append((session_key, choice)) or 1,
)
raw = {
"from": "15551234567",
"type": "interactive",
"interactive": {
"type": "button_reply",
"button_reply": {"id": "appr:app1:approve", "title": "Approve"},
},
}
handled = await adapter._dispatch_interactive_reply(raw, {})
assert handled is True
assert calls == [("sess-app-1", "approve")]
@pytest.mark.usefixtures("authorized_interactive_env")
class TestInteractiveReplyEndToEnd:
"""Integration: `_build_message_event_from_cloud` must SHORT-CIRCUIT
on a recognized interactive reply and NOT also produce a fresh
conversation turn (which would double-fire the agent)."""
@pytest.mark.asyncio
async def test_recognized_tap_returns_none_no_text_dispatch(self, monkeypatch):
adapter = _make_adapter()
adapter._clarify_state["q1"] = "sess-1"
monkeypatch.setattr(
"tools.clarify_gateway.resolve_gateway_clarify",
lambda cid, r: True,
)
raw = {
"from": "15551234567",
"id": "wamid.tap1",
"type": "interactive",
"interactive": {
"type": "button_reply",
"button_reply": {"id": "cl:q1:0", "title": "1"},
},
}
event = await adapter._build_message_event_from_cloud(
raw, {"15551234567": "Alice"}, {}
)
# The tap resolved the clarify; no MessageEvent dispatched so the
# agent thread that was waiting on clarify is unblocked exactly
# once, not once + a new turn for the tap.
assert event is None
# =========================================================================
# Phase 10 — Typing indicator + mark-as-read
# =========================================================================
#
# Meta couples the read receipt and typing indicator into a single POST
# to the messages endpoint. We refresh _last_inbound_wamid_by_chat on
# every accepted inbound message so the gateway can call send_typing()
# without threading event.message_id through the base contract.
class TestInboundWamidCache:
"""Cache hygiene: refreshes on accepted inbound, skipped on filtered."""
@pytest.mark.asyncio
async def test_accepted_message_populates_cache(self):
adapter = _make_adapter()
raw = {
"from": "15551234567",
"id": "wamid.AAA",
"type": "text",
"text": {"body": "hi"},
}
event = await adapter._build_message_event_from_cloud(
raw, {"15551234567": "Alice"}, {}
)
assert event is not None
assert adapter._last_inbound_wamid_by_chat["15551234567"] == "wamid.AAA"
class TestSendTyping:
"""``send_typing`` outbound — combined read receipt + indicator."""
@pytest.mark.asyncio
async def test_send_typing_posts_correct_payload(self):
adapter = _make_adapter()
adapter._last_inbound_wamid_by_chat["15551234567"] = "wamid.LATEST"
adapter._http_client = MagicMock()
adapter._http_client.post = AsyncMock(
return_value=_mock_httpx_response(200, {"success": True})
)
await adapter.send_typing("15551234567")
adapter._http_client.post.assert_called_once()
payload = adapter._http_client.post.call_args.kwargs["json"]
# Meta's combined endpoint shape
assert payload["messaging_product"] == "whatsapp"
assert payload["status"] == "read"
assert payload["message_id"] == "wamid.LATEST"
assert payload["typing_indicator"] == {"type": "text"}
@pytest.mark.asyncio
async def test_send_typing_stale_message_logged_at_info(self, caplog):
"""Graph error 131009 = wamid > 30 days old. Common after a
long-quiet conversation — log at INFO so it doesn't pollute
WARNING-level monitoring dashboards."""
adapter = _make_adapter()
adapter._last_inbound_wamid_by_chat["15551234567"] = "wamid.OLD"
adapter._http_client = MagicMock()
adapter._http_client.post = AsyncMock(
return_value=_mock_httpx_response(
400, {"error": {"code": 131009, "message": "Parameter value is not valid"}}
)
)
with caplog.at_level("INFO"):
await adapter.send_typing("15551234567")
assert any(
"older than 30 days" in rec.message
for rec in caplog.records
)
# ---------------------------------------------------------------------------
# Allowlist normalization + env decoupling (salvage follow-up)
# ---------------------------------------------------------------------------
class TestAllowlistNormalization:
def test_normalize_allow_ids_strips_jid_suffix_and_punctuation(self):
from gateway.platforms.whatsapp_cloud import WhatsAppCloudAdapter
ids = {"15551234567@s.whatsapp.net", "+1 (555) 765-4321", "15550000000"}
normalized = WhatsAppCloudAdapter._normalize_allow_ids(ids)
assert normalized == {"15551234567", "15557654321", "15550000000"}
class TestBoundedInteractiveState:
def test_bounded_put_evicts_oldest(self):
from collections import OrderedDict
from gateway.platforms.whatsapp_cloud import (
INTERACTIVE_STATE_CACHE_SIZE,
WhatsAppCloudAdapter,
)
cache: OrderedDict = OrderedDict()
for i in range(INTERACTIVE_STATE_CACHE_SIZE + 10):
WhatsAppCloudAdapter._bounded_put(cache, f"id-{i}", "sess")
assert len(cache) == INTERACTIVE_STATE_CACHE_SIZE
assert "id-0" not in cache
assert f"id-{INTERACTIVE_STATE_CACHE_SIZE + 9}" in cache
class TestMediaIdValidation:
@pytest.mark.asyncio
async def test_traversal_media_id_refused(self):
adapter = _make_adapter()
adapter._http_client = MagicMock() # would be used if not refused
path, mime = await adapter._download_media_to_cache("../../etc/passwd")
assert path is None and mime is None
adapter._http_client.get.assert_not_called()
class TestReplyContextResolution:
"""The Cloud webhook ``context`` object only carries the quoted message's
id (and author), never its text. We resolve the text from rich_sent_store,
which is populated on every inbound message and every outbound send. Without
a resolved ``reply_to_text`` run.py can't inject the disambiguation prefix,
so the agent never learns the message was a reply (the user-reported bug).
"""
@pytest.mark.asyncio
async def test_reply_to_own_earlier_message_resolves_text(self):
"""User replies to their own earlier message — its text was indexed
on the earlier inbound, so the reply resolves it."""
adapter = _make_adapter()
# First inbound message gets recorded by wamid.
await adapter._build_message_event_from_cloud(
{"from": "15551234567", "id": "wamid.PRIOR", "type": "text",
"text": {"body": "remind me to buy milk"}},
{"15551234567": "Alice"}, {},
)
# Now the user replies to that earlier message.
event = await adapter._build_message_event_from_cloud(
{"from": "15551234567", "id": "wamid.REPLY", "type": "text",
"text": {"body": "did you?"},
"context": {"id": "wamid.PRIOR", "from": "15551234567"}},
{"15551234567": "Alice"}, {},
)
assert event is not None
assert event.reply_to_message_id == "wamid.PRIOR"
assert event.reply_to_text == "remind me to buy milk"
assert event.reply_to_is_own_message is False # quoted author == the user
@pytest.mark.asyncio
async def test_non_reply_message_has_no_reply_context(self):
adapter = _make_adapter()
event = await adapter._build_message_event_from_cloud(
{"from": "15551234567", "id": "wamid.PLAIN", "type": "text",
"text": {"body": "hello"}},
{"15551234567": "Alice"}, {},
)
assert event is not None
assert event.reply_to_message_id is None
assert event.reply_to_text is None
assert event.reply_to_is_own_message is False