ForcePilot/backend/test/unit/channels/test_googlechat_send.py
Kris 3264900bc9 test: 新增多渠道单元测试用例并配置测试环境变量
新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块
同时在测试配置中添加了测试用的OpenAI API密钥环境变量
2026-05-12 00:56:47 +08:00

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