MCP Server

import { describe, test, expect, beforeEach, mock } from 'bun:test'; import { LinearAPIService, LinearClientInterface } from '../linear-api'; import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; describe('LinearAPIService', () => { let service: LinearAPIService; let mockClient: LinearClientInterface; let issueFn: ReturnType<typeof mock>; let issuesFn: ReturnType<typeof mock>; let createIssueFn: ReturnType<typeof mock>; let teamsFn: ReturnType<typeof mock>; let viewerFn: ReturnType<typeof mock>; beforeEach(() => { // Setup mocks issueFn = mock(() => Promise.resolve(null)); issuesFn = mock(() => Promise.resolve({ nodes: [] })); createIssueFn = mock(() => Promise.resolve({ success: true, issue: null })); teamsFn = mock(() => Promise.resolve({ nodes: [] })); // Create a properly typed mock viewer that matches Linear SDK's User type const mockViewer = { id: 'current-user', name: 'Current User', email: 'current@example.com', active: true, admin: false, avatarUrl: undefined, avatarBackgroundColor: '#000000', createdAt: new Date('2025-01-24'), displayName: 'Current User', inviteHash: 'invite-hash', lastSeen: new Date('2025-01-24'), organization: Promise.resolve({ id: 'org-1', name: 'Test Organization', urlKey: 'test-org', createdAt: new Date('2025-01-24'), updatedAt: new Date('2025-01-24'), initiativeUpdateRemindersDay: 1, projectUpdateRemindersDay: 1, projectUpdatesReminderFrequency: 'weekly', releaseChannel: 'stable', gitBranchFormat: '', periodUploadVolume: 0, trialEndsAt: null, userCount: 1, createdIssueCount: 0, logo: undefined, periodStartDate: new Date('2025-01-24'), subscription: undefined, samlEnabled: false, scimEnabled: false, deletionRequestedAt: undefined, roadmapEnabled: false, issueVoteEnabled: false, roadmapAccessEnabled: false, customersConfiguration: undefined, customersEnabled: false, fiscalYearStartMonth: 1, gitLinkbackMessagesEnabled: false, gitPublicLinkbackMessagesEnabled: false, integrationsSettings: {}, linearPreviewEnabled: false, projectUpdateRemindersEnabled: false, projectUpdatesEnabled: false, publicSignupEnabled: false, releasesEnabled: false, requireProjectLead: false, requireStateCategory: false, slackAsksEnabled: false, slackEnabled: false, slackIssueCreatedEnabled: false, slackIssueNewEnabled: false, slackIssueStatusChangedEnabled: false, slackIssueStatusDoneEnabled: false, slackIssueStatusInProgressEnabled: false, slackIssueStatusTodoEnabled: false, slackProjectUpdateEnabled: false, slackRequestEnabled: false, initiativeUpdateRemindersHour: 9, previousUrlKeys: [], projectUpdateRemindersHour: 9, projectStatuses: [], projectTeamIds: [], projectTemplateIds: [], projectUpdateTemplates: [], projectUpdatesRemindersDayOfWeek: 1, projectUpdatesRemindersEnabled: false, projectUpdatesRemindersHour: 9, projectUpdatesRemindersPeriod: 'weekly', projectUpdatesRemindersTimezone: 'UTC', webhookUrl: undefined, _request: () => Promise.resolve({}), paginate: () => Promise.resolve({ nodes: [] }) }), organizationId: 'org-1', status: Promise.resolve({ id: 'status-1', label: 'Available', type: 'available', emoji: '✅', createdAt: new Date('2025-01-24'), updatedAt: new Date('2025-01-24'), _request: () => Promise.resolve({}), paginate: () => Promise.resolve({ nodes: [] }) }), statusLabel: '', timezone: 'UTC', updatedAt: new Date('2025-01-24'), url: 'https://linear.app/user/current-user', createdIssueCount: 0, guest: false, initials: 'CU', isMe: true, description: undefined, calendarHash: undefined, disableReason: undefined, archivedAt: undefined, markedAsDuplicateAt: undefined, dueDate: undefined, estimate: undefined, identifier: 'USER-1', autoArchivedAt: undefined, canceledAt: undefined, completedAt: undefined, customerTicketCount: 0, assignedIssuesCount: 0, issueCreatedCount: 0, issueClosedCount: 0, allowAttachments: true, allowExternalUserInvites: true, allowGitHubIntegration: true, allowSlackIntegration: true, allowSsoLogin: true, archivedIssuesCount: 0, openIssuesCount: 0, totalIssuesCount: 0, totalTimeSpent: 0, settings: {}, // Add required LinearFetch properties assignedIssues: Promise.resolve({ nodes: [] }), createdIssues: Promise.resolve({ nodes: [] }), drafts: Promise.resolve({ nodes: [] }), teamMemberships: Promise.resolve({ nodes: [] }), teams: Promise.resolve({ nodes: [] }), workflowStates: Promise.resolve({ nodes: [] }), projectMemberships: Promise.resolve({ nodes: [] }), favoriteProjects: Promise.resolve({ nodes: [] }), favoriteIssues: Promise.resolve({ nodes: [] }), favoriteDocuments: Promise.resolve({ nodes: [] }), suspend: () => Promise.resolve(true), unsuspend: () => Promise.resolve(true), update: () => Promise.resolve(true), _request: () => Promise.resolve({}), paginate: () => Promise.resolve({ nodes: [] }) }; // Create mock client with properly typed mocks, using 'as any' for Linear SDK compatibility mockClient = { issue: issueFn as any, issues: issuesFn as any, createIssue: createIssueFn as any, teams: teamsFn as any, createComment: mock(() => Promise.resolve({ success: true, comment: null, _comment: null, lastSyncId: '1', _request: () => Promise.resolve({}), paginate: () => Promise.resolve({ nodes: [] }) })) as any, viewer: Promise.resolve(mockViewer) as any, deleteIssue: mock(() => Promise.resolve()) as any }; // Create service instance with mock client service = new LinearAPIService(mockClient); }); describe('constructor', () => { test('throws error when API key is missing', () => { expect(() => new LinearAPIService('')).toThrow('LINEAR_API_KEY is required'); }); test('creates instance with valid API key', () => { const serviceWithKey = new LinearAPIService('test-api-key'); expect(serviceWithKey).toBeInstanceOf(LinearAPIService); expect(serviceWithKey).toBeDefined(); }); test('creates instance with client interface', () => { expect(service).toBeInstanceOf(LinearAPIService); expect(service).toBeDefined(); }); }); describe('getCurrentUser', () => { test('returns current user information', async () => { const result = await (service as any).getCurrentUser(); expect(result).toEqual({ id: 'current-user', name: 'Current User', email: 'current@example.com' }); }); }); describe('createIssue', () => { test('creates issue with required fields', async () => { const mockCreatedIssue = { id: 'issue-1', identifier: 'TEST-1', title: 'Test Issue', description: 'Test Description', priority: 1, createdAt: new Date('2025-01-24'), updatedAt: new Date('2025-01-24'), state: Promise.resolve({ name: 'Backlog' }), assignee: Promise.resolve(undefined), team: Promise.resolve({ name: 'Engineering' }), creator: Promise.resolve({ name: 'John Doe' }), labels: () => Promise.resolve({ nodes: [] }), parent: Promise.resolve(undefined), children: () => Promise.resolve({ nodes: [] }), relations: () => Promise.resolve({ nodes: [] }), }; createIssueFn.mockImplementation(async () => ({ success: true, issue: mockCreatedIssue })); issueFn.mockImplementation(async () => mockCreatedIssue); const result = await service.createIssue({ teamId: 'team-1', title: 'Test Issue', description: 'Test Description' }); expect(createIssueFn).toHaveBeenCalledWith({ teamId: 'team-1', title: 'Test Issue', description: 'Test Description', priority: undefined, assigneeId: undefined, parentId: undefined, labelIds: undefined }); expect(result).toEqual({ id: 'issue-1', identifier: 'TEST-1', title: 'Test Issue', description: 'Test Description', status: 'Backlog', assignee: undefined, priority: 1, createdAt: '2025-01-24T00:00:00.000Z', updatedAt: '2025-01-24T00:00:00.000Z', teamName: 'Engineering', creatorName: 'John Doe', labels: [], parent: undefined, subIssues: [], relationships: [], mentionedIssues: [], mentionedUsers: [], estimate: undefined, dueDate: undefined, comments: undefined }); }); test('creates issue with self-assignment', async () => { const mockCreatedIssue = { id: 'issue-1', identifier: 'TEST-1', title: 'Test Issue', description: 'Test Description', priority: 1, createdAt: new Date('2025-01-24'), updatedAt: new Date('2025-01-24'), state: Promise.resolve({ name: 'Backlog' }), assignee: Promise.resolve({ name: 'Current User' }), team: Promise.resolve({ name: 'Engineering' }), creator: Promise.resolve({ name: 'Current User' }), labels: () => Promise.resolve({ nodes: [] }), parent: Promise.resolve(undefined), children: () => Promise.resolve({ nodes: [] }), relations: () => Promise.resolve({ nodes: [] }), }; createIssueFn.mockImplementation(async () => ({ success: true, issue: mockCreatedIssue })); issueFn.mockImplementation(async () => mockCreatedIssue); await service.createIssue({ teamId: 'team-1', title: 'Test Issue', description: 'Test Description', assigneeId: 'me' }); expect(createIssueFn).toHaveBeenCalledWith({ teamId: 'team-1', title: 'Test Issue', description: 'Test Description', priority: undefined, assigneeId: 'current-user', parentId: undefined, labelIds: undefined }); }); test('creates subissue with parent', async () => { const mockParent = { id: 'parent-1', identifier: 'TEST-1', title: 'Parent Issue', team: Promise.resolve({ id: 'team-1', name: 'Engineering' }) }; const mockCreatedIssue = { id: 'issue-2', identifier: 'TEST-2', title: 'Child Issue', description: 'Test Description', priority: 1, createdAt: new Date('2025-01-24'), updatedAt: new Date('2025-01-24'), state: Promise.resolve({ name: 'Backlog' }), assignee: Promise.resolve(undefined), team: Promise.resolve({ name: 'Engineering' }), creator: Promise.resolve({ name: 'John Doe' }), labels: () => Promise.resolve({ nodes: [] }), parent: Promise.resolve(mockParent), children: () => Promise.resolve({ nodes: [] }), relations: () => Promise.resolve({ nodes: [] }), }; issueFn.mockImplementation(async (id) => { if (id === 'parent-1' || id === 'TEST-1') return mockParent; if (id === 'issue-2' || id === 'TEST-2') return mockCreatedIssue; return undefined; }); createIssueFn.mockImplementation(async () => ({ success: true, issue: mockCreatedIssue })); const result = await service.createIssue({ title: 'Child Issue', description: 'Test Description', parentId: 'TEST-1' }); expect(createIssueFn).toHaveBeenCalledWith({ teamId: 'team-1', title: 'Child Issue', description: 'Test Description', priority: undefined, assigneeId: undefined, parentId: 'TEST-1', labelIds: undefined }); expect(result).toEqual({ id: 'issue-2', identifier: 'TEST-2', title: 'Child Issue', description: 'Test Description', status: 'Backlog', assignee: undefined, priority: 1, createdAt: '2025-01-24T00:00:00.000Z', updatedAt: '2025-01-24T00:00:00.000Z', teamName: 'Engineering', creatorName: 'John Doe', labels: [], parent: { id: 'parent-1', identifier: 'TEST-1', title: 'Parent Issue' }, subIssues: [], relationships: [ { type: 'parent', issueId: 'parent-1', identifier: 'TEST-1', title: 'Parent Issue' } ], mentionedIssues: [], mentionedUsers: [], estimate: undefined, dueDate: undefined, comments: undefined }); }); test('throws error when parent issue not found', async () => { issueFn.mockImplementation(async () => undefined); await expect(service.createIssue({ title: 'Child Issue', parentId: 'NONEXISTENT' })).rejects.toThrow( new McpError(ErrorCode.InvalidRequest, 'Parent issue not found: NONEXISTENT') ); expect(createIssueFn).not.toHaveBeenCalled(); }); test('throws error when create issue fails', async () => { createIssueFn.mockImplementation(async () => ({ success: false, issue: undefined })); await expect(service.createIssue({ teamId: 'team-1', title: 'Test Issue' })).rejects.toThrow('MCP error -32603: Failed to create issue: No issue returned'); }); }); describe('updateIssue', () => { test('updates issue with all fields', async () => { const mockUpdatedIssue = { id: 'issue-1', identifier: 'TEST-1', title: 'Updated Title', description: 'Updated Description', priority: 2, createdAt: new Date('2025-01-24'), updatedAt: new Date('2025-01-24'), state: Promise.resolve({ name: 'In Progress' }), assignee: Promise.resolve({ name: 'Jane Smith' }), team: Promise.resolve({ name: 'Engineering' }), creator: Promise.resolve({ name: 'John Doe' }), labels: () => Promise.resolve({ nodes: [{ name: 'feature' }] }), parent: Promise.resolve(undefined), children: () => Promise.resolve({ nodes: [] }), relations: () => Promise.resolve({ nodes: [] }), update: mock(() => Promise.resolve()) }; issueFn.mockImplementation(async () => mockUpdatedIssue); const result = await service.updateIssue({ issueId: 'TEST-1', title: 'Updated Title', description: 'Updated Description', status: 'In Progress', priority: 2, assigneeId: 'user-2', labelIds: ['label-2'] }); expect(mockUpdatedIssue.update).toHaveBeenCalledWith({ title: 'Updated Title', description: 'Updated Description', status: 'In Progress', priority: 2, assigneeId: 'user-2', labelIds: ['label-2'] }); expect(result).toEqual({ id: 'issue-1', identifier: 'TEST-1', title: 'Updated Title', description: 'Updated Description', status: 'In Progress', assignee: 'Jane Smith', priority: 2, createdAt: '2025-01-24T00:00:00.000Z', updatedAt: '2025-01-24T00:00:00.000Z', teamName: 'Engineering', creatorName: 'John Doe', labels: ['feature'], parent: undefined, subIssues: [], relationships: [], mentionedIssues: [], mentionedUsers: [], estimate: undefined, dueDate: undefined, comments: undefined }); }); test('updates issue with self-assignment', async () => { const mockUpdatedIssue = { id: 'issue-1', identifier: 'TEST-1', title: 'Test Issue', description: 'Test Description', priority: 1, createdAt: new Date('2025-01-24'), updatedAt: new Date('2025-01-24'), state: Promise.resolve({ name: 'In Progress' }), assignee: Promise.resolve({ name: 'Current User' }), team: Promise.resolve({ name: 'Engineering' }), creator: Promise.resolve({ name: 'John Doe' }), labels: () => Promise.resolve({ nodes: [] }), parent: Promise.resolve(undefined), children: () => Promise.resolve({ nodes: [] }), relations: () => Promise.resolve({ nodes: [] }), update: mock(() => Promise.resolve()) }; issueFn.mockImplementation(async () => mockUpdatedIssue); await service.updateIssue({ issueId: 'TEST-1', assigneeId: 'me' }); expect(mockUpdatedIssue.update).toHaveBeenCalledWith({ assigneeId: 'current-user' }); }); test('updates issue with partial fields', async () => { const mockUpdatedIssue = { id: 'issue-1', identifier: 'TEST-1', title: 'Original Title', description: 'Updated Description', priority: 1, createdAt: new Date('2025-01-24'), updatedAt: new Date('2025-01-24'), state: Promise.resolve({ name: 'Backlog' }), assignee: Promise.resolve({ name: 'John Doe' }), team: Promise.resolve({ name: 'Engineering' }), creator: Promise.resolve({ name: 'John Doe' }), labels: () => Promise.resolve({ nodes: [] }), parent: Promise.resolve(undefined), children: () => Promise.resolve({ nodes: [] }), relations: () => Promise.resolve({ nodes: [] }), update: mock(() => Promise.resolve()) }; issueFn.mockImplementation(async () => mockUpdatedIssue); const result = await service.updateIssue({ issueId: 'TEST-1', description: 'Updated Description' }); expect(mockUpdatedIssue.update).toHaveBeenCalledWith({ description: 'Updated Description' }); expect(result).toEqual({ id: 'issue-1', identifier: 'TEST-1', title: 'Original Title', description: 'Updated Description', status: 'Backlog', assignee: 'John Doe', priority: 1, createdAt: '2025-01-24T00:00:00.000Z', updatedAt: '2025-01-24T00:00:00.000Z', teamName: 'Engineering', creatorName: 'John Doe', labels: [], parent: undefined, subIssues: [], relationships: [], mentionedIssues: [], mentionedUsers: [], estimate: undefined, dueDate: undefined, comments: undefined }); }); test('throws error when issue not found', async () => { issueFn.mockImplementation(async () => undefined); await expect(service.updateIssue({ issueId: 'NONEXISTENT', title: 'Updated Title' })).rejects.toThrow( 'MCP error -32603: Failed to update issue: MCP error -32600: Issue not found: NONEXISTENT' ); }); test('throws error when update fails', async () => { const mockIssue = { id: 'issue-1', update: mock(() => Promise.reject(new Error('Update failed'))) }; issueFn.mockImplementation(async () => mockIssue); await expect(service.updateIssue({ issueId: 'TEST-1', title: 'Updated Title' })).rejects.toThrow( new McpError(ErrorCode.InternalError, 'Failed to update issue: Update failed') ); }); }); describe('searchIssues', () => { test('returns formatted search results with enhanced metadata', async () => { const testIssues = { nodes: [ { id: 'issue-1', identifier: 'TEST-1', title: 'Test Issue 1', priority: 1, state: Promise.resolve({ name: 'In Progress' }), assignee: Promise.resolve({ name: 'John Doe' }), team: Promise.resolve({ name: 'Engineering' }), labels: () => Promise.resolve({ nodes: [{ name: 'bug' }] }), }, { id: 'issue-2', identifier: 'TEST-2', title: 'Test Issue 2', priority: 2, state: Promise.resolve({ name: 'Todo' }), assignee: Promise.resolve(undefined), team: Promise.resolve({ name: 'Design' }), labels: () => Promise.resolve({ nodes: [{ name: 'feature' }] }), }, ], }; issuesFn.mockImplementation(async () => testIssues); const results = await service.searchIssues({ query: 'test' }); expect(results).toEqual([ { id: 'issue-1', identifier: 'TEST-1', title: 'Test Issue 1', status: 'In Progress', assignee: 'John Doe', priority: 1, teamName: 'Engineering', labels: ['bug'], }, { id: 'issue-2', identifier: 'TEST-2', title: 'Test Issue 2', status: 'Todo', assignee: undefined, priority: 2, teamName: 'Design', labels: ['feature'], }, ]); expect(issuesFn).toHaveBeenCalledWith({ filter: { and: [{ or: [ { title: { contains: 'test' } }, { description: { contains: 'test' } }, ], }] } }); }); test('filters issues assigned to current user', async () => { const testIssues = { nodes: [{ id: 'issue-1', identifier: 'TEST-1', title: 'Test Issue', priority: 1, state: Promise.resolve({ name: 'In Progress' }), assignee: Promise.resolve({ name: 'Current User' }), team: Promise.resolve({ name: 'Engineering' }), labels: () => Promise.resolve({ nodes: [] }), }], }; issuesFn.mockImplementation(async () => testIssues); await service.searchIssues({ query: '', filter: { assignedTo: 'me' } }); expect(issuesFn).toHaveBeenCalledWith({ filter: { and: [{ assignee: { id: { eq: 'current-user' } } }] } }); }); test('filters issues created by current user', async () => { const testIssues = { nodes: [{ id: 'issue-1', identifier: 'TEST-1', title: 'Test Issue', priority: 1, state: Promise.resolve({ name: 'In Progress' }), assignee: Promise.resolve(undefined), team: Promise.resolve({ name: 'Engineering' }), labels: () => Promise.resolve({ nodes: [] }), }], }; issuesFn.mockImplementation(async () => testIssues); await service.searchIssues({ query: '', filter: { createdBy: 'me' } }); expect(issuesFn).toHaveBeenCalledWith({ filter: { and: [{ creator: { id: { eq: 'current-user' } } }] } }); }); test('combines search query with filters', async () => { const testIssues = { nodes: [{ id: 'issue-1', identifier: 'TEST-1', title: 'Test Issue', priority: 1, state: Promise.resolve({ name: 'In Progress' }), assignee: Promise.resolve({ name: 'Current User' }), team: Promise.resolve({ name: 'Engineering' }), labels: () => Promise.resolve({ nodes: [] }), }], }; issuesFn.mockImplementation(async () => testIssues); await service.searchIssues({ query: 'bug', filter: { assignedTo: 'me', createdBy: 'me' } }); expect(issuesFn).toHaveBeenCalledWith({ filter: { and: [ { or: [ { title: { contains: 'bug' } }, { description: { contains: 'bug' } }, ], }, { assignee: { id: { eq: 'current-user' } } }, { creator: { id: { eq: 'current-user' } } } ] } }); }); test('uses specific user ID for filtering', async () => { const testIssues = { nodes: [{ id: 'issue-1', identifier: 'TEST-1', title: 'Test Issue', priority: 1, state: Promise.resolve({ name: 'In Progress' }), assignee: Promise.resolve({ name: 'John Doe' }), team: Promise.resolve({ name: 'Engineering' }), labels: () => Promise.resolve({ nodes: [] }), }], }; issuesFn.mockImplementation(async () => testIssues); await service.searchIssues({ query: '', filter: { assignedTo: 'user-123', createdBy: 'user-456' } }); expect(issuesFn).toHaveBeenCalledWith({ filter: { and: [ { assignee: { id: { eq: 'user-123' } } }, { creator: { id: { eq: 'user-456' } } } ] } }); }); test('returns empty array when no results found', async () => { issuesFn.mockImplementation(async () => ({ nodes: [] })); const results = await service.searchIssues({ query: 'nonexistent' }); expect(results).toEqual([]); expect(issuesFn).toHaveBeenCalled(); }); test('handles API errors gracefully', async () => { issuesFn.mockImplementation(async () => { throw new Error('API error'); }); await expect(service.searchIssues({ query: 'test' })) .rejects.toThrow(new McpError(ErrorCode.InternalError, 'API error')); }); test('handles empty/invalid search queries', async () => { // Empty query should work await service.searchIssues({ query: '' }); expect(issuesFn).toHaveBeenCalledWith({ filter: undefined }); // Whitespace query should be trimmed await service.searchIssues({ query: ' ' }); expect(issuesFn).toHaveBeenCalledWith({ filter: undefined }); }); test('handles null/undefined filter values', async () => { const testIssues = { nodes: [{ id: 'issue-1', identifier: 'TEST-1', title: 'Test Issue', priority: 1, state: Promise.resolve(null), assignee: Promise.resolve(undefined), team: Promise.resolve(null), labels: () => Promise.resolve({ nodes: [] }) }] }; issuesFn.mockImplementation(async () => testIssues); const result = await service.searchIssues({ query: 'test', filter: { assignedTo: undefined, createdBy: undefined } }); expect(result).toEqual([{ id: 'issue-1', identifier: 'TEST-1', title: 'Test Issue', status: undefined, assignee: undefined, priority: 1, teamName: undefined, labels: [] }]); // Should not include undefined filters in the query expect(issuesFn).toHaveBeenCalledWith({ filter: { and: [{ or: [ { title: { contains: 'test' } }, { description: { contains: 'test' } }, ] }] } }); }); }); describe('getTeams', () => { test('handles empty teams list', async () => { teamsFn.mockImplementation(async () => ({ nodes: [] })); const result = await service.getTeams({}); expect(result).toEqual([]); }); test('returns all teams when no filter provided', async () => { const mockTeams = { nodes: [ { id: 'team-1', name: 'Engineering', key: 'ENG', description: 'Engineering team' }, { id: 'team-2', name: 'Design', key: 'DES', description: null } ] }; teamsFn.mockImplementation(async () => mockTeams); const result = await service.getTeams({}); expect(result).toEqual([ { id: 'team-1', name: 'Engineering', key: 'ENG', description: 'Engineering team' }, { id: 'team-2', name: 'Design', key: 'DES', description: undefined } ]); }); test('performs case-insensitive name filtering', async () => { const mockTeams = { nodes: [ { id: 'team-1', name: 'Engineering', key: 'ENG', description: 'Engineering team' }, { id: 'team-2', name: 'Design', key: 'DES', description: null }, { id: 'team-3', name: 'QA TEAM', key: 'QA', description: 'Quality Assurance' } ] }; teamsFn.mockImplementation(async () => mockTeams); // Test lowercase filter let result = await service.getTeams({ nameFilter: 'engineering' }); expect(result).toEqual([ { id: 'team-1', name: 'Engineering', key: 'ENG', description: 'Engineering team' } ]); // Test uppercase filter result = await service.getTeams({ nameFilter: 'DESIGN' }); expect(result).toEqual([ { id: 'team-2', name: 'Design', key: 'DES', description: undefined } ]); // Test mixed case filter result = await service.getTeams({ nameFilter: 'Qa' }); expect(result).toEqual([ { id: 'team-3', name: 'QA TEAM', key: 'QA', description: 'Quality Assurance' } ]); }); test('filters teams by name', async () => { const mockTeams = { nodes: [ { id: 'team-1', name: 'Engineering', key: 'ENG', description: 'Engineering team' }, { id: 'team-2', name: 'Design', key: 'DES', description: null } ] }; teamsFn.mockImplementation(async () => mockTeams); const result = await service.getTeams({ nameFilter: 'eng' }); expect(result).toEqual([ { id: 'team-1', name: 'Engineering', key: 'ENG', description: 'Engineering team' } ]); }); test('filters teams by key', async () => { const mockTeams = { nodes: [ { id: 'team-1', name: 'Engineering', key: 'ENG', description: 'Engineering team' }, { id: 'team-2', name: 'Design', key: 'DES', description: null } ] }; teamsFn.mockImplementation(async () => mockTeams); const result = await service.getTeams({ nameFilter: 'des' }); expect(result).toEqual([ { id: 'team-2', name: 'Design', key: 'DES', description: undefined } ]); }); test('handles missing or invalid team fields', async () => { const mockTeams = { nodes: [ { id: 'team-1', name: '', key: 'ENG', description: 'Engineering team' }, { id: 'team-2', name: 'Design', key: '', description: null }, { id: 'team-3', name: '', key: '', description: undefined } ] }; teamsFn.mockImplementation(async () => mockTeams); const result = await service.getTeams({}); expect(result).toEqual([ { id: 'team-1', name: '', key: 'ENG', description: 'Engineering team' }, { id: 'team-2', name: 'Design', key: '', description: undefined }, { id: 'team-3', name: '', key: '', description: undefined } ]); }); test('handles malformed team data', async () => { const mockTeams = { nodes: [ { id: 'team-1', name: '', key: '' }, // Empty required fields { id: 'team-2', name: 'Design', key: '' }, // Missing key { id: 'team-3', name: '', key: 'QA' }, // Missing name null, // Null team undefined, // Undefined team {} // Empty team ] }; teamsFn.mockImplementation(async () => mockTeams); const result = await service.getTeams({}); expect(result).toEqual([ { id: 'team-1', name: '', key: '', description: undefined }, { id: 'team-2', name: 'Design', key: '', description: undefined }, { id: 'team-3', name: '', key: 'QA', description: undefined } ]); }); test('handles API errors', async () => { teamsFn.mockImplementation(async () => { throw new Error('API error'); }); await expect(service.getTeams({})).rejects.toThrow( new McpError(ErrorCode.InternalError, 'Failed to fetch teams: API error') ); }); }); describe('createComment', () => { test('creates comment successfully', async () => { const mockComment = { id: 'comment-1', body: 'Test comment', createdAt: new Date('2025-01-24'), updatedAt: new Date('2025-01-24'), user: Promise.resolve({ id: 'user-1', name: 'John Doe' }) }; issueFn.mockImplementation(async () => ({ id: 'issue-1' })); (mockClient.createComment as any).mockImplementation(async () => ({ success: true, comment: mockComment })); const result = await service.createComment({ issueId: 'TEST-1', body: 'Test comment' }); expect(result).toEqual({ id: 'comment-1', body: 'Test comment', userId: 'user-1', userName: 'John Doe', createdAt: '2025-01-24T00:00:00.000Z', updatedAt: '2025-01-24T00:00:00.000Z' }); expect(mockClient.createComment).toHaveBeenCalledWith({ issueId: 'issue-1', body: 'Test comment' }); }); test('throws error when issue not found', async () => { issueFn.mockImplementation(async () => null); await expect(service.createComment({ issueId: 'NONEXISTENT', body: 'Test comment' })).rejects.toThrow( 'MCP error -32603: Failed to create comment: MCP error -32600: Issue not found: NONEXISTENT' ); expect(mockClient.createComment).not.toHaveBeenCalled(); }); test('throws error when comment creation fails', async () => { issueFn.mockImplementation(async () => ({ id: 'issue-1' })); (mockClient.createComment as any).mockImplementation(async () => ({ success: false, comment: null })); await expect(service.createComment({ issueId: 'TEST-1', body: 'Test comment' })).rejects.toThrow( 'MCP error -32603: Failed to create comment: MCP error -32603: Failed to create comment' ); }); }); describe('getIssue', () => { test('returns full issue details with relationships', async () => { const mockIssue = { id: 'issue-1', identifier: 'TEST-1', title: 'Test Issue', description: 'Test Description with @mention and TEST-2 reference', priority: 1, createdAt: new Date('2025-01-24'), updatedAt: new Date('2025-01-24'), estimate: 5, dueDate: new Date('2025-02-24'), state: Promise.resolve({ name: 'In Progress' }), assignee: Promise.resolve({ name: 'John Doe' }), team: Promise.resolve({ name: 'Engineering' }), creator: Promise.resolve({ name: 'Jane Smith' }), labels: () => Promise.resolve({ nodes: [{ name: 'bug' }] }), parent: Promise.resolve({ id: 'parent-1', identifier: 'TEST-0', title: 'Parent Issue' }), children: () => Promise.resolve({ nodes: [{ id: 'child-1', identifier: 'TEST-2', title: 'Child Issue' }] }), relations: () => Promise.resolve({ nodes: [{ type: 'blocked', relatedIssue: Promise.resolve({ id: 'blocked-1', identifier: 'TEST-3', title: 'Blocking Issue' }) }] }), comments: () => Promise.resolve({ nodes: [{ id: 'comment-1', body: 'Test comment with @user and TEST-4 reference', createdAt: new Date('2025-01-24'), updatedAt: new Date('2025-01-24'), user: Promise.resolve({ id: 'user-1', name: 'Commenter' }) }] }) }; issueFn.mockImplementation(async () => mockIssue); const result = await service.getIssue({ issueId: 'TEST-1', includeRelationships: true }); expect(result).toEqual({ id: 'issue-1', identifier: 'TEST-1', title: 'Test Issue', description: 'Test Description with @mention and TEST-2 reference', status: 'In Progress', assignee: 'John Doe', priority: 1, createdAt: '2025-01-24T00:00:00.000Z', updatedAt: '2025-01-24T00:00:00.000Z', teamName: 'Engineering', creatorName: 'Jane Smith', labels: ['bug'], estimate: 5, dueDate: '2025-02-24T00:00:00.000Z', parent: { id: 'parent-1', identifier: 'TEST-0', title: 'Parent Issue' }, subIssues: [{ id: 'child-1', identifier: 'TEST-2', title: 'Child Issue' }], relationships: [ { type: 'parent', issueId: 'parent-1', identifier: 'TEST-0', title: 'Parent Issue' }, { type: 'sub', issueId: 'child-1', identifier: 'TEST-2', title: 'Child Issue' }, { type: 'blocked', issueId: 'blocked-1', identifier: 'TEST-3', title: 'Blocking Issue' } ], comments: [{ id: 'comment-1', body: 'Test comment with @user and TEST-4 reference', userId: 'user-1', userName: 'Commenter', createdAt: '2025-01-24T00:00:00.000Z', updatedAt: '2025-01-24T00:00:00.000Z' }], mentionedIssues: ['TEST-2', 'TEST-4'], mentionedUsers: ['mention', 'user'] }); }); test('handles invalid issue ID', async () => { issueFn.mockImplementation(async () => null); await expect(service.getIssue({ issueId: 'INVALID' })) .rejects.toThrow(new McpError(ErrorCode.InvalidRequest, 'Issue not found: INVALID')); }); test('handles API errors gracefully', async () => { issueFn.mockImplementation(async () => { throw new Error('API Error'); }); await expect(service.getIssue({ issueId: 'TEST-1' })) .rejects.toThrow(new McpError(ErrorCode.InternalError, 'API Error')); }); test('handles null/undefined issue fields', async () => { const mockIssue = { id: 'issue-1', identifier: 'TEST-1', title: 'Test Issue', description: null, priority: undefined, createdAt: new Date('2025-01-24'), updatedAt: new Date('2025-01-24'), state: Promise.resolve(null), assignee: Promise.resolve(null), team: Promise.resolve(null), creator: Promise.resolve(null), labels: () => Promise.resolve({ nodes: [] }), parent: Promise.resolve(null), children: () => Promise.resolve({ nodes: [] }), relations: () => Promise.resolve({ nodes: [] }) }; issueFn.mockImplementation(async () => mockIssue); const result = await service.getIssue({ issueId: 'TEST-1' }); expect(result).toEqual({ id: 'issue-1', identifier: 'TEST-1', title: 'Test Issue', description: null, status: undefined, assignee: undefined, priority: undefined, createdAt: '2025-01-24T00:00:00.000Z', updatedAt: '2025-01-24T00:00:00.000Z', teamName: undefined, creatorName: undefined, labels: [], estimate: undefined, dueDate: undefined, parent: undefined, subIssues: [], relationships: [], mentionedIssues: [], mentionedUsers: [] }); }); }); describe('data cleaning', () => { test('extracts multiple issue mentions from text', async () => { const testIssue = { id: 'issue-1', identifier: 'TEST-1', title: 'Test Issue', description: 'Related to TEST-2 and TEST-3. CC @john and @jane', priority: 1, createdAt: new Date('2025-01-24'), updatedAt: new Date('2025-01-24'), state: Promise.resolve({ name: 'In Progress' }), assignee: Promise.resolve({ name: 'John Doe' }), team: Promise.resolve({ name: 'Engineering' }), creator: Promise.resolve({ name: 'Jane Smith' }), labels: () => Promise.resolve({ nodes: [] }), parent: Promise.resolve(undefined), children: () => Promise.resolve({ nodes: [] }), relations: () => Promise.resolve({ nodes: [] }), }; issueFn.mockImplementation(async () => testIssue); const result = await service.getIssue({ issueId: 'TEST-1' }); expect(result.mentionedIssues).toEqual(['TEST-2', 'TEST-3']); expect(result.mentionedUsers).toEqual(['john', 'jane']); }); test('extracts mentions from comments when relationships included', async () => { const testIssue = { id: 'issue-1', identifier: 'TEST-1', title: 'Test Issue', description: 'Initial description', priority: 1, createdAt: new Date('2025-01-24'), updatedAt: new Date('2025-01-24'), state: Promise.resolve({ name: 'In Progress' }), assignee: Promise.resolve({ name: 'John Doe' }), team: Promise.resolve({ name: 'Engineering' }), creator: Promise.resolve({ name: 'Jane Smith' }), labels: () => Promise.resolve({ nodes: [] }), parent: Promise.resolve(undefined), children: () => Promise.resolve({ nodes: [] }), relations: () => Promise.resolve({ nodes: [] }), comments: () => Promise.resolve({ nodes: [ { id: 'comment-1', body: 'Related to TEST-4. CC @alice', createdAt: new Date('2025-01-24'), updatedAt: new Date('2025-01-24'), user: Promise.resolve({ id: 'user-1', name: 'John Doe' }) }, { id: 'comment-2', body: 'Blocked by TEST-5. CC @bob', createdAt: new Date('2025-01-24'), updatedAt: new Date('2025-01-24'), user: Promise.resolve({ id: 'user-2', name: 'Jane Smith' }) } ] }) }; issueFn.mockImplementation(async () => testIssue); const result = await service.getIssue({ issueId: 'TEST-1', includeRelationships: true }); expect(result.mentionedIssues).toEqual(['TEST-4', 'TEST-5']); expect(result.mentionedUsers).toEqual(['alice', 'bob']); }); test('cleans various markdown elements from description', async () => { const testIssue = { id: 'issue-1', identifier: 'TEST-1', title: 'Test Issue', description: '# Heading\n**Bold** and _italic_ text with `code`\n[Link](https://example.com)', priority: 1, createdAt: new Date('2025-01-24'), updatedAt: new Date('2025-01-24'), state: Promise.resolve({ name: 'In Progress' }), assignee: Promise.resolve({ name: 'John Doe' }), team: Promise.resolve({ name: 'Engineering' }), creator: Promise.resolve({ name: 'Jane Smith' }), labels: () => Promise.resolve({ nodes: [] }), parent: Promise.resolve(undefined), children: () => Promise.resolve({ nodes: [] }), relations: () => Promise.resolve({ nodes: [] }), }; issueFn.mockImplementation(async () => testIssue); const result = await service.getIssue({ issueId: 'TEST-1' }); expect(result.description).toBe('Bold and italic text with code Link'); }); test('handles complex markdown formatting', async () => { const testIssue = { id: 'issue-1', identifier: 'TEST-1', title: 'Test Issue', description: '# Main Heading\n## Sub Heading\n- List item 1\n- List item 2\n\n> Quote here\n\n```\ncode block\n```\n\n---\n\nFinal paragraph', priority: 1, createdAt: new Date('2025-01-24'), updatedAt: new Date('2025-01-24'), state: Promise.resolve({ name: 'In Progress' }), assignee: Promise.resolve({ name: 'John Doe' }), team: Promise.resolve({ name: 'Engineering' }), creator: Promise.resolve({ name: 'Jane Smith' }), labels: () => Promise.resolve({ nodes: [] }), parent: Promise.resolve(undefined), children: () => Promise.resolve({ nodes: [] }), relations: () => Promise.resolve({ nodes: [] }), }; issueFn.mockImplementation(async () => testIssue); const result = await service.getIssue({ issueId: 'TEST-1' }); expect(result.description).toBe('`` code block `` --- Final paragraph'); }); }); describe('deleteIssue', () => { test('deletes issue successfully', async () => { const mockIssue = { id: 'issue-1', delete: mock(() => Promise.resolve()) }; issueFn.mockImplementation(async () => mockIssue); await service.deleteIssue({ issueId: 'TEST-1' }); expect(mockIssue.delete).toHaveBeenCalled(); }); test('throws error when issue not found', async () => { issueFn.mockImplementation(async () => null); await expect(service.deleteIssue({ issueId: 'NONEXISTENT' })) .rejects.toThrow(new McpError(ErrorCode.InvalidRequest, 'Issue not found: NONEXISTENT')); }); test('handles API errors gracefully', async () => { const mockIssue = { id: 'issue-1', delete: mock(() => Promise.reject(new Error('API error'))) }; issueFn.mockImplementation(async () => mockIssue); await expect(service.deleteIssue({ issueId: 'TEST-1' })) .rejects.toThrow(new McpError(ErrorCode.InternalError, 'Failed to delete issue: API error')); }); }); describe('relationship tracking', () => { test('tracks all relationship types', async () => { const mockParent = { id: 'parent-1', identifier: 'TEST-1', title: 'Parent Issue' }; const mockChild = { id: 'child-1', identifier: 'TEST-3', title: 'Child Issue' }; const mockRelated = { id: 'related-1', identifier: 'TEST-4', title: 'Related Issue' }; const mockBlocked = { id: 'blocked-1', identifier: 'TEST-5', title: 'Blocked Issue' }; const testIssue = { id: 'issue-2', identifier: 'TEST-2', title: 'Test Issue', description: 'Test Description', priority: 1, createdAt: new Date('2025-01-24'), updatedAt: new Date('2025-01-24'), state: Promise.resolve({ name: 'In Progress' }), assignee: Promise.resolve({ name: 'John Doe' }), team: Promise.resolve({ name: 'Engineering' }), creator: Promise.resolve({ name: 'Jane Smith' }), labels: () => Promise.resolve({ nodes: [] }), parent: Promise.resolve(mockParent), children: () => Promise.resolve({ nodes: [mockChild] }), relations: () => Promise.resolve({ nodes: [ { type: 'related', relatedIssue: Promise.resolve(mockRelated) }, { type: 'blocked', relatedIssue: Promise.resolve(mockBlocked) } ] }) }; issueFn.mockImplementation(async () => testIssue); const result = await service.getIssue({ issueId: 'TEST-2' }); expect(result.relationships).toEqual([ { type: 'parent', issueId: 'parent-1', identifier: 'TEST-1', title: 'Parent Issue' }, { type: 'sub', issueId: 'child-1', identifier: 'TEST-3', title: 'Child Issue' }, { type: 'related', issueId: 'related-1', identifier: 'TEST-4', title: 'Related Issue' }, { type: 'blocked', issueId: 'blocked-1', identifier: 'TEST-5', title: 'Blocked Issue' } ]); }); }); });