"""Unit coverage for SPEC-004 input pipeline (IMG-10..16).""" import base64 import sqlite3 import tempfile import unittest from pathlib import Path from unittest.mock import AsyncMock, MagicMock, Mock, patch from fjerkroa_bot.ai_responder import AIMessage, AIResponse from fjerkroa_bot.images import ImageCache, sniff_ext from fjerkroa_bot.openai_responder import OpenAIResponder from fjerkroa_bot.persistence import PersistentStore from .test_bdd_envelope import FakeModelResponder from .test_spec_ops import OpsBase PNG = b"\x89PNG\r\n\x1a\n" + b"x" * 64 def make_cache(tmp, config=None): store = PersistentStore(Path(tmp) / "bot.db") cache = ImageCache(store, Path(tmp) / "images", lambda: config or {}) return store, cache class TestIngest(unittest.TestCase): def test_sniffed_types_only(self): """IMG-10: magic bytes decide; garbage and foreign types are rejected.""" self.assertEqual(sniff_ext(PNG), "png") self.assertEqual(sniff_ext(b"\xff\xd8\xff\xe0rest"), "jpg") self.assertIsNone(sniff_ext(b"MZ\x90\x00 definitely-an-exe")) with tempfile.TemporaryDirectory() as tmp: store, cache = make_cache(tmp) self.assertIsNone(cache.ingest_bytes(b"not an image", "chat", "alice", "1")) sha = cache.ingest_bytes(PNG, "chat", "alice", "1") self.assertIsNotNone(sha) self.assertTrue((Path(tmp) / "images" / f"{sha}.png").exists()) self.assertEqual(store.images_recent("chat", 5)[0]["sha256"], sha) def test_size_cap(self): """IMG-10: oversized uploads are dropped.""" with tempfile.TemporaryDirectory() as tmp: _, cache = make_cache(tmp, {"image-max-bytes": 32}) self.assertIsNone(cache.ingest_bytes(PNG, "chat", "alice", "1")) class TestOversizedDownloadRejected(unittest.IsolatedAsyncioTestCase): async def test_download_stays_over_limit_and_is_rejected(self): """IMG-10: an over-limit download must be rejected, not cached truncated.""" with tempfile.TemporaryDirectory() as tmp: _, cache = make_cache(tmp, {"image-max-bytes": 32}) class FakeContent: @staticmethod async def iter_chunked(size): yield PNG # 72 bytes > 32 class FakeResp: content = FakeContent() async def __aenter__(self): return self async def __aexit__(self, *a): return False def raise_for_status(self): pass class FakeSession: def get(self, url): return FakeResp() async def __aenter__(self): return self async def __aexit__(self, *a): return False with patch("fjerkroa_bot.images.aiohttp.ClientSession", return_value=FakeSession()): data = await cache._download("http://x.com/big.png") self.assertEqual(len(data), 33) # limit + 1, not silently capped to limit self.assertIsNone(await cache.ingest_url("http://x.com/big.png", "chat", "alice", "1")) class TestVisionDataUrls(OpsBase): async def test_attachment_becomes_data_url(self): """IMG-11: the model sees a data: URL, never the CDN link.""" with tempfile.TemporaryDirectory() as tmp: _, cache = make_cache(tmp) self.bot.airesponder.image_cache = cache self.bot.respond = AsyncMock() message = self.public_msg("look at this") attachment = Mock() attachment.url = "https://cdn.discordapp.com/attachments/1/2/cat.png?ex=deadbeef" message.attachments = [attachment] message.id = 42 with patch.object(ImageCache, "_download", new_callable=AsyncMock, return_value=PNG): await self.bot.on_message(message) sent_msg = self.bot.respond.await_args.args[0] self.assertTrue(sent_msg.urls[0].startswith("data:image/png;base64,")) self.assertNotIn("cdn.discordapp.com", sent_msg.urls[0]) class TestEviction(unittest.TestCase): def test_lru_cap(self): """IMG-12: byte cap evicts oldest first, file + row together.""" big = b"\x89PNG\r\n\x1a\n" + b"a" * (700 * 1024) big2 = b"\x89PNG\r\n\x1a\n" + b"b" * (700 * 1024) with tempfile.TemporaryDirectory() as tmp: store, cache = make_cache(tmp, {"image-cache-mb": 1}) first = cache.ingest_bytes(big, "chat", "alice", "1") second = cache.ingest_bytes(big2, "chat", "alice", "2") shas = [row["sha256"] for row in store.images_recent("chat", 5)] self.assertNotIn(first, shas) self.assertIn(second, shas) self.assertFalse((Path(tmp) / "images" / f"{first}.png").exists()) def test_ttl(self): """IMG-12: entries past image-cache-ttl-days age out.""" with tempfile.TemporaryDirectory() as tmp: store, cache = make_cache(tmp, {"image-cache-ttl-days": 30}) sha = cache.ingest_bytes(PNG, "chat", "alice", "1") with sqlite3.connect(store.db_path) as conn: conn.execute("UPDATE images SET created_at = datetime('now', '-60 days') WHERE sha256 = ?", (sha,)) cache.evict() self.assertEqual(store.images_recent("chat", 5), []) self.assertFalse((Path(tmp) / "images" / f"{sha}.png").exists()) class TestEditPath(OpsBase): async def prepare(self, with_images): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) _, cache = make_cache(self.tmp.name) self.bot.airesponder.image_cache = cache if with_images: cache.ingest_bytes(PNG, "chat", "alice", "1") self.bot.airesponder.edit_openai = AsyncMock(return_value=[__import__("io").BytesIO(PNG)]) self.bot.airesponder.draw = AsyncMock(return_value=[__import__("io").BytesIO(PNG)]) response = AIResponse("her", True, "chat", None, "als wikinger", True, False) channel = MagicMock() channel.name = "chat" channel.send = AsyncMock() channel.typing = MagicMock(return_value=AsyncMock(__aenter__=AsyncMock(), __aexit__=AsyncMock())) await self.bot.send_answer_with_typing(response, channel, self.bot.airesponder, factual=True) async def test_edit_uses_cached_sources(self): """IMG-13: picture_edit + cached images -> images.edit path.""" await self.prepare(with_images=True) self.bot.airesponder.edit_openai.assert_awaited_once() self.bot.airesponder.draw.assert_not_awaited() async def test_empty_cache_falls_back_to_generate(self): """IMG-13: empty cache -> plain generation, the flag never fails a reply.""" await self.prepare(with_images=False) self.bot.airesponder.edit_openai.assert_not_awaited() self.bot.airesponder.draw.assert_awaited_once() class TestPurges(OpsBase): async def test_message_delete_and_forgetme_purge_images(self): """IMG-14: message deletion and !forgetme remove files + rows.""" with tempfile.TemporaryDirectory() as tmp: store, cache = make_cache(tmp) self.bot.airesponder.image_cache = cache cache.ingest_bytes(PNG, "chat", "alice", "99") deleted = MagicMock() deleted.id = 99 deleted.content = "pic" deleted.author.name = "alice" deleted.channel = MagicMock() await self.bot.on_message_delete(deleted) self.assertEqual(store.images_recent("chat", 5), []) cache.ingest_bytes(b"\x89PNG\r\n\x1a\n" + b"z" * 32, "chat", "alice", "100") message = self.public_msg("!forgetme") message.author.name = "alice" await self.bot.on_message(message) self.assertEqual(store.images_recent("chat", 5), []) class TestGeneratedImagesCached(OpsBase): async def test_bot_output_joins_cache(self): """IMG-15: generated images are ingested as user 'assistant'.""" with tempfile.TemporaryDirectory() as tmp: store, cache = make_cache(tmp) self.bot.airesponder.image_cache = cache self.bot.airesponder.draw = AsyncMock(return_value=[__import__("io").BytesIO(PNG)]) response = AIResponse("her", True, "chat", None, "en katt", False, False) channel = MagicMock() channel.name = "chat" channel.send = AsyncMock() await self.bot.send_answer_with_typing(response, channel, self.bot.airesponder, factual=True) rows = store.images_recent("chat", 5) self.assertEqual(len(rows), 1) self.assertEqual(rows[0]["user"], "assistant") class TestImageOnlyMessages(OpsBase): async def test_image_only_post_cached_no_reply(self): """IMG-17: attachment without text -> cached + observed, no reply.""" with tempfile.TemporaryDirectory() as tmp: store, cache = make_cache(tmp) self.bot.airesponder.image_cache = cache self.bot.airesponder.observe_event = AsyncMock() self.bot.respond = AsyncMock() message = self.public_msg("") message.content = "" message.channel.name = "chat" attachment = Mock() attachment.url = "https://cdn.discordapp.com/attachments/1/2/silent.png" message.attachments = [attachment] message.id = 77 with patch.object(ImageCache, "_download", new_callable=AsyncMock, return_value=PNG): await self.bot.on_message(message) self.assertEqual(len(store.images_recent("chat", 5)), 1) self.bot.airesponder.observe_event.assert_awaited_once() self.bot.respond.assert_not_awaited() class TestContextAnnouncesImages(unittest.IsolatedAsyncioTestCase): def test_suffix_mentions_picture_edit(self): """IMG-16: cached channel images are announced in the context suffix.""" with tempfile.TemporaryDirectory() as tmp: config = {"system": "s", "history-limit": 5, "history-directory": tmp} responder = FakeModelResponder(config, "chat") responder.image_cache.ingest_bytes(PNG, "chat", "alice", "1") system = responder.message(AIMessage("alice", "hei", "chat"))[0]["content"] self.assertIn("picture_edit", system) self.assertIn("recent images in this channel: 1", system) class TestEditOpenai(unittest.IsolatedAsyncioTestCase): async def test_edit_call_shape_and_metering(self): """IMG-13: images.edit gets the file handles, n clamped, ledger counts.""" responder = OpenAIResponder({"openai-token": "t", "model": "m", "system": "s", "history-limit": 5}, "chat") with tempfile.TemporaryDirectory() as tmp: paths = [] for index in range(2): path = Path(tmp) / f"in{index}.png" path.write_bytes(PNG) paths.append(path) api_result = Mock(data=[Mock(b64_json=base64.b64encode(b"out").decode())]) with patch("fjerkroa_bot.openai_responder.openai_image_edit", new_callable=AsyncMock) as edit_mock: edit_mock.return_value = api_result buffers = await responder.edit_openai("wikinger", paths, 9) self.assertEqual(buffers[0].read(), b"out") self.assertEqual(edit_mock.await_args.kwargs["n"], 4) self.assertEqual(len(edit_mock.await_args.kwargs["image"]), 2) self.assertEqual(responder.ledger.images_today(), 1)