diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index c2d4f01351..3ca690d46c 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -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 diff --git a/tests/e2e/test_discord_commands.py b/tests/e2e/test_discord_commands.py new file mode 100644 index 0000000000..39e8d7ac5d --- /dev/null +++ b/tests/e2e/test_discord_commands.py @@ -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()