""" 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": "x"}) 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": "x"} 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="") 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 "" 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 "" in str(exc.value) assert exc.value.__cause__ is None and exc.value.__suppress_context__