ForcePilot/backend/test/unit/channels/test_telegram_comprehensive.py
Kris 69fe97a90d test: 批量修复并新增单元测试用例
1. 移除Telegram格式化测试中未使用的导入项
2. 修复Teams测试用例,添加monkeypatch参数并配置通配符开关
3. 更新钉钉适配器测试,替换弃用的流属性检查
4. 修正Twitch规范化测试,更新ROOMSTATE测试逻辑
5. 重构会话映射测试,完善数据库执行结果模拟
6. 格式化Slack块构建测试的长参数调用
7. 修复LINE适配器测试,更新能力断言和异步锁使用
8. 修正Slack会话解析测试,修复聊天类型判断错误
9. 更新能力测试,补充缺失的字段检查
10. 修复Matrix适配器测试,修正位置参数和配置校验逻辑
11. 为飞书分析模块测试添加跳过标记
12. 新增微信能力、限流、链接格式、会话路由等模块的单元测试
13. 修复Twitch适配器导入路径和测试断言
14. 新增Discord Webhook、Nextcloud Talk、Signal多账户等模块的单元测试
15. 修复Manager阶段测试的导入路径
16. 新增iMessage异常和命令处理的单元测试
17. 新增Nostr健康检查和相关模块的单元测试
18. 新增Signal守护进程和SSE重连相关测试
2026-05-13 16:43:01 +08:00

1701 lines
64 KiB
Python

from __future__ import annotations
import time
from unittest.mock import AsyncMock, MagicMock
import pytest
from telegram import (
Bot,
CallbackQuery,
Chat,
ForceReply,
InlineKeyboardMarkup,
Message,
MessageEntity,
ReplyKeyboardMarkup,
Update,
User,
)
from telegram.error import (
BadRequest,
Conflict,
Forbidden,
InvalidToken,
NetworkError,
RetryAfter,
TelegramError,
TimedOut,
)
from yuxi.channels.adapters.telegram.adapter import TelegramAdapter, _build_command_scope
from yuxi.channels.adapters.telegram.chunking import (
chunk_mode_from_config,
chunk_text,
)
from yuxi.channels.adapters.telegram.debounce import (
ConnectTimeoutConfig,
MessageDebouncer,
)
from yuxi.channels.adapters.telegram.format import (
_convert_bold,
_convert_italic,
_convert_links,
_convert_spoiler,
_convert_strikethrough,
_convert_underline,
_sanitize_html,
markdown_to_html,
strip_html_tags,
)
from yuxi.channels.adapters.telegram.inline_buttons import (
build_exec_approval_buttons,
build_inline_keyboard,
build_model_selector_buttons,
build_paginated_command_keyboard,
build_provider_browse_keyboard,
)
from yuxi.channels.adapters.telegram.network_errors import (
classify_telegram_error,
get_retry_delay,
is_recoverable_network_error,
is_safe_to_retry_send,
)
from yuxi.channels.adapters.telegram.reactions.reaction_level import (
ReactionLevelController,
get_reaction_level,
)
from yuxi.channels.adapters.telegram.reactions.reaction_notifications import (
ReactionNotifications,
)
from yuxi.channels.adapters.telegram.security.security_audit import (
audit_dm_access,
audit_security_policy,
)
from yuxi.channels.adapters.telegram.send import (
build_quote_payload,
build_reply_parameters,
send_stream_edit,
send_with_format_fallback,
send_with_retry,
)
from yuxi.channels.adapters.telegram.targets.normalize import (
format_chat_identifier,
normalize_chat_id,
normalize_group_id,
normalize_user_id,
strip_internal_prefixes,
)
from yuxi.channels.adapters.telegram.targets.target_writeback import (
handle_target_writeback,
)
from yuxi.channels.adapters.telegram.targets.targets import (
normalize_telegram_chat_id,
normalize_telegram_target,
parse_messaging_target,
parse_telegram_target,
)
from yuxi.channels.adapters.telegram.tools.request_timeouts import (
DEFAULT_GLOBAL_TIMEOUT_SECONDS,
DEFAULT_TIMEOUTS,
get_timeout,
get_timeout_config,
resolve_global_timeout,
)
from yuxi.channels.adapters.telegram.tools.sendchataction_401_backoff import (
SendChatAction401Backoff,
)
from yuxi.channels.adapters.telegram.tools.sequential_key import (
generate_debounce_key,
generate_group_sequential_key,
generate_sequential_key,
generate_topic_sequential_key,
)
from yuxi.channels.adapters.telegram.topic_routing import TopicRouter
from yuxi.channels.models import (
Attachment,
ChannelIdentity,
ChannelResponse,
ChannelStatus,
ChannelType,
ChatType,
EventType,
MessageType,
)
from datetime import datetime, timezone
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
_NOW = datetime.now(timezone.utc)
def make_user(id=12345, username="testuser", first_name="Test", last_name="User"):
return User(id=id, is_bot=False, first_name=first_name, username=username, last_name=last_name)
def make_chat(id=-10012345, chat_type="private", username=None, title=None):
return Chat(id=id, type=chat_type, username=username, title=title)
def make_message(
message_id=1,
text="hello",
from_user=None,
chat=None,
date=None,
entities=(),
is_topic_message=False,
message_thread_id=None,
):
from datetime import datetime
return Message(
message_id=message_id,
date=date or datetime.now(),
chat=chat or make_chat(),
from_user=from_user or make_user(),
text=text,
entities=entities,
is_topic_message=is_topic_message,
message_thread_id=message_thread_id,
)
def make_update(message=None):
return Update(update_id=1, message=message)
# ============================================================================
# 1. network_errors
# ============================================================================
class TestNetworkErrors:
def test_is_recoverable_timed_out(self):
assert is_recoverable_network_error(TimedOut("timeout")) is True
def test_is_recoverable_network_error(self):
assert is_recoverable_network_error(NetworkError("network")) is True
def test_is_recoverable_os_error(self):
assert is_recoverable_network_error(OSError("os")) is True
def test_is_recoverable_retry_after(self):
assert is_recoverable_network_error(RetryAfter(5)) is True
def test_is_not_recoverable_invalid_token(self):
assert is_recoverable_network_error(InvalidToken("bad")) is False
def test_is_not_recoverable_forbidden(self):
assert is_recoverable_network_error(Forbidden("forbidden")) is False
def test_is_safe_to_retry_retry_after(self):
assert is_safe_to_retry_send(RetryAfter(5)) is True
def test_is_safe_to_retry_timed_out(self):
assert is_safe_to_retry_send(TimedOut("timeout")) is True
def test_is_not_safe_to_retry_invalid_token(self):
assert is_safe_to_retry_send(InvalidToken("bad")) is False
def test_is_not_safe_to_retry_forbidden(self):
assert is_safe_to_retry_send(Forbidden("blocked")) is False
def test_is_not_safe_to_retry_bad_request(self):
assert is_safe_to_retry_send(BadRequest("chat not found")) is False
def test_is_not_safe_to_retry_conflict(self):
assert is_safe_to_retry_send(Conflict("conflict")) is False
def test_is_safe_telegram_error_flood(self):
err = TelegramError("Too many requests: retry later")
assert is_safe_to_retry_send(err) is True
def test_is_not_safe_telegram_error_chat_not_found(self):
err = TelegramError("chat not found")
assert is_safe_to_retry_send(err) is False
def test_is_not_safe_telegram_error_kicked(self):
err = TelegramError("bot was kicked from the group")
assert is_safe_to_retry_send(err) is False
def test_is_not_safe_plain_exception(self):
assert is_safe_to_retry_send(ValueError("random")) is False
def test_classify_rate_limited(self):
result = classify_telegram_error(RetryAfter(5))
assert result["category"] == "rate_limited"
assert result["recoverable"] is True
assert result["retry_after"] == 5
def test_classify_auth_error(self):
result = classify_telegram_error(InvalidToken("bad"))
assert result["category"] == "auth_error"
assert result["recoverable"] is False
def test_classify_blocked_by_user(self):
result = classify_telegram_error(Forbidden("bot was blocked by the user"))
assert result["category"] == "blocked_by_user"
def test_classify_forbidden(self):
result = classify_telegram_error(Forbidden("access denied"))
assert result["category"] == "forbidden"
def test_classify_chat_not_found(self):
result = classify_telegram_error(BadRequest("chat not found"))
assert result["category"] == "chat_not_found"
def test_classify_message_not_found(self):
result = classify_telegram_error(BadRequest("message not found"))
assert result["category"] == "message_not_found"
def test_classify_bad_request(self):
result = classify_telegram_error(BadRequest("invalid"))
assert result["category"] == "bad_request"
def test_classify_network_error(self):
result = classify_telegram_error(TimedOut("timeout"))
assert result["category"] == "network_error"
assert result["recoverable"] is True
def test_classify_unknown(self):
result = classify_telegram_error(ValueError("unknown"))
assert result["category"] == "unknown"
assert result["recoverable"] is False
def test_get_retry_delay_retry_after(self):
delay = get_retry_delay(RetryAfter(7), 1)
assert delay == 7
def test_get_retry_delay_flood(self):
err = TelegramError("Too many requests: flood control")
delay = get_retry_delay(err, 2)
expected = min(60.0, 1.0 * (2 ** (2 + 1)))
assert delay == expected
def test_get_retry_delay_regular(self):
delay = get_retry_delay(TimedOut("timeout"), 0)
assert delay == 1.0
def test_get_retry_delay_with_exponential(self):
delay = get_retry_delay(TimedOut("timeout"), 2)
assert delay == 4.0
def test_get_retry_delay_max_cap(self):
delay = get_retry_delay(TimedOut("timeout"), 100)
assert delay == 60.0
# ============================================================================
# 2. send
# ============================================================================
class TestSend:
@pytest.fixture
def bot(self):
return AsyncMock(spec=Bot)
@pytest.mark.asyncio
async def test_send_with_retry_success(self, bot):
payload = {"chat_id": "123", "text": "hello"}
bot.send_message = AsyncMock(return_value=MagicMock(message_id=1))
result = await send_with_retry(bot, "123", payload)
assert result.message_id == 1
bot.send_message.assert_called_once()
@pytest.mark.asyncio
async def test_send_with_retry_retry_after(self, bot):
payload = {"chat_id": "123", "text": "hello"}
bot.send_message = AsyncMock(
side_effect=[RetryAfter(0.01), MagicMock(message_id=2)]
)
result = await send_with_retry(bot, "123", payload, {"retry": {"attempts": 3}})
assert result.message_id == 2
@pytest.mark.asyncio
async def test_send_with_retry_bad_request_parse(self, bot):
payload = {"chat_id": "123", "text": "<b>bad", "parse_mode": "HTML"}
bot.send_message = AsyncMock(
side_effect=[BadRequest("can't parse entities"), MagicMock(message_id=3)]
)
result = await send_with_retry(bot, "123", payload, {"retry": {"attempts": 2}})
assert result.message_id == 3
@pytest.mark.asyncio
async def test_send_with_retry_reply_not_found(self, bot):
payload = {"chat_id": "123", "text": "hi", "reply_to_message_id": 999}
bot.send_message = AsyncMock(
side_effect=[BadRequest("reply message not found"), MagicMock(message_id=4)]
)
result = await send_with_retry(bot, "123", payload, {"retry": {"attempts": 2}})
assert result.message_id == 4
@pytest.mark.asyncio
async def test_send_with_retry_all_retries_exhausted(self, bot):
payload = {"chat_id": "123", "text": "hello"}
bot.send_message = AsyncMock(side_effect=TimedOut("timeout"))
with pytest.raises(TimedOut):
await send_with_retry(bot, "123", payload, {"retry": {"attempts": 2, "min_delay_ms": 10}})
@pytest.mark.asyncio
async def test_send_stream_edit_success(self):
app = MagicMock()
app.bot = AsyncMock()
app.bot.edit_message_text = AsyncMock()
await send_stream_edit(app, "123", "1", "hello")
app.bot.edit_message_text.assert_called_once()
@pytest.mark.asyncio
async def test_send_stream_edit_bad_request(self):
app = MagicMock()
app.bot = AsyncMock()
app.bot.edit_message_text = AsyncMock(side_effect=BadRequest("can't parse entities"))
await send_stream_edit(app, "123", "1", "hello")
@pytest.mark.asyncio
async def test_send_stream_edit_retry_after(self):
app = MagicMock()
app.bot = AsyncMock()
app.bot.edit_message_text = AsyncMock(side_effect=RetryAfter(0.01))
await send_stream_edit(app, "123", "1", "hello")
@pytest.mark.asyncio
async def test_send_with_format_fallback_success(self, bot):
bot.send_message = AsyncMock(return_value=MagicMock(message_id=1))
result = await send_with_format_fallback(bot, "123", "hello")
assert result.message_id == 1
@pytest.mark.asyncio
async def test_send_with_format_fallback_parse_error(self, bot):
bot.send_message = AsyncMock(
side_effect=[BadRequest("can't parse entities"), MagicMock(message_id=2)]
)
result = await send_with_format_fallback(bot, "123", "<b>bad")
assert result.message_id == 2
def test_build_reply_parameters_off(self):
response = MagicMock()
result = build_reply_parameters(response, "off")
assert result is None
def test_build_reply_parameters_first_with_id(self):
response = MagicMock()
response.reply_to_message_id = 42
response.metadata = {"thread_id": "5"}
result = build_reply_parameters(response, "first")
assert result is not None
assert result["reply_to_message_id"] == 42
def test_build_reply_parameters_first_without_id(self):
response = MagicMock()
response.reply_to_message_id = None
result = build_reply_parameters(response, "first")
assert result is None
def test_build_reply_parameters_unknown_mode(self):
response = MagicMock()
response.reply_to_message_id = 42
result = build_reply_parameters(response, "unknown")
assert result is None
def test_build_quote_payload_with_quote(self):
response = MagicMock()
response.reply_to_message_id = 42
response.quote_text = "hello world"
result = build_quote_payload(response)
assert result is not None
assert result["reply_parameters"].message_id == 42
def test_build_quote_payload_no_quote(self):
response = MagicMock()
response.reply_to_message_id = 42
response.quote_text = None
result = build_quote_payload(response)
assert result is None
def test_build_quote_payload_dict(self):
response = {"reply_to_message_id": 10, "quote_text": "test"}
result = build_quote_payload(response)
assert result is not None
# ============================================================================
# 3. inline_buttons
# ============================================================================
class TestInlineButtons:
def test_build_inline_keyboard_empty(self):
assert build_inline_keyboard([]) is None
assert build_inline_keyboard(None) is None
def test_build_inline_keyboard_simple(self):
buttons = [[{"text": "Click", "callback_data": "click1"}]]
result = build_inline_keyboard(buttons)
assert isinstance(result, InlineKeyboardMarkup)
assert len(result.inline_keyboard) == 1
def test_build_inline_keyboard_with_url(self):
buttons = [[{"text": "Go", "url": "https://example.com"}]]
result = build_inline_keyboard(buttons)
assert isinstance(result, InlineKeyboardMarkup)
def test_build_inline_keyboard_scope_off(self):
buttons = [[{"text": "Click", "callback_data": "c"}]]
result = build_inline_keyboard(buttons, scope="off")
assert result is None
def test_build_inline_keyboard_scope_dm_direct(self):
buttons = [[{"text": "Click", "callback_data": "c"}]]
result = build_inline_keyboard(buttons, scope="dm", chat_type="direct")
assert isinstance(result, InlineKeyboardMarkup)
def test_build_inline_keyboard_scope_dm_group(self):
buttons = [[{"text": "Click", "callback_data": "c"}]]
result = build_inline_keyboard(buttons, scope="dm", chat_type="group")
assert result is None
def test_build_inline_keyboard_scope_group_direct(self):
buttons = [[{"text": "Click", "callback_data": "c"}]]
result = build_inline_keyboard(buttons, scope="group", chat_type="direct")
assert result is None
def test_build_inline_keyboard_scope_allowlist_match(self):
buttons = [[{"text": "Click", "callback_data": "c"}]]
result = build_inline_keyboard(buttons, scope="allowlist", allowlist_chats={"123"}, chat_id="123")
assert isinstance(result, InlineKeyboardMarkup)
def test_build_inline_keyboard_scope_allowlist_no_match(self):
buttons = [[{"text": "Click", "callback_data": "c"}]]
result = build_inline_keyboard(buttons, scope="allowlist", allowlist_chats={"456"}, chat_id="123")
assert result is None
def test_build_inline_keyboard_single_dict(self):
buttons = [{"text": "Click", "callback_data": "c"}]
result = build_inline_keyboard(buttons)
assert isinstance(result, InlineKeyboardMarkup)
def test_build_model_selector_buttons(self):
result = build_model_selector_buttons(["gpt-4", "gpt-3.5"], "gpt-4")
assert len(result) == 2
assert "\u2705" in result[0]["text"]
def test_build_exec_approval_buttons(self):
result = build_exec_approval_buttons("approval123")
assert len(result) == 1
assert len(result[0]) == 2
assert result[0][0]["callback_data"] == "exec:approve:approval123"
assert result[0][1]["callback_data"] == "exec:reject:approval123"
def test_build_provider_browse_keyboard(self):
result = build_provider_browse_keyboard(["openai", "anthropic"], "openai")
assert len(result) == 2
assert "\u2705" in result[0][0]["text"]
def test_build_paginated_command_keyboard_single_page(self):
commands = [{"command": "help", "label": "Help"}]
result = build_paginated_command_keyboard(commands)
assert len(result) >= 1
def test_build_paginated_command_keyboard_multi_page(self):
commands = [{"command": f"cmd{i}", "label": f"C{i}"} for i in range(20)]
result = build_paginated_command_keyboard(commands, page=0, page_size=8)
assert len(result) > 0
nav_row = result[-1]
assert any("下一页" in btn["text"] for btn in nav_row)
# ============================================================================
# 4. targets
# ============================================================================
class TestTargets:
def test_parse_telegram_target_chat_id(self):
result = parse_telegram_target("12345")
assert result == {"chat_id": "12345", "type": "chat_id"}
def test_parse_telegram_target_tg_prefix(self):
result = parse_telegram_target("tg://12345")
assert result == {"chat_id": "12345", "type": "chat_id"}
def test_parse_telegram_target_username(self):
result = parse_telegram_target("@testbot")
assert result == {"username": "testbot", "type": "username"}
def test_parse_telegram_target_telegram_prefix(self):
result = parse_telegram_target("telegram://@user")
assert result == {"username": "user", "type": "username"}
def test_parse_telegram_target_invalid(self):
assert parse_telegram_target("not valid") is None
def test_parse_telegram_target_short_tg_prefix(self):
result = parse_telegram_target("tg:12345")
assert result == {"chat_id": "12345", "type": "chat_id"}
def test_parse_telegram_target_telegram_colon(self):
result = parse_telegram_target("telegram:@user")
assert result == {"username": "user", "type": "username"}
def test_parse_messaging_target_with_thread(self):
result = parse_messaging_target("123/456")
assert result == {"chat_id": "123", "type": "chat_id", "thread_id": "456"}
def test_parse_messaging_target_without_thread(self):
result = parse_messaging_target("123")
assert result == {"chat_id": "123", "type": "chat_id"}
def test_normalize_telegram_chat_id(self):
assert normalize_telegram_chat_id("-10012345") == "12345"
assert normalize_telegram_chat_id("12345") == "12345"
def test_normalize_telegram_target(self):
assert normalize_telegram_target("tg://12345") == "tg:12345"
assert normalize_telegram_target("@test") == "tg:@test"
assert normalize_telegram_target("invalid") == "invalid"
# ============================================================================
# 5. targets.normalize
# ============================================================================
class TestTargetsNormalize:
def test_strip_internal_prefixes_tg(self):
assert strip_internal_prefixes("tg:12345") == "12345"
def test_strip_internal_prefixes_tg_slash(self):
assert strip_internal_prefixes("tg:12345") == "12345"
def test_strip_internal_prefixes_telegram(self):
assert strip_internal_prefixes("telegram:12345") == "12345"
def test_strip_internal_prefixes_telegram_slash(self):
assert strip_internal_prefixes("telegram:12345") == "12345"
def test_strip_internal_prefixes_plain(self):
assert strip_internal_prefixes("12345") == "12345"
def test_strip_internal_prefixes_non_digit(self):
assert strip_internal_prefixes("tg:@user") == "@user"
def test_normalize_chat_id(self):
assert normalize_chat_id("-10012345") == "12345"
assert normalize_chat_id("12345") == "12345"
assert normalize_chat_id(-10067890) == "67890"
def test_normalize_user_id(self):
assert normalize_user_id("12345") == "12345"
assert normalize_user_id(67890) == "67890"
def test_normalize_group_id_negative_100(self):
assert normalize_group_id("-10012345") == "12345"
def test_normalize_group_id_negative(self):
assert normalize_group_id("-12345") == "12345"
def test_normalize_group_id_positive(self):
assert normalize_group_id("12345") == "12345"
def test_format_chat_identifier_with_username(self):
assert format_chat_identifier("12345", "group", "testuser") == "@testuser"
def test_format_chat_identifier_without_username(self):
assert format_chat_identifier("-10012345", "group") == "12345"
# ============================================================================
# 6. target_writeback
# ============================================================================
class TestTargetWriteback:
@pytest.mark.asyncio
async def test_writeback_updates_monitored_chats(self):
adapter = MagicMock()
adapter.config = {"monitored_chats": ["old123", "other456"]}
result = await handle_target_writeback(adapter, "old123", "new789")
assert result["updated"] is True
assert adapter.config["monitored_chats"] == ["new789", "other456"]
@pytest.mark.asyncio
async def test_writeback_updates_groups(self):
adapter = MagicMock()
adapter.config = {"groups": {"old123": {"name": "test"}}}
await handle_target_writeback(adapter, "old123", "new789")
assert "new789" in adapter.config["groups"]
@pytest.mark.asyncio
async def test_writeback_no_match(self):
adapter = MagicMock()
adapter.config = {"monitored_chats": [], "groups": {}}
result = await handle_target_writeback(adapter, "old123", "new789")
assert result["updated"] is True
# ============================================================================
# 7. debounce
# ============================================================================
class TestMessageDebouncer:
@pytest.fixture
def debouncer(self):
return MessageDebouncer(max_entries=5, ttl_seconds=60)
def test_build_debounce_key_with_message(self, debouncer):
update = {"message": {"chat": {"id": 123}, "message_id": 1, "text": "hello"}}
key = debouncer.build_debounce_key(update)
assert key is not None
assert len(key) == 16
def test_build_debounce_key_without_message(self, debouncer):
update = {"other": "data"}
key = debouncer.build_debounce_key(update)
assert key is None
def test_build_debounce_key_with_edited_message(self, debouncer):
update = {"edited_message": {"chat": {"id": 123}, "message_id": 1, "text": "hello"}}
key = debouncer.build_debounce_key(update)
assert key is not None
def test_build_debounce_key_with_channel_post(self, debouncer):
update = {"channel_post": {"chat": {"id": 123}, "message_id": 1, "text": "hello"}}
key = debouncer.build_debounce_key(update)
assert key is not None
def test_is_duplicate_first_time(self, debouncer):
key = "test_key_123"
assert debouncer.is_duplicate(key) is False
def test_is_duplicate_second_time(self, debouncer):
key = "test_key_456"
debouncer.is_duplicate(key)
assert debouncer.is_duplicate(key) is True
def test_cache_size(self, debouncer):
debouncer.is_duplicate("a")
debouncer.is_duplicate("b")
assert debouncer.cache_size() == 2
def test_cache_cleanup_by_ttl(self):
debouncer = MessageDebouncer(max_entries=5, ttl_seconds=60)
debouncer.is_duplicate("old_key")
assert debouncer.cache_size() == 1
assert debouncer.is_duplicate("old_key") is True
def test_cache_max_entries(self):
debouncer = MessageDebouncer(max_entries=3, ttl_seconds=3600)
for i in range(5):
debouncer.is_duplicate(f"key_{i}")
assert debouncer.cache_size() == 3
def test_clear_entry(self, debouncer):
debouncer.is_duplicate("test_key")
debouncer.clear_entry("test_key")
assert debouncer.cache_size() == 0
class TestConnectTimeoutConfig:
def test_default_values(self):
cfg = ConnectTimeoutConfig()
assert cfg.connect_timeout == 30
assert cfg.read_timeout == 60
assert cfg.write_timeout == 30
assert cfg.pool_timeout == 30
def test_custom_values(self):
cfg = ConnectTimeoutConfig({"connect_timeout": 5, "read_timeout": 10})
assert cfg.connect_timeout == 5
assert cfg.read_timeout == 10
def test_get_request_kwargs(self):
cfg = ConnectTimeoutConfig({"connect_timeout": 5, "read_timeout": 10})
kwargs = cfg.get_request_kwargs()
assert "connect_timeout" in kwargs
assert kwargs["connect_timeout"] == 5
# ============================================================================
# 8. tools
# ============================================================================
class TestSequentialKeys:
def test_generate_sequential_key(self):
assert generate_sequential_key("telegram", "123") == "telegram:chat:123"
def test_generate_debounce_key(self):
assert generate_debounce_key("telegram", "123", "456") == "telegram:debounce:123:456"
def test_generate_group_sequential_key(self):
assert generate_group_sequential_key("telegram", "789") == "telegram:group:789"
def test_generate_topic_sequential_key(self):
assert generate_topic_sequential_key("telegram", "123", "456") == "telegram:topic:123:456"
class TestRequestTimeouts:
def test_default_timeouts(self):
assert DEFAULT_TIMEOUTS["send_message"] == 30.0
assert DEFAULT_TIMEOUTS["send_media"] == 60.0
assert DEFAULT_TIMEOUTS["get_me"] == 10.0
def test_get_timeout_default(self):
assert get_timeout("send_message") == 30.0
def test_get_timeout_custom(self):
assert get_timeout("send_message", {"request_timeouts": {"send_message": 45}}) == 45.0
def test_get_timeout_unknown(self):
assert get_timeout("unknown_op") == 30.0
def test_get_timeout_config(self):
cfg = get_timeout_config()
assert "send_message" in cfg
assert cfg["send_message"] == 30.0
def test_resolve_global_timeout_default(self):
assert resolve_global_timeout() == 500
def test_resolve_global_timeout_custom(self):
assert resolve_global_timeout({"timeout_seconds": 30}) == 30
def test_dotsg_timeout_constant(self):
assert DEFAULT_GLOBAL_TIMEOUT_SECONDS == 500
class TestSendChatAction401Backoff:
@pytest.fixture
def backoff(self):
return SendChatAction401Backoff()
def test_initial_state(self, backoff):
assert backoff.can_send() is True
@pytest.mark.asyncio
async def test_successful_execute(self, backoff):
bot = AsyncMock()
bot.send_chat_action = AsyncMock()
result = await backoff.execute(bot, "123", "typing")
assert result is True
@pytest.mark.asyncio
async def test_401_backoff(self, backoff):
bot = AsyncMock()
bot.send_chat_action = AsyncMock(
side_effect=Exception("401 Unauthorized")
)
result = await backoff.execute(bot, "123", "typing")
assert result is False
@pytest.mark.asyncio
async def test_block_during_backoff(self, backoff):
bot = AsyncMock()
bot.send_chat_action = AsyncMock(
side_effect=Exception("401 Unauthorized")
)
await backoff.execute(bot, "123", "typing")
assert backoff.can_send() is False
def test_reset(self, backoff):
backoff._backoff_until = time.monotonic() + 100
backoff._consecutive_failures = 5
backoff.reset()
assert backoff.can_send() is True
assert backoff._consecutive_failures == 0
# ============================================================================
# 9. reaction_level & reaction_notifications
# ============================================================================
class TestReactionLevelController:
def test_default_level(self):
ctrl = ReactionLevelController()
assert ctrl._level == "minimal"
def test_custom_level(self):
ctrl = ReactionLevelController({"reaction_level": "ack"})
assert ctrl._level == "ack"
def test_should_react_off(self):
ctrl = ReactionLevelController({"reaction_level": "off"})
assert ctrl.should_react("message.received") is False
def test_should_react_ack_message_received(self):
ctrl = ReactionLevelController({"reaction_level": "ack"})
assert ctrl.should_react("message.received") is True
def test_should_react_ack_other(self):
ctrl = ReactionLevelController({"reaction_level": "ack"})
assert ctrl.should_react("other.event") is False
def test_should_react_minimal(self):
ctrl = ReactionLevelController({"reaction_level": "minimal"})
assert ctrl.should_react("anything") is True
def test_get_emojis(self):
ctrl = ReactionLevelController()
assert ctrl.get_ack_emoji() == "\u2705"
assert ctrl.get_error_emoji() == "\u274c"
assert ctrl.get_processing_emoji() == "\u23f3"
def test_get_reaction_level_func(self):
assert get_reaction_level({}) == "minimal"
assert get_reaction_level({"reaction_level": "off"}) == "off"
class TestReactionNotifications:
def test_default_mode(self):
rn = ReactionNotifications()
assert rn.get_mode() == "all"
def test_mode_off(self):
rn = ReactionNotifications({"reaction_notifications": "off"})
assert rn.should_notify("bot", "other") is False
def test_mode_own_match(self):
rn = ReactionNotifications({"reaction_notifications": "own"})
assert rn.should_notify("bot", "bot") is True
def test_mode_own_no_match(self):
rn = ReactionNotifications({"reaction_notifications": "own"})
assert rn.should_notify("bot", "other") is False
def test_mode_all(self):
rn = ReactionNotifications({"reaction_notifications": "all"})
assert rn.should_notify("bot", "anyone") is True
# ============================================================================
# 10. security_audit
# ============================================================================
class TestSecurityAudit:
def test_audit_security_policy_open_dm(self):
result = audit_security_policy({"dm_policy": "open", "group_policy": "allowlist"})
assert result["dm_policy"] == "open"
assert any(i["type"] == "dm_policy_open" for i in result["issues"])
def test_audit_security_policy_open_group(self):
result = audit_security_policy({"dm_policy": "pairing", "group_policy": "open"})
assert any(i["type"] == "group_policy_open" for i in result["issues"])
def test_audit_security_policy_all_disabled(self):
result = audit_security_policy({"dm_policy": "disabled", "group_policy": "disabled"})
assert any(i["type"] == "all_disabled" for i in result["issues"])
def test_audit_security_policy_empty_allowlist(self):
result = audit_security_policy({"dm_policy": "allowlist", "allow_from": []})
assert any(i["type"] == "empty_allowlist" for i in result["issues"])
def test_audit_security_policy_no_approvers(self):
config = {"exec_approvals": {"enabled": True, "approvers": []}}
result = audit_security_policy(config)
assert any(i["type"] == "no_approvers" for i in result["issues"])
def test_audit_security_policy_no_issues(self):
result = audit_security_policy({"dm_policy": "pairing", "group_policy": "allowlist", "allow_from": ["tg:123"]})
assert result["issues_count"] == 0
def test_audit_dm_access(self):
result = audit_dm_access({"dm_policy": "allowlist", "allow_from": ["tg:111"]}, ["111", "999"])
assert result["allowed_users"] == 1
assert len(result["unknown_users"]) == 1
assert "999" in result["unknown_users"]
# ============================================================================
# 11. topic_routing
# ============================================================================
class TestTopicRouter:
@pytest.fixture
def router(self):
return TopicRouter({
"groups": {
"-100xxx": {
"agent_id": "group_agent",
"topics": {
"42": {"agent_id": "topic_agent"},
},
},
},
"default_agent_id": "default_agent",
})
def test_resolve_topic_agent_specific(self, router):
assert router.resolve_topic_agent("-100xxx", "42") == "topic_agent"
def test_resolve_topic_agent_fallback_group(self, router):
assert router.resolve_topic_agent("-100xxx", "99") == "group_agent"
def test_resolve_topic_agent_fallback_default(self, router):
assert router.resolve_topic_agent("-999", "42") == "default_agent"
def test_resolve_group_agent(self, router):
assert router.resolve_group_agent("-100xxx") == "group_agent"
def test_resolve_group_agent_default(self, router):
assert router.resolve_group_agent("-999") == "default_agent"
def test_resolve_route_direct(self, router):
route = router.resolve_route("123", "private")
assert route.startswith("agent:default_agent:telegram:direct:123")
def test_resolve_route_group(self, router):
route = router.resolve_route("-100xxx", "group")
assert "group_agent" in route
def test_resolve_route_topic(self, router):
route = router.resolve_route("-100xxx", "group", "42")
assert "topic_agent" in route
assert "topic:42" in route
# ============================================================================
# 12. format (supplement)
# ============================================================================
class TestFormatSupplement:
def test_convert_underline(self):
assert _convert_underline("__hello__") == "<u>hello</u>"
def test_convert_spoiler(self):
assert _convert_spoiler("||secret||") == "<tg-spoiler>secret</tg-spoiler>"
def test_convert_strikethrough(self):
assert _convert_strikethrough("~~deleted~~") == "<s>deleted</s>"
def test_convert_bold_direct(self):
assert _convert_bold("**hello**") == "<b>hello</b>"
def test_convert_italic_direct(self):
assert "<i>hello</i>" in _convert_italic("*hello*")
def test_convert_links_direct(self):
result = _convert_links("[text](https://example.com)")
assert '<a href="https://example.com">text</a>' in result
def test_sanitize_html_entity_escape(self):
result = _sanitize_html("a > b && c")
assert "&amp;" in result
def test_strip_html_tags_alias(self):
result = strip_html_tags("<b>hello</b>")
assert "hello" in result
def test_markdown_to_html_underline(self):
result = markdown_to_html("__test__")
assert "<u>test</u>" in result
def test_markdown_to_html_mixed(self):
result = markdown_to_html("**bold** and *italic* and ~~strike~~ and __underline__")
assert "<b>bold</b>" in result
assert "<i>italic</i>" in result
assert "<s>strike</s>" in result
assert "<u>underline</u>" in result
# ============================================================================
# 13. chunking (supplement)
# ============================================================================
class TestChunkingSupplement:
def test_chunk_by_newline_mode(self):
line = "line" * 100
big_text = f"{line}\n{line}\n{line}"
result = chunk_text(big_text, limit=100, mode="newline")
assert len(result) >= 1
def test_chunk_by_length_mode(self):
result = chunk_text("hello world" * 1000, limit=100, mode="length")
assert len(result) > 1
def test_chunk_mode_from_config_default(self):
assert chunk_mode_from_config() == "paragraph"
def test_chunk_mode_from_config_streaming(self):
cfg = {"streaming": {"chunk_mode": "newline"}}
assert chunk_mode_from_config(cfg) == "newline"
def test_chunk_mode_from_config_direct(self):
cfg = {"chunking_mode": "length"}
assert chunk_mode_from_config(cfg) in ("paragraph", "length")
# ============================================================================
# 14. adapter (supplement)
# ============================================================================
class TestAdapterSupplement:
@pytest.fixture
def adapter(self):
return TelegramAdapter(config={"bot_token": "123:abc", "dm_policy": "open", "reply_to_mode": "first"})
def test_build_command_scope_default_str(self):
assert _build_command_scope("default") is None
def test_build_command_scope_all_private(self):
scope = _build_command_scope("all_private_chats")
assert scope is not None
assert scope.type == "all_private_chats"
def test_build_command_scope_all_group(self):
scope = _build_command_scope("all_group_chats")
assert scope is not None
assert scope.type == "all_group_chats"
def test_build_command_scope_all_admin(self):
scope = _build_command_scope("all_chat_administrators")
assert scope is not None
assert scope.type == "all_chat_administrators"
def test_build_command_scope_dict(self):
scope = _build_command_scope({"type": "all_private_chats"})
assert scope is not None
assert scope.type == "all_private_chats"
def test_build_command_scope_dict_default(self):
assert _build_command_scope({"type": "default"}) is None
def test_build_command_scope_unknown(self):
assert _build_command_scope("unknown_type") is None
def test_normalize_channel_id(self, adapter):
assert adapter._normalize_channel_id("-10012345") == "12345"
assert adapter._normalize_channel_id("12345") == "12345"
def test_make_skip_event(self, adapter):
msg = make_message(text="duplicate")
update = make_update(msg)
result = adapter._make_skip_event(update)
assert result.metadata["skip"] is True
def test_is_bot_command_with_entities(self, adapter):
entities = (MessageEntity(type=MessageEntity.BOT_COMMAND, offset=0, length=6),)
msg = make_message(text="/start", entities=entities)
assert adapter._is_bot_command(msg) is True
def test_is_bot_command_without_entities(self, adapter):
msg = make_message(text="/start")
assert adapter._is_bot_command(msg) is True
def test_is_bot_command_not_command(self, adapter):
msg = make_message(text="hello")
assert adapter._is_bot_command(msg) is False
def test_is_local_file_path_trusted_no_roots(self, adapter):
assert adapter.is_local_file_path_trusted("/some/path") is False
def test_is_local_file_path_trusted_with_roots(self):
import tempfile
import os
root = tempfile.gettempdir()
adapter = TelegramAdapter(config={"bot_token": "123:abc", "trusted_local_file_roots": [root]})
test_path = os.path.join(root, "test.txt")
assert adapter.is_local_file_path_trusted(test_path) is True
@pytest.mark.asyncio
async def test_verify_webhook_signature_no_secret(self, adapter):
assert await adapter.verify_webhook_signature({}, b"test") is True
@pytest.mark.asyncio
async def test_verify_webhook_signature_match(self):
adapter = TelegramAdapter(config={"bot_token": "123:abc", "webhook_secret": "mysecret"})
assert await adapter.verify_webhook_signature({"X-Telegram-Bot-Api-Secret-Token": "mysecret"}, b"test") is True
@pytest.mark.asyncio
async def test_verify_webhook_signature_mismatch(self):
adapter = TelegramAdapter(config={"bot_token": "123:abc", "webhook_secret": "mysecret"})
assert await adapter.verify_webhook_signature({"X-Telegram-Bot-Api-Secret-Token": "wrong"}, b"test") is False
def test_properties(self, adapter):
assert adapter.history_limit == 50
assert adapter.enabled is True
assert adapter.silent_error_replies is False
assert adapter.trusted_local_file_roots == []
assert adapter.config_writes is True
def test_commands_native_default(self, adapter):
assert adapter._get_commands_native() == "auto"
def test_commands_native_custom(self):
adapter = TelegramAdapter(config={"bot_token": "123:abc", "commands": {"native": "never"}})
assert adapter._get_commands_native() == "never"
def test_max_media_size_from_config(self):
adapter = TelegramAdapter(config={"bot_token": "123:abc", "max_media_size_mb": 50})
assert adapter.max_media_size_mb == 50
# ------------------------------------------------------------------
# format_outbound supplement
# ------------------------------------------------------------------
def test_format_outbound_silent(self, adapter):
identity = ChannelIdentity(
channel_id="telegram",
channel_type=ChannelType.TELEGRAM,
channel_user_id="12345",
channel_chat_id="-10012345",
)
adapter.config["silent"] = True
response = ChannelResponse(identity=identity, content="silent msg")
payload = adapter.format_outbound(response)
assert payload["disable_notification"] is True
def test_format_outbound_reply_mode_all(self, adapter):
identity = ChannelIdentity(
channel_id="telegram",
channel_type=ChannelType.TELEGRAM,
channel_user_id="12345",
channel_chat_id="-10012345",
channel_message_id="5",
)
adapter.config["reply_to_mode"] = "all"
response = ChannelResponse(identity=identity, content="reply all")
payload = adapter.format_outbound(response)
assert payload["reply_to_message_id"] == 5
def test_format_outbound_image_attachment(self, adapter):
identity = ChannelIdentity(
channel_id="telegram",
channel_type=ChannelType.TELEGRAM,
channel_user_id="12345",
channel_chat_id="-10012345",
)
response = ChannelResponse(
identity=identity,
content="image caption",
message_type=MessageType.IMAGE,
attachments=[Attachment(type="image", file_id="file_123")],
)
payload = adapter.format_outbound(response)
assert "photo" in payload
assert "caption" in payload
def test_format_outbound_file_attachment(self, adapter):
identity = ChannelIdentity(
channel_id="telegram",
channel_type=ChannelType.TELEGRAM,
channel_user_id="12345",
channel_chat_id="-10012345",
)
response = ChannelResponse(
identity=identity,
content="file caption",
message_type=MessageType.FILE,
attachments=[Attachment(type="file", file_id="file_456")],
)
payload = adapter.format_outbound(response)
assert "document" in payload
assert "caption" in payload
def test_build_reply_markup_none(self, adapter):
response = ChannelResponse(
identity=ChannelIdentity(
channel_id="telegram",
channel_type=ChannelType.TELEGRAM,
channel_user_id="123",
channel_chat_id="-100",
),
content="test",
)
assert adapter._build_reply_markup(response) is None
def test_build_reply_markup_force_reply(self, adapter):
response = ChannelResponse(
identity=ChannelIdentity(
channel_id="telegram",
channel_type=ChannelType.TELEGRAM,
channel_user_id="123",
channel_chat_id="-100",
),
content="test",
metadata={"reply_markup": {"type": "force_reply"}},
)
result = adapter._build_reply_markup(response)
assert isinstance(result, ForceReply)
def test_build_reply_markup_keyboard(self, adapter):
response = ChannelResponse(
identity=ChannelIdentity(
channel_id="telegram",
channel_type=ChannelType.TELEGRAM,
channel_user_id="123",
channel_chat_id="-100",
),
content="test",
metadata={
"reply_markup": {
"type": "keyboard",
"keyboard": [["Button1", "Button2"]],
}
},
)
result = adapter._build_reply_markup(response)
assert isinstance(result, ReplyKeyboardMarkup)
# ------------------------------------------------------------------
# normalize_inbound supplement
# ------------------------------------------------------------------
def test_normalize_member_joined(self, adapter):
msg = MagicMock(spec=Message)
msg.text = "joined"
msg.new_chat_members = [make_user(id=999)]
msg.chat = make_chat(id=-100999, chat_type="supergroup")
msg.message_id = 99
msg.from_user = make_user()
msg.date = _NOW
msg.photo = None
msg.audio = None
msg.voice = None
msg.video = None
msg.document = None
msg.sticker = None
msg.animation = None
msg.video_note = None
msg.entities = None
msg.pinned_message = None
msg.reply_to_message = None
update = make_update(msg)
result = adapter.normalize_inbound(update)
assert result.event_type == EventType.MEMBER_JOINED
def test_normalize_member_left(self, adapter):
msg = MagicMock(spec=Message)
msg.text = "left"
msg.new_chat_members = None
msg.left_chat_member = make_user(id=999)
msg.chat = make_chat(id=-100999, chat_type="supergroup")
msg.message_id = 99
msg.from_user = make_user()
msg.date = _NOW
msg.photo = None
msg.audio = None
msg.voice = None
msg.video = None
msg.document = None
msg.sticker = None
msg.animation = None
msg.video_note = None
msg.entities = None
msg.pinned_message = None
msg.reply_to_message = None
update = make_update(msg)
result = adapter.normalize_inbound(update)
assert result.event_type == EventType.MEMBER_LEFT
def test_normalize_pinned_message(self, adapter):
pinned_msg = make_message(text="pinned", message_id=99)
msg = MagicMock(spec=Message)
msg.text = "pinned"
msg.new_chat_members = None
msg.left_chat_member = None
msg.pinned_message = pinned_msg
msg.chat = make_chat(id=-100999, chat_type="supergroup")
msg.message_id = 99
msg.from_user = make_user()
msg.date = _NOW
msg.photo = None
msg.audio = None
msg.voice = None
msg.video = None
msg.document = None
msg.sticker = None
msg.animation = None
msg.video_note = None
msg.entities = None
msg.reply_to_message = None
update = make_update(msg)
result = adapter.normalize_inbound(update)
assert result.event_type == EventType.SYSTEM_EVENT
def test_normalize_channel_type_chat(self, adapter):
chat = make_chat(id=-100111, chat_type="channel")
msg = make_message(text="channel msg", chat=chat)
update = make_update(msg)
result = adapter.normalize_inbound(update)
assert result.chat_type == ChatType.GUILD_CHANNEL
def test_normalize_with_username_metadata(self, adapter):
chat = Chat(id=-100999, type="supergroup", username="testgroup", title="Test Group")
msg = make_message(text="hi", chat=chat)
update = make_update(msg)
result = adapter.normalize_inbound(update)
assert result.metadata["chat_username"] == "testgroup"
assert result.metadata["chat_title"] == "Test Group"
def test_normalize_reply_to_metadata(self, adapter):
replied = make_message(text="original", message_id=50)
msg = MagicMock(spec=Message)
msg.text = "reply"
msg.reply_to_message = replied
msg.chat = make_chat(id=-100999, chat_type="supergroup")
msg.message_id = 99
msg.from_user = make_user()
msg.date = _NOW
msg.photo = None
msg.audio = None
msg.voice = None
msg.video = None
msg.document = None
msg.sticker = None
msg.animation = None
msg.video_note = None
msg.entities = None
msg.pinned_message = None
update = make_update(msg)
result = adapter.normalize_inbound(update)
assert result.metadata["reply_to_message_id"] == "50"
assert result.reply_to_message_id == "50"
def test_normalize_caption_as_content(self, adapter):
msg = MagicMock(spec=Message)
msg.text = None
msg.caption = "image caption"
msg.chat = make_chat(id=-10012345)
msg.message_id = 99
msg.from_user = make_user()
msg.date = _NOW
msg.photo = None
msg.audio = None
msg.voice = None
msg.video = None
msg.document = None
msg.sticker = None
msg.animation = None
msg.video_note = None
msg.entities = None
msg.pinned_message = None
msg.reply_to_message = None
update = make_update(msg)
result = adapter.normalize_inbound(update)
assert result.content == "image caption"
def test_normalize_callback_query_passthrough(self, adapter):
update = MagicMock(spec=Update)
update.update_id = 1
update.message = None
update.edited_message = None
update.my_chat_member = None
update.chat_member = None
update.poll = None
update.poll_answer = None
update.message_reaction = None
update.message_reaction_count = None
update.chat_join_request = None
cb = MagicMock(spec=CallbackQuery)
cb.from_user = make_user(id=123)
cb.data = "some_data"
cb.message = MagicMock()
cb.message.chat = make_chat(id=-10012345)
update.callback_query = cb
result = adapter.normalize_inbound(update)
assert result.event_type == EventType.CARD_ACTION
assert result.content == "some_data"
# ------------------------------------------------------------------
# send methods - app not initialized
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_send_not_initialized(self, adapter):
identity = ChannelIdentity(
channel_id="telegram",
channel_type=ChannelType.TELEGRAM,
channel_user_id="123",
channel_chat_id="-100",
)
response = ChannelResponse(identity=identity, content="test")
result = await adapter.send(response)
assert result.success is False
assert "not initialized" in result.error.lower()
@pytest.mark.asyncio
async def test_send_media_not_initialized(self, adapter):
result = await adapter.send_media("123", "image", b"data")
assert result.success is False
@pytest.mark.asyncio
async def test_edit_message_not_initialized(self, adapter):
result = await adapter.edit_message("123", "1", "text")
assert result.success is False
@pytest.mark.asyncio
async def test_delete_message_not_initialized(self, adapter):
result = await adapter.delete_message("123", "1")
assert result.success is False
@pytest.mark.asyncio
async def test_send_reaction_not_initialized(self, adapter):
result = await adapter.send_reaction("123", "1", "\U0001f44d")
assert result.success is False
@pytest.mark.asyncio
async def test_send_chat_action_not_initialized(self, adapter):
result = await adapter.send_chat_action("123")
assert result.success is False
@pytest.mark.asyncio
async def test_pin_message_not_initialized(self, adapter):
result = await adapter.pin_message("123", "1")
assert result.success is False
@pytest.mark.asyncio
async def test_unpin_message_not_initialized(self, adapter):
result = await adapter.unpin_message("123", "1")
assert result.success is False
@pytest.mark.asyncio
async def test_unpin_all_not_initialized(self, adapter):
result = await adapter.unpin_all_messages("123")
assert result.success is False
@pytest.mark.asyncio
async def test_forward_message_not_initialized(self, adapter):
result = await adapter.forward_message("123", "456", "1")
assert result.success is False
@pytest.mark.asyncio
async def test_send_media_group_not_initialized(self, adapter):
result = await adapter.send_media_group("123", [])
assert result.success is False
@pytest.mark.asyncio
async def test_send_location_not_initialized(self, adapter):
result = await adapter.send_location("123", 0, 0)
assert result.success is False
@pytest.mark.asyncio
async def test_send_contact_not_initialized(self, adapter):
result = await adapter.send_contact("123", "12345", "Test")
assert result.success is False
@pytest.mark.asyncio
async def test_create_forum_topic_not_initialized(self, adapter):
result = await adapter.create_forum_topic("123", "test")
assert result.success is False
@pytest.mark.asyncio
async def test_send_dice_not_initialized(self, adapter):
result = await adapter.send_dice("123")
assert result.success is False
@pytest.mark.asyncio
async def test_edit_forum_topic_not_initialized(self, adapter):
result = await adapter.edit_forum_topic("123", "456")
assert result.success is False
@pytest.mark.asyncio
async def test_close_forum_topic_not_initialized(self, adapter):
result = await adapter.close_forum_topic("123", "456")
assert result.success is False
@pytest.mark.asyncio
async def test_reopen_forum_topic_not_initialized(self, adapter):
result = await adapter.reopen_forum_topic("123", "456")
assert result.success is False
@pytest.mark.asyncio
async def test_delete_forum_topic_not_initialized(self, adapter):
result = await adapter.delete_forum_topic("123", "456")
assert result.success is False
@pytest.mark.asyncio
async def test_kick_member_not_initialized(self, adapter):
result = await adapter.kick_member("123", "456")
assert result.success is False
@pytest.mark.asyncio
async def test_ban_member_not_initialized(self, adapter):
result = await adapter.ban_member("123", "456")
assert result.success is False
@pytest.mark.asyncio
async def test_unban_member_not_initialized(self, adapter):
result = await adapter.unban_member("123", "456")
assert result.success is False
@pytest.mark.asyncio
async def test_mute_member_not_initialized(self, adapter):
result = await adapter.mute_member("123", "456")
assert result.success is False
@pytest.mark.asyncio
async def test_unmute_member_not_initialized(self, adapter):
result = await adapter.unmute_member("123", "456")
assert result.success is False
@pytest.mark.asyncio
async def test_set_chat_permissions_not_initialized(self, adapter):
result = await adapter.set_chat_permissions("123", {})
assert result.success is False
@pytest.mark.asyncio
async def test_set_chat_photo_not_initialized(self, adapter):
result = await adapter.set_chat_photo("123", b"photo")
assert result.success is False
@pytest.mark.asyncio
async def test_delete_chat_photo_not_initialized(self, adapter):
result = await adapter.delete_chat_photo("123")
assert result.success is False
@pytest.mark.asyncio
async def test_rename_group_not_initialized(self, adapter):
result = await adapter.rename_group("123", "new name")
assert result.success is False
@pytest.mark.asyncio
async def test_set_chat_description_not_initialized(self, adapter):
result = await adapter.set_chat_description("123", "desc")
assert result.success is False
@pytest.mark.asyncio
async def test_promote_admin_not_initialized(self, adapter):
result = await adapter.promote_admin("123", "456")
assert result.success is False
@pytest.mark.asyncio
async def test_demote_admin_not_initialized(self, adapter):
result = await adapter.demote_admin("123", "456")
assert result.success is False
@pytest.mark.asyncio
async def test_create_chat_invite_link_not_initialized(self, adapter):
result = await adapter.create_chat_invite_link("123")
assert result.success is False
@pytest.mark.asyncio
async def test_export_chat_invite_link_not_initialized(self, adapter):
result = await adapter.export_chat_invite_link("123")
assert result.success is False
@pytest.mark.asyncio
async def test_leave_chat_not_initialized(self, adapter):
result = await adapter.leave_chat("123")
assert result.success is False
# ------------------------------------------------------------------
# send_media size check
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_send_media_too_large(self, adapter):
adapter._application = MagicMock()
adapter.max_media_size_mb = 1
large_data = b"x" * (2 * 1024 * 1024)
result = await adapter.send_media("123", "image", large_data)
assert result.success is False
assert "exceeds" in result.error.lower()
# ------------------------------------------------------------------
# send_media unsupported type
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_send_media_unsupported_type(self, adapter):
adapter._application = MagicMock()
result = await adapter.send_media("123", "unsupported_type", b"data")
assert result.success is False
assert "unsupported" in result.error.lower()
# ------------------------------------------------------------------
# edit_message_caption not initialized
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_edit_message_caption_not_initialized(self, adapter):
result = await adapter.edit_message_caption("123", "1", "caption")
assert result.success is False
# ------------------------------------------------------------------
# edit_message_reply_markup not initialized
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_edit_message_reply_markup_not_initialized(self, adapter):
result = await adapter.edit_message_reply_markup("123", "1")
assert result.success is False
# ------------------------------------------------------------------
# get_user_info not initialized
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_user_info_not_initialized(self, adapter):
result = await adapter.get_user_info("123")
assert result == {}
# ------------------------------------------------------------------
# download_media not initialized
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_download_media_not_initialized(self, adapter):
from yuxi.channels.exceptions import ChannelNotConnectedError
with pytest.raises(ChannelNotConnectedError):
await adapter.download_media("file_123")
# ------------------------------------------------------------------
# health_check disabled
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_health_check_disabled(self):
adapter = TelegramAdapter(config={"bot_token": "123:abc", "enabled": False})
result = await adapter.health_check()
assert result.status == "degraded"
assert result.metadata["reason"] == "channel_disabled"
# ------------------------------------------------------------------
# connect disabled
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_connect_disabled(self):
adapter = TelegramAdapter(config={"bot_token": "123:abc", "enabled": False})
await adapter.connect()
assert adapter._status == ChannelStatus.DISABLED
# ------------------------------------------------------------------
# connect already connected
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_connect_already_connected(self):
adapter = TelegramAdapter(config={"bot_token": "123:abc"})
adapter._status = ChannelStatus.CONNECTED
await adapter.connect()
assert adapter._status == ChannelStatus.CONNECTED
# ------------------------------------------------------------------
# disconnect already disconnected
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_disconnect_already_disconnected(self):
adapter = TelegramAdapter(config={"bot_token": "123:abc"})
await adapter.disconnect()
assert adapter._status == ChannelStatus.DISCONNECTED
# ------------------------------------------------------------------
# receive
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_receive_yields_nothing(self, adapter):
results = []
async for msg in adapter.receive():
results.append(msg)
assert len(results) == 0
# ------------------------------------------------------------------
# stream_chunk finished delegates to edit_message
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_send_stream_chunk_finished_not_initialized(self, adapter):
result = await adapter.send_stream_chunk("123", "1", "final", True)
assert result.success is False
@pytest.mark.asyncio
async def test_send_stream_chunk_not_finished(self, adapter):
adapter._application = MagicMock()
adapter._application.bot = AsyncMock()
adapter._application.bot.edit_message_text = AsyncMock()
result = await adapter.send_stream_chunk("123", "1", "chunk", False)
assert result is None