test(e2e): add Discord e2e integration tests

This commit is contained in:
Dylan Socolobsky 2026-04-06 16:38:02 -03:00 committed by Teknium
parent 9555a0cf31
commit 7033dbf5d6
2 changed files with 369 additions and 3 deletions

View file

@ -1,4 +1,4 @@
"""Shared fixtures for Telegram gateway e2e tests.
"""Shared fixtures for Telegram and Discord gateway e2e tests.
These tests exercise the full async message flow:
adapter.handle_message(event)
@ -14,14 +14,16 @@ import sys
import uuid
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
from unittest.mock import AsyncMock, MagicMock, patch
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.base import MessageEvent, SendResult
from gateway.session import SessionEntry, SessionSource, build_session_key
#Ensure telegram module is available (mock it if not installed)
# ---------------------------------------------------------------------------
# Telegram mock
# ---------------------------------------------------------------------------
def _ensure_telegram_mock():
"""Install mock telegram modules so TelegramAdapter can be imported."""
@ -56,6 +58,44 @@ _ensure_telegram_mock()
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
# ---------------------------------------------------------------------------
# Discord mock
# ---------------------------------------------------------------------------
def _ensure_discord_mock():
"""Install mock discord modules so DiscordAdapter can be imported."""
if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"):
return # Real library installed
discord_mod = MagicMock()
discord_mod.Intents.default.return_value = MagicMock()
discord_mod.DMChannel = type("DMChannel", (), {})
discord_mod.Thread = type("Thread", (), {})
discord_mod.ForumChannel = type("ForumChannel", (), {})
discord_mod.Interaction = object
discord_mod.app_commands = SimpleNamespace(
describe=lambda **kwargs: (lambda fn: fn),
choices=lambda **kwargs: (lambda fn: fn),
Choice=lambda **kwargs: SimpleNamespace(**kwargs),
)
discord_mod.opus.is_loaded.return_value = True
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)
sys.modules.setdefault("discord.opus", discord_mod.opus)
_ensure_discord_mock()
from gateway.platforms.discord import DiscordAdapter # noqa: E402
#GatewayRunner factory (based on tests/gateway/test_status_command.py)
def make_runner(session_entry: SessionEntry) -> "GatewayRunner":
@ -171,3 +211,108 @@ async def send_and_capture(adapter: TelegramAdapter, text: str, **event_kwargs)
# Let the background task complete
await asyncio.sleep(0.3)
return adapter.send
# ---------------------------------------------------------------------------
# Discord factories
# ---------------------------------------------------------------------------
def make_discord_runner(session_entry: SessionEntry) -> "GatewayRunner":
"""Create a GatewayRunner configured for Discord with mocked internals."""
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig(
platforms={Platform.DISCORD: PlatformConfig(enabled=True, token="e2e-test-token")}
)
runner.adapters = {}
runner._voice_mode = {}
runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
runner.session_store = MagicMock()
runner.session_store.get_or_create_session.return_value = session_entry
runner.session_store.load_transcript.return_value = []
runner.session_store.has_any_sessions.return_value = True
runner.session_store.append_to_transcript = MagicMock()
runner.session_store.rewrite_transcript = MagicMock()
runner.session_store.update_session = MagicMock()
runner.session_store.reset_session = MagicMock()
runner._running_agents = {}
runner._pending_messages = {}
runner._pending_approvals = {}
runner._session_db = None
runner._reasoning_config = None
runner._provider_routing = {}
runner._fallback_model = None
runner._show_reasoning = False
runner._is_user_authorized = lambda _source: True
runner._set_session_env = lambda _context: None
runner._should_send_voice_reply = lambda *_a, **_kw: False
runner._send_voice_reply = AsyncMock()
runner._capture_gateway_honcho_if_configured = lambda *a, **kw: None
runner._emit_gateway_run_progress = AsyncMock()
runner.pairing_store = MagicMock()
runner.pairing_store._is_rate_limited = MagicMock(return_value=False)
runner.pairing_store.generate_code = MagicMock(return_value="ABC123")
return runner
def make_discord_adapter(runner) -> DiscordAdapter:
"""Create a DiscordAdapter wired to *runner*, with send methods mocked.
connect() is NOT called no bot client, no real HTTP.
"""
config = PlatformConfig(enabled=True, token="e2e-test-token")
with patch.object(DiscordAdapter, "_load_participated_threads", return_value=set()):
adapter = DiscordAdapter(config)
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
def make_discord_source(chat_id: str = "e2e-chat-1", user_id: str = "e2e-user-1") -> SessionSource:
return SessionSource(
platform=Platform.DISCORD,
chat_id=chat_id,
user_id=user_id,
user_name="e2e_tester",
chat_type="dm",
)
def make_discord_event(text: str, chat_id: str = "e2e-chat-1", user_id: str = "e2e-user-1") -> MessageEvent:
return MessageEvent(
text=text,
source=make_discord_source(chat_id, user_id),
message_id=f"msg-{uuid.uuid4().hex[:8]}",
)
def make_discord_session_entry(source: SessionSource = None) -> SessionEntry:
source = source or make_discord_source()
return SessionEntry(
session_key=build_session_key(source),
session_id=f"sess-{uuid.uuid4().hex[:8]}",
created_at=datetime.now(),
updated_at=datetime.now(),
platform=Platform.DISCORD,
chat_type="dm",
)
async def discord_send_and_capture(adapter: DiscordAdapter, text: str, **event_kwargs) -> AsyncMock:
"""Send a message through the full Discord e2e flow and return the send mock."""
event = make_discord_event(text, **event_kwargs)
adapter.send.reset_mock()
await adapter.handle_message(event)
await asyncio.sleep(0.3)
return adapter.send

