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; type BackupTaskProgress = Extract; type BackupTaskResult = Extract; 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[0]> = {}) => taskStore.create({ organizationId: TEST_ORG_ID, resourceType: "backup_schedule", resourceId: "1", targetAgentId: "local", input: backupInput(), ...overrides, }); const createRestoreTask = (overrides: Partial[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(); });