refactor(restic): split each command into its own file refactor: add missing await before promise expects <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes * **Tests** * Improved async assertion handling across test suites for enhanced test reliability * Expanded test coverage for backup, restore, and other core operations * **Refactor** * Reorganized internal utility structure for improved code maintainability * **Chores** * Updated linting configuration and TypeScript dependencies <!-- end of auto-generated comment: release notes by coderabbit.ai -->
324 lines
10 KiB
TypeScript
324 lines
10 KiB
TypeScript
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
|
|
import * as cleanupModule from "~/server/utils/restic/helpers/cleanup-temporary-keys";
|
|
import * as spawnModule from "~/server/utils/spawn";
|
|
import { ResticError } from "~/server/utils/errors";
|
|
import { backup } from "../backup";
|
|
|
|
const VALID_SUMMARY = JSON.stringify({
|
|
message_type: "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,
|
|
total_files_processed: 100,
|
|
total_bytes_processed: 2097152,
|
|
total_duration: 12.34,
|
|
snapshot_id: "abcd1234",
|
|
});
|
|
|
|
const VALID_PROGRESS_LINE = JSON.stringify({
|
|
message_type: "status",
|
|
seconds_elapsed: 5,
|
|
percent_done: 0.5,
|
|
total_files: 100,
|
|
files_done: 50,
|
|
total_bytes: 2097152,
|
|
bytes_done: 1048576,
|
|
});
|
|
|
|
const config = {
|
|
backend: "local" as const,
|
|
path: "/tmp/restic-repo",
|
|
isExistingRepository: true,
|
|
customPassword: "custom-password",
|
|
};
|
|
|
|
type SetupOptions = {
|
|
spawnResult?: Partial<spawnModule.SpawnResult>;
|
|
onSpawnCall?: (params: spawnModule.SafeSpawnParams) => void;
|
|
};
|
|
|
|
/**
|
|
* Sets up mocks for safeSpawn and cleanupTemporaryKeys, captures spawn args,
|
|
* and returns helpers to inspect what was passed to restic.
|
|
*/
|
|
const setup = ({ spawnResult = {}, onSpawnCall }: SetupOptions = {}) => {
|
|
let capturedArgs: string[] = [];
|
|
|
|
const cleanupSpy = spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
|
|
spyOn(spawnModule, "safeSpawn").mockImplementation((params) => {
|
|
capturedArgs = params.args;
|
|
onSpawnCall?.(params);
|
|
return Promise.resolve({ exitCode: 0, summary: VALID_SUMMARY, error: "", ...spawnResult });
|
|
});
|
|
|
|
return {
|
|
cleanupSpy,
|
|
getArgs: () => capturedArgs,
|
|
hasFlag: (flag: string) => capturedArgs.includes(flag),
|
|
getOptionValues: (option: string): string[] => {
|
|
const values: string[] = [];
|
|
for (let i = 0; i < capturedArgs.length - 1; i++) {
|
|
if (capturedArgs[i] === option && capturedArgs[i + 1]) {
|
|
values.push(capturedArgs[i + 1]!);
|
|
}
|
|
}
|
|
return values;
|
|
},
|
|
};
|
|
};
|
|
|
|
afterEach(() => {
|
|
mock.restore();
|
|
});
|
|
|
|
describe("backup command", () => {
|
|
describe("argument construction", () => {
|
|
test("passes source path as positional arg when no include list is given", async () => {
|
|
const { getArgs, hasFlag } = setup();
|
|
await backup(config, "/mnt/data", { organizationId: "org-1" });
|
|
|
|
expect(getArgs()).toContain("/mnt/data");
|
|
expect(hasFlag("--files-from")).toBe(false);
|
|
});
|
|
|
|
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",
|
|
include: ["/mnt/data/docs", "/mnt/data/photos"],
|
|
});
|
|
|
|
expect(hasFlag("--files-from")).toBe(true);
|
|
expect(getArgs()).not.toContain("/mnt/data");
|
|
});
|
|
|
|
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"],
|
|
});
|
|
|
|
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: [] });
|
|
|
|
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" });
|
|
|
|
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" });
|
|
|
|
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 });
|
|
|
|
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 });
|
|
|
|
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"],
|
|
});
|
|
|
|
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: [] });
|
|
|
|
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"],
|
|
});
|
|
|
|
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" });
|
|
|
|
expect(getOptionValues("--exclude").length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("includes --host arg from config", async () => {
|
|
const { hasFlag } = setup();
|
|
await backup(config, "/mnt/data", { organizationId: "org-1" });
|
|
|
|
expect(hasFlag("--host")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("exit code handling", () => {
|
|
test("returns parsed result on exit code 0", async () => {
|
|
setup();
|
|
const { result, exitCode } = await backup(config, "/mnt/data", { organizationId: "org-1" });
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(result?.snapshot_id).toBe("abcd1234");
|
|
});
|
|
|
|
test("returns result without throwing on exit code 3 (partial read errors)", async () => {
|
|
setup({ spawnResult: { exitCode: 3 } });
|
|
const { result, exitCode } = await backup(config, "/mnt/data", { organizationId: "org-1" });
|
|
|
|
expect(exitCode).toBe(3);
|
|
expect(result).not.toBeNull();
|
|
});
|
|
|
|
test("throws ResticError on non-zero, non-3 exit codes", async () => {
|
|
setup({ spawnResult: { exitCode: 1, summary: "", error: "fatal error" } });
|
|
|
|
await expect(backup(config, "/mnt/data", { organizationId: "org-1" })).rejects.toBeInstanceOf(ResticError);
|
|
});
|
|
|
|
test("preserves the exit code inside the thrown ResticError", async () => {
|
|
setup({ spawnResult: { exitCode: 12, summary: "", error: "wrong password" } });
|
|
|
|
const error = await backup(config, "/mnt/data", { organizationId: "org-1" }).catch((e) => e);
|
|
expect(error).toBeInstanceOf(ResticError);
|
|
expect((error as ResticError).code).toBe(12);
|
|
});
|
|
|
|
test("returns { result: null } when the abort signal is triggered", async () => {
|
|
const controller = new AbortController();
|
|
setup({
|
|
onSpawnCall: () => controller.abort(),
|
|
spawnResult: { exitCode: 130, summary: "", error: "" },
|
|
});
|
|
|
|
const { result, exitCode } = await backup(config, "/mnt/data", {
|
|
organizationId: "org-1",
|
|
signal: controller.signal,
|
|
});
|
|
|
|
expect(result).toBeNull();
|
|
expect(exitCode).toBe(130);
|
|
});
|
|
});
|
|
|
|
describe("output parsing", () => {
|
|
test("returns a fully parsed summary object on valid output", async () => {
|
|
setup();
|
|
const { result } = await backup(config, "/mnt/data", { organizationId: "org-1" });
|
|
|
|
expect(result).toMatchObject({
|
|
message_type: "summary",
|
|
snapshot_id: "abcd1234",
|
|
total_duration: 12.34,
|
|
});
|
|
});
|
|
|
|
test("returns { result: null } when summary line is not valid JSON", async () => {
|
|
setup({ spawnResult: { summary: "not-json" } });
|
|
const { result } = await backup(config, "/mnt/data", { organizationId: "org-1" });
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
test("returns { result: null } when summary JSON does not satisfy the schema", async () => {
|
|
setup({ spawnResult: { summary: JSON.stringify({ message_type: "summary" }) } });
|
|
const { result } = await backup(config, "/mnt/data", { organizationId: "org-1" });
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("progress callbacks", () => {
|
|
test("calls onProgress with parsed data when a valid status line arrives", async () => {
|
|
const progressUpdates: unknown[] = [];
|
|
setup({ onSpawnCall: (params) => params.onStdout?.(VALID_PROGRESS_LINE) });
|
|
|
|
await backup(config, "/mnt/data", {
|
|
organizationId: "org-1",
|
|
onProgress: (p) => progressUpdates.push(p),
|
|
});
|
|
|
|
expect(progressUpdates.length).toBeGreaterThan(0);
|
|
expect(progressUpdates[0]).toMatchObject({
|
|
message_type: "status",
|
|
percent_done: 0.5,
|
|
files_done: 50,
|
|
});
|
|
});
|
|
|
|
test("ignores non-JSON stdout lines without throwing", async () => {
|
|
setup({
|
|
onSpawnCall: (params) => {
|
|
params.onStdout?.("scanning...");
|
|
params.onStdout?.("repository opened");
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
backup(config, "/mnt/data", { organizationId: "org-1", onProgress: () => {} }),
|
|
).resolves.toBeDefined();
|
|
});
|
|
|
|
test("ignores valid JSON lines that do not match the progress schema", async () => {
|
|
const progressUpdates: unknown[] = [];
|
|
setup({
|
|
onSpawnCall: (params) => params.onStdout?.(JSON.stringify({ message_type: "verbose_status", action: "scan" })),
|
|
});
|
|
|
|
await backup(config, "/mnt/data", {
|
|
organizationId: "org-1",
|
|
onProgress: (p) => progressUpdates.push(p),
|
|
});
|
|
|
|
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" });
|
|
|
|
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" }).catch(() => {});
|
|
|
|
expect(cleanupSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
});
|