mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-06 16:53:38 +02:00
Move to modular fanout bus
This commit is contained in:
+1
-1
@@ -68,7 +68,7 @@ def captured_broadcasts():
|
||||
"""Capture WebSocket broadcasts for verification."""
|
||||
broadcasts = []
|
||||
|
||||
def mock_broadcast(event_type: str, data: dict):
|
||||
def mock_broadcast(event_type: str, data: dict, **kwargs):
|
||||
broadcasts.append({"type": event_type, "data": data})
|
||||
|
||||
return broadcasts, mock_broadcast
|
||||
|
||||
@@ -21,7 +21,6 @@ from app.community_mqtt import (
|
||||
_format_raw_packet,
|
||||
_generate_jwt_token,
|
||||
_get_client_version,
|
||||
community_mqtt_broadcast,
|
||||
)
|
||||
from app.models import AppSettings
|
||||
|
||||
@@ -394,39 +393,6 @@ class TestCommunityMqttPublisher:
|
||||
assert pub._is_configured() is True
|
||||
|
||||
|
||||
class TestCommunityMqttBroadcast:
|
||||
def test_filters_non_raw_packet(self):
|
||||
"""Non-raw_packet events should be ignored."""
|
||||
with patch("app.community_mqtt.community_publisher") as mock_pub:
|
||||
mock_pub.connected = True
|
||||
mock_pub._settings = AppSettings(community_mqtt_enabled=True)
|
||||
community_mqtt_broadcast("message", {"text": "hello"})
|
||||
# No asyncio.create_task should be called for non-raw_packet events
|
||||
# Since we're filtering, we just verify no exception
|
||||
|
||||
def test_skips_when_disconnected(self):
|
||||
"""Should not publish when disconnected."""
|
||||
with (
|
||||
patch("app.community_mqtt.community_publisher") as mock_pub,
|
||||
patch("app.community_mqtt.asyncio.create_task") as mock_task,
|
||||
):
|
||||
mock_pub.connected = False
|
||||
mock_pub._settings = AppSettings(community_mqtt_enabled=True)
|
||||
community_mqtt_broadcast("raw_packet", {"data": "00"})
|
||||
mock_task.assert_not_called()
|
||||
|
||||
def test_skips_when_settings_none(self):
|
||||
"""Should not publish when settings are None."""
|
||||
with (
|
||||
patch("app.community_mqtt.community_publisher") as mock_pub,
|
||||
patch("app.community_mqtt.asyncio.create_task") as mock_task,
|
||||
):
|
||||
mock_pub.connected = True
|
||||
mock_pub._settings = None
|
||||
community_mqtt_broadcast("raw_packet", {"data": "00"})
|
||||
mock_task.assert_not_called()
|
||||
|
||||
|
||||
class TestPublishFailureSetsDisconnected:
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_error_sets_connected_false(self):
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,403 @@
|
||||
"""Integration tests: real MQTT capture broker + real fanout modules.
|
||||
|
||||
Spins up a minimal in-process MQTT 3.1.1 broker on a random port, creates
|
||||
fanout configs in an in-memory DB, starts real MqttPrivateModule instances
|
||||
via the FanoutManager, and verifies that PUBLISH packets arrive (or don't)
|
||||
based on enabled/disabled state and scope settings.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import struct
|
||||
|
||||
import pytest
|
||||
|
||||
import app.repository.fanout as fanout_mod
|
||||
from app.database import Database
|
||||
from app.fanout.manager import FanoutManager
|
||||
from app.repository.fanout import FanoutConfigRepository
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Minimal async MQTT 3.1.1 capture broker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MqttCaptureBroker:
|
||||
"""Tiny TCP server that speaks just enough MQTT to capture PUBLISH packets."""
|
||||
|
||||
def __init__(self):
|
||||
self.published: list[tuple[str, dict]] = []
|
||||
self._server: asyncio.Server | None = None
|
||||
self.port: int = 0
|
||||
|
||||
async def start(self) -> int:
|
||||
self._server = await asyncio.start_server(self._handle_client, "127.0.0.1", 0)
|
||||
self.port = self._server.sockets[0].getsockname()[1]
|
||||
return self.port
|
||||
|
||||
async def stop(self):
|
||||
if self._server:
|
||||
self._server.close()
|
||||
await self._server.wait_closed()
|
||||
|
||||
async def wait_for(self, count: int, timeout: float = 5.0) -> list[tuple[str, dict]]:
|
||||
"""Block until *count* messages captured, or timeout."""
|
||||
deadline = asyncio.get_event_loop().time() + timeout
|
||||
while len(self.published) < count:
|
||||
if asyncio.get_event_loop().time() >= deadline:
|
||||
break
|
||||
await asyncio.sleep(0.02)
|
||||
return list(self.published)
|
||||
|
||||
async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
||||
try:
|
||||
while True:
|
||||
first = await reader.readexactly(1)
|
||||
pkt_type = (first[0] & 0xF0) >> 4
|
||||
rem_len = await self._read_varlen(reader)
|
||||
payload = await reader.readexactly(rem_len) if rem_len else b""
|
||||
|
||||
if pkt_type == 1: # CONNECT -> CONNACK
|
||||
writer.write(b"\x20\x02\x00\x00")
|
||||
await writer.drain()
|
||||
elif pkt_type == 3: # PUBLISH (QoS 0)
|
||||
topic_len = struct.unpack("!H", payload[:2])[0]
|
||||
topic = payload[2 : 2 + topic_len].decode()
|
||||
body = payload[2 + topic_len :]
|
||||
try:
|
||||
data = json.loads(body)
|
||||
except Exception:
|
||||
data = {}
|
||||
self.published.append((topic, data))
|
||||
elif pkt_type == 12: # PINGREQ -> PINGRESP
|
||||
writer.write(b"\xd0\x00")
|
||||
await writer.drain()
|
||||
elif pkt_type == 14: # DISCONNECT
|
||||
break
|
||||
except (asyncio.IncompleteReadError, ConnectionError, OSError):
|
||||
pass
|
||||
finally:
|
||||
writer.close()
|
||||
|
||||
@staticmethod
|
||||
async def _read_varlen(reader: asyncio.StreamReader) -> int:
|
||||
value, shift = 0, 0
|
||||
while True:
|
||||
b = (await reader.readexactly(1))[0]
|
||||
value |= (b & 0x7F) << shift
|
||||
if not (b & 0x80):
|
||||
return value
|
||||
shift += 7
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mqtt_broker():
|
||||
broker = MqttCaptureBroker()
|
||||
await broker.start()
|
||||
yield broker
|
||||
await broker.stop()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def integration_db():
|
||||
"""In-memory DB with fanout_configs, wired into the repository module.
|
||||
|
||||
Database.connect() runs all migrations which create the fanout_configs
|
||||
table, so no manual DDL is needed here.
|
||||
"""
|
||||
test_db = Database(":memory:")
|
||||
await test_db.connect()
|
||||
|
||||
original_db = fanout_mod.db
|
||||
fanout_mod.db = test_db
|
||||
try:
|
||||
yield test_db
|
||||
finally:
|
||||
fanout_mod.db = original_db
|
||||
await test_db.disconnect()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _wait_connected(manager: FanoutManager, config_id: str, timeout: float = 5.0):
|
||||
"""Poll until the module reports 'connected'."""
|
||||
deadline = asyncio.get_event_loop().time() + timeout
|
||||
while asyncio.get_event_loop().time() < deadline:
|
||||
entry = manager._modules.get(config_id)
|
||||
if entry and entry[0].status == "connected":
|
||||
return
|
||||
await asyncio.sleep(0.05)
|
||||
raise TimeoutError(f"Module {config_id} did not connect within {timeout}s")
|
||||
|
||||
|
||||
def _private_config(port: int, prefix: str) -> dict:
|
||||
return {"broker_host": "127.0.0.1", "broker_port": port, "topic_prefix": prefix}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFanoutMqttIntegration:
|
||||
"""End-to-end: real capture broker <-> real fanout modules."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_both_enabled_both_receive(self, mqtt_broker, integration_db):
|
||||
"""Two enabled integrations with different prefixes both receive messages."""
|
||||
from unittest.mock import patch
|
||||
|
||||
cfg_a = await FanoutConfigRepository.create(
|
||||
config_type="mqtt_private",
|
||||
name="Alpha",
|
||||
config=_private_config(mqtt_broker.port, "alpha"),
|
||||
scope={"messages": "all", "raw_packets": "all"},
|
||||
enabled=True,
|
||||
)
|
||||
cfg_b = await FanoutConfigRepository.create(
|
||||
config_type="mqtt_private",
|
||||
name="Beta",
|
||||
config=_private_config(mqtt_broker.port, "beta"),
|
||||
scope={"messages": "all", "raw_packets": "all"},
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
manager = FanoutManager()
|
||||
with (
|
||||
patch("app.mqtt_base._broadcast_health"),
|
||||
patch("app.websocket.broadcast_success"),
|
||||
patch("app.websocket.broadcast_error"),
|
||||
patch("app.websocket.broadcast_health"),
|
||||
):
|
||||
try:
|
||||
await manager.load_from_db()
|
||||
await _wait_connected(manager, cfg_a["id"])
|
||||
await _wait_connected(manager, cfg_b["id"])
|
||||
|
||||
await manager.broadcast_message(
|
||||
{"type": "PRIV", "conversation_key": "pk1", "text": "hello"}
|
||||
)
|
||||
|
||||
messages = await mqtt_broker.wait_for(2)
|
||||
finally:
|
||||
await manager.stop_all()
|
||||
|
||||
topics = {m[0] for m in messages}
|
||||
assert "alpha/dm:pk1" in topics
|
||||
assert "beta/dm:pk1" in topics
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_one_disabled_only_enabled_receives(self, mqtt_broker, integration_db):
|
||||
"""Disabled integration must not publish any messages."""
|
||||
from unittest.mock import patch
|
||||
|
||||
cfg_on = await FanoutConfigRepository.create(
|
||||
config_type="mqtt_private",
|
||||
name="Enabled",
|
||||
config=_private_config(mqtt_broker.port, "on"),
|
||||
scope={"messages": "all", "raw_packets": "all"},
|
||||
enabled=True,
|
||||
)
|
||||
await FanoutConfigRepository.create(
|
||||
config_type="mqtt_private",
|
||||
name="Disabled",
|
||||
config=_private_config(mqtt_broker.port, "off"),
|
||||
scope={"messages": "all", "raw_packets": "all"},
|
||||
enabled=False,
|
||||
)
|
||||
|
||||
manager = FanoutManager()
|
||||
with (
|
||||
patch("app.mqtt_base._broadcast_health"),
|
||||
patch("app.websocket.broadcast_success"),
|
||||
patch("app.websocket.broadcast_error"),
|
||||
patch("app.websocket.broadcast_health"),
|
||||
):
|
||||
try:
|
||||
await manager.load_from_db()
|
||||
await _wait_connected(manager, cfg_on["id"])
|
||||
|
||||
# Only 1 module should be loaded
|
||||
assert len(manager._modules) == 1
|
||||
|
||||
await manager.broadcast_message(
|
||||
{"type": "PRIV", "conversation_key": "pk1", "text": "hello"}
|
||||
)
|
||||
|
||||
await mqtt_broker.wait_for(1)
|
||||
await asyncio.sleep(0.2) # extra time to catch stray messages
|
||||
finally:
|
||||
await manager.stop_all()
|
||||
|
||||
assert len(mqtt_broker.published) == 1
|
||||
assert mqtt_broker.published[0][0] == "on/dm:pk1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_both_disabled_nothing_published(self, mqtt_broker, integration_db):
|
||||
"""Both disabled -> zero messages published."""
|
||||
from unittest.mock import patch
|
||||
|
||||
await FanoutConfigRepository.create(
|
||||
config_type="mqtt_private",
|
||||
name="A",
|
||||
config=_private_config(mqtt_broker.port, "a"),
|
||||
scope={"messages": "all", "raw_packets": "all"},
|
||||
enabled=False,
|
||||
)
|
||||
await FanoutConfigRepository.create(
|
||||
config_type="mqtt_private",
|
||||
name="B",
|
||||
config=_private_config(mqtt_broker.port, "b"),
|
||||
scope={"messages": "all", "raw_packets": "all"},
|
||||
enabled=False,
|
||||
)
|
||||
|
||||
manager = FanoutManager()
|
||||
with (
|
||||
patch("app.mqtt_base._broadcast_health"),
|
||||
patch("app.websocket.broadcast_success"),
|
||||
patch("app.websocket.broadcast_error"),
|
||||
patch("app.websocket.broadcast_health"),
|
||||
):
|
||||
try:
|
||||
await manager.load_from_db()
|
||||
assert len(manager._modules) == 0
|
||||
|
||||
await manager.broadcast_message(
|
||||
{"type": "PRIV", "conversation_key": "pk1", "text": "hello"}
|
||||
)
|
||||
await asyncio.sleep(0.3)
|
||||
finally:
|
||||
await manager.stop_all()
|
||||
|
||||
assert len(mqtt_broker.published) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disable_after_enable_stops_publishing(self, mqtt_broker, integration_db):
|
||||
"""Disabling a live integration stops its publishing immediately."""
|
||||
from unittest.mock import patch
|
||||
|
||||
cfg = await FanoutConfigRepository.create(
|
||||
config_type="mqtt_private",
|
||||
name="Toggle",
|
||||
config=_private_config(mqtt_broker.port, "toggle"),
|
||||
scope={"messages": "all", "raw_packets": "all"},
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
manager = FanoutManager()
|
||||
with (
|
||||
patch("app.mqtt_base._broadcast_health"),
|
||||
patch("app.websocket.broadcast_success"),
|
||||
patch("app.websocket.broadcast_error"),
|
||||
patch("app.websocket.broadcast_health"),
|
||||
):
|
||||
try:
|
||||
await manager.load_from_db()
|
||||
await _wait_connected(manager, cfg["id"])
|
||||
|
||||
# Publishes while enabled
|
||||
await manager.broadcast_message(
|
||||
{"type": "PRIV", "conversation_key": "pk1", "text": "msg1"}
|
||||
)
|
||||
await mqtt_broker.wait_for(1)
|
||||
assert len(mqtt_broker.published) == 1
|
||||
|
||||
# Disable via DB + reload
|
||||
await FanoutConfigRepository.update(cfg["id"], enabled=False)
|
||||
await manager.reload_config(cfg["id"])
|
||||
assert cfg["id"] not in manager._modules
|
||||
|
||||
# Should NOT publish after disable
|
||||
await manager.broadcast_message(
|
||||
{"type": "PRIV", "conversation_key": "pk2", "text": "msg2"}
|
||||
)
|
||||
await asyncio.sleep(0.3)
|
||||
finally:
|
||||
await manager.stop_all()
|
||||
|
||||
# Only the first message
|
||||
assert len(mqtt_broker.published) == 1
|
||||
assert mqtt_broker.published[0][0] == "toggle/dm:pk1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scope_messages_only_no_raw(self, mqtt_broker, integration_db):
|
||||
"""Module with raw_packets=none receives messages but not raw packets."""
|
||||
from unittest.mock import patch
|
||||
|
||||
cfg = await FanoutConfigRepository.create(
|
||||
config_type="mqtt_private",
|
||||
name="Messages Only",
|
||||
config=_private_config(mqtt_broker.port, "msgsonly"),
|
||||
scope={"messages": "all", "raw_packets": "none"},
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
manager = FanoutManager()
|
||||
with (
|
||||
patch("app.mqtt_base._broadcast_health"),
|
||||
patch("app.websocket.broadcast_success"),
|
||||
patch("app.websocket.broadcast_error"),
|
||||
patch("app.websocket.broadcast_health"),
|
||||
):
|
||||
try:
|
||||
await manager.load_from_db()
|
||||
await _wait_connected(manager, cfg["id"])
|
||||
|
||||
await manager.broadcast_message(
|
||||
{"type": "PRIV", "conversation_key": "pk1", "text": "hi"}
|
||||
)
|
||||
await manager.broadcast_raw({"data": "aabbccdd"})
|
||||
|
||||
await mqtt_broker.wait_for(1)
|
||||
await asyncio.sleep(0.2)
|
||||
finally:
|
||||
await manager.stop_all()
|
||||
|
||||
assert len(mqtt_broker.published) == 1
|
||||
assert "dm:pk1" in mqtt_broker.published[0][0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scope_raw_only_no_messages(self, mqtt_broker, integration_db):
|
||||
"""Module with messages=none receives raw packets but not decoded messages."""
|
||||
from unittest.mock import patch
|
||||
|
||||
cfg = await FanoutConfigRepository.create(
|
||||
config_type="mqtt_private",
|
||||
name="Raw Only",
|
||||
config=_private_config(mqtt_broker.port, "rawonly"),
|
||||
scope={"messages": "none", "raw_packets": "all"},
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
manager = FanoutManager()
|
||||
with (
|
||||
patch("app.mqtt_base._broadcast_health"),
|
||||
patch("app.websocket.broadcast_success"),
|
||||
patch("app.websocket.broadcast_error"),
|
||||
patch("app.websocket.broadcast_health"),
|
||||
):
|
||||
try:
|
||||
await manager.load_from_db()
|
||||
await _wait_connected(manager, cfg["id"])
|
||||
|
||||
await manager.broadcast_message(
|
||||
{"type": "PRIV", "conversation_key": "pk1", "text": "hi"}
|
||||
)
|
||||
await manager.broadcast_raw({"data": "aabbccdd"})
|
||||
|
||||
await mqtt_broker.wait_for(1)
|
||||
await asyncio.sleep(0.2)
|
||||
finally:
|
||||
await manager.stop_all()
|
||||
|
||||
assert len(mqtt_broker.published) == 1
|
||||
assert "raw/" in mqtt_broker.published[0][0]
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Tests for health endpoint MQTT status field.
|
||||
"""Tests for health endpoint fanout status fields.
|
||||
|
||||
Verifies that build_health_data correctly reports MQTT status as
|
||||
'connected', 'disconnected', or 'disabled' based on publisher state.
|
||||
Verifies that build_health_data correctly reports fanout module statuses
|
||||
via the fanout_manager.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
@@ -11,96 +11,34 @@ import pytest
|
||||
from app.routers.health import build_health_data
|
||||
|
||||
|
||||
class TestHealthMqttStatus:
|
||||
"""Test MQTT status in build_health_data."""
|
||||
class TestHealthFanoutStatus:
|
||||
"""Test fanout_statuses in build_health_data."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mqtt_disabled_when_not_configured(self, test_db):
|
||||
"""MQTT status is 'disabled' when broker host is empty."""
|
||||
from app.mqtt import mqtt_publisher
|
||||
|
||||
original_settings = mqtt_publisher._settings
|
||||
original_connected = mqtt_publisher.connected
|
||||
try:
|
||||
from app.models import AppSettings
|
||||
|
||||
mqtt_publisher._settings = AppSettings(mqtt_broker_host="")
|
||||
mqtt_publisher.connected = False
|
||||
|
||||
async def test_no_fanout_modules_returns_empty(self, test_db):
|
||||
"""fanout_statuses should be empty dict when no modules are running."""
|
||||
with patch("app.fanout.manager.fanout_manager") as mock_fm:
|
||||
mock_fm.get_statuses.return_value = {}
|
||||
data = await build_health_data(True, "TCP: 1.2.3.4:4000")
|
||||
|
||||
assert data["mqtt_status"] == "disabled"
|
||||
finally:
|
||||
mqtt_publisher._settings = original_settings
|
||||
mqtt_publisher.connected = original_connected
|
||||
assert data["fanout_statuses"] == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mqtt_disabled_when_nothing_to_publish(self, test_db):
|
||||
"""MQTT status is 'disabled' when broker host is set but no publish options enabled."""
|
||||
from app.mqtt import mqtt_publisher
|
||||
async def test_fanout_statuses_reflect_manager(self, test_db):
|
||||
"""fanout_statuses should return whatever the manager reports."""
|
||||
mock_statuses = {
|
||||
"uuid-1": {"name": "Private MQTT", "type": "mqtt_private", "status": "connected"},
|
||||
"uuid-2": {
|
||||
"name": "Community MQTT",
|
||||
"type": "mqtt_community",
|
||||
"status": "disconnected",
|
||||
},
|
||||
}
|
||||
with patch("app.fanout.manager.fanout_manager") as mock_fm:
|
||||
mock_fm.get_statuses.return_value = mock_statuses
|
||||
data = await build_health_data(True, "Serial: /dev/ttyUSB0")
|
||||
|
||||
original_settings = mqtt_publisher._settings
|
||||
original_connected = mqtt_publisher.connected
|
||||
try:
|
||||
from app.models import AppSettings
|
||||
|
||||
mqtt_publisher._settings = AppSettings(
|
||||
mqtt_broker_host="broker.local",
|
||||
mqtt_publish_messages=False,
|
||||
mqtt_publish_raw_packets=False,
|
||||
)
|
||||
mqtt_publisher.connected = False
|
||||
|
||||
data = await build_health_data(True, "TCP: 1.2.3.4:4000")
|
||||
|
||||
assert data["mqtt_status"] == "disabled"
|
||||
finally:
|
||||
mqtt_publisher._settings = original_settings
|
||||
mqtt_publisher.connected = original_connected
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mqtt_connected_when_publisher_connected(self, test_db):
|
||||
"""MQTT status is 'connected' when publisher is connected."""
|
||||
from app.mqtt import mqtt_publisher
|
||||
|
||||
original_settings = mqtt_publisher._settings
|
||||
original_connected = mqtt_publisher.connected
|
||||
try:
|
||||
from app.models import AppSettings
|
||||
|
||||
mqtt_publisher._settings = AppSettings(
|
||||
mqtt_broker_host="broker.local", mqtt_publish_messages=True
|
||||
)
|
||||
mqtt_publisher.connected = True
|
||||
|
||||
data = await build_health_data(True, "TCP: 1.2.3.4:4000")
|
||||
|
||||
assert data["mqtt_status"] == "connected"
|
||||
finally:
|
||||
mqtt_publisher._settings = original_settings
|
||||
mqtt_publisher.connected = original_connected
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mqtt_disconnected_when_configured_but_not_connected(self, test_db):
|
||||
"""MQTT status is 'disconnected' when configured but not connected."""
|
||||
from app.mqtt import mqtt_publisher
|
||||
|
||||
original_settings = mqtt_publisher._settings
|
||||
original_connected = mqtt_publisher.connected
|
||||
try:
|
||||
from app.models import AppSettings
|
||||
|
||||
mqtt_publisher._settings = AppSettings(
|
||||
mqtt_broker_host="broker.local", mqtt_publish_raw_packets=True
|
||||
)
|
||||
mqtt_publisher.connected = False
|
||||
|
||||
data = await build_health_data(False, None)
|
||||
|
||||
assert data["mqtt_status"] == "disconnected"
|
||||
finally:
|
||||
mqtt_publisher._settings = original_settings
|
||||
mqtt_publisher.connected = original_connected
|
||||
assert data["fanout_statuses"] == mock_statuses
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_status_ok_when_connected(self, test_db):
|
||||
|
||||
+26
-26
@@ -100,8 +100,8 @@ class TestMigration001:
|
||||
# Run migrations
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 35 # All migrations run
|
||||
assert await get_version(conn) == 35
|
||||
assert applied == 36 # All migrations run
|
||||
assert await get_version(conn) == 36
|
||||
|
||||
# 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 == 35 # All migrations run
|
||||
assert applied1 == 36 # All migrations run
|
||||
assert applied2 == 0 # No migrations on second run
|
||||
assert await get_version(conn) == 35
|
||||
assert await get_version(conn) == 36
|
||||
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 == 35
|
||||
assert await get_version(conn) == 35
|
||||
assert applied == 36
|
||||
assert await get_version(conn) == 36
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@@ -374,10 +374,10 @@ class TestMigration013:
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
# Run migration 13 (plus 14-34 which also run)
|
||||
# Run migration 13 (plus 14-36 which also run)
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 23
|
||||
assert await get_version(conn) == 35
|
||||
assert applied == 24
|
||||
assert await get_version(conn) == 36
|
||||
|
||||
# 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) == 35
|
||||
assert await get_version(conn) == 36
|
||||
|
||||
# Verify autoindex is gone
|
||||
cursor = await conn.execute(
|
||||
@@ -575,8 +575,8 @@ class TestMigration018:
|
||||
await conn.commit()
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 18 # Migrations 18-35 run (18+19 skip internally)
|
||||
assert await get_version(conn) == 35
|
||||
assert applied == 19 # Migrations 18-36 run (18+19 skip internally)
|
||||
assert await get_version(conn) == 36
|
||||
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) == 35
|
||||
assert await get_version(conn) == 36
|
||||
|
||||
# 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 == 16 # Migrations 20-35
|
||||
assert await get_version(conn) == 35
|
||||
assert applied == 17 # Migrations 20-36
|
||||
assert await get_version(conn) == 36
|
||||
|
||||
# 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 == 15 # Migrations 21-35 still run
|
||||
assert applied == 16 # Migrations 21-36 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 == 8
|
||||
assert await get_version(conn) == 35
|
||||
assert applied == 9
|
||||
assert await get_version(conn) == 36
|
||||
|
||||
# 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 == 8 # Version still bumped
|
||||
assert await get_version(conn) == 35
|
||||
assert applied == 9 # Version still bumped
|
||||
assert await get_version(conn) == 36
|
||||
|
||||
# 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 == 4
|
||||
assert await get_version(conn) == 35
|
||||
assert applied == 5
|
||||
assert await get_version(conn) == 36
|
||||
|
||||
# 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 == 2
|
||||
assert await get_version(conn) == 35
|
||||
assert applied == 3
|
||||
assert await get_version(conn) == 36
|
||||
|
||||
# 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 == 3
|
||||
assert await get_version(conn) == 35
|
||||
assert applied == 4
|
||||
assert await get_version(conn) == 36
|
||||
|
||||
cursor = await conn.execute(
|
||||
"SELECT key, name, is_hashtag, on_radio FROM channels WHERE key = ?",
|
||||
|
||||
+1
-113
@@ -6,11 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from app.models import AppSettings
|
||||
from app.mqtt import (
|
||||
MqttPublisher,
|
||||
_build_message_topic,
|
||||
_build_raw_packet_topic,
|
||||
)
|
||||
from app.mqtt import MqttPublisher, _build_message_topic, _build_raw_packet_topic
|
||||
|
||||
|
||||
def _make_settings(**overrides) -> AppSettings:
|
||||
@@ -162,114 +158,6 @@ class TestMqttPublisher:
|
||||
assert pub._client is None
|
||||
|
||||
|
||||
class TestMqttBroadcast:
|
||||
@pytest.mark.asyncio
|
||||
async def test_mqtt_broadcast_skips_when_disconnected(self):
|
||||
"""mqtt_broadcast should return immediately if publisher is disconnected."""
|
||||
from app.mqtt import mqtt_publisher
|
||||
|
||||
original_settings = mqtt_publisher._settings
|
||||
original_connected = mqtt_publisher.connected
|
||||
|
||||
try:
|
||||
mqtt_publisher.connected = False
|
||||
mqtt_publisher._settings = _make_settings()
|
||||
|
||||
# This should not create any tasks or fail
|
||||
from app.mqtt import mqtt_broadcast
|
||||
|
||||
mqtt_broadcast("message", {"type": "PRIV", "conversation_key": "abc"})
|
||||
finally:
|
||||
mqtt_publisher._settings = original_settings
|
||||
mqtt_publisher.connected = original_connected
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mqtt_maybe_publish_message(self):
|
||||
"""_mqtt_maybe_publish should call publish for message events."""
|
||||
from app.mqtt import _mqtt_maybe_publish, mqtt_publisher
|
||||
|
||||
original_settings = mqtt_publisher._settings
|
||||
original_connected = mqtt_publisher.connected
|
||||
|
||||
try:
|
||||
mqtt_publisher._settings = _make_settings(mqtt_publish_messages=True)
|
||||
mqtt_publisher.connected = True
|
||||
|
||||
with patch.object(mqtt_publisher, "publish", new_callable=AsyncMock) as mock_pub:
|
||||
await _mqtt_maybe_publish("message", {"type": "PRIV", "conversation_key": "abc123"})
|
||||
mock_pub.assert_called_once()
|
||||
topic = mock_pub.call_args[0][0]
|
||||
assert topic == "meshcore/dm:abc123"
|
||||
finally:
|
||||
mqtt_publisher._settings = original_settings
|
||||
mqtt_publisher.connected = original_connected
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mqtt_maybe_publish_raw_packet(self):
|
||||
"""_mqtt_maybe_publish should call publish for raw_packet events."""
|
||||
from app.mqtt import _mqtt_maybe_publish, mqtt_publisher
|
||||
|
||||
original_settings = mqtt_publisher._settings
|
||||
original_connected = mqtt_publisher.connected
|
||||
|
||||
try:
|
||||
mqtt_publisher._settings = _make_settings(mqtt_publish_raw_packets=True)
|
||||
mqtt_publisher.connected = True
|
||||
|
||||
with patch.object(mqtt_publisher, "publish", new_callable=AsyncMock) as mock_pub:
|
||||
await _mqtt_maybe_publish(
|
||||
"raw_packet",
|
||||
{"decrypted_info": {"channel_key": "ch1", "contact_key": None}},
|
||||
)
|
||||
mock_pub.assert_called_once()
|
||||
topic = mock_pub.call_args[0][0]
|
||||
assert topic == "meshcore/raw/gm:ch1"
|
||||
finally:
|
||||
mqtt_publisher._settings = original_settings
|
||||
mqtt_publisher.connected = original_connected
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mqtt_maybe_publish_skips_disabled_messages(self):
|
||||
"""_mqtt_maybe_publish should skip messages when publish_messages is False."""
|
||||
from app.mqtt import _mqtt_maybe_publish, mqtt_publisher
|
||||
|
||||
original_settings = mqtt_publisher._settings
|
||||
original_connected = mqtt_publisher.connected
|
||||
|
||||
try:
|
||||
mqtt_publisher._settings = _make_settings(mqtt_publish_messages=False)
|
||||
mqtt_publisher.connected = True
|
||||
|
||||
with patch.object(mqtt_publisher, "publish", new_callable=AsyncMock) as mock_pub:
|
||||
await _mqtt_maybe_publish("message", {"type": "PRIV", "conversation_key": "abc"})
|
||||
mock_pub.assert_not_called()
|
||||
finally:
|
||||
mqtt_publisher._settings = original_settings
|
||||
mqtt_publisher.connected = original_connected
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mqtt_maybe_publish_skips_disabled_raw_packets(self):
|
||||
"""_mqtt_maybe_publish should skip raw_packets when publish_raw_packets is False."""
|
||||
from app.mqtt import _mqtt_maybe_publish, mqtt_publisher
|
||||
|
||||
original_settings = mqtt_publisher._settings
|
||||
original_connected = mqtt_publisher.connected
|
||||
|
||||
try:
|
||||
mqtt_publisher._settings = _make_settings(mqtt_publish_raw_packets=False)
|
||||
mqtt_publisher.connected = True
|
||||
|
||||
with patch.object(mqtt_publisher, "publish", new_callable=AsyncMock) as mock_pub:
|
||||
await _mqtt_maybe_publish(
|
||||
"raw_packet",
|
||||
{"decrypted_info": None},
|
||||
)
|
||||
mock_pub.assert_not_called()
|
||||
finally:
|
||||
mqtt_publisher._settings = original_settings
|
||||
mqtt_publisher.connected = original_connected
|
||||
|
||||
|
||||
class TestBuildTlsContext:
|
||||
def test_returns_none_when_tls_disabled(self):
|
||||
settings = _make_settings(mqtt_use_tls=False)
|
||||
|
||||
@@ -68,130 +68,6 @@ class TestUpdateSettings:
|
||||
assert exc.value.status_code == 400
|
||||
assert "syntax error" in exc.value.detail.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mqtt_fields_round_trip(self, test_db):
|
||||
"""MQTT settings should be saved and retrieved correctly."""
|
||||
mock_publisher = type("MockPublisher", (), {"restart": AsyncMock()})()
|
||||
with patch("app.mqtt.mqtt_publisher", mock_publisher):
|
||||
result = await update_settings(
|
||||
AppSettingsUpdate(
|
||||
mqtt_broker_host="broker.test",
|
||||
mqtt_broker_port=8883,
|
||||
mqtt_username="user",
|
||||
mqtt_password="pass",
|
||||
mqtt_use_tls=True,
|
||||
mqtt_tls_insecure=True,
|
||||
mqtt_topic_prefix="custom",
|
||||
mqtt_publish_messages=True,
|
||||
mqtt_publish_raw_packets=True,
|
||||
)
|
||||
)
|
||||
|
||||
assert result.mqtt_broker_host == "broker.test"
|
||||
assert result.mqtt_broker_port == 8883
|
||||
assert result.mqtt_username == "user"
|
||||
assert result.mqtt_password == "pass"
|
||||
assert result.mqtt_use_tls is True
|
||||
assert result.mqtt_tls_insecure is True
|
||||
assert result.mqtt_topic_prefix == "custom"
|
||||
assert result.mqtt_publish_messages is True
|
||||
assert result.mqtt_publish_raw_packets is True
|
||||
|
||||
# Verify persistence
|
||||
fresh = await AppSettingsRepository.get()
|
||||
assert fresh.mqtt_broker_host == "broker.test"
|
||||
assert fresh.mqtt_use_tls is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mqtt_defaults_on_fresh_db(self, test_db):
|
||||
"""MQTT fields should have correct defaults on a fresh database."""
|
||||
settings = await AppSettingsRepository.get()
|
||||
|
||||
assert settings.mqtt_broker_host == ""
|
||||
assert settings.mqtt_broker_port == 1883
|
||||
assert settings.mqtt_username == ""
|
||||
assert settings.mqtt_password == ""
|
||||
assert settings.mqtt_use_tls is False
|
||||
assert settings.mqtt_tls_insecure is False
|
||||
assert settings.mqtt_topic_prefix == "meshcore"
|
||||
assert settings.mqtt_publish_messages is False
|
||||
assert settings.mqtt_publish_raw_packets is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_community_mqtt_fields_round_trip(self, test_db):
|
||||
"""Community MQTT settings should be saved and retrieved correctly."""
|
||||
mock_community = type("MockCommunity", (), {"restart": AsyncMock()})()
|
||||
with patch("app.community_mqtt.community_publisher", mock_community):
|
||||
result = await update_settings(
|
||||
AppSettingsUpdate(
|
||||
community_mqtt_enabled=True,
|
||||
community_mqtt_iata="DEN",
|
||||
community_mqtt_broker_host="custom-broker.example.com",
|
||||
community_mqtt_broker_port=8883,
|
||||
community_mqtt_email="test@example.com",
|
||||
)
|
||||
)
|
||||
|
||||
assert result.community_mqtt_enabled is True
|
||||
assert result.community_mqtt_iata == "DEN"
|
||||
assert result.community_mqtt_broker_host == "custom-broker.example.com"
|
||||
assert result.community_mqtt_broker_port == 8883
|
||||
assert result.community_mqtt_email == "test@example.com"
|
||||
|
||||
# Verify persistence
|
||||
fresh = await AppSettingsRepository.get()
|
||||
assert fresh.community_mqtt_enabled is True
|
||||
assert fresh.community_mqtt_iata == "DEN"
|
||||
assert fresh.community_mqtt_broker_host == "custom-broker.example.com"
|
||||
assert fresh.community_mqtt_broker_port == 8883
|
||||
assert fresh.community_mqtt_email == "test@example.com"
|
||||
|
||||
# Verify restart was called
|
||||
mock_community.restart.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_community_mqtt_iata_validation_rejects_invalid(self, test_db):
|
||||
"""Invalid IATA codes should be rejected."""
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await update_settings(AppSettingsUpdate(community_mqtt_iata="A"))
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await update_settings(AppSettingsUpdate(community_mqtt_iata="ABCDE"))
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await update_settings(AppSettingsUpdate(community_mqtt_iata="12"))
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await update_settings(AppSettingsUpdate(community_mqtt_iata="ABCD"))
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_community_mqtt_enable_requires_iata(self, test_db):
|
||||
"""Enabling community MQTT without a valid IATA code should be rejected."""
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await update_settings(AppSettingsUpdate(community_mqtt_enabled=True))
|
||||
assert exc.value.status_code == 400
|
||||
assert "IATA" in exc.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_community_mqtt_iata_uppercased(self, test_db):
|
||||
"""IATA codes should be uppercased."""
|
||||
mock_community = type("MockCommunity", (), {"restart": AsyncMock()})()
|
||||
with patch("app.community_mqtt.community_publisher", mock_community):
|
||||
result = await update_settings(AppSettingsUpdate(community_mqtt_iata="den"))
|
||||
assert result.community_mqtt_iata == "DEN"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_community_mqtt_defaults_on_fresh_db(self, test_db):
|
||||
"""Community MQTT fields should have correct defaults on a fresh database."""
|
||||
settings = await AppSettingsRepository.get()
|
||||
assert settings.community_mqtt_enabled is False
|
||||
assert settings.community_mqtt_iata == ""
|
||||
assert settings.community_mqtt_email == ""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flood_scope_round_trip(self, test_db):
|
||||
"""Flood scope should be saved and retrieved correctly."""
|
||||
|
||||
+13
-16
@@ -206,45 +206,42 @@ class TestWebSocketConnectionManagement:
|
||||
|
||||
|
||||
class TestBroadcastEventFanout:
|
||||
"""Test that broadcast_event dispatches to WS, private MQTT, and community MQTT."""
|
||||
"""Test that broadcast_event dispatches to WS and fanout manager."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_event_dispatches_to_all_three_sinks(self):
|
||||
"""broadcast_event creates a WS task, calls mqtt_broadcast, and
|
||||
calls community_mqtt_broadcast."""
|
||||
async def test_broadcast_event_dispatches_to_ws_and_fanout(self):
|
||||
"""broadcast_event creates a WS task and dispatches to fanout manager."""
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
with (
|
||||
patch("app.websocket.ws_manager") as mock_ws,
|
||||
patch("app.mqtt.mqtt_broadcast") as mock_mqtt,
|
||||
patch("app.community_mqtt.community_mqtt_broadcast") as mock_community,
|
||||
patch("app.fanout.manager.fanout_manager") as mock_fm,
|
||||
):
|
||||
mock_ws.broadcast = AsyncMock()
|
||||
mock_fm.broadcast_message = AsyncMock()
|
||||
|
||||
broadcast_event("message", {"id": 1, "text": "hello"})
|
||||
|
||||
# Let the asyncio task (ws_manager.broadcast) run
|
||||
# Let the asyncio tasks run
|
||||
await asyncio.sleep(0)
|
||||
|
||||
mock_ws.broadcast.assert_called_once_with("message", {"id": 1, "text": "hello"})
|
||||
mock_mqtt.assert_called_once_with("message", {"id": 1, "text": "hello"})
|
||||
mock_community.assert_called_once_with("message", {"id": 1, "text": "hello"})
|
||||
mock_fm.broadcast_message.assert_called_once_with({"id": 1, "text": "hello"})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_event_passes_event_type_to_mqtt_filters(self):
|
||||
"""MQTT sinks receive the event_type so they can filter by message vs raw_packet."""
|
||||
async def test_broadcast_event_raw_packet_dispatches_to_fanout(self):
|
||||
"""broadcast_event for raw_packet dispatches to fanout broadcast_raw."""
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
with (
|
||||
patch("app.websocket.ws_manager") as mock_ws,
|
||||
patch("app.mqtt.mqtt_broadcast") as mock_mqtt,
|
||||
patch("app.community_mqtt.community_mqtt_broadcast") as mock_community,
|
||||
patch("app.fanout.manager.fanout_manager") as mock_fm,
|
||||
):
|
||||
mock_ws.broadcast = AsyncMock()
|
||||
mock_fm.broadcast_raw = AsyncMock()
|
||||
|
||||
broadcast_event("raw_packet", {"data": "ff00"})
|
||||
await asyncio.sleep(0)
|
||||
|
||||
# Both MQTT sinks receive the event type for filtering
|
||||
assert mock_mqtt.call_args.args[0] == "raw_packet"
|
||||
assert mock_community.call_args.args[0] == "raw_packet"
|
||||
mock_ws.broadcast.assert_called_once()
|
||||
mock_fm.broadcast_raw.assert_called_once_with({"data": "ff00"})
|
||||
|
||||
Reference in New Issue
Block a user