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