hermes-agent/tests/test_telegram_polling_progress_ptb.py
2026-07-14 17:32:41 +05:30

292 lines
10 KiB
Python

"""Integration coverage for polling progress against the installed PTB runtime."""
import asyncio
import pytest
from telegram.error import Conflict, TelegramError
from telegram.request import BaseRequest
from gateway.config import PlatformConfig
from plugins.platforms.telegram import adapter as tg_adapter
from plugins.platforms.telegram.adapter import TelegramAdapter
class _GeneralRequest(BaseRequest):
@property
def read_timeout(self):
return 10
async def initialize(self):
return None
async def shutdown(self):
return None
async def do_request(self, url, method, request_data=None, **_kwargs):
if url.endswith("/getMe"):
return (
200,
b'{"ok":true,"result":{"id":1,"is_bot":true,'
b'"first_name":"Test","username":"test_bot"}}',
)
return 200, b'{"ok":true,"result":true}'
class _GetUpdatesRequest(BaseRequest):
def __init__(self):
self.initial_conflict_sent = False
self.replacement_enabled = False
self.replacement_progress_sent = False
self.cleanup_calls = 0
self.block = asyncio.Event()
@property
def read_timeout(self):
return 10
async def initialize(self):
return None
async def shutdown(self):
return None
async def do_request(self, url, method, request_data=None, **_kwargs):
parameters = request_data.parameters if request_data is not None else {}
timeout = parameters.get("timeout")
timeout_seconds = (
timeout.total_seconds() if hasattr(timeout, "total_seconds") else timeout
)
if timeout_seconds == 0:
self.cleanup_calls += 1
return 200, b'{"ok":true,"result":[]}'
if not self.initial_conflict_sent:
self.initial_conflict_sent = True
return (
409,
b'{"ok":false,"error_code":409,'
b'"description":"Conflict: another getUpdates request"}',
)
if self.replacement_enabled and not self.replacement_progress_sent:
self.replacement_progress_sent = True
return 200, b'{"ok":true,"result":[]}'
await self.block.wait()
return 200, b'{"ok":true,"result":[]}'
class _EnvelopeRequest(BaseRequest):
def __init__(self, payload):
self.payload = payload
@property
def read_timeout(self):
return 10
async def initialize(self):
return None
async def shutdown(self):
return None
async def do_request(self, url, method, request_data=None, **_kwargs):
return 200, self.payload
async def _cancel_task(task):
if task is None or task.done():
return
task.cancel()
await asyncio.gather(task, return_exceptions=True)
@pytest.mark.asyncio
async def test_real_base_request_invalid_200_body_cannot_record_progress():
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="123456:test-token"))
generation, progress = adapter._begin_polling_generation()
adapter._polling_network_error_count = 4
adapter._polling_conflict_count = 3
request = adapter._instrument_polling_request(_EnvelopeRequest(b"not-json"))
context_token = tg_adapter._POLLING_GENERATION_CONTEXT.set(generation)
try:
with pytest.raises(TelegramError, match="Invalid server response"):
await request.post("https://api.telegram.org/bot-token/getUpdates")
finally:
tg_adapter._POLLING_GENERATION_CONTEXT.reset(context_token)
assert not progress.is_set()
assert adapter._polling_network_error_count == 4
assert adapter._polling_conflict_count == 3
assert adapter._send_path_degraded is True
@pytest.mark.asyncio
async def test_real_base_request_bom_rejected_by_ptb_cannot_record_progress():
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="123456:test-token"))
generation, progress = adapter._begin_polling_generation()
adapter._polling_network_error_count = 4
adapter._polling_conflict_count = 3
request = adapter._instrument_polling_request(
_EnvelopeRequest(b'\xef\xbb\xbf{"ok":true,"result":[]}')
)
context_token = tg_adapter._POLLING_GENERATION_CONTEXT.set(generation)
try:
with pytest.raises(TelegramError, match="Invalid server response"):
await request.post("https://api.telegram.org/bot-token/getUpdates")
finally:
tg_adapter._POLLING_GENERATION_CONTEXT.reset(context_token)
assert not progress.is_set()
assert adapter._polling_network_error_count == 4
assert adapter._polling_conflict_count == 3
assert adapter._send_path_degraded is True
@pytest.mark.asyncio
async def test_real_base_request_ptb_replacement_decode_records_progress():
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="123456:test-token"))
generation, progress = adapter._begin_polling_generation()
adapter._polling_network_error_count = 4
adapter._polling_conflict_count = 3
request = adapter._instrument_polling_request(
_EnvelopeRequest(b'{"ok":true,"result":[],"note":"\xff"}')
)
context_token = tg_adapter._POLLING_GENERATION_CONTEXT.set(generation)
try:
result = await request.post("https://api.telegram.org/bot-token/getUpdates")
finally:
tg_adapter._POLLING_GENERATION_CONTEXT.reset(context_token)
assert result == []
assert progress.is_set()
assert adapter._polling_network_error_count == 0
assert adapter._polling_conflict_count == 0
assert adapter._send_path_degraded is False
@pytest.mark.asyncio
@pytest.mark.parametrize(
("payload", "missing_result"),
[
(b'{"ok":false,"result":[]}', False),
(b'{"ok":true}', True),
],
)
async def test_real_base_request_unsuccessful_200_envelope_cannot_record_progress(
payload, missing_result
):
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="123456:test-token"))
generation, progress = adapter._begin_polling_generation()
adapter._polling_network_error_count = 4
adapter._polling_conflict_count = 3
request = adapter._instrument_polling_request(_EnvelopeRequest(payload))
context_token = tg_adapter._POLLING_GENERATION_CONTEXT.set(generation)
try:
if missing_result:
with pytest.raises(KeyError, match="result"):
await request.post("https://api.telegram.org/bot-token/getUpdates")
else:
assert await request.post(
"https://api.telegram.org/bot-token/getUpdates"
) == []
finally:
tg_adapter._POLLING_GENERATION_CONTEXT.reset(context_token)
assert not progress.is_set()
assert adapter._polling_network_error_count == 4
assert adapter._polling_conflict_count == 3
assert adapter._send_path_degraded is True
@pytest.mark.asyncio
async def test_real_base_request_valid_success_envelope_records_progress():
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="123456:test-token"))
generation, progress = adapter._begin_polling_generation()
adapter._polling_network_error_count = 4
adapter._polling_conflict_count = 3
request = adapter._instrument_polling_request(
_EnvelopeRequest(b'{"ok":true,"result":[]}')
)
context_token = tg_adapter._POLLING_GENERATION_CONTEXT.set(generation)
try:
result = await request.post(
"https://api.telegram.org/bot-token/getUpdates"
)
finally:
tg_adapter._POLLING_GENERATION_CONTEXT.reset(context_token)
assert result == []
assert progress.is_set()
assert adapter._polling_network_error_count == 0
assert adapter._polling_conflict_count == 0
assert adapter._send_path_degraded is False
@pytest.mark.asyncio
async def test_real_ptb_stop_cleanup_cannot_heal_recovery_generation():
assert tg_adapter.TELEGRAM_AVAILABLE is True
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="123456:test-token"))
polling_request = _GetUpdatesRequest()
app = (
tg_adapter.Application.builder()
.token("123456:test-token")
.request(_GeneralRequest())
.get_updates_request(adapter._instrument_polling_request(polling_request))
.build()
)
adapter._app = app
adapter._polling_network_error_count = 4
adapter._polling_conflict_count = 3
callback_called = asyncio.Event()
recovery_task = None
async def stop_for_recovery():
await app.updater.stop()
def schedule_recovery(error):
nonlocal recovery_task
assert isinstance(error, Conflict)
recovery_task = asyncio.create_task(stop_for_recovery())
callback_called.set()
await app.initialize()
try:
await adapter._start_polling_once(
app,
drop_pending_updates=False,
error_callback=schedule_recovery,
)
generation = adapter._polling_generation
progress = adapter._polling_progress_event
await asyncio.wait_for(callback_called.wait(), timeout=2)
await asyncio.wait_for(recovery_task, timeout=3)
assert polling_request.cleanup_calls == 1
assert not progress.is_set()
assert adapter._polling_network_error_count == 4
assert adapter._polling_conflict_count == 3
assert adapter._send_path_degraded is True
polling_request.replacement_enabled = True
await adapter._start_polling_once(
app,
drop_pending_updates=False,
error_callback=schedule_recovery,
)
replacement_generation = adapter._polling_generation
replacement_progress = adapter._polling_progress_event
await asyncio.wait_for(replacement_progress.wait(), timeout=2)
assert replacement_generation == generation + 1
assert adapter._polling_network_error_count == 0
assert adapter._polling_conflict_count == 0
assert adapter._send_path_degraded is False
finally:
polling_request.block.set()
if app.updater.running:
await app.updater.stop()
await _cancel_task(adapter._polling_progress_verifier_task)
await app.shutdown()