"""Tests for MeshCore connection functions.""" from unittest.mock import AsyncMock, MagicMock import pytest from meshmon.meshcore_client import ( _acquire_lock_async, auto_detect_serial_port, connect_from_env, connect_with_lock, ) def _reset_config(): import meshmon.env meshmon.env._config = None return meshmon.env.get_config() class TestAutoDetectSerialPort: """Tests for auto_detect_serial_port function.""" def test_prefers_acm_devices(self, mock_serial_port): """Prefers /dev/ttyACM* devices.""" mock_port_acm = MagicMock() mock_port_acm.device = "/dev/ttyACM0" mock_port_acm.description = "ACM Device" mock_port_usb = MagicMock() mock_port_usb.device = "/dev/ttyUSB0" mock_port_usb.description = "USB Device" mock_serial_port.tools.list_ports.comports.return_value = [mock_port_usb, mock_port_acm] result = auto_detect_serial_port() assert result == "/dev/ttyACM0" def test_falls_back_to_usb(self, mock_serial_port): """Falls back to /dev/ttyUSB* if no ACM.""" mock_port = MagicMock() mock_port.device = "/dev/ttyUSB0" mock_port.description = "USB Device" mock_serial_port.tools.list_ports.comports.return_value = [mock_port] result = auto_detect_serial_port() assert result == "/dev/ttyUSB0" def test_falls_back_to_first_available(self, mock_serial_port): """Falls back to first available port.""" mock_port = MagicMock() mock_port.device = "/dev/ttyS0" mock_port.description = "Serial Port" mock_serial_port.tools.list_ports.comports.return_value = [mock_port] result = auto_detect_serial_port() assert result == "/dev/ttyS0" def test_returns_none_when_no_ports(self, mock_serial_port): """Returns None when no ports available.""" mock_serial_port.tools.list_ports.comports.return_value = [] result = auto_detect_serial_port() assert result is None def test_handles_import_error(self, monkeypatch): """Returns None when pyserial not installed.""" import builtins real_import = builtins.__import__ def mock_import(name, *args, **kwargs): if name in {"serial", "serial.tools.list_ports"}: raise ImportError("No module named 'serial'") return real_import(name, *args, **kwargs) monkeypatch.setattr(builtins, "__import__", mock_import) assert auto_detect_serial_port() is None class TestConnectFromEnv: """Tests for connect_from_env function.""" @pytest.mark.asyncio async def test_returns_none_when_meshcore_unavailable(self, configured_env, monkeypatch): """Returns None when meshcore library not available.""" monkeypatch.setattr("meshmon.meshcore_client.MESHCORE_AVAILABLE", False) result = await connect_from_env() assert result is None @pytest.mark.asyncio async def test_serial_connection(self, configured_env, monkeypatch, mock_serial_port): """Connects via serial when configured.""" monkeypatch.setattr("meshmon.meshcore_client.MESHCORE_AVAILABLE", True) monkeypatch.setenv("MESH_TRANSPORT", "serial") monkeypatch.setenv("MESH_SERIAL_PORT", "/dev/ttyACM0") monkeypatch.setenv("MESH_SERIAL_BAUD", "57600") monkeypatch.setenv("MESH_DEBUG", "1") _reset_config() mock_client = MagicMock() mock_create = AsyncMock(return_value=mock_client) mock_meshcore = MagicMock() mock_meshcore.create_serial = mock_create monkeypatch.setattr("meshmon.meshcore_client.MeshCore", mock_meshcore) result = await connect_from_env() assert result is mock_client mock_create.assert_called_once_with("/dev/ttyACM0", 57600, debug=True) @pytest.mark.asyncio async def test_tcp_connection(self, configured_env, monkeypatch): """Connects via TCP when configured.""" monkeypatch.setattr("meshmon.meshcore_client.MESHCORE_AVAILABLE", True) monkeypatch.setenv("MESH_TRANSPORT", "tcp") monkeypatch.setenv("MESH_TCP_HOST", "localhost") monkeypatch.setenv("MESH_TCP_PORT", "4403") _reset_config() mock_client = MagicMock() mock_create = AsyncMock(return_value=mock_client) mock_meshcore = MagicMock() mock_meshcore.create_tcp = mock_create monkeypatch.setattr("meshmon.meshcore_client.MeshCore", mock_meshcore) result = await connect_from_env() assert result is mock_client mock_create.assert_called_once_with("localhost", 4403) @pytest.mark.asyncio async def test_unknown_transport(self, configured_env, monkeypatch): """Returns None for unknown transport.""" monkeypatch.setattr("meshmon.meshcore_client.MESHCORE_AVAILABLE", True) monkeypatch.setenv("MESH_TRANSPORT", "unknown") _reset_config() result = await connect_from_env() assert result is None @pytest.mark.asyncio async def test_handles_connection_error(self, configured_env, monkeypatch, mock_serial_port): """Returns None on connection error.""" monkeypatch.setattr("meshmon.meshcore_client.MESHCORE_AVAILABLE", True) monkeypatch.setenv("MESH_TRANSPORT", "serial") monkeypatch.setenv("MESH_SERIAL_PORT", "/dev/ttyACM0") _reset_config() mock_create = AsyncMock(side_effect=Exception("Connection failed")) mock_meshcore = MagicMock() mock_meshcore.create_serial = mock_create monkeypatch.setattr("meshmon.meshcore_client.MeshCore", mock_meshcore) result = await connect_from_env() assert result is None mock_create.assert_called_once() @pytest.mark.asyncio async def test_ble_connection(self, configured_env, monkeypatch): """Connects via BLE when configured.""" monkeypatch.setattr("meshmon.meshcore_client.MESHCORE_AVAILABLE", True) monkeypatch.setenv("MESH_TRANSPORT", "ble") monkeypatch.setenv("MESH_BLE_ADDR", "AA:BB:CC:DD:EE:FF") monkeypatch.setenv("MESH_BLE_PIN", "123456") _reset_config() mock_client = MagicMock() mock_create = AsyncMock(return_value=mock_client) mock_meshcore = MagicMock() mock_meshcore.create_ble = mock_create monkeypatch.setattr("meshmon.meshcore_client.MeshCore", mock_meshcore) result = await connect_from_env() assert result is mock_client mock_create.assert_called_once_with("AA:BB:CC:DD:EE:FF", pin="123456") @pytest.mark.asyncio async def test_ble_missing_address(self, configured_env, monkeypatch): """Returns None when BLE address not configured.""" monkeypatch.setattr("meshmon.meshcore_client.MESHCORE_AVAILABLE", True) monkeypatch.setenv("MESH_TRANSPORT", "ble") # Don't set MESH_BLE_ADDR _reset_config() result = await connect_from_env() assert result is None @pytest.mark.asyncio async def test_serial_auto_detect(self, configured_env, monkeypatch, mock_serial_port): """Auto-detects serial port when not configured.""" monkeypatch.setattr("meshmon.meshcore_client.MESHCORE_AVAILABLE", True) monkeypatch.setenv("MESH_TRANSPORT", "serial") # Don't set MESH_SERIAL_PORT to trigger auto-detection _reset_config() # Set up mock port detection mock_port = MagicMock() mock_port.device = "/dev/ttyACM0" mock_serial_port.tools.list_ports.comports.return_value = [mock_port] mock_client = MagicMock() mock_create = AsyncMock(return_value=mock_client) mock_meshcore = MagicMock() mock_meshcore.create_serial = mock_create monkeypatch.setattr("meshmon.meshcore_client.MeshCore", mock_meshcore) result = await connect_from_env() assert result is mock_client mock_create.assert_called_once_with("/dev/ttyACM0", 115200, debug=False) @pytest.mark.asyncio async def test_serial_auto_detect_fails(self, configured_env, monkeypatch, mock_serial_port): """Returns None when serial auto-detection fails.""" monkeypatch.setattr("meshmon.meshcore_client.MESHCORE_AVAILABLE", True) monkeypatch.setenv("MESH_TRANSPORT", "serial") # Don't set MESH_SERIAL_PORT to trigger auto-detection _reset_config() # No ports available mock_serial_port.tools.list_ports.comports.return_value = [] result = await connect_from_env() assert result is None class TestConnectWithLock: """Tests for connect_with_lock context manager.""" @pytest.mark.asyncio async def test_yields_client_on_success(self, configured_env, monkeypatch, mock_serial_port): """Yields connected client on success.""" monkeypatch.setattr("meshmon.meshcore_client.MESHCORE_AVAILABLE", True) monkeypatch.setenv("MESH_TRANSPORT", "serial") monkeypatch.setenv("MESH_SERIAL_PORT", "/dev/ttyACM0") _reset_config() mock_client = MagicMock() mock_client.disconnect = AsyncMock() mock_create = AsyncMock(return_value=mock_client) mock_meshcore = MagicMock() mock_meshcore.create_serial = mock_create monkeypatch.setattr("meshmon.meshcore_client.MeshCore", mock_meshcore) async with connect_with_lock() as mc: assert mc is mock_client # Should disconnect when exiting context mock_client.disconnect.assert_called_once() @pytest.mark.asyncio async def test_yields_none_on_connection_failure(self, configured_env, monkeypatch, mock_serial_port): """Yields None when connection fails.""" monkeypatch.setattr("meshmon.meshcore_client.MESHCORE_AVAILABLE", True) monkeypatch.setenv("MESH_TRANSPORT", "serial") monkeypatch.setenv("MESH_SERIAL_PORT", "/dev/ttyACM0") _reset_config() mock_create = AsyncMock(side_effect=Exception("Connection failed")) mock_meshcore = MagicMock() mock_meshcore.create_serial = mock_create monkeypatch.setattr("meshmon.meshcore_client.MeshCore", mock_meshcore) async with connect_with_lock() as mc: assert mc is None @pytest.mark.asyncio async def test_acquires_lock_for_serial(self, configured_env, monkeypatch, mock_serial_port): """Acquires lock file for serial transport.""" monkeypatch.setattr("meshmon.meshcore_client.MESHCORE_AVAILABLE", True) monkeypatch.setenv("MESH_TRANSPORT", "serial") monkeypatch.setenv("MESH_SERIAL_PORT", "/dev/ttyACM0") cfg = _reset_config() mock_client = MagicMock() mock_client.disconnect = AsyncMock() mock_create = AsyncMock(return_value=mock_client) mock_meshcore = MagicMock() mock_meshcore.create_serial = mock_create monkeypatch.setattr("meshmon.meshcore_client.MeshCore", mock_meshcore) async with connect_with_lock(): # Lock file should exist while connected lock_path = cfg.state_dir / "serial.lock" assert lock_path.exists() @pytest.mark.asyncio async def test_no_lock_for_tcp(self, configured_env, monkeypatch): """Does not acquire lock for TCP transport.""" monkeypatch.setattr("meshmon.meshcore_client.MESHCORE_AVAILABLE", True) monkeypatch.setenv("MESH_TRANSPORT", "tcp") monkeypatch.setenv("MESH_TCP_HOST", "localhost") monkeypatch.setenv("MESH_TCP_PORT", "4403") cfg = _reset_config() mock_client = MagicMock() mock_client.disconnect = AsyncMock() mock_create = AsyncMock(return_value=mock_client) mock_meshcore = MagicMock() mock_meshcore.create_tcp = mock_create monkeypatch.setattr("meshmon.meshcore_client.MeshCore", mock_meshcore) lock_path = cfg.state_dir / "serial.lock" async with connect_with_lock(): # Lock file should not exist for TCP assert not lock_path.exists() @pytest.mark.asyncio async def test_handles_disconnect_error(self, configured_env, monkeypatch, mock_serial_port): """Handles disconnect error gracefully.""" monkeypatch.setattr("meshmon.meshcore_client.MESHCORE_AVAILABLE", True) monkeypatch.setenv("MESH_TRANSPORT", "serial") monkeypatch.setenv("MESH_SERIAL_PORT", "/dev/ttyACM0") _reset_config() mock_client = MagicMock() mock_client.disconnect = AsyncMock(side_effect=Exception("Disconnect error")) mock_create = AsyncMock(return_value=mock_client) mock_meshcore = MagicMock() mock_meshcore.create_serial = mock_create monkeypatch.setattr("meshmon.meshcore_client.MeshCore", mock_meshcore) # Should not raise even when disconnect fails async with connect_with_lock() as mc: assert mc is mock_client # Disconnect was still called mock_client.disconnect.assert_called_once() @pytest.mark.asyncio async def test_releases_lock_on_failure(self, configured_env, monkeypatch, mock_serial_port): """Releases lock even when connection fails.""" monkeypatch.setattr("meshmon.meshcore_client.MESHCORE_AVAILABLE", True) monkeypatch.setenv("MESH_TRANSPORT", "serial") monkeypatch.setenv("MESH_SERIAL_PORT", "/dev/ttyACM0") cfg = _reset_config() mock_create = AsyncMock(side_effect=Exception("Connection failed")) mock_meshcore = MagicMock() mock_meshcore.create_serial = mock_create monkeypatch.setattr("meshmon.meshcore_client.MeshCore", mock_meshcore) async with connect_with_lock() as mc: assert mc is None # Lock should be released after exiting context # We can verify by acquiring it again without timeout lock_path = cfg.state_dir / "serial.lock" if lock_path.exists(): import fcntl with open(lock_path, "a") as f: fcntl.flock(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) class TestAcquireLockAsync: """Tests for _acquire_lock_async function.""" @pytest.mark.asyncio async def test_acquires_lock_immediately(self, tmp_path): """Acquires lock when not held by others.""" lock_file = tmp_path / "test.lock" with open(lock_file, "w") as f: await _acquire_lock_async(f, timeout=1.0) # If we get here, lock was acquired @pytest.mark.asyncio async def test_times_out_when_locked(self, tmp_path): """Times out when lock held by another.""" import fcntl lock_file = tmp_path / "test.lock" # Hold the lock in this process holder = open(lock_file, "w") # noqa: SIM115 - must stay open for lock fcntl.flock(holder.fileno(), fcntl.LOCK_EX) try: # Try to acquire with different file handle with open(lock_file, "a") as f, pytest.raises(TimeoutError): await _acquire_lock_async(f, timeout=0.2, poll_interval=0.05) finally: holder.close() @pytest.mark.asyncio async def test_waits_for_lock_release(self, tmp_path): """Waits and acquires when lock released.""" import asyncio import fcntl lock_file = tmp_path / "test.lock" holder = open(lock_file, "w") # noqa: SIM115 - must stay open for lock fcntl.flock(holder.fileno(), fcntl.LOCK_EX) async def release_later(): await asyncio.sleep(0.1) holder.close() # Start release task release_task = asyncio.create_task(release_later()) # Try to acquire - should succeed after release with open(lock_file, "a") as f: await _acquire_lock_async(f, timeout=2.0, poll_interval=0.05) await release_task