/ tests / gateway / test_telegram_documents.py
test_telegram_documents.py
  1  """
  2  Tests for Telegram document handling in gateway/platforms/telegram.py.
  3  
  4  Covers: document type detection, download/cache flow, size limits,
  5          text injection, error handling.
  6  
  7  Note: python-telegram-bot may not be installed in the test environment.
  8  We mock the telegram module at import time to avoid collection errors.
  9  """
 10  
 11  import asyncio
 12  import importlib
 13  import os
 14  import sys
 15  from types import SimpleNamespace
 16  from unittest.mock import AsyncMock, MagicMock, patch
 17  
 18  import pytest
 19  
 20  from gateway.config import Platform, PlatformConfig
 21  from gateway.platforms.base import (
 22      MessageEvent,
 23      MessageType,
 24      SendResult,
 25      SUPPORTED_DOCUMENT_TYPES,
 26      SUPPORTED_VIDEO_TYPES,
 27  )
 28  
 29  
 30  # ---------------------------------------------------------------------------
 31  # Mock the telegram package if it's not installed
 32  # ---------------------------------------------------------------------------
 33  
 34  def _ensure_telegram_mock():
 35      """Install mock telegram modules so TelegramAdapter can be imported."""
 36      if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"):
 37          # Real library is installed — no mocking needed
 38          return
 39  
 40      telegram_mod = MagicMock()
 41      # ContextTypes needs DEFAULT_TYPE as an actual attribute for the annotation
 42      telegram_mod.ext.ContextTypes.DEFAULT_TYPE = type(None)
 43      telegram_mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2"
 44      telegram_mod.constants.ChatType.GROUP = "group"
 45      telegram_mod.constants.ChatType.SUPERGROUP = "supergroup"
 46      telegram_mod.constants.ChatType.CHANNEL = "channel"
 47      telegram_mod.constants.ChatType.PRIVATE = "private"
 48  
 49      for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"):
 50          sys.modules.setdefault(name, telegram_mod)
 51  
 52  
 53  _ensure_telegram_mock()
 54  
 55  # Now we can safely import
 56  from gateway.platforms.telegram import TelegramAdapter  # noqa: E402
 57  
 58  
 59  # ---------------------------------------------------------------------------
 60  # Helpers to build mock Telegram objects
 61  # ---------------------------------------------------------------------------
 62  
 63  def _make_file_obj(data: bytes = b"hello"):
 64      """Create a mock Telegram File with download_as_bytearray."""
 65      f = AsyncMock()
 66      f.download_as_bytearray = AsyncMock(return_value=bytearray(data))
 67      f.file_path = "documents/file.pdf"
 68      return f
 69  
 70  
 71  def _make_document(
 72      file_name="report.pdf",
 73      mime_type="application/pdf",
 74      file_size=1024,
 75      file_obj=None,
 76  ):
 77      """Create a mock Telegram Document object."""
 78      doc = MagicMock()
 79      doc.file_name = file_name
 80      doc.mime_type = mime_type
 81      doc.file_size = file_size
 82      doc.get_file = AsyncMock(return_value=file_obj or _make_file_obj())
 83      return doc
 84  
 85  
 86  def _make_message(document=None, caption=None, media_group_id=None, photo=None):
 87      """Build a mock Telegram Message with the given document/photo."""
 88      msg = MagicMock()
 89      msg.message_id = 42
 90      msg.text = caption or ""
 91      msg.caption = caption
 92      msg.date = None
 93      # Media flags — all None except explicit payload
 94      msg.photo = photo
 95      msg.video = None
 96      msg.audio = None
 97      msg.voice = None
 98      msg.sticker = None
 99      msg.document = document
