# #region Test.MaintenanceService.TestMaintenanceService [C:3] [TYPE TestModule] [SEMANTICS test, maintenance, service, pytest, integration] # @BRIEF Integration tests for MaintenanceService — T018, T026, T026a. # Tests the C3/C4 orchestrators: start_maintenance, end_maintenance, end_all_maintenance, # and helpers: find_affected_dashboards, ensure_banner_chart, build_banner_text, rebuild_banner. # @LAYER Test # @RELATION BINDS_TO -> [EXT:frontend:MaintenanceServiceModule] # @TEST_CONTRACT: start_maintenance(str, Session, SupersetClient) -> dict # @TEST_CONTRACT: end_maintenance(str, Session, SupersetClient) -> dict # @TEST_CONTRACT: end_all_maintenance(Session, SupersetClient) -> dict # @TEST_EDGE: event_not_found -> returns failed status with error message # @TEST_EDGE: no_matching_dashboards -> returns "no_match" status # @TEST_EDGE: superset_api_failure -> returns partial/failed with error details from datetime import UTC, datetime import pytest from unittest.mock import AsyncMock, MagicMock from src.models.maintenance import ( DashboardScope, MaintenanceDashboardBanner, MaintenanceDashboardBannerStatus, MaintenanceDashboardState, MaintenanceDashboardStateStatus, MaintenanceEvent, MaintenanceEventStatus, MaintenanceSettings, ) from src.services.maintenance import ( build_banner_text, build_idempotency_key, end_all_maintenance, end_maintenance, ensure_banner_chart, find_affected_dashboards, rebuild_banner, start_maintenance, ) # ── Fixtures ────────────────────────────────────────────────── @pytest.fixture def mock_superset(): """Create a mock SupersetClient for testing.""" client = AsyncMock() # Default: no datasets client.get_datasets.return_value = (0, []) client.get_dashboards.return_value = (0, []) client.get_dataset_detail.return_value = {"linked_dashboards": []} client.get_dashboard_layout.return_value = { "ROOT_ID": {"children": ["GRID_ID"]}, "GRID_ID": {"children": ["ROW-banner-999"]}, "ROW-banner-999": {"children": ["MARKDOWN-banner-999"]}, "MARKDOWN-banner-999": {}, } client.update_banner_on_dashboard.return_value = {"result": "ok"} client.update_dashboard_layout.return_value = {"result": "ok"} client.remove_chart_from_layout.return_value = {"result": "ok"} return client @pytest.fixture def db_session(): """Create an in-memory SQLite session with tables.""" from sqlalchemy import create_engine, event from sqlalchemy.orm import sessionmaker from src.models.mapping import Base engine = create_engine("sqlite:///:memory:", echo=False) event.listen(engine, "connect", lambda c, _: c.execute("PRAGMA foreign_keys=ON")) Base.metadata.create_all(engine) Session = sessionmaker(bind=engine) session = Session() # Create default settings settings = MaintenanceSettings( id="default", target_environment_id="test-env", display_timezone="UTC", banner_template="Maintenance: {message} ({start_time} - {end_time})", dashboard_scope=DashboardScope.PUBLISHED_ONLY, excluded_dashboard_ids=[], forced_dashboard_ids=[], ) session.add(settings) session.commit() return session @pytest.fixture def pending_event(db_session): """Create a PENDING maintenance event in the DB.""" event = MaintenanceEvent( tables=["raw.sales", "raw.inventory"], start_time=datetime.now(UTC), end_time=datetime.now(UTC), message="Test maintenance", status=MaintenanceEventStatus.PENDING, environment_id="test-env", ) db_session.add(event) db_session.commit() return event # ── T017: build_idempotency_key tests ───────────────────────── class TestBuildIdempotencyKey: """Tests for build_idempotency_key helper.""" # #region Test.MaintenanceService.TestBasicKey [C:2] [TYPE Function] # @BRIEF Basic idempotency key with tables and times. def test_basic_key(self): tables = ["raw.sales", "RAW.INVENTORY"] start = datetime(2026, 5, 21, 22, 0, 0, tzinfo=UTC) end = datetime(2026, 5, 22, 2, 0, 0, tzinfo=UTC) key = build_idempotency_key(tables, start, end) assert key[0] == frozenset({"raw.inventory", "raw.sales"}) assert key[1] == "2026-05-21T22:00:00+00:00" assert key[2] == "2026-05-22T02:00:00+00:00" # #endregion Test.MaintenanceService.TestBasicKey # #region Test.MaintenanceService.TestKeyWithoutEndTime [C:2] [TYPE Function] # @BRIEF Idempotency key with None end_time. def test_key_without_end_time(self): tables = ["raw.sales"] start = datetime(2026, 5, 21, 22, 0, 0, tzinfo=UTC) key = build_idempotency_key(tables, start, None) assert key[0] == frozenset({"raw.sales"}) assert key[1] == "2026-05-21T22:00:00+00:00" assert key[2] is None # #endregion Test.MaintenanceService.TestKeyWithoutEndTime # #region Test.MaintenanceService.TestKeyTableSorting [C:2] [TYPE Function] # @BRIEF Tables are sorted alphabetically in frozenset. def test_key_table_sorting(self): key1 = build_idempotency_key(["z.table", "a.table"], None, None) key2 = build_idempotency_key(["a.table", "z.table"], None, None) assert key1 == key2 # #endregion Test.MaintenanceService.TestKeyTableSorting # ── T019: find_affected_dashboards tests ────────────────────── class TestFindAffectedDashboards: """Tests for find_affected_dashboards.""" # #region Test.MaintenanceService.TestEmptyTables [C:2] [TYPE Function] # @BRIEF Empty tables list returns empty. @pytest.mark.asyncio async def test_empty_tables(self, mock_superset): result = await find_affected_dashboards([], mock_superset) assert result == [] # #endregion Test.MaintenanceService.TestEmptyTables # #region Test.MaintenanceService.TestTableBasedMatch [C:2] [TYPE Function] # @BRIEF Table-based dataset matching. @pytest.mark.asyncio async def test_table_based_match(self, mock_superset): mock_superset.get_datasets.return_value = (2, [ {"id": 1, "schema": "raw", "table_name": "sales", "sql": None}, {"id": 2, "schema": "raw", "table_name": "inventory", "sql": None}, ]) mock_superset.get_dataset_detail.side_effect = [ {"linked_dashboards": [{"id": 101}]}, {"linked_dashboards": [{"id": 102}]}, ] result = await find_affected_dashboards(["raw.sales"], mock_superset) assert 101 in result assert 102 not in result # #endregion Test.MaintenanceService.TestTableBasedMatch # #region Test.MaintenanceService.TestVirtualDatasetMatch [C:2] [TYPE Function] # @BRIEF Virtual SQL dataset matching via SqlTableExtractor. @pytest.mark.asyncio async def test_virtual_dataset_match(self, mock_superset): mock_superset.get_datasets.return_value = (1, [ { "id": 1, "schema": "raw", "table_name": "sales_view", "sql": "SELECT * FROM raw.sales", "is_sqllab_view": True, }, ]) mock_superset.get_dataset_detail.return_value = { "linked_dashboards": [{"id": 101}], } result = await find_affected_dashboards(["raw.sales"], mock_superset) assert 101 in result # #endregion Test.MaintenanceService.TestVirtualDatasetMatch # #region Test.MaintenanceService.TestCaseInsensitiveMatching [C:2] [TYPE Function] # @BRIEF Matching is case-insensitive. @pytest.mark.asyncio async def test_case_insensitive_matching(self, mock_superset): mock_superset.get_datasets.return_value = (1, [ {"id": 1, "schema": "RAW", "table_name": "SALES", "sql": None}, ]) mock_superset.get_dataset_detail.return_value = { "linked_dashboards": [{"id": 101}], } result = await find_affected_dashboards(["raw.sales"], mock_superset) assert 101 in result # #endregion Test.MaintenanceService.TestCaseInsensitiveMatching # #region Test.MaintenanceService.TestSupersetApiFailure [C:2] [TYPE Function] # @BRIEF Superset API failure propagates. @pytest.mark.asyncio async def test_superset_api_failure(self, mock_superset): mock_superset.get_datasets.side_effect = Exception("Superset API error") with pytest.raises(Exception, match="Superset API error"): await find_affected_dashboards(["raw.sales"], mock_superset) # #endregion Test.MaintenanceService.TestSupersetApiFailure # ── T019a: ensure_banner_chart tests ────────────────────────── class TestEnsureBannerChart: """Tests for ensure_banner_chart.""" # #region Test.MaintenanceService.TestCreatesNewBanner [C:2] [TYPE Function] # @BRIEF No existing banner — inserts native MARKDOWN element and banner row. @pytest.mark.asyncio async def test_creates_new_banner(self, mock_superset, db_session): banner = await ensure_banner_chart( 101, "test-env", mock_superset, db_session ) assert banner.dashboard_id == 101 assert isinstance(banner.chart_id, int) assert banner.status == MaintenanceDashboardBannerStatus.ACTIVE # No chart resource is ever created mock_superset.create_markdown_chart.assert_not_called() mock_superset.update_dashboard_layout.assert_called_once_with(101, banner.chart_id) # #endregion Test.MaintenanceService.TestCreatesNewBanner # #region Test.MaintenanceService.TestReturnsExistingBanner [C:2] [TYPE Function] # @BRIEF Existing active banner with MARKDOWN element in layout — returns it. @pytest.mark.asyncio async def test_returns_existing_banner(self, mock_superset, db_session): existing = MaintenanceDashboardBanner( environment_id="test-env", dashboard_id=101, chart_id=999, banner_text="Existing", status=MaintenanceDashboardBannerStatus.ACTIVE, ) db_session.add(existing) db_session.commit() banner = await ensure_banner_chart( 101, "test-env", mock_superset, db_session ) assert banner.id == existing.id assert banner.chart_id == 999 mock_superset.get_dashboard_layout.assert_called_once_with(101) mock_superset.update_dashboard_layout.assert_not_called() # #endregion Test.MaintenanceService.TestReturnsExistingBanner # #region Test.MaintenanceService.TestLayoutUpdateFailure [C:2] [TYPE Function] # @BRIEF Layout update failure propagates. @pytest.mark.asyncio async def test_layout_update_failure(self, mock_superset, db_session): mock_superset.update_dashboard_layout.side_effect = Exception("Layout error") with pytest.raises(Exception, match="Layout error"): await ensure_banner_chart(101, "test-env", mock_superset, db_session) # #endregion Test.MaintenanceService.TestLayoutUpdateFailure # ── T020: build_banner_text tests ───────────────────────────── class TestBuildBannerText: """Tests for build_banner_text.""" # #region Test.MaintenanceService.TestSingleEvent [C:2] [TYPE Function] # @BRIEF Single event — direct substitution. def test_single_event(self): events = [ {"start_time": "2026-05-21T22:00:00+00:00", "end_time": "2026-05-22T02:00:00+00:00", "message": "Updating tables"} ] template = "Banner: {message} ({start_time} - {end_time})" result = build_banner_text(events, template) assert "Updating tables" in result assert "2026-05-21 22:00" in result assert "2026-05-22 02:00" in result # #endregion Test.MaintenanceService.TestSingleEvent # #region Test.MaintenanceService.TestMultipleEvents [C:2] [TYPE Function] # @BRIEF Multiple events — combined message. def test_multiple_events(self): events = [ {"start_time": "2026-05-21T22:00:00+00:00", "end_time": "2026-05-22T02:00:00+00:00", "message": "Event 1"}, {"start_time": "2026-05-23T22:00:00+00:00", "end_time": "2026-05-24T02:00:00+00:00", "message": "Event 2"}, ] template = "Banner: {message} ({start_time} - {end_time})" result = build_banner_text(events, template) assert "Event 1" in result assert "Event 2" in result # #endregion Test.MaintenanceService.TestMultipleEvents # #region Test.MaintenanceService.TestEmptyEvents [C:2] [TYPE Function] # @BRIEF Empty events list returns empty string. def test_empty_events(self): result = build_banner_text([], "Template: {message}") assert result == "" # #endregion Test.MaintenanceService.TestEmptyEvents # #region Test.MaintenanceService.TestMissingVariables [C:2] [TYPE Function] # @BRIEF Missing variables replaced with empty string. def test_missing_variables(self): events = [{"start_time": None, "end_time": None, "message": "Hi"}] template = "Start: {start_time} - End: {end_time} - Msg: {message}" result = build_banner_text(events, template) assert "уточняется" in result assert "Hi" in result # #endregion Test.MaintenanceService.TestMissingVariables # #region Test.MaintenanceService.TestHtmlEscaping [C:2] [TYPE Function] # @BRIEF Message is HTML-escaped. def test_html_escaping(self): events = [{ "start_time": "2026-05-21T22:00:00+00:00", "end_time": "2026-05-22T02:00:00+00:00", "message": "", }] template = "{message}" result = build_banner_text(events, template) assert "