mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 17:23:05 +02:00
Add basic bot functionality
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
"""Tests for the bot execution module."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.bot import (
|
||||
_bot_semaphore,
|
||||
execute_bot_code,
|
||||
run_bot_for_message,
|
||||
)
|
||||
|
||||
|
||||
class TestExecuteBotCode:
|
||||
"""Test bot code execution."""
|
||||
|
||||
def test_valid_code_returning_string(self):
|
||||
"""Bot code that returns a string works correctly."""
|
||||
code = """
|
||||
def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path):
|
||||
return f"Hello, {sender_name}!"
|
||||
"""
|
||||
result = execute_bot_code(
|
||||
code=code,
|
||||
sender_name="Alice",
|
||||
sender_key="abc123",
|
||||
message_text="Hi",
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
channel_name=None,
|
||||
sender_timestamp=None,
|
||||
path=None,
|
||||
)
|
||||
assert result == "Hello, Alice!"
|
||||
|
||||
def test_valid_code_returning_none(self):
|
||||
"""Bot code that returns None works correctly."""
|
||||
code = """
|
||||
def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path):
|
||||
return None
|
||||
"""
|
||||
result = execute_bot_code(
|
||||
code=code,
|
||||
sender_name="Alice",
|
||||
sender_key="abc123",
|
||||
message_text="Hi",
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
channel_name=None,
|
||||
sender_timestamp=None,
|
||||
path=None,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_empty_string_response_treated_as_none(self):
|
||||
"""Bot returning empty/whitespace string is treated as None."""
|
||||
code = """
|
||||
def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path):
|
||||
return " "
|
||||
"""
|
||||
result = execute_bot_code(
|
||||
code=code,
|
||||
sender_name="Alice",
|
||||
sender_key="abc123",
|
||||
message_text="Hi",
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
channel_name=None,
|
||||
sender_timestamp=None,
|
||||
path=None,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_code_with_syntax_error(self):
|
||||
"""Bot code with syntax error returns None."""
|
||||
code = """
|
||||
def bot(sender_name:
|
||||
return "broken"
|
||||
"""
|
||||
result = execute_bot_code(
|
||||
code=code,
|
||||
sender_name="Alice",
|
||||
sender_key="abc123",
|
||||
message_text="Hi",
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
channel_name=None,
|
||||
sender_timestamp=None,
|
||||
path=None,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_code_without_bot_function(self):
|
||||
"""Code that doesn't define 'bot' function returns None."""
|
||||
code = """
|
||||
def my_function():
|
||||
return "hello"
|
||||
"""
|
||||
result = execute_bot_code(
|
||||
code=code,
|
||||
sender_name="Alice",
|
||||
sender_key="abc123",
|
||||
message_text="Hi",
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
channel_name=None,
|
||||
sender_timestamp=None,
|
||||
path=None,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_bot_not_callable(self):
|
||||
"""Code where 'bot' is not callable returns None."""
|
||||
code = """
|
||||
bot = "I'm a string, not a function"
|
||||
"""
|
||||
result = execute_bot_code(
|
||||
code=code,
|
||||
sender_name="Alice",
|
||||
sender_key="abc123",
|
||||
message_text="Hi",
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
channel_name=None,
|
||||
sender_timestamp=None,
|
||||
path=None,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_bot_function_raises_exception(self):
|
||||
"""Bot function that raises exception returns None."""
|
||||
code = """
|
||||
def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path):
|
||||
raise ValueError("oops!")
|
||||
"""
|
||||
result = execute_bot_code(
|
||||
code=code,
|
||||
sender_name="Alice",
|
||||
sender_key="abc123",
|
||||
message_text="Hi",
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
channel_name=None,
|
||||
sender_timestamp=None,
|
||||
path=None,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_bot_returns_non_string(self):
|
||||
"""Bot function returning non-string returns None."""
|
||||
code = """
|
||||
def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path):
|
||||
return 42
|
||||
"""
|
||||
result = execute_bot_code(
|
||||
code=code,
|
||||
sender_name="Alice",
|
||||
sender_key="abc123",
|
||||
message_text="Hi",
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
channel_name=None,
|
||||
sender_timestamp=None,
|
||||
path=None,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_empty_code_returns_none(self):
|
||||
"""Empty bot code returns None."""
|
||||
result = execute_bot_code(
|
||||
code="",
|
||||
sender_name="Alice",
|
||||
sender_key="abc123",
|
||||
message_text="Hi",
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
channel_name=None,
|
||||
sender_timestamp=None,
|
||||
path=None,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_whitespace_only_code_returns_none(self):
|
||||
"""Whitespace-only bot code returns None."""
|
||||
result = execute_bot_code(
|
||||
code=" \n\t ",
|
||||
sender_name="Alice",
|
||||
sender_key="abc123",
|
||||
message_text="Hi",
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
channel_name=None,
|
||||
sender_timestamp=None,
|
||||
path=None,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_bot_receives_all_parameters(self):
|
||||
"""Bot function receives all expected parameters."""
|
||||
code = """
|
||||
def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path):
|
||||
# Verify all params are accessible
|
||||
parts = [
|
||||
f"name={sender_name}",
|
||||
f"key={sender_key}",
|
||||
f"msg={message_text}",
|
||||
f"dm={is_dm}",
|
||||
f"ch_key={channel_key}",
|
||||
f"ch_name={channel_name}",
|
||||
f"ts={sender_timestamp}",
|
||||
f"path={path}",
|
||||
]
|
||||
return "|".join(parts)
|
||||
"""
|
||||
result = execute_bot_code(
|
||||
code=code,
|
||||
sender_name="Bob",
|
||||
sender_key="def456",
|
||||
message_text="Test",
|
||||
is_dm=False,
|
||||
channel_key="AABBCCDD",
|
||||
channel_name="#test",
|
||||
sender_timestamp=12345,
|
||||
path="001122",
|
||||
)
|
||||
assert (
|
||||
result
|
||||
== "name=Bob|key=def456|msg=Test|dm=False|ch_key=AABBCCDD|ch_name=#test|ts=12345|path=001122"
|
||||
)
|
||||
|
||||
def test_channel_message_with_none_sender_key(self):
|
||||
"""Channel messages correctly pass None for sender_key."""
|
||||
code = """
|
||||
def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path):
|
||||
if sender_key is None and not is_dm:
|
||||
return "channel message detected"
|
||||
return "unexpected"
|
||||
"""
|
||||
result = execute_bot_code(
|
||||
code=code,
|
||||
sender_name="Someone",
|
||||
sender_key=None, # Channel messages don't have sender key
|
||||
message_text="Test",
|
||||
is_dm=False,
|
||||
channel_key="AABBCCDD",
|
||||
channel_name="#general",
|
||||
sender_timestamp=None,
|
||||
path=None,
|
||||
)
|
||||
assert result == "channel message detected"
|
||||
|
||||
|
||||
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_skips_outgoing_messages(self):
|
||||
"""Bot is not triggered for outgoing messages."""
|
||||
with patch("app.repository.AppSettingsRepository") as mock_repo:
|
||||
await run_bot_for_message(
|
||||
sender_name="Me",
|
||||
sender_key="abc123",
|
||||
message_text="Hello",
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
is_outgoing=True,
|
||||
)
|
||||
|
||||
# Should not even check settings
|
||||
mock_repo.get.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_when_bot_disabled(self):
|
||||
"""Bot is not triggered when disabled in settings."""
|
||||
with patch("app.repository.AppSettingsRepository") as mock_repo:
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.bot_enabled = False
|
||||
mock_settings.bot_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_bot_code_empty(self):
|
||||
"""Bot is not triggered when code is empty."""
|
||||
with patch("app.repository.AppSettingsRepository") as mock_repo:
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.bot_enabled = True
|
||||
mock_settings.bot_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.bot_enabled = True
|
||||
mock_settings_enabled.bot_code = "def bot(): return 'hi'"
|
||||
|
||||
mock_settings_disabled = MagicMock()
|
||||
mock_settings_disabled.bot_enabled = False
|
||||
mock_settings_disabled.bot_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 TestBotCodeValidation:
|
||||
"""Test bot code syntax validation on save."""
|
||||
|
||||
def test_valid_code_passes(self):
|
||||
"""Valid Python code passes validation."""
|
||||
from app.routers.settings import validate_bot_code
|
||||
|
||||
# Should not raise
|
||||
validate_bot_code("def bot(): return 'hello'")
|
||||
|
||||
def test_syntax_error_raises(self):
|
||||
"""Syntax error in code raises HTTPException."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.routers.settings import validate_bot_code
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
validate_bot_code("def bot(:\n return 'broken'")
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "syntax error" in exc_info.value.detail.lower()
|
||||
|
||||
def test_empty_code_passes(self):
|
||||
"""Empty code passes validation (disables bot)."""
|
||||
from app.routers.settings import validate_bot_code
|
||||
|
||||
# Should not raise
|
||||
validate_bot_code("")
|
||||
validate_bot_code(" ")
|
||||
@@ -192,6 +192,7 @@ class TestContactMessageCLIFiltering:
|
||||
patch("app.event_handlers.MessageRepository") as mock_repo,
|
||||
patch("app.event_handlers.ContactRepository") as mock_contact_repo,
|
||||
patch("app.event_handlers.broadcast_event") as mock_broadcast,
|
||||
patch("app.bot.run_bot_for_message", new_callable=AsyncMock),
|
||||
):
|
||||
mock_repo.create = AsyncMock(return_value=42)
|
||||
mock_contact_repo.get_by_key_prefix = AsyncMock(return_value=None)
|
||||
@@ -220,6 +221,7 @@ class TestContactMessageCLIFiltering:
|
||||
patch("app.event_handlers.MessageRepository") as mock_repo,
|
||||
patch("app.event_handlers.ContactRepository") as mock_contact_repo,
|
||||
patch("app.event_handlers.broadcast_event") as mock_broadcast,
|
||||
patch("app.bot.run_bot_for_message", new_callable=AsyncMock),
|
||||
):
|
||||
mock_repo.create = AsyncMock(return_value=42)
|
||||
mock_contact_repo.get_by_key_prefix = AsyncMock(return_value=None)
|
||||
@@ -254,6 +256,7 @@ class TestContactMessageCLIFiltering:
|
||||
patch("app.event_handlers.MessageRepository") as mock_repo,
|
||||
patch("app.event_handlers.ContactRepository") as mock_contact_repo,
|
||||
patch("app.event_handlers.broadcast_event"),
|
||||
patch("app.bot.run_bot_for_message", new_callable=AsyncMock),
|
||||
):
|
||||
mock_repo.create = AsyncMock(return_value=42)
|
||||
mock_contact_repo.get_by_key_prefix = AsyncMock(return_value=None)
|
||||
|
||||
@@ -100,8 +100,8 @@ class TestMigration001:
|
||||
# Run migrations
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 11 # All 11 migrations run
|
||||
assert await get_version(conn) == 11
|
||||
assert applied == 12 # All 11 migrations run
|
||||
assert await get_version(conn) == 12
|
||||
|
||||
# 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 == 11 # All 11 migrations run
|
||||
assert applied1 == 12 # All 11 migrations run
|
||||
assert applied2 == 0 # No migrations on second run
|
||||
assert await get_version(conn) == 11
|
||||
assert await get_version(conn) == 12
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@@ -246,8 +246,8 @@ class TestMigration001:
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
# All 11 migrations applied (version incremented) but no error
|
||||
assert applied == 11
|
||||
assert await get_version(conn) == 11
|
||||
assert applied == 12
|
||||
assert await get_version(conn) == 12
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user