Files
shopdb-flask/tests/test_plugin_scaffold.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

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'