555 lines
24 KiB
TypeScript
555 lines
24 KiB
TypeScript
// @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, getActiveToasts } = vi.hoisted(() => ({
|
|
reportError: vi.fn(),
|
|
notify: vi.fn(() => 1),
|
|
remove: vi.fn(),
|
|
getActiveToasts: vi.fn((): { id: number }[] => []),
|
|
}));
|
|
|
|
vi.mock("../errorDialog", () => ({ reportError }));
|
|
vi.mock("../notifications", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("../notifications")>()),
|
|
notify,
|
|
remove,
|
|
getActiveToasts,
|
|
}));
|
|
vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" } }));
|
|
|
|
import { setDebugSession } from "../log";
|
|
import { claimLocalData } from "../storage";
|
|
import { downloadsApi } from "./downloads";
|
|
import { filesApi } from "./files";
|
|
import {
|
|
beacon,
|
|
req,
|
|
uploadReq,
|
|
setUnauthorizedHandler,
|
|
HttpError,
|
|
alreadyReported,
|
|
isAborted,
|
|
} 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;
|
|
body: unknown = null;
|
|
url = "";
|
|
constructor() {
|
|
FakeXHR.last = this;
|
|
}
|
|
open(_method: string, url: string) {
|
|
this.url = url;
|
|
}
|
|
setRequestHeader(k: string, v: string) {
|
|
// A real XHR COMBINES values for a name it already has, case-insensitively, rather than
|
|
// overwriting — so a plain assignment here would hide exactly the duplicate-header bug the
|
|
// merge in `uploadReq` has to prevent.
|
|
const existing = Object.keys(this.headers).find((h) => h.toLowerCase() === k.toLowerCase());
|
|
if (existing === undefined) this.headers[k] = v;
|
|
else this.headers[existing] = `${this.headers[existing]}, ${v}`;
|
|
}
|
|
send(body?: unknown) {
|
|
this.sent = true;
|
|
this.body = body ?? null;
|
|
}
|
|
abort() {
|
|
this.onabort?.();
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
reportError.mockReset();
|
|
notify.mockReset();
|
|
remove.mockReset();
|
|
getActiveToasts.mockReset();
|
|
getActiveToasts.mockReturnValue([]);
|
|
setUnauthorizedHandler(null);
|
|
vi.stubGlobal("XMLHttpRequest", FakeXHR as unknown as typeof XMLHttpRequest);
|
|
});
|
|
|
|
// The upload body is a Blob and only a Blob: `FormData` was accepted once and is gone, because
|
|
// a multipart part is a DECLARED body that FastAPI spools before the route's auth dependency runs —
|
|
// and nothing on the backend can parse one any more (`python-multipart` was dropped with it).
|
|
const body = () => new Blob(["x"]);
|
|
|
|
describe("uploadReq", () => {
|
|
it("resolves with the parsed JSON on a 2xx", async () => {
|
|
const p = uploadReq<{ id: number }>("/u", body());
|
|
const x = FakeXHR.last;
|
|
x.status = 200;
|
|
x.responseText = '{"id":7}';
|
|
x.onload!();
|
|
await expect(p).resolves.toEqual({ id: 7 });
|
|
});
|
|
|
|
// Both abort producers are asserted through `isAborted`, not through `{status: 0}`: what the
|
|
// upload dialog reads is the SENTINEL, and a cancelled upload that stops matching it becomes a
|
|
// red "the upload failed" toast. Changing either `reject` to a friendlier message left every
|
|
// test green, because the only consumer test hand-builds the error it expects.
|
|
it("rejects immediately if the signal is already aborted (never sends)", async () => {
|
|
const ac = new AbortController();
|
|
ac.abort();
|
|
const p = uploadReq("/u", body(), { signal: ac.signal });
|
|
await expect(p).rejects.toSatisfy(isAborted);
|
|
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", body(), { signal: ac.signal });
|
|
ac.abort(); // → our abort listener calls xhr.abort() → onabort → reject
|
|
await expect(p).rejects.toSatisfy(isAborted);
|
|
});
|
|
|
|
it("calls the unauthorized handler on 401", async () => {
|
|
const onUnauth = vi.fn();
|
|
setUnauthorizedHandler(onUnauth);
|
|
const p = uploadReq("/u", body());
|
|
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", body());
|
|
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("localizes the edge ceiling's 413 too_large, which carries no limit", async () => {
|
|
// On a DEFAULT install this — not the route's numbered 422 above — is what an over-cap upload
|
|
// gets: the ceiling and the route's cap are the same figure and the edge answers first. It fell
|
|
// through to the generic "couldn't carry out that action" until the branch existed.
|
|
const p = uploadReq("/u", body());
|
|
const x = FakeXHR.last;
|
|
x.status = 413;
|
|
x.responseText = '{"detail":{"code":"too_large"}}';
|
|
x.onload!();
|
|
const err = (await p.catch((e: unknown) => e)) as HttpError;
|
|
expect(err).toBeInstanceOf(HttpError);
|
|
expect(err.detail).toBe("errors.tooLarge");
|
|
});
|
|
|
|
it("lets its own identity headers win over a caller's", () => {
|
|
// What a request is part of is this layer's to state, not its caller's. Merged the other way
|
|
// round, a caller's header of the same name would silently re-attribute the upload into someone
|
|
// else's debug session recording.
|
|
setDebugSession("0123456789abcdef");
|
|
try {
|
|
void uploadReq("/u", body(), {
|
|
// Lower-case on purpose: header names are case-insensitive and the codebase's own upload
|
|
// headers are spelled that way on the server. A case-sensitive merge would not replace
|
|
// this one, it would ADD it, and the wire would carry both values comma-joined.
|
|
headers: { "x-debug-session": "deadbeefdeadbeef", "X-Upload-Filename": "a.bin" },
|
|
});
|
|
const sent = Object.entries(FakeXHR.last.headers);
|
|
const at = (n: string) => sent.filter(([k]) => k.toLowerCase() === n).map(([, v]) => v);
|
|
// Exactly one, and it is the identity layer's — not two, and not the caller's.
|
|
expect(at("x-debug-session")).toEqual(["0123456789abcdef"]);
|
|
// ...while a header that is genuinely the caller's still rides.
|
|
expect(at("x-upload-filename")).toEqual(["a.bin"]);
|
|
} finally {
|
|
setDebugSession(null);
|
|
}
|
|
});
|
|
|
|
it("offers no string detail as the reason on the upload path either", async () => {
|
|
const p = uploadReq("/u", body());
|
|
const x = FakeXHR.last;
|
|
x.status = 405;
|
|
x.responseText = '{"detail":"Method Not Allowed"}';
|
|
x.onload!();
|
|
const err = (await p.catch((e: unknown) => e)) as HttpError;
|
|
expect(err).toBeInstanceOf(HttpError);
|
|
expect(err.detail).toBeUndefined();
|
|
});
|
|
|
|
it("reports a connectivity failure on a network error", async () => {
|
|
const p = uploadReq("/u", body());
|
|
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();
|
|
});
|
|
});
|
|
|
|
describe("req — a structured refusal reads in the user's language", () => {
|
|
it("names a playlist that is gone instead of passing the server's English through", async () => {
|
|
// The 404 every playlist route answers when the list was deleted in another tab. It used to be a
|
|
// bare "Unknown playlist" string, shown verbatim under the Hungarian UI.
|
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
new Response(JSON.stringify({ detail: { code: "not_found", reason: "playlist" } }), {
|
|
status: 404,
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
);
|
|
await expect(req("/api/playlists/7/items/v1", { method: "DELETE" })).rejects.toMatchObject({
|
|
status: 404,
|
|
detail: "errors.notFound.playlist",
|
|
});
|
|
expect(reportError).not.toHaveBeenCalled(); // a 404 is the caller's to report
|
|
});
|
|
|
|
it.each([
|
|
[{ code: "activate", reason: "not_allowed" }, "errors.admin.not_allowed"],
|
|
[{ code: "invite_send", reason: "not_approved" }, "errors.admin.not_approved"],
|
|
[{ code: "resend", reason: "send_failed" }, "errors.admin.send_failed"],
|
|
])("says why an admin action was refused (%o)", async (detail, localized) => {
|
|
// These read as the generic "try again" — which no retry fixes when the address is not approved.
|
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
new Response(JSON.stringify({ detail }), {
|
|
status: 409,
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
);
|
|
await expect(req("/api/admin/x", { method: "POST" }, { quiet: true })).rejects.toMatchObject({
|
|
detail: localized,
|
|
});
|
|
});
|
|
|
|
it("never offers a string detail as the reason — only a structured one, translated", async () => {
|
|
// Every refusal the app answers is structured; a string can only be the framework's own English
|
|
// ("Method Not Allowed"). Surfaces print `err.detail` — some destructured, out of any source
|
|
// scan's reach — so the guarantee has to live here, where the value is made.
|
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
new Response(JSON.stringify({ detail: "Method Not Allowed" }), {
|
|
status: 405,
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
);
|
|
const err = (await req("/api/x", { method: "POST" }, { quiet: true }).catch(
|
|
(e: unknown) => e,
|
|
)) as HttpError;
|
|
expect(err).toBeInstanceOf(HttpError);
|
|
expect(err.detail).toBeUndefined();
|
|
});
|
|
|
|
it.each([
|
|
["too_long", "clip.errors.too_long"],
|
|
["bad_crop", "clip.errors.bad_crop"],
|
|
])("says why a clip was not cut (%s)", async (reason, localized) => {
|
|
// It read as the generic "couldn't carry out that action" — a 10-minute cap no retry gets past.
|
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
new Response(JSON.stringify({ detail: { code: "clip", reason } }), {
|
|
status: 422,
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
);
|
|
await expect(
|
|
req("/api/downloads/clip", { method: "POST" }, { quiet: true }),
|
|
).rejects.toMatchObject({ detail: localized });
|
|
});
|
|
|
|
it("states the clip cap the server refused with, not a copy of it", async () => {
|
|
// The cap is the server's (downloads/clip.py MAX_CLIP_S): a message with "10 minutes" baked in
|
|
// goes stale the day it moves.
|
|
const i18n = (await import("../../i18n")).default;
|
|
const t = vi.spyOn(i18n, "t");
|
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
new Response(JSON.stringify({ detail: { code: "clip", reason: "too_long", limit_s: 300 } }), {
|
|
status: 422,
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
);
|
|
await req("/api/downloads/clip", { method: "POST" }, { quiet: true }).catch(() => undefined);
|
|
expect(t).toHaveBeenCalledWith(
|
|
"clip.errors.too_long",
|
|
expect.objectContaining({ minutes: "5" }),
|
|
);
|
|
});
|
|
|
|
it.each(["quota", "reconnect", "unavailable"])(
|
|
"says what to do about an action YouTube refused (%s)",
|
|
async (reason) => {
|
|
// Waiting for tomorrow's budget, reconnecting and retrying are different advice; the manual
|
|
// syncs used to answer a raw 500 here, and the other routes the upstream's English.
|
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
new Response(JSON.stringify({ detail: { code: "youtube", reason } }), {
|
|
status: 422,
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
);
|
|
await expect(req("/api/sync/subscriptions", { method: "POST" })).rejects.toMatchObject({
|
|
detail: `errors.youtube.${reason}`,
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("req — a file, not a parsed answer", () => {
|
|
it("hands back the response itself so the caller can read it as a blob", async () => {
|
|
// A zip. Without the `raw` branch this falls into `r.json()`, throws on the first byte, and the
|
|
// download dies as an opaque failure — which no lane would see, because the only consumer test
|
|
// mocks the api method wholesale.
|
|
const zip = new Response("PK", {
|
|
status: 200,
|
|
headers: {
|
|
"content-type": "application/zip",
|
|
"content-disposition": 'attachment; filename="siftlode-debug-260919.zip"',
|
|
},
|
|
});
|
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(zip);
|
|
|
|
const r = await req<Response>("/api/debug-sessions/abc/export", {}, { raw: true });
|
|
|
|
expect(r).toBe(zip);
|
|
expect(r.headers.get("content-disposition")).toContain("siftlode-debug-260919.zip");
|
|
// NOT `toBeInstanceOf(Blob)`: that asserts which realm built the body, not that there is one.
|
|
// Under jsdom the ambient `Blob` is jsdom's class, while `Response.blob()` hands back node's own
|
|
// on Node 22 and jsdom's on Node 24 — so the same assertion passed here and failed on the server
|
|
// gate (measured 2026-09-20). Nor `.text()`, for the mirror-image reason: jsdom's Blob has no
|
|
// such method, so reading the bytes back is green on 22 and red on 24. The brand and the size
|
|
// are the two things both realms agree on, and they are what this test means.
|
|
const body = await r.blob();
|
|
expect(Object.prototype.toString.call(body)).toBe("[object Blob]");
|
|
expect(body.size).toBe(4); // the body is still there to be read, all four of its bytes
|
|
expect(body.type).toBe("application/zip");
|
|
});
|
|
|
|
it("still refuses before the body is touched, so a refusal is shown and not saved", async () => {
|
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
new Response(JSON.stringify({ detail: { code: "refused", reason: "export_rate_limited" } }), {
|
|
status: 429,
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
);
|
|
await expect(
|
|
req<Response>("/api/debug-sessions/abc/export", {}, { raw: true, quiet: true }),
|
|
).rejects.toMatchObject({ status: 429, detail: "errors.refused.export_rate_limited" });
|
|
});
|
|
});
|
|
|
|
describe("req — what it has already put in front of the user", () => {
|
|
// A caller that toasts a failed action on top of the dialog `req` already opened reported one fault
|
|
// twice. `alreadyReported` is how `notifyActionError` tells the two apart.
|
|
const answer = (status: number) =>
|
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
new Response(JSON.stringify({ detail: "x" }), {
|
|
status,
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
);
|
|
|
|
it.each([500, 422, 409, 400])("marks a %s it raised the error dialog for", async (status) => {
|
|
answer(status);
|
|
const err = await req("/api/x", { method: "POST" }).catch((e: unknown) => e);
|
|
expect(reportError).toHaveBeenCalled();
|
|
expect(alreadyReported(err)).toBe(true);
|
|
});
|
|
|
|
it.each([404, 403, 429])("leaves a %s to the caller", async (status) => {
|
|
answer(status);
|
|
const err = await req("/api/x", { method: "POST" }).catch((e: unknown) => e);
|
|
expect(err).toBeInstanceOf(HttpError);
|
|
expect(alreadyReported(err)).toBe(false);
|
|
});
|
|
|
|
it("marks a 401 the app shell handles (it signs the tab out)", async () => {
|
|
answer(401);
|
|
const err = await req("/api/x", { method: "POST" }).catch((e: unknown) => e);
|
|
expect(alreadyReported(err)).toBe(true);
|
|
});
|
|
|
|
// The connection notice says the server is unreachable — not that THIS action failed — and it
|
|
// removes itself on the very next answer (the caller's own resync refetch). Marked as reported, a
|
|
// delete that hit a keepalive reset flashed the notice and was then reported nowhere at all.
|
|
it("raises the connection notice for a request that never reached the server, and leaves the failed action to the caller", async () => {
|
|
vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("Failed to fetch"));
|
|
const err = await req("/api/x", { method: "POST" }).catch((e: unknown) => e);
|
|
expect(notify).toHaveBeenCalled();
|
|
expect(alreadyReported(err)).toBe(false);
|
|
});
|
|
|
|
it.each([502, 503, 504])(
|
|
"raises the connection notice for a %s, and leaves the failed action to the caller",
|
|
async (status) => {
|
|
answer(status);
|
|
const err = await req("/api/x", { method: "POST" }).catch((e: unknown) => e);
|
|
expect(notify).toHaveBeenCalled();
|
|
expect(reportError).not.toHaveBeenCalled();
|
|
expect(err).toMatchObject({ status });
|
|
expect(alreadyReported(err)).toBe(false);
|
|
},
|
|
);
|
|
|
|
it("raises the connection notice again after the user closed it", async () => {
|
|
vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("Failed to fetch"));
|
|
notify.mockReturnValue(7);
|
|
await req("/api/x", { method: "POST" }).catch(() => {});
|
|
getActiveToasts.mockReturnValue([{ id: 7 }]); // still on screen: one notice, not a stack
|
|
notify.mockClear();
|
|
await req("/api/x", { method: "POST" }).catch(() => {});
|
|
expect(notify).not.toHaveBeenCalled();
|
|
getActiveToasts.mockReturnValue([]); // closed with its ✕
|
|
await req("/api/x", { method: "POST" }).catch(() => {});
|
|
expect(notify).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("marks what the upload path reported the same way", async () => {
|
|
const dead = uploadReq("/u", body());
|
|
FakeXHR.last.onerror!();
|
|
expect(alreadyReported(await dead.catch((e: unknown) => e))).toBe(false);
|
|
const refused = uploadReq("/u", body());
|
|
FakeXHR.last.status = 500;
|
|
FakeXHR.last.onload!();
|
|
expect(alreadyReported(await refused.catch((e: unknown) => e))).toBe(true);
|
|
const gone = uploadReq("/u", body());
|
|
FakeXHR.last.status = 404;
|
|
FakeXHR.last.onload!();
|
|
expect(alreadyReported(await gone.catch((e: unknown) => e))).toBe(false);
|
|
});
|
|
|
|
it("treats a gateway error on the upload path as the connection notice, not a blocking dialog", async () => {
|
|
// The upload path kept its own copy of this policy, and it had drifted: a 502 opened the
|
|
// blocking "Server error (502)" dialog and cleared a connection notice that was still true.
|
|
notify.mockReturnValue(9);
|
|
vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("Failed to fetch"));
|
|
await req("/api/x", { method: "POST" }).catch(() => {}); // a notice is up
|
|
getActiveToasts.mockReturnValue([{ id: 9 }]);
|
|
remove.mockClear();
|
|
const p = uploadReq("/u", body());
|
|
FakeXHR.last.status = 502;
|
|
FakeXHR.last.onload!();
|
|
await expect(p).rejects.toMatchObject({ status: 502 });
|
|
expect(reportError).not.toHaveBeenCalled();
|
|
expect(remove).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// While a debug session records, every request path names it — the server tags ITS lines of
|
|
// that request with it. A path that forgot the header would leave holes in the evidence exactly
|
|
// where the upload (XHR) or the unload beacon happened.
|
|
describe("the debug session a tab records into", () => {
|
|
const sentHeaders = () => {
|
|
const init = vi.mocked(globalThis.fetch).mock.lastCall?.[1] as RequestInit;
|
|
return new Headers(init.headers as HeadersInit);
|
|
};
|
|
|
|
it("rides every request path while it records, and none once it stops", async () => {
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(() =>
|
|
Promise.resolve(new Response("{}", { status: 200 })),
|
|
);
|
|
setDebugSession("0123456789abcdef");
|
|
try {
|
|
await req("/api/x");
|
|
expect(sentHeaders().get("X-Debug-Session")).toBe("0123456789abcdef");
|
|
beacon("/api/y", {});
|
|
expect(sentHeaders().get("X-Debug-Session")).toBe("0123456789abcdef");
|
|
void uploadReq("/u", body());
|
|
expect(FakeXHR.last.headers["X-Debug-Session"]).toBe("0123456789abcdef");
|
|
} finally {
|
|
setDebugSession(null);
|
|
}
|
|
await req("/api/x");
|
|
expect(sentHeaders().has("X-Debug-Session")).toBe(false);
|
|
});
|
|
});
|
|
|
|
// A tab built for one account must not act as another: a sign-in as someone else in another tab
|
|
// replaces the cookie under this one. Every request path names the account the tab was claimed for,
|
|
// so the server can refuse it — a beacon or an upload that went out without it would land on the
|
|
// account signed in now.
|
|
describe("the account a tab was built for", () => {
|
|
const sentHeaders = () => {
|
|
const init = vi.mocked(globalThis.fetch).mock.lastCall?.[1] as RequestInit;
|
|
return new Headers(init.headers as HeadersInit);
|
|
};
|
|
|
|
it("rides every request path once the tab is claimed", async () => {
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(() =>
|
|
Promise.resolve(new Response("{}", { status: 200 })),
|
|
);
|
|
claimLocalData(7);
|
|
await req("/api/x");
|
|
expect(sentHeaders().get("X-Siftlode-Expect-Account")).toBe("7");
|
|
beacon("/api/y", {});
|
|
expect(sentHeaders().get("X-Siftlode-Expect-Account")).toBe("7");
|
|
void uploadReq("/u", body());
|
|
expect(FakeXHR.last.headers["X-Siftlode-Expect-Account"]).toBe("7");
|
|
});
|
|
});
|
|
|
|
// The upload's wire shape is a security boundary, not a detail. A multipart part is a DECLARED
|
|
// body, which the server reads and spools BEFORE its auth dependency runs; the raw body is what lets
|
|
// `current_user` resolve first. A revert to FormData here would silently re-open that, and the
|
|
// backend's own test only sees the shape the client actually sends.
|
|
describe("uploadFile's wire shape", () => {
|
|
it("sends the File as the raw body, with the names as percent-encoded headers", () => {
|
|
const file = new File(["abc"], "nyaralás videó.mp4", { type: "video/mp4" });
|
|
void filesApi.uploadFile(file, { displayName: "Nyaralás 2026" });
|
|
const x = FakeXHR.last;
|
|
expect(x.body).toBe(file);
|
|
expect(x.body).not.toBeInstanceOf(FormData);
|
|
expect(x.headers["X-Upload-Filename"]).toBe(encodeURIComponent("nyaralás videó.mp4"));
|
|
expect(x.headers["X-Upload-Display-Name"]).toBe(encodeURIComponent("Nyaralás 2026"));
|
|
// Content-Type is the browser's to set from the File — an explicit one here would be a guess.
|
|
expect(x.headers["Content-Type"]).toBeUndefined();
|
|
});
|
|
|
|
it("omits the display-name header when there is no display name", () => {
|
|
void filesApi.uploadFile(new File(["abc"], "clip.mp4"));
|
|
expect(FakeXHR.last.headers["X-Upload-Display-Name"]).toBeUndefined();
|
|
expect(FakeXHR.last.headers["X-Upload-Folder"]).toBeUndefined();
|
|
});
|
|
|
|
it("names the folder it goes into, when it has one", () => {
|
|
void filesApi.uploadFile(new File(["abc"], "clip.mp4"), { folderId: 7 });
|
|
expect(FakeXHR.last.headers["X-Upload-Folder"]).toBe("7");
|
|
expect(FakeXHR.last.url).toBe("/api/files/upload");
|
|
});
|
|
});
|
|
|
|
// The library player re-reads its listings once its closing point is on the server: it cancelled
|
|
// their poll so an older answer could not put the point back, and only this callback brings them
|
|
// back. A beacon that never called it, or a slice that dropped it, left the lists stale with every
|
|
// test green.
|
|
describe("a beacon's settle callback", () => {
|
|
it("runs once the request is answered, and once it has failed", async () => {
|
|
const fetch = vi
|
|
.spyOn(globalThis, "fetch")
|
|
.mockResolvedValueOnce(new Response(null, { status: 204 }))
|
|
.mockRejectedValueOnce(new TypeError("offline"));
|
|
const answered = vi.fn();
|
|
const failed = vi.fn();
|
|
beacon("/api/a", {}, answered);
|
|
beacon("/api/b", {}, failed);
|
|
await vi.waitFor(() =>
|
|
expect([answered.mock.calls.length, failed.mock.calls.length]).toEqual([1, 1]),
|
|
);
|
|
expect(fetch.mock.calls.map(([url]) => url).slice(-2)).toEqual(["/api/a", "/api/b"]);
|
|
});
|
|
|
|
it("is passed on by the library progress beacon", async () => {
|
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 204 }));
|
|
const settled = vi.fn();
|
|
downloadsApi.saveDownloadProgressBeacon(7, 125.4, 600, settled);
|
|
await vi.waitFor(() => expect(settled).toHaveBeenCalledTimes(1));
|
|
});
|
|
});
|