@smartbear/mcp 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,920 +0,0 @@
1
- import { beforeEach, describe, expect, it, vi } from 'vitest';
2
- import { MCP_SERVER_NAME, MCP_SERVER_VERSION } from '../../../common/info.js';
3
- import { InsightHubClient } from '../../../insight-hub/client.js';
4
- // Mock the dependencies
5
- const mockCurrentUserAPI = {
6
- listUserOrganizations: vi.fn(),
7
- getOrganizationProjects: vi.fn()
8
- };
9
- const mockErrorAPI = {
10
- viewErrorOnProject: vi.fn(),
11
- viewLatestEventOnError: vi.fn(),
12
- viewEventById: vi.fn(),
13
- listProjectErrors: vi.fn(),
14
- updateErrorOnProject: vi.fn(),
15
- listErrorPivots: vi.fn(),
16
- listEventsOnProject: vi.fn()
17
- };
18
- const mockProjectAPI = {
19
- listProjectEventFields: vi.fn(),
20
- createProject: vi.fn()
21
- };
22
- const mockCache = {
23
- set: vi.fn(),
24
- get: vi.fn(),
25
- del: vi.fn()
26
- };
27
- vi.mock('../../../insight-hub/client/index.js', () => ({
28
- CurrentUserAPI: vi.fn().mockImplementation(() => mockCurrentUserAPI),
29
- ErrorAPI: vi.fn().mockImplementation(() => mockErrorAPI),
30
- Configuration: vi.fn().mockImplementation((config) => config)
31
- }));
32
- vi.mock('../../../insight-hub/client/api/Project.js', () => ({
33
- ProjectAPI: vi.fn().mockImplementation(() => mockProjectAPI)
34
- }));
35
- vi.mock('node-cache', () => ({
36
- default: vi.fn().mockImplementation(() => mockCache)
37
- }));
38
- vi.mock('../../../common/bugsnag.js', () => ({
39
- default: {
40
- notify: vi.fn()
41
- }
42
- }));
43
- describe('InsightHubClient', () => {
44
- let client;
45
- beforeEach(() => {
46
- vi.clearAllMocks();
47
- client = new InsightHubClient('test-token');
48
- });
49
- describe('constructor', () => {
50
- it('should create client instance with proper dependencies', () => {
51
- const client = new InsightHubClient('test-token');
52
- expect(client).toBeInstanceOf(InsightHubClient);
53
- });
54
- it('should configure endpoints correctly during construction', async () => {
55
- const { Configuration } = await import('../../../insight-hub/client/index.js');
56
- const MockedConfiguration = vi.mocked(Configuration);
57
- new InsightHubClient('test-token', '00000hub-key');
58
- expect(MockedConfiguration).toHaveBeenCalledWith(expect.objectContaining({
59
- basePath: 'https://api.insighthub.smartbear.com',
60
- authToken: 'test-token',
61
- headers: expect.objectContaining({
62
- 'User-Agent': `${MCP_SERVER_NAME}/${MCP_SERVER_VERSION}`,
63
- 'Content-Type': 'application/json',
64
- 'X-Bugsnag-API': 'true',
65
- 'X-Version': '2'
66
- })
67
- }));
68
- });
69
- it('should set project API key when provided', () => {
70
- const client = new InsightHubClient('test-token', 'test-project-key');
71
- expect(client).toBeInstanceOf(InsightHubClient);
72
- });
73
- });
74
- describe('getEndpoint method', () => {
75
- let client;
76
- beforeEach(() => {
77
- client = new InsightHubClient('test-token');
78
- });
79
- describe('without custom endpoint', () => {
80
- describe('with Hub API key (00000 prefix)', () => {
81
- it('should return Hub domain for api subdomain', () => {
82
- const result = client.getEndpoint('api', '00000hub-key');
83
- expect(result).toBe('https://api.insighthub.smartbear.com');
84
- });
85
- it('should return Hub domain for app subdomain', () => {
86
- const result = client.getEndpoint('app', '00000test-key');
87
- expect(result).toBe('https://app.insighthub.smartbear.com');
88
- });
89
- it('should return Hub domain for custom subdomain', () => {
90
- const result = client.getEndpoint('custom', '00000key');
91
- expect(result).toBe('https://custom.insighthub.smartbear.com');
92
- });
93
- it('should handle empty string after prefix', () => {
94
- const result = client.getEndpoint('api', '00000');
95
- expect(result).toBe('https://api.insighthub.smartbear.com');
96
- });
97
- });
98
- describe('with regular API key (non-Hub)', () => {
99
- it('should return Bugsnag domain for api subdomain', () => {
100
- const result = client.getEndpoint('api', 'regular-key');
101
- expect(result).toBe('https://api.bugsnag.com');
102
- });
103
- it('should return Bugsnag domain for app subdomain', () => {
104
- const result = client.getEndpoint('app', 'abc123def');
105
- expect(result).toBe('https://app.bugsnag.com');
106
- });
107
- it('should return Bugsnag domain for custom subdomain', () => {
108
- const result = client.getEndpoint('custom', 'test-key-123');
109
- expect(result).toBe('https://custom.bugsnag.com');
110
- });
111
- it('should handle API key with 00000 in middle', () => {
112
- const result = client.getEndpoint('api', 'key-00000-middle');
113
- expect(result).toBe('https://api.bugsnag.com');
114
- });
115
- });
116
- describe('without API key', () => {
117
- it('should return Bugsnag domain when API key is undefined', () => {
118
- const result = client.getEndpoint('api', undefined);
119
- expect(result).toBe('https://api.bugsnag.com');
120
- });
121
- it('should return Bugsnag domain when API key is empty string', () => {
122
- const result = client.getEndpoint('api', '');
123
- expect(result).toBe('https://api.bugsnag.com');
124
- });
125
- it('should return Bugsnag domain when API key is null', () => {
126
- const result = client.getEndpoint('api', null);
127
- expect(result).toBe('https://api.bugsnag.com');
128
- });
129
- });
130
- });
131
- describe('with custom endpoint', () => {
132
- describe('Hub domain endpoints (always normalized)', () => {
133
- it('should normalize to HTTPS subdomain for exact hub domain match', () => {
134
- const result = client.getEndpoint('api', '00000key', 'https://api.insighthub.smartbear.com');
135
- expect(result).toBe('https://api.insighthub.smartbear.com');
136
- });
137
- it('should normalize to HTTPS subdomain regardless of input protocol', () => {
138
- const result = client.getEndpoint('api', '00000key', 'http://app.insighthub.smartbear.com');
139
- expect(result).toBe('https://api.insighthub.smartbear.com');
140
- });
141
- it('should normalize to HTTPS subdomain regardless of input subdomain', () => {
142
- const result = client.getEndpoint('app', '00000key', 'https://api.insighthub.smartbear.com');
143
- expect(result).toBe('https://app.insighthub.smartbear.com');
144
- });
145
- it('should normalize hub domain with port', () => {
146
- const result = client.getEndpoint('api', '00000key', 'https://custom.insighthub.smartbear.com:8080');
147
- expect(result).toBe('https://api.insighthub.smartbear.com');
148
- });
149
- it('should normalize hub domain with path', () => {
150
- const result = client.getEndpoint('api', '00000key', 'https://custom.insighthub.smartbear.com/path');
151
- expect(result).toBe('https://api.insighthub.smartbear.com');
152
- });
153
- it('should normalize complex subdomains to standard format', () => {
154
- const result = client.getEndpoint('api', '00000key', 'https://staging.app.insighthub.smartbear.com');
155
- expect(result).toBe('https://api.insighthub.smartbear.com');
156
- });
157
- });
158
- describe('Bugsnag domain endpoints (always normalized)', () => {
159
- it('should normalize to HTTPS subdomain for exact bugsnag domain match', () => {
160
- const result = client.getEndpoint('api', 'regular-key', 'https://api.bugsnag.com');
161
- expect(result).toBe('https://api.bugsnag.com');
162
- });
163
- it('should normalize to HTTPS subdomain regardless of input protocol', () => {
164
- const result = client.getEndpoint('api', 'regular-key', 'http://app.bugsnag.com');
165
- expect(result).toBe('https://api.bugsnag.com');
166
- });
167
- it('should normalize bugsnag domain with port', () => {
168
- const result = client.getEndpoint('app', 'regular-key', 'https://api.bugsnag.com:9000');
169
- expect(result).toBe('https://app.bugsnag.com');
170
- });
171
- it('should normalize bugsnag domain with path', () => {
172
- const result = client.getEndpoint('app', 'regular-key', 'https://api.bugsnag.com/v2');
173
- expect(result).toBe('https://app.bugsnag.com');
174
- });
175
- });
176
- describe('Custom domain endpoints (used as-is)', () => {
177
- it('should return custom endpoint exactly as provided', () => {
178
- const customEndpoint = 'https://custom.api.com';
179
- const result = client.getEndpoint('api', '00000key', customEndpoint);
180
- expect(result).toBe(customEndpoint);
181
- });
182
- it('should return custom endpoint as-is regardless of API key type', () => {
183
- const customEndpoint = 'https://my-custom-domain.com/api';
184
- const result = client.getEndpoint('api', 'regular-key', customEndpoint);
185
- expect(result).toBe(customEndpoint);
186
- });
187
- it('should preserve HTTP protocol for custom domains', () => {
188
- const customEndpoint = 'http://localhost:3000';
189
- const result = client.getEndpoint('api', '00000key', customEndpoint);
190
- expect(result).toBe(customEndpoint);
191
- });
192
- it('should preserve custom domain with ports and paths', () => {
193
- const customEndpoint = 'https://192.168.1.100:8080/api/v1';
194
- const result = client.getEndpoint('api', '00000key', customEndpoint);
195
- expect(result).toBe(customEndpoint);
196
- });
197
- it('should preserve custom domain with query parameters', () => {
198
- const customEndpoint = 'https://custom.domain.com/api?version=1';
199
- const result = client.getEndpoint('api', '00000key', customEndpoint);
200
- expect(result).toBe(customEndpoint);
201
- });
202
- it('should preserve custom domain with fragments', () => {
203
- const customEndpoint = 'https://custom.domain.com/api#section';
204
- const result = client.getEndpoint('api', '00000key', customEndpoint);
205
- expect(result).toBe(customEndpoint);
206
- });
207
- });
208
- describe('edge cases', () => {
209
- it('should handle malformed custom endpoints gracefully', () => {
210
- // This should throw due to invalid URL, which is expected behavior
211
- expect(() => {
212
- client.getEndpoint('api', '00000key', 'not-a-valid-url');
213
- }).toThrow();
214
- });
215
- it('should preserve custom endpoints with userinfo', () => {
216
- const customEndpoint = 'https://user:pass@custom.domain.com';
217
- const result = client.getEndpoint('api', '00000key', customEndpoint);
218
- expect(result).toBe(customEndpoint);
219
- });
220
- it('should normalize known domains even with userinfo', () => {
221
- const result = client.getEndpoint('api', '00000key', 'https://user:pass@app.insighthub.smartbear.com');
222
- expect(result).toBe('https://api.insighthub.smartbear.com');
223
- });
224
- });
225
- });
226
- describe('subdomain validation', () => {
227
- it('should handle empty subdomain', () => {
228
- const result = client.getEndpoint('', '00000key');
229
- expect(result).toBe('https://.insighthub.smartbear.com');
230
- });
231
- it('should handle subdomain with special characters', () => {
232
- const result = client.getEndpoint('test-api_v2', '00000key');
233
- expect(result).toBe('https://test-api_v2.insighthub.smartbear.com');
234
- });
235
- it('should handle numeric subdomain', () => {
236
- const result = client.getEndpoint('v1', 'regular-key');
237
- expect(result).toBe('https://v1.bugsnag.com');
238
- });
239
- it('should handle very long subdomains', () => {
240
- const longSubdomain = 'very-long-subdomain-name-with-many-characters';
241
- const result = client.getEndpoint(longSubdomain, '00000key');
242
- expect(result).toBe(`https://${longSubdomain}.insighthub.smartbear.com`);
243
- });
244
- });
245
- });
246
- describe('static utility methods', () => {
247
- // Test static methods if they exist in the class
248
- it('should have proper class structure', () => {
249
- const client = new InsightHubClient('test-token');
250
- // Verify the client has expected methods
251
- expect(typeof client.initialize).toBe('function');
252
- expect(typeof client.registerTools).toBe('function');
253
- expect(typeof client.registerResources).toBe('function');
254
- });
255
- });
256
- describe('error handling', () => {
257
- it('should handle invalid tokens gracefully during construction', () => {
258
- expect(() => {
259
- new InsightHubClient('');
260
- }).not.toThrow();
261
- expect(() => {
262
- new InsightHubClient(' ');
263
- }).not.toThrow();
264
- });
265
- it('should handle special characters in project API key', () => {
266
- expect(() => {
267
- new InsightHubClient('test-token', '00000-special!@#$%^&*()');
268
- }).not.toThrow();
269
- });
270
- });
271
- describe('configuration validation', () => {
272
- it('should pass correct authToken to Configuration', async () => {
273
- const { Configuration } = await import('../../../insight-hub/client/index.js');
274
- const MockedConfiguration = vi.mocked(Configuration);
275
- const testToken = 'super-secret-token-123';
276
- new InsightHubClient(testToken);
277
- expect(MockedConfiguration).toHaveBeenCalledWith(expect.objectContaining({
278
- authToken: testToken
279
- }));
280
- });
281
- it('should include all required headers', async () => {
282
- const { Configuration } = await import('../../../insight-hub/client/index.js');
283
- const MockedConfiguration = vi.mocked(Configuration);
284
- new InsightHubClient('test-token');
285
- const configCall = MockedConfiguration.mock.calls[0][0];
286
- expect(configCall.headers).toEqual({
287
- "User-Agent": `${MCP_SERVER_NAME}/${MCP_SERVER_VERSION}`,
288
- "Content-Type": "application/json",
289
- "X-Bugsnag-API": "true",
290
- "X-Version": "2",
291
- });
292
- });
293
- });
294
- describe('API client initialization', () => {
295
- it('should initialize all required API clients', async () => {
296
- const { CurrentUserAPI, ErrorAPI } = await import('../../../insight-hub/client/index.js');
297
- const { ProjectAPI } = await import('../../../insight-hub/client/api/Project.js');
298
- const MockedCurrentUserAPI = vi.mocked(CurrentUserAPI);
299
- const MockedErrorAPI = vi.mocked(ErrorAPI);
300
- const MockedProjectAPI = vi.mocked(ProjectAPI);
301
- // Clear previous calls from beforeEach and other tests
302
- MockedCurrentUserAPI.mockClear();
303
- MockedErrorAPI.mockClear();
304
- MockedProjectAPI.mockClear();
305
- new InsightHubClient('test-token');
306
- expect(MockedCurrentUserAPI).toHaveBeenCalledOnce();
307
- expect(MockedErrorAPI).toHaveBeenCalledOnce();
308
- expect(MockedProjectAPI).toHaveBeenCalledOnce();
309
- });
310
- it('should initialize NodeCache', async () => {
311
- const NodeCacheModule = await import('node-cache');
312
- const MockedNodeCache = vi.mocked(NodeCacheModule.default);
313
- // Clear previous calls from beforeEach
314
- MockedNodeCache.mockClear();
315
- new InsightHubClient('test-token');
316
- expect(MockedNodeCache).toHaveBeenCalledOnce();
317
- });
318
- });
319
- describe('initialization', () => {
320
- it('should initialize successfully with organizations and projects', async () => {
321
- const mockOrg = { id: 'org-1', name: 'Test Org' };
322
- const mockProjects = [
323
- { id: 'proj-1', name: 'Project 1', api_key: 'key1' },
324
- { id: 'proj-2', name: 'Project 2', api_key: 'key2' }
325
- ];
326
- mockCache.get.mockReturnValueOnce(null) // No current projects
327
- .mockReturnValueOnce(null) // No cached projects
328
- .mockReturnValueOnce(null); // No cached organization
329
- mockCurrentUserAPI.listUserOrganizations.mockResolvedValue({ body: [mockOrg] });
330
- mockCurrentUserAPI.getOrganizationProjects.mockResolvedValue({ body: mockProjects });
331
- await client.initialize();
332
- expect(mockCurrentUserAPI.listUserOrganizations).toHaveBeenCalledOnce();
333
- expect(mockCurrentUserAPI.getOrganizationProjects).toHaveBeenCalledWith('org-1', { paginate: true });
334
- expect(mockCache.set).toHaveBeenCalledWith('insight_hub_org', mockOrg);
335
- expect(mockCache.set).toHaveBeenCalledWith('insight_hub_projects', mockProjects);
336
- });
337
- it('should initialize with project API key and set up event filters', async () => {
338
- const clientWithApiKey = new InsightHubClient('test-token', 'project-api-key');
339
- const mockProjects = [
340
- { id: 'proj-1', name: 'Project 1', api_key: 'project-api-key' },
341
- { id: 'proj-2', name: 'Project 2', api_key: 'other-key' }
342
- ];
343
- const mockEventFields = [
344
- { display_id: 'user.email', custom: false },
345
- { display_id: 'error.status', custom: false },
346
- { display_id: 'search', custom: false } // This should be filtered out
347
- ];
348
- mockCache.get.mockReturnValueOnce(mockProjects)
349
- .mockReturnValueOnce(null)
350
- .mockReturnValueOnce(mockProjects);
351
- mockProjectAPI.listProjectEventFields.mockResolvedValue({ body: mockEventFields });
352
- await clientWithApiKey.initialize();
353
- expect(mockCache.set).toHaveBeenCalledWith('insight_hub_current_project', mockProjects[0]);
354
- expect(mockProjectAPI.listProjectEventFields).toHaveBeenCalledWith('proj-1');
355
- // // Verify that 'search' field is filtered out
356
- const filteredFields = mockEventFields.filter(field => field.display_id !== 'search');
357
- expect(mockCache.set).toHaveBeenCalledWith('insight_hub_current_project_event_filters', filteredFields);
358
- });
359
- it('should throw error when no organizations found', async () => {
360
- mockCurrentUserAPI.listUserOrganizations.mockResolvedValue({ body: [] });
361
- await expect(client.initialize()).rejects.toThrow('No organizations found for the current user.');
362
- });
363
- it('should throw error when project with API key not found', async () => {
364
- const clientWithApiKey = new InsightHubClient('test-token', 'non-existent-key');
365
- const mockOrg = { id: 'org-1', name: 'Test Org' };
366
- const mockProject = { id: 'proj-1', name: 'Project 1', api_key: 'other-key' };
367
- mockCurrentUserAPI.listUserOrganizations.mockResolvedValue({ body: [mockOrg] });
368
- mockCurrentUserAPI.getOrganizationProjects.mockResolvedValue({ body: [mockProject] });
369
- await expect(clientWithApiKey.initialize()).rejects.toThrow('Unable to find project with API key non-existent-key in organization.');
370
- });
371
- it('should throw error when no event fields found for project', async () => {
372
- const clientWithApiKey = new InsightHubClient('test-token', 'project-api-key');
373
- const mockOrg = { id: 'org-1', name: 'Test Org' };
374
- const mockProjects = [
375
- { id: 'proj-1', name: 'Project 1', api_key: 'project-api-key' }
376
- ];
377
- mockCurrentUserAPI.listUserOrganizations.mockResolvedValue({ body: [mockOrg] });
378
- mockCurrentUserAPI.getOrganizationProjects.mockResolvedValue({ body: mockProjects });
379
- mockProjectAPI.listProjectEventFields.mockResolvedValue({ body: [] });
380
- await expect(clientWithApiKey.initialize()).rejects.toThrow('No event fields found for project Project 1.');
381
- });
382
- });
383
- describe('API methods', () => {
384
- describe('getProjects', () => {
385
- it('should return cached projects when available', async () => {
386
- const mockProjects = [{ id: 'proj-1', name: 'Project 1' }];
387
- mockCache.get.mockReturnValue(mockProjects);
388
- const result = await client.getProjects();
389
- expect(mockCache.get).toHaveBeenCalledWith('insight_hub_projects');
390
- expect(result).toEqual(mockProjects);
391
- });
392
- it('should fetch projects from API when not cached', async () => {
393
- const mockOrg = { id: 'org-1', name: 'Test Org' };
394
- const mockProjects = [{ id: 'proj-1', name: 'Project 1' }];
395
- mockCache.get
396
- .mockReturnValueOnce(null) // First call for projects
397
- .mockReturnValueOnce(mockOrg); // Second call for org
398
- mockCurrentUserAPI.getOrganizationProjects.mockResolvedValue({ body: mockProjects });
399
- const result = await client.getProjects();
400
- expect(mockCurrentUserAPI.getOrganizationProjects).toHaveBeenCalledWith('org-1', { paginate: true });
401
- expect(mockCache.set).toHaveBeenCalledWith('insight_hub_projects', mockProjects);
402
- expect(result).toEqual(mockProjects);
403
- });
404
- it('should return empty array when no projects found', async () => {
405
- const mockOrg = { id: 'org-1', name: 'Test Org' };
406
- mockCache.get
407
- .mockReturnValueOnce(null) // First call for projects
408
- .mockReturnValueOnce(mockOrg); // Second call for org
409
- mockCurrentUserAPI.getOrganizationProjects.mockResolvedValue({ body: [] });
410
- await expect(client.getProjects()).resolves.toEqual([]);
411
- });
412
- });
413
- describe('getEventById', () => {
414
- it('should find event across multiple projects', async () => {
415
- const mockOrgs = [{ id: 'org-1', name: 'Test Org' }];
416
- const mockProjects = [
417
- { id: 'proj-1', name: 'Project 1' },
418
- { id: 'proj-2', name: 'Project 2' }
419
- ];
420
- const mockEvent = { id: 'event-1', project_id: 'proj-2' };
421
- mockCache.get.mockReturnValueOnce(mockProjects);
422
- mockCache.get.mockReturnValueOnce(mockOrgs);
423
- mockCurrentUserAPI.getOrganizationProjects.mockResolvedValue({ body: mockProjects });
424
- mockErrorAPI.viewEventById
425
- .mockRejectedValueOnce(new Error('Not found')) // proj-1
426
- .mockResolvedValueOnce({ body: mockEvent }); // proj-2
427
- const result = await client.getEvent('event-1');
428
- expect(mockErrorAPI.viewEventById).toHaveBeenCalledWith('proj-1', 'event-1');
429
- expect(mockErrorAPI.viewEventById).toHaveBeenCalledWith('proj-2', 'event-1');
430
- expect(result).toEqual(mockEvent);
431
- });
432
- it('should return null when event not found in any project', async () => {
433
- const mockOrgs = [{ id: 'org-1', name: 'Test Org' }];
434
- const mockProjects = [{ id: 'proj-1', name: 'Project 1' }];
435
- mockCurrentUserAPI.listUserOrganizations.mockResolvedValue({ body: mockOrgs });
436
- mockCurrentUserAPI.getOrganizationProjects.mockResolvedValue({ body: mockProjects });
437
- mockErrorAPI.viewEventById.mockRejectedValue(new Error('Not found'));
438
- const result = await client.getEvent('event-1');
439
- expect(result).toBeNull();
440
- });
441
- });
442
- });
443
- describe('tool registration', () => {
444
- let mockServer;
445
- beforeEach(() => {
446
- mockServer = {
447
- registerTool: vi.fn(),
448
- resource: vi.fn()
449
- };
450
- });
451
- it('should register list_insight_hub_projects tool when no project API key', () => {
452
- client.registerTools(mockServer);
453
- expect(mockServer.registerTool).toHaveBeenCalledWith('list_insight_hub_projects', expect.any(Object), expect.any(Function));
454
- });
455
- it('should not register list_insight_hub_projects tool when project API key is provided', () => {
456
- const clientWithApiKey = new InsightHubClient('test-token', 'project-api-key');
457
- clientWithApiKey.registerTools(mockServer);
458
- const registeredTools = mockServer.registerTool.mock.calls.map((call) => call[0]);
459
- expect(registeredTools).not.toContain('list_insight_hub_projects');
460
- });
461
- it('should register common tools regardless of project API key', () => {
462
- client.registerTools(mockServer);
463
- const registeredTools = mockServer.registerTool.mock.calls.map((call) => call[0]);
464
- expect(registeredTools).toContain('get_insight_hub_error');
465
- expect(registeredTools).toContain('get_insight_hub_event_details');
466
- expect(registeredTools).toContain('list_insight_hub_project_errors');
467
- expect(registeredTools).toContain('get_project_event_filters');
468
- expect(registeredTools).toContain('update_error');
469
- });
470
- });
471
- describe('resource registration', () => {
472
- let mockServer;
473
- beforeEach(() => {
474
- mockServer = {
475
- registerTool: vi.fn(),
476
- resource: vi.fn()
477
- };
478
- });
479
- it('should register insight_hub_event resource', () => {
480
- client.registerResources(mockServer);
481
- expect(mockServer.resource).toHaveBeenCalledWith('insight_hub_event', expect.any(Object), expect.any(Function));
482
- });
483
- });
484
- describe('tool handlers', () => {
485
- let mockServer;
486
- beforeEach(() => {
487
- mockServer = {
488
- registerTool: vi.fn(),
489
- resource: vi.fn()
490
- };
491
- });
492
- describe('list_insight_hub_projects tool handler', () => {
493
- it('should return projects with pagination', async () => {
494
- const mockProjects = [
495
- { id: 'proj-1', name: 'Project 1' },
496
- { id: 'proj-2', name: 'Project 2' },
497
- { id: 'proj-3', name: 'Project 3' }
498
- ];
499
- mockCache.get.mockReturnValue(mockProjects);
500
- client.registerTools(mockServer);
501
- const toolHandler = mockServer.registerTool.mock.calls
502
- .find((call) => call[0] === 'list_insight_hub_projects')[2];
503
- const result = await toolHandler({ page_size: 2, page: 1 });
504
- const expectedResult = {
505
- data: mockProjects.slice(0, 2),
506
- count: 2
507
- };
508
- expect(result.content[0].text).toBe(JSON.stringify(expectedResult));
509
- });
510
- it('should return all projects when no pagination specified', async () => {
511
- const mockProjects = [{ id: 'proj-1', name: 'Project 1' }];
512
- mockCache.get.mockReturnValue(mockProjects);
513
- client.registerTools(mockServer);
514
- const toolHandler = mockServer.registerTool.mock.calls
515
- .find((call) => call[0] === 'list_insight_hub_projects')[2];
516
- const result = await toolHandler({});
517
- const expectedResult = {
518
- data: mockProjects,
519
- count: 1
520
- };
521
- expect(result.content[0].text).toBe(JSON.stringify(expectedResult));
522
- });
523
- it('should handle no projects found', async () => {
524
- mockCache.get.mockReturnValue([]);
525
- client.registerTools(mockServer);
526
- const toolHandler = mockServer.registerTool.mock.calls
527
- .find((call) => call[0] === 'list_insight_hub_projects')[2];
528
- const result = await toolHandler({});
529
- expect(result.content[0].text).toBe('No projects found.');
530
- });
531
- it('should handle pagination with only page_size', async () => {
532
- const mockProjects = [
533
- { id: 'proj-1', name: 'Project 1' },
534
- { id: 'proj-2', name: 'Project 2' },
535
- { id: 'proj-3', name: 'Project 3' }
536
- ];
537
- mockCache.get.mockReturnValue(mockProjects);
538
- client.registerTools(mockServer);
539
- const toolHandler = mockServer.registerTool.mock.calls
540
- .find((call) => call[0] === 'list_insight_hub_projects')[2];
541
- const result = await toolHandler({ page_size: 2 });
542
- const expectedResult = {
543
- data: mockProjects.slice(0, 2),
544
- count: 2
545
- };
546
- expect(result.content[0].text).toBe(JSON.stringify(expectedResult));
547
- });
548
- it('should handle pagination with only page', async () => {
549
- const mockProjects = Array.from({ length: 25 }, (_, i) => ({
550
- id: `proj-${i + 1}`,
551
- name: `Project ${i + 1}`
552
- }));
553
- mockCache.get.mockReturnValue(mockProjects);
554
- client.registerTools(mockServer);
555
- const toolHandler = mockServer.registerTool.mock.calls
556
- .find((call) => call[0] === 'list_insight_hub_projects')[2];
557
- const result = await toolHandler({ page: 2 });
558
- // Default page_size is 10, so page 2 should return projects 10-19
559
- const expectedResult = {
560
- data: mockProjects.slice(10, 20),
561
- count: 10
562
- };
563
- expect(result.content[0].text).toBe(JSON.stringify(expectedResult));
564
- });
565
- });
566
- describe('get_insight_hub_error tool handler', () => {
567
- it('should get error details with project from cache', async () => {
568
- const mockProject = { id: 'proj-1', name: 'Project 1', slug: 'my-project' };
569
- const mockError = { id: 'error-1', message: 'Test error' };
570
- const mockOrg = { id: 'org-1', name: 'Test Org', slug: 'test-org' };
571
- const mockEvents = [{ id: 'event-1', timestamp: '2023-01-01' }];
572
- const mockPivots = [{ id: 'pivot-1', name: 'test-pivot' }];
573
- mockCache.get.mockReturnValueOnce(mockProject)
574
- .mockReturnValueOnce(mockOrg);
575
- mockErrorAPI.viewErrorOnProject.mockResolvedValue({ body: mockError });
576
- mockErrorAPI.listEventsOnProject.mockResolvedValue({ body: mockEvents });
577
- mockErrorAPI.listErrorPivots.mockResolvedValue({ body: mockPivots });
578
- client.registerTools(mockServer);
579
- const toolHandler = mockServer.registerTool.mock.calls
580
- .find((call) => call[0] === 'get_insight_hub_error')[2];
581
- const result = await toolHandler({ errorId: 'error-1' });
582
- const queryString = '?filters[error][][type]=eq&filters[error][][value]=error-1';
583
- const encodedQueryString = encodeURI(queryString);
584
- expect(mockErrorAPI.viewErrorOnProject).toHaveBeenCalledWith('proj-1', 'error-1');
585
- expect(result.content[0].text).toBe(JSON.stringify({
586
- error_details: mockError,
587
- latest_event: mockEvents[0],
588
- pivots: mockPivots,
589
- url: `https://app.bugsnag.com/${mockOrg.slug}/${mockProject.slug}/errors/error-1${encodedQueryString}`
590
- }));
591
- });
592
- it('should throw error when required arguments missing', async () => {
593
- client.registerTools(mockServer);
594
- const toolHandler = mockServer.registerTool.mock.calls
595
- .find((call) => call[0] === 'get_insight_hub_error')[2];
596
- await expect(toolHandler({})).rejects.toThrow('Both projectId and errorId arguments are required');
597
- });
598
- });
599
- describe('get_insight_hub_event_details tool handler', () => {
600
- it('should get event details from dashboard URL', async () => {
601
- const mockProjects = [{ id: 'proj-1', slug: 'my-project', name: 'My Project' }];
602
- const mockEvent = { id: 'event-1', project_id: 'proj-1' };
603
- mockCache.get.mockReturnValue(mockProjects);
604
- mockErrorAPI.viewEventById.mockResolvedValue({ body: mockEvent });
605
- client.registerTools(mockServer);
606
- const toolHandler = mockServer.registerTool.mock.calls
607
- .find((call) => call[0] === 'get_insight_hub_event_details')[2];
608
- const result = await toolHandler({
609
- link: 'https://app.bugsnag.com/my-org/my-project/errors/error-123?event_id=event-1'
610
- });
611
- expect(mockErrorAPI.viewEventById).toHaveBeenCalledWith('proj-1', 'event-1');
612
- expect(result.content[0].text).toBe(JSON.stringify(mockEvent));
613
- });
614
- it('should throw error when link is invalid', async () => {
615
- client.registerTools(mockServer);
616
- const toolHandler = mockServer.registerTool.mock.calls
617
- .find((call) => call[0] === 'get_insight_hub_event_details')[2];
618
- await expect(toolHandler({ link: 'invalid-url' })).rejects.toThrow();
619
- });
620
- it('should throw error when project not found', async () => {
621
- mockCache.get.mockReturnValue([{ id: 'proj-1', slug: 'other-project', name: 'Other Project' }]);
622
- client.registerTools(mockServer);
623
- const toolHandler = mockServer.registerTool.mock.calls
624
- .find((call) => call[0] === 'get_insight_hub_event_details')[2];
625
- await expect(toolHandler({
626
- link: 'https://app.bugsnag.com/my-org/my-project/errors/error-123?event_id=event-1'
627
- })).rejects.toThrow('Project with the specified slug not found.');
628
- });
629
- it('should throw error when URL is missing required parameters', async () => {
630
- client.registerTools(mockServer);
631
- const toolHandler = mockServer.registerTool.mock.calls
632
- .find((call) => call[0] === 'get_insight_hub_event_details')[2];
633
- await expect(toolHandler({
634
- link: 'https://app.bugsnag.com/my-org/my-project/errors/error-123' // Missing event_id
635
- })).rejects.toThrow('Both projectSlug and eventId must be present in the link');
636
- });
637
- });
638
- describe('list_insight_hub_project_errors tool handler', () => {
639
- it('should list project errors with filters', async () => {
640
- const mockProject = { id: 'proj-1', name: 'Project 1' };
641
- const mockEventFields = [
642
- { display_id: 'error.status', custom: false },
643
- { display_id: 'user.email', custom: false }
644
- ];
645
- const mockErrors = [{ id: 'error-1', message: 'Test error' }];
646
- const filters = { 'error.status': [{ type: 'eq', value: 'open' }] };
647
- mockCache.get
648
- .mockReturnValueOnce(mockProject) // current project
649
- .mockReturnValueOnce(mockEventFields); // event fields
650
- mockErrorAPI.listProjectErrors.mockResolvedValue({ body: mockErrors });
651
- client.registerTools(mockServer);
652
- const toolHandler = mockServer.registerTool.mock.calls
653
- .find((call) => call[0] === 'list_insight_hub_project_errors')[2];
654
- const result = await toolHandler({ filters });
655
- expect(mockErrorAPI.listProjectErrors).toHaveBeenCalledWith('proj-1', { filters });
656
- const expectedResult = {
657
- data: mockErrors,
658
- count: 1
659
- };
660
- expect(result.content[0].text).toBe(JSON.stringify(expectedResult));
661
- });
662
- it('should validate filter keys against cached event fields', async () => {
663
- const mockProject = { id: 'proj-1', name: 'Project 1' };
664
- const mockEventFields = [{ display_id: 'error.status', custom: false }];
665
- const filters = { 'invalid.field': [{ type: 'eq', value: 'test' }] };
666
- mockCache.get
667
- .mockReturnValueOnce(mockProject)
668
- .mockReturnValueOnce(mockEventFields);
669
- client.registerTools(mockServer);
670
- const toolHandler = mockServer.registerTool.mock.calls
671
- .find((call) => call[0] === 'list_insight_hub_project_errors')[2];
672
- await expect(toolHandler({ filters })).rejects.toThrow('Invalid filter key: invalid.field');
673
- });
674
- it('should throw error when no project ID available', async () => {
675
- mockCache.get.mockReturnValue(null);
676
- client.registerTools(mockServer);
677
- const toolHandler = mockServer.registerTool.mock.calls
678
- .find((call) => call[0] === 'list_insight_hub_project_errors')[2];
679
- await expect(toolHandler({})).rejects.toThrow('No current project found. Please provide a projectId or configure a project API key.');
680
- });
681
- });
682
- describe('get_project_event_filters tool handler', () => {
683
- it('should return cached event fields', async () => {
684
- const mockEventFields = [
685
- { display_id: 'error.status', custom: false },
686
- { display_id: 'user.email', custom: false }
687
- ];
688
- mockCache.get.mockReturnValue(mockEventFields);
689
- client.registerTools(mockServer);
690
- const toolHandler = mockServer.registerTool.mock.calls
691
- .find((call) => call[0] === 'get_project_event_filters')[2];
692
- const result = await toolHandler({});
693
- expect(result.content[0].text).toBe(JSON.stringify(mockEventFields));
694
- });
695
- it('should throw error when no event filters in cache', async () => {
696
- mockCache.get.mockReturnValue(null);
697
- client.registerTools(mockServer);
698
- const toolHandler = mockServer.registerTool.mock.calls
699
- .find((call) => call[0] === 'get_project_event_filters')[2];
700
- await expect(toolHandler({})).rejects.toThrow('No event filters found in cache.');
701
- });
702
- });
703
- describe('update_error tool handler', () => {
704
- it('should update error successfully with project from cache', async () => {
705
- const mockProject = { id: 'proj-1', name: 'Project 1' };
706
- mockCache.get.mockReturnValue(mockProject);
707
- mockErrorAPI.updateErrorOnProject.mockResolvedValue({ status: 200 });
708
- client.registerTools(mockServer);
709
- const toolHandler = mockServer.registerTool.mock.calls
710
- .find((call) => call[0] === 'update_error')[2];
711
- const result = await toolHandler({
712
- errorId: 'error-1',
713
- operation: 'fix'
714
- });
715
- expect(mockErrorAPI.updateErrorOnProject).toHaveBeenCalledWith('proj-1', 'error-1', { operation: 'fix' });
716
- expect(result.content[0].text).toBe(JSON.stringify({ success: true }));
717
- });
718
- it('should update error successfully with explicit project ID', async () => {
719
- const mockProject = { id: 'proj-1', name: 'Project 1' };
720
- const mockProjects = [mockProject];
721
- mockCache.get.mockReturnValue(mockProjects);
722
- mockErrorAPI.updateErrorOnProject.mockResolvedValue({ status: 204 });
723
- client.registerTools(mockServer);
724
- const toolHandler = mockServer.registerTool.mock.calls
725
- .find((call) => call[0] === 'update_error')[2];
726
- const result = await toolHandler({
727
- projectId: 'proj-1',
728
- errorId: 'error-1',
729
- operation: 'ignore'
730
- });
731
- expect(mockErrorAPI.updateErrorOnProject).toHaveBeenCalledWith('proj-1', 'error-1', { operation: 'ignore' });
732
- expect(result.content[0].text).toBe(JSON.stringify({ success: true }));
733
- });
734
- it('should handle all permitted operations', async () => {
735
- const mockProject = { id: 'proj-1', name: 'Project 1' };
736
- // Test all operations except override_severity which requires special elicitInput handling
737
- const operations = ['open', 'fix', 'ignore', 'discard', 'undiscard'];
738
- mockCache.get.mockReturnValue(mockProject);
739
- mockErrorAPI.updateErrorOnProject.mockResolvedValue({ status: 200 });
740
- client.registerTools(mockServer);
741
- const toolHandler = mockServer.registerTool.mock.calls
742
- .find((call) => call[0] === 'update_error')[2];
743
- for (const operation of operations) {
744
- await toolHandler({
745
- errorId: 'error-1',
746
- operation: operation
747
- });
748
- expect(mockErrorAPI.updateErrorOnProject).toHaveBeenCalledWith('proj-1', 'error-1', { operation, severity: undefined });
749
- }
750
- expect(mockErrorAPI.updateErrorOnProject).toHaveBeenCalledTimes(operations.length);
751
- });
752
- it('should handle override_severity operation with elicitInput', async () => {
753
- const mockProject = { id: 'proj-1', name: 'Project 1' };
754
- const mockServerWithElicitInput = {
755
- registerTool: vi.fn(),
756
- server: {
757
- elicitInput: vi.fn().mockResolvedValue({
758
- action: 'accept',
759
- content: { severity: 'warning' }
760
- })
761
- }
762
- };
763
- mockCache.get.mockReturnValue(mockProject);
764
- mockErrorAPI.updateErrorOnProject.mockResolvedValue({ status: 200 });
765
- client.registerTools(mockServerWithElicitInput);
766
- const toolHandler = mockServerWithElicitInput.registerTool.mock.calls
767
- .find((call) => call[0] === 'update_error')[2];
768
- const result = await toolHandler({
769
- errorId: 'error-1',
770
- operation: 'override_severity'
771
- });
772
- expect(mockServerWithElicitInput.server.elicitInput).toHaveBeenCalledWith({
773
- message: "Please provide the new severity for the error (e.g. 'info', 'warning', 'error', 'critical')",
774
- requestedSchema: {
775
- type: "object",
776
- properties: {
777
- severity: {
778
- type: "string",
779
- enum: ['info', 'warning', 'error'],
780
- description: "The new severity level for the error"
781
- }
782
- }
783
- },
784
- required: ["severity"]
785
- });
786
- expect(mockErrorAPI.updateErrorOnProject).toHaveBeenCalledWith('proj-1', 'error-1', { operation: 'override_severity', severity: 'warning' });
787
- expect(result.content[0].text).toBe(JSON.stringify({ success: true }));
788
- });
789
- it('should handle override_severity operation when elicitInput is rejected', async () => {
790
- const mockProject = { id: 'proj-1', name: 'Project 1' };
791
- const mockServerWithElicitInput = {
792
- registerTool: vi.fn(),
793
- server: {
794
- elicitInput: vi.fn().mockResolvedValue({
795
- action: 'reject'
796
- })
797
- }
798
- };
799
- mockCache.get.mockReturnValue(mockProject);
800
- mockErrorAPI.updateErrorOnProject.mockResolvedValue({ status: 200 });
801
- client.registerTools(mockServerWithElicitInput);
802
- const toolHandler = mockServerWithElicitInput.registerTool.mock.calls
803
- .find((call) => call[0] === 'update_error')[2];
804
- const result = await toolHandler({
805
- errorId: 'error-1',
806
- operation: 'override_severity'
807
- });
808
- expect(mockErrorAPI.updateErrorOnProject).toHaveBeenCalledWith('proj-1', 'error-1', { operation: 'override_severity', severity: undefined });
809
- expect(result.content[0].text).toBe(JSON.stringify({ success: true }));
810
- });
811
- it('should return false when API returns non-success status', async () => {
812
- const mockProject = { id: 'proj-1', name: 'Project 1' };
813
- mockCache.get.mockReturnValue(mockProject);
814
- mockErrorAPI.updateErrorOnProject.mockResolvedValue({ status: 400 });
815
- client.registerTools(mockServer);
816
- const toolHandler = mockServer.registerTool.mock.calls
817
- .find((call) => call[0] === 'update_error')[2];
818
- const result = await toolHandler({
819
- errorId: 'error-1',
820
- operation: 'fix'
821
- });
822
- expect(result.content[0].text).toBe(JSON.stringify({ success: false }));
823
- });
824
- it('should throw error when no project found', async () => {
825
- mockCache.get.mockReturnValue(null);
826
- client.registerTools(mockServer);
827
- const toolHandler = mockServer.registerTool.mock.calls
828
- .find((call) => call[0] === 'update_error')[2];
829
- await expect(toolHandler({
830
- errorId: 'error-1',
831
- operation: 'fix'
832
- })).rejects.toThrow('No current project found. Please provide a projectId or configure a project API key.');
833
- });
834
- it('should throw error when project ID not found', async () => {
835
- const mockProjects = [{ id: 'proj-1', name: 'Project 1' }];
836
- mockCache.get.mockReturnValue(mockProjects);
837
- client.registerTools(mockServer);
838
- const toolHandler = mockServer.registerTool.mock.calls
839
- .find((call) => call[0] === 'update_error')[2];
840
- await expect(toolHandler({
841
- projectId: 'non-existent-project',
842
- errorId: 'error-1',
843
- operation: 'fix'
844
- })).rejects.toThrow('Project with ID non-existent-project not found.');
845
- });
846
- it('should notify Bugsnag when API call fails', async () => {
847
- const Bugsnag = (await import('../../../common/bugsnag.js')).default;
848
- const mockProject = { id: 'proj-1', name: 'Project 1' };
849
- const mockError = new Error('API error');
850
- mockCache.get.mockReturnValue(mockProject);
851
- mockErrorAPI.updateErrorOnProject.mockRejectedValue(mockError);
852
- client.registerTools(mockServer);
853
- const toolHandler = mockServer.registerTool.mock.calls
854
- .find((call) => call[0] === 'update_error')[2];
855
- await expect(toolHandler({
856
- errorId: 'error-1',
857
- operation: 'fix'
858
- })).rejects.toThrow('API error');
859
- expect(Bugsnag.notify).toHaveBeenCalledWith(mockError);
860
- });
861
- });
862
- describe('error handling in tool handlers', () => {
863
- it('should notify Bugsnag when error occurs in list_insight_hub_projects', async () => {
864
- const Bugsnag = (await import('../../../common/bugsnag.js')).default;
865
- const mockError = new Error('Test error');
866
- mockCache.get.mockImplementation(() => {
867
- throw mockError;
868
- });
869
- client.registerTools(mockServer);
870
- const toolHandler = mockServer.registerTool.mock.calls
871
- .find((call) => call[0] === 'list_insight_hub_projects')[2];
872
- await expect(toolHandler({})).rejects.toThrow('Test error');
873
- expect(Bugsnag.notify).toHaveBeenCalledWith(mockError);
874
- });
875
- it('should notify Bugsnag when error occurs in get_insight_hub_error', async () => {
876
- const Bugsnag = (await import('../../../common/bugsnag.js')).default;
877
- const mockError = new Error('API error');
878
- const mockProject = { id: 'proj-1', name: 'Project 1' };
879
- mockCache.get.mockReturnValue(mockProject);
880
- mockErrorAPI.viewErrorOnProject.mockRejectedValue(mockError);
881
- client.registerTools(mockServer);
882
- const toolHandler = mockServer.registerTool.mock.calls
883
- .find((call) => call[0] === 'get_insight_hub_error')[2];
884
- await expect(toolHandler({ errorId: 'error-1' })).rejects.toThrow('API error');
885
- expect(Bugsnag.notify).toHaveBeenCalledWith(mockError);
886
- });
887
- it('should notify Bugsnag when error occurs in resource handler', async () => {
888
- const Bugsnag = (await import('../../../common/bugsnag.js')).default;
889
- const mockError = new Error('Resource error');
890
- mockCache.get.mockRejectedValue(mockError);
891
- client.registerResources(mockServer);
892
- const resourceHandler = mockServer.resource.mock.calls[0][2];
893
- await expect(resourceHandler({ href: 'insighthub://event/event-1' }, { id: 'event-1' })).rejects.toThrow('Resource error');
894
- expect(Bugsnag.notify).toHaveBeenCalledWith(mockError);
895
- });
896
- });
897
- });
898
- describe('resource handlers', () => {
899
- let mockServer;
900
- beforeEach(() => {
901
- mockServer = {
902
- registerTool: vi.fn(),
903
- resource: vi.fn()
904
- };
905
- });
906
- describe('insight_hub_event resource handler', () => {
907
- it('should find event by ID across projects', async () => {
908
- const mockEvent = { id: 'event-1', project_id: 'proj-1' };
909
- const mockProjects = [{ id: 'proj-1', name: 'Project 1' }];
910
- mockCache.get.mockReturnValueOnce(mockProjects);
911
- mockErrorAPI.viewEventById.mockResolvedValue({ body: mockEvent });
912
- client.registerResources(mockServer);
913
- const resourceHandler = mockServer.resource.mock.calls[0][2];
914
- const result = await resourceHandler({ href: 'insighthub://event/event-1' }, { id: 'event-1' });
915
- expect(result.contents[0].uri).toBe('insighthub://event/event-1');
916
- expect(result.contents[0].text).toBe(JSON.stringify(mockEvent));
917
- });
918
- });
919
- });
920
- });