Files
shopdb-flask/tests/test_plugin_prune_schema.py
cproudlock c386e211df ADR-014 Phase 2: flask plugin prune-schema for lean per-site DBs
A lean site still gets every plugin's tables from the shared core Alembic
baseline. prune-schema drops the tables of plugins not installed on this
site, leaving core + chosen-plugin tables, with no edit to any released
migration (the relocate-into-plugin-baselines alternative would mean
rewriting ~15 released core migrations for a cosmetic gain - see ADR-014).

- shopdb/plugins/cli.py: prune-schema command. Dry-run by default; --yes to
  execute; refuses non-empty tables without --force. Drops by table name (no
  plugin import) so it works on a lean image. MySQL: private AUTOCOMMIT engine
  (db.engine's pooled connections sit idle-in-transaction in a CLI context and
  would deadlock the DROP on a metadata lock). SQLite: db.engine, restoring the
  prior foreign_keys pragma so the StaticPool connection is not left changed.
- tests/test_plugin_prune_schema.py: drop-only-not-installed, full no-op,
  refuse-non-empty, force-drops-non-empty.
- docs/DEPLOY.md: lean provisioning step after upgrade-all.
- ADR-014 ACCEPTED; index updated.

Verified on MySQL: full install then prune = no-op (86 tables); lean install
(machines+printers) then prune drops the other 19 plugin tables; second run
no-op. Full suite 1077 passed.
2026-07-19 11:39:46 -04:00

88 lines
3.8 KiB
Python

"""Tests for `flask plugin prune-schema` (ADR-014 schema-lean per-site).
The shared core Alembic baseline creates every plugin's tables, so a lean site
that omits a plugin still carries that plugin's (empty) tables. prune-schema
drops the tables of every not-installed plugin, leaving core + chosen-plugin
tables. These tests pin the three behaviors that matter: only not-installed
plugins are dropped, the non-empty guard refuses without --force, and a full
site (every plugin installed) is a no-op.
"""
import pytest
from sqlalchemy import inspect, text
from shopdb.extensions import db as _db
from shopdb.plugins.cli import plugin_cli
from shopdb.plugins.alembic_template import PLUGIN_TABLE_OWNERS
def _install_only(app, monkeypatch, installed_names):
"""Force discover_available to report exactly `installed_names` installed."""
pm = app.extensions['plugin_manager']
listing = [{'name': name, 'installed': name in installed_names}
for name in PLUGIN_TABLE_OWNERS]
monkeypatch.setattr(pm, 'discover_available', lambda: listing)
def test_prune_drops_only_not_installed(app, db, runner, monkeypatch):
"""A lean site keeps its plugins' tables and drops every other plugin's."""
_install_only(app, monkeypatch, {'machines', 'printers'})
before = set(inspect(_db.engine).get_table_names())
assert 'usbdevices' in before and 'machines' in before # baseline sanity
result = runner.invoke(plugin_cli, ['prune-schema', '--yes'])
assert result.exit_code == 0, result.output
after = set(inspect(_db.engine).get_table_names())
# Kept: machines + printers owned tables.
for keep in PLUGIN_TABLE_OWNERS['machines'] + PLUGIN_TABLE_OWNERS['printers']:
assert keep in after, f'{keep} should have been kept'
# Dropped: every other plugin's tables.
for plugin, tables in PLUGIN_TABLE_OWNERS.items():
if plugin in ('machines', 'printers'):
continue
for tablename in tables:
assert tablename not in after, f'{tablename} should have been dropped'
# Core tables untouched.
for core in ('assets', 'users', 'settings'):
assert core in after
def test_prune_full_site_is_noop(app, db, runner, monkeypatch):
"""Every plugin installed -> nothing to prune, schema unchanged."""
_install_only(app, monkeypatch, set(PLUGIN_TABLE_OWNERS))
before = set(inspect(_db.engine).get_table_names())
result = runner.invoke(plugin_cli, ['prune-schema', '--yes', '--force'])
assert result.exit_code == 0, result.output
assert 'Nothing to prune' in result.output
assert set(inspect(_db.engine).get_table_names()) == before
def test_prune_refuses_non_empty_without_force(app, db, runner, monkeypatch):
"""A not-installed table that holds rows is not dropped without --force."""
_install_only(app, monkeypatch, {'machines'})
_db.session.execute(text(
"INSERT INTO usbdevicetypes (typename, createddate, modifieddate, "
"isactive) VALUES ('kept-by-guard', '2026-01-01', '2026-01-01', 1)"))
_db.session.commit()
result = runner.invoke(plugin_cli, ['prune-schema', '--yes'])
assert result.exit_code == 1
assert 'REFUSING' in result.output
# The row-bearing table survives the refusal.
assert 'usbdevicetypes' in set(inspect(_db.engine).get_table_names())
def test_prune_force_drops_non_empty(app, db, runner, monkeypatch):
"""--force drops even a table that holds rows."""
_install_only(app, monkeypatch, {'machines'})
_db.session.execute(text(
"INSERT INTO usbdevicetypes (typename, createddate, modifieddate, "
"isactive) VALUES ('dropped-by-force', '2026-01-01', '2026-01-01', 1)"))
_db.session.commit()
result = runner.invoke(plugin_cli, ['prune-schema', '--yes', '--force'])
assert result.exit_code == 0, result.output
assert 'usbdevicetypes' not in set(inspect(_db.engine).get_table_names())