"""Tests for Discord reply_to_mode functionality. Covers the threading behavior control for multi-chunk replies: - "off": Never reply-reference to original message - "first": Only first chunk uses reply reference (default) - "all": All chunks reply-reference the original message """ import os import sys from types import SimpleNamespace from unittest.mock import MagicMock, AsyncMock, patch import pytest from gateway.config import PlatformConfig, GatewayConfig, Platform, _apply_env_overrides def _ensure_discord_mock(): """Install a mock discord module when discord.py isn't available.""" if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"): return discord_mod = MagicMock() discord_mod.Intents.default.return_value = MagicMock() discord_mod.Client = MagicMock discord_mod.File = MagicMock discord_mod.DMChannel = type("DMChannel", (), {}) discord_mod.Thread = type("Thread", (), {}) discord_mod.ForumChannel = type("ForumChannel", (), {}) discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object) discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, secondary=2, danger=3, green=1, grey=2, blurple=2, red=3) discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4, purple=lambda: 5) discord_mod.Interaction = object discord_mod.Embed = MagicMock discord_mod.app_commands = SimpleNamespace( describe=lambda **kwargs: (lambda fn: fn), choices=lambda **kwargs: (lambda fn: fn), Choice=lambda **kwargs: SimpleNamespace(**kwargs), ) 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) _ensure_discord_mock() from gateway.platforms.discord import DiscordAdapter # noqa: E402 @pytest.fixture() def adapter_factory(): """Factory to create DiscordAdapter with custom reply_to_mode.""" def create(reply_to_mode: str = "first"): config = PlatformConfig(enabled=True, token="test-token", reply_to_mode=reply_to_mode) return DiscordAdapter(config) return create class TestReplyToModeConfig: """Tests for reply_to_mode configuration loading.""" def test_default_mode_is_first(self, adapter_factory): adapter = adapter_factory() assert adapter._reply_to_mode == "first" def test_off_mode(self, adapter_factory): adapter = adapter_factory(reply_to_mode="off") assert adapter._reply_to_mode == "off" def test_first_mode(self, adapter_factory): adapter = adapter_factory(reply_to_mode="first") assert adapter._reply_to_mode == "first" def test_all_mode(self, adapter_factory): adapter = adapter_factory(reply_to_mode="all") assert adapter._reply_to_mode == "all" def test_invalid_mode_stored_as_is(self, adapter_factory): """Invalid modes are stored but send() handles them gracefully.""" adapter = adapter_factory(reply_to_mode="invalid") assert adapter._reply_to_mode == "invalid" def test_none_mode_defaults_to_first(self): config = PlatformConfig(enabled=True, token="test-token") adapter = DiscordAdapter(config) assert adapter._reply_to_mode == "first" def test_empty_string_mode_defaults_to_first(self): config = PlatformConfig(enabled=True, token="test-token", reply_to_mode="") adapter = DiscordAdapter(config) assert adapter._reply_to_mode == "first" def _make_discord_adapter(reply_to_mode: str = "first"): """Create a DiscordAdapter with mocked client and channel for send() tests.""" config = PlatformConfig(enabled=True, token="test-token", reply_to_mode=reply_to_mode) adapter = DiscordAdapter(config) # Mock the Discord client and channel mock_channel = AsyncMock() ref_message = MagicMock() mock_channel.fetch_message = AsyncMock(return_value=ref_message) sent_msg = MagicMock() sent_msg.id = 42 mock_channel.send = AsyncMock(return_value=sent_msg) mock_client = MagicMock() mock_client.get_channel = MagicMock(return_value=mock_channel) adapter._client = mock_client return adapter, mock_channel, ref_message class TestSendWithReplyToMode: """Tests for send() method respecting reply_to_mode.""" @pytest.mark.asyncio async def test_off_mode_no_reply_reference(self): adapter, channel, ref_msg = _make_discord_adapter("off") adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2", "chunk3"] await adapter.send("12345", "test content", reply_to="999") # Should never try to fetch the reference message channel.fetch_message.assert_not_called() # All chunks sent without reference for call in channel.send.call_args_list: assert call.kwargs.get("reference") is None @pytest.mark.asyncio async def test_first_mode_only_first_chunk_references(self): adapter, channel, ref_msg = _make_discord_adapter("first") adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2", "chunk3"] await adapter.send("12345", "test content", reply_to="999") # Should fetch the reference message channel.fetch_message.assert_called_once_with(999) calls = channel.send.call_args_list assert len(calls) == 3 assert calls[0].kwargs.get("reference") is ref_msg assert calls[1].kwargs.get("reference") is None assert calls[2].kwargs.get("reference") is None @pytest.mark.asyncio async def test_all_mode_all_chunks_reference(self): adapter, channel, ref_msg = _make_discord_adapter("all") adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2", "chunk3"] await adapter.send("12345", "test content", reply_to="999") channel.fetch_message.assert_called_once_with(999) calls = channel.send.call_args_list assert len(calls) == 3 for call in calls: assert call.kwargs.get("reference") is ref_msg @pytest.mark.asyncio async def test_no_reply_to_param_no_reference(self): adapter, channel, ref_msg = _make_discord_adapter("all") adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2"] await adapter.send("12345", "test content", reply_to=None) channel.fetch_message.assert_not_called() for call in channel.send.call_args_list: assert call.kwargs.get("reference") is None @pytest.mark.asyncio async def test_single_chunk_respects_first_mode(self): adapter, channel, ref_msg = _make_discord_adapter("first") adapter.truncate_message = lambda content, max_len: ["single chunk"] await adapter.send("12345", "test", reply_to="999") calls = channel.send.call_args_list assert len(calls) == 1 assert calls[0].kwargs.get("reference") is ref_msg @pytest.mark.asyncio async def test_single_chunk_off_mode(self): adapter, channel, ref_msg = _make_discord_adapter("off") adapter.truncate_message = lambda content, max_len: ["single chunk"] await adapter.send("12345", "test", reply_to="999") channel.fetch_message.assert_not_called() calls = channel.send.call_args_list assert len(calls) == 1 assert calls[0].kwargs.get("reference") is None @pytest.mark.asyncio async def test_invalid_mode_falls_back_to_first_behavior(self): """Invalid mode behaves like 'first' — only first chunk gets reference.""" adapter, channel, ref_msg = _make_discord_adapter("banana") adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2"] await adapter.send("12345", "test", reply_to="999") calls = channel.send.call_args_list assert len(calls) == 2 assert calls[0].kwargs.get("reference") is ref_msg assert calls[1].kwargs.get("reference") is None class TestConfigSerialization: """Tests for reply_to_mode serialization (shared with Telegram).""" def test_to_dict_includes_reply_to_mode(self): config = PlatformConfig(enabled=True, token="test", reply_to_mode="all") result = config.to_dict() assert result["reply_to_mode"] == "all" def test_from_dict_loads_reply_to_mode(self): data = {"enabled": True, "token": "***", "reply_to_mode": "off"} config = PlatformConfig.from_dict(data) assert config.reply_to_mode == "off" def test_from_dict_defaults_to_first(self): data = {"enabled": True, "token": "***"} config = PlatformConfig.from_dict(data) assert config.reply_to_mode == "first" class TestEnvVarOverride: """Tests for DISCORD_REPLY_TO_MODE environment variable override.""" def _make_config(self): config = GatewayConfig() config.platforms[Platform.DISCORD] = PlatformConfig(enabled=True, token="test") return config def test_env_var_sets_off_mode(self): config = self._make_config() with patch.dict(os.environ, {"DISCORD_REPLY_TO_MODE": "off"}, clear=False): _apply_env_overrides(config) assert config.platforms[Platform.DISCORD].reply_to_mode == "off" def test_env_var_sets_all_mode(self): config = self._make_config() with patch.dict(os.environ, {"DISCORD_REPLY_TO_MODE": "all"}, clear=False): _apply_env_overrides(config) assert config.platforms[Platform.DISCORD].reply_to_mode == "all" def test_env_var_case_insensitive(self): config = self._make_config() with patch.dict(os.environ, {"DISCORD_REPLY_TO_MODE": "ALL"}, clear=False): _apply_env_overrides(config) assert config.platforms[Platform.DISCORD].reply_to_mode == "all" def test_env_var_invalid_value_ignored(self): config = self._make_config() with patch.dict(os.environ, {"DISCORD_REPLY_TO_MODE": "banana"}, clear=False): _apply_env_overrides(config) assert config.platforms[Platform.DISCORD].reply_to_mode == "first" def test_env_var_empty_value_ignored(self): config = self._make_config() with patch.dict(os.environ, {"DISCORD_REPLY_TO_MODE": ""}, clear=False): _apply_env_overrides(config) assert config.platforms[Platform.DISCORD].reply_to_mode == "first" def test_env_var_creates_platform_config_if_missing(self): """DISCORD_REPLY_TO_MODE creates PlatformConfig even without DISCORD_BOT_TOKEN.""" config = GatewayConfig() assert Platform.DISCORD not in config.platforms with patch.dict(os.environ, {"DISCORD_REPLY_TO_MODE": "off"}, clear=False): _apply_env_overrides(config) assert Platform.DISCORD in config.platforms assert config.platforms[Platform.DISCORD].reply_to_mode == "off"