Targeted round over 3cd0c525's fix surfaces: 14 raised, 14 fixed. - VideoEditor: the "only X of Y clips" toast went silent exactly on the job-cap/quota refusal it was written for (req had already reported the error); it now always shows. The cut list holds still while Create queues, and a retry continues the "(i/N)" series. - ChannelPage: Subscribe/Unsubscribe use Button `loading`, "Load more" and ChannelDiscovery's Subscribe block the click with aria-disabled instead of native disabled; busy Block/Hide no longer light up on hover. - listNav: an item can name itself (`navItemAttrs(i, name)`) — Plex posters, seasons, episodes, message rows and download rows pass their title; focus on a toast's Undo keeps the removed card's slot. - AnchoredPopover: the hand-back only for focus-managed surfaces (a hover card no longer pulls focus back); the dead panel-ref check is gone. - AccountPanel: no confirmation mail offered to the demo account; send_failed copy no longer blames the message for an unreachable server. Pager comment points at scrollParent.
163 lines
6.0 KiB
TypeScript
163 lines
6.0 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The admin account panel's RECOVERY actions, and specifically that a refusal is visible.
|
|
//
|
|
// All three call `quiet: true` endpoints, which suppress the global error dialog on the promise that
|
|
// the caller reports the failure inline. Without an `onError` that promise is broken and a refusal
|
|
// renders as nothing at all — and on the broken-SMTP instance this panel exists for, an admin reads
|
|
// silence as "the mail went out". These pin that each failure says something, and that the resend
|
|
// distinguishes "this instance has no working mail" from a genuine send failure.
|
|
//
|
|
// i18n is a key-passthrough so assertions read stable keys; the API and the notify singleton are
|
|
// stubbed so the panel can be driven without a server.
|
|
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
|
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
|
|
vi.mock("react-i18next", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
|
return {
|
|
...actual,
|
|
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
|
};
|
|
});
|
|
|
|
const notifyMock = vi.fn();
|
|
vi.mock("../lib/notifications", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../lib/notifications")>();
|
|
return { ...actual, notify: (...a: unknown[]) => notifyMock(...a) };
|
|
});
|
|
|
|
const access = {
|
|
email_verified: false,
|
|
email_configured: true,
|
|
is_active: false,
|
|
is_allowed: true,
|
|
can_activate: true,
|
|
has_password: false,
|
|
google_email: null,
|
|
invite_status: null,
|
|
};
|
|
|
|
const apiMock = {
|
|
adminUserAccess: vi.fn(async () => access),
|
|
createSetPasswordLink: vi.fn(),
|
|
resendUserVerification: vi.fn(),
|
|
activateUser: vi.fn(),
|
|
};
|
|
vi.mock("../lib/api", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../lib/api")>();
|
|
return { ...actual, api: apiMock };
|
|
});
|
|
|
|
const { HttpError } = await import("../lib/api");
|
|
const AccountPanel = (await import("./AccountPanel")).default;
|
|
|
|
const user = {
|
|
id: 7,
|
|
email: "stuck@example.com",
|
|
display_name: null,
|
|
role: "user",
|
|
is_demo: false,
|
|
is_active: false,
|
|
is_suspended: false,
|
|
};
|
|
|
|
function mount(u: typeof user = user) {
|
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
render(
|
|
<QueryClientProvider client={qc}>
|
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
|
<AccountPanel user={u as any} onClose={() => {}} />
|
|
</QueryClientProvider>,
|
|
);
|
|
}
|
|
|
|
/** Click the recovery button whose label is `key`, once the readout has loaded. */
|
|
async function clickAction(key: string) {
|
|
mount();
|
|
await screen.findByText("users.account.recovery");
|
|
fireEvent.click(screen.getByText(key));
|
|
}
|
|
|
|
beforeEach(() => {
|
|
notifyMock.mockClear();
|
|
Object.values(apiMock).forEach((m) => m.mockClear());
|
|
apiMock.adminUserAccess.mockResolvedValue(access);
|
|
});
|
|
afterEach(cleanup);
|
|
|
|
// What each error toast names: its title when it has one (a failure reported with its reason under
|
|
// it — E11 S2b), else the specific sentence (the no-mail case).
|
|
const errorMessages = () =>
|
|
notifyMock.mock.calls
|
|
.map(([a]) => a as { level?: string; title?: string; message?: string })
|
|
.filter((a) => a.level === "error")
|
|
.map((a) => a.title ?? a.message);
|
|
|
|
describe("the account panel reports a refused recovery action", () => {
|
|
it("says the link could not be minted instead of leaving the button inert", async () => {
|
|
apiMock.createSetPasswordLink.mockRejectedValue(new HttpError(404, "gone", null));
|
|
|
|
await clickAction("users.account.setPassword");
|
|
|
|
await waitFor(() => expect(errorMessages()).toContain("users.account.linkFailed"));
|
|
// …and no link block was rendered, so the toast is the only thing that happened.
|
|
expect(screen.queryByText("users.account.copy")).toBeNull();
|
|
});
|
|
|
|
it("names the no-mail case, so a no-SMTP instance does not read as a bug", async () => {
|
|
// `req` has already localized the refusal into `detail` (lib/api/core); the toast carries it.
|
|
apiMock.resendUserVerification.mockRejectedValue(
|
|
new HttpError(409, "errors.admin.email_off", { code: "resend", reason: "email_off" }),
|
|
);
|
|
|
|
await clickAction("users.account.resend");
|
|
|
|
await waitFor(() =>
|
|
expect(notifyMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
title: "users.pendingAccess.resendFailed",
|
|
message: "errors.admin.email_off",
|
|
}),
|
|
),
|
|
);
|
|
});
|
|
|
|
it("reports a genuine send failure as a failure, not as a sent mail", async () => {
|
|
apiMock.resendUserVerification.mockRejectedValue(
|
|
new HttpError(422, "send failed", { code: "resend", reason: "send_failed" }),
|
|
);
|
|
|
|
await clickAction("users.account.resend");
|
|
|
|
await waitFor(() => expect(errorMessages()).toContain("users.pendingAccess.resendFailed"));
|
|
// The success toast must NOT also fire — that is the exact lie the backend guards against.
|
|
expect(notifyMock.mock.calls.map(([a]) => (a as { message?: string }).message)).not.toContain(
|
|
"users.account.resendSent",
|
|
);
|
|
});
|
|
|
|
it("reports a refused activation", async () => {
|
|
apiMock.activateUser.mockRejectedValue(
|
|
new HttpError(409, "not allowed", { code: "activate", reason: "not_allowed" }),
|
|
);
|
|
|
|
await clickAction("users.account.activate");
|
|
|
|
await waitFor(() => expect(errorMessages()).toContain("users.pendingAccess.activateFailed"));
|
|
});
|
|
});
|
|
|
|
describe("the account panel on the demo account", () => {
|
|
// The demo account's address is never confirmed, so "send the confirmation link again" looked
|
|
// available — and the server refused it as "nothing left to confirm", beside a row reading
|
|
// "unconfirmed" (deep-review E11 round 3).
|
|
it("does not offer it a confirmation mail, which the server refuses", async () => {
|
|
mount({ ...user, is_demo: true });
|
|
await screen.findByText("users.account.recovery");
|
|
const resend = screen.getByText("users.account.resend").closest("button")!;
|
|
expect(resend.hasAttribute("disabled")).toBe(true);
|
|
});
|
|
});
|