from __future__ import annotations import asyncio import json import time from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest from yuxi.channels.adapters.line.markdown_to_line import ( _apply_decorator, _BOLD_SERIF_BOLD, _ITALIC_SERIF, _safe_truncate, _STRIKETHROUGH, extract_flex_messages_from_markdown, markdown_to_line_decorated, markdown_to_line_text, ) from yuxi.channels.adapters.line.webhook import ( MultiAccountSignatureRouter, WebhookConcurrencyGuard, WebhookReplayGuard, ReplayDetectedError, validate_line_signature, parse_webhook_body, ) from yuxi.channels.adapters.line.quick_reply import build_quick_reply_items, build_text_with_quick_reply from yuxi.channels.adapters.line.template_messages import ( build_buttons_template, build_carousel_template, build_confirm_template, build_datetime_picker_action, build_image_carousel_column, build_image_carousel_template, build_link_menu, build_product_carousel, build_template_message_from_payload, build_yes_no_confirm, ) from yuxi.channels.adapters.line.reply_chunks import ReplyChunker, reply_with_chunks from yuxi.channels.adapters.line.approval import LINEApprovalAdapter from yuxi.channels.adapters.line.bindings import LINEBindingsProvider from yuxi.channels.adapters.line.config_schema import validate_line_config from yuxi.channels.adapters.line.transform_reply_payload import transform_reply_payload from yuxi.channels.adapters.line.sticker_catalog import ( search_stickers, get_sticker_by_id, get_stickers_by_category, get_stickers_by_package, list_available_packages, ) from yuxi.channels.adapters.line.adapter import ( LINEAdapter, _bot_mentioned, _classify_send_error, _gen_pairing_code, _is_auth_error, _is_comm_channel_disabled, _is_dm_chat, _is_group_chat, _is_network_error, _is_private_hostname, _is_rate_limited, _is_reply_token_expired, _resolve_mentions, _validate_target_id, ) from yuxi.channels.adapters.line.normalizer import LINEEventNormalizer from yuxi.channels.adapters.line.formatter import LINEMessageFormatter from yuxi.channels.adapters.line.send import LINESender, _parse_retry_after from yuxi.channels.models import ( ChannelIdentity, ChannelMessage, ChannelResponse, ChannelStatus, ChannelType, ChatType, DeliveryResult, EventType, HealthStatus, MentionsInfo, MessageType, ) # --------------------------------------------------------------------------- # Markdown to LINE tests # --------------------------------------------------------------------------- class TestMarkdownDecorated: def test_bold_decorated(self): result = markdown_to_line_decorated("Hello **world** test") assert "world" not in result assert len(result) == len("Hello world test") def test_italic_decorated(self): result = markdown_to_line_decorated("Hello *world* test") assert "world" not in result def test_strikethrough_decorated(self): result = markdown_to_line_decorated("Hello ~~world~~ test") assert "world" not in result def test_inline_code(self): result = markdown_to_line_decorated("Use `print()` function") assert "[print()]" in result def test_link(self): result = markdown_to_line_decorated("[click](https://example.com)") assert "click" in result assert "https://example.com" in result def test_heading_removed(self): result = markdown_to_line_decorated("# Heading\ncontent") assert "Heading" in result assert "#" not in result def test_unordered_list(self): result = markdown_to_line_decorated("- item1\n- item2") assert "•" in result def test_blockquote(self): result = markdown_to_line_decorated("> quote text") assert "┃" in result def test_horizontal_rule(self): result = markdown_to_line_decorated("text\n---\nmore text") assert "───" in result def test_code_block(self): result = markdown_to_line_decorated("```python\nprint('hello')\n```") assert "[Code Block]" in result assert "[/Code Block]" in result class TestMarkdownPlain: def test_bold_stripped(self): result = markdown_to_line_text("Hello **world** test") assert "**" not in result assert "world" in result def test_italic_stripped(self): result = markdown_to_line_text("Hello *world* test") assert "*" not in result def test_strikethrough_stripped(self): result = markdown_to_line_text("Hello ~~world~~ test") assert "~~" not in result assert "world" in result def test_code_block_stripped(self): result = markdown_to_line_text("```\ncode\n```") assert "code" in result class TestApplyDecorator: def test_bold_mapping(self): result = _apply_decorator("Hello", _BOLD_SERIF_BOLD) assert len(result) == 5 assert result != "Hello" def test_italic_mapping(self): result = _apply_decorator("Hello", _ITALIC_SERIF) assert len(result) == 5 assert result != "Hello" def test_strikethrough_mapping(self): result = _apply_decorator("Hello", _STRIKETHROUGH) assert len(result) > 5 def test_non_mapped_chars(self): result = _apply_decorator("!@#$%", _BOLD_SERIF_BOLD) assert result == "!@#$%" class TestSafeTruncateEdgeCases: def test_empty_string(self): assert _safe_truncate("", 10) == "" def test_zero_max_len(self): assert _safe_truncate("hello", 0) == "" def test_exact_length(self): assert _safe_truncate("hello", 5) == "hello" class TestExtractFlexMessages: def test_table_2_cols_creates_receipt(self): text = "| Item | Price |\n|------|-------|\n| Apple | $1 |" result = extract_flex_messages_from_markdown(text) assert len(result) >= 1 assert result[0]["type"] == "flex" def test_table_3_cols_creates_table(self): text = "| Name | Age | City |\n|------|-----|------|\n| Alice | 30 | NY |" result = extract_flex_messages_from_markdown(text) assert len(result) >= 1 assert result[0]["type"] == "flex" def test_code_block_flex(self): text = "```python\nprint('hello')\n```" result = extract_flex_messages_from_markdown(text) assert len(result) >= 1 assert result[0]["type"] == "flex" def test_link_bubble(self): text = "[Google](https://google.com)\n[Yahoo](https://yahoo.com)" result = extract_flex_messages_from_markdown(text) assert len(result) >= 1 assert result[0]["type"] == "flex" def test_no_flex_content(self): result = extract_flex_messages_from_markdown("plain text only") assert len(result) == 0 def test_max_5_results(self): lines = [] for i in range(10): lines.append(f"| Col1_{i} | Col2_{i} |\n|------|-------|\n| A | B |") text = "\n".join(lines) result = extract_flex_messages_from_markdown(text) assert len(result) <= 5 # --------------------------------------------------------------------------- # Sender tests # --------------------------------------------------------------------------- class TestSenderPostWithRetry: @pytest.fixture def sender(self): return LINESender(channel_access_token="test-token") @pytest.mark.asyncio async def test_success_200(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 result = await sender._post_with_retry("/message/push", {"to": "Uxxx"}, "push") assert result.success is True @pytest.mark.asyncio async def test_auth_error_401(self, sender): import httpx mock_resp = httpx.Response(401, text="auth error") mock_client = MagicMock() mock_client.post = AsyncMock(return_value=mock_resp) sender._client = mock_client result = await sender._post_with_retry("/message/push", {"to": "Uxxx"}, "push") assert result.success is False assert "401" in result.error @pytest.mark.asyncio async def test_forbidden_403(self, sender): import httpx mock_resp = httpx.Response(403) mock_client = MagicMock() mock_client.post = AsyncMock(return_value=mock_resp) sender._client = mock_client result = await sender._post_with_retry("/message/push", {"to": "Uxxx"}, "push") assert result.success is False assert "403" in result.error @pytest.mark.asyncio async def test_server_error_500_retry(self, sender): import httpx mock_resp = httpx.Response(500) mock_client = MagicMock() mock_client.post = AsyncMock(return_value=mock_resp) sender._client = mock_client result = await sender._post_with_retry("/message/push", {"to": "Uxxx"}, "push") assert result.success is False assert "server error" in result.error.lower() @pytest.mark.asyncio async def test_timeout_retry(self, sender): import httpx mock_client = MagicMock() mock_client.post = AsyncMock(side_effect=httpx.TimeoutException("timeout")) sender._client = mock_client result = await sender._post_with_retry("/message/push", {"to": "Uxxx"}, "push") assert result.success is False assert "timeout" in result.error.lower() @pytest.mark.asyncio async def test_network_error_retry(self, sender): import httpx mock_client = MagicMock() mock_client.post = AsyncMock(side_effect=httpx.NetworkError("network error")) sender._client = mock_client result = await sender._post_with_retry("/message/push", {"to": "Uxxx"}, "push") assert result.success is False assert "network" in result.error.lower() @pytest.mark.asyncio async def test_rate_limit_429_retry(self, sender): import httpx mock_resp_429 = httpx.Response(429, headers={"Retry-After": "0.1"}) mock_resp_200 = httpx.Response(200, json={}) mock_client = MagicMock() mock_client.post = AsyncMock(side_effect=[mock_resp_429, mock_resp_200]) sender._client = mock_client result = await sender._post_with_retry("/message/push", {"to": "Uxxx"}, "push") assert result.success is True @pytest.mark.asyncio async def test_no_client(self, sender): result = await sender._post_with_retry("/message/push", {"to": "Uxxx"}, "push") assert result.success is False assert "not initialized" in result.error.lower() @pytest.mark.asyncio async def test_generic_http_error(self, sender): import httpx mock_client = MagicMock() mock_client.post = AsyncMock(side_effect=httpx.HTTPError("generic error")) sender._client = mock_client result = await sender._post_with_retry("/message/push", {"to": "Uxxx"}, "push") assert result.success is False assert "http" in result.error.lower() class TestParseRetryAfter: def test_valid_number(self): assert _parse_retry_after("5.5") == 5.5 def test_invalid_string(self): assert _parse_retry_after("abc") == 1.0 def test_none(self): assert _parse_retry_after(None) == 1.0 def test_empty(self): assert _parse_retry_after("") == 1.0 class TestSenderReplyWithQuote: @pytest.fixture def sender(self): return LINESender(channel_access_token="test-token") @pytest.mark.asyncio async def test_reply_with_quote_token(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 result = await sender.reply_message("reply-abc", [{"type": "text", "text": "hi"}], quote_token="qt-123") assert result.success is True class TestSenderPushMessage: @pytest.fixture def sender(self): return LINESender(channel_access_token="test-token") @pytest.mark.asyncio async def test_push_truncates_messages(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 messages = [{"type": "text", "text": f"msg{i}"} for i in range(10)] result = await sender.push_message("Uxxx", messages) assert result.success is True # --------------------------------------------------------------------------- # Webhook replay guard tests # --------------------------------------------------------------------------- class TestWebhookReplayGuard: def test_check_and_claim_new(self): guard = WebhookReplayGuard() guard.check_and_claim("sig1") def test_check_and_claim_replay(self): guard = WebhookReplayGuard() guard.check_and_claim("sig1") with pytest.raises(ReplayDetectedError): guard.check_and_claim("sig1") def test_clear(self): guard = WebhookReplayGuard() guard.check_and_claim("sig1") guard.clear() guard.check_and_claim("sig1") def test_max_entries_eviction(self): guard = WebhookReplayGuard(_max_entries=5) for i in range(10): guard.check_and_claim(f"sig{i}") assert len(guard._seen_hashes) <= 5 class TestMultiAccountSignatureRouter: def test_register_and_match(self): router = MultiAccountSignatureRouter() router.register_account("bot1", "secret1") body = b'{"events":[]}' import base64, hashlib, hmac sig = base64.b64encode( hmac.new(key=b"secret1", msg=body, digestmod=hashlib.sha256).digest() ).decode("utf-8") matched = router.match_signature(body, sig) assert matched == "bot1" def test_no_match(self): router = MultiAccountSignatureRouter() router.register_account("bot1", "secret1") matched = router.match_signature(b"{}", "bad-sig") assert matched is None def test_unregister(self): router = MultiAccountSignatureRouter() router.register_account("bot1", "secret1") router.unregister_account("bot1") assert router.match_signature(b"{}", "sig") is None def test_list_accounts(self): router = MultiAccountSignatureRouter() router.register_account("a", "s1") router.register_account("b", "s2") assert set(router.list_accounts()) == {"a", "b"} def test_clear(self): router = MultiAccountSignatureRouter() router.register_account("a", "s1") router.clear() assert router.list_accounts() == [] class TestWebhookConcurrencyGuard: @pytest.mark.asyncio async def test_acquire_and_release(self): guard = WebhookConcurrencyGuard() result = await guard.acquire("test_path") assert result is True guard.release("test_path") @pytest.mark.asyncio async def test_in_flight_count(self): guard = WebhookConcurrencyGuard() assert guard.in_flight_count("test") == 0 await guard.acquire("test") assert guard.in_flight_count("test") == 1 guard.release("test") assert guard.in_flight_count("test") == 0 def test_release_without_acquire(self): guard = WebhookConcurrencyGuard() guard.release("nonexistent") @pytest.mark.asyncio async def test_second_acquire_blocks(self): guard = WebhookConcurrencyGuard(_max_concurrent=1) result1 = await guard.acquire("path") assert result1 is True import asyncio acquired = [] async def try_acquire(): ok = await guard.acquire("path") acquired.append(ok) guard.release("path") task = asyncio.ensure_future(try_acquire()) await asyncio.sleep(0.05) assert len(acquired) == 0 guard.release("path") await asyncio.sleep(0.05) assert len(acquired) == 1 assert acquired[0] is True task.cancel() # --------------------------------------------------------------------------- # Quick reply dict-based tests # --------------------------------------------------------------------------- class TestQuickReplyDictOptions: def test_postback_action(self): items = build_quick_reply_items([{"label": "Buy", "action_type": "postback", "data": "action=buy"}]) assert len(items) == 1 assert items[0]["action"]["type"] == "postback" assert items[0]["action"]["data"] == "action=buy" def test_uri_action(self): items = build_quick_reply_items([{"label": "Open", "action_type": "uri", "uri": "https://example.com"}]) assert len(items) == 1 assert items[0]["action"]["type"] == "uri" def test_camera_action(self): items = build_quick_reply_items([{"label": "Camera", "action_type": "camera"}]) assert len(items) == 1 assert items[0]["action"]["type"] == "camera" def test_camera_roll_action(self): items = build_quick_reply_items([{"label": "Gallery", "action_type": "camera_roll"}]) assert len(items) == 1 assert items[0]["action"]["type"] == "cameraRoll" def test_location_action(self): items = build_quick_reply_items([{"label": "Send Location", "action_type": "location"}]) assert len(items) == 1 assert items[0]["action"]["type"] == "location" def test_mixed_string_and_dict(self): items = build_quick_reply_items(["Option A", {"label": "Option B", "action_type": "postback", "data": "b"}]) assert len(items) == 2 assert items[0]["action"]["type"] == "message" assert items[1]["action"]["type"] == "postback" def test_postback_with_input_option(self): items = build_quick_reply_items( [{"label": "Buy", "action_type": "postback", "data": "buy", "input_option": "openKeyboard", "fill_in_text": "amount"}] ) assert len(items) == 1 assert items[0]["action"]["inputOption"] == "openKeyboard" assert items[0]["action"]["fillInText"] == "amount" def test_uri_with_alt_uri(self): alt_uri = {"desktop": "https://desktop.example.com"} items = build_quick_reply_items( [{"label": "Open", "action_type": "uri", "uri": "https://example.com", "alt_uri": alt_uri}] ) assert len(items) == 1 assert "altUri" in items[0]["action"] def test_dict_empty_label_skipped(self): items = build_quick_reply_items([{"label": "", "action_type": "message", "text": "hi"}]) assert len(items) == 0 # --------------------------------------------------------------------------- # Template messages extended tests # --------------------------------------------------------------------------- class TestTemplateMessagesExtended: def test_build_carousel_template(self): columns = [ {"title": "Col1", "text": "Text1", "actions": []}, {"title": "Col2", "text": "Text2", "actions": []}, ] result = build_carousel_template(columns) assert result["template"]["type"] == "carousel" assert len(result["template"]["columns"]) == 2 def test_build_carousel_max_10(self): columns = [{"title": f"Col{i}", "text": "T", "actions": []} for i in range(15)] result = build_carousel_template(columns) assert len(result["template"]["columns"]) == 10 def test_build_image_carousel(self): columns = [{"imageUrl": "https://a.jpg", "action": {"type": "uri", "uri": "https://a.com"}}] result = build_image_carousel_template(columns) assert result["template"]["type"] == "image_carousel" def test_build_image_carousel_column(self): col = build_image_carousel_column("https://img.jpg", {"type": "uri", "uri": "https://example.com"}) assert col["imageUrl"] == "https://img.jpg" assert col["action"] is not None def test_build_image_carousel_column_no_action(self): col = build_image_carousel_column("https://img.jpg") assert "action" not in col def test_build_product_carousel(self): products = [ {"title": "Product", "description": "Desc", "image_url": "https://img.jpg", "action_url": "https://buy.com", "price": "$10"}, ] result = build_product_carousel(products) assert result["template"]["type"] == "carousel" def test_build_link_menu(self): links = [{"label": "Link1", "uri": "https://a.com"}, {"label": "Link2", "uri": "https://b.com"}] result = build_link_menu("Menu", links) assert result["template"]["type"] == "buttons" assert len(result["template"]["actions"]) == 2 def test_build_link_menu_empty_links(self): result = build_link_menu("Menu", []) assert result == {} def test_build_yes_no_confirm(self): result = build_yes_no_confirm("Proceed?") assert result["template"]["type"] == "confirm" assert result["template"]["text"] == "Proceed?" assert len(result["template"]["actions"]) == 2 def test_build_buttons_with_thumbnail(self): result = build_buttons_template("Title", "Text", [], thumbnail_url="https://img.jpg") assert result["template"]["thumbnailImageUrl"] == "https://img.jpg" def test_build_template_from_payload_buttons(self): payload = {"type": "buttons", "title": "T", "text": "D", "actions": []} result = build_template_message_from_payload(payload) assert result is not None assert result["template"]["type"] == "buttons" def test_build_template_from_payload_confirm(self): payload = {"type": "confirm", "text": "Are you sure?", "actions": []} result = build_template_message_from_payload(payload) assert result is not None assert result["template"]["type"] == "confirm" def test_build_template_from_payload_carousel(self): payload = {"type": "carousel", "columns": []} result = build_template_message_from_payload(payload) assert result is not None def test_build_template_from_payload_image_carousel(self): payload = {"type": "image_carousel", "columns": []} result = build_template_message_from_payload(payload) assert result is not None def test_build_template_from_payload_product_carousel(self): payload = {"type": "product_carousel", "products": []} result = build_template_message_from_payload(payload) assert result is not None def test_build_template_from_payload_link_menu(self): payload = {"type": "link_menu", "title": "Menu", "links": []} result = build_template_message_from_payload(payload) assert result is not None def test_build_template_from_payload_yes_no(self): payload = {"type": "yes_no", "question": "Proceed?"} result = build_template_message_from_payload(payload) assert result is not None def test_build_template_from_payload_unknown(self): result = build_template_message_from_payload({"type": "unknown"}) assert result is None def test_build_datetime_picker_date(self): result = build_datetime_picker_action(mode="date", label="Pick", data="d1") assert result["type"] == "datetimepicker" assert result["mode"] == "date" def test_build_datetime_picker_time(self): result = build_datetime_picker_action(mode="time") assert result["mode"] == "time" def test_build_datetime_picker_with_bounds(self): result = build_datetime_picker_action( mode="datetime", initial="2025-01-01", max_value="2025-12-31", min_value="2024-01-01" ) assert result["initial"] == "2025-01-01" assert result["max"] == "2025-12-31" assert result["min"] == "2024-01-01" # --------------------------------------------------------------------------- # Reply chunks tests # --------------------------------------------------------------------------- class TestReplyChunker: def test_reset(self): chunker = ReplyChunker(None) chunker._accumulated = "data" chunker._sent_count = 3 chunker.reset() assert chunker._accumulated == "" assert chunker._sent_count == 0 class TestReplyWithChunks: @pytest.fixture def adapter(self): config = { "accounts": { "default": { "channel_access_token": "test-token", "channel_secret": "test-secret", } } } return LINEAdapter(config=config) @pytest.mark.asyncio async def test_short_content_no_split(self, adapter): result = await reply_with_chunks(adapter, "user_Uxxx", "short text") assert result.success is False # --------------------------------------------------------------------------- # Approval adapter tests # --------------------------------------------------------------------------- class FakeSender: async def push_message(self, *args, **kwargs): return DeliveryResult(success=True) class FakeAdapter: def __init__(self): self._sender = FakeSender() class TestApprovalAdapter: @pytest.fixture def mock_adapter(self): return FakeAdapter() def test_resolve_approval_from_postback(self): approval = LINEApprovalAdapter(None) aid = approval.resolve_approval_from_postback("approval:apr_123:approved") assert aid == "apr_123" def test_resolve_not_approval(self): approval = LINEApprovalAdapter(None) assert approval.resolve_approval_from_postback("action=buy") is None def test_resolve_approval_action(self): approval = LINEApprovalAdapter(None) action = approval.resolve_approval_action("approval:apr_123:rejected") assert action == "rejected" @pytest.mark.asyncio async def test_create_approval_and_resolve(self, mock_adapter): approval = LINEApprovalAdapter(mock_adapter) result = await approval.create_approval("user_Uxxx", "Test", "Description") assert result is not None aid = result["approval_id"] response = approval.handle_approval_response(aid, "approved", "Uxxx") assert response is not None assert response["status"] == "approved" assert response["approver"] == "Uxxx" @pytest.mark.asyncio async def test_handle_expired_approval(self, mock_adapter): approval = LINEApprovalAdapter(mock_adapter) result = await approval.create_approval("user_Uxxx", "Test", "Desc", timeout_seconds=-1) await asyncio.sleep(0.01) response = approval.handle_approval_response(result["approval_id"], "approved", "Uxxx") assert response is not None assert response["status"] == "expired" def test_get_nonexistent_approval(self): approval = LINEApprovalAdapter(None) assert approval.get_approval("nonexistent") is None @pytest.mark.asyncio async def test_cancel_pending_approval(self, mock_adapter): approval = LINEApprovalAdapter(mock_adapter) result = await approval.create_approval("user_Uxxx", "Test", "Desc") assert approval.cancel_approval(result["approval_id"]) is True assert approval.cancel_approval(result["approval_id"]) is False apr = approval.get_approval(result["approval_id"]) assert apr["status"] == "cancelled" @pytest.mark.asyncio async def test_list_pending(self, mock_adapter): approval = LINEApprovalAdapter(mock_adapter) r1 = await approval.create_approval("user_Uxxx", "Test1", "Desc1") await asyncio.sleep(0.002) r2 = await approval.create_approval("user_Uxxx", "Test2", "Desc2") assert r1 is not None assert r2 is not None pending = approval.list_pending_approvals("user_Uxxx") assert len(pending) == 2 @pytest.mark.asyncio async def test_create_command_approval(self, mock_adapter): approval = LINEApprovalAdapter(mock_adapter) result = await approval.create_command_approval("user_Uxxx", "deploy", "deploy to prod") assert result is not None assert "deploy" in result["title"] @pytest.mark.asyncio async def test_create_config_approval(self, mock_adapter): approval = LINEApprovalAdapter(mock_adapter) result = await approval.create_config_approval("user_Uxxx", "timeout", "30", "60") assert result is not None assert "timeout" in result["title"] @pytest.mark.asyncio async def test_create_sensitive_approval(self, mock_adapter): approval = LINEApprovalAdapter(mock_adapter) result = await approval.create_sensitive_approval("user_Uxxx", "delete_all", "delete all data") assert result is not None assert "delete_all" in result["title"] @pytest.mark.asyncio async def test_create_approval_failure(self): class FailingSender: async def push_message(self, *args, **kwargs): return DeliveryResult(success=False, error="error") class FailingAdapter: def __init__(self): self._sender = FailingSender() approval = LINEApprovalAdapter(FailingAdapter()) result = await approval.create_approval("user_Uxxx", "Test", "Desc") assert result is None @pytest.mark.asyncio async def test_cleanup_expired(self, mock_adapter): approval = LINEApprovalAdapter(mock_adapter) result1 = await approval.create_approval("user_Uxxx", "Test1", "Desc1", timeout_seconds=-1) await asyncio.sleep(0.002) result2 = await approval.create_approval("user_Uxxx", "Test2", "Desc2", timeout_seconds=3600) assert result1 is not None await asyncio.sleep(0.01) count = approval.cleanup_expired() assert count >= 1 assert approval.get_approval(result1["approval_id"])["status"] == "expired" # --------------------------------------------------------------------------- # Bindings tests # --------------------------------------------------------------------------- class TestBindingsProvider: def test_load_and_get(self): bp = LINEBindingsProvider() bp.load_bindings({"U123": "fp_456"}) assert bp.get_binding("U123") == "fp_456" def test_get_nonexistent(self): bp = LINEBindingsProvider() assert bp.get_binding("U999") is None def test_set_and_remove(self): bp = LINEBindingsProvider() bp.set_binding("U1", "fp_1") assert bp.get_binding("U1") == "fp_1" assert bp.remove_binding("U1") is True assert bp.get_binding("U1") is None def test_remove_nonexistent(self): bp = LINEBindingsProvider() assert bp.remove_binding("U999") is False def test_list_bindings(self): bp = LINEBindingsProvider() bp.load_bindings({"U1": "fp_1", "U2": "fp_2"}) bindings = bp.list_bindings() assert bindings == {"U1": "fp_1", "U2": "fp_2"} assert bindings is not bp._bindings def test_resolve_fp_user(self): bp = LINEBindingsProvider() bp.load_bindings({"U1": "fp_1"}) assert bp.resolve_fp_user("U1") == "fp_1" assert bp.resolve_fp_user("U999") is None def test_resolve_line_user(self): bp = LINEBindingsProvider() bp.load_bindings({"U1": "fp_1", "U2": "fp_2"}) assert bp.resolve_line_user("fp_2") == "U2" assert bp.resolve_line_user("fp_999") is None @pytest.mark.asyncio async def test_enrich_message(self): mock_adapter = MagicMock() mock_adapter.get_user_info = AsyncMock(return_value={"displayName": "TestUser"}) bp = LINEBindingsProvider(mock_adapter) bp.load_bindings({"U1": "fp_1"}) metadata = {} result = await bp.enrich_message("U1", metadata) assert result["fp_user_id"] == "fp_1" assert "line_user_info" in result @pytest.mark.asyncio async def test_enrich_message_no_binding(self): bp = LINEBindingsProvider() metadata = {} result = await bp.enrich_message("U999", metadata) assert "fp_user_id" not in result # --------------------------------------------------------------------------- # Config schema tests # --------------------------------------------------------------------------- class TestConfigSchema: def test_valid_minimal_config(self): config = { "accounts": { "default": { "channel_access_token": "test-token", "channel_secret": "test-secret", } } } valid, error = validate_line_config(config) assert valid is True assert error is None def test_missing_accounts(self): valid, error = validate_line_config({}) assert valid is False def test_not_dict(self): valid, error = validate_line_config("not dict") assert valid is False def test_accounts_not_dict(self): valid, error = validate_line_config({"accounts": "not dict"}) assert valid is False def test_invalid_dm_policy(self): config = { "accounts": {"default": {"channel_access_token": "t"}}, "dm_policy": "invalid_policy", } valid, error = validate_line_config(config) assert valid is False def test_invalid_group_policy(self): config = { "accounts": {"default": {"channel_access_token": "t"}}, "group_policy": "invalid_policy", } valid, error = validate_line_config(config) assert valid is False def test_valid_extended_config(self): config = { "accounts": { "default": { "channel_access_token": "t", "channel_secret": "s", }, "bot2": { "channel_access_token": "t2", }, }, "dm_policy": "allowlist", "group_policy": "open", "allow_from": ["U1", "U2"], "groups": [{"id": "Cxxx", "enabled": True}], "response_prefix": "[Bot] ", } valid, error = validate_line_config(config) assert valid is True # --------------------------------------------------------------------------- # Transform reply payload tests # --------------------------------------------------------------------------- class TestTransformReplyPayload: def test_directive_returns(self): result = transform_reply_payload("[[quick_replies: A, B]]") assert result is not None assert len(result) == 1 def test_card_command(self): result = transform_reply_payload( '[[card:receipt:{"title":"Receipt","items":[{"label":"Item","price":10}]}]]' ) assert result is not None assert len(result) == 1 def test_flex_from_markdown(self): result = transform_reply_payload( "| Col1 | Col2 |\n|------|------|\n| A | B |" ) assert result is not None assert len(result) >= 1 def test_empty_content(self): assert transform_reply_payload("") is None assert transform_reply_payload(" ") is None def test_plain_text(self): assert transform_reply_payload("Hello world") is None # --------------------------------------------------------------------------- # Sticker catalog tests # --------------------------------------------------------------------------- class TestStickerCatalog: def test_search_stickers(self): results = search_stickers("微笑") assert len(results) > 0 assert all("微笑" in r["description"] for r in results) def test_search_empty_query(self): results = search_stickers("", limit=10) assert len(results) == 10 def test_search_by_id(self): results = search_stickers("1", limit=5) assert len(results) > 0 def test_get_sticker_by_id_found(self): sticker = get_sticker_by_id("1", "1") assert sticker is not None assert sticker["sticker_id"] == "1" def test_get_sticker_by_id_not_found(self): assert get_sticker_by_id("999999", "1") is None def test_get_stickers_by_category(self): results = get_stickers_by_category("emotion", limit=10) assert len(results) > 0 def test_get_stickers_by_category_unknown(self): results = get_stickers_by_category("unknown_category") assert len(results) == 0 def test_get_stickers_by_package(self): results = get_stickers_by_package("1", limit=10) assert len(results) == 10 assert all(r["package_id"] == "1" for r in results) def test_list_available_packages(self): packages = list_available_packages() assert "1" in packages assert len(packages) >= 2 # --------------------------------------------------------------------------- # Normalizer edge case tests # --------------------------------------------------------------------------- class TestNormalizerEdgeCases: def setup_method(self): self.normalizer = LINEEventNormalizer() def test_unknown_message_type_fallback(self): event = { "type": "message", "message": {"type": "unknown_custom_type", "data": "xyz"}, "source": {"type": "user", "userId": "Uxxx"}, "timestamp": 0, } result = self.normalizer.normalize(event) assert result.message_type == MessageType.TEXT def test_unfollow_event(self): event = { "type": "unfollow", "source": {"type": "user", "userId": "Uxxx"}, "timestamp": 0, } result = self.normalizer.normalize(event) assert result.event_type == EventType.BOT_REMOVED assert "取消关注" in result.content def test_leave_event(self): event = { "type": "leave", "source": {"type": "group", "groupId": "Cxxx"}, "timestamp": 0, } result = self.normalizer.normalize(event) assert result.event_type == EventType.MEMBER_LEFT assert "离开" in result.content def test_member_joined_event(self): event = { "type": "memberJoined", "source": {"type": "group", "groupId": "Cxxx"}, "joined": {"members": [{"userId": "U1"}, {"userId": "U2"}]}, "timestamp": 0, } result = self.normalizer.normalize(event) assert result.event_type == EventType.MEMBER_JOINED assert "U1" in result.content assert "U2" in result.content def test_member_left_event(self): event = { "type": "memberLeft", "source": {"type": "group", "groupId": "Cxxx"}, "left": {"members": [{"userId": "U1"}]}, "timestamp": 0, } result = self.normalizer.normalize(event) assert result.event_type == EventType.MEMBER_LEFT assert "U1" in result.content def test_native_mentions(self): msg = { "type": "text", "text": "hello", "id": "msg-001", "mention": { "mentionees": [ {"userId": "Uxxx", "type": "user"}, {"userId": "Uyyy", "type": "user"}, ] }, } event = { "type": "message", "message": msg, "source": {"type": "group", "groupId": "Cxxx"}, "timestamp": 0, } result = self.normalizer.normalize(event) assert "Uxxx" in result.content assert "Uyyy" in result.content assert "Mentions" in result.content def test_native_mentions_empty(self): msg = { "type": "text", "text": "hello", "id": "msg-001", "mention": {"mentionees": []}, } event = { "type": "message", "message": msg, "source": {"type": "group", "groupId": "Cxxx"}, "timestamp": 0, } result = self.normalizer.normalize(event) assert "Mentions" not in result.content def test_location_message_no_title(self): event = { "type": "message", "message": {"type": "location", "id": "loc-001", "address": "Somewhere"}, "source": {"type": "user", "userId": "Uxxx"}, "timestamp": 0, } result = self.normalizer.normalize(event) assert result.message_type == MessageType.LOCATION def test_file_message_no_filename(self): event = { "type": "message", "message": {"type": "file", "id": "file-001", "file_size": 100}, "source": {"type": "user", "userId": "Uxxx"}, "timestamp": 0, } result = self.normalizer.normalize(event) assert result.message_type == MessageType.FILE def test_video_play_complete_event_no_tracking(self): event = { "type": "videoPlayComplete", "source": {"type": "user", "userId": "Uxxx"}, "videoPlayComplete": {}, "timestamp": 0, } result = self.normalizer.normalize(event) assert result.event_type == EventType.SYSTEM_EVENT def test_source_without_user_id(self): event = { "type": "message", "message": {"type": "text", "text": "hello", "id": "msg-001"}, "source": {"type": "unknown"}, "timestamp": 0, } result = self.normalizer.normalize(event) assert result.identity.channel_user_id == "unknown" def test_event_with_webhook_event_id(self): event = { "type": "message", "message": {"type": "text", "text": "hello"}, "source": {"type": "user", "userId": "Uxxx"}, "webhookEventId": "webhook-001", "timestamp": 0, } result = self.normalizer.normalize(event) assert result.identity.channel_message_id == "webhook-001" # --------------------------------------------------------------------------- # Formatter edge case tests # --------------------------------------------------------------------------- class TestFormatterEdgeCases: 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_with_strip_markdown_false(self): response = self._make_response("**bold** text", {"strip_markdown": False}) result = self.formatter.format(response) assert len(result) == 1 assert result[0]["type"] == "text" def test_format_with_decorated_text_false(self): response = self._make_response("**bold**", {"line_decorated_text": False}) result = self.formatter.format(response) assert len(result) == 1 def test_format_exact_5000_chars(self): response = self._make_response("A" * 5000) result = self.formatter.format(response) assert len(result) == 1 assert len(result[0]["text"]) <= 5000 def test_format_5001_chars(self): response = self._make_response("A" * 5001) result = self.formatter.format(response) assert len(result) > 1 def test_format_location_metadata(self): response = self._make_response("", {"line_message_type": "location"}) result = self.formatter.format(response) assert len(result) == 1 # --------------------------------------------------------------------------- # Adapter helper tests # --------------------------------------------------------------------------- class TestAdapterHelpers: def test_gen_pairing_code_length(self): code = _gen_pairing_code() assert len(code) == 6 assert code.isdigit() def test_gen_pairing_code_custom_length(self): code = _gen_pairing_code(4) assert len(code) == 4 def test_bot_mentioned_true(self): assert _bot_mentioned("hello @U123", "U123") is True def test_bot_mentioned_case_insensitive(self): assert _bot_mentioned("hello @u123", "U123") is True def test_bot_mentioned_false(self): assert _bot_mentioned("hello", "U123") is False def test_bot_mentioned_none_id(self): assert _bot_mentioned("hello", None) is False def test_bot_mentioned_empty_content(self): assert _bot_mentioned("", "U123") is False def test_is_dm_chat(self): assert _is_dm_chat("user_U123") is True assert _is_dm_chat("group_C123") is False assert _is_dm_chat("room_R123") is False def test_is_group_chat(self): assert _is_group_chat("group_C123") is True assert _is_group_chat("room_R123") is True assert _is_group_chat("user_U123") is False def test_validate_target_id_valid(self): assert _validate_target_id("U" + "a" * 32) is True assert _validate_target_id("C" + "b" * 32) is True assert _validate_target_id("R" + "c" * 32) is True assert _validate_target_id("line:bot1") is True def test_validate_target_id_invalid(self): assert _validate_target_id("invalid") is False assert _validate_target_id("") is False assert _validate_target_id("Uabc") is False def test_is_private_hostname_localhost(self): assert _is_private_hostname("localhost") is True assert _is_private_hostname("127.0.0.1") is True assert _is_private_hostname("::1") is True def test_is_private_hostname_private_ip(self): assert _is_private_hostname("10.0.0.1") is True assert _is_private_hostname("192.168.1.1") is True assert _is_private_hostname("172.16.0.1") is True assert _is_private_hostname("169.254.1.1") is True def test_is_private_hostname_public(self): assert _is_private_hostname("93.184.216.34") is False assert _is_private_hostname("example.com") is False assert _is_private_hostname("api.line.me") is False def test_is_private_hostname_suffixes(self): assert _is_private_hostname("test.local") is True assert _is_private_hostname("test.internal") is True assert _is_private_hostname("corp.lan") is True def test_is_private_hostname_empty(self): assert _is_private_hostname("") is True def test_is_private_hostname_172_out_of_range(self): assert _is_private_hostname("172.32.0.1") is False class TestErrorClassification: def test_classify_auth(self): assert _classify_send_error("401 Unauthorized") == "auth" assert _classify_send_error("authentication failed") == "auth" def test_classify_reply_token_expired(self): assert _classify_send_error("reply token expired") == "reply_token_expired" assert _classify_send_error("invalid reply token") == "reply_token_expired" def test_classify_channel_disabled(self): assert _classify_send_error("communication channel disabled") == "channel_disabled" assert _classify_send_error("not enabled") == "channel_disabled" def test_classify_rate_limited(self): assert _classify_send_error("429 too many requests") == "rate_limited" assert _classify_send_error("rate limit exceeded") == "rate_limited" def test_classify_network(self): assert _classify_send_error("network error") == "network" assert _classify_send_error("timeout occurred") == "network" assert _classify_send_error("connection refused") == "network" assert _classify_send_error("dns lookup failed") == "network" def test_classify_server_error(self): assert _classify_send_error("500 internal server error") == "server_error" def test_classify_forbidden(self): assert _classify_send_error("403 forbidden") == "forbidden" def test_classify_unknown(self): assert _classify_send_error("some random error") == "unknown" assert _classify_send_error(None) == "unknown" assert _classify_send_error("") == "unknown" def test_is_auth_error(self): assert _is_auth_error("401") is True assert _is_auth_error("unauthorized") is True assert _is_auth_error("not auth") is False def test_is_reply_token_expired(self): assert _is_reply_token_expired("reply token expired") is True assert _is_reply_token_expired("ok") is False def test_is_comm_channel_disabled(self): assert _is_comm_channel_disabled("communication channel disabled") is True assert _is_comm_channel_disabled("ok") is False def test_is_rate_limited(self): assert _is_rate_limited("429") is True assert _is_rate_limited("rate limited") is True assert _is_rate_limited("ok") is False def test_is_network_error(self): assert _is_network_error("timeout") is True assert _is_network_error("unreachable") is True assert _is_network_error("ok") is False class TestResolveMentions: def test_no_content_no_event(self): result = _resolve_mentions("", None, None) assert result.mentioned_user_ids == [] assert result.is_bot_mentioned is False def test_at_mention_in_content(self): result = _resolve_mentions("Hello @Uabc123def456abc123def456abc123de", "bot123", None) assert "Uabc123def456abc123def456abc123de" in result.mentioned_user_ids def test_bot_mentioned_in_content(self): result = _resolve_mentions("Hello @bot", "bot123", None) assert result.is_bot_mentioned is True def test_native_mentions_from_event(self): event = { "message": { "mention": { "mentionees": [ {"userId": "U111"}, {"userId": "bot123"}, ] } } } result = _resolve_mentions("", "bot123", event) assert "U111" in result.mentioned_user_ids assert result.is_bot_mentioned is True def test_native_mentions_without_mention_key(self): event = {"message": {"type": "text", "text": "hi"}} result = _resolve_mentions("hello", "bot123", event) assert result.is_bot_mentioned is False # --------------------------------------------------------------------------- # Adapter init and config tests # --------------------------------------------------------------------------- class TestAdapterInit: def test_init_with_group_config(self): config = { "accounts": { "default": { "channel_access_token": "test-token", "channel_secret": "test-secret", } }, "dm_policy": "allowlist", "group_policy": "allowlist", "groups": [ {"id": "Cxxx", "enabled": True, "require_mention": True}, {"id": "Cyyy", "enabled": False}, ], "response_prefix": "[Bot] ", "agent_prompt": "Custom prompt", "media_max_mb": 5, "thread_bindings": { "enabled": True, "idle_hours": 2, "max_age_hours": 48, "spawn_subagent_sessions": True, "spawn_acp_sessions": True, }, } adapter = LINEAdapter(config=config) assert adapter.dm_policy == "allowlist" assert adapter.group_policy == "allowlist" assert adapter.response_prefix == "[Bot] " assert adapter._agent_prompt == "Custom prompt" assert adapter.media_max_mb == 5 assert adapter.thread_bindings_enabled is True assert adapter.thread_bindings_idle_hours == 2 assert adapter.thread_bindings_max_age_hours == 48 def test_init_defaults(self): adapter = LINEAdapter(config={}) assert adapter.dm_policy == "open" assert adapter.group_policy == "open" assert adapter.response_prefix == "" def test_init_null_config(self): adapter = LINEAdapter(config=None) assert adapter.dm_policy == "open" assert adapter.group_policy == "open" def test_init_allow_from(self): config = {"accounts": {"default": {}}, "allow_from": ["U1", "U2"]} adapter = LINEAdapter(config=config) assert "U1" in adapter._dm_allow_from assert "U2" in adapter._dm_allow_from def test_create_default_menu_config(self): menu = LINEAdapter.create_default_menu_config() assert menu["size"]["width"] == 2500 assert menu["size"]["height"] == 1686 assert len(menu["areas"]) == 6 def test_build_text_v2_message(self): result = LINEAdapter.build_text_v2_message("Hello") assert result["type"] == "textV2" assert result["text"] == "Hello" def test_build_text_v2_with_substitutions(self): subs = [{"type": "emoji", "productId": "123", "emojiId": "456"}] result = LINEAdapter.build_text_v2_message("Hello", subs) assert result["substitutions"] == subs def test_build_imagemap_message(self): result = LINEAdapter.build_imagemap_message( "https://example.com/map", "Imagemap", 1040, 1040, [{"type": "uri", "linkUri": "https://example.com", "area": {"x": 0, "y": 0, "width": 520, "height": 520}}], ) assert result["type"] == "imagemap" assert result["baseUrl"] == "https://example.com/map" assert len(result["actions"]) == 1 def test_build_imagemap_with_video(self): video = {"originalContentUrl": "https://example.com/video.mp4", "previewImageUrl": "https://example.com/thumb.jpg"} result = LINEAdapter.build_imagemap_message("https://example.com/map", "Map", video=video) assert result["video"] == video class TestAdapterDMAndGroupAccess: @pytest.fixture def adapter(self): config = { "accounts": { "default": { "channel_access_token": "test-token", "channel_secret": "test-secret", } } } return LINEAdapter(config=config) @pytest.mark.asyncio async def test_dm_access_open(self, adapter): adapter.dm_policy = "open" allowed, msg = await adapter._check_dm_access("Uxxx") assert allowed is True assert msg is None @pytest.mark.asyncio async def test_dm_access_disabled(self, adapter): adapter.dm_policy = "disabled" allowed, msg = await adapter._check_dm_access("Uxxx") assert allowed is False assert "disabled" in msg @pytest.mark.asyncio async def test_dm_access_allowlist_allowed(self, adapter): adapter.dm_policy = "allowlist" adapter._dm_allow_from = {"Uxxx"} allowed, msg = await adapter._check_dm_access("Uxxx") assert allowed is True @pytest.mark.asyncio async def test_dm_access_allowlist_denied(self, adapter): adapter.dm_policy = "allowlist" adapter._dm_allow_from = set() allowed, msg = await adapter._check_dm_access("Uxxx") assert allowed is False assert "allowlist" in msg.lower() def test_group_access_open(self): adapter = LINEAdapter(config={ "accounts": {"default": {"channel_access_token": "t", "channel_secret": "s"}}, "group_policy": "open", }) import asyncio async def _check(): allowed, msg = await adapter._check_group_access("Cxxx") assert allowed is True asyncio.run(_check()) def test_group_access_allowlist_no_config(self): adapter = LINEAdapter(config={ "accounts": {"default": {"channel_access_token": "t", "channel_secret": "s"}}, "group_policy": "allowlist", "groups": [], }) import asyncio async def _check(): allowed, msg = await adapter._check_group_access("Cxxx") assert allowed is False asyncio.run(_check()) def test_group_access_allowlist_with_wildcard(self): adapter = LINEAdapter(config={ "accounts": {"default": {"channel_access_token": "t", "channel_secret": "s"}}, "group_policy": "allowlist", "groups": [{"id": "*", "enabled": True}], }) import asyncio async def _check(): allowed, msg = await adapter._check_group_access("Cxxx") assert allowed is True asyncio.run(_check()) class TestAdapterMetricsAndSummary: @pytest.fixture def adapter(self): config = { "accounts": { "default": { "channel_access_token": "test-token", "channel_secret": "test-secret", } } } return LINEAdapter(config=config) def test_get_metrics_initial(self, adapter): metrics = adapter.get_metrics() assert metrics["messages_sent"] == 0 assert metrics["messages_failed"] == 0 assert metrics["success_rate"] == 1.0 assert "uptime_seconds" in metrics def test_record_result_success(self, adapter): adapter._record_result(DeliveryResult(success=True)) assert adapter._metrics["messages_sent"] == 1 def test_record_result_failure(self, adapter): adapter._record_result(DeliveryResult(success=False, error="network error")) assert adapter._metrics["messages_failed"] == 1 assert adapter._metrics["error_counts"]["network"] == 1 def test_record_result_failure_unknown(self, adapter): adapter._record_result(DeliveryResult(success=False, error="some other error")) assert adapter._metrics["error_counts"]["unknown"] == 1 def test_build_channel_summary(self, adapter): summary = adapter.build_channel_summary() assert summary["channel"] == "line" assert "status" in summary assert "dm_policy" in summary def test_collect_status_issues_not_connected(self, adapter): issues = adapter.collect_status_issues() assert len(issues) >= 1 assert any("not connected" in i["message"].lower() for i in issues) def test_collect_audit_findings(self, adapter): findings = adapter.collect_audit_findings() assert len(findings) >= 5 def test_list_peers_empty(self, adapter): import asyncio async def _check(): peers = await adapter.list_peers(limit=10) assert peers == [] asyncio.run(_check()) def test_get_account_snapshot(self, adapter): snapshot = adapter.get_account_snapshot() assert snapshot.dm_policy == "open" assert snapshot.webhook_path == "line/callback" @pytest.mark.asyncio async def test_agent_prompt(self, adapter): prompt = await adapter.agent_prompt() assert prompt is not None assert "LINE" in prompt @pytest.mark.asyncio async def test_agent_prompt_custom(self): adapter = LINEAdapter(config={ "accounts": {"default": {"channel_access_token": "t", "channel_secret": "s"}}, "agent_prompt": "Custom bot prompt", }) prompt = await adapter.agent_prompt() assert "Custom bot prompt" in prompt @pytest.mark.asyncio async def test_heartbeat_not_connected(self, adapter): result = await adapter.heartbeat() assert result is False class TestAdapterPollAndReaction: @pytest.fixture def adapter(self): config = { "accounts": { "default": { "channel_access_token": "test-token", "channel_secret": "test-secret", } } } return LINEAdapter(config=config) def test_create_poll_not_connected(self, adapter): import asyncio async def _check(): result = await adapter.create_poll("user_Uxxx", "Question?", ["A", "B"]) assert result.success is False assert "not connected" in result.error.lower() asyncio.run(_check()) def test_create_poll_invalid_options(self): config = { "accounts": { "default": { "channel_access_token": "test-token", "channel_secret": "test-secret", } } } adapter = LINEAdapter(config=config) adapter._status = ChannelStatus.CONNECTED adapter._sender = MagicMock() import asyncio async def _check(): result = await adapter.create_poll("user_Uxxx", "Q?", []) assert result.success is False assert "1-4" in result.error asyncio.run(_check()) def test_create_poll_too_many_options(self): config = { "accounts": { "default": { "channel_access_token": "test-token", "channel_secret": "test-secret", } } } adapter = LINEAdapter(config=config) adapter._status = ChannelStatus.CONNECTED adapter._sender = MagicMock() import asyncio async def _check(): result = await adapter.create_poll("user_Uxxx", "Q?", ["A", "B", "C", "D", "E"]) assert result.success is False assert "1-4" in result.error asyncio.run(_check()) def test_record_vote(self, adapter): adapter._poll_results["poll_1"] = { "chat_id": "user_Uxxx", "question": "Q?", "options": ["A", "B"], "anonymous": False, "allow_multiple": False, "votes": {"A": 0, "B": 0}, "voters": {}, "created_at": time.time(), "expires_at": None, } result = adapter.record_vote("poll_1", 0, "Uxxx") assert result is not None assert result["votes"]["A"] == 1 def test_record_vote_duplicate_no_multi(self, adapter): adapter._poll_results["poll_1"] = { "chat_id": "user_Uxxx", "question": "Q?", "options": ["A", "B"], "anonymous": False, "allow_multiple": False, "votes": {"A": 1, "B": 0}, "voters": {"Uxxx": 0}, "created_at": time.time(), "expires_at": None, } result = adapter.record_vote("poll_1", 1, "Uxxx") assert result is None def test_record_vote_expired(self, adapter): adapter._poll_results["poll_1"] = { "chat_id": "user_Uxxx", "question": "Q?", "options": ["A", "B"], "anonymous": False, "allow_multiple": False, "votes": {"A": 0, "B": 0}, "voters": {}, "created_at": time.time(), "expires_at": time.time() - 1, } result = adapter.record_vote("poll_1", 0, "Uxxx") assert result is None def test_record_vote_invalid_index(self, adapter): adapter._poll_results["poll_1"] = { "chat_id": "user_Uxxx", "question": "Q?", "options": ["A", "B"], "anonymous": False, "allow_multiple": False, "votes": {"A": 0, "B": 0}, "voters": {}, "created_at": time.time(), "expires_at": None, } result = adapter.record_vote("poll_1", 5, "Uxxx") assert result is None def test_get_poll_results(self, adapter): adapter._poll_results["poll_1"] = { "chat_id": "user_Uxxx", "question": "Q?", "options": ["A", "B"], "anonymous": False, "allow_multiple": False, "votes": {"A": 1, "B": 0}, "voters": {"Uxxx": 0}, "created_at": time.time(), "expires_at": None, } result = adapter.get_poll_results("poll_1") assert result["total_votes"] == 1 assert result["voter_count"] == 1 def test_get_poll_results_nonexistent(self, adapter): assert adapter.get_poll_results("nonexistent") is None def test_list_active_polls(self, adapter): adapter._poll_results["poll_1"] = { "chat_id": "user_Uxxx", "question": "Q?", "options": ["A", "B"], "anonymous": False, "allow_multiple": False, "votes": {"A": 0, "B": 0}, "voters": {}, "created_at": time.time(), "expires_at": None, } polls = adapter.list_active_polls("user_Uxxx") assert len(polls) == 1 def test_close_poll(self, adapter): adapter._poll_results["poll_1"] = { "chat_id": "user_Uxxx", "question": "Q?", "options": ["A", "B"], "anonymous": False, "allow_multiple": False, "votes": {"A": 0, "B": 0}, "voters": {}, "created_at": time.time(), "expires_at": None, } result = adapter.close_poll("poll_1") assert result is not None assert result["expires_at"] is not None def test_send_reaction_not_connected(self, adapter): import asyncio async def _check(): result = await adapter.send_reaction("user_Uxxx", "msg-1", "👍") assert result.success is False asyncio.run(_check()) def test_get_reactions_empty(self, adapter): assert adapter.get_reactions("msg-1", "user_Uxxx") == [] def test_clear_reactions(self, adapter): adapter._reaction_cache = {"user_Uxxx:msg-1": [{"emoji": "👍", "timestamp": 0}]} adapter.clear_reactions("msg-1") assert len(adapter._reaction_cache) == 0 class TestAdapterDuplicateDetection: @pytest.fixture def adapter(self): config = { "accounts": { "default": { "channel_access_token": "test-token", "channel_secret": "test-secret", } } } return LINEAdapter(config=config) def test_is_duplicate_webhook_new(self, adapter): assert adapter._is_duplicate_webhook("wh-1") is False def test_is_duplicate_webhook_dup(self, adapter): adapter._is_duplicate_webhook("wh-1") assert adapter._is_duplicate_webhook("wh-1") is True def test_is_duplicate_webhook_empty(self, adapter): assert adapter._is_duplicate_webhook("") is False def test_is_duplicate_message_new(self, adapter): assert adapter._is_duplicate_message("msg-1") is False def test_is_duplicate_message_dup(self, adapter): adapter._is_duplicate_message("msg-1") assert adapter._is_duplicate_message("msg-1") is True def test_trim_set(self): s = set() from collections import deque dq = deque() for i in range(15000): s.add(i) dq.append(i) LINEAdapter._trim_set(s, dq, max_size=10000, keep=5000) assert len(dq) <= 5000 class TestAdapterStreamState: @pytest.fixture def adapter(self): config = { "accounts": { "default": { "channel_access_token": "test-token", "channel_secret": "test-secret", } } } return LINEAdapter(config=config) def test_get_or_create_stream_state(self, adapter): state = adapter._get_or_create_stream_state("chat1", "msg1") assert state["status"] == "idle" assert state["accumulated"] == "" def test_get_stream_state_existing(self, adapter): adapter._get_or_create_stream_state("chat1", "msg1") state = adapter.get_stream_state("chat1", "msg1") assert state is not None def test_get_stream_state_nonexistent(self, adapter): assert adapter.get_stream_state("nonexistent", "msg") is None def test_get_stream_state_cancelled(self): config = { "accounts": { "default": { "channel_access_token": "test-token", "channel_secret": "test-secret", } } } adapter = LINEAdapter(config=config) import asyncio async def _check(): adapter._get_or_create_stream_state("chat1", "msg1") await adapter.cancel_stream("chat1", "msg1") assert adapter.get_stream_state("chat1", "msg1") is None asyncio.run(_check()) def test_send_stream_chunk(self): config = { "accounts": { "default": { "channel_access_token": "test-token", "channel_secret": "test-secret", } } } adapter = LINEAdapter(config=config) import asyncio async def _check(): result = await adapter.send_stream_chunk("chat1", "msg1", "chunk1", finished=False) assert result.success is False state = adapter.get_stream_state("chat1", "msg1") assert state["accumulated"] == "chunk1" assert state["chunks_sent"] == 1 asyncio.run(_check()) class TestAdapterMiscMethods: @pytest.fixture def adapter(self): config = { "accounts": { "default": { "channel_access_token": "test-token", "channel_secret": "test-secret", } } } return LINEAdapter(config=config) def test_list_account_ids(self, adapter): adapter.config = { "accounts": { "default": {"channel_access_token": "t"}, "bot2": {"channel_access_token": "t2"}, "bot3": {"channel_access_token": "t3"}, } } ids = adapter.list_account_ids() assert "bot2" in ids assert "bot3" in ids assert "default" not in ids def test_ensure_https_url_valid(self, adapter): assert adapter._ensure_https_url("https://api.line.me/v2/test") == "https://api.line.me/v2/test" def test_ensure_https_url_invalid(self, adapter): assert adapter._ensure_https_url("http://api.line.me") is None assert adapter._ensure_https_url("") is None assert adapter._ensure_https_url(None) is None assert adapter._ensure_https_url("https://" + "a" * 2000) is None def test_ensure_https_url_private(self, adapter): assert adapter._ensure_https_url("https://localhost/test") is None assert adapter._ensure_https_url("https://192.168.1.1/test") is None def test_build_stream_identity(self): class FakeAdapter(LINEAdapter): def __init__(self): pass adapter = FakeAdapter() adapter.channel_id = "line" adapter.channel_type = ChannelType.LINE identity = adapter._build_stream_identity("user_Uxxx", "msg-1") assert identity.channel_id == "line" assert identity.channel_chat_id == "user_Uxxx" @pytest.mark.asyncio async def test_before_deliver_payload(self, adapter): result = await adapter.before_deliver_payload([{"type": "text"}, None, {"type": "image"}]) assert len(result) == 2 @pytest.mark.asyncio async def test_send_media_invalid_type(self, adapter): adapter._status = ChannelStatus.CONNECTED adapter._sender = MagicMock() adapter._sender.push_message = AsyncMock(return_value=DeliveryResult(success=True)) result = await adapter.send_media("user_Uxxx", "invalid_xyz", "https://example.com/test.mp3") assert result.success is False assert "not supported" in result.error.lower() @pytest.mark.asyncio async def test_send_narrowcast_not_connected(self, adapter): result = await adapter.send_narrowcast([{"type": "text", "text": "hello"}]) assert result.success is False @pytest.mark.asyncio async def test_get_narrowcast_progress_not_connected(self, adapter): result = await adapter.get_narrowcast_progress("req-1") assert result is None def test_get_group_system_prompt(self, adapter): adapter.group_policy = "allowlist" adapter._groups_config = {"Cxxx": {"enabled": True, "system_prompt": "custom prompt"}} assert adapter.get_group_system_prompt("Cxxx") == "custom prompt" assert adapter.get_group_system_prompt("Cyyy") is None def test_get_group_skills(self, adapter): adapter.group_policy = "allowlist" adapter._groups_config = {"Cxxx": {"enabled": True, "skills": ["skill_a", "skill_b"]}} assert adapter.get_group_skills("Cxxx") == ["skill_a", "skill_b"] def test_get_group_allow_from(self, adapter): adapter.group_policy = "allowlist" adapter._groups_config = {"Cxxx": {"allow_from": ["U1"]}} assert adapter.get_group_allow_from("Cxxx") == ["U1"] def test_get_skip_message_history(self, adapter): adapter._skip_message_history = [{"chat_id": "Cxxx", "content": "hello"}] history = adapter.get_skip_message_history() assert len(history) == 1 assert history[0]["chat_id"] == "Cxxx" assert adapter._skip_message_history is not history @pytest.mark.asyncio async def test_approve_pairing(self, adapter): adapter._dm_pending_pairing = {"Uxxx": "123456"} result = await adapter.approve_pairing("123456") assert result is True assert "Uxxx" not in adapter._dm_pending_pairing assert "Uxxx" in adapter._dm_allow_from @pytest.mark.asyncio async def test_approve_pairing_wrong_code(self, adapter): adapter._dm_pending_pairing = {"Uxxx": "123456"} result = await adapter.approve_pairing("000000") assert result is False assert "Uxxx" in adapter._dm_pending_pairing @pytest.mark.asyncio async def test_logout_account_all(self, adapter): adapter._cached_token = "token" adapter._cached_secret = "secret" await adapter.logout_account(None) assert adapter._cached_token is None assert adapter._cached_secret is None @pytest.mark.asyncio async def test_logout_account_specific(self, adapter): adapter.config = { "accounts": { "default": {"channel_access_token": "t", "channel_secret": "s"}, "bot2": {"channel_access_token": "t2", "channel_secret": "s2"}, } } await adapter.logout_account("bot2") assert "channel_access_token" not in adapter.config["accounts"]["bot2"] class TestSenderMoreMethods: @pytest.fixture def sender(self): return LINESender(channel_access_token="test-token") @pytest.mark.asyncio async def test_narrowcast_not_initialized(self, sender): result = await sender.narrowcast_message([{"type": "text"}]) assert result.success is False @pytest.mark.asyncio async def test_narrowcast_success(self, sender): import httpx mock_resp = httpx.Response(202, json={}) mock_client = MagicMock() mock_client.post = AsyncMock(return_value=mock_resp) sender._client = mock_client result = await sender.narrowcast_message([{"type": "text"}]) assert result.success is True @pytest.mark.asyncio async def test_narrowcast_with_all_options(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 result = await sender.narrowcast_message( [{"type": "text"}], recipient={"type": "operator", "and": []}, demographic_filter={"type": "age", "gte": "20", "lt": "30"}, limit={"max": 100}, notification_disabled=True, retry_key="retry-123", ) assert result.success is True @pytest.mark.asyncio async def test_narrowcast_http_error(self, sender): import httpx mock_client = MagicMock() mock_client.post = AsyncMock(side_effect=Exception("network")) sender._client = mock_client result = await sender.narrowcast_message([{"type": "text"}]) assert result.success is False @pytest.mark.asyncio async def test_get_narrowcast_progress_client(self, sender): import httpx mock_resp = httpx.Response(200, json={"phase": "completed"}) mock_client = MagicMock() mock_client.get = AsyncMock(return_value=mock_resp) sender._client = mock_client result = await sender.get_narrowcast_progress("req-1") assert result is not None @pytest.mark.asyncio async def test_get_narrowcast_progress_failure(self, sender): import httpx mock_resp = httpx.Response(404) mock_client = MagicMock() mock_client.get = AsyncMock(return_value=mock_resp) sender._client = mock_client result = await sender.get_narrowcast_progress("req-1") assert result is None @pytest.mark.asyncio async def test_send_file(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 result = await sender.send_file("Uxxx", "https://example.com/file.pdf", "doc.pdf", 1024) assert result.success is True @pytest.mark.asyncio async def test_send_sticker_push(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 result = await sender.send_sticker("Uxxx", "1", "1") assert result.success is True @pytest.mark.asyncio async def test_send_sticker_reply(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 result = await sender.send_sticker("Uxxx", "1", "1", reply_token="reply-abc") assert result.success is True @pytest.mark.asyncio async def test_get_number_of_followers(self, sender): import httpx mock_resp = httpx.Response(200, json={"followers": 100}) mock_client = MagicMock() mock_client.get = AsyncMock(return_value=mock_resp) sender._client = mock_client result = await sender.get_number_of_followers("20250101") assert result is not None assert result["followers"] == 100 @pytest.mark.asyncio async def test_get_friend_demographics(self, sender): import httpx mock_resp = httpx.Response(200, json={"available": True}) mock_client = MagicMock() mock_client.get = AsyncMock(return_value=mock_resp) sender._client = mock_client result = await sender.get_friend_demographics() assert result is not None @pytest.mark.asyncio async def test_get_rich_menu_alias_list(self, sender): import httpx mock_resp = httpx.Response(200, json={"aliases": [{"richMenuAliasId": "alias1"}]}) mock_client = MagicMock() mock_client.get = AsyncMock(return_value=mock_resp) sender._client = mock_client result = await sender.get_rich_menu_alias_list() assert result is not None assert len(result) == 1 @pytest.mark.asyncio async def test_create_rich_menu_alias(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 result = await sender.create_rich_menu_alias("alias1", "rm-123") assert result is True @pytest.mark.asyncio async def test_update_rich_menu_alias(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 result = await sender.update_rich_menu_alias("alias1", "rm-456") assert result is True @pytest.mark.asyncio async def test_delete_rich_menu_alias(self, sender): import httpx mock_resp = httpx.Response(200, json={}) mock_client = MagicMock() mock_client.delete = AsyncMock(return_value=mock_resp) sender._client = mock_client result = await sender.delete_rich_menu_alias("alias1") assert result is True @pytest.mark.asyncio async def test_get_rich_menu_by_alias(self, sender): import httpx mock_resp = httpx.Response(200, json={"richMenuId": "rm-123"}) mock_client = MagicMock() mock_client.get = AsyncMock(return_value=mock_resp) sender._client = mock_client result = await sender.get_rich_menu_by_alias("alias1") assert result is not None @pytest.mark.asyncio async def test_set_default_rich_menu(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 result = await sender.set_default_rich_menu("rm-123") assert result is True @pytest.mark.asyncio async def test_cancel_default_rich_menu(self, sender): import httpx mock_resp = httpx.Response(200, json={}) mock_client = MagicMock() mock_client.delete = AsyncMock(return_value=mock_resp) sender._client = mock_client result = await sender.cancel_default_rich_menu() assert result is True @pytest.mark.asyncio async def test_link_rich_menu_to_multiple_users(self, sender): import httpx mock_resp_ok = httpx.Response(200, json={}) mock_resp_fail = httpx.Response(400) mock_client = MagicMock() mock_client.post = AsyncMock(side_effect=[mock_resp_ok, mock_resp_fail]) sender._client = mock_client user_ids = [f"U{i}" for i in range(800)] result = await sender.link_rich_menu_to_multiple_users(user_ids, "rm-123") assert result is False @pytest.mark.asyncio async def test_unlink_rich_menu_from_multiple_users(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(800)] result = await sender.unlink_rich_menu_from_multiple_users(user_ids) assert result is True @pytest.mark.asyncio async def test_validate_rich_menu_object(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 result = await sender.validate_rich_menu_object({"size": {"width": 2500, "height": 1686}}) assert result is True @pytest.mark.asyncio async def test_get_rich_menu_image(self, sender): import httpx mock_resp = httpx.Response(200, content=b"fake-image") mock_client = MagicMock() mock_client.get = AsyncMock(return_value=mock_resp) sender._client = mock_client result = await sender.get_rich_menu_image("rm-123") assert result == b"fake-image" class TestAdapterMoreMethods: @pytest.fixture def adapter(self): config = { "accounts": { "default": { "channel_access_token": "test-token", "channel_secret": "test-secret", } }, "dm_policy": "open", "group_policy": "open", "groups": [{"id": "Cxxx", "enabled": True}], } return LINEAdapter(config=config) @pytest.mark.asyncio async def test_narrowcast_through_adapter(self): config = { "accounts": { "default": { "channel_access_token": "test-token", "channel_secret": "test-secret", } } } adapter = LINEAdapter(config=config) adapter._status = ChannelStatus.CONNECTED adapter._sender = MagicMock() adapter._sender.narrowcast_message = AsyncMock(return_value=DeliveryResult(success=True)) result = await adapter.send_narrowcast([{"type": "text"}]) assert result.success is True assert adapter._metrics["messages_sent"] == 1 @pytest.mark.asyncio async def test_narrowcast_through_adapter_failure(self): config = { "accounts": { "default": { "channel_access_token": "test-token", "channel_secret": "test-secret", } } } adapter = LINEAdapter(config=config) adapter._status = ChannelStatus.CONNECTED adapter._sender = MagicMock() adapter._sender.narrowcast_message = AsyncMock(return_value=DeliveryResult(success=False, error="failed")) result = await adapter.send_narrowcast([{"type": "text"}]) assert result.success is False assert adapter._metrics["messages_failed"] == 1 @pytest.mark.asyncio async def test_set_default_rich_menu_not_connected(self, adapter): result = await adapter.set_default_rich_menu("rm-123") assert result is False @pytest.mark.asyncio async def test_cancel_default_rich_menu_not_connected(self, adapter): result = await adapter.cancel_default_rich_menu() assert result is False @pytest.mark.asyncio async def test_unlink_rich_menu_bulk_not_connected(self, adapter): result = await adapter.unlink_rich_menu_bulk(["U1"]) assert result is False @pytest.mark.asyncio async def test_get_rich_menu_image_not_connected(self, adapter): result = await adapter.get_rich_menu_image("rm-123") assert result is None @pytest.mark.asyncio async def test_get_member_count_not_connected(self, adapter): result = await adapter.get_member_count("Cxxx") assert result is None @pytest.mark.asyncio async def test_get_group_info_not_connected(self, adapter): result = await adapter.get_group_info("Cxxx") assert result == {} @pytest.mark.asyncio async def test_send_sticker_not_connected(self, adapter): result = await adapter.send_sticker("user_Uxxx", "1", "1") assert result.success is False @pytest.mark.asyncio async def test_download_media_raises(self, adapter): with pytest.raises(RuntimeError, match="not connected"): await adapter.download_media("file-001") def test_get_user_info_from_cache(self, adapter): adapter._profile_cache = { "Uxxx": {"data": {"display_name": "Cached"}, "cached_at": time.time() + 3600} } import asyncio async def _check(): result = await adapter.get_user_info("Uxxx") assert result["display_name"] == "Cached" asyncio.run(_check()) class TestGroupAccessEdgeCases: def test_group_access_disabled_policy(self): adapter = LINEAdapter(config={ "accounts": {"default": {"channel_access_token": "t", "channel_secret": "s"}}, "group_policy": "disabled", }) import asyncio async def _check(): allowed, msg = await adapter._check_group_access("Cxxx") assert allowed is False asyncio.run(_check()) def test_group_config_specific_matches(self): adapter = LINEAdapter(config={ "accounts": {"default": {"channel_access_token": "t", "channel_secret": "s"}}, "group_policy": "allowlist", "groups": [ {"id": "Cxxx", "enabled": True}, {"id": "*", "enabled": False}, ], }) cfg1 = adapter._resolve_group_config("Cxxx") assert cfg1["enabled"] is True cfg2 = adapter._resolve_group_config("Cyyy") assert cfg2["enabled"] is False def test_group_access_require_mention(self): adapter = LINEAdapter(config={ "accounts": {"default": {"channel_access_token": "t", "channel_secret": "s"}}, "group_policy": "allowlist", "groups": [{"id": "Cxxx", "enabled": True, "require_mention": True}], }) adapter._self_user_id = "bot123" import asyncio async def _check(): allowed, msg = await adapter._check_group_access("Cxxx", "hello @bot123") assert allowed is True asyncio.run(_check()) def test_group_access_require_mention_not_mentioned(self): adapter = LINEAdapter(config={ "accounts": {"default": {"channel_access_token": "t", "channel_secret": "s"}}, "group_policy": "allowlist", "groups": [{"id": "Cxxx", "enabled": True, "require_mention": True}], }) adapter._self_user_id = "bot123" import asyncio async def _check(): allowed, msg = await adapter._check_group_access("Cxxx", "hello") assert allowed is False assert len(adapter._skip_message_history) == 1 asyncio.run(_check()) @pytest.mark.asyncio async def test_enqueue_message(self): adapter = LINEAdapter(config={ "accounts": {"default": {"channel_access_token": "t", "channel_secret": "s"}}, }) msg = ChannelMessage( identity=ChannelIdentity( channel_id="line", channel_type=ChannelType.LINE, channel_user_id="Uxxx", channel_chat_id="user_Uxxx", ), content="hello", ) await adapter.enqueue_message(msg) assert not adapter._message_queue.empty()