"""Tests for Meshtastic integration. Tests cover: - MeshtasticClient initialization and state management - PSK parsing (various formats) - Message callback handling - Route endpoints (mocked) - Graceful degradation when SDK not installed """ import json from datetime import datetime, timezone from unittest.mock import Mock, patch import pytest # ============================================================================= # Utility Module Tests # ============================================================================= class TestMeshtasticAvailability: """Tests for SDK availability checks.""" def test_is_meshtastic_available_returns_bool(self): """is_meshtastic_available should return a boolean.""" from utils.meshtastic import is_meshtastic_available result = is_meshtastic_available() assert isinstance(result, bool) class TestMeshtasticMessage: """Tests for MeshtasticMessage dataclass.""" def test_message_to_dict(self): """MeshtasticMessage should convert to dictionary.""" from utils.meshtastic import MeshtasticMessage msg = MeshtasticMessage( from_id='!a1b2c3d4', to_id='^all', message='Hello mesh!', portnum='TEXT_MESSAGE_APP', channel=0, rssi=-95, snr=-3.5, hop_limit=3, timestamp=datetime(2026, 1, 27, 12, 0, 0, tzinfo=timezone.utc), ) d = msg.to_dict() assert d['type'] == 'meshtastic' assert d['from'] == '!a1b2c3d4' assert d['to'] == '^all' assert d['message'] == 'Hello mesh!' assert d['portnum'] == 'TEXT_MESSAGE_APP' assert d['channel'] == 0 assert d['rssi'] == -95 assert d['snr'] == -3.5 assert d['hop_limit'] == 3 assert '2026-01-27' in d['timestamp'] def test_message_with_none_values(self): """MeshtasticMessage should handle None values.""" from utils.meshtastic import MeshtasticMessage msg = MeshtasticMessage( from_id='!00000001', to_id='!00000002', message=None, portnum='POSITION_APP', channel=1, rssi=None, snr=None, hop_limit=None, timestamp=datetime.now(timezone.utc), ) d = msg.to_dict() assert d['message'] is None assert d['rssi'] is None assert d['snr'] is None class TestChannelConfig: """Tests for ChannelConfig dataclass.""" def test_channel_to_dict_hides_psk(self): """ChannelConfig.to_dict should not expose raw PSK.""" from utils.meshtastic import ChannelConfig config = ChannelConfig( index=0, name='Primary', psk=b'\x01\x02\x03\x04' * 8, # 32-byte key role=1, # PRIMARY ) d = config.to_dict() assert 'psk' not in d # Raw PSK should not be in dict assert d['index'] == 0 assert d['name'] == 'Primary' assert d['role'] == 'PRIMARY' assert d['encrypted'] is True assert d['key_type'] == 'AES-256' def test_channel_default_key_detection(self): """ChannelConfig should detect default key.""" from utils.meshtastic import ChannelConfig # Default key is single byte 0x01 config = ChannelConfig(index=0, name='Test', psk=b'\x01', role=1) d = config.to_dict() assert d['is_default_key'] is True assert d['key_type'] == 'default' def test_channel_aes128_detection(self): """ChannelConfig should detect AES-128 key.""" from utils.meshtastic import ChannelConfig config = ChannelConfig(index=0, name='Test', psk=b'0' * 16, role=1) d = config.to_dict() assert d['key_type'] == 'AES-128' assert d['encrypted'] is True def test_channel_no_encryption(self): """ChannelConfig should detect no encryption.""" from utils.meshtastic import ChannelConfig config = ChannelConfig(index=0, name='Test', psk=b'', role=1) d = config.to_dict() assert d['key_type'] == 'none' assert d['encrypted'] is False class TestPSKParsing: """Tests for PSK format parsing.""" def test_parse_psk_none(self): """Should parse 'none' as empty bytes.""" from utils.meshtastic import MeshtasticClient client = MeshtasticClient() result = client._parse_psk('none') assert result == b'' def test_parse_psk_default(self): """Should parse 'default' as single byte.""" from utils.meshtastic import MeshtasticClient client = MeshtasticClient() result = client._parse_psk('default') assert result == b'\x01' def test_parse_psk_random(self): """Should generate 32 random bytes for 'random'.""" from utils.meshtastic import MeshtasticClient client = MeshtasticClient() result = client._parse_psk('random') assert len(result) == 32 # Verify it's actually random (two calls should differ) result2 = client._parse_psk('random') assert result != result2 def test_parse_psk_base64(self): """Should decode base64 PSK.""" import base64 from utils.meshtastic import MeshtasticClient client = MeshtasticClient() # 32-byte key encoded as base64 key = b'A' * 32 encoded = 'base64:' + base64.b64encode(key).decode() result = client._parse_psk(encoded) assert result == key def test_parse_psk_hex(self): """Should decode hex PSK.""" from utils.meshtastic import MeshtasticClient client = MeshtasticClient() # 16-byte key as hex result = client._parse_psk('0x' + '41' * 16) assert result == b'A' * 16 def test_parse_psk_simple_passphrase(self): """Should hash simple passphrase to 32-byte key.""" import hashlib from utils.meshtastic import MeshtasticClient client = MeshtasticClient() result = client._parse_psk('simple:MySecretPassword') expected = hashlib.sha256(b'MySecretPassword').digest() assert result == expected assert len(result) == 32 def test_parse_psk_invalid(self): """Should return None for invalid PSK format.""" from utils.meshtastic import MeshtasticClient client = MeshtasticClient() assert client._parse_psk('base64:!!!invalid!!!') is None assert client._parse_psk('0xZZZZ') is None def test_parse_psk_raw_base64(self): """Should accept raw base64 without prefix.""" import base64 from utils.meshtastic import MeshtasticClient client = MeshtasticClient() key = b'B' * 16 encoded = base64.b64encode(key).decode() result = client._parse_psk(encoded) assert result == key class TestNodeIdFormatting: """Tests for node ID formatting.""" def test_format_regular_node(self): """Should format regular node as hex.""" from utils.meshtastic import MeshtasticClient result = MeshtasticClient._format_node_id(0xDEADBEEF) assert result == '!deadbeef' def test_format_broadcast(self): """Should format broadcast address.""" from utils.meshtastic import MeshtasticClient result = MeshtasticClient._format_node_id(0xFFFFFFFF) assert result == '^all' # ============================================================================= # Route Tests (Mocked) # ============================================================================= class TestMeshtasticRoutes: """Tests for Flask route endpoints.""" @pytest.fixture def app(self): """Create Flask test app.""" from flask import Flask from routes.meshtastic import meshtastic_bp app = Flask(__name__) app.config['TESTING'] = True app.register_blueprint(meshtastic_bp) return app @pytest.fixture def client(self, app): """Create test client.""" return app.test_client() def test_status_sdk_not_installed(self, client): """GET /meshtastic/status should report SDK unavailable.""" with patch('routes.meshtastic.is_meshtastic_available', return_value=False): response = client.get('/meshtastic/status') data = json.loads(response.data) assert response.status_code == 200 assert data['available'] is False assert 'not installed' in data['error'] def test_status_not_connected(self, client): """GET /meshtastic/status should report not running when disconnected.""" with patch('routes.meshtastic.is_meshtastic_available', return_value=True): with patch('routes.meshtastic.get_meshtastic_client', return_value=None): response = client.get('/meshtastic/status') data = json.loads(response.data) assert response.status_code == 200 assert data['available'] is True assert data['running'] is False def test_start_sdk_not_installed(self, client): """POST /meshtastic/start should fail if SDK not installed.""" with patch('routes.meshtastic.is_meshtastic_available', return_value=False): response = client.post('/meshtastic/start') data = json.loads(response.data) assert response.status_code == 400 assert data['status'] == 'error' def test_stop_always_succeeds(self, client): """POST /meshtastic/stop should always succeed.""" with patch('routes.meshtastic.stop_meshtastic'): response = client.post('/meshtastic/stop') data = json.loads(response.data) assert response.status_code == 200 assert data['status'] == 'stopped' def test_channels_not_connected(self, client): """GET /meshtastic/channels should fail if not connected.""" with patch('routes.meshtastic.get_meshtastic_client', return_value=None): response = client.get('/meshtastic/channels') data = json.loads(response.data) assert response.status_code == 400 assert 'Not connected' in data['message'] def test_configure_channel_invalid_index(self, client): """POST /meshtastic/channels/ should reject invalid index.""" mock_client = Mock() mock_client.is_running = True with patch('routes.meshtastic.get_meshtastic_client', return_value=mock_client): response = client.post( '/meshtastic/channels/10', json={'name': 'Test'}, content_type='application/json' ) data = json.loads(response.data) assert response.status_code == 400 assert 'must be 0-7' in data['message'] def test_configure_channel_no_params(self, client): """POST /meshtastic/channels/ should require name or psk.""" mock_client = Mock() mock_client.is_running = True with patch('routes.meshtastic.get_meshtastic_client', return_value=mock_client): response = client.post( '/meshtastic/channels/0', json={}, content_type='application/json' ) data = json.loads(response.data) assert response.status_code == 400 assert 'Must provide' in data['message'] def test_messages_empty(self, client): """GET /meshtastic/messages should return empty list initially.""" with patch('routes.meshtastic._recent_messages', []): response = client.get('/meshtastic/messages') data = json.loads(response.data) assert response.status_code == 200 assert data['status'] == 'ok' assert data['messages'] == [] assert data['count'] == 0 def test_messages_with_limit(self, client): """GET /meshtastic/messages should respect limit param.""" test_messages = [{'id': i} for i in range(10)] with patch('routes.meshtastic._recent_messages', test_messages): response = client.get('/meshtastic/messages?limit=3') data = json.loads(response.data) assert response.status_code == 200 assert len(data['messages']) == 3 # Should return last 3 (most recent) assert data['messages'][0]['id'] == 7 def test_messages_filter_by_channel(self, client): """GET /meshtastic/messages should filter by channel.""" test_messages = [ {'id': 1, 'channel': 0}, {'id': 2, 'channel': 1}, {'id': 3, 'channel': 0}, ] with patch('routes.meshtastic._recent_messages', test_messages): response = client.get('/meshtastic/messages?channel=0') data = json.loads(response.data) assert response.status_code == 200 assert len(data['messages']) == 2 assert all(m['channel'] == 0 for m in data['messages']) def test_stream_endpoint_exists(self, client): """GET /meshtastic/stream should return SSE content type.""" response = client.get('/meshtastic/stream') assert response.content_type == 'text/event-stream' def test_node_not_connected(self, client): """GET /meshtastic/node should fail if not connected.""" with patch('routes.meshtastic.get_meshtastic_client', return_value=None): response = client.get('/meshtastic/node') data = json.loads(response.data) assert response.status_code == 400 assert 'Not connected' in data['message'] # ============================================================================= # Integration Tests (Mocked SDK) # ============================================================================= class TestMeshtasticClientMocked: """Tests for MeshtasticClient with mocked SDK.""" def test_client_init(self): """MeshtasticClient should initialize with default state.""" from utils.meshtastic import MeshtasticClient client = MeshtasticClient() assert client.is_running is False assert client.device_path is None assert client.error is None def test_client_connect_no_sdk(self): """MeshtasticClient.connect should fail gracefully without SDK.""" from utils.meshtastic import MeshtasticClient with patch('utils.meshtastic.HAS_MESHTASTIC', False): client = MeshtasticClient() result = client.connect() assert result is False assert 'not installed' in client.error def test_client_disconnect_idempotent(self): """MeshtasticClient.disconnect should be safe to call multiple times.""" from utils.meshtastic import MeshtasticClient client = MeshtasticClient() # Should not raise even when not connected client.disconnect() client.disconnect() assert client.is_running is False