hermes-agent/tests/gateway/test_telegram_documents.py
Teknium 6b81590c55
test: prune low-value tests suite-wide (wave 1) — 46,820 → 28,106 test functions
Systematic prune per AGENTS.md test policy, one pass over every major
test tree (gateway, hermes_cli, tools, agent, run_agent, plugins, cli,
cron, tui_gateway, honcho/openviking, root-level):

- DELETE: source-reading tests (read_text/getsource on prod files),
  change-detector tests (exact catalog counts, model-name snapshots,
  config version literals), mock-echo tests (assert a mock returns what
  it was told), assertion-free/trivial tests, near-duplicate
  parametrizations (boundaries + one representative kept), async/sync
  twin duplicates, cosmetic within-file variations.
- KEEP (mandatory): security/redaction/approval guards, message-role
  alternation invariants, prompt-caching/deterministic-call-id
  invariants, issue-number regression tests (deduped), E2E tests.
- 6 test files deleted outright (script-style/no-assert or fully
  redundant); conftest.py, fakes/, fixtures/ untouched.
- tests/acp/conftest.py added: autouse fixture stubs the live
  models.dev/GitHub/Copilot/Anthropic inventory fetches that ACP server
  tests performed on every session create — test_server.py 147s → 3.4s,
  and the tests are now genuinely hermetic.
- Sleep-based slowness shrunk where safe (codex_ttfb_watchdog,
  compression_concurrent_fork, etc.); no wall-clock assertion tightened.

Verification: full hermetic suite via scripts/run_tests.sh —
2439 files, 31,130 tests passed, 0 failed, 0 flaky retries, 315s wall
(baseline: 583s wall, 13,564s subprocess CPU).
2026-07-29 13:10:23 -07:00

566 lines
21 KiB
Python

