Files
OpenHands/frontend/__tests__/routes/settings.test.tsx
sp.wack cd2d0ee9a5 feat(frontend): Organizational support (#9496)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Hiep Le <69354317+hieptl@users.noreply.github.com>
Co-authored-by: Abhay Mishra <grabhaymishra@gmail.com>
Co-authored-by: Hyun Han <62870362+smosco@users.noreply.github.com>
Co-authored-by: Nhan Nguyen <nhan13574@gmail.com>
Co-authored-by: Bharath A V <avbharath1221@gmail.com>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
Co-authored-by: Chloe <chloe@openhands.com>
Co-authored-by: HeyItsChloe <54480367+HeyItsChloe@users.noreply.github.com>
2026-03-13 23:38:54 +07:00

1077 lines
36 KiB
TypeScript

import { render, screen, waitFor, within } from "@testing-library/react";
import { createRoutesStub } from "react-router";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { QueryClientProvider } from "@tanstack/react-query";
import SettingsScreen, { clientLoader } from "#/routes/settings";
import { getFirstAvailablePath } from "#/utils/settings-utils";
import OptionService from "#/api/option-service/option-service.api";
import { OrganizationMember } from "#/types/org";
import { organizationService } from "#/api/organization-service/organization-service.api";
import { MOCK_PERSONAL_ORG, MOCK_TEAM_ORG_ACME } from "#/mocks/org-handlers";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
import { WebClientFeatureFlags } from "#/api/option-service/option.types";
import { createMockWebClientConfig } from "#/mocks/settings-handlers";
// Module-level mocks using vi.hoisted
const { handleLogoutMock, mockQueryClient } = vi.hoisted(() => ({
handleLogoutMock: vi.fn(),
mockQueryClient: (() => {
const { QueryClient } = require("@tanstack/react-query");
return new QueryClient();
})(),
}));
vi.mock("#/hooks/use-app-logout", () => ({
useAppLogout: vi.fn().mockReturnValue({ handleLogout: handleLogoutMock }),
}));
vi.mock("#/query-client-config", () => ({
queryClient: mockQueryClient,
}));
// Mock the i18next hook
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> = {
SETTINGS$NAV_INTEGRATIONS: "Integrations",
SETTINGS$NAV_APPLICATION: "Application",
SETTINGS$NAV_CREDITS: "Credits",
SETTINGS$NAV_API_KEYS: "API Keys",
SETTINGS$NAV_LLM: "LLM",
SETTINGS$NAV_SECRETS: "Secrets",
SETTINGS$NAV_MCP: "MCP",
SETTINGS$NAV_USER: "User",
SETTINGS$NAV_BILLING: "Billing",
SETTINGS$TITLE: "Settings",
COMMON$LANGUAGE_MODEL_LLM: "LLM",
};
return translations[key] || key;
},
i18n: {
changeLanguage: vi.fn(),
},
}),
};
});
describe("Settings Screen", () => {
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),
);
};
const RouterStub = createRoutesStub([
{
Component: SettingsScreen,
// @ts-expect-error - custom loader
loader: clientLoader,
path: "/settings",
children: [
{
Component: () => <div data-testid="llm-settings-screen" />,
path: "/settings",
},
{
Component: () => <div data-testid="user-settings-screen" />,
path: "/settings/user",
},
{
Component: () => <div data-testid="git-settings-screen" />,
path: "/settings/integrations",
},
{
Component: () => <div data-testid="application-settings-screen" />,
path: "/settings/app",
},
{
Component: () => <div data-testid="billing-settings-screen" />,
path: "/settings/billing",
},
{
Component: () => <div data-testid="api-keys-settings-screen" />,
path: "/settings/api-keys",
},
{
Component: () => <div data-testid="org-members-settings-screen" />,
path: "/settings/org-members",
handle: { hideTitle: true },
},
{
Component: () => <div data-testid="organization-settings-screen" />,
path: "/settings/org",
},
],
},
]);
const renderSettingsScreen = (path = "/settings") =>
render(<RouterStub initialEntries={[path]} />, {
wrapper: ({ children }) => (
<QueryClientProvider client={mockQueryClient}>
{children}
</QueryClientProvider>
),
});
it("should render the navbar", async () => {
const sectionsToInclude = ["llm", "integrations", "application", "secrets"];
const sectionsToExclude = ["api keys", "credits", "billing"];
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - only return app mode
getConfigSpy.mockResolvedValue({
app_mode: "oss",
});
// Clear any existing query data
mockQueryClient.clear();
renderSettingsScreen();
const navbar = await screen.findByTestId("settings-navbar");
sectionsToInclude.forEach((section) => {
const sectionElement = within(navbar).getByText(section, {
exact: false, // case insensitive
});
expect(sectionElement).toBeInTheDocument();
});
sectionsToExclude.forEach((section) => {
const sectionElement = within(navbar).queryByText(section, {
exact: false, // case insensitive
});
expect(sectionElement).not.toBeInTheDocument();
});
getConfigSpy.mockRestore();
});
it("should render the saas navbar", async () => {
const saasConfig = {
app_mode: "saas",
feature_flags: {
enable_billing: true,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
},
};
// Clear any existing query data and set the config
mockQueryClient.clear();
mockQueryClient.setQueryData(["web-client-config"], saasConfig);
seedActiveUser({ role: "admin" });
const sectionsToInclude = [
"llm", // LLM settings are now always shown in SaaS mode
"user",
"integrations",
"application",
"billing", // The nav item shows "Billing" text and routes to /billing
"secrets",
"api keys",
];
const sectionsToExclude: string[] = []; // No sections are excluded in SaaS mode now
renderSettingsScreen();
const navbar = await screen.findByTestId("settings-navbar");
await waitFor(() => {
expect(within(navbar).getByText("Billing")).toBeInTheDocument();
});
sectionsToInclude.forEach((section) => {
const sectionElement = within(navbar).getByText(section, {
exact: false, // case insensitive
});
expect(sectionElement).toBeInTheDocument();
});
sectionsToExclude.forEach((section) => {
const sectionElement = within(navbar).queryByText(section, {
exact: false, // case insensitive
});
expect(sectionElement).not.toBeInTheDocument();
});
});
it("should not be able to access saas-only routes in oss mode", async () => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
// @ts-expect-error - only return app mode
getConfigSpy.mockResolvedValue({
app_mode: "oss",
});
// Clear any existing query data
mockQueryClient.clear();
// In OSS mode, accessing restricted routes should redirect to /settings
// Since createRoutesStub doesn't handle clientLoader redirects properly,
// we test that the correct navbar is shown (OSS navbar) and that
// the restricted route components are not rendered when accessing /settings
renderSettingsScreen("/settings");
// Verify we're in OSS mode by checking the navbar
const navbar = await screen.findByTestId("settings-navbar");
expect(within(navbar).getByText("LLM")).toBeInTheDocument();
expect(
within(navbar).queryByText("credits", { exact: false }),
).not.toBeInTheDocument();
// Verify the LLM settings screen is shown
expect(screen.getByTestId("llm-settings-screen")).toBeInTheDocument();
expect(
screen.queryByTestId("billing-settings-screen"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("api-keys-settings-screen"),
).not.toBeInTheDocument();
getConfigSpy.mockRestore();
});
it.todo("should not be able to access oss-only routes in saas mode");
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",
});
renderSettingsScreen();
const navbar = await screen.findByTestId("settings-navbar");
// Organization and Organization Members should NOT be visible for personal org
expect(
within(navbar).queryByText("Organization Members"),
).not.toBeInTheDocument();
expect(
within(navbar).queryByText("Organization"),
).not.toBeInTheDocument();
});
it("should not show Billing settings item when team org is selected", async () => {
// Set up SaaS mode (which has Billing in nav items)
mockQueryClient.clear();
mockQueryClient.setQueryData(["web-client-config"], { app_mode: "saas" });
// Pre-select the team org in the query client and Zustand store
mockQueryClient.setQueryData(["organizations"], {
items: [MOCK_TEAM_ORG_ACME],
currentOrgId: MOCK_TEAM_ORG_ACME.id,
});
useSelectedOrganizationStore.setState({ organizationId: "2" });
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
items: [MOCK_TEAM_ORG_ACME],
currentOrgId: MOCK_TEAM_ORG_ACME.id,
});
vi.spyOn(organizationService, "getMe").mockResolvedValue({
org_id: "2",
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",
});
renderSettingsScreen();
const navbar = await screen.findByTestId("settings-navbar");
// Wait for orgs to load, then verify Billing is hidden for team orgs
await waitFor(() => {
expect(
within(navbar).queryByText("Billing", { exact: false }),
).not.toBeInTheDocument();
});
});
it("should not allow direct URL access to /settings/org when personal org is selected", async () => {
// Set up orgs in query client so clientLoader can access them
mockQueryClient.setQueryData(["organizations"], {
items: [MOCK_PERSONAL_ORG],
currentOrgId: MOCK_PERSONAL_ORG.id,
});
// Use Zustand store instead of query client for selected org ID
// This is the correct pattern - the query client key ["selected_organization"] is never set in production
useSelectedOrganizationStore.setState({ organizationId: "1" });
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",
});
renderSettingsScreen("/settings/org");
// Should redirect away from org settings for personal org
await waitFor(() => {
expect(
screen.queryByTestId("organization-settings-screen"),
).not.toBeInTheDocument();
});
});
it("should not allow direct URL access to /settings/org-members when personal org is selected", async () => {
// Set up config and organizations in query client so clientLoader can access them
mockQueryClient.setQueryData(["web-client-config"], { app_mode: "saas" });
mockQueryClient.setQueryData(["organizations"], {
items: [MOCK_PERSONAL_ORG],
currentOrgId: MOCK_PERSONAL_ORG.id,
});
// Use Zustand store for selected org ID
useSelectedOrganizationStore.setState({ organizationId: "1" });
// Mock getMe so getActiveOrganizationUser returns admin
vi.spyOn(organizationService, "getMe").mockResolvedValue(
createMockUser({ role: "admin", org_id: "1" }),
);
// Act: Call clientLoader directly with the REAL route path (as defined in routes.ts)
const request = new Request("http://localhost/settings/org-members");
// @ts-expect-error - test only needs request and params, not full loader args
const result = await clientLoader({ request, params: {} });
// Assert: Should redirect away from org-members settings for personal org
expect(result).not.toBeNull();
expect(result).toBeInstanceOf(Response);
const response = result as Response;
expect(response.status).toBe(302);
expect(response.headers.get("Location")).toBe("/settings");
});
it("should not allow direct URL access to /settings/billing when team org is selected", async () => {
// Set up orgs in query client so clientLoader can access them
mockQueryClient.setQueryData(["organizations"], {
items: [MOCK_TEAM_ORG_ACME],
currentOrgId: MOCK_TEAM_ORG_ACME.id,
});
// Use Zustand store instead of query client for selected org ID
useSelectedOrganizationStore.setState({ organizationId: "2" });
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",
});
renderSettingsScreen("/settings/billing");
// Should redirect away from billing settings for team org
await waitFor(() => {
expect(
screen.queryByTestId("billing-settings-screen"),
).not.toBeInTheDocument();
});
});
});
describe("enable_billing feature flag", () => {
it("should show billing navigation item when enable_billing is true", async () => {
// Arrange
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue(
createMockWebClientConfig({
app_mode: "saas",
feature_flags: {
enable_billing: true, // When enable_billing is true, billing nav 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,
},
}),
);
mockQueryClient.clear();
// Set up personal org (billing is only shown for personal orgs, not team orgs)
mockQueryClient.setQueryData(["organizations"], {
items: [MOCK_PERSONAL_ORG],
currentOrgId: MOCK_PERSONAL_ORG.id,
});
useSelectedOrganizationStore.setState({ organizationId: "1" });
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",
});
// Act
renderSettingsScreen();
// Assert
const navbar = await screen.findByTestId("settings-navbar");
await waitFor(() => {
expect(within(navbar).getByText("Billing")).toBeInTheDocument();
});
getConfigSpy.mockRestore();
});
it("should hide billing navigation item when enable_billing is false", async () => {
// Arrange
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue(
createMockWebClientConfig({
app_mode: "saas",
feature_flags: {
enable_billing: false, // When enable_billing is false, billing nav is hidden
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,
},
}),
);
mockQueryClient.clear();
// Act
renderSettingsScreen();
// Assert
const navbar = await screen.findByTestId("settings-navbar");
expect(within(navbar).queryByText("Billing")).not.toBeInTheDocument();
getConfigSpy.mockRestore();
});
});
describe("clientLoader reads org ID from Zustand store", () => {
beforeEach(() => {
mockQueryClient.clear();
useSelectedOrganizationStore.setState({ organizationId: null });
});
afterEach(() => {
vi.restoreAllMocks();
});
it("should redirect away from /settings/org when personal org is selected in Zustand store", async () => {
// Arrange: Set up config and organizations in query client
mockQueryClient.setQueryData(["web-client-config"], { app_mode: "saas" });
mockQueryClient.setQueryData(["organizations"], {
items: [MOCK_PERSONAL_ORG],
currentOrgId: MOCK_PERSONAL_ORG.id,
});
// Set org ID ONLY in Zustand store (not in query client)
// This tests that clientLoader reads from the correct source
useSelectedOrganizationStore.setState({ organizationId: "1" });
// Mock getMe so getActiveOrganizationUser returns admin
vi.spyOn(organizationService, "getMe").mockResolvedValue(
createMockUser({ role: "admin", org_id: "1" }),
);
// Act: Call clientLoader directly
const request = new Request("http://localhost/settings/org");
// @ts-expect-error - test only needs request and params, not full loader args
const result = await clientLoader({ request, params: {} });
// Assert: Should redirect away from org settings for personal org
expect(result).not.toBeNull();
// In React Router, redirect returns a Response object
expect(result).toBeInstanceOf(Response);
const response = result as Response;
expect(response.status).toBe(302);
expect(response.headers.get("Location")).toBe("/settings");
});
it("should redirect away from /settings/billing when team org is selected in Zustand store", async () => {
// Arrange: Set up config and organizations in query client
mockQueryClient.setQueryData(["web-client-config"], { app_mode: "saas" });
mockQueryClient.setQueryData(["organizations"], {
items: [MOCK_TEAM_ORG_ACME],
currentOrgId: MOCK_TEAM_ORG_ACME.id,
});
// Set org ID ONLY in Zustand store (not in query client)
useSelectedOrganizationStore.setState({ organizationId: "2" });
// Mock getMe so getActiveOrganizationUser returns admin
vi.spyOn(organizationService, "getMe").mockResolvedValue(
createMockUser({ role: "admin", org_id: "2" }),
);
// Act: Call clientLoader directly
const request = new Request("http://localhost/settings/billing");
// @ts-expect-error - test only needs request and params, not full loader args
const result = await clientLoader({ request, params: {} });
// Assert: Should redirect away from billing settings for team org
expect(result).not.toBeNull();
expect(result).toBeInstanceOf(Response);
const response = result as Response;
expect(response.status).toBe(302);
expect(response.headers.get("Location")).toBe("/settings/user");
});
});
describe("hide page feature flags", () => {
beforeEach(() => {
// Set up as personal org admin so billing is accessible
mockQueryClient.setQueryData(["organizations"], {
items: [MOCK_PERSONAL_ORG],
currentOrgId: MOCK_PERSONAL_ORG.id,
});
useSelectedOrganizationStore.setState({ organizationId: "1" });
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",
});
});
it("should hide users page in navbar when hide_users_page is true", async () => {
const saasConfig = {
app_mode: "saas",
feature_flags: {
enable_billing: true, // Enable billing so it's not hidden by isBillingHidden
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
hide_users_page: true,
hide_billing_page: false,
hide_integrations_page: false,
},
};
mockQueryClient.clear();
mockQueryClient.setQueryData(["web-client-config"], saasConfig);
// Set up personal org so billing is visible
mockQueryClient.setQueryData(["organizations"], {
items: [MOCK_PERSONAL_ORG],
currentOrgId: MOCK_PERSONAL_ORG.id,
});
useSelectedOrganizationStore.setState({ organizationId: "1" });
// Pre-populate user data in cache so useMe() returns admin role immediately
mockQueryClient.setQueryData(["organizations", "1", "me"], createMockUser({ role: "admin", org_id: "1" }));
renderSettingsScreen();
const navbar = await screen.findByTestId("settings-navbar");
expect(
within(navbar).queryByText("User", { exact: false }),
).not.toBeInTheDocument();
// Other pages should still be visible
expect(
within(navbar).getByText("Integrations", { exact: false }),
).toBeInTheDocument();
expect(
within(navbar).getByText("Billing", { exact: false }),
).toBeInTheDocument();
});
it("should hide billing page in navbar when hide_billing_page is true", async () => {
const saasConfig = {
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: true,
hide_integrations_page: false,
},
};
mockQueryClient.clear();
mockQueryClient.setQueryData(["web-client-config"], saasConfig);
mockQueryClient.setQueryData(["organizations"], {
items: [MOCK_PERSONAL_ORG],
currentOrgId: MOCK_PERSONAL_ORG.id,
});
useSelectedOrganizationStore.setState({ organizationId: "1" });
renderSettingsScreen();
const navbar = await screen.findByTestId("settings-navbar");
expect(
within(navbar).queryByText("Billing", { exact: false }),
).not.toBeInTheDocument();
// Other pages should still be visible
expect(
within(navbar).getByText("User", { exact: false }),
).toBeInTheDocument();
expect(
within(navbar).getByText("Integrations", { exact: false }),
).toBeInTheDocument();
});
it("should hide integrations page in navbar when hide_integrations_page is true", async () => {
const saasConfig = {
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: true,
},
};
mockQueryClient.clear();
mockQueryClient.setQueryData(["web-client-config"], saasConfig);
mockQueryClient.setQueryData(["organizations"], {
items: [MOCK_PERSONAL_ORG],
currentOrgId: MOCK_PERSONAL_ORG.id,
});
useSelectedOrganizationStore.setState({ organizationId: "1" });
// Pre-populate user data in cache so useMe() returns admin role immediately
mockQueryClient.setQueryData(["organizations", "1", "me"], createMockUser({ role: "admin", org_id: "1" }));
renderSettingsScreen();
const navbar = await screen.findByTestId("settings-navbar");
expect(
within(navbar).queryByText("Integrations", { exact: false }),
).not.toBeInTheDocument();
// Other pages should still be visible
expect(
within(navbar).getByText("User", { exact: false }),
).toBeInTheDocument();
expect(
within(navbar).getByText("Billing", { exact: false }),
).toBeInTheDocument();
});
it("should hide multiple pages when multiple flags are true", async () => {
const saasConfig = {
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: true,
hide_billing_page: true,
hide_integrations_page: true,
},
};
mockQueryClient.clear();
mockQueryClient.setQueryData(["web-client-config"], saasConfig);
renderSettingsScreen();
const navbar = await screen.findByTestId("settings-navbar");
expect(
within(navbar).queryByText("User", { exact: false }),
).not.toBeInTheDocument();
expect(
within(navbar).queryByText("Billing", { exact: false }),
).not.toBeInTheDocument();
expect(
within(navbar).queryByText("Integrations", { exact: false }),
).not.toBeInTheDocument();
// Other pages should still be visible
expect(
within(navbar).getByText("Application", { exact: false }),
).toBeInTheDocument();
expect(
within(navbar).getByText("LLM", { exact: false }),
).toBeInTheDocument();
});
it("should hide integrations page in OSS mode when hide_integrations_page is true", async () => {
const ossConfig = {
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: true,
},
};
mockQueryClient.clear();
mockQueryClient.setQueryData(["web-client-config"], ossConfig);
renderSettingsScreen();
const navbar = await screen.findByTestId("settings-navbar");
expect(
within(navbar).queryByText("Integrations", { exact: false }),
).not.toBeInTheDocument();
// Other OSS pages should still be visible
expect(
within(navbar).getByText("LLM", { exact: false }),
).toBeInTheDocument();
expect(
within(navbar).getByText("Application", { exact: false }),
).toBeInTheDocument();
});
});
});
describe("getFirstAvailablePath", () => {
const baseFeatureFlags: WebClientFeatureFlags = {
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,
};
describe("SaaS mode", () => {
it("should return /settings/user when no pages are hidden", () => {
const result = getFirstAvailablePath(true, baseFeatureFlags);
expect(result).toBe("/settings/user");
});
it("should return /settings/integrations when users page is hidden", () => {
const flags = { ...baseFeatureFlags, hide_users_page: true };
const result = getFirstAvailablePath(true, flags);
expect(result).toBe("/settings/integrations");
});
it("should return /settings/app when users and integrations are hidden", () => {
const flags = {
...baseFeatureFlags,
hide_users_page: true,
hide_integrations_page: true,
};
const result = getFirstAvailablePath(true, flags);
expect(result).toBe("/settings/app");
});
it("should return /settings/app when users, integrations, and LLM settings are hidden", () => {
const flags = {
...baseFeatureFlags,
hide_users_page: true,
hide_integrations_page: true,
hide_llm_settings: true,
};
const result = getFirstAvailablePath(true, flags);
expect(result).toBe("/settings/app");
});
it("should return /settings/app when users, integrations, LLM, and billing are hidden", () => {
const flags = {
...baseFeatureFlags,
hide_users_page: true,
hide_integrations_page: true,
hide_llm_settings: true,
hide_billing_page: true,
};
// /settings/app is never hidden, so it should return that
const result = getFirstAvailablePath(true, flags);
expect(result).toBe("/settings/app");
});
it("should handle undefined feature flags", () => {
const result = getFirstAvailablePath(true, undefined);
expect(result).toBe("/settings/user");
});
});
describe("OSS mode", () => {
it("should return /settings when no pages are hidden", () => {
const result = getFirstAvailablePath(false, baseFeatureFlags);
expect(result).toBe("/settings");
});
it("should return /settings/mcp when LLM settings is hidden", () => {
const flags = { ...baseFeatureFlags, hide_llm_settings: true };
const result = getFirstAvailablePath(false, flags);
expect(result).toBe("/settings/mcp");
});
it("should return /settings/mcp when LLM settings and integrations are hidden", () => {
const flags = {
...baseFeatureFlags,
hide_llm_settings: true,
hide_integrations_page: true,
};
const result = getFirstAvailablePath(false, flags);
expect(result).toBe("/settings/mcp");
});
it("should handle undefined feature flags", () => {
const result = getFirstAvailablePath(false, undefined);
expect(result).toBe("/settings");
});
});
});
describe("clientLoader redirect behavior", () => {
const createMockRequest = (pathname: string) => ({
request: new Request(`http://localhost${pathname}`),
});
beforeEach(() => {
mockQueryClient.clear();
});
it("should redirect from /settings/user to first available page when hide_users_page is true", async () => {
const config = {
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: true,
hide_billing_page: false,
hide_integrations_page: false,
},
};
mockQueryClient.setQueryData(["web-client-config"], config);
const result = await clientLoader(
createMockRequest("/settings/user") as any,
);
expect(result).toBeDefined();
expect(result?.status).toBe(302);
expect(result?.headers.get("Location")).toBe("/settings/integrations");
});
it("should redirect from /settings/billing to first available page when hide_billing_page is true", async () => {
const config = {
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: true,
hide_integrations_page: false,
},
};
mockQueryClient.setQueryData(["web-client-config"], config);
const result = await clientLoader(
createMockRequest("/settings/billing") as any,
);
expect(result).toBeDefined();
expect(result?.status).toBe(302);
expect(result?.headers.get("Location")).toBe("/settings/user");
});
it("should redirect from /settings/integrations to first available page when hide_integrations_page is true", async () => {
const config = {
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: true,
},
};
mockQueryClient.setQueryData(["web-client-config"], config);
const result = await clientLoader(
createMockRequest("/settings/integrations") as any,
);
expect(result).toBeDefined();
expect(result?.status).toBe(302);
expect(result?.headers.get("Location")).toBe("/settings/user");
});
it("should redirect from /settings to /settings/app when LLM, users, and integrations are all hidden", async () => {
const config = {
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: true,
hide_billing_page: false,
hide_integrations_page: true,
},
};
mockQueryClient.setQueryData(["web-client-config"], config);
const result = await clientLoader(createMockRequest("/settings") as any);
expect(result).toBeDefined();
expect(result?.status).toBe(302);
expect(result?.headers.get("Location")).toBe("/settings/app");
});
it("should redirect from /settings to /settings/mcp in OSS mode when LLM settings is hidden", async () => {
const config = {
app_mode: "oss",
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,
},
};
mockQueryClient.setQueryData(["web-client-config"], config);
const result = await clientLoader(createMockRequest("/settings") as any);
expect(result).toBeDefined();
expect(result?.status).toBe(302);
expect(result?.headers.get("Location")).toBe("/settings/mcp");
});
it("should not redirect when accessing a non-hidden page", async () => {
const config = {
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: true,
hide_billing_page: true,
hide_integrations_page: true,
},
};
mockQueryClient.setQueryData(["web-client-config"], config);
// /settings/app is never hidden
const result = await clientLoader(
createMockRequest("/settings/app") as any,
);
expect(result).toBeNull();
});
it("should redirect from /settings/integrations in OSS mode when hide_integrations_page is true", async () => {
const config = {
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: true,
},
};
mockQueryClient.setQueryData(["web-client-config"], config);
const result = await clientLoader(
createMockRequest("/settings/integrations") as any,
);
expect(result).toBeDefined();
expect(result?.status).toBe(302);
// In OSS mode, first available is /settings (LLM)
expect(result?.headers.get("Location")).toBe("/settings");
});
});