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"