mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-20 01:06:11 +01:00
419 lines
14 KiB
JavaScript
419 lines
14 KiB
JavaScript
|
|
// Configurable file size limit for tests - use a getter so it can be changed per test
|
||
|
|
const fileSizeLimitConfig = { value: 20 * 1024 * 1024 }; // Default 20MB
|
||
|
|
|
||
|
|
// Mock librechat-data-provider with configurable file size limit
|
||
|
|
jest.mock('librechat-data-provider', () => {
|
||
|
|
const actual = jest.requireActual('librechat-data-provider');
|
||
|
|
return {
|
||
|
|
...actual,
|
||
|
|
mergeFileConfig: jest.fn((config) => {
|
||
|
|
const merged = actual.mergeFileConfig(config);
|
||
|
|
// Override the serverFileSizeLimit with our test value
|
||
|
|
return {
|
||
|
|
...merged,
|
||
|
|
get serverFileSizeLimit() {
|
||
|
|
return fileSizeLimitConfig.value;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}),
|
||
|
|
getEndpointFileConfig: jest.fn((options) => {
|
||
|
|
const config = actual.getEndpointFileConfig(options);
|
||
|
|
// Override fileSizeLimit with our test value
|
||
|
|
return {
|
||
|
|
...config,
|
||
|
|
get fileSizeLimit() {
|
||
|
|
return fileSizeLimitConfig.value;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}),
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
const { FileContext } = require('librechat-data-provider');
|
||
|
|
|
||
|
|
// Mock uuid
|
||
|
|
jest.mock('uuid', () => ({
|
||
|
|
v4: jest.fn(() => 'mock-uuid-1234'),
|
||
|
|
}));
|
||
|
|
|
||
|
|
// Mock axios
|
||
|
|
jest.mock('axios');
|
||
|
|
const axios = require('axios');
|
||
|
|
|
||
|
|
// Mock logger
|
||
|
|
jest.mock('@librechat/data-schemas', () => ({
|
||
|
|
logger: {
|
||
|
|
warn: jest.fn(),
|
||
|
|
debug: jest.fn(),
|
||
|
|
error: jest.fn(),
|
||
|
|
},
|
||
|
|
}));
|
||
|
|
|
||
|
|
// Mock getCodeBaseURL
|
||
|
|
jest.mock('@librechat/agents', () => ({
|
||
|
|
getCodeBaseURL: jest.fn(() => 'https://code-api.example.com'),
|
||
|
|
}));
|
||
|
|
|
||
|
|
// Mock logAxiosError and getBasePath
|
||
|
|
jest.mock('@librechat/api', () => ({
|
||
|
|
logAxiosError: jest.fn(),
|
||
|
|
getBasePath: jest.fn(() => ''),
|
||
|
|
}));
|
||
|
|
|
||
|
|
// Mock models
|
||
|
|
jest.mock('~/models', () => ({
|
||
|
|
createFile: jest.fn(),
|
||
|
|
getFiles: jest.fn(),
|
||
|
|
updateFile: jest.fn(),
|
||
|
|
}));
|
||
|
|
|
||
|
|
// Mock permissions (must be before process.js import)
|
||
|
|
jest.mock('~/server/services/Files/permissions', () => ({
|
||
|
|
filterFilesByAgentAccess: jest.fn((options) => Promise.resolve(options.files)),
|
||
|
|
}));
|
||
|
|
|
||
|
|
// Mock strategy functions
|
||
|
|
jest.mock('~/server/services/Files/strategies', () => ({
|
||
|
|
getStrategyFunctions: jest.fn(),
|
||
|
|
}));
|
||
|
|
|
||
|
|
// Mock convertImage
|
||
|
|
jest.mock('~/server/services/Files/images/convert', () => ({
|
||
|
|
convertImage: jest.fn(),
|
||
|
|
}));
|
||
|
|
|
||
|
|
// Mock determineFileType
|
||
|
|
jest.mock('~/server/utils', () => ({
|
||
|
|
determineFileType: jest.fn(),
|
||
|
|
}));
|
||
|
|
|
||
|
|
const { createFile, getFiles } = require('~/models');
|
||
|
|
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||
|
|
const { convertImage } = require('~/server/services/Files/images/convert');
|
||
|
|
const { determineFileType } = require('~/server/utils');
|
||
|
|
const { logger } = require('@librechat/data-schemas');
|
||
|
|
|
||
|
|
// Import after mocks
|
||
|
|
const { processCodeOutput } = require('./process');
|
||
|
|
|
||
|
|
describe('Code Process', () => {
|
||
|
|
const mockReq = {
|
||
|
|
user: { id: 'user-123' },
|
||
|
|
config: {
|
||
|
|
fileConfig: {},
|
||
|
|
fileStrategy: 'local',
|
||
|
|
imageOutputType: 'webp',
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
const baseParams = {
|
||
|
|
req: mockReq,
|
||
|
|
id: 'file-id-123',
|
||
|
|
name: 'test-file.txt',
|
||
|
|
apiKey: 'test-api-key',
|
||
|
|
toolCallId: 'tool-call-123',
|
||
|
|
conversationId: 'conv-123',
|
||
|
|
messageId: 'msg-123',
|
||
|
|
session_id: 'session-123',
|
||
|
|
};
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
jest.clearAllMocks();
|
||
|
|
// Default mock implementations
|
||
|
|
getFiles.mockResolvedValue(null);
|
||
|
|
createFile.mockResolvedValue({});
|
||
|
|
getStrategyFunctions.mockReturnValue({
|
||
|
|
saveBuffer: jest.fn().mockResolvedValue('/uploads/mock-file-path.txt'),
|
||
|
|
});
|
||
|
|
determineFileType.mockResolvedValue({ mime: 'text/plain' });
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('findExistingCodeFile (via processCodeOutput)', () => {
|
||
|
|
it('should find existing file by filename and conversationId', async () => {
|
||
|
|
const existingFile = {
|
||
|
|
file_id: 'existing-file-id',
|
||
|
|
filename: 'test-file.txt',
|
||
|
|
usage: 2,
|
||
|
|
createdAt: '2024-01-01T00:00:00.000Z',
|
||
|
|
};
|
||
|
|
getFiles.mockResolvedValue([existingFile]);
|
||
|
|
|
||
|
|
const smallBuffer = Buffer.alloc(100);
|
||
|
|
axios.mockResolvedValue({ data: smallBuffer });
|
||
|
|
|
||
|
|
const result = await processCodeOutput(baseParams);
|
||
|
|
|
||
|
|
// Verify getFiles was called with correct deduplication query
|
||
|
|
expect(getFiles).toHaveBeenCalledWith(
|
||
|
|
{
|
||
|
|
filename: 'test-file.txt',
|
||
|
|
conversationId: 'conv-123',
|
||
|
|
context: FileContext.execute_code,
|
||
|
|
},
|
||
|
|
{ createdAt: -1 },
|
||
|
|
{ text: 0 },
|
||
|
|
);
|
||
|
|
|
||
|
|
// Verify the existing file_id was reused
|
||
|
|
expect(result.file_id).toBe('existing-file-id');
|
||
|
|
// Verify usage was incremented
|
||
|
|
expect(result.usage).toBe(3);
|
||
|
|
// Verify original createdAt was preserved
|
||
|
|
expect(result.createdAt).toBe('2024-01-01T00:00:00.000Z');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should create new file when no existing file found', async () => {
|
||
|
|
getFiles.mockResolvedValue(null);
|
||
|
|
|
||
|
|
const smallBuffer = Buffer.alloc(100);
|
||
|
|
axios.mockResolvedValue({ data: smallBuffer });
|
||
|
|
|
||
|
|
const result = await processCodeOutput(baseParams);
|
||
|
|
|
||
|
|
// Should use the mocked uuid
|
||
|
|
expect(result.file_id).toBe('mock-uuid-1234');
|
||
|
|
// Should have usage of 1 for new file
|
||
|
|
expect(result.usage).toBe(1);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should return null for invalid inputs (empty filename)', async () => {
|
||
|
|
const smallBuffer = Buffer.alloc(100);
|
||
|
|
axios.mockResolvedValue({ data: smallBuffer });
|
||
|
|
|
||
|
|
// The function handles this internally - with empty name
|
||
|
|
// findExistingCodeFile returns null early for empty filename (guard clause)
|
||
|
|
const result = await processCodeOutput({ ...baseParams, name: '' });
|
||
|
|
|
||
|
|
// getFiles should NOT be called due to early return in findExistingCodeFile
|
||
|
|
expect(getFiles).not.toHaveBeenCalled();
|
||
|
|
// A new file_id should be generated since no existing file was found
|
||
|
|
expect(result.file_id).toBe('mock-uuid-1234');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('processCodeOutput', () => {
|
||
|
|
describe('image file processing', () => {
|
||
|
|
it('should process image files using convertImage', async () => {
|
||
|
|
const imageParams = { ...baseParams, name: 'chart.png' };
|
||
|
|
const imageBuffer = Buffer.alloc(500);
|
||
|
|
axios.mockResolvedValue({ data: imageBuffer });
|
||
|
|
|
||
|
|
const convertedFile = {
|
||
|
|
filepath: '/uploads/converted-image.webp',
|
||
|
|
bytes: 400,
|
||
|
|
};
|
||
|
|
convertImage.mockResolvedValue(convertedFile);
|
||
|
|
getFiles.mockResolvedValue(null);
|
||
|
|
|
||
|
|
const result = await processCodeOutput(imageParams);
|
||
|
|
|
||
|
|
expect(convertImage).toHaveBeenCalledWith(
|
||
|
|
mockReq,
|
||
|
|
imageBuffer,
|
||
|
|
'high',
|
||
|
|
'mock-uuid-1234.png',
|
||
|
|
);
|
||
|
|
expect(result.type).toBe('image/webp');
|
||
|
|
expect(result.context).toBe(FileContext.execute_code);
|
||
|
|
expect(result.filename).toBe('chart.png');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should update existing image file and increment usage', async () => {
|
||
|
|
const imageParams = { ...baseParams, name: 'chart.png' };
|
||
|
|
const existingFile = {
|
||
|
|
file_id: 'existing-img-id',
|
||
|
|
usage: 1,
|
||
|
|
createdAt: '2024-01-01T00:00:00.000Z',
|
||
|
|
};
|
||
|
|
getFiles.mockResolvedValue([existingFile]);
|
||
|
|
|
||
|
|
const imageBuffer = Buffer.alloc(500);
|
||
|
|
axios.mockResolvedValue({ data: imageBuffer });
|
||
|
|
convertImage.mockResolvedValue({ filepath: '/uploads/img.webp' });
|
||
|
|
|
||
|
|
const result = await processCodeOutput(imageParams);
|
||
|
|
|
||
|
|
expect(result.file_id).toBe('existing-img-id');
|
||
|
|
expect(result.usage).toBe(2);
|
||
|
|
expect(logger.debug).toHaveBeenCalledWith(
|
||
|
|
expect.stringContaining('Updating existing file'),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('non-image file processing', () => {
|
||
|
|
it('should process non-image files using saveBuffer', async () => {
|
||
|
|
const smallBuffer = Buffer.alloc(100);
|
||
|
|
axios.mockResolvedValue({ data: smallBuffer });
|
||
|
|
|
||
|
|
const mockSaveBuffer = jest.fn().mockResolvedValue('/uploads/saved-file.txt');
|
||
|
|
getStrategyFunctions.mockReturnValue({ saveBuffer: mockSaveBuffer });
|
||
|
|
determineFileType.mockResolvedValue({ mime: 'text/plain' });
|
||
|
|
|
||
|
|
const result = await processCodeOutput(baseParams);
|
||
|
|
|
||
|
|
expect(mockSaveBuffer).toHaveBeenCalledWith({
|
||
|
|
userId: 'user-123',
|
||
|
|
buffer: smallBuffer,
|
||
|
|
fileName: 'mock-uuid-1234__test-file.txt',
|
||
|
|
basePath: 'uploads',
|
||
|
|
});
|
||
|
|
expect(result.type).toBe('text/plain');
|
||
|
|
expect(result.filepath).toBe('/uploads/saved-file.txt');
|
||
|
|
expect(result.bytes).toBe(100);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should detect MIME type from buffer', async () => {
|
||
|
|
const smallBuffer = Buffer.alloc(100);
|
||
|
|
axios.mockResolvedValue({ data: smallBuffer });
|
||
|
|
determineFileType.mockResolvedValue({ mime: 'application/pdf' });
|
||
|
|
|
||
|
|
const result = await processCodeOutput({ ...baseParams, name: 'document.pdf' });
|
||
|
|
|
||
|
|
expect(determineFileType).toHaveBeenCalledWith(smallBuffer, true);
|
||
|
|
expect(result.type).toBe('application/pdf');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should fallback to application/octet-stream for unknown types', async () => {
|
||
|
|
const smallBuffer = Buffer.alloc(100);
|
||
|
|
axios.mockResolvedValue({ data: smallBuffer });
|
||
|
|
determineFileType.mockResolvedValue(null);
|
||
|
|
|
||
|
|
const result = await processCodeOutput({ ...baseParams, name: 'unknown.xyz' });
|
||
|
|
|
||
|
|
expect(result.type).toBe('application/octet-stream');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('file size limit enforcement', () => {
|
||
|
|
it('should fallback to download URL when file exceeds size limit', async () => {
|
||
|
|
// Set a small file size limit for this test
|
||
|
|
fileSizeLimitConfig.value = 1000; // 1KB limit
|
||
|
|
|
||
|
|
const largeBuffer = Buffer.alloc(5000); // 5KB - exceeds 1KB limit
|
||
|
|
axios.mockResolvedValue({ data: largeBuffer });
|
||
|
|
|
||
|
|
const result = await processCodeOutput(baseParams);
|
||
|
|
|
||
|
|
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('exceeds size limit'));
|
||
|
|
expect(result.filepath).toContain('/api/files/code/download/session-123/file-id-123');
|
||
|
|
expect(result.expiresAt).toBeDefined();
|
||
|
|
// Should not call createFile for oversized files (fallback path)
|
||
|
|
expect(createFile).not.toHaveBeenCalled();
|
||
|
|
|
||
|
|
// Reset to default for other tests
|
||
|
|
fileSizeLimitConfig.value = 20 * 1024 * 1024;
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('fallback behavior', () => {
|
||
|
|
it('should fallback to download URL when saveBuffer is not available', async () => {
|
||
|
|
const smallBuffer = Buffer.alloc(100);
|
||
|
|
axios.mockResolvedValue({ data: smallBuffer });
|
||
|
|
getStrategyFunctions.mockReturnValue({ saveBuffer: null });
|
||
|
|
|
||
|
|
const result = await processCodeOutput(baseParams);
|
||
|
|
|
||
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
||
|
|
expect.stringContaining('saveBuffer not available'),
|
||
|
|
);
|
||
|
|
expect(result.filepath).toContain('/api/files/code/download/');
|
||
|
|
expect(result.filename).toBe('test-file.txt');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should fallback to download URL on axios error', async () => {
|
||
|
|
axios.mockRejectedValue(new Error('Network error'));
|
||
|
|
|
||
|
|
const result = await processCodeOutput(baseParams);
|
||
|
|
|
||
|
|
expect(result.filepath).toContain('/api/files/code/download/session-123/file-id-123');
|
||
|
|
expect(result.conversationId).toBe('conv-123');
|
||
|
|
expect(result.messageId).toBe('msg-123');
|
||
|
|
expect(result.toolCallId).toBe('tool-call-123');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('usage counter increment', () => {
|
||
|
|
it('should set usage to 1 for new files', async () => {
|
||
|
|
getFiles.mockResolvedValue(null);
|
||
|
|
const smallBuffer = Buffer.alloc(100);
|
||
|
|
axios.mockResolvedValue({ data: smallBuffer });
|
||
|
|
|
||
|
|
const result = await processCodeOutput(baseParams);
|
||
|
|
|
||
|
|
expect(result.usage).toBe(1);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should increment usage for existing files', async () => {
|
||
|
|
const existingFile = { file_id: 'existing-id', usage: 5, createdAt: '2024-01-01' };
|
||
|
|
getFiles.mockResolvedValue([existingFile]);
|
||
|
|
const smallBuffer = Buffer.alloc(100);
|
||
|
|
axios.mockResolvedValue({ data: smallBuffer });
|
||
|
|
|
||
|
|
const result = await processCodeOutput(baseParams);
|
||
|
|
|
||
|
|
expect(result.usage).toBe(6);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should handle existing file with undefined usage', async () => {
|
||
|
|
const existingFile = { file_id: 'existing-id', createdAt: '2024-01-01' };
|
||
|
|
getFiles.mockResolvedValue([existingFile]);
|
||
|
|
const smallBuffer = Buffer.alloc(100);
|
||
|
|
axios.mockResolvedValue({ data: smallBuffer });
|
||
|
|
|
||
|
|
const result = await processCodeOutput(baseParams);
|
||
|
|
|
||
|
|
// (undefined ?? 0) + 1 = 1
|
||
|
|
expect(result.usage).toBe(1);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('metadata and file properties', () => {
|
||
|
|
it('should include fileIdentifier in metadata', async () => {
|
||
|
|
const smallBuffer = Buffer.alloc(100);
|
||
|
|
axios.mockResolvedValue({ data: smallBuffer });
|
||
|
|
|
||
|
|
const result = await processCodeOutput(baseParams);
|
||
|
|
|
||
|
|
expect(result.metadata).toEqual({
|
||
|
|
fileIdentifier: 'session-123/file-id-123',
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should set correct context for code-generated files', async () => {
|
||
|
|
const smallBuffer = Buffer.alloc(100);
|
||
|
|
axios.mockResolvedValue({ data: smallBuffer });
|
||
|
|
|
||
|
|
const result = await processCodeOutput(baseParams);
|
||
|
|
|
||
|
|
expect(result.context).toBe(FileContext.execute_code);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should include toolCallId and messageId in result', async () => {
|
||
|
|
const smallBuffer = Buffer.alloc(100);
|
||
|
|
axios.mockResolvedValue({ data: smallBuffer });
|
||
|
|
|
||
|
|
const result = await processCodeOutput(baseParams);
|
||
|
|
|
||
|
|
expect(result.toolCallId).toBe('tool-call-123');
|
||
|
|
expect(result.messageId).toBe('msg-123');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should call createFile with upsert enabled', async () => {
|
||
|
|
const smallBuffer = Buffer.alloc(100);
|
||
|
|
axios.mockResolvedValue({ data: smallBuffer });
|
||
|
|
|
||
|
|
await processCodeOutput(baseParams);
|
||
|
|
|
||
|
|
expect(createFile).toHaveBeenCalledWith(
|
||
|
|
expect.objectContaining({
|
||
|
|
file_id: 'mock-uuid-1234',
|
||
|
|
context: FileContext.execute_code,
|
||
|
|
}),
|
||
|
|
true, // upsert flag
|
||
|
|
);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|