mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
Stage 2 of the web plugin catalog, after #688. - The display publishes a plugin runtime snapshot (plugin_runtime.py) to the shared cache: per plugin loaded, lifecycle state, a short redacted error summary, the version it loaded and when, plus published_at / stale_after / running. Written on change (throttled to 10 s; the RUNNING/ENABLED flip of an ordinary update is not a change) and once a minute otherwise; cleanup() publishes running: false. - The web reads it back and restores loaded / state / error_info in /api/v3/plugins/installed (plus loaded_version, loaded_at and data.runtime). Only a live snapshot counts; stale, stopped or missing answers null and says which. - data/plugin_state.json is retired: every reader and writer moved to config + disk (desired) or the snapshot (observed). Nothing in it was non-derivable, so nothing is migrated and an existing file is left unread. The web-side PluginStateManager (state_manager.py) is removed; the display's plugin_state.PluginStateManager is the only state machine. - StateReconciliation compares config + disk with the snapshot, reporting enabled-but-not-loaded and older-version-loaded as no_action findings. - Backups list installed manifests with enabled from config.json. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
299 lines
15 KiB
Python
299 lines
15 KiB
Python
"""
|
|
Endpoint tests for POST /plugins/install and POST /plugins/install-from-url.
|
|
|
|
Both were only ever tested at the PluginStoreManager layer, so the route
|
|
logic — the queue-vs-direct branch, schema invalidation, plugin discovery,
|
|
state and history recording — was unexercised.
|
|
|
|
Neither route loads the plugin: the web process only lists it (the catalog
|
|
has no load_plugin, so calling one fails these tests). The display loads it
|
|
when it is enabled; restart_required says when that won't happen by itself
|
|
(test/web_interface/test_web_process_runs_no_plugin_code.py).
|
|
|
|
/plugins/install carries the same install logic twice: once inside the
|
|
operation-queue callback and once in the direct fallback. The paired
|
|
tests below assert both branches produce the same side effects, so the
|
|
duplication cannot quietly drift.
|
|
"""
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock
|
|
|
|
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
|
|
|
|
INSTALL = "/api/v3/plugins/install"
|
|
FROM_URL = "/api/v3/plugins/install-from-url"
|
|
|
|
|
|
@pytest.fixture
|
|
def queued(api_v3_module):
|
|
"""Enable the operation queue and run its callback synchronously."""
|
|
queue = MagicMock()
|
|
|
|
def enqueue(operation_type, plugin_id, operation_callback=None):
|
|
queue.callback_result = operation_callback(MagicMock())
|
|
return "op-123"
|
|
|
|
queue.enqueue_operation.side_effect = enqueue
|
|
api_v3_module.api_v3.operation_queue = queue
|
|
return queue
|
|
|
|
|
|
def side_effects(module):
|
|
"""The manager calls a successful install is expected to make."""
|
|
api = module.api_v3
|
|
return {
|
|
"schema_invalidated": api.schema_manager.invalidate_cache.call_args_list,
|
|
"discovered": api.plugin_catalog.discover_plugins.call_count,
|
|
"history": api.operation_history.record_operation.call_args_list,
|
|
}
|
|
|
|
|
|
class TestInstallValidation:
|
|
def test_uninitialized_store_manager_is_a_500(self, api_v3_client, api_v3_module):
|
|
api_v3_module.api_v3.plugin_store_manager = None
|
|
response = api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
|
assert response.status_code == 500
|
|
assert "not initialized" in response.get_json()["message"]
|
|
|
|
def test_missing_plugin_id_is_a_400(self, api_v3_client, api_v3_module):
|
|
response = api_v3_client.post(INSTALL, json={})
|
|
assert response.status_code == 400
|
|
assert "plugin_id required" in response.get_json()["message"]
|
|
api_v3_module.api_v3.plugin_store_manager.install_plugin.assert_not_called()
|
|
|
|
def test_empty_body_is_a_400(self, api_v3_client, api_v3_module):
|
|
assert api_v3_client.post(INSTALL, json=None).status_code == 400
|
|
|
|
|
|
class TestInstallDirectPath:
|
|
"""operation_queue is None — the fallback branch."""
|
|
|
|
def test_success(self, api_v3_client, api_v3_module):
|
|
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = True
|
|
response = api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
|
assert response.status_code == 200
|
|
assert response.get_json()["status"] == "success"
|
|
|
|
def test_success_side_effects(self, api_v3_client, api_v3_module):
|
|
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = True
|
|
api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
|
effects = side_effects(api_v3_module)
|
|
assert effects["schema_invalidated"] == [(("clock",), {})]
|
|
assert effects["discovered"] == 1
|
|
assert effects["history"][0].kwargs["status"] == "success"
|
|
|
|
def test_branch_forwarded_to_the_manager(self, api_v3_client, api_v3_module):
|
|
manager = api_v3_module.api_v3.plugin_store_manager
|
|
manager.install_plugin.return_value = True
|
|
api_v3_client.post(INSTALL, json={"plugin_id": "clock", "branch": "dev"})
|
|
manager.install_plugin.assert_called_once_with("clock", branch="dev")
|
|
|
|
def test_branch_named_in_the_message(self, api_v3_client, api_v3_module):
|
|
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = True
|
|
response = api_v3_client.post(INSTALL, json={"plugin_id": "clock", "branch": "dev"})
|
|
assert "(branch: dev)" in response.get_json()["message"]
|
|
|
|
def test_failure_is_a_500(self, api_v3_client, api_v3_module):
|
|
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = False
|
|
response = api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
|
assert response.status_code == 500
|
|
assert "Failed to install" in response.get_json()["message"]
|
|
|
|
def test_failure_mentions_missing_registry_entry(self, api_v3_client, api_v3_module):
|
|
manager = api_v3_module.api_v3.plugin_store_manager
|
|
manager.install_plugin.return_value = False
|
|
manager.get_plugin_info.return_value = None
|
|
response = api_v3_client.post(INSTALL, json={"plugin_id": "ghost"})
|
|
assert "not found in registry" in response.get_json()["message"]
|
|
|
|
def test_failure_omits_registry_note_when_plugin_is_known(
|
|
self, api_v3_client, api_v3_module):
|
|
manager = api_v3_module.api_v3.plugin_store_manager
|
|
manager.install_plugin.return_value = False
|
|
manager.get_plugin_info.return_value = {"id": "clock"}
|
|
response = api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
|
assert "not found in registry" not in response.get_json()["message"]
|
|
|
|
def test_failure_recorded_in_history(self, api_v3_client, api_v3_module):
|
|
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = False
|
|
api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
|
record = api_v3_module.api_v3.operation_history.record_operation.call_args
|
|
assert record.kwargs["status"] == "failed"
|
|
|
|
def test_no_side_effects_on_failure(self, api_v3_client, api_v3_module):
|
|
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = False
|
|
api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
|
effects = side_effects(api_v3_module)
|
|
assert effects["schema_invalidated"] == []
|
|
assert effects["discovered"] == 0
|
|
|
|
|
|
class TestInstallQueuedPath:
|
|
"""operation_queue present — the callback branch."""
|
|
|
|
def test_returns_an_operation_id(self, api_v3_client, api_v3_module, queued):
|
|
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = True
|
|
response = api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
|
assert response.status_code == 200
|
|
assert response.get_json()["data"]["operation_id"] == "op-123"
|
|
|
|
def test_message_says_queued(self, api_v3_client, api_v3_module, queued):
|
|
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = True
|
|
response = api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
|
assert "queued" in response.get_json()["message"]
|
|
|
|
def test_callback_success_side_effects(self, api_v3_client, api_v3_module, queued):
|
|
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = True
|
|
api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
|
effects = side_effects(api_v3_module)
|
|
assert effects["schema_invalidated"] == [(("clock",), {})]
|
|
assert effects["discovered"] == 1
|
|
assert effects["history"][0].kwargs["status"] == "success"
|
|
|
|
def test_callback_reports_success(self, api_v3_client, api_v3_module, queued):
|
|
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = True
|
|
api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
|
assert queued.callback_result["success"] is True
|
|
|
|
def test_callback_failure_raises_for_the_queue(self, api_v3_client, api_v3_module, queued):
|
|
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = False
|
|
# The callback signals failure by raising, so the queue can mark the
|
|
# operation failed; the route's catch-all turns it into a 500.
|
|
response = api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
|
assert response.status_code == 500
|
|
|
|
def test_callback_failure_recorded_in_history(self, api_v3_client, api_v3_module, queued):
|
|
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = False
|
|
api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
|
record = api_v3_module.api_v3.operation_history.record_operation.call_args
|
|
assert record.kwargs["status"] == "failed"
|
|
|
|
def test_branch_forwarded_from_the_callback(self, api_v3_client, api_v3_module, queued):
|
|
manager = api_v3_module.api_v3.plugin_store_manager
|
|
manager.install_plugin.return_value = True
|
|
api_v3_client.post(INSTALL, json={"plugin_id": "clock", "branch": "dev"})
|
|
manager.install_plugin.assert_called_once_with("clock", branch="dev")
|
|
|
|
|
|
class TestInstallPathsAgree:
|
|
"""The queue callback and the direct fallback duplicate the same logic."""
|
|
|
|
def _run(self, client, module, install_ok, queue):
|
|
module.api_v3.plugin_store_manager.install_plugin.return_value = install_ok
|
|
client.post(INSTALL, json={"plugin_id": "clock", "branch": "dev"})
|
|
return side_effects(module)
|
|
|
|
def test_success_side_effects_match(self, api_v3_client, api_v3_module):
|
|
direct = self._run(api_v3_client, api_v3_module, True, None)
|
|
|
|
# Reset and re-run through the queue.
|
|
for mock in (api_v3_module.api_v3.schema_manager,
|
|
api_v3_module.api_v3.plugin_catalog,
|
|
api_v3_module.api_v3.operation_history):
|
|
mock.reset_mock()
|
|
queue = MagicMock()
|
|
queue.enqueue_operation.side_effect = (
|
|
lambda t, p, operation_callback=None: operation_callback(MagicMock()) and "op")
|
|
api_v3_module.api_v3.operation_queue = queue
|
|
queued = self._run(api_v3_client, api_v3_module, True, queue)
|
|
|
|
assert direct["schema_invalidated"] == queued["schema_invalidated"]
|
|
assert direct["discovered"] == queued["discovered"]
|
|
assert (direct["history"][0].kwargs["status"]
|
|
== queued["history"][0].kwargs["status"])
|
|
assert (direct["history"][0].kwargs["details"]
|
|
== queued["history"][0].kwargs["details"])
|
|
|
|
def test_only_the_message_wording_differs(self, api_v3_client, api_v3_module):
|
|
# Characterized: the direct path says "Plugin installed
|
|
# successfully" while the queue callback says "Plugin clock
|
|
# installed successfully". Cosmetic, and the queue's text is
|
|
# internal to the operation record rather than the HTTP response.
|
|
api_v3_module.api_v3.plugin_store_manager.install_plugin.return_value = True
|
|
direct = api_v3_client.post(INSTALL, json={"plugin_id": "clock"}).get_json()
|
|
assert direct["message"] == "Plugin installed successfully"
|
|
|
|
|
|
class TestInstallFromUrl:
|
|
def test_uninitialized_store_manager_is_a_500(self, api_v3_client, api_v3_module):
|
|
api_v3_module.api_v3.plugin_store_manager = None
|
|
assert api_v3_client.post(FROM_URL, json={"repo_url": "http://x"}).status_code == 500
|
|
|
|
def test_missing_repo_url_is_a_400(self, api_v3_client, api_v3_module):
|
|
response = api_v3_client.post(FROM_URL, json={})
|
|
assert response.status_code == 400
|
|
assert "repo_url required" in response.get_json()["message"]
|
|
|
|
def test_success(self, api_v3_client, api_v3_module):
|
|
api_v3_module.api_v3.plugin_store_manager.install_from_url.return_value = {
|
|
"success": True, "plugin_id": "clock", "name": "Clock"}
|
|
response = api_v3_client.post(FROM_URL, json={"repo_url": "https://github.com/o/r"})
|
|
assert response.status_code == 200
|
|
body = response.get_json()
|
|
assert body["plugin_id"] == "clock"
|
|
assert body["name"] == "Clock"
|
|
|
|
def test_all_optional_arguments_forwarded(self, api_v3_client, api_v3_module):
|
|
manager = api_v3_module.api_v3.plugin_store_manager
|
|
manager.install_from_url.return_value = {"success": True, "plugin_id": "clock"}
|
|
api_v3_client.post(FROM_URL, json={
|
|
"repo_url": " https://github.com/o/r ",
|
|
"plugin_id": "clock",
|
|
"plugin_path": "plugins/clock",
|
|
"branch": "dev",
|
|
})
|
|
manager.install_from_url.assert_called_once_with(
|
|
repo_url="https://github.com/o/r",
|
|
plugin_id="clock",
|
|
plugin_path="plugins/clock",
|
|
branch="dev",
|
|
)
|
|
|
|
def test_success_invalidates_schema_and_lists_plugin(self, api_v3_client, api_v3_module):
|
|
api_v3_module.api_v3.plugin_store_manager.install_from_url.return_value = {
|
|
"success": True, "plugin_id": "clock"}
|
|
response = api_v3_client.post(FROM_URL, json={"repo_url": "http://x"})
|
|
assert response.status_code == 200, response.get_json()
|
|
api_v3_module.api_v3.schema_manager.invalidate_cache.assert_called_once_with("clock")
|
|
api_v3_module.api_v3.plugin_catalog.discover_plugins.assert_called_once_with()
|
|
|
|
def test_success_without_plugin_id_skips_discovery(self, api_v3_client, api_v3_module):
|
|
# install_from_url can succeed without naming the plugin; there is
|
|
# then nothing to invalidate or list.
|
|
api_v3_module.api_v3.plugin_store_manager.install_from_url.return_value = {
|
|
"success": True, "plugin_id": None}
|
|
api_v3_client.post(FROM_URL, json={"repo_url": "http://x"})
|
|
api_v3_module.api_v3.schema_manager.invalidate_cache.assert_not_called()
|
|
api_v3_module.api_v3.plugin_catalog.discover_plugins.assert_not_called()
|
|
|
|
def test_branch_from_result_included(self, api_v3_client, api_v3_module):
|
|
api_v3_module.api_v3.plugin_store_manager.install_from_url.return_value = {
|
|
"success": True, "plugin_id": "clock", "branch": "dev"}
|
|
body = api_v3_client.post(FROM_URL, json={"repo_url": "http://x"}).get_json()
|
|
assert body["branch"] == "dev"
|
|
assert "(branch: dev)" in body["message"]
|
|
|
|
def test_failure_reports_the_managers_error(self, api_v3_client, api_v3_module):
|
|
api_v3_module.api_v3.plugin_store_manager.install_from_url.return_value = {
|
|
"success": False, "error": "repo not found"}
|
|
response = api_v3_client.post(FROM_URL, json={"repo_url": "http://x"})
|
|
assert response.status_code == 500
|
|
assert response.get_json()["message"] == "repo not found"
|
|
|
|
def test_failure_without_error_uses_fallback_text(self, api_v3_client, api_v3_module):
|
|
api_v3_module.api_v3.plugin_store_manager.install_from_url.return_value = {
|
|
"success": False}
|
|
response = api_v3_client.post(FROM_URL, json={"repo_url": "http://x"})
|
|
assert "Failed to install plugin from URL" in response.get_json()["message"]
|
|
|
|
def test_manager_exception_is_a_500(self, api_v3_client, api_v3_module):
|
|
api_v3_module.api_v3.plugin_store_manager.install_from_url.side_effect = (
|
|
RuntimeError("boom"))
|
|
assert api_v3_client.post(FROM_URL, json={"repo_url": "http://x"}).status_code == 500
|