"""
Tests for Telegram document handling in gateway/platforms/telegram.py.
Covers: document type detection, download/cache flow, size limits,
text injection, error handling.
Note: python-telegram-bot may not be installed in the test environment.
We mock the telegram module at import time to avoid collection errors.
"""
import asyncio
import os
import sys
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from gateway.config import PlatformConfig
from gateway.platforms.base import (
MessageEvent,
MessageType,
SendResult,
SUPPORTED_VIDEO_TYPES,
)
# ---------------------------------------------------------------------------
# Mock the telegram package if it's not installed
# ---------------------------------------------------------------------------
def _ensure_telegram_mock():
"""Install mock telegram modules so TelegramAdapter can be imported."""
if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"):
# Real library is installed — no mocking needed
return
telegram_mod = MagicMock()
# ContextTypes needs DEFAULT_TYPE as an actual attribute for the annotation
telegram_mod.ext.ContextTypes.DEFAULT_TYPE = type(None)
telegram_mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2"
telegram_mod.constants.ChatType.GROUP = "group"
telegram_mod.constants.ChatType.SUPERGROUP = "supergroup"
telegram_mod.constants.ChatType.CHANNEL = "channel"
telegram_mod.constants.ChatType.PRIVATE = "private"
for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"):
sys.modules.setdefault(name, telegram_mod)
_ensure_telegram_mock()
# Now we can safely import
from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402
# ---------------------------------------------------------------------------
# Helpers to build mock Telegram objects
# ---------------------------------------------------------------------------
def _make_file_obj(data: bytes = b"hello"):
"""Create a mock Telegram File with download_as_bytearray."""
f = AsyncMock()
f.download_as_bytearray = AsyncMock(return_value=bytearray(data))
f.file_path = "documents/file.pdf"
return f
def _make_document(
file_name="report.pdf",
mime_type="application/pdf",
file_size=1024,
file_obj=None,
):
"""Create a mock Telegram Document object."""
doc = MagicMock()
doc.file_name = file_name
doc.mime_type = mime_type
doc.file_size = file_size
doc.get_file = AsyncMock(return_value=file_obj or _make_file_obj())
return doc
def _make_message(document=None, caption=None, media_group_id=None, photo=None):
"""Build a mock Telegram Message with the given document/photo."""
msg = MagicMock()
msg.message_id = 42
msg.text = caption or ""
msg.caption = caption
msg.date = None
# Media flags — all None except explicit payload
msg.photo = photo
msg.video = None
msg.audio = None
msg.voice = None
msg.sticker = None
msg.document = document
msg.media_group_id = media_group_id
# Chat / user
msg.chat = MagicMock()
msg.chat.id = 100
msg.chat.type = "private"
msg.chat.title = None
msg.chat.full_name = "Test User"
msg.from_user = MagicMock()
msg.from_user.id = 1
msg.from_user.full_name = "Test User"
msg.message_thread_id = None
msg.reply_text = AsyncMock()
return msg
def _make_update(msg):
"""Wrap a message in a mock Update."""
update = MagicMock()
update.message = msg
return update
def _make_video(file_obj=None):
video = MagicMock()
video.get_file = AsyncMock(return_value=file_obj or _make_file_obj(b"video-bytes"))
return video
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def adapter():
config = PlatformConfig(enabled=True, token="fake-token")
a = TelegramAdapter(config)
# Capture events instead of processing them
a.handle_message = AsyncMock()
# After PR #28494 made the empty-allowlist callback auth fail-closed
# (and #28492 wired _is_callback_user_authorized into _should_process_message),
# document-routing tests need to bypass the new gate so messages from fake
# senders reach handle_message.
a._is_callback_user_authorized = lambda user_id, **_kw: True
return a
@pytest.fixture(autouse=True)
def _redirect_cache(tmp_path, monkeypatch):
"""Point document/video cache to tmp_path so tests don't touch ~/.hermes."""
monkeypatch.setattr(
"gateway.platforms.base.DOCUMENT_CACHE_DIR", tmp_path / "doc_cache"
)
monkeypatch.setattr(
"gateway.platforms.base.VIDEO_CACHE_DIR", tmp_path / "video_cache"
)
monkeypatch.setattr(
"gateway.platforms.base.AUDIO_CACHE_DIR", tmp_path / "audio_cache"
)
# ---------------------------------------------------------------------------
# TestDocumentTypeDetection
# ---------------------------------------------------------------------------
class TestDocumentTypeDetection:
@pytest.mark.asyncio
async def test_document_detected_explicitly(self, adapter):
doc = _make_document()
msg = _make_message(document=doc)
update = _make_update(msg)
await adapter._handle_media_message(update, MagicMock())
event = adapter.handle_message.call_args[0][0]
assert event.message_type == MessageType.DOCUMENT
# ---------------------------------------------------------------------------
# TestDocumentDownloadBlock
# ---------------------------------------------------------------------------
def _make_photo(file_obj=None):
photo = MagicMock()
photo.get_file = AsyncMock(return_value=file_obj or _make_file_obj(b"photo-bytes"))
return photo
class TestDocumentDownloadBlock:
@pytest.mark.asyncio
async def test_supported_txt_injects_content(self, adapter):
content = b"Hello from a text file"
file_obj = _make_file_obj(content)
doc = _make_document(
file_name="notes.txt", mime_type="text/plain",
file_size=len(content), file_obj=file_obj,
)
msg = _make_message(document=doc)
update = _make_update(msg)
await adapter._handle_media_message(update, MagicMock())
event = adapter.handle_message.call_args[0][0]
assert "Hello from a text file" in event.text
assert "[Content of notes.txt]" in event.text
@pytest.mark.asyncio
async def test_supported_md_injects_content(self, adapter):
content = b"# Title\nSome markdown"
file_obj = _make_file_obj(content)
doc = _make_document(
file_name="readme.md", mime_type="text/markdown",
file_size=len(content), file_obj=file_obj,
)
msg = _make_message(document=doc)
update = _make_update(msg)
await adapter._handle_media_message(update, MagicMock())
event = adapter.handle_message.call_args[0][0]
assert "# Title" in event.text
@pytest.mark.asyncio
async def test_caption_preserved_with_injection(self, adapter):
content = b"file text"
file_obj = _make_file_obj(content)
doc = _make_document(
file_name="doc.txt", mime_type="text/plain",
file_size=len(content), file_obj=file_obj,
)
msg = _make_message(document=doc, caption="Please summarize")
update = _make_update(msg)
await adapter._handle_media_message(update, MagicMock())
event = adapter.handle_message.call_args[0][0]
assert "file text" in event.text
assert "Please summarize" in event.text
@pytest.mark.asyncio
async def test_text_injection_capped(self, adapter):
"""A .txt file over 100 KB should NOT have its content injected."""
large = b"x" * (200 * 1024) # 200 KB
file_obj = _make_file_obj(large)
doc = _make_document(
file_name="big.txt", mime_type="text/plain",
file_size=len(large), file_obj=file_obj,
)
msg = _make_message(document=doc)
update = _make_update(msg)
await adapter._handle_media_message(update, MagicMock())
event = adapter.handle_message.call_args[0][0]
# File should be cached
assert len(event.media_urls) == 1
# Content should NOT be injected
assert "[Content of" not in (event.text or "")
@pytest.mark.asyncio
async def test_document_cache_failure_replies_and_signals_agent(self, adapter):
"""A failed document download must surface on BOTH ends, not silently.
Regression for #23045 Bug 2: a CDN download/cache failure used to log a
warning and fall through to an empty agent turn — user thinks the file
arrived, agent sees nothing. Now the user gets a Telegram reply AND the
agent's event.text carries an attempted-attachment notice.
"""
doc = _make_document(file_name="notes.md", mime_type="text/markdown", file_size=100)
doc.get_file = AsyncMock(side_effect=RuntimeError("Telegram CDN down"))
msg = _make_message(document=doc)
update = _make_update(msg)
await adapter._handle_media_message(update, MagicMock())
# 1. User is told the download failed, with the filename + exception type.
msg.reply_text.assert_awaited_once()
reply = msg.reply_text.await_args.args[0]
assert "Couldn't download" in reply
assert "notes.md" in reply
assert "RuntimeError" in reply
# 2. The agent still gets a turn, but event.text now carries a notice so
# it knows an attachment was attempted and failed (not a silent empty turn).
adapter.handle_message.assert_called_once()
event = adapter.handle_message.call_args[0][0]
assert event.media_urls == [] # nothing cached
assert "could not be downloaded" in (event.text or "")
assert "notes.md" in (event.text or "")
@pytest.mark.asyncio
async def test_voice_cache_failure_replies_and_signals_agent(self, adapter):
"""Same fail-closed contract applies to the voice site (#23045 Bug 2 class)."""
msg = _make_message()
msg.voice = MagicMock()
msg.voice.file_size = 100
msg.voice.get_file = AsyncMock(side_effect=RuntimeError("CDN down"))
update = _make_update(msg)
await adapter._handle_media_message(update, MagicMock())
msg.reply_text.assert_awaited_once()
assert "voice message" in msg.reply_text.await_args.args[0]
adapter.handle_message.assert_called_once()
event = adapter.handle_message.call_args[0][0]
assert "could not be downloaded" in (event.text or "")
class TestVideoDownloadBlock:
@pytest.mark.asyncio
async def test_native_video_is_cached(self, adapter):
file_obj = _make_file_obj(b"fake-mp4")
file_obj.file_path = "videos/clip.mp4"
msg = _make_message()
msg.video = _make_video(file_obj)
update = _make_update(msg)
await adapter._handle_media_message(update, MagicMock())
event = adapter.handle_message.call_args[0][0]
assert event.message_type == MessageType.VIDEO
assert len(event.media_urls) == 1
assert os.path.exists(event.media_urls[0])
assert event.media_types == [SUPPORTED_VIDEO_TYPES[".mp4"]]
# ---------------------------------------------------------------------------
# TestMediaGroups — media group (album) buffering
# ---------------------------------------------------------------------------
class TestMediaGroups:
@pytest.mark.asyncio
async def test_non_album_photo_burst_is_buffered_and_combined(self, adapter):
first_photo = _make_photo(_make_file_obj(b"first"))
second_photo = _make_photo(_make_file_obj(b"second"))
msg1 = _make_message(caption="two images", photo=[first_photo])
msg2 = _make_message(photo=[second_photo])
with patch("plugins.platforms.telegram.adapter.cache_image_from_bytes", side_effect=["/tmp/burst-one.jpg", "/tmp/burst-two.jpg"]):
await adapter._handle_media_message(_make_update(msg1), MagicMock())
await adapter._handle_media_message(_make_update(msg2), MagicMock())
assert adapter.handle_message.await_count == 0
await asyncio.sleep(adapter.MEDIA_GROUP_WAIT_SECONDS + 0.05)
adapter.handle_message.assert_awaited_once()
event = adapter.handle_message.await_args.args[0]
assert event.text == "two images"
assert event.media_urls == ["/tmp/burst-one.jpg", "/tmp/burst-two.jpg"]
assert len(event.media_types) == 2
# ---------------------------------------------------------------------------
# TestSendVoice — outbound audio delivery
# ---------------------------------------------------------------------------
class TestSendVoice:
"""Tests for TelegramAdapter.send_voice() routing across audio formats."""
@pytest.fixture()
def connected_adapter(self, adapter):
"""Adapter with a mock bot attached."""
bot = AsyncMock()
adapter._bot = bot
return adapter
@pytest.mark.asyncio
async def test_flac_falls_back_to_document(self, connected_adapter, tmp_path):
"""Telegram sendAudio does not accept FLAC — must fall back to sendDocument."""
audio_file = tmp_path / "clip.flac"
audio_file.write_bytes(b"fLaC" + b"\x00" * 32)
mock_msg = MagicMock()
mock_msg.message_id = 101
connected_adapter._bot.send_voice = AsyncMock()
connected_adapter._bot.send_audio = AsyncMock()
connected_adapter._bot.send_document = AsyncMock(return_value=mock_msg)
result = await connected_adapter.send_voice(
chat_id="12345",
audio_path=str(audio_file),
caption="Audio",
)
assert result.success is True
assert result.message_id == "101"
connected_adapter._bot.send_document.assert_awaited_once()
connected_adapter._bot.send_audio.assert_not_awaited()
connected_adapter._bot.send_voice.assert_not_awaited()
# ---------------------------------------------------------------------------
# TestSendDocument — outbound file attachment delivery
# ---------------------------------------------------------------------------
class TestSendDocument:
"""Tests for TelegramAdapter.send_document() — sending files to users."""
@pytest.fixture()
def connected_adapter(self, adapter):
"""Adapter with a mock bot attached."""
bot = AsyncMock()
adapter._bot = bot
return adapter
@pytest.mark.asyncio
async def test_send_document_success(self, connected_adapter, tmp_path):
"""A local file is sent via bot.send_document and returns success."""
# Create a real temp file
test_file = tmp_path / "report.pdf"
test_file.write_bytes(b"%PDF-1.4 fake content")
mock_msg = MagicMock()
mock_msg.message_id = 99
connected_adapter._bot.send_document = AsyncMock(return_value=mock_msg)
result = await connected_adapter.send_document(
chat_id="12345",
file_path=str(test_file),
caption="Here's the report",
)
assert result.success is True
assert result.message_id == "99"
connected_adapter._bot.send_document.assert_called_once()
call_kwargs = connected_adapter._bot.send_document.call_args[1]
assert call_kwargs["chat_id"] == 12345
assert call_kwargs["filename"] == "report.pdf"
assert call_kwargs["caption"] == "Here's the report"
@pytest.mark.asyncio
async def test_send_document_custom_filename(self, connected_adapter, tmp_path):
"""The file_name parameter overrides the basename for display."""
test_file = tmp_path / "doc_abc123_ugly.csv"
test_file.write_bytes(b"a,b,c\n1,2,3")
mock_msg = MagicMock()
mock_msg.message_id = 100
connected_adapter._bot.send_document = AsyncMock(return_value=mock_msg)
result = await connected_adapter.send_document(
chat_id="12345",
file_path=str(test_file),
file_name="clean_data.csv",
)
assert result.success is True
call_kwargs = connected_adapter._bot.send_document.call_args[1]
assert call_kwargs["filename"] == "clean_data.csv"
@pytest.mark.asyncio
async def test_send_document_caption_truncated(self, connected_adapter, tmp_path):
"""Captions longer than 1024 chars are truncated."""
test_file = tmp_path / "data.json"
test_file.write_bytes(b"{}")
mock_msg = MagicMock()
mock_msg.message_id = 101
connected_adapter._bot.send_document = AsyncMock(return_value=mock_msg)
long_caption = "x" * 2000
await connected_adapter.send_document(
chat_id="12345",
file_path=str(test_file),
caption=long_caption,
)
call_kwargs = connected_adapter._bot.send_document.call_args[1]
assert len(call_kwargs["caption"]) == 1024
@pytest.mark.asyncio
async def test_send_document_api_error_falls_back(self, connected_adapter, tmp_path):
"""If Telegram API raises, falls back to base class text message."""
test_file = tmp_path / "file.pdf"
test_file.write_bytes(b"data")
connected_adapter._bot.send_document = AsyncMock(
side_effect=RuntimeError("Telegram API error")
)
# The base fallback calls self.send() which is also on _bot, so mock it
# to avoid cascading errors.
connected_adapter.send = AsyncMock(
return_value=SendResult(success=True, message_id="fallback")
)
result = await connected_adapter.send_document(
chat_id="12345",
file_path=str(test_file),
)
# Should have fallen back to base class
assert result.success is True
assert result.message_id == "fallback"
class TestTelegramPhotoBatching:
@pytest.mark.asyncio
async def test_flush_photo_batch_does_not_drop_newer_scheduled_task(self, adapter):
old_task = MagicMock()
new_task = MagicMock()
batch_key = "session:photo-burst"
adapter._pending_photo_batch_tasks[batch_key] = new_task
adapter._pending_photo_batches[batch_key] = MessageEvent(
text="",
message_type=MessageType.PHOTO,
source=SimpleNamespace(channel_id="chat-1"),
media_urls=["/tmp/a.jpg"],
media_types=["image/jpeg"],
)
with (
patch("plugins.platforms.telegram.adapter.asyncio.current_task", return_value=old_task),
patch("plugins.platforms.telegram.adapter.asyncio.sleep", new=AsyncMock()),
):
await adapter._flush_photo_batch(batch_key)
assert adapter._pending_photo_batch_tasks[batch_key] is new_task
# ---------------------------------------------------------------------------
# TestSendVideo — outbound video delivery
# ---------------------------------------------------------------------------
class TestSendVideo:
"""Tests for TelegramAdapter.send_video() — sending videos to users."""
@pytest.fixture()
def connected_adapter(self, adapter):
bot = AsyncMock()
adapter._bot = bot
return adapter
@pytest.mark.asyncio
async def test_send_video_success(self, connected_adapter, tmp_path):
test_file = tmp_path / "clip.mp4"
test_file.write_bytes(b"\x00\x00\x00\x1c" + b"ftyp" + b"\x00" * 100)
mock_msg = MagicMock()
mock_msg.message_id = 200
connected_adapter._bot.send_video = AsyncMock(return_value=mock_msg)
result = await connected_adapter.send_video(
chat_id="12345",
video_path=str(test_file),
caption="Check this out",
)
assert result.success is True
assert result.message_id == "200"
connected_adapter._bot.send_video.assert_called_once()
@pytest.mark.asyncio
async def test_send_video_thread_id(self, connected_adapter, tmp_path):
"""metadata thread_id is forwarded as message_thread_id (required for Telegram forum groups)."""
test_file = tmp_path / "clip.mp4"
test_file.write_bytes(b"\x00\x00\x00\x1c" + b"ftyp" + b"\x00" * 100)
mock_msg = MagicMock()
mock_msg.message_id = 201
connected_adapter._bot.send_video = AsyncMock(return_value=mock_msg)
await connected_adapter.send_video(
chat_id="12345",
video_path=str(test_file),
metadata={"thread_id": "789"},
)
call_kwargs = connected_adapter._bot.send_video.call_args[1]
assert call_kwargs["message_thread_id"] == 789