Files
siftlode/frontend/src/components/accountPanel.dom.test.tsx
T
peter 4aaaaa7860 chore(comments): drop epic, sprint and review-round ids from the code
The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").

Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.

Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).

One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
2026-10-05 00:19:39 +02:00

150 lines
5.4 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_active: false,
is_suspended: false,
};
function mount(u: typeof user = user) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
{/* a partial user fixture: `never` stands in for the full type without an `any` */}
<AccountPanel user={u as never} 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), 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"));
});
});