@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.
509 lines (432 loc) • 15.1 kB
text/typescript
import { describe, expect, it, vi } from 'vitest';
import { GenerationTopicModel } from '@/database/models/generationTopic';
import { GenerationTopicItem } from '@/database/schemas/generation';
import { FileService } from '@/server/services/file';
import { GenerationService } from '@/server/services/generation';
import { generationTopicRouter } from './generationTopic';
vi.mock('@/database/models/generationTopic');
vi.mock('@/server/services/file');
vi.mock('@/server/services/generation');
describe('generationTopicRouter', () => {
const mockCtx = {
userId: 'test-user',
serverDB: {} as any,
};
beforeEach(() => {
vi.clearAllMocks();
});
it('should create a new topic', async () => {
const mockTopicId = 'topic-123';
const mockCreatedTopic = {
id: mockTopicId,
title: '',
userId: 'test-user',
coverUrl: null,
accessedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
};
const mockCreate = vi.fn().mockResolvedValue(mockCreatedTopic);
vi.mocked(GenerationTopicModel).mockImplementation(
() =>
({
create: mockCreate,
}) as any,
);
const caller = generationTopicRouter.createCaller(mockCtx);
const result = await caller.createTopic();
expect(result).toBe(mockTopicId);
expect(mockCreate).toHaveBeenCalledWith('');
});
it('should get all generation topics', async () => {
const mockTopics: GenerationTopicItem[] = [
{
id: 'topic-1',
title: 'Test Topic 1',
userId: 'test-user',
coverUrl: 'cover-url-1',
accessedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: 'topic-2',
title: 'Test Topic 2',
userId: 'test-user',
coverUrl: null,
accessedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
},
];
const mockQueryAll = vi.fn().mockResolvedValue(mockTopics);
vi.mocked(GenerationTopicModel).mockImplementation(
() =>
({
queryAll: mockQueryAll,
}) as any,
);
const caller = generationTopicRouter.createCaller(mockCtx);
const result = await caller.getAllGenerationTopics();
expect(result).toEqual(mockTopics);
expect(mockQueryAll).toHaveBeenCalled();
});
it('should update a topic', async () => {
const mockTopicId = 'topic-123';
const mockUpdateValue = {
title: 'Updated Title',
coverUrl: 'updated-cover-url',
};
const mockUpdatedTopic = {
id: mockTopicId,
...mockUpdateValue,
userId: 'test-user',
accessedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
};
const mockUpdate = vi.fn().mockResolvedValue(mockUpdatedTopic);
vi.mocked(GenerationTopicModel).mockImplementation(
() =>
({
update: mockUpdate,
}) as any,
);
const caller = generationTopicRouter.createCaller(mockCtx);
const result = await caller.updateTopic({
id: mockTopicId,
value: mockUpdateValue,
});
expect(result).toEqual(mockUpdatedTopic);
expect(mockUpdate).toHaveBeenCalledWith(mockTopicId, mockUpdateValue);
});
it('should update topic cover', async () => {
const mockTopicId = 'topic-123';
const mockCoverUrl = 'https://example.com/cover.jpg';
const mockNewCoverKey = 'generations/covers/new-cover-key.webp';
const mockUpdatedTopic = {
id: mockTopicId,
title: 'Test Topic',
userId: 'test-user',
coverUrl: mockNewCoverKey,
accessedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
};
const mockCreateCoverFromUrl = vi.fn().mockResolvedValue(mockNewCoverKey);
const mockUpdate = vi.fn().mockResolvedValue(mockUpdatedTopic);
vi.mocked(GenerationService).mockImplementation(
() =>
({
createCoverFromUrl: mockCreateCoverFromUrl,
}) as any,
);
vi.mocked(GenerationTopicModel).mockImplementation(
() =>
({
update: mockUpdate,
}) as any,
);
const caller = generationTopicRouter.createCaller(mockCtx);
const result = await caller.updateTopicCover({
id: mockTopicId,
coverUrl: mockCoverUrl,
});
expect(result).toEqual(mockUpdatedTopic);
expect(mockCreateCoverFromUrl).toHaveBeenCalledWith(mockCoverUrl);
expect(mockUpdate).toHaveBeenCalledWith(mockTopicId, { coverUrl: mockNewCoverKey });
});
it('should delete a topic without cover', async () => {
const mockTopicId = 'topic-123';
const mockDeletedTopic = {
id: mockTopicId,
title: 'Deleted Topic',
userId: 'test-user',
coverUrl: null,
accessedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
};
// 修复 Mock 返回结构以匹配新的实现
const mockDelete = vi.fn().mockResolvedValue({
deletedTopic: mockDeletedTopic,
filesToDelete: [], // 没有封面时,文件列表为空
});
const mockDeleteFiles = vi.fn();
vi.mocked(GenerationTopicModel).mockImplementation(
() =>
({
delete: mockDelete,
}) as any,
);
vi.mocked(FileService).mockImplementation(
() =>
({
deleteFiles: mockDeleteFiles,
}) as any,
);
const caller = generationTopicRouter.createCaller(mockCtx);
const result = await caller.deleteTopic({ id: mockTopicId });
expect(result).toEqual(mockDeletedTopic);
expect(mockDelete).toHaveBeenCalledWith(mockTopicId);
expect(mockDeleteFiles).not.toHaveBeenCalled(); // 没有文件要删除
});
it('should delete a topic with cover and remove the cover file', async () => {
const mockTopicId = 'topic-123';
const mockCoverUrl = 'generations/covers/cover-key.webp';
const mockDeletedTopic = {
id: mockTopicId,
title: 'Deleted Topic',
userId: 'test-user',
coverUrl: mockCoverUrl,
accessedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
};
// 修复 Mock 返回结构以匹配新的实现
const mockDelete = vi.fn().mockResolvedValue({
deletedTopic: mockDeletedTopic,
filesToDelete: [mockCoverUrl], // 包含需要删除的封面文件
});
const mockDeleteFiles = vi.fn().mockResolvedValue(true);
vi.mocked(GenerationTopicModel).mockImplementation(
() =>
({
delete: mockDelete,
}) as any,
);
vi.mocked(FileService).mockImplementation(
() =>
({
deleteFiles: mockDeleteFiles,
}) as any,
);
const caller = generationTopicRouter.createCaller(mockCtx);
const result = await caller.deleteTopic({ id: mockTopicId });
expect(result).toEqual(mockDeletedTopic);
expect(mockDelete).toHaveBeenCalledWith(mockTopicId);
expect(mockDeleteFiles).toHaveBeenCalledWith([mockCoverUrl]); // 验证文件删除调用
});
it('should still return deleted topic when file deletion fails', async () => {
const mockTopicId = 'topic-123';
const mockCoverUrl = 'generations/covers/cover-key.webp';
const mockDeletedTopic = {
id: mockTopicId,
title: 'Deleted Topic',
userId: 'test-user',
coverUrl: mockCoverUrl,
accessedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
};
const mockDelete = vi.fn().mockResolvedValue({
deletedTopic: mockDeletedTopic,
filesToDelete: [mockCoverUrl],
});
// Mock file deletion to fail
const mockDeleteFiles = vi.fn().mockRejectedValue(new Error('S3 deletion failed'));
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.mocked(GenerationTopicModel).mockImplementation(
() =>
({
delete: mockDelete,
}) as any,
);
vi.mocked(FileService).mockImplementation(
() =>
({
deleteFiles: mockDeleteFiles,
}) as any,
);
const caller = generationTopicRouter.createCaller(mockCtx);
const result = await caller.deleteTopic({ id: mockTopicId });
// Database deletion should succeed even if file deletion fails
expect(result).toEqual(mockDeletedTopic);
expect(mockDelete).toHaveBeenCalledWith(mockTopicId);
expect(mockDeleteFiles).toHaveBeenCalledWith([mockCoverUrl]);
expect(consoleSpy).toHaveBeenCalledWith('Failed to delete files from S3:', expect.any(Error));
consoleSpy.mockRestore();
});
it('should handle topic with multiple files (cover + thumbnails)', async () => {
const mockTopicId = 'topic-123';
const mockCoverUrl = 'generations/covers/cover-key.webp';
const mockThumbnailUrls = ['thumb1.jpg', 'thumb2.jpg', 'thumb3.jpg'];
const mockDeletedTopic = {
id: mockTopicId,
title: 'Deleted Topic with Multiple Files',
userId: 'test-user',
coverUrl: mockCoverUrl,
accessedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
};
const mockDelete = vi.fn().mockResolvedValue({
deletedTopic: mockDeletedTopic,
filesToDelete: [mockCoverUrl, ...mockThumbnailUrls], // 包含封面和缩略图
});
const mockDeleteFiles = vi.fn().mockResolvedValue(true);
vi.mocked(GenerationTopicModel).mockImplementation(
() =>
({
delete: mockDelete,
}) as any,
);
vi.mocked(FileService).mockImplementation(
() =>
({
deleteFiles: mockDeleteFiles,
}) as any,
);
const caller = generationTopicRouter.createCaller(mockCtx);
const result = await caller.deleteTopic({ id: mockTopicId });
expect(result).toEqual(mockDeletedTopic);
expect(mockDelete).toHaveBeenCalledWith(mockTopicId);
expect(mockDeleteFiles).toHaveBeenCalledWith([mockCoverUrl, ...mockThumbnailUrls]);
});
it('should handle partial file deletion failure gracefully', async () => {
const mockTopicId = 'topic-123';
const mockCoverUrl = 'generations/covers/cover-key.webp';
const mockThumbnailUrls = ['thumb1.jpg', 'thumb2.jpg'];
const mockDeletedTopic = {
id: mockTopicId,
title: 'Deleted Topic',
userId: 'test-user',
coverUrl: mockCoverUrl,
accessedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
};
const mockDelete = vi.fn().mockResolvedValue({
deletedTopic: mockDeletedTopic,
filesToDelete: [mockCoverUrl, ...mockThumbnailUrls],
});
// Mock partial failure - some files deleted, others failed
const mockDeleteFiles = vi
.fn()
.mockRejectedValue(new Error('Some files could not be deleted from S3'));
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.mocked(GenerationTopicModel).mockImplementation(
() =>
({
delete: mockDelete,
}) as any,
);
vi.mocked(FileService).mockImplementation(
() =>
({
deleteFiles: mockDeleteFiles,
}) as any,
);
const caller = generationTopicRouter.createCaller(mockCtx);
const result = await caller.deleteTopic({ id: mockTopicId });
// Even with partial file deletion failure, topic deletion should succeed
expect(result).toEqual(mockDeletedTopic);
expect(mockDelete).toHaveBeenCalledWith(mockTopicId);
expect(mockDeleteFiles).toHaveBeenCalledWith([mockCoverUrl, ...mockThumbnailUrls]);
expect(consoleSpy).toHaveBeenCalledWith('Failed to delete files from S3:', expect.any(Error));
consoleSpy.mockRestore();
});
it('should return undefined when deleting non-existent topic', async () => {
const mockTopicId = 'non-existent-topic';
// Mock delete method to return undefined for non-existent topic
const mockDelete = vi.fn().mockResolvedValue(undefined);
const mockDeleteFiles = vi.fn();
vi.mocked(GenerationTopicModel).mockImplementation(
() =>
({
delete: mockDelete,
}) as any,
);
vi.mocked(FileService).mockImplementation(
() =>
({
deleteFiles: mockDeleteFiles,
}) as any,
);
const caller = generationTopicRouter.createCaller(mockCtx);
// Expect the function to return undefined for non-existent topic
const result = await caller.deleteTopic({ id: mockTopicId });
expect(result).toBeUndefined();
expect(mockDelete).toHaveBeenCalledWith(mockTopicId);
expect(mockDeleteFiles).not.toHaveBeenCalled(); // 没有文件要删除
});
it('should handle edge cases for update with partial data', async () => {
const mockTopicId = 'topic-123';
const mockUpdateValue = {
title: 'Only Title Updated',
};
const mockUpdatedTopic = {
id: mockTopicId,
title: 'Only Title Updated',
userId: 'test-user',
coverUrl: null,
accessedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
};
const mockUpdate = vi.fn().mockResolvedValue(mockUpdatedTopic);
vi.mocked(GenerationTopicModel).mockImplementation(
() =>
({
update: mockUpdate,
}) as any,
);
const caller = generationTopicRouter.createCaller(mockCtx);
const result = await caller.updateTopic({
id: mockTopicId,
value: mockUpdateValue,
});
expect(result).toEqual(mockUpdatedTopic);
expect(mockUpdate).toHaveBeenCalledWith(mockTopicId, mockUpdateValue);
});
it('should handle null values in update', async () => {
const mockTopicId = 'topic-123';
const mockUpdateValue = {
title: null,
coverUrl: null,
};
const mockUpdatedTopic = {
id: mockTopicId,
title: null,
userId: 'test-user',
coverUrl: null,
accessedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
};
const mockUpdate = vi.fn().mockResolvedValue(mockUpdatedTopic);
vi.mocked(GenerationTopicModel).mockImplementation(
() =>
({
update: mockUpdate,
}) as any,
);
const caller = generationTopicRouter.createCaller(mockCtx);
const result = await caller.updateTopic({
id: mockTopicId,
value: mockUpdateValue,
});
expect(result).toEqual(mockUpdatedTopic);
expect(mockUpdate).toHaveBeenCalledWith(mockTopicId, mockUpdateValue);
});
it('should return undefined when updating non-existent topic', async () => {
const mockTopicId = 'non-existent-topic';
const mockUpdateValue = {
title: 'New Title',
};
const mockUpdate = vi.fn().mockResolvedValue(undefined);
vi.mocked(GenerationTopicModel).mockImplementation(
() =>
({
update: mockUpdate,
}) as any,
);
const caller = generationTopicRouter.createCaller(mockCtx);
const result = await caller.updateTopic({
id: mockTopicId,
value: mockUpdateValue,
});
expect(result).toBeUndefined();
expect(mockUpdate).toHaveBeenCalledWith(mockTopicId, mockUpdateValue);
});
});