Files
AgentHub/client/src/components/settings/__tests__/AccountSettings.test.tsx
T
nntrivi2001 26952ae0d9 feat: avatar by link, loaded by the browser; Files nests workspaces under owners' homes
- POST /api/me/avatar/url stores the https link (users.avatar_link) instead
  of downloading it; GET /api/me/avatar and /api/users/:id/avatar answer 302
  to it (no referrer). Link rules: https, no userinfo, <= 2048 chars, no
  localhost or private/reserved IP literal. Upload and link replace each other.
- Settings -> Account first loads the link as an embedded image and refuses
  one this browser cannot show: a picture behind a login (Mattermost's
  /api/v4/users/<id>/image) gets no cookie cross-site and 401s.
- Files sidebar: each workspace under its owner's home, chevrons remembered
  per browser (agenthub.files.homesOpen); ownerless ones stay flat.
2026-10-22 14:34:50 +07:00

195 lines
8.0 KiB
TypeScript

/**
* @file AccountSettings.test.tsx
* @description Settings → Account: shows the account's own information, saves
* only a changed display name/email, refuses a password change whose two new
* passwords differ, keeps a password that misses a rule from being sent,
* sends the current + new password, asks before signing
* the other devices out, and uploads/removes the avatar or sets it from a link.
* @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";
const me = vi.hoisted(() => ({
account: vi.fn(),
updateAccount: vi.fn(),
changePassword: vi.fn(),
signOutOthers: vi.fn(),
setAvatar: vi.fn(),
setAvatarFromUrl: vi.fn(),
removeAvatar: vi.fn(),
avatarUrl: (at?: string | null) => (at ? `/api/me/avatar?v=${at}` : null),
}));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, api: { me } };
});
const confirm = vi.hoisted(() => vi.fn());
vi.mock("../../../lib/confirm", () => ({ confirmDialog: confirm }));
import { AccountSettings } from "../AccountSettings";
const account = {
mode: "server" as const,
user: {
id: 2,
username: "mia",
role: "developer" as const,
disabled: false,
displayName: "Mia Tran",
email: null,
createdAt: "2026-10-01T00:00:00.000Z",
},
workspaces: 3,
sessions: 12,
logins: 2,
lastLoginAt: null,
};
beforeEach(() => {
for (const fn of Object.values(me)) if ("mockReset" in fn) fn.mockReset();
me.account.mockResolvedValue(account);
me.updateAccount.mockResolvedValue(account);
me.changePassword.mockResolvedValue({ ok: true });
me.signOutOthers.mockResolvedValue({ ended: 1 });
confirm.mockReset().mockResolvedValue(true);
});
describe("AccountSettings", () => {
it("uploads a picked avatar and removes it again", async () => {
const ue = userEvent.setup();
const withAvatar = { ...account, user: { ...account.user, avatarAt: "2026-10-14T00:00:00Z" } };
me.setAvatar.mockResolvedValue(withAvatar);
me.removeAvatar.mockResolvedValue(account);
render(<AccountSettings />);
await screen.findByTestId("account-card");
expect(screen.getByTestId("user-avatar-initials").textContent).toBe("Mi");
expect(screen.queryByTestId("account-avatar-remove")).toBeNull();
const file = new File([new Uint8Array([0x89, 0x50])], "me.png", { type: "image/png" });
await ue.upload(screen.getByTestId("account-avatar-input"), file);
await waitFor(() => expect(me.setAvatar).toHaveBeenCalledWith(file));
expect((await screen.findByTestId("user-avatar-img")).getAttribute("src")).toBe(
"/api/me/avatar?v=2026-10-14T00:00:00Z"
);
await ue.click(screen.getByTestId("account-avatar-remove"));
await waitFor(() => expect(me.removeAvatar).toHaveBeenCalled());
expect(await screen.findByTestId("user-avatar-initials")).toBeTruthy();
});
/** jsdom never loads an image: play the browser's answer to `src`. */
const stubImage = (loads: boolean) =>
vi.stubGlobal(
"Image",
class {
naturalWidth = loads ? 64 : 0;
referrerPolicy = "";
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
set src(_v: string) {
setTimeout(() => (loads ? this.onload?.() : this.onerror?.()), 0);
}
}
);
it("refuses a link this browser cannot load as an image, and saves nothing", async () => {
const ue = userEvent.setup();
stubImage(false);
render(<AccountSettings />);
await screen.findByTestId("account-card");
await ue.click(screen.getByTestId("account-avatar-url-toggle"));
await ue.type(
screen.getByTestId("account-avatar-url-input"),
"https://chat.example/api/v4/users/x/image{Enter}"
);
await waitFor(() => expect(screen.getByTestId("account-avatar-url-input")).toBeTruthy());
await new Promise((r) => setTimeout(r, 20));
expect(me.setAvatarFromUrl).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
it("sets the avatar from a link and closes the field", async () => {
const ue = userEvent.setup();
stubImage(true);
const withAvatar = { ...account, user: { ...account.user, avatarAt: "2026-10-21T00:00:00Z" } };
me.setAvatarFromUrl.mockResolvedValue(withAvatar);
render(<AccountSettings />);
await screen.findByTestId("account-card");
expect(screen.queryByTestId("account-avatar-url-input")).toBeNull();
await ue.click(screen.getByTestId("account-avatar-url-toggle"));
await ue.type(screen.getByTestId("account-avatar-url-input"), " https://x.vn/me.png {Enter}");
await waitFor(() => expect(me.setAvatarFromUrl).toHaveBeenCalledWith("https://x.vn/me.png"));
expect((await screen.findByTestId("user-avatar-img")).getAttribute("src")).toBe(
"/api/me/avatar?v=2026-10-21T00:00:00Z"
);
expect(screen.queryByTestId("account-avatar-url-input")).toBeNull();
vi.unstubAllGlobals();
});
it("shows the account's own information", async () => {
render(<AccountSettings />);
const card = await screen.findByTestId("account-card");
expect(card.textContent).toContain("Mia Tran");
expect(card.textContent).toContain("@mia");
expect(card.textContent).toContain("Developer");
expect(card.textContent).toContain("12");
});
it("saves only a changed field", async () => {
const ue = userEvent.setup();
render(<AccountSettings />);
await screen.findByTestId("account-card");
await ue.type(screen.getByLabelText("Email"), "mia@x.vn");
await ue.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(me.updateAccount).toHaveBeenCalledWith({ email: "mia@x.vn" }));
});
it("refuses two different new passwords", async () => {
const ue = userEvent.setup();
render(<AccountSettings />);
await screen.findByTestId("account-card");
await ue.type(screen.getByLabelText("Current password"), "old-pass-1");
await ue.type(screen.getByLabelText("New password"), "new-pass-123");
await ue.type(screen.getByLabelText("Confirm new password"), "new-pass-999");
expect(screen.getByText("The two new passwords differ.")).toBeTruthy();
const button = screen.getByRole("button", { name: "Change password" });
expect(button.hasAttribute("disabled")).toBe(true);
expect(me.changePassword).not.toHaveBeenCalled();
});
it("changes the password with the current one", async () => {
const ue = userEvent.setup();
render(<AccountSettings />);
await screen.findByTestId("account-card");
await ue.type(screen.getByLabelText("Current password"), "old-pass-1");
await ue.type(screen.getByLabelText("New password"), "new-pass-123");
await ue.type(screen.getByLabelText("Confirm new password"), "new-pass-123");
// No upper-case letter: the checklist says so and the button stays off.
expect(
screen
.getByTestId("password-rules")
.querySelector('[data-rule="upper"]')
?.getAttribute("data-ok")
).toBe("false");
expect(screen.getByRole("button", { name: "Change password" })).toBeDisabled();
await ue.clear(screen.getByLabelText("New password"));
await ue.clear(screen.getByLabelText("Confirm new password"));
await ue.type(screen.getByLabelText("New password"), "New-pass-123");
await ue.type(screen.getByLabelText("Confirm new password"), "New-pass-123");
await ue.click(screen.getByRole("button", { name: "Change password" }));
await waitFor(() =>
expect(me.changePassword).toHaveBeenCalledWith("old-pass-1", "New-pass-123")
);
});
it("asks before signing the other devices out", async () => {
const ue = userEvent.setup();
render(<AccountSettings />);
await screen.findByTestId("account-card");
await ue.click(screen.getByRole("button", { name: "Sign out other devices" }));
expect(confirm).toHaveBeenCalled();
await waitFor(() => expect(me.signOutOthers).toHaveBeenCalled());
});
});