diff --git a/web/app/components/app/configuration/dataset-config/select-dataset/index.spec.tsx b/web/app/components/app/configuration/dataset-config/select-dataset/index.spec.tsx new file mode 100644 index 0000000000..e7c3d4a3c9 --- /dev/null +++ b/web/app/components/app/configuration/dataset-config/select-dataset/index.spec.tsx @@ -0,0 +1,141 @@ +import type { DataSet } from '@/models/datasets' +import { act, fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' + +import { describe, expect, it, vi } from 'vitest' +import { IndexingType } from '@/app/components/datasets/create/step-two' +import { DatasetPermission } from '@/models/datasets' +import { RETRIEVE_METHOD } from '@/types/app' +import SelectDataSet from './index' + +vi.mock('@/i18n-config/i18next-config', () => ({ + __esModule: true, + default: { + changeLanguage: vi.fn(), + addResourceBundle: vi.fn(), + use: vi.fn().mockReturnThis(), + init: vi.fn(), + addResource: vi.fn(), + hasResourceBundle: vi.fn().mockReturnValue(true), + }, +})) +const mockUseInfiniteScroll = vi.fn() +vi.mock('ahooks', async (importOriginal) => { + const actual = await importOriginal() + return { + ...(typeof actual === 'object' && actual !== null ? actual : {}), + useInfiniteScroll: (...args: any[]) => mockUseInfiniteScroll(...args), + } +}) + +const mockUseInfiniteDatasets = vi.fn() +vi.mock('@/service/knowledge/use-dataset', () => ({ + useInfiniteDatasets: (...args: any[]) => mockUseInfiniteDatasets(...args), +})) + +vi.mock('@/hooks/use-knowledge', () => ({ + useKnowledge: () => ({ + formatIndexingTechniqueAndMethod: (tech: string, method: string) => `${tech}:${method}`, + }), +})) + +const baseProps = { + isShow: true, + onClose: vi.fn(), + selectedIds: [] as string[], + onSelect: vi.fn(), +} + +const makeDataset = (overrides: Partial): DataSet => ({ + id: 'dataset-id', + name: 'Dataset Name', + provider: 'internal', + icon_info: { + icon_type: 'emoji', + icon: '💾', + icon_background: '#fff', + icon_url: '', + }, + embedding_available: true, + is_multimodal: false, + description: '', + permission: DatasetPermission.allTeamMembers, + indexing_technique: IndexingType.ECONOMICAL, + retrieval_model_dict: { + search_method: RETRIEVE_METHOD.fullText, + top_k: 5, + reranking_enable: false, + reranking_model: { + reranking_model_name: '', + reranking_provider_name: '', + }, + score_threshold_enabled: false, + score_threshold: 0, + }, + ...overrides, +} as DataSet) + +describe('SelectDataSet', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders dataset entries, allows selection, and fires onSelect', async () => { + const datasetOne = makeDataset({ + id: 'set-1', + name: 'Dataset One', + is_multimodal: true, + indexing_technique: IndexingType.ECONOMICAL, + }) + const datasetTwo = makeDataset({ + id: 'set-2', + name: 'Hidden Dataset', + embedding_available: false, + provider: 'external', + }) + mockUseInfiniteDatasets.mockReturnValue({ + data: { pages: [{ data: [datasetOne, datasetTwo] }] }, + isLoading: false, + isFetchingNextPage: false, + fetchNextPage: vi.fn(), + hasNextPage: false, + }) + + const onSelect = vi.fn() + await act(async () => { + render() + }) + + expect(screen.getByText('Dataset One')).toBeInTheDocument() + expect(screen.getByText('Hidden Dataset')).toBeInTheDocument() + + await act(async () => { + fireEvent.click(screen.getByText('Dataset One')) + }) + expect(screen.getByText('1 appDebug.feature.dataSet.selected')).toBeInTheDocument() + + const addButton = screen.getByRole('button', { name: 'common.operation.add' }) + await act(async () => { + fireEvent.click(addButton) + }) + expect(onSelect).toHaveBeenCalledWith([datasetOne]) + }) + + it('shows empty state when no datasets are available and disables add', async () => { + mockUseInfiniteDatasets.mockReturnValue({ + data: { pages: [{ data: [] }] }, + isLoading: false, + isFetchingNextPage: false, + fetchNextPage: vi.fn(), + hasNextPage: false, + }) + + await act(async () => { + render() + }) + + expect(screen.getByText('appDebug.feature.dataSet.noDataSet')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'appDebug.feature.dataSet.toCreate' })).toHaveAttribute('href', '/datasets/create') + expect(screen.getByRole('button', { name: 'common.operation.add' })).toBeDisabled() + }) +}) diff --git a/web/app/components/app/configuration/prompt-value-panel/index.spec.tsx b/web/app/components/app/configuration/prompt-value-panel/index.spec.tsx new file mode 100644 index 0000000000..039ed078d7 --- /dev/null +++ b/web/app/components/app/configuration/prompt-value-panel/index.spec.tsx @@ -0,0 +1,125 @@ +import type { IPromptValuePanelProps } from './index' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useStore } from '@/app/components/app/store' +import ConfigContext from '@/context/debug-configuration' +import { AppModeEnum, ModelModeType, Resolution } from '@/types/app' +import PromptValuePanel from './index' + +vi.mock('@/app/components/app/store', () => ({ + useStore: vi.fn(), +})) +vi.mock('@/app/components/base/features/new-feature-panel/feature-bar', () => ({ + __esModule: true, + default: ({ onFeatureBarClick }: { onFeatureBarClick: () => void }) => ( + + ), +})) + +const mockSetShowAppConfigureFeaturesModal = vi.fn() +const mockUseStore = vi.mocked(useStore) +const mockSetInputs = vi.fn() +const mockOnSend = vi.fn() + +const promptVariables = [ + { key: 'textVar', name: 'Text Var', type: 'string', required: true }, + { key: 'boolVar', name: 'Boolean Var', type: 'checkbox' }, +] as const + +const baseContextValue: any = { + modelModeType: ModelModeType.completion, + modelConfig: { + configs: { + prompt_template: 'prompt template', + prompt_variables: promptVariables, + }, + }, + setInputs: mockSetInputs, + mode: AppModeEnum.COMPLETION, + isAdvancedMode: false, + completionPromptConfig: { + prompt: { text: 'completion' }, + conversation_histories_role: { user_prefix: 'user', assistant_prefix: 'assistant' }, + }, + chatPromptConfig: { prompt: [] }, +} as any + +const defaultProps: IPromptValuePanelProps = { + appType: AppModeEnum.COMPLETION, + onSend: mockOnSend, + inputs: { textVar: 'initial', boolVar: false }, + visionConfig: { enabled: false, number_limits: 0, detail: Resolution.low, transfer_methods: [] }, + onVisionFilesChange: vi.fn(), +} + +const renderPanel = (options: { + context?: Partial + props?: Partial +} = {}) => { + const contextValue = { ...baseContextValue, ...options.context } + const props = { ...defaultProps, ...options.props } + return render( + + + , + ) +} + +describe('PromptValuePanel', () => { + beforeEach(() => { + mockUseStore.mockImplementation(selector => selector({ + setShowAppConfigureFeaturesModal: mockSetShowAppConfigureFeaturesModal, + appSidebarExpand: '', + currentLogModalActiveTab: 'prompt', + showPromptLogModal: false, + showAgentLogModal: false, + setShowPromptLogModal: vi.fn(), + setShowAgentLogModal: vi.fn(), + showMessageLogModal: false, + showAppConfigureFeaturesModal: false, + } as any)) + mockSetInputs.mockClear() + mockOnSend.mockClear() + mockSetShowAppConfigureFeaturesModal.mockClear() + }) + + it('updates inputs, clears values, and triggers run when ready', async () => { + renderPanel() + + const textInput = screen.getByPlaceholderText('Text Var') + fireEvent.change(textInput, { target: { value: 'updated' } }) + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ textVar: 'updated' })) + + const clearButton = screen.getByRole('button', { name: 'common.operation.clear' }) + fireEvent.click(clearButton) + + expect(mockSetInputs).toHaveBeenLastCalledWith({ + textVar: '', + boolVar: '', + }) + + const runButton = screen.getByRole('button', { name: 'appDebug.inputs.run' }) + expect(runButton).not.toBeDisabled() + fireEvent.click(runButton) + await waitFor(() => expect(mockOnSend).toHaveBeenCalledTimes(1)) + }) + + it('disables run when mode is not completion', () => { + renderPanel({ + context: { + mode: AppModeEnum.CHAT, + }, + props: { + appType: AppModeEnum.CHAT, + }, + }) + + const runButton = screen.getByRole('button', { name: 'appDebug.inputs.run' }) + expect(runButton).toBeDisabled() + fireEvent.click(runButton) + expect(mockOnSend).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/app/configuration/prompt-value-panel/utils.spec.ts b/web/app/components/app/configuration/prompt-value-panel/utils.spec.ts new file mode 100644 index 0000000000..7a7e0da9a9 --- /dev/null +++ b/web/app/components/app/configuration/prompt-value-panel/utils.spec.ts @@ -0,0 +1,29 @@ +import type { PromptVariable } from '@/models/debug' + +import { describe, expect, it } from 'vitest' +import { replaceStringWithValues } from './utils' + +const promptVariables: PromptVariable[] = [ + { key: 'user', name: 'User', type: 'string' }, + { key: 'topic', name: 'Topic', type: 'string' }, +] + +describe('replaceStringWithValues', () => { + it('should replace placeholders when inputs have values', () => { + const template = 'Hello {{user}} talking about {{topic}}' + const result = replaceStringWithValues(template, promptVariables, { user: 'Alice', topic: 'cats' }) + expect(result).toBe('Hello Alice talking about cats') + }) + + it('should use prompt variable name when value is missing', () => { + const template = 'Hi {{user}} from {{topic}}' + const result = replaceStringWithValues(template, promptVariables, {}) + expect(result).toBe('Hi {{User}} from {{Topic}}') + }) + + it('should leave placeholder untouched when no variable is defined', () => { + const template = 'Unknown {{missing}} placeholder' + const result = replaceStringWithValues(template, promptVariables, {}) + expect(result).toBe('Unknown {{missing}} placeholder') + }) +}) diff --git a/web/app/components/app/create-app-modal/index.spec.tsx b/web/app/components/app/create-app-modal/index.spec.tsx new file mode 100644 index 0000000000..02c00ed3fd --- /dev/null +++ b/web/app/components/app/create-app-modal/index.spec.tsx @@ -0,0 +1,162 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { useRouter } from 'next/navigation' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { trackEvent } from '@/app/components/base/amplitude' + +import { ToastContext } from '@/app/components/base/toast' +import { NEED_REFRESH_APP_LIST_KEY } from '@/config' +import { useAppContext } from '@/context/app-context' +import { useProviderContext } from '@/context/provider-context' +import { createApp } from '@/service/apps' +import { AppModeEnum } from '@/types/app' +import { getRedirection } from '@/utils/app-redirection' +import CreateAppModal from './index' + +vi.mock('ahooks', () => ({ + useDebounceFn: (fn: (...args: any[]) => any) => { + const run = (...args: any[]) => fn(...args) + const cancel = vi.fn() + const flush = vi.fn() + return { run, cancel, flush } + }, + useKeyPress: vi.fn(), + useHover: () => false, +})) +vi.mock('next/navigation', () => ({ + useRouter: vi.fn(), +})) +vi.mock('@/app/components/base/amplitude', () => ({ + trackEvent: vi.fn(), +})) +vi.mock('@/service/apps', () => ({ + createApp: vi.fn(), +})) +vi.mock('@/utils/app-redirection', () => ({ + getRedirection: vi.fn(), +})) +vi.mock('@/context/provider-context', () => ({ + useProviderContext: vi.fn(), +})) +vi.mock('@/context/app-context', () => ({ + useAppContext: vi.fn(), +})) +vi.mock('@/context/i18n', () => ({ + useDocLink: () => () => '/guides', +})) +vi.mock('@/hooks/use-theme', () => ({ + __esModule: true, + default: () => ({ theme: 'light' }), +})) + +const mockNotify = vi.fn() +const mockUseRouter = vi.mocked(useRouter) +const mockPush = vi.fn() +const mockCreateApp = vi.mocked(createApp) +const mockTrackEvent = vi.mocked(trackEvent) +const mockGetRedirection = vi.mocked(getRedirection) +const mockUseProviderContext = vi.mocked(useProviderContext) +const mockUseAppContext = vi.mocked(useAppContext) + +const defaultPlanUsage = { + buildApps: 0, + teamMembers: 0, + annotatedResponse: 0, + documentsUploadQuota: 0, + apiRateLimit: 0, + triggerEvents: 0, + vectorSpace: 0, +} + +const renderModal = () => { + const onClose = vi.fn() + const onSuccess = vi.fn() + render( + + + , + ) + return { onClose, onSuccess } +} + +describe('CreateAppModal', () => { + const mockSetItem = vi.fn() + const originalLocalStorage = window.localStorage + + beforeEach(() => { + vi.clearAllMocks() + mockUseRouter.mockReturnValue({ push: mockPush } as any) + mockUseProviderContext.mockReturnValue({ + plan: { + type: AppModeEnum.ADVANCED_CHAT, + usage: defaultPlanUsage, + total: { ...defaultPlanUsage, buildApps: 1 }, + reset: {}, + }, + enableBilling: true, + } as any) + mockUseAppContext.mockReturnValue({ + isCurrentWorkspaceEditor: true, + } as any) + mockSetItem.mockClear() + Object.defineProperty(window, 'localStorage', { + value: { + setItem: mockSetItem, + getItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + key: vi.fn(), + length: 0, + }, + writable: true, + }) + }) + + afterAll(() => { + Object.defineProperty(window, 'localStorage', { + value: originalLocalStorage, + writable: true, + }) + }) + + it('creates an app, notifies success, and fires callbacks', async () => { + const mockApp = { id: 'app-1', mode: AppModeEnum.ADVANCED_CHAT } + mockCreateApp.mockResolvedValue(mockApp as any) + const { onClose, onSuccess } = renderModal() + + const nameInput = screen.getByPlaceholderText('app.newApp.appNamePlaceholder') + fireEvent.change(nameInput, { target: { value: 'My App' } }) + fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Create' })) + + await waitFor(() => expect(mockCreateApp).toHaveBeenCalledWith({ + name: 'My App', + description: '', + icon_type: 'emoji', + icon: '🤖', + icon_background: '#FFEAD5', + mode: AppModeEnum.ADVANCED_CHAT, + })) + + expect(mockTrackEvent).toHaveBeenCalledWith('create_app', { + app_mode: AppModeEnum.ADVANCED_CHAT, + description: '', + }) + expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'app.newApp.appCreated' }) + expect(onSuccess).toHaveBeenCalled() + expect(onClose).toHaveBeenCalled() + await waitFor(() => expect(mockSetItem).toHaveBeenCalledWith(NEED_REFRESH_APP_LIST_KEY, '1')) + await waitFor(() => expect(mockGetRedirection).toHaveBeenCalledWith(true, mockApp, mockPush)) + }) + + it('shows error toast when creation fails', async () => { + mockCreateApp.mockRejectedValue(new Error('boom')) + const { onClose } = renderModal() + + const nameInput = screen.getByPlaceholderText('app.newApp.appNamePlaceholder') + fireEvent.change(nameInput, { target: { value: 'My App' } }) + fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Create' })) + + await waitFor(() => expect(mockCreateApp).toHaveBeenCalled()) + expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'boom' }) + expect(onClose).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/app/overview/embedded/index.spec.tsx b/web/app/components/app/overview/embedded/index.spec.tsx new file mode 100644 index 0000000000..36f2e980c4 --- /dev/null +++ b/web/app/components/app/overview/embedded/index.spec.tsx @@ -0,0 +1,121 @@ +import type { SiteInfo } from '@/models/share' +import { fireEvent, render, screen } from '@testing-library/react' +import copy from 'copy-to-clipboard' +import * as React from 'react' + +import { act } from 'react' +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' +import Embedded from './index' + +vi.mock('./style.module.css', () => ({ + __esModule: true, + default: { + option: 'option', + active: 'active', + iframeIcon: 'iframeIcon', + scriptsIcon: 'scriptsIcon', + chromePluginIcon: 'chromePluginIcon', + pluginInstallIcon: 'pluginInstallIcon', + }, +})) +const mockThemeBuilder = { + buildTheme: vi.fn(), + theme: { + primaryColor: '#123456', + }, +} +const mockUseAppContext = vi.fn(() => ({ + langGeniusVersionInfo: { + current_env: 'PRODUCTION', + current_version: '', + latest_version: '', + release_date: '', + release_notes: '', + version: '', + can_auto_update: false, + }, +})) + +vi.mock('copy-to-clipboard', () => ({ + __esModule: true, + default: vi.fn(), +})) +vi.mock('@/app/components/base/chat/embedded-chatbot/theme/theme-context', () => ({ + useThemeContext: () => mockThemeBuilder, +})) +vi.mock('@/context/app-context', () => ({ + useAppContext: () => mockUseAppContext(), +})) +const mockWindowOpen = vi.spyOn(window, 'open').mockImplementation(() => null) +const mockedCopy = vi.mocked(copy) + +const siteInfo: SiteInfo = { + title: 'test site', + chat_color_theme: '#000000', + chat_color_theme_inverted: false, +} + +const baseProps = { + isShow: true, + siteInfo, + onClose: vi.fn(), + appBaseUrl: 'https://app.example.com', + accessToken: 'token', + className: 'custom-modal', +} + +const getCopyButton = () => { + const buttons = screen.getAllByRole('button') + const actionButton = buttons.find(button => button.className.includes('action-btn')) + expect(actionButton).toBeDefined() + return actionButton! +} + +describe('Embedded', () => { + afterEach(() => { + vi.clearAllMocks() + mockWindowOpen.mockClear() + }) + + afterAll(() => { + mockWindowOpen.mockRestore() + }) + + it('builds theme and copies iframe snippet', async () => { + await act(async () => { + render() + }) + + const actionButton = getCopyButton() + const innerDiv = actionButton.querySelector('div') + act(() => { + fireEvent.click(innerDiv ?? actionButton) + }) + + expect(mockThemeBuilder.buildTheme).toHaveBeenCalledWith(siteInfo.chat_color_theme, siteInfo.chat_color_theme_inverted) + expect(mockedCopy).toHaveBeenCalledWith(expect.stringContaining('/chatbot/token')) + }) + + it('opens chrome plugin store link when chrome option selected', async () => { + await act(async () => { + render() + }) + + const optionButtons = document.body.querySelectorAll('[class*="option"]') + expect(optionButtons.length).toBeGreaterThanOrEqual(3) + act(() => { + fireEvent.click(optionButtons[2]) + }) + + const [chromeText] = screen.getAllByText('appOverview.overview.appInfo.embedded.chromePlugin') + act(() => { + fireEvent.click(chromeText) + }) + + expect(mockWindowOpen).toHaveBeenCalledWith( + 'https://chrome.google.com/webstore/detail/dify-chatbot/ceehdapohffmjmkdcifjofadiaoeggaf', + '_blank', + 'noopener,noreferrer', + ) + }) +}) diff --git a/web/app/components/app/text-generate/saved-items/index.spec.tsx b/web/app/components/app/text-generate/saved-items/index.spec.tsx new file mode 100644 index 0000000000..b83c812c19 --- /dev/null +++ b/web/app/components/app/text-generate/saved-items/index.spec.tsx @@ -0,0 +1,67 @@ +import type { ISavedItemsProps } from './index' +import { fireEvent, render, screen } from '@testing-library/react' +import copy from 'copy-to-clipboard' + +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Toast from '@/app/components/base/toast' +import SavedItems from './index' + +vi.mock('copy-to-clipboard', () => ({ + __esModule: true, + default: vi.fn(), +})) +vi.mock('next/navigation', () => ({ + useParams: () => ({}), + usePathname: () => '/', +})) + +const mockCopy = vi.mocked(copy) +const toastNotifySpy = vi.spyOn(Toast, 'notify') + +const baseProps: ISavedItemsProps = { + list: [ + { id: '1', answer: 'hello world' }, + ], + isShowTextToSpeech: true, + onRemove: vi.fn(), + onStartCreateContent: vi.fn(), +} + +describe('SavedItems', () => { + beforeEach(() => { + vi.clearAllMocks() + toastNotifySpy.mockClear() + }) + + it('renders saved answers with metadata and controls', () => { + const { container } = render() + + const markdownElement = container.querySelector('.markdown-body') + expect(markdownElement).toBeInTheDocument() + expect(screen.getByText('11 common.unit.char')).toBeInTheDocument() + + const actionArea = container.querySelector('[class*="bg-components-actionbar-bg"]') + const actionButtons = actionArea?.querySelectorAll('button') ?? [] + expect(actionButtons.length).toBeGreaterThanOrEqual(3) + }) + + it('copies content and notifies, and triggers remove callback', () => { + const handleRemove = vi.fn() + const { container } = render() + + const actionArea = container.querySelector('[class*="bg-components-actionbar-bg"]') + const actionButtons = actionArea?.querySelectorAll('button') ?? [] + expect(actionButtons.length).toBeGreaterThanOrEqual(3) + + const copyButton = actionButtons[1] + const deleteButton = actionButtons[2] + + fireEvent.click(copyButton) + expect(mockCopy).toHaveBeenCalledWith('hello world') + expect(toastNotifySpy).toHaveBeenCalledWith({ type: 'success', message: 'common.actionMsg.copySuccessfully' }) + + fireEvent.click(deleteButton) + expect(handleRemove).toHaveBeenCalledWith('1') + }) +}) diff --git a/web/app/components/app/text-generate/saved-items/no-data/index.spec.tsx b/web/app/components/app/text-generate/saved-items/no-data/index.spec.tsx new file mode 100644 index 0000000000..59b950054c --- /dev/null +++ b/web/app/components/app/text-generate/saved-items/no-data/index.spec.tsx @@ -0,0 +1,22 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import NoData from './index' + +describe('NoData', () => { + it('renders title/description and calls callback when button clicked', () => { + const handleStart = vi.fn() + render() + + const title = screen.getByText('share.generation.savedNoData.title') + const description = screen.getByText('share.generation.savedNoData.description') + const button = screen.getByRole('button', { name: 'share.generation.savedNoData.startCreateContent' }) + + expect(title).toBeInTheDocument() + expect(description).toBeInTheDocument() + expect(button).toBeInTheDocument() + + fireEvent.click(button) + expect(handleStart).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/custom/custom-web-app-brand/index.spec.tsx b/web/app/components/custom/custom-web-app-brand/index.spec.tsx new file mode 100644 index 0000000000..e50ca4e9b2 --- /dev/null +++ b/web/app/components/custom/custom-web-app-brand/index.spec.tsx @@ -0,0 +1,147 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils' +import { useToastContext } from '@/app/components/base/toast' +import { Plan } from '@/app/components/billing/type' +import { useAppContext } from '@/context/app-context' +import { useGlobalPublicStore } from '@/context/global-public-context' +import { useProviderContext } from '@/context/provider-context' +import { updateCurrentWorkspace } from '@/service/common' +import CustomWebAppBrand from './index' + +vi.mock('@/app/components/base/toast', () => ({ + useToastContext: vi.fn(), +})) +vi.mock('@/service/common', () => ({ + updateCurrentWorkspace: vi.fn(), +})) +vi.mock('@/context/app-context', () => ({ + useAppContext: vi.fn(), +})) +vi.mock('@/context/provider-context', () => ({ + useProviderContext: vi.fn(), +})) +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: vi.fn(), +})) +vi.mock('@/app/components/base/image-uploader/utils', () => ({ + imageUpload: vi.fn(), + getImageUploadErrorMessage: vi.fn(), +})) + +const mockNotify = vi.fn() +const mockUseToastContext = vi.mocked(useToastContext) +const mockUpdateCurrentWorkspace = vi.mocked(updateCurrentWorkspace) +const mockUseAppContext = vi.mocked(useAppContext) +const mockUseProviderContext = vi.mocked(useProviderContext) +const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore) +const mockImageUpload = vi.mocked(imageUpload) +const mockGetImageUploadErrorMessage = vi.mocked(getImageUploadErrorMessage) + +const defaultPlanUsage = { + buildApps: 0, + teamMembers: 0, + annotatedResponse: 0, + documentsUploadQuota: 0, + apiRateLimit: 0, + triggerEvents: 0, + vectorSpace: 0, +} + +const renderComponent = () => render() + +describe('CustomWebAppBrand', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseToastContext.mockReturnValue({ notify: mockNotify } as any) + mockUpdateCurrentWorkspace.mockResolvedValue({} as any) + mockUseAppContext.mockReturnValue({ + currentWorkspace: { + custom_config: { + replace_webapp_logo: 'https://example.com/replace.png', + remove_webapp_brand: false, + }, + }, + mutateCurrentWorkspace: vi.fn(), + isCurrentWorkspaceManager: true, + } as any) + mockUseProviderContext.mockReturnValue({ + plan: { + type: Plan.professional, + usage: defaultPlanUsage, + total: defaultPlanUsage, + reset: {}, + }, + enableBilling: false, + } as any) + const systemFeaturesState = { + branding: { + enabled: true, + workspace_logo: 'https://example.com/workspace-logo.png', + }, + } + mockUseGlobalPublicStore.mockImplementation(selector => selector ? selector({ systemFeatures: systemFeaturesState } as any) : { systemFeatures: systemFeaturesState }) + mockGetImageUploadErrorMessage.mockReturnValue('upload error') + }) + + it('disables upload controls when the user cannot manage the workspace', () => { + mockUseAppContext.mockReturnValue({ + currentWorkspace: { + custom_config: { + replace_webapp_logo: '', + remove_webapp_brand: false, + }, + }, + mutateCurrentWorkspace: vi.fn(), + isCurrentWorkspaceManager: false, + } as any) + + const { container } = renderComponent() + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement + expect(fileInput).toBeDisabled() + }) + + it('toggles remove brand switch and calls the backend + mutate', async () => { + const mutateMock = vi.fn() + mockUseAppContext.mockReturnValue({ + currentWorkspace: { + custom_config: { + replace_webapp_logo: '', + remove_webapp_brand: false, + }, + }, + mutateCurrentWorkspace: mutateMock, + isCurrentWorkspaceManager: true, + } as any) + + renderComponent() + const switchInput = screen.getByRole('switch') + fireEvent.click(switchInput) + + await waitFor(() => expect(mockUpdateCurrentWorkspace).toHaveBeenCalledWith({ + url: '/workspaces/custom-config', + body: { remove_webapp_brand: true }, + })) + await waitFor(() => expect(mutateMock).toHaveBeenCalled()) + }) + + it('shows cancel/apply buttons after successful upload and cancels properly', async () => { + mockImageUpload.mockImplementation(({ onProgressCallback, onSuccessCallback }) => { + onProgressCallback(50) + onSuccessCallback({ id: 'new-logo' }) + }) + + const { container } = renderComponent() + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement + const testFile = new File(['content'], 'logo.png', { type: 'image/png' }) + fireEvent.change(fileInput, { target: { files: [testFile] } }) + + await waitFor(() => expect(mockImageUpload).toHaveBeenCalled()) + await waitFor(() => screen.getByRole('button', { name: 'custom.apply' })) + + const cancelButton = screen.getByRole('button', { name: 'common.operation.cancel' }) + fireEvent.click(cancelButton) + + await waitFor(() => expect(screen.queryByRole('button', { name: 'custom.apply' })).toBeNull()) + }) +})