Phase 2: define plugin contract surface and add compliance tests
Locks the public surface plugin authors at sister sites depend on.
Contract version (shopdb/__init__.py):
- __contract_version__ = '0.1.0'. Per ADR-002, plugins declare a
compatible range in manifest.json `core_version`. Pre-1.0 signals
the contract is still settling; sister sites should pin tightly.
BasePlugin hook changes (shopdb/plugins/base.py):
- Add get_collector_schema() per ADR-006. Returns JSON Schema (with
identityfield + fields) describing the payload of an external
collector pushing to /api/collector/<pluginname>. Defaults to None
(no auto-registered endpoint).
- Remove get_event_handlers(). Event bus deferred indefinitely per
ADR-001 (no real use case yet; add via new ADR if it appears).
Hook reference (docs/PLUGIN-HOOKS.md):
- Canonical reference for the contract: required hooks (meta,
get_blueprint, get_models), optional hooks (init_app,
get_cli_commands, get_services, get_dashboard_widgets,
get_navigation_items, get_searchable_fields, get_collector_schema),
lifecycle hooks (on_install, on_uninstall, on_enable, on_disable),
helpers exposed in shopdb.api (audit_log, Setting,
resolve_asset_position).
- Versioning rules + change-classification guidance.
Compliance tests (tests/test_plugin_contract.py):
- 8 distinct contract assertions parametrized over 6 bundled plugins
(computers, equipment, network, notifications, printers, usb).
- Asserts: subclasses BasePlugin; manifest has required fields; meta
returns valid PluginMeta; get_blueprint returns Blueprint or None;
get_models returns model classes; get_collector_schema returns
None or {identityfield, fields}; get_navigation_items and
get_searchable_fields return list.
- Plus 3 framework-level: __contract_version__ is valid semver,
get_event_handlers absent, get_collector_schema present.
Test count: 15 -> 66 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
145
tests/test_plugin_contract.py
Normal file
145
tests/test_plugin_contract.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Plugin contract compliance tests.
|
||||
|
||||
Asserts every bundled plugin satisfies the contract surface declared in
|
||||
ADR-001 and the BasePlugin ABC. Run on every change to BasePlugin or any
|
||||
plugin's plugin.py / manifest.json.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from flask import Blueprint
|
||||
|
||||
from shopdb import __contract_version__
|
||||
from shopdb.plugins import plugin_manager
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
|
||||
|
||||
BUNDLED_PLUGINS = ('computers', 'equipment', 'network', 'notifications', 'printers', 'usb')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugin_classes(app):
|
||||
"""Load each bundled plugin's class without instantiating in app context."""
|
||||
classes = {}
|
||||
with app.app_context():
|
||||
loader = plugin_manager.loader
|
||||
for name in BUNDLED_PLUGINS:
|
||||
cls = loader.load_plugin_class(name)
|
||||
assert cls is not None, f'Plugin class for {name} could not be loaded'
|
||||
classes[name] = cls
|
||||
return classes
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugin_instances(app, plugin_classes):
|
||||
"""Instantiate each bundled plugin in app context."""
|
||||
return {name: cls() for name, cls in plugin_classes.items()}
|
||||
|
||||
|
||||
def test_contract_version_is_set():
|
||||
"""Framework declares a contract version."""
|
||||
assert __contract_version__
|
||||
parts = __contract_version__.split('.')
|
||||
assert len(parts) == 3, f'Expected semver X.Y.Z, got {__contract_version__}'
|
||||
for part in parts:
|
||||
assert part.isdigit(), f'Non-numeric semver part in {__contract_version__}'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
|
||||
def test_plugin_class_subclasses_baseplugin(plugin_classes, name):
|
||||
"""Each plugin class extends BasePlugin."""
|
||||
cls = plugin_classes[name]
|
||||
assert issubclass(cls, BasePlugin), f'{name} does not subclass BasePlugin'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
|
||||
def test_plugin_manifest_has_required_fields(name):
|
||||
"""Each plugin's manifest.json declares the required fields."""
|
||||
manifestpath = Path('plugins') / name / 'manifest.json'
|
||||
assert manifestpath.exists(), f'{name} is missing manifest.json'
|
||||
|
||||
manifest = json.loads(manifestpath.read_text())
|
||||
|
||||
assert manifest.get('name') == name
|
||||
assert manifest.get('version'), f'{name} manifest missing version'
|
||||
assert manifest.get('description'), f'{name} manifest missing description'
|
||||
assert manifest.get('core_version'), f'{name} manifest missing core_version'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
|
||||
def test_plugin_meta_returns_pluginmeta(plugin_instances, name):
|
||||
"""plugin.meta returns a PluginMeta instance with sane fields."""
|
||||
plugin = plugin_instances[name]
|
||||
meta = plugin.meta
|
||||
assert isinstance(meta, PluginMeta)
|
||||
assert meta.name == name
|
||||
assert meta.version
|
||||
assert meta.api_prefix and meta.api_prefix.startswith('/api/')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
|
||||
def test_plugin_get_blueprint_returns_blueprint_or_none(plugin_instances, name):
|
||||
"""get_blueprint returns a Flask Blueprint or None."""
|
||||
plugin = plugin_instances[name]
|
||||
bp = plugin.get_blueprint()
|
||||
assert bp is None or isinstance(bp, Blueprint), (
|
||||
f'{name}.get_blueprint returned {type(bp).__name__}'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
|
||||
def test_plugin_get_models_returns_list(plugin_instances, name):
|
||||
"""get_models returns a list of model classes."""
|
||||
plugin = plugin_instances[name]
|
||||
models = plugin.get_models()
|
||||
assert isinstance(models, list), f'{name}.get_models did not return a list'
|
||||
for model in models:
|
||||
assert hasattr(model, '__tablename__'), (
|
||||
f'{name}.get_models returned {model} without __tablename__'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
|
||||
def test_plugin_get_collector_schema_returns_dict_or_none(plugin_instances, name):
|
||||
"""get_collector_schema returns None or a dict with identityfield + fields."""
|
||||
plugin = plugin_instances[name]
|
||||
schema = plugin.get_collector_schema()
|
||||
if schema is None:
|
||||
return
|
||||
assert isinstance(schema, dict)
|
||||
assert 'identityfield' in schema, (
|
||||
f'{name}.get_collector_schema must declare identityfield'
|
||||
)
|
||||
assert 'fields' in schema, (
|
||||
f'{name}.get_collector_schema must declare fields'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
|
||||
def test_plugin_get_navigation_items_is_iterable(plugin_instances, name):
|
||||
"""get_navigation_items returns an iterable (default empty list)."""
|
||||
plugin = plugin_instances[name]
|
||||
items = plugin.get_navigation_items()
|
||||
assert isinstance(items, list)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
|
||||
def test_plugin_get_searchable_fields_is_iterable(plugin_instances, name):
|
||||
"""get_searchable_fields returns a list (default empty)."""
|
||||
plugin = plugin_instances[name]
|
||||
fields = plugin.get_searchable_fields()
|
||||
assert isinstance(fields, list)
|
||||
|
||||
|
||||
def test_baseplugin_does_not_have_event_handlers_hook():
|
||||
"""The removed get_event_handlers hook must not be on BasePlugin."""
|
||||
assert not hasattr(BasePlugin, 'get_event_handlers'), (
|
||||
'get_event_handlers was removed for v1. See ADR-001 contract surface.'
|
||||
)
|
||||
|
||||
|
||||
def test_baseplugin_has_collector_schema_hook():
|
||||
"""The collector schema hook is on the contract surface."""
|
||||
assert hasattr(BasePlugin, 'get_collector_schema')
|
||||
Reference in New Issue
Block a user