Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,647 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_auto_discovery(monkeypatch):
|
||||
"""Disable DoH auto-discovery so connect() uses the plain builder chain."""
|
||||
async def _noop():
|
||||
return []
|
||||
monkeypatch.setattr("plugins.platforms.telegram.adapter.discover_fallback_ips", _noop)
|
||||
# Mock HTTPXRequest so the builder chain doesn't fail
|
||||
monkeypatch.setattr("plugins.platforms.telegram.adapter.HTTPXRequest", lambda **kwargs: MagicMock())
|
||||
|
||||
|
||||
async def _cancel_heartbeat(adapter):
|
||||
"""Cancel the lifetime heartbeat task connect() starts in polling mode.
|
||||
|
||||
These tests call the real connect() but never disconnect(), so the
|
||||
_polling_heartbeat_loop task would otherwise outlive the test. With
|
||||
asyncio.sleep monkeypatched to instant, leaving it running busy-spins the
|
||||
event loop and starves the test (CI per-file timeout). disconnect() does
|
||||
this in production; tests that only connect() must do it themselves.
|
||||
"""
|
||||
task = getattr(adapter, "_polling_heartbeat_task", None)
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
adapter._polling_heartbeat_task = None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_polling_conflict_retries_before_fatal(monkeypatch):
|
||||
"""A single 409 should trigger a retry, not an immediate fatal error."""
|
||||
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="***"))
|
||||
fatal_handler = AsyncMock()
|
||||
adapter.set_fatal_error_handler(fatal_handler)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"gateway.status.acquire_scoped_lock",
|
||||
lambda scope, identity, metadata=None: (True, None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"gateway.status.release_scoped_lock",
|
||||
lambda scope, identity: None,
|
||||
)
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_start_polling(**kwargs):
|
||||
captured["error_callback"] = kwargs["error_callback"]
|
||||
# Cold connect requires real getUpdates readiness (#67498) — simulate
|
||||
# the first successful poll for the generation this call started, but
|
||||
# only on the initial connect: the conflict-retry generation must NOT
|
||||
# make progress here, or it would legitimately reset the conflict
|
||||
# count this test asserts on.
|
||||
if not captured.get("initial_done"):
|
||||
captured["initial_done"] = True
|
||||
adapter._record_polling_progress(adapter._polling_generation)
|
||||
|
||||
updater = SimpleNamespace(
|
||||
start_polling=AsyncMock(side_effect=fake_start_polling),
|
||||
stop=AsyncMock(),
|
||||
running=True,
|
||||
)
|
||||
bot = SimpleNamespace(set_my_commands=AsyncMock(), delete_webhook=AsyncMock())
|
||||
app = SimpleNamespace(
|
||||
bot=bot,
|
||||
updater=updater,
|
||||
add_handler=MagicMock(),
|
||||
initialize=AsyncMock(),
|
||||
start=AsyncMock(),
|
||||
)
|
||||
builder = MagicMock()
|
||||
builder.token.return_value = builder
|
||||
builder.request.return_value = builder
|
||||
builder.get_updates_request.return_value = builder
|
||||
builder.build.return_value = app
|
||||
monkeypatch.setattr("plugins.platforms.telegram.adapter.Application", SimpleNamespace(builder=MagicMock(return_value=builder)))
|
||||
|
||||
# Speed up retries for testing
|
||||
monkeypatch.setattr("asyncio.sleep", AsyncMock())
|
||||
|
||||
ok = await adapter.connect()
|
||||
|
||||
assert ok is True
|
||||
bot.delete_webhook.assert_awaited_once_with(drop_pending_updates=False)
|
||||
assert callable(captured["error_callback"])
|
||||
|
||||
conflict = type("Conflict", (Exception,), {})
|
||||
|
||||
# First conflict: should retry, NOT be fatal
|
||||
captured["error_callback"](conflict("Conflict: terminated by other getUpdates request"))
|
||||
await adapter._polling_error_task
|
||||
|
||||
assert adapter.has_fatal_error is False, "First conflict should not be fatal"
|
||||
assert adapter._polling_conflict_count == 1, (
|
||||
"Count must remain until the retried generation makes getUpdates progress"
|
||||
)
|
||||
assert adapter._send_path_degraded is True
|
||||
|
||||
# connect() now starts a lifetime _polling_heartbeat_loop task. With
|
||||
# asyncio.sleep mocked to instant above, it must not be left running or it
|
||||
# busy-spins on the event loop and starves the test. Cancel it explicitly.
|
||||
await _cancel_heartbeat(adapter)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_conflict_retry_drops_pending_updates(monkeypatch):
|
||||
"""Conflict recovery must use drop_pending_updates=True (#75017).
|
||||
|
||||
Without this, each retry starts a new getUpdates session that
|
||||
immediately gets 409'd by the previous still-expiring session,
|
||||
creating the very conflict we are trying to recover from.
|
||||
"""
|
||||
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="***"))
|
||||
adapter.set_fatal_error_handler(AsyncMock())
|
||||
adapter._drain_polling_connections = AsyncMock()
|
||||
monkeypatch.setattr("asyncio.sleep", AsyncMock())
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_start_polling(**kwargs):
|
||||
captured["drop_pending_updates"] = kwargs.get("drop_pending_updates")
|
||||
|
||||
updater = SimpleNamespace(
|
||||
start_polling=AsyncMock(side_effect=fake_start_polling),
|
||||
stop=AsyncMock(),
|
||||
running=True,
|
||||
)
|
||||
adapter._app = SimpleNamespace(updater=updater)
|
||||
|
||||
conflict = type("Conflict", (Exception,), {})
|
||||
await adapter._handle_polling_conflict(
|
||||
conflict("Conflict: terminated by other getUpdates request")
|
||||
)
|
||||
|
||||
assert captured.get("drop_pending_updates") is True, (
|
||||
"Conflict retry must use drop_pending_updates=True to terminate "
|
||||
"stale getUpdates sessions on Telegram's servers (#75017)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_conflict_retry_progress_does_not_reset_retry_ladder(monkeypatch):
|
||||
"""First getUpdates progress after a conflict retry is not durable recovery.
|
||||
|
||||
Telegram can accept the first long-poll after a retry and then return a 409
|
||||
from the still-expiring previous session. That transient success must not
|
||||
reset the retry counter back to 0, or every new 409 looks like attempt 1/5
|
||||
and the backoff never reaches the server-side expiry window.
|
||||
"""
|
||||
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="***"))
|
||||
adapter.set_fatal_error_handler(AsyncMock())
|
||||
adapter._drain_polling_connections = AsyncMock()
|
||||
monkeypatch.setattr("asyncio.sleep", AsyncMock())
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
async def fake_start_polling(**_kwargs):
|
||||
calls["n"] += 1
|
||||
adapter._record_polling_progress(adapter._polling_generation)
|
||||
|
||||
updater = SimpleNamespace(
|
||||
start_polling=AsyncMock(side_effect=fake_start_polling),
|
||||
stop=AsyncMock(),
|
||||
running=True,
|
||||
)
|
||||
adapter._app = SimpleNamespace(updater=updater)
|
||||
|
||||
conflict = type("Conflict", (Exception,), {})
|
||||
await adapter._handle_polling_conflict(
|
||||
conflict("Conflict: terminated by other getUpdates request")
|
||||
)
|
||||
|
||||
assert calls["n"] == 1
|
||||
assert adapter._polling_conflict_count == 1
|
||||
assert adapter._polling_conflict_recovery_generation is None
|
||||
assert adapter._send_path_degraded is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_polling_conflict_becomes_fatal_after_retries(monkeypatch):
|
||||
"""After exhausting retries, the conflict should become fatal."""
|
||||
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="***"))
|
||||
fatal_handler = AsyncMock()
|
||||
adapter.set_fatal_error_handler(fatal_handler)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"gateway.status.acquire_scoped_lock",
|
||||
lambda scope, identity, metadata=None: (True, None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"gateway.status.release_scoped_lock",
|
||||
lambda scope, identity: None,
|
||||
)
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_start_polling(**kwargs):
|
||||
captured["error_callback"] = kwargs["error_callback"]
|
||||
|
||||
# Make start_polling fail on retries to exhaust retries
|
||||
call_count = {"n": 0}
|
||||
|
||||
async def failing_start_polling(**kwargs):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
# First call (initial connect) succeeds
|
||||
captured["error_callback"] = kwargs["error_callback"]
|
||||
# Cold connect requires getUpdates readiness (#67498).
|
||||
adapter._record_polling_progress(adapter._polling_generation)
|
||||
else:
|
||||
# Retry calls fail
|
||||
raise Exception("Connection refused")
|
||||
|
||||
updater = SimpleNamespace(
|
||||
start_polling=AsyncMock(side_effect=failing_start_polling),
|
||||
stop=AsyncMock(),
|
||||
running=True,
|
||||
)
|
||||
bot = SimpleNamespace(set_my_commands=AsyncMock(), delete_webhook=AsyncMock())
|
||||
app = SimpleNamespace(
|
||||
bot=bot,
|
||||
updater=updater,
|
||||
add_handler=MagicMock(),
|
||||
initialize=AsyncMock(),
|
||||
start=AsyncMock(),
|
||||
)
|
||||
builder = MagicMock()
|
||||
builder.token.return_value = builder
|
||||
builder.request.return_value = builder
|
||||
builder.get_updates_request.return_value = builder
|
||||
builder.build.return_value = app
|
||||
monkeypatch.setattr("plugins.platforms.telegram.adapter.Application", SimpleNamespace(builder=MagicMock(return_value=builder)))
|
||||
|
||||
# Speed up retries for testing
|
||||
monkeypatch.setattr("asyncio.sleep", AsyncMock())
|
||||
|
||||
ok = await adapter.connect()
|
||||
assert ok is True
|
||||
|
||||
conflict = type("Conflict", (Exception,), {})
|
||||
|
||||
# Directly call _handle_polling_conflict to avoid event-loop scheduling
|
||||
# complexity. Each call simulates one 409 from Telegram.
|
||||
for i in range(6):
|
||||
await adapter._handle_polling_conflict(
|
||||
conflict("Conflict: terminated by other getUpdates request")
|
||||
)
|
||||
|
||||
# Retries 1-4 each schedule a background recovery task via
|
||||
# loop.create_task(self._handle_polling_conflict(...)) that this test
|
||||
# never awaits. Cancel the last one so a leaked task can't get a
|
||||
# scheduler turn under load and re-drive the counter into the fatal
|
||||
# branch a second time — which would fire _notify_fatal_error twice and
|
||||
# break assert_awaited_once() non-deterministically.
|
||||
leaked = adapter._polling_error_task
|
||||
if leaked is not None and not leaked.done():
|
||||
leaked.cancel()
|
||||
try:
|
||||
await leaked
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
|
||||
# After 5 failed retries (count 1-5 each enter the retry branch but
|
||||
# start_polling raises), the 6th conflict pushes count to 6 which
|
||||
# exceeds MAX_CONFLICT_RETRIES (5), entering the fatal branch.
|
||||
assert adapter.fatal_error_code == "telegram_polling_conflict", (
|
||||
f"Expected fatal after 6 conflicts, got code={adapter.fatal_error_code}, "
|
||||
f"count={adapter._polling_conflict_count}"
|
||||
)
|
||||
assert adapter.has_fatal_error is True
|
||||
fatal_handler.assert_awaited_once()
|
||||
await _cancel_heartbeat(adapter)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_clears_webhook_before_polling(monkeypatch):
|
||||
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="***"))
|
||||
|
||||
monkeypatch.setattr(
|
||||
"gateway.status.acquire_scoped_lock",
|
||||
lambda scope, identity, metadata=None: (True, None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"gateway.status.release_scoped_lock",
|
||||
lambda scope, identity: None,
|
||||
)
|
||||
|
||||
async def _start_polling_with_progress(**_kwargs):
|
||||
# Cold connect requires getUpdates readiness (#67498).
|
||||
adapter._record_polling_progress(adapter._polling_generation)
|
||||
|
||||
updater = SimpleNamespace(
|
||||
start_polling=AsyncMock(side_effect=_start_polling_with_progress),
|
||||
stop=AsyncMock(),
|
||||
running=True,
|
||||
)
|
||||
bot = SimpleNamespace(
|
||||
delete_webhook=AsyncMock(),
|
||||
set_my_commands=AsyncMock(),
|
||||
)
|
||||
app = SimpleNamespace(
|
||||
bot=bot,
|
||||
updater=updater,
|
||||
add_handler=MagicMock(),
|
||||
initialize=AsyncMock(),
|
||||
start=AsyncMock(),
|
||||
)
|
||||
builder = MagicMock()
|
||||
builder.token.return_value = builder
|
||||
builder.request.return_value = builder
|
||||
builder.get_updates_request.return_value = builder
|
||||
builder.build.return_value = app
|
||||
monkeypatch.setattr(
|
||||
"plugins.platforms.telegram.adapter.Application",
|
||||
SimpleNamespace(builder=MagicMock(return_value=builder)),
|
||||
)
|
||||
|
||||
ok = await adapter.connect()
|
||||
|
||||
assert ok is True
|
||||
bot.delete_webhook.assert_awaited_once_with(drop_pending_updates=False)
|
||||
await _cancel_heartbeat(adapter)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_does_not_block_on_post_connect_housekeeping(monkeypatch):
|
||||
"""Regression for #46298.
|
||||
|
||||
Command-menu registration and DM-topic setup make Bot API calls that can
|
||||
stall for certain tokens. If they run inside connect() (which the gateway
|
||||
wraps in a connect timeout), one slow call blows the whole connect and the
|
||||
adapter never comes up. connect() must return as soon as polling/webhook is
|
||||
live and defer that housekeeping to a cancellable background task.
|
||||
"""
|
||||
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="***"))
|
||||
|
||||
monkeypatch.setattr(
|
||||
"gateway.status.acquire_scoped_lock",
|
||||
lambda scope, identity, metadata=None: (True, None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"gateway.status.release_scoped_lock",
|
||||
lambda scope, identity: None,
|
||||
)
|
||||
|
||||
async def _hang_forever(*args, **kwargs):
|
||||
await asyncio.Future()
|
||||
|
||||
# Make the entire housekeeping coroutine hang. connect() must still return
|
||||
# promptly and expose the still-running task; disconnect() must cancel it.
|
||||
monkeypatch.setattr(adapter, "_run_post_connect_housekeeping", _hang_forever)
|
||||
|
||||
async def _start_polling_with_progress(**_kwargs):
|
||||
# Cold connect requires getUpdates readiness (#67498).
|
||||
adapter._record_polling_progress(adapter._polling_generation)
|
||||
|
||||
updater = SimpleNamespace(
|
||||
start_polling=AsyncMock(side_effect=_start_polling_with_progress),
|
||||
stop=AsyncMock(),
|
||||
running=True,
|
||||
)
|
||||
bot = SimpleNamespace(
|
||||
delete_webhook=AsyncMock(),
|
||||
set_my_commands=AsyncMock(),
|
||||
)
|
||||
app = SimpleNamespace(
|
||||
bot=bot,
|
||||
updater=updater,
|
||||
add_handler=MagicMock(),
|
||||
initialize=AsyncMock(),
|
||||
start=AsyncMock(),
|
||||
running=True,
|
||||
stop=AsyncMock(),
|
||||
shutdown=AsyncMock(),
|
||||
)
|
||||
builder = MagicMock()
|
||||
builder.token.return_value = builder
|
||||
builder.request.return_value = builder
|
||||
builder.get_updates_request.return_value = builder
|
||||
builder.build.return_value = app
|
||||
monkeypatch.setattr(
|
||||
"plugins.platforms.telegram.adapter.Application",
|
||||
SimpleNamespace(builder=MagicMock(return_value=builder)),
|
||||
)
|
||||
|
||||
# A tight timeout: if connect() awaited the hanging set_my_commands this
|
||||
# would raise TimeoutError instead of returning.
|
||||
ok = await asyncio.wait_for(adapter.connect(), timeout=0.5)
|
||||
|
||||
assert ok is True
|
||||
assert adapter._post_connect_task is not None
|
||||
assert not adapter._post_connect_task.done()
|
||||
|
||||
# disconnect() must cancel the still-hanging housekeeping task cleanly.
|
||||
await adapter.disconnect()
|
||||
assert adapter._post_connect_task is None
|
||||
await _cancel_heartbeat(adapter)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_polling_conflict_reschedule_uses_running_loop(monkeypatch):
|
||||
"""Regression for #19471.
|
||||
|
||||
When a conflict-retry's start_polling raises and we are still below the
|
||||
retry ceiling, the handler reschedules itself via loop.create_task. The
|
||||
old code used the deprecated asyncio.get_event_loop(), which raises
|
||||
"RuntimeError: There is no current event loop in thread 'MainThread'" on
|
||||
Python 3.11+ when no loop is attached to the thread (as happens when PTB
|
||||
dispatches this error callback). That left the gateway alive but silent
|
||||
and drove the --replace crash loop. The fix uses get_running_loop(), which
|
||||
is always valid inside a coroutine. Force get_event_loop() to raise so a
|
||||
regression would surface as the original RuntimeError, not pass silently.
|
||||
"""
|
||||
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="***"))
|
||||
adapter.set_fatal_error_handler(AsyncMock())
|
||||
|
||||
monkeypatch.setattr(
|
||||
"gateway.status.acquire_scoped_lock",
|
||||
lambda scope, identity, metadata=None: (True, None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"gateway.status.release_scoped_lock",
|
||||
lambda scope, identity: None,
|
||||
)
|
||||
|
||||
captured = {}
|
||||
call_count = {"n": 0}
|
||||
|
||||
async def failing_start_polling(**kwargs):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
captured["error_callback"] = kwargs["error_callback"]
|
||||
# Cold connect requires getUpdates readiness (#67498).
|
||||
adapter._record_polling_progress(adapter._polling_generation)
|
||||
else:
|
||||
# Retry attempt fails so the handler enters the reschedule branch.
|
||||
raise Exception("Connection refused")
|
||||
|
||||
updater = SimpleNamespace(
|
||||
start_polling=AsyncMock(side_effect=failing_start_polling),
|
||||
stop=AsyncMock(),
|
||||
running=True,
|
||||
)
|
||||
bot = SimpleNamespace(set_my_commands=AsyncMock(), delete_webhook=AsyncMock())
|
||||
app = SimpleNamespace(
|
||||
bot=bot,
|
||||
updater=updater,
|
||||
add_handler=MagicMock(),
|
||||
initialize=AsyncMock(),
|
||||
start=AsyncMock(),
|
||||
)
|
||||
builder = MagicMock()
|
||||
builder.token.return_value = builder
|
||||
builder.request.return_value = builder
|
||||
builder.get_updates_request.return_value = builder
|
||||
builder.build.return_value = app
|
||||
monkeypatch.setattr(
|
||||
"plugins.platforms.telegram.adapter.Application",
|
||||
SimpleNamespace(builder=MagicMock(return_value=builder)),
|
||||
)
|
||||
monkeypatch.setattr("asyncio.sleep", AsyncMock())
|
||||
|
||||
ok = await adapter.connect()
|
||||
assert ok is True
|
||||
|
||||
# If the fix regresses to get_event_loop(), this makes it raise — the same
|
||||
# RuntimeError users hit in #19471. The running-loop path ignores it.
|
||||
def _boom():
|
||||
raise RuntimeError("There is no current event loop in thread 'MainThread'.")
|
||||
|
||||
monkeypatch.setattr("asyncio.get_event_loop", _boom)
|
||||
|
||||
conflict = type("Conflict", (Exception,), {})
|
||||
|
||||
# One conflict: count goes to 1 (< MAX), retry's start_polling raises,
|
||||
# handler reschedules via loop.create_task — the previously-broken line.
|
||||
await adapter._handle_polling_conflict(
|
||||
conflict("Conflict: terminated by other getUpdates request")
|
||||
)
|
||||
|
||||
assert adapter.has_fatal_error is False
|
||||
assert adapter._polling_error_task is not None
|
||||
# The rescheduled task must be schedulable on the running loop.
|
||||
adapter._polling_error_task.cancel()
|
||||
try:
|
||||
await adapter._polling_error_task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
await _cancel_heartbeat(adapter)
|
||||
|
||||
|
||||
def _build_polling_app(monkeypatch, adapter):
|
||||
"""Wire a mock PTB Application whose start_polling captures kwargs."""
|
||||
captured = {}
|
||||
|
||||
async def fake_start_polling(**kwargs):
|
||||
captured.update(kwargs)
|
||||
# Cold connect requires getUpdates readiness (#67498).
|
||||
adapter._record_polling_progress(adapter._polling_generation)
|
||||
|
||||
updater = SimpleNamespace(
|
||||
start_polling=AsyncMock(side_effect=fake_start_polling),
|
||||
stop=AsyncMock(),
|
||||
running=True,
|
||||
)
|
||||
bot = SimpleNamespace(set_my_commands=AsyncMock(), delete_webhook=AsyncMock())
|
||||
app = SimpleNamespace(
|
||||
bot=bot,
|
||||
updater=updater,
|
||||
add_handler=MagicMock(),
|
||||
initialize=AsyncMock(),
|
||||
start=AsyncMock(),
|
||||
)
|
||||
builder = MagicMock()
|
||||
builder.token.return_value = builder
|
||||
builder.request.return_value = builder
|
||||
builder.get_updates_request.return_value = builder
|
||||
builder.build.return_value = app
|
||||
monkeypatch.setattr(
|
||||
"plugins.platforms.telegram.adapter.Application",
|
||||
SimpleNamespace(builder=MagicMock(return_value=builder)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"gateway.status.acquire_scoped_lock",
|
||||
lambda scope, identity, metadata=None: (True, None),
|
||||
)
|
||||
monkeypatch.setattr("asyncio.sleep", AsyncMock())
|
||||
return captured
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconnect_preserves_pending_updates(monkeypatch):
|
||||
"""A watcher reconnect (is_reconnect=True) preserves the queue Telegram
|
||||
accumulated during the outage — the core of #46621."""
|
||||
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="***"))
|
||||
captured = _build_polling_app(monkeypatch, adapter)
|
||||
|
||||
ok = await adapter.connect(is_reconnect=True)
|
||||
|
||||
assert ok is True
|
||||
assert captured["drop_pending_updates"] is False
|
||||
await _cancel_heartbeat(adapter)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disarm_sets_ptb_stop_event():
|
||||
"""_disarm_ptb_retry_loop sets PTB's name-mangled polling stop_event.
|
||||
|
||||
This is the root-cause fix for the 409 conflict loop (#30122): the
|
||||
error_callback must synchronously signal PTB's internal network_retry_loop
|
||||
to stop BEFORE our async recovery task restarts polling, otherwise the two
|
||||
polling sessions overlap and produce a fresh 409.
|
||||
"""
|
||||
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="***"))
|
||||
|
||||
stop_event = asyncio.Event()
|
||||
# PTB stores it name-mangled as _Updater__polling_task_stop_event.
|
||||
updater = SimpleNamespace(running=True)
|
||||
setattr(updater, "_Updater__polling_task_stop_event", stop_event)
|
||||
adapter._app = SimpleNamespace(updater=updater)
|
||||
|
||||
assert not stop_event.is_set()
|
||||
adapter._disarm_ptb_retry_loop()
|
||||
assert stop_event.is_set(), "disarm must set PTB's polling stop_event"
|
||||
# Must not flip _running — the recovery handler's stop() guards on running
|
||||
# and stop() raises if running is already False.
|
||||
assert updater.running is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_conflict_callback_disarms_before_scheduling(monkeypatch):
|
||||
"""The polling error_callback disarms PTB synchronously, then schedules
|
||||
recovery — proving the fix is wired into the live callback, not just the
|
||||
helper (#30122)."""
|
||||
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="***"))
|
||||
fatal_handler = AsyncMock()
|
||||
adapter.set_fatal_error_handler(fatal_handler)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"gateway.status.acquire_scoped_lock",
|
||||
lambda scope, identity, metadata=None: (True, None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"gateway.status.release_scoped_lock",
|
||||
lambda scope, identity: None,
|
||||
)
|
||||
monkeypatch.setattr("asyncio.sleep", AsyncMock())
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_start_polling(**kwargs):
|
||||
captured["error_callback"] = kwargs["error_callback"]
|
||||
# Cold connect requires getUpdates readiness (#67498).
|
||||
adapter._record_polling_progress(adapter._polling_generation)
|
||||
|
||||
stop_event = asyncio.Event()
|
||||
updater = SimpleNamespace(
|
||||
start_polling=AsyncMock(side_effect=fake_start_polling),
|
||||
stop=AsyncMock(),
|
||||
running=True,
|
||||
)
|
||||
setattr(updater, "_Updater__polling_task_stop_event", stop_event)
|
||||
bot = SimpleNamespace(set_my_commands=AsyncMock(), delete_webhook=AsyncMock())
|
||||
app = SimpleNamespace(
|
||||
bot=bot,
|
||||
updater=updater,
|
||||
add_handler=MagicMock(),
|
||||
initialize=AsyncMock(),
|
||||
start=AsyncMock(),
|
||||
)
|
||||
builder = MagicMock()
|
||||
builder.token.return_value = builder
|
||||
builder.request.return_value = builder
|
||||
builder.get_updates_request.return_value = builder
|
||||
builder.build.return_value = app
|
||||
monkeypatch.setattr(
|
||||
"plugins.platforms.telegram.adapter.Application",
|
||||
SimpleNamespace(builder=MagicMock(return_value=builder)),
|
||||
)
|
||||
|
||||
ok = await adapter.connect()
|
||||
assert ok is True
|
||||
|
||||
conflict = type("Conflict", (Exception,), {})
|
||||
# Fire a 409 through the live callback. The disarm must happen
|
||||
# synchronously (before any await), so the stop_event is set immediately
|
||||
# on return — before the scheduled recovery task gets a chance to run.
|
||||
assert not stop_event.is_set()
|
||||
captured["error_callback"](conflict("Conflict: terminated by other getUpdates"))
|
||||
assert stop_event.is_set(), "callback must disarm PTB synchronously"
|
||||
assert adapter._polling_error_task is not None, "recovery task must be scheduled"
|
||||
|
||||
# Drain the scheduled recovery task so it doesn't outlive the test.
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0)
|
||||
await _cancel_heartbeat(adapter)
|
||||
Reference in New Issue
Block a user