Files
wehub-resource-sync e115934061
Publish `@librechat/data-schemas` to NPM / pack (push) Failing after 8s
Publish `@librechat/data-schemas` to NPM / publish-npm (push) Waiting to run
Publish `@librechat/client` to NPM / pack (push) Failing after 0s
GitNexus Index / index (push) Failing after 1s
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been skipped
Sync Helm Chart Tags / Sync chart tags (push) Failing after 2s
Publish `librechat-data-provider` to NPM / pack (push) Failing after 1s
Publish `@librechat/client` to NPM / publish-npm (push) Waiting to run
Publish `librechat-data-provider` to NPM / publish-npm (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Failing after 1s
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Failing after 0s
Sync Helm Chart Tags / Ignore non-main push (push) Has been skipped
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Failing after 1s
GitNexus Index / post-index (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 12:08:12 +08:00

204 lines
7.2 KiB
JavaScript

/**
* Integration test for the delete-time path of issue #12776.
*
* Covers the full flow through `processDeleteRequest`:
* 1. Real Agent + File docs in an in-memory Mongo.
* 2. Invoke the delete service.
* 3. Assert both the File record is gone and every agent's
* tool_resources.*.file_ids no longer references the deleted id.
*
* Uses FileSources.text so the strategy layer (disk / S3 / OpenAI) is a
* no-op — we don't need real filesystem access to exercise the agent
* reference cleanup, which is what issue #12776 is about.
*/
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { agentSchema, fileSchema, createMethods } = require('@librechat/data-schemas');
const { FileSources } = require('librechat-data-provider');
jest.mock('@librechat/data-schemas', () => {
const actual = jest.requireActual('@librechat/data-schemas');
return {
...actual,
logger: { warn: jest.fn(), debug: jest.fn(), error: jest.fn(), info: 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('~/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('~/server/services/Files/strategies', () => ({
getStrategyFunctions: jest.fn(() => ({ deleteFile: jest.fn().mockResolvedValue(undefined) })),
}));
jest.mock('~/server/services/Files/Audio/STTService', () => ({
STTService: { getInstance: jest.fn() },
}));
jest.mock('~/server/services/Config', () => ({
checkCapability: jest.fn().mockResolvedValue(true),
}));
jest.mock('~/cache', () => ({
getLogStores: jest.fn(() => ({ get: jest.fn(), set: jest.fn(), delete: jest.fn() })),
}));
// Replace the mocked `~/models` from the sibling process.spec.js with real,
// mongoose-backed methods. All our in-memory models share this module.
jest.mock('~/models', () => {
const mongoose = require('mongoose');
const { createMethods } = require('@librechat/data-schemas');
return createMethods(mongoose, {
removeAllPermissions: jest.fn().mockResolvedValue(undefined),
});
});
require('module-alias/register');
const { processDeleteRequest } = require('./process');
describe('processDeleteRequest — agent reference cleanup (issue #12776)', () => {
let mongoServer;
let Agent;
let File;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
// createMethods (via ~/models) registers the File model as a side-effect,
// but we also need the Agent model registered before any queries run.
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
File = mongoose.models.File || mongoose.model('File', fileSchema);
// Touch createMethods once so the migration/setup side-effects run.
createMethods(mongoose, { removeAllPermissions: jest.fn() });
}, 30000);
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await Agent.deleteMany({});
await File.deleteMany({});
});
const seedFile = async (file_id, userId) =>
File.create({
file_id,
user: userId,
filename: `${file_id}.txt`,
filepath: `/tmp/${file_id}`,
object: 'file',
type: 'text/plain',
bytes: 1,
source: FileSources.text,
});
const seedAgent = async (authorId, tool_resources) =>
Agent.create({
id: `agent_${Math.random().toString(36).slice(2, 10)}`,
name: 'Integration Test Agent',
provider: 'test',
model: 'test-model',
author: authorId,
tool_resources,
});
const buildReq = (fileDocs, extraBody = {}) => ({
user: { id: fileDocs[0].user.toString() },
body: { files: fileDocs, ...extraBody },
config: { fileStrategy: 'local', fileConfig: {}, endpoints: {} },
});
test('strips deleted file_ids from every agent that referenced them', async () => {
const userId = new mongoose.Types.ObjectId();
const keeperId = `file_keeper_${Math.random().toString(36).slice(2, 10)}`;
const deletedId = `file_deleted_${Math.random().toString(36).slice(2, 10)}`;
const deletedFile = await seedFile(deletedId, userId);
await seedFile(keeperId, userId);
// Two agents both reference the file that's about to be deleted, plus the
// keeper. A third, unrelated agent has a different file_id and must not be
// touched by the cleanup.
const agentA = await seedAgent(userId, {
file_search: { file_ids: [deletedId, keeperId] },
});
const agentB = await seedAgent(userId, {
execute_code: { file_ids: [deletedId] },
});
const untouchedAgent = await seedAgent(userId, {
context: { file_ids: [keeperId] },
});
await processDeleteRequest({ req: buildReq([deletedFile.toObject()]), files: [deletedFile] });
expect(await File.findOne({ file_id: deletedId })).toBeNull();
expect(await File.findOne({ file_id: keeperId })).not.toBeNull();
const updatedA = await Agent.findOne({ id: agentA.id }).lean();
const updatedB = await Agent.findOne({ id: agentB.id }).lean();
const updatedUntouched = await Agent.findOne({ id: untouchedAgent.id }).lean();
expect(updatedA.tool_resources.file_search.file_ids).toEqual([keeperId]);
expect(updatedB.tool_resources.execute_code.file_ids).toEqual([]);
expect(updatedUntouched.tool_resources.context.file_ids).toEqual([keeperId]);
});
test('is a no-op when no agent references the deleted file', async () => {
const userId = new mongoose.Types.ObjectId();
const loneId = `file_lone_${Math.random().toString(36).slice(2, 10)}`;
const loneFile = await seedFile(loneId, userId);
const unrelatedAgent = await seedAgent(userId, {
file_search: { file_ids: ['other_id'] },
});
await processDeleteRequest({ req: buildReq([loneFile.toObject()]), files: [loneFile] });
expect(await File.findOne({ file_id: loneId })).toBeNull();
const after = await Agent.findOne({ id: unrelatedAgent.id }).lean();
expect(after.tool_resources.file_search.file_ids).toEqual(['other_id']);
});
test('still deletes the file when the agent cleanup step throws', async () => {
const userId = new mongoose.Types.ObjectId();
const targetId = `file_target_${Math.random().toString(36).slice(2, 10)}`;
const targetFile = await seedFile(targetId, userId);
const db = require('~/models');
const original = db.removeAgentResourceFilesFromAllAgents;
db.removeAgentResourceFilesFromAllAgents = jest
.fn()
.mockRejectedValue(new Error('simulated cleanup failure'));
try {
await processDeleteRequest({ req: buildReq([targetFile.toObject()]), files: [targetFile] });
expect(await File.findOne({ file_id: targetId })).toBeNull();
} finally {
db.removeAgentResourceFilesFromAllAgents = original;
}
});
});