mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-03-28 17:42:48 +01:00
483 lines
13 KiB
Python
483 lines
13 KiB
Python
import importlib
|
|
import sys
|
|
import types
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def mesh_module(monkeypatch):
|
|
"""Import data.mesh with stubbed dependencies."""
|
|
|
|
repo_root = Path(__file__).resolve().parents[1]
|
|
monkeypatch.syspath_prepend(str(repo_root))
|
|
|
|
# Stub meshtastic.serial_interface.SerialInterface
|
|
serial_interface_mod = types.ModuleType("meshtastic.serial_interface")
|
|
|
|
class DummySerialInterface:
|
|
def __init__(self, *_, **__):
|
|
self.closed = False
|
|
|
|
def close(self):
|
|
self.closed = True
|
|
|
|
serial_interface_mod.SerialInterface = DummySerialInterface
|
|
|
|
meshtastic_mod = types.ModuleType("meshtastic")
|
|
meshtastic_mod.serial_interface = serial_interface_mod
|
|
|
|
monkeypatch.setitem(sys.modules, "meshtastic", meshtastic_mod)
|
|
monkeypatch.setitem(
|
|
sys.modules, "meshtastic.serial_interface", serial_interface_mod
|
|
)
|
|
|
|
# Stub pubsub.pub
|
|
pubsub_mod = types.ModuleType("pubsub")
|
|
|
|
class DummyPub:
|
|
def __init__(self):
|
|
self.subscriptions = []
|
|
|
|
def subscribe(self, *args, **kwargs):
|
|
self.subscriptions.append((args, kwargs))
|
|
|
|
pubsub_mod.pub = DummyPub()
|
|
monkeypatch.setitem(sys.modules, "pubsub", pubsub_mod)
|
|
|
|
# Stub google.protobuf modules used by mesh.py
|
|
json_format_mod = types.ModuleType("google.protobuf.json_format")
|
|
|
|
def message_to_dict(obj, *_, **__):
|
|
if hasattr(obj, "to_dict"):
|
|
return obj.to_dict()
|
|
if hasattr(obj, "__dict__"):
|
|
return dict(obj.__dict__)
|
|
return {}
|
|
|
|
json_format_mod.MessageToDict = message_to_dict
|
|
|
|
message_mod = types.ModuleType("google.protobuf.message")
|
|
|
|
class DummyProtoMessage:
|
|
pass
|
|
|
|
message_mod.Message = DummyProtoMessage
|
|
|
|
protobuf_mod = types.ModuleType("google.protobuf")
|
|
protobuf_mod.json_format = json_format_mod
|
|
protobuf_mod.message = message_mod
|
|
|
|
google_mod = types.ModuleType("google")
|
|
google_mod.protobuf = protobuf_mod
|
|
|
|
monkeypatch.setitem(sys.modules, "google", google_mod)
|
|
monkeypatch.setitem(sys.modules, "google.protobuf", protobuf_mod)
|
|
monkeypatch.setitem(sys.modules, "google.protobuf.json_format", json_format_mod)
|
|
monkeypatch.setitem(sys.modules, "google.protobuf.message", message_mod)
|
|
|
|
module_name = "data.mesh"
|
|
if module_name in sys.modules:
|
|
module = importlib.reload(sys.modules[module_name])
|
|
else:
|
|
module = importlib.import_module(module_name)
|
|
|
|
if hasattr(module, "_clear_post_queue"):
|
|
module._clear_post_queue()
|
|
|
|
yield module
|
|
|
|
# Ensure a clean import for the next test
|
|
if hasattr(module, "_clear_post_queue"):
|
|
module._clear_post_queue()
|
|
sys.modules.pop(module_name, None)
|
|
|
|
|
|
def test_snapshot_interval_defaults_to_60_seconds(mesh_module):
|
|
mesh = mesh_module
|
|
|
|
assert mesh.SNAPSHOT_SECS == 60
|
|
|
|
|
|
def test_node_to_dict_handles_nested_structures(mesh_module):
|
|
mesh = mesh_module
|
|
|
|
@dataclass
|
|
class Child:
|
|
number: int
|
|
|
|
class DummyProto(mesh.ProtoMessage):
|
|
def __init__(self, **payload):
|
|
self._payload = payload
|
|
|
|
def to_dict(self):
|
|
return self._payload
|
|
|
|
@dataclass
|
|
class Node:
|
|
info: Child
|
|
proto: DummyProto
|
|
payload: bytes
|
|
seq: list
|
|
|
|
node = Node(Child(5), DummyProto(value=7), b"hi", [Child(1), DummyProto(value=9)])
|
|
|
|
result = mesh._node_to_dict(node)
|
|
assert result["info"] == {"number": 5}
|
|
assert result["proto"] == {"value": 7}
|
|
assert result["payload"] == "hi"
|
|
assert result["seq"] == [{"number": 1}, {"value": 9}]
|
|
|
|
|
|
def test_store_packet_dict_posts_text_message(mesh_module, monkeypatch):
|
|
mesh = mesh_module
|
|
captured = []
|
|
monkeypatch.setattr(
|
|
mesh,
|
|
"_queue_post_json",
|
|
lambda path, payload, *, priority: captured.append((path, payload, priority)),
|
|
)
|
|
|
|
packet = {
|
|
"id": 123,
|
|
"rxTime": 1_700_000_000,
|
|
"fromId": "!abc",
|
|
"toId": "!def",
|
|
"channel": "2",
|
|
"hopLimit": "3",
|
|
"snr": "1.25",
|
|
"rxRssi": "-70",
|
|
"decoded": {
|
|
"payload": {"text": "hello"},
|
|
"portnum": "TEXT_MESSAGE_APP",
|
|
"channel": 4,
|
|
},
|
|
}
|
|
|
|
mesh.store_packet_dict(packet)
|
|
|
|
assert captured, "Expected POST to be triggered for text message"
|
|
path, payload, priority = captured[0]
|
|
assert path == "/api/messages"
|
|
assert payload["id"] == 123
|
|
assert payload["channel"] == 4
|
|
assert payload["from_id"] == "!abc"
|
|
assert payload["to_id"] == "!def"
|
|
assert payload["text"] == "hello"
|
|
assert payload["portnum"] == "TEXT_MESSAGE_APP"
|
|
assert payload["rx_time"] == 1_700_000_000
|
|
assert payload["rx_iso"] == mesh._iso(1_700_000_000)
|
|
assert payload["hop_limit"] == 3
|
|
assert payload["snr"] == pytest.approx(1.25)
|
|
assert payload["rssi"] == -70
|
|
assert priority == mesh._MESSAGE_POST_PRIORITY
|
|
|
|
|
|
def test_store_packet_dict_ignores_non_text(mesh_module, monkeypatch):
|
|
mesh = mesh_module
|
|
captured = []
|
|
monkeypatch.setattr(
|
|
mesh,
|
|
"_queue_post_json",
|
|
lambda *args, **kwargs: captured.append((args, kwargs)),
|
|
)
|
|
|
|
packet = {
|
|
"id": 456,
|
|
"rxTime": 1_700_000_100,
|
|
"fromId": "!abc",
|
|
"toId": "!def",
|
|
"decoded": {
|
|
"payload": {"text": "ignored"},
|
|
"portnum": "POSITION_APP",
|
|
},
|
|
}
|
|
|
|
mesh.store_packet_dict(packet)
|
|
|
|
assert not captured, "Non-text messages should not be queued"
|
|
|
|
|
|
def test_node_items_snapshot_handles_transient_runtime_error(mesh_module):
|
|
mesh = mesh_module
|
|
|
|
class FlakyDict(dict):
|
|
def __init__(self):
|
|
super().__init__({"node": {"foo": "bar"}})
|
|
self.calls = 0
|
|
|
|
def items(self):
|
|
self.calls += 1
|
|
if self.calls == 1:
|
|
raise RuntimeError("dictionary changed size during iteration")
|
|
return super().items()
|
|
|
|
nodes = FlakyDict()
|
|
snapshot = mesh._node_items_snapshot(nodes, retries=3)
|
|
|
|
assert snapshot == [("node", {"foo": "bar"})]
|
|
assert nodes.calls == 2
|
|
|
|
|
|
def test_node_items_snapshot_returns_none_when_still_mutating(mesh_module):
|
|
mesh = mesh_module
|
|
|
|
class AlwaysChanging(dict):
|
|
def __init__(self):
|
|
super().__init__({"node": {"foo": "bar"}})
|
|
|
|
def items(self):
|
|
raise RuntimeError("dictionary changed size during iteration")
|
|
|
|
nodes = AlwaysChanging()
|
|
snapshot = mesh._node_items_snapshot(nodes, retries=2)
|
|
|
|
assert snapshot is None
|
|
|
|
|
|
def test_get_handles_dicts_and_objects(mesh_module):
|
|
mesh = mesh_module
|
|
|
|
class Dummy:
|
|
value = "obj"
|
|
|
|
assert mesh._get({"key": 1}, "key") == 1
|
|
assert mesh._get({"key": 1}, "missing", "fallback") == "fallback"
|
|
dummy = Dummy()
|
|
assert mesh._get(dummy, "value") == "obj"
|
|
assert mesh._get(dummy, "missing", "default") == "default"
|
|
|
|
|
|
def test_post_json_skips_without_instance(mesh_module, monkeypatch):
|
|
mesh = mesh_module
|
|
monkeypatch.setattr(mesh, "INSTANCE", "")
|
|
|
|
def fail_request(*_, **__):
|
|
raise AssertionError("Request should not be created when INSTANCE is empty")
|
|
|
|
monkeypatch.setattr(mesh.urllib.request, "Request", fail_request)
|
|
mesh._post_json("/ignored", {"foo": "bar"})
|
|
|
|
|
|
def test_post_json_sends_payload_with_token(mesh_module, monkeypatch):
|
|
mesh = mesh_module
|
|
monkeypatch.setattr(mesh, "INSTANCE", "https://example.test")
|
|
monkeypatch.setattr(mesh, "API_TOKEN", "secret")
|
|
|
|
captured = {}
|
|
|
|
def fake_urlopen(req, timeout=0):
|
|
captured["req"] = req
|
|
|
|
class DummyResponse:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
return False
|
|
|
|
def read(self):
|
|
return b"ok"
|
|
|
|
return DummyResponse()
|
|
|
|
monkeypatch.setattr(mesh.urllib.request, "urlopen", fake_urlopen)
|
|
|
|
mesh._post_json("/api/test", {"hello": "world"})
|
|
|
|
req = captured["req"]
|
|
assert req.full_url == "https://example.test/api/test"
|
|
assert req.headers["Content-type"] == "application/json"
|
|
assert req.get_header("Authorization") == "Bearer secret"
|
|
assert mesh.json.loads(req.data.decode("utf-8")) == {"hello": "world"}
|
|
|
|
|
|
def test_node_to_dict_handles_non_utf8_bytes(mesh_module):
|
|
mesh = mesh_module
|
|
|
|
@dataclass
|
|
class Node:
|
|
payload: bytes
|
|
other: object
|
|
|
|
class Custom:
|
|
def __str__(self):
|
|
return "custom!"
|
|
|
|
node = Node(b"\xff", Custom())
|
|
result = mesh._node_to_dict(node)
|
|
|
|
assert result["payload"] == "ff"
|
|
assert result["other"] == "custom!"
|
|
|
|
|
|
def test_first_prefers_first_non_empty_value(mesh_module):
|
|
mesh = mesh_module
|
|
data = {"primary": {"value": ""}, "secondary": {"value": "found"}}
|
|
|
|
assert mesh._first(data, "primary.value", "secondary.value") == "found"
|
|
assert mesh._first(data, "missing.path", default="fallback") == "fallback"
|
|
|
|
|
|
def test_first_handles_attribute_sources(mesh_module):
|
|
mesh = mesh_module
|
|
ns = SimpleNamespace(empty=None, value="attr")
|
|
|
|
assert mesh._first(ns, "empty", "value") == "attr"
|
|
|
|
|
|
def test_pkt_to_dict_handles_dict_and_proto(mesh_module, monkeypatch):
|
|
mesh = mesh_module
|
|
|
|
assert mesh._pkt_to_dict({"a": 1}) == {"a": 1}
|
|
|
|
class DummyProto(mesh.ProtoMessage):
|
|
def to_dict(self):
|
|
return {"value": 5}
|
|
|
|
assert mesh._pkt_to_dict(DummyProto()) == {"value": 5}
|
|
|
|
class Unknown:
|
|
pass
|
|
|
|
def broken_dumps(*_, **__):
|
|
raise TypeError("boom")
|
|
|
|
monkeypatch.setattr(mesh.json, "dumps", broken_dumps)
|
|
fallback = mesh._pkt_to_dict(Unknown())
|
|
assert set(fallback) == {"_unparsed"}
|
|
assert isinstance(fallback["_unparsed"], str)
|
|
|
|
|
|
def test_store_packet_dict_uses_top_level_channel(mesh_module, monkeypatch):
|
|
mesh = mesh_module
|
|
captured = []
|
|
monkeypatch.setattr(
|
|
mesh,
|
|
"_queue_post_json",
|
|
lambda path, payload, *, priority: captured.append((path, payload, priority)),
|
|
)
|
|
|
|
packet = {
|
|
"id": "789",
|
|
"rxTime": 123456,
|
|
"from": "!abc",
|
|
"to": "!def",
|
|
"channel": "5",
|
|
"decoded": {"text": "hi", "portnum": 1},
|
|
}
|
|
|
|
mesh.store_packet_dict(packet)
|
|
|
|
assert captured, "Expected message to be stored"
|
|
path, payload, priority = captured[0]
|
|
assert path == "/api/messages"
|
|
assert payload["channel"] == 5
|
|
assert payload["portnum"] == "1"
|
|
assert payload["text"] == "hi"
|
|
assert payload["snr"] is None and payload["rssi"] is None
|
|
assert priority == mesh._MESSAGE_POST_PRIORITY
|
|
|
|
|
|
def test_store_packet_dict_handles_invalid_channel(mesh_module, monkeypatch):
|
|
mesh = mesh_module
|
|
captured = []
|
|
monkeypatch.setattr(
|
|
mesh,
|
|
"_queue_post_json",
|
|
lambda path, payload, *, priority: captured.append((path, payload, priority)),
|
|
)
|
|
|
|
packet = {
|
|
"id": 321,
|
|
"rxTime": 999,
|
|
"fromId": "!abc",
|
|
"decoded": {
|
|
"payload": {"text": "hello"},
|
|
"portnum": "TEXT_MESSAGE_APP",
|
|
"channel": "not-a-number",
|
|
},
|
|
}
|
|
|
|
mesh.store_packet_dict(packet)
|
|
|
|
assert captured
|
|
path, payload, priority = captured[0]
|
|
assert path == "/api/messages"
|
|
assert payload["channel"] == 0
|
|
assert priority == mesh._MESSAGE_POST_PRIORITY
|
|
|
|
|
|
def test_post_queue_prioritises_nodes(mesh_module, monkeypatch):
|
|
mesh = mesh_module
|
|
mesh._clear_post_queue()
|
|
calls = []
|
|
|
|
def record(path, payload):
|
|
calls.append((path, payload))
|
|
|
|
monkeypatch.setattr(mesh, "_post_json", record)
|
|
|
|
mesh._enqueue_post_json("/api/messages", {"id": 1}, mesh._MESSAGE_POST_PRIORITY)
|
|
mesh._enqueue_post_json(
|
|
"/api/nodes", {"!node": {"foo": "bar"}}, mesh._NODE_POST_PRIORITY
|
|
)
|
|
|
|
mesh._drain_post_queue()
|
|
|
|
assert [path for path, _ in calls] == ["/api/nodes", "/api/messages"]
|
|
|
|
|
|
def test_store_packet_dict_requires_id(mesh_module, monkeypatch):
|
|
mesh = mesh_module
|
|
|
|
def fail_post(*_, **__):
|
|
raise AssertionError("Should not post without an id")
|
|
|
|
monkeypatch.setattr(mesh, "_queue_post_json", fail_post)
|
|
|
|
packet = {"decoded": {"payload": {"text": "hello"}, "portnum": "TEXT_MESSAGE_APP"}}
|
|
mesh.store_packet_dict(packet)
|
|
|
|
|
|
def test_on_receive_logs_when_store_fails(mesh_module, monkeypatch, capsys):
|
|
mesh = mesh_module
|
|
monkeypatch.setattr(mesh, "_pkt_to_dict", lambda pkt: {"id": 1})
|
|
|
|
def boom(*_, **__):
|
|
raise ValueError("boom")
|
|
|
|
monkeypatch.setattr(mesh, "store_packet_dict", boom)
|
|
|
|
mesh.on_receive(object(), interface=None)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "failed to store packet" in captured.out
|
|
|
|
|
|
def test_node_items_snapshot_iterable_without_items(mesh_module):
|
|
mesh = mesh_module
|
|
|
|
class Iterable:
|
|
def __init__(self):
|
|
self._data = {"node": {"foo": "bar"}}
|
|
|
|
def __iter__(self):
|
|
return iter(self._data)
|
|
|
|
def __getitem__(self, key):
|
|
return self._data[key]
|
|
|
|
snapshot = mesh._node_items_snapshot(Iterable(), retries=1)
|
|
assert snapshot == [("node", {"foo": "bar"})]
|
|
|
|
|
|
def test_node_items_snapshot_handles_empty_input(mesh_module):
|
|
mesh = mesh_module
|
|
|
|
assert mesh._node_items_snapshot(None) == []
|
|
assert mesh._node_items_snapshot({}) == []
|