@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.
541 lines (444 loc) • 18.4 kB
text/typescript
import * as lobeUIModules from '@lobehub/ui';
import { act, renderHook, waitFor } from '@testing-library/react';
import { mutate } from 'swr';
import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TraceEventType } from '@/const/trace';
import { messageService } from '@/services/message';
import { topicService } from '@/services/topic';
import { messageMapKey } from '@/store/chat/utils/messageMapKey';
import { ChatMessage } from '@/types/message';
import { useChatStore } from '../../store';
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve(new Response('mock'))),
);
vi.mock('zustand/traditional');
// Mock service
vi.mock('@/services/message', () => ({
messageService: {
getMessages: vi.fn(),
updateMessageError: vi.fn(),
removeMessage: vi.fn(),
removeMessagesByAssistant: vi.fn(),
removeMessages: vi.fn(() => Promise.resolve()),
createMessage: vi.fn(() => Promise.resolve('new-message-id')),
updateMessage: vi.fn(),
removeAllMessages: vi.fn(() => Promise.resolve()),
},
}));
vi.mock('@/services/topic', () => ({
topicService: {
createTopic: vi.fn(() => Promise.resolve()),
removeTopic: vi.fn(() => Promise.resolve()),
},
}));
const realRefreshMessages = useChatStore.getState().refreshMessages;
// Mock state
const mockState = {
activeId: 'session-id',
activeTopicId: 'topic-id',
messages: [],
refreshMessages: vi.fn(),
refreshTopic: vi.fn(),
internal_coreProcessMessage: vi.fn(),
saveToTopic: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
useChatStore.setState(mockState, false);
});
afterEach(() => {
process.env.NEXT_PUBLIC_BASE_PATH = undefined;
vi.restoreAllMocks();
});
describe('chatMessage actions', () => {
describe('addAIMessage', () => {
it('should return early if activeId is undefined', async () => {
useChatStore.setState({ activeId: undefined });
const { result } = renderHook(() => useChatStore());
const updateInputMessageSpy = vi.spyOn(result.current, 'updateInputMessage');
await act(async () => {
await result.current.addAIMessage();
});
expect(messageService.createMessage).not.toHaveBeenCalled();
expect(updateInputMessageSpy).not.toHaveBeenCalled();
});
it('should call internal_createMessage with correct parameters', async () => {
const inputMessage = 'Test input message';
useChatStore.setState({ inputMessage });
const { result } = renderHook(() => useChatStore());
await act(async () => {
await result.current.addAIMessage();
});
expect(messageService.createMessage).toHaveBeenCalledWith({
content: inputMessage,
role: 'assistant',
sessionId: mockState.activeId,
topicId: mockState.activeTopicId,
});
});
it('should call updateInputMessage with empty string', async () => {
const { result } = renderHook(() => useChatStore());
const updateInputMessageSpy = vi.spyOn(result.current, 'updateInputMessage');
await act(async () => {
await result.current.addAIMessage();
});
expect(updateInputMessageSpy).toHaveBeenCalledWith('');
});
});
describe('deleteMessage', () => {
it('deleteMessage should remove a message by id', async () => {
const { result } = renderHook(() => useChatStore());
const messageId = 'message-id';
const deleteSpy = vi.spyOn(result.current, 'deleteMessage');
act(() => {
useChatStore.setState({
activeId: 'session-id',
activeTopicId: undefined,
messagesMap: {
[messageMapKey('session-id')]: [{ id: messageId } as ChatMessage],
},
});
});
await act(async () => {
await result.current.deleteMessage(messageId);
});
expect(deleteSpy).toHaveBeenCalledWith(messageId);
expect(result.current.refreshMessages).toHaveBeenCalled();
});
it('deleteMessage should remove messages with tools', async () => {
const { result } = renderHook(() => useChatStore());
const messageId = 'message-id';
const removeMessagesSpy = vi.spyOn(messageService, 'removeMessages');
act(() => {
useChatStore.setState({
activeId: 'session-id',
activeTopicId: undefined,
messagesMap: {
[messageMapKey('session-id')]: [
{ id: messageId, tools: [{ id: 'tool1' }, { id: 'tool2' }] } as ChatMessage,
{ id: '2', tool_call_id: 'tool1', role: 'tool' } as ChatMessage,
{ id: '3', tool_call_id: 'tool2', role: 'tool' } as ChatMessage,
],
},
});
});
await act(async () => {
await result.current.deleteMessage(messageId);
});
expect(removeMessagesSpy).toHaveBeenCalledWith([messageId, '2', '3']);
expect(result.current.refreshMessages).toHaveBeenCalled();
});
});
describe('copyMessage', () => {
it('should call copyToClipboard with correct content', async () => {
const messageId = 'message-id';
const content = 'Test content';
const { result } = renderHook(() => useChatStore());
const copyToClipboardSpy = vi.spyOn(lobeUIModules, 'copyToClipboard');
await act(async () => {
await result.current.copyMessage(messageId, content);
});
expect(copyToClipboardSpy).toHaveBeenCalledWith(content);
});
it('should call internal_traceMessage with correct parameters', async () => {
const messageId = 'message-id';
const content = 'Test content';
const { result } = renderHook(() => useChatStore());
const internal_traceMessageSpy = vi.spyOn(result.current, 'internal_traceMessage');
await act(async () => {
await result.current.copyMessage(messageId, content);
});
expect(internal_traceMessageSpy).toHaveBeenCalledWith(messageId, {
eventType: TraceEventType.CopyMessage,
});
});
});
describe('deleteToolMessage', () => {
it('deleteMessage should remove a message by id', async () => {
const { result } = renderHook(() => useChatStore());
const messageId = 'message-id';
const updateMessageSpy = vi.spyOn(messageService, 'updateMessage');
const removeMessageSpy = vi.spyOn(messageService, 'removeMessage');
act(() => {
useChatStore.setState({
activeId: 'session-id',
activeTopicId: undefined,
messagesMap: {
[messageMapKey('session-id')]: [
{
id: messageId,
role: 'assistant',
tools: [{ id: 'tool1' }, { id: 'tool2' }],
} as ChatMessage,
{ id: '2', parentId: messageId, tool_call_id: 'tool1', role: 'tool' } as ChatMessage,
{ id: '3', tool_call_id: 'tool2', role: 'tool' } as ChatMessage,
],
},
});
});
await act(async () => {
await result.current.deleteToolMessage('2');
});
expect(removeMessageSpy).toHaveBeenCalled();
expect(updateMessageSpy).toHaveBeenCalledWith('message-id', {
tools: [{ id: 'tool2' }],
});
expect(result.current.refreshMessages).toHaveBeenCalled();
});
});
describe('clearAllMessages', () => {
it('clearAllMessages should remove all messages', async () => {
const { result } = renderHook(() => useChatStore());
const clearAllSpy = vi.spyOn(result.current, 'clearAllMessages');
await act(async () => {
await result.current.clearAllMessages();
});
expect(clearAllSpy).toHaveBeenCalled();
expect(result.current.refreshMessages).toHaveBeenCalled();
});
});
describe('updateInputMessage', () => {
it('updateInputMessage should update the input message state', () => {
const { result } = renderHook(() => useChatStore());
const newInputMessage = 'Updated message';
act(() => {
result.current.updateInputMessage(newInputMessage);
});
expect(result.current.inputMessage).toEqual(newInputMessage);
});
it('should not update state if message is the same as current inputMessage', () => {
const inputMessage = 'Test input message';
useChatStore.setState({ inputMessage });
const { result } = renderHook(() => useChatStore());
act(() => {
result.current.updateInputMessage(inputMessage);
});
expect(result.current.inputMessage).toBe(inputMessage);
});
});
describe('clearMessage', () => {
beforeEach(() => {
vi.clearAllMocks(); // 清除 mocks
useChatStore.setState(mockState, false); // 重置 state
});
afterEach(() => {
vi.restoreAllMocks(); // 恢复所有模拟
});
it('clearMessage should remove messages from the active session and topic', async () => {
const { result } = renderHook(() => useChatStore());
const clearSpy = vi.spyOn(result.current, 'clearMessage');
const switchTopicSpy = vi.spyOn(result.current, 'switchTopic');
await act(async () => {
await result.current.clearMessage();
});
expect(clearSpy).toHaveBeenCalled();
expect(result.current.refreshMessages).toHaveBeenCalled();
expect(result.current.refreshTopic).toHaveBeenCalled();
expect(switchTopicSpy).toHaveBeenCalled();
});
it('should remove messages from the active session and topic, then refresh topics and messages', async () => {
const { result } = renderHook(() => useChatStore());
const switchTopicSpy = vi.spyOn(result.current, 'switchTopic');
const refreshTopicSpy = vi.spyOn(result.current, 'refreshTopic');
await act(async () => {
await result.current.clearMessage();
});
expect(mockState.refreshMessages).toHaveBeenCalled();
expect(refreshTopicSpy).toHaveBeenCalled();
expect(switchTopicSpy).toHaveBeenCalled();
// 检查 activeTopicId 是否被清除,需要在状态更新后进行检查
expect(useChatStore.getState().activeTopicId).toBeNull();
});
it('should call removeTopic if there is an activeTopicId', async () => {
const { result } = renderHook(() => useChatStore());
const switchTopicSpy = vi.spyOn(result.current, 'switchTopic');
const refreshTopicSpy = vi.spyOn(result.current, 'refreshTopic');
await act(async () => {
await result.current.clearMessage();
});
expect(mockState.activeTopicId).not.toBeUndefined(); // 确保在测试前 activeTopicId 存在
expect(refreshTopicSpy).toHaveBeenCalled();
expect(mockState.refreshMessages).toHaveBeenCalled();
expect(topicService.removeTopic).toHaveBeenCalledWith(mockState.activeTopicId);
expect(switchTopicSpy).toHaveBeenCalled();
});
});
describe('toggleMessageEditing ', () => {
it('should add message id to messageEditingIds when editing is true', () => {
const { result } = renderHook(() => useChatStore());
const messageId = 'message-id';
act(() => {
result.current.toggleMessageEditing(messageId, true);
});
expect(result.current.messageEditingIds).toContain(messageId);
});
it('should remove message id from messageEditingIds when editing is false', () => {
const { result } = renderHook(() => useChatStore());
const messageId = 'abc';
act(() => {
result.current.toggleMessageEditing(messageId, true);
result.current.toggleMessageEditing(messageId, false);
});
expect(result.current.messageEditingIds).not.toContain(messageId);
});
it('should update messageEditingIds correctly when enabling editing', () => {
const messageId = 'message-id';
const { result } = renderHook(() => useChatStore());
act(() => {
result.current.toggleMessageEditing(messageId, true);
});
expect(result.current.messageEditingIds).toContain(messageId);
});
it('should update messageEditingIds correctly when disabling editing', () => {
const messageId = 'message-id';
useChatStore.setState({ messageEditingIds: [messageId] });
const { result } = renderHook(() => useChatStore());
act(() => {
result.current.toggleMessageEditing(messageId, false);
});
expect(result.current.messageEditingIds).not.toContain(messageId);
});
});
describe('internal_updateMessageContent', () => {
it('should call messageService.internal_updateMessageContent with correct parameters', async () => {
const { result } = renderHook(() => useChatStore());
const messageId = 'message-id';
const newContent = 'Updated content';
const spy = vi.spyOn(messageService, 'updateMessage');
await act(async () => {
await result.current.internal_updateMessageContent(messageId, newContent);
});
expect(spy).toHaveBeenCalledWith(messageId, { content: newContent });
});
it('should dispatch message update action', async () => {
const { result } = renderHook(() => useChatStore());
const messageId = 'message-id';
const newContent = 'Updated content';
const internal_dispatchMessageSpy = vi.spyOn(result.current, 'internal_dispatchMessage');
await act(async () => {
await result.current.internal_updateMessageContent(messageId, newContent);
});
expect(internal_dispatchMessageSpy).toHaveBeenCalledWith({
id: messageId,
type: 'updateMessage',
value: { content: newContent },
});
});
it('should refresh messages after updating content', async () => {
const { result } = renderHook(() => useChatStore());
const messageId = 'message-id';
const newContent = 'Updated content';
await act(async () => {
await result.current.internal_updateMessageContent(messageId, newContent);
});
expect(result.current.refreshMessages).toHaveBeenCalled();
});
});
describe('refreshMessages action', () => {
beforeEach(() => {
vi.mock('swr', async () => {
const actual = await vi.importActual('swr');
return {
...(actual as any),
mutate: vi.fn(),
};
});
});
afterEach(() => {
// 在每个测试用例开始前恢复到实际的 SWR 实现
vi.resetAllMocks();
});
it('should refresh messages by calling mutate with current activeId and activeTopicId', async () => {
useChatStore.setState({ refreshMessages: realRefreshMessages });
const { result } = renderHook(() => useChatStore());
const activeId = useChatStore.getState().activeId;
const activeTopicId = useChatStore.getState().activeTopicId;
// 在这里,我们不需要再次模拟 mutate,因为它已经在顶部被模拟了
await act(async () => {
await result.current.refreshMessages();
});
// 确保 mutate 调用了正确的参数
expect(mutate).toHaveBeenCalledWith(['SWR_USE_FETCH_MESSAGES', activeId, activeTopicId]);
});
it('should handle errors during refreshing messages', async () => {
useChatStore.setState({ refreshMessages: realRefreshMessages });
const { result } = renderHook(() => useChatStore());
// 设置模拟错误
(mutate as Mock).mockImplementation(() => {
throw new Error('Mutate error');
});
await act(async () => {
await expect(result.current.refreshMessages()).rejects.toThrow('Mutate error');
});
// 确保恢复 mutate 的模拟,以免影响其他测试
(mutate as Mock).mockReset();
});
});
describe('useFetchMessages hook', () => {
// beforeEach(() => {
// vi.mocked(useSWR).mockRestore();
// });
it('should fetch messages for given session and topic ids', async () => {
const sessionId = 'session-id';
const topicId = 'topic-id';
const messages = [{ id: 'message-id', content: 'Hello' }];
// 设置模拟返回值
(messageService.getMessages as Mock).mockResolvedValue(messages);
const { result } = renderHook(() =>
useChatStore().useFetchMessages(true, sessionId, topicId),
);
// 等待异步操作完成
await waitFor(() => {
expect(result.current.data).toEqual(messages);
});
});
});
describe('internal_toggleMessageLoading', () => {
it('should add message id to messageLoadingIds when loading is true', () => {
const { result } = renderHook(() => useChatStore());
const messageId = 'message-id';
act(() => {
result.current.internal_toggleMessageLoading(true, messageId);
});
expect(result.current.messageLoadingIds).toContain(messageId);
});
it('should remove message id from messageLoadingIds when loading is false', () => {
const { result } = renderHook(() => useChatStore());
const messageId = 'ddd-id';
act(() => {
result.current.internal_toggleMessageLoading(true, messageId);
result.current.internal_toggleMessageLoading(false, messageId);
});
expect(result.current.messageLoadingIds).not.toContain(messageId);
});
});
describe('modifyMessageContent', () => {
it('should call internal_traceMessage with correct parameters before updating', async () => {
const messageId = 'message-id';
const content = 'Updated content';
const { result } = renderHook(() => useChatStore());
const spy = vi.spyOn(result.current, 'internal_traceMessage');
await act(async () => {
await result.current.modifyMessageContent(messageId, content);
});
expect(spy).toHaveBeenCalledWith(messageId, {
eventType: TraceEventType.ModifyMessage,
nextContent: content,
});
});
it('should call internal_updateMessageContent with correct parameters', async () => {
const messageId = 'message-id';
const content = 'Updated content';
const { result } = renderHook(() => useChatStore());
const spy = vi.spyOn(result.current, 'internal_traceMessage');
await act(async () => {
await result.current.modifyMessageContent(messageId, content);
});
expect(spy).toHaveBeenCalledWith(messageId, {
eventType: 'Modify Message',
nextContent: 'Updated content',
});
});
});
});