mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-08 09:43:03 +02:00
Move bots into Fanout & Forwarding
This commit is contained in:
+2
-1
@@ -29,11 +29,12 @@ def cleanup_test_db_dir():
|
||||
async def test_db():
|
||||
"""Create an in-memory test database with schema + migrations."""
|
||||
from app.repository import channels, contacts, messages, raw_packets, settings
|
||||
from app.repository import fanout as fanout_repo
|
||||
|
||||
db = Database(":memory:")
|
||||
await db.connect()
|
||||
|
||||
submodules = [contacts, channels, messages, raw_packets, settings]
|
||||
submodules = [contacts, channels, messages, raw_packets, settings, fanout_repo]
|
||||
originals = [(mod, mod.db) for mod in submodules]
|
||||
|
||||
for mod in submodules:
|
||||
|
||||
@@ -183,13 +183,6 @@ export function markAllRead(): Promise<{ status: string; timestamp: number }> {
|
||||
|
||||
export type Favorite = { type: string; id: string };
|
||||
|
||||
export interface BotConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
max_radio_contacts: number;
|
||||
favorites: Favorite[];
|
||||
@@ -197,7 +190,6 @@ export interface AppSettings {
|
||||
sidebar_sort_order: string;
|
||||
last_message_times: Record<string, number>;
|
||||
preferences_migrated: boolean;
|
||||
bots: BotConfig[];
|
||||
advert_interval: number;
|
||||
}
|
||||
|
||||
@@ -212,6 +204,50 @@ export function updateSettings(patch: Partial<AppSettings>): Promise<AppSettings
|
||||
});
|
||||
}
|
||||
|
||||
// --- Fanout ---
|
||||
|
||||
export interface FanoutConfig {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
config: Record<string, unknown>;
|
||||
scope: Record<string, unknown>;
|
||||
sort_order: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export function getFanoutConfigs(): Promise<FanoutConfig[]> {
|
||||
return fetchJson('/fanout');
|
||||
}
|
||||
|
||||
export function createFanoutConfig(body: {
|
||||
type: string;
|
||||
name: string;
|
||||
config: Record<string, unknown>;
|
||||
scope?: Record<string, unknown>;
|
||||
enabled?: boolean;
|
||||
}): Promise<FanoutConfig> {
|
||||
return fetchJson('/fanout', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateFanoutConfig(
|
||||
id: string,
|
||||
patch: Partial<{ name: string; config: Record<string, unknown>; scope: Record<string, unknown>; enabled: boolean }>
|
||||
): Promise<FanoutConfig> {
|
||||
return fetchJson(`/fanout/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteFanoutConfig(id: string): Promise<{ deleted: boolean }> {
|
||||
return fetchJson(`/fanout/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
/**
|
||||
|
||||
+24
-20
@@ -1,6 +1,12 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { ensureFlightlessChannel, getSettings, updateSettings } from '../helpers/api';
|
||||
import type { BotConfig } from '../helpers/api';
|
||||
import {
|
||||
ensureFlightlessChannel,
|
||||
getFanoutConfigs,
|
||||
createFanoutConfig,
|
||||
deleteFanoutConfig,
|
||||
updateFanoutConfig,
|
||||
} from '../helpers/api';
|
||||
import type { FanoutConfig } from '../helpers/api';
|
||||
|
||||
const BOT_CODE = `def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path):
|
||||
if channel_name == "#flightless" and "!e2etest" in message_text.lower():
|
||||
@@ -8,45 +14,43 @@ const BOT_CODE = `def bot(sender_name, sender_key, message_text, is_dm, channel_
|
||||
return None`;
|
||||
|
||||
test.describe('Bot functionality', () => {
|
||||
let originalBots: BotConfig[];
|
||||
let createdBotId: string | null = null;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await ensureFlightlessChannel();
|
||||
const settings = await getSettings();
|
||||
originalBots = settings.bots ?? [];
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
// Restore original bot config
|
||||
try {
|
||||
await updateSettings({ bots: originalBots });
|
||||
} catch {
|
||||
console.warn('Failed to restore bot config');
|
||||
// Clean up the bot we created
|
||||
if (createdBotId) {
|
||||
try {
|
||||
await deleteFanoutConfig(createdBotId);
|
||||
} catch {
|
||||
console.warn('Failed to delete test bot');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('create a bot via API, verify it in UI, trigger it, and verify response', async ({
|
||||
page,
|
||||
}) => {
|
||||
// --- Step 1: Create and enable bot via API ---
|
||||
// CodeMirror is difficult to drive via Playwright (contenteditable, lazy-loaded),
|
||||
// so we set the bot code via the REST API and verify it through the UI.
|
||||
const testBot: BotConfig = {
|
||||
id: crypto.randomUUID(),
|
||||
// --- Step 1: Create and enable bot via fanout API ---
|
||||
const bot = await createFanoutConfig({
|
||||
type: 'bot',
|
||||
name: 'E2E Test Bot',
|
||||
config: { code: BOT_CODE },
|
||||
enabled: true,
|
||||
code: BOT_CODE,
|
||||
};
|
||||
await updateSettings({ bots: [...originalBots, testBot] });
|
||||
});
|
||||
createdBotId = bot.id;
|
||||
|
||||
// --- Step 2: Verify bot appears in settings UI ---
|
||||
await page.goto('/');
|
||||
await expect(page.getByText('Connected')).toBeVisible();
|
||||
|
||||
await page.getByText('Settings').click();
|
||||
await page.getByRole('button', { name: /🤖 Bots/ }).click();
|
||||
await page.getByRole('button', { name: /Fanout/ }).click();
|
||||
|
||||
// The bot name should be visible in the bot list
|
||||
// The bot name should be visible in the integration list
|
||||
await expect(page.getByText('E2E Test Bot')).toBeVisible();
|
||||
|
||||
// Exit settings page mode
|
||||
|
||||
+2
-14
@@ -162,16 +162,10 @@ class TestMessagesEndpoint:
|
||||
return_value=MagicMock(type=EventType.MSG_SENT, payload={})
|
||||
)
|
||||
|
||||
def _capture_task(coro):
|
||||
coro.close()
|
||||
return MagicMock()
|
||||
|
||||
radio_manager._meshcore = mock_mc
|
||||
with (
|
||||
patch("app.dependencies.radio_manager") as mock_rm,
|
||||
patch("app.bot.run_bot_for_message", new=AsyncMock()),
|
||||
patch("app.routers.messages.asyncio.create_task", side_effect=_capture_task),
|
||||
patch("app.routers.messages.broadcast_event", create=True) as mock_broadcast,
|
||||
patch("app.routers.messages.broadcast_event") as mock_broadcast,
|
||||
):
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.meshcore = mock_mc
|
||||
@@ -206,17 +200,11 @@ class TestMessagesEndpoint:
|
||||
mock_mc.commands.set_channel = AsyncMock(return_value=ok_result)
|
||||
mock_mc.commands.send_chan_msg = AsyncMock(return_value=ok_result)
|
||||
|
||||
def _capture_task(coro):
|
||||
coro.close()
|
||||
return MagicMock()
|
||||
|
||||
radio_manager._meshcore = mock_mc
|
||||
with (
|
||||
patch("app.dependencies.radio_manager") as mock_rm,
|
||||
patch("app.decoder.calculate_channel_hash", return_value="abcd"),
|
||||
patch("app.bot.run_bot_for_message", new=AsyncMock()),
|
||||
patch("app.routers.messages.asyncio.create_task", side_effect=_capture_task),
|
||||
patch("app.routers.messages.broadcast_event", create=True) as mock_broadcast,
|
||||
patch("app.routers.messages.broadcast_event") as mock_broadcast,
|
||||
):
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.meshcore = mock_mc
|
||||
|
||||
+15
-35
@@ -745,69 +745,49 @@ class TestMultipleBots:
|
||||
|
||||
|
||||
class TestBotCodeValidation:
|
||||
"""Test bot code syntax validation on save."""
|
||||
"""Test bot code syntax validation via fanout router."""
|
||||
|
||||
def test_valid_code_passes(self):
|
||||
"""Valid Python code passes validation."""
|
||||
from app.routers.settings import validate_bot_code
|
||||
from app.routers.fanout import _validate_bot_config
|
||||
|
||||
# Should not raise
|
||||
validate_bot_code("def bot(): return 'hello'")
|
||||
_validate_bot_config({"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
|
||||
from app.routers.fanout import _validate_bot_config
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
validate_bot_code("def bot(:\n return 'broken'")
|
||||
_validate_bot_config({"code": "def bot(:\n return 'broken'"})
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "syntax error" in exc_info.value.detail.lower()
|
||||
|
||||
def test_syntax_error_includes_bot_name(self):
|
||||
"""Syntax error message includes bot name when provided."""
|
||||
def test_empty_code_raises(self):
|
||||
"""Empty code raises HTTPException."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.routers.settings import validate_bot_code
|
||||
from app.routers.fanout import _validate_bot_config
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
validate_bot_code("def bot(:\n return 'broken'", bot_name="My Test Bot")
|
||||
_validate_bot_config({"code": ""})
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "My Test Bot" in exc_info.value.detail
|
||||
assert "empty" 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(" ")
|
||||
|
||||
def test_validate_all_bots(self):
|
||||
"""validate_all_bots validates all bots' code."""
|
||||
def test_missing_code_raises(self):
|
||||
"""Missing code key raises HTTPException."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.routers.settings import validate_all_bots
|
||||
from app.routers.fanout import _validate_bot_config
|
||||
|
||||
# Valid bots should pass
|
||||
valid_bots = [
|
||||
BotConfig(id="1", name="Bot 1", enabled=True, code="def bot(): return 'hi'"),
|
||||
BotConfig(id="2", name="Bot 2", enabled=False, code="def bot(): return 'hello'"),
|
||||
]
|
||||
validate_all_bots(valid_bots) # Should not raise
|
||||
|
||||
# Invalid code should raise with bot name
|
||||
invalid_bots = [
|
||||
BotConfig(id="1", name="Good Bot", enabled=True, code="def bot(): return 'hi'"),
|
||||
BotConfig(id="2", name="Bad Bot", enabled=True, code="def bot(:"),
|
||||
]
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
validate_all_bots(invalid_bots)
|
||||
_validate_bot_config({})
|
||||
|
||||
assert "Bad Bot" in exc_info.value.detail
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
|
||||
class TestBotMessageRateLimiting:
|
||||
|
||||
+24
-26
@@ -2,7 +2,7 @@
|
||||
|
||||
Verifies that when disable_bots=True:
|
||||
- run_bot_for_message() exits immediately without any work
|
||||
- PATCH /api/settings with bots returns 403
|
||||
- POST /api/fanout with type=bot returns 403
|
||||
- Health endpoint includes bots_disabled=True
|
||||
"""
|
||||
|
||||
@@ -14,8 +14,8 @@ 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
|
||||
from app.routers.settings import AppSettingsUpdate, update_settings
|
||||
|
||||
|
||||
class TestDisableBotsConfig:
|
||||
@@ -78,19 +78,20 @@ class TestDisableBotsBotExecution:
|
||||
mock_exec.assert_called_once()
|
||||
|
||||
|
||||
class TestDisableBotsSettingsEndpoint:
|
||||
"""Test that bot settings updates are rejected when bots are disabled."""
|
||||
class TestDisableBotsFanoutEndpoint:
|
||||
"""Test that bot creation via fanout router is rejected when bots are disabled."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_update_returns_403_when_disabled(self, test_db):
|
||||
"""PATCH /api/settings with bots field returns 403."""
|
||||
with patch("app.routers.settings.server_settings", MagicMock(disable_bots=True)):
|
||||
async def test_bot_create_returns_403_when_disabled(self, test_db):
|
||||
"""POST /api/fanout with type=bot returns 403."""
|
||||
with patch("app.routers.fanout.server_settings", MagicMock(disable_bots=True)):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await update_settings(
|
||||
AppSettingsUpdate(
|
||||
bots=[
|
||||
BotConfig(id="1", name="Bot", enabled=True, code="def bot(**k): pass")
|
||||
]
|
||||
await create_fanout_config(
|
||||
FanoutConfigCreate(
|
||||
type="bot",
|
||||
name="Test Bot",
|
||||
config={"code": "def bot(**k): pass"},
|
||||
enabled=False,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -98,22 +99,19 @@ class TestDisableBotsSettingsEndpoint:
|
||||
assert "disabled" in exc_info.value.detail.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_bot_update_allowed_when_disabled(self, test_db):
|
||||
"""Other settings can still be updated when bots are disabled."""
|
||||
with patch("app.routers.settings.server_settings", MagicMock(disable_bots=True)):
|
||||
result = await update_settings(AppSettingsUpdate(max_radio_contacts=50))
|
||||
assert result.max_radio_contacts == 50
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_update_allowed_when_not_disabled(self, test_db):
|
||||
"""Bot updates work normally when disable_bots is False."""
|
||||
with patch("app.routers.settings.server_settings", MagicMock(disable_bots=False)):
|
||||
result = await update_settings(
|
||||
AppSettingsUpdate(
|
||||
bots=[BotConfig(id="1", name="Bot", enabled=False, code="def bot(**k): pass")]
|
||||
async def test_mqtt_create_allowed_when_bots_disabled(self, test_db):
|
||||
"""Non-bot fanout configs can still be created when bots are disabled."""
|
||||
with patch("app.routers.fanout.server_settings", MagicMock(disable_bots=True)):
|
||||
# Create as disabled so fanout_manager.reload_config is not called
|
||||
result = await create_fanout_config(
|
||||
FanoutConfigCreate(
|
||||
type="mqtt_private",
|
||||
name="Test MQTT",
|
||||
config={"broker_host": "localhost", "broker_port": 1883},
|
||||
enabled=False,
|
||||
)
|
||||
)
|
||||
assert len(result.bots) == 1
|
||||
assert result["type"] == "mqtt_private"
|
||||
|
||||
|
||||
class TestDisableBotsHealthEndpoint:
|
||||
|
||||
@@ -5,7 +5,7 @@ delivery confirmation, contact message handling, and event registration.
|
||||
"""
|
||||
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -217,43 +217,12 @@ class TestContactMessageCLIFiltering:
|
||||
messages = await MessageRepository.get_all()
|
||||
assert len(messages) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normal_message_schedules_bot_in_background(self, test_db):
|
||||
"""Normal messages should schedule bot execution without blocking."""
|
||||
from app.event_handlers import on_contact_message
|
||||
|
||||
def _capture_task(coro):
|
||||
coro.close()
|
||||
return MagicMock()
|
||||
|
||||
with (
|
||||
patch("app.event_handlers.broadcast_event"),
|
||||
patch("app.event_handlers.asyncio.create_task", side_effect=_capture_task) as mock_task,
|
||||
patch("app.bot.run_bot_for_message", new_callable=AsyncMock) as mock_bot,
|
||||
):
|
||||
|
||||
class MockEvent:
|
||||
payload = {
|
||||
"pubkey_prefix": "abc123def456",
|
||||
"text": "Hello, bot",
|
||||
"txt_type": 0,
|
||||
"sender_timestamp": 1700000000,
|
||||
}
|
||||
|
||||
await on_contact_message(MockEvent())
|
||||
|
||||
mock_task.assert_called_once()
|
||||
mock_bot.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normal_message_still_processed(self, test_db):
|
||||
"""Normal messages (txt_type=0) are still processed normally."""
|
||||
from app.event_handlers import on_contact_message
|
||||
|
||||
with (
|
||||
patch("app.event_handlers.broadcast_event") as mock_broadcast,
|
||||
patch("app.bot.run_bot_for_message", new_callable=AsyncMock),
|
||||
):
|
||||
with patch("app.event_handlers.broadcast_event") as mock_broadcast:
|
||||
|
||||
class MockEvent:
|
||||
payload = {
|
||||
@@ -278,10 +247,7 @@ class TestContactMessageCLIFiltering:
|
||||
"""Broadcast payload should have acked as integer 0, not boolean False."""
|
||||
from app.event_handlers import on_contact_message
|
||||
|
||||
with (
|
||||
patch("app.event_handlers.broadcast_event") as mock_broadcast,
|
||||
patch("app.bot.run_bot_for_message", new_callable=AsyncMock),
|
||||
):
|
||||
with patch("app.event_handlers.broadcast_event") as mock_broadcast:
|
||||
|
||||
class MockEvent:
|
||||
payload = {
|
||||
@@ -326,10 +292,7 @@ class TestContactMessageCLIFiltering:
|
||||
"sender_name",
|
||||
}
|
||||
|
||||
with (
|
||||
patch("app.event_handlers.broadcast_event") as mock_broadcast,
|
||||
patch("app.bot.run_bot_for_message", new_callable=AsyncMock),
|
||||
):
|
||||
with patch("app.event_handlers.broadcast_event") as mock_broadcast:
|
||||
|
||||
class MockEvent:
|
||||
payload = {
|
||||
@@ -380,10 +343,7 @@ class TestContactMessageCLIFiltering:
|
||||
"""Messages without txt_type field are treated as normal (not filtered)."""
|
||||
from app.event_handlers import on_contact_message
|
||||
|
||||
with (
|
||||
patch("app.event_handlers.broadcast_event"),
|
||||
patch("app.bot.run_bot_for_message", new_callable=AsyncMock),
|
||||
):
|
||||
with patch("app.event_handlers.broadcast_event"):
|
||||
|
||||
class MockEvent:
|
||||
payload = {
|
||||
@@ -422,10 +382,7 @@ class TestContactMessageCLIFiltering:
|
||||
}
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.event_handlers.broadcast_event") as mock_broadcast,
|
||||
patch("app.bot.run_bot_for_message", new_callable=AsyncMock),
|
||||
):
|
||||
with patch("app.event_handlers.broadcast_event") as mock_broadcast:
|
||||
|
||||
class MockEvent:
|
||||
payload = {
|
||||
|
||||
@@ -526,3 +526,103 @@ class TestMigration036:
|
||||
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()
|
||||
|
||||
+26
-26
@@ -100,8 +100,8 @@ class TestMigration001:
|
||||
# Run migrations
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 36 # All migrations run
|
||||
assert await get_version(conn) == 36
|
||||
assert applied == 37 # All migrations run
|
||||
assert await get_version(conn) == 37
|
||||
|
||||
# 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 == 36 # All migrations run
|
||||
assert applied1 == 37 # All migrations run
|
||||
assert applied2 == 0 # No migrations on second run
|
||||
assert await get_version(conn) == 36
|
||||
assert await get_version(conn) == 37
|
||||
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 == 36
|
||||
assert await get_version(conn) == 36
|
||||
assert applied == 37
|
||||
assert await get_version(conn) == 37
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@@ -374,10 +374,10 @@ class TestMigration013:
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
# Run migration 13 (plus 14-36 which also run)
|
||||
# Run migration 13 (plus 14-37 which also run)
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 24
|
||||
assert await get_version(conn) == 36
|
||||
assert applied == 25
|
||||
assert await get_version(conn) == 37
|
||||
|
||||
# Verify bots array was created with migrated data
|
||||
cursor = await conn.execute("SELECT bots FROM app_settings WHERE id = 1")
|
||||
@@ -497,7 +497,7 @@ class TestMigration018:
|
||||
assert await cursor.fetchone() is not None
|
||||
|
||||
await run_migrations(conn)
|
||||
assert await get_version(conn) == 36
|
||||
assert await get_version(conn) == 37
|
||||
|
||||
# Verify autoindex is gone
|
||||
cursor = await conn.execute(
|
||||
@@ -575,8 +575,8 @@ class TestMigration018:
|
||||
await conn.commit()
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 19 # Migrations 18-36 run (18+19 skip internally)
|
||||
assert await get_version(conn) == 36
|
||||
assert applied == 20 # Migrations 18-37 run (18+19 skip internally)
|
||||
assert await get_version(conn) == 37
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@@ -648,7 +648,7 @@ class TestMigration019:
|
||||
assert await cursor.fetchone() is not None
|
||||
|
||||
await run_migrations(conn)
|
||||
assert await get_version(conn) == 36
|
||||
assert await get_version(conn) == 37
|
||||
|
||||
# Verify autoindex is gone
|
||||
cursor = await conn.execute(
|
||||
@@ -714,8 +714,8 @@ class TestMigration020:
|
||||
assert (await cursor.fetchone())[0] == "delete"
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 17 # Migrations 20-36
|
||||
assert await get_version(conn) == 36
|
||||
assert applied == 18 # Migrations 20-37
|
||||
assert await get_version(conn) == 37
|
||||
|
||||
# Verify WAL mode
|
||||
cursor = await conn.execute("PRAGMA journal_mode")
|
||||
@@ -745,7 +745,7 @@ class TestMigration020:
|
||||
await set_version(conn, 20)
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 16 # Migrations 21-36 still run
|
||||
assert applied == 17 # Migrations 21-37 still run
|
||||
|
||||
# Still WAL + INCREMENTAL
|
||||
cursor = await conn.execute("PRAGMA journal_mode")
|
||||
@@ -803,8 +803,8 @@ class TestMigration028:
|
||||
await conn.commit()
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 9
|
||||
assert await get_version(conn) == 36
|
||||
assert applied == 10
|
||||
assert await get_version(conn) == 37
|
||||
|
||||
# Verify payload_hash column is now BLOB
|
||||
cursor = await conn.execute("PRAGMA table_info(raw_packets)")
|
||||
@@ -873,8 +873,8 @@ class TestMigration028:
|
||||
await conn.commit()
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 9 # Version still bumped
|
||||
assert await get_version(conn) == 36
|
||||
assert applied == 10 # Version still bumped
|
||||
assert await get_version(conn) == 37
|
||||
|
||||
# Verify data unchanged
|
||||
cursor = await conn.execute("SELECT payload_hash FROM raw_packets")
|
||||
@@ -923,8 +923,8 @@ class TestMigration032:
|
||||
await conn.commit()
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 5
|
||||
assert await get_version(conn) == 36
|
||||
assert applied == 6
|
||||
assert await get_version(conn) == 37
|
||||
|
||||
# Verify all columns exist with correct defaults
|
||||
cursor = await conn.execute(
|
||||
@@ -996,8 +996,8 @@ class TestMigration034:
|
||||
await conn.commit()
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 3
|
||||
assert await get_version(conn) == 36
|
||||
assert applied == 4
|
||||
assert await get_version(conn) == 37
|
||||
|
||||
# Verify column exists with correct default
|
||||
cursor = await conn.execute("SELECT flood_scope FROM app_settings WHERE id = 1")
|
||||
@@ -1039,8 +1039,8 @@ class TestMigration033:
|
||||
await conn.commit()
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 4
|
||||
assert await get_version(conn) == 36
|
||||
assert applied == 5
|
||||
assert await get_version(conn) == 37
|
||||
|
||||
cursor = await conn.execute(
|
||||
"SELECT key, name, is_hashtag, on_radio FROM channels WHERE key = ?",
|
||||
|
||||
@@ -509,40 +509,6 @@ class TestAckPipeline:
|
||||
class TestCreateMessageFromDecrypted:
|
||||
"""Test the shared message creation function used by both real-time and historical decryption."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schedules_bot_in_background(self, test_db, captured_broadcasts):
|
||||
"""Bot execution is scheduled and does not block channel message persistence."""
|
||||
from app.packet_processor import create_message_from_decrypted
|
||||
|
||||
packet_id, _ = await RawPacketRepository.create(b"test_packet_bot_channel", 1700000000)
|
||||
broadcasts, mock_broadcast = captured_broadcasts
|
||||
|
||||
def _capture_task(coro):
|
||||
coro.close()
|
||||
return MagicMock()
|
||||
|
||||
with (
|
||||
patch("app.packet_processor.broadcast_event", mock_broadcast),
|
||||
patch(
|
||||
"app.packet_processor.asyncio.create_task", side_effect=_capture_task
|
||||
) as mock_task,
|
||||
patch("app.bot.run_bot_for_message", new_callable=AsyncMock) as mock_bot,
|
||||
):
|
||||
msg_id = await create_message_from_decrypted(
|
||||
packet_id=packet_id,
|
||||
channel_key="ABC123DEF456",
|
||||
sender="BotTrigger",
|
||||
message_text="Hello from channel",
|
||||
timestamp=1700000000,
|
||||
received_at=1700000001,
|
||||
trigger_bot=True,
|
||||
)
|
||||
|
||||
assert msg_id is not None
|
||||
mock_task.assert_called_once()
|
||||
mock_bot.assert_called_once()
|
||||
assert mock_bot.await_count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creates_message_and_broadcasts(self, test_db, captured_broadcasts):
|
||||
"""create_message_from_decrypted creates message and broadcasts correctly."""
|
||||
@@ -760,48 +726,6 @@ class TestCreateDMMessageFromDecrypted:
|
||||
FACE12_PUB = "FACE123334789E2B81519AFDBC39A3C9EB7EA3457AD367D3243597A484847E46"
|
||||
A1B2C3_PUB = "a1b2c3d3ba9f5fa8705b9845fe11cc6f01d1d49caaf4d122ac7121663c5beec7"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schedules_bot_in_background(self, test_db, captured_broadcasts):
|
||||
"""Bot execution is scheduled and does not block DM persistence."""
|
||||
from app.decoder import DecryptedDirectMessage
|
||||
from app.packet_processor import create_dm_message_from_decrypted
|
||||
|
||||
packet_id, _ = await RawPacketRepository.create(b"test_packet_bot_dm", 1700000000)
|
||||
decrypted = DecryptedDirectMessage(
|
||||
timestamp=1700000000,
|
||||
flags=0,
|
||||
message="Hello from DM",
|
||||
dest_hash="fa",
|
||||
src_hash="a1",
|
||||
)
|
||||
broadcasts, mock_broadcast = captured_broadcasts
|
||||
|
||||
def _capture_task(coro):
|
||||
coro.close()
|
||||
return MagicMock()
|
||||
|
||||
with (
|
||||
patch("app.packet_processor.broadcast_event", mock_broadcast),
|
||||
patch(
|
||||
"app.packet_processor.asyncio.create_task", side_effect=_capture_task
|
||||
) as mock_task,
|
||||
patch("app.bot.run_bot_for_message", new_callable=AsyncMock) as mock_bot,
|
||||
):
|
||||
msg_id = await create_dm_message_from_decrypted(
|
||||
packet_id=packet_id,
|
||||
decrypted=decrypted,
|
||||
their_public_key=self.A1B2C3_PUB,
|
||||
our_public_key=self.FACE12_PUB,
|
||||
received_at=1700000001,
|
||||
outgoing=False,
|
||||
trigger_bot=True,
|
||||
)
|
||||
|
||||
assert msg_id is not None
|
||||
mock_task.assert_called_once()
|
||||
mock_bot.assert_called_once()
|
||||
assert mock_bot.await_count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creates_dm_message_and_broadcasts(self, test_db, captured_broadcasts):
|
||||
"""create_dm_message_from_decrypted creates message and broadcasts correctly."""
|
||||
|
||||
+36
-118
@@ -1,4 +1,4 @@
|
||||
"""Tests for bot triggering on outgoing messages sent via the messages router."""
|
||||
"""Tests for outgoing message sending via the messages router."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
@@ -76,77 +76,36 @@ async def _insert_contact(public_key, name="Alice"):
|
||||
)
|
||||
|
||||
|
||||
class TestOutgoingDMBotTrigger:
|
||||
"""Test that sending a DM triggers bots with is_outgoing=True."""
|
||||
class TestOutgoingDMBroadcast:
|
||||
"""Test that outgoing DMs are broadcast via broadcast_event for fanout dispatch."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_dm_triggers_bot(self, test_db):
|
||||
"""Sending a DM creates a background task to run bots."""
|
||||
async def test_send_dm_broadcasts_outgoing(self, test_db):
|
||||
"""Sending a DM broadcasts the message with outgoing=True for fanout dispatch."""
|
||||
mc = _make_mc()
|
||||
pub_key = "ab" * 32
|
||||
await _insert_contact(pub_key, "Alice")
|
||||
|
||||
broadcasts = []
|
||||
|
||||
def capture_broadcast(event_type, data):
|
||||
broadcasts.append({"type": event_type, "data": data})
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.bot.run_bot_for_message", new=AsyncMock()) as mock_bot,
|
||||
patch("app.routers.messages.broadcast_event", side_effect=capture_broadcast),
|
||||
):
|
||||
request = SendDirectMessageRequest(destination=pub_key, text="!lasttime Alice")
|
||||
await send_direct_message(request)
|
||||
|
||||
# Let the background task run
|
||||
await asyncio.sleep(0)
|
||||
|
||||
mock_bot.assert_called_once()
|
||||
call_kwargs = mock_bot.call_args[1]
|
||||
assert call_kwargs["message_text"] == "!lasttime Alice"
|
||||
assert call_kwargs["is_dm"] is True
|
||||
assert call_kwargs["is_outgoing"] is True
|
||||
assert call_kwargs["sender_key"] == pub_key
|
||||
assert call_kwargs["channel_key"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_dm_bot_does_not_block_response(self, test_db):
|
||||
"""Bot trigger runs in background and doesn't delay the message response."""
|
||||
mc = _make_mc()
|
||||
pub_key = "ab" * 32
|
||||
await _insert_contact(pub_key, "Alice")
|
||||
|
||||
# Bot that would take a long time
|
||||
async def _slow(**kw):
|
||||
await asyncio.sleep(10)
|
||||
|
||||
slow_bot = AsyncMock(side_effect=_slow)
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.bot.run_bot_for_message", new=slow_bot),
|
||||
):
|
||||
request = SendDirectMessageRequest(destination=pub_key, text="Hello")
|
||||
# This should return immediately, not wait 10 seconds
|
||||
message = await send_direct_message(request)
|
||||
assert message.text == "Hello"
|
||||
assert message.outgoing is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_dm_passes_no_sender_name(self, test_db):
|
||||
"""Outgoing DMs pass sender_name=None (we are the sender)."""
|
||||
mc = _make_mc()
|
||||
pub_key = "cd" * 32
|
||||
await _insert_contact(pub_key, "Bob")
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.bot.run_bot_for_message", new=AsyncMock()) as mock_bot,
|
||||
):
|
||||
request = SendDirectMessageRequest(destination=pub_key, text="test")
|
||||
await send_direct_message(request)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
call_kwargs = mock_bot.call_args[1]
|
||||
assert call_kwargs["sender_name"] is None
|
||||
msg_broadcasts = [b for b in broadcasts if b["type"] == "message"]
|
||||
assert len(msg_broadcasts) == 1
|
||||
data = msg_broadcasts[0]["data"]
|
||||
assert data["text"] == "!lasttime Alice"
|
||||
assert data["outgoing"] is True
|
||||
assert data["type"] == "PRIV"
|
||||
assert data["conversation_key"] == pub_key
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_dm_ambiguous_prefix_returns_409(self, test_db):
|
||||
@@ -167,77 +126,37 @@ class TestOutgoingDMBotTrigger:
|
||||
assert "ambiguous" in exc_info.value.detail.lower()
|
||||
|
||||
|
||||
class TestOutgoingChannelBotTrigger:
|
||||
"""Test that sending a channel message triggers bots with is_outgoing=True."""
|
||||
class TestOutgoingChannelBroadcast:
|
||||
"""Test that outgoing channel messages are broadcast via broadcast_event for fanout dispatch."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_channel_msg_triggers_bot(self, test_db):
|
||||
"""Sending a channel message creates a background task to run bots."""
|
||||
async def test_send_channel_msg_broadcasts_outgoing(self, test_db):
|
||||
"""Sending a channel message broadcasts with outgoing=True for fanout dispatch."""
|
||||
mc = _make_mc(name="MyNode")
|
||||
chan_key = "aa" * 16
|
||||
await ChannelRepository.upsert(key=chan_key, name="#general")
|
||||
|
||||
broadcasts = []
|
||||
|
||||
def capture_broadcast(event_type, data):
|
||||
broadcasts.append({"type": event_type, "data": data})
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.decoder.calculate_channel_hash", return_value="abcd"),
|
||||
patch("app.bot.run_bot_for_message", new=AsyncMock()) as mock_bot,
|
||||
patch("app.routers.messages.broadcast_event", side_effect=capture_broadcast),
|
||||
):
|
||||
request = SendChannelMessageRequest(channel_key=chan_key, text="!lasttime5 someone")
|
||||
await send_channel_message(request)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
mock_bot.assert_called_once()
|
||||
call_kwargs = mock_bot.call_args[1]
|
||||
assert call_kwargs["message_text"] == "!lasttime5 someone"
|
||||
assert call_kwargs["is_dm"] is False
|
||||
assert call_kwargs["is_outgoing"] is True
|
||||
assert call_kwargs["channel_key"] == chan_key.upper()
|
||||
assert call_kwargs["channel_name"] == "#general"
|
||||
assert call_kwargs["sender_name"] == "MyNode"
|
||||
assert call_kwargs["sender_key"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_channel_msg_no_radio_name(self, test_db):
|
||||
"""When radio has no name, sender_name is None."""
|
||||
mc = _make_mc(name="")
|
||||
chan_key = "bb" * 16
|
||||
await ChannelRepository.upsert(key=chan_key, name="#test")
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.decoder.calculate_channel_hash", return_value="abcd"),
|
||||
patch("app.bot.run_bot_for_message", new=AsyncMock()) as mock_bot,
|
||||
):
|
||||
request = SendChannelMessageRequest(channel_key=chan_key, text="hello")
|
||||
await send_channel_message(request)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
call_kwargs = mock_bot.call_args[1]
|
||||
assert call_kwargs["sender_name"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_channel_msg_bot_does_not_block_response(self, test_db):
|
||||
"""Bot trigger runs in background and doesn't delay the message response."""
|
||||
mc = _make_mc(name="MyNode")
|
||||
chan_key = "cc" * 16
|
||||
await ChannelRepository.upsert(key=chan_key, name="#slow")
|
||||
|
||||
async def _slow(**kw):
|
||||
await asyncio.sleep(10)
|
||||
|
||||
slow_bot = AsyncMock(side_effect=_slow)
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.decoder.calculate_channel_hash", return_value="abcd"),
|
||||
patch("app.bot.run_bot_for_message", new=slow_bot),
|
||||
):
|
||||
request = SendChannelMessageRequest(channel_key=chan_key, text="test")
|
||||
message = await send_channel_message(request)
|
||||
assert message.outgoing is True
|
||||
msg_broadcasts = [b for b in broadcasts if b["type"] == "message"]
|
||||
assert len(msg_broadcasts) == 1
|
||||
data = msg_broadcasts[0]["data"]
|
||||
assert data["outgoing"] is True
|
||||
assert data["type"] == "CHAN"
|
||||
assert data["conversation_key"] == chan_key.upper()
|
||||
assert data["sender_name"] == "MyNode"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_channel_msg_response_includes_current_ack_count(self, test_db):
|
||||
@@ -250,7 +169,7 @@ class TestOutgoingChannelBotTrigger:
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.decoder.calculate_channel_hash", return_value="abcd"),
|
||||
patch("app.bot.run_bot_for_message", new=AsyncMock()),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
):
|
||||
request = SendChannelMessageRequest(channel_key=chan_key, text="acked now")
|
||||
message = await send_channel_message(request)
|
||||
@@ -277,7 +196,6 @@ class TestOutgoingChannelBotTrigger:
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.decoder.calculate_channel_hash", return_value="abcd"),
|
||||
patch("app.bot.run_bot_for_message", new=AsyncMock()),
|
||||
patch("app.routers.messages.broadcast_event", side_effect=capture_broadcast),
|
||||
):
|
||||
request = SendChannelMessageRequest(channel_key=chan_key, text="hello")
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.models import AppSettings, BotConfig
|
||||
from app.models import AppSettings
|
||||
from app.repository import AppSettingsRepository
|
||||
from app.routers.settings import (
|
||||
AppSettingsUpdate,
|
||||
@@ -53,21 +52,6 @@ class TestUpdateSettings:
|
||||
assert isinstance(result, AppSettings)
|
||||
assert result.max_radio_contacts == 200 # default
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_bot_syntax_returns_400(self):
|
||||
bad_bot = BotConfig(
|
||||
id="bot-1",
|
||||
name="BadBot",
|
||||
enabled=True,
|
||||
code="def bot(:\n return 'x'\n",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await update_settings(AppSettingsUpdate(bots=[bad_bot]))
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert "syntax error" in exc.value.detail.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flood_scope_round_trip(self, test_db):
|
||||
"""Flood scope should be saved and retrieved correctly."""
|
||||
|
||||
Reference in New Issue
Block a user