Files
pyMC_Repeater/tests/test_presets.py
T
Daniel Duran dfacfeade8 feat: bundled MC2MQTT broker presets (waev, letsmesh) + format family
Introduces a 'set format and forget' workflow for MQTT brokers. Users
reference a bundled preset by name inside the existing brokers: list,
and the package supplies the endpoints, audiences, and TLS settings.
Endpoint changes ship via 'pip install -U' instead of manual edits.

What changes
- New repeater/presets/ package with a tiny lazy YAML loader and two
  bundled presets: waev (mqtt-{a,b}.waev.app) and letsmesh (EU + US).
- New format-family constant MC2MQTT_FORMATS = ('meshcoretomqtt',
  'letsmesh', 'waev') replaces the inline tuple in topic resolution.
  The legacy 'mqtt' format keeps its custom-topic semantics unchanged.
- Two-pass broker assembly in mqtt_handler.py: pass 1 expands every
  {preset: <name>} entry inline; pass 2 collapses duplicates by name
  with later-wins semantics. Place override entries AFTER preset
  entries.
- Hard-coded LETSMESH_BROKERS constant deleted; its data now lives in
  repeater/presets/letsmesh.yaml.
- convert_letsmesh_to_broker_config() collapsed from ~70 to ~25 lines
  by emitting {preset: letsmesh} plus disable overrides for unwanted
  brokers. Honors broker_index in (-1, 0, 1), additional_brokers, and
  enabled flag exactly as before.
- update_mqtt_config API endpoint accepts {preset: <name>} entries and
  passes them through unchanged so the web UI can author them when the
  frontend is updated.
- config.yaml.example documents the preset entry shape, the override
  rule, and the format family hierarchy.
