A right-aligned column at the end of every Users & roles row says how long ago the account signed in, with the exact moment as the hint and as a line in the account panel; a dash where nothing is recorded, and the demo account says it is not recorded. formatDateTime formats that moment in the UI language rather than the browser's.
127 lines
4.3 KiB
TypeScript
127 lines
4.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("./ConfirmProvider", () => ({ 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);
|
|
});
|
|
});
|
|
|
|
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([]);
|
|
});
|
|
});
|