mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 21:03:01 +00:00
* fix(test): repair test infrastructure and mock fixtures - Add test/__init__.py for proper test collection - Fix ConfigManager instantiation to use config_path parameter - Route schedule config through config_service mock - Update mock to match get_raw_file_content endpoint change Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(test): correct get_main_config assertion per CodeRabbit review The endpoint calls load_config(), not get_raw_file_content('main'). Also set up load_config mock return value in the fixture so the test's data assertions pass correctly. Co-Authored-By: 5ymb01 <noreply@github.com> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(test): correct plugin config test mock structure and schema returns - Plugin configs live at top-level keys, not under 'plugins' subkey - Mock schema_manager.generate_default_config to return a dict - Mock schema_manager.merge_with_defaults to merge dicts (not MagicMock) - Fixes test_get_plugin_config returning 500 due to non-serializable MagicMock Co-Authored-By: 5ymb01 <noreply@github.com> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(test): use patch.object for config_service.get_config in schedule tests config_service.get_config is a real method, not a mock — can't set return_value on it directly. Use patch.object context manager instead. Co-Authored-By: 5ymb01 <noreply@github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: 5ymb01 <5ymb01@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: 5ymb01 <noreply@github.com>
586 lines
20 KiB
Python
586 lines
20 KiB
Python
"""
|
|
Tests for Web Interface API endpoints.
|
|
|
|
Tests Flask routes, request/response handling, and API functionality.
|
|
"""
|
|
|
|
import pytest
|
|
import json
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch, Mock
|
|
|
|
# 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_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.subprocess')
|
|
def test_get_system_status(self, mock_subprocess, client):
|
|
"""Test getting system status."""
|
|
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.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.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._ensure_cache_manager')
|
|
def test_stop_on_demand_display(self, mock_ensure_cache, client):
|
|
"""Test stopping on-demand display."""
|
|
from web_interface.blueprints.api_v3 import api_v3
|
|
|
|
# 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_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."""
|
|
from web_interface.blueprints.api_v3 import api_v3
|
|
|
|
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."""
|
|
from web_interface.blueprints.api_v3 import api_v3
|
|
|
|
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."""
|
|
from web_interface.blueprints.api_v3 import api_v3
|
|
|
|
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
|