Files
ss-tools/backend/tests/test_maintenance_service.py
busya fbe0ba122c 037: fix 99 failing tests — missing await after async migration
Fixed async/sync boundary bugs across 14 test files. Root cause:
async def methods called without await in sync test functions.

Fixed files:
  - test_translate_jobs.py (10): create_job/get_job/update_job/delete_job
  - test_translate_scheduler.py (5): create_schedule/update/delete
  - test_datasets.py (14): AsyncMock + corrected patch target
  - test_mapping_service.py (11): sync_environment + MockSupersetClient
  - test_defensive_guards.py (6): GitService/SupersetClient guards
  - test_maintenance_service.py (29): all 6 maintenance services
  - test_dry_run_orchestrator.py (1): run() without await
  - test_dashboards_api.py (23): registry client via AsyncMock
  - test_validation_tasks.py (4): trailing slash in POST URL
  - test_superset_matrix.py (3): AsyncMock for compile_preview
  - test_payload_reduction.py (6): LLMClient._optimize_image wrapper
  - test_compliance_task_integration.py (2): event_bus ref
  - test_smoke_plugins.py (1): flusher_stop_event fallback
  - test_task_manager.py (1): _flusher_stop_event/thread fallback

Remaining 31 failures in test_task_manager.py (29) and
test_smoke_plugins.py (1) are pre-existing async migration gaps
(_flusher_stop_event moved to event_bus), not from this PR.
2026-06-05 15:43:35 +03:00

642 lines
25 KiB
Python

