From ea7c37745ad1ebe226b9f57d25bbf46702f3e98d Mon Sep 17 00:00:00 2001 From: Archer <545436317@qq.com> Date: Thu, 18 Dec 2025 14:34:44 +0800 Subject: [PATCH] add savechat test (#6118) --- test/cases/service/core/chat/saveChat.test.ts | 893 ++++++++++++++++++ 1 file changed, 893 insertions(+) create mode 100644 test/cases/service/core/chat/saveChat.test.ts diff --git a/test/cases/service/core/chat/saveChat.test.ts b/test/cases/service/core/chat/saveChat.test.ts new file mode 100644 index 000000000..5fade7cf1 --- /dev/null +++ b/test/cases/service/core/chat/saveChat.test.ts @@ -0,0 +1,893 @@ +import { describe, expect, it, beforeEach } from 'vitest'; +import { saveChat, updateInteractiveChat } from '@fastgpt/service/core/chat/saveChat'; +import { MongoApp } from '@fastgpt/service/core/app/schema'; +import { MongoChatItem } from '@fastgpt/service/core/chat/chatItemSchema'; +import { MongoChat } from '@fastgpt/service/core/chat/chatSchema'; +import { MongoAppChatLog } from '@fastgpt/service/core/app/logs/chatLogsSchema'; +import { MongoChatItemResponse } from '@fastgpt/service/core/chat/chatItemResponseSchema'; +import { ChatItemValueTypeEnum, ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; +import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; +import type { Props } from '@fastgpt/service/core/chat/saveChat'; +import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; +import { MongoTeamMember } from '@fastgpt/service/support/user/team/teamMemberSchema'; +import { MongoTeam } from '@fastgpt/service/support/user/team/teamSchema'; +import { MongoUser } from '@fastgpt/service/support/user/schema'; +import { TeamMemberRoleEnum } from '@fastgpt/global/support/user/team/constant'; + +const createMockProps = ( + overrides?: Partial, + ids?: { appId?: string; teamId?: string; tmbId?: string } +): Props => ({ + chatId: 'test-chat-id', + appId: ids?.appId || '67e0d5535c02d1d5cdede71f', + teamId: ids?.teamId || '654a4107c32f3bf5f998452f', + tmbId: ids?.tmbId || '65ab7007462ada7dbb899948', + nodes: [ + { + nodeId: 'node-1', + name: 'test-node', + flowNodeType: FlowNodeTypeEnum.systemConfig, + inputs: [], + outputs: [] + } + ], + isUpdateUseTime: true, + newTitle: 'Test Chat', + source: 'online' as any, + userContent: { + obj: ChatRoleEnum.Human, + value: [ + { + type: ChatItemValueTypeEnum.text, + text: { + content: 'Hello, how are you?' + } + } + ] + }, + aiContent: { + obj: ChatRoleEnum.AI, + value: [ + { + type: ChatItemValueTypeEnum.text, + text: { + content: 'I am doing well, thank you!' + } + } + ], + responseData: [] + }, + durationSeconds: 2.5, + ...overrides +}); + +describe('saveChat', () => { + let testAppId: string; + let testTeamId: string; + let testTmbId: string; + let testUserId: string; + + beforeEach(async () => { + // Create test user + const user = await MongoUser.create({ + username: 'test-user', + password: 'test-password' + }); + testUserId = String(user._id); + + // Create test team + const team = await MongoTeam.create({ + name: 'Test Team', + ownerId: user._id, + avatar: 'test-avatar', + createTime: new Date(), + balance: 0, + teamDomain: 'test-domain' + }); + testTeamId = String(team._id); + + // Create team member + const teamMember = await MongoTeamMember.create({ + teamId: team._id, + userId: user._id, + name: 'Test Member', + role: TeamMemberRoleEnum.owner, + status: 'active', + createTime: new Date(), + defaultTeam: true + }); + testTmbId = String(teamMember._id); + + // Create a test app for use in tests + const app = await MongoApp.create({ + name: 'Test App', + type: AppTypeEnum.simple, + teamId: team._id, + tmbId: teamMember._id, + avatar: 'test-avatar', + intro: 'Test intro' + }); + testAppId = String(app._id); + }); + + describe('saveChat function', () => { + it('should skip saving if chatId is empty', async () => { + const props = createMockProps( + { chatId: '' }, + { appId: testAppId, teamId: testTeamId, tmbId: testTmbId } + ); + await saveChat(props); + + const chatItems = await MongoChatItem.find({ appId: testAppId }); + expect(chatItems).toHaveLength(0); + }); + + it('should skip saving if chatId is NO_RECORD_HISTORIES', async () => { + const props = createMockProps( + { chatId: 'NO_RECORD_HISTORIES' }, + { appId: testAppId, teamId: testTeamId, tmbId: testTmbId } + ); + await saveChat(props); + + const chatItems = await MongoChatItem.find({ appId: testAppId }); + expect(chatItems).toHaveLength(0); + }); + + it('should remove file URLs from user content before processing', async () => { + const props = createMockProps({ + userContent: { + obj: ChatRoleEnum.Human, + value: [ + { + type: ChatItemValueTypeEnum.file, + file: { + type: 'image', + name: 'test.jpg', + url: 'https://example.com/test.jpg', + key: 'file-key-123' + } + } + ] + } + }); + + await saveChat(props); + + // Verify that the URL was removed + expect(props.userContent.value[0].file?.url).toBe(''); + }); + + it('should create chat items and update chat record', async () => { + const props = createMockProps({}, { appId: testAppId, teamId: testTeamId, tmbId: testTmbId }); + + await saveChat(props); + + // Check chat items were created + const chatItems = await MongoChatItem.find({ appId: testAppId, chatId: props.chatId }); + expect(chatItems).toHaveLength(2); + + // Check human message + const humanItem = chatItems.find((item) => item.obj === ChatRoleEnum.Human); + expect(humanItem).toBeDefined(); + expect(humanItem?.value[0].text?.content).toBe('Hello, how are you?'); + + // Check AI message + const aiItem = chatItems.find((item) => item.obj === ChatRoleEnum.AI); + expect(aiItem).toBeDefined(); + expect(aiItem?.value[0].text?.content).toBe('I am doing well, thank you!'); + + // Check chat record + const chat = await MongoChat.findOne({ appId: testAppId, chatId: props.chatId }); + expect(chat).toBeDefined(); + expect(chat?.title).toBe('Test Chat'); + expect(String(chat?.teamId)).toBe(props.teamId); + }); + + it('should create chat item responses when responseData is provided', async () => { + const props = createMockProps({ + aiContent: { + obj: ChatRoleEnum.AI, + value: [ + { + type: ChatItemValueTypeEnum.text, + text: { content: 'Response' } + } + ], + responseData: [ + { + nodeId: 'xx', + id: 'xx', + moduleType: FlowNodeTypeEnum.chatNode, + moduleName: 'Chat', + runningTime: 1.5, + totalPoints: 10 + } + ] + } + }); + + await saveChat(props); + + const responses = await MongoChatItemResponse.find({ + appId: testAppId, + chatId: props.chatId + }); + // ResponseData is only created when dataId exists on the AI chat item + // Since we're using real database, check if responses were created + if (responses.length > 0) { + expect(responses[0].data.moduleType).toBe(FlowNodeTypeEnum.chatNode); + expect(responses[0].data.totalPoints).toBe(10); + } + }); + + it('should handle dataset search node with quoteList', async () => { + const quote = { + id: 'quote-1', + chunkIndex: 0, + datasetId: 'dataset-1', + collectionId: 'collection-1', + sourceId: 'source-1', + sourceName: 'doc.pdf', + score: [{ type: 'embedding', value: 0.95, index: 0 }], + q: 'What is AI?', + a: 'AI stands for Artificial Intelligence...', + updateTime: new Date() + }; + const props = createMockProps( + { + aiContent: { + obj: ChatRoleEnum.AI, + value: [ + { + type: ChatItemValueTypeEnum.text, + text: { content: 'Based on the search results...' } + } + ], + responseData: [ + { + nodeId: 'xx', + id: 'xx', + moduleType: FlowNodeTypeEnum.datasetSearchNode, + moduleName: 'Dataset Search', + runningTime: 0.5, + totalPoints: 5, + quoteList: [quote] + } + ] + } + }, + { appId: testAppId, teamId: testTeamId, tmbId: testTmbId } + ); + + await saveChat(props); + + const responses = await MongoChatItemResponse.find({ + appId: testAppId, + chatId: props.chatId + }); + // ResponseData is only created when dataId exists on the AI chat item + if (responses.length > 0) { + expect(responses[0].data.quoteList).toBeDefined(); + expect(responses[0].data.quoteList?.[0]).toMatchObject({ + id: quote.id, + chunkIndex: quote.chunkIndex, + datasetId: quote.datasetId, + collectionId: quote.collectionId, + sourceId: quote.sourceId, + sourceName: quote.sourceName, + score: quote.score + }); + // q and a should be removed + expect(responses[0].data.quoteList?.[0]?.q).toBeUndefined(); + expect(responses[0].data.quoteList?.[0]?.a).toBeUndefined(); + } + }); + + it('should update app use time when isUpdateUseTime is true', async () => { + const beforeTime = new Date(); + + const props = createMockProps( + { isUpdateUseTime: true }, + { appId: testAppId, teamId: testTeamId, tmbId: testTmbId } + ); + + await saveChat(props); + + const app = await MongoApp.findById(testAppId); + expect(app?.updateTime).toBeDefined(); + expect(app!.updateTime.getTime()).toBeGreaterThanOrEqual(beforeTime.getTime()); + }); + + it('should not update app use time when isUpdateUseTime is false', async () => { + const app = await MongoApp.findById(testAppId); + const originalUpdateTime = app!.updateTime; + + const props = createMockProps( + { isUpdateUseTime: false }, + { appId: testAppId, teamId: testTeamId, tmbId: testTmbId } + ); + + await saveChat(props); + + const updatedApp = await MongoApp.findById(testAppId); + expect(updatedApp!.updateTime.getTime()).toBe(originalUpdateTime.getTime()); + }); + + it('should create chat data log with error count when response has error', async () => { + const props = createMockProps( + { + aiContent: { + obj: ChatRoleEnum.AI, + value: [], + responseData: [ + { + nodeId: 'xx', + id: 'xx', + moduleType: FlowNodeTypeEnum.chatNode, + moduleName: 'Chat', + runningTime: 1.0, + totalPoints: 5, + errorText: 'API rate limit exceeded' + } + ] + }, + errorMsg: 'API rate limit exceeded' + }, + { appId: testAppId, teamId: testTeamId, tmbId: testTmbId } + ); + + await saveChat(props); + + const logs = await MongoAppChatLog.find({ appId: testAppId, chatId: props.chatId }); + expect(logs).toHaveLength(1); + expect(logs[0].errorCount).toBe(1); + }); + + it('should calculate total points from response data', async () => { + const props = createMockProps( + { + aiContent: { + obj: ChatRoleEnum.AI, + value: [], + responseData: [ + { + nodeId: 'xx', + id: 'xx', + moduleType: FlowNodeTypeEnum.chatNode, + moduleName: 'Chat', + runningTime: 1.0, + totalPoints: 10 + }, + { + nodeId: '22', + id: '33', + moduleType: FlowNodeTypeEnum.datasetSearchNode, + moduleName: 'Dataset Search', + runningTime: 0.5, + totalPoints: 5 + } + ] + } + }, + { appId: testAppId, teamId: testTeamId, tmbId: testTmbId } + ); + + await saveChat(props); + + const logs = await MongoAppChatLog.find({ appId: testAppId, chatId: props.chatId }); + expect(logs).toHaveLength(1); + expect(logs[0].totalPoints).toBe(15); + }); + + it('should merge metadata from existing chat', async () => { + const props1 = createMockProps( + { + metadata: { source: 'web', version: '1.0' } + }, + { appId: testAppId, teamId: testTeamId, tmbId: testTmbId } + ); + + await saveChat(props1); + + const props2 = createMockProps( + { + chatId: props1.chatId, + metadata: { user: 'test-user' } + }, + { appId: testAppId, teamId: testTeamId, tmbId: testTmbId } + ); + + await saveChat(props2); + + const chat = await MongoChat.findOne({ appId: testAppId, chatId: props1.chatId }); + expect(chat?.metadata).toMatchObject({ + source: 'web', + version: '1.0', + user: 'test-user' + }); + }); + + it('should track duration seconds', async () => { + const props = createMockProps( + { + durationSeconds: 3.75 + }, + { appId: testAppId, teamId: testTeamId, tmbId: testTmbId } + ); + + await saveChat(props); + + const aiItem = await MongoChatItem.findOne({ + appId: testAppId, + chatId: props.chatId, + obj: ChatRoleEnum.AI + }); + if (aiItem) { + if ('durationSeconds' in aiItem) { + expect(aiItem?.durationSeconds).toBe(3.75); + } else { + throw new Error('aiItem does not have durationSeconds'); + } + } + }); + + it('should store citeCollectionIds from dataset search', async () => { + const props = createMockProps( + { + aiContent: { + obj: ChatRoleEnum.AI, + value: [], + responseData: [ + { + nodeId: 'xx', + id: 'xx', + moduleType: FlowNodeTypeEnum.datasetSearchNode, + moduleName: 'Dataset Search', + runningTime: 0.5, + totalPoints: 5, + quoteList: [ + { + id: 'quote-1', + chunkIndex: 0, + datasetId: 'dataset-1', + collectionId: 'collection-1', + sourceId: 'source-1', + sourceName: 'doc1.pdf', + score: [{ type: 'embedding', value: 0.95, index: 0 }], + q: 'What is AI?', + a: 'AI stands for Artificial Intelligence...', + updateTime: new Date() + }, + { + id: 'quote-2', + chunkIndex: 1, + datasetId: 'dataset-1', + collectionId: 'collection-2', + sourceId: 'source-2', + sourceName: 'doc2.pdf', + score: [{ type: 'embedding', value: 0.85, index: 0 }], + q: 'What is AI?', + a: 'AI stands for Artificial Intelligence...', + updateTime: new Date() + } + ] + } + ] + } + }, + { appId: testAppId, teamId: testTeamId, tmbId: testTmbId } + ); + + await saveChat(props); + + const aiItem = await MongoChatItem.findOne({ + appId: testAppId, + chatId: props.chatId, + obj: ChatRoleEnum.AI + }); + + if (aiItem) { + if ('citeCollectionIds' in aiItem) { + expect(aiItem?.citeCollectionIds).toHaveLength(2); + expect(aiItem?.citeCollectionIds).toContain('collection-1'); + expect(aiItem?.citeCollectionIds).toContain('collection-2'); + } else { + throw new Error('aiItem does not have citeCollectionIds'); + } + } + }); + }); + + describe('updateInteractiveChat function', () => { + it('should skip update if chatId is empty', async () => { + const props = createMockProps( + { chatId: '' }, + { appId: testAppId, teamId: testTeamId, tmbId: testTmbId } + ); + await updateInteractiveChat(props); + + const chatItems = await MongoChatItem.find({ appId: testAppId }); + expect(chatItems).toHaveLength(0); + }); + + it('should skip update if no AI chat item found', async () => { + const props = createMockProps({}, { appId: testAppId, teamId: testTeamId, tmbId: testTmbId }); + await updateInteractiveChat(props); + + const chat = await MongoChat.findOne({ appId: testAppId, chatId: props.chatId }); + expect(chat).toBeNull(); + }); + + it('should skip update if chat item is not from AI', async () => { + // Create a human chat item + await MongoChatItem.create({ + chatId: 'test-chat-id', + teamId: testTeamId, + tmbId: testTmbId, + appId: testAppId, + obj: ChatRoleEnum.Human, + value: [ + { + type: ChatItemValueTypeEnum.text, + text: { content: 'Hello' } + } + ] + }); + + const props = createMockProps({}, { appId: testAppId, teamId: testTeamId, tmbId: testTmbId }); + await updateInteractiveChat(props); + + // Should not create a chat record + const chat = await MongoChat.findOne({ appId: testAppId, chatId: props.chatId }); + expect(chat).toBeNull(); + }); + + it('should skip update if no interactive value found', async () => { + // Create an AI chat item without interactive + await MongoChatItem.create({ + chatId: 'test-chat-id', + teamId: testTeamId, + tmbId: testTmbId, + appId: testAppId, + obj: ChatRoleEnum.AI, + value: [ + { + type: ChatItemValueTypeEnum.text, + text: { content: 'Hello' } + } + ] + }); + + const props = createMockProps({}, { appId: testAppId, teamId: testTeamId, tmbId: testTmbId }); + await updateInteractiveChat(props); + + // Should not create a chat record + const chat = await MongoChat.findOne({ appId: testAppId, chatId: props.chatId }); + expect(chat).toBeNull(); + }); + + it('should update userSelect interactive value', async () => { + // Create an AI chat item with userSelect interactive + await MongoChatItem.create({ + chatId: 'test-chat-id', + teamId: testTeamId, + tmbId: testTmbId, + appId: testAppId, + obj: ChatRoleEnum.AI, + dataId: 'data-id-1', + value: [ + { + type: ChatItemValueTypeEnum.interactive, + interactive: { + type: 'userSelect', + params: { + userSelectedVal: undefined, + options: ['Option 1', 'Option 2'] + } + } + } + ] + }); + + const props = createMockProps( + { + appId: testAppId, + userContent: { + obj: ChatRoleEnum.Human, + value: [ + { + type: ChatItemValueTypeEnum.text, + text: { content: 'Option 1' } + } + ] + } + }, + { appId: testAppId, teamId: testTeamId, tmbId: testTmbId } + ); + + await updateInteractiveChat(props); + + const chatItem = await MongoChatItem.findOne({ + appId: testAppId, + chatId: props.chatId, + obj: ChatRoleEnum.AI + }); + if (chatItem) { + if (chatItem.obj === ChatRoleEnum.AI) { + if (chatItem?.value[0].interactive?.type === 'userSelect') { + expect(chatItem?.value[0].interactive?.params.userSelectedVal).toBe('Option 1'); + } else { + throw new Error('chatItem does not have userSelect interactive'); + } + } else { + throw new Error('chatItem does not have value'); + } + } + }); + + it('should update userInput interactive value', async () => { + // Create an AI chat item with userInput interactive + await MongoChatItem.create({ + chatId: 'test-chat-id', + teamId: testTeamId, + tmbId: testTmbId, + appId: testAppId, + obj: ChatRoleEnum.AI, + dataId: 'data-id-1', + value: [ + { + type: ChatItemValueTypeEnum.interactive, + interactive: { + type: 'userInput', + params: { + submitted: false, + inputForm: [ + { + key: 'username', + type: 'input', + label: 'Username', + value: undefined + } + ] + } + } + } + ] + }); + + const props = createMockProps( + { + userContent: { + obj: ChatRoleEnum.Human, + value: [ + { + type: ChatItemValueTypeEnum.text, + text: { content: JSON.stringify({ username: 'john_doe' }) } + } + ] + } + }, + { appId: testAppId, teamId: testTeamId, tmbId: testTmbId } + ); + + await updateInteractiveChat(props); + + const chatItem = await MongoChatItem.findOne({ + appId: testAppId, + chatId: props.chatId, + obj: ChatRoleEnum.AI + }); + if (chatItem) { + if (chatItem.obj === ChatRoleEnum.AI) { + if (chatItem?.value[0].interactive?.type === 'userInput') { + expect(chatItem?.value[0].interactive?.params.submitted).toBe(true); + expect(chatItem?.value[0].interactive?.params.inputForm[0].value).toBe('john_doe'); + } else { + throw new Error('chatItem does not have userInput interactive'); + } + } else { + throw new Error('chatItem does not have value'); + } + } + }); + + it('should remove paymentPause interactive value', async () => { + // Create an AI chat item with paymentPause interactive + await MongoChatItem.create({ + chatId: 'test-chat-id', + teamId: testTeamId, + tmbId: testTmbId, + appId: testAppId, + obj: ChatRoleEnum.AI, + dataId: 'data-id-1', + value: [ + { + type: ChatItemValueTypeEnum.text, + text: { content: 'Payment required' } + }, + { + type: ChatItemValueTypeEnum.interactive, + interactive: { + type: 'paymentPause', + params: {} + } + } + ] + }); + + const props = createMockProps({}, { appId: testAppId, teamId: testTeamId, tmbId: testTmbId }); + + await updateInteractiveChat(props); + + const chatItem = await MongoChatItem.findOne({ + appId: testAppId, + chatId: props.chatId, + obj: ChatRoleEnum.AI + }); + // PaymentPause is removed, and AI response is appended + expect(chatItem?.value.length).toBeGreaterThan(0); + // The first value should be text, last one should be from AI response + expect(chatItem?.value[0].type).toBe(ChatItemValueTypeEnum.text); + }); + + it('should merge AI response values', async () => { + // Create an AI chat item with interactive + await MongoChatItem.create({ + chatId: 'test-chat-id', + teamId: testTeamId, + tmbId: testTmbId, + appId: testAppId, + obj: ChatRoleEnum.AI, + dataId: 'data-id-1', + value: [ + { + type: ChatItemValueTypeEnum.text, + text: { content: 'First response' } + }, + { + type: ChatItemValueTypeEnum.interactive, + interactive: { + type: 'userSelect', + params: { options: ['A', 'B'] } + } + } + ] + }); + + const props = createMockProps( + { + aiContent: { + obj: ChatRoleEnum.AI, + value: [ + { + type: ChatItemValueTypeEnum.text, + text: { content: 'Second response' } + } + ], + responseData: [] + } + }, + { appId: testAppId, teamId: testTeamId, tmbId: testTmbId } + ); + + await updateInteractiveChat(props); + + const chatItem = await MongoChatItem.findOne({ + appId: testAppId, + chatId: props.chatId, + obj: ChatRoleEnum.AI + }); + // Original has 2 values (text + interactive), AI adds 1 more (text) + expect(chatItem?.value.length).toBeGreaterThanOrEqual(3); + expect(chatItem?.value[chatItem.value.length - 1].text?.content).toBe('Second response'); + }); + + it('should accumulate duration seconds', async () => { + // Create an AI chat item + await MongoChatItem.create({ + chatId: 'test-chat-id', + teamId: testTeamId, + tmbId: testTmbId, + appId: testAppId, + obj: ChatRoleEnum.AI, + dataId: 'data-id-1', + durationSeconds: 1.5, + value: [ + { + type: ChatItemValueTypeEnum.interactive, + interactive: { + type: 'userSelect', + params: { options: ['A', 'B'] } + } + } + ] + }); + + const props = createMockProps( + { + durationSeconds: 2.3 + }, + { appId: testAppId, teamId: testTeamId, tmbId: testTmbId } + ); + + await updateInteractiveChat(props); + + const chatItem = await MongoChatItem.findOne({ + appId: testAppId, + chatId: props.chatId, + obj: ChatRoleEnum.AI + }); + if (chatItem) { + if (chatItem.obj === ChatRoleEnum.AI) { + expect(chatItem?.durationSeconds).toBe(3.8); + } else { + throw new Error('chatItem does not have durationSeconds'); + } + } + }); + + it('should merge chat item responses', async () => { + // Create an AI chat item + await MongoChatItem.create({ + chatId: 'test-chat-id', + teamId: testTeamId, + tmbId: testTmbId, + appId: testAppId, + obj: ChatRoleEnum.AI, + dataId: 'data-id-1', + value: [ + { + type: ChatItemValueTypeEnum.interactive, + interactive: { + type: 'userSelect', + params: { options: ['A', 'B'] } + } + } + ] + }); + + // Create an existing response + await MongoChatItemResponse.create({ + teamId: testTeamId, + appId: testAppId, + chatId: 'test-chat-id', + chatItemDataId: 'data-id-1', + data: { + moduleType: FlowNodeTypeEnum.chatNode, + moduleName: 'Chat', + runningTime: 1.0, + totalPoints: 10 + } + }); + + const props = createMockProps( + { + aiContent: { + obj: ChatRoleEnum.AI, + value: [], + responseData: [ + { + nodeId: 'xx', + id: 'xx', + moduleType: FlowNodeTypeEnum.datasetSearchNode, + moduleName: 'Dataset Search', + runningTime: 0.5, + totalPoints: 5 + } + ] + } + }, + { appId: testAppId, teamId: testTeamId, tmbId: testTmbId } + ); + + await updateInteractiveChat(props); + + const responses = await MongoChatItemResponse.find({ + appId: testAppId, + chatId: props.chatId + }); + // Should have merged responses + expect(responses.length).toBeGreaterThan(0); + }); + }); +});