mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 01:03:34 +02:00
Add MQTT removal migration and fix tests + docs
This commit is contained in:
+21
-354
@@ -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"),
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user