Add custom pathing (closes #45)

This commit is contained in:
Jack Kingsman
2026-03-09 10:26:01 -07:00
parent 7e384c12bb
commit 0c5b37c07c
22 changed files with 718 additions and 163 deletions
+3
View File
@@ -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;
+65 -40
View File
@@ -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:
+69 -6
View File
@@ -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."""
+42
View File
@@ -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."""
+28
View File
@@ -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."""