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

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 == {}