UNPKG

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