mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-09-14 11:55:34 +00:00
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.
186 lines
6.0 KiB
Python
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
|