mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 01:03:34 +02:00
Add custom pathing (closes #45)
This commit is contained in:
@@ -91,6 +91,9 @@ export interface Contact {
|
||||
last_path: string | null;
|
||||
last_path_len: number;
|
||||
out_path_hash_mode: number;
|
||||
route_override_path?: string | null;
|
||||
route_override_len?: number | null;
|
||||
route_override_hash_mode?: number | null;
|
||||
last_advert: number | null;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
|
||||
@@ -649,45 +649,39 @@ class TestCreateContactWithHistorical:
|
||||
mock_start.assert_not_awaited()
|
||||
|
||||
|
||||
class TestResetPath:
|
||||
"""Test POST /api/contacts/{public_key}/reset-path."""
|
||||
class TestRoutingOverride:
|
||||
"""Test POST /api/contacts/{public_key}/routing-override."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_path_to_flood(self, test_db, client):
|
||||
"""Happy path: resets path to flood and returns ok."""
|
||||
await _insert_contact(KEY_A, last_path="1122", last_path_len=1)
|
||||
async def test_set_explicit_routing_override(self, test_db, client):
|
||||
await _insert_contact(KEY_A, last_path="11", last_path_len=1, out_path_hash_mode=0)
|
||||
|
||||
with (
|
||||
patch("app.routers.contacts.radio_manager") as mock_rm,
|
||||
patch("app.websocket.broadcast_event"),
|
||||
patch("app.websocket.broadcast_event") as mock_broadcast,
|
||||
):
|
||||
mock_rm.is_connected = False
|
||||
response = await client.post(f"/api/contacts/{KEY_A}/reset-path")
|
||||
response = await client.post(
|
||||
f"/api/contacts/{KEY_A}/routing-override",
|
||||
json={"route": "ae92,f13e"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["public_key"] == KEY_A
|
||||
|
||||
# Verify path was reset in DB
|
||||
contact = await ContactRepository.get_by_key(KEY_A)
|
||||
assert contact.last_path == ""
|
||||
assert contact.last_path_len == -1
|
||||
assert contact.out_path_hash_mode == -1
|
||||
assert contact is not None
|
||||
assert contact.last_path == "11"
|
||||
assert contact.last_path_len == 1
|
||||
assert contact.route_override_path == "ae92f13e"
|
||||
assert contact.route_override_len == 2
|
||||
assert contact.route_override_hash_mode == 1
|
||||
mock_broadcast.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_path_not_found(self, test_db, client):
|
||||
response = await client.post(f"/api/contacts/{KEY_A}/reset-path")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_path_pushes_to_radio(self, test_db, client):
|
||||
"""When radio connected and contact on_radio, pushes updated path."""
|
||||
async def test_force_flood_routing_override_pushes_effective_route(self, test_db, client):
|
||||
await _insert_contact(
|
||||
KEY_A,
|
||||
on_radio=True,
|
||||
last_path="1122",
|
||||
last_path="11",
|
||||
last_path_len=1,
|
||||
out_path_hash_mode=0,
|
||||
)
|
||||
@@ -703,33 +697,64 @@ class TestResetPath:
|
||||
):
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.radio_operation = _noop_radio_operation(mock_mc)
|
||||
response = await client.post(f"/api/contacts/{KEY_A}/reset-path")
|
||||
response = await client.post(
|
||||
f"/api/contacts/{KEY_A}/routing-override",
|
||||
json={"route": "-1"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_mc.commands.add_contact.assert_called_once()
|
||||
contact_payload = mock_mc.commands.add_contact.call_args.args[0]
|
||||
assert contact_payload["out_path"] == ""
|
||||
assert contact_payload["out_path_len"] == -1
|
||||
assert contact_payload["out_path_hash_mode"] == -1
|
||||
payload = mock_mc.commands.add_contact.call_args.args[0]
|
||||
assert payload["out_path"] == ""
|
||||
assert payload["out_path_len"] == -1
|
||||
assert payload["out_path_hash_mode"] == -1
|
||||
|
||||
contact = await ContactRepository.get_by_key(KEY_A)
|
||||
assert contact is not None
|
||||
assert contact.route_override_len == -1
|
||||
assert contact.last_path == "11"
|
||||
assert contact.last_path_len == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_path_broadcasts_websocket_event(self, test_db, client):
|
||||
"""After resetting, broadcasts updated contact via WebSocket."""
|
||||
await _insert_contact(KEY_A, last_path="1122", last_path_len=1)
|
||||
async def test_blank_route_clears_override_and_resets_learned_path(self, test_db, client):
|
||||
await _insert_contact(
|
||||
KEY_A,
|
||||
last_path="11",
|
||||
last_path_len=1,
|
||||
out_path_hash_mode=0,
|
||||
route_override_path="ae92f13e",
|
||||
route_override_len=2,
|
||||
route_override_hash_mode=1,
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.contacts.radio_manager") as mock_rm,
|
||||
patch("app.websocket.broadcast_event") as mock_broadcast,
|
||||
patch("app.websocket.broadcast_event"),
|
||||
):
|
||||
mock_rm.is_connected = False
|
||||
response = await client.post(f"/api/contacts/{KEY_A}/reset-path")
|
||||
response = await client.post(
|
||||
f"/api/contacts/{KEY_A}/routing-override",
|
||||
json={"route": ""},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_broadcast.assert_called_once()
|
||||
event_type, event_data = mock_broadcast.call_args[0]
|
||||
assert event_type == "contact"
|
||||
assert event_data["public_key"] == KEY_A
|
||||
assert event_data["last_path_len"] == -1
|
||||
contact = await ContactRepository.get_by_key(KEY_A)
|
||||
assert contact is not None
|
||||
assert contact.route_override_len is None
|
||||
assert contact.last_path == ""
|
||||
assert contact.last_path_len == -1
|
||||
assert contact.out_path_hash_mode == -1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_invalid_explicit_route(self, test_db, client):
|
||||
await _insert_contact(KEY_A)
|
||||
|
||||
response = await client.post(
|
||||
f"/api/contacts/{KEY_A}/routing-override",
|
||||
json={"route": "ae,f13e"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "same width" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
class TestAddRemoveRadio:
|
||||
|
||||
@@ -1116,8 +1116,8 @@ class TestMigration039:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 2
|
||||
assert await get_version(conn) == 40
|
||||
assert applied == 3
|
||||
assert await get_version(conn) == 41
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
@@ -1186,8 +1186,8 @@ class TestMigration039:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 2
|
||||
assert await get_version(conn) == 40
|
||||
assert applied == 3
|
||||
assert await get_version(conn) == 41
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
@@ -1240,8 +1240,8 @@ class TestMigration040:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 1
|
||||
assert await get_version(conn) == 40
|
||||
assert applied == 2
|
||||
assert await get_version(conn) == 41
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
@@ -1271,6 +1271,69 @@ class TestMigration040:
|
||||
await conn.close()
|
||||
|
||||
|
||||
class TestMigration041:
|
||||
"""Test migration 041: add nullable routing override columns."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adds_contact_routing_override_columns(self):
|
||||
conn = await aiosqlite.connect(":memory:")
|
||||
conn.row_factory = aiosqlite.Row
|
||||
try:
|
||||
await set_version(conn, 40)
|
||||
await conn.execute("""
|
||||
CREATE TABLE contacts (
|
||||
public_key TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
type INTEGER DEFAULT 0,
|
||||
flags INTEGER DEFAULT 0,
|
||||
last_path TEXT,
|
||||
last_path_len INTEGER DEFAULT -1,
|
||||
out_path_hash_mode INTEGER DEFAULT 0,
|
||||
last_advert INTEGER,
|
||||
lat REAL,
|
||||
lon REAL,
|
||||
last_seen INTEGER,
|
||||
on_radio INTEGER DEFAULT 0,
|
||||
last_contacted INTEGER,
|
||||
first_seen INTEGER
|
||||
)
|
||||
""")
|
||||
await conn.commit()
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 1
|
||||
assert await get_version(conn) == 41
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO contacts (
|
||||
public_key,
|
||||
route_override_path,
|
||||
route_override_len,
|
||||
route_override_hash_mode
|
||||
) VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
("aa" * 32, "ae92f13e", 2, 1),
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
SELECT route_override_path, route_override_len, route_override_hash_mode
|
||||
FROM contacts
|
||||
WHERE public_key = ?
|
||||
""",
|
||||
("aa" * 32,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
assert row["route_override_path"] == "ae92f13e"
|
||||
assert row["route_override_len"] == 2
|
||||
assert row["route_override_hash_mode"] == 1
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
class TestMigrationPacketHelpers:
|
||||
"""Test migration-local packet helpers against canonical path validation."""
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ from app.path_utils import (
|
||||
decode_path_byte,
|
||||
first_hop_hex,
|
||||
normalize_contact_route,
|
||||
normalize_route_override,
|
||||
parse_explicit_hop_route,
|
||||
parse_packet_envelope,
|
||||
path_wire_len,
|
||||
split_path_hex,
|
||||
@@ -174,6 +176,29 @@ class TestNormalizeContactRoute:
|
||||
assert hash_mode == -1
|
||||
|
||||
|
||||
class TestNormalizeRouteOverride:
|
||||
def test_preserves_unset_override(self):
|
||||
assert normalize_route_override(None, None, None) == (None, None, None)
|
||||
|
||||
def test_normalizes_forced_direct_override(self):
|
||||
path_hex, path_len, hash_mode = normalize_route_override(None, 0, None)
|
||||
assert path_hex == ""
|
||||
assert path_len == 0
|
||||
assert hash_mode == 0
|
||||
|
||||
|
||||
class TestParseExplicitHopRoute:
|
||||
def test_parses_one_byte_hops(self):
|
||||
assert parse_explicit_hop_route("ae,f1") == ("aef1", 2, 0)
|
||||
|
||||
def test_parses_two_byte_hops(self):
|
||||
assert parse_explicit_hop_route("ae92,f13e") == ("ae92f13e", 2, 1)
|
||||
|
||||
def test_rejects_mixed_width_hops(self):
|
||||
with pytest.raises(ValueError, match="same width"):
|
||||
parse_explicit_hop_route("ae,f13e")
|
||||
|
||||
|
||||
class TestContactToRadioDictHashMode:
|
||||
"""Test that Contact.to_radio_dict() preserves the stored out_path_hash_mode."""
|
||||
|
||||
@@ -251,6 +276,23 @@ class TestContactToRadioDictHashMode:
|
||||
assert d["out_path_len"] == 3
|
||||
assert d["out_path_hash_mode"] == 2
|
||||
|
||||
def test_route_override_takes_precedence_over_learned_route(self):
|
||||
from app.models import Contact
|
||||
|
||||
c = Contact(
|
||||
public_key="11" * 32,
|
||||
last_path="aabb",
|
||||
last_path_len=1,
|
||||
out_path_hash_mode=0,
|
||||
route_override_path="cc00dd00",
|
||||
route_override_len=2,
|
||||
route_override_hash_mode=1,
|
||||
)
|
||||
d = c.to_radio_dict()
|
||||
assert d["out_path"] == "cc00dd00"
|
||||
assert d["out_path_len"] == 2
|
||||
assert d["out_path_hash_mode"] == 1
|
||||
|
||||
|
||||
class TestContactFromRadioDictHashMode:
|
||||
"""Test that Contact.from_radio_dict() preserves explicit path hash mode."""
|
||||
|
||||
@@ -152,6 +152,34 @@ class TestOutgoingDMBroadcast:
|
||||
assert contact_payload["out_path_len"] == 2
|
||||
assert contact_payload["out_path_hash_mode"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_dm_prefers_route_override_over_learned_path(self, test_db):
|
||||
mc = _make_mc()
|
||||
pub_key = "ef" * 32
|
||||
await _insert_contact(
|
||||
pub_key,
|
||||
"Alice",
|
||||
last_path="aabb",
|
||||
last_path_len=1,
|
||||
out_path_hash_mode=0,
|
||||
route_override_path="cc00dd00",
|
||||
route_override_len=2,
|
||||
route_override_hash_mode=1,
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
):
|
||||
request = SendDirectMessageRequest(destination=pub_key, text="Hello")
|
||||
await send_direct_message(request)
|
||||
|
||||
contact_payload = mc.commands.add_contact.call_args.args[0]
|
||||
assert contact_payload["out_path"] == "cc00dd00"
|
||||
assert contact_payload["out_path_len"] == 2
|
||||
assert contact_payload["out_path_hash_mode"] == 1
|
||||
|
||||
|
||||
class TestOutgoingChannelBroadcast:
|
||||
"""Test that outgoing channel messages are broadcast via broadcast_event for fanout dispatch."""
|
||||
|
||||
Reference in New Issue
Block a user