Files
vibe-bot/vibe_bot/tests/test_commands.py
T
2026-08-19 13:16:43 -04:00

644 lines
19 KiB
Python

"""Wiring tests for the commands package: every handler reaches its service."""
from __future__ import annotations
import asyncio
from datetime import UTC, datetime
from unittest.mock import MagicMock
import pytest
from discord.ext import commands
from vibe_bot.commands.custom_bots import MAX_PERSONALITY_LENGTH
from vibe_bot.tests._helpers import invoke, sent_texts
from vibe_bot.tests.conftest import AppMocks
# ---------------------------------------------------------------------------
# custom_bots: create / list / delete
# ---------------------------------------------------------------------------
def test_custom_bot_success_updates_cache(
app_mocks: AppMocks,
bot: commands.Bot,
mock_ctx: MagicMock,
) -> None:
"""A successful create invalidates the cache with the new bot."""
app_mocks.manager.create_custom_bot.return_value = "created"
app_mocks.manager.list_custom_bots.return_value = [
("newbot", "a personality", "user123"),
]
invoke(
bot, "custom-bot", mock_ctx, "newbot", personality="you are a british butler"
)
app_mocks.manager.create_custom_bot.assert_called_once_with(
bot_name="newbot",
system_prompt="you are a british butler",
created_by="12345",
)
assert app_mocks.app.bot_cache == {"newbot": ("a personality", "user123")}
assert "alfred" not in app_mocks.app.bot_cache
texts = sent_texts(mock_ctx)
assert any("has been created" in t for t in texts)
assert any("You can now use this bot" in t for t in texts)
def test_custom_bot_replaced_message(
app_mocks: AppMocks,
bot: commands.Bot,
mock_ctx: MagicMock,
) -> None:
"""When the name already exists, the command reports a replace, not a create."""
app_mocks.manager.create_custom_bot.return_value = "replaced"
invoke(
bot, "custom-bot", mock_ctx, "alfred", personality="you are a british butler"
)
texts = sent_texts(mock_ctx)
assert any("already existed" in t and "replaced" in t for t in texts)
def test_custom_bot_invalid_name_too_short(
app_mocks: AppMocks,
bot: commands.Bot,
mock_ctx: MagicMock,
) -> None:
"""A one-character name is rejected before any manager call."""
invoke(bot, "custom-bot", mock_ctx, "a", personality="this is a valid personality")
texts = sent_texts(mock_ctx)
assert any("Invalid bot name" in t for t in texts)
app_mocks.manager.create_custom_bot.assert_not_called()
def test_custom_bot_invalid_name_empty(
app_mocks: AppMocks,
bot: commands.Bot,
mock_ctx: MagicMock,
) -> None:
"""An empty name is rejected before any manager call."""
invoke(bot, "custom-bot", mock_ctx, "", personality="this is a valid personality")
texts = sent_texts(mock_ctx)
assert any("Invalid bot name" in t for t in texts)
app_mocks.manager.create_custom_bot.assert_not_called()
def test_custom_bot_invalid_personality(
app_mocks: AppMocks,
bot: commands.Bot,
mock_ctx: MagicMock,
) -> None:
"""A personality under 10 characters is rejected."""
invoke(bot, "custom-bot", mock_ctx, "testbot", personality="short")
texts = sent_texts(mock_ctx)
assert any("Invalid personality" in t for t in texts)
app_mocks.manager.create_custom_bot.assert_not_called()
def test_custom_bot_personality_too_long(
app_mocks: AppMocks,
bot: commands.Bot,
mock_ctx: MagicMock,
) -> None:
"""A personality over MAX_PERSONALITY_LENGTH is rejected before any DB call."""
invoke(
bot,
"custom-bot",
mock_ctx,
"testbot",
personality="a" * (MAX_PERSONALITY_LENGTH + 1),
)
texts = sent_texts(mock_ctx)
assert any("Personality too long" in t for t in texts)
app_mocks.manager.create_custom_bot.assert_not_called()
def test_custom_bot_create_fails(
app_mocks: AppMocks,
bot: commands.Bot,
mock_ctx: MagicMock,
) -> None:
"""A failed create reports an error and does not invalidate the cache."""
app_mocks.manager.create_custom_bot.return_value = False
invoke(
bot, "custom-bot", mock_ctx, "alfred", personality="you are a british butler"
)
texts = sent_texts(mock_ctx)
assert any("Failed to create custom bot" in t for t in texts)
app_mocks.manager.list_custom_bots.assert_not_called()
assert app_mocks.app.bot_cache == {"alfred": ("british butler", "user123")}
def test_list_custom_bots_empty(
app_mocks: AppMocks,
bot: commands.Bot,
mock_ctx: MagicMock,
) -> None:
"""Listing with no bots suggests creating one."""
app_mocks.manager.list_custom_bots.return_value = []
invoke(bot, "list-custom-bots", mock_ctx)
texts = sent_texts(mock_ctx)
assert any("No custom bots" in t for t in texts)
def test_list_custom_bots_with_bots(
app_mocks: AppMocks,
bot: commands.Bot,
mock_ctx: MagicMock,
) -> None:
"""Listing shows every bot name."""
app_mocks.manager.list_custom_bots.return_value = [
("alfred", "british butler", "user-1"),
("jarvis", "ai assistant", "user-2"),
]
invoke(bot, "list-custom-bots", mock_ctx)
texts = sent_texts(mock_ctx)
assert any("Available Custom Bots" in t for t in texts)
assert any("* alfred" in t and "* jarvis" in t for t in texts)
def test_delete_custom_bot_success(
app_mocks: AppMocks,
bot: commands.Bot,
mock_ctx: MagicMock,
) -> None:
"""The creator can delete their bot; the cache is invalidated."""
app_mocks.manager.get_custom_bot.return_value = (
"alfred",
"prompt",
"12345",
"2024-01-01",
)
app_mocks.manager.delete_custom_bot.return_value = True
app_mocks.manager.list_custom_bots.return_value = []
invoke(bot, "delete-custom-bot", mock_ctx, "alfred")
app_mocks.manager.delete_custom_bot.assert_called_once_with("alfred")
texts = sent_texts(mock_ctx)
assert any("has been deleted" in t for t in texts)
assert app_mocks.app.bot_cache == {}
def test_delete_custom_bot_not_found(
app_mocks: AppMocks,
bot: commands.Bot,
mock_ctx: MagicMock,
) -> None:
"""Deleting a non-existent bot reports not found."""
app_mocks.manager.get_custom_bot.return_value = None
invoke(bot, "delete-custom-bot", mock_ctx, "nonexistent")
texts = sent_texts(mock_ctx)
assert any("not found" in t for t in texts)
app_mocks.manager.delete_custom_bot.assert_not_called()
def test_delete_custom_bot_not_owner(
app_mocks: AppMocks,
bot: commands.Bot,
mock_ctx: MagicMock,
) -> None:
"""A non-owner cannot delete the bot."""
app_mocks.manager.get_custom_bot.return_value = (
"alfred",
"prompt",
"other-user-id",
"2024-01-01",
)
invoke(bot, "delete-custom-bot", mock_ctx, "alfred")
texts = sent_texts(mock_ctx)
assert any("You can only delete your own" in t for t in texts)
app_mocks.manager.delete_custom_bot.assert_not_called()
def test_delete_custom_bot_delete_fails(
app_mocks: AppMocks,
bot: commands.Bot,
mock_ctx: MagicMock,
) -> None:
"""A failed delete reports an error and keeps the cache."""
app_mocks.manager.get_custom_bot.return_value = (
"alfred",
"prompt",
"12345",
"2024-01-01",
)
app_mocks.manager.delete_custom_bot.return_value = False
invoke(bot, "delete-custom-bot", mock_ctx, "alfred")
texts = sent_texts(mock_ctx)
assert any("Failed to delete" in t for t in texts)
app_mocks.manager.list_custom_bots.assert_not_called()
assert app_mocks.app.bot_cache == {"alfred": ("british butler", "user123")}
# ---------------------------------------------------------------------------
# speech: speak / voices
# ---------------------------------------------------------------------------
def test_speak_delegates_to_service(
app_mocks: AppMocks,
bot: commands.Bot,
mock_ctx: MagicMock,
) -> None:
"""!speak forwards to SpeechService.speak."""
invoke(bot, "speak", mock_ctx, message="hello world")
app_mocks.speech.speak.assert_awaited_once_with(mock_ctx, message="hello world")
def test_speak_cooldown_blocks_third_invocation(
app_mocks: AppMocks,
bot: commands.Bot,
mock_ctx: MagicMock,
) -> None:
"""speak allows 3 per 30s; the 4th immediate call is rate-limited."""
cmd = bot.get_command("speak")
assert cmd is not None
assert cmd.cooldown is not None
mock_ctx.message.edited_at = None
mock_ctx.message.created_at = datetime(2026, 1, 1, tzinfo=UTC)
for _ in range(3):
cmd._prepare_cooldowns(mock_ctx)
with pytest.raises(commands.CommandOnCooldown) as exc_info:
cmd._prepare_cooldowns(mock_ctx)
asyncio.run(bot.on_command_error(mock_ctx, exc_info.value))
app_mocks.speech.speak.assert_not_awaited()
texts = sent_texts(mock_ctx)
assert any("too quickly" in t for t in texts)
def test_voices(
bot: commands.Bot,
mock_ctx: MagicMock,
) -> None:
"""!voices lists the voice catalog."""
invoke(bot, "voices", mock_ctx)
full = "\n".join(sent_texts(mock_ctx))
assert "Available Voices" in full
assert "af_sarah" in full
assert "Use `!speak" in full
# ---------------------------------------------------------------------------
# images: doodlebob / retcon
# ---------------------------------------------------------------------------
def test_doodlebob_delegates_to_service(
app_mocks: AppMocks,
bot: commands.Bot,
mock_ctx: MagicMock,
) -> None:
"""!doodlebob forwards to ImageService.generate."""
invoke(bot, "doodlebob", mock_ctx, message="a centaur")
app_mocks.image.generate.assert_awaited_once_with(mock_ctx, message="a centaur")
def test_retcon_delegates_to_service(
app_mocks: AppMocks,
bot: commands.Bot,
mock_ctx: MagicMock,
) -> None:
"""!retcon forwards to ImageService.edit."""
invoke(bot, "retcon", mock_ctx, message="make it blue")
app_mocks.image.edit.assert_awaited_once_with(mock_ctx, message="make it blue")
def test_doodlebob_cooldown_blocks_second_invocation(
app_mocks: AppMocks,
bot: commands.Bot,
mock_ctx: MagicMock,
) -> None:
"""A second immediate doodlebob invocation is rate-limited before the LLM."""
cmd = bot.get_command("doodlebob")
assert cmd is not None
assert cmd.cooldown is not None
mock_ctx.message.edited_at = None
mock_ctx.message.created_at = datetime(2026, 1, 1, tzinfo=UTC)
# First invocation consumes the single token (no error).
cmd._prepare_cooldowns(mock_ctx)
# Second immediate invocation is rejected before the service is called.
with pytest.raises(commands.CommandOnCooldown) as exc_info:
cmd._prepare_cooldowns(mock_ctx)
# The error handler turns it into a friendly message.
asyncio.run(bot.on_command_error(mock_ctx, exc_info.value))
app_mocks.image.generate.assert_not_awaited()
texts = sent_texts(mock_ctx)
assert any("too quickly" in t for t in texts)
# ---------------------------------------------------------------------------
# conversation: talkforme
# ---------------------------------------------------------------------------
def test_talkforme_delegates_to_service(
app_mocks: AppMocks,
bot: commands.Bot,
mock_ctx: MagicMock,
) -> None:
"""!talkforme parses its args, then forwards to ConversationService.run."""
invoke(bot, "talkforme", mock_ctx, message="a b 3 talking cats")
app_mocks.conversation.run.assert_awaited_once_with(
mock_ctx, "a", "b", "3", "talking cats"
)
def test_talkforme_invalid_args(
app_mocks: AppMocks,
bot: commands.Bot,
mock_ctx: MagicMock,
) -> None:
"""!talkforme with too few parts shows usage and calls no service."""
invoke(bot, "talkforme", mock_ctx, message="bot1 bot2")
texts = sent_texts(mock_ctx)
assert any("Usage" in t for t in texts)
app_mocks.conversation.run.assert_not_awaited()
def test_talkforme_cooldown_blocks_second_invocation(
app_mocks: AppMocks,
bot: commands.Bot,
mock_ctx: MagicMock,
) -> None:
"""A second immediate talkforme invocation is rate-limited before the LLM."""
cmd = bot.get_command("talkforme")
assert cmd is not None
assert cmd.cooldown is not None
mock_ctx.message.edited_at = None
mock_ctx.message.created_at = datetime(2026, 1, 1, tzinfo=UTC)
cmd._prepare_cooldowns(mock_ctx)
with pytest.raises(commands.CommandOnCooldown) as exc_info:
cmd._prepare_cooldowns(mock_ctx)
asyncio.run(bot.on_command_error(mock_ctx, exc_info.value))
app_mocks.conversation.run.assert_not_awaited()
texts = sent_texts(mock_ctx)
assert any("too quickly" in t for t in texts)
# ---------------------------------------------------------------------------
# admin: lobotomize / debug / history
# ---------------------------------------------------------------------------
def test_lobotomize(
app_mocks: AppMocks,
bot: commands.Bot,
mock_ctx: MagicMock,
) -> None:
"""!lobotomize clears all messages on the shared db."""
invoke(bot, "lobotomize", mock_ctx)
app_mocks.db.clear_all_messages.assert_called_once_with()
texts = sent_texts(mock_ctx)
assert any("cleared" in t for t in texts)
def test_history_bot_not_found(
app_mocks: AppMocks,
bot: commands.Bot,
mock_ctx: MagicMock,
) -> None:
"""!history on an unknown bot reports not found."""
app_mocks.manager.get_custom_bot.return_value = None
invoke(bot, "history", mock_ctx, "nonexistent")
texts = sent_texts(mock_ctx)
assert any("not found" in t for t in texts)
app_mocks.db.get_bot_history.assert_not_called()
def test_history_no_history(
app_mocks: AppMocks,
bot: commands.Bot,
mock_ctx: MagicMock,
) -> None:
"""!history on a bot with no messages says so."""
app_mocks.manager.get_custom_bot.return_value = (
"alfred",
"british butler",
"user-123",
"2024-01-01",
)
app_mocks.db.get_bot_history.return_value = []
invoke(bot, "history", mock_ctx, "alfred")
texts = sent_texts(mock_ctx)
assert any("No chat history" in t and "**alfred**" in t for t in texts)
def test_history_with_data(
app_mocks: AppMocks,
bot: commands.Bot,
mock_ctx: MagicMock,
) -> None:
"""!history formats the stored exchange, newest last."""
app_mocks.manager.get_custom_bot.return_value = (
"alfred",
"british butler",
"user-123",
"2024-01-01",
)
app_mocks.db.get_bot_history.return_value = [
("hello", "yes master?"),
("what time is it", "it is currently 3pm"),
]
invoke(bot, "history", mock_ctx, "alfred")
full = "\n".join(sent_texts(mock_ctx))
assert "Chat History for **alfred**" in full
assert "what time is it" in full
assert "alfred: it is currently 3pm" in full
def test_history_long_response_chunked(
app_mocks: AppMocks,
bot: commands.Bot,
mock_ctx: MagicMock,
) -> None:
"""Long history payloads are split into multiple sends."""
app_mocks.manager.get_custom_bot.return_value = (
"alfred",
"british butler",
"user-123",
"2024-01-01",
)
app_mocks.db.get_bot_history.return_value = [
("x" * 2000, "y" * 2000),
]
invoke(bot, "history", mock_ctx, "alfred")
assert mock_ctx.send.call_count >= 2
def test_debug_no_subcommand(
bot: commands.Bot,
mock_ctx: MagicMock,
) -> None:
"""!debug without a subcommand shows the menu."""
invoke(bot, "debug", mock_ctx, subcommand=None)
mock_ctx.send.assert_called_once()
call_args = mock_ctx.send.call_args[0][0]
assert "Debug Menu" in call_args
assert "members" in call_args
def test_debug_members_no_guild(
bot: commands.Bot,
mock_ctx: MagicMock,
) -> None:
"""!debug members on a channel without guild members says so."""
mock_ctx.channel.guild = None
invoke(bot, "debug", mock_ctx, subcommand="members")
mock_ctx.send.assert_called_once()
call_args = mock_ctx.send.call_args[0][0]
assert "No members found in this channel." in call_args
def test_debug_members_with_members(
bot: commands.Bot,
mock_ctx: MagicMock,
) -> None:
"""!debug members lists the guild members."""
mock_member = MagicMock()
mock_member.display_name = "Alice"
mock_member.name = "alice"
mock_member.nick = None
mock_member.global_name = None
mock_member.status = MagicMock(value="online")
mock_ctx.channel.guild.members = [mock_member]
invoke(bot, "debug", mock_ctx, subcommand="members")
assert mock_ctx.send.called
call_args = mock_ctx.send.call_args[0][0]
assert "Alice" in call_args
assert "1 total" in call_args
def test_debug_unknown_subcommand(
bot: commands.Bot,
mock_ctx: MagicMock,
) -> None:
"""!debug with an unknown subcommand explains the usage."""
invoke(bot, "debug", mock_ctx, subcommand="unknown")
mock_ctx.send.assert_called_once()
call_args = mock_ctx.send.call_args[0][0]
assert "Unknown debug sub-command" in call_args
def test_debug_members_many_chunks(
bot: commands.Bot,
mock_ctx: MagicMock,
) -> None:
"""!debug members with many members exceeds the chunk limit."""
mock_members = []
for i in range(50):
mock_member = MagicMock()
mock_member.display_name = f"User{i}_with_a_very_long_display_name"
mock_member.name = f"user{i}"
mock_member.nick = None
mock_member.global_name = None
mock_member.status = MagicMock(value="online")
mock_members.append(mock_member)
mock_ctx.channel.guild.members = mock_members
invoke(bot, "debug", mock_ctx, subcommand="members")
assert mock_ctx.send.call_count >= 2
first_chunk = mock_ctx.send.call_args_list[0][0][0]
assert "Members in this channel (50 total)" in first_chunk
def test_debug_whoami(
bot: commands.Bot,
mock_ctx: MagicMock,
) -> None:
"""!debug whoami shows user info."""
invoke(bot, "debug", mock_ctx, subcommand="whoami")
mock_ctx.send.assert_called_once()
call_args = mock_ctx.send.call_args[0][0]
assert "Username: testuser" in call_args
assert "User ID: 12345" in call_args
assert "Global Name: Test User" in call_args
assert "Nickname: tester" in call_args
def test_debug_whoami_minimal(
bot: commands.Bot,
mock_ctx: MagicMock,
) -> None:
"""!debug whoami omits fields the user does not have."""
mock_ctx.author.global_name = None
mock_ctx.author.nick = None
mock_ctx.author.top_role.name = "@everyone"
mock_ctx.author.activities = []
mock_ctx.author.joined_at = None
mock_ctx.author.created_at = None
invoke(bot, "debug", mock_ctx, subcommand="whoami")
mock_ctx.send.assert_called_once()
call_args = mock_ctx.send.call_args[0][0]
assert "Username: testuser" in call_args
assert "User ID: 12345" in call_args
assert "Global Name" not in call_args
assert "Nickname" not in call_args
assert "Activities" not in call_args
assert "Joined" not in call_args
def test_debug_tools(
bot: commands.Bot,
mock_ctx: MagicMock,
) -> None:
"""!debug tools shows the LLM tool schema."""
invoke(bot, "debug", mock_ctx, subcommand="tools")
mock_ctx.send.assert_called_once()
call_args = mock_ctx.send.call_args[0][0]
assert "LLM Tools" in call_args
assert "get_channel_members" in call_args
assert "members" in call_args.lower()