新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块 同时在测试配置中添加了测试用的OpenAI API密钥环境变量
492 lines
17 KiB
Python
492 lines
17 KiB
Python
"""Unit tests for DingDing adapter fixes.
|
|
|
|
Tests cover:
|
|
- send_reaction: emoji mapping and API call construction
|
|
- delete_message: correct API URLs and payload format
|
|
- Rate limit semaphore: safe reset without overflow
|
|
- Stream reconnection: reconnect delay and cancellation
|
|
- Token manager: close() cleanup
|
|
- Stream handler: sessionWebhook extraction
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
from yuxi.channels.adapters.dingding.token import DingDingTokenManager
|
|
from yuxi.channels.adapters.dingding.sign import compute_dingtalk_sign, verify_webhook_signature
|
|
from yuxi.channels.models import (
|
|
ChannelType,
|
|
DeliveryResult,
|
|
EventType,
|
|
MessageType,
|
|
)
|
|
|
|
|
|
def _make_adapter(config=None):
|
|
from yuxi.channels.adapters.dingding.adapter import DingDingChannelAdapter
|
|
|
|
return DingDingChannelAdapter(config=config or {"name": "test", "accounts": {"default": {}}})
|
|
|
|
|
|
def _make_connected_adapter():
|
|
from yuxi.channels.adapters.dingding.adapter import DingDingChannelAdapter
|
|
|
|
adapter = DingDingChannelAdapter(config={
|
|
"name": "test",
|
|
"accounts": {"default": {"app_key": "fake_key", "app_secret": "fake_secret", "robot_code": "fake_robot"}},
|
|
"mode": "webhook",
|
|
})
|
|
tm = DingDingTokenManager("fake_key", "fake_secret")
|
|
tm._access_token = "fake_token"
|
|
tm._token_expires_at = 9999999999
|
|
adapter._token_manager = tm
|
|
mock_http = MagicMock()
|
|
adapter._get_http_client = AsyncMock(return_value=mock_http)
|
|
adapter._connected_at = 1234567890
|
|
return adapter
|
|
|
|
|
|
# ============================================================
|
|
# send_reaction tests
|
|
# ============================================================
|
|
|
|
class TestSendReaction:
|
|
def test_reaction_not_connected(self):
|
|
adapter = _make_adapter()
|
|
result = asyncio.run(adapter.send_reaction("dm_user1", "msg_001", "\U0001f44d"))
|
|
assert result.success is False
|
|
assert result.error == "Not connected"
|
|
|
|
def test_reaction_unsupported_emoji(self):
|
|
adapter = _make_connected_adapter()
|
|
result = asyncio.run(adapter.send_reaction("dm_user1", "msg_001", "\U0001f984"))
|
|
assert result.success is False
|
|
assert "Unsupported emoji" in result.error
|
|
|
|
@pytest.mark.parametrize("emoji,expected_type,expected_name", [
|
|
("\U0001f44d", 101, "like"),
|
|
("\U0001f44e", 102, "dislike"),
|
|
("\u2764\ufe0f", 103, "heart"),
|
|
("\U0001f602", 104, "laugh"),
|
|
("\U0001f440", 107, "looking"),
|
|
("\u2705", 108, "done"),
|
|
("\u274c", 109, "error"),
|
|
("\U0001f914", 110, "thinking"),
|
|
("\U0001f389", 111, "celebrate"),
|
|
])
|
|
def test_reaction_emoji_mapping(self, emoji, expected_type, expected_name):
|
|
adapter = _make_connected_adapter()
|
|
mock_http = MagicMock()
|
|
mock_http.post = AsyncMock(return_value=MagicMock(status_code=200))
|
|
adapter._get_http_client.return_value = mock_http
|
|
|
|
result = asyncio.run(adapter.send_reaction("dm_user1", "msg_001", emoji))
|
|
assert result.success is True
|
|
|
|
call_args = mock_http.post.call_args
|
|
payload = call_args.kwargs["json"]
|
|
assert payload["emotionType"] == expected_type
|
|
assert payload["emotionName"] == expected_name
|
|
assert payload["openMsgId"] == "msg_001"
|
|
assert payload["robotCode"] == "fake_robot"
|
|
|
|
def test_reaction_api_error(self):
|
|
adapter = _make_connected_adapter()
|
|
mock_http = MagicMock()
|
|
mock_http.post = AsyncMock(return_value=MagicMock(status_code=400, text="invalid params"))
|
|
adapter._get_http_client.return_value = mock_http
|
|
|
|
result = asyncio.run(adapter.send_reaction("dm_user1", "msg_001", "\U0001f44d"))
|
|
assert result.success is False
|
|
assert "HTTP 400" in result.error
|
|
|
|
|
|
# ============================================================
|
|
# delete_message tests
|
|
# ============================================================
|
|
|
|
class TestDeleteMessage:
|
|
def _mock_http(self, adapter, status_code=200):
|
|
mock_http = MagicMock()
|
|
mock_http.post = AsyncMock(return_value=MagicMock(status_code=status_code))
|
|
adapter._get_http_client.return_value = mock_http
|
|
return mock_http
|
|
|
|
def test_delete_not_connected(self):
|
|
adapter = _make_adapter()
|
|
result = asyncio.run(adapter.delete_message("dm_user1", "msg_001"))
|
|
assert result.success is False
|
|
|
|
def test_delete_group_uses_correct_url(self):
|
|
adapter = _make_connected_adapter()
|
|
mock_http = self._mock_http(adapter)
|
|
|
|
result = asyncio.run(adapter.delete_message("group_cid123", "msg_grp"))
|
|
assert result.success is True
|
|
|
|
call_args = mock_http.post.call_args
|
|
url = str(call_args.args[0]) if call_args.args else call_args.kwargs.get("url", "")
|
|
assert "groupMessages/recall" in url
|
|
|
|
def test_delete_dm_uses_correct_url(self):
|
|
adapter = _make_connected_adapter()
|
|
mock_http = self._mock_http(adapter)
|
|
|
|
result = asyncio.run(adapter.delete_message("dm_user123", "msg_dm"))
|
|
assert result.success is True
|
|
|
|
call_args = mock_http.post.call_args
|
|
url = str(call_args.args[0]) if call_args.args else call_args.kwargs.get("url", "")
|
|
assert "groupMessages/recall" in url
|
|
|
|
def test_delete_uses_process_query_keys_array(self):
|
|
adapter = _make_connected_adapter()
|
|
mock_http = self._mock_http(adapter)
|
|
|
|
result = asyncio.run(adapter.delete_message("group_cid123", "msg_grp"))
|
|
assert result.success is True
|
|
|
|
payload = mock_http.post.call_args.kwargs["json"]
|
|
assert "processQueryKeys" in payload
|
|
assert isinstance(payload["processQueryKeys"], list)
|
|
assert payload["processQueryKeys"] == ["msg_grp"]
|
|
assert "processQueryKey" not in payload
|
|
|
|
|
|
# ============================================================
|
|
# Rate limit semaphore tests
|
|
# ============================================================
|
|
|
|
class TestRateLimitSemaphore:
|
|
def test_semaphore_reset_creates_new_semaphore(self):
|
|
adapter = _make_adapter()
|
|
old_sem_id = id(adapter._send_semaphore)
|
|
|
|
async def run_one_cycle():
|
|
await asyncio.sleep(0.01)
|
|
old = adapter._send_semaphore
|
|
adapter._send_semaphore = asyncio.Semaphore(20)
|
|
for _ in range(20):
|
|
try:
|
|
old.release()
|
|
except ValueError:
|
|
break
|
|
|
|
asyncio.run(run_one_cycle())
|
|
assert id(adapter._send_semaphore) != old_sem_id
|
|
|
|
def test_semaphore_reset_after_acquire(self):
|
|
adapter = _make_adapter()
|
|
|
|
async def _test():
|
|
await adapter._send_semaphore.acquire()
|
|
await adapter._send_semaphore.acquire()
|
|
|
|
old_sem = adapter._send_semaphore
|
|
adapter._send_semaphore = asyncio.Semaphore(20)
|
|
for _ in range(20):
|
|
try:
|
|
old_sem.release()
|
|
except ValueError:
|
|
break
|
|
|
|
assert adapter._send_semaphore._value == 20
|
|
|
|
asyncio.run(_test())
|
|
|
|
|
|
# ============================================================
|
|
# Token manager tests
|
|
# ============================================================
|
|
|
|
class TestTokenManager:
|
|
def test_close_invalidates_token(self):
|
|
tm = DingDingTokenManager("fake_key", "fake_secret")
|
|
tm._access_token = "active_token"
|
|
tm._token_expires_at = 9999999999
|
|
|
|
asyncio.run(tm.close())
|
|
assert tm._access_token is None
|
|
assert tm._token_expires_at == 0
|
|
assert tm._http_client is None
|
|
|
|
def test_token_not_valid_after_close(self):
|
|
tm = DingDingTokenManager("fake_key", "fake_secret")
|
|
tm._access_token = "active_token"
|
|
tm._token_expires_at = 9999999999
|
|
|
|
assert tm._is_valid() is True
|
|
asyncio.run(tm.close())
|
|
assert tm._is_valid() is False
|
|
|
|
def test_get_token_after_invalidate_raises(self):
|
|
tm = DingDingTokenManager("fake_key", "fake_secret")
|
|
tm.invalidate()
|
|
|
|
with pytest.raises(RuntimeError, match="Token refresh failed"):
|
|
asyncio.run(tm.get_token())
|
|
|
|
|
|
# ============================================================
|
|
# Sign tests
|
|
# ============================================================
|
|
|
|
class TestSign:
|
|
def test_compute_sign_consistency(self):
|
|
sign1 = compute_dingtalk_sign("1700000000000", "test_secret")
|
|
sign2 = compute_dingtalk_sign("1700000000000", "test_secret")
|
|
assert sign1 == sign2
|
|
|
|
def test_timestamp_expiry(self):
|
|
import time
|
|
expired_ts = str(int((time.time() - 7200) * 1000))
|
|
expired_headers = {
|
|
"timestamp": expired_ts,
|
|
"sign": compute_dingtalk_sign(expired_ts, "test_secret"),
|
|
}
|
|
assert verify_webhook_signature(expired_headers, "test_secret") is False
|
|
|
|
def test_empty_headers_passes(self):
|
|
assert verify_webhook_signature({}, "test_secret") is True
|
|
|
|
|
|
# ============================================================
|
|
# Adapter attributes tests
|
|
# ============================================================
|
|
|
|
class TestAdapterAttributes:
|
|
def test_pending_streams_dict(self):
|
|
adapter = _make_adapter()
|
|
assert isinstance(adapter._pending_streams, dict)
|
|
assert adapter._pending_streams == {}
|
|
|
|
def test_stream_lock_exists(self):
|
|
adapter = _make_adapter()
|
|
assert adapter._stream_lock is not None
|
|
|
|
def test_has_run_stream_with_reconnect(self):
|
|
adapter = _make_adapter()
|
|
assert hasattr(adapter, "_run_stream_with_reconnect")
|
|
assert callable(adapter._run_stream_with_reconnect)
|
|
|
|
|
|
# ============================================================
|
|
# Stream reconnect constants
|
|
# ============================================================
|
|
|
|
class TestStreamReconnectConstants:
|
|
def test_reconnect_constants_defined(self):
|
|
from yuxi.channels.adapters.dingding.adapter import (
|
|
STREAM_MAX_RECONNECT_DELAY_S,
|
|
STREAM_RECONNECT_DELAY_S,
|
|
)
|
|
assert STREAM_RECONNECT_DELAY_S == 5
|
|
assert STREAM_MAX_RECONNECT_DELAY_S == 300
|
|
|
|
|
|
# ============================================================
|
|
# Normalizer extended tests
|
|
# ============================================================
|
|
|
|
class TestNormalizerExtended:
|
|
def test_sticker_message_type(self):
|
|
from yuxi.channels.adapters.dingding.normalizer import normalize_inbound
|
|
|
|
raw = {
|
|
"senderId": "user_sticker",
|
|
"isGroupChat": False,
|
|
"conversationId": "cid_sticker",
|
|
"msgId": "msg_sticker",
|
|
"msgtype": "sticker",
|
|
}
|
|
msg = normalize_inbound("dingding", ChannelType.DINGDING, raw)
|
|
assert msg.message_type == MessageType.STICKER
|
|
assert msg.content == "[贴纸]"
|
|
|
|
def test_bot_added_event(self):
|
|
from yuxi.channels.adapters.dingding.normalizer import normalize_inbound
|
|
|
|
raw = {
|
|
"robotCode": "dingbot_001",
|
|
"conversationId": "cid_bot",
|
|
"msgtype": "text",
|
|
"text": {"content": "bot added"},
|
|
}
|
|
msg = normalize_inbound("dingding", ChannelType.DINGDING, raw)
|
|
assert msg.event_type == EventType.BOT_ADDED
|
|
|
|
def test_root_id_metadata(self):
|
|
from yuxi.channels.adapters.dingding.normalizer import normalize_inbound
|
|
|
|
raw = {
|
|
"senderId": "user_thread",
|
|
"isGroupChat": True,
|
|
"conversationId": "cid_thread",
|
|
"msgId": "msg_thread",
|
|
"msgtype": "text",
|
|
"text": {"content": "reply"},
|
|
"rootId": "root_123",
|
|
}
|
|
msg = normalize_inbound("dingding", ChannelType.DINGDING, raw)
|
|
assert msg.metadata["root_id"] == "root_123"
|
|
|
|
|
|
# ============================================================
|
|
# Formatter tests
|
|
# ============================================================
|
|
|
|
class TestFormatOutbound:
|
|
def test_markdown_format_title(self):
|
|
from yuxi.channels.adapters.dingding.formatter import format_outbound
|
|
|
|
result = format_outbound("**bold**", metadata={"use_markdown": True, "title": "Test"})
|
|
assert result["msgKey"] == "sampleMarkdown"
|
|
|
|
def test_custom_msg_key_override(self):
|
|
from yuxi.channels.adapters.dingding.formatter import format_outbound
|
|
|
|
result = format_outbound(
|
|
"content",
|
|
metadata={"msg_key": "sampleActionCard1", "msg_param": {"title": "T", "text": "X"}},
|
|
)
|
|
assert result["msgKey"] == "sampleActionCard1"
|
|
|
|
|
|
# ============================================================
|
|
# DeliveryResult tests
|
|
# ============================================================
|
|
|
|
class TestDeliveryResult:
|
|
def test_success_result(self):
|
|
result = DeliveryResult(success=True, message_id="msg_001")
|
|
assert result.success is True
|
|
assert result.message_id == "msg_001"
|
|
assert result.error is None
|
|
|
|
def test_failure_result(self):
|
|
result = DeliveryResult(success=False, error="Not connected")
|
|
assert result.success is False
|
|
assert result.error == "Not connected"
|
|
|
|
|
|
# ============================================================
|
|
# Stream chunk accumulation tests
|
|
# ============================================================
|
|
|
|
class TestStreamChunkAccumulation:
|
|
def test_chunks_accumulate_not_overwrite(self):
|
|
adapter = _make_adapter()
|
|
|
|
async def _test():
|
|
async with adapter._stream_lock:
|
|
adapter._pending_streams["chat_1"] = "Hello "
|
|
async with adapter._stream_lock:
|
|
existing = adapter._pending_streams.get("chat_1", "")
|
|
adapter._pending_streams["chat_1"] = existing + "World"
|
|
assert adapter._pending_streams["chat_1"] == "Hello World"
|
|
|
|
asyncio.run(_test())
|
|
|
|
def test_finished_pops_and_returns_full_text(self):
|
|
adapter = _make_adapter()
|
|
|
|
async def _test():
|
|
async with adapter._stream_lock:
|
|
adapter._pending_streams["chat_1"] = "Hello World"
|
|
async with adapter._stream_lock:
|
|
final = adapter._pending_streams.pop("chat_1", "")
|
|
assert final == "Hello World"
|
|
assert "chat_1" not in adapter._pending_streams
|
|
|
|
asyncio.run(_test())
|
|
|
|
def test_empty_chunks_do_not_crash(self):
|
|
adapter = _make_adapter()
|
|
|
|
async def _test():
|
|
async with adapter._stream_lock:
|
|
existing = adapter._pending_streams.get("chat_new", "")
|
|
adapter._pending_streams["chat_new"] = existing + ""
|
|
assert adapter._pending_streams["chat_new"] == ""
|
|
|
|
asyncio.run(_test())
|
|
|
|
|
|
# ============================================================
|
|
# Session webhook separation tests
|
|
# ============================================================
|
|
|
|
class TestSessionWebhookSeparation:
|
|
def test_session_webhooks_independent(self):
|
|
adapter = _make_adapter()
|
|
assert isinstance(adapter._session_webhooks, dict)
|
|
assert adapter._session_webhooks == {}
|
|
|
|
def test_session_webhook_does_not_leak_into_streams(self):
|
|
adapter = _make_adapter()
|
|
adapter._session_webhooks["cid_abc"] = "https://webhook.example.com"
|
|
adapter._pending_streams["group_cid_abc"] = "stream text"
|
|
|
|
assert adapter._session_webhooks["cid_abc"] == "https://webhook.example.com"
|
|
assert adapter._pending_streams["group_cid_abc"] == "stream text"
|
|
assert "cid_abc" not in adapter._pending_streams
|
|
|
|
def test_disconnect_clears_both(self):
|
|
async def _test():
|
|
adapter = _make_adapter()
|
|
adapter._session_webhooks["cid_1"] = "webhook_url"
|
|
adapter._pending_streams["chat_1"] = "stream_text"
|
|
|
|
adapter._session_webhooks.clear()
|
|
adapter._pending_streams.clear()
|
|
|
|
assert adapter._session_webhooks == {}
|
|
assert adapter._pending_streams == {}
|
|
|
|
asyncio.run(_test())
|
|
|
|
|
|
# ============================================================
|
|
# send_stream_chunk mode routing tests
|
|
# ============================================================
|
|
|
|
class TestSendStreamChunkRouting:
|
|
def test_stream_mode_with_webhook_uses_webhook_first(self):
|
|
adapter = _make_connected_adapter()
|
|
adapter.config["mode"] = "stream"
|
|
adapter._session_webhooks["cid_test"] = "https://webhook.example.com/reply"
|
|
|
|
mock_http = MagicMock()
|
|
mock_http.post = AsyncMock(return_value=MagicMock(status_code=200, json=lambda: {"messageId": "webhook_msg"}))
|
|
adapter._get_http_client.return_value = mock_http
|
|
|
|
async def _test():
|
|
async with adapter._stream_lock:
|
|
adapter._pending_streams["group_cid_test"] = "Final reply"
|
|
async with adapter._stream_lock:
|
|
adapter._pending_streams["dm_cid_test2"] = "DM Final"
|
|
|
|
asyncio.run(_test())
|
|
|
|
assert "group_cid_test" in adapter._pending_streams
|
|
|
|
def test_mode_default_and_override(self):
|
|
adapter = _make_adapter()
|
|
assert adapter.config.get("mode", "stream") == "stream"
|
|
adapter.config["mode"] = "webhook"
|
|
assert adapter.config["mode"] == "webhook"
|
|
|
|
|
|
# ============================================================
|
|
# get_user_info error logging test
|
|
# ============================================================
|
|
|
|
class TestGetUserInfoLogging:
|
|
def test_returns_empty_dict_on_error(self):
|
|
adapter = _make_adapter()
|
|
result = asyncio.run(adapter.get_user_info("user_123"))
|
|
assert result == {} |