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