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

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"