mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
fix: web UI and src.common bugs (wifi wrong-password, plugin icon, starlark toggle, API caching, scroll/logo/font helpers) (#646)
- wifi: keep the "wrong_password:" prefix through the restore/AP fallback so the UI's incorrect-password prompt fires again. - /plugins/installed returns the manifest's icon (string only). - /starlark/apps/<id>/toggle coerces `enabled` and delegates to _toggle_starlark_app (disk before memory, no KeyError, "false" is false). - /api/v3/ JSON GETs are sent Cache-Control: no-store; non-JSON keeps 5s. - ScrollHelper.set_scrolling_image converts non-RGB input (alpha onto black); create/set_scrolling_image reset last_update_time like reset_scroll. - LogoHelper backs off a failed download per path for MISSING_LOGO_RECHECK_SECONDS; cleared on invalidate/clear_cache. - refresh_placeholder_timestamp saves atomically. - FontManager.clear_cache / _clear_plugin_font_cache bump cache_generation. - Odds manager: per-game logs to DEBUG; JSON decode error caught before RequestException (same cooldown). - element_style mangled continuations; startup validator skips null plugin blocks and reuses the controller's discovery. - src/common/README lists frame_timing, json_body, render_gate. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
"""/api/v3 JSON GETs must not be served from the browser cache.
|
||||
|
||||
They carried ``Cache-Control: private, max-age=5``, so a fetch() made right
|
||||
after an install, a toggle or a Wi-Fi connect could be answered with the copy
|
||||
from before it. Non-JSON files served through the API keep the short cache.
|
||||
"""
|
||||
|
||||
from flask import Flask, Response, jsonify
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
import web_interface.app as web_app
|
||||
# The real after_request hook on a throwaway app, so the header logic is
|
||||
# tested without depending on any real endpoint's managers.
|
||||
app = Flask(__name__)
|
||||
app.after_request(web_app.add_security_headers)
|
||||
app.add_url_rule('/api/v3/probe/json', 'probe_json',
|
||||
lambda: jsonify({'status': 'success'}))
|
||||
app.add_url_rule('/api/v3/probe/file', 'probe_file',
|
||||
lambda: Response('body{}', mimetype='text/css'))
|
||||
app.add_url_rule('/static/v3/probe.css', 'probe_static',
|
||||
lambda: Response('body{}', mimetype='text/css'))
|
||||
with app.test_client() as c:
|
||||
yield c
|
||||
|
||||
|
||||
def test_json_gets_are_not_stored(client):
|
||||
resp = client.get('/api/v3/probe/json')
|
||||
assert resp.headers['Cache-Control'] == 'no-store'
|
||||
|
||||
|
||||
def test_non_json_files_keep_the_short_cache(client):
|
||||
resp = client.get('/api/v3/probe/file')
|
||||
assert resp.headers['Cache-Control'] == 'private, max-age=5, must-revalidate'
|
||||
|
||||
|
||||
def test_static_assets_are_still_long_cached(client):
|
||||
resp = client.get('/static/v3/probe.css')
|
||||
assert 'immutable' in resp.headers.get('Cache-Control', '')
|
||||
|
||||
|
||||
def test_the_real_app_marks_an_api_json_answer_no_store():
|
||||
import web_interface.app as web_app
|
||||
web_app.app.config['TESTING'] = True
|
||||
with web_app.app.test_client() as c:
|
||||
resp = c.get('/api/v3/no-such-endpoint-for-cache-test')
|
||||
assert resp.mimetype == 'application/json'
|
||||
assert resp.headers['Cache-Control'] == 'no-store'
|
||||
@@ -0,0 +1,116 @@
|
||||
"""POST /api/v3/starlark/apps/<app_id>/toggle shares _toggle_starlark_app.
|
||||
|
||||
The route carried its own copy of the toggle with the bugs the shared helper
|
||||
had already fixed: it changed the loaded app before the disk write (so a
|
||||
failed save left the two disagreeing), indexed ``manifest['apps'][app_id]``
|
||||
(a KeyError, answered as a 500, for a loaded app with no on-disk entry), and
|
||||
stored ``"false"`` -- a truthy string -- as the new state.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
PKG = 'web_interface.blueprints.api_v3'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
from web_interface.app import app
|
||||
app.config['TESTING'] = True
|
||||
with app.test_client() as c:
|
||||
yield c
|
||||
|
||||
|
||||
def _loaded_plugin(enabled=True, save_ok=True, disk=None):
|
||||
app = MagicMock()
|
||||
app.manifest = {'enabled': enabled}
|
||||
app.is_enabled.return_value = enabled
|
||||
plugin = MagicMock()
|
||||
plugin.apps = {'clock': app}
|
||||
disk = {'apps': {}} if disk is None else disk
|
||||
|
||||
def _update(fn):
|
||||
if not save_ok:
|
||||
return False
|
||||
fn(disk)
|
||||
return True
|
||||
plugin._update_manifest_safe.side_effect = _update
|
||||
return plugin, app, disk
|
||||
|
||||
|
||||
def _post(client, body):
|
||||
return client.post('/api/v3/starlark/apps/clock/toggle', json=body)
|
||||
|
||||
|
||||
class TestLoadedApp:
|
||||
def test_a_missing_disk_entry_is_created_not_a_500(self, client):
|
||||
plugin, app, disk = _loaded_plugin(enabled=True)
|
||||
with patch(f'{PKG}._get_starlark_plugin', return_value=plugin):
|
||||
resp = _post(client, {'enabled': False})
|
||||
body = resp.get_json()
|
||||
assert resp.status_code == 200, body
|
||||
assert body == {'status': 'success', 'message': body['message'], 'enabled': False}
|
||||
assert disk['apps']['clock']['enabled'] is False
|
||||
assert app.manifest['enabled'] is False
|
||||
|
||||
def test_a_failed_save_leaves_the_loaded_app_alone(self, client):
|
||||
plugin, app, _ = _loaded_plugin(enabled=True, save_ok=False)
|
||||
with patch(f'{PKG}._get_starlark_plugin', return_value=plugin):
|
||||
resp = _post(client, {'enabled': False})
|
||||
assert resp.status_code == 500
|
||||
assert resp.get_json()['status'] == 'error'
|
||||
assert app.manifest['enabled'] is True
|
||||
|
||||
@pytest.mark.parametrize('sent', ['false', 'False', 0, '0'])
|
||||
def test_a_false_string_or_zero_disables(self, client, sent):
|
||||
plugin, app, disk = _loaded_plugin(enabled=True)
|
||||
with patch(f'{PKG}._get_starlark_plugin', return_value=plugin):
|
||||
resp = _post(client, {'enabled': sent})
|
||||
assert resp.get_json()['enabled'] is False
|
||||
assert disk['apps']['clock']['enabled'] is False
|
||||
|
||||
def test_an_unrecognised_value_is_refused(self, client):
|
||||
plugin, app, disk = _loaded_plugin(enabled=True)
|
||||
with patch(f'{PKG}._get_starlark_plugin', return_value=plugin):
|
||||
resp = _post(client, {'enabled': 'maybe'})
|
||||
assert resp.status_code == 400
|
||||
assert disk == {'apps': {}}
|
||||
|
||||
def test_no_enabled_flips_the_current_state(self, client):
|
||||
plugin, app, disk = _loaded_plugin(enabled=True)
|
||||
with patch(f'{PKG}._get_starlark_plugin', return_value=plugin):
|
||||
resp = _post(client, {})
|
||||
assert resp.get_json()['enabled'] is False
|
||||
|
||||
|
||||
class TestStandalone:
|
||||
def _run(self, client, body, manifest):
|
||||
written = {}
|
||||
# The real lock is fcntl-based (Linux only); the toggle logic is what
|
||||
# is under test here.
|
||||
with patch(f'{PKG}._get_starlark_plugin', return_value=None), \
|
||||
patch(f'{PKG}._starlark_manifest_lock', contextlib.nullcontext), \
|
||||
patch(f'{PKG}._read_starlark_manifest', return_value=manifest), \
|
||||
patch(f'{PKG}._write_starlark_manifest',
|
||||
side_effect=lambda m: written.update(m) or True):
|
||||
resp = _post(client, body)
|
||||
return resp, written
|
||||
|
||||
def test_toggle_is_written(self, client):
|
||||
resp, written = self._run(client, {'enabled': 'false'},
|
||||
{'apps': {'clock': {'enabled': True}}})
|
||||
assert resp.get_json()['enabled'] is False
|
||||
assert written['apps']['clock']['enabled'] is False
|
||||
|
||||
def test_no_enabled_flips_the_manifest_state(self, client):
|
||||
resp, _ = self._run(client, {}, {'apps': {'clock': {'enabled': False}}})
|
||||
assert resp.get_json()['enabled'] is True
|
||||
|
||||
@pytest.mark.parametrize('body', [{}, {'enabled': True}])
|
||||
def test_an_unknown_app_is_404(self, client, body):
|
||||
resp, written = self._run(client, body, {'apps': {}})
|
||||
assert resp.status_code == 404
|
||||
assert 'clock' in resp.get_json()['message']
|
||||
assert written == {}
|
||||
Reference in New Issue
Block a user