Files
LEDMatrix/test/web_interface/test_plugin_config_legacy_values.py
T
ChuckandClaude Opus 5 6d1cbfb70b fix(web): plugin config page survives stored values the schema outgrew (#578)
Two stored shapes broke the config form:

* A scalar under a field that is now an object. News' dynamic_duration
  was a boolean and is becoming an object; render_nested_section did
  `key in true` and the whole page failed to render. Look into dicts
  only, and carry a legacy boolean over as the object's `enabled`, so
  the next save upgrades it without switching the feature off.
* A custom feed logo with a path but no id. The template always emitted
  an empty `logo.id` input, which the save route parsed to null, failing
  the id's string type on every save. Emit it only when there is an id,
  as custom-feeds.js already does.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:03:15 -04:00

181 lines
6.4 KiB
Python

"""Stored values the current schema no longer describes must not break the form.
Two cases from the news plugin:
* ``global.dynamic_duration`` used to be a boolean and is now an object. An
install that has not saved since still holds ``true``; rendering the nested
section did ``key in true`` and the whole config page failed to load.
* A custom feed logo stored with a ``path`` but no ``id``. The template always
emitted an empty ``logo.id`` input, which the save route parsed to ``None``
and the schema rejected (``Expected type string``) -- 400 on every save.
"""
import json
import sys
from html.parser import HTMLParser
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
from werkzeug.datastructures import MultiDict
PROJECT_ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
SCHEMA = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"global": {"type": "object", "properties": {
"dynamic_duration": {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"min_duration_seconds": {"type": "integer", "default": 30,
"minimum": 10, "maximum": 300},
},
"additionalProperties": False,
},
}, "additionalProperties": False},
"feeds": {"type": "object", "properties": {
"custom_feeds": {
"type": "array",
"x-widget": "custom-feeds",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"url": {"type": "string"},
"enabled": {"type": "boolean", "default": True},
"logo": {"type": "object", "required": ["path"],
"properties": {"id": {"type": "string"},
"path": {"type": "string"}}},
},
"required": ["name", "url"],
"additionalProperties": False,
},
},
}, "additionalProperties": False},
},
"required": ["enabled"],
"additionalProperties": False,
}
LOGO_PATH = "assets/plugins/news/uploads/verge.png"
STORED = {
"enabled": True,
"global": {"dynamic_duration": True},
"feeds": {"custom_feeds": [{"name": "Verge",
"url": "https://www.theverge.com/rss/index.xml",
"enabled": True,
"logo": {"path": LOGO_PATH}}]},
}
class _FormFields(HTMLParser):
"""Collect what a browser would submit from the rendered form."""
def __init__(self):
super().__init__()
self.pairs = []
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag != "input" or not a.get("name") or "disabled" in a:
return
if a.get("type") in ("checkbox", "radio") and "checked" not in a:
return
if a.get("type") in ("button", "submit", "file"):
return
self.pairs.append((a["name"], a.get("value", "")))
def _render():
from jinja2 import Environment, FileSystemLoader, select_autoescape
env = Environment(
loader=FileSystemLoader(str(PROJECT_ROOT / "web_interface" / "templates")),
autoescape=select_autoescape(["html"]),
)
plugin = {"id": "demo", "name": "Demo", "description": "", "enabled": True,
"author": "me", "version": "1.0.0"}
return env.get_template("v3/partials/plugin_config.html").render(
plugin=plugin, schema=SCHEMA, config=json.loads(json.dumps(STORED)))
@pytest.fixture
def post(tmp_path):
from src.plugin_system.schema_manager import SchemaManager
from web_interface.blueprints import api_v3 as a
originals = {k: getattr(a.api_v3, k, None)
for k in ("config_manager", "schema_manager", "plugin_manager")}
pdir = tmp_path / "plugins" / "demo"
pdir.mkdir(parents=True)
(pdir / "config_schema.json").write_text(json.dumps(SCHEMA), encoding="utf-8")
store = {"demo": json.loads(json.dumps(STORED))}
cm = MagicMock()
cm.load_config.side_effect = lambda: json.loads(json.dumps(store))
cm.get_raw_file_content.return_value = {}
cm.get_config_path.return_value = str(tmp_path / "config.json")
def _save(cfg, **_kw):
store.clear()
store.update(cfg)
return type("R", (), {"status": type("S", (), {"value": "success"})(),
"message": None})()
cm.save_config_atomic.side_effect = _save
a.api_v3.config_manager = cm
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
project_root=tmp_path)
pm = MagicMock()
pm.plugins = {}
pm.get_plugin.return_value = None
a.api_v3.plugin_manager = pm
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(a.api_v3, url_prefix="/api/v3")
client = app.test_client()
def _post(pairs):
resp = client.post("/api/v3/plugins/config?plugin_id=demo",
data=MultiDict(pairs))
return resp, store.get("demo", {})
try:
yield _post
finally:
for k, v in originals.items():
setattr(a.api_v3, k, v)
def test_a_scalar_stored_under_an_object_field_still_renders():
html = _render()
assert 'name="global.dynamic_duration.min_duration_seconds"' in html
def test_a_logo_without_an_id_posts_no_id_field():
parser = _FormFields()
parser.feed(_render())
names = [name for name, _ in parser.pairs]
assert "feeds.custom_feeds.0.logo.path" in names
assert "feeds.custom_feeds.0.logo.id" not in names
def test_saving_that_form_succeeds_and_upgrades_the_legacy_value(post):
parser = _FormFields()
parser.feed(_render())
resp, cfg = post(parser.pairs)
assert resp.status_code == 200, resp.get_json()
assert isinstance(cfg["global"]["dynamic_duration"], dict)
# The legacy `true` was the on switch; upgrading must not turn it off.
assert cfg["global"]["dynamic_duration"]["enabled"] is True
assert cfg["global"]["dynamic_duration"]["min_duration_seconds"] == 30
assert cfg["feeds"]["custom_feeds"][0]["logo"] == {"path": LOGO_PATH}