jira-pat 1.0.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.
Files changed (95) hide show
  1. package/AGENTS.md +218 -0
  2. package/README.md +64 -0
  3. package/backend/.env.example +1 -0
  4. package/backend/__tests__/getJiraClient.test.js +57 -0
  5. package/backend/__tests__/issues.test.js +565 -0
  6. package/backend/__tests__/jiraService.test.js +1127 -0
  7. package/backend/__tests__/projects.test.js +256 -0
  8. package/backend/coverage/clover.xml +426 -0
  9. package/backend/coverage/coverage-final.json +4 -0
  10. package/backend/coverage/lcov-report/base.css +224 -0
  11. package/backend/coverage/lcov-report/block-navigation.js +87 -0
  12. package/backend/coverage/lcov-report/favicon.png +0 -0
  13. package/backend/coverage/lcov-report/index.html +131 -0
  14. package/backend/coverage/lcov-report/prettify.css +1 -0
  15. package/backend/coverage/lcov-report/prettify.js +2 -0
  16. package/backend/coverage/lcov-report/routes/index.html +131 -0
  17. package/backend/coverage/lcov-report/routes/issues.js.html +823 -0
  18. package/backend/coverage/lcov-report/routes/projects.js.html +190 -0
  19. package/backend/coverage/lcov-report/service/index.html +116 -0
  20. package/backend/coverage/lcov-report/service/jiraService.js.html +1663 -0
  21. package/backend/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  22. package/backend/coverage/lcov-report/sorter.js +210 -0
  23. package/backend/coverage/lcov.info +707 -0
  24. package/backend/index.js +38 -0
  25. package/backend/jest.config.js +11 -0
  26. package/backend/package-lock.json +5636 -0
  27. package/backend/package.json +28 -0
  28. package/backend/routes/issues.js +246 -0
  29. package/backend/routes/projects.js +35 -0
  30. package/backend/service/jiraService.js +526 -0
  31. package/bin/jira.js +92 -0
  32. package/frontend/.env.example +1 -0
  33. package/frontend/coverage/base.css +224 -0
  34. package/frontend/coverage/block-navigation.js +87 -0
  35. package/frontend/coverage/clover.xml +559 -0
  36. package/frontend/coverage/components/CreateIssueModal.jsx.html +592 -0
  37. package/frontend/coverage/components/IssueDetailPanel.jsx.html +1633 -0
  38. package/frontend/coverage/components/IssueDrawer.jsx.html +550 -0
  39. package/frontend/coverage/components/IssueTable.jsx.html +571 -0
  40. package/frontend/coverage/components/SkeletonComponents.jsx.html +223 -0
  41. package/frontend/coverage/components/ToastContainer.jsx.html +142 -0
  42. package/frontend/coverage/components/index.html +191 -0
  43. package/frontend/coverage/coverage-final.json +14 -0
  44. package/frontend/coverage/favicon.png +0 -0
  45. package/frontend/coverage/hooks/index.html +161 -0
  46. package/frontend/coverage/hooks/useFocusTrap.js.html +262 -0
  47. package/frontend/coverage/hooks/useIssueDrawer.js.html +1000 -0
  48. package/frontend/coverage/hooks/useIssuesList.js.html +175 -0
  49. package/frontend/coverage/hooks/useToasts.js.html +142 -0
  50. package/frontend/coverage/index.html +161 -0
  51. package/frontend/coverage/prettify.css +1 -0
  52. package/frontend/coverage/prettify.js +2 -0
  53. package/frontend/coverage/services/api.js.html +547 -0
  54. package/frontend/coverage/services/index.html +116 -0
  55. package/frontend/coverage/sort-arrow-sprite.png +0 -0
  56. package/frontend/coverage/sorter.js +210 -0
  57. package/frontend/coverage/utils/index.html +131 -0
  58. package/frontend/coverage/utils/issueHelpers.jsx.html +334 -0
  59. package/frontend/coverage/utils/sanitize.js.html +166 -0
  60. package/frontend/index.html +13 -0
  61. package/frontend/package-lock.json +3436 -0
  62. package/frontend/package.json +30 -0
  63. package/frontend/src/App.jsx +447 -0
  64. package/frontend/src/__tests__/components/CreateIssueModal.test.jsx +375 -0
  65. package/frontend/src/__tests__/components/IssueDetailPanel.test.jsx +962 -0
  66. package/frontend/src/__tests__/components/IssueDrawer.test.jsx +240 -0
  67. package/frontend/src/__tests__/components/IssueTable.test.jsx +423 -0
  68. package/frontend/src/__tests__/components/ToastContainer.test.jsx +196 -0
  69. package/frontend/src/__tests__/hooks/useFocusTrap.test.js +197 -0
  70. package/frontend/src/__tests__/hooks/useIssueDrawer.test.js +1053 -0
  71. package/frontend/src/__tests__/hooks/useIssuesList.test.js +175 -0
  72. package/frontend/src/__tests__/hooks/useToasts.test.js +110 -0
  73. package/frontend/src/__tests__/services/api.test.js +568 -0
  74. package/frontend/src/__tests__/setup.js +54 -0
  75. package/frontend/src/__tests__/utils/issueHelpers.test.jsx +336 -0
  76. package/frontend/src/__tests__/utils/sanitize.test.js +238 -0
  77. package/frontend/src/components/CreateIssueModal.jsx +169 -0
  78. package/frontend/src/components/ErrorBoundary.jsx +52 -0
  79. package/frontend/src/components/IssueDetailPanel.jsx +517 -0
  80. package/frontend/src/components/IssueDrawer.jsx +155 -0
  81. package/frontend/src/components/IssueTable.jsx +162 -0
  82. package/frontend/src/components/SkeletonComponents.jsx +46 -0
  83. package/frontend/src/components/StandaloneIssuePage.jsx +176 -0
  84. package/frontend/src/components/ToastContainer.jsx +19 -0
  85. package/frontend/src/hooks/useFocusTrap.js +59 -0
  86. package/frontend/src/hooks/useIssueDrawer.js +305 -0
  87. package/frontend/src/hooks/useIssuesList.js +30 -0
  88. package/frontend/src/hooks/useToasts.js +19 -0
  89. package/frontend/src/index.css +2070 -0
  90. package/frontend/src/main.jsx +13 -0
  91. package/frontend/src/services/api.js +154 -0
  92. package/frontend/src/utils/issueHelpers.jsx +84 -0
  93. package/frontend/src/utils/sanitize.js +27 -0
  94. package/frontend/vite.config.js +15 -0
  95. package/package.json +19 -0
