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
190 lines
6.0 KiB
Python
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'
|