| | 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([]); |
| | }); |
| | }); |
| | }); |
| |
|