diff --git a/web/app/components/base/chat/chat-with-history/hooks.spec.tsx b/web/app/components/base/chat/chat-with-history/hooks.spec.tsx new file mode 100644 index 0000000000..eaa743bfea --- /dev/null +++ b/web/app/components/base/chat/chat-with-history/hooks.spec.tsx @@ -0,0 +1,211 @@ +import type { ReactNode } from 'react' +import type { ChatConfig } from '../types' +import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, renderHook, waitFor } from '@testing-library/react' +import { + fetchChatList, + fetchConversations, + generationConversationName, +} from '@/service/share' +import { shareQueryKeys } from '@/service/use-share' +import { CONVERSATION_ID_INFO } from '../constants' +import { useChatWithHistory } from './hooks' + +const notifyMock = vi.fn() + +vi.mock('@/app/components/base/toast', () => ({ + useToastContext: () => ({ + notify: notifyMock, + }), +})) + +vi.mock('@/hooks/use-app-favicon', () => ({ + useAppFavicon: vi.fn(), +})) + +vi.mock('@/i18n-config/i18next-config', () => ({ + changeLanguage: vi.fn().mockResolvedValue(undefined), +})) + +const mockStoreState: { + appInfo: AppData | null + appMeta: AppMeta | null + appParams: ChatConfig | null +} = { + appInfo: null, + appMeta: null, + appParams: null, +} + +const useWebAppStoreMock = vi.fn((selector?: (state: typeof mockStoreState) => unknown) => { + return selector ? selector(mockStoreState) : mockStoreState +}) + +vi.mock('@/context/web-app-context', () => ({ + useWebAppStore: (selector?: (state: typeof mockStoreState) => unknown) => useWebAppStoreMock(selector), +})) + +vi.mock('../utils', async () => { + const actual = await vi.importActual('../utils') + return { + ...actual, + getProcessedSystemVariablesFromUrlParams: vi.fn().mockResolvedValue({ user_id: 'user-1' }), + getRawInputsFromUrlParams: vi.fn().mockResolvedValue({}), + getRawUserVariablesFromUrlParams: vi.fn().mockResolvedValue({}), + } +}) + +vi.mock('@/service/share', () => ({ + fetchChatList: vi.fn(), + fetchConversations: vi.fn(), + generationConversationName: vi.fn(), + fetchAppInfo: vi.fn(), + fetchAppMeta: vi.fn(), + fetchAppParams: vi.fn(), + getAppAccessModeByAppCode: vi.fn(), + delConversation: vi.fn(), + pinConversation: vi.fn(), + renameConversation: vi.fn(), + unpinConversation: vi.fn(), + updateFeedback: vi.fn(), +})) + +const mockFetchConversations = vi.mocked(fetchConversations) +const mockFetchChatList = vi.mocked(fetchChatList) +const mockGenerationConversationName = vi.mocked(generationConversationName) + +const createQueryClient = () => new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}) + +const createWrapper = (queryClient: QueryClient) => { + return ({ children }: { children: ReactNode }) => ( + {children} + ) +} + +const renderWithClient = (hook: () => T) => { + const queryClient = createQueryClient() + const wrapper = createWrapper(queryClient) + return { + queryClient, + ...renderHook(hook, { wrapper }), + } +} + +const createConversationItem = (overrides: Partial = {}): ConversationItem => ({ + id: 'conversation-1', + name: 'Conversation 1', + inputs: null, + introduction: '', + ...overrides, +}) + +const createConversationData = (overrides: Partial = {}): AppConversationData => ({ + data: [createConversationItem()], + has_more: false, + limit: 100, + ...overrides, +}) + +const setConversationIdInfo = (appId: string, conversationId: string) => { + const value = { + [appId]: { + 'user-1': conversationId, + 'DEFAULT': conversationId, + }, + } + localStorage.setItem(CONVERSATION_ID_INFO, JSON.stringify(value)) +} + +// Scenario: useChatWithHistory integrates share queries for conversations and chat list. +describe('useChatWithHistory', () => { + beforeEach(() => { + vi.clearAllMocks() + mockStoreState.appInfo = { + app_id: 'app-1', + custom_config: null, + site: { + title: 'Test App', + default_language: 'en-US', + }, + } + mockStoreState.appMeta = { + tool_icons: {}, + } + mockStoreState.appParams = null + setConversationIdInfo('app-1', 'conversation-1') + }) + + // Scenario: share query results populate conversation lists and trigger chat list fetch. + describe('Share queries', () => { + it('should load pinned, unpinned, and chat list data from share queries', async () => { + // Arrange + const pinnedData = createConversationData({ + data: [createConversationItem({ id: 'pinned-1', name: 'Pinned' })], + }) + const listData = createConversationData({ + data: [createConversationItem({ id: 'conversation-1', name: 'First' })], + }) + mockFetchConversations.mockImplementation(async (_isInstalledApp, _appId, _lastId, pinned) => { + return pinned ? pinnedData : listData + }) + mockFetchChatList.mockResolvedValue({ data: [] }) + + // Act + const { result } = renderWithClient(() => useChatWithHistory()) + + // Assert + await waitFor(() => { + expect(mockFetchConversations).toHaveBeenCalledWith(false, 'app-1', undefined, true, 100) + }) + await waitFor(() => { + expect(mockFetchConversations).toHaveBeenCalledWith(false, 'app-1', undefined, false, 100) + }) + await waitFor(() => { + expect(mockFetchChatList).toHaveBeenCalledWith('conversation-1', false, 'app-1') + }) + expect(result.current.pinnedConversationList).toEqual(pinnedData.data) + expect(result.current.conversationList).toEqual(listData.data) + }) + }) + + // Scenario: completion invalidates share caches and merges generated names. + describe('New conversation completion', () => { + it('should invalidate share conversations and apply generated name', async () => { + // Arrange + const listData = createConversationData({ + data: [createConversationItem({ id: 'conversation-1', name: 'First' })], + }) + const generatedConversation = createConversationItem({ + id: 'conversation-new', + name: 'Generated', + }) + mockFetchConversations.mockResolvedValue(listData) + mockFetchChatList.mockResolvedValue({ data: [] }) + mockGenerationConversationName.mockResolvedValue(generatedConversation) + + const { result, queryClient } = renderWithClient(() => useChatWithHistory()) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + // Act + act(() => { + result.current.handleNewConversationCompleted('conversation-new') + }) + + // Assert + await waitFor(() => { + expect(mockGenerationConversationName).toHaveBeenCalledWith(false, 'app-1', 'conversation-new') + }) + await waitFor(() => { + expect(result.current.conversationList[0]).toEqual(generatedConversation) + }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: shareQueryKeys.conversations }) + }) + }) +}) diff --git a/web/app/components/base/chat/embedded-chatbot/hooks.spec.tsx b/web/app/components/base/chat/embedded-chatbot/hooks.spec.tsx new file mode 100644 index 0000000000..e4001c2464 --- /dev/null +++ b/web/app/components/base/chat/embedded-chatbot/hooks.spec.tsx @@ -0,0 +1,197 @@ +import type { ReactNode } from 'react' +import type { ChatConfig } from '../types' +import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, renderHook, waitFor } from '@testing-library/react' +import { + fetchChatList, + fetchConversations, + generationConversationName, +} from '@/service/share' +import { shareQueryKeys } from '@/service/use-share' +import { useEmbeddedChatbot } from './hooks' + +const notifyMock = vi.fn() + +vi.mock('@/app/components/base/toast', () => ({ + useToastContext: () => ({ + notify: notifyMock, + }), +})) + +vi.mock('@/i18n-config/i18next-config', () => ({ + changeLanguage: vi.fn().mockResolvedValue(undefined), +})) + +const mockStoreState: { + appInfo: AppData | null + appMeta: AppMeta | null + appParams: ChatConfig | null + embeddedConversationId: string | null + embeddedUserId: string | null +} = { + appInfo: null, + appMeta: null, + appParams: null, + embeddedConversationId: null, + embeddedUserId: null, +} + +const useWebAppStoreMock = vi.fn((selector?: (state: typeof mockStoreState) => unknown) => { + return selector ? selector(mockStoreState) : mockStoreState +}) + +vi.mock('@/context/web-app-context', () => ({ + useWebAppStore: (selector?: (state: typeof mockStoreState) => unknown) => useWebAppStoreMock(selector), +})) + +vi.mock('../utils', async () => { + const actual = await vi.importActual('../utils') + return { + ...actual, + getProcessedInputsFromUrlParams: vi.fn().mockResolvedValue({}), + getProcessedSystemVariablesFromUrlParams: vi.fn().mockResolvedValue({}), + getProcessedUserVariablesFromUrlParams: vi.fn().mockResolvedValue({}), + } +}) + +vi.mock('@/service/share', () => ({ + fetchChatList: vi.fn(), + fetchConversations: vi.fn(), + generationConversationName: vi.fn(), + fetchAppInfo: vi.fn(), + fetchAppMeta: vi.fn(), + fetchAppParams: vi.fn(), + getAppAccessModeByAppCode: vi.fn(), + updateFeedback: vi.fn(), +})) + +const mockFetchConversations = vi.mocked(fetchConversations) +const mockFetchChatList = vi.mocked(fetchChatList) +const mockGenerationConversationName = vi.mocked(generationConversationName) + +const createQueryClient = () => new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}) + +const createWrapper = (queryClient: QueryClient) => { + return ({ children }: { children: ReactNode }) => ( + {children} + ) +} + +const renderWithClient = (hook: () => T) => { + const queryClient = createQueryClient() + const wrapper = createWrapper(queryClient) + return { + queryClient, + ...renderHook(hook, { wrapper }), + } +} + +const createConversationItem = (overrides: Partial = {}): ConversationItem => ({ + id: 'conversation-1', + name: 'Conversation 1', + inputs: null, + introduction: '', + ...overrides, +}) + +const createConversationData = (overrides: Partial = {}): AppConversationData => ({ + data: [createConversationItem()], + has_more: false, + limit: 100, + ...overrides, +}) + +// Scenario: useEmbeddedChatbot integrates share queries for conversations and chat list. +describe('useEmbeddedChatbot', () => { + beforeEach(() => { + vi.clearAllMocks() + mockStoreState.appInfo = { + app_id: 'app-1', + custom_config: null, + site: { + title: 'Test App', + default_language: 'en-US', + }, + } + mockStoreState.appMeta = { + tool_icons: {}, + } + mockStoreState.appParams = null + mockStoreState.embeddedConversationId = 'conversation-1' + mockStoreState.embeddedUserId = 'embedded-user-1' + }) + + // Scenario: share query results populate conversation lists and trigger chat list fetch. + describe('Share queries', () => { + it('should load pinned, unpinned, and chat list data from share queries', async () => { + // Arrange + const pinnedData = createConversationData({ + data: [createConversationItem({ id: 'pinned-1', name: 'Pinned' })], + }) + const listData = createConversationData({ + data: [createConversationItem({ id: 'conversation-1', name: 'First' })], + }) + mockFetchConversations.mockImplementation(async (_isInstalledApp, _appId, _lastId, pinned) => { + return pinned ? pinnedData : listData + }) + mockFetchChatList.mockResolvedValue({ data: [] }) + + // Act + const { result } = renderWithClient(() => useEmbeddedChatbot()) + + // Assert + await waitFor(() => { + expect(mockFetchConversations).toHaveBeenCalledWith(false, 'app-1', undefined, true, 100) + }) + await waitFor(() => { + expect(mockFetchConversations).toHaveBeenCalledWith(false, 'app-1', undefined, false, 100) + }) + await waitFor(() => { + expect(mockFetchChatList).toHaveBeenCalledWith('conversation-1', false, 'app-1') + }) + expect(result.current.pinnedConversationList).toEqual(pinnedData.data) + expect(result.current.conversationList).toEqual(listData.data) + }) + }) + + // Scenario: completion invalidates share caches and merges generated names. + describe('New conversation completion', () => { + it('should invalidate share conversations and apply generated name', async () => { + // Arrange + const listData = createConversationData({ + data: [createConversationItem({ id: 'conversation-1', name: 'First' })], + }) + const generatedConversation = createConversationItem({ + id: 'conversation-new', + name: 'Generated', + }) + mockFetchConversations.mockResolvedValue(listData) + mockFetchChatList.mockResolvedValue({ data: [] }) + mockGenerationConversationName.mockResolvedValue(generatedConversation) + + const { result, queryClient } = renderWithClient(() => useEmbeddedChatbot()) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + // Act + act(() => { + result.current.handleNewConversationCompleted('conversation-new') + }) + + // Assert + await waitFor(() => { + expect(mockGenerationConversationName).toHaveBeenCalledWith(false, 'app-1', 'conversation-new') + }) + await waitFor(() => { + expect(result.current.conversationList[0]).toEqual(generatedConversation) + }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: shareQueryKeys.conversations }) + }) + }) +}) diff --git a/web/service/use-share.spec.tsx b/web/service/use-share.spec.tsx new file mode 100644 index 0000000000..4f04392315 --- /dev/null +++ b/web/service/use-share.spec.tsx @@ -0,0 +1,228 @@ +import type { ReactNode } from 'react' +import type { AppConversationData, ConversationItem } from '@/models/share' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, renderHook, waitFor } from '@testing-library/react' +import { + fetchChatList, + fetchConversations, + generationConversationName, +} from './share' +import { + shareQueryKeys, + useInvalidateShareConversations, + useShareChatList, + useShareConversationName, + useShareConversations, +} from './use-share' + +vi.mock('./share', () => ({ + fetchChatList: vi.fn(), + fetchConversations: vi.fn(), + generationConversationName: vi.fn(), + fetchAppInfo: vi.fn(), + fetchAppMeta: vi.fn(), + fetchAppParams: vi.fn(), + getAppAccessModeByAppCode: vi.fn(), +})) + +const mockFetchConversations = vi.mocked(fetchConversations) +const mockFetchChatList = vi.mocked(fetchChatList) +const mockGenerationConversationName = vi.mocked(generationConversationName) + +const createQueryClient = () => new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}) + +const createWrapper = (queryClient: QueryClient) => { + return ({ children }: { children: ReactNode }) => ( + {children} + ) +} + +const renderShareHook = (hook: () => T) => { + const queryClient = createQueryClient() + const wrapper = createWrapper(queryClient) + return { + queryClient, + ...renderHook(hook, { wrapper }), + } +} + +const createConversationItem = (overrides: Partial = {}): ConversationItem => ({ + id: 'conversation-1', + name: 'Conversation 1', + inputs: null, + introduction: 'Intro', + ...overrides, +}) + +const createConversationData = (overrides: Partial = {}): AppConversationData => ({ + data: [createConversationItem()], + has_more: false, + limit: 20, + ...overrides, +}) + +// Scenario: share conversation list queries behave consistently with params and enablement. +describe('useShareConversations', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should fetch conversations when enabled for non-installed apps', async () => { + // Arrange + const params = { + isInstalledApp: false, + appId: undefined, + pinned: true, + limit: 50, + } + const response = createConversationData() + mockFetchConversations.mockResolvedValueOnce(response) + + // Act + const { result, queryClient } = renderShareHook(() => useShareConversations(params)) + + // Assert + await waitFor(() => { + expect(mockFetchConversations).toHaveBeenCalledWith(false, undefined, undefined, true, 50) + }) + await waitFor(() => { + expect(result.current.data).toEqual(response) + }) + expect(queryClient.getQueryCache().find({ queryKey: shareQueryKeys.conversationList(params) })).toBeDefined() + }) + + it('should not fetch conversations when installed app lacks appId', async () => { + // Arrange + const params = { + isInstalledApp: true, + appId: undefined, + } + + // Act + renderShareHook(() => useShareConversations(params)) + + // Assert + await waitFor(() => { + expect(mockFetchConversations).not.toHaveBeenCalled() + }) + }) +}) + +// Scenario: chat list queries respect conversation ID and app installation rules. +describe('useShareChatList', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should fetch chat list when conversationId is provided', async () => { + // Arrange + const params = { + conversationId: 'conversation-1', + isInstalledApp: true, + appId: 'app-1', + } + const response = { data: [] } + mockFetchChatList.mockResolvedValueOnce(response) + + // Act + const { result } = renderShareHook(() => useShareChatList(params)) + + // Assert + await waitFor(() => { + expect(mockFetchChatList).toHaveBeenCalledWith('conversation-1', true, 'app-1') + }) + await waitFor(() => { + expect(result.current.data).toEqual(response) + }) + }) + + it('should not fetch chat list when conversationId is empty', async () => { + // Arrange + const params = { + conversationId: '', + isInstalledApp: false, + appId: undefined, + } + + // Act + renderShareHook(() => useShareChatList(params)) + + // Assert + await waitFor(() => { + expect(mockFetchChatList).not.toHaveBeenCalled() + }) + }) +}) + +// Scenario: conversation name queries follow enabled flags and installation constraints. +describe('useShareConversationName', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should fetch conversation name when enabled and conversationId exists', async () => { + // Arrange + const params = { + conversationId: 'conversation-2', + isInstalledApp: false, + appId: undefined, + } + const response = createConversationItem({ id: 'conversation-2', name: 'Generated' }) + mockGenerationConversationName.mockResolvedValueOnce(response) + + // Act + const { result } = renderShareHook(() => useShareConversationName(params)) + + // Assert + await waitFor(() => { + expect(mockGenerationConversationName).toHaveBeenCalledWith(false, undefined, 'conversation-2') + }) + await waitFor(() => { + expect(result.current.data).toEqual(response) + }) + }) + + it('should not fetch conversation name when disabled via options', async () => { + // Arrange + const params = { + conversationId: 'conversation-3', + isInstalledApp: false, + appId: undefined, + } + + // Act + renderShareHook(() => useShareConversationName(params, { enabled: false })) + + // Assert + await waitFor(() => { + expect(mockGenerationConversationName).not.toHaveBeenCalled() + }) + }) +}) + +// Scenario: invalidation helper clears share conversation caches. +describe('useInvalidateShareConversations', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should invalidate share conversations query key when invoked', () => { + // Arrange + const { result, queryClient } = renderShareHook(() => useInvalidateShareConversations()) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + // Act + act(() => { + result.current() + }) + + // Assert + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: shareQueryKeys.conversations }) + }) +})