874 lines
30 KiB
Python
874 lines
30 KiB
Python
|
|
"""Comprehensive unit tests for DingDing channel adapter.
|
|||
|
|
|
|||
|
|
Covers: dedup, policy, config_schema, directory, reactions, stream,
|
|||
|
|
normalizer (extended events), session, cards, media, adapter snapshot.
|
|||
|
|
|
|||
|
|
Target: ~35+ test scenarios.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import asyncio
|
|||
|
|
import time
|
|||
|
|
from unittest.mock import AsyncMock, MagicMock
|
|||
|
|
|
|||
|
|
import pytest
|
|||
|
|
from yuxi.channels.adapters.dingding.cards import (
|
|||
|
|
TEXT_CHUNK_LIMIT,
|
|||
|
|
build_card_template,
|
|||
|
|
build_standard_card,
|
|||
|
|
generate_card_biz_id,
|
|||
|
|
parse_card_callback_params,
|
|||
|
|
parse_card_private_data,
|
|||
|
|
)
|
|||
|
|
from yuxi.channels.adapters.dingding.config_schema import refine_dingding_config, validate_dingding_config
|
|||
|
|
from yuxi.channels.adapters.dingding.config_ui_hints import get_dingding_ui_hints, resolve_field_order
|
|||
|
|
from yuxi.channels.adapters.dingding.dedup import DingDingDedupGuard
|
|||
|
|
from yuxi.channels.adapters.dingding.formatter import format_outbound
|
|||
|
|
from yuxi.channels.adapters.dingding.normalizer import (
|
|||
|
|
extract_mentions,
|
|||
|
|
map_event_type,
|
|||
|
|
normalize_inbound,
|
|||
|
|
)
|
|||
|
|
from yuxi.channels.adapters.dingding.policy import DingDingPolicyMatcher, PolicyAccessTracker
|
|||
|
|
from yuxi.channels.adapters.dingding.reactions import (
|
|||
|
|
DINGDING_EMOJI_MAP,
|
|||
|
|
EMOTION_TYPE_TO_EMOJI,
|
|||
|
|
clear_all_bot_reactions,
|
|||
|
|
list_reactions,
|
|||
|
|
resolve_emoji,
|
|||
|
|
)
|
|||
|
|
from yuxi.channels.adapters.dingding.session import (
|
|||
|
|
SessionMode,
|
|||
|
|
SessionScope,
|
|||
|
|
generate_dingding_chat_id,
|
|||
|
|
generate_thread_key,
|
|||
|
|
normalize_chat_id,
|
|||
|
|
parse_chat_id,
|
|||
|
|
resolve_session_scope,
|
|||
|
|
)
|
|||
|
|
from yuxi.channels.adapters.dingding.sign import compute_dingtalk_sign, verify_webhook_signature
|
|||
|
|
from yuxi.channels.adapters.dingding.stream import (
|
|||
|
|
MAX_CONSECUTIVE_FAILURES,
|
|||
|
|
STREAM_NATURAL_BOUNDARIES,
|
|||
|
|
DingDingStreamingSession,
|
|||
|
|
)
|
|||
|
|
from yuxi.channels.adapters.dingding.token import DingDingTokenManager
|
|||
|
|
from yuxi.channels.models import (
|
|||
|
|
ChannelIdentity,
|
|||
|
|
ChannelMessage,
|
|||
|
|
ChannelStatus,
|
|||
|
|
ChannelType,
|
|||
|
|
ChatType,
|
|||
|
|
DeliveryResult,
|
|||
|
|
EventType,
|
|||
|
|
MessageType,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
CHANNEL_ID = "dingding"
|
|||
|
|
CHANNEL_TYPE = ChannelType.DINGDING
|
|||
|
|
|
|||
|
|
|
|||
|
|
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 = time.time()
|
|||
|
|
return adapter
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============================================================
|
|||
|
|
# DedupGuard tests
|
|||
|
|
# ============================================================
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestDedupGuard:
|
|||
|
|
def test_has_processed_returns_false_initially(self):
|
|||
|
|
guard = DingDingDedupGuard()
|
|||
|
|
assert guard.has_processed("msg_001") is False
|
|||
|
|
|
|||
|
|
def test_record_and_check(self):
|
|||
|
|
guard = DingDingDedupGuard()
|
|||
|
|
guard.record_as_processed("msg_001")
|
|||
|
|
assert guard.has_processed("msg_001") is True
|
|||
|
|
|
|||
|
|
def test_different_ids_are_independent(self):
|
|||
|
|
guard = DingDingDedupGuard()
|
|||
|
|
guard.record_as_processed("msg_001")
|
|||
|
|
assert guard.has_processed("msg_002") is False
|
|||
|
|
|
|||
|
|
def test_empty_message_id_safe(self):
|
|||
|
|
guard = DingDingDedupGuard()
|
|||
|
|
assert guard.has_processed("") is False
|
|||
|
|
guard.record_as_processed("")
|
|||
|
|
|
|||
|
|
def test_claim_first_time_returns_true(self):
|
|||
|
|
guard = DingDingDedupGuard()
|
|||
|
|
assert guard.claim("msg_new") is True
|
|||
|
|
|
|||
|
|
def test_claim_second_time_returns_false(self):
|
|||
|
|
guard = DingDingDedupGuard()
|
|||
|
|
guard.claim("msg_new")
|
|||
|
|
guard.commit("msg_new")
|
|||
|
|
assert guard.claim("msg_new") is False
|
|||
|
|
|
|||
|
|
def test_claim_pending_returns_false(self):
|
|||
|
|
guard = DingDingDedupGuard()
|
|||
|
|
guard.claim("msg_pending")
|
|||
|
|
assert guard.claim("msg_pending") is False
|
|||
|
|
|
|||
|
|
def test_release_allows_reclaim(self):
|
|||
|
|
guard = DingDingDedupGuard()
|
|||
|
|
guard.claim("msg_rel")
|
|||
|
|
guard.release("msg_rel")
|
|||
|
|
assert guard.claim("msg_rel") is True
|
|||
|
|
|
|||
|
|
def test_finalize_processing(self):
|
|||
|
|
guard = DingDingDedupGuard()
|
|||
|
|
guard.claim("msg_fin")
|
|||
|
|
guard.finalize_processing("msg_fin")
|
|||
|
|
assert guard.has_processed("msg_fin") is True
|
|||
|
|
|
|||
|
|
def test_check_and_mark(self):
|
|||
|
|
guard = DingDingDedupGuard()
|
|||
|
|
assert guard.check_and_mark("msg_cm") is False
|
|||
|
|
assert guard.check_and_mark("msg_cm") is True
|
|||
|
|
|
|||
|
|
def test_stats(self):
|
|||
|
|
guard = DingDingDedupGuard()
|
|||
|
|
guard.record_as_processed("msg_1")
|
|||
|
|
guard.record_as_processed("msg_2")
|
|||
|
|
guard.claim("msg_3")
|
|||
|
|
stats = guard.stats()
|
|||
|
|
assert stats["committed"] == 2
|
|||
|
|
assert stats["pending"] == 1
|
|||
|
|
|
|||
|
|
def test_clear(self):
|
|||
|
|
guard = DingDingDedupGuard()
|
|||
|
|
guard.record_as_processed("msg_1")
|
|||
|
|
guard.clear()
|
|||
|
|
assert guard.has_processed("msg_1") is False
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============================================================
|
|||
|
|
# Policy tests
|
|||
|
|
# ============================================================
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestPolicyAccessTracker:
|
|||
|
|
def test_record_and_get_history(self):
|
|||
|
|
tracker = PolicyAccessTracker()
|
|||
|
|
tracker.record_access("allowlist", "chat_1", True)
|
|||
|
|
tracker.record_access("allowlist", "chat_2", False)
|
|||
|
|
history = tracker.get_history("allowlist")
|
|||
|
|
assert len(history) == 2
|
|||
|
|
assert history[0]["allowed"] is True
|
|||
|
|
assert history[1]["allowed"] is False
|
|||
|
|
|
|||
|
|
def test_count_recent(self):
|
|||
|
|
tracker = PolicyAccessTracker()
|
|||
|
|
tracker.record_access("open", "chat_1", True)
|
|||
|
|
tracker.record_access("open", "chat_2", False)
|
|||
|
|
allowed, denied = tracker.count_recent("open", window_s=3600)
|
|||
|
|
assert allowed == 1
|
|||
|
|
assert denied == 1
|
|||
|
|
|
|||
|
|
def test_clear_key(self):
|
|||
|
|
tracker = PolicyAccessTracker()
|
|||
|
|
tracker.record_access("open", "chat_1", True)
|
|||
|
|
tracker.clear("open")
|
|||
|
|
assert tracker.get_history("open") == []
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestDingDingPolicyMatcher:
|
|||
|
|
def _make_config(self, **overrides):
|
|||
|
|
config = {
|
|||
|
|
"groupPolicy": "allowlist",
|
|||
|
|
"dmPolicy": "pairing",
|
|||
|
|
"allowFrom": ["group_abc", "user_*"],
|
|||
|
|
}
|
|||
|
|
config.update(overrides)
|
|||
|
|
return config
|
|||
|
|
|
|||
|
|
def test_dm_pairing_allows_any(self):
|
|||
|
|
matcher = DingDingPolicyMatcher(self._make_config())
|
|||
|
|
allowed, msg = matcher.check_chat_access("dm_user123", "direct")
|
|||
|
|
assert allowed is True
|
|||
|
|
|
|||
|
|
def test_group_open_allows_any(self):
|
|||
|
|
matcher = DingDingPolicyMatcher(self._make_config(groupPolicy="open"))
|
|||
|
|
allowed, msg = matcher.check_chat_access("group_xyz", "group")
|
|||
|
|
assert allowed is True
|
|||
|
|
|
|||
|
|
def test_group_disabled_denies_all(self):
|
|||
|
|
matcher = DingDingPolicyMatcher(self._make_config(groupPolicy="disabled"))
|
|||
|
|
allowed, msg = matcher.check_chat_access("group_xyz", "group")
|
|||
|
|
assert allowed is False
|
|||
|
|
assert "没有权限" in msg
|
|||
|
|
|
|||
|
|
def test_allowlist_exact_match(self):
|
|||
|
|
matcher = DingDingPolicyMatcher(self._make_config())
|
|||
|
|
allowed, msg = matcher.check_chat_access("group_abc", "group")
|
|||
|
|
assert allowed is True
|
|||
|
|
|
|||
|
|
def test_allowlist_wildcard_match(self):
|
|||
|
|
matcher = DingDingPolicyMatcher(self._make_config())
|
|||
|
|
allowed, msg = matcher.check_chat_access("user_bot_001", "direct")
|
|||
|
|
assert allowed is True
|
|||
|
|
|
|||
|
|
def test_allowlist_no_match(self):
|
|||
|
|
matcher = DingDingPolicyMatcher(self._make_config(allowFrom=["group_specific"]))
|
|||
|
|
allowed, msg = matcher.check_chat_access("group_other", "group")
|
|||
|
|
assert allowed is False
|
|||
|
|
|
|||
|
|
def test_blocklist_overrides_allowlist(self):
|
|||
|
|
matcher = DingDingPolicyMatcher(
|
|||
|
|
self._make_config(allowFrom=["group_abc"], blockFrom=["group_abc"])
|
|||
|
|
)
|
|||
|
|
allowed, msg = matcher.check_chat_access("group_abc", "group")
|
|||
|
|
assert allowed is False
|
|||
|
|
|
|||
|
|
def test_resolve_require_mention_global(self):
|
|||
|
|
matcher = DingDingPolicyMatcher(self._make_config(requireMention=True))
|
|||
|
|
assert matcher.resolve_require_mention("group_abc") is True
|
|||
|
|
|
|||
|
|
def test_resolve_require_mention_per_group(self):
|
|||
|
|
matcher = DingDingPolicyMatcher(
|
|||
|
|
self._make_config(
|
|||
|
|
requireMention=True,
|
|||
|
|
groups={"group_abc": {"requireMention": False}},
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
assert matcher.resolve_require_mention("group_abc") is False
|
|||
|
|
assert matcher.resolve_require_mention("group_other") is True
|
|||
|
|
|
|||
|
|
def test_resolve_system_prompt(self):
|
|||
|
|
matcher = DingDingPolicyMatcher(
|
|||
|
|
self._make_config(
|
|||
|
|
systemPrompt="全局提示词",
|
|||
|
|
groups={"group_abc": {"systemPrompt": "群专属提示词"}},
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
assert matcher.resolve_system_prompt("group_abc", "group") == "群专属提示词"
|
|||
|
|
assert matcher.resolve_system_prompt("group_other", "group") == "全局提示词"
|
|||
|
|
|
|||
|
|
def test_resolve_dm_config(self):
|
|||
|
|
matcher = DingDingPolicyMatcher(
|
|||
|
|
self._make_config(dms={"dm_user123": {"systemPrompt": "私聊专属"}})
|
|||
|
|
)
|
|||
|
|
assert matcher.resolve_system_prompt("dm_user123", "direct") == "私聊专属"
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============================================================
|
|||
|
|
# Config Schema tests
|
|||
|
|
# ============================================================
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestConfigSchema:
|
|||
|
|
def test_validate_valid_config(self):
|
|||
|
|
config = {
|
|||
|
|
"accounts": {"default": {"app_key": "key123", "app_secret": "sec456"}},
|
|||
|
|
"mode": "stream",
|
|||
|
|
"groupPolicy": "allowlist",
|
|||
|
|
"dmPolicy": "pairing",
|
|||
|
|
}
|
|||
|
|
errors = validate_dingding_config(config)
|
|||
|
|
assert errors == []
|
|||
|
|
|
|||
|
|
def test_validate_missing_accounts(self):
|
|||
|
|
errors = validate_dingding_config({})
|
|||
|
|
assert any("accounts" in e for e in errors)
|
|||
|
|
|
|||
|
|
def test_validate_missing_app_key(self):
|
|||
|
|
errors = validate_dingding_config({"accounts": {"default": {}}})
|
|||
|
|
assert any("app_key" in e for e in errors)
|
|||
|
|
|
|||
|
|
def test_validate_invalid_mode(self):
|
|||
|
|
errors = validate_dingding_config(
|
|||
|
|
{"accounts": {"default": {"app_key": "k", "app_secret": "s"}}, "mode": "invalid_mode"}
|
|||
|
|
)
|
|||
|
|
assert any("mode" in e for e in errors)
|
|||
|
|
|
|||
|
|
def test_validate_invalid_group_policy(self):
|
|||
|
|
errors = validate_dingding_config(
|
|||
|
|
{
|
|||
|
|
"accounts": {"default": {"app_key": "k", "app_secret": "s"}},
|
|||
|
|
"groupPolicy": "bad_policy",
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
assert any("groupPolicy" in e for e in errors)
|
|||
|
|
|
|||
|
|
def test_validate_invalid_text_limit(self):
|
|||
|
|
errors = validate_dingding_config(
|
|||
|
|
{
|
|||
|
|
"accounts": {"default": {"app_key": "k", "app_secret": "s"}},
|
|||
|
|
"text_chunk_limit": 10,
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
assert any("text_chunk_limit" in e for e in errors)
|
|||
|
|
|
|||
|
|
def test_validate_blank_allowlist_entry(self):
|
|||
|
|
errors = validate_dingding_config(
|
|||
|
|
{
|
|||
|
|
"accounts": {"default": {"app_key": "k", "app_secret": "s"}},
|
|||
|
|
"allowlist": [" "],
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
assert any("allowlist" in e for e in errors)
|
|||
|
|
|
|||
|
|
def test_refine_falls_back_to_stream_on_invalid_mode(self):
|
|||
|
|
refined = refine_dingding_config({"mode": "bogus"})
|
|||
|
|
assert refined["mode"] == "stream"
|
|||
|
|
|
|||
|
|
def test_refine_falls_back_on_invalid_policies(self):
|
|||
|
|
refined = refine_dingding_config({"groupPolicy": "bad", "dmPolicy": "bad"})
|
|||
|
|
assert refined["groupPolicy"] == "allowlist"
|
|||
|
|
assert refined["dmPolicy"] == "pairing"
|
|||
|
|
|
|||
|
|
def test_refine_warns_on_invalid_streaming_mode(self):
|
|||
|
|
refined = refine_dingding_config({"streaming": {"mode": "bad"}})
|
|||
|
|
assert refined["streaming"]["mode"] == "block"
|
|||
|
|
|
|||
|
|
def test_refine_preserves_valid_values(self):
|
|||
|
|
refined = refine_dingding_config(
|
|||
|
|
{
|
|||
|
|
"mode": "webhook",
|
|||
|
|
"groupPolicy": "open",
|
|||
|
|
"dmPolicy": "open",
|
|||
|
|
"accounts": {"default": {"robot_code": "rc"}},
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
assert refined["mode"] == "webhook"
|
|||
|
|
assert refined["groupPolicy"] == "open"
|
|||
|
|
assert refined["dmPolicy"] == "open"
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============================================================
|
|||
|
|
# Directory tests
|
|||
|
|
# ============================================================
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestDirectoryClient:
|
|||
|
|
def test_directory_client_init(self):
|
|||
|
|
from yuxi.channels.adapters.dingding.directory import DingDingDirectoryClient
|
|||
|
|
|
|||
|
|
tm = DingDingTokenManager("fake", "fake_secret")
|
|||
|
|
client = DingDingDirectoryClient(tm)
|
|||
|
|
assert client._token_manager is tm
|
|||
|
|
|
|||
|
|
def test_get_user_info_structure(self):
|
|||
|
|
from yuxi.channels.adapters.dingding.directory import DingDingDirectoryClient
|
|||
|
|
|
|||
|
|
tm = DingDingTokenManager("fake", "fake_secret")
|
|||
|
|
client = DingDingDirectoryClient(tm)
|
|||
|
|
assert client._token_manager is tm
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_get_channel_info_no_token(self):
|
|||
|
|
from yuxi.channels.adapters.dingding.directory import DingDingDirectoryClient
|
|||
|
|
|
|||
|
|
tm = DingDingTokenManager("fake", "fake_secret")
|
|||
|
|
client = DingDingDirectoryClient(tm)
|
|||
|
|
result = await client.get_channel_info("group_cid123")
|
|||
|
|
assert result["chat_id"] == "group_cid123"
|
|||
|
|
assert "error" in result
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============================================================
|
|||
|
|
# Reactions tests
|
|||
|
|
# ============================================================
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestReactionsExtended:
|
|||
|
|
def test_emoji_map_has_all_entries(self):
|
|||
|
|
assert len(DINGDING_EMOJI_MAP) == 14
|
|||
|
|
assert DINGDING_EMOJI_MAP["👍"] == ("101", "like")
|
|||
|
|
assert DINGDING_EMOJI_MAP["❤️"] == ("103", "heart")
|
|||
|
|
|
|||
|
|
def test_resolve_emoji_by_emoji(self):
|
|||
|
|
result = resolve_emoji("👍")
|
|||
|
|
assert result == ("101", "like")
|
|||
|
|
|
|||
|
|
def test_resolve_emoji_by_name(self):
|
|||
|
|
result = resolve_emoji("like")
|
|||
|
|
assert result == ("101", "like")
|
|||
|
|
|
|||
|
|
def test_resolve_emoji_unknown(self):
|
|||
|
|
assert resolve_emoji("invalid_emoji") is None
|
|||
|
|
|
|||
|
|
def test_emotion_type_to_emoji_reverse(self):
|
|||
|
|
assert EMOTION_TYPE_TO_EMOJI["101"] == "👍"
|
|||
|
|
assert EMOTION_TYPE_TO_EMOJI.get("999") is None
|
|||
|
|
|
|||
|
|
def test_remove_reaction_not_connected(self):
|
|||
|
|
adapter = _make_adapter()
|
|||
|
|
result = asyncio.run(adapter.remove_reaction("dm_user1", "msg_001", "👍"))
|
|||
|
|
assert result.success is False
|
|||
|
|
|
|||
|
|
def test_list_reactions_not_connected(self):
|
|||
|
|
adapter = _make_adapter()
|
|||
|
|
result = asyncio.run(adapter.list_reactions("dm_user1", "msg_001"))
|
|||
|
|
assert result["total"] == 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============================================================
|
|||
|
|
# Session tests
|
|||
|
|
# ============================================================
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestSessionExtended:
|
|||
|
|
def test_session_scope_values(self):
|
|||
|
|
assert SessionScope.DIRECT == "dm"
|
|||
|
|
assert SessionScope.GROUP == "group"
|
|||
|
|
assert SessionScope.GROUP_SENDER == "group_sender"
|
|||
|
|
assert SessionScope.TOPIC == "topic"
|
|||
|
|
|
|||
|
|
def test_session_mode_values(self):
|
|||
|
|
assert SessionMode.RAW == "raw"
|
|||
|
|
assert SessionMode.CHAT_RAW == "chat_raw"
|
|||
|
|
assert SessionMode.CHAT_RESOLVE == "chat_resolve"
|
|||
|
|
|
|||
|
|
def test_generate_chat_id_group_sender(self):
|
|||
|
|
raw = {"senderId": "user001", "isGroupChat": True, "conversationId": "cid_grp"}
|
|||
|
|
chat_id = generate_dingding_chat_id(raw, "group", sender_id="user001", scope="group_sender")
|
|||
|
|
assert chat_id.startswith("dingding:group_sender:")
|
|||
|
|
assert "cid_grp" in chat_id
|
|||
|
|
assert "user001" in chat_id
|
|||
|
|
|
|||
|
|
def test_normalize_chat_id_dm(self):
|
|||
|
|
assert normalize_chat_id("dm_user123") == "dm_user123"
|
|||
|
|
|
|||
|
|
def test_normalize_chat_id_group(self):
|
|||
|
|
assert normalize_chat_id("group_cid123") == "group_cid123"
|
|||
|
|
|
|||
|
|
def test_normalize_chat_id_raw(self):
|
|||
|
|
result = normalize_chat_id("cid_raw")
|
|||
|
|
assert result.startswith("dingding:group:")
|
|||
|
|
|
|||
|
|
def test_parse_chat_id_dm(self):
|
|||
|
|
info = parse_chat_id("dm_user123")
|
|||
|
|
assert info["type"] == "direct"
|
|||
|
|
assert info["open_id"] == "user123"
|
|||
|
|
|
|||
|
|
def test_parse_chat_id_group(self):
|
|||
|
|
info = parse_chat_id("group_cid123")
|
|||
|
|
assert info["type"] == "group"
|
|||
|
|
assert info["open_conversation_id"] == "cid123"
|
|||
|
|
|
|||
|
|
def test_resolve_session_scope(self):
|
|||
|
|
assert resolve_session_scope("dm_user123") == SessionScope.DIRECT
|
|||
|
|
assert resolve_session_scope("group_cid123") == SessionScope.GROUP
|
|||
|
|
|
|||
|
|
def test_generate_thread_key(self):
|
|||
|
|
key = generate_thread_key("dingding", "group_cid123")
|
|||
|
|
assert key == "dingding:group:group_cid123"
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============================================================
|
|||
|
|
# Streaming session tests
|
|||
|
|
# ============================================================
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestDingDingStreamingSession:
|
|||
|
|
def _make_session(self, **overrides):
|
|||
|
|
config = {"streaming": {"mode": "partial"}}
|
|||
|
|
config.update(overrides)
|
|||
|
|
tm = DingDingTokenManager("fake", "fake_secret")
|
|||
|
|
return DingDingStreamingSession(
|
|||
|
|
tm,
|
|||
|
|
"cid_open",
|
|||
|
|
"robot_code",
|
|||
|
|
"group_cid_open",
|
|||
|
|
config=config,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_session_init_state(self):
|
|||
|
|
session = self._make_session()
|
|||
|
|
assert session.message_id is None
|
|||
|
|
assert session.accumulated_text == ""
|
|||
|
|
assert session.is_closed is False
|
|||
|
|
assert session.consecutive_failures == 0
|
|||
|
|
|
|||
|
|
def test_session_should_not_degrade_early(self):
|
|||
|
|
session = self._make_session()
|
|||
|
|
assert session.should_degrade() is False
|
|||
|
|
|
|||
|
|
def test_backoff_delay_zero_when_no_failures(self):
|
|||
|
|
session = self._make_session()
|
|||
|
|
assert session.backoff_delay() == 0.0
|
|||
|
|
|
|||
|
|
@pytest.mark.parametrize(
|
|||
|
|
"boundary",
|
|||
|
|
["\n", "。", "!", "?", ";", ":", ",", ". ", "! ", "? "],
|
|||
|
|
)
|
|||
|
|
def test_natural_boundaries(self, boundary):
|
|||
|
|
assert boundary in STREAM_NATURAL_BOUNDARIES
|
|||
|
|
|
|||
|
|
def test_max_consecutive_failures_constant(self):
|
|||
|
|
assert MAX_CONSECUTIVE_FAILURES == 5
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============================================================
|
|||
|
|
# Cards tests
|
|||
|
|
# ============================================================
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestCardsExtended:
|
|||
|
|
def test_generate_card_biz_id_unique(self):
|
|||
|
|
id1 = generate_card_biz_id()
|
|||
|
|
id2 = generate_card_biz_id()
|
|||
|
|
assert id1 != id2
|
|||
|
|
assert id1.startswith("fp_")
|
|||
|
|
|
|||
|
|
def test_generate_card_biz_id_custom_prefix(self):
|
|||
|
|
bid = generate_card_biz_id("custom")
|
|||
|
|
assert bid.startswith("custom_")
|
|||
|
|
|
|||
|
|
def test_build_standard_card_basic(self):
|
|||
|
|
card = build_standard_card(title="Test", markdown_content="**Hello**")
|
|||
|
|
assert "cardData" in card
|
|||
|
|
assert card["cardData"]["header"]["title"]["text"] == "Test"
|
|||
|
|
body = card["cardData"]["body"]
|
|||
|
|
assert body[0]["text"] == "**Hello**"
|
|||
|
|
|
|||
|
|
def test_build_standard_card_with_buttons(self):
|
|||
|
|
card = build_standard_card(
|
|||
|
|
title="Test",
|
|||
|
|
markdown_content="content",
|
|||
|
|
buttons=[
|
|||
|
|
{"title": "OK", "action_type": "CALLBACK", "callback_key": "key1"},
|
|||
|
|
{"title": "URL", "action_type": "URL", "url": "https://example.com"},
|
|||
|
|
],
|
|||
|
|
)
|
|||
|
|
body = card["cardData"]["body"]
|
|||
|
|
action_group = body[-1]
|
|||
|
|
assert action_group["componentType"] == "actionGroup"
|
|||
|
|
assert len(action_group["actions"]) == 2
|
|||
|
|
|
|||
|
|
def test_build_standard_card_private_data(self):
|
|||
|
|
card = build_standard_card(title="T", private_data={"key": "value"})
|
|||
|
|
assert card["privateData"] == {"key": "value"}
|
|||
|
|
|
|||
|
|
def test_build_card_template(self):
|
|||
|
|
card = build_card_template("tmpl_001", variables={"name": "test"})
|
|||
|
|
assert card["cardData"]["templateId"] == "tmpl_001"
|
|||
|
|
assert card["cardData"]["cardTemplateVariableParams"]["name"] == "test"
|
|||
|
|
|
|||
|
|
def test_parse_card_private_data(self):
|
|||
|
|
callback = {"cardPrivateData": {"action": "approve"}}
|
|||
|
|
assert parse_card_private_data(callback) == {"action": "approve"}
|
|||
|
|
|
|||
|
|
def test_parse_card_callback_params(self):
|
|||
|
|
callback = {"actionParameter": {"key": "val"}}
|
|||
|
|
assert parse_card_callback_params(callback) == {"key": "val"}
|
|||
|
|
|
|||
|
|
def test_text_chunk_limit_value(self):
|
|||
|
|
assert TEXT_CHUNK_LIMIT == 4096
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============================================================
|
|||
|
|
# Normalizer extended event tests
|
|||
|
|
# ============================================================
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestNormalizerExtendedEvents:
|
|||
|
|
@pytest.mark.parametrize(
|
|||
|
|
"event_type_str,expected",
|
|||
|
|
[
|
|||
|
|
("message_updated", EventType.MESSAGE_UPDATED),
|
|||
|
|
("message_deleted", EventType.MESSAGE_DELETED),
|
|||
|
|
("bot_removed", EventType.BOT_REMOVED),
|
|||
|
|
("member_joined", EventType.MEMBER_JOINED),
|
|||
|
|
("member_left", EventType.MEMBER_LEFT),
|
|||
|
|
("member_added", EventType.MEMBER_ADDED),
|
|||
|
|
("member_removed", EventType.MEMBER_REMOVED),
|
|||
|
|
("reaction_added", EventType.REACTION_ADDED),
|
|||
|
|
("reaction_removed", EventType.REACTION_REMOVED),
|
|||
|
|
("channel_updated", EventType.SYSTEM_EVENT),
|
|||
|
|
],
|
|||
|
|
)
|
|||
|
|
def test_map_event_type(self, event_type_str, expected):
|
|||
|
|
raw = {"event_type": event_type_str}
|
|||
|
|
assert map_event_type(raw) == expected
|
|||
|
|
|
|||
|
|
def test_map_event_type_default(self):
|
|||
|
|
assert map_event_type({"event_type": "unknown"}) == EventType.MESSAGE_RECEIVED
|
|||
|
|
|
|||
|
|
def test_bot_added_via_robot_code(self):
|
|||
|
|
raw = {"robotCode": "rb_001"}
|
|||
|
|
assert map_event_type(raw) == EventType.BOT_ADDED
|
|||
|
|
|
|||
|
|
def test_mentions_not_triggered_for_dm(self):
|
|||
|
|
raw = {
|
|||
|
|
"senderId": "user1",
|
|||
|
|
"isGroupChat": False,
|
|||
|
|
"conversationId": "cid1",
|
|||
|
|
"msgId": "msg1",
|
|||
|
|
"msgtype": "text",
|
|||
|
|
"text": {"content": "hello"},
|
|||
|
|
"atUsers": [{"dingtalkId": "bot"}],
|
|||
|
|
}
|
|||
|
|
mentions = extract_mentions(raw)
|
|||
|
|
assert mentions is None
|
|||
|
|
|
|||
|
|
def test_mentions_at_all(self):
|
|||
|
|
raw = {
|
|||
|
|
"senderId": "user1",
|
|||
|
|
"isGroupChat": True,
|
|||
|
|
"conversationId": "cid1",
|
|||
|
|
"msgId": "msg1",
|
|||
|
|
"msgtype": "text",
|
|||
|
|
"text": {"content": "@all"},
|
|||
|
|
"isAtAll": True,
|
|||
|
|
}
|
|||
|
|
mentions = extract_mentions(raw)
|
|||
|
|
assert mentions is not None
|
|||
|
|
assert "@all" in mentions.mentioned_user_ids
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============================================================
|
|||
|
|
# Config UI Hints tests
|
|||
|
|
# ============================================================
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestConfigUIHints:
|
|||
|
|
def test_get_ui_hints_returns_dict(self):
|
|||
|
|
hints = get_dingding_ui_hints()
|
|||
|
|
assert isinstance(hints, dict)
|
|||
|
|
assert "mode" in hints
|
|||
|
|
assert "accounts.default.app_key" in hints
|
|||
|
|
|
|||
|
|
def test_resolve_field_order(self):
|
|||
|
|
fields = resolve_field_order()
|
|||
|
|
assert isinstance(fields, list)
|
|||
|
|
assert len(fields) > 5
|
|||
|
|
orders = [f["order"] for f in fields]
|
|||
|
|
assert orders == sorted(orders)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============================================================
|
|||
|
|
# Sign extended tests
|
|||
|
|
# ============================================================
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestSignExtended:
|
|||
|
|
def test_sign_url_encoded(self):
|
|||
|
|
sign = compute_dingtalk_sign("1700000000000", "test")
|
|||
|
|
assert "%" in sign or sign.replace("-", "").replace("_", "").isalnum()
|
|||
|
|
|
|||
|
|
def test_tampered_sign_fails(self):
|
|||
|
|
now_ms = str(int(time.time() * 1000))
|
|||
|
|
headers = {"timestamp": now_ms, "sign": compute_dingtalk_sign(now_ms, "secret")}
|
|||
|
|
tampered = dict(headers)
|
|||
|
|
tampered["sign"] = "bad_signature"
|
|||
|
|
assert verify_webhook_signature(tampered, "secret") is False
|
|||
|
|
|
|||
|
|
def test_missing_timestamp_passes(self):
|
|||
|
|
headers = {"sign": "anything"}
|
|||
|
|
assert verify_webhook_signature(headers, "secret") is True
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============================================================
|
|||
|
|
# Token manager extended tests
|
|||
|
|
# ============================================================
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestTokenManagerExtended:
|
|||
|
|
def test_is_valid_when_no_token(self):
|
|||
|
|
tm = DingDingTokenManager("k", "s")
|
|||
|
|
assert tm._is_valid() is False
|
|||
|
|
|
|||
|
|
def test_is_valid_when_expired(self):
|
|||
|
|
tm = DingDingTokenManager("k", "s")
|
|||
|
|
tm._access_token = "tok"
|
|||
|
|
tm._token_expires_at = 0
|
|||
|
|
assert tm._is_valid() is False
|
|||
|
|
|
|||
|
|
def test_is_valid_when_active(self):
|
|||
|
|
tm = DingDingTokenManager("k", "s")
|
|||
|
|
tm._access_token = "tok"
|
|||
|
|
tm._token_expires_at = 9999999999
|
|||
|
|
assert tm._is_valid() is True
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============================================================
|
|||
|
|
# Formatter extended tests
|
|||
|
|
# ============================================================
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestFormatterExtended:
|
|||
|
|
def test_metadata_title_preserved(self):
|
|||
|
|
result = format_outbound("content", metadata={"use_markdown": True, "title": "MyTitle"})
|
|||
|
|
import json
|
|||
|
|
|
|||
|
|
params = json.loads(result["msgParam"])
|
|||
|
|
assert params["title"] == "MyTitle"
|
|||
|
|
|
|||
|
|
def test_open_conversation_id_passed(self):
|
|||
|
|
result = format_outbound("text", metadata={"openConversationId": "oc_123"})
|
|||
|
|
assert result["openConversationId"] == "oc_123"
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============================================================
|
|||
|
|
# Adapter build_snapshot tests
|
|||
|
|
# ============================================================
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestAdapterSnapshot:
|
|||
|
|
def test_build_snapshot_without_connection(self):
|
|||
|
|
adapter = _make_adapter()
|
|||
|
|
snapshot = adapter.build_snapshot()
|
|||
|
|
assert snapshot.running is False
|
|||
|
|
assert snapshot.connected is False
|
|||
|
|
assert snapshot.health_state in ("stopped", "disconnected")
|
|||
|
|
|
|||
|
|
def test_build_snapshot_with_connection(self):
|
|||
|
|
adapter = _make_connected_adapter()
|
|||
|
|
adapter._status = ChannelStatus.CONNECTED
|
|||
|
|
snapshot = adapter.build_snapshot()
|
|||
|
|
assert snapshot.running is True
|
|||
|
|
assert snapshot.connected is True
|
|||
|
|
assert snapshot.account_id != ""
|
|||
|
|
|
|||
|
|
def test_snapshot_includes_bot_info(self):
|
|||
|
|
adapter = _make_connected_adapter()
|
|||
|
|
snapshot = adapter.build_snapshot()
|
|||
|
|
assert "bot" in snapshot.model_dump()
|
|||
|
|
bot = snapshot.bot
|
|||
|
|
assert bot["channel"] == "dingding"
|
|||
|
|
assert "capabilities" in bot
|
|||
|
|
|
|||
|
|
def test_snapshot_includes_audit(self):
|
|||
|
|
adapter = _make_connected_adapter()
|
|||
|
|
snapshot = adapter.build_snapshot()
|
|||
|
|
audit = snapshot.audit
|
|||
|
|
assert "dedup_committed" in audit
|
|||
|
|
assert "rate_limit" in audit
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============================================================
|
|||
|
|
# Capabilities tests
|
|||
|
|
# ============================================================
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestCapabilities:
|
|||
|
|
def test_chat_types_includes_group(self):
|
|||
|
|
from yuxi.channels.adapters.dingding.adapter import DingDingChannelAdapter
|
|||
|
|
|
|||
|
|
caps = DingDingChannelAdapter.capabilities
|
|||
|
|
assert "group" in caps.chat_types
|
|||
|
|
assert "direct" in caps.chat_types
|
|||
|
|
|
|||
|
|
def test_streaming_modes_include_all(self):
|
|||
|
|
from yuxi.channels.adapters.dingding.adapter import DingDingChannelAdapter
|
|||
|
|
|
|||
|
|
assert "off" in DingDingChannelAdapter.streaming_modes
|
|||
|
|
assert "block" in DingDingChannelAdapter.streaming_modes
|
|||
|
|
assert "partial" in DingDingChannelAdapter.streaming_modes
|
|||
|
|
|
|||
|
|
def test_capabilities_declare_all_features(self):
|
|||
|
|
from yuxi.channels.adapters.dingding.adapter import DingDingChannelAdapter
|
|||
|
|
|
|||
|
|
caps = DingDingChannelAdapter.capabilities
|
|||
|
|
assert caps.reactions is True
|
|||
|
|
assert caps.edit is True
|
|||
|
|
assert caps.unsend is True
|
|||
|
|
assert caps.reply is True
|
|||
|
|
assert caps.media is True
|
|||
|
|
assert caps.threads is True
|
|||
|
|
assert caps.typing is False
|
|||
|
|
|
|||
|
|
def test_text_chunk_limit_consistent(self):
|
|||
|
|
from yuxi.channels.adapters.dingding.adapter import DingDingChannelAdapter
|
|||
|
|
from yuxi.channels.adapters.dingding.cards import TEXT_CHUNK_LIMIT as CARD_LIMIT
|
|||
|
|
|
|||
|
|
assert DingDingChannelAdapter.text_chunk_limit == 4096
|
|||
|
|
assert DingDingChannelAdapter.capabilities.text_chunk_limit == 4096
|
|||
|
|
assert CARD_LIMIT == 4096
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============================================================
|
|||
|
|
# ChannelMeta tests
|
|||
|
|
# ============================================================
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestChannelMeta:
|
|||
|
|
def test_meta_has_aliases(self):
|
|||
|
|
from yuxi.channels.adapters.dingding.adapter import DingDingChannelAdapter
|
|||
|
|
|
|||
|
|
meta = DingDingChannelAdapter.meta
|
|||
|
|
assert "dingtalk" in meta.aliases
|
|||
|
|
|
|||
|
|
def test_meta_has_docs(self):
|
|||
|
|
from yuxi.channels.adapters.dingding.adapter import DingDingChannelAdapter
|
|||
|
|
|
|||
|
|
meta = DingDingChannelAdapter.meta
|
|||
|
|
assert meta.docs_path == "/channels/dingding"
|
|||
|
|
assert meta.docs_label == "钉钉渠道配置"
|
|||
|
|
|
|||
|
|
def test_meta_has_blurb(self):
|
|||
|
|
from yuxi.channels.adapters.dingding.adapter import DingDingChannelAdapter
|
|||
|
|
|
|||
|
|
meta = DingDingChannelAdapter.meta
|
|||
|
|
assert len(meta.blurb) > 10
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============================================================
|
|||
|
|
# PolicyAccessTracker scenario tests
|
|||
|
|
# ============================================================
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestPolicyScenario:
|
|||
|
|
def test_allowlist_empty_with_policy_denies(self):
|
|||
|
|
matcher = DingDingPolicyMatcher(
|
|||
|
|
{"groupPolicy": "allowlist", "allowFrom": []}
|
|||
|
|
)
|
|||
|
|
allowed, msg = matcher.check_chat_access("group_any", "group")
|
|||
|
|
assert allowed is False
|
|||
|
|
|
|||
|
|
def test_access_history_aggregation(self):
|
|||
|
|
matcher = DingDingPolicyMatcher(
|
|||
|
|
{
|
|||
|
|
"dmPolicy": "allowlist",
|
|||
|
|
"allowFrom": ["dm_user_ok"],
|
|||
|
|
"groupPolicy": "allowlist",
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
matcher.check_chat_access("dm_user_ok", "direct")
|
|||
|
|
matcher.check_chat_access("dm_user_bad", "direct")
|
|||
|
|
history = matcher.get_access_history()
|
|||
|
|
assert len(history) == 2
|
|||
|
|
|
|||
|
|
def test_reset_tracker(self):
|
|||
|
|
matcher = DingDingPolicyMatcher({"dmPolicy": "open"})
|
|||
|
|
matcher.check_chat_access("dm_user1", "direct")
|
|||
|
|
matcher.reset_tracker()
|
|||
|
|
history = matcher.get_access_history()
|
|||
|
|
assert history == []
|
|||
|
|
|
|||
|
|
|
|||
|
|
print(f"\nDingDing comprehensive tests loaded. TEXT_CHUNK_LIMIT={TEXT_CHUNK_LIMIT}")
|