@lobehub/chat
Version:
Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.
543 lines (456 loc) • 18.6 kB
text/typescript
import { act, renderHook, waitFor } from '@testing-library/react';
import { mutate } from 'swr';
import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { LOADING_FLAT } from '@/const/message';
import { chatService } from '@/services/chat';
import { messageService } from '@/services/message';
import { topicService } from '@/services/topic';
import { messageMapKey } from '@/store/chat/utils/messageMapKey';
import { ChatMessage } from '@/types/message';
import { ChatTopic } from '@/types/topic';
import { useChatStore } from '../../store';
vi.mock('zustand/traditional');
// Mock topicService 和 messageService
vi.mock('@/services/topic', () => ({
topicService: {
removeTopics: vi.fn(),
removeAllTopic: vi.fn(),
removeTopic: vi.fn(),
cloneTopic: vi.fn(),
createTopic: vi.fn(),
updateTopicFavorite: vi.fn(),
updateTopicTitle: vi.fn(),
updateTopic: vi.fn(),
batchRemoveTopics: vi.fn(),
getTopics: vi.fn(),
searchTopics: vi.fn(),
},
}));
vi.mock('@/services/message', () => ({
messageService: {
removeMessages: vi.fn(),
removeMessagesByAssistant: vi.fn(),
getMessages: vi.fn(),
},
}));
vi.mock('@/components/AntdStaticMethods', () => ({
message: {
loading: vi.fn(),
success: vi.fn(),
error: vi.fn(),
destroy: vi.fn(),
},
}));
vi.mock('i18next', () => ({
t: vi.fn((key, params) => (params.title ? key + '_' + params.title : key)),
}));
beforeEach(() => {
// Setup initial state and mocks before each test
vi.clearAllMocks();
useChatStore.setState(
{
activeId: undefined,
activeTopicId: undefined,
// ... initial state
},
false,
);
});
afterEach(() => {
// Cleanup mocks after each test
vi.restoreAllMocks();
});
describe('topic action', () => {
describe('openNewTopicOrSaveTopic', () => {
it('should call switchTopic if activeTopicId exists', async () => {
const { result } = renderHook(() => useChatStore());
await act(async () => {
useChatStore.setState({ activeTopicId: 'existing-topic-id' });
});
const switchTopicSpy = vi.spyOn(result.current, 'switchTopic');
await act(async () => {
result.current.openNewTopicOrSaveTopic();
});
expect(switchTopicSpy).toHaveBeenCalled();
});
it('should call saveToTopic if activeTopicId does not exist', async () => {
const { result } = renderHook(() => useChatStore());
await act(async () => {
useChatStore.setState({ activeTopicId: '' });
});
const saveToTopicSpy = vi.spyOn(result.current, 'saveToTopic');
await act(async () => {
await result.current.openNewTopicOrSaveTopic();
});
expect(saveToTopicSpy).toHaveBeenCalled();
});
});
describe('saveToTopic', () => {
it('should not create a topic if there are no messages', async () => {
const { result } = renderHook(() => useChatStore());
act(() => {
useChatStore.setState({
messagesMap: {
[messageMapKey('session')]: [],
},
activeId: 'session',
});
});
const createTopicSpy = vi.spyOn(topicService, 'createTopic');
const topicId = await result.current.saveToTopic();
expect(createTopicSpy).not.toHaveBeenCalled();
expect(topicId).toBeUndefined();
});
it('should create a topic and bind messages to it', async () => {
const { result } = renderHook(() => useChatStore());
const messages = [{ id: 'message1' }, { id: 'message2' }] as ChatMessage[];
act(() => {
useChatStore.setState({
messagesMap: {
[messageMapKey('session-id')]: messages,
},
activeId: 'session-id',
});
});
const createTopicSpy = vi
.spyOn(topicService, 'createTopic')
.mockResolvedValue('new-topic-id');
const topicId = await result.current.saveToTopic();
expect(createTopicSpy).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: 'session-id',
messages: messages.map((m) => m.id),
}),
);
expect(topicId).toEqual('new-topic-id');
});
});
describe('refreshTopic', () => {
beforeEach(() => {
vi.mock('swr', async () => {
const actual = await vi.importActual('swr');
return {
...(actual as any),
mutate: vi.fn(),
};
});
});
afterEach(() => {
// 在每个测试用例开始前恢复到实际的 SWR 实现
vi.resetAllMocks();
});
it('should call mutate to refresh topics', async () => {
const { result } = renderHook(() => useChatStore());
const activeId = 'test-session-id';
act(() => {
useChatStore.setState({ activeId });
});
// Mock the mutate function to resolve immediately
await act(async () => {
await result.current.refreshTopic();
});
// Check if mutate has been called with the active session ID
expect(mutate).toHaveBeenCalledWith(['SWR_USE_FETCH_TOPIC', activeId]);
});
it('should handle errors during refreshing topics', async () => {
const { result } = renderHook(() => useChatStore());
const activeId = 'test-session-id';
act(() => {
useChatStore.setState({ activeId });
});
// Mock the mutate function to throw an error
// 设置模拟错误
(mutate as Mock).mockImplementation(() => {
throw new Error('Mutate error');
});
await act(async () => {
await expect(result.current.refreshTopic()).rejects.toThrow('Mutate error');
});
// 确保恢复 mutate 的模拟,以免影响其他测试
(mutate as Mock).mockReset();
});
// Additional tests for refreshTopic can be added here...
});
describe('favoriteTopic', () => {
it('should update the favorite state of a topic and refresh topics', async () => {
const { result } = renderHook(() => useChatStore());
const topicId = 'topic-id';
const favState = true;
const updateFavoriteSpy = vi
.spyOn(topicService, 'updateTopic')
.mockResolvedValue({ success: 1 });
const refreshTopicSpy = vi.spyOn(result.current, 'refreshTopic');
await act(async () => {
await result.current.favoriteTopic(topicId, favState);
});
expect(updateFavoriteSpy).toHaveBeenCalledWith(topicId, { favorite: favState });
expect(refreshTopicSpy).toHaveBeenCalled();
});
});
describe('useFetchTopics', () => {
it('should fetch topics for a given session id', async () => {
const sessionId = 'test-session-id';
const topics = [{ id: 'topic-id', title: 'Test Topic' }];
// Mock the topicService.getTopics to resolve with topics array
(topicService.getTopics as Mock).mockResolvedValue(topics);
// Use the hook with the session id
const { result } = renderHook(() => useChatStore().useFetchTopics(true, sessionId));
// Wait for the hook to resolve and update the state
await waitFor(() => {
expect(result.current.data).toEqual(topics);
});
expect(useChatStore.getState().topicsInit).toBeTruthy();
expect(useChatStore.getState().topicMaps).toEqual({ [sessionId]: topics });
});
});
describe('useSearchTopics', () => {
it('should search topics with the given keywords', async () => {
const keywords = 'search-term';
const searchResults = [{ id: 'searched-topic-id', title: 'Searched Topic' }];
// Mock the topicService.searchTopics to resolve with search results
(topicService.searchTopics as Mock).mockResolvedValue(searchResults);
// Use the hook with the keywords
const { result } = renderHook(() => useChatStore().useSearchTopics(keywords));
// Wait for the hook to resolve and update the state
await waitFor(() => {
expect(result.current.data).toEqual(searchResults);
});
});
});
describe('updateTopicTitle', () => {
it('should call topicService.updateTitle with correct parameters and refresh the topic', async () => {
const topicId = 'topic-id';
const newTitle = 'Updated Topic Title';
// Mock the topicService.updateTitle to resolve immediately
const spyOn = vi.spyOn(topicService, 'updateTopic');
const { result } = renderHook(() => useChatStore());
const refreshTopicSpy = vi.spyOn(result.current, 'refreshTopic');
// Call the action with the topicId and newTitle
await act(async () => {
await result.current.updateTopicTitle(topicId, newTitle);
});
// Verify that the topicService.updateTitle was called with correct parameters
expect(spyOn).toHaveBeenCalledWith(topicId, {
title: 'Updated Topic Title',
});
// Verify that the refreshTopic was called to update the state
expect(refreshTopicSpy).toHaveBeenCalled();
});
});
describe('switchTopic', () => {
it('should update activeTopicId and call refreshMessages', async () => {
const topicId = 'topic-id';
const { result } = renderHook(() => useChatStore());
const refreshMessagesSpy = vi.spyOn(result.current, 'refreshMessages');
// Call the switchTopic action with the topicId
await act(async () => {
await result.current.switchTopic(topicId);
});
// Verify that the activeTopicId has been updated
expect(useChatStore.getState().activeTopicId).toBe(topicId);
// Verify that the refreshMessages was called to update the messages
expect(refreshMessagesSpy).toHaveBeenCalled();
});
});
describe('removeSessionTopics', () => {
it('should remove all topics from the current session and refresh the topic list', async () => {
const { result } = renderHook(() => useChatStore());
const activeId = 'test-session-id';
await act(async () => {
useChatStore.setState({ activeId });
});
const refreshTopicSpy = vi.spyOn(result.current, 'refreshTopic');
const switchTopicSpy = vi.spyOn(result.current, 'switchTopic');
await act(async () => {
await result.current.removeSessionTopics();
});
expect(topicService.removeTopics).toHaveBeenCalledWith(activeId);
expect(refreshTopicSpy).toHaveBeenCalled();
expect(switchTopicSpy).toHaveBeenCalled();
});
});
describe('removeAllTopics', () => {
it('should remove all topics and refresh the topic list', async () => {
const { result } = renderHook(() => useChatStore());
const refreshTopicSpy = vi.spyOn(result.current, 'refreshTopic');
await act(async () => {
await result.current.removeAllTopics();
});
expect(topicService.removeAllTopic).toHaveBeenCalled();
expect(refreshTopicSpy).toHaveBeenCalled();
});
});
describe('removeTopic', () => {
it('should remove a specific topic and its messages, then refresh the topic list', async () => {
const topicId = 'topic-1';
const { result } = renderHook(() => useChatStore());
const activeId = 'test-session-id';
await act(async () => {
useChatStore.setState({ activeId, activeTopicId: topicId });
});
const refreshTopicSpy = vi.spyOn(result.current, 'refreshTopic');
const switchTopicSpy = vi.spyOn(result.current, 'switchTopic');
await act(async () => {
await result.current.removeTopic(topicId);
});
expect(messageService.removeMessagesByAssistant).toHaveBeenCalledWith(activeId, topicId);
expect(topicService.removeTopic).toHaveBeenCalledWith(topicId);
expect(refreshTopicSpy).toHaveBeenCalled();
expect(switchTopicSpy).toHaveBeenCalled();
});
it('should remove a specific topic and its messages, then not refresh the topic list', async () => {
const topicId = 'topic-1';
const { result } = renderHook(() => useChatStore());
const activeId = 'test-session-id';
await act(async () => {
useChatStore.setState({ activeId });
});
const refreshTopicSpy = vi.spyOn(result.current, 'refreshTopic');
const switchTopicSpy = vi.spyOn(result.current, 'switchTopic');
await act(async () => {
await result.current.removeTopic(topicId);
});
expect(messageService.removeMessagesByAssistant).toHaveBeenCalledWith(activeId, topicId);
expect(topicService.removeTopic).toHaveBeenCalledWith(topicId);
expect(refreshTopicSpy).toHaveBeenCalled();
expect(switchTopicSpy).not.toHaveBeenCalled();
});
});
describe('removeUnstarredTopic', () => {
it('should remove unstarred topics and refresh the topic list', async () => {
const { result } = renderHook(() => useChatStore());
// Set up mock state with unstarred topics
await act(async () => {
useChatStore.setState({
activeId: 'abc',
topicMaps: {
abc: [
{ id: 'topic-1', favorite: false },
{ id: 'topic-2', favorite: true },
{ id: 'topic-3', favorite: false },
] as ChatTopic[],
},
});
});
const refreshTopicSpy = vi.spyOn(result.current, 'refreshTopic');
const switchTopicSpy = vi.spyOn(result.current, 'switchTopic');
await act(async () => {
await result.current.removeUnstarredTopic();
});
expect(topicService.batchRemoveTopics).toHaveBeenCalledWith(['topic-1', 'topic-3']);
expect(refreshTopicSpy).toHaveBeenCalled();
expect(switchTopicSpy).toHaveBeenCalled();
});
});
describe('updateTopicLoading', () => {
it('should call update topicLoadingId', async () => {
const { result } = renderHook(() => useChatStore());
act(() => {
useChatStore.setState({ topicLoadingIds: [] });
});
expect(result.current.topicLoadingIds).toHaveLength(0);
// Call the action with the topicId and newTitle
act(() => {
result.current.internal_updateTopicLoading('loading-id', true);
});
expect(result.current.topicLoadingIds).toEqual(['loading-id']);
});
});
describe('summaryTopicTitle', () => {
it('should auto-summarize the topic title and update it', async () => {
const topicId = 'topic-1';
const messages = [{ id: 'message-1', content: 'Hello' }] as ChatMessage[];
const topics = [{ id: 'topic-1', title: 'Test Topic' }] as ChatTopic[];
const { result } = renderHook(() => useChatStore());
await act(async () => {
useChatStore.setState({ topicMaps: { test: topics }, activeId: 'test' });
});
// Mock the `updateTopicTitleInSummary` and `refreshTopic` for spying
const updateTopicTitleInSummarySpy = vi.spyOn(
result.current,
'internal_updateTopicTitleInSummary',
);
const refreshTopicSpy = vi.spyOn(result.current, 'refreshTopic');
// Mock the `chatService.fetchPresetTaskResult` to simulate the AI response
vi.spyOn(chatService, 'fetchPresetTaskResult').mockImplementation((params) => {
if (params) {
params.onFinish?.('Summarized Title', { type: 'done' });
}
return Promise.resolve(undefined);
});
await act(async () => {
await result.current.summaryTopicTitle(topicId, messages);
});
// Verify that the title was updated and the topic was refreshed
expect(updateTopicTitleInSummarySpy).toHaveBeenCalledWith(topicId, LOADING_FLAT);
expect(refreshTopicSpy).toHaveBeenCalled();
// TODO: need to test with fetchPresetTaskResult
});
});
describe('createTopic', () => {
it('should create a new topic and update the store', async () => {
const { result } = renderHook(() => useChatStore());
const activeId = 'test-session-id';
const newTopicId = 'new-topic-id';
const messages = [{ id: 'message-1' }, { id: 'message-2' }] as ChatMessage[];
await act(async () => {
useChatStore.setState({
activeId,
messagesMap: {
[messageMapKey(activeId)]: messages,
},
});
});
const createTopicSpy = vi.spyOn(topicService, 'createTopic').mockResolvedValue(newTopicId);
const refreshTopicSpy = vi.spyOn(result.current, 'refreshTopic');
await act(async () => {
const topicId = await result.current.createTopic();
expect(topicId).toBe(newTopicId);
});
expect(createTopicSpy).toHaveBeenCalledWith({
sessionId: activeId,
messages: messages.map((m) => m.id),
title: 'defaultTitle',
});
expect(refreshTopicSpy).toHaveBeenCalled();
});
});
describe('duplicateTopic', () => {
it('should duplicate a topic and switch to the new topic', async () => {
const { result } = renderHook(() => useChatStore());
const topicId = 'topic-1';
const newTopicId = 'new-topic-id';
const topics = [{ id: topicId, title: 'Original Topic' }] as ChatTopic[];
await act(async () => {
useChatStore.setState({ activeId: 'abc', topicMaps: { abc: topics } });
});
const cloneTopicSpy = vi.spyOn(topicService, 'cloneTopic').mockResolvedValue(newTopicId);
const refreshTopicSpy = vi.spyOn(result.current, 'refreshTopic');
const switchTopicSpy = vi.spyOn(result.current, 'switchTopic');
await act(async () => {
await result.current.duplicateTopic(topicId);
});
expect(cloneTopicSpy).toHaveBeenCalledWith(topicId, 'duplicateTitle_Original Topic');
expect(refreshTopicSpy).toHaveBeenCalled();
expect(switchTopicSpy).toHaveBeenCalledWith(newTopicId);
});
});
describe('autoRenameTopicTitle', () => {
it('should auto-rename the topic title based on the messages', async () => {
const { result } = renderHook(() => useChatStore());
const topicId = 'topic-1';
const activeId = 'test-session-id';
const messages = [{ id: 'message-1', content: 'Hello' }] as ChatMessage[];
await act(async () => {
useChatStore.setState({ activeId });
});
const getMessagesSpy = vi.spyOn(messageService, 'getMessages').mockResolvedValue(messages);
const summaryTopicTitleSpy = vi.spyOn(result.current, 'summaryTopicTitle');
await act(async () => {
await result.current.autoRenameTopicTitle(topicId);
});
expect(getMessagesSpy).toHaveBeenCalledWith(activeId, topicId);
expect(summaryTopicTitleSpy).toHaveBeenCalledWith(topicId, messages);
});
});
});