feat(downloads): public generic-file watch surface (E7 S5d)

The /watch/{token} SPA gets a third branch: media_kind==='file' renders an
info card (name / TYPE · size) + a Download button, no player. The <video>/
<audio> branches now guard on media_kind!=='file' so legacy links
(media_kind null) still play. Download is gated on the link's allow_download,
mirroring the media branch; the backend serves a file force-download
(attachment + octet-stream) regardless, so the gate only reflects owner ACL.

FE-only (public_meta already emits media_kind/size_bytes/container since S5a).
i18n HU/EN/DE 'file' fallback label. Two DOM tests: file -> info card +
Download, no player; view-only file -> no Download.
This commit is contained in:
2026-08-29 19:51:58 +02:00
parent 584e9ced0c
commit 104bd1e3a6
2 changed files with 76 additions and 2 deletions
@@ -36,4 +36,41 @@ describe("WatchPage media element (S3 · 5a)", () => {
await waitFor(() => expect(container.querySelector("video")).not.toBeNull());
expect(container.querySelector("audio")).toBeNull();
});
it("renders an info card + Download for a generic file, no player (S5d)", async () => {
window.history.replaceState({}, "", "/watch/tok-file");
mockMeta({
needs_password: false,
media_kind: "file",
title: "report.pdf",
container: "pdf",
size_bytes: 2_400_000,
allow_download: true,
file_url: "/f.pdf",
});
const { container, findByText } = render(<WatchPage />);
await findByText("report.pdf");
expect(container.querySelector("video")).toBeNull();
expect(container.querySelector("audio")).toBeNull();
// Type · size chip and a Download link to the file.
await findByText(/PDF · 2\.3 MB/);
const dl = container.querySelector('a[href="/f.pdf"]');
expect(dl).not.toBeNull();
});
it("omits the Download for a view-only file link (S5d)", async () => {
window.history.replaceState({}, "", "/watch/tok-file-noview");
mockMeta({
needs_password: false,
media_kind: "file",
title: "secret.zip",
container: "zip",
size_bytes: 1000,
allow_download: false,
file_url: "/f.zip",
});
const { container, findByText } = render(<WatchPage />);
await findByText("secret.zip");
expect(container.querySelector('a[href="/f.zip"]')).toBeNull();
});
});