Files
ss-tools/backend/tests/plugins/test_backup_plugin.py

337 lines
13 KiB
Python

# #region Test.BackupPlugin [C:3] [TYPE Module] [SEMANTICS test, backup, plugin, coverage]
# @BRIEF Unit tests for BackupPlugin — properties, get_schema, execute, and edge cases.
# @RELATION BINDS_TO -> [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
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()
with 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()
ctx = _make_task_context()
with 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"