mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
529 lines
18 KiB
Python
529 lines
18 KiB
Python
"""Tests for fanout bus: manager, scope matching, repository, and modules."""
|
|
|
|
import json
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
from app.database import Database
|
|
from app.fanout.base import FanoutModule
|
|
from app.fanout.manager import (
|
|
FanoutManager,
|
|
_scope_matches_message,
|
|
_scope_matches_raw,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scope matching unit tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestScopeMatchesMessage:
|
|
def test_all_matches_everything(self):
|
|
assert _scope_matches_message({"messages": "all"}, {"type": "PRIV"})
|
|
|
|
def test_none_matches_nothing(self):
|
|
assert not _scope_matches_message({"messages": "none"}, {"type": "PRIV"})
|
|
|
|
def test_missing_key_defaults_none(self):
|
|
assert not _scope_matches_message({}, {"type": "PRIV"})
|
|
|
|
def test_dict_channels_all(self):
|
|
scope = {"messages": {"channels": "all", "contacts": "none"}}
|
|
assert _scope_matches_message(scope, {"type": "CHAN", "conversation_key": "ch1"})
|
|
|
|
def test_dict_channels_none(self):
|
|
scope = {"messages": {"channels": "none"}}
|
|
assert not _scope_matches_message(scope, {"type": "CHAN", "conversation_key": "ch1"})
|
|
|
|
def test_dict_channels_list_match(self):
|
|
scope = {"messages": {"channels": ["ch1", "ch2"]}}
|
|
assert _scope_matches_message(scope, {"type": "CHAN", "conversation_key": "ch1"})
|
|
|
|
def test_dict_channels_list_no_match(self):
|
|
scope = {"messages": {"channels": ["ch1", "ch2"]}}
|
|
assert not _scope_matches_message(scope, {"type": "CHAN", "conversation_key": "ch3"})
|
|
|
|
def test_dict_contacts_all(self):
|
|
scope = {"messages": {"contacts": "all"}}
|
|
assert _scope_matches_message(scope, {"type": "PRIV", "conversation_key": "pk1"})
|
|
|
|
def test_dict_contacts_list_match(self):
|
|
scope = {"messages": {"contacts": ["pk1"]}}
|
|
assert _scope_matches_message(scope, {"type": "PRIV", "conversation_key": "pk1"})
|
|
|
|
def test_dict_contacts_list_no_match(self):
|
|
scope = {"messages": {"contacts": ["pk1"]}}
|
|
assert not _scope_matches_message(scope, {"type": "PRIV", "conversation_key": "pk2"})
|
|
|
|
|
|
class TestScopeMatchesRaw:
|
|
def test_all_matches(self):
|
|
assert _scope_matches_raw({"raw_packets": "all"}, {})
|
|
|
|
def test_none_does_not_match(self):
|
|
assert not _scope_matches_raw({"raw_packets": "none"}, {})
|
|
|
|
def test_missing_key_does_not_match(self):
|
|
assert not _scope_matches_raw({}, {})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# FanoutManager dispatch tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class StubModule(FanoutModule):
|
|
"""Minimal FanoutModule for testing dispatch."""
|
|
|
|
def __init__(self):
|
|
super().__init__("stub", {})
|
|
self.message_calls: list[dict] = []
|
|
self.raw_calls: list[dict] = []
|
|
self._status = "connected"
|
|
|
|
async def start(self) -> None:
|
|
pass
|
|
|
|
async def stop(self) -> None:
|
|
pass
|
|
|
|
async def on_message(self, data: dict) -> None:
|
|
self.message_calls.append(data)
|
|
|
|
async def on_raw(self, data: dict) -> None:
|
|
self.raw_calls.append(data)
|
|
|
|
@property
|
|
def status(self) -> str:
|
|
return self._status
|
|
|
|
|
|
class TestFanoutManagerDispatch:
|
|
@pytest.mark.asyncio
|
|
async def test_broadcast_message_dispatches_to_matching_module(self):
|
|
manager = FanoutManager()
|
|
mod = StubModule()
|
|
scope = {"messages": "all", "raw_packets": "none"}
|
|
manager._modules["test-id"] = (mod, scope)
|
|
|
|
await manager.broadcast_message({"type": "PRIV", "conversation_key": "pk1"})
|
|
|
|
assert len(mod.message_calls) == 1
|
|
assert mod.message_calls[0]["conversation_key"] == "pk1"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_broadcast_message_skips_non_matching_module(self):
|
|
manager = FanoutManager()
|
|
mod = StubModule()
|
|
scope = {"messages": "none", "raw_packets": "all"}
|
|
manager._modules["test-id"] = (mod, scope)
|
|
|
|
await manager.broadcast_message({"type": "PRIV", "conversation_key": "pk1"})
|
|
|
|
assert len(mod.message_calls) == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_broadcast_raw_dispatches_to_matching_module(self):
|
|
manager = FanoutManager()
|
|
mod = StubModule()
|
|
scope = {"messages": "none", "raw_packets": "all"}
|
|
manager._modules["test-id"] = (mod, scope)
|
|
|
|
await manager.broadcast_raw({"data": "aabbccdd"})
|
|
|
|
assert len(mod.raw_calls) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_broadcast_raw_skips_non_matching(self):
|
|
manager = FanoutManager()
|
|
mod = StubModule()
|
|
scope = {"messages": "all", "raw_packets": "none"}
|
|
manager._modules["test-id"] = (mod, scope)
|
|
|
|
await manager.broadcast_raw({"data": "aabbccdd"})
|
|
|
|
assert len(mod.raw_calls) == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_all_stops_all_modules(self):
|
|
manager = FanoutManager()
|
|
mod1 = StubModule()
|
|
mod1.stop = AsyncMock()
|
|
mod2 = StubModule()
|
|
mod2.stop = AsyncMock()
|
|
manager._modules["id1"] = (mod1, {})
|
|
manager._modules["id2"] = (mod2, {})
|
|
|
|
await manager.stop_all()
|
|
|
|
mod1.stop.assert_called_once()
|
|
mod2.stop.assert_called_once()
|
|
assert len(manager._modules) == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_module_error_does_not_halt_broadcast(self):
|
|
manager = FanoutManager()
|
|
bad_mod = StubModule()
|
|
|
|
async def fail(data):
|
|
raise RuntimeError("boom")
|
|
|
|
bad_mod.on_message = fail
|
|
good_mod = StubModule()
|
|
|
|
manager._modules["bad"] = (bad_mod, {"messages": "all"})
|
|
manager._modules["good"] = (good_mod, {"messages": "all"})
|
|
|
|
await manager.broadcast_message({"type": "PRIV", "conversation_key": "pk1"})
|
|
|
|
# Good module should still receive the message despite the bad one failing
|
|
assert len(good_mod.message_calls) == 1
|
|
|
|
def test_get_statuses(self):
|
|
manager = FanoutManager()
|
|
mod = StubModule()
|
|
mod._status = "connected"
|
|
manager._modules["test-id"] = (mod, {})
|
|
|
|
with patch(
|
|
"app.repository.fanout._configs_cache",
|
|
{"test-id": {"name": "Test", "type": "mqtt_private"}},
|
|
):
|
|
statuses = manager.get_statuses()
|
|
|
|
assert "test-id" in statuses
|
|
assert statuses["test-id"]["status"] == "connected"
|
|
assert statuses["test-id"]["name"] == "Test"
|
|
assert statuses["test-id"]["type"] == "mqtt_private"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Repository tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
async def fanout_db():
|
|
"""Create an in-memory database with fanout_configs table."""
|
|
import app.repository.fanout as fanout_mod
|
|
|
|
db = Database(":memory:")
|
|
await db.connect()
|
|
|
|
await db.conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS fanout_configs (
|
|
id TEXT PRIMARY KEY,
|
|
type TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
enabled INTEGER NOT NULL DEFAULT 0,
|
|
config TEXT NOT NULL DEFAULT '{}',
|
|
scope TEXT NOT NULL DEFAULT '{}',
|
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
created_at INTEGER NOT NULL DEFAULT 0
|
|
)
|
|
""")
|
|
await db.conn.commit()
|
|
|
|
original_db = fanout_mod.db
|
|
fanout_mod.db = db
|
|
|
|
try:
|
|
yield db
|
|
finally:
|
|
fanout_mod.db = original_db
|
|
await db.disconnect()
|
|
|
|
|
|
class TestFanoutConfigRepository:
|
|
@pytest.mark.asyncio
|
|
async def test_create_and_get(self, fanout_db):
|
|
from app.repository.fanout import FanoutConfigRepository
|
|
|
|
cfg = await FanoutConfigRepository.create(
|
|
config_type="mqtt_private",
|
|
name="Test MQTT",
|
|
config={"broker_host": "localhost", "broker_port": 1883},
|
|
scope={"messages": "all", "raw_packets": "all"},
|
|
enabled=True,
|
|
)
|
|
|
|
assert cfg["type"] == "mqtt_private"
|
|
assert cfg["name"] == "Test MQTT"
|
|
assert cfg["enabled"] is True
|
|
assert cfg["config"]["broker_host"] == "localhost"
|
|
|
|
fetched = await FanoutConfigRepository.get(cfg["id"])
|
|
assert fetched is not None
|
|
assert fetched["id"] == cfg["id"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_all(self, fanout_db):
|
|
from app.repository.fanout import FanoutConfigRepository
|
|
|
|
await FanoutConfigRepository.create(
|
|
config_type="mqtt_private", name="A", config={}, scope={}, enabled=True
|
|
)
|
|
await FanoutConfigRepository.create(
|
|
config_type="mqtt_community", name="B", config={}, scope={}, enabled=False
|
|
)
|
|
|
|
all_configs = await FanoutConfigRepository.get_all()
|
|
assert len(all_configs) == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update(self, fanout_db):
|
|
from app.repository.fanout import FanoutConfigRepository
|
|
|
|
cfg = await FanoutConfigRepository.create(
|
|
config_type="mqtt_private",
|
|
name="Original",
|
|
config={"broker_host": "old"},
|
|
scope={},
|
|
enabled=True,
|
|
)
|
|
|
|
updated = await FanoutConfigRepository.update(
|
|
cfg["id"],
|
|
name="Renamed",
|
|
config={"broker_host": "new"},
|
|
enabled=False,
|
|
)
|
|
|
|
assert updated is not None
|
|
assert updated["name"] == "Renamed"
|
|
assert updated["config"]["broker_host"] == "new"
|
|
assert updated["enabled"] is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete(self, fanout_db):
|
|
from app.repository.fanout import FanoutConfigRepository
|
|
|
|
cfg = await FanoutConfigRepository.create(
|
|
config_type="mqtt_private", name="Doomed", config={}, scope={}, enabled=True
|
|
)
|
|
|
|
await FanoutConfigRepository.delete(cfg["id"])
|
|
|
|
assert await FanoutConfigRepository.get(cfg["id"]) is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_enabled(self, fanout_db):
|
|
from app.repository.fanout import FanoutConfigRepository
|
|
|
|
await FanoutConfigRepository.create(
|
|
config_type="mqtt_private", name="On", config={}, scope={}, enabled=True
|
|
)
|
|
await FanoutConfigRepository.create(
|
|
config_type="mqtt_community", name="Off", config={}, scope={}, enabled=False
|
|
)
|
|
|
|
enabled = await FanoutConfigRepository.get_enabled()
|
|
assert len(enabled) == 1
|
|
assert enabled[0]["name"] == "On"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# broadcast_event realtime=False test
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBroadcastEventRealtime:
|
|
@pytest.mark.asyncio
|
|
async def test_realtime_false_does_not_dispatch_fanout(self):
|
|
"""broadcast_event with realtime=False should NOT trigger fanout dispatch."""
|
|
from app.websocket import broadcast_event
|
|
|
|
with (
|
|
patch("app.websocket.ws_manager") as mock_ws,
|
|
patch("app.fanout.manager.fanout_manager") as mock_fm,
|
|
):
|
|
mock_ws.broadcast = AsyncMock()
|
|
|
|
broadcast_event("message", {"type": "PRIV"}, realtime=False)
|
|
|
|
# Allow tasks to run
|
|
import asyncio
|
|
|
|
await asyncio.sleep(0)
|
|
|
|
# WebSocket broadcast should still fire
|
|
mock_ws.broadcast.assert_called_once()
|
|
# But fanout should NOT be called
|
|
mock_fm.broadcast_message.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_realtime_true_dispatches_fanout(self):
|
|
"""broadcast_event with realtime=True should trigger fanout dispatch."""
|
|
from app.websocket import broadcast_event
|
|
|
|
with (
|
|
patch("app.websocket.ws_manager") as mock_ws,
|
|
patch("app.fanout.manager.fanout_manager") as mock_fm,
|
|
):
|
|
mock_ws.broadcast = AsyncMock()
|
|
mock_fm.broadcast_message = AsyncMock()
|
|
|
|
broadcast_event("message", {"type": "PRIV"}, realtime=True)
|
|
|
|
import asyncio
|
|
|
|
await asyncio.sleep(0)
|
|
|
|
mock_ws.broadcast.assert_called_once()
|
|
mock_fm.broadcast_message.assert_called_once()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Migration test
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _create_app_settings_table_sql():
|
|
"""SQL to create app_settings with all MQTT columns for migration testing."""
|
|
return """
|
|
CREATE TABLE IF NOT EXISTS app_settings (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
max_radio_contacts INTEGER DEFAULT 200,
|
|
favorites TEXT DEFAULT '[]',
|
|
auto_decrypt_dm_on_advert INTEGER DEFAULT 0,
|
|
sidebar_sort_order TEXT DEFAULT 'recent',
|
|
last_message_times TEXT DEFAULT '{}',
|
|
preferences_migrated INTEGER DEFAULT 0,
|
|
advert_interval INTEGER DEFAULT 0,
|
|
last_advert_time INTEGER DEFAULT 0,
|
|
bots TEXT DEFAULT '[]',
|
|
mqtt_broker_host TEXT DEFAULT '',
|
|
mqtt_broker_port INTEGER DEFAULT 1883,
|
|
mqtt_username TEXT DEFAULT '',
|
|
mqtt_password TEXT DEFAULT '',
|
|
mqtt_use_tls INTEGER DEFAULT 0,
|
|
mqtt_tls_insecure INTEGER DEFAULT 0,
|
|
mqtt_topic_prefix TEXT DEFAULT 'meshcore',
|
|
mqtt_publish_messages INTEGER DEFAULT 0,
|
|
mqtt_publish_raw_packets INTEGER DEFAULT 0,
|
|
community_mqtt_enabled INTEGER DEFAULT 0,
|
|
community_mqtt_iata TEXT DEFAULT '',
|
|
community_mqtt_broker_host TEXT DEFAULT 'mqtt-us-v1.letsmesh.net',
|
|
community_mqtt_broker_port INTEGER DEFAULT 443,
|
|
community_mqtt_email TEXT DEFAULT '',
|
|
flood_scope TEXT DEFAULT '',
|
|
blocked_keys TEXT DEFAULT '[]',
|
|
blocked_names TEXT DEFAULT '[]'
|
|
)
|
|
"""
|
|
|
|
|
|
class TestMigration036:
|
|
@pytest.mark.asyncio
|
|
async def test_fanout_configs_table_created(self):
|
|
"""Migration 36 should create the fanout_configs table."""
|
|
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()
|
|
|
|
try:
|
|
await _migrate_036_create_fanout_configs(db.conn)
|
|
|
|
cursor = await db.conn.execute(
|
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='fanout_configs'"
|
|
)
|
|
row = await cursor.fetchone()
|
|
assert row is not None
|
|
finally:
|
|
await db.disconnect()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_migration_creates_mqtt_private_from_settings(self):
|
|
"""Migration should create mqtt_private config from existing MQTT settings."""
|
|
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 REPLACE INTO app_settings (id, mqtt_broker_host, mqtt_broker_port,
|
|
mqtt_username, mqtt_password, mqtt_use_tls, mqtt_tls_insecure,
|
|
mqtt_topic_prefix, mqtt_publish_messages, mqtt_publish_raw_packets)
|
|
VALUES (1, 'broker.local', 1883, 'user', 'pass', 0, 0, 'mesh', 1, 0)"""
|
|
)
|
|
await db.conn.commit()
|
|
|
|
try:
|
|
await _migrate_036_create_fanout_configs(db.conn)
|
|
|
|
cursor = await db.conn.execute(
|
|
"SELECT * FROM fanout_configs WHERE type = 'mqtt_private'"
|
|
)
|
|
row = await cursor.fetchone()
|
|
assert row is not None
|
|
|
|
config = json.loads(row["config"])
|
|
assert config["broker_host"] == "broker.local"
|
|
assert config["username"] == "user"
|
|
|
|
scope = json.loads(row["scope"])
|
|
assert scope["messages"] == "all"
|
|
assert scope["raw_packets"] == "none"
|
|
finally:
|
|
await db.disconnect()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_migration_creates_community_from_settings(self):
|
|
"""Migration should create mqtt_community config when community was enabled."""
|
|
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 REPLACE INTO app_settings (id, community_mqtt_enabled, community_mqtt_iata,
|
|
community_mqtt_broker_host, community_mqtt_broker_port, community_mqtt_email)
|
|
VALUES (1, 1, 'DEN', 'mqtt-us-v1.letsmesh.net', 443, 'test@example.com')"""
|
|
)
|
|
await db.conn.commit()
|
|
|
|
try:
|
|
await _migrate_036_create_fanout_configs(db.conn)
|
|
|
|
cursor = await db.conn.execute(
|
|
"SELECT * FROM fanout_configs WHERE type = 'mqtt_community'"
|
|
)
|
|
row = await cursor.fetchone()
|
|
assert row is not None
|
|
assert bool(row["enabled"])
|
|
|
|
config = json.loads(row["config"])
|
|
assert config["iata"] == "DEN"
|
|
assert config["email"] == "test@example.com"
|
|
finally:
|
|
await db.disconnect()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_migration_skips_when_no_mqtt_configured(self):
|
|
"""Migration should not create rows when MQTT was not configured."""
|
|
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()
|
|
|
|
try:
|
|
await _migrate_036_create_fanout_configs(db.conn)
|
|
|
|
cursor = await db.conn.execute("SELECT COUNT(*) FROM fanout_configs")
|
|
row = await cursor.fetchone()
|
|
assert row[0] == 0
|
|
finally:
|
|
await db.disconnect()
|