View file

@ -0,0 +1,221 @@
"""E2E tests for Discord gateway slash commands.
Each test drives a message through the full async pipeline:
adapter.handle_message(event)
BasePlatformAdapter._process_message_background()
GatewayRunner._handle_message() (command dispatch)
adapter.send() (captured for assertions)
No LLM involved only gateway-level commands are tested.
"""
import asyncio
from unittest.mock import AsyncMock
import pytest
from gateway.platforms.base import SendResult
from tests.e2e.conftest import (
discord_send_and_capture,
make_discord_adapter,
make_discord_event,
make_discord_runner,
make_discord_session_entry,
make_discord_source,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def source():
return make_discord_source()
@pytest.fixture()
def session_entry(source):
return make_discord_session_entry(source)
@pytest.fixture()
def runner(session_entry):
return make_discord_runner(session_entry)
@pytest.fixture()
def adapter(runner):
return make_discord_adapter(runner)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestDiscordSlashCommands:
"""Gateway slash commands dispatched through the full adapter pipeline."""
@pytest.mark.asyncio
async def test_help_returns_command_list(self, adapter):
send = await discord_send_and_capture(adapter, "/help")
send.assert_called_once()
response_text = send.call_args[1].get("content") or send.call_args[0][1]
assert "/new" in response_text
assert "/status" in response_text
@pytest.mark.asyncio
async def test_status_shows_session_info(self, adapter):
send = await discord_send_and_capture(adapter, "/status")
send.assert_called_once()
response_text = send.call_args[1].get("content") or send.call_args[0][1]
assert "session" in response_text.lower() or "Session" in response_text
@pytest.mark.asyncio
async def test_new_resets_session(self, adapter, runner):
send = await discord_send_and_capture(adapter, "/new")
send.assert_called_once()
runner.session_store.reset_session.assert_called_once()
@pytest.mark.asyncio
async def test_stop_when_no_agent_running(self, adapter):
send = await discord_send_and_capture(adapter, "/stop")
send.assert_called_once()
response_text = send.call_args[1].get("content") or send.call_args[0][1]
response_lower = response_text.lower()
assert "no" in response_lower or "stop" in response_lower or "not running" in response_lower
@pytest.mark.asyncio
async def test_commands_shows_listing(self, adapter):
send = await discord_send_and_capture(adapter, "/commands")
send.assert_called_once()
response_text = send.call_args[1].get("content") or send.call_args[0][1]
assert "/" in response_text
@pytest.mark.asyncio
async def test_sequential_commands_share_session(self, adapter):
"""Two commands from the same chat_id should both succeed."""
send_help = await discord_send_and_capture(adapter, "/help")
send_help.assert_called_once()
send_status = await discord_send_and_capture(adapter, "/status")
send_status.assert_called_once()
@pytest.mark.asyncio
@pytest.mark.xfail(
reason="Bug: _handle_provider_command references unbound model_cfg when config.yaml is absent",
strict=False,
)
async def test_provider_shows_current_provider(self, adapter):
send = await discord_send_and_capture(adapter, "/provider")
send.assert_called_once()
response_text = send.call_args[1].get("content") or send.call_args[0][1]
assert "provider" in response_text.lower()
@pytest.mark.asyncio
async def test_verbose_responds(self, adapter):
send = await discord_send_and_capture(adapter, "/verbose")
send.assert_called_once()
response_text = send.call_args[1].get("content") or send.call_args[0][1]
assert "verbose" in response_text.lower() or "tool_progress" in response_text
@pytest.mark.asyncio
async def test_personality_lists_options(self, adapter):
send = await discord_send_and_capture(adapter, "/personality")
send.assert_called_once()
response_text = send.call_args[1].get("content") or send.call_args[0][1]
assert "personalit" in response_text.lower()
@pytest.mark.asyncio
async def test_yolo_toggles_mode(self, adapter):
send = await discord_send_and_capture(adapter, "/yolo")
send.assert_called_once()
response_text = send.call_args[1].get("content") or send.call_args[0][1]
assert "yolo" in response_text.lower()
@pytest.mark.asyncio
async def test_compress_command(self, adapter):
send = await discord_send_and_capture(adapter, "/compress")
send.assert_called_once()
response_text = send.call_args[1].get("content") or send.call_args[0][1]
assert "compress" in response_text.lower() or "context" in response_text.lower()
class TestSessionLifecycle:
"""Verify session state changes across command sequences."""
@pytest.mark.asyncio
async def test_new_then_status_reflects_reset(self, adapter, runner, session_entry):
"""After /new, /status should report the fresh session."""
await discord_send_and_capture(adapter, "/new")
runner.session_store.reset_session.assert_called_once()
send = await discord_send_and_capture(adapter, "/status")
send.assert_called_once()
response_text = send.call_args[1].get("content") or send.call_args[0][1]
assert session_entry.session_id[:8] in response_text
@pytest.mark.asyncio
async def test_new_is_idempotent(self, adapter, runner):
"""/new called twice should not crash."""
await discord_send_and_capture(adapter, "/new")
await discord_send_and_capture(adapter, "/new")
assert runner.session_store.reset_session.call_count == 2
class TestAuthorization:
"""Verify the pipeline handles unauthorized users."""
@pytest.mark.asyncio
async def test_unauthorized_user_gets_pairing_response(self, adapter, runner):
"""Unauthorized DM should trigger pairing code, not a command response."""
runner._is_user_authorized = lambda _source: False
event = make_discord_event("/help")
adapter.send.reset_mock()
await adapter.handle_message(event)
await asyncio.sleep(0.3)
adapter.send.assert_called()
response_text = adapter.send.call_args[0][1] if len(adapter.send.call_args[0]) > 1 else ""
assert "recognize" in response_text.lower() or "pair" in response_text.lower() or "ABC123" in response_text
@pytest.mark.asyncio
async def test_unauthorized_user_does_not_get_help(self, adapter, runner):
"""Unauthorized user should NOT see the help command output."""
runner._is_user_authorized = lambda _source: False
event = make_discord_event("/help")
adapter.send.reset_mock()
await adapter.handle_message(event)
await asyncio.sleep(0.3)
if adapter.send.called:
response_text = adapter.send.call_args[0][1] if len(adapter.send.call_args[0]) > 1 else ""
assert "/new" not in response_text
class TestSendFailureResilience:
"""Verify the pipeline handles send failures gracefully."""
@pytest.mark.asyncio
async def test_send_failure_does_not_crash_pipeline(self, adapter):
"""If send() returns failure, the pipeline should not raise."""
adapter.send = AsyncMock(return_value=SendResult(success=False, error="network timeout"))
adapter.set_message_handler(adapter._message_handler) # re-wire with same handler
event = make_discord_event("/help")
await adapter.handle_message(event)
await asyncio.sleep(0.3)
adapter.send.assert_called()