mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-25 04:28:00 +09:00
The `app` fixture called create_app('testing') for every one of 3173 tests,
registering 90+ blueprints each time. Measured on a fixed 199-test sample
(pytest --splits 16 --group 5 --durations=0):
setup 144.9s 89.5%
call 8.8s 5.4%
teardown 8.2s 5.0%
95% of the suite's time was preparing to test; 5% was testing. The app is now
built once per process and shared.
Same 794-test slice, before and after — identical results, 2.66x faster:
before 4 failed, 773 passed, 17 skipped 593.30s
after 4 failed, 773 passed, 17 skipped 223.11s
(the 4 are pre-existing locally: serverkit-wordpress isn't checked out here.)
Sharing one app surfaces three things a per-test app hid, all handled in the
fixture rather than patched test by test:
1. create_app() is not a pure constructor — it SEEDS the bundled flagship
extension rows as a boot side effect (test_cloudflare_extraction asserts
they are "seeded on boot"). The per-test drop_all wipes them, and a shared
app never re-boots, so every test after the first saw no flagship and the
extension routes answered 503. seed_flagship_extensions() is now re-run
after each create_all. The session fixture also boots twice on purpose:
once to create the schema, once to seed into a schema that exists.
2. Config mutation now leaks where it used to die with the app (e.g.
test_demo_deploys sets DEMO_DEPLOYS_ENABLED=False and never restores it),
so config is snapshotted and restored around every test.
3. Structure mutation cannot be undone at all — Flask has no
unregister_blueprint, and refuses setup methods once an app has served a
request. Those tests opt out via `pytestmark = pytest.mark.fresh_app` and
get a private app. Three groups needed it: 13 modules calling
register_blueprint/add_url_rule directly, 11 that install plugins (
plugin_service hot-loads their blueprints onto the live app), and
test_audit_log which adds routes with @app.route.
Tests using the `wp_extension` fixture are detected from the fixture graph
instead of being marked, since that fixture mounts blueprints on whatever app
it is handed — miss one marker and the failure lands in an unrelated test much
later in the run.
Plan 64 Phase 1. Phase 2 (savepoint rollback instead of create_all/drop_all)
is NOT included: it currently takes the slice from 4 failures to 100 failures
and 201 errors, and needs its own round.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
246 lines
9.0 KiB
Python
246 lines
9.0 KiB
Python
"""Phase 3 platform primitives: version gates, permissions, data models,
|
|
declarative jobs, socket extension point, and data-purge on uninstall.
|
|
|
|
Backend plugin code normally lives at app/plugins/<slug>/ on disk and is imported
|
|
by name. To exercise the importlib-driven hooks without writing packages into the
|
|
repo, we inject synthetic modules into sys.modules under the expected names.
|
|
"""
|
|
import io
|
|
import json
|
|
import sys
|
|
import types
|
|
import zipfile
|
|
|
|
import pytest
|
|
|
|
from app import db
|
|
from app.models.plugin import InstalledPlugin
|
|
from app.services import plugin_service, extension_lifecycle
|
|
from app.plugins_sdk import permissions, sockets as sdk_sockets
|
|
import app.plugins_sdk as sdk
|
|
|
|
# Installs plugins, and plugin_service hot-loads their blueprints onto the
|
|
# live app. Flask refuses register_blueprint once an app has served a
|
|
# request, so these need a private app (plan 64 Phase 1).
|
|
pytestmark = pytest.mark.fresh_app
|
|
|
|
|
|
SLUG = 'testext'
|
|
|
|
|
|
# A plugin-owned model, defined once against the real db so it registers on the
|
|
# shared metadata a single time (extend_existing keeps re-imports harmless).
|
|
class _ExtThing(db.Model):
|
|
__tablename__ = 'ext_testext_thing'
|
|
__table_args__ = {'extend_existing': True}
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
label = db.Column(db.String(40))
|
|
|
|
|
|
_job_calls = []
|
|
|
|
|
|
def _inject_plugin_modules():
|
|
"""Register synthetic app.plugins.testext.{models,jobs,sockets} modules."""
|
|
pkg = types.ModuleType(f'app.plugins.{SLUG}')
|
|
pkg.__path__ = []
|
|
sys.modules[f'app.plugins.{SLUG}'] = pkg
|
|
|
|
models_mod = types.ModuleType(f'app.plugins.{SLUG}.models')
|
|
models_mod.register = lambda _db: [_ExtThing]
|
|
sys.modules[f'app.plugins.{SLUG}.models'] = models_mod
|
|
|
|
jobs_mod = types.ModuleType(f'app.plugins.{SLUG}.jobs')
|
|
jobs_mod.reindex = lambda job: _job_calls.append(job)
|
|
sys.modules[f'app.plugins.{SLUG}.jobs'] = jobs_mod
|
|
|
|
sockets_mod = types.ModuleType(f'app.plugins.{SLUG}.sockets')
|
|
|
|
def _register():
|
|
def on_connect():
|
|
return 'connected'
|
|
def on_ping(data):
|
|
return {'pong': data}
|
|
return {'connect': on_connect, 'ping': on_ping}
|
|
sockets_mod.register = _register
|
|
sys.modules[f'app.plugins.{SLUG}.sockets'] = sockets_mod
|
|
|
|
|
|
@pytest.fixture
|
|
def injected_plugin():
|
|
_inject_plugin_modules()
|
|
yield
|
|
for name in list(sys.modules):
|
|
if name == f'app.plugins.{SLUG}' or name.startswith(f'app.plugins.{SLUG}.'):
|
|
del sys.modules[name]
|
|
_job_calls.clear()
|
|
|
|
|
|
def _plugin_row(manifest):
|
|
p = InstalledPlugin(
|
|
name=SLUG, display_name='Test Ext', slug=SLUG, version='1.0.0',
|
|
status=InstalledPlugin.STATUS_ACTIVE, has_backend=True,
|
|
)
|
|
p.manifest = manifest
|
|
db.session.add(p)
|
|
db.session.commit()
|
|
return p
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# #28 — version compat gate at install (all sources)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def test_min_panel_version_blocks_install(app):
|
|
manifest = {'name': 'toonew', 'display_name': 'Too New', 'version': '1.0.0',
|
|
'min_panel_version': '999.0.0'}
|
|
with pytest.raises(ValueError, match='needs panel'):
|
|
plugin_service._assert_manifest_panel_compatible(manifest)
|
|
|
|
|
|
def test_no_bounds_installs_anywhere(app):
|
|
# Missing bounds → no exception.
|
|
plugin_service._assert_manifest_panel_compatible(
|
|
{'name': 'x', 'display_name': 'X', 'version': '1.0.0'})
|
|
|
|
|
|
def test_sdk_exposes_panel_version(app):
|
|
v = sdk.panel_version()
|
|
assert isinstance(v, str) and v
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# #25 — permission enforcement
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def test_permission_gate(app):
|
|
_plugin_row({'permissions': ['docker', 'network']})
|
|
assert permissions.has(SLUG, 'docker') is True
|
|
assert permissions.has(SLUG, 'shell') is False
|
|
assert permissions.require(SLUG, 'docker') is True
|
|
with pytest.raises(permissions.PermissionDenied):
|
|
permissions.require(SLUG, 'shell')
|
|
# SDK re-export points at the same gate.
|
|
with pytest.raises(permissions.PermissionDenied):
|
|
sdk.require_permission(SLUG, 'filesystem')
|
|
|
|
|
|
def test_unknown_permissions_flagged():
|
|
assert permissions.unknown_permissions(['docker', 'bogus', 'agent.command:x']) == ['bogus']
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# #24 — plugin data models + purge
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def test_register_and_purge_models(app, injected_plugin):
|
|
from sqlalchemy import inspect
|
|
p = _plugin_row({'models': 'models:register'})
|
|
|
|
extension_lifecycle.register_models(p, p.manifest)
|
|
assert 'ext_testext_thing' in inspect(db.engine).get_table_names()
|
|
|
|
dropped = extension_lifecycle.purge_models(p)
|
|
assert dropped >= 1
|
|
assert 'ext_testext_thing' not in inspect(db.engine).get_table_names()
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# #29 — declarative jobs (register / pause / resume / remove)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def test_declarative_jobs_lifecycle(app, injected_plugin):
|
|
from app.jobs import registry
|
|
from app.jobs.models import ScheduledJob
|
|
|
|
manifest = {
|
|
'jobs': [{'kind': 'testext.reindex', 'handler': 'jobs:reindex'}],
|
|
'schedules': [{'name': 'testext-nightly', 'kind': 'testext.reindex',
|
|
'interval_seconds': 3600}],
|
|
}
|
|
p = _plugin_row(manifest)
|
|
|
|
extension_lifecycle.register_jobs(p, manifest)
|
|
assert registry.get('testext.reindex') is not None
|
|
sched = ScheduledJob.query.filter_by(name='testext-nightly').first()
|
|
assert sched is not None and sched.enabled is True
|
|
|
|
extension_lifecycle.pause_jobs(p, manifest)
|
|
assert ScheduledJob.query.filter_by(name='testext-nightly').first().enabled is False
|
|
|
|
extension_lifecycle.resume_jobs(p, manifest)
|
|
assert ScheduledJob.query.filter_by(name='testext-nightly').first().enabled is True
|
|
|
|
extension_lifecycle.remove_jobs(p, manifest)
|
|
assert ScheduledJob.query.filter_by(name='testext-nightly').first() is None
|
|
|
|
|
|
def test_disable_enable_pauses_jobs(app, injected_plugin):
|
|
from app.jobs.models import ScheduledJob
|
|
manifest = {
|
|
'schedules': [{'name': 'testext-nightly', 'kind': 'testext.reindex',
|
|
'interval_seconds': 3600}],
|
|
}
|
|
p = _plugin_row(manifest)
|
|
extension_lifecycle.register_jobs(p, manifest)
|
|
|
|
plugin_service.disable_plugin(p.id)
|
|
assert ScheduledJob.query.filter_by(name='testext-nightly').first().enabled is False
|
|
plugin_service.enable_plugin(p.id)
|
|
assert ScheduledJob.query.filter_by(name='testext-nightly').first().enabled is True
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# #26 — Socket.IO extension point
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
class _FakeIO:
|
|
def __init__(self):
|
|
self.registered = []
|
|
def on_event(self, event, handler, namespace=None):
|
|
self.registered.append((event, namespace, handler))
|
|
|
|
|
|
def test_socket_namespace_registration(app, injected_plugin, monkeypatch):
|
|
fake = _FakeIO()
|
|
monkeypatch.setattr('app.get_socketio', lambda: fake, raising=False)
|
|
import app as app_pkg
|
|
monkeypatch.setattr(app_pkg, 'get_socketio', lambda: fake)
|
|
|
|
p = _plugin_row({'socket_entry': 'sockets:register'})
|
|
ns = sdk_sockets.register_from_manifest(p, p.manifest)
|
|
|
|
assert ns == f'/ext/{SLUG}'
|
|
events = {e for e, _ns, _h in fake.registered}
|
|
assert events == {'connect', 'ping'}
|
|
assert all(_ns == f'/ext/{SLUG}' for _e, _ns, _h in fake.registered)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# #30 — uninstall purge vs keep-data
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def test_uninstall_purge_drops_tables(app, injected_plugin, tmp_path, monkeypatch):
|
|
from sqlalchemy import inspect
|
|
monkeypatch.setattr(plugin_service, 'BACKEND_PLUGINS_DIR', str(tmp_path / 'b'))
|
|
monkeypatch.setattr(plugin_service, 'FRONTEND_PLUGINS_DIR', str(tmp_path / 'f'))
|
|
|
|
p = _plugin_row({'models': 'models:register'})
|
|
extension_lifecycle.register_models(p, p.manifest)
|
|
assert 'ext_testext_thing' in inspect(db.engine).get_table_names()
|
|
|
|
plugin_service.uninstall_plugin(p.id, purge=True)
|
|
assert 'ext_testext_thing' not in inspect(db.engine).get_table_names()
|
|
|
|
|
|
def test_uninstall_keep_data_leaves_tables(app, injected_plugin, tmp_path, monkeypatch):
|
|
from sqlalchemy import inspect
|
|
monkeypatch.setattr(plugin_service, 'BACKEND_PLUGINS_DIR', str(tmp_path / 'b'))
|
|
monkeypatch.setattr(plugin_service, 'FRONTEND_PLUGINS_DIR', str(tmp_path / 'f'))
|
|
|
|
p = _plugin_row({'models': 'models:register'})
|
|
extension_lifecycle.register_models(p, p.manifest)
|
|
|
|
plugin_service.uninstall_plugin(p.id, purge=False)
|
|
assert 'ext_testext_thing' in inspect(db.engine).get_table_names()
|