Merge branch 'main' of github.com:maplemesh/Remote-Terminal-for-MeshCore into gnomeadrift/repeater_telemetry_history

This commit is contained in:
Gnome Adrift
2026-03-31 09:11:49 -07:00
86 changed files with 4450 additions and 724 deletions
+23
View File
@@ -190,6 +190,29 @@ class TestDebugEndpoint:
assert payload["database"]["total_channel_messages"] == 1
assert payload["database"]["total_outgoing"] == 1
@pytest.mark.asyncio
async def test_support_snapshot_uses_lightweight_message_totals(self, test_db, client):
"""Debug snapshot should not call the full statistics aggregation."""
with (
patch(
"app.routers.debug.StatisticsRepository.get_all",
new=AsyncMock(side_effect=AssertionError("get_all should not be called")),
),
patch(
"app.routers.debug.StatisticsRepository.get_database_message_totals",
new=AsyncMock(
return_value={
"total_dms": 0,
"total_channel_messages": 0,
"total_outgoing": 0,
}
),
),
):
response = await client.get("/api/debug")
assert response.status_code == 200
class TestRadioDisconnectedHandler:
"""Test that RadioDisconnectedError maps to 503."""
+1 -2
View File
@@ -11,8 +11,6 @@ import pytest
from app.event_handlers import (
_active_subscriptions,
_buffered_acks,
_pending_acks,
cleanup_expired_acks,
register_event_handlers,
track_pending_ack,
@@ -23,6 +21,7 @@ from app.repository import (
ContactRepository,
MessageRepository,
)
from app.services.dm_ack_tracker import _buffered_acks, _pending_acks
@pytest.fixture(autouse=True)
+78 -14
View File
@@ -1247,8 +1247,8 @@ class TestMigration039:
applied = await run_migrations(conn)
assert applied == 8
assert await get_version(conn) == 46
assert applied == 9
assert await get_version(conn) == 47
cursor = await conn.execute(
"""
@@ -1319,8 +1319,8 @@ class TestMigration039:
applied = await run_migrations(conn)
assert applied == 8
assert await get_version(conn) == 46
assert applied == 9
assert await get_version(conn) == 47
cursor = await conn.execute(
"""
@@ -1386,8 +1386,8 @@ class TestMigration039:
applied = await run_migrations(conn)
assert applied == 2
assert await get_version(conn) == 46
assert applied == 3
assert await get_version(conn) == 47
cursor = await conn.execute(
"""
@@ -1439,8 +1439,8 @@ class TestMigration040:
applied = await run_migrations(conn)
assert applied == 7
assert await get_version(conn) == 46
assert applied == 8
assert await get_version(conn) == 47
await conn.execute(
"""
@@ -1501,8 +1501,8 @@ class TestMigration041:
applied = await run_migrations(conn)
assert applied == 6
assert await get_version(conn) == 46
assert applied == 7
assert await get_version(conn) == 47
await conn.execute(
"""
@@ -1554,8 +1554,8 @@ class TestMigration042:
applied = await run_migrations(conn)
assert applied == 5
assert await get_version(conn) == 46
assert applied == 6
assert await get_version(conn) == 47
await conn.execute(
"""
@@ -1694,8 +1694,8 @@ class TestMigration046:
applied = await run_migrations(conn)
assert applied == 1
assert await get_version(conn) == 46
assert applied == 2
assert await get_version(conn) == 47
cursor = await conn.execute(
"""
@@ -1750,6 +1750,70 @@ class TestMigration046:
await conn.close()
class TestMigration047:
"""Test migration 047: add statistics indexes."""
@pytest.mark.asyncio
async def test_adds_statistics_indexes(self):
conn = await aiosqlite.connect(":memory:")
conn.row_factory = aiosqlite.Row
try:
await set_version(conn, 46)
await conn.execute("""
CREATE TABLE contacts (
public_key TEXT PRIMARY KEY,
name TEXT,
type INTEGER DEFAULT 0,
last_seen INTEGER
)
""")
await conn.execute("""
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
conversation_key TEXT NOT NULL,
received_at INTEGER NOT NULL
)
""")
await conn.execute("""
CREATE TABLE raw_packets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
data BLOB NOT NULL,
message_id INTEGER,
payload_hash BLOB
)
""")
await conn.commit()
applied = await run_migrations(conn)
assert applied == 1
assert await get_version(conn) == 47
cursor = await conn.execute(
"""
SELECT name
FROM sqlite_master
WHERE type = 'index'
AND name IN (
'idx_raw_packets_timestamp',
'idx_contacts_type_last_seen',
'idx_messages_type_received_conversation'
)
ORDER BY name
"""
)
rows = await cursor.fetchall()
assert [row["name"] for row in rows] == [
"idx_contacts_type_last_seen",
"idx_messages_type_received_conversation",
"idx_raw_packets_timestamp",
]
finally:
await conn.close()
class TestMigrationPacketHelpers:
"""Test migration-local packet helpers against canonical path validation."""
+38 -1
View File
@@ -5,7 +5,7 @@ undecrypted count endpoint, and the maintenance endpoint.
"""
import time
from unittest.mock import patch
from unittest.mock import AsyncMock, patch
import pytest
@@ -305,6 +305,43 @@ class TestDecryptHistoricalPackets:
assert "key_type" in data["detail"].lower()
class TestUndecryptedTextPacketStreaming:
@pytest.mark.asyncio
async def test_count_undecrypted_text_messages_uses_batched_streaming(self, test_db):
"""Counting undecrypted DM packets should stream batches and filter by payload type."""
class FakeCursor:
def __init__(self):
self._batches = [
[
{"id": 1, "data": b"\x09\x00dm", "timestamp": 1000},
{"id": 2, "data": b"\x15\x00chan", "timestamp": 1001},
],
[{"id": 3, "data": b"\x09\x00dm2", "timestamp": 1002}],
[],
]
self.fetchall_called = False
async def fetchmany(self, size):
assert size > 0
return self._batches.pop(0)
async def close(self):
return None
async def fetchall(self):
self.fetchall_called = True
raise AssertionError("fetchall() should not be used")
fake_cursor = FakeCursor()
with patch.object(test_db.conn, "execute", new=AsyncMock(return_value=fake_cursor)):
count = await RawPacketRepository.count_undecrypted_text_messages(batch_size=2)
assert fake_cursor.fetchall_called is False
assert count == 2
class TestRunHistoricalChannelDecryption:
"""Test the _run_historical_channel_decryption background task."""
+240 -3
View File
@@ -2,14 +2,14 @@
import asyncio
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import ANY, AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException
from meshcore import EventType
from pydantic import ValidationError
from app.models import Contact
from app.models import CONTACT_TYPE_REPEATER, Contact, RadioTraceHopRequest, RadioTraceRequest
from app.radio import RadioManager, radio_manager
from app.routers.radio import (
PrivateKeyUpdate,
@@ -25,6 +25,7 @@ from app.routers.radio import (
reconnect_radio,
send_advertisement,
set_private_key,
trace_path,
update_radio_config,
)
from app.services.radio_runtime import RadioRuntime
@@ -375,6 +376,11 @@ class TestDiscoverMesh:
return_value=None,
),
patch("app.routers.radio.ContactRepository.upsert", new_callable=AsyncMock),
patch(
"app.routers.radio.promote_prefix_contacts_for_contact",
new_callable=AsyncMock,
return_value=[],
),
patch("app.routers.radio.broadcast_event"),
):
response = await discover_mesh(RadioDiscoveryRequest(target="repeaters"))
@@ -436,18 +442,27 @@ class TestDiscoverMesh:
patch(
"app.routers.radio.ContactRepository.get_by_key",
new_callable=AsyncMock,
side_effect=[None, created_contact],
# 1st: _persist check (not found), 2nd: _persist re-fetch (created),
# 3rd: _attach_known_names lookup
side_effect=[None, created_contact, created_contact],
) as mock_get_by_key,
patch(
"app.routers.radio.ContactRepository.upsert", new_callable=AsyncMock
) as mock_upsert,
patch(
"app.routers.radio.promote_prefix_contacts_for_contact",
new_callable=AsyncMock,
return_value=[],
) as mock_promote,
patch("app.routers.radio.broadcast_event") as mock_broadcast,
):
response = await discover_mesh(RadioDiscoveryRequest(target="repeaters"))
assert len(response.results) == 1
assert response.results[0].name is None # created_contact has no name
mock_get_by_key.assert_awaited()
mock_upsert.assert_awaited_once()
mock_promote.assert_awaited_once_with(public_key="44" * 32, log=ANY)
upsert_arg = mock_upsert.await_args.args[0]
assert upsert_arg.public_key == "44" * 32
assert upsert_arg.type == 2
@@ -510,6 +525,223 @@ class TestDiscoverMesh:
mock_upsert.assert_not_awaited()
mock_broadcast.assert_not_called()
class TestTracePath:
@pytest.mark.asyncio
async def test_returns_resolved_nodes_for_multi_hop_trace(self):
mc = _mock_meshcore_with_info()
repeater_a = Contact(
public_key="11" * 32,
name="Relay Alpha",
type=CONTACT_TYPE_REPEATER,
flags=0,
direct_path=None,
direct_path_len=-1,
direct_path_hash_mode=-1,
last_advert=None,
lat=None,
lon=None,
last_seen=None,
on_radio=False,
last_contacted=None,
last_read_at=None,
first_seen=None,
)
repeater_b = Contact(
public_key="22" * 32,
name="Relay Beta",
type=CONTACT_TYPE_REPEATER,
flags=0,
direct_path=None,
direct_path_len=-1,
direct_path_hash_mode=-1,
last_advert=None,
lat=None,
lon=None,
last_seen=None,
on_radio=False,
last_contacted=None,
last_read_at=None,
first_seen=None,
)
mc.commands.send_trace = AsyncMock(
return_value=_radio_result(EventType.MSG_SENT, {"suggested_timeout": 4000})
)
mc.wait_for_event = AsyncMock(
return_value=MagicMock(
payload={
"path_len": 2,
"path": [
{"hash": "11111111", "snr": 7.5},
{"hash": "22222222", "snr": 3.25},
{"snr": 5.0},
],
}
)
)
with (
patch("app.routers.radio.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch(
"app.routers.radio.ContactRepository.get_by_key", new_callable=AsyncMock
) as mock_get,
patch("app.routers.radio.radio_manager") as mock_rm,
):
mock_get.side_effect = [repeater_a, repeater_b]
mock_rm.radio_operation = _noop_radio_operation(mc)
response = await trace_path(
RadioTraceRequest(
hop_hash_bytes=4,
hops=[
RadioTraceHopRequest(public_key=repeater_a.public_key),
RadioTraceHopRequest(public_key=repeater_b.public_key),
],
)
)
mc.commands.send_trace.assert_awaited_once_with(
path="11111111,22222222",
tag=ANY,
flags=2,
)
mc.wait_for_event.assert_awaited_once()
assert response.path_len == 2
assert response.nodes[0].name == "Relay Alpha"
assert response.nodes[0].snr == 7.5
assert response.nodes[1].name == "Relay Beta"
assert response.nodes[1].observed_hash == "22222222"
assert response.nodes[2].role == "local"
assert response.nodes[2].public_key == "aa" * 32
assert response.nodes[2].observed_hash is None
assert response.nodes[2].snr == 5.0
@pytest.mark.asyncio
async def test_rejects_non_repeater_nodes(self):
mc = _mock_meshcore_with_info()
non_repeater = Contact(
public_key="33" * 32,
name="Client",
type=1,
flags=0,
direct_path=None,
direct_path_len=-1,
direct_path_hash_mode=-1,
last_advert=None,
lat=None,
lon=None,
last_seen=None,
on_radio=False,
last_contacted=None,
last_read_at=None,
first_seen=None,
)
with (
patch("app.routers.radio.require_connected", return_value=mc),
patch(
"app.routers.radio.ContactRepository.get_by_key", new_callable=AsyncMock
) as mock_get,
):
mock_get.return_value = non_repeater
with pytest.raises(HTTPException) as exc:
await trace_path(
RadioTraceRequest(
hop_hash_bytes=4,
hops=[RadioTraceHopRequest(public_key=non_repeater.public_key)],
)
)
assert exc.value.status_code == 400
assert "not a repeater" in exc.value.detail
@pytest.mark.asyncio
async def test_returns_504_when_no_trace_response_is_heard(self):
mc = _mock_meshcore_with_info()
repeater = Contact(
public_key="44" * 32,
name="Relay",
type=CONTACT_TYPE_REPEATER,
flags=0,
direct_path=None,
direct_path_len=-1,
direct_path_hash_mode=-1,
last_advert=None,
lat=None,
lon=None,
last_seen=None,
on_radio=False,
last_contacted=None,
last_read_at=None,
first_seen=None,
)
mc.commands.send_trace = AsyncMock(
return_value=_radio_result(EventType.MSG_SENT, {"suggested_timeout": 1000})
)
mc.wait_for_event = AsyncMock(return_value=None)
with (
patch("app.routers.radio.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch(
"app.routers.radio.ContactRepository.get_by_key", new_callable=AsyncMock
) as mock_get,
patch("app.routers.radio.radio_manager") as mock_rm,
):
mock_get.return_value = repeater
mock_rm.radio_operation = _noop_radio_operation(mc)
with pytest.raises(HTTPException) as exc:
await trace_path(
RadioTraceRequest(
hop_hash_bytes=4,
hops=[RadioTraceHopRequest(public_key=repeater.public_key)],
)
)
assert exc.value.status_code == 504
assert "No trace response heard" in exc.value.detail
@pytest.mark.asyncio
async def test_supports_custom_hops_with_shorter_hash_width(self):
mc = _mock_meshcore_with_info()
mc.commands.send_trace = AsyncMock(
return_value=_radio_result(EventType.MSG_SENT, {"suggested_timeout": 2500})
)
mc.wait_for_event = AsyncMock(
return_value=MagicMock(
payload={
"path_len": 2,
"path": [
{"hash": "ae", "snr": 4.0},
{"hash": "bf", "snr": 2.5},
{"snr": 3.0},
],
}
)
)
with (
patch("app.routers.radio.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch("app.routers.radio.radio_manager") as mock_rm,
):
mock_rm.radio_operation = _noop_radio_operation(mc)
response = await trace_path(
RadioTraceRequest(
hop_hash_bytes=1,
hops=[
RadioTraceHopRequest(hop_hex="ae"),
RadioTraceHopRequest(hop_hex="bf"),
],
)
)
mc.commands.send_trace.assert_awaited_once_with(path="ae,bf", tag=ANY, flags=0)
assert response.nodes[0].role == "custom"
assert response.nodes[0].observed_hash == "ae"
assert response.nodes[1].role == "custom"
assert response.nodes[1].observed_hash == "bf"
@pytest.mark.asyncio
async def test_discovers_all_supported_types(self):
mc = _mock_meshcore_with_info()
@@ -542,6 +774,11 @@ class TestDiscoverMesh:
return_value=None,
),
patch("app.routers.radio.ContactRepository.upsert", new_callable=AsyncMock),
patch(
"app.routers.radio.promote_prefix_contacts_for_contact",
new_callable=AsyncMock,
return_value=[],
),
patch("app.routers.radio.broadcast_event"),
):
response = await discover_mesh(RadioDiscoveryRequest(target="all"))
+68 -33
View File
@@ -12,7 +12,6 @@ from app.repository import ContactRepository
from app.routers.contacts import request_trace
from app.routers.repeaters import (
_batch_cli_fetch,
_fetch_repeater_response,
prepare_repeater_connection,
repeater_acl,
repeater_advert_intervals,
@@ -25,12 +24,17 @@ from app.routers.repeaters import (
repeater_status,
send_repeater_command,
)
from app.routers.server_control import fetch_contact_cli_response
KEY_A = "aa" * 32
# Patch target for the wall-clock wrapper used by _fetch_repeater_response.
# Patch target for the wall-clock wrapper used by fetch_contact_cli_response.
# We patch _monotonic (not time.monotonic) to avoid breaking the asyncio event loop.
_MONOTONIC = "app.routers.repeaters._monotonic"
_MONOTONIC = "app.routers.server_control._monotonic"
# Patch targets for the store helpers called on consumed non-target messages.
_STORE_DM = "app.routers.server_control._store_pending_direct_message"
_STORE_CHAN = "app.routers.server_control._store_pending_channel_message"
@pytest.fixture(autouse=True)
@@ -104,8 +108,8 @@ def _advancing_clock(start=0.0, step=0.1):
return _tick
class TestFetchRepeaterResponse:
"""Tests for the _fetch_repeater_response helper."""
class TestFetchContactCliResponse:
"""Tests for the fetch_contact_cli_response helper."""
@pytest.mark.asyncio
async def test_returns_matching_cli_response(self):
@@ -118,7 +122,7 @@ class TestFetchRepeaterResponse:
)
with patch(_MONOTONIC, side_effect=_advancing_clock()):
result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=5.0)
result = await fetch_contact_cli_response(mc, "aaaaaaaaaaaa", timeout=5.0)
assert result is not None
assert result.payload["text"] == "ok"
@@ -138,16 +142,20 @@ class TestFetchRepeaterResponse:
)
mc.commands.get_msg = AsyncMock(side_effect=[non_cli, cli_response])
with patch(_MONOTONIC, side_effect=_advancing_clock()):
result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=5.0)
with (
patch(_MONOTONIC, side_effect=_advancing_clock()),
patch(_STORE_DM, new_callable=AsyncMock) as store_dm,
):
result = await fetch_contact_cli_response(mc, "aaaaaaaaaaaa", timeout=5.0)
assert result is not None
assert result.payload["text"] == "ver 1.0"
assert mc.commands.get_msg.await_count == 2
store_dm.assert_awaited_once_with(non_cli)
@pytest.mark.asyncio
async def test_unrelated_dm_is_skipped(self):
"""Unrelated DMs are skipped (dispatcher already handled them)."""
async def test_unrelated_dm_is_stored(self):
"""Unrelated DMs consumed during CLI fetch are stored, not discarded."""
mc = _mock_mc()
unrelated = _radio_result(
EventType.CONTACT_MSG_RECV,
@@ -159,14 +167,18 @@ class TestFetchRepeaterResponse:
)
mc.commands.get_msg = AsyncMock(side_effect=[unrelated, expected])
with patch(_MONOTONIC, side_effect=_advancing_clock()):
result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=5.0)
with (
patch(_MONOTONIC, side_effect=_advancing_clock()),
patch(_STORE_DM, new_callable=AsyncMock) as store_dm,
):
result = await fetch_contact_cli_response(mc, "aaaaaaaaaaaa", timeout=5.0)
assert result is not None
assert result.payload["text"] == "ver 1.0"
store_dm.assert_awaited_once_with(unrelated)
@pytest.mark.asyncio
async def test_channel_message_is_skipped(self):
async def test_channel_message_is_stored(self):
mc = _mock_mc()
channel_msg = _radio_result(
EventType.CHANNEL_MSG_RECV,
@@ -178,11 +190,15 @@ class TestFetchRepeaterResponse:
)
mc.commands.get_msg = AsyncMock(side_effect=[channel_msg, expected])
with patch(_MONOTONIC, side_effect=_advancing_clock()):
result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=5.0)
with (
patch(_MONOTONIC, side_effect=_advancing_clock()),
patch(_STORE_CHAN, new_callable=AsyncMock) as store_chan,
):
result = await fetch_contact_cli_response(mc, "aaaaaaaaaaaa", timeout=5.0)
assert result is not None
assert result.payload["text"] == "ok"
store_chan.assert_awaited_once_with(mc, channel_msg.payload)
@pytest.mark.asyncio
async def test_no_more_msgs_retries_then_succeeds(self):
@@ -196,9 +212,9 @@ class TestFetchRepeaterResponse:
with (
patch(_MONOTONIC, side_effect=_advancing_clock()),
patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock),
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
):
result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=5.0)
result = await fetch_contact_cli_response(mc, "aaaaaaaaaaaa", timeout=5.0)
assert result is not None
assert result.payload["text"] == "ok"
@@ -215,9 +231,9 @@ class TestFetchRepeaterResponse:
with (
patch(_MONOTONIC, side_effect=times),
patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock),
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
):
result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=2.0)
result = await fetch_contact_cli_response(mc, "aaaaaaaaaaaa", timeout=2.0)
assert result is None
@@ -233,16 +249,16 @@ class TestFetchRepeaterResponse:
with (
patch(_MONOTONIC, side_effect=_advancing_clock()),
patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock),
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
):
result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=5.0)
result = await fetch_contact_cli_response(mc, "aaaaaaaaaaaa", timeout=5.0)
assert result is not None
assert result.payload["text"] == "ok"
@pytest.mark.asyncio
async def test_high_traffic_does_not_exhaust_budget(self):
"""Many unrelated messages don't prevent eventual success (wall-clock deadline)."""
async def test_high_traffic_stores_all_consumed_messages(self):
"""Many unrelated messages are stored and don't prevent eventual success."""
mc = _mock_mc()
# 20 unrelated DMs followed by the expected CLI response
unrelated = [
@@ -258,12 +274,16 @@ class TestFetchRepeaterResponse:
)
mc.commands.get_msg = AsyncMock(side_effect=[*unrelated, expected])
with patch(_MONOTONIC, side_effect=_advancing_clock()):
result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=30.0)
with (
patch(_MONOTONIC, side_effect=_advancing_clock()),
patch(_STORE_DM, new_callable=AsyncMock) as store_dm,
):
result = await fetch_contact_cli_response(mc, "aaaaaaaaaaaa", timeout=30.0)
assert result is not None
assert result.payload["text"] == "ver 1.0"
assert mc.commands.get_msg.await_count == 21
assert store_dm.await_count == 20
class TestRepeaterCommandRoute:
@@ -297,7 +317,7 @@ class TestRepeaterCommandRoute:
patch("app.routers.repeaters.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch(_MONOTONIC, side_effect=[0.0, 5.0, 25.0]),
patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock),
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
):
response = await send_repeater_command(KEY_A, CommandRequest(command="ver"))
@@ -457,7 +477,7 @@ class TestRepeaterCommandRoute:
patch("app.routers.repeaters.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch(_MONOTONIC, side_effect=_advancing_clock()),
patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock),
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
):
response = await send_repeater_command(KEY_A, CommandRequest(command="ver"))
@@ -483,6 +503,11 @@ class TestTraceRoute:
await request_trace(KEY_A)
assert exc.value.status_code == 500
mc.commands.send_trace.assert_awaited_once_with(
path=KEY_A[:8],
tag=1234,
flags=2,
)
@pytest.mark.asyncio
async def test_wait_timeout_returns_504(self, test_db):
@@ -500,6 +525,11 @@ class TestTraceRoute:
await request_trace(KEY_A)
assert exc.value.status_code == 504
mc.commands.send_trace.assert_awaited_once_with(
path=KEY_A[:8],
tag=1234,
flags=2,
)
@pytest.mark.asyncio
async def test_success_returns_remote_and_local_snr(self, test_db):
@@ -520,6 +550,11 @@ class TestTraceRoute:
assert response.remote_snr == 5.5
assert response.local_snr == 3.2
assert response.path_len == 2
mc.commands.send_trace.assert_awaited_once_with(
path=KEY_A[:8],
tag=1234,
flags=2,
)
# ---------------------------------------------------------------------------
@@ -983,7 +1018,7 @@ class TestRepeaterRadioSettings:
patch("app.routers.repeaters.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch(_MONOTONIC, side_effect=clock_ticks),
patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock),
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
):
response = await repeater_radio_settings(KEY_A)
@@ -1058,7 +1093,7 @@ class TestRepeaterNodeInfo:
patch("app.routers.repeaters.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch(_MONOTONIC, side_effect=clock_ticks),
patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock),
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
):
response = await repeater_node_info(KEY_A)
@@ -1111,7 +1146,7 @@ class TestRepeaterAdvertIntervals:
patch("app.routers.repeaters.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch(_MONOTONIC, side_effect=clock_ticks),
patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock),
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
):
response = await repeater_advert_intervals(KEY_A)
@@ -1166,7 +1201,7 @@ class TestRepeaterOwnerInfo:
patch("app.routers.repeaters.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch(_MONOTONIC, side_effect=clock_ticks),
patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock),
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
):
response = await repeater_owner_info(KEY_A)
@@ -1224,7 +1259,7 @@ class TestBatchCliFetch:
with (
patch.object(radio_manager, "_meshcore", mc),
patch(_MONOTONIC, side_effect=_advancing_clock()),
patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock),
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
):
results = await _batch_cli_fetch(
contact, "test_op", [("bad_cmd", "field_a"), ("good_cmd", "field_b")]
@@ -1245,7 +1280,7 @@ class TestBatchCliFetch:
with (
patch.object(radio_manager, "_meshcore", mc),
patch(_MONOTONIC, side_effect=[0.0, 5.0, 11.0]),
patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock),
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
):
results = await _batch_cli_fetch(contact, "test_op", [("clock", "clock_output")])
+74
View File
@@ -1,6 +1,8 @@
"""Tests for the statistics repository and endpoint."""
import time
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
@@ -347,3 +349,75 @@ class TestPathHashWidthStats:
assert breakdown["single_byte_pct"] == pytest.approx(100 / 3, rel=1e-3)
assert breakdown["double_byte_pct"] == pytest.approx(100 / 3, rel=1e-3)
assert breakdown["triple_byte_pct"] == pytest.approx(100 / 3, rel=1e-3)
@pytest.mark.asyncio
async def test_path_hash_width_scan_uses_batched_fetchmany(self, test_db):
"""Hash-width stats should stream batches instead of calling fetchall()."""
class FakeCursor:
def __init__(self):
self._batches = [
[{"data": b"a"}, {"data": b"b"}],
[{"data": b"c"}],
[],
]
self.fetchall_called = False
async def fetchmany(self, size):
assert size > 0
return self._batches.pop(0)
async def fetchall(self):
self.fetchall_called = True
raise AssertionError("fetchall() should not be used")
fake_cursor = FakeCursor()
def fake_parse(raw_packet: bytes):
hash_sizes = {
b"a": 1,
b"b": 2,
b"c": 3,
}
hash_size = hash_sizes.get(raw_packet)
if hash_size is None:
return None
return SimpleNamespace(hash_size=hash_size)
with (
patch.object(test_db.conn, "execute", new=AsyncMock(return_value=fake_cursor)),
patch("app.repository.settings.parse_packet_envelope", side_effect=fake_parse),
):
breakdown = await StatisticsRepository._path_hash_width_24h()
assert fake_cursor.fetchall_called is False
assert breakdown["total_packets"] == 3
assert breakdown["single_byte"] == 1
assert breakdown["double_byte"] == 1
assert breakdown["triple_byte"] == 1
class TestStatisticsEndpoint:
@pytest.mark.asyncio
async def test_statistics_endpoint_includes_noise_floor_history(self, test_db, client):
noise_floor_history = {
"sample_interval_seconds": 300,
"coverage_seconds": 1800,
"latest_noise_floor_dbm": -119,
"latest_timestamp": 1_700_000_000,
"supported": True,
"samples": [
{"timestamp": 1_699_998_200, "noise_floor_dbm": -121},
{"timestamp": 1_700_000_000, "noise_floor_dbm": -119},
],
}
with patch(
"app.routers.statistics.get_noise_floor_history",
new=AsyncMock(return_value=noise_floor_history),
):
response = await client.get("/api/statistics")
assert response.status_code == 200
payload = response.json()
assert payload["noise_floor_24h"] == noise_floor_history