test: add tests for Discord channel controls and Telegram reactions

- 14 tests for ignored_channels, no_thread_channels, and config bridging
- 17 tests for reaction enable/disable, API calls, error handling, and config
This commit is contained in:
Teknium
2026-04-07 17:35:31 -07:00
committed by Teknium
parent 52b3a3ca3a
commit a6547f399f
2 changed files with 600 additions and 0 deletions

View File

@@ -0,0 +1,342 @@
"""Tests for Discord ignored_channels and no_thread_channels config."""
from types import SimpleNamespace
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock
import sys
import pytest
from gateway.config import PlatformConfig
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()
import gateway.platforms.discord as discord_platform # noqa: E402
from gateway.platforms.discord import DiscordAdapter # noqa: E402
class FakeDMChannel:
def __init__(self, channel_id: int = 1, name: str = "dm"):
self.id = channel_id
self.name = name
class FakeTextChannel:
def __init__(self, channel_id: int = 1, name: str = "general", guild_name: str = "Hermes Server"):
self.id = channel_id
self.name = name
self.guild = SimpleNamespace(name=guild_name)
self.topic = None
class FakeThread:
def __init__(self, channel_id: int = 1, name: str = "thread", parent=None, guild_name: str = "Hermes Server"):
self.id = channel_id
self.name = name
self.parent = parent
self.parent_id = getattr(parent, "id", None)
self.guild = getattr(parent, "guild", None) or SimpleNamespace(name=guild_name)
self.topic = None
@pytest.fixture
def adapter(monkeypatch):
monkeypatch.setattr(discord_platform.discord, "DMChannel", FakeDMChannel, raising=False)
monkeypatch.setattr(discord_platform.discord, "Thread", FakeThread, raising=False)
monkeypatch.setattr(discord_platform.discord, "ForumChannel", type("ForumChannel", (), {}), raising=False)
config = PlatformConfig(enabled=True, token="fake-token")
adapter = DiscordAdapter(config)
adapter._client = SimpleNamespace(user=SimpleNamespace(id=999))
adapter.handle_message = AsyncMock()
return adapter
def make_message(*, channel, content: str, mentions=None):
author = SimpleNamespace(id=42, display_name="TestUser", name="TestUser")
return SimpleNamespace(
id=123,
content=content,
mentions=list(mentions or []),
attachments=[],
reference=None,
created_at=datetime.now(timezone.utc),
channel=channel,
author=author,
)
# ── ignored_channels ─────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_ignored_channel_blocks_message(adapter, monkeypatch):
"""Messages in ignored channels are silently dropped."""
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
monkeypatch.setenv("DISCORD_IGNORED_CHANNELS", "500")
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
message = make_message(channel=FakeTextChannel(channel_id=500), content="hello")
await adapter._handle_message(message)
adapter.handle_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_ignored_channel_blocks_even_with_mention(adapter, monkeypatch):
"""Ignored channels take priority — even @mentions are dropped."""
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
monkeypatch.setenv("DISCORD_IGNORED_CHANNELS", "500")
bot_user = adapter._client.user
message = make_message(
channel=FakeTextChannel(channel_id=500),
content=f"<@{bot_user.id}> hello",
mentions=[bot_user],
)
await adapter._handle_message(message)
adapter.handle_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_non_ignored_channel_processes_normally(adapter, monkeypatch):
"""Channels not in the ignored list process normally."""
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
monkeypatch.setenv("DISCORD_IGNORED_CHANNELS", "500,600")
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
message = make_message(channel=FakeTextChannel(channel_id=700), content="hello")
await adapter._handle_message(message)
adapter.handle_message.assert_awaited_once()
@pytest.mark.asyncio
async def test_ignored_channels_csv_parsing(adapter, monkeypatch):
"""Multiple channel IDs are parsed correctly from CSV."""
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
monkeypatch.setenv("DISCORD_IGNORED_CHANNELS", "500, 600 , 700")
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
for ch_id in (500, 600, 700):
adapter.handle_message.reset_mock()
message = make_message(channel=FakeTextChannel(channel_id=ch_id), content="hello")
await adapter._handle_message(message)
adapter.handle_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_ignored_channels_empty_string_ignores_nothing(adapter, monkeypatch):
"""Empty DISCORD_IGNORED_CHANNELS means nothing is ignored."""
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
monkeypatch.setenv("DISCORD_IGNORED_CHANNELS", "")
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
message = make_message(channel=FakeTextChannel(channel_id=500), content="hello")
await adapter._handle_message(message)
adapter.handle_message.assert_awaited_once()
@pytest.mark.asyncio
async def test_ignored_channel_thread_parent_match(adapter, monkeypatch):
"""Thread whose parent channel is ignored should also be ignored."""
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
monkeypatch.setenv("DISCORD_IGNORED_CHANNELS", "500")
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
parent = FakeTextChannel(channel_id=500, name="ignored-channel")
thread = FakeThread(channel_id=501, name="thread-in-ignored", parent=parent)
message = make_message(channel=thread, content="hello from thread")
await adapter._handle_message(message)
adapter.handle_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_dms_unaffected_by_ignored_channels(adapter, monkeypatch):
"""DMs should never be affected by ignored_channels."""
monkeypatch.setenv("DISCORD_IGNORED_CHANNELS", "500")
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
message = make_message(channel=FakeDMChannel(channel_id=500), content="dm hello")
await adapter._handle_message(message)
adapter.handle_message.assert_awaited_once()
# ── no_thread_channels ───────────────────────────────────────────────
@pytest.mark.asyncio
async def test_no_thread_channel_skips_auto_thread(adapter, monkeypatch):
"""Channels in no_thread_channels should not auto-create threads."""
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
monkeypatch.setenv("DISCORD_NO_THREAD_CHANNELS", "800")
monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False)
monkeypatch.delenv("DISCORD_IGNORED_CHANNELS", raising=False)
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
adapter._auto_create_thread = AsyncMock(return_value=FakeThread(channel_id=999))
message = make_message(channel=FakeTextChannel(channel_id=800), content="hello")
await adapter._handle_message(message)
adapter._auto_create_thread.assert_not_awaited()
adapter.handle_message.assert_awaited_once()
event = adapter.handle_message.await_args.args[0]
assert event.source.chat_type == "group"
@pytest.mark.asyncio
async def test_normal_channel_still_auto_threads(adapter, monkeypatch):
"""Channels NOT in no_thread_channels still get auto-threading."""
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
monkeypatch.setenv("DISCORD_NO_THREAD_CHANNELS", "800")
monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False)
monkeypatch.delenv("DISCORD_IGNORED_CHANNELS", raising=False)
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
fake_thread = FakeThread(channel_id=999, name="auto-thread")
adapter._auto_create_thread = AsyncMock(return_value=fake_thread)
message = make_message(channel=FakeTextChannel(channel_id=900), content="hello")
await adapter._handle_message(message)
adapter._auto_create_thread.assert_awaited_once()
adapter.handle_message.assert_awaited_once()
event = adapter.handle_message.await_args.args[0]
assert event.source.chat_type == "thread"
@pytest.mark.asyncio
async def test_no_thread_channels_csv_parsing(adapter, monkeypatch):
"""Multiple no_thread channel IDs parsed from CSV."""
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
monkeypatch.setenv("DISCORD_NO_THREAD_CHANNELS", "800, 900")
monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False)
monkeypatch.delenv("DISCORD_IGNORED_CHANNELS", raising=False)
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
adapter._auto_create_thread = AsyncMock(return_value=FakeThread(channel_id=999))
for ch_id in (800, 900):
adapter._auto_create_thread.reset_mock()
adapter.handle_message.reset_mock()
message = make_message(channel=FakeTextChannel(channel_id=ch_id), content="hello")
await adapter._handle_message(message)
adapter._auto_create_thread.assert_not_awaited()
@pytest.mark.asyncio
async def test_no_thread_with_auto_thread_disabled_is_noop(adapter, monkeypatch):
"""no_thread_channels is a no-op when auto_thread is globally disabled."""
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
monkeypatch.setenv("DISCORD_AUTO_THREAD", "false")
monkeypatch.setenv("DISCORD_NO_THREAD_CHANNELS", "800")
monkeypatch.delenv("DISCORD_IGNORED_CHANNELS", raising=False)
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
adapter._auto_create_thread = AsyncMock()
message = make_message(channel=FakeTextChannel(channel_id=800), content="hello")
await adapter._handle_message(message)
adapter._auto_create_thread.assert_not_awaited()
adapter.handle_message.assert_awaited_once()
# ── config.py bridging ───────────────────────────────────────────────
def test_config_bridges_ignored_channels(monkeypatch, tmp_path):
"""gateway/config.py bridges discord.ignored_channels to env var."""
import yaml
config_file = tmp_path / "config.yaml"
config_file.write_text(yaml.dump({
"discord": {
"ignored_channels": ["111", "222"],
},
}))
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.delenv("DISCORD_IGNORED_CHANNELS", raising=False)
from gateway.config import load_gateway_config
load_gateway_config()
import os
assert os.getenv("DISCORD_IGNORED_CHANNELS") == "111,222"
def test_config_bridges_no_thread_channels(monkeypatch, tmp_path):
"""gateway/config.py bridges discord.no_thread_channels to env var."""
import yaml
config_file = tmp_path / "config.yaml"
config_file.write_text(yaml.dump({
"discord": {
"no_thread_channels": ["333"],
},
}))
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.delenv("DISCORD_NO_THREAD_CHANNELS", raising=False)
from gateway.config import load_gateway_config
load_gateway_config()
import os
assert os.getenv("DISCORD_NO_THREAD_CHANNELS") == "333"
def test_config_env_var_takes_precedence(monkeypatch, tmp_path):
"""Env vars should take precedence over config.yaml values."""
import yaml
config_file = tmp_path / "config.yaml"
config_file.write_text(yaml.dump({
"discord": {
"ignored_channels": ["111"],
},
}))
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("DISCORD_IGNORED_CHANNELS", "999")
from gateway.config import load_gateway_config
load_gateway_config()
import os
# Env var should NOT be overwritten
assert os.getenv("DISCORD_IGNORED_CHANNELS") == "999"

