columba/python/test_telemetry_host_mode.py
torlando-tech 7e95f278b3 Add tests for set_telemetry_allowed_requesters method
Cover all branches: success with items, empty list, lowercase
normalization, empty string filtering, set storage, deduplication,
and list replacement.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 13:32:29 -05:00

1436 lines
55 KiB
Python

"""
Test suite for telemetry collector host mode functionality.
Tests the host mode features in ReticulumWrapper that allow Columba to act
as a telemetry collector (group host) compatible with Sideband's protocol.
"""
import sys
import os
import unittest
import time
from unittest.mock import MagicMock, patch, Mock
import tempfile
import shutil
# Add parent directory to path to import reticulum_wrapper
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# Try to import u-msgpack-python
try:
import umsgpack
except ImportError:
# Skip all tests if umsgpack not available (will be installed in CI)
umsgpack = None
# Skip all tests if umsgpack is not available
if umsgpack is None:
raise unittest.SkipTest("umsgpack not available - skipping telemetry host mode tests")
# Make umsgpack available BEFORE importing reticulum_wrapper
sys.modules['umsgpack'] = umsgpack
# Mock RNS and LXMF before importing reticulum_wrapper
mock_rns = MagicMock()
mock_lxmf = MagicMock()
sys.modules['RNS'] = mock_rns
sys.modules['RNS.vendor'] = MagicMock()
sys.modules['RNS.vendor.platformutils'] = MagicMock()
sys.modules['LXMF'] = mock_lxmf
# Now import after mocking
import reticulum_wrapper
import importlib
reticulum_wrapper.umsgpack = umsgpack
importlib.reload(reticulum_wrapper)
from reticulum_wrapper import (
pack_telemetry_stream,
unpack_telemetry_stream,
pack_location_telemetry,
unpack_location_telemetry,
ReticulumWrapper,
)
class TestPackTelemetryStream(unittest.TestCase):
"""Test pack_telemetry_stream function."""
def test_returns_bytes(self):
"""pack_telemetry_stream should return bytes."""
result = pack_telemetry_stream([])
self.assertIsInstance(result, bytes)
def test_empty_list_returns_packed_empty_list(self):
"""Empty list input should return msgpack-encoded empty list."""
result = pack_telemetry_stream([])
unpacked = umsgpack.unpackb(result)
self.assertEqual(unpacked, [])
def test_single_entry_packs_correctly(self):
"""Single entry should pack and unpack correctly."""
source_hash = bytes.fromhex("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4")
timestamp = 1703980800
packed_telemetry = pack_location_telemetry(
lat=37.7749, lon=-122.4194, accuracy=10.0, timestamp_ms=1703980800000
)
appearance = None
entries = [[source_hash, timestamp, packed_telemetry, appearance]]
result = pack_telemetry_stream(entries)
# Verify it can be unpacked
unpacked = umsgpack.unpackb(result)
self.assertEqual(len(unpacked), 1)
self.assertEqual(unpacked[0][0], source_hash)
self.assertEqual(unpacked[0][1], timestamp)
def test_multiple_entries_pack_correctly(self):
"""Multiple entries should pack and unpack correctly."""
entries = []
for i in range(3):
source_hash = bytes([i] * 16)
timestamp = 1703980800 + i * 60
packed_telemetry = pack_location_telemetry(
lat=37.0 + i, lon=-122.0 - i, accuracy=10.0, timestamp_ms=(1703980800 + i * 60) * 1000
)
entries.append([source_hash, timestamp, packed_telemetry, None])
result = pack_telemetry_stream(entries)
unpacked = umsgpack.unpackb(result)
self.assertEqual(len(unpacked), 3)
class TestUnpackTelemetryStream(unittest.TestCase):
"""Test unpack_telemetry_stream function."""
def _create_valid_packed_telemetry(self, lat=37.7749, lon=-122.4194):
"""Create valid packed telemetry data for testing."""
return pack_location_telemetry(
lat=lat, lon=lon, accuracy=10.0, timestamp_ms=1703980800000
)
def test_empty_stream_returns_empty_list(self):
"""Empty stream should return empty list."""
result = unpack_telemetry_stream([])
self.assertEqual(result, [])
def test_unpacks_valid_entry_with_bytes_source_hash(self):
"""Should unpack entry with bytes source hash."""
source_hash = bytes.fromhex("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4")
timestamp = 1703980800
packed_telemetry = self._create_valid_packed_telemetry()
stream = [[source_hash, timestamp, packed_telemetry, None]]
result = unpack_telemetry_stream(stream)
self.assertEqual(len(result), 1)
self.assertEqual(result[0]['source_hash'], source_hash.hex())
def test_unpacks_valid_entry_with_string_source_hash(self):
"""Should handle string source hash (non-bytes)."""
source_hash = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"
timestamp = 1703980800
packed_telemetry = self._create_valid_packed_telemetry()
stream = [[source_hash, timestamp, packed_telemetry, None]]
result = unpack_telemetry_stream(stream)
self.assertEqual(len(result), 1)
self.assertEqual(result[0]['source_hash'], source_hash)
def test_skips_entry_with_too_few_elements(self):
"""Should skip entries with fewer than 3 elements."""
short_entry = [bytes.fromhex("a1" * 16), 1703980800] # Only 2 elements
valid_entry = [bytes.fromhex("b2" * 16), 1703980800, self._create_valid_packed_telemetry(), None]
stream = [short_entry, valid_entry]
result = unpack_telemetry_stream(stream)
# Only valid entry should be returned
self.assertEqual(len(result), 1)
def test_skips_entry_with_invalid_telemetry(self):
"""Should skip entries where telemetry unpacking fails."""
source_hash = bytes.fromhex("a1" * 16)
timestamp = 1703980800
invalid_telemetry = b'not valid telemetry'
stream = [[source_hash, timestamp, invalid_telemetry, None]]
result = unpack_telemetry_stream(stream)
self.assertEqual(len(result), 0)
def test_applies_valid_timestamp(self):
"""Should apply valid timestamp from entry."""
source_hash = bytes.fromhex("a1" * 16)
timestamp = 1703980800 # Specific timestamp
packed_telemetry = self._create_valid_packed_telemetry()
stream = [[source_hash, timestamp, packed_telemetry, None]]
result = unpack_telemetry_stream(stream)
self.assertEqual(len(result), 1)
# Timestamp should be converted to milliseconds
self.assertEqual(result[0]['ts'], timestamp * 1000)
def test_rejects_future_timestamp(self):
"""Should reject timestamps more than 1 hour in the future."""
source_hash = bytes.fromhex("a1" * 16)
future_timestamp = time.time() + 7200 # 2 hours in future
packed_telemetry = self._create_valid_packed_telemetry()
stream = [[source_hash, future_timestamp, packed_telemetry, None]]
result = unpack_telemetry_stream(stream)
# Entry should still be returned but timestamp should not be the future one
self.assertEqual(len(result), 1)
# The original timestamp from packed telemetry should be used instead
self.assertNotEqual(result[0]['ts'], future_timestamp * 1000)
def test_handles_zero_timestamp(self):
"""Should not apply zero timestamp."""
source_hash = bytes.fromhex("a1" * 16)
timestamp = 0
packed_telemetry = self._create_valid_packed_telemetry()
stream = [[source_hash, timestamp, packed_telemetry, None]]
result = unpack_telemetry_stream(stream)
self.assertEqual(len(result), 1)
# Original timestamp from telemetry should be preserved
def test_handles_none_timestamp(self):
"""Should not apply None timestamp."""
source_hash = bytes.fromhex("a1" * 16)
timestamp = None
packed_telemetry = self._create_valid_packed_telemetry()
stream = [[source_hash, timestamp, packed_telemetry, None]]
result = unpack_telemetry_stream(stream)
self.assertEqual(len(result), 1)
def test_parses_valid_appearance(self):
"""Should parse valid appearance data."""
source_hash = bytes.fromhex("a1" * 16)
timestamp = 1703980800
packed_telemetry = self._create_valid_packed_telemetry()
appearance = ["icon_name", b'\xff\x00\x00', b'\x00\xff\x00'] # Red fg, green bg
stream = [[source_hash, timestamp, packed_telemetry, appearance]]
result = unpack_telemetry_stream(stream)
self.assertEqual(len(result), 1)
self.assertIn('appearance', result[0])
self.assertEqual(result[0]['appearance']['name'], 'icon_name')
self.assertEqual(result[0]['appearance']['fg'], '#ff0000')
self.assertEqual(result[0]['appearance']['bg'], '#00ff00')
def test_handles_appearance_with_invalid_icon_name(self):
"""Should reject appearance with invalid icon name."""
source_hash = bytes.fromhex("a1" * 16)
timestamp = 1703980800
packed_telemetry = self._create_valid_packed_telemetry()
# Icon name with invalid characters
appearance = ["icon-name-with-dashes!", b'\xff\x00\x00', b'\x00\xff\x00']
stream = [[source_hash, timestamp, packed_telemetry, appearance]]
result = unpack_telemetry_stream(stream)
self.assertEqual(len(result), 1)
# Appearance should not be set due to invalid icon name
self.assertNotIn('appearance', result[0])
def test_handles_appearance_with_too_long_icon_name(self):
"""Should reject appearance with icon name > 50 chars."""
source_hash = bytes.fromhex("a1" * 16)
timestamp = 1703980800
packed_telemetry = self._create_valid_packed_telemetry()
appearance = ["a" * 51, b'\xff\x00\x00', b'\x00\xff\x00']
stream = [[source_hash, timestamp, packed_telemetry, appearance]]
result = unpack_telemetry_stream(stream)
self.assertEqual(len(result), 1)
self.assertNotIn('appearance', result[0])
def test_handles_appearance_with_invalid_color_bytes(self):
"""Should handle appearance with invalid color bytes gracefully."""
source_hash = bytes.fromhex("a1" * 16)
timestamp = 1703980800
packed_telemetry = self._create_valid_packed_telemetry()
# Invalid color bytes (too short)
appearance = ["icon_name", b'\xff', b'\x00']
stream = [[source_hash, timestamp, packed_telemetry, appearance]]
result = unpack_telemetry_stream(stream)
self.assertEqual(len(result), 1)
# Appearance should be set but colors should be None
if 'appearance' in result[0]:
self.assertIsNone(result[0]['appearance']['fg'])
self.assertIsNone(result[0]['appearance']['bg'])
def test_handles_appearance_with_non_bytes_colors(self):
"""Should handle appearance with non-bytes color values."""
source_hash = bytes.fromhex("a1" * 16)
timestamp = 1703980800
packed_telemetry = self._create_valid_packed_telemetry()
# Color values as strings instead of bytes
appearance = ["icon_name", "not_bytes", "also_not_bytes"]
stream = [[source_hash, timestamp, packed_telemetry, appearance]]
result = unpack_telemetry_stream(stream)
self.assertEqual(len(result), 1)
if 'appearance' in result[0]:
self.assertIsNone(result[0]['appearance']['fg'])
self.assertIsNone(result[0]['appearance']['bg'])
def test_processes_multiple_entries(self):
"""Should process multiple valid entries."""
entries = []
for i in range(3):
source_hash = bytes([i + 1] * 16)
timestamp = 1703980800 + i * 60
packed_telemetry = self._create_valid_packed_telemetry(lat=37.0 + i, lon=-122.0 - i)
entries.append([source_hash, timestamp, packed_telemetry, None])
result = unpack_telemetry_stream(entries)
self.assertEqual(len(result), 3)
def test_handles_entry_processing_exception(self):
"""Should handle exceptions during entry processing gracefully."""
# Create an entry that will cause an exception during processing
# by having packed_telemetry be something that causes issues
source_hash = bytes.fromhex("a1" * 16)
timestamp = 1703980800
# This might cause issues during unpacking
broken_entry = [source_hash, timestamp, None, None]
valid_entry = [bytes.fromhex("b2" * 16), timestamp, self._create_valid_packed_telemetry(), None]
stream = [broken_entry, valid_entry]
result = unpack_telemetry_stream(stream)
# Valid entry should still be processed
self.assertEqual(len(result), 1)
class TestSetTelemetryCollectorEnabled(unittest.TestCase):
"""Test set_telemetry_collector_enabled method."""
def setUp(self):
"""Set up test fixtures."""
self.temp_dir = tempfile.mkdtemp()
self.wrapper = ReticulumWrapper(self.temp_dir)
# Mark as initialized to allow operations
self.wrapper.initialized = False
def tearDown(self):
"""Clean up test fixtures."""
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_enable_returns_success(self):
"""Enabling collector mode should return success."""
result = self.wrapper.set_telemetry_collector_enabled(True)
self.assertTrue(result['success'])
self.assertTrue(result['enabled'])
def test_disable_returns_success(self):
"""Disabling collector mode should return success."""
result = self.wrapper.set_telemetry_collector_enabled(False)
self.assertTrue(result['success'])
self.assertFalse(result['enabled'])
def test_enable_sets_flag(self):
"""Enabling should set the internal flag."""
self.wrapper.set_telemetry_collector_enabled(True)
self.assertTrue(self.wrapper.telemetry_collector_enabled)
def test_disable_sets_flag(self):
"""Disabling should clear the internal flag."""
self.wrapper.telemetry_collector_enabled = True
self.wrapper.set_telemetry_collector_enabled(False)
self.assertFalse(self.wrapper.telemetry_collector_enabled)
def test_disable_clears_collected_telemetry(self):
"""Disabling should clear any collected telemetry."""
# Add some test telemetry
self.wrapper.collected_telemetry = {
'abc123': {'timestamp': 123, 'packed_telemetry': b'test'}
}
# Disable
self.wrapper.set_telemetry_collector_enabled(False)
# Should be cleared
self.assertEqual(len(self.wrapper.collected_telemetry), 0)
def test_enable_does_not_clear_collected_telemetry(self):
"""Enabling should not clear existing collected telemetry."""
# Add some test telemetry
self.wrapper.collected_telemetry = {
'abc123': {'timestamp': 123, 'packed_telemetry': b'test'}
}
# Enable
self.wrapper.set_telemetry_collector_enabled(True)
# Should still be there
self.assertEqual(len(self.wrapper.collected_telemetry), 1)
class TestSetTelemetryAllowedRequesters(unittest.TestCase):
"""Test set_telemetry_allowed_requesters method."""
def setUp(self):
"""Set up test fixtures."""
self.temp_dir = tempfile.mkdtemp()
self.wrapper = ReticulumWrapper(self.temp_dir)
def tearDown(self):
"""Clean up test fixtures."""
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_set_allowed_requesters_returns_success(self):
"""Setting allowed requesters should return success with count."""
result = self.wrapper.set_telemetry_allowed_requesters(["a" * 32, "b" * 32])
self.assertTrue(result['success'])
self.assertEqual(result['count'], 2)
def test_set_empty_list_returns_zero_count(self):
"""Setting empty list should return success with zero count."""
result = self.wrapper.set_telemetry_allowed_requesters([])
self.assertTrue(result['success'])
self.assertEqual(result['count'], 0)
def test_normalizes_to_lowercase(self):
"""Should normalize hashes to lowercase."""
self.wrapper.set_telemetry_allowed_requesters(["AABBCCDD" * 4])
self.assertIn("aabbccdd" * 4, self.wrapper.telemetry_allowed_requesters)
def test_filters_empty_strings(self):
"""Should filter out empty strings from the list."""
result = self.wrapper.set_telemetry_allowed_requesters(["a" * 32, "", "b" * 32, ""])
self.assertEqual(result['count'], 2)
self.assertEqual(len(self.wrapper.telemetry_allowed_requesters), 2)
def test_stores_as_set(self):
"""Should store allowed requesters as a set for O(1) lookup."""
self.wrapper.set_telemetry_allowed_requesters(["a" * 32, "b" * 32])
self.assertIsInstance(self.wrapper.telemetry_allowed_requesters, set)
def test_deduplicates_entries(self):
"""Should deduplicate entries when same hash appears multiple times."""
result = self.wrapper.set_telemetry_allowed_requesters(["a" * 32, "a" * 32, "a" * 32])
self.assertEqual(result['count'], 1)
def test_replaces_previous_list(self):
"""Setting new list should replace previous list entirely."""
self.wrapper.set_telemetry_allowed_requesters(["a" * 32, "b" * 32])
self.wrapper.set_telemetry_allowed_requesters(["c" * 32])
self.assertEqual(len(self.wrapper.telemetry_allowed_requesters), 1)
self.assertIn("c" * 32, self.wrapper.telemetry_allowed_requesters)
self.assertNotIn("a" * 32, self.wrapper.telemetry_allowed_requesters)
class TestStoreTelemetryForCollector(unittest.TestCase):
"""Test _store_telemetry_for_collector method."""
def setUp(self):
"""Set up test fixtures."""
self.temp_dir = tempfile.mkdtemp()
self.wrapper = ReticulumWrapper(self.temp_dir)
self.wrapper.telemetry_collector_enabled = True
def tearDown(self):
"""Clean up test fixtures."""
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_stores_telemetry_entry(self):
"""Should store telemetry entry in collected_telemetry dict."""
source_hash = "a1b2c3d4e5f6a1b2"
packed_telemetry = b'test_data'
timestamp = 1703980800
self.wrapper._store_telemetry_for_collector(
source_hash_hex=source_hash,
packed_telemetry=packed_telemetry,
timestamp=timestamp,
)
self.assertIn(source_hash, self.wrapper.collected_telemetry)
entry = self.wrapper.collected_telemetry[source_hash]
self.assertEqual(entry['timestamp'], timestamp)
self.assertEqual(entry['packed_telemetry'], packed_telemetry)
self.assertIn('received_at', entry)
def test_stores_appearance_data(self):
"""Should store appearance data when provided."""
source_hash = "a1b2c3d4e5f6a1b2"
appearance = ['icon_name', b'\xff\x00\x00', b'\x00\xff\x00']
self.wrapper._store_telemetry_for_collector(
source_hash_hex=source_hash,
packed_telemetry=b'test',
timestamp=1703980800,
appearance=appearance,
)
entry = self.wrapper.collected_telemetry[source_hash]
self.assertEqual(entry['appearance'], appearance)
def test_overwrites_previous_entry_from_same_source(self):
"""Should keep only latest entry per source (not accumulate)."""
source_hash = "a1b2c3d4e5f6a1b2"
# Store first entry
self.wrapper._store_telemetry_for_collector(
source_hash_hex=source_hash,
packed_telemetry=b'old_data',
timestamp=1703980800,
)
# Store second entry from same source
self.wrapper._store_telemetry_for_collector(
source_hash_hex=source_hash,
packed_telemetry=b'new_data',
timestamp=1703980860,
)
# Should only have one entry
self.assertEqual(len(self.wrapper.collected_telemetry), 1)
entry = self.wrapper.collected_telemetry[source_hash]
self.assertEqual(entry['packed_telemetry'], b'new_data')
self.assertEqual(entry['timestamp'], 1703980860)
def test_stores_entries_from_different_sources(self):
"""Should store entries from different sources separately."""
self.wrapper._store_telemetry_for_collector(
source_hash_hex="source1",
packed_telemetry=b'data1',
timestamp=1703980800,
)
self.wrapper._store_telemetry_for_collector(
source_hash_hex="source2",
packed_telemetry=b'data2',
timestamp=1703980860,
)
self.assertEqual(len(self.wrapper.collected_telemetry), 2)
self.assertIn("source1", self.wrapper.collected_telemetry)
self.assertIn("source2", self.wrapper.collected_telemetry)
class TestCleanupExpiredTelemetry(unittest.TestCase):
"""Test _cleanup_expired_telemetry method."""
def setUp(self):
"""Set up test fixtures."""
self.temp_dir = tempfile.mkdtemp()
self.wrapper = ReticulumWrapper(self.temp_dir)
self.wrapper.telemetry_collector_enabled = True
# Set retention to 1 second for fast testing
self.wrapper.telemetry_retention_seconds = 1
def tearDown(self):
"""Clean up test fixtures."""
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_removes_expired_entries(self):
"""Should remove entries older than retention period."""
# Add entry with old received_at
self.wrapper.collected_telemetry['old_source'] = {
'timestamp': 1703980800,
'packed_telemetry': b'old',
'received_at': time.time() - 10, # 10 seconds ago
}
self.wrapper._cleanup_expired_telemetry()
self.assertNotIn('old_source', self.wrapper.collected_telemetry)
def test_keeps_fresh_entries(self):
"""Should keep entries within retention period."""
# Add fresh entry
self.wrapper.collected_telemetry['fresh_source'] = {
'timestamp': 1703980800,
'packed_telemetry': b'fresh',
'received_at': time.time(), # Just now
}
self.wrapper._cleanup_expired_telemetry()
self.assertIn('fresh_source', self.wrapper.collected_telemetry)
def test_keeps_fresh_removes_old(self):
"""Should keep fresh entries while removing old ones."""
current_time = time.time()
# Add mix of old and fresh entries
self.wrapper.collected_telemetry['old'] = {
'timestamp': 1703980800,
'packed_telemetry': b'old',
'received_at': current_time - 10,
}
self.wrapper.collected_telemetry['fresh'] = {
'timestamp': 1703980860,
'packed_telemetry': b'fresh',
'received_at': current_time,
}
self.wrapper._cleanup_expired_telemetry()
self.assertNotIn('old', self.wrapper.collected_telemetry)
self.assertIn('fresh', self.wrapper.collected_telemetry)
class TestTelemetryHostModeIntegration(unittest.TestCase):
"""Integration tests for host mode workflow."""
def setUp(self):
"""Set up test fixtures."""
self.temp_dir = tempfile.mkdtemp()
self.wrapper = ReticulumWrapper(self.temp_dir)
self.wrapper.telemetry_collector_enabled = True
self.wrapper.telemetry_retention_seconds = 86400 # 24 hours
def tearDown(self):
"""Clean up test fixtures."""
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_full_workflow_store_and_retrieve(self):
"""Test complete workflow: store telemetry, then prepare stream."""
# Store some telemetry with valid hex source hashes
for i in range(3):
source_hash_hex = f"{i:032x}" # 32 hex chars = 16 bytes
self.wrapper._store_telemetry_for_collector(
source_hash_hex=source_hash_hex,
packed_telemetry=pack_location_telemetry(
lat=37.0 + i,
lon=-122.0 - i,
accuracy=10.0,
timestamp_ms=(1703980800 + i * 60) * 1000,
),
timestamp=1703980800 + i * 60,
)
# Verify all stored
self.assertEqual(len(self.wrapper.collected_telemetry), 3)
# Pack all entries into a stream
entries = []
for source_hash_hex, entry in self.wrapper.collected_telemetry.items():
entries.append([
bytes.fromhex(source_hash_hex),
entry['timestamp'],
entry['packed_telemetry'],
entry.get('appearance'),
])
packed_stream = pack_telemetry_stream(entries)
# Verify stream can be unpacked
unpacked = umsgpack.unpackb(packed_stream)
self.assertEqual(len(unpacked), 3)
def test_initial_state_is_disabled(self):
"""Host mode should be disabled by default."""
fresh_wrapper = ReticulumWrapper(self.temp_dir)
self.assertFalse(fresh_wrapper.telemetry_collector_enabled)
def test_collected_telemetry_starts_empty(self):
"""Collected telemetry dict should start empty."""
fresh_wrapper = ReticulumWrapper(self.temp_dir)
self.assertEqual(len(fresh_wrapper.collected_telemetry), 0)
def test_retention_period_default(self):
"""Default retention period should be 24 hours (86400 seconds)."""
fresh_wrapper = ReticulumWrapper(self.temp_dir)
self.assertEqual(fresh_wrapper.telemetry_retention_seconds, 86400)
class TestSendTelemetryStreamResponse(unittest.TestCase):
"""Test _send_telemetry_stream_response method."""
def setUp(self):
"""Set up test fixtures."""
self.temp_dir = tempfile.mkdtemp()
self.wrapper = ReticulumWrapper(self.temp_dir)
self.wrapper.telemetry_collector_enabled = True
self.wrapper.telemetry_retention_seconds = 86400
# Set up mock router and destination
self.wrapper.router = MagicMock()
self.wrapper.local_lxmf_destination = MagicMock()
# Ensure RNS and LXMF are properly mocked in reticulum_wrapper module
reticulum_wrapper.RNS = MagicMock()
reticulum_wrapper.LXMF = MagicMock()
def tearDown(self):
"""Clean up test fixtures."""
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_sends_all_entries_when_timebase_is_none(self):
"""Should send all entries when timebase is None."""
current_time = time.time()
# Store test entries
for i in range(3):
self.wrapper.collected_telemetry[f"{i:032x}"] = {
'timestamp': 1703980800 + i * 60,
'packed_telemetry': b'test_data',
'appearance': None,
'received_at': current_time - i * 10,
}
# Create mock identity and hash
mock_identity = MagicMock()
requester_hash = bytes.fromhex("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4")
# Call the method with timebase=None
self.wrapper._send_telemetry_stream_response(requester_hash, mock_identity, None)
# Verify router.handle_outbound was called
self.wrapper.router.handle_outbound.assert_called_once()
def test_sends_all_entries_when_timebase_is_zero(self):
"""Should send all entries when timebase is 0."""
current_time = time.time()
# Store test entries
self.wrapper.collected_telemetry["a" * 32] = {
'timestamp': 1703980800,
'packed_telemetry': b'test_data',
'appearance': None,
'received_at': current_time,
}
mock_identity = MagicMock()
requester_hash = bytes.fromhex("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4")
self.wrapper._send_telemetry_stream_response(requester_hash, mock_identity, 0)
self.wrapper.router.handle_outbound.assert_called_once()
def test_filters_entries_by_timebase(self):
"""Should only send entries with received_at >= timebase."""
current_time = time.time()
# Store old entry (before timebase)
self.wrapper.collected_telemetry["0" * 32] = {
'timestamp': 1703980800,
'packed_telemetry': b'old_data',
'appearance': None,
'received_at': current_time - 100, # 100 seconds ago
}
# Store new entry (after timebase)
self.wrapper.collected_telemetry["f" * 32] = {
'timestamp': 1703980860,
'packed_telemetry': b'new_data',
'appearance': None,
'received_at': current_time, # Now
}
mock_identity = MagicMock()
requester_hash = bytes.fromhex("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4")
# Capture the fields passed to LXMessage using side_effect
captured_fields = {}
original_mock = MagicMock()
original_mock.DIRECT = 2 # LXMF.LXMessage.DIRECT value
def capture_lxmessage(*args, **kwargs):
captured_fields.update(kwargs.get('fields', {}))
mock_msg = MagicMock()
mock_msg.fields = kwargs.get('fields', {})
return mock_msg
original_mock.side_effect = capture_lxmessage
reticulum_wrapper.LXMF.LXMessage = original_mock
# Set timebase to 50 seconds ago - should only include "new" entry
timebase = current_time - 50
self.wrapper._send_telemetry_stream_response(requester_hash, mock_identity, timebase)
# Verify handle_outbound was called
self.wrapper.router.handle_outbound.assert_called_once()
# Check the fields contain the stream with only the new entry
self.assertIn(reticulum_wrapper.FIELD_TELEMETRY_STREAM, captured_fields)
stream_data = captured_fields[reticulum_wrapper.FIELD_TELEMETRY_STREAM]
self.assertEqual(len(stream_data), 1)
self.assertEqual(stream_data[0][2], b'new_data')
def test_creates_lxmf_message_with_correct_fields(self):
"""Should create LXMF message with FIELD_TELEMETRY_STREAM."""
current_time = time.time()
self.wrapper.collected_telemetry["a" * 32] = {
'timestamp': 1703980800,
'packed_telemetry': b'test_data',
'appearance': ['icon', b'\xff'],
'received_at': current_time,
}
mock_identity = MagicMock()
requester_hash = bytes.fromhex("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4")
# Capture the fields passed to LXMessage using side_effect
captured_fields = {}
original_mock = MagicMock()
original_mock.DIRECT = 2
def capture_lxmessage(*args, **kwargs):
captured_fields.update(kwargs.get('fields', {}))
mock_msg = MagicMock()
mock_msg.fields = kwargs.get('fields', {})
return mock_msg
original_mock.side_effect = capture_lxmessage
reticulum_wrapper.LXMF.LXMessage = original_mock
self.wrapper._send_telemetry_stream_response(requester_hash, mock_identity, None)
# Verify handle_outbound was called
self.wrapper.router.handle_outbound.assert_called_once()
# Check fields
self.assertIn(reticulum_wrapper.FIELD_TELEMETRY_STREAM, captured_fields)
stream_data = captured_fields[reticulum_wrapper.FIELD_TELEMETRY_STREAM]
# Stream should be a list of entries
self.assertIsInstance(stream_data, list)
self.assertEqual(len(stream_data), 1)
# Each entry should have [source_hash, timestamp, packed_telemetry, appearance]
entry = stream_data[0]
self.assertEqual(len(entry), 4)
self.assertEqual(entry[0], bytes.fromhex("a" * 32))
self.assertEqual(entry[1], 1703980800)
self.assertEqual(entry[2], b'test_data')
self.assertEqual(entry[3], ['icon', b'\xff'])
def test_handles_empty_collected_telemetry(self):
"""Should handle case with no collected telemetry gracefully."""
mock_identity = MagicMock()
requester_hash = bytes.fromhex("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4")
# No telemetry stored
self.assertEqual(len(self.wrapper.collected_telemetry), 0)
# Should not raise
self.wrapper._send_telemetry_stream_response(requester_hash, mock_identity, None)
# Should still send (empty stream)
self.wrapper.router.handle_outbound.assert_called_once()
def test_cleans_up_expired_before_sending(self):
"""Should call _cleanup_expired_telemetry before sending."""
current_time = time.time()
# Store an expired entry
self.wrapper.telemetry_retention_seconds = 1 # 1 second retention
self.wrapper.collected_telemetry["expired" + "0" * 25] = {
'timestamp': 1703980800,
'packed_telemetry': b'expired_data',
'appearance': None,
'received_at': current_time - 100, # 100 seconds ago, well past retention
}
mock_identity = MagicMock()
requester_hash = bytes.fromhex("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4")
self.wrapper._send_telemetry_stream_response(requester_hash, mock_identity, None)
# The expired entry should have been cleaned up
self.assertNotIn("expired" + "0" * 25, self.wrapper.collected_telemetry)
class TestSendTelemetryRequest(unittest.TestCase):
"""Test send_telemetry_request method."""
def setUp(self):
"""Set up test fixtures."""
self.temp_dir = tempfile.mkdtemp()
self.wrapper = ReticulumWrapper(self.temp_dir)
self.wrapper.initialized = True
# Set up mock router and destination
self.wrapper.router = MagicMock()
self.wrapper.local_lxmf_destination = MagicMock()
self.wrapper.local_lxmf_destination.hexhash = "a" * 32
# Ensure RNS and LXMF are properly mocked in reticulum_wrapper module
reticulum_wrapper.RNS = MagicMock()
reticulum_wrapper.LXMF = MagicMock()
def tearDown(self):
"""Clean up test fixtures."""
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_returns_error_when_not_initialized(self):
"""Should return error when wrapper is not initialized."""
self.wrapper.initialized = False
# send_telemetry_request takes bytes, not hex strings
dest_hash = bytes.fromhex("b" * 32)
source_key = bytes.fromhex("c" * 64)
result = self.wrapper.send_telemetry_request(
dest_hash=dest_hash,
source_identity_private_key=source_key,
timebase=None,
is_collector_request=True,
)
self.assertFalse(result.get('success', True))
def test_returns_error_when_router_not_initialized(self):
"""Should return error when router is not initialized."""
self.wrapper.router = None
dest_hash = bytes.fromhex("b" * 32)
source_key = bytes.fromhex("c" * 64)
result = self.wrapper.send_telemetry_request(
dest_hash=dest_hash,
source_identity_private_key=source_key,
timebase=None,
is_collector_request=True,
)
self.assertFalse(result.get('success', True))
class TestSendTelemetryRequestSuccess(unittest.TestCase):
"""Test send_telemetry_request success paths."""
def setUp(self):
"""Set up test fixtures."""
self.temp_dir = tempfile.mkdtemp()
self.wrapper = ReticulumWrapper(self.temp_dir)
self.wrapper.initialized = True
self.wrapper.router = MagicMock()
self.wrapper.local_lxmf_destination = MagicMock()
self.wrapper.local_lxmf_destination.hexhash = "a" * 32
self.wrapper.identities = {}
# Set up RNS mock
reticulum_wrapper.RNS = MagicMock()
reticulum_wrapper.LXMF = MagicMock()
reticulum_wrapper.RETICULUM_AVAILABLE = True
def tearDown(self):
"""Clean up test fixtures."""
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_successful_send_with_immediate_identity_recall(self):
"""Should successfully send when identity is immediately recalled."""
dest_hash = bytes.fromhex("b" * 32)
source_key = bytes.fromhex("c" * 64)
# Mock identity recall to return identity immediately
mock_recipient_identity = MagicMock()
reticulum_wrapper.RNS.Identity.recall.return_value = mock_recipient_identity
# Mock destination and message creation
mock_destination = MagicMock()
mock_destination.hash = bytes.fromhex("d" * 32)
reticulum_wrapper.RNS.Destination.return_value = mock_destination
mock_message = MagicMock()
mock_message.hash = bytes.fromhex("e" * 32)
reticulum_wrapper.LXMF.LXMessage.return_value = mock_message
result = self.wrapper.send_telemetry_request(
dest_hash=dest_hash,
source_identity_private_key=source_key,
timebase=1234567890.0,
is_collector_request=True,
)
self.assertTrue(result.get('success'))
self.assertIn('message_hash', result)
self.wrapper.router.handle_outbound.assert_called_once()
def test_successful_send_with_cached_identity(self):
"""Should use cached identity from self.identities if recall fails."""
dest_hash = bytes.fromhex("b" * 32)
source_key = bytes.fromhex("c" * 64)
# Mock identity recall to return None (not found)
reticulum_wrapper.RNS.Identity.recall.return_value = None
# Add identity to cache
mock_cached_identity = MagicMock()
self.wrapper.identities["b" * 32] = mock_cached_identity
# Mock destination and message
mock_destination = MagicMock()
mock_destination.hash = bytes.fromhex("d" * 32)
reticulum_wrapper.RNS.Destination.return_value = mock_destination
mock_message = MagicMock()
mock_message.hash = bytes.fromhex("e" * 32)
reticulum_wrapper.LXMF.LXMessage.return_value = mock_message
result = self.wrapper.send_telemetry_request(
dest_hash=dest_hash,
source_identity_private_key=source_key,
timebase=None,
is_collector_request=True,
)
self.assertTrue(result.get('success'))
self.wrapper.router.handle_outbound.assert_called_once()
def test_returns_error_when_local_destination_missing(self):
"""Should raise error when local_lxmf_destination is None."""
dest_hash = bytes.fromhex("b" * 32)
source_key = bytes.fromhex("c" * 64)
self.wrapper.local_lxmf_destination = None
# Mock identity recall to return identity
mock_recipient_identity = MagicMock()
reticulum_wrapper.RNS.Identity.recall.return_value = mock_recipient_identity
result = self.wrapper.send_telemetry_request(
dest_hash=dest_hash,
source_identity_private_key=source_key,
timebase=None,
is_collector_request=True,
)
self.assertFalse(result.get('success'))
self.assertIn('error', result)
def test_returns_error_when_identity_not_resolved(self):
"""Should return error when identity cannot be resolved after path request."""
dest_hash = bytes.fromhex("b" * 32)
source_key = bytes.fromhex("c" * 64)
# Mock identity recall to always return None
reticulum_wrapper.RNS.Identity.recall.return_value = None
# Mock time.sleep to avoid actual delay
with unittest.mock.patch('time.sleep'):
result = self.wrapper.send_telemetry_request(
dest_hash=dest_hash,
source_identity_private_key=source_key,
timebase=None,
is_collector_request=True,
)
self.assertFalse(result.get('success'))
self.assertIn('error', result)
self.assertIn('not known', result['error'])
def test_handles_jarray_conversion(self):
"""Should convert jarray-like iterables to bytes."""
# Simulate jarray (list of ints)
dest_hash_list = list(bytes.fromhex("b" * 32))
source_key_list = list(bytes.fromhex("c" * 64))
# Mock identity recall
mock_recipient_identity = MagicMock()
reticulum_wrapper.RNS.Identity.recall.return_value = mock_recipient_identity
# Mock destination and message
mock_destination = MagicMock()
mock_destination.hash = bytes.fromhex("d" * 32)
reticulum_wrapper.RNS.Destination.return_value = mock_destination
mock_message = MagicMock()
mock_message.hash = bytes.fromhex("e" * 32)
reticulum_wrapper.LXMF.LXMessage.return_value = mock_message
result = self.wrapper.send_telemetry_request(
dest_hash=dest_hash_list,
source_identity_private_key=source_key_list,
timebase=None,
is_collector_request=True,
)
self.assertTrue(result.get('success'))
def test_builds_correct_field_commands_structure(self):
"""Should build FIELD_COMMANDS with correct format."""
dest_hash = bytes.fromhex("b" * 32)
source_key = bytes.fromhex("c" * 64)
# Mock identity recall
mock_recipient_identity = MagicMock()
reticulum_wrapper.RNS.Identity.recall.return_value = mock_recipient_identity
# Mock destination
mock_destination = MagicMock()
mock_destination.hash = bytes.fromhex("d" * 32)
reticulum_wrapper.RNS.Destination.return_value = mock_destination
# Capture fields passed to LXMessage
captured_fields = {}
mock_message = MagicMock()
mock_message.hash = bytes.fromhex("e" * 32)
def capture_lxmessage(*args, **kwargs):
captured_fields.update(kwargs.get('fields', {}))
return mock_message
reticulum_wrapper.LXMF.LXMessage.side_effect = capture_lxmessage
timebase = 1234567890.0
result = self.wrapper.send_telemetry_request(
dest_hash=dest_hash,
source_identity_private_key=source_key,
timebase=timebase,
is_collector_request=True,
)
self.assertTrue(result.get('success'))
# Verify FIELD_COMMANDS structure
self.assertIn(reticulum_wrapper.FIELD_COMMANDS, captured_fields)
commands = captured_fields[reticulum_wrapper.FIELD_COMMANDS]
# Should be list of dicts: [{0x01: [timebase, is_collector_request]}]
self.assertEqual(len(commands), 1)
self.assertIn(reticulum_wrapper.COMMAND_TELEMETRY_REQUEST, commands[0])
args = commands[0][reticulum_wrapper.COMMAND_TELEMETRY_REQUEST]
self.assertEqual(args[0], timebase)
self.assertEqual(args[1], True)
class TestFieldCommandsConstants(unittest.TestCase):
"""Test FIELD_COMMANDS related constants are defined."""
def test_field_commands_constant_exists(self):
"""FIELD_COMMANDS constant should be defined."""
self.assertTrue(hasattr(reticulum_wrapper, 'FIELD_COMMANDS'))
self.assertEqual(reticulum_wrapper.FIELD_COMMANDS, 0x09)
def test_command_telemetry_request_constant_exists(self):
"""COMMAND_TELEMETRY_REQUEST constant should be defined."""
self.assertTrue(hasattr(reticulum_wrapper, 'COMMAND_TELEMETRY_REQUEST'))
self.assertEqual(reticulum_wrapper.COMMAND_TELEMETRY_REQUEST, 0x01)
def test_field_telemetry_stream_constant_exists(self):
"""FIELD_TELEMETRY_STREAM constant should be defined."""
self.assertTrue(hasattr(reticulum_wrapper, 'FIELD_TELEMETRY_STREAM'))
self.assertEqual(reticulum_wrapper.FIELD_TELEMETRY_STREAM, 0x03)
def test_field_telemetry_constant_exists(self):
"""FIELD_TELEMETRY constant should be defined."""
self.assertTrue(hasattr(reticulum_wrapper, 'FIELD_TELEMETRY'))
self.assertEqual(reticulum_wrapper.FIELD_TELEMETRY, 0x02)
class TestOnLxmfDeliveryFieldCommands(unittest.TestCase):
"""Test _on_lxmf_delivery handling of FIELD_COMMANDS."""
def setUp(self):
"""Set up test fixtures."""
self.temp_dir = tempfile.mkdtemp()
self.wrapper = ReticulumWrapper(self.temp_dir)
self.wrapper.telemetry_collector_enabled = True
self.wrapper.telemetry_retention_seconds = 86400
self.wrapper.router = MagicMock()
self.wrapper.local_lxmf_destination = MagicMock()
self.wrapper.collected_telemetry = {}
self.wrapper.kotlin_message_callback = None
# Set up RNS mock
reticulum_wrapper.RNS = MagicMock()
reticulum_wrapper.LXMF = MagicMock()
def tearDown(self):
"""Clean up test fixtures."""
shutil.rmtree(self.temp_dir, ignore_errors=True)
def _create_mock_lxmf_message(self, fields=None, source_hash=None):
"""Create a mock LXMF message for testing."""
mock_message = MagicMock()
mock_message.fields = fields or {}
mock_message.source_hash = source_hash or bytes.fromhex("a" * 32)
mock_message.content = b""
mock_message.title = ""
mock_message.timestamp = time.time()
mock_message.signature_validated = True
mock_message.unverified_reason = None
return mock_message
def test_handles_field_commands_with_telemetry_request(self):
"""Should handle FIELD_COMMANDS with telemetry request when collector enabled."""
# Create mock message with FIELD_COMMANDS
commands = [{reticulum_wrapper.COMMAND_TELEMETRY_REQUEST: [1234567890.0, True]}]
fields = {reticulum_wrapper.FIELD_COMMANDS: commands}
mock_message = self._create_mock_lxmf_message(fields=fields)
# Add requester to allowed list (source_hash is "a" * 32)
self.wrapper.telemetry_allowed_requesters = {"a" * 32}
# Mock identity recall to return an identity immediately
mock_identity = MagicMock()
reticulum_wrapper.RNS.Identity.recall.return_value = mock_identity
# Store some telemetry to send
self.wrapper.collected_telemetry["b" * 32] = {
'timestamp': 1703980800,
'packed_telemetry': b'test_data',
'appearance': None,
'received_at': time.time(),
}
# Spy on _send_telemetry_stream_response
self.wrapper._send_telemetry_stream_response = MagicMock()
# Call delivery handler
self.wrapper._on_lxmf_delivery(mock_message)
# Verify _send_telemetry_stream_response was called
self.wrapper._send_telemetry_stream_response.assert_called_once()
def test_skips_field_commands_when_collector_disabled(self):
"""Should not handle FIELD_COMMANDS when collector is disabled."""
self.wrapper.telemetry_collector_enabled = False
commands = [{reticulum_wrapper.COMMAND_TELEMETRY_REQUEST: [None, True]}]
fields = {reticulum_wrapper.FIELD_COMMANDS: commands}
mock_message = self._create_mock_lxmf_message(fields=fields)
# Spy on _send_telemetry_stream_response
self.wrapper._send_telemetry_stream_response = MagicMock()
# Call delivery handler
self.wrapper._on_lxmf_delivery(mock_message)
# Verify _send_telemetry_stream_response was NOT called
self.wrapper._send_telemetry_stream_response.assert_not_called()
def test_handles_field_commands_with_identity_retry(self):
"""Should retry sending when identity is not immediately recalled."""
commands = [{reticulum_wrapper.COMMAND_TELEMETRY_REQUEST: [0, True]}]
fields = {reticulum_wrapper.FIELD_COMMANDS: commands}
mock_message = self._create_mock_lxmf_message(fields=fields)
# Add requester to allowed list (source_hash is "a" * 32)
self.wrapper.telemetry_allowed_requesters = {"a" * 32}
# First recall returns None, then returns identity on retry
mock_identity = MagicMock()
reticulum_wrapper.RNS.Identity.recall.side_effect = [None, mock_identity]
# Spy on _send_telemetry_stream_response
self.wrapper._send_telemetry_stream_response = MagicMock()
# Patch threading and time.sleep to execute immediately
with unittest.mock.patch('threading.Thread') as mock_thread:
# Capture and execute the thread target immediately
def run_target(*args, **kwargs):
target = kwargs.get('target')
if target:
# Patch time.sleep inside the target
with unittest.mock.patch('time.sleep'):
target()
return MagicMock()
mock_thread.side_effect = run_target
self.wrapper._on_lxmf_delivery(mock_message)
# Verify _send_telemetry_stream_response was called after retry
self.wrapper._send_telemetry_stream_response.assert_called()
def test_ignores_non_collector_request(self):
"""Should ignore telemetry requests with is_collector_request=False."""
commands = [{reticulum_wrapper.COMMAND_TELEMETRY_REQUEST: [0, False]}]
fields = {reticulum_wrapper.FIELD_COMMANDS: commands}
mock_message = self._create_mock_lxmf_message(fields=fields)
# Spy on _send_telemetry_stream_response
self.wrapper._send_telemetry_stream_response = MagicMock()
self.wrapper._on_lxmf_delivery(mock_message)
# Should not be called since is_collector_request is False
self.wrapper._send_telemetry_stream_response.assert_not_called()
def test_blocks_request_from_non_allowed_requester(self):
"""Should block telemetry requests from requesters not in allowed list."""
commands = [{reticulum_wrapper.COMMAND_TELEMETRY_REQUEST: [0, True]}]
fields = {reticulum_wrapper.FIELD_COMMANDS: commands}
mock_message = self._create_mock_lxmf_message(fields=fields)
# Set allowed list to a different hash (not "a" * 32)
self.wrapper.telemetry_allowed_requesters = {"b" * 32}
# Store some telemetry to send
self.wrapper.collected_telemetry["c" * 32] = {
'timestamp': 1703980800,
'packed_telemetry': b'test_data',
'appearance': None,
'received_at': time.time(),
}
# Spy on _send_telemetry_stream_response
self.wrapper._send_telemetry_stream_response = MagicMock()
self.wrapper._on_lxmf_delivery(mock_message)
# Should NOT be called since requester ("a" * 32) is not in allowed list
self.wrapper._send_telemetry_stream_response.assert_not_called()
def test_blocks_request_when_allowed_list_empty(self):
"""Should block all telemetry requests when allowed list is empty."""
commands = [{reticulum_wrapper.COMMAND_TELEMETRY_REQUEST: [0, True]}]
fields = {reticulum_wrapper.FIELD_COMMANDS: commands}
mock_message = self._create_mock_lxmf_message(fields=fields)
# Empty allowed list = block all
self.wrapper.telemetry_allowed_requesters = set()
# Store some telemetry to send
self.wrapper.collected_telemetry["b" * 32] = {
'timestamp': 1703980800,
'packed_telemetry': b'test_data',
'appearance': None,
'received_at': time.time(),
}
# Spy on _send_telemetry_stream_response
self.wrapper._send_telemetry_stream_response = MagicMock()
self.wrapper._on_lxmf_delivery(mock_message)
# Should NOT be called since allowed list is empty (blocks all)
self.wrapper._send_telemetry_stream_response.assert_not_called()
class TestTelemetryStreamFiltering(unittest.TestCase):
"""Test telemetry stream filtering logic in detail."""
def setUp(self):
"""Set up test fixtures."""
self.temp_dir = tempfile.mkdtemp()
self.wrapper = ReticulumWrapper(self.temp_dir)
self.wrapper.telemetry_collector_enabled = True
self.wrapper.telemetry_retention_seconds = 86400
self.wrapper.router = MagicMock()
self.wrapper.local_lxmf_destination = MagicMock()
# Ensure RNS and LXMF are properly mocked in reticulum_wrapper module
reticulum_wrapper.RNS = MagicMock()
reticulum_wrapper.LXMF = MagicMock()
def tearDown(self):
"""Clean up test fixtures."""
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_timebase_filtering_uses_received_at_not_timestamp(self):
"""Filtering should use received_at, not the telemetry timestamp."""
current_time = time.time()
# Entry with old telemetry timestamp but recent received_at
# This simulates receiving telemetry that was generated a while ago
self.wrapper.collected_telemetry["a" * 32] = {
'timestamp': 1000, # Very old telemetry timestamp
'packed_telemetry': b'test_data',
'appearance': None,
'received_at': current_time, # But received just now
}
mock_identity = MagicMock()
requester_hash = bytes.fromhex("b" * 32)
# Capture the fields passed to LXMessage using side_effect
captured_fields = {}
original_mock = MagicMock()
original_mock.DIRECT = 2
def capture_lxmessage(*args, **kwargs):
captured_fields.update(kwargs.get('fields', {}))
mock_msg = MagicMock()
mock_msg.fields = kwargs.get('fields', {})
return mock_msg
original_mock.side_effect = capture_lxmessage
reticulum_wrapper.LXMF.LXMessage = original_mock
# Timebase is 1 second ago - entry should be included because received_at is recent
timebase = current_time - 1
self.wrapper._send_telemetry_stream_response(requester_hash, mock_identity, timebase)
# Get the stream data from captured fields
stream_data = captured_fields[reticulum_wrapper.FIELD_TELEMETRY_STREAM]
# Entry should be included despite old telemetry timestamp
self.assertEqual(len(stream_data), 1)
def test_multiple_entries_filtered_correctly(self):
"""Multiple entries should be filtered based on their received_at times."""
current_time = time.time()
# Entry 1: received 10 seconds ago
self.wrapper.collected_telemetry["1" * 32] = {
'timestamp': 1703980800,
'packed_telemetry': b'data1',
'appearance': None,
'received_at': current_time - 10,
}
# Entry 2: received 5 seconds ago
self.wrapper.collected_telemetry["2" * 32] = {
'timestamp': 1703980860,
'packed_telemetry': b'data2',
'appearance': None,
'received_at': current_time - 5,
}
# Entry 3: received just now
self.wrapper.collected_telemetry["3" * 32] = {
'timestamp': 1703980920,
'packed_telemetry': b'data3',
'appearance': None,
'received_at': current_time,
}
mock_identity = MagicMock()
requester_hash = bytes.fromhex("b" * 32)
# Capture the fields passed to LXMessage using side_effect
captured_fields = {}
original_mock = MagicMock()
original_mock.DIRECT = 2
def capture_lxmessage(*args, **kwargs):
captured_fields.update(kwargs.get('fields', {}))
mock_msg = MagicMock()
mock_msg.fields = kwargs.get('fields', {})
return mock_msg
original_mock.side_effect = capture_lxmessage
reticulum_wrapper.LXMF.LXMessage = original_mock
# Timebase is 7 seconds ago - should include entries 2 and 3
timebase = current_time - 7
self.wrapper._send_telemetry_stream_response(requester_hash, mock_identity, timebase)
stream_data = captured_fields[reticulum_wrapper.FIELD_TELEMETRY_STREAM]
# Should have 2 entries (entry 2 and 3)
self.assertEqual(len(stream_data), 2)
# Verify the correct entries are included (by their packed_telemetry)
packed_data = [entry[2] for entry in stream_data]
self.assertIn(b'data2', packed_data)
self.assertIn(b'data3', packed_data)
self.assertNotIn(b'data1', packed_data)
if __name__ == '__main__':
unittest.main()