mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-10 09:06:36 +00:00
Merge remote-tracking branch 'origin/main' into claude/scan-order-compensation
# Conflicts: # CHANGELOG.md
This commit is contained in:
+42
-13
@@ -92,7 +92,7 @@ class TestGet:
|
||||
helper.session.get.assert_not_called()
|
||||
rate_spy.assert_not_called()
|
||||
|
||||
def test_cache_miss_fetches_and_caches_without_ttl(self, helper, cache):
|
||||
def test_cache_miss_fetches_and_caches_with_ttl(self, helper, cache):
|
||||
cache.get.return_value = None
|
||||
helper.session.get = Mock(return_value=_make_response({'a': 1}))
|
||||
|
||||
@@ -100,9 +100,47 @@ class TestGet:
|
||||
cache_ttl=999)
|
||||
|
||||
assert result == {'a': 1}
|
||||
# Pin the ttl-dropped contract: CacheManager.set is called with
|
||||
# (key, data) only — the cache_ttl argument is discarded.
|
||||
cache.set.assert_called_once_with('k', {'a': 1})
|
||||
cache.set.assert_called_once_with('k', {'a': 1}, ttl=999)
|
||||
|
||||
def test_set_cache_passes_ttl(self, helper, cache):
|
||||
helper.set_cache('k', {'a': 1}, ttl=42)
|
||||
cache.set.assert_called_once_with('k', {'a': 1}, ttl=42)
|
||||
|
||||
|
||||
class TestCacheLifetimeWithRealCacheManager:
|
||||
"""cache_ttl decides how long a response is reused, in both directions:
|
||||
past CacheManager's 300-second default read age, and not beyond it."""
|
||||
|
||||
@pytest.fixture
|
||||
def real_cache(self, tmp_path):
|
||||
from unittest.mock import patch
|
||||
from src.cache_manager import CacheManager
|
||||
with patch('src.cache_manager.CacheManager._get_writable_cache_dir',
|
||||
return_value=str(tmp_path)):
|
||||
cache = CacheManager()
|
||||
yield cache
|
||||
# Releases the class-wide cleanup-thread claim on this directory,
|
||||
# which would otherwise leak into test_cache_cleanup_thread_ownership.
|
||||
cache.stop_cleanup_thread()
|
||||
|
||||
def _fetch_twice(self, real_cache, monkeypatch, ttl, elapsed):
|
||||
helper = APIHelper(cache_manager=real_cache)
|
||||
helper.set_rate_limit(0)
|
||||
helper.session.get = Mock(side_effect=[_make_response({'n': 1}),
|
||||
_make_response({'n': 2})])
|
||||
now = [1_000_000.0]
|
||||
monkeypatch.setattr('src.cache.memory_cache.time.time', lambda: now[0])
|
||||
monkeypatch.setattr('src.cache.disk_cache.time.time', lambda: now[0])
|
||||
monkeypatch.setattr('src.cache_manager.time.time', lambda: now[0])
|
||||
helper.get('https://example.com/api', cache_key='lifetime_test', cache_ttl=ttl)
|
||||
now[0] += elapsed
|
||||
return helper.get('https://example.com/api', cache_key='lifetime_test', cache_ttl=ttl)
|
||||
|
||||
def test_long_ttl_outlives_the_default_read_age(self, real_cache, monkeypatch):
|
||||
assert self._fetch_twice(real_cache, monkeypatch, ttl=3600, elapsed=1000) == {'n': 1}
|
||||
|
||||
def test_short_ttl_expires(self, real_cache, monkeypatch):
|
||||
assert self._fetch_twice(real_cache, monkeypatch, ttl=60, elapsed=120) == {'n': 2}
|
||||
|
||||
def test_request_exception_returns_none_and_caches_nothing(
|
||||
self, helper, cache):
|
||||
@@ -223,15 +261,6 @@ class TestClearCache:
|
||||
|
||||
manager.clear_cache.assert_called_once_with()
|
||||
|
||||
def test_no_pattern_falls_back_to_clear(self):
|
||||
manager = types.SimpleNamespace(clear=Mock())
|
||||
helper = APIHelper(cache_manager=manager)
|
||||
helper.set_rate_limit(0)
|
||||
|
||||
helper.clear_cache()
|
||||
|
||||
manager.clear.assert_called_once_with()
|
||||
|
||||
def test_no_pattern_manager_without_any_clear_is_noop(self):
|
||||
helper = APIHelper(cache_manager=object())
|
||||
helper.set_rate_limit(0)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""GET /api/v3/health: the plugin count is real, and a failed check is logged.
|
||||
|
||||
The plugin check counted ``plugin_manager.get_available_plugins()``, which
|
||||
PluginManager does not have; a hasattr guard turned that into a permanent 0.
|
||||
Each check that fails answers "see logs for details", so it has to log.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||
|
||||
URL = "/api/v3/health"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_systemctl(monkeypatch):
|
||||
monkeypatch.setattr("web_interface.blueprints.api_v3.misc._get_display_service_status",
|
||||
lambda: {"active": True})
|
||||
|
||||
|
||||
def _checks(client):
|
||||
response = client.get(URL)
|
||||
assert response.status_code == 200, response.get_json()
|
||||
return response.get_json()["data"]["checks"]
|
||||
|
||||
|
||||
def test_plugin_count_is_the_number_of_discovered_plugins(api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_manager.plugin_manifests = {
|
||||
"clock": {"id": "clock"}, "weather": {"id": "weather"}, "stocks": {"id": "stocks"},
|
||||
}
|
||||
|
||||
check = _checks(api_v3_client)["plugin_system"]
|
||||
|
||||
assert check == {"status": "operational", "plugin_count": 3}
|
||||
|
||||
|
||||
def test_plugin_count_discovers_when_nothing_is_discovered_yet(api_v3_client, api_v3_module):
|
||||
pm = api_v3_module.api_v3.plugin_manager
|
||||
pm.plugin_manifests = {}
|
||||
|
||||
def discover():
|
||||
pm.plugin_manifests = {"clock": {"id": "clock"}}
|
||||
pm.discover_plugins.side_effect = discover
|
||||
|
||||
assert _checks(api_v3_client)["plugin_system"]["plugin_count"] == 1
|
||||
|
||||
|
||||
def test_a_failed_config_check_is_logged(api_v3_client, api_v3_module, caplog):
|
||||
api_v3_module.api_v3.config_manager.load_config.side_effect = OSError("disk gone")
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
check = _checks(api_v3_client)["config_file"]
|
||||
|
||||
assert check["error"] == "see logs for details"
|
||||
logged = [r for r in caplog.records if "config file" in r.getMessage()]
|
||||
assert logged and logged[0].exc_info and "disk gone" in str(logged[0].exc_info[1])
|
||||
|
||||
|
||||
def test_a_failed_plugin_check_is_logged(api_v3_client, api_v3_module, caplog, monkeypatch):
|
||||
def boom():
|
||||
raise RuntimeError("manifests unreadable")
|
||||
monkeypatch.setattr("web_interface.blueprints.api_v3.misc._discovered_plugin_manifests", boom)
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
check = _checks(api_v3_client)["plugin_system"]
|
||||
|
||||
assert check["status"] == "error"
|
||||
logged = [r for r in caplog.records if "count plugins" in r.getMessage()]
|
||||
assert logged and logged[0].exc_info
|
||||
|
||||
|
||||
def test_a_failed_hardware_check_is_logged(api_v3_client, api_v3_module, caplog, monkeypatch):
|
||||
def getmtime(_path):
|
||||
raise PermissionError("denied")
|
||||
fake_os = SimpleNamespace(path=SimpleNamespace(exists=lambda _p: True, getmtime=getmtime))
|
||||
monkeypatch.setattr("web_interface.blueprints.api_v3.misc.os", fake_os)
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
check = _checks(api_v3_client)["hardware"]
|
||||
|
||||
assert check["status"] == "unknown"
|
||||
logged = [r for r in caplog.records if "snapshot" in r.getMessage()]
|
||||
assert logged and logged[0].exc_info
|
||||
@@ -0,0 +1,68 @@
|
||||
"""GET /plugins/health/<id> and /plugins/metrics/<id> read the display
|
||||
service's latest state, not the web process's first snapshot.
|
||||
|
||||
The display service writes health and metrics to the shared cache; the web
|
||||
process only reads them. Its tracker and monitor keep what they read first in
|
||||
memory, so without ``force_reload`` the per-plugin routes kept answering with
|
||||
that first read while the list routes (which pass it) moved on.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from src.plugin_system.plugin_health import PluginHealthTracker # noqa: E402
|
||||
from src.plugin_system.resource_monitor import PluginResourceMonitor # noqa: E402
|
||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||
|
||||
|
||||
class SharedCache:
|
||||
"""The on-disk cache both processes see, reduced to a dict."""
|
||||
|
||||
def __init__(self):
|
||||
self.entries = {}
|
||||
|
||||
def get(self, key, max_age=None, memory_ttl=None):
|
||||
return self.entries.get(key)
|
||||
|
||||
def set(self, key, value, *args, **kwargs):
|
||||
self.entries[key] = value
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shared_cache(api_v3_module):
|
||||
cache = SharedCache()
|
||||
pm = api_v3_module.api_v3.plugin_manager
|
||||
pm.health_tracker = PluginHealthTracker(cache)
|
||||
pm.resource_monitor = PluginResourceMonitor(cache)
|
||||
return cache
|
||||
|
||||
|
||||
def test_health_reflects_failures_recorded_after_the_first_read(api_v3_client, shared_cache):
|
||||
first = api_v3_client.get("/api/v3/plugins/health/weather").get_json()["data"]
|
||||
assert first["total_failures"] == 0
|
||||
|
||||
display_side = PluginHealthTracker(shared_cache)
|
||||
display_side.record_failure("weather", RuntimeError("api down"))
|
||||
display_side.record_failure("weather", RuntimeError("api down"))
|
||||
|
||||
later = api_v3_client.get("/api/v3/plugins/health/weather").get_json()["data"]
|
||||
assert later["total_failures"] == 2
|
||||
|
||||
|
||||
def test_metrics_reflect_calls_recorded_after_the_first_read(api_v3_client, shared_cache):
|
||||
first = api_v3_client.get("/api/v3/plugins/metrics/weather").get_json()["data"]
|
||||
assert first["call_count"] == 0
|
||||
|
||||
shared_cache.set("plugin_metrics:weather", {
|
||||
"memory_mb": 12.5, "cpu_percent": 3.0, "execution_time": 0.2,
|
||||
"call_count": 40, "total_execution_time": 8.0,
|
||||
"max_execution_time": 0.5, "min_execution_time": 0.1,
|
||||
"last_update_time": 1000.0,
|
||||
})
|
||||
|
||||
later = api_v3_client.get("/api/v3/plugins/metrics/weather").get_json()["data"]
|
||||
assert later["call_count"] == 40
|
||||
@@ -66,12 +66,16 @@ class TestRefreshPluginStore:
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.parametrize("key", ["fetch_commit_info", "fetch_latest_versions"])
|
||||
def test_either_commit_info_key_extends_the_message(
|
||||
def test_commit_info_flag_claims_no_refresh_it_does_not_do(
|
||||
self, api_v3_client, api_v3_module, key):
|
||||
# fetch_latest_versions is the older spelling; both must work.
|
||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry.return_value = {"plugins": []}
|
||||
# The route only re-downloads the registry. It used to append "(with
|
||||
# refreshed commit metadata from GitHub)" for either flag without
|
||||
# fetching any.
|
||||
store = api_v3_module.api_v3.plugin_store_manager
|
||||
store.fetch_registry.return_value = {"plugins": [{"id": "a"}]}
|
||||
response = api_v3_client.post(self.URL, json={key: True})
|
||||
assert "commit metadata" in response.get_json()["message"]
|
||||
assert response.get_json()["message"] == "Plugin store refreshed"
|
||||
store.fetch_registry.assert_called_once_with(force_refresh=True)
|
||||
|
||||
def test_message_stays_plain_without_the_flag(self, api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_store_manager.fetch_registry.return_value = {"plugins": []}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""POST /config/main refuses a malformed Vegas plugin order or exclusion list.
|
||||
|
||||
Both were parsed with ``except JSONDecodeError: ... = []``, so a bad value
|
||||
cleared the saved list and answered 200. They now fail the save with a 400,
|
||||
as plugin_rotation_order already did.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||
|
||||
URL = "/api/v3/config/main"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def saved(api_v3_module):
|
||||
config = {"display": {"vegas_scroll": {"plugin_order": ["clock", "weather"],
|
||||
"excluded_plugins": ["stocks"]}}}
|
||||
cm = api_v3_module.api_v3.config_manager
|
||||
cm.load_config.return_value = config
|
||||
cm.save_config_atomic.return_value.status.value = 'success'
|
||||
return cm
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["vegas_plugin_order", "vegas_excluded_plugins"])
|
||||
@pytest.mark.parametrize("value", ["[not json", '{"a": 1}', "[1, 2]", 7])
|
||||
def test_malformed_list_is_refused_and_nothing_is_saved(api_v3_client, saved, field, value):
|
||||
response = api_v3_client.post(URL, json={field: value})
|
||||
|
||||
assert response.status_code == 400, response.get_json()
|
||||
assert field in response.get_json()["message"]
|
||||
saved.save_config_atomic.assert_not_called()
|
||||
saved.save_config.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ['["weather", "clock"]', ["weather", "clock"]])
|
||||
def test_json_text_or_array_is_stored(api_v3_client, saved, value):
|
||||
response = api_v3_client.post(URL, json={"vegas_plugin_order": value})
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
stored = saved.save_config_atomic.call_args.args[0]
|
||||
assert stored["display"]["vegas_scroll"]["plugin_order"] == ["weather", "clock"]
|
||||
assert stored["display"]["vegas_scroll"]["excluded_plugins"] == ["stocks"]
|
||||
|
||||
|
||||
def test_rotation_order_keeps_its_messages(api_v3_client, saved):
|
||||
response = api_v3_client.post(URL, json={"plugin_rotation_order": "[oops"})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.get_json()["message"] == "plugin_rotation_order must be valid JSON"
|
||||
@@ -408,6 +408,14 @@ class TestAutoEnableApMode:
|
||||
assert response.status_code == 400
|
||||
assert "auto_enable_ap_mode" not in wifi_manager.config
|
||||
|
||||
def test_a_failed_save_is_reported(self, api_v3_client, wifi_manager):
|
||||
# wifi_config.json left owned by root is the usual cause.
|
||||
wifi_manager.config = {}
|
||||
wifi_manager._save_config.return_value = False
|
||||
response = api_v3_client.post(self.URL, json={"auto_enable_ap_mode": False})
|
||||
assert response.status_code == 500
|
||||
assert response.get_json()["status"] == "error"
|
||||
|
||||
|
||||
class TestRadioEnabledAndForceAcceptIntegers:
|
||||
"""`{"enabled": 1}` / `{"enabled": 0}` used to be mishandled: the old
|
||||
|
||||
@@ -362,6 +362,3 @@ class TestPriorityIsAcceptedAndIgnored:
|
||||
rid = service.submit_fetch_request(
|
||||
"nfl", 2026, "http://example.invalid/x", cache_key="k", priority=5)
|
||||
assert service.get_result(rid).cached is True
|
||||
|
||||
def test_statistics_still_report_an_empty_queue(self, service):
|
||||
assert service.get_statistics()["queue_size"] == 0
|
||||
|
||||
@@ -180,6 +180,31 @@ def test_create_backup_manifest(project: Path, tmp_path: Path) -> None:
|
||||
assert set(manifest["contents"]) >= {"config", "secrets", "wifi", "fonts", "plugin_uploads", "plugins"}
|
||||
|
||||
|
||||
def test_manifest_version_is_the_core_release(project: Path, tmp_path: Path) -> None:
|
||||
"""Not a git sha or a truncated "ref: refs/he..." read from .git/HEAD."""
|
||||
from src import __version__
|
||||
git = project / ".git"
|
||||
git.mkdir()
|
||||
(git / "HEAD").write_text("ref: refs/heads/some-branch-that-is-not-there\n", encoding="utf-8")
|
||||
zip_path = create_backup(project, output_dir=tmp_path / "exports")
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
manifest = json.loads(zf.read("manifest.json"))
|
||||
assert manifest["ledmatrix_version"] == __version__
|
||||
|
||||
|
||||
def test_installed_plugins_come_from_the_configured_directory(tmp_path: Path) -> None:
|
||||
root = tmp_path / "proj"
|
||||
(root / "config").mkdir(parents=True)
|
||||
(root / "config" / "config.json").write_text(
|
||||
json.dumps({"plugin_system": {"plugins_directory": "plugins"}}), encoding="utf-8")
|
||||
plugin_dir = root / "plugins" / "dev-plugin"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
(plugin_dir / "manifest.json").write_text(
|
||||
json.dumps({"id": "dev-plugin", "version": "0.3.0"}), encoding="utf-8")
|
||||
|
||||
assert [p["plugin_id"] for p in list_installed_plugins(root)] == ["dev-plugin"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -342,7 +342,6 @@ class TestLoadConfiguration:
|
||||
'base_odds_manager': {
|
||||
'update_interval': 100,
|
||||
'timeout': 5,
|
||||
'cache_ttl': 42,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,7 +351,6 @@ class TestLoadConfiguration:
|
||||
# Key/attr mismatch pin: the config key is 'timeout' but the
|
||||
# attribute is request_timeout.
|
||||
assert manager.request_timeout == 5
|
||||
assert manager.cache_ttl == 42
|
||||
|
||||
def test_get_config_raising_keeps_defaults(self, cache_manager):
|
||||
config_manager = MagicMock()
|
||||
@@ -362,4 +360,3 @@ class TestLoadConfiguration:
|
||||
|
||||
assert manager.update_interval == 3600
|
||||
assert manager.request_timeout == 5
|
||||
assert manager.cache_ttl == 1800
|
||||
|
||||
@@ -299,3 +299,48 @@ class TestDefaultMerging:
|
||||
|
||||
assert merged["enabled"] is False
|
||||
assert merged["display_duration"] == 60
|
||||
|
||||
|
||||
class TestMissingRequiredFields:
|
||||
"""One message per missing field, naming that field.
|
||||
|
||||
A manual ``required`` loop used to run after Draft7Validator, which already
|
||||
reports ``required``, so every missing top-level field was listed twice --
|
||||
and the validator's copy printed the schema's whole ``required`` list as
|
||||
if it were the field name.
|
||||
"""
|
||||
|
||||
SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"api_key": {"type": "string"},
|
||||
"city": {"type": "string"},
|
||||
"units": {"type": "string"},
|
||||
},
|
||||
"required": ["api_key", "city", "units"],
|
||||
}
|
||||
|
||||
def test_each_missing_field_is_reported_once_by_name(self):
|
||||
ok, errors = SchemaManager().validate_config_against_schema(
|
||||
{"units": "metric"}, self.SCHEMA, "test-plugin")
|
||||
|
||||
assert not ok
|
||||
assert errors == [
|
||||
"Field root: Missing required property 'api_key'",
|
||||
"Field root: Missing required property 'city'",
|
||||
]
|
||||
|
||||
def test_nested_missing_field_names_the_field_and_its_parent(self):
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"nfl": {
|
||||
"type": "object",
|
||||
"properties": {"api_key": {"type": "string"}},
|
||||
"required": ["api_key"],
|
||||
}},
|
||||
}
|
||||
ok, errors = SchemaManager().validate_config_against_schema(
|
||||
{"nfl": {}}, schema, "test-plugin")
|
||||
|
||||
assert not ok
|
||||
assert errors == ["Field 'nfl': Missing required property 'api_key'"]
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""GET /api/v3/display/current passes the snapshot PNG through untouched.
|
||||
|
||||
It used to PIL-decode the snapshot and re-encode it, which cost CPU on the Pi
|
||||
for no change in the picture, and it swallowed any read failure with
|
||||
``except Exception: pass``. It now sends the file's own bytes, the payload the
|
||||
/stream/display SSE stream sends, and logs a failed read.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from PIL import Image, PngImagePlugin
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from web_interface import display_preview # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
from web_interface.blueprints.api_v3 import api_v3
|
||||
monkeypatch.setattr(api_v3, 'config_manager', MagicMock(), raising=False)
|
||||
api_v3.config_manager.load_config.return_value = {}
|
||||
app = Flask(__name__)
|
||||
app.config['TESTING'] = True
|
||||
app.register_blueprint(api_v3, url_prefix='/api/v3')
|
||||
with app.test_client() as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def snapshot(tmp_path, monkeypatch):
|
||||
"""A snapshot PNG carrying a text chunk, which a PIL re-encode drops."""
|
||||
info = PngImagePlugin.PngInfo()
|
||||
info.add_text('written-by', 'display_manager')
|
||||
buffer = io.BytesIO()
|
||||
Image.new('RGB', (4, 2), (255, 0, 0)).save(buffer, format='PNG', pnginfo=info)
|
||||
path = tmp_path / 'led_matrix_preview.png'
|
||||
path.write_bytes(buffer.getvalue())
|
||||
monkeypatch.setattr(display_preview, 'SNAPSHOT_PATH', str(path))
|
||||
return path
|
||||
|
||||
|
||||
def _image(client):
|
||||
response = client.get('/api/v3/display/current')
|
||||
assert response.status_code == 200
|
||||
return response.get_json()['data']
|
||||
|
||||
|
||||
def test_the_snapshot_bytes_are_sent_as_they_are(client, snapshot):
|
||||
data = _image(client)
|
||||
assert base64.b64decode(data['image']) == snapshot.read_bytes()
|
||||
|
||||
|
||||
def test_route_and_stream_send_the_same_payload_keys(client, snapshot):
|
||||
data = _image(client)
|
||||
assert set(data) == set(display_preview.preview_payload(1, 1, None))
|
||||
|
||||
|
||||
def test_no_snapshot_is_a_null_image_without_a_warning(client, tmp_path, monkeypatch, caplog):
|
||||
monkeypatch.setattr(display_preview, 'SNAPSHOT_PATH', str(tmp_path / 'missing.png'))
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert _image(client)['image'] is None
|
||||
assert not [r for r in caplog.records if 'snapshot' in r.getMessage()]
|
||||
|
||||
|
||||
def test_an_unreadable_snapshot_is_logged(client, snapshot, monkeypatch, caplog):
|
||||
def denied(_path=None):
|
||||
raise PermissionError('denied')
|
||||
monkeypatch.setattr(display_preview, 'read_snapshot_base64', denied)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert _image(client)['image'] is None
|
||||
assert [r for r in caplog.records if 'snapshot' in r.getMessage() and r.exc_info]
|
||||
@@ -185,3 +185,35 @@ class TestLogoScale:
|
||||
def test_an_unusable_scale_is_ignored(self, logo, bad):
|
||||
helper = LogoHelper(display_width=64, display_height=32)
|
||||
assert helper.load_logo("AAA", logo, 32, 32, scale=bad).size == (32, 32)
|
||||
|
||||
def test_a_scale_the_schema_allows_is_applied(self, logo):
|
||||
"""The Scale field's maximum is honoured, not reset to 1.0."""
|
||||
from src.element_style import MAX_ELEMENT_SCALE
|
||||
helper = LogoHelper(display_width=64, display_height=32)
|
||||
big = helper.load_logo("AAA", logo, 4, 4, scale=MAX_ELEMENT_SCALE)
|
||||
assert big.size == (40, 40)
|
||||
|
||||
def test_a_scale_beyond_the_range_is_clamped(self, logo):
|
||||
from src.element_style import MAX_ELEMENT_SCALE, MIN_ELEMENT_SCALE
|
||||
helper = LogoHelper(display_width=64, display_height=32)
|
||||
assert helper.load_logo("AAA", logo, 4, 4, scale=MAX_ELEMENT_SCALE * 3).size == (40, 40)
|
||||
assert helper.load_logo("AAA", logo, 40, 40, scale=MIN_ELEMENT_SCALE / 2).size == (4, 4)
|
||||
|
||||
|
||||
class TestScaleCoercion:
|
||||
"""One range for the schema, element_scale and LogoHelper."""
|
||||
|
||||
def test_schema_bounds_are_the_clamp_bounds(self):
|
||||
from src.element_style import (MAX_ELEMENT_SCALE, MIN_ELEMENT_SCALE,
|
||||
_offset_block_from_spec)
|
||||
prop = _offset_block_from_spec("home_logo", {"scale": True})["properties"]["scale"]
|
||||
assert (prop["minimum"], prop["maximum"]) == (MIN_ELEMENT_SCALE, MAX_ELEMENT_SCALE)
|
||||
|
||||
@pytest.mark.parametrize("raw,expected", [
|
||||
(0.5, 0.5), (25, 10.0), (0.01, 0.1),
|
||||
(0, 1.0), (-2, 1.0), ("x", 1.0), (True, 1.0),
|
||||
(float("nan"), 1.0), (float("inf"), 1.0),
|
||||
])
|
||||
def test_element_scale_clamps_and_rejects(self, raw, expected):
|
||||
cfg = {"customization": {"layout": {"home_logo": {"scale": raw}}}}
|
||||
assert element_scale(cfg, "home_logo") == expected
|
||||
|
||||
@@ -124,6 +124,45 @@ class TestErrorRecording:
|
||||
assert aggregator._plugin_error_counts["plugin-a"]["ValueError"] == 2
|
||||
assert aggregator._plugin_error_counts["plugin-b"]["ValueError"] == 1
|
||||
|
||||
def test_stack_trace_recorded_outside_except_block(self):
|
||||
"""The trace comes from the exception, not from the handler in progress.
|
||||
|
||||
plugin_executor records exceptions caught on a worker thread after
|
||||
its except block has ended, where format_exc() only says
|
||||
"NoneType: None".
|
||||
"""
|
||||
def failing_plugin_update():
|
||||
raise ValueError("boom")
|
||||
|
||||
caught = []
|
||||
|
||||
def worker():
|
||||
try:
|
||||
failing_plugin_update()
|
||||
except ValueError as e:
|
||||
caught.append(e)
|
||||
|
||||
thread = threading.Thread(target=worker)
|
||||
thread.start()
|
||||
thread.join()
|
||||
|
||||
record = ErrorAggregator().record_error(caught[0], plugin_id="p")
|
||||
|
||||
assert "NoneType: None" not in record.stack_trace
|
||||
assert "failing_plugin_update" in record.stack_trace
|
||||
assert "ValueError: boom" in record.stack_trace
|
||||
|
||||
def test_record_error_leaves_caller_context_unchanged(self):
|
||||
"""LEDMatrixError context is merged into a copy of the caller's dict."""
|
||||
context = {"caller": "value"}
|
||||
error = PluginError("failed", plugin_id="p", context={"extra": 1})
|
||||
|
||||
record = ErrorAggregator().record_error(error, context=context)
|
||||
|
||||
assert context == {"caller": "value"}
|
||||
assert record.context["caller"] == "value"
|
||||
assert record.context["extra"] == 1
|
||||
|
||||
|
||||
class TestPatternDetection:
|
||||
"""Test error pattern detection."""
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""scripts/fix_perms/fix_web_permissions.sh must not undo the installer's hardening.
|
||||
|
||||
The script chowns the whole project to the web user. That used to include the
|
||||
two helpers /etc/sudoers.d/ledmatrix_web lets the web user run as root
|
||||
(safe_plugin_rm.sh, safe_pip_install.sh) -- a helper the web user owns is a
|
||||
root shell for anyone who can edit it -- and config_secrets.json, which lost
|
||||
the ledmatrix group first_time_install.sh gives it. After the chown the script
|
||||
now puts both back the way the installer's Steps 11 and 11.1 leave them.
|
||||
|
||||
The behavioural test runs the real script against a scratch copy of the
|
||||
project with `sudo`, `getent` and `journalctl` stubbed, and checks the order
|
||||
of what it asked sudo to do.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRIPT = ROOT / "scripts" / "fix_perms" / "fix_web_permissions.sh"
|
||||
LIB = ROOT / "scripts" / "install" / "lib_sudoers.sh"
|
||||
|
||||
|
||||
def _text(path):
|
||||
return path.read_text(encoding="utf-8", errors="replace").replace("\r\n", "\n")
|
||||
|
||||
|
||||
def _granted_helpers():
|
||||
helpers = set(re.findall(r"scripts/fix_perms/([\w.-]+\.sh) \*", _text(LIB)))
|
||||
assert helpers, "no fix_perms helper grant found in lib_sudoers.sh"
|
||||
return helpers
|
||||
|
||||
|
||||
def test_every_granted_helper_is_rehardened_after_the_chown():
|
||||
text = _text(SCRIPT)
|
||||
chown = text.index('sudo chown -R "$WEB_USER:$WEB_USER" "$PROJECT_DIR"')
|
||||
loop = re.search(r"for helper in ([^;]+); do\n(.*?)\ndone", text, re.S)
|
||||
assert loop, "no helper-hardening loop in fix_web_permissions.sh"
|
||||
assert "sudo chown root:root" in loop.group(2) and "sudo chmod 755" in loop.group(2)
|
||||
assert loop.start() > chown, "helpers are hardened before the chown that undoes it"
|
||||
assert _granted_helpers() <= set(loop.group(1).split())
|
||||
|
||||
|
||||
def test_no_longer_claims_to_configure_sudoers():
|
||||
text = _text(SCRIPT)
|
||||
assert "Configure sudoers for passwordless access" not in text
|
||||
assert "./configure_web_sudo.sh" not in text.replace("scripts/install/configure_web_sudo.sh", "")
|
||||
|
||||
|
||||
_STUB_SUDO = """#!/bin/bash
|
||||
printf '%s\\n' "$*" >> "$SUDO_LOG"
|
||||
# `sudo -n ...` probes and `sudo -u ...` tests: report failure, run nothing.
|
||||
case "$1" in -n|-u) exit 1 ;; esac
|
||||
exit 0
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32" or shutil.which("bash") is None,
|
||||
reason="needs a POSIX bash")
|
||||
def test_script_rehardens_helpers_and_secrets(tmp_path):
|
||||
project = tmp_path / "LED Matrix"
|
||||
(project / "scripts" / "fix_perms").mkdir(parents=True)
|
||||
(project / "config").mkdir()
|
||||
script = project / "scripts" / "fix_perms" / "fix_web_permissions.sh"
|
||||
script.write_text(_text(SCRIPT), encoding="utf-8")
|
||||
for helper in ("safe_plugin_rm.sh", "safe_pip_install.sh"):
|
||||
(project / "scripts" / "fix_perms" / helper).write_text("#!/bin/bash\n")
|
||||
(project / "config" / "config_secrets.json").write_text("{}\n")
|
||||
|
||||
stubs = tmp_path / "stubs"
|
||||
stubs.mkdir()
|
||||
for name, body in (("sudo", _STUB_SUDO),
|
||||
("getent", "#!/bin/sh\nexit 0\n"),
|
||||
("journalctl", "#!/bin/sh\nexit 1\n")):
|
||||
(stubs / name).write_text(body)
|
||||
(stubs / name).chmod(0o755)
|
||||
log = tmp_path / "sudo.log"
|
||||
env = dict(os.environ, SUDO_LOG=str(log),
|
||||
PATH=os.pathsep.join([str(stubs), os.environ.get("PATH", "")]))
|
||||
|
||||
result = subprocess.run(["bash", str(script)], input="y", env=env,
|
||||
capture_output=True, text=True)
|
||||
if os.geteuid() == 0:
|
||||
# The script refuses to run as root; that refusal is the whole test.
|
||||
assert result.returncode == 1 and "should not be run as root" in result.stdout
|
||||
return
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
|
||||
calls = log.read_text().splitlines()
|
||||
user = subprocess.run(["whoami"], capture_output=True, text=True).stdout.strip()
|
||||
chown_all = calls.index(f"chown -R {user}:{user} {project}")
|
||||
for helper in ("safe_plugin_rm.sh", "safe_pip_install.sh"):
|
||||
path = project / "scripts" / "fix_perms" / helper
|
||||
assert calls.index(f"chown root:root {path}") > chown_all, calls
|
||||
assert calls.index(f"chmod 755 {path}") > chown_all, calls
|
||||
secrets = project / "config" / "config_secrets.json"
|
||||
# The owner is the installed web unit's User= when there is one.
|
||||
owner = user
|
||||
unit = Path("/etc/systemd/system/ledmatrix-web.service")
|
||||
if unit.is_file():
|
||||
m = re.search(r"^User=(.*)$", unit.read_text(), re.M)
|
||||
if m and m.group(1):
|
||||
owner = m.group(1)
|
||||
assert calls.index(f"chown {owner}:ledmatrix {secrets}") > chown_all, calls
|
||||
assert calls.index(f"chmod 640 {secrets}") > chown_all, calls
|
||||
@@ -8,10 +8,14 @@ test here asserts observable behavior: returned font types, cache identity,
|
||||
fallback selection, and BDF native-size reading.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
|
||||
import freetype
|
||||
import pytest
|
||||
from PIL import ImageFont
|
||||
|
||||
from src.common.font_layout import resolve_asset_path
|
||||
from src.font_manager import FontManager
|
||||
|
||||
|
||||
@@ -132,3 +136,37 @@ class TestCacheLifecycle:
|
||||
fm.reload_config({})
|
||||
assert fm.cache_generation == gen_before + 1
|
||||
assert not fm.font_cache
|
||||
|
||||
|
||||
class TestPluginFonts:
|
||||
"""plugin:// sources resolve against the plugin's own directory, which
|
||||
by default lives under plugin-repos/, not a cwd-relative plugins/."""
|
||||
|
||||
MANIFEST = {"fonts": [{"family": "bundled", "source": "plugin://fonts/Bundled.ttf"}]}
|
||||
|
||||
@staticmethod
|
||||
def _plugin_with_font(root, name="my-plugin"):
|
||||
plugin_dir = root / name
|
||||
(plugin_dir / "fonts").mkdir(parents=True)
|
||||
(plugin_dir / "manifest.json").write_text(json.dumps({"id": "my-plugin"}))
|
||||
shutil.copy(resolve_asset_path("assets/fonts/PressStart2P-Regular.ttf"),
|
||||
plugin_dir / "fonts" / "Bundled.ttf")
|
||||
return plugin_dir
|
||||
|
||||
def test_font_resolves_under_the_given_plugin_dir(self, fm, tmp_path):
|
||||
plugin_dir = self._plugin_with_font(tmp_path / "plugin-repos")
|
||||
|
||||
assert fm.register_plugin_fonts("my-plugin", self.MANIFEST, plugin_dir=plugin_dir)
|
||||
|
||||
assert fm.font_catalog["my-plugin::bundled"] == str(plugin_dir / "fonts" / "Bundled.ttf")
|
||||
font = fm.resolve_font("x.y", "bundled", 8, plugin_id="my-plugin")
|
||||
assert isinstance(font, ImageFont.FreeTypeFont)
|
||||
|
||||
def test_without_a_plugin_dir_the_configured_directory_is_searched(self, tmp_path):
|
||||
plugins_root = tmp_path / "installed"
|
||||
plugin_dir = self._plugin_with_font(plugins_root, name="ledmatrix-my-plugin")
|
||||
fm = FontManager({"plugin_system": {"plugins_directory": str(plugins_root)}})
|
||||
|
||||
assert fm.register_plugin_fonts("my-plugin", self.MANIFEST)
|
||||
|
||||
assert fm.font_catalog["my-plugin::bundled"] == str(plugin_dir / "fonts" / "Bundled.ttf")
|
||||
|
||||
@@ -133,6 +133,26 @@ class TestAssetPathsIgnoreTheWorkingDirectory:
|
||||
rel = f"assets/fonts/{FOUR_BY_SIX}"
|
||||
assert FontManager._resolve_asset_path(rel) == resolve_asset_path(rel)
|
||||
|
||||
def test_font_overrides_file_lives_in_the_install_config(self, tmp_path, monkeypatch):
|
||||
from src.font_manager import FontManager
|
||||
monkeypatch.chdir(tmp_path)
|
||||
fm = FontManager({})
|
||||
assert fm.font_overrides_file == str(PROJECT_ROOT / "config" / "font_overrides.json")
|
||||
|
||||
def test_logo_placeholder_draws_with_the_bundled_font(self, tmp_path, monkeypatch):
|
||||
import src.logo_downloader as logo_downloader
|
||||
from src.logo_downloader import LogoDownloader
|
||||
loaded = []
|
||||
|
||||
def spy(font, size, **kwargs):
|
||||
loaded.append(font)
|
||||
return load_truetype(font, size, **kwargs)
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setattr(logo_downloader, "load_truetype", spy)
|
||||
assert LogoDownloader().create_placeholder_logo("AB", str(tmp_path))
|
||||
assert loaded == [str(PROJECT_ROOT / "assets" / "fonts" / PRESS_START)]
|
||||
|
||||
|
||||
class TestTheHarnessForkAgreesWithTheCore:
|
||||
"""The divergence that let the wrong rendering be blessed as golden.
|
||||
|
||||
@@ -78,3 +78,25 @@ class TestBackgroundDataServiceHeaders:
|
||||
assert 'yourusername' not in str(headers)
|
||||
finally:
|
||||
service.shutdown(wait=False)
|
||||
|
||||
|
||||
class TestResolverHeaders:
|
||||
def test_dynamic_team_resolver_sends_the_user_agent(self):
|
||||
from unittest.mock import patch
|
||||
from src.dynamic_team_resolver import DynamicTeamResolver
|
||||
DynamicTeamResolver._rankings_cache = {}
|
||||
DynamicTeamResolver._cache_timestamp = 0
|
||||
try:
|
||||
with patch('src.dynamic_team_resolver.requests.get',
|
||||
side_effect=RuntimeError("stop")) as get:
|
||||
DynamicTeamResolver().resolve_teams(["AP_TOP_5"])
|
||||
assert get.call_args.kwargs['headers']['User-Agent'] == USER_AGENT
|
||||
finally:
|
||||
DynamicTeamResolver._rankings_cache = {}
|
||||
DynamicTeamResolver._cache_timestamp = 0
|
||||
|
||||
def test_odds_manager_uses_the_shared_headers(self):
|
||||
from src.base_odds_manager import BaseOddsManager
|
||||
headers = BaseOddsManager(MagicMock()).session.headers
|
||||
for name, value in DEFAULT_HTTP_HEADERS.items():
|
||||
assert headers[name] == value
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""first_time_install.sh prints its completion summary before it reboots.
|
||||
|
||||
With -y (and so with the one-shot `curl | bash` installer, which always
|
||||
passes -y) the reboot used to be issued ~180 lines before the "Installation
|
||||
Complete / Web UI Access" summary. `reboot` returns at once and the script
|
||||
carried on printing while the system went down, so the SSH session usually
|
||||
dropped before the user saw the web UI address.
|
||||
|
||||
first_time_install.sh exits on anything but Raspberry Pi OS Trixie before it
|
||||
parses its arguments, so the behavioural test runs only the tail of the
|
||||
script -- from the summary to the end -- with systemctl, nmcli, hostname, ip
|
||||
and reboot stubbed.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
FIRST_TIME = ROOT / "first_time_install.sh"
|
||||
SUMMARY_START = 'echo "Installation Complete!"'
|
||||
|
||||
|
||||
def _text():
|
||||
return FIRST_TIME.read_text(encoding="utf-8").replace("\r\n", "\n")
|
||||
|
||||
|
||||
def test_every_reboot_comes_after_the_summary():
|
||||
text = _text()
|
||||
summary = text.index(SUMMARY_START)
|
||||
lines = text.splitlines()
|
||||
reboots = [i for i, line in enumerate(lines) if line.strip() == "reboot"]
|
||||
assert reboots, "no reboot call found"
|
||||
summary_line = text[:summary].count("\n")
|
||||
enjoy_line = text[:text.index('echo "Enjoy your LED Matrix display!"')].count("\n")
|
||||
assert all(i > enjoy_line > summary_line for i in reboots), (
|
||||
f"reboot at line(s) {[i + 1 for i in reboots]} runs before the summary "
|
||||
f"(line {summary_line + 1}) has finished printing")
|
||||
|
||||
|
||||
def _tail():
|
||||
"""The script from the summary header to the end, header rule included."""
|
||||
text = _text()
|
||||
start = text.rindex('echo "=========================================="', 0,
|
||||
text.index(SUMMARY_START))
|
||||
return text[start:]
|
||||
|
||||
|
||||
_POSIX = pytest.mark.skipif(sys.platform == "win32" or shutil.which("bash") is None,
|
||||
reason="needs a POSIX bash")
|
||||
|
||||
|
||||
def _run(tmp_path, env_extra, nmcli_active_line=True, stdin="", hostapd_active=False):
|
||||
stubs = tmp_path / "stubs"
|
||||
stubs.mkdir()
|
||||
log = tmp_path / "calls.log"
|
||||
active = 'echo "yes:HomeNet"' if nmcli_active_line else ":"
|
||||
hostapd = 'case "$*" in *"is-active --quiet hostapd"*) exit 0 ;; esac\n' if hostapd_active else ""
|
||||
bodies = {
|
||||
"reboot": f'#!/bin/sh\necho REBOOT-CALLED\necho reboot >> "{log}"\n',
|
||||
"systemctl": f"#!/bin/sh\n{hostapd}exit 3\n",
|
||||
"hostname": '#!/bin/sh\necho "192.168.1.50 fe80::1"\n',
|
||||
"ip": "#!/bin/sh\nexit 1\n",
|
||||
# device status -> one connected wifi device; device wifi -> active line
|
||||
"nmcli": ('#!/bin/sh\ncase "$*" in\n'
|
||||
' *"device status"*) echo "wlan0:wifi:connected" ;;\n'
|
||||
f' *"device wifi"*) {active} ;;\n'
|
||||
"esac\n"),
|
||||
}
|
||||
for name, body in bodies.items():
|
||||
(stubs / name).write_text(body)
|
||||
(stubs / name).chmod(0o755)
|
||||
script = "\n".join([
|
||||
"set -Eeuo pipefail",
|
||||
"on_error() { echo \"ERR-TRAP line $1\" >&2; exit 1; }",
|
||||
"trap 'on_error $LINENO' ERR",
|
||||
"PROJECT_ROOT_DIR=/home/pi/LEDMatrix",
|
||||
"ASSUME_YES=${ASSUME_YES:-0}",
|
||||
"SKIP_REBOOT_PROMPT=${SKIP_REBOOT_PROMPT:-0}",
|
||||
_tail(),
|
||||
])
|
||||
env = dict(os.environ, PATH=os.pathsep.join([str(stubs), "/usr/bin", "/bin"]), **env_extra)
|
||||
result = subprocess.run(["bash", "-c", script], env=env, input=stdin,
|
||||
capture_output=True, text=True)
|
||||
calls = log.read_text().splitlines() if log.exists() else []
|
||||
return result, calls
|
||||
|
||||
|
||||
@_POSIX
|
||||
@pytest.mark.parametrize("nmcli_active_line", [True, False], ids=["ssid", "no-ssid"])
|
||||
def test_assume_yes_prints_the_summary_then_reboots(tmp_path, nmcli_active_line):
|
||||
result, calls = _run(tmp_path, {"ASSUME_YES": "1"}, nmcli_active_line)
|
||||
out = result.stdout
|
||||
assert result.returncode == 0, out + result.stderr
|
||||
assert calls == ["reboot"]
|
||||
for text in ("Installation Complete!", "Web UI Access:", "http://192.168.1.50:5000",
|
||||
"Enjoy your LED Matrix display!"):
|
||||
assert out.index(text) < out.index("REBOOT-CALLED"), text
|
||||
assert "Password: ledmatrix123" not in out
|
||||
|
||||
|
||||
@_POSIX
|
||||
def test_setup_access_point_is_described_as_open(tmp_path):
|
||||
"""wifi_manager creates the setup AP with no security ("No password" on
|
||||
the panel); the summary used to print a password it does not have."""
|
||||
result, _ = _run(tmp_path, {"ASSUME_YES": "1"}, hostapd_active=True)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert "AP Mode is ACTIVE" in result.stdout
|
||||
assert "Open network, no password" in result.stdout
|
||||
assert "Password:" not in result.stdout
|
||||
|
||||
|
||||
@_POSIX
|
||||
def test_no_reboot_prompt_prints_the_summary_and_does_not_reboot(tmp_path):
|
||||
result, calls = _run(tmp_path, {"ASSUME_YES": "1", "SKIP_REBOOT_PROMPT": "1"})
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert calls == []
|
||||
assert "Enjoy your LED Matrix display!" in result.stdout
|
||||
assert "Skipping reboot prompt" in result.stdout
|
||||
|
||||
|
||||
@_POSIX
|
||||
@pytest.mark.parametrize("answer,expected", [("y", ["reboot"]), ("n", [])])
|
||||
def test_interactive_prompt_comes_after_the_summary(tmp_path, answer, expected):
|
||||
result, calls = _run(tmp_path, {}, stdin=answer)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert calls == expected
|
||||
out = result.stdout
|
||||
assert "Enjoy your LED Matrix display!" in out
|
||||
if expected:
|
||||
assert out.index("Enjoy your LED Matrix display!") < out.index("REBOOT-CALLED")
|
||||
@@ -31,8 +31,12 @@ import pytest
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from web_interface import system_metrics # noqa: E402
|
||||
from web_interface.system_metrics import collect_system_metrics # noqa: E402
|
||||
|
||||
MB = 1024 * 1024
|
||||
GB = 1024 * MB
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def metrics(monkeypatch):
|
||||
@@ -45,10 +49,11 @@ def metrics(monkeypatch):
|
||||
lambda _p: (_ for _ in ()).throw(OSError("no such mount")))
|
||||
else:
|
||||
monkeypatch.setattr(psutil, "disk_usage",
|
||||
lambda _p: SimpleNamespace(percent=disk_percent))
|
||||
lambda _p: SimpleNamespace(percent=disk_percent,
|
||||
total=32 * GB, used=4 * GB))
|
||||
monkeypatch.setattr(psutil, "virtual_memory",
|
||||
lambda: SimpleNamespace(percent=used_percent,
|
||||
available=available_bytes))
|
||||
lambda: SimpleNamespace(percent=used_percent, total=1024 * MB,
|
||||
used=600 * MB, available=available_bytes))
|
||||
return collect_system_metrics()
|
||||
return _collect
|
||||
|
||||
@@ -101,3 +106,18 @@ class TestEveryPredictiveFieldIsPresent:
|
||||
"memory_available_mb", "disk_used_percent"):
|
||||
assert field in m
|
||||
assert m["disk_used_percent"] is None
|
||||
assert all(m[key] is None for key in m if key != "cpu_temp")
|
||||
|
||||
|
||||
class TestUnavailableIsNull:
|
||||
"""One answer for "could not be read": None, never 0."""
|
||||
|
||||
def test_unreadable_temperature_is_null_not_zero_degrees(self, metrics, monkeypatch):
|
||||
monkeypatch.setattr(system_metrics, "_THERMAL_ZONE", "/nonexistent/thermal/temp")
|
||||
assert metrics()["cpu_temp"] is None
|
||||
|
||||
def test_readable_temperature_is_degrees_c(self, metrics, monkeypatch, tmp_path):
|
||||
zone = tmp_path / "temp"
|
||||
zone.write_text("48312\n")
|
||||
monkeypatch.setattr(system_metrics, "_THERMAL_ZONE", str(zone))
|
||||
assert metrics()["cpu_temp"] == 48.3
|
||||
|
||||
@@ -357,6 +357,33 @@ class TestRefreshPlaceholderTimestamp:
|
||||
assert refresh_placeholder_timestamp(tmp_path / "nope.png") is False
|
||||
|
||||
|
||||
class TestFailurePaths:
|
||||
def test_a_team_without_logos_is_a_failed_download(self, tmp_path):
|
||||
downloader = LogoDownloader()
|
||||
with patch.object(downloader, "fetch_single_team",
|
||||
return_value={"team": {"logos": []}}):
|
||||
assert downloader.download_missing_logo_for_team(
|
||||
"nfl", "1", "XYZ", tmp_path / "XYZ.png") is False
|
||||
|
||||
def test_placeholder_is_written_where_the_caller_looks(self, tmp_path):
|
||||
"""A path that is not <normalized abbreviation>.png (the plugin's own
|
||||
file naming, or an abbreviation normalize_abbreviation rewrites)
|
||||
still ends up holding the placeholder, so True means it exists."""
|
||||
logo_path = tmp_path / "TA&M.png"
|
||||
with patch.object(LogoDownloader, "download_logo", return_value=False):
|
||||
assert download_missing_logo(
|
||||
"ncaa_fb", "245", "TA&M", logo_path,
|
||||
logo_url="http://example/tamu.png") is True
|
||||
assert is_placeholder_logo(logo_path)
|
||||
assert not (tmp_path / "TAANDM.png").exists()
|
||||
|
||||
def test_placeholder_uses_the_placeholder_geometry(self, tmp_path):
|
||||
assert LogoDownloader().create_placeholder_logo("AB", str(tmp_path))
|
||||
with Image.open(tmp_path / "AB.png") as img:
|
||||
assert img.size == PLACEHOLDER_SIZE
|
||||
assert img.convert("RGBA").getpixel((0, 0)) == PLACEHOLDER_BG
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# download_logo: the download the scoreboard plugins actually use
|
||||
#
|
||||
|
||||
@@ -116,10 +116,14 @@ def test_values_of_the_wrong_type_fall_back_to_usable_defaults(bad):
|
||||
assert isinstance(getattr(metrics, field_name), (int, float)), \
|
||||
f"{field_name} came back as {getattr(metrics, field_name)!r}"
|
||||
|
||||
# The real proof: arithmetic on the loaded metrics must not explode.
|
||||
# The real proof: the arithmetic monitor_call and get_metrics_summary do
|
||||
# on the loaded metrics must not explode.
|
||||
metrics.call_count += 1
|
||||
metrics.total_execution_time += 0.5
|
||||
metrics.update_average_execution_time()
|
||||
metrics.max_execution_time = max(metrics.max_execution_time, 0.5)
|
||||
metrics.min_execution_time = min(metrics.min_execution_time, 0.5)
|
||||
metrics.memory_mb = max(metrics.memory_mb, 1.0)
|
||||
assert metrics.total_execution_time / metrics.call_count >= 0
|
||||
|
||||
|
||||
def test_a_numeric_string_is_accepted_rather_than_discarded():
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""/partials/<name> dispatch and the plugin web_ui page's directory lookup."""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from web_interface.blueprints import pages_v3 as module # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path, monkeypatch):
|
||||
plugin_manager = MagicMock()
|
||||
plugin_manager.plugins_dir = tmp_path
|
||||
monkeypatch.setattr(module.pages_v3, "plugin_manager", plugin_manager, raising=False)
|
||||
monkeypatch.setattr(module.pages_v3, "config_manager",
|
||||
MagicMock(load_config=lambda: {}), raising=False)
|
||||
app = Flask(__name__, template_folder=str(
|
||||
Path(module.__file__).resolve().parents[1] / "templates"))
|
||||
app.register_blueprint(module.pages_v3)
|
||||
return app.test_client()
|
||||
|
||||
|
||||
def test_every_tab_the_old_if_chain_served_is_still_served():
|
||||
assert set(module._PARTIAL_LOADERS) == {
|
||||
"overview", "general", "display", "durations", "schedule", "plugins",
|
||||
"fonts", "logs", "raw-json", "backup-restore", "wifi", "cache",
|
||||
"operation-history", "tools",
|
||||
}
|
||||
|
||||
|
||||
def test_an_unknown_partial_is_a_404(client):
|
||||
response = client.get("/partials/nope")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_a_failing_loader_is_one_500_that_names_the_partial(client, monkeypatch, caplog):
|
||||
def boom():
|
||||
raise RuntimeError("template exploded")
|
||||
monkeypatch.setitem(module._PARTIAL_LOADERS, "logs", boom)
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
response = client.get("/partials/logs")
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.get_data(as_text=True) == "Error loading partial"
|
||||
errors = [r for r in caplog.records if r.levelno >= logging.ERROR]
|
||||
assert len(errors) == 1
|
||||
assert errors[0].getMessage() == "Error loading partial logs"
|
||||
|
||||
|
||||
def test_web_ui_page_uses_the_ledmatrix_prefix_fallback(client, tmp_path):
|
||||
web_ui = tmp_path / "ledmatrix-radar" / "web_ui"
|
||||
web_ui.mkdir(parents=True)
|
||||
(web_ui / "panel.html").write_text("<p>radar panel</p>", encoding="utf-8")
|
||||
|
||||
response = client.get("/plugin-ui/radar/web-ui/panel.html")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "radar panel" in response.get_data(as_text=True)
|
||||
@@ -294,6 +294,31 @@ class TestValidateConfigFailure:
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestPluginFontRegistration:
|
||||
"""A manifest's fonts block is registered against the directory the
|
||||
plugin was loaded from, which plugin:// font sources are relative to."""
|
||||
|
||||
def test_plugin_dir_is_passed_to_the_font_manager(self, temp_plugin_dir, mock_managers):
|
||||
plugin_dir = temp_plugin_dir / "test-plugin"
|
||||
plugin_dir.mkdir()
|
||||
fonts = {"fonts": [{"family": "f", "source": "plugin://f.ttf"}]}
|
||||
manifest = {"id": "test-plugin", "name": "Test Plugin",
|
||||
"entry_point": "manager.py", "class_name": "TestPlugin",
|
||||
"fonts": fonts}
|
||||
|
||||
with patch('src.common.permission_utils.ensure_directory_permissions'):
|
||||
manager = PluginManager(plugins_dir=str(temp_plugin_dir), **mock_managers)
|
||||
manager.plugin_manifests["test-plugin"] = manifest
|
||||
with patch.object(manager.plugin_loader, 'load_plugin',
|
||||
return_value=(MagicMock(), MagicMock())):
|
||||
with patch.object(manager.plugin_loader, 'find_plugin_directory',
|
||||
return_value=plugin_dir):
|
||||
manager.load_plugin("test-plugin")
|
||||
|
||||
mock_managers["font_manager"].register_plugin_fonts.assert_called_once_with(
|
||||
"test-plugin", fonts, plugin_dir=plugin_dir)
|
||||
|
||||
|
||||
class TestPluginStateOnFailure:
|
||||
"""Test that plugin state is correctly set on various failures."""
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""reload_plugin re-reads the manifest from the plugin's actual directory.
|
||||
|
||||
It read ``plugins_dir / plugin_id / manifest.json``, but a plugin directory's
|
||||
name need not be the id its manifest declares -- discovery maps ids to
|
||||
directories for exactly that reason. For such a plugin the path did not exist,
|
||||
the re-read was skipped silently, and the reload kept the stale manifest.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugin_system.plugin_manager import PluginManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pm_with_renamed_dir(tmp_path):
|
||||
plugins_dir = tmp_path / "plugins"
|
||||
plugin_dir = plugins_dir / "stock-ticker-v2"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
manifest_path = plugin_dir / "manifest.json"
|
||||
manifest_path.write_text(json.dumps({"id": "stocks", "version": "1.0.0"}))
|
||||
pm = PluginManager(plugins_dir=str(plugins_dir))
|
||||
assert pm.discover_plugins() == ["stocks"]
|
||||
return pm, manifest_path
|
||||
|
||||
|
||||
def test_reload_picks_up_an_edited_manifest(pm_with_renamed_dir, monkeypatch):
|
||||
pm, manifest_path = pm_with_renamed_dir
|
||||
manifest_path.write_text(json.dumps({"id": "stocks", "version": "2.0.0"}))
|
||||
loaded = []
|
||||
monkeypatch.setattr(pm, "load_plugin", lambda pid: loaded.append(pid) or True)
|
||||
|
||||
assert pm.reload_plugin("stocks") is True
|
||||
assert pm.plugin_manifests["stocks"]["version"] == "2.0.0"
|
||||
assert loaded == ["stocks"]
|
||||
@@ -115,5 +115,22 @@ def test_get_state_info_is_a_consistent_snapshot():
|
||||
assert not inconsistent, f"observed a torn snapshot: {inconsistent[:1]}"
|
||||
|
||||
|
||||
def test_state_info_reports_only_what_something_records():
|
||||
"""No field that is always null.
|
||||
|
||||
``last_display`` was reported here, but nothing ever recorded a display()
|
||||
call, so it was null for every plugin. Its only reader is the web process,
|
||||
whose PluginManager never calls display(), so recording it in the display
|
||||
process could not have filled it either.
|
||||
"""
|
||||
manager = PluginStateManager()
|
||||
manager.set_state("clock", PluginState.ENABLED)
|
||||
manager.record_update("clock")
|
||||
|
||||
info = manager.get_state_info("clock")
|
||||
assert "last_display" not in info
|
||||
assert info["last_update"] is not None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main([__file__, "-v"]))
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Repository URL handling shared by the plugin store and saved repositories.
|
||||
|
||||
The store cleaned URLs with ``url.rstrip('/').replace('.git', '')`` in two
|
||||
places, which removes ``.git`` anywhere in the URL:
|
||||
``https://github.com/user/my.github.io`` became ``.../myhub.io``, so installing
|
||||
or browsing such a repository asked GitHub for one that does not exist.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugin_system.repo_urls import (
|
||||
github_api_headers, github_owner_repo, normalize_repo_url, same_repo,
|
||||
)
|
||||
from src.plugin_system.store_manager import PluginStoreManager
|
||||
|
||||
PAGES_REPO = "https://github.com/user/my.github.io"
|
||||
|
||||
|
||||
class TestNormalizeRepoUrl:
|
||||
@pytest.mark.parametrize("raw, expected", [
|
||||
(PAGES_REPO, PAGES_REPO),
|
||||
(PAGES_REPO + ".git", PAGES_REPO),
|
||||
("https://github.com/user/repo.git/", "https://github.com/user/repo"),
|
||||
(" https://github.com/user/repo/ ", "https://github.com/user/repo"),
|
||||
])
|
||||
def test_only_a_trailing_dot_git_is_removed(self, raw, expected):
|
||||
assert normalize_repo_url(raw) == expected
|
||||
|
||||
def test_same_repo_ignores_case_and_suffix(self):
|
||||
assert same_repo("https://github.com/Owner/Repo.git",
|
||||
"https://github.com/owner/repo/")
|
||||
assert not same_repo("https://github.com/owner/repo",
|
||||
"https://github.com/owner/other")
|
||||
|
||||
|
||||
class TestGithubOwnerRepo:
|
||||
@pytest.mark.parametrize("url, expected", [
|
||||
(PAGES_REPO + ".git", ("user", "my.github.io")),
|
||||
("https://www.github.com/owner/repo", ("owner", "repo")),
|
||||
("https://github.com/owner/repo/tree/main/plugins/x", ("owner", "repo")),
|
||||
])
|
||||
def test_github_urls(self, url, expected):
|
||||
assert github_owner_repo(url) == expected
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"https://github.com.example.org/owner/repo",
|
||||
"https://gitlab.com/owner/repo",
|
||||
"https://github.com/owner",
|
||||
"github.com/owner/repo",
|
||||
])
|
||||
def test_anything_else_is_not_a_github_repo(self, url):
|
||||
assert github_owner_repo(url) is None
|
||||
|
||||
def test_headers_carry_the_token_only_when_given(self):
|
||||
assert "Authorization" not in github_api_headers(None)
|
||||
assert github_api_headers("abc")["Authorization"] == "token abc"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path):
|
||||
return PluginStoreManager(
|
||||
plugins_dir=str(tmp_path / "plugins"),
|
||||
uninstalled_registry_path=str(tmp_path / "uninstalled.json"))
|
||||
|
||||
|
||||
def test_install_from_url_keeps_an_interior_dot_git(store, monkeypatch):
|
||||
cloned_from = []
|
||||
monkeypatch.setattr(store, "_install_via_git",
|
||||
lambda url, *a, **k: cloned_from.append(url))
|
||||
downloaded = []
|
||||
monkeypatch.setattr(store, "_install_via_download",
|
||||
lambda url, *a, **k: downloaded.append(url) or False)
|
||||
|
||||
result = store.install_from_url(PAGES_REPO + ".git")
|
||||
|
||||
assert result["success"] is False
|
||||
assert cloned_from == [PAGES_REPO]
|
||||
assert all(url.startswith(PAGES_REPO + "/archive/") for url in downloaded)
|
||||
|
||||
|
||||
def test_fetch_registry_from_url_asks_for_the_named_repository(store, monkeypatch):
|
||||
requested = []
|
||||
|
||||
def fake_get(url, **kwargs):
|
||||
requested.append(url)
|
||||
return MagicMock(status_code=404)
|
||||
|
||||
monkeypatch.setattr(store, "_http_get_with_retries", fake_get)
|
||||
|
||||
assert store.fetch_registry_from_url(PAGES_REPO) is None
|
||||
assert requested
|
||||
assert all(url.startswith("https://raw.githubusercontent.com/user/my.github.io/")
|
||||
for url in requested)
|
||||
@@ -93,6 +93,27 @@ class TestResourceLimits:
|
||||
with pytest.raises(ResourceLimitExceeded):
|
||||
mon.monitor_call("p", lambda: time.sleep(0.02))
|
||||
|
||||
def test_memory_limit_judges_each_call_on_its_own_growth(self):
|
||||
"""One expensive call must not fail every call after it.
|
||||
|
||||
The check used to compare the stored high-water mark, which never
|
||||
decreases, so after one call grew memory past the limit every later
|
||||
call raised too and the plugin never updated again.
|
||||
"""
|
||||
mon = PluginResourceMonitor(_cache(), enable_monitoring=False)
|
||||
mon.enable_monitoring = True # measure without needing psutil
|
||||
readings = iter([100.0, 200.0, # first call grows RSS by 100 MB
|
||||
200.0, 201.0]) # second call grows it by 1 MB
|
||||
mon._get_process_memory_mb = lambda: next(readings)
|
||||
mon._get_process_cpu_percent = lambda: 0.0
|
||||
mon.set_limits("p", ResourceLimits(max_memory_mb=50))
|
||||
|
||||
with pytest.raises(ResourceLimitExceeded):
|
||||
mon.monitor_call("p", lambda: None)
|
||||
assert mon.monitor_call("p", lambda: "ok") == "ok"
|
||||
# The high-water mark is still reported.
|
||||
assert mon.get_metrics("p").memory_mb == 100.0
|
||||
|
||||
def test_reset_metrics_clears_counts(self):
|
||||
cache = _cache()
|
||||
mon = PluginResourceMonitor(cache, enable_monitoring=False)
|
||||
|
||||
@@ -5,7 +5,7 @@ SavedRepositoriesManager contract.
|
||||
Covers: the three accepted on-disk load shapes (bare list, wrapped
|
||||
{"repositories": [...]}, anything else -> []) and that saves always write
|
||||
the bare-list form; add/remove/has round trips through a fresh manager;
|
||||
URL normalization post-fix (_clean_url strips only a TRAILING '.git' after
|
||||
URL normalization post-fix (normalize_repo_url strips only a TRAILING '.git' after
|
||||
trailing slashes — the old unanchored .replace('.git', '') mangled URLs
|
||||
like my.github.io); name derivation and registry-vs-single type
|
||||
classification (the ledmatrix-plugins check is lowercased, the
|
||||
|
||||
@@ -63,6 +63,15 @@ class TestValidateConfig:
|
||||
assert "Missing required configuration key: display" in errors
|
||||
assert "Missing required configuration key: timezone" in errors
|
||||
|
||||
@pytest.mark.parametrize("config,expected", [
|
||||
({'timezone': 'UTC'}, "Missing required configuration key: display"),
|
||||
({'display': {}, 'timezone': 'UTC'}, "Display configuration is empty"),
|
||||
])
|
||||
def test_a_missing_display_section_is_reported_once(self, good_cache, config, expected):
|
||||
validator = StartupValidator(make_config_manager(config))
|
||||
_, errors, _ = validator.validate_all()
|
||||
assert errors == [expected]
|
||||
|
||||
def test_config_error_does_not_propagate(self, good_cache):
|
||||
mgr = make_config_manager(GOOD_CONFIG)
|
||||
mgr.load_config.side_effect = ConfigError("bad json")
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""A repository whose only branch is neither main nor master still installs.
|
||||
|
||||
_install_via_git tries the candidate branches, then the repository's default
|
||||
branch -- but it returned None both for "every clone failed" and for "the
|
||||
default-branch clone succeeded". install_from_url took the None as failure,
|
||||
fell through to the archive download of main/master (which does not exist),
|
||||
and reported "Failed to clone or download repository" for a repository it had
|
||||
just cloned.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugin_system.store_manager import PluginStoreManager
|
||||
|
||||
pytestmark = pytest.mark.skipif(shutil.which("git") is None, reason="git not installed")
|
||||
|
||||
MANIFEST = {
|
||||
"id": "develop-only", "name": "Develop Only", "class_name": "P",
|
||||
"display_modes": ["develop_only"], "version": "1.0.0",
|
||||
}
|
||||
|
||||
|
||||
def _git(*args, cwd):
|
||||
subprocess.run(
|
||||
["git", "-c", "user.name=t", "-c", "user.email=t@example.invalid", *args],
|
||||
cwd=cwd, check=True, capture_output=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def develop_only_repo(tmp_path):
|
||||
repo = tmp_path / "upstream"
|
||||
repo.mkdir()
|
||||
_git("init", "-q", "-b", "develop", cwd=repo)
|
||||
(repo / "manifest.json").write_text(json.dumps(MANIFEST))
|
||||
(repo / "manager.py").write_text("class P: pass\n")
|
||||
_git("add", ".", cwd=repo)
|
||||
_git("commit", "-q", "-m", "init", cwd=repo)
|
||||
return repo.as_uri()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path, monkeypatch):
|
||||
mgr = PluginStoreManager(
|
||||
plugins_dir=str(tmp_path / "plugins"),
|
||||
uninstalled_registry_path=str(tmp_path / "uninstalled.json"))
|
||||
monkeypatch.setattr(mgr, "_install_dependencies", lambda *a, **k: True)
|
||||
downloads = []
|
||||
monkeypatch.setattr(mgr, "_install_via_download",
|
||||
lambda url, *a, **k: downloads.append(url) or False)
|
||||
mgr.downloads = downloads
|
||||
return mgr
|
||||
|
||||
|
||||
def test_a_default_branch_clone_reports_its_branch(store, develop_only_repo, tmp_path):
|
||||
target = tmp_path / "clone"
|
||||
assert store._install_via_git(develop_only_repo, target, ["main", "master"]) == "develop"
|
||||
assert (target / "manifest.json").exists()
|
||||
|
||||
|
||||
def test_a_failed_clone_reports_none(store, tmp_path):
|
||||
missing = (tmp_path / "no-such-repo").as_uri()
|
||||
target = tmp_path / "clone"
|
||||
assert store._install_via_git(missing, target, ["main"]) is None
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_install_from_url_installs_a_develop_only_repository(store, develop_only_repo):
|
||||
result = store.install_from_url(develop_only_repo)
|
||||
|
||||
assert result == {"success": True, "plugin_id": "develop-only",
|
||||
"name": "Develop Only", "branch": "develop"}
|
||||
assert (store.plugins_dir / "develop-only" / "manifest.json").exists()
|
||||
assert store.downloads == []
|
||||
@@ -0,0 +1,62 @@
|
||||
"""update_plugin must not borrow the enclosing LEDMatrix checkout's remote.
|
||||
|
||||
Plugins live in ``plugin-repos/`` inside the LEDMatrix git checkout. For a
|
||||
plugin installed from a ZIP (no ``.git`` of its own), ``git -C <plugin>``
|
||||
walks up to the LEDMatrix repository, and ``git config --local --get
|
||||
remote.origin.url`` answers with LEDMatrix's own URL. update_plugin then tried
|
||||
to "reinstall" the plugin from the LEDMatrix repository.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugin_system.store_manager import PluginStoreManager
|
||||
|
||||
pytestmark = pytest.mark.skipif(shutil.which("git") is None, reason="git not installed")
|
||||
|
||||
PLUGIN_ID = "zip-installed"
|
||||
PARENT_REMOTE = "https://github.com/example/LEDMatrix"
|
||||
|
||||
|
||||
def _git(*args, cwd):
|
||||
subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store_inside_checkout(tmp_path):
|
||||
checkout = tmp_path / "LEDMatrix"
|
||||
checkout.mkdir()
|
||||
_git("init", "-q", cwd=checkout)
|
||||
_git("remote", "add", "origin", PARENT_REMOTE, cwd=checkout)
|
||||
|
||||
plugins_dir = checkout / "plugin-repos"
|
||||
plugin_dir = plugins_dir / PLUGIN_ID
|
||||
plugin_dir.mkdir(parents=True)
|
||||
(plugin_dir / "manifest.json").write_text(json.dumps(
|
||||
{"id": PLUGIN_ID, "name": "Zip", "version": "1.0.0"}))
|
||||
|
||||
store = PluginStoreManager(
|
||||
plugins_dir=str(plugins_dir),
|
||||
uninstalled_registry_path=str(tmp_path / "uninstalled.json"))
|
||||
return store, plugin_dir
|
||||
|
||||
|
||||
def test_a_plugin_without_its_own_git_has_no_remote(store_inside_checkout, monkeypatch):
|
||||
store, plugin_dir = store_inside_checkout
|
||||
# The premise: git itself does report the parent's remote here.
|
||||
parent_view = subprocess.run(
|
||||
["git", "-C", str(plugin_dir), "config", "--local", "--get", "remote.origin.url"],
|
||||
capture_output=True, text=True)
|
||||
assert parent_view.stdout.strip() == PARENT_REMOTE
|
||||
|
||||
monkeypatch.setattr(store, "fetch_registry", lambda *a, **k: {"plugins": []})
|
||||
monkeypatch.setattr(store, "get_plugin_info", lambda *a, **k: None)
|
||||
install_calls = []
|
||||
monkeypatch.setattr(store, "install_from_url",
|
||||
lambda *a, **k: install_calls.append((a, k)) or {"success": True})
|
||||
|
||||
assert store.update_plugin(PLUGIN_ID) is False
|
||||
assert install_calls == []
|
||||
@@ -11,12 +11,16 @@ Four such calls were ungranted, all of them captive-portal teardown/setup:
|
||||
rfkill unblock wifi wifi_manager.py:1811
|
||||
mkdir -p .../dnsmasq-shared.d wifi_manager.py:922
|
||||
|
||||
The drop-in written into that directory was missing too: the literal
|
||||
`cp /tmp/ledmatrix-nm-dnsmasq.conf .../dnsmasq-shared.d/ledmatrix-captive.conf`
|
||||
and `rm -f` of the same file, so the directory was granted but not the file.
|
||||
|
||||
It goes unnoticed because a stock Raspberry Pi image ships
|
||||
/etc/sudoers.d/010_pi-nopasswd granting the default user
|
||||
`ALL=(ALL) NOPASSWD: ALL`, which satisfies every gap in both files. It only
|
||||
bites once that blanket rule is removed or the service runs as another user.
|
||||
|
||||
Scope, deliberately narrow: this pins the four commands above, each of which
|
||||
Scope, deliberately narrow: this pins the commands above, each of which
|
||||
can be written out literally. The portal makes further sudo calls whose
|
||||
arguments are built at runtime -- iptables and nft rules carrying an interface
|
||||
name and a port, `ip addr`, `ip link` -- and those cannot be granted safely
|
||||
@@ -50,6 +54,11 @@ REQUIRED = (
|
||||
("nft", "delete", "table", "ip", "ledmatrix"),
|
||||
("rfkill", "unblock", "wifi"),
|
||||
("mkdir", "-p", "/etc/NetworkManager/dnsmasq-shared.d"),
|
||||
# The drop-in that directory exists for, written and removed by
|
||||
# _write_nm_dnsmasq_captive_conf / _remove_nm_dnsmasq_captive_conf.
|
||||
("cp", "/tmp/ledmatrix-nm-dnsmasq.conf",
|
||||
"/etc/NetworkManager/dnsmasq-shared.d/ledmatrix-captive.conf"),
|
||||
("rm", "-f", "/etc/NetworkManager/dnsmasq-shared.d/ledmatrix-captive.conf"),
|
||||
)
|
||||
|
||||
#: Tools with an option that executes a program of the caller's choosing.
|
||||
|
||||
@@ -19,6 +19,7 @@ import pytest
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
FIRST_TIME = os.path.join(REPO_ROOT, "first_time_install.sh")
|
||||
CONFIGURE = os.path.join(REPO_ROOT, "scripts", "install", "configure_web_sudo.sh")
|
||||
WIFI = os.path.join(REPO_ROOT, "scripts", "install", "configure_wifi_permissions.sh")
|
||||
|
||||
VISUDO = shutil.which("visudo") or (
|
||||
"/usr/sbin/visudo" if os.path.exists("/usr/sbin/visudo") else None
|
||||
@@ -62,6 +63,54 @@ def test_configure_web_sudo_validates_before_installing():
|
||||
assert validate < install, "the rules must be checked before they are installed"
|
||||
|
||||
|
||||
def test_configure_web_sudo_does_not_use_a_predictable_temp_file():
|
||||
body = _read(CONFIGURE)
|
||||
assert 'TEMP_SUDOERS=$(mktemp' in body
|
||||
assert "/tmp/ledmatrix_web_sudoers_$$" not in body
|
||||
assert "trap 'rm -f \"$TEMP_SUDOERS\"' EXIT" in body
|
||||
|
||||
|
||||
def test_configure_web_sudo_installs_mode_440():
|
||||
body = _read(CONFIGURE)
|
||||
install = body.index('cp "$TEMP_SUDOERS" /etc/sudoers.d/ledmatrix_web')
|
||||
assert body.index("chmod 440 /etc/sudoers.d/ledmatrix_web") > install
|
||||
|
||||
|
||||
def test_configure_wifi_permissions_validates_before_installing():
|
||||
"""The third sudoers writer. It installed its rules unchecked."""
|
||||
body = _read(WIFI)
|
||||
# The check itself, as a condition -- not merely the command appearing in
|
||||
# the error report that follows it.
|
||||
validate = body.index('if ! visudo -c -f "$TEMP_SUDOERS"')
|
||||
install = body.index('sudo cp "$TEMP_SUDOERS" "$SUDOERS_FILE"')
|
||||
assert validate < install, "the rules must be checked before they are installed"
|
||||
# ...and a failed check stops the script before the copy.
|
||||
assert "exit 1" in body[validate:install]
|
||||
assert "TEMP_SUDOERS=$(mktemp" in body
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="visudo is POSIX only")
|
||||
@pytest.mark.skipif(VISUDO is None, reason="visudo not installed")
|
||||
def test_the_wifi_rules_actually_parse(tmp_path):
|
||||
"""Render configure_wifi_permissions.sh's heredoc with realistic paths."""
|
||||
body = _read(WIFI)
|
||||
opener = 'cat > "$TEMP_SUDOERS" << EOF\n'
|
||||
start = body.index(opener) + len(opener)
|
||||
end = body.index("\nEOF\n", start)
|
||||
out = tmp_path / "wifi"
|
||||
script = "\n".join([
|
||||
"WEB_USER=ledmatrix", "NMCLI_PATH=/usr/bin/nmcli",
|
||||
"SYSTEMCTL_PATH=/usr/bin/systemctl", "SYSCTL_PATH=/usr/sbin/sysctl",
|
||||
"NFT_PATH=/usr/sbin/nft", "RFKILL_PATH=/usr/sbin/rfkill",
|
||||
"MKDIR_PATH=/usr/bin/mkdir",
|
||||
f"cat > '{out}' << EOF", body[start:end], "EOF",
|
||||
])
|
||||
subprocess.run(["bash", "-c", script], check=True)
|
||||
os.chmod(out, 0o440)
|
||||
result = subprocess.run([VISUDO, "-c", "-f", str(out)], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
|
||||
|
||||
def test_a_missing_rules_library_installs_nothing():
|
||||
"""If lib_sudoers.sh is missing, nothing is generated -- and an empty file
|
||||
would pass `visudo -c` -- so that branch must set the flag the install is
|
||||
|
||||
@@ -38,6 +38,7 @@ import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401
|
||||
from src.common import sync_manager
|
||||
from src.common.sync_manager import (
|
||||
DisplaySyncManager,
|
||||
@@ -878,6 +879,14 @@ class TestWriteStatusFile:
|
||||
mgr.write_status_file() # must not raise
|
||||
assert mgr.logger.debug.called
|
||||
|
||||
def test_web_status_endpoint_reads_the_file_that_was_written(self, api_v3_client):
|
||||
"""GET /sync/status reads STATUS_FILE, which lives under
|
||||
tempfile.gettempdir() -- not always /tmp."""
|
||||
mgr = make_manager(role=SyncRole.LEADER)
|
||||
mgr.write_status_file()
|
||||
response = api_v3_client.get("/api/v3/sync/status")
|
||||
assert response.get_json()["data"]["role"] == "leader"
|
||||
|
||||
|
||||
class TestStop:
|
||||
def _stub_with_sockets(self):
|
||||
|
||||
@@ -51,7 +51,7 @@ def _memory(total_mb, used_mb, available_mb):
|
||||
|
||||
def _get_status(client, memory):
|
||||
# The endpoint caches for 10s; bypass so each case is measured fresh.
|
||||
with patch("web_interface.cache.get_cached", return_value=None), \
|
||||
with patch("web_interface.blueprints.api_v3.system.get_cached", return_value=None), \
|
||||
patch("psutil.virtual_memory", return_value=memory), \
|
||||
patch("psutil.cpu_percent", return_value=5.0), \
|
||||
patch("psutil.boot_time", return_value=0.0):
|
||||
@@ -90,3 +90,24 @@ def test_a_nearly_exhausted_board_reports_a_small_number(client):
|
||||
# to fork. The readout has to surface that rather than round it away.
|
||||
data = _get_status(client, _memory(total_mb=905, used_mb=800, available_mb=73))
|
||||
assert data["memory_available_mb"] == pytest.approx(73, abs=0.5)
|
||||
|
||||
|
||||
def test_status_and_live_stream_give_the_same_answer(client, monkeypatch):
|
||||
"""/system/status is built on collect_system_metrics(), so a metric has one
|
||||
value, and "could not be read" is null in both."""
|
||||
from web_interface import system_metrics
|
||||
monkeypatch.setattr(system_metrics, "_THERMAL_ZONE", "/nonexistent/thermal/temp")
|
||||
memory = _memory(total_mb=905, used_mb=620, available_mb=284)
|
||||
with patch("psutil.virtual_memory", return_value=memory), \
|
||||
patch("psutil.cpu_percent", return_value=5.0), \
|
||||
patch("psutil.boot_time", return_value=0.0), \
|
||||
patch("psutil.disk_usage", side_effect=OSError("no such mount")):
|
||||
streamed = system_metrics.collect_system_metrics()
|
||||
with patch("web_interface.blueprints.api_v3.system.get_cached", return_value=None):
|
||||
status = json.loads(client.get("/api/v3/system/status").data)["data"]
|
||||
|
||||
assert status["cpu_temp"] is None
|
||||
assert status["disk_used_percent"] is None
|
||||
for key in system_metrics.METRIC_KEYS:
|
||||
if key != "uptime_seconds": # the two reads are a moment apart
|
||||
assert status[key] == streamed[key], key
|
||||
|
||||
@@ -17,6 +17,7 @@ editing files under /etc and restarting services is the installer's job, not
|
||||
something a display process should do to a machine while it boots.
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
@@ -253,6 +254,24 @@ def test_sed_escape_replacement_preserves_special_characters():
|
||||
"a sed-special character in the replacement was not preserved literally")
|
||||
|
||||
|
||||
def test_every_unit_renderer_escapes_its_replacement():
|
||||
"""Each `sed s|__PLACEHOLDER__|$VALUE|` in an install script uses an escaped value.
|
||||
|
||||
install_dns_fix.sh and install_mqtt_bridge.sh interpolated the raw project
|
||||
path while the other three renderers went through sed_escape_replacement,
|
||||
so a checkout under a path containing `&` rendered a broken unit from
|
||||
those two only.
|
||||
"""
|
||||
project_root = Path("src/startup_validator.py").resolve().parent.parent
|
||||
offenders = []
|
||||
for script in sorted((project_root / "scripts" / "install").glob("*.sh")):
|
||||
text = script.read_text(encoding="utf-8")
|
||||
for m in re.finditer(r"s\|__[A-Z_]+__\|\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?\|", text):
|
||||
if not m.group(1).startswith("ESCAPED_") and m.group(1) != "root":
|
||||
offenders.append(f"{script.name}: ${m.group(1)}")
|
||||
assert not offenders, "unescaped sed replacement(s): " + ", ".join(offenders)
|
||||
|
||||
|
||||
def test_no_installer_carries_its_own_copy_of_a_unit():
|
||||
"""The regression guard.
|
||||
|
||||
|
||||
@@ -24,10 +24,13 @@ class TestTextHelper:
|
||||
assert th.font_dir == tmp_path
|
||||
assert th._font_cache == {}
|
||||
|
||||
def test_init_default_font_dir(self):
|
||||
"""Test TextHelper initialization with default font directory."""
|
||||
def test_init_default_font_dir(self, tmp_path, monkeypatch):
|
||||
"""The default is the install's assets/fonts, not a cwd-relative path."""
|
||||
from pathlib import Path
|
||||
monkeypatch.chdir(tmp_path)
|
||||
th = TextHelper()
|
||||
assert th.font_dir == pytest.importorskip("pathlib").Path("assets/fonts")
|
||||
assert th.font_dir == Path(__file__).resolve().parents[1] / "assets" / "fonts"
|
||||
assert isinstance(th.load_fonts()["score"], ImageFont.FreeTypeFont)
|
||||
|
||||
@patch('PIL.ImageFont.truetype')
|
||||
@patch('PIL.ImageFont.load_default')
|
||||
@@ -123,6 +126,17 @@ class TestTextHelper:
|
||||
def test_get_default_font_config(self, text_helper):
|
||||
"""Test getting default font configuration."""
|
||||
config = text_helper._get_default_font_config()
|
||||
|
||||
|
||||
assert isinstance(config, dict)
|
||||
assert len(config) > 0
|
||||
|
||||
def test_each_font_file_and_size_is_loaded_once(self):
|
||||
th = TextHelper()
|
||||
first = th.load_fonts()
|
||||
second = th.load_fonts()
|
||||
# Six names, three (file, size) pairs: PressStart2P at 10 and 8, 4x6 at 6.
|
||||
assert first["score"] is second["score"] is first["rank"]
|
||||
assert th.get_font_cache_stats()["cached_fonts"] == 3
|
||||
th.clear_font_cache()
|
||||
assert th.get_font_cache_stats()["cached_fonts"] == 0
|
||||
assert th.load_fonts()["score"] is not first["score"]
|
||||
|
||||
@@ -369,6 +369,26 @@ def test_default_config_has_no_saved_networks(tmp_path: Path) -> None:
|
||||
assert "saved_networks" not in json.loads(config_path.read_text())
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_save_config_reports_a_failed_write(manager: WiFiManager, tmp_path: Path) -> None:
|
||||
# A directory where the file should be: every write to it fails, as one
|
||||
# to a root-owned wifi_config.json does for the web user.
|
||||
blocked = tmp_path / "blocked.json"
|
||||
blocked.mkdir()
|
||||
manager.config_path = blocked
|
||||
|
||||
assert manager._save_config() is False
|
||||
assert list(tmp_path.glob(".blocked.json.tmp.*")) == []
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_save_config_round_trips(manager: WiFiManager) -> None:
|
||||
manager.config["auto_enable_ap_mode"] = False
|
||||
|
||||
assert manager._save_config() is True
|
||||
assert json.loads(manager.config_path.read_text())["auto_enable_ap_mode"] is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_connecting_does_not_store_the_password(manager: WiFiManager) -> None:
|
||||
commands = []
|
||||
@@ -389,3 +409,77 @@ def test_connecting_does_not_store_the_password(manager: WiFiManager) -> None:
|
||||
"the new-connection path was not reached"
|
||||
assert "hunter22" not in json.dumps(manager.config)
|
||||
assert "hunter22" not in manager.config_path.read_text()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Disconnect takes the saved profile down
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _profile_nmcli(profiles: dict):
|
||||
"""A fake subprocess.run for `nmcli connection show` over ``profiles``
|
||||
(profile name -> SSID), recording every command it is given."""
|
||||
commands = []
|
||||
|
||||
def fake_run(cmd, *args, **kwargs):
|
||||
commands.append(cmd)
|
||||
if cmd == ["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show"]:
|
||||
lines = [name.replace(":", "\\:") + ":802-11-wireless" for name in profiles]
|
||||
lines.append("Wired connection 1:802-3-ethernet")
|
||||
return _ok(stdout="\n".join(lines) + "\n")
|
||||
if cmd[:4] == ["nmcli", "-g", "802-11-wireless.ssid", "connection"]:
|
||||
return _ok(stdout=profiles.get(cmd[-1], "") + "\n")
|
||||
if cmd[:3] == ["nmcli", "connection", "show"]:
|
||||
return _ok() if cmd[3] in profiles else _fail()
|
||||
# nmcli rejects 802-11-wireless.ssid as a `connection show -f` column.
|
||||
if "802-11-wireless.ssid" in cmd:
|
||||
return _fail(stderr="Error: invalid field '802-11-wireless.ssid'")
|
||||
return _ok()
|
||||
|
||||
return fake_run, commands
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_find_profile_for_ssid_matches_by_ssid_not_name(manager: WiFiManager) -> None:
|
||||
fake_run, _ = _profile_nmcli({"home: upstairs": "HomeNet", "Office": "OfficeNet"})
|
||||
with patch("src.wifi_manager.subprocess.run", side_effect=fake_run):
|
||||
assert manager._find_profile_for_ssid("HomeNet") == "home: upstairs"
|
||||
assert manager._find_profile_for_ssid("OfficeNet") == "Office"
|
||||
assert manager._find_profile_for_ssid("Elsewhere") is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_disconnect_takes_the_profile_down(manager: WiFiManager) -> None:
|
||||
from src.wifi_manager import WiFiStatus
|
||||
|
||||
fake_run, commands = _profile_nmcli({"Home profile": "HomeNet"})
|
||||
with patch("src.wifi_manager.subprocess.run", side_effect=fake_run), \
|
||||
patch("src.wifi_manager.time.sleep"), \
|
||||
patch.object(manager, "get_wifi_status",
|
||||
return_value=WiFiStatus(connected=True, ssid="HomeNet")):
|
||||
ok, _ = manager.disconnect_from_network(skip_ap_check=True)
|
||||
|
||||
assert ok
|
||||
assert ["nmcli", "connection", "down", "Home profile"] in commands
|
||||
assert ["nmcli", "device", "disconnect", "wlan0"] in commands
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. The nmcli Wi-Fi list parser both scan paths share
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_nmcli_wifi_list_parsing() -> None:
|
||||
out = (
|
||||
"HomeNet:40:WPA2:2437 MHz\n"
|
||||
"Cafe:80::5180 MHz\n"
|
||||
"HomeNet:90:WPA2:5180 MHz\n" # duplicate SSID: first line wins
|
||||
":70:WPA2:2412 MHz\n" # hidden network
|
||||
"Broken:notanumber:WPA2:2412 MHz\n"
|
||||
"Modern:60:WPA3 SAE:5745 MHz\n"
|
||||
)
|
||||
networks = WiFiManager._parse_nmcli_wifi_list(out)
|
||||
assert [(n.ssid, n.signal, n.security, n.frequency) for n in networks] == [
|
||||
("Cafe", 80, "open", 5180.0),
|
||||
("Modern", 60, "wpa3", 5745.0),
|
||||
("HomeNet", 40, "wpa2", 2437.0),
|
||||
]
|
||||
|
||||
@@ -21,10 +21,7 @@ class TestPluginOperationsIntegration(unittest.TestCase):
|
||||
self.temp_dir = Path(tempfile.mkdtemp())
|
||||
|
||||
# Initialize components
|
||||
self.operation_queue = PluginOperationQueue(
|
||||
history_file=str(self.temp_dir / "operations.json"),
|
||||
max_history=100
|
||||
)
|
||||
self.operation_queue = PluginOperationQueue(max_history=100)
|
||||
|
||||
self.state_manager = PluginStateManager(
|
||||
state_file=str(self.temp_dir / "state.json"),
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"""POST /plugins/config and POST /plugins/config/reset over a real
|
||||
ConfigManager and SchemaManager.
|
||||
|
||||
The save path reshapes what the browser posts before validating it, and these
|
||||
tests pin the reshaping that validation depends on: repeats in a uniqueItems
|
||||
list are dropped, and a list the form posted as numbered fields becomes a list
|
||||
again.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from src.config_manager import ConfigManager # noqa: E402
|
||||
from src.plugin_system.schema_manager import SchemaManager # noqa: E402
|
||||
from test._api_v3_test_helpers import api_v3_module, build_app # noqa: E402,F401
|
||||
|
||||
PLUGIN_ID = "stocks"
|
||||
|
||||
SCHEMA = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean", "default": True},
|
||||
"stock_symbols": {
|
||||
"type": "array",
|
||||
"uniqueItems": True,
|
||||
"items": {"type": "string"},
|
||||
"default": ["AAPL"],
|
||||
},
|
||||
"refresh_seconds": {"type": "integer", "default": 60},
|
||||
"api_key": {"type": "string", "x-secret": True, "default": ""},
|
||||
},
|
||||
}
|
||||
|
||||
# The shape of the news plugin's schema: a list of objects nested one level
|
||||
# down. A form posts it as feeds.custom_feeds.0.name, feeds.custom_feeds.0.url,
|
||||
# which lands as a dict keyed "0", "1", ...
|
||||
NEWS_ID = "news"
|
||||
NEWS_SCHEMA = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean", "default": True},
|
||||
"feeds": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled_feeds": {
|
||||
"type": "array", "items": {"type": "string"}, "default": [],
|
||||
},
|
||||
"custom_feeds": {
|
||||
"type": "array",
|
||||
"default": [],
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"url": {"type": "string"},
|
||||
"enabled": {"type": "boolean", "default": True},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(tmp_path, api_v3_module):
|
||||
"""Real config and schema managers under tmp_path, on the blueprint."""
|
||||
plugins_dir = tmp_path / "plugins"
|
||||
for plugin_id, schema in ((PLUGIN_ID, SCHEMA), (NEWS_ID, NEWS_SCHEMA)):
|
||||
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, "name": plugin_id, "version": "1.0.0"}))
|
||||
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text(json.dumps(
|
||||
{PLUGIN_ID: {"enabled": True, "stock_symbols": ["AAPL", "FNMA"]}}))
|
||||
config_manager = ConfigManager(
|
||||
config_path=str(config_file),
|
||||
secrets_path=str(tmp_path / "config_secrets.json"))
|
||||
config_manager.template_path = str(tmp_path / "no-template.json")
|
||||
|
||||
api = api_v3_module.api_v3
|
||||
api.config_manager = config_manager
|
||||
api.schema_manager = SchemaManager(plugins_dir=plugins_dir, project_root=tmp_path)
|
||||
api.plugin_manager.plugin_manifests = {PLUGIN_ID: {"id": PLUGIN_ID},
|
||||
NEWS_ID: {"id": NEWS_ID}}
|
||||
api.plugin_manager.get_plugin.return_value = None
|
||||
|
||||
class Env:
|
||||
client = build_app(api).test_client()
|
||||
plugin_manager = api.plugin_manager
|
||||
|
||||
@staticmethod
|
||||
def stored(plugin_id=PLUGIN_ID):
|
||||
return json.loads(config_file.read_text())[plugin_id]
|
||||
|
||||
Env.config_manager = config_manager
|
||||
return Env
|
||||
|
||||
|
||||
class TestUniqueItemsRepeats:
|
||||
"""A repeat in a uniqueItems list is dropped, not a failed save."""
|
||||
|
||||
def test_form_post_repeating_a_saved_symbol_saves(self, env):
|
||||
response = env.client.post(
|
||||
f"/api/v3/plugins/config?plugin_id={PLUGIN_ID}",
|
||||
data={"stock_symbols": "AAPL, FNMA, FNMA"})
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert env.stored()["stock_symbols"] == ["AAPL", "FNMA"]
|
||||
|
||||
def test_json_post_keeps_first_occurrence_order(self, env):
|
||||
response = env.client.post("/api/v3/plugins/config", json={
|
||||
"plugin_id": PLUGIN_ID,
|
||||
"config": {"stock_symbols": ["TSLA", "AAPL", "TSLA", "FNMA"]},
|
||||
})
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert env.stored()["stock_symbols"] == ["TSLA", "AAPL", "FNMA"]
|
||||
|
||||
|
||||
class TestNumberedFieldsBecomeLists:
|
||||
"""The news plugin's custom feeds, posted one field per row."""
|
||||
|
||||
FEEDS = [{"name": "Local", "url": "https://example.com/local.xml", "enabled": True},
|
||||
{"name": "Tech", "url": "https://example.com/tech.xml", "enabled": False}]
|
||||
|
||||
def test_form_post(self, env):
|
||||
response = env.client.post(f"/api/v3/plugins/config?plugin_id={NEWS_ID}", data={
|
||||
"feeds.custom_feeds.0.name": "Local",
|
||||
"feeds.custom_feeds.0.url": "https://example.com/local.xml",
|
||||
"feeds.custom_feeds.0.enabled": "on",
|
||||
"feeds.custom_feeds.1.name": "Tech",
|
||||
"feeds.custom_feeds.1.url": "https://example.com/tech.xml",
|
||||
})
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert env.stored(NEWS_ID)["feeds"]["custom_feeds"] == self.FEEDS
|
||||
|
||||
def test_json_post(self, env):
|
||||
response = env.client.post("/api/v3/plugins/config", json={
|
||||
"plugin_id": NEWS_ID,
|
||||
"config": {"feeds": {"custom_feeds": {"1": self.FEEDS[1], "0": self.FEEDS[0]}}},
|
||||
})
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert env.stored(NEWS_ID)["feeds"]["custom_feeds"] == self.FEEDS
|
||||
|
||||
|
||||
class TestReset:
|
||||
"""POST /plugins/config/reset saves the way every other plugin save does."""
|
||||
|
||||
def test_reset_saves_atomically_with_a_backup(self, env, monkeypatch):
|
||||
cm = env.config_manager
|
||||
calls = []
|
||||
real_atomic = cm.save_config_atomic
|
||||
|
||||
def spy(config, create_backup=True, **kwargs):
|
||||
calls.append(create_backup)
|
||||
return real_atomic(config, create_backup=create_backup, **kwargs)
|
||||
|
||||
def no_plain_save(_config):
|
||||
raise AssertionError("reset bypassed the atomic save")
|
||||
|
||||
monkeypatch.setattr(cm, "save_config_atomic", spy)
|
||||
monkeypatch.setattr(cm, "save_config", no_plain_save)
|
||||
|
||||
response = env.client.post("/api/v3/plugins/config/reset",
|
||||
json={"plugin_id": PLUGIN_ID})
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert calls == [True]
|
||||
assert env.stored()["stock_symbols"] == ["AAPL"]
|
||||
|
||||
def test_reset_notifies_the_plugin_with_its_prepared_config(self, env):
|
||||
plugin = MagicMock()
|
||||
env.plugin_manager.get_plugin.return_value = plugin
|
||||
env.plugin_manager.prepare_plugin_config.side_effect = (
|
||||
lambda _pid, raw: {**raw, "prepared": True})
|
||||
|
||||
response = env.client.post("/api/v3/plugins/config/reset",
|
||||
json={"plugin_id": PLUGIN_ID})
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
handed_over = plugin.on_config_change.call_args.args[0]
|
||||
assert handed_over["prepared"] is True
|
||||
assert handed_over["stock_symbols"] == ["AAPL"]
|
||||
|
||||
def test_a_failed_save_is_reported(self, env, monkeypatch):
|
||||
failed = MagicMock(message="disk full")
|
||||
failed.status.value = "failed"
|
||||
monkeypatch.setattr(env.config_manager, "save_config_atomic",
|
||||
MagicMock(return_value=failed))
|
||||
|
||||
response = env.client.post("/api/v3/plugins/config/reset",
|
||||
json={"plugin_id": PLUGIN_ID})
|
||||
|
||||
assert response.status_code == 500
|
||||
assert "disk full" in response.get_json()["message"]
|
||||
@@ -0,0 +1,58 @@
|
||||
"""coerce_array_shapes: position-keyed dicts back into lists before validation."""
|
||||
|
||||
from src.web_interface.config_arrays import coerce_array_shapes
|
||||
|
||||
COLOR = {"type": "array", "items": {"type": "integer"},
|
||||
"minItems": 3, "maxItems": 3, "default": [255, 255, 255]}
|
||||
|
||||
|
||||
def test_position_keys_become_a_list_in_numeric_order():
|
||||
config = {"tags": {"10": "k", "2": "c", "0": "a"}}
|
||||
coerce_array_shapes(config, {"tags": {"type": "array"}})
|
||||
assert config["tags"] == ["a", "c", "k"]
|
||||
|
||||
|
||||
def test_an_empty_dict_becomes_an_empty_list():
|
||||
config = {"tags": {}}
|
||||
coerce_array_shapes(config, {"tags": {"type": "array"}})
|
||||
assert config["tags"] == []
|
||||
|
||||
|
||||
def test_a_dict_with_other_keys_is_left_for_validation_to_report():
|
||||
config = {"tags": {"0": "a", "name": "b"}}
|
||||
coerce_array_shapes(config, {"tags": {"type": "array"}})
|
||||
assert config["tags"] == {"0": "a", "name": "b"}
|
||||
|
||||
|
||||
def test_element_types_are_left_to_normalization():
|
||||
config = {"color": {"0": "1", "1": "2", "2": "3"}}
|
||||
coerce_array_shapes(config, {"color": COLOR})
|
||||
assert config["color"] == ["1", "2", "3"]
|
||||
|
||||
|
||||
def test_nested_objects_and_array_items_are_walked():
|
||||
schema = {"feeds": {"type": "object", "properties": {
|
||||
"custom_feeds": {"type": "array", "items": {"type": "object", "properties": {
|
||||
"tags": {"type": "array"},
|
||||
}}},
|
||||
}}}
|
||||
config = {"feeds": {"custom_feeds": {"0": {"tags": {"0": "news"}}}}}
|
||||
coerce_array_shapes(config, schema)
|
||||
assert config == {"feeds": {"custom_feeds": [{"tags": ["news"]}]}}
|
||||
|
||||
|
||||
def test_a_short_form_list_takes_the_default_only_when_asked():
|
||||
config = {"color": ["10", "20"]}
|
||||
coerce_array_shapes(config, {"color": COLOR})
|
||||
assert config["color"] == ["10", "20"]
|
||||
|
||||
coerce_array_shapes(config, {"color": COLOR}, short_lists_take_default=True)
|
||||
assert config["color"] == [255, 255, 255]
|
||||
assert config["color"] is not COLOR["default"]
|
||||
|
||||
|
||||
def test_a_default_too_short_itself_is_not_used():
|
||||
schema = {"color": dict(COLOR, default=[0])}
|
||||
config = {"color": ["10"]}
|
||||
coerce_array_shapes(config, schema, short_lists_take_default=True)
|
||||
assert config["color"] == ["10"]
|
||||
@@ -1,11 +1,9 @@
|
||||
"""Tests for dedup_unique_arrays used by save_plugin_config.
|
||||
"""Tests for dedup_unique_arrays, which the plugin-config save path
|
||||
(_prepare_plugin_config_for_save in api_v3/plugins.py) runs before validation.
|
||||
|
||||
Validates that arrays with uniqueItems: true in the JSON schema have
|
||||
duplicates removed before validation, preventing spurious validation
|
||||
failures when form merging introduces duplicate entries.
|
||||
|
||||
Tests import the production function from src.web_interface.validators
|
||||
to ensure they exercise the real code path.
|
||||
Arrays with uniqueItems: true in the JSON schema lose their duplicates, so a
|
||||
repeat introduced by form merging does not fail validation.
|
||||
test_api_v3_plugin_config_save.py checks the same through the endpoint.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user