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() + }) + }) +})