363 lines
13 KiB
Python
363 lines
13 KiB
Python
# #region Test.Maintenance.Routes.Edge [C:3] [TYPE Module] [SEMANTICS test,maintenance,routes,edge,coverage]
|
|
# @BRIEF Edge-case tests for Maintenance Banner API routes — covers uncovered branches from _routes.py.
|
|
# @RELATION BINDS_TO -> [MaintenanceRoutesModule]
|
|
# @TEST_EDGE: settings_not_found -> GET /settings returns 404 when no settings row exists
|
|
# @TEST_EDGE: settings_upsert -> PUT /settings creates new settings when none exist
|
|
# @TEST_EDGE: settings_all_fields -> PUT /settings with all fields updates correctly
|
|
# @TEST_EDGE: end_already_completed -> POST /{id}/end returns idempotent success for already-completed
|
|
# @TEST_EDGE: start_past_time -> POST /start with start_time too far in past returns 400
|
|
# @TEST_EDGE: start_html_message -> POST /start with HTML in message is accepted
|
|
# @TEST_EDGE: end_all_with_env_body -> POST /end-all with environment_id in body
|
|
# @TEST_EDGE: banners_with_linked_states -> GET /dashboard-banners with linked states/events
|
|
from datetime import UTC, datetime, timedelta
|
|
from unittest.mock import MagicMock
|
|
import pytest
|
|
|
|
# ── Shared fixtures ────────────────────────────────────────────
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
from fastapi.testclient import TestClient
|
|
from src.app import app
|
|
return TestClient(app)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def auth_mock(client, mock_db):
|
|
from src.app import app
|
|
from src.core.auth.api_key import generate_api_key
|
|
from src.dependencies import get_current_user
|
|
from src.models.api_key import APIKey
|
|
|
|
raw, prefix, key_hash = generate_api_key()
|
|
api_key = APIKey(
|
|
key_hash=key_hash, prefix=prefix, name="Test Key",
|
|
permissions=["maintenance:start", "maintenance:end", "maintenance:end_all"],
|
|
active=True,
|
|
)
|
|
mock_db.add(api_key)
|
|
mock_db.commit()
|
|
client.headers.update({"X-API-Key": raw})
|
|
|
|
mock_role = MagicMock()
|
|
mock_role.name = "Admin"
|
|
mock_role.permissions = []
|
|
|
|
mock_user = MagicMock()
|
|
mock_user.username = "test-admin"
|
|
mock_user.roles = [mock_role]
|
|
|
|
async def _mock_get_current_user():
|
|
return mock_user
|
|
|
|
app.dependency_overrides[get_current_user] = _mock_get_current_user
|
|
|
|
yield
|
|
|
|
client.headers.pop("X-API-Key", None)
|
|
app.dependency_overrides = {}
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_db():
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import sessionmaker
|
|
from sqlalchemy.pool import StaticPool
|
|
from src.dependencies import get_db
|
|
from src.models.mapping import Base
|
|
|
|
engine = create_engine(
|
|
"sqlite:///:memory:",
|
|
poolclass=StaticPool,
|
|
connect_args={"check_same_thread": False},
|
|
)
|
|
Base.metadata.create_all(engine)
|
|
Session = sessionmaker(bind=engine)
|
|
session = Session()
|
|
|
|
def _get_db_override():
|
|
db = Session()
|
|
try:
|
|
yield db
|
|
finally:
|
|
db.close()
|
|
|
|
from src.app import app
|
|
app.dependency_overrides[get_db] = _get_db_override
|
|
yield session
|
|
app.dependency_overrides.pop(get_db, None)
|
|
session.close()
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_task_manager():
|
|
from src.dependencies import get_task_manager
|
|
|
|
from unittest.mock import AsyncMock
|
|
mock_tm = MagicMock()
|
|
mock_task = MagicMock()
|
|
mock_task.id = "test-task-id-edge"
|
|
mock_tm.create_task = AsyncMock(return_value=mock_task)
|
|
|
|
from src.app import app
|
|
app.dependency_overrides[get_task_manager] = lambda: mock_tm
|
|
yield mock_tm
|
|
app.dependency_overrides.pop(get_task_manager, None)
|
|
|
|
|
|
# ── GET /api/maintenance/settings — not found ─────────────────
|
|
|
|
|
|
class TestSettingsNotFound:
|
|
"""GET /api/maintenance/settings when no settings exist."""
|
|
|
|
# #region test_get_settings_not_found [C:2] [TYPE Function]
|
|
# @BRIEF No MaintenanceSettings row -> 404.
|
|
# @TEST_EDGE: settings_not_found -> GET /settings returns 404
|
|
def test_get_settings_not_found(self, client, mock_db, mock_task_manager):
|
|
# Remove the default settings created by the fixture
|
|
from src.models.maintenance import MaintenanceSettings
|
|
mock_db.query(MaintenanceSettings).delete()
|
|
mock_db.commit()
|
|
|
|
response = client.get("/api/maintenance/settings")
|
|
assert response.status_code == 404
|
|
assert "not configured" in response.json()["detail"]
|
|
# #endregion test_get_settings_not_found
|
|
|
|
|
|
# ── PUT /api/maintenance/settings — upsert (no existing) ──────
|
|
|
|
|
|
class TestSettingsUpsert:
|
|
"""PUT /api/maintenance/settings when no settings row exists."""
|
|
|
|
# #region test_upsert_settings [C:2] [TYPE Function]
|
|
# @BRIEF No settings row -> upsert creates one.
|
|
def test_upsert_settings(self, client, mock_db, mock_task_manager):
|
|
from src.models.maintenance import MaintenanceSettings
|
|
mock_db.query(MaintenanceSettings).delete()
|
|
mock_db.commit()
|
|
|
|
response = client.put(
|
|
"/api/maintenance/settings",
|
|
json={"target_environment_id": "new-env", "display_timezone": "Asia/Tokyo"},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["target_environment_id"] == "new-env"
|
|
assert data["display_timezone"] == "Asia/Tokyo"
|
|
# #endregion test_upsert_settings
|
|
|
|
# #region test_upsert_settings_all_fields [C:2] [TYPE Function]
|
|
# @BRIEF All fields provided -> upsert with complete data.
|
|
def test_upsert_settings_all_fields(self, client, mock_db, mock_task_manager):
|
|
from src.models.maintenance import MaintenanceSettings
|
|
mock_db.query(MaintenanceSettings).delete()
|
|
mock_db.commit()
|
|
|
|
response = client.put(
|
|
"/api/maintenance/settings",
|
|
json={
|
|
"target_environment_id": "env-2",
|
|
"display_timezone": "Europe/London",
|
|
"banner_template": "Custom: {message}",
|
|
"dashboard_scope": "all",
|
|
"excluded_dashboard_ids": [1, 2],
|
|
"forced_dashboard_ids": [3],
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["target_environment_id"] == "env-2"
|
|
assert data["display_timezone"] == "Europe/London"
|
|
assert data["banner_template"] == "Custom: {message}"
|
|
assert data["dashboard_scope"] == "all"
|
|
assert 1 in data["excluded_dashboard_ids"]
|
|
assert 3 in data["forced_dashboard_ids"]
|
|
# #endregion test_upsert_settings_all_fields
|
|
|
|
|
|
# ── POST /api/maintenance/{id}/end — already completed ────────
|
|
|
|
|
|
class TestEndAlreadyCompleted:
|
|
"""POST /api/maintenance/{id}/end when event is already COMPLETED."""
|
|
|
|
# #region test_end_idempotent_completed [C:2] [TYPE Function]
|
|
# @BRIEF Event already COMPLETED -> returns 202 with already_completed status.
|
|
def test_end_idempotent_completed(self, client, mock_db, mock_task_manager):
|
|
from src.models.maintenance import MaintenanceEvent, MaintenanceEventStatus
|
|
|
|
event = MaintenanceEvent(
|
|
tables=["raw.sales"],
|
|
start_time=datetime.now(UTC),
|
|
status=MaintenanceEventStatus.COMPLETED,
|
|
environment_id="test-env",
|
|
task_id="existing-task",
|
|
)
|
|
mock_db.add(event)
|
|
mock_db.commit()
|
|
|
|
response = client.post(f"/api/maintenance/{event.id}/end")
|
|
assert response.status_code == 202
|
|
data = response.json()
|
|
assert data["status"] == "already_completed"
|
|
assert data["task_id"] == "existing-task"
|
|
# #endregion test_end_idempotent_completed
|
|
|
|
|
|
# ── POST /api/maintenance/start — edge cases ──────────────────
|
|
|
|
|
|
class TestStartEdgeCases:
|
|
"""POST /api/maintenance/start — edge paths."""
|
|
|
|
# #region test_start_too_far_past [C:2] [TYPE Function]
|
|
# @BRIEF start_time more than 1h in the past -> 400.
|
|
def test_start_too_far_past(self, client, mock_db, mock_task_manager):
|
|
response = client.post(
|
|
"/api/maintenance/start",
|
|
json={
|
|
"tables": ["raw.sales"],
|
|
"start_time": (datetime.now(UTC) - timedelta(hours=5)).isoformat(),
|
|
"environment_id": "test-env",
|
|
},
|
|
)
|
|
assert response.status_code == 400
|
|
assert "too far in the past" in response.json()["detail"]
|
|
# #endregion test_start_too_far_past
|
|
|
|
# #region test_start_with_html_message [C:2] [TYPE Function]
|
|
# @BRIEF Message with HTML content -> accepted (HTML will be escaped at render).
|
|
def test_start_with_html_message(self, client, mock_db, mock_task_manager):
|
|
response = client.post(
|
|
"/api/maintenance/start",
|
|
json={
|
|
"tables": ["raw.sales"],
|
|
"start_time": (datetime.now(UTC) + timedelta(hours=1)).isoformat(),
|
|
"end_time": (datetime.now(UTC) + timedelta(hours=3)).isoformat(),
|
|
"message": "<script>alert('xss')</script>",
|
|
"environment_id": "test-env",
|
|
},
|
|
)
|
|
# HTML in message is accepted — rendering layer escapes it
|
|
assert response.status_code == 202
|
|
# #endregion test_start_with_html_message
|
|
|
|
|
|
# ── POST /api/maintenance/end-all — with env in body ──────────
|
|
|
|
|
|
class TestEndAllWithEnv:
|
|
"""POST /api/maintenance/end-all with environment_id in body."""
|
|
|
|
# #region test_end_all_with_env_body [C:2] [TYPE Function]
|
|
# @BRIEF environment_id in JSON body -> passed to task params.
|
|
def test_end_all_with_env_body(self, client, mock_db, mock_task_manager):
|
|
response = client.post(
|
|
"/api/maintenance/end-all",
|
|
json={"environment_id": "env-42"},
|
|
)
|
|
assert response.status_code == 202
|
|
data = response.json()
|
|
assert data["status"] == "pending"
|
|
# Verify environment_id was passed through
|
|
call_kwargs = mock_task_manager.create_task.call_args[1]
|
|
assert call_kwargs["params"]["environment_id"] == "env-42"
|
|
# #endregion test_end_all_with_env_body
|
|
|
|
|
|
# ── GET /api/maintenance/dashboard-banners — with linked states ─
|
|
|
|
|
|
class TestBannersWithLinkedStates:
|
|
"""GET /api/maintenance/dashboard-banners with linked dashboard states and events."""
|
|
|
|
# #region test_banners_with_linked_states [C:2] [TYPE Function]
|
|
# @BRIEF Banners with linked MaintenanceDashboardState records -> events aggregated.
|
|
def test_banners_with_linked_states(self, client, mock_db, mock_task_manager):
|
|
from src.models.maintenance import (
|
|
MaintenanceDashboardBanner,
|
|
MaintenanceDashboardBannerStatus,
|
|
MaintenanceDashboardState,
|
|
MaintenanceDashboardStateStatus,
|
|
MaintenanceEvent,
|
|
MaintenanceEventStatus,
|
|
)
|
|
|
|
event = MaintenanceEvent(
|
|
tables=["raw.test"],
|
|
start_time=datetime.now(UTC),
|
|
end_time=datetime.now(UTC) + timedelta(hours=2),
|
|
status=MaintenanceEventStatus.ACTIVE,
|
|
environment_id="test-env",
|
|
)
|
|
mock_db.add(event)
|
|
mock_db.flush()
|
|
|
|
banner = MaintenanceDashboardBanner(
|
|
environment_id="test-env",
|
|
dashboard_id=201,
|
|
chart_id=67890,
|
|
banner_text="Linked test",
|
|
status=MaintenanceDashboardBannerStatus.ACTIVE,
|
|
)
|
|
mock_db.add(banner)
|
|
mock_db.flush()
|
|
|
|
state = MaintenanceDashboardState(
|
|
banner_id=banner.id,
|
|
event_id=event.id,
|
|
dashboard_id=201,
|
|
status=MaintenanceDashboardStateStatus.ACTIVE,
|
|
)
|
|
mock_db.add(state)
|
|
mock_db.commit()
|
|
|
|
response = client.get("/api/maintenance/dashboard-banners")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert isinstance(data, list)
|
|
assert len(data) >= 1
|
|
banners_for_dash = [b for b in data if b["dashboard_id"] == 201]
|
|
assert len(banners_for_dash) == 1
|
|
assert len(banners_for_dash[0]["events"]) == 1
|
|
assert banners_for_dash[0]["active"] is True
|
|
# #endregion test_banners_with_linked_states
|
|
|
|
|
|
# ── GET /api/maintenance/dashboard-banners — no linked states ─
|
|
|
|
|
|
class TestBannersNoLinkedStates:
|
|
"""GET /api/maintenance/dashboard-banners with no linked states (just banner)."""
|
|
|
|
# #region test_banners_no_linked_states [C:2] [TYPE Function]
|
|
# @BRIEF Active banner without linked states -> event lists are empty.
|
|
def test_banners_no_linked_states(self, client, mock_db, mock_task_manager):
|
|
from src.models.maintenance import (
|
|
MaintenanceDashboardBanner,
|
|
MaintenanceDashboardBannerStatus,
|
|
)
|
|
|
|
banner = MaintenanceDashboardBanner(
|
|
environment_id="test-env",
|
|
dashboard_id=301,
|
|
chart_id=11111,
|
|
banner_text="No linked state",
|
|
status=MaintenanceDashboardBannerStatus.ACTIVE,
|
|
)
|
|
mock_db.add(banner)
|
|
mock_db.commit()
|
|
|
|
response = client.get("/api/maintenance/dashboard-banners")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
banners_for_dash = [b for b in data if b["dashboard_id"] == 301]
|
|
assert len(banners_for_dash) == 1
|
|
assert banners_for_dash[0]["events"] == []
|
|
# #endregion test_banners_no_linked_states
|
|
# #endregion Test.Maintenance.Routes.Edge
|