columba/python/test_wrapper_propagation.py
torlando-tech b3345e66e0 test: update propagation node tests to match None-clearing behavior
The production code was fixed to not pass None to LXMF's
set_outbound_propagation_node() since LXMF doesn't support it.
Updated the tests to verify that the router method is NOT called
when clearing with None, only internal state is cleared.

Fixes two failing tests:
- test_set_propagation_node_clears_when_none
- test_set_outbound_propagation_node_clears_with_none

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-16 19:00:06 -05:00

1234 lines
44 KiB
Python

"""
Test suite for ReticulumWrapper propagation node functionality.
Tests the four key propagation node methods:
1. set_outbound_propagation_node - Set/clear propagation node
2. get_outbound_propagation_node - Get current node configuration
3. request_messages_from_propagation_node - Request messages from node
4. get_propagation_state - Get sync state and progress
"""
import sys
import os
import unittest
from unittest.mock import Mock, MagicMock, patch, PropertyMock
# Add parent directory to path to import reticulum_wrapper
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# Mock RNS and LXMF before importing reticulum_wrapper
sys.modules['RNS'] = MagicMock()
sys.modules['RNS.vendor'] = MagicMock()
sys.modules['RNS.vendor.platformutils'] = MagicMock()
# Create LXMF mock with proper LXMRouter constants
# These values must match the actual LXMF library constants
lxmf_mock = MagicMock()
lxmf_mock.LXMRouter.PR_IDLE = 0
lxmf_mock.LXMRouter.PR_PATH_REQUESTED = 1
lxmf_mock.LXMRouter.PR_LINK_ESTABLISHING = 2
lxmf_mock.LXMRouter.PR_LINK_ESTABLISHED = 3
lxmf_mock.LXMRouter.PR_REQUEST_SENT = 4
lxmf_mock.LXMRouter.PR_RECEIVING = 5
lxmf_mock.LXMRouter.PR_RESPONSE_RECEIVED = 6
lxmf_mock.LXMRouter.PR_COMPLETE = 7
lxmf_mock.LXMRouter.PR_NO_PATH = 8
lxmf_mock.LXMRouter.PR_LINK_FAILED = 9
lxmf_mock.LXMRouter.PR_TRANSFER_FAILED = 10
lxmf_mock.LXMRouter.PR_NO_IDENTITY_RCVD = 11
lxmf_mock.LXMRouter.PR_NO_ACCESS = 12
sys.modules['LXMF'] = lxmf_mock
# Now import after mocking
import reticulum_wrapper
class PropagationTestBase(unittest.TestCase):
"""Base class that sets up LXMF mock for propagation tests."""
def setUp(self):
# Save original LXMF value
self._original_lxmf = reticulum_wrapper.LXMF
# Set the module-level LXMF variable in reticulum_wrapper
# (it's initialized to None and only set during initialize() which we skip in tests)
reticulum_wrapper.LXMF = lxmf_mock
def tearDown(self):
# Restore original LXMF value
reticulum_wrapper.LXMF = self._original_lxmf
class TestSetOutboundPropagationNode(PropagationTestBase):
"""Test set_outbound_propagation_node method"""
def setUp(self):
"""Set up test fixtures"""
super().setUp()
import tempfile
self.temp_dir = tempfile.mkdtemp()
self.wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
# Enable Reticulum
reticulum_wrapper.RETICULUM_AVAILABLE = True
# Mock router
self.mock_router = Mock()
self.wrapper.router = self.mock_router
self.wrapper.initialized = True
def tearDown(self):
"""Clean up test fixtures"""
import shutil
if os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir)
def test_set_propagation_node_success(self):
"""Test successfully setting a propagation node"""
test_hash = b'1234567890123456' # 16-byte hash
result = self.wrapper.set_outbound_propagation_node(test_hash)
# Verify success
self.assertTrue(result['success'])
# Verify router was called with correct hash
self.mock_router.set_outbound_propagation_node.assert_called_once_with(test_hash)
# Verify internal state was updated
self.assertEqual(self.wrapper.active_propagation_node, test_hash)
def test_set_propagation_node_clears_when_none(self):
"""Test clearing propagation node by passing None"""
# First set a node
self.wrapper.active_propagation_node = b'existing_node_hash'
result = self.wrapper.set_outbound_propagation_node(None)
# Verify success
self.assertTrue(result['success'])
# Verify router was NOT called (LXMF doesn't support None, we just clear internally)
self.mock_router.set_outbound_propagation_node.assert_not_called()
# Verify internal state was cleared
self.assertIsNone(self.wrapper.active_propagation_node)
def test_set_propagation_node_converts_jarray(self):
"""Test that jarray-like objects are converted to bytes"""
# Create a mock jarray-like object (iterable but not bytes)
test_jarray = [0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef,
0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef]
result = self.wrapper.set_outbound_propagation_node(test_jarray)
# Verify success
self.assertTrue(result['success'])
# Verify router was called with bytes
call_args = self.mock_router.set_outbound_propagation_node.call_args[0][0]
self.assertIsInstance(call_args, bytes)
# Verify bytes content matches
expected_bytes = bytes(test_jarray)
self.assertEqual(call_args, expected_bytes)
def test_set_propagation_node_not_initialized(self):
"""Test error when wrapper not initialized"""
self.wrapper.initialized = False
result = self.wrapper.set_outbound_propagation_node(b'test_hash_123456')
# Verify failure
self.assertFalse(result['success'])
self.assertIn('error', result)
self.assertIn('not initialized', result['error'].lower())
def test_set_propagation_node_no_router(self):
"""Test error when router is not available"""
self.wrapper.router = None
result = self.wrapper.set_outbound_propagation_node(b'test_hash_123456')
# Verify failure
self.assertFalse(result['success'])
self.assertIn('error', result)
self.assertIn('not initialized', result['error'].lower())
def test_set_propagation_node_router_exception(self):
"""Test handling of router exceptions"""
self.mock_router.set_outbound_propagation_node.side_effect = Exception("Router error")
result = self.wrapper.set_outbound_propagation_node(b'test_hash_123456')
# Verify failure
self.assertFalse(result['success'])
self.assertIn('error', result)
self.assertEqual(result['error'], "Router error")
class TestGetOutboundPropagationNode(PropagationTestBase):
"""Test get_outbound_propagation_node method"""
def setUp(self):
"""Set up test fixtures"""
super().setUp()
import tempfile
self.temp_dir = tempfile.mkdtemp()
self.wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
# Enable Reticulum
reticulum_wrapper.RETICULUM_AVAILABLE = True
# Mock router
self.mock_router = Mock()
self.wrapper.router = self.mock_router
self.wrapper.initialized = True
def tearDown(self):
"""Clean up test fixtures"""
import shutil
if os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir)
def test_get_propagation_node_returns_hex(self):
"""Test getting propagation node returns hex string"""
test_hash = b'1234567890123456'
self.mock_router.get_outbound_propagation_node.return_value = test_hash
result = self.wrapper.get_outbound_propagation_node()
# Verify success
self.assertTrue(result['success'])
# Verify hex string returned
self.assertIn('propagation_node', result)
self.assertEqual(result['propagation_node'], test_hash.hex())
def test_get_propagation_node_returns_none_when_not_set(self):
"""Test getting propagation node when none is set"""
self.mock_router.get_outbound_propagation_node.return_value = None
result = self.wrapper.get_outbound_propagation_node()
# Verify success
self.assertTrue(result['success'])
# Verify None returned
self.assertIn('propagation_node', result)
self.assertIsNone(result['propagation_node'])
def test_get_propagation_node_not_initialized(self):
"""Test error when wrapper not initialized"""
self.wrapper.initialized = False
result = self.wrapper.get_outbound_propagation_node()
# Verify failure
self.assertFalse(result['success'])
self.assertIn('error', result)
self.assertIn('not initialized', result['error'].lower())
def test_get_propagation_node_no_router(self):
"""Test error when router is not available"""
self.wrapper.router = None
result = self.wrapper.get_outbound_propagation_node()
# Verify failure
self.assertFalse(result['success'])
self.assertIn('error', result)
def test_get_propagation_node_router_exception(self):
"""Test handling of router exceptions"""
self.mock_router.get_outbound_propagation_node.side_effect = Exception("Router error")
result = self.wrapper.get_outbound_propagation_node()
# Verify failure
self.assertFalse(result['success'])
self.assertIn('error', result)
class TestRequestMessagesFromPropagationNode(PropagationTestBase):
"""Test request_messages_from_propagation_node method"""
def setUp(self):
"""Set up test fixtures"""
super().setUp()
import tempfile
self.temp_dir = tempfile.mkdtemp()
self.wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
# Mock router
self.mock_router = Mock()
self.mock_router.propagation_transfer_state = 0
self.wrapper.router = self.mock_router
self.wrapper.initialized = True
# Set active propagation node
self.wrapper.active_propagation_node = b'1234567890123456'
# Mock default identity
self.mock_identity = Mock()
self.mock_identity.hash = b'identity_hash123'
self.wrapper.default_identity = self.mock_identity
def tearDown(self):
"""Clean up test fixtures"""
import shutil
if os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir)
def test_request_messages_success_default_identity(self):
"""Test successfully requesting messages with default identity"""
result = self.wrapper.request_messages_from_propagation_node()
# Verify success
self.assertTrue(result['success'])
# Verify router was called with default identity
self.mock_router.request_messages_from_propagation_node.assert_called_once()
call_args = self.mock_router.request_messages_from_propagation_node.call_args
self.assertEqual(call_args[0][0], self.mock_identity)
# Verify state is returned
self.assertIn('state', result)
self.assertEqual(result['state'], 0)
@patch('reticulum_wrapper.RNS')
def test_request_messages_with_custom_identity(self, mock_rns):
"""Test requesting messages with custom identity"""
# Create mock identity private key
test_private_key = b'test_private_key_bytes_32_chars!'
# Mock RNS.Identity.from_bytes
mock_custom_identity = Mock()
mock_custom_identity.hash = b'custom_identity_'
mock_rns.Identity.from_bytes.return_value = mock_custom_identity
result = self.wrapper.request_messages_from_propagation_node(
identity_private_key=test_private_key
)
# Verify success
self.assertTrue(result['success'])
# Verify from_bytes was called with the private key
mock_rns.Identity.from_bytes.assert_called_once_with(test_private_key)
# Verify router was called with custom identity
call_args = self.mock_router.request_messages_from_propagation_node.call_args
self.assertEqual(call_args[0][0], mock_custom_identity)
def test_request_messages_with_custom_max_messages(self):
"""Test requesting messages with custom max_messages"""
result = self.wrapper.request_messages_from_propagation_node(max_messages=512)
# Verify success
self.assertTrue(result['success'])
# Verify router was called with custom max_messages
call_args = self.mock_router.request_messages_from_propagation_node.call_args
self.assertEqual(call_args[1]['max_messages'], 512)
def test_request_messages_no_propagation_node(self):
"""Test error when no propagation node is configured"""
self.wrapper.active_propagation_node = None
result = self.wrapper.request_messages_from_propagation_node()
# Verify failure
self.assertFalse(result['success'])
self.assertIn('error', result)
self.assertIn('No propagation node', result['error'])
# Verify error code
self.assertEqual(result['errorCode'], 'NO_PROPAGATION_NODE')
def test_request_messages_not_initialized(self):
"""Test error when wrapper not initialized"""
self.wrapper.initialized = False
result = self.wrapper.request_messages_from_propagation_node()
# Verify failure
self.assertFalse(result['success'])
self.assertIn('error', result)
self.assertIn('not initialized', result['error'].lower())
def test_request_messages_no_router(self):
"""Test error when router is not available"""
self.wrapper.router = None
result = self.wrapper.request_messages_from_propagation_node()
# Verify failure
self.assertFalse(result['success'])
self.assertIn('error', result)
@patch('reticulum_wrapper.RNS')
def test_request_messages_converts_jarray_identity(self, mock_rns):
"""Test that jarray-like private key is converted to bytes"""
# Create a mock jarray-like object (iterable but not bytes)
test_jarray = [0x01] * 32 # 32-byte private key
# Mock RNS.Identity.from_bytes
mock_identity = Mock()
mock_identity.hash = b'converted_ident'
mock_rns.Identity.from_bytes.return_value = mock_identity
result = self.wrapper.request_messages_from_propagation_node(
identity_private_key=test_jarray
)
# Verify success
self.assertTrue(result['success'])
# Verify from_bytes was called with bytes
call_args = mock_rns.Identity.from_bytes.call_args[0][0]
self.assertIsInstance(call_args, bytes)
self.assertEqual(call_args, bytes(test_jarray))
def test_request_messages_router_exception(self):
"""Test handling of router exceptions"""
self.mock_router.request_messages_from_propagation_node.side_effect = Exception("Request failed")
result = self.wrapper.request_messages_from_propagation_node()
# Verify failure
self.assertFalse(result['success'])
self.assertIn('error', result)
self.assertEqual(result['error'], "Request failed")
def test_request_messages_returns_transfer_state(self):
"""Test that current transfer state is returned"""
# Set different transfer states
test_states = [0, 1, 2, 3, 4, 5, 7]
for state in test_states:
self.mock_router.propagation_transfer_state = state
result = self.wrapper.request_messages_from_propagation_node()
self.assertTrue(result['success'])
self.assertEqual(result['state'], state)
class TestGetPropagationState(PropagationTestBase):
"""Test get_propagation_state method"""
def setUp(self):
"""Set up test fixtures"""
super().setUp()
import tempfile
self.temp_dir = tempfile.mkdtemp()
self.wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
# Enable Reticulum
reticulum_wrapper.RETICULUM_AVAILABLE = True
# Mock router
self.mock_router = Mock()
self.wrapper.router = self.mock_router
self.wrapper.initialized = True
def tearDown(self):
"""Clean up test fixtures"""
import shutil
if os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir)
def test_get_propagation_state_idle(self):
"""Test getting state when idle (state 0)"""
self.mock_router.propagation_transfer_state = 0
self.mock_router.propagation_transfer_progress = 0.0
result = self.wrapper.get_propagation_state()
# Verify success
self.assertTrue(result['success'])
# Verify state details
self.assertEqual(result['state'], 0)
self.assertEqual(result['state_name'], 'idle')
self.assertEqual(result['progress'], 0.0)
def test_get_propagation_state_path_requested(self):
"""Test state during path discovery (state 1)"""
self.mock_router.propagation_transfer_state = 1
self.mock_router.propagation_transfer_progress = 0.0
result = self.wrapper.get_propagation_state()
self.assertTrue(result['success'])
self.assertEqual(result['state'], 1)
self.assertEqual(result['state_name'], 'path_requested')
def test_get_propagation_state_link_establishing(self):
"""Test state during link establishment (state 2)"""
self.mock_router.propagation_transfer_state = 2
self.mock_router.propagation_transfer_progress = 0.0
result = self.wrapper.get_propagation_state()
self.assertTrue(result['success'])
self.assertEqual(result['state'], 2)
self.assertEqual(result['state_name'], 'link_establishing')
def test_get_propagation_state_link_established(self):
"""Test state when link is established (state 3)"""
self.mock_router.propagation_transfer_state = 3
self.mock_router.propagation_transfer_progress = 0.0
result = self.wrapper.get_propagation_state()
self.assertTrue(result['success'])
self.assertEqual(result['state'], 3)
self.assertEqual(result['state_name'], 'link_established')
def test_get_propagation_state_request_sent(self):
"""Test state when message list requested (state 4)"""
self.mock_router.propagation_transfer_state = 4
self.mock_router.propagation_transfer_progress = 0.0
result = self.wrapper.get_propagation_state()
self.assertTrue(result['success'])
self.assertEqual(result['state'], 4)
self.assertEqual(result['state_name'], 'request_sent')
def test_get_propagation_state_receiving(self):
"""Test state during message download (state 5)"""
self.mock_router.propagation_transfer_state = 5
self.mock_router.propagation_transfer_progress = 0.65
result = self.wrapper.get_propagation_state()
self.assertTrue(result['success'])
self.assertEqual(result['state'], 5)
self.assertEqual(result['state_name'], 'receiving')
self.assertEqual(result['progress'], 0.65)
def test_get_propagation_state_complete(self):
"""Test state when transfer complete (state 7)"""
self.mock_router.propagation_transfer_state = 7
self.mock_router.propagation_transfer_progress = 1.0
self.mock_router.propagation_transfer_last_result = 5 # 5 messages received
result = self.wrapper.get_propagation_state()
self.assertTrue(result['success'])
self.assertEqual(result['state'], 7)
self.assertEqual(result['state_name'], 'complete')
self.assertEqual(result['progress'], 1.0)
self.assertEqual(result['messages_received'], 5)
def test_get_propagation_state_unknown_state(self):
"""Test handling of unknown state values"""
self.mock_router.propagation_transfer_state = 99
self.mock_router.propagation_transfer_progress = 0.0
result = self.wrapper.get_propagation_state()
self.assertTrue(result['success'])
self.assertEqual(result['state'], 99)
self.assertEqual(result['state_name'], 'unknown_99')
def test_get_propagation_state_messages_received(self):
"""Test that messages_received is included in response"""
self.mock_router.propagation_transfer_state = 0
self.mock_router.propagation_transfer_progress = 0.0
self.mock_router.propagation_transfer_last_result = 10
result = self.wrapper.get_propagation_state()
self.assertTrue(result['success'])
self.assertIn('messages_received', result)
self.assertEqual(result['messages_received'], 10)
def test_get_propagation_state_no_last_result_attribute(self):
"""Test handling when propagation_transfer_last_result doesn't exist"""
self.mock_router.propagation_transfer_state = 0
self.mock_router.propagation_transfer_progress = 0.0
# Use a special mock that raises AttributeError for propagation_transfer_last_result
del self.mock_router.propagation_transfer_last_result
result = self.wrapper.get_propagation_state()
self.assertTrue(result['success'])
self.assertIn('messages_received', result)
self.assertEqual(result['messages_received'], 0) # Should default to 0
def test_get_propagation_state_not_initialized(self):
"""Test error when wrapper not initialized"""
self.wrapper.initialized = False
result = self.wrapper.get_propagation_state()
self.assertFalse(result['success'])
self.assertIn('error', result)
self.assertIn('not initialized', result['error'].lower())
def test_get_propagation_state_no_router(self):
"""Test error when router is not available"""
self.wrapper.router = None
result = self.wrapper.get_propagation_state()
self.assertFalse(result['success'])
self.assertIn('error', result)
def test_get_propagation_state_router_exception(self):
"""Test handling of router exceptions"""
# Make accessing state raise an exception
type(self.mock_router).propagation_transfer_state = PropertyMock(
side_effect=Exception("State error")
)
result = self.wrapper.get_propagation_state()
self.assertFalse(result['success'])
self.assertIn('error', result)
class TestPropagationNodeIntegration(PropagationTestBase):
"""Integration tests for propagation node workflow"""
def setUp(self):
"""Set up test fixtures"""
super().setUp()
import tempfile
self.temp_dir = tempfile.mkdtemp()
self.wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
# Enable Reticulum
reticulum_wrapper.RETICULUM_AVAILABLE = True
# Mock router
self.mock_router = Mock()
self.wrapper.router = self.mock_router
self.wrapper.initialized = True
# Mock default identity
self.mock_identity = Mock()
self.mock_identity.hash = b'identity_hash123'
self.wrapper.default_identity = self.mock_identity
def tearDown(self):
"""Clean up test fixtures"""
import shutil
if os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir)
def test_full_propagation_workflow(self):
"""Test complete workflow: set node, request messages, check state"""
node_hash = b'propagation_node'
# Step 1: Set propagation node
self.mock_router.get_outbound_propagation_node.return_value = node_hash
result = self.wrapper.set_outbound_propagation_node(node_hash)
self.assertTrue(result['success'])
# Step 2: Verify node was set
result = self.wrapper.get_outbound_propagation_node()
self.assertTrue(result['success'])
self.assertEqual(result['propagation_node'], node_hash.hex())
# Step 3: Request messages
self.mock_router.propagation_transfer_state = 1 # path_requested
result = self.wrapper.request_messages_from_propagation_node()
self.assertTrue(result['success'])
self.assertEqual(result['state'], 1)
# Step 4: Check state during transfer
self.mock_router.propagation_transfer_state = 5 # receiving
self.mock_router.propagation_transfer_progress = 0.5
result = self.wrapper.get_propagation_state()
self.assertTrue(result['success'])
self.assertEqual(result['state'], 5)
self.assertEqual(result['state_name'], 'receiving')
self.assertEqual(result['progress'], 0.5)
# Step 5: Check state when complete
self.mock_router.propagation_transfer_state = 7 # complete
self.mock_router.propagation_transfer_progress = 1.0
self.mock_router.propagation_transfer_last_result = 3
result = self.wrapper.get_propagation_state()
self.assertTrue(result['success'])
self.assertEqual(result['state'], 7)
self.assertEqual(result['state_name'], 'complete')
self.assertEqual(result['messages_received'], 3)
def test_cannot_request_without_setting_node(self):
"""Test that requesting messages fails if no node is set"""
# Ensure no node is set
self.wrapper.active_propagation_node = None
# Try to request messages
result = self.wrapper.request_messages_from_propagation_node()
# Verify failure
self.assertFalse(result['success'])
self.assertEqual(result['errorCode'], 'NO_PROPAGATION_NODE')
def test_can_get_state_without_active_transfer(self):
"""Test that getting state works even without active transfer"""
self.mock_router.propagation_transfer_state = 0
self.mock_router.propagation_transfer_progress = 0.0
result = self.wrapper.get_propagation_state()
self.assertTrue(result['success'])
self.assertEqual(result['state'], 0)
self.assertEqual(result['state_name'], 'idle')
def test_clearing_node_resets_internal_state(self):
"""Test that clearing node resets wrapper's internal state"""
# Set a node first
node_hash = b'propagation_node'
self.wrapper.set_outbound_propagation_node(node_hash)
self.assertEqual(self.wrapper.active_propagation_node, node_hash)
# Clear the node
result = self.wrapper.set_outbound_propagation_node(None)
self.assertTrue(result['success'])
# Verify internal state cleared
self.assertIsNone(self.wrapper.active_propagation_node)
# Verify can't request messages after clearing
result = self.wrapper.request_messages_from_propagation_node()
self.assertFalse(result['success'])
self.assertEqual(result['errorCode'], 'NO_PROPAGATION_NODE')
class TestSetIncomingMessageSizeLimit(PropagationTestBase):
"""Test set_incoming_message_size_limit method"""
def setUp(self):
"""Set up test fixtures"""
super().setUp()
import tempfile
self.temp_dir = tempfile.mkdtemp()
self.wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
# Enable Reticulum
reticulum_wrapper.RETICULUM_AVAILABLE = True
# Mock router
self.mock_router = Mock()
self.wrapper.router = self.mock_router
self.wrapper.initialized = True
def tearDown(self):
"""Clean up test fixtures"""
super().tearDown()
import shutil
if os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir)
def test_set_size_limit_success(self):
"""Test successfully setting incoming message size limit"""
result = self.wrapper.set_incoming_message_size_limit(1024)
# Verify success
self.assertTrue(result['success'])
# Verify both limits were set
self.assertEqual(self.mock_router.delivery_per_transfer_limit, 1024)
self.assertEqual(self.mock_router.propagation_per_transfer_limit, 1024)
def test_set_size_limit_large_value(self):
"""Test setting large size limit (128MB for 'unlimited')"""
result = self.wrapper.set_incoming_message_size_limit(131072)
self.assertTrue(result['success'])
self.assertEqual(self.mock_router.delivery_per_transfer_limit, 131072)
self.assertEqual(self.mock_router.propagation_per_transfer_limit, 131072)
def test_set_size_limit_not_initialized(self):
"""Test error when wrapper not initialized"""
self.wrapper.initialized = False
result = self.wrapper.set_incoming_message_size_limit(1024)
self.assertFalse(result['success'])
self.assertIn('error', result)
self.assertIn('not initialized', result['error'].lower())
def test_set_size_limit_no_router(self):
"""Test error when router is not available"""
self.wrapper.router = None
result = self.wrapper.set_incoming_message_size_limit(1024)
self.assertFalse(result['success'])
self.assertIn('error', result)
def test_set_size_limit_reticulum_unavailable(self):
"""Test error when Reticulum is not available"""
reticulum_wrapper.RETICULUM_AVAILABLE = False
result = self.wrapper.set_incoming_message_size_limit(1024)
self.assertFalse(result['success'])
self.assertIn('error', result)
def test_set_size_limit_router_exception(self):
"""Test handling of router exceptions"""
# Make setting limit raise an exception
type(self.mock_router).delivery_per_transfer_limit = PropertyMock(
side_effect=Exception("Setting limit failed")
)
result = self.wrapper.set_incoming_message_size_limit(1024)
self.assertFalse(result['success'])
self.assertIn('error', result)
class TestExtractFileSummary(PropagationTestBase):
"""Test _extract_file_summary method"""
def setUp(self):
"""Set up test fixtures"""
super().setUp()
import tempfile
self.temp_dir = tempfile.mkdtemp()
self.wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
def tearDown(self):
"""Clean up test fixtures"""
super().tearDown()
import shutil
if os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir)
def test_extract_file_summary_single_file(self):
"""Test extracting summary from message with single file attachment"""
mock_message = Mock()
mock_message.fields = {
5: [['document.pdf', b'PDF content here with more data']]
}
result = self.wrapper._extract_file_summary(mock_message)
self.assertIsNotNone(result)
self.assertEqual(result['first_filename'], 'document.pdf')
self.assertEqual(result['file_count'], 1)
self.assertEqual(result['total_size'], 31) # len(b'PDF content here with more data')
def test_extract_file_summary_multiple_files(self):
"""Test extracting summary from message with multiple file attachments"""
mock_message = Mock()
mock_message.fields = {
5: [
['file1.txt', b'First file content'],
['file2.bin', b'\x00\x01\x02\x03'],
['file3.pdf', b'PDF data']
]
}
result = self.wrapper._extract_file_summary(mock_message)
self.assertIsNotNone(result)
self.assertEqual(result['first_filename'], 'file1.txt')
self.assertEqual(result['file_count'], 3)
self.assertEqual(result['total_size'], 18 + 4 + 8) # Sum of all file sizes
def test_extract_file_summary_tuple_format(self):
"""Test extracting summary from message with tuple format attachments"""
mock_message = Mock()
mock_message.fields = {
5: [('image.jpg', b'\xff\xd8\xff\xe0\x00\x10JFIF')]
}
result = self.wrapper._extract_file_summary(mock_message)
self.assertIsNotNone(result)
self.assertEqual(result['first_filename'], 'image.jpg')
self.assertEqual(result['file_count'], 1)
def test_extract_file_summary_no_fields(self):
"""Test when message has no fields"""
mock_message = Mock()
mock_message.fields = None
result = self.wrapper._extract_file_summary(mock_message)
self.assertIsNone(result)
def test_extract_file_summary_no_file_field(self):
"""Test when message has fields but no file attachments (field 5)"""
mock_message = Mock()
mock_message.fields = {6: ['jpg', b'image data']} # Image field, not file field
result = self.wrapper._extract_file_summary(mock_message)
self.assertIsNone(result)
def test_extract_file_summary_empty_attachments(self):
"""Test when file attachments list is empty"""
mock_message = Mock()
mock_message.fields = {5: []}
result = self.wrapper._extract_file_summary(mock_message)
self.assertIsNone(result)
def test_extract_file_summary_invalid_attachment_format(self):
"""Test when attachments have invalid format"""
mock_message = Mock()
mock_message.fields = {
5: [
'invalid_string', # Not a list/tuple
['valid.txt', b'content'], # Valid
[123], # Too short
]
}
result = self.wrapper._extract_file_summary(mock_message)
self.assertIsNotNone(result)
self.assertEqual(result['file_count'], 1) # Only the valid one counted
def test_extract_file_summary_none_filename(self):
"""Test handling of None filename"""
mock_message = Mock()
mock_message.fields = {
5: [[None, b'content without name']]
}
result = self.wrapper._extract_file_summary(mock_message)
self.assertIsNotNone(result)
self.assertEqual(result['first_filename'], 'file') # Default fallback
def test_extract_file_summary_no_hasattr(self):
"""Test when message doesn't have fields attribute"""
mock_message = Mock(spec=[]) # No attributes
result = self.wrapper._extract_file_summary(mock_message)
self.assertIsNone(result)
class TestFailMessagePermanently(PropagationTestBase):
"""Test _fail_message_permanently method"""
def setUp(self):
"""Set up test fixtures"""
super().setUp()
import tempfile
self.temp_dir = tempfile.mkdtemp()
self.wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
def tearDown(self):
"""Clean up test fixtures"""
super().tearDown()
import shutil
if os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir)
def test_fail_message_notifies_kotlin(self):
"""Test that permanent failure notifies Kotlin callback"""
mock_callback = Mock()
self.wrapper.kotlin_delivery_status_callback = mock_callback
mock_message = Mock()
mock_message.hash = b'messagehash12345'
self.wrapper._fail_message_permanently(mock_message, 'max_relay_retries_exceeded')
# Kotlin callback should be invoked
mock_callback.assert_called_once()
call_arg = mock_callback.call_args[0][0]
# Parse JSON
import json
status_event = json.loads(call_arg)
self.assertEqual(status_event['status'], 'failed')
self.assertEqual(status_event['reason'], 'max_relay_retries_exceeded')
self.assertEqual(status_event['message_hash'], mock_message.hash.hex())
self.assertIn('timestamp', status_event)
def test_fail_message_removes_from_pending(self):
"""Test that permanent failure removes message from pending fallback"""
mock_message = Mock()
mock_message.hash = b'messagehash12345'
# Add to pending
self.wrapper._pending_relay_fallback_messages[mock_message.hash.hex()] = mock_message
self.wrapper._fail_message_permanently(mock_message, 'no_relays_available')
# Should be removed from pending
self.assertNotIn(mock_message.hash.hex(), self.wrapper._pending_relay_fallback_messages)
def test_fail_message_no_callback(self):
"""Test that permanent failure works without Kotlin callback"""
self.wrapper.kotlin_delivery_status_callback = None
mock_message = Mock()
mock_message.hash = b'messagehash12345'
# Should not raise exception
self.wrapper._fail_message_permanently(mock_message, 'test_failure')
def test_fail_message_callback_exception(self):
"""Test handling of exception in Kotlin callback"""
mock_callback = Mock(side_effect=Exception("Callback error"))
self.wrapper.kotlin_delivery_status_callback = mock_callback
mock_message = Mock()
mock_message.hash = b'messagehash12345'
# Should not raise exception
self.wrapper._fail_message_permanently(mock_message, 'test_failure')
class TestOnAlternativeRelayReceived(PropagationTestBase):
"""Test on_alternative_relay_received method"""
def setUp(self):
"""Set up test fixtures"""
super().setUp()
import tempfile
self.temp_dir = tempfile.mkdtemp()
self.wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
# Enable Reticulum
reticulum_wrapper.RETICULUM_AVAILABLE = True
# Mock router
self.mock_router = Mock()
self.wrapper.router = self.mock_router
self.wrapper.initialized = True
def tearDown(self):
"""Clean up test fixtures"""
super().tearDown()
import shutil
if os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir)
def test_alternative_relay_no_pending_messages(self):
"""Test when no pending messages for relay fallback"""
self.wrapper._pending_relay_fallback_messages = {}
# Should not crash
self.wrapper.on_alternative_relay_received(b'newrelay12345678')
def test_alternative_relay_none_fails_all(self):
"""Test that None relay hash fails all pending messages"""
mock_callback = Mock()
self.wrapper.kotlin_delivery_status_callback = mock_callback
mock_message1 = Mock()
mock_message1.hash = b'message1hash1234'
mock_message2 = Mock()
mock_message2.hash = b'message2hash1234'
self.wrapper._pending_relay_fallback_messages = {
mock_message1.hash.hex(): mock_message1,
mock_message2.hash.hex(): mock_message2,
}
self.wrapper.on_alternative_relay_received(None)
# All pending should be cleared
self.assertEqual(len(self.wrapper._pending_relay_fallback_messages), 0)
# Callback should be called for each failed message
self.assertEqual(mock_callback.call_count, 2)
def test_alternative_relay_retries_with_new_relay(self):
"""Test that alternative relay retries pending messages"""
mock_callback = Mock()
self.wrapper.kotlin_delivery_status_callback = mock_callback
mock_message = Mock()
mock_message.hash = b'messagehash12345'
mock_message.tried_relays = []
mock_message.fields = {}
self.wrapper._pending_relay_fallback_messages = {
mock_message.hash.hex(): mock_message
}
new_relay = b'newrelay12345678'
self.wrapper.on_alternative_relay_received(new_relay)
# Active propagation node should be updated
self.assertEqual(self.wrapper.active_propagation_node, new_relay)
# Message should be resubmitted
self.mock_router.handle_outbound.assert_called_once_with(mock_message)
# Pending should be cleared
self.assertEqual(len(self.wrapper._pending_relay_fallback_messages), 0)
# Status callback should be invoked
mock_callback.assert_called()
call_arg = mock_callback.call_args[0][0]
import json
status = json.loads(call_arg)
self.assertEqual(status['status'], 'retrying_propagated')
def test_alternative_relay_converts_jarray(self):
"""Test that jarray-like relay hash is converted to bytes"""
mock_message = Mock()
mock_message.hash = b'messagehash12345'
mock_message.tried_relays = []
mock_message.fields = {}
self.wrapper._pending_relay_fallback_messages = {
mock_message.hash.hex(): mock_message
}
# Pass list instead of bytes (simulating jarray from Java)
relay_jarray = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10]
self.wrapper.on_alternative_relay_received(relay_jarray)
# Should be converted to bytes
self.assertIsInstance(self.wrapper.active_propagation_node, bytes)
self.assertEqual(self.wrapper.active_propagation_node, bytes(relay_jarray))
def test_alternative_relay_tracks_tried_relays(self):
"""Test that tried relays are tracked on the message"""
mock_message = Mock()
mock_message.hash = b'messagehash12345'
mock_message.tried_relays = [b'previousrelay123']
mock_message.fields = {}
self.wrapper._pending_relay_fallback_messages = {
mock_message.hash.hex(): mock_message
}
new_relay = b'newrelay12345678'
self.wrapper.on_alternative_relay_received(new_relay)
# New relay should be added to tried list
self.assertIn(new_relay, mock_message.tried_relays)
self.assertEqual(len(mock_message.tried_relays), 2)
def test_alternative_relay_resets_message_state(self):
"""Test that message state is reset for fresh retry"""
mock_message = Mock()
mock_message.hash = b'messagehash12345'
mock_message.tried_relays = []
mock_message.delivery_attempts = 5
mock_message.packed = b'old_packed'
mock_message.propagation_packed = b'old_prop_packed'
mock_message.propagation_stamp = 'old_stamp'
mock_message.fields = {}
self.wrapper._pending_relay_fallback_messages = {
mock_message.hash.hex(): mock_message
}
self.wrapper.on_alternative_relay_received(b'newrelay12345678')
# State should be reset
self.assertEqual(mock_message.delivery_attempts, 0)
self.assertIsNone(mock_message.packed)
self.assertIsNone(mock_message.propagation_packed)
self.assertIsNone(mock_message.propagation_stamp)
self.assertTrue(mock_message.defer_propagation_stamp)
self.assertEqual(mock_message.desired_method, lxmf_mock.LXMessage.PROPAGATED)
def test_alternative_relay_updates_router(self):
"""Test that router propagation node is updated"""
mock_message = Mock()
mock_message.hash = b'messagehash12345'
mock_message.tried_relays = []
mock_message.fields = {}
self.wrapper._pending_relay_fallback_messages = {
mock_message.hash.hex(): mock_message
}
new_relay = b'newrelay12345678'
self.wrapper.on_alternative_relay_received(new_relay)
# Router should be updated
self.mock_router.set_outbound_propagation_node.assert_called_once_with(new_relay)
class TestSendPendingFileNotification(PropagationTestBase):
"""Test _send_pending_file_notification method"""
def setUp(self):
"""Set up test fixtures"""
super().setUp()
import tempfile
self.temp_dir = tempfile.mkdtemp()
self.wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
# Enable Reticulum
reticulum_wrapper.RETICULUM_AVAILABLE = True
# Mock router
self.mock_router = Mock()
self.wrapper.router = self.mock_router
self.wrapper.initialized = True
def tearDown(self):
"""Clean up test fixtures"""
super().tearDown()
import shutil
if os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir)
def test_send_notification_for_file_message(self):
"""Test sending notification for message with file attachments"""
mock_message = Mock()
mock_message.hash = b'messagehash12345'
mock_message.destination = Mock()
mock_message.source = Mock()
mock_message.fields = {
5: [['document.pdf', b'PDF content']]
}
self.wrapper._send_pending_file_notification(mock_message)
# Router should have received a notification message
self.mock_router.handle_outbound.assert_called_once()
notification_msg = self.mock_router.handle_outbound.call_args[0][0]
# Verify it's an OPPORTUNISTIC message
self.assertEqual(notification_msg.desired_method, lxmf_mock.LXMessage.OPPORTUNISTIC)
def test_send_notification_skips_no_files(self):
"""Test that notification is skipped when no file attachments"""
mock_message = Mock()
mock_message.hash = b'messagehash12345'
mock_message.fields = None
self.wrapper._send_pending_file_notification(mock_message)
# Router should NOT have received any message
self.mock_router.handle_outbound.assert_not_called()
def test_send_notification_handles_exception(self):
"""Test that exceptions in notification sending are handled"""
mock_message = Mock()
mock_message.hash = b'messagehash12345'
mock_message.destination = Mock()
mock_message.source = Mock()
mock_message.fields = {
5: [['file.txt', b'content']]
}
# Make handle_outbound raise
self.mock_router.handle_outbound.side_effect = Exception("Send failed")
# Should not raise exception
self.wrapper._send_pending_file_notification(mock_message)
if __name__ == '__main__':
# Run tests with verbose output
unittest.main(verbosity=2)