# #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": "", "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