mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-31 05:53:04 +02:00
Fix unscoped flood-scope handling
This commit is contained in:
@@ -32,6 +32,26 @@ class TestChannelFloodScopeOverride:
|
||||
mock_broadcast.assert_called_once()
|
||||
assert mock_broadcast.call_args.args[0] == "channel"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unscoped_marker_forces_channel_unscoped(self, test_db, client):
|
||||
"""'*' persists as the canonical unscoped marker (issue #303), distinct
|
||||
from blank (which clears the override / inherits the global scope)."""
|
||||
key = "DD" * 16
|
||||
await ChannelRepository.upsert(key=key, name="#flightless", is_hashtag=True)
|
||||
|
||||
with patch("app.routers.channels.broadcast_event"):
|
||||
response = await client.post(
|
||||
f"/api/channels/{key}/flood-scope-override",
|
||||
json={"flood_scope_override": "*"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["flood_scope_override"] == "*"
|
||||
|
||||
channel = await ChannelRepository.get_by_key(key)
|
||||
assert channel is not None
|
||||
assert channel.flood_scope_override == "*"
|
||||
|
||||
|
||||
class TestCreateChannel:
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from meshcore import EventType
|
||||
|
||||
from app.services.flood_scope import (
|
||||
FIRMWARE_VER_UNSCOPED_MODE,
|
||||
FORCE_UNSCOPED_FRAME,
|
||||
firmware_supports_unscoped_mode,
|
||||
set_radio_flood_scope,
|
||||
)
|
||||
|
||||
# A protocol version that supports the mode-1 unscoped command (>= 12).
|
||||
FW_SUPPORTS_UNSCOPED = FIRMWARE_VER_UNSCOPED_MODE
|
||||
# A protocol version that predates the mode-1 command.
|
||||
FW_NO_UNSCOPED = FIRMWARE_VER_UNSCOPED_MODE - 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_radio_flood_scope_uses_meshcore_scope_for_regions():
|
||||
mc = MagicMock()
|
||||
mc.commands.set_flood_scope = AsyncMock(return_value="ok")
|
||||
mc.commands.send = AsyncMock()
|
||||
|
||||
# Region path is version-independent.
|
||||
result = await set_radio_flood_scope(mc, "Esperance", fw_ver=FW_NO_UNSCOPED)
|
||||
|
||||
assert result == "ok"
|
||||
mc.commands.set_flood_scope.assert_awaited_once_with("#Esperance")
|
||||
mc.commands.send.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_radio_flood_scope_empty_uses_firmware_unscoped_mode_on_v12():
|
||||
mc = MagicMock()
|
||||
mc.commands.set_flood_scope = AsyncMock()
|
||||
mc.commands.send = AsyncMock(return_value="ok")
|
||||
|
||||
result = await set_radio_flood_scope(mc, "", fw_ver=FW_SUPPORTS_UNSCOPED)
|
||||
|
||||
assert result == "ok"
|
||||
mc.commands.send.assert_awaited_once_with(FORCE_UNSCOPED_FRAME, [EventType.OK, EventType.ERROR])
|
||||
mc.commands.set_flood_scope.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("scope", [None, " ", "0", "*"])
|
||||
async def test_set_radio_flood_scope_unscoped_sentinels_use_firmware_mode_on_v12(scope):
|
||||
mc = MagicMock()
|
||||
mc.commands.set_flood_scope = AsyncMock()
|
||||
mc.commands.send = AsyncMock(return_value="ok")
|
||||
|
||||
result = await set_radio_flood_scope(mc, scope, fw_ver=FW_SUPPORTS_UNSCOPED)
|
||||
|
||||
assert result == "ok"
|
||||
mc.commands.send.assert_awaited_once_with(FORCE_UNSCOPED_FRAME, [EventType.OK, EventType.ERROR])
|
||||
mc.commands.set_flood_scope.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("fw_ver", [None, FW_NO_UNSCOPED])
|
||||
async def test_set_radio_flood_scope_unscoped_falls_back_on_old_or_unknown_firmware(fw_ver):
|
||||
"""Pre-v12 (or unknown) firmware has no mode-1 command; reset scope via mode 0."""
|
||||
mc = MagicMock()
|
||||
mc.commands.set_flood_scope = AsyncMock(return_value="ok")
|
||||
mc.commands.send = AsyncMock()
|
||||
|
||||
result = await set_radio_flood_scope(mc, "", fw_ver=fw_ver)
|
||||
|
||||
assert result == "ok"
|
||||
mc.commands.set_flood_scope.assert_awaited_once_with("")
|
||||
mc.commands.send.assert_not_awaited()
|
||||
|
||||
|
||||
def test_firmware_supports_unscoped_mode():
|
||||
assert firmware_supports_unscoped_mode(FIRMWARE_VER_UNSCOPED_MODE) is True
|
||||
assert firmware_supports_unscoped_mode(FIRMWARE_VER_UNSCOPED_MODE + 1) is True
|
||||
assert firmware_supports_unscoped_mode(FIRMWARE_VER_UNSCOPED_MODE - 1) is False
|
||||
assert firmware_supports_unscoped_mode(None) is False
|
||||
+13
-1
@@ -6,8 +6,11 @@ import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from meshcore import EventType
|
||||
from serial.serialutil import SerialException
|
||||
|
||||
from app.services.flood_scope import FORCE_UNSCOPED_FRAME
|
||||
|
||||
|
||||
class TestRadioManagerConnect:
|
||||
"""Test that connect() dispatches to the correct transport."""
|
||||
@@ -977,6 +980,12 @@ class TestPostConnectSetupOrdering:
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.start_auto_message_fetching = AsyncMock()
|
||||
mock_mc.commands.set_flood_scope = AsyncMock()
|
||||
mock_mc.commands.send = AsyncMock()
|
||||
# Flood scope is applied after the device query, so report a protocol
|
||||
# version that supports the mode-1 unscoped command (>= 12).
|
||||
device_query = MagicMock()
|
||||
device_query.payload = {"fw ver": 13}
|
||||
mock_mc.commands.send_device_query = AsyncMock(return_value=device_query)
|
||||
rm._meshcore = mock_mc
|
||||
|
||||
mock_settings = AppSettings(flood_scope="")
|
||||
@@ -999,7 +1008,10 @@ class TestPostConnectSetupOrdering:
|
||||
):
|
||||
await rm.post_connect_setup()
|
||||
|
||||
mock_mc.commands.set_flood_scope.assert_awaited_once_with("")
|
||||
mock_mc.commands.send.assert_awaited_once_with(
|
||||
FORCE_UNSCOPED_FRAME, [EventType.OK, EventType.ERROR]
|
||||
)
|
||||
mock_mc.commands.set_flood_scope.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_polling_starts_hourly_audit_by_default(self):
|
||||
|
||||
@@ -94,6 +94,7 @@ class TestRunPostConnectSetup:
|
||||
initial_mc = MagicMock()
|
||||
initial_mc.commands.send_device_query = AsyncMock(return_value=None)
|
||||
initial_mc.commands.set_flood_scope = AsyncMock(return_value=None)
|
||||
initial_mc.commands.send = AsyncMock(return_value=None)
|
||||
initial_mc._reader = MagicMock()
|
||||
initial_mc._reader.handle_rx = AsyncMock()
|
||||
initial_mc.start_auto_message_fetching = AsyncMock()
|
||||
@@ -103,6 +104,7 @@ class TestRunPostConnectSetup:
|
||||
return_value=MagicMock(payload={"max_channels": 8})
|
||||
)
|
||||
replacement_mc.commands.set_flood_scope = AsyncMock(return_value=None)
|
||||
replacement_mc.commands.send = AsyncMock(return_value=None)
|
||||
replacement_mc._reader = MagicMock()
|
||||
replacement_mc._reader.handle_rx = AsyncMock()
|
||||
replacement_mc.start_auto_message_fetching = AsyncMock()
|
||||
@@ -168,6 +170,7 @@ class TestRunPostConnectSetup:
|
||||
)
|
||||
)
|
||||
mc.commands.set_flood_scope = AsyncMock(return_value=None)
|
||||
mc.commands.send = AsyncMock(return_value=None)
|
||||
mc._reader = MagicMock()
|
||||
mc._reader.handle_rx = AsyncMock()
|
||||
mc.start_auto_message_fetching = AsyncMock()
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
from app.region_scope import (
|
||||
UNSCOPED_OVERRIDE_MARKER,
|
||||
is_unscoped,
|
||||
normalize_region_scope,
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_region_scope_preserves_regions():
|
||||
assert normalize_region_scope("Esperance") == "#Esperance"
|
||||
assert normalize_region_scope("#Esperance") == "#Esperance"
|
||||
|
||||
|
||||
def test_normalize_region_scope_unscoped_sentinels():
|
||||
assert normalize_region_scope(None) == ""
|
||||
assert normalize_region_scope("") == ""
|
||||
assert normalize_region_scope(" ") == ""
|
||||
assert normalize_region_scope("0") == ""
|
||||
assert normalize_region_scope("*") == ""
|
||||
|
||||
|
||||
def test_is_unscoped():
|
||||
assert is_unscoped(None) is True
|
||||
assert is_unscoped("") is True
|
||||
assert is_unscoped(" ") is True
|
||||
assert is_unscoped("0") is True
|
||||
assert is_unscoped("*") is True
|
||||
assert is_unscoped(UNSCOPED_OVERRIDE_MARKER) is True
|
||||
assert is_unscoped("Esperance") is False
|
||||
assert is_unscoped("#Esperance") is False
|
||||
|
||||
|
||||
def test_unscoped_override_marker_is_recognized_and_not_a_region():
|
||||
# The canonical persisted marker must round-trip as unscoped, never as a region.
|
||||
assert is_unscoped(UNSCOPED_OVERRIDE_MARKER) is True
|
||||
assert normalize_region_scope(UNSCOPED_OVERRIDE_MARKER) == ""
|
||||
@@ -26,6 +26,7 @@ from app.routers.messages import (
|
||||
send_direct_message,
|
||||
)
|
||||
from app.services import dm_ack_tracker
|
||||
from app.services.flood_scope import FORCE_UNSCOPED_FRAME
|
||||
from app.services.message_send import NO_RADIO_RESPONSE_AFTER_SEND_DETAIL
|
||||
|
||||
|
||||
@@ -67,6 +68,7 @@ def _make_mc(name="TestNode"):
|
||||
mc.self_info = {"name": name}
|
||||
mc.commands = MagicMock()
|
||||
mc.commands.set_flood_scope = AsyncMock(return_value=_make_radio_result())
|
||||
mc.commands.send = AsyncMock(return_value=_make_radio_result())
|
||||
mc.commands.send_msg = AsyncMock(return_value=_make_radio_result())
|
||||
mc.commands.send_chan_msg = AsyncMock(return_value=_make_radio_result())
|
||||
mc.commands.add_contact = AsyncMock(return_value=_make_radio_result())
|
||||
@@ -662,6 +664,7 @@ class TestOutgoingChannelBroadcast:
|
||||
with (
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch.object(radio_manager, "firmware_ver_code", 13),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
):
|
||||
request = SendChannelMessageRequest(
|
||||
@@ -669,10 +672,10 @@ class TestOutgoingChannelBroadcast:
|
||||
)
|
||||
await send_channel_message(request)
|
||||
|
||||
# Apply the region, then restore the (empty) baseline.
|
||||
assert mc.commands.set_flood_scope.await_args_list == [
|
||||
call("#Region"),
|
||||
call(""),
|
||||
# Apply the region, then restore the empty baseline via explicit unscoped mode.
|
||||
assert mc.commands.set_flood_scope.await_args_list == [call("#Region")]
|
||||
assert mc.commands.send.await_args_list == [
|
||||
call(FORCE_UNSCOPED_FRAME, [EventType.OK, EventType.ERROR])
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -687,6 +690,7 @@ class TestOutgoingChannelBroadcast:
|
||||
with (
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch.object(radio_manager, "firmware_ver_code", 13),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
):
|
||||
request = SendChannelMessageRequest(
|
||||
@@ -694,11 +698,38 @@ class TestOutgoingChannelBroadcast:
|
||||
)
|
||||
await send_channel_message(request)
|
||||
|
||||
# Explicit unscoped: set empty scope, then restore the global baseline.
|
||||
assert mc.commands.set_flood_scope.await_args_list == [
|
||||
call(""),
|
||||
call("#Baseline"),
|
||||
# Explicit unscoped uses firmware mode 1, then restores the global baseline.
|
||||
assert mc.commands.send.await_args_list == [
|
||||
call(FORCE_UNSCOPED_FRAME, [EventType.OK, EventType.ERROR])
|
||||
]
|
||||
assert mc.commands.set_flood_scope.await_args_list == [call("#Baseline")]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persisted_unscoped_channel_forces_plain_flood_over_scoped_global(self, test_db):
|
||||
"""A channel persistently marked unscoped ('*') sends unscoped even when the
|
||||
companion has a global region set — the core of issue #303. No per-send
|
||||
override is supplied, so this exercises the persisted-override path."""
|
||||
mc = _make_mc(name="MyNode")
|
||||
chan_key = "d3" * 16
|
||||
await ChannelRepository.upsert(key=chan_key, name="#plain")
|
||||
await ChannelRepository.update_flood_scope_override(chan_key, "*")
|
||||
await AppSettingsRepository.update(flood_scope="Baseline")
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch.object(radio_manager, "firmware_ver_code", 13),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
):
|
||||
# No per-send flood_scope_override: fall back to the channel's persisted "*".
|
||||
request = SendChannelMessageRequest(channel_key=chan_key, text="hello")
|
||||
await send_channel_message(request)
|
||||
|
||||
# Force unscoped via mode 1, then restore the global region baseline.
|
||||
assert mc.commands.send.await_args_list == [
|
||||
call(FORCE_UNSCOPED_FRAME, [EventType.OK, EventType.ERROR])
|
||||
]
|
||||
assert mc.commands.set_flood_scope.await_args_list == [call("#Baseline")]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_channel_msg_aborts_when_override_apply_fails(self, test_db):
|
||||
|
||||
@@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from meshcore import EventType
|
||||
|
||||
from app.models import CONTACT_TYPE_REPEATER, AppSettings, ContactUpsert
|
||||
from app.repository import AppSettingsRepository, ContactRepository
|
||||
@@ -16,6 +17,7 @@ from app.routers.settings import (
|
||||
toggle_tracked_telemetry,
|
||||
update_settings,
|
||||
)
|
||||
from app.services.flood_scope import FORCE_UNSCOPED_FRAME
|
||||
|
||||
|
||||
class TestUpdateSettings:
|
||||
@@ -140,6 +142,7 @@ class TestUpdateSettings:
|
||||
mock_rm = AsyncMock()
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.meshcore = mock_mc
|
||||
mock_rm.firmware_ver_code = 13 # supports mode-1 unscoped
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@@ -166,6 +169,7 @@ class TestUpdateSettings:
|
||||
mock_rm = AsyncMock()
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.meshcore = mock_mc
|
||||
mock_rm.firmware_ver_code = 13 # supports mode-1 unscoped
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@@ -178,7 +182,10 @@ class TestUpdateSettings:
|
||||
with patch("app.radio.radio_manager", mock_rm):
|
||||
await update_settings(AppSettingsUpdate(flood_scope=""))
|
||||
|
||||
mock_mc.commands.set_flood_scope.assert_awaited_once_with("")
|
||||
mock_mc.commands.send.assert_awaited_once_with(
|
||||
FORCE_UNSCOPED_FRAME, [EventType.OK, EventType.ERROR]
|
||||
)
|
||||
mock_mc.commands.set_flood_scope.assert_not_awaited()
|
||||
|
||||
|
||||
class TestToggleFavorite:
|
||||
|
||||
Reference in New Issue
Block a user