Deep-review round 3 over main..improvement/watch-background-audio. Account boot - The notification history and settings, and the hints toggle, are read when their modules load, which in a new tab (bookmark, typed URL) is before BootGate pins the account: they read as nobody's, and the tab's first notice replaced the account's stored history (up to 100 entries) with a one-item list. They now read again when the tab is pinned (onAccountPinned, run by setActiveAccount); a notice raised before the pin stays, after the history. - A tab pinned to an account still in this browser's wallet but whose session ended (suspended, deleted, or revoked by "log out everywhere") got "signed out" from /api/me while the default account was still signed in, and signing in again as anyone else left it there. A refused per-tab account now leaves the wallet, and the probe answers as the default, so the tab reloads onto it. Share page (/watch) - The password unlock is limited per viewer (link + client IP), under a per-link ceiling that counts only wrong passwords. Keyed on the link alone, ten wrong guesses every five minutes locked out every viewer, the ones with the password included. client_ip moves to app.ratelimit so the public routes can key on it. - Its failure messages and the load-failure screen are announced (role=alert, the password input described by its error). The sign-in, passphrase, password-change and setup form errors get role=alert too. Comments that still described removed machinery are corrected: App's collapse flags, SettingsPanel's Save bar, ConfigPanel, authed_format_sig. Tests: the account's history and hints across the pin; a revoked and a suspended pinned account; the per-viewer limit and the ceiling; the limiter's exhausted(); the share page's announced failures; a share link's feed filters persisted. Each mutation-checked RED for its own reason.
268 lines
11 KiB
TypeScript
268 lines
11 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 {
|
|
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("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,
|
|
});
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|