Files
shopdb-flask/tests/test_plugin_loader.py
Cam Proudlock a9d0dfff4a Initial import: shopdb-flask multi-site asset platform
Flask + Vue rewrite of the classic-ASP shopdb, built as a plugin
framework so sister GE Aerospace sites can adopt it. Product version
0.7.0-pre-0.8.0, plugin contract 0.11.0, 966 backend tests.

Key entry points:
- docs/INSTALL-WINDOWS-IIS.md - tested Windows + IIS install runbook
  (own-site and subpath deployment methods)
- docs/PILOT-DEPLOY.md - production pilot: stand up + import + verify
- docs/IMPORT-API.md - schema-agnostic legacy data import contract
- scripts/site_imports/wjf/ - West Jefferson reference import loader
- docs/adr/ - twelve accepted architecture decision records
2026-07-14 07:58:51 -04:00

262 lines
7.6 KiB
Python

"""Tests for the plugin loader behavior.
Pins the manifest-first design and the strict-vs-isolate failure policy
described in the enforcing-plugin-contract skill.
"""
import json
from pathlib import Path
import pytest
from shopdb.plugins.loader import PluginLoader
from shopdb.plugins.registry import PluginRegistry
from shopdb.exceptions import (
PluginNotFoundError,
PluginContractError,
PluginVersionError,
PluginDependencyError,
PluginError,
)
@pytest.fixture
def temp_plugins_dir(tmp_path):
"""An empty temporary plugins directory."""
plugins = tmp_path / 'plugins'
plugins.mkdir()
return plugins
@pytest.fixture
def temp_registry(tmp_path):
"""An empty temporary registry."""
return PluginRegistry(tmp_path / 'plugins.json')
@pytest.fixture
def loader(temp_plugins_dir, temp_registry):
"""A loader pointed at the temporary plugins dir."""
return PluginLoader(temp_plugins_dir, temp_registry)
def write_plugin(plugins_dir, name, manifest=None, plugin_py=None):
"""Helper: write a plugin directory with manifest.json and plugin.py."""
plugin_dir = plugins_dir / name
plugin_dir.mkdir()
if manifest is not None:
(plugin_dir / 'manifest.json').write_text(json.dumps(manifest))
if plugin_py is not None:
(plugin_dir / 'plugin.py').write_text(plugin_py)
return plugin_dir
VALID_PLUGIN_PY = '''
from shopdb.plugins.base import BasePlugin, PluginMeta
class FakePlugin(BasePlugin):
@property
def meta(self):
return PluginMeta(
name='fake',
version='1.0.0',
description='Fake test plugin',
)
def get_blueprint(self):
return None
def get_models(self):
return []
'''
def test_load_manifest_raises_on_missing_manifest(loader, temp_plugins_dir):
"""A plugin without manifest.json raises PluginNotFoundError."""
plugin_dir = temp_plugins_dir / 'noplugin'
plugin_dir.mkdir()
(plugin_dir / 'plugin.py').write_text('# empty')
with pytest.raises(PluginNotFoundError, match='manifest.json'):
loader.load_manifest('noplugin')
def test_load_manifest_raises_on_invalid_json(loader, temp_plugins_dir):
"""An unparseable manifest.json raises PluginContractError."""
plugin_dir = temp_plugins_dir / 'bad'
plugin_dir.mkdir()
(plugin_dir / 'manifest.json').write_text('{not json}')
with pytest.raises(PluginContractError, match='invalid manifest.json'):
loader.load_manifest('bad')
def test_load_manifest_raises_on_missing_required_fields(loader, temp_plugins_dir):
"""Missing name/version/description raises PluginContractError."""
write_plugin(temp_plugins_dir, 'incomplete', manifest={'name': 'incomplete'})
with pytest.raises(PluginContractError, match='missing required field'):
loader.load_manifest('incomplete')
def test_load_manifest_raises_on_name_mismatch(loader, temp_plugins_dir):
"""manifest.name must match the directory name."""
write_plugin(
temp_plugins_dir,
'mydir',
manifest={'name': 'different', 'version': '1.0.0', 'description': 'x'},
)
with pytest.raises(PluginContractError, match='does not match manifest name'):
loader.load_manifest('mydir')
def test_check_contract_version_passes_when_in_range(loader, temp_plugins_dir):
"""A core_version range that covers the framework version passes."""
write_plugin(
temp_plugins_dir,
'compat',
manifest={
'name': 'compat',
'version': '1.0.0',
'description': 'x',
'core_version': '>=0.1.0,<1.0.0',
},
)
loader.check_contract_version('compat', '0.1.0')
def test_check_contract_version_raises_when_out_of_range(loader, temp_plugins_dir):
"""A core_version range that excludes the framework version raises."""
write_plugin(
temp_plugins_dir,
'incompat',
manifest={
'name': 'incompat',
'version': '1.0.0',
'description': 'x',
'core_version': '>=2.0.0',
},
)
with pytest.raises(PluginVersionError, match='requires core_version'):
loader.check_contract_version('incompat', '0.1.0')
def test_check_contract_version_skipped_when_unspecified(loader, temp_plugins_dir):
"""A manifest without core_version does not raise."""
write_plugin(
temp_plugins_dir,
'unspec',
manifest={
'name': 'unspec',
'version': '1.0.0',
'description': 'x',
},
)
loader.check_contract_version('unspec', '0.1.0')
def test_load_plugin_class_raises_on_missing_plugin_py(loader, temp_plugins_dir):
"""A directory with no plugin.py raises PluginNotFoundError."""
plugin_dir = temp_plugins_dir / 'nofile'
plugin_dir.mkdir()
with pytest.raises(PluginNotFoundError, match='plugin.py not found'):
loader.load_plugin_class('nofile')
def test_load_plugin_class_raises_when_no_subclass_found(loader, temp_plugins_dir):
"""A plugin.py without a BasePlugin subclass raises PluginContractError."""
write_plugin(
temp_plugins_dir,
'empty',
manifest={'name': 'empty', 'version': '1.0.0', 'description': 'x'},
plugin_py='# no plugin defined',
)
with pytest.raises(PluginContractError, match='no BasePlugin subclass'):
loader.load_plugin_class('empty')
def test_load_plugin_class_raises_on_import_error(loader, temp_plugins_dir):
"""A plugin.py with a syntax/import error raises PluginContractError."""
write_plugin(
temp_plugins_dir,
'broken',
manifest={'name': 'broken', 'version': '1.0.0', 'description': 'x'},
plugin_py='import nonexistent_module_xyz',
)
with pytest.raises(PluginContractError, match='import failed'):
loader.load_plugin_class('broken')
def test_sort_by_dependencies_uses_manifest_not_instantiation(loader, temp_plugins_dir):
"""Topological sort reads manifest.json directly, never instantiates."""
write_plugin(
temp_plugins_dir,
'a',
manifest={
'name': 'a',
'version': '1.0.0',
'description': 'x',
'dependencies': ['b'],
},
)
write_plugin(
temp_plugins_dir,
'b',
manifest={'name': 'b', 'version': '1.0.0', 'description': 'x'},
)
sorted_names = loader._sort_by_dependencies(['a', 'b'])
assert sorted_names.index('b') < sorted_names.index('a')
def test_load_plugin_strict_mode_reraises(loader, app, temp_plugins_dir):
"""In TESTING/DEBUG mode, load_plugin re-raises plugin errors."""
write_plugin(
temp_plugins_dir,
'incompat',
manifest={
'name': 'incompat',
'version': '1.0.0',
'description': 'x',
'core_version': '>=99.0.0',
},
plugin_py=VALID_PLUGIN_PY,
)
assert app.config.get('TESTING') is True
with pytest.raises(PluginVersionError):
loader.load_plugin('incompat', app, None)
def test_load_plugin_production_mode_isolates(loader, temp_plugins_dir):
"""In production-like config, load_plugin returns None on failure."""
from flask import Flask
fake_app = Flask(__name__)
fake_app.config['TESTING'] = False
fake_app.config['DEBUG'] = False
write_plugin(
temp_plugins_dir,
'incompat',
manifest={
'name': 'incompat',
'version': '1.0.0',
'description': 'x',
'core_version': '>=99.0.0',
},
plugin_py=VALID_PLUGIN_PY,
)
result = loader.load_plugin('incompat', fake_app, None)
assert result is None