# #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
from datetime import UTC, datetime
import pytest
from unittest.mock import 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 = 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, 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_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=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_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=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 "