add savechat test (#6118)
Some checks are pending
Document deploy / sync-images (push) Waiting to run
Document deploy / generate-timestamp (push) Blocked by required conditions
Document deploy / build-images (map[domain:https://fastgpt.cn suffix:cn]) (push) Blocked by required conditions
Document deploy / build-images (map[domain:https://fastgpt.io suffix:io]) (push) Blocked by required conditions
Document deploy / update-images (map[deployment:fastgpt-docs domain:https://fastgpt.cn kube_config:KUBE_CONFIG_CN suffix:cn]) (push) Blocked by required conditions
Document deploy / update-images (map[deployment:fastgpt-docs domain:https://fastgpt.io kube_config:KUBE_CONFIG_IO suffix:io]) (push) Blocked by required conditions
Build FastGPT images in Personal warehouse / get-vars (push) Waiting to run
Build FastGPT images in Personal warehouse / build-fastgpt-images (map[arch:amd64 runs-on:ubuntu-24.04]) (push) Blocked by required conditions
Build FastGPT images in Personal warehouse / build-fastgpt-images (map[arch:arm64 runs-on:ubuntu-24.04-arm]) (push) Blocked by required conditions
Build FastGPT images in Personal warehouse / release-fastgpt-images (push) Blocked by required conditions

This commit is contained in:
Archer 2025-12-18 14:34:44 +08:00 committed by GitHub
parent c6fe6f18da
commit ea7c37745a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -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<Props>,
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);
});
});
});