From 145609faf9823475432ea015a5f00677bd65eead Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Wed, 4 Mar 2026 15:42:21 -0800 Subject: [PATCH] Add outgoing message region tagging. Closes #35. --- AGENTS.md | 2 +- app/AGENTS.md | 1 + app/migrations.py | 25 ++++ app/models.py | 4 + app/radio.py | 8 ++ app/repository/settings.py | 8 +- app/routers/settings.py | 24 ++++ frontend/AGENTS.md | 1 + .../settings/SettingsIdentitySection.tsx | 25 +++- frontend/src/test/settingsModal.test.tsx | 1 + frontend/src/types.ts | 2 + tests/test_migrations.py | 112 ++++++++++++++---- tests/test_radio.py | 82 +++++++++++++ tests/test_repository.py | 1 + tests/test_settings_router.py | 70 +++++++++++ 15 files changed, 339 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ae8aa39..4cdc22a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -427,7 +427,7 @@ mc.subscribe(EventType.ACK, handler) | `MESHCORE_DATABASE_PATH` | `data/meshcore.db` | SQLite database location | | `MESHCORE_DISABLE_BOTS` | `false` | Disable bot system entirely (blocks execution and config) | -**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `bots`, all MQTT configuration (`mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`, `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`), and community MQTT configuration (`community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email`). They are configured via `GET/PATCH /api/settings` (and related settings endpoints). +**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `bots`, all MQTT configuration (`mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`, `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`), community MQTT configuration (`community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email`), and `flood_scope`. They are configured via `GET/PATCH /api/settings` (and related settings endpoints). Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/{message_id}/resend` and are allowed for 30 seconds after the original send. diff --git a/app/AGENTS.md b/app/AGENTS.md index 8b7efe1..eb21f24 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -244,6 +244,7 @@ Main tables: - `mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password` - `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets` - `community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email` +- `flood_scope` ## Security Posture (intentional) diff --git a/app/migrations.py b/app/migrations.py index e075caa..5166b9f 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -268,6 +268,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int: await set_version(conn, 33) applied += 1 + # Migration 34: Add flood_scope column to app_settings + if version < 34: + logger.info("Applying migration 34: add flood_scope column to app_settings") + await _migrate_034_add_flood_scope(conn) + await set_version(conn, 34) + applied += 1 + if applied > 0: logger.info( "Applied %d migration(s), schema now at version %d", applied, await get_version(conn) @@ -1951,3 +1958,21 @@ async def _migrate_033_seed_remoteterm_channel(conn: aiosqlite.Connection) -> No await conn.commit() except Exception: logger.debug("Skipping #remoteterm seed (channels table not ready)") + + +async def _migrate_034_add_flood_scope(conn: aiosqlite.Connection) -> None: + """Add flood_scope column to app_settings for outbound region tagging. + + Empty string means disabled (no scope set, messages sent unscoped). + """ + try: + await conn.execute("ALTER TABLE app_settings ADD COLUMN flood_scope TEXT DEFAULT ''") + await conn.commit() + except Exception as e: + error_msg = str(e).lower() + if "duplicate column" in error_msg: + logger.debug("flood_scope column already exists, skipping") + elif "no such table" in error_msg: + logger.debug("app_settings table not ready, skipping flood_scope migration") + else: + raise diff --git a/app/models.py b/app/models.py index 6ea44f3..af4da03 100644 --- a/app/models.py +++ b/app/models.py @@ -518,6 +518,10 @@ class AppSettings(BaseModel): default="", description="Email address for node claiming on the community aggregator (optional)", ) + flood_scope: str = Field( + default="", + description="Outbound flood scope / region name (empty = disabled, no tagging)", + ) class BusyChannel(BaseModel): diff --git a/app/radio.py b/app/radio.py index bb5fbe3..4f0100a 100644 --- a/app/radio.py +++ b/app/radio.py @@ -258,6 +258,14 @@ class RadioManager: # Sync radio clock with system time await sync_radio_time(mc) + # Apply flood scope from settings + from app.repository import AppSettingsRepository + + app_settings = await AppSettingsRepository.get() + scope = app_settings.flood_scope + await mc.commands.set_flood_scope(scope if scope else "") + logger.info("Applied flood_scope=%r", scope or "(disabled)") + # Sync contacts/channels from radio to DB and clear radio logger.info("Syncing and offloading radio data...") result = await sync_and_offload_all(mc) diff --git a/app/repository/settings.py b/app/repository/settings.py index ef489fd..599cbad 100644 --- a/app/repository/settings.py +++ b/app/repository/settings.py @@ -32,7 +32,7 @@ class AppSettingsRepository: mqtt_publish_messages, mqtt_publish_raw_packets, community_mqtt_enabled, community_mqtt_iata, community_mqtt_broker_host, community_mqtt_broker_port, - community_mqtt_email + community_mqtt_email, flood_scope FROM app_settings WHERE id = 1 """ ) @@ -112,6 +112,7 @@ class AppSettingsRepository: or "mqtt-us-v1.letsmesh.net", community_mqtt_broker_port=row["community_mqtt_broker_port"] or 443, community_mqtt_email=row["community_mqtt_email"] or "", + flood_scope=row["flood_scope"] or "", ) @staticmethod @@ -139,6 +140,7 @@ class AppSettingsRepository: community_mqtt_broker_host: str | None = None, community_mqtt_broker_port: int | None = None, community_mqtt_email: str | None = None, + flood_scope: str | None = None, ) -> AppSettings: """Update app settings. Only provided fields are updated.""" updates = [] @@ -238,6 +240,10 @@ class AppSettingsRepository: updates.append("community_mqtt_email = ?") params.append(community_mqtt_email) + if flood_scope is not None: + updates.append("flood_scope = ?") + params.append(flood_scope) + if updates: query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1" await db.conn.execute(query, params) diff --git a/app/routers/settings.py b/app/routers/settings.py index cdb7a3b..08c7fb7 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -121,6 +121,10 @@ class AppSettingsUpdate(BaseModel): default=None, description="Email address for node claiming on the community aggregator", ) + flood_scope: str | None = Field( + default=None, + description="Outbound flood scope / region name (empty = disabled)", + ) class FavoriteRequest(BaseModel): @@ -237,6 +241,13 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings: kwargs["community_mqtt_email"] = update.community_mqtt_email community_mqtt_changed = True + # Flood scope + flood_scope_changed = False + if update.flood_scope is not None: + stripped = update.flood_scope.strip() + kwargs["flood_scope"] = stripped + flood_scope_changed = True + # Require IATA when enabling community MQTT if kwargs.get("community_mqtt_enabled", False): # Check the IATA value being set, or fall back to current settings @@ -265,6 +276,19 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings: await community_publisher.restart(result) + # Apply flood scope to radio immediately if changed + if flood_scope_changed: + from app.radio import radio_manager + + if radio_manager.is_connected: + try: + scope = result.flood_scope + async with radio_manager.radio_operation("set_flood_scope") as mc: + await mc.commands.set_flood_scope(scope if scope else "") + logger.info("Applied flood_scope=%r to radio", scope or "(disabled)") + except Exception as e: + logger.warning("Failed to apply flood_scope to radio: %s", e) + return result return await AppSettingsRepository.get() diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 73998c2..0cc2e37 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -246,6 +246,7 @@ LocalStorage migration helpers for favorites; canonical favorites are server-sid - `mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password` - `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets` - `community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email` +- `flood_scope` `HealthStatus` includes `mqtt_status` (`"connected"`, `"disconnected"`, `"disabled"`, or `null`). `HealthStatus` also includes `community_mqtt_status` with the same status values. diff --git a/frontend/src/components/settings/SettingsIdentitySection.tsx b/frontend/src/components/settings/SettingsIdentitySection.tsx index 7474769..1ac2b8f 100644 --- a/frontend/src/components/settings/SettingsIdentitySection.tsx +++ b/frontend/src/components/settings/SettingsIdentitySection.tsx @@ -40,6 +40,7 @@ export function SettingsIdentitySection({ const [name, setName] = useState(''); const [privateKey, setPrivateKey] = useState(''); const [advertIntervalHours, setAdvertIntervalHours] = useState('0'); + const [floodScope, setFloodScope] = useState(''); const [busy, setBusy] = useState(false); const [rebooting, setRebooting] = useState(false); const [advertising, setAdvertising] = useState(false); @@ -51,6 +52,7 @@ export function SettingsIdentitySection({ useEffect(() => { setAdvertIntervalHours(String(Math.round(appSettings.advert_interval / 3600))); + setFloodScope(appSettings.flood_scope); }, [appSettings]); const handleSaveIdentity = async () => { @@ -61,10 +63,17 @@ export function SettingsIdentitySection({ const update: RadioConfigUpdate = { name }; await onSave(update); + const appUpdate: AppSettingsUpdate = {}; const hours = parseInt(advertIntervalHours, 10); const newAdvertInterval = isNaN(hours) ? 0 : hours * 3600; if (newAdvertInterval !== appSettings.advert_interval) { - await onSaveAppSettings({ advert_interval: newAdvertInterval }); + appUpdate.advert_interval = newAdvertInterval; + } + if (floodScope !== appSettings.flood_scope) { + appUpdate.flood_scope = floodScope; + } + if (Object.keys(appUpdate).length > 0) { + await onSaveAppSettings(appUpdate); } toast.success('Identity settings saved'); @@ -140,6 +149,20 @@ export function SettingsIdentitySection({

