complete restructure
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user