新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块 同时在测试配置中添加了测试用的OpenAI API密钥环境变量
340 lines
12 KiB
Python
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
|