+
+ + setFloodScope(e.target.value)} + placeholder="#MyRegion" + /> +

+ Tag outgoing flood messages with a region name (e.g. #MyRegion). Repeaters with this + region configured will prioritize your traffic. Leave empty to disable. +

+
+ diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index d67d2d3..66b69a6 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -67,6 +67,7 @@ const baseSettings: AppSettings = { community_mqtt_broker_host: 'mqtt-us-v1.letsmesh.net', community_mqtt_broker_port: 443, community_mqtt_email: '', + flood_scope: '', }; function renderModal(overrides?: { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 24c84d7..2c79de0 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -228,6 +228,7 @@ export interface AppSettings { community_mqtt_broker_host: string; community_mqtt_broker_port: number; community_mqtt_email: string; + flood_scope: string; } export interface AppSettingsUpdate { @@ -250,6 +251,7 @@ export interface AppSettingsUpdate { community_mqtt_broker_host?: string; community_mqtt_broker_port?: number; community_mqtt_email?: string; + flood_scope?: string; } export interface MigratePreferencesRequest { diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 8b059bc..b5bba1c 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -100,8 +100,8 @@ class TestMigration001: # Run migrations applied = await run_migrations(conn) - assert applied == 33 # All migrations run - assert await get_version(conn) == 33 + assert applied == 34 # All migrations run + assert await get_version(conn) == 34 # Verify columns exist by inserting and selecting await conn.execute( @@ -183,9 +183,9 @@ class TestMigration001: applied1 = await run_migrations(conn) applied2 = await run_migrations(conn) - assert applied1 == 33 # All migrations run + assert applied1 == 34 # All migrations run assert applied2 == 0 # No migrations on second run - assert await get_version(conn) == 33 + assert await get_version(conn) == 34 finally: await conn.close() @@ -246,8 +246,8 @@ class TestMigration001: applied = await run_migrations(conn) # All migrations applied (version incremented) but no error - assert applied == 33 - assert await get_version(conn) == 33 + assert applied == 34 + assert await get_version(conn) == 34 finally: await conn.close() @@ -374,10 +374,10 @@ class TestMigration013: ) await conn.commit() - # Run migration 13 (plus 14-33 which also run) + # Run migration 13 (plus 14-34 which also run) applied = await run_migrations(conn) - assert applied == 21 - assert await get_version(conn) == 33 + assert applied == 22 + assert await get_version(conn) == 34 # Verify bots array was created with migrated data cursor = await conn.execute("SELECT bots FROM app_settings WHERE id = 1") @@ -497,7 +497,7 @@ class TestMigration018: assert await cursor.fetchone() is not None await run_migrations(conn) - assert await get_version(conn) == 33 + assert await get_version(conn) == 34 # Verify autoindex is gone cursor = await conn.execute( @@ -575,8 +575,8 @@ class TestMigration018: await conn.commit() applied = await run_migrations(conn) - assert applied == 16 # Migrations 18-33 run (18+19 skip internally) - assert await get_version(conn) == 33 + assert applied == 17 # Migrations 18-34 run (18+19 skip internally) + assert await get_version(conn) == 34 finally: await conn.close() @@ -648,7 +648,7 @@ class TestMigration019: assert await cursor.fetchone() is not None await run_migrations(conn) - assert await get_version(conn) == 33 + assert await get_version(conn) == 34 # Verify autoindex is gone cursor = await conn.execute( @@ -714,8 +714,8 @@ class TestMigration020: assert (await cursor.fetchone())[0] == "delete" applied = await run_migrations(conn) - assert applied == 14 # Migrations 20-33 - assert await get_version(conn) == 33 + assert applied == 15 # Migrations 20-34 + assert await get_version(conn) == 34 # Verify WAL mode cursor = await conn.execute("PRAGMA journal_mode") @@ -745,7 +745,7 @@ class TestMigration020: await set_version(conn, 20) applied = await run_migrations(conn) - assert applied == 13 # Migrations 21-33 still run + assert applied == 14 # Migrations 21-34 still run # Still WAL + INCREMENTAL cursor = await conn.execute("PRAGMA journal_mode") @@ -803,8 +803,8 @@ class TestMigration028: await conn.commit() applied = await run_migrations(conn) - assert applied == 6 - assert await get_version(conn) == 33 + assert applied == 7 + assert await get_version(conn) == 34 # Verify payload_hash column is now BLOB cursor = await conn.execute("PRAGMA table_info(raw_packets)") @@ -873,8 +873,8 @@ class TestMigration028: await conn.commit() applied = await run_migrations(conn) - assert applied == 6 # Version still bumped - assert await get_version(conn) == 33 + assert applied == 7 # Version still bumped + assert await get_version(conn) == 34 # Verify data unchanged cursor = await conn.execute("SELECT payload_hash FROM raw_packets") @@ -923,8 +923,8 @@ class TestMigration032: await conn.commit() applied = await run_migrations(conn) - assert applied == 2 - assert await get_version(conn) == 33 + assert applied == 3 + assert await get_version(conn) == 34 # Verify all columns exist with correct defaults cursor = await conn.execute( @@ -943,6 +943,70 @@ class TestMigration032: await conn.close() +class TestMigration034: + """Test migration 034: add flood_scope column to app_settings.""" + + @pytest.mark.asyncio + async def test_migration_adds_flood_scope_column(self): + """Migration adds flood_scope column with empty string default.""" + conn = await aiosqlite.connect(":memory:") + conn.row_factory = aiosqlite.Row + try: + await set_version(conn, 33) + + # Create app_settings without flood_scope (pre-migration schema) + await conn.execute(""" + CREATE TABLE app_settings ( + id INTEGER PRIMARY KEY, + max_radio_contacts INTEGER DEFAULT 200, + favorites TEXT DEFAULT '[]', + auto_decrypt_dm_on_advert INTEGER DEFAULT 0, + sidebar_sort_order TEXT DEFAULT 'recent', + last_message_times TEXT DEFAULT '{}', + preferences_migrated INTEGER DEFAULT 0, + advert_interval INTEGER DEFAULT 0, + last_advert_time INTEGER DEFAULT 0, + bots TEXT DEFAULT '[]', + mqtt_broker_host TEXT DEFAULT '', + mqtt_broker_port INTEGER DEFAULT 1883, + mqtt_username TEXT DEFAULT '', + mqtt_password TEXT DEFAULT '', + mqtt_use_tls INTEGER DEFAULT 0, + mqtt_tls_insecure INTEGER DEFAULT 0, + mqtt_topic_prefix TEXT DEFAULT 'meshcore', + mqtt_publish_messages INTEGER DEFAULT 0, + mqtt_publish_raw_packets INTEGER DEFAULT 0, + community_mqtt_enabled INTEGER DEFAULT 0, + community_mqtt_iata TEXT DEFAULT '', + community_mqtt_broker_host TEXT DEFAULT 'mqtt-us-v1.letsmesh.net', + community_mqtt_broker_port INTEGER DEFAULT 443, + community_mqtt_email TEXT DEFAULT '' + ) + """) + await conn.execute("INSERT INTO app_settings (id) VALUES (1)") + # Channels table needed for migration 33 + await conn.execute(""" + CREATE TABLE channels ( + key TEXT PRIMARY KEY, + name TEXT NOT NULL, + is_hashtag INTEGER DEFAULT 0, + on_radio INTEGER DEFAULT 0 + ) + """) + await conn.commit() + + applied = await run_migrations(conn) + assert applied == 1 + assert await get_version(conn) == 34 + + # Verify column exists with correct default + cursor = await conn.execute("SELECT flood_scope FROM app_settings WHERE id = 1") + row = await cursor.fetchone() + assert row["flood_scope"] == "" + finally: + await conn.close() + + class TestMigration033: """Test migration 033: seed #remoteterm channel.""" @@ -975,8 +1039,8 @@ class TestMigration033: await conn.commit() applied = await run_migrations(conn) - assert applied == 1 - assert await get_version(conn) == 33 + assert applied == 2 + assert await get_version(conn) == 34 cursor = await conn.execute( "SELECT key, name, is_hashtag, on_radio FROM channels WHERE key = ?", diff --git a/tests/test_radio.py b/tests/test_radio.py index b363508..2ff8cce 100644 --- a/tests/test_radio.py +++ b/tests/test_radio.py @@ -495,11 +495,13 @@ class TestPostConnectSetupOrdering: @pytest.mark.asyncio async def test_drain_runs_before_auto_fetch(self): """drain_pending_messages must be called BEFORE start_auto_message_fetching.""" + from app.models import AppSettings from app.radio import RadioManager rm = RadioManager() mock_mc = MagicMock() mock_mc.start_auto_message_fetching = AsyncMock() + mock_mc.commands.set_flood_scope = AsyncMock() rm._meshcore = mock_mc call_order = [] @@ -517,6 +519,11 @@ class TestPostConnectSetupOrdering: patch("app.event_handlers.register_event_handlers"), patch("app.keystore.export_and_store_private_key", new_callable=AsyncMock), patch("app.radio_sync.sync_radio_time", new_callable=AsyncMock), + patch( + "app.repository.AppSettingsRepository.get", + new_callable=AsyncMock, + return_value=AppSettings(), + ), patch("app.radio_sync.sync_and_offload_all", new_callable=AsyncMock, return_value={}), patch("app.radio_sync.start_periodic_sync"), patch("app.radio_sync.send_advertisement", new_callable=AsyncMock, return_value=False), @@ -537,11 +544,13 @@ class TestPostConnectSetupOrdering: @pytest.mark.asyncio async def test_setup_sets_and_clears_in_progress_flag(self): """is_setup_in_progress is True during setup and False after.""" + from app.models import AppSettings from app.radio import RadioManager rm = RadioManager() mock_mc = MagicMock() mock_mc.start_auto_message_fetching = AsyncMock() + mock_mc.commands.set_flood_scope = AsyncMock() rm._meshcore = mock_mc observed_during = None @@ -555,6 +564,11 @@ class TestPostConnectSetupOrdering: patch("app.event_handlers.register_event_handlers"), patch("app.keystore.export_and_store_private_key", new_callable=AsyncMock), patch("app.radio_sync.sync_radio_time", new_callable=AsyncMock), + patch( + "app.repository.AppSettingsRepository.get", + new_callable=AsyncMock, + return_value=AppSettings(), + ), patch("app.radio_sync.sync_and_offload_all", new_callable=AsyncMock, return_value={}), patch("app.radio_sync.start_periodic_sync"), patch("app.radio_sync.send_advertisement", new_callable=AsyncMock, return_value=False), @@ -606,3 +620,71 @@ class TestPostConnectSetupOrdering: # Should not raise or call any functions await rm.post_connect_setup() assert rm.is_setup_in_progress is False + + @pytest.mark.asyncio + async def test_flood_scope_applied_during_setup(self): + """Non-empty flood_scope from settings is applied during post_connect_setup.""" + from app.models import AppSettings + from app.radio import RadioManager + + rm = RadioManager() + mock_mc = MagicMock() + mock_mc.start_auto_message_fetching = AsyncMock() + mock_mc.commands.set_flood_scope = AsyncMock() + rm._meshcore = mock_mc + + mock_settings = AppSettings(flood_scope="#TestRegion") + + with ( + patch("app.event_handlers.register_event_handlers"), + patch("app.keystore.export_and_store_private_key", new_callable=AsyncMock), + patch("app.radio_sync.sync_radio_time", new_callable=AsyncMock), + patch( + "app.repository.AppSettingsRepository.get", + new_callable=AsyncMock, + return_value=mock_settings, + ), + patch("app.radio_sync.sync_and_offload_all", new_callable=AsyncMock, return_value={}), + patch("app.radio_sync.start_periodic_sync"), + patch("app.radio_sync.send_advertisement", new_callable=AsyncMock, return_value=False), + patch("app.radio_sync.start_periodic_advert"), + patch("app.radio_sync.drain_pending_messages", new_callable=AsyncMock, return_value=0), + patch("app.radio_sync.start_message_polling"), + ): + await rm.post_connect_setup() + + mock_mc.commands.set_flood_scope.assert_awaited_once_with("#TestRegion") + + @pytest.mark.asyncio + async def test_flood_scope_empty_resets_during_setup(self): + """Empty flood_scope calls set_flood_scope("") during post_connect_setup.""" + from app.models import AppSettings + from app.radio import RadioManager + + rm = RadioManager() + mock_mc = MagicMock() + mock_mc.start_auto_message_fetching = AsyncMock() + mock_mc.commands.set_flood_scope = AsyncMock() + rm._meshcore = mock_mc + + mock_settings = AppSettings(flood_scope="") + + with ( + patch("app.event_handlers.register_event_handlers"), + patch("app.keystore.export_and_store_private_key", new_callable=AsyncMock), + patch("app.radio_sync.sync_radio_time", new_callable=AsyncMock), + patch( + "app.repository.AppSettingsRepository.get", + new_callable=AsyncMock, + return_value=mock_settings, + ), + patch("app.radio_sync.sync_and_offload_all", new_callable=AsyncMock, return_value={}), + patch("app.radio_sync.start_periodic_sync"), + patch("app.radio_sync.send_advertisement", new_callable=AsyncMock, return_value=False), + patch("app.radio_sync.start_periodic_advert"), + patch("app.radio_sync.drain_pending_messages", new_callable=AsyncMock, return_value=0), + patch("app.radio_sync.start_message_polling"), + ): + await rm.post_connect_setup() + + mock_mc.commands.set_flood_scope.assert_awaited_once_with("") diff --git a/tests/test_repository.py b/tests/test_repository.py index f4aad30..5044959 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -507,6 +507,7 @@ class TestAppSettingsRepository: "community_mqtt_broker_host": "mqtt-us-v1.letsmesh.net", "community_mqtt_broker_port": 443, "community_mqtt_email": "", + "flood_scope": "", } ) mock_conn.execute = AsyncMock(return_value=mock_cursor) diff --git a/tests/test_settings_router.py b/tests/test_settings_router.py index 6ee4a2d..7120742 100644 --- a/tests/test_settings_router.py +++ b/tests/test_settings_router.py @@ -192,6 +192,76 @@ class TestUpdateSettings: assert settings.community_mqtt_iata == "" assert settings.community_mqtt_email == "" + @pytest.mark.asyncio + async def test_flood_scope_round_trip(self, test_db): + """Flood scope should be saved and retrieved correctly.""" + result = await update_settings(AppSettingsUpdate(flood_scope="#MyRegion")) + assert result.flood_scope == "#MyRegion" + + fresh = await AppSettingsRepository.get() + assert fresh.flood_scope == "#MyRegion" + + @pytest.mark.asyncio + async def test_flood_scope_default_empty(self, test_db): + """Fresh DB should have flood_scope as empty string.""" + settings = await AppSettingsRepository.get() + assert settings.flood_scope == "" + + @pytest.mark.asyncio + async def test_flood_scope_whitespace_stripped(self, test_db): + """Flood scope should be stripped of whitespace.""" + result = await update_settings(AppSettingsUpdate(flood_scope=" #MyRegion ")) + assert result.flood_scope == "#MyRegion" + + @pytest.mark.asyncio + async def test_flood_scope_applies_to_radio(self, test_db): + """When radio is connected, setting flood_scope calls set_flood_scope on radio.""" + mock_mc = AsyncMock() + mock_mc.commands.set_flood_scope = AsyncMock() + + mock_rm = AsyncMock() + mock_rm.is_connected = True + mock_rm.meshcore = mock_mc + + from contextlib import asynccontextmanager + + @asynccontextmanager + async def mock_radio_op(name): + yield mock_mc + + mock_rm.radio_operation = mock_radio_op + + with patch("app.radio.radio_manager", mock_rm): + await update_settings(AppSettingsUpdate(flood_scope="#TestRegion")) + + mock_mc.commands.set_flood_scope.assert_awaited_once_with("#TestRegion") + + @pytest.mark.asyncio + async def test_flood_scope_empty_resets_radio(self, test_db): + """Setting flood_scope to empty calls set_flood_scope("") on radio.""" + # First set a non-empty scope + await update_settings(AppSettingsUpdate(flood_scope="#TestRegion")) + + mock_mc = AsyncMock() + mock_mc.commands.set_flood_scope = AsyncMock() + + mock_rm = AsyncMock() + mock_rm.is_connected = True + mock_rm.meshcore = mock_mc + + from contextlib import asynccontextmanager + + @asynccontextmanager + async def mock_radio_op(name): + yield mock_mc + + mock_rm.radio_operation = mock_radio_op + + with patch("app.radio.radio_manager", mock_rm): + await update_settings(AppSettingsUpdate(flood_scope="")) + + mock_mc.commands.set_flood_scope.assert_awaited_once_with("") + class TestToggleFavorite: @pytest.mark.asyncio