hermes-agent/tests/gateway/test_discord_send.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

380 lines
14 KiB
Python

import asyncio
import json
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from gateway.config import PlatformConfig
def _ensure_discord_mock():
if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"):
return
discord_mod = MagicMock()
discord_mod.Intents.default.return_value = MagicMock()
discord_mod.Client = MagicMock
discord_mod.File = MagicMock
discord_mod.DMChannel = type("DMChannel", (), {})
discord_mod.Thread = type("Thread", (), {})
discord_mod.ForumChannel = type("ForumChannel", (), {})
discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object)
discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, secondary=2, danger=3, green=1, grey=2, blurple=2, red=3)
discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4, purple=lambda: 5)
discord_mod.Interaction = object
discord_mod.Embed = MagicMock
discord_mod.app_commands = SimpleNamespace(
describe=lambda **kwargs: (lambda fn: fn),
choices=lambda **kwargs: (lambda fn: fn),
Choice=lambda **kwargs: SimpleNamespace(**kwargs),
)
ext_mod = MagicMock()
commands_mod = MagicMock()
commands_mod.Bot = MagicMock
ext_mod.commands = commands_mod
sys.modules.setdefault("discord", discord_mod)
sys.modules.setdefault("discord.ext", ext_mod)
sys.modules.setdefault("discord.ext.commands", commands_mod)
_ensure_discord_mock()
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
def _voice_adapter(reference_obj, *, native_result=None, native_error=None):
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
ref_msg = SimpleNamespace(id=99, to_reference=MagicMock(return_value=reference_obj))
channel = SimpleNamespace(
id=555,
fetch_message=AsyncMock(return_value=ref_msg),
send=AsyncMock(return_value=SimpleNamespace(id=888)),
)
request = AsyncMock(return_value=native_result or {"id": "777"})
if native_error is not None:
request.side_effect = native_error
adapter._client = SimpleNamespace(
get_channel=lambda _chat_id: channel,
fetch_channel=AsyncMock(),
http=SimpleNamespace(request=request),
)
return adapter, channel, request
def _native_voice_payload(request):
form = request.await_args.kwargs["form"]
payload = next(part["value"] for part in form if part["name"] == "payload_json")
return json.loads(payload)
@pytest.mark.asyncio
async def test_send_retries_without_reference_when_reply_target_is_deleted():
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
reference_obj = object()
ref_msg = SimpleNamespace(id=99, to_reference=MagicMock(return_value=reference_obj))
sent_msgs = [SimpleNamespace(id=1001), SimpleNamespace(id=1002)]
send_calls = []
async def fake_send(*, content, reference=None):
send_calls.append({"content": content, "reference": reference})
if len(send_calls) == 1:
raise RuntimeError(
"400 Bad Request (error code: 10008): Unknown Message"
)
return sent_msgs[len(send_calls) - 2]
channel = SimpleNamespace(
fetch_message=AsyncMock(return_value=ref_msg),
send=AsyncMock(side_effect=fake_send),
)
adapter._client = SimpleNamespace(
get_channel=lambda _chat_id: channel,
fetch_channel=AsyncMock(),
)
long_text = "A" * (adapter.MAX_MESSAGE_LENGTH + 50)
result = await adapter.send("555", long_text, reply_to="99")
assert result.success is True
assert result.message_id == "1001"
assert channel.fetch_message.await_count == 1
assert channel.send.await_count == 3
ref_msg.to_reference.assert_called_once_with(fail_if_not_exists=False)
assert send_calls[0]["reference"] is reference_obj
assert send_calls[1]["reference"] is None
assert send_calls[2]["reference"] is None
# ---------------------------------------------------------------------------
# Forum channel tests
# ---------------------------------------------------------------------------
import discord as _discord_mod # noqa: E402 — imported after _ensure_discord_mock
class TestIsForumParent:
def test_none_returns_false(self):
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
assert adapter._is_forum_parent(None) is False
def test_forum_channel_class_instance(self):
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
forum_cls = getattr(_discord_mod, "ForumChannel", None)
if forum_cls is None:
# Re-create a type for the mock
forum_cls = type("ForumChannel", (), {})
_discord_mod.ForumChannel = forum_cls
ch = forum_cls()
assert adapter._is_forum_parent(ch) is True
# ---------------------------------------------------------------------------
# Forum follow-up chunk failure reporting + media on forum paths
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_forum_post_file_creates_thread_with_attachment():
"""_forum_post_file routes file-bearing sends to create_thread with file kwarg."""
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
thread_ch = SimpleNamespace(id=777, send=AsyncMock())
thread = SimpleNamespace(
id=777,
message=SimpleNamespace(
id=800,
attachments=[SimpleNamespace(filename="photo.png")],
),
thread=thread_ch,
)
forum_channel = _discord_mod.ForumChannel()
forum_channel.id = 999
forum_channel.name = "ideas"
forum_channel.create_thread = AsyncMock(return_value=thread)
# discord.File is a real class; build a MagicMock that looks like one
fake_file = SimpleNamespace(filename="photo.png")
result = await adapter._forum_post_file(
forum_channel,
content="here is a photo",
file=fake_file,
)
assert result.success is True
assert result.message_id == "800"
forum_channel.create_thread.assert_awaited_once()
call_kwargs = forum_channel.create_thread.await_args.kwargs
assert call_kwargs["file"] is fake_file
assert call_kwargs["content"] == "here is a photo"
# Thread name derived from content's first line
assert call_kwargs["name"] == "here is a photo"
@pytest.mark.asyncio
async def test_forum_post_file_fails_when_starter_has_no_attachments():
"""Forum create_thread can succeed yet return an attachmentless starter (#66797)."""
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
thread = SimpleNamespace(
id=7,
message=SimpleNamespace(id=8, attachments=[]),
thread=SimpleNamespace(id=7, send=AsyncMock()),
)
forum_channel = _discord_mod.ForumChannel()
forum_channel.id = 999
forum_channel.create_thread = AsyncMock(return_value=thread)
fake_file = SimpleNamespace(filename="clip.mp4")
result = await adapter._forum_post_file(
forum_channel,
content="video clip",
files=[fake_file],
)
assert result.success is False
assert "no files" in (result.error or "").lower()
forum_channel.create_thread.assert_awaited_once()
# ---------------------------------------------------------------------------
# Typing indicator task lifecycle
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_typing_restartable_after_error():
"""After a typing error, send_typing should start a new task (not blocked by stale entry)."""
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
adapter._client = MagicMock()
adapter._client.http = MagicMock()
adapter._typing_tasks = {}
# First call fails
adapter._client.http.request = AsyncMock(side_effect=Exception("503"))
await adapter.send_typing("12345")
await asyncio.sleep(0.1)
# Second call should work
adapter._client.http.request = AsyncMock()
await adapter.send_typing("12345")
assert "12345" in adapter._typing_tasks, \
"Should restart typing after previous failure"
# ---------------------------------------------------------------------------
# #66797 — outbound MEDIA video must reach channel.send as a real attachment
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_send_video_uses_path_based_files_kwarg(tmp_path, monkeypatch):
"""Regression for #66797: video MEDIA delivery must use path-based
``discord.File`` via ``files=[...]`` (same pattern as image batching).
The previous open-handle + singular ``file=`` form could return a successful
message with zero attachments after an earlier image batch on the same
channel — silent drop from the user's perspective.
"""
import plugins.platforms.discord.adapter as discord_platform
video = tmp_path / "clip.mp4"
video.write_bytes(b"\x00\x00\x00\x18ftypmp42fake")
captured = {}
class _FakeFile:
def __init__(self, fp, filename=None, **kwargs):
captured["fp"] = fp
captured["filename"] = filename
monkeypatch.setattr(discord_platform.discord, "File", _FakeFile)
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
sent_msg = SimpleNamespace(
id=4242,
attachments=[SimpleNamespace(filename="clip.mp4", url="https://cdn.example/clip.mp4")],
)
channel = SimpleNamespace(
send=AsyncMock(return_value=sent_msg),
type=0,
)
adapter._client = SimpleNamespace(
get_channel=lambda _chat_id: channel,
fetch_channel=AsyncMock(),
)
monkeypatch.setattr(adapter, "_is_forum_parent", lambda _ch: False)
result = await adapter.send_video("555", str(video))
assert result.success is True
assert result.message_id == "4242"
assert captured["fp"] == str(video)
assert captured["filename"] == "clip.mp4"
channel.send.assert_awaited_once()
send_kwargs = channel.send.await_args.kwargs
assert send_kwargs.get("file") is None
assert isinstance(send_kwargs.get("files"), list) and len(send_kwargs["files"]) == 1
@pytest.mark.asyncio
async def test_send_video_fails_loud_when_message_has_no_attachments(tmp_path, monkeypatch):
"""If Discord accepts the message but attaches nothing, fail loud (#66797)."""
import plugins.platforms.discord.adapter as discord_platform
video = tmp_path / "clip.mp4"
video.write_bytes(b"fake-mp4")
monkeypatch.setattr(
discord_platform.discord,
"File",
lambda fp, filename=None, **kwargs: SimpleNamespace(fp=fp, filename=filename),
)
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
# Message id present, but no attachments — the silent-drop failure mode.
sent_msg = SimpleNamespace(id=99, attachments=[])
channel = SimpleNamespace(send=AsyncMock(return_value=sent_msg), type=0)
adapter._client = SimpleNamespace(
get_channel=lambda _chat_id: channel,
fetch_channel=AsyncMock(),
)
monkeypatch.setattr(adapter, "_is_forum_parent", lambda _ch: False)
result = await adapter.send_video("555", str(video))
assert result.success is False
assert "no files" in (result.error or "").lower()
channel.send.assert_awaited_once()
@pytest.mark.asyncio
async def test_send_video_missing_file_fails_fast_without_touching_channel():
"""A missing MEDIA path must fail loud before any Discord I/O (#66797).
The pre-flight ``os.path.isfile`` guard turns a would-be crash inside
``discord.File`` into an actionable ``File not found`` result, and must
short-circuit before the channel is ever resolved.
"""
def _boom(*_args, **_kwargs):
raise AssertionError("channel must not be resolved for a missing file")
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
adapter._client = SimpleNamespace(get_channel=_boom, fetch_channel=AsyncMock(side_effect=_boom))
result = await adapter.send_video("555", "/no/such/clip.mp4")
assert result.success is False
assert "not found" in (result.error or "").lower()
@pytest.mark.asyncio
async def test_send_file_attachment_forum_uses_files_kwarg(tmp_path, monkeypatch):
"""Forum-parent delivery must also route the path-based file through the
plural ``files=[...]`` kwarg (#66797), so the create_thread starter message
carries the attachment rather than silently dropping it."""
import plugins.platforms.discord.adapter as discord_platform
video = tmp_path / "clip.mp4"
video.write_bytes(b"fake-mp4")
monkeypatch.setattr(
discord_platform.discord,
"File",
lambda fp, filename=None, **kwargs: SimpleNamespace(fp=fp, filename=filename),
)
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
created_thread = SimpleNamespace(
id=7,
message=SimpleNamespace(
id=8,
attachments=[SimpleNamespace(filename="clip.mp4")],
),
)
forum_channel = SimpleNamespace(
id=7,
create_thread=AsyncMock(return_value=created_thread),
)
adapter._client = SimpleNamespace(
get_channel=lambda _chat_id: forum_channel,
fetch_channel=AsyncMock(),
)
monkeypatch.setattr(adapter, "_is_forum_parent", lambda _ch: True)
result = await adapter.send_video("555", str(video))
assert result.success is True
forum_channel.create_thread.assert_awaited_once()
thread_kwargs = forum_channel.create_thread.await_args.kwargs
assert thread_kwargs.get("file") is None
assert isinstance(thread_kwargs.get("files"), list) and len(thread_kwargs["files"]) == 1