新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块 同时在测试配置中添加了测试用的OpenAI API密钥环境变量
330 lines
12 KiB
Python
330 lines
12 KiB
Python
"""Unit tests for Google Chat adapter - send.py module.
|
|
|
|
Tests cover:
|
|
- Error classification (_classify_error)
|
|
- Exponential backoff with jitter
|
|
- Retry with backoff (_retry_with_backoff)
|
|
- All send functions with proper error handling
|
|
|
|
Note: Tests mock googleapiclient to run without the package installed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import sys
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from yuxi.channels.models import DeliveryResult
|
|
from yuxi.channels.exceptions import (
|
|
ChannelAuthenticationError,
|
|
ChannelRateLimitError,
|
|
DeliveryFailedError,
|
|
)
|
|
|
|
|
|
def _make_fake_googleapiclient():
|
|
fake_errors = MagicMock()
|
|
resp_cls = MagicMock()
|
|
resp_cls.status = 200
|
|
resp_cls.reason = "OK"
|
|
http_error_cls = type("HttpError", (Exception,), {})
|
|
http_error_cls.resp = resp_cls
|
|
|
|
def _mock_http_error_init(self, resp, content, uri=None):
|
|
self.resp = resp
|
|
self.content = content
|
|
self.uri = uri
|
|
|
|
http_error_cls.__init__ = _mock_http_error_init
|
|
fake_errors.HttpError = http_error_cls
|
|
|
|
fake_http = MagicMock()
|
|
fake_http.MediaIoBaseUpload = MagicMock()
|
|
|
|
fake_apiclient = MagicMock()
|
|
fake_apiclient.errors = fake_errors
|
|
fake_apiclient.http = fake_http
|
|
return fake_apiclient, http_error_cls
|
|
|
|
|
|
def _install_googleapiclient_mock():
|
|
if "googleapiclient" not in sys.modules:
|
|
fake, _ = _make_fake_googleapiclient()
|
|
sys.modules["googleapiclient"] = fake
|
|
sys.modules["googleapiclient.errors"] = fake.errors
|
|
sys.modules["googleapiclient.http"] = fake.http
|
|
|
|
|
|
def _make_http_error(status: int, reason: str = "", content: bytes = b""):
|
|
from googleapiclient.errors import HttpError
|
|
|
|
resp = MagicMock(status=status, reason=reason)
|
|
error = HttpError(resp, content)
|
|
return error
|
|
|
|
|
|
class TestClassifyError:
|
|
@pytest.fixture(autouse=True)
|
|
def _setup_googleapiclient(self):
|
|
_install_googleapiclient_mock()
|
|
yield
|
|
|
|
def test_429_raises_rate_limit(self):
|
|
from yuxi.channels.adapters.googlechat.send import _classify_error
|
|
|
|
with pytest.raises(ChannelRateLimitError):
|
|
_classify_error(_make_http_error(429))
|
|
|
|
def test_401_raises_auth_error(self):
|
|
from yuxi.channels.adapters.googlechat.send import _classify_error
|
|
|
|
with pytest.raises(ChannelAuthenticationError):
|
|
_classify_error(_make_http_error(401))
|
|
|
|
def test_403_without_rate_limit_raises_auth_error(self):
|
|
from yuxi.channels.adapters.googlechat.send import _classify_error
|
|
|
|
with pytest.raises(ChannelAuthenticationError):
|
|
_classify_error(_make_http_error(403, content=b'{"error": "forbidden"}'))
|
|
|
|
def test_403_with_rate_limit_raises_rate_limit(self):
|
|
from yuxi.channels.adapters.googlechat.send import _classify_error
|
|
|
|
with pytest.raises(ChannelRateLimitError):
|
|
_classify_error(_make_http_error(403, content=b"rateLimitExceeded"))
|
|
|
|
def test_500_raises_delivery_failed(self):
|
|
from yuxi.channels.adapters.googlechat.send import _classify_error
|
|
|
|
with pytest.raises(DeliveryFailedError):
|
|
_classify_error(_make_http_error(500))
|
|
|
|
def test_non_http_error_raises_delivery_failed(self):
|
|
from yuxi.channels.adapters.googlechat.send import _classify_error
|
|
|
|
with pytest.raises(DeliveryFailedError):
|
|
_classify_error(ValueError("unexpected"))
|
|
|
|
|
|
class TestBackoffWithJitter:
|
|
def test_backoff_range(self):
|
|
from yuxi.channels.adapters.googlechat.send import _backoff_with_jitter
|
|
|
|
for i in range(5):
|
|
delay = _backoff_with_jitter(i)
|
|
assert 0 < delay <= 64, f"iter {i}: delay={delay}"
|
|
|
|
def test_max_cap(self):
|
|
from yuxi.channels.adapters.googlechat.send import _backoff_with_jitter
|
|
|
|
delay = _backoff_with_jitter(20)
|
|
assert delay <= 64
|
|
|
|
|
|
class TestExtractRateLimitReason:
|
|
def test_rate_limit_exceeded_bytes(self):
|
|
from yuxi.channels.adapters.googlechat.send import _extract_rate_limit_reason
|
|
|
|
error = _make_http_error(403, content=b"rateLimitExceeded")
|
|
assert _extract_rate_limit_reason(error) is True
|
|
|
|
def test_user_rate_limit_exceeded(self):
|
|
from yuxi.channels.adapters.googlechat.send import _extract_rate_limit_reason
|
|
|
|
error = _make_http_error(403, content=b"userRateLimitExceeded")
|
|
assert _extract_rate_limit_reason(error) is True
|
|
|
|
def test_no_match(self):
|
|
from yuxi.channels.adapters.googlechat.send import _extract_rate_limit_reason
|
|
|
|
error = _make_http_error(403, content=b'{"error": "forbidden"}')
|
|
assert _extract_rate_limit_reason(error) is False
|
|
|
|
def test_no_content_attr(self):
|
|
from yuxi.channels.adapters.googlechat.send import _extract_rate_limit_reason
|
|
|
|
e = Exception("test")
|
|
assert _extract_rate_limit_reason(e) is False
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_chat_service():
|
|
service = MagicMock()
|
|
return service
|
|
|
|
|
|
class TestSendMessage:
|
|
@pytest.fixture(autouse=True)
|
|
def _setup_googleapiclient(self):
|
|
_install_googleapiclient_mock()
|
|
yield
|
|
|
|
def test_success(self, mock_chat_service):
|
|
from yuxi.channels.adapters.googlechat.send import send_message
|
|
|
|
mock_chat_service.spaces().messages().create().execute.return_value = {"name": "spaces/ABC/messages/xyz"}
|
|
result = asyncio.run(send_message(mock_chat_service, "spaces/ABC", {"text": "hello"}))
|
|
assert result.success is True
|
|
assert result.message_id == "spaces/ABC/messages/xyz"
|
|
|
|
def test_rate_limit_with_eventual_success(self, mock_chat_service):
|
|
from yuxi.channels.adapters.googlechat.send import send_message
|
|
|
|
call_count = [0]
|
|
|
|
def mock_execute():
|
|
call_count[0] += 1
|
|
if call_count[0] < 3:
|
|
raise ChannelRateLimitError()
|
|
return {"name": "spaces/ABC/messages/ok"}
|
|
|
|
mock_chat_service.spaces().messages().create.return_value.execute.side_effect = mock_execute
|
|
|
|
with patch("yuxi.channels.adapters.googlechat.send.asyncio.sleep", new_callable=AsyncMock):
|
|
result = asyncio.run(send_message(mock_chat_service, "spaces/ABC", {"text": "hello"}))
|
|
assert result.success is True
|
|
assert result.message_id == "spaces/ABC/messages/ok"
|
|
|
|
def test_auth_error_no_retry(self, mock_chat_service):
|
|
from yuxi.channels.adapters.googlechat.send import send_message
|
|
|
|
mock_chat_service.spaces().messages().create.return_value.execute.side_effect = ChannelAuthenticationError(
|
|
"bad creds"
|
|
)
|
|
result = asyncio.run(send_message(mock_chat_service, "spaces/ABC", {"text": "hello"}))
|
|
assert result.success is False
|
|
|
|
def test_max_retries_exhausted(self, mock_chat_service):
|
|
from yuxi.channels.adapters.googlechat.send import send_message
|
|
|
|
mock_chat_service.spaces().messages().create.return_value.execute.side_effect = ChannelRateLimitError()
|
|
with patch("yuxi.channels.adapters.googlechat.send.asyncio.sleep", new_callable=AsyncMock):
|
|
result = asyncio.run(send_message(mock_chat_service, "spaces/ABC", {"text": "hello"}))
|
|
assert result.success is False
|
|
|
|
def test_generic_http_error_retries(self, mock_chat_service):
|
|
from yuxi.channels.adapters.googlechat.send import send_message
|
|
|
|
call_count = [0]
|
|
|
|
def mock_execute():
|
|
call_count[0] += 1
|
|
if call_count[0] < 3:
|
|
raise _make_http_error(500, content=b"internal error")
|
|
return {"name": "spaces/ABC/messages/ok"}
|
|
|
|
mock_chat_service.spaces().messages().create.return_value.execute.side_effect = mock_execute
|
|
|
|
with patch("yuxi.channels.adapters.googlechat.send.asyncio.sleep", new_callable=AsyncMock):
|
|
result = asyncio.run(send_message(mock_chat_service, "spaces/ABC", {"text": "hello"}))
|
|
assert result.success is True
|
|
|
|
|
|
class TestUpdateMessage:
|
|
def test_success(self, mock_chat_service):
|
|
from yuxi.channels.adapters.googlechat.send import update_message
|
|
|
|
mock_chat_service.spaces().messages().update().execute.return_value = {"name": "spaces/ABC/messages/xyz"}
|
|
result = asyncio.run(update_message(mock_chat_service, "spaces/ABC/messages/old", {"text": "updated"}))
|
|
assert result.success is True
|
|
|
|
def test_with_custom_update_mask(self, mock_chat_service):
|
|
from yuxi.channels.adapters.googlechat.send import update_message
|
|
|
|
mock_chat_service.spaces().messages().update().execute.return_value = {"name": "spaces/ABC/messages/xyz"}
|
|
result = asyncio.run(
|
|
update_message(
|
|
mock_chat_service,
|
|
"spaces/ABC/messages/old",
|
|
{"text": "updated", "cardsV2": []},
|
|
update_mask="text,cardsV2",
|
|
)
|
|
)
|
|
assert result.success is True
|
|
|
|
|
|
class TestDeleteMessage:
|
|
def test_success(self, mock_chat_service):
|
|
from yuxi.channels.adapters.googlechat.send import delete_message
|
|
|
|
mock_chat_service.spaces().messages().delete().execute.return_value = None
|
|
result = asyncio.run(delete_message(mock_chat_service, "spaces/ABC/messages/xyz"))
|
|
assert result.success is True
|
|
|
|
|
|
class TestSendMedia:
|
|
def test_success(self, mock_chat_service):
|
|
from yuxi.channels.adapters.googlechat.send import send_media
|
|
|
|
mock_chat_service.spaces().messages().create().execute.return_value = {"name": "spaces/ABC/messages/media1"}
|
|
result = asyncio.run(send_media(mock_chat_service, "spaces/ABC", "https://example.com/img.png"))
|
|
assert result.success is True
|
|
|
|
|
|
class TestSendReaction:
|
|
def test_success(self, mock_chat_service):
|
|
from yuxi.channels.adapters.googlechat.send import send_reaction
|
|
|
|
mock_chat_service.spaces().messages().reactions().create().execute.return_value = {}
|
|
result = asyncio.run(send_reaction(mock_chat_service, "spaces/ABC/messages/xyz", "\U0001f44d"))
|
|
assert result.success is True
|
|
|
|
def test_failure(self, mock_chat_service):
|
|
from yuxi.channels.adapters.googlechat.send import send_reaction
|
|
|
|
mock_chat_service.spaces().messages().reactions().create().execute.side_effect = DeliveryFailedError("oops")
|
|
result = asyncio.run(send_reaction(mock_chat_service, "spaces/ABC/messages/xyz", "\U0001f44d"))
|
|
assert result.success is False
|
|
|
|
|
|
class TestUploadFileMessage:
|
|
def test_success(self, mock_chat_service):
|
|
_install_googleapiclient_mock()
|
|
from yuxi.channels.adapters.googlechat.send import upload_file_message
|
|
|
|
mock_chat_service.spaces().messages().create().execute.return_value = {"name": "spaces/ABC/messages/file1"}
|
|
result = asyncio.run(upload_file_message(mock_chat_service, "spaces/ABC", b"file data", "test.txt"))
|
|
assert result.success is True
|
|
|
|
|
|
class TestUploadImageMessage:
|
|
def test_success(self, mock_chat_service):
|
|
_install_googleapiclient_mock()
|
|
from yuxi.channels.adapters.googlechat.send import upload_image_message
|
|
|
|
mock_chat_service.spaces().messages().create().execute.return_value = {"name": "spaces/ABC/messages/img1"}
|
|
result = asyncio.run(upload_image_message(mock_chat_service, "spaces/ABC", b"png data"))
|
|
assert result.success is True
|
|
|
|
|
|
class TestRetryWithBackoff:
|
|
async def test_immediate_success(self):
|
|
from yuxi.channels.adapters.googlechat.send import _retry_with_backoff
|
|
|
|
async def succeed():
|
|
return DeliveryResult(success=True)
|
|
|
|
result = await _retry_with_backoff(succeed)
|
|
assert result.success is True
|
|
|
|
async def test_non_retryable_error(self):
|
|
from yuxi.channels.adapters.googlechat.send import _retry_with_backoff
|
|
|
|
async def fail():
|
|
raise DeliveryFailedError("hard failure")
|
|
|
|
with pytest.raises(DeliveryFailedError):
|
|
await _retry_with_backoff(fail)
|
|
|
|
async def test_max_retries_exhausted(self):
|
|
from yuxi.channels.adapters.googlechat.send import _retry_with_backoff
|
|
|
|
async def always_fail():
|
|
raise ChannelRateLimitError()
|
|
|
|
result = await _retry_with_backoff(always_fail)
|
|
assert result.success is False
|