test: refactor some tests to verify behavior instead of implementation details

This commit is contained in:
Nicolas Meienberger 2026-04-01 19:40:42 +02:00
parent 0c43320fed
commit d8bf4c2ef3
15 changed files with 449 additions and 450 deletions

View file

@ -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);
});

View file

@ -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();
});
});

View file

@ -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();
});
});

View file

@ -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

View file

@ -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 () => {

View file

@ -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();
}
});
});

View file

@ -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);

View file

@ -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);
});
});

View file

@ -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);
});
});
});

View file

@ -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]);
});
});

View file

@ -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);
});
});

View file

@ -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"]);
});
});

View file

@ -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]);
});
});

View file

@ -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);
});
});
});

View file

@ -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);
});
});