from __future__ import annotations import pytest from yuxi.channels.adapters.msteams.streaming import StreamManager from yuxi.channels.adapters.msteams.adapter import MSTeamsAdapter from yuxi.channels.adapters.msteams.send import ( MessageSender, _parse_retry_after, classify_http_error, MAX_RETRIES, ) from yuxi.channels.adapters.msteams.graph import ( GraphClient, GRAPH_MAX_RETRIES, GRAPH_RETRYABLE_STATUSES, ) from yuxi.channels.models import ( ChannelIdentity, ChannelResponse, ChannelStatus, ChannelType, DeliveryResult, ) class TestStreamManagerFix: @pytest.fixture def stream_mgr(self): return StreamManager() def test_send_update_without_finish_preserves_last_update(self, stream_mgr): stream_mgr.register_message("chat-1", "msg-1", "initial text") initial_update = stream_mgr._last_update.get("chat-1") import asyncio async def _run(): from unittest.mock import AsyncMock sender = AsyncMock() sender.update_activity.return_value = DeliveryResult(success=True, message_id="msg-1") await stream_mgr.send_update(sender, "chat-1", finished=False) assert "chat-1" in stream_mgr._messages assert "chat-1" in stream_mgr._texts assert "chat-1" in stream_mgr._last_update asyncio.run(_run()) def test_send_update_finished_cleanup_complete(self, stream_mgr): stream_mgr.register_message("chat-1", "msg-1", "initial text") import asyncio async def _run(): from unittest.mock import AsyncMock sender = AsyncMock() sender.update_activity.return_value = DeliveryResult(success=True, message_id="msg-1") result = await stream_mgr.send_update(sender, "chat-1", finished=True) assert result is not None assert result.success assert "chat-1" not in stream_mgr._messages assert "chat-1" not in stream_mgr._texts assert "chat-1" not in stream_mgr._last_update asyncio.run(_run()) def test_send_update_unknown_chat_returns_none(self, stream_mgr): import asyncio async def _run(): from unittest.mock import AsyncMock sender = AsyncMock() result = await stream_mgr.send_update(sender, "nonexistent") assert result is None asyncio.run(_run()) class TestSendParseRetryAfter: def test_numeric_value(self): assert _parse_retry_after({"Retry-After": "10"}) == 10.0 def test_lowercase_header(self): assert _parse_retry_after({"retry-after": "3.5"}) == 3.5 def test_missing_header_returns_default(self): assert _parse_retry_after({}) == 5.0 def test_invalid_value_returns_default(self): assert _parse_retry_after({"Retry-After": "not-a-number"}) == 5.0 def test_empty_string_returns_default(self): assert _parse_retry_after({"Retry-After": ""}) == 5.0 class TestClassifyHttpError: def test_rate_limited(self): result = classify_http_error(429, "too many requests") assert result.startswith("rate_limited:") def test_auth_failed_401(self): result = classify_http_error(401, "unauthorized") assert "auth_failed:401" in result assert "token_expired_or_invalid" in result def test_auth_failed_403(self): result = classify_http_error(403, "forbidden") assert "auth_failed:403" in result assert "insufficient_permissions" in result def test_server_error(self): result = classify_http_error(500, "internal error") assert result.startswith("server_error:") def test_server_error_502(self): result = classify_http_error(502, "bad gateway") assert result.startswith("server_error:") def test_other_error(self): result = classify_http_error(404, "not found") assert result.startswith("http_404:") class TestMessageSenderTokenLock: def test_token_lock_exists(self): sender = MessageSender(app_id="test", app_password="secret") import asyncio assert isinstance(sender._token_lock, asyncio.Lock) def test_max_retries_constant(self): assert MAX_RETRIES == 3 class TestGraphClientRetry: def test_max_retries_constant(self): assert GRAPH_MAX_RETRIES == 3 def test_retryable_statuses(self): assert 429 in GRAPH_RETRYABLE_STATUSES assert 500 in GRAPH_RETRYABLE_STATUSES assert 502 in GRAPH_RETRYABLE_STATUSES assert 503 in GRAPH_RETRYABLE_STATUSES assert 504 in GRAPH_RETRYABLE_STATUSES assert 404 not in GRAPH_RETRYABLE_STATUSES assert 401 not in GRAPH_RETRYABLE_STATUSES def test_client_creation(self): client = GraphClient(token="fake-token") assert client._token == "fake-token" class TestAdapterSendChunking: @pytest.fixture def adapter(self): return MSTeamsAdapter(config={"app_id": "test-app-id", "app_password": "test-secret"}) def test_send_with_long_content_chunks_properly(self, adapter): identity = ChannelIdentity( channel_id="msteams", channel_type=ChannelType.MS_TEAMS, channel_user_id="user-001", channel_chat_id="conv-001", ) long_content = "A" * 10000 response = ChannelResponse(identity=identity, content=long_content) import asyncio async def _run(): result = await adapter.send(response) assert isinstance(result, DeliveryResult) assert result.success is False assert result.error == "Not connected" asyncio.run(_run()) class TestAdapterDisconnectCleanup: @pytest.fixture def adapter(self): return MSTeamsAdapter(config={"app_id": "test-app-id", "app_password": "test-secret"}) def test_disconnect_cleans_up_session(self, adapter): import asyncio async def _run(): await adapter.disconnect() assert adapter._http_session is None assert adapter._jwks_client is None asyncio.run(_run()) def test_disconnect_when_already_disconnected(self, adapter): adapter._status = ChannelStatus.DISCONNECTED import asyncio async def _run(): await adapter.disconnect() asyncio.run(_run()) class TestAdapterSendMediaErrorHandling: @pytest.fixture def adapter(self): return MSTeamsAdapter(config={"app_id": "test-app-id", "app_password": "test-secret"}) def test_send_media_disconnected_returns_error(self, adapter): import asyncio async def _run(): result = await adapter.send_media("chat-1", "image", b"fake-bytes") assert isinstance(result, DeliveryResult) assert result.success is False assert result.error == "Not connected" asyncio.run(_run()) class TestSendTyping: @pytest.fixture def adapter(self): return MSTeamsAdapter(config={"app_id": "test-app-id", "app_password": "test-secret"}) def test_send_typing_disconnected_returns_error(self, adapter): import asyncio async def _run(): result = await adapter.send_typing("chat-1") assert isinstance(result, DeliveryResult) assert result.success is False assert result.error == "Not connected" asyncio.run(_run()) def test_send_typing_has_method(self, adapter): assert hasattr(adapter, "send_typing") assert callable(adapter.send_typing) class TestSendReactionV2: @pytest.fixture def adapter(self): return MSTeamsAdapter(config={"app_id": "test-app-id", "app_password": "test-secret"}) def test_send_reaction_disconnected_returns_error(self, adapter): import asyncio async def _run(): result = await adapter.send_reaction("chat-1", "msg-1", "👍") assert isinstance(result, DeliveryResult) assert result.success is False assert result.error == "Not connected" asyncio.run(_run()) class TestEmojiToReactionMapping: def test_thumbs_up_maps_to_like(self): from yuxi.channels.adapters.msteams.adapter import _EMOJI_TO_REACTION assert _EMOJI_TO_REACTION["👍"] == "like" def test_heart_maps_to_heart(self): from yuxi.channels.adapters.msteams.adapter import _EMOJI_TO_REACTION assert _EMOJI_TO_REACTION["❤️"] == "heart" def test_laugh_maps_to_laugh(self): from yuxi.channels.adapters.msteams.adapter import _EMOJI_TO_REACTION assert _EMOJI_TO_REACTION["😂"] == "laugh" def test_surprised_maps_to_surprised(self): from yuxi.channels.adapters.msteams.adapter import _EMOJI_TO_REACTION assert _EMOJI_TO_REACTION["😲"] == "surprised" def test_sad_maps_to_sad(self): from yuxi.channels.adapters.msteams.adapter import _EMOJI_TO_REACTION assert _EMOJI_TO_REACTION["😢"] == "sad" def test_angry_maps_to_angry(self): from yuxi.channels.adapters.msteams.adapter import _EMOJI_TO_REACTION assert _EMOJI_TO_REACTION["😡"] == "angry" def test_unknown_emoji_defaults_to_like(self): from yuxi.channels.adapters.msteams.adapter import _EMOJI_TO_REACTION assert _EMOJI_TO_REACTION.get("🎉", "like") == "like" class TestGraphClientPagination: @pytest.fixture def client(self): return GraphClient(token="fake-token") def test_list_all_exists(self, client): assert hasattr(client, "_list_all") assert callable(client._list_all) def test_list_channels_uses_list_all(self, client): import inspect source = inspect.getsource(client.list_channels) assert "_list_all" in source def test_list_channel_messages_uses_list_all(self, client): import inspect source = inspect.getsource(client.list_channel_messages) assert "_list_all" in source def test_list_message_replies_uses_list_all(self, client): import inspect source = inspect.getsource(client.list_message_replies) assert "_list_all" in source @pytest.mark.asyncio async def test_list_all_with_error_page(self, client): from unittest.mock import AsyncMock, MagicMock client._get = AsyncMock(return_value={"error": "Not found"}) result = await client._list_all("/test/path") assert result == [] @pytest.mark.asyncio async def test_list_all_single_page(self, client): from unittest.mock import AsyncMock client._get = AsyncMock(return_value={ "value": [{"id": "1"}, {"id": "2"}], }) result = await client._list_all("/test/path") assert len(result) == 2 assert result[0]["id"] == "1" @pytest.mark.asyncio async def test_list_all_multi_page(self, client): from unittest.mock import AsyncMock, MagicMock call_count = [0] async def mock_get(*args, **kwargs): call_count[0] += 1 if call_count[0] == 1: return {"value": [{"id": "1"}], "@odata.nextLink": "https://graph.microsoft.com/v1.0/next"} return {"value": [{"id": "2"}]} client._get = mock_get mock_session = MagicMock() mock_ensure = AsyncMock(return_value=mock_session) mock_resp = MagicMock() mock_resp.status = 200 mock_resp.json = AsyncMock(return_value={"value": [{"id": "2"}]}) mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) mock_resp.__aexit__ = AsyncMock(return_value=None) mock_session.get = MagicMock(return_value=mock_resp) client._ensure_session = mock_ensure result = await client._list_all("/test/path") assert len(result) == 2 assert result[0]["id"] == "1" assert result[1]["id"] == "2"