"""Tests for DingTalk channel implementation.""" import json from unittest.mock import AsyncMock, MagicMock import pytest from EvoScientist.channels.base import ChannelError, OutboundMessage from EvoScientist.channels.dingtalk.channel import DingTalkChannel, DingTalkConfig class TestDingTalkConfig: def test_default_values(self): config = DingTalkConfig() assert config.client_id == "" assert config.client_secret == "" assert config.allowed_senders is None assert config.text_chunk_limit == 4096 def test_custom_values(self): config = DingTalkConfig( client_id="test-id", client_secret="test-secret", allowed_senders={"user1"}, text_chunk_limit=2000, proxy="http://proxy:8080", ) assert config.client_id == "test-id" assert config.client_secret == "test-secret" assert config.allowed_senders == {"user1"} assert config.text_chunk_limit == 2000 assert config.proxy == "http://proxy:8080" class TestDingTalkChannel: def test_init(self): config = DingTalkConfig(client_id="test-id", client_secret="test-secret") channel = DingTalkChannel(config) assert channel.config is config assert channel._running is False assert channel.name == "dingtalk" async def test_start_raises_without_credentials(self): config = DingTalkConfig(client_id="", client_secret="") channel = DingTalkChannel(config) with pytest.raises(ChannelError, match="client_id and client_secret"): await channel.start() async def test_start_raises_without_client_id(self): config = DingTalkConfig(client_id="", client_secret="secret") channel = DingTalkChannel(config) with pytest.raises(ChannelError, match="client_id and client_secret"): await channel.start() async def test_start_raises_without_client_secret(self): config = DingTalkConfig(client_id="id", client_secret="") channel = DingTalkChannel(config) with pytest.raises(ChannelError, match="client_id and client_secret"): await channel.start() async def test_stop_when_not_running(self): config = DingTalkConfig(client_id="test-id", client_secret="test-secret") channel = DingTalkChannel(config) await channel.stop() async def test_send_returns_false_without_client(self): config = DingTalkConfig(client_id="test-id", client_secret="test-secret") channel = DingTalkChannel(config) msg = OutboundMessage( channel="dingtalk", chat_id="user123", content="hello", metadata={"chat_id": "user123"}, ) result = await channel.send(msg) assert result is False def test_capabilities(self): from EvoScientist.channels.capabilities import DINGTALK config = DingTalkConfig() channel = DingTalkChannel(config) assert channel.capabilities is DINGTALK assert channel.capabilities.format_type == "markdown" assert channel.capabilities.groups is True assert channel.capabilities.mentions is True assert channel.capabilities.media_send is True assert channel.capabilities.media_receive is True class TestDingTalkErrorPatterns: """Test non-retryable and rate-limit pattern detection.""" def test_non_retryable_patterns_defined(self): config = DingTalkConfig() channel = DingTalkChannel(config) assert "invalidauthentication" in channel._non_retryable_patterns assert "forbidden" in channel._non_retryable_patterns assert "40014" in channel._non_retryable_patterns def test_non_retryable_returns_none(self): config = DingTalkConfig() channel = DingTalkChannel(config) exc = Exception("invalidauthentication: bad credentials") result = channel._extract_retry_after(exc) assert result is None def test_rate_limit_returns_delay(self): config = DingTalkConfig() channel = DingTalkChannel(config) # Base class default includes "429" and "ratelimit" exc = Exception("HTTP 429 ratelimit exceeded") result = channel._extract_retry_after(exc) assert result is not None assert result > 0 class TestDingTalkWsMessageParsing: """Test _on_ws_message parsing logic with mocked bus.""" def _make_channel(self): config = DingTalkConfig(client_id="test-app", client_secret="test-secret") channel = DingTalkChannel(config) channel._running = True channel._ws_session = MagicMock() channel._ws_session.send_str = AsyncMock() channel._http_client = MagicMock() channel._access_token = "fake-token" channel._token_expires = 9999999999 return channel async def test_system_ping_ack(self): channel = self._make_channel() data = { "type": "SYSTEM", "headers": {"topic": "ping", "messageId": "ping-1"}, "data": "pong-data", } await channel._on_ws_message(data) channel._ws_session.send_str.assert_called_once() sent = json.loads(channel._ws_session.send_str.call_args[0][0]) assert sent["code"] == 200 assert sent["data"] == "pong-data" async def test_callback_text_message(self): channel = self._make_channel() channel._enqueue_raw = AsyncMock() payload = { "text": {"content": "hello bot"}, "senderStaffId": "staff123", "conversationType": "1", "createAt": "1700000000000", } data = { "type": "CALLBACK", "headers": {"messageId": "msg-1", "contentType": "application/json"}, "data": json.dumps(payload), } await channel._on_ws_message(data) channel._enqueue_raw.assert_called_once() raw = channel._enqueue_raw.call_args[0][0] assert raw.text == "hello bot" assert raw.sender_id == "staff123" assert raw.is_group is False async def test_callback_group_message_mention(self): channel = self._make_channel() channel._enqueue_raw = AsyncMock() payload = { "text": {"content": "@bot hello"}, "senderStaffId": "staff456", "conversationType": "2", "isInAtList": True, "createAt": "1700000000000", } data = { "type": "CALLBACK", "headers": {"messageId": "msg-2"}, "data": json.dumps(payload), } await channel._on_ws_message(data) raw = channel._enqueue_raw.call_args[0][0] assert raw.is_group is True assert raw.was_mentioned is True async def test_callback_group_no_mention(self): channel = self._make_channel() channel._enqueue_raw = AsyncMock() payload = { "text": {"content": "just chatting"}, "senderStaffId": "staff789", "conversationType": "2", "createAt": "1700000000000", } data = { "type": "CALLBACK", "headers": {"messageId": "msg-3"}, "data": json.dumps(payload), } await channel._on_ws_message(data) raw = channel._enqueue_raw.call_args[0][0] assert raw.is_group is True assert raw.was_mentioned is False async def test_ignores_non_callback(self): channel = self._make_channel() channel._enqueue_raw = AsyncMock() data = { "type": "EVENT", "headers": {"messageId": "msg-x"}, "data": "{}", } await channel._on_ws_message(data) channel._enqueue_raw.assert_not_called() async def test_ignores_empty_content(self): channel = self._make_channel() channel._enqueue_raw = AsyncMock() payload = { "text": {"content": ""}, "senderStaffId": "staff0", "conversationType": "1", } data = { "type": "CALLBACK", "headers": {"messageId": "msg-e"}, "data": json.dumps(payload), } await channel._on_ws_message(data) channel._enqueue_raw.assert_not_called() async def test_non_dict_data_ignored(self): channel = self._make_channel() channel._enqueue_raw = AsyncMock() await channel._on_ws_message("not a dict") channel._enqueue_raw.assert_not_called() class TestDingTalkSendChunk: """Test _send_chunk with mocked HTTP client.""" async def test_send_chunk_calls_api(self): config = DingTalkConfig(client_id="test-app", client_secret="test-secret") channel = DingTalkChannel(config) channel._access_token = "fake-token" channel._token_expires = 9999999999 mock_response = MagicMock() mock_response.json.return_value = {"processQueryKey": "ok"} channel._http_client = MagicMock() channel._http_client.post = AsyncMock(return_value=mock_response) await channel._send_chunk("user1", "formatted", "raw text", None, {}) channel._http_client.post.assert_called_once() call_args = channel._http_client.post.call_args body = call_args.kwargs.get("json") or call_args[1].get("json") assert body["robotCode"] == "test-app" assert body["userIds"] == ["user1"] class TestDingTalkChannelRegistration: def test_dingtalk_registered(self): from EvoScientist.channels.channel_manager import available_channels channels = available_channels() assert "dingtalk" in channels class TestDingTalkProbe: async def test_missing_credentials(self): from EvoScientist.channels.dingtalk.probe import validate_dingtalk ok, msg = await validate_dingtalk("", "") assert ok is False assert "required" in msg async def test_missing_client_id(self): from EvoScientist.channels.dingtalk.probe import validate_dingtalk ok, _msg = await validate_dingtalk("", "secret") assert ok is False async def test_missing_client_secret(self): from EvoScientist.channels.dingtalk.probe import validate_dingtalk ok, _msg = await validate_dingtalk("id", "") assert ok is False