Merge pull request #307 from Bjorkan/fixRegions. Closes #303.

Fix unscoped flood-scope handling
This commit is contained in:
Jack Kingsman
2026-07-08 15:51:50 -07:00
committed by GitHub
20 changed files with 432 additions and 55 deletions
+20
View File
@@ -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
+79
View File
@@ -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
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,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):
+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()
+35
View File
@@ -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) == ""
+39 -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())
@@ -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):
+8 -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:
@@ -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: