Files
MoviePilot/tests/test_telegram_botapi.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

400 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Telegram Bot API 精简客户端测试:请求编码、错误语义、文件上传和长轮询循环。
"""
import json
import threading
from types import SimpleNamespace
from unittest.mock import patch
import pytest
import requests
from app.modules.telegram.botapi import TelegramApiError, TelegramBotApi, TelegramNetworkError
def _response(payload=None, status_code=200, text=None):
"""
构造 requests.Response 替身;payload 为 None 时 json() 抛 ValueError。
"""
def _json():
"""
返回预设 JSON,模拟非 JSON 响应时保留解析错误。
"""
if payload is None:
raise ValueError(text)
return payload
return SimpleNamespace(status_code=status_code, json=_json)
@pytest.fixture
def post_res():
"""
拦截客户端的全部出站 POST,默认返回成功结果。
"""
with patch("app.modules.telegram.botapi.RequestUtils.post_res") as mock_post:
mock_post.return_value = _response({"ok": True, "result": {"message_id": 7}})
yield mock_post
def test_default_api_uses_official_host_and_proxy(post_res):
"""
官方地址走全局代理,自定义地址只取主机并跳过代理。
"""
proxy = {"https": "http://127.0.0.1:7890"}
client = TelegramBotApi("1:abc", proxy=proxy)
assert client.proxy == proxy
assert client.file_url("a/b.jpg") == "https://api.telegram.org/file/bot1:abc/a/b.jpg"
client.get_me()
assert post_res.call_args.args[0] == "https://api.telegram.org/bot1:abc/getMe"
custom = TelegramBotApi("1:abc", api_url="http://tg.lan:8081/ignored/path", proxy=proxy)
assert custom.proxy is None
custom.get_me()
assert post_res.call_args.args[0] == "http://tg.lan:8081/bot1:abc/getMe"
assert custom.file_url("x") == "http://tg.lan:8081/file/bot1:abc/x"
def test_request_verifies_tls():
"""
Bot token 随每次请求发送,客户端不能沿用 RequestUtils 的默认不校验证书。
"""
with patch("app.modules.telegram.botapi.RequestUtils") as mock_utils:
TelegramBotApi("1:abc")
assert mock_utils.call_args.kwargs["verify"] is True
assert mock_utils.call_args.kwargs["use_session"] is True
def test_send_message_encodes_bot_api_parameters(post_res):
"""
嵌套结构编码为 JSON,旧式回复和预览参数转换为当前 Bot API 字段,None 不发送。
"""
client = TelegramBotApi("1:abc", parse_mode="MarkdownV2")
result = client.send_message(
chat_id=10001,
text="你好",
reply_markup={"inline_keyboard": [[{"text": "按钮", "callback_data": "x"}]]},
disable_web_page_preview=True,
reply_to_message_id="42",
message_thread_id=None,
)
assert result == {"message_id": 7}
data = post_res.call_args.kwargs["data"]
assert data["chat_id"] == 10001
assert data["parse_mode"] == "MarkdownV2"
assert json.loads(data["reply_markup"])["inline_keyboard"][0][0]["text"] == "按钮"
assert json.loads(data["link_preview_options"]) == {"is_disabled": True}
assert json.loads(data["reply_parameters"]) == {"message_id": 42}
assert "message_thread_id" not in data
assert post_res.call_args.kwargs["files"] is None
assert post_res.call_args.kwargs["raise_exception"] is True
def test_plain_parse_mode_is_omitted_and_rich_edit_has_no_parse_mode(post_res):
"""
空 parse_mode 表示纯文本;Rich Message 编辑不带默认 parse_mode。
"""
client = TelegramBotApi("1:abc", parse_mode="MarkdownV2")
client.send_message(chat_id=1, text="plain", parse_mode="")
assert "parse_mode" not in post_res.call_args.kwargs["data"]
client.edit_message_text(chat_id=1, message_id=2, rich_message={"html": "<b>x</b>"})
data = post_res.call_args.kwargs["data"]
assert "parse_mode" not in data and "text" not in data
assert json.loads(data["rich_message"]) == {"html": "<b>x</b>"}
client.send_message(chat_id=1, text="flag", disable_web_page_preview=False)
assert json.loads(post_res.call_args.kwargs["data"]["link_preview_options"]) == {
"is_disabled": False
}
def test_media_upload_routes_by_input_type(post_res):
"""
字符串按 file_id/URL 发送,bytes 和 (文件名, 内容) 走 multipart。
"""
client = TelegramBotApi("1:abc")
client.send_photo(chat_id=1, photo="https://img.example/a.jpg", caption="c")
assert post_res.call_args.kwargs["data"]["photo"] == "https://img.example/a.jpg"
assert post_res.call_args.kwargs["files"] is None
client.send_photo(chat_id=1, photo=b"raw")
assert post_res.call_args.kwargs["files"] == {"photo": b"raw"}
client.send_document(chat_id=1, document=("报告.txt", b"doc"))
assert post_res.call_args.args[0].endswith("/sendDocument")
assert post_res.call_args.kwargs["files"] == {"document": ("报告.txt", b"doc")}
client.send_photo(chat_id=1, photo=("", b"img"))
assert post_res.call_args.kwargs["files"] == {"photo": ("photo", b"img")}
def test_api_error_keeps_telegram_description(post_res):
"""
ok=false 抛出含 description 的异常,调用方据此识别可忽略的编辑错误。
"""
post_res.return_value = _response({
"ok": False,
"error_code": 400,
"description": "Bad Request: message is not modified",
})
client = TelegramBotApi("1:abc")
with pytest.raises(TelegramApiError) as exc:
client.edit_message_text(chat_id=1, message_id=2, text="same")
assert exc.value.error_code == 400
assert "message is not modified" in str(exc.value).lower()
post_res.return_value = _response(None, status_code=502, text="<html>")
with pytest.raises(TelegramApiError) as exc:
client.get_me()
assert exc.value.error_code == 502
def test_api_error_preserves_rate_limit_parameters(post_res):
"""
429 响应中的 retry_after 应保留给出站冷却策略。
"""
post_res.return_value = _response({
"ok": False,
"error_code": 429,
"description": "Too Many Requests: retry after 17",
"parameters": {"retry_after": 17},
})
client = TelegramBotApi("1:abc")
with pytest.raises(TelegramApiError) as exc:
client.edit_message_text(chat_id=1, message_id=2, text="next")
assert exc.value.error_code == 429
assert exc.value.parameters == {"retry_after": 17}
def test_polling_dispatches_in_order_and_survives_handler_errors():
"""
更新按顺序分发,offset 确认到最后一条;单条处理失败不影响后续更新。
"""
client = TelegramBotApi("1:abc")
batches = [
[{"update_id": 10, "n": 1}, {"update_id": 11, "n": 2}],
[{"update_id": 12, "n": 3}],
]
offsets = []
handled = []
def fake_get_updates(offset, timeout):
"""
依次交付两批更新,最后停止轮询。
"""
offsets.append(offset)
if batches:
return batches.pop(0)
client.stop_polling(0)
return []
def handler(update):
"""
记录分发顺序并模拟首条消息处理失败。
"""
handled.append(update["n"])
if update["n"] == 1:
raise RuntimeError("boom")
with patch.object(client, "get_updates", side_effect=fake_get_updates):
client.polling(handler, long_polling_timeout=1)
assert handled == [1, 2, 3]
assert offsets == [None, 12, 13]
def test_polling_backs_off_on_failure_and_stops_promptly():
"""
拉取失败进入退避等待,stop_polling 能立即打断等待并退出循环。
"""
client = TelegramBotApi("1:abc")
calls = threading.Event()
def failing_get_updates(offset, timeout):
"""
通知测试线程已开始拉取,再模拟网络失败。
"""
calls.set()
raise ConnectionError("network down")
with patch.object(client, "get_updates", side_effect=failing_get_updates):
thread = threading.Thread(
target=client.polling, args=(lambda update: None,), kwargs={"long_polling_timeout": 1}
)
thread.start()
assert calls.wait(1)
client.stop_polling(0)
thread.join(1)
assert not thread.is_alive()
def test_polling_rebuilds_only_the_long_polling_session_after_network_failure():
"""
长轮询连接失败后应重建独立会话,避免后续更新继续复用坏连接池。
"""
client = TelegramBotApi("1:abc")
client._get_polling_http()
replies = [TelegramNetworkError("getUpdates", "connection reset"), []]
polling_sessions = []
def fake_get_updates(offset, timeout):
"""
记录每次拉取使用的会话,并在恢复后结束测试轮询。
"""
polling_sessions.append(client._polling_http)
reply = replies.pop(0)
if isinstance(reply, Exception):
raise reply
client.stop_polling(0)
return reply
with patch.object(client, "get_updates", side_effect=fake_get_updates), \
patch.object(client._polling_stop, "wait"):
client.polling(lambda update: None, long_polling_timeout=1)
assert polling_sessions[0] is not None
assert polling_sessions[0] is not polling_sessions[1]
assert client._http is not polling_sessions[1]
client.close()
def test_polling_reports_persistent_failures_and_resets_after_recovery():
"""
失败每分钟警告,恢复后再次中断立即警告,offset 和退避计数均保持正确。
"""
client = TelegramBotApi("1:abc")
failure_times = [0, 1, 59, 60, 119, 120]
replies = [
[{"update_id": 4}],
*[TelegramNetworkError("getUpdates", f"failure-{index}") for index in range(6)],
[{"update_id": 5}],
TelegramApiError("getUpdates", "Conflict", 409),
[],
]
def fake_get_updates(offset, timeout):
"""
交付更新后持续失败,再模拟恢复和一次新的 API 错误。
"""
if replies:
reply = replies.pop(0)
if isinstance(reply, Exception):
raise reply
return reply
client.stop_polling(0)
return []
with patch.object(client, "get_updates", side_effect=fake_get_updates) as get_updates, \
patch.object(client._polling_stop, "wait") as wait, \
patch("app.modules.telegram.botapi.time.monotonic",
side_effect=[*failure_times, 121, 122, 123]), \
patch("app.modules.telegram.botapi.logger") as log:
client.polling(lambda update: None, long_polling_timeout=30)
warnings = [call.args[0] for call in log.warning.call_args_list]
assert len(warnings) == 4
assert "连续1次,已持续0秒,3秒后重试" in warnings[0]
assert "连续4次,已持续60秒,24秒后重试" in warnings[1]
assert "failure-3" in warnings[1]
assert "连续6次,已持续120秒,60秒后重试" in warnings[2]
assert "连续1次,已持续0秒,3秒后重试" in warnings[3]
assert "Conflict" in warnings[3]
assert log.debug.call_count == 3
assert [call.args[0] for call in log.info.call_args_list] == [
"Telegram消息拉取已恢复(此前连续6次失败,持续121秒)",
"Telegram消息拉取已恢复(此前连续1次失败,持续1秒)",
]
assert [call.args[0] for call in wait.call_args_list] == [3, 6, 12, 24, 48, 60, 3]
assert [call.args[0] for call in get_updates.call_args_list] == [
None, 5, 5, 5, 5, 5, 5, 5, 6, 6, 6,
]
def test_polling_persistent_warning_hides_token_and_keeps_retry_limit(post_res):
"""
实际请求边界的超时日志保持脱敏,持续警告不改变调用方指定的退避上限。
"""
token = "123456:SECRET-token"
client = TelegramBotApi(token)
post_res.side_effect = requests.ReadTimeout(f"/bot{token}/getUpdates timed out")
waits = []
def wait_or_stop(timeout):
"""
记录等待预算,第二次失败后结束循环。
"""
waits.append(timeout)
if len(waits) == 2:
client.stop_polling(0)
with patch.object(client._polling_stop, "wait", side_effect=wait_or_stop), \
patch("app.modules.telegram.botapi.time.monotonic", side_effect=[0, 60]), \
patch("app.modules.telegram.botapi.logger") as log:
client.polling(lambda update: None, long_polling_timeout=30, retry_max_seconds=4)
assert post_res.call_count == 2
assert waits == [3, 4]
assert log.warning.call_count == 2
for call in log.warning.call_args_list:
assert token not in call.args[0]
assert "<token>" in call.args[0]
assert "ReadTimeout" in call.args[0]
def test_polling_stop_during_failed_request_does_not_warn_or_wait():
"""
停止期间返回的请求错误直接退出,不报持续失败也不再退避重试。
"""
client = TelegramBotApi("1:abc")
def stop_then_fail(offset, timeout):
"""
模拟请求完成前模块已经停止。
"""
client.stop_polling(0)
raise TelegramNetworkError("getUpdates", "stopped")
with patch.object(client, "get_updates", side_effect=stop_then_fail) as get_updates, \
patch.object(client._polling_stop, "wait") as wait, \
patch("app.modules.telegram.botapi.logger") as log:
client.polling(lambda update: None, long_polling_timeout=30)
get_updates.assert_called_once()
wait.assert_not_called()
log.warning.assert_not_called()
log.info.assert_not_called()
def test_get_updates_requests_only_handled_update_types(post_res):
"""
getUpdates 只订阅消息和按钮回调,读取超时大于长轮询等待。
"""
post_res.return_value = _response({"ok": True, "result": []})
client = TelegramBotApi("1:abc")
assert client.get_updates(offset=5, timeout=8) == []
data = post_res.call_args.kwargs["data"]
assert data["offset"] == 5 and data["timeout"] == 8
assert json.loads(data["allowed_updates"]) == ["message", "callback_query"]
connect_timeout, read_timeout = post_res.call_args.kwargs["timeout"]
assert read_timeout > 8 and connect_timeout > 0
def test_network_error_hides_bot_token(post_res):
"""
requests 异常文本含完整地址,抛出前必须去掉 token 且不保留异常链。
"""
token = "123456:SECRET-token"
post_res.side_effect = requests.ConnectTimeout(
f"HTTPSConnectionPool(host='api.telegram.org', port=443): url: /bot{token}/getMe"
)
client = TelegramBotApi(token)
with pytest.raises(TelegramNetworkError) as exc:
client.get_me()
assert token not in str(exc.value)
assert "<token>" in str(exc.value)
assert exc.value.__cause__ is None and exc.value.__suppress_context__