| const { getCachedTools, getAppConfig } = require('~/server/services/Config'); |
| const { getLogStores } = require('~/cache'); |
|
|
| jest.mock('@librechat/data-schemas', () => ({ |
| logger: { |
| debug: jest.fn(), |
| error: jest.fn(), |
| warn: jest.fn(), |
| }, |
| })); |
|
|
| jest.mock('~/server/services/Config', () => ({ |
| getCachedTools: jest.fn(), |
| getAppConfig: jest.fn().mockResolvedValue({ |
| filteredTools: [], |
| includedTools: [], |
| }), |
| setCachedTools: jest.fn(), |
| })); |
|
|
| |
| |
|
|
| jest.mock('~/app/clients/tools', () => ({ |
| availableTools: [], |
| toolkits: [], |
| })); |
|
|
| jest.mock('~/cache', () => ({ |
| getLogStores: jest.fn(), |
| })); |
|
|
| const { getAvailableTools, getAvailablePluginsController } = require('./PluginController'); |
|
|
| describe('PluginController', () => { |
| let mockReq, mockRes, mockCache; |
|
|
| beforeEach(() => { |
| jest.clearAllMocks(); |
| mockReq = { |
| user: { id: 'test-user-id' }, |
| config: { |
| filteredTools: [], |
| includedTools: [], |
| }, |
| }; |
| mockRes = { status: jest.fn().mockReturnThis(), json: jest.fn() }; |
| mockCache = { get: jest.fn(), set: jest.fn() }; |
| getLogStores.mockReturnValue(mockCache); |
|
|
| |
| require('~/app/clients/tools').availableTools.length = 0; |
| require('~/app/clients/tools').toolkits.length = 0; |
|
|
| |
| getCachedTools.mockReset(); |
|
|
| |
| getAppConfig.mockReset(); |
| getAppConfig.mockResolvedValue({ |
| filteredTools: [], |
| includedTools: [], |
| }); |
| }); |
|
|
| describe('getAvailablePluginsController', () => { |
| it('should use filterUniquePlugins to remove duplicate plugins', async () => { |
| |
| const mockPlugins = [ |
| { name: 'Plugin1', pluginKey: 'key1', description: 'First' }, |
| { name: 'Plugin1', pluginKey: 'key1', description: 'First duplicate' }, |
| { name: 'Plugin2', pluginKey: 'key2', description: 'Second' }, |
| ]; |
|
|
| require('~/app/clients/tools').availableTools.push(...mockPlugins); |
|
|
| mockCache.get.mockResolvedValue(null); |
|
|
| |
| getAppConfig.mockResolvedValueOnce({ |
| filteredTools: [], |
| includedTools: [], |
| }); |
|
|
| await getAvailablePluginsController(mockReq, mockRes); |
|
|
| expect(mockRes.status).toHaveBeenCalledWith(200); |
| const responseData = mockRes.json.mock.calls[0][0]; |
| |
| expect(responseData).toHaveLength(2); |
| expect(responseData[0].pluginKey).toBe('key1'); |
| expect(responseData[1].pluginKey).toBe('key2'); |
| }); |
|
|
| it('should use checkPluginAuth to verify plugin authentication', async () => { |
| |
| |
| const mockPlugin = { name: 'Plugin1', pluginKey: 'key1', description: 'First' }; |
|
|
| require('~/app/clients/tools').availableTools.push(mockPlugin); |
| mockCache.get.mockResolvedValue(null); |
|
|
| |
| getAppConfig.mockResolvedValueOnce({ |
| filteredTools: [], |
| includedTools: [], |
| }); |
|
|
| await getAvailablePluginsController(mockReq, mockRes); |
|
|
| const responseData = mockRes.json.mock.calls[0][0]; |
| |
| expect(responseData[0].authenticated).toBeUndefined(); |
| }); |
|
|
| it('should return cached plugins when available', async () => { |
| const cachedPlugins = [ |
| { name: 'CachedPlugin', pluginKey: 'cached', description: 'Cached plugin' }, |
| ]; |
|
|
| mockCache.get.mockResolvedValue(cachedPlugins); |
|
|
| await getAvailablePluginsController(mockReq, mockRes); |
|
|
| |
| expect(mockRes.json).toHaveBeenCalledWith(cachedPlugins); |
| }); |
|
|
| it('should filter plugins based on includedTools', async () => { |
| const mockPlugins = [ |
| { name: 'Plugin1', pluginKey: 'key1', description: 'First' }, |
| { name: 'Plugin2', pluginKey: 'key2', description: 'Second' }, |
| ]; |
|
|
| require('~/app/clients/tools').availableTools.push(...mockPlugins); |
| mockCache.get.mockResolvedValue(null); |
|
|
| |
| getAppConfig.mockResolvedValueOnce({ |
| filteredTools: [], |
| includedTools: ['key1'], |
| }); |
|
|
| await getAvailablePluginsController(mockReq, mockRes); |
|
|
| const responseData = mockRes.json.mock.calls[0][0]; |
| expect(responseData).toHaveLength(1); |
| expect(responseData[0].pluginKey).toBe('key1'); |
| }); |
| }); |
|
|
| describe('getAvailableTools', () => { |
| it('should use filterUniquePlugins to deduplicate combined tools', async () => { |
| const mockUserTools = { |
| 'user-tool': { |
| type: 'function', |
| function: { |
| name: 'user-tool', |
| description: 'User tool', |
| parameters: { type: 'object', properties: {} }, |
| }, |
| }, |
| }; |
|
|
| const mockCachedPlugins = [ |
| { name: 'user-tool', pluginKey: 'user-tool', description: 'Duplicate user tool' }, |
| { name: 'ManifestTool', pluginKey: 'manifest-tool', description: 'Manifest tool' }, |
| ]; |
|
|
| mockCache.get.mockResolvedValue(mockCachedPlugins); |
| getCachedTools.mockResolvedValueOnce(mockUserTools); |
| mockReq.config = { |
| mcpConfig: null, |
| paths: { structuredTools: '/mock/path' }, |
| }; |
|
|
| await getAvailableTools(mockReq, mockRes); |
|
|
| expect(mockRes.status).toHaveBeenCalledWith(200); |
| const responseData = mockRes.json.mock.calls[0][0]; |
| expect(Array.isArray(responseData)).toBe(true); |
| |
| const userToolCount = responseData.filter((tool) => tool.pluginKey === 'user-tool').length; |
| expect(userToolCount).toBe(1); |
| }); |
|
|
| it('should use checkPluginAuth to verify authentication status', async () => { |
| |
| const mockPlugin = { |
| name: 'Tool1', |
| pluginKey: 'tool1', |
| description: 'Tool 1', |
| |
| }; |
|
|
| require('~/app/clients/tools').availableTools.push(mockPlugin); |
|
|
| mockCache.get.mockResolvedValue(null); |
| |
| getCachedTools.mockResolvedValueOnce({ |
| tool1: { |
| type: 'function', |
| function: { |
| name: 'tool1', |
| description: 'Tool 1', |
| parameters: {}, |
| }, |
| }, |
| }); |
| mockReq.config = { |
| mcpConfig: null, |
| paths: { structuredTools: '/mock/path' }, |
| }; |
|
|
| await getAvailableTools(mockReq, mockRes); |
|
|
| expect(mockRes.status).toHaveBeenCalledWith(200); |
| const responseData = mockRes.json.mock.calls[0][0]; |
| expect(Array.isArray(responseData)).toBe(true); |
| const tool = responseData.find((t) => t.pluginKey === 'tool1'); |
| expect(tool).toBeDefined(); |
| |
| expect(tool.authenticated).toBeUndefined(); |
| }); |
|
|
| it('should use getToolkitKey for toolkit validation', async () => { |
| const mockToolkit = { |
| name: 'Toolkit1', |
| pluginKey: 'toolkit1', |
| description: 'Toolkit 1', |
| toolkit: true, |
| }; |
|
|
| require('~/app/clients/tools').availableTools.push(mockToolkit); |
|
|
| |
| require('~/app/clients/tools').toolkits.push({ |
| name: 'Toolkit1', |
| pluginKey: 'toolkit1', |
| tools: ['toolkit1_function'], |
| }); |
|
|
| mockCache.get.mockResolvedValue(null); |
| |
| getCachedTools.mockResolvedValueOnce({ |
| toolkit1_function: { |
| type: 'function', |
| function: { |
| name: 'toolkit1_function', |
| description: 'Toolkit function', |
| parameters: {}, |
| }, |
| }, |
| }); |
| mockReq.config = { |
| mcpConfig: null, |
| paths: { structuredTools: '/mock/path' }, |
| }; |
|
|
| await getAvailableTools(mockReq, mockRes); |
|
|
| expect(mockRes.status).toHaveBeenCalledWith(200); |
| const responseData = mockRes.json.mock.calls[0][0]; |
| expect(Array.isArray(responseData)).toBe(true); |
| const toolkit = responseData.find((t) => t.pluginKey === 'toolkit1'); |
| expect(toolkit).toBeDefined(); |
| }); |
| }); |
|
|
| describe('helper function integration', () => { |
| it('should handle error cases gracefully', async () => { |
| mockCache.get.mockRejectedValue(new Error('Cache error')); |
|
|
| await getAvailableTools(mockReq, mockRes); |
|
|
| expect(mockRes.status).toHaveBeenCalledWith(500); |
| expect(mockRes.json).toHaveBeenCalledWith({ message: 'Cache error' }); |
| }); |
| }); |
|
|
| describe('edge cases with undefined/null values', () => { |
| it('should handle undefined cache gracefully', async () => { |
| getLogStores.mockReturnValue(undefined); |
|
|
| await getAvailableTools(mockReq, mockRes); |
|
|
| expect(mockRes.status).toHaveBeenCalledWith(500); |
| }); |
|
|
| it('should handle null cachedTools and cachedUserTools', async () => { |
| mockCache.get.mockResolvedValue(null); |
| |
| getCachedTools.mockResolvedValueOnce({}); |
| mockReq.config = { |
| mcpConfig: null, |
| paths: { structuredTools: '/mock/path' }, |
| }; |
|
|
| await getAvailableTools(mockReq, mockRes); |
|
|
| |
| expect(mockRes.status).toHaveBeenCalledWith(200); |
| expect(mockRes.json).toHaveBeenCalledWith([]); |
| }); |
|
|
| it('should handle when getCachedTools returns undefined', async () => { |
| mockCache.get.mockResolvedValue(null); |
| mockReq.config = { |
| mcpConfig: null, |
| paths: { structuredTools: '/mock/path' }, |
| }; |
|
|
| |
| getCachedTools.mockReset(); |
| getCachedTools.mockResolvedValueOnce(undefined); |
|
|
| await getAvailableTools(mockReq, mockRes); |
|
|
| |
| expect(mockRes.status).toHaveBeenCalledWith(200); |
| expect(mockRes.json).toHaveBeenCalledWith([]); |
| }); |
|
|
| it('should handle empty toolDefinitions object', async () => { |
| mockCache.get.mockResolvedValue(null); |
| |
| getCachedTools.mockReset(); |
| getCachedTools.mockResolvedValue({}); |
| mockReq.config = {}; |
|
|
| |
| require('~/app/clients/tools').availableTools.length = 0; |
|
|
| await getAvailableTools(mockReq, mockRes); |
|
|
| |
| expect(mockRes.json).toHaveBeenCalledWith([]); |
| }); |
|
|
| it('should handle undefined filteredTools and includedTools', async () => { |
| mockReq.config = {}; |
| mockCache.get.mockResolvedValue(null); |
|
|
| |
| |
| getAppConfig.mockResolvedValueOnce({}); |
|
|
| await getAvailablePluginsController(mockReq, mockRes); |
|
|
| expect(mockRes.status).toHaveBeenCalledWith(200); |
| expect(mockRes.json).toHaveBeenCalledWith([]); |
| }); |
|
|
| it('should handle toolkit with undefined toolDefinitions keys', async () => { |
| const mockToolkit = { |
| name: 'Toolkit1', |
| pluginKey: 'toolkit1', |
| description: 'Toolkit 1', |
| toolkit: true, |
| }; |
|
|
| |
|
|
| |
| require('~/app/clients/tools').availableTools.push(mockToolkit); |
|
|
| mockCache.get.mockResolvedValue(null); |
| |
| getCachedTools.mockResolvedValueOnce({}); |
| mockReq.config = { |
| mcpConfig: null, |
| paths: { structuredTools: '/mock/path' }, |
| }; |
|
|
| await getAvailableTools(mockReq, mockRes); |
|
|
| |
| expect(mockRes.status).toHaveBeenCalledWith(200); |
| }); |
|
|
| it('should handle undefined toolDefinitions when checking isToolDefined (traversaal_search bug)', async () => { |
| |
| |
| const mockPlugin = { |
| name: 'Traversaal Search', |
| pluginKey: 'traversaal_search', |
| description: 'Search plugin', |
| }; |
|
|
| |
| require('~/app/clients/tools').availableTools.push(mockPlugin); |
|
|
| mockCache.get.mockResolvedValue(null); |
|
|
| mockReq.config = { |
| mcpConfig: null, |
| paths: { structuredTools: '/mock/path' }, |
| }; |
|
|
| |
| |
| getCachedTools.mockResolvedValueOnce(undefined); |
|
|
| |
| await getAvailableTools(mockReq, mockRes); |
|
|
| |
| expect(mockRes.status).toHaveBeenCalledWith(200); |
| expect(mockRes.json).toHaveBeenCalledWith([]); |
| }); |
|
|
| it('should re-initialize tools from appConfig when cache returns null', async () => { |
| |
| const mockAppTools = { |
| tool1: { |
| type: 'function', |
| function: { |
| name: 'tool1', |
| description: 'Tool 1', |
| parameters: {}, |
| }, |
| }, |
| tool2: { |
| type: 'function', |
| function: { |
| name: 'tool2', |
| description: 'Tool 2', |
| parameters: {}, |
| }, |
| }, |
| }; |
|
|
| |
| require('~/app/clients/tools').availableTools.push( |
| { name: 'Tool 1', pluginKey: 'tool1', description: 'Tool 1' }, |
| { name: 'Tool 2', pluginKey: 'tool2', description: 'Tool 2' }, |
| ); |
|
|
| |
| mockCache.get.mockResolvedValue(null); |
| getCachedTools.mockResolvedValueOnce(null); |
|
|
| mockReq.config = { |
| filteredTools: [], |
| includedTools: [], |
| availableTools: mockAppTools, |
| }; |
|
|
| |
| const { setCachedTools } = require('~/server/services/Config'); |
|
|
| await getAvailableTools(mockReq, mockRes); |
|
|
| |
| expect(setCachedTools).toHaveBeenCalledWith(mockAppTools); |
|
|
| |
| expect(mockRes.status).toHaveBeenCalledWith(200); |
| const responseData = mockRes.json.mock.calls[0][0]; |
| expect(responseData).toHaveLength(2); |
| expect(responseData.find((t) => t.pluginKey === 'tool1')).toBeDefined(); |
| expect(responseData.find((t) => t.pluginKey === 'tool2')).toBeDefined(); |
| }); |
|
|
| it('should handle cache clear without appConfig.availableTools gracefully', async () => { |
| |
| getAppConfig.mockResolvedValue({ |
| filteredTools: [], |
| includedTools: [], |
| |
| }); |
|
|
| |
| require('~/app/clients/tools').availableTools.length = 0; |
|
|
| |
| mockCache.get.mockResolvedValue(null); |
| getCachedTools.mockResolvedValueOnce(null); |
|
|
| mockReq.config = { |
| filteredTools: [], |
| includedTools: [], |
| |
| }; |
|
|
| await getAvailableTools(mockReq, mockRes); |
|
|
| |
| expect(mockRes.status).toHaveBeenCalledWith(200); |
| expect(mockRes.json).toHaveBeenCalledWith([]); |
| }); |
| }); |
| }); |
|
|