ForcePilot/backend/test/unit/channels/test_whatsapp_adapter.py
Kris 3264900bc9 test: 新增多渠道单元测试用例并配置测试环境变量
新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块
同时在测试配置中添加了测试用的OpenAI API密钥环境变量
2026-05-12 00:56:47 +08:00

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"