mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-13 14:48:06 +00:00
/plugins/install and /plugins/install-from-url were tested only at the
PluginStoreManager layer, so the route logic — the queue-versus-direct
branch, schema invalidation, discovery, state and history recording — was
unexercised.
Covering them surfaced the wider form of the body-parsing bug fixed for
the `or {}` handlers in the previous commit. Fourteen handlers read
`data = request.get_json()` and immediately guard with `if not data:
return 400, 'No data provided'`. That guard cannot run: get_json()
without silent=True raises UnsupportedMediaType for a request with no
JSON body, so the catch-all answered 500 "an error occurred; see logs
for details" where the handler plainly meant to answer 400 and say
which field was missing. Every one of these endpoints told a caller who
simply forgot the body to go read the server logs.
All fourteen now use silent=True, so the guard each author already wrote
is the one that runs. This covers /config/raw/main and /config/raw/secrets
among them, whose own bodyless case had the same shape.
The two remaining bare reads are left alone: neither declares what a
missing body should do, so there is no stated intent to honour.
31 install tests plus 17 body tests. The install pair is checked against
each other rather than only individually — the same install logic is
written twice, once in the queue callback and once in the fallback, so
the tests assert both produce identical schema, discovery, state and
history effects. They agree today; the one difference is the success
message wording, which is characterized rather than changed.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
303 lines
15 KiB
Python
303 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.
|
|
|
|
/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_manager.discover_plugins.call_count,
|
|
"loaded": api.plugin_manager.load_plugin.call_args_list,
|
|
"state_set": api.plugin_state_manager.set_plugin_installed.call_args_list,
|
|
"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["loaded"] == [(("clock",), {})]
|
|
assert effects["state_set"] == [(("clock",), {})]
|
|
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["loaded"] == []
|
|
assert effects["state_set"] == []
|
|
|
|
|
|
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["loaded"] == [(("clock",), {})]
|
|
assert effects["state_set"] == [(("clock",), {})]
|
|
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_manager,
|
|
api_v3_module.api_v3.plugin_state_manager,
|
|
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["loaded"] == queued["loaded"]
|
|
assert direct["state_set"] == queued["state_set"]
|
|
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_loads_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"}
|
|
api_v3_client.post(FROM_URL, json={"repo_url": "http://x"})
|
|
api_v3_module.api_v3.schema_manager.invalidate_cache.assert_called_once_with("clock")
|
|
api_v3_module.api_v3.plugin_manager.load_plugin.assert_called_once_with("clock")
|
|
|
|
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 load.
|
|
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_manager.load_plugin.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
|