Files
echo-core/tests/test_discord.py
MoltBot Service 24a4d87f8c stage-8: cron scheduler with APScheduler
Scheduler class, cron/jobs.json, Discord /cron commands, CLI cron subcommand, job lifecycle management. 88 new tests (281 total).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 16:12:56 +00:00

891 lines
31 KiB
Python

"""Tests for src/adapters/discord_bot.py — Discord bot adapter."""
import json
import logging
import signal
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
import discord
from src.config import Config
from src.adapters import discord_bot
from src.adapters.discord_bot import (
create_bot,
is_admin,
is_owner,
is_registered_channel,
split_message,
)
# --- Fixtures ---
@pytest.fixture
def tmp_config(tmp_path):
"""Create a Config backed by a temp file with default data."""
data = {
"bot": {
"name": "Echo",
"default_model": "sonnet",
"owner": None,
"admins": [],
},
"channels": {},
}
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps(data, indent=2))
return Config(config_file)
@pytest.fixture
def owned_config(tmp_path):
"""Config with owner already set."""
data = {
"bot": {
"name": "Echo",
"default_model": "sonnet",
"owner": "111",
"admins": ["222"],
},
"channels": {
"general": {"id": "900", "default_model": "sonnet"},
},
}
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps(data, indent=2))
return Config(config_file)
@pytest.fixture
def bot(tmp_config):
"""Create a bot with fresh (no-owner) config, return the client."""
return create_bot(tmp_config)
@pytest.fixture
def owned_bot(owned_config):
"""Create a bot with owned config, return the client."""
return create_bot(owned_config)
def _mock_interaction(user_id="123", channel_id="456"):
"""Create a mock discord.Interaction."""
interaction = AsyncMock(spec=discord.Interaction)
interaction.user = MagicMock()
interaction.user.id = int(user_id)
interaction.channel_id = int(channel_id)
interaction.response = AsyncMock()
interaction.response.send_message = AsyncMock()
return interaction
def _find_command(tree, name):
"""Find a top-level command on the tree by name."""
for cmd in tree.get_commands():
if cmd.name == name:
return cmd
return None
def _find_subcommand(tree, group_name, sub_name):
"""Find a subcommand inside a group on the tree."""
group = _find_command(tree, group_name)
if group is None or not isinstance(group, discord.app_commands.Group):
return None
for cmd in group.commands:
if cmd.name == sub_name:
return cmd
return None
# --- Authorization helpers ---
class TestIsOwner:
def test_is_owner_true(self, owned_bot, owned_config):
assert is_owner("111") is True
def test_is_owner_false(self, owned_bot, owned_config):
assert is_owner("999") is False
def test_is_owner_none_owner(self, bot, tmp_config):
assert is_owner("123") is False
class TestIsAdmin:
def test_is_admin_owner_is_admin(self, owned_bot):
assert is_admin("111") is True
def test_is_admin_listed(self, owned_bot):
assert is_admin("222") is True
def test_is_admin_not_listed(self, owned_bot):
assert is_admin("999") is False
class TestIsRegisteredChannel:
def test_is_registered_channel_true(self, owned_bot):
assert is_registered_channel("900") is True
def test_is_registered_channel_false(self, owned_bot):
assert is_registered_channel("000") is False
def test_is_registered_channel_empty(self, bot):
assert is_registered_channel("900") is False
# --- create_bot ---
class TestCreateBot:
def test_create_bot_returns_client(self, bot):
assert isinstance(bot, discord.Client)
def test_create_bot_has_tree(self, bot):
assert hasattr(bot, "tree")
assert isinstance(bot.tree, discord.app_commands.CommandTree)
def test_create_bot_intents_message_content(self, bot):
assert bot.intents.message_content is True
# --- Slash commands ---
class TestPing:
@pytest.mark.asyncio
async def test_ping_responds(self, bot):
cmd = _find_command(bot.tree, "ping")
assert cmd is not None
interaction = _mock_interaction()
with patch.object(type(bot), "latency", new_callable=lambda: property(lambda self: 0.042)):
await cmd.callback(interaction)
interaction.response.send_message.assert_awaited_once()
msg = interaction.response.send_message.call_args
assert "Pong!" in msg.args[0] or "Pong!" in msg.kwargs.get("content", msg.args[0] if msg.args else "")
assert msg.kwargs.get("ephemeral") is True
class TestHelp:
@pytest.mark.asyncio
async def test_help_responds_with_commands(self, bot):
cmd = _find_command(bot.tree, "help")
assert cmd is not None
interaction = _mock_interaction()
await cmd.callback(interaction)
interaction.response.send_message.assert_awaited_once()
msg_text = interaction.response.send_message.call_args.args[0]
assert "/ping" in msg_text
assert "/help" in msg_text
assert "/setup" in msg_text
assert msg_text # non-empty
class TestSetup:
@pytest.mark.asyncio
async def test_setup_sets_owner(self, bot, tmp_config):
cmd = _find_command(bot.tree, "setup")
interaction = _mock_interaction(user_id="123")
await cmd.callback(interaction)
interaction.response.send_message.assert_awaited_once()
msg = interaction.response.send_message.call_args
assert "owner of Echo" in msg.args[0]
assert tmp_config.get("bot.owner") == "123"
@pytest.mark.asyncio
async def test_setup_already_set(self, owned_bot, owned_config):
cmd = _find_command(owned_bot.tree, "setup")
interaction = _mock_interaction(user_id="999")
await cmd.callback(interaction)
msg = interaction.response.send_message.call_args
assert "already set" in msg.args[0].lower()
class TestChannelAdd:
@pytest.mark.asyncio
async def test_channel_add_as_owner(self, owned_bot, owned_config):
cmd = _find_subcommand(owned_bot.tree, "channel", "add")
assert cmd is not None
interaction = _mock_interaction(user_id="111", channel_id="456")
await cmd.callback(interaction, alias="work")
interaction.response.send_message.assert_awaited_once()
msg = interaction.response.send_message.call_args
assert "work" in msg.args[0]
assert owned_config.get("channels.work") is not None
assert owned_config.get("channels.work")["id"] == "456"
@pytest.mark.asyncio
async def test_channel_add_not_owner(self, owned_bot, owned_config):
cmd = _find_subcommand(owned_bot.tree, "channel", "add")
interaction = _mock_interaction(user_id="999", channel_id="456")
await cmd.callback(interaction, alias="work")
msg = interaction.response.send_message.call_args
assert "owner only" in msg.args[0].lower()
@pytest.mark.asyncio
async def test_channel_add_saves_to_config(self, owned_bot, owned_config):
cmd = _find_subcommand(owned_bot.tree, "channel", "add")
interaction = _mock_interaction(user_id="111", channel_id="789")
await cmd.callback(interaction, alias="dev")
# Reload config from disk to verify save() was called
owned_config.reload()
assert owned_config.get("channels.dev")["id"] == "789"
assert owned_config.get("channels.dev")["default_model"] == "sonnet"
class TestChannels:
@pytest.mark.asyncio
async def test_channels_lists(self, owned_bot, owned_config):
cmd = _find_command(owned_bot.tree, "channels")
interaction = _mock_interaction()
await cmd.callback(interaction)
msg = interaction.response.send_message.call_args
text = msg.args[0]
assert "general" in text
assert "900" in text
@pytest.mark.asyncio
async def test_channels_empty(self, bot, tmp_config):
cmd = _find_command(bot.tree, "channels")
interaction = _mock_interaction()
await cmd.callback(interaction)
msg = interaction.response.send_message.call_args
assert "no channels" in msg.args[0].lower()
class TestAdminAdd:
@pytest.mark.asyncio
async def test_admin_add_as_owner(self, owned_bot, owned_config):
cmd = _find_subcommand(owned_bot.tree, "admin", "add")
assert cmd is not None
interaction = _mock_interaction(user_id="111")
await cmd.callback(interaction, user_id="333")
msg = interaction.response.send_message.call_args
assert "333" in msg.args[0]
assert "333" in owned_config.get("bot.admins")
@pytest.mark.asyncio
async def test_admin_add_not_owner(self, owned_bot, owned_config):
cmd = _find_subcommand(owned_bot.tree, "admin", "add")
interaction = _mock_interaction(user_id="999")
await cmd.callback(interaction, user_id="333")
msg = interaction.response.send_message.call_args
assert "owner only" in msg.args[0].lower()
@pytest.mark.asyncio
async def test_admin_add_idempotent(self, owned_bot, owned_config):
cmd = _find_subcommand(owned_bot.tree, "admin", "add")
interaction = _mock_interaction(user_id="111")
# Add "222" which is already in admins list
await cmd.callback(interaction, user_id="222")
admins = owned_config.get("bot.admins")
assert admins.count("222") == 1 # no duplicates
@pytest.mark.asyncio
async def test_admin_add_saves_to_config(self, owned_bot, owned_config):
cmd = _find_subcommand(owned_bot.tree, "admin", "add")
interaction = _mock_interaction(user_id="111")
await cmd.callback(interaction, user_id="444")
owned_config.reload()
assert "444" in owned_config.get("bot.admins")
# --- on_message ---
class TestOnMessage:
def _get_on_message(self, client):
"""Extract the on_message handler from the client's event listeners."""
# discord.py Client stores overridden events; we can call it directly
# The on_message is registered via @client.event decorator
return client.on_message
@pytest.mark.asyncio
async def test_ignores_own_messages(self, owned_bot):
on_message = self._get_on_message(owned_bot)
message = MagicMock(spec=discord.Message)
message.author = owned_bot.user # bot's own user
message.channel = MagicMock()
# Should return without logging (no error)
await on_message(message)
@pytest.mark.asyncio
async def test_ignores_unregistered_channel(self, owned_bot, caplog):
on_message = self._get_on_message(owned_bot)
message = MagicMock(spec=discord.Message)
message.author = MagicMock()
message.author.id = 999
message.author != owned_bot.user # not the bot
# Make sure author comparison returns False
message.author.__eq__ = lambda self, other: False
message.channel = MagicMock(spec=discord.TextChannel)
message.channel.id = 12345 # not registered
message.content = "hello"
with caplog.at_level(logging.INFO, logger="echo-core.discord"):
await on_message(message)
assert "registered channel" not in caplog.text.lower()
@pytest.mark.asyncio
async def test_logs_registered_channel(self, owned_bot, caplog):
on_message = self._get_on_message(owned_bot)
message = MagicMock(spec=discord.Message)
message.author = MagicMock()
message.author.id = 555
message.author.__eq__ = lambda self, other: False
message.channel = MagicMock(spec=discord.TextChannel)
message.channel.id = 900 # registered channel
message.content = "hello world"
with caplog.at_level(logging.INFO, logger="echo-core.discord"):
await on_message(message)
assert "registered channel" in caplog.text.lower()
@pytest.mark.asyncio
async def test_ignores_non_admin_dm(self, owned_bot, caplog):
on_message = self._get_on_message(owned_bot)
message = MagicMock(spec=discord.Message)
message.author = MagicMock()
message.author.id = 999 # not admin
message.author.__eq__ = lambda self, other: False
message.channel = MagicMock(spec=discord.DMChannel)
message.content = "hello"
with caplog.at_level(logging.INFO, logger="echo-core.discord"):
await on_message(message)
assert "dm from admin" not in caplog.text.lower()
@pytest.mark.asyncio
async def test_logs_admin_dm(self, owned_bot, caplog):
on_message = self._get_on_message(owned_bot)
message = MagicMock(spec=discord.Message)
message.author = MagicMock()
message.author.id = 222 # in admins list
message.author.__eq__ = lambda self, other: False
message.channel = MagicMock(spec=discord.DMChannel)
message.content = "admin message"
with caplog.at_level(logging.INFO, logger="echo-core.discord"):
await on_message(message)
assert "dm from admin" in caplog.text.lower()
@pytest.mark.asyncio
@patch("src.adapters.discord_bot.route_message")
async def test_chat_flow(self, mock_route, owned_bot):
"""on_message chat flow: reaction, typing, route, send, cleanup."""
mock_route.return_value = ("Hello from Claude!", False)
on_message = self._get_on_message(owned_bot)
message = AsyncMock(spec=discord.Message)
message.author = MagicMock()
message.author.id = 555
message.author.__eq__ = lambda self, other: False
message.channel = AsyncMock(spec=discord.TextChannel)
message.channel.id = 900 # registered channel
message.content = "test message"
await on_message(message)
# Verify eyes reaction added
message.add_reaction.assert_awaited_once_with("\U0001f440")
# Verify typing indicator was triggered
message.channel.typing.assert_called_once()
# Verify response sent
message.channel.send.assert_awaited_once_with("Hello from Claude!")
# Verify eyes reaction removed
message.remove_reaction.assert_awaited_once()
# --- split_message ---
class TestSplitMessage:
def test_short_text_no_split(self):
result = split_message("hello")
assert result == ["hello"]
def test_long_text_split_at_newline(self):
text = "a" * 10 + "\n" + "b" * 10
result = split_message(text, limit=15)
assert result == ["a" * 10, "b" * 10]
def test_very_long_without_newlines_hard_split(self):
text = "a" * 30
result = split_message(text, limit=10)
assert result == ["a" * 10, "a" * 10, "a" * 10]
# --- /clear slash command ---
class TestClearSlashCommand:
@pytest.mark.asyncio
@patch("src.adapters.discord_bot.clear_session")
async def test_clear_with_session(self, mock_clear, owned_bot):
mock_clear.return_value = True
cmd = _find_command(owned_bot.tree, "clear")
interaction = _mock_interaction(channel_id="900")
await cmd.callback(interaction)
msg = interaction.response.send_message.call_args
assert "session cleared" in msg.args[0].lower()
@pytest.mark.asyncio
@patch("src.adapters.discord_bot.clear_session")
async def test_clear_no_session(self, mock_clear, owned_bot):
mock_clear.return_value = False
cmd = _find_command(owned_bot.tree, "clear")
interaction = _mock_interaction(channel_id="900")
await cmd.callback(interaction)
msg = interaction.response.send_message.call_args
assert "no active session" in msg.args[0].lower()
# --- /status slash command ---
class TestStatusSlashCommand:
@pytest.mark.asyncio
@patch("src.adapters.discord_bot.get_active_session")
async def test_status_with_session(self, mock_get, owned_bot):
mock_get.return_value = {
"session_id": "abcdef1234567890",
"model": "sonnet",
"message_count": 3,
}
cmd = _find_command(owned_bot.tree, "status")
interaction = _mock_interaction(channel_id="900")
await cmd.callback(interaction)
msg = interaction.response.send_message.call_args
text = msg.args[0] if msg.args else msg.kwargs.get("content", "")
assert "sonnet" in text
assert "3" in text
@pytest.mark.asyncio
@patch("src.adapters.discord_bot.get_active_session")
async def test_status_no_session(self, mock_get, owned_bot):
mock_get.return_value = None
cmd = _find_command(owned_bot.tree, "status")
interaction = _mock_interaction(channel_id="900")
await cmd.callback(interaction)
msg = interaction.response.send_message.call_args
assert "no active session" in msg.args[0].lower()
# --- /clear mentions model reset ---
class TestClearMentionsModelReset:
@pytest.mark.asyncio
@patch("src.adapters.discord_bot.clear_session")
async def test_clear_mentions_model_reset(self, mock_clear, owned_bot):
mock_clear.return_value = True
cmd = _find_command(owned_bot.tree, "clear")
interaction = _mock_interaction(channel_id="900")
await cmd.callback(interaction)
msg = interaction.response.send_message.call_args
text = msg.args[0]
assert "model reset" in text.lower()
assert "sonnet" in text.lower()
# --- /model slash command ---
class TestModelSlashCommand:
@pytest.mark.asyncio
@patch("src.adapters.discord_bot.get_active_session")
async def test_model_no_args_shows_current_with_session(self, mock_get, owned_bot):
mock_get.return_value = {"model": "opus"}
cmd = _find_command(owned_bot.tree, "model")
interaction = _mock_interaction(channel_id="900")
await cmd.callback(interaction, choice=None)
msg = interaction.response.send_message.call_args
text = msg.args[0]
assert "opus" in text.lower()
assert "haiku" in text
assert "sonnet" in text
assert msg.kwargs.get("ephemeral") is True
@pytest.mark.asyncio
@patch("src.adapters.discord_bot.get_active_session")
async def test_model_no_args_shows_default_without_session(self, mock_get, owned_bot):
mock_get.return_value = None
cmd = _find_command(owned_bot.tree, "model")
interaction = _mock_interaction(channel_id="900")
await cmd.callback(interaction, choice=None)
msg = interaction.response.send_message.call_args
text = msg.args[0]
assert "sonnet" in text # default from config
@pytest.mark.asyncio
@patch("src.adapters.discord_bot.set_session_model")
@patch("src.adapters.discord_bot.get_active_session")
async def test_model_with_choice_changes_existing_session(self, mock_get, mock_set, owned_bot):
mock_get.return_value = {"model": "sonnet", "session_id": "abc"}
cmd = _find_command(owned_bot.tree, "model")
interaction = _mock_interaction(channel_id="900")
choice = MagicMock()
choice.value = "opus"
await cmd.callback(interaction, choice=choice)
mock_set.assert_called_once_with("900", "opus")
msg = interaction.response.send_message.call_args
assert "opus" in msg.args[0]
@pytest.mark.asyncio
@patch("src.claude_session._save_sessions")
@patch("src.claude_session._load_sessions")
@patch("src.adapters.discord_bot.get_active_session")
async def test_model_with_choice_presets_when_no_session(self, mock_get, mock_load, mock_save, owned_bot):
mock_get.return_value = None
mock_load.return_value = {}
cmd = _find_command(owned_bot.tree, "model")
interaction = _mock_interaction(channel_id="900")
choice = MagicMock()
choice.value = "haiku"
await cmd.callback(interaction, choice=choice)
mock_save.assert_called_once()
saved_data = mock_save.call_args[0][0]
assert saved_data["900"]["model"] == "haiku"
assert saved_data["900"]["session_id"] == ""
msg = interaction.response.send_message.call_args
assert "haiku" in msg.args[0]
# --- /restart slash command ---
class TestRestartSlashCommand:
@pytest.mark.asyncio
async def test_restart_owner_succeeds(self, owned_bot, tmp_path):
pid_file = tmp_path / "echo-core.pid"
pid_file.write_text("12345")
with patch.object(discord_bot, "PROJECT_ROOT", tmp_path), \
patch("os.kill") as mock_kill:
cmd = _find_command(owned_bot.tree, "restart")
interaction = _mock_interaction(user_id="111")
await cmd.callback(interaction)
mock_kill.assert_called_once_with(12345, signal.SIGTERM)
msg = interaction.response.send_message.call_args
assert "restarting" in msg.args[0].lower()
@pytest.mark.asyncio
async def test_restart_non_owner_rejected(self, owned_bot):
cmd = _find_command(owned_bot.tree, "restart")
interaction = _mock_interaction(user_id="999")
await cmd.callback(interaction)
msg = interaction.response.send_message.call_args
assert "owner only" in msg.args[0].lower()
@pytest.mark.asyncio
async def test_restart_no_pid_file(self, owned_bot, tmp_path):
with patch.object(discord_bot, "PROJECT_ROOT", tmp_path):
cmd = _find_command(owned_bot.tree, "restart")
interaction = _mock_interaction(user_id="111")
await cmd.callback(interaction)
msg = interaction.response.send_message.call_args
assert "no pid file" in msg.args[0].lower()
# --- /logs slash command ---
class TestLogsSlashCommand:
@pytest.mark.asyncio
async def test_logs_returns_code_block(self, owned_bot, tmp_path):
log_dir = tmp_path / "logs"
log_dir.mkdir()
log_file = log_dir / "echo-core.log"
log_file.write_text("line1\nline2\nline3\n")
with patch.object(discord_bot, "PROJECT_ROOT", tmp_path):
cmd = _find_command(owned_bot.tree, "logs")
interaction = _mock_interaction()
await cmd.callback(interaction, n=10)
msg = interaction.response.send_message.call_args
text = msg.args[0]
assert "```" in text
assert "line1" in text
assert "line3" in text
@pytest.mark.asyncio
async def test_logs_no_file(self, owned_bot, tmp_path):
with patch.object(discord_bot, "PROJECT_ROOT", tmp_path):
cmd = _find_command(owned_bot.tree, "logs")
interaction = _mock_interaction()
await cmd.callback(interaction, n=10)
msg = interaction.response.send_message.call_args
assert "no log file" in msg.args[0].lower()
# --- /cron slash commands ---
class TestCronList:
@pytest.mark.asyncio
async def test_cron_list_shows_table(self, owned_bot):
mock_scheduler = MagicMock()
mock_scheduler.list_jobs.return_value = [
{
"name": "daily-summary",
"cron": "30 6 * * *",
"channel": "work",
"model": "sonnet",
"enabled": True,
"last_status": "ok",
"next_run": "2025-01-15T06:30:00+00:00",
}
]
owned_bot.scheduler = mock_scheduler
cmd = _find_subcommand(owned_bot.tree, "cron", "list")
assert cmd is not None
interaction = _mock_interaction()
await cmd.callback(interaction)
msg = interaction.response.send_message.call_args
text = msg.args[0]
assert "daily-summary" in text
assert "30 6 * * *" in text
assert "```" in text
@pytest.mark.asyncio
async def test_cron_list_empty(self, owned_bot):
mock_scheduler = MagicMock()
mock_scheduler.list_jobs.return_value = []
owned_bot.scheduler = mock_scheduler
cmd = _find_subcommand(owned_bot.tree, "cron", "list")
interaction = _mock_interaction()
await cmd.callback(interaction)
msg = interaction.response.send_message.call_args
assert "no scheduled jobs" in msg.args[0].lower()
@pytest.mark.asyncio
async def test_cron_list_no_scheduler(self, owned_bot):
cmd = _find_subcommand(owned_bot.tree, "cron", "list")
interaction = _mock_interaction()
await cmd.callback(interaction)
msg = interaction.response.send_message.call_args
assert "not available" in msg.args[0].lower()
class TestCronRun:
@pytest.mark.asyncio
async def test_cron_run_defers_and_runs(self, owned_bot):
mock_scheduler = MagicMock()
mock_scheduler.run_job = AsyncMock(return_value="Job output here")
owned_bot.scheduler = mock_scheduler
cmd = _find_subcommand(owned_bot.tree, "cron", "run")
assert cmd is not None
interaction = _mock_interaction()
interaction.followup = AsyncMock()
await cmd.callback(interaction, name="my-job")
interaction.response.defer.assert_awaited_once()
mock_scheduler.run_job.assert_awaited_once_with("my-job")
interaction.followup.send.assert_awaited_once_with("Job output here")
@pytest.mark.asyncio
async def test_cron_run_not_found(self, owned_bot):
mock_scheduler = MagicMock()
mock_scheduler.run_job = AsyncMock(side_effect=KeyError("Job 'nope' not found"))
owned_bot.scheduler = mock_scheduler
cmd = _find_subcommand(owned_bot.tree, "cron", "run")
interaction = _mock_interaction()
interaction.followup = AsyncMock()
await cmd.callback(interaction, name="nope")
interaction.response.defer.assert_awaited_once()
msg = interaction.followup.send.call_args
assert "not found" in msg.args[0].lower()
@pytest.mark.asyncio
async def test_cron_run_no_scheduler(self, owned_bot):
cmd = _find_subcommand(owned_bot.tree, "cron", "run")
interaction = _mock_interaction()
await cmd.callback(interaction, name="test")
msg = interaction.response.send_message.call_args
assert "not available" in msg.args[0].lower()
class TestCronAdd:
@pytest.mark.asyncio
async def test_cron_add_admin_only(self, owned_bot):
cmd = _find_subcommand(owned_bot.tree, "cron", "add")
assert cmd is not None
# Non-admin user
interaction = _mock_interaction(user_id="999")
await cmd.callback(interaction, name="test", expression="0 * * * *", model=None)
msg = interaction.response.send_message.call_args
assert "admin only" in msg.args[0].lower()
@pytest.mark.asyncio
async def test_cron_add_rejects_non_admin(self, owned_bot):
cmd = _find_subcommand(owned_bot.tree, "cron", "add")
interaction = _mock_interaction(user_id="888")
await cmd.callback(interaction, name="test", expression="0 * * * *", model=None)
msg = interaction.response.send_message.call_args
assert "admin only" in msg.args[0].lower()
class TestCronRemove:
@pytest.mark.asyncio
async def test_cron_remove_admin_only(self, owned_bot):
cmd = _find_subcommand(owned_bot.tree, "cron", "remove")
assert cmd is not None
interaction = _mock_interaction(user_id="999")
await cmd.callback(interaction, name="test")
msg = interaction.response.send_message.call_args
assert "admin only" in msg.args[0].lower()
@pytest.mark.asyncio
async def test_cron_remove_success(self, owned_bot):
mock_scheduler = MagicMock()
mock_scheduler.remove_job.return_value = True
owned_bot.scheduler = mock_scheduler
cmd = _find_subcommand(owned_bot.tree, "cron", "remove")
interaction = _mock_interaction(user_id="111") # owner
await cmd.callback(interaction, name="my-job")
msg = interaction.response.send_message.call_args
assert "removed" in msg.args[0].lower()
@pytest.mark.asyncio
async def test_cron_remove_not_found(self, owned_bot):
mock_scheduler = MagicMock()
mock_scheduler.remove_job.return_value = False
owned_bot.scheduler = mock_scheduler
cmd = _find_subcommand(owned_bot.tree, "cron", "remove")
interaction = _mock_interaction(user_id="111")
await cmd.callback(interaction, name="ghost")
msg = interaction.response.send_message.call_args
assert "not found" in msg.args[0].lower()
class TestCronEnable:
@pytest.mark.asyncio
async def test_cron_enable_admin_only(self, owned_bot):
cmd = _find_subcommand(owned_bot.tree, "cron", "enable")
assert cmd is not None
interaction = _mock_interaction(user_id="999")
await cmd.callback(interaction, name="test")
msg = interaction.response.send_message.call_args
assert "admin only" in msg.args[0].lower()
@pytest.mark.asyncio
async def test_cron_enable_success(self, owned_bot):
mock_scheduler = MagicMock()
mock_scheduler.enable_job.return_value = True
owned_bot.scheduler = mock_scheduler
cmd = _find_subcommand(owned_bot.tree, "cron", "enable")
interaction = _mock_interaction(user_id="111")
await cmd.callback(interaction, name="my-job")
msg = interaction.response.send_message.call_args
assert "enabled" in msg.args[0].lower()
@pytest.mark.asyncio
async def test_cron_enable_not_found(self, owned_bot):
mock_scheduler = MagicMock()
mock_scheduler.enable_job.return_value = False
owned_bot.scheduler = mock_scheduler
cmd = _find_subcommand(owned_bot.tree, "cron", "enable")
interaction = _mock_interaction(user_id="111")
await cmd.callback(interaction, name="ghost")
msg = interaction.response.send_message.call_args
assert "not found" in msg.args[0].lower()
class TestCronDisable:
@pytest.mark.asyncio
async def test_cron_disable_admin_only(self, owned_bot):
cmd = _find_subcommand(owned_bot.tree, "cron", "disable")
assert cmd is not None
interaction = _mock_interaction(user_id="999")
await cmd.callback(interaction, name="test")
msg = interaction.response.send_message.call_args
assert "admin only" in msg.args[0].lower()
@pytest.mark.asyncio
async def test_cron_disable_success(self, owned_bot):
mock_scheduler = MagicMock()
mock_scheduler.disable_job.return_value = True
owned_bot.scheduler = mock_scheduler
cmd = _find_subcommand(owned_bot.tree, "cron", "disable")
interaction = _mock_interaction(user_id="111")
await cmd.callback(interaction, name="my-job")
msg = interaction.response.send_message.call_args
assert "disabled" in msg.args[0].lower()
@pytest.mark.asyncio
async def test_cron_disable_not_found(self, owned_bot):
mock_scheduler = MagicMock()
mock_scheduler.disable_job.return_value = False
owned_bot.scheduler = mock_scheduler
cmd = _find_subcommand(owned_bot.tree, "cron", "disable")
interaction = _mock_interaction(user_id="111")
await cmd.callback(interaction, name="ghost")
msg = interaction.response.send_message.call_args
assert "not found" in msg.args[0].lower()