add integration tests for refactor

This commit is contained in:
yyh 2025-12-27 12:48:41 +08:00
parent fd7f8fd1f0
commit 2365d237b3
No known key found for this signature in database
3 changed files with 636 additions and 0 deletions

View File

@ -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<typeof import('../utils')>('../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 }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}
const renderWithClient = <T,>(hook: () => T) => {
const queryClient = createQueryClient()
const wrapper = createWrapper(queryClient)
return {
queryClient,
...renderHook(hook, { wrapper }),
}
}
const createConversationItem = (overrides: Partial<ConversationItem> = {}): ConversationItem => ({
id: 'conversation-1',
name: 'Conversation 1',
inputs: null,
introduction: '',
...overrides,
})
const createConversationData = (overrides: Partial<AppConversationData> = {}): 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 })
})
})
})

View File

@ -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<typeof import('../utils')>('../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 }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}
const renderWithClient = <T,>(hook: () => T) => {
const queryClient = createQueryClient()
const wrapper = createWrapper(queryClient)
return {
queryClient,
...renderHook(hook, { wrapper }),
}
}
const createConversationItem = (overrides: Partial<ConversationItem> = {}): ConversationItem => ({
id: 'conversation-1',
name: 'Conversation 1',
inputs: null,
introduction: '',
...overrides,
})
const createConversationData = (overrides: Partial<AppConversationData> = {}): 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 })
})
})
})

View File

@ -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 }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}
const renderShareHook = <T,>(hook: () => T) => {
const queryClient = createQueryClient()
const wrapper = createWrapper(queryClient)
return {
queryClient,
...renderHook(hook, { wrapper }),
}
}
const createConversationItem = (overrides: Partial<ConversationItem> = {}): ConversationItem => ({
id: 'conversation-1',
name: 'Conversation 1',
inputs: null,
introduction: 'Intro',
...overrides,
})
const createConversationData = (overrides: Partial<AppConversationData> = {}): 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 })
})
})