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,
        });
      });
    });
  });
});