"""Tests for the app composition root (singletons, services, bot handlers).""" from __future__ import annotations import asyncio import logging from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest from discord.ext import commands from vibe_bot.app import ( configure_logging, create_app, invalidate_bot_cache, ) from vibe_bot.commands import speech as speech_commands from vibe_bot.config import TTS_MODEL_PATH, TTS_VOICES_PATH from vibe_bot.services.chat_service import ChatService from vibe_bot.services.conversation_service import ConversationService from vibe_bot.services.image_service import ImageService from vibe_bot.services.speech_service import SpeechService from vibe_bot.tests._helpers import sent_texts from vibe_bot.tests.conftest import AppMocks def test_create_app_wires_singletons( chat_db: Any, custom_bot_manager: Any, ) -> None: """create_app shares one db/manager with the services and seeds the cache.""" engine = MagicMock() with ( patch("vibe_bot.app.ChatDatabase", return_value=chat_db), patch("vibe_bot.app.CustomBotManager", return_value=custom_bot_manager), patch("vibe_bot.app.TTSEngine", return_value=engine) as mock_tts, patch( "vibe_bot.llm_client.get_tool_registry", return_value=MagicMock(), ) as mock_registry, ): app = create_app() mock_tts.assert_called_once_with(TTS_MODEL_PATH, TTS_VOICES_PATH) assert app.db is chat_db assert app.manager is custom_bot_manager assert app.tts is engine assert app.registry is mock_registry.return_value assert isinstance(app.chat, ChatService) assert isinstance(app.image, ImageService) assert isinstance(app.speech, SpeechService) assert isinstance(app.conversation, ConversationService) assert app.chat._db is chat_db assert app.chat._registry is mock_registry.return_value assert app.image._db is chat_db assert app.speech._db is chat_db assert app.speech._manager is custom_bot_manager assert app.speech._tts is engine assert app.conversation._manager is custom_bot_manager assert app.bot_cache == {} def test_create_app_tts_failure_tolerant( chat_db: Any, custom_bot_manager: Any, ) -> None: """A failing TTS engine degrades to None instead of crashing startup.""" with ( patch("vibe_bot.app.ChatDatabase", return_value=chat_db), patch("vibe_bot.app.CustomBotManager", return_value=custom_bot_manager), patch("vibe_bot.app.TTSEngine", side_effect=OSError("no model file")), ): app = create_app() assert app.tts is None def test_invalidate_bot_cache_rebuilds(app_mocks: AppMocks) -> None: """invalidate_bot_cache rebuilds the cache from the manager.""" app_mocks.manager.list_custom_bots.return_value = [ ("newbot", "a personality", "user999"), ("alfred", "british butler", "user123"), ] invalidate_bot_cache(app_mocks.app) assert app_mocks.app.bot_cache == { "newbot": ("a personality", "user999"), "alfred": ("british butler", "user123"), } def test_configure_logging_configures_root() -> None: """configure_logging is the sole basicConfig: it adds a root handler.""" root = logging.getLogger() original = root.handlers root.handlers.clear() try: configure_logging() assert len(root.handlers) == 1 handler = root.handlers[0] assert handler.formatter is not None assert handler.formatter._fmt == ( "%(asctime)s - %(name)s - %(levelname)s - %(message)s" ) finally: root.handlers = original # --------------------------------------------------------------------------- # build_bot # --------------------------------------------------------------------------- def test_build_bot_registers_commands(bot: commands.Bot) -> None: """build_bot returns a commands.Bot with every command registered.""" assert isinstance(bot, commands.Bot) for name in ( "custom-bot", "list-custom-bots", "delete-custom-bot", "lobotomize", "debug", "voices", "speak", "doodlebob", "retcon", "history", "talkforme", ): assert bot.get_command(name) is not None for event in ("on_ready", "on_message", "on_command_error"): assert event in bot.__dict__ def test_build_bot_intents(bot: commands.Bot) -> None: """message_content, members, and presences intents are enabled.""" assert bot.intents.message_content is True assert bot.intents.members is True assert bot.intents.presences is True def test_handlers_refuse_to_run_before_build_bot() -> None: """Event and command handlers raise if invoked before the wiring exists.""" import vibe_bot.app as app_module from vibe_bot.commands import _state as commands_state saved_app, saved_bot = app_module._app, app_module._bot saved_commands_app = commands_state._app app_module._app = None app_module._bot = None commands_state._app = None try: with pytest.raises(RuntimeError, match="App is not initialized"): asyncio.run(app_module.on_message(MagicMock())) with pytest.raises(RuntimeError, match="Bot is not initialized"): asyncio.run(app_module.on_ready()) with pytest.raises(RuntimeError, match="App is not initialized"): asyncio.run(speech_commands.speak(MagicMock(), message="hello")) finally: app_module._app = saved_app app_module._bot = saved_bot commands_state._app = saved_commands_app # --------------------------------------------------------------------------- # on_message guards # --------------------------------------------------------------------------- def test_on_message_ignores_non_bang_messages( app_mocks: AppMocks, bot: commands.Bot, ) -> None: """A non-! message returns before any service or DB touch.""" message = MagicMock() message.author = MagicMock() message.content = "hello there" with patch.object(bot, "process_commands", new=AsyncMock()) as mock_process: asyncio.run(bot.on_message(message)) mock_process.assert_not_called() app_mocks.chat.handle.assert_not_awaited() app_mocks.speech.speak.assert_not_awaited() app_mocks.image.generate.assert_not_awaited() app_mocks.image.edit.assert_not_awaited() app_mocks.conversation.run.assert_not_awaited() assert app_mocks.manager.mock_calls == [] assert app_mocks.db.mock_calls == [] def test_on_message_skips_bot_authors(app_mocks: AppMocks, bot: commands.Bot) -> None: """A bot-authored message returns without calling any service or DB.""" message = MagicMock() message.author = bot.user message.content = "!alfred hi" with patch.object(bot, "process_commands", new=AsyncMock()) as mock_process: asyncio.run(bot.on_message(message)) mock_process.assert_not_called() app_mocks.chat.handle.assert_not_awaited() assert app_mocks.manager.mock_calls == [] assert app_mocks.db.mock_calls == [] def test_on_message_routes_custom_bot( app_mocks: AppMocks, bot: commands.Bot, mock_ctx: MagicMock, ) -> None: """!alfred hi dispatches to the chat service with the parsed args.""" message = MagicMock() message.author = MagicMock() message.author.name = "testuser" message.content = "!alfred hi" with ( patch.object(bot, "get_context", new=AsyncMock(return_value=mock_ctx)), patch.object(bot, "process_commands", new=AsyncMock()) as mock_process, ): asyncio.run(bot.on_message(message)) app_mocks.chat.handle.assert_awaited_once_with( ctx=mock_ctx, bot_name="alfred", message="hi", system_prompt="british butler", response_prefix="alfred response", ) mock_process.assert_not_called() def test_on_message_falls_through_to_commands( app_mocks: AppMocks, bot: commands.Bot, ) -> None: """An unmatched ! message falls through to process_commands.""" message = MagicMock() message.author = MagicMock() message.author.name = "testuser" message.content = "!unknown hi" with patch.object(bot, "process_commands", new=AsyncMock()) as mock_process: asyncio.run(bot.on_message(message)) mock_process.assert_awaited_once_with(message) app_mocks.chat.handle.assert_not_awaited() def test_on_message_uses_cache_not_manager( app_mocks: AppMocks, bot: commands.Bot, ) -> None: """Bot-name matching reads bot_cache, never list_custom_bots().""" message = MagicMock() message.author = MagicMock() message.author.name = "testuser" message.content = "!alfred hi" with patch.object(bot, "get_context", new=AsyncMock(return_value=MagicMock())): asyncio.run(bot.on_message(message)) app_mocks.manager.list_custom_bots.assert_not_called() # --------------------------------------------------------------------------- # on_ready / on_command_error # --------------------------------------------------------------------------- def test_on_ready(bot: commands.Bot) -> None: """on_ready logs startup without raising.""" handler = bot.__dict__["on_ready"] asyncio.run(handler()) def test_on_command_error_cooldown( bot: commands.Bot, mock_ctx: MagicMock, ) -> None: """A cooldown error becomes a friendly message.""" from discord.ext.commands.cooldowns import Cooldown error = commands.CommandOnCooldown( Cooldown(1.0, 60.0), 0.5, commands.BucketType.user ) asyncio.run(bot.on_command_error(mock_ctx, error)) texts = sent_texts(mock_ctx) assert any("too quickly" in t for t in texts) def test_on_command_error_generic( bot: commands.Bot, mock_ctx: MagicMock, ) -> None: """Any other command error is logged and not re-raised.""" error = commands.CommandError("boom") asyncio.run(bot.on_command_error(mock_ctx, error)) # --------------------------------------------------------------------------- # main.py entrypoint # --------------------------------------------------------------------------- def test_main_builds_and_runs_bot() -> None: """main() validates config, configures logging, and runs the built bot.""" from vibe_bot import main as main_module with ( patch.object(main_module, "validate_config") as mock_validate, patch.object(main_module, "configure_logging") as mock_logging, patch.object( main_module, "create_app", return_value=MagicMock() ) as mock_create, patch.object(main_module, "build_bot", return_value=MagicMock()) as mock_build, patch.object(main_module, "DISCORD_TOKEN", "test-token"), ): main_module.main() mock_validate.assert_called_once_with() mock_logging.assert_called_once_with() mock_create.assert_called_once_with() mock_build.assert_called_once() mock_build.return_value.run.assert_called_once_with("test-token")