新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块 同时在测试配置中添加了测试用的OpenAI API密钥环境变量
577 lines
20 KiB
Python
577 lines
20 KiB
Python
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from yuxi.channels.adapters.msteams.adapter import MSTeamsAdapter
|
|
from yuxi.channels.adapters.msteams.cards import (
|
|
build_confirm_card,
|
|
build_info_card,
|
|
build_input_card,
|
|
build_vote_card,
|
|
create_hero_card,
|
|
wrap_as_attachment,
|
|
)
|
|
from yuxi.channels.adapters.msteams.formatter import format_outbound, format_adaptive_card_response
|
|
from yuxi.channels.adapters.msteams.normalizer import normalize_inbound, normalize_conversation_update, normalize_invoke
|
|
from yuxi.channels.adapters.msteams.session import determine_chat_type, resolve_channel_chat_id
|
|
from yuxi.channels.adapters.msteams.tenant import TenantValidator
|
|
from yuxi.channels.models import (
|
|
ChannelIdentity,
|
|
ChannelMessage,
|
|
ChannelResponse,
|
|
ChannelStatus,
|
|
ChannelType,
|
|
DeliveryResult,
|
|
EventType,
|
|
MessageType,
|
|
)
|
|
|
|
|
|
def make_activity(
|
|
activity_type="message",
|
|
text="hello",
|
|
from_info=None,
|
|
conversation=None,
|
|
channel_data=None,
|
|
attachments=None,
|
|
entities=None,
|
|
recipient=None,
|
|
members_added=None,
|
|
):
|
|
activity = {
|
|
"type": activity_type,
|
|
"id": "msg-001",
|
|
"timestamp": "2024-01-15T10:30:00Z",
|
|
"serviceUrl": "https://smba.trafficmanager.net/emea",
|
|
"channelId": "msteams",
|
|
"from": from_info or {"id": "user-001", "name": "Test User", "aadObjectId": "aad-001"},
|
|
"conversation": conversation or {"id": "conv-001", "conversationType": "personal"},
|
|
"recipient": recipient or {"id": "bot-001", "name": "Test Bot"},
|
|
"text": text,
|
|
"textFormat": "markdown",
|
|
"channelData": channel_data or {"tenant": {"id": "tenant-001"}},
|
|
}
|
|
|
|
if activity_type == "conversationUpdate" and members_added:
|
|
activity["membersAdded"] = members_added
|
|
|
|
if activity_type == "invoke":
|
|
activity["name"] = "adaptiveCard/action"
|
|
activity["value"] = {"action": "confirm"}
|
|
|
|
if attachments:
|
|
activity["attachments"] = attachments
|
|
|
|
if entities:
|
|
activity["entities"] = entities
|
|
|
|
return activity
|
|
|
|
|
|
class TestMSTeamsAdapterMeta:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return MSTeamsAdapter(config={"app_id": "test-app-id", "app_password": "test-secret"})
|
|
|
|
def test_channel_id(self, adapter):
|
|
assert adapter.channel_id == "msteams"
|
|
|
|
def test_channel_type(self, adapter):
|
|
assert adapter.channel_type == ChannelType.MS_TEAMS
|
|
|
|
def test_capabilities(self, adapter):
|
|
assert adapter.supports_streaming is True
|
|
assert adapter.supports_markdown is True
|
|
assert adapter.text_chunk_limit == 4000
|
|
assert adapter.max_media_size_mb == 100
|
|
assert adapter.streaming_modes == ["off", "block", "progress"]
|
|
|
|
def test_initial_status(self, adapter):
|
|
assert adapter._status == ChannelStatus.DISCONNECTED
|
|
|
|
def test_webhook_path(self, adapter):
|
|
assert adapter.webhook_path == "/webhook/msteams"
|
|
|
|
def test_require_connected_returns_error_when_disconnected(self, adapter):
|
|
result = adapter._require_connected()
|
|
assert result is not None
|
|
assert result.success is False
|
|
assert result.error == "Not connected"
|
|
|
|
|
|
class TestNormalizeInbound:
|
|
def test_normalize_text_message(self):
|
|
raw = make_activity(text="hello world")
|
|
result = normalize_inbound(raw)
|
|
|
|
assert isinstance(result, ChannelMessage)
|
|
assert result.content == "hello world"
|
|
assert result.event_type == EventType.MESSAGE_RECEIVED
|
|
assert result.message_type == MessageType.TEXT
|
|
assert result.identity.channel_id == "msteams"
|
|
assert result.identity.channel_type == ChannelType.MS_TEAMS
|
|
assert result.identity.channel_user_id == "aad-001"
|
|
assert result.identity.channel_chat_id == "personal_aad-001"
|
|
assert result.identity.channel_message_id == "msg-001"
|
|
|
|
def test_normalize_group_chat(self):
|
|
raw = make_activity(
|
|
conversation={"id": "group-001", "conversationType": "groupChat"},
|
|
)
|
|
result = normalize_inbound(raw)
|
|
assert result.identity.channel_chat_id == "group_group-001"
|
|
|
|
def test_normalize_channel_message(self):
|
|
raw = make_activity(
|
|
conversation={"id": "conv-001", "conversationType": "channel"},
|
|
channel_data={
|
|
"tenant": {"id": "tenant-001"},
|
|
"channel": {"id": "ch-001"},
|
|
},
|
|
)
|
|
result = normalize_inbound(raw)
|
|
assert result.identity.channel_chat_id == "channel_ch-001"
|
|
|
|
def test_normalize_with_mentions(self):
|
|
raw = make_activity(
|
|
text="<at>Test Bot</at> hello",
|
|
entities=[
|
|
{"type": "mention", "mentioned": {"id": "bot-001", "name": "Test Bot"}},
|
|
{"type": "mention", "mentioned": {"id": "user-002", "name": "Other"}},
|
|
],
|
|
)
|
|
result = normalize_inbound(raw)
|
|
assert result.mentions is not None
|
|
assert result.mentions.is_bot_mentioned is True
|
|
assert len(result.mentions.mentioned_user_ids) == 2
|
|
assert result.content == "hello"
|
|
|
|
def test_normalize_with_attachment(self):
|
|
raw = make_activity(
|
|
attachments=[{"contentType": "image/png", "contentUrl": "https://example.com/img.png", "name": "photo.png"}],
|
|
)
|
|
result = normalize_inbound(raw)
|
|
assert len(result.attachments) == 1
|
|
assert result.attachments[0].type == "image"
|
|
assert result.attachments[0].url == "https://example.com/img.png"
|
|
|
|
def test_normalize_with_adaptive_card_attachment(self):
|
|
raw = make_activity(
|
|
attachments=[{"contentType": "application/vnd.microsoft.card.adaptive", "contentUrl": "", "name": ""}],
|
|
)
|
|
result = normalize_inbound(raw)
|
|
assert len(result.attachments) == 1
|
|
assert result.attachments[0].type == "card"
|
|
|
|
def test_normalize_reply_to(self):
|
|
raw = make_activity(text="reply", from_info={"id": "user-001", "name": "Test User", "aadObjectId": "aad-001"})
|
|
raw["replyToId"] = "original-msg-001"
|
|
|
|
result = normalize_inbound(raw)
|
|
assert result.reply_to_message_id == "original-msg-001"
|
|
|
|
def test_normalize_metadata(self):
|
|
raw = make_activity(
|
|
channel_data={
|
|
"tenant": {"id": "tenant-001"},
|
|
"team": {"id": "team-001"},
|
|
"channel": {"id": "ch-001"},
|
|
},
|
|
)
|
|
result = normalize_inbound(raw)
|
|
assert result.metadata["tenant_id"] == "tenant-001"
|
|
assert result.metadata["team_id"] == "team-001"
|
|
assert result.metadata["teams_channel_id"] == "ch-001"
|
|
|
|
|
|
class TestNormalizeConversationUpdate:
|
|
def test_bot_added(self):
|
|
raw = make_activity(
|
|
activity_type="conversationUpdate",
|
|
text="",
|
|
members_added=[{"id": "bot-001"}],
|
|
)
|
|
result = normalize_conversation_update(raw)
|
|
assert result.event_type == EventType.BOT_ADDED
|
|
assert result.metadata["bot_added"] is True
|
|
|
|
def test_member_joined(self):
|
|
raw = make_activity(
|
|
activity_type="conversationUpdate",
|
|
text="",
|
|
members_added=[{"id": "user-099"}],
|
|
)
|
|
result = normalize_conversation_update(raw)
|
|
assert result.event_type == EventType.MEMBER_JOINED
|
|
assert result.metadata["bot_added"] is False
|
|
|
|
|
|
class TestNormalizeInvoke:
|
|
def test_card_action(self):
|
|
raw = make_activity(
|
|
activity_type="invoke",
|
|
text="",
|
|
)
|
|
result = normalize_invoke(raw)
|
|
assert result.event_type == EventType.CARD_ACTION
|
|
assert result.metadata["invoke_name"] == "adaptiveCard/action"
|
|
|
|
|
|
class TestAdapterNormalizeInbound:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return MSTeamsAdapter(config={"app_id": "test-app-id", "app_password": "test-secret"})
|
|
|
|
def test_message_activity(self, adapter):
|
|
raw = make_activity(text="hello")
|
|
result = adapter.normalize_inbound(raw)
|
|
assert result.event_type == EventType.MESSAGE_RECEIVED
|
|
|
|
def test_conversation_update_activity(self, adapter):
|
|
raw = make_activity(
|
|
activity_type="conversationUpdate",
|
|
text="",
|
|
members_added=[{"id": "bot-001"}],
|
|
)
|
|
result = adapter.normalize_inbound(raw)
|
|
assert result.event_type == EventType.BOT_ADDED
|
|
|
|
def test_invoke_activity(self, adapter):
|
|
raw = make_activity(activity_type="invoke", text="")
|
|
result = adapter.normalize_inbound(raw)
|
|
assert result.event_type == EventType.CARD_ACTION
|
|
|
|
def test_unknown_activity_type(self, adapter):
|
|
raw = make_activity(activity_type="typing", text="")
|
|
result = adapter.normalize_inbound(raw)
|
|
assert result.event_type == EventType.MESSAGE_RECEIVED
|
|
assert result.content == ""
|
|
|
|
|
|
class TestFormatOutbound:
|
|
def test_format_text_message(self):
|
|
identity = ChannelIdentity(
|
|
channel_id="msteams",
|
|
channel_type=ChannelType.MS_TEAMS,
|
|
channel_user_id="user-001",
|
|
channel_chat_id="conv-001",
|
|
)
|
|
response = ChannelResponse(identity=identity, content="hello")
|
|
activity = format_outbound(response)
|
|
|
|
assert activity["type"] == "message"
|
|
assert activity["text"] == "hello"
|
|
assert activity["textFormat"] == "markdown"
|
|
assert activity["conversation"]["id"] == "conv-001"
|
|
|
|
def test_format_with_reply(self):
|
|
identity = ChannelIdentity(
|
|
channel_id="msteams",
|
|
channel_type=ChannelType.MS_TEAMS,
|
|
channel_user_id="user-001",
|
|
channel_chat_id="conv-001",
|
|
)
|
|
response = ChannelResponse(identity=identity, content="reply", reply_to_message_id="original-001")
|
|
activity = format_outbound(response)
|
|
assert activity["replyToId"] == "original-001"
|
|
|
|
def test_format_with_adaptive_card(self):
|
|
identity = ChannelIdentity(
|
|
channel_id="msteams",
|
|
channel_type=ChannelType.MS_TEAMS,
|
|
channel_user_id="user-001",
|
|
channel_chat_id="conv-001",
|
|
)
|
|
card = build_confirm_card(title="Confirm", message="Are you sure?")
|
|
response = ChannelResponse(
|
|
identity=identity,
|
|
content="card content",
|
|
metadata={"adaptive_card": card},
|
|
)
|
|
activity = format_outbound(response)
|
|
assert len(activity["attachments"]) == 1
|
|
assert activity["attachments"][0]["contentType"] == "application/vnd.microsoft.card.adaptive"
|
|
|
|
def test_format_adaptive_card_response(self):
|
|
card = build_vote_card(title="Vote", options=["A", "B"])
|
|
activity = format_adaptive_card_response("conv-001", card, reply_to_id="msg-001")
|
|
|
|
assert activity["conversation"]["id"] == "conv-001"
|
|
assert activity["replyToId"] == "msg-001"
|
|
assert activity["attachments"][0]["content"] == card
|
|
|
|
def test_text_chunk_limit(self):
|
|
identity = ChannelIdentity(
|
|
channel_id="msteams",
|
|
channel_type=ChannelType.MS_TEAMS,
|
|
channel_user_id="user-001",
|
|
channel_chat_id="conv-001",
|
|
)
|
|
long_text = "x" * 5000
|
|
response = ChannelResponse(identity=identity, content=long_text)
|
|
activity = format_outbound(response, text_chunk_limit=4000)
|
|
assert len(activity["text"]) == 4000
|
|
|
|
|
|
class TestSession:
|
|
def test_determine_chat_type_personal(self):
|
|
assert determine_chat_type("personal") == "direct"
|
|
|
|
def test_determine_chat_type_channel(self):
|
|
assert determine_chat_type("channel") == "group"
|
|
|
|
def test_determine_chat_type_group_chat(self):
|
|
assert determine_chat_type("groupChat") == "group"
|
|
|
|
def test_resolve_personal_chat_id(self):
|
|
result = resolve_channel_chat_id("personal", "conv-001", "aad-001")
|
|
assert result == "personal_aad-001"
|
|
|
|
def test_resolve_channel_chat_id(self):
|
|
result = resolve_channel_chat_id(
|
|
"channel", "conv-001", "aad-001",
|
|
channel_data={"channel": {"id": "ch-001"}},
|
|
)
|
|
assert result == "channel_ch-001"
|
|
|
|
def test_resolve_group_chat_id(self):
|
|
result = resolve_channel_chat_id("groupChat", "conv-001")
|
|
assert result == "group_conv-001"
|
|
|
|
|
|
class TestTenantValidator:
|
|
def test_open_mode_allows_all(self):
|
|
validator = TenantValidator()
|
|
assert validator.mode == "open"
|
|
assert validator.validate(None) is True
|
|
assert validator.validate({"tenant": {"id": "any-tenant"}}) is True
|
|
|
|
def test_restricted_mode_rejects_unknown(self):
|
|
validator = TenantValidator({"tenant-001"})
|
|
assert validator.mode == "restricted"
|
|
assert validator.validate({"tenant": {"id": "tenant-001"}}) is True
|
|
assert validator.validate({"tenant": {"id": "tenant-999"}}) is False
|
|
|
|
def test_set_allowed_tenants(self):
|
|
validator = TenantValidator()
|
|
validator.set_allowed_tenants(["t1", "t2"])
|
|
assert validator.mode == "restricted"
|
|
assert validator.validate({"tenant": {"id": "t1"}}) is True
|
|
|
|
|
|
class TestCards:
|
|
def test_build_vote_card(self):
|
|
card = build_vote_card("Choose", options=["A", "B", "C"])
|
|
assert card["type"] == "AdaptiveCard"
|
|
assert card["version"] == "1.5"
|
|
assert len(card["actions"]) == 1
|
|
|
|
def test_build_vote_card_max_options(self):
|
|
card = build_vote_card("Choose", options=[f"opt{i}" for i in range(20)])
|
|
choices = card["body"][1]["choices"]
|
|
assert len(choices) == 12
|
|
|
|
def test_build_confirm_card(self):
|
|
card = build_confirm_card("Title", "Message")
|
|
assert len(card["actions"]) == 2
|
|
assert card["actions"][0]["title"] == "确认"
|
|
assert card["actions"][1]["title"] == "取消"
|
|
|
|
def test_build_info_card(self):
|
|
card = build_info_card("Title", {"key": "value"}, subtitle="sub")
|
|
body_types = [b["type"] for b in card["body"]]
|
|
assert "FactSet" in body_types
|
|
|
|
def test_build_input_card(self):
|
|
card = build_input_card("Input", fields=[{"type": "Input.Text", "id": "name"}])
|
|
assert card["body"][1]["id"] == "name"
|
|
|
|
def test_create_hero_card(self):
|
|
card = create_hero_card("Title", "Text", images=["url1"], buttons=[{"title": "Btn", "value": "val"}])
|
|
assert card["contentType"] == "application/vnd.microsoft.card.hero"
|
|
assert card["content"]["title"] == "Title"
|
|
|
|
def test_wrap_as_attachment(self):
|
|
card = build_vote_card("Test", ["A"])
|
|
attachment = wrap_as_attachment(card)
|
|
assert attachment["contentType"] == "application/vnd.microsoft.card.adaptive"
|
|
assert attachment["content"] == card
|
|
|
|
|
|
class TestAdapterMethods:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return MSTeamsAdapter(config={"app_id": "test-app-id", "app_password": "test-secret"})
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_message_formats_correct_activity(self, adapter):
|
|
result = await adapter.edit_message("chat-1", "msg-1", "updated content")
|
|
assert isinstance(result, DeliveryResult)
|
|
assert result.success is False
|
|
assert result.error == "Not connected"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_message_disconnected(self, adapter):
|
|
result = await adapter.delete_message("chat-1", "msg-1")
|
|
assert isinstance(result, DeliveryResult)
|
|
assert result.success is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_media_disconnected(self, adapter):
|
|
result = await adapter.send_media("chat-1", "image", b"fake-bytes")
|
|
assert result.success is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_reaction_disconnected(self, adapter):
|
|
result = await adapter.send_reaction("chat-1", "msg-1", "👍")
|
|
assert result.success is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_stream_chunk_disconnected(self, adapter):
|
|
result = await adapter.send_stream_chunk("chat-1", "msg-1", "chunk", False)
|
|
assert result.success is False
|
|
|
|
def test_get_user_info_disconnected(self, adapter):
|
|
import asyncio
|
|
|
|
async def _run():
|
|
return await adapter.get_user_info("user-1")
|
|
|
|
result = asyncio.run(_run())
|
|
assert result == {}
|
|
|
|
def test_download_media_disconnected(self, adapter):
|
|
import asyncio
|
|
|
|
async def _run():
|
|
return await adapter.download_media("file-1")
|
|
|
|
result = asyncio.run(_run())
|
|
assert result == b""
|
|
|
|
def test_message_tracker_lru_eviction(self, adapter):
|
|
for i in range(1100):
|
|
msg = ChannelMessage(
|
|
identity=ChannelIdentity(
|
|
channel_id="msteams",
|
|
channel_type=ChannelType.MS_TEAMS,
|
|
channel_user_id="u1",
|
|
channel_chat_id=f"chat-{i}",
|
|
channel_message_id=f"msg-{i}",
|
|
),
|
|
event_type=EventType.MESSAGE_RECEIVED,
|
|
content="",
|
|
)
|
|
adapter._track_message(msg)
|
|
assert len(adapter._message_tracker) == 1000
|
|
|
|
|
|
class TestHealthCheck:
|
|
def test_health_check_disconnected(self):
|
|
import asyncio
|
|
|
|
adapter = MSTeamsAdapter(config={})
|
|
|
|
async def _check():
|
|
result = await adapter.health_check()
|
|
assert result.status == "unhealthy"
|
|
|
|
asyncio.run(_check())
|
|
|
|
|
|
class TestPreConnect:
|
|
@pytest.mark.asyncio
|
|
async def test_pre_connect_missing_config(self):
|
|
adapter = MSTeamsAdapter(config={})
|
|
result = await adapter.pre_connect()
|
|
assert result["status"] == "error"
|
|
assert "Missing" in result["message"]
|
|
|
|
|
|
class TestWebhookSignature:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return MSTeamsAdapter(config={"app_id": "test-app-id", "app_password": "test-secret"})
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_auth_header(self, adapter):
|
|
result = await adapter.verify_webhook_signature({}, b"{}")
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_bearer(self, adapter):
|
|
result = await adapter.verify_webhook_signature(
|
|
{"Authorization": "Basic xxx"},
|
|
b"{}",
|
|
)
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_valid_bearer_format(self, adapter):
|
|
from unittest.mock import MagicMock, patch
|
|
import time
|
|
|
|
expected_payload = {
|
|
"aud": "https://api.botframework.com",
|
|
"iss": "https://api.botframework.com",
|
|
"exp": int(time.time()) + 3600,
|
|
}
|
|
|
|
mock_jwks_client = MagicMock()
|
|
mock_key = MagicMock()
|
|
mock_key.key = "fake-rs256-key"
|
|
mock_jwks_client.get_signing_key_from_jwt.return_value = mock_key
|
|
adapter._jwks_client = mock_jwks_client
|
|
|
|
def mock_decode(token, key=None, algorithms=None, audience=None, issuer=None, options=None):
|
|
return expected_payload
|
|
|
|
with patch("yuxi.channels.adapters.msteams.adapter.jwt.decode", side_effect=mock_decode):
|
|
result = await adapter.verify_webhook_signature(
|
|
{"Authorization": "Bearer fake-valid-token"},
|
|
b"{}",
|
|
)
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_audience(self, adapter):
|
|
import jwt
|
|
import time
|
|
|
|
token = jwt.encode(
|
|
{
|
|
"aud": "https://evil.example.com",
|
|
"iss": "https://api.botframework.com",
|
|
"exp": int(time.time()) + 3600,
|
|
},
|
|
"secret",
|
|
algorithm="HS256",
|
|
)
|
|
result = await adapter.verify_webhook_signature(
|
|
{"Authorization": f"Bearer {token}"},
|
|
b"{}",
|
|
)
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_domain_validation(self, adapter):
|
|
import jwt
|
|
import time
|
|
|
|
token = jwt.encode(
|
|
{
|
|
"aud": "https://api.botframework.com",
|
|
"iss": "https://api.botframework.com",
|
|
"exp": int(time.time()) + 3600,
|
|
},
|
|
"secret",
|
|
algorithm="HS256",
|
|
)
|
|
result = await adapter.verify_webhook_signature(
|
|
{
|
|
"Authorization": f"Bearer {token}",
|
|
"X-Ms-Service-Url": "https://evil.example.com/v3",
|
|
},
|
|
b"{}",
|
|
)
|
|
assert result is False |