mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-03 16:32:00 +02:00
Merge branch 'main' of github.com:maplemesh/Remote-Terminal-for-MeshCore into gnomeadrift/repeater_telemetry_history
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -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
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
@@ -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"))
|
||||
|
||||
@@ -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")])
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user