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 { 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 <div>{status}</div>;
|
||||
};
|
||||
|
||||
const QueryStatusConsumer = ({ getValue }: { getValue: () => string }) => {
|
||||
const { data } = useQuery({
|
||||
queryKey: ["backup-status"],
|
||||
queryFn: async () => getValue(),
|
||||
});
|
||||
|
||||
return <div>{data}</div>;
|
||||
};
|
||||
|
||||
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(
|
||||
<>
|
||||
<ConnectionConsumer />
|
||||
<BackupCompletedListener scheduleId="0b9c940b" />
|
||||
<QueryStatusConsumer getValue={() => 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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(<LoginPage error="INVITE_REQUIRED" />);
|
||||
mockSsoProvidersRequest();
|
||||
|
||||
render(<LoginPage error="INVITE_REQUIRED" />, { withSuspense: true });
|
||||
|
||||
expect(await screen.findByText(inviteOnlyMessage)).toBeTruthy();
|
||||
});
|
||||
|
||||
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(
|
||||
await screen.findByText(
|
||||
|
|
@ -40,7 +54,9 @@ describe("LoginPage", () => {
|
|||
});
|
||||
|
||||
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(
|
||||
await screen.findByText(
|
||||
|
|
@ -50,21 +66,35 @@ describe("LoginPage", () => {
|
|||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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(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 { 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<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";
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<typeof spawn>;
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<SpawnResult>;
|
||||
onSpawnCall?: (params: SafeSpawnParams) => void | Promise<void>;
|
||||
};
|
||||
|
||||
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<typeof restore>[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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue