// @vitest-environment jsdom // // A failed user action is said once, and a user who is gone is not left listed. // setRole / suspend / delete had no onError at all — "the global error dialog says it" — but a 404 // (the user was deleted in another tab) gets no dialog, so the click did nothing and the row stayed. // And the invite approve/deny toasted unconditionally on top of the dialog `req` opens for a 409. import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, cleanup, screen, fireEvent, waitFor } 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" } }), }; }); vi.mock("../lib/useMe", () => ({ useMe: () => ({ id: 1, email: "admin@x", role: "admin", pending_invites: 0, pending_activations: 0, }), })); vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true })); const { notify } = vi.hoisted(() => ({ notify: vi.fn((_n: { level: string; message: string }) => 0), })); vi.mock("../lib/notifications", async (importOriginal) => ({ ...(await importOriginal()), notify, })); vi.mock("../lib/errorDialog", () => ({ reportError: vi.fn() })); import AdminUsers from "./AdminUsers"; import { api, type AdminUserRow } from "../lib/api"; import { relativeTime } from "../lib/format"; import i18n from "../i18n"; const row = { id: 2, email: "gone@x", role: "user", is_suspended: false, email_verified: true, is_active: true, } as unknown as AdminUserRow; function answer(status: number, detail: unknown) { return new Response(JSON.stringify({ detail }), { status, headers: { "content-type": "application/json" }, }); } beforeEach(() => { notify.mockClear(); localStorage.clear(); }); afterEach(() => { cleanup(); vi.restoreAllMocks(); }); function mount() { const users = vi.spyOn(api, "adminUsers").mockResolvedValue([row] as never); render( , ); return users; } describe("the last sign-in column", () => { it("says how long ago each account signed in, and a dash where nothing is recorded", async () => { const at = new Date(Date.now() - 3 * 86_400_000).toISOString(); vi.spyOn(api, "adminUsers").mockResolvedValue([ { ...row, id: 3, email: "seen@x", last_login_at: at }, { ...row, id: 4, email: "never@x", last_login_at: null }, ] as never); render( , ); expect(await screen.findByText(relativeTime(at, "en", i18n.t))).toBeTruthy(); expect(screen.getAllByText("—")).toHaveLength(1); }); }); // The hover ended wherever the row's first badge or button began, because the layer was // the name button's own. The card is the row: it paints the layer under its content, and the name // button's hit layer spans the card, under the actions that sit above it. describe("a user row", () => { it("answers as a whole card, with its actions above the card's hit layer", async () => { mount(); const open = await screen.findByTitle("users.account.open"); const card = open.parentElement!; expect(card.className).toMatch(/(^|\s)isolate(\s|$)/); expect(card.className).toMatch(/(^|\s)hover:before:bg-\(--hover\)(\s|$)/); expect(open.className).toMatch(/(^|\s)after:inset-0(\s|$)/); expect(open.className).not.toMatch(/selectable|hover:/); const actions = [...card.querySelectorAll("button")].filter((b) => b !== open); expect(actions.length).toBeGreaterThan(0); for (const b of actions) expect(b.className).toMatch(/(^|\s)relative(\s|$)/); }); }); /** The open menu's rows (a closed popover's are hidden from the accessibility tree in jsdom). */ const menuRows = () => screen.queryAllByRole("menuitem", { hidden: true }); /** The row's actions live in its "…" menu: open it, then pick the row. */ async function chooseInRowMenu(label: string) { fireEvent.click(await screen.findByRole("button", { name: "users.rowMenu" })); const item = await waitFor(() => { const found = menuRows().find((m) => m.textContent?.startsWith(label)); if (!found) throw new Error(`no menu row "${label}"`); return found; }); fireEvent.click(item); } // DESIGN § Action placement: the role, the suspension and the delete moved off the row into // ONE menu — the row's "…", which a right-click on the row opens too. A locked one stays listed with // its reason. describe("a user row's menu", () => { it("holds the role, the suspension and the delete; the row shows no action button", async () => { mount(); const open = await screen.findByTitle("users.account.open"); const card = open.parentElement!; const buttons = [...card.querySelectorAll("button")].filter((b) => b !== open); expect(buttons.map((b) => b.getAttribute("aria-label"))).toEqual(["users.rowMenu"]); fireEvent.contextMenu(card, { clientX: 5, clientY: 5 }); await waitFor(() => expect(menuRows().length).toBe(3)); expect(menuRows().map((i) => i.textContent)).toEqual([ "users.roles.makeAdmin", "users.suspend.action", "users.delete.action", ]); }); it("lists your own row's actions as locked, with the reason", async () => { vi.spyOn(api, "adminUsers").mockResolvedValue([{ ...row, id: 1, email: "admin@x" }] as never); render( , ); fireEvent.click(await screen.findByRole("button", { name: "users.rowMenu" })); await waitFor(() => expect(menuRows().length).toBe(3)); const [role, suspend, del] = menuRows(); expect(role!.hasAttribute("disabled")).toBe(true); expect(role!.textContent).toBe("users.roles.makeAdminusers.roles.selfLocked"); expect(suspend!.textContent).toBe("users.suspend.actionusers.suspend.selfLocked"); expect(del!.textContent).toBe("users.delete.actionusers.delete.locked"); }); }); describe("a user action that fails", () => { it("says a user who is gone, and refetches the list", async () => { const users = mount(); vi.spyOn(globalThis, "fetch").mockResolvedValue( answer(404, { code: "not_found", reason: "user" }), ); await chooseInRowMenu("users.roles.makeAdmin"); await waitFor(() => expect(notify).toHaveBeenCalledWith({ level: "error", title: "users.roles.failed", // what failed… message: i18n.t("errors.notFound.user"), // …and why: the request layer localizes a structured detail }), ); await waitFor(() => expect(users).toHaveBeenCalledTimes(2)); }); it("is not toasted on top of the error dialog the request already opened", async () => { mount(); vi.spyOn(globalThis, "fetch").mockResolvedValue(answer(409, "Cannot demote the last admin")); await chooseInRowMenu("users.roles.makeAdmin"); const { reportError } = await import("../lib/errorDialog"); await waitFor(() => expect(reportError).toHaveBeenCalled()); expect(notify.mock.calls.filter(([n]) => n.level === "error")).toEqual([]); }); });