- pyproject.toml ships presets/*.yaml as package data.

How to use
  mqtt_brokers:
    iata_code: "LAX"
    brokers:
      - preset: waev

  # Override a single preset broker:
  brokers:
    - preset: waev
    - name: waev-b
      enabled: false

Tests
- tests/test_presets.py: 9 tests covering loader, expand/merge,
  MC2MQTT topic-family parity, and parametrized legacy migration.

Co-Authored-By: Oz <oz-agent@warp.dev>
2026-05-02 15:32:22 -07:00

169 lines
6.4 KiB
Python

"""Tests for the bundled MQTT broker preset system.
Locks the public contract documented in `config.yaml.example` and the
behavior contract in the feat/generalized-mqtt PR.
"""
import logging
import pytest
from repeater.data_acquisition.mqtt_handler import (
MC2MQTT_FORMATS,
MeshCoreToMqttPusher,
_BrokerConnection,
_expand_preset_entries,
_merge_overrides_by_name,
)
from repeater.presets import get_preset, list_presets
# --------------------------------------------------------------------
# Preset loader contract
# --------------------------------------------------------------------
def test_list_presets_returns_bundled_names():
"""The shipped wheel must contain at least 'waev' and 'letsmesh'."""
names = list_presets()
assert "waev" in names
assert "letsmesh" in names
def test_get_preset_waev_has_two_brokers():
"""Waev preset shape: top-level 'brokers' list with two MC2MQTT entries."""
preset = get_preset("waev")
brokers = preset.get("brokers", [])
assert len(brokers) == 2
for b in brokers:
assert "name" in b
assert "host" in b
assert b.get("format") == "waev"
def test_get_preset_unknown_returns_empty_dict():
"""Unknown preset names resolve to {} - no exception."""
assert get_preset("definitely-not-a-real-preset") == {}
# --------------------------------------------------------------------
# Pass 1: preset expansion
# --------------------------------------------------------------------
def test_expand_preset_entries_inlines_bundled_brokers():
"""A {preset: waev} entry expands to the two Waev broker dicts."""
expanded = _expand_preset_entries([{"preset": "waev"}])
assert len(expanded) == 2
names = [b["name"] for b in expanded]
assert "waev-a" in names
assert "waev-b" in names
def test_expand_preset_entries_drops_unknown_preset_with_warning(caplog):
"""An unknown preset is dropped; the daemon does not crash."""
with caplog.at_level(logging.WARNING, logger="MQTTHandler"):
expanded = _expand_preset_entries([{"preset": "bogus"}])
assert expanded == []
assert any("bogus" in record.message for record in caplog.records)
# --------------------------------------------------------------------
# Pass 2: override-by-name merge
# --------------------------------------------------------------------
def test_merge_overrides_by_name_disables_one_preset_broker():
"""Override AFTER preset wins: documented happy-path."""
pre_expanded = _expand_preset_entries([{"preset": "waev"}])
merged = _merge_overrides_by_name(pre_expanded + [{"name": "waev-b", "enabled": False}])
assert len(merged) == 2
by_name = {b["name"]: b for b in merged}
assert by_name["waev-a"]["enabled"] is True
assert by_name["waev-b"]["enabled"] is False
def test_merge_overrides_by_name_later_wins_documented_rule():
"""Override BEFORE preset is overwritten - locks the documented rule.
The preset-expanded entry comes after the user's override in this case,
so the preset wins and the user's `enabled: False` is silently lost. This
is the published rule ("place override entries AFTER preset entries");
this test exists so a future refactor can't quietly flip it.
"""
user_first = [{"name": "waev-b", "enabled": False}]
pipeline = _merge_overrides_by_name(user_first + _expand_preset_entries([{"preset": "waev"}]))
by_name = {b["name"]: b for b in pipeline}
# Preset wins - waev-b is enabled despite the user trying to disable it earlier.
assert by_name["waev-b"]["enabled"] is True
# --------------------------------------------------------------------
# MC2MQTT family parity in topic resolution
# --------------------------------------------------------------------
def _make_broker_connection(format_value: str) -> _BrokerConnection:
"""Build a minimal _BrokerConnection for topic-structure assertions."""
broker = {
"name": f"test-{format_value}",
"host": "test.example",
"port": 443,
"format": format_value,
"enabled": True,
}
return _BrokerConnection(
broker=broker,
local_identity=object(),
public_key="ABCD" * 16, # 64-char hex stand-in
iata_code="LAX",
jwt_expiry_minutes=10,
email="",
owner="",
broker_index=0,
node_name="testnode",
)
def test_mc2mqtt_formats_share_topic_structure():
"""Every MC2MQTT family member resolves to the canonical topic prefix."""
expected_mc2mqtt = "meshcore/LAX/" + ("ABCD" * 16)
for fmt in MC2MQTT_FORMATS:
conn = _make_broker_connection(fmt)
assert conn.base_topic == expected_mc2mqtt, f"format '{fmt}' should be MC2MQTT family"
# Legacy custom-MQTT format uses a different (operator-defined) prefix.
legacy = _make_broker_connection("mqtt")
assert legacy.base_topic == "meshcore/repeater/testnode"
# --------------------------------------------------------------------
# Legacy `letsmesh:` block migration
# --------------------------------------------------------------------
@pytest.mark.parametrize(
"broker_index, expected_disabled_names",
[
(-1, set()), # both brokers enabled (preset default)
(0, {"US West (LetsMesh v1)"}), # EU only - US disabled
(1, {"Europe (LetsMesh v1)"}), # US only - EU disabled
],
)
def test_legacy_letsmesh_block_migrates_to_preset_for_each_broker_index(
broker_index, expected_disabled_names
):
"""Legacy letsmesh.broker_index produces the same broker set as before.
The new migrator emits {preset: letsmesh} plus disable overrides; running
that through the expansion+merge pipeline must preserve the legacy
enabled/disabled topology.
"""
legacy_cfg = {"enabled": True, "broker_index": broker_index}
# Call the unbound method - it doesn't read instance state.
entries = MeshCoreToMqttPusher.convert_letsmesh_to_broker_config(
MeshCoreToMqttPusher.__new__(MeshCoreToMqttPusher), legacy_cfg
)
expanded = _expand_preset_entries(entries)
merged = _merge_overrides_by_name(expanded)
# Always two LetsMesh brokers come out of the pipeline.
assert len(merged) == 2
by_name = {b["name"]: b for b in merged}
for name, broker in by_name.items():
if name in expected_disabled_names:
assert broker["enabled"] is False, f"{name} should be disabled for index {broker_index}"
else:
assert broker["enabled"] is True, f"{name} should be enabled for index {broker_index}"