# #region test_maintenance_api [C:3] [TYPE TestModule] [SEMANTICS test, maintenance, api, fastapi, pytest] # @BRIEF Contract tests for Maintenance Banner API endpoints — T016, T025. # Tests POST /start, POST /{id}/end, POST /end-all, GET /events, GET /dashboard-banners, # GET /settings, PUT /settings. # @LAYER Test # @RELATION BINDS_TO -> [MaintenanceRoutesModule] # @TEST_CONTRACT: POST /api/maintenance/start -> 202 {task_id, maintenance_id, status} # @TEST_CONTRACT: POST /api/maintenance/{id}/end -> 202 {task_id, status} # @TEST_CONTRACT: POST /api/maintenance/end-all -> 202 {task_id, status} # @TEST_EDGE: missing_tables -> 422 validation error # @TEST_EDGE: missing_environment_id -> 422 validation error # @TEST_EDGE: end_time_before_start_time -> 400 validation error # @TEST_EDGE: non_existent_event_end -> 404 not found from datetime import UTC, datetime, timedelta import pytest # Patch GitService at module level to prevent /app/storage/repositories error # This patches the base module BEFORE any imports can reach it import sys from unittest.mock import AsyncMock, MagicMock _mock_git_svc_cls = MagicMock() _mock_git_svc_cls.return_value = MagicMock() sys.modules['src.services.git._base'] = MagicMock(GitService=_mock_git_svc_cls) sys.modules['src.services.git_service'] = MagicMock(GitService=_mock_git_svc_cls) # ── Fixtures ────────────────────────────────────────────────── @pytest.fixture def client(): """Create a TestClient with all dependencies mocked.""" from fastapi.testclient import TestClient from src.app import app return TestClient(app) @pytest.fixture(autouse=True) def auth_mock(client, mock_db): """Create a valid API key with maintenance permissions and mock JWT user.""" 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 # Create API key for mutation endpoints raw, prefix, key_hash = generate_api_key() api_key = APIKey( key_hash=key_hash, prefix=prefix, name="Test API 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 JWT user for read-only endpoints 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(): """Patch get_db dependency to return a clean in-memory session. Uses StaticPool so all Session() instances share the same connection, preventing SQLite threading issues where each :memory: connection creates a separate database. Creates all tables on the shared Base.metadata. """ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool from src.dependencies import get_db from src.models.maintenance import DashboardScope, MaintenanceSettings from src.models.mapping import Base # StaticPool ensures all sessions share the same in-memory connection engine = create_engine( "sqlite:///:memory:", poolclass=StaticPool, connect_args={"check_same_thread": False}, 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="Test: {message} ({start_time}-{end_time})", dashboard_scope=DashboardScope.PUBLISHED_ONLY, excluded_dashboard_ids=[], forced_dashboard_ids=[], ) session.add(settings) session.commit() 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(): """Patch get_task_manager to return a mock.""" from src.dependencies import get_task_manager mock_tm = MagicMock() mock_task = MagicMock() mock_task.id = "test-task-id-123" 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) # ── T016: POST /api/maintenance/start ───────────────────────── class TestStartEndpoint: """Contract tests for POST /api/maintenance/start.""" # #region test_start_returns_202 [C:2] [TYPE Function] # @BRIEF Valid request returns 202 with task_id and maintenance_id. def test_start_returns_202(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": "Test maintenance", "environment_id": "test-env", }, ) assert response.status_code == 202 data = response.json() assert "task_id" in data assert "maintenance_id" in data assert data["status"] == "pending" # #endregion test_start_returns_202 # #region test_start_missing_tables [C:2] [TYPE Function] # @BRIEF Missing tables field returns 422. def test_start_missing_tables(self, client, mock_db, mock_task_manager): response = client.post( "/api/maintenance/start", json={ "start_time": (datetime.now(UTC) + timedelta(hours=1)).isoformat(), }, ) assert response.status_code == 422 # #endregion test_start_missing_tables # #region test_start_missing_environment_id [C:2] [TYPE Function] # @BRIEF Missing environment_id field returns 422. # @TEST_EDGE: missing_environment_id -> 422 validation error def test_start_missing_environment_id(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": "Test maintenance", }, ) assert response.status_code == 422 data = response.json() assert any( "environment_id" in str(err.get("loc", [])) for err in (data.get("detail", []) if isinstance(data.get("detail"), list) else []) ) # #endregion test_start_missing_environment_id # #region test_start_end_time_before_start [C:2] [TYPE Function] # @BRIEF end_time before start_time returns 400. def test_start_end_time_before_start(self, client, mock_db, mock_task_manager): response = client.post( "/api/maintenance/start", json={ "tables": ["raw.sales"], "start_time": (datetime.now(UTC) + timedelta(hours=3)).isoformat(), "end_time": (datetime.now(UTC) + timedelta(hours=1)).isoformat(), "environment_id": "test-env", }, ) assert response.status_code == 400 # #endregion test_start_end_time_before_start # #region test_start_too_many_tables [C:2] [TYPE Function] # @BRIEF More than 100 tables returns 422 (Pydantic validation). def test_start_too_many_tables(self, client, mock_db, mock_task_manager): tables = [f"raw.table{i}" for i in range(101)] response = client.post( "/api/maintenance/start", json={ "tables": tables, "start_time": (datetime.now(UTC) + timedelta(hours=1)).isoformat(), "environment_id": "test-env", }, ) assert response.status_code == 422 # #endregion test_start_too_many_tables # #region test_start_idempotency [C:2] [TYPE Function] # @BRIEF Same (tables, start, end) returns already_active. def test_start_idempotency(self, client, mock_db, mock_task_manager): from src.models.maintenance import MaintenanceEvent, MaintenanceEventStatus payload = { "tables": ["raw.sales"], "start_time": (datetime.now(UTC) + timedelta(hours=1)).isoformat(), "end_time": (datetime.now(UTC) + timedelta(hours=3)).isoformat(), "environment_id": "test-env", } # First call creates event response1 = client.post("/api/maintenance/start", json=payload) assert response1.status_code == 202 # Manually set event to ACTIVE so idempotency check finds it maintenance_id = response1.json()["maintenance_id"] event = mock_db.query(MaintenanceEvent).filter( MaintenanceEvent.id == maintenance_id ).first() if event: event.status = MaintenanceEventStatus.ACTIVE mock_db.commit() # Second call with same params — idempotent response2 = client.post("/api/maintenance/start", json=payload) assert response2.status_code == 202 data = response2.json() # Should have maintenance_id and status assert data.get("maintenance_id") or data.get("status") # #endregion test_start_idempotency # ── T025: POST /api/maintenance/{id}/end ────────────────────── class TestEndEndpoint: """Contract tests for POST /api/maintenance/{id}/end.""" # #region test_end_returns_202 [C:2] [TYPE Function] # @BRIEF Valid maintenance_id returns 202. def test_end_returns_202(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.ACTIVE, environment_id="test-env", ) 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 "task_id" in data # #endregion test_end_returns_202 # #region test_end_not_found [C:2] [TYPE Function] # @BRIEF Non-existent maintenance_id returns 404. def test_end_not_found(self, client, mock_db, mock_task_manager): response = client.post("/api/maintenance/nonexistent-id/end") assert response.status_code == 404 # #endregion test_end_not_found # ── POST /api/maintenance/end-all ───────────────────────────── class TestEndAllEndpoint: """Contract tests for POST /api/maintenance/end-all.""" # #region test_end_all_returns_202 [C:2] [TYPE Function] # @BRIEF end-all returns 202. def test_end_all_returns_202(self, client, mock_db, mock_task_manager): response = client.post("/api/maintenance/end-all") assert response.status_code == 202 data = response.json() assert "task_id" in data assert data["status"] == "pending" # #endregion test_end_all_returns_202 # ── GET /api/maintenance/events ─────────────────────────────── class TestEventsEndpoint: """Contract tests for GET /api/maintenance/events.""" # #region test_list_events [C:2] [TYPE Function] # @BRIEF Returns active and completed events. def test_list_events(self, client, mock_db, mock_task_manager): from src.models.maintenance import MaintenanceEvent, MaintenanceEventStatus active = MaintenanceEvent( tables=["raw.active"], start_time=datetime.now(UTC), status=MaintenanceEventStatus.ACTIVE, environment_id="test-env", ) mock_db.add(active) completed = MaintenanceEvent( tables=["raw.done"], start_time=datetime.now(UTC), status=MaintenanceEventStatus.COMPLETED, environment_id="test-env", ) mock_db.add(completed) mock_db.commit() response = client.get("/api/maintenance/events") assert response.status_code == 200 data = response.json() assert "active" in data assert "completed" in data # #endregion test_list_events # #region test_expired_event_auto_ends [C:2] [TYPE Function] # @BRIEF Event past end_time with ACTIVE status → create_task called with auto-end params. def test_expired_event_auto_ends(self, client, mock_db, mock_task_manager): from datetime import timedelta from src.models.maintenance import MaintenanceEvent, MaintenanceEventStatus event = MaintenanceEvent( tables=["raw.sales"], start_time=datetime.now(UTC) - timedelta(hours=2), end_time=datetime.now(UTC) - timedelta(hours=1), message="Expired maintenance", status=MaintenanceEventStatus.ACTIVE, environment_id="test-env", ) mock_db.add(event) mock_db.commit() response = client.get("/api/maintenance/events") assert response.status_code == 200 mock_task_manager.create_task.assert_called_once_with( plugin_id="maintenance_banner_apply", params={ "operation": "end", "event_id": event.id, "environment_id": "test-env", "user": "system", }, ) # #endregion test_expired_event_auto_ends # #region test_future_end_time_skipped [C:2] [TYPE Function] # @BRIEF Event with future end_time → no task created, skipped by < now filter. def test_future_end_time_skipped(self, client, mock_db, mock_task_manager): from datetime import timedelta from src.models.maintenance import MaintenanceEvent, MaintenanceEventStatus event = MaintenanceEvent( tables=["raw.sales"], start_time=datetime.now(UTC) + timedelta(hours=1), end_time=datetime.now(UTC) + timedelta(hours=3), message="Future maintenance", status=MaintenanceEventStatus.ACTIVE, environment_id="test-env", ) mock_db.add(event) mock_db.commit() response = client.get("/api/maintenance/events") assert response.status_code == 200 mock_task_manager.create_task.assert_not_called() # #endregion test_future_end_time_skipped # #region test_no_end_time_skipped [C:2] [TYPE Function] # @BRIEF Event with end_time=None → no task created, skipped by isnot(None) filter. def test_no_end_time_skipped(self, client, mock_db, mock_task_manager): from datetime import timedelta from src.models.maintenance import MaintenanceEvent, MaintenanceEventStatus event = MaintenanceEvent( tables=["raw.sales"], start_time=datetime.now(UTC) - timedelta(hours=2), end_time=None, message="No end time", status=MaintenanceEventStatus.ACTIVE, environment_id="test-env", ) mock_db.add(event) mock_db.commit() response = client.get("/api/maintenance/events") assert response.status_code == 200 mock_task_manager.create_task.assert_not_called() # #endregion test_no_end_time_skipped # ── GET /api/maintenance/dashboard-banners ──────────────────── class TestDashboardBannersEndpoint: """Contract tests for GET /api/maintenance/dashboard-banners.""" # #region test_list_banners [C:2] [TYPE Function] # @BRIEF Returns list of active banners. def test_list_banners(self, client, mock_db, mock_task_manager): from src.models.maintenance import ( MaintenanceDashboardBanner, MaintenanceDashboardBannerStatus, ) banner = MaintenanceDashboardBanner( environment_id="test-env", dashboard_id=101, chart_id=12345, banner_text="Test", 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() assert isinstance(data, list) # #endregion test_list_banners # ── GET /api/maintenance/settings ───────────────────────────── class TestSettingsEndpoint: """Contract tests for GET/PUT /api/maintenance/settings.""" # #region test_get_settings [C:2] [TYPE Function] # @BRIEF GET returns settings. def test_get_settings(self, client, mock_db, mock_task_manager): response = client.get("/api/maintenance/settings") assert response.status_code == 200 data = response.json() assert "target_environment_id" in data assert data["target_environment_id"] == "test-env" # #endregion test_get_settings # #region test_put_settings [C:2] [TYPE Function] # @BRIEF PUT updates settings. def test_put_settings(self, client, mock_db, mock_task_manager): response = client.put( "/api/maintenance/settings", json={"display_timezone": "Europe/Moscow"}, ) assert response.status_code == 200 data = response.json() assert data["display_timezone"] == "Europe/Moscow" # #endregion test_put_settings # #endregion test_maintenance_api