test(e2e): add Discord e2e integration tests
This commit is contained in:
committed by
Teknium
parent
9555a0cf31
commit
7033dbf5d6
@@ -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:
|
These tests exercise the full async message flow:
|
||||||
adapter.handle_message(event)
|
adapter.handle_message(event)
|
||||||
@@ -14,14 +14,16 @@ import sys
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from types import SimpleNamespace
|
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.config import GatewayConfig, Platform, PlatformConfig
|
||||||
from gateway.platforms.base import MessageEvent, SendResult
|
from gateway.platforms.base import MessageEvent, SendResult
|
||||||
from gateway.session import SessionEntry, SessionSource, build_session_key
|
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():
|
def _ensure_telegram_mock():
|
||||||
"""Install mock telegram modules so TelegramAdapter can be imported."""
|
"""Install mock telegram modules so TelegramAdapter can be imported."""
|
||||||
@@ -56,6 +58,44 @@ _ensure_telegram_mock()
|
|||||||
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
|
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)
|
#GatewayRunner factory (based on tests/gateway/test_status_command.py)
|
||||||
|
|
||||||
def make_runner(session_entry: SessionEntry) -> "GatewayRunner":
|
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
|
# Let the background task complete
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
return adapter.send
|
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
|
||||||
|
|||||||
221
tests/e2e/test_discord_commands.py
Normal file
221
tests/e2e/test_discord_commands.py
Normal 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()
|
||||||
Reference in New Issue
Block a user