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

364 lines
12 KiB
Python

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"