@@ -0,0 +1,568 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import axios from 'axios';
3
+ import {
4
+ withRetry,
5
+ getIssues,
6
+ fetchIssueDetail,
7
+ fetchComments,
8
+ postComment,
9
+ fetchTransitions,
10
+ transitionIssue,
11
+ fetchAssignableUsers,
12
+ assignIssue,
13
+ uploadAttachment,
14
+ fetchProjects,
15
+ fetchIssueTypes,
16
+ createIssue,
17
+ fetchProjectVersions,
18
+ updateIssue,
19
+ searchLabels
20
+ } from '../../services/api';
21
+
22
+ vi.mock('axios');
23
+
24
+ describe('API Service', () => {
25
+ beforeEach(() => {
26
+ vi.clearAllMocks();
27
+ vi.spyOn(axios, 'isCancel').mockImplementation(() => false);
28
+ });
29
+
30
+ afterEach(() => {
31
+ vi.restoreAllMocks();
32
+ });
33
+
34
+ describe('withRetry', () => {
35
+ it('should return result on first success', async () => {
36
+ const fn = vi.fn().mockResolvedValue('success');
37
+ const result = await withRetry(fn);
38
+
39
+ expect(result).toBe('success');
40
+ expect(fn).toHaveBeenCalledTimes(1);
41
+ });
42
+
43
+ it('should retry on failure and succeed', async () => {
44
+ const fn = vi.fn()
45
+ .mockRejectedValueOnce(new Error('Temporary error'))
46
+ .mockResolvedValueOnce('success');
47
+
48
+ const result = await withRetry(fn, 3, 10);
49
+
50
+ expect(result).toBe('success');
51
+ expect(fn).toHaveBeenCalledTimes(2);
52
+ });
53
+
54
+ it('should retry multiple times before success', async () => {
55
+ const fn = vi.fn()
56
+ .mockRejectedValueOnce(new Error('Error 1'))
57
+ .mockRejectedValueOnce(new Error('Error 2'))
58
+ .mockResolvedValueOnce('success');
59
+
60
+ const result = await withRetry(fn, 3, 10);
61
+
62
+ expect(result).toBe('success');
63
+ expect(fn).toHaveBeenCalledTimes(3);
64
+ });
65
+
66
+ it('should not retry on client errors (4xx)', async () => {
67
+ const error = new Error('Bad Request');
68
+ error.response = { status: 400 };
69
+ const fn = vi.fn().mockRejectedValue(error);
70
+
71
+ await expect(withRetry(fn, 3, 10)).rejects.toThrow('Bad Request');
72
+ expect(fn).toHaveBeenCalledTimes(1);
73
+ });
74
+
75
+ it('should throw after max attempts', async () => {
76
+ const fn = vi.fn().mockRejectedValue(new Error('Server error'));
77
+ fn.mockImplementation(() => Promise.reject(new Error('Server error')));
78
+
79
+ await expect(withRetry(fn, 3, 10)).rejects.toThrow('Server error');
80
+ expect(fn).toHaveBeenCalledTimes(3);
81
+ });
82
+
83
+ it('should respect custom maxAttempts', async () => {
84
+ const fn = vi.fn().mockRejectedValue(new Error('Error'));
85
+
86
+ await expect(withRetry(fn, 5, 10)).rejects.toThrow('Error');
87
+ expect(fn).toHaveBeenCalledTimes(5);
88
+ });
89
+
90
+ it('should throw lastError when all retries fail with 5xx errors', async () => {
91
+ const error = new Error('Server error 500');
92
+ error.response = { status: 500 };
93
+ const fn = vi.fn()
94
+ .mockRejectedValueOnce(error)
95
+ .mockRejectedValueOnce(error)
96
+ .mockRejectedValueOnce(error);
97
+
98
+ await expect(withRetry(fn, 3, 10)).rejects.toThrow('Server error 500');
99
+ expect(fn).toHaveBeenCalledTimes(3);
100
+ });
101
+
102
+ it('should throw lastError after exhausting all attempts', async () => {
103
+ const fn = vi.fn()
104
+ .mockRejectedValueOnce(new Error('Error 1'))
105
+ .mockRejectedValueOnce(new Error('Error 2'))
106
+ .mockRejectedValueOnce(new Error('Error 3'));
107
+
108
+ await expect(withRetry(fn, 3, 10)).rejects.toThrow('Error 3');
109
+ });
110
+ });
111
+
112
+ describe('getIssues', () => {
113
+ it('should fetch issues with params', async () => {
114
+ const mockData = { issues: [{ key: 'TEST-1' }] };
115
+ axios.mockResolvedValue({ data: mockData });
116
+
117
+ const result = await getIssues({ project: 'TEST', status: 'Open' });
118
+
119
+ expect(axios).toHaveBeenCalled();
120
+ expect(result).toEqual(mockData);
121
+ });
122
+
123
+ it('should filter out empty params', async () => {
124
+ axios.mockResolvedValue({ data: { issues: [] } });
125
+
126
+ await getIssues({ project: 'TEST', status: undefined, empty: '' });
127
+
128
+ const call = axios.mock.calls[0][0];
129
+ expect(call.params).toEqual({ project: 'TEST' });
130
+ });
131
+
132
+ it('should handle empty response', async () => {
133
+ axios.mockResolvedValue({ data: {} });
134
+
135
+ const result = await getIssues({ project: 'TEST' });
136
+
137
+ expect(result).toEqual({});
138
+ });
139
+ });
140
+
141
+ describe('fetchIssueDetail', () => {
142
+ it('should fetch issue by key', async () => {
143
+ const mockData = { key: 'TEST-123', fields: { summary: 'Test' } };
144
+ axios.mockResolvedValue({ data: mockData });
145
+
146
+ const result = await fetchIssueDetail('TEST-123');
147
+
148
+ expect(axios).toHaveBeenCalled();
149
+ expect(result).toEqual(mockData);
150
+ });
151
+
152
+ it('should include retry logic', async () => {
153
+ const mockData = { key: 'TEST-123' };
154
+ axios
155
+ .mockRejectedValueOnce(new Error('Network error'))
156
+ .mockResolvedValueOnce({ data: mockData });
157
+
158
+ const result = await fetchIssueDetail('TEST-123');
159
+
160
+ expect(result).toEqual(mockData);
161
+ expect(axios).toHaveBeenCalledTimes(2);
162
+ });
163
+ });
164
+
165
+ describe('fetchComments', () => {
166
+ it('should fetch comments for issue', async () => {
167
+ const mockComments = [{ id: 1, body: 'Comment' }];
168
+ axios.mockResolvedValue({ data: mockComments });
169
+
170
+ const result = await fetchComments('TEST-123');
171
+
172
+ expect(result).toEqual(mockComments);
173
+ });
174
+ });
175
+
176
+ describe('postComment', () => {
177
+ it('should post comment without retry (no withRetry)', async () => {
178
+ const mockComment = { id: 1, body: 'New comment' };
179
+ axios.mockResolvedValue({ data: mockComment });
180
+
181
+ const result = await postComment('TEST-123', 'New comment');
182
+
183
+ expect(result).toEqual(mockComment);
184
+ });
185
+
186
+ it('should include comment body in request', async () => {
187
+ axios.mockResolvedValue({ data: {} });
188
+
189
+ await postComment('TEST-123', 'Test body');
190
+
191
+ const call = axios.mock.calls[0][0];
192
+ expect(call.data).toEqual({ body: 'Test body' });
193
+ });
194
+ });
195
+
196
+ describe('fetchTransitions', () => {
197
+ it('should fetch transitions with retry', async () => {
198
+ const mockTransitions = [{ id: '1', name: 'To Do' }];
199
+ axios
200
+ .mockRejectedValueOnce(new Error('Retry'))
201
+ .mockResolvedValueOnce({ data: mockTransitions });
202
+
203
+ const result = await fetchTransitions('TEST-123');
204
+
205
+ expect(result).toEqual(mockTransitions);
206
+ });
207
+ });
208
+
209
+ describe('transitionIssue', () => {
210
+ it('should transition issue without retry', async () => {
211
+ axios.mockResolvedValue({ data: { success: true } });
212
+
213
+ const result = await transitionIssue('TEST-123', '2');
214
+
215
+ expect(result).toEqual({ success: true });
216
+ });
217
+
218
+ it('should send transitionId in request body', async () => {
219
+ axios.mockResolvedValue({ data: {} });
220
+
221
+ await transitionIssue('TEST-123', 'transition-5');
222
+
223
+ const call = axios.mock.calls[0][0];
224
+ expect(call.data).toEqual({ transitionId: 'transition-5' });
225
+ });
226
+ });
227
+
228
+ describe('fetchAssignableUsers', () => {
229
+ it('should fetch assignable users with query', async () => {
230
+ const mockUsers = [{ accountId: '1', displayName: 'User' }];
231
+ axios.mockResolvedValue({ data: mockUsers });
232
+
233
+ const result = await fetchAssignableUsers('TEST-123', 'john');
234
+
235
+ const call = axios.mock.calls[0][0];
236
+ expect(call.params).toEqual({ query: 'john' });
237
+ expect(result).toEqual(mockUsers);
238
+ });
239
+
240
+ it('should fetch all users when no query provided', async () => {
241
+ axios.mockResolvedValue({ data: [] });
242
+
243
+ await fetchAssignableUsers('TEST-123');
244
+
245
+ const call = axios.mock.calls[0][0];
246
+ expect(call.params).toEqual({ query: undefined });
247
+ });
248
+ });
249
+
250
+ describe('assignIssue', () => {
251
+ it('should assign issue without retry', async () => {
252
+ axios.mockResolvedValue({ data: { success: true } });
253
+
254
+ const result = await assignIssue('TEST-123', 'user-456');
255
+
256
+ expect(result).toEqual({ success: true });
257
+ });
258
+
259
+ it('should send accountId in request body', async () => {
260
+ axios.mockResolvedValue({ data: {} });
261
+
262
+ await assignIssue('TEST-123', 'user-789');
263
+
264
+ const call = axios.mock.calls[0][0];
265
+ expect(call.data).toEqual({ accountId: 'user-789' });
266
+ });
267
+ });
268
+
269
+ describe('uploadAttachment', () => {
270
+ it('should upload file with FormData', async () => {
271
+ axios.post = vi.fn().mockResolvedValue({ data: { success: true } });
272
+ const file = new File(['content'], 'test.pdf', { type: 'application/pdf' });
273
+
274
+ const result = await uploadAttachment('TEST-123', file);
275
+
276
+ expect(result).toEqual({ success: true });
277
+ });
278
+
279
+ it('should throw timeout error for aborted requests', async () => {
280
+ const error = new Error('timeout');
281
+ error.name = 'AbortError';
282
+ axios.post = vi.fn().mockRejectedValue(error);
283
+ const file = new File(['content'], 'test.pdf', { type: 'application/pdf' });
284
+
285
+ await expect(uploadAttachment('TEST-123', file)).rejects.toThrow(
286
+ 'Request timed out. Please check your connection.'
287
+ );
288
+ });
289
+
290
+ it('should set correct content-type header', async () => {
291
+ axios.post = vi.fn().mockResolvedValue({ data: {} });
292
+ const file = new File(['content'], 'test.pdf', { type: 'application/pdf' });
293
+
294
+ await uploadAttachment('TEST-123', file);
295
+
296
+ const call = axios.post.mock.calls[0];
297
+ expect(call[2]?.headers?.['Content-Type']).toBe('multipart/form-data');
298
+ });
299
+
300
+ it('should rethrow non-timeout errors', async () => {
301
+ const error = new Error('Server error');
302
+ axios.post = vi.fn().mockRejectedValue(error);
303
+ const file = new File(['content'], 'test.pdf', { type: 'application/pdf' });
304
+
305
+ await expect(uploadAttachment('TEST-123', file)).rejects.toThrow('Server error');
306
+ });
307
+
308
+ it('should handle ERR_CANCELED error code for non-timeout cancellations', async () => {
309
+ const error = new Error('Network error');
310
+ error.code = 'ERR_CANCELED';
311
+ axios.post = vi.fn().mockRejectedValue(error);
312
+ const file = new File(['content'], 'test.pdf', { type: 'application/pdf' });
313
+
314
+ await expect(uploadAttachment('TEST-123', file)).rejects.toThrow(
315
+ 'Request timed out. Please check your connection.'
316
+ );
317
+ });
318
+ });
319
+
320
+ describe('fetchProjectVersions', () => {
321
+ it('should fetch project versions', async () => {
322
+ const mockVersions = [{ id: '1', name: 'v1.0' }];
323
+ axios.mockResolvedValue({ data: mockVersions });
324
+
325
+ const result = await fetchProjectVersions('TEST');
326
+
327
+ expect(result).toEqual(mockVersions);
328
+ });
329
+ });
330
+
331
+ describe('fetchProjects', () => {
332
+ it('should fetch all projects', async () => {
333
+ const mockProjects = [
334
+ { id: '1', key: 'PROJ', name: 'Project 1' },
335
+ { id: '2', key: 'TEST', name: 'Test Project' }
336
+ ];
337
+ axios.mockResolvedValue({ data: mockProjects });
338
+
339
+ const result = await fetchProjects();
340
+
341
+ expect(result).toEqual(mockProjects);
342
+ });
343
+
344
+ it('should include retry logic', async () => {
345
+ const mockProjects = [{ id: '1', key: 'PROJ', name: 'Project' }];
346
+ axios
347
+ .mockRejectedValueOnce(new Error('Network error'))
348
+ .mockResolvedValueOnce({ data: mockProjects });
349
+
350
+ const result = await fetchProjects();
351
+
352
+ expect(result).toEqual(mockProjects);
353
+ expect(axios).toHaveBeenCalledTimes(2);
354
+ });
355
+
356
+ it('should handle empty projects list', async () => {
357
+ axios.mockResolvedValue({ data: [] });
358
+
359
+ const result = await fetchProjects();
360
+
361
+ expect(result).toEqual([]);
362
+ });
363
+
364
+ it('should handle server error with retry', async () => {
365
+ const error = new Error('Internal Server Error');
366
+ error.response = { status: 500 };
367
+ axios
368
+ .mockRejectedValueOnce(error)
369
+ .mockRejectedValueOnce(error)
370
+ .mockResolvedValueOnce({ data: [{ key: 'PROJ' }] });
371
+
372
+ const result = await fetchProjects();
373
+
374
+ expect(result).toEqual([{ key: 'PROJ' }]);
375
+ });
376
+ });
377
+
378
+ describe('fetchIssueTypes', () => {
379
+ it('should fetch issue types for project', async () => {
380
+ const mockIssueTypes = [
381
+ { id: '1', name: 'Bug' },
382
+ { id: '2', name: 'Story' }
383
+ ];
384
+ axios.mockResolvedValue({ data: mockIssueTypes });
385
+
386
+ const result = await fetchIssueTypes('TEST');
387
+
388
+ expect(result).toEqual(mockIssueTypes);
389
+ });
390
+
391
+ it('should include project key in URL', async () => {
392
+ axios.mockResolvedValue({ data: [] });
393
+
394
+ await fetchIssueTypes('MYPROJ');
395
+
396
+ const call = axios.mock.calls[0][0];
397
+ expect(call.url).toContain('/projects/MYPROJ/issuetypes');
398
+ });
399
+
400
+ it('should include retry logic', async () => {
401
+ const mockIssueTypes = [{ id: '1', name: 'Task' }];
402
+ axios
403
+ .mockRejectedValueOnce(new Error('Timeout'))
404
+ .mockResolvedValueOnce({ data: mockIssueTypes });
405
+
406
+ const result = await fetchIssueTypes('TEST');
407
+
408
+ expect(result).toEqual(mockIssueTypes);
409
+ });
410
+
411
+ it('should throw on client error without retry', async () => {
412
+ const error = new Error('Not Found');
413
+ error.response = { status: 404 };
414
+ axios.mockRejectedValue(error);
415
+
416
+ await expect(fetchIssueTypes('INVALID')).rejects.toThrow('Not Found');
417
+ expect(axios).toHaveBeenCalledTimes(1);
418
+ });
419
+
420
+ it('should handle null response data', async () => {
421
+ axios.mockResolvedValue({ data: null });
422
+
423
+ const result = await fetchIssueTypes('TEST');
424
+
425
+ expect(result).toBeNull();
426
+ });
427
+ });
428
+
429
+ describe('createIssue', () => {
430
+ it('should create issue with provided data', async () => {
431
+ const issueData = {
432
+ project: { key: 'TEST' },
433
+ summary: 'New Issue',
434
+ issuetype: { name: 'Task' }
435
+ };
436
+ const mockResponse = { id: '12345', key: 'TEST-100', ...issueData };
437
+ axios.mockResolvedValue({ data: mockResponse });
438
+
439
+ const result = await createIssue(issueData);
440
+
441
+ expect(result).toEqual(mockResponse);
442
+ });
443
+
444
+ it('should send issue data in request body', async () => {
445
+ const issueData = {
446
+ project: { key: 'PROJ' },
447
+ summary: 'Test Summary',
448
+ description: 'Test Description',
449
+ issuetype: { name: 'Bug' }
450
+ };
451
+ axios.mockResolvedValue({ data: {} });
452
+
453
+ await createIssue(issueData);
454
+
455
+ const call = axios.mock.calls[0][0];
456
+ expect(call.data).toEqual(issueData);
457
+ });
458
+
459
+ it('should handle validation error response', async () => {
460
+ const error = new Error('Validation Failed');
461
+ error.response = { status: 400, data: { errorMessages: ['Summary is required'] } };
462
+ axios.mockRejectedValue(error);
463
+
464
+ await expect(createIssue({})).rejects.toThrow('Validation Failed');
465
+ });
466
+
467
+ it('should handle empty issue data', async () => {
468
+ axios.mockResolvedValue({ data: { id: '1' } });
469
+
470
+ const result = await createIssue({});
471
+
472
+ const call = axios.mock.calls[0][0];
473
+ expect(call.data).toEqual({});
474
+ expect(result).toEqual({ id: '1' });
475
+ });
476
+
477
+ it('should handle unauthorized error', async () => {
478
+ const error = new Error('Unauthorized');
479
+ error.response = { status: 401 };
480
+ axios.mockRejectedValue(error);
481
+
482
+ await expect(createIssue({ project: { key: 'TEST' } })).rejects.toThrow('Unauthorized');
483
+ });
484
+
485
+ it('should handle server error', async () => {
486
+ const error = new Error('Internal Server Error');
487
+ error.response = { status: 500 };
488
+ axios.mockRejectedValue(error);
489
+
490
+ await expect(createIssue({ project: { key: 'TEST' } })).rejects.toThrow('Internal Server Error');
491
+ });
492
+ });
493
+
494
+ describe('updateIssue', () => {
495
+ it('should update issue fields', async () => {
496
+ axios.mockResolvedValue({ data: { success: true } });
497
+
498
+ const result = await updateIssue('TEST-123', { summary: 'New summary' });
499
+
500
+ const call = axios.mock.calls[0][0];
501
+ expect(call.data).toEqual({ fields: { summary: 'New summary' } });
502
+ expect(result).toEqual({ success: true });
503
+ });
504
+
505
+ it('should send multiple fields', async () => {
506
+ axios.mockResolvedValue({ data: {} });
507
+
508
+ await updateIssue('TEST-123', {
509
+ summary: 'Summary',
510
+ description: 'Description',
511
+ labels: ['bug']
512
+ });
513
+
514
+ const call = axios.mock.calls[0][0];
515
+ expect(call.data.fields).toEqual({
516
+ summary: 'Summary',
517
+ description: 'Description',
518
+ labels: ['bug']
519
+ });
520
+ });
521
+ });
522
+
523
+ describe('searchLabels', () => {
524
+ it('should search labels with query', async () => {
525
+ const mockLabels = ['bug', 'feature', 'urgent'];
526
+ axios.mockResolvedValue({ data: mockLabels });
527
+
528
+ const result = await searchLabels('bu');
529
+
530
+ const call = axios.mock.calls[0][0];
531
+ expect(call.params).toEqual({ query: 'bu' });
532
+ expect(result).toEqual(mockLabels);
533
+ });
534
+
535
+ it('should include retry logic', async () => {
536
+ const mockLabels = ['test'];
537
+ axios
538
+ .mockRejectedValueOnce(new Error('Network error'))
539
+ .mockResolvedValueOnce({ data: mockLabels });
540
+
541
+ const result = await searchLabels('test');
542
+
543
+ expect(result).toEqual(mockLabels);
544
+ });
545
+ });
546
+
547
+ describe('timeout handling', () => {
548
+ it('should handle timeout error for regular requests', async () => {
549
+ const error = new Error('Request timeout');
550
+ error.name = 'AbortError';
551
+ axios.mockRejectedValue(error);
552
+
553
+ await expect(getIssues({ project: 'TEST' })).rejects.toThrow(
554
+ 'Request timed out. Please check your connection.'
555
+ );
556
+ });
557
+
558
+ it('should handle ERR_CANCELED error code', async () => {
559
+ const error = new Error('Canceled');
560
+ error.code = 'ERR_CANCELED';
561
+ axios.mockRejectedValue(error);
562
+
563
+ await expect(fetchIssueDetail('TEST-123')).rejects.toThrow(
564
+ 'Request timed out. Please check your connection.'
565
+ );
566
+ });
567
+ });
568
+ });
@@ -0,0 +1,54 @@
1
+ import '@testing-library/jest-dom';
2
+ import { vi } from 'vitest';
3
+ import React from 'react';
4
+
5
+ Object.defineProperty(window, 'matchMedia', {
6
+ writable: true,
7
+ value: vi.fn().mockImplementation(query => ({
8
+ matches: false,
9
+ media: query,
10
+ onchange: null,
11
+ addListener: vi.fn(),
12
+ removeListener: vi.fn(),
13
+ addEventListener: vi.fn(),
14
+ removeEventListener: vi.fn(),
15
+ dispatchEvent: vi.fn(),
16
+ })),
17
+ });
18
+
19
+ vi.mock('lucide-react', () => {
20
+ const createIconMock = (testId) => {
21
+ return (props) => React.createElement('div', {
22
+ 'data-testid': testId,
23
+ className: props?.className,
24
+ ...props
25
+ });
26
+ };
27
+
28
+ const icons = {
29
+ Inbox: createIconMock('inbox-icon'),
30
+ Bug: createIconMock('bug-icon'),
31
+ FileText: createIconMock('filetext-icon'),
32
+ Layers: createIconMock('layers-icon'),
33
+ ClipboardList: createIconMock('clipboard-icon'),
34
+ ChevronUp: createIconMock('chevron-up-icon'),
35
+ ChevronDown: createIconMock('chevron-down-icon'),
36
+ Minus: createIconMock('minus-icon'),
37
+ AlertCircle: createIconMock('alert-icon'),
38
+ Info: createIconMock('info-icon'),
39
+ X: () => React.createElement('button', { 'aria-label': 'Close' }),
40
+ Link2: createIconMock('link-icon'),
41
+ ExternalLink: createIconMock('external-link-icon'),
42
+ CheckCircle: createIconMock('check-icon'),
43
+ Loader2: createIconMock('loader-icon'),
44
+ Paperclip: createIconMock('paperclip-icon'),
45
+ Calendar: createIconMock('calendar-icon'),
46
+ Package: createIconMock('package-icon'),
47
+ PlusCircle: createIconMock('plus-icon'),
48
+ };
49
+
50
+ return {
51
+ ...icons,
52
+ default: ({ children, ...props }) => React.createElement('div', props, children),
53
+ };
54
+ });