Files
meshcore-bot/tests/test_meshcore_bot_cli.py
agessaman e0d0288cd3 fix(startup): send config-lint findings to the log, not just stderr
The linter already catches the mistake that costs the most time — a misspelled
key that silently does nothing — and it usually names the intended key. But it
ran before MeshCoreBot existed, so there was no logger, so it printed to
stderr. Under systemd that lands in the journal and never in the configured
log file, which is what an operator actually reads.

The cost is concrete: three consecutive startups today emitted

    [Test_Command] unknown key 'alias'. Did you mean 'aliases'?

and none of them reached logs/meshcore_bot.log, so the answer to "why isn't
the alias firing" sat unread while the bot ran.

Findings are still collected at the same point, before anything opens the
database. They are now reported after construction through bot.logger, at
error level for errors and warning for the rest. If construction raises, they
go to stderr instead: there is no logger to use, and a bad config is the
likeliest reason it failed, so that is exactly when stderr earns its place.

Extracted to _collect_config_issues / _report_config_issues so the truncation
and the "run with --validate-config" pointer are not duplicated across the two
paths.
2026-08-25 10:28:17 -07:00

186 lines
6.0 KiB
Python

"""Tests for meshcore_bot.py CLI config-inspection flags."""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
from meshcore_bot import (
_MAX_CONFIG_ISSUES_SHOWN,
_collect_config_issues,
_report_config_issues,
main,
)
def _write_config(path: Path) -> None:
path.write_text(
"""[Connection]
connection_type = serial
serial_port = /dev/ttyUSB0
[Bot]
db_path = /tmp/bot.db
api_token = super-secret-token
[Notifications]
smtp_user = alerts@example.com
smtp_password = hunter2
recipient = ops@example.com
""",
encoding="utf-8",
)
def test_show_config_prints_redacted_ini(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys) -> None:
config_path = tmp_path / "config.ini"
_write_config(config_path)
monkeypatch.setattr(sys, "argv", ["meshcore_bot.py", "--show-config", "--config", str(config_path)])
with pytest.raises(SystemExit) as exc:
main()
assert exc.value.code == 0
out = capsys.readouterr().out
assert "[Bot]" in out
assert "db_path = /tmp/bot.db" in out
assert "api_token = ●●●●●●" in out
assert "smtp_user = ●●●●●●" in out
assert "smtp_password = ●●●●●●" in out
def test_show_config_json_prints_redacted_json(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
config_path = tmp_path / "config.ini"
_write_config(config_path)
monkeypatch.setattr(
sys, "argv", ["meshcore_bot.py", "--show-config-json", "--config", str(config_path)]
)
with pytest.raises(SystemExit) as exc:
main()
assert exc.value.code == 0
payload = json.loads(capsys.readouterr().out)
assert payload["Bot"]["db_path"] == "/tmp/bot.db"
assert payload["Bot"]["api_token"] == "●●●●●●"
assert payload["Notifications"]["smtp_password"] == "●●●●●●"
def test_show_config_missing_file_exits_1(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
missing_path = tmp_path / "missing.ini"
monkeypatch.setattr(sys, "argv", ["meshcore_bot.py", "--show-config", "--config", str(missing_path)])
with pytest.raises(SystemExit) as exc:
main()
assert exc.value.code == 1
err = capsys.readouterr().err
assert "Config file not found" in err
def test_show_config_invalid_ini_exits_1(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
bad_path = tmp_path / "bad.ini"
bad_path.write_text("[Connection\nserial_port=/dev/ttyUSB0\n", encoding="utf-8")
monkeypatch.setattr(sys, "argv", ["meshcore_bot.py", "--show-config-json", "--config", str(bad_path)])
with pytest.raises(SystemExit) as exc:
main()
assert exc.value.code == 1
err = capsys.readouterr().err
assert "Invalid config file" in err
class _RecordingLogger:
def __init__(self):
self.warnings: list[str] = []
self.errors: list[str] = []
def warning(self, msg):
self.warnings.append(msg)
def error(self, msg):
self.errors.append(msg)
class TestReportConfigIssues:
"""Startup config findings must reach the configured log file, not just stderr:
an operator reading logs/ never saw the linter name their misspelled key."""
def test_findings_go_to_the_logger_when_there_is_one(self):
logger = _RecordingLogger()
_report_config_issues(
[("warning", "[Test_Command] unknown key 'alias'. Did you mean 'aliases'?")],
logger,
)
assert logger.warnings == [
"Config warning: [Test_Command] unknown key 'alias'. Did you mean 'aliases'?"
]
assert logger.errors == []
def test_errors_log_at_error_level(self):
logger = _RecordingLogger()
_report_config_issues([("error", "boom"), ("warning", "meh")], logger)
assert logger.errors == ["Config error: boom"]
assert logger.warnings == ["Config warning: meh"]
def test_without_a_logger_it_still_reaches_stderr(self, capsys):
"""The window before the bot is constructed, where stderr is all there is."""
_report_config_issues([("warning", "early problem")])
captured = capsys.readouterr()
assert "Config warning: early problem" in captured.err
assert captured.out == ""
def test_long_reports_are_truncated_with_a_pointer(self):
logger = _RecordingLogger()
issues = [("warning", f"issue {i}") for i in range(_MAX_CONFIG_ISSUES_SHOWN + 4)]
_report_config_issues(issues, logger)
assert len(logger.warnings) == _MAX_CONFIG_ISSUES_SHOWN + 1
assert "and 4 more issue(s)" in logger.warnings[-1]
assert "--validate-config" in logger.warnings[-1]
def test_nothing_is_emitted_when_the_config_is_clean(self, capsys):
logger = _RecordingLogger()
_report_config_issues([], logger)
assert logger.warnings == [] and logger.errors == []
assert capsys.readouterr().err == ""
class TestCollectConfigIssues:
"""Only errors and warnings are worth an operator's attention at startup."""
def test_info_severity_is_filtered_out(self, tmp_path: Path):
cfg = tmp_path / "config.ini"
cfg.write_text(
"[Test_Command]\nenabled = true\nalias = p,path\n\n[Nonsense_Section]\nx = 1\n",
encoding="utf-8",
)
issues = _collect_config_issues(str(cfg))
assert all(sev in ("error", "warning") for sev, _ in issues)
assert any("alias" in msg and "aliases" in msg for _, msg in issues)
def test_a_broken_linter_never_blocks_startup(self, monkeypatch, capsys):
import modules.config_validation as cv
def explode(_path):
raise RuntimeError("linter exploded")
monkeypatch.setattr(cv, "validate_config", explode)
assert _collect_config_issues("whatever.ini") == []
assert "Config validation skipped" in capsys.readouterr().err