新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块 同时在测试配置中添加了测试用的OpenAI API密钥环境变量
889 lines
33 KiB
Python
889 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"
|
|
adapter._bridge = MagicMock()
|
|
adapter._bridge.send_message = AsyncMock(
|
|
side_effect=__import__("aiohttp").ClientConnectorError(
|
|
MagicMock(), MagicMock()
|
|
)
|
|
)
|
|
|
|
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"
|
|
adapter._bridge = MagicMock()
|
|
adapter._bridge.send_message = AsyncMock(side_effect=RuntimeError("unexpected"))
|
|
|
|
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"})
|
|
adapter._bridge = MagicMock()
|
|
adapter._bridge.delete_message = AsyncMock(
|
|
return_value=DeliveryResult(success=True, message_id="del-123")
|
|
)
|
|
|
|
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"})
|
|
adapter._bridge = MagicMock()
|
|
adapter._bridge.delete_message = AsyncMock(
|
|
return_value=DeliveryResult(success=False, error="Not connected")
|
|
)
|
|
|
|
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" |