/** * @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ĩ */ 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>(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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()); }); });