Files
siftlode/frontend/src/components/AdminUsers.dom.test.tsx
T
peter 113da76c36 fix(design): a user row answers as a whole card — the hover layer under its badges and buttons, a click anywhere opens the account (E28)
The layer was the name button's own and ended at the row's first badge or button. The card now paints
the B3 layer under its content (isolate + a new z-under level), the name button's hit layer spans the
card, and the actions sit above it.
2026-09-25 23:43:26 +02:00

145 lines
5.3 KiB
TypeScript

// @vitest-environment jsdom
//
// A failed user action is said once, and a user who is gone is not left listed (deep-review round 4).
// 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<typeof import("react-i18next")>();
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<typeof import("../lib/notifications")>()),
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,
is_demo: 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(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<AdminUsers />
</QueryClientProvider>,
);
return users;
}
describe("the last sign-in column (E22 S1)", () => {
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 },
{ ...row, id: 5, email: "demo@x", is_demo: true, last_login_at: null },
] as never);
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<AdminUsers />
</QueryClientProvider>,
);
expect(await screen.findByText(relativeTime(at))).toBeTruthy();
expect(screen.getAllByText("—")).toHaveLength(2);
});
});
// E28 (owner): 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|$)/);
});
});
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" }),
);
fireEvent.click(await screen.findByText("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"));
fireEvent.click(await screen.findByText("users.roles.makeAdmin"));
const { reportError } = await import("../lib/errorDialog");
await waitFor(() => expect(reportError).toHaveBeenCalled());
expect(notify.mock.calls.filter(([n]) => n.level === "error")).toEqual([]);
});
});