diff --git a/app/client/components/__test__/file-tree.test.tsx b/app/client/components/__test__/file-tree.test.tsx index 70016899..c01f71ca 100644 --- a/app/client/components/__test__/file-tree.test.tsx +++ b/app/client/components/__test__/file-tree.test.tsx @@ -1,6 +1,6 @@ /** biome-ignore-all lint/style/noNonNullAssertion: Testing file - non-null assertions are acceptable here */ -import { expect, test, describe } from "bun:test"; -import { render, screen, fireEvent, within } from "@testing-library/react"; +import { afterEach, expect, test, describe } from "bun:test"; +import { cleanup, render, screen, fireEvent, within } from "@testing-library/react"; import { useState } from "react"; import { FileTree, type FileEntry } from "../file-tree"; @@ -39,6 +39,10 @@ const getSelectedPaths = () => { return JSON.parse(selectedPaths ?? "[]") as string[]; }; +afterEach(() => { + cleanup(); +}); + describe("FileTree Pagination", () => { const testFiles: FileEntry[] = [ { name: "root", path: "/root", type: "folder" }, @@ -181,6 +185,19 @@ describe("FileTree Pagination", () => { expect(screen.queryByText("Load more files")).toBeNull(); }); + + test("renders missing ancestor folders for nested paths", () => { + render( + , + ); + + expect(screen.getByRole("button", { name: "project" })).toBeTruthy(); + }); }); describe("FileTree Selection Logic", () => { diff --git a/app/client/components/__test__/restore-form.test.tsx b/app/client/components/__test__/restore-form.test.tsx new file mode 100644 index 00000000..97dcfbf3 --- /dev/null +++ b/app/client/components/__test__/restore-form.test.tsx @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { HttpResponse, http, server } from "~/test/msw/server"; +import { cleanup, render, screen, userEvent, waitFor, within } from "~/test/test-utils"; +import { fromAny } from "@total-typescript/shoehorn"; + +await mock.module("@tanstack/react-router", () => ({ + useNavigate: () => mock(() => {}), +})); + +import { RestoreForm } from "../restore-form"; + +class MockEventSource { + addEventListener() {} + close() {} + onerror: ((event: Event) => void) | null = null; + + constructor(public url: string) {} +} + +const originalEventSource = globalThis.EventSource; + +beforeEach(() => { + globalThis.EventSource = MockEventSource as unknown as typeof EventSource; +}); + +afterEach(() => { + globalThis.EventSource = originalEventSource; + cleanup(); +}); + +describe("RestoreForm", () => { + test("restores the selected ancestor folder path from a broader display root", async () => { + let restoreRequestBody: unknown; + + server.use( + http.get("/api/v1/repositories/:shortId/snapshots/:snapshotId/files", () => { + return HttpResponse.json({ + files: [ + { name: "subdir", path: "/mnt/project/subdir", type: "dir" }, + { name: "deep.tx", path: "/mnt/project/subdir/deep.tx", type: "file" }, + ], + }); + }), + http.post("/api/v1/repositories/:shortId/restore", async ({ request }) => { + restoreRequestBody = await request.json(); + return HttpResponse.json({ + success: true, + message: "Snapshot restored successfully", + filesRestored: 1, + filesSkipped: 0, + }); + }), + ); + + render( + , + ); + + const row = await screen.findByRole("button", { name: "project" }); + await userEvent.click(within(row).getByRole("checkbox")); + await userEvent.click(screen.getByRole("button", { name: "Restore 1 item" })); + + await waitFor(() => { + expect(restoreRequestBody).toEqual({ + snapshotId: "snap-1", + include: ["/mnt/project"], + overwrite: "always", + }); + }); + }); +}); diff --git a/app/client/components/file-browsers/__tests__/snapshot-tree-browser.test.tsx b/app/client/components/file-browsers/__tests__/snapshot-tree-browser.test.tsx index 568461eb..463f6bc0 100644 --- a/app/client/components/file-browsers/__tests__/snapshot-tree-browser.test.tsx +++ b/app/client/components/file-browsers/__tests__/snapshot-tree-browser.test.tsx @@ -20,7 +20,7 @@ const snapshotFiles = { import { SnapshotTreeBrowser } from "../snapshot-tree-browser"; -const mockListSnapshotFiles = () => { +const mockListSnapshotFiles = (response = snapshotFiles) => { const requests: SnapshotFilesRequest[] = []; server.use( @@ -34,7 +34,7 @@ const mockListSnapshotFiles = () => { limit: url.searchParams.get("limit"), }); - return HttpResponse.json(snapshotFiles); + return HttpResponse.json(response); }), ); @@ -66,6 +66,49 @@ describe("SnapshotTreeBrowser", () => { expect(await screen.findByRole("button", { name: "project" })).toBeTruthy(); }); + test("renders ancestor folders when the query root is nested multiple levels below the display root", async () => { + mockListSnapshotFiles({ + files: [ + { name: "subdir", path: "/mnt/project/subdir", type: "dir" }, + { name: "a.txt", path: "/mnt/project/subdir/a.txt", type: "file" }, + ], + }); + + renderSnapshotTreeBrowser({ + queryBasePath: "/mnt/project/subdir", + displayBasePath: "/mnt", + }); + + expect(await screen.findByRole("button", { name: "project" })).toBeTruthy(); + }); + + test("returns the ancestor folder path when selecting above the query root", async () => { + mockListSnapshotFiles({ + files: [ + { name: "subdir", path: "/mnt/project/subdir", type: "dir" }, + { name: "a.txt", path: "/mnt/project/subdir/a.txt", type: "file" }, + ], + }); + + let selectedPaths: Set | undefined; + + renderSnapshotTreeBrowser({ + queryBasePath: "/mnt/project/subdir", + displayBasePath: "/mnt", + withCheckboxes: true, + onSelectionChange: (paths) => { + selectedPaths = paths; + }, + }); + + const row = await screen.findByRole("button", { name: "project" }); + const checkbox = within(row).getByRole("checkbox"); + + await userEvent.click(checkbox); + + expect(selectedPaths ? Array.from(selectedPaths) : []).toEqual(["/mnt/project"]); + }); + test("shows selected folder state when full paths are provided from the parent", async () => { mockListSnapshotFiles(); diff --git a/app/client/components/file-tree.tsx b/app/client/components/file-tree.tsx index ca543893..47763725 100644 --- a/app/client/components/file-tree.tsx +++ b/app/client/components/file-tree.tsx @@ -627,6 +627,10 @@ function buildFileList(files: FileEntry[], foldersOnly = false): Node[] { const depth = segments.length - 1; const name = segments[segments.length - 1]; + if (!name) { + continue; + } + if (!fileMap.has(file.path)) { const isFile = file.type === "file"; fileMap.set(file.path, { @@ -638,6 +642,33 @@ function buildFileList(files: FileEntry[], foldersOnly = false): Node[] { size: file.size, }); } + + let parentPath = file.path; + while (true) { + const lastSlashIndex = parentPath.lastIndexOf("/"); + if (lastSlashIndex <= 0) { + break; + } + + parentPath = parentPath.slice(0, lastSlashIndex); + if (fileMap.has(parentPath)) { + continue; + } + + const parentSegments = parentPath.split("/").filter((segment) => segment); + const parentName = parentSegments[parentSegments.length - 1]; + if (!parentName) { + continue; + } + + fileMap.set(parentPath, { + kind: "folder", + id: fileMap.size, + name: parentName, + fullPath: parentPath, + depth: parentSegments.length - 1, + }); + } } // Convert map to array and sort diff --git a/app/client/modules/backups/components/__tests__/snapshot-file-browser.test.tsx b/app/client/modules/backups/components/__tests__/snapshot-file-browser.test.tsx index 2fe14a0c..7940a2fc 100644 --- a/app/client/modules/backups/components/__tests__/snapshot-file-browser.test.tsx +++ b/app/client/modules/backups/components/__tests__/snapshot-file-browser.test.tsx @@ -23,6 +23,7 @@ import { SnapshotFileBrowser } from "../snapshot-file-browser"; afterEach(() => { cleanup(); + mock.restore(); }); describe("SnapshotFileBrowser", () => { 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 7111f932..97d8c6f6 100644 --- a/app/client/modules/backups/routes/__tests__/backup-details.test.tsx +++ b/app/client/modules/backups/routes/__tests__/backup-details.test.tsx @@ -78,6 +78,7 @@ const mockScheduleDetailsRequests = () => { afterEach(() => { cleanup(); + mock.restore(); }); describe("ScheduleDetailsPage", () => {