Ctrl K
ComunidadGuías…

api/test/migrate-orphaned-agent-files.spec.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * 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;
    }
  });
});