Files
meshcore-bot/tests/test_shutdown_reliability.py
T
agessaman e058da4968 fix: harden shutdown (single stop, viewer, MQTT logs, scheduler)
- Make MeshCoreBot.stop idempotent; remove duplicate stop from start().
- Avoid web viewer restart during shutdown; gate main-loop restarts.
- Fix packet capture MQTT callbacks to log each broker’s host.
- Only shutdown APScheduler when running; silence double-shutdown noise.
- Add regression tests in tests/test_shutdown_reliability.py.
2026-04-14 12:46:01 -07:00

152 lines
4.6 KiB
Python

"""Regression tests for graceful shutdown (idempotent stop, scheduler, MQTT logging)."""
import threading
from configparser import ConfigParser
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from modules.core import MeshCoreBot
from modules.scheduler import MessageScheduler
def _write_minimal_config(path, db_path) -> None:
path.write_text(
f"""[Connection]
connection_type = serial
serial_port = /dev/ttyUSB0
timeout = 30
[Bot]
db_path = {db_path.as_posix()}
prefix_bytes = 1
[Channels]
monitor_channels = #general
""",
encoding="utf-8",
)
@pytest.fixture
def shutdown_test_bot(tmp_path):
config_file = tmp_path / "config.ini"
db_path = tmp_path / "bot.db"
_write_minimal_config(config_file, db_path)
return MeshCoreBot(config_file=str(config_file))
@pytest.mark.asyncio
async def test_stop_is_idempotent(shutdown_test_bot):
"""Second stop() must not re-run teardown (entrypoint owns single shutdown)."""
bot = shutdown_test_bot
bot.feed_manager = MagicMock()
bot.feed_manager.stop = AsyncMock()
bot.mesh_graph = None
bot.services = {}
bot.web_viewer_integration = None
bot.scheduler = MagicMock()
bot.scheduler.scheduler_thread = None
bot.meshcore = None
await bot.stop()
await bot.stop()
bot.feed_manager.stop.assert_called_once()
@pytest.mark.asyncio
async def test_second_stop_skips_when_complete(shutdown_test_bot):
bot = shutdown_test_bot
bot.feed_manager = MagicMock()
bot.feed_manager.stop = AsyncMock()
bot.mesh_graph = None
bot.services = {}
bot.web_viewer_integration = None
bot.scheduler = MagicMock()
bot.scheduler.scheduler_thread = None
bot.meshcore = None
await bot.stop()
bot.feed_manager.stop.reset_mock()
await bot.stop()
bot.feed_manager.stop.assert_not_called()
class TestWebViewerRestartGating:
"""restart_viewer must not run during bot shutdown."""
def test_restart_viewer_noop_when_shutdown_event_set(self):
from modules.web_viewer.integration import WebViewerIntegration
bot = MagicMock()
bot.logger = MagicMock()
bot._shutdown_event = threading.Event()
bot._shutdown_event.set()
bot.connected = False
cfg = ConfigParser()
cfg.add_section("Web_Viewer")
cfg.set("Web_Viewer", "enabled", "false")
cfg.set("Web_Viewer", "auto_start", "false")
cfg.set("Web_Viewer", "host", "127.0.0.1")
cfg.set("Web_Viewer", "port", "8080")
cfg.set("Web_Viewer", "debug", "false")
bot.config = cfg
with patch.object(WebViewerIntegration, "_validate_config"), patch(
"modules.web_viewer.integration.BotIntegration", MagicMock()
):
wvi = WebViewerIntegration(bot)
with patch.object(wvi, "stop_viewer") as mock_stop:
wvi.restart_viewer()
mock_stop.assert_not_called()
class TestSchedulerShutdownSafe:
"""join() / setup must not spuriously log APScheduler 'not running' errors."""
def test_join_twice_after_shutdown_no_not_running_debug(self, mock_logger):
bot = MagicMock()
bot.logger = mock_logger
bot.config = ConfigParser()
bot.config.add_section("Bot")
scheduler = MessageScheduler(bot)
scheduler.setup_scheduled_messages()
scheduler.join(timeout=1)
mock_logger.debug.reset_mock()
scheduler.join(timeout=0.1)
joined = " ".join(str(c) for c in mock_logger.debug.call_args_list).lower()
assert "not running" not in joined
def test_mqtt_disconnect_logs_use_matched_client_config():
"""Mirrors PacketCaptureService on_disconnect: each client logs its own broker host."""
log_hosts: list[str] = []
class Svc:
mqtt_clients: list
mqtt_connected = False
svc = Svc()
def on_disconnect(client, userdata, rc, properties=None):
for mqtt_info in svc.mqtt_clients:
if mqtt_info["client"] == client:
mqtt_info["connected"] = False
cfg = mqtt_info["config"]
host = cfg["host"]
log_hosts.append(host)
break
svc.mqtt_connected = any(m.get("connected", False) for m in svc.mqtt_clients)
c1, c2 = object(), object()
svc.mqtt_clients = [
{"client": c1, "config": {"host": "broker-a.example", "port": 1883}, "connected": True},
{"client": c2, "config": {"host": "broker-b.example", "port": 1883}, "connected": True},
]
on_disconnect(c1, None, 0)
on_disconnect(c2, None, 0)
assert log_hosts == ["broker-a.example", "broker-b.example"]