Systematic rename of all semantic anchors (#region, [DEF], @RELATION) across 1400+ files — backend Python, frontend Svelte/TS, specs, docs: - Flat anchors become Namespace.Module.Entity - @RELATION references updated to match new anchor paths - Zero business logic changes
465 lines
18 KiB
Python
465 lines
18 KiB
Python
# #region Test.BackupPlugin [C:3] [TYPE Module] [SEMANTICS test, backup, plugin, coverage, integrity]
|
|
# @BRIEF Unit tests for BackupPlugin — properties, get_schema, execute, integrity metadata, and edge cases.
|
|
# @RELATION BINDS_TO -> [Plugin.Backup.BackupPlugin]
|
|
# @TEST_EDGE: missing_env -> Raises KeyError
|
|
# @TEST_EDGE: no_dashboards -> Returns NO_DASHBOARDS status
|
|
# @TEST_EDGE: partial_failure -> Returns PARTIAL_SUCCESS with failed_dashboards
|
|
# @TEST_EDGE: empty_environments -> get_schema returns empty list
|
|
# @TEST_EDGE: integrity_metadata -> dashboard entries include archive_sha256, content_hash, integrity_status, manifest_path
|
|
|
|
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
|
|
from pathlib import Path
|
|
from requests.exceptions import RequestException
|
|
|
|
from src.plugins.backup import BackupPlugin
|
|
|
|
|
|
# ── Helpers ──
|
|
|
|
|
|
def _make_env(id_val="env-1", name="Test Env"):
|
|
env = MagicMock()
|
|
env.id = id_val
|
|
env.name = name
|
|
return env
|
|
|
|
|
|
def _make_task_context():
|
|
ctx = MagicMock()
|
|
ctx.logger = MagicMock()
|
|
ctx.logger.with_source.return_value = MagicMock()
|
|
return ctx
|
|
|
|
|
|
def _make_dashboard(dash_id=1, title="Test Dash"):
|
|
return {"id": dash_id, "dashboard_title": title}
|
|
|
|
|
|
class TestBackupPluginProperties:
|
|
"""Verify static property values."""
|
|
|
|
def test_id(self):
|
|
plugin = BackupPlugin()
|
|
assert plugin.id == "superset-backup"
|
|
|
|
def test_name(self):
|
|
plugin = BackupPlugin()
|
|
assert plugin.name == "Superset Dashboard Backup"
|
|
|
|
def test_description(self):
|
|
plugin = BackupPlugin()
|
|
assert plugin.description == "Backs up all dashboards from a Superset instance."
|
|
|
|
def test_version(self):
|
|
plugin = BackupPlugin()
|
|
assert plugin.version == "1.0.0"
|
|
|
|
def test_ui_route(self):
|
|
plugin = BackupPlugin()
|
|
assert plugin.ui_route == "/tools/backups"
|
|
|
|
|
|
class TestBackupPluginGetSchema:
|
|
"""Verify get_schema — dynamic schema based on environments."""
|
|
|
|
def test_get_schema_with_envs(self):
|
|
plugin = BackupPlugin()
|
|
mock_cm = MagicMock()
|
|
mock_cm.get_environments.return_value = [_make_env("e1", "Dev"), _make_env("e2", "Prod")]
|
|
|
|
with patch('src.plugins.backup.get_config_manager', return_value=mock_cm):
|
|
schema = plugin.get_schema()
|
|
|
|
assert schema["type"] == "object"
|
|
assert schema["properties"]["env"]["enum"] == ["Dev", "Prod"]
|
|
assert "env" in schema["required"]
|
|
|
|
def test_get_schema_no_envs(self):
|
|
"""Fallback to empty list when no environments configured."""
|
|
plugin = BackupPlugin()
|
|
mock_cm = MagicMock()
|
|
mock_cm.get_environments.return_value = []
|
|
|
|
with patch('src.plugins.backup.get_config_manager', return_value=mock_cm):
|
|
schema = plugin.get_schema()
|
|
|
|
assert schema["properties"]["env"]["enum"] == []
|
|
|
|
|
|
class TestBackupPluginExecute:
|
|
"""Verify BackupPlugin.execute with various scenarios."""
|
|
|
|
# ── Missing env → KeyError ──
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_missing_env(self):
|
|
"""Missing env param raises KeyError."""
|
|
plugin = BackupPlugin()
|
|
mock_cm = MagicMock()
|
|
mock_cm.has_environments.return_value = True
|
|
mock_cm.get_environments.return_value = []
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.settings.storage.root_path = "/tmp"
|
|
mock_cm.get_config.return_value = mock_cfg
|
|
with patch('src.plugins.backup.get_config_manager', return_value=mock_cm), \
|
|
pytest.raises(KeyError, match="env"):
|
|
await plugin.execute({})
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_missing_env_with_ctx(self):
|
|
"""Missing env param raises KeyError even with context."""
|
|
plugin = BackupPlugin()
|
|
mock_cm = MagicMock()
|
|
mock_cm.has_environments.return_value = True
|
|
mock_cm.get_environments.return_value = []
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.settings.storage.root_path = "/tmp"
|
|
mock_cm.get_config.return_value = mock_cfg
|
|
ctx = _make_task_context()
|
|
with patch('src.plugins.backup.get_config_manager', return_value=mock_cm), \
|
|
pytest.raises(KeyError, match="env"):
|
|
await plugin.execute({"other": "value"}, context=ctx)
|
|
|
|
# ── No dashboards ──
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_no_dashboards(self):
|
|
"""Zero dashboards returns NO_DASHBOARDS status."""
|
|
plugin = BackupPlugin()
|
|
env = _make_env("env-1", "Source")
|
|
mock_cm = MagicMock()
|
|
mock_cm.has_environments.return_value = True
|
|
mock_cm.get_environments.return_value = [env]
|
|
mock_cm.get_environment.return_value = env
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.settings.storage.root_path = "/tmp"
|
|
mock_cm.get_config.return_value = mock_cfg
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.get_dashboards = AsyncMock(return_value=(0, []))
|
|
|
|
with patch('src.plugins.backup.get_config_manager', return_value=mock_cm), \
|
|
patch('src.plugins.backup.SupersetClient', return_value=mock_client):
|
|
result = await plugin.execute({"env": "Source"})
|
|
|
|
assert result["status"] == "NO_DASHBOARDS"
|
|
assert result["total_dashboards"] == 0
|
|
assert result["backed_up_dashboards"] == 0
|
|
assert result["failed_dashboards"] == 0
|
|
|
|
# ── Partial success ──
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_partial_failure(self):
|
|
"""One dashboard fails, one succeeds → PARTIAL_SUCCESS."""
|
|
plugin = BackupPlugin()
|
|
env = _make_env("env-1", "Source")
|
|
mock_cm = MagicMock()
|
|
mock_cm.has_environments.return_value = True
|
|
mock_cm.get_environments.return_value = [env]
|
|
mock_cm.get_environment.return_value = env
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.settings.storage.root_path = "/tmp"
|
|
mock_cm.get_config.return_value = mock_cfg
|
|
|
|
dashboards = [
|
|
_make_dashboard(1, "Good Dash"),
|
|
_make_dashboard(2, "Bad Dash"),
|
|
]
|
|
mock_client = MagicMock()
|
|
mock_client.get_dashboards = AsyncMock(return_value=(2, dashboards))
|
|
mock_client.export_dashboard = AsyncMock(side_effect=[
|
|
(b"zip_content", "good.zip"),
|
|
RequestException("Export failed"),
|
|
])
|
|
|
|
ctx = _make_task_context()
|
|
ctx.logger.progress = MagicMock()
|
|
|
|
with patch('src.plugins.backup.get_config_manager', return_value=mock_cm), \
|
|
patch('src.plugins.backup.SupersetClient', return_value=mock_client), \
|
|
patch('src.plugins.backup.run_blocking') as mock_run:
|
|
mock_run.return_value = None
|
|
|
|
result = await plugin.execute({"env": "Source"}, context=ctx)
|
|
|
|
assert result["status"] == "PARTIAL_SUCCESS"
|
|
assert result["total_dashboards"] == 2
|
|
assert result["backed_up_dashboards"] == 1
|
|
assert result["failed_dashboards"] == 1
|
|
assert result["dashboards"][0]["title"] == "Good Dash"
|
|
assert result["failures"][0]["title"] == "Bad Dash"
|
|
|
|
# ── Full success with environment_id resolution ──
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_with_environment_id(self):
|
|
"""environment_id param resolves to env name."""
|
|
plugin = BackupPlugin()
|
|
env = _make_env("env-abc", "ResolvedEnv")
|
|
mock_cm = MagicMock()
|
|
mock_cm.has_environments.return_value = True
|
|
mock_cm.get_environments.return_value = [env]
|
|
mock_cm.get_environment.return_value = env
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.settings.storage.root_path = "/tmp"
|
|
mock_cm.get_config.return_value = mock_cfg
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.get_dashboards = AsyncMock(return_value=(0, []))
|
|
|
|
with patch('src.plugins.backup.get_config_manager', return_value=mock_cm), \
|
|
patch('src.plugins.backup.SupersetClient', return_value=mock_client):
|
|
result = await plugin.execute({"environment_id": "env-abc"})
|
|
|
|
assert result["status"] == "NO_DASHBOARDS"
|
|
assert result["environment"] == "ResolvedEnv"
|
|
|
|
# ── Dashboard ID filter ──
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_dashboard_ids_filter(self):
|
|
"""Filtering by specific dashboard IDs."""
|
|
plugin = BackupPlugin()
|
|
env = _make_env("env-1", "Source")
|
|
mock_cm = MagicMock()
|
|
mock_cm.has_environments.return_value = True
|
|
mock_cm.get_environments.return_value = [env]
|
|
mock_cm.get_environment.return_value = env
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.settings.storage.root_path = "/tmp"
|
|
mock_cm.get_config.return_value = mock_cfg
|
|
|
|
all_dashboards = [
|
|
_make_dashboard(1, "Dash A"),
|
|
_make_dashboard(2, "Dash B"),
|
|
_make_dashboard(3, "Dash C"),
|
|
]
|
|
mock_client = MagicMock()
|
|
mock_client.get_dashboards = AsyncMock(return_value=(3, all_dashboards))
|
|
mock_client.export_dashboard = AsyncMock(return_value=(b"zip", "meta.zip"))
|
|
|
|
ctx = _make_task_context()
|
|
ctx.logger.progress = MagicMock()
|
|
|
|
with patch('src.plugins.backup.get_config_manager', return_value=mock_cm), \
|
|
patch('src.plugins.backup.SupersetClient', return_value=mock_client), \
|
|
patch('src.plugins.backup.run_blocking') as mock_run:
|
|
mock_run.return_value = None
|
|
|
|
result = await plugin.execute({"env": "Source", "dashboard_ids": ["1", "3"]}, context=ctx)
|
|
|
|
assert result["status"] == "SUCCESS"
|
|
assert result["total_dashboards"] == 2
|
|
assert result["dashboards"][0]["id"] == 1
|
|
assert result["dashboards"][1]["id"] == 3
|
|
|
|
# ── No env configured ──
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_no_environments_configured(self):
|
|
"""has_environments returns False → raise ValueError."""
|
|
plugin = BackupPlugin()
|
|
mock_cm = MagicMock()
|
|
mock_cm.has_environments.return_value = False
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.settings.storage.root_path = "/tmp"
|
|
mock_cm.get_config.return_value = mock_cfg
|
|
|
|
with patch('src.plugins.backup.get_config_manager', return_value=mock_cm), \
|
|
pytest.raises(ValueError, match="No Superset environments configured"):
|
|
await plugin.execute({"env": "Source"})
|
|
|
|
# ── Environment not found ──
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_environment_not_found(self):
|
|
"""get_environment returns None → raise ValueError."""
|
|
plugin = BackupPlugin()
|
|
mock_cm = MagicMock()
|
|
mock_cm.has_environments.return_value = True
|
|
mock_cm.get_environments.return_value = [_make_env("other", "Other")]
|
|
mock_cm.get_environment.return_value = None
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.settings.storage.root_path = "/tmp"
|
|
mock_cm.get_config.return_value = mock_cfg
|
|
|
|
with patch('src.plugins.backup.get_config_manager', return_value=mock_cm), \
|
|
pytest.raises(ValueError, match="not found"):
|
|
await plugin.execute({"env": "NonExistent"})
|
|
|
|
# ── Fatal error wraps OSError ──
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_oserror_raised(self):
|
|
"""OSError during execution propagates."""
|
|
plugin = BackupPlugin()
|
|
env = _make_env("env-1", "Source")
|
|
mock_cm = MagicMock()
|
|
mock_cm.has_environments.return_value = True
|
|
mock_cm.get_environments.return_value = [env]
|
|
mock_cm.get_environment.return_value = env
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.settings.storage.root_path = "/tmp"
|
|
mock_cm.get_config.return_value = mock_cfg
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.get_dashboards = AsyncMock(side_effect=OSError("Disk full"))
|
|
|
|
with patch('src.plugins.backup.get_config_manager', return_value=mock_cm), \
|
|
patch('src.plugins.backup.SupersetClient', return_value=mock_client), \
|
|
pytest.raises(OSError, match="Disk full"):
|
|
await plugin.execute({"env": "Source"})
|
|
|
|
# ── Missing dashboard_id in metadata ──
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_skip_dashboard_without_id(self):
|
|
"""Dashboard without id field is skipped."""
|
|
plugin = BackupPlugin()
|
|
env = _make_env("env-1", "Source")
|
|
mock_cm = MagicMock()
|
|
mock_cm.has_environments.return_value = True
|
|
mock_cm.get_environments.return_value = [env]
|
|
mock_cm.get_environment.return_value = env
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.settings.storage.root_path = "/tmp"
|
|
mock_cm.get_config.return_value = mock_cfg
|
|
|
|
dashboards = [
|
|
{"dashboard_title": "No ID Dash"}, # no 'id' key
|
|
_make_dashboard(2, "Valid Dash"),
|
|
]
|
|
mock_client = MagicMock()
|
|
mock_client.get_dashboards = AsyncMock(return_value=(2, dashboards))
|
|
mock_client.export_dashboard = AsyncMock(return_value=(b"zip", "meta.zip"))
|
|
|
|
ctx = _make_task_context()
|
|
ctx.logger.progress = MagicMock()
|
|
|
|
with patch('src.plugins.backup.get_config_manager', return_value=mock_cm), \
|
|
patch('src.plugins.backup.SupersetClient', return_value=mock_client), \
|
|
patch('src.plugins.backup.run_blocking') as mock_run:
|
|
mock_run.return_value = None
|
|
|
|
result = await plugin.execute({"env": "Source"}, context=ctx)
|
|
|
|
assert result["status"] == "SUCCESS"
|
|
assert result["total_dashboards"] == 2
|
|
assert result["backed_up_dashboards"] == 1
|
|
assert result["dashboards"][0]["title"] == "Valid Dash"
|
|
|
|
# ── Invalid dashboard_id string in filter ──
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_invalid_dashboard_id_string(self):
|
|
"""Non-numeric dashboard_ids raises ValueError."""
|
|
plugin = BackupPlugin()
|
|
env = _make_env("env-1", "Source")
|
|
mock_cm = MagicMock()
|
|
mock_cm.has_environments.return_value = True
|
|
mock_cm.get_environments.return_value = [env]
|
|
mock_cm.get_environment.return_value = env
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.settings.storage.root_path = "/tmp"
|
|
mock_cm.get_config.return_value = mock_cfg
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.get_dashboards = AsyncMock(return_value=(0, []))
|
|
|
|
with patch('src.plugins.backup.get_config_manager', return_value=mock_cm), \
|
|
patch('src.plugins.backup.SupersetClient', return_value=mock_client), \
|
|
pytest.raises(ValueError, match="Invalid dashboard_ids"):
|
|
await plugin.execute({"env": "Source", "dashboard_ids": ["abc", "123"]})
|
|
|
|
# ── Integrity metadata in result ──
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_with_integrity_metadata(self, tmp_path):
|
|
"""Dashboard entries include archive_sha256, content_hash, integrity_status, manifest_path
|
|
when ZIP files exist on disk after export."""
|
|
plugin = BackupPlugin()
|
|
env = _make_env("env-1", "Source")
|
|
mock_cm = MagicMock()
|
|
mock_cm.has_environments.return_value = True
|
|
mock_cm.get_environments.return_value = [env]
|
|
mock_cm.get_environment.return_value = env
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.settings.storage.root_path = str(tmp_path)
|
|
mock_cm.get_config.return_value = mock_cfg
|
|
|
|
dashboards = [_make_dashboard(1, "Integrity Dash")]
|
|
mock_client = MagicMock()
|
|
mock_client.get_dashboards = AsyncMock(return_value=(1, dashboards))
|
|
|
|
# Create a valid ZIP archive in memory
|
|
import io
|
|
import zipfile as zf_mod
|
|
zip_buffer = io.BytesIO()
|
|
with zf_mod.ZipFile(zip_buffer, "w", zf_mod.ZIP_DEFLATED) as zf:
|
|
zf.writestr("dashboard_metadata.yaml", "dashboard_name: Integrity Dash\n")
|
|
zf.writestr("charts/chart_1.yaml", "chart_name: Test Chart\n")
|
|
valid_zip_bytes = zip_buffer.getvalue()
|
|
|
|
mock_client.export_dashboard = AsyncMock(return_value=(valid_zip_bytes, "export.zip"))
|
|
|
|
ctx = _make_task_context()
|
|
ctx.logger.progress = MagicMock()
|
|
|
|
# We need to intercept run_blocking calls for file ops to make them real
|
|
# so that the ZIP file actually appears on disk.
|
|
# We'll save the ZIP via real fileio function, then mock the rest.
|
|
from src.core.utils.fileio import save_and_unpack_dashboard
|
|
|
|
actual_results = {}
|
|
|
|
async def _run_blocking_side_effect(kind='file', fn=None, **kwargs):
|
|
if fn is save_and_unpack_dashboard:
|
|
# Actually call the real function to create the ZIP on disk
|
|
real_result = fn(
|
|
zip_content=kwargs.get('zip_content', b''),
|
|
original_filename=kwargs.get('original_filename'),
|
|
output_dir=kwargs.get('output_dir'),
|
|
unpack=False,
|
|
)
|
|
actual_results['zip_path'] = real_result[0]
|
|
return real_result
|
|
if fn.__name__ == 'write_backup_manifest':
|
|
# Actually call the real function
|
|
real_result = fn(**kwargs)
|
|
actual_results['manifest_path'] = real_result
|
|
return real_result
|
|
return None
|
|
|
|
with patch('src.plugins.backup.get_config_manager', return_value=mock_cm), \
|
|
patch('src.plugins.backup.SupersetClient', return_value=mock_client), \
|
|
patch('src.plugins.backup.run_blocking', side_effect=_run_blocking_side_effect):
|
|
result = await plugin.execute({"env": "Source"}, context=ctx)
|
|
|
|
assert result["status"] == "SUCCESS"
|
|
assert result["total_dashboards"] == 1
|
|
assert result["backed_up_dashboards"] == 1
|
|
|
|
dash_entry = result["dashboards"][0]
|
|
assert dash_entry["title"] == "Integrity Dash"
|
|
assert dash_entry["archive_sha256"] is not None
|
|
assert isinstance(dash_entry["archive_sha256"], str)
|
|
assert len(dash_entry["archive_sha256"]) == 64
|
|
assert dash_entry["content_hash"] is not None
|
|
assert isinstance(dash_entry["content_hash"], str)
|
|
assert len(dash_entry["content_hash"]) == 64
|
|
assert dash_entry["integrity_status"] == "verified"
|
|
assert dash_entry["manifest_path"] is not None
|
|
|
|
# Verify the manifest file exists on disk
|
|
manifest_path = Path(dash_entry["manifest_path"])
|
|
assert manifest_path.exists()
|
|
import json
|
|
manifest = json.loads(manifest_path.read_bytes())
|
|
assert manifest["archive_sha256"] == dash_entry["archive_sha256"]
|
|
assert manifest["content_hash"] == dash_entry["content_hash"]
|
|
assert manifest["integrity_status"] == "verified"
|
|
assert manifest["dashboard_id"] == 1
|
|
# #endregion Test.BackupPlugin
|