337 lines
13 KiB
Python
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"
|