From d8bf4c2ef3419133d6ac0e059677b301a61466d8 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Wed, 1 Apr 2026 19:40:42 +0200 Subject: [PATCH] test: refactor some tests to verify behavior instead of implementation details --- .../__tests__/use-server-events.test.tsx | 28 +- .../auth/routes/__tests__/login.test.tsx | 50 ++- .../routes/__tests__/backup-details.test.tsx | 103 ++++-- .../__tests__/backups.execution.test.ts | 63 ---- .../backups/__tests__/backups.service.test.ts | 3 + .../__tests__/repositories.controller.test.ts | 53 ++-- .../__tests__/repositories.service.test.ts | 118 ++++--- .../__tests__/system.controller.test.ts | 7 +- .../restic/commands/__tests__/backup.test.ts | 134 +------- .../restic/commands/__tests__/copy.test.ts | 15 +- .../__tests__/delete-snapshots.test.ts | 4 +- .../restic/commands/__tests__/dump.test.ts | 16 +- .../src/restic/commands/__tests__/ls.test.ts | 4 +- .../restic/commands/__tests__/restore.test.ts | 297 +++++++++++------- .../commands/__tests__/tag-snapshots.test.ts | 4 +- 15 files changed, 449 insertions(+), 450 deletions(-) diff --git a/app/client/hooks/__tests__/use-server-events.test.tsx b/app/client/hooks/__tests__/use-server-events.test.tsx index 2e106f3d..5887c68d 100644 --- a/app/client/hooks/__tests__/use-server-events.test.tsx +++ b/app/client/hooks/__tests__/use-server-events.test.tsx @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { cleanup, createTestQueryClient, render, screen } from "~/test/test-utils"; import { useServerEvents } from "../use-server-events"; @@ -67,6 +68,15 @@ const BackupCompletedListener = ({ scheduleId }: { scheduleId: string }) => { return
{status}
; }; +const QueryStatusConsumer = ({ getValue }: { getValue: () => string }) => { + const { data } = useQuery({ + queryKey: ["backup-status"], + queryFn: async () => getValue(), + }); + + return
{data}
; +}; + describe("useServerEvents", () => { beforeEach(() => { MockEventSource.reset(); @@ -83,22 +93,23 @@ describe("useServerEvents", () => { MockEventSource.reset(); }); - test("shares one EventSource across consumers and invalidates queries once on backup completion", async () => { + test("shares one EventSource across consumers and refreshes active queries on backup completion", async () => { const queryClient = createTestQueryClient(); - const invalidateQueries = vi.fn(async () => undefined); - const refetchQueries = vi.fn(async () => undefined); - queryClient.invalidateQueries = invalidateQueries as typeof queryClient.invalidateQueries; - queryClient.refetchQueries = refetchQueries as typeof queryClient.refetchQueries; + let queryValue = "before"; - render( + const view = render( <> + queryValue} /> , { queryClient }, ); expect(MockEventSource.instances).toHaveLength(1); + expect(await screen.findByText("before")).toBeTruthy(); + + queryValue = "after"; MockEventSource.instances[0]?.emit("backup:completed", { organizationId: "default-org", @@ -109,10 +120,9 @@ describe("useServerEvents", () => { }); expect(await screen.findByText("success")).toBeTruthy(); - expect(invalidateQueries).toHaveBeenCalledTimes(1); - expect(refetchQueries).not.toHaveBeenCalled(); + expect(await screen.findByText("after")).toBeTruthy(); - cleanup(); + view.unmount(); expect(MockEventSource.instances[0]?.close).toHaveBeenCalledTimes(1); }); diff --git a/app/client/modules/auth/routes/__tests__/login.test.tsx b/app/client/modules/auth/routes/__tests__/login.test.tsx index a50f4fe3..13c18da4 100644 --- a/app/client/modules/auth/routes/__tests__/login.test.tsx +++ b/app/client/modules/auth/routes/__tests__/login.test.tsx @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test, vi } from "vitest"; +import { HttpResponse, http, server } from "~/test/msw/server"; import { cleanup, render, screen } from "~/test/test-utils"; vi.mock("@tanstack/react-router", async (importOriginal) => { @@ -10,27 +11,40 @@ vi.mock("@tanstack/react-router", async (importOriginal) => { }; }); -vi.mock("~/client/modules/sso/components/sso-login-section", () => ({ - SsoLoginSection: () => <>, -})); - import { LoginPage } from "../login"; const inviteOnlyMessage = "Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO."; +const mockSsoProvidersRequest = ( + providers: Array<{ + providerId: string; + organizationSlug: string; + }> = [], +) => { + server.use( + http.get("/api/v1/auth/sso-providers", () => { + return HttpResponse.json({ providers }); + }), + ); +}; + afterEach(() => { cleanup(); }); describe("LoginPage", () => { test("shows an invite-only message when SSO returns INVITE_REQUIRED code", async () => { - render(); + mockSsoProvidersRequest(); + + render(, { withSuspense: true }); expect(await screen.findByText(inviteOnlyMessage)).toBeTruthy(); }); test("shows account link required message when SSO returns ACCOUNT_LINK_REQUIRED code", async () => { - render(); + mockSsoProvidersRequest(); + + render(, { withSuspense: true }); expect( await screen.findByText( @@ -40,7 +54,9 @@ describe("LoginPage", () => { }); test("shows banned message when SSO returns BANNED_USER code", async () => { - render(); + mockSsoProvidersRequest(); + + render(, { withSuspense: true }); expect( await screen.findByText( @@ -50,21 +66,35 @@ describe("LoginPage", () => { }); test("shows email not verified message when SSO returns EMAIL_NOT_VERIFIED code", async () => { - render(); + mockSsoProvidersRequest(); + + render(, { withSuspense: true }); expect(await screen.findByText("Your identity provider did not mark your email as verified.")).toBeTruthy(); }); test("shows generic SSO error message when SSO returns SSO_LOGIN_FAILED code", async () => { - render(); + mockSsoProvidersRequest(); + + render(, { withSuspense: true }); expect(await screen.findByText("SSO authentication failed. Please try again.")).toBeTruthy(); }); test("does not show error message for invalid error codes", async () => { - render(); + mockSsoProvidersRequest(); + + render(, { withSuspense: true }); expect(await screen.findByText("Login to your account")).toBeTruthy(); expect(screen.queryByText(inviteOnlyMessage)).toBeNull(); }); + + test("renders available SSO providers from the real SSO section", async () => { + mockSsoProvidersRequest([{ providerId: "acme", organizationSlug: "acme-org" }]); + + render(, { withSuspense: true }); + + expect(await screen.findByRole("button", { name: "Log in with acme" })).toBeTruthy(); + }); }); diff --git a/app/client/modules/backups/routes/__tests__/backup-details.test.tsx b/app/client/modules/backups/routes/__tests__/backup-details.test.tsx index 1fdfd625..26863d30 100644 --- a/app/client/modules/backups/routes/__tests__/backup-details.test.tsx +++ b/app/client/modules/backups/routes/__tests__/backup-details.test.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import { afterEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { fromAny } from "@total-typescript/shoehorn"; import { HttpResponse, http, server } from "~/test/msw/server"; import { cleanup, render, screen } from "~/test/test-utils"; @@ -15,26 +15,6 @@ vi.mock("@tanstack/react-router", async (importOriginal) => { }; }); -vi.mock("~/client/components/backup-summary-card", () => ({ - BackupSummaryCard: () => <>, -})); - -vi.mock("~/client/modules/backups/components/schedule-summary", () => ({ - ScheduleSummary: () => <>, -})); - -vi.mock("~/client/modules/backups/components/snapshot-timeline", () => ({ - SnapshotTimeline: () => <>, -})); - -vi.mock("~/client/modules/backups/components/schedule-notifications-config", () => ({ - ScheduleNotificationsConfig: () => <>, -})); - -vi.mock("~/client/modules/backups/components/schedule-mirrors-config", () => ({ - ScheduleMirrorsConfig: () => <>, -})); - vi.mock("~/client/lib/datetime", async (importOriginal) => { const actual = await importOriginal(); @@ -47,8 +27,35 @@ vi.mock("~/client/lib/datetime", async (importOriginal) => { }; }); +vi.mock("~/client/modules/backups/components/schedule-notifications-config", () => ({ + ScheduleNotificationsConfig: () => null, +})); + +vi.mock("~/client/modules/backups/components/schedule-mirrors-config", () => ({ + ScheduleMirrorsConfig: () => null, +})); + +vi.mock("~/client/modules/backups/components/schedule-summary", () => ({ + ScheduleSummary: ({ schedule }: { schedule: { name: string } }) => ( +
+

{schedule.name}

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