mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-10 10:05:18 +00:00
* feat: add agent file retention exemption * refactor: centralize agent file retention policy
1338 lines
46 KiB
JavaScript
1338 lines
46 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(), info: jest.fn() },
|
||
runAsSystem: jest.fn((fn) => fn()),
|
||
createTempChatExpirationDate: jest.fn(() => new Date('2030-01-01T00:00:00.000Z')),
|
||
}));
|
||
|
||
jest.mock('@librechat/agents', () => ({
|
||
Providers: {
|
||
XAI: 'xai',
|
||
DEEPSEEK: 'deepseek',
|
||
MOONSHOT: 'moonshot',
|
||
OPENROUTER: 'openrouter',
|
||
VERTEXAI: 'vertexai',
|
||
},
|
||
}));
|
||
|
||
jest.mock('librechat-data-provider', () => {
|
||
const actual = jest.requireActual('librechat-data-provider');
|
||
return {
|
||
...actual,
|
||
Providers: actual.Providers,
|
||
RetentionMode: actual.RetentionMode ?? { ALL: 'all', TEMPORARY: 'temporary' },
|
||
documentParserMimeTypes: actual.documentParserMimeTypes ?? [
|
||
/^application\/pdf$/,
|
||
/^application\/vnd\.openxmlformats-officedocument\./,
|
||
/^application\/vnd\.ms-excel$/,
|
||
/^application\/vnd\.oasis\.opendocument\./,
|
||
/^application\/(?:x-)?msexcel$/,
|
||
],
|
||
mergeFileConfig: jest.fn(),
|
||
};
|
||
});
|
||
|
||
jest.mock('@librechat/api', () => {
|
||
const actualDataProvider = jest.requireActual('librechat-data-provider');
|
||
const RetentionMode = actualDataProvider.RetentionMode ?? { ALL: 'all', TEMPORARY: 'temporary' };
|
||
const getRetentionExpiry = jest.fn(() => ({}));
|
||
return {
|
||
sanitizeFilename: jest.fn((n) => n),
|
||
parseText: jest.fn().mockResolvedValue({ text: '', bytes: 0 }),
|
||
processAudioFile: jest.fn(),
|
||
getStorageMetadata: jest.fn(() => ({})),
|
||
getRetentionExpiry,
|
||
getAgentFileRetentionExpiry: jest.fn(({ req, messageAttachment, toolResource }) => {
|
||
const interfaceConfig = req?.config?.interfaceConfig;
|
||
if (
|
||
!messageAttachment &&
|
||
!!toolResource &&
|
||
(interfaceConfig?.retentionMode !== RetentionMode.ALL ||
|
||
interfaceConfig?.retainAgentFiles === true)
|
||
) {
|
||
return {};
|
||
}
|
||
return getRetentionExpiry(req);
|
||
}),
|
||
sweepExpiredFiles: jest.fn().mockResolvedValue({ scanned: 0, deleted: 0, failed: 0 }),
|
||
startExpiredFileSweep: jest.fn().mockReturnValue('sweep-interval'),
|
||
};
|
||
});
|
||
|
||
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('~/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(),
|
||
findFileById: jest.fn(),
|
||
getConvo: jest.fn(),
|
||
getExpiredFiles: jest.fn(),
|
||
addAgentResourceFile: jest.fn().mockResolvedValue({}),
|
||
removeAgentResourceFiles: jest.fn(),
|
||
removeAgentResourceFilesFromAllAgents: 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('./VectorDB/crud', () => ({
|
||
uploadVectors: jest.fn().mockResolvedValue({
|
||
bytes: 42,
|
||
filename: 'upload.bin',
|
||
filepath: 'vectordb',
|
||
embedded: true,
|
||
}),
|
||
deleteVectors: jest.fn(),
|
||
}));
|
||
|
||
jest.mock('~/server/utils', () => ({
|
||
determineFileType: jest.fn(),
|
||
}));
|
||
|
||
jest.mock('~/server/services/Files/Audio/STTService', () => ({
|
||
STTService: { getInstance: jest.fn() },
|
||
}));
|
||
|
||
const {
|
||
getRetentionExpiry,
|
||
getAgentFileRetentionExpiry,
|
||
sweepExpiredFiles: sweepExpiredFilesWithDeps,
|
||
startExpiredFileSweep: startExpiredFileSweepWithDeps,
|
||
} = require('@librechat/api');
|
||
const {
|
||
EToolResources,
|
||
FileSources,
|
||
FileContext,
|
||
RetentionMode,
|
||
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 { uploadVectors } = require('./VectorDB/crud');
|
||
const db = require('~/models');
|
||
const {
|
||
processAgentFileUpload,
|
||
processDeleteRequest,
|
||
processFileURL,
|
||
sweepExpiredFiles,
|
||
startExpiredFileSweep,
|
||
} = 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 ODS_MIME = 'application/vnd.oasis.opendocument.spreadsheet';
|
||
const ODT_MIME = 'application/vnd.oasis.opendocument.text';
|
||
const ODP_MIME = 'application/vnd.oasis.opendocument.presentation';
|
||
const ODG_MIME = 'application/vnd.oasis.opendocument.graphics';
|
||
|
||
const makeReq = ({ mimetype = PDF_MIME, ocrConfig = null, interfaceConfig, body } = {}) => ({
|
||
user: { id: 'user-123', tenantId: 'tenant-a' },
|
||
file: {
|
||
path: '/tmp/upload.bin',
|
||
originalname: 'upload.bin',
|
||
filename: 'upload-uuid.bin',
|
||
mimetype,
|
||
},
|
||
body: { model: 'gpt-4o', ...body },
|
||
config: {
|
||
fileConfig: {},
|
||
fileStrategy: 'local',
|
||
ocr: ocrConfig,
|
||
...(interfaceConfig ? { interfaceConfig } : {}),
|
||
},
|
||
});
|
||
|
||
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: [] },
|
||
});
|
||
|
||
const setupStoredFileUpload = (result = {}) => {
|
||
const handleFileUpload = jest.fn().mockResolvedValue({
|
||
bytes: 42,
|
||
filename: 'upload.bin',
|
||
filepath: '/uploads/upload.bin',
|
||
...result,
|
||
});
|
||
getStrategyFunctions.mockReturnValue({ handleFileUpload });
|
||
return handleFileUpload;
|
||
};
|
||
|
||
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],
|
||
['ODS', ODS_MIME],
|
||
['Excel variant (msexcel)', 'application/msexcel'],
|
||
['Excel variant (x-msexcel)', 'application/x-msexcel'],
|
||
])('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.each([
|
||
['ODT', ODT_MIME],
|
||
['ODP', ODP_MIME],
|
||
['ODG', ODG_MIME],
|
||
])('routes %s through configured OCR when OCR supports the type', async (_, mime) => {
|
||
mergeFileConfig.mockReturnValue(makeFileConfig({ ocrSupportedMimeTypes: [mime] }));
|
||
const req = makeReq({
|
||
mimetype: 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('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();
|
||
});
|
||
});
|
||
|
||
describe('retention for agent resource uploads', () => {
|
||
test('skips retention metadata for persistent agent context files outside all-data retention when retainAgentFiles is disabled', async () => {
|
||
const expiredAt = new Date('2030-01-01T00:00:00.000Z');
|
||
const req = makeReq({
|
||
mimetype: PDF_MIME,
|
||
ocrConfig: null,
|
||
interfaceConfig: { retentionMode: RetentionMode.TEMPORARY, retainAgentFiles: false },
|
||
body: { conversationId: 'temporary-convo', isTemporary: true },
|
||
});
|
||
|
||
await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() });
|
||
|
||
expect(getAgentFileRetentionExpiry).toHaveBeenCalledWith(
|
||
{
|
||
req,
|
||
messageAttachment: false,
|
||
toolResource: EToolResources.context,
|
||
},
|
||
expect.any(Object),
|
||
);
|
||
expect(getRetentionExpiry).not.toHaveBeenCalled();
|
||
expect(db.createFile).toHaveBeenCalledWith(expect.not.objectContaining({ expiredAt }), true);
|
||
expect(db.addAgentResourceFile).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
agent_id: 'agent-abc',
|
||
tool_resource: EToolResources.context,
|
||
}),
|
||
);
|
||
});
|
||
|
||
test('skips retention metadata for persistent agent context files outside all-data retention when retainAgentFiles is enabled', async () => {
|
||
const expiredAt = new Date('2030-01-01T00:00:00.000Z');
|
||
const req = makeReq({
|
||
mimetype: PDF_MIME,
|
||
ocrConfig: null,
|
||
interfaceConfig: { retentionMode: RetentionMode.TEMPORARY, retainAgentFiles: true },
|
||
});
|
||
|
||
await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() });
|
||
|
||
expect(getRetentionExpiry).not.toHaveBeenCalled();
|
||
expect(db.createFile).toHaveBeenCalledWith(expect.not.objectContaining({ expiredAt }), true);
|
||
expect(db.addAgentResourceFile).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
agent_id: 'agent-abc',
|
||
tool_resource: EToolResources.context,
|
||
}),
|
||
);
|
||
});
|
||
|
||
test('applies all-data retention metadata to persistent agent context files when retainAgentFiles is disabled', async () => {
|
||
const expiredAt = new Date('2030-01-01T00:00:00.000Z');
|
||
getRetentionExpiry.mockResolvedValueOnce({ expiredAt });
|
||
const req = makeReq({
|
||
mimetype: PDF_MIME,
|
||
ocrConfig: null,
|
||
interfaceConfig: { retentionMode: RetentionMode.ALL, retainAgentFiles: false },
|
||
});
|
||
|
||
await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() });
|
||
|
||
expect(getRetentionExpiry).toHaveBeenCalledTimes(1);
|
||
expect(getRetentionExpiry.mock.calls[0][0]).toBe(req);
|
||
expect(db.createFile).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
expiredAt,
|
||
context: FileContext.agents,
|
||
}),
|
||
true,
|
||
);
|
||
expect(db.addAgentResourceFile).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
agent_id: 'agent-abc',
|
||
tool_resource: EToolResources.context,
|
||
}),
|
||
);
|
||
});
|
||
|
||
test('skips all-data retention metadata for persistent agent context files when retainAgentFiles is enabled', async () => {
|
||
const expiredAt = new Date('2030-01-01T00:00:00.000Z');
|
||
const req = makeReq({
|
||
mimetype: PDF_MIME,
|
||
ocrConfig: null,
|
||
interfaceConfig: { retentionMode: RetentionMode.ALL, retainAgentFiles: true },
|
||
});
|
||
|
||
await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() });
|
||
|
||
expect(getAgentFileRetentionExpiry).toHaveBeenCalledWith(
|
||
{
|
||
req,
|
||
messageAttachment: false,
|
||
toolResource: EToolResources.context,
|
||
},
|
||
expect.any(Object),
|
||
);
|
||
expect(getRetentionExpiry).not.toHaveBeenCalled();
|
||
expect(db.createFile).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
context: FileContext.agents,
|
||
}),
|
||
true,
|
||
);
|
||
expect(db.createFile).toHaveBeenCalledWith(expect.not.objectContaining({ expiredAt }), true);
|
||
expect(db.addAgentResourceFile).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
agent_id: 'agent-abc',
|
||
tool_resource: EToolResources.context,
|
||
}),
|
||
);
|
||
});
|
||
|
||
test('applies retention metadata to context files uploaded as message attachments', async () => {
|
||
const expiredAt = new Date('2030-01-01T00:00:00.000Z');
|
||
getRetentionExpiry.mockResolvedValueOnce({ expiredAt });
|
||
const req = makeReq({ mimetype: PDF_MIME, ocrConfig: null });
|
||
|
||
await processAgentFileUpload({
|
||
req,
|
||
res: mockRes,
|
||
metadata: { ...makeMetadata(), message_file: true },
|
||
});
|
||
|
||
expect(getRetentionExpiry).toHaveBeenCalledTimes(1);
|
||
expect(getRetentionExpiry.mock.calls[0][0]).toBe(req);
|
||
expect(db.createFile).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
expiredAt,
|
||
context: FileContext.message_attachment,
|
||
}),
|
||
true,
|
||
);
|
||
expect(db.addAgentResourceFile).not.toHaveBeenCalled();
|
||
});
|
||
|
||
test('skips retention metadata for persistent agent file-search files outside all-data retention', async () => {
|
||
const expiredAt = new Date('2030-01-01T00:00:00.000Z');
|
||
setupStoredFileUpload();
|
||
const req = makeReq({ mimetype: 'text/plain', ocrConfig: null });
|
||
|
||
await processAgentFileUpload({
|
||
req,
|
||
res: mockRes,
|
||
metadata: { ...makeMetadata(), tool_resource: EToolResources.file_search },
|
||
});
|
||
|
||
expect(uploadVectors).toHaveBeenCalled();
|
||
expect(getRetentionExpiry).not.toHaveBeenCalled();
|
||
expect(db.createFile).toHaveBeenCalledWith(expect.not.objectContaining({ expiredAt }), true);
|
||
expect(db.addAgentResourceFile).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
agent_id: 'agent-abc',
|
||
tool_resource: EToolResources.file_search,
|
||
}),
|
||
);
|
||
});
|
||
|
||
test('applies all-data retention metadata to persistent agent file-search files', async () => {
|
||
const expiredAt = new Date('2030-01-01T00:00:00.000Z');
|
||
getRetentionExpiry.mockResolvedValueOnce({ expiredAt });
|
||
setupStoredFileUpload();
|
||
const req = makeReq({
|
||
mimetype: 'text/plain',
|
||
ocrConfig: null,
|
||
interfaceConfig: { retentionMode: RetentionMode.ALL },
|
||
});
|
||
|
||
await processAgentFileUpload({
|
||
req,
|
||
res: mockRes,
|
||
metadata: { ...makeMetadata(), tool_resource: EToolResources.file_search },
|
||
});
|
||
|
||
expect(uploadVectors).toHaveBeenCalled();
|
||
expect(getRetentionExpiry).toHaveBeenCalledTimes(1);
|
||
expect(getRetentionExpiry.mock.calls[0][0]).toBe(req);
|
||
expect(db.createFile).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
expiredAt,
|
||
context: FileContext.agents,
|
||
}),
|
||
true,
|
||
);
|
||
expect(db.addAgentResourceFile).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
agent_id: 'agent-abc',
|
||
tool_resource: EToolResources.file_search,
|
||
}),
|
||
);
|
||
});
|
||
});
|
||
|
||
/* Phase C / option α regression: the upload must persist its sandbox
|
||
* pointer under `metadata.codeEnvRef` (the post-cutover schema). The
|
||
* legacy `metadata.fileIdentifier` key is silently stripped by mongoose
|
||
* strict mode and downstream readers (`primeFiles`, `getCodeFilesByIds`,
|
||
* `categorizeFileForToolResources`, controller filtering) only check
|
||
* `codeEnvRef`. Storing under the legacy key would orphan the file —
|
||
* priming would skip it on subsequent code-execution turns and the
|
||
* sandbox copy would never re-mount. */
|
||
describe('execute_code uploads persist codeEnvRef metadata', () => {
|
||
const fs = require('fs');
|
||
const { Readable } = require('stream');
|
||
let createReadStreamSpy;
|
||
|
||
beforeEach(() => {
|
||
/* `processAgentFileUpload` opens the multer-staged temp file via
|
||
* `fs.createReadStream`. The test fixture path doesn't exist, so
|
||
* stub it to a tiny in-memory stream. */
|
||
createReadStreamSpy = jest
|
||
.spyOn(fs, 'createReadStream')
|
||
.mockImplementation(() => Readable.from(Buffer.from('')));
|
||
});
|
||
|
||
afterEach(() => {
|
||
createReadStreamSpy.mockRestore();
|
||
});
|
||
|
||
const setupCodeEnvUpload = (uploaded) => {
|
||
/* `processAgentFileUpload` calls `getStrategyFunctions` twice:
|
||
* once with `execute_code` for the codeapi upload, then again with
|
||
* the on-disk strategy (`local`) for the standard storage step that
|
||
* runs in the same flow. Both must return a working
|
||
* `handleFileUpload`. */
|
||
const codeEnvUpload = jest.fn().mockResolvedValue(uploaded);
|
||
const localUpload = jest.fn().mockResolvedValue({
|
||
bytes: 0,
|
||
filename: 'upload.bin',
|
||
filepath: '/uploads/upload.bin',
|
||
});
|
||
getStrategyFunctions.mockImplementation((src) =>
|
||
src === FileSources.execute_code
|
||
? { handleFileUpload: codeEnvUpload }
|
||
: { handleFileUpload: localUpload, saveBuffer: jest.fn() },
|
||
);
|
||
return codeEnvUpload;
|
||
};
|
||
|
||
it('persists kind:user codeEnvRef for chat attachments (messageAttachment=true)', async () => {
|
||
setupCodeEnvUpload({ storage_session_id: 'sess-1', file_id: 'fid-1' });
|
||
const req = makeReq();
|
||
await processAgentFileUpload({
|
||
req,
|
||
res: mockRes,
|
||
metadata: {
|
||
agent_id: 'agent-abc',
|
||
tool_resource: EToolResources.execute_code,
|
||
file_id: 'file-uuid',
|
||
message_file: true,
|
||
},
|
||
});
|
||
|
||
expect(db.createFile).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
metadata: {
|
||
codeEnvRef: {
|
||
kind: 'user',
|
||
id: 'user-123',
|
||
storage_session_id: 'sess-1',
|
||
file_id: 'fid-1',
|
||
},
|
||
},
|
||
}),
|
||
true,
|
||
);
|
||
});
|
||
|
||
it('persists kind:agent codeEnvRef for agent setup files (messageAttachment=false)', async () => {
|
||
setupCodeEnvUpload({ storage_session_id: 'sess-2', file_id: 'fid-2' });
|
||
const req = makeReq();
|
||
await processAgentFileUpload({
|
||
req,
|
||
res: mockRes,
|
||
metadata: {
|
||
agent_id: 'agent-abc',
|
||
tool_resource: EToolResources.execute_code,
|
||
file_id: 'file-uuid',
|
||
},
|
||
});
|
||
|
||
expect(db.createFile).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
metadata: {
|
||
codeEnvRef: {
|
||
kind: 'agent',
|
||
id: 'agent-abc',
|
||
storage_session_id: 'sess-2',
|
||
file_id: 'fid-2',
|
||
},
|
||
},
|
||
}),
|
||
true,
|
||
);
|
||
});
|
||
|
||
it('skips retention metadata for persistent agent execute_code files outside all-data retention', async () => {
|
||
const expiredAt = new Date('2030-01-01T00:00:00.000Z');
|
||
setupCodeEnvUpload({ storage_session_id: 'sess-4', file_id: 'fid-4' });
|
||
const req = makeReq();
|
||
|
||
await processAgentFileUpload({
|
||
req,
|
||
res: mockRes,
|
||
metadata: {
|
||
agent_id: 'agent-abc',
|
||
tool_resource: EToolResources.execute_code,
|
||
file_id: 'file-uuid',
|
||
},
|
||
});
|
||
|
||
expect(getRetentionExpiry).not.toHaveBeenCalled();
|
||
expect(db.createFile).toHaveBeenCalledWith(expect.not.objectContaining({ expiredAt }), true);
|
||
expect(db.addAgentResourceFile).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
agent_id: 'agent-abc',
|
||
tool_resource: EToolResources.execute_code,
|
||
}),
|
||
);
|
||
});
|
||
|
||
it('applies all-data retention metadata to persistent agent execute_code files', async () => {
|
||
const expiredAt = new Date('2030-01-01T00:00:00.000Z');
|
||
getRetentionExpiry.mockResolvedValueOnce({ expiredAt });
|
||
setupCodeEnvUpload({ storage_session_id: 'sess-5', file_id: 'fid-5' });
|
||
const req = makeReq({ interfaceConfig: { retentionMode: RetentionMode.ALL } });
|
||
|
||
await processAgentFileUpload({
|
||
req,
|
||
res: mockRes,
|
||
metadata: {
|
||
agent_id: 'agent-abc',
|
||
tool_resource: EToolResources.execute_code,
|
||
file_id: 'file-uuid',
|
||
},
|
||
});
|
||
|
||
expect(getRetentionExpiry).toHaveBeenCalledTimes(1);
|
||
expect(getRetentionExpiry.mock.calls[0][0]).toBe(req);
|
||
expect(db.createFile).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
expiredAt,
|
||
context: FileContext.agents,
|
||
metadata: {
|
||
codeEnvRef: {
|
||
kind: 'agent',
|
||
id: 'agent-abc',
|
||
storage_session_id: 'sess-5',
|
||
file_id: 'fid-5',
|
||
},
|
||
},
|
||
}),
|
||
true,
|
||
);
|
||
expect(db.addAgentResourceFile).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
agent_id: 'agent-abc',
|
||
tool_resource: EToolResources.execute_code,
|
||
}),
|
||
);
|
||
});
|
||
|
||
it('does not persist legacy fileIdentifier key (mongoose strict drops it)', async () => {
|
||
setupCodeEnvUpload({ storage_session_id: 'sess-3', file_id: 'fid-3' });
|
||
const req = makeReq();
|
||
await processAgentFileUpload({
|
||
req,
|
||
res: mockRes,
|
||
metadata: {
|
||
agent_id: 'agent-abc',
|
||
tool_resource: EToolResources.execute_code,
|
||
file_id: 'file-uuid',
|
||
message_file: true,
|
||
},
|
||
});
|
||
|
||
const persisted = db.createFile.mock.calls[0][0];
|
||
expect(persisted.metadata).not.toHaveProperty('fileIdentifier');
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('processFileURL', () => {
|
||
beforeEach(() => {
|
||
jest.clearAllMocks();
|
||
});
|
||
|
||
it('throws and skips DB persistence when saveURL returns null', async () => {
|
||
const saveURL = jest.fn().mockResolvedValue(null);
|
||
const getFileURL = jest.fn();
|
||
getStrategyFunctions.mockReturnValue({ saveURL, getFileURL });
|
||
|
||
await expect(
|
||
processFileURL({
|
||
fileStrategy: FileSources.local,
|
||
userId: 'user-123',
|
||
URL: 'https://example.com/image.png',
|
||
fileName: 'image.png',
|
||
basePath: 'images',
|
||
context: FileContext.image_generation,
|
||
tenantId: 'tenant-a',
|
||
}),
|
||
).rejects.toThrow('Strategy "local" did not save "image.png"');
|
||
|
||
expect(getFileURL).not.toHaveBeenCalled();
|
||
expect(db.createFile).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('persists tenantId and strategy-returned filepath metadata', async () => {
|
||
const saveURL = jest.fn().mockResolvedValue({
|
||
filepath: 'https://cdn.example.com/t/tenant-a/images/user-123/image.png',
|
||
bytes: 512,
|
||
type: 'image/png',
|
||
dimensions: { width: 32, height: 64 },
|
||
});
|
||
const getFileURL = jest.fn();
|
||
getStrategyFunctions.mockReturnValue({ saveURL, getFileURL });
|
||
|
||
await processFileURL({
|
||
fileStrategy: FileSources.cloudfront,
|
||
userId: 'user-123',
|
||
URL: 'https://example.com/image.png',
|
||
fileName: 'image.png',
|
||
basePath: 'images',
|
||
context: FileContext.image_generation,
|
||
tenantId: 'tenant-a',
|
||
});
|
||
|
||
expect(getFileURL).not.toHaveBeenCalled();
|
||
expect(db.createFile).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
user: 'user-123',
|
||
filepath: 'https://cdn.example.com/t/tenant-a/images/user-123/image.png',
|
||
bytes: 512,
|
||
filename: 'image.png',
|
||
source: FileSources.cloudfront,
|
||
type: 'image/png',
|
||
context: FileContext.image_generation,
|
||
tenantId: 'tenant-a',
|
||
width: 32,
|
||
height: 64,
|
||
}),
|
||
true,
|
||
);
|
||
});
|
||
|
||
it('applies retention metadata for generated images when retention mode is all', async () => {
|
||
getRetentionExpiry.mockResolvedValueOnce({
|
||
expiredAt: new Date('2030-01-01T00:00:00.000Z'),
|
||
});
|
||
const saveURL = jest.fn().mockResolvedValue({
|
||
filepath: 'https://cdn.example.com/t/tenant-a/images/user-123/image.png',
|
||
bytes: 512,
|
||
type: 'image/png',
|
||
});
|
||
const getFileURL = jest.fn();
|
||
getStrategyFunctions.mockReturnValue({ saveURL, getFileURL });
|
||
|
||
await processFileURL({
|
||
fileStrategy: FileSources.cloudfront,
|
||
userId: 'user-123',
|
||
URL: 'https://example.com/image.png',
|
||
fileName: 'image.png',
|
||
basePath: 'images',
|
||
context: FileContext.image_generation,
|
||
tenantId: 'tenant-a',
|
||
req: {
|
||
user: { id: 'user-123', tenantId: 'tenant-a' },
|
||
body: {},
|
||
config: { interfaceConfig: { retentionMode: 'all', retainAgentFiles: true } },
|
||
},
|
||
});
|
||
|
||
expect(db.createFile).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
expiredAt: new Date('2030-01-01T00:00:00.000Z'),
|
||
}),
|
||
true,
|
||
);
|
||
});
|
||
|
||
it('applies retention metadata for retained non-temporary conversations', async () => {
|
||
const saveURL = jest.fn().mockResolvedValue({
|
||
filepath: 'https://cdn.example.com/t/tenant-a/images/user-123/image.png',
|
||
bytes: 512,
|
||
type: 'image/png',
|
||
});
|
||
const getFileURL = jest.fn();
|
||
getStrategyFunctions.mockReturnValue({ saveURL, getFileURL });
|
||
getRetentionExpiry.mockResolvedValueOnce({
|
||
expiredAt: new Date('2030-01-01T00:00:00.000Z'),
|
||
});
|
||
|
||
await processFileURL({
|
||
fileStrategy: FileSources.cloudfront,
|
||
userId: 'user-123',
|
||
URL: 'https://example.com/image.png',
|
||
fileName: 'image.png',
|
||
basePath: 'images',
|
||
context: FileContext.image_generation,
|
||
tenantId: 'tenant-a',
|
||
req: {
|
||
user: { id: 'user-123', tenantId: 'tenant-a' },
|
||
body: { conversationId: 'convo-123' },
|
||
config: { interfaceConfig: { retentionMode: RetentionMode.TEMPORARY } },
|
||
},
|
||
});
|
||
|
||
expect(db.createFile).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
expiredAt: new Date('2030-01-01T00:00:00.000Z'),
|
||
}),
|
||
true,
|
||
);
|
||
});
|
||
|
||
it('keeps expired retained conversation files on the parent expiration', async () => {
|
||
const parentExpiredAt = new Date('2020-01-01T00:00:00.000Z');
|
||
const saveURL = jest.fn().mockResolvedValue({
|
||
filepath: 'https://cdn.example.com/t/tenant-a/images/user-123/image.png',
|
||
bytes: 512,
|
||
type: 'image/png',
|
||
});
|
||
const getFileURL = jest.fn();
|
||
getStrategyFunctions.mockReturnValue({ saveURL, getFileURL });
|
||
getRetentionExpiry.mockResolvedValueOnce({ expiredAt: parentExpiredAt });
|
||
|
||
await processFileURL({
|
||
fileStrategy: FileSources.cloudfront,
|
||
userId: 'user-123',
|
||
URL: 'https://example.com/image.png',
|
||
fileName: 'image.png',
|
||
basePath: 'images',
|
||
context: FileContext.image_generation,
|
||
tenantId: 'tenant-a',
|
||
req: {
|
||
user: { id: 'user-123', tenantId: 'tenant-a' },
|
||
body: { conversationId: 'convo-123' },
|
||
config: { interfaceConfig: { retentionMode: RetentionMode.TEMPORARY } },
|
||
},
|
||
});
|
||
|
||
expect(db.createFile).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
expiredAt: parentExpiredAt,
|
||
}),
|
||
true,
|
||
);
|
||
});
|
||
|
||
it('falls back to getFileURL with user and tenant context when metadata lacks filepath', async () => {
|
||
const saveURL = jest.fn().mockResolvedValue({
|
||
bytes: 256,
|
||
type: 'image/png',
|
||
});
|
||
const getFileURL = jest
|
||
.fn()
|
||
.mockResolvedValue('https://cdn.example.com/t/tenant-a/images/user-123/image.png');
|
||
getStrategyFunctions.mockReturnValue({ saveURL, getFileURL });
|
||
|
||
await processFileURL({
|
||
fileStrategy: FileSources.cloudfront,
|
||
userId: 'user-123',
|
||
URL: 'https://example.com/image.png',
|
||
fileName: 'image.png',
|
||
basePath: 'images',
|
||
context: FileContext.image_generation,
|
||
tenantId: 'tenant-a',
|
||
});
|
||
|
||
expect(getFileURL).toHaveBeenCalledWith({
|
||
userId: 'user-123',
|
||
fileName: 'image.png',
|
||
basePath: 'images',
|
||
tenantId: 'tenant-a',
|
||
});
|
||
expect(db.createFile).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
filepath: 'https://cdn.example.com/t/tenant-a/images/user-123/image.png',
|
||
tenantId: 'tenant-a',
|
||
}),
|
||
true,
|
||
);
|
||
});
|
||
|
||
it('preserves the user path segment for local fallback URLs', async () => {
|
||
const saveURL = jest.fn().mockResolvedValue({
|
||
bytes: 256,
|
||
type: 'image/png',
|
||
});
|
||
const getFileURL = jest.fn().mockResolvedValue('/images/user-123/image.png');
|
||
getStrategyFunctions.mockReturnValue({ saveURL, getFileURL });
|
||
|
||
await processFileURL({
|
||
fileStrategy: FileSources.local,
|
||
userId: 'user-123',
|
||
URL: 'https://example.com/image.png',
|
||
fileName: 'image.png',
|
||
basePath: 'images',
|
||
context: FileContext.image_generation,
|
||
tenantId: 'tenant-a',
|
||
});
|
||
|
||
expect(getFileURL).toHaveBeenCalledWith({
|
||
userId: 'user-123',
|
||
fileName: 'user-123/image.png',
|
||
basePath: 'images',
|
||
tenantId: 'tenant-a',
|
||
});
|
||
expect(db.createFile).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
filepath: '/images/user-123/image.png',
|
||
tenantId: 'tenant-a',
|
||
}),
|
||
true,
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('processDeleteRequest', () => {
|
||
beforeEach(() => {
|
||
jest.clearAllMocks();
|
||
});
|
||
|
||
it('removes metadata when backing storage is already missing', async () => {
|
||
const missingError = Object.assign(new Error('no such file'), { code: 'ENOENT' });
|
||
const deleteFile = jest.fn().mockRejectedValue(missingError);
|
||
getStrategyFunctions.mockReturnValue({ deleteFile });
|
||
db.deleteFiles.mockResolvedValue({ deletedCount: 1 });
|
||
|
||
const result = await processDeleteRequest({
|
||
req: {
|
||
body: {},
|
||
config: {},
|
||
user: { id: 'user-123', tenantId: 'tenant-a' },
|
||
},
|
||
files: [
|
||
{
|
||
file_id: 'expired-file',
|
||
filepath: '/images/user-123/expired.png',
|
||
source: FileSources.local,
|
||
},
|
||
],
|
||
});
|
||
|
||
expect(db.deleteFiles).toHaveBeenCalledWith(['expired-file']);
|
||
expect(result).toEqual({ deletedFileIds: ['expired-file'], failedFileIds: [] });
|
||
});
|
||
|
||
it('does not treat unrelated not found messages as missing storage', async () => {
|
||
const deleteFile = jest.fn().mockRejectedValue(new Error('Configuration not found'));
|
||
getStrategyFunctions.mockReturnValue({ deleteFile });
|
||
|
||
const result = await processDeleteRequest({
|
||
req: {
|
||
body: {},
|
||
config: {},
|
||
user: { id: 'user-123', tenantId: 'tenant-a' },
|
||
},
|
||
files: [
|
||
{
|
||
file_id: 'expired-file',
|
||
filepath: '/images/user-123/expired.png',
|
||
source: FileSources.local,
|
||
},
|
||
],
|
||
});
|
||
|
||
expect(db.deleteFiles).not.toHaveBeenCalled();
|
||
expect(result).toEqual({ deletedFileIds: [], failedFileIds: ['expired-file'] });
|
||
});
|
||
|
||
it('throws metadata delete failures after storage deletion succeeds', async () => {
|
||
const deleteFile = jest.fn().mockResolvedValue(undefined);
|
||
const metadataError = new Error('mongo unavailable');
|
||
getStrategyFunctions.mockReturnValue({ deleteFile });
|
||
db.deleteFiles.mockRejectedValue(metadataError);
|
||
|
||
await expect(
|
||
processDeleteRequest({
|
||
req: {
|
||
body: {},
|
||
config: {},
|
||
user: { id: 'user-123', tenantId: 'tenant-a' },
|
||
},
|
||
files: [
|
||
{
|
||
file_id: 'expired-file',
|
||
filepath: '/images/user-123/expired.png',
|
||
source: FileSources.local,
|
||
},
|
||
],
|
||
}),
|
||
).rejects.toThrow('mongo unavailable');
|
||
|
||
expect(db.deleteFiles).toHaveBeenCalledWith(['expired-file']);
|
||
expect(db.removeAgentResourceFilesFromAllAgents).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('deletes vector storage before removing embedded file metadata', async () => {
|
||
const primaryDelete = jest.fn().mockResolvedValue(undefined);
|
||
const vectorDelete = jest.fn().mockResolvedValue(undefined);
|
||
getStrategyFunctions.mockImplementation((source) =>
|
||
source === FileSources.vectordb
|
||
? { deleteFile: vectorDelete }
|
||
: { deleteFile: primaryDelete },
|
||
);
|
||
db.deleteFiles.mockResolvedValue({ deletedCount: 1 });
|
||
const req = {
|
||
body: {},
|
||
config: {},
|
||
user: { id: 'user-123', tenantId: 'tenant-a' },
|
||
};
|
||
const file = {
|
||
file_id: 'embedded-file',
|
||
filepath: '/uploads/embedded.txt',
|
||
source: FileSources.local,
|
||
embedded: true,
|
||
};
|
||
|
||
const result = await processDeleteRequest({ req, files: [file] });
|
||
|
||
expect(primaryDelete).toHaveBeenCalledWith(req, file, undefined);
|
||
expect(vectorDelete).toHaveBeenCalledWith(req, file);
|
||
expect(db.deleteFiles).toHaveBeenCalledWith(['embedded-file']);
|
||
expect(result).toEqual({ deletedFileIds: ['embedded-file'], failedFileIds: [] });
|
||
});
|
||
|
||
it('keeps embedded file metadata when vector deletion fails', async () => {
|
||
const primaryDelete = jest.fn().mockResolvedValue(undefined);
|
||
const vectorDelete = jest.fn().mockRejectedValue(new Error('rag unavailable'));
|
||
getStrategyFunctions.mockImplementation((source) =>
|
||
source === FileSources.vectordb
|
||
? { deleteFile: vectorDelete }
|
||
: { deleteFile: primaryDelete },
|
||
);
|
||
const req = {
|
||
body: {},
|
||
config: {},
|
||
user: { id: 'user-123', tenantId: 'tenant-a' },
|
||
};
|
||
const file = {
|
||
file_id: 'embedded-file',
|
||
filepath: '/uploads/embedded.txt',
|
||
source: FileSources.local,
|
||
embedded: true,
|
||
};
|
||
|
||
const result = await processDeleteRequest({ req, files: [file] });
|
||
|
||
expect(primaryDelete).toHaveBeenCalledWith(req, file, undefined);
|
||
expect(vectorDelete).toHaveBeenCalledWith(req, file);
|
||
expect(db.deleteFiles).not.toHaveBeenCalled();
|
||
expect(result).toEqual({ deletedFileIds: [], failedFileIds: ['embedded-file'] });
|
||
});
|
||
|
||
it('does not delete vector storage when primary embedded file deletion fails', async () => {
|
||
const primaryDelete = jest.fn().mockRejectedValue(new Error('permission denied'));
|
||
const vectorDelete = jest.fn().mockResolvedValue(undefined);
|
||
getStrategyFunctions.mockImplementation((source) =>
|
||
source === FileSources.vectordb
|
||
? { deleteFile: vectorDelete }
|
||
: { deleteFile: primaryDelete },
|
||
);
|
||
const req = {
|
||
body: {},
|
||
config: {},
|
||
user: { id: 'user-123', tenantId: 'tenant-a' },
|
||
};
|
||
const file = {
|
||
file_id: 'embedded-file',
|
||
filepath: '/uploads/embedded.txt',
|
||
source: FileSources.local,
|
||
embedded: true,
|
||
};
|
||
|
||
const result = await processDeleteRequest({ req, files: [file] });
|
||
|
||
expect(primaryDelete).toHaveBeenCalledWith(req, file, undefined);
|
||
expect(vectorDelete).not.toHaveBeenCalled();
|
||
expect(db.deleteFiles).not.toHaveBeenCalled();
|
||
expect(result).toEqual({ deletedFileIds: [], failedFileIds: ['embedded-file'] });
|
||
});
|
||
|
||
it('still deletes vector storage when primary embedded file storage is already missing', async () => {
|
||
const missingError = Object.assign(new Error('no such file'), { code: 'ENOENT' });
|
||
const primaryDelete = jest.fn().mockRejectedValue(missingError);
|
||
const vectorDelete = jest.fn().mockResolvedValue(undefined);
|
||
getStrategyFunctions.mockImplementation((source) =>
|
||
source === FileSources.vectordb
|
||
? { deleteFile: vectorDelete }
|
||
: { deleteFile: primaryDelete },
|
||
);
|
||
db.deleteFiles.mockResolvedValue({ deletedCount: 1 });
|
||
const req = {
|
||
body: {},
|
||
config: {},
|
||
user: { id: 'user-123', tenantId: 'tenant-a' },
|
||
};
|
||
const file = {
|
||
file_id: 'embedded-file',
|
||
filepath: '/uploads/embedded.txt',
|
||
source: FileSources.local,
|
||
embedded: true,
|
||
};
|
||
|
||
const result = await processDeleteRequest({ req, files: [file] });
|
||
|
||
expect(primaryDelete).toHaveBeenCalledWith(req, file, undefined);
|
||
expect(vectorDelete).toHaveBeenCalledWith(req, file);
|
||
expect(db.deleteFiles).toHaveBeenCalledWith(['embedded-file']);
|
||
expect(result).toEqual({ deletedFileIds: ['embedded-file'], failedFileIds: [] });
|
||
});
|
||
|
||
it('deletes code environment storage before removing code resource file metadata', async () => {
|
||
const primaryDelete = jest.fn().mockResolvedValue(undefined);
|
||
const codeDelete = jest.fn().mockResolvedValue(undefined);
|
||
getStrategyFunctions.mockImplementation((source) =>
|
||
source === FileSources.execute_code
|
||
? { deleteFile: codeDelete }
|
||
: { deleteFile: primaryDelete },
|
||
);
|
||
db.deleteFiles.mockResolvedValue({ deletedCount: 1 });
|
||
const req = {
|
||
body: {},
|
||
config: {},
|
||
user: { id: 'user-123', tenantId: 'tenant-a' },
|
||
};
|
||
const file = {
|
||
file_id: 'code-resource-file',
|
||
filepath: '/uploads/code-resource.txt',
|
||
source: FileSources.local,
|
||
metadata: {
|
||
codeEnvRef: {
|
||
kind: 'agent',
|
||
id: 'agent-abc',
|
||
storage_session_id: 'sess-1',
|
||
file_id: 'fid-1',
|
||
},
|
||
},
|
||
};
|
||
|
||
const result = await processDeleteRequest({ req, files: [file] });
|
||
|
||
expect(primaryDelete).toHaveBeenCalledWith(req, file, undefined);
|
||
expect(codeDelete).toHaveBeenCalledWith(req, file);
|
||
expect(db.deleteFiles).toHaveBeenCalledWith(['code-resource-file']);
|
||
expect(result).toEqual({ deletedFileIds: ['code-resource-file'], failedFileIds: [] });
|
||
});
|
||
});
|
||
|
||
describe('sweepExpiredFiles', () => {
|
||
beforeEach(() => {
|
||
jest.clearAllMocks();
|
||
});
|
||
|
||
it('delegates expired file sweeping to the shared package with backend dependencies', async () => {
|
||
const options = {
|
||
appConfig: { paths: { publicPath: '/tmp/public', uploads: '/tmp/uploads' } },
|
||
limit: 1,
|
||
};
|
||
sweepExpiredFilesWithDeps.mockResolvedValue({ scanned: 1, deleted: 1, failed: 0 });
|
||
|
||
const result = await sweepExpiredFiles(options);
|
||
|
||
expect(sweepExpiredFilesWithDeps).toHaveBeenCalledWith(
|
||
options,
|
||
expect.objectContaining({
|
||
getExpiredFiles: db.getExpiredFiles,
|
||
processDeleteRequest: expect.any(Function),
|
||
logger: expect.objectContaining({
|
||
error: expect.any(Function),
|
||
info: expect.any(Function),
|
||
warn: expect.any(Function),
|
||
}),
|
||
}),
|
||
);
|
||
expect(result).toEqual({ scanned: 1, deleted: 1, failed: 0 });
|
||
});
|
||
});
|
||
|
||
describe('startExpiredFileSweep', () => {
|
||
beforeEach(() => {
|
||
jest.clearAllMocks();
|
||
});
|
||
|
||
it('delegates background sweep startup to the shared package with system context', () => {
|
||
const options = {
|
||
appConfig: { paths: { publicPath: '/tmp/public', uploads: '/tmp/uploads' } },
|
||
};
|
||
|
||
const interval = startExpiredFileSweep(options);
|
||
|
||
expect(startExpiredFileSweepWithDeps).toHaveBeenCalledWith(
|
||
options,
|
||
expect.objectContaining({
|
||
sweepExpiredFiles: expect.any(Function),
|
||
runAsSystem: expect.any(Function),
|
||
logger: expect.objectContaining({
|
||
error: expect.any(Function),
|
||
info: expect.any(Function),
|
||
warn: expect.any(Function),
|
||
}),
|
||
}),
|
||
);
|
||
expect(interval).toBe('sweep-interval');
|
||
});
|
||
});
|