323 lines
11 KiB
Python
323 lines
11 KiB
Python
"""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")
|