Files
meshcore-bot/tests/test_transmission_tracker.py
T
agessaman ea0e25d746 Implement LRU caching for SNR and RSSI in MessageHandler and enhance rate limiting with thread safety
- Updated MessageHandler to use OrderedDict for SNR and RSSI caches, implementing LRU eviction to maintain a maximum size.
- Added thread safety to rate limiting classes by introducing locks, ensuring consistent behavior in concurrent environments.
- Introduced periodic cleanup in TransmissionTracker to manage memory usage effectively.
- Added unit tests for LRU cache behavior and automatic cleanup functionality.

These changes improve performance and reliability in handling message data and rate limiting.
2026-03-29 14:11:12 -07:00

574 lines
22 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
@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")
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")
result = tracker.match_packet_hash("deadbeef", rec.timestamp + 1)
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")
# First match confirms it
tracker.match_packet_hash("abc123", rec.timestamp)
# 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")
# RF timestamp far in the future
result = tracker.match_packet_hash("deadbeef", rec.timestamp + 9999)
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")
# First confirm the hash
tracker.match_packet_hash("hash01", rec.timestamp)
# 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")
tracker.match_packet_hash("hash02", rec.timestamp)
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")
tracker.match_packet_hash("hash03", rec.timestamp)
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")
tracker.match_packet_hash("hashXX", rec.timestamp)
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")
tracker.match_packet_hash("hashYY", rec.timestamp)
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_filters_own_prefix(self, tracker):
tracker.bot_prefix = "86"
result = tracker.extract_repeater_prefixes_from_path("01,7e,86")
assert result == []
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 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_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