mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
Six Starlark fixes are on main via #535 and #537, but a follow-up commit carrying tests for half of them was pushed to fix/starlark-pixlet-install six minutes after #535 merged, so those tests never landed. This ports them onto the api_v3 package split: - a failed toggle write answers 500, and a loaded app is not flipped in memory when the manifest write fails - each manifest writer gets its own temp file; concurrent writes leave readable JSON; no temp files are left behind - a failed dynamic import of tronbyte_repository / pixlet_renderer does not stay cached in sys.modules - a failed save_config() leaves config and timing untouched and does not re-render; a successful save still applies It also logs when the timing update to the manifest is not persisted. _update_manifest_safe answers False rather than raising, so the existing except branch never saw that failure. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,10 @@ reads, so a future rewrite of this file cannot silently drop them again.
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import types
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -349,6 +353,194 @@ class TestInstalledAppsAppearWithTheOtherPlugins:
|
|||||||
assert resp.status_code == 200, resp.get_json()
|
assert resp.status_code == 200, resp.get_json()
|
||||||
assert written['apps']['demo']['enabled'] is True
|
assert written['apps']['demo']['enabled'] is True
|
||||||
|
|
||||||
|
def test_a_failed_manifest_write_is_not_reported_as_success(self, client):
|
||||||
|
"""The toggle answered 200 while the change was never persisted."""
|
||||||
|
manifest = {'apps': {'demo': {'name': 'Demo', 'enabled': False}}}
|
||||||
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
|
||||||
|
patch('web_interface.blueprints.api_v3._read_starlark_manifest',
|
||||||
|
return_value=manifest), \
|
||||||
|
patch('web_interface.blueprints.api_v3._write_starlark_manifest',
|
||||||
|
return_value=False):
|
||||||
|
resp = client.post('/api/v3/plugins/toggle',
|
||||||
|
json={'plugin_id': 'starlark:demo', 'enabled': True})
|
||||||
|
assert resp.status_code == 500
|
||||||
|
|
||||||
|
def test_a_loaded_app_is_not_flipped_when_the_manifest_write_fails(self, client):
|
||||||
|
"""Persist first, then update memory -- otherwise the UI shows a
|
||||||
|
toggle that silently reverts on the next restart."""
|
||||||
|
app = MagicMock()
|
||||||
|
app.manifest = {'enabled': False}
|
||||||
|
plugin = MagicMock()
|
||||||
|
plugin.apps = {'demo': app}
|
||||||
|
plugin._update_manifest_safe.return_value = False
|
||||||
|
|
||||||
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=plugin):
|
||||||
|
resp = client.post('/api/v3/plugins/toggle',
|
||||||
|
json={'plugin_id': 'starlark:demo', 'enabled': True})
|
||||||
|
assert resp.status_code == 500
|
||||||
|
assert app.manifest['enabled'] is False, "in-memory state changed without being saved"
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheManifestSurvivesConcurrentWriters:
|
||||||
|
"""Flask serves requests concurrently and five routes write this file.
|
||||||
|
|
||||||
|
The atomic-write pattern used one fixed temp name, `manifest.tmp`, shared
|
||||||
|
by every writer: two of them opened it, interleaved their json.dump output,
|
||||||
|
and both renamed. The rename is atomic; the content it published was the
|
||||||
|
mixture, which the next read could not parse.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def starlark_dir(self, tmp_path, monkeypatch):
|
||||||
|
from web_interface.blueprints import api_v3 as module
|
||||||
|
apps_dir = tmp_path / "starlark-apps"
|
||||||
|
apps_dir.mkdir()
|
||||||
|
monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir)
|
||||||
|
monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json')
|
||||||
|
return apps_dir
|
||||||
|
|
||||||
|
def test_each_writer_gets_its_own_temp_file(self, starlark_dir):
|
||||||
|
from web_interface.blueprints import api_v3 as module
|
||||||
|
|
||||||
|
seen = []
|
||||||
|
real_mkstemp = tempfile.mkstemp
|
||||||
|
|
||||||
|
def recording_mkstemp(*args, **kwargs):
|
||||||
|
fd, name = real_mkstemp(*args, **kwargs)
|
||||||
|
seen.append(name)
|
||||||
|
return fd, name
|
||||||
|
|
||||||
|
with patch.object(module.tempfile, 'mkstemp', side_effect=recording_mkstemp):
|
||||||
|
for i in range(5):
|
||||||
|
assert module._write_starlark_manifest({'apps': {f'app{i}': {}}})
|
||||||
|
|
||||||
|
assert len(set(seen)) == 5, f"writers shared a temp file: {seen}"
|
||||||
|
|
||||||
|
def test_concurrent_writes_leave_readable_json(self, starlark_dir):
|
||||||
|
from web_interface.blueprints import api_v3 as module
|
||||||
|
|
||||||
|
manifests = [{'apps': {f'app{i}': {'name': 'x' * 400}}} for i in range(8)]
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
def write(m):
|
||||||
|
try:
|
||||||
|
module._write_starlark_manifest(m)
|
||||||
|
except Exception as exc: # noqa: BLE001 - surfaced by the assert below
|
||||||
|
errors.append(exc)
|
||||||
|
|
||||||
|
threads = [threading.Thread(target=write, args=(m,)) for m in manifests]
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
for t in threads:
|
||||||
|
t.join()
|
||||||
|
|
||||||
|
assert not errors
|
||||||
|
loaded = json.loads((starlark_dir / 'manifest.json').read_text())
|
||||||
|
assert loaded in manifests, "the published manifest was a mix of two writers"
|
||||||
|
|
||||||
|
def test_no_temp_files_are_left_behind(self, starlark_dir):
|
||||||
|
from web_interface.blueprints import api_v3 as module
|
||||||
|
module._write_starlark_manifest({'apps': {}})
|
||||||
|
assert list(starlark_dir.glob('*.tmp')) == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestATransientImportFailureIsNotPermanent:
|
||||||
|
"""Both importers insert into sys.modules before executing the module.
|
||||||
|
|
||||||
|
That order is required -- a module has to be findable while it runs -- but
|
||||||
|
a failure left the half-initialised object cached, so every later call took
|
||||||
|
the cache branch and raised AttributeError on the missing class instead of
|
||||||
|
retrying. One transient failure disabled the repository or the renderer for
|
||||||
|
the life of the process.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("getter,key", [
|
||||||
|
('_get_tronbyte_repository_class', 'tronbyte_repository'),
|
||||||
|
('_get_pixlet_renderer_class', 'pixlet_renderer'),
|
||||||
|
])
|
||||||
|
def test_a_failed_import_leaves_no_entry_behind(self, getter, key):
|
||||||
|
from web_interface.blueprints import api_v3 as module
|
||||||
|
|
||||||
|
original = sys.modules.pop(key, None)
|
||||||
|
try:
|
||||||
|
with patch('importlib.util.module_from_spec') as from_spec, \
|
||||||
|
patch('importlib.util.spec_from_file_location') as spec_from, \
|
||||||
|
patch.object(module.Path, 'exists', return_value=True):
|
||||||
|
from_spec.return_value = types.ModuleType(key)
|
||||||
|
spec = MagicMock()
|
||||||
|
spec.loader.exec_module.side_effect = RuntimeError("network down")
|
||||||
|
spec_from.return_value = spec
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
getattr(module, getter)()
|
||||||
|
|
||||||
|
assert key not in sys.modules, "a half-initialised module stayed cached"
|
||||||
|
finally:
|
||||||
|
if original is not None:
|
||||||
|
sys.modules[key] = original
|
||||||
|
else:
|
||||||
|
sys.modules.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigIsNotAppliedUntilItIsSaved:
|
||||||
|
"""save_config() returning False answers 500, but the loaded app kept the
|
||||||
|
new values -- so GET config reported settings that were never written and
|
||||||
|
the plugin rendered with them until a restart silently reverted them."""
|
||||||
|
|
||||||
|
def _plugin_with_app(self, save_ok):
|
||||||
|
app = MagicMock()
|
||||||
|
app.config = {'city': 'Philadelphia'}
|
||||||
|
app.manifest = {'render_interval': 300, 'display_duration': 15}
|
||||||
|
app.save_config.return_value = save_ok
|
||||||
|
plugin = MagicMock()
|
||||||
|
plugin.apps = {'demo': app}
|
||||||
|
plugin._update_manifest_safe.return_value = True
|
||||||
|
return plugin, app
|
||||||
|
|
||||||
|
def test_a_failed_save_leaves_the_config_untouched(self, client):
|
||||||
|
plugin, app = self._plugin_with_app(save_ok=False)
|
||||||
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=plugin):
|
||||||
|
resp = client.put('/api/v3/starlark/apps/demo/config',
|
||||||
|
json={'city': 'Pittsburgh'})
|
||||||
|
assert resp.status_code == 500
|
||||||
|
assert app.config == {'city': 'Philadelphia'}
|
||||||
|
|
||||||
|
def test_a_failed_save_leaves_the_timing_untouched(self, client):
|
||||||
|
plugin, app = self._plugin_with_app(save_ok=False)
|
||||||
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=plugin):
|
||||||
|
resp = client.put('/api/v3/starlark/apps/demo/config',
|
||||||
|
json={'render_interval': 60})
|
||||||
|
assert resp.status_code == 500
|
||||||
|
assert app.manifest['render_interval'] == 300
|
||||||
|
|
||||||
|
def test_a_failed_save_does_not_re_render_with_values_it_did_not_keep(self, client):
|
||||||
|
plugin, _ = self._plugin_with_app(save_ok=False)
|
||||||
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=plugin):
|
||||||
|
client.put('/api/v3/starlark/apps/demo/config', json={'city': 'Pittsburgh'})
|
||||||
|
plugin._render_app.assert_not_called()
|
||||||
|
|
||||||
|
def test_a_successful_save_still_applies(self, client):
|
||||||
|
plugin, app = self._plugin_with_app(save_ok=True)
|
||||||
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=plugin):
|
||||||
|
resp = client.put('/api/v3/starlark/apps/demo/config',
|
||||||
|
json={'city': 'Pittsburgh', 'render_interval': 60})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert app.config['city'] == 'Pittsburgh'
|
||||||
|
assert app.manifest['render_interval'] == 60
|
||||||
|
plugin._render_app.assert_called_once()
|
||||||
|
|
||||||
|
def test_an_unsaved_timing_change_is_logged(self, client, caplog):
|
||||||
|
"""_update_manifest_safe answers False rather than raising, so the
|
||||||
|
except branch alone never saw a failed timing write."""
|
||||||
|
plugin, _ = self._plugin_with_app(save_ok=True)
|
||||||
|
plugin._update_manifest_safe.return_value = False
|
||||||
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=plugin), \
|
||||||
|
caplog.at_level('WARNING'):
|
||||||
|
resp = client.put('/api/v3/starlark/apps/demo/config',
|
||||||
|
json={'render_interval': 60})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert any('not persisted' in r.getMessage() for r in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
class TestTheManifestStaysRelocatable:
|
class TestTheManifestStaysRelocatable:
|
||||||
"""`star_file` is joined to the app's own directory by its readers.
|
"""`star_file` is joined to the app's own directory by its readers.
|
||||||
|
|||||||
@@ -465,7 +465,10 @@ def update_starlark_app_config(app_id):
|
|||||||
|
|
||||||
def update_fn(manifest):
|
def update_fn(manifest):
|
||||||
manifest['apps'][app_id].update(timing_updates)
|
manifest['apps'][app_id].update(timing_updates)
|
||||||
starlark_plugin._update_manifest_safe(update_fn)
|
# _update_manifest_safe answers False rather than
|
||||||
|
# raising, so the except below never sees this failure.
|
||||||
|
if starlark_plugin._update_manifest_safe(update_fn) is False:
|
||||||
|
logger.warning("Timing for %s was not persisted to the manifest", app_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to persist timing to manifest for {app_id}: {e}")
|
logger.warning(f"Failed to persist timing to manifest for {app_id}: {e}")
|
||||||
starlark_plugin._render_app(app, force=True)
|
starlark_plugin._render_app(app, force=True)
|
||||||
|
|||||||
Reference in New Issue
Block a user