"""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