Files
EvoScientist/tests/test_dingtalk_channel.py
2026-03-19 17:04:05 +01:00

294 lines
10 KiB
Python

"""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
from tests.conftest import run_async as _run
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"
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"):
_run(channel.start())
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"):
_run(channel.start())
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"):
_run(channel.start())
def test_stop_when_not_running(self):
config = DingTalkConfig(client_id="test-id", client_secret="test-secret")
channel = DingTalkChannel(config)
_run(channel.stop())
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 = _run(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
def test_system_ping_ack(self):
channel = self._make_channel()
data = {
"type": "SYSTEM",
"headers": {"topic": "ping", "messageId": "ping-1"},
"data": "pong-data",
}
_run(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"
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),
}
_run(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
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),
}
_run(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
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),
}
_run(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
def test_ignores_non_callback(self):
channel = self._make_channel()
channel._enqueue_raw = AsyncMock()
data = {
"type": "EVENT",
"headers": {"messageId": "msg-x"},
"data": "{}",
}
_run(channel._on_ws_message(data))
channel._enqueue_raw.assert_not_called()
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),
}
_run(channel._on_ws_message(data))
channel._enqueue_raw.assert_not_called()
def test_non_dict_data_ignored(self):
channel = self._make_channel()
channel._enqueue_raw = AsyncMock()
_run(channel._on_ws_message("not a dict"))
channel._enqueue_raw.assert_not_called()
class TestDingTalkSendChunk:
"""Test _send_chunk with mocked HTTP client."""
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)
_run(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:
def test_missing_credentials(self):
from EvoScientist.channels.dingtalk.probe import validate_dingtalk
ok, msg = _run(validate_dingtalk("", ""))
assert ok is False
assert "required" in msg
def test_missing_client_id(self):
from EvoScientist.channels.dingtalk.probe import validate_dingtalk
ok, _msg = _run(validate_dingtalk("", "secret"))
assert ok is False
def test_missing_client_secret(self):
from EvoScientist.channels.dingtalk.probe import validate_dingtalk
ok, _msg = _run(validate_dingtalk("id", ""))
assert ok is False