@the_cfdude/productboard-mcp
Version:
Model Context Protocol server for Productboard REST API with dynamic tool loading
917 lines (916 loc) • 40.6 kB
JavaScript
/**
* @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);
});
});
});