Fix firmware-compatible unscoped flood scope handling

This commit is contained in:
Björkan
2026-06-28 01:23:19 +02:00
parent ce4946351f
commit 55020a5e22
11 changed files with 134 additions and 21 deletions
+46
View File
@@ -0,0 +1,46 @@
from unittest.mock import AsyncMock, MagicMock
import pytest
from meshcore import EventType
from app.services.flood_scope import FORCE_UNSCOPED_FRAME, set_radio_flood_scope
@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()
result = await set_radio_flood_scope(mc, "Esperance")
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():
mc = MagicMock()
mc.commands.set_flood_scope = AsyncMock()
mc.commands.send = AsyncMock(return_value="ok")
result = await set_radio_flood_scope(mc, "")
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(scope):
mc = MagicMock()
mc.commands.set_flood_scope = AsyncMock()
mc.commands.send = AsyncMock(return_value="ok")
result = await set_radio_flood_scope(mc, scope)
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()
+8 -1
View File
@@ -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,7 @@ class TestPostConnectSetupOrdering:
mock_mc = MagicMock()
mock_mc.start_auto_message_fetching = AsyncMock()
mock_mc.commands.set_flood_scope = AsyncMock()
mock_mc.commands.send = AsyncMock()
rm._meshcore = mock_mc
mock_settings = AppSettings(flood_scope="")
@@ -999,7 +1003,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):
+3
View File
@@ -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()
+14
View File
@@ -0,0 +1,14 @@
from app.region_scope import 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("*") == ""
+10 -8
View File
@@ -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())
@@ -669,10 +671,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
@@ -694,11 +696,11 @@ 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_send_channel_msg_aborts_when_override_apply_fails(self, test_db):
+6 -1
View File
@@ -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:
@@ -178,7 +180,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: