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重连相关测试
897 lines
33 KiB
Python
897 lines
33 KiB
Python
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from yuxi.channels.adapters.whatsapp.format import (
|
|
_extract_content,
|
|
_extract_sender_number,
|
|
_jid_to_chat_type,
|
|
_map_message_type,
|
|
format_outbound,
|
|
normalize_inbound,
|
|
)
|
|
from yuxi.channels.adapters.whatsapp.heartbeat import HeartbeatManager
|
|
from yuxi.channels.adapters.whatsapp.media import _MEDIA_SUFFIX_MAP, supported_media_types
|
|
from yuxi.channels.adapters.whatsapp.send import chunk_message
|
|
from yuxi.channels.adapters.whatsapp.session import (
|
|
jid_to_chat_type,
|
|
jid_to_thread_key,
|
|
normalize_phone,
|
|
)
|
|
from yuxi.channels.models import (
|
|
Attachment,
|
|
ChannelIdentity,
|
|
ChannelResponse,
|
|
ChannelType,
|
|
ChatType,
|
|
EventType,
|
|
MessageType,
|
|
)
|
|
|
|
|
|
class TestNormalizeInbound:
|
|
def test_text_conversation(self):
|
|
raw = {
|
|
"key": {"remoteJid": "8613800138000@s.whatsapp.net", "id": "msg-001", "fromMe": False},
|
|
"message": {"conversation": "Hello World"},
|
|
"pushName": "TestUser",
|
|
"messageTimestamp": 1700000000,
|
|
"broadcast": False,
|
|
}
|
|
result = normalize_inbound(raw, "whatsapp")
|
|
assert result.identity.channel_user_id == "8613800138000"
|
|
assert result.identity.channel_chat_id == "8613800138000@s.whatsapp.net"
|
|
assert result.identity.channel_message_id == "msg-001"
|
|
assert result.content == "Hello World"
|
|
assert result.message_type == MessageType.TEXT
|
|
assert result.chat_type == ChatType.DIRECT
|
|
assert result.event_type == EventType.MESSAGE_RECEIVED
|
|
assert "push_name" in result.metadata
|
|
|
|
def test_extended_text_message(self):
|
|
raw = {
|
|
"key": {"remoteJid": "8613800138000@s.whatsapp.net", "id": "msg-002", "fromMe": False},
|
|
"message": {"extendedTextMessage": {"text": "Long formatted text"}},
|
|
"pushName": "TestUser",
|
|
"messageTimestamp": 1700000000,
|
|
}
|
|
result = normalize_inbound(raw, "whatsapp")
|
|
assert result.content == "Long formatted text"
|
|
|
|
def test_image_message(self):
|
|
raw = {
|
|
"key": {"remoteJid": "8613800138000@s.whatsapp.net", "id": "msg-003", "fromMe": False},
|
|
"message": {
|
|
"imageMessage": {
|
|
"url": "https://example.com/img.jpg",
|
|
"mimetype": "image/jpeg",
|
|
"caption": "Check this out",
|
|
}
|
|
},
|
|
"pushName": "TestUser",
|
|
"messageTimestamp": 1700000000,
|
|
}
|
|
result = normalize_inbound(raw, "whatsapp")
|
|
assert result.message_type == MessageType.IMAGE
|
|
assert result.content == "Check this out"
|
|
assert len(result.attachments) == 1
|
|
assert result.attachments[0].type == "image"
|
|
assert result.attachments[0].mime_type == "image/jpeg"
|
|
|
|
def test_video_message(self):
|
|
raw = {
|
|
"key": {"remoteJid": "8613800138000@s.whatsapp.net", "id": "msg-004", "fromMe": False},
|
|
"message": {
|
|
"videoMessage": {
|
|
"url": "https://example.com/vid.mp4",
|
|
"mimetype": "video/mp4",
|
|
"caption": "Watch this",
|
|
}
|
|
},
|
|
"pushName": "TestUser",
|
|
"messageTimestamp": 1700000000,
|
|
}
|
|
result = normalize_inbound(raw, "whatsapp")
|
|
assert result.message_type == MessageType.VIDEO
|
|
assert len(result.attachments) == 1
|
|
assert result.attachments[0].type == "video"
|
|
|
|
def test_audio_message(self):
|
|
raw = {
|
|
"key": {"remoteJid": "8613800138000@s.whatsapp.net", "id": "msg-005", "fromMe": False},
|
|
"message": {
|
|
"audioMessage": {
|
|
"url": "https://example.com/audio.ogg",
|
|
"mimetype": "audio/ogg",
|
|
}
|
|
},
|
|
"pushName": "TestUser",
|
|
"messageTimestamp": 1700000000,
|
|
}
|
|
result = normalize_inbound(raw, "whatsapp")
|
|
assert result.message_type == MessageType.AUDIO
|
|
assert result.content == ""
|
|
|
|
def test_document_message(self):
|
|
raw = {
|
|
"key": {"remoteJid": "8613800138000@s.whatsapp.net", "id": "msg-006", "fromMe": False},
|
|
"message": {
|
|
"documentMessage": {
|
|
"url": "https://example.com/doc.pdf",
|
|
"filename": "report.pdf",
|
|
"mimetype": "application/pdf",
|
|
"caption": "Monthly report",
|
|
}
|
|
},
|
|
"pushName": "TestUser",
|
|
"messageTimestamp": 1700000000,
|
|
}
|
|
result = normalize_inbound(raw, "whatsapp")
|
|
assert result.message_type == MessageType.FILE
|
|
assert len(result.attachments) == 1
|
|
assert result.attachments[0].type == "file"
|
|
assert result.attachments[0].filename == "report.pdf"
|
|
|
|
def test_sticker_message(self):
|
|
raw = {
|
|
"key": {"remoteJid": "8613800138000@s.whatsapp.net", "id": "msg-007", "fromMe": False},
|
|
"message": {"stickerMessage": {"url": "https://example.com/sticker.webp"}},
|
|
"pushName": "TestUser",
|
|
"messageTimestamp": 1700000000,
|
|
}
|
|
result = normalize_inbound(raw, "whatsapp")
|
|
assert result.message_type == MessageType.STICKER
|
|
assert len(result.attachments) == 1
|
|
assert result.attachments[0].type == "sticker"
|
|
|
|
def test_location_message(self):
|
|
raw = {
|
|
"key": {"remoteJid": "8613800138000@s.whatsapp.net", "id": "msg-008", "fromMe": False},
|
|
"message": {
|
|
"locationMessage": {
|
|
"degreesLatitude": 39.9042,
|
|
"degreesLongitude": 116.4074,
|
|
}
|
|
},
|
|
"pushName": "TestUser",
|
|
"messageTimestamp": 1700000000,
|
|
}
|
|
result = normalize_inbound(raw, "whatsapp")
|
|
assert result.message_type == MessageType.LOCATION
|
|
assert "39.9042" in result.content and "116.4074" in result.content
|
|
|
|
def test_contact_card_message(self):
|
|
raw = {
|
|
"key": {"remoteJid": "8613800138000@s.whatsapp.net", "id": "msg-009", "fromMe": False},
|
|
"message": {"contactMessage": {"displayName": "John Doe"}},
|
|
"pushName": "TestUser",
|
|
"messageTimestamp": 1700000000,
|
|
}
|
|
result = normalize_inbound(raw, "whatsapp")
|
|
assert result.message_type == MessageType.CARD
|
|
assert result.content == "John Doe"
|
|
|
|
def test_reaction_message(self):
|
|
raw = {
|
|
"key": {"remoteJid": "8613800138000@s.whatsapp.net", "id": "msg-010", "fromMe": False},
|
|
"message": {"reactionMessage": {"text": "👍"}},
|
|
"pushName": "TestUser",
|
|
"messageTimestamp": 1700000000,
|
|
}
|
|
result = normalize_inbound(raw, "whatsapp")
|
|
assert result.content == "👍"
|
|
|
|
def test_buttons_response_message(self):
|
|
raw = {
|
|
"key": {"remoteJid": "8613800138000@s.whatsapp.net", "id": "msg-011", "fromMe": False},
|
|
"message": {
|
|
"buttonsResponseMessage": {
|
|
"selectedButtonId": "btn-1",
|
|
"selectedDisplayText": "Option A",
|
|
}
|
|
},
|
|
"pushName": "TestUser",
|
|
"messageTimestamp": 1700000000,
|
|
}
|
|
result = normalize_inbound(raw, "whatsapp")
|
|
assert result.content == "Option A"
|
|
|
|
def test_list_response_message(self):
|
|
raw = {
|
|
"key": {"remoteJid": "8613800138000@s.whatsapp.net", "id": "msg-012", "fromMe": False},
|
|
"message": {
|
|
"listResponseMessage": {
|
|
"title": "Color",
|
|
"description": "Red",
|
|
}
|
|
},
|
|
"pushName": "TestUser",
|
|
"messageTimestamp": 1700000000,
|
|
}
|
|
result = normalize_inbound(raw, "whatsapp")
|
|
assert result.content == "Color"
|
|
|
|
def test_template_button_reply_message(self):
|
|
raw = {
|
|
"key": {"remoteJid": "8613800138000@s.whatsapp.net", "id": "msg-013", "fromMe": False},
|
|
"message": {
|
|
"templateButtonReplyMessage": {
|
|
"selectedId": "tpl-1",
|
|
"selectedDisplayText": "Confirm",
|
|
}
|
|
},
|
|
"pushName": "TestUser",
|
|
"messageTimestamp": 1700000000,
|
|
}
|
|
result = normalize_inbound(raw, "whatsapp")
|
|
assert result.content == "Confirm"
|
|
|
|
def test_poll_creation_message(self):
|
|
raw = {
|
|
"key": {"remoteJid": "8613800138000@s.whatsapp.net", "id": "msg-014", "fromMe": False},
|
|
"message": {
|
|
"pollCreationMessage": {
|
|
"name": "Lunch options",
|
|
"options": [{"optionName": "Pizza"}, {"optionName": "Sushi"}, {"optionName": "Salad"}],
|
|
}
|
|
},
|
|
"pushName": "TestUser",
|
|
"messageTimestamp": 1700000000,
|
|
}
|
|
result = normalize_inbound(raw, "whatsapp")
|
|
assert "[Poll] Lunch options" in result.content
|
|
assert "Pizza" in result.content
|
|
assert "Sushi" in result.content
|
|
assert "Salad" in result.content
|
|
|
|
def test_protocol_revoke_message(self):
|
|
raw = {
|
|
"key": {"remoteJid": "8613800138000@s.whatsapp.net", "id": "msg-015", "fromMe": False},
|
|
"message": {
|
|
"protocolMessage": {
|
|
"type": 0,
|
|
"key": {"id": "revoked-msg-001"},
|
|
}
|
|
},
|
|
"pushName": "TestUser",
|
|
"messageTimestamp": 1700000000,
|
|
}
|
|
result = normalize_inbound(raw, "whatsapp")
|
|
assert result.event_type == EventType.MESSAGE_DELETED
|
|
assert result.identity.channel_message_id == "revoked-msg-001"
|
|
|
|
def test_from_me_message_is_filtered(self):
|
|
raw = {
|
|
"key": {"remoteJid": "8613800138000@s.whatsapp.net", "id": "msg-016", "fromMe": True},
|
|
"message": {"conversation": "Sent by me"},
|
|
"pushName": "Me",
|
|
"messageTimestamp": 1700000000,
|
|
}
|
|
result = normalize_inbound(raw, "whatsapp")
|
|
assert result.content == ""
|
|
assert result.metadata.get("from_me") is True
|
|
|
|
def test_group_message(self):
|
|
raw = {
|
|
"key": {"remoteJid": "123456789@g.us", "id": "msg-017", "fromMe": False},
|
|
"message": {"conversation": "Hello group"},
|
|
"pushName": "TestUser",
|
|
"messageTimestamp": 1700000000,
|
|
}
|
|
result = normalize_inbound(raw, "whatsapp")
|
|
assert result.chat_type == ChatType.GROUP
|
|
assert result.identity.channel_user_id == "123456789"
|
|
|
|
def test_broadcast_message(self):
|
|
raw = {
|
|
"key": {"remoteJid": "status@broadcast", "id": "msg-018", "fromMe": False},
|
|
"message": {"conversation": "Broadcast"},
|
|
"pushName": "TestUser",
|
|
"messageTimestamp": 1700000000,
|
|
}
|
|
result = normalize_inbound(raw, "whatsapp")
|
|
assert result.chat_type == ChatType.GUILD_CHANNEL
|
|
|
|
def test_unknown_message_type_fallback(self):
|
|
raw = {
|
|
"key": {"remoteJid": "8613800138000@s.whatsapp.net", "id": "msg-019", "fromMe": False},
|
|
"message": {"unknownType": {"some": "data"}},
|
|
"pushName": "TestUser",
|
|
"messageTimestamp": 1700000000,
|
|
}
|
|
result = normalize_inbound(raw, "whatsapp")
|
|
assert result.content != ""
|
|
|
|
|
|
class TestFormatOutbound:
|
|
def test_basic_text_response(self):
|
|
identity = ChannelIdentity(
|
|
channel_id="whatsapp",
|
|
channel_type=ChannelType.WHATSAPP,
|
|
channel_user_id="8613800138000",
|
|
channel_chat_id="8613800138000@s.whatsapp.net",
|
|
channel_message_id="msg-001",
|
|
)
|
|
response = ChannelResponse(identity=identity, content="Hello")
|
|
result = format_outbound(response)
|
|
assert result["jid"] == "8613800138000@s.whatsapp.net"
|
|
assert result["content"] == "Hello"
|
|
|
|
def test_reply_attachment(self):
|
|
identity = ChannelIdentity(
|
|
channel_id="whatsapp",
|
|
channel_type=ChannelType.WHATSAPP,
|
|
channel_user_id="8613800138000",
|
|
channel_chat_id="8613800138000@s.whatsapp.net",
|
|
)
|
|
response = ChannelResponse(
|
|
identity=identity,
|
|
content="Reply text",
|
|
reply_to_message_id="original-msg-001",
|
|
)
|
|
result = format_outbound(response)
|
|
assert result["reply_to"] == "original-msg-001"
|
|
|
|
def test_image_attachment(self):
|
|
identity = ChannelIdentity(
|
|
channel_id="whatsapp",
|
|
channel_type=ChannelType.WHATSAPP,
|
|
channel_user_id="8613800138000",
|
|
channel_chat_id="8613800138000@s.whatsapp.net",
|
|
)
|
|
response = ChannelResponse(
|
|
identity=identity,
|
|
message_type=MessageType.IMAGE,
|
|
content="Image caption",
|
|
attachments=[Attachment(type="image", url="/tmp/photo.jpg")],
|
|
)
|
|
result = format_outbound(response)
|
|
assert result["media_type"] == "image"
|
|
assert result["media_path"] == "/tmp/photo.jpg"
|
|
|
|
|
|
class TestJidToChatType:
|
|
def test_direct_chat(self):
|
|
assert _jid_to_chat_type("8613800138000@s.whatsapp.net") == ChatType.DIRECT
|
|
|
|
def test_group_chat(self):
|
|
assert _jid_to_chat_type("123456789@g.us") == ChatType.GROUP
|
|
|
|
def test_broadcast_chat(self):
|
|
assert _jid_to_chat_type("status@broadcast") == ChatType.GUILD_CHANNEL
|
|
|
|
|
|
class TestExtractSenderNumber:
|
|
def test_phone_number(self):
|
|
assert _extract_sender_number("+8613800138000@s.whatsapp.net") == "+8613800138000"
|
|
|
|
def test_group_jid(self):
|
|
assert _extract_sender_number("123456789@g.us") == "123456789"
|
|
|
|
def test_broadcast_jid(self):
|
|
assert _extract_sender_number("status@broadcast") == "status"
|
|
|
|
|
|
class TestMapMessageType:
|
|
def test_known_types(self):
|
|
assert _map_message_type("text") == MessageType.TEXT
|
|
assert _map_message_type("image") == MessageType.IMAGE
|
|
assert _map_message_type("video") == MessageType.VIDEO
|
|
assert _map_message_type("audio") == MessageType.AUDIO
|
|
assert _map_message_type("file") == MessageType.FILE
|
|
assert _map_message_type("location") == MessageType.LOCATION
|
|
assert _map_message_type("sticker") == MessageType.STICKER
|
|
assert _map_message_type("card") == MessageType.CARD
|
|
|
|
def test_unknown_type_defaults_to_text(self):
|
|
assert _map_message_type("unknown_type") == MessageType.TEXT
|
|
|
|
|
|
class TestExtractContent:
|
|
def test_conversation(self):
|
|
msg = {"conversation": "Hi there"}
|
|
ctype, content, attachments = _extract_content(msg, "msg-001")
|
|
assert ctype == "text"
|
|
assert content == "Hi there"
|
|
assert attachments == []
|
|
|
|
def test_extended_text(self):
|
|
msg = {"extendedTextMessage": {"text": "Long text"}}
|
|
ctype, content, attachments = _extract_content(msg, "msg-001")
|
|
assert ctype == "text"
|
|
assert content == "Long text"
|
|
|
|
def test_image_with_caption(self):
|
|
msg = {"imageMessage": {"url": "http://x.com/i.jpg", "mimetype": "image/jpeg", "caption": "Photo"}}
|
|
ctype, content, attachments = _extract_content(msg, "msg-001")
|
|
assert ctype == "image"
|
|
assert content == "Photo"
|
|
assert len(attachments) == 1
|
|
assert attachments[0].file_id == "msg-001"
|
|
|
|
def test_video(self):
|
|
msg = {"videoMessage": {"url": "http://x.com/v.mp4", "mimetype": "video/mp4"}}
|
|
ctype, content, attachments = _extract_content(msg, "msg-002")
|
|
assert ctype == "video"
|
|
assert content == ""
|
|
|
|
def test_audio(self):
|
|
msg = {"audioMessage": {"url": "http://x.com/a.ogg", "mimetype": "audio/ogg"}}
|
|
ctype, content, attachments = _extract_content(msg, "msg-003")
|
|
assert ctype == "audio"
|
|
assert attachments[0].type == "audio"
|
|
|
|
def test_document(self):
|
|
msg = {
|
|
"documentMessage": {
|
|
"url": "http://x.com/d.pdf",
|
|
"filename": "doc.pdf",
|
|
"mimetype": "application/pdf",
|
|
}
|
|
}
|
|
ctype, content, attachments = _extract_content(msg, "msg-004")
|
|
assert ctype == "file"
|
|
assert attachments[0].filename == "doc.pdf"
|
|
|
|
def test_sticker(self):
|
|
msg = {"stickerMessage": {"url": "http://x.com/s.webp"}}
|
|
ctype, content, attachments = _extract_content(msg, "msg-005")
|
|
assert ctype == "sticker"
|
|
|
|
def test_location(self):
|
|
msg = {"locationMessage": {"degreesLatitude": 39.9, "degreesLongitude": 116.4}}
|
|
ctype, content, attachments = _extract_content(msg, "msg-006")
|
|
assert ctype == "location"
|
|
assert "39.9" in content
|
|
|
|
def test_contact(self):
|
|
msg = {"contactMessage": {"displayName": "John"}}
|
|
ctype, content, attachments = _extract_content(msg, "msg-007")
|
|
assert ctype == "card"
|
|
assert content == "John"
|
|
|
|
def test_reaction(self):
|
|
msg = {"reactionMessage": {"text": "❤️"}}
|
|
ctype, content, attachments = _extract_content(msg, "msg-008")
|
|
assert content == "❤️"
|
|
|
|
def test_buttons_response(self):
|
|
msg = {"buttonsResponseMessage": {"selectedButtonId": "b1", "selectedDisplayText": "Yes"}}
|
|
ctype, content, attachments = _extract_content(msg, "msg-009")
|
|
assert content == "Yes"
|
|
|
|
def test_buttons_response_fallback_to_id(self):
|
|
msg = {"buttonsResponseMessage": {"selectedButtonId": "b2"}}
|
|
ctype, content, attachments = _extract_content(msg, "msg-010")
|
|
assert content == "b2"
|
|
|
|
def test_list_response(self):
|
|
msg = {"listResponseMessage": {"title": "Pick one", "description": "option A"}}
|
|
ctype, content, attachments = _extract_content(msg, "msg-011")
|
|
assert content == "Pick one"
|
|
|
|
def test_template_button_reply(self):
|
|
msg = {"templateButtonReplyMessage": {"selectedId": "t1", "selectedDisplayText": "Approve"}}
|
|
ctype, content, attachments = _extract_content(msg, "msg-012")
|
|
assert content == "Approve"
|
|
|
|
def test_template_button_reply_fallback_to_id(self):
|
|
msg = {"templateButtonReplyMessage": {"selectedId": "t2"}}
|
|
ctype, content, attachments = _extract_content(msg, "msg-013")
|
|
assert content == "t2"
|
|
|
|
def test_poll_creation(self):
|
|
msg = {
|
|
"pollCreationMessage": {
|
|
"name": "Vote",
|
|
"options": [{"optionName": "A"}, {"optionName": "B"}],
|
|
}
|
|
}
|
|
ctype, content, attachments = _extract_content(msg, "msg-014")
|
|
assert "[Poll] Vote" in content
|
|
assert "A" in content
|
|
assert "B" in content
|
|
|
|
def test_unknown_fallback(self):
|
|
msg = {"weirdType": {"data": "value"}}
|
|
ctype, content, attachments = _extract_content(msg, "msg-015")
|
|
assert ctype == "text"
|
|
assert len(content) > 0
|
|
assert len(content) <= 500
|
|
|
|
|
|
class TestChunkMessage:
|
|
def test_short_message_returns_single_chunk(self):
|
|
result = chunk_message("Hello", 4096)
|
|
assert result == ["Hello"]
|
|
|
|
def test_message_at_limit(self):
|
|
content = "a" * 4096
|
|
result = chunk_message(content, 4096)
|
|
assert len(result) == 1
|
|
assert result[0] == content
|
|
|
|
def test_long_message_splits(self):
|
|
content = "a" * 5000
|
|
result = chunk_message(content, 100)
|
|
assert len(result) > 1
|
|
assert "".join(result) == content
|
|
|
|
def test_split_at_space_boundary(self):
|
|
content = "word1 " + "x" * 100 + " word2 " + "y" * 100
|
|
result = chunk_message(content, 50)
|
|
reconstructed = "".join(result)
|
|
assert reconstructed == content
|
|
for chunk in result[:-1]:
|
|
assert chunk.endswith(" ") or chunk.endswith("\n") or len(chunk) <= 50
|
|
|
|
def test_split_at_newline_boundary(self):
|
|
content = "line1\n" + "x" * 100 + "\nline2\n" + "y" * 100
|
|
result = chunk_message(content, 50)
|
|
reconstructed = "".join(result)
|
|
assert reconstructed == content
|
|
|
|
def test_no_boundary_within_limit(self):
|
|
content = "x" * 200
|
|
result = chunk_message(content, 50)
|
|
reconstructed = "".join(result)
|
|
assert reconstructed == content
|
|
assert len(result) == 4
|
|
|
|
def test_empty_content(self):
|
|
result = chunk_message("", 4096)
|
|
assert result == [""]
|
|
|
|
def test_default_limit(self):
|
|
content = "Hello" * 1000
|
|
result = chunk_message(content)
|
|
assert all(len(chunk) <= 4096 for chunk in result)
|
|
assert "".join(result) == content
|
|
|
|
|
|
class TestSessionUtils:
|
|
def test_jid_to_thread_key_dm(self):
|
|
key = jid_to_thread_key("whatsapp", "8613800138000@s.whatsapp.net")
|
|
assert key == "whatsapp:dm:8613800138000"
|
|
|
|
def test_jid_to_thread_key_group(self):
|
|
key = jid_to_thread_key("whatsapp", "123456789@g.us")
|
|
assert key == "whatsapp:group:123456789@g.us"
|
|
|
|
def test_jid_to_chat_type_direct(self):
|
|
assert jid_to_chat_type("8613800138000@s.whatsapp.net") == "direct"
|
|
|
|
def test_jid_to_chat_type_group(self):
|
|
assert jid_to_chat_type("123456789@g.us") == "group"
|
|
|
|
def test_jid_to_chat_type_broadcast(self):
|
|
assert jid_to_chat_type("status@broadcast") == "broadcast"
|
|
|
|
def test_normalize_phone_strips_plus(self):
|
|
assert normalize_phone("+8613800138000") == "8613800138000"
|
|
|
|
def test_normalize_phone_strips_spaces(self):
|
|
assert normalize_phone("+1 555 123 4567") == "15551234567"
|
|
|
|
def test_normalize_phone_strips_dashes(self):
|
|
assert normalize_phone("+1-555-123-4567") == "15551234567"
|
|
|
|
def test_normalize_phone_no_changes_needed(self):
|
|
assert normalize_phone("8613800138000") == "8613800138000"
|
|
|
|
|
|
class TestMediaUtils:
|
|
def test_suffix_map(self):
|
|
assert _MEDIA_SUFFIX_MAP["image"] == ".jpg"
|
|
assert _MEDIA_SUFFIX_MAP["video"] == ".mp4"
|
|
assert _MEDIA_SUFFIX_MAP["audio"] == ".ogg"
|
|
assert _MEDIA_SUFFIX_MAP["document"] == ""
|
|
assert _MEDIA_SUFFIX_MAP["sticker"] == ".webp"
|
|
|
|
def test_supported_media_types(self):
|
|
types = supported_media_types()
|
|
assert "image" in types
|
|
assert "video" in types
|
|
assert "audio" in types
|
|
assert "document" in types
|
|
assert "sticker" in types
|
|
|
|
|
|
class TestConnectionController:
|
|
def test_initial_state(self):
|
|
from yuxi.channels.adapters.whatsapp.connection_controller import ConnectionController, ConnectionState
|
|
|
|
ctrl = ConnectionController()
|
|
assert ctrl.state == ConnectionState.DISCONNECTED
|
|
assert ctrl.reconnect_count == 0
|
|
|
|
def test_transition_to_connecting(self):
|
|
from yuxi.channels.adapters.whatsapp.connection_controller import ConnectionController, ConnectionState
|
|
|
|
ctrl = ConnectionController()
|
|
ctrl.transition(ConnectionState.CONNECTING)
|
|
assert ctrl.state == ConnectionState.CONNECTING
|
|
|
|
def test_transition_to_qr_pending(self):
|
|
from yuxi.channels.adapters.whatsapp.connection_controller import ConnectionController, ConnectionState
|
|
|
|
ctrl = ConnectionController()
|
|
ctrl.transition(ConnectionState.QR_PENDING)
|
|
assert ctrl.state == ConnectionState.QR_PENDING
|
|
|
|
def test_transition_to_active(self):
|
|
from yuxi.channels.adapters.whatsapp.connection_controller import ConnectionController, ConnectionState
|
|
|
|
ctrl = ConnectionController()
|
|
ctrl.transition(ConnectionState.ACTIVE)
|
|
assert ctrl.state == ConnectionState.ACTIVE
|
|
assert ctrl.is_stable()
|
|
|
|
def test_same_state_no_transition(self):
|
|
from yuxi.channels.adapters.whatsapp.connection_controller import ConnectionController, ConnectionState
|
|
|
|
ctrl = ConnectionController()
|
|
assert ctrl.transition(ConnectionState.ACTIVE) is True
|
|
assert ctrl.transition(ConnectionState.ACTIVE) is False
|
|
|
|
def test_reconnect_count(self):
|
|
from yuxi.channels.adapters.whatsapp.connection_controller import ConnectionController, ConnectionState
|
|
|
|
ctrl = ConnectionController()
|
|
assert ctrl.reconnect_count == 0
|
|
ctrl.transition(ConnectionState.RECONNECTING)
|
|
assert ctrl.reconnect_count == 1
|
|
ctrl.transition(ConnectionState.DISCONNECTED)
|
|
ctrl.transition(ConnectionState.RECONNECTING)
|
|
assert ctrl.reconnect_count == 2
|
|
ctrl.transition(ConnectionState.ACTIVE)
|
|
assert ctrl.reconnect_count == 0
|
|
|
|
def test_logged_out_transition(self):
|
|
from yuxi.channels.adapters.whatsapp.connection_controller import ConnectionController, ConnectionState
|
|
|
|
ctrl = ConnectionController()
|
|
ctrl.transition(ConnectionState.ACTIVE)
|
|
ctrl.transition(ConnectionState.LOGGED_OUT)
|
|
assert ctrl.state == ConnectionState.LOGGED_OUT
|
|
|
|
def test_should_retry_and_next_delay(self):
|
|
from yuxi.channels.adapters.whatsapp.connection_controller import ConnectionController, ConnectionState
|
|
|
|
ctrl = ConnectionController()
|
|
assert ctrl.should_retry() is True
|
|
assert ctrl.next_delay() == 1.0
|
|
ctrl.transition(ConnectionState.RECONNECTING)
|
|
assert ctrl.next_delay() == 2.0
|
|
ctrl.transition(ConnectionState.DISCONNECTED)
|
|
ctrl.transition(ConnectionState.RECONNECTING)
|
|
assert ctrl.next_delay() == 4.0
|
|
|
|
|
|
class TestHeartbeatManager:
|
|
@pytest.fixture
|
|
def bridge_mock(self):
|
|
bridge = MagicMock()
|
|
bridge.health_check = AsyncMock(
|
|
return_value=MagicMock(status="healthy", last_error=None)
|
|
)
|
|
return bridge
|
|
|
|
def test_initial_state(self, bridge_mock):
|
|
mgr = HeartbeatManager(bridge_mock, interval=1.0)
|
|
assert mgr._consecutive_failures == 0
|
|
assert mgr._stopping is False
|
|
|
|
def test_backoff_zero_failures(self, bridge_mock):
|
|
mgr = HeartbeatManager(bridge_mock, interval=30.0)
|
|
assert mgr._backoff_seconds() == 30.0
|
|
|
|
def test_backoff_one_failure(self, bridge_mock):
|
|
mgr = HeartbeatManager(bridge_mock, interval=30.0)
|
|
mgr._consecutive_failures = 1
|
|
assert mgr._backoff_seconds() == 30.0
|
|
|
|
def test_backoff_two_failures(self, bridge_mock):
|
|
mgr = HeartbeatManager(bridge_mock, interval=30.0)
|
|
mgr._consecutive_failures = 2
|
|
assert mgr._backoff_seconds() == 60.0
|
|
|
|
def test_backoff_three_failures(self, bridge_mock):
|
|
mgr = HeartbeatManager(bridge_mock, interval=30.0)
|
|
mgr._consecutive_failures = 3
|
|
assert mgr._backoff_seconds() == 120.0
|
|
|
|
def test_backoff_max_cap(self, bridge_mock):
|
|
mgr = HeartbeatManager(bridge_mock, interval=30.0)
|
|
mgr._consecutive_failures = 10
|
|
assert mgr._backoff_seconds() == 300.0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_sets_stopping_false(self, bridge_mock):
|
|
mgr = HeartbeatManager(bridge_mock)
|
|
mgr._stopping = True
|
|
mgr._task = None
|
|
await mgr.start()
|
|
assert mgr._stopping is False
|
|
await mgr.stop()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_marks_stopping(self, bridge_mock):
|
|
mgr = HeartbeatManager(bridge_mock, interval=1.0)
|
|
await mgr.stop()
|
|
assert mgr._stopping is True
|
|
assert mgr._task is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_unhealthy_callback(self, bridge_mock):
|
|
mgr = HeartbeatManager(bridge_mock, interval=0.1)
|
|
callback_called = []
|
|
|
|
async def handler(error: str):
|
|
callback_called.append(error)
|
|
|
|
mgr.on_unhealthy(handler)
|
|
assert mgr._on_unhealthy is not None
|
|
|
|
|
|
class TestWhatsAppAdapterSend:
|
|
"""Test WhatsAppAdapter send error handling."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_connection_error(self):
|
|
from yuxi.channels.adapters.whatsapp.adapter import WhatsAppAdapter
|
|
from yuxi.channels.models import ChannelIdentity, ChannelResponse
|
|
|
|
adapter = WhatsAppAdapter({"name": "test-wa"})
|
|
adapter._status = "connected"
|
|
mock_bridge = MagicMock()
|
|
mock_bridge.send_message = AsyncMock(
|
|
side_effect=__import__("aiohttp").ClientConnectorError(
|
|
MagicMock(), MagicMock()
|
|
)
|
|
)
|
|
adapter._send_mgr._bridge = mock_bridge
|
|
adapter._bridge = mock_bridge
|
|
|
|
identity = ChannelIdentity(
|
|
channel_id="whatsapp",
|
|
channel_type="whatsapp",
|
|
channel_user_id="test",
|
|
channel_chat_id="test@s.whatsapp.net",
|
|
)
|
|
response = ChannelResponse(identity=identity, content="test")
|
|
result = await adapter.send(response)
|
|
assert result.success is False
|
|
assert "Bridge connection failed" in result.error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_generic_exception(self):
|
|
from yuxi.channels.adapters.whatsapp.adapter import WhatsAppAdapter
|
|
from yuxi.channels.models import ChannelIdentity, ChannelResponse
|
|
|
|
adapter = WhatsAppAdapter({"name": "test-wa"})
|
|
adapter._status = "connected"
|
|
mock_bridge = MagicMock()
|
|
mock_bridge.send_message = AsyncMock(side_effect=RuntimeError("unexpected"))
|
|
adapter._send_mgr._bridge = mock_bridge
|
|
adapter._bridge = mock_bridge
|
|
|
|
identity = ChannelIdentity(
|
|
channel_id="whatsapp",
|
|
channel_type="whatsapp",
|
|
channel_user_id="test",
|
|
channel_chat_id="test@s.whatsapp.net",
|
|
)
|
|
response = ChannelResponse(identity=identity, content="test")
|
|
result = await adapter.send(response)
|
|
assert result.success is False
|
|
assert "unexpected" in result.error
|
|
|
|
|
|
class TestWhatsAppAdapterDeleteMessage:
|
|
@pytest.mark.asyncio
|
|
async def test_delete_message_delegates_to_bridge(self):
|
|
from yuxi.channels.adapters.whatsapp.adapter import WhatsAppAdapter
|
|
from yuxi.channels.models import DeliveryResult
|
|
|
|
adapter = WhatsAppAdapter({"name": "test-wa"})
|
|
mock_bridge = MagicMock()
|
|
mock_bridge.delete_message = AsyncMock(
|
|
return_value=DeliveryResult(success=True, message_id="del-123")
|
|
)
|
|
adapter._send_mgr._bridge = mock_bridge
|
|
adapter._bridge = mock_bridge
|
|
|
|
result = await adapter.delete_message("123456789@g.us", "msg-001")
|
|
assert result.success is True
|
|
assert result.message_id == "del-123"
|
|
adapter._bridge.delete_message.assert_called_once_with(
|
|
jid="123456789@g.us",
|
|
message_id="msg-001",
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_message_handles_failure(self):
|
|
from yuxi.channels.adapters.whatsapp.adapter import WhatsAppAdapter
|
|
from yuxi.channels.models import DeliveryResult
|
|
|
|
adapter = WhatsAppAdapter({"name": "test-wa"})
|
|
mock_bridge = MagicMock()
|
|
mock_bridge.delete_message = AsyncMock(
|
|
return_value=DeliveryResult(success=False, error="Not connected")
|
|
)
|
|
adapter._send_mgr._bridge = mock_bridge
|
|
adapter._bridge = mock_bridge
|
|
|
|
result = await adapter.delete_message("test@s.whatsapp.net", "msg-002")
|
|
assert result.success is False
|
|
assert "Not connected" in result.error
|
|
|
|
|
|
class TestWhatsAppAdapterGroupApis:
|
|
@pytest.mark.asyncio
|
|
async def test_get_groups_delegates(self):
|
|
from yuxi.channels.adapters.whatsapp.adapter import WhatsAppAdapter
|
|
|
|
adapter = WhatsAppAdapter({"name": "test-wa"})
|
|
adapter._bridge = MagicMock()
|
|
adapter._bridge.get_groups = AsyncMock(return_value={"success": True, "groups": []})
|
|
|
|
result = await adapter.get_groups()
|
|
assert result["success"] is True
|
|
adapter._bridge.get_groups.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_group_info_delegates(self):
|
|
from yuxi.channels.adapters.whatsapp.adapter import WhatsAppAdapter
|
|
|
|
adapter = WhatsAppAdapter({"name": "test-wa"})
|
|
adapter._bridge = MagicMock()
|
|
adapter._bridge.get_group_info = AsyncMock(return_value={"success": True, "metadata": {}})
|
|
|
|
result = await adapter.get_group_info("123456789@g.us")
|
|
assert result["success"] is True
|
|
adapter._bridge.get_group_info.assert_called_once_with("123456789@g.us")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wait_scan_delegates(self):
|
|
from yuxi.channels.adapters.whatsapp.adapter import WhatsAppAdapter
|
|
|
|
adapter = WhatsAppAdapter({"name": "test-wa"})
|
|
adapter._bridge = MagicMock()
|
|
adapter._bridge.wait_scan = AsyncMock(return_value={"success": True, "jid": "test"})
|
|
|
|
result = await adapter.wait_scan(60.0)
|
|
assert result["success"] is True
|
|
adapter._bridge.wait_scan.assert_called_once_with(60.0)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_qr_status_delegates(self):
|
|
from yuxi.channels.adapters.whatsapp.adapter import WhatsAppAdapter
|
|
|
|
adapter = WhatsAppAdapter({"name": "test-wa"})
|
|
adapter._bridge = MagicMock()
|
|
adapter._bridge.get_qr_status = AsyncMock(
|
|
return_value={"connected": False, "connectionState": "connecting"}
|
|
)
|
|
|
|
result = await adapter.get_qr_status()
|
|
assert result["connected"] is False
|
|
adapter._bridge.get_qr_status.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_groups_async(self):
|
|
from yuxi.channels.adapters.whatsapp.adapter import WhatsAppAdapter
|
|
|
|
adapter = WhatsAppAdapter({"name": "test-wa"})
|
|
adapter._bridge = MagicMock()
|
|
adapter._bridge.get_groups = AsyncMock(
|
|
return_value={"success": True, "groups": [{"id": "g1", "subject": "Test"}]}
|
|
)
|
|
|
|
result = await adapter.get_groups()
|
|
assert result["success"] is True
|
|
assert len(result["groups"]) == 1
|
|
assert result["groups"][0]["subject"] == "Test" |