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