Settings: - display_timezone defaults to Europe/Moscow everywhere (model, service fallbacks, settings form); migration flips untouched 'UTC' default row. - New banner_height setting (1-200 grid units, NULL = auto): threaded from settings through start/rebuild flows into MARKDOWN insert/content updates; settings panel gains Auto/Manual radio + number input. Banner removal: - update_dashboard_layout returns the pre-mutation position_json; stored on maintenance_dashboard_banners.original_position_json at creation. - Removal restores the snapshot verbatim when the current layout matches the deterministic replay of the insert (normalize + y-shift + banner keys); diverged layouts (user edits during maintenance) and legacy banners fall back to surgical removal. Fixes layout drift from y-shift and unreverted ROOT->TABS -> ROOT->GRID normalization. UX: - StartMaintenanceForm: recent-tables chips from event history, Enter-to- submit, schema.table format hint, inline task progress panel (status + progress bar + task-log link), templates section removed, Button atoms. - Maintenance page: environment selector + env context init. - Events table: Active/Completed tabs with count badges. - Backend: plugins/services report monotonic progress via context.logger.progress (per-dashboard) for start/end/end-all. Tests: 203 backend (matcher, restore/fallback, height, progress) + 3645 frontend pass; migration E2E-verified (upgrade/downgrade).
381 lines
15 KiB
Python
381 lines
15 KiB
Python
# #region Test.MaintenanceBannerPlugin [C:3] [TYPE Module] [SEMANTICS test, maintenance, banner, plugin, coverage]
|
|
# @BRIEF Unit tests for MaintenanceBannerPlugin — properties, get_schema, execute operations, and edge cases.
|
|
# @RELATION BINDS_TO -> [Plugin.MaintenanceBanner.MaintenanceBannerPlugin]
|
|
# @TEST_EDGE: unknown_operation -> Returns failed status
|
|
# @TEST_EDGE: start_without_event_id -> Returns failed status
|
|
# @TEST_EDGE: end_without_event_id -> Returns failed status
|
|
# @TEST_EDGE: environment_not_found -> Returns failed status
|
|
# @TEST_EDGE: db_rollback_on_exception -> Rollback called
|
|
|
|
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from src.plugins.maintenance_banner import MaintenanceBannerPlugin
|
|
|
|
# ── 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()
|
|
return ctx
|
|
|
|
|
|
class TestMaintenanceBannerPluginProperties:
|
|
"""Verify static property values."""
|
|
|
|
def test_id(self):
|
|
plugin = MaintenanceBannerPlugin()
|
|
assert plugin.id == "maintenance_banner_apply"
|
|
|
|
def test_name(self):
|
|
plugin = MaintenanceBannerPlugin()
|
|
assert plugin.name == "Maintenance Banner"
|
|
|
|
def test_description(self):
|
|
plugin = MaintenanceBannerPlugin()
|
|
assert plugin.description == "Applies or removes maintenance banners on Superset dashboards."
|
|
|
|
def test_version(self):
|
|
plugin = MaintenanceBannerPlugin()
|
|
assert plugin.version == "1.0.0"
|
|
|
|
def test_ui_route(self):
|
|
plugin = MaintenanceBannerPlugin()
|
|
assert plugin.ui_route == ""
|
|
|
|
|
|
class TestMaintenanceBannerPluginGetSchema:
|
|
"""Verify get_schema returns correct structure."""
|
|
|
|
def test_get_schema(self):
|
|
plugin = MaintenanceBannerPlugin()
|
|
schema = plugin.get_schema()
|
|
|
|
assert schema["type"] == "object"
|
|
assert "operation" in schema["properties"]
|
|
assert schema["properties"]["operation"]["enum"] == ["start", "end", "end_all"]
|
|
assert "operation" in schema["required"]
|
|
assert "event_id" in schema["properties"]
|
|
assert "environment_id" in schema["properties"]
|
|
|
|
|
|
class TestMaintenanceBannerPluginExecute:
|
|
"""Verify MaintenanceBannerPlugin.execute with different operations."""
|
|
|
|
# ── Unknown operation ──
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_unknown_operation(self):
|
|
"""Unknown operation returns failed status."""
|
|
plugin = MaintenanceBannerPlugin()
|
|
env = _make_env("env-1", "Test")
|
|
mock_db = MagicMock()
|
|
mock_query = MagicMock()
|
|
mock_query.filter.return_value.first.return_value = None # no MaintenanceSettings
|
|
mock_db.query.return_value = mock_query
|
|
|
|
mock_cm = MagicMock()
|
|
mock_cm.get_environments.return_value = [env]
|
|
|
|
with patch('src.plugins.maintenance_banner.SessionLocal', return_value=mock_db), \
|
|
patch('src.plugins.maintenance_banner.get_config_manager', return_value=mock_cm):
|
|
result = await plugin.execute({"operation": "bogus", "environment_id": "env-1"})
|
|
|
|
assert result["status"] == "failed"
|
|
assert "Unknown operation" in result["error"]
|
|
|
|
# ── start without event_id ──
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_start_without_event_id(self):
|
|
"""start operation without event_id returns failed."""
|
|
plugin = MaintenanceBannerPlugin()
|
|
env = _make_env("env-1", "Test")
|
|
mock_db = MagicMock()
|
|
|
|
mock_cm = MagicMock()
|
|
mock_cm.get_environments.return_value = [env]
|
|
|
|
with patch('src.plugins.maintenance_banner.SessionLocal', return_value=mock_db), \
|
|
patch('src.plugins.maintenance_banner.get_config_manager', return_value=mock_cm):
|
|
result = await plugin.execute({"operation": "start", "environment_id": "env-1"})
|
|
|
|
assert result["status"] == "failed"
|
|
assert "event_id required" in result["error"]
|
|
|
|
# ── end without event_id ──
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_end_without_event_id(self):
|
|
"""end operation without event_id returns failed."""
|
|
plugin = MaintenanceBannerPlugin()
|
|
env = _make_env("env-1", "Test")
|
|
mock_db = MagicMock()
|
|
|
|
mock_cm = MagicMock()
|
|
mock_cm.get_environments.return_value = [env]
|
|
|
|
with patch('src.plugins.maintenance_banner.SessionLocal', return_value=mock_db), \
|
|
patch('src.plugins.maintenance_banner.get_config_manager', return_value=mock_cm):
|
|
result = await plugin.execute({"operation": "end", "environment_id": "env-1"})
|
|
|
|
assert result["status"] == "failed"
|
|
assert "event_id required" in result["error"]
|
|
|
|
# ── Environment not found ──
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_environment_not_found(self):
|
|
"""Non-matching environment_id returns failed."""
|
|
plugin = MaintenanceBannerPlugin()
|
|
env = _make_env("env-1", "Test")
|
|
mock_db = MagicMock()
|
|
|
|
mock_cm = MagicMock()
|
|
mock_cm.get_environments.return_value = [env]
|
|
|
|
with patch('src.plugins.maintenance_banner.SessionLocal', return_value=mock_db), \
|
|
patch('src.plugins.maintenance_banner.get_config_manager', return_value=mock_cm):
|
|
result = await plugin.execute({
|
|
"operation": "start",
|
|
"event_id": "evt-1",
|
|
"environment_id": "nonexistent",
|
|
})
|
|
|
|
assert result["status"] == "failed"
|
|
assert "not found" in result["error"]
|
|
|
|
# ── environment_id resolved from MaintenanceSettings ──
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_resolve_env_from_settings(self):
|
|
"""environment_id resolved from MaintenanceSettings when not in params."""
|
|
plugin = MaintenanceBannerPlugin()
|
|
env = _make_env("env-default", "DefaultEnv")
|
|
mock_db = MagicMock()
|
|
mock_query = MagicMock()
|
|
mock_settings = MagicMock()
|
|
mock_settings.target_environment_id = "env-default"
|
|
mock_query.filter.return_value.first.return_value = mock_settings
|
|
mock_db.query.return_value = mock_query
|
|
|
|
mock_cm = MagicMock()
|
|
mock_cm.get_environments.return_value = [env]
|
|
|
|
with patch('src.plugins.maintenance_banner.SessionLocal', return_value=mock_db), \
|
|
patch('src.plugins.maintenance_banner.get_config_manager', return_value=mock_cm), \
|
|
patch('src.plugins.maintenance_banner.start_maintenance', AsyncMock(return_value={"status": "ok"})):
|
|
result = await plugin.execute({
|
|
"operation": "start",
|
|
"event_id": "evt-1",
|
|
})
|
|
|
|
assert result["status"] == "ok"
|
|
|
|
# ── environment_id resolved from first env when no settings ──
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_resolve_env_from_first_env(self):
|
|
"""environment_id falls back to first env when no settings match."""
|
|
plugin = MaintenanceBannerPlugin()
|
|
env = _make_env("env-first", "FirstEnv")
|
|
mock_db = MagicMock()
|
|
mock_query = MagicMock()
|
|
mock_query.filter.return_value.first.return_value = None # no MaintenanceSettings
|
|
mock_db.query.return_value = mock_query
|
|
|
|
mock_cm = MagicMock()
|
|
mock_cm.get_environments.return_value = [env]
|
|
|
|
with patch('src.plugins.maintenance_banner.SessionLocal', return_value=mock_db), \
|
|
patch('src.plugins.maintenance_banner.get_config_manager', return_value=mock_cm), \
|
|
patch('src.plugins.maintenance_banner.start_maintenance', AsyncMock(return_value={"status": "ok"})):
|
|
result = await plugin.execute({
|
|
"operation": "start",
|
|
"event_id": "evt-1",
|
|
})
|
|
|
|
assert result["status"] == "ok"
|
|
|
|
# ── start operation calls start_maintenance ──
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_start_success(self):
|
|
"""start operation delegates to start_maintenance."""
|
|
plugin = MaintenanceBannerPlugin()
|
|
env = _make_env("env-1", "Test")
|
|
mock_db = MagicMock()
|
|
|
|
mock_cm = MagicMock()
|
|
mock_cm.get_environments.return_value = [env]
|
|
|
|
mock_start = AsyncMock(return_value={"status": "ok", "message": "maintenance started"})
|
|
|
|
with patch('src.plugins.maintenance_banner.SessionLocal', return_value=mock_db), \
|
|
patch('src.plugins.maintenance_banner.get_config_manager', return_value=mock_cm), \
|
|
patch('src.plugins.maintenance_banner.start_maintenance', mock_start):
|
|
result = await plugin.execute({
|
|
"operation": "start",
|
|
"event_id": "evt-1",
|
|
"environment_id": "env-1",
|
|
})
|
|
|
|
assert result["status"] == "ok"
|
|
mock_start.assert_awaited_once()
|
|
mock_db.commit.assert_called_once()
|
|
|
|
# ── end operation calls end_maintenance ──
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_end_success(self):
|
|
"""end operation delegates to end_maintenance."""
|
|
plugin = MaintenanceBannerPlugin()
|
|
env = _make_env("env-1", "Test")
|
|
mock_db = MagicMock()
|
|
|
|
mock_cm = MagicMock()
|
|
mock_cm.get_environments.return_value = [env]
|
|
|
|
mock_end = AsyncMock(return_value={"status": "ok", "message": "maintenance ended"})
|
|
|
|
with patch('src.plugins.maintenance_banner.SessionLocal', return_value=mock_db), \
|
|
patch('src.plugins.maintenance_banner.get_config_manager', return_value=mock_cm), \
|
|
patch('src.plugins.maintenance_banner.end_maintenance', mock_end):
|
|
result = await plugin.execute({
|
|
"operation": "end",
|
|
"event_id": "evt-1",
|
|
"environment_id": "env-1",
|
|
})
|
|
|
|
assert result["status"] == "ok"
|
|
mock_end.assert_awaited_once()
|
|
|
|
# ── end_all operation calls end_all_maintenance ──
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_end_all_success(self):
|
|
"""end_all operation delegates to end_all_maintenance."""
|
|
plugin = MaintenanceBannerPlugin()
|
|
env = _make_env("env-1", "Test")
|
|
mock_db = MagicMock()
|
|
|
|
mock_cm = MagicMock()
|
|
mock_cm.get_environments.return_value = [env]
|
|
|
|
mock_end_all = AsyncMock(return_value={"status": "ok", "message": "all maintenance ended"})
|
|
|
|
with patch('src.plugins.maintenance_banner.SessionLocal', return_value=mock_db), \
|
|
patch('src.plugins.maintenance_banner.get_config_manager', return_value=mock_cm), \
|
|
patch('src.plugins.maintenance_banner.end_all_maintenance', mock_end_all):
|
|
result = await plugin.execute({
|
|
"operation": "end_all",
|
|
"environment_id": "env-1",
|
|
})
|
|
|
|
assert result["status"] == "ok"
|
|
mock_end_all.assert_awaited_once()
|
|
mock_db.commit.assert_called_once()
|
|
|
|
# ── progress is reported via context.logger.progress ──
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_start_reports_progress(self):
|
|
"""start operation reports monotonic progress via context.logger.progress."""
|
|
plugin = MaintenanceBannerPlugin()
|
|
env = _make_env("env-1", "Test")
|
|
mock_db = MagicMock()
|
|
|
|
mock_cm = MagicMock()
|
|
mock_cm.get_environments.return_value = [env]
|
|
|
|
async def fake_start(_event_id, _db, _client, on_progress=None):
|
|
if on_progress:
|
|
on_progress(0.1, "first dashboard")
|
|
on_progress(0.6, "second dashboard")
|
|
return {"status": "ok", "affected_dashboards": 2}
|
|
|
|
mock_context = MagicMock()
|
|
|
|
with patch('src.plugins.maintenance_banner.SessionLocal', return_value=mock_db), \
|
|
patch('src.plugins.maintenance_banner.get_config_manager', return_value=mock_cm), \
|
|
patch('src.plugins.maintenance_banner.start_maintenance', side_effect=fake_start):
|
|
result = await plugin.execute({
|
|
"operation": "start",
|
|
"event_id": "evt-1",
|
|
"environment_id": "env-1",
|
|
}, context=mock_context)
|
|
|
|
assert result["status"] == "ok"
|
|
percents = [
|
|
call.kwargs.get("percent")
|
|
for call in mock_context.logger.progress.call_args_list
|
|
]
|
|
# 0.05 discovery + 0.1/0.6 from service callback + terminal 1.0
|
|
assert 5 in percents
|
|
assert 10 in percents
|
|
assert 60 in percents
|
|
assert 100 in percents
|
|
assert percents == sorted(percents), "progress must be monotonic"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_without_context_does_not_crash(self):
|
|
"""No TaskContext → progress reporting is a no-op."""
|
|
plugin = MaintenanceBannerPlugin()
|
|
env = _make_env("env-1", "Test")
|
|
mock_db = MagicMock()
|
|
|
|
mock_cm = MagicMock()
|
|
mock_cm.get_environments.return_value = [env]
|
|
|
|
async def fake_start(_event_id, _db, _client, on_progress=None):
|
|
if on_progress:
|
|
on_progress(0.5, "mid")
|
|
return {"status": "ok", "affected_dashboards": 1}
|
|
|
|
with patch('src.plugins.maintenance_banner.SessionLocal', return_value=mock_db), \
|
|
patch('src.plugins.maintenance_banner.get_config_manager', return_value=mock_cm), \
|
|
patch('src.plugins.maintenance_banner.start_maintenance', side_effect=fake_start):
|
|
result = await plugin.execute({
|
|
"operation": "start",
|
|
"event_id": "evt-1",
|
|
"environment_id": "env-1",
|
|
})
|
|
|
|
assert result["status"] == "ok"
|
|
|
|
# ── Exception causes rollback ──
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_exception_causes_rollback(self):
|
|
"""Exception during operation triggers rollback and returns failed."""
|
|
plugin = MaintenanceBannerPlugin()
|
|
env = _make_env("env-1", "Test")
|
|
mock_db = MagicMock()
|
|
|
|
mock_cm = MagicMock()
|
|
mock_cm.get_environments.return_value = [env]
|
|
|
|
with patch('src.plugins.maintenance_banner.SessionLocal', return_value=mock_db), \
|
|
patch('src.plugins.maintenance_banner.get_config_manager', return_value=mock_cm), \
|
|
patch('src.plugins.maintenance_banner.start_maintenance',
|
|
AsyncMock(side_effect=RuntimeError("DB error"))):
|
|
result = await plugin.execute({
|
|
"operation": "start",
|
|
"event_id": "evt-1",
|
|
"environment_id": "env-1",
|
|
})
|
|
|
|
assert result["status"] == "failed"
|
|
assert "DB error" in result["error"]
|
|
mock_db.rollback.assert_called_once()
|
|
mock_db.close.assert_called_once()
|
|
# #endregion Test.MaintenanceBannerPlugin
|