mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-13 02:26:37 +00:00
fix(plugins): prepare plugin configs one way for load, saves, GET, hot reload and dev tools
Plugin config was prepared differently depending on how it arrived:
- JSON POST /plugins/config built a partial body on schema defaults, so
{"enabled": true} reset every other setting of the plugin. It now merges
onto the stored section first, as the form path already did.
- Legacy-boolean normalization (#588) ran only at load: GET /plugins/config
returned the raw boolean, posting it back failed validation, and hot
reload handed plugins the raw section (a legacy dynamic_duration: true
came back as a boolean). schema_manager.prepare_plugin_config (normalize,
then defaults) is now used by PluginManager.load_plugin, both save paths,
GET, the save notifications and DisplayController's hot-reload callback.
- The JSON save's filter kept only enabled/display_duration/live_priority
and dropped a submitted skin, skin_options or vegas_* tuning key. There
is now one core-owned per-plugin list, schema_manager.CORE_PLUGIN_PROPERTIES,
used by validation and by the save filter; PluginManager's
CORE_OWNED_CONFIG_KEYS is its vegas subset.
- Plugin sections posted to /config/main were stored verbatim, including
values /plugins/config rejects. They now go through the same preparation
(_prepare_plugin_config_for_save, extracted from save_plugin_config), and
a failing section rejects the whole save before anything is written.
- dev_server read only top-level defaults and let a schema enabled:false
win; build_full_config shallow-merged overrides, dropping sibling
defaults; the harness extracted defaults differently from the device.
loading.build_config now uses the device's extraction and preparation,
and dev_server, check_plugin, render_plugin and the harness all use it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
"""One preparation for a plugin's config, wherever the config comes from.
|
||||
|
||||
A plugin's stored section becomes the config it runs with through
|
||||
schema_manager.prepare_plugin_config: legacy booleans (#588) read as
|
||||
``{"enabled": ...}`` objects, then schema and core defaults filled in. Loading
|
||||
did that; hot reload handed plugins the raw section instead (a legacy
|
||||
``dynamic_duration: true`` came back as a boolean, turning dynamic duration
|
||||
off), and the dev tools each built configs their own way:
|
||||
|
||||
- dev_server read only top-level defaults and let a schema ``enabled: false``
|
||||
override its forced ``enabled: True``;
|
||||
- check_plugin/render_plugin (build_full_config) merged overrides with
|
||||
dict.update, so ``{"nhl": {"enabled": true}}`` dropped every other nhl
|
||||
default;
|
||||
- the harness and the device disagreed on object and array defaults.
|
||||
|
||||
The web saves and GET are covered in
|
||||
test/web_interface/test_plugin_config_json_saves.py.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugin_system.plugin_manager import PluginManager
|
||||
from src.plugin_system.schema_manager import (
|
||||
CORE_PLUGIN_PROPERTIES, SchemaManager, extract_schema_defaults,
|
||||
)
|
||||
from src.plugin_system.testing import loading
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
|
||||
SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean", "default": False},
|
||||
"global": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dynamic_duration": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean", "default": True},
|
||||
"max_duration_seconds": {"type": "integer", "default": 300},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"nhl": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean", "default": False},
|
||||
"favorite_teams": {"type": "array", "default": ["TB"]},
|
||||
"show_records": {"type": "boolean", "default": True},
|
||||
},
|
||||
},
|
||||
"colors": {
|
||||
"type": "object",
|
||||
"default": {"text": [255, 255, 255]},
|
||||
"properties": {"text": {"type": "array", "default": [1, 2, 3]}},
|
||||
},
|
||||
"feeds": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugin_dir(tmp_path):
|
||||
pdir = tmp_path / "plugins" / "demo"
|
||||
pdir.mkdir(parents=True)
|
||||
(pdir / "config_schema.json").write_text(json.dumps(SCHEMA))
|
||||
(pdir / "manifest.json").write_text(json.dumps({"id": "demo"}))
|
||||
return pdir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugin_manager(plugin_dir, tmp_path):
|
||||
manager = PluginManager.__new__(PluginManager) # skip the heavy constructor
|
||||
manager.logger = MagicMock()
|
||||
manager.schema_manager = SchemaManager(plugins_dir=plugin_dir.parent, project_root=tmp_path)
|
||||
return manager
|
||||
|
||||
|
||||
class TestPluginManagerPreparation:
|
||||
def test_legacy_boolean_and_defaults(self, plugin_manager):
|
||||
prepared = plugin_manager.prepare_plugin_config(
|
||||
"demo", {"enabled": True, "global": {"dynamic_duration": True}})
|
||||
assert prepared["global"]["dynamic_duration"] == {
|
||||
"enabled": True, "max_duration_seconds": 300}
|
||||
assert prepared["nhl"]["favorite_teams"] == ["TB"]
|
||||
assert prepared["display_duration"] == 15
|
||||
|
||||
def test_does_not_mutate_the_raw_section(self, plugin_manager):
|
||||
raw = {"global": {"dynamic_duration": True}}
|
||||
plugin_manager.prepare_plugin_config("demo", raw)
|
||||
assert raw == {"global": {"dynamic_duration": True}}
|
||||
|
||||
def test_never_raises(self, plugin_manager):
|
||||
plugin_manager.schema_manager = MagicMock()
|
||||
plugin_manager.schema_manager.load_schema.return_value = SCHEMA
|
||||
plugin_manager.schema_manager.prepare_plugin_config.side_effect = RuntimeError("boom")
|
||||
prepared = plugin_manager.prepare_plugin_config("demo", {"global": {"dynamic_duration": False}})
|
||||
assert prepared["global"]["dynamic_duration"] == {"enabled": False}
|
||||
|
||||
|
||||
class TestHotReload:
|
||||
def test_on_config_change_gets_the_prepared_config(self, test_display_controller, plugin_manager):
|
||||
controller = test_display_controller
|
||||
plugin = MagicMock()
|
||||
plugin.modes = ["demo"]
|
||||
pm = controller.plugin_manager
|
||||
pm.discover_plugins.return_value = ["demo"]
|
||||
pm.load_plugin.return_value = True
|
||||
pm.plugin_manifests = {}
|
||||
pm.get_plugin.side_effect = lambda pid: plugin if pid == "demo" else None
|
||||
pm.prepare_plugin_config.side_effect = plugin_manager.prepare_plugin_config
|
||||
controller.config_service.get_config = lambda: {"demo": {"enabled": True}}
|
||||
controller._reconcile_enabled_plugins()
|
||||
|
||||
callback = controller._plugin_config_callbacks["demo"]
|
||||
callback({"enabled": True}, {"enabled": True, "global": {"dynamic_duration": True}})
|
||||
|
||||
new_config = plugin.on_config_change.call_args[0][0]
|
||||
assert new_config["global"]["dynamic_duration"] == {
|
||||
"enabled": True, "max_duration_seconds": 300}
|
||||
assert new_config["nhl"]["show_records"] is True
|
||||
|
||||
def test_raw_section_still_delivered_without_a_preparer(self, test_display_controller):
|
||||
controller = test_display_controller
|
||||
plugin = MagicMock()
|
||||
plugin.modes = ["demo"]
|
||||
pm = controller.plugin_manager
|
||||
pm.discover_plugins.return_value = ["demo"]
|
||||
pm.load_plugin.return_value = True
|
||||
pm.plugin_manifests = {}
|
||||
pm.get_plugin.side_effect = lambda pid: plugin if pid == "demo" else None
|
||||
pm.prepare_plugin_config.return_value = None
|
||||
controller.config_service.get_config = lambda: {"demo": {"enabled": True}}
|
||||
controller._reconcile_enabled_plugins()
|
||||
|
||||
raw = {"enabled": False}
|
||||
controller._plugin_config_callbacks["demo"]({}, raw)
|
||||
plugin.on_config_change.assert_called_once_with(raw)
|
||||
|
||||
|
||||
class TestDevToolsMatchTheDevice:
|
||||
def test_harness_defaults_are_the_device_defaults(self, plugin_dir):
|
||||
assert loading.load_config_defaults(plugin_dir) == extract_schema_defaults(SCHEMA)
|
||||
defaults = loading.load_config_defaults(plugin_dir)
|
||||
# An object's own default wins, as on a device; arrays start empty
|
||||
assert defaults["colors"] == {"text": [255, 255, 255]}
|
||||
assert defaults["feeds"] == []
|
||||
|
||||
def test_nested_override_keeps_sibling_defaults(self, plugin_dir):
|
||||
config = loading.build_full_config(plugin_dir, cli_config={"nhl": {"enabled": True}})
|
||||
assert config["nhl"] == {"enabled": True, "favorite_teams": ["TB"], "show_records": True}
|
||||
|
||||
def test_spec_and_cli_overrides_both_deep_merge(self, plugin_dir):
|
||||
config = loading.build_full_config(
|
||||
plugin_dir, spec={"config": {"nhl": {"show_records": False}}},
|
||||
cli_config={"nhl": {"enabled": True}})
|
||||
assert config["nhl"] == {"enabled": True, "favorite_teams": ["TB"], "show_records": False}
|
||||
|
||||
def test_build_config_matches_the_device_load(self, plugin_dir, plugin_manager):
|
||||
overrides = {"enabled": True, "global": {"dynamic_duration": False}}
|
||||
assert loading.build_config(plugin_dir, overrides) == \
|
||||
plugin_manager.prepare_plugin_config("demo", overrides)
|
||||
|
||||
def test_core_defaults_are_present(self, plugin_dir):
|
||||
config = loading.build_full_config(plugin_dir)
|
||||
assert config["enabled"] is True
|
||||
assert config["display_duration"] == CORE_PLUGIN_PROPERTIES["display_duration"]["default"]
|
||||
assert config["live_priority"] is False
|
||||
|
||||
def test_render_plugin_matrix_config(self, plugin_dir, monkeypatch):
|
||||
from src.plugin_system.testing import harness
|
||||
seen = {}
|
||||
|
||||
def fake_render_size(plugin_id, manifest, pdir, config, *args, **kwargs):
|
||||
seen["config"] = config
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(harness, "_render_size", fake_render_size)
|
||||
harness.render_plugin_matrix("demo", plugin_dir, config={"nhl": {"enabled": True}},
|
||||
sizes=[(64, 32)], run_update=False)
|
||||
assert seen["config"]["enabled"] is True, "a schema enabled:false must not win"
|
||||
assert seen["config"]["nhl"]["favorite_teams"] == ["TB"]
|
||||
|
||||
def test_dev_server_render_config(self, plugin_dir, monkeypatch):
|
||||
spec = importlib.util.spec_from_file_location("dev_server_under_test",
|
||||
REPO / "scripts" / "dev_server.py")
|
||||
dev_server = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(dev_server)
|
||||
monkeypatch.setattr(dev_server, "find_plugin_dir", lambda pid: plugin_dir)
|
||||
monkeypatch.setattr(dev_server, "_trusted_plugin_dir", lambda d: plugin_dir)
|
||||
|
||||
_, _, config, _, _ = dev_server._parse_render_request(
|
||||
{"plugin_id": "demo", "config": {"nhl": {"enabled": True}}})
|
||||
assert config["enabled"] is True, "a schema enabled:false must not win"
|
||||
assert config["nhl"] == {"enabled": True, "favorite_teams": ["TB"], "show_records": True}
|
||||
assert config["global"]["dynamic_duration"]["max_duration_seconds"] == 300
|
||||
assert dev_server.load_config_defaults(plugin_dir) == extract_schema_defaults(SCHEMA)
|
||||
@@ -0,0 +1,258 @@
|
||||
"""JSON plugin-config saves store what the device would load, and only what was sent.
|
||||
|
||||
Runs a real ConfigManager and SchemaManager over tmp_path, like
|
||||
test_api_v3_secret_roundtrip.py, so assertions are on config.json itself.
|
||||
|
||||
- POST /plugins/config with a partial ``config`` reset every unsent setting to
|
||||
its schema default: the JSON path built on defaults, not the stored section.
|
||||
- Legacy booleans (#588) were normalized only at load, so posting back what
|
||||
GET /plugins/config returned failed validation.
|
||||
- The JSON save's filter kept only enabled/display_duration/live_priority, so
|
||||
a submitted skin, skin_options or vegas_* tuning key was silently dropped.
|
||||
- Plugin sections posted to /config/main skipped all of that and were stored
|
||||
verbatim, including values /plugins/config rejects.
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from src.config_manager import ConfigManager
|
||||
from src.plugin_system.schema_manager import SchemaManager
|
||||
from web_interface.blueprints.api_v3 import api_v3
|
||||
|
||||
PLUGIN_ID = "demo"
|
||||
|
||||
SCHEMA = {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean", "default": True},
|
||||
"city": {"type": "string", "default": "Dallas"},
|
||||
"update_interval": {"type": "integer", "default": 300, "minimum": 30},
|
||||
"api_key": {"type": "string", "x-secret": True, "default": ""},
|
||||
"display": {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"brightness": {"type": "integer", "default": 50},
|
||||
"show_icons": {"type": "boolean", "default": True},
|
||||
},
|
||||
},
|
||||
"global": {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"dynamic_duration": {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean", "default": True},
|
||||
"min_duration_seconds": {"type": "integer", "default": 30},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
STORED = {
|
||||
"enabled": True,
|
||||
"city": "Paris",
|
||||
"update_interval": 600,
|
||||
"display": {"brightness": 80, "show_icons": False},
|
||||
"global": {"dynamic_duration": {"enabled": False, "min_duration_seconds": 45}},
|
||||
"vegas_width_pct": 60,
|
||||
"skin": "neon",
|
||||
}
|
||||
|
||||
_ATTRS = ('config_manager', 'plugin_manager', 'plugin_store_manager',
|
||||
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
|
||||
'operation_queue', 'operation_history', 'cache_manager')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(tmp_path):
|
||||
config_file = tmp_path / "config.json"
|
||||
secrets_file = tmp_path / "config_secrets.json"
|
||||
plugins_dir = tmp_path / "plugins"
|
||||
plugin_dir = plugins_dir / PLUGIN_ID
|
||||
plugin_dir.mkdir(parents=True)
|
||||
(plugin_dir / "config_schema.json").write_text(json.dumps(SCHEMA))
|
||||
(plugin_dir / "manifest.json").write_text(json.dumps({"id": PLUGIN_ID}))
|
||||
|
||||
sentinel = object()
|
||||
originals = {name: getattr(api_v3, name, sentinel) for name in _ATTRS}
|
||||
|
||||
config_manager = ConfigManager(config_path=str(config_file),
|
||||
secrets_path=str(secrets_file))
|
||||
config_manager.template_path = str(tmp_path / "no-template.json")
|
||||
plugin_manager = MagicMock()
|
||||
plugin_manager.plugin_manifests = {PLUGIN_ID: {"id": PLUGIN_ID}}
|
||||
plugin_manager.plugins_dir = plugins_dir
|
||||
plugin_manager.get_plugin.return_value = None
|
||||
|
||||
for name in _ATTRS:
|
||||
setattr(api_v3, name, MagicMock())
|
||||
api_v3.config_manager = config_manager
|
||||
api_v3.schema_manager = SchemaManager(plugins_dir=plugins_dir, project_root=tmp_path)
|
||||
api_v3.plugin_manager = plugin_manager
|
||||
api_v3.operation_queue = None
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
app.register_blueprint(api_v3, url_prefix="/api/v3")
|
||||
|
||||
class Env:
|
||||
client = app.test_client()
|
||||
|
||||
@staticmethod
|
||||
def store(section):
|
||||
config_file.write_text(json.dumps({"timezone": "UTC", PLUGIN_ID: section}))
|
||||
|
||||
@staticmethod
|
||||
def main():
|
||||
return json.loads(config_file.read_text())
|
||||
|
||||
@staticmethod
|
||||
def stored():
|
||||
return json.loads(config_file.read_text())[PLUGIN_ID]
|
||||
|
||||
@staticmethod
|
||||
def secrets():
|
||||
return json.loads(secrets_file.read_text()) if secrets_file.exists() else {}
|
||||
|
||||
@staticmethod
|
||||
def save(config):
|
||||
return app.test_client().post("/api/v3/plugins/config",
|
||||
json={"plugin_id": PLUGIN_ID, "config": config})
|
||||
|
||||
Env.store(json.loads(json.dumps(STORED)))
|
||||
yield Env
|
||||
|
||||
for name, original in originals.items():
|
||||
if original is sentinel:
|
||||
if hasattr(api_v3, name):
|
||||
delattr(api_v3, name)
|
||||
else:
|
||||
setattr(api_v3, name, original)
|
||||
|
||||
|
||||
class TestPartialJsonSave:
|
||||
def test_unsent_settings_keep_their_values(self, env):
|
||||
resp = env.save({"enabled": True})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
stored = env.stored()
|
||||
assert stored["city"] == "Paris"
|
||||
assert stored["update_interval"] == 600
|
||||
assert stored["display"] == {"brightness": 80, "show_icons": False}
|
||||
assert stored["global"]["dynamic_duration"] == {"enabled": False, "min_duration_seconds": 45}
|
||||
|
||||
def test_a_nested_partial_keeps_its_siblings(self, env):
|
||||
resp = env.save({"display": {"brightness": 20}})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert env.stored()["display"] == {"brightness": 20, "show_icons": False}
|
||||
|
||||
def test_a_sent_setting_still_changes(self, env):
|
||||
assert env.save({"city": "Lyon", "enabled": False}).status_code == 200
|
||||
stored = env.stored()
|
||||
assert stored["city"] == "Lyon" and stored["enabled"] is False
|
||||
|
||||
def test_an_invalid_sent_value_is_still_rejected(self, env):
|
||||
resp = env.save({"update_interval": 5})
|
||||
assert resp.status_code == 400
|
||||
assert env.stored()["update_interval"] == 600
|
||||
|
||||
def test_config_must_be_an_object(self, env):
|
||||
assert env.save(["city"]).status_code == 400
|
||||
|
||||
|
||||
class TestCoreOwnedKeysSurviveTheFilter:
|
||||
def test_submitted_core_keys_are_stored(self, env):
|
||||
env.store({"enabled": True, "city": "Paris"})
|
||||
resp = env.save({
|
||||
"vegas_width_pct": 50, "vegas_overflow": "truncate",
|
||||
"vegas_max_width_screens": 2, "skin": "retro",
|
||||
"skin_options": {"accent": "#ff0000"}, "live_priority": True,
|
||||
})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
stored = env.stored()
|
||||
assert stored["vegas_width_pct"] == 50
|
||||
assert stored["vegas_overflow"] == "truncate"
|
||||
assert stored["vegas_max_width_screens"] == 2
|
||||
assert stored["skin"] == "retro"
|
||||
assert stored["skin_options"] == {"accent": "#ff0000"}
|
||||
assert stored["live_priority"] is True
|
||||
|
||||
def test_stored_core_keys_survive_an_unrelated_save(self, env):
|
||||
assert env.save({"city": "Nice"}).status_code == 200
|
||||
stored = env.stored()
|
||||
assert stored["vegas_width_pct"] == 60 and stored["skin"] == "neon"
|
||||
|
||||
def test_a_non_core_unknown_key_is_still_filtered(self, env):
|
||||
assert env.save({"not_in_schema": 1}).status_code == 200
|
||||
assert "not_in_schema" not in env.stored()
|
||||
|
||||
|
||||
class TestLegacyBooleans:
|
||||
LEGACY = {"enabled": True, "city": "Paris", "global": {"dynamic_duration": True}}
|
||||
|
||||
def test_get_returns_the_object_shape(self, env):
|
||||
env.store(dict(self.LEGACY))
|
||||
data = env.client.get(f"/api/v3/plugins/config?plugin_id={PLUGIN_ID}").get_json()["data"]
|
||||
assert data["global"]["dynamic_duration"] == {"enabled": True, "min_duration_seconds": 30}
|
||||
|
||||
def test_get_output_posts_back(self, env):
|
||||
env.store(dict(self.LEGACY))
|
||||
data = env.client.get(f"/api/v3/plugins/config?plugin_id={PLUGIN_ID}").get_json()["data"]
|
||||
resp = env.save(data)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert env.stored()["global"]["dynamic_duration"] == {"enabled": True, "min_duration_seconds": 30}
|
||||
|
||||
def test_an_unrelated_json_save_upgrades_the_stored_boolean(self, env):
|
||||
env.store(dict(self.LEGACY))
|
||||
resp = env.save({"city": "Lyon"})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert env.stored()["global"]["dynamic_duration"] == {"enabled": True, "min_duration_seconds": 30}
|
||||
|
||||
def test_a_posted_legacy_boolean_is_read_as_the_object(self, env):
|
||||
resp = env.save({"global": {"dynamic_duration": False}})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert env.stored()["global"]["dynamic_duration"]["enabled"] is False
|
||||
|
||||
|
||||
class TestPluginSectionsInConfigMain:
|
||||
def _post(self, env, body):
|
||||
return env.client.post("/api/v3/config/main", json=body)
|
||||
|
||||
def test_a_value_plugins_config_rejects_is_rejected_here_too(self, env):
|
||||
resp = self._post(env, {PLUGIN_ID: {"update_interval": "abc"}})
|
||||
assert resp.status_code == 400
|
||||
assert env.stored()["update_interval"] == 600
|
||||
assert env.save({"update_interval": "abc"}).status_code == 400
|
||||
|
||||
def test_nothing_is_saved_when_a_plugin_section_fails(self, env):
|
||||
resp = self._post(env, {"timezone": "Europe/Paris", PLUGIN_ID: {"update_interval": 1}})
|
||||
assert resp.status_code == 400
|
||||
assert env.main()["timezone"] == "UTC"
|
||||
assert env.stored()["update_interval"] == 600
|
||||
|
||||
def test_partial_section_merges_and_keeps_core_keys(self, env):
|
||||
resp = self._post(env, {PLUGIN_ID: {"city": "Lyon", "vegas_overflow": "rotate"}})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
stored = env.stored()
|
||||
assert stored["city"] == "Lyon"
|
||||
assert stored["display"] == {"brightness": 80, "show_icons": False}
|
||||
assert stored["vegas_overflow"] == "rotate"
|
||||
assert stored["vegas_width_pct"] == 60 and stored["skin"] == "neon"
|
||||
|
||||
def test_secrets_still_go_to_the_secrets_file(self, env):
|
||||
resp = self._post(env, {PLUGIN_ID: {"api_key": "s3cret"}})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert "api_key" not in env.stored() or env.stored()["api_key"] in ("", None)
|
||||
assert env.secrets()[PLUGIN_ID]["api_key"] == "s3cret"
|
||||
|
||||
def test_a_non_object_section_is_rejected(self, env):
|
||||
assert self._post(env, {PLUGIN_ID: "on"}).status_code == 400
|
||||
Reference in New Issue
Block a user