Files
shopdb-flask/tests/test_plugin_scaffold.py
cproudlock 8eb9362452 Phase 4: plugin scaffolding (flask plugin new) with canary tests
Lowers the barrier for sister sites to build their own plugins.
Generated output satisfies the framework contract out of the box.

CLI command (shopdb/plugins/cli.py):
- `flask plugin new <name> --description "..."` generates a plugin
  skeleton under plugins/<name>/. Validates the name against
  CONTRIBUTING.md rules (lowercase letters/digits only, no
  underscores or hyphens, not in the reserved list) and refuses to
  overwrite existing plugins unless --overwrite is passed.
- Output prints the next steps (install, migrate, test).

Scaffolder (shopdb/plugins/scaffolder.py):
- validate_name: enforces the naming rules
- pascal_case: lowercase-to-PascalCase for class names
- scaffold_plugin: copies templates with string.Template
  substitution. Three placeholders: $name, $Name, $description.
  Files with `model.py` in the path get renamed to <name>.py.

Templates (shopdb/plugins/templates/):
- manifest.json.tmpl: name, version 0.1.0, description, core_version
  range >=0.1.0,<1.0.0 (broad enough to survive minor framework bumps)
- plugin.py.tmpl: <Name>Plugin class extending BasePlugin with all
  required hooks implemented (meta from manifest, get_blueprint
  returning the bp, get_models returning the example model). Includes
  on_install hook that seeds the AssetType row.
- models/__init__.py.tmpl + models/model.py.tmpl: Asset extension
  table keyed by assetid with one example field. TODO comment marks
  it as a placeholder.
- api/__init__.py.tmpl + api/routes.py.tmpl: Blueprint with list and
  detail endpoints using the framework's pagination + response helpers.
- schemas/__init__.py.tmpl: marshmallow schema stub.
- tests/__init__.py.tmpl + tests/test_plugin.py.tmpl: smoke tests
  asserting plugin loads, get_blueprint returns Blueprint, get_models
  returns at least one model.
- README.md.tmpl: one-pager for plugin authors with common edits and
  next-step references.

Canary tests (tests/test_plugin_scaffold.py):
- 14 tests asserting the scaffold output passes contract checks.
- Validates name rules (lowercase, reserved, hyphens, digits, etc.)
- Verifies all expected files generated, manifest fields present.
- Loads the generated plugin via PluginLoader (spec_from_file_location
  bypasses the real `plugins` package shadowing).
- Asserts subclasses BasePlugin, get_blueprint returns Blueprint,
  get_models returns model with __tablename__.
- Module-scoped fixture; cleans up sys.modules + SQLAlchemy metadata
  on teardown to avoid cross-test contamination.

Quickstart docs (docs/PLUGIN-QUICKSTART.md):
- 30-minute walkthrough: scaffold -> edit model -> add routes ->
  install -> verify -> add hooks. Cross-links to PLUGIN-HOOKS.md and
  the ADRs. Includes common-errors table.

Test count: 87 -> 101 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 17:13:46 -04:00

190 lines
6.0 KiB
Python

