A /watch link may now open one of its owner's own folders (migration 0108: folder_id beside job_id, exactly one set; a folder's links go with it). The page shows what is in the folder when it is opened, newest first: a video or a song plays in the same player a file link has, above the list, each keeping its own resume point; a document only downloads, never opens as a page. A file moved out stops opening through the link at its next request. Password, expiry and stream-only work as on a file link; the unfurl names the folder and counts its files, never their names. Source folders and the Hub cannot be shared. The folder menu gets "Copy link" and "Share with a link…" (the Share dialog's link half); the rail marks a folder a live link opens. Two fixes in the Share dialog on the way: revoking the last link no longer mints a new one at once, and the new-link form goes back to downloadable after making a link.
211 lines
8.3 KiB
TypeScript
211 lines
8.3 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The share dialog's link row says two things with colour alone: a PROTECTED link's lock is lit, and
|
|
// Revoke is the destructive one. Both colours once sat on the button, where the ghost variant's own
|
|
// colour class outranks them (clsx does not merge Tailwind), so a password-protected link looked
|
|
// unprotected and Revoke hovered neutral. They live on the ICON now; this pins them there.
|
|
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
|
import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import type { ReactNode } from "react";
|
|
|
|
// Partial: the import graph reaches `src/i18n`, which initialises against the real
|
|
// `initReactI18next`, so a wholesale mock throws before any test runs.
|
|
vi.mock("react-i18next", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("react-i18next")>()),
|
|
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
|
}));
|
|
vi.mock("./ui/Modal", () => ({
|
|
default: ({ title, children }: { title: string; children: ReactNode }) => (
|
|
<div>
|
|
<h2>{title}</h2>
|
|
{children}
|
|
</div>
|
|
),
|
|
}));
|
|
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => h.confirm }));
|
|
const h = vi.hoisted(() => ({
|
|
confirm: false,
|
|
revokeDownloadLink: vi.fn(),
|
|
links: null as unknown[] | null,
|
|
folderLinks: vi.fn(),
|
|
createFolderLink: vi.fn(),
|
|
createDownloadLink: vi.fn(),
|
|
}));
|
|
vi.mock("../lib/api", () => ({
|
|
api: {
|
|
shareRecipients: async () => [],
|
|
folderLinks: h.folderLinks,
|
|
createFolderLink: h.createFolderLink,
|
|
createDownloadLink: h.createDownloadLink,
|
|
revokeDownloadLink: h.revokeDownloadLink,
|
|
downloadLinks: async () =>
|
|
h.links ?? [
|
|
{
|
|
id: 1,
|
|
token: "a",
|
|
url: "/watch/a",
|
|
allow_download: false,
|
|
has_password: true,
|
|
expires_at: null,
|
|
view_count: 0,
|
|
created_at: null,
|
|
},
|
|
{
|
|
id: 2,
|
|
token: "b",
|
|
url: "/watch/b",
|
|
allow_download: false,
|
|
has_password: false,
|
|
expires_at: null,
|
|
view_count: 0,
|
|
created_at: null,
|
|
},
|
|
],
|
|
},
|
|
}));
|
|
|
|
import ShareDialog from "./ShareDialog";
|
|
import type { DownloadJob } from "../lib/api";
|
|
|
|
afterEach(cleanup);
|
|
beforeEach(() => {
|
|
h.links = null;
|
|
h.confirm = false;
|
|
});
|
|
|
|
const iconClass = (button: HTMLElement) => button.querySelector("svg")?.getAttribute("class") ?? "";
|
|
|
|
describe("ShareDialog · a link row's state colours", () => {
|
|
it("lights the lock of a protected link only, and paints Revoke as danger — on the icon", async () => {
|
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
render(
|
|
<QueryClientProvider client={qc}>
|
|
<ShareDialog job={{ id: 9 } as DownloadJob} onClose={() => {}} />
|
|
</QueryClientProvider>,
|
|
);
|
|
const protectedLock = await screen.findByRole("button", {
|
|
name: "downloads.share.changePassword",
|
|
});
|
|
const openLock = screen.getByRole("button", { name: "downloads.share.setPassword" });
|
|
expect(iconClass(protectedLock)).toContain("text-accent");
|
|
expect(iconClass(openLock)).not.toContain("text-accent");
|
|
|
|
for (const revoke of screen.getAllByRole("button", { name: "downloads.share.revoke" }))
|
|
expect(iconClass(revoke)).toContain("text-danger");
|
|
});
|
|
});
|
|
|
|
describe("ShareDialog · a link whose address cannot be shown again", () => {
|
|
it("says so in place of the address, with nothing to copy", async () => {
|
|
const link = (id: number, url: string | null) => ({
|
|
id,
|
|
token: url && url.slice("/watch/".length),
|
|
url,
|
|
allow_download: false,
|
|
has_password: false,
|
|
expires_at: null,
|
|
view_count: 0,
|
|
created_at: null,
|
|
});
|
|
h.links = [link(3, null), link(2, "/watch/b")];
|
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
render(
|
|
<QueryClientProvider client={qc}>
|
|
<ShareDialog job={{ id: 9 } as DownloadJob} onClose={() => {}} />
|
|
</QueryClientProvider>,
|
|
);
|
|
expect(await screen.findByText("downloads.share.urlHidden")).toBeTruthy();
|
|
// One row has an address to show and copy; the hidden one shows none (not "…/null").
|
|
expect(screen.getAllByRole("button", { name: "downloads.share.copy" })).toHaveLength(1);
|
|
const shown = screen.getAllByRole("textbox").map((el) => (el as HTMLInputElement).value);
|
|
expect(shown).toContain(`${window.location.origin}/watch/b`);
|
|
expect(shown.some((v) => v.includes("null"))).toBe(false);
|
|
// Its other controls stay: it can still be protected, made downloadable, or revoked.
|
|
expect(screen.getAllByRole("button", { name: "downloads.share.revoke" })).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
describe("ShareDialog · a folder", () => {
|
|
const link = (id: number) => ({
|
|
id,
|
|
token: `f${id}`,
|
|
url: `/watch/f${id}`,
|
|
allow_download: true,
|
|
has_password: false,
|
|
expires_at: null,
|
|
view_count: 0,
|
|
created_at: null,
|
|
});
|
|
|
|
it("shares by link only, says what a folder link shows, and makes it for the folder", async () => {
|
|
h.folderLinks.mockReset().mockResolvedValue([link(4)]);
|
|
h.createFolderLink.mockReset().mockResolvedValue(link(5));
|
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
render(
|
|
<QueryClientProvider client={qc}>
|
|
<ShareDialog folder={{ id: 7, name: "Friends" }} onClose={() => {}} />
|
|
</QueryClientProvider>,
|
|
);
|
|
expect(await screen.findByDisplayValue(`${window.location.origin}/watch/f4`)).toBeTruthy();
|
|
expect(h.folderLinks).toHaveBeenCalledWith(7);
|
|
expect(screen.getByRole("heading").textContent).toBe("downloads.share.folderTitle");
|
|
expect(screen.getByText("downloads.share.folderLinkHint")).toBeTruthy();
|
|
// No sharing with a user: "Shared with me" lists files, not folders.
|
|
expect(screen.queryByText("downloads.share.userSection")).toBeNull();
|
|
|
|
// A stream-only link, then the form is back at its default for the next one: downloadable.
|
|
fireEvent.click(screen.getByRole("button", { name: "downloads.share.newLink" }));
|
|
// The form's own checkbox, under the link rows' (each row has its own).
|
|
const allow = () => {
|
|
const boxes = screen.getAllByRole("checkbox", { name: "downloads.share.allowDownload" });
|
|
return boxes[boxes.length - 1]!;
|
|
};
|
|
fireEvent.click(allow());
|
|
expect((allow() as HTMLInputElement).checked).toBe(false);
|
|
fireEvent.click(screen.getByRole("button", { name: "downloads.share.createLink" }));
|
|
await waitFor(() =>
|
|
expect(h.createFolderLink).toHaveBeenCalledWith(
|
|
7,
|
|
expect.objectContaining({
|
|
allow_download: false,
|
|
}),
|
|
),
|
|
);
|
|
expect(h.createDownloadLink).not.toHaveBeenCalled();
|
|
fireEvent.click(await screen.findByRole("button", { name: "downloads.share.newLink" }));
|
|
expect((allow() as HTMLInputElement).checked).toBe(true);
|
|
});
|
|
|
|
it("makes a link on opening with none, but never right after the last one was revoked", async () => {
|
|
h.folderLinks.mockReset().mockResolvedValue([]);
|
|
h.createFolderLink.mockReset().mockResolvedValue(link(6));
|
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
const view = render(
|
|
<QueryClientProvider client={qc}>
|
|
<ShareDialog folder={{ id: 7, name: "Friends" }} onClose={() => {}} />
|
|
</QueryClientProvider>,
|
|
);
|
|
await waitFor(() => expect(h.createFolderLink).toHaveBeenCalledTimes(1));
|
|
view.unmount();
|
|
|
|
h.confirm = true;
|
|
h.folderLinks
|
|
.mockReset()
|
|
.mockResolvedValueOnce([link(4)])
|
|
.mockResolvedValue([]);
|
|
h.createFolderLink.mockReset();
|
|
h.revokeDownloadLink.mockReset().mockResolvedValue({ ok: true, deleted: 4 });
|
|
const qc2 = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
render(
|
|
<QueryClientProvider client={qc2}>
|
|
<ShareDialog folder={{ id: 7, name: "Friends" }} onClose={() => {}} />
|
|
</QueryClientProvider>,
|
|
);
|
|
fireEvent.click(await screen.findByRole("button", { name: "downloads.share.revoke" }));
|
|
await waitFor(() => expect(h.revokeDownloadLink).toHaveBeenCalledWith(4));
|
|
await screen.findByText("downloads.share.noLinks");
|
|
expect(h.createFolderLink).not.toHaveBeenCalled();
|
|
});
|
|
});
|