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重连相关测试
1799 lines
71 KiB
Python
1799 lines
71 KiB
Python
"""全面的飞书模块单元测试 - 覆盖所有未充分测试的核心功能、边界条件和异常处理场景"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import tempfile
|
|
import time
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from yuxi.channels.adapters.feishu.config_schema import (
|
|
_camel_to_snake,
|
|
super_refine_feishu_config,
|
|
validate_feishu_config,
|
|
)
|
|
from yuxi.channels.adapters.feishu.policy import FeishuPolicyMatcher, PolicyAccessTracker
|
|
from yuxi.channels.adapters.feishu.subagent import FeishuSubAgentManager, SubAgentLifecycle
|
|
from yuxi.channels.adapters.feishu.reasoning import build_reasoning_preview_card
|
|
from yuxi.channels.adapters.feishu.commands import build_synthetic_message, parse_bot_menu_event
|
|
from yuxi.channels.adapters.feishu.reply_dispatcher import dispatch_render, extract_urls, should_use_card
|
|
from yuxi.channels.adapters.feishu.cards import (
|
|
CARD_TEMPLATE_COLORS,
|
|
PRESENTATION_TONE_MAP,
|
|
build_feishu_card,
|
|
build_feishu_post_content,
|
|
build_feishu_text_content,
|
|
is_post_format_requested,
|
|
make_checkbox_group,
|
|
make_date_picker,
|
|
make_datetime_picker,
|
|
make_image_element,
|
|
make_input_field,
|
|
make_overflow_menu,
|
|
make_select_person,
|
|
make_select_static,
|
|
make_time_picker,
|
|
resolve_button_type,
|
|
resolve_selector_tag,
|
|
resolve_template_color,
|
|
)
|
|
from yuxi.channels.adapters.feishu.dedup import FeishuDedupStore
|
|
from yuxi.channels.adapters.feishu.formatter import (
|
|
format_outbound,
|
|
format_outbound_card,
|
|
format_outbound_diagnostic_card,
|
|
format_outbound_message,
|
|
make_error_card,
|
|
)
|
|
from yuxi.channels.adapters.feishu.media import (
|
|
MediaSizeError,
|
|
recover_utf8_filename_from_latin1_header,
|
|
resolve_feishu_outbound_media_kind,
|
|
sanitize_filename_for_upload,
|
|
validate_file_size,
|
|
validate_image_size,
|
|
validate_media_size,
|
|
)
|
|
from yuxi.channels.adapters.feishu.normalizer import (
|
|
_escape_post_md,
|
|
_extract_interactive_text,
|
|
_get_i18n_text,
|
|
_resolve_template_vars,
|
|
extract_content,
|
|
extract_mentioned_user_ids,
|
|
extract_mentions,
|
|
map_event_type,
|
|
map_msg_type,
|
|
normalize_inbound,
|
|
strip_at_mentions,
|
|
)
|
|
from yuxi.channels.adapters.feishu.reactions import emoji_type_to_emoji
|
|
from yuxi.channels.adapters.feishu.secret_resolver import (
|
|
invalidate_secret_cache,
|
|
resolve_secret,
|
|
resolve_secret_with_rotation,
|
|
)
|
|
from yuxi.channels.adapters.feishu.sequential import FeishuSequentialQueue
|
|
from yuxi.channels.adapters.feishu.session import (
|
|
SessionMode,
|
|
SessionPersistence,
|
|
generate_feishu_chat_id,
|
|
looks_like_feishu_id,
|
|
match_feishu_acp_conversation,
|
|
normalize_feishu_acp_conversation_id,
|
|
normalize_feishu_target,
|
|
resolve_feishu_chat_type,
|
|
resolve_feishu_command_conversation,
|
|
)
|
|
from yuxi.channels.adapters.feishu.stream import CharacterStreamingSession, merge_streaming_text
|
|
from yuxi.channels.adapters.feishu.verify import (
|
|
check_webhook_rate_limit,
|
|
decrypt_feishu_body,
|
|
verify_and_decrypt_webhook,
|
|
verify_feishu_signature,
|
|
)
|
|
from yuxi.channels.models import ChannelType, ChatType, EventType, MessageType
|
|
|
|
CHANNEL_ID = "feishu"
|
|
CHANNEL_TYPE = ChannelType.FEISHU
|
|
|
|
|
|
# ============================================================
|
|
# config_schema.py
|
|
# ============================================================
|
|
class TestConfigSchema:
|
|
def test_camel_to_snake_simple(self):
|
|
assert _camel_to_snake("appId") == "app_id"
|
|
assert _camel_to_snake("appSecret") == "app_secret"
|
|
assert _camel_to_snake("verifyToken") == "verify_token"
|
|
assert _camel_to_snake("encryptKey") == "encrypt_key"
|
|
|
|
def test_camel_to_snake_single_word(self):
|
|
assert _camel_to_snake("domain") == "domain"
|
|
assert _camel_to_snake("platform") == "platform"
|
|
|
|
def test_camel_to_snake_empty(self):
|
|
assert _camel_to_snake("") == ""
|
|
|
|
def test_validate_config_valid_minimal(self):
|
|
errors = validate_feishu_config({"appId": "cli_123", "appSecret": "secret_456"})
|
|
assert errors == []
|
|
|
|
def test_validate_config_snake_case(self):
|
|
errors = validate_feishu_config({"app_id": "cli_123", "app_secret": "secret_456"})
|
|
assert errors == []
|
|
|
|
def test_validate_config_missing_app_id(self):
|
|
errors = validate_feishu_config({"appSecret": "secret_456"})
|
|
assert any("app_id" in e or "appId" in e for e in errors)
|
|
|
|
def test_validate_config_missing_app_secret(self):
|
|
errors = validate_feishu_config({"appId": "cli_123"})
|
|
assert any("app_secret" in e or "appSecret" in e for e in errors)
|
|
|
|
def test_validate_config_empty_all(self):
|
|
errors = validate_feishu_config({})
|
|
assert len(errors) >= 2
|
|
|
|
def test_validate_config_invalid_group_policy(self):
|
|
errors = validate_feishu_config({"appId": "x", "appSecret": "x", "groupPolicy": "invalid"})
|
|
assert any("groupPolicy" in e for e in errors)
|
|
|
|
def test_validate_config_valid_group_policies(self):
|
|
for policy in ("open", "allowlist", "disabled", "allowall"):
|
|
errors = validate_feishu_config({"appId": "x", "appSecret": "x", "groupPolicy": policy})
|
|
assert not any("groupPolicy" in e for e in errors)
|
|
|
|
def test_validate_config_invalid_dm_policy(self):
|
|
errors = validate_feishu_config({"appId": "x", "appSecret": "x", "dmPolicy": "nonexistent"})
|
|
assert any("dmPolicy" in e for e in errors)
|
|
|
|
def test_validate_config_valid_dm_policies(self):
|
|
for policy in ("open", "pairing", "allowlist"):
|
|
errors = validate_feishu_config({"appId": "x", "appSecret": "x", "dmPolicy": policy})
|
|
assert not any("dmPolicy" in e for e in errors)
|
|
|
|
def test_validate_config_accounts_missing_fields(self):
|
|
errors = validate_feishu_config(
|
|
{"appId": "x", "appSecret": "x", "accounts": {"acct1": {"appId": "", "appSecret": ""}}}
|
|
)
|
|
assert any("acct1" in e for e in errors)
|
|
|
|
def test_validate_config_accounts_valid(self):
|
|
errors = validate_feishu_config(
|
|
{
|
|
"appId": "x",
|
|
"appSecret": "x",
|
|
"accounts": {"acct1": {"appId": "id1", "appSecret": "secret1"}},
|
|
}
|
|
)
|
|
assert len(errors) == 0
|
|
|
|
def test_validate_config_invalid_max_media_zero(self):
|
|
errors = validate_feishu_config({"appId": "x", "appSecret": "x", "mediaMaxMb": 0})
|
|
assert any("mediaMaxMb" in e for e in errors)
|
|
|
|
def test_validate_config_invalid_max_media_too_high(self):
|
|
errors = validate_feishu_config({"appId": "x", "appSecret": "x", "mediaMaxMb": 300})
|
|
assert any("mediaMaxMb" in e for e in errors)
|
|
|
|
def test_validate_config_max_media_valid(self):
|
|
errors = validate_feishu_config({"appId": "x", "appSecret": "x", "mediaMaxMb": 50})
|
|
assert not any("mediaMaxMb" in e for e in errors)
|
|
|
|
def test_super_refine_allowall_conversion(self):
|
|
config = {"appId": "x", "appSecret": "x", "group_policy": "allowall"}
|
|
refined = super_refine_feishu_config(config)
|
|
assert refined["groupPolicy"] == "open"
|
|
|
|
def test_super_refine_default_account_validation(self):
|
|
config = {"appId": "x", "appSecret": "x", "defaultAccount": "nonexistent", "accounts": {}}
|
|
refined = super_refine_feishu_config(config)
|
|
assert "defaultAccount" not in refined
|
|
|
|
def test_super_refine_default_account_valid(self):
|
|
config = {
|
|
"appId": "x",
|
|
"appSecret": "x",
|
|
"defaultAccount": "acct1",
|
|
"accounts": {"acct1": {"appId": "id1"}},
|
|
}
|
|
refined = super_refine_feishu_config(config)
|
|
assert refined.get("defaultAccount") == "acct1"
|
|
|
|
def test_super_refine_webhook_warning(self):
|
|
config = {"appId": "x", "appSecret": "x", "webhookPath": "/webhook", "verifyToken": "", "encryptKey": ""}
|
|
refined = super_refine_feishu_config(config)
|
|
assert refined["webhookPath"] == "/webhook"
|
|
|
|
def test_super_refine_webhook_with_token(self):
|
|
config = {
|
|
"appId": "x",
|
|
"appSecret": "x",
|
|
"webhookPath": "/webhook",
|
|
"verifyToken": "token123",
|
|
}
|
|
refined = super_refine_feishu_config(config)
|
|
assert refined["webhookPath"] == "/webhook"
|
|
|
|
def test_super_refine_account_field_inheritance(self):
|
|
config = {
|
|
"app_id": "top_app_id",
|
|
"app_secret": "top_secret",
|
|
"accounts": {"acct1": {}},
|
|
}
|
|
refined = super_refine_feishu_config(config)
|
|
assert refined["accounts"]["acct1"]["appId"] == "top_app_id"
|
|
assert refined["accounts"]["acct1"]["appSecret"] == "top_secret"
|
|
|
|
def test_super_refine_account_field_no_override(self):
|
|
config = {
|
|
"appId": "top_app_id",
|
|
"appSecret": "top_secret",
|
|
"accounts": {"acct1": {"appId": "override_id", "appSecret": "override_secret"}},
|
|
}
|
|
refined = super_refine_feishu_config(config)
|
|
assert refined["accounts"]["acct1"]["appId"] == "override_id"
|
|
|
|
def test_super_refine_dm_policy_open_wildcard(self):
|
|
config = {"appId": "x", "appSecret": "x", "dmPolicy": "open", "allowlist": ["*"]}
|
|
refined = super_refine_feishu_config(config)
|
|
assert refined["dmPolicy"] == "open"
|
|
|
|
def test_super_refine_no_accounts(self):
|
|
config = {"appId": "x", "appSecret": "x"}
|
|
refined = super_refine_feishu_config(config)
|
|
assert refined["appId"] == "x"
|
|
|
|
|
|
# ============================================================
|
|
# policy.py
|
|
# ============================================================
|
|
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]["resource_id"] == "chat_1"
|
|
assert history[0]["allowed"] is True
|
|
assert history[1]["resource_id"] == "chat_2"
|
|
assert history[1]["allowed"] is False
|
|
|
|
def test_count_recent(self):
|
|
tracker = PolicyAccessTracker()
|
|
tracker.record_access("allowlist", "chat_1", True)
|
|
tracker.record_access("allowlist", "chat_2", False)
|
|
tracker.record_access("allowlist", "chat_3", True)
|
|
allowed, denied = tracker.count_recent("allowlist", window_s=120)
|
|
assert allowed == 2
|
|
assert denied == 1
|
|
|
|
def test_history_max_entries(self):
|
|
tracker = PolicyAccessTracker()
|
|
for i in range(150):
|
|
tracker.record_access("allowlist", f"chat_{i}", True)
|
|
history = tracker.get_history("allowlist")
|
|
assert len(history) <= PolicyAccessTracker.MAX_HISTORY_PER_KEY
|
|
|
|
def test_clear_specific_key(self):
|
|
tracker = PolicyAccessTracker()
|
|
tracker.record_access("allowlist", "chat_1", True)
|
|
tracker.record_access("open", "chat_2", True)
|
|
tracker.clear("allowlist")
|
|
assert len(tracker.get_history("allowlist")) == 0
|
|
assert len(tracker.get_history("open")) == 1
|
|
|
|
def test_clear_all(self):
|
|
tracker = PolicyAccessTracker()
|
|
tracker.record_access("allowlist", "chat_1", True)
|
|
tracker.clear()
|
|
assert len(tracker.get_history("allowlist")) == 0
|
|
|
|
def test_empty_history(self):
|
|
tracker = PolicyAccessTracker()
|
|
assert tracker.get_history("nonexistent") == []
|
|
|
|
def test_count_recent_empty(self):
|
|
tracker = PolicyAccessTracker()
|
|
allowed, denied = tracker.count_recent("empty")
|
|
assert allowed == 0
|
|
assert denied == 0
|
|
|
|
|
|
class TestFeishuPolicyMatcher:
|
|
def test_open_policy_allows_all(self):
|
|
matcher = FeishuPolicyMatcher({"groupPolicy": "open"})
|
|
allowed, _ = matcher.check_chat_access("any_chat", "group")
|
|
assert allowed is True
|
|
|
|
def test_allowlist_matched(self):
|
|
matcher = FeishuPolicyMatcher({"groupPolicy": "allowlist", "allowFrom": ["oc_123", "oc_456"]})
|
|
allowed, _ = matcher.check_chat_access("oc_123", "group")
|
|
assert allowed is True
|
|
|
|
def test_allowlist_not_matched(self):
|
|
matcher = FeishuPolicyMatcher({"groupPolicy": "allowlist", "allowFrom": ["oc_123"]})
|
|
allowed, msg = matcher.check_chat_access("oc_999", "group")
|
|
assert allowed is False
|
|
assert len(msg) > 0
|
|
|
|
def test_allowlist_empty(self):
|
|
matcher = FeishuPolicyMatcher({"groupPolicy": "allowlist", "allowFrom": []})
|
|
allowed, _ = matcher.check_chat_access("oc_123", "group")
|
|
assert allowed is False
|
|
|
|
def test_allowlist_wildcard(self):
|
|
matcher = FeishuPolicyMatcher({"groupPolicy": "allowlist", "allowFrom": ["oc_*"]})
|
|
allowed, _ = matcher.check_chat_access("oc_anything", "group")
|
|
assert allowed is True
|
|
|
|
def test_allowlist_question_mark_wildcard(self):
|
|
matcher = FeishuPolicyMatcher({"groupPolicy": "allowlist", "allowFrom": ["oc_???"]})
|
|
allowed, _ = matcher.check_chat_access("oc_abc", "group")
|
|
assert allowed is True
|
|
|
|
def test_disabled_policy(self):
|
|
matcher = FeishuPolicyMatcher({"groupPolicy": "disabled"})
|
|
allowed, _ = matcher.check_chat_access("oc_123", "group")
|
|
assert allowed is False
|
|
|
|
def test_blocklist_takes_priority(self):
|
|
matcher = FeishuPolicyMatcher(
|
|
{"groupPolicy": "open", "blockFrom": ["oc_blocked"], "allowFrom": ["oc_blocked"]}
|
|
)
|
|
allowed, _ = matcher.check_chat_access("oc_blocked", "group")
|
|
assert allowed is False
|
|
|
|
def test_dm_policy_for_direct_chat(self):
|
|
matcher = FeishuPolicyMatcher({"dmPolicy": "open", "groupPolicy": "disabled"})
|
|
allowed, _ = matcher.check_chat_access("ou_123", "direct")
|
|
assert allowed is True
|
|
|
|
def test_dm_policy_pairing(self):
|
|
matcher = FeishuPolicyMatcher({"dmPolicy": "pairing"})
|
|
allowed, _ = matcher.check_chat_access("ou_123", "direct")
|
|
assert allowed is False
|
|
|
|
def test_custom_deny_message(self):
|
|
matcher = FeishuPolicyMatcher(
|
|
{"groupPolicy": "disabled", "denyMessage": "自定义拒绝消息"}
|
|
)
|
|
_, msg = matcher.check_chat_access("oc_123", "group")
|
|
assert msg == "自定义拒绝消息"
|
|
|
|
def test_resolve_require_mention_global(self):
|
|
matcher = FeishuPolicyMatcher({"requireMention": True})
|
|
assert matcher.resolve_require_mention("oc_123") is True
|
|
|
|
def test_resolve_require_mention_group_override(self):
|
|
matcher = FeishuPolicyMatcher(
|
|
{"requireMention": True, "groups": {"oc_123": {"requireMention": False}}}
|
|
)
|
|
assert matcher.resolve_require_mention("oc_123") is False
|
|
|
|
def test_resolve_group_config_empty(self):
|
|
matcher = FeishuPolicyMatcher({})
|
|
assert matcher.resolve_group_config("unknown") == {}
|
|
|
|
def test_resolve_group_config_found(self):
|
|
matcher = FeishuPolicyMatcher({"groups": {"oc_123": {"threadOnly": True}}})
|
|
cfg = matcher.resolve_group_config("oc_123")
|
|
assert cfg["threadOnly"] is True
|
|
|
|
def test_get_access_history(self):
|
|
matcher = FeishuPolicyMatcher({"groupPolicy": "open"})
|
|
matcher.check_chat_access("chat_1", "group")
|
|
history = matcher.get_access_history()
|
|
assert len(history) >= 1
|
|
|
|
def test_reset_tracker(self):
|
|
matcher = FeishuPolicyMatcher({"groupPolicy": "open"})
|
|
matcher.check_chat_access("chat_1", "group")
|
|
matcher.reset_tracker()
|
|
assert matcher.get_access_history() == []
|
|
|
|
def test_match_pattern_exact(self):
|
|
assert FeishuPolicyMatcher._match_pattern("exact", "exact") is True
|
|
assert FeishuPolicyMatcher._match_pattern("exact", "different") is False
|
|
|
|
def test_match_pattern_no_wildcard_no_match(self):
|
|
assert FeishuPolicyMatcher._match_pattern("abc123", "xyz789") is False
|
|
|
|
def test_unknown_policy_defaults_deny(self):
|
|
matcher = FeishuPolicyMatcher({"groupPolicy": "invalid_policy"})
|
|
allowed, _ = matcher.check_chat_access("oc_123", "group")
|
|
assert allowed is False
|
|
|
|
|
|
# ============================================================
|
|
# sequential.py
|
|
# ============================================================
|
|
class TestFeishuSequentialQueue:
|
|
@pytest.mark.asyncio
|
|
async def test_run_sequential_order(self):
|
|
queue = FeishuSequentialQueue(timeout_s=5)
|
|
results = []
|
|
|
|
async def task(val):
|
|
results.append(val)
|
|
|
|
await queue.run_sequential("key1", task(1))
|
|
await queue.run_sequential("key1", task(2))
|
|
assert results == [1, 2]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_different_keys_concurrent(self):
|
|
queue = FeishuSequentialQueue(timeout_s=5)
|
|
results = []
|
|
|
|
async def task(val):
|
|
await asyncio.sleep(0.01)
|
|
results.append(val)
|
|
|
|
await asyncio.gather(
|
|
queue.run_sequential("key1", task(1)),
|
|
queue.run_sequential("key2", task(2)),
|
|
)
|
|
assert len(results) == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_release_unlocks(self):
|
|
queue = FeishuSequentialQueue(timeout_s=5)
|
|
await queue.acquire("key_release")
|
|
queue.release("key_release")
|
|
|
|
executed = False
|
|
|
|
async def task():
|
|
nonlocal executed
|
|
executed = True
|
|
|
|
await asyncio.wait_for(queue.run_sequential("key_release", task()), timeout=1)
|
|
assert executed is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_remove_releases_lock(self):
|
|
queue = FeishuSequentialQueue(timeout_s=5)
|
|
await queue.acquire("key_remove")
|
|
queue.remove("key_remove")
|
|
|
|
executed = False
|
|
|
|
async def task():
|
|
nonlocal executed
|
|
executed = True
|
|
|
|
await asyncio.wait_for(queue.run_sequential("key_remove", task()), timeout=1)
|
|
assert executed is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_clear_releases_all(self):
|
|
queue = FeishuSequentialQueue(timeout_s=5)
|
|
await queue.acquire("key1")
|
|
await queue.acquire("key2")
|
|
queue.clear()
|
|
|
|
executed = 0
|
|
|
|
async def task():
|
|
nonlocal executed
|
|
executed += 1
|
|
|
|
await asyncio.wait_for(queue.run_sequential("key1", task()), timeout=1)
|
|
await asyncio.wait_for(queue.run_sequential("key2", task()), timeout=1)
|
|
assert executed == 2
|
|
|
|
|
|
# ============================================================
|
|
# subagent.py
|
|
# ============================================================
|
|
class TestFeishuSubAgentManager:
|
|
def test_start_sub_agent(self):
|
|
mgr = FeishuSubAgentManager()
|
|
lifecycle = mgr.start_sub_agent("agent_1", "chat_1")
|
|
assert lifecycle.agent_id == "agent_1"
|
|
assert lifecycle.parent_chat_id == "chat_1"
|
|
assert lifecycle.status == "active"
|
|
assert lifecycle.created_at > 0
|
|
|
|
def test_complete_sub_agent(self):
|
|
mgr = FeishuSubAgentManager()
|
|
mgr.start_sub_agent("agent_1", "chat_1")
|
|
lifecycle = mgr.complete_sub_agent("agent_1", result={"output": "done"})
|
|
assert lifecycle is not None
|
|
assert lifecycle.status == "completed"
|
|
assert lifecycle.result.get("output") == "done"
|
|
|
|
def test_complete_nonexistent(self):
|
|
mgr = FeishuSubAgentManager()
|
|
assert mgr.complete_sub_agent("nonexistent") is None
|
|
|
|
def test_fail_sub_agent(self):
|
|
mgr = FeishuSubAgentManager()
|
|
mgr.start_sub_agent("agent_1", "chat_1")
|
|
lifecycle = mgr.fail_sub_agent("agent_1", "timeout")
|
|
assert lifecycle is not None
|
|
assert lifecycle.status == "failed"
|
|
assert lifecycle.error == "timeout"
|
|
|
|
def test_end_sub_agent(self):
|
|
mgr = FeishuSubAgentManager()
|
|
mgr.start_sub_agent("agent_1", "chat_1")
|
|
lifecycle = mgr.end_sub_agent("agent_1")
|
|
assert lifecycle is not None
|
|
assert lifecycle.status == "ended"
|
|
assert mgr.get_sub_agent("agent_1") is None
|
|
|
|
def test_get_sub_agent(self):
|
|
mgr = FeishuSubAgentManager()
|
|
mgr.start_sub_agent("agent_1", "chat_1")
|
|
found = mgr.get_sub_agent("agent_1")
|
|
assert found is not None
|
|
assert found.agent_id == "agent_1"
|
|
|
|
def test_list_active(self):
|
|
mgr = FeishuSubAgentManager()
|
|
mgr.start_sub_agent("agent_1", "chat_1")
|
|
mgr.start_sub_agent("agent_2", "chat_1")
|
|
mgr.complete_sub_agent("agent_2")
|
|
active = mgr.list_active()
|
|
assert len(active) == 1
|
|
assert active[0].agent_id == "agent_1"
|
|
|
|
def test_list_by_parent(self):
|
|
mgr = FeishuSubAgentManager()
|
|
mgr.start_sub_agent("agent_1", "chat_A")
|
|
mgr.start_sub_agent("agent_2", "chat_B")
|
|
mgr.start_sub_agent("agent_3", "chat_A")
|
|
by_parent = mgr.list_by_parent("chat_A")
|
|
assert len(by_parent) == 2
|
|
assert {s.agent_id for s in by_parent} == {"agent_1", "agent_3"}
|
|
|
|
def test_max_concurrent_eviction(self):
|
|
mgr = FeishuSubAgentManager(max_concurrent=3)
|
|
mgr.start_sub_agent("agent_1", "chat_1")
|
|
mgr.start_sub_agent("agent_2", "chat_1")
|
|
mgr.start_sub_agent("agent_3", "chat_1")
|
|
mgr.start_sub_agent("agent_4", "chat_1")
|
|
assert mgr.get_sub_agent("agent_1") is None
|
|
assert mgr.get_sub_agent("agent_4") is not None
|
|
|
|
def test_clean_completed(self):
|
|
mgr = FeishuSubAgentManager()
|
|
mgr.start_sub_agent("agent_1", "chat_1")
|
|
mgr.complete_sub_agent("agent_1")
|
|
lifecycle = mgr.get_sub_agent("agent_1")
|
|
assert lifecycle is not None
|
|
lifecycle.completed_at = time.monotonic() - 3600
|
|
removed = mgr.clean_completed(max_age_s=0)
|
|
assert removed == 1
|
|
assert mgr.get_sub_agent("agent_1") is None
|
|
|
|
def test_clean_not_completed(self):
|
|
mgr = FeishuSubAgentManager()
|
|
mgr.start_sub_agent("agent_1", "chat_1")
|
|
removed = mgr.clean_completed(max_age_s=3600)
|
|
assert removed == 0
|
|
assert mgr.get_sub_agent("agent_1") is not None
|
|
|
|
def test_clear(self):
|
|
mgr = FeishuSubAgentManager()
|
|
mgr.start_sub_agent("agent_1", "chat_1")
|
|
mgr.start_sub_agent("agent_2", "chat_1")
|
|
mgr.clear()
|
|
assert mgr.list_active() == []
|
|
|
|
def test_sub_agent_lifecycle_defaults(self):
|
|
lifecycle = SubAgentLifecycle(agent_id="test", parent_chat_id="chat")
|
|
assert lifecycle.status == "pending"
|
|
assert lifecycle.created_at == 0.0
|
|
assert lifecycle.result == {}
|
|
assert lifecycle.error == ""
|
|
|
|
|
|
# ============================================================
|
|
# reasoning.py
|
|
# ============================================================
|
|
class TestReasoning:
|
|
def test_build_card_with_reasoning(self):
|
|
card = build_reasoning_preview_card("答案内容", reasoning_text="推理过程")
|
|
markdown_elements = [e for e in card["elements"] if e.get("tag") == "markdown"]
|
|
assert len(markdown_elements) >= 1
|
|
content = markdown_elements[0]["content"]
|
|
assert "推理中" in content
|
|
assert "推理过程" in content
|
|
assert "推理完成" in content
|
|
assert "答案内容" in content
|
|
|
|
def test_build_card_without_reasoning(self):
|
|
card = build_reasoning_preview_card("答案内容", reasoning_text="")
|
|
markdown_elements = [e for e in card["elements"] if e.get("tag") == "markdown"]
|
|
assert len(markdown_elements) >= 1
|
|
content = markdown_elements[0]["content"]
|
|
assert "推理中" not in content
|
|
|
|
def test_build_card_default_title(self):
|
|
card = build_reasoning_preview_card("内容")
|
|
assert card["header"]["title"]["content"] == "AI 助手"
|
|
|
|
|
|
# ============================================================
|
|
# commands.py
|
|
# ============================================================
|
|
class TestCommands:
|
|
def test_parse_bot_menu_event_valid(self):
|
|
event = {
|
|
"event": {
|
|
"event_key": "/help",
|
|
"timestamp": "1234567890",
|
|
"operator": {"open_id": "ou_123"},
|
|
}
|
|
}
|
|
result = parse_bot_menu_event(event)
|
|
assert result is not None
|
|
assert result["command"] == "/help"
|
|
assert result["open_id"] == "ou_123"
|
|
|
|
def test_parse_bot_menu_event_no_event_key(self):
|
|
event = {"event": {"operator": {"open_id": "ou_123"}}}
|
|
result = parse_bot_menu_event(event)
|
|
assert result is None
|
|
|
|
def test_parse_bot_menu_event_empty_operator(self):
|
|
event = {"event": {"event_key": "/help", "operator": {}}}
|
|
result = parse_bot_menu_event(event)
|
|
assert result is not None
|
|
assert result["open_id"] == ""
|
|
|
|
def test_build_synthetic_message(self):
|
|
from yuxi.channels.models import ChannelType
|
|
|
|
msg = build_synthetic_message("feishu", ChannelType.FEISHU, "ou_123", "chat_456", "help")
|
|
assert msg.event_type == EventType.BOT_MENU
|
|
assert msg.content == "/help"
|
|
assert msg.identity.channel_user_id == "ou_123"
|
|
assert msg.identity.channel_chat_id == "chat_456"
|
|
assert msg.metadata["source"] == "bot_menu"
|
|
|
|
|
|
# ============================================================
|
|
# reply_dispatcher.py
|
|
# ============================================================
|
|
class TestReplyDispatcher:
|
|
def test_should_use_card_with_buttons(self):
|
|
assert should_use_card("short", buttons=[{"text": "OK"}]) is True
|
|
|
|
def test_should_use_card_long_content(self):
|
|
long_text = "x" * 2000
|
|
assert should_use_card(long_text) is True
|
|
|
|
def test_should_use_card_code_block(self):
|
|
assert should_use_card("```python\nprint('hello')\n```") is True
|
|
|
|
def test_should_use_card_table(self):
|
|
assert should_use_card("| col1 | col2 |\n|------|------|") is True
|
|
|
|
def test_should_use_card_short_plain(self):
|
|
assert should_use_card("Hello world") is False
|
|
|
|
def test_dispatch_render_card_mode(self):
|
|
assert dispatch_render("hello", render_mode="card") == "card"
|
|
|
|
def test_dispatch_render_raw_mode(self):
|
|
assert dispatch_render("hello", render_mode="raw") == "text"
|
|
|
|
def test_dispatch_render_auto_long(self):
|
|
assert dispatch_render("x" * 2000, render_mode="auto") == "card"
|
|
|
|
def test_dispatch_render_auto_short(self):
|
|
assert dispatch_render("Hello", render_mode="auto") == "text"
|
|
|
|
def test_extract_urls_multiple(self):
|
|
urls = extract_urls("a https://a.com b https://b.com c https://c.com d https://d.com e https://e.com f https://f.com")
|
|
assert len(urls) == 5
|
|
|
|
def test_extract_urls_no_duplicates(self):
|
|
urls = extract_urls("https://a.com and https://a.com")
|
|
assert urls == ["https://a.com"]
|
|
|
|
|
|
# ============================================================
|
|
# cards.py - 额外覆盖
|
|
# ============================================================
|
|
class TestCardsExtended:
|
|
def test_resolve_template_color_known(self):
|
|
assert resolve_template_color("green") == "green"
|
|
assert resolve_template_color("red") == "red"
|
|
|
|
def test_resolve_template_color_tone(self):
|
|
assert resolve_template_color(tone="danger") == "red"
|
|
assert resolve_template_color(tone="warning") == "orange"
|
|
assert resolve_template_color(tone="success") == "green"
|
|
|
|
def test_resolve_template_color_default(self):
|
|
assert resolve_template_color() == "blue"
|
|
assert resolve_template_color("unknown") == "blue" if "unknown" not in CARD_TEMPLATE_COLORS else "unknown"
|
|
|
|
def test_resolve_selector_tag_valid(self):
|
|
assert resolve_selector_tag("select_static") == "select_static"
|
|
assert resolve_selector_tag("date_picker") == "date_picker"
|
|
assert resolve_selector_tag("overflow") == "overflow"
|
|
|
|
def test_resolve_selector_tag_invalid(self):
|
|
assert resolve_selector_tag("invalid_tag") == "select_static"
|
|
|
|
def test_make_select_static_full(self):
|
|
sel = make_select_static(
|
|
placeholder="请选择",
|
|
options=[{"text": "选项A", "value": "a"}, {"text": "选项B", "value": "b"}],
|
|
value="a",
|
|
initial_option="选项A",
|
|
)
|
|
assert sel["tag"] == "select_static"
|
|
assert sel["placeholder"]["content"] == "请选择"
|
|
assert len(sel["options"]) == 2
|
|
assert sel["value"] == "a"
|
|
assert sel["initial_option"] == "选项A"
|
|
|
|
def test_make_select_static_minimal(self):
|
|
sel = make_select_static()
|
|
assert sel["tag"] == "select_static"
|
|
|
|
def test_make_select_person_full(self):
|
|
sel = make_select_person(placeholder="选择成员", value="ou_123")
|
|
assert sel["tag"] == "select_person"
|
|
assert sel["placeholder"]["content"] == "选择成员"
|
|
assert sel["value"] == "ou_123"
|
|
|
|
def test_make_date_picker_minimal(self):
|
|
sel = make_date_picker()
|
|
assert sel["tag"] == "date_picker"
|
|
|
|
def test_make_time_picker_full(self):
|
|
sel = make_time_picker(placeholder="选择时间", value="12:00", initial_time="12:00")
|
|
assert sel["tag"] == "time_picker"
|
|
assert sel["value"] == "12:00"
|
|
assert sel["initial_time"] == "12:00"
|
|
|
|
def test_make_datetime_picker_full(self):
|
|
sel = make_datetime_picker(
|
|
placeholder="选择日期时间", value="2026-05-10 12:00", initial_datetime="2026-05-10 12:00"
|
|
)
|
|
assert sel["tag"] == "datetime_picker"
|
|
assert sel["value"] == "2026-05-10 12:00"
|
|
|
|
def test_make_overflow_menu_full(self):
|
|
sel = make_overflow_menu(
|
|
options=[{"text": "编辑", "value": "edit"}, {"text": "删除", "value": "delete"}],
|
|
value="edit",
|
|
)
|
|
assert sel["tag"] == "overflow"
|
|
assert len(sel["options"]) == 2
|
|
assert sel["value"] == "edit"
|
|
|
|
def test_make_input_field_minimal(self):
|
|
field = make_input_field()
|
|
assert field["tag"] == "input"
|
|
|
|
def test_make_input_field_multiline(self):
|
|
field = make_input_field(placeholder="输入内容", value="test", multiline=True, max_length=500)
|
|
assert field["tag"] == "input"
|
|
assert field["placeholder"]["content"] == "输入内容"
|
|
assert field["value"] == "test"
|
|
assert field["multiline"] is True
|
|
assert field["max_length"] == 500
|
|
|
|
def test_make_checkbox_group(self):
|
|
group = make_checkbox_group(
|
|
options=[{"text": "选项1", "value": "1"}, {"text": "选项2", "value": "2"}],
|
|
values=["1"],
|
|
)
|
|
assert group["tag"] == "checkbox"
|
|
assert len(group["options"]) == 2
|
|
assert group["value"] == ["1"]
|
|
|
|
def test_make_image_element(self):
|
|
img = make_image_element("img_key_123", alt_text="测试图片")
|
|
assert img["tag"] == "img"
|
|
assert img["img_key"] == "img_key_123"
|
|
assert img["alt"]["content"] == "测试图片"
|
|
|
|
def test_is_post_format_requested_true(self):
|
|
assert is_post_format_requested({"use_post_format": True}) is True
|
|
assert is_post_format_requested({"usePostFormat": True}) is True
|
|
|
|
def test_is_post_format_requested_false(self):
|
|
assert is_post_format_requested(None) is False
|
|
assert is_post_format_requested({}) is False
|
|
assert is_post_format_requested({"other": True}) is False
|
|
|
|
def test_build_feishu_post_content(self):
|
|
result = build_feishu_post_content("Hello\n\nWorld")
|
|
assert "zh_cn" in result
|
|
assert "content" in result["zh_cn"]
|
|
paragraphs = result["zh_cn"]["content"]
|
|
assert len(paragraphs) > 0
|
|
|
|
def test_card_with_visible_to_operator(self):
|
|
card = build_feishu_card("secret", visible_to_operator=True)
|
|
assert card.get("config", {}).get("update_multi") is False
|
|
|
|
def test_card_with_context_text(self):
|
|
card = build_feishu_card("content", context_text="上下文信息")
|
|
note_elements = [e for e in card["elements"] if e.get("tag") == "note"]
|
|
assert len(note_elements) >= 1
|
|
|
|
def test_card_with_dividers(self):
|
|
card = build_feishu_card("content", dividers=3)
|
|
hr_elements = [e for e in card["elements"] if e.get("tag") == "hr"]
|
|
assert len(hr_elements) == 3
|
|
|
|
def test_card_with_selectors(self):
|
|
card = build_feishu_card(
|
|
"content",
|
|
selectors=[
|
|
{"tag": "date_picker", "placeholder": "选择日期", "value": "2026-01-01"}
|
|
],
|
|
)
|
|
selector_elements = [e for e in card["elements"] if e.get("tag") == "date_picker"]
|
|
assert len(selector_elements) == 1
|
|
|
|
|
|
# ============================================================
|
|
# session.py - 额外覆盖
|
|
# ============================================================
|
|
class TestSessionExtended:
|
|
def test_session_mode_enum(self):
|
|
assert SessionMode.RAW == "raw"
|
|
assert SessionMode.THREAD == "thread"
|
|
assert SessionMode.CHAT_RAW == "chat_raw"
|
|
assert SessionMode.CHAT_RESOLVE == "chat_resolve"
|
|
|
|
def test_generate_chat_id_raw_mode(self):
|
|
chat_id = generate_feishu_chat_id("ou_123", "direct", mode=SessionMode.RAW)
|
|
assert chat_id == "ou_123"
|
|
|
|
def test_generate_chat_id_chat_raw_mode(self):
|
|
chat_id = generate_feishu_chat_id("oc_456", "group", mode=SessionMode.CHAT_RAW)
|
|
assert chat_id == "feishu:group:oc_456"
|
|
|
|
def test_generate_chat_id_thread_mode(self):
|
|
chat_id = generate_feishu_chat_id("oc_456", "group", mode=SessionMode.THREAD, root_id="om_root")
|
|
assert chat_id == "feishu:thread:om_root"
|
|
|
|
def test_generate_chat_id_group_topic_sender_scope(self):
|
|
chat_id = generate_feishu_chat_id(
|
|
"oc_456", "group", root_id="om_root", sender_id="ou_sender", scope="group_topic_sender"
|
|
)
|
|
assert chat_id == "feishu:group_topic_sender:om_root:oc_456:ou_sender"
|
|
|
|
def test_generate_chat_id_group_topic_scope(self):
|
|
chat_id = generate_feishu_chat_id("oc_456", "group", root_id="om_root", scope="group_topic")
|
|
assert chat_id == "feishu:group_topic:om_root:oc_456"
|
|
|
|
def test_generate_chat_id_group_sender_scope(self):
|
|
chat_id = generate_feishu_chat_id("oc_456", "group", sender_id="ou_sender", scope="group_sender")
|
|
assert chat_id == "feishu:group_sender:oc_456:ou_sender"
|
|
|
|
def test_generate_chat_id_direct_sender_scope(self):
|
|
chat_id = generate_feishu_chat_id("ou_123", "direct", sender_id="ou_sender", scope="group_sender")
|
|
assert chat_id == "feishu:dm:ou_123:ou_sender"
|
|
|
|
def test_generate_chat_id_unknown_type(self):
|
|
chat_id = generate_feishu_chat_id("bt_unknown", "unknown")
|
|
assert chat_id.startswith("feishu:dm:")
|
|
|
|
def test_looks_like_feishu_id_valid(self):
|
|
assert looks_like_feishu_id("ou_123") is True
|
|
assert looks_like_feishu_id("oc_456") is True
|
|
assert looks_like_feishu_id("on_789") is True
|
|
assert looks_like_feishu_id("om_000") is True
|
|
assert looks_like_feishu_id("od_111") is True
|
|
assert looks_like_feishu_id("og_222") is True
|
|
assert looks_like_feishu_id("tg_333") is True
|
|
|
|
def test_looks_like_feishu_id_invalid(self):
|
|
assert looks_like_feishu_id("") is False
|
|
assert looks_like_feishu_id("not_a_feishu_id") is False
|
|
assert looks_like_feishu_id(123) is False
|
|
|
|
def test_normalize_feishu_target_dm(self):
|
|
assert normalize_feishu_target("ou_123") == "feishu:dm:ou_123"
|
|
assert normalize_feishu_target("on_456") == "feishu:dm:on_456"
|
|
|
|
def test_normalize_feishu_target_group(self):
|
|
assert normalize_feishu_target("oc_789") == "feishu:group:oc_789"
|
|
assert normalize_feishu_target("om_000") == "feishu:group:om_000"
|
|
|
|
def test_normalize_feishu_target_already_normalized(self):
|
|
assert normalize_feishu_target("feishu:dm:ou_123") == "feishu:dm:ou_123"
|
|
|
|
def test_normalize_feishu_target_lark_prefix(self):
|
|
assert normalize_feishu_target("lark:dm:ou_123") == "feishu:dm:ou_123"
|
|
|
|
def test_normalize_feishu_target_feishu_protocol(self):
|
|
assert normalize_feishu_target("feishu://dm/ou_123") == "feishu://dm/ou_123"
|
|
|
|
def test_normalize_feishu_target_empty(self):
|
|
assert normalize_feishu_target("") == ""
|
|
|
|
def test_normalize_feishu_target_unknown(self):
|
|
assert normalize_feishu_target("unknown_id") == "unknown_id"
|
|
|
|
def test_normalize_acp_conv_id_feishu_prefix(self):
|
|
assert normalize_feishu_acp_conversation_id("feishu:dm:ou_123") == "feishu:dm:ou_123"
|
|
|
|
def test_normalize_acp_conv_id_dm(self):
|
|
assert normalize_feishu_acp_conversation_id("ou_123") == "feishu:dm:ou_123"
|
|
|
|
def test_normalize_acp_conv_id_group(self):
|
|
assert normalize_feishu_acp_conversation_id("oc_456") == "feishu:group:oc_456"
|
|
|
|
def test_match_feishu_acp_conversation(self):
|
|
assert match_feishu_acp_conversation("ou_123", "feishu:dm:ou_*") is True
|
|
assert match_feishu_acp_conversation("ou_123", "feishu:group:*") is False
|
|
|
|
def test_resolve_feishu_command_conversation_dm(self):
|
|
result = resolve_feishu_command_conversation("oc_chat", "ou_user", scope="group")
|
|
assert result == "feishu:dm:ou_user"
|
|
|
|
def test_resolve_feishu_command_conversation_group(self):
|
|
result = resolve_feishu_command_conversation("oc_chat", "oc_group")
|
|
assert result == "feishu:group:oc_chat"
|
|
|
|
|
|
class TestSessionPersistence:
|
|
def test_get_set_delete(self):
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
|
path = f.name
|
|
try:
|
|
p = SessionPersistence(path)
|
|
p.set("session_1", "key_a", "value_a")
|
|
assert p.get("session_1", "key_a") == "value_a"
|
|
p.delete("session_1", "key_a")
|
|
assert p.get("session_1", "key_a") is None
|
|
finally:
|
|
try:
|
|
os.unlink(path)
|
|
except OSError:
|
|
pass
|
|
|
|
def test_delete_session(self):
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
|
path = f.name
|
|
try:
|
|
p = SessionPersistence(path)
|
|
p.set("session_1", "key_a", "value_a")
|
|
p.delete("session_1")
|
|
assert p.get("session_1", "key_a") is None
|
|
finally:
|
|
try:
|
|
os.unlink(path)
|
|
except OSError:
|
|
pass
|
|
|
|
def test_no_persist_path(self):
|
|
p = SessionPersistence()
|
|
p.set("session_1", "key_a", "value_a")
|
|
assert p.get("session_1", "key_a") == "value_a"
|
|
|
|
def test_load_invalid_json(self):
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
|
f.write("not valid json")
|
|
path = f.name
|
|
try:
|
|
p = SessionPersistence(path)
|
|
assert p._data == {}
|
|
finally:
|
|
try:
|
|
os.unlink(path)
|
|
except OSError:
|
|
pass
|
|
|
|
def test_cleanup_expired(self):
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
|
path = f.name
|
|
try:
|
|
p = SessionPersistence(path)
|
|
p._data["old_session"] = {"_last_active": 0}
|
|
p._data["recent_session"] = {"_last_active": time.time()}
|
|
p.cleanup_expired(max_age_s=3600)
|
|
assert "old_session" not in p._data
|
|
assert "recent_session" in p._data
|
|
finally:
|
|
try:
|
|
os.unlink(path)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
# ============================================================
|
|
# verify.py - 额外覆盖
|
|
# ============================================================
|
|
class TestVerifyExtended:
|
|
def test_decrypt_body_no_key(self):
|
|
result = decrypt_feishu_body(b"encrypted", "")
|
|
assert result is None
|
|
|
|
def test_verify_and_decrypt_plain(self):
|
|
body = b'{"challenge":"test"}'
|
|
ts = "1234567890"
|
|
nonce = "abc"
|
|
key = ""
|
|
raw = f"{ts}{nonce}{key}".encode() + body
|
|
sig = hashlib.sha256(raw).hexdigest()
|
|
headers = {
|
|
"X-Lark-Request-Timestamp": ts,
|
|
"X-Lark-Request-Nonce": nonce,
|
|
"X-Lark-Signature": sig,
|
|
}
|
|
ok, result_body, status = verify_and_decrypt_webhook(headers, body, key, "test_ip")
|
|
assert ok is True
|
|
assert status == "ok"
|
|
assert result_body == body
|
|
|
|
def test_verify_and_decrypt_rate_limited(self):
|
|
for _ in range(1000):
|
|
check_webhook_rate_limit("rate_limit_test_ip")
|
|
body = b"test"
|
|
ts = "123"
|
|
nonce = "abc"
|
|
key = "secret"
|
|
raw = f"{ts}{nonce}{key}".encode() + body
|
|
sig = hashlib.sha256(raw).hexdigest()
|
|
headers = {
|
|
"X-Lark-Request-Timestamp": ts,
|
|
"X-Lark-Request-Nonce": nonce,
|
|
"X-Lark-Signature": sig,
|
|
}
|
|
ok, result_body, status = verify_and_decrypt_webhook(headers, body, key, "rate_limit_test_ip")
|
|
assert ok is False
|
|
assert status == "rate_limited"
|
|
|
|
def test_verify_and_decrypt_signature_mismatch(self):
|
|
headers = {
|
|
"X-Lark-Request-Timestamp": "123",
|
|
"X-Lark-Request-Nonce": "abc",
|
|
"X-Lark-Signature": "wrong_sig",
|
|
}
|
|
ok, _, status = verify_and_decrypt_webhook(headers, b"test", "secret", "test_ip2")
|
|
assert ok is False
|
|
assert status == "signature_mismatch"
|
|
|
|
|
|
# ============================================================
|
|
# formatter.py - 额外覆盖
|
|
# ============================================================
|
|
class TestFormatterExtended:
|
|
def test_format_outbound_message_text(self):
|
|
result = format_outbound_message("Hello World")
|
|
assert "text" in result
|
|
assert result["text"] == "Hello World"
|
|
|
|
def test_format_outbound_message_post(self):
|
|
result = format_outbound_message("Hello", metadata={"use_post_format": True})
|
|
assert "zh_cn" in result
|
|
|
|
def test_format_outbound_card_full(self):
|
|
card = format_outbound_card(
|
|
"Content",
|
|
title="标题",
|
|
buttons=[{"text": "OK", "action": "confirm"}],
|
|
streaming=True,
|
|
url_unfurl=["https://example.com"],
|
|
images=["img_key_1"],
|
|
files=[{"name": "file.pdf", "url": "https://example.com/file.pdf"}],
|
|
note="备注",
|
|
template="green",
|
|
tone="success",
|
|
context_text="上下文",
|
|
dividers=1,
|
|
selectors=[{"tag": "date_picker"}],
|
|
)
|
|
assert "header" in card
|
|
assert "elements" in card
|
|
|
|
def test_format_outbound_diagnostic_card(self):
|
|
card = format_outbound_diagnostic_card("诊断标题", {"key1": "value1", "key2": "value2"})
|
|
markdown_elements = [e for e in card["elements"] if e.get("tag") == "markdown"]
|
|
assert len(markdown_elements) >= 1
|
|
content = markdown_elements[0]["content"]
|
|
assert "诊断标题" in content
|
|
assert "key1" in content
|
|
assert "key2" in content
|
|
|
|
def test_make_error_card(self):
|
|
card = make_error_card("错误标题", "错误详情")
|
|
assert card["header"]["template"] == "red"
|
|
markdown_elements = [e for e in card["elements"] if e.get("tag") == "markdown"]
|
|
assert len(markdown_elements) >= 1
|
|
|
|
def test_format_outbound_with_metadata_buttons(self):
|
|
result = format_outbound("Hello", metadata={"buttons": [{"text": "OK"}]})
|
|
assert result["buttons"] == [{"text": "OK"}]
|
|
|
|
def test_format_outbound_with_metadata_thread_id(self):
|
|
result = format_outbound("Hello", metadata={"thread_id": "om_thread_123"})
|
|
assert result["thread_id"] == "om_thread_123"
|
|
|
|
def test_format_outbound_basic(self):
|
|
result = format_outbound("Hello")
|
|
assert result["content"] == "Hello"
|
|
|
|
def test_truncate_long_text(self):
|
|
long_text = "x" * 40000
|
|
result = format_outbound_message(long_text)
|
|
text = result.get("text", "")
|
|
assert len(text) <= 30000
|
|
|
|
|
|
# ============================================================
|
|
# normalizer.py - 额外覆盖
|
|
# ============================================================
|
|
class TestNormalizerExtended:
|
|
def test_map_event_type_all(self):
|
|
assert map_event_type("im.message.receive_v1") == EventType.MESSAGE_RECEIVED
|
|
assert map_event_type("im.message.updated_v1") == EventType.MESSAGE_UPDATED
|
|
assert map_event_type("im.message.deleted_v1") == EventType.MESSAGE_DELETED
|
|
assert map_event_type("im.message.message_read_v1") == EventType.READ_RECEIPT
|
|
assert map_event_type("im.message.reaction.created_v1") == EventType.REACTION_ADDED
|
|
assert map_event_type("im.message.reaction.deleted_v1") == EventType.REACTION_REMOVED
|
|
assert map_event_type("im.chat.member.bot.added_v1") == EventType.BOT_ADDED
|
|
assert map_event_type("im.chat.member.bot.deleted_v1") == EventType.BOT_REMOVED
|
|
assert map_event_type("card.action.trigger") == EventType.CARD_ACTION
|
|
assert map_event_type("card.action.trigger_v1") == EventType.CARD_ACTION
|
|
assert map_event_type("application.bot.menu_v6") == EventType.BOT_MENU
|
|
|
|
def test_map_msg_type_all(self):
|
|
assert map_msg_type("text") == MessageType.TEXT
|
|
assert map_msg_type("image") == MessageType.IMAGE
|
|
assert map_msg_type("file") == MessageType.FILE
|
|
assert map_msg_type("audio") == MessageType.AUDIO
|
|
assert map_msg_type("video") == MessageType.VIDEO
|
|
assert map_msg_type("post") == MessageType.TEXT
|
|
assert map_msg_type("interactive") == MessageType.CARD
|
|
assert map_msg_type("unknown") == MessageType.TEXT
|
|
|
|
def test_strip_at_mentions(self):
|
|
text = '<at user_id="ou_123">@某人</at> 你好'
|
|
result = strip_at_mentions(text)
|
|
assert "@某人" not in result
|
|
assert "你好" in result
|
|
|
|
def test_strip_at_mentions_replace_with(self):
|
|
text = '<at user_id="ou_123">@某人</at> 你好'
|
|
result = strip_at_mentions(text, replace_with="@某人 ")
|
|
assert result.startswith("@某人 ")
|
|
|
|
def test_strip_at_mentions_empty(self):
|
|
assert strip_at_mentions("") == ""
|
|
assert strip_at_mentions(None) is None
|
|
|
|
def test_extract_mentioned_user_ids(self):
|
|
text = '<at user_id="ou_123">@A</at> <at user_id="ou_456">@B</at>'
|
|
ids = extract_mentioned_user_ids(text)
|
|
assert ids == ["ou_123", "ou_456"]
|
|
|
|
def test_extract_mentioned_user_ids_empty(self):
|
|
assert extract_mentioned_user_ids("") == []
|
|
assert extract_mentioned_user_ids(None) == []
|
|
|
|
def test_get_i18n_text_preferred(self):
|
|
content = {"i18n": {"zh_cn": "中文", "en_us": "English"}}
|
|
assert _get_i18n_text(content, prefer_lang="zh_cn") == "中文"
|
|
|
|
def test_get_i18n_text_fallback(self):
|
|
content = {"i18n": {"en_us": "English"}}
|
|
assert _get_i18n_text(content, prefer_lang="zh_cn") == "English"
|
|
|
|
def test_get_i18n_text_no_i18n(self):
|
|
content = {"content": "直接内容"}
|
|
assert _get_i18n_text(content) == "直接内容"
|
|
|
|
def test_get_i18n_text_first_fallback(self):
|
|
content = {"i18n": {"ja_jp": "日本語"}}
|
|
assert _get_i18n_text(content, prefer_lang="zh_cn") == "日本語"
|
|
|
|
def test_resolve_template_vars(self):
|
|
text = "Hello ${name}, {{age}} years old"
|
|
event = {"name": "Alice", "age": 25}
|
|
result = _resolve_template_vars(text, event)
|
|
assert "Alice" in result
|
|
assert "25" in result
|
|
|
|
def test_resolve_template_vars_from_message(self):
|
|
text = "${msg_type}"
|
|
event = {"message": {"msg_type": "text"}}
|
|
result = _resolve_template_vars(text, event)
|
|
assert result == "text"
|
|
|
|
def test_resolve_template_vars_none_value(self):
|
|
text = "${name}"
|
|
event = {"name": None}
|
|
result = _resolve_template_vars(text, event)
|
|
assert result == ""
|
|
|
|
def test_resolve_template_vars_unknown(self):
|
|
text = "${unknown_var}"
|
|
event = {}
|
|
result = _resolve_template_vars(text, event)
|
|
assert result == "${unknown_var}"
|
|
|
|
def test_escape_post_md(self):
|
|
assert _escape_post_md("hello*world*") == "hello\\*world\\*"
|
|
assert _escape_post_md("test[link](url)") != "test[link](url)"
|
|
|
|
def test_extract_interactive_text_title(self):
|
|
content = {"title": "卡片标题", "elements": []}
|
|
result = _extract_interactive_text(content)
|
|
assert "卡片标题" in result
|
|
|
|
def test_extract_interactive_text_header(self):
|
|
content = {"header": {"title": {"content": "头部标题"}}, "elements": []}
|
|
result = _extract_interactive_text(content)
|
|
assert "头部标题" in result
|
|
|
|
def test_extract_interactive_text_markdown(self):
|
|
content = {"elements": [{"tag": "markdown", "content": "**加粗**"}]}
|
|
result = _extract_interactive_text(content)
|
|
assert "**加粗**" in result
|
|
|
|
def test_extract_interactive_text_plain_text(self):
|
|
content = {"elements": [{"tag": "plain_text", "content": "普通文本"}]}
|
|
result = _extract_interactive_text(content)
|
|
assert "普通文本" in result
|
|
|
|
def test_extract_interactive_text_action(self):
|
|
content = {
|
|
"elements": [
|
|
{
|
|
"tag": "action",
|
|
"actions": [{"tag": "button", "value": {"text": {"content": "点击"}}}],
|
|
}
|
|
]
|
|
}
|
|
result = _extract_interactive_text(content)
|
|
assert "[点击]" in result
|
|
|
|
def test_extract_content_forwarded(self):
|
|
event = {
|
|
"message": {
|
|
"msg_type": "forwarded",
|
|
"content": json.dumps({"msg_type": "text", "content": {"text": "转发内容"}}),
|
|
"sender": {"sender_name": "张三"},
|
|
}
|
|
}
|
|
content = extract_content(event, EventType.MESSAGE_RECEIVED)
|
|
assert "转发" in content
|
|
assert "转发内容" in content
|
|
|
|
def test_extract_content_media(self):
|
|
event = {
|
|
"message": {
|
|
"msg_type": "media",
|
|
"content": json.dumps({"items": [{"msg_type": "image"}, {"msg_type": "file"}]}),
|
|
}
|
|
}
|
|
content = extract_content(event, EventType.MESSAGE_RECEIVED)
|
|
assert "媒体消息" in content
|
|
|
|
def test_extract_content_sticker(self):
|
|
event = {"message": {"msg_type": "sticker", "content": '{"file_key": "sticker_1"}'}}
|
|
content = extract_content(event, EventType.MESSAGE_RECEIVED)
|
|
assert content == "[贴纸]"
|
|
|
|
def test_mentions_with_no_mentions_list(self):
|
|
event = {"message": {"content": '{"text": "hello"}'}}
|
|
mentions = extract_mentions(event)
|
|
assert mentions.mentioned_user_ids == []
|
|
assert mentions.is_bot_mentioned is False
|
|
|
|
|
|
# ============================================================
|
|
# dedup.py - 额外覆盖
|
|
# ============================================================
|
|
class TestDedupExtended:
|
|
def test_make_key_no_event_message_ids(self):
|
|
event = {"data": "test", "type": "unknown"}
|
|
key = FeishuDedupStore._make_key(event)
|
|
assert len(key) == 32
|
|
|
|
def test_record_processing_state(self):
|
|
store = FeishuDedupStore()
|
|
event = {"event": {"event_id": "evt_001"}}
|
|
assert not store.has_processed(event)
|
|
store.record_processed(event)
|
|
assert store.has_processed(event)
|
|
|
|
def test_finalize_removes_from_processing(self):
|
|
store = FeishuDedupStore()
|
|
event = {"event": {"event_id": "evt_001"}}
|
|
store.record_processed(event)
|
|
store.finalize_processing(event)
|
|
assert store.has_processed(event)
|
|
|
|
def test_eviction_by_ttl(self):
|
|
store = FeishuDedupStore(ttl_s=0, max_entries=10)
|
|
event = {"event": {"event_id": "evt_ttl_test"}}
|
|
store.record_processed(event)
|
|
store.finalize_processing(event)
|
|
assert not store.has_processed(event)
|
|
|
|
def test_trim_to_max(self):
|
|
store = FeishuDedupStore(max_entries=3)
|
|
for i in range(5):
|
|
event = {"event": {"event_id": f"evt_{i}"}}
|
|
store.record_processed(event)
|
|
store.finalize_processing(event)
|
|
assert len(store) <= 3
|
|
|
|
def test_clear(self):
|
|
store = FeishuDedupStore()
|
|
for i in range(5):
|
|
event = {"event": {"event_id": f"evt_{i}"}}
|
|
store.record_processed(event)
|
|
store.finalize_processing(event)
|
|
count_before = len(store)
|
|
store.clear()
|
|
assert len(store) == 0
|
|
assert count_before > 0
|
|
|
|
|
|
# ============================================================
|
|
# sent_cache.py
|
|
# ============================================================
|
|
class TestFeishuSentCache:
|
|
@pytest.mark.asyncio
|
|
async def test_cache_and_get(self):
|
|
cache = None
|
|
from yuxi.channels.adapters.feishu.sent_cache import FeishuSentCache
|
|
cache = FeishuSentCache()
|
|
await cache.cache_sent("msg_1", "chat_1")
|
|
entry = await cache.get_sent("msg_1", "chat_1")
|
|
assert entry is not None
|
|
assert entry["message_id"] == "msg_1"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_nonexistent(self):
|
|
from yuxi.channels.adapters.feishu.sent_cache import FeishuSentCache
|
|
cache = FeishuSentCache()
|
|
entry = await cache.get_sent("nonexistent", "chat_1")
|
|
assert entry is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalidate(self):
|
|
from yuxi.channels.adapters.feishu.sent_cache import FeishuSentCache
|
|
cache = FeishuSentCache()
|
|
await cache.cache_sent("msg_1", "chat_1")
|
|
await cache.invalidate("msg_1", "chat_1")
|
|
entry = await cache.get_sent("msg_1", "chat_1")
|
|
assert entry is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_clear(self):
|
|
from yuxi.channels.adapters.feishu.sent_cache import FeishuSentCache
|
|
cache = FeishuSentCache()
|
|
await cache.cache_sent("msg_1", "chat_1")
|
|
await cache.cache_sent("msg_2", "chat_1")
|
|
await cache.clear()
|
|
assert await cache.get_sent("msg_1", "chat_1") is None
|
|
assert await cache.get_sent("msg_2", "chat_1") is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_expired_ttl(self):
|
|
from yuxi.channels.adapters.feishu.sent_cache import FeishuSentCache
|
|
cache = FeishuSentCache(ttl_s=0)
|
|
await cache.cache_sent("msg_1", "chat_1")
|
|
entry = await cache.get_sent("msg_1", "chat_1")
|
|
assert entry is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_max_entries_eviction(self):
|
|
from yuxi.channels.adapters.feishu.sent_cache import FeishuSentCache
|
|
cache = FeishuSentCache(max_entries=3)
|
|
await cache.cache_sent("msg_1", "chat_1")
|
|
await cache.cache_sent("msg_2", "chat_1")
|
|
await cache.cache_sent("msg_3", "chat_1")
|
|
await cache.cache_sent("msg_4", "chat_1")
|
|
assert await cache.get_sent("msg_1", "chat_1") is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_make_key_format(self):
|
|
from yuxi.channels.adapters.feishu.sent_cache import FeishuSentCache
|
|
key = FeishuSentCache._make_key("msg_123", "chat_456")
|
|
assert key == "chat_456:msg_123"
|
|
|
|
|
|
# ============================================================
|
|
# reactions.py
|
|
# ============================================================
|
|
class TestReactions:
|
|
def test_emoji_type_to_emoji_known(self):
|
|
assert emoji_type_to_emoji("THUMBSUP") == "👍"
|
|
assert emoji_type_to_emoji("HEART") == "❤️"
|
|
assert emoji_type_to_emoji("FIRE") == "🔥"
|
|
assert emoji_type_to_emoji("CHECK") == "✅"
|
|
|
|
def test_emoji_type_to_emoji_unknown(self):
|
|
assert emoji_type_to_emoji("CUSTOM_TYPE") == "CUSTOM_TYPE"
|
|
|
|
|
|
# ============================================================
|
|
# media.py - 额外覆盖
|
|
# ============================================================
|
|
class TestMediaExtended:
|
|
def test_resolve_media_kind_by_ext(self):
|
|
assert resolve_feishu_outbound_media_kind(filename="test.png") == "img"
|
|
assert resolve_feishu_outbound_media_kind(filename="test.mp3") == "stream"
|
|
assert resolve_feishu_outbound_media_kind(filename="test.mp4") == "mp4"
|
|
assert resolve_feishu_outbound_media_kind(filename="test.opus") == "opus"
|
|
|
|
def test_resolve_media_kind_by_mime(self):
|
|
assert resolve_feishu_outbound_media_kind(mime_type="image/jpeg") == "img"
|
|
assert resolve_feishu_outbound_media_kind(mime_type="audio/mpeg") == "stream"
|
|
|
|
def test_resolve_media_kind_default(self):
|
|
assert resolve_feishu_outbound_media_kind(filename="test.xyz") == "stream"
|
|
assert resolve_feishu_outbound_media_kind() == "stream"
|
|
|
|
def test_resolve_media_kind_document_ext(self):
|
|
assert resolve_feishu_outbound_media_kind(filename="test.pdf") == "stream"
|
|
assert resolve_feishu_outbound_media_kind(filename="test.csv") == "stream"
|
|
|
|
def test_sanitize_filename_normal(self):
|
|
assert sanitize_filename_for_upload("hello.txt") == "hello.txt"
|
|
|
|
def test_sanitize_filename_control_chars(self):
|
|
result = sanitize_filename_for_upload("test\x00file.txt")
|
|
assert "\x00" not in result
|
|
|
|
def test_sanitize_filename_empty_name(self):
|
|
result = sanitize_filename_for_upload(".txt")
|
|
assert "txt" in result.lower()
|
|
|
|
def test_validate_media_size_ok(self):
|
|
validate_media_size(b"x" * 100, max_mb=1)
|
|
|
|
def test_validate_media_size_exceeded(self):
|
|
with pytest.raises(MediaSizeError):
|
|
validate_media_size(b"x" * (2 * 1024 * 1024), max_mb=1)
|
|
|
|
def test_validate_image_size(self):
|
|
validate_image_size(b"x" * 512)
|
|
|
|
def test_validate_file_size(self):
|
|
validate_file_size(b"x" * 512)
|
|
|
|
def test_recover_utf8_no_need(self):
|
|
result = recover_utf8_filename_from_latin1_header("hello.txt")
|
|
assert result == "hello.txt"
|
|
|
|
def test_recover_utf8_non_latin1(self):
|
|
result = recover_utf8_filename_from_latin1_header("测试文件.txt")
|
|
assert result == "测试文件.txt"
|
|
|
|
|
|
# ============================================================
|
|
# stream.py - 额外覆盖
|
|
# ============================================================
|
|
class TestStreamExtended:
|
|
def test_merge_empty_existing(self):
|
|
assert merge_streaming_text("", "Hello") == "Hello"
|
|
|
|
def test_merge_empty_incoming(self):
|
|
assert merge_streaming_text("Hello", "") == "Hello"
|
|
|
|
def test_merge_incoming_starts_with_existing(self):
|
|
assert merge_streaming_text("Hello", "Hello World") == "Hello World"
|
|
|
|
def test_merge_existing_starts_with_incoming(self):
|
|
assert merge_streaming_text("Hello World", "Hello") == "Hello World"
|
|
|
|
def test_merge_overlap_suffix(self):
|
|
assert merge_streaming_text("today is", "is lovely") == "today is lovely"
|
|
|
|
def test_merge_overlap_prefix(self):
|
|
assert merge_streaming_text("is lovely", "today is") == "today is lovely"
|
|
|
|
def test_merge_no_overlap(self):
|
|
assert merge_streaming_text("Hello", "World") == "HelloWorld"
|
|
|
|
def test_merge_same_text(self):
|
|
assert merge_streaming_text("Hello", "Hello") == "Hello"
|
|
|
|
def test_merge_partial_overlap(self):
|
|
assert merge_streaming_text("Hello Wo", "o World!") == "Hello World!"
|
|
|
|
|
|
# ============================================================
|
|
# secret_resolver.py - 额外覆盖
|
|
# ============================================================
|
|
class TestSecretResolverExtended:
|
|
def test_resolve_secret_with_rotation_cache(self):
|
|
from yuxi.channels.adapters.feishu.secret_resolver import invalidate_secret_cache
|
|
invalidate_secret_cache()
|
|
result = resolve_secret_with_rotation({"test_key": "cached_value"}, "test_key", rotation_window_s=3600)
|
|
assert result == "cached_value"
|
|
|
|
def test_resolve_secret_with_rotation_hit(self):
|
|
invalidate_secret_cache()
|
|
result1 = resolve_secret_with_rotation({"test_key": "cached_value"}, "test_key", rotation_window_s=3600)
|
|
result2 = resolve_secret_with_rotation({"test_key": "cached_value"}, "test_key", rotation_window_s=3600)
|
|
assert result1 == "cached_value"
|
|
assert result2 == "cached_value"
|
|
|
|
def test_invalidate_specific_cache(self):
|
|
invalidate_secret_cache()
|
|
resolve_secret_with_rotation({"test_key": "value1"}, "test_key", rotation_window_s=3600)
|
|
invalidate_secret_cache("test_key")
|
|
result = resolve_secret_with_rotation({"test_key": "value2"}, "test_key", rotation_window_s=3600)
|
|
assert result == "value2"
|
|
|
|
def test_invalidate_all_cache(self):
|
|
invalidate_secret_cache()
|
|
resolve_secret_with_rotation({"test_key": "value1"}, "test_key", rotation_window_s=3600)
|
|
invalidate_secret_cache()
|
|
result = resolve_secret_with_rotation({"test_key": "value2"}, "test_key", rotation_window_s=3600)
|
|
assert result == "value2"
|
|
|
|
def test_resolve_secret_env_source(self, monkeypatch):
|
|
monkeypatch.setenv("MY_SECRET_ENV", "env_secret_value")
|
|
result = resolve_secret({"test_key": {"source": "env", "env": "MY_SECRET_ENV"}}, "test_key")
|
|
assert result == "env_secret_value"
|
|
|
|
def test_resolve_secret_command_failure(self):
|
|
result = resolve_secret({"test_key": {"source": "exec", "command": "exit 1"}}, "test_key")
|
|
assert result == ""
|
|
|
|
|
|
# ============================================================
|
|
# send.py - 额外覆盖
|
|
# ============================================================
|
|
class TestSendExtended:
|
|
def test_is_local_image_path_valid(self):
|
|
import yuxi.channels.adapters.feishu.send as send_mod
|
|
result = send_mod.is_local_image_path("C:\\Windows\\win.ini")
|
|
assert result is None
|
|
|
|
def test_is_local_image_path_not_image(self):
|
|
import yuxi.channels.adapters.feishu.send as send_mod
|
|
result = send_mod.is_local_image_path("/some/path/file.txt")
|
|
assert result is None
|
|
|
|
def test_is_local_image_path_not_absolute(self):
|
|
import yuxi.channels.adapters.feishu.send as send_mod
|
|
result = send_mod.is_local_image_path("relative/path/image.png")
|
|
assert result is None
|
|
|
|
def test_resolve_receive_id_type(self):
|
|
from yuxi.channels.adapters.feishu.send import _resolve_receive_id_type
|
|
assert _resolve_receive_id_type("direct") == "open_id"
|
|
assert _resolve_receive_id_type("private") == "open_id"
|
|
assert _resolve_receive_id_type("p2p") == "open_id"
|
|
assert _resolve_receive_id_type("group") == "chat_id"
|
|
assert _resolve_receive_id_type("thread") == "chat_id"
|
|
assert _resolve_receive_id_type("unknown") == "open_id"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reply_message_no_sdk(self, monkeypatch):
|
|
import yuxi.channels.adapters.feishu.send as send_mod
|
|
monkeypatch.setattr(send_mod, "HAS_LARK_SDK", False)
|
|
result = await send_mod.reply_message(None, "msg_123", "hello")
|
|
assert result.success is False
|
|
assert "not available" in result.error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_text_no_sdk(self, monkeypatch):
|
|
import yuxi.channels.adapters.feishu.send as send_mod
|
|
monkeypatch.setattr(send_mod, "HAS_LARK_SDK", False)
|
|
result = await send_mod.send_text(None, "chat_123", "hello")
|
|
assert result.success is False
|
|
assert "not available" in result.error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_card_no_sdk(self, monkeypatch):
|
|
import yuxi.channels.adapters.feishu.send as send_mod
|
|
monkeypatch.setattr(send_mod, "HAS_LARK_SDK", False)
|
|
result = await send_mod.send_card(None, "chat_123", "hello")
|
|
assert result.success is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_reaction_no_sdk(self, monkeypatch):
|
|
import yuxi.channels.adapters.feishu.send as send_mod
|
|
monkeypatch.setattr(send_mod, "HAS_LARK_SDK", False)
|
|
result = await send_mod.send_reaction(None, "msg_123", "THUMBSUP")
|
|
assert result.success is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_message_no_sdk(self, monkeypatch):
|
|
import yuxi.channels.adapters.feishu.send as send_mod
|
|
monkeypatch.setattr(send_mod, "HAS_LARK_SDK", False)
|
|
result = await send_mod.read_message(None, "msg_123")
|
|
assert result == {}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_messages_no_sdk(self, monkeypatch):
|
|
import yuxi.channels.adapters.feishu.send as send_mod
|
|
monkeypatch.setattr(send_mod, "HAS_LARK_SDK", False)
|
|
result = await send_mod.list_messages(None, "chat_123")
|
|
assert result == {"messages": [], "has_more": False, "page_token": ""}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_card_no_sdk(self, monkeypatch):
|
|
import yuxi.channels.adapters.feishu.send as send_mod
|
|
monkeypatch.setattr(send_mod, "HAS_LARK_SDK", False)
|
|
result = await send_mod.update_card_message(None, "msg_123", "updated")
|
|
assert result.success is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_forward_message_no_sdk(self, monkeypatch):
|
|
import yuxi.channels.adapters.feishu.send as send_mod
|
|
monkeypatch.setattr(send_mod, "HAS_LARK_SDK", False)
|
|
result = await send_mod.forward_message(None, "msg_123", "target_chat")
|
|
assert result.success is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_message_read_status_no_sdk(self, monkeypatch):
|
|
import yuxi.channels.adapters.feishu.send as send_mod
|
|
monkeypatch.setattr(send_mod, "HAS_LARK_SDK", False)
|
|
result = await send_mod.get_message_read_status(None, "msg_123")
|
|
assert result == {"users": [], "has_more": False, "page_token": ""}
|
|
|
|
|
|
# ============================================================
|
|
# send.py - mock SDK tests
|
|
# ============================================================
|
|
class TestSendWithMockSDK:
|
|
@pytest.mark.asyncio
|
|
async def test_reply_message_success(self, monkeypatch):
|
|
mock_client = MagicMock()
|
|
mock_resp = MagicMock()
|
|
mock_resp.success.return_value = True
|
|
mock_resp.data = {"message_id": "reply_123"}
|
|
mock_client.im.v1.message.reply = AsyncMock(return_value=mock_resp)
|
|
|
|
from yuxi.channels.adapters.feishu.send import reply_message
|
|
result = await reply_message(mock_client, "msg_123", "hello")
|
|
assert result.success is True
|
|
assert result.message_id == "reply_123"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reply_message_with_post_format(self, monkeypatch):
|
|
mock_client = MagicMock()
|
|
mock_resp = MagicMock()
|
|
mock_resp.success.return_value = True
|
|
mock_resp.data = {"message_id": "reply_456"}
|
|
mock_client.im.v1.message.reply = AsyncMock(return_value=mock_resp)
|
|
|
|
from yuxi.channels.adapters.feishu.send import reply_message
|
|
result = await reply_message(mock_client, "msg_123", "hello", use_post_format=True)
|
|
assert result.success is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reply_message_api_error(self, monkeypatch):
|
|
mock_client = MagicMock()
|
|
mock_resp = MagicMock()
|
|
mock_resp.success.return_value = False
|
|
mock_resp.msg = "permission denied"
|
|
mock_client.im.v1.message.reply = AsyncMock(return_value=mock_resp)
|
|
|
|
from yuxi.channels.adapters.feishu.send import reply_message
|
|
result = await reply_message(mock_client, "msg_123", "hello")
|
|
assert result.success is False
|
|
assert "permission denied" in result.error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reply_message_exception(self, monkeypatch):
|
|
mock_client = MagicMock()
|
|
mock_client.im.v1.message.reply = AsyncMock(side_effect=Exception("network error"))
|
|
|
|
from yuxi.channels.adapters.feishu.send import reply_message
|
|
result = await reply_message(mock_client, "msg_123", "hello")
|
|
assert result.success is False
|
|
assert "network error" in result.error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_text_success(self, monkeypatch):
|
|
import yuxi.channels.adapters.feishu.send as send_mod
|
|
monkeypatch.setattr(send_mod, "_make_create_msg_request", MagicMock(return_value="mock_req"))
|
|
mock_client = MagicMock()
|
|
mock_resp = MagicMock()
|
|
mock_resp.success.return_value = True
|
|
mock_resp.data = {"message_id": "send_123"}
|
|
mock_client.im.message.create = AsyncMock(return_value=mock_resp)
|
|
|
|
result = await send_mod.send_text(mock_client, "chat_123", "hello")
|
|
assert result.success is True
|
|
assert result.message_id == "send_123"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_card_success(self, monkeypatch):
|
|
import yuxi.channels.adapters.feishu.send as send_mod
|
|
monkeypatch.setattr(send_mod, "_make_create_msg_request", MagicMock(return_value="mock_req"))
|
|
mock_client = MagicMock()
|
|
mock_resp = MagicMock()
|
|
mock_resp.success.return_value = True
|
|
mock_resp.data = {"message_id": "card_123"}
|
|
mock_client.im.message.create = AsyncMock(return_value=mock_resp)
|
|
|
|
result = await send_mod.send_card(mock_client, "chat_123", "hello")
|
|
assert result.success is True
|
|
assert result.message_id == "card_123"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_card_raw(self, monkeypatch):
|
|
import yuxi.channels.adapters.feishu.send as send_mod
|
|
monkeypatch.setattr(send_mod, "_make_create_msg_request", MagicMock(return_value="mock_req"))
|
|
mock_client = MagicMock()
|
|
mock_resp = MagicMock()
|
|
mock_resp.success.return_value = True
|
|
mock_resp.data = {"message_id": "card_raw"}
|
|
mock_client.im.message.create = AsyncMock(return_value=mock_resp)
|
|
|
|
result = await send_mod.send_card(mock_client, "chat_123", '{"key": "value"}', raw_card=True)
|
|
assert result.success is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_reaction_sdk_limitation(self, monkeypatch):
|
|
import yuxi.channels.adapters.feishu.send as send_mod
|
|
mock_client = MagicMock()
|
|
result = await send_mod.send_reaction(mock_client, "msg_123", "THUMBSUP")
|
|
assert result.success is False
|
|
assert "SDK version incompatible" in result.error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_card_success(self, monkeypatch):
|
|
mock_client = MagicMock()
|
|
mock_resp = MagicMock()
|
|
mock_resp.success.return_value = True
|
|
mock_client.im.v1.message.patch = AsyncMock(return_value=mock_resp)
|
|
|
|
from yuxi.channels.adapters.feishu.send import update_card_message
|
|
result = await update_card_message(mock_client, "msg_123", "updated content")
|
|
assert result.success is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_forward_message_success(self, monkeypatch):
|
|
mock_client = MagicMock()
|
|
mock_resp = MagicMock()
|
|
mock_resp.success.return_value = True
|
|
mock_resp.data = {"message_id": "fwd_123"}
|
|
mock_client.im.v1.message.forward = AsyncMock(return_value=mock_resp)
|
|
|
|
from yuxi.channels.adapters.feishu.send import forward_message
|
|
result = await forward_message(mock_client, "msg_123", "target_chat")
|
|
assert result.success is True
|
|
|
|
|
|
# ============================================================
|
|
# reactions.py - mock SDK tests
|
|
# ============================================================
|
|
class TestReactionsWithMockSDK:
|
|
@pytest.mark.asyncio
|
|
async def test_remove_reaction_no_sdk(self, monkeypatch):
|
|
import yuxi.channels.adapters.feishu.reactions as reactions_mod
|
|
monkeypatch.setattr(reactions_mod, "HAS_LARK_SDK", False)
|
|
result = await reactions_mod.remove_reaction(None, "msg_123", "rid_123")
|
|
assert result.success is False
|
|
assert "not available" in result.error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_reactions_no_sdk(self, monkeypatch):
|
|
import yuxi.channels.adapters.feishu.reactions as reactions_mod
|
|
monkeypatch.setattr(reactions_mod, "HAS_LARK_SDK", False)
|
|
result = await reactions_mod.list_reactions(None, "msg_123")
|
|
assert result == {"reactions": [], "total": 0}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_clear_all_bot_reactions_no_sdk(self, monkeypatch):
|
|
import yuxi.channels.adapters.feishu.reactions as reactions_mod
|
|
monkeypatch.setattr(reactions_mod, "HAS_LARK_SDK", False)
|
|
result = await reactions_mod.clear_all_bot_reactions(None, "msg_123", "bot_123")
|
|
assert result.success is False |