tests(e2e): test command stripping behavior in Discord

This commit is contained in:
Dylan Socolobsky 2026-04-08 17:36:19 -03:00 committed by Teknium
parent 2008e997dc
commit e640ea736c
2 changed files with 247 additions and 2 deletions

View file

@ -12,7 +12,7 @@ No LLM, no real platform connections.
import asyncio
import sys
import uuid
from datetime import datetime
from datetime import datetime, timezone, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
@ -22,6 +22,7 @@ from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.base import MessageEvent, SendResult
from gateway.session import SessionEntry, SessionSource, build_session_key
E2E_MESSAGE_SETTLE_DELAY = 0.3
# Platform library mocks
@ -113,8 +114,9 @@ _ensure_telegram_mock()
_ensure_discord_mock()
_ensure_slack_mock()
from gateway.platforms.discord import DiscordAdapter # noqa: E402
import discord # noqa: E402 — mocked above
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
from gateway.platforms.discord import DiscordAdapter # noqa: E402
import gateway.platforms.slack as _slack_mod # noqa: E402
_slack_mod.SLACK_AVAILABLE = True
@ -264,3 +266,140 @@ def runner(platform, session_entry):
@pytest.fixture()
def adapter(platform, runner):
return make_adapter(platform, runner)
# ═══════════════════════════════════════════════════════════════════════════
# Discord helpers and fixtures
# ═══════════════════════════════════════════════════════════════════════════
BOT_USER_ID = 99999
BOT_USER_NAME = "HermesBot"
CHANNEL_ID = 22222
GUILD_ID = 44444
THREAD_ID = 33333
MESSAGE_ID_COUNTER = 0
def _next_message_id() -> int:
global MESSAGE_ID_COUNTER
MESSAGE_ID_COUNTER += 1
return 70000 + MESSAGE_ID_COUNTER
def make_fake_bot_user():
return SimpleNamespace(
id=BOT_USER_ID, name=BOT_USER_NAME,
display_name=BOT_USER_NAME, bot=True,
)
def make_fake_guild(guild_id: int = GUILD_ID, name: str = "Test Server"):
return SimpleNamespace(id=guild_id, name=name)
def make_fake_text_channel(channel_id: int = CHANNEL_ID, name: str = "general", guild=None):
return SimpleNamespace(
id=channel_id, name=name,
guild=guild or make_fake_guild(),
topic=None, type=0,
)
def make_fake_dm_channel(channel_id: int = 55555):
ch = MagicMock(spec=[])
ch.id = channel_id
ch.name = "DM"
ch.topic = None
ch.__class__ = discord.DMChannel
return ch
def make_fake_thread(thread_id: int = THREAD_ID, name: str = "test-thread", parent=None):
th = MagicMock(spec=[])
th.id = thread_id
th.name = name
th.parent = parent or make_fake_text_channel()
th.parent_id = th.parent.id
th.guild = th.parent.guild
th.topic = None
th.type = 11
th.__class__ = discord.Thread
return th
def make_discord_message(
*, content: str = "hello", author=None, channel=None, mentions=None,
attachments=None, message_id: int = None,
):
if message_id is None:
message_id = _next_message_id()
if author is None:
author = SimpleNamespace(
id=11111, name="testuser", display_name="testuser", bot=False,
)
if channel is None:
channel = make_fake_text_channel()
if mentions is None:
mentions = []
if attachments is None:
attachments = []
return SimpleNamespace(
id=message_id, content=content, author=author, channel=channel,
mentions=mentions, attachments=attachments,
type=getattr(discord, "MessageType", SimpleNamespace()).default,
reference=None, created_at=datetime.now(timezone.utc),
create_thread=AsyncMock(),
)
def get_response_text(adapter) -> str | None:
"""Extract the response text from adapter.send() call args, or None if not called."""
if not adapter.send.called:
return None
return adapter.send.call_args[1].get("content") or adapter.send.call_args[0][1]
def _make_discord_adapter_wired(runner=None):
"""Create a DiscordAdapter wired to a GatewayRunner for e2e tests."""
if runner is None:
runner = make_runner(Platform.DISCORD)
config = PlatformConfig(enabled=True, token="e2e-test-token")
from gateway.platforms.helpers import ThreadParticipationTracker
with patch.object(ThreadParticipationTracker, "_load", return_value=set()):
adapter = DiscordAdapter(config)
bot_user = make_fake_bot_user()
adapter._client = SimpleNamespace(
user=bot_user,
get_channel=lambda _id: None,
fetch_channel=AsyncMock(),
)
adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="e2e-resp-1"))
adapter.send_typing = AsyncMock()
adapter.set_message_handler(runner._handle_message)
runner.adapters[Platform.DISCORD] = adapter
return adapter, runner
@pytest.fixture()
def discord_setup():
return _make_discord_adapter_wired()
@pytest.fixture()
def discord_adapter(discord_setup):
return discord_setup[0]
@pytest.fixture()
def discord_runner(discord_setup):
return discord_setup[1]
@pytest.fixture()
def bot_user():
return make_fake_bot_user()

