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="Test Bot 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