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,1053 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { renderHook, waitFor } from '@testing-library/react';
3
+ import { useIssueDrawer } from '../../hooks/useIssueDrawer';
4
+ import * as api from '../../services/api';
5
+
6
+ vi.mock('../../services/api', () => ({
7
+ fetchIssueDetail: vi.fn(),
8
+ fetchComments: vi.fn(),
9
+ fetchTransitions: vi.fn(),
10
+ fetchAssignableUsers: vi.fn(),
11
+ postComment: vi.fn(),
12
+ transitionIssue: vi.fn(),
13
+ assignIssue: vi.fn(),
14
+ uploadAttachment: vi.fn(),
15
+ fetchProjectVersions: vi.fn(),
16
+ updateIssue: vi.fn(),
17
+ searchLabels: vi.fn()
18
+ }));
19
+
20
+ const mockIssueDetail = {
21
+ key: 'TEST-123',
22
+ fields: {
23
+ summary: 'Test Issue',
24
+ status: { name: 'To Do' },
25
+ project: { key: 'TEST' },
26
+ labels: ['bug', 'urgent'],
27
+ assignee: null
28
+ }
29
+ };
30
+
31
+ const mockComments = [
32
+ { id: 1, body: 'Comment 1' },
33
+ { id: 2, body: 'Comment 2' }
34
+ ];
35
+
36
+ const mockTransitions = [
37
+ { id: '1', name: 'To Do' },
38
+ { id: '2', name: 'In Progress' }
39
+ ];
40
+
41
+ const mockUsers = [
42
+ { accountId: 'user1', displayName: 'User One' },
43
+ { accountId: 'user2', displayName: 'User Two' }
44
+ ];
45
+
46
+ describe('useIssueDrawer', () => {
47
+ const mockAddToast = vi.fn();
48
+ const mockRefetch = vi.fn();
49
+
50
+ beforeEach(() => {
51
+ vi.clearAllMocks();
52
+
53
+ api.fetchIssueDetail.mockResolvedValue(mockIssueDetail);
54
+ api.fetchComments.mockResolvedValue(mockComments);
55
+ api.fetchTransitions.mockResolvedValue(mockTransitions);
56
+ api.fetchAssignableUsers.mockResolvedValue(mockUsers);
57
+ api.fetchProjectVersions.mockResolvedValue([]);
58
+ });
59
+
60
+ afterEach(() => {
61
+ vi.restoreAllMocks();
62
+ });
63
+
64
+ it('should initialize with null issueDetail and empty arrays', () => {
65
+ const { result } = renderHook(() => useIssueDrawer(null, mockAddToast, mockRefetch));
66
+
67
+ expect(result.current.issueDetail).toBe(null);
68
+ expect(result.current.comments).toEqual([]);
69
+ expect(result.current.transitions).toEqual([]);
70
+ expect(result.current.assignableUsers).toEqual([]);
71
+ expect(result.current.drawerLoading).toBe(false);
72
+ expect(result.current.drawerError).toBe(null);
73
+ });
74
+
75
+ it('should not fetch data when issueKey is null', () => {
76
+ renderHook(() => useIssueDrawer(null, mockAddToast, mockRefetch));
77
+
78
+ expect(api.fetchIssueDetail).not.toHaveBeenCalled();
79
+ });
80
+
81
+ it('should fetch issue data when issueKey is provided', async () => {
82
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
83
+
84
+ await waitFor(() => {
85
+ expect(result.current.drawerLoading).toBe(false);
86
+ });
87
+
88
+ expect(api.fetchIssueDetail).toHaveBeenCalledWith('TEST-123');
89
+ expect(api.fetchComments).toHaveBeenCalledWith('TEST-123');
90
+ expect(api.fetchTransitions).toHaveBeenCalledWith('TEST-123');
91
+ expect(api.fetchAssignableUsers).toHaveBeenCalledWith('TEST-123');
92
+ });
93
+
94
+ it('should set issue detail and related data on successful fetch', async () => {
95
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
96
+
97
+ await waitFor(() => {
98
+ expect(result.current.issueDetail).toEqual(mockIssueDetail);
99
+ });
100
+
101
+ expect(result.current.comments).toEqual(mockComments);
102
+ expect(result.current.transitions).toEqual(mockTransitions);
103
+ expect(result.current.assignableUsers).toEqual(mockUsers);
104
+ });
105
+
106
+ it('should handle fetch errors gracefully', async () => {
107
+ api.fetchIssueDetail.mockRejectedValue(new Error('Network error'));
108
+
109
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
110
+
111
+ await waitFor(() => {
112
+ expect(result.current.drawerLoading).toBe(false);
113
+ });
114
+
115
+ expect(result.current.drawerError).toBe('Network error');
116
+ });
117
+
118
+ it('should use fallback error message when error has no message', async () => {
119
+ api.fetchIssueDetail.mockRejectedValue(new Error());
120
+
121
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
122
+
123
+ await waitFor(() => {
124
+ expect(result.current.drawerLoading).toBe(false);
125
+ });
126
+
127
+ expect(result.current.drawerError).toBe('Failed to load issue details');
128
+ });
129
+
130
+ it('should continue if transitions fetch fails', async () => {
131
+ api.fetchTransitions.mockRejectedValue(new Error('Transitions error'));
132
+
133
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
134
+
135
+ await waitFor(() => {
136
+ expect(result.current.drawerLoading).toBe(false);
137
+ });
138
+
139
+ expect(result.current.drawerError).toBe(null);
140
+ expect(result.current.issueDetail).toEqual(mockIssueDetail);
141
+ });
142
+
143
+ it('should not post empty comment', async () => {
144
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
145
+
146
+ await waitFor(() => {
147
+ expect(result.current.drawerLoading).toBe(false);
148
+ });
149
+
150
+ await result.current.handlePostComment();
151
+
152
+ expect(api.postComment).not.toHaveBeenCalled();
153
+ });
154
+
155
+ it('should not assign without accountId', async () => {
156
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
157
+
158
+ await waitFor(() => {
159
+ expect(result.current.drawerLoading).toBe(false);
160
+ });
161
+
162
+ await result.current.handleAssignUser(null, 'User One');
163
+
164
+ expect(api.assignIssue).not.toHaveBeenCalled();
165
+ });
166
+
167
+ it('should not update status without transitionId', async () => {
168
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
169
+
170
+ await waitFor(() => {
171
+ expect(result.current.drawerLoading).toBe(false);
172
+ });
173
+
174
+ await result.current.handleUpdateStatus(null, 'In Progress');
175
+
176
+ expect(api.transitionIssue).not.toHaveBeenCalled();
177
+ });
178
+
179
+ it('should not update version without issueKey', async () => {
180
+ const { result } = renderHook(() => useIssueDrawer(null, mockAddToast, mockRefetch));
181
+
182
+ await result.current.handleUpdateVersion('1');
183
+
184
+ expect(api.updateIssue).not.toHaveBeenCalled();
185
+ });
186
+
187
+ it('should reject disallowed file types', async () => {
188
+ const disallowedFile = new File(['content'], 'test.exe', { type: 'application/x-executable' });
189
+
190
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
191
+
192
+ await waitFor(() => {
193
+ expect(result.current.drawerLoading).toBe(false);
194
+ });
195
+
196
+ const mockEvent = {
197
+ target: {
198
+ files: [disallowedFile],
199
+ value: ''
200
+ }
201
+ };
202
+
203
+ await result.current.handleFileUpload(mockEvent);
204
+
205
+ expect(mockAddToast).toHaveBeenCalledWith('File type not allowed', 'error');
206
+ expect(api.uploadAttachment).not.toHaveBeenCalled();
207
+ });
208
+
209
+ describe('handlePostComment', () => {
210
+ it('should successfully post a comment', async () => {
211
+ const postedComment = { id: 3, body: 'New comment' };
212
+ api.postComment.mockResolvedValue(postedComment);
213
+
214
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
215
+
216
+ await waitFor(() => {
217
+ expect(result.current.drawerLoading).toBe(false);
218
+ });
219
+
220
+ await waitFor(() => {
221
+ result.current.setNewCommentText('New comment');
222
+ });
223
+ await waitFor(() => {
224
+ expect(result.current.newCommentText).toBe('New comment');
225
+ });
226
+ await result.current.handlePostComment();
227
+
228
+ await waitFor(() => {
229
+ expect(result.current.comments).toHaveLength(3);
230
+ expect(result.current.comments[2]).toEqual(postedComment);
231
+ });
232
+ expect(result.current.newCommentText).toBe('');
233
+ expect(mockAddToast).toHaveBeenCalledWith('Comment posted', 'success');
234
+ });
235
+
236
+ it('should handle comment post error', async () => {
237
+ api.postComment.mockRejectedValue(new Error('Comment failed'));
238
+
239
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
240
+
241
+ await waitFor(() => {
242
+ expect(result.current.drawerLoading).toBe(false);
243
+ });
244
+
245
+ await waitFor(() => {
246
+ result.current.setNewCommentText('New comment');
247
+ });
248
+ await waitFor(() => {
249
+ expect(result.current.newCommentText).toBe('New comment');
250
+ });
251
+ await result.current.handlePostComment();
252
+
253
+ await waitFor(() => {
254
+ expect(mockAddToast).toHaveBeenCalledWith('Error posting comment: Comment failed', 'error');
255
+ });
256
+ });
257
+
258
+ it('should set isPostingComment while posting', async () => {
259
+ let resolveComment;
260
+ api.postComment.mockImplementation(() => new Promise(r => resolveComment = r));
261
+
262
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
263
+
264
+ await waitFor(() => {
265
+ expect(result.current.drawerLoading).toBe(false);
266
+ });
267
+
268
+ await waitFor(() => {
269
+ result.current.setNewCommentText('New comment');
270
+ });
271
+ await waitFor(() => {
272
+ expect(result.current.newCommentText).toBe('New comment');
273
+ });
274
+ const postPromise = result.current.handlePostComment();
275
+ await waitFor(() => {
276
+ expect(result.current.isPostingComment).toBe(true);
277
+ });
278
+ resolveComment({ id: 3, body: 'New comment' });
279
+ await postPromise;
280
+ await waitFor(() => {
281
+ expect(result.current.isPostingComment).toBe(false);
282
+ });
283
+ });
284
+ });
285
+
286
+ describe('handleAssignUser', () => {
287
+ it('should successfully assign a user', async () => {
288
+ api.assignIssue.mockResolvedValue(undefined);
289
+ api.fetchIssueDetail.mockResolvedValue({ ...mockIssueDetail, fields: { ...mockIssueDetail.fields, assignee: { displayName: 'User One' } } });
290
+
291
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
292
+
293
+ await waitFor(() => {
294
+ expect(result.current.drawerLoading).toBe(false);
295
+ });
296
+
297
+ await result.current.handleAssignUser('user1', 'User One');
298
+
299
+ expect(api.assignIssue).toHaveBeenCalledWith('TEST-123', 'user1');
300
+ expect(mockAddToast).toHaveBeenCalledWith('Issue assigned successfully', 'success');
301
+ expect(mockRefetch).toHaveBeenCalled();
302
+ });
303
+
304
+ it('should handle assign error', async () => {
305
+ api.assignIssue.mockRejectedValue(new Error('Assign failed'));
306
+
307
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
308
+
309
+ await waitFor(() => {
310
+ expect(result.current.drawerLoading).toBe(false);
311
+ });
312
+
313
+ await result.current.handleAssignUser('user1', 'User One');
314
+
315
+ expect(result.current.optimisticAssignee).toBe(null);
316
+ expect(mockAddToast).toHaveBeenCalledWith('Failed to assign: Assign failed', 'error');
317
+ });
318
+
319
+ it('should clear assigneeSearch and targetAccountId on assign', async () => {
320
+ api.assignIssue.mockResolvedValue(undefined);
321
+ api.fetchIssueDetail.mockResolvedValue(mockIssueDetail);
322
+
323
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
324
+
325
+ await waitFor(() => {
326
+ expect(result.current.drawerLoading).toBe(false);
327
+ });
328
+
329
+ result.current.setAssigneeSearch('search');
330
+ result.current.setTargetAccountId('user1');
331
+ await result.current.handleAssignUser('user1', 'User One');
332
+
333
+ expect(result.current.assigneeSearch).toBe('');
334
+ expect(result.current.targetAccountId).toBe('');
335
+ });
336
+
337
+ it('should set isAssigningOptimistic during assignment', async () => {
338
+ let resolveAssign;
339
+ api.assignIssue.mockImplementation(() => new Promise(r => resolveAssign = r));
340
+
341
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
342
+
343
+ await waitFor(() => {
344
+ expect(result.current.drawerLoading).toBe(false);
345
+ });
346
+
347
+ const assignPromise = result.current.handleAssignUser('user1', 'User One');
348
+ await waitFor(() => {
349
+ expect(result.current.isAssigningOptimistic).toBe(true);
350
+ });
351
+ resolveAssign(undefined);
352
+ await assignPromise;
353
+ await waitFor(() => {
354
+ expect(result.current.isAssigningOptimistic).toBe(false);
355
+ });
356
+ });
357
+ });
358
+
359
+ describe('handleUpdateStatus', () => {
360
+ it('should successfully update status', async () => {
361
+ api.transitionIssue.mockResolvedValue(undefined);
362
+ api.fetchIssueDetail.mockResolvedValue({ ...mockIssueDetail, fields: { ...mockIssueDetail.fields, status: { name: 'In Progress' } } });
363
+ api.fetchTransitions.mockResolvedValue([]);
364
+
365
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
366
+
367
+ await waitFor(() => {
368
+ expect(result.current.drawerLoading).toBe(false);
369
+ });
370
+
371
+ await result.current.handleUpdateStatus('2', 'In Progress');
372
+
373
+ await waitFor(() => {
374
+ expect(api.transitionIssue).toHaveBeenCalledWith('TEST-123', '2');
375
+ });
376
+ expect(result.current.optimisticStatus).toBe('In Progress');
377
+ expect(mockAddToast).toHaveBeenCalledWith('Status updated successfully', 'success');
378
+ expect(mockRefetch).toHaveBeenCalled();
379
+ });
380
+
381
+ it('should handle status update error', async () => {
382
+ api.transitionIssue.mockRejectedValue(new Error('Transition failed'));
383
+
384
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
385
+
386
+ await waitFor(() => {
387
+ expect(result.current.drawerLoading).toBe(false);
388
+ });
389
+
390
+ await result.current.handleUpdateStatus('2', 'In Progress');
391
+
392
+ expect(result.current.optimisticStatus).toBe(null);
393
+ expect(mockAddToast).toHaveBeenCalledWith('Failed to update status: Transition failed', 'error');
394
+ });
395
+
396
+ it('should set targetTransitionId to empty after update', async () => {
397
+ api.transitionIssue.mockResolvedValue(undefined);
398
+ api.fetchIssueDetail.mockResolvedValue(mockIssueDetail);
399
+ api.fetchTransitions.mockResolvedValue([]);
400
+
401
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
402
+
403
+ await waitFor(() => {
404
+ expect(result.current.drawerLoading).toBe(false);
405
+ });
406
+
407
+ result.current.setTargetTransitionId('2');
408
+ await result.current.handleUpdateStatus('2', 'In Progress');
409
+ expect(result.current.targetTransitionId).toBe('');
410
+ });
411
+
412
+ it('should set isTransitioningOptimistic during transition', async () => {
413
+ let resolveTransition;
414
+ api.transitionIssue.mockImplementation(() => new Promise(r => resolveTransition = r));
415
+
416
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
417
+
418
+ await waitFor(() => {
419
+ expect(result.current.drawerLoading).toBe(false);
420
+ });
421
+
422
+ const transitionPromise = result.current.handleUpdateStatus('2', 'In Progress');
423
+ await waitFor(() => {
424
+ expect(result.current.isTransitioningOptimistic).toBe(true);
425
+ });
426
+ resolveTransition(undefined);
427
+ await transitionPromise;
428
+ await waitFor(() => {
429
+ expect(result.current.isTransitioningOptimistic).toBe(false);
430
+ });
431
+ });
432
+ });
433
+
434
+ describe('handleFileUpload', () => {
435
+ it('should reject files larger than 10MB', async () => {
436
+ const largeFile = new File(['x'.repeat(11 * 1024 * 1024)], 'large.pdf', { type: 'application/pdf' });
437
+
438
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
439
+
440
+ await waitFor(() => {
441
+ expect(result.current.drawerLoading).toBe(false);
442
+ });
443
+
444
+ const mockEvent = {
445
+ target: {
446
+ files: [largeFile],
447
+ value: 'file-input-value'
448
+ }
449
+ };
450
+
451
+ await result.current.handleFileUpload(mockEvent);
452
+
453
+ await waitFor(() => {
454
+ expect(result.current.uploadMessage).toBe('Error: File size exceeds 10MB limit');
455
+ });
456
+ expect(api.uploadAttachment).not.toHaveBeenCalled();
457
+ });
458
+
459
+ it('should successfully upload a file', async () => {
460
+ const validFile = new File(['content'], 'test.pdf', { type: 'application/pdf' });
461
+ api.uploadAttachment.mockResolvedValue(undefined);
462
+ api.fetchIssueDetail.mockResolvedValue(mockIssueDetail);
463
+
464
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
465
+
466
+ await waitFor(() => {
467
+ expect(result.current.drawerLoading).toBe(false);
468
+ });
469
+
470
+ const mockEvent = {
471
+ target: {
472
+ files: [validFile],
473
+ value: 'file-input-value'
474
+ }
475
+ };
476
+
477
+ await result.current.handleFileUpload(mockEvent);
478
+
479
+ await waitFor(() => {
480
+ expect(api.uploadAttachment).toHaveBeenCalledWith('TEST-123', validFile);
481
+ });
482
+ await waitFor(() => {
483
+ expect(result.current.uploadMessage).toBe('File uploaded successfully');
484
+ });
485
+ expect(mockAddToast).toHaveBeenCalledWith('File attached successfully', 'success');
486
+ });
487
+
488
+ it('should handle upload error', async () => {
489
+ const validFile = new File(['content'], 'test.pdf', { type: 'application/pdf' });
490
+ api.uploadAttachment.mockRejectedValue(new Error('Upload failed'));
491
+
492
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
493
+
494
+ await waitFor(() => {
495
+ expect(result.current.drawerLoading).toBe(false);
496
+ });
497
+
498
+ const mockEvent = {
499
+ target: {
500
+ files: [validFile],
501
+ value: 'file-input-value'
502
+ }
503
+ };
504
+
505
+ await result.current.handleFileUpload(mockEvent);
506
+
507
+ await waitFor(() => {
508
+ expect(result.current.uploadMessage).toBe('Error uploading file: Upload failed');
509
+ });
510
+ expect(mockAddToast).toHaveBeenCalledWith('Error attaching file: Upload failed', 'error');
511
+ });
512
+
513
+ it('should clear file input after upload', async () => {
514
+ const validFile = new File(['content'], 'test.pdf', { type: 'application/pdf' });
515
+ api.uploadAttachment.mockResolvedValue(undefined);
516
+ api.fetchIssueDetail.mockResolvedValue(mockIssueDetail);
517
+
518
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
519
+
520
+ await waitFor(() => {
521
+ expect(result.current.drawerLoading).toBe(false);
522
+ });
523
+
524
+ const mockEvent = {
525
+ target: {
526
+ files: [validFile],
527
+ value: 'file-input-value'
528
+ }
529
+ };
530
+
531
+ await result.current.handleFileUpload(mockEvent);
532
+
533
+ expect(mockEvent.target.value).toBe('');
534
+ });
535
+
536
+ it('should accept valid file types', async () => {
537
+ const validFile = new File(['content'], 'test.pdf', { type: 'application/pdf' });
538
+ api.uploadAttachment.mockResolvedValue(undefined);
539
+ api.fetchIssueDetail.mockResolvedValue(mockIssueDetail);
540
+
541
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
542
+
543
+ await waitFor(() => {
544
+ expect(result.current.drawerLoading).toBe(false);
545
+ });
546
+
547
+ const mockEvent = {
548
+ target: {
549
+ files: [validFile],
550
+ value: ''
551
+ }
552
+ };
553
+
554
+ await result.current.handleFileUpload(mockEvent);
555
+ expect(api.uploadAttachment).toHaveBeenCalled();
556
+ });
557
+ });
558
+
559
+ describe('handleUpdateVersion', () => {
560
+ it('should successfully update version', async () => {
561
+ api.updateIssue.mockResolvedValue(undefined);
562
+ api.fetchIssueDetail.mockResolvedValue({ ...mockIssueDetail, fields: { ...mockIssueDetail.fields, fixVersions: [{ id: '1', name: 'v1.0' }] } });
563
+
564
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
565
+
566
+ await waitFor(() => {
567
+ expect(result.current.issueDetail).toBeDefined();
568
+ });
569
+
570
+ await result.current.handleUpdateVersion('1');
571
+
572
+ expect(api.updateIssue).toHaveBeenCalledWith('TEST-123', { fixVersions: [{ id: '1' }] });
573
+ expect(mockAddToast).toHaveBeenCalledWith('Fix version updated', 'success');
574
+ });
575
+
576
+ it('should handle version update error', async () => {
577
+ api.updateIssue.mockRejectedValue(new Error('Update failed'));
578
+
579
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
580
+
581
+ await waitFor(() => {
582
+ expect(result.current.issueDetail).toBeDefined();
583
+ });
584
+
585
+ await result.current.handleUpdateVersion('1');
586
+
587
+ expect(mockAddToast).toHaveBeenCalledWith('Error updating version: Update failed', 'error');
588
+ });
589
+
590
+ it('should clear version when null versionId provided', async () => {
591
+ api.updateIssue.mockResolvedValue(undefined);
592
+ api.fetchIssueDetail.mockResolvedValue({ ...mockIssueDetail, fields: { ...mockIssueDetail.fields, fixVersions: [] } });
593
+
594
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
595
+
596
+ await waitFor(() => {
597
+ expect(result.current.issueDetail).toBeDefined();
598
+ });
599
+
600
+ await result.current.handleUpdateVersion(null);
601
+
602
+ expect(api.updateIssue).toHaveBeenCalledWith('TEST-123', { fixVersions: [] });
603
+ });
604
+
605
+ it('should set isUpdatingVersions during update', async () => {
606
+ api.updateIssue.mockResolvedValue(undefined);
607
+ api.fetchIssueDetail.mockResolvedValue({ ...mockIssueDetail, fields: { ...mockIssueDetail.fields, fixVersions: [{ id: '1' }] } });
608
+
609
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
610
+
611
+ await waitFor(() => {
612
+ expect(result.current.issueDetail).toBeDefined();
613
+ });
614
+
615
+ await result.current.handleUpdateVersion('1');
616
+
617
+ await waitFor(() => {
618
+ expect(result.current.isUpdatingVersions).toBe(false);
619
+ });
620
+ });
621
+ });
622
+
623
+ describe('handleAddLabel', () => {
624
+ it('should successfully add a label', async () => {
625
+ api.updateIssue.mockResolvedValue(undefined);
626
+ api.fetchIssueDetail.mockResolvedValue({ ...mockIssueDetail, fields: { ...mockIssueDetail.fields, labels: ['bug', 'urgent'] } });
627
+
628
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
629
+
630
+ await waitFor(() => {
631
+ expect(result.current.issueDetail).toBeDefined();
632
+ });
633
+
634
+ await result.current.handleAddLabel('new-label');
635
+
636
+ expect(api.updateIssue).toHaveBeenCalledWith('TEST-123', { labels: ['bug', 'urgent', 'new-label'] });
637
+ expect(mockAddToast).toHaveBeenCalledWith('Label added', 'success');
638
+ expect(result.current.newLabelText).toBe('');
639
+ expect(result.current.showLabelSuggestions).toBe(false);
640
+ });
641
+
642
+ it('should handle add label error', async () => {
643
+ api.updateIssue.mockRejectedValue(new Error('Label update failed'));
644
+
645
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
646
+
647
+ await waitFor(() => {
648
+ expect(result.current.issueDetail).toBeDefined();
649
+ });
650
+
651
+ await result.current.handleAddLabel('new-label');
652
+
653
+ expect(mockAddToast).toHaveBeenCalledWith('Error adding label: Label update failed', 'error');
654
+ });
655
+
656
+ it('should not add duplicate label', async () => {
657
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
658
+
659
+ await waitFor(() => {
660
+ expect(result.current.issueDetail).toBeDefined();
661
+ });
662
+
663
+ await result.current.handleAddLabel('bug');
664
+
665
+ expect(api.updateIssue).not.toHaveBeenCalled();
666
+ });
667
+
668
+ it('should not add empty label', async () => {
669
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
670
+
671
+ await waitFor(() => {
672
+ expect(result.current.issueDetail).toBeDefined();
673
+ });
674
+
675
+ await result.current.handleAddLabel('');
676
+
677
+ expect(api.updateIssue).not.toHaveBeenCalled();
678
+ });
679
+
680
+ it('should use newLabelText when labelToAdd is not string', async () => {
681
+ api.updateIssue.mockResolvedValue(undefined);
682
+ api.fetchIssueDetail.mockResolvedValue({ ...mockIssueDetail, fields: { ...mockIssueDetail.fields, labels: ['bug', 'urgent'] } });
683
+
684
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
685
+
686
+ await waitFor(() => {
687
+ expect(result.current.issueDetail).toBeDefined();
688
+ });
689
+
690
+ result.current.setNewLabelText('typed-label');
691
+ await waitFor(() => {
692
+ expect(result.current.newLabelText).toBe('typed-label');
693
+ });
694
+
695
+ await result.current.handleAddLabel(null);
696
+
697
+ expect(api.updateIssue).toHaveBeenCalledWith('TEST-123', { labels: ['bug', 'urgent', 'typed-label'] });
698
+ });
699
+
700
+ it('should set isUpdatingLabels during update', async () => {
701
+ api.updateIssue.mockResolvedValue(undefined);
702
+ api.fetchIssueDetail.mockResolvedValue({ ...mockIssueDetail, fields: { ...mockIssueDetail.fields, labels: ['bug', 'urgent'] } });
703
+
704
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
705
+
706
+ await waitFor(() => {
707
+ expect(result.current.issueDetail).toBeDefined();
708
+ });
709
+
710
+ await result.current.handleAddLabel('new-label');
711
+
712
+ await waitFor(() => {
713
+ expect(result.current.isUpdatingLabels).toBe(false);
714
+ });
715
+ });
716
+ });
717
+
718
+ describe('handleRemoveLabel', () => {
719
+ it('should successfully remove a label', async () => {
720
+ api.updateIssue.mockResolvedValue(undefined);
721
+ api.fetchIssueDetail.mockResolvedValue({ ...mockIssueDetail, fields: { ...mockIssueDetail.fields, labels: ['bug'] } });
722
+
723
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
724
+
725
+ await waitFor(() => {
726
+ expect(result.current.issueDetail).toBeDefined();
727
+ });
728
+
729
+ await result.current.handleRemoveLabel('urgent');
730
+
731
+ expect(api.updateIssue).toHaveBeenCalledWith('TEST-123', { labels: ['bug'] });
732
+ expect(mockAddToast).toHaveBeenCalledWith('Label removed', 'success');
733
+ });
734
+
735
+ it('should handle remove label error', async () => {
736
+ api.updateIssue.mockRejectedValue(new Error('Remove label failed'));
737
+
738
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
739
+
740
+ await waitFor(() => {
741
+ expect(result.current.issueDetail).toBeDefined();
742
+ });
743
+
744
+ await result.current.handleRemoveLabel('urgent');
745
+
746
+ expect(mockAddToast).toHaveBeenCalledWith('Error removing label: Remove label failed', 'error');
747
+ });
748
+
749
+ it('should set isUpdatingLabels during removal', async () => {
750
+ api.updateIssue.mockResolvedValue(undefined);
751
+ api.fetchIssueDetail.mockResolvedValue({ ...mockIssueDetail, fields: { ...mockIssueDetail.fields, labels: ['bug'] } });
752
+
753
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
754
+
755
+ await waitFor(() => {
756
+ expect(result.current.issueDetail).toBeDefined();
757
+ });
758
+
759
+ await result.current.handleRemoveLabel('urgent');
760
+
761
+ await waitFor(() => {
762
+ expect(result.current.isUpdatingLabels).toBe(false);
763
+ });
764
+ });
765
+
766
+ it('should not remove label without issueKey', async () => {
767
+ const { result } = renderHook(() => useIssueDrawer(null, mockAddToast, mockRefetch));
768
+
769
+ await result.current.handleRemoveLabel('urgent');
770
+
771
+ expect(api.updateIssue).not.toHaveBeenCalled();
772
+ });
773
+
774
+ it('should use empty array when no labels remain after removal', async () => {
775
+ api.updateIssue.mockResolvedValue(undefined);
776
+ api.fetchIssueDetail.mockResolvedValue({ ...mockIssueDetail, fields: { ...mockIssueDetail.fields, labels: null } });
777
+
778
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
779
+
780
+ await waitFor(() => {
781
+ expect(result.current.issueDetail).toBeDefined();
782
+ });
783
+
784
+ await result.current.handleRemoveLabel('urgent');
785
+
786
+ expect(api.updateIssue).toHaveBeenCalledWith('TEST-123', { labels: [] });
787
+ });
788
+ });
789
+
790
+ describe('label search debounce', () => {
791
+ it('should clear suggestions when label text is empty', async () => {
792
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
793
+
794
+ await waitFor(() => {
795
+ expect(result.current.drawerLoading).toBe(false);
796
+ });
797
+
798
+ result.current.setNewLabelText('test');
799
+ await waitFor(() => {
800
+ expect(result.current.labelSuggestions.length).toBeGreaterThanOrEqual(0);
801
+ });
802
+
803
+ result.current.setNewLabelText('');
804
+ expect(result.current.labelSuggestions).toEqual([]);
805
+ expect(result.current.showLabelSuggestions).toBe(false);
806
+ });
807
+
808
+ it('should filter out existing labels from suggestions', async () => {
809
+ api.searchLabels.mockResolvedValue(['bug', 'new-label', 'urgent']);
810
+
811
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
812
+
813
+ await waitFor(() => {
814
+ expect(result.current.drawerLoading).toBe(false);
815
+ });
816
+
817
+ result.current.setNewLabelText('b');
818
+ await waitFor(() => {
819
+ expect(result.current.labelSuggestions).toEqual([]);
820
+ });
821
+ });
822
+
823
+ it('should handle label search error gracefully', async () => {
824
+ api.searchLabels.mockRejectedValue(new Error('Search failed'));
825
+
826
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
827
+
828
+ await waitFor(() => {
829
+ expect(result.current.drawerLoading).toBe(false);
830
+ });
831
+
832
+ result.current.setNewLabelText('test');
833
+ await waitFor(() => {
834
+ expect(result.current.labelSuggestions).toEqual([]);
835
+ });
836
+ });
837
+
838
+ it('should show suggestions when labels are available', async () => {
839
+ api.searchLabels.mockResolvedValue(['backend', 'frontend']);
840
+
841
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
842
+
843
+ await waitFor(() => {
844
+ expect(result.current.drawerLoading).toBe(false);
845
+ });
846
+
847
+ result.current.setNewLabelText('b');
848
+ await waitFor(() => {
849
+ expect(result.current.showLabelSuggestions).toBe(true);
850
+ });
851
+ });
852
+
853
+ it('should trigger error catch when label search fails after debounce', async () => {
854
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
855
+ api.searchLabels.mockRejectedValue(new Error('Label search failed'));
856
+
857
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
858
+
859
+ await waitFor(() => {
860
+ expect(result.current.drawerLoading).toBe(false);
861
+ });
862
+
863
+ result.current.setNewLabelText('test');
864
+ await waitFor(() => {
865
+ expect(consoleErrorSpy).toHaveBeenCalledWith('Label search error:', expect.any(Error));
866
+ }, { timeout: 1000 });
867
+
868
+ consoleErrorSpy.mockRestore();
869
+ });
870
+ });
871
+
872
+ describe('assignee search debounce', () => {
873
+ it('should clear users when search is empty', async () => {
874
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
875
+
876
+ await waitFor(() => {
877
+ expect(result.current.drawerLoading).toBe(false);
878
+ });
879
+
880
+ result.current.setAssigneeSearch('');
881
+ expect(result.current.assignableUsers).toEqual(mockUsers);
882
+ });
883
+
884
+ it('should handle assignee search error gracefully', async () => {
885
+ api.fetchAssignableUsers.mockRejectedValue(new Error('Search failed'));
886
+
887
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
888
+
889
+ await waitFor(() => {
890
+ expect(result.current.drawerLoading).toBe(false);
891
+ });
892
+
893
+ result.current.setAssigneeSearch('test');
894
+ await waitFor(() => {
895
+ expect(result.current.assignableUsers).toEqual([]);
896
+ });
897
+ });
898
+
899
+ it('should update assignable users after search', async () => {
900
+ api.fetchAssignableUsers.mockResolvedValue([{ accountId: 'user3', displayName: 'User Three' }]);
901
+
902
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
903
+
904
+ await waitFor(() => {
905
+ expect(result.current.drawerLoading).toBe(false);
906
+ });
907
+
908
+ result.current.setAssigneeSearch('three');
909
+ await waitFor(() => {
910
+ expect(result.current.assignableUsers).toHaveLength(1);
911
+ expect(result.current.assignableUsers[0].accountId).toBe('user3');
912
+ });
913
+ });
914
+
915
+ it('should not search when assignee search length is less than 1', async () => {
916
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
917
+
918
+ await waitFor(() => {
919
+ expect(result.current.drawerLoading).toBe(false);
920
+ });
921
+
922
+ api.fetchAssignableUsers.mockClear();
923
+ result.current.setAssigneeSearch('');
924
+ expect(api.fetchAssignableUsers).not.toHaveBeenCalled();
925
+ });
926
+
927
+ it('should trigger error catch when assignee search fails after debounce', async () => {
928
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
929
+ api.fetchAssignableUsers.mockRejectedValue(new Error('Assignee search failed'));
930
+
931
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
932
+
933
+ await waitFor(() => {
934
+ expect(result.current.drawerLoading).toBe(false);
935
+ });
936
+
937
+ result.current.setAssigneeSearch('test');
938
+ await waitFor(() => {
939
+ expect(consoleErrorSpy).toHaveBeenCalledWith('Assignee search error:', expect.any(Error));
940
+ }, { timeout: 1000 });
941
+
942
+ consoleErrorSpy.mockRestore();
943
+ });
944
+ });
945
+
946
+ describe('edge cases', () => {
947
+ it('should handle missing project key in issue detail', async () => {
948
+ api.fetchIssueDetail.mockResolvedValue({
949
+ ...mockIssueDetail,
950
+ fields: { ...mockIssueDetail.fields, project: null }
951
+ });
952
+
953
+ renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
954
+
955
+ await waitFor(() => {
956
+ expect(api.fetchIssueDetail).toHaveBeenCalled();
957
+ });
958
+
959
+ expect(api.fetchProjectVersions).not.toHaveBeenCalled();
960
+ });
961
+
962
+ it('should handle null issueDetail fields', async () => {
963
+ api.fetchIssueDetail.mockResolvedValue({
964
+ key: 'TEST-123',
965
+ fields: {
966
+ summary: 'Test',
967
+ status: null,
968
+ project: null,
969
+ labels: null,
970
+ assignee: null
971
+ }
972
+ });
973
+
974
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
975
+
976
+ await waitFor(() => {
977
+ expect(result.current.drawerLoading).toBe(false);
978
+ });
979
+
980
+ expect(result.current.issueDetail).toBeDefined();
981
+ });
982
+
983
+ it('should handle fetchProjectVersions error gracefully', async () => {
984
+ api.fetchProjectVersions.mockRejectedValue(new Error('Versions error'));
985
+
986
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
987
+
988
+ await waitFor(() => {
989
+ expect(result.current.drawerLoading).toBe(false);
990
+ });
991
+
992
+ expect(result.current.projectVersions).toEqual([]);
993
+ });
994
+
995
+ it('should load project versions when project key exists', async () => {
996
+ const versions = [{ id: '1', name: 'v1.0' }, { id: '2', name: 'v2.0' }];
997
+ api.fetchProjectVersions.mockResolvedValue(versions);
998
+
999
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
1000
+
1001
+ await waitFor(() => {
1002
+ expect(result.current.drawerLoading).toBe(false);
1003
+ });
1004
+
1005
+ await waitFor(() => {
1006
+ expect(result.current.projectVersions).toEqual(versions);
1007
+ });
1008
+ });
1009
+
1010
+ it('should not upload when no file is selected', async () => {
1011
+ const { result } = renderHook(() => useIssueDrawer('TEST-123', mockAddToast, mockRefetch));
1012
+
1013
+ await waitFor(() => {
1014
+ expect(result.current.drawerLoading).toBe(false);
1015
+ });
1016
+
1017
+ const mockEvent = {
1018
+ target: {
1019
+ files: [],
1020
+ value: ''
1021
+ }
1022
+ };
1023
+
1024
+ await result.current.handleFileUpload(mockEvent);
1025
+ expect(api.uploadAttachment).not.toHaveBeenCalled();
1026
+ });
1027
+
1028
+ it('should reset state when issueKey changes', async () => {
1029
+ const { result, rerender } = renderHook(
1030
+ ({ issueKey }) => useIssueDrawer(issueKey, mockAddToast, mockRefetch),
1031
+ { initialProps: { issueKey: 'TEST-123' } }
1032
+ );
1033
+
1034
+ await waitFor(() => {
1035
+ expect(result.current.drawerLoading).toBe(false);
1036
+ });
1037
+
1038
+ expect(result.current.issueDetail).toEqual(mockIssueDetail);
1039
+
1040
+ api.fetchIssueDetail.mockResolvedValue({ ...mockIssueDetail, key: 'TEST-456' });
1041
+ rerender({ issueKey: 'TEST-456' });
1042
+
1043
+ await waitFor(() => {
1044
+ expect(result.current.drawerLoading).toBe(true);
1045
+ });
1046
+
1047
+ await waitFor(() => {
1048
+ expect(result.current.drawerLoading).toBe(false);
1049
+ expect(result.current.issueDetail.key).toBe('TEST-456');
1050
+ });
1051
+ });
1052
+ });
1053
+ });