create-issue.test.ts•18.4 kB
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { handleCreateIssue, createIssueTool } from '../../../src/tools/create-issue.js';
import { validateInput } from '../../../src/utils/validators.js';
import { createIssue } from '../../../src/utils/api-helpers.js';
import { formatIssueResponse } from '../../../src/utils/formatters.js';
import { handleError } from '../../../src/utils/error-handler.js';
import { mockJiraIssue, mockJiraValidationErrorResponse } 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 mockedCreateIssue = vi.mocked(createIssue);
const mockedFormatIssueResponse = vi.mocked(formatIssueResponse);
const mockedHandleError = vi.mocked(handleError);
describe('create-issue tool', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });
  describe('createIssueTool configuration', () => {
    it('should have correct tool configuration', () => {
      expect(createIssueTool.name).toBe(TOOL_NAMES.CREATE_ISSUE);
      expect(createIssueTool.description).toContain('Creates a new Jira issue');
      expect(createIssueTool.inputSchema.type).toBe('object');
      expect(createIssueTool.inputSchema.required).toEqual(['projectKey', 'summary', 'issueType']);
      // Check required fields
      expect(createIssueTool.inputSchema.properties.projectKey).toBeDefined();
      expect(createIssueTool.inputSchema.properties.summary).toBeDefined();
      expect(createIssueTool.inputSchema.properties.issueType).toBeDefined();
      // Check optional fields
      expect(createIssueTool.inputSchema.properties.description).toBeDefined();
      expect(createIssueTool.inputSchema.properties.priority).toBeDefined();
      expect(createIssueTool.inputSchema.properties.assignee).toBeDefined();
      expect(createIssueTool.inputSchema.properties.labels).toBeDefined();
      expect(createIssueTool.inputSchema.properties.components).toBeDefined();
    });
  });
  describe('handleCreateIssue', () => {
    describe('Success Cases', () => {
      it('should create issue with required fields only', async () => {
        const input = {
          projectKey: 'TEST',
          summary: 'New bug report',
          issueType: 'Bug',
        };
        const validatedInput = { ...input };
        const mockResponse = { content: [{ type: 'text', text: 'formatted issue' }] };
        mockedValidateInput.mockReturnValue(validatedInput);
        mockedCreateIssue.mockResolvedValue(mockJiraIssue);
        mockedFormatIssueResponse.mockReturnValue(mockResponse);
        const result = await handleCreateIssue(input);
        expect(mockedValidateInput).toHaveBeenCalledWith(expect.any(Object), input);
        expect(mockedCreateIssue).toHaveBeenCalledWith({
          projectKey: 'TEST',
          summary: 'New bug report',
          issueType: 'Bug',
        });
        expect(mockedFormatIssueResponse).toHaveBeenCalledWith(mockJiraIssue);
        expect(result).toEqual(mockResponse);
      });
      it('should create issue with all optional fields', async () => {
        const input = {
          projectKey: 'TEST',
          summary: 'New bug report',
          description: 'Detailed description',
          issueType: 'Bug',
          priority: 'High',
          assignee: 'user-123',
          labels: ['urgent', 'bug'],
          components: ['Frontend', 'Backend'],
        };
        const validatedInput = { ...input };
        mockedValidateInput.mockReturnValue(validatedInput);
        mockedCreateIssue.mockResolvedValue(mockJiraIssue);
        mockedFormatIssueResponse.mockReturnValue({ content: [] });
        await handleCreateIssue(input);
        expect(mockedCreateIssue).toHaveBeenCalledWith({
          projectKey: 'TEST',
          summary: 'New bug report',
          description: 'Detailed description',
          issueType: 'Bug',
          priority: 'High',
          assignee: 'user-123',
          labels: ['urgent', 'bug'],
          components: ['Frontend', 'Backend'],
        });
      });
      it('should handle partial optional fields', async () => {
        const input = {
          projectKey: 'TEST',
          summary: 'New bug report',
          issueType: 'Bug',
          priority: 'High',
          labels: ['bug'],
        };
        const validatedInput = { ...input };
        mockedValidateInput.mockReturnValue(validatedInput);
        mockedCreateIssue.mockResolvedValue(mockJiraIssue);
        mockedFormatIssueResponse.mockReturnValue({ content: [] });
        await handleCreateIssue(input);
        expect(mockedCreateIssue).toHaveBeenCalledWith({
          projectKey: 'TEST',
          summary: 'New bug report',
          issueType: 'Bug',
          priority: 'High',
          labels: ['bug'],
        });
      });
      it('should forward customFields to API helper', async () => {
        const input = {
          projectKey: 'TEST',
          summary: 'New task',
          issueType: 'Task',
          customFields: {
            customfield_10071: { id: '20010' },
          },
        } as any;
        const validatedInput = { ...input };
        mockedValidateInput.mockReturnValue(validatedInput);
        mockedCreateIssue.mockResolvedValue(mockJiraIssue);
        mockedFormatIssueResponse.mockReturnValue({ content: [] });
        await handleCreateIssue(input);
        expect(mockedCreateIssue).toHaveBeenCalledWith({
          projectKey: 'TEST',
          summary: 'New task',
          issueType: 'Task',
          customFields: { customfield_10071: { id: '20010' } },
        });
      });
      it('should handle empty arrays for labels and components', async () => {
        const input = {
          projectKey: 'TEST',
          summary: 'New bug report',
          issueType: 'Bug',
          labels: [],
          components: [],
        };
        const validatedInput = { ...input };
        mockedValidateInput.mockReturnValue(validatedInput);
        mockedCreateIssue.mockResolvedValue(mockJiraIssue);
        mockedFormatIssueResponse.mockReturnValue({ content: [] });
        await handleCreateIssue(input);
        expect(mockedCreateIssue).toHaveBeenCalledWith({
          projectKey: 'TEST',
          summary: 'New bug report',
          issueType: 'Bug',
          labels: [],
          components: [],
        });
      });
      it('should handle undefined optional fields gracefully', async () => {
        const validatedInput = {
          projectKey: 'TEST',
          summary: 'New bug report',
          issueType: 'Bug',
          description: undefined,
          priority: undefined,
          assignee: undefined,
          labels: undefined,
          components: undefined,
        };
        mockedValidateInput.mockReturnValue(validatedInput);
        mockedCreateIssue.mockResolvedValue(mockJiraIssue);
        mockedFormatIssueResponse.mockReturnValue({ content: [] });
        await handleCreateIssue({});
        const expectedParams = {
          projectKey: 'TEST',
          summary: 'New bug report',
          issueType: 'Bug',
        };
        expect(mockedCreateIssue).toHaveBeenCalledWith(expectedParams);
      });
    });
    describe('Validation', () => {
      it('should validate input using schema', async () => {
        const input = {
          projectKey: 'TEST',
          summary: 'New bug report',
          issueType: 'Bug',
        };
        mockedValidateInput.mockReturnValue(input);
        mockedCreateIssue.mockResolvedValue(mockJiraIssue);
        mockedFormatIssueResponse.mockReturnValue({ content: [] });
        await handleCreateIssue(input);
        expect(mockedValidateInput).toHaveBeenCalledWith(
          expect.objectContaining({
            _def: expect.objectContaining({
              typeName: 'ZodObject',
            }),
          }),
          input
        );
      });
      it('should handle validation errors for missing required fields', async () => {
        const input = { projectKey: 'TEST' }; // missing summary and issueType
        const validationError = new Error(
          'Validation failed: summary is required, issueType is required'
        );
        const mockErrorResponse = { content: [{ type: 'text', text: 'Validation error' }] };
        mockedValidateInput.mockImplementation(() => {
          throw validationError;
        });
        mockedHandleError.mockReturnValue(mockErrorResponse);
        const result = await handleCreateIssue(input);
        expect(mockedHandleError).toHaveBeenCalledWith(validationError);
        expect(result).toEqual(mockErrorResponse);
      });
      it('should handle validation errors for invalid field types', async () => {
        const input = {
          projectKey: 'TEST',
          summary: 123, // should be string
          issueType: 'Bug',
        };
        const validationError = new Error('Validation failed: summary must be string');
        const mockErrorResponse = { content: [{ type: 'text', text: 'Validation error' }] };
        mockedValidateInput.mockImplementation(() => {
          throw validationError;
        });
        mockedHandleError.mockReturnValue(mockErrorResponse);
        const result = await handleCreateIssue(input);
        expect(mockedHandleError).toHaveBeenCalledWith(validationError);
        expect(result).toEqual(mockErrorResponse);
      });
      it('should handle validation errors for empty summary', async () => {
        const input = {
          projectKey: 'TEST',
          summary: '', // empty string
          issueType: 'Bug',
        };
        const validationError = new Error(
          'Validation failed: summary must be at least 1 character'
        );
        const mockErrorResponse = { content: [{ type: 'text', text: 'Validation error' }] };
        mockedValidateInput.mockImplementation(() => {
          throw validationError;
        });
        mockedHandleError.mockReturnValue(mockErrorResponse);
        const result = await handleCreateIssue(input);
        expect(mockedHandleError).toHaveBeenCalledWith(validationError);
        expect(result).toEqual(mockErrorResponse);
      });
    });
    describe('Error Handling', () => {
      it('should handle API errors from createIssue', async () => {
        const input = {
          projectKey: 'TEST',
          summary: 'New bug report',
          issueType: 'Bug',
        };
        const apiError = {
          response: {
            status: 400,
            data: mockJiraValidationErrorResponse,
          },
        };
        const mockErrorResponse = { content: [{ type: 'text', text: 'API error' }] };
        mockedValidateInput.mockReturnValue(input);
        mockedCreateIssue.mockRejectedValue(apiError);
        mockedHandleError.mockReturnValue(mockErrorResponse);
        const result = await handleCreateIssue(input);
        expect(mockedHandleError).toHaveBeenCalledWith(apiError);
        expect(result).toEqual(mockErrorResponse);
      });
      it('should handle authentication errors', async () => {
        const input = {
          projectKey: 'TEST',
          summary: 'New bug report',
          issueType: 'Bug',
        };
        const authError = { response: { status: 401, data: { errorMessages: ['Unauthorized'] } } };
        const mockErrorResponse = { content: [{ type: 'text', text: 'Auth error' }] };
        mockedValidateInput.mockReturnValue(input);
        mockedCreateIssue.mockRejectedValue(authError);
        mockedHandleError.mockReturnValue(mockErrorResponse);
        const result = await handleCreateIssue(input);
        expect(mockedHandleError).toHaveBeenCalledWith(authError);
        expect(result).toEqual(mockErrorResponse);
      });
      it('should handle permission errors', async () => {
        const input = {
          projectKey: 'TEST',
          summary: 'New bug report',
          issueType: 'Bug',
        };
        const permissionError = {
          response: {
            status: 403,
            data: { errorMessages: ['Insufficient permissions to create issues'] },
          },
        };
        const mockErrorResponse = { content: [{ type: 'text', text: 'Permission error' }] };
        mockedValidateInput.mockReturnValue(input);
        mockedCreateIssue.mockRejectedValue(permissionError);
        mockedHandleError.mockReturnValue(mockErrorResponse);
        const result = await handleCreateIssue(input);
        expect(mockedHandleError).toHaveBeenCalledWith(permissionError);
        expect(result).toEqual(mockErrorResponse);
      });
      it('should handle project not found errors', async () => {
        const input = {
          projectKey: 'NONEXISTENT',
          summary: 'New bug report',
          issueType: 'Bug',
        };
        const notFoundError = {
          response: {
            status: 404,
            data: { errorMessages: ['Project not found'] },
          },
        };
        const mockErrorResponse = { content: [{ type: 'text', text: 'Not found error' }] };
        mockedValidateInput.mockReturnValue(input);
        mockedCreateIssue.mockRejectedValue(notFoundError);
        mockedHandleError.mockReturnValue(mockErrorResponse);
        const result = await handleCreateIssue(input);
        expect(mockedHandleError).toHaveBeenCalledWith(notFoundError);
        expect(result).toEqual(mockErrorResponse);
      });
      it('should handle network errors', async () => {
        const input = {
          projectKey: 'TEST',
          summary: 'New bug report',
          issueType: 'Bug',
        };
        const networkError = new Error('Network Error');
        networkError.code = 'ECONNREFUSED';
        const mockErrorResponse = { content: [{ type: 'text', text: 'Network error' }] };
        mockedValidateInput.mockReturnValue(input);
        mockedCreateIssue.mockRejectedValue(networkError);
        mockedHandleError.mockReturnValue(mockErrorResponse);
        const result = await handleCreateIssue(input);
        expect(mockedHandleError).toHaveBeenCalledWith(networkError);
        expect(result).toEqual(mockErrorResponse);
      });
      it('should handle rate limit errors', async () => {
        const input = {
          projectKey: 'TEST',
          summary: 'New bug report',
          issueType: 'Bug',
        };
        const rateLimitError = {
          response: {
            status: 429,
            data: { errorMessages: ['Rate limit exceeded'] },
          },
        };
        const mockErrorResponse = { content: [{ type: 'text', text: 'Rate limit error' }] };
        mockedValidateInput.mockReturnValue(input);
        mockedCreateIssue.mockRejectedValue(rateLimitError);
        mockedHandleError.mockReturnValue(mockErrorResponse);
        const result = await handleCreateIssue(input);
        expect(mockedHandleError).toHaveBeenCalledWith(rateLimitError);
        expect(result).toEqual(mockErrorResponse);
      });
    });
    describe('Edge Cases', () => {
      it('should handle very long summary', async () => {
        const longSummary = 'A'.repeat(1000);
        const input = {
          projectKey: 'TEST',
          summary: longSummary,
          issueType: 'Bug',
        };
        mockedValidateInput.mockReturnValue(input);
        mockedCreateIssue.mockResolvedValue(mockJiraIssue);
        mockedFormatIssueResponse.mockReturnValue({ content: [] });
        await handleCreateIssue(input);
        expect(mockedCreateIssue).toHaveBeenCalledWith({
          projectKey: 'TEST',
          summary: longSummary,
          issueType: 'Bug',
        });
      });
      it('should handle special characters in fields', async () => {
        const input = {
          projectKey: 'TEST',
          summary: 'Bug with special chars: äöü@#$%&',
          description: 'Description with\nnewlines\tand\ttabs',
          issueType: 'Bug',
        };
        mockedValidateInput.mockReturnValue(input);
        mockedCreateIssue.mockResolvedValue(mockJiraIssue);
        mockedFormatIssueResponse.mockReturnValue({ content: [] });
        await handleCreateIssue(input);
        expect(mockedCreateIssue).toHaveBeenCalledWith({
          projectKey: 'TEST',
          summary: 'Bug with special chars: äöü@#$%&',
          description: 'Description with\nnewlines\tand\ttabs',
          issueType: 'Bug',
        });
      });
      it('should handle large arrays of labels and components', async () => {
        const input = {
          projectKey: 'TEST',
          summary: 'Bug with many labels',
          issueType: 'Bug',
          labels: Array(50)
            .fill('label')
            .map((l, i) => `${l}-${i}`),
          components: Array(20)
            .fill('component')
            .map((c, i) => `${c}-${i}`),
        };
        mockedValidateInput.mockReturnValue(input);
        mockedCreateIssue.mockResolvedValue(mockJiraIssue);
        mockedFormatIssueResponse.mockReturnValue({ content: [] });
        await handleCreateIssue(input);
        expect(mockedCreateIssue).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 handleCreateIssue(null);
        expect(result).toEqual(mockErrorResponse);
      });
      it('should handle empty input object', async () => {
        const input = {};
        const validationError = new Error('Required fields missing');
        const mockErrorResponse = { content: [{ type: 'text', text: 'Validation error' }] };
        mockedValidateInput.mockImplementation(() => {
          throw validationError;
        });
        mockedHandleError.mockReturnValue(mockErrorResponse);
        const result = await handleCreateIssue(input);
        expect(result).toEqual(mockErrorResponse);
      });
    });
  });
});