fix(backup): confirm legacy macOS destinations before manual runs

This commit is contained in:
C.J.S authored and GitHub committed 2026-09-08 00:14:08 +08:00
1 parent 889fa4f5d0
commit 0ac2d33188
8 files changed
+123 -2

No files matched your search

@@ -357,9 +357,28 @@ async function requestCancelRun(runId: string) {
if (await cancelRun(runId)) toast(t("databaseBackup.cancelRequested"), 2500);
}
async function confirmLegacyDestination(schedule: DatabaseBackupSchedule): Promise<DatabaseBackupSchedule | null> {
if (!(await api.databaseExportDestinationNeedsConfirmation(schedule.destinationDirectory))) return schedule;
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
directory: true,
multiple: false,
defaultPath: schedule.destinationDirectory,
title: t("databaseBackup.selectDestination"),
});
if (typeof selected !== "string") return null;
await api.recordDatabaseExportDestination(selected);
if (selected === schedule.destinationDirectory) return schedule;
return saveSchedule({ ...schedule, destinationDirectory: selected });
}
async function runNow(schedule: DatabaseBackupSchedule) {
try {
const run = await runSchedule(schedule.id, "manual");
const confirmedSchedule = await confirmLegacyDestination(schedule);
if (!confirmedSchedule) return;
const run = await runSchedule(confirmedSchedule.id, "manual");
if (!run) return;
if (run.status === "success") toast(t("databaseBackup.runSuccess", { count: run.files.length }), 3000);
else if (run.status === "cancelled") toast(t("databaseBackup.runCancelled"), 3000);
@@ -15,7 +15,9 @@ const mocks = vi.hoisted(() => ({
activeRuns: [] as DatabaseBackupRun[],
ensureConnected: vi.fn(async () => {}),
listDatabases: vi.fn(async (_connectionId: string) => [{ name: "app" }]),
databaseExportDestinationNeedsConfirmation: vi.fn(async (_directory: string) => false),
recordDatabaseExportDestination: vi.fn(async (_directory: string) => {}),
openDirectory: vi.fn(async (): Promise<string | null> => "/backups"),
toast: vi.fn(),
saveSchedule: vi.fn(),
setScheduleEnabled: vi.fn(),
@@ -58,13 +60,14 @@ vi.mock("@/composables/useToast", () => ({
}));
vi.mock("@tauri-apps/plugin-dialog", () => ({
open: vi.fn(async () => "/backups"),
open: mocks.openDirectory,
}));
vi.mock("@/lib/backend/api", () => ({
listDatabases: mocks.listDatabases,
deleteDatabaseBackupFiles: vi.fn(),
revealPathInFileManager: vi.fn(),
databaseExportDestinationNeedsConfirmation: mocks.databaseExportDestinationNeedsConfirmation,
recordDatabaseExportDestination: mocks.recordDatabaseExportDestination,
}));
@@ -230,6 +233,13 @@ function saveScheduleButton(): HTMLButtonElement {
return button;
}
function scheduleRunNowButton(): HTMLButtonElement {
const title = String(i18n.global.t("databaseBackup.runNow"));
const button = Array.from(document.body.querySelectorAll<HTMLButtonElement>("button")).find((item) => item.title === title && !item.textContent?.trim());
if (!button) throw new Error("Schedule run-now button not found");
return button;
}
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
@@ -242,11 +252,17 @@ afterEach(() => {
mocks.ensureConnected.mockClear();
mocks.listDatabases.mockReset();
mocks.listDatabases.mockResolvedValue([{ name: "app" }]);
mocks.databaseExportDestinationNeedsConfirmation.mockReset();
mocks.databaseExportDestinationNeedsConfirmation.mockResolvedValue(false);
mocks.recordDatabaseExportDestination.mockReset();
mocks.recordDatabaseExportDestination.mockResolvedValue(undefined);
mocks.openDirectory.mockReset();
mocks.openDirectory.mockResolvedValue("/backups");
mocks.toast.mockClear();
mocks.saveSchedule.mockClear();
mocks.cancelRun.mockClear();
mocks.runSchedule.mockReset();
mocks.runSchedule.mockResolvedValue(null);
mocks.runOneShot.mockReset();
mocks.runOneShot.mockResolvedValue(null);
});
@@ -500,6 +516,46 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
expect(document.body.textContent).toContain(String(i18n.global.t("databaseBackup.noSupportedConnections")));
});
it("reconfirms a legacy destination before manually running its schedule", async () => {
mocks.schedules.push(schedule());
mocks.databaseExportDestinationNeedsConfirmation.mockResolvedValueOnce(true);
mocks.runSchedule.mockResolvedValueOnce({
id: "run-1",
scheduleId: "schedule-1",
scheduleName: "Nightly backup",
connectionId: "mysql-1",
connectionName: "Local MySQL",
trigger: "manual",
source: "scheduled",
status: "success",
startedAt: "2026-08-18T00:00:00.000Z",
completedAt: "2026-08-18T00:01:00.000Z",
files: [],
});
await mountSettings();
scheduleRunNowButton().click();
await flush();
expect(mocks.databaseExportDestinationNeedsConfirmation).toHaveBeenCalledWith("/backups");
expect(mocks.openDirectory).toHaveBeenCalledWith(expect.objectContaining({ directory: true, defaultPath: "/backups" }));
expect(mocks.recordDatabaseExportDestination).toHaveBeenCalledWith("/backups");
expect(mocks.runSchedule).toHaveBeenCalledWith("schedule-1", "manual");
});
it("does not run a legacy schedule when destination confirmation is cancelled", async () => {
mocks.schedules.push(schedule());
mocks.databaseExportDestinationNeedsConfirmation.mockResolvedValueOnce(true);
mocks.openDirectory.mockResolvedValueOnce(null);
await mountSettings();
scheduleRunNowButton().click();
await flush();
expect(mocks.recordDatabaseExportDestination).not.toHaveBeenCalled();
expect(mocks.runSchedule).not.toHaveBeenCalled();
});
it("removes unavailable databases from an edited schedule before saving", async () => {
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
mocks.schedules.push(schedule({ databases: ["app", "deleted"] }));
+1
View File
@@ -457,6 +457,7 @@ export const beginDatabaseBackupSnapshot = forward("beginDatabaseBackupSnapshot"
export const exportDatabaseSql = forward("exportDatabaseSql");
export const cancelDatabaseExport = forward("cancelDatabaseExport");
export const clearDatabaseExportCancellation = forward("clearDatabaseExportCancellation");
export const databaseExportDestinationNeedsConfirmation = forward("databaseExportDestinationNeedsConfirmation");
export const recordDatabaseExportDestination = forward("recordDatabaseExportDestination");
export const exportQueryResultCsv = forward("exportQueryResultCsv");
export const exportTableDataCsv = forward("exportTableDataCsv");
+4
View File
@@ -2480,6 +2480,10 @@ export async function clearDatabaseExportCancellation(_exportId: string): Promis
// The web exporter owns and clears its cancellation marker on completion.
}
export async function databaseExportDestinationNeedsConfirmation(_directory: string): Promise<boolean> {
throw new Error("Scheduled database backups are only available in the desktop app.");
}
export async function recordDatabaseExportDestination(_directory: string): Promise<void> {
throw new Error("Scheduled database backups are only available in the desktop app.");
}
+4
View File
@@ -5055,6 +5055,10 @@ export async function clearDatabaseExportCancellation(exportId: string): Promise
await invoke("clear_database_export_cancellation", { exportId });
}
export async function databaseExportDestinationNeedsConfirmation(directory: string): Promise<boolean> {
return invoke("database_export_destination_needs_confirmation", { directory });
}
export async function recordDatabaseExportDestination(directory: string): Promise<void> {
await invoke("record_database_export_destination", { directory });
}