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.
- package/AGENTS.md +218 -0
- package/README.md +64 -0
- package/backend/.env.example +1 -0
- package/backend/__tests__/getJiraClient.test.js +57 -0
- package/backend/__tests__/issues.test.js +565 -0
- package/backend/__tests__/jiraService.test.js +1127 -0
- package/backend/__tests__/projects.test.js +256 -0
- package/backend/coverage/clover.xml +426 -0
- package/backend/coverage/coverage-final.json +4 -0
- package/backend/coverage/lcov-report/base.css +224 -0
- package/backend/coverage/lcov-report/block-navigation.js +87 -0
- package/backend/coverage/lcov-report/favicon.png +0 -0
- package/backend/coverage/lcov-report/index.html +131 -0
- package/backend/coverage/lcov-report/prettify.css +1 -0
- package/backend/coverage/lcov-report/prettify.js +2 -0
- package/backend/coverage/lcov-report/routes/index.html +131 -0
- package/backend/coverage/lcov-report/routes/issues.js.html +823 -0
- package/backend/coverage/lcov-report/routes/projects.js.html +190 -0
- package/backend/coverage/lcov-report/service/index.html +116 -0
- package/backend/coverage/lcov-report/service/jiraService.js.html +1663 -0
- package/backend/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/backend/coverage/lcov-report/sorter.js +210 -0
- package/backend/coverage/lcov.info +707 -0
- package/backend/index.js +38 -0
- package/backend/jest.config.js +11 -0
- package/backend/package-lock.json +5636 -0
- package/backend/package.json +28 -0
- package/backend/routes/issues.js +246 -0
- package/backend/routes/projects.js +35 -0
- package/backend/service/jiraService.js +526 -0
- package/bin/jira.js +92 -0
- package/frontend/.env.example +1 -0
- package/frontend/coverage/base.css +224 -0
- package/frontend/coverage/block-navigation.js +87 -0
- package/frontend/coverage/clover.xml +559 -0
- package/frontend/coverage/components/CreateIssueModal.jsx.html +592 -0
- package/frontend/coverage/components/IssueDetailPanel.jsx.html +1633 -0
- package/frontend/coverage/components/IssueDrawer.jsx.html +550 -0
- package/frontend/coverage/components/IssueTable.jsx.html +571 -0
- package/frontend/coverage/components/SkeletonComponents.jsx.html +223 -0
- package/frontend/coverage/components/ToastContainer.jsx.html +142 -0
- package/frontend/coverage/components/index.html +191 -0
- package/frontend/coverage/coverage-final.json +14 -0
- package/frontend/coverage/favicon.png +0 -0
- package/frontend/coverage/hooks/index.html +161 -0
- package/frontend/coverage/hooks/useFocusTrap.js.html +262 -0
- package/frontend/coverage/hooks/useIssueDrawer.js.html +1000 -0
- package/frontend/coverage/hooks/useIssuesList.js.html +175 -0
- package/frontend/coverage/hooks/useToasts.js.html +142 -0
- package/frontend/coverage/index.html +161 -0
- package/frontend/coverage/prettify.css +1 -0
- package/frontend/coverage/prettify.js +2 -0
- package/frontend/coverage/services/api.js.html +547 -0
- package/frontend/coverage/services/index.html +116 -0
- package/frontend/coverage/sort-arrow-sprite.png +0 -0
- package/frontend/coverage/sorter.js +210 -0
- package/frontend/coverage/utils/index.html +131 -0
- package/frontend/coverage/utils/issueHelpers.jsx.html +334 -0
- package/frontend/coverage/utils/sanitize.js.html +166 -0
- package/frontend/index.html +13 -0
- package/frontend/package-lock.json +3436 -0
- package/frontend/package.json +30 -0
- package/frontend/src/App.jsx +447 -0
- package/frontend/src/__tests__/components/CreateIssueModal.test.jsx +375 -0
- package/frontend/src/__tests__/components/IssueDetailPanel.test.jsx +962 -0
- package/frontend/src/__tests__/components/IssueDrawer.test.jsx +240 -0
- package/frontend/src/__tests__/components/IssueTable.test.jsx +423 -0
- package/frontend/src/__tests__/components/ToastContainer.test.jsx +196 -0
- package/frontend/src/__tests__/hooks/useFocusTrap.test.js +197 -0
- package/frontend/src/__tests__/hooks/useIssueDrawer.test.js +1053 -0
- package/frontend/src/__tests__/hooks/useIssuesList.test.js +175 -0
- package/frontend/src/__tests__/hooks/useToasts.test.js +110 -0
- package/frontend/src/__tests__/services/api.test.js +568 -0
- package/frontend/src/__tests__/setup.js +54 -0
- package/frontend/src/__tests__/utils/issueHelpers.test.jsx +336 -0
- package/frontend/src/__tests__/utils/sanitize.test.js +238 -0
- package/frontend/src/components/CreateIssueModal.jsx +169 -0
- package/frontend/src/components/ErrorBoundary.jsx +52 -0
- package/frontend/src/components/IssueDetailPanel.jsx +517 -0
- package/frontend/src/components/IssueDrawer.jsx +155 -0
- package/frontend/src/components/IssueTable.jsx +162 -0
- package/frontend/src/components/SkeletonComponents.jsx +46 -0
- package/frontend/src/components/StandaloneIssuePage.jsx +176 -0
- package/frontend/src/components/ToastContainer.jsx +19 -0
- package/frontend/src/hooks/useFocusTrap.js +59 -0
- package/frontend/src/hooks/useIssueDrawer.js +305 -0
- package/frontend/src/hooks/useIssuesList.js +30 -0
- package/frontend/src/hooks/useToasts.js +19 -0
- package/frontend/src/index.css +2070 -0
- package/frontend/src/main.jsx +13 -0
- package/frontend/src/services/api.js +154 -0
- package/frontend/src/utils/issueHelpers.jsx +84 -0
- package/frontend/src/utils/sanitize.js +27 -0
- package/frontend/vite.config.js +15 -0
- package/package.json +19 -0
|
@@ -0,0 +1,1127 @@
|
|
|
1
|
+
const axios = require('axios');
|
|
2
|
+
const NodeCache = require('node-cache');
|
|
3
|
+
|
|
4
|
+
jest.mock('axios');
|
|
5
|
+
jest.mock('dotenv', () => ({ config: jest.fn() }));
|
|
6
|
+
jest.mock('node-cache');
|
|
7
|
+
|
|
8
|
+
const mockCacheInstance = {
|
|
9
|
+
get: jest.fn(),
|
|
10
|
+
set: jest.fn(),
|
|
11
|
+
del: jest.fn(),
|
|
12
|
+
flushAll: jest.fn(),
|
|
13
|
+
keys: jest.fn(() => [])
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
NodeCache.mockImplementation(() => mockCacheInstance);
|
|
17
|
+
|
|
18
|
+
process.env.JIRA_BASE_URL = 'https://test.atlassian.net';
|
|
19
|
+
process.env.JIRA_PAT = 'test-pat';
|
|
20
|
+
process.env.JIRA_EMAIL = 'test@example.com';
|
|
21
|
+
process.env.BACKEND_BASE_URL = 'http://localhost:5000';
|
|
22
|
+
|
|
23
|
+
const jiraService = require('../service/jiraService');
|
|
24
|
+
|
|
25
|
+
describe('jiraService', () => {
|
|
26
|
+
let mockAxiosInstance;
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
jest.clearAllMocks();
|
|
30
|
+
mockCacheInstance.get.mockReset();
|
|
31
|
+
mockCacheInstance.set.mockReset();
|
|
32
|
+
mockCacheInstance.del.mockReset();
|
|
33
|
+
mockCacheInstance.flushAll.mockReset();
|
|
34
|
+
mockCacheInstance.keys.mockReturnValue([]);
|
|
35
|
+
|
|
36
|
+
mockAxiosInstance = {
|
|
37
|
+
get: jest.fn(),
|
|
38
|
+
post: jest.fn(),
|
|
39
|
+
put: jest.fn(),
|
|
40
|
+
defaults: { baseURL: 'https://test.atlassian.net' }
|
|
41
|
+
};
|
|
42
|
+
axios.create.mockReturnValue(mockAxiosInstance);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('adfToText', () => {
|
|
46
|
+
it('should convert ADF text node to plain text', () => {
|
|
47
|
+
const node = { type: 'text', text: 'Hello World' };
|
|
48
|
+
expect(jiraService.adfToText(node)).toBe('Hello World');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should return empty string for null node', () => {
|
|
52
|
+
expect(jiraService.adfToText(null)).toBe('');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should handle paragraph with multiple text nodes', () => {
|
|
56
|
+
const node = {
|
|
57
|
+
type: 'paragraph',
|
|
58
|
+
content: [
|
|
59
|
+
{ type: 'text', text: 'Hello' },
|
|
60
|
+
{ type: 'text', text: 'World' }
|
|
61
|
+
]
|
|
62
|
+
};
|
|
63
|
+
const result = jiraService.adfToText(node);
|
|
64
|
+
expect(result).toContain('Hello');
|
|
65
|
+
expect(result).toContain('World');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should handle nested content', () => {
|
|
69
|
+
const node = {
|
|
70
|
+
type: 'doc',
|
|
71
|
+
content: [
|
|
72
|
+
{
|
|
73
|
+
type: 'paragraph',
|
|
74
|
+
content: [{ type: 'text', text: 'First line' }]
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
type: 'paragraph',
|
|
78
|
+
content: [{ type: 'text', text: 'Second line' }]
|
|
79
|
+
}
|
|
80
|
+
]
|
|
81
|
+
};
|
|
82
|
+
const result = jiraService.adfToText(node);
|
|
83
|
+
expect(result).toContain('First line');
|
|
84
|
+
expect(result).toContain('Second line');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('searchIssues', () => {
|
|
89
|
+
it('should search issues with project filter', async () => {
|
|
90
|
+
const mockResponse = {
|
|
91
|
+
data: {
|
|
92
|
+
issues: [{ key: 'TEST-1', fields: { summary: 'Test Issue' } }],
|
|
93
|
+
total: 1
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
mockAxiosInstance.get.mockResolvedValue(mockResponse);
|
|
97
|
+
|
|
98
|
+
const result = await jiraService.searchIssues({ project: 'TEST' });
|
|
99
|
+
|
|
100
|
+
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
|
|
101
|
+
'/rest/api/3/search/jql',
|
|
102
|
+
expect.objectContaining({
|
|
103
|
+
params: expect.objectContaining({
|
|
104
|
+
jql: expect.stringContaining('project = "TEST"')
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
);
|
|
108
|
+
expect(result.issues).toHaveLength(1);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should handle status filter with exclude syntax', async () => {
|
|
112
|
+
const mockResponse = { data: { issues: [], total: 0 } };
|
|
113
|
+
mockAxiosInstance.get.mockResolvedValue(mockResponse);
|
|
114
|
+
|
|
115
|
+
await jiraService.searchIssues({ status: 'exclude:Done,In Progress' });
|
|
116
|
+
|
|
117
|
+
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
|
|
118
|
+
'/rest/api/3/search/jql',
|
|
119
|
+
expect.objectContaining({
|
|
120
|
+
params: expect.objectContaining({
|
|
121
|
+
jql: expect.stringContaining('status != "Done"')
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should throw error on Jira API failure', async () => {
|
|
128
|
+
mockCacheInstance.get.mockReturnValue(undefined);
|
|
129
|
+
const error = {
|
|
130
|
+
response: {
|
|
131
|
+
status: 400,
|
|
132
|
+
data: { errorMessages: ['Invalid JQL'] }
|
|
133
|
+
},
|
|
134
|
+
message: 'Request failed'
|
|
135
|
+
};
|
|
136
|
+
mockAxiosInstance.get.mockRejectedValue(error);
|
|
137
|
+
|
|
138
|
+
await expect(jiraService.searchIssues({ project: 'TEST' }))
|
|
139
|
+
.rejects.toThrow();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should search by issue key when format is PROJECT-NUMBER', async () => {
|
|
143
|
+
const mockResponse = { data: { issues: [], total: 0 } };
|
|
144
|
+
mockAxiosInstance.get.mockResolvedValue(mockResponse);
|
|
145
|
+
|
|
146
|
+
await jiraService.searchIssues({ search: 'TEST-123', project: 'TEST' });
|
|
147
|
+
|
|
148
|
+
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
|
|
149
|
+
'/rest/api/3/search/jql',
|
|
150
|
+
expect.objectContaining({
|
|
151
|
+
params: expect.objectContaining({
|
|
152
|
+
jql: expect.stringContaining('issueKey = "TEST-123"')
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should search by number when format is just a number', async () => {
|
|
159
|
+
const mockResponse = { data: { issues: [], total: 0 } };
|
|
160
|
+
mockAxiosInstance.get.mockResolvedValue(mockResponse);
|
|
161
|
+
|
|
162
|
+
await jiraService.searchIssues({ search: '123', project: 'TEST' });
|
|
163
|
+
|
|
164
|
+
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
|
|
165
|
+
'/rest/api/3/search/jql',
|
|
166
|
+
expect.objectContaining({
|
|
167
|
+
params: expect.objectContaining({
|
|
168
|
+
jql: expect.stringContaining('issueKey = "TEST-123"')
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('should use text search when search is not a number or key', async () => {
|
|
175
|
+
const mockResponse = { data: { issues: [], total: 0 } };
|
|
176
|
+
mockAxiosInstance.get.mockResolvedValue(mockResponse);
|
|
177
|
+
|
|
178
|
+
await jiraService.searchIssues({ search: 'some text', project: 'TEST' });
|
|
179
|
+
|
|
180
|
+
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
|
|
181
|
+
'/rest/api/3/search/jql',
|
|
182
|
+
expect.objectContaining({
|
|
183
|
+
params: expect.objectContaining({
|
|
184
|
+
jql: expect.stringContaining('text ~ "some text"')
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('should return from cache when cache hit', async () => {
|
|
191
|
+
const cachedData = { issues: [{ key: 'TEST-1' }], total: 1 };
|
|
192
|
+
mockCacheInstance.get.mockReturnValue(cachedData);
|
|
193
|
+
|
|
194
|
+
const result = await jiraService.searchIssues({ project: 'TEST' });
|
|
195
|
+
|
|
196
|
+
expect(result).toEqual(cachedData);
|
|
197
|
+
expect(mockAxiosInstance.get).not.toHaveBeenCalled();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('should throw error on non-response API failure', async () => {
|
|
201
|
+
mockCacheInstance.get.mockReturnValue(undefined);
|
|
202
|
+
mockAxiosInstance.get.mockRejectedValue(new Error('Network timeout'));
|
|
203
|
+
|
|
204
|
+
await expect(jiraService.searchIssues({ project: 'TEST' }))
|
|
205
|
+
.rejects.toThrow('Network timeout');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('should handle regular status filter', async () => {
|
|
209
|
+
const mockResponse = { data: { issues: [], total: 0 } };
|
|
210
|
+
mockAxiosInstance.get.mockResolvedValue(mockResponse);
|
|
211
|
+
|
|
212
|
+
await jiraService.searchIssues({ status: 'Done' });
|
|
213
|
+
|
|
214
|
+
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
|
|
215
|
+
'/rest/api/3/search/jql',
|
|
216
|
+
expect.objectContaining({
|
|
217
|
+
params: expect.objectContaining({
|
|
218
|
+
jql: expect.stringContaining('status = "Done"')
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('should return newline for empty paragraph', () => {
|
|
225
|
+
const node = { type: 'paragraph', content: [] };
|
|
226
|
+
expect(jiraService.adfToText(node)).toBe('\n');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('should return empty string for unknown node type', () => {
|
|
230
|
+
const node = { type: 'unknown', content: [] };
|
|
231
|
+
expect(jiraService.adfToText(node)).toBe('');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('should return empty string for node with undefined text property', () => {
|
|
235
|
+
const node = { type: 'text' };
|
|
236
|
+
expect(jiraService.adfToText(node)).toBe('');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('should return empty string for node without content array', () => {
|
|
240
|
+
const node = { type: 'other' };
|
|
241
|
+
expect(jiraService.adfToText(node)).toBe('');
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe('getIssueDetail', () => {
|
|
246
|
+
it('should fetch issue details and transform data', async () => {
|
|
247
|
+
const mockIssue = {
|
|
248
|
+
key: 'TEST-1',
|
|
249
|
+
fields: {
|
|
250
|
+
summary: 'Test Issue',
|
|
251
|
+
description: {
|
|
252
|
+
type: 'doc',
|
|
253
|
+
version: 1,
|
|
254
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Description' }] }]
|
|
255
|
+
},
|
|
256
|
+
subtasks: [{ key: 'TEST-2', fields: { summary: 'Subtask', status: { name: 'Done' } } }],
|
|
257
|
+
issuelinks: []
|
|
258
|
+
},
|
|
259
|
+
renderedFields: {
|
|
260
|
+
description: '<p>Description HTML</p>'
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
mockAxiosInstance.get.mockResolvedValue({ data: mockIssue });
|
|
264
|
+
|
|
265
|
+
const result = await jiraService.getIssueDetail('TEST-1');
|
|
266
|
+
|
|
267
|
+
expect(result.fields.summary).toBe('Test Issue');
|
|
268
|
+
expect(result.fields.subtasksMapped).toHaveLength(1);
|
|
269
|
+
expect(result.fields.issuelinksMapped).toEqual([]);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('should handle inward issue links', async () => {
|
|
273
|
+
const mockIssue = {
|
|
274
|
+
key: 'TEST-1',
|
|
275
|
+
fields: {
|
|
276
|
+
issuelinks: [{
|
|
277
|
+
type: { name: 'Blocks', inward: 'is blocked by' },
|
|
278
|
+
inwardIssue: {
|
|
279
|
+
key: 'TEST-2',
|
|
280
|
+
fields: { summary: 'Blocker', status: { name: 'In Progress' } }
|
|
281
|
+
}
|
|
282
|
+
}]
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
mockAxiosInstance.get.mockResolvedValue({ data: mockIssue });
|
|
286
|
+
|
|
287
|
+
const result = await jiraService.getIssueDetail('TEST-1');
|
|
288
|
+
|
|
289
|
+
expect(result.fields.issuelinksMapped[0]).toMatchObject({
|
|
290
|
+
direction: 'inward',
|
|
291
|
+
label: 'is blocked by',
|
|
292
|
+
key: 'TEST-2'
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('should handle outward issue links', async () => {
|
|
297
|
+
const mockIssue = {
|
|
298
|
+
key: 'TEST-1',
|
|
299
|
+
fields: {
|
|
300
|
+
issuelinks: [{
|
|
301
|
+
type: { name: 'Blocks', outward: 'blocks' },
|
|
302
|
+
outwardIssue: {
|
|
303
|
+
key: 'TEST-3',
|
|
304
|
+
fields: { summary: 'Blocked', status: { name: 'To Do' } }
|
|
305
|
+
}
|
|
306
|
+
}]
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
mockAxiosInstance.get.mockResolvedValue({ data: mockIssue });
|
|
310
|
+
|
|
311
|
+
const result = await jiraService.getIssueDetail('TEST-1');
|
|
312
|
+
|
|
313
|
+
expect(result.fields.issuelinksMapped[0]).toMatchObject({
|
|
314
|
+
direction: 'outward',
|
|
315
|
+
label: 'blocks',
|
|
316
|
+
key: 'TEST-3'
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('should proxy images from Atlassian domain', async () => {
|
|
321
|
+
const mockIssue = {
|
|
322
|
+
key: 'TEST-1',
|
|
323
|
+
fields: {
|
|
324
|
+
description: '<img src="https://test.atlassian.net/attachment.png" />'
|
|
325
|
+
},
|
|
326
|
+
renderedFields: {
|
|
327
|
+
description: '<img src="https://test.atlassian.net/attachment.png" />'
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
mockAxiosInstance.get.mockResolvedValue({ data: mockIssue });
|
|
331
|
+
|
|
332
|
+
const result = await jiraService.getIssueDetail('TEST-1');
|
|
333
|
+
|
|
334
|
+
expect(result.fields.descriptionHtml).toContain('/api/issues/image/proxy?url=');
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('should not proxy images from external domains', async () => {
|
|
338
|
+
const mockIssue = {
|
|
339
|
+
key: 'TEST-1',
|
|
340
|
+
fields: {
|
|
341
|
+
description: '<img src="https://external.com/image.png" />'
|
|
342
|
+
},
|
|
343
|
+
renderedFields: {
|
|
344
|
+
description: '<img src="https://external.com/image.png" />'
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
mockAxiosInstance.get.mockResolvedValue({ data: mockIssue });
|
|
348
|
+
|
|
349
|
+
const result = await jiraService.getIssueDetail('TEST-1');
|
|
350
|
+
|
|
351
|
+
expect(result.fields.descriptionHtml).toContain('https://external.com/image.png');
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('should handle empty fields', async () => {
|
|
355
|
+
const mockIssue = { key: 'TEST-1' };
|
|
356
|
+
mockAxiosInstance.get.mockResolvedValue({ data: mockIssue });
|
|
357
|
+
|
|
358
|
+
const result = await jiraService.getIssueDetail('TEST-1');
|
|
359
|
+
|
|
360
|
+
expect(result.fields.descriptionText).toBe('');
|
|
361
|
+
expect(result.fields.subtasksMapped).toEqual([]);
|
|
362
|
+
expect(result.fields.issuelinksMapped).toEqual([]);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('should handle null subtasks', async () => {
|
|
366
|
+
const mockIssue = {
|
|
367
|
+
key: 'TEST-1',
|
|
368
|
+
fields: {
|
|
369
|
+
summary: 'Test',
|
|
370
|
+
subtasks: null,
|
|
371
|
+
issuelinks: null
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
mockAxiosInstance.get.mockResolvedValue({ data: mockIssue });
|
|
375
|
+
|
|
376
|
+
const result = await jiraService.getIssueDetail('TEST-1');
|
|
377
|
+
|
|
378
|
+
expect(result.fields.subtasksMapped).toEqual([]);
|
|
379
|
+
expect(result.fields.issuelinksMapped).toEqual([]);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('should handle subtasks with missing fields summary and status', async () => {
|
|
383
|
+
const mockIssue = {
|
|
384
|
+
key: 'TEST-1',
|
|
385
|
+
fields: {
|
|
386
|
+
subtasks: [
|
|
387
|
+
{ key: 'TEST-2', fields: {} },
|
|
388
|
+
{ key: 'TEST-3', fields: { summary: 'Has summary' } }
|
|
389
|
+
],
|
|
390
|
+
issuelinks: []
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
mockAxiosInstance.get.mockResolvedValue({ data: mockIssue });
|
|
394
|
+
|
|
395
|
+
const result = await jiraService.getIssueDetail('TEST-1');
|
|
396
|
+
|
|
397
|
+
expect(result.fields.subtasksMapped[0].summary).toBe('');
|
|
398
|
+
expect(result.fields.subtasksMapped[0].status).toBe('Unknown');
|
|
399
|
+
expect(result.fields.subtasksMapped[1].summary).toBe('Has summary');
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('should handle inward issue links with missing fields', async () => {
|
|
403
|
+
const mockIssue = {
|
|
404
|
+
key: 'TEST-1',
|
|
405
|
+
fields: {
|
|
406
|
+
issuelinks: [{
|
|
407
|
+
type: { name: 'Blocks', inward: 'is blocked by' },
|
|
408
|
+
inwardIssue: {
|
|
409
|
+
key: 'TEST-2',
|
|
410
|
+
fields: {}
|
|
411
|
+
}
|
|
412
|
+
}]
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
mockAxiosInstance.get.mockResolvedValue({ data: mockIssue });
|
|
416
|
+
|
|
417
|
+
const result = await jiraService.getIssueDetail('TEST-1');
|
|
418
|
+
|
|
419
|
+
expect(result.fields.issuelinksMapped[0].summary).toBe('');
|
|
420
|
+
expect(result.fields.issuelinksMapped[0].status).toBe('Unknown');
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it('should handle outward issue links with missing fields', async () => {
|
|
424
|
+
const mockIssue = {
|
|
425
|
+
key: 'TEST-1',
|
|
426
|
+
fields: {
|
|
427
|
+
issuelinks: [{
|
|
428
|
+
type: { name: 'Blocks', outward: 'blocks' },
|
|
429
|
+
outwardIssue: {
|
|
430
|
+
key: 'TEST-3',
|
|
431
|
+
fields: {}
|
|
432
|
+
}
|
|
433
|
+
}]
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
mockAxiosInstance.get.mockResolvedValue({ data: mockIssue });
|
|
437
|
+
|
|
438
|
+
const result = await jiraService.getIssueDetail('TEST-1');
|
|
439
|
+
|
|
440
|
+
expect(result.fields.issuelinksMapped[0].summary).toBe('');
|
|
441
|
+
expect(result.fields.issuelinksMapped[0].status).toBe('Unknown');
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it('should use ADF description text when no renderedFields', async () => {
|
|
445
|
+
const mockIssue = {
|
|
446
|
+
key: 'TEST-1',
|
|
447
|
+
fields: {
|
|
448
|
+
summary: 'Test Issue',
|
|
449
|
+
description: {
|
|
450
|
+
type: 'doc',
|
|
451
|
+
version: 1,
|
|
452
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'ADF Description' }] }]
|
|
453
|
+
}
|
|
454
|
+
},
|
|
455
|
+
renderedFields: null
|
|
456
|
+
};
|
|
457
|
+
mockAxiosInstance.get.mockResolvedValue({ data: mockIssue });
|
|
458
|
+
|
|
459
|
+
const result = await jiraService.getIssueDetail('TEST-1');
|
|
460
|
+
|
|
461
|
+
expect(result.fields.descriptionText).toContain('ADF Description');
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('should filter out null issue links (neither inward nor outward)', async () => {
|
|
465
|
+
const mockIssue = {
|
|
466
|
+
key: 'TEST-1',
|
|
467
|
+
fields: {
|
|
468
|
+
issuelinks: [{
|
|
469
|
+
type: { name: 'Relates', inward: 'relates to', outward: 'relates to' },
|
|
470
|
+
inwardIssue: null,
|
|
471
|
+
outwardIssue: null
|
|
472
|
+
}]
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
mockAxiosInstance.get.mockResolvedValue({ data: mockIssue });
|
|
476
|
+
|
|
477
|
+
const result = await jiraService.getIssueDetail('TEST-1');
|
|
478
|
+
|
|
479
|
+
expect(result.fields.issuelinksMapped).toEqual([]);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it('should proxy images from .atlassian.net domain', async () => {
|
|
483
|
+
const mockIssue = {
|
|
484
|
+
key: 'TEST-1',
|
|
485
|
+
fields: {},
|
|
486
|
+
renderedFields: {
|
|
487
|
+
description: '<img src="https://myteam.atlassian.net/attachment.png" />'
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
mockAxiosInstance.get.mockResolvedValue({ data: mockIssue });
|
|
491
|
+
|
|
492
|
+
const result = await jiraService.getIssueDetail('TEST-1');
|
|
493
|
+
|
|
494
|
+
expect(result.fields.descriptionHtml).toContain('/api/issues/image/proxy?url=');
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it('should not proxy images from non-Atlassian domains in description', async () => {
|
|
498
|
+
const mockIssue = {
|
|
499
|
+
key: 'TEST-1',
|
|
500
|
+
fields: {},
|
|
501
|
+
renderedFields: {
|
|
502
|
+
description: '<img src="https://external.com/image.png" />'
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
mockAxiosInstance.get.mockResolvedValue({ data: mockIssue });
|
|
506
|
+
|
|
507
|
+
const result = await jiraService.getIssueDetail('TEST-1');
|
|
508
|
+
|
|
509
|
+
expect(result.fields.descriptionHtml).toBe('<img src="https://external.com/image.png" />');
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it('should handle empty description html', async () => {
|
|
513
|
+
const mockIssue = {
|
|
514
|
+
key: 'TEST-1',
|
|
515
|
+
fields: {
|
|
516
|
+
summary: 'Test'
|
|
517
|
+
},
|
|
518
|
+
renderedFields: {
|
|
519
|
+
description: ''
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
mockAxiosInstance.get.mockResolvedValue({ data: mockIssue });
|
|
523
|
+
|
|
524
|
+
const result = await jiraService.getIssueDetail('TEST-1');
|
|
525
|
+
|
|
526
|
+
expect(result.fields.descriptionText).toBe('');
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it('should throw error on API failure', async () => {
|
|
530
|
+
mockAxiosInstance.get.mockRejectedValue(new Error('Network Error'));
|
|
531
|
+
|
|
532
|
+
await expect(jiraService.getIssueDetail('TEST-1'))
|
|
533
|
+
.rejects.toThrow('Network Error');
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it('should throw error without response on network error', async () => {
|
|
537
|
+
mockAxiosInstance.get.mockRejectedValue(new Error('Network timeout'));
|
|
538
|
+
|
|
539
|
+
await expect(jiraService.getIssueDetail('TEST-1'))
|
|
540
|
+
.rejects.toThrow('Network timeout');
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
describe('getComments', () => {
|
|
545
|
+
it('should fetch and transform comments', async () => {
|
|
546
|
+
const mockComments = [{
|
|
547
|
+
id: '1',
|
|
548
|
+
renderedBody: '<img src="https://test.atlassian.net/img.png" />',
|
|
549
|
+
body: { type: 'doc', content: [] }
|
|
550
|
+
}];
|
|
551
|
+
mockAxiosInstance.get.mockResolvedValue({
|
|
552
|
+
data: { comments: mockComments }
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
const result = await jiraService.getComments('TEST-1');
|
|
556
|
+
|
|
557
|
+
expect(result).toHaveLength(1);
|
|
558
|
+
expect(result[0].renderedHtml).toContain('/api/issues/image/proxy?url=');
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it('should return empty array when no comments', async () => {
|
|
562
|
+
mockAxiosInstance.get.mockResolvedValue({ data: {} });
|
|
563
|
+
|
|
564
|
+
const result = await jiraService.getComments('TEST-1');
|
|
565
|
+
|
|
566
|
+
expect(result).toEqual([]);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it('should use ADF body text when no rendered body', async () => {
|
|
570
|
+
const mockComments = [{
|
|
571
|
+
id: '1',
|
|
572
|
+
body: {
|
|
573
|
+
type: 'doc',
|
|
574
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Plain text' }] }]
|
|
575
|
+
}
|
|
576
|
+
}];
|
|
577
|
+
mockAxiosInstance.get.mockResolvedValue({
|
|
578
|
+
data: { comments: mockComments }
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
const result = await jiraService.getComments('TEST-1');
|
|
582
|
+
|
|
583
|
+
expect(result[0].bodyText).toContain('Plain text');
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it('should handle comment with null body', async () => {
|
|
587
|
+
const mockComments = [{ id: '1', body: null }];
|
|
588
|
+
mockAxiosInstance.get.mockResolvedValue({
|
|
589
|
+
data: { comments: mockComments }
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
const result = await jiraService.getComments('TEST-1');
|
|
593
|
+
|
|
594
|
+
expect(result[0].bodyText).toBe('');
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it('should not proxy images from non-Atlassian domains in comments', async () => {
|
|
598
|
+
const mockComments = [{
|
|
599
|
+
id: '1',
|
|
600
|
+
renderedBody: '<img src="https://external.com/img.png" />'
|
|
601
|
+
}];
|
|
602
|
+
mockAxiosInstance.get.mockResolvedValue({
|
|
603
|
+
data: { comments: mockComments }
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
const result = await jiraService.getComments('TEST-1');
|
|
607
|
+
|
|
608
|
+
expect(result[0].renderedHtml).toBe('<img src="https://external.com/img.png" />');
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it('should handle comments with empty renderedBody and no body', async () => {
|
|
612
|
+
const mockComments = [{ id: '1', renderedBody: '', body: null }];
|
|
613
|
+
mockAxiosInstance.get.mockResolvedValue({
|
|
614
|
+
data: { comments: mockComments }
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
const result = await jiraService.getComments('TEST-1');
|
|
618
|
+
|
|
619
|
+
expect(result[0].bodyText).toBe('');
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it('should handle comments with falsy renderedBody and no body', async () => {
|
|
623
|
+
const mockComments = [{ id: '1', body: null }];
|
|
624
|
+
mockAxiosInstance.get.mockResolvedValue({
|
|
625
|
+
data: { comments: mockComments }
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
const result = await jiraService.getComments('TEST-1');
|
|
629
|
+
|
|
630
|
+
expect(result[0].bodyText).toBe('');
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it('should throw error on API failure', async () => {
|
|
634
|
+
mockAxiosInstance.get.mockRejectedValue(new Error('API Error'));
|
|
635
|
+
|
|
636
|
+
await expect(jiraService.getComments('TEST-1')).rejects.toThrow('API Error');
|
|
637
|
+
});
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
describe('addComment', () => {
|
|
641
|
+
it('should add comment with ADF format', async () => {
|
|
642
|
+
const mockResponse = {
|
|
643
|
+
data: {
|
|
644
|
+
id: '100',
|
|
645
|
+
body: {
|
|
646
|
+
type: 'doc',
|
|
647
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Test comment' }] }]
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
mockAxiosInstance.post.mockResolvedValue(mockResponse);
|
|
652
|
+
|
|
653
|
+
const result = await jiraService.addComment('TEST-1', 'Test comment');
|
|
654
|
+
|
|
655
|
+
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
|
|
656
|
+
'/rest/api/3/issue/TEST-1/comment',
|
|
657
|
+
expect.objectContaining({
|
|
658
|
+
body: expect.objectContaining({
|
|
659
|
+
type: 'doc',
|
|
660
|
+
content: expect.arrayContaining([
|
|
661
|
+
expect.objectContaining({
|
|
662
|
+
type: 'paragraph'
|
|
663
|
+
})
|
|
664
|
+
])
|
|
665
|
+
})
|
|
666
|
+
})
|
|
667
|
+
);
|
|
668
|
+
expect(result.bodyText).toBe('Test comment');
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it('should handle addComment response without body', async () => {
|
|
672
|
+
const mockResponse = {
|
|
673
|
+
data: {
|
|
674
|
+
id: '100'
|
|
675
|
+
}
|
|
676
|
+
};
|
|
677
|
+
mockAxiosInstance.post.mockResolvedValue(mockResponse);
|
|
678
|
+
|
|
679
|
+
const result = await jiraService.addComment('TEST-1', 'Test comment');
|
|
680
|
+
|
|
681
|
+
expect(result.bodyText).toBeUndefined();
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
it('should throw error on API failure', async () => {
|
|
685
|
+
mockAxiosInstance.post.mockRejectedValue(new Error('Failed to add comment'));
|
|
686
|
+
|
|
687
|
+
await expect(jiraService.addComment('TEST-1', 'Test'))
|
|
688
|
+
.rejects.toThrow('Failed to add comment');
|
|
689
|
+
});
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
describe('getTransitions', () => {
|
|
693
|
+
it('should fetch available transitions', async () => {
|
|
694
|
+
const mockTransitions = [
|
|
695
|
+
{ id: '1', name: 'To Do' },
|
|
696
|
+
{ id: '2', name: 'In Progress' }
|
|
697
|
+
];
|
|
698
|
+
mockAxiosInstance.get.mockResolvedValue({
|
|
699
|
+
data: { transitions: mockTransitions }
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
const result = await jiraService.getTransitions('TEST-1');
|
|
703
|
+
|
|
704
|
+
expect(result).toEqual(mockTransitions);
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
it('should return empty array when no transitions', async () => {
|
|
708
|
+
mockAxiosInstance.get.mockResolvedValue({ data: {} });
|
|
709
|
+
|
|
710
|
+
const result = await jiraService.getTransitions('TEST-1');
|
|
711
|
+
|
|
712
|
+
expect(result).toEqual([]);
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
it('should throw error on API failure', async () => {
|
|
716
|
+
mockAxiosInstance.get.mockRejectedValue(new Error('API Error'));
|
|
717
|
+
|
|
718
|
+
await expect(jiraService.getTransitions('TEST-1')).rejects.toThrow();
|
|
719
|
+
});
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
describe('transitionIssue', () => {
|
|
723
|
+
it('should transition issue with given transitionId', async () => {
|
|
724
|
+
mockAxiosInstance.post.mockResolvedValue({ data: {} });
|
|
725
|
+
|
|
726
|
+
const result = await jiraService.transitionIssue('TEST-1', '3');
|
|
727
|
+
|
|
728
|
+
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
|
|
729
|
+
'/rest/api/3/issue/TEST-1/transitions',
|
|
730
|
+
{ transition: { id: '3' } }
|
|
731
|
+
);
|
|
732
|
+
expect(result.success).toBe(true);
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
it('should throw error on API failure', async () => {
|
|
736
|
+
mockAxiosInstance.post.mockRejectedValue(new Error('Transition failed'));
|
|
737
|
+
|
|
738
|
+
await expect(jiraService.transitionIssue('TEST-1', '3'))
|
|
739
|
+
.rejects.toThrow();
|
|
740
|
+
});
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
describe('getAssignableUsers', () => {
|
|
744
|
+
it('should fetch assignable users', async () => {
|
|
745
|
+
const mockUsers = [{ accountId: '1', displayName: 'John' }];
|
|
746
|
+
mockAxiosInstance.get.mockResolvedValue({ data: mockUsers });
|
|
747
|
+
|
|
748
|
+
const result = await jiraService.getAssignableUsers('TEST-1');
|
|
749
|
+
|
|
750
|
+
expect(result).toEqual(mockUsers);
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
it('should filter by query when provided', async () => {
|
|
754
|
+
mockAxiosInstance.get.mockResolvedValue({ data: [] });
|
|
755
|
+
|
|
756
|
+
await jiraService.getAssignableUsers('TEST-1', 'john');
|
|
757
|
+
|
|
758
|
+
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
|
|
759
|
+
'/rest/api/3/user/assignable/search',
|
|
760
|
+
{ params: { issueKey: 'TEST-1', query: 'john' } }
|
|
761
|
+
);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
it('should return from cache when cache hit', async () => {
|
|
765
|
+
const cachedUsers = [{ accountId: '1', displayName: 'Cached User' }];
|
|
766
|
+
mockCacheInstance.get.mockReturnValue(cachedUsers);
|
|
767
|
+
|
|
768
|
+
const result = await jiraService.getAssignableUsers('TEST-1');
|
|
769
|
+
|
|
770
|
+
expect(result).toEqual(cachedUsers);
|
|
771
|
+
expect(mockAxiosInstance.get).not.toHaveBeenCalled();
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
it('should return from cache when cache hit with query', async () => {
|
|
775
|
+
const cachedUsers = [{ accountId: '1', displayName: 'Cached User' }];
|
|
776
|
+
mockCacheInstance.get.mockReturnValue(cachedUsers);
|
|
777
|
+
|
|
778
|
+
const result = await jiraService.getAssignableUsers('TEST-1', 'john');
|
|
779
|
+
|
|
780
|
+
expect(result).toEqual(cachedUsers);
|
|
781
|
+
expect(mockAxiosInstance.get).not.toHaveBeenCalled();
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it('should handle empty response data', async () => {
|
|
785
|
+
mockCacheInstance.get.mockReturnValue(undefined);
|
|
786
|
+
mockAxiosInstance.get.mockResolvedValue({ data: null });
|
|
787
|
+
|
|
788
|
+
const result = await jiraService.getAssignableUsers('TEST-1');
|
|
789
|
+
|
|
790
|
+
expect(result).toEqual([]);
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
it('should throw error on API failure', async () => {
|
|
794
|
+
mockCacheInstance.get.mockReturnValue(undefined);
|
|
795
|
+
mockAxiosInstance.get.mockRejectedValue(new Error('API Error'));
|
|
796
|
+
|
|
797
|
+
await expect(jiraService.getAssignableUsers('TEST-1')).rejects.toThrow();
|
|
798
|
+
});
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
describe('assignIssue', () => {
|
|
802
|
+
it('should assign issue to user', async () => {
|
|
803
|
+
mockAxiosInstance.put.mockResolvedValue({ data: {} });
|
|
804
|
+
|
|
805
|
+
const result = await jiraService.assignIssue('TEST-1', 'account123');
|
|
806
|
+
|
|
807
|
+
expect(mockAxiosInstance.put).toHaveBeenCalledWith(
|
|
808
|
+
'/rest/api/3/issue/TEST-1/assignee',
|
|
809
|
+
{ accountId: 'account123' }
|
|
810
|
+
);
|
|
811
|
+
expect(result.success).toBe(true);
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
it('should unassign issue when accountId is null', async () => {
|
|
815
|
+
mockAxiosInstance.put.mockResolvedValue({ data: {} });
|
|
816
|
+
|
|
817
|
+
await jiraService.assignIssue('TEST-1', null);
|
|
818
|
+
|
|
819
|
+
expect(mockAxiosInstance.put).toHaveBeenCalledWith(
|
|
820
|
+
'/rest/api/3/issue/TEST-1/assignee',
|
|
821
|
+
{ accountId: null }
|
|
822
|
+
);
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
it('should throw error on API failure', async () => {
|
|
826
|
+
mockAxiosInstance.put.mockRejectedValue(new Error('Assignment failed'));
|
|
827
|
+
|
|
828
|
+
await expect(jiraService.assignIssue('TEST-1', 'user1'))
|
|
829
|
+
.rejects.toThrow();
|
|
830
|
+
});
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
describe('attachFile', () => {
|
|
834
|
+
it('should attach file with FormData', async () => {
|
|
835
|
+
mockAxiosInstance.post.mockResolvedValue({ data: [{ filename: 'test.pdf' }] });
|
|
836
|
+
|
|
837
|
+
await jiraService.attachFile(
|
|
838
|
+
'TEST-1',
|
|
839
|
+
Buffer.from('test'),
|
|
840
|
+
'test.pdf',
|
|
841
|
+
'application/pdf'
|
|
842
|
+
);
|
|
843
|
+
|
|
844
|
+
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
|
|
845
|
+
'/rest/api/3/issue/TEST-1/attachments',
|
|
846
|
+
expect.any(Object),
|
|
847
|
+
expect.objectContaining({
|
|
848
|
+
headers: expect.objectContaining({
|
|
849
|
+
'X-Atlassian-Token': 'no-check'
|
|
850
|
+
})
|
|
851
|
+
})
|
|
852
|
+
);
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
it('should throw error on API failure', async () => {
|
|
856
|
+
mockAxiosInstance.post.mockRejectedValue(new Error('Upload failed'));
|
|
857
|
+
|
|
858
|
+
await expect(jiraService.attachFile('TEST-1', Buffer.from('test'), 'test.pdf', 'application/pdf'))
|
|
859
|
+
.rejects.toThrow();
|
|
860
|
+
});
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
describe('getProjects', () => {
|
|
864
|
+
it('should fetch all projects', async () => {
|
|
865
|
+
const mockProjects = [
|
|
866
|
+
{ key: 'TEST', name: 'Test Project' },
|
|
867
|
+
{ key: 'PROD', name: 'Production' }
|
|
868
|
+
];
|
|
869
|
+
mockAxiosInstance.get.mockResolvedValue({ data: mockProjects });
|
|
870
|
+
|
|
871
|
+
const result = await jiraService.getProjects();
|
|
872
|
+
|
|
873
|
+
expect(result).toEqual(mockProjects);
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
it('should return empty array when response data is null', async () => {
|
|
877
|
+
mockAxiosInstance.get.mockResolvedValue({ data: null });
|
|
878
|
+
|
|
879
|
+
const result = await jiraService.getProjects();
|
|
880
|
+
|
|
881
|
+
expect(result).toEqual([]);
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
it('should throw error on API failure', async () => {
|
|
885
|
+
mockAxiosInstance.get.mockRejectedValue(new Error('API Error'));
|
|
886
|
+
|
|
887
|
+
await expect(jiraService.getProjects()).rejects.toThrow();
|
|
888
|
+
});
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
describe('getIssueTypes', () => {
|
|
892
|
+
it('should fetch issue types for project', async () => {
|
|
893
|
+
const mockTypes = [
|
|
894
|
+
{ id: '1', name: 'Bug' },
|
|
895
|
+
{ id: '2', name: 'Story' }
|
|
896
|
+
];
|
|
897
|
+
mockAxiosInstance.get.mockResolvedValue({
|
|
898
|
+
data: { issueTypes: mockTypes }
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
const result = await jiraService.getIssueTypes('TEST');
|
|
902
|
+
|
|
903
|
+
expect(result).toEqual(mockTypes);
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
it('should return empty array when issueTypes is missing', async () => {
|
|
907
|
+
mockAxiosInstance.get.mockResolvedValue({
|
|
908
|
+
data: {}
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
const result = await jiraService.getIssueTypes('TEST');
|
|
912
|
+
|
|
913
|
+
expect(result).toEqual([]);
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
it('should throw error on API failure', async () => {
|
|
917
|
+
mockAxiosInstance.get.mockRejectedValue(new Error('API Error'));
|
|
918
|
+
|
|
919
|
+
await expect(jiraService.getIssueTypes('TEST')).rejects.toThrow();
|
|
920
|
+
});
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
describe('getProjectVersions', () => {
|
|
924
|
+
it('should fetch versions for project', async () => {
|
|
925
|
+
const mockVersions = [
|
|
926
|
+
{ id: '1', name: 'v1.0' },
|
|
927
|
+
{ id: '2', name: 'v2.0' }
|
|
928
|
+
];
|
|
929
|
+
mockAxiosInstance.get.mockResolvedValue({ data: mockVersions });
|
|
930
|
+
|
|
931
|
+
const result = await jiraService.getProjectVersions('TEST');
|
|
932
|
+
|
|
933
|
+
expect(result).toEqual(mockVersions);
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
it('should return empty array when response data is null', async () => {
|
|
937
|
+
mockAxiosInstance.get.mockResolvedValue({ data: null });
|
|
938
|
+
|
|
939
|
+
const result = await jiraService.getProjectVersions('TEST');
|
|
940
|
+
|
|
941
|
+
expect(result).toEqual([]);
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
it('should throw error on API failure', async () => {
|
|
945
|
+
mockAxiosInstance.get.mockRejectedValue(new Error('API Error'));
|
|
946
|
+
|
|
947
|
+
await expect(jiraService.getProjectVersions('TEST')).rejects.toThrow();
|
|
948
|
+
});
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
describe('createIssue', () => {
|
|
952
|
+
it('should create issue with required fields', async () => {
|
|
953
|
+
const mockResponse = { data: { key: 'TEST-100', id: '100' } };
|
|
954
|
+
mockAxiosInstance.post.mockResolvedValue(mockResponse);
|
|
955
|
+
|
|
956
|
+
const result = await jiraService.createIssue({
|
|
957
|
+
projectKey: 'TEST',
|
|
958
|
+
summary: 'New Issue',
|
|
959
|
+
issueType: '1'
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
|
|
963
|
+
'/rest/api/3/issue',
|
|
964
|
+
expect.objectContaining({
|
|
965
|
+
fields: expect.objectContaining({
|
|
966
|
+
project: { key: 'TEST' },
|
|
967
|
+
summary: 'New Issue',
|
|
968
|
+
issuetype: { id: '1' }
|
|
969
|
+
})
|
|
970
|
+
})
|
|
971
|
+
);
|
|
972
|
+
expect(result.key).toBe('TEST-100');
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
it('should include description in ADF format', async () => {
|
|
976
|
+
const mockResponse = { data: { key: 'TEST-100' } };
|
|
977
|
+
mockAxiosInstance.post.mockResolvedValue(mockResponse);
|
|
978
|
+
|
|
979
|
+
await jiraService.createIssue({
|
|
980
|
+
projectKey: 'TEST',
|
|
981
|
+
summary: 'New Issue',
|
|
982
|
+
issueType: '1',
|
|
983
|
+
description: 'This is a description'
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
|
|
987
|
+
'/rest/api/3/issue',
|
|
988
|
+
expect.objectContaining({
|
|
989
|
+
fields: expect.objectContaining({
|
|
990
|
+
description: expect.objectContaining({
|
|
991
|
+
type: 'doc',
|
|
992
|
+
content: expect.arrayContaining([
|
|
993
|
+
expect.objectContaining({
|
|
994
|
+
type: 'paragraph'
|
|
995
|
+
})
|
|
996
|
+
])
|
|
997
|
+
})
|
|
998
|
+
})
|
|
999
|
+
})
|
|
1000
|
+
);
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
it('should include priority when provided', async () => {
|
|
1004
|
+
const mockResponse = { data: { key: 'TEST-100' } };
|
|
1005
|
+
mockAxiosInstance.post.mockResolvedValue(mockResponse);
|
|
1006
|
+
|
|
1007
|
+
await jiraService.createIssue({
|
|
1008
|
+
projectKey: 'TEST',
|
|
1009
|
+
summary: 'New Issue',
|
|
1010
|
+
issueType: '1',
|
|
1011
|
+
priority: '3'
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
|
|
1015
|
+
'/rest/api/3/issue',
|
|
1016
|
+
expect.objectContaining({
|
|
1017
|
+
fields: expect.objectContaining({
|
|
1018
|
+
priority: { id: '3' }
|
|
1019
|
+
})
|
|
1020
|
+
})
|
|
1021
|
+
);
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
it('should throw error on API failure', async () => {
|
|
1025
|
+
mockAxiosInstance.post.mockRejectedValue(new Error('Create failed'));
|
|
1026
|
+
|
|
1027
|
+
await expect(jiraService.createIssue({
|
|
1028
|
+
projectKey: 'TEST',
|
|
1029
|
+
summary: 'Test',
|
|
1030
|
+
issueType: '1'
|
|
1031
|
+
})).rejects.toThrow();
|
|
1032
|
+
});
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
describe('updateIssue', () => {
|
|
1036
|
+
it('should update issue fields', async () => {
|
|
1037
|
+
mockAxiosInstance.put.mockResolvedValue({ data: {} });
|
|
1038
|
+
|
|
1039
|
+
const result = await jiraService.updateIssue('TEST-1', { summary: 'Updated' });
|
|
1040
|
+
|
|
1041
|
+
expect(mockAxiosInstance.put).toHaveBeenCalledWith(
|
|
1042
|
+
'/rest/api/3/issue/TEST-1',
|
|
1043
|
+
{ fields: { summary: 'Updated' } }
|
|
1044
|
+
);
|
|
1045
|
+
expect(result.success).toBe(true);
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
it('should throw error on API failure', async () => {
|
|
1049
|
+
mockAxiosInstance.put.mockRejectedValue(new Error('Update failed'));
|
|
1050
|
+
|
|
1051
|
+
await expect(jiraService.updateIssue('TEST-1', { summary: 'Test' }))
|
|
1052
|
+
.rejects.toThrow();
|
|
1053
|
+
});
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
describe('searchLabels', () => {
|
|
1057
|
+
it('should search labels', async () => {
|
|
1058
|
+
const mockResponse = {
|
|
1059
|
+
data: {
|
|
1060
|
+
results: [
|
|
1061
|
+
{ displayName: 'bug', value: 'bug' },
|
|
1062
|
+
{ displayName: 'bug-fix', value: 'bug-fix' }
|
|
1063
|
+
]
|
|
1064
|
+
}
|
|
1065
|
+
};
|
|
1066
|
+
mockAxiosInstance.get.mockResolvedValue(mockResponse);
|
|
1067
|
+
|
|
1068
|
+
const result = await jiraService.searchLabels('bug');
|
|
1069
|
+
|
|
1070
|
+
expect(result).toEqual(['bug', 'bug-fix']);
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
it('should handle empty query', async () => {
|
|
1074
|
+
mockAxiosInstance.get.mockResolvedValue({ data: { results: [] } });
|
|
1075
|
+
|
|
1076
|
+
const result = await jiraService.searchLabels('');
|
|
1077
|
+
|
|
1078
|
+
expect(result).toEqual([]);
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
it('should return empty array when results is missing', async () => {
|
|
1082
|
+
mockAxiosInstance.get.mockResolvedValue({ data: {} });
|
|
1083
|
+
|
|
1084
|
+
const result = await jiraService.searchLabels('bug');
|
|
1085
|
+
|
|
1086
|
+
expect(result).toEqual([]);
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
it('should throw error on API failure', async () => {
|
|
1090
|
+
mockAxiosInstance.get.mockRejectedValue(new Error('Search failed'));
|
|
1091
|
+
|
|
1092
|
+
await expect(jiraService.searchLabels('bug')).rejects.toThrow();
|
|
1093
|
+
});
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
describe('downloadAttachment', () => {
|
|
1097
|
+
it('should download attachment with stream', async () => {
|
|
1098
|
+
const mockStream = { pipe: jest.fn() };
|
|
1099
|
+
mockAxiosInstance.get.mockResolvedValue({
|
|
1100
|
+
data: mockStream,
|
|
1101
|
+
headers: { 'content-type': 'image/png' }
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
const result = await jiraService.downloadAttachment('https://example.com/file.png');
|
|
1105
|
+
|
|
1106
|
+
expect(result.data).toBe(mockStream);
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
it('should handle relative URLs', async () => {
|
|
1110
|
+
mockAxiosInstance.get.mockResolvedValue({ data: {} });
|
|
1111
|
+
|
|
1112
|
+
await jiraService.downloadAttachment('/rest/api/file.png');
|
|
1113
|
+
|
|
1114
|
+
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
|
|
1115
|
+
expect.stringContaining('/rest/api/file.png'),
|
|
1116
|
+
{ responseType: 'stream' }
|
|
1117
|
+
);
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
it('should throw error on API failure', async () => {
|
|
1121
|
+
mockAxiosInstance.get.mockRejectedValue(new Error('Download failed'));
|
|
1122
|
+
|
|
1123
|
+
await expect(jiraService.downloadAttachment('https://example.com/file.png'))
|
|
1124
|
+
.rejects.toThrow();
|
|
1125
|
+
});
|
|
1126
|
+
});
|
|
1127
|
+
});
|