Add MQTT removal migration and fix tests + docs

This commit is contained in:
Jack Kingsman
2026-03-05 21:21:08 -08:00
parent e99fed2e76
commit adfb4addb7
30 changed files with 352 additions and 1630 deletions
+4 -4
View File
@@ -31,7 +31,7 @@ test.describe('Apprise integration settings', () => {
await page.getByRole('button', { name: 'Apprise' }).click();
// Should navigate to the detail/edit view with default name
await expect(page.getByDisplayValue('Apprise')).toBeVisible();
await expect(page.locator('#fanout-edit-name')).toHaveValue('Apprise');
// Fill in notification URL
const urlsTextarea = page.locator('#fanout-apprise-urls');
@@ -135,7 +135,7 @@ test.describe('Apprise integration settings', () => {
await page.getByText('All except listed channels/contacts').click();
// Should show channel and contact lists with exclude label
await expect(page.getByText('(exclude)')).toBeVisible();
await expect(page.getByText('Channels (exclude)')).toBeVisible();
// Go back
await page.getByText('← Back to list').click();
@@ -158,9 +158,9 @@ test.describe('Apprise integration settings', () => {
await page.getByText('Settings').click();
await page.getByRole('button', { name: /MQTT.*Forwarding/ }).click();
// Should show "Disabled" text
// Should show "Disabled" status text
const row = page.getByText('Disabled Apprise').locator('..');
await expect(row.getByText('Disabled')).toBeVisible();
await expect(row.getByText('Disabled', { exact: true })).toBeVisible();
// Edit it
await row.getByRole('button', { name: 'Edit' }).click();
+4 -5
View File
@@ -31,7 +31,7 @@ test.describe('Webhook integration settings', () => {
await page.getByRole('button', { name: 'Webhook' }).click();
// Should navigate to the detail/edit view with default name
await expect(page.getByDisplayValue('Webhook')).toBeVisible();
await expect(page.locator('#fanout-edit-name')).toHaveValue('Webhook');
// Fill in webhook URL
const urlInput = page.locator('#fanout-webhook-url');
@@ -85,7 +85,7 @@ test.describe('Webhook integration settings', () => {
await row.getByRole('button', { name: 'Edit' }).click();
// Should be in edit view
await expect(page.getByDisplayValue('API Webhook')).toBeVisible();
await expect(page.locator('#fanout-edit-name')).toHaveValue('API Webhook');
// Change method to PUT
await page.locator('#fanout-webhook-method').selectOption('PUT');
@@ -129,9 +129,8 @@ test.describe('Webhook integration settings', () => {
// Select "Only listed" to see channel/contact checkboxes
await page.getByText('Only listed channels/contacts').click();
// Should show Channels and Contacts sections
await expect(page.getByText('Channels')).toBeVisible();
await expect(page.getByText('Contacts')).toBeVisible();
// Should show Channels section (Contacts only appears if non-repeater contacts exist)
await expect(page.getByText('Channels (include)')).toBeVisible();
// Go back without saving
await page.getByText('← Back to list').click();
-4
View File
@@ -79,7 +79,6 @@ class TestDMAckTrackingWiring:
patch.object(radio_manager, "_meshcore", mc),
patch("app.routers.messages.track_pending_ack") as mock_track,
patch("app.routers.messages.broadcast_event"),
patch("app.bot.run_bot_for_message", new=AsyncMock()),
):
request = SendDirectMessageRequest(destination=pub_key, text="Hello")
message = await send_direct_message(request)
@@ -112,7 +111,6 @@ class TestDMAckTrackingWiring:
patch.object(radio_manager, "_meshcore", mc),
patch("app.routers.messages.track_pending_ack") as mock_track,
patch("app.routers.messages.broadcast_event"),
patch("app.bot.run_bot_for_message", new=AsyncMock()),
):
request = SendDirectMessageRequest(destination=pub_key, text="Hello")
message = await send_direct_message(request)
@@ -142,7 +140,6 @@ class TestDMAckTrackingWiring:
patch.object(radio_manager, "_meshcore", mc),
patch("app.routers.messages.track_pending_ack") as mock_track,
patch("app.routers.messages.broadcast_event"),
patch("app.bot.run_bot_for_message", new=AsyncMock()),
):
request = SendDirectMessageRequest(destination=pub_key, text="Hello")
await send_direct_message(request)
@@ -171,7 +168,6 @@ class TestDMAckTrackingWiring:
patch.object(radio_manager, "_meshcore", mc),
patch("app.routers.messages.track_pending_ack") as mock_track,
patch("app.routers.messages.broadcast_event"),
patch("app.bot.run_bot_for_message", new=AsyncMock()),
):
request = SendDirectMessageRequest(destination=pub_key, text="Hello")
message = await send_direct_message(request)
+21 -354
View File
@@ -5,15 +5,12 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import app.bot as bot_module
from app.bot import (
import app.fanout.bot_exec as bot_module
from app.fanout.bot_exec import (
BOT_MESSAGE_SPACING,
_bot_semaphore,
execute_bot_code,
process_bot_response,
run_bot_for_message,
)
from app.models import BotConfig
class TestExecuteBotCode:
@@ -414,336 +411,6 @@ def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name,
assert result is None
class TestRunBotForMessage:
"""Test the main bot entry point."""
@pytest.fixture(autouse=True)
def reset_semaphore(self):
"""Reset semaphore state between tests."""
# Ensure semaphore is fully released
while _bot_semaphore.locked():
_bot_semaphore.release()
yield
@pytest.mark.asyncio
async def test_runs_for_outgoing_messages(self):
"""Bot is triggered for outgoing messages (user can trigger their own bots)."""
with patch("app.repository.AppSettingsRepository") as mock_repo:
mock_settings = MagicMock()
mock_settings.bots = [
BotConfig(id="1", name="Echo", enabled=True, code="def bot(**k): return 'echo'")
]
mock_repo.get = AsyncMock(return_value=mock_settings)
with (
patch("app.bot.asyncio.sleep", new_callable=AsyncMock),
patch("app.bot.execute_bot_code", return_value="echo") as mock_exec,
patch("app.bot.process_bot_response", new_callable=AsyncMock),
):
await run_bot_for_message(
sender_name="Me",
sender_key="abc123" + "0" * 58,
message_text="Hello",
is_dm=True,
channel_key=None,
is_outgoing=True,
)
# Bot should actually execute for outgoing messages
mock_exec.assert_called_once()
@pytest.mark.asyncio
async def test_skips_when_no_enabled_bots(self):
"""Bot is not triggered when no bots are enabled."""
with patch("app.repository.AppSettingsRepository") as mock_repo:
mock_settings = MagicMock()
mock_settings.bots = [
BotConfig(id="1", name="Bot 1", enabled=False, code="def bot(): pass")
]
mock_repo.get = AsyncMock(return_value=mock_settings)
with patch("app.bot.execute_bot_code") as mock_exec:
await run_bot_for_message(
sender_name="Alice",
sender_key="abc123",
message_text="Hello",
is_dm=True,
channel_key=None,
)
mock_exec.assert_not_called()
@pytest.mark.asyncio
async def test_skips_when_bots_array_empty(self):
"""Bot is not triggered when bots array is empty."""
with patch("app.repository.AppSettingsRepository") as mock_repo:
mock_settings = MagicMock()
mock_settings.bots = []
mock_repo.get = AsyncMock(return_value=mock_settings)
with patch("app.bot.execute_bot_code") as mock_exec:
await run_bot_for_message(
sender_name="Alice",
sender_key="abc123",
message_text="Hello",
is_dm=True,
channel_key=None,
)
mock_exec.assert_not_called()
@pytest.mark.asyncio
async def test_skips_bot_with_empty_code(self):
"""Bot with empty code is skipped even if enabled."""
with patch("app.repository.AppSettingsRepository") as mock_repo:
mock_settings = MagicMock()
mock_settings.bots = [
BotConfig(id="1", name="Empty Bot", enabled=True, code=""),
BotConfig(id="2", name="Whitespace Bot", enabled=True, code=" "),
]
mock_repo.get = AsyncMock(return_value=mock_settings)
with patch("app.bot.execute_bot_code") as mock_exec:
await run_bot_for_message(
sender_name="Alice",
sender_key="abc123",
message_text="Hello",
is_dm=True,
channel_key=None,
)
mock_exec.assert_not_called()
@pytest.mark.asyncio
async def test_rechecks_settings_after_sleep(self):
"""Settings are re-checked after 2 second sleep."""
with patch("app.repository.AppSettingsRepository") as mock_repo:
# First call: bot enabled
# Second call (after sleep): bot disabled
mock_settings_enabled = MagicMock()
mock_settings_enabled.bots = [
BotConfig(id="1", name="Bot 1", enabled=True, code="def bot(): return 'hi'")
]
mock_settings_disabled = MagicMock()
mock_settings_disabled.bots = [
BotConfig(id="1", name="Bot 1", enabled=False, code="def bot(): return 'hi'")
]
mock_repo.get = AsyncMock(side_effect=[mock_settings_enabled, mock_settings_disabled])
with (
patch("app.bot.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
patch("app.bot.execute_bot_code") as mock_exec,
):
await run_bot_for_message(
sender_name="Alice",
sender_key="abc123",
message_text="Hello",
is_dm=True,
channel_key=None,
)
# Should have slept
mock_sleep.assert_called_once_with(2)
# Should NOT have executed bot (disabled after sleep)
mock_exec.assert_not_called()
class TestMultipleBots:
"""Test multiple bots functionality."""
@pytest.fixture(autouse=True)
def reset_semaphore(self):
"""Reset semaphore state between tests."""
while _bot_semaphore.locked():
_bot_semaphore.release()
yield
@pytest.fixture(autouse=True)
def reset_rate_limit_state(self):
"""Reset rate limiting state between tests."""
bot_module._last_bot_send_time = 0.0
yield
bot_module._last_bot_send_time = 0.0
@pytest.mark.asyncio
async def test_multiple_bots_execute_serially(self):
"""Multiple enabled bots execute serially in order."""
executed_bots = []
def mock_execute(code, *args, **kwargs):
# Extract bot identifier from the code
if "Bot 1" in code:
executed_bots.append("Bot 1")
return "Response 1"
elif "Bot 2" in code:
executed_bots.append("Bot 2")
return "Response 2"
return None
with patch("app.repository.AppSettingsRepository") as mock_repo:
mock_settings = MagicMock()
mock_settings.bots = [
BotConfig(id="1", name="Bot 1", enabled=True, code="# Bot 1\ndef bot(): pass"),
BotConfig(id="2", name="Bot 2", enabled=True, code="# Bot 2\ndef bot(): pass"),
]
mock_repo.get = AsyncMock(return_value=mock_settings)
with (
patch("app.bot.asyncio.sleep", new_callable=AsyncMock),
patch("app.bot.execute_bot_code", side_effect=mock_execute),
patch("app.bot.process_bot_response", new_callable=AsyncMock),
):
await run_bot_for_message(
sender_name="Alice",
sender_key="abc123" + "0" * 58,
message_text="Hello",
is_dm=True,
channel_key=None,
)
# Both bots should have executed in order
assert executed_bots == ["Bot 1", "Bot 2"]
@pytest.mark.asyncio
async def test_disabled_bots_are_skipped(self):
"""Disabled bots in the array are skipped."""
executed_bots = []
def mock_execute(code, *args, **kwargs):
if "Bot 1" in code:
executed_bots.append("Bot 1")
elif "Bot 2" in code:
executed_bots.append("Bot 2")
elif "Bot 3" in code:
executed_bots.append("Bot 3")
return None
with patch("app.repository.AppSettingsRepository") as mock_repo:
mock_settings = MagicMock()
mock_settings.bots = [
BotConfig(id="1", name="Bot 1", enabled=True, code="# Bot 1\ndef bot(): pass"),
BotConfig(id="2", name="Bot 2", enabled=False, code="# Bot 2\ndef bot(): pass"),
BotConfig(id="3", name="Bot 3", enabled=True, code="# Bot 3\ndef bot(): pass"),
]
mock_repo.get = AsyncMock(return_value=mock_settings)
with (
patch("app.bot.asyncio.sleep", new_callable=AsyncMock),
patch("app.bot.execute_bot_code", side_effect=mock_execute),
):
await run_bot_for_message(
sender_name="Alice",
sender_key="abc123" + "0" * 58,
message_text="Hello",
is_dm=True,
channel_key=None,
)
# Only enabled bots should have executed
assert executed_bots == ["Bot 1", "Bot 3"]
@pytest.mark.asyncio
async def test_error_in_one_bot_doesnt_stop_others(self):
"""Error in one bot doesn't prevent other bots from running."""
executed_bots = []
def mock_execute(code, *args, **kwargs):
if "Bot 1" in code:
executed_bots.append("Bot 1")
raise ValueError("Bot 1 crashed!")
elif "Bot 2" in code:
executed_bots.append("Bot 2")
return "Response 2"
elif "Bot 3" in code:
executed_bots.append("Bot 3")
return "Response 3"
return None
with patch("app.repository.AppSettingsRepository") as mock_repo:
mock_settings = MagicMock()
mock_settings.bots = [
BotConfig(id="1", name="Bot 1", enabled=True, code="# Bot 1\ndef bot(): pass"),
BotConfig(id="2", name="Bot 2", enabled=True, code="# Bot 2\ndef bot(): pass"),
BotConfig(id="3", name="Bot 3", enabled=True, code="# Bot 3\ndef bot(): pass"),
]
mock_repo.get = AsyncMock(return_value=mock_settings)
with (
patch("app.bot.asyncio.sleep", new_callable=AsyncMock),
patch("app.bot.execute_bot_code", side_effect=mock_execute),
patch("app.bot.process_bot_response", new_callable=AsyncMock) as mock_respond,
):
await run_bot_for_message(
sender_name="Alice",
sender_key="abc123" + "0" * 58,
message_text="Hello",
is_dm=True,
channel_key=None,
)
# All bots should have been attempted
assert executed_bots == ["Bot 1", "Bot 2", "Bot 3"]
# Responses from successful bots should have been sent
assert mock_respond.call_count == 2
@pytest.mark.asyncio
async def test_timeout_in_one_bot_doesnt_stop_others(self):
"""Timeout in one bot doesn't prevent other bots from running."""
executed_bots = []
async def mock_wait_for(coro, timeout):
result = await coro
# Simulate timeout for Bot 2
if len(executed_bots) == 2 and executed_bots[-1] == "Bot 2":
raise asyncio.TimeoutError()
return result
def mock_execute(code, *args, **kwargs):
if "Bot 1" in code:
executed_bots.append("Bot 1")
return "Response 1"
elif "Bot 2" in code:
executed_bots.append("Bot 2")
return "Response 2" # This will be "timed out"
elif "Bot 3" in code:
executed_bots.append("Bot 3")
return "Response 3"
return None
with patch("app.repository.AppSettingsRepository") as mock_repo:
mock_settings = MagicMock()
mock_settings.bots = [
BotConfig(id="1", name="Bot 1", enabled=True, code="# Bot 1\ndef bot(): pass"),
BotConfig(id="2", name="Bot 2", enabled=True, code="# Bot 2\ndef bot(): pass"),
BotConfig(id="3", name="Bot 3", enabled=True, code="# Bot 3\ndef bot(): pass"),
]
mock_repo.get = AsyncMock(return_value=mock_settings)
with (
patch("app.bot.asyncio.sleep", new_callable=AsyncMock),
patch("app.bot.execute_bot_code", side_effect=mock_execute),
patch("app.bot.asyncio.wait_for", side_effect=mock_wait_for),
patch("app.bot.process_bot_response", new_callable=AsyncMock) as mock_respond,
):
await run_bot_for_message(
sender_name="Alice",
sender_key="abc123" + "0" * 58,
message_text="Hello",
is_dm=True,
channel_key=None,
)
# All bots should have been attempted
assert executed_bots == ["Bot 1", "Bot 2", "Bot 3"]
# Only responses from non-timed-out bots (Bot 1 and Bot 3)
assert mock_respond.call_count == 2
class TestBotCodeValidation:
"""Test bot code syntax validation via fanout router."""
@@ -804,8 +471,8 @@ class TestBotMessageRateLimiting:
async def test_first_send_does_not_wait(self):
"""First bot send should not wait (no previous send)."""
with (
patch("app.bot.time.monotonic", return_value=100.0),
patch("app.bot.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
patch("app.fanout.bot_exec.time.monotonic", return_value=100.0),
patch("app.fanout.bot_exec.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
patch("app.routers.messages.send_direct_message", new_callable=AsyncMock) as mock_send,
patch("app.websocket.broadcast_event"),
):
@@ -832,8 +499,8 @@ class TestBotMessageRateLimiting:
bot_module._last_bot_send_time = 100.0
with (
patch("app.bot.time.monotonic", return_value=100.5),
patch("app.bot.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
patch("app.fanout.bot_exec.time.monotonic", return_value=100.5),
patch("app.fanout.bot_exec.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
patch("app.routers.messages.send_direct_message", new_callable=AsyncMock) as mock_send,
patch("app.websocket.broadcast_event"),
):
@@ -860,8 +527,8 @@ class TestBotMessageRateLimiting:
bot_module._last_bot_send_time = 97.0
with (
patch("app.bot.time.monotonic", return_value=100.0),
patch("app.bot.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
patch("app.fanout.bot_exec.time.monotonic", return_value=100.0),
patch("app.fanout.bot_exec.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
patch("app.routers.messages.send_direct_message", new_callable=AsyncMock) as mock_send,
patch("app.websocket.broadcast_event"),
):
@@ -883,7 +550,7 @@ class TestBotMessageRateLimiting:
async def test_timestamp_updated_after_successful_send(self):
"""Last send timestamp should be updated after successful send."""
with (
patch("app.bot.time.monotonic", return_value=150.0),
patch("app.fanout.bot_exec.time.monotonic", return_value=150.0),
patch("app.routers.messages.send_direct_message", new_callable=AsyncMock) as mock_send,
patch("app.websocket.broadcast_event"),
):
@@ -908,7 +575,7 @@ class TestBotMessageRateLimiting:
bot_module._last_bot_send_time = 50.0 # Previous timestamp
with (
patch("app.bot.time.monotonic", return_value=100.0),
patch("app.fanout.bot_exec.time.monotonic", return_value=100.0),
patch(
"app.routers.messages.send_direct_message",
new_callable=AsyncMock,
@@ -930,7 +597,7 @@ class TestBotMessageRateLimiting:
"""Last send timestamp should NOT be updated if no destination."""
bot_module._last_bot_send_time = 50.0
with patch("app.bot.time.monotonic", return_value=100.0):
with patch("app.fanout.bot_exec.time.monotonic", return_value=100.0):
await process_bot_response(
response="Hello!",
is_dm=False, # Not a DM
@@ -964,8 +631,8 @@ class TestBotMessageRateLimiting:
time_counter[0] += duration
with (
patch("app.bot.time.monotonic", side_effect=mock_monotonic),
patch("app.bot.asyncio.sleep", side_effect=mock_sleep),
patch("app.fanout.bot_exec.time.monotonic", side_effect=mock_monotonic),
patch("app.fanout.bot_exec.asyncio.sleep", side_effect=mock_sleep),
patch("app.routers.messages.send_direct_message", side_effect=mock_send),
patch("app.websocket.broadcast_event"),
):
@@ -990,8 +657,8 @@ class TestBotMessageRateLimiting:
bot_module._last_bot_send_time = 99.0 # 1 second ago
with (
patch("app.bot.time.monotonic", return_value=100.0),
patch("app.bot.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
patch("app.fanout.bot_exec.time.monotonic", return_value=100.0),
patch("app.fanout.bot_exec.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
patch("app.routers.messages.send_channel_message", new_callable=AsyncMock) as mock_send,
patch("app.websocket.broadcast_event"),
):
@@ -1035,8 +702,8 @@ class TestBotListResponses:
return mock_message
with (
patch("app.bot.time.monotonic", return_value=100.0),
patch("app.bot.asyncio.sleep", new_callable=AsyncMock),
patch("app.fanout.bot_exec.time.monotonic", return_value=100.0),
patch("app.fanout.bot_exec.asyncio.sleep", new_callable=AsyncMock),
patch("app.routers.messages.send_direct_message", side_effect=mock_send),
patch("app.websocket.broadcast_event"),
):
@@ -1069,8 +736,8 @@ class TestBotListResponses:
return mock_message
with (
patch("app.bot.time.monotonic", side_effect=mock_monotonic),
patch("app.bot.asyncio.sleep", side_effect=mock_sleep),
patch("app.fanout.bot_exec.time.monotonic", side_effect=mock_monotonic),
patch("app.fanout.bot_exec.asyncio.sleep", side_effect=mock_sleep),
patch("app.routers.messages.send_direct_message", side_effect=mock_send),
patch("app.websocket.broadcast_event"),
):
@@ -1098,8 +765,8 @@ class TestBotListResponses:
return mock_message
with (
patch("app.bot.time.monotonic", return_value=100.0),
patch("app.bot.asyncio.sleep", new_callable=AsyncMock),
patch("app.fanout.bot_exec.time.monotonic", return_value=100.0),
patch("app.fanout.bot_exec.asyncio.sleep", new_callable=AsyncMock),
patch("app.routers.messages.send_direct_message", side_effect=mock_send),
patch("app.websocket.broadcast_event"),
):
+50 -36
View File
@@ -3,12 +3,13 @@
import json
import time
from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import nacl.bindings
import pytest
from app.community_mqtt import (
from app.fanout.community_mqtt import (
_CLIENT_ID,
_DEFAULT_BROKER,
_STATS_REFRESH_INTERVAL,
@@ -22,7 +23,6 @@ from app.community_mqtt import (
_generate_jwt_token,
_get_client_version,
)
from app.models import AppSettings
def _make_test_keys() -> tuple[bytes, bytes]:
@@ -49,6 +49,19 @@ def _make_test_keys() -> tuple[bytes, bytes]:
return private_key, public_key
def _make_community_settings(**overrides) -> SimpleNamespace:
"""Create a settings namespace with all community MQTT fields."""
defaults = {
"community_mqtt_enabled": True,
"community_mqtt_broker_host": "mqtt-us-v1.letsmesh.net",
"community_mqtt_broker_port": 443,
"community_mqtt_iata": "",
"community_mqtt_email": "",
}
defaults.update(overrides)
return SimpleNamespace(**defaults)
class TestBase64UrlEncode:
def test_encodes_without_padding(self):
result = _base64url_encode(b"\x00\x01\x02")
@@ -376,19 +389,19 @@ class TestCommunityMqttPublisher:
def test_is_configured_false_when_disabled(self):
pub = CommunityMqttPublisher()
pub._settings = AppSettings(community_mqtt_enabled=False)
pub._settings = SimpleNamespace(community_mqtt_enabled=False)
with patch("app.keystore.has_private_key", return_value=True):
assert pub._is_configured() is False
def test_is_configured_false_when_no_private_key(self):
pub = CommunityMqttPublisher()
pub._settings = AppSettings(community_mqtt_enabled=True)
pub._settings = SimpleNamespace(community_mqtt_enabled=True)
with patch("app.keystore.has_private_key", return_value=False):
assert pub._is_configured() is False
def test_is_configured_true_when_enabled_with_key(self):
pub = CommunityMqttPublisher()
pub._settings = AppSettings(community_mqtt_enabled=True)
pub._settings = SimpleNamespace(community_mqtt_enabled=True)
with patch("app.keystore.has_private_key", return_value=True):
assert pub._is_configured() is True
@@ -408,12 +421,12 @@ class TestPublishFailureSetsDisconnected:
class TestBuildStatusTopic:
def test_builds_correct_topic(self):
settings = AppSettings(community_mqtt_iata="LAX")
settings = SimpleNamespace(community_mqtt_iata="LAX")
topic = _build_status_topic(settings, "AABB1122")
assert topic == "meshcore/LAX/AABB1122/status"
def test_iata_uppercased_and_stripped(self):
settings = AppSettings(community_mqtt_iata=" lax ")
settings = SimpleNamespace(community_mqtt_iata=" lax ")
topic = _build_status_topic(settings, "PUBKEY")
assert topic == "meshcore/LAX/PUBKEY/status"
@@ -424,10 +437,7 @@ class TestLwtAndStatusPublish:
pub = CommunityMqttPublisher()
private_key, public_key = _make_test_keys()
pubkey_hex = public_key.hex().upper()
settings = AppSettings(
community_mqtt_enabled=True,
community_mqtt_iata="SFO",
)
settings = _make_community_settings(community_mqtt_iata="SFO")
mock_radio = MagicMock()
mock_radio.meshcore = MagicMock()
@@ -457,7 +467,7 @@ class TestLwtAndStatusPublish:
pub = CommunityMqttPublisher()
private_key, public_key = _make_test_keys()
pubkey_hex = public_key.hex().upper()
settings = AppSettings(
settings = SimpleNamespace(
community_mqtt_enabled=True,
community_mqtt_iata="LAX",
)
@@ -478,8 +488,8 @@ class TestLwtAndStatusPublish:
patch.object(
pub, "_fetch_stats", new_callable=AsyncMock, return_value={"battery_mv": 4200}
),
patch("app.community_mqtt._build_radio_info", return_value="915.0,250.0,10,8"),
patch("app.community_mqtt._get_client_version", return_value="RemoteTerm 2.4.0"),
patch("app.fanout.community_mqtt._build_radio_info", return_value="915.0,250.0,10,8"),
patch("app.fanout.community_mqtt._get_client_version", return_value="RemoteTerm 2.4.0"),
patch.object(pub, "publish", new_callable=AsyncMock) as mock_publish,
):
await pub._on_connected_async(settings)
@@ -507,10 +517,7 @@ class TestLwtAndStatusPublish:
pub = CommunityMqttPublisher()
private_key, public_key = _make_test_keys()
pubkey_hex = public_key.hex().upper()
settings = AppSettings(
community_mqtt_enabled=True,
community_mqtt_iata="JFK",
)
settings = _make_community_settings(community_mqtt_iata="JFK")
mock_radio = MagicMock()
mock_radio.meshcore = None
@@ -530,7 +537,7 @@ class TestLwtAndStatusPublish:
async def test_on_connected_async_skips_when_no_public_key(self):
"""_on_connected_async should no-op when public key is unavailable."""
pub = CommunityMqttPublisher()
settings = AppSettings(community_mqtt_enabled=True, community_mqtt_iata="LAX")
settings = SimpleNamespace(community_mqtt_enabled=True, community_mqtt_iata="LAX")
with (
patch("app.keystore.get_public_key", return_value=None),
@@ -545,7 +552,7 @@ class TestLwtAndStatusPublish:
"""Should use 'MeshCore Device' when radio name is unavailable."""
pub = CommunityMqttPublisher()
_, public_key = _make_test_keys()
settings = AppSettings(community_mqtt_enabled=True, community_mqtt_iata="LAX")
settings = SimpleNamespace(community_mqtt_enabled=True, community_mqtt_iata="LAX")
mock_radio = MagicMock()
mock_radio.meshcore = None
@@ -560,8 +567,10 @@ class TestLwtAndStatusPublish:
return_value={"model": "unknown", "firmware_version": "unknown"},
),
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=None),
patch("app.community_mqtt._build_radio_info", return_value="0,0,0,0"),
patch("app.community_mqtt._get_client_version", return_value="RemoteTerm unknown"),
patch("app.fanout.community_mqtt._build_radio_info", return_value="0,0,0,0"),
patch(
"app.fanout.community_mqtt._get_client_version", return_value="RemoteTerm unknown"
),
patch.object(pub, "publish", new_callable=AsyncMock) as mock_publish,
):
await pub._on_connected_async(settings)
@@ -844,14 +853,15 @@ class TestGetClientVersion:
def test_returns_version_from_metadata(self):
"""Should use importlib.metadata to get version."""
with patch("app.community_mqtt.importlib.metadata.version", return_value="1.2.3"):
with patch("app.fanout.community_mqtt.importlib.metadata.version", return_value="1.2.3"):
result = _get_client_version()
assert result == "RemoteTerm 1.2.3"
def test_fallback_on_error(self):
"""Should return 'RemoteTerm unknown' if metadata lookup fails."""
with patch(
"app.community_mqtt.importlib.metadata.version", side_effect=Exception("not found")
"app.fanout.community_mqtt.importlib.metadata.version",
side_effect=Exception("not found"),
):
result = _get_client_version()
assert result == "RemoteTerm unknown"
@@ -864,7 +874,7 @@ class TestPublishStatus:
pub = CommunityMqttPublisher()
_, public_key = _make_test_keys()
pubkey_hex = public_key.hex().upper()
settings = AppSettings(community_mqtt_enabled=True, community_mqtt_iata="LAX")
settings = SimpleNamespace(community_mqtt_enabled=True, community_mqtt_iata="LAX")
mock_radio = MagicMock()
mock_radio.meshcore = MagicMock()
@@ -882,8 +892,8 @@ class TestPublishStatus:
return_value={"model": "T-Deck", "firmware_version": "v2.2.2 (Build: 2025-01-15)"},
),
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=stats),
patch("app.community_mqtt._build_radio_info", return_value="915.0,250.0,10,8"),
patch("app.community_mqtt._get_client_version", return_value="RemoteTerm 2.4.0"),
patch("app.fanout.community_mqtt._build_radio_info", return_value="915.0,250.0,10,8"),
patch("app.fanout.community_mqtt._get_client_version", return_value="RemoteTerm 2.4.0"),
patch.object(pub, "publish", new_callable=AsyncMock) as mock_publish,
):
await pub._publish_status(settings)
@@ -904,7 +914,7 @@ class TestPublishStatus:
"""Should not include 'stats' key when stats are None."""
pub = CommunityMqttPublisher()
_, public_key = _make_test_keys()
settings = AppSettings(community_mqtt_enabled=True, community_mqtt_iata="LAX")
settings = SimpleNamespace(community_mqtt_enabled=True, community_mqtt_iata="LAX")
mock_radio = MagicMock()
mock_radio.meshcore = None
@@ -919,8 +929,10 @@ class TestPublishStatus:
return_value={"model": "unknown", "firmware_version": "unknown"},
),
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=None),
patch("app.community_mqtt._build_radio_info", return_value="0,0,0,0"),
patch("app.community_mqtt._get_client_version", return_value="RemoteTerm unknown"),
patch("app.fanout.community_mqtt._build_radio_info", return_value="0,0,0,0"),
patch(
"app.fanout.community_mqtt._get_client_version", return_value="RemoteTerm unknown"
),
patch.object(pub, "publish", new_callable=AsyncMock) as mock_publish,
):
await pub._publish_status(settings)
@@ -933,7 +945,7 @@ class TestPublishStatus:
"""Should update _last_status_publish after publishing."""
pub = CommunityMqttPublisher()
_, public_key = _make_test_keys()
settings = AppSettings(community_mqtt_enabled=True, community_mqtt_iata="LAX")
settings = SimpleNamespace(community_mqtt_enabled=True, community_mqtt_iata="LAX")
mock_radio = MagicMock()
mock_radio.meshcore = None
@@ -950,8 +962,10 @@ class TestPublishStatus:
return_value={"model": "unknown", "firmware_version": "unknown"},
),
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=None),
patch("app.community_mqtt._build_radio_info", return_value="0,0,0,0"),
patch("app.community_mqtt._get_client_version", return_value="RemoteTerm unknown"),
patch("app.fanout.community_mqtt._build_radio_info", return_value="0,0,0,0"),
patch(
"app.fanout.community_mqtt._get_client_version", return_value="RemoteTerm unknown"
),
patch.object(pub, "publish", new_callable=AsyncMock),
):
await pub._publish_status(settings)
@@ -962,7 +976,7 @@ class TestPublishStatus:
async def test_no_publish_key_returns_none(self):
"""Should skip publish when public key is unavailable."""
pub = CommunityMqttPublisher()
settings = AppSettings(community_mqtt_enabled=True, community_mqtt_iata="LAX")
settings = SimpleNamespace(community_mqtt_enabled=True, community_mqtt_iata="LAX")
with (
patch("app.keystore.get_public_key", return_value=None),
@@ -978,7 +992,7 @@ class TestPeriodicWake:
async def test_skips_before_interval(self):
"""Should not republish before _STATS_REFRESH_INTERVAL."""
pub = CommunityMqttPublisher()
pub._settings = AppSettings(community_mqtt_enabled=True, community_mqtt_iata="LAX")
pub._settings = SimpleNamespace(community_mqtt_enabled=True, community_mqtt_iata="LAX")
pub._last_status_publish = time.monotonic() # Just published
with patch.object(pub, "_publish_status", new_callable=AsyncMock) as mock_ps:
@@ -990,7 +1004,7 @@ class TestPeriodicWake:
async def test_publishes_after_interval(self):
"""Should republish after _STATS_REFRESH_INTERVAL elapsed."""
pub = CommunityMqttPublisher()
pub._settings = AppSettings(community_mqtt_enabled=True, community_mqtt_iata="LAX")
pub._settings = SimpleNamespace(community_mqtt_enabled=True, community_mqtt_iata="LAX")
pub._last_status_publish = time.monotonic() - _STATS_REFRESH_INTERVAL - 1
with patch.object(pub, "_publish_status", new_callable=AsyncMock) as mock_ps:
+1 -52
View File
@@ -1,19 +1,16 @@
"""Tests for the --disable-bots (MESHCORE_DISABLE_BOTS) startup flag.
Verifies that when disable_bots=True:
- run_bot_for_message() exits immediately without any work
- POST /api/fanout with type=bot returns 403
- Health endpoint includes bots_disabled=True
"""
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import MagicMock, patch
import pytest
from fastapi import HTTPException
from app.bot import run_bot_for_message
from app.config import Settings
from app.models import BotConfig
from app.routers.fanout import FanoutConfigCreate, create_fanout_config
from app.routers.health import build_health_data
@@ -30,54 +27,6 @@ class TestDisableBotsConfig:
assert s.disable_bots is True
class TestDisableBotsBotExecution:
"""Test that run_bot_for_message exits immediately when bots are disabled."""
@pytest.mark.asyncio
async def test_returns_immediately_when_disabled(self):
"""No settings load, no semaphore, no bot execution."""
with patch("app.bot.server_settings", MagicMock(disable_bots=True)):
with patch("app.repository.AppSettingsRepository") as mock_repo:
mock_repo.get = AsyncMock()
await run_bot_for_message(
sender_name="Alice",
sender_key="ab" * 32,
message_text="Hello",
is_dm=True,
channel_key=None,
)
# Should never even load settings
mock_repo.get.assert_not_called()
@pytest.mark.asyncio
async def test_runs_normally_when_not_disabled(self):
"""Bots execute normally when disable_bots is False."""
with patch("app.bot.server_settings", MagicMock(disable_bots=False)):
with patch("app.repository.AppSettingsRepository") as mock_repo:
mock_settings = MagicMock()
mock_settings.bots = [
BotConfig(id="1", name="Echo", enabled=True, code="def bot(**k): return 'echo'")
]
mock_repo.get = AsyncMock(return_value=mock_settings)
with (
patch("app.bot.asyncio.sleep", new_callable=AsyncMock),
patch("app.bot.execute_bot_code", return_value="echo") as mock_exec,
patch("app.bot.process_bot_response", new_callable=AsyncMock),
):
await run_bot_for_message(
sender_name="Alice",
sender_key="ab" * 32,
message_text="Hello",
is_dm=True,
channel_key=None,
)
mock_exec.assert_called_once()
class TestDisableBotsFanoutEndpoint:
"""Test that bot creation via fanout router is rejected when bots are disabled."""
-255
View File
@@ -1,6 +1,5 @@
"""Tests for fanout bus: manager, scope matching, repository, and modules."""
import json
from unittest.mock import AsyncMock, patch
import pytest
@@ -394,260 +393,6 @@ class TestBroadcastEventRealtime:
mock_fm.broadcast_message.assert_called_once()
# ---------------------------------------------------------------------------
# Migration test
# ---------------------------------------------------------------------------
def _create_app_settings_table_sql():
"""SQL to create app_settings with all MQTT columns for migration testing."""
return """
CREATE TABLE IF NOT EXISTS app_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
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 '',
flood_scope TEXT DEFAULT '',
blocked_keys TEXT DEFAULT '[]',
blocked_names TEXT DEFAULT '[]'
)
"""
class TestMigration036:
@pytest.mark.asyncio
async def test_fanout_configs_table_created(self):
"""Migration 36 should create the fanout_configs table."""
from app.migrations import _migrate_036_create_fanout_configs
db = Database(":memory:")
await db.connect()
await db.conn.execute(_create_app_settings_table_sql())
await db.conn.execute("INSERT OR IGNORE INTO app_settings (id) VALUES (1)")
await db.conn.commit()
try:
await _migrate_036_create_fanout_configs(db.conn)
cursor = await db.conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='fanout_configs'"
)
row = await cursor.fetchone()
assert row is not None
finally:
await db.disconnect()
@pytest.mark.asyncio
async def test_migration_creates_mqtt_private_from_settings(self):
"""Migration should create mqtt_private config from existing MQTT settings."""
from app.migrations import _migrate_036_create_fanout_configs
db = Database(":memory:")
await db.connect()
await db.conn.execute(_create_app_settings_table_sql())
await db.conn.execute(
"""INSERT OR REPLACE INTO app_settings (id, 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)
VALUES (1, 'broker.local', 1883, 'user', 'pass', 0, 0, 'mesh', 1, 0)"""
)
await db.conn.commit()
try:
await _migrate_036_create_fanout_configs(db.conn)
cursor = await db.conn.execute(
"SELECT * FROM fanout_configs WHERE type = 'mqtt_private'"
)
row = await cursor.fetchone()
assert row is not None
config = json.loads(row["config"])
assert config["broker_host"] == "broker.local"
assert config["username"] == "user"
scope = json.loads(row["scope"])
assert scope["messages"] == "all"
assert scope["raw_packets"] == "none"
finally:
await db.disconnect()
@pytest.mark.asyncio
async def test_migration_creates_community_from_settings(self):
"""Migration should create mqtt_community config when community was enabled."""
from app.migrations import _migrate_036_create_fanout_configs
db = Database(":memory:")
await db.connect()
await db.conn.execute(_create_app_settings_table_sql())
await db.conn.execute(
"""INSERT OR REPLACE INTO app_settings (id, community_mqtt_enabled, community_mqtt_iata,
community_mqtt_broker_host, community_mqtt_broker_port, community_mqtt_email)
VALUES (1, 1, 'DEN', 'mqtt-us-v1.letsmesh.net', 443, 'test@example.com')"""
)
await db.conn.commit()
try:
await _migrate_036_create_fanout_configs(db.conn)
cursor = await db.conn.execute(
"SELECT * FROM fanout_configs WHERE type = 'mqtt_community'"
)
row = await cursor.fetchone()
assert row is not None
assert bool(row["enabled"])
config = json.loads(row["config"])
assert config["iata"] == "DEN"
assert config["email"] == "test@example.com"
finally:
await db.disconnect()
@pytest.mark.asyncio
async def test_migration_skips_when_no_mqtt_configured(self):
"""Migration should not create rows when MQTT was not configured."""
from app.migrations import _migrate_036_create_fanout_configs
db = Database(":memory:")
await db.connect()
await db.conn.execute(_create_app_settings_table_sql())
await db.conn.execute("INSERT OR IGNORE INTO app_settings (id) VALUES (1)")
await db.conn.commit()
try:
await _migrate_036_create_fanout_configs(db.conn)
cursor = await db.conn.execute("SELECT COUNT(*) FROM fanout_configs")
row = await cursor.fetchone()
assert row[0] == 0
finally:
await db.disconnect()
async def _setup_db_with_fanout_table():
"""Create a DB with app_settings + fanout_configs tables for migration 37 tests."""
from app.migrations import _migrate_036_create_fanout_configs
db = Database(":memory:")
await db.connect()
await db.conn.execute(_create_app_settings_table_sql())
await db.conn.execute("INSERT OR IGNORE INTO app_settings (id) VALUES (1)")
await db.conn.commit()
await _migrate_036_create_fanout_configs(db.conn)
return db
class TestMigration037:
@pytest.mark.asyncio
async def test_migration_creates_bot_from_settings(self):
"""Migration should create a fanout_configs row for each bot in app_settings."""
from app.migrations import _migrate_037_bots_to_fanout
db = await _setup_db_with_fanout_table()
try:
bots_json = json.dumps(
[
{
"id": "bot-1",
"name": "EchoBot",
"enabled": True,
"code": "def bot(**k): return 'echo'",
},
{
"id": "bot-2",
"name": "Quiet",
"enabled": False,
"code": "def bot(**k): pass",
},
]
)
await db.conn.execute("UPDATE app_settings SET bots = ? WHERE id = 1", (bots_json,))
await db.conn.commit()
await _migrate_037_bots_to_fanout(db.conn)
cursor = await db.conn.execute(
"SELECT * FROM fanout_configs WHERE type = 'bot' ORDER BY sort_order"
)
rows = await cursor.fetchall()
assert len(rows) == 2
# First bot
assert rows[0]["name"] == "EchoBot"
assert bool(rows[0]["enabled"])
config0 = json.loads(rows[0]["config"])
assert config0["code"] == "def bot(**k): return 'echo'"
scope0 = json.loads(rows[0]["scope"])
assert scope0["messages"] == "all"
assert scope0["raw_packets"] == "none"
assert rows[0]["sort_order"] == 200
# Second bot
assert rows[1]["name"] == "Quiet"
assert not bool(rows[1]["enabled"])
assert rows[1]["sort_order"] == 201
finally:
await db.disconnect()
@pytest.mark.asyncio
async def test_migration_skips_when_no_bots(self):
"""Migration should not create rows when there are no bots."""
from app.migrations import _migrate_037_bots_to_fanout
db = await _setup_db_with_fanout_table()
try:
await _migrate_037_bots_to_fanout(db.conn)
cursor = await db.conn.execute("SELECT COUNT(*) FROM fanout_configs WHERE type = 'bot'")
row = await cursor.fetchone()
assert row[0] == 0
finally:
await db.disconnect()
@pytest.mark.asyncio
async def test_migration_handles_empty_bots_array(self):
"""Migration handles bots=[] gracefully."""
from app.migrations import _migrate_037_bots_to_fanout
db = await _setup_db_with_fanout_table()
try:
await db.conn.execute("UPDATE app_settings SET bots = '[]' WHERE id = 1")
await db.conn.commit()
await _migrate_037_bots_to_fanout(db.conn)
cursor = await db.conn.execute("SELECT COUNT(*) FROM fanout_configs WHERE type = 'bot'")
row = await cursor.fetchone()
assert row[0] == 0
finally:
await db.disconnect()
# ---------------------------------------------------------------------------
# Webhook module unit tests
# ---------------------------------------------------------------------------
+6 -6
View File
@@ -174,7 +174,7 @@ class TestFanoutMqttIntegration:
manager = FanoutManager()
with (
patch("app.mqtt_base._broadcast_health"),
patch("app.fanout.mqtt_base._broadcast_health"),
patch("app.websocket.broadcast_success"),
patch("app.websocket.broadcast_error"),
patch("app.websocket.broadcast_health"),
@@ -218,7 +218,7 @@ class TestFanoutMqttIntegration:
manager = FanoutManager()
with (
patch("app.mqtt_base._broadcast_health"),
patch("app.fanout.mqtt_base._broadcast_health"),
patch("app.websocket.broadcast_success"),
patch("app.websocket.broadcast_error"),
patch("app.websocket.broadcast_health"),
@@ -264,7 +264,7 @@ class TestFanoutMqttIntegration:
manager = FanoutManager()
with (
patch("app.mqtt_base._broadcast_health"),
patch("app.fanout.mqtt_base._broadcast_health"),
patch("app.websocket.broadcast_success"),
patch("app.websocket.broadcast_error"),
patch("app.websocket.broadcast_health"),
@@ -297,7 +297,7 @@ class TestFanoutMqttIntegration:
manager = FanoutManager()
with (
patch("app.mqtt_base._broadcast_health"),
patch("app.fanout.mqtt_base._broadcast_health"),
patch("app.websocket.broadcast_success"),
patch("app.websocket.broadcast_error"),
patch("app.websocket.broadcast_health"),
@@ -345,7 +345,7 @@ class TestFanoutMqttIntegration:
manager = FanoutManager()
with (
patch("app.mqtt_base._broadcast_health"),
patch("app.fanout.mqtt_base._broadcast_health"),
patch("app.websocket.broadcast_success"),
patch("app.websocket.broadcast_error"),
patch("app.websocket.broadcast_health"),
@@ -382,7 +382,7 @@ class TestFanoutMqttIntegration:
manager = FanoutManager()
with (
patch("app.mqtt_base._broadcast_health"),
patch("app.fanout.mqtt_base._broadcast_health"),
patch("app.websocket.broadcast_success"),
patch("app.websocket.broadcast_error"),
patch("app.websocket.broadcast_health"),
+41 -49
View File
@@ -100,8 +100,8 @@ class TestMigration001:
# Run migrations
applied = await run_migrations(conn)
assert applied == 37 # All migrations run
assert await get_version(conn) == 37
assert applied == 38 # All migrations run
assert await get_version(conn) == 38
# 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 == 37 # All migrations run
assert applied1 == 38 # All migrations run
assert applied2 == 0 # No migrations on second run
assert await get_version(conn) == 37
assert await get_version(conn) == 38
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 == 37
assert await get_version(conn) == 37
assert applied == 38
assert await get_version(conn) == 38
finally:
await conn.close()
@@ -374,28 +374,27 @@ class TestMigration013:
)
await conn.commit()
# Run migration 13 (plus 14-37 which also run)
# Run migration 13 (plus 14-38 which also run)
applied = await run_migrations(conn)
assert applied == 25
assert await get_version(conn) == 37
assert applied == 26
assert await get_version(conn) == 38
# Verify bots array was created with migrated data
cursor = await conn.execute("SELECT bots FROM app_settings WHERE id = 1")
# Bots were migrated from app_settings to fanout_configs (migration 37)
# and the bots column was dropped (migration 38)
cursor = await conn.execute("SELECT * FROM fanout_configs WHERE type = 'bot'")
row = await cursor.fetchone()
bots = json.loads(row["bots"])
assert row is not None
assert len(bots) == 1
assert bots[0]["name"] == "Bot 1"
assert bots[0]["enabled"] is True
assert bots[0]["code"] == 'def bot(): return "hello"'
assert "id" in bots[0] # Should have a UUID
config = json.loads(row["config"])
assert config["code"] == 'def bot(): return "hello"'
assert row["name"] == "Bot 1"
assert bool(row["enabled"])
finally:
await conn.close()
@pytest.mark.asyncio
async def test_migration_creates_empty_array_when_no_bot(self):
"""Migration creates empty bots array when no existing bot data."""
import json
conn = await aiosqlite.connect(":memory:")
conn.row_factory = aiosqlite.Row
@@ -424,11 +423,10 @@ class TestMigration013:
await run_migrations(conn)
cursor = await conn.execute("SELECT bots FROM app_settings WHERE id = 1")
# Bots column was dropped by migration 38; verify no bots in fanout_configs
cursor = await conn.execute("SELECT COUNT(*) FROM fanout_configs WHERE type = 'bot'")
row = await cursor.fetchone()
bots = json.loads(row["bots"])
assert bots == []
assert row[0] == 0
finally:
await conn.close()
@@ -497,7 +495,7 @@ class TestMigration018:
assert await cursor.fetchone() is not None
await run_migrations(conn)
assert await get_version(conn) == 37
assert await get_version(conn) == 38
# Verify autoindex is gone
cursor = await conn.execute(
@@ -575,8 +573,8 @@ class TestMigration018:
await conn.commit()
applied = await run_migrations(conn)
assert applied == 20 # Migrations 18-37 run (18+19 skip internally)
assert await get_version(conn) == 37
assert applied == 21 # Migrations 18-38 run (18+19 skip internally)
assert await get_version(conn) == 38
finally:
await conn.close()
@@ -648,7 +646,7 @@ class TestMigration019:
assert await cursor.fetchone() is not None
await run_migrations(conn)
assert await get_version(conn) == 37
assert await get_version(conn) == 38
# Verify autoindex is gone
cursor = await conn.execute(
@@ -714,8 +712,8 @@ class TestMigration020:
assert (await cursor.fetchone())[0] == "delete"
applied = await run_migrations(conn)
assert applied == 18 # Migrations 20-37
assert await get_version(conn) == 37
assert applied == 19 # Migrations 20-38
assert await get_version(conn) == 38
# Verify WAL mode
cursor = await conn.execute("PRAGMA journal_mode")
@@ -745,7 +743,7 @@ class TestMigration020:
await set_version(conn, 20)
applied = await run_migrations(conn)
assert applied == 17 # Migrations 21-37 still run
assert applied == 18 # Migrations 21-38 still run
# Still WAL + INCREMENTAL
cursor = await conn.execute("PRAGMA journal_mode")
@@ -803,8 +801,8 @@ class TestMigration028:
await conn.commit()
applied = await run_migrations(conn)
assert applied == 10
assert await get_version(conn) == 37
assert applied == 11
assert await get_version(conn) == 38
# Verify payload_hash column is now BLOB
cursor = await conn.execute("PRAGMA table_info(raw_packets)")
@@ -873,8 +871,8 @@ class TestMigration028:
await conn.commit()
applied = await run_migrations(conn)
assert applied == 10 # Version still bumped
assert await get_version(conn) == 37
assert applied == 11 # Version still bumped
assert await get_version(conn) == 38
# Verify data unchanged
cursor = await conn.execute("SELECT payload_hash FROM raw_packets")
@@ -923,22 +921,16 @@ class TestMigration032:
await conn.commit()
applied = await run_migrations(conn)
assert applied == 6
assert await get_version(conn) == 37
assert applied == 7
assert await get_version(conn) == 38
# Verify all columns exist with correct defaults
# Community MQTT columns were added by migration 32 and dropped by migration 38.
# Verify community settings were NOT migrated (no community config existed).
cursor = await conn.execute(
"""SELECT community_mqtt_enabled, community_mqtt_iata,
community_mqtt_broker_host, community_mqtt_broker_port,
community_mqtt_email
FROM app_settings WHERE id = 1"""
"SELECT COUNT(*) FROM fanout_configs WHERE type = 'mqtt_community'"
)
row = await cursor.fetchone()
assert row["community_mqtt_enabled"] == 0
assert row["community_mqtt_iata"] == ""
assert row["community_mqtt_broker_host"] == "mqtt-us-v1.letsmesh.net"
assert row["community_mqtt_broker_port"] == 443
assert row["community_mqtt_email"] == ""
assert row[0] == 0
finally:
await conn.close()
@@ -996,8 +988,8 @@ class TestMigration034:
await conn.commit()
applied = await run_migrations(conn)
assert applied == 4
assert await get_version(conn) == 37
assert applied == 5
assert await get_version(conn) == 38
# Verify column exists with correct default
cursor = await conn.execute("SELECT flood_scope FROM app_settings WHERE id = 1")
@@ -1039,8 +1031,8 @@ class TestMigration033:
await conn.commit()
applied = await run_migrations(conn)
assert applied == 5
assert await get_version(conn) == 37
assert applied == 6
assert await get_version(conn) == 38
cursor = await conn.execute(
"SELECT key, name, is_hashtag, on_radio FROM channels WHERE key = ?",
+23 -22
View File
@@ -1,28 +1,29 @@
"""Tests for MQTT publisher module."""
import ssl
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.models import AppSettings
from app.mqtt import MqttPublisher, _build_message_topic, _build_raw_packet_topic
from app.fanout.mqtt import MqttPublisher, _build_message_topic, _build_raw_packet_topic
def _make_settings(**overrides) -> AppSettings:
"""Create an AppSettings with MQTT fields."""
def _make_settings(**overrides) -> SimpleNamespace:
"""Create a settings namespace with MQTT fields."""
defaults = {
"mqtt_broker_host": "broker.local",
"mqtt_broker_port": 1883,
"mqtt_username": "",
"mqtt_password": "",
"mqtt_use_tls": False,
"mqtt_tls_insecure": False,
"mqtt_topic_prefix": "meshcore",
"mqtt_publish_messages": True,
"mqtt_publish_raw_packets": True,
}
defaults.update(overrides)
return AppSettings(**defaults)
return SimpleNamespace(**defaults)
class TestTopicBuilders:
@@ -214,8 +215,8 @@ class TestConnectionLoop:
mock_client.__aenter__ = AsyncMock(side_effect=side_effect_aenter)
with (
patch("app.mqtt_base.aiomqtt.Client", return_value=mock_client),
patch("app.mqtt_base._broadcast_health"),
patch("app.fanout.mqtt_base.aiomqtt.Client", return_value=mock_client),
patch("app.fanout.mqtt_base._broadcast_health"),
patch("app.websocket.broadcast_success"),
patch("app.websocket.broadcast_health"),
):
@@ -235,7 +236,7 @@ class TestConnectionLoop:
"""Connection loop should retry after a connection error with backoff."""
import asyncio
from app.mqtt_base import _BACKOFF_MIN
from app.fanout.mqtt_base import _BACKOFF_MIN
pub = MqttPublisher()
settings = _make_settings()
@@ -268,12 +269,12 @@ class TestConnectionLoop:
return factory
with (
patch("app.mqtt_base.aiomqtt.Client", side_effect=make_client_factory()),
patch("app.mqtt_base._broadcast_health"),
patch("app.fanout.mqtt_base.aiomqtt.Client", side_effect=make_client_factory()),
patch("app.fanout.mqtt_base._broadcast_health"),
patch("app.websocket.broadcast_success"),
patch("app.websocket.broadcast_error"),
patch("app.websocket.broadcast_health"),
patch("app.mqtt_base.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
patch("app.fanout.mqtt_base.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
):
await pub.start(settings)
@@ -292,7 +293,7 @@ class TestConnectionLoop:
"""Backoff should double after each failure, capped at _backoff_max."""
import asyncio
from app.mqtt_base import _BACKOFF_MIN
from app.fanout.mqtt_base import _BACKOFF_MIN
pub = MqttPublisher()
settings = _make_settings()
@@ -322,11 +323,11 @@ class TestConnectionLoop:
raise asyncio.CancelledError
with (
patch("app.mqtt_base.aiomqtt.Client", side_effect=factory),
patch("app.mqtt_base._broadcast_health"),
patch("app.fanout.mqtt_base.aiomqtt.Client", side_effect=factory),
patch("app.fanout.mqtt_base._broadcast_health"),
patch("app.websocket.broadcast_error"),
patch("app.websocket.broadcast_health"),
patch("app.mqtt_base.asyncio.sleep", side_effect=capture_sleep),
patch("app.fanout.mqtt_base.asyncio.sleep", side_effect=capture_sleep),
):
await pub.start(settings)
try:
@@ -363,8 +364,8 @@ class TestConnectionLoop:
return mock
with (
patch("app.mqtt_base.aiomqtt.Client", side_effect=make_success_client),
patch("app.mqtt_base._broadcast_health"),
patch("app.fanout.mqtt_base.aiomqtt.Client", side_effect=make_success_client),
patch("app.fanout.mqtt_base._broadcast_health"),
patch("app.websocket.broadcast_success"),
patch("app.websocket.broadcast_health"),
):
@@ -411,8 +412,8 @@ class TestConnectionLoop:
return mock
with (
patch("app.mqtt_base.aiomqtt.Client", side_effect=make_client),
patch("app.mqtt_base._broadcast_health", side_effect=track_health),
patch("app.fanout.mqtt_base.aiomqtt.Client", side_effect=make_client),
patch("app.fanout.mqtt_base._broadcast_health", side_effect=track_health),
patch("app.websocket.broadcast_success"),
patch("app.websocket.broadcast_health"),
):
@@ -448,11 +449,11 @@ class TestConnectionLoop:
return mock
with (
patch("app.mqtt_base.aiomqtt.Client", side_effect=make_failing_client),
patch("app.mqtt_base._broadcast_health", side_effect=track_health),
patch("app.fanout.mqtt_base.aiomqtt.Client", side_effect=make_failing_client),
patch("app.fanout.mqtt_base._broadcast_health", side_effect=track_health),
patch("app.websocket.broadcast_error"),
patch("app.websocket.broadcast_health"),
patch("app.mqtt_base.asyncio.sleep", side_effect=cancel_on_sleep),
patch("app.fanout.mqtt_base.asyncio.sleep", side_effect=cancel_on_sleep),
):
await pub.start(settings)
try:
-16
View File
@@ -492,21 +492,6 @@ class TestAppSettingsRepository:
"preferences_migrated": 0,
"advert_interval": None,
"last_advert_time": None,
"bots": "{bad-bots-json",
"mqtt_broker_host": "",
"mqtt_broker_port": 1883,
"mqtt_username": "",
"mqtt_password": "",
"mqtt_use_tls": 0,
"mqtt_tls_insecure": 0,
"mqtt_topic_prefix": "meshcore",
"mqtt_publish_messages": 0,
"mqtt_publish_raw_packets": 0,
"community_mqtt_enabled": 0,
"community_mqtt_iata": "",
"community_mqtt_broker_host": "mqtt-us-v1.letsmesh.net",
"community_mqtt_broker_port": 443,
"community_mqtt_email": "",
"flood_scope": "",
"blocked_keys": "[]",
"blocked_names": "[]",
@@ -525,7 +510,6 @@ class TestAppSettingsRepository:
assert settings.favorites == []
assert settings.last_message_times == {}
assert settings.sidebar_sort_order == "recent"
assert settings.bots == []
assert settings.advert_interval == 0
assert settings.last_advert_time == 0