Files
EvoScientist-Multi/tests/test_telegram_channel.py
T
Faych 9bd7a37a77 fix: _extract_retry_after returns None for non-retryable errors (#394)
* fix: _extract_retry_after returns None for non-retryable errors

* fix: update _extract_retry_after to handle generic transient errors with default retry delay

* fix: extend base _non_retryable_patterns in channel subclasses

* fix(channels): merge structured SDK error check into non-retryable step

* fix(channels): decouple status code and SDK error code extraction in retry logic

- Independently evaluate HTTP status codes and structured SDK error codes
- Fix misleading doc comments for Feishu and DingTalk patterns
- Remove redundant try-except AttributeError on getattr with default
- Expand test coverage for dual-signal matrix and header parsing

* refactor(channels): simplify status code and SDK error extraction via channel overrides

- Handle httpx and aiohttp exceptions in base Channel class
- Override _extract_status_code and _extract_sdk_error_code in SlackChannel and DiscordChannel
- Replace mock exception types in comprehensive test suite with real httpx and aiohttp errors
- Add dedicated Slack and Discord retry error extraction test suites

* fix(channels): clean up Slack and Discord error code extraction

- Remove defensive string checks and attribute guards in SlackChannel
- Directly access exc.response.status_code and exc.response.get('error') in SlackChannel
- Remove unnecessary _extract_sdk_error_code override in DiscordChannel
- Use real SlackApiError, SlackResponse, and discord.HTTPException in unit tests

* refactor: reorder retry logic to prioritize non-retryable checks, remove aiohttp dependency, and clean up exception handling in base and channel modules.

* test(channels): skip Slack/Discord retry tests when the SDK extra is absent

The retry-extraction tests build real SlackApiError / discord.HTTPException
objects, but slack-sdk and discord.py are optional extras that the dev
dependency group does not install. Under CI's `uv sync --dev` all nine
tests failed with ModuleNotFoundError raised from the channel override.

Gate both test classes with skipif(find_spec(...) is None) so the suite is
green without the extras and the tests still run wherever they are installed.

* refactor(channels): replace retry-delay lookup with _extract_retry_delay

_extract_retry_after still read the server-supplied delay by probing
exc.retry_after and exc.response.headers via getattr/hasattr, the last
remnant of the pattern the extractors moved away from. Replace both steps
with one overridable hook, _extract_retry_delay, implemented against the
real exception types:

- base: httpx.HTTPStatusError -> Retry-After header (httpx.Headers is
  case-insensitive; HTTP-date form remains unsupported)
- SlackChannel: SlackApiError -> Retry-After, matched case-insensitively
  because SlackResponse.headers is a plain dict whose casing depends on the
  HTTP client (same approach as slack_sdk's RateLimitErrorRetryHandler)
- TelegramChannel: telegram.error.RetryAfter.retry_after (int, or timedelta
  under PTB_TIMEDELTA)
- DiscordChannel: discord.RateLimited.retry_after, which the old duck-typed
  getattr matched and would otherwise have been lost

Drop the isinstance(retry, bool) and val >= 0 guards; no SDK produces those.
Delete the test that asserted the duck-typed attribute; add real-object tests
for each override, guarded like the existing SDK-dependent classes.

* ci: install the all-channels extra so SDK-dependent channel tests run

The Slack, Discord, and Telegram retry tests build real SDK exception
objects and are skipped when the SDK is absent. CI only ran `uv sync --dev`,
so those tests never executed there. Install the existing all-channels
extra alongside the dev group; the skipif guards remain for lean local runs.

* fix(channels): honor HTTP-date Retry-After and tolerate malformed values

RFC 9110 allows Retry-After as either delay-seconds or an HTTP-date. The
httpx path treated a date as unparseable and fell back to the 1.0 s default,
so a 503 asking for a specific wait was retried too early. Add
Channel._parse_retry_after, which returns delay-seconds as-is and converts
an HTTP-date to the non-negative seconds until it (tz-less dates read as
UTC).

SlackChannel used a bare float() on the header. A non-numeric value raised
inside the retry predicate, which escapes retry_async and drops the chunk
instead of retrying. Route Slack through the same helper so a bad header
falls back to _rate_limit_delay.

Addresses CodeRabbit review comments on base.py:866 and slack/channel.py:229.

* fix(channels): treat HTTP 400 and 404 as non-retryable

Both are permanent for a given request, so retrying burns the attempt
budget for nothing. Add them to _non_retryable_status_codes alongside
401/403.

Deliberately not a 4xx range check: 408 and 425 are retryable by
definition and 429 is handled by the rate-limit path. A test pins 408 as
still retryable so the range shortcut is not reintroduced later.

Partially addresses CodeRabbit's outside-diff comment on base.py:749-750.

* fix(channels): guard Slack retry extractors against raw aiohttp responses

slack_sdk attaches the bare aiohttp.ClientResponse to SlackApiError when a
JSON-declared body fails to parse. That object has neither status_code nor
get(), so _extract_status_code raised AttributeError inside should_retry,
replacing the original error and skipping the remaining attempts. Narrow
both extractors to SlackResponse/AsyncSlackResponse so such errors fall
through to the message patterns and retry as before. Add a wire-level
regression test against a local aiohttp server.

---------

Co-authored-by: Dinos Papakostas <dinospk1999@gmail.com>
Co-authored-by: X-iZhang <zacharyzhang2022@gmail.com>
2026-09-10 22:25:12 +00:00

303 lines
10 KiB
Python

"""Tests for Telegram channel implementation."""
import importlib.util
import sys
from datetime import datetime
from types import ModuleType, SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from EvoScientist.channels.base import ChannelError
from EvoScientist.channels.telegram.channel import TelegramChannel, TelegramConfig
class TestTelegramConfig:
def test_default_values(self):
config = TelegramConfig()
assert config.bot_token == ""
assert config.allowed_senders is None
assert config.text_chunk_limit == 4096
def test_custom_values(self):
config = TelegramConfig(
bot_token="test-token",
allowed_senders={"123", "456"},
text_chunk_limit=2000,
)
assert config.bot_token == "test-token"
assert config.allowed_senders == {"123", "456"}
assert config.text_chunk_limit == 2000
class TestTelegramChannel:
def test_init(self):
config = TelegramConfig(bot_token="test")
channel = TelegramChannel(config)
assert channel.config is config
assert channel._running is False
async def test_start_raises_without_token(self):
config = TelegramConfig(bot_token="")
channel = TelegramChannel(config)
with pytest.raises(ChannelError, match="bot token"):
await channel.start()
async def test_stop_when_not_running(self):
config = TelegramConfig(bot_token="test")
channel = TelegramChannel(config)
await channel.stop()
async def test_cleanup_is_idempotent(self):
channel = TelegramChannel(TelegramConfig(bot_token="test"))
app = SimpleNamespace(
updater=SimpleNamespace(running=True, stop=AsyncMock()),
running=True,
shutdown=AsyncMock(),
)
async def stop_once():
if not app.running:
raise RuntimeError("This Application is not running!")
app.running = False
app.stop = AsyncMock(side_effect=stop_once)
channel._app = app
await channel._cleanup()
await channel._cleanup()
app.updater.stop.assert_awaited_once()
app.stop.assert_awaited_once()
app.shutdown.assert_awaited_once()
assert channel._app is None
async def test_cleanup_partially_initialized_application(self):
channel = TelegramChannel(TelegramConfig(bot_token="test"))
app = SimpleNamespace(
updater=SimpleNamespace(running=False, stop=AsyncMock()),
running=False,
stop=AsyncMock(),
shutdown=AsyncMock(),
)
channel._app = app
await channel._cleanup()
app.updater.stop.assert_not_awaited()
app.stop.assert_not_awaited()
app.shutdown.assert_awaited_once()
assert channel._app is None
async def test_send_returns_false_without_app(self):
from EvoScientist.channels.base import OutboundMessage
config = TelegramConfig(bot_token="test")
channel = TelegramChannel(config)
msg = OutboundMessage(
channel="telegram",
chat_id="123",
content="hello",
metadata={"chat_id": "123"},
)
result = await channel.send(msg)
assert result is False
async def test_registered_handler_accepts_bot_commands(self, monkeypatch):
class FakeFilter:
def __init__(self, predicate):
self._predicate = predicate
def __or__(self, other):
return FakeFilter(
lambda update: (
self.check_update(update) or other.check_update(update)
)
)
def __and__(self, other):
return FakeFilter(
lambda update: (
self.check_update(update) and other.check_update(update)
)
)
def __invert__(self):
return FakeFilter(lambda update: not self.check_update(update))
def check_update(self, update):
return self._predicate(update)
class FakeMessageHandler:
def __init__(self, message_filter, callback):
self.filters = message_filter
self.callback = callback
app = SimpleNamespace(
handlers=[],
bot=SimpleNamespace(
get_me=AsyncMock(return_value=SimpleNamespace(username="botname"))
),
updater=SimpleNamespace(start_polling=AsyncMock()),
initialize=AsyncMock(),
start=AsyncMock(),
)
app.add_handler = app.handlers.append
class FakeApplicationBuilder:
def token(self, _token):
return self
def build(self):
return app
text_filter = FakeFilter(lambda update: update.message.text is not None)
command_filter = FakeFilter(lambda update: update.message.is_command)
false_filter = FakeFilter(lambda _update: False)
fake_filters = SimpleNamespace(
TEXT=text_filter,
COMMAND=command_filter,
PHOTO=false_filter,
VOICE=false_filter,
AUDIO=false_filter,
Document=SimpleNamespace(ALL=false_filter),
VIDEO=false_filter,
Sticker=SimpleNamespace(ALL=false_filter),
LOCATION=false_filter,
)
telegram_module = ModuleType("telegram")
ext_module = ModuleType("telegram.ext")
ext_module.ApplicationBuilder = FakeApplicationBuilder
ext_module.MessageHandler = FakeMessageHandler
ext_module.filters = fake_filters
telegram_module.ext = ext_module
monkeypatch.setitem(sys.modules, "telegram", telegram_module)
monkeypatch.setitem(sys.modules, "telegram.ext", ext_module)
channel = TelegramChannel(
TelegramConfig(bot_token="test", include_attachments=False)
)
await channel.start()
update = SimpleNamespace(message=SimpleNamespace(text="/help", is_command=True))
assert app.handlers[0].filters.check_update(update) is True
assert app.handlers[0].callback == channel._on_message
async def test_group_command_suffix_is_removed_before_enqueue(self):
channel = TelegramChannel(
TelegramConfig(bot_token="test", include_attachments=False)
)
channel._bot_username = "botname"
update = self._text_update("/stop@botname", chat_type="supergroup")
await channel._on_message(update, None)
message = await channel._queue.get()
assert message.content == "/stop"
assert message.is_group is True
assert message.was_mentioned is True
async def test_private_command_suffix_is_removed_before_enqueue(self):
channel = TelegramChannel(
TelegramConfig(bot_token="test", include_attachments=False)
)
channel._bot_username = "botname"
update = self._text_update("/stop@botname")
await channel._on_message(update, None)
message = await channel._queue.get()
assert message.content == "/stop"
assert message.is_group is False
async def test_bare_group_command_passes_mention_gating(self):
channel = TelegramChannel(
TelegramConfig(bot_token="test", include_attachments=False)
)
channel._bot_username = "botname"
update = self._text_update("/stop", chat_type="supergroup")
await channel._on_message(update, None)
message = await channel._queue.get()
assert message.content == "/stop"
assert message.was_mentioned is True
async def test_group_command_for_other_bot_is_ignored(self):
channel = TelegramChannel(
TelegramConfig(bot_token="test", include_attachments=False)
)
channel._bot_username = "botname"
update = self._text_update("/stop@otherbot", chat_type="supergroup")
await channel._on_message(update, None)
assert channel._queue.empty()
async def test_group_command_bypasses_buffered_history(self):
channel = TelegramChannel(
TelegramConfig(bot_token="test", include_attachments=False)
)
channel._bot_username = "botname"
chatter = self._text_update("background chatter", chat_type="supergroup")
await channel._on_message(chatter, None)
assert channel._queue.empty()
command = self._text_update("/help@botname", chat_type="supergroup")
command.message.message_id = 790
await channel._on_message(command, None)
message = await channel._queue.get()
assert message.content == "/help"
assert message.is_group is True
assert message.was_mentioned is True
async def test_start_command_flows_to_shared_dispatch(self):
channel = TelegramChannel(
TelegramConfig(bot_token="test", include_attachments=False)
)
update = self._text_update("/start")
await channel._on_message(update, None)
message = await channel._queue.get()
assert message.content == "/start"
@staticmethod
def _text_update(text, *, chat_type="private"):
message = SimpleNamespace(
from_user=SimpleNamespace(id=123),
chat_id=456,
chat=SimpleNamespace(type=chat_type),
text=text,
caption=None,
date=datetime(2026, 1, 1),
message_id=789,
)
return SimpleNamespace(message=message)
@pytest.mark.skipif(
importlib.util.find_spec("telegram") is None,
reason="python-telegram-bot not installed",
)
class TestTelegramRetryDelay:
def test_retry_after_honored(self):
from telegram.error import RetryAfter
ch = TelegramChannel(TelegramConfig(bot_token="t"))
assert ch._extract_retry_delay(RetryAfter(7)) == 7.0
assert ch._extract_retry_after(RetryAfter(7)) == 7.0
def test_other_errors_fall_through(self):
import httpx
ch = TelegramChannel(TelegramConfig(bot_token="t"))
exc = httpx.HTTPStatusError(
"429",
request=httpx.Request("POST", "https://example.invalid"),
response=httpx.Response(429, headers={"Retry-After": "3"}),
)
assert ch._extract_retry_delay(exc) == 3.0