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重连相关测试
1208 lines
40 KiB
Python
1208 lines
40 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import threading
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from yuxi.channels.adapters.line.adapter import LINEAdapter
|
|
from yuxi.channels.adapters.line.webhook import (
|
|
LINE_WEBHOOK_MAX_BODY_BYTES,
|
|
parse_webhook_body,
|
|
validate_line_signature,
|
|
)
|
|
from yuxi.channels.adapters.line.normalizer import LINEEventNormalizer
|
|
from yuxi.channels.adapters.line.formatter import LINEMessageFormatter, _safe_truncate
|
|
from yuxi.channels.adapters.line.directives import parse_line_directives
|
|
from yuxi.channels.adapters.line.session import (
|
|
resolve_chat_type,
|
|
resolve_channel_chat_id,
|
|
resolve_thread_key,
|
|
)
|
|
from yuxi.channels.adapters.line.flex_messages import build_bubble_message, build_carousel_message
|
|
from yuxi.channels.adapters.line.template_messages import (
|
|
build_buttons_template,
|
|
build_confirm_template,
|
|
)
|
|
from yuxi.channels.adapters.line.quick_reply import build_quick_reply_items, build_text_with_quick_reply
|
|
from yuxi.channels.models import (
|
|
ChannelIdentity,
|
|
ChannelMessage,
|
|
ChannelResponse,
|
|
ChannelStatus,
|
|
ChannelType,
|
|
ChatType,
|
|
DeliveryResult,
|
|
EventType,
|
|
MessageType,
|
|
)
|
|
|
|
|
|
def make_text_event(text="hello", user_id="Uxxx", reply_token="reply-abc"):
|
|
return {
|
|
"type": "message",
|
|
"message": {"type": "text", "id": "1234567890", "text": text},
|
|
"source": {"type": "user", "userId": user_id},
|
|
"replyToken": reply_token,
|
|
"timestamp": 1700000000000,
|
|
"mode": "active",
|
|
}
|
|
|
|
|
|
def make_image_event(user_id="Uxxx"):
|
|
return {
|
|
"type": "message",
|
|
"message": {"type": "image", "id": "img-001"},
|
|
"source": {"type": "user", "userId": user_id},
|
|
"replyToken": "reply-img",
|
|
"timestamp": 1700000000000,
|
|
}
|
|
|
|
|
|
def make_follow_event(user_id="Uxxx"):
|
|
return {
|
|
"type": "follow",
|
|
"source": {"type": "user", "userId": user_id},
|
|
"replyToken": "reply-follow",
|
|
"timestamp": 1700000000000,
|
|
}
|
|
|
|
|
|
def make_group_text_event(text="hello group", group_id="Cxxx"):
|
|
return {
|
|
"type": "message",
|
|
"message": {"type": "text", "id": "msg-group-001", "text": text},
|
|
"source": {"type": "group", "groupId": group_id},
|
|
"replyToken": "reply-group",
|
|
"timestamp": 1700000000000,
|
|
}
|
|
|
|
|
|
def make_webhook_body(events: list[dict]) -> bytes:
|
|
return json.dumps({"destination": "Ubot", "events": events}).encode("utf-8")
|
|
|
|
|
|
class TestWebhookSignature:
|
|
def test_valid_signature(self):
|
|
secret = "test-channel-secret"
|
|
body = b'{"destination":"Ubot","events":[]}'
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
|
|
computed = hmac.new(
|
|
key=secret.encode("utf-8"),
|
|
msg=body,
|
|
digestmod=hashlib.sha256,
|
|
).digest()
|
|
signature = base64.b64encode(computed).decode("utf-8")
|
|
|
|
assert validate_line_signature(body, signature, secret) is True
|
|
|
|
def test_invalid_signature(self):
|
|
secret = "test-channel-secret"
|
|
body = b'{"events":[]}'
|
|
assert validate_line_signature(body, "bad-signature", secret) is False
|
|
|
|
def test_empty_signature(self):
|
|
assert validate_line_signature(b"{}", "", "secret") is False
|
|
|
|
def test_empty_secret(self):
|
|
assert validate_line_signature(b"{}", "sig", "") is False
|
|
|
|
def test_parse_valid_body(self):
|
|
events = parse_webhook_body(b'{"events":[{"type":"message"}]}')
|
|
assert events is not None
|
|
assert len(events) == 1
|
|
assert events[0]["type"] == "message"
|
|
|
|
def test_parse_invalid_json(self):
|
|
assert parse_webhook_body(b"not json") is None
|
|
|
|
|
|
class TestLINEEventNormalizer:
|
|
def setup_method(self):
|
|
self.normalizer = LINEEventNormalizer()
|
|
|
|
def test_normalize_text_message(self):
|
|
event = make_text_event(text="hello world")
|
|
result = self.normalizer.normalize(event)
|
|
|
|
assert result.identity.channel_id == "line"
|
|
assert result.identity.channel_type == ChannelType.LINE
|
|
assert result.identity.channel_user_id == "Uxxx"
|
|
assert result.identity.channel_chat_id == "user_Uxxx"
|
|
assert result.content == "hello world"
|
|
assert result.message_type == MessageType.TEXT
|
|
assert result.chat_type == ChatType.DIRECT
|
|
assert result.event_type == EventType.MESSAGE_RECEIVED
|
|
assert result.metadata["reply_token"] == "reply-abc"
|
|
|
|
def test_normalize_image_message(self):
|
|
event = make_image_event()
|
|
result = self.normalizer.normalize(event)
|
|
|
|
assert result.message_type == MessageType.IMAGE
|
|
assert len(result.attachments) == 1
|
|
assert result.attachments[0].type == "image"
|
|
|
|
def test_normalize_video_message(self):
|
|
event = {
|
|
"type": "message",
|
|
"message": {"type": "video", "id": "vid-001"},
|
|
"source": {"type": "user", "userId": "Uxxx"},
|
|
"timestamp": 0,
|
|
}
|
|
result = self.normalizer.normalize(event)
|
|
assert result.message_type == MessageType.VIDEO
|
|
|
|
def test_normalize_audio_message(self):
|
|
event = {
|
|
"type": "message",
|
|
"message": {"type": "audio", "id": "aud-001", "duration": 5000},
|
|
"source": {"type": "user", "userId": "Uxxx"},
|
|
"timestamp": 0,
|
|
}
|
|
result = self.normalizer.normalize(event)
|
|
assert result.message_type == MessageType.AUDIO
|
|
assert "Audio" in result.content
|
|
|
|
def test_normalize_file_message(self):
|
|
event = {
|
|
"type": "message",
|
|
"message": {"type": "file", "id": "file-001", "file_name": "doc.pdf", "file_size": 1024},
|
|
"source": {"type": "user", "userId": "Uxxx"},
|
|
"timestamp": 0,
|
|
}
|
|
result = self.normalizer.normalize(event)
|
|
assert result.message_type == MessageType.FILE
|
|
assert "doc.pdf" in result.content
|
|
|
|
def test_normalize_location_message(self):
|
|
event = {
|
|
"type": "message",
|
|
"message": {"type": "location", "id": "loc-001", "title": "Tokyo", "address": "Shibuya"},
|
|
"source": {"type": "user", "userId": "Uxxx"},
|
|
"timestamp": 0,
|
|
}
|
|
result = self.normalizer.normalize(event)
|
|
assert result.message_type == MessageType.LOCATION
|
|
assert "Tokyo" in result.content
|
|
|
|
def test_normalize_sticker_message(self):
|
|
event = {
|
|
"type": "message",
|
|
"message": {"type": "sticker", "id": "stk-001", "package_id": "1", "sticker_id": "2"},
|
|
"source": {"type": "user", "userId": "Uxxx"},
|
|
"timestamp": 0,
|
|
}
|
|
result = self.normalizer.normalize(event)
|
|
assert "Sticker" in result.content
|
|
|
|
def test_normalize_follow_event(self):
|
|
event = make_follow_event()
|
|
result = self.normalizer.normalize(event)
|
|
|
|
assert result.event_type == EventType.BOT_ADDED
|
|
assert "关注" in result.content
|
|
|
|
def test_normalize_join_event(self):
|
|
event = {
|
|
"type": "join",
|
|
"source": {"type": "group", "groupId": "Cxxx"},
|
|
"timestamp": 0,
|
|
}
|
|
result = self.normalizer.normalize(event)
|
|
assert result.event_type == EventType.MEMBER_JOINED
|
|
assert result.chat_type == ChatType.GROUP
|
|
assert result.identity.channel_chat_id == "group_Cxxx"
|
|
|
|
def test_normalize_postback_event(self):
|
|
event = {
|
|
"type": "postback",
|
|
"source": {"type": "user", "userId": "Uxxx"},
|
|
"postback": {"data": "action=confirm"},
|
|
"timestamp": 0,
|
|
}
|
|
result = self.normalizer.normalize(event)
|
|
assert result.event_type == EventType.CARD_ACTION
|
|
assert result.content == "action=confirm"
|
|
|
|
def test_normalize_group_message(self):
|
|
event = make_group_text_event(text="hello group")
|
|
result = self.normalizer.normalize(event)
|
|
|
|
assert result.chat_type == ChatType.GROUP
|
|
assert result.identity.channel_chat_id == "group_Cxxx"
|
|
assert result.identity.channel_user_id == "Cxxx"
|
|
|
|
def test_normalize_room_message(self):
|
|
event = {
|
|
"type": "message",
|
|
"message": {"type": "text", "id": "msg-001", "text": "hi room"},
|
|
"source": {"type": "room", "roomId": "Rxxx"},
|
|
"timestamp": 0,
|
|
}
|
|
result = self.normalizer.normalize(event)
|
|
assert result.chat_type == ChatType.GROUP
|
|
assert result.identity.channel_chat_id == "room_Rxxx"
|
|
|
|
|
|
class TestLINEDirectives:
|
|
def test_quick_replies(self):
|
|
text = "Choose an option\n[[quick_replies: Option A, Option B, Option C]]"
|
|
result = parse_line_directives(text)
|
|
assert result is not None
|
|
assert len(result) == 1
|
|
assert result[0]["type"] == "text"
|
|
assert result[0]["quickReply"]["items"][0]["action"]["label"] == "Option A"
|
|
assert len(result[0]["quickReply"]["items"]) == 3
|
|
|
|
def test_quick_replies_max_13(self):
|
|
options = ", ".join([f"Opt{i}" for i in range(20)])
|
|
text = f"[[quick_replies: {options}]]"
|
|
result = parse_line_directives(text)
|
|
assert len(result[0]["quickReply"]["items"]) == 13
|
|
|
|
def test_confirm_template(self):
|
|
text = "[[confirm: Are you sure? | Yes | No]]"
|
|
result = parse_line_directives(text)
|
|
assert result is not None
|
|
tmpl = [m for m in result if m["type"] == "template"]
|
|
assert len(tmpl) == 1
|
|
assert tmpl[0]["template"]["type"] == "confirm"
|
|
assert tmpl[0]["template"]["text"] == "Are you sure?"
|
|
|
|
def test_buttons_template(self):
|
|
text = "[[buttons: My Title | Description | Btn1:action1, Btn2:https://example.com]]"
|
|
result = parse_line_directives(text)
|
|
assert result is not None
|
|
tmpl = [m for m in result if m["type"] == "template"]
|
|
assert len(tmpl) == 1
|
|
assert tmpl[0]["template"]["type"] == "buttons"
|
|
assert tmpl[0]["template"]["title"] == "My Title"
|
|
actions = tmpl[0]["template"]["actions"]
|
|
assert actions[0]["type"] == "message"
|
|
assert actions[1]["type"] == "uri"
|
|
|
|
def test_location(self):
|
|
text = "[[location: Tokyo Tower | Tokyo, Japan | 35.6586 | 139.7454]]"
|
|
result = parse_line_directives(text)
|
|
assert result is not None
|
|
loc = [m for m in result if m["type"] == "location"]
|
|
assert len(loc) == 1
|
|
assert loc[0]["title"] == "Tokyo Tower"
|
|
assert loc[0]["latitude"] == 35.6586
|
|
|
|
def test_no_directives(self):
|
|
result = parse_line_directives("Plain text")
|
|
assert result is None
|
|
|
|
def test_combined_directives(self):
|
|
text = "Header\n[[quick_replies: A, B]]\nFooter"
|
|
result = parse_line_directives(text)
|
|
assert result is not None
|
|
assert len(result) == 1
|
|
assert result[0]["type"] == "text"
|
|
|
|
def test_confirm_with_prefix_text(self):
|
|
text = "Please confirm:\n[[confirm: Proceed? | OK | Cancel]]"
|
|
result = parse_line_directives(text)
|
|
assert len(result) == 2
|
|
assert result[0]["type"] == "text"
|
|
assert result[1]["type"] == "template"
|
|
|
|
|
|
class TestLINEMessageFormatter:
|
|
def setup_method(self):
|
|
self.formatter = LINEMessageFormatter()
|
|
|
|
def _make_response(self, content, metadata=None):
|
|
identity = ChannelIdentity(
|
|
channel_id="line",
|
|
channel_type=ChannelType.LINE,
|
|
channel_user_id="Uxxx",
|
|
channel_chat_id="user_Uxxx",
|
|
)
|
|
return ChannelResponse(identity=identity, content=content, metadata=metadata or {})
|
|
|
|
def test_format_plain_text(self):
|
|
response = self._make_response("Hello")
|
|
result = self.formatter.format(response)
|
|
assert len(result) == 1
|
|
assert result[0]["type"] == "text"
|
|
assert result[0]["text"] == "Hello"
|
|
|
|
def test_format_directive(self):
|
|
response = self._make_response("[[quick_replies: A, B]]")
|
|
result = self.formatter.format(response)
|
|
assert "quickReply" in result[0]
|
|
|
|
def test_format_flex_message(self):
|
|
response = self._make_response(
|
|
"",
|
|
metadata={
|
|
"line_message_type": "flex",
|
|
"alt_text": "Test Flex",
|
|
"flex_contents": {"type": "bubble"},
|
|
},
|
|
)
|
|
result = self.formatter.format(response)
|
|
assert result[0]["type"] == "flex"
|
|
|
|
def test_format_template_message(self):
|
|
response = self._make_response(
|
|
"",
|
|
metadata={"line_message_type": "template", "template": {"type": "buttons"}},
|
|
)
|
|
result = self.formatter.format(response)
|
|
assert result[0]["type"] == "template"
|
|
|
|
def test_format_long_text_split(self):
|
|
long_text = "A" * 10000
|
|
response = self._make_response(long_text)
|
|
result = self.formatter.format(response)
|
|
assert len(result) > 1
|
|
assert len(result) <= 5
|
|
assert all(m["type"] == "text" for m in result)
|
|
|
|
|
|
class TestLINEAdapter:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
config = {
|
|
"accounts": {
|
|
"default": {
|
|
"channel_access_token": "test-token",
|
|
"channel_secret": "test-secret",
|
|
}
|
|
},
|
|
"dm_policy": "open",
|
|
"group_policy": "allowlist",
|
|
}
|
|
return LINEAdapter(config=config)
|
|
|
|
def test_channel_id(self, adapter):
|
|
assert adapter.channel_id == "line"
|
|
|
|
def test_channel_type(self, adapter):
|
|
assert adapter.channel_type == ChannelType.LINE
|
|
|
|
def test_capabilities(self, adapter):
|
|
assert adapter.supports_streaming is True
|
|
assert adapter.supports_markdown is True
|
|
assert adapter.text_chunk_limit == 5000
|
|
assert adapter.max_media_size_mb == 10
|
|
assert "loading_animation" in adapter.streaming_modes
|
|
assert adapter.webhook_path == "line/callback"
|
|
assert adapter.capabilities.polls is True
|
|
assert adapter.capabilities.reactions is True
|
|
assert adapter.capabilities.reply is True
|
|
assert adapter.capabilities.edit is False
|
|
assert adapter.capabilities.unsend is False
|
|
|
|
def test_normalize_text_message(self, adapter):
|
|
body = make_webhook_body([make_text_event(text="hello world")])
|
|
result = adapter.normalize_inbound(body)
|
|
|
|
assert isinstance(result, ChannelMessage)
|
|
assert result.content == "hello world"
|
|
assert result.identity.channel_user_id == "Uxxx"
|
|
assert result.identity.channel_chat_id == "user_Uxxx"
|
|
assert result.message_type == MessageType.TEXT
|
|
assert result.chat_type == ChatType.DIRECT
|
|
assert result.metadata["reply_token"] == "reply-abc"
|
|
|
|
def test_normalize_empty_webhook(self, adapter):
|
|
body = b'{"events":[]}'
|
|
result = adapter.normalize_inbound(body)
|
|
assert "(empty webhook)" in result.content
|
|
|
|
def test_normalize_group_message(self, adapter):
|
|
body = make_webhook_body([make_group_text_event()])
|
|
result = adapter.normalize_inbound(body)
|
|
|
|
assert result.chat_type == ChatType.GROUP
|
|
assert result.identity.channel_chat_id == "group_Cxxx"
|
|
assert result.identity.channel_user_id == "Cxxx"
|
|
|
|
def test_format_outbound_text(self, adapter):
|
|
identity = ChannelIdentity(
|
|
channel_id="line",
|
|
channel_type=ChannelType.LINE,
|
|
channel_user_id="Uxxx",
|
|
channel_chat_id="user_Uxxx",
|
|
)
|
|
response = ChannelResponse(identity=identity, content="hello world")
|
|
result = adapter.format_outbound(response)
|
|
|
|
assert len(result) == 1
|
|
assert result[0]["type"] == "text"
|
|
assert result[0]["text"] == "hello world"
|
|
|
|
def test_format_outbound_with_directives(self, adapter):
|
|
identity = ChannelIdentity(
|
|
channel_id="line",
|
|
channel_type=ChannelType.LINE,
|
|
channel_user_id="Uxxx",
|
|
channel_chat_id="user_Uxxx",
|
|
)
|
|
response = ChannelResponse(identity=identity, content="[[quick_replies: A, B]]")
|
|
result = adapter.format_outbound(response)
|
|
assert "quickReply" in result[0]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_not_connected(self, adapter):
|
|
identity = ChannelIdentity(
|
|
channel_id="line",
|
|
channel_type=ChannelType.LINE,
|
|
channel_user_id="Uxxx",
|
|
channel_chat_id="user_Uxxx",
|
|
)
|
|
response = ChannelResponse(identity=identity, content="test")
|
|
result = await adapter.send(response)
|
|
assert result.success is False
|
|
assert "not connected" in result.error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_health_check_not_connected(self, adapter):
|
|
result = await adapter.health_check()
|
|
assert result.status == "unhealthy"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_verify_signature_no_secret(self, adapter):
|
|
adapter._cached_token = None
|
|
adapter._cached_secret = None
|
|
adapter.config = {"accounts": {"default": {"channel_access_token": "t", "channel_secret": ""}}}
|
|
result = await adapter.verify_webhook_signature({}, b"{}")
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_verify_signature_missing_header(self, adapter):
|
|
await adapter._resolve_token_and_secret()
|
|
result = await adapter.verify_webhook_signature({}, b"{}")
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pre_connect_missing_token(self):
|
|
adapter = LINEAdapter(config={})
|
|
result = await adapter.pre_connect()
|
|
assert result["status"] == "error"
|
|
assert "Missing" in result["message"]
|
|
|
|
def test_get_user_info_not_connected(self, adapter):
|
|
import asyncio
|
|
|
|
async def _check():
|
|
result = await adapter.get_user_info("Uxxx")
|
|
assert result == {}
|
|
|
|
asyncio.run(_check())
|
|
|
|
|
|
class TestSenderMarkAsRead:
|
|
@pytest.fixture
|
|
def sender(self):
|
|
from yuxi.channels.adapters.line.send import LINESender
|
|
|
|
return LINESender(channel_access_token="test-token")
|
|
|
|
def test_mark_as_read_no_client(self, sender):
|
|
import asyncio
|
|
|
|
async def _check():
|
|
result = await sender.mark_as_read("Uxxx")
|
|
assert result is False
|
|
|
|
asyncio.run(_check())
|
|
|
|
|
|
class TestSenderValidateMessages:
|
|
@pytest.fixture
|
|
def sender(self):
|
|
from yuxi.channels.adapters.line.send import LINESender
|
|
|
|
return LINESender(channel_access_token="test-token")
|
|
|
|
def test_validate_push_no_client(self, sender):
|
|
import asyncio
|
|
|
|
async def _check():
|
|
result = await sender.validate_push([{"type": "text", "text": "hi"}])
|
|
assert result is False
|
|
|
|
asyncio.run(_check())
|
|
|
|
def test_validate_reply_no_client(self, sender):
|
|
import asyncio
|
|
|
|
async def _check():
|
|
result = await sender.validate_reply([{"type": "text", "text": "hi"}])
|
|
assert result is False
|
|
|
|
asyncio.run(_check())
|
|
|
|
def test_validate_multicast_no_client(self, sender):
|
|
import asyncio
|
|
|
|
async def _check():
|
|
result = await sender.validate_multicast(["U1"], [{"type": "text", "text": "hi"}])
|
|
assert result is False
|
|
|
|
asyncio.run(_check())
|
|
|
|
def test_validate_broadcast_no_client(self, sender):
|
|
import asyncio
|
|
|
|
async def _check():
|
|
result = await sender.validate_broadcast([{"type": "text", "text": "hi"}])
|
|
assert result is False
|
|
|
|
asyncio.run(_check())
|
|
|
|
|
|
class TestSenderRichMenu:
|
|
@pytest.fixture
|
|
def sender(self):
|
|
from yuxi.channels.adapters.line.send import LINESender
|
|
|
|
return LINESender(channel_access_token="test-token")
|
|
|
|
def test_get_rich_menus_not_initialized(self, sender):
|
|
import asyncio
|
|
|
|
async def _check():
|
|
result = await sender.get_rich_menus()
|
|
assert result is None
|
|
|
|
asyncio.run(_check())
|
|
|
|
def test_get_rich_menu_not_initialized(self, sender):
|
|
import asyncio
|
|
|
|
async def _check():
|
|
result = await sender.get_rich_menu("rm-123")
|
|
assert result is None
|
|
|
|
asyncio.run(_check())
|
|
|
|
def test_create_rich_menu_not_initialized(self, sender):
|
|
import asyncio
|
|
|
|
async def _check():
|
|
result = await sender.create_rich_menu({})
|
|
assert result is None
|
|
|
|
asyncio.run(_check())
|
|
|
|
def test_delete_rich_menu_not_initialized(self, sender):
|
|
import asyncio
|
|
|
|
async def _check():
|
|
result = await sender.delete_rich_menu("rm-123")
|
|
assert result is False
|
|
|
|
asyncio.run(_check())
|
|
|
|
def test_set_rich_menu_image_not_initialized(self, sender):
|
|
import asyncio
|
|
|
|
async def _check():
|
|
result = await sender.set_rich_menu_image("rm-123", b"fake-png")
|
|
assert result is False
|
|
|
|
asyncio.run(_check())
|
|
|
|
def test_link_rich_menu_to_user_not_initialized(self, sender):
|
|
import asyncio
|
|
|
|
async def _check():
|
|
result = await sender.link_rich_menu_to_user("Uxxx", "rm-123")
|
|
assert result is False
|
|
|
|
asyncio.run(_check())
|
|
|
|
def test_unlink_rich_menu_not_initialized(self, sender):
|
|
import asyncio
|
|
|
|
async def _check():
|
|
result = await sender.unlink_rich_menu_from_user("Uxxx")
|
|
assert result is False
|
|
|
|
asyncio.run(_check())
|
|
|
|
def test_get_user_rich_menu_not_initialized(self, sender):
|
|
import asyncio
|
|
|
|
async def _check():
|
|
result = await sender.get_user_rich_menu("Uxxx")
|
|
assert result is None
|
|
|
|
asyncio.run(_check())
|
|
|
|
def test_link_rich_menu_to_users_truncates(self, sender):
|
|
import asyncio
|
|
from unittest.mock import AsyncMock, Mock
|
|
|
|
mock_resp = Mock()
|
|
mock_resp.status_code = 200
|
|
mock_client = Mock()
|
|
mock_client.post = AsyncMock(return_value=mock_resp)
|
|
sender._client = mock_client
|
|
|
|
async def _check():
|
|
user_ids = [f"U{i}" for i in range(200)]
|
|
result = await sender.link_rich_menu_to_users(user_ids, "rm-123")
|
|
assert result is True
|
|
call_args = mock_client.post.call_args
|
|
payload = call_args[1]["json"]
|
|
assert len(payload["userIds"]) == 150
|
|
|
|
asyncio.run(_check())
|
|
|
|
|
|
class TestAdapterMarkAsRead:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
config = {"accounts": {"default": {"channel_access_token": "t", "channel_secret": "s"}}}
|
|
return LINEAdapter(config=config)
|
|
|
|
def test_mark_as_read_not_connected(self, adapter):
|
|
import asyncio
|
|
|
|
async def _check():
|
|
result = await adapter.mark_as_read("Uxxx")
|
|
assert result is False
|
|
|
|
asyncio.run(_check())
|
|
|
|
|
|
class TestAdapterValidateMessages:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
config = {"accounts": {"default": {"channel_access_token": "t", "channel_secret": "s"}}}
|
|
return LINEAdapter(config=config)
|
|
|
|
def test_validate_not_connected(self, adapter):
|
|
import asyncio
|
|
|
|
async def _check():
|
|
result = await adapter.validate_messages([{"type": "text", "text": "hi"}], "push")
|
|
assert result is False
|
|
|
|
asyncio.run(_check())
|
|
|
|
def test_validate_invalid_type(self, adapter):
|
|
import asyncio
|
|
|
|
async def _check():
|
|
result = await adapter.validate_messages([{"type": "text", "text": "hi"}], "unknown")
|
|
assert result is False
|
|
|
|
asyncio.run(_check())
|
|
|
|
|
|
class TestAdapterRichMenu:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
config = {"accounts": {"default": {"channel_access_token": "t", "channel_secret": "s"}}}
|
|
adapter = LINEAdapter(config=config)
|
|
return adapter
|
|
|
|
def test_list_rich_menus_not_connected(self, adapter):
|
|
import asyncio
|
|
|
|
async def _check():
|
|
result = await adapter.list_rich_menus()
|
|
assert result is None
|
|
|
|
asyncio.run(_check())
|
|
|
|
def test_create_rich_menu_not_connected(self, adapter):
|
|
import asyncio
|
|
|
|
async def _check():
|
|
result = await adapter.create_rich_menu({})
|
|
assert result is None
|
|
|
|
asyncio.run(_check())
|
|
|
|
def test_delete_rich_menu_not_connected(self, adapter):
|
|
import asyncio
|
|
|
|
async def _check():
|
|
result = await adapter.delete_rich_menu("rm-123")
|
|
assert result is False
|
|
|
|
asyncio.run(_check())
|
|
|
|
def test_link_rich_menu_not_connected(self, adapter):
|
|
import asyncio
|
|
|
|
async def _check():
|
|
result = await adapter.link_rich_menu("Uxxx", "rm-123")
|
|
assert result is False
|
|
|
|
asyncio.run(_check())
|
|
|
|
def test_link_rich_menu_bulk_not_connected(self, adapter):
|
|
import asyncio
|
|
|
|
async def _check():
|
|
result = await adapter.link_rich_menu_bulk(["U1", "U2"], "rm-123")
|
|
assert result is False
|
|
|
|
asyncio.run(_check())
|
|
|
|
def test_unlink_rich_menu_not_connected(self, adapter):
|
|
import asyncio
|
|
|
|
async def _check():
|
|
result = await adapter.unlink_rich_menu("Uxxx")
|
|
assert result is False
|
|
|
|
asyncio.run(_check())
|
|
|
|
def test_send_media_not_connected(self, adapter):
|
|
import asyncio
|
|
|
|
async def _check():
|
|
result = await adapter.send_media("chat", "image", b"")
|
|
assert result.success is False
|
|
assert "not connected" in result.error.lower()
|
|
|
|
asyncio.run(_check())
|
|
|
|
def test_send_media_no_url(self, adapter):
|
|
import asyncio
|
|
from unittest.mock import MagicMock
|
|
|
|
async def _check():
|
|
adapter._status = ChannelStatus.CONNECTED
|
|
adapter._sender = MagicMock()
|
|
result = await adapter.send_media("chat", "image", b"")
|
|
assert result.success is False
|
|
assert "HTTPS URL" in result.error
|
|
|
|
asyncio.run(_check())
|
|
|
|
|
|
class TestSessionResolution:
|
|
def test_resolve_chat_type_user(self):
|
|
assert resolve_chat_type("user") == ChatType.DIRECT
|
|
|
|
def test_resolve_chat_type_group(self):
|
|
assert resolve_chat_type("group") == ChatType.GROUP
|
|
|
|
def test_resolve_chat_type_room(self):
|
|
assert resolve_chat_type("room") == ChatType.GROUP
|
|
|
|
def test_resolve_channel_chat_id_user(self):
|
|
assert resolve_channel_chat_id("user", "U123") == "user_U123"
|
|
|
|
def test_resolve_channel_chat_id_group(self):
|
|
assert resolve_channel_chat_id("group", "C123") == "group_C123"
|
|
|
|
def test_resolve_channel_chat_id_room(self):
|
|
assert resolve_channel_chat_id("room", "R123") == "room_R123"
|
|
|
|
def test_resolve_thread_key_user(self):
|
|
key = resolve_thread_key("main", "user", "U123")
|
|
assert key == "agent:main:line:default:dm:U123"
|
|
|
|
def test_resolve_thread_key_group(self):
|
|
key = resolve_thread_key("main", "group", "C123")
|
|
assert key == "agent:main:line:default:group:C123"
|
|
|
|
def test_resolve_thread_key_with_account(self):
|
|
key = resolve_thread_key("main", "user", "U123", "bot2")
|
|
assert key == "agent:main:line:bot2:dm:U123"
|
|
key2 = resolve_thread_key("main", "room", "R123", "bot2")
|
|
assert key2 == "agent:main:line:bot2:room:R123"
|
|
|
|
|
|
class TestFlexMessages:
|
|
def test_build_bubble_basic(self):
|
|
result = build_bubble_message(
|
|
header_text="Hello",
|
|
body_sections=[{"type": "text", "text": "Body text"}],
|
|
)
|
|
assert result["type"] == "flex"
|
|
assert result["contents"]["type"] == "bubble"
|
|
assert result["contents"]["header"] is not None
|
|
assert result["contents"]["body"] is not None
|
|
|
|
def test_build_bubble_with_hero(self):
|
|
result = build_bubble_message(
|
|
hero_url="https://example.com/img.jpg",
|
|
alt_text="Test",
|
|
)
|
|
assert result["contents"]["hero"] is not None
|
|
|
|
def test_build_carousel(self):
|
|
b1 = build_bubble_message(header_text="Slide 1")["contents"]
|
|
b2 = build_bubble_message(header_text="Slide 2")["contents"]
|
|
result = build_carousel_message([b1, b2])
|
|
assert result["contents"]["type"] == "carousel"
|
|
assert len(result["contents"]["contents"]) == 2
|
|
|
|
def test_alt_text_truncation(self):
|
|
long_alt = "A" * 500
|
|
result = build_bubble_message(alt_text=long_alt)
|
|
assert len(result["altText"]) <= 400
|
|
|
|
|
|
class TestTemplateMessages:
|
|
def test_build_buttons_template(self):
|
|
actions = [
|
|
{"type": "message", "label": "OK", "text": "ok"},
|
|
{"type": "uri", "label": "Link", "uri": "https://example.com"},
|
|
]
|
|
result = build_buttons_template("Title", "Description", actions)
|
|
assert result["type"] == "template"
|
|
assert result["template"]["type"] == "buttons"
|
|
assert len(result["template"]["actions"]) == 2
|
|
|
|
def test_build_confirm_template(self):
|
|
actions = [
|
|
{"type": "message", "label": "Yes", "text": "yes"},
|
|
{"type": "message", "label": "No", "text": "no"},
|
|
]
|
|
result = build_confirm_template("Are you sure?", actions)
|
|
assert result["template"]["type"] == "confirm"
|
|
|
|
|
|
class TestQuickReply:
|
|
def test_build_items(self):
|
|
items = build_quick_reply_items(["Option A", "Option B", "Option C"])
|
|
assert len(items) == 3
|
|
assert items[0]["action"]["label"] == "Option A"
|
|
assert items[0]["action"]["type"] == "message"
|
|
|
|
def test_build_items_max_13(self):
|
|
items = build_quick_reply_items([f"Opt{i}" for i in range(20)])
|
|
assert len(items) == 13
|
|
|
|
def test_build_text_with_quick_reply(self):
|
|
result = build_text_with_quick_reply("Choose:", ["A", "B"])
|
|
assert result["type"] == "text"
|
|
assert result["text"] == "Choose:"
|
|
assert len(result["quickReply"]["items"]) == 2
|
|
|
|
|
|
class TestNewEventTypes:
|
|
def setup_method(self):
|
|
self.normalizer = LINEEventNormalizer()
|
|
|
|
def test_normalize_unsend_event(self):
|
|
event = {
|
|
"type": "unsend",
|
|
"source": {"type": "user", "userId": "Uxxx"},
|
|
"unsend": {"messageId": "msg-123"},
|
|
"timestamp": 0,
|
|
}
|
|
result = self.normalizer.normalize(event)
|
|
assert result.event_type == EventType.MESSAGE_DELETED
|
|
assert "msg-123" in result.content
|
|
|
|
def test_normalize_beacon_event(self):
|
|
event = {
|
|
"type": "beacon",
|
|
"source": {"type": "user", "userId": "Uxxx"},
|
|
"beacon": {"type": "enter", "hwid": "hw-001"},
|
|
"timestamp": 0,
|
|
}
|
|
result = self.normalizer.normalize(event)
|
|
assert result.event_type == EventType.SYSTEM_EVENT
|
|
assert "Beacon" in result.content
|
|
assert "hw-001" in result.content
|
|
|
|
def test_normalize_account_link_event(self):
|
|
event = {
|
|
"type": "accountLink",
|
|
"source": {"type": "user", "userId": "Uxxx"},
|
|
"link": {"result": "ok"},
|
|
"timestamp": 0,
|
|
}
|
|
result = self.normalizer.normalize(event)
|
|
assert result.event_type == EventType.SYSTEM_EVENT
|
|
assert "ok" in result.content
|
|
|
|
def test_normalize_things_event(self):
|
|
event = {
|
|
"type": "things",
|
|
"source": {"type": "user", "userId": "Uxxx"},
|
|
"things": {"type": "scenarioResult", "deviceId": "dev-001"},
|
|
"timestamp": 0,
|
|
}
|
|
result = self.normalizer.normalize(event)
|
|
assert result.event_type == EventType.SYSTEM_EVENT
|
|
assert "Things" in result.content
|
|
assert "dev-001" in result.content
|
|
|
|
def test_normalize_activated_event(self):
|
|
event = {
|
|
"type": "activated",
|
|
"source": {"type": "user", "userId": "Uxxx"},
|
|
"timestamp": 0,
|
|
}
|
|
result = self.normalizer.normalize(event)
|
|
assert result.event_type == EventType.SYSTEM_EVENT
|
|
assert "激活" in result.content
|
|
|
|
def test_normalize_deactivated_event(self):
|
|
event = {
|
|
"type": "deactivated",
|
|
"source": {"type": "user", "userId": "Uxxx"},
|
|
"timestamp": 0,
|
|
}
|
|
result = self.normalizer.normalize(event)
|
|
assert result.event_type == EventType.SYSTEM_EVENT
|
|
assert "停用" in result.content
|
|
|
|
def test_normalize_video_play_complete_event(self):
|
|
event = {
|
|
"type": "videoPlayComplete",
|
|
"source": {"type": "user", "userId": "Uxxx"},
|
|
"videoPlayComplete": {"trackingId": "track-001"},
|
|
"timestamp": 0,
|
|
}
|
|
result = self.normalizer.normalize(event)
|
|
assert result.event_type == EventType.SYSTEM_EVENT
|
|
assert "track-001" in result.content
|
|
|
|
|
|
class TestQuickReplyValidation:
|
|
def test_empty_label_skipped(self):
|
|
items = build_quick_reply_items(["", "Option A", " "])
|
|
assert len(items) == 1
|
|
assert items[0]["action"]["label"] == "Option A"
|
|
|
|
def test_all_empty_labels(self):
|
|
items = build_quick_reply_items(["", " ", "\n"])
|
|
assert len(items) == 0
|
|
|
|
def test_empty_options_text_with_quick_reply(self):
|
|
result = build_text_with_quick_reply("Hello", [])
|
|
assert result["type"] == "text"
|
|
assert result["text"] == "Hello"
|
|
assert "quickReply" not in result
|
|
|
|
def test_whitespace_label_trimmed(self):
|
|
items = build_quick_reply_items([" Option A "])
|
|
assert items[0]["action"]["label"] == "Option A"
|
|
|
|
|
|
class TestFlexMessagesValidation:
|
|
def test_carousel_max_12_bubbles(self):
|
|
bubbles = [build_bubble_message(header_text=f"Slide {i}")["contents"] for i in range(15)]
|
|
result = build_carousel_message(bubbles)
|
|
assert len(result["contents"]["contents"]) == 12
|
|
|
|
|
|
class TestDirectivesValidation:
|
|
def test_empty_text(self):
|
|
result = parse_line_directives("")
|
|
assert result is None
|
|
|
|
def test_whitespace_only_text(self):
|
|
result = parse_line_directives(" \n ")
|
|
assert result is None
|
|
|
|
def test_location_invalid_float(self):
|
|
text = "[[location: Tokyo | Japan | abc | xyz]]"
|
|
result = parse_line_directives(text)
|
|
loc = [m for m in result if m["type"] == "location"]
|
|
assert len(loc) == 1
|
|
assert loc[0]["latitude"] == 0.0
|
|
assert loc[0]["longitude"] == 0.0
|
|
|
|
def test_quick_replies_empty_labels_filtered(self):
|
|
text = "[[quick_replies: , , Option A, , ]]"
|
|
result = parse_line_directives(text)
|
|
assert result is not None
|
|
assert len(result[0]["quickReply"]["items"]) == 1
|
|
|
|
def test_all_empty_labels_no_items(self):
|
|
text = "[[quick_replies: , , ]]"
|
|
result = parse_line_directives(text)
|
|
assert result is None
|
|
|
|
def test_directive_count_limit(self):
|
|
text = (
|
|
"[[quick_replies: A, B]]\n"
|
|
"[[confirm: Q? | Y | N]]\n"
|
|
"[[buttons: Title | Desc | Btn:action]]\n"
|
|
"[[location: Tokyo | Japan | 35.6 | 139.7]]\n"
|
|
"[[quick_replies: C, D]]\n"
|
|
"[[confirm: Q2? | Y2 | N2]]"
|
|
)
|
|
result = parse_line_directives(text)
|
|
assert len(result) <= 5
|
|
|
|
|
|
class TestWebhookValidation:
|
|
def test_body_too_large(self):
|
|
oversized = b"x" * (LINE_WEBHOOK_MAX_BODY_BYTES + 1)
|
|
assert validate_line_signature(oversized, "sig", "secret") is False
|
|
|
|
|
|
class TestSafeTruncate:
|
|
def test_normal_text(self):
|
|
assert _safe_truncate("hello", 10) == "hello"
|
|
|
|
def test_truncation(self):
|
|
result = _safe_truncate("abcdefghij", 5)
|
|
assert result == "abcde"
|
|
|
|
def test_combining_marks(self):
|
|
text = "a\u0300b\u0301c" # a + combining grave, b + combining acute, c
|
|
result = _safe_truncate(text, 2)
|
|
assert len(result) <= 2
|
|
|
|
def test_no_truncation_needed(self):
|
|
assert _safe_truncate("hello", 100) == "hello"
|
|
|
|
|
|
class TestAdapterThreadSafety:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
config = {
|
|
"accounts": {
|
|
"default": {
|
|
"channel_access_token": "test-token",
|
|
"channel_secret": "test-secret",
|
|
}
|
|
}
|
|
}
|
|
return LINEAdapter(config=config)
|
|
|
|
def test_token_lock_initial_state(self, adapter):
|
|
assert adapter._last_reply_token is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reply_token_set_and_read_thread_safe(self, adapter):
|
|
body = json.dumps({
|
|
"events": [{
|
|
"type": "message",
|
|
"message": {"type": "text", "id": "123", "text": "hello"},
|
|
"source": {"type": "user", "userId": "Uxxx"},
|
|
"replyToken": "token-123",
|
|
"timestamp": 0,
|
|
}]
|
|
}).encode("utf-8")
|
|
adapter.normalize_inbound(body)
|
|
async with adapter._token_lock:
|
|
assert adapter._last_reply_token == "token-123"
|
|
|
|
def test_receive_returns_empty(self, adapter):
|
|
import asyncio
|
|
|
|
async def _check():
|
|
messages = []
|
|
async for msg in adapter.receive():
|
|
messages.append(msg)
|
|
assert len(messages) == 0
|
|
|
|
asyncio.run(_check())
|
|
|
|
|
|
class TestLINESenderNewFeatures:
|
|
@pytest.fixture
|
|
def sender(self):
|
|
from yuxi.channels.adapters.line.send import LINESender
|
|
|
|
return LINESender(channel_access_token="test-token")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multicast_truncates_recipients(self, sender):
|
|
import httpx
|
|
|
|
mock_resp = httpx.Response(200, json={})
|
|
mock_client = MagicMock()
|
|
mock_client.post = AsyncMock(return_value=mock_resp)
|
|
sender._client = mock_client
|
|
|
|
user_ids = [f"U{i}" for i in range(600)]
|
|
result = await sender.multicast_message(user_ids, [{"type": "text", "text": "hello"}])
|
|
assert result.success is True
|
|
call_args = mock_client.post.call_args
|
|
payload = call_args[1]["json"]
|
|
assert len(payload["to"]) == 500
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multicast_rejects_no_client(self, sender):
|
|
result = await sender.multicast_message(["U1"], [])
|
|
assert result.success is False
|
|
assert "not initialized" in result.error.lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_broadcast_rejects_no_client(self, sender):
|
|
result = await sender.broadcast_message([])
|
|
assert result.success is False
|
|
assert "not initialized" in result.error.lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_quota_not_initialized(self, sender):
|
|
result = await sender.get_message_quota()
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_quota_consumption_not_initialized(self, sender):
|
|
result = await sender.get_message_quota_consumption()
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_delivery_status_invalid_type(self, sender):
|
|
sender._client = MagicMock()
|
|
result = await sender.get_delivery_status("20250101", "invalid")
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_delivery_status_not_initialized(self, sender):
|
|
result = await sender.get_delivery_status("20250101", "push")
|
|
assert result is None
|
|
|
|
|
|
class TestLINEAdapterNewFeatures:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
config = {
|
|
"accounts": {
|
|
"default": {
|
|
"channel_access_token": "test-token",
|
|
"channel_secret": "test-secret",
|
|
}
|
|
}
|
|
}
|
|
return LINEAdapter(config=config)
|
|
|
|
def _make_response(self, content="test"):
|
|
identity = ChannelIdentity(
|
|
channel_id="line",
|
|
channel_type=ChannelType.LINE,
|
|
channel_user_id="Uxxx",
|
|
channel_chat_id="user_Uxxx",
|
|
)
|
|
return ChannelResponse(identity=identity, content=content)
|
|
|
|
def test_send_multicast_not_connected(self, adapter):
|
|
import asyncio
|
|
|
|
async def _check():
|
|
result = await adapter.send_multicast(["U1", "U2"], self._make_response())
|
|
assert result.success is False
|
|
assert "not connected" in result.error.lower()
|
|
|
|
asyncio.run(_check())
|
|
|
|
def test_send_broadcast_not_connected(self, adapter):
|
|
import asyncio
|
|
|
|
async def _check():
|
|
result = await adapter.send_broadcast(self._make_response())
|
|
assert result.success is False
|
|
assert "not connected" in result.error.lower()
|
|
|
|
asyncio.run(_check()) |