ForcePilot/backend/test/unit/channels/test_line_adapter.py
Kris 69fe97a90d test: 批量修复并新增单元测试用例
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重连相关测试
2026-05-13 16:43:01 +08:00

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