@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.
615 lines (516 loc) • 20 kB
text/typescript
// @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 { GenerationConfig } from '@/types/generation';
import {
NewGenerationBatch,
generationBatches,
generationTopics,
generations,
users,
} from '../../schemas';
import { GenerationBatchModel } from '../generationBatch';
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 GenerationModel
const mockTransformGeneration = vi.fn();
vi.mock('../generation', () => ({
GenerationModel: vi.fn().mockImplementation(() => ({
transformGeneration: mockTransformGeneration,
})),
}));
const userId = 'generation-batch-test-user-id';
const otherUserId = 'other-user-id';
const generationBatchModel = new GenerationBatchModel(serverDB, userId);
// Test data
const testTopic = {
id: 'test-topic-id',
userId,
title: 'Test Generation Topic',
coverUrl: null,
};
const testBatch: NewGenerationBatch = {
userId, // Required by schema, but will be overridden by model
generationTopicId: 'test-topic-id',
provider: 'test-provider',
model: 'test-model',
prompt: 'Test prompt for image generation',
width: 1024,
height: 1024,
config: {
prompt: 'Test prompt for image generation',
imageUrls: ['image1.jpg', 'image2.jpg'],
width: 1024,
height: 1024,
} as GenerationConfig,
};
const testGeneration = {
id: 'test-gen-id',
generationBatchId: 'test-batch-id',
asyncTaskId: null, // Use null instead of invalid foreign key
fileId: null, // Use null instead of invalid foreign key
seed: 12345,
asset: {
type: 'image',
url: 'asset-url.jpg',
thumbnailUrl: 'thumbnail-url.jpg',
width: 1024,
height: 1024,
},
userId,
};
beforeEach(async () => {
// Clear all mocks
vi.clearAllMocks();
// Setup mock return values
mockGetFullFileUrl.mockImplementation((url: string) => `https://example.com/${url}`);
mockTransformGeneration.mockResolvedValue({
id: testGeneration.id,
asset: {
url: 'https://example.com/asset-url.jpg',
thumbnailUrl: 'https://example.com/thumbnail-url.jpg',
width: 1024,
height: 1024,
},
seed: testGeneration.seed,
createdAt: new Date(),
asyncTaskId: testGeneration.asyncTaskId,
task: {
id: testGeneration.asyncTaskId,
status: AsyncTaskStatus.Success,
},
});
// 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);
});
afterEach(async () => {
// Clean up database
await serverDB.delete(users);
});
describe('GenerationBatchModel', () => {
describe('create', () => {
it('should create a new generation batch', async () => {
const result = await generationBatchModel.create(testBatch);
expect(result.id).toBeDefined();
expect(result).toMatchObject({
...testBatch,
userId,
});
// Verify in database
const dbBatch = await serverDB.query.generationBatches.findFirst({
where: eq(generationBatches.id, result.id),
});
expect(dbBatch).toMatchObject({
...testBatch,
userId,
});
});
it('should automatically set userId when creating batch', async () => {
// Create batch data without userId to test auto-assignment
const batchWithoutUserId = {
generationTopicId: 'test-topic-id',
provider: 'test-provider',
model: 'test-model',
prompt: 'Test prompt for image generation',
width: 1024,
height: 1024,
config: {
prompt: 'Test prompt for image generation',
imageUrls: ['image1.jpg', 'image2.jpg'],
width: 1024,
height: 1024,
} as GenerationConfig,
};
const result = await generationBatchModel.create(batchWithoutUserId as NewGenerationBatch);
expect(result.userId).toBe(userId);
});
});
describe('findById', () => {
it('should find a generation batch by id', async () => {
const [createdBatch] = await serverDB
.insert(generationBatches)
.values({ ...testBatch, userId })
.returning();
const result = await generationBatchModel.findById(createdBatch.id);
expect(result).toMatchObject({
id: createdBatch.id,
...testBatch,
userId,
});
});
it('should return undefined for non-existent batch', async () => {
const result = await generationBatchModel.findById('non-existent-id');
expect(result).toBeUndefined();
});
it('should NOT find batches from other users', async () => {
// Create batch for other user
const [otherUserBatch] = await serverDB
.insert(generationBatches)
.values({ ...testBatch, userId: otherUserId })
.returning();
const result = await generationBatchModel.findById(otherUserBatch.id);
expect(result).toBeUndefined();
});
});
describe('findByTopicId', () => {
it('should find batches by topic id', async () => {
// Create multiple batches for the topic with explicit timestamps
const oldDate = new Date('2024-01-01T10:00:00Z');
const newDate = new Date('2024-01-02T10:00:00Z');
const batch1 = { ...testBatch, prompt: 'First batch', userId, createdAt: oldDate };
const batch2 = { ...testBatch, prompt: 'Second batch', userId, createdAt: newDate };
await serverDB.insert(generationBatches).values([batch1, batch2]);
const results = await generationBatchModel.findByTopicId(testTopic.id);
expect(results).toHaveLength(2);
expect(results[0].prompt).toBe('Second batch'); // Latest first (desc order)
expect(results[1].prompt).toBe('First batch');
});
it('should only return batches for current user', async () => {
// Create batches for both users
await serverDB.insert(generationBatches).values([
{ ...testBatch, userId, prompt: 'User batch' },
{ ...testBatch, userId: otherUserId, prompt: 'Other user batch' },
]);
const results = await generationBatchModel.findByTopicId(testTopic.id);
expect(results).toHaveLength(1);
expect(results[0].prompt).toBe('User batch');
});
it('should return empty array when no batches exist for topic', async () => {
const results = await generationBatchModel.findByTopicId('non-existent-topic');
expect(results).toHaveLength(0);
});
});
describe('findByTopicIdWithGenerations', () => {
it('should find batches with their generations', async () => {
// Create batch
const [createdBatch] = await serverDB
.insert(generationBatches)
.values({ ...testBatch, userId })
.returning();
// Create generations for the batch
await serverDB.insert(generations).values([
{ ...testGeneration, generationBatchId: createdBatch.id },
{ ...testGeneration, id: 'gen2', generationBatchId: createdBatch.id },
]);
const results = await generationBatchModel.findByTopicIdWithGenerations(testTopic.id);
expect(results).toHaveLength(1);
expect(results[0].generations).toHaveLength(2);
// Generations are ordered by createdAt ASC, then by id ASC
// Since both have same createdAt, 'gen2' comes before 'test-gen-id' alphabetically
expect(results[0].generations[0].id).toBe('gen2');
});
it('should order generations by createdAt and id', async () => {
const [createdBatch] = await serverDB
.insert(generationBatches)
.values({ ...testBatch, userId })
.returning();
// Create generations with different timestamps
const oldDate = new Date('2024-01-01');
const newDate = new Date('2024-01-02');
await serverDB.insert(generations).values([
{
...testGeneration,
id: 'gen-new',
generationBatchId: createdBatch.id,
createdAt: newDate,
},
{
...testGeneration,
id: 'gen-old',
generationBatchId: createdBatch.id,
createdAt: oldDate,
},
]);
const results = await generationBatchModel.findByTopicIdWithGenerations(testTopic.id);
expect(results[0].generations[0].id).toBe('gen-old');
expect(results[0].generations[1].id).toBe('gen-new');
});
it('should NOT include generations from other users', async () => {
// Create batch for current user
const [userBatch] = await serverDB
.insert(generationBatches)
.values({ ...testBatch, userId })
.returning();
// Create batch for other user
const [otherBatch] = await serverDB
.insert(generationBatches)
.values({ ...testBatch, userId: otherUserId })
.returning();
// Create generations for both batches
await serverDB.insert(generations).values([
{ ...testGeneration, generationBatchId: userBatch.id, userId },
{
...testGeneration,
id: 'other-gen',
generationBatchId: otherBatch.id,
userId: otherUserId,
},
]);
const results = await generationBatchModel.findByTopicIdWithGenerations(testTopic.id);
expect(results).toHaveLength(1);
expect(results[0].id).toBe(userBatch.id);
});
});
describe('queryGenerationBatchesByTopicIdWithGenerations', () => {
it('should return transformed batches with generations', async () => {
// Create batch
const [createdBatch] = await serverDB
.insert(generationBatches)
.values({ ...testBatch, userId })
.returning();
// Create generation
await serverDB
.insert(generations)
.values([{ ...testGeneration, generationBatchId: createdBatch.id }]);
const results = await generationBatchModel.queryGenerationBatchesByTopicIdWithGenerations(
testTopic.id,
);
expect(results).toHaveLength(1);
expect(results[0]).toMatchObject({
id: createdBatch.id,
provider: testBatch.provider,
model: testBatch.model,
prompt: testBatch.prompt,
width: testBatch.width,
height: testBatch.height,
generations: expect.any(Array),
});
// Verify FileService was called for config imageUrls
expect(mockGetFullFileUrl).toHaveBeenCalledWith('image1.jpg');
expect(mockGetFullFileUrl).toHaveBeenCalledWith('image2.jpg');
// Verify GenerationModel.transformGeneration was called
expect(mockTransformGeneration).toHaveBeenCalledTimes(1);
});
it('should transform config imageUrls through FileService', async () => {
const [createdBatch] = await serverDB
.insert(generationBatches)
.values({
...testBatch,
userId,
config: { imageUrls: ['url1.jpg', 'url2.jpg'] },
})
.returning();
const results = await generationBatchModel.queryGenerationBatchesByTopicIdWithGenerations(
testTopic.id,
);
expect(results[0].config).toEqual({
imageUrls: ['https://example.com/url1.jpg', 'https://example.com/url2.jpg'],
});
});
it('should handle config without imageUrls', async () => {
const [createdBatch] = await serverDB
.insert(generationBatches)
.values({
...testBatch,
userId,
config: { otherField: 'value' },
})
.returning();
const results = await generationBatchModel.queryGenerationBatchesByTopicIdWithGenerations(
testTopic.id,
);
expect(results[0].config).toEqual({ otherField: 'value' });
expect(mockGetFullFileUrl).not.toHaveBeenCalled();
});
it('should return empty array when no batches exist', async () => {
const results =
await generationBatchModel.queryGenerationBatchesByTopicIdWithGenerations(
'non-existent-topic',
);
expect(results).toHaveLength(0);
});
it('should handle batches without generations', async () => {
await serverDB.insert(generationBatches).values({ ...testBatch, userId });
const results = await generationBatchModel.queryGenerationBatchesByTopicIdWithGenerations(
testTopic.id,
);
expect(results).toHaveLength(1);
expect(results[0].generations).toHaveLength(0);
});
});
describe('delete', () => {
it('should delete a generation batch and return batch data with thumbnail URLs', async () => {
const [createdBatch] = await serverDB
.insert(generationBatches)
.values({ ...testBatch, userId })
.returning();
// Create generation with thumbnail URL
await serverDB.insert(generations).values({
...testGeneration,
generationBatchId: createdBatch.id,
asset: {
type: 'image',
url: 'asset-url.jpg',
thumbnailUrl: 'thumbnail-url.jpg',
width: 1024,
height: 1024,
},
});
const result = await generationBatchModel.delete(createdBatch.id);
// Verify return value structure
expect(result).toBeDefined();
expect(result!.deletedBatch).toMatchObject({
id: createdBatch.id,
...testBatch,
userId,
});
expect(result!.thumbnailUrls).toEqual(['thumbnail-url.jpg']);
// Verify batch was actually deleted from database
const deletedBatch = await serverDB.query.generationBatches.findFirst({
where: eq(generationBatches.id, createdBatch.id),
});
expect(deletedBatch).toBeUndefined();
});
it('should collect multiple thumbnail URLs from multiple generations', async () => {
const [createdBatch] = await serverDB
.insert(generationBatches)
.values({ ...testBatch, userId })
.returning();
// Create multiple generations with different thumbnail URLs
await serverDB.insert(generations).values([
{
...testGeneration,
id: 'gen1',
generationBatchId: createdBatch.id,
asset: {
type: 'image',
url: 'asset1.jpg',
thumbnailUrl: 'thumbnail1.jpg',
width: 1024,
height: 1024,
},
},
{
...testGeneration,
id: 'gen2',
generationBatchId: createdBatch.id,
asset: {
type: 'image',
url: 'asset2.jpg',
thumbnailUrl: 'thumbnail2.jpg',
width: 1024,
height: 1024,
},
},
]);
const result = await generationBatchModel.delete(createdBatch.id);
expect(result).toBeDefined();
expect(result!.thumbnailUrls).toHaveLength(2);
expect(result!.thumbnailUrls).toContain('thumbnail1.jpg');
expect(result!.thumbnailUrls).toContain('thumbnail2.jpg');
});
it('should return empty thumbnail URLs when no generations have thumbnails', async () => {
const [createdBatch] = await serverDB
.insert(generationBatches)
.values({ ...testBatch, userId })
.returning();
const result = await generationBatchModel.delete(createdBatch.id);
expect(result).toBeDefined();
expect(result!.deletedBatch.id).toBe(createdBatch.id);
expect(result!.thumbnailUrls).toEqual([]);
});
it('should return undefined when trying to delete non-existent batch', async () => {
const result = await generationBatchModel.delete('non-existent-id');
expect(result).toBeUndefined();
});
it('should return undefined when trying to delete batch from other user', async () => {
// Create batch for other user
const [otherUserBatch] = await serverDB
.insert(generationBatches)
.values({ ...testBatch, userId: otherUserId })
.returning();
// Try to delete using current user's model
const result = await generationBatchModel.delete(otherUserBatch.id);
expect(result).toBeUndefined();
// Verify batch still exists
const stillExists = await serverDB.query.generationBatches.findFirst({
where: eq(generationBatches.id, otherUserBatch.id),
});
expect(stillExists).toBeDefined();
});
});
describe('user isolation security tests', () => {
it('should enforce user data isolation across all methods', async () => {
// Create batches for both users with same topic
const userBatch = { ...testBatch, userId };
const otherUserBatch = { ...testBatch, userId: otherUserId };
const [userBatchCreated] = await serverDB
.insert(generationBatches)
.values(userBatch)
.returning();
const [otherUserBatchCreated] = await serverDB
.insert(generationBatches)
.values(otherUserBatch)
.returning();
// Test findById isolation
const foundUserBatch = await generationBatchModel.findById(userBatchCreated.id);
const foundOtherBatch = await generationBatchModel.findById(otherUserBatchCreated.id);
expect(foundUserBatch).toBeDefined();
expect(foundOtherBatch).toBeUndefined(); // Should not find other user's batch
// Test findByTopicId isolation
const topicBatches = await generationBatchModel.findByTopicId(testTopic.id);
expect(topicBatches).toHaveLength(1);
expect(topicBatches[0].id).toBe(userBatchCreated.id);
// Test delete isolation - should not affect other user's data
await generationBatchModel.delete(otherUserBatchCreated.id);
const otherUserBatchStillExists = await serverDB.query.generationBatches.findFirst({
where: eq(generationBatches.id, otherUserBatchCreated.id),
});
expect(otherUserBatchStillExists).toBeDefined(); // Should not be deleted
});
});
describe('External service integration', () => {
it('should call FileService with correct parameters', async () => {
const [createdBatch] = await serverDB
.insert(generationBatches)
.values({
...testBatch,
userId,
config: { imageUrls: ['test-image.jpg'] },
})
.returning();
await generationBatchModel.queryGenerationBatchesByTopicIdWithGenerations(testTopic.id);
expect(mockGetFullFileUrl).toHaveBeenCalledWith('test-image.jpg');
expect(mockGetFullFileUrl).toHaveBeenCalledTimes(1);
});
it('should call GenerationModel.transformGeneration for each generation', async () => {
const [createdBatch] = await serverDB
.insert(generationBatches)
.values({ ...testBatch, userId })
.returning();
// Create multiple generations
await serverDB.insert(generations).values([
{ ...testGeneration, id: 'gen1', generationBatchId: createdBatch.id },
{ ...testGeneration, id: 'gen2', generationBatchId: createdBatch.id },
]);
await generationBatchModel.queryGenerationBatchesByTopicIdWithGenerations(testTopic.id);
expect(mockTransformGeneration).toHaveBeenCalledTimes(2);
});
it('should handle FileService errors gracefully', async () => {
mockGetFullFileUrl.mockRejectedValue(new Error('FileService error'));
const [createdBatch] = await serverDB
.insert(generationBatches)
.values({
...testBatch,
userId,
config: { imageUrls: ['failing-image.jpg'] },
})
.returning();
await expect(
generationBatchModel.queryGenerationBatchesByTopicIdWithGenerations(testTopic.id),
).rejects.toThrow('FileService error');
});
});
});