Files
LEDMatrix/test/test_config_validation_edge_cases.py
Chuck 8fb2800495 feat: add error detection, monitoring, and code quality improvements (#223)
* feat: add error detection, monitoring, and code quality improvements

This comprehensive update addresses automatic error detection, code
quality, and plugin development experience:

## Error Detection & Monitoring
- Add ErrorAggregator service for centralized error tracking
- Add pattern detection for recurring errors (5+ in 60 min)
- Add error dashboard API endpoints (/api/v3/errors/*)
- Integrate error recording into plugin executor

## Code Quality
- Remove 10 silent `except: pass` blocks in sports.py and football.py
- Remove hardcoded debug log paths
- Add pre-commit hooks to prevent future bare except clauses

## Validation & Type Safety
- Add warnings when plugins lack config_schema.json
- Add config key collision detection for plugins
- Improve type coercion logging in BasePlugin

## Testing
- Add test_config_validation_edge_cases.py
- Add test_plugin_loading_failures.py
- Add test_error_aggregator.py

## Documentation
- Add PLUGIN_ERROR_HANDLING.md guide
- Add CONFIG_DEBUGGING.md guide

Note: GitHub Actions CI workflow is available in the plan but requires
workflow scope to push. Add .github/workflows/ci.yml manually.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address code review issues

- Fix GitHub issues URL in CONFIG_DEBUGGING.md
- Use RLock in error_aggregator.py to prevent deadlock in export_to_file
- Distinguish missing vs invalid schema files in plugin_manager.py
- Add assertions to test_null_value_for_required_field test
- Remove unused initial_count variable in test_plugin_load_error_recorded
- Add validation for max_age_hours in clear_old_errors API endpoint

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 10:05:09 -05:00

309 lines
10 KiB
Python

"""
Tests for configuration validation edge cases.
Tests scenarios that commonly cause user configuration errors:
- Invalid JSON in config files
- Missing required fields
- Type mismatches
- Nested object validation
- Array validation
"""
import pytest
import json
from pathlib import Path
from unittest.mock import Mock, patch, MagicMock
import tempfile
import os
# Add project root to path
import sys
project_root = Path(__file__).parent.parent
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
from src.config_manager import ConfigManager
from src.plugin_system.schema_manager import SchemaManager
class TestInvalidJson:
"""Test handling of invalid JSON in config files."""
def test_invalid_json_syntax(self, tmp_path):
"""Config with invalid JSON syntax should be handled gracefully."""
config_file = tmp_path / "config.json"
config_file.write_text("{ invalid json }")
with patch.object(ConfigManager, '_get_config_path', return_value=str(config_file)):
config_manager = ConfigManager(config_dir=str(tmp_path))
# Should not raise, should return empty or default config
config = config_manager.load_config()
assert isinstance(config, dict)
def test_truncated_json(self, tmp_path):
"""Config with truncated JSON should be handled gracefully."""
config_file = tmp_path / "config.json"
config_file.write_text('{"plugin": {"enabled": true') # Missing closing braces
with patch.object(ConfigManager, '_get_config_path', return_value=str(config_file)):
config_manager = ConfigManager(config_dir=str(tmp_path))
config = config_manager.load_config()
assert isinstance(config, dict)
def test_empty_config_file(self, tmp_path):
"""Empty config file should be handled gracefully."""
config_file = tmp_path / "config.json"
config_file.write_text("")
with patch.object(ConfigManager, '_get_config_path', return_value=str(config_file)):
config_manager = ConfigManager(config_dir=str(tmp_path))
config = config_manager.load_config()
assert isinstance(config, dict)
class TestTypeValidation:
"""Test type validation and coercion."""
def test_string_where_number_expected(self):
"""String value where number expected should be handled."""
schema_manager = SchemaManager()
schema = {
"type": "object",
"properties": {
"display_duration": {"type": "number", "default": 15}
}
}
config = {"display_duration": "invalid_string"}
# Validation should fail
is_valid, errors = schema_manager.validate_config_against_schema(
config, schema, "test-plugin"
)
assert not is_valid
assert len(errors) > 0
def test_number_where_string_expected(self):
"""Number value where string expected should be handled."""
schema_manager = SchemaManager()
schema = {
"type": "object",
"properties": {
"team_name": {"type": "string", "default": ""}
}
}
config = {"team_name": 12345}
is_valid, errors = schema_manager.validate_config_against_schema(
config, schema, "test-plugin"
)
assert not is_valid
assert len(errors) > 0
def test_null_value_for_required_field(self):
"""Null value for required field should be detected."""
schema_manager = SchemaManager()
# Schema that explicitly disallows null for api_key
schema = {
"type": "object",
"properties": {
"api_key": {"type": "string"} # string type doesn't allow null
},
"required": ["api_key"]
}
config = {"api_key": None}
is_valid, errors = schema_manager.validate_config_against_schema(
config, schema, "test-plugin"
)
# JSON Schema Draft 7: null is not a valid string type
assert not is_valid, "Null value should fail validation for string type"
assert errors, "Should have validation errors"
assert any("api_key" in str(e).lower() or "null" in str(e).lower() or "type" in str(e).lower() for e in errors), \
f"Error should mention api_key, null, or type issue: {errors}"
class TestNestedValidation:
"""Test validation of nested configuration objects."""
def test_nested_object_missing_required(self):
"""Missing required field in nested object should be detected."""
schema_manager = SchemaManager()
schema = {
"type": "object",
"properties": {
"nfl": {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"api_key": {"type": "string"}
},
"required": ["api_key"]
}
}
}
config = {"nfl": {"enabled": True}} # Missing api_key
is_valid, errors = schema_manager.validate_config_against_schema(
config, schema, "test-plugin"
)
assert not is_valid
def test_deeply_nested_validation(self):
"""Validation should work for deeply nested objects."""
schema_manager = SchemaManager()
schema = {
"type": "object",
"properties": {
"level1": {
"type": "object",
"properties": {
"level2": {
"type": "object",
"properties": {
"value": {"type": "number", "minimum": 0}
}
}
}
}
}
}
config = {"level1": {"level2": {"value": -5}}} # Invalid: negative
is_valid, errors = schema_manager.validate_config_against_schema(
config, schema, "test-plugin"
)
assert not is_valid
class TestArrayValidation:
"""Test validation of array configurations."""
def test_array_min_items(self):
"""Array with fewer items than minItems should fail."""
schema_manager = SchemaManager()
schema = {
"type": "object",
"properties": {
"teams": {
"type": "array",
"items": {"type": "string"},
"minItems": 1
}
}
}
config = {"teams": []} # Empty array, minItems is 1
is_valid, errors = schema_manager.validate_config_against_schema(
config, schema, "test-plugin"
)
assert not is_valid
def test_array_max_items(self):
"""Array with more items than maxItems should fail."""
schema_manager = SchemaManager()
schema = {
"type": "object",
"properties": {
"teams": {
"type": "array",
"items": {"type": "string"},
"maxItems": 2
}
}
}
config = {"teams": ["A", "B", "C", "D"]} # 4 items, maxItems is 2
is_valid, errors = schema_manager.validate_config_against_schema(
config, schema, "test-plugin"
)
assert not is_valid
class TestCollisionDetection:
"""Test config key collision detection."""
def test_reserved_key_collision(self):
"""Plugin IDs that conflict with reserved keys should be detected."""
schema_manager = SchemaManager()
plugin_ids = ["display", "custom-plugin", "schedule"]
collisions = schema_manager.detect_config_key_collisions(plugin_ids)
# Should detect 'display' and 'schedule' as collisions
collision_types = [c["type"] for c in collisions]
collision_plugins = [c["plugin_id"] for c in collisions]
assert "reserved_key_collision" in collision_types
assert "display" in collision_plugins
assert "schedule" in collision_plugins
def test_case_collision(self):
"""Plugin IDs that differ only in case should be detected."""
schema_manager = SchemaManager()
plugin_ids = ["football-scoreboard", "Football-Scoreboard", "other-plugin"]
collisions = schema_manager.detect_config_key_collisions(plugin_ids)
case_collisions = [c for c in collisions if c["type"] == "case_collision"]
assert len(case_collisions) == 1
def test_no_collisions(self):
"""Unique plugin IDs should not trigger collisions."""
schema_manager = SchemaManager()
plugin_ids = ["football-scoreboard", "odds-ticker", "weather-display"]
collisions = schema_manager.detect_config_key_collisions(plugin_ids)
assert len(collisions) == 0
class TestDefaultMerging:
"""Test default value merging with user config."""
def test_defaults_applied_to_missing_fields(self):
"""Missing fields should get default values from schema."""
schema_manager = SchemaManager()
defaults = {
"enabled": True,
"display_duration": 15,
"nfl": {"enabled": True}
}
config = {"display_duration": 30} # Only override one field
merged = schema_manager.merge_with_defaults(config, defaults)
assert merged["enabled"] is True # From defaults
assert merged["display_duration"] == 30 # User override
assert merged["nfl"]["enabled"] is True # Nested default
def test_user_values_not_overwritten(self):
"""User-provided values should not be overwritten by defaults."""
schema_manager = SchemaManager()
defaults = {"enabled": True, "display_duration": 15}
config = {"enabled": False, "display_duration": 60}
merged = schema_manager.merge_with_defaults(config, defaults)
assert merged["enabled"] is False
assert merged["display_duration"] == 60