diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 3ca690d46c..67db74ddc0 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -1,4 +1,4 @@ -"""Shared fixtures for Telegram and Discord gateway e2e tests. +"""Shared fixtures for gateway e2e tests (Telegram, Discord). These tests exercise the full async message flow: adapter.handle_message(event) @@ -16,19 +16,20 @@ from datetime import datetime from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch +import pytest + from gateway.config import GatewayConfig, Platform, PlatformConfig from gateway.platforms.base import MessageEvent, SendResult from gateway.session import SessionEntry, SessionSource, build_session_key -# --------------------------------------------------------------------------- -# Telegram mock -# --------------------------------------------------------------------------- +# Platform library mocks +# Ensure telegram module is available (mock it if not installed) def _ensure_telegram_mock(): """Install mock telegram modules so TelegramAdapter can be imported.""" if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"): - return # Real library installed + return # Real library installed telegram_mod = MagicMock() telegram_mod.Update = MagicMock() @@ -53,19 +54,11 @@ def _ensure_telegram_mock(): sys.modules.setdefault(name, telegram_mod) -_ensure_telegram_mock() - -from gateway.platforms.telegram import TelegramAdapter # noqa: E402 - - -# --------------------------------------------------------------------------- -# Discord mock -# --------------------------------------------------------------------------- - +# Ensure discord module is available (mock it if not installed) 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 + return # Real library installed discord_mod = MagicMock() discord_mod.Intents.default.return_value = MagicMock() @@ -91,139 +84,58 @@ def _ensure_discord_mock(): sys.modules.setdefault("discord.opus", discord_mod.opus) +_ensure_telegram_mock() _ensure_discord_mock() -from gateway.platforms.discord import DiscordAdapter # noqa: E402 +from gateway.platforms.discord import DiscordAdapter # noqa: E402 +from gateway.platforms.telegram import TelegramAdapter # noqa: E402 -#GatewayRunner factory (based on tests/gateway/test_status_command.py) +# Platform-generic factories -def make_runner(session_entry: SessionEntry) -> "GatewayRunner": +def make_source(platform: Platform, chat_id: str = "e2e-chat-1", user_id: str = "e2e-user-1") -> SessionSource: + return SessionSource( + platform=platform, + chat_id=chat_id, + user_id=user_id, + user_name="e2e_tester", + chat_type="dm", + ) + + +def make_session_entry(platform: Platform, source: SessionSource = None) -> SessionEntry: + source = source or make_source(platform) + 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, + chat_type="dm", + ) + + +def make_event(platform: Platform, text: str = "/help", chat_id: str = "e2e-chat-1", user_id: str = "e2e-user-1") -> MessageEvent: + return MessageEvent( + text=text, + source=make_source(platform, chat_id, user_id), + message_id=f"msg-{uuid.uuid4().hex[:8]}", + ) + + +def make_runner(platform: Platform, session_entry: SessionEntry = None) -> "GatewayRunner": """Create a GatewayRunner with mocked internals for e2e testing. Skips __init__ to avoid filesystem/network side effects. - All command-dispatch dependencies are wired manually. """ from gateway.run import GatewayRunner - runner = object.__new__(GatewayRunner) - runner.config = GatewayConfig( - platforms={Platform.TELEGRAM: 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() - - # Pairing store (used by authorization rejection path) - 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 - - -#TelegramAdapter factory - -def make_adapter(runner) -> TelegramAdapter: - """Create a TelegramAdapter wired to *runner*, with send methods mocked. - - connect() is NOT called — no polling, no token lock, no real HTTP. - """ - config = PlatformConfig(enabled=True, token="e2e-test-token") - adapter = TelegramAdapter(config) - - # Mock outbound methods so tests can capture what was sent - adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="e2e-resp-1")) - adapter.send_typing = AsyncMock() - - # Wire adapter ↔ runner - adapter.set_message_handler(runner._handle_message) - runner.adapters[Platform.TELEGRAM] = adapter - - return adapter - - -#Helpers - -def make_source(chat_id: str = "e2e-chat-1", user_id: str = "e2e-user-1") -> SessionSource: - return SessionSource( - platform=Platform.TELEGRAM, - chat_id=chat_id, - user_id=user_id, - user_name="e2e_tester", - chat_type="dm", - ) - - -def make_event(text: str, chat_id: str = "e2e-chat-1", user_id: str = "e2e-user-1") -> MessageEvent: - return MessageEvent( - text=text, - source=make_source(chat_id, user_id), - message_id=f"msg-{uuid.uuid4().hex[:8]}", - ) - - -def make_session_entry(source: SessionSource = None) -> SessionEntry: - source = source or make_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.TELEGRAM, - chat_type="dm", - ) - - -async def send_and_capture(adapter: TelegramAdapter, text: str, **event_kwargs) -> AsyncMock: - """Send a message through the full e2e flow and return the send mock. - - Drives: adapter.handle_message → background task → runner dispatch → adapter.send. - """ - event = make_event(text, **event_kwargs) - adapter.send.reset_mock() - await adapter.handle_message(event) - # 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 + if session_entry is None: + session_entry = make_session_entry(platform) runner = object.__new__(GatewayRunner) runner.config = GatewayConfig( - platforms={Platform.DISCORD: PlatformConfig(enabled=True, token="e2e-test-token")} + platforms={platform: PlatformConfig(enabled=True, token="e2e-test-token")} ) runner.adapters = {} runner._voice_mode = {} @@ -261,58 +173,60 @@ def make_discord_runner(session_entry: SessionEntry) -> "GatewayRunner": return runner -def make_discord_adapter(runner) -> DiscordAdapter: - """Create a DiscordAdapter wired to *runner*, with send methods mocked. +def make_adapter(platform: Platform, runner=None): + """Create a platform adapter wired to *runner*, with send methods mocked.""" + if runner is None: + runner = make_runner(platform) - 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) + + if platform == Platform.DISCORD: + with patch.object(DiscordAdapter, "_load_participated_threads", return_value=set()): + adapter = DiscordAdapter(config) + platform_key = Platform.DISCORD + else: + adapter = TelegramAdapter(config) + platform_key = Platform.TELEGRAM 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 + runner.adapters[platform_key] = 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) +async def send_and_capture(adapter, text: str, platform: Platform, **event_kwargs) -> AsyncMock: + """Send a message through the full e2e flow and return the send mock.""" + event = make_event(platform, text, **event_kwargs) adapter.send.reset_mock() await adapter.handle_message(event) await asyncio.sleep(0.3) return adapter.send + + +# Parametrized fixtures for platform-generic tests +@pytest.fixture(params=[Platform.TELEGRAM, Platform.DISCORD], ids=["telegram", "discord"]) +def platform(request): + return request.param + + +@pytest.fixture() +def source(platform): + return make_source(platform) + + +@pytest.fixture() +def session_entry(platform, source): + return make_session_entry(platform, source) + + +@pytest.fixture() +def runner(platform, session_entry): + return make_runner(platform, session_entry) + + +@pytest.fixture() +def adapter(platform, runner): + return make_adapter(platform, runner) diff --git a/tests/e2e/test_discord_commands.py b/tests/e2e/test_discord_commands.py deleted file mode 100644 index 39e8d7ac5d..0000000000 --- a/tests/e2e/test_discord_commands.py +++ /dev/null @@ -1,221 +0,0 @@ -"""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() diff --git a/tests/e2e/test_telegram_commands.py b/tests/e2e/test_platform_commands.py similarity index 66% rename from tests/e2e/test_telegram_commands.py rename to tests/e2e/test_platform_commands.py index e21be32f53..5bf72f11dd 100644 --- a/tests/e2e/test_telegram_commands.py +++ b/tests/e2e/test_platform_commands.py @@ -1,4 +1,4 @@ -"""E2E tests for Telegram gateway slash commands. +"""E2E tests for gateway slash commands (Telegram, Discord). Each test drives a message through the full async pipeline: adapter.handle_message(event) @@ -7,6 +7,7 @@ Each test drives a message through the full async pipeline: → adapter.send() (captured for assertions) No LLM involved — only gateway-level commands are tested. +Tests are parametrized over platforms via the ``platform`` fixture in conftest. """ import asyncio @@ -15,46 +16,15 @@ from unittest.mock import AsyncMock import pytest from gateway.platforms.base import SendResult -from tests.e2e.conftest import ( - make_adapter, - make_event, - make_runner, - make_session_entry, - make_source, - send_and_capture, -) +from tests.e2e.conftest import make_event, send_and_capture -#Fixtures - -@pytest.fixture() -def source(): - return make_source() - - -@pytest.fixture() -def session_entry(source): - return make_session_entry(source) - - -@pytest.fixture() -def runner(session_entry): - return make_runner(session_entry) - - -@pytest.fixture() -def adapter(runner): - return make_adapter(runner) - - -#Tests - -class TestTelegramSlashCommands: +class TestSlashCommands: """Gateway slash commands dispatched through the full adapter pipeline.""" @pytest.mark.asyncio - async def test_help_returns_command_list(self, adapter): - send = await send_and_capture(adapter, "/help") + async def test_help_returns_command_list(self, adapter, platform): + send = await send_and_capture(adapter, "/help", platform) send.assert_called_once() response_text = send.call_args[1].get("content") or send.call_args[0][1] @@ -62,24 +32,23 @@ class TestTelegramSlashCommands: assert "/status" in response_text @pytest.mark.asyncio - async def test_status_shows_session_info(self, adapter): - send = await send_and_capture(adapter, "/status") + async def test_status_shows_session_info(self, adapter, platform): + send = await send_and_capture(adapter, "/status", platform) send.assert_called_once() response_text = send.call_args[1].get("content") or send.call_args[0][1] - # Status output includes session metadata 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 send_and_capture(adapter, "/new") + async def test_new_resets_session(self, adapter, runner, platform): + send = await send_and_capture(adapter, "/new", platform) 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 send_and_capture(adapter, "/stop") + async def test_stop_when_no_agent_running(self, adapter, platform): + send = await send_and_capture(adapter, "/stop", platform) send.assert_called_once() response_text = send.call_args[1].get("content") or send.call_args[0][1] @@ -87,8 +56,8 @@ class TestTelegramSlashCommands: 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 send_and_capture(adapter, "/commands") + async def test_commands_shows_listing(self, adapter, platform): + send = await send_and_capture(adapter, "/commands", platform) send.assert_called_once() response_text = send.call_args[1].get("content") or send.call_args[0][1] @@ -96,25 +65,29 @@ class TestTelegramSlashCommands: assert "/" in response_text @pytest.mark.asyncio - async def test_sequential_commands_share_session(self, adapter): + async def test_sequential_commands_share_session(self, adapter, platform): """Two commands from the same chat_id should both succeed.""" - send_help = await send_and_capture(adapter, "/help") + send_help = await send_and_capture(adapter, "/help", platform) send_help.assert_called_once() - send_status = await send_and_capture(adapter, "/status") + send_status = await send_and_capture(adapter, "/status", platform) send_status.assert_called_once() @pytest.mark.asyncio - async def test_provider_shows_current_provider(self, adapter): - send = await send_and_capture(adapter, "/provider") + @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, platform): + send = await send_and_capture(adapter, "/provider", platform) 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 send_and_capture(adapter, "/verbose") + async def test_verbose_responds(self, adapter, platform): + send = await send_and_capture(adapter, "/verbose", platform) send.assert_called_once() response_text = send.call_args[1].get("content") or send.call_args[0][1] @@ -122,42 +95,50 @@ class TestTelegramSlashCommands: 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 send_and_capture(adapter, "/personality") + async def test_personality_lists_options(self, adapter, platform): + send = await send_and_capture(adapter, "/personality", platform) send.assert_called_once() response_text = send.call_args[1].get("content") or send.call_args[0][1] assert "personalit" in response_text.lower() # matches "personality" or "personalities" @pytest.mark.asyncio - async def test_yolo_toggles_mode(self, adapter): - send = await send_and_capture(adapter, "/yolo") + async def test_yolo_toggles_mode(self, adapter, platform): + send = await send_and_capture(adapter, "/yolo", platform) 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, platform): + send = await send_and_capture(adapter, "/compress", platform) + + 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): + async def test_new_then_status_reflects_reset(self, adapter, runner, session_entry, platform): """After /new, /status should report the fresh session.""" - await send_and_capture(adapter, "/new") + await send_and_capture(adapter, "/new", platform) runner.session_store.reset_session.assert_called_once() - send = await send_and_capture(adapter, "/status") + send = await send_and_capture(adapter, "/status", platform) send.assert_called_once() response_text = send.call_args[1].get("content") or send.call_args[0][1] # Session ID from the entry should appear in the status output assert session_entry.session_id[:8] in response_text @pytest.mark.asyncio - async def test_new_is_idempotent(self, adapter, runner): + async def test_new_is_idempotent(self, adapter, runner, platform): """/new called twice should not crash.""" - await send_and_capture(adapter, "/new") - await send_and_capture(adapter, "/new") + await send_and_capture(adapter, "/new", platform) + await send_and_capture(adapter, "/new", platform) assert runner.session_store.reset_session.call_count == 2 @@ -165,11 +146,11 @@ class TestAuthorization: """Verify the pipeline handles unauthorized users.""" @pytest.mark.asyncio - async def test_unauthorized_user_gets_pairing_response(self, adapter, runner): + async def test_unauthorized_user_gets_pairing_response(self, adapter, runner, platform): """Unauthorized DM should trigger pairing code, not a command response.""" runner._is_user_authorized = lambda _source: False - event = make_event("/help") + event = make_event(platform, "/help") adapter.send.reset_mock() await adapter.handle_message(event) await asyncio.sleep(0.3) @@ -181,11 +162,11 @@ class TestAuthorization: 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): + async def test_unauthorized_user_does_not_get_help(self, adapter, runner, platform): """Unauthorized user should NOT see the help command output.""" runner._is_user_authorized = lambda _source: False - event = make_event("/help") + event = make_event(platform, "/help") adapter.send.reset_mock() await adapter.handle_message(event) await asyncio.sleep(0.3) @@ -200,12 +181,12 @@ class TestSendFailureResilience: """Verify the pipeline handles send failures gracefully.""" @pytest.mark.asyncio - async def test_send_failure_does_not_crash_pipeline(self, adapter): + async def test_send_failure_does_not_crash_pipeline(self, adapter, platform): """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 + adapter.set_message_handler(adapter._message_handler) # re-wire with same handler - event = make_event("/help") + event = make_event(platform, "/help") # Should not raise — pipeline handles send failures internally await adapter.handle_message(event) await asyncio.sleep(0.3)