diff --git a/web/app/components/header/account-about/index.spec.tsx b/web/app/components/header/account-about/index.spec.tsx
new file mode 100644
index 0000000000..2e2ee1cf4a
--- /dev/null
+++ b/web/app/components/header/account-about/index.spec.tsx
@@ -0,0 +1,131 @@
+import type { LangGeniusVersionResponse } from '@/models/common'
+import type { SystemFeatures } from '@/types/feature'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { useGlobalPublicStore } from '@/context/global-public-context'
+import AccountAbout from './index'
+
+vi.mock('@/context/global-public-context', () => ({
+ useGlobalPublicStore: vi.fn(),
+}))
+
+let mockIsCEEdition = false
+vi.mock('@/config', () => ({
+ get IS_CE_EDITION() { return mockIsCEEdition },
+}))
+
+type GlobalPublicStore = {
+ systemFeatures: SystemFeatures
+ setSystemFeatures: (systemFeatures: SystemFeatures) => void
+}
+
+describe('AccountAbout', () => {
+ const mockVersionInfo: LangGeniusVersionResponse = {
+ current_version: '0.6.0',
+ latest_version: '0.6.0',
+ release_notes: 'https://github.com/langgenius/dify/releases/tag/0.6.0',
+ version: '0.6.0',
+ release_date: '2024-01-01',
+ can_auto_update: false,
+ current_env: 'production',
+ }
+
+ const mockOnCancel = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockIsCEEdition = false
+ vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({
+ systemFeatures: { branding: { enabled: false } },
+ } as unknown as GlobalPublicStore))
+ })
+
+ describe('Rendering', () => {
+ it('should render correctly with version information', () => {
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/^Version/)).toBeInTheDocument()
+ expect(screen.getAllByText(/0.6.0/).length).toBeGreaterThan(0)
+ })
+
+ it('should render branding logo if enabled', () => {
+ // Arrange
+ vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({
+ systemFeatures: { branding: { enabled: true, workspace_logo: 'custom-logo.png' } },
+ } as unknown as GlobalPublicStore))
+
+ // Act
+ render()
+
+ // Assert
+ const img = screen.getByAltText('logo')
+ expect(img).toBeInTheDocument()
+ expect(img).toHaveAttribute('src', 'custom-logo.png')
+ })
+ })
+
+ describe('Version Logic', () => {
+ it('should show "Latest Available" when current version equals latest', () => {
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/about.latestAvailable/)).toBeInTheDocument()
+ })
+
+ it('should show "Now Available" when current version is behind', () => {
+ // Arrange
+ const behindVersionInfo = { ...mockVersionInfo, latest_version: '0.7.0' }
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/about.nowAvailable/)).toBeInTheDocument()
+ expect(screen.getByText(/about.updateNow/)).toBeInTheDocument()
+ })
+ })
+
+ describe('Community Edition', () => {
+ it('should render correctly in Community Edition', () => {
+ // Arrange
+ mockIsCEEdition = true
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText(/Open Source License/)).toBeInTheDocument()
+ })
+
+ it('should hide update button in Community Edition when behind version', () => {
+ // Arrange
+ mockIsCEEdition = true
+ const behindVersionInfo = { ...mockVersionInfo, latest_version: '0.7.0' }
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.queryByText(/about.updateNow/)).not.toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onCancel when close button is clicked', () => {
+ // Act
+ render()
+ // Modal uses Headless UI Dialog which renders into a portal, so we need to use document
+ const closeButton = document.querySelector('div.absolute.cursor-pointer')
+
+ if (!closeButton)
+ throw new Error('Close button not found')
+
+ fireEvent.click(closeButton)
+
+ // Assert
+ expect(mockOnCancel).toHaveBeenCalled()
+ })
+ })
+})
diff --git a/web/app/components/header/account-dropdown/compliance.spec.tsx b/web/app/components/header/account-dropdown/compliance.spec.tsx
new file mode 100644
index 0000000000..54a0460f82
--- /dev/null
+++ b/web/app/components/header/account-dropdown/compliance.spec.tsx
@@ -0,0 +1,218 @@
+import type { ModalContextState } from '@/context/modal-context'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { Plan } from '@/app/components/billing/type'
+import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
+import { useModalContext } from '@/context/modal-context'
+import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
+import { getDocDownloadUrl } from '@/service/common'
+import { downloadUrl } from '@/utils/download'
+import Toast from '../../base/toast'
+import Compliance from './compliance'
+
+vi.mock('@/context/provider-context', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ useProviderContext: vi.fn(),
+ }
+})
+
+vi.mock('@/context/modal-context', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ useModalContext: vi.fn(),
+ }
+})
+
+vi.mock('@/service/common', () => ({
+ getDocDownloadUrl: vi.fn(),
+}))
+
+vi.mock('@/utils/download', () => ({
+ downloadUrl: vi.fn(),
+}))
+
+describe('Compliance', () => {
+ const mockSetShowPricingModal = vi.fn()
+ const mockSetShowAccountSettingModal = vi.fn()
+ let queryClient: QueryClient
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ })
+ vi.mocked(useProviderContext).mockReturnValue({
+ ...baseProviderContextValue,
+ plan: {
+ ...baseProviderContextValue.plan,
+ type: Plan.sandbox,
+ },
+ })
+ vi.mocked(useModalContext).mockReturnValue({
+ setShowPricingModal: mockSetShowPricingModal,
+ setShowAccountSettingModal: mockSetShowAccountSettingModal,
+ } as unknown as ModalContextState)
+
+ vi.spyOn(Toast, 'notify').mockImplementation(() => ({}))
+ })
+
+ const renderWithQueryClient = (ui: React.ReactElement) => {
+ return render(
+
+ {ui}
+ ,
+ )
+ }
+
+ // Wrapper for tests that need the menu open
+ const openMenuAndRender = () => {
+ renderWithQueryClient()
+ fireEvent.click(screen.getByRole('button'))
+ }
+
+ describe('Rendering', () => {
+ it('should render compliance menu trigger', () => {
+ // Act
+ renderWithQueryClient()
+
+ // Assert
+ expect(screen.getByText('common.userProfile.compliance')).toBeInTheDocument()
+ })
+
+ it('should show SOC2, ISO, GDPR items when opened', () => {
+ // Act
+ openMenuAndRender()
+
+ // Assert
+ expect(screen.getByText('common.compliance.soc2Type1')).toBeInTheDocument()
+ expect(screen.getByText('common.compliance.soc2Type2')).toBeInTheDocument()
+ expect(screen.getByText('common.compliance.iso27001')).toBeInTheDocument()
+ expect(screen.getByText('common.compliance.gdpr')).toBeInTheDocument()
+ })
+ })
+
+ describe('Plan-based Content', () => {
+ it('should show Upgrade badge for sandbox plan on restricted docs', () => {
+ // Act
+ openMenuAndRender()
+
+ // Assert
+ // SOC2 Type I is restricted for sandbox
+ expect(screen.getAllByText('billing.upgradeBtn.encourageShort').length).toBeGreaterThan(0)
+ })
+
+ it('should show Download button for plan that allows it', () => {
+ // Arrange
+ vi.mocked(useProviderContext).mockReturnValue({
+ ...baseProviderContextValue,
+ plan: {
+ ...baseProviderContextValue.plan,
+ type: Plan.team,
+ },
+ })
+
+ // Act
+ openMenuAndRender()
+
+ // Assert
+ expect(screen.getAllByText('common.operation.download').length).toBeGreaterThan(0)
+ })
+ })
+
+ describe('Actions', () => {
+ it('should trigger download mutation successfully', async () => {
+ // Arrange
+ const mockUrl = 'http://example.com/doc.pdf'
+ vi.mocked(getDocDownloadUrl).mockResolvedValue({ url: mockUrl })
+ vi.mocked(useProviderContext).mockReturnValue({
+ ...baseProviderContextValue,
+ plan: {
+ ...baseProviderContextValue.plan,
+ type: Plan.team,
+ },
+ })
+
+ // Act
+ openMenuAndRender()
+ const downloadButtons = screen.getAllByText('common.operation.download')
+ fireEvent.click(downloadButtons[0])
+
+ // Assert
+ await waitFor(() => {
+ expect(getDocDownloadUrl).toHaveBeenCalled()
+ expect(downloadUrl).toHaveBeenCalledWith({ url: mockUrl })
+ expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'success',
+ message: 'common.operation.downloadSuccess',
+ }))
+ })
+ })
+
+ it('should handle download mutation error', async () => {
+ // Arrange
+ vi.mocked(getDocDownloadUrl).mockRejectedValue(new Error('Download failed'))
+ vi.mocked(useProviderContext).mockReturnValue({
+ ...baseProviderContextValue,
+ plan: {
+ ...baseProviderContextValue.plan,
+ type: Plan.team,
+ },
+ })
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { })
+
+ // Act
+ openMenuAndRender()
+ const downloadButtons = screen.getAllByText('common.operation.download')
+ fireEvent.click(downloadButtons[0])
+
+ // Assert
+ await waitFor(() => {
+ expect(getDocDownloadUrl).toHaveBeenCalled()
+ expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'error',
+ message: 'common.operation.downloadFailed',
+ }))
+ })
+ expect(consoleSpy).toHaveBeenCalled()
+ consoleSpy.mockRestore()
+ })
+
+ it('should handle upgrade click on badge for sandbox plan', () => {
+ // Act
+ openMenuAndRender()
+ const upgradeBadges = screen.getAllByText('billing.upgradeBtn.encourageShort')
+ fireEvent.click(upgradeBadges[0])
+
+ // Assert
+ expect(mockSetShowPricingModal).toHaveBeenCalled()
+ })
+
+ it('should handle upgrade click on badge for non-sandbox plan', () => {
+ // Arrange
+ vi.mocked(useProviderContext).mockReturnValue({
+ ...baseProviderContextValue,
+ plan: {
+ ...baseProviderContextValue.plan,
+ type: Plan.professional,
+ },
+ })
+
+ // Act
+ openMenuAndRender()
+ // SOC2 Type II is restricted for professional
+ const upgradeBadges = screen.getAllByText('billing.upgradeBtn.encourageShort')
+ fireEvent.click(upgradeBadges[0])
+
+ // Assert
+ expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
+ payload: ACCOUNT_SETTING_TAB.BILLING,
+ })
+ })
+ })
+})
diff --git a/web/app/components/header/account-dropdown/index.spec.tsx b/web/app/components/header/account-dropdown/index.spec.tsx
new file mode 100644
index 0000000000..af3defccad
--- /dev/null
+++ b/web/app/components/header/account-dropdown/index.spec.tsx
@@ -0,0 +1,340 @@
+import type { AppRouterInstance } from 'next/dist/shared/lib/app-router-context.shared-runtime'
+import type { AppContextValue } from '@/context/app-context'
+import type { ModalContextState } from '@/context/modal-context'
+import type { ProviderContextState } from '@/context/provider-context'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { AppRouterContext } from 'next/dist/shared/lib/app-router-context.shared-runtime'
+import { Plan } from '@/app/components/billing/type'
+import { useAppContext } from '@/context/app-context'
+import { useGlobalPublicStore } from '@/context/global-public-context'
+import { useModalContext } from '@/context/modal-context'
+import { useProviderContext } from '@/context/provider-context'
+import { useLogout } from '@/service/use-common'
+import AppSelector from './index'
+
+vi.mock('../account-setting', () => ({
+ default: () => AccountSetting
,
+}))
+
+vi.mock('../account-about', () => ({
+ default: ({ onCancel }: { onCancel: () => void }) => (
+
+ Version
+
+
+ ),
+}))
+
+vi.mock('@/app/components/header/github-star', () => ({
+ default: () => GithubStar
,
+}))
+
+vi.mock('@/context/app-context', () => ({
+ useAppContext: vi.fn(),
+}))
+
+vi.mock('@/context/global-public-context', () => ({
+ useGlobalPublicStore: vi.fn(),
+}))
+
+vi.mock('@/context/provider-context', () => ({
+ useProviderContext: vi.fn(),
+}))
+
+vi.mock('@/context/modal-context', () => ({
+ useModalContext: vi.fn(),
+}))
+
+vi.mock('@/service/use-common', () => ({
+ useLogout: vi.fn(),
+}))
+
+vi.mock('@/context/i18n', () => ({
+ useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
+}))
+
+// Mock config and env
+const { mockConfig, mockEnv } = vi.hoisted(() => ({
+ mockConfig: {
+ IS_CLOUD_EDITION: false,
+ },
+ mockEnv: {
+ env: {
+ NEXT_PUBLIC_SITE_ABOUT: 'show',
+ },
+ },
+}))
+vi.mock('@/config', () => ({
+ get IS_CLOUD_EDITION() { return mockConfig.IS_CLOUD_EDITION },
+ IS_DEV: false,
+ IS_CE_EDITION: false,
+}))
+vi.mock('@/env', () => mockEnv)
+
+const baseAppContextValue: AppContextValue = {
+ userProfile: {
+ id: '1',
+ name: 'Test User',
+ email: 'test@example.com',
+ avatar: '',
+ avatar_url: 'avatar.png',
+ is_password_set: false,
+ },
+ mutateUserProfile: vi.fn(),
+ currentWorkspace: {
+ id: '1',
+ name: 'Workspace',
+ plan: '',
+ status: '',
+ created_at: 0,
+ role: 'owner',
+ providers: [],
+ trial_credits: 0,
+ trial_credits_used: 0,
+ next_credit_reset_date: 0,
+ },
+ isCurrentWorkspaceManager: true,
+ isCurrentWorkspaceOwner: true,
+ isCurrentWorkspaceEditor: true,
+ isCurrentWorkspaceDatasetOperator: false,
+ mutateCurrentWorkspace: vi.fn(),
+ langGeniusVersionInfo: {
+ current_env: 'testing',
+ current_version: '0.6.0',
+ latest_version: '0.6.0',
+ release_date: '',
+ release_notes: '',
+ version: '0.6.0',
+ can_auto_update: false,
+ },
+ useSelector: vi.fn(),
+ isLoadingCurrentWorkspace: false,
+ isValidatingCurrentWorkspace: false,
+}
+
+describe('AccountDropdown', () => {
+ const mockPush = vi.fn()
+ const mockLogout = vi.fn()
+ const mockSetShowAccountSettingModal = vi.fn()
+
+ const renderWithRouter = (ui: React.ReactElement) => {
+ const mockRouter = {
+ push: mockPush,
+ replace: vi.fn(),
+ prefetch: vi.fn(),
+ back: vi.fn(),
+ forward: vi.fn(),
+ refresh: vi.fn(),
+ } as unknown as AppRouterInstance
+
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+ })
+
+ return render(
+
+
+ {ui}
+
+ ,
+ )
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('localStorage', { removeItem: vi.fn() })
+ mockConfig.IS_CLOUD_EDITION = false
+ mockEnv.env.NEXT_PUBLIC_SITE_ABOUT = 'show'
+
+ vi.mocked(useAppContext).mockReturnValue(baseAppContextValue)
+ vi.mocked(useGlobalPublicStore).mockImplementation((selector?: unknown) => {
+ const fullState = { systemFeatures: { branding: { enabled: false } }, setSystemFeatures: vi.fn() }
+ return typeof selector === 'function' ? (selector as (state: typeof fullState) => unknown)(fullState) : fullState
+ })
+ vi.mocked(useProviderContext).mockReturnValue({
+ isEducationAccount: false,
+ plan: { type: Plan.sandbox },
+ } as unknown as ProviderContextState)
+ vi.mocked(useModalContext).mockReturnValue({
+ setShowAccountSettingModal: mockSetShowAccountSettingModal,
+ } as unknown as ModalContextState)
+ vi.mocked(useLogout).mockReturnValue({
+ mutateAsync: mockLogout,
+ } as unknown as ReturnType)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ describe('Rendering', () => {
+ it('should render user profile correctly', () => {
+ // Act
+ renderWithRouter()
+ fireEvent.click(screen.getByRole('button'))
+
+ // Assert
+ expect(screen.getByText('Test User')).toBeInTheDocument()
+ expect(screen.getByText('test@example.com')).toBeInTheDocument()
+ })
+
+ it('should show EDU badge for education accounts', () => {
+ // Arrange
+ vi.mocked(useProviderContext).mockReturnValue({
+ isEducationAccount: true,
+ plan: { type: Plan.sandbox },
+ } as unknown as ProviderContextState)
+
+ // Act
+ renderWithRouter()
+ fireEvent.click(screen.getByRole('button'))
+
+ // Assert
+ expect(screen.getByText('EDU')).toBeInTheDocument()
+ })
+ })
+
+ describe('Settings and Support', () => {
+ it('should trigger setShowAccountSettingModal when settings is clicked', () => {
+ // Act
+ renderWithRouter()
+ fireEvent.click(screen.getByRole('button'))
+ fireEvent.click(screen.getByText('common.userProfile.settings'))
+
+ // Assert
+ expect(mockSetShowAccountSettingModal).toHaveBeenCalled()
+ })
+
+ it('should show Compliance in Cloud Edition for workspace owner', () => {
+ // Arrange
+ mockConfig.IS_CLOUD_EDITION = true
+ vi.mocked(useAppContext).mockReturnValue({
+ ...baseAppContextValue,
+ userProfile: { ...baseAppContextValue.userProfile, name: 'User' },
+ isCurrentWorkspaceOwner: true,
+ langGeniusVersionInfo: { ...baseAppContextValue.langGeniusVersionInfo, current_version: '0.6.0', latest_version: '0.6.0' },
+ })
+
+ // Act
+ renderWithRouter()
+ fireEvent.click(screen.getByRole('button'))
+
+ // Assert
+ expect(screen.getByText('common.userProfile.compliance')).toBeInTheDocument()
+ })
+ })
+
+ describe('Actions', () => {
+ it('should handle logout correctly', async () => {
+ // Arrange
+ mockLogout.mockResolvedValue({})
+
+ // Act
+ renderWithRouter()
+ fireEvent.click(screen.getByRole('button'))
+ fireEvent.click(screen.getByText('common.userProfile.logout'))
+
+ // Assert
+ await waitFor(() => {
+ expect(mockLogout).toHaveBeenCalled()
+ expect(localStorage.removeItem).toHaveBeenCalledWith('setup_status')
+ expect(mockPush).toHaveBeenCalledWith('/signin')
+ })
+ })
+
+ it('should show About section when about button is clicked and can close it', () => {
+ // Act
+ renderWithRouter()
+ fireEvent.click(screen.getByRole('button'))
+ fireEvent.click(screen.getByText('common.userProfile.about'))
+
+ // Assert
+ expect(screen.getByTestId('account-about')).toBeInTheDocument()
+
+ // Act
+ fireEvent.click(screen.getByText('Close'))
+
+ // Assert
+ expect(screen.queryByTestId('account-about')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Branding and Environment', () => {
+ it('should hide sections when branding is enabled', () => {
+ // Arrange
+ vi.mocked(useGlobalPublicStore).mockImplementation((selector?: unknown) => {
+ const fullState = { systemFeatures: { branding: { enabled: true } }, setSystemFeatures: vi.fn() }
+ return typeof selector === 'function' ? (selector as (state: typeof fullState) => unknown)(fullState) : fullState
+ })
+
+ // Act
+ renderWithRouter()
+ fireEvent.click(screen.getByRole('button'))
+
+ // Assert
+ expect(screen.queryByText('common.userProfile.helpCenter')).not.toBeInTheDocument()
+ expect(screen.queryByText('common.userProfile.roadmap')).not.toBeInTheDocument()
+ })
+
+ it('should hide About section when NEXT_PUBLIC_SITE_ABOUT is hide', () => {
+ // Arrange
+ mockEnv.env.NEXT_PUBLIC_SITE_ABOUT = 'hide'
+
+ // Act
+ renderWithRouter()
+ fireEvent.click(screen.getByRole('button'))
+
+ // Assert
+ expect(screen.queryByText('common.userProfile.about')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Version Indicators', () => {
+ it('should show orange indicator when version is not latest', () => {
+ // Arrange
+ vi.mocked(useAppContext).mockReturnValue({
+ ...baseAppContextValue,
+ userProfile: { ...baseAppContextValue.userProfile, name: 'User' },
+ langGeniusVersionInfo: {
+ ...baseAppContextValue.langGeniusVersionInfo,
+ current_version: '0.6.0',
+ latest_version: '0.7.0',
+ },
+ })
+
+ // Act
+ renderWithRouter()
+ fireEvent.click(screen.getByRole('button'))
+
+ // Assert
+ const indicator = screen.getByTestId('status-indicator')
+ expect(indicator).toHaveClass('bg-components-badge-status-light-warning-bg')
+ })
+
+ it('should show green indicator when version is latest', () => {
+ // Arrange
+ vi.mocked(useAppContext).mockReturnValue({
+ ...baseAppContextValue,
+ userProfile: { ...baseAppContextValue.userProfile, name: 'User' },
+ langGeniusVersionInfo: {
+ ...baseAppContextValue.langGeniusVersionInfo,
+ current_version: '0.7.0',
+ latest_version: '0.7.0',
+ },
+ })
+
+ // Act
+ renderWithRouter()
+ fireEvent.click(screen.getByRole('button'))
+
+ // Assert
+ const indicator = screen.getByTestId('status-indicator')
+ expect(indicator).toHaveClass('bg-components-badge-status-light-success-bg')
+ })
+ })
+})
diff --git a/web/app/components/header/account-dropdown/support.spec.tsx b/web/app/components/header/account-dropdown/support.spec.tsx
new file mode 100644
index 0000000000..b30a290ea5
--- /dev/null
+++ b/web/app/components/header/account-dropdown/support.spec.tsx
@@ -0,0 +1,183 @@
+import type { AppContextValue } from '@/context/app-context'
+import { fireEvent, render, screen } from '@testing-library/react'
+
+import { Plan } from '@/app/components/billing/type'
+import { useAppContext } from '@/context/app-context'
+import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
+import Support from './support'
+
+const { mockZendeskKey } = vi.hoisted(() => ({
+ mockZendeskKey: { value: 'test-key' },
+}))
+
+vi.mock('@/context/app-context', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ useAppContext: vi.fn(),
+ }
+})
+
+vi.mock('@/context/provider-context', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ useProviderContext: vi.fn(),
+ }
+})
+
+vi.mock('@/config', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ IS_CE_EDITION: false,
+ get ZENDESK_WIDGET_KEY() { return mockZendeskKey.value },
+ }
+})
+
+describe('Support', () => {
+ const mockCloseAccountDropdown = vi.fn()
+
+ const baseAppContextValue: AppContextValue = {
+ userProfile: {
+ id: '1',
+ name: 'Test User',
+ email: 'test@example.com',
+ avatar: '',
+ avatar_url: '',
+ is_password_set: false,
+ },
+ mutateUserProfile: vi.fn(),
+ currentWorkspace: {
+ id: '1',
+ name: 'Workspace',
+ plan: '',
+ status: '',
+ created_at: 0,
+ role: 'owner',
+ providers: [],
+ trial_credits: 0,
+ trial_credits_used: 0,
+ next_credit_reset_date: 0,
+ },
+ isCurrentWorkspaceManager: true,
+ isCurrentWorkspaceOwner: true,
+ isCurrentWorkspaceEditor: true,
+ isCurrentWorkspaceDatasetOperator: false,
+ mutateCurrentWorkspace: vi.fn(),
+ langGeniusVersionInfo: {
+ current_env: 'testing',
+ current_version: '0.6.0',
+ latest_version: '0.6.0',
+ release_date: '',
+ release_notes: '',
+ version: '0.6.0',
+ can_auto_update: false,
+ },
+ useSelector: vi.fn(),
+ isLoadingCurrentWorkspace: false,
+ isValidatingCurrentWorkspace: false,
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ window.zE = vi.fn()
+ mockZendeskKey.value = 'test-key'
+ vi.mocked(useAppContext).mockReturnValue(baseAppContextValue)
+ vi.mocked(useProviderContext).mockReturnValue({
+ ...baseProviderContextValue,
+ plan: {
+ ...baseProviderContextValue.plan,
+ type: Plan.professional,
+ },
+ })
+ })
+
+ describe('Rendering', () => {
+ it('should render support menu trigger', () => {
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText('common.userProfile.support')).toBeInTheDocument()
+ })
+
+ it('should show forum and community links when opened', () => {
+ // Act
+ render()
+ fireEvent.click(screen.getByRole('button'))
+
+ // Assert
+ expect(screen.getByText('common.userProfile.forum')).toBeInTheDocument()
+ expect(screen.getByText('common.userProfile.community')).toBeInTheDocument()
+ })
+ })
+
+ describe('Plan-based Channels', () => {
+ it('should show "Contact Us" when ZENDESK_WIDGET_KEY is present', () => {
+ // Act
+ render()
+ fireEvent.click(screen.getByRole('button'))
+
+ // Assert
+ expect(screen.getByText('common.userProfile.contactUs')).toBeInTheDocument()
+ })
+
+ it('should hide dedicated support channels for Sandbox plan', () => {
+ // Arrange
+ vi.mocked(useProviderContext).mockReturnValue({
+ ...baseProviderContextValue,
+ plan: {
+ ...baseProviderContextValue.plan,
+ type: Plan.sandbox,
+ },
+ })
+
+ // Act
+ render()
+ fireEvent.click(screen.getByRole('button'))
+
+ // Assert
+ expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument()
+ expect(screen.queryByText('common.userProfile.emailSupport')).not.toBeInTheDocument()
+ })
+
+ it('should show "Email Support" when ZENDESK_WIDGET_KEY is absent', () => {
+ // Arrange
+ mockZendeskKey.value = ''
+
+ // Act
+ render()
+ fireEvent.click(screen.getByRole('button'))
+
+ // Assert
+ expect(screen.getByText('common.userProfile.emailSupport')).toBeInTheDocument()
+ expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Interactions and Links', () => {
+ it('should call toggleZendeskWindow and closeAccountDropdown when "Contact Us" is clicked', () => {
+ // Act
+ render()
+ fireEvent.click(screen.getByRole('button'))
+ fireEvent.click(screen.getByText('common.userProfile.contactUs'))
+
+ // Assert
+ expect(window.zE).toHaveBeenCalledWith('messenger', 'open')
+ expect(mockCloseAccountDropdown).toHaveBeenCalled()
+ })
+
+ it('should have correct forum and community links', () => {
+ // Act
+ render()
+ fireEvent.click(screen.getByRole('button'))
+
+ // Assert
+ const forumLink = screen.getByText('common.userProfile.forum').closest('a')
+ const communityLink = screen.getByText('common.userProfile.community').closest('a')
+ expect(forumLink).toHaveAttribute('href', 'https://forum.dify.ai/')
+ expect(communityLink).toHaveAttribute('href', 'https://discord.gg/5AEfbxcd9k')
+ })
+ })
+})
diff --git a/web/app/components/header/account-dropdown/workplace-selector/index.spec.tsx b/web/app/components/header/account-dropdown/workplace-selector/index.spec.tsx
new file mode 100644
index 0000000000..fc32b5f8df
--- /dev/null
+++ b/web/app/components/header/account-dropdown/workplace-selector/index.spec.tsx
@@ -0,0 +1,139 @@
+import type { ProviderContextState } from '@/context/provider-context'
+import type { IWorkspace } from '@/models/common'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { ToastContext } from '@/app/components/base/toast'
+import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
+import { useWorkspacesContext } from '@/context/workspace-context'
+import { switchWorkspace } from '@/service/common'
+import WorkplaceSelector from './index'
+
+vi.mock('@/context/workspace-context', () => ({
+ useWorkspacesContext: vi.fn(),
+}))
+
+vi.mock('@/context/provider-context', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ useProviderContext: vi.fn(),
+ }
+})
+
+vi.mock('@/service/common', () => ({
+ switchWorkspace: vi.fn(),
+}))
+
+describe('WorkplaceSelector', () => {
+ const mockWorkspaces: IWorkspace[] = [
+ { id: '1', name: 'Workspace 1', current: true, plan: 'professional', status: 'normal', created_at: Date.now() },
+ { id: '2', name: 'Workspace 2', current: false, plan: 'sandbox', status: 'normal', created_at: Date.now() },
+ ]
+
+ const mockNotify = vi.fn()
+ const mockAssign = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.mocked(useWorkspacesContext).mockReturnValue({
+ workspaces: mockWorkspaces,
+ })
+ vi.mocked(useProviderContext).mockReturnValue({
+ ...baseProviderContextValue,
+ isFetchedPlan: true,
+ isEducationWorkspace: false,
+ } as ProviderContextState)
+ vi.stubGlobal('location', { ...window.location, assign: mockAssign })
+ })
+
+ const renderComponent = () => {
+ return render(
+
+
+ ,
+ )
+ }
+
+ describe('Rendering', () => {
+ it('should render current workspace correctly', () => {
+ // Act
+ renderComponent()
+
+ // Assert
+ expect(screen.getByText('Workspace 1')).toBeInTheDocument()
+ expect(screen.getByText('W')).toBeInTheDocument() // First letter icon
+ })
+
+ it('should open menu and display all workspaces when clicked', () => {
+ // Act
+ renderComponent()
+ fireEvent.click(screen.getByRole('button'))
+
+ // Assert
+ expect(screen.getAllByText('Workspace 1').length).toBeGreaterThan(0)
+ expect(screen.getByText('Workspace 2')).toBeInTheDocument()
+ // The real PlanBadge renders uppercase plan name or "pro"
+ expect(screen.getByText('pro')).toBeInTheDocument()
+ expect(screen.getByText('sandbox')).toBeInTheDocument()
+ })
+ })
+
+ describe('Workspace Switching', () => {
+ it('should switch workspace successfully', async () => {
+ // Arrange
+ vi.mocked(switchWorkspace).mockResolvedValue({
+ result: 'success',
+ new_tenant: mockWorkspaces[1],
+ })
+
+ // Act
+ renderComponent()
+ fireEvent.click(screen.getByRole('button'))
+ const workspace2 = screen.getByText('Workspace 2')
+ fireEvent.click(workspace2)
+
+ // Assert
+ expect(switchWorkspace).toHaveBeenCalledWith({
+ url: '/workspaces/switch',
+ body: { tenant_id: '2' },
+ })
+
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith({
+ type: 'success',
+ message: 'common.actionMsg.modifiedSuccessfully',
+ })
+ expect(mockAssign).toHaveBeenCalled()
+ })
+ })
+
+ it('should not switch to the already current workspace', () => {
+ // Act
+ renderComponent()
+ fireEvent.click(screen.getByRole('button'))
+ const workspacesInMenu = screen.getAllByText('Workspace 1')
+ fireEvent.click(workspacesInMenu[workspacesInMenu.length - 1])
+
+ // Assert
+ expect(switchWorkspace).not.toHaveBeenCalled()
+ })
+
+ it('should handle switching error correctly', async () => {
+ // Arrange
+ vi.mocked(switchWorkspace).mockRejectedValue(new Error('Failed'))
+
+ // Act
+ renderComponent()
+ fireEvent.click(screen.getByRole('button'))
+ const workspace2 = screen.getByText('Workspace 2')
+ fireEvent.click(workspace2)
+
+ // Assert
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'common.provider.saveFailed',
+ })
+ })
+ })
+ })
+})
diff --git a/web/app/components/header/account-setting/Integrations-page/index.spec.tsx b/web/app/components/header/account-setting/Integrations-page/index.spec.tsx
new file mode 100644
index 0000000000..6275e74479
--- /dev/null
+++ b/web/app/components/header/account-setting/Integrations-page/index.spec.tsx
@@ -0,0 +1,126 @@
+import type { AccountIntegrate } from '@/models/common'
+import { render, screen } from '@testing-library/react'
+import { useAccountIntegrates } from '@/service/use-common'
+import IntegrationsPage from './index'
+
+vi.mock('@/service/use-common', () => ({
+ useAccountIntegrates: vi.fn(),
+}))
+
+describe('IntegrationsPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering connected integrations', () => {
+ it('should render connected integrations when list is provided', () => {
+ // Arrange
+ const mockData: AccountIntegrate[] = [
+ { provider: 'google', is_bound: true, link: '', created_at: 1678888888 },
+ { provider: 'github', is_bound: true, link: '', created_at: 1678888888 },
+ ]
+
+ vi.mocked(useAccountIntegrates).mockReturnValue({
+ data: {
+ data: mockData,
+ },
+ isPending: false,
+ isError: false,
+ } as unknown as ReturnType)
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText('common.integrations.connected')).toBeInTheDocument()
+ expect(screen.getByText('common.integrations.google')).toBeInTheDocument()
+ expect(screen.getByText('common.integrations.github')).toBeInTheDocument()
+ // Connect link should not be present when bound
+ expect(screen.queryByText('common.integrations.connect')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Unbound integrations', () => {
+ it('should render connect link for unbound integrations', () => {
+ // Arrange
+ const mockData: AccountIntegrate[] = [
+ { provider: 'google', is_bound: false, link: 'https://google.com', created_at: 1678888888 },
+ ]
+
+ vi.mocked(useAccountIntegrates).mockReturnValue({
+ data: {
+ data: mockData,
+ },
+ isPending: false,
+ isError: false,
+ } as unknown as ReturnType)
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText('common.integrations.google')).toBeInTheDocument()
+ const connectLink = screen.getByText('common.integrations.connect')
+ expect(connectLink).toBeInTheDocument()
+ expect(connectLink.closest('a')).toHaveAttribute('href', 'https://google.com')
+ })
+ })
+
+ describe('Edge cases', () => {
+ it('should render nothing when no integrations are provided', () => {
+ // Arrange
+ vi.mocked(useAccountIntegrates).mockReturnValue({
+ data: {
+ data: [],
+ },
+ isPending: false,
+ isError: false,
+ } as unknown as ReturnType)
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText('common.integrations.connected')).toBeInTheDocument()
+ expect(screen.queryByText('common.integrations.google')).not.toBeInTheDocument()
+ expect(screen.queryByText('common.integrations.github')).not.toBeInTheDocument()
+ })
+
+ it('should handle unknown providers gracefully', () => {
+ // Arrange
+ const mockData = [
+ { provider: 'unknown', is_bound: false, link: '', created_at: 1678888888 } as unknown as AccountIntegrate,
+ ]
+
+ vi.mocked(useAccountIntegrates).mockReturnValue({
+ data: {
+ data: mockData,
+ },
+ isPending: false,
+ isError: false,
+ } as unknown as ReturnType)
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.queryByText('common.integrations.connect')).not.toBeInTheDocument()
+ })
+
+ it('should handle undefined data gracefully', () => {
+ // Arrange
+ vi.mocked(useAccountIntegrates).mockReturnValue({
+ data: undefined,
+ isPending: false,
+ isError: false,
+ } as unknown as ReturnType)
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText('common.integrations.connected')).toBeInTheDocument()
+ expect(screen.queryByText('common.integrations.google')).not.toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/header/account-setting/api-based-extension-page/empty.spec.tsx b/web/app/components/header/account-setting/api-based-extension-page/empty.spec.tsx
new file mode 100644
index 0000000000..11a4e8278f
--- /dev/null
+++ b/web/app/components/header/account-setting/api-based-extension-page/empty.spec.tsx
@@ -0,0 +1,18 @@
+import { render, screen } from '@testing-library/react'
+import Empty from './empty'
+
+describe('Empty State', () => {
+ describe('Rendering', () => {
+ it('should render title and documentation link', () => {
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText('common.apiBasedExtension.title')).toBeInTheDocument()
+ const link = screen.getByText('common.apiBasedExtension.link')
+ expect(link).toBeInTheDocument()
+ // The real useDocLink includes the language prefix (defaulting to /en in tests)
+ expect(link.closest('a')).toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/workspace/api-extension/api-extension')
+ })
+ })
+})
diff --git a/web/app/components/header/account-setting/api-based-extension-page/index.spec.tsx b/web/app/components/header/account-setting/api-based-extension-page/index.spec.tsx
new file mode 100644
index 0000000000..9c21b4f64c
--- /dev/null
+++ b/web/app/components/header/account-setting/api-based-extension-page/index.spec.tsx
@@ -0,0 +1,151 @@
+import type { SetStateAction } from 'react'
+import type { ModalContextState, ModalState } from '@/context/modal-context'
+import type { ApiBasedExtension } from '@/models/common'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { useModalContext } from '@/context/modal-context'
+import { useApiBasedExtensions } from '@/service/use-common'
+import ApiBasedExtensionPage from './index'
+
+vi.mock('@/service/use-common', () => ({
+ useApiBasedExtensions: vi.fn(),
+}))
+
+vi.mock('@/context/modal-context', () => ({
+ useModalContext: vi.fn(),
+}))
+
+describe('ApiBasedExtensionPage', () => {
+ const mockRefetch = vi.fn<() => void>()
+ const mockSetShowApiBasedExtensionModal = vi.fn<(value: SetStateAction | null>) => void>()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.mocked(useModalContext).mockReturnValue({
+ setShowApiBasedExtensionModal: mockSetShowApiBasedExtensionModal,
+ } as unknown as ModalContextState)
+ })
+
+ describe('Rendering', () => {
+ it('should render empty state when no data exists', () => {
+ // Arrange
+ vi.mocked(useApiBasedExtensions).mockReturnValue({
+ data: [],
+ isPending: false,
+ refetch: mockRefetch,
+ } as unknown as ReturnType)
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText('common.apiBasedExtension.title')).toBeInTheDocument()
+ })
+
+ it('should render list of extensions when data exists', () => {
+ // Arrange
+ const mockData = [
+ { id: '1', name: 'Extension 1', api_endpoint: 'url1' },
+ { id: '2', name: 'Extension 2', api_endpoint: 'url2' },
+ ]
+
+ vi.mocked(useApiBasedExtensions).mockReturnValue({
+ data: mockData,
+ isPending: false,
+ refetch: mockRefetch,
+ } as unknown as ReturnType)
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText('Extension 1')).toBeInTheDocument()
+ expect(screen.getByText('url1')).toBeInTheDocument()
+ expect(screen.getByText('Extension 2')).toBeInTheDocument()
+ expect(screen.getByText('url2')).toBeInTheDocument()
+ })
+
+ it('should handle loading state', () => {
+ // Arrange
+ vi.mocked(useApiBasedExtensions).mockReturnValue({
+ data: null,
+ isPending: true,
+ refetch: mockRefetch,
+ } as unknown as ReturnType)
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.queryByText('common.apiBasedExtension.title')).not.toBeInTheDocument()
+ expect(screen.getByText('common.apiBasedExtension.add')).toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should open modal when clicking add button', () => {
+ // Arrange
+ vi.mocked(useApiBasedExtensions).mockReturnValue({
+ data: [],
+ isPending: false,
+ refetch: mockRefetch,
+ } as unknown as ReturnType)
+
+ // Act
+ render()
+ fireEvent.click(screen.getByText('common.apiBasedExtension.add'))
+
+ // Assert
+ expect(mockSetShowApiBasedExtensionModal).toHaveBeenCalledWith(expect.objectContaining({
+ payload: {},
+ }))
+ })
+
+ it('should call refetch when onSaveCallback is executed from the modal', () => {
+ // Arrange
+ vi.mocked(useApiBasedExtensions).mockReturnValue({
+ data: [],
+ isPending: false,
+ refetch: mockRefetch,
+ } as unknown as ReturnType)
+
+ // Act
+ render()
+ fireEvent.click(screen.getByText('common.apiBasedExtension.add'))
+
+ // Trigger callback manually from the mock call
+ const callArgs = mockSetShowApiBasedExtensionModal.mock.calls[0][0]
+ if (typeof callArgs === 'object' && callArgs !== null && 'onSaveCallback' in callArgs) {
+ if (callArgs.onSaveCallback) {
+ callArgs.onSaveCallback()
+ // Assert
+ expect(mockRefetch).toHaveBeenCalled()
+ }
+ }
+ })
+
+ it('should call refetch when an item is updated', () => {
+ // Arrange
+ const mockData = [{ id: '1', name: 'Extension 1', api_endpoint: 'url1' }]
+ vi.mocked(useApiBasedExtensions).mockReturnValue({
+ data: mockData,
+ isPending: false,
+ refetch: mockRefetch,
+ } as unknown as ReturnType)
+
+ render()
+
+ // Act - Click edit on the rendered item
+ fireEvent.click(screen.getByText('common.operation.edit'))
+
+ // Retrieve the onSaveCallback from the modal call and execute it
+ const callArgs = mockSetShowApiBasedExtensionModal.mock.calls[0][0]
+ if (typeof callArgs === 'object' && callArgs !== null && 'onSaveCallback' in callArgs) {
+ if (callArgs.onSaveCallback)
+ callArgs.onSaveCallback()
+ }
+
+ // Assert
+ expect(mockRefetch).toHaveBeenCalled()
+ })
+ })
+})
diff --git a/web/app/components/header/account-setting/api-based-extension-page/item.spec.tsx b/web/app/components/header/account-setting/api-based-extension-page/item.spec.tsx
new file mode 100644
index 0000000000..47c5166285
--- /dev/null
+++ b/web/app/components/header/account-setting/api-based-extension-page/item.spec.tsx
@@ -0,0 +1,190 @@
+import type { TFunction } from 'i18next'
+import type { ModalContextState } from '@/context/modal-context'
+import type { ApiBasedExtension } from '@/models/common'
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
+import * as reactI18next from 'react-i18next'
+import { useModalContext } from '@/context/modal-context'
+import { deleteApiBasedExtension } from '@/service/common'
+import Item from './item'
+
+// Mock dependencies
+vi.mock('@/context/modal-context', () => ({
+ useModalContext: vi.fn(),
+}))
+
+vi.mock('@/service/common', () => ({
+ deleteApiBasedExtension: vi.fn(),
+}))
+
+describe('Item Component', () => {
+ const mockData: ApiBasedExtension = {
+ id: '1',
+ name: 'Test Extension',
+ api_endpoint: 'https://api.example.com',
+ api_key: 'test-api-key',
+ }
+ const mockOnUpdate = vi.fn()
+ const mockSetShowApiBasedExtensionModal = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.mocked(useModalContext).mockReturnValue({
+ setShowApiBasedExtensionModal: mockSetShowApiBasedExtensionModal,
+ } as unknown as ModalContextState)
+ })
+
+ describe('Rendering', () => {
+ it('should render extension data correctly', () => {
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('Test Extension')).toBeInTheDocument()
+ expect(screen.getByText('https://api.example.com')).toBeInTheDocument()
+ })
+
+ it('should render with minimal extension data', () => {
+ // Arrange
+ const minimalData: ApiBasedExtension = { id: '2' }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('common.operation.edit')).toBeInTheDocument()
+ expect(screen.getByText('common.operation.delete')).toBeInTheDocument()
+ })
+ })
+
+ describe('Modal Interactions', () => {
+ it('should open edit modal with correct payload when clicking edit button', () => {
+ // Act
+ render( )
+ fireEvent.click(screen.getByText('common.operation.edit'))
+
+ // Assert
+ expect(mockSetShowApiBasedExtensionModal).toHaveBeenCalledWith(expect.objectContaining({
+ payload: mockData,
+ }))
+ const lastCall = mockSetShowApiBasedExtensionModal.mock.calls[0][0]
+ if (typeof lastCall === 'object' && lastCall !== null && 'onSaveCallback' in lastCall)
+ expect(lastCall.onSaveCallback).toBeInstanceOf(Function)
+ })
+
+ it('should execute onUpdate callback when edit modal save callback is invoked', () => {
+ // Act
+ render( )
+ fireEvent.click(screen.getByText('common.operation.edit'))
+
+ // Assert
+ const modalCallArg = mockSetShowApiBasedExtensionModal.mock.calls[0][0]
+ if (typeof modalCallArg === 'object' && modalCallArg !== null && 'onSaveCallback' in modalCallArg) {
+ const onSaveCallback = modalCallArg.onSaveCallback
+ if (onSaveCallback) {
+ onSaveCallback()
+ expect(mockOnUpdate).toHaveBeenCalledTimes(1)
+ }
+ }
+ })
+ })
+
+ describe('Deletion', () => {
+ it('should show delete confirmation dialog when clicking delete button', () => {
+ // Act
+ render( )
+ fireEvent.click(screen.getByText('common.operation.delete'))
+
+ // Assert
+ expect(screen.getByText(/common\.operation\.delete.*Test Extension.*\?/i)).toBeInTheDocument()
+ })
+
+ it('should call delete API and triggers onUpdate when confirming deletion', async () => {
+ // Arrange
+ vi.mocked(deleteApiBasedExtension).mockResolvedValue({ result: 'success' })
+ render( )
+
+ // Act
+ fireEvent.click(screen.getByText('common.operation.delete'))
+ const dialog = screen.getByTestId('confirm-overlay')
+ const confirmButton = within(dialog).getByText('common.operation.delete')
+ fireEvent.click(confirmButton)
+
+ // Assert
+ await waitFor(() => {
+ expect(deleteApiBasedExtension).toHaveBeenCalledWith('/api-based-extension/1')
+ expect(mockOnUpdate).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ it('should hide delete confirmation dialog after successful deletion', async () => {
+ // Arrange
+ vi.mocked(deleteApiBasedExtension).mockResolvedValue({ result: 'success' })
+ render( )
+
+ // Act
+ fireEvent.click(screen.getByText('common.operation.delete'))
+ const dialog = screen.getByTestId('confirm-overlay')
+ const confirmButton = within(dialog).getByText('common.operation.delete')
+ fireEvent.click(confirmButton)
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.queryByText(/common\.operation\.delete.*Test Extension.*\?/i)).not.toBeInTheDocument()
+ })
+ })
+
+ it('should close delete confirmation when clicking cancel button', () => {
+ // Act
+ render( )
+ fireEvent.click(screen.getByText('common.operation.delete'))
+ fireEvent.click(screen.getByText('common.operation.cancel'))
+
+ // Assert
+ expect(screen.queryByText(/common\.operation\.delete.*Test Extension.*\?/i)).not.toBeInTheDocument()
+ })
+
+ it('should not call delete API when canceling deletion', () => {
+ // Act
+ render( )
+ fireEvent.click(screen.getByText('common.operation.delete'))
+ fireEvent.click(screen.getByText('common.operation.cancel'))
+
+ // Assert
+ expect(deleteApiBasedExtension).not.toHaveBeenCalled()
+ expect(mockOnUpdate).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should still show confirmation modal when operation.delete translation is missing', () => {
+ // Arrange
+ const useTranslationSpy = vi.spyOn(reactI18next, 'useTranslation')
+ const originalValue = useTranslationSpy.getMockImplementation()?.() || {
+ t: (key: string) => key,
+ i18n: { language: 'en', changeLanguage: vi.fn() },
+ }
+
+ useTranslationSpy.mockReturnValue({
+ ...originalValue,
+ t: vi.fn().mockImplementation((key: string) => {
+ if (key === 'operation.delete')
+ return ''
+ return key
+ }) as unknown as TFunction,
+ } as unknown as ReturnType)
+
+ // Act
+ render( )
+ const allButtons = screen.getAllByRole('button')
+ const editBtn = screen.getByText('operation.edit')
+ const deleteBtn = allButtons.find(btn => btn !== editBtn)
+ if (deleteBtn)
+ fireEvent.click(deleteBtn)
+
+ // Assert
+ expect(screen.getByText(/.*Test Extension.*\?/i)).toBeInTheDocument()
+
+ useTranslationSpy.mockRestore()
+ })
+ })
+})
diff --git a/web/app/components/header/account-setting/api-based-extension-page/modal.spec.tsx b/web/app/components/header/account-setting/api-based-extension-page/modal.spec.tsx
new file mode 100644
index 0000000000..3903fbfcf3
--- /dev/null
+++ b/web/app/components/header/account-setting/api-based-extension-page/modal.spec.tsx
@@ -0,0 +1,223 @@
+import type { TFunction } from 'i18next'
+import type { IToastProps } from '@/app/components/base/toast'
+import { fireEvent, render as RTLRender, screen, waitFor } from '@testing-library/react'
+import * as reactI18next from 'react-i18next'
+import { ToastContext } from '@/app/components/base/toast'
+import { useDocLink } from '@/context/i18n'
+import { addApiBasedExtension, updateApiBasedExtension } from '@/service/common'
+import ApiBasedExtensionModal from './modal'
+
+vi.mock('@/context/i18n', () => ({
+ useDocLink: vi.fn(),
+}))
+
+vi.mock('@/service/common', () => ({
+ addApiBasedExtension: vi.fn(),
+ updateApiBasedExtension: vi.fn(),
+}))
+
+describe('ApiBasedExtensionModal', () => {
+ const mockOnCancel = vi.fn()
+ const mockOnSave = vi.fn()
+ const mockNotify = vi.fn()
+ const mockDocLink = vi.fn((path?: string) => `https://docs.dify.ai${path || ''}`)
+
+ const render = (ui: React.ReactElement) => RTLRender(
+ void,
+ close: vi.fn(),
+ }}
+ >
+ {ui}
+ ,
+ )
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.mocked(useDocLink).mockReturnValue(mockDocLink)
+ })
+
+ describe('Rendering', () => {
+ it('should render correctly for adding a new extension', () => {
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText('common.apiBasedExtension.modal.title')).toBeInTheDocument()
+ expect(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder')).toBeInTheDocument()
+ expect(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiEndpoint.placeholder')).toBeInTheDocument()
+ expect(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiKey.placeholder')).toBeInTheDocument()
+ })
+
+ it('should render correctly for editing an existing extension', () => {
+ // Arrange
+ const data = { id: '1', name: 'Existing', api_endpoint: 'url', api_key: 'key' }
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText('common.apiBasedExtension.modal.editTitle')).toBeInTheDocument()
+ expect(screen.getByDisplayValue('Existing')).toBeInTheDocument()
+ expect(screen.getByDisplayValue('url')).toBeInTheDocument()
+ expect(screen.getByDisplayValue('key')).toBeInTheDocument()
+ })
+ })
+
+ describe('Form Submissions', () => {
+ it('should call addApiBasedExtension on save for new extension', async () => {
+ // Arrange
+ vi.mocked(addApiBasedExtension).mockResolvedValue({ id: 'new-id' })
+ render()
+
+ // Act
+ fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder'), { target: { value: 'New Ext' } })
+ fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiEndpoint.placeholder'), { target: { value: 'https://api.test' } })
+ fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiKey.placeholder'), { target: { value: 'secret-key' } })
+ fireEvent.click(screen.getByText('common.operation.save'))
+
+ // Assert
+ await waitFor(() => {
+ expect(addApiBasedExtension).toHaveBeenCalledWith({
+ url: '/api-based-extension',
+ body: {
+ name: 'New Ext',
+ api_endpoint: 'https://api.test',
+ api_key: 'secret-key',
+ },
+ })
+ expect(mockOnSave).toHaveBeenCalledWith({ id: 'new-id' })
+ })
+ })
+
+ it('should call updateApiBasedExtension on save for existing extension', async () => {
+ // Arrange
+ const data = { id: '1', name: 'Existing', api_endpoint: 'url', api_key: 'long-secret-key' }
+ vi.mocked(updateApiBasedExtension).mockResolvedValue({ ...data, name: 'Updated' })
+ render()
+
+ // Act
+ fireEvent.change(screen.getByDisplayValue('Existing'), { target: { value: 'Updated' } })
+ fireEvent.click(screen.getByText('common.operation.save'))
+
+ // Assert
+ await waitFor(() => {
+ expect(updateApiBasedExtension).toHaveBeenCalledWith({
+ url: '/api-based-extension/1',
+ body: expect.objectContaining({
+ id: '1',
+ name: 'Updated',
+ api_endpoint: 'url',
+ api_key: '[__HIDDEN__]',
+ }),
+ })
+ expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'common.actionMsg.modifiedSuccessfully' })
+ expect(mockOnSave).toHaveBeenCalled()
+ })
+ })
+
+ it('should call updateApiBasedExtension with new api_key when key is changed', async () => {
+ // Arrange
+ const data = { id: '1', name: 'Existing', api_endpoint: 'url', api_key: 'old-key' }
+ vi.mocked(updateApiBasedExtension).mockResolvedValue({ ...data, api_key: 'new-longer-key' })
+ render()
+
+ // Act
+ fireEvent.change(screen.getByDisplayValue('old-key'), { target: { value: 'new-longer-key' } })
+ fireEvent.click(screen.getByText('common.operation.save'))
+
+ // Assert
+ await waitFor(() => {
+ expect(updateApiBasedExtension).toHaveBeenCalledWith({
+ url: '/api-based-extension/1',
+ body: expect.objectContaining({
+ api_key: 'new-longer-key',
+ }),
+ })
+ })
+ })
+ })
+
+ describe('Validation', () => {
+ it('should show error if api key is too short', async () => {
+ // Arrange
+ render()
+
+ // Act
+ fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder'), { target: { value: 'Ext' } })
+ fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiEndpoint.placeholder'), { target: { value: 'url' } })
+ fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiKey.placeholder'), { target: { value: '123' } })
+ fireEvent.click(screen.getByText('common.operation.save'))
+
+ // Assert
+ expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'common.apiBasedExtension.modal.apiKey.lengthError' })
+ expect(addApiBasedExtension).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('Interactions', () => {
+ it('should work when onSave is not provided', async () => {
+ // Arrange
+ vi.mocked(addApiBasedExtension).mockResolvedValue({ id: 'new-id' })
+ render()
+
+ // Act
+ fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder'), { target: { value: 'New Ext' } })
+ fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiEndpoint.placeholder'), { target: { value: 'https://api.test' } })
+ fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiKey.placeholder'), { target: { value: 'secret-key' } })
+ fireEvent.click(screen.getByText('common.operation.save'))
+
+ // Assert
+ await waitFor(() => {
+ expect(addApiBasedExtension).toHaveBeenCalled()
+ })
+ })
+
+ it('should call onCancel when clicking cancel button', () => {
+ // Arrange
+ render()
+
+ // Act
+ fireEvent.click(screen.getByText('common.operation.cancel'))
+
+ // Assert
+ expect(mockOnCancel).toHaveBeenCalled()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle missing translations for placeholders gracefully', () => {
+ // Arrange
+ const useTranslationSpy = vi.spyOn(reactI18next, 'useTranslation')
+ const originalValue = useTranslationSpy.getMockImplementation()?.() || {
+ t: (key: string) => key,
+ i18n: { language: 'en', changeLanguage: vi.fn() },
+ }
+
+ useTranslationSpy.mockReturnValue({
+ ...originalValue,
+ t: vi.fn().mockImplementation((key: string) => {
+ const missingKeys = [
+ 'apiBasedExtension.modal.name.placeholder',
+ 'apiBasedExtension.modal.apiEndpoint.placeholder',
+ 'apiBasedExtension.modal.apiKey.placeholder',
+ ]
+ if (missingKeys.some(k => key.includes(k)))
+ return ''
+ return key
+ }) as unknown as TFunction,
+ } as unknown as ReturnType)
+
+ // Act
+ const { container } = render()
+
+ // Assert
+ const inputs = container.querySelectorAll('input')
+ inputs.forEach((input) => {
+ expect(input.placeholder).toBe('')
+ })
+
+ useTranslationSpy.mockRestore()
+ })
+ })
+})
diff --git a/web/app/components/header/account-setting/api-based-extension-page/selector.spec.tsx b/web/app/components/header/account-setting/api-based-extension-page/selector.spec.tsx
new file mode 100644
index 0000000000..5e4c51b1b2
--- /dev/null
+++ b/web/app/components/header/account-setting/api-based-extension-page/selector.spec.tsx
@@ -0,0 +1,123 @@
+import type { UseQueryResult } from '@tanstack/react-query'
+import type { ModalContextState } from '@/context/modal-context'
+import type { ApiBasedExtension } from '@/models/common'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
+import { useModalContext } from '@/context/modal-context'
+import { useApiBasedExtensions } from '@/service/use-common'
+import ApiBasedExtensionSelector from './selector'
+
+vi.mock('@/context/modal-context', () => ({
+ useModalContext: vi.fn(),
+}))
+
+vi.mock('@/service/use-common', () => ({
+ useApiBasedExtensions: vi.fn(),
+}))
+
+describe('ApiBasedExtensionSelector', () => {
+ const mockOnChange = vi.fn()
+ const mockSetShowAccountSettingModal = vi.fn()
+ const mockSetShowApiBasedExtensionModal = vi.fn()
+ const mockRefetch = vi.fn()
+
+ const mockData: ApiBasedExtension[] = [
+ { id: '1', name: 'Extension 1', api_endpoint: 'https://api1.test' },
+ { id: '2', name: 'Extension 2', api_endpoint: 'https://api2.test' },
+ ]
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.mocked(useModalContext).mockReturnValue({
+ setShowAccountSettingModal: mockSetShowAccountSettingModal,
+ setShowApiBasedExtensionModal: mockSetShowApiBasedExtensionModal,
+ } as unknown as ModalContextState)
+ vi.mocked(useApiBasedExtensions).mockReturnValue({
+ data: mockData,
+ refetch: mockRefetch,
+ isPending: false,
+ isError: false,
+ } as unknown as UseQueryResult)
+ })
+
+ describe('Rendering', () => {
+ it('should render placeholder when no value is selected', () => {
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText('common.apiBasedExtension.selector.placeholder')).toBeInTheDocument()
+ })
+
+ it('should render selected item name', async () => {
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText('Extension 1')).toBeInTheDocument()
+ })
+ })
+
+ describe('Dropdown Interactions', () => {
+ it('should open dropdown when clicked', async () => {
+ // Act
+ render()
+ const trigger = screen.getByText('common.apiBasedExtension.selector.placeholder')
+ fireEvent.click(trigger)
+
+ // Assert
+ expect(await screen.findByText('common.apiBasedExtension.selector.title')).toBeInTheDocument()
+ })
+
+ it('should call onChange and closes dropdown when an extension is selected', async () => {
+ // Act
+ render()
+ fireEvent.click(screen.getByText('common.apiBasedExtension.selector.placeholder'))
+
+ const option = await screen.findByText('Extension 2')
+ fireEvent.click(option)
+
+ // Assert
+ expect(mockOnChange).toHaveBeenCalledWith('2')
+ })
+ })
+
+ describe('Manage and Add Extensions', () => {
+ it('should open account settings when clicking manage', async () => {
+ // Act
+ render()
+ fireEvent.click(screen.getByText('common.apiBasedExtension.selector.placeholder'))
+
+ const manageButton = await screen.findByText('common.apiBasedExtension.selector.manage')
+ fireEvent.click(manageButton)
+
+ // Assert
+ expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
+ payload: ACCOUNT_SETTING_TAB.API_BASED_EXTENSION,
+ })
+ })
+
+ it('should open add modal when clicking add button and refetches on save', async () => {
+ // Act
+ render()
+ fireEvent.click(screen.getByText('common.apiBasedExtension.selector.placeholder'))
+
+ const addButton = await screen.findByText('common.operation.add')
+ fireEvent.click(addButton)
+
+ // Assert
+ expect(mockSetShowApiBasedExtensionModal).toHaveBeenCalledWith(expect.objectContaining({
+ payload: {},
+ }))
+
+ // Trigger callback
+ const lastCall = mockSetShowApiBasedExtensionModal.mock.calls[0][0]
+ if (typeof lastCall === 'object' && lastCall !== null && 'onSaveCallback' in lastCall) {
+ if (lastCall.onSaveCallback) {
+ lastCall.onSaveCallback()
+ expect(mockRefetch).toHaveBeenCalled()
+ }
+ }
+ })
+ })
+})
diff --git a/web/app/components/header/account-setting/collapse/index.spec.tsx b/web/app/components/header/account-setting/collapse/index.spec.tsx
new file mode 100644
index 0000000000..4b1ced4579
--- /dev/null
+++ b/web/app/components/header/account-setting/collapse/index.spec.tsx
@@ -0,0 +1,121 @@
+import type { IItem } from './index'
+import { fireEvent, render, screen } from '@testing-library/react'
+import Collapse from './index'
+
+describe('Collapse', () => {
+ const mockItems: IItem[] = [
+ { key: '1', name: 'Item 1' },
+ { key: '2', name: 'Item 2' },
+ ]
+
+ const mockRenderItem = (item: IItem) => (
+
+ {item.name}
+
+ )
+
+ const mockOnSelect = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render title and initially closed state', () => {
+ // Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ expect(screen.getByText('Test Title')).toBeInTheDocument()
+ expect(screen.queryByTestId('item-1')).not.toBeInTheDocument()
+ expect(container.querySelector('svg')).toBeInTheDocument()
+ })
+
+ it('should apply custom wrapperClassName', () => {
+ // Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ expect(container.firstChild).toHaveClass('custom-class')
+ })
+ })
+
+ describe('Interactions', () => {
+ it('should toggle content open and closed', () => {
+ // Act & Assert
+ render(
+ ,
+ )
+
+ // Initially closed
+ expect(screen.queryByTestId('item-1')).not.toBeInTheDocument()
+
+ // Click to open
+ fireEvent.click(screen.getByText('Test Title'))
+ expect(screen.getByTestId('item-1')).toBeInTheDocument()
+ expect(screen.getByTestId('item-2')).toBeInTheDocument()
+
+ // Click to close
+ fireEvent.click(screen.getByText('Test Title'))
+ expect(screen.queryByTestId('item-1')).not.toBeInTheDocument()
+ })
+
+ it('should handle item selection', () => {
+ // Arrange
+ render(
+ ,
+ )
+
+ // Act
+ fireEvent.click(screen.getByText('Test Title'))
+ const item1 = screen.getByTestId('item-1')
+ fireEvent.click(item1)
+
+ // Assert
+ expect(mockOnSelect).toHaveBeenCalledTimes(1)
+ expect(mockOnSelect).toHaveBeenCalledWith(mockItems[0])
+ })
+
+ it('should not crash when onSelect is undefined and item is clicked', () => {
+ // Arrange
+ render(
+ ,
+ )
+
+ // Act
+ fireEvent.click(screen.getByText('Test Title'))
+ const item1 = screen.getByTestId('item-1')
+ fireEvent.click(item1)
+
+ // Assert
+ // Should not throw
+ expect(screen.getByTestId('item-1')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/header/account-setting/constants.spec.ts b/web/app/components/header/account-setting/constants.spec.ts
new file mode 100644
index 0000000000..aaf7259a0e
--- /dev/null
+++ b/web/app/components/header/account-setting/constants.spec.ts
@@ -0,0 +1,42 @@
+import {
+ ACCOUNT_SETTING_MODAL_ACTION,
+ ACCOUNT_SETTING_TAB,
+ DEFAULT_ACCOUNT_SETTING_TAB,
+ isValidAccountSettingTab,
+} from './constants'
+
+describe('AccountSetting Constants', () => {
+ it('should have correct ACCOUNT_SETTING_MODAL_ACTION', () => {
+ expect(ACCOUNT_SETTING_MODAL_ACTION).toBe('showSettings')
+ })
+
+ it('should have correct ACCOUNT_SETTING_TAB values', () => {
+ expect(ACCOUNT_SETTING_TAB.PROVIDER).toBe('provider')
+ expect(ACCOUNT_SETTING_TAB.MEMBERS).toBe('members')
+ expect(ACCOUNT_SETTING_TAB.BILLING).toBe('billing')
+ expect(ACCOUNT_SETTING_TAB.DATA_SOURCE).toBe('data-source')
+ expect(ACCOUNT_SETTING_TAB.API_BASED_EXTENSION).toBe('api-based-extension')
+ expect(ACCOUNT_SETTING_TAB.CUSTOM).toBe('custom')
+ expect(ACCOUNT_SETTING_TAB.LANGUAGE).toBe('language')
+ })
+
+ it('should have correct DEFAULT_ACCOUNT_SETTING_TAB', () => {
+ expect(DEFAULT_ACCOUNT_SETTING_TAB).toBe(ACCOUNT_SETTING_TAB.MEMBERS)
+ })
+
+ it('isValidAccountSettingTab should return true for valid tabs', () => {
+ expect(isValidAccountSettingTab('provider')).toBe(true)
+ expect(isValidAccountSettingTab('members')).toBe(true)
+ expect(isValidAccountSettingTab('billing')).toBe(true)
+ expect(isValidAccountSettingTab('data-source')).toBe(true)
+ expect(isValidAccountSettingTab('api-based-extension')).toBe(true)
+ expect(isValidAccountSettingTab('custom')).toBe(true)
+ expect(isValidAccountSettingTab('language')).toBe(true)
+ })
+
+ it('isValidAccountSettingTab should return false for invalid tabs', () => {
+ expect(isValidAccountSettingTab(null)).toBe(false)
+ expect(isValidAccountSettingTab('')).toBe(false)
+ expect(isValidAccountSettingTab('invalid')).toBe(false)
+ })
+})
diff --git a/web/app/components/header/account-setting/index.spec.tsx b/web/app/components/header/account-setting/index.spec.tsx
new file mode 100644
index 0000000000..3a98d8afb8
--- /dev/null
+++ b/web/app/components/header/account-setting/index.spec.tsx
@@ -0,0 +1,334 @@
+import type { AppContextValue } from '@/context/app-context'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { useAppContext } from '@/context/app-context'
+import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
+import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
+import { ACCOUNT_SETTING_TAB } from './constants'
+import AccountSetting from './index'
+
+vi.mock('@/context/provider-context', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ useProviderContext: vi.fn(),
+ }
+})
+
+vi.mock('@/context/app-context', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ useAppContext: vi.fn(),
+ }
+})
+
+vi.mock('next/navigation', () => ({
+ useRouter: vi.fn(() => ({
+ push: vi.fn(),
+ replace: vi.fn(),
+ prefetch: vi.fn(),
+ })),
+ usePathname: vi.fn(() => '/'),
+ useParams: vi.fn(() => ({})),
+ useSearchParams: vi.fn(() => ({ get: vi.fn() })),
+}))
+
+vi.mock('@/hooks/use-breakpoints', () => ({
+ MediaType: {
+ mobile: 'mobile',
+ tablet: 'tablet',
+ pc: 'pc',
+ },
+ default: vi.fn(),
+}))
+
+vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
+ useDefaultModel: vi.fn(() => ({ data: null, isLoading: false })),
+ useUpdateDefaultModel: vi.fn(() => ({ trigger: vi.fn() })),
+ useUpdateModelList: vi.fn(() => vi.fn()),
+ useModelList: vi.fn(() => ({ data: [], isLoading: false })),
+ useSystemDefaultModelAndModelList: vi.fn(() => [null, vi.fn()]),
+}))
+
+vi.mock('@/service/use-datasource', () => ({
+ useGetDataSourceListAuth: vi.fn(() => ({ data: { result: [] } })),
+}))
+
+vi.mock('@/service/use-common', () => ({
+ useApiBasedExtensions: vi.fn(() => ({ data: [], isPending: false })),
+ useMembers: vi.fn(() => ({ data: { accounts: [] }, refetch: vi.fn() })),
+ useProviderContext: vi.fn(),
+}))
+
+const baseAppContextValue: AppContextValue = {
+ userProfile: {
+ id: '1',
+ name: 'Test User',
+ email: 'test@example.com',
+ avatar: '',
+ avatar_url: '',
+ is_password_set: false,
+ },
+ mutateUserProfile: vi.fn(),
+ currentWorkspace: {
+ id: '1',
+ name: 'Workspace',
+ plan: '',
+ status: '',
+ created_at: 0,
+ role: 'owner',
+ providers: [],
+ trial_credits: 0,
+ trial_credits_used: 0,
+ next_credit_reset_date: 0,
+ },
+ isCurrentWorkspaceManager: true,
+ isCurrentWorkspaceOwner: true,
+ isCurrentWorkspaceEditor: true,
+ isCurrentWorkspaceDatasetOperator: false,
+ mutateCurrentWorkspace: vi.fn(),
+ langGeniusVersionInfo: {
+ current_env: 'testing',
+ current_version: '0.1.0',
+ latest_version: '0.1.0',
+ release_date: '',
+ release_notes: '',
+ version: '0.1.0',
+ can_auto_update: false,
+ },
+ useSelector: vi.fn(),
+ isLoadingCurrentWorkspace: false,
+ isValidatingCurrentWorkspace: false,
+}
+
+describe('AccountSetting', () => {
+ const mockOnCancel = vi.fn()
+ const mockOnTabChange = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.mocked(useProviderContext).mockReturnValue({
+ ...baseProviderContextValue,
+ enableBilling: true,
+ enableReplaceWebAppLogo: true,
+ })
+ vi.mocked(useAppContext).mockReturnValue(baseAppContextValue)
+ vi.mocked(useBreakpoints).mockReturnValue(MediaType.pc)
+ })
+
+ describe('Rendering', () => {
+ it('should render the sidebar with correct menu items', () => {
+ // Act
+ render(
+
+
+ ,
+ )
+
+ // Assert
+ expect(screen.getByText('common.userProfile.settings')).toBeInTheDocument()
+ expect(screen.getByText('common.settings.provider')).toBeInTheDocument()
+ expect(screen.getAllByText('common.settings.members').length).toBeGreaterThan(0)
+ expect(screen.getByText('common.settings.billing')).toBeInTheDocument()
+ expect(screen.getByText('common.settings.dataSource')).toBeInTheDocument()
+ expect(screen.getByText('common.settings.apiBasedExtension')).toBeInTheDocument()
+ expect(screen.getByText('custom.custom')).toBeInTheDocument()
+ expect(screen.getAllByText('common.settings.language').length).toBeGreaterThan(0)
+ })
+
+ it('should respect the activeTab prop', () => {
+ // Act
+ render(
+
+
+ ,
+ )
+
+ // Assert
+ // Check that the active item title is Data Source
+ const titles = screen.getAllByText('common.settings.dataSource')
+ // One in sidebar, one in header.
+ expect(titles.length).toBeGreaterThan(1)
+ })
+
+ it('should hide sidebar labels on mobile', () => {
+ // Arrange
+ vi.mocked(useBreakpoints).mockReturnValue(MediaType.mobile)
+
+ // Act
+ render(
+
+
+ ,
+ )
+
+ // Assert
+ // On mobile, the labels should not be rendered as per the implementation
+ expect(screen.queryByText('common.settings.provider')).not.toBeInTheDocument()
+ })
+
+ it('should filter items for dataset operator', () => {
+ // Arrange
+ vi.mocked(useAppContext).mockReturnValue({
+ ...baseAppContextValue,
+ isCurrentWorkspaceDatasetOperator: true,
+ })
+
+ // Act
+ render(
+
+
+ ,
+ )
+
+ // Assert
+ expect(screen.queryByText('common.settings.provider')).not.toBeInTheDocument()
+ expect(screen.queryByText('common.settings.members')).not.toBeInTheDocument()
+ expect(screen.getByText('common.settings.language')).toBeInTheDocument()
+ })
+
+ it('should hide billing and custom tabs when disabled', () => {
+ // Arrange
+ vi.mocked(useProviderContext).mockReturnValue({
+ ...baseProviderContextValue,
+ enableBilling: false,
+ enableReplaceWebAppLogo: false,
+ })
+
+ // Act
+ render(
+
+
+ ,
+ )
+
+ // Assert
+ expect(screen.queryByText('common.settings.billing')).not.toBeInTheDocument()
+ expect(screen.queryByText('custom.custom')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Tab Navigation', () => {
+ it('should change active tab when clicking on menu item', () => {
+ // Arrange
+ render(
+
+
+ ,
+ )
+
+ // Act
+ fireEvent.click(screen.getByText('common.settings.provider'))
+
+ // Assert
+ expect(mockOnTabChange).toHaveBeenCalledWith(ACCOUNT_SETTING_TAB.PROVIDER)
+ // Check for content from ModelProviderPage
+ expect(screen.getByText('common.modelProvider.models')).toBeInTheDocument()
+ })
+
+ it('should navigate through various tabs and show correct details', () => {
+ // Act & Assert
+ render(
+
+
+ ,
+ )
+
+ // Billing
+ fireEvent.click(screen.getByText('common.settings.billing'))
+ // Billing Page renders plansCommon.plan if data is loaded, or generic text.
+ // Checking for title in header which is always there
+ expect(screen.getAllByText('common.settings.billing').length).toBeGreaterThan(1)
+
+ // Data Source
+ fireEvent.click(screen.getByText('common.settings.dataSource'))
+ expect(screen.getAllByText('common.settings.dataSource').length).toBeGreaterThan(1)
+
+ // API Based Extension
+ fireEvent.click(screen.getByText('common.settings.apiBasedExtension'))
+ expect(screen.getAllByText('common.settings.apiBasedExtension').length).toBeGreaterThan(1)
+
+ // Custom
+ fireEvent.click(screen.getByText('custom.custom'))
+ // Custom Page uses 'custom.custom' key as well.
+ expect(screen.getAllByText('custom.custom').length).toBeGreaterThan(1)
+
+ // Language
+ fireEvent.click(screen.getAllByText('common.settings.language')[0])
+ expect(screen.getAllByText('common.settings.language').length).toBeGreaterThan(1)
+
+ // Members
+ fireEvent.click(screen.getAllByText('common.settings.members')[0])
+ expect(screen.getAllByText('common.settings.members').length).toBeGreaterThan(1)
+ })
+ })
+
+ describe('Interactions', () => {
+ it('should call onCancel when clicking close button', () => {
+ // Act
+ render(
+
+
+ ,
+ )
+ const buttons = screen.getAllByRole('button')
+ fireEvent.click(buttons[0])
+
+ // Assert
+ expect(mockOnCancel).toHaveBeenCalled()
+ })
+
+ it('should call onCancel when pressing Escape key', () => {
+ // Act
+ render(
+
+
+ ,
+ )
+ fireEvent.keyDown(document, { key: 'Escape' })
+
+ // Assert
+ expect(mockOnCancel).toHaveBeenCalled()
+ })
+
+ it('should update search value in provider tab', () => {
+ // Arrange
+ render(
+
+
+ ,
+ )
+ fireEvent.click(screen.getByText('common.settings.provider'))
+
+ // Act
+ const input = screen.getByRole('textbox')
+ fireEvent.change(input, { target: { value: 'test-search' } })
+
+ // Assert
+ expect(input).toHaveValue('test-search')
+ expect(screen.getByText('common.modelProvider.models')).toBeInTheDocument()
+ })
+
+ it('should handle scroll event in panel', () => {
+ // Act
+ render(
+
+
+ ,
+ )
+ const scrollContainer = screen.getByRole('dialog').querySelector('.overflow-y-auto')
+
+ // Assert
+ expect(scrollContainer).toBeInTheDocument()
+ if (scrollContainer) {
+ // Scroll down
+ fireEvent.scroll(scrollContainer, { target: { scrollTop: 100 } })
+ expect(scrollContainer).toHaveClass('overflow-y-auto')
+
+ // Scroll back up
+ fireEvent.scroll(scrollContainer, { target: { scrollTop: 0 } })
+ }
+ })
+ })
+})
diff --git a/web/app/components/header/account-setting/menu-dialog.spec.tsx b/web/app/components/header/account-setting/menu-dialog.spec.tsx
new file mode 100644
index 0000000000..648e8e4576
--- /dev/null
+++ b/web/app/components/header/account-setting/menu-dialog.spec.tsx
@@ -0,0 +1,94 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import MenuDialog from './menu-dialog'
+
+describe('MenuDialog', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render children when show is true', () => {
+ // Act
+ render(
+
+ Content
+ ,
+ )
+
+ // Assert
+ expect(screen.getByTestId('dialog-content')).toBeInTheDocument()
+ })
+
+ it('should not render children when show is false', () => {
+ // Act
+ render(
+
+ Content
+ ,
+ )
+
+ // Assert
+ expect(screen.queryByTestId('dialog-content')).not.toBeInTheDocument()
+ })
+
+ it('should apply custom className', () => {
+ // Act
+ render(
+
+ Content
+ ,
+ )
+
+ // Assert
+ const panel = screen.getByRole('dialog').querySelector('.custom-class')
+ expect(panel).toBeInTheDocument()
+ })
+ })
+
+ describe('Interactions', () => {
+ it('should call onClose when Escape key is pressed', () => {
+ // Arrange
+ const onClose = vi.fn()
+ render(
+
+ Content
+ ,
+ )
+
+ // Act
+ fireEvent.keyDown(document, { key: 'Escape' })
+
+ // Assert
+ expect(onClose).toHaveBeenCalled()
+ })
+
+ it('should not call onClose when a key other than Escape is pressed', () => {
+ // Arrange
+ const onClose = vi.fn()
+ render(
+
+ Content
+ ,
+ )
+
+ // Act
+ fireEvent.keyDown(document, { key: 'Enter' })
+
+ // Assert
+ expect(onClose).not.toHaveBeenCalled()
+ })
+
+ it('should not crash when Escape is pressed and onClose is not provided', () => {
+ // Arrange
+ render(
+
+ Content
+ ,
+ )
+
+ // Act & Assert
+ fireEvent.keyDown(document, { key: 'Escape' })
+ expect(screen.getByTestId('dialog-content')).toBeInTheDocument()
+ })
+ })
+})