mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 13:47:19 +08:00
763 lines
26 KiB
TypeScript
763 lines
26 KiB
TypeScript
import { render, screen, waitFor } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { MemoryRouter } from "react-router";
|
|
import { UserContextMenu } from "#/components/features/user/user-context-menu";
|
|
import { organizationService } from "#/api/organization-service/organization-service.api";
|
|
import { GetComponentPropTypes } from "#/utils/get-component-prop-types";
|
|
import {
|
|
INITIAL_MOCK_ORGS,
|
|
MOCK_PERSONAL_ORG,
|
|
MOCK_TEAM_ORG_ACME,
|
|
} from "#/mocks/org-handlers";
|
|
import AuthService from "#/api/auth-service/auth-service.api";
|
|
import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav";
|
|
import OptionService from "#/api/option-service/option-service.api";
|
|
import { OrganizationMember } from "#/types/org";
|
|
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
|
import { createMockWebClientConfig } from "#/mocks/settings-handlers";
|
|
|
|
// Mock useBreakpoint hook
|
|
vi.mock("#/hooks/use-breakpoint", () => ({
|
|
useBreakpoint: vi.fn(() => false), // Default to desktop (not mobile)
|
|
}));
|
|
|
|
// Mock feature flags
|
|
const mockEnableProjUserJourney = vi.fn(() => true);
|
|
vi.mock("#/utils/feature-flags", () => ({
|
|
ENABLE_PROJ_USER_JOURNEY: () => mockEnableProjUserJourney(),
|
|
}));
|
|
|
|
// Mock useTracking hook for CTA
|
|
vi.mock("#/hooks/use-tracking", () => ({
|
|
useTracking: () => ({
|
|
trackSaasSelfhostedInquiry: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
// Import the mocked modules
|
|
import * as breakpoint from "#/hooks/use-breakpoint";
|
|
|
|
type UserContextMenuProps = GetComponentPropTypes<typeof UserContextMenu>;
|
|
|
|
function UserContextMenuWithRootOutlet({
|
|
type,
|
|
onClose,
|
|
onOpenInviteModal,
|
|
}: UserContextMenuProps) {
|
|
return (
|
|
<div>
|
|
<div data-testid="portal-root" id="portal-root" />
|
|
<UserContextMenu
|
|
type={type}
|
|
onClose={onClose}
|
|
onOpenInviteModal={onOpenInviteModal}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const renderUserContextMenu = ({
|
|
type,
|
|
onClose,
|
|
onOpenInviteModal,
|
|
}: UserContextMenuProps) =>
|
|
render(
|
|
<UserContextMenuWithRootOutlet
|
|
type={type}
|
|
onClose={onClose}
|
|
onOpenInviteModal={onOpenInviteModal}
|
|
/>,
|
|
{
|
|
wrapper: ({ children }) => (
|
|
<MemoryRouter>
|
|
<QueryClientProvider client={new QueryClient()}>
|
|
{children}
|
|
</QueryClientProvider>
|
|
</MemoryRouter>
|
|
),
|
|
});
|
|
|
|
const { navigateMock } = vi.hoisted(() => ({
|
|
navigateMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("react-router", async (importActual) => ({
|
|
...(await importActual()),
|
|
useNavigate: () => navigateMock,
|
|
useRevalidator: () => ({
|
|
revalidate: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
// Mock useIsAuthed to return authenticated state
|
|
vi.mock("#/hooks/query/use-is-authed", () => ({
|
|
useIsAuthed: () => ({ data: true }),
|
|
}));
|
|
|
|
const createMockUser = (
|
|
overrides: Partial<OrganizationMember> = {},
|
|
): OrganizationMember => ({
|
|
org_id: "org-1",
|
|
user_id: "user-1",
|
|
email: "test@example.com",
|
|
role: "member",
|
|
llm_api_key: "",
|
|
max_iterations: 100,
|
|
llm_model: "gpt-4",
|
|
llm_api_key_for_byor: null,
|
|
llm_base_url: "",
|
|
status: "active",
|
|
...overrides,
|
|
});
|
|
|
|
const seedActiveUser = (user: Partial<OrganizationMember>) => {
|
|
useSelectedOrganizationStore.setState({ organizationId: "org-1" });
|
|
vi.spyOn(organizationService, "getMe").mockResolvedValue(
|
|
createMockUser(user),
|
|
);
|
|
};
|
|
|
|
vi.mock("react-i18next", async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import("react-i18next")>("react-i18next");
|
|
return {
|
|
...actual,
|
|
useTranslation: () => ({
|
|
t: (key: string) => {
|
|
const translations: Record<string, string> = {
|
|
ORG$SELECT_ORGANIZATION_PLACEHOLDER: "Please select an organization",
|
|
ORG$PERSONAL_WORKSPACE: "Personal Workspace",
|
|
};
|
|
return translations[key] || key;
|
|
},
|
|
i18n: {
|
|
changeLanguage: vi.fn(),
|
|
},
|
|
}),
|
|
};
|
|
});
|
|
|
|
describe("UserContextMenu", () => {
|
|
beforeEach(() => {
|
|
// Ensure clean state at the start of each test
|
|
vi.restoreAllMocks();
|
|
useSelectedOrganizationStore.setState({ organizationId: null });
|
|
// Reset feature flag and breakpoint mocks to defaults
|
|
mockEnableProjUserJourney.mockReturnValue(true);
|
|
vi.mocked(breakpoint.useBreakpoint).mockReturnValue(false); // Desktop by default
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
navigateMock.mockClear();
|
|
// Reset Zustand store to ensure clean state between tests
|
|
useSelectedOrganizationStore.setState({ organizationId: null });
|
|
});
|
|
|
|
it("should render the default context items for a user", async () => {
|
|
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
|
createMockWebClientConfig({ app_mode: "saas" }),
|
|
);
|
|
|
|
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
|
|
|
screen.getByTestId("org-selector");
|
|
|
|
// Wait for config to load so logout button appears
|
|
await waitFor(() => {
|
|
expect(screen.getByText("ACCOUNT_SETTINGS$LOGOUT")).toBeInTheDocument();
|
|
});
|
|
|
|
expect(
|
|
screen.queryByText("ORG$INVITE_ORG_MEMBERS"),
|
|
).not.toBeInTheDocument();
|
|
expect(
|
|
screen.queryByText("ORG$ORGANIZATION_MEMBERS"),
|
|
).not.toBeInTheDocument();
|
|
expect(
|
|
screen.queryByText("COMMON$ORGANIZATION"),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("should render navigation items from SAAS_NAV_ITEMS (except organization-members/org)", async () => {
|
|
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
|
createMockWebClientConfig({
|
|
app_mode: "saas",
|
|
feature_flags: {
|
|
enable_billing: true,
|
|
hide_llm_settings: false,
|
|
enable_jira: false,
|
|
enable_jira_dc: false,
|
|
enable_linear: false,
|
|
hide_users_page: false,
|
|
hide_billing_page: false,
|
|
hide_integrations_page: false,
|
|
},
|
|
}),
|
|
);
|
|
|
|
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
|
|
|
// Wait for config to load and verify that navigation items are rendered (except organization-members/org which are filtered out)
|
|
const expectedItems = SAAS_NAV_ITEMS.filter(
|
|
(item) =>
|
|
item.to !== "/settings/org-members" &&
|
|
item.to !== "/settings/org" &&
|
|
item.to !== "/settings/billing",
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expectedItems.forEach((item) => {
|
|
expect(screen.getByText(item.text)).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
it("should render navigation items from SAAS_NAV_ITEMS when user role is admin (except organization-members/org)", async () => {
|
|
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
|
createMockWebClientConfig({
|
|
app_mode: "saas",
|
|
feature_flags: {
|
|
enable_billing: true,
|
|
hide_llm_settings: false,
|
|
enable_jira: false,
|
|
enable_jira_dc: false,
|
|
enable_linear: false,
|
|
hide_users_page: false,
|
|
hide_billing_page: false,
|
|
hide_integrations_page: false,
|
|
},
|
|
}),
|
|
);
|
|
|
|
seedActiveUser({ role: "admin" });
|
|
|
|
renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
|
|
|
// Wait for config to load and verify that navigation items are rendered (except organization-members/org which are filtered out)
|
|
const expectedItems = SAAS_NAV_ITEMS.filter(
|
|
(item) =>
|
|
item.to !== "/settings/org-members" && item.to !== "/settings/org",
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expectedItems.forEach((item) => {
|
|
expect(screen.getByText(item.text)).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
it("should not display Organization Members menu item for regular users (filtered out)", () => {
|
|
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
|
|
|
// Organization Members is filtered out from nav items for all users
|
|
expect(screen.queryByText("Organization Members")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("should render a documentation link", () => {
|
|
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
|
|
|
const docsLink = screen.getByText("SIDEBAR$DOCS").closest("a");
|
|
expect(docsLink).toHaveAttribute("href", "https://docs.openhands.dev");
|
|
expect(docsLink).toHaveAttribute("target", "_blank");
|
|
});
|
|
|
|
describe("OSS mode", () => {
|
|
beforeEach(() => {
|
|
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
|
createMockWebClientConfig({
|
|
app_mode: "oss",
|
|
feature_flags: {
|
|
enable_billing: false,
|
|
hide_llm_settings: false,
|
|
enable_jira: false,
|
|
enable_jira_dc: false,
|
|
enable_linear: false,
|
|
hide_users_page: false,
|
|
hide_billing_page: false,
|
|
hide_integrations_page: false,
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("should render OSS_NAV_ITEMS when in OSS mode", async () => {
|
|
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
|
|
|
// Wait for the config to load and OSS nav items to appear
|
|
await waitFor(() => {
|
|
OSS_NAV_ITEMS.forEach((item) => {
|
|
expect(screen.getByText(item.text)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
// Verify SAAS-only items are NOT rendered (e.g., Billing)
|
|
expect(
|
|
screen.queryByText("SETTINGS$NAV_BILLING"),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("should not display Organization Members menu item in OSS mode", async () => {
|
|
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
|
|
|
// Wait for the config to load
|
|
await waitFor(() => {
|
|
expect(screen.getByText("SETTINGS$NAV_LLM")).toBeInTheDocument();
|
|
});
|
|
|
|
// Verify Organization Members is NOT rendered in OSS mode
|
|
expect(
|
|
screen.queryByText("Organization Members"),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("should not display logout button in OSS mode", async () => {
|
|
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
|
|
|
// Wait for the config to load
|
|
await waitFor(() => {
|
|
expect(screen.getByText("SETTINGS$NAV_LLM")).toBeInTheDocument();
|
|
});
|
|
|
|
// Verify logout button is NOT rendered in OSS mode
|
|
expect(
|
|
screen.queryByText("ACCOUNT_SETTINGS$LOGOUT"),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("HIDE_LLM_SETTINGS feature flag", () => {
|
|
it("should hide LLM settings link when HIDE_LLM_SETTINGS is true", async () => {
|
|
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
|
createMockWebClientConfig({
|
|
app_mode: "saas",
|
|
feature_flags: {
|
|
enable_billing: false,
|
|
hide_llm_settings: true,
|
|
enable_jira: false,
|
|
enable_jira_dc: false,
|
|
enable_linear: false,
|
|
hide_users_page: false,
|
|
hide_billing_page: false,
|
|
hide_integrations_page: false,
|
|
},
|
|
}),
|
|
);
|
|
|
|
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
|
|
|
await waitFor(() => {
|
|
// Other nav items should still be visible
|
|
expect(screen.getByText("SETTINGS$NAV_USER")).toBeInTheDocument();
|
|
// LLM settings (to: "/settings") should NOT be visible
|
|
expect(
|
|
screen.queryByText("COMMON$LANGUAGE_MODEL_LLM"),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("should show LLM settings link when HIDE_LLM_SETTINGS is false", async () => {
|
|
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
|
createMockWebClientConfig({
|
|
app_mode: "saas",
|
|
feature_flags: {
|
|
enable_billing: false,
|
|
hide_llm_settings: false,
|
|
enable_jira: false,
|
|
enable_jira_dc: false,
|
|
enable_linear: false,
|
|
hide_users_page: false,
|
|
hide_billing_page: false,
|
|
hide_integrations_page: false,
|
|
},
|
|
}),
|
|
);
|
|
|
|
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByText("COMMON$LANGUAGE_MODEL_LLM"),
|
|
).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
it("should render additional context items when user is an admin", () => {
|
|
renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
|
|
|
screen.getByTestId("org-selector");
|
|
screen.getByText("ORG$INVITE_ORG_MEMBERS");
|
|
screen.getByText("ORG$ORGANIZATION_MEMBERS");
|
|
screen.getByText("COMMON$ORGANIZATION");
|
|
});
|
|
|
|
it("should render additional context items when user is an owner", () => {
|
|
renderUserContextMenu({ type: "owner", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
|
|
|
screen.getByTestId("org-selector");
|
|
screen.getByText("ORG$INVITE_ORG_MEMBERS");
|
|
screen.getByText("ORG$ORGANIZATION_MEMBERS");
|
|
screen.getByText("COMMON$ORGANIZATION");
|
|
});
|
|
|
|
it("should call the logout handler when Logout is clicked", async () => {
|
|
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
|
createMockWebClientConfig({ app_mode: "saas" }),
|
|
);
|
|
|
|
const logoutSpy = vi.spyOn(AuthService, "logout");
|
|
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
|
|
|
// Wait for config to load so logout button appears
|
|
const logoutButton = await screen.findByText("ACCOUNT_SETTINGS$LOGOUT");
|
|
await userEvent.click(logoutButton);
|
|
|
|
expect(logoutSpy).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("should have correct navigation links for nav items", async () => {
|
|
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
|
createMockWebClientConfig({
|
|
app_mode: "saas",
|
|
feature_flags: {
|
|
enable_billing: true, // Enable billing so billing link is shown
|
|
hide_llm_settings: false,
|
|
enable_jira: false,
|
|
enable_jira_dc: false,
|
|
enable_linear: false,
|
|
hide_users_page: false,
|
|
hide_billing_page: false,
|
|
hide_integrations_page: false,
|
|
},
|
|
}),
|
|
);
|
|
|
|
seedActiveUser({ role: "admin" });
|
|
|
|
renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
|
|
|
// Wait for config to load and test a few representative nav items have the correct href
|
|
await waitFor(() => {
|
|
const userLink = screen.getByText("SETTINGS$NAV_USER").closest("a");
|
|
expect(userLink).toHaveAttribute("href", "/settings/user");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
const billingLink = screen.getByText("SETTINGS$NAV_BILLING").closest("a");
|
|
expect(billingLink).toHaveAttribute("href", "/settings/billing");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
const integrationsLink = screen
|
|
.getByText("SETTINGS$NAV_INTEGRATIONS")
|
|
.closest("a");
|
|
expect(integrationsLink).toHaveAttribute(
|
|
"href",
|
|
"/settings/integrations",
|
|
);
|
|
});
|
|
});
|
|
|
|
it("should navigate to /settings/org-members when Manage Organization Members is clicked", async () => {
|
|
// Mock a team org so org management buttons are visible (not personal org)
|
|
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
|
items: [MOCK_TEAM_ORG_ACME],
|
|
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
|
});
|
|
|
|
renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
|
|
|
// Wait for orgs to load so org management buttons are visible
|
|
const manageOrganizationMembersButton = await screen.findByText(
|
|
"ORG$ORGANIZATION_MEMBERS",
|
|
);
|
|
await userEvent.click(manageOrganizationMembersButton);
|
|
|
|
expect(navigateMock).toHaveBeenCalledExactlyOnceWith(
|
|
"/settings/org-members",
|
|
);
|
|
});
|
|
|
|
it("should navigate to /settings/org when Manage Account is clicked", async () => {
|
|
// Mock a team org so org management buttons are visible (not personal org)
|
|
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
|
items: [MOCK_TEAM_ORG_ACME],
|
|
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
|
});
|
|
|
|
renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
|
|
|
// Wait for orgs to load so org management buttons are visible
|
|
const manageAccountButton = await screen.findByText(
|
|
"COMMON$ORGANIZATION",
|
|
);
|
|
await userEvent.click(manageAccountButton);
|
|
|
|
expect(navigateMock).toHaveBeenCalledExactlyOnceWith("/settings/org");
|
|
});
|
|
|
|
it("should call the onClose handler when clicking outside the context menu", async () => {
|
|
const onCloseMock = vi.fn();
|
|
renderUserContextMenu({ type: "member", onClose: onCloseMock, onOpenInviteModal: vi.fn });
|
|
|
|
const contextMenu = screen.getByTestId("user-context-menu");
|
|
await userEvent.click(contextMenu);
|
|
|
|
expect(onCloseMock).not.toHaveBeenCalled();
|
|
|
|
// Simulate clicking outside the context menu
|
|
await userEvent.click(document.body);
|
|
|
|
expect(onCloseMock).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should call the onClose handler after each action", async () => {
|
|
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
|
createMockWebClientConfig({ app_mode: "saas" }),
|
|
);
|
|
|
|
// Mock a team org so org management buttons are visible
|
|
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
|
items: [MOCK_TEAM_ORG_ACME],
|
|
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
|
});
|
|
|
|
const onCloseMock = vi.fn();
|
|
renderUserContextMenu({ type: "owner", onClose: onCloseMock, onOpenInviteModal: vi.fn });
|
|
|
|
// Wait for config to load so logout button appears
|
|
const logoutButton = await screen.findByText("ACCOUNT_SETTINGS$LOGOUT");
|
|
await userEvent.click(logoutButton);
|
|
expect(onCloseMock).toHaveBeenCalledTimes(1);
|
|
|
|
// Wait for orgs to load so org management buttons are visible
|
|
const manageOrganizationMembersButton = await screen.findByText(
|
|
"ORG$ORGANIZATION_MEMBERS",
|
|
);
|
|
await userEvent.click(manageOrganizationMembersButton);
|
|
expect(onCloseMock).toHaveBeenCalledTimes(2);
|
|
|
|
const manageAccountButton = screen.getByText("COMMON$ORGANIZATION");
|
|
await userEvent.click(manageAccountButton);
|
|
expect(onCloseMock).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
describe("Personal org vs team org visibility", () => {
|
|
it("should not show Organization and Organization Members settings items when personal org is selected", async () => {
|
|
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
|
items: [MOCK_PERSONAL_ORG],
|
|
currentOrgId: MOCK_PERSONAL_ORG.id,
|
|
});
|
|
vi.spyOn(organizationService, "getMe").mockResolvedValue({
|
|
org_id: "1",
|
|
user_id: "99",
|
|
email: "me@test.com",
|
|
role: "admin",
|
|
llm_api_key: "**********",
|
|
max_iterations: 20,
|
|
llm_model: "gpt-4",
|
|
llm_api_key_for_byor: null,
|
|
llm_base_url: "https://api.openai.com",
|
|
status: "active",
|
|
});
|
|
|
|
// Pre-select the personal org in the Zustand store
|
|
useSelectedOrganizationStore.setState({ organizationId: "1" });
|
|
|
|
renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
|
|
|
// Wait for org selector to load and org management buttons to disappear
|
|
// (they disappear when personal org is selected)
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.queryByText("ORG$ORGANIZATION_MEMBERS"),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
expect(
|
|
screen.queryByText("COMMON$ORGANIZATION"),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("should not show Billing settings item when team org is selected", async () => {
|
|
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
|
items: [MOCK_TEAM_ORG_ACME],
|
|
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
|
});
|
|
vi.spyOn(organizationService, "getMe").mockResolvedValue({
|
|
org_id: "1",
|
|
user_id: "99",
|
|
email: "me@test.com",
|
|
role: "admin",
|
|
llm_api_key: "**********",
|
|
max_iterations: 20,
|
|
llm_model: "gpt-4",
|
|
llm_api_key_for_byor: null,
|
|
llm_base_url: "https://api.openai.com",
|
|
status: "active",
|
|
});
|
|
|
|
renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
|
|
|
// Wait for org selector to load and billing to disappear
|
|
// (billing disappears when team org is selected)
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.queryByText("SETTINGS$NAV_BILLING"),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
it("should call onOpenInviteModal and onClose when Invite Organization Member is clicked", async () => {
|
|
// Mock a team org so org management buttons are visible (not personal org)
|
|
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
|
items: [MOCK_TEAM_ORG_ACME],
|
|
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
|
});
|
|
|
|
const onCloseMock = vi.fn();
|
|
const onOpenInviteModalMock = vi.fn();
|
|
renderUserContextMenu({
|
|
type: "admin",
|
|
onClose: onCloseMock,
|
|
onOpenInviteModal: onOpenInviteModalMock,
|
|
});
|
|
|
|
// Wait for orgs to load so org management buttons are visible
|
|
const inviteButton = await screen.findByText("ORG$INVITE_ORG_MEMBERS");
|
|
await userEvent.click(inviteButton);
|
|
|
|
expect(onOpenInviteModalMock).toHaveBeenCalledOnce();
|
|
expect(onCloseMock).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
test("the user can change orgs", async () => {
|
|
// Mock SaaS mode and organizations for this test
|
|
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
|
createMockWebClientConfig({
|
|
app_mode: "saas",
|
|
feature_flags: {
|
|
enable_billing: true,
|
|
hide_llm_settings: false,
|
|
enable_jira: false,
|
|
enable_jira_dc: false,
|
|
enable_linear: false,
|
|
hide_users_page: false,
|
|
hide_billing_page: false,
|
|
hide_integrations_page: false,
|
|
},
|
|
}),
|
|
);
|
|
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
|
items: INITIAL_MOCK_ORGS,
|
|
currentOrgId: INITIAL_MOCK_ORGS[0].id,
|
|
});
|
|
|
|
const user = userEvent.setup();
|
|
const onCloseMock = vi.fn();
|
|
renderUserContextMenu({ type: "member", onClose: onCloseMock, onOpenInviteModal: vi.fn });
|
|
|
|
// Wait for org selector to appear (it may take a moment for config to load)
|
|
const orgSelector = await screen.findByTestId("org-selector");
|
|
expect(orgSelector).toBeInTheDocument();
|
|
|
|
// Wait for organizations to load (indicated by org name appearing in the dropdown)
|
|
// INITIAL_MOCK_ORGS[0] is a personal org, so it displays "Personal Workspace"
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("combobox")).toHaveValue("Personal Workspace");
|
|
});
|
|
|
|
// Open the dropdown by clicking the trigger
|
|
const trigger = screen.getByTestId("dropdown-trigger");
|
|
await user.click(trigger);
|
|
|
|
// Select a different organization
|
|
const orgOption = screen.getByRole("option", {
|
|
name: INITIAL_MOCK_ORGS[1].name,
|
|
});
|
|
await user.click(orgOption);
|
|
|
|
expect(onCloseMock).not.toHaveBeenCalled();
|
|
|
|
// Verify that the dropdown shows the selected organization
|
|
expect(screen.getByRole("combobox")).toHaveValue(INITIAL_MOCK_ORGS[1].name);
|
|
});
|
|
|
|
describe("Context Menu CTA", () => {
|
|
it("should render the CTA component in SaaS mode on desktop with feature flag enabled", async () => {
|
|
// Set SaaS mode
|
|
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
|
createMockWebClientConfig({ app_mode: "saas" }),
|
|
);
|
|
|
|
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
|
|
|
// Wait for config to load
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("context-menu-cta")).toBeInTheDocument();
|
|
});
|
|
expect(screen.getByText("CTA$ENTERPRISE_TITLE")).toBeInTheDocument();
|
|
expect(screen.getByText("CTA$LEARN_MORE")).toBeInTheDocument();
|
|
});
|
|
|
|
it("should not render the CTA component in OSS mode even with feature flag enabled", async () => {
|
|
// Set OSS mode
|
|
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
|
createMockWebClientConfig({ app_mode: "oss" }),
|
|
);
|
|
|
|
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
|
|
|
// Wait for config to load
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
|
|
});
|
|
|
|
expect(screen.queryByTestId("context-menu-cta")).not.toBeInTheDocument();
|
|
expect(screen.queryByText("CTA$ENTERPRISE_TITLE")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("should not render the CTA component on mobile even in SaaS mode with feature flag enabled", async () => {
|
|
// Set SaaS mode
|
|
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
|
createMockWebClientConfig({ app_mode: "saas" }),
|
|
);
|
|
// Set mobile mode
|
|
vi.mocked(breakpoint.useBreakpoint).mockReturnValue(true);
|
|
|
|
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
|
|
|
// Wait for config to load
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
|
|
});
|
|
|
|
expect(screen.queryByTestId("context-menu-cta")).not.toBeInTheDocument();
|
|
expect(screen.queryByText("CTA$ENTERPRISE_TITLE")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("should not render the CTA component when feature flag is disabled in SaaS mode", async () => {
|
|
// Set SaaS mode
|
|
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
|
createMockWebClientConfig({ app_mode: "saas" }),
|
|
);
|
|
// Disable the feature flag
|
|
mockEnableProjUserJourney.mockReturnValue(false);
|
|
|
|
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
|
|
|
// Wait for config to load
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
|
|
});
|
|
|
|
expect(screen.queryByTestId("context-menu-cta")).not.toBeInTheDocument();
|
|
expect(screen.queryByText("CTA$ENTERPRISE_TITLE")).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|