Move bots into Fanout & Forwarding

This commit is contained in:
Jack Kingsman
2026-03-05 18:14:03 -08:00
parent 7cd54d14d8
commit 5ecb63fde9
27 changed files with 671 additions and 908 deletions
+2 -1
View File
@@ -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:
+44 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+6 -49
View File
@@ -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 = {
+100
View File
@@ -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
View File
@@ -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 = ?",
-76
View File
@@ -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
View File
@@ -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")
+1 -17
View File
@@ -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."""