// @vitest-environment jsdom import { act } from "react"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { sanitizeAssetNamespace } from "@paperclipai/shared"; import { ApiError } from "@/api/client"; import { queryKeys } from "../lib/queryKeys"; import { markCachedSession } from "../lib/cached-session"; import { startPersistedQueryCache } from "../lib/persisted-query-cache"; import { ProfileSettings } from "./ProfileSettings"; const mockAuthApi = vi.hoisted(() => ({ getSession: vi.fn(), signInEmail: vi.fn(), signUpEmail: vi.fn(), getProfile: vi.fn(), updateProfile: vi.fn(), signOut: vi.fn(), })); const mockAssetsApi = vi.hoisted(() => ({ uploadImage: vi.fn(), uploadCompanyLogo: vi.fn(), })); const mockSetBreadcrumbs = vi.hoisted(() => vi.fn()); vi.mock("@/api/auth", () => ({ authApi: mockAuthApi, })); vi.mock("@/components/InboxAgentPolicyControl", () => ({ InboxAgentPolicyControl: () => null })); vi.mock("@/api/assets", () => ({ assetsApi: mockAssetsApi, })); vi.mock("../context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs, }), })); vi.mock("../context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company-1", selectedCompany: { id: "company-1", name: "Paperclip", issuePrefix: "PAP" }, }), })); // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; async function flushReact() { await act(async () => { await Promise.resolve(); await new Promise((resolve) => window.setTimeout(resolve, 0)); }); } describe("ProfileSettings", () => { let container: HTMLDivElement; beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); mockAuthApi.getSession.mockResolvedValue({ session: { id: "session-1", userId: "user-1" }, user: { id: "user-1", name: "Jane Example", email: "jane@example.com", image: "https://example.com/jane.png", }, }); mockAssetsApi.uploadImage.mockResolvedValue({ assetId: "asset-1", contentPath: "/api/assets/asset-1/content", }); mockAuthApi.updateProfile.mockImplementation(async (input: { name: string; image: string | null }) => ({ id: "user-1", name: input.name, email: "jane@example.com", image: input.image, })); }); afterEach(() => { container.remove(); document.body.innerHTML = ""; vi.clearAllMocks(); }); it("does not render a keyboard shortcuts toggle because shortcuts are always enabled", async () => { const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); await act(async () => { root.render(); }); await flushReact(); expect(container.textContent).toContain("Jane Example"); expect(container.textContent).not.toContain("Keyboard shortcuts"); expect(container.querySelector('[aria-label="Toggle keyboard shortcuts"]')).toBeNull(); await act(async () => root.unmount()); }); it("uploads a clicked avatar into Paperclip storage and persists the returned asset path", async () => { const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); await act(async () => { root.render( , ); }); await flushReact(); await flushReact(); expect(container.textContent).not.toContain("Avatar image URL"); const avatarInput = container.querySelector('input[type="file"]') as HTMLInputElement | null; expect(avatarInput).not.toBeNull(); const file = new File(["avatar"], "avatar.png", { type: "image/png" }); Object.defineProperty(avatarInput, "files", { configurable: true, value: [file], }); await act(async () => { avatarInput?.dispatchEvent(new Event("change", { bubbles: true })); }); await flushReact(); await flushReact(); expect(mockAssetsApi.uploadImage).toHaveBeenCalledWith("company-1", file, "profiles/user-1"); expect(mockAuthApi.updateProfile).toHaveBeenCalledWith({ name: "Jane Example", image: "/api/assets/asset-1/content", }); await act(async () => { root.unmount(); }); }); it("uploads an avatar for a user id that comes from an identity provider", async () => { const userId = "oidc:example|jane.example@example.com"; mockAuthApi.getSession.mockResolvedValue({ session: { id: "session-1", userId }, user: { id: userId, name: "Jane Example", email: "jane@example.com", image: "https://example.com/jane.png", }, }); const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); await act(async () => { root.render( , ); }); await flushReact(); await flushReact(); const avatarInput = container.querySelector('input[type="file"]') as HTMLInputElement | null; expect(avatarInput).not.toBeNull(); const file = new File(["avatar"], "avatar.png", { type: "image/png" }); Object.defineProperty(avatarInput, "files", { configurable: true, value: [file], }); await act(async () => { avatarInput?.dispatchEvent(new Event("change", { bubbles: true })); }); await flushReact(); await flushReact(); const namespace = `profiles/${userId}`; expect(mockAssetsApi.uploadImage).toHaveBeenCalledWith("company-1", file, namespace); // The namespace goes to the API without a change, so the avatar arrives // under the identity of the user. expect(sanitizeAssetNamespace(namespace)).toBe(namespace); expect(mockAuthApi.updateProfile).toHaveBeenCalledWith({ name: "Jane Example", image: "/api/assets/asset-1/content", }); await act(async () => { root.unmount(); }); }); async function renderPage(queryClient: QueryClient) { const root = createRoot(container); await act(async () => { root.render( , ); }); await flushReact(); await flushReact(); return root; } it("keeps the loaded profile on screen when a session refetch fails transiently", async () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const root = await renderPage(queryClient); expect(container.textContent).toContain("Jane Example"); mockAuthApi.getSession.mockRejectedValue( new ApiError("Paperclip is restarting.", 503, { error: "tenant_app_unavailable" }), ); await act(async () => { await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session }); }); await flushReact(); expect(mockAuthApi.getSession).toHaveBeenCalledTimes(2); expect(container.textContent).toContain("Jane Example"); expect(container.querySelector('[data-query-view="error"]')).toBeNull(); expect(container.textContent).not.toContain("Paperclip is restarting."); expect(container.textContent).not.toContain("tenant_app_unavailable"); await act(async () => root.unmount()); }); it("shows readable copy and a Retry button when the session fails to load", async () => { mockAuthApi.getSession.mockRejectedValueOnce(new ApiError("Boom", 500, { error: "Boom" })); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const root = await renderPage(queryClient); expect(container.textContent).toContain("Couldn't load your profile"); expect(container.querySelector('[data-query-view="error"]')).not.toBeNull(); expect(container.textContent).not.toContain("Jane Example"); const retryButton = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent === "Retry", ); expect(retryButton).toBeTruthy(); await act(async () => { retryButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await flushReact(); expect(container.textContent).toContain("Jane Example"); expect(container.querySelector('[data-query-view="error"]')).toBeNull(); await act(async () => root.unmount()); }); it.each(["user-1", "user-2"])("waits for session verification before saving a restored profile (%s)", async (verifiedUser) => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity }, mutations: { retry: false } } }); queryClient.setQueryData(queryKeys.auth.session, { session: { id: "session-1", userId: "user-1" }, user: { id: "user-1", name: "Jane Example", email: "jane@example.com", image: null }, }); const cache = startPersistedQueryCache(queryClient, { storage: null, buildId: "test", eventTarget: null }); markCachedSession(queryClient); let complete!: (value: unknown) => void; mockAuthApi.getSession.mockReturnValue(new Promise((resolve) => { complete = resolve; })); const root = await renderPage(queryClient); try { expect(container.textContent).toContain("Jane Example"); await act(async () => { container.querySelector("form")?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); }); await flushReact(); expect(mockAuthApi.getSession).toHaveBeenCalled(); expect(mockAuthApi.updateProfile).not.toHaveBeenCalled(); await act(async () => { complete({ session: { id: "session-2", userId: verifiedUser }, user: { id: verifiedUser, name: "Verified User", email: "verified@example.com", image: null }, }); }); await flushReact(); await flushReact(); if (verifiedUser === "user-1") { expect(mockAuthApi.updateProfile).toHaveBeenCalledTimes(1); expect(mockAuthApi.updateProfile).toHaveBeenCalledWith({ name: "Jane Example", image: null }); } else { expect(mockAuthApi.updateProfile).not.toHaveBeenCalled(); } } finally { await act(async () => root.unmount()); cache.dispose(); queryClient.clear(); } }); });