below re-announces it via the aria-live wrapper) — don't bake the value into the name.
+ aria-label={t("downloads.upload.uploadProgress")}
+ >
({
+ 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 = {};
+ 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();
+ });
+});
diff --git a/frontend/src/lib/downloadActions.test.ts b/frontend/src/lib/downloadActions.test.ts
new file mode 100644
index 00000000..4b3fc85a
--- /dev/null
+++ b/frontend/src/lib/downloadActions.test.ts
@@ -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,
+ asset: Partial> | 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 });
+ });
+});
diff --git a/frontend/src/lib/downloadActions.ts b/frontend/src/lib/downloadActions.ts
new file mode 100644
index 00000000..c89d53da
--- /dev/null
+++ b/frontend/src/lib/downloadActions.ts
@@ -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),
+ };
+}