Files
ss-tools/backend/tests/services/maintenance/test_chart_manager.py

305 lines
14 KiB
Python

# #region Test.Maintenance.ChartManager [C:3] [TYPE Module] [SEMANTICS test, maintenance, chart, banner, edge]
# @BRIEF Tests for maintenance chart manager — ensure_banner_chart, process_dashboards, process_states.
# @RELATION BINDS_TO -> [MaintenanceChartManager]
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.models.maintenance import (
MaintenanceDashboardBanner,
MaintenanceDashboardBannerStatus,
MaintenanceDashboardState,
MaintenanceDashboardStateStatus,
MaintenanceEvent,
MaintenanceEventStatus,
MaintenanceSettings,
DashboardScope,
)
@pytest.fixture
def mock_db():
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = None
db.query.return_value.filter.return_value.count.return_value = 0
db.query.return_value.filter.return_value.all.return_value = []
return db
@pytest.fixture
def mock_superset():
client = AsyncMock()
client.get_chart.return_value = {"id": 123, "result": {"viz_type": "markdown"}}
client.create_markdown_chart.return_value = 456
client.update_dashboard_layout.return_value = {"result": "ok"}
client.update_banner_on_dashboard.return_value = {"result": "ok"}
client.remove_chart_from_layout.return_value = {"result": "ok"}
client.delete_chart.return_value = {"result": "ok"}
return client
class TestEnsureBannerChart:
@pytest.mark.asyncio
async def test_creates_new_banner(self, mock_db, mock_superset):
"""No existing banner → creates chart, updates layout, persists banner."""
from src.services.maintenance._chart_manager import ensure_banner_chart
result = await ensure_banner_chart(1, "env1", mock_superset, mock_db)
assert result.chart_id == 456
assert result.status == MaintenanceDashboardBannerStatus.ACTIVE
mock_superset.create_markdown_chart.assert_called_once_with(1, "*Maintenance banner placeholder*")
mock_superset.update_dashboard_layout.assert_called_once_with(1, 456)
mock_db.add.assert_called_once()
mock_db.flush.assert_called()
@pytest.mark.asyncio
async def test_reuses_existing_banner(self, mock_db, mock_superset):
"""Existing active banner → verifies chart in Superset and reuses."""
from src.services.maintenance._chart_manager import ensure_banner_chart
existing = MaintenanceDashboardBanner(
id="b1", dashboard_id=1, environment_id="env1",
chart_id=123, status=MaintenanceDashboardBannerStatus.ACTIVE, banner_text=""
)
mock_db.query.return_value.filter.return_value.filter.return_value.filter.return_value.first.return_value = existing
mock_superset.get_chart.return_value = {"id": 123}
result = await ensure_banner_chart(1, "env1", mock_superset, mock_db)
assert result is existing
mock_superset.get_chart.assert_called_once_with(123)
mock_superset.create_markdown_chart.assert_not_called()
@pytest.mark.asyncio
async def test_stale_banner_removed_and_recreated(self, mock_db, mock_superset):
"""Existing but stale banner → marked REMOVED, new one created."""
from src.services.maintenance._chart_manager import ensure_banner_chart
existing = MaintenanceDashboardBanner(
id="b1", dashboard_id=1, environment_id="env1",
chart_id=123, status=MaintenanceDashboardBannerStatus.ACTIVE, banner_text=""
)
mock_db.query.return_value.filter.return_value.filter.return_value.filter.return_value.first.return_value = existing
mock_superset.get_chart.side_effect = Exception("Chart not found")
result = await ensure_banner_chart(1, "env1", mock_superset, mock_db)
assert existing.status == MaintenanceDashboardBannerStatus.REMOVED
assert result.chart_id == 456 # new chart
mock_superset.create_markdown_chart.assert_called_once()
@pytest.mark.asyncio
async def test_create_chart_failure(self, mock_db, mock_superset):
"""Failure to create chart → exception propagates."""
from src.services.maintenance._chart_manager import ensure_banner_chart
mock_superset.create_markdown_chart.side_effect = Exception("API error")
with pytest.raises(Exception, match="API error"):
await ensure_banner_chart(1, "env1", mock_superset, mock_db)
@pytest.mark.asyncio
async def test_layout_update_failure_cleans_up_chart(self, mock_db, mock_superset):
"""Failure to update layout → deletes chart and raises."""
from src.services.maintenance._chart_manager import ensure_banner_chart
mock_superset.update_dashboard_layout.side_effect = Exception("Layout error")
with pytest.raises(Exception, match="Layout error"):
await ensure_banner_chart(1, "env1", mock_superset, mock_db)
mock_superset.delete_chart.assert_called_once_with(456)
@pytest.mark.asyncio
async def test_layout_update_failure_cleanup_ignores_delete_error(self, mock_db, mock_superset):
"""Delete chart failure during cleanup does not mask original error."""
from src.services.maintenance._chart_manager import ensure_banner_chart
mock_superset.update_dashboard_layout.side_effect = Exception("Layout error")
mock_superset.delete_chart.side_effect = Exception("Delete also failed")
with pytest.raises(Exception, match="Layout error"):
await ensure_banner_chart(1, "env1", mock_superset, mock_db)
class TestProcessDashboardsForStart:
@pytest.mark.asyncio
async def test_successful_processing(self, mock_db, mock_superset):
"""All dashboards processed successfully."""
from src.services.maintenance._chart_manager import _process_dashboards_for_start
mock_superset.get_dashboards.return_value = (1, [{"id": 1, "dashboard_title": "Test"}])
successful, failed, banners = await _process_dashboards_for_start(
[1], "evt1", "env1", "Template: {message}", "UTC", mock_superset, mock_db
)
assert len(successful) == 1
assert len(failed) == 0
assert len(banners) == 1
@pytest.mark.asyncio
async def test_banner_update_failure_handled(self, mock_db, mock_superset):
"""Failed banner update → records as APPLY_FAILED."""
from src.services.maintenance._chart_manager import _process_dashboards_for_start
mock_superset.update_banner_on_dashboard.side_effect = Exception("Update failed")
mock_superset.get_dashboards.return_value = (1, [{"id": 1, "dashboard_title": "Test"}])
successful, failed, banners = await _process_dashboards_for_start(
[1], "evt1", "env1", "Template", "UTC", mock_superset, mock_db
)
assert len(successful) == 0
assert len(failed) == 1
assert len(banners) == 1
@pytest.mark.asyncio
async def test_top_level_exception_handling(self, mock_db, mock_superset):
"""Exception outside banner creation → recorded as failed."""
from src.services.maintenance._chart_manager import _process_dashboards_for_start
mock_superset.create_markdown_chart.side_effect = Exception("Chart creation failed")
successful, failed, banners = await _process_dashboards_for_start(
[1], "evt1", "env1", "Template", "UTC", mock_superset, mock_db
)
assert len(successful) == 0
assert len(failed) == 1
class TestProcessStatesForEnd:
@pytest.mark.asyncio
async def test_no_banner_id(self, mock_db, mock_superset):
"""State with no banner_id → marked REMOVED."""
from src.services.maintenance._chart_manager import _process_states_for_end
state = MaintenanceDashboardState(
event_id="evt1", dashboard_id=1, status=MaintenanceDashboardStateStatus.ACTIVE
)
state.banner_id = None
removed, failed = await _process_states_for_end([state], "evt1", mock_superset, mock_db)
assert removed == 1
assert state.status == MaintenanceDashboardStateStatus.REMOVED
@pytest.mark.asyncio
async def test_other_events_still_active_rebuilds(self, mock_db, mock_superset):
"""Other active events exist → rebuild banner."""
from src.services.maintenance._chart_manager import _process_states_for_end
banner = MaintenanceDashboardBanner(
id="b1", dashboard_id=1, chart_id=123,
status=MaintenanceDashboardBannerStatus.ACTIVE, banner_text="old"
)
state = MaintenanceDashboardState(
event_id="evt1", dashboard_id=1, banner_id="b1",
status=MaintenanceDashboardStateStatus.ACTIVE
)
# Mock query: other active events exist
mock_db.query.return_value.filter.return_value.filter.return_value.filter.return_value.count.return_value = 1
mock_db.query.return_value.filter.return_value.first.return_value = banner
removed, failed = await _process_states_for_end([state], "evt1", mock_superset, mock_db)
assert removed == 1
assert state.status == MaintenanceDashboardStateStatus.REMOVED
@pytest.mark.asyncio
async def test_no_other_events_removes_banner(self, mock_db, mock_superset):
"""No other active events → remove banner fully."""
from src.services.maintenance._chart_manager import _process_states_for_end
banner = MaintenanceDashboardBanner(
id="b1", dashboard_id=1, chart_id=123,
status=MaintenanceDashboardBannerStatus.ACTIVE, banner_text="old"
)
state = MaintenanceDashboardState(
event_id="evt1", dashboard_id=1, banner_id="b1",
status=MaintenanceDashboardStateStatus.ACTIVE
)
mock_db.query.return_value.filter.return_value.filter.return_value.filter.return_value.count.return_value = 0
mock_db.query.return_value.filter.return_value.first.return_value = banner
removed, failed = await _process_states_for_end([state], "evt1", mock_superset, mock_db)
assert removed == 1
assert banner.status == MaintenanceDashboardBannerStatus.REMOVED
assert state.status == MaintenanceDashboardStateStatus.REMOVED
mock_superset.remove_chart_from_layout.assert_called_once_with(1, 123)
mock_superset.delete_chart.assert_called_once_with(123)
@pytest.mark.asyncio
async def test_remove_chart_failure(self, mock_db, mock_superset):
"""Failure to remove chart from layout → REMOVAL_FAILED."""
from src.services.maintenance._chart_manager import _process_states_for_end
banner = MaintenanceDashboardBanner(
id="b1", dashboard_id=1, chart_id=123,
status=MaintenanceDashboardBannerStatus.ACTIVE, banner_text="old"
)
state = MaintenanceDashboardState(
event_id="evt1", dashboard_id=1, banner_id="b1",
status=MaintenanceDashboardStateStatus.ACTIVE
)
mock_db.query.return_value.filter.return_value.filter.return_value.filter.return_value.count.return_value = 0
mock_db.query.return_value.filter.return_value.first.return_value = banner
mock_superset.remove_chart_from_layout.side_effect = Exception("Remove failed")
removed, failed = await _process_states_for_end([state], "evt1", mock_superset, mock_db)
assert removed == 0
assert len(failed) == 1
assert state.status == MaintenanceDashboardStateStatus.REMOVAL_FAILED
@pytest.mark.asyncio
async def test_delete_chart_failure(self, mock_db, mock_superset):
"""Failure to delete chart → REMOVAL_FAILED."""
from src.services.maintenance._chart_manager import _process_states_for_end
banner = MaintenanceDashboardBanner(
id="b1", dashboard_id=1, chart_id=123,
status=MaintenanceDashboardBannerStatus.ACTIVE, banner_text="old"
)
state = MaintenanceDashboardState(
event_id="evt1", dashboard_id=1, banner_id="b1",
status=MaintenanceDashboardStateStatus.ACTIVE
)
mock_db.query.return_value.filter.return_value.filter.return_value.filter.return_value.count.return_value = 0
mock_db.query.return_value.filter.return_value.first.return_value = banner
mock_superset.delete_chart.side_effect = Exception("Delete failed")
removed, failed = await _process_states_for_end([state], "evt1", mock_superset, mock_db)
assert removed == 0
assert len(failed) == 1
assert state.status == MaintenanceDashboardStateStatus.REMOVAL_FAILED
@pytest.mark.asyncio
async def test_no_banner_record(self, mock_db, mock_superset):
"""State with banner_id but no banner record → REMOVED."""
from src.services.maintenance._chart_manager import _process_states_for_end
state = MaintenanceDashboardState(
event_id="evt1", dashboard_id=1, banner_id="nonexistent",
status=MaintenanceDashboardStateStatus.ACTIVE
)
mock_db.query.return_value.filter.return_value.filter.return_value.filter.return_value.count.return_value = 0
mock_db.query.return_value.filter.return_value.first.return_value = None
removed, failed = await _process_states_for_end([state], "evt1", mock_superset, mock_db)
assert removed == 1
assert state.status == MaintenanceDashboardStateStatus.REMOVED
@pytest.mark.asyncio
async def test_top_level_exception(self, mock_db, mock_superset):
"""Exception at top level → REMOVAL_FAILED."""
from src.services.maintenance._chart_manager import _process_states_for_end
state = MaintenanceDashboardState(
event_id="evt1", dashboard_id=1, banner_id="b1",
status=MaintenanceDashboardStateStatus.ACTIVE
)
mock_db.query.side_effect = Exception("DB error")
removed, failed = await _process_states_for_end([state], "evt1", mock_superset, mock_db)
assert removed == 0
assert len(failed) == 1
# #endregion Test.Maintenance.ChartManager