# #region Test.Api.Migration [C:3] [TYPE Module] [SEMANTICS test,migration,routes] # @BRIEF Tests for migration API routes — dashboards, execute, dry-run, settings, mappings, sync. # @RELATION BINDS_TO -> [MigrationApi] import os os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:") os.environ.setdefault("SECRET_KEY", "test-secret-key-for-tests") os.environ.setdefault("DEV_MODE", "true") import sys from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import FastAPI from fastapi.testclient import TestClient _src = str(Path(__file__).resolve().parent.parent.parent / "src") if _src not in sys.path: sys.path.insert(0, _src) def _make_env(id_val="env-1", name="Test Env", url="https://superset.example.com"): env = MagicMock() env.id = id_val env.name = name env.url = url return env def _make_client(overrides: dict | None = None) -> TestClient: from src.api.routes.migration import router from src.core.database import get_db from src.dependencies import get_config_manager, get_current_user, get_task_manager from src.schemas.auth import User, RoleSchema app = FastAPI() app.include_router(router) mock_user = User( id="admin-1", username="admin", email="admin@x.com", auth_source="LOCAL", created_at=__import__("datetime").datetime.now(), roles=[RoleSchema(id="r1", name="Admin", description="", permissions=[])], ) app.dependency_overrides[get_db] = lambda: MagicMock() app.dependency_overrides[get_current_user] = lambda: mock_user app.dependency_overrides[get_config_manager] = lambda: MagicMock() app.dependency_overrides[get_task_manager] = lambda: MagicMock() if overrides: for dep, fn in overrides.items(): app.dependency_overrides[dep] = fn return TestClient(app) # ── get_dashboards ── class TestGetDashboards: """GET /api/environments/{env_id}/dashboards""" def test_success(self): mock_config = MagicMock() mock_config.get_environments.return_value = [_make_env()] dashboards = [{"id": 1, "title": "Test Dash", "last_modified": "2024-01-01T00:00:00", "status": "published"}] mock_client = MagicMock() mock_client.get_dashboards_summary = AsyncMock(return_value=dashboards) from src.dependencies import get_config_manager with patch("src.api.routes.migration.AsyncSupersetClient", return_value=mock_client): client = _make_client({get_config_manager: lambda: mock_config}) resp = client.get("/api/environments/env-1/dashboards") assert resp.status_code == 200 data = resp.json() assert len(data) == 1 assert data[0]["id"] == 1 def test_environment_not_found(self): mock_config = MagicMock() mock_config.get_environments.return_value = [] from src.dependencies import get_config_manager client = _make_client({get_config_manager: lambda: mock_config}) resp = client.get("/api/environments/bad-env/dashboards") assert resp.status_code == 404 # ── execute_migration ── class TestExecuteMigration: """POST /migration/execute""" SELECTION_PAYLOAD = { "source_env_id": "env-1", "target_env_id": "env-2", "selected_ids": [1, 2, 3], "replace_db_config": True, "fix_cross_filters": False, } def test_success(self): mock_config = MagicMock() e1 = _make_env("env-1") e2 = _make_env("env-2") mock_config.get_environments.return_value = [e1, e2] mock_tm = MagicMock() mock_tm.create_task = AsyncMock(return_value=MagicMock(id="task-123")) from src.dependencies import get_config_manager, get_task_manager client = _make_client({ get_config_manager: lambda: mock_config, get_task_manager: lambda: mock_tm, }) resp = client.post("/api/migration/execute", json=self.SELECTION_PAYLOAD) assert resp.status_code == 200 data = resp.json() assert data["task_id"] == "task-123" def test_invalid_source_env(self): mock_config = MagicMock() mock_config.get_environments.return_value = [_make_env("env-1")] from src.dependencies import get_config_manager client = _make_client({get_config_manager: lambda: mock_config}) resp = client.post("/api/migration/execute", json=self.SELECTION_PAYLOAD) assert resp.status_code == 400 def test_invalid_target_env(self): mock_config = MagicMock() mock_config.get_environments.return_value = [_make_env("env-2")] from src.dependencies import get_config_manager client = _make_client({get_config_manager: lambda: mock_config}) resp = client.post("/api/migration/execute", json=self.SELECTION_PAYLOAD) assert resp.status_code == 400 def test_invalid_target_env(self): mock_config = MagicMock() mock_config.get_environments.return_value = [_make_env("env-2")] from src.dependencies import get_config_manager client = _make_client({get_config_manager: lambda: mock_config}) resp = client.post("/api/migration/execute", json=self.SELECTION_PAYLOAD) assert resp.status_code == 400 def test_task_creation_fails(self): mock_config = MagicMock() e1 = _make_env("env-1") e2 = _make_env("env-2") mock_config.get_environments.return_value = [e1, e2] mock_tm = MagicMock() mock_tm.create_task = AsyncMock(side_effect=RuntimeError("task failed")) from src.dependencies import get_config_manager, get_task_manager client = _make_client({ get_config_manager: lambda: mock_config, get_task_manager: lambda: mock_tm, }) resp = client.post("/api/migration/execute", json=self.SELECTION_PAYLOAD) assert resp.status_code == 500 # ── dry_run_migration ── class TestDryRunMigration: """POST /migration/dry-run""" SELECTION_PAYLOAD = { "source_env_id": "env-1", "target_env_id": "env-2", "selected_ids": [1, 2], "replace_db_config": False, "fix_cross_filters": False, } def test_success(self): mock_config = MagicMock() e1 = _make_env("env-1") e2 = _make_env("env-2") mock_config.get_environments.return_value = [e1, e2] mock_dry_run = MagicMock() mock_dry_run.run = AsyncMock(return_value={"summary": "ok", "risks": []}) from src.dependencies import get_config_manager with patch("src.api.routes.migration.MigrationDryRunService", return_value=mock_dry_run): client = _make_client({get_config_manager: lambda: mock_config}) resp = client.post("/api/migration/dry-run", json=self.SELECTION_PAYLOAD) assert resp.status_code == 200 assert resp.json()["summary"] == "ok" def test_identical_environments(self): mock_config = MagicMock() e1 = _make_env("env-1") mock_config.get_environments.return_value = [e1] from src.dependencies import get_config_manager client = _make_client({get_config_manager: lambda: mock_config}) resp = client.post("/api/migration/dry-run", json={ "source_env_id": "env-1", "target_env_id": "env-1", "selected_ids": [1], }) assert resp.status_code == 400 def test_invalid_environments(self): mock_config = MagicMock() mock_config.get_environments.return_value = [] from src.dependencies import get_config_manager client = _make_client({get_config_manager: lambda: mock_config}) resp = client.post("/api/migration/dry-run", json=self.SELECTION_PAYLOAD) assert resp.status_code == 400 def test_no_dashboards_selected(self): mock_config = MagicMock() e1 = _make_env("env-1") e2 = _make_env("env-2") mock_config.get_environments.return_value = [e1, e2] from src.dependencies import get_config_manager client = _make_client({get_config_manager: lambda: mock_config}) resp = client.post("/api/migration/dry-run", json={ "source_env_id": "env-1", "target_env_id": "env-2", "selected_ids": [], }) assert resp.status_code == 400 def test_value_error_from_service(self): mock_config = MagicMock() e1 = _make_env("env-1") e2 = _make_env("env-2") mock_config.get_environments.return_value = [e1, e2] mock_dry_run = MagicMock() mock_dry_run.run = AsyncMock(side_effect=ValueError("orchestrator error")) from src.dependencies import get_config_manager with patch("src.api.routes.migration.MigrationDryRunService", return_value=mock_dry_run): client = _make_client({get_config_manager: lambda: mock_config}) resp = client.post("/api/migration/dry-run", json=self.SELECTION_PAYLOAD) assert resp.status_code == 500 # ── get_migration_settings ── class TestGetMigrationSettings: """GET /migration/settings""" def test_success(self): mock_config = MagicMock() mock_cfg = MagicMock() mock_cfg.settings.migration_sync_cron = "0 2 * * *" mock_config.get_config.return_value = mock_cfg from src.dependencies import get_config_manager client = _make_client({get_config_manager: lambda: mock_config}) resp = client.get("/api/migration/settings") assert resp.status_code == 200 assert resp.json()["cron"] == "0 2 * * *" # ── update_migration_settings ── class TestUpdateMigrationSettings: """PUT /migration/settings""" def test_success(self): mock_config = MagicMock() mock_cfg = MagicMock() mock_config.get_config.return_value = mock_cfg from src.dependencies import get_config_manager client = _make_client({get_config_manager: lambda: mock_config}) resp = client.put("/api/migration/settings", json={"cron": "0 3 * * *"}) assert resp.status_code == 200 data = resp.json() assert data["cron"] == "0 3 * * *" assert data["status"] == "updated" mock_config.save_config.assert_called_once() def test_missing_cron_field(self): mock_config = MagicMock() from src.dependencies import get_config_manager client = _make_client({get_config_manager: lambda: mock_config}) resp = client.put("/api/migration/settings", json={}) assert resp.status_code == 400 # ── get_resource_mappings ── class TestGetResourceMappings: """GET /migration/mappings-data""" def test_success(self): mock_db = MagicMock() mock_query = MagicMock() mock_db.query.return_value = mock_query mock_query.filter.return_value = mock_query mock_query.count.return_value = 2 def _mock_resource_type(value: str): m = MagicMock() m.value = value return m mock_query.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [ MagicMock(id=1, environment_id="env-1", resource_type=_mock_resource_type("database"), uuid="u1", remote_integer_id=100, resource_name="DB", last_synced_at=None), MagicMock(id=2, environment_id="env-1", resource_type=_mock_resource_type("database"), uuid="u2", remote_integer_id=101, resource_name="DB2", last_synced_at=None), ] from src.core.database import get_db client = _make_client({get_db: lambda: mock_db}) resp = client.get("/api/migration/mappings-data") assert resp.status_code == 200 data = resp.json() assert data["total"] == 2 assert len(data["items"]) == 2 def test_with_filters(self): mock_db = MagicMock() mock_query = MagicMock() mock_db.query.return_value = mock_query mock_query.filter.return_value = mock_query mock_query.count.return_value = 1 mock_query.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [] from src.core.database import get_db client = _make_client({get_db: lambda: mock_db}) resp = client.get("/api/migration/mappings-data?env_id=env-1&resource_type=database&search=test&skip=0&limit=10") assert resp.status_code == 200 # ── trigger_sync_now ── class TestTriggerSyncNow: """POST /migration/sync-now""" def test_success(self): mock_config = MagicMock() mock_cfg = MagicMock() e1 = _make_env("env-1", "Env 1", "https://s1.example.com") e2 = _make_env("env-2", "Env 2", "https://s2.example.com") mock_cfg.environments = [e1, e2] mock_config.get_config.return_value = mock_cfg mock_db = MagicMock() mock_db.query.return_value.filter_by.return_value.first.side_effect = [None, None] mock_sync = MagicMock() mock_sync.sync_environment = AsyncMock() from src.core.database import get_db from src.dependencies import get_config_manager with patch("src.api.routes.migration.IdMappingService", return_value=mock_sync), \ patch("src.api.routes.migration.AsyncSupersetClient"): client = _make_client({ get_config_manager: lambda: mock_config, get_db: lambda: mock_db, }) resp = client.post("/api/migration/sync-now") assert resp.status_code == 200 data = resp.json() assert data["status"] == "completed" assert data["synced_count"] == 2 def test_no_environments(self): mock_config = MagicMock() mock_cfg = MagicMock() mock_cfg.environments = [] mock_config.get_config.return_value = mock_cfg from src.dependencies import get_config_manager client = _make_client({get_config_manager: lambda: mock_config}) resp = client.post("/api/migration/sync-now") assert resp.status_code == 400 def test_sync_with_failures(self): mock_config = MagicMock() mock_cfg = MagicMock() e1 = _make_env("env-1") e2 = _make_env("env-2") mock_cfg.environments = [e1, e2] mock_config.get_config.return_value = mock_cfg mock_db = MagicMock() mock_db.query.return_value.filter_by.return_value.first.return_value = None mock_sync = MagicMock() mock_sync.sync_environment = AsyncMock() from src.core.database import get_db from src.dependencies import get_config_manager # Make AsyncSupersetClient raise on 2nd call to simulate sync failure with patch("src.api.routes.migration.IdMappingService", return_value=mock_sync), \ patch("src.api.routes.migration.AsyncSupersetClient", side_effect=[MagicMock(), RuntimeError("Connection failed")]): client = _make_client({ get_config_manager: lambda: mock_config, get_db: lambda: mock_db, }) resp = client.post("/api/migration/sync-now") assert resp.status_code == 200 data = resp.json() assert data["synced_count"] == 1 assert data["failed_count"] == 1 # #endregion Test.Api.Migration