View file

@ -0,0 +1,106 @@
"""Minimal e2e tests for Discord mention stripping + /command detection.
Covers the fix for slash commands not being recognized when sent via
@mention in a channel, especially after auto-threading.
"""
import asyncio
from unittest.mock import AsyncMock
import pytest
from tests.e2e.conftest import (
BOT_USER_ID,
E2E_MESSAGE_SETTLE_DELAY,
get_response_text,
make_discord_message,
make_fake_dm_channel,
make_fake_thread,
)
pytestmark = pytest.mark.asyncio
async def dispatch(adapter, msg):
await adapter._handle_message(msg)
await asyncio.sleep(E2E_MESSAGE_SETTLE_DELAY)
class TestMentionStrippedCommandDispatch:
async def test_mention_then_command(self, discord_adapter, bot_user):
"""<@BOT> /help → mention stripped, /help dispatched."""
msg = make_discord_message(
content=f"<@{BOT_USER_ID}> /help",
mentions=[bot_user],
)
await dispatch(discord_adapter, msg)
response = get_response_text(discord_adapter)
assert response is not None
assert "/new" in response
async def test_nickname_mention_then_command(self, discord_adapter, bot_user):
"""<@!BOT> /help → nickname mention also stripped, /help works."""
msg = make_discord_message(
content=f"<@!{BOT_USER_ID}> /help",
mentions=[bot_user],
)
await dispatch(discord_adapter, msg)
response = get_response_text(discord_adapter)
assert response is not None
assert "/new" in response
async def test_text_before_command_not_detected(self, discord_adapter, bot_user):
"""'<@BOT> something else /help' → mention stripped, but 'something else /help'
doesn't start with / so it's treated as text, not a command."""
msg = make_discord_message(
content=f"<@{BOT_USER_ID}> something else /help",
mentions=[bot_user],
)
await dispatch(discord_adapter, msg)
# Message is accepted (not dropped), but not dispatched as a command
discord_adapter.send.assert_awaited()
response = get_response_text(discord_adapter)
# /help command output lists /new — if it went through as text, it won't
assert response is None or "/new" not in response
async def test_no_mention_in_channel_dropped(self, discord_adapter):
"""Message without @mention in server channel → silently dropped."""
msg = make_discord_message(content="/help", mentions=[])
await dispatch(discord_adapter, msg)
assert get_response_text(discord_adapter) is None
async def test_dm_no_mention_needed(self, discord_adapter):
"""DMs don't require @mention — /help works directly."""
dm = make_fake_dm_channel()
msg = make_discord_message(content="/help", channel=dm, mentions=[])
await dispatch(discord_adapter, msg)
response = get_response_text(discord_adapter)
assert response is not None
assert "/new" in response
class TestAutoThreadingPreservesCommand:
async def test_command_detected_after_auto_thread(self, discord_adapter, bot_user, monkeypatch):
"""@mention /help in channel with auto-thread → thread created AND command dispatched."""
monkeypatch.setenv("DISCORD_AUTO_THREAD", "true")
fake_thread = make_fake_thread(thread_id=90001, name="help")
msg = make_discord_message(
content=f"<@{BOT_USER_ID}> /help",
mentions=[bot_user],
)
# Simulate discord.py restoring the original raw content (with mention)
# after create_thread(), which undoes any prior mention stripping.
original_content = msg.content
async def clobber_content(**kwargs):
msg.content = original_content
return fake_thread
msg.create_thread = AsyncMock(side_effect=clobber_content)
await dispatch(discord_adapter, msg)
msg.create_thread.assert_awaited_once()
response = get_response_text(discord_adapter)
assert response is not None
assert "/new" in response