"""Tests for the plugin settings UI stack. Covers modules/ini_writer.py (comment-preserving INI writes), modules/settings_schema.py (validation + view assembly), modules/settings_store.py (INI backend), and the /api/plugins endpoints. """ from __future__ import annotations import configparser from pathlib import Path from unittest.mock import patch import pytest from modules.ini_writer import backup_config, update_ini_values from modules.settings_schema import ( build_plugin_settings_view, read_enabled, to_config_string, validate_field, ) from modules.settings_store import ConfigIniStore, get_settings_store # --------------------------------------------------------------------------- # ini_writer # --------------------------------------------------------------------------- SAMPLE_INI = """\ # Top comment stays [Bot] ; bot name comment bot_name = MeshCoreBot rate_limit_seconds = 10 [Weather] default_state = WA """ class TestUpdateIniValues: def _write(self, tmp_path: Path, text: str = SAMPLE_INI) -> Path: p = tmp_path / "config.ini" p.write_text(text, encoding="utf-8") return p def test_change_existing_value_preserves_comments(self, tmp_path): p = self._write(tmp_path) result = update_ini_values(str(p), {"Bot": {"rate_limit_seconds": "5"}}) text = p.read_text(encoding="utf-8") assert "rate_limit_seconds = 5" in text assert "# Top comment stays" in text assert "; bot name comment" in text assert ("Bot", "rate_limit_seconds") in result["changed"] def test_unchanged_value_not_reported_changed(self, tmp_path): p = self._write(tmp_path) result = update_ini_values(str(p), {"Bot": {"bot_name": "MeshCoreBot"}}) assert result["changed"] == [] def test_new_key_appended_to_existing_section(self, tmp_path): p = self._write(tmp_path) result = update_ini_values(str(p), {"Bot": {"new_key": "42"}}) cfg = configparser.ConfigParser() cfg.read(p, encoding="utf-8") assert cfg.get("Bot", "new_key") == "42" # Must land in [Bot], not [Weather]. assert not cfg.has_option("Weather", "new_key") assert ("Bot", "new_key") in result["added"] def test_new_section_appended_at_eof(self, tmp_path): p = self._write(tmp_path) result = update_ini_values(str(p), {"Brand_New": {"alpha": "1"}}) cfg = configparser.ConfigParser() cfg.read(p, encoding="utf-8") assert cfg.get("Brand_New", "alpha") == "1" assert "Brand_New" in result["created_sections"] def test_delete_key(self, tmp_path): p = self._write(tmp_path) result = update_ini_values(str(p), {}, deletes={"Weather": ["default_state"]}) cfg = configparser.ConfigParser() cfg.read(p, encoding="utf-8") assert not cfg.has_option("Weather", "default_state") assert ("weather", "default_state") in result["removed"] def test_case_insensitive_section_and_key_match(self, tmp_path): p = self._write(tmp_path) update_ini_values(str(p), {"bot": {"BOT_NAME": "Zed"}}) cfg = configparser.ConfigParser() cfg.read(p, encoding="utf-8") assert cfg.get("Bot", "bot_name") == "Zed" # No duplicate key line was appended. assert p.read_text(encoding="utf-8").lower().count("bot_name") == 1 def test_backup_created(self, tmp_path): p = self._write(tmp_path) result = update_ini_values(str(p), {"Bot": {"bot_name": "X"}}) assert Path(result["backup_path"]).exists() assert "config.ini.bak." in result["backup_path"] def test_value_with_percent_survives(self, tmp_path): """'%' must round-trip: cron/strftime values would break interpolation.""" p = self._write(tmp_path) update_ini_values(str(p), {"Bot": {"fmt": "%H:%M"}}) raw = configparser.RawConfigParser() raw.read(p, encoding="utf-8") assert raw.get("Bot", "fmt") == "%H:%M" def test_noop_returns_empty_summary(self, tmp_path): p = self._write(tmp_path) before = p.read_text(encoding="utf-8") result = update_ini_values(str(p), {}) assert result["backup_path"] == "" assert p.read_text(encoding="utf-8") == before def test_backup_prune_keeps_bounded_count(self, tmp_path): p = self._write(tmp_path) for i in range(25): # Distinct timestamps not guaranteed; emulate by naming manually. (tmp_path / f"config.ini.bak.20260101-{i:06d}").write_text("x") backup_config(str(p)) backups = list(tmp_path.glob("config.ini.bak.*")) assert len(backups) <= 20 # --------------------------------------------------------------------------- # settings_schema.validate_field # --------------------------------------------------------------------------- class TestValidateField: def test_bool_accepts_configparser_forms(self): f = {"key": "x", "type": "bool"} for raw, expected in [("true", True), ("no", False), ("1", True), ("off", False)]: ok, val, err = validate_field(f, raw) assert ok and val is expected and err is None def test_bool_rejects_garbage(self): ok, _, err = validate_field({"key": "x", "type": "bool", "label": "Flag"}, "maybe") assert not ok and "Flag" in err def test_int_bounds(self): f = {"key": "x", "type": "int", "min": 1, "max": 10} assert validate_field(f, "5")[0] assert not validate_field(f, "0")[0] assert not validate_field(f, "11")[0] assert not validate_field(f, "abc")[0] def test_empty_optional_returns_default(self): f = {"key": "x", "type": "int", "default": 7} ok, val, _ = validate_field(f, "") assert ok and val == 7 def test_empty_required_fails(self): ok, _, err = validate_field({"key": "x", "type": "str", "required": True, "label": "Name"}, "") assert not ok and "required" in err def test_enum_membership(self): f = {"key": "x", "type": "enum", "options": [{"value": "a"}, {"value": "b"}]} assert validate_field(f, "a") == (True, "a", None) assert not validate_field(f, "c")[0] def test_list_split_and_pattern(self): f = {"key": "x", "type": "list", "pattern": r"[a-z]+"} ok, val, _ = validate_field(f, "abc, def") assert ok and val == ["abc", "def"] assert not validate_field(f, "abc, 123")[0] def test_str_pattern(self): f = {"key": "x", "type": "str", "pattern": r"[0-9a-f]{4}"} assert validate_field(f, "beef")[0] assert not validate_field(f, "zzzz")[0] def test_to_config_string_bool_and_list(self): assert to_config_string({"type": "bool"}, True) == "true" assert to_config_string({"type": "list"}, ["a", "b"]) == "a, b" # --------------------------------------------------------------------------- # settings_schema.read_enabled — legacy aliases # --------------------------------------------------------------------------- class TestReadEnabled: def test_canonical_key_wins(self): cfg = configparser.ConfigParser() cfg.add_section("Joke_Command") cfg.set("Joke_Command", "enabled", "false") assert read_enabled(cfg, "Joke_Command", True) is False def test_legacy_jokes_alias(self): cfg = configparser.ConfigParser() cfg.add_section("Jokes") cfg.set("Jokes", "joke_enabled", "false") assert read_enabled(cfg, "Joke_Command", True) is False def test_default_when_absent(self): cfg = configparser.ConfigParser() assert read_enabled(cfg, "Anything_Command", True) is True assert read_enabled(cfg, "SomeService", False) is False # --------------------------------------------------------------------------- # settings_schema.build_plugin_settings_view # --------------------------------------------------------------------------- class TestBuildView: @pytest.fixture(scope="class") def view(self): cfg = configparser.ConfigParser() return build_plugin_settings_view(cfg) def _entry(self, view, kind, name): return next((e for e in view if e["kind"] == kind and e["name"] == name), None) def test_discovers_commands_and_services(self, view): kinds = {e["kind"] for e in view} assert kinds == {"command", "service"} assert len(view) > 20 def test_optin_commands_show_disabled_by_default(self, view): """Announcements/Greeter read enabled with fallback=False in __init__; the UI must mirror that instead of showing them 'on'.""" for name in ("announcements", "greeter"): entry = self._entry(view, "command", name) assert entry is not None assert entry["enabled"] is False, f"{name} should default to disabled" def test_regular_command_defaults_enabled(self, view): entry = self._entry(view, "command", "ping") assert entry is not None and entry["enabled"] is True def test_services_default_disabled(self, view): assert all(e["enabled"] is False for e in view if e["kind"] == "service") def test_every_command_gets_channels_field(self, view): for e in view: if e["kind"] == "command": assert any(f["key"].lower() == "channels" for f in e["fields"]), ( f"{e['name']} is missing the injected channels field" ) # --------------------------------------------------------------------------- # settings_store # --------------------------------------------------------------------------- class TestConfigIniStore: def test_write_values_updates_memory_and_disk(self, tmp_path): p = tmp_path / "config.ini" p.write_text("[Bot]\nbot_name = A\n", encoding="utf-8") cfg = configparser.ConfigParser() cfg.read(p, encoding="utf-8") store = ConfigIniStore(cfg, str(p)) store.write_values("Bot", {"bot_name": "B"}) assert cfg.get("Bot", "bot_name") == "B" assert "bot_name = B" in p.read_text(encoding="utf-8") def test_percent_value_skips_memory_but_hits_disk(self, tmp_path): p = tmp_path / "config.ini" p.write_text("[Bot]\n", encoding="utf-8") cfg = configparser.ConfigParser() # BasicInterpolation rejects lone % cfg.read(p, encoding="utf-8") store = ConfigIniStore(cfg, str(p)) store.write_values("Bot", {"fmt": "%H"}) raw = configparser.RawConfigParser() raw.read(p, encoding="utf-8") assert raw.get("Bot", "fmt") == "%H" def test_database_mode_falls_back_to_ini(self, tmp_path): p = tmp_path / "config.ini" p.write_text("[Bot]\npersist_settings = database\n", encoding="utf-8") cfg = configparser.ConfigParser() cfg.read(p, encoding="utf-8") store = get_settings_store(cfg, str(p)) assert isinstance(store, ConfigIniStore) # --------------------------------------------------------------------------- # /api/plugins endpoints # --------------------------------------------------------------------------- @pytest.fixture def viewer(tmp_path): """BotDataViewer against a temp config + empty DB (migrations create tables).""" from modules.web_viewer.app import BotDataViewer config = configparser.ConfigParser() config.add_section("Bot") config.set("Bot", "db_path", str(tmp_path / "meshcore_bot.db")) config.add_section("Web_Viewer") for k, v in [ ("host", "127.0.0.1"), ("port", "8080"), ("enabled", "false"), ("auto_start", "false"), ("debug", "false"), ("cors_allowed_origins", "*"), ("web_viewer_password", ""), ]: config.set("Web_Viewer", k, v) config.add_section("Channels_List") config.set("Channels_List", "seattle", "Seattle chat") config.set("Channels_List", "tacoma", "Tacoma chat") config_path = str(tmp_path / "config.ini") with open(config_path, "w") as f: config.write(f) with patch.object(BotDataViewer, "_start_database_polling"), \ patch.object(BotDataViewer, "_start_log_tailing"), \ patch.object(BotDataViewer, "_start_cleanup_scheduler"), \ patch.object(BotDataViewer, "_setup_socketio_handlers"), \ patch("modules.web_viewer.app.RepeaterManager"): v = BotDataViewer(db_path=str(tmp_path / "meshcore_bot.db"), config_path=config_path) v.app.testing = True return v class TestPluginsApi: def test_get_plugins_lists_view(self, viewer): client = viewer.app.test_client() resp = client.get("/api/plugins") assert resp.status_code == 200 data = resp.get_json() assert "plugins" in data and len(data["plugins"]) > 20 def test_save_unknown_plugin_404(self, viewer): client = viewer.app.test_client() resp = client.post("/api/plugins/command/nope", json={"values": {}}) assert resp.status_code == 404 def test_save_validation_error_400(self, viewer): client = viewer.app.test_client() resp = client.post( "/api/plugins/command/greeter", json={"values": {"rollout_days": "not-a-number"}}, ) assert resp.status_code == 400 assert "rollout_days" in resp.get_json().get("errors", {}) def test_save_writes_config_and_queues_reload(self, viewer, tmp_path): client = viewer.app.test_client() resp = client.post( "/api/plugins/command/ping", json={"enabled": True, "values": {}}, ) assert resp.status_code == 200 body = resp.get_json() assert body["success"] is True assert body["reload_queued"] is True text = (tmp_path / "config.ini").read_text(encoding="utf-8") assert "[Ping_Command]" in text rows = viewer.db_manager.execute_query( "SELECT operation_type, status FROM channel_operations " "WHERE operation_type = 'config_reload'" ) assert rows and rows[0]["status"] == "pending" def test_omitted_dynamic_sections_do_not_wipe_managed_keys(self, viewer, tmp_path): """A payload without dynamic_sections must leave [Channels_List] alone.""" client = viewer.app.test_client() resp = client.post( "/api/plugins/command/channels", json={"enabled": True, "values": {}}, ) assert resp.status_code == 200 cfg = configparser.ConfigParser() cfg.read(tmp_path / "config.ini", encoding="utf-8") assert cfg.get("Channels_List", "seattle") == "Seattle chat" assert cfg.get("Channels_List", "tacoma") == "Tacoma chat" def test_dynamic_sections_row_delete_and_add(self, viewer, tmp_path): client = viewer.app.test_client() resp = client.post( "/api/plugins/command/channels", json={ "enabled": True, "values": {}, "dynamic_sections": { "Channels_List": [ {"key": "seattle", "value": "Seattle chat"}, {"key": "olympia", "value": "Olympia chat"}, ] }, }, ) assert resp.status_code == 200, resp.get_json() cfg = configparser.ConfigParser() cfg.read(tmp_path / "config.ini", encoding="utf-8") assert cfg.get("Channels_List", "olympia") == "Olympia chat" assert not cfg.has_option("Channels_List", "tacoma") def test_dynamic_key_with_separator_rejected(self, viewer): client = viewer.app.test_client() resp = client.post( "/api/plugins/command/channels", json={ "enabled": True, "values": {}, "dynamic_sections": { "Channels_List": [{"key": "bad=key", "value": "x"}] }, }, ) assert resp.status_code == 400 def test_reload_status_endpoint(self, viewer): client = viewer.app.test_client() resp = client.get("/api/plugins/reload-status") assert resp.status_code == 200 assert resp.get_json()["status"] is None