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重连相关测试
856 lines
32 KiB
Python
856 lines
32 KiB
Python
"""Unit tests for Feishu/Lark channel adapter.
|
|
|
|
Tests the normalizer, formatter, cards, session helpers, and verify modules.
|
|
"""
|
|
import pytest
|
|
|
|
from yuxi.channels.adapters.feishu.adapter import FeishuAdapter
|
|
from yuxi.channels.adapters.feishu.cards import build_feishu_card, build_feishu_text_content, TEXT_CHUNK_LIMIT
|
|
from yuxi.channels.adapters.feishu.formatter import format_outbound
|
|
from yuxi.channels.adapters.feishu.normalizer import (
|
|
extract_attachments,
|
|
normalize_inbound,
|
|
)
|
|
from yuxi.channels.adapters.feishu.session import generate_feishu_chat_id, resolve_feishu_chat_type
|
|
from yuxi.channels.adapters.feishu.verify import verify_feishu_signature
|
|
from yuxi.channels.models import ChannelStatus, ChannelType, ChatType, EventType, MessageType
|
|
|
|
CHANNEL_ID = "feishu"
|
|
CHANNEL_TYPE = ChannelType.FEISHU
|
|
|
|
|
|
class TestFeishuNormalizer:
|
|
def test_dm_text_message(self):
|
|
raw = {
|
|
"event": {
|
|
"sender": {"sender_id": {"open_id": "ou_abc123"}},
|
|
"message": {
|
|
"message_id": "om_msg001",
|
|
"chat_id": "oc_dm001",
|
|
"chat_type": "p2p",
|
|
"msg_type": "text",
|
|
"content": '{"text": "Hello"}',
|
|
},
|
|
},
|
|
"event_type": "im.message.receive_v1",
|
|
}
|
|
msg = normalize_inbound(CHANNEL_ID, CHANNEL_TYPE, raw)
|
|
assert msg.chat_type == ChatType.DIRECT
|
|
assert msg.event_type == EventType.MESSAGE_RECEIVED
|
|
assert msg.message_type == MessageType.TEXT
|
|
assert msg.identity.channel_chat_id == "feishu:dm:ou_abc123"
|
|
assert msg.identity.channel_user_id == "ou_abc123"
|
|
assert msg.identity.channel_message_id == "om_msg001"
|
|
|
|
def test_group_message_with_mentions(self):
|
|
raw = {
|
|
"event": {
|
|
"sender": {"sender_id": {"open_id": "ou_user2"}},
|
|
"message": {
|
|
"message_id": "om_msg002",
|
|
"chat_id": "oc_group001",
|
|
"chat_type": "group",
|
|
"msg_type": "text",
|
|
"content": '{"text": "@bot hello"}',
|
|
"mentions": [{"key": "ou_bot", "name": "AI助手"}],
|
|
},
|
|
},
|
|
"event_type": "im.message.receive_v1",
|
|
}
|
|
msg = normalize_inbound(CHANNEL_ID, CHANNEL_TYPE, raw)
|
|
assert msg.chat_type == ChatType.GROUP
|
|
assert msg.identity.channel_chat_id == "feishu:group:oc_group001"
|
|
assert msg.mentions is not None
|
|
assert "ou_bot" in msg.mentions.mentioned_user_ids
|
|
|
|
def test_card_action_event(self):
|
|
raw = {
|
|
"event": {
|
|
"sender": {"sender_id": {"open_id": "ou_user3"}},
|
|
"message": {
|
|
"message_id": "om_msg003",
|
|
"chat_id": "oc_dm001",
|
|
"chat_type": "p2p",
|
|
"msg_type": "interactive",
|
|
},
|
|
},
|
|
"event_type": "card.action.trigger",
|
|
}
|
|
msg = normalize_inbound(CHANNEL_ID, CHANNEL_TYPE, raw)
|
|
assert msg.event_type == EventType.CARD_ACTION
|
|
assert msg.message_type == MessageType.CARD
|
|
|
|
def test_bot_added_event(self):
|
|
raw = {
|
|
"event": {
|
|
"sender": {"sender_id": {"open_id": "ou_admin"}},
|
|
"message": {"chat_id": "oc_group002", "chat_type": "group"},
|
|
},
|
|
"event_type": "im.chat.member.bot.added_v1",
|
|
}
|
|
msg = normalize_inbound(CHANNEL_ID, CHANNEL_TYPE, raw)
|
|
assert msg.event_type == EventType.BOT_ADDED
|
|
|
|
def test_bot_removed_event(self):
|
|
raw = {
|
|
"event": {
|
|
"sender": {"sender_id": {"open_id": "ou_admin"}},
|
|
"message": {"chat_id": "oc_group002", "chat_type": "group"},
|
|
},
|
|
"event_type": "im.chat.member.bot.deleted_v1",
|
|
}
|
|
msg = normalize_inbound(CHANNEL_ID, CHANNEL_TYPE, raw)
|
|
assert msg.event_type == EventType.BOT_REMOVED
|
|
|
|
def test_thread_message(self):
|
|
raw = {
|
|
"event": {
|
|
"sender": {"sender_id": {"open_id": "ou_user4"}},
|
|
"message": {
|
|
"message_id": "om_msg006",
|
|
"chat_id": "oc_group001",
|
|
"chat_type": "group",
|
|
"msg_type": "text",
|
|
"content": '{"text": "reply"}',
|
|
"root_id": "om_root001",
|
|
"parent_id": "om_parent001",
|
|
},
|
|
},
|
|
"event_type": "im.message.receive_v1",
|
|
}
|
|
msg = normalize_inbound(CHANNEL_ID, CHANNEL_TYPE, raw)
|
|
assert msg.chat_type == ChatType.THREAD
|
|
assert msg.metadata.get("root_id") == "om_root001"
|
|
assert msg.metadata.get("parent_id") == "om_parent001"
|
|
|
|
def test_post_message_type(self):
|
|
raw = {
|
|
"event": {
|
|
"sender": {"sender_id": {"open_id": "ou_user5"}},
|
|
"message": {
|
|
"message_id": "om_msg007",
|
|
"chat_id": "oc_dm002",
|
|
"chat_type": "p2p",
|
|
"msg_type": "post",
|
|
"content": {
|
|
"zh_cn": {
|
|
"title": "post title",
|
|
"content": [[{"tag": "text", "text": "rich text"}]],
|
|
}
|
|
},
|
|
},
|
|
},
|
|
"event_type": "im.message.receive_v1",
|
|
}
|
|
msg = normalize_inbound(CHANNEL_ID, CHANNEL_TYPE, raw)
|
|
assert msg.message_type == MessageType.TEXT
|
|
assert "rich text" in msg.content
|
|
|
|
def test_image_message_type(self):
|
|
raw = {
|
|
"event": {
|
|
"sender": {"sender_id": {"open_id": "ou_user6"}},
|
|
"message": {
|
|
"message_id": "om_msg008",
|
|
"chat_id": "oc_dm001",
|
|
"chat_type": "p2p",
|
|
"msg_type": "image",
|
|
},
|
|
},
|
|
"event_type": "im.message.receive_v1",
|
|
}
|
|
msg = normalize_inbound(CHANNEL_ID, CHANNEL_TYPE, raw)
|
|
assert msg.message_type == MessageType.IMAGE
|
|
|
|
def test_audio_message_type(self):
|
|
raw = {
|
|
"event": {
|
|
"sender": {"sender_id": {"open_id": "ou_user7"}},
|
|
"message": {
|
|
"message_id": "om_msg009",
|
|
"chat_id": "oc_dm001",
|
|
"chat_type": "p2p",
|
|
"msg_type": "audio",
|
|
},
|
|
},
|
|
"event_type": "im.message.receive_v1",
|
|
}
|
|
msg = normalize_inbound(CHANNEL_ID, CHANNEL_TYPE, raw)
|
|
assert msg.message_type == MessageType.AUDIO
|
|
|
|
def test_file_message_type(self):
|
|
raw = {
|
|
"event": {
|
|
"sender": {"sender_id": {"open_id": "ou_user8"}},
|
|
"message": {
|
|
"message_id": "om_msg010",
|
|
"chat_id": "oc_dm001",
|
|
"chat_type": "p2p",
|
|
"msg_type": "file",
|
|
},
|
|
},
|
|
"event_type": "im.message.receive_v1",
|
|
}
|
|
msg = normalize_inbound(CHANNEL_ID, CHANNEL_TYPE, raw)
|
|
assert msg.message_type == MessageType.FILE
|
|
|
|
def test_image_attachment_extraction(self):
|
|
raw = {
|
|
"event": {
|
|
"sender": {"sender_id": {"open_id": "ou_user1"}},
|
|
"message": {
|
|
"message_id": "om_img001",
|
|
"chat_id": "oc_dm001",
|
|
"chat_type": "p2p",
|
|
"msg_type": "image",
|
|
"content": '{"image_key": "img_abc123"}',
|
|
},
|
|
},
|
|
"event_type": "im.message.receive_v1",
|
|
}
|
|
msg = normalize_inbound(CHANNEL_ID, CHANNEL_TYPE, raw)
|
|
assert msg.content == "[图片]"
|
|
assert len(msg.attachments) == 1
|
|
assert msg.attachments[0].type == "image"
|
|
|
|
import json
|
|
parsed = json.loads(msg.attachments[0].file_id)
|
|
assert parsed["file_key"] == "img_abc123"
|
|
assert parsed["file_type"] == "image"
|
|
|
|
def test_file_attachment_extraction(self):
|
|
raw = {
|
|
"event": {
|
|
"sender": {"sender_id": {"open_id": "ou_user2"}},
|
|
"message": {
|
|
"message_id": "om_file001",
|
|
"chat_id": "oc_dm001",
|
|
"chat_type": "p2p",
|
|
"msg_type": "file",
|
|
"content": '{"file_key": "file_xyz789", "file_name": "report.pdf", "file_size": 1024}',
|
|
},
|
|
},
|
|
"event_type": "im.message.receive_v1",
|
|
}
|
|
msg = normalize_inbound(CHANNEL_ID, CHANNEL_TYPE, raw)
|
|
assert msg.content == "[文件: report.pdf]"
|
|
assert len(msg.attachments) == 1
|
|
assert msg.attachments[0].type == "file"
|
|
assert msg.attachments[0].filename == "report.pdf"
|
|
assert msg.attachments[0].size_bytes == 1024
|
|
|
|
def test_audio_attachment_extraction(self):
|
|
raw = {
|
|
"event": {
|
|
"sender": {"sender_id": {"open_id": "ou_user3"}},
|
|
"message": {
|
|
"message_id": "om_audio001",
|
|
"chat_id": "oc_dm001",
|
|
"chat_type": "p2p",
|
|
"msg_type": "audio",
|
|
"content": '{"file_key": "audio_key_001"}',
|
|
},
|
|
},
|
|
"event_type": "im.message.receive_v1",
|
|
}
|
|
msg = normalize_inbound(CHANNEL_ID, CHANNEL_TYPE, raw)
|
|
assert msg.content == "[语音]"
|
|
assert len(msg.attachments) == 1
|
|
assert msg.attachments[0].type == "audio"
|
|
|
|
def test_video_attachment_extraction(self):
|
|
raw = {
|
|
"event": {
|
|
"sender": {"sender_id": {"open_id": "ou_user4"}},
|
|
"message": {
|
|
"message_id": "om_video001",
|
|
"chat_id": "oc_dm001",
|
|
"chat_type": "p2p",
|
|
"msg_type": "video",
|
|
"content": '{"image_key": "vid_cover_001", "file_key": "vid_file_001"}',
|
|
},
|
|
},
|
|
"event_type": "im.message.receive_v1",
|
|
}
|
|
msg = normalize_inbound(CHANNEL_ID, CHANNEL_TYPE, raw)
|
|
assert msg.content == "[视频]"
|
|
assert len(msg.attachments) == 2
|
|
types = {a.type for a in msg.attachments}
|
|
assert types == {"image", "video"}
|
|
cover = [a for a in msg.attachments if a.type == "image"][0]
|
|
assert cover.metadata.get("role") == "cover"
|
|
|
|
def test_sticker_attachment_extraction(self):
|
|
raw = {
|
|
"event": {
|
|
"sender": {"sender_id": {"open_id": "ou_user5"}},
|
|
"message": {
|
|
"message_id": "om_sticker001",
|
|
"chat_id": "oc_dm001",
|
|
"chat_type": "p2p",
|
|
"msg_type": "sticker",
|
|
"content": '{"file_key": "sticker_key_001"}',
|
|
},
|
|
},
|
|
"event_type": "im.message.receive_v1",
|
|
}
|
|
msg = normalize_inbound(CHANNEL_ID, CHANNEL_TYPE, raw)
|
|
assert msg.content == "[贴纸]"
|
|
assert len(msg.attachments) == 1
|
|
assert msg.attachments[0].type == "sticker"
|
|
|
|
def test_text_message_no_attachments(self):
|
|
raw = {
|
|
"event": {
|
|
"sender": {"sender_id": {"open_id": "ou_user6"}},
|
|
"message": {
|
|
"message_id": "om_text001",
|
|
"chat_id": "oc_dm001",
|
|
"chat_type": "p2p",
|
|
"msg_type": "text",
|
|
"content": '{"text": "hello"}',
|
|
},
|
|
},
|
|
"event_type": "im.message.receive_v1",
|
|
}
|
|
msg = normalize_inbound(CHANNEL_ID, CHANNEL_TYPE, raw)
|
|
assert msg.attachments == []
|
|
|
|
def test_extract_attachments_image(self):
|
|
event = {
|
|
"message": {
|
|
"message_id": "om_123",
|
|
"msg_type": "image",
|
|
"content": '{"image_key": "img_abc"}',
|
|
}
|
|
}
|
|
attachments = extract_attachments(event)
|
|
assert len(attachments) == 1
|
|
assert attachments[0].type == "image"
|
|
assert attachments[0].file_id
|
|
|
|
def test_extract_attachments_file(self):
|
|
event = {
|
|
"message": {
|
|
"message_id": "om_456",
|
|
"msg_type": "file",
|
|
"content": '{"file_key": "file_xyz", "file_name": "doc.pdf", "file_size": 2048}',
|
|
}
|
|
}
|
|
attachments = extract_attachments(event)
|
|
assert len(attachments) == 1
|
|
assert attachments[0].type == "file"
|
|
assert attachments[0].filename == "doc.pdf"
|
|
assert attachments[0].size_bytes == 2048
|
|
|
|
def test_extract_attachments_empty_for_text(self):
|
|
event = {
|
|
"message": {
|
|
"message_id": "om_789",
|
|
"msg_type": "text",
|
|
"content": '{"text": "hello"}',
|
|
}
|
|
}
|
|
assert extract_attachments(event) == []
|
|
|
|
def test_extract_attachments_media_combined(self):
|
|
event = {
|
|
"message": {
|
|
"message_id": "om_media001",
|
|
"msg_type": "media",
|
|
"content": '{"items": ['
|
|
'{"msg_type": "image", "content": "{\\"image_key\\": \\"img_1\\"}"},'
|
|
'{"msg_type": "image", "content": "{\\"image_key\\": \\"img_2\\"}"}'
|
|
"]}",
|
|
}
|
|
}
|
|
attachments = extract_attachments(event)
|
|
assert len(attachments) == 2
|
|
assert all(a.type == "image" for a in attachments)
|
|
|
|
def test_unknown_event_type_defaults_to_message_received(self):
|
|
raw = {
|
|
"event": {
|
|
"sender": {"sender_id": {"open_id": "ou_user9"}},
|
|
"message": {
|
|
"message_id": "om_msg011",
|
|
"chat_id": "oc_dm001",
|
|
"chat_type": "p2p",
|
|
"msg_type": "text",
|
|
"content": '{"text": "test"}',
|
|
},
|
|
},
|
|
"event_type": "unknown.event.type",
|
|
}
|
|
msg = normalize_inbound(CHANNEL_ID, CHANNEL_TYPE, raw)
|
|
assert msg.event_type == EventType.MESSAGE_RECEIVED
|
|
|
|
def test_no_mentions(self):
|
|
raw = {
|
|
"event": {
|
|
"sender": {"sender_id": {"open_id": "ou_user10"}},
|
|
"message": {
|
|
"message_id": "om_msg012",
|
|
"chat_id": "oc_dm001",
|
|
"chat_type": "p2p",
|
|
"msg_type": "text",
|
|
"content": '{"text": "plain text"}',
|
|
},
|
|
},
|
|
"event_type": "im.message.receive_v1",
|
|
}
|
|
msg = normalize_inbound(CHANNEL_ID, CHANNEL_TYPE, raw)
|
|
assert msg.mentions is not None
|
|
assert msg.mentions.mentioned_user_ids == []
|
|
|
|
def test_metadata(self):
|
|
raw = {
|
|
"event": {
|
|
"sender": {"sender_id": {"open_id": "ou_user11"}},
|
|
"message": {
|
|
"message_id": "om_msg013",
|
|
"chat_id": "oc_dm001",
|
|
"chat_type": "p2p",
|
|
"msg_type": "text",
|
|
"content": '{"text": "test"}',
|
|
},
|
|
},
|
|
"event_type": "im.message.receive_v1",
|
|
}
|
|
msg = normalize_inbound(CHANNEL_ID, CHANNEL_TYPE, raw)
|
|
assert msg.metadata.get("msg_type") == "text"
|
|
assert msg.metadata.get("chat_type_raw") == "p2p"
|
|
|
|
def test_bot_mentioned(self):
|
|
raw = {
|
|
"event": {
|
|
"sender": {"sender_id": {"open_id": "ou_user12"}},
|
|
"message": {
|
|
"message_id": "om_msg014",
|
|
"chat_id": "oc_group001",
|
|
"chat_type": "group",
|
|
"msg_type": "text",
|
|
"content": '{"text": "@bot help"}',
|
|
"mentions": [{"key": "ou_bot123", "name": "AI助手"}],
|
|
},
|
|
},
|
|
"event_type": "im.message.receive_v1",
|
|
}
|
|
msg = normalize_inbound(CHANNEL_ID, CHANNEL_TYPE, raw, bot_open_id="ou_bot123")
|
|
assert msg.mentions is not None
|
|
assert msg.mentions.is_bot_mentioned is True
|
|
|
|
def test_all_users_mentioned(self):
|
|
raw = {
|
|
"event": {
|
|
"sender": {"sender_id": {"open_id": "ou_user13"}},
|
|
"message": {
|
|
"message_id": "om_msg015",
|
|
"chat_id": "oc_group001",
|
|
"chat_type": "group",
|
|
"msg_type": "text",
|
|
"content": '{"text": "@所有人 hello"}',
|
|
"mentions": [{"key": "@all_users", "name": "所有人"}],
|
|
},
|
|
},
|
|
"event_type": "im.message.receive_v1",
|
|
}
|
|
msg = normalize_inbound(CHANNEL_ID, CHANNEL_TYPE, raw)
|
|
assert msg.mentions is not None
|
|
assert msg.mentions.is_bot_mentioned is True
|
|
|
|
def test_bot_not_mentioned(self):
|
|
raw = {
|
|
"event": {
|
|
"sender": {"sender_id": {"open_id": "ou_user14"}},
|
|
"message": {
|
|
"message_id": "om_msg016",
|
|
"chat_id": "oc_group001",
|
|
"chat_type": "group",
|
|
"msg_type": "text",
|
|
"content": '{"text": "@someone hello"}',
|
|
"mentions": [{"key": "ou_other", "name": "Someone"}],
|
|
},
|
|
},
|
|
"event_type": "im.message.receive_v1",
|
|
}
|
|
msg = normalize_inbound(CHANNEL_ID, CHANNEL_TYPE, raw, bot_open_id="ou_bot123")
|
|
assert msg.mentions is not None
|
|
assert msg.mentions.is_bot_mentioned is False
|
|
|
|
|
|
class TestFeishuFormatter:
|
|
def test_basic_format(self):
|
|
fmt = format_outbound("Hello", chat_type="private")
|
|
assert fmt["content"] == "Hello"
|
|
assert fmt["chat_type"] == "private"
|
|
|
|
def test_format_with_buttons(self):
|
|
fmt = format_outbound(
|
|
"Click me",
|
|
chat_type="private",
|
|
metadata={"buttons": [{"text": "OK", "action": "confirm"}]},
|
|
)
|
|
assert fmt["buttons"] == [{"text": "OK", "action": "confirm"}]
|
|
|
|
def test_format_with_thread_id(self):
|
|
fmt = format_outbound("Reply", chat_type="group", metadata={"thread_id": "om_thread_123"})
|
|
assert fmt["thread_id"] == "om_thread_123"
|
|
|
|
|
|
class TestFeishuCards:
|
|
def test_text_content(self):
|
|
result = build_feishu_text_content("Hello")
|
|
assert result == {"text": "Hello"}
|
|
|
|
def test_basic_card(self):
|
|
card = build_feishu_card("Hello World", title="Test")
|
|
assert card["header"]["template"] == "blue"
|
|
assert card["header"]["title"]["content"] == "Test"
|
|
assert len(card["elements"]) >= 1
|
|
assert "Hello World" in card["elements"][0]["content"]
|
|
|
|
def test_card_with_buttons(self):
|
|
card = build_feishu_card("Click me", buttons=[{"text": "OK", "action": "ok"}])
|
|
has_action = any(elem.get("tag") == "action" for elem in card["elements"])
|
|
assert has_action
|
|
|
|
def test_card_with_streaming(self):
|
|
card = build_feishu_card("Loading", streaming=True)
|
|
assert "回复中" in card["header"]["title"]["content"]
|
|
|
|
def test_text_chunk_limit(self):
|
|
assert TEXT_CHUNK_LIMIT == 30000
|
|
|
|
def test_card_with_images(self):
|
|
card = build_feishu_card("Look at this", images=["img_key_001", "img_key_002"])
|
|
img_elements = [e for e in card["elements"] if e.get("tag") == "img"]
|
|
assert len(img_elements) == 2
|
|
assert img_elements[0]["img_key"] == "img_key_001"
|
|
assert img_elements[1]["img_key"] == "img_key_002"
|
|
|
|
def test_card_with_files(self):
|
|
card = build_feishu_card(
|
|
"Files attached",
|
|
files=[{"name": "report.pdf", "url": "https://example.com/report.pdf"}],
|
|
)
|
|
markdown_elements = [e for e in card["elements"] if e.get("tag") == "markdown"]
|
|
file_md = [e for e in markdown_elements if "report.pdf" in e.get("content", "")]
|
|
assert len(file_md) == 1
|
|
|
|
def test_card_with_files_no_url(self):
|
|
card = build_feishu_card(
|
|
"Files attached",
|
|
files=[{"name": "data.csv"}],
|
|
)
|
|
markdown_elements = [e for e in card["elements"] if e.get("tag") == "markdown"]
|
|
file_md = [e for e in markdown_elements if "data.csv" in e.get("content", "")]
|
|
assert len(file_md) == 1
|
|
|
|
def test_card_with_note(self):
|
|
card = build_feishu_card("Hello", note="仅供参考")
|
|
note_elements = [e for e in card["elements"] if e.get("tag") == "note"]
|
|
assert len(note_elements) == 1
|
|
assert note_elements[0]["elements"][0]["content"] == "仅供参考"
|
|
|
|
def test_card_with_all_features(self):
|
|
card = build_feishu_card(
|
|
"Main content",
|
|
title="综合卡片",
|
|
images=["img_1"],
|
|
files=[{"name": "data.csv", "url": "https://example.com/data.csv"}],
|
|
buttons=[{"text": "确认", "action": "ok"}],
|
|
url_unfurl=["https://example.com"],
|
|
note="此消息由AI生成",
|
|
streaming=False,
|
|
)
|
|
tags = {e.get("tag") for e in card["elements"]}
|
|
assert "img" in tags
|
|
assert "markdown" in tags
|
|
assert "action" in tags
|
|
assert "note" in tags
|
|
assert card["header"]["title"]["content"] == "综合卡片"
|
|
|
|
|
|
class TestFeishuSession:
|
|
def test_dm_chat_id(self):
|
|
assert generate_feishu_chat_id(raw_chat_id="ou_123", chat_type="direct") == "feishu:dm:ou_123"
|
|
|
|
def test_group_chat_id(self):
|
|
assert generate_feishu_chat_id(raw_chat_id="oc_456", chat_type="group") == "feishu:group:oc_456"
|
|
|
|
def test_dm_chat_type(self):
|
|
assert resolve_feishu_chat_type("ou_123") == "direct"
|
|
|
|
def test_group_chat_type(self):
|
|
assert resolve_feishu_chat_type("oc_456") == "group"
|
|
|
|
def test_thread_chat_type(self):
|
|
assert resolve_feishu_chat_type("oc_thread_123") == "group"
|
|
|
|
def test_thread_chat_type_by_parent_id(self):
|
|
assert resolve_feishu_chat_type("oc_456") == "group"
|
|
|
|
def test_default_chat_type(self):
|
|
assert resolve_feishu_chat_type("bt_unknown") == "group"
|
|
|
|
|
|
class TestFeishuVerify:
|
|
def test_empty_headers_fail_closed(self):
|
|
assert verify_feishu_signature({}, b"") is False
|
|
|
|
def test_missing_signature_headers(self):
|
|
headers = {"X-Lark-Request-Timestamp": "123"}
|
|
assert verify_feishu_signature(headers, b"") is False
|
|
|
|
def test_valid_signature_with_encrypt_key_and_body(self):
|
|
encrypt_key = "test_encrypt_key_32_bytes_long_"
|
|
timestamp = "1234567890"
|
|
nonce = "abcdefgh"
|
|
body = b'{"challenge":"test_challenge_value","token":"v_token","type":"url_verification"}'
|
|
|
|
import hashlib
|
|
raw = f"{timestamp}{nonce}{encrypt_key}".encode("utf-8") + body
|
|
expected_signature = hashlib.sha256(raw).hexdigest()
|
|
|
|
headers = {
|
|
"X-Lark-Request-Timestamp": timestamp,
|
|
"X-Lark-Request-Nonce": nonce,
|
|
"X-Lark-Signature": expected_signature,
|
|
}
|
|
|
|
assert verify_feishu_signature(headers, body, encrypt_key) is True
|
|
|
|
def test_invalid_signature_with_wrong_encrypt_key(self):
|
|
encrypt_key = "test_encrypt_key_32_bytes_long_"
|
|
wrong_key = "wrong_encrypt_key_32_bytes_xxxx_"
|
|
timestamp = "1234567890"
|
|
nonce = "abcdefgh"
|
|
body = b'{"challenge":"test_challenge_value"}'
|
|
|
|
import hashlib
|
|
raw = f"{timestamp}{nonce}{wrong_key}".encode("utf-8") + body
|
|
fake_signature = hashlib.sha256(raw).hexdigest()
|
|
|
|
headers = {
|
|
"X-Lark-Request-Timestamp": timestamp,
|
|
"X-Lark-Request-Nonce": nonce,
|
|
"X-Lark-Signature": fake_signature,
|
|
}
|
|
|
|
assert verify_feishu_signature(headers, body, encrypt_key) is False
|
|
|
|
def test_signature_verification_fails_with_wrong_body(self):
|
|
encrypt_key = "test_encrypt_key_32_bytes_long_"
|
|
timestamp = "1234567890"
|
|
nonce = "abcdefgh"
|
|
original_body = b'{"challenge":"original"}'
|
|
tampered_body = b'{"challenge":"tampered"}'
|
|
|
|
import hashlib
|
|
raw = f"{timestamp}{nonce}{encrypt_key}".encode("utf-8") + original_body
|
|
signature = hashlib.sha256(raw).hexdigest()
|
|
|
|
headers = {
|
|
"X-Lark-Request-Timestamp": timestamp,
|
|
"X-Lark-Request-Nonce": nonce,
|
|
"X-Lark-Signature": signature,
|
|
}
|
|
|
|
assert verify_feishu_signature(headers, tampered_body, encrypt_key) is False
|
|
|
|
def test_signature_case_insensitive_headers(self):
|
|
encrypt_key = "test_encrypt_key_32_bytes_long_"
|
|
timestamp = "1234567890"
|
|
nonce = "abcdefgh"
|
|
body = b'{"challenge":"test"}'
|
|
|
|
import hashlib
|
|
raw = f"{timestamp}{nonce}{encrypt_key}".encode("utf-8") + body
|
|
signature = hashlib.sha256(raw).hexdigest()
|
|
|
|
headers = {
|
|
"x-lark-request-timestamp": timestamp,
|
|
"x-lark-request-nonce": nonce,
|
|
"x-lark-signature": signature,
|
|
}
|
|
|
|
# Headers are accessed with case-sensitive keys by the implementation
|
|
# This tests the actual behavior - headers dict keys should match exactly
|
|
assert verify_feishu_signature(headers, body, encrypt_key) is False
|
|
|
|
|
|
class TestFeishuSessionDefensive:
|
|
def test_generate_chat_id_with_missing_sender(self):
|
|
chat_id = generate_feishu_chat_id(raw_chat_id="oc_group123", chat_type="group")
|
|
assert chat_id == "feishu:group:oc_group123"
|
|
|
|
def test_generate_chat_id_with_none_sender(self):
|
|
chat_id = generate_feishu_chat_id(raw_chat_id="oc_group123", chat_type="group", sender_id=None)
|
|
assert chat_id == "feishu:group:oc_group123"
|
|
|
|
def test_generate_chat_id_with_empty_sender_dict(self):
|
|
chat_id = generate_feishu_chat_id(raw_chat_id="oc_group123", chat_type="group")
|
|
assert chat_id == "feishu:group:oc_group123"
|
|
|
|
def test_generate_chat_id_full_event(self):
|
|
chat_id = generate_feishu_chat_id(
|
|
raw_chat_id="oc_chat456",
|
|
chat_type="group",
|
|
root_id="om_root123",
|
|
sender_id="ou_sender456",
|
|
)
|
|
assert chat_id == "feishu:group:om_root123:oc_chat456"
|
|
|
|
|
|
class TestFeishuNormalizerValidation:
|
|
CHANNEL_ID = "feishu"
|
|
CHANNEL_TYPE = ChannelType.FEISHU
|
|
|
|
def test_invalid_raw_payload_not_dict(self):
|
|
import pytest
|
|
with pytest.raises(ValueError, match="Invalid raw_payload"):
|
|
normalize_inbound(self.CHANNEL_ID, self.CHANNEL_TYPE, "not_a_dict")
|
|
|
|
def test_invalid_raw_payload_missing_event(self):
|
|
import pytest
|
|
with pytest.raises(ValueError, match="Invalid raw_payload"):
|
|
normalize_inbound(self.CHANNEL_ID, self.CHANNEL_TYPE, {"not_event": "value"})
|
|
|
|
def test_invalid_raw_payload_none(self):
|
|
import pytest
|
|
with pytest.raises(ValueError, match="Invalid raw_payload"):
|
|
normalize_inbound(self.CHANNEL_ID, self.CHANNEL_TYPE, None)
|
|
|
|
|
|
class TestFeishuAnalysisModule:
|
|
@pytest.mark.skip(reason="analysis module not implemented yet")
|
|
def test_orchestrator_import(self):
|
|
from yuxi.channels.adapters.feishu.analysis import FeishuAnalyzer
|
|
analyzer = FeishuAnalyzer()
|
|
assert analyzer is not None
|
|
assert len(analyzer._agents) == 3
|
|
|
|
@pytest.mark.skip(reason="analysis module not implemented yet")
|
|
def test_web_research_agent(self):
|
|
from yuxi.channels.adapters.feishu.analysis.sub_agents import SubAgentWebResearch
|
|
agent = SubAgentWebResearch()
|
|
assert agent is not None
|
|
|
|
@pytest.mark.skip(reason="analysis module not implemented yet")
|
|
def test_functional_research_agent(self):
|
|
from yuxi.channels.adapters.feishu.analysis.sub_agents import SubAgentFunctionalResearch
|
|
agent = SubAgentFunctionalResearch()
|
|
assert agent is not None
|
|
|
|
@pytest.mark.skip(reason="analysis module not implemented yet")
|
|
def test_issue_research_agent(self):
|
|
from yuxi.channels.adapters.feishu.analysis.sub_agents import SubAgentIssueResearch
|
|
agent = SubAgentIssueResearch()
|
|
assert agent is not None
|
|
|
|
@pytest.mark.skip(reason="analysis module not implemented yet")
|
|
def test_report_generator_import(self):
|
|
from yuxi.channels.adapters.feishu.analysis.report_generator import (
|
|
GapReport,
|
|
generate_gap_report,
|
|
)
|
|
assert GapReport is not None
|
|
assert generate_gap_report is not None
|
|
|
|
|
|
class TestReplyMessage:
|
|
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
|
|
|
|
async def test_reply_message_text(self):
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
mock_client = MagicMock()
|
|
mock_resp = MagicMock()
|
|
mock_resp.success.return_value = True
|
|
mock_resp.data = {"message_id": "reply_msg_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")
|
|
assert result.success is True
|
|
assert result.message_id == "reply_msg_456"
|
|
|
|
async def test_reply_message_with_buttons(self):
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
mock_client = MagicMock()
|
|
mock_resp = MagicMock()
|
|
mock_resp.success.return_value = True
|
|
mock_resp.data = {"message_id": "reply_card_msg"}
|
|
mock_client.im.v1.message.reply = AsyncMock(return_value=mock_resp)
|
|
|
|
from yuxi.channels.adapters.feishu.send import reply_message
|
|
buttons = [{"text": "OK", "value": "confirm"}]
|
|
result = await reply_message(mock_client, "msg_123", "Choose an option", buttons=buttons)
|
|
assert result.success is True
|
|
assert result.message_id == "reply_card_msg"
|
|
|
|
async def test_reply_message_api_error(self):
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
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
|
|
|
|
|
|
class TestTokenRefresh:
|
|
def test_token_fields_initialized(self):
|
|
adapter = FeishuAdapter(config={"app_id": "test", "app_secret": "test"})
|
|
assert adapter._token_expire_at == 0
|
|
assert adapter._token_refresh_task is None
|
|
|
|
def test_disconnect_cancels_token_refresh(self):
|
|
import asyncio
|
|
adapter = FeishuAdapter(config={"app_id": "test", "app_secret": "test"})
|
|
adapter._status = ChannelStatus.CONNECTED
|
|
adapter._ws_task = asyncio.Future()
|
|
adapter._token_refresh_task = asyncio.Future()
|
|
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
try:
|
|
loop.run_until_complete(adapter.disconnect())
|
|
finally:
|
|
loop.close()
|
|
|
|
assert adapter._token_refresh_task is None
|
|
assert adapter._status == ChannelStatus.DISCONNECTED
|
|
|
|
def test_receive_method_removed(self):
|
|
adapter = FeishuAdapter(config={"app_id": "test", "app_secret": "test"})
|
|
assert "receive" not in type(adapter).__dict__
|
|
|
|
|
|
class TestFeishuAdapterLongPoll:
|
|
def test_start_long_poll_no_sdk(self, monkeypatch):
|
|
import pytest
|
|
|
|
import yuxi.channels.adapters.feishu.adapter as adapter_mod
|
|
|
|
monkeypatch.setattr(adapter_mod, "HAS_LARK_SDK", False)
|
|
adapter = FeishuAdapter(config={"app_id": "test", "app_secret": "test"})
|
|
with pytest.raises(Exception) as exc_info:
|
|
import asyncio
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
try:
|
|
loop.run_until_complete(adapter._start_long_poll())
|
|
finally:
|
|
loop.close()
|
|
assert "not installed" in str(exc_info.value)
|