新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块 同时在测试配置中添加了测试用的OpenAI API密钥环境变量
1207 lines
40 KiB
Python
1207 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 False
|
|
assert adapter.capabilities.reactions is False
|
|
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
|
|
|
|
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)
|
|
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()) |