Files
siftlode/frontend/src/components/AdminUsers.dom.test.tsx
T
peter e886890d6e perf(watch): a shared link loads the player, not the app
A /watch link is opened by whoever it was sent to — signed out, often on a phone on mobile data —
and it booted through main.tsx: every app provider, the app's i18n (every locale of every area),
the flag sprites, ~392 KB gzip before the player could start. It now has its own HTML entry,
watch.html → watchMain.tsx, which imports WatchPage and what it renders; the server hands every
/watch/ path that page (the OG block injected as before), and vite dev does the same.

Measured on the build: /watch JS 290 → 87 KB gzip, CSS 104 → 19 KB; the app's own bytes unchanged.
Two leaks had to go for that: lib/format imported the app's i18n for one "just now" string (the
relative-time formatters now take `t`, like the file's other t-formatters), and the boundary's
crash copy came from the same i18n (CrashBoundary takes its copy; ErrorBoundary is the app's
wrapper, the watch entry gives it the page's own strings). The page's popover tree went too: it
renders no popover.

Held by: a contract test walking the entry's runtime imports, naming the path to anything only the
app may load (red on a planted i18n import in lib/format), and holding watch.html's head equal to
index.html's; a DB-lane test that every /watch/ path gets the player's page with the link's OG tags
and app paths get the app (red on the old routing). Screenshots at 375 px, EN and HU, video / audio /
password / gone / wrong password / unlocked: identical to the old entry's, bar the video's buffered bar.
2026-10-05 20:51:39 +02:00

193 lines
7.4 KiB
TypeScript

// @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<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,
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", () => {
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(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<AdminUsers />
</QueryClientProvider>,
);
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(
<QueryClientProvider client={new QueryClient()}>
<AdminUsers />
</QueryClientProvider>,
);
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([]);
});
});