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

157 lines
5.3 KiB
Python

"""WhatsApp media delivery for send_message (#19105).
Covers two layers:
* ``_bridge_media_type`` — extension/voice/force_document -> bridge mediaType.
* ``_standalone_send`` — text-first then per-file ``/send-media`` uploads,
media-only (skip ``/send``), and missing-file errors. The bridge HTTP calls
are mocked at the ``aiohttp.ClientSession`` boundary.
"""
import asyncio
import os
import tempfile
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from plugins.platforms.whatsapp.adapter import _bridge_media_type, _standalone_send
# ---------------------------------------------------------------------------
# _bridge_media_type
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"path,is_voice,force_document,expected",
[
("a.png", False, False, "image"),
("a.JPG", False, False, "image"),
("a.jpeg", False, False, "image"),
("a.webp", False, False, "image"),
("a.gif", False, False, "image"),
("a.mp4", False, False, "video"),
("a.mov", False, False, "video"),
("a.webm", False, False, "video"),
("a.ogg", True, False, "audio"),
("a.opus", False, False, "audio"),
("a.mp3", False, False, "audio"),
("a.wav", False, False, "audio"),
("a.pdf", False, False, "document"),
("a.zip", False, False, "document"),
# force_document overrides everything
("a.png", False, True, "document"),
("a.mp4", False, True, "document"),
# is_voice wins over a video extension
("a.mp4", True, False, "audio"),
],
)
def test_bridge_media_type(path, is_voice, force_document, expected):
assert _bridge_media_type(path, is_voice, force_document) == expected
# ---------------------------------------------------------------------------
# _standalone_send — bridge HTTP mocked
# ---------------------------------------------------------------------------
def _resp(status, json_data=None, text_data=None):
r = AsyncMock()
r.status = status
r.json = AsyncMock(return_value=json_data or {})
r.text = AsyncMock(return_value=text_data or "")
return r
def _session_with(responses):
"""Build a mocked aiohttp.ClientSession that returns *responses* in order
and records every POST (url, json_payload)."""
calls = []
idx = [0]
def _post(url, **kwargs):
calls.append((url, kwargs.get("json")))
r = responses[idx[0]] if idx[0] < len(responses) else responses[-1]
idx[0] += 1
ctx = MagicMock()
ctx.__aenter__ = AsyncMock(return_value=r)
ctx.__aexit__ = AsyncMock(return_value=False)
return ctx
session = MagicMock()
session.post = MagicMock(side_effect=_post)
session_ctx = MagicMock()
session_ctx.__aenter__ = AsyncMock(return_value=session)
session_ctx.__aexit__ = AsyncMock(return_value=False)
return session_ctx, calls
def _pconfig():
return SimpleNamespace(token="", extra={"bridge_port": 3000})
def _tmpfile(suffix):
f = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
f.write(b"x")
f.close()
return f.name
def test_text_plus_mixed_media_routes_native_types():
img = _tmpfile(".png")
vid = _tmpfile(".mp4")
voice = _tmpfile(".ogg")
try:
session_ctx, calls = _session_with(
[
_resp(200, {"messageId": "t1"}),
_resp(200, {"messageId": "m1"}),
_resp(200, {"messageId": "m2"}),
_resp(200, {"messageId": "m3"}),
]
)
with patch("aiohttp.ClientSession", return_value=session_ctx):
res = asyncio.run(
_standalone_send(
_pconfig(),
"12345",
"hello",
media_files=[(img, False), (vid, False), (voice, True)],
)
)
assert res["success"] is True
# text first, then three media uploads in order
assert calls[0][0].endswith("/send")
assert calls[0][1]["message"] == "hello"
media_types = [c[1]["mediaType"] for c in calls if c[0].endswith("/send-media")]
assert media_types == ["image", "video", "audio"]
# chat id normalized to a WhatsApp JID
assert "@" in calls[0][1]["chatId"]
finally:
for p in (img, vid, voice):
os.unlink(p)
def test_missing_captioned_file_falls_back_to_text():
"""If the single captioned file is missing, the caption is delivered as a
plain /send message rather than being silently lost (W1)."""
session_ctx, calls = _session_with([_resp(200, {"messageId": "t1"})])
with patch("aiohttp.ClientSession", return_value=session_ctx):
res = asyncio.run(
_standalone_send(
_pconfig(),
"12345",
"",
media_files=[("/no/such/file.png", False)],
caption="floor plan",
)
)
# The send still surfaces the missing-file error...
assert "error" in res
assert "not found" in res["error"]
# ...but the caption text was delivered on its own first.
assert len(calls) == 1
assert calls[0][0].endswith("/send")
assert calls[0][1]["message"] == "floor plan"