"""Service-layer tests: chat, image, speech, and conversation services. These exercise the LLM-backed logic directly (constructing each service with mock dependencies) rather than going through the thin Discord command wrappers. """ from __future__ import annotations import asyncio import base64 from io import BytesIO from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest import requests from vibe_bot.config import TTS_VOICE from vibe_bot.services.chat_service import ChatService from vibe_bot.services.conversation_service import ( MAX_TOPIC_LENGTH, ConversationService, flip_counter, ) from vibe_bot.services.image_service import ( MAX_IMAGE_DOWNLOAD_BYTES, MAX_IMAGE_PROMPT_LENGTH, ImageService, _allowed_image_url, _download_image_bytes, select_image_layout, verify_image_prompt, ) from vibe_bot.services.speech_service import ( MAX_SPEAK_LENGTH, SpeechService, parse_voice_flag, ) @pytest.fixture def mock_ctx() -> MagicMock: """A mock Discord command context.""" ctx = MagicMock() ctx.author.name = "testuser" ctx.author.id = "12345" ctx.author.global_name = "Test User" ctx.author.nick = "tester" ctx.author.top_role.name = "@everyone" ctx.author.activities = [] ctx.author.joined_at = None ctx.author.created_at = None ctx.channel.id = "channel-1" ctx.guild.id = "guild-1" ctx.message.id = "msg-1" ctx.message.attachments = [] ctx.bot.user = MagicMock() ctx.bot.user.name = "test-bot" ctx.bot.user.id = "bot-123" ctx.send = AsyncMock() return ctx def _file_factory() -> MagicMock: """A File factory that records (buffer, filename) as a tuple.""" factory = MagicMock() def make_file(buf: BytesIO, name: str) -> tuple[str, BytesIO, str]: return ("FILE", buf, name) factory.side_effect = make_file return factory def _sent_texts(ctx: MagicMock) -> list[str]: """All positional text messages sent through ctx.send.""" return [c.args[0] for c in ctx.send.call_args_list if c.args] def _registry() -> MagicMock: """A mock ToolRegistry.""" reg = MagicMock() reg.to_openai_tools.return_value = [] reg.execute.return_value = "tool result" return reg def _fake_bot(name: str) -> tuple[str, str, str, str]: """A stand-in custom bot tuple for manager.get_custom_bot.""" return (name, "a personality", "user-123", "2024-01-01") # --------------------------------------------------------------------------- # ChatService # --------------------------------------------------------------------------- def test_chat_success(mock_ctx: MagicMock) -> None: """A normal turn persists the exchange and sends the reply.""" db = MagicMock() db.get_conversation_context.return_value = [] svc = ChatService(db, _registry()) with patch( "vibe_bot.llm_client.chat_completion_with_tools", new=AsyncMock(return_value="This is a bot response"), ): asyncio.run( svc.handle( mock_ctx, bot_name="alfred", message="hello", system_prompt="you are a butler", response_prefix="alfred response", ) ) db.add_message.assert_called() assert mock_ctx.send.call_count >= 2 def test_chat_turn_embedding_budget( mock_ctx: MagicMock, temp_db_path: str, ) -> None: """One chat turn embeds exactly twice: the RAG query and the user row. The assistant row is persisted with embed=False, so it costs no embedding call and stores no embedding row. """ import sqlite3 from vibe_bot.database import ChatDatabase db = ChatDatabase(db_path=temp_db_path) svc = ChatService(db, _registry()) with ( patch( "vibe_bot.llm_client.embedding", return_value=[0.25] * 32, ) as mock_embedding, patch( "vibe_bot.llm_client.chat_completion_with_tools", new=AsyncMock(return_value="This is a bot response"), ), ): asyncio.run( svc.handle( mock_ctx, bot_name="alfred", message="hello", system_prompt="you are a butler", response_prefix="alfred response", ) ) assert mock_embedding.call_count == 2 conn = sqlite3.connect(temp_db_path) embedding_rows = conn.execute("SELECT COUNT(*) FROM message_embeddings").fetchone() conn.close() assert embedding_rows[0] == 1 def test_chat_error(mock_ctx: MagicMock) -> None: """An LLM error surfaces a friendly message.""" db = MagicMock() db.get_conversation_context.return_value = [] svc = ChatService(db, _registry()) with patch( "vibe_bot.llm_client.chat_completion_with_tools", new=AsyncMock(side_effect=Exception("API error")), ): asyncio.run( svc.handle( mock_ctx, bot_name="alfred", message="hello", system_prompt="you are a butler", response_prefix="alfred response", ) ) call_args = mock_ctx.send.call_args[0][0] assert "error occurred" in call_args.lower() db.add_message.assert_not_called() def test_chat_long_response_chunked(mock_ctx: MagicMock) -> None: """Long responses are split into multiple sends.""" db = MagicMock() db.get_conversation_context.return_value = [] svc = ChatService(db, _registry()) with patch( "vibe_bot.llm_client.chat_completion_with_tools", new=AsyncMock(return_value="x" * 2500), ): asyncio.run( svc.handle( mock_ctx, bot_name="alfred", message="hello", system_prompt="you are a butler", response_prefix="alfred response", ) ) assert mock_ctx.send.call_count >= 3 def test_chat_includes_user_info(mock_ctx: MagicMock) -> None: """The system prompt sent to the LLM includes the requester's info.""" db = MagicMock() db.get_conversation_context.return_value = [] svc = ChatService(db, _registry()) mock_llm = AsyncMock(return_value="resp") with patch("vibe_bot.llm_client.chat_completion_with_tools", new=mock_llm): asyncio.run( svc.handle( mock_ctx, bot_name="alfred", message="hello", system_prompt="you are a butler", response_prefix="alfred response", ) ) system_prompt = mock_llm.call_args.kwargs["system_prompt"] assert "testuser" in system_prompt def test_chat_with_context_and_tools(mock_ctx: MagicMock) -> None: """Prior RAG context is prepended and tool calls reach the registry.""" db = MagicMock() db.get_conversation_context.return_value = [ {"role": "user", "content": "old question"}, {"role": "assistant", "content": "old answer"}, ] registry = _registry() captured: dict[str, Any] = {} async def fake_llm(**kwargs: Any) -> str: captured.update(kwargs) kwargs["tool_executor"]("get_channel_members", {}) await kwargs["tool_call_notifier"]("get_channel_members", {}) return "resp" with patch("vibe_bot.llm_client.chat_completion_with_tools", new=fake_llm): asyncio.run( ChatService(db, registry).handle( mock_ctx, bot_name="alfred", message="hello", system_prompt="you are a butler", response_prefix="alfred response", ) ) prompts = captured["prompts"] assert prompts[0] == {"role": "user", "content": "old question"} assert prompts[-1] == {"role": "user", "content": "hello"} registry.execute.assert_called_once_with( "get_channel_members", {}, channel=mock_ctx.channel ) assert any("looking at the channel members" in t for t in _sent_texts(mock_ctx)) # --------------------------------------------------------------------------- # SpeechService # --------------------------------------------------------------------------- def _speech_service( tts: MagicMock | None, manager: MagicMock | None = None, make_file: MagicMock | None = None, ) -> SpeechService: return SpeechService( MagicMock(), manager or MagicMock(), tts, make_file or _file_factory() ) def test_speak_tts_not_initialized(mock_ctx: MagicMock) -> None: """No TTS engine means a clear error, no LLM or TTS calls.""" svc = _speech_service(None) asyncio.run(svc.speak(mock_ctx, message="hello world")) call_args = mock_ctx.send.call_args[0][0] assert "TTS engine not initialized" in call_args def test_speak_empty_message(mock_ctx: MagicMock) -> None: """Empty text is rejected before any TTS work.""" svc = _speech_service(MagicMock()) asyncio.run(svc.speak(mock_ctx, message="")) call_args = mock_ctx.send.call_args[0][0] assert "Please provide text" in call_args def test_speak_too_long(mock_ctx: MagicMock) -> None: """Oversized text is rejected without calling the TTS engine.""" tts = MagicMock() svc = _speech_service(tts) asyncio.run(svc.speak(mock_ctx, message="a" * (MAX_SPEAK_LENGTH + 1))) tts.generate_audio.assert_not_called() call_args = mock_ctx.send.call_args[0][0] assert "Text too long to speak" in call_args def test_speak_partial_audio_warns(mock_ctx: MagicMock) -> None: """Partial audio triggers a warning line.""" tts = MagicMock() tts.generate_audio.return_value = MagicMock( audio=MagicMock(), partial=True, failed_chunks=1 ) manager = MagicMock() manager.list_custom_bots.return_value = [] svc = _speech_service(tts, manager=manager) asyncio.run(svc.speak(mock_ctx, message="hello world")) assert any("audio may be incomplete" in t for t in _sent_texts(mock_ctx)) def test_speak_plain_text(mock_ctx: MagicMock) -> None: """Plain text is spoken and the audio file is sent.""" tts = MagicMock() tts.generate_audio.return_value = MagicMock(audio=MagicMock(), partial=False) manager = MagicMock() manager.list_custom_bots.return_value = [] svc = _speech_service(tts, manager=manager) asyncio.run(svc.speak(mock_ctx, message="hello world")) tts.generate_audio.assert_called_once() assert mock_ctx.send.call_count >= 2 def test_speak_with_custom_bot(mock_ctx: MagicMock) -> None: """A bot prefix routes through the LLM, then speaks the response.""" tts = MagicMock() tts.generate_audio.return_value = MagicMock(audio=MagicMock(), partial=False) manager = MagicMock() manager.list_custom_bots.return_value = [ ("alfred", "british butler", "user-123"), ] manager.get_custom_bot.return_value = ( "alfred", "british butler", "user-123", "2024-01-01", ) svc = _speech_service(tts, manager=manager) with patch( "vibe_bot.llm_client.chat_completion_with_tools", new=AsyncMock(return_value="The time is 3pm"), ): asyncio.run(svc.speak(mock_ctx, message="alfred what time is it")) tts.generate_audio.assert_called_once() assert any("**alfred**:" in t for t in _sent_texts(mock_ctx)) def test_speak_uses_requested_voice(mock_ctx: MagicMock) -> None: """A trailing --voice flag selects that voice for the TTS call.""" tts = MagicMock() tts.generate_audio.return_value = MagicMock(audio=MagicMock(), partial=False) manager = MagicMock() manager.list_custom_bots.return_value = [] svc = _speech_service(tts, manager=manager) asyncio.run(svc.speak(mock_ctx, message="hello world --voice af_bella")) assert tts.generate_audio.call_args.kwargs["voice"] == "af_bella" def test_speak_mid_text_voice_flag_spoken_verbatim(mock_ctx: MagicMock) -> None: """A --voice mid-message is preserved as speech and the default voice is used.""" tts = MagicMock() tts.generate_audio.return_value = MagicMock(audio=MagicMock(), partial=False) manager = MagicMock() manager.list_custom_bots.return_value = [] svc = _speech_service(tts, manager=manager) message = "hello --voice af_bella world" asyncio.run(svc.speak(mock_ctx, message=message)) assert tts.generate_audio.call_args.args[0] == message assert tts.generate_audio.call_args.kwargs["voice"] == TTS_VOICE def test_speak_unknown_voice(mock_ctx: MagicMock) -> None: """An unknown voice is rejected before any TTS call.""" tts = MagicMock() manager = MagicMock() manager.list_custom_bots.return_value = [] svc = _speech_service(tts, manager=manager) asyncio.run(svc.speak(mock_ctx, message="hello --voice not_a_real_voice")) tts.generate_audio.assert_not_called() call_args = mock_ctx.send.call_args[0][0] assert "Unknown voice" in call_args def test_speak_language_lookup_uses_precomputed_dict( mock_ctx: MagicMock, ) -> None: """The speak hot path resolves the language via VOICE_LANGUAGES.get(). The dict is built once at import from VOICES_LIST (covering every catalog voice) and the per-speak lookup is a single dict get — no per-call scan of the category list. """ from vibe_bot.config import VOICES_LIST from vibe_bot.services import speech_service class LanguageLookupSpy: """Counts .get() lookups on the voice->language mapping.""" def __init__(self, data: dict[str, str]) -> None: self.data = data self.lookups = 0 def get(self, key: str, default: str | None = None) -> str | None: self.lookups += 1 return self.data.get(key, default) def __contains__(self, key: object) -> bool: return key in self.data counting = LanguageLookupSpy(speech_service.VOICE_LANGUAGES) assert set(counting.data) == { voice for category in VOICES_LIST.values() for voice in category["voices"] } tts = MagicMock() tts.generate_audio.return_value = MagicMock(audio=MagicMock(), partial=False) manager = MagicMock() manager.list_custom_bots.return_value = [] svc = _speech_service(tts, manager=manager) with patch.object(speech_service, "VOICE_LANGUAGES", counting): asyncio.run(svc.speak(mock_ctx, message="hello --voice bf_alice")) assert counting.lookups == 1 assert tts.generate_audio.call_args.kwargs["lang"] == "en-gb" # --------------------------------------------------------------------------- # ConversationService # --------------------------------------------------------------------------- def _conversation_service( manager: MagicMock | None = None, ) -> ConversationService: return ConversationService(manager or MagicMock()) def test_flip_counter() -> None: """flip_counter toggles between 0 and 1.""" assert flip_counter(0) == 1 assert flip_counter(1) == 0 def test_talkforme_topic_too_long(mock_ctx: MagicMock) -> None: """Oversized topics are rejected before any LLM call.""" svc = _conversation_service() asyncio.run(svc.run(mock_ctx, "a", "b", "3", "x" * (MAX_TOPIC_LENGTH + 1))) call_args = mock_ctx.send.call_args[0][0] assert "Topic too long" in call_args def test_talkforme_bot1_not_found(mock_ctx: MagicMock) -> None: """A missing first bot is reported and the run stops.""" manager = MagicMock() manager.get_custom_bot.return_value = None svc = _conversation_service(manager=manager) asyncio.run(svc.run(mock_ctx, "ghost", "alfred", "3", "cats")) call_args = mock_ctx.send.call_args[0][0] assert "ghost is not a real bot" in call_args def test_talkforme_invalid_limit(mock_ctx: MagicMock) -> None: """A non-integer limit is rejected after both bots are found.""" manager = MagicMock() manager.get_custom_bot.side_effect = _fake_bot svc = _conversation_service(manager=manager) asyncio.run(svc.run(mock_ctx, "a", "b", "abc", "cats")) call_args = mock_ctx.send.call_args[0][0] assert "Message limit must be an integer" in call_args def test_talkforme_first_reply_chunked(mock_ctx: MagicMock) -> None: """Long first replies are sent in multiple chunks.""" manager = MagicMock() manager.get_custom_bot.side_effect = _fake_bot svc = _conversation_service(manager=manager) with patch( "vibe_bot.llm_client.chat_completion_with_history", new=AsyncMock(return_value="y" * 2500), ): asyncio.run(svc.run(mock_ctx, "a", "b", "1", "cats")) assert mock_ctx.send.call_count >= 3 # --------------------------------------------------------------------------- # ImageService (doodlebob / retcon) # --------------------------------------------------------------------------- def _image_service(db: MagicMock | None = None) -> ImageService: return ImageService(db or MagicMock(), _file_factory()) def test_doodlebob_prompt_too_long(mock_ctx: MagicMock) -> None: """Oversized prompts are rejected before any LLM call.""" svc = _image_service() asyncio.run(svc.generate(mock_ctx, message="a" * (MAX_IMAGE_PROMPT_LENGTH + 1))) call_args = mock_ctx.send.call_args[0][0] assert "Prompt too long" in call_args def test_doodlebob_generate_success(mock_ctx: MagicMock) -> None: """A full generate flow ends with an image file and a completion line.""" db = MagicMock() db.get_image_generation_time_estimate.return_value = None svc = _image_service(db) b64 = base64.b64encode(b"fake image").decode() with ( patch( "vibe_bot.llm_client.chat_completion_instruct", new=AsyncMock(side_effect=["square", "a detailed prompt", "PASS"]), ), patch( "vibe_bot.llm_client.image_generation", new=AsyncMock(return_value=b64), ), ): asyncio.run(svc.generate(mock_ctx, message="a centaur in a field")) assert db.record_image_generation_time.called assert any("Strike complete" in t for t in _sent_texts(mock_ctx)) def test_doodlebob_failed_generation(mock_ctx: MagicMock) -> None: """An empty image-generation response is reported as a failure.""" db = MagicMock() db.get_image_generation_time_estimate.return_value = None svc = _image_service(db) with ( patch( "vibe_bot.llm_client.chat_completion_instruct", new=AsyncMock(side_effect=["square", "a detailed prompt", "PASS"]), ), patch( "vibe_bot.llm_client.image_generation", new=AsyncMock(return_value=""), ), ): asyncio.run(svc.generate(mock_ctx, message="a centaur")) assert any("Failed to generate image" in t for t in _sent_texts(mock_ctx)) assert not db.record_image_generation_time.called def test_doodlebob_reports_estimate(mock_ctx: MagicMock) -> None: """A prior-history estimate produces a Drone ETA line.""" db = MagicMock() db.get_image_generation_time_estimate.return_value = 12.5 svc = _image_service(db) b64 = base64.b64encode(b"fake image").decode() with ( patch( "vibe_bot.llm_client.chat_completion_instruct", new=AsyncMock(side_effect=["square", "a detailed prompt", "PASS"]), ), patch( "vibe_bot.llm_client.image_generation", new=AsyncMock(return_value=b64), ), ): asyncio.run(svc.generate(mock_ctx, message="a centaur")) assert any("Drone ETA" in t for t in _sent_texts(mock_ctx)) def test_doodlebob_no_estimate_without_history(mock_ctx: MagicMock) -> None: """No estimate means no Drone ETA line.""" db = MagicMock() db.get_image_generation_time_estimate.return_value = None svc = _image_service(db) b64 = base64.b64encode(b"fake image").decode() with ( patch( "vibe_bot.llm_client.chat_completion_instruct", new=AsyncMock(side_effect=["square", "a detailed prompt", "PASS"]), ), patch( "vibe_bot.llm_client.image_generation", new=AsyncMock(return_value=b64), ), ): asyncio.run(svc.generate(mock_ctx, message="a centaur")) assert not any("Drone ETA" in t for t in _sent_texts(mock_ctx)) def test_doodlebob_empty_prompt_stops(mock_ctx: MagicMock) -> None: """An empty image-prompt response stops the flow without generating.""" db = MagicMock() svc = _image_service(db) with ( patch( "vibe_bot.llm_client.chat_completion_instruct", new=AsyncMock(return_value=""), ), patch("vibe_bot.llm_client.image_generation") as mock_gen, ): asyncio.run(svc.generate(mock_ctx, message="a centaur")) mock_gen.assert_not_called() assert not db.record_image_generation_time.called def test_doodlebob_decode_failure(mock_ctx: MagicMock) -> None: """Invalid base64 from the generation API is reported as a failure.""" db = MagicMock() db.get_image_generation_time_estimate.return_value = None svc = _image_service(db) with ( patch( "vibe_bot.llm_client.chat_completion_instruct", new=AsyncMock(side_effect=["square", "a detailed prompt", "PASS"]), ), patch( "vibe_bot.llm_client.image_generation", new=AsyncMock(return_value="abcde!!!"), ), ): asyncio.run(svc.generate(mock_ctx, message="a centaur")) assert any( "Failed to process the generated image" in t for t in _sent_texts(mock_ctx) ) @pytest.mark.parametrize( ("response", "expected"), [ ("portrait", "portrait"), ("landscape", "landscape"), ("square", "square"), ("PORTRAIT", "portrait"), ("I think landscape", "landscape"), ], ) def test_select_image_layout_returns_parsed( response: str, expected: str, ) -> None: """select_image_layout parses the LLM's layout choice.""" with patch( "vibe_bot.llm_client.chat_completion_instruct", new=AsyncMock(return_value=response), ): result = asyncio.run(select_image_layout("a tall tree")) assert result == expected def test_select_image_layout_uses_minimal_token_budget() -> None: """Layout selection is a one-word answer, so max_tokens is 2.""" mock_llm = AsyncMock(return_value="square") with patch("vibe_bot.llm_client.chat_completion_instruct", new=mock_llm): assert asyncio.run(select_image_layout("a tall tree")) == "square" assert mock_llm.call_args.kwargs["max_tokens"] == 2 def test_doodlebob_latency_within_budget(mock_ctx: MagicMock) -> None: """End-to-end doodlebob latency with 50ms simulated per LLM/image call. Hermetic latency figure: four mocked calls (layout, prompt, verify, generate) at 50ms each must dominate the wall time; the overhead on top of the simulated 200ms stays far below the budget. """ import time db = MagicMock() db.get_image_generation_time_estimate.return_value = None svc = _image_service(db) b64 = base64.b64encode(b"fake image").decode() responses = ["square", "a detailed prompt", "PASS"] async def slow_instruct(**_kwargs: Any) -> str: await asyncio.sleep(0.05) return responses.pop(0) async def slow_generate(**_kwargs: Any) -> str: await asyncio.sleep(0.05) return b64 with ( patch("vibe_bot.llm_client.chat_completion_instruct", new=slow_instruct), patch("vibe_bot.llm_client.image_generation", new=slow_generate), ): start = time.monotonic() asyncio.run(svc.generate(mock_ctx, message="a centaur in a field")) elapsed = time.monotonic() - start assert elapsed >= 0.2 assert elapsed < 5.0 assert any("Strike complete" in t for t in _sent_texts(mock_ctx)) def test_verify_image_prompt_pass_keeps_prompt() -> None: """A PASS verdict keeps the original prompt unchanged.""" with patch( "vibe_bot.llm_client.chat_completion_instruct", new=AsyncMock(return_value="PASS"), ): result = asyncio.run(verify_image_prompt("a centaur", "a detailed prompt")) assert result == "a detailed prompt" def test_verify_image_prompt_correction_replaces() -> None: """A non-passing verdict is used as the corrected prompt.""" correction = "a rewritten prompt that is definitely long enough to be a fix" with patch( "vibe_bot.llm_client.chat_completion_instruct", new=AsyncMock(return_value=correction), ): result = asyncio.run(verify_image_prompt("a centaur", "a detailed prompt")) assert result == correction def test_verify_image_prompt_empty_falls_back() -> None: """An empty verdict falls back to the original prompt.""" with patch( "vibe_bot.llm_client.chat_completion_instruct", new=AsyncMock(return_value=""), ): result = asyncio.run(verify_image_prompt("a centaur", "a detailed prompt")) assert result == "a detailed prompt" def test_retcon_no_attachments(mock_ctx: MagicMock) -> None: """retcon with no attachments asks the user to attach an image.""" svc = _image_service() mock_ctx.message.attachments = [] asyncio.run(svc.edit(mock_ctx, message="make it blue")) call_args = mock_ctx.send.call_args[0][0] assert "Please attach an image" in call_args def test_retcon_rejected_url_not_downloaded(mock_ctx: MagicMock) -> None: """A non-Discord attachment URL is refused before any download happens.""" svc = _image_service() attachment = MagicMock() attachment.url = "https://evil.example.com/img.png" mock_ctx.message.attachments = [attachment] mock_edit = AsyncMock(return_value="") with ( patch("vibe_bot.llm_client.image_edit", new=mock_edit), patch("requests.get") as mock_get, ): asyncio.run(svc.edit(mock_ctx, message="make it blue")) mock_get.assert_not_called() mock_edit.assert_not_called() assert any("Please attach an image" in t for t in _sent_texts(mock_ctx)) def test_retcon_prompt_too_long(mock_ctx: MagicMock) -> None: """Oversized retcon prompts are rejected before any download.""" svc = _image_service() asyncio.run(svc.edit(mock_ctx, message="a" * (MAX_IMAGE_PROMPT_LENGTH + 1))) call_args = mock_ctx.send.call_args[0][0] assert "Prompt too long" in call_args def test_retcon_image_edit_empty(mock_ctx: MagicMock) -> None: """An empty edit response is reported as a failure.""" svc = _image_service() attachment = MagicMock() attachment.url = "https://cdn.discordapp.com/attachments/1/2/3/img.png" mock_ctx.message.attachments = [attachment] with ( patch( "vibe_bot.services.image_service._download_image_bytes", return_value=b"fake image bytes", ), patch( "vibe_bot.llm_client.image_edit", new=AsyncMock(return_value=""), ), ): asyncio.run(svc.edit(mock_ctx, message="make it blue")) call_args = mock_ctx.send.call_args[0][0] assert "Failed to edit the image" in call_args def test_retcon_success(mock_ctx: MagicMock) -> None: """A successful edit sends the edited image file.""" svc = _image_service() attachment = MagicMock() attachment.url = "https://cdn.discordapp.com/attachments/1/2/3/img.png" mock_ctx.message.attachments = [attachment] b64 = base64.b64encode(b"edited").decode() with ( patch( "vibe_bot.services.image_service._download_image_bytes", return_value=b"fake image bytes", ), patch( "vibe_bot.llm_client.image_edit", new=AsyncMock(return_value=b64), ), ): asyncio.run(svc.edit(mock_ctx, message="make it blue")) assert any("Rewriting history" in t for t in _sent_texts(mock_ctx)) def test_retcon_edit_decode_failure(mock_ctx: MagicMock) -> None: """Invalid base64 from the edit API is reported as a processing failure.""" svc = _image_service() attachment = MagicMock() attachment.url = "https://cdn.discordapp.com/attachments/1/2/3/img.png" mock_ctx.message.attachments = [attachment] with ( patch( "vibe_bot.services.image_service._download_image_bytes", return_value=b"fake image bytes", ), patch( "vibe_bot.llm_client.image_edit", new=AsyncMock(return_value="abcde!!!"), ), ): asyncio.run(svc.edit(mock_ctx, message="make it blue")) call_args = mock_ctx.send.call_args[0][0] assert "Failed to process the edited image" in call_args def test_allowed_image_url() -> None: """Only Discord CDN hosts are allowed for retcon downloads.""" assert _allowed_image_url("https://cdn.discordapp.com/a/b/c.png") assert _allowed_image_url("https://media.discordapp.net/a/b/c.png") assert not _allowed_image_url("https://example.com/a/b/c.png") assert not _allowed_image_url("https://evilcdn.com/a/b/c.png") assert not _allowed_image_url("http://[::1") def test_download_image_bytes_success() -> None: """An allowed Discord URL is downloaded and its chunks joined.""" response = MagicMock() response.headers = {} response.iter_content.return_value = [b"abc", b"", b"def"] response.raise_for_status.return_value = None with patch( "vibe_bot.services.image_service.requests.get", return_value=response, ) as mock_get: data = _download_image_bytes("https://cdn.discordapp.com/a/b/c.png") mock_get.assert_called_once() assert data == b"abcdef" def test_download_image_bytes_request_failure() -> None: """A failing download returns None instead of raising.""" with patch( "vibe_bot.services.image_service.requests.get", side_effect=requests.RequestException("boom"), ): assert _download_image_bytes("https://cdn.discordapp.com/a/b/c.png") is None def test_download_image_bytes_content_length_cap() -> None: """A declared Content-Length above the cap is refused before streaming.""" response = MagicMock() response.headers = {"Content-Length": str(MAX_IMAGE_DOWNLOAD_BYTES + 1)} response.iter_content = MagicMock() with patch("vibe_bot.services.image_service.requests.get", return_value=response): assert _download_image_bytes("https://cdn.discordapp.com/a/b/c.png") is None response.iter_content.assert_not_called() def test_download_image_bytes_streaming_cap() -> None: """Streaming past the size cap is refused even without a Content-Length.""" response = MagicMock() response.headers = {} response.iter_content.return_value = [b"a" * (MAX_IMAGE_DOWNLOAD_BYTES + 1)] with patch("vibe_bot.services.image_service.requests.get", return_value=response): assert _download_image_bytes("https://cdn.discordapp.com/a/b/c.png") is None def test_download_image_bytes_bad_content_length() -> None: """A non-numeric Content-Length is ignored and streaming proceeds.""" response = MagicMock() response.headers = {"Content-Length": "not-a-number"} response.iter_content.return_value = [b"xyz"] response.raise_for_status.return_value = None with patch("vibe_bot.services.image_service.requests.get", return_value=response): assert _download_image_bytes("https://cdn.discordapp.com/a/b/c.png") == b"xyz" def test_download_image_bytes_stream_error() -> None: """A mid-stream request error returns None instead of raising.""" response = MagicMock() response.headers = {} response.iter_content.side_effect = requests.RequestException("stream died") with patch("vibe_bot.services.image_service.requests.get", return_value=response): assert _download_image_bytes("https://cdn.discordapp.com/a/b/c.png") is None # --------------------------------------------------------------------------- # parse_voice_flag # --------------------------------------------------------------------------- def test_parse_voice_flag_no_flag() -> None: """Without a trailing flag the message is returned unchanged.""" assert parse_voice_flag("hello world") == ("hello world", None) def test_parse_voice_flag_trailing() -> None: """A trailing --voice flag is split off.""" assert parse_voice_flag("hello world --voice af_bella") == ( "hello world", "af_bella", ) def test_parse_voice_flag_mid_text_preserved() -> None: """A --voice that is not at the end is treated as speech, not a flag.""" assert parse_voice_flag("hello --voice af_bella world") == ( "hello --voice af_bella world", None, ) def test_parse_voice_flag_missing_value() -> None: """A --voice with no value (or only trailing spaces) is not a flag.""" assert parse_voice_flag("hello --voice") == ("hello --voice", None) assert parse_voice_flag("hello --voice ") == ("hello --voice ", None)