Skip to main content
Glama

mcp-jira-stdio

search-issues.test.ts•22.4 kB
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { handleSearchIssues, searchIssuesTool } from '../../../src/tools/search-issues.js'; import { validateInput } from '../../../src/utils/validators.js'; import { searchIssues } from '../../../src/utils/api-helpers.js'; import { formatSearchResultsResponse } from '../../../src/utils/formatters.js'; import { handleError } from '../../../src/utils/error-handler.js'; import { mockJiraSearchResult, mockUnauthorizedError } from '../../mocks/jira-responses.js'; import { TOOL_NAMES } from '../../../src/config/constants.js'; // Mock dependencies vi.mock('../../../src/utils/validators.js'); vi.mock('../../../src/utils/api-helpers.js'); vi.mock('../../../src/utils/formatters.js'); vi.mock('../../../src/utils/error-handler.js'); const mockedValidateInput = vi.mocked(validateInput); const mockedSearchIssues = vi.mocked(searchIssues); const mockedFormatSearchResultsResponse = vi.mocked(formatSearchResultsResponse); const mockedHandleError = vi.mocked(handleError); describe('search-issues tool', () => { beforeEach(() => { vi.clearAllMocks(); }); describe('searchIssuesTool configuration', () => { it('should have correct tool configuration', () => { expect(searchIssuesTool.name).toBe(TOOL_NAMES.SEARCH_ISSUES); expect(searchIssuesTool.description).toContain('Search for Jira issues using JQL'); expect(searchIssuesTool.inputSchema.type).toBe('object'); expect(searchIssuesTool.inputSchema.required).toEqual(['jql']); // Check required fields expect(searchIssuesTool.inputSchema.properties.jql).toBeDefined(); // Check optional fields with defaults expect(searchIssuesTool.inputSchema.properties.nextPageToken).toBeDefined(); expect(searchIssuesTool.inputSchema.properties.maxResults).toBeDefined(); expect(searchIssuesTool.inputSchema.properties.fields).toBeDefined(); expect(searchIssuesTool.inputSchema.properties.expand).toBeDefined(); }); }); describe('handleSearchIssues', () => { describe('Success Cases', () => { it('should search issues with basic JQL', async () => { const input = { jql: 'project = TEST' }; const validatedInput = { jql: 'project = TEST', maxResults: 50 }; const mockResponse = { content: [{ type: 'text', text: 'search results' }] }; mockedValidateInput.mockReturnValue(validatedInput); mockedSearchIssues.mockResolvedValue(mockJiraSearchResult); mockedFormatSearchResultsResponse.mockReturnValue(mockResponse); const result = await handleSearchIssues(input); expect(mockedValidateInput).toHaveBeenCalledWith(expect.any(Object), input); expect(mockedSearchIssues).toHaveBeenCalledWith({ jql: 'project = TEST', maxResults: 50, }); expect(mockedFormatSearchResultsResponse).toHaveBeenCalledWith(mockJiraSearchResult); expect(result).toEqual(mockResponse); }); it('should search issues with pagination parameters', async () => { const input = { jql: 'project = TEST', nextPageToken: 'token-abc-123', maxResults: 10, }; const validatedInput = { ...input }; mockedValidateInput.mockReturnValue(validatedInput); mockedSearchIssues.mockResolvedValue(mockJiraSearchResult); mockedFormatSearchResultsResponse.mockReturnValue({ content: [] }); await handleSearchIssues(input); expect(mockedSearchIssues).toHaveBeenCalledWith({ jql: 'project = TEST', nextPageToken: 'token-abc-123', maxResults: 10, }); }); it('should search issues with specific fields', async () => { const input = { jql: 'project = TEST', fields: ['summary', 'status', 'assignee'], }; const validatedInput = { ...input, maxResults: 50, }; mockedValidateInput.mockReturnValue(validatedInput); mockedSearchIssues.mockResolvedValue(mockJiraSearchResult); mockedFormatSearchResultsResponse.mockReturnValue({ content: [] }); await handleSearchIssues(input); expect(mockedSearchIssues).toHaveBeenCalledWith({ jql: 'project = TEST', maxResults: 50, fields: ['summary', 'status', 'assignee'], }); }); it('should search issues with expand options', async () => { const input = { jql: 'project = TEST', expand: ['comments', 'changelog'], }; const validatedInput = { ...input, maxResults: 50, }; mockedValidateInput.mockReturnValue(validatedInput); mockedSearchIssues.mockResolvedValue(mockJiraSearchResult); mockedFormatSearchResultsResponse.mockReturnValue({ content: [] }); await handleSearchIssues(input); expect(mockedSearchIssues).toHaveBeenCalledWith({ jql: 'project = TEST', maxResults: 50, expand: ['comments', 'changelog'], }); }); it('should search issues with all parameters', async () => { const input = { jql: 'project = TEST AND assignee = currentUser()', nextPageToken: 'token-xyz-789', maxResults: 25, fields: ['summary', 'status'], expand: ['comments'], }; const validatedInput = { ...input }; mockedValidateInput.mockReturnValue(validatedInput); mockedSearchIssues.mockResolvedValue(mockJiraSearchResult); mockedFormatSearchResultsResponse.mockReturnValue({ content: [] }); await handleSearchIssues(input); expect(mockedSearchIssues).toHaveBeenCalledWith({ jql: 'project = TEST AND assignee = currentUser()', nextPageToken: 'token-xyz-789', maxResults: 25, fields: ['summary', 'status'], expand: ['comments'], }); }); it('should handle complex JQL queries', async () => { const complexJql = 'project in (TEST, DEMO) AND status in ("In Progress", "Open") AND priority >= High AND assignee in membersOf("jira-developers") ORDER BY created DESC'; const input = { jql: complexJql }; const validatedInput = { jql: complexJql, maxResults: 50, }; mockedValidateInput.mockReturnValue(validatedInput); mockedSearchIssues.mockResolvedValue(mockJiraSearchResult); mockedFormatSearchResultsResponse.mockReturnValue({ content: [] }); await handleSearchIssues(input); expect(mockedSearchIssues).toHaveBeenCalledWith({ jql: complexJql, maxResults: 50, }); }); it('should handle undefined optional fields gracefully', async () => { const validatedInput = { jql: 'project = TEST', maxResults: 50, fields: undefined, expand: undefined, }; mockedValidateInput.mockReturnValue(validatedInput); mockedSearchIssues.mockResolvedValue(mockJiraSearchResult); mockedFormatSearchResultsResponse.mockReturnValue({ content: [] }); await handleSearchIssues({}); expect(mockedSearchIssues).toHaveBeenCalledWith({ jql: 'project = TEST', maxResults: 50, }); }); }); describe('Validation', () => { it('should validate input using schema', async () => { const input = { jql: 'project = TEST' }; mockedValidateInput.mockReturnValue({ ...input, maxResults: 50 }); mockedSearchIssues.mockResolvedValue(mockJiraSearchResult); mockedFormatSearchResultsResponse.mockReturnValue({ content: [] }); await handleSearchIssues(input); expect(mockedValidateInput).toHaveBeenCalledWith( expect.objectContaining({ _def: expect.objectContaining({ typeName: 'ZodObject', }), }), input ); }); it('should handle validation errors for missing JQL', async () => { const input = {}; // missing jql const validationError = new Error('Validation failed: jql is required'); const mockErrorResponse = { content: [{ type: 'text', text: 'Validation error' }] }; mockedValidateInput.mockImplementation(() => { throw validationError; }); mockedHandleError.mockReturnValue(mockErrorResponse); const result = await handleSearchIssues(input); expect(mockedHandleError).toHaveBeenCalledWith(validationError); expect(result).toEqual(mockErrorResponse); }); it('should handle validation errors for invalid nextPageToken', async () => { const input = { jql: 'project = TEST', nextPageToken: 123 }; const validationError = new Error('Validation failed: nextPageToken must be string'); const mockErrorResponse = { content: [{ type: 'text', text: 'Validation error' }] }; mockedValidateInput.mockImplementation(() => { throw validationError; }); mockedHandleError.mockReturnValue(mockErrorResponse); const result = await handleSearchIssues(input); expect(mockedHandleError).toHaveBeenCalledWith(validationError); expect(result).toEqual(mockErrorResponse); }); it('should handle validation errors for invalid maxResults', async () => { const input = { jql: 'project = TEST', maxResults: 101 }; const validationError = new Error( 'Validation failed: maxResults must be between 1 and 100' ); const mockErrorResponse = { content: [{ type: 'text', text: 'Validation error' }] }; mockedValidateInput.mockImplementation(() => { throw validationError; }); mockedHandleError.mockReturnValue(mockErrorResponse); const result = await handleSearchIssues(input); expect(mockedHandleError).toHaveBeenCalledWith(validationError); expect(result).toEqual(mockErrorResponse); }); it('should handle validation errors for empty JQL string', async () => { const input = { jql: '' }; const validationError = new Error('Validation failed: jql cannot be empty'); const mockErrorResponse = { content: [{ type: 'text', text: 'Validation error' }] }; mockedValidateInput.mockImplementation(() => { throw validationError; }); mockedHandleError.mockReturnValue(mockErrorResponse); const result = await handleSearchIssues(input); expect(mockedHandleError).toHaveBeenCalledWith(validationError); expect(result).toEqual(mockErrorResponse); }); it('should handle validation errors for invalid field types', async () => { const input = { jql: 'project = TEST', nextPageToken: 123, fields: 'not-an-array', }; const validationError = new Error( 'Validation failed: nextPageToken must be string, fields must be array' ); const mockErrorResponse = { content: [{ type: 'text', text: 'Validation error' }] }; mockedValidateInput.mockImplementation(() => { throw validationError; }); mockedHandleError.mockReturnValue(mockErrorResponse); const result = await handleSearchIssues(input); expect(mockedHandleError).toHaveBeenCalledWith(validationError); expect(result).toEqual(mockErrorResponse); }); }); describe('Error Handling', () => { it('should handle API errors from searchIssues', async () => { const input = { jql: 'project = TEST' }; const apiError = { response: { status: 400, data: { errorMessages: ['Invalid JQL syntax'] }, }, }; const mockErrorResponse = { content: [{ type: 'text', text: 'API error' }] }; mockedValidateInput.mockReturnValue({ ...input, maxResults: 50 }); mockedSearchIssues.mockRejectedValue(apiError); mockedHandleError.mockReturnValue(mockErrorResponse); const result = await handleSearchIssues(input); expect(mockedHandleError).toHaveBeenCalledWith(apiError); expect(result).toEqual(mockErrorResponse); }); it('should handle authentication errors', async () => { const input = { jql: 'project = TEST' }; const mockErrorResponse = { content: [{ type: 'text', text: 'Auth error' }] }; mockedValidateInput.mockReturnValue({ ...input, maxResults: 50 }); mockedSearchIssues.mockRejectedValue(mockUnauthorizedError); mockedHandleError.mockReturnValue(mockErrorResponse); const result = await handleSearchIssues(input); expect(mockedHandleError).toHaveBeenCalledWith(mockUnauthorizedError); expect(result).toEqual(mockErrorResponse); }); it('should handle permission errors', async () => { const input = { jql: 'project = PRIVATE' }; const permissionError = { response: { status: 403, data: { errorMessages: ['Insufficient permissions to search this project'] }, }, }; const mockErrorResponse = { content: [{ type: 'text', text: 'Permission error' }] }; mockedValidateInput.mockReturnValue({ ...input, maxResults: 50 }); mockedSearchIssues.mockRejectedValue(permissionError); mockedHandleError.mockReturnValue(mockErrorResponse); const result = await handleSearchIssues(input); expect(mockedHandleError).toHaveBeenCalledWith(permissionError); expect(result).toEqual(mockErrorResponse); }); it('should handle invalid JQL syntax errors', async () => { const input = { jql: 'invalid jql syntax here' }; const jqlError = { response: { status: 400, data: { errorMessages: [ "Error in the JQL Query: The character 'h' is a reserved word and cannot be used in field names", ], }, }, }; const mockErrorResponse = { content: [{ type: 'text', text: 'JQL error' }] }; mockedValidateInput.mockReturnValue({ ...input, maxResults: 50 }); mockedSearchIssues.mockRejectedValue(jqlError); mockedHandleError.mockReturnValue(mockErrorResponse); const result = await handleSearchIssues(input); expect(mockedHandleError).toHaveBeenCalledWith(jqlError); expect(result).toEqual(mockErrorResponse); }); it('should handle network errors', async () => { const input = { jql: 'project = TEST' }; const networkError = new Error('Network Error'); networkError.code = 'ECONNREFUSED'; const mockErrorResponse = { content: [{ type: 'text', text: 'Network error' }] }; mockedValidateInput.mockReturnValue({ ...input, maxResults: 50 }); mockedSearchIssues.mockRejectedValue(networkError); mockedHandleError.mockReturnValue(mockErrorResponse); const result = await handleSearchIssues(input); expect(mockedHandleError).toHaveBeenCalledWith(networkError); expect(result).toEqual(mockErrorResponse); }); it('should handle timeout errors', async () => { const input = { jql: 'project = TEST' }; const timeoutError = new Error('Request timeout'); timeoutError.code = 'ECONNABORTED'; const mockErrorResponse = { content: [{ type: 'text', text: 'Timeout error' }] }; mockedValidateInput.mockReturnValue({ ...input, maxResults: 50 }); mockedSearchIssues.mockRejectedValue(timeoutError); mockedHandleError.mockReturnValue(mockErrorResponse); const result = await handleSearchIssues(input); expect(mockedHandleError).toHaveBeenCalledWith(timeoutError); expect(result).toEqual(mockErrorResponse); }); it('should handle rate limit errors', async () => { const input = { jql: 'project = TEST' }; const rateLimitError = { response: { status: 429, data: { errorMessages: ['Rate limit exceeded'] }, }, }; const mockErrorResponse = { content: [{ type: 'text', text: 'Rate limit error' }] }; mockedValidateInput.mockReturnValue({ ...input, maxResults: 50 }); mockedSearchIssues.mockRejectedValue(rateLimitError); mockedHandleError.mockReturnValue(mockErrorResponse); const result = await handleSearchIssues(input); expect(mockedHandleError).toHaveBeenCalledWith(rateLimitError); expect(result).toEqual(mockErrorResponse); }); }); describe('Edge Cases', () => { it('should handle very complex JQL with multiple conditions', async () => { const complexJql = ` project in (TEST, DEMO, PROD) AND status in ("To Do", "In Progress", "Code Review", "Testing", "Done") AND priority in (Blocker, Critical, High) AND assignee in membersOf("jira-developers") AND reporter in membersOf("product-owners") AND created >= -30d AND updated >= -7d AND labels in (urgent, critical, bug, feature) AND component in ("Frontend", "Backend", "Database", "API") AND fixVersion in ("v1.0", "v1.1", "v2.0") ORDER BY priority DESC, created ASC `; const input = { jql: complexJql }; mockedValidateInput.mockReturnValue({ ...input, maxResults: 50 }); mockedSearchIssues.mockResolvedValue(mockJiraSearchResult); mockedFormatSearchResultsResponse.mockReturnValue({ content: [] }); await handleSearchIssues(input); expect(mockedSearchIssues).toHaveBeenCalledWith({ jql: complexJql, maxResults: 50, }); }); it('should handle JQL with special characters and quotes', async () => { const specialJql = 'summary ~ "test\'s \\"quoted\\" string" AND description ~ "Line 1\\nLine 2\\tTabbed"'; const input = { jql: specialJql }; mockedValidateInput.mockReturnValue({ ...input, maxResults: 50 }); mockedSearchIssues.mockResolvedValue(mockJiraSearchResult); mockedFormatSearchResultsResponse.mockReturnValue({ content: [] }); await handleSearchIssues(input); expect(mockedSearchIssues).toHaveBeenCalledWith({ jql: specialJql, maxResults: 50, }); }); it('should handle large field and expand arrays', async () => { const input = { jql: 'project = TEST', fields: Array(50) .fill('field') .map((f, i) => `${f}${i}`), expand: Array(20) .fill('expand') .map((e, i) => `${e}${i}`), }; mockedValidateInput.mockReturnValue({ ...input, maxResults: 50 }); mockedSearchIssues.mockResolvedValue(mockJiraSearchResult); mockedFormatSearchResultsResponse.mockReturnValue({ content: [] }); await handleSearchIssues(input); expect(mockedSearchIssues).toHaveBeenCalledWith({ jql: 'project = TEST', maxResults: 50, fields: input.fields, expand: input.expand, }); }); it('should handle boundary values for pagination', async () => { const input = { jql: 'project = TEST', maxResults: 1, }; mockedValidateInput.mockReturnValue(input); mockedSearchIssues.mockResolvedValue(mockJiraSearchResult); mockedFormatSearchResultsResponse.mockReturnValue({ content: [] }); await handleSearchIssues(input); expect(mockedSearchIssues).toHaveBeenCalledWith(input); }); it('should handle maximum pagination values with nextPageToken', async () => { const input = { jql: 'project = TEST', nextPageToken: 'very-long-token-' + 'x'.repeat(200), maxResults: 100, }; mockedValidateInput.mockReturnValue(input); mockedSearchIssues.mockResolvedValue(mockJiraSearchResult); mockedFormatSearchResultsResponse.mockReturnValue({ content: [] }); await handleSearchIssues(input); expect(mockedSearchIssues).toHaveBeenCalledWith(input); }); it('should handle null input', async () => { const validationError = new Error('Input cannot be null'); const mockErrorResponse = { content: [{ type: 'text', text: 'Validation error' }] }; mockedValidateInput.mockImplementation(() => { throw validationError; }); mockedHandleError.mockReturnValue(mockErrorResponse); const result = await handleSearchIssues(null); expect(result).toEqual(mockErrorResponse); }); it('should handle empty search results', async () => { const input = { jql: 'project = NONEXISTENT' }; const emptyResults = { ...mockJiraSearchResult, total: 0, issues: [] }; mockedValidateInput.mockReturnValue({ ...input, maxResults: 50 }); mockedSearchIssues.mockResolvedValue(emptyResults); mockedFormatSearchResultsResponse.mockReturnValue({ content: [{ type: 'text', text: 'No issues found' }], }); const result = await handleSearchIssues(input); expect(mockedFormatSearchResultsResponse).toHaveBeenCalledWith(emptyResults); expect(result.content[0].text).toBe('No issues found'); }); it('should handle JQL functions and operators', async () => { const functionalJql = ` assignee = currentUser() AND reporter in membersOf("jira-users") AND duedate >= startOfWeek() AND created >= startOfMonth(-1) AND worklogDate >= now("-1w") `; const input = { jql: functionalJql }; mockedValidateInput.mockReturnValue({ ...input, maxResults: 50 }); mockedSearchIssues.mockResolvedValue(mockJiraSearchResult); mockedFormatSearchResultsResponse.mockReturnValue({ content: [] }); await handleSearchIssues(input); expect(mockedSearchIssues).toHaveBeenCalledWith({ jql: functionalJql, maxResults: 50, }); }); }); }); });

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/freema/mcp-jira-stdio'

If you have feedback or need assistance with the MCP directory API, please join our Discord server