"""Canary tests for the plugin scaffolder.
The scaffold output must satisfy the framework contract the moment it
is generated. If a contract test would fail on a freshly scaffolded
plugin, the scaffold templates are broken, not the plugin author's
mistake.
"""
import json
import sys
from pathlib import Path
import pytest
from flask import Blueprint
from shopdb.plugins.scaffolder import (
scaffold_plugin,
validate_name,
pascal_case,
ScaffoldError,
)
@pytest.fixture(scope='module')
def scaffolded(tmp_path_factory):
"""Scaffold a plugin named 'widgets' under a temporary plugins dir.
Module-scoped so the generated plugin module imports exactly once
per test module. SQLAlchemy registers the model once, no metadata
collision. Returns a tuple (target_dir, plugins_dir) where
plugins_dir is suitable as PluginLoader's plugins_dir argument.
The scaffold output is loaded via PluginLoader's spec-based import
so the canary does not collide with the real `plugins/` package
that already exists at <repo>/plugins/.
"""
tmp_path = tmp_path_factory.mktemp('scaffold')
plugins_dir = tmp_path / 'plugins'
plugins_dir.mkdir()
target = scaffold_plugin(
name='widgets',
description='Test widgets plugin',
plugins_dir=plugins_dir,
)
import plugins as real_plugins
real_plugins.__path__.insert(0, str(plugins_dir))
try:
yield target, plugins_dir
finally:
try:
real_plugins.__path__.remove(str(plugins_dir))
except ValueError:
pass
for mod_name in list(sys.modules):
if mod_name.startswith('plugins.widgets'):
del sys.modules[mod_name]
from shopdb.extensions import db as _db
if 'widgets' in _db.metadata.tables:
_db.metadata.remove(_db.metadata.tables['widgets'])
def _load_widgets_plugin_class(plugins_dir):
"""Load the scaffolded widgets plugin class via PluginLoader."""
from shopdb.plugins.loader import PluginLoader
from shopdb.plugins.registry import PluginRegistry
registry = PluginRegistry(plugins_dir.parent / 'plugins.json')
loader = PluginLoader(plugins_dir, registry)
return loader.load_plugin_class('widgets')
def test_validate_name_accepts_simple_lowercase():
validate_name('cameras')
validate_name('barcode')
validate_name('vlan2')
def test_validate_name_rejects_underscore():
with pytest.raises(ScaffoldError, match='lowercase letters and digits'):
validate_name('my_plugin')
def test_validate_name_rejects_hyphen():
with pytest.raises(ScaffoldError, match='lowercase letters and digits'):
validate_name('my-plugin')
def test_validate_name_rejects_uppercase():
with pytest.raises(ScaffoldError, match='lowercase letters and digits'):
validate_name('MyPlugin')
def test_validate_name_rejects_leading_digit():
with pytest.raises(ScaffoldError, match='lowercase letters and digits'):
validate_name('1plugin')
def test_validate_name_rejects_reserved():
with pytest.raises(ScaffoldError, match='reserved'):
validate_name('plugins')
def test_pascal_case():
assert pascal_case('cameras') == 'Cameras'
assert pascal_case('vlan') == 'Vlan'
def test_scaffold_creates_expected_files(scaffolded):
"""Scaffold output has every file the contract expects."""
target, _ = scaffolded
expected = [
'manifest.json',
'plugin.py',
'__init__.py',
'models/__init__.py',
'models/widgets.py',
'api/__init__.py',
'api/routes.py',
'schemas/__init__.py',
'tests/__init__.py',
'tests/test_plugin.py',
'README.md',
]
for relpath in expected:
assert (target / relpath).exists(), f'Missing scaffold output: {relpath}'
def test_scaffold_manifest_has_required_fields(scaffolded):
"""manifest.json contains the fields the loader requires."""
target, _ = scaffolded
manifest = json.loads((target / 'manifest.json').read_text())
assert manifest['name'] == 'widgets'
assert manifest['version']
assert manifest['description'] == 'Test widgets plugin'
assert manifest['core_version']
assert manifest['api_prefix'] == '/api/widgets'
def test_scaffold_plugin_class_is_importable(scaffolded):
"""The generated plugin.py loads via PluginLoader and subclasses BasePlugin."""
from shopdb.plugins.base import BasePlugin
_, plugins_dir = scaffolded
plugin_class = _load_widgets_plugin_class(plugins_dir)
assert issubclass(plugin_class, BasePlugin)
instance = plugin_class()
assert instance.meta.name == 'widgets'
def test_scaffold_get_blueprint_returns_blueprint(scaffolded):
"""The generated plugin's get_blueprint returns a Flask Blueprint."""
_, plugins_dir = scaffolded
plugin_class = _load_widgets_plugin_class(plugins_dir)
bp = plugin_class().get_blueprint()
assert isinstance(bp, Blueprint)
def test_scaffold_get_models_returns_model_class(scaffolded):
"""The generated plugin's get_models returns at least one model with a tablename."""
_, plugins_dir = scaffolded
plugin_class = _load_widgets_plugin_class(plugins_dir)
models = plugin_class().get_models()
assert len(models) == 1
assert models[0].__tablename__ == 'widgets'
def test_scaffold_refuses_to_overwrite_by_default(tmp_path):
"""A second scaffold of the same name without overwrite raises."""
plugins_dir = tmp_path / 'plugins'
plugins_dir.mkdir()
scaffold_plugin('widgets', 'first', plugins_dir)
with pytest.raises(ScaffoldError, match='already exists'):
scaffold_plugin('widgets', 'second', plugins_dir)
def test_scaffold_overwrites_when_explicitly_requested(tmp_path):
"""overwrite=True allows a re-scaffold."""
plugins_dir = tmp_path / 'plugins'
plugins_dir.mkdir()
scaffold_plugin('widgets', 'first', plugins_dir)
scaffold_plugin('widgets', 'second', plugins_dir, overwrite=True)
manifest = json.loads((plugins_dir / 'widgets' / 'manifest.json').read_text())
assert manifest['description'] == 'second'