Files
LEDMatrix/test/web_interface/test_update_all_plugins.py
T
ChuckandClaude Opus 5 641cbfba31 fix(web): report a no-op plugin update as already up to date
update_plugin() returns True both for a real update and for "nothing to
do" (a ZIP-installed monorepo plugin already at the registry version, a
bundled plugin). With no git commit to compare, POST /plugins/update
called every such success "updated successfully", so Check & Update All
counted most official plugins as updated on every run.

The route now reads what changed off the plugin itself (commit, else
manifest version, else last_updated) and returns data.update_status
(updated / up_to_date / local_only). The update-all toast is summarised
by PluginInstallManager.summarizeUpdateResults from that status, falling
back to the message for older servers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 08:49:38 -04:00

166 lines
7.8 KiB
Python

"""'Check & Update All' must only send ids POST /plugins/update handles.
Seen on a device running core 3.4.0: update-all posted every entry from
/plugins/installed, including the virtual `starlark:<app_id>` entries that
list installed Starlark apps, and the route answered each with a 500
"Plugin update failed: plugin not found". A web-service restart during the
same run also cost stock-news its update: its request was refused while the
service came back, and update-all never sent it again.
The id selection and retry live in install_manager.js and are covered by
test/js/unit/test_update_all.js, which this module runs so CI sees it. The
route contract is tested here directly.
"""
import shutil
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
REPO = Path(__file__).resolve().parents[2]
JS_SUITE = REPO / 'test' / 'js' / 'unit' / 'test_update_all.js'
@pytest.fixture
def client():
from web_interface.app import app
app.config['TESTING'] = True
with app.test_client() as c:
yield c
@pytest.fixture
def store(tmp_path):
"""A store manager that records calls; no git, no network."""
from web_interface.blueprints.api_v3 import api_v3
sm = MagicMock()
sm.plugins_dir = str(tmp_path)
sm._get_local_git_info.return_value = None
sm.get_plugin_info.return_value = None
sm.update_plugin.return_value = True
with patch.object(api_v3, 'plugin_store_manager', sm, create=True), \
patch.object(api_v3, 'plugin_manager', None, create=True), \
patch.object(api_v3, 'schema_manager', None, create=True), \
patch.object(api_v3, 'plugin_state_manager', None, create=True), \
patch.object(api_v3, 'operation_history', None, create=True):
yield sm
class TestUpdateRouteRejectsStarlarkIds:
@pytest.mark.parametrize('app_id', ['starlark:analogtime', 'starlark:analogclock'])
def test_a_starlark_id_is_a_400_not_a_500(self, client, store, app_id):
resp = client.post('/api/v3/plugins/update', json={'plugin_id': app_id})
assert resp.status_code == 400, resp.get_json()
body = resp.get_json()
assert body['status'] == 'error'
assert body['error_code'] == 'INVALID_INPUT'
assert 'Starlark app' in body['message'], body
assert 'not found' not in body['message'], \
"the app is installed; 'not found' is the misleading message this replaces"
def test_the_store_manager_is_never_asked(self, client, store):
client.post('/api/v3/plugins/update', json={'plugin_id': 'starlark:analogtime'})
store.update_plugin.assert_not_called()
def test_form_encoded_starlark_id_is_rejected_the_same_way(self, client, store):
resp = client.post('/api/v3/plugins/update', data={'plugin_id': 'starlark:analogtime'})
assert resp.status_code == 400
def test_a_plugin_id_still_reaches_the_updater(self, client, store, tmp_path):
# The guard is on the 'starlark:' prefix only: the starlark-apps plugin
# and a disabled plugin with an update are still updated.
for pid in ('stock-news', 'starlark-apps'):
(tmp_path / pid).mkdir()
(tmp_path / pid / 'manifest.json').write_text('{"id": "%s"}' % pid, encoding='utf-8')
resp = client.post('/api/v3/plugins/update', json={'plugin_id': pid})
assert resp.status_code == 200, (pid, resp.get_json())
assert [c.args[0] for c in store.update_plugin.call_args_list] == ['stock-news', 'starlark-apps']
class TestUpdateRouteReportsNoOps:
"""update_plugin() answers True when there was nothing to do.
A ZIP-installed monorepo plugin (no .git) already at the registry version
is the common case for official plugins. The route used to call that
"updated successfully", so Check & Update All counted it as updated.
"""
def _install(self, tmp_path, pid, version, last_updated='2026-09-01'):
(tmp_path / pid).mkdir()
(tmp_path / pid / 'manifest.json').write_text(
'{"id": "%s", "version": "%s", "last_updated": "%s"}' % (pid, version, last_updated),
encoding='utf-8')
def test_a_zip_plugin_already_at_the_registry_version_is_up_to_date(self, client, store, tmp_path):
self._install(tmp_path, 'stock-news', '2.8.0')
body = client.post('/api/v3/plugins/update', json={'plugin_id': 'stock-news'}).get_json()
assert body['status'] == 'success'
assert body['data']['update_status'] == 'up_to_date'
assert 'already up to date' in body['message'], body
assert 'updated successfully' not in body['message']
def test_a_zip_plugin_reinstalled_at_a_new_version_is_updated(self, client, store, tmp_path):
self._install(tmp_path, 'stock-news', '2.6.2')
def reinstall(pid):
(tmp_path / pid / 'manifest.json').write_text(
'{"id": "%s", "version": "2.8.0", "last_updated": "2026-09-01"}' % pid,
encoding='utf-8')
return True
store.update_plugin.side_effect = reinstall
body = client.post('/api/v3/plugins/update', json={'plugin_id': 'stock-news'}).get_json()
assert body['data']['update_status'] == 'updated'
assert '2.8.0' in body['message'], body
def test_a_git_plugin_whose_commit_is_unchanged_is_up_to_date(self, client, store, tmp_path):
self._install(tmp_path, 'clock', '1.0.0')
store._get_local_git_info.return_value = {'sha': 'abcdef1234567', 'branch': 'main'}
body = client.post('/api/v3/plugins/update', json={'plugin_id': 'clock'}).get_json()
assert body['data']['update_status'] == 'up_to_date'
def test_a_git_plugin_that_moved_is_updated(self, client, store, tmp_path):
self._install(tmp_path, 'clock', '1.0.0')
store._get_local_git_info.side_effect = [
{'sha': 'aaaaaaa000', 'branch': 'main'}, # before
{'sha': 'aaaaaaa000', 'branch': 'main'}, # is-git check
{'sha': 'bbbbbbb111', 'branch': 'main'}, # after
]
body = client.post('/api/v3/plugins/update', json={'plugin_id': 'clock'}).get_json()
assert body['data']['update_status'] == 'updated', body
def test_a_local_only_plugin_says_so(self, client, store, tmp_path):
(tmp_path / 'mine').mkdir()
(tmp_path / 'mine' / 'manifest.json').write_text(
'{"id": "mine", "version": "0.1.0", "local_only": true}', encoding='utf-8')
body = client.post('/api/v3/plugins/update', json={'plugin_id': 'mine'}).get_json()
assert body['data']['update_status'] == 'local_only'
store.update_plugin.assert_not_called()
class TestInstalledListContract:
"""What update-all filters on is what /plugins/installed publishes."""
def test_starlark_entries_are_flagged_and_prefixed(self, client):
apps = {'apps': {'analogtime': {'name': 'Analog Time', '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=apps):
resp = client.get('/api/v3/plugins/installed')
plugins = resp.get_json()['data']['plugins']
entry = next(p for p in plugins if p['id'] == 'starlark:analogtime')
assert entry['is_starlark_app'] is True
assert not any(p.get('is_starlark_app') for p in plugins
if not p['id'].startswith('starlark:')), \
"a real plugin carries the Starlark flag and would be dropped from update-all"
@pytest.mark.skipif(shutil.which('node') is None, reason='node is not installed')
def test_update_all_js_selection_and_retry():
node = shutil.which('node')
result = subprocess.run([node, str(JS_SUITE)], capture_output=True, text=True,
timeout=120, cwd=str(JS_SUITE.parent))
assert result.returncode == 0, result.stdout + result.stderr