Skip to content

Commit ff980c0

Browse files
authored
Merge pull request #172 from utof/backup-slice-1/v0.6.x
feat: DB backup primitive (slice 1 of #168)
2 parents cb415eb + fe784db commit ff980c0

36 files changed

Lines changed: 1709 additions & 29 deletions

Cargo.lock

Lines changed: 86 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ clap = { version = "4", features = ["derive"] }
9292
tempfile = "3"
9393
insta = { version = "1", features = ["yaml", "filters"] }
9494
proptest = "1"
95+
assert_cmd = "2"
96+
predicates = "3"
9597
rayon = "1"
9698
ctrlc = { version = "3", features = ["termination"] }
9799
chrono = { version = "0.4", features = ["serde"] }
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* StatusBar Backup button — Task 9 of the database-backup slice.
3+
*
4+
* Branches under test:
5+
* - Click Backup → api.backupDatabase resolves Ok → info toast with
6+
* absolute_path and formatted MB.
7+
* - Click Backup → api.backupDatabase resolves Err(BackupFailed/TargetExists)
8+
* → error toast that includes "already exists".
9+
*
10+
* WHY mock `../api` (not `@tauri-apps/api/core`): the perima frontend test
11+
* pattern mocks the API layer with `vi.importActual` partial spread, returning
12+
* `okAsync`/`errAsync` from neverthrow. Mocking `invoke` directly bypasses
13+
* `fromInvoke`'s `parseCoreError` and produces a different shape than
14+
* `useBackupDatabase`'s `.match(...)` branch consumes.
15+
*/
16+
import { describe, it, expect, vi, beforeEach } from "vitest";
17+
import { fireEvent, screen, waitFor } from "@testing-library/react";
18+
import { errAsync, okAsync } from "neverthrow";
19+
import StatusBar from "../components/StatusBar";
20+
import * as api from "../api";
21+
import { useUiStore } from "../stores/ui";
22+
import { renderWithProviders, resetUiStore } from "./test-utils";
23+
import type { CoreError } from "../bindings";
24+
25+
vi.mock("../api", async () => {
26+
const actual = await vi.importActual<typeof import("../api")>("../api");
27+
return {
28+
...actual,
29+
backupDatabase: vi.fn(),
30+
// WHY listQuickHashCollisions: StatusBar renders <CollisionPill> which
31+
// calls useCollisions() → listQuickHashCollisions. Without a mock the
32+
// real `invoke` fires, which fails in jsdom. Return an empty list so the
33+
// pill renders in its neutral "0 groups" state without affecting the
34+
// backup-button assertions.
35+
listQuickHashCollisions: vi.fn(),
36+
};
37+
});
38+
39+
const mockBackupDatabase = vi.mocked(api.backupDatabase);
40+
const mockListCollisions = vi.mocked(api.listQuickHashCollisions);
41+
42+
beforeEach(() => {
43+
vi.clearAllMocks();
44+
resetUiStore();
45+
mockListCollisions.mockReturnValue(okAsync([]));
46+
});
47+
48+
describe("StatusBar Backup button", () => {
49+
it("notifies success with path + MB on Ok", async () => {
50+
mockBackupDatabase.mockReturnValueOnce(
51+
okAsync({
52+
absolute_path: "/tmp/backup.sqlite",
53+
size_bytes: 1024 * 1024 * 5, // 5 MB
54+
}),
55+
);
56+
57+
renderWithProviders(<StatusBar />);
58+
59+
fireEvent.click(screen.getByRole("button", { name: /backup/i }));
60+
61+
await waitFor(() => {
62+
const notifications = useUiStore.getState().notifications;
63+
expect(
64+
notifications.some(
65+
(n) =>
66+
n.kind === "info" &&
67+
n.message.includes("/tmp/backup.sqlite") &&
68+
n.message.includes("5.0 MB"),
69+
),
70+
).toBe(true);
71+
});
72+
});
73+
74+
it("notifies typed error on TargetExists", async () => {
75+
const err: CoreError = {
76+
kind: "BackupFailed",
77+
data: {
78+
reason: {
79+
kind: "TargetExists",
80+
data: { path: "/tmp/backup.sqlite" },
81+
},
82+
},
83+
};
84+
mockBackupDatabase.mockReturnValueOnce(errAsync(err));
85+
86+
renderWithProviders(<StatusBar />);
87+
88+
fireEvent.click(screen.getByRole("button", { name: /backup/i }));
89+
90+
await waitFor(() => {
91+
const notifications = useUiStore.getState().notifications;
92+
expect(
93+
notifications.some(
94+
(n) => n.kind === "error" && n.message.includes("already exists"),
95+
),
96+
).toBe(true);
97+
});
98+
});
99+
});

apps/desktop/src/api.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { listen } from "@tauri-apps/api/event";
1212
import { ResultAsync } from "neverthrow";
1313
import type {
1414
AppEvent,
15+
BackupOutput,
1516
BatchHandle,
1617
BatchId,
1718
BlakeHash,
@@ -44,6 +45,7 @@ const KNOWN_KINDS: ReadonlySet<CoreError["kind"]> = new Set([
4445
"Unsupported",
4546
"Internal",
4647
"FullHashUnavailable",
48+
"BackupFailed",
4749
]);
4850

4951
/**
@@ -331,3 +333,27 @@ export function markVerifiedDistinct(
331333
): ResultAsync<void, CoreError> {
332334
return fromInvoke("mark_verified_distinct", { fileUuids });
333335
}
336+
337+
/**
338+
* Backup the SQLite database to `target` (absolute path).
339+
*
340+
* When `target` is omitted, the backend chooses a timestamped path under
341+
* `<data_dir>/backups/`. When `force` is true, an existing file at `target`
342+
* is silently overwritten (otherwise `CoreError::BackupFailed` with reason
343+
* `TargetExists` is returned).
344+
*
345+
* WHY `target?: string` (not required): callers that only want a safe
346+
* one-click backup need not know or construct a path.
347+
*
348+
* @param target - Optional absolute path for the backup file.
349+
* @param force - Overwrite an existing file at `target`. Default false.
350+
*/
351+
export function backupDatabase(
352+
target?: string,
353+
force = false,
354+
): ResultAsync<BackupOutput, CoreError> {
355+
return fromInvoke<BackupOutput>("backup_database", {
356+
target: target ?? null,
357+
force,
358+
});
359+
}

apps/desktop/src/bindings.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,8 @@ export type CoreError =
148148
| { kind: "Io"; data: { kind: string; message: string } }
149149
| { kind: "Unsupported"; data: string }
150150
| { kind: "Internal"; data: string }
151-
| { kind: "FullHashUnavailable"; data: { reason: FullHashUnavailableReason } };
151+
| { kind: "FullHashUnavailable"; data: { reason: FullHashUnavailableReason } }
152+
| { kind: "BackupFailed"; data: { reason: BackupFailureReason } };
152153

153154
// ── Structs ──────────────────────────────────────────────────────────
154155

@@ -344,3 +345,29 @@ export type CollisionGroup = {
344345
files: FileLocationRecord[];
345346
verified_state: VerifiedState;
346347
};
348+
349+
// ── Backup types (slice 1, GH #168) ──────────────────────────────────
350+
351+
/**
352+
* Typed reasons a `BackupDatabaseUseCase::execute` call can fail.
353+
* Inner payload of `CoreError::BackupFailed`.
354+
* Rust: `BackupFailureReason` with `#[serde(tag = "kind", content = "data")]`
355+
* (per `crates/core/src/errors.rs`).
356+
*/
357+
export type BackupFailureReason =
358+
| { kind: "TargetExists"; data: { path: string } }
359+
| { kind: "TargetUnwritable"; data: { path: string; message: string } }
360+
| { kind: "DiskFull"; data: { path: string } }
361+
| { kind: "AlreadyInProgress" }
362+
| { kind: "Internal"; data: string };
363+
364+
/**
365+
* Successful output of `backup_database` / `BackupDatabaseUseCase::execute`.
366+
* Rust: `BackupOutput` in `crates/app/src/backup.rs`.
367+
*/
368+
export type BackupOutput = {
369+
/** Absolute path to the freshly written backup file. */
370+
absolute_path: string;
371+
/** Size in bytes of the freshly written backup file. */
372+
size_bytes: number;
373+
};

0 commit comments

Comments
 (0)