205 lines
8.4 KiB
TypeScript
Vendored
205 lines
8.4 KiB
TypeScript
Vendored
import { describe, expect, it, vi } from "vitest";
|
|
import type { DatabaseBackupExecutionConfig, DatabaseBackupRun } from "../../lib/backup/scheduledDatabaseBackup";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
connection: { id: "mysql-1", name: "Local MySQL", db_type: "mysql" },
|
|
listDatabases: vi.fn(async () => [{ name: "app" }]),
|
|
beginSnapshot: vi.fn(async () => ({ sessionId: "snapshot-1" })),
|
|
rollbackSnapshot: vi.fn(async () => {}),
|
|
deleteFiles: vi.fn(async () => 0),
|
|
cancelExport: vi.fn(async () => {}),
|
|
clearExportCancellation: vi.fn(async () => {}),
|
|
exportDatabase: vi.fn(),
|
|
runDatabaseExport: vi.fn(),
|
|
addTask: vi.fn(),
|
|
registerCancel: vi.fn(),
|
|
unregisterCancel: vi.fn(),
|
|
updateTask: vi.fn(),
|
|
markTaskCancelling: vi.fn(),
|
|
restoreTaskRunning: vi.fn(),
|
|
nextId: 0,
|
|
}));
|
|
|
|
vi.mock("@/lib/backend/api", () => ({
|
|
listDatabases: mocks.listDatabases,
|
|
beginDatabaseBackupSnapshot: mocks.beginSnapshot,
|
|
rollbackManualTransaction: mocks.rollbackSnapshot,
|
|
deleteDatabaseBackupFiles: mocks.deleteFiles,
|
|
cancelDatabaseExport: mocks.cancelExport,
|
|
clearDatabaseExportCancellation: mocks.clearExportCancellation,
|
|
}));
|
|
|
|
vi.mock("@/lib/export/databaseExport", () => ({
|
|
buildAllDatabaseExportPlan: () => [{ database: "app", schema: "app", fileStem: "app", displayName: "app" }],
|
|
generateDatabaseExportId: () => `run-${++mocks.nextId}`,
|
|
runDatabaseExportUntilTerminal: mocks.runDatabaseExport,
|
|
}));
|
|
|
|
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
|
|
vi.mock("@/stores/connectionStore", () => ({
|
|
useConnectionStore: () => ({
|
|
getConfig: () => mocks.connection,
|
|
ensureConnected: vi.fn(async () => {}),
|
|
initFromDisk: vi.fn(async () => {}),
|
|
}),
|
|
}));
|
|
vi.mock("@/composables/useExportTracker", () => ({
|
|
useExportTracker: () => ({
|
|
addDatabaseExportTask: mocks.addTask,
|
|
registerTaskCancelHandler: mocks.registerCancel,
|
|
unregisterTaskCancelHandler: mocks.unregisterCancel,
|
|
updateDatabaseExportTask: mocks.updateTask,
|
|
markDatabaseExportTaskCancelling: mocks.markTaskCancelling,
|
|
restoreDatabaseExportTaskRunning: mocks.restoreTaskRunning,
|
|
}),
|
|
}));
|
|
|
|
import { useScheduledDatabaseBackups } from "../useScheduledDatabaseBackups";
|
|
|
|
const config: DatabaseBackupExecutionConfig = {
|
|
connectionId: "mysql-1",
|
|
databases: ["app"],
|
|
tableFilterMode: "all",
|
|
tablePatterns: [],
|
|
destinationDirectory: "/backups",
|
|
includeStructure: true,
|
|
includeData: true,
|
|
includeObjects: true,
|
|
dropTableIfExists: false,
|
|
outputCompression: "none",
|
|
};
|
|
|
|
describe("useScheduledDatabaseBackups one-shot execution", () => {
|
|
it("executes one-shot backups through the shared exporter without a schedule", async () => {
|
|
mocks.runDatabaseExport.mockImplementationOnce(async (_request: unknown, onProgress: (progress: unknown) => void) => {
|
|
onProgress({ status: "Done", objectIndex: 1, totalObjects: 1, currentObject: "app" });
|
|
return { status: "Done", objectIndex: 1, totalObjects: 1, currentObject: "app" };
|
|
});
|
|
|
|
const backup = useScheduledDatabaseBackups();
|
|
const scheduleCountBefore = backup.schedules.value.length;
|
|
const run = await backup.runOneShot(config, "One-time backup");
|
|
|
|
expect(run).toEqual(expect.objectContaining({ status: "success", source: "one-shot", trigger: "manual", scheduleId: undefined }));
|
|
expect(run).not.toHaveProperty("nextRunAt");
|
|
expect(backup.schedules.value).toHaveLength(scheduleCountBefore);
|
|
expect(mocks.addTask).toHaveBeenCalledWith(expect.any(String), "One-time backup", "/backups", "manual");
|
|
});
|
|
|
|
it("prevents a second one-shot while one is already running", async () => {
|
|
const backup = useScheduledDatabaseBackups();
|
|
const activeRun = {
|
|
id: "active-one-shot",
|
|
scheduleName: "One-time backup",
|
|
connectionId: "mysql-1",
|
|
connectionName: "Local MySQL",
|
|
trigger: "manual",
|
|
source: "one-shot",
|
|
status: "running",
|
|
startedAt: new Date().toISOString(),
|
|
files: [],
|
|
} satisfies DatabaseBackupRun;
|
|
backup.runs.value.push(activeRun);
|
|
backup.activeRunIds.add(activeRun.id);
|
|
const exportCallCount = mocks.runDatabaseExport.mock.calls.length;
|
|
|
|
try {
|
|
expect(await backup.runOneShot(config, "One-time backup")).toBeNull();
|
|
expect(mocks.runDatabaseExport).toHaveBeenCalledTimes(exportCallCount);
|
|
} finally {
|
|
backup.activeRunIds.delete(activeRun.id);
|
|
backup.runs.value = backup.runs.value.filter((run: DatabaseBackupRun) => run.id !== activeRun.id);
|
|
}
|
|
});
|
|
|
|
it("cancels an active one-shot through the shared export cancellation path", async () => {
|
|
mocks.cancelExport.mockClear();
|
|
mocks.deleteFiles.mockClear();
|
|
let resolveExport!: (progress: { status: "Cancelled"; objectIndex: number; totalObjects: number; currentObject: string }) => void;
|
|
mocks.runDatabaseExport.mockImplementationOnce(
|
|
(_request: unknown, onProgress: (progress: unknown) => void) =>
|
|
new Promise((resolve) => {
|
|
resolveExport = (progress) => {
|
|
onProgress(progress);
|
|
resolve(progress);
|
|
};
|
|
}),
|
|
);
|
|
|
|
const backup = useScheduledDatabaseBackups();
|
|
const pendingRun = backup.runOneShot(config, "One-time backup");
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
const activeRunId = [...backup.activeRunIds][0];
|
|
expect(activeRunId).toBeTruthy();
|
|
|
|
await backup.cancelRun(activeRunId!);
|
|
expect(mocks.cancelExport).toHaveBeenCalledWith(activeRunId);
|
|
expect(mocks.cancelExport).toHaveBeenCalledWith(`${activeRunId}-1`);
|
|
expect(mocks.markTaskCancelling).toHaveBeenCalledWith(activeRunId);
|
|
expect(backup.cancellingRunIds.has(activeRunId!)).toBe(true);
|
|
await backup.cancelRun(activeRunId!);
|
|
expect(mocks.cancelExport).toHaveBeenCalledTimes(2);
|
|
resolveExport({ status: "Cancelled", objectIndex: 0, totalObjects: 1, currentObject: "app" });
|
|
|
|
const finishedRun = await pendingRun;
|
|
expect(finishedRun).toEqual(expect.objectContaining({ status: "cancelled", source: "one-shot", files: [] }));
|
|
expect(mocks.deleteFiles).toHaveBeenCalledWith([expect.stringContaining(activeRunId!)]);
|
|
expect(backup.runs.value.find((run) => run.id === activeRunId)).toEqual(expect.objectContaining({ status: "cancelled", files: [] }));
|
|
expect(backup.cancellingRunIds.has(activeRunId!)).toBe(false);
|
|
});
|
|
|
|
it("stops after cancelling during database discovery without creating a snapshot", async () => {
|
|
mocks.beginSnapshot.mockClear();
|
|
mocks.cancelExport.mockClear();
|
|
let resolveDatabases!: (databases: Array<{ name: string }>) => void;
|
|
mocks.listDatabases.mockImplementationOnce(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
resolveDatabases = resolve;
|
|
}),
|
|
);
|
|
|
|
const backup = useScheduledDatabaseBackups();
|
|
const pendingRun = backup.runOneShot(config, "One-time backup");
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
const activeRunId = [...backup.activeRunIds][0];
|
|
expect(activeRunId).toBeTruthy();
|
|
|
|
await backup.cancelRun(activeRunId!);
|
|
expect(mocks.cancelExport).toHaveBeenCalledWith(activeRunId);
|
|
resolveDatabases([{ name: "app" }]);
|
|
|
|
const finishedRun = await pendingRun;
|
|
expect(finishedRun).toEqual(expect.objectContaining({ status: "cancelled", files: [] }));
|
|
expect(mocks.beginSnapshot).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("signals the backup run while snapshot creation is pending", async () => {
|
|
mocks.beginSnapshot.mockClear();
|
|
mocks.cancelExport.mockClear();
|
|
let resolveSnapshot!: (snapshot: { sessionId: string }) => void;
|
|
mocks.beginSnapshot.mockImplementationOnce(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
resolveSnapshot = resolve;
|
|
}),
|
|
);
|
|
|
|
const backup = useScheduledDatabaseBackups();
|
|
const pendingRun = backup.runOneShot(config, "One-time backup");
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
const activeRunId = [...backup.activeRunIds][0];
|
|
expect(activeRunId).toBeTruthy();
|
|
expect(mocks.beginSnapshot).toHaveBeenCalledWith("mysql-1", "app", activeRunId);
|
|
|
|
await backup.cancelRun(activeRunId!);
|
|
expect(mocks.cancelExport).toHaveBeenCalledWith(activeRunId);
|
|
resolveSnapshot({ sessionId: "snapshot-pending" });
|
|
|
|
const finishedRun = await pendingRun;
|
|
expect(finishedRun).toEqual(expect.objectContaining({ status: "cancelled", files: [] }));
|
|
expect(mocks.rollbackSnapshot).toHaveBeenCalledWith("snapshot-pending");
|
|
expect(mocks.clearExportCancellation).toHaveBeenCalledWith(activeRunId);
|
|
});
|
|
});
|