100      msg.media_group_id = media_group_id
101      # Chat / user
102      msg.chat = MagicMock()
103      msg.chat.id = 100
104      msg.chat.type = "private"
105      msg.chat.title = None
106      msg.chat.full_name = "Test User"
107      msg.from_user = MagicMock()
108      msg.from_user.id = 1
109      msg.from_user.full_name = "Test User"
110      msg.message_thread_id = None
111      return msg
112  
113  
114  def _make_update(msg):
115      """Wrap a message in a mock Update."""
116      update = MagicMock()
117      update.message = msg
118      return update
119  
120  
121  def _make_video(file_obj=None):
122      video = MagicMock()
123      video.get_file = AsyncMock(return_value=file_obj or _make_file_obj(b"video-bytes"))
124      return video
125  
126  
127  # ---------------------------------------------------------------------------
128  # Fixtures
129  # ---------------------------------------------------------------------------
130  
131  @pytest.fixture()
132  def adapter():
133      config = PlatformConfig(enabled=True, token="fake-token")
134      a = TelegramAdapter(config)
135      # Capture events instead of processing them
136      a.handle_message = AsyncMock()
137      return a
138  
139  
140  @pytest.fixture(autouse=True)
141  def _redirect_cache(tmp_path, monkeypatch):
142      """Point document/video cache to tmp_path so tests don't touch ~/.hermes."""
143      monkeypatch.setattr(
144          "gateway.platforms.base.DOCUMENT_CACHE_DIR", tmp_path / "doc_cache"
145      )
146      monkeypatch.setattr(
147          "gateway.platforms.base.VIDEO_CACHE_DIR", tmp_path / "video_cache"
148      )
149  
150  
151  # ---------------------------------------------------------------------------
152  # TestDocumentTypeDetection
153  # ---------------------------------------------------------------------------
154  
155  class TestDocumentTypeDetection:
156      @pytest.mark.asyncio
157      async def test_document_detected_explicitly(self, adapter):
158          doc = _make_document()
159          msg = _make_message(document=doc)
160          update = _make_update(msg)
161          await adapter._handle_media_message(update, MagicMock())
162          event = adapter.handle_message.call_args[0][0]
163          assert event.message_type == MessageType.DOCUMENT
164  
165      @pytest.mark.asyncio
166      async def test_fallback_is_document(self, adapter):
167          """When no specific media attr is set, message_type defaults to DOCUMENT."""
168          msg = _make_message()
169          msg.document = None  # no media at all
170          update = _make_update(msg)
171          await adapter._handle_media_message(update, MagicMock())
172          event = adapter.handle_message.call_args[0][0]
173          assert event.message_type == MessageType.DOCUMENT
174  
175  
176  # ---------------------------------------------------------------------------
177  # TestDocumentDownloadBlock
178  # ---------------------------------------------------------------------------
179  
180  def _make_photo(file_obj=None):
181      photo = MagicMock()
182      photo.get_file = AsyncMock(return_value=file_obj or _make_file_obj(b"photo-bytes"))
183      return photo
184  
185  
186  class TestDocumentDownloadBlock:
187      @pytest.mark.asyncio
188      async def test_supported_pdf_is_cached(self, adapter):
189          pdf_bytes = b"%PDF-1.4 fake"
190          file_obj = _make_file_obj(pdf_bytes)
191          doc = _make_document(file_name="report.pdf", file_size=1024, file_obj=file_obj)
192          msg = _make_message(document=doc)
193          update = _make_update(msg)
194  
195          await adapter._handle_media_message(update, MagicMock())
196          event = adapter.handle_message.call_args[0][0]
197          assert len(event.media_urls) == 1
198          assert os.path.exists(event.media_urls[0])
199          assert event.media_types == ["application/pdf"]
200  
201      @pytest.mark.asyncio
202      async def test_supported_txt_injects_content(self, adapter):
203          content = b"Hello from a text file"
204          file_obj = _make_file_obj(content)
205          doc = _make_document(
206              file_name="notes.txt", mime_type="text/plain",
207              file_size=len(content), file_obj=file_obj,
208          )
209          msg = _make_message(document=doc)
210          update = _make_update(msg)
211  
212          await adapter._handle_media_message(update, MagicMock())
213          event = adapter.handle_message.call_args[0][0]
214          assert "Hello from a text file" in event.text
215          assert "[Content of notes.txt]" in event.text
216  
217      @pytest.mark.asyncio
218      async def test_supported_md_injects_content(self, adapter):
219          content = b"# Title\nSome markdown"
220          file_obj = _make_file_obj(content)
221          doc = _make_document(
222              file_name="readme.md", mime_type="text/markdown",
223              file_size=len(content), file_obj=file_obj,
224          )
225          msg = _make_message(document=doc)
226          update = _make_update(msg)
227  
228          await adapter._handle_media_message(update, MagicMock())
229          event = adapter.handle_message.call_args[0][0]
230          assert "# Title" in event.text
231  
232      @pytest.mark.asyncio
233      async def test_caption_preserved_with_injection(self, adapter):
234          content = b"file text"
235          file_obj = _make_file_obj(content)
236          doc = _make_document(
237              file_name="doc.txt", mime_type="text/plain",
238              file_size=len(content), file_obj=file_obj,
239          )
240          msg = _make_message(document=doc, caption="Please summarize")
241          update = _make_update(msg)
242  
243          await adapter._handle_media_message(update, MagicMock())
244          event = adapter.handle_message.call_args[0][0]
245          assert "file text" in event.text
246          assert "Please summarize" in event.text
247  
248      @pytest.mark.asyncio
249      async def test_zip_document_cached(self, adapter):
250          """A .zip upload should be cached as a supported document."""
251          doc = _make_document(file_name="archive.zip", mime_type="application/zip", file_size=100)
252          msg = _make_message(document=doc)
253          update = _make_update(msg)
254  
255          await adapter._handle_media_message(update, MagicMock())
256          event = adapter.handle_message.call_args[0][0]
257          assert event.media_urls and event.media_urls[0].endswith("archive.zip")
258          assert event.media_types == ["application/zip"]
259  
260      @pytest.mark.asyncio
261      async def test_oversized_file_rejected(self, adapter):
262          doc = _make_document(file_name="huge.pdf", file_size=25 * 1024 * 1024)
263          msg = _make_message(document=doc)
264          update = _make_update(msg)
265  
266          await adapter._handle_media_message(update, MagicMock())
267          event = adapter.handle_message.call_args[0][0]
268          assert "too large" in event.text
269  
270      @pytest.mark.asyncio
271      async def test_none_file_size_rejected(self, adapter):
272          """Security fix: file_size=None must be rejected (not silently allowed)."""
273          doc = _make_document(file_name="tricky.pdf", file_size=None)
274          msg = _make_message(document=doc)
275          update = _make_update(msg)
276  
277          await adapter._handle_media_message(update, MagicMock())
278          event = adapter.handle_message.call_args[0][0]
279          assert "too large" in event.text or "could not be verified" in event.text
280  
281      @pytest.mark.asyncio
282      async def test_missing_filename_uses_mime_lookup(self, adapter):
283          """No file_name but valid mime_type should resolve to extension."""
284          content = b"some pdf bytes"
285          file_obj = _make_file_obj(content)
286          doc = _make_document(
287              file_name=None, mime_type="application/pdf",
288              file_size=len(content), file_obj=file_obj,
289          )
290          msg = _make_message(document=doc)
291          update = _make_update(msg)
292  
293          await adapter._handle_media_message(update, MagicMock())
294          event = adapter.handle_message.call_args[0][0]
295          assert len(event.media_urls) == 1
296          assert event.media_types == ["application/pdf"]
297  
298      @pytest.mark.asyncio
299      async def test_missing_filename_and_mime_rejected(self, adapter):
300          doc = _make_document(file_name=None, mime_type=None, file_size=100)
301          msg = _make_message(document=doc)
302          update = _make_update(msg)
303  
304          await adapter._handle_media_message(update, MagicMock())
305          event = adapter.handle_message.call_args[0][0]
306          assert "Unsupported" in event.text
307  
308      @pytest.mark.asyncio
309      async def test_unicode_decode_error_handled(self, adapter):
310          """Binary bytes that aren't valid UTF-8 in a .txt — content not injected but file still cached."""
311          binary = bytes(range(128, 256))  # not valid UTF-8
312          file_obj = _make_file_obj(binary)
313          doc = _make_document(
314              file_name="binary.txt", mime_type="text/plain",
315              file_size=len(binary), file_obj=file_obj,
316          )
317          msg = _make_message(document=doc)
318          update = _make_update(msg)
319  
320          await adapter._handle_media_message(update, MagicMock())
321          event = adapter.handle_message.call_args[0][0]
322          # File should still be cached
323          assert len(event.media_urls) == 1
324          assert os.path.exists(event.media_urls[0])
325          # Content NOT injected — text should be empty (no caption set)
326          assert "[Content of" not in (event.text or "")
327  
328      @pytest.mark.asyncio
329      async def test_text_injection_capped(self, adapter):
330          """A .txt file over 100 KB should NOT have its content injected."""
331          large = b"x" * (200 * 1024)  # 200 KB
332          file_obj = _make_file_obj(large)
333          doc = _make_document(
334              file_name="big.txt", mime_type="text/plain",
335              file_size=len(large), file_obj=file_obj,
336          )
337          msg = _make_message(document=doc)
338          update = _make_update(msg)
339  
340          await adapter._handle_media_message(update, MagicMock())
341          event = adapter.handle_message.call_args[0][0]
342          # File should be cached
343          assert len(event.media_urls) == 1
344          # Content should NOT be injected
345          assert "[Content of" not in (event.text or "")
346  
347      @pytest.mark.asyncio
348      async def test_download_exception_handled(self, adapter):
349          """If get_file() raises, the handler logs the error without crashing."""
350          doc = _make_document(file_name="crash.pdf", file_size=100)
351          doc.get_file = AsyncMock(side_effect=RuntimeError("Telegram API down"))
352          msg = _make_message(document=doc)
353          update = _make_update(msg)
354  
355          # Should not raise
356          await adapter._handle_media_message(update, MagicMock())
357          # handle_message should still be called (the handler catches the exception)
358          adapter.handle_message.assert_called_once()
359  
360  
361  class TestVideoDownloadBlock:
362      @pytest.mark.asyncio
363      async def test_native_video_is_cached(self, adapter):
364          file_obj = _make_file_obj(b"fake-mp4")
365          file_obj.file_path = "videos/clip.mp4"
366          msg = _make_message()
367          msg.video = _make_video(file_obj)
368          update = _make_update(msg)
369  
370          await adapter._handle_media_message(update, MagicMock())
371          event = adapter.handle_message.call_args[0][0]
372          assert event.message_type == MessageType.VIDEO
373          assert len(event.media_urls) == 1
374          assert os.path.exists(event.media_urls[0])
375          assert event.media_types == [SUPPORTED_VIDEO_TYPES[".mp4"]]
376  
377      @pytest.mark.asyncio
378      async def test_mp4_document_is_treated_as_video(self, adapter):
379          file_obj = _make_file_obj(b"fake-mp4-doc")
380          doc = _make_document(file_name="good.mp4", mime_type="video/mp4", file_size=1024, file_obj=file_obj)
381          msg = _make_message(document=doc)
382          update = _make_update(msg)
383  
384          await adapter._handle_media_message(update, MagicMock())
385          event = adapter.handle_message.call_args[0][0]
386          assert event.message_type == MessageType.VIDEO
387          assert len(event.media_urls) == 1
388          assert os.path.exists(event.media_urls[0])
389          assert event.media_types == [SUPPORTED_VIDEO_TYPES[".mp4"]]
390  
391  
392  # ---------------------------------------------------------------------------
393  # TestMediaGroups — media group (album) buffering
394  # ---------------------------------------------------------------------------
395  
396  class TestMediaGroups:
397      @pytest.mark.asyncio
398      async def test_non_album_photo_burst_is_buffered_and_combined(self, adapter):
399          first_photo = _make_photo(_make_file_obj(b"first"))
400          second_photo = _make_photo(_make_file_obj(b"second"))
401  
402          msg1 = _make_message(caption="two images", photo=[first_photo])
403          msg2 = _make_message(photo=[second_photo])
404  
405          with patch("gateway.platforms.telegram.cache_image_from_bytes", side_effect=["/tmp/burst-one.jpg", "/tmp/burst-two.jpg"]):
406              await adapter._handle_media_message(_make_update(msg1), MagicMock())
407              await adapter._handle_media_message(_make_update(msg2), MagicMock())
408              assert adapter.handle_message.await_count == 0
409              await asyncio.sleep(adapter.MEDIA_GROUP_WAIT_SECONDS + 0.05)
410  
411          adapter.handle_message.assert_awaited_once()
412          event = adapter.handle_message.await_args.args[0]
413          assert event.text == "two images"
414          assert event.media_urls == ["/tmp/burst-one.jpg", "/tmp/burst-two.jpg"]
415          assert len(event.media_types) == 2
416  
417      @pytest.mark.asyncio
418      async def test_photo_album_is_buffered_and_combined(self, adapter):
419          first_photo = _make_photo(_make_file_obj(b"first"))
420          second_photo = _make_photo(_make_file_obj(b"second"))
421  
422          msg1 = _make_message(caption="two images", media_group_id="album-1", photo=[first_photo])
423          msg2 = _make_message(media_group_id="album-1", photo=[second_photo])
424  
425          with patch("gateway.platforms.telegram.cache_image_from_bytes", side_effect=["/tmp/one.jpg", "/tmp/two.jpg"]):
426              await adapter._handle_media_message(_make_update(msg1), MagicMock())
427              await adapter._handle_media_message(_make_update(msg2), MagicMock())
428              assert adapter.handle_message.await_count == 0
429              await asyncio.sleep(adapter.MEDIA_GROUP_WAIT_SECONDS + 0.05)
430  
431          adapter.handle_message.assert_awaited_once()
432          event = adapter.handle_message.call_args[0][0]
433          assert event.text == "two images"
434          assert event.media_urls == ["/tmp/one.jpg", "/tmp/two.jpg"]
435          assert len(event.media_types) == 2
436  
437      @pytest.mark.asyncio
438      async def test_disconnect_cancels_pending_media_group_flush(self, adapter):
439          first_photo = _make_photo(_make_file_obj(b"first"))
440          msg = _make_message(caption="two images", media_group_id="album-2", photo=[first_photo])
441  
442          with patch("gateway.platforms.telegram.cache_image_from_bytes", return_value="/tmp/one.jpg"):
443              await adapter._handle_media_message(_make_update(msg), MagicMock())
444  
445          assert "album-2" in adapter._media_group_events
446          assert "album-2" in adapter._media_group_tasks
447  
448          await adapter.disconnect()
449          await asyncio.sleep(adapter.MEDIA_GROUP_WAIT_SECONDS + 0.05)
450  
451          assert adapter._media_group_events == {}
452          assert adapter._media_group_tasks == {}
453          adapter.handle_message.assert_not_awaited()
454  
455  
456  # ---------------------------------------------------------------------------
457  # TestSendVoice — outbound audio delivery
458  # ---------------------------------------------------------------------------
459  
460  class TestSendVoice:
461      """Tests for TelegramAdapter.send_voice() routing across audio formats."""
462  
463      @pytest.fixture()
464      def connected_adapter(self, adapter):
465          """Adapter with a mock bot attached."""
466          bot = AsyncMock()
467          adapter._bot = bot
468          return adapter
469  
470      @pytest.mark.asyncio
471      async def test_flac_falls_back_to_document(self, connected_adapter, tmp_path):
472          """Telegram sendAudio does not accept FLAC — must fall back to sendDocument."""
473          audio_file = tmp_path / "clip.flac"
474          audio_file.write_bytes(b"fLaC" + b"\x00" * 32)
475  
476          mock_msg = MagicMock()
477          mock_msg.message_id = 101
478          connected_adapter._bot.send_voice = AsyncMock()
479          connected_adapter._bot.send_audio = AsyncMock()
480          connected_adapter._bot.send_document = AsyncMock(return_value=mock_msg)
481  
482          result = await connected_adapter.send_voice(
483              chat_id="12345",
484              audio_path=str(audio_file),
485              caption="Audio",
486          )
487  
488          assert result.success is True
489          assert result.message_id == "101"
490          connected_adapter._bot.send_document.assert_awaited_once()
491          connected_adapter._bot.send_audio.assert_not_awaited()
492          connected_adapter._bot.send_voice.assert_not_awaited()
493  
494      @pytest.mark.asyncio
495      async def test_wav_falls_back_to_document(self, connected_adapter, tmp_path):
496          """Telegram sendAudio does not accept WAV — must fall back to sendDocument."""
497          audio_file = tmp_path / "clip.wav"
498          audio_file.write_bytes(b"RIFF" + b"\x00" * 32)
499  
500          mock_msg = MagicMock()
501          mock_msg.message_id = 102
502          connected_adapter._bot.send_voice = AsyncMock()
503          connected_adapter._bot.send_audio = AsyncMock()
504          connected_adapter._bot.send_document = AsyncMock(return_value=mock_msg)
505  
506          result = await connected_adapter.send_voice(
507              chat_id="12345",
508              audio_path=str(audio_file),
509          )
510  
511          assert result.success is True
512          connected_adapter._bot.send_document.assert_awaited_once()
513          connected_adapter._bot.send_audio.assert_not_awaited()
514  
515      @pytest.mark.asyncio
516      async def test_mp3_routes_to_send_audio(self, connected_adapter, tmp_path):
517          """MP3 is Telegram-sendAudio-compatible."""
518          audio_file = tmp_path / "clip.mp3"
519          audio_file.write_bytes(b"ID3" + b"\x00" * 32)
520  
521          mock_msg = MagicMock()
522          mock_msg.message_id = 103
523          connected_adapter._bot.send_voice = AsyncMock()
524          connected_adapter._bot.send_audio = AsyncMock(return_value=mock_msg)
525          connected_adapter._bot.send_document = AsyncMock()
526  
527          result = await connected_adapter.send_voice(
528              chat_id="12345",
529              audio_path=str(audio_file),
530          )
531  
532          assert result.success is True
533          connected_adapter._bot.send_audio.assert_awaited_once()
534          connected_adapter._bot.send_document.assert_not_awaited()
535  
536  
537  # ---------------------------------------------------------------------------
538  # TestSendDocument — outbound file attachment delivery
539  # ---------------------------------------------------------------------------
540  
541  class TestSendDocument:
542      """Tests for TelegramAdapter.send_document() — sending files to users."""
543  
544      @pytest.fixture()
545      def connected_adapter(self, adapter):
546          """Adapter with a mock bot attached."""
547          bot = AsyncMock()
548          adapter._bot = bot
549          return adapter
550  
551      @pytest.mark.asyncio
552      async def test_send_document_success(self, connected_adapter, tmp_path):
553          """A local file is sent via bot.send_document and returns success."""
554          # Create a real temp file
555          test_file = tmp_path / "report.pdf"
556          test_file.write_bytes(b"%PDF-1.4 fake content")
557  
558          mock_msg = MagicMock()
559          mock_msg.message_id = 99
560          connected_adapter._bot.send_document = AsyncMock(return_value=mock_msg)
561  
562          result = await connected_adapter.send_document(
563              chat_id="12345",
564              file_path=str(test_file),
565              caption="Here's the report",
566          )
567  
568          assert result.success is True
569          assert result.message_id == "99"
570          connected_adapter._bot.send_document.assert_called_once()
571          call_kwargs = connected_adapter._bot.send_document.call_args[1]
572          assert call_kwargs["chat_id"] == 12345
573          assert call_kwargs["filename"] == "report.pdf"
574          assert call_kwargs["caption"] == "Here's the report"
575  
576      @pytest.mark.asyncio
577      async def test_send_document_custom_filename(self, connected_adapter, tmp_path):
578          """The file_name parameter overrides the basename for display."""
579          test_file = tmp_path / "doc_abc123_ugly.csv"
580          test_file.write_bytes(b"a,b,c\n1,2,3")
581  
582          mock_msg = MagicMock()
583          mock_msg.message_id = 100
584          connected_adapter._bot.send_document = AsyncMock(return_value=mock_msg)
585  
586          result = await connected_adapter.send_document(
587              chat_id="12345",
588              file_path=str(test_file),
589              file_name="clean_data.csv",
590          )
591  
592          assert result.success is True
593          call_kwargs = connected_adapter._bot.send_document.call_args[1]
594          assert call_kwargs["filename"] == "clean_data.csv"
595  
596      @pytest.mark.asyncio
597      async def test_send_document_file_not_found(self, connected_adapter):
598          """Missing file returns error without calling Telegram API."""
599          result = await connected_adapter.send_document(
600              chat_id="12345",
601              file_path="/nonexistent/file.pdf",
602          )
603  
604          assert result.success is False
605          assert "not found" in result.error.lower()
606          connected_adapter._bot.send_document.assert_not_called()
607  
608      @pytest.mark.asyncio
609      async def test_send_document_workspace_path_has_docker_hint(self, connected_adapter):
610          """Container-local-looking paths get a more actionable Docker hint."""
611          result = await connected_adapter.send_document(
612              chat_id="12345",
613              file_path="/workspace/report.txt",
614          )
615  
616          assert result.success is False
617          assert "docker sandbox" in result.error.lower()
618          assert "host-visible path" in result.error.lower()
619          connected_adapter._bot.send_document.assert_not_called()
620  
621      @pytest.mark.asyncio
622      async def test_send_document_outputs_path_has_docker_hint(self, connected_adapter):
623          """Legacy /outputs paths also get the Docker hint."""
624          result = await connected_adapter.send_document(
625              chat_id="12345",
626              file_path="/outputs/report.txt",
627          )
628  
629          assert result.success is False
630          assert "docker sandbox" in result.error.lower()
631          assert "host-visible path" in result.error.lower()
632          connected_adapter._bot.send_document.assert_not_called()
633  
634      @pytest.mark.asyncio
635      async def test_send_document_not_connected(self, adapter):
636          """If bot is None, returns not connected error."""
637          result = await adapter.send_document(
638              chat_id="12345",
639              file_path="/some/file.pdf",
640          )
641  
642          assert result.success is False
643          assert "Not connected" in result.error
644  
645      @pytest.mark.asyncio
646      async def test_send_document_caption_truncated(self, connected_adapter, tmp_path):
647          """Captions longer than 1024 chars are truncated."""
648          test_file = tmp_path / "data.json"
649          test_file.write_bytes(b"{}")
650  
651          mock_msg = MagicMock()
652          mock_msg.message_id = 101
653          connected_adapter._bot.send_document = AsyncMock(return_value=mock_msg)
654  
655          long_caption = "x" * 2000
656          await connected_adapter.send_document(
657              chat_id="12345",
658              file_path=str(test_file),
659              caption=long_caption,
660          )
661  
662          call_kwargs = connected_adapter._bot.send_document.call_args[1]
663          assert len(call_kwargs["caption"]) == 1024
664  
665      @pytest.mark.asyncio
666      async def test_send_document_api_error_falls_back(self, connected_adapter, tmp_path):
667          """If Telegram API raises, falls back to base class text message."""
668          test_file = tmp_path / "file.pdf"
669          test_file.write_bytes(b"data")
670  
671          connected_adapter._bot.send_document = AsyncMock(
672              side_effect=RuntimeError("Telegram API error")
673          )
674  
675          # The base fallback calls self.send() which is also on _bot, so mock it
676          # to avoid cascading errors.
677          connected_adapter.send = AsyncMock(
678              return_value=SendResult(success=True, message_id="fallback")
679          )
680  
681          result = await connected_adapter.send_document(
682              chat_id="12345",
683              file_path=str(test_file),
684          )
685  
686          # Should have fallen back to base class
687          assert result.success is True
688          assert result.message_id == "fallback"
689  
690      @pytest.mark.asyncio
691      async def test_send_document_reply_to(self, connected_adapter, tmp_path):
692          """reply_to parameter is forwarded as reply_to_message_id."""
693          test_file = tmp_path / "spec.md"
694          test_file.write_bytes(b"# Spec")
695  
696          mock_msg = MagicMock()
697          mock_msg.message_id = 102
698          connected_adapter._bot.send_document = AsyncMock(return_value=mock_msg)
699  
700          await connected_adapter.send_document(
701              chat_id="12345",
702              file_path=str(test_file),
703              reply_to="50",
704          )
705  
706          call_kwargs = connected_adapter._bot.send_document.call_args[1]
707          assert call_kwargs["reply_to_message_id"] == 50
708  
709      @pytest.mark.asyncio
710      async def test_send_document_thread_id(self, connected_adapter, tmp_path):
711          """metadata thread_id is forwarded as message_thread_id (required for Telegram forum groups)."""
712          test_file = tmp_path / "report.pdf"
713          test_file.write_bytes(b"%PDF-1.4 data")
714  
715          mock_msg = MagicMock()
716          mock_msg.message_id = 103
717          connected_adapter._bot.send_document = AsyncMock(return_value=mock_msg)
718  
719          await connected_adapter.send_document(
720              chat_id="12345",
721              file_path=str(test_file),
722              metadata={"thread_id": "789"},
723          )
724  
725          call_kwargs = connected_adapter._bot.send_document.call_args[1]
726          assert call_kwargs["message_thread_id"] == 789
727  
728  
729  class TestTelegramPhotoBatching:
730      @pytest.mark.asyncio
731      async def test_flush_photo_batch_does_not_drop_newer_scheduled_task(self, adapter):
732          old_task = MagicMock()
733          new_task = MagicMock()
734          batch_key = "session:photo-burst"
735          adapter._pending_photo_batch_tasks[batch_key] = new_task
736          adapter._pending_photo_batches[batch_key] = MessageEvent(
737              text="",
738              message_type=MessageType.PHOTO,
739              source=SimpleNamespace(channel_id="chat-1"),
740              media_urls=["/tmp/a.jpg"],
741              media_types=["image/jpeg"],
742          )
743  
744          with (
745              patch("gateway.platforms.telegram.asyncio.current_task", return_value=old_task),
746              patch("gateway.platforms.telegram.asyncio.sleep", new=AsyncMock()),
747          ):
748              await adapter._flush_photo_batch(batch_key)
749  
750          assert adapter._pending_photo_batch_tasks[batch_key] is new_task
751  
752      @pytest.mark.asyncio
753      async def test_disconnect_cancels_pending_photo_batch_tasks(self, adapter):
754          task = MagicMock()
755          task.done.return_value = False
756          adapter._pending_photo_batch_tasks["session:photo-burst"] = task
757          adapter._pending_photo_batches["session:photo-burst"] = MessageEvent(
758              text="",
759              message_type=MessageType.PHOTO,
760              source=SimpleNamespace(channel_id="chat-1"),
761          )
762          adapter._app = MagicMock()
763          adapter._app.updater.stop = AsyncMock()
764          adapter._app.stop = AsyncMock()
765          adapter._app.shutdown = AsyncMock()
766  
767          await adapter.disconnect()
768  
769          task.cancel.assert_called_once()
770          assert adapter._pending_photo_batch_tasks == {}
771          assert adapter._pending_photo_batches == {}
772  
773  
774  # ---------------------------------------------------------------------------
775  # TestSendVideo — outbound video delivery
776  # ---------------------------------------------------------------------------
777  
778  class TestSendVideo:
779      """Tests for TelegramAdapter.send_video() — sending videos to users."""
780  
781      @pytest.fixture()
782      def connected_adapter(self, adapter):
783          bot = AsyncMock()
784          adapter._bot = bot
785          return adapter
786  
787      @pytest.mark.asyncio
788      async def test_send_video_success(self, connected_adapter, tmp_path):
789          test_file = tmp_path / "clip.mp4"
790          test_file.write_bytes(b"\x00\x00\x00\x1c" + b"ftyp" + b"\x00" * 100)
791  
792          mock_msg = MagicMock()
793          mock_msg.message_id = 200
794          connected_adapter._bot.send_video = AsyncMock(return_value=mock_msg)
795  
796          result = await connected_adapter.send_video(
797              chat_id="12345",
798              video_path=str(test_file),
799              caption="Check this out",
800          )
801  
802          assert result.success is True
803          assert result.message_id == "200"
804          connected_adapter._bot.send_video.assert_called_once()
805  
806      @pytest.mark.asyncio
807      async def test_send_video_file_not_found(self, connected_adapter):
808          result = await connected_adapter.send_video(
809              chat_id="12345",
810              video_path="/nonexistent/video.mp4",
811          )
812  
813          assert result.success is False
814          assert "not found" in result.error.lower()
815  
816      @pytest.mark.asyncio
817      async def test_send_video_workspace_path_has_docker_hint(self, connected_adapter):
818          result = await connected_adapter.send_video(
819              chat_id="12345",
820              video_path="/workspace/video.mp4",
821          )
822  
823          assert result.success is False
824          assert "docker sandbox" in result.error.lower()
825          assert "host-visible path" in result.error.lower()
826  
827      @pytest.mark.asyncio
828      async def test_send_video_not_connected(self, adapter):
829          result = await adapter.send_video(
830              chat_id="12345",
831              video_path="/some/video.mp4",
832          )
833  
834          assert result.success is False
835          assert "Not connected" in result.error
836  
837      @pytest.mark.asyncio
838      async def test_send_video_thread_id(self, connected_adapter, tmp_path):
839          """metadata thread_id is forwarded as message_thread_id (required for Telegram forum groups)."""
840          test_file = tmp_path / "clip.mp4"
841          test_file.write_bytes(b"\x00\x00\x00\x1c" + b"ftyp" + b"\x00" * 100)
842  
843          mock_msg = MagicMock()
844          mock_msg.message_id = 201
845          connected_adapter._bot.send_video = AsyncMock(return_value=mock_msg)
846  
847          await connected_adapter.send_video(
848              chat_id="12345",
849              video_path=str(test_file),
850              metadata={"thread_id": "789"},
851          )
852  
853          call_kwargs = connected_adapter._bot.send_video.call_args[1]
854          assert call_kwargs["message_thread_id"] == 789