"""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()