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

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