ForcePilot/backend/test/unit/channels/test_line_adapter.py
Kris 3264900bc9 test: 新增多渠道单元测试用例并配置测试环境变量
新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块
同时在测试配置中添加了测试用的OpenAI API密钥环境变量
2026-05-12 00:56:47 +08:00

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())