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

340 lines
12 KiB
Python

"""Unit tests for Google Chat adapter - adapter.py module.
Tests cover:
- GoogleChatAdapter initialization and configuration
- CircuitBreaker integration
- Connection timeout
- send/edit/delete/reaction/stream methods
- receive() stub
- Health check
- Token refresh
"""
from __future__ import annotations
import asyncio
import time
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from yuxi.channels.exceptions import (
ChannelAuthenticationError,
ChannelException,
)
from yuxi.channels.models import (
ChannelIdentity,
ChannelResponse,
ChannelStatus,
ChannelType,
DeliveryResult,
)
from yuxi.channels.adapters.googlechat.adapter import (
GoogleChatAdapter,
ChatRateLimiter,
)
@pytest.fixture
def adapter():
return GoogleChatAdapter(
config={
"name": "test-gc",
"service_account_file": "/fake/sa.json",
"project_id": "test-project",
}
)
@pytest.fixture
def connected_adapter():
adapter = GoogleChatAdapter(
config={
"name": "test-gc",
"service_account_file": "/fake/sa.json",
"project_id": "test-project",
}
)
adapter._status = ChannelStatus.CONNECTED
adapter._chat_service = MagicMock()
adapter._connected_at = 1234567890.0
adapter._service_account_email = "test@project.iam.gserviceaccount.com"
return adapter
class TestGoogleChatAdapterInit:
def test_default_status_is_disconnected(self, adapter):
assert adapter._status == ChannelStatus.DISCONNECTED
def test_channel_id_class_var(self):
assert GoogleChatAdapter.channel_id == "googlechat"
def test_channel_type(self):
assert GoogleChatAdapter.channel_type == ChannelType.GOOGLE_CHAT
def test_supports_streaming(self):
assert GoogleChatAdapter.supports_streaming is True
def test_has_circuit_breaker(self, adapter):
assert adapter._circuit_breaker is not None
assert adapter._circuit_breaker.state == "closed"
def test_has_rate_limiter(self, adapter):
assert adapter._rate_limiter is not None
class TestChatRateLimiter:
def test_first_acquire_no_wait(self):
limiter = ChatRateLimiter(ops_per_second=10.0)
asyncio.run(limiter.acquire("space1"))
def test_rapid_acquires_throttled(self):
limiter = ChatRateLimiter(ops_per_second=0.5)
start = time.monotonic()
asyncio.run(limiter.acquire("space1"))
asyncio.run(limiter.acquire("space1"))
elapsed = time.monotonic() - start
assert elapsed >= 1.9
class TestConnect:
def test_connect_timeout(self, adapter):
async def slow_connect():
await asyncio.sleep(99)
with patch.object(adapter, "_connect_impl", side_effect=slow_connect):
with pytest.raises(ChannelException, match="timed out"):
asyncio.run(adapter.connect())
def test_connect_with_service_account(self, adapter):
async def mock_connect():
adapter._chat_service = MagicMock()
with patch.object(adapter, "_connect_impl", side_effect=mock_connect):
asyncio.run(adapter.connect())
assert adapter._status == ChannelStatus.CONNECTED
def test_connect_auth_failure(self, adapter):
with patch.object(adapter, "_init_service_account", side_effect=ChannelAuthenticationError()):
with pytest.raises(ChannelAuthenticationError):
asyncio.run(asyncio.wait_for(adapter._connect_impl(), timeout=5))
class TestDisconnect:
def test_disconnect_clears_state(self, connected_adapter):
asyncio.run(connected_adapter.disconnect())
assert connected_adapter._status == ChannelStatus.DISCONNECTED
assert connected_adapter._chat_service is None
def test_disconnect_resets_circuit_breaker(self, connected_adapter):
asyncio.run(connected_adapter.disconnect())
assert connected_adapter._circuit_breaker.state == "closed"
class TestSend:
def test_send_success(self, connected_adapter):
with patch(
"yuxi.channels.adapters.googlechat.adapter.send_message",
new_callable=AsyncMock,
) as mock_send:
mock_send.return_value = DeliveryResult(success=True, message_id="msg1")
resp = ChannelResponse(
identity=ChannelIdentity(
channel_id="googlechat",
channel_type=ChannelType.GOOGLE_CHAT,
channel_user_id="user1",
channel_chat_id="spaces/ABC",
),
content="hello",
)
result = asyncio.run(connected_adapter.send(resp))
assert result.success is True
assert result.message_id == "msg1"
class TestEditMessage:
def test_edit_success(self, connected_adapter):
with patch(
"yuxi.channels.adapters.googlechat.adapter.update_message",
new_callable=AsyncMock,
) as mock_update:
mock_update.return_value = DeliveryResult(success=True, message_id="msg1")
result = asyncio.run(connected_adapter.edit_message("spaces/ABC", "spaces/ABC/messages/old", "updated"))
assert result.success is True
class TestDeleteMessage:
def test_delete_success(self, connected_adapter):
with patch(
"yuxi.channels.adapters.googlechat.adapter._delete_message",
new_callable=AsyncMock,
) as mock_del:
mock_del.return_value = DeliveryResult(success=True)
result = asyncio.run(connected_adapter.delete_message("spaces/ABC", "spaces/ABC/messages/xyz"))
assert result.success is True
class TestSendReaction:
def test_reaction_success(self, connected_adapter):
with patch(
"yuxi.channels.adapters.googlechat.adapter.send_reaction",
new_callable=AsyncMock,
) as mock_react:
mock_react.return_value = DeliveryResult(success=True)
result = asyncio.run(connected_adapter.send_reaction("spaces/ABC", "spaces/ABC/messages/xyz", "👍"))
assert result.success is True
class TestSendStreamChunk:
def test_first_chunk_creates_message(self, connected_adapter):
with patch(
"yuxi.channels.adapters.googlechat.adapter.send_message",
new_callable=AsyncMock,
) as mock_send:
mock_send.return_value = DeliveryResult(success=True, message_id="stream_msg_1")
result = asyncio.run(connected_adapter.send_stream_chunk("spaces/ABC", "", "hello", False))
assert result.success is True
assert mock_send.called
def test_circuit_breaker_catches_error(self, connected_adapter):
connected_adapter._circuit_breaker.state = "open"
connected_adapter._circuit_breaker._last_failure_time = time.monotonic()
result = asyncio.run(connected_adapter.send_stream_chunk("spaces/ABC", "", "hello", False))
assert result.success is False
assert "Circuit breaker open" in result.error
class TestReceive:
def test_receive_yields_nothing(self, adapter):
async def collect():
items = []
async for msg in adapter.receive():
items.append(msg)
return items
items = asyncio.run(collect())
assert items == []
class TestNormalizeInbound:
def test_normalize_delegates(self, adapter):
raw = {"event": {"type": "MESSAGE", "message": {"text": "hi"}}}
result = adapter.normalize_inbound(raw)
assert result.content == "hi"
class TestFormatOutbound:
def test_format_outbound(self, adapter):
resp = ChannelResponse(
identity=ChannelIdentity(
channel_id="googlechat",
channel_type=ChannelType.GOOGLE_CHAT,
channel_user_id="user1",
channel_chat_id="spaces/ABC",
),
content="hello",
)
result = adapter.format_outbound(resp)
assert result["text"] == "hello"
class TestHealthCheck:
def test_unhealthy_when_disconnected(self, adapter):
result = asyncio.run(adapter.health_check())
assert result.status == "unhealthy"
def test_healthy_when_connected(self, connected_adapter):
connected_adapter._chat_service.spaces().list().execute.return_value = {}
result = asyncio.run(connected_adapter.health_check())
assert result.status == "healthy"
assert "circuit_breaker_state" in result.metadata
def test_unhealthy_on_api_failure(self, connected_adapter):
connected_adapter._chat_service.spaces().list().execute.side_effect = Exception("API down")
result = asyncio.run(connected_adapter.health_check())
assert result.status == "unhealthy"
class TestRefreshToken:
def test_no_credentials_returns_false(self, adapter):
result = asyncio.run(adapter._refresh_token_if_needed())
assert result is False
def test_invalid_credentials_returns_false(self, connected_adapter):
connected_adapter._credentials = MagicMock(valid=False)
result = asyncio.run(connected_adapter._refresh_token_if_needed())
assert result is False
def test_valid_not_expired_returns_true(self, connected_adapter):
connected_adapter._credentials = MagicMock(valid=True, expired=False)
result = asyncio.run(connected_adapter._refresh_token_if_needed())
assert result is True
class TestVerifyWebhookSignature:
def test_missing_auth_header(self, connected_adapter):
result = asyncio.run(connected_adapter.verify_webhook_signature({}, b"body"))
assert result is False
def test_invalid_auth_header(self, connected_adapter):
result = asyncio.run(connected_adapter.verify_webhook_signature({"Authorization": "Basic xyz"}, b"body"))
assert result is False
def test_valid_bearer_token(self, connected_adapter):
with patch(
"yuxi.channels.adapters.googlechat.adapter._verify_jwt",
return_value=True,
):
result = asyncio.run(
connected_adapter.verify_webhook_signature(
{"Authorization": "Bearer valid.jwt.token", "Content-Type": "application/json"}, b"body"
)
)
assert result is True
class TestGetUserInfo:
def test_success(self, connected_adapter):
connected_adapter._chat_service.users().get().execute.return_value = {
"name": "users/123",
"displayName": "Test User",
"email": "test@example.com",
}
result = asyncio.run(connected_adapter.get_user_info("users/123"))
assert result["display_name"] == "Test User"
assert result["email"] == "test@example.com"
def test_failure_returns_empty_dict(self, connected_adapter):
connected_adapter._chat_service.users().get().execute.side_effect = Exception("not found")
result = asyncio.run(connected_adapter.get_user_info("users/999"))
assert result == {}
class TestDownloadMedia:
def test_success(self, connected_adapter):
with patch(
"yuxi.channels.adapters.googlechat.adapter._download_media",
new_callable=AsyncMock,
) as mock_dl:
mock_dl.return_value = b"file contents"
result = asyncio.run(connected_adapter.download_media('{"name":"att/1"}'))
assert result == b"file contents"
class TestSendMedia:
def test_url_media(self, connected_adapter):
with patch(
"yuxi.channels.adapters.googlechat.adapter.send_media",
new_callable=AsyncMock,
) as mock_sm:
mock_sm.return_value = DeliveryResult(success=True, message_id="m1")
result = asyncio.run(connected_adapter.send_media("spaces/ABC", "IMAGE", "https://example.com/photo.png"))
assert result.success is True
def test_unsupported_type(self, connected_adapter):
result = asyncio.run(connected_adapter.send_media("spaces/ABC", "TEXT", 123))
assert result.success is False
assert "Unsupported media data type" in result.error