Backend: - Add validate_mapping_database_ownership() to verify source/target UUIDs belong to declared environments before persisting mappings (mappings.py) - Add API-key environment scoping to get_mappings (filter) and suggest_mappings_api (enforce) (mappings.py) - Add user_id Column to TaskRecord model + Alembic migration (task.py) - Persist task.user_id on save, restore on load (persistence.py) - Wire current_user.id into migrate_dashboards + backup_dashboards task creation (_action_routes.py) - Fix test_migration_routes.py: module-level patch leak → autouse fixture, SupersetClient→AsyncSupersetClient, AsyncMock for sync_environment/run - Fix 7 Pydantic serializer warnings: 'PENDING'→TaskStatus.PENDING in test_tasks.py + import TaskStatus Frontend: - Deepen isDryRunResult(): validate selection field, risk.items entries (all 5 fields), and diff object uuids individually (ExecutorModel.svelte.ts) Prior work included: task password redaction, resume ownership checks, canonical dry-run DTO alignment, migration UI callback fixes, credential exposure reduction, assistant dry-run await fix.
472 lines
27 KiB
TypeScript
472 lines
27 KiB
TypeScript
// frontend/src/lib/models/__tests__/MigrationModel.test.ts
|
|
// #region MigrationModelTests [C:3] [TYPE Module] [SEMANTICS test,model,migration]
|
|
// @BRIEF L1 unit tests for MigrationModel @INVARIANT guarantees — no DOM render.
|
|
// @RELATION BINDS_TO -> [MigrationModel]
|
|
// @TEST_INVARIANT: source-env-resets-dashboards -> VERIFIED_BY: [test_selectSourceEnv_resets_dashboards]
|
|
// @TEST_INVARIANT: source-env-clears-dryrun -> VERIFIED_BY: [test_selectSourceEnv_clears_dryRunResult]
|
|
// @TEST_INVARIANT: migration-blocked-same-env -> VERIFIED_BY: [test_executeMigration_blocked_same_env]
|
|
// @TEST_INVARIANT: dryrun-required-before-execute -> VERIFIED_BY: [test_canExecute_false_without_dryRunResult]
|
|
// @TEST_EDGE: missing_field -> missing env ids trigger precondition fails
|
|
// @TEST_EDGE: invalid_type -> API returns unexpected shape
|
|
// @TEST_EDGE: external_fail -> API throws on fetchDatabases
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
vi.mock("$lib/api.js", () => ({
|
|
api: { requestApi: vi.fn(), postApi: vi.fn(), getTask: vi.fn().mockResolvedValue({ id: "task-1", status: "RUNNING" }), getEnvironmentsList: vi.fn().mockResolvedValue([{ id: "env-1", name: "Dev" }, { id: "env-2", name: "Prod" }]) },
|
|
}));
|
|
vi.mock("$lib/stores/selectedTask.svelte.js", () => ({ selectedTask: { set: vi.fn(), current: null, subscribe: vi.fn() } }));
|
|
vi.mock("$lib/stores/environmentContext.svelte.js", () => ({ environmentContextStore: { current: { selectedEnvId: "env-1" } } }));
|
|
vi.mock("../../../services/taskService.js", () => ({ resumeTask: vi.fn().mockResolvedValue({ ok: true }) }));
|
|
vi.mock('$lib/i18n/index.svelte.js', () => ({ t: { migration: { select_both_envs: "Select both environments", different_envs: "Must be different", select_dashboards: "Select dashboards", resume_failed: "Resume failed" } } }));
|
|
|
|
import { MigrationModel } from "../MigrationModel.svelte.ts";
|
|
import { api } from "$lib/api.js";
|
|
import { selectedTask } from "$lib/stores/selectedTask.svelte.js";
|
|
import { environmentContextStore } from "$lib/stores/environmentContext.svelte.js";
|
|
import { resumeTask } from "../../../services/taskService.js";
|
|
|
|
describe("MigrationModel — L1 invariants (no render)", () => {
|
|
let model: MigrationModel;
|
|
beforeEach(() => { vi.clearAllMocks(); model = new MigrationModel(); selectedTask.current = null; });
|
|
|
|
describe("initial state", () => {
|
|
it("starts with defaults", () => {
|
|
expect(model.currentStep).toBe(1); expect(model.sourceEnvId).toBe(""); expect(model.targetEnvId).toBe("");
|
|
expect(model.selectedDashboardIds).toEqual([]); expect(model.dryRunResult).toBeNull(); expect(model.error).toBe("");
|
|
expect(model.loading).toBe(true); expect(model.replaceDb).toBe(false); expect(model.fixCrossFilters).toBe(true);
|
|
expect(model.showPasswordPrompt).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("proxy setters for password fields", () => {
|
|
it("set showPasswordPrompt via model proxy", () => {
|
|
model.showPasswordPrompt = true;
|
|
expect(model.showPasswordPrompt).toBe(true);
|
|
expect(model.executor.showPasswordPrompt).toBe(true);
|
|
});
|
|
it("set passwordPromptDatabases via model proxy", () => {
|
|
model.passwordPromptDatabases = ["db1", "db2"];
|
|
expect(model.passwordPromptDatabases).toEqual(["db1", "db2"]);
|
|
});
|
|
it("set passwordPromptErrorMessage via model proxy", () => {
|
|
model.passwordPromptErrorMessage = "custom error";
|
|
expect(model.passwordPromptErrorMessage).toBe("custom error");
|
|
});
|
|
});
|
|
|
|
describe("selectSourceEnv — reset invariants", () => {
|
|
it("resets selectedDashboardIds and dashboards", () => {
|
|
model.selectedDashboardIds = ["1", "2"]; model.dashboards = [{ id: "1" }];
|
|
model.selectSourceEnv("env-2");
|
|
expect(model.selectedDashboardIds).toEqual([]);
|
|
});
|
|
it("does NOT reset if same env is selected again", () => {
|
|
model.sourceEnvId = "env-1"; model.selectedDashboardIds = ["1"];
|
|
model.selectSourceEnv("env-1");
|
|
expect(model.selectedDashboardIds).toEqual(["1"]);
|
|
});
|
|
it("does not fetch dashboards for empty envId", () => {
|
|
model.sourceEnvId = "env-1";
|
|
model.selectSourceEnv("");
|
|
expect(model.sourceEnvId).toBe("");
|
|
expect(api.requestApi).not.toHaveBeenCalled();
|
|
});
|
|
it("resets databases, mappings, and suggestions", () => {
|
|
model.sourceDatabases = [{ uuid: "db-1", database_name: "test" }];
|
|
model.targetDatabases = [{ uuid: "db-2", database_name: "test" }];
|
|
model.mappings = [{ id: 1 }]; model.suggestions = [{ id: 2 }];
|
|
model.selectSourceEnv("env-2");
|
|
expect(model.sourceDatabases).toEqual([]); expect(model.mappings).toEqual([]);
|
|
});
|
|
it("clears dryRunResult", () => {
|
|
model.dryRunResult = { risk: { score: 10, level: "low", items: [] } };
|
|
model.selectSourceEnv("env-2");
|
|
expect(model.dryRunResult).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("selectTargetEnv", () => {
|
|
it("sets targetEnvId and clears dryRunResult", () => {
|
|
model.dryRunResult = { risk: { score: 10, level: "low", items: [] } };
|
|
model.selectTargetEnv("env-2");
|
|
expect(model.targetEnvId).toBe("env-2"); expect(model.dryRunResult).toBeNull();
|
|
});
|
|
it("does nothing when same env", () => { model.selectTargetEnv("env-2"); model.selectTargetEnv("env-2"); expect(model.targetEnvId).toBe("env-2"); });
|
|
});
|
|
|
|
describe("toggleDashboard", () => {
|
|
it("adds id when not present", () => { model.toggleDashboard("5"); expect(model.selectedDashboardIds).toContain("5"); });
|
|
it("removes id when present", () => { model.selectedDashboardIds = ["1", "5"]; model.toggleDashboard("5"); expect(model.selectedDashboardIds).toEqual(["1"]); });
|
|
it("clears dryRunResult", () => { model.dryRunResult = {} as any; model.toggleDashboard("1"); expect(model.dryRunResult).toBeNull(); });
|
|
});
|
|
|
|
describe("selectAllDashboards / deselectAllDashboards", () => {
|
|
it("selects all", () => { model.dashboards = [{ id: "1" }, { id: "2" }]; model.selectAllDashboards(); expect(model.selectedDashboardIds).toEqual(["1", "2"]); });
|
|
it("deselects all", () => { model.selectedDashboardIds = ["1"]; model.deselectAllDashboards(); expect(model.selectedDashboardIds).toEqual([]); });
|
|
});
|
|
|
|
describe("executeMigration blocked invariants", () => {
|
|
it("blocks when source=target", async () => {
|
|
model.sourceEnvId = "env-1"; model.targetEnvId = "env-1"; model.selectedDashboardIds = ["1"];
|
|
await model.executeMigration();
|
|
expect(model.error).toContain("different"); expect(api.postApi).not.toHaveBeenCalled();
|
|
});
|
|
it("blocks when no dashboards selected", async () => {
|
|
model.sourceEnvId = "env-1"; model.targetEnvId = "env-2"; model.selectedDashboardIds = [];
|
|
await model.executeMigration();
|
|
expect(model.error).toContain("dashboards"); expect(api.postApi).not.toHaveBeenCalled();
|
|
});
|
|
it("blocks when source env empty", async () => {
|
|
model.sourceEnvId = ""; model.targetEnvId = "env-2"; model.selectedDashboardIds = ["1"];
|
|
await model.executeMigration();
|
|
expect(model.error).toContain("both"); expect(api.postApi).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("canExecute derived", () => {
|
|
it("false when dryRunResult is null", () => { model.selectedDashboardIds = ["1"]; expect(model.canExecute).toBe(false); });
|
|
it("false when no dashboards", () => { model.dryRunResult = {} as any; model.selectedDashboardIds = []; expect(model.canExecute).toBe(false); });
|
|
it("true when ready", () => { model.selectedDashboardIds = ["1"]; model.dryRunResult = { risk: { score: 10, level: "low", items: [] } }; expect(model.canExecute).toBe(true); });
|
|
});
|
|
|
|
describe("stepReady derived", () => {
|
|
it("step 1 ready when both envs selected and distinct", () => { model.sourceEnvId = "env-1"; model.targetEnvId = "env-2"; expect(model.stepReady[1]).toBe(true); });
|
|
it("step 1 not ready when same env", () => { model.sourceEnvId = "env-1"; model.targetEnvId = "env-1"; expect(model.stepReady[1]).toBe(false); });
|
|
it("step 2 ready when dashboards selected", () => { model.selectedDashboardIds = ["1"]; expect(model.stepReady[2]).toBe(true); });
|
|
it("step 3 requires dry-run result", () => {
|
|
expect(model.stepReady[3]).toBe(false);
|
|
model.dryRunResult = { summary: {}, risk: { score: 10, level: "low", items: [] } };
|
|
expect(model.stepReady[3]).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("setReplaceDb", () => {
|
|
it("clears databases and mappings when set to false", () => {
|
|
model.sourceDatabases = [{ uuid: "db-1", database_name: "test" }]; model.mappings = [{ id: 1 }];
|
|
model.setReplaceDb(false);
|
|
expect(model.sourceDatabases).toEqual([]); expect(model.mappings).toEqual([]);
|
|
});
|
|
it("clears dryRunResult", () => { model.dryRunResult = {} as any; model.setReplaceDb(true); expect(model.dryRunResult).toBeNull(); });
|
|
});
|
|
|
|
describe("setFixCrossFilters", () => {
|
|
it("sets value and clears dryRunResult", () => { model.setFixCrossFilters(false); expect(model.fixCrossFilters).toBe(false); });
|
|
});
|
|
|
|
describe("goToStep gating", () => {
|
|
it("allows backward navigation", () => { model.currentStep = 3; model.goToStep(1); expect(model.currentStep).toBe(1); });
|
|
it("allows forward to step 2 when ready", () => { model.sourceEnvId = "env-1"; model.targetEnvId = "env-2"; model.goToStep(2); expect(model.currentStep).toBe(2); });
|
|
it("blocks forward to step 2 when not ready", () => { model.goToStep(2); expect(model.currentStep).toBe(1); });
|
|
it("blocks forward to step 3 without dryRunResult", () => {
|
|
model.sourceEnvId = "env-1"; model.targetEnvId = "env-2"; model.selectedDashboardIds = ["1"];
|
|
model.goToStep(3); expect(model.currentStep).toBe(1);
|
|
});
|
|
it("allows step 4 only with dryRunResult", () => {
|
|
model.currentStep = 3; model.dryRunResult = {} as any; model.selectedDashboardIds = ["1"]; model.sourceEnvId = "env-1"; model.targetEnvId = "env-2";
|
|
model.goToStep(4); expect(model.currentStep).toBe(4);
|
|
});
|
|
it("blocks step 4 without dryRunResult", () => { model.currentStep = 3; model.goToStep(4); expect(model.currentStep).toBe(3); });
|
|
});
|
|
|
|
describe("checkPasswordPrompt", () => {
|
|
it("shows prompt for AWAITING_INPUT with database_password", () => {
|
|
selectedTask.current = { id: "t-1", status: "AWAITING_INPUT", input_request: { type: "database_password", databases: ["db1"], error_message: "Auth failed" } };
|
|
model.checkPasswordPrompt();
|
|
expect(model.showPasswordPrompt).toBe(true); expect(model.passwordPromptDatabases).toEqual(["db1"]);
|
|
});
|
|
it("does NOT show prompt when not AWAITING_INPUT", () => {
|
|
selectedTask.current = { id: "t-1", status: "RUNNING" };
|
|
model.checkPasswordPrompt(); expect(model.showPasswordPrompt).toBe(false);
|
|
});
|
|
it("does NOT show prompt for different input_request type", () => {
|
|
selectedTask.current = { id: "t-1", status: "AWAITING_INPUT", input_request: { type: "confirmation" } };
|
|
model.checkPasswordPrompt(); expect(model.showPasswordPrompt).toBe(false);
|
|
});
|
|
it("does nothing when no active task", () => { selectedTask.current = null; model.checkPasswordPrompt(); expect(model.showPasswordPrompt).toBe(false); });
|
|
it("does NOT show prompt when AWAITING_INPUT but input_request missing", () => {
|
|
selectedTask.current = { id: "t-1", status: "AWAITING_INPUT" };
|
|
model.checkPasswordPrompt();
|
|
expect(model.showPasswordPrompt).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("clearError", () => { it("clears error", () => { model.error = "Something"; model.clearError(); expect(model.error).toBe(""); }); });
|
|
|
|
describe("screenState derived", () => {
|
|
it("idle when loading", () => { model.loading = true; expect(model.screenState).toBe("idle"); });
|
|
it("error when error set", () => { model.loading = false; model.error = "Fail"; expect(model.screenState).toBe("error"); });
|
|
it("loading when dryRunLoading", () => { model.loading = false; model.dryRunLoading = true; expect(model.screenState).toBe("loading"); });
|
|
it("loading when fetchingDbs", () => { model.loading = false; model.dryRunLoading = false; model.fetchingDbs = true; expect(model.screenState).toBe("loading"); });
|
|
it("review when dryRunResult exists", () => { model.loading = false; model.dryRunResult = {} as any; expect(model.screenState).toBe("review"); });
|
|
it("ready when no special state", () => { model.loading = false; expect(model.screenState).toBe("ready"); });
|
|
it("executing when currentStep >= 4", () => { model.loading = false; model.currentStep = 4; expect(model.screenState).toBe("executing"); });
|
|
});
|
|
|
|
describe("loadEnvironments", () => {
|
|
it("sets loading lifecycle", async () => {
|
|
vi.mocked(api.getEnvironmentsList).mockResolvedValueOnce([]);
|
|
const p = model.loadEnvironments(); expect(model.loading).toBe(true); await p; expect(model.loading).toBe(false);
|
|
});
|
|
it("pre-fills sourceEnvId from context", async () => {
|
|
vi.mocked(api.getEnvironmentsList).mockResolvedValueOnce([{ id: "env-1", name: "Dev" }]);
|
|
await model.loadEnvironments(); expect(model.sourceEnvId).toBe("env-1");
|
|
});
|
|
it("sets error on failure", async () => {
|
|
vi.mocked(api.getEnvironmentsList).mockRejectedValueOnce(new Error("Fail"));
|
|
await model.loadEnvironments(); expect(model.error).toBe("Fail");
|
|
});
|
|
it("does not pre-fill sourceEnvId without active context", async () => {
|
|
const saved = environmentContextStore.current;
|
|
environmentContextStore.current = null;
|
|
try {
|
|
vi.mocked(api.getEnvironmentsList).mockResolvedValueOnce([{ id: "env-1", name: "Dev" }]);
|
|
await model.loadEnvironments();
|
|
expect(model.sourceEnvId).toBe("");
|
|
} finally {
|
|
environmentContextStore.current = saved;
|
|
}
|
|
});
|
|
it("sets fallback error message on non-Error rejection", async () => {
|
|
vi.mocked(api.getEnvironmentsList).mockRejectedValueOnce("Raw string error");
|
|
await model.loadEnvironments();
|
|
expect(model.error).toBe("Failed to load environments");
|
|
});
|
|
});
|
|
|
|
describe("fetchDatabases", () => {
|
|
it("returns early without env ids", async () => { await model.fetchDatabases(); expect(api.requestApi).not.toHaveBeenCalled(); });
|
|
it("fetches in parallel", async () => {
|
|
model.sourceEnvId = "env-1"; model.targetEnvId = "env-2";
|
|
vi.mocked(api.requestApi).mockResolvedValueOnce([{ uuid: "db-1", database_name: "DevDB" }]).mockResolvedValueOnce([{ uuid: "db-2", database_name: "ProdDB" }]).mockResolvedValueOnce([{ id: 1 }]);
|
|
vi.mocked(api.postApi).mockResolvedValueOnce([]);
|
|
await model.fetchDatabases();
|
|
expect(model.sourceDatabases).toHaveLength(1); expect(model.fetchingDbs).toBe(false);
|
|
});
|
|
it("sets error on failure", async () => {
|
|
model.sourceEnvId = "env-1"; model.targetEnvId = "env-2";
|
|
vi.mocked(api.requestApi).mockRejectedValue(new Error("DB fail"));
|
|
await model.fetchDatabases(); expect(model.error).toBe("DB fail");
|
|
});
|
|
});
|
|
|
|
describe("saveMapping", () => {
|
|
it("does nothing when dbs not found", async () => { await model.saveMapping("missing", "tgt"); expect(api.postApi).not.toHaveBeenCalled(); });
|
|
it("saves and updates mappings", async () => {
|
|
model.sourceDatabases = [{ uuid: "suuid", database_name: "SrcDB" }]; model.targetDatabases = [{ uuid: "tuuid", database_name: "TgtDB" }];
|
|
vi.mocked(api.postApi).mockResolvedValue({ id: 1 });
|
|
await model.saveMapping("suuid", "tuuid");
|
|
expect(model.mappings).toHaveLength(1);
|
|
});
|
|
it("replaces existing mapping with same source_db_uuid", async () => {
|
|
model.sourceDatabases = [{ uuid: "suuid", database_name: "SrcDB" }];
|
|
model.targetDatabases = [{ uuid: "tuuid", database_name: "TgtDB" }, { uuid: "other-uuid", database_name: "Other" }];
|
|
model.mappings = [
|
|
{ id: 1, source_db_uuid: "suuid", target_db_uuid: "old-uuid" },
|
|
{ id: 2, source_db_uuid: "other-uuid", target_db_uuid: "tgt-other" },
|
|
];
|
|
vi.mocked(api.postApi).mockResolvedValue({ id: 3, source_db_uuid: "suuid", target_db_uuid: "tuuid" });
|
|
await model.saveMapping("suuid", "tuuid");
|
|
expect(model.mappings).toHaveLength(2);
|
|
expect(model.mappings[0].id).toBe(2);
|
|
expect(model.mappings[1].id).toBe(3);
|
|
});
|
|
it("clears a completed dry-run after choosing a different target database", async () => {
|
|
model.sourceDatabases = [{ uuid: "suuid", database_name: "SrcDB" }];
|
|
model.targetDatabases = [{ uuid: "recommended", database_name: "Recommended" }, { uuid: "manual", database_name: "Manual" }];
|
|
model.mappings = [{ id: 1, source_db_uuid: "suuid", target_db_uuid: "recommended" }];
|
|
model.dryRunResult = { summary: {}, risk: { score: 10, level: "low", items: [] } } as any;
|
|
vi.mocked(api.postApi).mockResolvedValue({ id: 2, source_db_uuid: "suuid", target_db_uuid: "manual" });
|
|
|
|
await model.saveMapping("suuid", "manual");
|
|
|
|
expect(model.mappings).toEqual([{ id: 2, source_db_uuid: "suuid", target_db_uuid: "manual" }]);
|
|
expect(model.dryRunResult).toBeNull();
|
|
});
|
|
it("sets error on failure", async () => {
|
|
model.sourceDatabases = [{ uuid: "suuid", database_name: "S" }]; model.targetDatabases = [{ uuid: "tuuid", database_name: "T" }];
|
|
vi.mocked(api.postApi).mockRejectedValue(new Error("Save failed"));
|
|
await model.saveMapping("suuid", "tuuid"); expect(model.error).toBe("Save failed");
|
|
});
|
|
});
|
|
|
|
describe("_validatePreconditions", () => {
|
|
it("false when both envs empty", () => { expect(model._validatePreconditions()).toBe(false); expect(model.error).toContain("both"); });
|
|
it("false when same env", () => { model.sourceEnvId = "env-1"; model.targetEnvId = "env-1"; expect(model._validatePreconditions()).toBe(false); });
|
|
it("false when no dashboards", () => { model.sourceEnvId = "env-1"; model.targetEnvId = "env-2"; expect(model._validatePreconditions()).toBe(false); });
|
|
it("true when all met", () => { model.sourceEnvId = "env-1"; model.targetEnvId = "env-2"; model.selectedDashboardIds = ["1"]; expect(model._validatePreconditions()).toBe(true); });
|
|
});
|
|
|
|
describe("_buildSelection", () => {
|
|
it("builds correct payload", () => {
|
|
model.selectedDashboardIds = ["1"]; model.sourceEnvId = "src"; model.targetEnvId = "tgt"; model.replaceDb = true; model.fixCrossFilters = false;
|
|
const sel = model._buildSelection();
|
|
expect(sel).toEqual({ selected_ids: [1], source_env_id: "src", target_env_id: "tgt", replace_db_config: true, fix_cross_filters: false });
|
|
});
|
|
});
|
|
|
|
describe("executeMigration", () => {
|
|
it("executes with default endpoint", async () => {
|
|
model.sourceEnvId = "env-1"; model.targetEnvId = "env-2"; model.selectedDashboardIds = ["1"]; model.dryRunResult = { risk: { score: 10, level: "low", items: [] } } as any;
|
|
vi.mocked(api.postApi).mockResolvedValue({ task_id: "task-1" }); vi.mocked(api.getTask).mockResolvedValue({ id: "task-1", status: "RUNNING" });
|
|
await model.executeMigration();
|
|
expect(api.postApi).toHaveBeenCalledWith("/migration/execute", expect.any(Object));
|
|
});
|
|
it("executes with custom endpoint", async () => {
|
|
model.sourceEnvId = "env-1"; model.targetEnvId = "env-2"; model.selectedDashboardIds = ["1"]; model.dryRunResult = { risk: { score: 10, level: "low", items: [] } } as any;
|
|
vi.mocked(api.postApi).mockResolvedValue({ task_id: "task-1" });
|
|
await model.executeMigration("/custom/migrate");
|
|
expect(api.postApi).toHaveBeenCalledWith("/custom/migrate", expect.any(Object));
|
|
});
|
|
it("sets error on failure", async () => {
|
|
model.sourceEnvId = "env-1"; model.targetEnvId = "env-2"; model.selectedDashboardIds = ["1"]; model.dryRunResult = {} as any;
|
|
vi.mocked(api.postApi).mockRejectedValue(new Error("Failed"));
|
|
await model.executeMigration(); expect(model.error).toBe("Failed");
|
|
});
|
|
it("sets fallback error on non-Error rejection", async () => {
|
|
model.sourceEnvId = "env-1"; model.targetEnvId = "env-2"; model.selectedDashboardIds = ["1"]; model.dryRunResult = {} as any;
|
|
vi.mocked(api.postApi).mockRejectedValue("Raw string error");
|
|
await model.executeMigration();
|
|
expect(model.error).toBe("Migration execution failed");
|
|
});
|
|
});
|
|
|
|
describe("calculateDryRun → toggleDashboard invariant", () => {
|
|
it("clears dryRunResult on toggle after dry-run", async () => {
|
|
model.sourceEnvId = "env-1"; model.targetEnvId = "env-2"; model.selectedDashboardIds = ["1"];
|
|
vi.mocked(api.postApi).mockResolvedValueOnce({
|
|
generated_at: "2026-07-15T00:00:00+00:00",
|
|
selection: { selected_ids: [1], source_env_id: "env-1", target_env_id: "env-2", replace_db_config: false, fix_cross_filters: true },
|
|
selected_dashboard_titles: ["Sales"],
|
|
diff: { dashboards: { create: [], update: [], delete: [] }, charts: { create: [], update: [], delete: [] }, datasets: { create: [], update: [], delete: [] } },
|
|
summary: { dashboards: { create: 0, update: 0, delete: 0 }, charts: { create: 0, update: 0, delete: 0 }, datasets: { create: 0, update: 0, delete: 0 }, selected_dashboards: 1 },
|
|
risk: { score: 10, level: "low", items: [] },
|
|
});
|
|
await model.calculateDryRun();
|
|
expect(model.dryRunResult).toBeTruthy();
|
|
model.toggleDashboard("1");
|
|
expect(model.dryRunResult).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("resumeMigration", () => {
|
|
it("does nothing with no active task", async () => { selectedTask.current = null; await model.resumeMigration({}); expect(resumeTask).not.toHaveBeenCalled(); });
|
|
it("resumes and closes prompt", async () => {
|
|
selectedTask.current = { id: "task-1" };
|
|
await model.resumeMigration({ pw: "secret" });
|
|
expect(resumeTask).toHaveBeenCalledWith("task-1", { pw: "secret" }); expect(model.showPasswordPrompt).toBe(false);
|
|
});
|
|
it("sets error on failure", async () => {
|
|
selectedTask.current = { id: "task-1" };
|
|
vi.mocked(resumeTask).mockRejectedValue(new Error("Resume failed"));
|
|
await model.resumeMigration({}); expect(model.passwordPromptErrorMessage).toBe("Resume failed");
|
|
});
|
|
it("sets fallback error on non-Error rejection", async () => {
|
|
selectedTask.current = { id: "task-1" };
|
|
vi.mocked(resumeTask).mockRejectedValue("Raw string error");
|
|
await model.resumeMigration({});
|
|
expect(model.passwordPromptErrorMessage).toBe("Resume failed");
|
|
});
|
|
});
|
|
|
|
describe("log viewer / task history", () => {
|
|
it("openLogViewer sets state", () => { model.openLogViewer({ id: "t-1", status: "RUNNING" }); expect(model.showLogViewer).toBe(true); expect(model.logViewerTaskId).toBe("t-1"); });
|
|
it("closeLogViewer resets", () => { model.showLogViewer = true; model.closeLogViewer(); expect(model.showLogViewer).toBe(false); expect(model.logViewerTaskId).toBeNull(); });
|
|
it("toggleTaskHistory toggles", () => { expect(model.showTaskHistory).toBe(false); model.toggleTaskHistory(); expect(model.showTaskHistory).toBe(true); model.toggleTaskHistory(); expect(model.showTaskHistory).toBe(false); });
|
|
});
|
|
|
|
describe("calculateDryRun", () => {
|
|
it("returns early with invalid preconditions", async () => {
|
|
await model.calculateDryRun();
|
|
expect(api.postApi).not.toHaveBeenCalled();
|
|
expect(model.dryRunLoading).toBe(false);
|
|
});
|
|
it("sets error on API failure", async () => {
|
|
model.sourceEnvId = "env-1"; model.targetEnvId = "env-2"; model.selectedDashboardIds = ["1"];
|
|
vi.mocked(api.postApi).mockRejectedValue(new Error("Dry-run failed"));
|
|
await model.calculateDryRun();
|
|
expect(model.error).toBe("Dry-run failed");
|
|
expect(model.dryRunResult).toBeNull();
|
|
expect(model.dryRunLoading).toBe(false);
|
|
});
|
|
it("sets fallback error on non-Error rejection", async () => {
|
|
model.sourceEnvId = "env-1"; model.targetEnvId = "env-2"; model.selectedDashboardIds = ["1"];
|
|
vi.mocked(api.postApi).mockRejectedValue("Non-Error rejection");
|
|
await model.calculateDryRun();
|
|
expect(model.error).toBe("Dry-run failed");
|
|
expect(model.dryRunResult).toBeNull();
|
|
});
|
|
it("advances to step 3 on success", async () => {
|
|
model.sourceEnvId = "env-1"; model.targetEnvId = "env-2"; model.selectedDashboardIds = ["1"];
|
|
vi.mocked(api.postApi).mockResolvedValue({
|
|
generated_at: "2026-07-15T00:00:00+00:00",
|
|
selection: { selected_ids: [1], source_env_id: "env-1", target_env_id: "env-2", replace_db_config: false, fix_cross_filters: true },
|
|
selected_dashboard_titles: ["Sales"],
|
|
diff: { dashboards: { create: [], update: [], delete: [] }, charts: { create: [], update: [], delete: [] }, datasets: { create: [], update: [], delete: [] } },
|
|
summary: { dashboards: { create: 0, update: 0, delete: 0 }, charts: { create: 0, update: 0, delete: 0 }, datasets: { create: 0, update: 0, delete: 0 }, selected_dashboards: 1 },
|
|
risk: { score: 10, level: "low", items: [] },
|
|
});
|
|
await model.calculateDryRun();
|
|
expect(model.currentStep).toBe(3);
|
|
expect(model.dryRunResult).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("executeMigration — fallback task", () => {
|
|
it("creates fallback task when getTask fails", async () => {
|
|
model.sourceEnvId = "env-1"; model.targetEnvId = "env-2"; model.selectedDashboardIds = ["1"]; model.dryRunResult = {} as any;
|
|
vi.mocked(api.postApi).mockResolvedValue({ task_id: "task-1" });
|
|
vi.mocked(api.getTask).mockRejectedValue(new Error("Task not found"));
|
|
await model.executeMigration();
|
|
expect(api.postApi).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("_fetchDashboards", () => {
|
|
it("populates dashboards on success", async () => {
|
|
vi.mocked(api.requestApi).mockResolvedValue([{ id: "d1" }, { id: "d2" }]);
|
|
await (model as any)._fetchDashboards("env-1");
|
|
expect(model.dashboards).toHaveLength(2);
|
|
});
|
|
it("sets error on failure", async () => {
|
|
vi.mocked(api.requestApi).mockRejectedValue(new Error("Fetch failed"));
|
|
await (model as any)._fetchDashboards("env-1");
|
|
expect(model.error).toBe("Fetch failed");
|
|
expect(model.dashboards).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("fetchDatabases — lifecycle", () => {
|
|
it("sets fetchingDbs true then false", async () => {
|
|
model.sourceEnvId = "env-1"; model.targetEnvId = "env-2";
|
|
vi.mocked(api.requestApi).mockResolvedValue([]);
|
|
vi.mocked(api.postApi).mockResolvedValue([]);
|
|
const promise = model.fetchDatabases();
|
|
expect(model.fetchingDbs).toBe(true);
|
|
await promise;
|
|
expect(model.fetchingDbs).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("isCalculating derived", () => {
|
|
it("reflects dryRunLoading state", () => {
|
|
expect(model.isCalculating).toBe(false);
|
|
model.dryRunLoading = true;
|
|
expect(model.isCalculating).toBe(true);
|
|
model.dryRunLoading = false;
|
|
expect(model.isCalculating).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("envsReady derived", () => {
|
|
it("true when source and target envs distinct", () => {
|
|
model.sourceEnvId = "env-1"; model.targetEnvId = "env-2";
|
|
expect(model.envsReady).toBe(true);
|
|
});
|
|
it("false when same env", () => {
|
|
model.sourceEnvId = "env-1"; model.targetEnvId = "env-1";
|
|
expect(model.envsReady).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
// #endregion MigrationModelTests
|