Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ clap = { version = "4", features = ["derive"] }
tempfile = "3"
insta = { version = "1", features = ["yaml", "filters"] }
proptest = "1"
assert_cmd = "2"
predicates = "3"
rayon = "1"
ctrlc = { version = "3", features = ["termination"] }
chrono = { version = "0.4", features = ["serde"] }
Expand Down
99 changes: 99 additions & 0 deletions apps/desktop/src/__tests__/StatusBar.backup.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* StatusBar Backup button — Task 9 of the database-backup slice.
*
* Branches under test:
* - Click Backup → api.backupDatabase resolves Ok → info toast with
* absolute_path and formatted MB.
* - Click Backup → api.backupDatabase resolves Err(BackupFailed/TargetExists)
* → error toast that includes "already exists".
*
* WHY mock `../api` (not `@tauri-apps/api/core`): the perima frontend test
* pattern mocks the API layer with `vi.importActual` partial spread, returning
* `okAsync`/`errAsync` from neverthrow. Mocking `invoke` directly bypasses
* `fromInvoke`'s `parseCoreError` and produces a different shape than
* `useBackupDatabase`'s `.match(...)` branch consumes.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { errAsync, okAsync } from "neverthrow";
import StatusBar from "../components/StatusBar";
import * as api from "../api";
import { useUiStore } from "../stores/ui";
import { renderWithProviders, resetUiStore } from "./test-utils";
import type { CoreError } from "../bindings";

vi.mock("../api", async () => {
const actual = await vi.importActual<typeof import("../api")>("../api");
return {
...actual,
backupDatabase: vi.fn(),
// WHY listQuickHashCollisions: StatusBar renders <CollisionPill> which
// calls useCollisions() → listQuickHashCollisions. Without a mock the
// real `invoke` fires, which fails in jsdom. Return an empty list so the
// pill renders in its neutral "0 groups" state without affecting the
// backup-button assertions.
listQuickHashCollisions: vi.fn(),
};
});

const mockBackupDatabase = vi.mocked(api.backupDatabase);
const mockListCollisions = vi.mocked(api.listQuickHashCollisions);

beforeEach(() => {
vi.clearAllMocks();
resetUiStore();
mockListCollisions.mockReturnValue(okAsync([]));
});

describe("StatusBar Backup button", () => {
it("notifies success with path + MB on Ok", async () => {
mockBackupDatabase.mockReturnValueOnce(
okAsync({
absolute_path: "/tmp/backup.sqlite",
size_bytes: 1024 * 1024 * 5, // 5 MB
}),
);

renderWithProviders(<StatusBar />);

fireEvent.click(screen.getByRole("button", { name: /backup/i }));

await waitFor(() => {
const notifications = useUiStore.getState().notifications;
expect(
notifications.some(
(n) =>
n.kind === "info" &&
n.message.includes("/tmp/backup.sqlite") &&
n.message.includes("5.0 MB"),
),
).toBe(true);
});
});

it("notifies typed error on TargetExists", async () => {
const err: CoreError = {
kind: "BackupFailed",
data: {
reason: {
kind: "TargetExists",
data: { path: "/tmp/backup.sqlite" },
},
},
};
mockBackupDatabase.mockReturnValueOnce(errAsync(err));

renderWithProviders(<StatusBar />);

fireEvent.click(screen.getByRole("button", { name: /backup/i }));

await waitFor(() => {
const notifications = useUiStore.getState().notifications;
expect(
notifications.some(
(n) => n.kind === "error" && n.message.includes("already exists"),
),
).toBe(true);
});
});
});
26 changes: 26 additions & 0 deletions apps/desktop/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { listen } from "@tauri-apps/api/event";
import { ResultAsync } from "neverthrow";
import type {
AppEvent,
BackupOutput,
BatchHandle,
BatchId,
BlakeHash,
Expand Down Expand Up @@ -44,6 +45,7 @@ const KNOWN_KINDS: ReadonlySet<CoreError["kind"]> = new Set([
"Unsupported",
"Internal",
"FullHashUnavailable",
"BackupFailed",
]);

/**
Expand Down Expand Up @@ -331,3 +333,27 @@ export function markVerifiedDistinct(
): ResultAsync<void, CoreError> {
return fromInvoke("mark_verified_distinct", { fileUuids });
}

/**
* Backup the SQLite database to `target` (absolute path).
*
* When `target` is omitted, the backend chooses a timestamped path under
* `<data_dir>/backups/`. When `force` is true, an existing file at `target`
* is silently overwritten (otherwise `CoreError::BackupFailed` with reason
* `TargetExists` is returned).
*
* WHY `target?: string` (not required): callers that only want a safe
* one-click backup need not know or construct a path.
*
* @param target - Optional absolute path for the backup file.
* @param force - Overwrite an existing file at `target`. Default false.
*/
export function backupDatabase(
target?: string,
force = false,
): ResultAsync<BackupOutput, CoreError> {
return fromInvoke<BackupOutput>("backup_database", {
target: target ?? null,
force,
});
}
29 changes: 28 additions & 1 deletion apps/desktop/src/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,8 @@ export type CoreError =
| { kind: "Io"; data: { kind: string; message: string } }
| { kind: "Unsupported"; data: string }
| { kind: "Internal"; data: string }
| { kind: "FullHashUnavailable"; data: { reason: FullHashUnavailableReason } };
| { kind: "FullHashUnavailable"; data: { reason: FullHashUnavailableReason } }
| { kind: "BackupFailed"; data: { reason: BackupFailureReason } };

// ── Structs ──────────────────────────────────────────────────────────

Expand Down Expand Up @@ -344,3 +345,29 @@ export type CollisionGroup = {
files: FileLocationRecord[];
verified_state: VerifiedState;
};

// ── Backup types (slice 1, GH #168) ──────────────────────────────────

/**
* Typed reasons a `BackupDatabaseUseCase::execute` call can fail.
* Inner payload of `CoreError::BackupFailed`.
* Rust: `BackupFailureReason` with `#[serde(tag = "kind", content = "data")]`
* (per `crates/core/src/errors.rs`).
*/
export type BackupFailureReason =
| { kind: "TargetExists"; data: { path: string } }
| { kind: "TargetUnwritable"; data: { path: string; message: string } }
| { kind: "DiskFull"; data: { path: string } }
| { kind: "AlreadyInProgress" }
| { kind: "Internal"; data: string };

/**
* Successful output of `backup_database` / `BackupDatabaseUseCase::execute`.
* Rust: `BackupOutput` in `crates/app/src/backup.rs`.
*/
export type BackupOutput = {
/** Absolute path to the freshly written backup file. */
absolute_path: string;
/** Size in bytes of the freshly written backup file. */
size_bytes: number;
};
Loading
Loading