// @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(); return { ...actual, useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), }; }); const notifyMock = vi.fn(); vi.mock("../lib/notifications", async (importOriginal) => { const actual = await importOriginal(); 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(); 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( {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} {}} /> , ); } /** 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); }); });