columba/python/test_wrapper_messaging.py
torlando-tech 38c994680b fix(test): make sleep count assertion less strict in path request test
The test was expecting exactly 10 sleep calls, but background threads
(from other tests or the same test) may also call time.sleep, causing
the count to be higher. Changed to assertGreaterEqual(10) which still
verifies the retry loop ran while being robust to background activity.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 18:25:50 -05:00

2194 lines
82 KiB
Python

"""
Test suite for ReticulumWrapper messaging methods.
Tests send_lxmf_message, send_lxmf_message_with_method, send_packet,
message callbacks, delivery confirmations, and error handling.
"""
import sys
import os
import unittest
import time
import json
from unittest.mock import Mock, MagicMock, patch, call
# Add parent directory to path to import reticulum_wrapper
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# Mock LXMF with message state constants before importing
mock_lxmf = MagicMock()
mock_lxmf.LXMessage.OPPORTUNISTIC = 0x01
mock_lxmf.LXMessage.DIRECT = 0x02
mock_lxmf.LXMessage.PROPAGATED = 0x03
mock_lxmf.LXMessage.SENT = 0x04
mock_lxmf.LXMessage.DELIVERED = 0x05
mock_lxmf.LXMessage.FAILED = 0x06
# Mock RNS and LXMF before importing reticulum_wrapper
sys.modules['RNS'] = MagicMock()
sys.modules['RNS.vendor'] = MagicMock()
sys.modules['RNS.vendor.platformutils'] = MagicMock()
sys.modules['LXMF'] = mock_lxmf
# Now import after mocking
import reticulum_wrapper
class TestSendLXMFMessage(unittest.TestCase):
"""Test send_lxmf_message method - full message send flow"""
def setUp(self):
"""Set up test fixtures"""
import tempfile
self.temp_dir = tempfile.mkdtemp()
# Enable RETICULUM_AVAILABLE for these tests
self.original_available = reticulum_wrapper.RETICULUM_AVAILABLE
reticulum_wrapper.RETICULUM_AVAILABLE = True
def tearDown(self):
"""Clean up test fixtures"""
import shutil
if os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir)
reticulum_wrapper.RETICULUM_AVAILABLE = self.original_available
@patch('reticulum_wrapper.RNS')
@patch('reticulum_wrapper.LXMF')
def test_send_message_not_initialized(self, mock_lxmf, mock_rns):
"""Test that send fails when wrapper is not initialized"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = False
result = wrapper.send_lxmf_message(
dest_hash=b'0123456789abcdef',
content="Test message",
source_identity_private_key=b'privkey'
)
self.assertFalse(result['success'])
self.assertIn('error', result)
self.assertEqual(result['error'], 'LXMF not initialized')
@patch('reticulum_wrapper.RNS')
@patch('reticulum_wrapper.LXMF')
def test_send_message_success(self, mock_lxmf, mock_rns):
"""Test successful message send"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
# Mock router
mock_router = MagicMock()
wrapper.router = mock_router
# Mock local LXMF destination
mock_local_dest = MagicMock()
mock_local_dest.hash = b'localdest1234567'
wrapper.local_lxmf_destination = mock_local_dest
wrapper.display_name = "TestUser"
# Mock identity recall
mock_identity = MagicMock()
mock_identity.hash = b'0123456789abcdef'
mock_rns.Identity.recall.return_value = mock_identity
# Mock LXMF destination creation
mock_dest = MagicMock()
mock_dest.hash = b'lxmfdest12345678'
mock_rns.Destination.return_value = mock_dest
# Mock LXMF message
mock_message = MagicMock()
mock_message.hash = b'messagehash12345'
mock_message.state = mock_lxmf.LXMessage.SENT
mock_lxmf.LXMessage.return_value = mock_message
# Send message
result = wrapper.send_lxmf_message(
dest_hash=b'0123456789abcdef',
content="Test message",
source_identity_private_key=b'privkey' * 10
)
# Verify success
self.assertTrue(result['success'])
self.assertIn('message_hash', result)
self.assertIn('timestamp', result)
# Verify router.handle_outbound was called
mock_router.handle_outbound.assert_called_once_with(mock_message)
# Verify callbacks were registered
mock_message.register_delivery_callback.assert_called_once()
mock_message.register_failed_callback.assert_called_once()
@patch('reticulum_wrapper.RNS')
@patch('reticulum_wrapper.LXMF')
def test_send_message_with_image_attachment(self, mock_lxmf, mock_rns):
"""Test sending message with image attachment"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
wrapper.router = MagicMock()
mock_local_dest = MagicMock()
mock_local_dest.hash = b'localdest1234567'
wrapper.local_lxmf_destination = mock_local_dest
wrapper.display_name = "TestUser"
# Mock identity recall
mock_identity = MagicMock()
mock_rns.Identity.recall.return_value = mock_identity
# Mock destination creation
mock_dest = MagicMock()
mock_dest.hash = b'lxmfdest12345678'
mock_rns.Destination.return_value = mock_dest
# Mock message
mock_message = MagicMock()
mock_message.hash = b'messagehash12345'
mock_message.state = mock_lxmf.LXMessage.SENT
mock_lxmf.LXMessage.return_value = mock_message
# Send with image
image_data = b'\x89PNG\r\n\x1a\n' + b'\x00' * 100
result = wrapper.send_lxmf_message(
dest_hash=b'0123456789abcdef',
content="Check out this image!",
source_identity_private_key=b'privkey' * 10,
image_data=image_data,
image_format='png'
)
self.assertTrue(result['success'])
# Verify LXMessage was created with fields containing image
call_kwargs = mock_lxmf.LXMessage.call_args[1]
self.assertIn('fields', call_kwargs)
fields = call_kwargs['fields']
# Field 6 is the image field: {6: [format, data]}
self.assertIn(6, fields)
self.assertEqual(fields[6][0], 'png') # format
self.assertEqual(fields[6][1], image_data) # data
@patch('reticulum_wrapper.RNS')
@patch('reticulum_wrapper.LXMF')
def test_send_message_identity_not_found(self, mock_lxmf, mock_rns):
"""Test sending when recipient identity cannot be recalled"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
wrapper.router = MagicMock()
mock_local_dest = MagicMock()
wrapper.local_lxmf_destination = mock_local_dest
wrapper.display_name = "TestUser"
# Identity recall returns None
mock_rns.Identity.recall.return_value = None
# Empty identities cache
wrapper.identities = {}
# Should still request path and attempt send
result = wrapper.send_lxmf_message(
dest_hash=b'0123456789abcdef',
content="Test message",
source_identity_private_key=b'privkey' * 10
)
# Should fail with identity not found error
self.assertFalse(result['success'])
self.assertIn('error', result)
# Verify path request was attempted
mock_rns.Transport.request_path.assert_called()
@patch('reticulum_wrapper.RNS')
@patch('reticulum_wrapper.LXMF')
def test_send_message_with_cached_identity(self, mock_lxmf, mock_rns):
"""Test that cached identities are used when recall fails"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
wrapper.router = MagicMock()
mock_local_dest = MagicMock()
mock_local_dest.hash = b'localdest1234567'
wrapper.local_lxmf_destination = mock_local_dest
wrapper.display_name = "TestUser"
# Identity recall fails
mock_rns.Identity.recall.return_value = None
# But identity is in cache
dest_hash = b'0123456789abcdef'
mock_cached_identity = MagicMock()
mock_cached_identity.hash = dest_hash
wrapper.identities[dest_hash.hex()] = mock_cached_identity
# Mock destination
mock_dest = MagicMock()
mock_dest.hash = b'lxmfdest12345678'
mock_rns.Destination.return_value = mock_dest
# Mock message
mock_message = MagicMock()
mock_message.hash = b'messagehash12345'
mock_message.state = mock_lxmf.LXMessage.SENT
mock_lxmf.LXMessage.return_value = mock_message
result = wrapper.send_lxmf_message(
dest_hash=dest_hash,
content="Test message",
source_identity_private_key=b'privkey' * 10
)
# Should succeed using cached identity
self.assertTrue(result['success'])
def test_send_message_when_reticulum_unavailable(self):
"""Test behavior when Reticulum is not available"""
reticulum_wrapper.RETICULUM_AVAILABLE = False
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
result = wrapper.send_lxmf_message(
dest_hash=b'0123456789abcdef',
content="Test message",
source_identity_private_key=b'privkey'
)
self.assertFalse(result['success'])
self.assertIn('error', result)
class TestSendLXMFMessageWithMethod(unittest.TestCase):
"""Test send_lxmf_message_with_method - delivery method selection"""
def setUp(self):
"""Set up test fixtures"""
import tempfile
self.temp_dir = tempfile.mkdtemp()
self.original_available = reticulum_wrapper.RETICULUM_AVAILABLE
reticulum_wrapper.RETICULUM_AVAILABLE = True
def tearDown(self):
"""Clean up test fixtures"""
import shutil
if os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir)
reticulum_wrapper.RETICULUM_AVAILABLE = self.original_available
@patch('reticulum_wrapper.RNS')
@patch('reticulum_wrapper.LXMF')
def test_send_with_direct_method(self, mock_lxmf, mock_rns):
"""Test sending with DIRECT delivery method"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
wrapper.router = MagicMock()
mock_local_dest = MagicMock()
mock_local_dest.hash = b'localdest1234567'
wrapper.local_lxmf_destination = mock_local_dest
# Mock identity
mock_identity = MagicMock()
mock_rns.Identity.recall.return_value = mock_identity
# Mock destination
mock_dest = MagicMock()
mock_dest.hash = b'lxmfdest12345678'
mock_rns.Destination.return_value = mock_dest
# Mock message
mock_message = MagicMock()
mock_message.hash = b'messagehash12345'
mock_lxmf.LXMessage.return_value = mock_message
result = wrapper.send_lxmf_message_with_method(
dest_hash=b'0123456789abcdef',
content="Test message",
source_identity_private_key=b'privkey' * 10,
delivery_method="direct"
)
self.assertTrue(result['success'])
self.assertEqual(result['delivery_method'], 'direct')
# Verify message was created with DIRECT method
call_kwargs = mock_lxmf.LXMessage.call_args[1]
self.assertEqual(call_kwargs['desired_method'], mock_lxmf.LXMessage.DIRECT)
@patch('reticulum_wrapper.RNS')
@patch('reticulum_wrapper.LXMF')
def test_send_with_opportunistic_method(self, mock_lxmf, mock_rns):
"""Test sending with OPPORTUNISTIC delivery method"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
wrapper.router = MagicMock()
mock_local_dest = MagicMock()
mock_local_dest.hash = b'localdest1234567'
wrapper.local_lxmf_destination = mock_local_dest
# Mock identity
mock_identity = MagicMock()
mock_rns.Identity.recall.return_value = mock_identity
# Mock destination
mock_dest = MagicMock()
mock_dest.hash = b'lxmfdest12345678'
mock_rns.Destination.return_value = mock_dest
# Mock message
mock_message = MagicMock()
mock_message.hash = b'messagehash12345'
mock_lxmf.LXMessage.return_value = mock_message
# Short message for opportunistic
result = wrapper.send_lxmf_message_with_method(
dest_hash=b'0123456789abcdef',
content="Short",
source_identity_private_key=b'privkey' * 10,
delivery_method="opportunistic"
)
self.assertTrue(result['success'])
self.assertEqual(result['delivery_method'], 'opportunistic')
@patch('reticulum_wrapper.RNS')
@patch('reticulum_wrapper.LXMF')
def test_opportunistic_falls_back_for_large_content(self, mock_lxmf, mock_rns):
"""Test OPPORTUNISTIC falls back to DIRECT for large content"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
wrapper.router = MagicMock()
mock_local_dest = MagicMock()
mock_local_dest.hash = b'localdest1234567'
wrapper.local_lxmf_destination = mock_local_dest
mock_identity = MagicMock()
mock_rns.Identity.recall.return_value = mock_identity
mock_dest = MagicMock()
mock_dest.hash = b'lxmfdest12345678'
mock_rns.Destination.return_value = mock_dest
mock_message = MagicMock()
mock_message.hash = b'messagehash12345'
mock_lxmf.LXMessage.return_value = mock_message
# Large content (>295 bytes)
large_content = "A" * 300
result = wrapper.send_lxmf_message_with_method(
dest_hash=b'0123456789abcdef',
content=large_content,
source_identity_private_key=b'privkey' * 10,
delivery_method="opportunistic"
)
self.assertTrue(result['success'])
# Should fall back to direct
call_kwargs = mock_lxmf.LXMessage.call_args[1]
self.assertEqual(call_kwargs['desired_method'], mock_lxmf.LXMessage.DIRECT)
@patch('reticulum_wrapper.RNS')
@patch('reticulum_wrapper.LXMF')
def test_opportunistic_falls_back_for_attachments(self, mock_lxmf, mock_rns):
"""Test OPPORTUNISTIC falls back to DIRECT when image attached"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
wrapper.router = MagicMock()
mock_local_dest = MagicMock()
mock_local_dest.hash = b'localdest1234567'
wrapper.local_lxmf_destination = mock_local_dest
mock_identity = MagicMock()
mock_rns.Identity.recall.return_value = mock_identity
mock_dest = MagicMock()
mock_dest.hash = b'lxmfdest12345678'
mock_rns.Destination.return_value = mock_dest
mock_message = MagicMock()
mock_message.hash = b'messagehash12345'
mock_lxmf.LXMessage.return_value = mock_message
result = wrapper.send_lxmf_message_with_method(
dest_hash=b'0123456789abcdef',
content="Short",
source_identity_private_key=b'privkey' * 10,
delivery_method="opportunistic",
image_data=b'imagedata',
image_format='jpg'
)
self.assertTrue(result['success'])
# Should fall back to direct due to attachment
call_kwargs = mock_lxmf.LXMessage.call_args[1]
self.assertEqual(call_kwargs['desired_method'], mock_lxmf.LXMessage.DIRECT)
@patch('reticulum_wrapper.RNS')
@patch('reticulum_wrapper.LXMF')
def test_send_with_propagated_method(self, mock_lxmf, mock_rns):
"""Test sending with PROPAGATED delivery method"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
wrapper.router = MagicMock()
# Set active propagation node
wrapper.active_propagation_node = b'propnode12345678'
mock_local_dest = MagicMock()
mock_local_dest.hash = b'localdest1234567'
wrapper.local_lxmf_destination = mock_local_dest
mock_identity = MagicMock()
mock_rns.Identity.recall.return_value = mock_identity
mock_dest = MagicMock()
mock_dest.hash = b'lxmfdest12345678'
mock_rns.Destination.return_value = mock_dest
mock_message = MagicMock()
mock_message.hash = b'messagehash12345'
mock_lxmf.LXMessage.return_value = mock_message
result = wrapper.send_lxmf_message_with_method(
dest_hash=b'0123456789abcdef',
content="Test message",
source_identity_private_key=b'privkey' * 10,
delivery_method="propagated"
)
self.assertTrue(result['success'])
self.assertEqual(result['delivery_method'], 'propagated')
@patch('reticulum_wrapper.RNS')
@patch('reticulum_wrapper.LXMF')
def test_propagated_falls_back_to_direct_without_propagation_node(self, mock_lxmf, mock_rns):
"""Test PROPAGATED falls back to DIRECT when no propagation node is set"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
wrapper.router = MagicMock()
wrapper.active_propagation_node = None
mock_local_dest = MagicMock()
mock_local_dest.hash = b'localdest1234567'
wrapper.local_lxmf_destination = mock_local_dest
# Set up identity mock for send to work
mock_identity = MagicMock()
mock_rns.Identity.return_value = mock_identity
mock_rns.Identity.recall.return_value = mock_identity
mock_dest = MagicMock()
mock_dest.hash = b'lxmfdest12345678'
mock_rns.Destination.return_value = mock_dest
result = wrapper.send_lxmf_message_with_method(
dest_hash=b'0123456789abcdef',
content="Test message",
source_identity_private_key=b'privkey' * 10,
delivery_method="propagated"
)
# Should succeed by falling back to DIRECT
self.assertTrue(result['success'])
# Verify it used DIRECT method (fallback from PROPAGATED)
mock_lxmf.LXMessage.assert_called()
call_kwargs = mock_lxmf.LXMessage.call_args
# The desired_method should be DIRECT (fallback)
self.assertEqual(call_kwargs.kwargs.get('desired_method'), mock_lxmf.LXMessage.DIRECT)
@patch('reticulum_wrapper.RNS')
@patch('reticulum_wrapper.LXMF')
def test_try_propagation_on_fail_flag(self, mock_lxmf, mock_rns):
"""Test that try_propagation_on_fail flag is set on message"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
wrapper.router = MagicMock()
wrapper.active_propagation_node = b'propnode12345678'
mock_local_dest = MagicMock()
mock_local_dest.hash = b'localdest1234567'
wrapper.local_lxmf_destination = mock_local_dest
mock_identity = MagicMock()
mock_rns.Identity.recall.return_value = mock_identity
mock_dest = MagicMock()
mock_dest.hash = b'lxmfdest12345678'
mock_rns.Destination.return_value = mock_dest
mock_message = MagicMock()
mock_message.hash = b'messagehash12345'
mock_lxmf.LXMessage.return_value = mock_message
result = wrapper.send_lxmf_message_with_method(
dest_hash=b'0123456789abcdef',
content="Test",
source_identity_private_key=b'privkey' * 10,
delivery_method="direct",
try_propagation_on_fail=True
)
self.assertTrue(result['success'])
# Verify flag was set on message
self.assertTrue(mock_message.try_propagation_on_fail)
@patch('reticulum_wrapper.LXMF')
@patch('reticulum_wrapper.RNS')
def test_send_with_immediate_sent_state_check(self, mock_rns, mock_lxmf):
"""Test that send_lxmf_message_with_method checks for immediate SENT state"""
# Setup module-level mocks
reticulum_wrapper.RETICULUM_AVAILABLE = True
reticulum_wrapper.LXMF = mock_lxmf
reticulum_wrapper.RNS = mock_rns
# Set LXMF constants
mock_lxmf.LXMessage.SENT = 0x04
mock_lxmf.LXMessage.PROPAGATED = 0x03
mock_lxmf.LXMessage.OUTBOUND = 0x01
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
wrapper.router = MagicMock()
wrapper.active_propagation_node = b'\xaa\x01\x8e\xd2\x8f\xb6\x44\x05\xf8\x47\x78\x66\xb7\x8a\x66\x8c'
# Mock identity
mock_identity = MagicMock()
mock_identity.hash = b'\xde\xad\xbe\xef' * 4
mock_identity.load_private_key = MagicMock()
mock_rns.Identity.recall.return_value = mock_identity
mock_rns.Identity.return_value = mock_identity
# Mock destination
mock_dest = MagicMock()
mock_dest.hash = b'\xca\xfe\xba\xbe' * 8
mock_dest.announce = MagicMock() # Mock announce to avoid errors
mock_rns.Destination.return_value = mock_dest
wrapper.local_lxmf_destination = mock_dest
# Create a message that has SENT state immediately after handle_outbound
mock_message = MagicMock()
mock_message.hash = b'\xab\xcd\xef' * 10 + b'\x12\x34'
mock_message.state = 0x04 # SENT state
mock_message.try_propagation_on_fail = False
mock_message.register_delivery_callback = MagicMock()
mock_message.register_failed_callback = MagicMock()
mock_lxmf.LXMessage.return_value = mock_message
# Mock _on_message_sent to track calls
wrapper._on_message_sent = MagicMock()
# Send message
result = wrapper.send_lxmf_message_with_method(
dest_hash=b'\xde\xad\xbe\xef' * 4,
content="Test message",
source_identity_private_key=b'\x00' * 32,
delivery_method="propagated"
)
# Verify result success
self.assertTrue(result['success'])
# Verify _on_message_sent was called since state was SENT
wrapper._on_message_sent.assert_called_once_with(mock_message)
@patch('reticulum_wrapper.LXMF')
@patch('reticulum_wrapper.RNS')
def test_send_does_not_call_on_message_sent_for_outbound_state(self, mock_rns, mock_lxmf):
"""Test that _on_message_sent is NOT called when state is OUTBOUND (not SENT)"""
reticulum_wrapper.RETICULUM_AVAILABLE = True
reticulum_wrapper.LXMF = mock_lxmf
reticulum_wrapper.RNS = mock_rns
# Set LXMF constants
mock_lxmf.LXMessage.SENT = 0x04
mock_lxmf.LXMessage.PROPAGATED = 0x03
mock_lxmf.LXMessage.OUTBOUND = 0x01
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
wrapper.router = MagicMock()
wrapper.active_propagation_node = b'\xaa\x01\x8e\xd2\x8f\xb6\x44\x05\xf8\x47\x78\x66\xb7\x8a\x66\x8c'
# Mock identity
mock_identity = MagicMock()
mock_identity.hash = b'\xde\xad\xbe\xef' * 4
mock_identity.load_private_key = MagicMock()
mock_rns.Identity.recall.return_value = mock_identity
mock_rns.Identity.return_value = mock_identity
# Mock destination
mock_dest = MagicMock()
mock_dest.hash = b'\xca\xfe\xba\xbe' * 8
mock_dest.announce = MagicMock()
mock_rns.Destination.return_value = mock_dest
wrapper.local_lxmf_destination = mock_dest
# Create a message with OUTBOUND state (not SENT)
mock_message = MagicMock()
mock_message.hash = b'\xab\xcd\xef' * 10 + b'\x12\x34'
mock_message.state = 0x01 # OUTBOUND state, not SENT
mock_message.try_propagation_on_fail = False
mock_message.register_delivery_callback = MagicMock()
mock_message.register_failed_callback = MagicMock()
mock_lxmf.LXMessage.return_value = mock_message
# Mock _on_message_sent to track calls
wrapper._on_message_sent = MagicMock()
# Send message
result = wrapper.send_lxmf_message_with_method(
dest_hash=b'\xde\xad\xbe\xef' * 4,
content="Test message",
source_identity_private_key=b'\x00' * 32,
delivery_method="propagated"
)
# Verify result success
self.assertTrue(result['success'])
# Verify _on_message_sent was NOT called since state was not SENT
wrapper._on_message_sent.assert_not_called()
@patch('reticulum_wrapper.LXMF')
@patch('reticulum_wrapper.RNS')
def test_send_handles_missing_state_attribute_gracefully(self, mock_rns, mock_lxmf):
"""Test that send handles messages without state attribute gracefully"""
reticulum_wrapper.RETICULUM_AVAILABLE = True
reticulum_wrapper.LXMF = mock_lxmf
reticulum_wrapper.RNS = mock_rns
# Set LXMF constants
mock_lxmf.LXMessage.SENT = 0x04
mock_lxmf.LXMessage.PROPAGATED = 0x03
mock_lxmf.LXMessage.OUTBOUND = 0x01
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
wrapper.router = MagicMock()
wrapper.active_propagation_node = b'\xaa\x01\x8e\xd2\x8f\xb6\x44\x05\xf8\x47\x78\x66\xb7\x8a\x66\x8c'
# Mock identity
mock_identity = MagicMock()
mock_identity.hash = b'\xde\xad\xbe\xef' * 4
mock_identity.load_private_key = MagicMock()
mock_rns.Identity.recall.return_value = mock_identity
mock_rns.Identity.return_value = mock_identity
# Mock destination
mock_dest = MagicMock()
mock_dest.hash = b'\xca\xfe\xba\xbe' * 8
mock_dest.announce = MagicMock()
mock_rns.Destination.return_value = mock_dest
wrapper.local_lxmf_destination = mock_dest
# Create a message WITHOUT state attribute (use spec to exclude it)
mock_message = MagicMock(spec=['hash', 'try_propagation_on_fail',
'register_delivery_callback', 'register_failed_callback'])
mock_message.hash = b'\xab\xcd\xef' * 10 + b'\x12\x34'
mock_message.try_propagation_on_fail = False
mock_message.register_delivery_callback = MagicMock()
mock_message.register_failed_callback = MagicMock()
mock_lxmf.LXMessage.return_value = mock_message
# Mock _on_message_sent
wrapper._on_message_sent = MagicMock()
# Send message - should NOT crash
result = wrapper.send_lxmf_message_with_method(
dest_hash=b'\xde\xad\xbe\xef' * 4,
content="Test message",
source_identity_private_key=b'\x00' * 32,
delivery_method="propagated"
)
# Verify result success - should not crash
self.assertTrue(result['success'])
# _on_message_sent should NOT be called (no state attribute)
wrapper._on_message_sent.assert_not_called()
@patch('reticulum_wrapper.LXMF')
@patch('reticulum_wrapper.RNS')
def test_send_handles_state_check_exception_gracefully(self, mock_rns, mock_lxmf):
"""Test that exceptions during state check don't crash send"""
reticulum_wrapper.RETICULUM_AVAILABLE = True
reticulum_wrapper.LXMF = mock_lxmf
reticulum_wrapper.RNS = mock_rns
# Set LXMF constants
mock_lxmf.LXMessage.SENT = 0x04
mock_lxmf.LXMessage.PROPAGATED = 0x03
mock_lxmf.LXMessage.OUTBOUND = 0x01
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
wrapper.router = MagicMock()
wrapper.active_propagation_node = b'\xaa\x01\x8e\xd2\x8f\xb6\x44\x05\xf8\x47\x78\x66\xb7\x8a\x66\x8c'
# Mock identity
mock_identity = MagicMock()
mock_identity.hash = b'\xde\xad\xbe\xef' * 4
mock_identity.load_private_key = MagicMock()
mock_rns.Identity.recall.return_value = mock_identity
mock_rns.Identity.return_value = mock_identity
# Mock destination
mock_dest = MagicMock()
mock_dest.hash = b'\xca\xfe\xba\xbe' * 8
mock_dest.announce = MagicMock()
mock_rns.Destination.return_value = mock_dest
wrapper.local_lxmf_destination = mock_dest
# Create a message where accessing state raises an exception
mock_message = MagicMock()
mock_message.hash = b'\xab\xcd\xef' * 10 + b'\x12\x34'
# Make state property raise an exception
type(mock_message).state = property(lambda self: (_ for _ in ()).throw(RuntimeError("State error")))
mock_message.try_propagation_on_fail = False
mock_message.register_delivery_callback = MagicMock()
mock_message.register_failed_callback = MagicMock()
mock_lxmf.LXMessage.return_value = mock_message
# Mock _on_message_sent
wrapper._on_message_sent = MagicMock()
# Send message - should NOT crash despite state access error
result = wrapper.send_lxmf_message_with_method(
dest_hash=b'\xde\xad\xbe\xef' * 4,
content="Test message",
source_identity_private_key=b'\x00' * 32,
delivery_method="propagated"
)
# Verify result success - exception should be caught
self.assertTrue(result['success'])
# _on_message_sent should NOT be called due to exception
wrapper._on_message_sent.assert_not_called()
@patch('reticulum_wrapper.LXMF')
@patch('reticulum_wrapper.RNS')
def test_send_with_propagated_state_does_not_trigger_sent_callback(self, mock_rns, mock_lxmf):
"""Test that PROPAGATED state does not trigger _on_message_sent"""
reticulum_wrapper.RETICULUM_AVAILABLE = True
reticulum_wrapper.LXMF = mock_lxmf
reticulum_wrapper.RNS = mock_rns
mock_lxmf.LXMessage.SENT = 0x04
mock_lxmf.LXMessage.PROPAGATED = 0x03
mock_lxmf.LXMessage.OUTBOUND = 0x01
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
wrapper.router = MagicMock()
wrapper.active_propagation_node = b'\xaa\x01\x8e\xd2\x8f\xb6\x44\x05\xf8\x47\x78\x66\xb7\x8a\x66\x8c'
mock_identity = MagicMock()
mock_identity.hash = b'\xde\xad\xbe\xef' * 4
mock_identity.load_private_key = MagicMock()
mock_rns.Identity.recall.return_value = mock_identity
mock_rns.Identity.return_value = mock_identity
mock_dest = MagicMock()
mock_dest.hash = b'\xca\xfe\xba\xbe' * 8
mock_dest.announce = MagicMock()
mock_rns.Destination.return_value = mock_dest
wrapper.local_lxmf_destination = mock_dest
# Create a message with PROPAGATED state (not SENT)
mock_message = MagicMock()
mock_message.hash = b'\xab\xcd\xef' * 10 + b'\x12\x34'
mock_message.state = 0x03 # PROPAGATED state
mock_message.try_propagation_on_fail = False
mock_message.register_delivery_callback = MagicMock()
mock_message.register_failed_callback = MagicMock()
mock_lxmf.LXMessage.return_value = mock_message
wrapper._on_message_sent = MagicMock()
result = wrapper.send_lxmf_message_with_method(
dest_hash=b'\xde\xad\xbe\xef' * 4,
content="Test message",
source_identity_private_key=b'\x00' * 32,
delivery_method="propagated"
)
self.assertTrue(result['success'])
# PROPAGATED != SENT, so callback should NOT be called
wrapper._on_message_sent.assert_not_called()
@patch('reticulum_wrapper.RNS')
@patch('reticulum_wrapper.LXMF')
def test_send_with_image_data_path_reads_file(self, mock_lxmf, mock_rns):
"""Test that image_data_path reads image from file and cleans up"""
import tempfile
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
wrapper.router = MagicMock()
mock_local_dest = MagicMock()
mock_local_dest.hash = b'localdest1234567'
wrapper.local_lxmf_destination = mock_local_dest
# Mock identity
mock_identity = MagicMock()
mock_rns.Identity.recall.return_value = mock_identity
# Mock destination
mock_dest = MagicMock()
mock_dest.hash = b'lxmfdest12345678'
mock_rns.Destination.return_value = mock_dest
# Mock message
mock_message = MagicMock()
mock_message.hash = b'messagehash12345'
mock_lxmf.LXMessage.return_value = mock_message
# Create a temp file with image data
image_data = b'\x89PNG\r\n\x1a\n' + b'\x00' * 100
with tempfile.NamedTemporaryFile(delete=False, suffix='.png') as f:
f.write(image_data)
temp_image_path = f.name
try:
result = wrapper.send_lxmf_message_with_method(
dest_hash=b'0123456789abcdef',
content="Test message with image",
source_identity_private_key=b'privkey' * 10,
delivery_method="direct",
image_data_path=temp_image_path,
image_format="png"
)
self.assertTrue(result['success'])
# Verify image data was passed to message creation
call_args = mock_lxmf.LXMessage.call_args
# The fields should include the image data
fields = call_args[1].get('fields')
if fields:
# Check that image data was included in fields
field_found = False
for field in fields:
if isinstance(field, tuple) and len(field) >= 2:
if field[1] == image_data:
field_found = True
break
self.assertTrue(field_found or mock_lxmf.LXMessage.called)
# Verify file was deleted (cleanup)
self.assertFalse(os.path.exists(temp_image_path))
finally:
# Clean up in case test fails
if os.path.exists(temp_image_path):
os.remove(temp_image_path)
@patch('reticulum_wrapper.RNS')
@patch('reticulum_wrapper.LXMF')
def test_send_with_missing_image_file_returns_error(self, mock_lxmf, mock_rns):
"""Test error handling when image_data_path file doesn't exist"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
wrapper.router = MagicMock()
mock_local_dest = MagicMock()
mock_local_dest.hash = b'localdest1234567'
wrapper.local_lxmf_destination = mock_local_dest
# Mock identity
mock_identity = MagicMock()
mock_rns.Identity.recall.return_value = mock_identity
result = wrapper.send_lxmf_message_with_method(
dest_hash=b'0123456789abcdef',
content="Test message",
source_identity_private_key=b'privkey' * 10,
delivery_method="direct",
image_data_path="/nonexistent/path/to/image.png",
image_format="png"
)
# Should return error for missing file
self.assertFalse(result['success'])
self.assertIn('delivery_method', result)
self.assertIsNone(result['delivery_method'])
@patch('reticulum_wrapper.RNS')
@patch('reticulum_wrapper.LXMF')
def test_error_response_includes_delivery_method_field(self, mock_lxmf, mock_rns):
"""Verify all error responses include delivery_method=None"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = False # Not initialized - will cause error
wrapper.router = None
result = wrapper.send_lxmf_message_with_method(
dest_hash=b'0123456789abcdef',
content="Test message",
source_identity_private_key=b'privkey' * 10,
delivery_method="direct"
)
self.assertFalse(result['success'])
self.assertIn('delivery_method', result)
self.assertIsNone(result['delivery_method'])
class TestSendPacket(unittest.TestCase):
"""Test send_packet method - raw packet sending"""
def setUp(self):
"""Set up test fixtures"""
import tempfile
self.temp_dir = tempfile.mkdtemp()
def tearDown(self):
"""Clean up test fixtures"""
import shutil
if os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir)
def test_send_packet_when_unavailable(self):
"""Test send_packet returns mock data when Reticulum unavailable"""
original = reticulum_wrapper.RETICULUM_AVAILABLE
reticulum_wrapper.RETICULUM_AVAILABLE = False
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
result = wrapper.send_packet(
dest_hash=b'0123456789abcdef',
data=b'test data'
)
# Should return mock receipt
self.assertIn('receipt_hash', result)
self.assertIn('delivered', result)
self.assertIn('timestamp', result)
self.assertTrue(result['delivered'])
reticulum_wrapper.RETICULUM_AVAILABLE = original
def test_send_packet_not_implemented(self):
"""Test that send_packet is not fully implemented yet"""
original = reticulum_wrapper.RETICULUM_AVAILABLE
reticulum_wrapper.RETICULUM_AVAILABLE = True
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
result = wrapper.send_packet(
dest_hash=b'0123456789abcdef',
data=b'test data'
)
# Should return empty receipt (not implemented)
self.assertIn('receipt_hash', result)
self.assertFalse(result['delivered'])
reticulum_wrapper.RETICULUM_AVAILABLE = original
class TestMessageCallbacks(unittest.TestCase):
"""Test register_message_callback and _on_message"""
def setUp(self):
"""Set up test fixtures"""
import tempfile
self.temp_dir = tempfile.mkdtemp()
def tearDown(self):
"""Clean up test fixtures"""
import shutil
if os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir)
def test_register_callback(self):
"""Test registering a message callback"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
callback = Mock()
wrapper.register_message_callback(callback)
# Callback should be in list
self.assertIn(callback, wrapper.message_callbacks)
def test_on_message_invokes_callbacks(self):
"""Test that _on_message invokes all registered callbacks"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
# Register multiple callbacks
callback1 = Mock()
callback2 = Mock()
wrapper.register_message_callback(callback1)
wrapper.register_message_callback(callback2)
# Create mock message
mock_message = Mock()
mock_message.content = b'Test content'
mock_message.source_hash = b'source123'
mock_message.destination_hash = b'dest456'
mock_message.timestamp = 1234567890
# Invoke internal handler
wrapper._on_message(mock_message)
# Both callbacks should be called with message dict
callback1.assert_called_once()
callback2.assert_called_once()
# Verify structure of passed data
call_arg = callback1.call_args[0][0]
self.assertEqual(call_arg['content'], b'Test content')
self.assertEqual(call_arg['source'], b'source123')
self.assertEqual(call_arg['destination'], b'dest456')
self.assertEqual(call_arg['timestamp'], 1234567890)
def test_on_message_handles_callback_errors(self):
"""Test that callback errors don't crash _on_message"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
# Register callback that raises exception
bad_callback = Mock(side_effect=Exception("Callback error"))
good_callback = Mock()
wrapper.register_message_callback(bad_callback)
wrapper.register_message_callback(good_callback)
mock_message = Mock()
mock_message.content = b'Test'
mock_message.source_hash = b'src'
mock_message.destination_hash = b'dst'
mock_message.timestamp = 123
# Should not raise exception
wrapper._on_message(mock_message)
# Good callback should still be called
good_callback.assert_called_once()
class TestDeliveryCallbacks(unittest.TestCase):
"""Test delivery status callbacks: _on_message_delivered, _on_message_failed, _on_message_sent"""
def setUp(self):
"""Set up test fixtures"""
import tempfile
self.temp_dir = tempfile.mkdtemp()
def tearDown(self):
"""Clean up test fixtures"""
import shutil
if os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir)
@patch('reticulum_wrapper.LXMF', mock_lxmf)
def test_on_message_delivered_direct(self):
"""Test _on_message_delivered callback for DIRECT delivery (state=DELIVERED)"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
# Mock Kotlin callback
mock_kotlin_callback = Mock()
wrapper.kotlin_delivery_status_callback = mock_kotlin_callback
# Create mock message with DELIVERED state (direct delivery confirmed by recipient)
mock_message = Mock()
mock_message.hash = b'messagehash12345'
mock_message.state = mock_lxmf.LXMessage.DELIVERED # Direct delivery confirmed
# Call delivery callback
wrapper._on_message_delivered(mock_message)
# Kotlin callback should be invoked with JSON
mock_kotlin_callback.assert_called_once()
call_arg = mock_kotlin_callback.call_args[0][0]
# Parse JSON
status_event = json.loads(call_arg)
self.assertEqual(status_event['status'], 'delivered')
self.assertEqual(status_event['message_hash'], mock_message.hash.hex())
self.assertIn('timestamp', status_event)
@patch('reticulum_wrapper.LXMF', mock_lxmf)
def test_on_message_delivered_propagated(self):
"""Test _on_message_delivered callback for PROPAGATED delivery (state=SENT)
When a message is sent via propagation, LXMF sets state=SENT (not DELIVERED)
because the relay accepted the message, but the end recipient hasn't confirmed.
"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
# Mock Kotlin callback
mock_kotlin_callback = Mock()
wrapper.kotlin_delivery_status_callback = mock_kotlin_callback
# Create mock message with SENT state (propagated, relay accepted)
mock_message = Mock()
mock_message.hash = b'messagehash12345'
mock_message.state = mock_lxmf.LXMessage.SENT # Propagated (relay accepted)
# Call delivery callback
wrapper._on_message_delivered(mock_message)
# Kotlin callback should be invoked with 'propagated' status
mock_kotlin_callback.assert_called_once()
call_arg = mock_kotlin_callback.call_args[0][0]
# Parse JSON - should be 'propagated', not 'delivered'
status_event = json.loads(call_arg)
self.assertEqual(status_event['status'], 'propagated')
self.assertEqual(status_event['message_hash'], mock_message.hash.hex())
self.assertIn('timestamp', status_event)
@patch('reticulum_wrapper.LXMF', mock_lxmf)
def test_on_message_delivered_removes_from_opportunistic_tracking(self):
"""Test that delivered messages are removed from opportunistic tracking"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.kotlin_delivery_status_callback = Mock()
mock_message = Mock()
mock_message.hash = b'messagehash12345'
mock_message.state = mock_lxmf.LXMessage.DELIVERED # Set state for proper status
# Add to opportunistic tracking
wrapper._opportunistic_messages = {
mock_message.hash.hex(): {'message': mock_message, 'sent_time': time.time()}
}
# Call delivery callback
wrapper._on_message_delivered(mock_message)
# Should be removed from tracking
self.assertNotIn(mock_message.hash.hex(), wrapper._opportunistic_messages)
@patch('reticulum_wrapper.LXMF')
def test_on_message_failed_without_retry(self, mock_lxmf):
"""Test _on_message_failed when not retrying via propagation"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
mock_kotlin_callback = Mock()
wrapper.kotlin_delivery_status_callback = mock_kotlin_callback
mock_message = Mock()
mock_message.hash = b'messagehash12345'
mock_message.try_propagation_on_fail = False
# New attributes for relay fallback tracking
mock_message.propagation_retry_attempted = False
mock_message.tried_relays = []
wrapper._on_message_failed(mock_message)
# Kotlin callback should be invoked with 'failed' status
mock_kotlin_callback.assert_called_once()
call_arg = mock_kotlin_callback.call_args[0][0]
status_event = json.loads(call_arg)
self.assertEqual(status_event['status'], 'failed')
self.assertEqual(status_event['message_hash'], mock_message.hash.hex())
self.assertEqual(status_event['reason'], 'delivery_failed')
@patch('reticulum_wrapper.LXMF')
def test_on_message_failed_retries_via_propagation(self, mock_lxmf):
"""Test _on_message_failed retries via propagation when flag set"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.router = Mock()
wrapper.active_propagation_node = b'propnode12345678'
mock_kotlin_callback = Mock()
wrapper.kotlin_delivery_status_callback = mock_kotlin_callback
mock_message = Mock()
mock_message.hash = b'messagehash12345'
mock_message.try_propagation_on_fail = True
# New attributes for relay fallback tracking (first attempt)
mock_message.propagation_retry_attempted = False
mock_message.tried_relays = []
# fields must be a dict (not Mock) for "5 in fields" check to work
mock_message.fields = {}
wrapper._on_message_failed(mock_message)
# Should switch to PROPAGATED method
self.assertEqual(mock_message.desired_method, mock_lxmf.LXMessage.PROPAGATED)
# Should re-submit to router
wrapper.router.handle_outbound.assert_called_once_with(mock_message)
# Kotlin callback should be invoked with 'retrying_propagated' status
call_arg = mock_kotlin_callback.call_args[0][0]
status_event = json.loads(call_arg)
self.assertEqual(status_event['status'], 'retrying_propagated')
# Flag should be cleared to prevent infinite retry
self.assertFalse(mock_message.try_propagation_on_fail)
# Should track propagation attempt
self.assertTrue(mock_message.propagation_retry_attempted)
self.assertIn(b'propnode12345678', mock_message.tried_relays)
def test_on_message_failed_no_retry_without_propagation_node(self):
"""Test _on_message_failed doesn't retry when no propagation node"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.router = Mock()
wrapper.active_propagation_node = None # No propagation node
mock_kotlin_callback = Mock()
wrapper.kotlin_delivery_status_callback = mock_kotlin_callback
mock_message = Mock()
mock_message.hash = b'messagehash12345'
mock_message.try_propagation_on_fail = True
# New attributes for relay fallback tracking
mock_message.propagation_retry_attempted = False
mock_message.tried_relays = []
wrapper._on_message_failed(mock_message)
# Should not retry
wrapper.router.handle_outbound.assert_not_called()
# Should report as failed
call_arg = mock_kotlin_callback.call_args[0][0]
status_event = json.loads(call_arg)
self.assertEqual(status_event['status'], 'failed')
def test_on_message_sent(self):
"""Test _on_message_sent callback"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
mock_kotlin_callback = Mock()
wrapper.kotlin_delivery_status_callback = mock_kotlin_callback
mock_message = Mock()
mock_message.hash = b'messagehash12345'
wrapper._on_message_sent(mock_message)
# Kotlin callback should be invoked with 'sent' status
mock_kotlin_callback.assert_called_once()
call_arg = mock_kotlin_callback.call_args[0][0]
status_event = json.loads(call_arg)
self.assertEqual(status_event['status'], 'sent')
self.assertEqual(status_event['message_hash'], mock_message.hash.hex())
@patch('reticulum_wrapper.LXMF', mock_lxmf)
def test_delivery_callbacks_handle_missing_kotlin_callback(self):
"""Test delivery callbacks work when Kotlin callback not set"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.kotlin_delivery_status_callback = None
mock_message = Mock()
mock_message.hash = b'messagehash12345'
mock_message.state = mock_lxmf.LXMessage.DELIVERED # Set state for proper status
# Should not raise exception
wrapper._on_message_delivered(mock_message)
wrapper._on_message_sent(mock_message)
class TestOnLXMFDelivery(unittest.TestCase):
"""Test _on_lxmf_delivery - incoming message handler"""
def setUp(self):
"""Set up test fixtures"""
import tempfile
self.temp_dir = tempfile.mkdtemp()
# Mock the global LXMF module which is None in test environment
self.mock_lxmf = Mock()
self.mock_lxmf.FIELD_FILE_ATTACHMENTS = 5
self.lxmf_patcher = patch.object(reticulum_wrapper, 'LXMF', self.mock_lxmf)
self.lxmf_patcher.start()
def tearDown(self):
"""Clean up test fixtures"""
self.lxmf_patcher.stop()
import shutil
if os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir)
def test_on_lxmf_delivery_adds_to_pending_inbound(self):
"""Test that received messages are added to pending_inbound queue"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
# Mock router with pending_inbound
mock_router = Mock()
mock_router.pending_inbound = []
wrapper.router = mock_router
# Create mock LXMF message
mock_message = Mock()
mock_message.source_hash = b'source123'
mock_message.destination_hash = b'dest456'
mock_message.content = b'Test message content'
mock_message.timestamp = 1234567890
mock_message.fields = {} # Required for reaction detection
wrapper._on_lxmf_delivery(mock_message)
# Message should be added to pending_inbound
self.assertEqual(len(mock_router.pending_inbound), 1)
self.assertEqual(mock_router.pending_inbound[0], mock_message)
def test_on_lxmf_delivery_creates_pending_inbound_if_missing(self):
"""Test that pending_inbound is created if router doesn't have it"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
# Mock router WITHOUT pending_inbound
mock_router = Mock(spec=[])
wrapper.router = mock_router
mock_message = Mock()
mock_message.source_hash = b'source123'
mock_message.destination_hash = b'dest456'
mock_message.content = b'Test message'
mock_message.fields = {} # Required for reaction detection
wrapper._on_lxmf_delivery(mock_message)
# Should create pending_inbound
self.assertTrue(hasattr(mock_router, 'pending_inbound'))
self.assertIn(mock_message, mock_router.pending_inbound)
def test_on_lxmf_delivery_prevents_duplicates(self):
"""Test that duplicate messages are not added twice"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
mock_router = Mock()
mock_router.pending_inbound = []
wrapper.router = mock_router
mock_message = Mock()
mock_message.source_hash = b'source123'
mock_message.destination_hash = b'dest456'
mock_message.content = b'Test message'
mock_message.fields = {} # Required for reaction detection
# Add same message twice
wrapper._on_lxmf_delivery(mock_message)
wrapper._on_lxmf_delivery(mock_message)
# Should only be added once
self.assertEqual(len(mock_router.pending_inbound), 1)
def test_on_lxmf_delivery_invokes_kotlin_callback(self):
"""Test that Kotlin message received callback is invoked"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
mock_router = Mock()
mock_router.pending_inbound = []
wrapper.router = mock_router
mock_kotlin_callback = Mock()
wrapper.kotlin_message_received_callback = mock_kotlin_callback
# Use MagicMock with proper byte values
mock_message = MagicMock()
mock_message.source_hash = b'source123source1' # 16 bytes
mock_message.destination_hash = b'dest456dest45678' # 16 bytes
mock_message.content = b'Test message'
mock_message.timestamp = 1234567890
mock_message.fields = None
mock_message.hash = b'msghash123456789'
wrapper._on_lxmf_delivery(mock_message)
# Kotlin callback should be invoked with JSON
mock_kotlin_callback.assert_called_once()
call_arg = mock_kotlin_callback.call_args[0][0]
# Parse JSON
event = json.loads(call_arg)
self.assertEqual(event['source_hash'], mock_message.source_hash.hex())
self.assertEqual(event['destination_hash'], mock_message.destination_hash.hex())
class TestPollReceivedMessages(unittest.TestCase):
"""Test poll_received_messages - message polling"""
def setUp(self):
"""Set up test fixtures"""
import tempfile
self.temp_dir = tempfile.mkdtemp()
self.original_available = reticulum_wrapper.RETICULUM_AVAILABLE
reticulum_wrapper.RETICULUM_AVAILABLE = True
def tearDown(self):
"""Clean up test fixtures"""
import shutil
if os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir)
reticulum_wrapper.RETICULUM_AVAILABLE = self.original_available
def test_poll_returns_empty_when_not_initialized(self):
"""Test polling returns empty list when not initialized"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = False
messages = wrapper.poll_received_messages()
self.assertEqual(messages, [])
def test_poll_returns_empty_when_no_router(self):
"""Test polling returns empty list when router is None"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
wrapper.router = None
messages = wrapper.poll_received_messages()
self.assertEqual(messages, [])
def test_poll_returns_empty_when_reticulum_unavailable(self):
"""Test polling returns empty when Reticulum unavailable"""
reticulum_wrapper.RETICULUM_AVAILABLE = False
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
messages = wrapper.poll_received_messages()
self.assertEqual(messages, [])
def test_poll_retrieves_pending_messages(self):
"""Test polling retrieves messages from pending_inbound"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
# Mock router with pending messages
mock_router = Mock()
mock_message = MagicMock()
mock_message.source_hash = b'source123source1' # 16 bytes
mock_message.destination_hash = b'dest456dest45678' # 16 bytes
mock_message.content = b'Test content'
mock_message.timestamp = 1234567890
mock_message.fields = None
mock_message.hash = b'msghash123456789'
mock_router.pending_inbound = [mock_message]
wrapper.router = mock_router
messages = wrapper.poll_received_messages()
# Should retrieve message
self.assertEqual(len(messages), 1)
# Verify message structure
msg = messages[0]
# The returned message has bytes for hashes (not hex strings)
self.assertEqual(msg['source_hash'], mock_message.source_hash)
self.assertEqual(msg['destination_hash'], mock_message.destination_hash)
# Content should be decoded string
self.assertIn('content', msg)
self.assertEqual(msg['content'], mock_message.content.decode('utf-8'))
def test_poll_prevents_duplicate_processing(self):
"""Test that messages are not returned twice"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
mock_router = Mock()
mock_message = Mock()
mock_message.source_hash = b'source123'
mock_message.destination_hash = b'dest456'
mock_message.content = b'Test'
mock_message.timestamp = 1234567890
mock_message.fields = None
mock_message.hash = b'msghash123456789'
mock_router.pending_inbound = [mock_message]
wrapper.router = mock_router
# Poll first time
messages1 = wrapper.poll_received_messages()
self.assertEqual(len(messages1), 1)
# Poll second time - same message still in queue
messages2 = wrapper.poll_received_messages()
# Should not return same message twice (tracked by seen_message_hashes)
self.assertEqual(len(messages2), 0)
def test_poll_handles_missing_pending_inbound(self):
"""Test polling handles router without pending_inbound attribute"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
# Mock router without pending_inbound
mock_router = Mock(spec=[])
wrapper.router = mock_router
# Should not crash
messages = wrapper.poll_received_messages()
self.assertEqual(messages, [])
@patch('reticulum_wrapper.RNS')
def test_poll_extracts_public_key_when_identity_found(self, mock_rns):
"""Test that poll_received_messages extracts public key from RNS.Identity.recall()"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
# Mock router with pending message
mock_router = Mock()
mock_message = MagicMock()
mock_message.source_hash = b'source123source1' # 16 bytes
mock_message.destination_hash = b'dest456dest45678' # 16 bytes
mock_message.content = b'Test content'
mock_message.timestamp = 1234567890
mock_message.fields = None
mock_message.hash = b'msghash123456789'
mock_router.pending_inbound = [mock_message]
wrapper.router = mock_router
# Mock identity with public key
test_public_key = b'test_public_key_32bytes_exactly!'
mock_identity = Mock()
mock_identity.get_public_key.return_value = test_public_key
mock_rns.Identity.recall.return_value = mock_identity
messages = wrapper.poll_received_messages()
# Should retrieve message
self.assertEqual(len(messages), 1)
# Verify public key is included
msg = messages[0]
self.assertIn('public_key', msg)
self.assertEqual(msg['public_key'], test_public_key)
# Verify Identity.recall was called with source_hash
mock_rns.Identity.recall.assert_called_once_with(mock_message.source_hash)
@patch('reticulum_wrapper.RNS')
def test_poll_handles_identity_not_found(self, mock_rns):
"""Test that poll_received_messages handles RNS.Identity.recall() returning None"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
# Mock router with pending message
mock_router = Mock()
mock_message = MagicMock()
mock_message.source_hash = b'source123source1' # 16 bytes
mock_message.destination_hash = b'dest456dest45678' # 16 bytes
mock_message.content = b'Test content'
mock_message.timestamp = 1234567890
mock_message.fields = None
mock_message.hash = b'msghash567891234'
mock_router.pending_inbound = [mock_message]
wrapper.router = mock_router
# Mock identity recall returning None
mock_rns.Identity.recall.return_value = None
messages = wrapper.poll_received_messages()
# Should retrieve message
self.assertEqual(len(messages), 1)
# Verify public_key is NOT in message (identity not found)
msg = messages[0]
self.assertNotIn('public_key', msg)
@patch('reticulum_wrapper.RNS')
def test_poll_handles_identity_recall_exception(self, mock_rns):
"""Test that poll_received_messages handles exceptions from RNS.Identity.recall()"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
# Mock router with pending message
mock_router = Mock()
mock_message = MagicMock()
mock_message.source_hash = b'source123source1' # 16 bytes
mock_message.destination_hash = b'dest456dest45678' # 16 bytes
mock_message.content = b'Test content'
mock_message.timestamp = 1234567890
mock_message.fields = None
mock_message.hash = b'msghash891234567'
mock_router.pending_inbound = [mock_message]
wrapper.router = mock_router
# Mock identity recall raising exception
mock_rns.Identity.recall.side_effect = Exception("Network error")
# Should not crash
messages = wrapper.poll_received_messages()
# Should still retrieve message
self.assertEqual(len(messages), 1)
# Verify public_key is NOT in message (exception occurred)
msg = messages[0]
self.assertNotIn('public_key', msg)
class TestErrorHandling(unittest.TestCase):
"""Test error handling across messaging methods"""
def setUp(self):
"""Set up test fixtures"""
import tempfile
self.temp_dir = tempfile.mkdtemp()
def tearDown(self):
"""Clean up test fixtures"""
import shutil
if os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir)
@patch('reticulum_wrapper.RNS')
@patch('reticulum_wrapper.LXMF')
def test_send_message_handles_router_exception(self, mock_lxmf, mock_rns):
"""Test that exceptions during send are caught and returned"""
original = reticulum_wrapper.RETICULUM_AVAILABLE
reticulum_wrapper.RETICULUM_AVAILABLE = True
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
# Mock router that raises exception
mock_router = Mock()
mock_router.handle_outbound.side_effect = Exception("Network error")
wrapper.router = mock_router
mock_local_dest = MagicMock()
wrapper.local_lxmf_destination = mock_local_dest
wrapper.display_name = "Test"
mock_identity = MagicMock()
mock_rns.Identity.recall.return_value = mock_identity
mock_dest = MagicMock()
mock_rns.Destination.return_value = mock_dest
mock_message = MagicMock()
mock_lxmf.LXMessage.return_value = mock_message
result = wrapper.send_lxmf_message(
dest_hash=b'0123456789abcdef',
content="Test",
source_identity_private_key=b'privkey' * 10
)
# Should return error dict
self.assertFalse(result['success'])
self.assertIn('error', result)
reticulum_wrapper.RETICULUM_AVAILABLE = original
@patch('reticulum_wrapper.LXMF', mock_lxmf)
def test_delivery_callback_handles_kotlin_callback_error(self):
"""Test that errors in Kotlin callback don't crash delivery handler"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
# Mock callback that raises exception
mock_kotlin_callback = Mock(side_effect=Exception("Kotlin error"))
wrapper.kotlin_delivery_status_callback = mock_kotlin_callback
mock_message = Mock()
mock_message.hash = b'messagehash12345'
mock_message.state = mock_lxmf.LXMessage.DELIVERED # Set state for proper status
# Should not raise exception
wrapper._on_message_delivered(mock_message)
wrapper._on_message_sent(mock_message)
class TestPathRequestRetryLogic(unittest.TestCase):
"""Test path request retry logic when identity not immediately found"""
def setUp(self):
"""Set up test fixtures"""
import tempfile
self.temp_dir = tempfile.mkdtemp()
self.original_available = reticulum_wrapper.RETICULUM_AVAILABLE
reticulum_wrapper.RETICULUM_AVAILABLE = True
def tearDown(self):
"""Clean up test fixtures"""
import shutil
if os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir)
reticulum_wrapper.RETICULUM_AVAILABLE = self.original_available
@patch('reticulum_wrapper.time.sleep')
@patch('reticulum_wrapper.RNS')
@patch('reticulum_wrapper.LXMF')
def test_requests_path_when_identity_not_found(self, mock_lxmf_module, mock_rns, mock_sleep):
"""Test that path is requested when identity recall initially fails"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
wrapper.router = MagicMock()
wrapper.local_lxmf_destination = MagicMock()
wrapper.display_name = "Test"
# Identity found on 3rd attempt (after path request)
mock_identity = MagicMock()
mock_identity.hash = b'0123456789abcdef'
# recall returns None twice, then identity on 3rd call
mock_rns.Identity.recall.side_effect = [None, None, None, mock_identity]
mock_dest = MagicMock()
mock_rns.Destination.return_value = mock_dest
mock_message = MagicMock()
mock_message.hash = b'msghash123456789'
mock_lxmf_module.LXMessage.return_value = mock_message
result = wrapper.send_lxmf_message_with_method(
dest_hash=b'0123456789abcdef',
content="Test message",
source_identity_private_key=b'privkey' * 10,
delivery_method="direct"
)
# Verify path was requested
mock_rns.Transport.request_path.assert_called()
# Verify sleep was called (retry loop)
self.assertTrue(mock_sleep.called)
@patch('reticulum_wrapper.time.sleep')
@patch('reticulum_wrapper.RNS')
@patch('reticulum_wrapper.LXMF')
def test_path_request_timeout_returns_error(self, mock_lxmf_module, mock_rns, mock_sleep):
"""Test error returned when path request times out after all retries"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
wrapper.router = MagicMock()
wrapper.local_lxmf_destination = MagicMock()
wrapper.display_name = "Test"
# Identity never found
mock_rns.Identity.recall.return_value = None
result = wrapper.send_lxmf_message_with_method(
dest_hash=b'0123456789abcdef',
content="Test message",
source_identity_private_key=b'privkey' * 10,
delivery_method="direct"
)
# Should fail with "not known" error
self.assertFalse(result['success'])
self.assertIn('not known', result['error'].lower())
# Verify retry attempts ran (at least 10 sleep calls for the retry loop)
# Note: Use >= because background threads may also call time.sleep
self.assertGreaterEqual(mock_sleep.call_count, 10)
@patch('reticulum_wrapper.time.sleep')
@patch('reticulum_wrapper.RNS')
@patch('reticulum_wrapper.LXMF')
def test_path_request_exception_handled(self, mock_lxmf_module, mock_rns, mock_sleep):
"""Test that request_path exception doesn't crash, continues to retry"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
wrapper.router = MagicMock()
wrapper.local_lxmf_destination = MagicMock()
wrapper.display_name = "Test"
# request_path raises exception
mock_rns.Transport.request_path.side_effect = Exception("Network error")
# Identity never found
mock_rns.Identity.recall.return_value = None
result = wrapper.send_lxmf_message_with_method(
dest_hash=b'0123456789abcdef',
content="Test message",
source_identity_private_key=b'privkey' * 10,
delivery_method="direct"
)
# Should still return error (not crash)
self.assertFalse(result['success'])
self.assertIn('error', result)
class TestFileAttachments(unittest.TestCase):
"""Test file attachment handling in send_lxmf_message and send_lxmf_message_with_method"""
def setUp(self):
"""Set up test fixtures"""
import tempfile
self.temp_dir = tempfile.mkdtemp()
self.original_available = reticulum_wrapper.RETICULUM_AVAILABLE
reticulum_wrapper.RETICULUM_AVAILABLE = True
def tearDown(self):
"""Clean up test fixtures"""
import shutil
if os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir)
reticulum_wrapper.RETICULUM_AVAILABLE = self.original_available
@patch('reticulum_wrapper.RNS')
@patch('reticulum_wrapper.LXMF')
def test_send_with_file_attachments_list_format(self, mock_lxmf, mock_rns):
"""Test sending with file attachments in [filename, bytes] list format"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
wrapper.router = MagicMock()
mock_local_dest = MagicMock()
mock_local_dest.hash = b'localdest1234567'
wrapper.local_lxmf_destination = mock_local_dest
mock_identity = MagicMock()
mock_rns.Identity.recall.return_value = mock_identity
mock_dest = MagicMock()
mock_dest.hash = b'lxmfdest12345678'
mock_rns.Destination.return_value = mock_dest
mock_message = MagicMock()
mock_message.hash = b'msghash123456789'
mock_lxmf.LXMessage.return_value = mock_message
file_attachments = [
['test.txt', b'Hello, World!'],
['data.bin', b'\x00\x01\x02\x03']
]
result = wrapper.send_lxmf_message_with_method(
dest_hash=b'0123456789abcdef',
content="Test with files",
source_identity_private_key=b'privkey' * 10,
delivery_method="direct",
file_attachments=file_attachments
)
self.assertTrue(result['success'])
# Verify LXMessage was created with fields containing file attachments
call_kwargs = mock_lxmf.LXMessage.call_args[1]
self.assertIn('fields', call_kwargs)
self.assertIn(5, call_kwargs['fields'])
self.assertEqual(len(call_kwargs['fields'][5]), 2)
@patch('reticulum_wrapper.RNS')
@patch('reticulum_wrapper.LXMF')
def test_send_with_file_attachments_tuple_format(self, mock_lxmf, mock_rns):
"""Test sending with file attachments in (filename, bytes) tuple format"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
wrapper.router = MagicMock()
mock_local_dest = MagicMock()
mock_local_dest.hash = b'localdest1234567'
wrapper.local_lxmf_destination = mock_local_dest
mock_identity = MagicMock()
mock_rns.Identity.recall.return_value = mock_identity
mock_dest = MagicMock()
mock_dest.hash = b'lxmfdest12345678'
mock_rns.Destination.return_value = mock_dest
mock_message = MagicMock()
mock_message.hash = b'msghash123456789'
mock_lxmf.LXMessage.return_value = mock_message
# Tuple format
file_attachments = [
('document.pdf', b'%PDF-1.4...'),
]
result = wrapper.send_lxmf_message_with_method(
dest_hash=b'0123456789abcdef',
content="Test with PDF",
source_identity_private_key=b'privkey' * 10,
delivery_method="direct",
file_attachments=file_attachments
)
self.assertTrue(result['success'])
call_kwargs = mock_lxmf.LXMessage.call_args[1]
self.assertIn(5, call_kwargs['fields'])
@patch('reticulum_wrapper.RNS')
@patch('reticulum_wrapper.LXMF')
def test_opportunistic_falls_back_for_file_attachments(self, mock_lxmf, mock_rns):
"""Test OPPORTUNISTIC falls back to DIRECT when file attachments present"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
wrapper.router = MagicMock()
mock_local_dest = MagicMock()
mock_local_dest.hash = b'localdest1234567'
wrapper.local_lxmf_destination = mock_local_dest
mock_identity = MagicMock()
mock_rns.Identity.recall.return_value = mock_identity
mock_dest = MagicMock()
mock_dest.hash = b'lxmfdest12345678'
mock_rns.Destination.return_value = mock_dest
mock_message = MagicMock()
mock_message.hash = b'msghash123456789'
mock_lxmf.LXMessage.return_value = mock_message
file_attachments = [['small.txt', b'tiny']]
result = wrapper.send_lxmf_message_with_method(
dest_hash=b'0123456789abcdef',
content="Short",
source_identity_private_key=b'privkey' * 10,
delivery_method="opportunistic",
file_attachments=file_attachments
)
self.assertTrue(result['success'])
# Should fall back to direct due to file attachment
call_kwargs = mock_lxmf.LXMessage.call_args[1]
self.assertEqual(call_kwargs['desired_method'], mock_lxmf.LXMessage.DIRECT)
@patch('reticulum_wrapper.RNS')
@patch('reticulum_wrapper.LXMF')
def test_send_with_both_image_and_file_attachments(self, mock_lxmf, mock_rns):
"""Test sending with both image (field 6) and file attachments (field 5)"""
# Set the FIELD_IMAGE constant so it's used as the key instead of a MagicMock
mock_lxmf.FIELD_IMAGE = 6
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
wrapper.router = MagicMock()
mock_local_dest = MagicMock()
mock_local_dest.hash = b'localdest1234567'
wrapper.local_lxmf_destination = mock_local_dest
mock_identity = MagicMock()
mock_rns.Identity.recall.return_value = mock_identity
mock_dest = MagicMock()
mock_dest.hash = b'lxmfdest12345678'
mock_rns.Destination.return_value = mock_dest
mock_message = MagicMock()
mock_message.hash = b'msghash123456789'
mock_lxmf.LXMessage.return_value = mock_message
file_attachments = [['doc.txt', b'Document content']]
result = wrapper.send_lxmf_message_with_method(
dest_hash=b'0123456789abcdef',
content="Test with both",
source_identity_private_key=b'privkey' * 10,
delivery_method="direct",
image_data=b'\xff\xd8\xff\xe0...',
image_format='jpg',
file_attachments=file_attachments
)
self.assertTrue(result['success'])
call_kwargs = mock_lxmf.LXMessage.call_args[1]
# Should have both field 5 (files) and field 6 (image)
self.assertIn(5, call_kwargs['fields'])
self.assertIn(6, call_kwargs['fields'])
@patch('reticulum_wrapper.RNS')
@patch('reticulum_wrapper.LXMF')
def test_send_with_empty_file_attachments_list(self, mock_lxmf, mock_rns):
"""Test that empty file_attachments list doesn't add field 5"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
wrapper.router = MagicMock()
mock_local_dest = MagicMock()
mock_local_dest.hash = b'localdest1234567'
wrapper.local_lxmf_destination = mock_local_dest
mock_identity = MagicMock()
mock_rns.Identity.recall.return_value = mock_identity
mock_dest = MagicMock()
mock_dest.hash = b'lxmfdest12345678'
mock_rns.Destination.return_value = mock_dest
mock_message = MagicMock()
mock_message.hash = b'msghash123456789'
mock_lxmf.LXMessage.return_value = mock_message
result = wrapper.send_lxmf_message_with_method(
dest_hash=b'0123456789abcdef',
content="No attachments",
source_identity_private_key=b'privkey' * 10,
delivery_method="direct",
file_attachments=[]
)
self.assertTrue(result['success'])
call_kwargs = mock_lxmf.LXMessage.call_args[1]
# Fields should be None or not contain field 5
if call_kwargs.get('fields'):
self.assertNotIn(5, call_kwargs['fields'])
@patch('reticulum_wrapper.RNS')
@patch('reticulum_wrapper.LXMF')
def test_send_lxmf_message_with_file_attachments(self, mock_lxmf, mock_rns):
"""Test send_lxmf_message (not with_method) with file attachments"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
wrapper.router = MagicMock()
mock_local_dest = MagicMock()
mock_local_dest.hash = b'localdest1234567'
wrapper.local_lxmf_destination = mock_local_dest
mock_identity = MagicMock()
mock_rns.Identity.recall.return_value = mock_identity
mock_dest = MagicMock()
mock_dest.hash = b'lxmfdest12345678'
mock_rns.Destination.return_value = mock_dest
mock_message = MagicMock()
mock_message.hash = b'msghash123456789'
mock_lxmf.LXMessage.return_value = mock_message
file_attachments = [
['report.pdf', b'PDF content here'],
['data.csv', b'a,b,c\n1,2,3']
]
result = wrapper.send_lxmf_message(
dest_hash=b'0123456789abcdef',
content="Test message",
source_identity_private_key=b'privkey' * 10,
file_attachments=file_attachments
)
self.assertTrue(result['success'])
call_kwargs = mock_lxmf.LXMessage.call_args[1]
self.assertIn(5, call_kwargs['fields'])
self.assertEqual(len(call_kwargs['fields'][5]), 2)
@patch('reticulum_wrapper.RNS')
@patch('reticulum_wrapper.LXMF')
def test_file_attachment_dict_format(self, mock_lxmf, mock_rns):
"""Test file attachments in dict format with 'filename' and 'data' keys"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
wrapper.router = MagicMock()
mock_local_dest = MagicMock()
mock_local_dest.hash = b'localdest1234567'
wrapper.local_lxmf_destination = mock_local_dest
mock_identity = MagicMock()
mock_rns.Identity.recall.return_value = mock_identity
mock_dest = MagicMock()
mock_dest.hash = b'lxmfdest12345678'
mock_rns.Destination.return_value = mock_dest
mock_message = MagicMock()
mock_message.hash = b'msghash123456789'
mock_lxmf.LXMessage.return_value = mock_message
# Dict format (used by send_lxmf_message)
file_attachments = [
{'filename': 'config.json', 'data': b'{"key": "value"}'},
]
result = wrapper.send_lxmf_message(
dest_hash=b'0123456789abcdef',
content="Dict format test",
source_identity_private_key=b'privkey' * 10,
file_attachments=file_attachments
)
self.assertTrue(result['success'])
call_kwargs = mock_lxmf.LXMessage.call_args[1]
self.assertIn(5, call_kwargs['fields'])
@patch('reticulum_wrapper.RNS')
@patch('reticulum_wrapper.LXMF')
def test_file_attachment_skips_invalid_format(self, mock_lxmf, mock_rns):
"""Test that invalid attachment formats are skipped without crashing"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
wrapper.router = MagicMock()
mock_local_dest = MagicMock()
mock_local_dest.hash = b'localdest1234567'
wrapper.local_lxmf_destination = mock_local_dest
mock_identity = MagicMock()
mock_rns.Identity.recall.return_value = mock_identity
mock_dest = MagicMock()
mock_dest.hash = b'lxmfdest12345678'
mock_rns.Destination.return_value = mock_dest
mock_message = MagicMock()
mock_message.hash = b'msghash123456789'
mock_lxmf.LXMessage.return_value = mock_message
# Mix of valid and invalid formats
file_attachments = [
['valid.txt', b'Valid content'],
'invalid_string', # Invalid - should be skipped
123, # Invalid - should be skipped
['another_valid.bin', b'\x00\x01'],
]
result = wrapper.send_lxmf_message(
dest_hash=b'0123456789abcdef',
content="Mixed formats",
source_identity_private_key=b'privkey' * 10,
file_attachments=file_attachments
)
self.assertTrue(result['success'])
call_kwargs = mock_lxmf.LXMessage.call_args[1]
# Only 2 valid attachments should be in field 5
self.assertEqual(len(call_kwargs['fields'][5]), 2)
@patch('reticulum_wrapper.RNS')
@patch('reticulum_wrapper.LXMF')
def test_file_attachment_binary_data_conversion(self, mock_lxmf, mock_rns):
"""Test that non-bytes data is converted to bytes"""
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
wrapper.router = MagicMock()
mock_local_dest = MagicMock()
mock_local_dest.hash = b'localdest1234567'
wrapper.local_lxmf_destination = mock_local_dest
mock_identity = MagicMock()
mock_rns.Identity.recall.return_value = mock_identity
mock_dest = MagicMock()
mock_dest.hash = b'lxmfdest12345678'
mock_rns.Destination.return_value = mock_dest
mock_message = MagicMock()
mock_message.hash = b'msghash123456789'
mock_lxmf.LXMessage.return_value = mock_message
# Use bytearray instead of bytes
file_attachments = [
['array.bin', bytearray([0, 1, 2, 3, 4])],
]
result = wrapper.send_lxmf_message_with_method(
dest_hash=b'0123456789abcdef',
content="Bytearray test",
source_identity_private_key=b'privkey' * 10,
delivery_method="direct",
file_attachments=file_attachments
)
self.assertTrue(result['success'])
if __name__ == '__main__':
# Run tests with verbose output
unittest.main(verbosity=2)