# #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 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.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.
@pytest.mark.asyncio
async def test_empty_tables(self, mock_superset):
result = await 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.
@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_table_based_match
# #region test_virtual_dataset_match [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_virtual_dataset_match
# #region test_case_insensitive_matching [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_case_insensitive_matching
# #region test_superset_api_failure [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_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.
@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 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.
@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.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.
@pytest.mark.asyncio
async 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"):
await 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": "<script>alert('xss')</script>",
}]
template = "{message}"
result = build_banner_text(events, template)
assert "<script>" not in result
assert "&lt;script&gt;" in result
# #endregion test_html_escaping
# ── T018/T021: start_maintenance tests ────────────────────────
class TestStartMaintenance:
"""Tests for start_maintenance C4 orchestrator."""
# #region test_happy_path [C:2] [TYPE Function]
# @BRIEF Happy path: event with matching dashboards.
@pytest.mark.asyncio
async def test_happy_path(self, mock_superset, db_session, pending_event):
# Mock dataset with linked dashboard
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, "title": "Sales Dashboard"}],
}
# Mock dashboard list for title resolution
mock_superset.get_dashboards.return_value = (1, [
{"id": 101, "dashboard_title": "Sales Dashboard", "published": True},
])
result = await start_maintenance(pending_event.id, db_session, mock_superset)
assert result["maintenance_id"] == pending_event.id
assert result["status"] in ("active", "partial")
assert result["affected_dashboards"] >= 1
# Verify event status updated
db_session.refresh(pending_event)
assert pending_event.status == MaintenanceEventStatus.ACTIVE
# #endregion test_happy_path
# #region test_no_matching_dashboards [C:2] [TYPE Function]
# @BRIEF No dashboards match the tables.
@pytest.mark.asyncio
async def test_no_matching_dashboards(self, mock_superset, db_session, pending_event):
# No datasets
mock_superset.get_datasets.return_value = (0, [])
result = await start_maintenance(pending_event.id, db_session, mock_superset)
assert result["status"] == "no_match"
assert result["affected_dashboards"] == 0
# #endregion test_no_matching_dashboards
# #region test_event_not_found [C:2] [TYPE Function]
# @BRIEF Event ID not in DB.
@pytest.mark.asyncio
async def test_event_not_found(self, mock_superset, db_session):
result = await start_maintenance("nonexistent-id", db_session, mock_superset)
assert result["status"] == "failed"
assert "error" in result
# #endregion test_event_not_found
# #region test_already_processed_event [C:2] [TYPE Function]
# @BRIEF Event already ACTIVE — idempotent.
@pytest.mark.asyncio
async def test_already_processed_event(self, mock_superset, db_session, pending_event):
# First run succeeds
mock_superset.get_datasets.return_value = (0, [])
result1 = await start_maintenance(pending_event.id, db_session, mock_superset)
assert result1["status"] == "no_match"
# Second run — event is already ACTIVE
result2 = await start_maintenance(pending_event.id, db_session, mock_superset)
assert result2["status"] == "active"
# #endregion test_already_processed_event
# ── T027: rebuild_banner tests ────────────────────────────────
class TestRebuildBanner:
"""Tests for rebuild_banner."""
# #region test_rebuild_active_banner [C:2] [TYPE Function]
# @BRIEF Active banner with active state — rebuilds text.
@pytest.mark.asyncio
async def test_rebuild_active_banner(self, mock_superset, db_session):
banner = MaintenanceDashboardBanner(
environment_id="test-env",
dashboard_id=101,
chart_id=12345,
banner_text="Old text",
status=MaintenanceDashboardBannerStatus.ACTIVE,
)
db_session.add(banner)
db_session.flush()
event = MaintenanceEvent(
tables=["raw.sales"],
start_time=datetime(2026, 5, 21, 22, 0, 0, tzinfo=UTC),
end_time=datetime(2026, 5, 22, 2, 0, 0, tzinfo=UTC),
message="Test",
status=MaintenanceEventStatus.ACTIVE,
environment_id="test-env",
)
db_session.add(event)
db_session.flush()
state = MaintenanceDashboardState(
event_id=event.id,
dashboard_id=101,
banner_id=banner.id,
status=MaintenanceDashboardStateStatus.ACTIVE,
)
db_session.add(state)
db_session.commit()
result = await rebuild_banner(banner.id, db_session, mock_superset)
assert result is True
mock_superset.update_banner_on_dashboard.assert_called_once()
# #endregion test_rebuild_active_banner
# #region test_banner_not_found [C:2] [TYPE Function]
# @BRIEF Banner ID not found.
@pytest.mark.asyncio
async def test_banner_not_found(self, mock_superset, db_session):
result = await rebuild_banner("nonexistent", db_session, mock_superset)
assert result is False
# #endregion test_banner_not_found
# ── T026/T028: end_maintenance tests ──────────────────────────
class TestEndMaintenance:
"""Tests for end_maintenance C4 orchestrator."""
# #region test_end_active_event_single_banner [C:2] [TYPE Function]
# @BRIEF End event with single active dashboard — banner removed entirely.
@pytest.mark.asyncio
async def test_end_active_event_single_banner(self, mock_superset, db_session, pending_event):
# Set up: create banner and state
banner = MaintenanceDashboardBanner(
environment_id="test-env",
dashboard_id=101,
chart_id=12345,
banner_text="Test banner",
status=MaintenanceDashboardBannerStatus.ACTIVE,
)
db_session.add(banner)
db_session.flush()
state = MaintenanceDashboardState(
event_id=pending_event.id,
dashboard_id=101,
banner_id=banner.id,
status=MaintenanceDashboardStateStatus.ACTIVE,
)
db_session.add(state)
pending_event.status = MaintenanceEventStatus.ACTIVE
db_session.commit()
result = await end_maintenance(pending_event.id, db_session, mock_superset)
assert result["maintenance_id"] == pending_event.id
assert result["removed_from"] >= 1
# Verify chart was deleted
mock_superset.remove_chart_from_layout.assert_called()
mock_superset.delete_chart.assert_called_with(12345)
# Verify event status
db_session.refresh(pending_event)
assert pending_event.status == MaintenanceEventStatus.COMPLETED
# #endregion test_end_active_event_single_banner
# #region test_end_event_shared_banner [C:2] [TYPE Function]
# @BRIEF Two events, same dashboard — ending one rebuilds banner, doesn't delete.
@pytest.mark.asyncio
async def test_end_event_shared_banner(self, mock_superset, db_session, pending_event):
# Banner shared by two events
banner = MaintenanceDashboardBanner(
environment_id="test-env",
dashboard_id=101,
chart_id=12345,
banner_text="Shared banner",
status=MaintenanceDashboardBannerStatus.ACTIVE,
)
db_session.add(banner)
db_session.flush()
# Event 1 (pending_event) state
state1 = MaintenanceDashboardState(
event_id=pending_event.id,
dashboard_id=101,
banner_id=banner.id,
status=MaintenanceDashboardStateStatus.ACTIVE,
)
db_session.add(state1)
# Event 2
event2 = MaintenanceEvent(
tables=["raw.products"],
start_time=datetime.now(UTC),
message="Second event",
status=MaintenanceEventStatus.ACTIVE,
environment_id="test-env",
)
db_session.add(event2)
db_session.flush()
state2 = MaintenanceDashboardState(
event_id=event2.id,
dashboard_id=101,
banner_id=banner.id,
status=MaintenanceDashboardStateStatus.ACTIVE,
)
db_session.add(state2)
pending_event.status = MaintenanceEventStatus.ACTIVE
db_session.commit()
# End event1
result = await end_maintenance(pending_event.id, db_session, mock_superset)
assert result["removed_from"] >= 1
# Chart should NOT be deleted (event2 still active)
mock_superset.delete_chart.assert_not_called()
# But rebuild should be called
mock_superset.update_banner_on_dashboard.assert_called()
# Verify event1 completed
db_session.refresh(pending_event)
assert pending_event.status == MaintenanceEventStatus.COMPLETED
# #endregion test_end_event_shared_banner
# #region test_end_already_completed_event [C:2] [TYPE Function]
# @BRIEF Already completed event — idempotent.
@pytest.mark.asyncio
async def test_end_already_completed_event(self, mock_superset, db_session, pending_event):
pending_event.status = MaintenanceEventStatus.COMPLETED
db_session.commit()
result = await end_maintenance(pending_event.id, db_session, mock_superset)
assert result["status"] == "already_completed"
# #endregion test_end_already_completed_event
# #region test_event_not_found [C:2] [TYPE Function]
# @BRIEF Nonexistent event.
@pytest.mark.asyncio
async def test_event_not_found(self, mock_superset, db_session):
result = await end_maintenance("nonexistent", db_session, mock_superset)
assert result["status"] == "failed"
# #endregion test_event_not_found
# ── T026a/T030: end_all_maintenance tests ─────────────────────
class TestEndAllMaintenance:
"""Tests for end_all_maintenance."""
# #region test_end_all_active_events [C:2] [TYPE Function]
# @BRIEF End all active events.
@pytest.mark.asyncio
async def test_end_all_active_events(self, mock_superset, db_session):
# Create two active events
for i in range(2):
event = MaintenanceEvent(
tables=[f"raw.table{i}"],
start_time=datetime.now(UTC),
status=MaintenanceEventStatus.ACTIVE,
environment_id="test-env",
)
db_session.add(event)
db_session.commit()
result = await end_all_maintenance(db_session, mock_superset)
assert result["closed_events"] == 2
assert result["status"] == "completed"
# #endregion test_end_all_active_events
# #region test_no_active_events [C:2] [TYPE Function]
# @BRIEF No active events.
@pytest.mark.asyncio
async def test_no_active_events(self, mock_superset, db_session):
result = await end_all_maintenance(db_session, mock_superset)
assert result["closed_events"] == 0
assert result["status"] == "completed"
# #endregion test_no_active_events
# #region test_mixed_active_and_completed [C:2] [TYPE Function]
# @BRIEF Mix of active and completed events — only active get ended.
@pytest.mark.asyncio
async def test_mixed_active_and_completed(self, mock_superset, db_session):
active = MaintenanceEvent(
tables=["raw.active"],
start_time=datetime.now(UTC),
status=MaintenanceEventStatus.ACTIVE,
environment_id="test-env",
)
db_session.add(active)
completed = MaintenanceEvent(
tables=["raw.done"],
start_time=datetime.now(UTC),
status=MaintenanceEventStatus.COMPLETED,
environment_id="test-env",
)
db_session.add(completed)
db_session.commit()
result = await end_all_maintenance(db_session, mock_superset)
assert result["closed_events"] == 1
# #endregion test_mixed_active_and_completed
# #endregion test_maintenance_service