Move to modular fanout bus

This commit is contained in:
Jack Kingsman
2026-03-05 17:16:13 -08:00
parent 93b5bd908a
commit 7cd54d14d8
34 changed files with 2489 additions and 1292 deletions
+1 -1
View File
@@ -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
-34
View File
@@ -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):
+528
View File
@@ -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()
+403
View File
@@ -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]
+24 -86
View File
@@ -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
View File
@@ -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
View File
@@ -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)
-124
View File
@@ -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
View File
@@ -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"})