fix(downloads): media-library UI review fixes (E7)
Deep-review remediation on the frontend, all findings adversarially verified: - LibraryPlayer picks audio vs video from the canonical media_kind (single source), not a hardcoded container whitelist, so an uploaded audio file with an uncommon/absent container renders in <audio> instead of a broken video box (legacy null media_kind keeps the heuristic). - UploadDialog aborts the in-flight upload when the dialog is closed by X/ESC/backdrop/Back (not only the footer Cancel); drag highlight uses an enter/leave depth counter to stop the flicker; the progress bar gets a stable role=progressbar aria-label with the value on aria-valuenow. - DownloadCenter's Shared tab renders honest loading/error states instead of a blank panel, and the library/shared row-action gating moves to a single tested helper (lib/downloadActions). Tests: downloadActions gating matrix, the uploadReq XHR path (abort/401/422-localize/onerror), and the media_kind-driven audio/video branch. i18n: downloads.upload.uploadProgress (HU + EN).
This commit is contained in:
@@ -35,6 +35,7 @@ import { useLiveQuery } from "../lib/useLiveQuery";
|
|||||||
import { useMe } from "../lib/useMe";
|
import { useMe } from "../lib/useMe";
|
||||||
import { api, type DownloadJob } from "../lib/api";
|
import { api, type DownloadJob } from "../lib/api";
|
||||||
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
|
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
|
||||||
|
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
|
||||||
import { inputCls } from "./ui/form";
|
import { inputCls } from "./ui/form";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import { DownloadRow, IconBtn, UsageBar } from "./downloadRow";
|
import { DownloadRow, IconBtn, UsageBar } from "./downloadRow";
|
||||||
@@ -280,28 +281,22 @@ export default function DownloadCenter() {
|
|||||||
<UsageBar />
|
<UsageBar />
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{library.map((job) => {
|
{library.map((job) => {
|
||||||
// E7·S5b — gate row actions on the stored media_kind. A generic uploaded file
|
// Row actions gated on the stored media_kind + job_kind (see downloadActions).
|
||||||
// (media_kind='file') is download+share only: no player and no timeline to trim
|
const a = libraryRowActions(job);
|
||||||
// (Play/Edit are media-only), and no metadata/channel edit. Re-download needs a
|
|
||||||
// re-fetchable source, which an edit clip and an upload both lack (backend rejects
|
|
||||||
// both), so it's a true-download-only action.
|
|
||||||
const isFile = job.asset?.media_kind === "file";
|
|
||||||
const isMedia = !isFile;
|
|
||||||
const canRedownload = job.job_kind !== "edit" && job.job_kind !== "upload";
|
|
||||||
return (
|
return (
|
||||||
<DownloadRow key={job.id} job={job}>
|
<DownloadRow key={job.id} job={job}>
|
||||||
{isMedia && (
|
{a.play && (
|
||||||
<IconBtn onClick={() => setPlayJob(job)} title={t("downloads.actions.play")}>
|
<IconBtn onClick={() => setPlayJob(job)} title={t("downloads.actions.play")}>
|
||||||
<PlayCircle className="w-4 h-4" />
|
<PlayCircle className="w-4 h-4" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
)}
|
)}
|
||||||
{saveBtn(job)}
|
{saveBtn(job)}
|
||||||
{isMedia && job.can_download && (job.asset?.duration_s ?? 0) > 0 && (
|
{a.edit && (
|
||||||
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
|
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
|
||||||
<Scissors className="w-4 h-4" />
|
<Scissors className="w-4 h-4" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
)}
|
)}
|
||||||
{isMedia && (
|
{a.editDetails && (
|
||||||
<IconBtn
|
<IconBtn
|
||||||
onClick={() => setMetaJob(job)}
|
onClick={() => setMetaJob(job)}
|
||||||
title={t("downloads.actions.editDetails")}
|
title={t("downloads.actions.editDetails")}
|
||||||
@@ -318,7 +313,7 @@ export default function DownloadCenter() {
|
|||||||
>
|
>
|
||||||
<Link2 className="w-4 h-4" />
|
<Link2 className="w-4 h-4" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
{canRedownload && (
|
{a.redownload && (
|
||||||
<IconBtn
|
<IconBtn
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
confirmThen(
|
confirmThen(
|
||||||
@@ -367,18 +362,17 @@ export default function DownloadCenter() {
|
|||||||
{tab === "shared" && (
|
{tab === "shared" && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{(sharedQ.data ?? []).map((job) => {
|
{(sharedQ.data ?? []).map((job) => {
|
||||||
// Same media_kind gate as the library (S5b): a shared generic file is download-only —
|
// Same media_kind + can_download gate as the library, via the shared helper (S5b).
|
||||||
// no player, no trim. Its owner set can_download, which still gates save/edit here.
|
const a = sharedRowActions(job);
|
||||||
const isMedia = job.asset?.media_kind !== "file";
|
|
||||||
return (
|
return (
|
||||||
<DownloadRow key={job.id} job={job}>
|
<DownloadRow key={job.id} job={job}>
|
||||||
{isMedia && job.can_download && (
|
{a.play && (
|
||||||
<IconBtn onClick={() => setPlayJob(job)} title={t("downloads.actions.play")}>
|
<IconBtn onClick={() => setPlayJob(job)} title={t("downloads.actions.play")}>
|
||||||
<PlayCircle className="w-4 h-4" />
|
<PlayCircle className="w-4 h-4" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
)}
|
)}
|
||||||
{job.can_download && saveBtn(job)}
|
{a.save && saveBtn(job)}
|
||||||
{isMedia && job.can_download && (job.asset?.duration_s ?? 0) > 0 && (
|
{a.edit && (
|
||||||
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
|
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
|
||||||
<Scissors className="w-4 h-4" />
|
<Scissors className="w-4 h-4" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
@@ -399,11 +393,18 @@ export default function DownloadCenter() {
|
|||||||
</DownloadRow>
|
</DownloadRow>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{sharedQ.data && sharedQ.data.length === 0 && (
|
{(sharedQ.data ?? []).length === 0 &&
|
||||||
<div className="text-sm text-muted py-10 text-center">
|
(sharedQ.isError && !sharedQ.data ? (
|
||||||
{t("downloads.empty.shared")}
|
<StateMessage tone="error" onRetry={() => sharedQ.refetch()}>
|
||||||
</div>
|
{t("common.loadError")}
|
||||||
)}
|
</StateMessage>
|
||||||
|
) : sharedQ.isLoading ? (
|
||||||
|
<LoadingState />
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted py-10 text-center">
|
||||||
|
{t("downloads.empty.shared")}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
//
|
//
|
||||||
// E7·S4b — the in-app library player streams the completed item's LOCAL file into a native media
|
// E7·S4b — the in-app library player streams the completed item's LOCAL file into a native media
|
||||||
// element (no transcode; S4a guarantees a browser-native container). A video asset (has pixel dims)
|
// element. video/audio is chosen from the stored media_kind (the single source), NOT a container
|
||||||
// renders <video src=/file>; an audio-only asset (m4a/mp3, no dims) renders <audio src=/file>. i18n is
|
// list — so an audio upload with an uncommon/absent container still renders <audio src=/file>. i18n
|
||||||
// key-passthrough here.
|
// is key-passthrough here.
|
||||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||||
import { render, cleanup } from "@testing-library/react";
|
import { render, cleanup } from "@testing-library/react";
|
||||||
|
|
||||||
@@ -89,11 +89,37 @@ describe("LibraryPlayer", () => {
|
|||||||
expect(document.body.querySelector("audio")).toBeNull();
|
expect(document.body.querySelector("audio")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("plays an audio-only asset (no dims, m4a) in an <audio> element", () => {
|
it("plays an audio-only asset (media_kind='audio') in an <audio> element", () => {
|
||||||
render(<LibraryPlayer job={withAsset({ container: "m4a" })} onClose={() => {}} />);
|
render(
|
||||||
|
<LibraryPlayer
|
||||||
|
job={withAsset({ media_kind: "audio", container: "m4a" })}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
const audio = document.body.querySelector("audio");
|
const audio = document.body.querySelector("audio");
|
||||||
expect(audio).not.toBeNull();
|
expect(audio).not.toBeNull();
|
||||||
expect(audio!.getAttribute("src")).toBe("/api/downloads/7/file");
|
expect(audio!.getAttribute("src")).toBe("/api/downloads/7/file");
|
||||||
expect(document.body.querySelector("video")).toBeNull();
|
expect(document.body.querySelector("video")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses media_kind — an audio upload with an uncommon container still renders <audio>", () => {
|
||||||
|
// The exact bug the fix targets: the old container whitelist (m4a/mp3/…) would drop an .m4b/.weba
|
||||||
|
// audio upload into a <video> box. media_kind='audio' is authoritative regardless of container.
|
||||||
|
render(
|
||||||
|
<LibraryPlayer
|
||||||
|
job={withAsset({ media_kind: "audio", container: "m4b" })}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(document.body.querySelector("audio")).not.toBeNull();
|
||||||
|
expect(document.body.querySelector("video")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the container heuristic for a legacy null media_kind", () => {
|
||||||
|
// A pre-discriminator asset (media_kind null) still resolves audio vs video from dims+container.
|
||||||
|
render(
|
||||||
|
<LibraryPlayer job={withAsset({ media_kind: null, container: "mp3" })} onClose={() => {}} />,
|
||||||
|
);
|
||||||
|
expect(document.body.querySelector("audio")).not.toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,19 +8,24 @@ import { jobTitle } from "./downloadRow";
|
|||||||
// YouTube IFrame shell) or PlexPlayer (a Plex HLS/session shell): both are bound to their own domain
|
// YouTube IFrame shell) or PlexPlayer (a Plex HLS/session shell): both are bound to their own domain
|
||||||
// APIs and can't point at a local file. Instead we reuse the shared a11y shell (`Modal`: focus-trap,
|
// APIs and can't point at a local file. Instead we reuse the shared a11y shell (`Modal`: focus-trap,
|
||||||
// ESC/back-to-close, scroll-lock, layer-stack, portal) and stream the local file into a NATIVE
|
// ESC/back-to-close, scroll-lock, layer-stack, portal) and stream the local file into a NATIVE
|
||||||
// <video>/<audio controls> — the same element VideoEditor already plays this URL in. S4a guarantees
|
// <video>/<audio controls> — the same element VideoEditor already plays this URL in. Downloads are a
|
||||||
// every download is a browser-native container (mp4/H.264/AAC + m4a/mp3), so native playback (seek via
|
// browser-native container (mp4/H.264/AAC + m4a/mp3), so native playback (seek via the range-aware
|
||||||
// the range-aware /file endpoint, volume, fullscreen, speed) always works with zero transcode.
|
// /file endpoint, volume, fullscreen, speed) works with zero transcode; an uploaded media item may be
|
||||||
|
// any codec (best-effort playback).
|
||||||
export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
|
export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const fileUrl = useMemo(() => api.downloadFileUrl(job.id), [job.id]);
|
const fileUrl = useMemo(() => api.downloadFileUrl(job.id), [job.id]);
|
||||||
// Video vs audio-only: a video asset carries real pixel dimensions; audio (m4a/mp3) has none. Fall
|
// Video vs audio-only comes from the stored media_kind (the single source every sibling reads —
|
||||||
// back to the container kind so a dimension-less-but-video asset still gets the <video> element.
|
// WatchPage/downloadRow/DownloadCenter) — NOT a re-derived container list, which would misclassify
|
||||||
|
// an uploaded audio file whose container is outside a hardcoded set (uploads accept any container).
|
||||||
|
// Only fall back to the old dimension+container heuristic for a legacy asset with a null media_kind.
|
||||||
const asset = job.asset;
|
const asset = job.asset;
|
||||||
const isAudio =
|
const isAudio =
|
||||||
!(asset?.width && asset?.height) &&
|
asset?.media_kind === "audio" ||
|
||||||
!!asset?.container &&
|
(asset?.media_kind == null &&
|
||||||
["m4a", "mp3", "opus", "ogg", "wav", "flac", "aac"].includes(asset.container.toLowerCase());
|
!(asset?.width && asset?.height) &&
|
||||||
|
!!asset?.container &&
|
||||||
|
["m4a", "mp3", "opus", "ogg", "wav", "flac", "aac"].includes(asset.container.toLowerCase()));
|
||||||
// Our self-hosted poster (always same-origin, never expires) is the primary; the source thumbnail is
|
// Our self-hosted poster (always same-origin, never expires) is the primary; the source thumbnail is
|
||||||
// the fallback. Same precedence as the library-row thumbnail.
|
// the fallback. Same precedence as the library-row thumbnail.
|
||||||
const poster = job.poster_url ? api.posterImageUrl(job.id) : (asset?.thumbnail_url ?? undefined);
|
const poster = job.poster_url ? api.posterImageUrl(job.id) : (asset?.thumbnail_url ?? undefined);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { UploadCloud, File as FileIcon } from "lucide-react";
|
import { UploadCloud, File as FileIcon } from "lucide-react";
|
||||||
@@ -24,8 +24,16 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
|
|||||||
const [pct, setPct] = useState<number | null>(null); // null = idle; 0–100 while uploading
|
const [pct, setPct] = useState<number | null>(null); // null = idle; 0–100 while uploading
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
// dragenter/dragleave fire as the cursor crosses the drop zone's OWN children (icon/text), so a
|
||||||
|
// plain boolean flickers. Count enter vs leave and only clear the highlight at depth 0.
|
||||||
|
const dragDepth = useRef(0);
|
||||||
const busy = pct !== null;
|
const busy = pct !== null;
|
||||||
|
|
||||||
|
// Closing the dialog mid-upload (X / ESC / backdrop / browser Back all unmount us) must abort the
|
||||||
|
// in-flight XHR — otherwise it keeps streaming in the background and still toasts success. Only the
|
||||||
|
// footer Cancel wired the abort; this covers every other dismissal.
|
||||||
|
useEffect(() => () => abortRef.current?.abort(), []);
|
||||||
|
|
||||||
const pick = () => inputRef.current?.click();
|
const pick = () => inputRef.current?.click();
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
@@ -62,13 +70,19 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={pick}
|
onClick={pick}
|
||||||
onDragOver={(e) => {
|
onDragEnter={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
dragDepth.current += 1;
|
||||||
setDragOver(true);
|
setDragOver(true);
|
||||||
}}
|
}}
|
||||||
onDragLeave={() => setDragOver(false)}
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDragLeave={() => {
|
||||||
|
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
||||||
|
if (dragDepth.current === 0) setDragOver(false);
|
||||||
|
}}
|
||||||
onDrop={(e) => {
|
onDrop={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
dragDepth.current = 0;
|
||||||
setDragOver(false);
|
setDragOver(false);
|
||||||
const f = e.dataTransfer.files?.[0];
|
const f = e.dataTransfer.files?.[0];
|
||||||
if (f) {
|
if (f) {
|
||||||
@@ -123,7 +137,16 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
|
|||||||
|
|
||||||
{busy && (
|
{busy && (
|
||||||
<div className="mb-5" aria-live="polite">
|
<div className="mb-5" aria-live="polite">
|
||||||
<div className="h-2 rounded-full bg-surface overflow-hidden">
|
<div
|
||||||
|
className="h-2 rounded-full bg-surface overflow-hidden"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={pct ?? 0}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
// A STABLE descriptor as the name; the live percentage rides aria-valuenow (and the visible
|
||||||
|
// <p> below re-announces it via the aria-live wrapper) — don't bake the value into the name.
|
||||||
|
aria-label={t("downloads.upload.uploadProgress")}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="h-full bg-accent transition-[width] duration-150"
|
className="h-full bg-accent transition-[width] duration-150"
|
||||||
style={{ width: `${pct ?? 0}%` }}
|
style={{ width: `${pct ?? 0}%` }}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"namePlaceholder": "Leave blank to use the file name",
|
"namePlaceholder": "Leave blank to use the file name",
|
||||||
"submit": "Upload",
|
"submit": "Upload",
|
||||||
"uploading": "Uploading… {{pct}}%",
|
"uploading": "Uploading… {{pct}}%",
|
||||||
|
"uploadProgress": "Upload progress",
|
||||||
"added": "File uploaded"
|
"added": "File uploaded"
|
||||||
},
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"namePlaceholder": "Hagyd üresen a fájlnév használatához",
|
"namePlaceholder": "Hagyd üresen a fájlnév használatához",
|
||||||
"submit": "Feltöltés",
|
"submit": "Feltöltés",
|
||||||
"uploading": "Feltöltés… {{pct}}%",
|
"uploading": "Feltöltés… {{pct}}%",
|
||||||
|
"uploadProgress": "Feltöltés folyamata",
|
||||||
"added": "Fájl feltöltve"
|
"added": "Fájl feltöltve"
|
||||||
},
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// uploadReq is the XHR upload path (progress/abort/error/401/4xx-localize) that `fetch`/`req` can't
|
||||||
|
// cover. UploadDialog's DOM test mocks api.uploadDownload wholesale, so without this the abort, 401,
|
||||||
|
// and structured-detail-localize branches ship untested. A controllable fake XMLHttpRequest drives
|
||||||
|
// each lifecycle branch.
|
||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
|
||||||
|
const { reportError, notify, remove } = vi.hoisted(() => ({
|
||||||
|
reportError: vi.fn(),
|
||||||
|
notify: vi.fn(() => 1),
|
||||||
|
remove: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../errorDialog", () => ({ reportError }));
|
||||||
|
vi.mock("../notifications", () => ({ notify, remove }));
|
||||||
|
vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" } }));
|
||||||
|
vi.mock("../activeAccount", () => ({
|
||||||
|
activeAccountId: () => null,
|
||||||
|
ACTIVE_ACCOUNT_KEY: "k",
|
||||||
|
notifyActiveAccountChange: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { uploadReq, setUnauthorizedHandler, HttpError } from "./core";
|
||||||
|
|
||||||
|
// A fake XHR whose lifecycle the test drives: capture the instance, set status/responseText, then
|
||||||
|
// invoke onload/onerror/abort by hand.
|
||||||
|
class FakeXHR {
|
||||||
|
static last: FakeXHR;
|
||||||
|
status = 0;
|
||||||
|
responseText = "";
|
||||||
|
withCredentials = false;
|
||||||
|
headers: Record<string, string> = {};
|
||||||
|
upload: { onprogress: ((e: ProgressEvent) => void) | null } = { onprogress: null };
|
||||||
|
onload: (() => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
onabort: (() => void) | null = null;
|
||||||
|
sent = false;
|
||||||
|
constructor() {
|
||||||
|
FakeXHR.last = this;
|
||||||
|
}
|
||||||
|
open() {}
|
||||||
|
setRequestHeader(k: string, v: string) {
|
||||||
|
this.headers[k] = v;
|
||||||
|
}
|
||||||
|
send() {
|
||||||
|
this.sent = true;
|
||||||
|
}
|
||||||
|
abort() {
|
||||||
|
this.onabort?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
reportError.mockReset();
|
||||||
|
notify.mockReset();
|
||||||
|
setUnauthorizedHandler(null);
|
||||||
|
vi.stubGlobal("XMLHttpRequest", FakeXHR as unknown as typeof XMLHttpRequest);
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = () => new FormData();
|
||||||
|
|
||||||
|
describe("uploadReq", () => {
|
||||||
|
it("resolves with the parsed JSON on a 2xx", async () => {
|
||||||
|
const p = uploadReq<{ id: number }>("/u", form());
|
||||||
|
const x = FakeXHR.last;
|
||||||
|
x.status = 200;
|
||||||
|
x.responseText = '{"id":7}';
|
||||||
|
x.onload!();
|
||||||
|
await expect(p).resolves.toEqual({ id: 7 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects immediately if the signal is already aborted (never sends)", async () => {
|
||||||
|
const ac = new AbortController();
|
||||||
|
ac.abort();
|
||||||
|
const p = uploadReq("/u", form(), { signal: ac.signal });
|
||||||
|
await expect(p).rejects.toBeInstanceOf(HttpError);
|
||||||
|
expect(FakeXHR.last?.sent).not.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborts the XHR and rejects when the signal fires", async () => {
|
||||||
|
const ac = new AbortController();
|
||||||
|
const p = uploadReq("/u", form(), { signal: ac.signal });
|
||||||
|
ac.abort(); // → our abort listener calls xhr.abort() → onabort → reject
|
||||||
|
await expect(p).rejects.toMatchObject({ status: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls the unauthorized handler on 401", async () => {
|
||||||
|
const onUnauth = vi.fn();
|
||||||
|
setUnauthorizedHandler(onUnauth);
|
||||||
|
const p = uploadReq("/u", form());
|
||||||
|
const x = FakeXHR.last;
|
||||||
|
x.status = 401;
|
||||||
|
x.onload!();
|
||||||
|
await expect(p).rejects.toBeInstanceOf(HttpError);
|
||||||
|
expect(onUnauth).toHaveBeenCalledOnce();
|
||||||
|
expect(reportError).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("localizes a structured 422 upload_too_large and surfaces the error", async () => {
|
||||||
|
const p = uploadReq("/u", form());
|
||||||
|
const x = FakeXHR.last;
|
||||||
|
x.status = 422;
|
||||||
|
x.responseText = '{"detail":{"code":"upload_too_large","limit":5}}';
|
||||||
|
x.onload!();
|
||||||
|
await expect(p).rejects.toMatchObject({ status: 422 });
|
||||||
|
// localizeDetail routes upload_too_large → the tooLarge key (t is passthrough in the mock).
|
||||||
|
expect(reportError).toHaveBeenCalledWith("downloads.errors.upload.tooLarge");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a connectivity failure on a network error", async () => {
|
||||||
|
const p = uploadReq("/u", form());
|
||||||
|
FakeXHR.last.onerror!();
|
||||||
|
await expect(p).rejects.toMatchObject({ status: 0 });
|
||||||
|
// The soft self-clearing "connection lost" toast (via notify), not a blocking modal.
|
||||||
|
expect(notify).toHaveBeenCalled();
|
||||||
|
expect(reportError).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// E7·S5b — the Library/Shared row-action gate. A generic uploaded file (media_kind='file') is
|
||||||
|
// download+share only (no Play/Edit/Details); an edit clip and an upload can't be re-downloaded.
|
||||||
|
// These pin the gate so a regression that shows Play/Edit on a file, or re-download on an upload,
|
||||||
|
// is caught (the affordance itself, not just the backend guard behind it).
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { libraryRowActions, sharedRowActions } from "./downloadActions";
|
||||||
|
import type { DownloadJob } from "./api";
|
||||||
|
|
||||||
|
const job = (
|
||||||
|
over: Partial<DownloadJob>,
|
||||||
|
asset: Partial<NonNullable<DownloadJob["asset"]>> | null,
|
||||||
|
) =>
|
||||||
|
({
|
||||||
|
id: 1,
|
||||||
|
status: "done",
|
||||||
|
job_kind: "download",
|
||||||
|
can_download: true,
|
||||||
|
asset: asset && {
|
||||||
|
id: 1,
|
||||||
|
status: "ready",
|
||||||
|
title: "x",
|
||||||
|
duration_s: 90,
|
||||||
|
media_kind: "video",
|
||||||
|
container: "mp4",
|
||||||
|
...asset,
|
||||||
|
},
|
||||||
|
...over,
|
||||||
|
}) as unknown as DownloadJob;
|
||||||
|
|
||||||
|
describe("libraryRowActions", () => {
|
||||||
|
it("a media (video) item gets play, edit, details, redownload", () => {
|
||||||
|
expect(libraryRowActions(job({}, {}))).toEqual({
|
||||||
|
play: true,
|
||||||
|
edit: true,
|
||||||
|
editDetails: true,
|
||||||
|
redownload: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a generic file gets NO play/edit/details (download+share only)", () => {
|
||||||
|
const a = libraryRowActions(job({}, { media_kind: "file", duration_s: null }));
|
||||||
|
expect(a.play).toBe(false);
|
||||||
|
expect(a.edit).toBe(false);
|
||||||
|
expect(a.editDetails).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an upload cannot be re-downloaded (no re-fetchable source)", () => {
|
||||||
|
expect(libraryRowActions(job({ job_kind: "upload" }, { media_kind: "file" })).redownload).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an edit clip cannot be re-downloaded", () => {
|
||||||
|
expect(libraryRowActions(job({ job_kind: "edit" }, {})).redownload).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("edit is hidden when the media has no duration", () => {
|
||||||
|
expect(libraryRowActions(job({}, { duration_s: 0 })).edit).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sharedRowActions", () => {
|
||||||
|
it("a downloadable media item gets play, save, edit", () => {
|
||||||
|
expect(sharedRowActions(job({}, {}))).toEqual({ play: true, save: true, edit: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nothing is offered when the owner disabled download", () => {
|
||||||
|
expect(sharedRowActions(job({ can_download: false }, {}))).toEqual({
|
||||||
|
play: false,
|
||||||
|
save: false,
|
||||||
|
edit: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a shared generic file gets only save (no player/trim)", () => {
|
||||||
|
const a = sharedRowActions(job({}, { media_kind: "file", duration_s: null }));
|
||||||
|
expect(a).toEqual({ play: false, save: true, edit: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { DownloadJob } from "./api";
|
||||||
|
|
||||||
|
// Which row actions apply to a completed library / shared item — gated on the stored media_kind and
|
||||||
|
// job_kind (E7·S5b). A generic uploaded file (media_kind='file') is download + share only: no player,
|
||||||
|
// no trim, no details edit. Re-download needs a re-fetchable source, which an edit clip and an upload
|
||||||
|
// both lack (the backend 409s both). A single source for the gate the Library + Shared rows render,
|
||||||
|
// so it's unit-testable instead of buried in JSX conditionals — and the two tabs can't drift apart.
|
||||||
|
|
||||||
|
const isMediaKind = (job: DownloadJob): boolean => job.asset?.media_kind !== "file";
|
||||||
|
const hasDuration = (job: DownloadJob): boolean => (job.asset?.duration_s ?? 0) > 0;
|
||||||
|
|
||||||
|
export interface LibraryRowActions {
|
||||||
|
play: boolean;
|
||||||
|
edit: boolean;
|
||||||
|
editDetails: boolean;
|
||||||
|
redownload: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function libraryRowActions(job: DownloadJob): LibraryRowActions {
|
||||||
|
const media = isMediaKind(job);
|
||||||
|
return {
|
||||||
|
play: media,
|
||||||
|
edit: media && job.can_download && hasDuration(job),
|
||||||
|
editDetails: media,
|
||||||
|
redownload: job.job_kind !== "edit" && job.job_kind !== "upload",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharedRowActions {
|
||||||
|
play: boolean;
|
||||||
|
save: boolean;
|
||||||
|
edit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sharedRowActions(job: DownloadJob): SharedRowActions {
|
||||||
|
const media = isMediaKind(job);
|
||||||
|
// The owner's `can_download` gates save/play/edit on a shared item (it reflects their link ACL).
|
||||||
|
return {
|
||||||
|
play: media && job.can_download,
|
||||||
|
save: job.can_download,
|
||||||
|
edit: media && job.can_download && hasDuration(job),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user