e115934061
Publish `@librechat/data-schemas` to NPM / pack (push) Failing after 8s
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
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
Publish `@librechat/client` to NPM / publish-npm (push) Has been cancelled
Publish `librechat-data-provider` to NPM / publish-npm (push) Has been cancelled
GitNexus Index / post-index (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / publish-npm (push) Has been cancelled
185 lines
6.5 KiB
JavaScript
185 lines
6.5 KiB
JavaScript
/**
|
|
* Integration test for the orphan-cleanup migration script used to heal
|
|
* agents corrupted before the delete-time and save-time fixes for issue
|
|
* #12776 shipped. Exercises the full module end-to-end:
|
|
* - dry-run reports orphans without writing
|
|
* - apply mode removes them
|
|
* - re-running on a cleaned database is a no-op (idempotent)
|
|
* - the DETAIL_SAMPLE_LIMIT truncation kicks in on wide corruption
|
|
*/
|
|
|
|
// Replace the migration's `./connect` helper — it opens its own connection
|
|
// via the mongo URI env var, but the test already owns the mongoose instance.
|
|
jest.mock('../../config/connect', () => jest.fn(async () => undefined));
|
|
|
|
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() },
|
|
};
|
|
});
|
|
|
|
const mongoose = require('mongoose');
|
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
|
const { agentSchema, fileSchema } = require('@librechat/data-schemas');
|
|
const { FileSources } = require('librechat-data-provider');
|
|
|
|
const { migrateOrphanedAgentFiles } = require('../../config/migrate-orphaned-agent-files');
|
|
|
|
describe('migrate-orphaned-agent-files (issue #12776)', () => {
|
|
let mongoServer;
|
|
let Agent;
|
|
let File;
|
|
const userId = () => new mongoose.Types.ObjectId();
|
|
|
|
beforeAll(async () => {
|
|
mongoServer = await MongoMemoryServer.create();
|
|
await mongoose.connect(mongoServer.getUri());
|
|
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
|
|
File = mongoose.models.File || mongoose.model('File', fileSchema);
|
|
}, 30000);
|
|
|
|
afterAll(async () => {
|
|
await mongoose.disconnect();
|
|
await mongoServer.stop();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await Agent.deleteMany({});
|
|
await File.deleteMany({});
|
|
});
|
|
|
|
const seedFile = (file_id) =>
|
|
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 = (tool_resources) =>
|
|
Agent.create({
|
|
id: `agent_${Math.random().toString(36).slice(2, 10)}`,
|
|
name: `Test Agent ${Math.random().toString(36).slice(2, 6)}`,
|
|
provider: 'test',
|
|
model: 'test-model',
|
|
author: userId(),
|
|
tool_resources,
|
|
});
|
|
|
|
test('dry-run reports orphans without mutating any agent', async () => {
|
|
const keeperId = 'keeper';
|
|
await seedFile(keeperId);
|
|
const agent = await seedAgent({
|
|
file_search: { file_ids: [keeperId, 'orphan_1', 'orphan_2'] },
|
|
});
|
|
|
|
const result = await migrateOrphanedAgentFiles({ dryRun: true });
|
|
|
|
expect(result.dryRun).toBe(true);
|
|
expect(result.scannedAgents).toBe(1);
|
|
expect(result.agentsWithOrphans).toBe(1);
|
|
expect(result.totalOrphansRemoved).toBe(2);
|
|
// Dry-run reports what would change without writing — no updates counted.
|
|
expect(result.agentsUpdated).toBe(0);
|
|
|
|
const after = await Agent.findOne({ id: agent.id }).lean();
|
|
expect(after.tool_resources.file_search.file_ids).toEqual([keeperId, 'orphan_1', 'orphan_2']);
|
|
});
|
|
|
|
test('apply mode removes orphans across every tool_resource category', async () => {
|
|
const keeperA = 'k_a';
|
|
const keeperB = 'k_b';
|
|
await seedFile(keeperA);
|
|
await seedFile(keeperB);
|
|
|
|
const agent = await seedAgent({
|
|
file_search: { file_ids: [keeperA, 'o1'] },
|
|
execute_code: { file_ids: ['o2', keeperB] },
|
|
context: { file_ids: ['o3'] },
|
|
});
|
|
|
|
const result = await migrateOrphanedAgentFiles({ dryRun: false });
|
|
|
|
expect(result.dryRun).toBe(false);
|
|
expect(result.agentsWithOrphans).toBe(1);
|
|
expect(result.agentsUpdated).toBe(1);
|
|
expect(result.totalOrphansRemoved).toBe(3);
|
|
|
|
const after = await Agent.findOne({ id: agent.id }).lean();
|
|
expect(after.tool_resources.file_search.file_ids).toEqual([keeperA]);
|
|
expect(after.tool_resources.execute_code.file_ids).toEqual([keeperB]);
|
|
expect(after.tool_resources.context.file_ids).toEqual([]);
|
|
});
|
|
|
|
test('is idempotent — re-running on a clean database is a no-op', async () => {
|
|
await seedFile('keeper');
|
|
await seedAgent({ file_search: { file_ids: ['keeper', 'orphan'] } });
|
|
|
|
await migrateOrphanedAgentFiles({ dryRun: false });
|
|
const second = await migrateOrphanedAgentFiles({ dryRun: false });
|
|
|
|
expect(second.agentsWithOrphans).toBe(0);
|
|
expect(second.agentsUpdated).toBe(0);
|
|
expect(second.totalOrphansRemoved).toBe(0);
|
|
});
|
|
|
|
test('leaves agents without orphans completely alone', async () => {
|
|
await seedFile('only');
|
|
const agent = await seedAgent({ file_search: { file_ids: ['only'] } });
|
|
|
|
const result = await migrateOrphanedAgentFiles({ dryRun: false });
|
|
|
|
expect(result.scannedAgents).toBe(1);
|
|
expect(result.agentsWithOrphans).toBe(0);
|
|
const after = await Agent.findOne({ id: agent.id }).lean();
|
|
expect(after.tool_resources.file_search.file_ids).toEqual(['only']);
|
|
});
|
|
|
|
test('sample array is bounded on wide corruption (DETAIL_SAMPLE_LIMIT)', async () => {
|
|
// Seed more than the cap (50) so the truncation branch is exercised.
|
|
const agents = [];
|
|
for (let i = 0; i < 55; i++) {
|
|
agents.push(
|
|
await seedAgent({
|
|
file_search: { file_ids: [`orphan_${i}`] },
|
|
}),
|
|
);
|
|
}
|
|
|
|
const result = await migrateOrphanedAgentFiles({ dryRun: true });
|
|
|
|
expect(result.agentsWithOrphans).toBe(55);
|
|
expect(result.details.length).toBeLessThanOrEqual(50);
|
|
expect(result.details.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('runs the body inside a system tenant context (strict-mode safe)', async () => {
|
|
// Pins the runAsSystem wrap: without it the migration throws under
|
|
// TENANT_ISOLATION_STRICT=true on the very first Agent.countDocuments(),
|
|
// blocking the intended remediation path for corrupted agents.
|
|
const { SYSTEM_TENANT_ID, tenantStorage } = require('@librechat/data-schemas');
|
|
await seedFile('keeper');
|
|
await seedAgent({ file_search: { file_ids: ['keeper', 'orphan'] } });
|
|
|
|
const contextsObserved = [];
|
|
const originalCountDocuments = Agent.countDocuments.bind(Agent);
|
|
Agent.countDocuments = jest.fn((...args) => {
|
|
contextsObserved.push(tenantStorage.getStore()?.tenantId);
|
|
return originalCountDocuments(...args);
|
|
});
|
|
|
|
try {
|
|
await migrateOrphanedAgentFiles({ dryRun: false });
|
|
expect(contextsObserved).toContain(SYSTEM_TENANT_ID);
|
|
} finally {
|
|
Agent.countDocuments = originalCountDocuments;
|
|
}
|
|
});
|
|
});
|