Files
siftlode/frontend/src/lib/api/core.test.ts
T
peter ec63056bd7 test(api): assert the export body's brand, not which realm built its Blob
`expect(x).toBeInstanceOf(Blob)` asks which realm constructed the body, not
whether 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
both assertions passed locally and failed on the server gate, for a reason that
says nothing about the code under test. `.text()` is the mirror image: jsdom's
Blob does not implement it, so reading the bytes back is green on 22 and red on
24.

Both now assert the brand (`[object Blob]`, agreed by both realms) plus size and
type. Mutation-checked in node:22-bookworm: dropping `{ raw: true }` from
`debugExport` still turns the test red, so the repaired assertion still catches
the defect it was written for.

core.test.ts also stops building its Response from a jsdom `Blob`: on Node 22
undici does not recognise it as a body and stringifies it, so the fixture's body
was the 13 bytes of "[object Blob]" rather than the zip header. A string body is
what the sibling test already used.
2026-09-20 16:33:26 +02:00

387 lines
16 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", () => ({ notify, remove, getActiveToasts }));
vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" } }));
vi.mock("../activeAccount", () => ({
activeAccountId: () => null,
ACTIVE_ACCOUNT_KEY: "k",
pinActiveAccount: () => {},
}));
import { setDebugSession } from "../log";
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;
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();
remove.mockReset();
getActiveToasts.mockReset();
getActiveToasts.mockReturnValue([]);
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 });
});
// 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 (round 6).
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.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", form(), { 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", 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("offers no string detail as the reason on the upload path either", async () => {
const p = uploadReq("/u", form());
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", 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();
});
});
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"],
[{ code: "setpw", reason: "demo" }, "errors.admin.demo"],
])("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(["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", form());
FakeXHR.last.onerror!();
expect(alreadyReported(await dead.catch((e: unknown) => e))).toBe(false);
const refused = uploadReq("/u", form());
FakeXHR.last.status = 500;
FakeXHR.last.onload!();
expect(alreadyReported(await refused.catch((e: unknown) => e))).toBe(true);
const gone = uploadReq("/u", form());
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", form());
FakeXHR.last.status = 502;
FakeXHR.last.onload!();
await expect(p).rejects.toMatchObject({ status: 502 });
expect(reportError).not.toHaveBeenCalled();
expect(remove).not.toHaveBeenCalled();
});
});
// E16: 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", form());
expect(FakeXHR.last.headers["X-Debug-Session"]).toBe("0123456789abcdef");
} finally {
setDebugSession(null);
}
await req("/api/x");
expect(sentHeaders().has("X-Debug-Session")).toBe(false);
});
});