mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-06 01:42:11 +02:00
Try out request-scoped subscription for console commands
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
"""Tests for the pre-send CLI buffer flush.
|
||||
|
||||
These exercise the *real* ``_flush_pending_messages`` (unlike the route tests in
|
||||
``test_repeater_routes.py``, which neutralize it), including the regression
|
||||
guard that a stale buffered CLI response is not mis-attributed to a later
|
||||
command.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from meshcore import EventType
|
||||
|
||||
from app.models import CommandRequest
|
||||
from app.radio import radio_manager
|
||||
from app.repository import ContactRepository
|
||||
from app.routers import server_control
|
||||
from app.routers.repeaters import send_repeater_command
|
||||
|
||||
KEY_A = "aa" * 32
|
||||
|
||||
# Patch target for the wall-clock wrapper used by fetch_contact_cli_response.
|
||||
_MONOTONIC = "app.routers.server_control._monotonic"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_radio_state():
|
||||
"""Save/restore radio_manager state so tests don't leak."""
|
||||
prev = radio_manager._meshcore
|
||||
prev_lock = radio_manager._operation_lock
|
||||
yield
|
||||
radio_manager._meshcore = prev
|
||||
radio_manager._operation_lock = prev_lock
|
||||
|
||||
|
||||
def _radio_result(event_type=EventType.OK, payload=None):
|
||||
result = MagicMock()
|
||||
result.type = event_type
|
||||
result.payload = payload or {}
|
||||
return result
|
||||
|
||||
|
||||
def _advancing_clock(start=0.0, step=0.1):
|
||||
t = start
|
||||
|
||||
def _tick():
|
||||
nonlocal t
|
||||
val = t
|
||||
t += step
|
||||
return val
|
||||
|
||||
return _tick
|
||||
|
||||
|
||||
def _mock_mc():
|
||||
mc = MagicMock()
|
||||
mc.commands = MagicMock()
|
||||
mc.commands.send_cmd = AsyncMock(return_value=_radio_result(EventType.OK))
|
||||
mc.commands.get_msg = AsyncMock(return_value=_radio_result(EventType.NO_MORE_MSGS))
|
||||
mc.commands.add_contact = AsyncMock(return_value=_radio_result(EventType.OK))
|
||||
mc.subscribe = MagicMock(return_value=MagicMock(unsubscribe=MagicMock()))
|
||||
mc.stop_auto_message_fetching = AsyncMock()
|
||||
mc.start_auto_message_fetching = AsyncMock()
|
||||
return mc
|
||||
|
||||
|
||||
async def _insert_contact(public_key: str, name: str = "Repeater", contact_type: int = 2):
|
||||
await ContactRepository.upsert(
|
||||
{
|
||||
"public_key": public_key,
|
||||
"name": name,
|
||||
"type": contact_type,
|
||||
"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,
|
||||
"first_seen": None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TestFlushPendingMessages:
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_drains_pending_buffer(self):
|
||||
mc = _mock_mc()
|
||||
with patch(
|
||||
"app.routers.server_control.drain_pending_messages",
|
||||
new_callable=AsyncMock,
|
||||
return_value=2,
|
||||
) as drain:
|
||||
await server_control._flush_pending_messages(mc)
|
||||
|
||||
drain.assert_awaited_once_with(mc)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_swallows_drain_errors(self):
|
||||
"""A flaky radio mid-flush must not abort the command."""
|
||||
mc = _mock_mc()
|
||||
with patch(
|
||||
"app.routers.server_control.drain_pending_messages",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=RuntimeError("radio gone"),
|
||||
):
|
||||
# Must not raise.
|
||||
await server_control._flush_pending_messages(mc)
|
||||
|
||||
|
||||
class TestStaleResponseRegression:
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_buffered_cli_response_is_flushed_not_returned(self, test_db):
|
||||
"""A stale CLI response buffered before the command is drained, so the
|
||||
fetch returns the fresh response rather than the leftover one.
|
||||
|
||||
Without the pre-send flush, the fetch loop would pull ``stale`` (same
|
||||
contact, txt_type=1) and return it as the answer to ``get lat``.
|
||||
"""
|
||||
mc = _mock_mc()
|
||||
await _insert_contact(KEY_A)
|
||||
|
||||
stale = _radio_result(
|
||||
EventType.CONTACT_MSG_RECV,
|
||||
{"pubkey_prefix": KEY_A[:12], "text": "stale-name", "txt_type": 1},
|
||||
)
|
||||
no_more = _radio_result(EventType.NO_MORE_MSGS)
|
||||
fresh = _radio_result(
|
||||
EventType.CONTACT_MSG_RECV,
|
||||
{"pubkey_prefix": KEY_A[:12], "text": "fresh-lat", "txt_type": 1},
|
||||
)
|
||||
# Flush drains [stale, no_more]; the subsequent fetch then sees [fresh].
|
||||
mc.commands.get_msg = AsyncMock(side_effect=[stale, no_more, fresh])
|
||||
|
||||
with (
|
||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
||||
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
|
||||
):
|
||||
response = await send_repeater_command(KEY_A, CommandRequest(command="get lat"))
|
||||
|
||||
assert response.command == "get lat"
|
||||
assert response.response == "fresh-lat"
|
||||
@@ -47,6 +47,24 @@ def _reset_radio_state():
|
||||
radio_manager._operation_lock = prev_lock
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_op_pre_send_flush():
|
||||
"""Neutralize the pre-send buffer flush for command/batch route tests.
|
||||
|
||||
``_flush_pending_messages`` drains ``mc.commands.get_msg``, which the tests
|
||||
in this module mock to return fetch responses; flushing here would consume
|
||||
them. The flush behavior and its stale-response regression guard are covered
|
||||
in ``test_cli_stale_response_flush.py``, which exercises the real flush.
|
||||
Tests in ``TestFetchContactCliResponse`` call ``fetch_contact_cli_response``
|
||||
directly and never reach the flush, so this patch is a harmless no-op there.
|
||||
"""
|
||||
with patch(
|
||||
"app.routers.server_control._flush_pending_messages",
|
||||
new_callable=AsyncMock,
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
def _radio_result(event_type=EventType.OK, payload=None):
|
||||
result = MagicMock()
|
||||
result.type = event_type
|
||||
@@ -285,6 +303,101 @@ class TestFetchContactCliResponse:
|
||||
assert mc.commands.get_msg.await_count == 21
|
||||
assert store_dm.await_count == 20
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscription_captures_response_when_get_msg_misses_it(self):
|
||||
"""The drop race: get_msg never returns the response, but the
|
||||
request-scoped subscription captures the cloned push event."""
|
||||
mc = _mock_mc()
|
||||
captured: dict = {}
|
||||
|
||||
def _fake_subscribe(event_type, callback, attribute_filters=None):
|
||||
captured["cb"] = callback
|
||||
return MagicMock(unsubscribe=MagicMock())
|
||||
|
||||
mc.subscribe = MagicMock(side_effect=_fake_subscribe)
|
||||
|
||||
push_event = _radio_result(
|
||||
EventType.CONTACT_MSG_RECV,
|
||||
{"pubkey_prefix": "aaaaaaaaaaaa", "text": "ver 1.0", "txt_type": 1},
|
||||
)
|
||||
calls = {"n": 0}
|
||||
|
||||
async def _fake_get_msg(timeout=None):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
# Simulate the response being delivered to the permanent
|
||||
# subscriber path (our scoped subscription) rather than via
|
||||
# this get_msg's return value.
|
||||
captured["cb"](push_event)
|
||||
return _radio_result(EventType.NO_MORE_MSGS)
|
||||
|
||||
mc.commands.get_msg = _fake_get_msg
|
||||
|
||||
with (
|
||||
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
||||
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
|
||||
):
|
||||
result = await fetch_contact_cli_response(mc, "aaaaaaaaaaaa", timeout=5.0)
|
||||
|
||||
assert result is not None
|
||||
assert result.payload["text"] == "ver 1.0"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscription_uses_target_and_cli_filter(self):
|
||||
"""The scoped subscription filters on the target prefix and txt_type=1."""
|
||||
mc = _mock_mc()
|
||||
mc.commands.get_msg = AsyncMock(
|
||||
return_value=_radio_result(
|
||||
EventType.CONTACT_MSG_RECV,
|
||||
{"pubkey_prefix": "aaaaaaaaaaaa", "text": "ok", "txt_type": 1},
|
||||
)
|
||||
)
|
||||
|
||||
with patch(_MONOTONIC, side_effect=_advancing_clock()):
|
||||
await fetch_contact_cli_response(mc, "aaaaaaaaaaaa", timeout=5.0)
|
||||
|
||||
args, kwargs = mc.subscribe.call_args
|
||||
assert args[0] == EventType.CONTACT_MSG_RECV
|
||||
assert kwargs["attribute_filters"] == {
|
||||
"pubkey_prefix": "aaaaaaaaaaaa",
|
||||
"txt_type": 1,
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribes_on_success(self):
|
||||
mc = _mock_mc()
|
||||
sub = MagicMock(unsubscribe=MagicMock())
|
||||
mc.subscribe = MagicMock(return_value=sub)
|
||||
mc.commands.get_msg = AsyncMock(
|
||||
return_value=_radio_result(
|
||||
EventType.CONTACT_MSG_RECV,
|
||||
{"pubkey_prefix": "aaaaaaaaaaaa", "text": "ok", "txt_type": 1},
|
||||
)
|
||||
)
|
||||
|
||||
with patch(_MONOTONIC, side_effect=_advancing_clock()):
|
||||
result = await fetch_contact_cli_response(mc, "aaaaaaaaaaaa", timeout=5.0)
|
||||
|
||||
assert result is not None
|
||||
sub.unsubscribe.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribes_on_timeout(self):
|
||||
mc = _mock_mc()
|
||||
sub = MagicMock(unsubscribe=MagicMock())
|
||||
mc.subscribe = MagicMock(return_value=sub)
|
||||
mc.commands.get_msg = AsyncMock(return_value=_radio_result(EventType.NO_MORE_MSGS))
|
||||
times = iter([100.0, 100.5, 101.0, 103.0])
|
||||
|
||||
with (
|
||||
patch(_MONOTONIC, side_effect=times),
|
||||
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
|
||||
):
|
||||
result = await fetch_contact_cli_response(mc, "aaaaaaaaaaaa", timeout=2.0)
|
||||
|
||||
assert result is None
|
||||
sub.unsubscribe.assert_called_once()
|
||||
|
||||
|
||||
class TestRepeaterCommandRoute:
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user