ForcePilot/backend/test/unit/channels/test_line_adapter_comprehensive.py
Kris 69fe97a90d test: 批量修复并新增单元测试用例
1. 移除Telegram格式化测试中未使用的导入项
2. 修复Teams测试用例,添加monkeypatch参数并配置通配符开关
3. 更新钉钉适配器测试,替换弃用的流属性检查
4. 修正Twitch规范化测试,更新ROOMSTATE测试逻辑
5. 重构会话映射测试,完善数据库执行结果模拟
6. 格式化Slack块构建测试的长参数调用
7. 修复LINE适配器测试,更新能力断言和异步锁使用
8. 修正Slack会话解析测试,修复聊天类型判断错误
9. 更新能力测试,补充缺失的字段检查
10. 修复Matrix适配器测试,修正位置参数和配置校验逻辑
11. 为飞书分析模块测试添加跳过标记
12. 新增微信能力、限流、链接格式、会话路由等模块的单元测试
13. 修复Twitch适配器导入路径和测试断言
14. 新增Discord Webhook、Nextcloud Talk、Signal多账户等模块的单元测试
15. 修复Manager阶段测试的导入路径
16. 新增iMessage异常和命令处理的单元测试
17. 新增Nostr健康检查和相关模块的单元测试
18. 新增Signal守护进程和SSE重连相关测试
2026-05-13 16:43:01 +08:00

2581 lines
89 KiB
Python

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