"""全面的飞书模块单元测试 - 覆盖所有未充分测试的核心功能、边界条件和异常处理场景"""
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 = '@某人 你好'
result = strip_at_mentions(text)
assert "@某人" not in result
assert "你好" in result
def test_strip_at_mentions_replace_with(self):
text = '@某人 你好'
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 = '@A @B'
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