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
+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"),
):