1. 移除Telegram格式化测试中未使用的导入项 2. 修复Teams测试用例,添加monkeypatch参数并配置通配符开关 3. 更新钉钉适配器测试,替换弃用的流属性检查 4. 修正Twitch规范化测试,更新ROOMSTATE测试逻辑 5. 重构会话映射测试,完善数据库执行结果模拟 6. 格式化Slack块构建测试的长参数调用 7. 修复LINE适配器测试,更新能力断言和异步锁使用 8. 修正Slack会话解析测试,修复聊天类型判断错误 9. 更新能力测试,补充缺失的字段检查 10. 修复Matrix适配器测试,修正位置参数和配置校验逻辑 11. 为飞书分析模块测试添加跳过标记 12. 新增微信能力、限流、链接格式、会话路由等模块的单元测试 13. 修复Twitch适配器导入路径和测试断言 14. 新增Discord Webhook、Nextcloud Talk、Signal多账户等模块的单元测试 15. 修复Manager阶段测试的导入路径 16. 新增iMessage异常和命令处理的单元测试 17. 新增Nostr健康检查和相关模块的单元测试 18. 新增Signal守护进程和SSE重连相关测试
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}") |