dry run migration
This commit is contained in:
@@ -407,4 +407,104 @@ async def test_execute_migration_invalid_env_raises_400(_mock_env):
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dry_run_migration_returns_diff_and_risk(db_session):
|
||||
# @TEST_EDGE: missing_target_datasource -> validates high risk item generation
|
||||
# @TEST_EDGE: breaking_reference -> validates high risk on missing dataset link
|
||||
from src.api.routes.migration import dry_run_migration
|
||||
from src.models.dashboard import DashboardSelection
|
||||
|
||||
env_source = MagicMock()
|
||||
env_source.id = "src"
|
||||
env_source.name = "Source"
|
||||
env_source.url = "http://source"
|
||||
env_source.username = "admin"
|
||||
env_source.password = "admin"
|
||||
env_source.verify_ssl = False
|
||||
env_source.timeout = 30
|
||||
|
||||
env_target = MagicMock()
|
||||
env_target.id = "tgt"
|
||||
env_target.name = "Target"
|
||||
env_target.url = "http://target"
|
||||
env_target.username = "admin"
|
||||
env_target.password = "admin"
|
||||
env_target.verify_ssl = False
|
||||
env_target.timeout = 30
|
||||
|
||||
cm = _make_sync_config_manager([env_source, env_target])
|
||||
selection = DashboardSelection(
|
||||
selected_ids=[42],
|
||||
source_env_id="src",
|
||||
target_env_id="tgt",
|
||||
replace_db_config=False,
|
||||
fix_cross_filters=True,
|
||||
)
|
||||
|
||||
with patch("src.api.routes.migration.SupersetClient") as MockClient, \
|
||||
patch("src.api.routes.migration.MigrationDryRunService") as MockService:
|
||||
source_client = MagicMock()
|
||||
target_client = MagicMock()
|
||||
MockClient.side_effect = [source_client, target_client]
|
||||
|
||||
service_instance = MagicMock()
|
||||
service_payload = {
|
||||
"generated_at": "2026-02-27T00:00:00+00:00",
|
||||
"selection": selection.model_dump(),
|
||||
"selected_dashboard_titles": ["Sales"],
|
||||
"diff": {
|
||||
"dashboards": {"create": [], "update": [{"uuid": "dash-1"}], "delete": []},
|
||||
"charts": {"create": [{"uuid": "chart-1"}], "update": [], "delete": []},
|
||||
"datasets": {"create": [{"uuid": "dataset-1"}], "update": [], "delete": []},
|
||||
},
|
||||
"summary": {
|
||||
"dashboards": {"create": 0, "update": 1, "delete": 0},
|
||||
"charts": {"create": 1, "update": 0, "delete": 0},
|
||||
"datasets": {"create": 1, "update": 0, "delete": 0},
|
||||
"selected_dashboards": 1,
|
||||
},
|
||||
"risk": {
|
||||
"score": 75,
|
||||
"level": "high",
|
||||
"items": [
|
||||
{"code": "missing_datasource"},
|
||||
{"code": "breaking_reference"},
|
||||
],
|
||||
},
|
||||
}
|
||||
service_instance.run.return_value = service_payload
|
||||
MockService.return_value = service_instance
|
||||
|
||||
result = await dry_run_migration(selection=selection, config_manager=cm, db=db_session, _=None)
|
||||
|
||||
assert result["summary"]["dashboards"]["update"] == 1
|
||||
assert result["summary"]["charts"]["create"] == 1
|
||||
assert result["summary"]["datasets"]["create"] == 1
|
||||
assert result["risk"]["score"] > 0
|
||||
assert any(item["code"] == "missing_datasource" for item in result["risk"]["items"])
|
||||
assert any(item["code"] == "breaking_reference" for item in result["risk"]["items"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dry_run_migration_rejects_same_environment(db_session):
|
||||
from src.api.routes.migration import dry_run_migration
|
||||
from src.models.dashboard import DashboardSelection
|
||||
|
||||
env = MagicMock()
|
||||
env.id = "same"
|
||||
env.name = "Same"
|
||||
env.url = "http://same"
|
||||
env.username = "admin"
|
||||
env.password = "admin"
|
||||
env.verify_ssl = False
|
||||
env.timeout = 30
|
||||
|
||||
cm = _make_sync_config_manager([env])
|
||||
selection = DashboardSelection(selected_ids=[1], source_env_id="same", target_env_id="same")
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await dry_run_migration(selection=selection, config_manager=cm, db=db_session, _=None)
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
# [/DEF:backend.src.api.routes.__tests__.test_migration_routes:Module]
|
||||
|
||||
@@ -14,6 +14,7 @@ from ...core.database import get_db
|
||||
from ...models.dashboard import DashboardMetadata, DashboardSelection
|
||||
from ...core.superset_client import SupersetClient
|
||||
from ...core.logger import belief_scope
|
||||
from ...core.migration.dry_run_orchestrator import MigrationDryRunService
|
||||
from ...core.mapping_service import IdMappingService
|
||||
from ...models.mapping import ResourceMapping
|
||||
|
||||
@@ -83,6 +84,44 @@ async def execute_migration(
|
||||
raise HTTPException(status_code=500, detail=f"Failed to create migration task: {str(e)}")
|
||||
# [/DEF:execute_migration:Function]
|
||||
|
||||
|
||||
# [DEF:dry_run_migration:Function]
|
||||
# @PURPOSE: Build pre-flight diff and risk summary without applying migration.
|
||||
# @PRE: Selection and environments are valid.
|
||||
# @POST: Returns deterministic JSON diff and risk scoring.
|
||||
@router.post("/migration/dry-run", response_model=Dict[str, Any])
|
||||
async def dry_run_migration(
|
||||
selection: DashboardSelection,
|
||||
config_manager=Depends(get_config_manager),
|
||||
db: Session = Depends(get_db),
|
||||
_ = Depends(has_permission("plugin:migration", "EXECUTE"))
|
||||
):
|
||||
with belief_scope("dry_run_migration"):
|
||||
environments = config_manager.get_environments()
|
||||
env_map = {env.id: env for env in environments}
|
||||
source_env = env_map.get(selection.source_env_id)
|
||||
target_env = env_map.get(selection.target_env_id)
|
||||
if not source_env or not target_env:
|
||||
raise HTTPException(status_code=400, detail="Invalid source or target environment")
|
||||
if selection.source_env_id == selection.target_env_id:
|
||||
raise HTTPException(status_code=400, detail="Source and target environments must be different")
|
||||
if not selection.selected_ids:
|
||||
raise HTTPException(status_code=400, detail="No dashboards selected for dry run")
|
||||
|
||||
service = MigrationDryRunService()
|
||||
source_client = SupersetClient(source_env)
|
||||
target_client = SupersetClient(target_env)
|
||||
try:
|
||||
return service.run(
|
||||
selection=selection,
|
||||
source_client=source_client,
|
||||
target_client=target_client,
|
||||
db=db,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
# [/DEF:dry_run_migration:Function]
|
||||
|
||||
# [DEF:get_migration_settings:Function]
|
||||
# @PURPOSE: Get current migration Cron string explicitly.
|
||||
@router.get("/migration/settings", response_model=Dict[str, str])
|
||||
@@ -221,4 +260,4 @@ async def trigger_sync_now(
|
||||
}
|
||||
# [/DEF:trigger_sync_now:Function]
|
||||
|
||||
# [/DEF:backend.src.api.routes.migration:Module]
|
||||
# [/DEF:backend.src.api.routes.migration:Module]
|
||||
|
||||
Reference in New Issue
Block a user