# SPDX-License-Identifier: 0BSD import asyncio import json from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch import LXMF import pytest from meshchatx.meshchat import ReticulumMeshChat from meshchatx.src.backend.reticulum_pathfinding import OutboundPathOutcome @pytest.fixture def mock_app(): # Use __new__ to avoid full initialization app = ReticulumMeshChat.__new__(ReticulumMeshChat) app.current_context = MagicMock() app.config = MagicMock() app.database = MagicMock() app.reticulum = MagicMock() app.message_router = MagicMock() app._await_transport_path = AsyncMock( return_value=OutboundPathOutcome(True, "reused_valid_path", False), ) app.get_current_icon_hash = MagicMock(return_value=None) app.db_upsert_lxmf_message = MagicMock() app.websocket_broadcast = AsyncMock() app._is_contact = MagicMock(return_value=False) app._convert_webm_opus_to_ogg = MagicMock(side_effect=lambda b: b) app.handle_lxmf_message_progress = AsyncMock() # Setup context ctx = app.current_context ctx.message_router = app.message_router ctx.database = app.database ctx.config = app.config ctx.local_lxmf_destination = MagicMock() ctx.local_lxmf_destination.hexhash = "local_hash" ctx.forwarding_manager = None return app @pytest.mark.asyncio async def test_send_message_no_path_identity_recall_fails(mock_app): destination_hash = "aa" * 16 with patch("meshchatx.meshchat.RNS.Identity.recall", return_value=None): with pytest.raises(Exception, match="Could not find path to destination"): await mock_app.send_message( destination_hash=destination_hash, content="hi", ) @pytest.mark.asyncio async def test_send_message_immediate_exception_in_router(mock_app): destination_hash = "aa" * 16 fake_identity = MagicMock() mock_app.message_router.handle_outbound.side_effect = Exception("Router failure") with ( patch("meshchatx.meshchat.RNS.Identity.recall", return_value=fake_identity), patch("meshchatx.meshchat.RNS.Destination", return_value=MagicMock()), patch("meshchatx.meshchat.LXMF.LXMessage", return_value=MagicMock()), ): with pytest.raises(Exception, match="Router failure"): await mock_app.send_message( destination_hash=destination_hash, content="hi", ) @pytest.mark.asyncio async def test_on_lxmf_sending_failed_updates_state(mock_app): mock_msg = MagicMock(spec=LXMF.LXMessage) mock_msg.state = LXMF.LXMessage.FAILED mock_msg.try_propagation_on_fail = False mock_app.on_lxmf_sending_state_updated = MagicMock() from meshchatx.meshchat import ReticulumMeshChat ReticulumMeshChat.on_lxmf_sending_failed(mock_app, mock_msg) mock_app.on_lxmf_sending_state_updated.assert_called_once_with( mock_msg, context=mock_app.current_context ) @pytest.mark.asyncio async def test_propagation_fallback_on_failure(mock_app): mock_msg = MagicMock(spec=LXMF.LXMessage) mock_msg.state = LXMF.LXMessage.FAILED mock_msg.try_propagation_on_fail = True mock_msg.source_hash = b"source" mock_app.send_failed_message_via_propagation_node = MagicMock() mock_app.on_lxmf_sending_state_updated = MagicMock() from meshchatx.meshchat import ReticulumMeshChat ReticulumMeshChat.on_lxmf_sending_failed(mock_app, mock_msg) mock_app.send_failed_message_via_propagation_node.assert_called_once_with( mock_msg, context=mock_app.current_context ) mock_app.on_lxmf_sending_state_updated.assert_called_once_with( mock_msg, context=mock_app.current_context ) @pytest.mark.asyncio async def test_handle_lxmf_message_progress_failure_broadcast(mock_app): mock_msg = MagicMock() mock_msg.hash = MagicMock() mock_msg.hash.hex.return_value = "msg_hash_hex" mock_msg.progress = 0.0 mock_msg.delivery_attempts = 1 # State sequence: FAILED (first iteration should terminate loop) type(mock_msg).state = PropertyMock(return_value=LXMF.LXMessage.FAILED) mock_msg.method = LXMF.LXMessage.DIRECT with ( patch("meshchatx.meshchat.convert_lxmf_state_to_string", return_value="failed"), patch( "meshchatx.meshchat.convert_lxmf_method_to_string", return_value="direct", ), patch( "meshchatx.meshchat.convert_lxmf_message_to_dict", return_value={"hash": "hex", "state": "failed"}, ), patch("asyncio.sleep", return_value=asyncio.Future()) as mock_sleep, ): mock_sleep.return_value.set_result(None) from meshchatx.meshchat import ReticulumMeshChat await ReticulumMeshChat.handle_lxmf_message_progress( mock_app, mock_msg, context=mock_app.current_context ) # Verify update was called mock_app.database.messages.update_lxmf_message_state.assert_called() # Verify websocket broadcast was called mock_app.websocket_broadcast.assert_called() args = mock_app.websocket_broadcast.call_args[0][0] payload = json.loads(args) assert payload["type"] == "lxmf_message_state_updated" assert payload["lxmf_message"]["state"] == "failed" @pytest.mark.asyncio async def test_handle_lxmf_message_progress_continues_while_propagation_fallback_pending( mock_app, ): mock_msg = MagicMock() mock_msg.hash = MagicMock() mock_msg.hash.hex.return_value = "msg_hash_hex" mock_msg.progress = 0.0 mock_msg.delivery_attempts = 1 mock_msg.try_propagation_on_fail = True mock_msg.state = LXMF.LXMessage.FAILED mock_msg.method = LXMF.LXMessage.DIRECT iteration = 0 async def counting_sleep(_duration): nonlocal iteration iteration += 1 if iteration == 2: mock_msg.try_propagation_on_fail = False mock_msg.state = LXMF.LXMessage.SENT mock_msg.method = LXMF.LXMessage.PROPAGATED def state_to_str(msg): if msg.state == LXMF.LXMessage.FAILED: return "failed" if msg.state == LXMF.LXMessage.SENT: return "sent" return "unknown" with ( patch( "meshchatx.meshchat.convert_lxmf_state_to_string", side_effect=state_to_str ), patch( "meshchatx.meshchat.convert_lxmf_method_to_string", side_effect=lambda m: ( "propagated" if m.method == LXMF.LXMessage.PROPAGATED else "direct" ), ), patch( "meshchatx.meshchat.convert_lxmf_message_to_dict", side_effect=lambda *a, **k: { "hash": "hex", "state": "failed" if a[0].state == LXMF.LXMessage.FAILED else "sent", }, ), patch("asyncio.sleep", side_effect=counting_sleep), ): from meshchatx.meshchat import ReticulumMeshChat await ReticulumMeshChat.handle_lxmf_message_progress( mock_app, mock_msg, context=mock_app.current_context ) assert iteration == 2 assert mock_app.database.messages.update_lxmf_message_state.call_count == 2 @pytest.mark.asyncio async def test_send_message_db_upsert_failure_still_broadcasts(mock_app): # If db_upsert fails, we want to know if it's caught or if it crashes send_message. # Actually, send_message doesn't have a try-except around db_upsert_lxmf_message. # If it fails, the whole send_message fails, which returns 503 to frontend. destination_hash = "aa" * 16 fake_identity = MagicMock() mock_app.db_upsert_lxmf_message.side_effect = Exception("DB Error") with ( patch("meshchatx.meshchat.RNS.Identity.recall", return_value=fake_identity), patch("meshchatx.meshchat.RNS.Destination", return_value=MagicMock()), patch("meshchatx.meshchat.LXMF.LXMessage", return_value=MagicMock()), ): with pytest.raises(Exception, match="DB Error"): await mock_app.send_message( destination_hash=destination_hash, content="hi", ) @pytest.mark.asyncio async def test_send_message_await_path_timeout(mock_app): mock_app._await_transport_path = AsyncMock( return_value=OutboundPathOutcome(False, "new_path_requested", True), ) destination_hash = "aa" * 16 # Even if _await_transport_path returns False, it continues to recall identity with patch("meshchatx.meshchat.RNS.Identity.recall", return_value=None): with pytest.raises(Exception, match="Could not find path to destination"): await mock_app.send_message( destination_hash=destination_hash, content="hi", )