UNPKG

@the_cfdude/productboard-mcp

Version:

Model Context Protocol server for Productboard REST API with dynamic tool loading

917 lines (916 loc) 40.6 kB
/** * @jest-environment node */ import { describe, it, expect, jest, beforeEach } from '@jest/globals'; import { SearchEngine } from '../utils/search-engine.js'; import { ValidationError } from '../errors/index.js'; // Mock all dependencies jest.mock('../utils/debug-logger.js', () => ({ debugLog: jest.fn(), })); // Create a mock process output function const mockProcessOutput = jest.fn((data, _entityType, _output) => data); jest.mock('../utils/search-output-processor.js', () => ({ OutputProcessor: jest.fn().mockImplementation(() => ({ processOutput: mockProcessOutput, })), })); jest.mock('../utils/search-field-mappings.js', () => ({ EntityFieldMappings: { features: { listFunction: 'get_features', searchableFields: ['name', 'description', 'status.id', 'owner.email'], serverSideFilters: ['status.id', 'owner.email'], filterMappings: { 'status.id': 'statusId', 'owner.email': 'ownerEmail', }, }, products: { listFunction: 'get_products', searchableFields: ['name', 'description', 'owner.email'], serverSideFilters: ['owner.email'], filterMappings: { 'owner.email': 'ownerEmail', }, }, companies: { listFunction: 'get_companies', searchableFields: ['name', 'domain'], serverSideFilters: ['domain'], filterMappings: {}, }, }, })); jest.mock('../utils/search-pattern-utils.js', () => ({ compilePattern: jest.fn(() => ({ type: 'mock', pattern: 'test' })), applyPatternFilter: jest.fn(() => true), generateSearchSuggestions: jest.fn(() => ['suggestion1', 'suggestion2']), expandFieldPatterns: jest.fn((fields) => [ ...fields, 'expanded_field', ]), validatePatternComplexity: jest.fn(() => true), })); // Mock tool handlers with proper jest.fn() implementations jest.mock('../tools/features.js', () => ({ handleFeaturesTool: jest.fn(), })); jest.mock('../tools/products.js', () => ({ handleProductsTool: jest.fn(), })); jest.mock('../tools/companies.js', () => ({ handleCompaniesTool: jest.fn(), })); jest.mock('../tools/components.js', () => ({ handleComponentsTool: jest.fn(), })); jest.mock('../tools/notes.js', () => ({ handleNotesTool: jest.fn(), })); jest.mock('../tools/users.js', () => ({ handleUsersTool: jest.fn(), })); jest.mock('../tools/releases.js', () => ({ handleReleasesTool: jest.fn(), })); jest.mock('../tools/objectives.js', () => ({ handleObjectivesTool: jest.fn(), })); jest.mock('../tools/custom-fields.js', () => ({ handleCustomFieldsTool: jest.fn(), })); jest.mock('../tools/webhooks.js', () => ({ handleWebhooksTool: jest.fn(), })); jest.mock('../tools/plugin-integrations.js', () => ({ handlePluginIntegrationsTool: jest.fn(), })); jest.mock('../tools/jira-integrations.js', () => ({ handleJiraIntegrationsTool: jest.fn(), })); // Import mocked modules import { handleFeaturesTool as mockHandleFeaturesTool } from '../tools/features.js'; import { handleProductsTool as mockHandleProductsTool } from '../tools/products.js'; import { handleCompaniesTool as mockHandleCompaniesTool } from '../tools/companies.js'; import { handleComponentsTool as mockHandleComponentsTool } from '../tools/components.js'; import { handleNotesTool as mockHandleNotesTool } from '../tools/notes.js'; import { handleUsersTool as mockHandleUsersTool } from '../tools/users.js'; import { handleReleasesTool as mockHandleReleasesTool } from '../tools/releases.js'; import { handleObjectivesTool as mockHandleObjectivesTool } from '../tools/objectives.js'; import { handleCustomFieldsTool as mockHandleCustomFieldsTool } from '../tools/custom-fields.js'; import { handleWebhooksTool as mockHandleWebhooksTool } from '../tools/webhooks.js'; import { handlePluginIntegrationsTool as mockHandlePluginIntegrationsTool } from '../tools/plugin-integrations.js'; import { handleJiraIntegrationsTool as mockHandleJiraIntegrationsTool } from '../tools/jira-integrations.js'; describe('SearchEngine', () => { let searchEngine; let mockContext; beforeEach(() => { jest.clearAllMocks(); searchEngine = new SearchEngine(); mockContext = { session: 'test-session' }; }); describe('validateAndNormalizeParams', () => { describe('Basic Validation', () => { it('should normalize single entity type to array', async () => { const params = { entityType: 'features', filters: {}, }; const result = await searchEngine.validateAndNormalizeParams(params); expect(result.entityType).toBe('features'); expect(result.entityTypes).toEqual(['features']); }); it('should keep entity type array as is', async () => { const params = { entityType: ['features', 'products'], filters: {}, }; const result = await searchEngine.validateAndNormalizeParams(params); expect(result.entityType).toEqual(['features', 'products']); expect(result.entityTypes).toEqual(['features', 'products']); }); it('should throw ValidationError for unsupported entity type', async () => { const params = { entityType: 'invalid', filters: {}, }; await expect(searchEngine.validateAndNormalizeParams(params)).rejects.toThrow(ValidationError); }); it('should apply default values correctly', async () => { const params = { entityType: 'features', filters: {}, }; const result = await searchEngine.validateAndNormalizeParams(params); expect(result.output).toBe('full'); expect(result.limit).toBe(50); expect(result.startWith).toBe(0); expect(result.detail).toBe('standard'); expect(result.includeSubData).toBe(false); expect(result.includeCustomFields).toBe(false); expect(result.patternMatchMode).toBe('wildcard'); expect(result.caseSensitive).toBe(false); expect(result.suggestAlternatives).toBe(false); expect(result.maxSuggestions).toBe(5); }); it('should enforce limit boundaries', async () => { const params = { entityType: 'features', filters: {}, limit: 150, startWith: -5, }; const result = await searchEngine.validateAndNormalizeParams(params); expect(result.limit).toBe(100); // Max enforced expect(result.startWith).toBe(0); // Minimum enforced }); it('should enforce minimum limit of 1', async () => { const params = { entityType: 'features', filters: {}, limit: 0, }; const result = await searchEngine.validateAndNormalizeParams(params); expect(result.limit).toBe(1); }); }); describe('Filter Validation', () => { it('should validate filters for single entity type', async () => { const params = { entityType: 'features', filters: { name: 'test', 'status.id': '123', }, }; const result = await searchEngine.validateAndNormalizeParams(params); expect(result.filters).toEqual({ name: 'test', 'status.id': '123', }); }); it('should throw ValidationError for invalid filter fields', async () => { const params = { entityType: 'features', filters: { invalidField: 'test', }, }; await expect(searchEngine.validateAndNormalizeParams(params)).rejects.toThrow(ValidationError); }); it('should validate filters for multiple entity types', async () => { const params = { entityType: ['features', 'products'], filters: { name: 'test', // Available in both }, }; const result = await searchEngine.validateAndNormalizeParams(params); expect(result.filters.name).toBe('test'); }); it('should throw error when filter field is not available in any entity type', async () => { const params = { entityType: ['features', 'products'], filters: { invalidField: 'test', }, }; await expect(searchEngine.validateAndNormalizeParams(params)).rejects.toThrow(ValidationError); }); }); describe('Operator Validation', () => { it('should validate valid operators', async () => { const params = { entityType: 'features', filters: { name: 'test' }, operators: { name: 'contains', }, }; const result = await searchEngine.validateAndNormalizeParams(params); expect(result.operators.name).toBe('contains'); }); it('should throw ValidationError for invalid operators', async () => { const params = { entityType: 'features', filters: { name: 'test' }, operators: { name: 'invalidOperator', }, }; await expect(searchEngine.validateAndNormalizeParams(params)).rejects.toThrow(ValidationError); }); it('should accept all valid operators', async () => { const validOperators = [ 'equals', 'contains', 'isEmpty', 'startsWith', 'endsWith', 'before', 'after', 'regex', 'wildcard', ]; for (const operator of validOperators) { const params = { entityType: 'features', filters: { name: 'test' }, operators: { name: operator }, }; const result = await searchEngine.validateAndNormalizeParams(params); expect(result.operators.name).toBe(operator); } }); }); describe('Output Field Validation', () => { it('should validate output fields for single entity type', async () => { const params = { entityType: 'features', filters: {}, output: ['name', 'description'], }; const result = await searchEngine.validateAndNormalizeParams(params); expect(result.output).toEqual(['name', 'description']); }); it('should throw ValidationError for invalid output fields', async () => { const params = { entityType: 'features', filters: {}, output: ['invalidField'], }; await expect(searchEngine.validateAndNormalizeParams(params)).rejects.toThrow(ValidationError); }); it('should skip validation for non-array output values', async () => { const params = { entityType: 'features', filters: {}, output: 'full', }; const result = await searchEngine.validateAndNormalizeParams(params); expect(result.output).toBe('full'); }); }); describe('Enhanced Parameters', () => { it('should handle all enhanced search parameters', async () => { const params = { entityType: 'features', filters: {}, patternMatchMode: 'regex', caseSensitive: true, suggestAlternatives: true, maxSuggestions: 8, instance: 'test-instance', workspaceId: 'test-workspace', }; const result = await searchEngine.validateAndNormalizeParams(params); expect(result.patternMatchMode).toBe('regex'); expect(result.caseSensitive).toBe(true); expect(result.suggestAlternatives).toBe(true); expect(result.maxSuggestions).toBe(8); expect(result.instance).toBe('test-instance'); expect(result.workspaceId).toBe('test-workspace'); }); it('should enforce maxSuggestions limit', async () => { const params = { entityType: 'features', filters: {}, maxSuggestions: 15, }; const result = await searchEngine.validateAndNormalizeParams(params); expect(result.maxSuggestions).toBe(10); }); }); }); describe('executeEntitySearch', () => { const mockNormalizedParams = { entityType: 'features', entityTypes: ['features'], filters: {}, operators: {}, output: 'full', limit: 50, startWith: 0, detail: 'standard', includeSubData: false, includeCustomFields: false, patternMatchMode: 'wildcard', caseSensitive: false, suggestAlternatives: false, maxSuggestions: 5, }; describe('Single Entity Search', () => { it('should execute single entity search successfully', async () => { const mockResponse = { content: [ { type: 'text', text: JSON.stringify({ data: [{ id: '1', name: 'Feature 1' }], totalRecords: 1, hasMore: false, }), }, ], }; mockHandleFeaturesTool.mockResolvedValue(mockResponse); const result = await searchEngine.executeEntitySearch(mockContext, mockNormalizedParams); expect(result.data).toEqual([{ id: '1', name: 'Feature 1' }]); expect(result.totalRecords).toBe(1); expect(result.hasMore).toBe(false); expect(result.warnings).toEqual([]); expect(typeof result.queryTimeMs).toBe('number'); }); it('should handle search errors gracefully', async () => { mockHandleFeaturesTool.mockRejectedValue(new Error('API Error')); await expect(searchEngine.executeEntitySearch(mockContext, mockNormalizedParams)).rejects.toThrow('Entity search failed for features: API Error'); }); }); describe('Multiple Entity Search', () => { it('should execute multi-entity search successfully', async () => { const multiEntityParams = { ...mockNormalizedParams, entityType: ['features', 'products'], entityTypes: ['features', 'products'], }; const featureResponse = { content: [ { type: 'text', text: JSON.stringify({ data: [{ id: '1', name: 'Feature 1', type: 'feature' }], totalRecords: 1, hasMore: false, }), }, ], }; const productResponse = { content: [ { type: 'text', text: JSON.stringify({ data: [{ id: '2', name: 'Product 1', type: 'product' }], totalRecords: 1, hasMore: false, }), }, ], }; mockHandleFeaturesTool.mockResolvedValue(featureResponse); mockHandleProductsTool.mockResolvedValue(productResponse); const result = await searchEngine.executeEntitySearch(mockContext, multiEntityParams); expect(result.data).toHaveLength(2); expect(result.data[0]).toEqual({ id: '1', name: 'Feature 1', type: 'feature', _entityType: 'features', }); expect(result.data[1]).toEqual({ id: '2', name: 'Product 1', type: 'product', _entityType: 'products', }); expect(result.totalRecords).toBe(2); }); it('should handle partial failures in multi-entity search', async () => { const multiEntityParams = { ...mockNormalizedParams, entityType: ['features', 'products'], entityTypes: ['features', 'products'], }; mockHandleFeaturesTool.mockResolvedValue({ content: [ { type: 'text', text: JSON.stringify({ data: [{ id: '1', name: 'Feature 1' }], totalRecords: 1, hasMore: false, }), }, ], }); mockHandleProductsTool.mockRejectedValue(new Error('Products API Error')); await expect(searchEngine.executeEntitySearch(mockContext, multiEntityParams)).rejects.toThrow('Multi-entity search failed'); }); }); describe('Entity Handler Routing', () => { const testCases = [ { entityType: 'features', handler: 'handleFeaturesTool' }, { entityType: 'products', handler: 'handleProductsTool' }, { entityType: 'companies', handler: 'handleCompaniesTool', }, { entityType: 'components', handler: 'handleComponentsTool', }, { entityType: 'notes', handler: 'handleNotesTool' }, { entityType: 'users', handler: 'handleUsersTool' }, { entityType: 'releases', handler: 'handleReleasesTool' }, { entityType: 'objectives', handler: 'handleObjectivesTool', }, { entityType: 'custom_fields', handler: 'handleCustomFieldsTool', }, { entityType: 'webhooks', handler: 'handleWebhooksTool' }, { entityType: 'plugin_integrations', handler: 'handlePluginIntegrationsTool', }, { entityType: 'jira_integrations', handler: 'handleJiraIntegrationsTool', }, ]; testCases.forEach(({ entityType, handler }) => { it(`should route ${entityType} to ${handler}`, async () => { const params = { ...mockNormalizedParams, entityType, entityTypes: [entityType], }; const mockResponse = { content: [ { type: 'text', text: JSON.stringify({ data: [{ id: '1', name: 'Test Item' }], totalRecords: 1, hasMore: false, }), }, ], }; // Mock the specific handler const mockHandlers = { handleFeaturesTool: mockHandleFeaturesTool, handleProductsTool: mockHandleProductsTool, handleCompaniesTool: mockHandleCompaniesTool, handleComponentsTool: mockHandleComponentsTool, handleNotesTool: mockHandleNotesTool, handleUsersTool: mockHandleUsersTool, handleReleasesTool: mockHandleReleasesTool, handleObjectivesTool: mockHandleObjectivesTool, handleCustomFieldsTool: mockHandleCustomFieldsTool, handleWebhooksTool: mockHandleWebhooksTool, handlePluginIntegrationsTool: mockHandlePluginIntegrationsTool, handleJiraIntegrationsTool: mockHandleJiraIntegrationsTool, }; const mockHandler = mockHandlers[handler]; mockHandler.mockResolvedValue(mockResponse); await searchEngine.executeEntitySearch(mockContext, params); expect(mockHandler).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ limit: 50, startWith: 0, detail: 'standard', includeSubData: false, includeCustomFields: false, })); }); }); it('should throw error for unsupported entity type', async () => { const params = { ...mockNormalizedParams, entityType: 'unsupported', entityTypes: ['unsupported'], }; await expect(searchEngine.executeEntitySearch(mockContext, params)).rejects.toThrow('No handler available for entity type: unsupported'); }); }); describe('Pagination Handling', () => { it('should handle multiple pages with cursor-based pagination', async () => { // First page response const firstPageResponse = { content: [ { type: 'text', text: JSON.stringify({ data: [{ id: '1', name: 'Feature 1' }], links: { next: 'https://api.productboard.com/features?pageCursor=cursor123', }, }), }, ], }; // Second page response const secondPageResponse = { content: [ { type: 'text', text: JSON.stringify({ data: [{ id: '2', name: 'Feature 2' }], // No links.next - indicates end of results }), }, ], }; mockHandleFeaturesTool .mockResolvedValueOnce(firstPageResponse) .mockResolvedValueOnce(secondPageResponse); const result = await searchEngine.executeEntitySearch(mockContext, mockNormalizedParams); expect(result.data).toHaveLength(2); expect(result.data[0]).toEqual({ id: '1', name: 'Feature 1' }); expect(result.data[1]).toEqual({ id: '2', name: 'Feature 2' }); expect(mockHandleFeaturesTool).toHaveBeenCalledTimes(2); }); it('should handle multiple pages with offset-based pagination fallback', async () => { const firstPageResponse = { content: [ { type: 'text', text: JSON.stringify({ data: [{ id: '1', name: 'Feature 1' }], links: { next: 'https://api.productboard.com/features?offset=50', // No pageCursor }, }), }, ], }; const secondPageResponse = { content: [ { type: 'text', text: JSON.stringify({ data: [{ id: '2', name: 'Feature 2' }], }), }, ], }; mockHandleFeaturesTool .mockResolvedValueOnce(firstPageResponse) .mockResolvedValueOnce(secondPageResponse); await searchEngine.executeEntitySearch(mockContext, mockNormalizedParams); // Check that second call used offset-based pagination expect(mockHandleFeaturesTool).toHaveBeenCalledTimes(2); expect(mockHandleFeaturesTool).toHaveBeenNthCalledWith(2, expect.any(String), expect.objectContaining({ startWith: 50, // Offset-based pagination })); }); it('should respect page limit for safety', async () => { // Mock 51 pages (exceeds max of 50) const infiniteResponse = { content: [ { type: 'text', text: JSON.stringify({ data: [{ id: '1', name: 'Feature 1' }], links: { next: 'https://api.productboard.com/features?pageCursor=nextCursor', }, }), }, ], }; mockHandleFeaturesTool.mockResolvedValue(infiniteResponse); const result = await searchEngine.executeEntitySearch(mockContext, mockNormalizedParams); expect(mockHandleFeaturesTool).toHaveBeenCalledTimes(50); // Max page limit expect(result.hasMore).toBe(true); }); }); }); describe('processResults', () => { const mockNormalizedParams = { entityType: 'features', entityTypes: ['features'], filters: {}, operators: {}, output: 'full', limit: 50, startWith: 0, detail: 'standard', includeSubData: false, includeCustomFields: false, patternMatchMode: 'wildcard', caseSensitive: false, suggestAlternatives: false, maxSuggestions: 5, }; it('should process results without modification for full output', async () => { const rawResults = { data: [{ id: '1', name: 'Feature 1' }], totalRecords: 1, hasMore: false, warnings: [], queryTimeMs: 100, }; const result = await searchEngine.processResults(rawResults, mockNormalizedParams); expect(result.data).toEqual([{ id: '1', name: 'Feature 1' }]); expect(result.warnings).toEqual([]); }); it('should process output with OutputProcessor for non-full output', async () => { const rawResults = { data: [{ id: '1', name: 'Feature 1', description: 'Test description' }], totalRecords: 1, hasMore: false, warnings: [], queryTimeMs: 100, }; const paramsWithOutput = { ...mockNormalizedParams, output: ['name'], }; mockProcessOutput.mockReturnValue([{ name: 'Feature 1' }]); const result = await searchEngine.processResults(rawResults, paramsWithOutput); expect(mockProcessOutput).toHaveBeenCalledWith(rawResults.data, 'features', ['name']); expect(result.data).toEqual([{ name: 'Feature 1' }]); }); it('should add warning for conflicting output and detail parameters', async () => { const rawResults = { data: [{ id: '1', name: 'Feature 1' }], totalRecords: 1, hasMore: false, warnings: [], queryTimeMs: 100, }; const paramsWithConflict = { ...mockNormalizedParams, output: ['name'], detail: 'full', }; const result = await searchEngine.processResults(rawResults, paramsWithConflict); expect(result.warnings).toContain('output parameter overrides detail level "full" - exact fields and order determined by output specification'); }); it('should handle multi-entity results with proper entity type preservation', async () => { const rawResults = { data: [ { id: '1', name: 'Feature 1', _entityType: 'features' }, { id: '2', name: 'Product 1', _entityType: 'products' }, ], totalRecords: 2, hasMore: false, warnings: [], queryTimeMs: 100, }; const multiEntityParams = { ...mockNormalizedParams, entityTypes: ['features', 'products'], output: ['name'], }; mockProcessOutput.mockReturnValue([{ name: 'Processed' }]); const result = await searchEngine.processResults(rawResults, multiEntityParams); expect(mockProcessOutput).toHaveBeenCalledTimes(2); // Once per item expect(result.data).toHaveLength(2); }); it('should handle ids-only output for multi-entity results', async () => { const rawResults = { data: [ { id: '1', name: 'Feature 1', _entityType: 'features' }, { id: '2', name: 'Product 1', _entityType: 'products' }, ], totalRecords: 2, hasMore: false, warnings: [], queryTimeMs: 100, }; const idsOnlyParams = { ...mockNormalizedParams, entityTypes: ['features', 'products'], output: 'ids-only', }; mockProcessOutput.mockReturnValue(['1', '2']); const result = await searchEngine.processResults(rawResults, idsOnlyParams); expect(mockProcessOutput).toHaveBeenCalledWith(rawResults.data, 'features', // Entity type doesn't matter for ids-only 'ids-only'); expect(result.data).toEqual(['1', '2']); }); }); describe('generateSmartSuggestions', () => { const mockNormalizedParams = { entityType: 'features', entityTypes: ['features'], filters: { name: 'test*', description: 'sample' }, operators: { name: 'equals' }, output: 'full', limit: 50, startWith: 0, detail: 'standard', includeSubData: false, includeCustomFields: false, patternMatchMode: 'wildcard', caseSensitive: false, suggestAlternatives: false, maxSuggestions: 5, }; it('should generate field expansion suggestions', async () => { const suggestions = await searchEngine.generateSmartSuggestions(mockNormalizedParams); expect(suggestions).toContainEqual(expect.objectContaining({ type: 'field_expansion', originalField: expect.any(String), suggestedFields: expect.any(Array), message: expect.stringContaining('Try expanding'), })); }); it('should generate pattern suggestions for search terms', async () => { const suggestions = await searchEngine.generateSmartSuggestions(mockNormalizedParams); expect(suggestions).toContainEqual(expect.objectContaining({ type: 'pattern_suggestion', originalTerm: expect.any(String), suggestions: expect.any(Array), message: expect.stringContaining('Try similar terms'), })); }); it('should suggest wildcard operator for patterns with asterisks', async () => { const suggestions = await searchEngine.generateSmartSuggestions(mockNormalizedParams); expect(suggestions).toContainEqual(expect.objectContaining({ type: 'operator_suggestion', field: 'name', currentOperator: 'equals', suggestedOperator: 'wildcard', message: expect.stringContaining('Use "wildcard" operator'), })); }); it('should suggest regex operator for regex patterns', async () => { const regexParams = { ...mockNormalizedParams, filters: { name: 'test.*pattern' }, operators: { name: 'equals' }, }; const suggestions = await searchEngine.generateSmartSuggestions(regexParams); expect(suggestions).toContainEqual(expect.objectContaining({ type: 'operator_suggestion', field: 'name', currentOperator: 'equals', suggestedOperator: 'regex', message: expect.stringContaining('Use "regex" operator'), })); }); it('should return empty array on error', async () => { // Mock expandFieldPatterns to throw an error const patternModule = await import('../utils/search-pattern-utils.js'); const mockExpandFieldPatterns = patternModule.expandFieldPatterns; mockExpandFieldPatterns.mockImplementation(() => { throw new Error('Test error'); }); const suggestions = await searchEngine.generateSmartSuggestions(mockNormalizedParams); expect(suggestions).toEqual([]); }); }); describe('Edge Cases and Error Handling', () => { it('should handle empty search results gracefully', async () => { mockHandleFeaturesTool.mockResolvedValue({ content: [ { type: 'text', text: JSON.stringify({ data: [], totalRecords: 0, hasMore: false, }), }, ], }); const result = await searchEngine.executeEntitySearch(mockContext, { entityType: 'features', entityTypes: ['features'], filters: {}, operators: {}, output: 'full', limit: 50, startWith: 0, detail: 'standard', includeSubData: false, includeCustomFields: false, patternMatchMode: 'wildcard', caseSensitive: false, suggestAlternatives: false, maxSuggestions: 5, }); expect(result.data).toEqual([]); expect(result.totalRecords).toBe(0); expect(result.hasMore).toBe(false); }); it('should handle malformed API responses', async () => { mockHandleFeaturesTool.mockResolvedValue({ content: [ { type: 'text', text: 'invalid json response', }, ], }); const result = await searchEngine.executeEntitySearch(mockContext, { entityType: 'features', entityTypes: ['features'], filters: {}, operators: {}, output: 'full', limit: 50, startWith: 0, detail: 'standard', includeSubData: false, includeCustomFields: false, patternMatchMode: 'wildcard', caseSensitive: false, suggestAlternatives: false, maxSuggestions: 5, }); expect(result.data).toEqual([]); expect(result.totalRecords).toBe(0); expect(result.hasMore).toBe(false); }); it('should handle missing content in API response', async () => { mockHandleFeaturesTool.mockResolvedValue({ content: [], // Empty content array }); const result = await searchEngine.executeEntitySearch(mockContext, { entityType: 'features', entityTypes: ['features'], filters: {}, operators: {}, output: 'full', limit: 50, startWith: 0, detail: 'standard', includeSubData: false, includeCustomFields: false, patternMatchMode: 'wildcard', caseSensitive: false, suggestAlternatives: false, maxSuggestions: 5, }); expect(result.data).toEqual([]); expect(result.totalRecords).toBe(0); expect(result.hasMore).toBe(false); }); it('should handle complex pattern validation errors', async () => { const patternModule = await import('../utils/search-pattern-utils.js'); const mockValidatePatternComplexity = patternModule.validatePatternComplexity; mockValidatePatternComplexity.mockReturnValue(false); const paramsWithComplexPattern = { entityType: 'features', entityTypes: ['features'], filters: { name: 'very*complex*pattern*here*' }, operators: { name: 'wildcard' }, output: 'full', limit: 50, startWith: 0, detail: 'standard', includeSubData: false, includeCustomFields: false, patternMatchMode: 'wildcard', caseSensitive: false, suggestAlternatives: false, maxSuggestions: 5, }; const rawResults = { data: [{ id: '1', name: 'Feature 1' }], totalRecords: 1, hasMore: false, warnings: [], queryTimeMs: 100, }; await expect(searchEngine.processResults(rawResults, paramsWithComplexPattern)).rejects.toThrow(ValidationError); }); }); });