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 index eaa743bfea..32ef133453 100644 --- a/web/app/components/base/chat/chat-with-history/hooks.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/hooks.spec.tsx @@ -3,6 +3,7 @@ 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 { ToastProvider } from '@/app/components/base/toast' import { fetchChatList, fetchConversations, @@ -12,14 +13,6 @@ 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(), })) @@ -85,7 +78,9 @@ const createQueryClient = () => new QueryClient({ const createWrapper = (queryClient: QueryClient) => { return ({ children }: { children: ReactNode }) => ( - {children} + + {children} + ) } @@ -127,6 +122,7 @@ const setConversationIdInfo = (appId: string, conversationId: string) => { describe('useChatWithHistory', () => { beforeEach(() => { vi.clearAllMocks() + localStorage.removeItem(CONVERSATION_ID_INFO) mockStoreState.appInfo = { app_id: 'app-1', custom_config: null, @@ -142,6 +138,10 @@ describe('useChatWithHistory', () => { setConversationIdInfo('app-1', 'conversation-1') }) + afterEach(() => { + localStorage.removeItem(CONVERSATION_ID_INFO) + }) + // 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 () => { @@ -208,4 +208,63 @@ describe('useChatWithHistory', () => { expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: shareQueryKeys.conversations }) }) }) + + // Scenario: chat list queries stop when reload key is cleared. + describe('Chat list gating', () => { + it('should not refetch chat list when newConversationId matches current conversation', async () => { + // Arrange + const listData = createConversationData({ + data: [createConversationItem({ id: 'conversation-1', name: 'First' })], + }) + mockFetchConversations.mockResolvedValue(listData) + mockFetchChatList.mockResolvedValue({ data: [] }) + mockGenerationConversationName.mockResolvedValue(createConversationItem({ id: 'conversation-1' })) + + const { result } = renderWithClient(() => useChatWithHistory()) + + await waitFor(() => { + expect(mockFetchChatList).toHaveBeenCalledTimes(1) + }) + + // Act + act(() => { + result.current.handleNewConversationCompleted('conversation-1') + }) + + // Assert + await waitFor(() => { + expect(result.current.chatShouldReloadKey).toBe('') + }) + expect(mockFetchChatList).toHaveBeenCalledTimes(1) + }) + }) + + // Scenario: conversation id updates persist to localStorage. + describe('Conversation id persistence', () => { + it('should store new conversation id in localStorage after completion', async () => { + // Arrange + const listData = createConversationData({ + data: [createConversationItem({ id: 'conversation-1', name: 'First' })], + }) + mockFetchConversations.mockResolvedValue(listData) + mockFetchChatList.mockResolvedValue({ data: [] }) + mockGenerationConversationName.mockResolvedValue(createConversationItem({ id: 'conversation-new' })) + + const { result } = renderWithClient(() => useChatWithHistory()) + + // Act + act(() => { + result.current.handleNewConversationCompleted('conversation-new') + }) + + // Assert + await waitFor(() => { + const storedValue = localStorage.getItem(CONVERSATION_ID_INFO) + const parsed = storedValue ? JSON.parse(storedValue) : {} + const storedUserId = parsed['app-1']?.['user-1'] + const storedDefaultId = parsed['app-1']?.DEFAULT + expect([storedUserId, storedDefaultId]).toContain('conversation-new') + }) + }) + }) }) diff --git a/web/app/components/base/chat/embedded-chatbot/hooks.spec.tsx b/web/app/components/base/chat/embedded-chatbot/hooks.spec.tsx index e4001c2464..ca6a90c4d8 100644 --- a/web/app/components/base/chat/embedded-chatbot/hooks.spec.tsx +++ b/web/app/components/base/chat/embedded-chatbot/hooks.spec.tsx @@ -3,22 +3,16 @@ 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 { ToastProvider } from '@/app/components/base/toast' import { fetchChatList, fetchConversations, generationConversationName, } from '@/service/share' import { shareQueryKeys } from '@/service/use-share' +import { CONVERSATION_ID_INFO } from '../constants' 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), })) @@ -80,7 +74,9 @@ const createQueryClient = () => new QueryClient({ const createWrapper = (queryClient: QueryClient) => { return ({ children }: { children: ReactNode }) => ( - {children} + + {children} + ) } @@ -112,6 +108,7 @@ const createConversationData = (overrides: Partial = {}): A describe('useEmbeddedChatbot', () => { beforeEach(() => { vi.clearAllMocks() + localStorage.removeItem(CONVERSATION_ID_INFO) mockStoreState.appInfo = { app_id: 'app-1', custom_config: null, @@ -128,6 +125,10 @@ describe('useEmbeddedChatbot', () => { mockStoreState.embeddedUserId = 'embedded-user-1' }) + afterEach(() => { + localStorage.removeItem(CONVERSATION_ID_INFO) + }) + // 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 () => { @@ -194,4 +195,63 @@ describe('useEmbeddedChatbot', () => { expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: shareQueryKeys.conversations }) }) }) + + // Scenario: chat list queries stop when reload key is cleared. + describe('Chat list gating', () => { + it('should not refetch chat list when newConversationId matches current conversation', async () => { + // Arrange + const listData = createConversationData({ + data: [createConversationItem({ id: 'conversation-1', name: 'First' })], + }) + mockFetchConversations.mockResolvedValue(listData) + mockFetchChatList.mockResolvedValue({ data: [] }) + mockGenerationConversationName.mockResolvedValue(createConversationItem({ id: 'conversation-1' })) + + const { result } = renderWithClient(() => useEmbeddedChatbot()) + + await waitFor(() => { + expect(mockFetchChatList).toHaveBeenCalledTimes(1) + }) + + // Act + act(() => { + result.current.handleNewConversationCompleted('conversation-1') + }) + + // Assert + await waitFor(() => { + expect(result.current.chatShouldReloadKey).toBe('') + }) + expect(mockFetchChatList).toHaveBeenCalledTimes(1) + }) + }) + + // Scenario: conversation id updates persist to localStorage. + describe('Conversation id persistence', () => { + it('should store new conversation id in localStorage after completion', async () => { + // Arrange + const listData = createConversationData({ + data: [createConversationItem({ id: 'conversation-1', name: 'First' })], + }) + mockFetchConversations.mockResolvedValue(listData) + mockFetchChatList.mockResolvedValue({ data: [] }) + mockGenerationConversationName.mockResolvedValue(createConversationItem({ id: 'conversation-new' })) + + const { result } = renderWithClient(() => useEmbeddedChatbot()) + + // Act + act(() => { + result.current.handleNewConversationCompleted('conversation-new') + }) + + // Assert + await waitFor(() => { + const storedValue = localStorage.getItem(CONVERSATION_ID_INFO) + const parsed = storedValue ? JSON.parse(storedValue) : {} + const storedUserId = parsed['app-1']?.['embedded-user-1'] + const storedDefaultId = parsed['app-1']?.DEFAULT + expect([storedUserId, storedDefaultId]).toContain('conversation-new') + }) + }) + }) }) diff --git a/web/service/use-share.spec.tsx b/web/service/use-share.spec.tsx index 4f04392315..d0202ed140 100644 --- a/web/service/use-share.spec.tsx +++ b/web/service/use-share.spec.tsx @@ -105,12 +105,13 @@ describe('useShareConversations', () => { } // Act - renderShareHook(() => useShareConversations(params)) + const { result } = renderShareHook(() => useShareConversations(params)) // Assert await waitFor(() => { - expect(mockFetchConversations).not.toHaveBeenCalled() + expect(result.current.fetchStatus).toBe('idle') }) + expect(mockFetchConversations).not.toHaveBeenCalled() }) }) @@ -151,12 +152,13 @@ describe('useShareChatList', () => { } // Act - renderShareHook(() => useShareChatList(params)) + const { result } = renderShareHook(() => useShareChatList(params)) // Assert await waitFor(() => { - expect(mockFetchChatList).not.toHaveBeenCalled() + expect(result.current.fetchStatus).toBe('idle') }) + expect(mockFetchChatList).not.toHaveBeenCalled() }) }) @@ -197,12 +199,13 @@ describe('useShareConversationName', () => { } // Act - renderShareHook(() => useShareConversationName(params, { enabled: false })) + const { result } = renderShareHook(() => useShareConversationName(params, { enabled: false })) // Assert await waitFor(() => { - expect(mockGenerationConversationName).not.toHaveBeenCalled() + expect(result.current.fetchStatus).toBe('idle') }) + expect(mockGenerationConversationName).not.toHaveBeenCalled() }) })