test: refactor some tests to verify behavior instead of implementation details
This commit is contained in:
parent
0c43320fed
commit
d8bf4c2ef3
15 changed files with 449 additions and 450 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { cleanup, createTestQueryClient, render, screen } from "~/test/test-utils";
|
import { cleanup, createTestQueryClient, render, screen } from "~/test/test-utils";
|
||||||
import { useServerEvents } from "../use-server-events";
|
import { useServerEvents } from "../use-server-events";
|
||||||
|
|
||||||
|
|
@ -67,6 +68,15 @@ const BackupCompletedListener = ({ scheduleId }: { scheduleId: string }) => {
|
||||||
return <div>{status}</div>;
|
return <div>{status}</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const QueryStatusConsumer = ({ getValue }: { getValue: () => string }) => {
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ["backup-status"],
|
||||||
|
queryFn: async () => getValue(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return <div>{data}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
describe("useServerEvents", () => {
|
describe("useServerEvents", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
MockEventSource.reset();
|
MockEventSource.reset();
|
||||||
|
|
@ -83,22 +93,23 @@ describe("useServerEvents", () => {
|
||||||
MockEventSource.reset();
|
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 queryClient = createTestQueryClient();
|
||||||
const invalidateQueries = vi.fn(async () => undefined);
|
let queryValue = "before";
|
||||||
const refetchQueries = vi.fn(async () => undefined);
|
|
||||||
queryClient.invalidateQueries = invalidateQueries as typeof queryClient.invalidateQueries;
|
|
||||||
queryClient.refetchQueries = refetchQueries as typeof queryClient.refetchQueries;
|
|
||||||
|
|
||||||
render(
|
const view = render(
|
||||||
<>
|
<>
|
||||||
<ConnectionConsumer />
|
<ConnectionConsumer />
|
||||||
<BackupCompletedListener scheduleId="0b9c940b" />
|
<BackupCompletedListener scheduleId="0b9c940b" />
|
||||||
|
<QueryStatusConsumer getValue={() => queryValue} />
|
||||||
</>,
|
</>,
|
||||||
{ queryClient },
|
{ queryClient },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(MockEventSource.instances).toHaveLength(1);
|
expect(MockEventSource.instances).toHaveLength(1);
|
||||||
|
expect(await screen.findByText("before")).toBeTruthy();
|
||||||
|
|
||||||
|
queryValue = "after";
|
||||||
|
|
||||||
MockEventSource.instances[0]?.emit("backup:completed", {
|
MockEventSource.instances[0]?.emit("backup:completed", {
|
||||||
organizationId: "default-org",
|
organizationId: "default-org",
|
||||||
|
|
@ -109,10 +120,9 @@ describe("useServerEvents", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await screen.findByText("success")).toBeTruthy();
|
expect(await screen.findByText("success")).toBeTruthy();
|
||||||
expect(invalidateQueries).toHaveBeenCalledTimes(1);
|
expect(await screen.findByText("after")).toBeTruthy();
|
||||||
expect(refetchQueries).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
cleanup();
|
view.unmount();
|
||||||
|
|
||||||
expect(MockEventSource.instances[0]?.close).toHaveBeenCalledTimes(1);
|
expect(MockEventSource.instances[0]?.close).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { HttpResponse, http, server } from "~/test/msw/server";
|
||||||
import { cleanup, render, screen } from "~/test/test-utils";
|
import { cleanup, render, screen } from "~/test/test-utils";
|
||||||
|
|
||||||
vi.mock("@tanstack/react-router", async (importOriginal) => {
|
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";
|
import { LoginPage } from "../login";
|
||||||
const inviteOnlyMessage =
|
const inviteOnlyMessage =
|
||||||
"Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO.";
|
"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(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("LoginPage", () => {
|
describe("LoginPage", () => {
|
||||||
test("shows an invite-only message when SSO returns INVITE_REQUIRED code", async () => {
|
test("shows an invite-only message when SSO returns INVITE_REQUIRED code", async () => {
|
||||||
render(<LoginPage error="INVITE_REQUIRED" />);
|
mockSsoProvidersRequest();
|
||||||
|
|
||||||
|
render(<LoginPage error="INVITE_REQUIRED" />, { withSuspense: true });
|
||||||
|
|
||||||
expect(await screen.findByText(inviteOnlyMessage)).toBeTruthy();
|
expect(await screen.findByText(inviteOnlyMessage)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows account link required message when SSO returns ACCOUNT_LINK_REQUIRED code", async () => {
|
test("shows account link required message when SSO returns ACCOUNT_LINK_REQUIRED code", async () => {
|
||||||
render(<LoginPage error="ACCOUNT_LINK_REQUIRED" />);
|
mockSsoProvidersRequest();
|
||||||
|
|
||||||
|
render(<LoginPage error="ACCOUNT_LINK_REQUIRED" />, { withSuspense: true });
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText(
|
await screen.findByText(
|
||||||
|
|
@ -40,7 +54,9 @@ describe("LoginPage", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows banned message when SSO returns BANNED_USER code", async () => {
|
test("shows banned message when SSO returns BANNED_USER code", async () => {
|
||||||
render(<LoginPage error="BANNED_USER" />);
|
mockSsoProvidersRequest();
|
||||||
|
|
||||||
|
render(<LoginPage error="BANNED_USER" />, { withSuspense: true });
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText(
|
await screen.findByText(
|
||||||
|
|
@ -50,21 +66,35 @@ describe("LoginPage", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows email not verified message when SSO returns EMAIL_NOT_VERIFIED code", async () => {
|
test("shows email not verified message when SSO returns EMAIL_NOT_VERIFIED code", async () => {
|
||||||
render(<LoginPage error="EMAIL_NOT_VERIFIED" />);
|
mockSsoProvidersRequest();
|
||||||
|
|
||||||
|
render(<LoginPage error="EMAIL_NOT_VERIFIED" />, { withSuspense: true });
|
||||||
|
|
||||||
expect(await screen.findByText("Your identity provider did not mark your email as verified.")).toBeTruthy();
|
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 () => {
|
test("shows generic SSO error message when SSO returns SSO_LOGIN_FAILED code", async () => {
|
||||||
render(<LoginPage error="SSO_LOGIN_FAILED" />);
|
mockSsoProvidersRequest();
|
||||||
|
|
||||||
|
render(<LoginPage error="SSO_LOGIN_FAILED" />, { withSuspense: true });
|
||||||
|
|
||||||
expect(await screen.findByText("SSO authentication failed. Please try again.")).toBeTruthy();
|
expect(await screen.findByText("SSO authentication failed. Please try again.")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not show error message for invalid error codes", async () => {
|
test("does not show error message for invalid error codes", async () => {
|
||||||
render(<LoginPage error="some_random_error" />);
|
mockSsoProvidersRequest();
|
||||||
|
|
||||||
|
render(<LoginPage error="some_random_error" />, { withSuspense: true });
|
||||||
|
|
||||||
expect(await screen.findByText("Login to your account")).toBeTruthy();
|
expect(await screen.findByText("Login to your account")).toBeTruthy();
|
||||||
expect(screen.queryByText(inviteOnlyMessage)).toBeNull();
|
expect(screen.queryByText(inviteOnlyMessage)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("renders available SSO providers from the real SSO section", async () => {
|
||||||
|
mockSsoProvidersRequest([{ providerId: "acme", organizationSlug: "acme-org" }]);
|
||||||
|
|
||||||
|
render(<LoginPage />, { withSuspense: true });
|
||||||
|
|
||||||
|
expect(await screen.findByRole("button", { name: "Log in with acme" })).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { ReactNode } from "react";
|
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 { fromAny } from "@total-typescript/shoehorn";
|
||||||
import { HttpResponse, http, server } from "~/test/msw/server";
|
import { HttpResponse, http, server } from "~/test/msw/server";
|
||||||
import { cleanup, render, screen } from "~/test/test-utils";
|
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) => {
|
vi.mock("~/client/lib/datetime", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("~/client/lib/datetime")>();
|
const actual = await importOriginal<typeof import("~/client/lib/datetime")>();
|
||||||
|
|
||||||
|
|
@ -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 } }) => (
|
||||||
|
<div>
|
||||||
|
<h1>{schedule.name}</h1>
|
||||||
|
<button type="button">Backup now</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
import { ScheduleDetailsPage } from "../backup-details";
|
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 = {
|
const schedule = {
|
||||||
shortId: "backup-1",
|
shortId: "backup-1",
|
||||||
name: "Backup 1",
|
name: "Backup 1",
|
||||||
|
|
@ -62,6 +69,10 @@ const schedule = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
cronExpression: "0 0 * * *",
|
cronExpression: "0 0 * * *",
|
||||||
retentionPolicy: null,
|
retentionPolicy: null,
|
||||||
|
lastBackupAt: 1711411200000,
|
||||||
|
nextBackupAt: 1711497600000,
|
||||||
|
lastBackupStatus: null,
|
||||||
|
lastBackupError: null,
|
||||||
includePaths: ["/project"],
|
includePaths: ["/project"],
|
||||||
includePatterns: [],
|
includePatterns: [],
|
||||||
excludePatterns: [],
|
excludePatterns: [],
|
||||||
|
|
@ -74,8 +85,26 @@ const snapshot = {
|
||||||
short_id: "snap-1",
|
short_id: "snap-1",
|
||||||
paths: ["/mnt/project"],
|
paths: ["/mnt/project"],
|
||||||
tags: ["backup-1"],
|
tags: ["backup-1"],
|
||||||
time: "2026-03-26T00:00:00.000Z",
|
time: new Date("2026-03-26T00:00:00.000Z").getTime(),
|
||||||
summary: {},
|
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 = () => {
|
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(() => {
|
afterEach(() => {
|
||||||
|
globalThis.EventSource = originalEventSource;
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ScheduleDetailsPage", () => {
|
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();
|
mockScheduleDetailsRequests();
|
||||||
|
|
||||||
render(
|
render(
|
||||||
|
|
@ -113,16 +159,21 @@ describe("ScheduleDetailsPage", () => {
|
||||||
repos: [],
|
repos: [],
|
||||||
scheduleNotifs: [],
|
scheduleNotifs: [],
|
||||||
mirrors: [],
|
mirrors: [],
|
||||||
snapshotTimelineSortOrder: "newest",
|
snapshotTimelineSortOrder: "desc",
|
||||||
snapshots: [snapshot],
|
snapshots: [snapshot],
|
||||||
})}
|
})}
|
||||||
scheduleId="backup-1"
|
scheduleId="backup-1"
|
||||||
initialSnapshotId="snap-1"
|
initialSnapshotId="snap-1"
|
||||||
initialSnapshotSortOrder={fromAny("newest")}
|
initialSnapshotSortOrder="desc"
|
||||||
/>,
|
/>,
|
||||||
{ withSuspense: true },
|
{ 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();
|
expect(await screen.findByRole("button", { name: "project" })).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,6 @@ import * as spawnModule from "@zerobyte/core/node";
|
||||||
import type { SafeSpawnParams } from "@zerobyte/core/node";
|
import type { SafeSpawnParams } from "@zerobyte/core/node";
|
||||||
import { restic } from "~/server/core/restic";
|
import { restic } from "~/server/core/restic";
|
||||||
import { NotFoundError, BadRequestError } from "http-errors-enhanced";
|
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 { repositoriesService } from "~/server/modules/repositories/repositories.service";
|
||||||
import { repoMutex } from "~/server/core/repository-mutex";
|
import { repoMutex } from "~/server/core/repository-mutex";
|
||||||
|
|
||||||
|
|
@ -76,67 +74,6 @@ describe("backup execution - validation failures", () => {
|
||||||
expect(resticBackupMock).not.toHaveBeenCalled();
|
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 () => {
|
test("should fail backup when schedule does not exist", async () => {
|
||||||
setup();
|
setup();
|
||||||
// act
|
// act
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,9 @@ describe("execute backup", () => {
|
||||||
|
|
||||||
// assert
|
// assert
|
||||||
expect(resticBackupMock).toHaveBeenCalled();
|
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 () => {
|
test("should keep next backup time empty for manual-only schedules after a manual run", async () => {
|
||||||
|
|
|
||||||
|
|
@ -356,19 +356,22 @@ describe("repositories updates", () => {
|
||||||
describe("dump snapshot", () => {
|
describe("dump snapshot", () => {
|
||||||
test("continues streaming a download after the request signal aborts", async () => {
|
test("continues streaming a download after the request signal aborts", async () => {
|
||||||
const repository = await createRepositoryRecord(session.organizationId);
|
const repository = await createRepositoryRecord(session.organizationId);
|
||||||
const { repositoriesService } = await import("~/server/modules/repositories/repositories.service");
|
|
||||||
|
|
||||||
const stream = new PassThrough();
|
const stream = new PassThrough();
|
||||||
const expectedContent = "downloaded snapshot contents";
|
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,
|
stream,
|
||||||
completion: Promise.resolve(),
|
completion: Promise.resolve(),
|
||||||
abort: () => {
|
abort: vi.fn(),
|
||||||
stream.destroy(new Error("download aborted"));
|
|
||||||
},
|
|
||||||
filename: "snapshot.txt",
|
|
||||||
contentType: "application/octet-stream",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -385,38 +388,48 @@ describe("repositories updates", () => {
|
||||||
|
|
||||||
await expect(response.text()).resolves.toBe(expectedContent);
|
await expect(response.text()).resolves.toBe(expectedContent);
|
||||||
} finally {
|
} finally {
|
||||||
dumpSnapshotSpy.mockRestore();
|
snapshotsSpy.mockRestore();
|
||||||
|
dumpSpy.mockRestore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns a valid content-disposition header for non-ascii filenames", async () => {
|
test("returns a valid content-disposition header for non-ascii filenames", async () => {
|
||||||
const repository = await createRepositoryRecord(session.organizationId);
|
const repository = await createRepositoryRecord(session.organizationId);
|
||||||
const { repositoriesService } = await import("~/server/modules/repositories/repositories.service");
|
|
||||||
|
|
||||||
const stream = new PassThrough();
|
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,
|
stream,
|
||||||
completion: Promise.resolve(),
|
completion: Promise.resolve(),
|
||||||
abort: () => {
|
abort: vi.fn(),
|
||||||
stream.destroy(new Error("download aborted"));
|
|
||||||
},
|
|
||||||
filename: "möte.txt",
|
|
||||||
contentType: "application/octet-stream",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
stream.end("downloaded snapshot contents");
|
stream.end("downloaded snapshot contents");
|
||||||
|
|
||||||
const response = await app.request(`/api/v1/repositories/${repository.shortId}/snapshots/test-snapshot/dump`, {
|
const encodedPath = encodeURIComponent("/mnt/project/möte.txt");
|
||||||
headers: session.headers,
|
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.status).toBe(200);
|
||||||
expect(response.headers.get("Content-Disposition")).toBe(
|
expect(response.headers.get("Content-Disposition")).toBe(
|
||||||
`attachment; filename="m?te.txt"; filename*=UTF-8''m%C3%B6te.txt`,
|
`attachment; filename="m?te.txt"; filename*=UTF-8''m%C3%B6te.txt`,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
dumpSnapshotSpy.mockRestore();
|
snapshotsSpy.mockRestore();
|
||||||
|
dumpSpy.mockRestore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import waitForExpect from "wait-for-expect";
|
||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import nodePath from "node:path";
|
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 { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import type { RepositoryConfig } from "@zerobyte/core/restic";
|
import type { RepositoryConfig } from "@zerobyte/core/restic";
|
||||||
import { REPOSITORY_BASE } from "~/server/core/constants";
|
import { REPOSITORY_BASE } from "~/server/core/constants";
|
||||||
import { serverEvents } from "~/server/core/events";
|
|
||||||
import { withContext } from "~/server/core/request-context";
|
import { withContext } from "~/server/core/request-context";
|
||||||
import { db } from "~/server/db/db";
|
import { db } from "~/server/db/db";
|
||||||
import { repositoriesTable } from "~/server/db/schema";
|
import { repositoriesTable } from "~/server/db/schema";
|
||||||
|
|
@ -201,12 +201,21 @@ describe("repositoriesService.dumpSnapshot", () => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
const createDumpResult = () => ({
|
const createDumpResult = (payload: string) => ({
|
||||||
stream: Readable.from([]),
|
stream: Readable.from([payload]),
|
||||||
completion: Promise.resolve(),
|
completion: Promise.resolve(),
|
||||||
abort: () => {},
|
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 ({
|
const setupDumpSnapshotScenario = async ({
|
||||||
snapshotId,
|
snapshotId,
|
||||||
basePath,
|
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);
|
vi.spyOn(restic, "dump").mockImplementation(dumpMock);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -251,45 +271,33 @@ describe("repositoriesService.dumpSnapshot", () => {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
shortId,
|
shortId,
|
||||||
basePath,
|
basePath,
|
||||||
dumpMock,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
test("calls restic.dump with common-ancestor selector and stripped path", async () => {
|
test("returns a tar download rooted at the selected directory within the snapshot", async () => {
|
||||||
const { organizationId, userId, shortId, basePath, dumpMock } = await setupDumpSnapshotScenario({
|
const { organizationId, userId, shortId, basePath } = await setupDumpSnapshotScenario({
|
||||||
snapshotId: "snapshot-123",
|
snapshotId: "snapshot-123",
|
||||||
basePath: "/var/lib/zerobyte/volumes/vol123/_data",
|
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"),
|
repositoriesService.dumpSnapshot(shortId, "snapshot-123", `${basePath}/documents`, "dir"),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(dumpMock).toHaveBeenCalledTimes(1);
|
expect(result.filename).toBe("snapshot-snapshot-123.tar");
|
||||||
expect(dumpMock).toHaveBeenCalledWith(
|
expect(result.contentType).toBe("application/x-tar");
|
||||||
expect.objectContaining({
|
expect(await readStreamText(result.stream)).toBe(
|
||||||
backend: "local",
|
JSON.stringify({
|
||||||
}),
|
snapshotRef: `snapshot-123:${basePath}`,
|
||||||
`snapshot-123:${basePath}`,
|
|
||||||
{
|
|
||||||
organizationId,
|
|
||||||
path: "/documents",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(emitSpy).toHaveBeenCalledWith(
|
|
||||||
"dump:started",
|
|
||||||
expect.objectContaining({
|
|
||||||
organizationId,
|
|
||||||
repositoryId: shortId,
|
|
||||||
snapshotId: "snapshot-123",
|
|
||||||
path: "/documents",
|
path: "/documents",
|
||||||
|
archive: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
await expect(result.completion).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("streams a single file directly when selected path is a file", async () => {
|
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",
|
snapshotId: "snapshot-file",
|
||||||
basePath: "/var/lib/zerobyte/volumes/vol123/_data",
|
basePath: "/var/lib/zerobyte/volumes/vol123/_data",
|
||||||
});
|
});
|
||||||
|
|
@ -298,31 +306,38 @@ describe("repositoriesService.dumpSnapshot", () => {
|
||||||
repositoriesService.dumpSnapshot(shortId, "snapshot-file", `${basePath}/documents/report.txt`, "file"),
|
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.filename).toBe("report.txt");
|
||||||
expect(result.contentType).toBe("application/octet-stream");
|
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 () => {
|
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 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",
|
snapshotId: "snapshot-parent-dir",
|
||||||
basePath: `${parentPath}/report.txt`,
|
basePath: `${parentPath}/report.txt`,
|
||||||
snapshotPaths: [`${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"),
|
repositoriesService.dumpSnapshot(shortId, "snapshot-parent-dir", parentPath, "dir"),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(dumpMock).toHaveBeenCalledWith(expect.anything(), `snapshot-parent-dir:${parentPath}`, {
|
expect(result.filename).toBe("snapshot-snapshot-parent-dir.tar");
|
||||||
organizationId,
|
expect(result.contentType).toBe("application/x-tar");
|
||||||
path: "/",
|
expect(await readStreamText(result.stream)).toBe(
|
||||||
});
|
JSON.stringify({
|
||||||
|
snapshotRef: `snapshot-parent-dir:${parentPath}`,
|
||||||
|
path: "/",
|
||||||
|
archive: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects path downloads without a kind", async () => {
|
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");
|
).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 () => {
|
test("downloads the full snapshot from the common ancestor when path is omitted", async () => {
|
||||||
const { organizationId, userId, shortId, basePath, dumpMock } = await setupDumpSnapshotScenario({
|
const { organizationId, userId, shortId, basePath } = await setupDumpSnapshotScenario({
|
||||||
snapshotId: "snapshot-999",
|
snapshotId: "snapshot-999",
|
||||||
basePath: "/var/lib/zerobyte/volumes/vol555/_data",
|
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}`, {
|
expect(result.filename).toBe("snapshot-snapshot-999.tar");
|
||||||
organizationId,
|
expect(result.contentType).toBe("application/x-tar");
|
||||||
path: "/",
|
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"),
|
repositoriesService.deleteSnapshot(repository.shortId, "snap-1"),
|
||||||
);
|
);
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
await waitForExpect(() => {
|
||||||
|
expect(statsSpy).toHaveBeenCalledTimes(1);
|
||||||
expect(statsSpy).toHaveBeenCalledTimes(1);
|
});
|
||||||
|
|
||||||
const updatedRepository = await db.query.repositoriesTable.findFirst({ where: { id: repository.id } });
|
const updatedRepository = await db.query.repositoriesTable.findFirst({ where: { id: repository.id } });
|
||||||
expect(updatedRepository?.stats).toEqual(expectedStats);
|
expect(updatedRepository?.stats).toEqual(expectedStats);
|
||||||
|
|
|
||||||
|
|
@ -105,15 +105,20 @@ describe("system security", () => {
|
||||||
|
|
||||||
describe("updates endpoint", () => {
|
describe("updates endpoint", () => {
|
||||||
test("GET /api/v1/system/updates should be accessible with valid session", async () => {
|
test("GET /api/v1/system/updates should be accessible with valid session", async () => {
|
||||||
vi.spyOn(systemService, "getUpdates").mockResolvedValue({
|
const expectedUpdates = {
|
||||||
currentVersion: "1.0.0",
|
currentVersion: "1.0.0",
|
||||||
latestVersion: "1.0.0",
|
latestVersion: "1.0.0",
|
||||||
hasUpdate: false,
|
hasUpdate: false,
|
||||||
missedReleases: [],
|
missedReleases: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.spyOn(systemService, "getUpdates").mockResolvedValue({
|
||||||
|
...expectedUpdates,
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await app.request("/api/v1/system/updates", { headers: session.headers });
|
const res = await app.request("/api/v1/system/updates", { headers: session.headers });
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.json()).toEqual(expectedUpdates);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ type SetupOptions = {
|
||||||
const setup = ({ spawnResult = {}, onSpawnCall }: SetupOptions = {}) => {
|
const setup = ({ spawnResult = {}, onSpawnCall }: SetupOptions = {}) => {
|
||||||
let capturedArgs: string[] = [];
|
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) => {
|
vi.spyOn(spawnModule, "safeSpawn").mockImplementation((params) => {
|
||||||
capturedArgs = params.args;
|
capturedArgs = params.args;
|
||||||
return Promise.resolve(onSpawnCall?.(params)).then(() => ({
|
return Promise.resolve(onSpawnCall?.(params)).then(() => ({
|
||||||
|
|
@ -73,7 +73,6 @@ const setup = ({ spawnResult = {}, onSpawnCall }: SetupOptions = {}) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cleanupSpy,
|
|
||||||
getArgs: () => capturedArgs,
|
getArgs: () => capturedArgs,
|
||||||
hasFlag: (flag: string) => capturedArgs.includes(flag),
|
hasFlag: (flag: string) => capturedArgs.includes(flag),
|
||||||
getOptionValues: (option: string): string[] => {
|
getOptionValues: (option: string): string[] => {
|
||||||
|
|
@ -114,27 +113,11 @@ describe("backup command", () => {
|
||||||
expect(getArgs().at(-1)).toBe(source);
|
expect(getArgs().at(-1)).toBe(source);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses --files-from instead of source path when include list is provided", async () => {
|
test("writes include paths and patterns to separate files instead of passing the source path directly", 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 () => {
|
|
||||||
const literalDir = "/mnt/data/movies [1]";
|
const literalDir = "/mnt/data/movies [1]";
|
||||||
let rawIncludeContent = "";
|
let rawIncludeContent = "";
|
||||||
let patternIncludeContent = "";
|
let patternIncludeContent = "";
|
||||||
const { hasFlag } = setup({
|
const { getArgs, hasFlag } = setup({
|
||||||
onSpawnCall: async (params) => {
|
onSpawnCall: async (params) => {
|
||||||
const rawIncludeIndex = params.args.indexOf("--files-from-raw");
|
const rawIncludeIndex = params.args.indexOf("--files-from-raw");
|
||||||
const patternIncludeIndex = params.args.indexOf("--files-from");
|
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-raw")).toBe(true);
|
||||||
expect(hasFlag("--files-from")).toBe(true);
|
expect(hasFlag("--files-from")).toBe(true);
|
||||||
|
expect(getArgs()).not.toContain("/mnt/data");
|
||||||
expect(rawIncludeContent).toBe(`${literalDir}\0`);
|
expect(rawIncludeContent).toBe(`${literalDir}\0`);
|
||||||
expect(patternIncludeContent).toBe("/mnt/data/**/*.zip");
|
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 () => {
|
test("always includes DEFAULT_EXCLUDES as --exclude args", async () => {
|
||||||
const { getOptionValues } = setup();
|
const { getOptionValues } = setup();
|
||||||
await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps);
|
await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps);
|
||||||
|
|
||||||
expect(getOptionValues("--exclude").length).toBeGreaterThan(0);
|
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", () => {
|
describe("exit code handling", () => {
|
||||||
|
|
@ -427,20 +317,4 @@ describe("backup command", () => {
|
||||||
expect(progressUpdates).toHaveLength(0);
|
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -51,17 +51,8 @@ describe("copy command", () => {
|
||||||
|
|
||||||
await copy(sourceConfig, destConfig, { organizationId: "org-1", snapshotId, tag: "daily" }, mockDeps);
|
await copy(sourceConfig, destConfig, { organizationId: "org-1", snapshotId, tag: "daily" }, mockDeps);
|
||||||
|
|
||||||
expect(getArgs()).toEqual([
|
const separatorIndex = getArgs().indexOf("--");
|
||||||
"--repo",
|
expect(separatorIndex).toBeGreaterThan(-1);
|
||||||
"/tmp/dest-repo",
|
expect(getArgs().slice(separatorIndex + 1)).toEqual([snapshotId]);
|
||||||
"copy",
|
|
||||||
"--from-repo",
|
|
||||||
"/tmp/source-repo",
|
|
||||||
"--tag",
|
|
||||||
"daily",
|
|
||||||
"--json",
|
|
||||||
"--",
|
|
||||||
snapshotId,
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ describe("deleteSnapshots command", () => {
|
||||||
|
|
||||||
await deleteSnapshots(config, snapshotIds, "org-1", mockDeps);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { PassThrough } from "node:stream";
|
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
import * as cleanupModule from "../../helpers/cleanup-temporary-keys";
|
import * as cleanupModule from "../../helpers/cleanup-temporary-keys";
|
||||||
|
|
@ -27,7 +26,7 @@ const setup = () => {
|
||||||
vi.spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
|
vi.spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
|
||||||
vi.spyOn(nodeModule, "safeSpawn").mockImplementation((params) => {
|
vi.spyOn(nodeModule, "safeSpawn").mockImplementation((params) => {
|
||||||
capturedArgs = params.args;
|
capturedArgs = params.args;
|
||||||
const child = { stdout: new PassThrough() } as unknown as ReturnType<typeof spawn>;
|
const child = spawn(process.execPath, ["-e", ""]);
|
||||||
params.onSpawn?.(child);
|
params.onSpawn?.(child);
|
||||||
return Promise.resolve({ exitCode: 0, summary: "", error: "" });
|
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);
|
const result = await dump(config, "--help", { organizationId: "org-1", path: "folder/file.txt" }, mockDeps);
|
||||||
await result.completion;
|
await result.completion;
|
||||||
|
|
||||||
expect(getArgs()).toEqual([
|
const separatorIndex = getArgs().indexOf("--");
|
||||||
"--repo",
|
expect(separatorIndex).toBeGreaterThan(-1);
|
||||||
"/tmp/restic-repo",
|
expect(getArgs().slice(separatorIndex + 1)).toEqual(["--help", "/folder/file.txt"]);
|
||||||
"dump",
|
|
||||||
"--archive",
|
|
||||||
"tar",
|
|
||||||
"--",
|
|
||||||
"--help",
|
|
||||||
"/folder/file.txt",
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,8 @@ describe("ls command", () => {
|
||||||
|
|
||||||
await ls(config, snapshotId, "org-1", path, undefined, mockDeps);
|
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]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import * as cleanupModule from "../../helpers/cleanup-temporary-keys";
|
||||||
import * as spawnModule from "../../../utils/spawn";
|
import * as spawnModule from "../../../utils/spawn";
|
||||||
|
import { ResticError } from "../../error";
|
||||||
import { restore } from "../restore";
|
import { restore } from "../restore";
|
||||||
import type { ResticDeps } from "../../types";
|
import type { ResticDeps } from "../../types";
|
||||||
import type { SafeSpawnParams } from "../../../utils/spawn";
|
import type { SafeSpawnParams, SpawnResult } from "../../../utils/spawn";
|
||||||
|
|
||||||
const mockDeps: ResticDeps = {
|
const mockDeps: ResticDeps = {
|
||||||
resolveSecret: async (s) => s,
|
resolveSecret: async (s) => s,
|
||||||
|
|
@ -14,11 +16,22 @@ const mockDeps: ResticDeps = {
|
||||||
|
|
||||||
const successfulRestoreSummary = JSON.stringify({
|
const successfulRestoreSummary = JSON.stringify({
|
||||||
message_type: "summary",
|
message_type: "summary",
|
||||||
|
total_files: 2,
|
||||||
files_restored: 1,
|
files_restored: 1,
|
||||||
files_skipped: 0,
|
files_skipped: 1,
|
||||||
bytes_skipped: 0,
|
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 = {
|
const config = {
|
||||||
backend: "local" as const,
|
backend: "local" as const,
|
||||||
path: "/tmp/restic-repo",
|
path: "/tmp/restic-repo",
|
||||||
|
|
@ -26,16 +39,23 @@ const config = {
|
||||||
customPassword: "custom-password",
|
customPassword: "custom-password",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
type SetupOptions = {
|
||||||
* Sets up the safeSpawn mock and returns helpers to inspect the restic args
|
spawnResult?: Partial<SpawnResult>;
|
||||||
* that were built for the restore command.
|
onSpawnCall?: (params: SafeSpawnParams) => void | Promise<void>;
|
||||||
*/
|
};
|
||||||
const setup = () => {
|
|
||||||
|
const setup = ({ spawnResult = {}, onSpawnCall }: SetupOptions = {}) => {
|
||||||
let capturedArgs: string[] = [];
|
let capturedArgs: string[] = [];
|
||||||
|
|
||||||
|
vi.spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
|
||||||
vi.spyOn(spawnModule, "safeSpawn").mockImplementation((params: SafeSpawnParams) => {
|
vi.spyOn(spawnModule, "safeSpawn").mockImplementation((params: SafeSpawnParams) => {
|
||||||
capturedArgs = params.args;
|
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 = () => {
|
const getRestoreArg = () => {
|
||||||
|
|
@ -46,7 +66,7 @@ const setup = () => {
|
||||||
return capturedArgs[separatorIndex + 1]!;
|
return capturedArgs[separatorIndex + 1]!;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getOptionValues = (option: string): string[] => {
|
const getOptionValues = (option: string) => {
|
||||||
const values: string[] = [];
|
const values: string[] = [];
|
||||||
for (let i = 0; i < capturedArgs.length - 1; i++) {
|
for (let i = 0; i < capturedArgs.length - 1; i++) {
|
||||||
if (capturedArgs[i] === option && capturedArgs[i + 1]) {
|
if (capturedArgs[i] === option && capturedArgs[i + 1]) {
|
||||||
|
|
@ -68,123 +88,168 @@ afterEach(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("restore command", () => {
|
describe("restore command", () => {
|
||||||
test("keeps snapshot restore arg and absolute include paths when target is root", async () => {
|
describe("path selection", () => {
|
||||||
const { getRestoreArg, getOptionValues } = setup();
|
test("uses the common ancestor as restore root and strips includes for non-root targets", async () => {
|
||||||
await restore(
|
const { getRestoreArg, getOptionValues } = setup();
|
||||||
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,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(getRestoreArg()).toBe("snapshot-123");
|
await restore(
|
||||||
expect(getOptionValues("--include")).toEqual([
|
config,
|
||||||
"/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf",
|
"snapshot-456",
|
||||||
"/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg",
|
"/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 () => {
|
describe("output handling", () => {
|
||||||
const { getRestoreArg, getOptionValues } = setup();
|
test("returns a parsed restore summary on success", async () => {
|
||||||
await restore(
|
setup();
|
||||||
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");
|
const result = await restore(
|
||||||
expect(getOptionValues("--include")).toEqual(["Documents/report.pdf", "Photos/summer.jpg"]);
|
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 () => {
|
describe("progress callbacks", () => {
|
||||||
const { getRestoreArg, getOptionValues } = setup();
|
test("calls onProgress with parsed status updates", async () => {
|
||||||
await restore(
|
const progressUpdates: unknown[] = [];
|
||||||
config,
|
setup({ onSpawnCall: (params) => params.onStdout?.(validProgressLine) });
|
||||||
"snapshot-789",
|
|
||||||
"/tmp/restore-target",
|
|
||||||
{
|
|
||||||
organizationId: "org-1",
|
|
||||||
basePath: "/var/lib/zerobyte/volumes/vol123/_data",
|
|
||||||
},
|
|
||||||
mockDeps,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(getRestoreArg()).toBe("snapshot-789:/var/lib/zerobyte/volumes/vol123/_data");
|
await restore(
|
||||||
expect(getOptionValues("--include")).toEqual([]);
|
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 () => {
|
expect(progressUpdates).toHaveLength(1);
|
||||||
const { getRestoreArg, getOptionValues } = setup();
|
expect(progressUpdates[0]).toMatchObject({
|
||||||
const options: Parameters<typeof restore>[3] & { selectedItemKind: "file" } = {
|
message_type: "status",
|
||||||
organizationId: "org-1",
|
percent_done: 0.5,
|
||||||
include: ["/var/lib/zerobyte/volumes/vol123/_data/archive/backup.20260301-233001.7z"],
|
files_restored: 1,
|
||||||
selectedItemKind: "file",
|
});
|
||||||
};
|
});
|
||||||
|
|
||||||
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");
|
await restore(
|
||||||
expect(getOptionValues("--include")).toEqual(["backup.20260301-233001.7z"]);
|
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 () => {
|
expect(progressUpdates).toHaveLength(0);
|
||||||
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",
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ describe("tagSnapshots command", () => {
|
||||||
|
|
||||||
await tagSnapshots(config, snapshotIds, { add: ["keep"] }, "org-1", mockDeps);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue