Files
ServerKit/backend/tests/test_plugin_store_sdk.py
Juan DenisandClaude Opus 5 9d570834aa perf(tests): build the Flask app once per session, not once per test
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>
2026-08-06 02:59:14 -04:00

183 lines
6.9 KiB
Python

"""Per-plugin key/value storage (plugins_sdk.store).
The tax this removes: before it, an extension that needed to remember anything
had to add a model AND an Alembic migration to core — which is why extension
tables sit in the panel's own migration history.
What the tests pin down is the part a shared table makes load-bearing: two
plugins can never see or clobber each other's keys, one plugin can't fill the
table, and an uninstall that wasn't asked to purge leaves the data alone.
"""
import pytest
from app import db
from app.models.plugin_store import PluginStore
from app.plugins_sdk.store_sdk import MAX_VALUE_BYTES, PluginStoreError, store
# 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
@pytest.fixture
def mine(app):
return store.for_plugin('serverkit-demo')
class TestRoundTrip:
def test_set_then_get(self, app, mine):
mine.set('world:1:seed', 8675309)
assert mine.get('world:1:seed') == 8675309
def test_stores_json_shapes(self, app, mine):
value = {'players': ['a', 'b'], 'settings': {'hardcore': True, 'slots': 20},
'motd': None}
mine.set('world:1', value)
assert mine.get('world:1') == value
def test_missing_key_returns_the_default(self, app, mine):
assert mine.get('nope') is None
assert mine.get('nope', 'fallback') == 'fallback'
def test_has_distinguishes_a_stored_none_from_absence(self, app, mine):
mine.set('explicit', None)
# get() alone can't tell these apart, which is why has() exists.
assert mine.get('explicit') is None and mine.get('absent') is None
assert mine.has('explicit') is True
assert mine.has('absent') is False
def test_set_overwrites_without_duplicating_the_row(self, app, mine):
mine.set('k', 'first')
mine.set('k', 'second')
assert mine.get('k') == 'second'
assert PluginStore.query.filter_by(plugin_slug='serverkit-demo',
key='k').count() == 1
def test_setdefault_only_writes_when_unset(self, app, mine):
assert mine.setdefault('k', 'original') == 'original'
assert mine.setdefault('k', 'ignored') == 'original'
def test_delete(self, app, mine):
mine.set('k', 1)
assert mine.delete('k') is True
assert mine.delete('k') is False
assert mine.get('k') is None
def test_keys_and_all_with_a_prefix(self, app, mine):
mine.set('world:1:seed', 1)
mine.set('world:2:seed', 2)
mine.set('backup:last', 3)
assert mine.keys() == ['backup:last', 'world:1:seed', 'world:2:seed']
assert mine.keys(prefix='world:') == ['world:1:seed', 'world:2:seed']
assert mine.all(prefix='world:') == {'world:1:seed': 1, 'world:2:seed': 2}
def test_prefix_wildcards_are_matched_literally(self, app, mine):
# '_' and '%' are LIKE wildcards; an unescaped prefix would match keys
# the caller never asked for.
mine.set('a_b', 'wanted')
mine.set('axb', 'not wanted')
assert mine.keys(prefix='a_') == ['a_b']
class TestIsolation:
def test_two_plugins_cannot_see_or_clobber_each_other(self, app):
one = store.for_plugin('plugin-one')
two = store.for_plugin('plugin-two')
one.set('shared-key', 'from one')
two.set('shared-key', 'from two')
assert one.get('shared-key') == 'from one'
assert two.get('shared-key') == 'from two'
assert one.keys() == ['shared-key']
one.clear()
assert one.get('shared-key') is None
assert two.get('shared-key') == 'from two'
class TestRefusals:
def test_rejects_unserialisable_values(self, app, mine):
# Caught at set() rather than at commit, where the error is opaque and
# the session is already poisoned.
with pytest.raises(PluginStoreError):
mine.set('k', object())
def test_rejects_oversized_values(self, app, mine):
with pytest.raises(PluginStoreError):
mine.set('k', 'x' * (MAX_VALUE_BYTES + 1))
def test_rejects_empty_keys(self, app, mine):
for key in ('', ' ', None, 7):
with pytest.raises(PluginStoreError):
mine.set(key, 1)
def test_rejects_overlong_keys(self, app, mine):
with pytest.raises(PluginStoreError):
mine.set('k' * 256, 1)
def test_a_store_needs_a_slug(self, app):
with pytest.raises(PluginStoreError):
store.for_plugin('')
def test_a_refused_value_leaves_the_session_usable(self, app, mine):
with pytest.raises(PluginStoreError):
mine.set('k', object())
# The point of validating early: the next write still works.
mine.set('k', 'fine')
assert mine.get('k') == 'fine'
class TestUninstall:
def _install_row(self, slug):
from app.models.plugin import InstalledPlugin
plugin = InstalledPlugin(slug=slug, name=slug, display_name=slug,
version='1.0.0',
status=InstalledPlugin.STATUS_ACTIVE)
db.session.add(plugin)
db.session.commit()
return plugin
def test_purge_removes_only_that_plugins_rows(self, app):
store.for_plugin('plugin-one').set('k', 1)
store.for_plugin('plugin-two').set('k', 2)
assert store.purge('plugin-one') == 1
assert store.for_plugin('plugin-one').get('k') is None
assert store.for_plugin('plugin-two').get('k') == 2
def test_uninstall_with_purge_clears_the_store(self, app, tmp_path, monkeypatch):
from app.services import plugin_service
plugin = self._install_row('demo-purge')
store.for_plugin('demo-purge').set('k', 'data')
monkeypatch.setattr(plugin_service, 'BACKEND_PLUGINS_DIR', str(tmp_path / 'b'))
monkeypatch.setattr(plugin_service, 'FRONTEND_PLUGINS_DIR', str(tmp_path / 'f'))
plugin_service.uninstall_plugin(plugin.id, purge=True)
assert store.for_plugin('demo-purge').get('k') is None
def test_uninstall_without_purge_keeps_the_store(self, app, tmp_path, monkeypatch):
# Same rule the ext_* tables follow: reinstalling finds its state.
from app.services import plugin_service
plugin = self._install_row('demo-keep')
store.for_plugin('demo-keep').set('k', 'data')
monkeypatch.setattr(plugin_service, 'BACKEND_PLUGINS_DIR', str(tmp_path / 'b'))
monkeypatch.setattr(plugin_service, 'FRONTEND_PLUGINS_DIR', str(tmp_path / 'f'))
plugin_service.uninstall_plugin(plugin.id, purge=False)
assert store.for_plugin('demo-keep').get('k') == 'data'
def test_sdk_is_reachable_from_the_package(app):
from app import plugins_sdk
plugins_sdk.store.for_plugin('serverkit-demo').set('k', 'v')
assert plugins_sdk.store.for_plugin('serverkit-demo').get('k') == 'v'