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.

787 lines (663 loc) 24.7 kB
// @vitest-environment node import { eq } from 'drizzle-orm'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { LobeChatDatabase } from '@/database/type'; import { AsyncTaskStatus } from '@/types/asyncTask'; import { FileSource } from '@/types/files'; import { ImageGenerationAsset } from '@/types/generation'; import { NewGeneration, asyncTasks, files, generationBatches, generationTopics, generations, users, } from '../../schemas'; import { GenerationModel } from '../generation'; import { getTestDB } from './_util'; const serverDB: LobeChatDatabase = await getTestDB(); // Mock FileService const mockGetFullFileUrl = vi.fn(); vi.mock('@/server/services/file', () => ({ FileService: vi.fn().mockImplementation(() => ({ getFullFileUrl: mockGetFullFileUrl, })), })); // Mock FileModel const mockFileModelCreate = vi.fn(); vi.mock('../file', () => ({ FileModel: vi.fn().mockImplementation(() => ({ create: mockFileModelCreate, })), })); const userId = 'generation-test-user-id'; const otherUserId = 'other-user-id'; const generationModel = new GenerationModel(serverDB, userId); // Test data const testTopic = { id: 'test-topic-id', userId, title: 'Test Generation Topic', coverUrl: null, }; const testBatch = { id: 'test-batch-id', generationTopicId: 'test-topic-id', provider: 'test-provider', model: 'test-model', prompt: 'Test prompt for image generation', width: 1024, height: 1024, config: {}, userId, }; const testAsyncTask = { id: '550e8400-e29b-41d4-a716-446655440000', status: AsyncTaskStatus.Success, type: 'imageGeneration', params: {}, error: null, userId, }; const testGeneration: Omit<NewGeneration, 'userId'> = { generationBatchId: 'test-batch-id', asyncTaskId: '550e8400-e29b-41d4-a716-446655440000', // 使用有效的 asyncTaskId fileId: null, // 使用 null 避免外键约束 seed: 12345, asset: { url: 'asset-url.jpg', thumbnailUrl: 'thumbnail-url.jpg', width: 1024, height: 1024, } as ImageGenerationAsset, }; const testFile = { id: 'test-file-id', name: 'generated-image.jpg', url: 'https://example.com/generated-image.jpg', size: 1048576, fileType: 'image/jpeg', source: FileSource.ImageGeneration, userId, }; beforeEach(async () => { // Clear all mocks vi.clearAllMocks(); // Setup mock return values mockGetFullFileUrl.mockImplementation((url: string) => `https://example.com/${url}`); // Clear database and create test users await serverDB.delete(users); await serverDB.insert(users).values([{ id: userId }, { id: otherUserId }]); // Create test topic await serverDB.insert(generationTopics).values(testTopic); // Create test batch await serverDB.insert(generationBatches).values(testBatch); // Create test async task await serverDB.insert(asyncTasks).values(testAsyncTask); // Create test file await serverDB.insert(files).values(testFile); // Create a file that will be returned by the mock for createAssetAndFile tests const mockFileForUpdateTest = { id: 'new-file-id', name: 'mock-generated-image.jpg', url: 'https://example.com/mock-generated-image.jpg', size: 1048576, fileType: 'image/jpeg', source: FileSource.ImageGeneration, userId, }; await serverDB.insert(files).values(mockFileForUpdateTest); // Setup FileModel mock to return the actual file ID that exists in database mockFileModelCreate.mockResolvedValue({ id: 'new-file-id' }); }); afterEach(async () => { // Clean up database await serverDB.delete(users); }); describe('GenerationModel', () => { describe('create', () => { it('should create a new generation', async () => { const result = await generationModel.create(testGeneration); expect(result.id).toBeDefined(); expect(result).toMatchObject({ ...testGeneration, userId, }); // Verify in database const dbGeneration = await serverDB.query.generations.findFirst({ where: eq(generations.id, result.id), }); expect(dbGeneration).toMatchObject({ ...testGeneration, userId, }); }); it('should automatically set userId when creating generation', async () => { const result = await generationModel.create(testGeneration); expect(result.userId).toBe(userId); }); it('should create generation with minimal data', async () => { const minimalGeneration: Omit<NewGeneration, 'userId'> = { generationBatchId: 'test-batch-id', }; const result = await generationModel.create(minimalGeneration); expect(result.id).toBeDefined(); expect(result.userId).toBe(userId); expect(result.generationBatchId).toBe('test-batch-id'); }); }); describe('findById', () => { it('should find a generation by id', async () => { const [createdGeneration] = await serverDB .insert(generations) .values({ ...testGeneration, userId }) .returning(); const result = await generationModel.findById(createdGeneration.id); expect(result).toMatchObject({ id: createdGeneration.id, ...testGeneration, userId, }); }); it('should return undefined for non-existent generation', async () => { const result = await generationModel.findById('non-existent-id'); expect(result).toBeUndefined(); }); it('should NOT find generations from other users', async () => { // Create generation for other user const [otherUserGeneration] = await serverDB .insert(generations) .values({ ...testGeneration, userId: otherUserId }) .returning(); const result = await generationModel.findById(otherUserGeneration.id); expect(result).toBeUndefined(); }); }); describe('findByIdWithAsyncTask', () => { it('should find generation with async task data', async () => { const [createdGeneration] = await serverDB .insert(generations) .values({ ...testGeneration, userId }) .returning(); const result = await generationModel.findByIdWithAsyncTask(createdGeneration.id); expect(result).toMatchObject({ id: createdGeneration.id, ...testGeneration, userId, }); expect(result?.asyncTask).toMatchObject({ id: testAsyncTask.id, status: AsyncTaskStatus.Success, }); }); it('should return undefined for non-existent generation', async () => { const result = await generationModel.findByIdWithAsyncTask('non-existent-id'); expect(result).toBeUndefined(); }); it('should NOT find generations from other users', async () => { const [otherUserGeneration] = await serverDB .insert(generations) .values({ ...testGeneration, userId: otherUserId }) .returning(); const result = await generationModel.findByIdWithAsyncTask(otherUserGeneration.id); expect(result).toBeUndefined(); }); }); describe('update', () => { it('should update a generation', async () => { const [createdGeneration] = await serverDB .insert(generations) .values({ ...testGeneration, userId }) .returning(); const updateData = { seed: 54321, asset: { url: 'updated-asset.jpg', thumbnailUrl: 'updated-thumbnail.jpg', width: 512, height: 512, } as ImageGenerationAsset, }; await generationModel.update(createdGeneration.id, updateData); const updatedGeneration = await serverDB.query.generations.findFirst({ where: eq(generations.id, createdGeneration.id), }); expect(updatedGeneration).toMatchObject({ ...testGeneration, ...updateData, userId, }); expect(updatedGeneration?.updatedAt).toBeDefined(); }); it('should NOT update generations from other users', async () => { // Create generation for other user const [otherUserGeneration] = await serverDB .insert(generations) .values({ ...testGeneration, userId: otherUserId }) .returning(); const updateData = { seed: 99999 }; await generationModel.update(otherUserGeneration.id, updateData); // Verify original data unchanged const unchanged = await serverDB.query.generations.findFirst({ where: eq(generations.id, otherUserGeneration.id), }); expect(unchanged?.seed).toBe(testGeneration.seed); }); it('should handle partial updates', async () => { const [createdGeneration] = await serverDB .insert(generations) .values({ ...testGeneration, userId }) .returning(); await generationModel.update(createdGeneration.id, { seed: 11111 }); const updatedGeneration = await serverDB.query.generations.findFirst({ where: eq(generations.id, createdGeneration.id), }); expect(updatedGeneration?.seed).toBe(11111); expect(updatedGeneration?.generationBatchId).toBe(testGeneration.generationBatchId); }); }); describe('createAssetAndFile', () => { it('should update generation asset and create file in transaction', async () => { const [createdGeneration] = await serverDB .insert(generations) .values({ ...testGeneration, userId, asset: null, fileId: null }) .returning(); const newAsset = { url: 'new-asset.jpg', thumbnailUrl: 'new-thumbnail.jpg', width: 2048, height: 2048, } as ImageGenerationAsset; const newFileData = { name: 'new-generated-image.jpg', url: 'https://example.com/new-generated-image.jpg', size: 2097152, fileType: 'image/jpeg', }; const result = await generationModel.createAssetAndFile( createdGeneration.id, newAsset, newFileData, ); expect(result.file.id).toBe('new-file-id'); expect(mockFileModelCreate).toHaveBeenCalledWith( { ...newFileData, source: FileSource.ImageGeneration, }, true, expect.any(Object), // transaction object ); // Verify generation was updated const updatedGeneration = await serverDB.query.generations.findFirst({ where: eq(generations.id, createdGeneration.id), }); expect(updatedGeneration?.asset).toEqual(newAsset); expect(updatedGeneration?.fileId).toBe('new-file-id'); }); it('should NOT update assets for other users generations', async () => { // Create generation for other user const [otherUserGeneration] = await serverDB .insert(generations) .values({ ...testGeneration, userId: otherUserId, asset: null, fileId: null }) .returning(); const newAsset = { url: 'hacked-asset.jpg', thumbnailUrl: 'hacked-thumbnail.jpg', width: 1, height: 1, } as ImageGenerationAsset; const newFileData = { name: 'hacked-file.jpg', url: 'https://example.com/hacked-file.jpg', size: 1, fileType: 'image/jpeg', }; await generationModel.createAssetAndFile(otherUserGeneration.id, newAsset, newFileData); // Verify no changes to other user's generation const unchanged = await serverDB.query.generations.findFirst({ where: eq(generations.id, otherUserGeneration.id), }); expect(unchanged?.asset).toBeNull(); expect(unchanged?.fileId).toBeNull(); }); it('should handle FileModel errors in transaction', async () => { const [createdGeneration] = await serverDB .insert(generations) .values({ ...testGeneration, userId, asset: null, fileId: null }) .returning(); mockFileModelCreate.mockRejectedValue(new Error('File creation failed')); const newAsset = { url: 'asset.jpg', thumbnailUrl: 'thumbnail.jpg', width: 1024, height: 1024, } as ImageGenerationAsset; const newFileData = { name: 'image.jpg', url: 'https://example.com/image.jpg', size: 1024, fileType: 'image/jpeg', }; await expect( generationModel.createAssetAndFile(createdGeneration.id, newAsset, newFileData), ).rejects.toThrow('File creation failed'); // Verify generation was not updated due to transaction rollback const unchanged = await serverDB.query.generations.findFirst({ where: eq(generations.id, createdGeneration.id), }); expect(unchanged?.asset).toBeNull(); expect(unchanged?.fileId).toBeNull(); }); }); describe('delete', () => { it('should delete a generation', async () => { const [createdGeneration] = await serverDB .insert(generations) .values({ ...testGeneration, userId }) .returning(); const deletedGeneration = await generationModel.delete(createdGeneration.id); expect(deletedGeneration.id).toBe(createdGeneration.id); const deletedRecord = await serverDB.query.generations.findFirst({ where: eq(generations.id, createdGeneration.id), }); expect(deletedRecord).toBeUndefined(); }); it('should NOT delete generations from other users', async () => { // Create generation for other user const [otherUserGeneration] = await serverDB .insert(generations) .values({ ...testGeneration, userId: otherUserId }) .returning(); const result = await generationModel.delete(otherUserGeneration.id); expect(result).toBeUndefined(); // Verify generation still exists const stillExists = await serverDB.query.generations.findFirst({ where: eq(generations.id, otherUserGeneration.id), }); expect(stillExists).toBeDefined(); }); it('should return undefined when trying to delete non-existent generation', async () => { const result = await generationModel.delete('non-existent-id'); expect(result).toBeUndefined(); }); }); describe('findByIdAndTransform', () => { it('should find and transform generation to frontend type', async () => { const [createdGeneration] = await serverDB .insert(generations) .values({ ...testGeneration, userId }) .returning(); const result = await generationModel.findByIdAndTransform(createdGeneration.id); expect(result).toMatchObject({ id: createdGeneration.id, asset: { url: 'https://example.com/asset-url.jpg', thumbnailUrl: 'https://example.com/thumbnail-url.jpg', width: 1024, height: 1024, }, seed: testGeneration.seed, asyncTaskId: testGeneration.asyncTaskId, task: { id: testGeneration.asyncTaskId, status: AsyncTaskStatus.Success, }, }); expect(mockGetFullFileUrl).toHaveBeenCalledWith('asset-url.jpg'); expect(mockGetFullFileUrl).toHaveBeenCalledWith('thumbnail-url.jpg'); }); it('should return null for non-existent generation', async () => { const result = await generationModel.findByIdAndTransform('non-existent-id'); expect(result).toBeNull(); }); it('should NOT transform generations from other users', async () => { const [otherUserGeneration] = await serverDB .insert(generations) .values({ ...testGeneration, userId: otherUserId }) .returning(); const result = await generationModel.findByIdAndTransform(otherUserGeneration.id); expect(result).toBeNull(); }); }); describe('transformGeneration', () => { it('should transform generation with asset URLs', async () => { const generationWithTask = { id: 'test-gen-id', userId, generationBatchId: 'batch-id', asyncTaskId: '550e8400-e29b-41d4-a716-446655440000', fileId: 'file-id', seed: 12345, asset: { url: 'original-asset.jpg', thumbnailUrl: 'original-thumbnail.jpg', width: 1024, height: 1024, } as ImageGenerationAsset, accessedAt: new Date(), createdAt: new Date(), updatedAt: new Date(), asyncTask: { id: '550e8400-e29b-41d4-a716-446655440000', status: AsyncTaskStatus.Success, type: 'imageGeneration', params: {}, error: null, duration: null, accessedAt: new Date(), createdAt: new Date(), updatedAt: new Date(), userId, }, }; const result = await generationModel.transformGeneration(generationWithTask); expect(result).toMatchObject({ id: 'test-gen-id', asset: { url: 'https://example.com/original-asset.jpg', thumbnailUrl: 'https://example.com/original-thumbnail.jpg', width: 1024, height: 1024, }, seed: 12345, asyncTaskId: '550e8400-e29b-41d4-a716-446655440000', task: { id: '550e8400-e29b-41d4-a716-446655440000', status: AsyncTaskStatus.Success, }, }); expect(mockGetFullFileUrl).toHaveBeenCalledWith('original-asset.jpg'); expect(mockGetFullFileUrl).toHaveBeenCalledWith('original-thumbnail.jpg'); }); it('should handle generation without asset', async () => { const generationWithoutAsset = { id: 'test-gen-id', userId, generationBatchId: 'batch-id', asyncTaskId: '550e8400-e29b-41d4-a716-446655440000', fileId: null, seed: 12345, asset: null, accessedAt: new Date(), createdAt: new Date(), updatedAt: new Date(), asyncTask: { id: '550e8400-e29b-41d4-a716-446655440000', status: AsyncTaskStatus.Pending, type: 'imageGeneration', params: {}, error: null, duration: null, accessedAt: new Date(), createdAt: new Date(), updatedAt: new Date(), userId, }, }; const result = await generationModel.transformGeneration(generationWithoutAsset as any); expect(result).toMatchObject({ id: 'test-gen-id', asset: null, seed: 12345, asyncTaskId: '550e8400-e29b-41d4-a716-446655440000', task: { id: '550e8400-e29b-41d4-a716-446655440000', status: AsyncTaskStatus.Pending, }, }); expect(mockGetFullFileUrl).not.toHaveBeenCalled(); }); it('should handle generation without async task', async () => { const generationWithoutTask = { id: 'test-gen-id', userId, generationBatchId: 'batch-id', asyncTaskId: null, fileId: null, seed: 12345, asset: null, accessedAt: new Date(), createdAt: new Date(), updatedAt: new Date(), asyncTask: undefined, }; const result = await generationModel.transformGeneration(generationWithoutTask as any); expect(result).toMatchObject({ id: 'test-gen-id', asset: null, seed: 12345, asyncTaskId: null, task: { id: '', status: 'pending', }, }); }); it('should handle FileService errors during transformation', async () => { mockGetFullFileUrl.mockRejectedValue(new Error('FileService error')); const generationWithAsset = { id: 'test-gen-id', userId, generationBatchId: 'batch-id', asyncTaskId: null, fileId: null, seed: 12345, asset: { url: 'failing-asset.jpg', thumbnailUrl: 'failing-thumbnail.jpg', width: 1024, height: 1024, } as ImageGenerationAsset, accessedAt: new Date(), createdAt: new Date(), updatedAt: new Date(), asyncTask: undefined, }; await expect(generationModel.transformGeneration(generationWithAsset as any)).rejects.toThrow( 'FileService error', ); }); }); describe('user isolation security tests', () => { it('should enforce user data isolation across all methods', async () => { // Create generations for both users const userGeneration = { ...testGeneration, userId }; const otherUserGeneration = { ...testGeneration, userId: otherUserId }; const [userGenerationCreated] = await serverDB .insert(generations) .values(userGeneration) .returning(); const [otherUserGenerationCreated] = await serverDB .insert(generations) .values(otherUserGeneration) .returning(); // Test findById isolation const foundUserGeneration = await generationModel.findById(userGenerationCreated.id); const foundOtherGeneration = await generationModel.findById(otherUserGenerationCreated.id); expect(foundUserGeneration).toBeDefined(); expect(foundOtherGeneration).toBeUndefined(); // Should not find other user's generation // Test findByIdWithAsyncTask isolation const foundUserGenerationWithTask = await generationModel.findByIdWithAsyncTask( userGenerationCreated.id, ); const foundOtherGenerationWithTask = await generationModel.findByIdWithAsyncTask( otherUserGenerationCreated.id, ); expect(foundUserGenerationWithTask).toBeDefined(); expect(foundOtherGenerationWithTask).toBeUndefined(); // Test findByIdAndTransform isolation const transformedUserGeneration = await generationModel.findByIdAndTransform( userGenerationCreated.id, ); const transformedOtherGeneration = await generationModel.findByIdAndTransform( otherUserGenerationCreated.id, ); expect(transformedUserGeneration).toBeDefined(); expect(transformedOtherGeneration).toBeNull(); // Test update isolation - should not affect other user's data await generationModel.update(otherUserGenerationCreated.id, { seed: 99999 }); const otherUserGenerationUnchanged = await serverDB.query.generations.findFirst({ where: eq(generations.id, otherUserGenerationCreated.id), }); expect(otherUserGenerationUnchanged?.seed).toBe(testGeneration.seed); // Should not be updated // Test delete isolation - should not affect other user's data await generationModel.delete(otherUserGenerationCreated.id); const otherUserGenerationStillExists = await serverDB.query.generations.findFirst({ where: eq(generations.id, otherUserGenerationCreated.id), }); expect(otherUserGenerationStillExists).toBeDefined(); // Should not be deleted }); }); describe('External service integration', () => { it('should call FileService with correct parameters', async () => { const [createdGeneration] = await serverDB .insert(generations) .values({ ...testGeneration, userId }) .returning(); await generationModel.findByIdAndTransform(createdGeneration.id); expect(mockGetFullFileUrl).toHaveBeenCalledWith('asset-url.jpg'); expect(mockGetFullFileUrl).toHaveBeenCalledWith('thumbnail-url.jpg'); expect(mockGetFullFileUrl).toHaveBeenCalledTimes(2); }); it('should call FileModel.create with correct parameters during createAssetAndFile', async () => { const [createdGeneration] = await serverDB .insert(generations) .values({ ...testGeneration, userId, asset: null, fileId: null }) .returning(); const asset = { url: 'new-asset.jpg', thumbnailUrl: 'new-thumbnail.jpg', width: 1024, height: 1024, } as ImageGenerationAsset; const fileData = { name: 'test-image.jpg', url: 'https://example.com/test-image.jpg', size: 1024, fileType: 'image/jpeg', }; await generationModel.createAssetAndFile(createdGeneration.id, asset, fileData); expect(mockFileModelCreate).toHaveBeenCalledWith( { ...fileData, source: FileSource.ImageGeneration, }, true, expect.any(Object), ); expect(mockFileModelCreate).toHaveBeenCalledTimes(1); }); it('should handle FileService errors gracefully', async () => { mockGetFullFileUrl.mockRejectedValue(new Error('FileService error')); const [createdGeneration] = await serverDB .insert(generations) .values({ ...testGeneration, userId }) .returning(); await expect(generationModel.findByIdAndTransform(createdGeneration.id)).rejects.toThrow( 'FileService error', ); }); }); });