feat(auth): 用户账户 - 完善个人资料与登录日志 (#752)

Co-authored-by: wuligao <1247662379@qq.com>
This commit is contained in:
ddcat666
2026-10-13 05:31:30 +08:00
committed by GitHub
parent e70fe1b9c9
commit 4c1d2b4012
83 changed files with 5836 additions and 328 deletions

View File

@@ -86,10 +86,10 @@ test("channel model fetch requires explicit selection before import", async () =
expect(component).toContain("setFetchPreviewOpen(true)");
expect(component).toContain("setSelectedFetchModels(result.models)");
expect(component).toContain("已选择 {selectedFetchModels.length} / {fetchPreviewModels.length} 个模型");
expect(component).toContain("disabled={importing || allFetchModelsSelected}");
expect(component).toContain("onClick={() => setSelectedFetchModels(fetchPreviewModels)}");
expect(component).toContain("disabled={importing || selectedFetchModels.length === 0}");
expect(component).toContain("onClick={() => setSelectedFetchModels([])}");
expect(component).toContain("disabled={importing || !visibleFetchModels.length || allVisibleFetchModelsSelected}");
expect(component).toContain("onClick={() => setSelectedFetchModels((current) => selectVisibleModels(current, visibleFetchModels))}");
expect(component).toContain("disabled={importing || selectedVisibleFetchCount === 0}");
expect(component).toContain("onClick={() => setSelectedFetchModels((current) => deselectVisibleModels(current, visibleFetchModels))}");
expect(component).toContain("取消全选");
expect(component).toContain("disabled={importing}");
expect(component).toContain("importAdminChannelModels(channel.id, selectedFetchModels)");

View File

@@ -1,4 +1,5 @@
import { expect, test } from "bun:test";
import { fileURLToPath } from "node:url";
import { runInNewContext } from "node:vm";
import { clampAgentLauncherPosition, moveAgentLauncher, restoreAgentLauncherPosition } from "../src/lib/canvas/agent-launcher-position";
@@ -36,7 +37,7 @@ test("small pointer jitter remains a click, but dragging back to the origin rema
// Execute the production hook with a tiny hook scheduler; only React and browser
// side effects are substituted, not the pointer/click handlers being tested.
const build = await Bun.build({
entrypoints: [new URL("../src/components/canvas/use-agent-launcher-position.ts", import.meta.url).pathname],
entrypoints: [fileURLToPath(new URL("../src/components/canvas/use-agent-launcher-position.ts", import.meta.url))],
target: "browser",
format: "cjs",
plugins: [

View File

@@ -1,12 +1,13 @@
import { expect, test } from "bun:test";
import { runInNewContext } from "node:vm";
import { fileURLToPath } from "node:url";
import { isIsolatedPrevisRepro } from "../src/lib/dev-repro";
// Execute the real entry point; replace only its font, network and UI side effects.
async function prepareEntry(dev, pathname) {
const build = await Bun.build({
entrypoints: [new URL("../src/main.tsx", import.meta.url).pathname],
entrypoints: [fileURLToPath(new URL("../src/main.tsx", import.meta.url))],
target: "browser",
format: "iife",
define: { "import.meta.env.DEV": JSON.stringify(dev) },

View File

@@ -0,0 +1,14 @@
import { describe, expect, test } from "bun:test";
import { loginLogDevice } from "../src/pages/admin/login-logs/login-log-display";
describe("登录日志设备识别", () => {
test("Edge 不误判为 Chrome,Android 不误判为 Linux", () => {
expect(loginLogDevice("Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0")).toEqual({ browser: "Edge 130", os: "Windows" });
expect(loginLogDevice("Mozilla/5.0 (Linux; Android 14) Chrome/130.0.0.0 Mobile Safari/537.36")).toEqual({ browser: "Chrome 130", os: "Android 14" });
});
test("iPhone 和未知 UA 明确展示识别边界", () => {
expect(loginLogDevice("Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) Version/18.0 Mobile/15E148 Safari/604.1")).toEqual({ browser: "Safari 18", os: "iOS 18.0" });
expect(loginLogDevice("custom-client")).toEqual({ browser: "未识别浏览器", os: "未识别系统" });
expect(loginLogDevice("")).toEqual({ browser: "未记录", os: "未记录" });
});
});

View File

@@ -0,0 +1,136 @@
import { describe, expect, test } from "bun:test";
import { centeredAspectSelection, resizeSelection, translateSelection, type CropGrip } from "../src/lib/canvas/image-crop-selection";
import { cropProfileAvatar, planProfileAvatarCrop } from "../src/services/profile-avatar";
describe("fixed avatar crop geometry", () => {
test("starts with the largest centered square on wide, tall and odd images", () => {
for (const size of [
{ width: 1200, height: 800 },
{ width: 800, height: 1200 },
{ width: 901, height: 600 },
]) {
const crop = centeredAspectSelection(size, 1);
expect(crop.width * size.width).toBeCloseTo(Math.min(size.width, size.height));
expect(crop.height * size.height).toBeCloseTo(Math.min(size.width, size.height));
expect(crop.x + crop.width / 2).toBeCloseTo(0.5);
expect(crop.y + crop.height / 2).toBeCloseTo(0.5);
const plan = planProfileAvatarCrop(size, crop);
expect(plan.source.width).toBe(plan.source.height);
expect(plan.outputSide).toBe(512);
}
});
test("every locked grip and movement preserves physical square pixels", () => {
const size = { width: 1200, height: 600 };
const initial = centeredAspectSelection(size, 1);
for (const x of [-1, 0, 1] as const)
for (const y of [-1, 0, 1] as const) {
if (!x && !y) continue;
const resized = resizeSelection(initial, [x, y] as CropGrip, -0.1, -0.1, true);
const moved = translateSelection(resized, 0.2, -0.3);
expect(moved.width * size.width).toBeCloseTo(moved.height * size.height);
expect(planProfileAvatarCrop(size, moved).source.width).toBe(planProfileAvatarCrop(size, moved).source.height);
}
});
test("square crops on panoramic images can shrink from every grip and remain square", () => {
for (const size of [
{ width: 2000, height: 100 },
{ width: 100, height: 2000 },
]) {
const initial = centeredAspectSelection(size, 1);
for (const x of [-1, 0, 1] as const)
for (const y of [-1, 0, 1] as const) {
if (!x && !y) continue;
const next = resizeSelection(initial, [x, y], (-x * initial.width) / 4, (-y * initial.height) / 4, true);
expect(next.width * size.width).toBeCloseTo(75);
expect(next.height * size.height).toBeCloseTo(75);
expect(next.x).toBeGreaterThanOrEqual(0);
expect(next.y).toBeGreaterThanOrEqual(0);
expect(next.x + next.width).toBeLessThanOrEqual(1);
expect(next.y + next.height).toBeLessThanOrEqual(1);
}
}
});
test("one-pixel keyboard steps shrink panoramic squares and enforce a reachable positive minimum", () => {
for (const size of [
{ width: 2000, height: 100 },
{ width: 100, height: 2000 },
]) {
const initial = centeredAspectSelection(size, 1);
expect(resizeSelection(initial, [1, 0], -1 / size.width, 0, true).width * size.width).toBeCloseTo(99);
expect(resizeSelection(initial, [0, 1], 0, -1 / size.height, true).height * size.height).toBeCloseTo(99);
const smallest = resizeSelection(initial, [1, 1], -10, -10, true);
expect(smallest.width * size.width).toBeCloseTo(6);
expect(smallest.height * size.height).toBeCloseTo(6);
}
});
test("rejects decoded images over 16M pixels, invalid and non-square crops", () => {
expect(() => planProfileAvatarCrop({ width: 4001, height: 4000 }, { x: 0, y: 0, width: 1, height: 1 })).toThrow("1600");
expect(() => planProfileAvatarCrop({ width: 100, height: 100 }, { x: 0, y: 0, width: 1, height: 0.5 })).toThrow("正方形");
expect(() => centeredAspectSelection({ width: 100, height: 0 }, 1)).toThrow();
expect(() => centeredAspectSelection({ width: 100, height: 100 }, 0)).toThrow();
expect(() => planProfileAvatarCrop({ width: 100, height: 100 }, { x: 2, y: 0, width: 1, height: 1 })).toThrow();
expect(planProfileAvatarCrop({ width: 100, height: 100 }, { x: 0.2, y: 0.3, width: 0.4, height: 0.4 })).toEqual({ source: { x: 20, y: 30, width: 40, height: 40 }, outputSide: 40 });
});
});
async function withRasterDOM(width: number, height: number, run: (state: { drawn: unknown[][]; imageSources: string[]; canvas: { width: number; height: number }; failExport: () => void }) => Promise<void>) {
const previousImage = globalThis.Image;
const previousDocument = globalThis.document;
const drawn: unknown[][] = [];
const imageSources: string[] = [];
let exportFails = false;
class TestImage {
naturalWidth = width;
naturalHeight = height;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
set src(value: string) {
imageSources.push(value);
if (value) queueMicrotask(() => this.onload?.());
}
}
const canvas = { width: 0, height: 0, getContext: () => ({ drawImage: (...values: unknown[]) => drawn.push(values) }), toBlob: (callback: (blob: Blob | null) => void) => callback(exportFails ? null : new Blob(["png"], { type: "image/png" })) };
Object.assign(globalThis, { Image: TestImage, document: { createElement: () => canvas } });
try {
await run({
drawn,
imageSources,
canvas,
failExport: () => {
exportFails = true;
},
});
} finally {
Object.assign(globalThis, { Image: previousImage, document: previousDocument });
}
}
describe("avatar PNG export", () => {
test("draws the chosen source region into a square bounded by 512 and releases buffers", async () => {
await withRasterDOM(1200, 800, async ({ drawn, canvas, imageSources }) => {
const file = await cropProfileAvatar("blob:test", { x: 0.25, y: 0.125, width: 0.5, height: 0.75 }, "../../private.jpg");
expect(drawn[0].slice(1)).toEqual([300, 100, 600, 600, 0, 0, 512, 512]);
expect(file.name).toBe("avatar.png");
expect(file.type).toBe("image/png");
expect(file.size).toBeGreaterThan(0);
expect(canvas.width).toBe(0);
expect(canvas.height).toBe(0);
expect(imageSources.at(-1)).toBe("");
});
});
test("fails explicitly when PNG export fails and still releases image and canvas", async () => {
await withRasterDOM(100, 100, async ({ failExport, canvas, imageSources }) => {
failExport();
await expect(cropProfileAvatar("blob:test", { x: 0, y: 0, width: 1, height: 1 }, "avatar.jpg")).rejects.toThrow("PNG");
expect(canvas.width).toBe(0);
expect(imageSources.at(-1)).toBe("");
});
});
test("rejects pixel bombs before drawing", async () => {
await withRasterDOM(5000, 5000, async ({ drawn, imageSources }) => {
await expect(cropProfileAvatar("blob:test", { x: 0, y: 0, width: 1, height: 1 }, "avatar.jpg")).rejects.toThrow("1600");
expect(drawn).toHaveLength(0);
expect(imageSources.at(-1)).toBe("");
});
});
});

View File

@@ -0,0 +1,44 @@
import { expect, test } from "bun:test";
import { QueryClient } from "@tanstack/react-query";
import { apiClient } from "../src/services/api/request";
import { loadProfileSession } from "../src/services/profile-session";
import { useUserStore, type LocalUser } from "../src/stores/use-user-store";
const originalUser: LocalUser = { id: "profile-user", username: "user", displayName: "User", role: "user", status: "active", avatarMode: "default", avatarPreset: "person" };
test("profile refresh synchronizes avatars while rejecting stale account and cancelled responses", async () => {
const originalRequest = apiClient.request;
const previousUser = useUserStore.getState().user;
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
try {
for (const scenario of ["refresh", "switched-account", "wrong-user", "cancelled", "failure"] as const) {
useUserStore.getState().setUser(originalUser);
let respond!: (value: unknown) => void;
let reject!: (reason: Error) => void;
apiClient.request = (() =>
new Promise((resolve, fail) => {
respond = resolve;
reject = fail;
})) as typeof apiClient.request;
const key = ["user-profile", scenario];
const request = client.fetchQuery({ queryKey: key, queryFn: ({ signal }) => loadProfileSession(originalUser.id, signal) }).catch((error) => error);
if (scenario === "switched-account") useUserStore.getState().setUser({ ...originalUser, id: "other-user" });
if (scenario === "cancelled") {
await client.cancelQueries({ queryKey: key });
useUserStore.getState().setUser({ ...originalUser, avatarPreset: "cat" });
}
if (scenario === "failure") reject(new Error("profile unavailable"));
else respond({ data: { code: 0, data: { user: { ...originalUser, id: scenario === "wrong-user" ? "unexpected-user" : originalUser.id, avatarPreset: "robot" } }, msg: "ok" }, status: 200 });
await request;
await Promise.resolve();
const stored = useUserStore.getState().user;
expect(stored?.id).toBe(scenario === "switched-account" ? "other-user" : originalUser.id);
expect(stored?.avatarPreset).toBe(scenario === "refresh" ? "robot" : scenario === "cancelled" ? "cat" : "person");
}
} finally {
await client.cancelQueries();
client.clear();
apiClient.request = originalRequest;
useUserStore.setState({ user: previousUser });
}
});

View File

@@ -0,0 +1,99 @@
import { describe, expect, test } from "bun:test";
import { profileAvatarError, PROFILE_AVATAR_MAX_BYTES, profilePasswordError, profileReauthReady, profileSecondsRemaining } from "../src/pages/settings/profile-validation";
import { settingsPath } from "../src/lib/settings-navigation";
import { changeProfileEmail, changeProfilePassword, getUserProfile, profileLinuxDOReauthURL, requestProfileEmailCode, resetProfileAvatar, selectDefaultProfileAvatar, uploadProfileAvatar } from "../src/services/api/profile";
import { ApiError, apiClient } from "../src/services/api/request";
describe("profile input and security expiry", () => {
test("rejects unsupported, empty and oversized avatar uploads", () => {
expect(profileAvatarError({ type: "image/svg+xml", size: 100 })).toBeDefined();
expect(profileAvatarError({ type: "image/png", size: 0 })).toBeDefined();
expect(profileAvatarError({ type: "image/jpeg", size: PROFILE_AVATAR_MAX_BYTES + 1 })).toBeDefined();
expect(profileAvatarError({ type: "image/png", size: PROFILE_AVATAR_MAX_BYTES })).toBeUndefined();
});
test("checks password UTF-8 byte limit to prevent bcrypt truncation", () => {
expect(profilePasswordError("1234567")).toBeDefined();
expect(profilePasswordError("a".repeat(72))).toBeUndefined();
expect(profilePasswordError("a".repeat(73))).toBeDefined();
expect(profilePasswordError("密".repeat(24))).toBeUndefined();
expect(profilePasswordError("密".repeat(25))).toBeDefined();
expect(profilePasswordError("😀".repeat(4))).toBeDefined();
expect(profilePasswordError("😀".repeat(8))).toBeUndefined();
});
test("expires identity proof and code cooldown at the boundary", () => {
const deadline = "2026-10-12T12:00:00Z";
const timestamp = Date.parse(deadline);
expect(profileReauthReady(deadline, timestamp - 1)).toBe(true);
expect(profileReauthReady(deadline, timestamp)).toBe(false);
expect(profileReauthReady("invalid", timestamp)).toBe(false);
expect(profileReauthReady(undefined, timestamp)).toBe(false);
expect(profileSecondsRemaining(timestamp, timestamp - 1001)).toBe(2);
expect(profileSecondsRemaining(timestamp, timestamp + 1)).toBe(0);
});
test("account settings default to profile and creation continues to channels", () => {
expect(settingsPath()).toBe("/settings?section=profile");
expect(settingsPath(undefined, true)).toBe("/settings?section=channels&continue=1");
expect(settingsPath("models", true)).toBe("/settings?section=models&continue=1");
expect(settingsPath("profile")).toBe("/settings?section=profile");
});
});
describe("profile API request contract", () => {
test("forwards load cancellation and uses multipart avatar without forcing content type", async () => {
const original = apiClient.request;
const requests: Array<Record<string, unknown>> = [];
apiClient.request = (async (config: Record<string, unknown>) => {
requests.push(config);
return { data: { code: 0, data: { user: { id: "user-1" } }, msg: "ok" }, status: 200 };
}) as typeof apiClient.request;
try {
const signal = new AbortController().signal;
await getUserProfile(signal);
const file = new File(["image-bytes"], "avatar.png", { type: "image/png" });
await uploadProfileAvatar(file);
await resetProfileAvatar();
await selectDefaultProfileAvatar("cat");
expect(requests[0]).toMatchObject({ url: "/auth/profile", method: "get", signal });
expect(requests[1]).toMatchObject({ url: "/auth/profile/avatar", method: "put" });
const multipart = requests[1].data as FormData;
expect(multipart.get("file")).toBeInstanceOf(File);
expect((multipart.get("file") as File).name).toBe("avatar.png");
expect(requests[1].headers).toBeUndefined();
expect(requests[2]).toMatchObject({ url: "/auth/profile/avatar", method: "delete" });
expect(requests[3]).toMatchObject({ url: "/auth/profile/avatar/default", method: "post", data: { preset: "cat" } });
} finally {
apiClient.request = original;
}
});
test("keeps passwords, email codes and tickets in POST bodies", async () => {
const original = apiClient.request;
const requests: Array<Record<string, unknown>> = [];
apiClient.request = (async (config: Record<string, unknown>) => {
requests.push(config);
return { data: { code: 0, data: { changed: true }, msg: "ok" }, status: 200 };
}) as typeof apiClient.request;
try {
await changeProfilePassword({ currentPassword: "old-password", newPassword: "new-password" });
await requestProfileEmailCode({ email: "new@example.test", currentPassword: "old-password" });
await changeProfileEmail({ ticket: "test-ticket", emailCode: "123456", currentPassword: "old-password" });
expect(requests.map((request) => request.url)).toEqual(["/auth/profile/password", "/auth/profile/email-code", "/auth/profile/email"]);
expect(requests.every((request) => request.method === "post" && request.params === undefined)).toBe(true);
expect(requests[2].data).toEqual({ ticket: "test-ticket", emailCode: "123456", currentPassword: "old-password" });
expect(profileLinuxDOReauthURL()).toEndWith("/auth/profile/linuxdo/start");
expect(profileLinuxDOReauthURL()).not.toContain("?");
} finally {
apiClient.request = original;
}
});
test("propagates failed security writes with the backend reason", async () => {
const original = apiClient.request;
apiClient.request = (async () => ({ data: { code: 40001, data: null, msg: "身份验证失败", reason: "bad_auth_request" }, status: 400 })) as typeof apiClient.request;
try {
const error = await changeProfilePassword({ currentPassword: "wrong", newPassword: "new-password" }).catch((failure) => failure);
expect(error).toBeInstanceOf(ApiError);
expect(error.reason).toBe("bad_auth_request");
} finally {
apiClient.request = original;
}
});
});

View File

@@ -0,0 +1,127 @@
import { describe, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { UserAvatar } from "../src/components/layout/user-avatar";
import { resolveUserAvatarURL } from "../src/lib/user-avatar-url";
import { DEFAULT_USER_AVATARS, isDefaultUserAvatarID } from "../src/lib/default-user-avatars";
import { apiBaseURL } from "../src/services/api/request";
import type { LocalUser } from "../src/stores/use-user-store";
const user: LocalUser = { id: "user-1", username: "user", displayName: "User", role: "user", status: "active" };
function renderAvatar(avatarUrl?: string) {
return renderToStaticMarkup(<UserAvatar user={{ ...user, avatarUrl }} className="size-8" />);
}
describe("shared user avatar image sources", () => {
test("renders authenticated profile images and linked HTTP avatars", () => {
for (const source of ["/api/auth/profile/avatar/user-1?v=012abc", "https://example.com/avatar.png", "http://example.com/avatar.jpg"]) {
const markup = renderAvatar(source);
expect(markup).toContain(`<img src="${resolveUserAvatarURL(source, apiBaseURL)}"`);
expect(markup).toContain("object-cover");
}
});
test("rejects unrelated relative URLs, traversal, protocol-relative and executable sources", () => {
for (const source of [
undefined,
"",
"/assets/avatar.png",
"/api/auth/profile/avatar/../settings",
"/api/auth/profile/avatar/user-1/../../settings",
"/api/auth/profile/avatar/user-1?redirect=https://example.com",
"//example.com/avatar.png",
"data:image/svg+xml,test",
"javascript:alert(1)",
"blob:https://example.com/test",
]) {
const markup = renderAvatar(source);
expect(markup).not.toContain("<img");
expect(markup).toContain("<svg");
}
});
test("preserves sidebar icon styling when using the system default", () => {
const markup = renderToStaticMarkup(<UserAvatar user={user} className="size-7" iconClassName="app-workspace-account-icon" />);
expect(markup).toContain("app-workspace-account-icon");
expect(markup).not.toContain("<img");
});
});
describe("profile avatar deployment routing", () => {
const canonical = "/api/auth/profile/avatar/user-1?v=012abc";
test("resolves the canonical path onto API origins and proxy prefixes", () => {
for (const [base, expected] of [
["/api", canonical],
["/api/", canonical],
["/proxy/yingce/api", "/proxy/yingce/api/auth/profile/avatar/user-1?v=012abc"],
["/proxy/yingce/api///", "/proxy/yingce/api/auth/profile/avatar/user-1?v=012abc"],
["https://backend.example.com/api", "https://backend.example.com/api/auth/profile/avatar/user-1?v=012abc"],
["https://backend.example.com/proxy/yingce/", "https://backend.example.com/proxy/yingce/auth/profile/avatar/user-1?v=012abc"],
["http://localhost:8080", "http://localhost:8080/auth/profile/avatar/user-1?v=012abc"],
["/", "/auth/profile/avatar/user-1?v=012abc"],
]) {
expect(resolveUserAvatarURL(canonical, base)).toBe(expected);
}
});
test("keeps third-party URLs independent of the configured API base", () => {
for (const source of ["https://thirdparty.example.com/avatar.png?q=123", "http://thirdparty.example.com/avatar.jpg"]) {
expect(resolveUserAvatarURL(source, "https://backend.example.com/proxy/api")).toBe(source);
}
});
test("rejects noncanonical paths before joining with a custom backend", () => {
for (const source of [
undefined,
"",
"/api/auth/profile/avatar/user-1",
"/api/auth/profile/avatar/user-1?v=",
"/api/auth/profile/avatar/user-1?v=012abc&next=/settings",
"/api/auth/profile/avatar/%2e%2e?v=012abc",
"/api/auth/profile/avatar/user-1/../other?v=012abc",
"/api/auth/profile/avatar/user-1?v=012abc#fragment",
"/proxy/api/auth/profile/avatar/user-1?v=012abc",
"//thirdparty.example.com/avatar.png",
"data:image/png;base64,AAAA",
]) {
expect(resolveUserAvatarURL(source, "https://backend.example.com/proxy/api")).toBe("");
}
});
});
describe("system avatar catalog and shared rendering", () => {
test("exposes eight stable distinct presets and validates exact IDs", () => {
expect(DEFAULT_USER_AVATARS.map((avatar) => avatar.id)).toEqual(["person", "director", "camera", "cat", "rabbit", "robot", "rocket", "sparkles"]);
expect(new Set(DEFAULT_USER_AVATARS.map((avatar) => avatar.id)).size).toBe(8);
for (const avatar of DEFAULT_USER_AVATARS) {
expect(isDefaultUserAvatarID(avatar.id)).toBe(true);
expect(avatar.label).not.toBe("");
}
for (const invalid of [undefined, null, "", "Cat", " cat", "cat ", "default", "../person"]) {
expect(isDefaultUserAvatarID(invalid)).toBe(false);
}
});
test("renders the selected icon globally and overrides inherited image URLs", () => {
const iconClasses = ["lucide-circle-user-round", "lucide-clapperboard", "lucide-camera", "lucide-cat", "lucide-rabbit", "lucide-bot", "lucide-rocket", "lucide-sparkles"];
for (const [index, preset] of DEFAULT_USER_AVATARS.entries()) {
const markup = renderToStaticMarkup(
<UserAvatar user={{ ...user, avatarMode: "default", avatarPreset: preset.id, avatarUrl: "https://example.com/inherited.png" }} className="creation-user-avatar" iconClassName="size-6 app-workspace-account-icon" />,
);
expect(markup).not.toContain("<img");
expect(markup).toContain(iconClasses[index]);
expect(markup).toContain("creation-user-avatar");
expect(markup).toContain("app-workspace-account-icon");
}
});
test("keeps custom images after remembering a preset preference", () => {
const markup = renderToStaticMarkup(<UserAvatar user={{ ...user, avatarMode: "custom", avatarPreset: "cat", avatarUrl: "/api/auth/profile/avatar/user-1?v=012abc" }} />);
expect(markup).toContain("<img");
expect(markup).not.toContain("lucide-cat");
const legacyDefault = renderToStaticMarkup(<UserAvatar user={{ ...user, avatarMode: "default" }} />);
expect(legacyDefault).toContain("lucide-circle-user-round");
});
});