mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-03 23:42:56 +02:00
Move to multi-connection modality
This commit is contained in:
@@ -14,7 +14,7 @@ export default async function globalSetup(_config: FullConfig) {
|
||||
if (!res.ok) {
|
||||
throw new Error(`Health check returned ${res.status}`);
|
||||
}
|
||||
const health = (await res.json()) as { radio_connected: boolean; serial_port: string | null };
|
||||
const health = (await res.json()) as { radio_connected: boolean; connection_info: string | null };
|
||||
|
||||
if (!health.radio_connected) {
|
||||
throw new Error(
|
||||
@@ -23,7 +23,7 @@ export default async function globalSetup(_config: FullConfig) {
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Radio connected on ${health.serial_port}`);
|
||||
console.log(`Radio connected on ${health.connection_info}`);
|
||||
return;
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
|
||||
@@ -21,7 +21,7 @@ async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
|
||||
export interface HealthStatus {
|
||||
radio_connected: boolean;
|
||||
serial_port: string | null;
|
||||
connection_info: string | null;
|
||||
}
|
||||
|
||||
export function getHealth(): Promise<HealthStatus> {
|
||||
|
||||
+8
-8
@@ -18,7 +18,7 @@ class TestHealthEndpoint:
|
||||
|
||||
with patch("app.routers.health.radio_manager") as mock_rm:
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.port = "/dev/ttyUSB0"
|
||||
mock_rm.connection_info = "Serial: /dev/ttyUSB0"
|
||||
|
||||
from app.main import app
|
||||
|
||||
@@ -29,7 +29,7 @@ class TestHealthEndpoint:
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["radio_connected"] is True
|
||||
assert data["serial_port"] == "/dev/ttyUSB0"
|
||||
assert data["connection_info"] == "Serial: /dev/ttyUSB0"
|
||||
|
||||
def test_health_disconnected_state(self):
|
||||
"""Health endpoint reflects disconnected radio."""
|
||||
@@ -37,7 +37,7 @@ class TestHealthEndpoint:
|
||||
|
||||
with patch("app.routers.health.radio_manager") as mock_rm:
|
||||
mock_rm.is_connected = False
|
||||
mock_rm.port = None
|
||||
mock_rm.connection_info = None
|
||||
|
||||
from app.main import app
|
||||
|
||||
@@ -48,7 +48,7 @@ class TestHealthEndpoint:
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["radio_connected"] is False
|
||||
assert data["serial_port"] is None
|
||||
assert data["connection_info"] is None
|
||||
|
||||
|
||||
class TestMessagesEndpoint:
|
||||
@@ -1252,7 +1252,7 @@ class TestHealthEndpointDatabaseSize:
|
||||
patch("app.routers.health.os.path.getsize") as mock_getsize,
|
||||
):
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.port = "/dev/ttyUSB0"
|
||||
mock_rm.connection_info = "Serial: /dev/ttyUSB0"
|
||||
mock_getsize.return_value = 10 * 1024 * 1024 # 10 MB
|
||||
|
||||
from app.main import app
|
||||
@@ -1282,7 +1282,7 @@ class TestHealthEndpointOldestUndecrypted:
|
||||
patch("app.routers.health.RawPacketRepository") as mock_repo,
|
||||
):
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.port = "/dev/ttyUSB0"
|
||||
mock_rm.connection_info = "Serial: /dev/ttyUSB0"
|
||||
mock_getsize.return_value = 5 * 1024 * 1024 # 5 MB
|
||||
mock_repo.get_oldest_undecrypted = AsyncMock(return_value=1700000000)
|
||||
|
||||
@@ -1309,7 +1309,7 @@ class TestHealthEndpointOldestUndecrypted:
|
||||
patch("app.routers.health.RawPacketRepository") as mock_repo,
|
||||
):
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.port = "/dev/ttyUSB0"
|
||||
mock_rm.connection_info = "Serial: /dev/ttyUSB0"
|
||||
mock_getsize.return_value = 1 * 1024 * 1024 # 1 MB
|
||||
mock_repo.get_oldest_undecrypted = AsyncMock(return_value=None)
|
||||
|
||||
@@ -1336,7 +1336,7 @@ class TestHealthEndpointOldestUndecrypted:
|
||||
patch("app.routers.health.RawPacketRepository") as mock_repo,
|
||||
):
|
||||
mock_rm.is_connected = False
|
||||
mock_rm.port = None
|
||||
mock_rm.connection_info = None
|
||||
mock_getsize.side_effect = OSError("File not found")
|
||||
mock_repo.get_oldest_undecrypted = AsyncMock(side_effect=RuntimeError("No DB"))
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Tests for configuration validation.
|
||||
|
||||
These tests verify transport mutual exclusivity, BLE PIN requirement,
|
||||
and connection_type derivation.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
class TestTransportExclusivity:
|
||||
"""Ensure only one transport can be configured at a time."""
|
||||
|
||||
def test_no_transport_defaults_to_serial(self):
|
||||
"""No transport env vars means serial auto-detect."""
|
||||
s = Settings(serial_port="", tcp_host="", ble_address="")
|
||||
assert s.connection_type == "serial"
|
||||
|
||||
def test_serial_only(self):
|
||||
s = Settings(serial_port="/dev/ttyUSB0")
|
||||
assert s.connection_type == "serial"
|
||||
|
||||
def test_tcp_only(self):
|
||||
s = Settings(tcp_host="192.168.1.1")
|
||||
assert s.connection_type == "tcp"
|
||||
|
||||
def test_tcp_with_custom_port(self):
|
||||
s = Settings(tcp_host="192.168.1.1", tcp_port=5000)
|
||||
assert s.connection_type == "tcp"
|
||||
assert s.tcp_port == 5000
|
||||
|
||||
def test_tcp_default_port(self):
|
||||
s = Settings(tcp_host="192.168.1.1")
|
||||
assert s.tcp_port == 4000
|
||||
|
||||
def test_ble_only(self):
|
||||
s = Settings(ble_address="AA:BB:CC:DD:EE:FF", ble_pin="123456")
|
||||
assert s.connection_type == "ble"
|
||||
|
||||
def test_serial_and_tcp_rejected(self):
|
||||
with pytest.raises(ValidationError, match="Only one transport"):
|
||||
Settings(serial_port="/dev/ttyUSB0", tcp_host="192.168.1.1")
|
||||
|
||||
def test_serial_and_ble_rejected(self):
|
||||
with pytest.raises(ValidationError, match="Only one transport"):
|
||||
Settings(
|
||||
serial_port="/dev/ttyUSB0",
|
||||
ble_address="AA:BB:CC:DD:EE:FF",
|
||||
ble_pin="123456",
|
||||
)
|
||||
|
||||
def test_tcp_and_ble_rejected(self):
|
||||
with pytest.raises(ValidationError, match="Only one transport"):
|
||||
Settings(
|
||||
tcp_host="192.168.1.1",
|
||||
ble_address="AA:BB:CC:DD:EE:FF",
|
||||
ble_pin="123456",
|
||||
)
|
||||
|
||||
def test_all_three_rejected(self):
|
||||
with pytest.raises(ValidationError, match="Only one transport"):
|
||||
Settings(
|
||||
serial_port="/dev/ttyUSB0",
|
||||
tcp_host="192.168.1.1",
|
||||
ble_address="AA:BB:CC:DD:EE:FF",
|
||||
ble_pin="123456",
|
||||
)
|
||||
|
||||
|
||||
class TestBLEPinRequirement:
|
||||
"""BLE address requires a PIN."""
|
||||
|
||||
def test_ble_address_without_pin_rejected(self):
|
||||
with pytest.raises(ValidationError, match="MESHCORE_BLE_PIN is required"):
|
||||
Settings(ble_address="AA:BB:CC:DD:EE:FF", ble_pin="")
|
||||
|
||||
def test_ble_address_with_pin_accepted(self):
|
||||
s = Settings(ble_address="AA:BB:CC:DD:EE:FF", ble_pin="123456")
|
||||
assert s.ble_address == "AA:BB:CC:DD:EE:FF"
|
||||
assert s.ble_pin == "123456"
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Tests for RadioManager multi-transport connect dispatch.
|
||||
|
||||
These tests verify that connect() routes to the correct transport method
|
||||
based on settings.connection_type, and that connection_info is set correctly.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestRadioManagerConnect:
|
||||
"""Test that connect() dispatches to the correct transport."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_serial_explicit_port(self):
|
||||
"""Serial connect with explicit port sets connection_info."""
|
||||
from app.radio import RadioManager
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.is_connected = True
|
||||
|
||||
with (
|
||||
patch("app.radio.settings") as mock_settings,
|
||||
patch("app.radio.MeshCore") as mock_meshcore,
|
||||
):
|
||||
mock_settings.connection_type = "serial"
|
||||
mock_settings.serial_port = "/dev/ttyUSB0"
|
||||
mock_settings.serial_baudrate = 115200
|
||||
mock_meshcore.create_serial = AsyncMock(return_value=mock_mc)
|
||||
|
||||
rm = RadioManager()
|
||||
await rm.connect()
|
||||
|
||||
mock_meshcore.create_serial.assert_awaited_once_with(
|
||||
port="/dev/ttyUSB0",
|
||||
baudrate=115200,
|
||||
auto_reconnect=True,
|
||||
max_reconnect_attempts=10,
|
||||
)
|
||||
assert rm.connection_info == "Serial: /dev/ttyUSB0"
|
||||
assert rm.meshcore is mock_mc
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_serial_autodetect(self):
|
||||
"""Serial connect without port auto-detects."""
|
||||
from app.radio import RadioManager
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.is_connected = True
|
||||
|
||||
with (
|
||||
patch("app.radio.settings") as mock_settings,
|
||||
patch("app.radio.MeshCore") as mock_meshcore,
|
||||
patch("app.radio.find_radio_port", new_callable=AsyncMock) as mock_find,
|
||||
):
|
||||
mock_settings.connection_type = "serial"
|
||||
mock_settings.serial_port = ""
|
||||
mock_settings.serial_baudrate = 115200
|
||||
mock_find.return_value = "/dev/ttyACM0"
|
||||
mock_meshcore.create_serial = AsyncMock(return_value=mock_mc)
|
||||
|
||||
rm = RadioManager()
|
||||
await rm.connect()
|
||||
|
||||
mock_find.assert_awaited_once_with(115200)
|
||||
assert rm.connection_info == "Serial: /dev/ttyACM0"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_serial_autodetect_fails(self):
|
||||
"""Serial auto-detect raises when no radio found."""
|
||||
from app.radio import RadioManager
|
||||
|
||||
with (
|
||||
patch("app.radio.settings") as mock_settings,
|
||||
patch("app.radio.find_radio_port", new_callable=AsyncMock) as mock_find,
|
||||
):
|
||||
mock_settings.connection_type = "serial"
|
||||
mock_settings.serial_port = ""
|
||||
mock_settings.serial_baudrate = 115200
|
||||
mock_find.return_value = None
|
||||
|
||||
rm = RadioManager()
|
||||
with pytest.raises(RuntimeError, match="No MeshCore radio found"):
|
||||
await rm.connect()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_tcp(self):
|
||||
"""TCP connect sets connection_info with host:port."""
|
||||
from app.radio import RadioManager
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.is_connected = True
|
||||
|
||||
with (
|
||||
patch("app.radio.settings") as mock_settings,
|
||||
patch("app.radio.MeshCore") as mock_meshcore,
|
||||
):
|
||||
mock_settings.connection_type = "tcp"
|
||||
mock_settings.tcp_host = "192.168.1.100"
|
||||
mock_settings.tcp_port = 4000
|
||||
mock_meshcore.create_tcp = AsyncMock(return_value=mock_mc)
|
||||
|
||||
rm = RadioManager()
|
||||
await rm.connect()
|
||||
|
||||
mock_meshcore.create_tcp.assert_awaited_once_with(
|
||||
host="192.168.1.100",
|
||||
port=4000,
|
||||
auto_reconnect=True,
|
||||
max_reconnect_attempts=10,
|
||||
)
|
||||
assert rm.connection_info == "TCP: 192.168.1.100:4000"
|
||||
assert rm.meshcore is mock_mc
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_ble(self):
|
||||
"""BLE connect sets connection_info with address."""
|
||||
from app.radio import RadioManager
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.is_connected = True
|
||||
|
||||
with (
|
||||
patch("app.radio.settings") as mock_settings,
|
||||
patch("app.radio.MeshCore") as mock_meshcore,
|
||||
):
|
||||
mock_settings.connection_type = "ble"
|
||||
mock_settings.ble_address = "AA:BB:CC:DD:EE:FF"
|
||||
mock_settings.ble_pin = "123456"
|
||||
mock_meshcore.create_ble = AsyncMock(return_value=mock_mc)
|
||||
|
||||
rm = RadioManager()
|
||||
await rm.connect()
|
||||
|
||||
mock_meshcore.create_ble.assert_awaited_once_with(
|
||||
address="AA:BB:CC:DD:EE:FF",
|
||||
pin="123456",
|
||||
auto_reconnect=True,
|
||||
max_reconnect_attempts=15,
|
||||
)
|
||||
assert rm.connection_info == "BLE: AA:BB:CC:DD:EE:FF"
|
||||
assert rm.meshcore is mock_mc
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_disconnects_existing_first(self):
|
||||
"""Calling connect() when already connected disconnects first."""
|
||||
from app.radio import RadioManager
|
||||
|
||||
old_mc = MagicMock()
|
||||
old_mc.disconnect = AsyncMock()
|
||||
new_mc = MagicMock()
|
||||
new_mc.is_connected = True
|
||||
|
||||
with (
|
||||
patch("app.radio.settings") as mock_settings,
|
||||
patch("app.radio.MeshCore") as mock_meshcore,
|
||||
):
|
||||
mock_settings.connection_type = "tcp"
|
||||
mock_settings.tcp_host = "10.0.0.1"
|
||||
mock_settings.tcp_port = 4000
|
||||
mock_meshcore.create_tcp = AsyncMock(return_value=new_mc)
|
||||
|
||||
rm = RadioManager()
|
||||
rm._meshcore = old_mc
|
||||
|
||||
await rm.connect()
|
||||
|
||||
old_mc.disconnect.assert_awaited_once()
|
||||
assert rm.meshcore is new_mc
|
||||
Reference in New Issue
Block a user