diff --git a/app/client/hooks/__tests__/use-server-events.test.tsx b/app/client/hooks/__tests__/use-server-events.test.tsx
index 2e106f3d..5887c68d 100644
--- a/app/client/hooks/__tests__/use-server-events.test.tsx
+++ b/app/client/hooks/__tests__/use-server-events.test.tsx
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { useEffect, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
import { cleanup, createTestQueryClient, render, screen } from "~/test/test-utils";
import { useServerEvents } from "../use-server-events";
@@ -67,6 +68,15 @@ const BackupCompletedListener = ({ scheduleId }: { scheduleId: string }) => {
return
{status}
;
};
+const QueryStatusConsumer = ({ getValue }: { getValue: () => string }) => {
+ const { data } = useQuery({
+ queryKey: ["backup-status"],
+ queryFn: async () => getValue(),
+ });
+
+ return {data}
;
+};
+
describe("useServerEvents", () => {
beforeEach(() => {
MockEventSource.reset();
@@ -83,22 +93,23 @@ describe("useServerEvents", () => {
MockEventSource.reset();
});
- test("shares one EventSource across consumers and invalidates queries once on backup completion", async () => {
+ test("shares one EventSource across consumers and refreshes active queries on backup completion", async () => {
const queryClient = createTestQueryClient();
- const invalidateQueries = vi.fn(async () => undefined);
- const refetchQueries = vi.fn(async () => undefined);
- queryClient.invalidateQueries = invalidateQueries as typeof queryClient.invalidateQueries;
- queryClient.refetchQueries = refetchQueries as typeof queryClient.refetchQueries;
+ let queryValue = "before";
- render(
+ const view = render(
<>
+ queryValue} />
>,
{ queryClient },
);
expect(MockEventSource.instances).toHaveLength(1);
+ expect(await screen.findByText("before")).toBeTruthy();
+
+ queryValue = "after";
MockEventSource.instances[0]?.emit("backup:completed", {
organizationId: "default-org",
@@ -109,10 +120,9 @@ describe("useServerEvents", () => {
});
expect(await screen.findByText("success")).toBeTruthy();
- expect(invalidateQueries).toHaveBeenCalledTimes(1);
- expect(refetchQueries).not.toHaveBeenCalled();
+ expect(await screen.findByText("after")).toBeTruthy();
- cleanup();
+ view.unmount();
expect(MockEventSource.instances[0]?.close).toHaveBeenCalledTimes(1);
});
diff --git a/app/client/modules/auth/routes/__tests__/login.test.tsx b/app/client/modules/auth/routes/__tests__/login.test.tsx
index a50f4fe3..13c18da4 100644
--- a/app/client/modules/auth/routes/__tests__/login.test.tsx
+++ b/app/client/modules/auth/routes/__tests__/login.test.tsx
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, test, vi } from "vitest";
+import { HttpResponse, http, server } from "~/test/msw/server";
import { cleanup, render, screen } from "~/test/test-utils";
vi.mock("@tanstack/react-router", async (importOriginal) => {
@@ -10,27 +11,40 @@ vi.mock("@tanstack/react-router", async (importOriginal) => {
};
});
-vi.mock("~/client/modules/sso/components/sso-login-section", () => ({
- SsoLoginSection: () => <>>,
-}));
-
import { LoginPage } from "../login";
const inviteOnlyMessage =
"Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO.";
+const mockSsoProvidersRequest = (
+ providers: Array<{
+ providerId: string;
+ organizationSlug: string;
+ }> = [],
+) => {
+ server.use(
+ http.get("/api/v1/auth/sso-providers", () => {
+ return HttpResponse.json({ providers });
+ }),
+ );
+};
+
afterEach(() => {
cleanup();
});
describe("LoginPage", () => {
test("shows an invite-only message when SSO returns INVITE_REQUIRED code", async () => {
- render();
+ mockSsoProvidersRequest();
+
+ render(, { withSuspense: true });
expect(await screen.findByText(inviteOnlyMessage)).toBeTruthy();
});
test("shows account link required message when SSO returns ACCOUNT_LINK_REQUIRED code", async () => {
- render();
+ mockSsoProvidersRequest();
+
+ render(, { withSuspense: true });
expect(
await screen.findByText(
@@ -40,7 +54,9 @@ describe("LoginPage", () => {
});
test("shows banned message when SSO returns BANNED_USER code", async () => {
- render();
+ mockSsoProvidersRequest();
+
+ render(, { withSuspense: true });
expect(
await screen.findByText(
@@ -50,21 +66,35 @@ describe("LoginPage", () => {
});
test("shows email not verified message when SSO returns EMAIL_NOT_VERIFIED code", async () => {
- render();
+ mockSsoProvidersRequest();
+
+ render(, { withSuspense: true });
expect(await screen.findByText("Your identity provider did not mark your email as verified.")).toBeTruthy();
});
test("shows generic SSO error message when SSO returns SSO_LOGIN_FAILED code", async () => {
- render();
+ mockSsoProvidersRequest();
+
+ render(, { withSuspense: true });
expect(await screen.findByText("SSO authentication failed. Please try again.")).toBeTruthy();
});
test("does not show error message for invalid error codes", async () => {
- render();
+ mockSsoProvidersRequest();
+
+ render(, { withSuspense: true });
expect(await screen.findByText("Login to your account")).toBeTruthy();
expect(screen.queryByText(inviteOnlyMessage)).toBeNull();
});
+
+ test("renders available SSO providers from the real SSO section", async () => {
+ mockSsoProvidersRequest([{ providerId: "acme", organizationSlug: "acme-org" }]);
+
+ render(, { withSuspense: true });
+
+ expect(await screen.findByRole("button", { name: "Log in with acme" })).toBeTruthy();
+ });
});
diff --git a/app/client/modules/backups/routes/__tests__/backup-details.test.tsx b/app/client/modules/backups/routes/__tests__/backup-details.test.tsx
index 1fdfd625..26863d30 100644
--- a/app/client/modules/backups/routes/__tests__/backup-details.test.tsx
+++ b/app/client/modules/backups/routes/__tests__/backup-details.test.tsx
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
-import { afterEach, describe, expect, test, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { fromAny } from "@total-typescript/shoehorn";
import { HttpResponse, http, server } from "~/test/msw/server";
import { cleanup, render, screen } from "~/test/test-utils";
@@ -15,26 +15,6 @@ vi.mock("@tanstack/react-router", async (importOriginal) => {
};
});
-vi.mock("~/client/components/backup-summary-card", () => ({
- BackupSummaryCard: () => <>>,
-}));
-
-vi.mock("~/client/modules/backups/components/schedule-summary", () => ({
- ScheduleSummary: () => <>>,
-}));
-
-vi.mock("~/client/modules/backups/components/snapshot-timeline", () => ({
- SnapshotTimeline: () => <>>,
-}));
-
-vi.mock("~/client/modules/backups/components/schedule-notifications-config", () => ({
- ScheduleNotificationsConfig: () => <>>,
-}));
-
-vi.mock("~/client/modules/backups/components/schedule-mirrors-config", () => ({
- ScheduleMirrorsConfig: () => <>>,
-}));
-
vi.mock("~/client/lib/datetime", async (importOriginal) => {
const actual = await importOriginal();
@@ -47,8 +27,35 @@ vi.mock("~/client/lib/datetime", async (importOriginal) => {
};
});
+vi.mock("~/client/modules/backups/components/schedule-notifications-config", () => ({
+ ScheduleNotificationsConfig: () => null,
+}));
+
+vi.mock("~/client/modules/backups/components/schedule-mirrors-config", () => ({
+ ScheduleMirrorsConfig: () => null,
+}));
+
+vi.mock("~/client/modules/backups/components/schedule-summary", () => ({
+ ScheduleSummary: ({ schedule }: { schedule: { name: string } }) => (
+
+
{schedule.name}
+
+
+ ),
+}));
+
import { ScheduleDetailsPage } from "../backup-details";
+class MockEventSource {
+ addEventListener() {}
+ close() {}
+ onerror: ((event: Event) => void) | null = null;
+
+ constructor(public url: string) {}
+}
+
+const originalEventSource = globalThis.EventSource;
+
const schedule = {
shortId: "backup-1",
name: "Backup 1",
@@ -62,6 +69,10 @@ const schedule = {
enabled: true,
cronExpression: "0 0 * * *",
retentionPolicy: null,
+ lastBackupAt: 1711411200000,
+ nextBackupAt: 1711497600000,
+ lastBackupStatus: null,
+ lastBackupError: null,
includePaths: ["/project"],
includePatterns: [],
excludePatterns: [],
@@ -74,8 +85,26 @@ const snapshot = {
short_id: "snap-1",
paths: ["/mnt/project"],
tags: ["backup-1"],
- time: "2026-03-26T00:00:00.000Z",
- summary: {},
+ time: new Date("2026-03-26T00:00:00.000Z").getTime(),
+ size: 2097152,
+ duration: 12,
+ retentionCategories: [],
+ summary: {
+ files_new: 10,
+ files_changed: 5,
+ files_unmodified: 85,
+ dirs_new: 2,
+ dirs_changed: 1,
+ dirs_unmodified: 17,
+ data_blobs: 20,
+ tree_blobs: 5,
+ data_added: 1048576,
+ data_added_packed: 524288,
+ total_files_processed: 100,
+ total_bytes_processed: 2097152,
+ backup_start: "2026-03-26T00:00:00.000Z",
+ backup_end: "2026-03-26T00:00:12.000Z",
+ },
};
const mockScheduleDetailsRequests = () => {
@@ -94,15 +123,32 @@ const mockScheduleDetailsRequests = () => {
],
});
}),
+ http.get("/api/v1/backups/:shortId/progress", () => {
+ return HttpResponse.json(null);
+ }),
+ http.get("/api/v1/backups/:shortId/notifications", () => {
+ return HttpResponse.json([]);
+ }),
+ http.get("/api/v1/backups/:shortId/mirrors", () => {
+ return HttpResponse.json([]);
+ }),
+ http.get("/api/v1/backups/:shortId/mirrors/compatibility", () => {
+ return HttpResponse.json([]);
+ }),
);
};
+beforeEach(() => {
+ globalThis.EventSource = MockEventSource as unknown as typeof EventSource;
+});
+
afterEach(() => {
+ globalThis.EventSource = originalEventSource;
cleanup();
});
describe("ScheduleDetailsPage", () => {
- test("shows the selected snapshot from the volume root on the backup details page", async () => {
+ test("renders the real schedule details page with the selected snapshot", async () => {
mockScheduleDetailsRequests();
render(
@@ -113,16 +159,21 @@ describe("ScheduleDetailsPage", () => {
repos: [],
scheduleNotifs: [],
mirrors: [],
- snapshotTimelineSortOrder: "newest",
+ snapshotTimelineSortOrder: "desc",
snapshots: [snapshot],
})}
scheduleId="backup-1"
initialSnapshotId="snap-1"
- initialSnapshotSortOrder={fromAny("newest")}
+ initialSnapshotSortOrder="desc"
/>,
{ withSuspense: true },
);
+ expect(await screen.findByRole("heading", { name: "Backup 1" })).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Backup now" })).toBeTruthy();
+ expect(screen.getByText("Snapshots")).toBeTruthy();
+ expect(screen.getByText("Files processed")).toBeTruthy();
+ expect(screen.getByRole("link", { name: /restore/i })).toBeTruthy();
expect(await screen.findByRole("button", { name: "project" })).toBeTruthy();
});
});
diff --git a/app/server/modules/backups/__tests__/backups.execution.test.ts b/app/server/modules/backups/__tests__/backups.execution.test.ts
index 0064d45d..feb444ee 100644
--- a/app/server/modules/backups/__tests__/backups.execution.test.ts
+++ b/app/server/modules/backups/__tests__/backups.execution.test.ts
@@ -13,8 +13,6 @@ import * as spawnModule from "@zerobyte/core/node";
import type { SafeSpawnParams } from "@zerobyte/core/node";
import { restic } from "~/server/core/restic";
import { NotFoundError, BadRequestError } from "http-errors-enhanced";
-import { scheduleQueries } from "../backups.queries";
-import { fromAny } from "@total-typescript/shoehorn";
import { repositoriesService } from "~/server/modules/repositories/repositories.service";
import { repoMutex } from "~/server/core/repository-mutex";
@@ -76,67 +74,6 @@ describe("backup execution - validation failures", () => {
expect(resticBackupMock).not.toHaveBeenCalled();
});
- test("should fail backup when volume does not exist", async () => {
- // arrange
- setup();
- const volume = await createTestVolume();
- const repository = await createTestRepository();
- const schedule = await createTestBackupSchedule({
- volumeId: volume.id,
- repositoryId: repository.id,
- });
-
- const hydratedSchedule = await scheduleQueries.findById(schedule.id, TEST_ORG_ID);
- expect(hydratedSchedule).toBeDefined();
- const scheduleWithoutVolume = {
- ...hydratedSchedule,
- volume: null,
- };
- vi.spyOn(scheduleQueries, "findById").mockResolvedValueOnce(fromAny(scheduleWithoutVolume));
-
- // act
- const result = await backupsExecutionService.validateBackupExecution(schedule.id);
-
- // assert
- expect(result.type).toBe("failure");
- if (result.type === "failure") {
- expect(result.error).toBeInstanceOf(NotFoundError);
- expect(result.error.message).toBe("Volume not found");
- expect(result.partialContext?.schedule).toBeDefined();
- }
- });
-
- test("should fail backup when repository does not exist", async () => {
- // arrange
- setup();
- const volume = await createTestVolume();
- const repository = await createTestRepository();
- const schedule = await createTestBackupSchedule({
- volumeId: volume.id,
- repositoryId: repository.id,
- });
-
- const hydratedSchedule = await scheduleQueries.findById(schedule.id, TEST_ORG_ID);
- expect(hydratedSchedule).toBeDefined();
- const scheduleWithoutRepository = {
- ...hydratedSchedule,
- repository: null,
- };
- vi.spyOn(scheduleQueries, "findById").mockResolvedValueOnce(fromAny(scheduleWithoutRepository));
-
- // act
- const result = await backupsExecutionService.validateBackupExecution(schedule.id);
-
- // assert
- expect(result.type).toBe("failure");
- if (result.type === "failure") {
- expect(result.error).toBeInstanceOf(NotFoundError);
- expect(result.error.message).toBe("Repository not found");
- expect(result.partialContext?.schedule).toBeDefined();
- expect(result.partialContext?.volume).toBeDefined();
- }
- });
-
test("should fail backup when schedule does not exist", async () => {
setup();
// act
diff --git a/app/server/modules/backups/__tests__/backups.service.test.ts b/app/server/modules/backups/__tests__/backups.service.test.ts
index d69137c1..357e5051 100644
--- a/app/server/modules/backups/__tests__/backups.service.test.ts
+++ b/app/server/modules/backups/__tests__/backups.service.test.ts
@@ -110,6 +110,9 @@ describe("execute backup", () => {
// assert
expect(resticBackupMock).toHaveBeenCalled();
+ const updatedSchedule = await backupsService.getScheduleById(schedule.id);
+ expect(updatedSchedule.lastBackupStatus).toBe("success");
+ expect(updatedSchedule.lastBackupAt).not.toBeNull();
});
test("should keep next backup time empty for manual-only schedules after a manual run", async () => {
diff --git a/app/server/modules/repositories/__tests__/repositories.controller.test.ts b/app/server/modules/repositories/__tests__/repositories.controller.test.ts
index c9cd0446..f65cdb07 100644
--- a/app/server/modules/repositories/__tests__/repositories.controller.test.ts
+++ b/app/server/modules/repositories/__tests__/repositories.controller.test.ts
@@ -356,19 +356,22 @@ describe("repositories updates", () => {
describe("dump snapshot", () => {
test("continues streaming a download after the request signal aborts", async () => {
const repository = await createRepositoryRecord(session.organizationId);
- const { repositoriesService } = await import("~/server/modules/repositories/repositories.service");
-
const stream = new PassThrough();
const expectedContent = "downloaded snapshot contents";
- const dumpSnapshotSpy = vi.spyOn(repositoriesService, "dumpSnapshot").mockResolvedValue({
+ const snapshotsSpy = vi.spyOn(restic, "snapshots").mockResolvedValue([
+ {
+ id: "test-snapshot",
+ short_id: "test-snapshot",
+ time: new Date().toISOString(),
+ paths: ["/mnt/project"],
+ hostname: "host",
+ },
+ ]);
+ const dumpSpy = vi.spyOn(restic, "dump").mockResolvedValue({
stream,
completion: Promise.resolve(),
- abort: () => {
- stream.destroy(new Error("download aborted"));
- },
- filename: "snapshot.txt",
- contentType: "application/octet-stream",
+ abort: vi.fn(),
});
try {
@@ -385,38 +388,48 @@ describe("repositories updates", () => {
await expect(response.text()).resolves.toBe(expectedContent);
} finally {
- dumpSnapshotSpy.mockRestore();
+ snapshotsSpy.mockRestore();
+ dumpSpy.mockRestore();
}
});
test("returns a valid content-disposition header for non-ascii filenames", async () => {
const repository = await createRepositoryRecord(session.organizationId);
- const { repositoriesService } = await import("~/server/modules/repositories/repositories.service");
const stream = new PassThrough();
- const dumpSnapshotSpy = vi.spyOn(repositoriesService, "dumpSnapshot").mockResolvedValue({
+ const snapshotsSpy = vi.spyOn(restic, "snapshots").mockResolvedValue([
+ {
+ id: "test-snapshot",
+ short_id: "test-snapshot",
+ time: new Date().toISOString(),
+ paths: ["/mnt/project"],
+ hostname: "host",
+ },
+ ]);
+ const dumpSpy = vi.spyOn(restic, "dump").mockResolvedValue({
stream,
completion: Promise.resolve(),
- abort: () => {
- stream.destroy(new Error("download aborted"));
- },
- filename: "möte.txt",
- contentType: "application/octet-stream",
+ abort: vi.fn(),
});
try {
stream.end("downloaded snapshot contents");
- const response = await app.request(`/api/v1/repositories/${repository.shortId}/snapshots/test-snapshot/dump`, {
- headers: session.headers,
- });
+ const encodedPath = encodeURIComponent("/mnt/project/möte.txt");
+ const response = await app.request(
+ `/api/v1/repositories/${repository.shortId}/snapshots/test-snapshot/dump?path=${encodedPath}&kind=file`,
+ {
+ headers: session.headers,
+ },
+ );
expect(response.status).toBe(200);
expect(response.headers.get("Content-Disposition")).toBe(
`attachment; filename="m?te.txt"; filename*=UTF-8''m%C3%B6te.txt`,
);
} finally {
- dumpSnapshotSpy.mockRestore();
+ snapshotsSpy.mockRestore();
+ dumpSpy.mockRestore();
}
});
});
diff --git a/app/server/modules/repositories/__tests__/repositories.service.test.ts b/app/server/modules/repositories/__tests__/repositories.service.test.ts
index e5068b88..bc4ac038 100644
--- a/app/server/modules/repositories/__tests__/repositories.service.test.ts
+++ b/app/server/modules/repositories/__tests__/repositories.service.test.ts
@@ -1,3 +1,4 @@
+import waitForExpect from "wait-for-expect";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import nodePath from "node:path";
@@ -6,7 +7,6 @@ import { Readable } from "node:stream";
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import type { RepositoryConfig } from "@zerobyte/core/restic";
import { REPOSITORY_BASE } from "~/server/core/constants";
-import { serverEvents } from "~/server/core/events";
import { withContext } from "~/server/core/request-context";
import { db } from "~/server/db/db";
import { repositoriesTable } from "~/server/db/schema";
@@ -201,12 +201,21 @@ describe("repositoriesService.dumpSnapshot", () => {
vi.restoreAllMocks();
});
- const createDumpResult = () => ({
- stream: Readable.from([]),
+ const createDumpResult = (payload: string) => ({
+ stream: Readable.from([payload]),
completion: Promise.resolve(),
abort: () => {},
});
+ const readStreamText = async (stream: Readable) => {
+ const chunks: Buffer[] = [];
+ for await (const chunk of stream) {
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
+ }
+
+ return Buffer.concat(chunks).toString("utf8");
+ };
+
const setupDumpSnapshotScenario = async ({
snapshotId,
basePath,
@@ -243,7 +252,18 @@ describe("repositoriesService.dumpSnapshot", () => {
},
]);
- const dumpMock = vi.fn(() => Promise.resolve(createDumpResult()));
+ const dumpMock = vi.fn(
+ (_config: unknown, snapshotRef: string, options: { organizationId: string; path: string; archive?: false }) =>
+ Promise.resolve(
+ createDumpResult(
+ JSON.stringify({
+ snapshotRef,
+ path: options.path,
+ archive: options.archive !== false,
+ }),
+ ),
+ ),
+ );
vi.spyOn(restic, "dump").mockImplementation(dumpMock);
return {
@@ -251,45 +271,33 @@ describe("repositoriesService.dumpSnapshot", () => {
userId: session.user.id,
shortId,
basePath,
- dumpMock,
};
};
- test("calls restic.dump with common-ancestor selector and stripped path", async () => {
- const { organizationId, userId, shortId, basePath, dumpMock } = await setupDumpSnapshotScenario({
+ test("returns a tar download rooted at the selected directory within the snapshot", async () => {
+ const { organizationId, userId, shortId, basePath } = await setupDumpSnapshotScenario({
snapshotId: "snapshot-123",
basePath: "/var/lib/zerobyte/volumes/vol123/_data",
});
- const emitSpy = vi.spyOn(serverEvents, "emit");
- await withContext({ organizationId, userId }, () =>
+ const result = await withContext({ organizationId, userId }, () =>
repositoriesService.dumpSnapshot(shortId, "snapshot-123", `${basePath}/documents`, "dir"),
);
- expect(dumpMock).toHaveBeenCalledTimes(1);
- expect(dumpMock).toHaveBeenCalledWith(
- expect.objectContaining({
- backend: "local",
- }),
- `snapshot-123:${basePath}`,
- {
- organizationId,
- path: "/documents",
- },
- );
- expect(emitSpy).toHaveBeenCalledWith(
- "dump:started",
- expect.objectContaining({
- organizationId,
- repositoryId: shortId,
- snapshotId: "snapshot-123",
+ expect(result.filename).toBe("snapshot-snapshot-123.tar");
+ expect(result.contentType).toBe("application/x-tar");
+ expect(await readStreamText(result.stream)).toBe(
+ JSON.stringify({
+ snapshotRef: `snapshot-123:${basePath}`,
path: "/documents",
+ archive: true,
}),
);
+ await expect(result.completion).resolves.toBeUndefined();
});
test("streams a single file directly when selected path is a file", async () => {
- const { organizationId, userId, shortId, basePath, dumpMock } = await setupDumpSnapshotScenario({
+ const { organizationId, userId, shortId, basePath } = await setupDumpSnapshotScenario({
snapshotId: "snapshot-file",
basePath: "/var/lib/zerobyte/volumes/vol123/_data",
});
@@ -298,31 +306,38 @@ describe("repositoriesService.dumpSnapshot", () => {
repositoriesService.dumpSnapshot(shortId, "snapshot-file", `${basePath}/documents/report.txt`, "file"),
);
- expect(dumpMock).toHaveBeenCalledWith(expect.anything(), `snapshot-file:${basePath}`, {
- organizationId,
- path: "/documents/report.txt",
- archive: false,
- });
expect(result.filename).toBe("report.txt");
expect(result.contentType).toBe("application/octet-stream");
+ expect(await readStreamText(result.stream)).toBe(
+ JSON.stringify({
+ snapshotRef: `snapshot-file:${basePath}`,
+ path: "/documents/report.txt",
+ archive: false,
+ }),
+ );
});
test("downloads a selected parent directory when snapshot paths point to a nested file", async () => {
const parentPath = "/var/lib/zerobyte/volumes/vol123/_data/documents";
- const { organizationId, userId, shortId, dumpMock } = await setupDumpSnapshotScenario({
+ const { organizationId, userId, shortId } = await setupDumpSnapshotScenario({
snapshotId: "snapshot-parent-dir",
basePath: `${parentPath}/report.txt`,
snapshotPaths: [`${parentPath}/report.txt`],
});
- await withContext({ organizationId, userId }, () =>
+ const result = await withContext({ organizationId, userId }, () =>
repositoriesService.dumpSnapshot(shortId, "snapshot-parent-dir", parentPath, "dir"),
);
- expect(dumpMock).toHaveBeenCalledWith(expect.anything(), `snapshot-parent-dir:${parentPath}`, {
- organizationId,
- path: "/",
- });
+ expect(result.filename).toBe("snapshot-snapshot-parent-dir.tar");
+ expect(result.contentType).toBe("application/x-tar");
+ expect(await readStreamText(result.stream)).toBe(
+ JSON.stringify({
+ snapshotRef: `snapshot-parent-dir:${parentPath}`,
+ path: "/",
+ archive: true,
+ }),
+ );
});
test("rejects path downloads without a kind", async () => {
@@ -338,18 +353,25 @@ describe("repositoriesService.dumpSnapshot", () => {
).rejects.toThrow("Path kind is required when downloading a specific snapshot path");
});
- test("downloads full snapshot relative to common ancestor when path is omitted", async () => {
- const { organizationId, userId, shortId, basePath, dumpMock } = await setupDumpSnapshotScenario({
+ test("downloads the full snapshot from the common ancestor when path is omitted", async () => {
+ const { organizationId, userId, shortId, basePath } = await setupDumpSnapshotScenario({
snapshotId: "snapshot-999",
basePath: "/var/lib/zerobyte/volumes/vol555/_data",
});
- await withContext({ organizationId, userId }, () => repositoriesService.dumpSnapshot(shortId, "snapshot-999"));
+ const result = await withContext({ organizationId, userId }, () =>
+ repositoriesService.dumpSnapshot(shortId, "snapshot-999"),
+ );
- expect(dumpMock).toHaveBeenCalledWith(expect.anything(), `snapshot-999:${basePath}`, {
- organizationId,
- path: "/",
- });
+ expect(result.filename).toBe("snapshot-snapshot-999.tar");
+ expect(result.contentType).toBe("application/x-tar");
+ expect(await readStreamText(result.stream)).toBe(
+ JSON.stringify({
+ snapshotRef: `snapshot-999:${basePath}`,
+ path: "/",
+ archive: true,
+ }),
+ );
});
});
@@ -522,9 +544,9 @@ describe("repositoriesService.deleteSnapshot", () => {
repositoriesService.deleteSnapshot(repository.shortId, "snap-1"),
);
- await new Promise((resolve) => setTimeout(resolve, 20));
-
- expect(statsSpy).toHaveBeenCalledTimes(1);
+ await waitForExpect(() => {
+ expect(statsSpy).toHaveBeenCalledTimes(1);
+ });
const updatedRepository = await db.query.repositoriesTable.findFirst({ where: { id: repository.id } });
expect(updatedRepository?.stats).toEqual(expectedStats);
diff --git a/app/server/modules/system/__tests__/system.controller.test.ts b/app/server/modules/system/__tests__/system.controller.test.ts
index 4d659d9d..989e250f 100644
--- a/app/server/modules/system/__tests__/system.controller.test.ts
+++ b/app/server/modules/system/__tests__/system.controller.test.ts
@@ -105,15 +105,20 @@ describe("system security", () => {
describe("updates endpoint", () => {
test("GET /api/v1/system/updates should be accessible with valid session", async () => {
- vi.spyOn(systemService, "getUpdates").mockResolvedValue({
+ const expectedUpdates = {
currentVersion: "1.0.0",
latestVersion: "1.0.0",
hasUpdate: false,
missedReleases: [],
+ };
+
+ vi.spyOn(systemService, "getUpdates").mockResolvedValue({
+ ...expectedUpdates,
});
const res = await app.request("/api/v1/system/updates", { headers: session.headers });
expect(res.status).toBe(200);
+ expect(await res.json()).toEqual(expectedUpdates);
});
});
diff --git a/packages/core/src/restic/commands/__tests__/backup.test.ts b/packages/core/src/restic/commands/__tests__/backup.test.ts
index 554cee32..fdf7aca8 100644
--- a/packages/core/src/restic/commands/__tests__/backup.test.ts
+++ b/packages/core/src/restic/commands/__tests__/backup.test.ts
@@ -61,7 +61,7 @@ type SetupOptions = {
const setup = ({ spawnResult = {}, onSpawnCall }: SetupOptions = {}) => {
let capturedArgs: string[] = [];
- const cleanupSpy = vi.spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
+ vi.spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
vi.spyOn(spawnModule, "safeSpawn").mockImplementation((params) => {
capturedArgs = params.args;
return Promise.resolve(onSpawnCall?.(params)).then(() => ({
@@ -73,7 +73,6 @@ const setup = ({ spawnResult = {}, onSpawnCall }: SetupOptions = {}) => {
});
return {
- cleanupSpy,
getArgs: () => capturedArgs,
hasFlag: (flag: string) => capturedArgs.includes(flag),
getOptionValues: (option: string): string[] => {
@@ -114,27 +113,11 @@ describe("backup command", () => {
expect(getArgs().at(-1)).toBe(source);
});
- test("uses --files-from instead of source path when include list is provided", async () => {
- const { hasFlag, getArgs } = setup();
- await backup(
- config,
- "/mnt/data",
- {
- organizationId: "org-1",
- includePatterns: ["/mnt/data/docs", "/mnt/data/photos"],
- },
- mockDeps,
- );
-
- expect(hasFlag("--files-from")).toBe(true);
- expect(getArgs()).not.toContain("/mnt/data");
- });
-
- test("passes include paths via --files-from-raw and include patterns via --files-from", async () => {
+ test("writes include paths and patterns to separate files instead of passing the source path directly", async () => {
const literalDir = "/mnt/data/movies [1]";
let rawIncludeContent = "";
let patternIncludeContent = "";
- const { hasFlag } = setup({
+ const { getArgs, hasFlag } = setup({
onSpawnCall: async (params) => {
const rawIncludeIndex = params.args.indexOf("--files-from-raw");
const patternIncludeIndex = params.args.indexOf("--files-from");
@@ -162,110 +145,17 @@ describe("backup command", () => {
expect(hasFlag("--files-from-raw")).toBe(true);
expect(hasFlag("--files-from")).toBe(true);
+ expect(getArgs()).not.toContain("/mnt/data");
expect(rawIncludeContent).toBe(`${literalDir}\0`);
expect(patternIncludeContent).toBe("/mnt/data/**/*.zip");
});
- test("adds --tag for each entry in options.tags", async () => {
- const { getOptionValues } = setup();
- await backup(
- config,
- "/mnt/data",
- {
- organizationId: "org-1",
- tags: ["tag-a", "tag-b"],
- },
- mockDeps,
- );
-
- expect(getOptionValues("--tag")).toEqual(["tag-a", "tag-b"]);
- });
-
- test("omits --tag when tags list is empty", async () => {
- const { hasFlag } = setup();
- await backup(config, "/mnt/data", { organizationId: "org-1", tags: [] }, mockDeps);
-
- expect(hasFlag("--tag")).toBe(false);
- });
-
- test("passes provided compressionMode to --compression", async () => {
- const { getOptionValues } = setup();
- await backup(config, "/mnt/data", { organizationId: "org-1", compressionMode: "max" }, mockDeps);
-
- expect(getOptionValues("--compression")).toEqual(["max"]);
- });
-
- test("defaults --compression to auto when compressionMode is omitted", async () => {
- const { getOptionValues } = setup();
- await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps);
-
- expect(getOptionValues("--compression")).toEqual(["auto"]);
- });
-
- test("adds --one-file-system when oneFileSystem is true", async () => {
- const { hasFlag } = setup();
- await backup(config, "/mnt/data", { organizationId: "org-1", oneFileSystem: true }, mockDeps);
-
- expect(hasFlag("--one-file-system")).toBe(true);
- });
-
- test("omits --one-file-system when oneFileSystem is false", async () => {
- const { hasFlag } = setup();
- await backup(config, "/mnt/data", { organizationId: "org-1", oneFileSystem: false }, mockDeps);
-
- expect(hasFlag("--one-file-system")).toBe(false);
- });
-
- test("adds --exclude-file when exclude list is provided", async () => {
- const { hasFlag } = setup();
- await backup(
- config,
- "/mnt/data",
- {
- organizationId: "org-1",
- exclude: ["/mnt/data/tmp", "/mnt/data/cache"],
- },
- mockDeps,
- );
-
- expect(hasFlag("--exclude-file")).toBe(true);
- });
-
- test("omits --exclude-file when exclude list is empty", async () => {
- const { hasFlag } = setup();
- await backup(config, "/mnt/data", { organizationId: "org-1", exclude: [] }, mockDeps);
-
- expect(hasFlag("--exclude-file")).toBe(false);
- });
-
- test("adds --exclude-if-present for each entry in excludeIfPresent", async () => {
- const { getOptionValues } = setup();
- await backup(
- config,
- "/mnt/data",
- {
- organizationId: "org-1",
- excludeIfPresent: [".nobackup", ".gitignore"],
- },
- mockDeps,
- );
-
- expect(getOptionValues("--exclude-if-present")).toEqual([".nobackup", ".gitignore"]);
- });
-
test("always includes DEFAULT_EXCLUDES as --exclude args", async () => {
const { getOptionValues } = setup();
await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps);
expect(getOptionValues("--exclude").length).toBeGreaterThan(0);
});
-
- test("includes --host arg from config", async () => {
- const { hasFlag } = setup();
- await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps);
-
- expect(hasFlag("--host")).toBe(true);
- });
});
describe("exit code handling", () => {
@@ -427,20 +317,4 @@ describe("backup command", () => {
expect(progressUpdates).toHaveLength(0);
});
});
-
- describe("cleanup", () => {
- test("calls cleanupTemporaryKeys after a successful backup", async () => {
- const { cleanupSpy } = setup();
- await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps);
-
- expect(cleanupSpy).toHaveBeenCalledTimes(1);
- });
-
- test("calls cleanupTemporaryKeys even when the command fails", async () => {
- const { cleanupSpy } = setup({ spawnResult: { exitCode: 1, summary: "", error: "fail" } });
- await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps).catch(() => {});
-
- expect(cleanupSpy).toHaveBeenCalledTimes(1);
- });
- });
});
diff --git a/packages/core/src/restic/commands/__tests__/copy.test.ts b/packages/core/src/restic/commands/__tests__/copy.test.ts
index e4ec40f5..3450902e 100644
--- a/packages/core/src/restic/commands/__tests__/copy.test.ts
+++ b/packages/core/src/restic/commands/__tests__/copy.test.ts
@@ -51,17 +51,8 @@ describe("copy command", () => {
await copy(sourceConfig, destConfig, { organizationId: "org-1", snapshotId, tag: "daily" }, mockDeps);
- expect(getArgs()).toEqual([
- "--repo",
- "/tmp/dest-repo",
- "copy",
- "--from-repo",
- "/tmp/source-repo",
- "--tag",
- "daily",
- "--json",
- "--",
- snapshotId,
- ]);
+ const separatorIndex = getArgs().indexOf("--");
+ expect(separatorIndex).toBeGreaterThan(-1);
+ expect(getArgs().slice(separatorIndex + 1)).toEqual([snapshotId]);
});
});
diff --git a/packages/core/src/restic/commands/__tests__/delete-snapshots.test.ts b/packages/core/src/restic/commands/__tests__/delete-snapshots.test.ts
index 460dca03..052545b8 100644
--- a/packages/core/src/restic/commands/__tests__/delete-snapshots.test.ts
+++ b/packages/core/src/restic/commands/__tests__/delete-snapshots.test.ts
@@ -44,6 +44,8 @@ describe("deleteSnapshots command", () => {
await deleteSnapshots(config, snapshotIds, "org-1", mockDeps);
- expect(getArgs()).toEqual(["--repo", "/tmp/restic-repo", "forget", "--prune", "--json", "--", ...snapshotIds]);
+ const separatorIndex = getArgs().indexOf("--");
+ expect(separatorIndex).toBeGreaterThan(-1);
+ expect(getArgs().slice(separatorIndex + 1)).toEqual(snapshotIds);
});
});
diff --git a/packages/core/src/restic/commands/__tests__/dump.test.ts b/packages/core/src/restic/commands/__tests__/dump.test.ts
index 8d1f2b54..9048fc02 100644
--- a/packages/core/src/restic/commands/__tests__/dump.test.ts
+++ b/packages/core/src/restic/commands/__tests__/dump.test.ts
@@ -1,4 +1,3 @@
-import { PassThrough } from "node:stream";
import { spawn } from "node:child_process";
import { afterEach, describe, expect, test, vi } from "vitest";
import * as cleanupModule from "../../helpers/cleanup-temporary-keys";
@@ -27,7 +26,7 @@ const setup = () => {
vi.spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
vi.spyOn(nodeModule, "safeSpawn").mockImplementation((params) => {
capturedArgs = params.args;
- const child = { stdout: new PassThrough() } as unknown as ReturnType;
+ const child = spawn(process.execPath, ["-e", ""]);
params.onSpawn?.(child);
return Promise.resolve({ exitCode: 0, summary: "", error: "" });
});
@@ -48,15 +47,8 @@ describe("dump command", () => {
const result = await dump(config, "--help", { organizationId: "org-1", path: "folder/file.txt" }, mockDeps);
await result.completion;
- expect(getArgs()).toEqual([
- "--repo",
- "/tmp/restic-repo",
- "dump",
- "--archive",
- "tar",
- "--",
- "--help",
- "/folder/file.txt",
- ]);
+ const separatorIndex = getArgs().indexOf("--");
+ expect(separatorIndex).toBeGreaterThan(-1);
+ expect(getArgs().slice(separatorIndex + 1)).toEqual(["--help", "/folder/file.txt"]);
});
});
diff --git a/packages/core/src/restic/commands/__tests__/ls.test.ts b/packages/core/src/restic/commands/__tests__/ls.test.ts
index ea68c563..ebe21c85 100644
--- a/packages/core/src/restic/commands/__tests__/ls.test.ts
+++ b/packages/core/src/restic/commands/__tests__/ls.test.ts
@@ -58,6 +58,8 @@ describe("ls command", () => {
await ls(config, snapshotId, "org-1", path, undefined, mockDeps);
- expect(getArgs()).toEqual(["--repo", "/tmp/restic-repo", "ls", "--long", "--json", "--", snapshotId, path]);
+ const separatorIndex = getArgs().indexOf("--");
+ expect(separatorIndex).toBeGreaterThan(-1);
+ expect(getArgs().slice(separatorIndex + 1)).toEqual([snapshotId, path]);
});
});
diff --git a/packages/core/src/restic/commands/__tests__/restore.test.ts b/packages/core/src/restic/commands/__tests__/restore.test.ts
index 07d85712..ba731f44 100644
--- a/packages/core/src/restic/commands/__tests__/restore.test.ts
+++ b/packages/core/src/restic/commands/__tests__/restore.test.ts
@@ -1,8 +1,10 @@
import { afterEach, describe, expect, test, vi } from "vitest";
+import * as cleanupModule from "../../helpers/cleanup-temporary-keys";
import * as spawnModule from "../../../utils/spawn";
+import { ResticError } from "../../error";
import { restore } from "../restore";
import type { ResticDeps } from "../../types";
-import type { SafeSpawnParams } from "../../../utils/spawn";
+import type { SafeSpawnParams, SpawnResult } from "../../../utils/spawn";
const mockDeps: ResticDeps = {
resolveSecret: async (s) => s,
@@ -14,11 +16,22 @@ const mockDeps: ResticDeps = {
const successfulRestoreSummary = JSON.stringify({
message_type: "summary",
+ total_files: 2,
files_restored: 1,
- files_skipped: 0,
+ files_skipped: 1,
bytes_skipped: 0,
});
+const validProgressLine = JSON.stringify({
+ message_type: "status",
+ seconds_elapsed: 5,
+ percent_done: 0.5,
+ total_files: 2,
+ files_restored: 1,
+ total_bytes: 1024,
+ bytes_restored: 512,
+});
+
const config = {
backend: "local" as const,
path: "/tmp/restic-repo",
@@ -26,16 +39,23 @@ const config = {
customPassword: "custom-password",
};
-/**
- * Sets up the safeSpawn mock and returns helpers to inspect the restic args
- * that were built for the restore command.
- */
-const setup = () => {
+type SetupOptions = {
+ spawnResult?: Partial;
+ onSpawnCall?: (params: SafeSpawnParams) => void | Promise;
+};
+
+const setup = ({ spawnResult = {}, onSpawnCall }: SetupOptions = {}) => {
let capturedArgs: string[] = [];
+ vi.spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
vi.spyOn(spawnModule, "safeSpawn").mockImplementation((params: SafeSpawnParams) => {
capturedArgs = params.args;
- return Promise.resolve({ exitCode: 0, summary: successfulRestoreSummary, error: "" });
+ return Promise.resolve(onSpawnCall?.(params)).then(() => ({
+ exitCode: 0,
+ summary: successfulRestoreSummary,
+ error: "",
+ ...spawnResult,
+ }));
});
const getRestoreArg = () => {
@@ -46,7 +66,7 @@ const setup = () => {
return capturedArgs[separatorIndex + 1]!;
};
- const getOptionValues = (option: string): string[] => {
+ const getOptionValues = (option: string) => {
const values: string[] = [];
for (let i = 0; i < capturedArgs.length - 1; i++) {
if (capturedArgs[i] === option && capturedArgs[i + 1]) {
@@ -68,123 +88,168 @@ afterEach(() => {
});
describe("restore command", () => {
- test("keeps snapshot restore arg and absolute include paths when target is root", async () => {
- const { getRestoreArg, getOptionValues } = setup();
- await restore(
- config,
- "snapshot-123",
- "/",
- {
- organizationId: "org-1",
- include: [
- "/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf",
- "/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg",
- ],
- },
- mockDeps,
- );
+ describe("path selection", () => {
+ test("uses the common ancestor as restore root and strips includes for non-root targets", async () => {
+ const { getRestoreArg, getOptionValues } = setup();
- expect(getRestoreArg()).toBe("snapshot-123");
- expect(getOptionValues("--include")).toEqual([
- "/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf",
- "/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg",
- ]);
+ await restore(
+ config,
+ "snapshot-456",
+ "/tmp/restore-target",
+ {
+ organizationId: "org-1",
+ include: [
+ "/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf",
+ "/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg",
+ ],
+ },
+ mockDeps,
+ );
+
+ expect(getRestoreArg()).toBe("snapshot-456:/var/lib/zerobyte/volumes/vol123/_data");
+ expect(getOptionValues("--include")).toEqual(["Documents/report.pdf", "Photos/summer.jpg"]);
+ });
+
+ test("restores a selected file from its parent directory for non-root targets", async () => {
+ const { getRestoreArg, getOptionValues } = setup();
+
+ await restore(
+ config,
+ "snapshot-single-file",
+ "/tmp/restore-target",
+ {
+ organizationId: "org-1",
+ include: ["/var/lib/zerobyte/volumes/vol123/_data/archive/backup.20260301-233001.7z"],
+ selectedItemKind: "file",
+ },
+ mockDeps,
+ );
+
+ expect(getRestoreArg()).toBe("snapshot-single-file:/var/lib/zerobyte/volumes/vol123/_data/archive");
+ expect(getOptionValues("--include")).toEqual(["backup.20260301-233001.7z"]);
+ });
+
+ test("treats flag-like snapshot IDs as positional restore args", async () => {
+ const { getArgs, getRestoreArg } = setup();
+
+ await restore(
+ config,
+ "--help",
+ "/tmp/restore-target",
+ {
+ organizationId: "org-1",
+ basePath: "/var/lib/zerobyte/volumes/vol123/_data",
+ },
+ mockDeps,
+ );
+
+ const separatorIndex = getArgs().indexOf("--");
+ expect(separatorIndex).toBeGreaterThan(-1);
+ expect(getRestoreArg()).toBe("--help:/var/lib/zerobyte/volumes/vol123/_data");
+ });
});
- test("restores from common ancestor and strips include paths for non-root targets", async () => {
- const { getRestoreArg, getOptionValues } = setup();
- await restore(
- config,
- "snapshot-456",
- "/tmp/restore-target",
- {
- organizationId: "org-1",
- include: [
- "/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf",
- "/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg",
- ],
- },
- mockDeps,
- );
+ describe("output handling", () => {
+ test("returns a parsed restore summary on success", async () => {
+ setup();
- expect(getRestoreArg()).toBe("snapshot-456:/var/lib/zerobyte/volumes/vol123/_data");
- expect(getOptionValues("--include")).toEqual(["Documents/report.pdf", "Photos/summer.jpg"]);
+ const result = await restore(
+ config,
+ "snapshot-123",
+ "/tmp/restore-target",
+ { organizationId: "org-1", basePath: "/var/lib/zerobyte/volumes/vol123/_data" },
+ mockDeps,
+ );
+
+ expect(result).toMatchObject({
+ message_type: "summary",
+ files_restored: 1,
+ files_skipped: 1,
+ });
+ });
+
+ test("throws ResticError when the command fails", async () => {
+ setup({ spawnResult: { exitCode: 1, summary: "", error: "restore failed" } });
+
+ await expect(
+ restore(
+ config,
+ "snapshot-123",
+ "/tmp/restore-target",
+ { organizationId: "org-1", basePath: "/var/lib/zerobyte/volumes/vol123/_data" },
+ mockDeps,
+ ),
+ ).rejects.toBeInstanceOf(ResticError);
+ });
+
+ test("falls back to an empty summary when restic output cannot be parsed", async () => {
+ setup({ spawnResult: { summary: "not-json" } });
+
+ const result = await restore(
+ config,
+ "snapshot-123",
+ "/tmp/restore-target",
+ { organizationId: "org-1", basePath: "/var/lib/zerobyte/volumes/vol123/_data" },
+ mockDeps,
+ );
+
+ expect(result).toEqual({
+ message_type: "summary",
+ total_files: 0,
+ files_restored: 0,
+ files_skipped: 0,
+ bytes_skipped: 0,
+ });
+ });
});
- test("uses base path for non-root restore when includes are omitted", async () => {
- const { getRestoreArg, getOptionValues } = setup();
- await restore(
- config,
- "snapshot-789",
- "/tmp/restore-target",
- {
- organizationId: "org-1",
- basePath: "/var/lib/zerobyte/volumes/vol123/_data",
- },
- mockDeps,
- );
+ describe("progress callbacks", () => {
+ test("calls onProgress with parsed status updates", async () => {
+ const progressUpdates: unknown[] = [];
+ setup({ onSpawnCall: (params) => params.onStdout?.(validProgressLine) });
- expect(getRestoreArg()).toBe("snapshot-789:/var/lib/zerobyte/volumes/vol123/_data");
- expect(getOptionValues("--include")).toEqual([]);
- });
+ await restore(
+ config,
+ "snapshot-123",
+ "/tmp/restore-target",
+ {
+ organizationId: "org-1",
+ basePath: "/var/lib/zerobyte/volumes/vol123/_data",
+ onProgress: (progress) => progressUpdates.push(progress),
+ },
+ mockDeps,
+ );
- test("restores a single selected file from its parent directory for non-root targets", async () => {
- const { getRestoreArg, getOptionValues } = setup();
- const options: Parameters[3] & { selectedItemKind: "file" } = {
- organizationId: "org-1",
- include: ["/var/lib/zerobyte/volumes/vol123/_data/archive/backup.20260301-233001.7z"],
- selectedItemKind: "file",
- };
+ expect(progressUpdates).toHaveLength(1);
+ expect(progressUpdates[0]).toMatchObject({
+ message_type: "status",
+ percent_done: 0.5,
+ files_restored: 1,
+ });
+ });
- await restore(config, "snapshot-single-file", "/tmp/restore-target", options, mockDeps);
+ test("ignores non-JSON progress lines", async () => {
+ const progressUpdates: unknown[] = [];
+ setup({
+ onSpawnCall: (params) => {
+ params.onStdout?.("scanning...");
+ params.onStdout?.("repository opened");
+ },
+ });
- expect(getRestoreArg()).toBe("snapshot-single-file:/var/lib/zerobyte/volumes/vol123/_data/archive");
- expect(getOptionValues("--include")).toEqual(["backup.20260301-233001.7z"]);
- });
+ await restore(
+ config,
+ "snapshot-123",
+ "/tmp/restore-target",
+ {
+ organizationId: "org-1",
+ basePath: "/var/lib/zerobyte/volumes/vol123/_data",
+ onProgress: (progress) => progressUpdates.push(progress),
+ },
+ mockDeps,
+ );
- test("does not pass an empty include when include equals restore root", async () => {
- const { getArgs, getRestoreArg, getOptionValues } = setup();
- await restore(
- config,
- "snapshot-7202d8cc",
- "/Users/nicolas/Documents/restore",
- {
- organizationId: "org-1",
- include: ["/Users/nicolas/Developer/zerobyte/tmp/deep/test/files"],
- overwrite: "always",
- },
- mockDeps,
- );
-
- expect(getRestoreArg()).toBe("snapshot-7202d8cc:/Users/nicolas/Developer/zerobyte/tmp/deep/test/files");
- expect(getOptionValues("--include")).toEqual([]);
- expect(getArgs()).not.toContain("");
- });
-
- test("treats flag-like snapshot IDs as positional restore args", async () => {
- const { getArgs, getRestoreArg } = setup();
-
- await restore(
- config,
- "--help",
- "/tmp/restore-target",
- {
- organizationId: "org-1",
- basePath: "/var/lib/zerobyte/volumes/vol123/_data",
- },
- mockDeps,
- );
-
- expect(getRestoreArg()).toBe("--help:/var/lib/zerobyte/volumes/vol123/_data");
- expect(getArgs()).toEqual([
- "--repo",
- "/tmp/restic-repo",
- "restore",
- "--target",
- "/tmp/restore-target",
- "--json",
- "--",
- "--help:/var/lib/zerobyte/volumes/vol123/_data",
- ]);
+ expect(progressUpdates).toHaveLength(0);
+ });
});
});
diff --git a/packages/core/src/restic/commands/__tests__/tag-snapshots.test.ts b/packages/core/src/restic/commands/__tests__/tag-snapshots.test.ts
index 8bb63d6b..8d412505 100644
--- a/packages/core/src/restic/commands/__tests__/tag-snapshots.test.ts
+++ b/packages/core/src/restic/commands/__tests__/tag-snapshots.test.ts
@@ -44,6 +44,8 @@ describe("tagSnapshots command", () => {
await tagSnapshots(config, snapshotIds, { add: ["keep"] }, "org-1", mockDeps);
- expect(getArgs()).toEqual(["--repo", "/tmp/restic-repo", "tag", "--add", "keep", "--json", "--", ...snapshotIds]);
+ const separatorIndex = getArgs().indexOf("--");
+ expect(separatorIndex).toBeGreaterThan(-1);
+ expect(getArgs().slice(separatorIndex + 1)).toEqual(snapshotIds);
});
});