* feat: snapshot restores through rpc * fix(restore): do not wait for lock before returning response * chore: fix liniting issue
251 lines
7.8 KiB
TypeScript
251 lines
7.8 KiB
TypeScript
import { beforeEach, expect, test } from "vitest";
|
|
import { db } from "~/server/db/db";
|
|
import { tasksTable } from "~/server/db/schema";
|
|
import { ensureTestOrganization, TEST_ORG_ID } from "~/test/helpers/organization";
|
|
import { generateBackupOutput } from "~/test/helpers/restic";
|
|
import { taskStore } from "../tasks.store";
|
|
import type { TaskInput, TaskProgress, TaskResult } from "../tasks.schemas";
|
|
|
|
type BackupTaskInput = Extract<TaskInput, { kind: "backup" }>;
|
|
type BackupTaskProgress = Extract<TaskProgress, { kind: "backup" }>;
|
|
type BackupTaskResult = Extract<TaskResult, { kind: "backup" }>;
|
|
|
|
const backupInput = (scheduleId = 1): BackupTaskInput => ({
|
|
kind: "backup",
|
|
scheduleId,
|
|
scheduleShortId: `schedule-${scheduleId}`,
|
|
manual: false,
|
|
});
|
|
|
|
const backupProgress = (percentDone = 0.5): BackupTaskProgress => ({
|
|
kind: "backup",
|
|
progress: {
|
|
message_type: "status",
|
|
seconds_elapsed: 1,
|
|
seconds_remaining: 1,
|
|
percent_done: percentDone,
|
|
total_files: 10,
|
|
files_done: 5,
|
|
total_bytes: 100,
|
|
bytes_done: 50,
|
|
current_files: [],
|
|
},
|
|
});
|
|
|
|
const backupResult = (): BackupTaskResult => ({
|
|
kind: "backup",
|
|
exitCode: 0,
|
|
result: JSON.parse(generateBackupOutput()),
|
|
warningDetails: null,
|
|
});
|
|
|
|
const createBackupTask = (overrides: Partial<Parameters<typeof taskStore.create>[0]> = {}) =>
|
|
taskStore.create({
|
|
organizationId: TEST_ORG_ID,
|
|
resourceType: "backup_schedule",
|
|
resourceId: "1",
|
|
targetAgentId: "local",
|
|
input: backupInput(),
|
|
...overrides,
|
|
});
|
|
|
|
const createRestoreTask = (overrides: Partial<Parameters<typeof taskStore.create>[0]> = {}) =>
|
|
taskStore.create({
|
|
organizationId: TEST_ORG_ID,
|
|
resourceType: "repository",
|
|
resourceId: "repo-short",
|
|
targetAgentId: "local",
|
|
input: { kind: "restore", repositoryId: "repo-short", snapshotId: "snapshot-1", target: "/tmp/restore" },
|
|
...overrides,
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await ensureTestOrganization();
|
|
await db.delete(tasksTable);
|
|
});
|
|
|
|
test("creates queued backup tasks with parsed input and durable metadata only", () => {
|
|
const task = createBackupTask({
|
|
id: "task-create",
|
|
input: { ...backupInput(12), manual: true },
|
|
resourceId: "12",
|
|
});
|
|
|
|
expect(task).toMatchObject({
|
|
id: "task-create",
|
|
organizationId: TEST_ORG_ID,
|
|
kind: "backup",
|
|
status: "queued",
|
|
resourceType: "backup_schedule",
|
|
resourceId: "12",
|
|
targetAgentId: "local",
|
|
input: {
|
|
kind: "backup",
|
|
scheduleId: 12,
|
|
scheduleShortId: "schedule-12",
|
|
manual: true,
|
|
},
|
|
progress: null,
|
|
result: null,
|
|
error: null,
|
|
cancellationRequested: false,
|
|
startedAt: null,
|
|
finishedAt: null,
|
|
});
|
|
expect(Object.keys(task.input)).toEqual(["kind", "scheduleId", "scheduleShortId", "manual"]);
|
|
});
|
|
|
|
test("moves an active task through running, progress, cancellation request, and success", () => {
|
|
const task = createBackupTask({ id: "task-success" });
|
|
|
|
const running = taskStore.markRunning(task.id);
|
|
expect(running.status).toBe("running");
|
|
expect(running.startedAt).toEqual(expect.any(Number));
|
|
|
|
const progressed = taskStore.updateProgress(task.id, backupProgress(0.7));
|
|
expect(progressed.progress?.progress.percent_done).toBe(0.7);
|
|
|
|
const cancelling = taskStore.requestCancel(task.id);
|
|
expect(cancelling.status).toBe("cancelling");
|
|
expect(cancelling.cancellationRequested).toBe(true);
|
|
|
|
const completed = taskStore.complete(task.id, backupResult());
|
|
expect(completed.status).toBe("succeeded");
|
|
expect(completed.result?.kind).toBe("backup");
|
|
if (completed.result?.kind !== "backup") {
|
|
throw new Error("Expected backup result");
|
|
}
|
|
expect(completed.result?.result?.snapshot_id).toBe("abcd1234");
|
|
expect(completed.finishedAt).toEqual(expect.any(Number));
|
|
expect(completed.cancellationRequested).toBe(true);
|
|
});
|
|
|
|
test("records failed and cancelled terminal task states", () => {
|
|
const failedTask = createBackupTask({ id: "task-failed", resourceId: "failed" });
|
|
const failed = taskStore.fail(failedTask.id, "restic failed");
|
|
expect(failed.status).toBe("failed");
|
|
expect(failed.error).toBe("restic failed");
|
|
expect(failed.finishedAt).toEqual(expect.any(Number));
|
|
|
|
const cancelledTask = createBackupTask({ id: "task-cancelled", resourceId: "cancelled" });
|
|
taskStore.requestCancel(cancelledTask.id);
|
|
const cancelled = taskStore.cancel(cancelledTask.id, "Backup was stopped by the user");
|
|
expect(cancelled.status).toBe("cancelled");
|
|
expect(cancelled.error).toBe("Backup was stopped by the user");
|
|
expect(cancelled.cancellationRequested).toBe(true);
|
|
});
|
|
|
|
test("moves restore tasks through progress and success", () => {
|
|
const task = createRestoreTask({ id: "restore-task" });
|
|
|
|
const running = taskStore.markRunning(task.id);
|
|
expect(running.kind).toBe("restore");
|
|
expect(running.input).toMatchObject({ kind: "restore", snapshotId: "snapshot-1" });
|
|
|
|
const progressed = taskStore.updateProgress(task.id, {
|
|
kind: "restore",
|
|
progress: {
|
|
message_type: "status",
|
|
seconds_elapsed: 2,
|
|
percent_done: 0.25,
|
|
total_files: 4,
|
|
files_restored: 1,
|
|
total_bytes: 400,
|
|
bytes_restored: 100,
|
|
},
|
|
});
|
|
expect(progressed.progress?.progress.percent_done).toBe(0.25);
|
|
|
|
const completed = taskStore.complete(task.id, {
|
|
kind: "restore",
|
|
result: {
|
|
message_type: "summary",
|
|
total_files: 4,
|
|
files_restored: 4,
|
|
files_skipped: 0,
|
|
},
|
|
});
|
|
expect(completed.status).toBe("succeeded");
|
|
expect(completed.result?.kind).toBe("restore");
|
|
if (completed.result?.kind !== "restore") {
|
|
throw new Error("Expected restore result");
|
|
}
|
|
expect(completed.result?.result.files_restored).toBe(4);
|
|
});
|
|
|
|
test("finds the newest active task for a resource and marks only matching active tasks stale", async () => {
|
|
createBackupTask({ id: "task-a-old", resourceId: "shared" });
|
|
const newest = createBackupTask({ id: "task-z-new", resourceId: "shared" });
|
|
const otherResource = createBackupTask({ id: "task-other", resourceId: "other" });
|
|
const terminal = taskStore.complete(
|
|
createBackupTask({ id: "task-terminal", resourceId: "shared" }).id,
|
|
backupResult(),
|
|
);
|
|
|
|
const active = taskStore.findActiveByResource({
|
|
organizationId: TEST_ORG_ID,
|
|
kind: "backup",
|
|
resourceType: "backup_schedule",
|
|
resourceId: "shared",
|
|
});
|
|
expect(active?.id).toBe(newest.id);
|
|
|
|
const staleTasks = taskStore.markActiveStale({
|
|
organizationId: TEST_ORG_ID,
|
|
kind: "backup",
|
|
resourceType: "backup_schedule",
|
|
resourceId: "shared",
|
|
error: "process restarted",
|
|
});
|
|
expect(staleTasks.map((task) => task.id).sort()).toEqual(["task-a-old", "task-z-new"]);
|
|
|
|
const other = await db.query.tasksTable.findFirst({ where: { id: otherResource.id } });
|
|
const completed = await db.query.tasksTable.findFirst({ where: { id: terminal.id } });
|
|
expect(other?.status).toBe("queued");
|
|
expect(completed?.status).toBe("succeeded");
|
|
});
|
|
|
|
test("parses task JSON on reads and rejects invalid persisted shapes", () => {
|
|
db.insert(tasksTable)
|
|
.values({
|
|
id: "task-invalid-json",
|
|
organizationId: TEST_ORG_ID,
|
|
kind: "backup",
|
|
status: "queued",
|
|
resourceType: "backup_schedule",
|
|
resourceId: "invalid",
|
|
input: {
|
|
kind: "backup",
|
|
scheduleId: "not-a-number",
|
|
scheduleShortId: "schedule-invalid",
|
|
manual: false,
|
|
},
|
|
cancellationRequested: false,
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
})
|
|
.run();
|
|
|
|
expect(() =>
|
|
taskStore.findActiveByResource({
|
|
organizationId: TEST_ORG_ID,
|
|
kind: "backup",
|
|
resourceType: "backup_schedule",
|
|
resourceId: "invalid",
|
|
}),
|
|
).toThrow();
|
|
});
|
|
|
|
test("terminal updates do not mutate unrelated task rows", async () => {
|
|
const task = createBackupTask({ id: "task-target", resourceId: "target" });
|
|
const unrelated = createBackupTask({ id: "task-unrelated", resourceId: "unrelated" });
|
|
|
|
taskStore.complete(task.id, backupResult());
|
|
|
|
const targetRow = await db.query.tasksTable.findFirst({ where: { id: task.id } });
|
|
const unrelatedRow = await db.query.tasksTable.findFirst({ where: { id: unrelated.id } });
|
|
|
|
expect(targetRow?.status).toBe("succeeded");
|
|
expect(unrelatedRow?.status).toBe("queued");
|
|
expect(unrelatedRow?.finishedAt).toBeNull();
|
|
});
|