750d74a2af
- omp-share.js: an admin's switch (S3) runs omp's own auth broker + gateway as the host on loopback; a member's omp pane reaches it as a scoped client keyed by its pane token (clients file = live omp panes only), runs on --model agenthub/<model>, never holds a credential. - omp catalog: --allow-home, so a workspace at the account's home keeps its sessions there (they were recorded under a temp dir and dropped). - Settings → Appearance → Open Markdown as: Preview (default) or Raw, for Files/Changes, the config viewer and a chat's file panel. - First-login password change and password rules, avatar URLs, Artifacts "Mine" includes installed shared skills/plugins, a member's own hook pages.
162 lines
6.8 KiB
TypeScript
162 lines
6.8 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();
|
|
});
|
|
|
|
it("sets the avatar from a link and closes the field", async () => {
|
|
const ue = userEvent.setup();
|
|
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();
|
|
});
|
|
|
|
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());
|
|
});
|
|
});
|