1979 lines
67 KiB
Python
1979 lines
67 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
Telegram Channel Unit Tests
|
|
|
|
Comprehensive unit tests for TelegramChannel covering:
|
|
- Initialization and configuration
|
|
- Factory methods (from_env, from_config)
|
|
- Message chunking and formatting
|
|
- Typing indicators
|
|
- Send methods (text and media)
|
|
- Session resolution and routing
|
|
- File download and handling
|
|
- Media sending with error handling
|
|
|
|
Test Patterns:
|
|
- Uses tmp_path fixture for temporary files
|
|
- Uses AsyncMock for async method mocking
|
|
- Only uses @pytest.mark.asyncio on async test methods (no global pytestmark)
|
|
|
|
Run:
|
|
pytest tests/unit/channels/test_telegram.py -v
|
|
pytest tests/unit/channels/test_telegram.py::TestTelegramChannelInit -v
|
|
"""
|
|
# pylint: disable=redefined-outer-name,protected-access,unused-argument
|
|
# pylint: disable=broad-exception-raised,using-constant-test
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from pathlib import Path
|
|
from typing import Generator
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from pineagents.app.channels.renderer import ChannelDisplayConfig
|
|
|
|
from pineagents.schemas import (
|
|
TextContent,
|
|
ImageContent,
|
|
VideoContent,
|
|
AudioContent,
|
|
FileContent,
|
|
ContentType,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Fixtures
|
|
# =============================================================================
|
|
|
|
|
|
def test_telegram_base_urls_derive_api_and_file_prefixes():
|
|
"""Custom root URL should derive Bot API and file API prefixes."""
|
|
from pineagents.app.channels.telegram.channel import _telegram_base_urls
|
|
|
|
assert _telegram_base_urls("") == ("", "")
|
|
assert _telegram_base_urls(" https://tg-api.example.com/ ") == (
|
|
"https://tg-api.example.com/bot",
|
|
"https://tg-api.example.com/file/bot",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_process_handler() -> AsyncMock:
|
|
"""Mock process handler that yields simple events."""
|
|
|
|
async def mock_process(*_args, **_kwargs):
|
|
mock_event = MagicMock()
|
|
mock_event.object = "message"
|
|
mock_event.status = "completed"
|
|
mock_event.type = "text"
|
|
yield mock_event
|
|
|
|
return AsyncMock(side_effect=mock_process)
|
|
|
|
|
|
@pytest.fixture
|
|
def telegram_channel(
|
|
mock_process_handler,
|
|
tmp_path: Path,
|
|
) -> Generator:
|
|
"""Create a TelegramChannel instance for testing."""
|
|
from pineagents.app.channels.telegram.channel import TelegramChannel
|
|
|
|
channel = TelegramChannel(
|
|
process=mock_process_handler,
|
|
enabled=True,
|
|
bot_token="test_bot_token_12345",
|
|
http_proxy="",
|
|
http_proxy_auth="",
|
|
bot_prefix="[TestBot] ",
|
|
on_reply_sent=None,
|
|
media_dir=str(tmp_path / "media"),
|
|
workspace_dir=tmp_path / "workspace",
|
|
show_typing=True,
|
|
display_config=ChannelDisplayConfig(
|
|
show_tool_calls=True,
|
|
show_tool_results=True,
|
|
),
|
|
dm_policy="open",
|
|
group_policy="open",
|
|
allow_from=None,
|
|
deny_message="",
|
|
require_mention=False,
|
|
)
|
|
yield channel
|
|
|
|
|
|
@pytest.fixture
|
|
def disabled_telegram_channel(
|
|
mock_process_handler,
|
|
tmp_path: Path,
|
|
) -> Generator:
|
|
"""Create a disabled TelegramChannel instance."""
|
|
from pineagents.app.channels.telegram.channel import TelegramChannel
|
|
|
|
channel = TelegramChannel(
|
|
process=mock_process_handler,
|
|
enabled=False,
|
|
bot_token="",
|
|
http_proxy="",
|
|
http_proxy_auth="",
|
|
bot_prefix="",
|
|
)
|
|
yield channel
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_telegram_bot() -> MagicMock:
|
|
"""Create a mock Telegram bot."""
|
|
bot = MagicMock()
|
|
bot.username = "test_bot"
|
|
bot.id = 123456789
|
|
bot.send_message = AsyncMock()
|
|
bot.send_chat_action = AsyncMock()
|
|
bot.send_photo = AsyncMock()
|
|
bot.send_video = AsyncMock()
|
|
bot.send_audio = AsyncMock()
|
|
bot.send_document = AsyncMock()
|
|
return bot
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_telegram_file() -> MagicMock:
|
|
"""Create a mock Telegram file."""
|
|
tg_file = MagicMock()
|
|
tg_file.file_path = "photos/test_file.jpg"
|
|
tg_file.download_to_drive = AsyncMock()
|
|
return tg_file
|
|
|
|
|
|
# =============================================================================
|
|
# P0: Initialization and Configuration
|
|
# =============================================================================
|
|
|
|
|
|
class TestTelegramChannelInit:
|
|
"""
|
|
Tests for TelegramChannel initialization.
|
|
Verifies correct storage of configuration parameters.
|
|
"""
|
|
|
|
def test_init_stores_basic_config(
|
|
self,
|
|
mock_process_handler,
|
|
tmp_path: Path,
|
|
):
|
|
"""Constructor should store all basic configuration parameters."""
|
|
from pineagents.app.channels.telegram.channel import TelegramChannel
|
|
|
|
channel = TelegramChannel(
|
|
process=mock_process_handler,
|
|
enabled=True,
|
|
bot_token="my_bot_token",
|
|
http_proxy="http://proxy.example.com:8080",
|
|
http_proxy_auth="user:pass",
|
|
bot_prefix="[MyBot] ",
|
|
media_dir=str(tmp_path / "custom_media"),
|
|
show_typing=False,
|
|
)
|
|
|
|
assert channel.enabled is True
|
|
assert channel._bot_token == "my_bot_token"
|
|
assert channel._http_proxy == "http://proxy.example.com:8080"
|
|
assert channel._http_proxy_auth == "user:pass"
|
|
assert channel.bot_prefix == "[MyBot] "
|
|
assert channel._show_typing is False
|
|
assert channel.channel == "telegram"
|
|
|
|
def test_init_stores_policy_config(
|
|
self,
|
|
mock_process_handler,
|
|
tmp_path: Path,
|
|
):
|
|
"""Constructor should store policy configuration."""
|
|
from pineagents.app.channels.telegram.channel import TelegramChannel
|
|
|
|
channel = TelegramChannel(
|
|
process=mock_process_handler,
|
|
enabled=True,
|
|
bot_token="token",
|
|
http_proxy="",
|
|
http_proxy_auth="",
|
|
bot_prefix="",
|
|
dm_policy="restricted",
|
|
group_policy="restricted",
|
|
allow_from=["user1", "user2"],
|
|
deny_message="Access denied",
|
|
require_mention=True,
|
|
)
|
|
|
|
assert channel.dm_policy == "restricted"
|
|
assert channel.group_policy == "restricted"
|
|
assert channel.allow_from == {"user1", "user2"}
|
|
assert channel.deny_message == "Access denied"
|
|
assert channel.require_mention is True
|
|
|
|
def test_init_creates_internal_data_structures(
|
|
self,
|
|
mock_process_handler,
|
|
):
|
|
"""Constructor should initialize required internal data structures."""
|
|
from pineagents.app.channels.telegram.channel import TelegramChannel
|
|
|
|
channel = TelegramChannel(
|
|
process=mock_process_handler,
|
|
enabled=True,
|
|
bot_token="token",
|
|
http_proxy="",
|
|
http_proxy_auth="",
|
|
bot_prefix="",
|
|
)
|
|
|
|
# Typing tasks dict
|
|
assert hasattr(channel, "_typing_tasks")
|
|
assert isinstance(channel._typing_tasks, dict)
|
|
assert len(channel._typing_tasks) == 0
|
|
|
|
# Task placeholder
|
|
assert hasattr(channel, "_task")
|
|
assert channel._task is None
|
|
|
|
# Application placeholder
|
|
assert hasattr(channel, "_application")
|
|
|
|
def test_channel_type_is_telegram(self, telegram_channel):
|
|
"""Channel type must be 'telegram'."""
|
|
assert telegram_channel.channel == "telegram"
|
|
|
|
def test_init_disabled_without_token(
|
|
self,
|
|
mock_process_handler,
|
|
caplog,
|
|
):
|
|
"""Channel should log info when enabled but token is empty."""
|
|
import logging
|
|
from pineagents.app.channels.telegram.channel import TelegramChannel
|
|
|
|
with caplog.at_level(logging.INFO):
|
|
channel = TelegramChannel(
|
|
process=mock_process_handler,
|
|
enabled=True,
|
|
bot_token="",
|
|
http_proxy="",
|
|
http_proxy_auth="",
|
|
bot_prefix="",
|
|
)
|
|
|
|
assert channel.enabled is True
|
|
# Check that channel was created successfully
|
|
assert channel.channel == "telegram"
|
|
assert channel._bot_token == ""
|
|
|
|
def test_uses_manager_queue_is_true(self, telegram_channel):
|
|
"""uses_manager_queue should be True for queue-based processing."""
|
|
assert telegram_channel.uses_manager_queue is True
|
|
|
|
|
|
# =============================================================================
|
|
# P0: Factory Methods
|
|
# =============================================================================
|
|
|
|
|
|
class TestTelegramChannelFromEnv:
|
|
"""Tests for from_env factory method."""
|
|
|
|
def test_from_env_reads_basic_env_vars(
|
|
self,
|
|
mock_process_handler,
|
|
monkeypatch,
|
|
):
|
|
"""from_env should read basic environment variables."""
|
|
from pineagents.app.channels.telegram.channel import TelegramChannel
|
|
|
|
monkeypatch.setenv("TELEGRAM_CHANNEL_ENABLED", "1")
|
|
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "env_bot_token")
|
|
monkeypatch.setenv("TELEGRAM_HTTP_PROXY", "http://env.proxy:8080")
|
|
monkeypatch.setenv("TELEGRAM_HTTP_PROXY_AUTH", "env_user:env_pass")
|
|
monkeypatch.setenv("TELEGRAM_BOT_PREFIX", "[EnvBot] ")
|
|
monkeypatch.setenv("TELEGRAM_SHOW_TYPING", "0")
|
|
|
|
channel = TelegramChannel.from_env(mock_process_handler)
|
|
|
|
assert channel.enabled is True
|
|
assert channel._bot_token == "env_bot_token"
|
|
assert channel._http_proxy == "http://env.proxy:8080"
|
|
assert channel._http_proxy_auth == "env_user:env_pass"
|
|
assert channel.bot_prefix == "[EnvBot] "
|
|
assert channel._show_typing is False
|
|
|
|
def test_from_env_reads_policy_vars(
|
|
self,
|
|
mock_process_handler,
|
|
monkeypatch,
|
|
):
|
|
"""from_env should read policy environment variables."""
|
|
from pineagents.app.channels.telegram.channel import TelegramChannel
|
|
|
|
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "token")
|
|
monkeypatch.setenv("TELEGRAM_DM_POLICY", "restricted")
|
|
monkeypatch.setenv("TELEGRAM_GROUP_POLICY", "restricted")
|
|
monkeypatch.setenv("TELEGRAM_ALLOW_FROM", "user1,user2,user3")
|
|
monkeypatch.setenv("TELEGRAM_DENY_MESSAGE", "Custom deny message")
|
|
monkeypatch.setenv("TELEGRAM_REQUIRE_MENTION", "1")
|
|
|
|
channel = TelegramChannel.from_env(mock_process_handler)
|
|
|
|
assert channel.dm_policy == "restricted"
|
|
assert channel.group_policy == "restricted"
|
|
assert channel.allow_from == {"user1", "user2", "user3"}
|
|
assert channel.deny_message == "Custom deny message"
|
|
assert channel.require_mention is True
|
|
|
|
def test_from_env_allow_from_parsing(
|
|
self,
|
|
mock_process_handler,
|
|
monkeypatch,
|
|
):
|
|
"""from_env should parse TELEGRAM_ALLOW_FROM with whitespace."""
|
|
from pineagents.app.channels.telegram.channel import TelegramChannel
|
|
|
|
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "token")
|
|
monkeypatch.setenv("TELEGRAM_ALLOW_FROM", " user1 , user2 , user3 ")
|
|
|
|
channel = TelegramChannel.from_env(mock_process_handler)
|
|
|
|
assert "user1" in channel.allow_from
|
|
assert "user2" in channel.allow_from
|
|
assert "user3" in channel.allow_from
|
|
|
|
def test_from_env_empty_allow_from(
|
|
self,
|
|
mock_process_handler,
|
|
monkeypatch,
|
|
):
|
|
"""from_env should handle empty TELEGRAM_ALLOW_FROM."""
|
|
from pineagents.app.channels.telegram.channel import TelegramChannel
|
|
|
|
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "token")
|
|
monkeypatch.setenv("TELEGRAM_ALLOW_FROM", "")
|
|
|
|
channel = TelegramChannel.from_env(mock_process_handler)
|
|
|
|
assert channel.allow_from == set()
|
|
|
|
def test_from_env_defaults(
|
|
self,
|
|
mock_process_handler,
|
|
monkeypatch,
|
|
):
|
|
"""from_env should use sensible defaults."""
|
|
from pineagents.app.channels.telegram.channel import TelegramChannel
|
|
|
|
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "token")
|
|
monkeypatch.delenv("TELEGRAM_CHANNEL_ENABLED", raising=False)
|
|
monkeypatch.delenv("TELEGRAM_SHOW_TYPING", raising=False)
|
|
monkeypatch.delenv("TELEGRAM_DM_POLICY", raising=False)
|
|
|
|
channel = TelegramChannel.from_env(mock_process_handler)
|
|
|
|
assert channel.enabled is False # Default disabled
|
|
assert channel._show_typing is True # Default True
|
|
assert channel.dm_policy == "open" # Default open
|
|
|
|
|
|
class TestTelegramChannelFromConfig:
|
|
"""Tests for from_config factory method."""
|
|
|
|
def test_from_config_uses_config_values(
|
|
self,
|
|
mock_process_handler,
|
|
):
|
|
"""from_config should use values from config dict."""
|
|
from pineagents.app.channels.telegram.channel import TelegramChannel
|
|
|
|
config = {
|
|
"enabled": True,
|
|
"bot_token": "config_token",
|
|
"http_proxy": "http://config.proxy:8080",
|
|
"http_proxy_auth": "config_user:config_pass",
|
|
"bot_prefix": "[ConfigBot] ",
|
|
"show_typing": False,
|
|
"dm_policy": "restricted",
|
|
"group_policy": "restricted",
|
|
"allow_from": ["user1", "user2"],
|
|
"deny_message": "Config deny message",
|
|
"require_mention": True,
|
|
}
|
|
|
|
channel = TelegramChannel.from_config(
|
|
process=mock_process_handler,
|
|
config=config,
|
|
)
|
|
|
|
assert channel.enabled is True
|
|
assert channel._bot_token == "config_token"
|
|
assert channel._base_url == ""
|
|
assert channel._http_proxy == "http://config.proxy:8080"
|
|
assert channel._http_proxy_auth == "config_user:config_pass"
|
|
assert channel.bot_prefix == "[ConfigBot]"
|
|
assert channel._show_typing is False
|
|
assert channel.dm_policy == "restricted"
|
|
assert channel.group_policy == "restricted"
|
|
assert channel.allow_from == {"user1", "user2"}
|
|
assert channel.deny_message == "Config deny message"
|
|
assert channel.require_mention is True
|
|
|
|
def test_from_config_with_config_object(
|
|
self,
|
|
mock_process_handler,
|
|
):
|
|
"""from_config should work with TelegramConfig object."""
|
|
from pineagents.app.channels.telegram.channel import TelegramChannel
|
|
from pineagents.config.config import TelegramConfig
|
|
|
|
config = TelegramConfig(
|
|
enabled=True,
|
|
bot_token="obj_token",
|
|
http_proxy="",
|
|
http_proxy_auth="",
|
|
bot_prefix="[Obj] ",
|
|
)
|
|
|
|
channel = TelegramChannel.from_config(
|
|
process=mock_process_handler,
|
|
config=config,
|
|
)
|
|
|
|
assert channel._bot_token == "obj_token"
|
|
assert channel.bot_prefix == "[Obj]"
|
|
|
|
def test_from_config_uses_base_url(
|
|
self,
|
|
mock_process_handler,
|
|
):
|
|
"""from_config should pass custom Telegram API root URL."""
|
|
from pineagents.app.channels.telegram.channel import TelegramChannel
|
|
|
|
channel = TelegramChannel.from_config(
|
|
process=mock_process_handler,
|
|
config={
|
|
"bot_token": "config_token",
|
|
"base_url": " https://tg-api.example.com/ ",
|
|
},
|
|
)
|
|
|
|
assert channel._base_url == "https://tg-api.example.com"
|
|
|
|
def test_from_config_defaults(
|
|
self,
|
|
mock_process_handler,
|
|
):
|
|
"""from_config should use defaults for missing values."""
|
|
from pineagents.app.channels.telegram.channel import TelegramChannel
|
|
|
|
config = {
|
|
"bot_token": "token_only",
|
|
}
|
|
|
|
channel = TelegramChannel.from_config(
|
|
process=mock_process_handler,
|
|
config=config,
|
|
)
|
|
|
|
assert channel.enabled is False # Default
|
|
assert channel.dm_policy == "open" # Default
|
|
assert channel.group_policy == "open" # Default
|
|
assert channel.require_mention is False # Default
|
|
|
|
|
|
# =============================================================================
|
|
# P1: Text Chunking
|
|
# =============================================================================
|
|
|
|
|
|
class TestTelegramChunkText:
|
|
"""Tests for _chunk_text method."""
|
|
|
|
def test_chunk_text_empty(self, telegram_channel):
|
|
"""Empty text should return empty list."""
|
|
result = telegram_channel._chunk_text("")
|
|
assert result == []
|
|
|
|
def test_chunk_text_short(self, telegram_channel):
|
|
"""Short text should return single chunk."""
|
|
text = "Hello world"
|
|
result = telegram_channel._chunk_text(text)
|
|
assert result == ["Hello world"]
|
|
|
|
def test_chunk_text_exact_size(self, telegram_channel):
|
|
"""Text at exactly chunk size should return single chunk."""
|
|
text = "A" * 4000
|
|
result = telegram_channel._chunk_text(text)
|
|
assert len(result) == 1
|
|
assert len(result[0]) == 4000
|
|
|
|
def test_chunk_text_long_splits(self, telegram_channel):
|
|
"""Long text should be split into chunks."""
|
|
text = "A" * 5000
|
|
result = telegram_channel._chunk_text(text)
|
|
assert len(result) > 1
|
|
assert all(len(chunk) <= 4000 for chunk in result)
|
|
|
|
def test_chunk_text_respects_newlines(self, telegram_channel):
|
|
"""Split should prefer newline boundaries."""
|
|
text = "Line 1\n" * 1000 # Many lines
|
|
result = telegram_channel._chunk_text(text)
|
|
# Each chunk should be at chunk size or less
|
|
assert all(len(chunk) <= 4000 for chunk in result)
|
|
|
|
def test_chunk_text_respects_spaces(self, telegram_channel):
|
|
"""Split should fallback to space boundaries."""
|
|
text = "word " * 2000 # Many words, no newlines
|
|
result = telegram_channel._chunk_text(text)
|
|
assert all(len(chunk) <= 4000 for chunk in result)
|
|
# First chunk should end with space
|
|
assert result[0].endswith(" ")
|
|
|
|
def test_chunk_text_no_good_boundary(self, telegram_channel):
|
|
"""Long word without spaces should be hard split."""
|
|
text = "A" * 5000 # Single long word
|
|
result = telegram_channel._chunk_text(text)
|
|
assert len(result) > 1
|
|
assert all(len(chunk) <= 4000 for chunk in result)
|
|
|
|
|
|
# =============================================================================
|
|
# P1: Typing Indicators
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestTelegramTypingIndicators:
|
|
"""Tests for typing indicator methods."""
|
|
|
|
async def test_send_chat_action_disabled_channel(
|
|
self,
|
|
disabled_telegram_channel,
|
|
):
|
|
"""send_chat_action should return early when disabled."""
|
|
await disabled_telegram_channel._send_chat_action("12345", "typing")
|
|
# Should not raise
|
|
|
|
async def test_send_chat_action_no_application(self, telegram_channel):
|
|
"""send_chat_action should return early when no application."""
|
|
telegram_channel._application = None
|
|
await telegram_channel._send_chat_action("12345", "typing")
|
|
# Should not raise
|
|
|
|
async def test_send_chat_action_success(
|
|
self,
|
|
telegram_channel,
|
|
mock_telegram_bot,
|
|
):
|
|
"""send_chat_action should send chat action via bot."""
|
|
telegram_channel._application = MagicMock()
|
|
telegram_channel._application.bot = mock_telegram_bot
|
|
|
|
await telegram_channel._send_chat_action("12345", "typing")
|
|
|
|
mock_telegram_bot.send_chat_action.assert_called_once_with(
|
|
chat_id="12345",
|
|
action="typing",
|
|
)
|
|
|
|
async def test_send_chat_action_handles_exception(self, telegram_channel):
|
|
"""send_chat_action should handle exceptions gracefully."""
|
|
telegram_channel._application = MagicMock()
|
|
telegram_channel._application.bot = MagicMock()
|
|
telegram_channel._application.bot.send_chat_action = AsyncMock(
|
|
side_effect=Exception("Network error"),
|
|
)
|
|
|
|
# Should not raise
|
|
await telegram_channel._send_chat_action("12345", "typing")
|
|
|
|
def test_start_typing_creates_task(self, telegram_channel):
|
|
"""_start_typing should create typing task."""
|
|
telegram_channel._show_typing = True
|
|
telegram_channel._application = MagicMock()
|
|
|
|
with patch("asyncio.create_task") as mock_create_task:
|
|
mock_task = MagicMock()
|
|
mock_create_task.return_value = mock_task
|
|
|
|
telegram_channel._start_typing("12345")
|
|
|
|
assert "12345" in telegram_channel._typing_tasks
|
|
mock_create_task.assert_called_once()
|
|
|
|
def test_start_typing_disabled(self, telegram_channel):
|
|
"""_start_typing should do nothing when show_typing is False."""
|
|
telegram_channel._show_typing = False
|
|
|
|
with patch("asyncio.create_task") as mock_create_task:
|
|
telegram_channel._start_typing("12345")
|
|
mock_create_task.assert_not_called()
|
|
|
|
def test_start_typing_replaces_existing(self, telegram_channel):
|
|
"""_start_typing should replace existing typing task."""
|
|
telegram_channel._show_typing = True
|
|
old_task = MagicMock()
|
|
old_task.done.return_value = False
|
|
telegram_channel._typing_tasks["12345"] = old_task
|
|
|
|
with patch("asyncio.create_task") as mock_create_task:
|
|
new_task = MagicMock()
|
|
mock_create_task.return_value = new_task
|
|
|
|
telegram_channel._start_typing("12345")
|
|
|
|
old_task.cancel.assert_called_once()
|
|
assert telegram_channel._typing_tasks["12345"] is new_task
|
|
|
|
def test_stop_typing_cancels_task(self, telegram_channel):
|
|
"""_stop_typing should cancel typing task."""
|
|
mock_task = MagicMock()
|
|
mock_task.done.return_value = False
|
|
telegram_channel._typing_tasks["12345"] = mock_task
|
|
|
|
telegram_channel._stop_typing("12345")
|
|
|
|
mock_task.cancel.assert_called_once()
|
|
assert "12345" not in telegram_channel._typing_tasks
|
|
|
|
def test_stop_typing_no_task(self, telegram_channel):
|
|
"""_stop_typing should handle missing task."""
|
|
# Should not raise
|
|
telegram_channel._stop_typing("99999")
|
|
|
|
async def test_typing_loop_sends_typing(
|
|
self,
|
|
telegram_channel,
|
|
mock_telegram_bot,
|
|
):
|
|
"""_typing_loop should send typing action periodically."""
|
|
telegram_channel._application = MagicMock()
|
|
telegram_channel._application.bot = mock_telegram_bot
|
|
|
|
# Mock current_task in typing_tasks
|
|
task = asyncio.create_task(
|
|
telegram_channel._typing_loop("12345"),
|
|
)
|
|
telegram_channel._typing_tasks["12345"] = task
|
|
|
|
# Let it run briefly then cancel
|
|
await asyncio.sleep(0.05)
|
|
task.cancel()
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
# Bot should have been called with typing action
|
|
mock_telegram_bot.send_chat_action.assert_called()
|
|
|
|
|
|
# =============================================================================
|
|
# P1: Send Messages
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestTelegramSend:
|
|
"""Tests for send method."""
|
|
|
|
async def test_send_disabled_channel(self, disabled_telegram_channel):
|
|
"""send should return early when channel is disabled."""
|
|
result = await disabled_telegram_channel.send("12345", "Hello", {})
|
|
assert result is None
|
|
|
|
async def test_send_no_application(self, telegram_channel):
|
|
"""send should return early when no application."""
|
|
telegram_channel._application = None
|
|
result = await telegram_channel.send("12345", "Hello", {})
|
|
assert result is None
|
|
|
|
async def test_send_no_chat_id(self, telegram_channel):
|
|
"""send should return early when no chat_id."""
|
|
telegram_channel._application = MagicMock()
|
|
telegram_channel._application.bot = MagicMock()
|
|
|
|
result = await telegram_channel.send("", "Hello", {})
|
|
assert result is None
|
|
|
|
result = await telegram_channel.send("", "Hello", {"chat_id": ""})
|
|
assert result is None
|
|
|
|
async def test_send_success(self, telegram_channel, mock_telegram_bot):
|
|
"""send should send message via bot."""
|
|
telegram_channel._application = MagicMock()
|
|
telegram_channel._application.bot = mock_telegram_bot
|
|
|
|
await telegram_channel.send("12345", "Hello world", {})
|
|
|
|
mock_telegram_bot.send_message.assert_called_once()
|
|
call_kwargs = mock_telegram_bot.send_message.call_args.kwargs
|
|
assert call_kwargs["chat_id"] == "12345"
|
|
assert "Hello world" in call_kwargs["text"]
|
|
|
|
async def test_send_with_message_thread_id(
|
|
self,
|
|
telegram_channel,
|
|
mock_telegram_bot,
|
|
):
|
|
"""send should include message_thread_id when provided."""
|
|
telegram_channel._application = MagicMock()
|
|
telegram_channel._application.bot = mock_telegram_bot
|
|
|
|
await telegram_channel.send(
|
|
"12345",
|
|
"Hello",
|
|
{"chat_id": "12345", "message_thread_id": 789},
|
|
)
|
|
|
|
call_kwargs = mock_telegram_bot.send_message.call_args.kwargs
|
|
assert call_kwargs["message_thread_id"] == 789
|
|
|
|
async def test_send_stops_typing(
|
|
self,
|
|
telegram_channel,
|
|
mock_telegram_bot,
|
|
):
|
|
"""send should stop typing indicator before sending."""
|
|
telegram_channel._application = MagicMock()
|
|
telegram_channel._application.bot = mock_telegram_bot
|
|
|
|
with patch.object(telegram_channel, "_stop_typing") as mock_stop:
|
|
await telegram_channel.send("12345", "Hello", {})
|
|
mock_stop.assert_called_once_with("12345")
|
|
|
|
async def test_send_chunks_long_messages(
|
|
self,
|
|
telegram_channel,
|
|
mock_telegram_bot,
|
|
):
|
|
"""send should split long messages into chunks."""
|
|
telegram_channel._application = MagicMock()
|
|
telegram_channel._application.bot = mock_telegram_bot
|
|
|
|
long_text = "A" * 5000
|
|
await telegram_channel.send("12345", long_text, {})
|
|
|
|
# Should call multiple times for chunks
|
|
assert mock_telegram_bot.send_message.call_count > 1
|
|
|
|
async def test_send_handles_badrequest_fallback(
|
|
self,
|
|
telegram_channel,
|
|
mock_telegram_bot,
|
|
):
|
|
"""send should fallback to plain text on BadRequest."""
|
|
from telegram.error import BadRequest
|
|
|
|
telegram_channel._application = MagicMock()
|
|
# First call raises BadRequest, second call succeeds
|
|
mock_telegram_bot.send_message = AsyncMock(
|
|
side_effect=[
|
|
BadRequest("Can't parse HTML"),
|
|
None, # Second call succeeds
|
|
],
|
|
)
|
|
telegram_channel._application.bot = mock_telegram_bot
|
|
|
|
# Text with markdown that might fail HTML parsing
|
|
html_text = "<b>Bold</b> and <i>italic</i>"
|
|
await telegram_channel.send("12345", html_text, {})
|
|
|
|
# Should be called twice - first with HTML, second without
|
|
assert mock_telegram_bot.send_message.call_count == 2
|
|
|
|
async def test_send_handles_general_exception(
|
|
self,
|
|
telegram_channel,
|
|
mock_telegram_bot,
|
|
):
|
|
"""send should handle general exceptions."""
|
|
telegram_channel._application = MagicMock()
|
|
mock_telegram_bot.send_message = AsyncMock(
|
|
side_effect=Exception("Error"),
|
|
)
|
|
telegram_channel._application.bot = mock_telegram_bot
|
|
|
|
# Should not raise
|
|
result = await telegram_channel.send("12345", "Hello", {})
|
|
assert result is None
|
|
|
|
|
|
# =============================================================================
|
|
# P1: Send Media
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestTelegramSendMedia:
|
|
"""Tests for send_media method."""
|
|
|
|
async def test_send_media_disabled_channel(
|
|
self,
|
|
disabled_telegram_channel,
|
|
):
|
|
"""send_media should return early when disabled."""
|
|
part = MagicMock()
|
|
part.type = ContentType.IMAGE
|
|
result = await disabled_telegram_channel.send_media("12345", part, {})
|
|
assert result is None
|
|
|
|
async def test_send_media_no_application(self, telegram_channel):
|
|
"""send_media should return early when no application."""
|
|
telegram_channel._application = None
|
|
part = MagicMock()
|
|
part.type = ContentType.IMAGE
|
|
result = await telegram_channel.send_media("12345", part, {})
|
|
assert result is None
|
|
|
|
async def test_send_media_no_chat_id(self, telegram_channel):
|
|
"""send_media should return early when no chat_id."""
|
|
telegram_channel._application = MagicMock()
|
|
part = MagicMock()
|
|
part.type = ContentType.IMAGE
|
|
|
|
result = await telegram_channel.send_media("", part, {})
|
|
assert result is None
|
|
|
|
async def test_send_media_image(self, telegram_channel, mock_telegram_bot):
|
|
"""send_media should send image via send_photo."""
|
|
telegram_channel._application = MagicMock()
|
|
telegram_channel._application.bot = mock_telegram_bot
|
|
|
|
part = ImageContent(
|
|
type=ContentType.IMAGE,
|
|
image_url="http://example.com/img.jpg",
|
|
)
|
|
await telegram_channel.send_media("12345", part, {})
|
|
|
|
mock_telegram_bot.send_photo.assert_called_once()
|
|
call_kwargs = mock_telegram_bot.send_photo.call_args.kwargs
|
|
assert call_kwargs["chat_id"] == "12345"
|
|
assert call_kwargs["photo"] == "http://example.com/img.jpg"
|
|
|
|
async def test_send_media_video(self, telegram_channel, mock_telegram_bot):
|
|
"""send_media should send video via send_video."""
|
|
telegram_channel._application = MagicMock()
|
|
telegram_channel._application.bot = mock_telegram_bot
|
|
|
|
part = VideoContent(
|
|
type=ContentType.VIDEO,
|
|
video_url="http://example.com/video.mp4",
|
|
)
|
|
await telegram_channel.send_media("12345", part, {})
|
|
|
|
mock_telegram_bot.send_video.assert_called_once()
|
|
call_kwargs = mock_telegram_bot.send_video.call_args.kwargs
|
|
assert call_kwargs["video"] == "http://example.com/video.mp4"
|
|
|
|
async def test_send_media_audio(self, telegram_channel, mock_telegram_bot):
|
|
"""send_media should send audio via send_audio."""
|
|
telegram_channel._application = MagicMock()
|
|
telegram_channel._application.bot = mock_telegram_bot
|
|
|
|
part = AudioContent(type=ContentType.AUDIO, data=b"audio_data")
|
|
await telegram_channel.send_media("12345", part, {})
|
|
|
|
mock_telegram_bot.send_audio.assert_called_once()
|
|
call_kwargs = mock_telegram_bot.send_audio.call_args.kwargs
|
|
# Audio data may be bytes or string depending on implementation
|
|
assert call_kwargs["audio"] in [b"audio_data", "audio_data"]
|
|
|
|
async def test_send_media_file(self, telegram_channel, mock_telegram_bot):
|
|
"""send_media should send file via send_document."""
|
|
telegram_channel._application = MagicMock()
|
|
telegram_channel._application.bot = mock_telegram_bot
|
|
|
|
part = FileContent(
|
|
type=ContentType.FILE,
|
|
file_url="http://example.com/doc.pdf",
|
|
)
|
|
await telegram_channel.send_media("12345", part, {})
|
|
|
|
mock_telegram_bot.send_document.assert_called_once()
|
|
call_kwargs = mock_telegram_bot.send_document.call_args.kwargs
|
|
assert call_kwargs["document"] == "http://example.com/doc.pdf"
|
|
|
|
async def test_send_media_unknown_type(
|
|
self,
|
|
telegram_channel,
|
|
mock_telegram_bot,
|
|
):
|
|
"""send_media should handle unknown content type."""
|
|
telegram_channel._application = MagicMock()
|
|
telegram_channel._application.bot = mock_telegram_bot
|
|
|
|
part = MagicMock()
|
|
part.type = "unknown_type"
|
|
|
|
# Should complete without calling any send method
|
|
await telegram_channel.send_media("12345", part, {})
|
|
|
|
mock_telegram_bot.send_photo.assert_not_called()
|
|
mock_telegram_bot.send_video.assert_not_called()
|
|
mock_telegram_bot.send_audio.assert_not_called()
|
|
mock_telegram_bot.send_document.assert_not_called()
|
|
|
|
async def test_send_media_with_message_thread_id(
|
|
self,
|
|
telegram_channel,
|
|
mock_telegram_bot,
|
|
):
|
|
"""send_media should include message_thread_id when provided."""
|
|
telegram_channel._application = MagicMock()
|
|
telegram_channel._application.bot = mock_telegram_bot
|
|
|
|
part = ImageContent(
|
|
type=ContentType.IMAGE,
|
|
image_url="http://example.com/img.jpg",
|
|
)
|
|
await telegram_channel.send_media(
|
|
"12345",
|
|
part,
|
|
{"message_thread_id": 789},
|
|
)
|
|
|
|
call_kwargs = mock_telegram_bot.send_photo.call_args.kwargs
|
|
assert call_kwargs["message_thread_id"] == 789
|
|
|
|
|
|
# =============================================================================
|
|
# P1: Send Media Value (Local Files)
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestTelegramSendMediaValue:
|
|
"""Tests for _send_media_value with local files."""
|
|
|
|
async def test_send_media_value_local_file_success(
|
|
self,
|
|
telegram_channel,
|
|
mock_telegram_bot,
|
|
tmp_path: Path,
|
|
):
|
|
"""Should send local file when file:// URL provided."""
|
|
# Create a test file
|
|
test_file = tmp_path / "test_image.jpg"
|
|
test_file.write_bytes(b"fake_image_data")
|
|
|
|
await telegram_channel._send_media_value(
|
|
bot=mock_telegram_bot,
|
|
chat_id="12345",
|
|
value=f"file://{test_file}",
|
|
method_name="send_photo",
|
|
payload_name="photo",
|
|
message_thread_id=None,
|
|
)
|
|
|
|
mock_telegram_bot.send_photo.assert_called_once()
|
|
|
|
async def test_send_media_value_file_not_found(
|
|
self,
|
|
telegram_channel,
|
|
mock_telegram_bot,
|
|
tmp_path: Path,
|
|
):
|
|
"""Should raise _MediaFileUnavailableError when file not found."""
|
|
from pineagents.app.channels.telegram.channel import (
|
|
_MediaFileUnavailableError,
|
|
)
|
|
|
|
nonexistent_file = tmp_path / "nonexistent.jpg"
|
|
|
|
with pytest.raises(_MediaFileUnavailableError):
|
|
await telegram_channel._send_media_value(
|
|
bot=mock_telegram_bot,
|
|
chat_id="12345",
|
|
value=f"file://{nonexistent_file}",
|
|
method_name="send_photo",
|
|
payload_name="photo",
|
|
message_thread_id=None,
|
|
)
|
|
|
|
async def test_send_media_value_file_too_large(
|
|
self,
|
|
telegram_channel,
|
|
mock_telegram_bot,
|
|
tmp_path: Path,
|
|
):
|
|
"""Should raise _FileTooLargeError when file exceeds limit."""
|
|
from pineagents.app.channels.telegram.channel import (
|
|
_FileTooLargeError,
|
|
TELEGRAM_MAX_FILE_SIZE_BYTES,
|
|
)
|
|
|
|
# Create a file larger than 50MB
|
|
test_file = tmp_path / "large_file.bin"
|
|
test_file.write_bytes(b"x" * (TELEGRAM_MAX_FILE_SIZE_BYTES + 1000))
|
|
|
|
with pytest.raises(_FileTooLargeError):
|
|
await telegram_channel._send_media_value(
|
|
bot=mock_telegram_bot,
|
|
chat_id="12345",
|
|
value=f"file://{test_file}",
|
|
method_name="send_document",
|
|
payload_name="document",
|
|
message_thread_id=None,
|
|
)
|
|
|
|
async def test_send_media_value_http_url(
|
|
self,
|
|
telegram_channel,
|
|
mock_telegram_bot,
|
|
):
|
|
"""Should send HTTP URL directly without file handling."""
|
|
await telegram_channel._send_media_value(
|
|
bot=mock_telegram_bot,
|
|
chat_id="12345",
|
|
value="http://example.com/image.jpg",
|
|
method_name="send_photo",
|
|
payload_name="photo",
|
|
message_thread_id=789,
|
|
)
|
|
|
|
mock_telegram_bot.send_photo.assert_called_once_with(
|
|
chat_id="12345",
|
|
photo="http://example.com/image.jpg",
|
|
message_thread_id=789,
|
|
)
|
|
|
|
async def test_send_media_value_empty_value(
|
|
self,
|
|
telegram_channel,
|
|
mock_telegram_bot,
|
|
):
|
|
"""Should return early when value is empty."""
|
|
await telegram_channel._send_media_value(
|
|
bot=mock_telegram_bot,
|
|
chat_id="12345",
|
|
value="",
|
|
method_name="send_photo",
|
|
payload_name="photo",
|
|
message_thread_id=None,
|
|
)
|
|
|
|
mock_telegram_bot.send_photo.assert_not_called()
|
|
|
|
|
|
# =============================================================================
|
|
# P1: Session Resolution
|
|
# =============================================================================
|
|
|
|
|
|
class TestTelegramSessionResolution:
|
|
"""Tests for session resolution methods."""
|
|
|
|
def test_resolve_session_id_with_chat_id(self, telegram_channel):
|
|
"""resolve_session_id should use chat_id when available."""
|
|
result = telegram_channel.resolve_session_id(
|
|
"user123",
|
|
{"chat_id": "456"},
|
|
)
|
|
assert result == "telegram:456"
|
|
|
|
def test_resolve_session_id_fallback_to_sender(self, telegram_channel):
|
|
"""resolve_session_id should fallback to sender_id when no chat_id."""
|
|
result = telegram_channel.resolve_session_id(
|
|
"user123",
|
|
{},
|
|
)
|
|
assert result == "telegram:user123"
|
|
|
|
def test_resolve_session_id_no_meta(self, telegram_channel):
|
|
"""resolve_session_id should handle None meta."""
|
|
result = telegram_channel.resolve_session_id("user123", None)
|
|
assert result == "telegram:user123"
|
|
|
|
def test_to_handle_from_request_with_chat_id(self, telegram_channel):
|
|
"""get_to_handle_from_request should use chat_id from meta."""
|
|
mock_request = MagicMock()
|
|
mock_request.channel_meta = {"chat_id": "456"}
|
|
mock_request.session_id = "telegram:789"
|
|
|
|
result = telegram_channel.get_to_handle_from_request(mock_request)
|
|
assert result == "456"
|
|
|
|
def test_to_handle_from_request_with_session_id(self, telegram_channel):
|
|
"""get_to_handle_from_request should parse session_id."""
|
|
mock_request = MagicMock()
|
|
mock_request.channel_meta = {}
|
|
mock_request.session_id = "telegram:789"
|
|
mock_request.user_id = "user123"
|
|
|
|
result = telegram_channel.get_to_handle_from_request(mock_request)
|
|
assert result == "789"
|
|
|
|
def test_to_handle_from_request_fallback_to_user_id(
|
|
self,
|
|
telegram_channel,
|
|
):
|
|
"""get_to_handle_from_request should fallback to user_id."""
|
|
mock_request = MagicMock()
|
|
mock_request.channel_meta = {}
|
|
mock_request.session_id = "other:789"
|
|
mock_request.user_id = "user123"
|
|
|
|
result = telegram_channel.get_to_handle_from_request(mock_request)
|
|
assert result == "user123"
|
|
|
|
def test_to_handle_from_target_with_session_id(self, telegram_channel):
|
|
"""to_handle_from_target should parse telegram: session_id."""
|
|
result = telegram_channel.to_handle_from_target(
|
|
user_id="user123",
|
|
session_id="telegram:456",
|
|
)
|
|
assert result == "456"
|
|
|
|
def test_to_handle_from_target_fallback_to_user_id(self, telegram_channel):
|
|
"""to_handle_from_target should fallback to user_id."""
|
|
result = telegram_channel.to_handle_from_target(
|
|
user_id="user123",
|
|
session_id="other:456",
|
|
)
|
|
assert result == "user123"
|
|
|
|
|
|
# =============================================================================
|
|
# P1: Build Agent Request
|
|
# =============================================================================
|
|
|
|
|
|
class TestTelegramBuildAgentRequest:
|
|
"""Tests for build_agent_request_from_native method."""
|
|
|
|
def test_build_agent_request_with_full_payload(self, telegram_channel):
|
|
"""Should create AgentRequest from complete native payload."""
|
|
payload = {
|
|
"channel_id": "telegram",
|
|
"sender_id": "user123",
|
|
"content_parts": [
|
|
TextContent(type=ContentType.TEXT, text="Hello"),
|
|
],
|
|
"meta": {"chat_id": "456", "user_id": "789"},
|
|
}
|
|
|
|
request = telegram_channel.build_agent_request_from_native(payload)
|
|
|
|
assert request.user_id == "789" # From meta
|
|
assert request.channel == "telegram"
|
|
assert len(request.input) == 1
|
|
assert request.channel_meta == {"chat_id": "456", "user_id": "789"}
|
|
|
|
def test_build_agent_request_defaults(self, telegram_channel):
|
|
"""Should use defaults for missing fields."""
|
|
payload = {
|
|
"sender_id": "user123",
|
|
"content_parts": [],
|
|
}
|
|
|
|
request = telegram_channel.build_agent_request_from_native(payload)
|
|
|
|
assert request.user_id == "user123" # From sender_id
|
|
assert request.channel == "telegram" # Default channel
|
|
|
|
|
|
# =============================================================================
|
|
# P1: Module-Level File Download
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestTelegramDownloadFile:
|
|
"""Tests for _download_telegram_file function."""
|
|
|
|
async def test_download_file_success(self, tmp_path: Path):
|
|
"""Should download file and return local path."""
|
|
from pineagents.app.channels.telegram.channel import (
|
|
_download_telegram_file,
|
|
)
|
|
|
|
mock_bot = MagicMock()
|
|
mock_file = MagicMock()
|
|
mock_file.file_path = "photos/test.jpg"
|
|
mock_file.download_to_drive = AsyncMock()
|
|
mock_bot.get_file = AsyncMock(return_value=mock_file)
|
|
|
|
result = await _download_telegram_file(
|
|
bot=mock_bot,
|
|
file_id="file123",
|
|
media_dir=tmp_path,
|
|
filename_hint="test.jpg",
|
|
)
|
|
|
|
assert result is not None
|
|
assert tmp_path in Path(result).parents or str(tmp_path) in result
|
|
mock_file.download_to_drive.assert_called_once()
|
|
|
|
async def test_download_file_telegram_error(self, tmp_path: Path):
|
|
"""Should return None on TelegramError."""
|
|
from pineagents.app.channels.telegram.channel import (
|
|
_download_telegram_file,
|
|
)
|
|
from telegram.error import TelegramError
|
|
|
|
mock_bot = MagicMock()
|
|
mock_bot.get_file = AsyncMock(side_effect=TelegramError("Error"))
|
|
|
|
result = await _download_telegram_file(
|
|
bot=mock_bot,
|
|
file_id="file123",
|
|
media_dir=tmp_path,
|
|
)
|
|
|
|
assert result is None
|
|
|
|
async def test_download_file_with_suffix_from_hint(self, tmp_path: Path):
|
|
"""Should use file suffix from filename_hint when not in file_path."""
|
|
from pineagents.app.channels.telegram.channel import (
|
|
_download_telegram_file,
|
|
)
|
|
|
|
mock_bot = MagicMock()
|
|
mock_file = MagicMock()
|
|
mock_file.file_path = "photos/file_no_ext" # No suffix
|
|
mock_file.download_to_drive = AsyncMock()
|
|
mock_bot.get_file = AsyncMock(return_value=mock_file)
|
|
|
|
result = await _download_telegram_file(
|
|
bot=mock_bot,
|
|
file_id="file123",
|
|
media_dir=tmp_path,
|
|
filename_hint="image.png",
|
|
)
|
|
|
|
# Result should have .png suffix from hint
|
|
assert result is not None
|
|
assert result.endswith(".png")
|
|
|
|
async def test_download_file_creates_directory(self, tmp_path: Path):
|
|
"""Should create media_dir if it doesn't exist."""
|
|
from pineagents.app.channels.telegram.channel import (
|
|
_download_telegram_file,
|
|
)
|
|
|
|
mock_bot = MagicMock()
|
|
mock_file = MagicMock()
|
|
mock_file.file_path = "test.jpg"
|
|
mock_file.download_to_drive = AsyncMock()
|
|
mock_bot.get_file = AsyncMock(return_value=mock_file)
|
|
|
|
nested_dir = tmp_path / "nested" / "media"
|
|
assert not nested_dir.exists()
|
|
|
|
result = await _download_telegram_file(
|
|
bot=mock_bot,
|
|
file_id="file123",
|
|
media_dir=nested_dir,
|
|
)
|
|
|
|
assert nested_dir.exists()
|
|
assert result is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestTelegramResolveFileUrl:
|
|
"""Tests for _resolve_telegram_file_url function."""
|
|
|
|
async def test_resolve_external_url(self):
|
|
"""Should return external URL as-is."""
|
|
from pineagents.app.channels.telegram.channel import (
|
|
_resolve_telegram_file_url,
|
|
)
|
|
|
|
mock_bot = MagicMock()
|
|
mock_file = MagicMock()
|
|
mock_file.file_path = "http://external.server.com/file.jpg"
|
|
mock_bot.get_file = AsyncMock(return_value=mock_file)
|
|
|
|
result = await _resolve_telegram_file_url(
|
|
bot=mock_bot,
|
|
file_id="file123",
|
|
bot_token="test_token",
|
|
)
|
|
|
|
assert result == "http://external.server.com/file.jpg"
|
|
|
|
async def test_resolve_api_url(self):
|
|
"""Should construct Telegram API URL for local file paths."""
|
|
from pineagents.app.channels.telegram.channel import (
|
|
_resolve_telegram_file_url,
|
|
)
|
|
|
|
mock_bot = MagicMock()
|
|
mock_file = MagicMock()
|
|
mock_file.file_path = "photos/file_123.jpg"
|
|
mock_bot.get_file = AsyncMock(return_value=mock_file)
|
|
|
|
result = await _resolve_telegram_file_url(
|
|
bot=mock_bot,
|
|
file_id="file123",
|
|
bot_token="my_bot_token",
|
|
)
|
|
|
|
expected = (
|
|
"https://api.telegram.org/file/botmy_bot_token/photos/file_123.jpg"
|
|
)
|
|
assert result == expected
|
|
|
|
async def test_resolve_api_url_with_custom_base_url(self):
|
|
"""Should construct custom Telegram file API URL."""
|
|
from pineagents.app.channels.telegram.channel import (
|
|
_resolve_telegram_file_url,
|
|
)
|
|
|
|
mock_bot = MagicMock()
|
|
mock_file = MagicMock()
|
|
mock_file.file_path = "photos/file_123.jpg"
|
|
mock_bot.get_file = AsyncMock(return_value=mock_file)
|
|
|
|
result = await _resolve_telegram_file_url(
|
|
bot=mock_bot,
|
|
file_id="file123",
|
|
bot_token="my_bot_token",
|
|
base_url="https://tg-api.example.com/",
|
|
)
|
|
|
|
expected = (
|
|
"https://tg-api.example.com/file/"
|
|
"botmy_bot_token/photos/file_123.jpg"
|
|
)
|
|
assert result == expected
|
|
|
|
async def test_resolve_error_returns_empty(self):
|
|
"""Should return empty string on TelegramError."""
|
|
from pineagents.app.channels.telegram.channel import (
|
|
_resolve_telegram_file_url,
|
|
)
|
|
from telegram.error import TelegramError
|
|
|
|
mock_bot = MagicMock()
|
|
mock_bot.get_file = AsyncMock(side_effect=TelegramError("Error"))
|
|
|
|
result = await _resolve_telegram_file_url(
|
|
bot=mock_bot,
|
|
file_id="file123",
|
|
bot_token="test_token",
|
|
)
|
|
|
|
assert result == ""
|
|
|
|
|
|
# =============================================================================
|
|
# P1: Message Meta Extraction
|
|
# =============================================================================
|
|
|
|
|
|
class TestTelegramMessageMeta:
|
|
"""Tests for _message_meta function."""
|
|
|
|
def test_message_meta_full(self):
|
|
"""Should extract all meta fields from update."""
|
|
from pineagents.app.channels.telegram.channel import _message_meta
|
|
|
|
mock_update = MagicMock()
|
|
mock_message = MagicMock()
|
|
mock_chat = MagicMock()
|
|
mock_user = MagicMock()
|
|
|
|
mock_chat.id = 123456
|
|
mock_chat.type = "group"
|
|
mock_user.id = 789
|
|
mock_user.username = "testuser"
|
|
mock_message.chat = mock_chat
|
|
mock_message.from_user = mock_user
|
|
mock_message.message_id = 100
|
|
mock_message.message_thread_id = 50
|
|
|
|
mock_update.message = mock_message
|
|
|
|
result = _message_meta(mock_update)
|
|
|
|
assert result["chat_id"] == "123456"
|
|
assert result["user_id"] == "789"
|
|
assert result["username"] == "testuser"
|
|
assert result["message_id"] == "100"
|
|
assert result["is_group"] is True
|
|
assert result["message_thread_id"] == 50
|
|
|
|
def test_message_meta_edited_message(self):
|
|
"""Should handle edited_message."""
|
|
from pineagents.app.channels.telegram.channel import _message_meta
|
|
|
|
mock_update = MagicMock()
|
|
mock_message = MagicMock()
|
|
mock_chat = MagicMock()
|
|
mock_user = MagicMock()
|
|
|
|
mock_chat.id = 123456
|
|
mock_chat.type = "private"
|
|
mock_user.id = 789
|
|
mock_user.username = None
|
|
mock_message.chat = mock_chat
|
|
mock_message.from_user = mock_user
|
|
mock_message.message_id = 100
|
|
mock_message.message_thread_id = None
|
|
|
|
mock_update.message = None
|
|
mock_update.edited_message = mock_message
|
|
|
|
result = _message_meta(mock_update)
|
|
|
|
assert result["is_group"] is False
|
|
assert result["username"] == ""
|
|
|
|
def test_message_meta_no_message(self):
|
|
"""Should return empty dict when no message."""
|
|
from pineagents.app.channels.telegram.channel import _message_meta
|
|
|
|
mock_update = MagicMock()
|
|
mock_update.message = None
|
|
mock_update.edited_message = None
|
|
|
|
result = _message_meta(mock_update)
|
|
|
|
assert not result
|
|
|
|
def test_message_meta_supergroup_is_group(self):
|
|
"""Should treat supergroup as group."""
|
|
from pineagents.app.channels.telegram.channel import _message_meta
|
|
|
|
mock_update = MagicMock()
|
|
mock_message = MagicMock()
|
|
mock_chat = MagicMock()
|
|
mock_user = MagicMock()
|
|
|
|
mock_chat.id = 123456
|
|
mock_chat.type = "supergroup"
|
|
mock_user.id = 789
|
|
mock_message.chat = mock_chat
|
|
mock_message.from_user = mock_user
|
|
mock_message.message_id = 100
|
|
mock_message.message_thread_id = None
|
|
|
|
mock_update.message = mock_message
|
|
|
|
result = _message_meta(mock_update)
|
|
|
|
assert result["is_group"] is True
|
|
|
|
|
|
# =============================================================================
|
|
# P2: Build Content Parts from Message
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestTelegramBuildContentParts:
|
|
"""Tests for _build_content_parts_from_message function."""
|
|
|
|
async def test_text_only_message(self, tmp_path: Path):
|
|
"""Should extract text content from message."""
|
|
from pineagents.app.channels.telegram.channel import (
|
|
_build_content_parts_from_message,
|
|
)
|
|
|
|
mock_update = MagicMock()
|
|
mock_message = MagicMock()
|
|
mock_message.text = "Hello world"
|
|
mock_message.caption = None
|
|
mock_message.entities = []
|
|
mock_message.caption_entities = None
|
|
mock_message.photo = []
|
|
mock_message.document = None
|
|
mock_message.video = None
|
|
mock_message.voice = None
|
|
mock_message.audio = None
|
|
mock_update.message = mock_message
|
|
mock_update.edited_message = None
|
|
|
|
mock_bot = MagicMock()
|
|
mock_bot.username = "test_bot"
|
|
mock_bot.id = "12345"
|
|
|
|
(
|
|
parts,
|
|
has_command,
|
|
is_mentioned,
|
|
) = await _build_content_parts_from_message(
|
|
mock_update,
|
|
bot=mock_bot,
|
|
media_dir=tmp_path,
|
|
)
|
|
|
|
assert len(parts) == 1
|
|
assert parts[0].type == ContentType.TEXT
|
|
assert parts[0].text == "Hello world"
|
|
assert has_command is False
|
|
assert is_mentioned is False
|
|
|
|
async def test_message_with_bot_command(self, tmp_path: Path):
|
|
"""Should detect bot command entities."""
|
|
from pineagents.app.channels.telegram.channel import (
|
|
_build_content_parts_from_message,
|
|
)
|
|
|
|
mock_update = MagicMock()
|
|
mock_message = MagicMock()
|
|
mock_message.text = "/start"
|
|
mock_message.caption = None
|
|
|
|
mock_entity = MagicMock()
|
|
mock_entity.type = "bot_command"
|
|
mock_message.entities = [mock_entity]
|
|
mock_message.caption_entities = None
|
|
mock_message.photo = []
|
|
mock_message.document = None
|
|
mock_message.video = None
|
|
mock_message.voice = None
|
|
mock_message.audio = None
|
|
|
|
mock_update.message = mock_message
|
|
mock_update.edited_message = None
|
|
|
|
mock_bot = MagicMock()
|
|
mock_bot.username = "test_bot"
|
|
mock_bot.id = "12345"
|
|
|
|
(
|
|
parts,
|
|
has_command,
|
|
_is_mentioned,
|
|
) = await _build_content_parts_from_message(
|
|
mock_update,
|
|
bot=mock_bot,
|
|
media_dir=tmp_path,
|
|
)
|
|
|
|
assert has_command is True
|
|
assert len(parts) == 1
|
|
assert parts[0].text == "/start"
|
|
|
|
async def test_message_with_mention(self, tmp_path: Path):
|
|
"""Should detect bot mention."""
|
|
from pineagents.app.channels.telegram.channel import (
|
|
_build_content_parts_from_message,
|
|
)
|
|
|
|
mock_update = MagicMock()
|
|
mock_message = MagicMock()
|
|
mock_message.text = "@test_bot Hello"
|
|
mock_message.caption = None
|
|
|
|
mock_entity = MagicMock()
|
|
mock_entity.type = "mention"
|
|
mock_entity.offset = 0
|
|
mock_entity.length = 9 # @test_bot
|
|
mock_message.entities = [mock_entity]
|
|
mock_message.caption_entities = None
|
|
mock_message.photo = []
|
|
mock_message.document = None
|
|
mock_message.video = None
|
|
mock_message.voice = None
|
|
mock_message.audio = None
|
|
|
|
mock_update.message = mock_message
|
|
mock_update.edited_message = None
|
|
|
|
mock_bot = MagicMock()
|
|
mock_bot.username = "test_bot"
|
|
mock_bot.id = "12345"
|
|
|
|
(
|
|
parts,
|
|
_has_command,
|
|
is_mentioned,
|
|
) = await _build_content_parts_from_message(
|
|
mock_update,
|
|
bot=mock_bot,
|
|
media_dir=tmp_path,
|
|
)
|
|
|
|
assert is_mentioned is True
|
|
# Mention should be removed from text
|
|
assert "@test_bot" not in parts[0].text
|
|
assert "Hello" in parts[0].text
|
|
|
|
async def test_message_no_content(self, tmp_path: Path):
|
|
"""Should return empty list when no content."""
|
|
from pineagents.app.channels.telegram.channel import (
|
|
_build_content_parts_from_message,
|
|
)
|
|
|
|
mock_update = MagicMock()
|
|
mock_update.message = None
|
|
mock_update.edited_message = None
|
|
|
|
mock_bot = MagicMock()
|
|
|
|
(
|
|
parts,
|
|
has_command,
|
|
is_mentioned,
|
|
) = await _build_content_parts_from_message(
|
|
mock_update,
|
|
bot=mock_bot,
|
|
media_dir=tmp_path,
|
|
)
|
|
|
|
assert parts == []
|
|
assert has_command is False
|
|
assert is_mentioned is False
|
|
|
|
|
|
# =============================================================================
|
|
# P2: Channel Lifecycle
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestTelegramLifecycle:
|
|
"""Tests for start/stop lifecycle."""
|
|
|
|
async def test_start_disabled_channel(
|
|
self,
|
|
disabled_telegram_channel,
|
|
caplog,
|
|
):
|
|
"""start should return early for disabled channel."""
|
|
import logging
|
|
|
|
with caplog.at_level(logging.DEBUG):
|
|
await disabled_telegram_channel.start()
|
|
|
|
# Should not create task
|
|
assert disabled_telegram_channel._task is None
|
|
|
|
async def test_start_no_token(self, telegram_channel, caplog):
|
|
"""start should return early when no token."""
|
|
import logging
|
|
|
|
telegram_channel._bot_token = ""
|
|
|
|
with caplog.at_level(logging.DEBUG):
|
|
await telegram_channel.start()
|
|
|
|
assert telegram_channel._task is None
|
|
|
|
async def test_stop_disabled_channel(self, disabled_telegram_channel):
|
|
"""stop should return early for disabled channel."""
|
|
# Should not raise
|
|
await disabled_telegram_channel.stop()
|
|
|
|
async def test_stop_without_start(self, telegram_channel):
|
|
"""stop should succeed without prior start."""
|
|
telegram_channel._task = None
|
|
telegram_channel._application = None
|
|
|
|
# Should not raise
|
|
await telegram_channel.stop()
|
|
|
|
|
|
# =============================================================================
|
|
# P2: Exception Classes
|
|
# =============================================================================
|
|
|
|
|
|
class TestTelegramExceptions:
|
|
"""Tests for custom exception classes."""
|
|
|
|
def test_file_too_large_error(self):
|
|
"""_FileTooLargeError should be catchable."""
|
|
from pineagents.app.channels.telegram.channel import _FileTooLargeError
|
|
|
|
exc = _FileTooLargeError("File is too big")
|
|
assert str(exc) == "File is too big"
|
|
assert isinstance(exc, Exception)
|
|
|
|
def test_media_file_unavailable_error(self):
|
|
"""_MediaFileUnavailableError should be catchable."""
|
|
from pineagents.app.channels.telegram.channel import (
|
|
_MediaFileUnavailableError,
|
|
)
|
|
|
|
exc = _MediaFileUnavailableError("File not found")
|
|
assert str(exc) == "File not found"
|
|
assert isinstance(exc, Exception)
|
|
|
|
|
|
# =============================================================================
|
|
# P2: Constants
|
|
# =============================================================================
|
|
|
|
|
|
class TestTelegramConstants:
|
|
"""Tests for module constants."""
|
|
|
|
def test_max_message_length(self):
|
|
"""TELEGRAM_MAX_MESSAGE_LENGTH should be 4096."""
|
|
from pineagents.app.channels.telegram.channel import (
|
|
TELEGRAM_MAX_MESSAGE_LENGTH,
|
|
)
|
|
|
|
assert TELEGRAM_MAX_MESSAGE_LENGTH == 4096
|
|
|
|
def test_send_chunk_size(self):
|
|
"""TELEGRAM_SEND_CHUNK_SIZE should be 4000."""
|
|
from pineagents.app.channels.telegram.channel import (
|
|
TELEGRAM_SEND_CHUNK_SIZE,
|
|
)
|
|
|
|
assert TELEGRAM_SEND_CHUNK_SIZE == 4000
|
|
|
|
def test_max_file_size(self):
|
|
"""TELEGRAM_MAX_FILE_SIZE_BYTES should be 50MB."""
|
|
from pineagents.app.channels.telegram.channel import (
|
|
TELEGRAM_MAX_FILE_SIZE_BYTES,
|
|
)
|
|
|
|
assert TELEGRAM_MAX_FILE_SIZE_BYTES == 50 * 1024 * 1024
|
|
|
|
|
|
# =============================================================================
|
|
# P2: Proxy URL Building
|
|
# =============================================================================
|
|
|
|
|
|
class TestTelegramProxyUrl:
|
|
"""Tests for proxy URL construction."""
|
|
|
|
def test_no_proxy_returns_none(self, telegram_channel):
|
|
"""_build_application should handle no proxy."""
|
|
telegram_channel._http_proxy = ""
|
|
telegram_channel._bot_token = "test_token"
|
|
|
|
# Just test the _build_application method exists and can be called
|
|
# without throwing during the proxy_url() function
|
|
with patch("telegram.ext.Application.builder") as mock_builder_class:
|
|
mock_builder = MagicMock()
|
|
mock_builder_class.return_value = mock_builder
|
|
mock_builder.token.return_value = mock_builder
|
|
mock_builder.get_updates_read_timeout.return_value = mock_builder
|
|
mock_builder.get_updates_connect_timeout.return_value = (
|
|
mock_builder
|
|
)
|
|
mock_builder.build.return_value = MagicMock()
|
|
|
|
# Should complete without error
|
|
telegram_channel._build_application()
|
|
|
|
# No proxy methods should be called
|
|
assert (
|
|
not hasattr(mock_builder, "proxy")
|
|
or not mock_builder.proxy.called
|
|
)
|
|
assert not mock_builder.base_url.called
|
|
assert not mock_builder.base_file_url.called
|
|
|
|
def test_custom_base_url_configures_builder(self, telegram_channel):
|
|
"""Should configure PTB Bot API and file API prefixes."""
|
|
telegram_channel._base_url = "https://tg-api.example.com"
|
|
telegram_channel._bot_token = "test_token"
|
|
|
|
with patch("telegram.ext.Application.builder") as mock_builder_class:
|
|
mock_builder = MagicMock()
|
|
mock_builder_class.return_value = mock_builder
|
|
mock_builder.token.return_value = mock_builder
|
|
mock_builder.base_url.return_value = mock_builder
|
|
mock_builder.base_file_url.return_value = mock_builder
|
|
mock_builder.get_updates_read_timeout.return_value = mock_builder
|
|
mock_builder.get_updates_connect_timeout.return_value = (
|
|
mock_builder
|
|
)
|
|
mock_builder.build.return_value = MagicMock()
|
|
|
|
telegram_channel._build_application()
|
|
|
|
mock_builder.base_url.assert_called_once_with(
|
|
"https://tg-api.example.com/bot",
|
|
)
|
|
mock_builder.base_file_url.assert_called_once_with(
|
|
"https://tg-api.example.com/file/bot",
|
|
)
|
|
|
|
def test_proxy_without_auth(self, telegram_channel):
|
|
"""Should use proxy without auth when no auth provided."""
|
|
telegram_channel._http_proxy = "http://proxy.example.com:8080"
|
|
telegram_channel._http_proxy_auth = ""
|
|
|
|
with patch("telegram.ext.Application.builder") as mock_builder_class:
|
|
mock_builder = MagicMock()
|
|
mock_builder_class.return_value = mock_builder
|
|
mock_builder.token.return_value = mock_builder
|
|
mock_builder.get_updates_read_timeout.return_value = mock_builder
|
|
mock_builder.get_updates_connect_timeout.return_value = (
|
|
mock_builder
|
|
)
|
|
mock_builder.proxy.return_value = mock_builder
|
|
mock_builder.get_updates_proxy.return_value = mock_builder
|
|
mock_builder.build.return_value = MagicMock()
|
|
|
|
telegram_channel._build_application()
|
|
|
|
mock_builder.proxy.assert_called_once_with(
|
|
"http://proxy.example.com:8080",
|
|
)
|
|
|
|
def test_proxy_with_auth(self, telegram_channel):
|
|
"""Should include auth in proxy URL when provided."""
|
|
telegram_channel._http_proxy = "http://proxy.example.com:8080"
|
|
telegram_channel._http_proxy_auth = "user:pass"
|
|
|
|
with patch("telegram.ext.Application.builder") as mock_builder_class:
|
|
mock_builder = MagicMock()
|
|
mock_builder_class.return_value = mock_builder
|
|
mock_builder.token.return_value = mock_builder
|
|
mock_builder.get_updates_read_timeout.return_value = mock_builder
|
|
mock_builder.get_updates_connect_timeout.return_value = (
|
|
mock_builder
|
|
)
|
|
mock_builder.proxy.return_value = mock_builder
|
|
mock_builder.get_updates_proxy.return_value = mock_builder
|
|
mock_builder.build.return_value = MagicMock()
|
|
|
|
telegram_channel._build_application()
|
|
|
|
expected_proxy = "http://user:pass@proxy.example.com:8080"
|
|
mock_builder.proxy.assert_called_once_with(expected_proxy)
|
|
|
|
|
|
# =============================================================================
|
|
# Polling reconnect / 409 conflict handling
|
|
# =============================================================================
|
|
|
|
|
|
class TestTelegramPollingReconnect:
|
|
"""Cover conflict/network classification and reconnect backoff timing."""
|
|
|
|
def test_conflict_classification(self, telegram_channel):
|
|
"""Conflict errors are detected by class name or message text."""
|
|
from pineagents.app.channels.telegram.channel import TelegramChannel
|
|
|
|
class Conflict(Exception):
|
|
pass
|
|
|
|
assert TelegramChannel._looks_like_polling_conflict(Conflict("x"))
|
|
assert TelegramChannel._looks_like_polling_conflict(
|
|
Exception(
|
|
"Conflict: terminated by other getUpdates request; "
|
|
"make sure that only one bot instance is running",
|
|
),
|
|
)
|
|
assert not TelegramChannel._looks_like_polling_conflict(
|
|
Exception("some unrelated error"),
|
|
)
|
|
|
|
def test_network_classification(self, telegram_channel):
|
|
"""Transport errors (OSError etc.) are treated as network errors."""
|
|
from pineagents.app.channels.telegram.channel import TelegramChannel
|
|
|
|
assert TelegramChannel._looks_like_network_error(OSError("boom"))
|
|
assert not TelegramChannel._looks_like_network_error(
|
|
Exception("logic error"),
|
|
)
|
|
|
|
def test_conflict_backoff_is_monotonic_and_capped(self, telegram_channel):
|
|
"""Conflict delay escalates and never exceeds the safety cap."""
|
|
from pineagents.app.channels.telegram.channel import (
|
|
_POLLING_CONFLICT_RETRY_MAX_S,
|
|
)
|
|
|
|
delays = []
|
|
for expected_attempt in range(1, 8):
|
|
attempt, delay = telegram_channel._plan_polling_reconnect(
|
|
"conflict",
|
|
)
|
|
assert attempt == expected_attempt
|
|
assert delay <= _POLLING_CONFLICT_RETRY_MAX_S
|
|
delays.append(delay)
|
|
|
|
# Non-decreasing and eventually pinned to the cap.
|
|
assert delays == sorted(delays)
|
|
assert delays[0] < delays[-1]
|
|
assert delays[-1] == _POLLING_CONFLICT_RETRY_MAX_S
|
|
|
|
def test_conflict_cap_exceeds_read_timeout(self):
|
|
"""Regression lock: worst-case wait must clear a lingering poll."""
|
|
from pineagents.app.channels.telegram.channel import (
|
|
_GET_UPDATES_READ_TIMEOUT_S,
|
|
_POLLING_CONFLICT_RETRY_MAX_S,
|
|
)
|
|
|
|
assert _POLLING_CONFLICT_RETRY_MAX_S >= _GET_UPDATES_READ_TIMEOUT_S + 1
|
|
|
|
def test_conflict_and_network_counters_are_mutually_reset(
|
|
self,
|
|
telegram_channel,
|
|
):
|
|
"""Switching reason resets the other counter (fresh backoff)."""
|
|
c_attempt1, _ = telegram_channel._plan_polling_reconnect("conflict")
|
|
assert c_attempt1 == 1
|
|
telegram_channel._plan_polling_reconnect("conflict")
|
|
|
|
n_attempt1, _ = telegram_channel._plan_polling_reconnect("network")
|
|
assert n_attempt1 == 1
|
|
assert telegram_channel._polling_conflict_count == 0
|
|
|
|
# Back to conflict: counter restarts from 1.
|
|
c_again, _ = telegram_channel._plan_polling_reconnect("conflict")
|
|
assert c_again == 1
|
|
assert telegram_channel._polling_network_error_count == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_request_reconnect_stops_updater_and_sets_event(
|
|
self,
|
|
telegram_channel,
|
|
):
|
|
"""Reconnect request stops the updater and wakes the watchdog."""
|
|
telegram_channel._pending_reconnect_reason = None
|
|
telegram_channel._reconnect_event.clear()
|
|
|
|
app = MagicMock()
|
|
app.updater = MagicMock()
|
|
app.updater.running = True
|
|
app.updater.stop = AsyncMock()
|
|
|
|
await telegram_channel._request_polling_reconnect(
|
|
app,
|
|
reason="conflict",
|
|
error=Exception("Conflict"),
|
|
)
|
|
|
|
app.updater.stop.assert_awaited_once()
|
|
assert telegram_channel._reconnect_event.is_set()
|
|
assert telegram_channel._pending_reconnect_reason == "conflict"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_teardown_swallows_errors_and_settles(
|
|
self,
|
|
telegram_channel,
|
|
):
|
|
"""Teardown shuts down the app and applies the settle delay."""
|
|
from pineagents.app.channels.telegram.channel import TelegramChannel
|
|
|
|
app = MagicMock()
|
|
app.updater = MagicMock()
|
|
app.updater.running = True
|
|
app.updater.stop = AsyncMock()
|
|
app.running = True
|
|
app.stop = AsyncMock()
|
|
app.shutdown = AsyncMock()
|
|
|
|
with patch(
|
|
"pineagents.app.channels.telegram.channel.asyncio.sleep",
|
|
new=AsyncMock(),
|
|
) as mock_sleep:
|
|
await TelegramChannel._teardown_application(app)
|
|
|
|
app.updater.stop.assert_awaited_once()
|
|
app.stop.assert_awaited_once()
|
|
app.shutdown.assert_awaited_once()
|
|
mock_sleep.assert_awaited_once()
|