Endpoint telemetry, PC Detail rework and a checked backup/restore path
This commit is contained in:
44
tests/test_core/test_pluginui_panel_groups.py
Normal file
44
tests/test_core/test_pluginui_panel_groups.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""The server-side panel group vocabulary must admit every group a plugin uses.
|
||||
|
||||
`asset_panels()` resolves a panel's group BEFORE the client sees it, coercing
|
||||
any unrecognised value to 'details'. That makes ASSET_PANEL_GROUPS a third copy
|
||||
of the tab vocabulary, alongside RENDERED_GROUPS in PluginAssetPanels.vue and
|
||||
the tab list in PCDetail.vue. Phase 3 added 'serviceprocess' to the two
|
||||
frontend copies and missed this one, so both Service/Process panels were
|
||||
silently relabelled 'details' on the wire and the tab never appeared.
|
||||
|
||||
The failure is invisible: no error, no log line, and the panels still render -
|
||||
just in the wrong tab, underneath the Details cards.
|
||||
"""
|
||||
from shopdb.core.api.pluginui import ASSET_PANEL_GROUPS
|
||||
from plugins.endpoints.plugin import EndpointsPlugin
|
||||
|
||||
|
||||
def test_every_group_the_endpoints_plugin_declares_survives_resolution():
|
||||
declared = {
|
||||
str(panel.get('group') or '').strip().lower()
|
||||
for panel in EndpointsPlugin().get_asset_panels()
|
||||
}
|
||||
declared.discard('')
|
||||
unknown = declared - set(ASSET_PANEL_GROUPS)
|
||||
assert not unknown, (
|
||||
f'panels declare {sorted(unknown)}, which the server rewrites to '
|
||||
"'details' - the tab will not appear"
|
||||
)
|
||||
|
||||
|
||||
def test_serviceprocess_is_admitted():
|
||||
# Named explicitly: the generic test above would also pass if the
|
||||
# serviceprocess panels were deleted outright.
|
||||
assert 'serviceprocess' in ASSET_PANEL_GROUPS
|
||||
|
||||
|
||||
def test_reports_is_admitted():
|
||||
# Same reasoning as serviceprocess above. Operator Reports was moved out of
|
||||
# System Monitors into a tab of its own, which is the same three-copy
|
||||
# change that Phase 3 got wrong.
|
||||
assert 'reports' in ASSET_PANEL_GROUPS
|
||||
|
||||
|
||||
def test_details_remains_the_fallback():
|
||||
assert ASSET_PANEL_GROUPS[0] == 'details'
|
||||
242
tests/test_core/test_shopdbbackups_finalize.py
Normal file
242
tests/test_core/test_shopdbbackups_finalize.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""The post-restore schema upgrade: who runs it, and what happens when it fails.
|
||||
|
||||
A restore loads the dump in the Flask-less backup sidecar and leaves a sentinel,
|
||||
because only the api container can run the migrations. These tests pin the two
|
||||
properties that failure taught us to care about: the upgrade must not depend on
|
||||
a browser staying open, and it must never run twice at once.
|
||||
"""
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.core.api import shopdbbackups
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backups_root(tmp_path, monkeypatch):
|
||||
"""Point the backup routes at a real, empty directory."""
|
||||
root = tmp_path / 'backups'
|
||||
root.mkdir()
|
||||
monkeypatch.setenv(shopdbbackups.BACKUPS_ROOT_ENV, str(root))
|
||||
return root
|
||||
|
||||
|
||||
def _arm(root, setname='wj-2026-09-06T120000Z'):
|
||||
(root / shopdbbackups.UPGRADE_SENTINEL).write_text(setname, encoding='utf-8')
|
||||
return setname
|
||||
|
||||
|
||||
def test_only_one_claimant_wins_a_pending_upgrade(app, backups_root):
|
||||
"""Two workers racing the same sentinel must not both run Alembic."""
|
||||
setname = _arm(backups_root)
|
||||
with app.app_context():
|
||||
first = shopdbbackups._claim_upgrade()
|
||||
second = shopdbbackups._claim_upgrade()
|
||||
|
||||
assert first == setname
|
||||
assert second is None, 'a second worker claimed an upgrade already in flight'
|
||||
assert not (backups_root / shopdbbackups.UPGRADE_SENTINEL).exists()
|
||||
assert (backups_root / shopdbbackups.UPGRADE_CLAIM).exists()
|
||||
|
||||
|
||||
def test_claiming_nothing_is_not_an_error(app, backups_root):
|
||||
with app.app_context():
|
||||
assert shopdbbackups._claim_upgrade() is None
|
||||
|
||||
|
||||
def test_a_successful_finalize_clears_the_claim_and_says_done(app, backups_root,
|
||||
monkeypatch):
|
||||
setname = _arm(backups_root)
|
||||
monkeypatch.setattr(shopdbbackups, '_run_schema_upgrade', lambda: None)
|
||||
|
||||
with app.app_context():
|
||||
claimed = shopdbbackups._claim_upgrade()
|
||||
assert shopdbbackups._finalize_claimed(claimed) is True
|
||||
|
||||
assert not (backups_root / shopdbbackups.UPGRADE_CLAIM).exists()
|
||||
with app.app_context():
|
||||
status = shopdbbackups._read_status('restore')
|
||||
assert status and status['phase'] == 'done'
|
||||
assert status['setname'] == setname
|
||||
|
||||
|
||||
def test_a_failed_upgrade_parks_itself_instead_of_retrying_forever(app,
|
||||
backups_root,
|
||||
monkeypatch):
|
||||
"""A failing migration must not be re-armed automatically.
|
||||
|
||||
Putting the sentinel back would run the same broken chain on every poll for
|
||||
as long as the container lives.
|
||||
"""
|
||||
_arm(backups_root)
|
||||
|
||||
def _boom():
|
||||
raise RuntimeError('Can\'t locate revision identified by abc123')
|
||||
|
||||
monkeypatch.setattr(shopdbbackups, '_run_schema_upgrade', _boom)
|
||||
|
||||
with app.app_context():
|
||||
claimed = shopdbbackups._claim_upgrade()
|
||||
assert shopdbbackups._finalize_claimed(claimed) is False
|
||||
|
||||
assert (backups_root / shopdbbackups.UPGRADE_FAILED).exists()
|
||||
assert not (backups_root / shopdbbackups.UPGRADE_SENTINEL).exists(), \
|
||||
'a failed upgrade re-armed itself and will retry forever'
|
||||
assert not (backups_root / shopdbbackups.UPGRADE_CLAIM).exists()
|
||||
with app.app_context():
|
||||
status = shopdbbackups._read_status('restore')
|
||||
assert status and status['phase'] == 'failed'
|
||||
|
||||
|
||||
def test_the_endpoint_does_not_run_the_upgrade_itself(app, client, auth_headers,
|
||||
backups_root, monkeypatch):
|
||||
"""Alembic in a request can outrun the worker timeout and be killed mid-chain."""
|
||||
_arm(backups_root)
|
||||
ran = []
|
||||
monkeypatch.setattr(shopdbbackups, '_run_schema_upgrade',
|
||||
lambda: ran.append(True))
|
||||
|
||||
response = client.post('/api/shopdbbackups/finalize-restore',
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert ran == [], 'the request ran the schema upgrade inline'
|
||||
assert response.get_json()['data']['queued'] is True
|
||||
assert (backups_root / shopdbbackups.UPGRADE_SENTINEL).exists(), \
|
||||
'the endpoint consumed the sentinel the watcher needs to claim'
|
||||
|
||||
|
||||
def test_the_endpoint_re_arms_a_failed_upgrade(app, client, auth_headers,
|
||||
backups_root):
|
||||
"""Retrying a failed upgrade is what this endpoint is for now."""
|
||||
(backups_root / shopdbbackups.UPGRADE_FAILED).write_text('wj-set',
|
||||
encoding='utf-8')
|
||||
|
||||
response = client.post('/api/shopdbbackups/finalize-restore',
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['data']['retried'] is True
|
||||
assert (backups_root / shopdbbackups.UPGRADE_SENTINEL).read_text() == 'wj-set'
|
||||
assert not (backups_root / shopdbbackups.UPGRADE_FAILED).exists()
|
||||
|
||||
|
||||
def test_nothing_pending_reports_nothing_pending(app, client, auth_headers,
|
||||
backups_root):
|
||||
response = client.post('/api/shopdbbackups/finalize-restore',
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['data']['queued'] is False
|
||||
|
||||
|
||||
def test_an_unconfigured_deployment_refuses(app, client, auth_headers,
|
||||
tmp_path, monkeypatch):
|
||||
"""No store behind the feature means say so, not 500 on a missing path."""
|
||||
monkeypatch.setenv(shopdbbackups.BACKUPS_ROOT_ENV,
|
||||
str(tmp_path / 'nothing-here'))
|
||||
|
||||
response = client.post('/api/shopdbbackups/finalize-restore',
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestPluginChainCompatibility:
|
||||
"""The restore gate has to see plugin chains, not just the core one.
|
||||
|
||||
The failure it exists for: roll code back to an older plugin revision and
|
||||
the CORE head can be identical, so a core-only gate passes a dump whose
|
||||
plugin tables the code has never heard of. It loads - destroying what was
|
||||
there - and only then dies in 'flask plugin upgrade-all'.
|
||||
"""
|
||||
|
||||
def _manifest(self, core_rev, plugins=None):
|
||||
manifest = {'datarev': core_rev, 'schemarev': {'core': [core_rev]}}
|
||||
if plugins is not None:
|
||||
manifest['dataplugins'] = plugins
|
||||
return manifest
|
||||
|
||||
def _real_core_rev(self, app):
|
||||
with app.app_context():
|
||||
known = shopdbbackups._known_core_revisions()
|
||||
assert known, 'expected this build to know its own core revisions'
|
||||
return sorted(known)[0]
|
||||
|
||||
def _real_plugin_rev(self, app):
|
||||
with app.app_context():
|
||||
known = shopdbbackups._known_plugin_revisions()
|
||||
assert known, 'expected this build to have plugin migration chains'
|
||||
name = sorted(known)[0]
|
||||
return name, sorted(known[name])[0]
|
||||
|
||||
def test_a_set_from_a_newer_plugin_chain_is_refused(self, app):
|
||||
core = self._real_core_rev(app)
|
||||
name, _ = self._real_plugin_rev(app)
|
||||
manifest = self._manifest(core, {name: 'thisrevisiondoesnotexist'})
|
||||
|
||||
with app.app_context():
|
||||
assert shopdbbackups._is_compatible(manifest, {}) is False, \
|
||||
'a dump carrying unknown plugin data was called restorable'
|
||||
|
||||
def test_a_set_whose_plugin_chains_are_known_is_allowed(self, app):
|
||||
core = self._real_core_rev(app)
|
||||
name, rev = self._real_plugin_rev(app)
|
||||
manifest = self._manifest(core, {name: rev})
|
||||
|
||||
with app.app_context():
|
||||
assert shopdbbackups._is_compatible(manifest, {}) is True
|
||||
|
||||
def test_a_plugin_this_build_does_not_have_is_refused(self, app):
|
||||
core = self._real_core_rev(app)
|
||||
manifest = self._manifest(core, {'nosuchplugin': 'somerevision'})
|
||||
|
||||
with app.app_context():
|
||||
assert shopdbbackups._is_compatible(manifest, {}) is False
|
||||
|
||||
def test_a_legacy_set_without_the_stamp_keeps_its_old_verdict(self, app):
|
||||
"""Sets taken before dataplugins existed must not become unrestorable."""
|
||||
core = self._real_core_rev(app)
|
||||
|
||||
with app.app_context():
|
||||
assert shopdbbackups._is_compatible(self._manifest(core), {}) is True
|
||||
assert shopdbbackups._is_compatible(
|
||||
self._manifest(core, {}), {}) is True
|
||||
|
||||
def test_the_core_gate_still_refuses_an_unknown_core_revision(self, app):
|
||||
name, rev = self._real_plugin_rev(app)
|
||||
manifest = self._manifest('unknowncorerevision', {name: rev})
|
||||
|
||||
with app.app_context():
|
||||
assert shopdbbackups._is_compatible(manifest, {}) is False
|
||||
|
||||
|
||||
class TestDatarevEvidence:
|
||||
"""An unreadable data revision must not be badged restorable."""
|
||||
|
||||
def _real_core_rev(self, app):
|
||||
with app.app_context():
|
||||
known = shopdbbackups._known_core_revisions()
|
||||
return sorted(known)[0]
|
||||
|
||||
def test_an_empty_datarev_is_no_evidence_not_a_legacy_set(self, app):
|
||||
"""backup.sh discards the mysql error, so datarev can be ''.
|
||||
|
||||
Falling back to the code-to-code head check there would badge the set
|
||||
compatible using the very comparison datarev was introduced to replace.
|
||||
"""
|
||||
core = self._real_core_rev(app)
|
||||
manifest = {'datarev': '', 'schemarev': {'core': [core]}}
|
||||
|
||||
with app.app_context():
|
||||
assert shopdbbackups._is_compatible(
|
||||
manifest, {'core': [core]}) is False
|
||||
|
||||
def test_a_manifest_with_no_datarev_key_still_falls_back(self, app):
|
||||
"""A genuine legacy set predates the field entirely and keeps working."""
|
||||
core = self._real_core_rev(app)
|
||||
manifest = {'schemarev': {'core': [core]}}
|
||||
|
||||
with app.app_context():
|
||||
assert shopdbbackups._is_compatible(
|
||||
manifest, {'core': [core]}) is True
|
||||
Reference in New Issue
Block a user