Files
AgentHub/client/src/components/users/__tests__/UserEditDialog.test.tsx
T
nntrivi2001 f1a90e120e fix(users): every user row shows its avatar, not only its initials
System Health's per-user CPU/RAM card asked UserAvatar for the viewer's own
picture with no user id, and the host stats never carried avatarAt, so every
card fell back to letters. The groups now carry avatarAt and the card passes
userId. The user editor's header, the pool board's member chips and the pool
member picker drew hand-made initials; they use UserAvatar too.
2026-10-29 13:40:28 +07:00

180 lines
6.5 KiB
TypeScript

/**
* @file UserEditDialog.test.tsx
* @description The admin's account editor: Save stays off until something
* changed AND the admin typed their own password; one PATCH carries only the
* changed fields (profile, role, disable, workspace add/remove, sign-out); an
* admin editing their own row cannot touch role or state; Generate fills a
* 16-character password.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ServerUserUsage } from "../../../lib/api";
const users = vi.hoisted(() => ({
get: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
avatarUrl: () => null,
}));
const confirmDialog = vi.hoisted(() => vi.fn());
vi.mock("../../../lib/confirm", () => ({ confirmDialog }));
const lanes = vi.hoisted(() => ({ list: vi.fn() }));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, api: { users, lanes } };
});
import { UserEditDialog, generatePassword } from "../UserEditDialog";
const usage = { cost: 0, tokens: 0 };
const mia: ServerUserUsage = {
id: 2,
username: "mia",
role: "developer",
disabled: false,
displayName: null,
email: null,
createdAt: "2026-10-01T00:00:00.000Z",
workspaces: 1,
sessions: 3,
last7d: usage,
last30d: usage,
logins: 2,
lastLoginAt: null,
};
function open(user = mia, self = false) {
const onDone = vi.fn();
const onClose = vi.fn();
render(
<UserEditDialog user={user} self={self} users={[mia]} onClose={onClose} onDone={onDone} />
);
return { onDone, onClose };
}
const save = () => screen.getByRole("button", { name: "Save" });
beforeEach(() => {
users.get.mockReset().mockResolvedValue({
user: mia,
workspaces: [{ id: 5, title: "api", cwd: "/w/api" }],
});
users.update.mockReset().mockResolvedValue({ user: mia });
lanes.list.mockReset().mockResolvedValue({
lanes: [
{ id: 5, title: "api", cwd: "/w/api", owner_id: 2 },
{ id: 7, title: "web", cwd: "/w/web", owner_id: null },
],
});
});
describe("UserEditDialog", () => {
it("needs a change and the admin's own password before Save", async () => {
const ue = userEvent.setup();
open();
await screen.findByText("api");
expect(save().hasAttribute("disabled")).toBe(true);
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
expect(save().hasAttribute("disabled")).toBe(true); // nothing changed yet
await ue.type(screen.getByLabelText("Display name"), "Mia");
expect(save().hasAttribute("disabled")).toBe(false);
});
it("sends one PATCH with only what changed", async () => {
const ue = userEvent.setup();
const { onDone } = open();
await screen.findByText("api");
await ue.type(screen.getByLabelText("Email"), "mia@x.vn");
await ue.selectOptions(screen.getByLabelText("Role"), "analyst");
await ue.click(screen.getByRole("switch", { name: "Active" }));
await ue.click(screen.getByRole("button", { name: "Remove api" }));
await ue.selectOptions(screen.getByLabelText("Add workspace"), "7");
await ue.click(screen.getByRole("button", { name: "Add workspace" }));
await ue.click(screen.getByRole("checkbox", { name: "Sign out everywhere" }));
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
await ue.click(save());
await waitFor(() =>
expect(users.update).toHaveBeenCalledWith(
2,
{
email: "mia@x.vn",
role: "analyst",
disabled: true,
addWorkspaces: [7],
removeWorkspaces: [5],
signOut: true,
},
"boss-pass"
)
);
expect(onDone).toHaveBeenCalledWith("mia updated");
});
it("never lets an admin change their own role or disable themselves", async () => {
open({ ...mia, role: "admin" }, true);
await screen.findByText("api");
expect(screen.getByLabelText("Role").hasAttribute("disabled")).toBe(true);
expect(screen.getByRole("switch", { name: "Active" }).hasAttribute("disabled")).toBe(true);
});
it("accepts a short new password: there is no minimum length", async () => {
const ue = userEvent.setup();
open();
await screen.findByText("api");
await ue.type(screen.getByLabelText("New password"), "abc");
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
expect(save().hasAttribute("disabled")).toBe(false);
});
it("deletes a user after the admin's password and a red confirm that says what goes", async () => {
users.remove.mockReset().mockResolvedValue({ user: mia });
confirmDialog.mockReset().mockResolvedValue(true);
const ue = userEvent.setup();
const { onDone } = open();
await screen.findByText("api");
// No password yet: asked for it, nothing sent.
await ue.click(screen.getByRole("button", { name: "Delete user" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Enter your own password");
expect(users.remove).not.toHaveBeenCalled();
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
await ue.click(screen.getByRole("button", { name: "Delete user" }));
expect(confirmDialog).toHaveBeenCalledWith(
expect.objectContaining({
destructive: true,
message: expect.stringContaining("/srv/agenthub/users/mia"),
})
);
await waitFor(() => expect(users.remove).toHaveBeenCalledWith(2, "boss-pass"));
expect(onDone).toHaveBeenCalled();
});
it("sends nothing when the confirm is cancelled, and offers no delete for yourself", async () => {
users.remove.mockReset();
confirmDialog.mockReset().mockResolvedValue(false);
const ue = userEvent.setup();
open();
await screen.findByText("api");
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
await ue.click(screen.getByRole("button", { name: "Delete user" }));
expect(users.remove).not.toHaveBeenCalled();
});
it("has no Delete button on your own account", async () => {
open(mia, true);
await screen.findByText("api");
expect(screen.queryByRole("button", { name: "Delete user" })).toBeNull();
});
it("generates a 16-character password", async () => {
expect(generatePassword()).toHaveLength(16);
const ue = userEvent.setup();
open();
await screen.findByText("api");
await ue.click(screen.getByRole("button", { name: /Generate/ }));
expect((screen.getByLabelText("New password") as HTMLInputElement).value).toHaveLength(16);
});
});