mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-25 03:44:09 +01:00
324 lines
12 KiB
JavaScript
324 lines
12 KiB
JavaScript
|
|
jest.mock('uuid', () => ({ v4: jest.fn(() => 'mock-uuid') }));
|
||
|
|
|
||
|
|
jest.mock('@librechat/data-schemas', () => ({
|
||
|
|
logger: { warn: jest.fn(), debug: jest.fn(), error: jest.fn() },
|
||
|
|
}));
|
||
|
|
|
||
|
|
jest.mock('@librechat/agents', () => ({
|
||
|
|
EnvVar: { CODE_API_KEY: 'CODE_API_KEY' },
|
||
|
|
}));
|
||
|
|
|
||
|
|
jest.mock('@librechat/api', () => ({
|
||
|
|
sanitizeFilename: jest.fn((n) => n),
|
||
|
|
parseText: jest.fn().mockResolvedValue({ text: '', bytes: 0 }),
|
||
|
|
processAudioFile: jest.fn(),
|
||
|
|
}));
|
||
|
|
|
||
|
|
jest.mock('librechat-data-provider', () => ({
|
||
|
|
...jest.requireActual('librechat-data-provider'),
|
||
|
|
mergeFileConfig: jest.fn(),
|
||
|
|
}));
|
||
|
|
|
||
|
|
jest.mock('~/server/services/Files/images', () => ({
|
||
|
|
convertImage: jest.fn(),
|
||
|
|
resizeAndConvert: jest.fn(),
|
||
|
|
resizeImageBuffer: jest.fn(),
|
||
|
|
}));
|
||
|
|
|
||
|
|
jest.mock('~/server/controllers/assistants/v2', () => ({
|
||
|
|
addResourceFileId: jest.fn(),
|
||
|
|
deleteResourceFileId: jest.fn(),
|
||
|
|
}));
|
||
|
|
|
||
|
|
jest.mock('~/models/Agent', () => ({
|
||
|
|
addAgentResourceFile: jest.fn().mockResolvedValue({}),
|
||
|
|
removeAgentResourceFiles: jest.fn(),
|
||
|
|
}));
|
||
|
|
|
||
|
|
jest.mock('~/server/controllers/assistants/helpers', () => ({
|
||
|
|
getOpenAIClient: jest.fn(),
|
||
|
|
}));
|
||
|
|
|
||
|
|
jest.mock('~/server/services/Tools/credentials', () => ({
|
||
|
|
loadAuthValues: jest.fn(),
|
||
|
|
}));
|
||
|
|
|
||
|
|
jest.mock('~/models', () => ({
|
||
|
|
createFile: jest.fn().mockResolvedValue({ file_id: 'created-file-id' }),
|
||
|
|
updateFileUsage: jest.fn(),
|
||
|
|
deleteFiles: jest.fn(),
|
||
|
|
}));
|
||
|
|
|
||
|
|
jest.mock('~/server/utils/getFileStrategy', () => ({
|
||
|
|
getFileStrategy: jest.fn().mockReturnValue('local'),
|
||
|
|
}));
|
||
|
|
|
||
|
|
jest.mock('~/server/services/Config', () => ({
|
||
|
|
checkCapability: jest.fn().mockResolvedValue(true),
|
||
|
|
}));
|
||
|
|
|
||
|
|
jest.mock('~/server/utils/queue', () => ({
|
||
|
|
LB_QueueAsyncCall: jest.fn(),
|
||
|
|
}));
|
||
|
|
|
||
|
|
jest.mock('~/server/services/Files/strategies', () => ({
|
||
|
|
getStrategyFunctions: jest.fn(),
|
||
|
|
}));
|
||
|
|
|
||
|
|
jest.mock('~/server/utils', () => ({
|
||
|
|
determineFileType: jest.fn(),
|
||
|
|
}));
|
||
|
|
|
||
|
|
jest.mock('~/server/services/Files/Audio/STTService', () => ({
|
||
|
|
STTService: { getInstance: jest.fn() },
|
||
|
|
}));
|
||
|
|
|
||
|
|
const { EToolResources, FileSources, AgentCapabilities } = require('librechat-data-provider');
|
||
|
|
const { mergeFileConfig } = require('librechat-data-provider');
|
||
|
|
const { checkCapability } = require('~/server/services/Config');
|
||
|
|
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||
|
|
const { processAgentFileUpload } = require('./process');
|
||
|
|
|
||
|
|
const PDF_MIME = 'application/pdf';
|
||
|
|
const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
||
|
|
const XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||
|
|
const XLS_MIME = 'application/vnd.ms-excel';
|
||
|
|
|
||
|
|
const makeReq = ({ mimetype = PDF_MIME, ocrConfig = null } = {}) => ({
|
||
|
|
user: { id: 'user-123' },
|
||
|
|
file: {
|
||
|
|
path: '/tmp/upload.bin',
|
||
|
|
originalname: 'upload.bin',
|
||
|
|
filename: 'upload-uuid.bin',
|
||
|
|
mimetype,
|
||
|
|
},
|
||
|
|
body: { model: 'gpt-4o' },
|
||
|
|
config: {
|
||
|
|
fileConfig: {},
|
||
|
|
fileStrategy: 'local',
|
||
|
|
ocr: ocrConfig,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const makeMetadata = () => ({
|
||
|
|
agent_id: 'agent-abc',
|
||
|
|
tool_resource: EToolResources.context,
|
||
|
|
file_id: 'file-uuid-123',
|
||
|
|
});
|
||
|
|
|
||
|
|
const mockRes = {
|
||
|
|
status: jest.fn().mockReturnThis(),
|
||
|
|
json: jest.fn().mockReturnValue({}),
|
||
|
|
};
|
||
|
|
|
||
|
|
const makeFileConfig = ({ ocrSupportedMimeTypes = [] } = {}) => ({
|
||
|
|
checkType: (mime, types) => (types ?? []).includes(mime),
|
||
|
|
ocr: { supportedMimeTypes: ocrSupportedMimeTypes },
|
||
|
|
stt: { supportedMimeTypes: [] },
|
||
|
|
text: { supportedMimeTypes: [] },
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('processAgentFileUpload', () => {
|
||
|
|
beforeEach(() => {
|
||
|
|
jest.clearAllMocks();
|
||
|
|
mockRes.status.mockReturnThis();
|
||
|
|
mockRes.json.mockReturnValue({});
|
||
|
|
checkCapability.mockResolvedValue(true);
|
||
|
|
getStrategyFunctions.mockReturnValue({
|
||
|
|
handleFileUpload: jest
|
||
|
|
.fn()
|
||
|
|
.mockResolvedValue({ text: 'extracted text', bytes: 42, filepath: 'doc://result' }),
|
||
|
|
});
|
||
|
|
mergeFileConfig.mockReturnValue(makeFileConfig());
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('OCR strategy selection', () => {
|
||
|
|
test.each([
|
||
|
|
['PDF', PDF_MIME],
|
||
|
|
['DOCX', DOCX_MIME],
|
||
|
|
['XLSX', XLSX_MIME],
|
||
|
|
['XLS', XLS_MIME],
|
||
|
|
])('uses document_parser automatically for %s when no OCR is configured', async (_, mime) => {
|
||
|
|
mergeFileConfig.mockReturnValue(makeFileConfig());
|
||
|
|
const req = makeReq({ mimetype: mime, ocrConfig: null });
|
||
|
|
|
||
|
|
await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() });
|
||
|
|
|
||
|
|
expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.document_parser);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('does not check OCR capability when using automatic document_parser fallback', async () => {
|
||
|
|
const req = makeReq({ mimetype: PDF_MIME, ocrConfig: null });
|
||
|
|
|
||
|
|
await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() });
|
||
|
|
|
||
|
|
expect(checkCapability).not.toHaveBeenCalledWith(expect.anything(), AgentCapabilities.ocr);
|
||
|
|
expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.document_parser);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('uses the configured OCR strategy when OCR is set up for the file type', async () => {
|
||
|
|
mergeFileConfig.mockReturnValue(makeFileConfig({ ocrSupportedMimeTypes: [PDF_MIME] }));
|
||
|
|
const req = makeReq({
|
||
|
|
mimetype: PDF_MIME,
|
||
|
|
ocrConfig: { strategy: FileSources.mistral_ocr },
|
||
|
|
});
|
||
|
|
|
||
|
|
await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() });
|
||
|
|
|
||
|
|
expect(checkCapability).toHaveBeenCalledWith(expect.anything(), AgentCapabilities.ocr);
|
||
|
|
expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.mistral_ocr);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('uses document_parser as default when OCR is configured but no strategy is specified', async () => {
|
||
|
|
mergeFileConfig.mockReturnValue(makeFileConfig({ ocrSupportedMimeTypes: [PDF_MIME] }));
|
||
|
|
const req = makeReq({
|
||
|
|
mimetype: PDF_MIME,
|
||
|
|
ocrConfig: { supportedMimeTypes: [PDF_MIME] },
|
||
|
|
});
|
||
|
|
|
||
|
|
await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() });
|
||
|
|
|
||
|
|
expect(checkCapability).toHaveBeenCalledWith(expect.anything(), AgentCapabilities.ocr);
|
||
|
|
expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.document_parser);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('throws when configured OCR capability is not enabled for the agent', async () => {
|
||
|
|
mergeFileConfig.mockReturnValue(makeFileConfig({ ocrSupportedMimeTypes: [PDF_MIME] }));
|
||
|
|
checkCapability.mockResolvedValue(false);
|
||
|
|
const req = makeReq({
|
||
|
|
mimetype: PDF_MIME,
|
||
|
|
ocrConfig: { strategy: FileSources.mistral_ocr },
|
||
|
|
});
|
||
|
|
|
||
|
|
await expect(
|
||
|
|
processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }),
|
||
|
|
).rejects.toThrow('OCR capability is not enabled for Agents');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('uses document_parser (no capability check) when OCR capability returns false but no OCR config', async () => {
|
||
|
|
checkCapability.mockResolvedValue(false);
|
||
|
|
const req = makeReq({ mimetype: PDF_MIME, ocrConfig: null });
|
||
|
|
|
||
|
|
await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() });
|
||
|
|
|
||
|
|
expect(checkCapability).not.toHaveBeenCalledWith(expect.anything(), AgentCapabilities.ocr);
|
||
|
|
expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.document_parser);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('uses document_parser when OCR is configured but the file type is not in OCR supported types', async () => {
|
||
|
|
mergeFileConfig.mockReturnValue(makeFileConfig({ ocrSupportedMimeTypes: [PDF_MIME] }));
|
||
|
|
const req = makeReq({
|
||
|
|
mimetype: DOCX_MIME,
|
||
|
|
ocrConfig: { strategy: FileSources.mistral_ocr },
|
||
|
|
});
|
||
|
|
|
||
|
|
await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() });
|
||
|
|
|
||
|
|
expect(checkCapability).not.toHaveBeenCalledWith(expect.anything(), AgentCapabilities.ocr);
|
||
|
|
expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.document_parser);
|
||
|
|
expect(getStrategyFunctions).not.toHaveBeenCalledWith(FileSources.mistral_ocr);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('does not invoke any OCR strategy for unsupported MIME types without OCR config', async () => {
|
||
|
|
const req = makeReq({ mimetype: 'text/plain', ocrConfig: null });
|
||
|
|
|
||
|
|
await expect(
|
||
|
|
processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }),
|
||
|
|
).rejects.toThrow('File type text/plain is not supported for text parsing.');
|
||
|
|
|
||
|
|
expect(getStrategyFunctions).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('throws instead of falling back to parseText when document_parser fails for a document MIME type', async () => {
|
||
|
|
getStrategyFunctions.mockReturnValue({
|
||
|
|
handleFileUpload: jest.fn().mockRejectedValue(new Error('No text found in document')),
|
||
|
|
});
|
||
|
|
const req = makeReq({ mimetype: PDF_MIME, ocrConfig: null });
|
||
|
|
const { parseText } = require('@librechat/api');
|
||
|
|
|
||
|
|
await expect(
|
||
|
|
processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }),
|
||
|
|
).rejects.toThrow(/image-based and requires an OCR service/);
|
||
|
|
|
||
|
|
expect(parseText).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('falls back to document_parser when configured OCR fails for a document MIME type', async () => {
|
||
|
|
mergeFileConfig.mockReturnValue(makeFileConfig({ ocrSupportedMimeTypes: [PDF_MIME] }));
|
||
|
|
const failingUpload = jest.fn().mockRejectedValue(new Error('OCR API returned 500'));
|
||
|
|
const fallbackUpload = jest
|
||
|
|
.fn()
|
||
|
|
.mockResolvedValue({ text: 'parsed text', bytes: 11, filepath: 'doc://result' });
|
||
|
|
getStrategyFunctions
|
||
|
|
.mockReturnValueOnce({ handleFileUpload: failingUpload })
|
||
|
|
.mockReturnValueOnce({ handleFileUpload: fallbackUpload });
|
||
|
|
const req = makeReq({
|
||
|
|
mimetype: PDF_MIME,
|
||
|
|
ocrConfig: { strategy: FileSources.mistral_ocr },
|
||
|
|
});
|
||
|
|
|
||
|
|
await expect(
|
||
|
|
processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }),
|
||
|
|
).resolves.not.toThrow();
|
||
|
|
|
||
|
|
expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.mistral_ocr);
|
||
|
|
expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.document_parser);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('throws when both configured OCR and document_parser fallback fail', async () => {
|
||
|
|
mergeFileConfig.mockReturnValue(makeFileConfig({ ocrSupportedMimeTypes: [PDF_MIME] }));
|
||
|
|
getStrategyFunctions.mockReturnValue({
|
||
|
|
handleFileUpload: jest.fn().mockRejectedValue(new Error('failure')),
|
||
|
|
});
|
||
|
|
const req = makeReq({
|
||
|
|
mimetype: PDF_MIME,
|
||
|
|
ocrConfig: { strategy: FileSources.mistral_ocr },
|
||
|
|
});
|
||
|
|
const { parseText } = require('@librechat/api');
|
||
|
|
|
||
|
|
await expect(
|
||
|
|
processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }),
|
||
|
|
).rejects.toThrow(/image-based and requires an OCR service/);
|
||
|
|
|
||
|
|
expect(parseText).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('text size guard', () => {
|
||
|
|
test('throws before writing to MongoDB when extracted text exceeds 15MB', async () => {
|
||
|
|
const oversizedText = 'x'.repeat(15 * 1024 * 1024 + 1);
|
||
|
|
getStrategyFunctions.mockReturnValue({
|
||
|
|
handleFileUpload: jest.fn().mockResolvedValue({
|
||
|
|
text: oversizedText,
|
||
|
|
bytes: Buffer.byteLength(oversizedText, 'utf8'),
|
||
|
|
filepath: 'doc://result',
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
const req = makeReq({ mimetype: PDF_MIME, ocrConfig: null });
|
||
|
|
const { createFile } = require('~/models');
|
||
|
|
|
||
|
|
await expect(
|
||
|
|
processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }),
|
||
|
|
).rejects.toThrow(/exceeds the 15MB storage limit/);
|
||
|
|
|
||
|
|
expect(createFile).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('succeeds when extracted text is within the 15MB limit', async () => {
|
||
|
|
const okText = 'x'.repeat(1024);
|
||
|
|
getStrategyFunctions.mockReturnValue({
|
||
|
|
handleFileUpload: jest.fn().mockResolvedValue({
|
||
|
|
text: okText,
|
||
|
|
bytes: Buffer.byteLength(okText, 'utf8'),
|
||
|
|
filepath: 'doc://result',
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
const req = makeReq({ mimetype: PDF_MIME, ocrConfig: null });
|
||
|
|
|
||
|
|
await expect(
|
||
|
|
processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }),
|
||
|
|
).resolves.not.toThrow();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|