# #region test_maintenance_service [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 import uuid from datetime import datetime, timezone from typing import Any, cast from unittest.mock import MagicMock, patch, PropertyMock import pytest 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 = MagicMock() # Default: no datasets client.get_datasets.return_value = (0, []) client.get_dashboards.return_value = (0, []) client.get_dataset_detail.return_value = {"linked_dashboards": []} client.create_markdown_chart.return_value = 12345 client.get_chart.return_value = {"id": 12345, "result": {"viz_type": "markdown"}} 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"} client.delete_chart.return_value = {"result": "ok"} return client @pytest.fixture def db_session(): """Create an in-memory SQLite session with tables.""" from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from src.models.mapping import Base engine = create_engine("sqlite:///:memory:", echo=False) 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(timezone.utc), end_time=datetime.now(timezone.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_basic_key [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=timezone.utc) end = datetime(2026, 5, 22, 2, 0, 0, tzinfo=timezone.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_basic_key # #region test_key_without_end_time [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=timezone.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_key_without_end_time # #region test_key_table_sorting [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_key_table_sorting # ── T019: find_affected_dashboards tests ────────────────────── class TestFindAffectedDashboards: """Tests for find_affected_dashboards.""" # #region test_empty_tables [C:2] [TYPE Function] # @BRIEF Empty tables list returns empty. def test_empty_tables(self, mock_superset): result = find_affected_dashboards([], mock_superset) assert result == [] # #endregion test_empty_tables # #region test_table_based_match [C:2] [TYPE Function] # @BRIEF Table-based dataset matching. 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 = find_affected_dashboards(["raw.sales"], mock_superset) assert 101 in result assert 102 not in result # #endregion test_table_based_match # #region test_virtual_dataset_match [C:2] [TYPE Function] # @BRIEF Virtual SQL dataset matching via SqlTableExtractor. 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 = find_affected_dashboards(["raw.sales"], mock_superset) assert 101 in result # #endregion test_virtual_dataset_match # #region test_case_insensitive_matching [C:2] [TYPE Function] # @BRIEF Matching is case-insensitive. 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 = find_affected_dashboards(["raw.sales"], mock_superset) assert 101 in result # #endregion test_case_insensitive_matching # #region test_superset_api_failure [C:2] [TYPE Function] # @BRIEF Superset API failure propagates. 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"): find_affected_dashboards(["raw.sales"], mock_superset) # #endregion test_superset_api_failure # ── T019a: ensure_banner_chart tests ────────────────────────── class TestEnsureBannerChart: """Tests for ensure_banner_chart.""" # #region test_creates_new_banner [C:2] [TYPE Function] # @BRIEF No existing banner — creates new chart and banner row. def test_creates_new_banner(self, mock_superset, db_session): banner = ensure_banner_chart( 101, "test-env", mock_superset, db_session ) assert banner.dashboard_id == 101 assert banner.chart_id == 12345 assert banner.status == MaintenanceDashboardBannerStatus.ACTIVE mock_superset.create_markdown_chart.assert_called_once_with(101, "*Maintenance banner placeholder*") mock_superset.update_dashboard_layout.assert_called_once_with(101, 12345) # #endregion test_creates_new_banner # #region test_returns_existing_banner [C:2] [TYPE Function] # @BRIEF Existing active banner — returns it. 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 = ensure_banner_chart( 101, "test-env", mock_superset, db_session ) assert banner.id == existing.id assert banner.chart_id == 999 mock_superset.create_markdown_chart.assert_not_called() # #endregion test_returns_existing_banner # #region test_chart_creation_failure [C:2] [TYPE Function] # @BRIEF Chart creation failure propagates. def test_chart_creation_failure(self, mock_superset, db_session): mock_superset.create_markdown_chart.side_effect = Exception("Chart error") with pytest.raises(Exception, match="Chart error"): ensure_banner_chart(101, "test-env", mock_superset, db_session) # #endregion test_chart_creation_failure # ── T020: build_banner_text tests ───────────────────────────── class TestBuildBannerText: """Tests for build_banner_text.""" # #region test_single_event [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_single_event # #region test_multiple_events [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_multiple_events # #region test_empty_events [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_empty_events # #region test_missing_variables [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_missing_variables # #region test_html_escaping [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 "