mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-11-12 15:37:35 +00:00
Real-time was three equal panels (commands, packets, messages) that each scrolled on their own, couldn't be held still, and showed the same transmission up to three times. It's now one tape with one row per transmission: a packet, the channel message decrypted from it and a command it triggered share a row, joined by packet hash, and the bot's reply hangs under the command with how many times a repeater was heard echoing it. Those echoes fold into the reply instead of getting rows of their own, and a packet heard several times is one row with a count and every reception (SNR, RSSI, hops, path) when opened. A receiver strip at the top says whether the bot is listening or the radio is offline or not responding (from the banner poll base.html already runs, now published as a banner-status event), when it last heard a packet, the packet rate and a 10-minute activity bar. The tape holds still while you read it: Pause (P), scrolling down, a moving mouse pointer or keyboard focus queue new rows behind an "N new" button. Rows can be kept (safe from the 300-row limit and from Clear), filtered to messages or commands, by packet type or channel, and searched (/). Replayed history sits under a "Heard before you opened this page" divider, and adverts and keyed group text are decoded in the browser and labeled as such. Each row's glyph chip carries its packet type's icon in the dashboard chart's color for that type; decoded messages get a stronger tint, and the bot's commands and replies are violet. Server side, command rows carry trigger_packet_hash (from the message's routing info) and, once the transmission tracker matches a repeat, reply_packet_hash. Replayed rows carry stream_ts and replayed, and live rows stream_ts. Commands stored before this pair with their message only on an exact sender, channel and text match within 20 seconds. Real-time is admin-only now: its socket already was, so anonymous visitors got three "Admin login required" badges over spinners that never filled. They're redirected to login and the nav link is hidden from them. The byte breakdown sliced multibyte paths by hop count, so 2- and 3-byte hops spilled into the payload; it uses the path's byte count and shows the raw path-length byte. Route types 0 and 2 were swapped, and the channel key store was often built before the decoder had loaded, so it never decrypted anything. js-sha256 is vendored under static/vendor instead of loading from cdnjs.
732 lines
30 KiB
Python
732 lines
30 KiB
Python
"""Tests for modules/transmission_tracker.py."""
|
|
|
|
import json
|
|
import sqlite3
|
|
import time
|
|
from contextlib import closing
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
|
|
from modules.transmission_tracker import TransmissionRecord, TransmissionTracker
|
|
|
|
|
|
def _chan(text, idx=0, sender=None):
|
|
"""A received GRP_TXT that decrypted on channel ``idx`` to ``text`` (from ``sender`` when given)."""
|
|
message = f"{sender}: {text}" if sender else text
|
|
return {"payload_type": 5, "payload_hex": "", "channel_idx": idx, "channel_text": text, "channel_message": message}
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_bot(mock_logger):
|
|
"""Minimal bot mock for TransmissionTracker."""
|
|
bot = Mock()
|
|
bot.logger = mock_logger
|
|
bot.meshcore = None # No device connected
|
|
bot.prefix_hex_chars = 2
|
|
return bot
|
|
|
|
|
|
@pytest.fixture
|
|
def tracker(mock_bot):
|
|
"""TransmissionTracker instance with a mock bot."""
|
|
return TransmissionTracker(mock_bot)
|
|
|
|
|
|
class TestTransmissionRecord:
|
|
"""Tests for TransmissionRecord dataclass."""
|
|
|
|
def test_default_fields(self):
|
|
rec = TransmissionRecord(
|
|
timestamp=1234.0,
|
|
content="hello",
|
|
target="general",
|
|
message_type="channel",
|
|
)
|
|
assert rec.repeat_count == 0
|
|
assert rec.packet_hash is None
|
|
assert rec.command_id is None
|
|
assert rec.repeater_prefixes == set()
|
|
assert rec.repeater_counts == {}
|
|
|
|
def test_custom_fields(self):
|
|
rec = TransmissionRecord(
|
|
timestamp=5678.0,
|
|
content="dm text",
|
|
target="Alice",
|
|
message_type="dm",
|
|
packet_hash="abcd1234",
|
|
command_id="cmd-001",
|
|
)
|
|
assert rec.packet_hash == "abcd1234"
|
|
assert rec.command_id == "cmd-001"
|
|
|
|
|
|
class TestRecordTransmission:
|
|
"""Tests for TransmissionTracker.record_transmission()."""
|
|
|
|
def test_returns_transmission_record(self, tracker):
|
|
rec = tracker.record_transmission("hello", "general", "channel")
|
|
assert isinstance(rec, TransmissionRecord)
|
|
assert rec.content == "hello"
|
|
assert rec.target == "general"
|
|
assert rec.message_type == "channel"
|
|
|
|
def test_stores_in_pending(self, tracker):
|
|
rec = tracker.record_transmission("msg", "ch", "channel", channel_idx=0)
|
|
key = int(rec.timestamp)
|
|
assert key in tracker.pending_transmissions
|
|
assert rec in tracker.pending_transmissions[key]
|
|
|
|
def test_multiple_records_same_second(self, tracker):
|
|
rec1 = tracker.record_transmission("a", "ch", "channel")
|
|
rec2 = tracker.record_transmission("b", "ch", "channel")
|
|
int(rec1.timestamp)
|
|
# Both records should be in the same (or nearby) bucket
|
|
assert rec1 in tracker.pending_transmissions.get(int(rec1.timestamp), [])
|
|
assert rec2 in tracker.pending_transmissions.get(int(rec2.timestamp), [])
|
|
|
|
def test_with_command_id(self, tracker):
|
|
rec = tracker.record_transmission("msg", "ch", "channel", command_id="cmd-42")
|
|
assert rec.command_id == "cmd-42"
|
|
|
|
|
|
class TestMatchPacketHash:
|
|
"""Tests for TransmissionTracker.match_packet_hash()."""
|
|
|
|
def test_null_hash_returns_none(self, tracker):
|
|
assert tracker.match_packet_hash("", time.time()) is None
|
|
assert tracker.match_packet_hash("0000000000000000", time.time()) is None
|
|
|
|
def test_matches_pending_transmission(self, tracker):
|
|
rec = tracker.record_transmission("msg", "general", "channel", channel_idx=0)
|
|
result = tracker.match_packet_hash("deadbeef", rec.timestamp + 1, _chan("msg"))
|
|
assert result is not None
|
|
assert result.packet_hash == "deadbeef"
|
|
|
|
def test_already_confirmed_returned_immediately(self, tracker):
|
|
rec = tracker.record_transmission("msg", "ch", "channel", channel_idx=0)
|
|
# First match confirms it
|
|
tracker.match_packet_hash("abc123", rec.timestamp, _chan("msg"))
|
|
# Second call returns same confirmed record
|
|
result2 = tracker.match_packet_hash("abc123", time.time())
|
|
assert result2 is not None
|
|
assert result2.packet_hash == "abc123"
|
|
|
|
def test_no_match_outside_window(self, tracker):
|
|
rec = tracker.record_transmission("msg", "ch", "channel", channel_idx=0)
|
|
# RF timestamp far in the future
|
|
result = tracker.match_packet_hash("deadbeef", rec.timestamp + 9999, _chan("msg"))
|
|
assert result is None
|
|
|
|
|
|
class TestRecordRepeat:
|
|
"""Tests for TransmissionTracker.record_repeat()."""
|
|
|
|
def test_null_hash_returns_false(self, tracker):
|
|
assert tracker.record_repeat("") is False
|
|
assert tracker.record_repeat("0000000000000000") is False
|
|
|
|
def test_repeat_increments_count(self, tracker):
|
|
rec = tracker.record_transmission("msg", "ch", "channel", channel_idx=0)
|
|
# First confirm the hash
|
|
tracker.match_packet_hash("hash01", rec.timestamp, _chan("msg"))
|
|
# Now record a repeat
|
|
result = tracker.record_repeat("hash01", repeater_prefix="7e")
|
|
assert result is True
|
|
assert rec.repeat_count == 1
|
|
assert "7e" in rec.repeater_prefixes
|
|
|
|
def test_repeat_without_prefix(self, tracker):
|
|
rec = tracker.record_transmission("msg", "ch", "channel", channel_idx=0)
|
|
tracker.match_packet_hash("hash02", rec.timestamp, _chan("msg"))
|
|
result = tracker.record_repeat("hash02")
|
|
assert result is True
|
|
assert rec.repeat_count == 1
|
|
assert rec.repeater_counts.get("_unknown") == 1
|
|
|
|
def test_multiple_repeats_same_repeater(self, tracker):
|
|
rec = tracker.record_transmission("msg", "ch", "channel", channel_idx=0)
|
|
tracker.match_packet_hash("hash03", rec.timestamp, _chan("msg"))
|
|
tracker.record_repeat("hash03", repeater_prefix="01")
|
|
tracker.record_repeat("hash03", repeater_prefix="01")
|
|
assert rec.repeat_count == 2
|
|
assert rec.repeater_counts["01"] == 2
|
|
|
|
def test_unmatched_hash_returns_false(self, tracker):
|
|
result = tracker.record_repeat("nonexistent_hash")
|
|
assert result is False
|
|
|
|
|
|
class TestGetRepeatInfo:
|
|
"""Tests for TransmissionTracker.get_repeat_info()."""
|
|
|
|
def test_unknown_hash_returns_zeros(self, tracker):
|
|
info = tracker.get_repeat_info(packet_hash="unknown")
|
|
assert info["repeat_count"] == 0
|
|
assert info["repeater_prefixes"] == []
|
|
assert info["repeater_counts"] == {}
|
|
|
|
def test_lookup_by_packet_hash(self, tracker):
|
|
rec = tracker.record_transmission("msg", "ch", "channel", channel_idx=0)
|
|
tracker.match_packet_hash("hashXX", rec.timestamp, _chan("msg"))
|
|
tracker.record_repeat("hashXX", repeater_prefix="7e")
|
|
info = tracker.get_repeat_info(packet_hash="hashXX")
|
|
assert info["repeat_count"] == 1
|
|
assert "7e" in info["repeater_prefixes"]
|
|
|
|
def test_lookup_by_command_id(self, tracker):
|
|
rec = tracker.record_transmission("msg", "ch", "channel", command_id="cmd-99", channel_idx=0)
|
|
tracker.match_packet_hash("hashYY", rec.timestamp, _chan("msg"))
|
|
tracker.record_repeat("hashYY", repeater_prefix="ab")
|
|
info = tracker.get_repeat_info(command_id="cmd-99")
|
|
assert info["repeat_count"] == 1
|
|
assert "ab" in info["repeater_prefixes"]
|
|
|
|
|
|
class TestExtractRepeaterPrefixes:
|
|
"""Tests for TransmissionTracker.extract_repeater_prefixes_from_path()."""
|
|
|
|
def test_extracts_last_hop_from_path_string(self, tracker):
|
|
result = tracker.extract_repeater_prefixes_from_path("01,7e,86")
|
|
assert result == ["86"]
|
|
|
|
def test_extracts_from_path_nodes(self, tracker):
|
|
result = tracker.extract_repeater_prefixes_from_path(None, path_nodes=["01", "7e", "86"])
|
|
assert result == ["86"]
|
|
|
|
def test_a_repeater_sharing_our_prefix_keeps_the_repeat(self, tracker):
|
|
"""A companion never forwards, so a last hop matching our prefix is a repeater."""
|
|
tracker.bot_prefix = "86"
|
|
assert tracker.extract_repeater_prefixes_from_path("01,86") == ["86"]
|
|
assert tracker.extract_repeater_prefixes_from_path(None, ["01", "86"]) == ["86"]
|
|
|
|
def test_empty_path_returns_empty(self, tracker):
|
|
result = tracker.extract_repeater_prefixes_from_path(None)
|
|
assert result == []
|
|
|
|
def test_path_with_route_type_annotation(self, tracker):
|
|
result = tracker.extract_repeater_prefixes_from_path("01,7e,55 via ROUTE_TYPE_FLOOD")
|
|
assert result == ["55"]
|
|
|
|
def test_single_node_path(self, tracker):
|
|
result = tracker.extract_repeater_prefixes_from_path("7e")
|
|
assert result == ["7e"]
|
|
|
|
|
|
class TestCleanupOldRecords:
|
|
"""Tests for TransmissionTracker.cleanup_old_records()."""
|
|
|
|
def test_removes_old_pending(self, tracker):
|
|
# Inject a record with an old timestamp
|
|
old_rec = TransmissionRecord(
|
|
timestamp=time.time() - 600, # 10 minutes ago (beyond cleanup_after=300)
|
|
content="old msg",
|
|
target="ch",
|
|
message_type="channel",
|
|
)
|
|
old_key = int(old_rec.timestamp)
|
|
tracker.pending_transmissions[old_key] = [old_rec]
|
|
tracker.cleanup_old_records()
|
|
assert old_key not in tracker.pending_transmissions
|
|
|
|
def test_keeps_recent_pending(self, tracker):
|
|
rec = tracker.record_transmission("recent", "ch", "channel")
|
|
key = int(rec.timestamp)
|
|
tracker.cleanup_old_records()
|
|
assert key in tracker.pending_transmissions
|
|
|
|
def test_removes_old_confirmed_without_repeats(self, tracker):
|
|
old_rec = TransmissionRecord(
|
|
timestamp=time.time() - 600,
|
|
content="old",
|
|
target="ch",
|
|
message_type="channel",
|
|
packet_hash="stale_hash",
|
|
)
|
|
tracker.confirmed_transmissions["stale_hash"] = old_rec
|
|
tracker.cleanup_old_records()
|
|
assert "stale_hash" not in tracker.confirmed_transmissions
|
|
|
|
def test_keeps_old_confirmed_with_repeats(self, tracker):
|
|
old_rec = TransmissionRecord(
|
|
timestamp=time.time() - 600,
|
|
content="old",
|
|
target="ch",
|
|
message_type="channel",
|
|
packet_hash="repeat_hash",
|
|
repeat_count=3,
|
|
)
|
|
tracker.confirmed_transmissions["repeat_hash"] = old_rec
|
|
tracker.cleanup_old_records()
|
|
assert "repeat_hash" in tracker.confirmed_transmissions
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_bot_with_device(mock_logger, pubkey, prefix_hex_chars=2):
|
|
"""Build a minimal bot mock whose meshcore.device.public_key == pubkey."""
|
|
device = Mock()
|
|
device.public_key = pubkey
|
|
meshcore = Mock()
|
|
meshcore.device = device
|
|
bot = Mock()
|
|
bot.logger = mock_logger
|
|
bot.meshcore = meshcore
|
|
bot.prefix_hex_chars = prefix_hex_chars
|
|
return bot
|
|
|
|
|
|
def _make_db_with_packet_stream(db_path: str) -> None:
|
|
"""Create a minimal packet_stream table in a SQLite file."""
|
|
with closing(sqlite3.connect(db_path)) as conn:
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS packet_stream (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
type TEXT,
|
|
timestamp REAL,
|
|
data TEXT
|
|
)
|
|
""")
|
|
conn.commit()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests for _update_bot_prefix (lines 57-67)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestBotPrefixFromSelfInfo:
|
|
"""meshcore_py reports the radio's key in self_info, after the tracker is created."""
|
|
|
|
def test_prefix_is_read_once_the_radio_reports_its_key(self, mock_logger):
|
|
bot = Mock()
|
|
bot.logger = mock_logger
|
|
bot.prefix_hex_chars = 2
|
|
bot.meshcore = Mock(self_info={})
|
|
tracker = TransmissionTracker(bot)
|
|
assert tracker.bot_prefix is None
|
|
bot.meshcore.self_info = {"public_key": "C3D4" + "00" * 30}
|
|
assert tracker.bot_prefix == "c3"
|
|
|
|
|
|
class TestBotPrefixFollowsReconnect:
|
|
def test_a_reconnect_to_another_radio_changes_the_prefix(self, mock_logger):
|
|
bot = Mock()
|
|
bot.logger = mock_logger
|
|
bot.prefix_hex_chars = 2
|
|
bot.meshcore = Mock(self_info={"public_key": "c3" + "00" * 31})
|
|
tracker = TransmissionTracker(bot)
|
|
assert tracker.bot_prefix == "c3"
|
|
bot.meshcore = Mock(self_info={"public_key": "e5" + "00" * 31})
|
|
assert tracker.bot_prefix == "e5"
|
|
|
|
|
|
class TestUpdateBotPrefix:
|
|
"""Cover lines 57-67: _update_bot_prefix with str and bytes public_key."""
|
|
|
|
def test_str_pubkey_sets_bot_prefix(self, mock_logger):
|
|
"""When public_key is a str, bot_prefix is set to its first prefix_hex_chars."""
|
|
bot = _make_bot_with_device(mock_logger, pubkey="abcdef1234")
|
|
tracker = TransmissionTracker(bot)
|
|
assert tracker.bot_prefix == "ab"
|
|
|
|
def test_str_pubkey_prefix_hex_chars_4(self, mock_logger):
|
|
"""prefix_hex_chars=4 slices the first 4 characters."""
|
|
bot = _make_bot_with_device(mock_logger, pubkey="deadbeef99", prefix_hex_chars=4)
|
|
tracker = TransmissionTracker(bot)
|
|
assert tracker.bot_prefix == "dead"
|
|
|
|
def test_bytes_pubkey_sets_bot_prefix(self, mock_logger):
|
|
"""When public_key is bytes, bot_prefix is the hex of the first byte."""
|
|
bot = _make_bot_with_device(mock_logger, pubkey=b"\xab\xcd\xef")
|
|
tracker = TransmissionTracker(bot)
|
|
assert tracker.bot_prefix == "ab"
|
|
|
|
def test_bytes_pubkey_zero_byte(self, mock_logger):
|
|
"""Bytes public key starting with 0x00 produces '00'."""
|
|
bot = _make_bot_with_device(mock_logger, pubkey=b"\x00\xff")
|
|
tracker = TransmissionTracker(bot)
|
|
assert tracker.bot_prefix == "00"
|
|
|
|
def test_str_pubkey_too_short_stays_none(self, mock_logger):
|
|
"""A one-character str pubkey does not satisfy len >= 2; prefix stays None."""
|
|
bot = _make_bot_with_device(mock_logger, pubkey="a")
|
|
tracker = TransmissionTracker(bot)
|
|
assert tracker.bot_prefix is None
|
|
|
|
def test_no_meshcore_leaves_prefix_none(self, mock_logger):
|
|
"""When bot.meshcore is None the prefix is never set."""
|
|
bot = Mock()
|
|
bot.logger = mock_logger
|
|
bot.meshcore = None
|
|
bot.prefix_hex_chars = 2
|
|
tracker = TransmissionTracker(bot)
|
|
assert tracker.bot_prefix is None
|
|
|
|
def test_exception_during_prefix_update_leaves_prefix_none(self, mock_logger):
|
|
"""If accessing device.public_key raises an exception bot_prefix remains None."""
|
|
device = Mock()
|
|
# Make accessing public_key raise an exception.
|
|
type(device).public_key = property(lambda s: (_ for _ in ()).throw(RuntimeError("boom")))
|
|
meshcore = Mock()
|
|
meshcore.device = device
|
|
bot = Mock()
|
|
bot.logger = mock_logger
|
|
bot.meshcore = meshcore
|
|
bot.prefix_hex_chars = 2
|
|
tracker = TransmissionTracker(bot)
|
|
assert tracker.bot_prefix is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests for _update_command_in_database (lines 190, 198-246)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _build_tracker_with_db(mock_logger, tmp_path):
|
|
"""Build a tracker whose bot has a real SQLite DB at tmp_path/test.db."""
|
|
db_path = str(tmp_path / "test.db")
|
|
_make_db_with_packet_stream(db_path)
|
|
|
|
config = Mock()
|
|
config.has_section = Mock(return_value=False)
|
|
config.has_option = Mock(return_value=False)
|
|
config.get = Mock(return_value="")
|
|
|
|
db_manager = Mock()
|
|
db_manager.db_path = db_path
|
|
|
|
bot = Mock()
|
|
bot.logger = mock_logger
|
|
bot.meshcore = None
|
|
bot.prefix_hex_chars = 2
|
|
bot.config = config
|
|
bot.bot_root = str(tmp_path)
|
|
bot.db_manager = db_manager
|
|
bot.web_viewer_integration = Mock() # truthy so the DB path is reached
|
|
|
|
return TransmissionTracker(bot), db_path
|
|
|
|
|
|
class TestUpdateCommandInDatabase:
|
|
"""Cover lines 190 and 198-246: _update_command_in_database."""
|
|
|
|
def test_early_return_when_command_id_is_none(self, mock_logger, tmp_path):
|
|
"""Line 190: method returns immediately when record.command_id is None."""
|
|
tracker, db_path = _build_tracker_with_db(mock_logger, tmp_path)
|
|
rec = TransmissionRecord(
|
|
timestamp=time.time(),
|
|
content="msg",
|
|
target="ch",
|
|
message_type="channel",
|
|
command_id=None,
|
|
)
|
|
# No exception and the DB is untouched (table stays empty).
|
|
tracker._update_command_in_database(rec)
|
|
with closing(sqlite3.connect(db_path)) as conn:
|
|
count = conn.execute("SELECT COUNT(*) FROM packet_stream").fetchone()[0]
|
|
assert count == 0
|
|
|
|
def test_updates_matching_row_in_database(self, mock_logger, tmp_path):
|
|
"""Lines 198-246: a matching command row is found and updated."""
|
|
tracker, db_path = _build_tracker_with_db(mock_logger, tmp_path)
|
|
|
|
command_id = "cmd-update-test"
|
|
initial_data = {
|
|
"command_id": command_id,
|
|
"repeat_count": 0,
|
|
"repeater_prefixes": [],
|
|
"repeater_counts": {},
|
|
}
|
|
|
|
# Insert a row into packet_stream.
|
|
with closing(sqlite3.connect(db_path)) as conn:
|
|
conn.execute(
|
|
"INSERT INTO packet_stream (type, timestamp, data) VALUES (?, ?, ?)",
|
|
("command", time.time(), json.dumps(initial_data)),
|
|
)
|
|
conn.commit()
|
|
|
|
# Build a record whose command_id matches.
|
|
rec = TransmissionRecord(
|
|
timestamp=time.time(),
|
|
content="hello",
|
|
target="general",
|
|
message_type="channel",
|
|
command_id=command_id,
|
|
repeat_count=3,
|
|
)
|
|
rec.repeater_prefixes = {"7e", "ab"}
|
|
rec.repeater_counts = {"7e": 2, "ab": 1}
|
|
|
|
tracker._update_command_in_database(rec)
|
|
|
|
# Verify the row was updated.
|
|
with closing(sqlite3.connect(db_path)) as conn:
|
|
row = conn.execute(
|
|
"SELECT data FROM packet_stream WHERE type = 'command'"
|
|
).fetchone()
|
|
|
|
assert row is not None
|
|
updated = json.loads(row[0])
|
|
assert updated["repeat_count"] == 3
|
|
assert sorted(updated["repeater_prefixes"]) == ["7e", "ab"] or set(updated["repeater_prefixes"]) == {"7e", "ab"}
|
|
assert updated["repeater_counts"]["7e"] == 2
|
|
assert updated["repeater_counts"]["ab"] == 1
|
|
|
|
def test_update_records_reply_packet_hash(self, mock_logger, tmp_path):
|
|
"""The heard-back hash lets the realtime page fold the reply's echoes into the command."""
|
|
tracker, db_path = _build_tracker_with_db(mock_logger, tmp_path)
|
|
with closing(sqlite3.connect(db_path)) as conn:
|
|
conn.execute(
|
|
"INSERT INTO packet_stream (type, timestamp, data) VALUES (?, ?, ?)",
|
|
("command", time.time(), json.dumps({"command_id": "cmd-hash", "repeat_count": 0})),
|
|
)
|
|
conn.commit()
|
|
rec = TransmissionRecord(
|
|
timestamp=time.time(), content="hello", target="general", message_type="channel",
|
|
command_id="cmd-hash", repeat_count=1, packet_hash="FEEDFACE00112233",
|
|
)
|
|
tracker._update_command_in_database(rec)
|
|
with closing(sqlite3.connect(db_path)) as conn:
|
|
row = conn.execute("SELECT data FROM packet_stream WHERE type = 'command'").fetchone()
|
|
assert json.loads(row[0])["reply_packet_hash"] == "FEEDFACE00112233"
|
|
|
|
def test_get_repeat_info_reports_packet_hash(self, mock_logger, tmp_path):
|
|
tracker, _ = _build_tracker_with_db(mock_logger, tmp_path)
|
|
rec = TransmissionRecord(
|
|
timestamp=time.time(), content="hello", target="general", message_type="channel",
|
|
command_id="cmd-info", packet_hash="FEEDFACE00112233",
|
|
)
|
|
tracker.confirmed_transmissions["FEEDFACE00112233"] = rec
|
|
assert tracker.get_repeat_info(command_id="cmd-info")["packet_hash"] == "FEEDFACE00112233"
|
|
assert tracker.get_repeat_info(command_id="missing")["packet_hash"] is None
|
|
|
|
def test_no_matching_row_leaves_db_unchanged(self, mock_logger, tmp_path):
|
|
"""If no row matches command_id, the DB is not modified."""
|
|
tracker, db_path = _build_tracker_with_db(mock_logger, tmp_path)
|
|
|
|
other_data = {"command_id": "other-cmd", "repeat_count": 0}
|
|
with closing(sqlite3.connect(db_path)) as conn:
|
|
conn.execute(
|
|
"INSERT INTO packet_stream (type, timestamp, data) VALUES (?, ?, ?)",
|
|
("command", time.time(), json.dumps(other_data)),
|
|
)
|
|
conn.commit()
|
|
|
|
rec = TransmissionRecord(
|
|
timestamp=time.time(),
|
|
content="msg",
|
|
target="ch",
|
|
message_type="channel",
|
|
command_id="no-match-cmd",
|
|
repeat_count=1,
|
|
)
|
|
tracker._update_command_in_database(rec)
|
|
|
|
with closing(sqlite3.connect(db_path)) as conn:
|
|
row = conn.execute(
|
|
"SELECT data FROM packet_stream WHERE type = 'command'"
|
|
).fetchone()
|
|
assert json.loads(row[0])["command_id"] == "other-cmd"
|
|
|
|
def test_malformed_json_row_is_skipped(self, mock_logger, tmp_path):
|
|
"""A row with invalid JSON is silently skipped (json.JSONDecodeError path)."""
|
|
tracker, db_path = _build_tracker_with_db(mock_logger, tmp_path)
|
|
|
|
with closing(sqlite3.connect(db_path)) as conn:
|
|
conn.execute(
|
|
"INSERT INTO packet_stream (type, timestamp, data) VALUES (?, ?, ?)",
|
|
("command", time.time(), "NOT VALID JSON"),
|
|
)
|
|
conn.commit()
|
|
|
|
rec = TransmissionRecord(
|
|
timestamp=time.time(),
|
|
content="msg",
|
|
target="ch",
|
|
message_type="channel",
|
|
command_id="cmd-x",
|
|
repeat_count=1,
|
|
)
|
|
# Should not raise even though JSON is malformed.
|
|
tracker._update_command_in_database(rec)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests for line 314: path with parenthesis hop-count annotation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestExtractRepeaterPrefixesParenPath:
|
|
"""Cover line 314: path containing '(' (hop-count annotation) is stripped."""
|
|
|
|
def test_path_with_paren_stripped_before_split(self, mock_logger):
|
|
"""'01,7e,86(3)' should extract '86' after stripping the parenthesised part."""
|
|
bot = Mock()
|
|
bot.logger = mock_logger
|
|
bot.meshcore = None
|
|
bot.prefix_hex_chars = 2
|
|
tracker = TransmissionTracker(bot)
|
|
tracker.bot_prefix = None
|
|
|
|
result = tracker.extract_repeater_prefixes_from_path("01,7e,86(3)")
|
|
assert result == ["86"]
|
|
|
|
def test_path_with_paren_and_via(self, mock_logger):
|
|
"""Combined annotation: ' via ROUTE_TYPE_*' and '(' in the path part."""
|
|
bot = Mock()
|
|
bot.logger = mock_logger
|
|
bot.meshcore = None
|
|
bot.prefix_hex_chars = 2
|
|
tracker = TransmissionTracker(bot)
|
|
tracker.bot_prefix = None
|
|
|
|
result = tracker.extract_repeater_prefixes_from_path("01,7e,ab(2) via ROUTE_TYPE_FLOOD")
|
|
assert result == ["ab"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Automatic cleanup via _maybe_cleanup
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMaybeCleanup:
|
|
"""Tests for automatic periodic cleanup in TransmissionTracker."""
|
|
|
|
def test_maybe_cleanup_runs_after_interval(self, tracker):
|
|
"""_maybe_cleanup runs cleanup_old_records when interval has elapsed."""
|
|
# Record an old transmission manually
|
|
old_record = TransmissionRecord(
|
|
timestamp=time.time() - 600, # 10 minutes ago (past cleanup_after=300s)
|
|
content="old", target="chan", message_type="channel",
|
|
)
|
|
tracker.pending_transmissions[int(old_record.timestamp)] = [old_record]
|
|
# Force the interval to have elapsed
|
|
tracker._last_cleanup_time = 0.0
|
|
tracker._maybe_cleanup()
|
|
# Old record should be cleaned up
|
|
assert int(old_record.timestamp) not in tracker.pending_transmissions
|
|
|
|
def test_maybe_cleanup_skips_within_interval(self, tracker):
|
|
"""_maybe_cleanup does NOT run cleanup if interval hasn't elapsed."""
|
|
old_record = TransmissionRecord(
|
|
timestamp=time.time() - 600,
|
|
content="old", target="chan", message_type="channel",
|
|
)
|
|
tracker.pending_transmissions[int(old_record.timestamp)] = [old_record]
|
|
# Set last cleanup to now — interval hasn't elapsed
|
|
tracker._last_cleanup_time = time.time()
|
|
tracker._maybe_cleanup()
|
|
# Old record should still be present (cleanup didn't run)
|
|
assert int(old_record.timestamp) in tracker.pending_transmissions
|
|
|
|
def test_record_transmission_triggers_cleanup(self, tracker):
|
|
"""record_transmission calls _maybe_cleanup, cleaning stale records."""
|
|
old_record = TransmissionRecord(
|
|
timestamp=time.time() - 600,
|
|
content="old", target="chan", message_type="channel",
|
|
)
|
|
old_key = int(old_record.timestamp)
|
|
tracker.pending_transmissions[old_key] = [old_record]
|
|
tracker._last_cleanup_time = 0.0 # Force cleanup to run
|
|
# Recording a new transmission should trigger cleanup
|
|
tracker.record_transmission("new msg", "general", "channel")
|
|
assert old_key not in tracker.pending_transmissions
|
|
|
|
|
|
class TestOnlyOurOwnPacketsMatch:
|
|
"""A pending transmission is claimed only by a packet carrying its identity."""
|
|
|
|
def test_an_unrelated_packet_does_not_claim_a_channel_send(self, tracker):
|
|
rec = tracker.record_transmission("hello", "general", "channel", channel_idx=0)
|
|
assert tracker.match_packet_hash("aaaa", rec.timestamp + 1, {"payload_type": 4, "payload_hex": "c1" * 40}) is None
|
|
assert tracker.match_packet_hash("bbbb", rec.timestamp + 1, _chan("someone else's text")) is None
|
|
assert tracker.match_packet_hash("cccc", rec.timestamp + 1, _chan("hello", idx=1)) is None
|
|
assert rec.packet_hash is None
|
|
assert tracker.match_packet_hash("dddd", rec.timestamp + 2, _chan("hello")) is rec
|
|
assert tracker.record_repeat("dddd", "7e") is True
|
|
|
|
def test_a_hash_without_packet_evidence_is_only_looked_up(self, tracker):
|
|
rec = tracker.record_transmission("hello", "general", "channel", channel_idx=0)
|
|
assert tracker.match_packet_hash("eeee", rec.timestamp + 1) is None
|
|
assert tracker.record_repeat("eeee", "7e") is False
|
|
assert rec.repeat_count == 0
|
|
|
|
def test_a_dm_is_matched_by_recipient_and_sender_hash(self, mock_bot):
|
|
mock_bot.meshcore = Mock(self_info={"public_key": "ab" + "00" * 31})
|
|
tracker = TransmissionTracker(mock_bot)
|
|
rec = tracker.record_transmission("hi", "Alice", "dm", recipient_key="c4" + "11" * 31)
|
|
other_recipient = {"payload_type": 2, "payload_hex": "d5ab" + "00" * 20}
|
|
other_sender = {"payload_type": 2, "payload_hex": "c4ee" + "00" * 20}
|
|
ours = {"payload_type": 2, "payload_hex": "c4ab" + "00" * 20}
|
|
assert tracker.match_packet_hash("1111", rec.timestamp, other_recipient) is None
|
|
assert tracker.match_packet_hash("2222", rec.timestamp, other_sender) is None
|
|
assert tracker.match_packet_hash("3333", rec.timestamp, ours) is rec
|
|
|
|
def test_a_trace_is_matched_by_its_tag(self, tracker):
|
|
tag = 0x12345678
|
|
rec = tracker.record_transmission("trace", "", "trace", command_id=str(tag), trace_tag=tag)
|
|
other = {"payload_type": 9, "payload_hex": (0x0BADF00D).to_bytes(4, "little").hex() + "00" * 5}
|
|
ours = {"payload_type": 9, "payload_hex": tag.to_bytes(4, "little").hex() + "00" * 5}
|
|
assert tracker.match_packet_hash("4444", rec.timestamp, other) is None
|
|
assert tracker.match_packet_hash("5555", rec.timestamp, ours) is rec
|
|
|
|
def test_a_record_without_identity_is_never_claimed(self, tracker):
|
|
rec = tracker.record_transmission("msg", "ch", "channel")
|
|
assert tracker.match_packet_hash("6666", rec.timestamp, _chan("msg")) is None
|
|
|
|
|
|
class TestRepeatCountWrites:
|
|
async def test_repeat_count_is_written_off_the_event_loop(self, tracker, monkeypatch):
|
|
import threading
|
|
|
|
threads = []
|
|
monkeypatch.setattr(tracker, "_update_command_in_database", lambda rec: threads.append(threading.current_thread().name))
|
|
tracker.bot.web_viewer_integration = Mock()
|
|
rec = tracker.record_transmission("msg", "ch", "channel", command_id="cmd-1", channel_idx=0)
|
|
tracker.match_packet_hash("7777", rec.timestamp, _chan("msg"))
|
|
assert tracker.record_repeat("7777", "7e") is True
|
|
tracker._db_executor.shutdown(wait=True)
|
|
assert threads and threads[0] != threading.current_thread().name
|
|
|
|
|
|
class TestChannelSenderIsChecked:
|
|
@pytest.fixture
|
|
def named_tracker(self, mock_bot):
|
|
mock_bot.meshcore = Mock(self_info={"name": "Bot [RF]: west", "public_key": "ab" * 32})
|
|
return TransmissionTracker(mock_bot)
|
|
|
|
def test_another_node_sending_the_same_text_does_not_claim_it(self, named_tracker):
|
|
rec = named_tracker.record_transmission("hello", "general", "channel", channel_idx=0)
|
|
assert named_tracker.match_packet_hash("8888", rec.timestamp, _chan("hello", sender="OtherNode")) is None
|
|
assert rec.packet_hash is None
|
|
|
|
def test_a_name_the_prefix_heuristic_cannot_split_still_matches(self, named_tracker):
|
|
rec = named_tracker.record_transmission("hello", "general", "channel", channel_idx=0)
|
|
packet = {"payload_type": 5, "payload_hex": "", "channel_idx": 0,
|
|
"channel_text": "Bot [RF]: west: hello", "channel_message": "Bot [RF]: west: hello"}
|
|
assert named_tracker.match_packet_hash("9999", rec.timestamp, packet) is rec
|
|
|
|
|
|
def test_close_waits_for_queued_writes_and_later_writes_run_inline(tracker, monkeypatch):
|
|
import threading
|
|
import time as _time
|
|
|
|
written = []
|
|
|
|
def slow_write(rec):
|
|
_time.sleep(0.05)
|
|
written.append(threading.current_thread().name)
|
|
|
|
monkeypatch.setattr(tracker, "_update_command_in_database", slow_write)
|
|
|
|
async def _queue():
|
|
tracker._schedule_command_update(TransmissionRecord(timestamp=0.0, content="x", target="c", message_type="channel"))
|
|
|
|
import asyncio
|
|
asyncio.run(_queue())
|
|
tracker.close()
|
|
assert len(written) == 1
|
|
tracker._schedule_command_update(TransmissionRecord(timestamp=0.0, content="y", target="c", message_type="channel"))
|
|
assert written[-1] == threading.current_thread().name
|