新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块 同时在测试配置中添加了测试用的OpenAI API密钥环境变量
364 lines
12 KiB
Python
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" |