View File

@@ -0,0 +1,258 @@
"""Tests for Telegram message reactions tied to processing lifecycle hooks."""
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import MessageEvent, MessageType
from gateway.session import SessionSource
def _make_adapter(**extra_env):
from gateway.platforms.telegram import TelegramAdapter
adapter = object.__new__(TelegramAdapter)
adapter.platform = Platform.TELEGRAM
adapter.config = PlatformConfig(enabled=True, token="fake-token")
adapter._bot = AsyncMock()
adapter._bot.set_message_reaction = AsyncMock()
return adapter
def _make_event(chat_id: str = "123", message_id: str = "456") -> MessageEvent:
return MessageEvent(
text="hello",
message_type=MessageType.TEXT,
source=SessionSource(
platform=Platform.TELEGRAM,
chat_id=chat_id,
chat_type="private",
user_id="42",
user_name="TestUser",
),
message_id=message_id,
)
# ── _reactions_enabled ───────────────────────────────────────────────
def test_reactions_disabled_by_default(monkeypatch):
"""Telegram reactions should be disabled by default."""
monkeypatch.delenv("TELEGRAM_REACTIONS", raising=False)
adapter = _make_adapter()
assert adapter._reactions_enabled() is False
def test_reactions_enabled_when_set_true(monkeypatch):
"""Setting TELEGRAM_REACTIONS=true enables reactions."""
monkeypatch.setenv("TELEGRAM_REACTIONS", "true")
adapter = _make_adapter()
assert adapter._reactions_enabled() is True
def test_reactions_enabled_with_1(monkeypatch):
"""TELEGRAM_REACTIONS=1 enables reactions."""
monkeypatch.setenv("TELEGRAM_REACTIONS", "1")
adapter = _make_adapter()
assert adapter._reactions_enabled() is True
def test_reactions_disabled_with_false(monkeypatch):
"""TELEGRAM_REACTIONS=false disables reactions."""
monkeypatch.setenv("TELEGRAM_REACTIONS", "false")
adapter = _make_adapter()
assert adapter._reactions_enabled() is False
def test_reactions_disabled_with_0(monkeypatch):
"""TELEGRAM_REACTIONS=0 disables reactions."""
monkeypatch.setenv("TELEGRAM_REACTIONS", "0")
adapter = _make_adapter()
assert adapter._reactions_enabled() is False
def test_reactions_disabled_with_no(monkeypatch):
"""TELEGRAM_REACTIONS=no disables reactions."""
monkeypatch.setenv("TELEGRAM_REACTIONS", "no")
adapter = _make_adapter()
assert adapter._reactions_enabled() is False
# ── _set_reaction ────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_set_reaction_calls_bot_api(monkeypatch):
"""_set_reaction should call bot.set_message_reaction with correct args."""
monkeypatch.setenv("TELEGRAM_REACTIONS", "true")
adapter = _make_adapter()
result = await adapter._set_reaction("123", "456", "\U0001f440")
assert result is True
adapter._bot.set_message_reaction.assert_awaited_once_with(
chat_id=123,
message_id=456,
reaction="\U0001f440",
)
@pytest.mark.asyncio
async def test_set_reaction_returns_false_without_bot(monkeypatch):
"""_set_reaction should return False when bot is not available."""
monkeypatch.setenv("TELEGRAM_REACTIONS", "true")
adapter = _make_adapter()
adapter._bot = None
result = await adapter._set_reaction("123", "456", "\U0001f440")
assert result is False
@pytest.mark.asyncio
async def test_set_reaction_handles_api_error_gracefully(monkeypatch):
"""API errors during reaction should not propagate."""
monkeypatch.setenv("TELEGRAM_REACTIONS", "true")
adapter = _make_adapter()
adapter._bot.set_message_reaction = AsyncMock(side_effect=RuntimeError("no perms"))
result = await adapter._set_reaction("123", "456", "\U0001f440")
assert result is False
# ── on_processing_start ──────────────────────────────────────────────
@pytest.mark.asyncio
async def test_on_processing_start_adds_eyes_reaction(monkeypatch):
"""Processing start should add eyes reaction when enabled."""
monkeypatch.setenv("TELEGRAM_REACTIONS", "true")
adapter = _make_adapter()
event = _make_event()
await adapter.on_processing_start(event)
adapter._bot.set_message_reaction.assert_awaited_once_with(
chat_id=123,
message_id=456,
reaction="\U0001f440",
)
@pytest.mark.asyncio
async def test_on_processing_start_skipped_when_disabled(monkeypatch):
"""Processing start should not react when reactions are disabled."""
monkeypatch.delenv("TELEGRAM_REACTIONS", raising=False)
adapter = _make_adapter()
event = _make_event()
await adapter.on_processing_start(event)
adapter._bot.set_message_reaction.assert_not_awaited()
@pytest.mark.asyncio
async def test_on_processing_start_handles_missing_ids(monkeypatch):
"""Should handle events without chat_id or message_id gracefully."""
monkeypatch.setenv("TELEGRAM_REACTIONS", "true")
adapter = _make_adapter()
event = MessageEvent(
text="hello",
message_type=MessageType.TEXT,
source=SimpleNamespace(chat_id=None),
message_id=None,
)
await adapter.on_processing_start(event)
adapter._bot.set_message_reaction.assert_not_awaited()
# ── on_processing_complete ───────────────────────────────────────────
@pytest.mark.asyncio
async def test_on_processing_complete_success(monkeypatch):
"""Successful processing should set check mark reaction."""
monkeypatch.setenv("TELEGRAM_REACTIONS", "true")
adapter = _make_adapter()
event = _make_event()
await adapter.on_processing_complete(event, success=True)
adapter._bot.set_message_reaction.assert_awaited_once_with(
chat_id=123,
message_id=456,
reaction="\u2705",
)
@pytest.mark.asyncio
async def test_on_processing_complete_failure(monkeypatch):
"""Failed processing should set cross mark reaction."""
monkeypatch.setenv("TELEGRAM_REACTIONS", "true")
adapter = _make_adapter()
event = _make_event()
await adapter.on_processing_complete(event, success=False)
adapter._bot.set_message_reaction.assert_awaited_once_with(
chat_id=123,
message_id=456,
reaction="\u274c",
)
@pytest.mark.asyncio
async def test_on_processing_complete_skipped_when_disabled(monkeypatch):
"""Processing complete should not react when reactions are disabled."""
monkeypatch.delenv("TELEGRAM_REACTIONS", raising=False)
adapter = _make_adapter()
event = _make_event()
await adapter.on_processing_complete(event, success=True)
adapter._bot.set_message_reaction.assert_not_awaited()
# ── config.py bridging ───────────────────────────────────────────────
def test_config_bridges_telegram_reactions(monkeypatch, tmp_path):
"""gateway/config.py bridges telegram.reactions to TELEGRAM_REACTIONS env var."""
import yaml
config_file = tmp_path / "config.yaml"
config_file.write_text(yaml.dump({
"telegram": {
"reactions": True,
},
}))
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.delenv("TELEGRAM_REACTIONS", raising=False)
from gateway.config import load_gateway_config
load_gateway_config()
import os
assert os.getenv("TELEGRAM_REACTIONS") == "true"
def test_config_reactions_env_takes_precedence(monkeypatch, tmp_path):
"""Env var should take precedence over config.yaml for reactions."""
import yaml
config_file = tmp_path / "config.yaml"
config_file.write_text(yaml.dump({
"telegram": {
"reactions": True,
},
}))
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("TELEGRAM_REACTIONS", "false")
from gateway.config import load_gateway_config
load_gateway_config()
import os
assert os.getenv("TELEGRAM_REACTIONS") == "false"