mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-28 23:57:58 +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>
183 lines
6.9 KiB
Python
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'
|