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重连相关测试
2581 lines
89 KiB
Python
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() |