diff --git a/docs/PLUGIN-QUICKSTART.md b/docs/PLUGIN-QUICKSTART.md new file mode 100644 index 0000000..d92c7b2 --- /dev/null +++ b/docs/PLUGIN-QUICKSTART.md @@ -0,0 +1,158 @@ +# Plugin Quickstart + +Build a working shopdb-flask plugin in 30 minutes. This walks through generating, customizing, installing, and testing a plugin from scratch. + +For the full hook reference, see [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md). +For the architectural decisions behind the contract, see [migrations/adr/](../migrations/adr/). + +## Step 1: Generate the skeleton + +```bash +flask plugin new cameras --description "Tracks shop-floor surveillance cameras" +``` + +Output: `plugins/cameras/` with manifest, plugin class, example model, example routes, schemas stub, tests, and a README. + +The generated plugin already passes the framework's contract tests. Verify before editing: + +```bash +pytest plugins/cameras/tests/ +``` + +## Step 2: Edit the model + +Open `plugins/cameras/models/cameras.py`. Replace the `examplefield` placeholder with your domain fields: + +```python +class Cameras(BaseModel): + __tablename__ = 'cameras' + + assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid', ondelete='CASCADE'), + primary_key=True, + ) + streamurl = db.Column(db.String(255), nullable=False) + resolution = db.Column(db.String(20)) + fps = db.Column(db.Integer) + poeport = db.Column(db.String(50)) + asset = db.relationship('Asset', backref=db.backref('cameras', uselist=False)) + + def to_dict(self): + return { + 'assetid': self.assetid, + 'streamurl': self.streamurl, + 'resolution': self.resolution, + 'fps': self.fps, + 'poeport': self.poeport, + } +``` + +Note the naming convention: lowercase concatenated, no underscores (`streamurl`, not `stream_url`). See [CONTRIBUTING.md](../CONTRIBUTING.md). + +## Step 3: Add routes + +Open `plugins/cameras/api/routes.py`. The scaffold provides list and detail endpoints. Add CRUD as needed: + +```python +@cameras_bp.route('', methods=['POST']) +@jwt_required() +def create_camera(): + data = request.get_json() + + asset = Asset(assetnumber=data['assetnumber'], name=data['name'], ...) + db.session.add(asset) + db.session.flush() + + camera = Cameras( + assetid=asset.assetid, + streamurl=data['streamurl'], + resolution=data.get('resolution'), + ) + db.session.add(camera) + db.session.commit() + + return success_response(camera.to_dict(), http_code=201) +``` + +For audit logging, use the public helper: + +```python +from shopdb.api import audit_log + +audit_log(action='created', entitytype='Camera', entityid=asset.assetid, entityname=asset.name) +``` + +## Step 4: Install the plugin + +```bash +flask plugin install cameras +flask db migrate -m "Add cameras plugin tables" +flask db upgrade +``` + +`install` runs the plugin's `on_install` hook (which seeds the AssetType row), registers it in the plugin registry, and runs migrations. + +## Step 5: Verify it works + +```bash +flask plugin list +``` + +You should see `cameras [Enabled]`. + +Run the plugin's tests: + +```bash +pytest plugins/cameras/tests/ +``` + +Hit the API: + +```bash +curl http://localhost:5001/api/cameras +``` + +## Step 6: Add hooks (optional) + +Override hooks on the plugin class as needed. See [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md) for the full list. Common ones: + +| Hook | Adds | +|------|------| +| `get_searchable_fields` | Plugin contributes to the global search endpoint | +| `get_navigation_items` | Plugin shows up in the sidebar nav | +| `get_dashboard_widgets` | Plugin's dashboard widget appears on the home page | +| `get_collector_schema` | Plugin accepts external pushes at `/api/collector/` | + +Each hook has a default that does nothing. Override only what your plugin needs. + +## Step 7: Frontend (manual for now) + +Backend scaffolding is automated. Frontend is manual until the frontend scaffolding skill ships. Convention: + +- `frontend/src/views/cameras/CamerasList.vue` +- `frontend/src/views/cameras/CameraDetail.vue` +- `frontend/src/views/cameras/CameraForm.vue` + +Copy from an existing plugin's view files (e.g., `frontend/src/views/network/`) as a starting point. Update the API client in `frontend/src/api/index.js` to add cameras endpoints. + +## Common errors + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `PluginNotFoundError: manifest.json` | Manifest deleted or moved | Restore `plugins//manifest.json` | +| `PluginContractError: missing required field` | manifest.json incomplete | Re-add `name`, `version`, `description` | +| `PluginVersionError: requires core_version X but framework is Y` | Framework upgraded past your range | Update `core_version` in manifest | +| `Table 'cameras' is already defined` | Two models declared the same `__tablename__` | Pick a unique table name | +| Index name collision | Two indexes share the same name (SQLite enforces global uniqueness) | Prefix index names with table: `idx_cameras_streamurl` | + +## Next steps + +- [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md) for the full hook reference +- [CONTRIBUTING.md](../CONTRIBUTING.md) for naming conventions +- [migrations/adr/ADR-001-asset-as-platform-contract.md](../migrations/adr/ADR-001-asset-as-platform-contract.md) for what your plugin can rely on +- [migrations/adr/ADR-006-collector-contract.md](../migrations/adr/ADR-006-collector-contract.md) for accepting external collector input + +## Distribution + +If you are building a plugin for a specific GE Aerospace site (sister-site adoption), ship it as its own git repo. The site running shopdb-flask clones or symlinks your plugin into `/plugins//`. See [ADR-003](../migrations/adr/ADR-003-plugin-distribution.md). diff --git a/shopdb/plugins/cli.py b/shopdb/plugins/cli.py index bfd1a82..cc966cc 100644 --- a/shopdb/plugins/cli.py +++ b/shopdb/plugins/cli.py @@ -179,6 +179,46 @@ def plugin_info(name: str): click.echo("") +@plugin_cli.command('new') +@click.argument('name') +@click.option('--description', default='', help='One-sentence plugin description') +@click.option('--overwrite', is_flag=True, help='Overwrite existing plugin directory') +@with_appcontext +def new_plugin(name: str, description: str, overwrite: bool): + """Scaffold a new plugin from the bundled templates. + + Usage: flask plugin new cameras --description "Tracks shop-floor cameras" + """ + from pathlib import Path + from .scaffolder import scaffold_plugin, ScaffoldError + + plugins_dir = Path(current_app.root_path).parent / 'plugins' + + if not description: + description = f'{name.capitalize()} plugin (TODO: replace this description)' + + try: + target = scaffold_plugin( + name=name, + description=description, + plugins_dir=plugins_dir, + overwrite=overwrite, + ) + except ScaffoldError as e: + click.echo(click.style(f'Scaffold failed: {e}', fg='red')) + raise SystemExit(1) + + click.echo(click.style(f'Created plugin at {target}', fg='green')) + click.echo('') + click.echo('Next steps:') + click.echo(f' 1. Edit plugins/{name}/models/{name}.py with your domain fields') + click.echo(f' 2. Edit plugins/{name}/api/routes.py with your endpoints') + click.echo(f' 3. Run: flask plugin install {name}') + click.echo(f' 4. Run: flask db migrate -m "Add {name} plugin"') + click.echo(f' 5. Run: flask db upgrade') + click.echo(f' 6. Run: pytest plugins/{name}/tests/') + + @plugin_cli.command('migrate') @click.argument('name') @click.option('--revision', default='head', help='Target revision') diff --git a/shopdb/plugins/scaffolder.py b/shopdb/plugins/scaffolder.py new file mode 100644 index 0000000..72a1b6a --- /dev/null +++ b/shopdb/plugins/scaffolder.py @@ -0,0 +1,121 @@ +"""Plugin scaffolder. + +Generates a new plugin skeleton from the templates under +shopdb/plugins/templates/. The generated plugin satisfies the framework +contract out of the box; tests/test_plugin_scaffold.py is the canary +that this stays true as the contract evolves. +""" + +import re +from pathlib import Path +from string import Template +from typing import Optional + + +TEMPLATE_ROOT = Path(__file__).parent / 'templates' + +VALID_NAME_PATTERN = re.compile(r'^[a-z][a-z0-9]*$') + +RESERVED_NAMES = { + 'plugin', 'plugins', 'shopdb', 'core', 'api', 'tests', 'templates', + 'schemas', 'models', 'frontend', 'docs', 'migrations', 'scripts', +} + + +class ScaffoldError(Exception): + """Raised when scaffolding cannot proceed.""" + + +def validate_name(name: str) -> None: + """Validate plugin name against CONTRIBUTING.md and reserved list. + + Raises ScaffoldError on any violation. + """ + if not name: + raise ScaffoldError('Plugin name is required') + + if not VALID_NAME_PATTERN.match(name): + raise ScaffoldError( + f'Plugin name "{name}" must be lowercase letters and digits only, ' + f'starting with a letter (no hyphens, underscores, or special chars). ' + f'See CONTRIBUTING.md for the naming convention.' + ) + + if name in RESERVED_NAMES: + raise ScaffoldError( + f'Plugin name "{name}" is reserved. Pick a different name.' + ) + + +def pascal_case(name: str) -> str: + """Convert lowercase plugin name to PascalCase class name. + + 'cameras' -> 'Cameras'. The convention assumes single-word lowercase + plugin names per the naming rules in CONTRIBUTING.md, so this is + just title-casing. + """ + return name[:1].upper() + name[1:] + + +def scaffold_plugin( + name: str, + description: str, + plugins_dir: Path, + template_root: Optional[Path] = None, + overwrite: bool = False, +) -> Path: + """Generate a new plugin from templates. + + Args: + name: Plugin name (lowercase, single word) + description: One-sentence description for manifest.json + README + plugins_dir: Target plugins directory (e.g., /plugins) + template_root: Override template source dir (default: bundled templates) + overwrite: If True, overwrite an existing plugin directory + + Returns: + Path to the generated plugin directory. + + Raises: + ScaffoldError on validation failure or when target exists and + overwrite is False. + """ + validate_name(name) + + template_root = template_root or TEMPLATE_ROOT + if not template_root.exists(): + raise ScaffoldError(f'Template root not found: {template_root}') + + target = plugins_dir / name + + if target.exists(): + if not overwrite: + raise ScaffoldError( + f'Plugin directory already exists: {target}. ' + f'Pass overwrite=True or remove it first.' + ) + + substitutions = { + 'name': name, + 'Name': pascal_case(name), + 'description': description, + } + + target.mkdir(parents=True, exist_ok=True) + + for template_path in template_root.rglob('*.tmpl'): + rel = template_path.relative_to(template_root) + + out_rel_str = str(rel.with_suffix('')) + if 'model.py' in out_rel_str: + out_rel_str = out_rel_str.replace('model.py', f'{name}.py') + + out_path = target / out_rel_str + out_path.parent.mkdir(parents=True, exist_ok=True) + + body = template_path.read_text() + rendered = Template(body).safe_substitute(substitutions) + + out_path.write_text(rendered) + + return target diff --git a/shopdb/plugins/templates/README.md.tmpl b/shopdb/plugins/templates/README.md.tmpl new file mode 100644 index 0000000..8a92daf --- /dev/null +++ b/shopdb/plugins/templates/README.md.tmpl @@ -0,0 +1,43 @@ +# $Name plugin + +$description + +This plugin was generated by `flask plugin new $name`. It satisfies the framework contract out of the box. Replace the example model and routes with your domain. + +## What's here + +- `plugin.py` - the `${Name}Plugin` class extending `BasePlugin`. Edit `init_app` for custom setup, `on_install` to seed reference data. +- `models/$name.py` - example Asset extension table. Replace `examplefield` with your domain fields. +- `api/routes.py` - example list and detail endpoints. Add CRUD as needed. +- `schemas/__init__.py` - marshmallow schema stub for request/response validation. +- `tests/test_plugin.py` - smoke tests asserting contract compliance. +- `manifest.json` - plugin metadata. Bump `version` on changes; keep `core_version` range broad. + +## Common edits + +| You want to... | Do this | +|---|---| +| Add a hook (search, navigation, dashboard widget) | Override the method in `${Name}Plugin`. See `docs/PLUGIN-HOOKS.md`. | +| Accept external collector data | Override `get_collector_schema()` to return a JSON Schema. See ADR-006. | +| Add another model | Create `models/.py`, export it in `models/__init__.py`, return it in `get_models()`. | +| Add a CLI command | Override `get_cli_commands()` returning a list of Click commands. | + +## Frontend + +Vue components for this plugin live under `frontend/src/views/$name/` (per project convention). Backend scaffolding does not generate frontend yet; copy from an existing plugin's view files (e.g., `frontend/src/views/network/`) as a starting point. + +## Install and run + +```bash +flask plugin install $name +flask db migrate -m "Add $name plugin tables" +flask db upgrade +pytest plugins/$name/tests/ +``` + +## References + +- `docs/PLUGIN-HOOKS.md` - canonical hook reference +- `docs/PLUGIN-QUICKSTART.md` - 30-minute walkthrough +- `migrations/adr/ADR-001-asset-as-platform-contract.md` - the platform contract +- `migrations/adr/ADR-002-plugin-versioning.md` - versioning rules diff --git a/shopdb/plugins/templates/__init__.py.tmpl b/shopdb/plugins/templates/__init__.py.tmpl new file mode 100644 index 0000000..61eebb5 --- /dev/null +++ b/shopdb/plugins/templates/__init__.py.tmpl @@ -0,0 +1,5 @@ +"""$Name plugin package.""" + +from .plugin import ${Name}Plugin + +__all__ = ['${Name}Plugin'] diff --git a/shopdb/plugins/templates/api/__init__.py.tmpl b/shopdb/plugins/templates/api/__init__.py.tmpl new file mode 100644 index 0000000..a2e3882 --- /dev/null +++ b/shopdb/plugins/templates/api/__init__.py.tmpl @@ -0,0 +1,5 @@ +"""$Name plugin API package.""" + +from .routes import ${name}_bp + +__all__ = ['${name}_bp'] diff --git a/shopdb/plugins/templates/api/routes.py.tmpl b/shopdb/plugins/templates/api/routes.py.tmpl new file mode 100644 index 0000000..bfe0d28 --- /dev/null +++ b/shopdb/plugins/templates/api/routes.py.tmpl @@ -0,0 +1,44 @@ +"""$Name plugin API routes.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.utils.responses import ( + success_response, + error_response, + paginated_response, + ErrorCodes, +) +from shopdb.utils.pagination import get_pagination_params, paginate_query + +from ..models import $Name + + +${name}_bp = Blueprint('$name', __name__) + + +@${name}_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_${name}(): + """List $name assets, paginated.""" + page, per_page = get_pagination_params(request) + + query = ${Name}.query + items, total = paginate_query(query, page, per_page) + data = [item.to_dict() for item in items] + + return paginated_response(data, page, per_page, total) + + +@${name}_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_${name}(assetid: int): + """Get a single $name by assetid.""" + item = ${Name}.query.get(assetid) + if not item: + return error_response( + ErrorCodes.NOT_FOUND, + f'$Name with assetid {assetid} not found', + http_code=404, + ) + return success_response(item.to_dict()) diff --git a/shopdb/plugins/templates/manifest.json.tmpl b/shopdb/plugins/templates/manifest.json.tmpl new file mode 100644 index 0000000..7c8f9d0 --- /dev/null +++ b/shopdb/plugins/templates/manifest.json.tmpl @@ -0,0 +1,9 @@ +{ + "name": "$name", + "version": "0.1.0", + "description": "$description", + "author": "", + "dependencies": [], + "core_version": ">=0.1.0,<1.0.0", + "api_prefix": "/api/$name" +} diff --git a/shopdb/plugins/templates/models/__init__.py.tmpl b/shopdb/plugins/templates/models/__init__.py.tmpl new file mode 100644 index 0000000..792db2c --- /dev/null +++ b/shopdb/plugins/templates/models/__init__.py.tmpl @@ -0,0 +1,5 @@ +"""$Name plugin models.""" + +from .$name import $Name + +__all__ = ['$Name'] diff --git a/shopdb/plugins/templates/models/model.py.tmpl b/shopdb/plugins/templates/models/model.py.tmpl new file mode 100644 index 0000000..5d3d42f --- /dev/null +++ b/shopdb/plugins/templates/models/model.py.tmpl @@ -0,0 +1,33 @@ +"""$Name model. + +This is an Asset extension table keyed by assetid. The Asset row holds +the platform fields (assetnumber, name, vendorid, locationid, etc.); +this table holds the $name-specific fields. Replace the example fields +below with your domain model. +""" + +from shopdb.extensions import db +from shopdb.core.models.base import BaseModel + + +class $Name(BaseModel): + """$Name domain entity, extending Asset by assetid.""" + + __tablename__ = '$name' + + assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid', ondelete='CASCADE'), + primary_key=True, + ) + + # TODO: replace these example fields with your domain fields. + examplefield = db.Column(db.String(255), nullable=True) + + asset = db.relationship('Asset', backref=db.backref('$name', uselist=False)) + + def to_dict(self): + return { + 'assetid': self.assetid, + 'examplefield': self.examplefield, + } diff --git a/shopdb/plugins/templates/plugin.py.tmpl b/shopdb/plugins/templates/plugin.py.tmpl new file mode 100644 index 0000000..ceb474d --- /dev/null +++ b/shopdb/plugins/templates/plugin.py.tmpl @@ -0,0 +1,71 @@ +"""$Name plugin main class. + +$description +""" + +import json +import logging +from pathlib import Path +from typing import List, Dict, Optional, Type + +from flask import Flask, Blueprint + +from shopdb.plugins.base import BasePlugin, PluginMeta +from shopdb.core.models import AssetType +from shopdb.extensions import db + +from .models import $Name +from .api import ${name}_bp + +logger = logging.getLogger(__name__) + + +class ${Name}Plugin(BasePlugin): + """$Name plugin. + + $description + """ + + def __init__(self): + manifest_path = Path(__file__).parent / 'manifest.json' + with open(manifest_path) as f: + self._manifest = json.load(f) + + @property + def meta(self) -> PluginMeta: + return PluginMeta( + name=self._manifest['name'], + version=self._manifest['version'], + description=self._manifest['description'], + author=self._manifest.get('author', ''), + dependencies=self._manifest.get('dependencies', []), + core_version=self._manifest.get('core_version', '>=0.1.0'), + api_prefix=self._manifest.get('api_prefix'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + return ${name}_bp + + def get_models(self) -> List[Type]: + return [$Name] + + def init_app(self, app: Flask, db_instance) -> None: + logger.info(f'$Name plugin initialized (v{self.meta.version})') + + def on_install(self, app: Flask) -> None: + with app.app_context(): + self._ensure_asset_type() + logger.info('$Name plugin installed') + + def _ensure_asset_type(self) -> None: + existing = AssetType.query.filter_by(assettype='$name').first() + if not existing: + asset_type = AssetType( + assettype='$name', + pluginname='$name', + tablename='$name', + description=self.meta.description, + ) + db.session.add(asset_type) + db.session.commit() + logger.debug('Created asset type: $name') diff --git a/shopdb/plugins/templates/schemas/__init__.py.tmpl b/shopdb/plugins/templates/schemas/__init__.py.tmpl new file mode 100644 index 0000000..4971ea6 --- /dev/null +++ b/shopdb/plugins/templates/schemas/__init__.py.tmpl @@ -0,0 +1,6 @@ +"""$Name plugin schemas (marshmallow). + +Add schema classes here when you need request/response validation +beyond the simple to_dict() output. The framework wires marshmallow +into the response helpers; see docs/PLUGIN-HOOKS.md for details. +""" diff --git a/shopdb/plugins/templates/tests/__init__.py.tmpl b/shopdb/plugins/templates/tests/__init__.py.tmpl new file mode 100644 index 0000000..e69de29 diff --git a/shopdb/plugins/templates/tests/test_plugin.py.tmpl b/shopdb/plugins/templates/tests/test_plugin.py.tmpl new file mode 100644 index 0000000..295af38 --- /dev/null +++ b/shopdb/plugins/templates/tests/test_plugin.py.tmpl @@ -0,0 +1,30 @@ +"""$Name plugin smoke tests. + +Asserts the plugin loads cleanly and satisfies the framework contract. +Replace and extend with domain tests as you build the plugin out. +""" + +from plugins.$name.plugin import ${Name}Plugin + + +def test_${name}_plugin_meta_is_valid(): + """${Name}Plugin.meta returns a PluginMeta with the expected name.""" + plugin = ${Name}Plugin() + assert plugin.meta.name == '$name' + assert plugin.meta.api_prefix == '/api/$name' + + +def test_${name}_plugin_get_blueprint_returns_blueprint(): + """get_blueprint returns a Flask Blueprint, not None.""" + from flask import Blueprint + plugin = ${Name}Plugin() + assert isinstance(plugin.get_blueprint(), Blueprint) + + +def test_${name}_plugin_get_models_returns_a_model(): + """get_models returns a list with at least one SQLAlchemy model.""" + plugin = ${Name}Plugin() + models = plugin.get_models() + assert len(models) >= 1 + for model in models: + assert hasattr(model, '__tablename__') diff --git a/tests/test_plugin_scaffold.py b/tests/test_plugin_scaffold.py new file mode 100644 index 0000000..f7e63ed --- /dev/null +++ b/tests/test_plugin_scaffold.py @@ -0,0 +1,189 @@ +"""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 /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'