mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
* refactor(api-v3): split the 10,469-line blueprint into a package web_interface/blueprints/api_v3.py held 111 routes, 56 helpers and 181 functions in one module -- 9% of the core by line count and three times the next largest file. It becomes a package of nine route modules grouped by path segment, plus __init__.py for the shared imports, constants, Blueprint and helpers. Every route module decorates the SAME api_v3 Blueprint object, so endpoint names stay api_v3.<function>, the URL map is unchanged and app.py is untouched. Verified: 111 routes before, 111 after, byte-identical rules, endpoints and methods, and every endpoint still on the one blueprint. plugins 3,867 config 1,178 starlark 692 system 619 fonts 452 misc 398 wifi 361 display 326 backup 212 __init__ 1,787 (imports, constants, Blueprint, 56 helpers) Two things the URL-map check could not catch, both found by running the suite: 1. PROJECT_ROOT = Path(__file__).parent.parent.parent. Moving the code one directory deeper made that resolve to web_interface/ instead of the project root. Nothing failed at import; it surfaced as ~110 tests failing with 404s and "installation script not found", because every path built from it was one level too shallow. Now parents[3], and test_api_v3_url_map.py asserts PROJECT_ROOT/run.py exists so the next move cannot repeat it. 2. Module-attribute patching. Tests do monkeypatch.setattr(api_v3_module, "_BACKUP_EXPORT_DIR", ...) and a route module that binds such a name by value never sees the patch. The shared code therefore stays in __init__.py rather than moving to a _common submodule -- it has to live on the module the tests patch -- and the eleven names tests patch are read back through the package (_pkg.X) instead of bound by value. Those eleven were found by AST-scanning every setattr in the test tree, not by guessing; "time" is among them, used to drive a fake clock through the second-resolution credential-backup filenames. Test changes are confined to what genuinely moved: patch targets that now name the owning route module, imports of helpers, and six tests that scan the api_v3 source as a file and now read the package directory. Full suite: 4,278 passed, 68 skipped, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 * fix(api-v3): address CodeRabbit findings from the blueprint-split review Fixes to the api_v3 package split (PR #553), one per finding verified against the actual code: - __init__.py: _redact_credentials only blanked scalar values under a credential-named key; a bare list of secrets under such a key (e.g. tokens: ["a", "b"]) passed through untouched, since the list branch recursed with no memory that its key looked like a credential. Nested dicts still walk normally (a documented, tested behaviour -- a container like secrets: {api_key: ..., note: ...} is a section name, not a value to blank outright), but any value reached under a credential-shaped key is now actually blanked. - __init__.py: the OAuth helper script's raw stderr/stdout went to logger.error unredacted (CWE-532) right next to a comment claiming this was deliberate; the HTTP response already used the existing redact_text helper. Routed the log line through the same helper. - __init__.py / starlark.py: the standalone Starlark manifest fallback (used when the plugin instance isn't loaded) read-modified-wrote manifest.json with no lock, unlike StarlarkAppsPlugin._update_manifest_safe (plugin-repos/starlark-apps/manager.py), which already holds an flock for the same file when the plugin is loaded. Added _starlark_manifest_lock, mirroring that pattern, and wrapped every standalone read-modify-write call site in it. The app-config update route also wrote config.json and the manifest as two separate, non-transactional writes (a second, distinct finding at the same call site); config.json is now rolled back if the manifest write that follows it fails. - backup.py: restore options used bare bool() on values from the request, so {"restore_secrets": "false"} restored secrets anyway (bool("false") is True). Switched to the existing _coerce_to_bool helper already used for this exact purpose elsewhere in the package. - config.py: an automated import-rewrite mangled four user-facing validation strings and their neighbouring comments -- "Invalid start time" had become "Invalid start _pkg.time" (and likewise for "end time") in both the schedule and dim-schedule per-day validation paths. - display.py: `import _pkg.time as time_module` -- _pkg is a local alias for the package, not a real importable module, so this raised ModuleNotFoundError whenever a caller restarted an already-running display service via /display/on-demand/start, after the on-demand request was already written to cache. Fixed to `import time`. Audited the rest of the package for the same `_pkg.<module>` import mistake; every other `_pkg.` reference is a legitimate attribute read-through (`_pkg.time.time()`, `_pkg._get_starlark_plugin()`, ...), not a broken import statement. - fonts.py: validate_file_upload's max_size_mb parameter is silently unused by that helper (it only checks filename/extension) -- the font upload route saved arbitrarily large files as a result. Added the same seek-and-check pattern already used for the sibling .star upload. - wifi.py: two ad hoc, inconsistent bool coercions. POST /wifi/ap/auto-enable used bare bool(), so a JSON string "false" enabled it. POST /wifi/radio's enabled/force parsing recognized real bool and some strings but not int 1/0 (1 is True is False in Python). Factored one small _parse_bool_ish helper local to this file and used it at all three sites. Not changed: the "unknown/misspelled restore option keys default to True" half of the backup.py finding -- the file's own comment documents that a missing key deliberately means "restore everything," matching the already-existing JSON-parse-failure guard a few lines above it; only the bool-coercion defect was a real bug. Added or extended regression tests for every fix, following each area's existing test conventions. Full suite: 4328 passed, 62 skipped, 2 failed on both this branch and origin/main (missing tzdata package breaks two timezone-alias tests in test_onboarding_checklist.py, unrelated to this change) -- no new failures. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S3bPMESe2TfrGvbs1ef9c5 * fix(api-v3): reject unknown restore option keys CodeRabbit's review of the blueprint split (#553) asked that POST /backup/restore reject option keys outside RestoreOptions' known set. The follow-up commit fixed the bool("false")-is-True bug with _coerce_to_bool but never added the key check: a typo'd or renamed key (e.g. "restoreSecrets") is silently ignored by opts_dict.get(key, True), so the flag stays at its True default and secrets get restored despite the caller's request saying otherwise -- with no indication anything was wrong. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vmcwf5vMgYqdt8bJTZtiwb * fix(api-v3): address CodeRabbit findings on the blueprint split - _redact_credentials: blank scalar descendants of objects reached through a credential-owned list (e.g. tokens: [{"value": "secret"}]) regardless of field name -- the existing name-based walk only protected direct dict values under a credential key, not list items. - wifi.py: reject enabled/force/auto_enable_ap_mode values _parse_bool_ish can't recognize (400) instead of silently treating them as False, which could disable Wi-Fi or the radio itself. - Starlark manifest locking: lock a stable manifest.json.lock sidecar instead of manifest.json itself, in both the standalone route path (_starlark_manifest_lock) and the plugin path (StarlarkAppsPlugin._save_manifest / _update_manifest_safe). manifest.json is replaced by an atomic rename on every write, which swaps in a fresh inode; a lock held on the old inode does not exclude a second locker that opens the path afresh right after the rename and gets the new inode, so two writers could race despite each holding "a lock". A sidecar that no write ever touches always resolves to the same inode for every locker. Skipped as stale: the "serialize the complete manifest read-modify-write" finding at api_v3/__init__.py -- every standalone handler that calls _write_starlark_manifest is already wrapped in _starlark_manifest_lock() on this branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(api-v3): re-check reconciliation findings by the reconciler's own rules Both CodeRabbit findings on the merge commit, verified against the code first. Major, plugins.py: the stale-findings filter derived its own notion of "in config" and "on disk", and both were looser than the reconciliation module's. set(load_config()) also contains system keys, the secrets-file keys load_config() merges in, and non-dict values; and any directory holding a manifest.json counted as installed even when that manifest does not parse. Either looseness clears a finding that is still true -- and a secrets key read as a plugin is the precise bug the filter exists to stop reporting, so reintroducing that asymmetry while re-checking was the wrong way round. The two extractions now live in state_reconciliation.py as config_plugin_ids() and disk_plugin_ids(), with ignored_config_keys() and secrets_top_level_keys() alongside. _get_config_state() and _get_disk_state() use them too, so there is one definition rather than two that can drift. _get_disk_state() re-reads each manifest for version/name after taking membership from the shared extractor; that costs one extra small read per plugin on a path that runs once per boot. Minor, the new test: the fixture assigned api_v3.config_manager and api_v3.plugin_manager directly. Those live on a module-level blueprint singleton, so the mocks leaked into every later test that imports api_v3 -- pointing at a tmp_path already deleted. Both now go through monkeypatch.setattr, which restores them. This is the same pollution class that made an earlier test in this session break seven unrelated ones, so it is worth getting right. Five cases added for the parity itself: a secrets key, a system key and a non-dict value must not clear an "installed but missing from config" finding, and neither an unparseable manifest nor a .standalone-backup- directory may count as installed. All five fail against the looser version. Linux CI on the preceding commit: Core unit tests, plugin harness, CodeQL and CodeRabbit all pass. Codacy reads action_required on every commit of this branch including the first, so it is pre-existing and not from this work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1038 lines
40 KiB
Python
1038 lines
40 KiB
Python
"""
|
|
Tests for Web Interface API endpoints.
|
|
|
|
Tests Flask routes, request/response handling, and API functionality.
|
|
"""
|
|
|
|
import pytest
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
# Add project root to path
|
|
project_root = Path(__file__).parent.parent
|
|
sys.path.insert(0, str(project_root))
|
|
|
|
from flask import Flask
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_config_manager():
|
|
"""Create a mock config manager."""
|
|
mock = MagicMock()
|
|
mock.load_config.return_value = {
|
|
'display': {'brightness': 50},
|
|
'plugins': {},
|
|
'timezone': 'UTC'
|
|
}
|
|
mock.get_config_path.return_value = 'config/config.json'
|
|
mock.get_secrets_path.return_value = 'config/config_secrets.json'
|
|
mock_config = {
|
|
'display': {'brightness': 50},
|
|
'plugins': {},
|
|
'timezone': 'UTC'
|
|
}
|
|
mock.load_config.return_value = mock_config
|
|
mock.get_raw_file_content.return_value = mock_config
|
|
mock.save_config_atomic.return_value = MagicMock(
|
|
status=MagicMock(value='success'),
|
|
message=None
|
|
)
|
|
return mock
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_plugin_manager():
|
|
"""Create a mock plugin manager."""
|
|
mock = MagicMock()
|
|
mock.plugins = {}
|
|
mock.discover_plugins.return_value = []
|
|
mock.health_tracker = MagicMock()
|
|
mock.health_tracker.get_health_status.return_value = {'healthy': True}
|
|
return mock
|
|
|
|
|
|
@pytest.fixture
|
|
def client(mock_config_manager, mock_plugin_manager):
|
|
"""Create a Flask test client with mocked dependencies."""
|
|
# Create a minimal Flask app for testing
|
|
test_app = Flask(__name__)
|
|
test_app.config['TESTING'] = True
|
|
test_app.config['SECRET_KEY'] = 'test-secret-key'
|
|
|
|
# Register the API blueprint
|
|
from web_interface.blueprints.api_v3 import api_v3
|
|
|
|
# Mock the managers on the blueprint
|
|
api_v3.config_manager = mock_config_manager
|
|
api_v3.plugin_manager = mock_plugin_manager
|
|
api_v3.plugin_store_manager = MagicMock()
|
|
api_v3.saved_repositories_manager = MagicMock()
|
|
api_v3.schema_manager = MagicMock()
|
|
api_v3.operation_queue = MagicMock()
|
|
api_v3.plugin_state_manager = MagicMock()
|
|
api_v3.operation_history = MagicMock()
|
|
api_v3.cache_manager = MagicMock()
|
|
|
|
# Setup operation queue mocks
|
|
mock_operation = MagicMock()
|
|
mock_operation.operation_id = 'test-op-123'
|
|
mock_operation.status = MagicMock(value='pending')
|
|
api_v3.operation_queue.get_operation_status.return_value = mock_operation
|
|
api_v3.operation_queue.get_recent_operations.return_value = []
|
|
|
|
# Setup schema manager mocks
|
|
api_v3.schema_manager.load_schema.return_value = {
|
|
'type': 'object',
|
|
'properties': {'enabled': {'type': 'boolean'}}
|
|
}
|
|
|
|
# Setup state manager mocks
|
|
api_v3.plugin_state_manager.get_all_states.return_value = {}
|
|
|
|
test_app.register_blueprint(api_v3, url_prefix='/api/v3')
|
|
|
|
with test_app.test_client() as client:
|
|
yield client
|
|
|
|
|
|
class TestConfigAPI:
|
|
"""Test configuration API endpoints."""
|
|
|
|
def test_get_main_config(self, client, mock_config_manager):
|
|
"""Test getting main configuration."""
|
|
response = client.get('/api/v3/config/main')
|
|
|
|
assert response.status_code == 200
|
|
data = json.loads(response.data)
|
|
assert data.get('status') == 'success'
|
|
assert 'data' in data
|
|
assert 'display' in data['data']
|
|
mock_config_manager.load_config.assert_called_once()
|
|
|
|
def test_save_main_config(self, client, mock_config_manager):
|
|
"""Test saving main configuration."""
|
|
new_config = {
|
|
'display': {'brightness': 75},
|
|
'timezone': 'UTC'
|
|
}
|
|
|
|
response = client.post(
|
|
'/api/v3/config/main',
|
|
data=json.dumps(new_config),
|
|
content_type='application/json'
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
mock_config_manager.save_config_atomic.assert_called_once()
|
|
|
|
def test_save_main_config_validation_error(self, client, mock_config_manager):
|
|
"""Test saving config with validation error."""
|
|
invalid_config = {'invalid': 'data'}
|
|
|
|
mock_config_manager.save_config_atomic.return_value = MagicMock(
|
|
status=MagicMock(value='validation_failed'),
|
|
message='Validation error'
|
|
)
|
|
|
|
response = client.post(
|
|
'/api/v3/config/main',
|
|
data=json.dumps(invalid_config),
|
|
content_type='application/json'
|
|
)
|
|
|
|
assert response.status_code in [400, 500]
|
|
|
|
def test_save_double_sided_settings(self, client, mock_config_manager):
|
|
"""Double-sided form fields are persisted under display.double_sided."""
|
|
# 2 copies on the vertical axis needs parallel to be a multiple of 2.
|
|
mock_config_manager.load_config.return_value['display']['hardware'] = {
|
|
'chain_length': 2, 'parallel': 2,
|
|
}
|
|
|
|
response = client.post(
|
|
'/api/v3/config/main',
|
|
data={
|
|
'double_sided_enabled': 'true',
|
|
'double_sided_copies': '2',
|
|
'double_sided_axis': 'vertical',
|
|
},
|
|
content_type='application/x-www-form-urlencoded',
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
saved = mock_config_manager.save_config_atomic.call_args[0][0]
|
|
assert saved['display']['double_sided'] == {
|
|
'enabled': True, 'copies': 2, 'axis': 'vertical',
|
|
}
|
|
|
|
def test_save_target_fps(self, client, mock_config_manager):
|
|
"""The device-wide scroll frame rate persists as a top-level int."""
|
|
response = client.post(
|
|
'/api/v3/config/main',
|
|
data={'target_fps': '90'},
|
|
content_type='application/x-www-form-urlencoded',
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
saved = mock_config_manager.save_config_atomic.call_args[0][0]
|
|
# Must be the coerced int, not the raw form string -- the generic
|
|
# remaining-keys loop would otherwise write '90' back over it.
|
|
assert saved['target_fps'] == 90
|
|
|
|
def test_save_target_fps_alone_does_not_reset_other_general_settings(
|
|
self, client, mock_config_manager):
|
|
"""A target_fps-only POST must not be treated as a full General-tab save.
|
|
|
|
The general branch reads web_display_autostart as an unchecked-checkbox
|
|
(absent means False), so counting target_fps as a general update would
|
|
silently switch autostart off for anyone setting only the frame rate.
|
|
"""
|
|
mock_config_manager.load_config.return_value['web_display_autostart'] = True
|
|
|
|
response = client.post(
|
|
'/api/v3/config/main',
|
|
data={'target_fps': '90'},
|
|
content_type='application/x-www-form-urlencoded',
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
saved = mock_config_manager.save_config_atomic.call_args[0][0]
|
|
assert saved['web_display_autostart'] is True
|
|
|
|
@pytest.mark.parametrize('value', [90.5, 90.0, True])
|
|
def test_save_target_fps_rejects_non_integer_json(self, client, mock_config_manager, value):
|
|
"""int() would truncate silently: 90.5 -> 90, True -> 1.
|
|
|
|
Only JSON can carry these; a form post sends '90.5', which int()
|
|
already rejects.
|
|
"""
|
|
response = client.post(
|
|
'/api/v3/config/main',
|
|
data=json.dumps({'target_fps': value}),
|
|
content_type='application/json',
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
|
|
@pytest.mark.parametrize('value', ['20', '250', 'fast'])
|
|
def test_save_target_fps_rejects_out_of_range(self, client, mock_config_manager, value):
|
|
"""Values ScrollHelper would silently clamp are reported instead."""
|
|
response = client.post(
|
|
'/api/v3/config/main',
|
|
data={'target_fps': value},
|
|
content_type='application/x-www-form-urlencoded',
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
|
|
def test_save_target_fps_accepts_bounds(self, client, mock_config_manager):
|
|
"""Both endpoints of the documented range are valid."""
|
|
for value in ('30', '200'):
|
|
response = client.post(
|
|
'/api/v3/config/main',
|
|
data={'target_fps': value},
|
|
content_type='application/x-www-form-urlencoded',
|
|
)
|
|
assert response.status_code == 200, f"{value} should be accepted"
|
|
saved = mock_config_manager.save_config_atomic.call_args[0][0]
|
|
assert saved['target_fps'] == int(value)
|
|
|
|
def test_save_double_sided_unchecked_disables(self, client, mock_config_manager):
|
|
"""An omitted 'enabled' checkbox is saved as disabled, not left stale."""
|
|
response = client.post(
|
|
'/api/v3/config/main',
|
|
data={'double_sided_copies': '4', 'double_sided_axis': 'horizontal'},
|
|
content_type='application/x-www-form-urlencoded',
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
ds = mock_config_manager.save_config_atomic.call_args[0][0]['display']['double_sided']
|
|
assert ds['enabled'] is False
|
|
assert ds['copies'] == 4
|
|
|
|
def test_save_double_sided_disabled_skips_divisibility_check(self, client, mock_config_manager):
|
|
"""A copies/chain_length mismatch must not block saves while disabled.
|
|
|
|
The Display form posts copies/axis on every save, so validating them
|
|
with the feature off locked users out of every other display setting.
|
|
"""
|
|
mock_config_manager.load_config.return_value['display']['hardware'] = {
|
|
'chain_length': 3, 'parallel': 1,
|
|
}
|
|
|
|
response = client.post(
|
|
'/api/v3/config/main',
|
|
data={
|
|
'double_sided_copies': '2',
|
|
'double_sided_axis': 'horizontal',
|
|
'brightness': '75',
|
|
},
|
|
content_type='application/x-www-form-urlencoded',
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
ds = mock_config_manager.save_config_atomic.call_args[0][0]['display']['double_sided']
|
|
assert ds['enabled'] is False
|
|
assert ds['copies'] == 2
|
|
|
|
def test_save_double_sided_enabled_enforces_divisibility(self, client, mock_config_manager):
|
|
"""The same mismatch is still rejected once the feature is turned on."""
|
|
mock_config_manager.load_config.return_value['display']['hardware'] = {
|
|
'chain_length': 3, 'parallel': 1,
|
|
}
|
|
|
|
response = client.post(
|
|
'/api/v3/config/main',
|
|
data={
|
|
'double_sided_enabled': 'true',
|
|
'double_sided_copies': '2',
|
|
'double_sided_axis': 'horizontal',
|
|
},
|
|
content_type='application/x-www-form-urlencoded',
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert 'chain length' in response.get_json()['message']
|
|
mock_config_manager.save_config_atomic.assert_not_called()
|
|
|
|
def test_save_double_sided_vertical_checks_parallel(self, client, mock_config_manager):
|
|
"""The vertical axis is checked against parallel, not chain_length."""
|
|
mock_config_manager.load_config.return_value['display']['hardware'] = {
|
|
'chain_length': 2, 'parallel': 3,
|
|
}
|
|
|
|
response = client.post(
|
|
'/api/v3/config/main',
|
|
data={
|
|
'double_sided_enabled': 'true',
|
|
'double_sided_copies': '2',
|
|
'double_sided_axis': 'vertical',
|
|
},
|
|
content_type='application/x-www-form-urlencoded',
|
|
)
|
|
|
|
# chain_length 2 would divide evenly — only parallel 3 rejects this.
|
|
assert response.status_code == 400
|
|
assert 'parallel' in response.get_json()['message']
|
|
mock_config_manager.save_config_atomic.assert_not_called()
|
|
|
|
def test_save_double_sided_disabled_ignores_bad_values(self, client, mock_config_manager):
|
|
"""While disabled, unusable copies/axis are dropped rather than rejected."""
|
|
mock_config_manager.load_config.return_value['display']['double_sided'] = {
|
|
'enabled': True, 'copies': 2, 'axis': 'horizontal',
|
|
}
|
|
|
|
response = client.post(
|
|
'/api/v3/config/main',
|
|
data={'double_sided_copies': 'abc', 'double_sided_axis': 'diagonal'},
|
|
content_type='application/x-www-form-urlencoded',
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
ds = mock_config_manager.save_config_atomic.call_args[0][0]['display']['double_sided']
|
|
assert ds['enabled'] is False
|
|
# Stored values left untouched rather than overwritten with junk.
|
|
assert ds['copies'] == 2
|
|
assert ds['axis'] == 'horizontal'
|
|
|
|
def test_save_double_sided_invalid_copies_rejected(self, client, mock_config_manager):
|
|
"""copies < 2 is rejected with a 400 before any save."""
|
|
response = client.post(
|
|
'/api/v3/config/main',
|
|
data={'double_sided_enabled': 'true', 'double_sided_copies': '1'},
|
|
content_type='application/x-www-form-urlencoded',
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
mock_config_manager.save_config_atomic.assert_not_called()
|
|
|
|
def test_save_double_sided_invalid_axis_rejected(self, client, mock_config_manager):
|
|
"""An unknown axis is rejected with a 400 before any save."""
|
|
response = client.post(
|
|
'/api/v3/config/main',
|
|
data={'double_sided_enabled': 'true', 'double_sided_axis': 'diagonal'},
|
|
content_type='application/x-www-form-urlencoded',
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
mock_config_manager.save_config_atomic.assert_not_called()
|
|
|
|
def test_get_secrets_config(self, client, mock_config_manager):
|
|
"""Test getting secrets configuration."""
|
|
response = client.get('/api/v3/config/secrets')
|
|
|
|
assert response.status_code == 200
|
|
data = json.loads(response.data)
|
|
assert 'weather' in data or 'data' in data
|
|
mock_config_manager.get_raw_file_content.assert_called_once()
|
|
|
|
def test_save_schedule_config(self, client, mock_config_manager):
|
|
"""Test saving schedule configuration."""
|
|
schedule_config = {
|
|
'enabled': True,
|
|
'start_time': '07:00',
|
|
'end_time': '23:00',
|
|
'mode': 'global'
|
|
}
|
|
|
|
response = client.post(
|
|
'/api/v3/config/schedule',
|
|
data=json.dumps(schedule_config),
|
|
content_type='application/json'
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
mock_config_manager.save_config_atomic.assert_called_once()
|
|
|
|
|
|
class TestSystemAPI:
|
|
"""Test system API endpoints."""
|
|
|
|
@patch('web_interface.blueprints.api_v3.system.subprocess')
|
|
def test_get_system_status(self, mock_subprocess, client):
|
|
"""Test getting system status."""
|
|
# The endpoint returns 503 without psutil, which is an optional
|
|
# runtime dependency (requirements-test.txt installs it for CI).
|
|
pytest.importorskip("psutil")
|
|
mock_result = MagicMock()
|
|
mock_result.stdout = 'active\n'
|
|
mock_result.returncode = 0
|
|
mock_subprocess.run.return_value = mock_result
|
|
|
|
response = client.get('/api/v3/system/status')
|
|
|
|
assert response.status_code == 200
|
|
data = json.loads(response.data)
|
|
assert 'service' in data or 'status' in data or 'active' in data
|
|
|
|
@patch('web_interface.blueprints.api_v3.system.subprocess')
|
|
def test_get_system_version(self, mock_subprocess, client):
|
|
"""Test getting system version."""
|
|
mock_result = MagicMock()
|
|
mock_result.returncode = 0
|
|
mock_result.stdout = 'v1.0.0\n'
|
|
mock_subprocess.run.return_value = mock_result
|
|
|
|
response = client.get('/api/v3/system/version')
|
|
|
|
assert response.status_code == 200
|
|
data = json.loads(response.data)
|
|
assert 'version' in data.get('data', {}) or 'version' in data
|
|
|
|
@patch('web_interface.blueprints.api_v3.system.subprocess')
|
|
def test_execute_system_action(self, mock_subprocess, client):
|
|
"""Test executing system action."""
|
|
mock_result = MagicMock()
|
|
mock_result.returncode = 0
|
|
mock_result.stdout = 'success'
|
|
mock_subprocess.run.return_value = mock_result
|
|
|
|
action_data = {
|
|
'action': 'restart',
|
|
'service': 'ledmatrix'
|
|
}
|
|
|
|
response = client.post(
|
|
'/api/v3/system/action',
|
|
data=json.dumps(action_data),
|
|
content_type='application/json'
|
|
)
|
|
|
|
# May return 400 if action validation fails, or 200 if successful
|
|
assert response.status_code in [200, 400]
|
|
|
|
|
|
class TestDisplayAPI:
|
|
"""Test display API endpoints."""
|
|
|
|
def test_get_display_current(self, client):
|
|
"""Test getting current display information."""
|
|
# Mock cache manager on the blueprint
|
|
from web_interface.blueprints.api_v3 import api_v3
|
|
api_v3.cache_manager.get.return_value = {
|
|
'mode': 'weather',
|
|
'plugin_id': 'weather'
|
|
}
|
|
|
|
response = client.get('/api/v3/display/current')
|
|
|
|
assert response.status_code == 200
|
|
data = json.loads(response.data)
|
|
assert 'mode' in data or 'current' in data or 'data' in data
|
|
|
|
def test_get_on_demand_status(self, client):
|
|
"""Test getting on-demand display status."""
|
|
from web_interface.blueprints.api_v3 import api_v3
|
|
api_v3.cache_manager.get.return_value = {
|
|
'active': False,
|
|
'mode': None
|
|
}
|
|
|
|
response = client.get('/api/v3/display/on-demand/status')
|
|
|
|
assert response.status_code == 200
|
|
data = json.loads(response.data)
|
|
assert 'active' in data or 'status' in data or 'data' in data
|
|
|
|
def test_start_on_demand_display(self, client):
|
|
"""Test starting on-demand display."""
|
|
from web_interface.blueprints.api_v3 import api_v3
|
|
|
|
request_data = {
|
|
'plugin_id': 'weather',
|
|
'mode': 'weather_current',
|
|
'duration': 30
|
|
}
|
|
|
|
# Ensure cache manager is set up
|
|
if not hasattr(api_v3, 'cache_manager') or api_v3.cache_manager is None:
|
|
api_v3.cache_manager = MagicMock()
|
|
|
|
response = client.post(
|
|
'/api/v3/display/on-demand/start',
|
|
data=json.dumps(request_data),
|
|
content_type='application/json'
|
|
)
|
|
|
|
# May return 404 if plugin not found, 200 if successful, or 500 on error
|
|
assert response.status_code in [200, 201, 404, 500]
|
|
# Verify cache was updated if successful
|
|
if response.status_code in [200, 201]:
|
|
assert api_v3.cache_manager.set.called
|
|
|
|
@patch('web_interface.blueprints.api_v3.display._ensure_cache_manager')
|
|
def test_stop_on_demand_display(self, mock_ensure_cache, client):
|
|
"""Test stopping on-demand display."""
|
|
|
|
# Mock the cache manager returned by _ensure_cache_manager
|
|
mock_cache_manager = MagicMock()
|
|
mock_ensure_cache.return_value = mock_cache_manager
|
|
|
|
response = client.post('/api/v3/display/on-demand/stop')
|
|
|
|
# May return 200 if successful or 500 on error
|
|
assert response.status_code in [200, 500]
|
|
# Verify stop request was set in cache if successful
|
|
if response.status_code == 200:
|
|
assert mock_cache_manager.set.called
|
|
|
|
|
|
class TestPluginsAPI:
|
|
"""Test plugins API endpoints."""
|
|
|
|
def test_get_installed_plugins(self, client, mock_plugin_manager):
|
|
"""Test getting list of installed plugins."""
|
|
from web_interface.blueprints.api_v3 import api_v3
|
|
api_v3.plugin_manager = mock_plugin_manager
|
|
|
|
mock_plugin_manager.plugins = {
|
|
'weather': MagicMock(plugin_id='weather'),
|
|
'clock': MagicMock(plugin_id='clock')
|
|
}
|
|
mock_plugin_manager.get_plugin_metadata.return_value = {
|
|
'id': 'weather',
|
|
'name': 'Weather Plugin'
|
|
}
|
|
|
|
response = client.get('/api/v3/plugins/installed')
|
|
|
|
assert response.status_code == 200
|
|
data = json.loads(response.data)
|
|
assert isinstance(data, (list, dict))
|
|
|
|
def test_installed_plugins_report_update_available(self, client, mock_plugin_manager):
|
|
"""Installed-plugin entries surface latest_version + update_available
|
|
by comparing the on-disk manifest version to the registry."""
|
|
from web_interface.blueprints.api_v3 import api_v3
|
|
api_v3.plugin_manager = mock_plugin_manager
|
|
# No on-disk manifest to merge — keep the version we hand in below.
|
|
mock_plugin_manager.plugins_dir = '/nonexistent-plugins-dir'
|
|
mock_plugin_manager.get_all_plugin_info.return_value = [
|
|
{'id': 'weather', 'name': 'Weather', 'version': '1.0.0'}
|
|
]
|
|
# Avoid touching plugin instances (Vegas hooks, enabled fallback).
|
|
mock_plugin_manager.get_plugin.return_value = None
|
|
# Registry advertises a newer version than the installed one.
|
|
api_v3.plugin_store_manager.get_registry_info.return_value = {
|
|
'verified': True, 'latest_version': '1.2.0'
|
|
}
|
|
|
|
response = client.get('/api/v3/plugins/installed')
|
|
|
|
assert response.status_code == 200
|
|
payload = json.loads(response.data)
|
|
entry = payload['data']['plugins'][0]
|
|
assert entry['version'] == '1.0.0'
|
|
assert entry['latest_version'] == '1.2.0'
|
|
assert entry['update_available'] is True
|
|
|
|
def test_installed_plugins_no_update_when_current(self, client, mock_plugin_manager):
|
|
"""No update is flagged when installed version matches the registry."""
|
|
from web_interface.blueprints.api_v3 import api_v3
|
|
api_v3.plugin_manager = mock_plugin_manager
|
|
mock_plugin_manager.plugins_dir = '/nonexistent-plugins-dir'
|
|
mock_plugin_manager.get_all_plugin_info.return_value = [
|
|
{'id': 'weather', 'name': 'Weather', 'version': '1.2.0'}
|
|
]
|
|
mock_plugin_manager.get_plugin.return_value = None
|
|
api_v3.plugin_store_manager.get_registry_info.return_value = {
|
|
'verified': True, 'latest_version': '1.2.0'
|
|
}
|
|
|
|
response = client.get('/api/v3/plugins/installed')
|
|
|
|
assert response.status_code == 200
|
|
entry = json.loads(response.data)['data']['plugins'][0]
|
|
assert entry['latest_version'] == '1.2.0'
|
|
assert entry['update_available'] is False
|
|
|
|
def test_is_plugin_update_available_helper(self):
|
|
"""Unit-level checks for the semver-aware update comparison."""
|
|
from web_interface.blueprints.api_v3 import _is_plugin_update_available
|
|
assert _is_plugin_update_available('1.0.0', '1.0.1') is True
|
|
assert _is_plugin_update_available('1.0.1', '1.0.1') is False
|
|
# Local build ahead of the registry must not be flagged.
|
|
assert _is_plugin_update_available('2.0.0', '1.9.9') is False
|
|
# Missing either side yields no signal.
|
|
assert _is_plugin_update_available('', '1.0.0') is False
|
|
assert _is_plugin_update_available('1.0.0', '') is False
|
|
# Unparseable version differing from the installed one surfaces the
|
|
# mismatch rather than hiding a possible update.
|
|
assert _is_plugin_update_available('1.0.0', 'not-a-semver') is True
|
|
|
|
def test_get_plugin_health(self, client, mock_plugin_manager):
|
|
"""Test getting plugin health information."""
|
|
from web_interface.blueprints.api_v3 import api_v3
|
|
api_v3.plugin_manager = mock_plugin_manager
|
|
|
|
# Setup health tracker
|
|
mock_health_tracker = MagicMock()
|
|
mock_health_tracker.get_all_health_summaries.return_value = {
|
|
'weather': {'healthy': True}
|
|
}
|
|
mock_plugin_manager.health_tracker = mock_health_tracker
|
|
|
|
response = client.get('/api/v3/plugins/health')
|
|
|
|
assert response.status_code == 200
|
|
data = json.loads(response.data)
|
|
assert isinstance(data, (list, dict))
|
|
|
|
def test_get_plugin_health_single(self, client, mock_plugin_manager):
|
|
"""Test getting health for single plugin."""
|
|
from web_interface.blueprints.api_v3 import api_v3
|
|
api_v3.plugin_manager = mock_plugin_manager
|
|
|
|
# Setup health tracker with proper method (endpoint calls get_health_summary)
|
|
mock_health_tracker = MagicMock()
|
|
mock_health_tracker.get_health_summary.return_value = {
|
|
'healthy': True,
|
|
'failures': 0,
|
|
'last_success': '2024-01-01T00:00:00'
|
|
}
|
|
mock_plugin_manager.health_tracker = mock_health_tracker
|
|
|
|
response = client.get('/api/v3/plugins/health/weather')
|
|
|
|
assert response.status_code == 200
|
|
data = json.loads(response.data)
|
|
assert 'healthy' in data.get('data', {}) or 'data' in data
|
|
|
|
def test_toggle_plugin(self, client, mock_config_manager, mock_plugin_manager):
|
|
"""Test toggling plugin enabled state."""
|
|
from web_interface.blueprints.api_v3 import api_v3
|
|
api_v3.config_manager = mock_config_manager
|
|
api_v3.plugin_manager = mock_plugin_manager
|
|
api_v3.plugin_state_manager = MagicMock()
|
|
api_v3.operation_history = MagicMock()
|
|
|
|
# Setup plugin manifests
|
|
mock_plugin_manager.plugin_manifests = {'weather': {}}
|
|
|
|
request_data = {
|
|
'plugin_id': 'weather',
|
|
'enabled': True
|
|
}
|
|
|
|
response = client.post(
|
|
'/api/v3/plugins/toggle',
|
|
data=json.dumps(request_data),
|
|
content_type='application/json'
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
mock_config_manager.save_config_atomic.assert_called_once()
|
|
|
|
def test_get_plugin_config(self, client, mock_config_manager):
|
|
"""Test getting plugin configuration."""
|
|
# Plugin configs live at top-level keys (not under 'plugins')
|
|
mock_config_manager.load_config.return_value = {
|
|
'weather': {
|
|
'enabled': True,
|
|
'api_key': 'test_key'
|
|
}
|
|
}
|
|
|
|
# Ensure schema manager returns serializable values
|
|
from web_interface.blueprints.api_v3 import api_v3
|
|
api_v3.schema_manager.generate_default_config.return_value = {'enabled': False}
|
|
api_v3.schema_manager.merge_with_defaults.side_effect = lambda config, defaults: {**defaults, **config}
|
|
|
|
response = client.get('/api/v3/plugins/config?plugin_id=weather')
|
|
|
|
assert response.status_code == 200
|
|
data = json.loads(response.data)
|
|
assert 'enabled' in data or 'config' in data or 'data' in data
|
|
|
|
def test_save_plugin_config(self, client, mock_config_manager):
|
|
"""Test saving plugin configuration."""
|
|
from web_interface.blueprints.api_v3 import api_v3
|
|
api_v3.config_manager = mock_config_manager
|
|
api_v3.schema_manager = MagicMock()
|
|
api_v3.schema_manager.load_schema.return_value = {
|
|
'type': 'object',
|
|
'properties': {'enabled': {'type': 'boolean'}}
|
|
}
|
|
|
|
request_data = {
|
|
'plugin_id': 'weather',
|
|
'config': {
|
|
'enabled': True,
|
|
'update_interval': 300
|
|
}
|
|
}
|
|
|
|
response = client.post(
|
|
'/api/v3/plugins/config',
|
|
data=json.dumps(request_data),
|
|
content_type='application/json'
|
|
)
|
|
|
|
assert response.status_code in [200, 500] # May fail if validation fails
|
|
if response.status_code == 200:
|
|
mock_config_manager.save_config_atomic.assert_called_once()
|
|
|
|
def test_get_plugin_schema(self, client):
|
|
"""Test getting plugin configuration schema."""
|
|
|
|
response = client.get('/api/v3/plugins/schema?plugin_id=weather')
|
|
|
|
assert response.status_code == 200
|
|
data = json.loads(response.data)
|
|
assert 'type' in data or 'schema' in data or 'data' in data
|
|
|
|
def test_get_operation_status(self, client):
|
|
"""Test getting plugin operation status."""
|
|
from web_interface.blueprints.api_v3 import api_v3
|
|
|
|
# Setup operation queue mock
|
|
mock_operation = MagicMock()
|
|
mock_operation.operation_id = 'test-op-123'
|
|
mock_operation.status = MagicMock(value='pending')
|
|
mock_operation.operation_type = MagicMock(value='install')
|
|
mock_operation.plugin_id = 'test-plugin'
|
|
mock_operation.created_at = '2024-01-01T00:00:00'
|
|
# Add to_dict method that the endpoint calls
|
|
mock_operation.to_dict.return_value = {
|
|
'operation_id': 'test-op-123',
|
|
'status': 'pending',
|
|
'operation_type': 'install',
|
|
'plugin_id': 'test-plugin'
|
|
}
|
|
|
|
api_v3.operation_queue.get_operation_status.return_value = mock_operation
|
|
|
|
response = client.get('/api/v3/plugins/operation/test-op-123')
|
|
|
|
assert response.status_code == 200
|
|
data = json.loads(response.data)
|
|
assert 'status' in data or 'operation' in data or 'data' in data
|
|
|
|
def test_get_operation_history(self, client):
|
|
"""Test getting operation history."""
|
|
|
|
response = client.get('/api/v3/plugins/operation/history')
|
|
|
|
assert response.status_code == 200
|
|
data = json.loads(response.data)
|
|
assert isinstance(data, (list, dict))
|
|
|
|
def test_get_plugin_state(self, client):
|
|
"""Test getting plugin state."""
|
|
|
|
response = client.get('/api/v3/plugins/state')
|
|
|
|
assert response.status_code == 200
|
|
data = json.loads(response.data)
|
|
assert isinstance(data, (list, dict))
|
|
|
|
|
|
class TestFontsAPI:
|
|
"""Test fonts API endpoints."""
|
|
|
|
def test_get_fonts_catalog(self, client):
|
|
"""Test getting fonts catalog."""
|
|
# Fonts endpoints don't use FontManager, they return hardcoded data
|
|
response = client.get('/api/v3/fonts/catalog')
|
|
|
|
assert response.status_code == 200
|
|
data = json.loads(response.data)
|
|
assert 'catalog' in data.get('data', {}) or 'data' in data
|
|
|
|
def test_get_font_tokens(self, client):
|
|
"""Test getting font tokens."""
|
|
response = client.get('/api/v3/fonts/tokens')
|
|
|
|
assert response.status_code == 200
|
|
data = json.loads(response.data)
|
|
assert 'tokens' in data.get('data', {}) or 'data' in data
|
|
|
|
def test_get_fonts_overrides(self, client):
|
|
"""Test getting font overrides."""
|
|
response = client.get('/api/v3/fonts/overrides')
|
|
|
|
assert response.status_code == 200
|
|
data = json.loads(response.data)
|
|
assert 'overrides' in data.get('data', {}) or 'data' in data
|
|
|
|
def test_save_fonts_overrides(self, client):
|
|
"""Test saving font overrides."""
|
|
request_data = {
|
|
'weather': 'small',
|
|
'clock': 'regular'
|
|
}
|
|
|
|
response = client.post(
|
|
'/api/v3/fonts/overrides',
|
|
data=json.dumps(request_data),
|
|
content_type='application/json'
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
|
class TestAPIErrorHandling:
|
|
"""Test API error handling."""
|
|
|
|
def test_invalid_json_request(self, client):
|
|
"""Test handling invalid JSON in request."""
|
|
response = client.post(
|
|
'/api/v3/config/main',
|
|
data='invalid json',
|
|
content_type='application/json'
|
|
)
|
|
|
|
# Flask may return 500 for JSON decode errors or 400 for bad request
|
|
assert response.status_code in [400, 415, 500]
|
|
|
|
def test_missing_required_fields(self, client):
|
|
"""Test handling missing required fields."""
|
|
response = client.post(
|
|
'/api/v3/plugins/toggle',
|
|
data=json.dumps({}),
|
|
content_type='application/json'
|
|
)
|
|
|
|
assert response.status_code in [400, 422, 500]
|
|
|
|
def test_nonexistent_endpoint(self, client):
|
|
"""Test accessing nonexistent endpoint."""
|
|
response = client.get('/api/v3/nonexistent')
|
|
|
|
assert response.status_code == 404
|
|
|
|
def test_method_not_allowed(self, client):
|
|
"""Test using wrong HTTP method."""
|
|
# GET instead of POST
|
|
response = client.get('/api/v3/config/main',
|
|
query_string={'method': 'POST'})
|
|
|
|
# Should work for GET, but if we try POST-only endpoint with GET
|
|
response = client.get('/api/v3/config/schedule')
|
|
|
|
# Schedule might allow GET, so test a POST-only endpoint
|
|
response = client.get('/api/v3/display/on-demand/start')
|
|
|
|
assert response.status_code in [200, 405] # Depends on implementation
|
|
|
|
|
|
class TestDottedKeyNormalization:
|
|
"""Regression tests for fix_array_structures / ensure_array_defaults with dotted schema keys."""
|
|
|
|
def test_save_plugin_config_dotted_key_arrays(self, client, mock_config_manager):
|
|
"""Nested dotted-key objects with numeric-keyed dicts are converted to arrays."""
|
|
from web_interface.blueprints.api_v3 import api_v3
|
|
|
|
api_v3.config_manager = mock_config_manager
|
|
mock_config_manager.load_config.return_value = {}
|
|
|
|
schema_mgr = MagicMock()
|
|
schema = {
|
|
'type': 'object',
|
|
'properties': {
|
|
'leagues': {
|
|
'type': 'object',
|
|
'properties': {
|
|
'eng.1': {
|
|
'type': 'object',
|
|
'properties': {
|
|
'enabled': {'type': 'boolean', 'default': True},
|
|
'favorite_teams': {
|
|
'type': 'array',
|
|
'items': {'type': 'string'},
|
|
'default': [],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
schema_mgr.load_schema.return_value = schema
|
|
schema_mgr.generate_default_config.return_value = {
|
|
'leagues': {'eng.1': {'enabled': True, 'favorite_teams': []}},
|
|
}
|
|
schema_mgr.merge_with_defaults.side_effect = lambda config, defaults: {**defaults, **config}
|
|
# Must be a (bool, list) tuple: the endpoint does is_valid, errors = validate_config_against_schema(...)
|
|
schema_mgr.validate_config_against_schema.return_value = (True, [])
|
|
api_v3.schema_manager = schema_mgr
|
|
|
|
request_data = {
|
|
'plugin_id': 'soccer-scoreboard',
|
|
'config': {
|
|
'leagues': {
|
|
'eng.1': {
|
|
'enabled': True,
|
|
'favorite_teams': ['Arsenal', 'Chelsea'],
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
response = client.post(
|
|
'/api/v3/plugins/config',
|
|
data=json.dumps(request_data),
|
|
content_type='application/json',
|
|
)
|
|
|
|
assert response.status_code == 200, f"Expected 200, got {response.status_code}: {response.data}"
|
|
saved = mock_config_manager.save_config_atomic.call_args[0][0]
|
|
soccer_cfg = saved.get('soccer-scoreboard', {})
|
|
leagues = soccer_cfg.get('leagues', {})
|
|
assert 'eng.1' in leagues, f"Expected 'eng.1' key, got: {list(leagues.keys())}"
|
|
assert isinstance(leagues['eng.1'].get('favorite_teams'), list)
|
|
assert leagues['eng.1']['favorite_teams'] == ['Arsenal', 'Chelsea']
|
|
|
|
def test_save_plugin_config_none_array_gets_default(self, client, mock_config_manager):
|
|
"""None array fields under dotted-key parents are replaced with defaults."""
|
|
from web_interface.blueprints.api_v3 import api_v3
|
|
|
|
api_v3.config_manager = mock_config_manager
|
|
mock_config_manager.load_config.return_value = {}
|
|
|
|
schema_mgr = MagicMock()
|
|
schema = {
|
|
'type': 'object',
|
|
'properties': {
|
|
'leagues': {
|
|
'type': 'object',
|
|
'properties': {
|
|
'eng.1': {
|
|
'type': 'object',
|
|
'properties': {
|
|
'favorite_teams': {
|
|
'type': 'array',
|
|
'items': {'type': 'string'},
|
|
'default': [],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
schema_mgr.load_schema.return_value = schema
|
|
schema_mgr.generate_default_config.return_value = {
|
|
'leagues': {'eng.1': {'favorite_teams': []}},
|
|
}
|
|
schema_mgr.merge_with_defaults.side_effect = lambda config, defaults: {**defaults, **config}
|
|
schema_mgr.validate_config_against_schema.return_value = (True, [])
|
|
api_v3.schema_manager = schema_mgr
|
|
|
|
request_data = {
|
|
'plugin_id': 'soccer-scoreboard',
|
|
'config': {
|
|
'leagues': {
|
|
'eng.1': {
|
|
'favorite_teams': None,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
response = client.post(
|
|
'/api/v3/plugins/config',
|
|
data=json.dumps(request_data),
|
|
content_type='application/json',
|
|
)
|
|
|
|
assert response.status_code == 200, f"Expected 200, got {response.status_code}: {response.data}"
|
|
saved = mock_config_manager.save_config_atomic.call_args[0][0]
|
|
soccer_cfg = saved.get('soccer-scoreboard', {})
|
|
teams = soccer_cfg.get('leagues', {}).get('eng.1', {}).get('favorite_teams')
|
|
assert isinstance(teams, list), f"Expected list, got: {type(teams)}"
|
|
assert teams == [], f"Expected empty default list, got: {teams}"
|
|
|
|
|
|
class TestPluginHealthRoutes:
|
|
"""Phase 1: /plugins/health and /plugins/metrics build per-installed-id so
|
|
they surface cross-process data persisted by the display service."""
|
|
|
|
def test_health_route_builds_per_installed_id(self, client, mock_plugin_manager):
|
|
from web_interface.blueprints.api_v3 import api_v3
|
|
from src.plugin_system.plugin_health import PluginHealthTracker
|
|
|
|
cache = MagicMock()
|
|
cache.get.return_value = None
|
|
api_v3.plugin_manager = mock_plugin_manager
|
|
mock_plugin_manager.plugin_manifests = {'p1': {}, 'p2': {}}
|
|
mock_plugin_manager.health_tracker = PluginHealthTracker(cache)
|
|
|
|
resp = client.get('/api/v3/plugins/health')
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()['data']
|
|
assert set(data.keys()) == {'p1', 'p2'}
|
|
assert data['p1']['is_healthy'] is True
|
|
assert data['p1']['degraded'] is False
|
|
|
|
def test_health_route_reports_not_available_without_tracker(self, client, mock_plugin_manager):
|
|
from web_interface.blueprints.api_v3 import api_v3
|
|
api_v3.plugin_manager = mock_plugin_manager
|
|
mock_plugin_manager.health_tracker = None
|
|
|
|
resp = client.get('/api/v3/plugins/health')
|
|
assert resp.status_code == 200
|
|
body = resp.get_json()
|
|
assert body['data'] == {}
|
|
assert 'not available' in body['message'].lower()
|
|
|
|
def test_metrics_route_builds_per_installed_id(self, client, mock_plugin_manager):
|
|
from web_interface.blueprints.api_v3 import api_v3
|
|
from src.plugin_system.resource_monitor import PluginResourceMonitor
|
|
|
|
cache = MagicMock()
|
|
cache.get.return_value = None
|
|
api_v3.plugin_manager = mock_plugin_manager
|
|
mock_plugin_manager.plugin_manifests = {'p1': {}}
|
|
mock_plugin_manager.resource_monitor = PluginResourceMonitor(
|
|
cache, enable_monitoring=False
|
|
)
|
|
|
|
resp = client.get('/api/v3/plugins/metrics')
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()['data']
|
|
assert 'p1' in data
|
|
assert data['p1']['call_count'] == 0
|