Files
dbx/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.retry.spec.ts
T

404 lines
18 KiB
TypeScript
Vendored

// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, reactive } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
addSqlFileTask: vi.fn(),
cancelSqlFileExecution: vi.fn(),
ensureConnected: vi.fn(),
executeSqlFiles: vi.fn(),
fetchSqlFileTargetOptions: vi.fn(),
listenSqlFileProgress: vi.fn(),
openFileDialog: vi.fn(),
previewSqlFile: vi.fn(),
inspectSqlFileTables: vi.fn(),
progressHandler: undefined as undefined | ((progress: Record<string, unknown>) => void),
refreshDatabaseTreeNode: vi.fn(),
requestConfirmation: vi.fn(),
toast: vi.fn(),
trackerTask: undefined as any,
unlisten: vi.fn(),
updateSqlFileTask: vi.fn(),
uuid: vi.fn(),
}));
function passthrough(tag: string) {
return defineComponent({
inheritAttrs: false,
setup(_, { attrs, slots }) {
return () => h(tag, attrs, slots.default?.());
},
});
}
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("@/lib/common/utils", () => ({ uuid: mocks.uuid }));
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: mocks.openFileDialog }));
vi.mock("@/composables/useSqlHighlighter", () => ({ useSqlHighlighter: () => ({ highlight: (sql: string) => sql }) }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
vi.mock("@/composables/useExportTracker", () => ({
useExportTracker: () => ({ addSqlFileTask: mocks.addSqlFileTask, updateSqlFileTask: mocks.updateSqlFileTask }),
}));
vi.mock("@/composables/useDatabaseOptions", () => ({ fetchSqlFileTargetOptions: mocks.fetchSqlFileTargetOptions }));
vi.mock("@/lib/connection/connectionLevelDatabaseBootstrap", () => ({ requiresSqlFileTargetDatabaseSelection: () => false, supportsConnectionLevelDatabaseBootstrap: (connection: any) => connection?.db_type === "mysql" }));
vi.mock("@/lib/database/productionSafety", () => ({ productionContextForDatabase: () => ({ active: false, databases: [] }) }));
vi.mock("@/stores/productionSafetyStore", () => ({
useProductionSafetyStore: () => ({ requestConfirmation: mocks.requestConfirmation }),
}));
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({
connections: [{ id: "mysql-1", name: "MySQL", db_type: "mysql", driver_profile: "mysql" }],
ensureConnected: mocks.ensureConnected,
getConfig: () => ({ id: "mysql-1", name: "MySQL", db_type: "mysql", driver_profile: "mysql", database: "" }),
refreshDatabaseTreeNode: mocks.refreshDatabaseTreeNode,
}),
}));
vi.mock("@/lib/backend/api", () => ({
cancelSqlFileExecution: mocks.cancelSqlFileExecution,
executeSqlFiles: mocks.executeSqlFiles,
listenSqlFileProgress: mocks.listenSqlFileProgress,
previewSqlFile: mocks.previewSqlFile,
inspectSqlFileTables: mocks.inspectSqlFileTables,
}));
vi.mock("@lucide/vue", () => {
const Icon = passthrough("span");
return { Check: Icon, CheckSquare: Icon, ChevronRight: Icon, FileCode: Icon, FolderOpen: Icon, Loader2: Icon, Play: Icon, Square: Icon, X: Icon };
});
vi.mock("@/components/ui/dialog", () => ({
Dialog: passthrough("div"),
DialogFooter: passthrough("div"),
DialogHeader: passthrough("div"),
DialogScrollContent: passthrough("div"),
DialogTitle: passthrough("div"),
}));
vi.mock("@/components/ui/tooltip", () => ({
Tooltip: passthrough("div"),
TooltipContent: passthrough("div"),
TooltipTrigger: passthrough("div"),
}));
vi.mock("@/components/ui/button", () => ({ Button: passthrough("button") }));
vi.mock("@/components/ui/input", () => ({
Input: defineComponent({
props: ["modelValue"],
emits: ["update:modelValue"],
setup(props, { emit }) {
return () => h("input", { value: props.modelValue, onInput: (event: Event) => emit("update:modelValue", (event.target as HTMLInputElement).value) });
},
}),
}));
vi.mock("@/components/ui/label", () => ({ Label: passthrough("label") }));
vi.mock("@/components/ui/select", () => ({
Select: passthrough("div"),
SelectContent: passthrough("div"),
SelectItem: passthrough("div"),
SelectTrigger: passthrough("div"),
SelectValue: passthrough("span"),
}));
vi.mock("@/components/icons/DatabaseIcon.vue", () => ({ default: passthrough("span") }));
vi.mock("@/components/connection/ConnectionGroupBadge.vue", () => ({ default: passthrough("span") }));
import SqlFileExecutionDialog from "./SqlFileExecutionDialog.vue";
let app: ReturnType<typeof createApp> | undefined;
let root: HTMLDivElement | undefined;
function progress(executionId: string, status: string, overrides: Record<string, unknown> = {}) {
return {
executionId,
status,
statementIndex: 0,
successCount: 0,
failureCount: 0,
affectedRows: 0,
elapsedMs: 10,
statementSummary: "",
...overrides,
};
}
function findButton(label: string): HTMLButtonElement {
const button = Array.from(root!.querySelectorAll("button")).find((item) => item.textContent?.trim().endsWith(label));
expect(button, `button ${label}`).toBeDefined();
return button!;
}
async function mountReadyDialog() {
root = document.createElement("div");
document.body.append(root);
app = createApp(SqlFileExecutionDialog, { open: true });
app.mount(root);
await vi.waitFor(() => expect(mocks.fetchSqlFileTargetOptions).toHaveBeenCalled());
findButton("sqlFile.browse").click();
await vi.waitFor(() => expect(mocks.previewSqlFile).toHaveBeenCalledTimes(2));
await vi.waitFor(() => expect(findButton("sqlFile.execute").disabled).toBe(false));
}
async function completeFirstExecution() {
mocks.executeSqlFiles.mockImplementationOnce(async (request: { executionId: string }) => {
mocks.progressHandler?.(progress(request.executionId, "running", { fileIndex: 0, fileName: "first.sql" }));
mocks.progressHandler?.(progress(request.executionId, "statementDone", { fileIndex: 0, fileName: "first.sql", statementIndex: 1, successCount: 1 }));
mocks.progressHandler?.(progress(request.executionId, "running", { fileIndex: 1, fileName: "second.sql", statementIndex: 1, successCount: 1 }));
mocks.progressHandler?.(progress(request.executionId, "statementDone", { fileIndex: 1, fileName: "second.sql", statementIndex: 1, successCount: 1, affectedRows: 2 }));
mocks.progressHandler?.(progress(request.executionId, "done", { statementIndex: 2, successCount: 2, affectedRows: 2 }));
});
findButton("sqlFile.execute").click();
await vi.waitFor(() => expect(root!.querySelector("table")).not.toBeNull());
expect(root!.querySelector("table")!.textContent).toContain("first.sql");
expect(root!.querySelector("table")!.textContent).toContain("second.sql");
}
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
}
beforeEach(() => {
vi.clearAllMocks();
mocks.progressHandler = undefined;
mocks.trackerTask = undefined;
mocks.addSqlFileTask.mockImplementation((exportId: string, tableName: string, filePath: string) => {
mocks.trackerTask = reactive({
exportId,
kind: "sql-file",
tableName,
format: "sql",
filePath,
rowsExported: 0,
totalRows: null,
status: "Running",
errorMessage: null,
sqlFileFailures: [],
sqlFileFailuresOmitted: 0,
});
return mocks.trackerTask;
});
mocks.updateSqlFileTask.mockImplementation((_executionId: string, next: Record<string, any>, context?: { fileIndex?: number; fileName?: string }) => {
if (next.status === "statementFailed" && next.error) {
mocks.trackerTask.sqlFileFailures.push({
statementIndex: next.statementIndex,
statementSummary: next.statementSummary,
error: next.error,
...(context?.fileIndex === undefined ? {} : { fileIndex: context.fileIndex }),
...(context?.fileName ? { fileName: context.fileName } : {}),
});
}
});
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.inspectSqlFileTables.mockResolvedValue([
{ database: "app", name: "users" },
{ database: "app", name: "orders" },
{ database: "archive", name: "users" },
]);
mocks.fetchSqlFileTargetOptions.mockResolvedValue([]);
mocks.openFileDialog.mockResolvedValue(["/tmp/first.sql", "/tmp/second.sql"]);
mocks.previewSqlFile.mockImplementation(async (filePath: string) => ({
fileName: filePath.split("/").pop()!,
filePath,
sizeBytes: 9,
preview: "select 1;",
canExecuteWithoutSelectedDatabase: true,
}));
mocks.listenSqlFileProgress.mockImplementation((handler: (event: Record<string, unknown>) => void) => {
mocks.progressHandler = handler;
return mocks.unlisten;
});
mocks.cancelSqlFileExecution.mockResolvedValue(true);
mocks.refreshDatabaseTreeNode.mockResolvedValue(undefined);
mocks.requestConfirmation.mockResolvedValue(true);
mocks.uuid.mockReturnValueOnce("run-1").mockReturnValueOnce("run-2");
});
afterEach(() => {
app?.unmount();
root?.remove();
app = undefined;
root = undefined;
});
describe("SqlFileExecutionDialog retries", () => {
it("does not auto-select the first connection for an unassociated external file", async () => {
root = document.createElement("div");
document.body.append(root);
app = createApp(SqlFileExecutionDialog, { open: true, prefillFilePath: "/tmp/unassociated.sql" });
app.mount(root);
await vi.waitFor(() => expect(mocks.previewSqlFile).toHaveBeenCalledWith("/tmp/unassociated.sql"));
expect(mocks.ensureConnected).not.toHaveBeenCalled();
expect(mocks.fetchSqlFileTargetOptions).not.toHaveBeenCalled();
expect(findButton("sqlFile.execute").disabled).toBe(true);
});
it("does not restore a completed run's file summary after an early retry failure", async () => {
await mountReadyDialog();
await completeFirstExecution();
mocks.ensureConnected.mockRejectedValueOnce(new Error("retry connection failed"));
findButton("sqlFile.execute").click();
await vi.waitFor(() => expect(root!.textContent).toContain("retry connection failed"));
expect(root!.querySelector("table")).toBeNull();
});
it("does not restore a completed run's file summary after an early retry cancellation", async () => {
await mountReadyDialog();
await completeFirstExecution();
const connectionGate = deferred();
mocks.ensureConnected.mockImplementationOnce(() => connectionGate.promise);
findButton("sqlFile.execute").click();
await nextTick();
findButton("sqlFile.cancel").click();
connectionGate.resolve();
await vi.waitFor(() => expect(root!.textContent).toContain("sqlFile.status.cancelled"));
expect(root!.querySelector("table")).toBeNull();
expect(mocks.executeSqlFiles).toHaveBeenCalledTimes(1);
});
it("shows every statement failure in order after continue-on-error completion", async () => {
await mountReadyDialog();
mocks.executeSqlFiles.mockImplementationOnce(async (request: { executionId: string }) => {
mocks.progressHandler?.(progress(request.executionId, "statementFailed", { statementIndex: 1, failureCount: 1, statementSummary: "INSERT bad_one", error: "unknown column one" }));
mocks.progressHandler?.(progress(request.executionId, "statementFailed", { statementIndex: 2, failureCount: 2, statementSummary: "INSERT bad_two", error: "unknown column two" }));
mocks.progressHandler?.(progress(request.executionId, "statementFailed", { statementIndex: 3, failureCount: 3, statementSummary: "INSERT bad_three", error: "syntax error three" }));
mocks.progressHandler?.(progress(request.executionId, "done", { statementIndex: 4, successCount: 1, failureCount: 3 }));
});
findButton("sqlFile.execute").click();
await vi.waitFor(() => expect(root!.textContent).toContain("syntax error three"));
const text = root!.textContent ?? "";
expect(text).toContain("unknown column one");
expect(text).toContain("unknown column two");
expect(text.indexOf("INSERT bad_one")).toBeLessThan(text.indexOf("INSERT bad_two"));
expect(text.indexOf("INSERT bad_two")).toBeLessThan(text.indexOf("INSERT bad_three"));
});
it("shows the first statement failure when stop-on-error becomes terminal", async () => {
await mountReadyDialog();
mocks.executeSqlFiles.mockImplementationOnce(async (request: { executionId: string }) => {
mocks.progressHandler?.(progress(request.executionId, "statementFailed", { statementIndex: 1, failureCount: 1, statementSummary: "INSERT stop_here", error: "stop-on-error failure" }));
mocks.progressHandler?.(progress(request.executionId, "error", { statementIndex: 1, failureCount: 1, statementSummary: "INSERT stop_here", error: "stop-on-error failure" }));
});
findButton("sqlFile.execute").click();
await vi.waitFor(() => expect(root!.textContent).toContain("stop-on-error failure"));
expect(root!.textContent?.match(/stop-on-error failure/g)).toHaveLength(1);
expect(root!.textContent).toContain("INSERT stop_here");
});
it("clears statement failure details before an early retry failure", async () => {
await mountReadyDialog();
mocks.executeSqlFiles.mockImplementationOnce(async (request: { executionId: string }) => {
mocks.progressHandler?.(progress(request.executionId, "statementFailed", { statementIndex: 1, failureCount: 1, statementSummary: "INSERT stale", error: "stale statement failure" }));
mocks.progressHandler?.(progress(request.executionId, "done", { statementIndex: 2, successCount: 1, failureCount: 1 }));
});
findButton("sqlFile.execute").click();
await vi.waitFor(() => expect(root!.textContent).toContain("stale statement failure"));
mocks.ensureConnected.mockRejectedValueOnce(new Error("retry connection failed"));
findButton("sqlFile.execute").click();
await vi.waitFor(() => expect(root!.textContent).toContain("retry connection failed"));
expect(root!.textContent).not.toContain("stale statement failure");
});
});
describe("SqlFileExecutionDialog selected-table restore", () => {
async function mountBackup() {
root = document.createElement("div");
document.body.append(root);
app = createApp(SqlFileExecutionDialog, { open: true, prefillConnectionId: "mysql-1", prefillDatabase: "app", prefillFilePath: "/tmp/backup.sql.gz" });
app.mount(root);
await vi.waitFor(() => expect(findButton("sqlFile.execute").disabled).toBe(false));
root.querySelectorAll<HTMLInputElement>('input[type="radio"]')[1]!.click();
await nextTick();
}
it("scans the backup and sends only checked table identities", async () => {
await mountBackup();
await vi.waitFor(() => expect(root!.textContent).toContain("archive.users"));
expect(mocks.inspectSqlFileTables).toHaveBeenCalledWith("/tmp/backup.sql.gz");
expect(findButton("sqlFile.execute").disabled).toBe(true);
const label = Array.from(root!.querySelectorAll("label")).find((label) => label.textContent === "app.users")!;
label.querySelector<HTMLInputElement>("input")!.click();
await nextTick();
mocks.executeSqlFiles.mockImplementationOnce(async (request) => {
mocks.progressHandler?.(progress(request.executionId, "done", { successCount: 2 }));
});
findButton("sqlFile.execute").click();
await vi.waitFor(() => expect(mocks.executeSqlFiles).toHaveBeenCalledWith(expect.objectContaining({ selectedTables: [{ database: "app", name: "users" }] }), ["/tmp/backup.sql.gz"]));
});
it("selects only search matches and preserves selections outside the search", async () => {
await mountBackup();
await vi.waitFor(() => expect(root!.textContent).toContain("archive.users"));
const search = root!.querySelector<HTMLInputElement>('[aria-label="sqlFile.searchBackupTables"]')!;
search.value = "app.";
search.dispatchEvent(new Event("input"));
await nextTick();
root!.querySelector<HTMLInputElement>('[data-table-restore] input[type="checkbox"]')!.click();
await nextTick();
search.value = "archive";
search.dispatchEvent(new Event("input"));
await nextTick();
expect(root!.querySelector<HTMLInputElement>('[data-table-restore] input[type="checkbox"]')!.checked).toBe(false);
mocks.executeSqlFiles.mockImplementationOnce(async (request) => {
mocks.progressHandler?.(progress(request.executionId, "done"));
});
findButton("sqlFile.execute").click();
await vi.waitFor(() =>
expect(mocks.executeSqlFiles).toHaveBeenCalledWith(
expect.objectContaining({
selectedTables: [
{ database: "app", name: "users" },
{ database: "app", name: "orders" },
],
}),
expect.any(Array),
),
);
});
it("blocks execution on scan failure and allows switching back to full restore", async () => {
mocks.inspectSqlFileTables.mockRejectedValueOnce(new Error("unsupported dump statement"));
await mountBackup();
await vi.waitFor(() => expect(root!.querySelector('[role="alert"]')?.textContent).toContain("unsupported dump statement"));
expect(findButton("sqlFile.execute").disabled).toBe(true);
root!.querySelector<HTMLInputElement>('input[type="radio"]')!.click();
await nextTick();
expect(findButton("sqlFile.execute").disabled).toBe(false);
});
it("ignores a scan result after switching back to all contents", async () => {
let finish!: (tables: any[]) => void;
mocks.inspectSqlFileTables.mockImplementationOnce(
() =>
new Promise((resolve) => {
finish = resolve;
}),
);
await mountBackup();
expect(findButton("sqlFile.execute").disabled).toBe(true);
root!.querySelector<HTMLInputElement>('input[type="radio"]')!.click();
await nextTick();
finish([{ database: null, name: "stale" }]);
await nextTick();
expect(root!.textContent).not.toContain("stale");
mocks.executeSqlFiles.mockImplementationOnce(async (request) => {
mocks.progressHandler?.(progress(request.executionId, "done"));
});
findButton("sqlFile.execute").click();
await vi.waitFor(() => expect(mocks.executeSqlFiles).toHaveBeenCalled());
expect(mocks.executeSqlFiles.mock.calls[0]![0]).not.toHaveProperty("selectedTables");
});
});