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