@elisra-devops/docgen-data-provider 1.63.13 → 1.67.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 (91) hide show
  1. package/.github/workflows/ci.yml +26 -9
  2. package/.github/workflows/release.yml +9 -10
  3. package/bin/helpers/tfs.d.ts +3 -0
  4. package/bin/helpers/tfs.js +44 -7
  5. package/bin/helpers/tfs.js.map +1 -1
  6. package/bin/modules/GitDataProvider.d.ts +10 -0
  7. package/bin/modules/GitDataProvider.js +10 -0
  8. package/bin/modules/GitDataProvider.js.map +1 -1
  9. package/bin/modules/TestDataProvider.js +0 -1
  10. package/bin/modules/TestDataProvider.js.map +1 -1
  11. package/bin/modules/TicketsDataProvider.d.ts +63 -24
  12. package/bin/modules/TicketsDataProvider.js +216 -114
  13. package/bin/modules/TicketsDataProvider.js.map +1 -1
  14. package/bin/tests/helpers/helper.test.js +279 -0
  15. package/bin/tests/helpers/helper.test.js.map +1 -0
  16. package/bin/{helpers/test → tests/helpers}/tfs.test.js +312 -49
  17. package/bin/tests/helpers/tfs.test.js.map +1 -0
  18. package/bin/tests/index.test.js +25 -0
  19. package/bin/tests/index.test.js.map +1 -0
  20. package/bin/tests/models/tfs-data.test.js +160 -0
  21. package/bin/tests/models/tfs-data.test.js.map +1 -0
  22. package/bin/{modules/test → tests/modules}/JfrogDataProvider.test.js +9 -9
  23. package/bin/tests/modules/JfrogDataProvider.test.js.map +1 -0
  24. package/bin/tests/modules/ResultDataProvider.test.js +1942 -0
  25. package/bin/tests/modules/ResultDataProvider.test.js.map +1 -0
  26. package/bin/tests/modules/gitDataProvider.test.js +1888 -0
  27. package/bin/tests/modules/gitDataProvider.test.js.map +1 -0
  28. package/bin/{modules/test → tests/modules}/managmentDataProvider.test.js +13 -1
  29. package/bin/tests/modules/managmentDataProvider.test.js.map +1 -0
  30. package/bin/tests/modules/pipelineDataProvider.test.d.ts +1 -0
  31. package/bin/tests/modules/pipelineDataProvider.test.js +783 -0
  32. package/bin/tests/modules/pipelineDataProvider.test.js.map +1 -0
  33. package/bin/tests/modules/testDataProvider.test.d.ts +1 -0
  34. package/bin/tests/modules/testDataProvider.test.js +717 -0
  35. package/bin/tests/modules/testDataProvider.test.js.map +1 -0
  36. package/bin/tests/modules/ticketsDataProvider.test.d.ts +1 -0
  37. package/bin/tests/modules/ticketsDataProvider.test.js +1681 -0
  38. package/bin/tests/modules/ticketsDataProvider.test.js.map +1 -0
  39. package/bin/tests/utils/DataProviderUtils.test.d.ts +1 -0
  40. package/bin/tests/utils/DataProviderUtils.test.js +61 -0
  41. package/bin/tests/utils/DataProviderUtils.test.js.map +1 -0
  42. package/bin/tests/utils/testStepParserHelper.test.d.ts +1 -0
  43. package/bin/tests/utils/testStepParserHelper.test.js +359 -0
  44. package/bin/tests/utils/testStepParserHelper.test.js.map +1 -0
  45. package/package.json +9 -1
  46. package/src/helpers/tfs.ts +51 -7
  47. package/src/modules/GitDataProvider.ts +10 -0
  48. package/src/modules/TestDataProvider.ts +0 -1
  49. package/src/modules/TicketsDataProvider.ts +298 -141
  50. package/src/tests/helpers/helper.test.ts +337 -0
  51. package/src/tests/helpers/tfs.test.ts +1092 -0
  52. package/src/tests/index.test.ts +28 -0
  53. package/src/tests/models/tfs-data.test.ts +203 -0
  54. package/src/tests/modules/JfrogDataProvider.test.ts +167 -0
  55. package/src/tests/modules/ResultDataProvider.test.ts +2571 -0
  56. package/src/tests/modules/gitDataProvider.test.ts +2628 -0
  57. package/src/{modules/test → tests/modules}/managmentDataProvider.test.ts +33 -1
  58. package/src/tests/modules/pipelineDataProvider.test.ts +1038 -0
  59. package/src/tests/modules/testDataProvider.test.ts +1046 -0
  60. package/src/tests/modules/ticketsDataProvider.test.ts +2204 -0
  61. package/src/tests/utils/DataProviderUtils.test.ts +76 -0
  62. package/src/tests/utils/testStepParserHelper.test.ts +437 -0
  63. package/tsconfig.json +1 -0
  64. package/bin/helpers/test/tfs.test.js.map +0 -1
  65. package/bin/modules/test/JfrogDataProvider.test.js.map +0 -1
  66. package/bin/modules/test/ResultDataProvider.test.js +0 -444
  67. package/bin/modules/test/ResultDataProvider.test.js.map +0 -1
  68. package/bin/modules/test/gitDataProvider.test.js +0 -428
  69. package/bin/modules/test/gitDataProvider.test.js.map +0 -1
  70. package/bin/modules/test/managmentDataProvider.test.js.map +0 -1
  71. package/bin/modules/test/pipelineDataProvider.test.js +0 -237
  72. package/bin/modules/test/pipelineDataProvider.test.js.map +0 -1
  73. package/bin/modules/test/testDataProvider.test.js +0 -234
  74. package/bin/modules/test/testDataProvider.test.js.map +0 -1
  75. package/bin/modules/test/ticketsDataProvider.test.js +0 -348
  76. package/bin/modules/test/ticketsDataProvider.test.js.map +0 -1
  77. package/src/helpers/test/tfs.test.ts +0 -748
  78. package/src/modules/test/JfrogDataProvider.test.ts +0 -171
  79. package/src/modules/test/ResultDataProvider.test.ts +0 -542
  80. package/src/modules/test/gitDataProvider.test.ts +0 -645
  81. package/src/modules/test/pipelineDataProvider.test.ts +0 -292
  82. package/src/modules/test/testDataProvider.test.ts +0 -318
  83. package/src/modules/test/ticketsDataProvider.test.ts +0 -462
  84. /package/bin/{helpers/test/tfs.test.d.ts → tests/helpers/helper.test.d.ts} +0 -0
  85. /package/bin/{modules/test/JfrogDataProvider.test.d.ts → tests/helpers/tfs.test.d.ts} +0 -0
  86. /package/bin/{modules/test/ResultDataProvider.test.d.ts → tests/index.test.d.ts} +0 -0
  87. /package/bin/{modules/test/gitDataProvider.test.d.ts → tests/models/tfs-data.test.d.ts} +0 -0
  88. /package/bin/{modules/test/managmentDataProvider.test.d.ts → tests/modules/JfrogDataProvider.test.d.ts} +0 -0
  89. /package/bin/{modules/test/pipelineDataProvider.test.d.ts → tests/modules/ResultDataProvider.test.d.ts} +0 -0
  90. /package/bin/{modules/test/testDataProvider.test.d.ts → tests/modules/gitDataProvider.test.d.ts} +0 -0
  91. /package/bin/{modules/test/ticketsDataProvider.test.d.ts → tests/modules/managmentDataProvider.test.d.ts} +0 -0
@@ -0,0 +1,2204 @@
1
+ import { TFSServices } from '../../helpers/tfs';
2
+ import TicketsDataProvider from '../../modules/TicketsDataProvider';
3
+ import logger from '../../utils/logger';
4
+ import { Helper } from '../../helpers/helper';
5
+ import { QueryType } from '../../models/tfs-data';
6
+
7
+ jest.mock('../../helpers/tfs');
8
+ jest.mock('../../utils/logger');
9
+ jest.mock('../../helpers/helper');
10
+
11
+ describe('TicketsDataProvider', () => {
12
+ let ticketsDataProvider: TicketsDataProvider;
13
+ const mockOrgUrl = 'https://dev.azure.com/organization/';
14
+ const mockToken = 'mock-token';
15
+ const mockProject = 'test-project';
16
+
17
+ beforeEach(() => {
18
+ jest.clearAllMocks();
19
+ ticketsDataProvider = new TicketsDataProvider(mockOrgUrl, mockToken);
20
+ });
21
+
22
+ describe('isLinkSideAllowedByTypeOrId', () => {
23
+ it('should accept when allowedTypes is empty and field is present', async () => {
24
+ const wiql = "SELECT * FROM WorkItemLinks WHERE Source.[System.WorkItemType] = 'Epic'";
25
+ const result = await (ticketsDataProvider as any).isLinkSideAllowedByTypeOrId(
26
+ {},
27
+ wiql,
28
+ 'Source',
29
+ [],
30
+ new Map()
31
+ );
32
+ expect(result).toBe(true);
33
+ });
34
+
35
+ it('should return false when allowedTypes is empty but field is not present', async () => {
36
+ const wiql = 'SELECT * FROM WorkItems';
37
+ const result = await (ticketsDataProvider as any).isLinkSideAllowedByTypeOrId(
38
+ {},
39
+ wiql,
40
+ 'Source',
41
+ [],
42
+ new Map()
43
+ );
44
+ expect(result).toBe(false);
45
+ });
46
+
47
+ it('should support equality operator and reject disallowed types', async () => {
48
+ const wiql = "SELECT * FROM WorkItemLinks WHERE Source.[System.WorkItemType] = 'Bug'";
49
+ const result = await (ticketsDataProvider as any).isLinkSideAllowedByTypeOrId(
50
+ {},
51
+ wiql,
52
+ 'Source',
53
+ ['Epic', 'Feature'],
54
+ new Map()
55
+ );
56
+ expect(result).toBe(false);
57
+ });
58
+
59
+ it('should support IN operator when all types are allowed', async () => {
60
+ const wiql = "SELECT * FROM WorkItemLinks WHERE Target.[System.WorkItemType] IN ('Requirement', 'Bug')";
61
+ const result = await (ticketsDataProvider as any).isLinkSideAllowedByTypeOrId(
62
+ {},
63
+ wiql,
64
+ 'Target',
65
+ ['Requirement', 'Bug'],
66
+ new Map()
67
+ );
68
+ expect(result).toBe(true);
69
+ });
70
+
71
+ it('should return false when no types are found in WIQL and no ids exist', async () => {
72
+ const wiql = "SELECT * FROM WorkItemLinks WHERE Target.[System.AreaPath] = 'A'";
73
+ const result = await (ticketsDataProvider as any).isLinkSideAllowedByTypeOrId(
74
+ {},
75
+ wiql,
76
+ 'Target',
77
+ ['Bug'],
78
+ new Map()
79
+ );
80
+ expect(result).toBe(false);
81
+ });
82
+ });
83
+
84
+ describe('findChildFolderByPossibleNames', () => {
85
+ it('should return null when parent is missing or possibleNames is empty', async () => {
86
+ await expect(
87
+ (ticketsDataProvider as any).findChildFolderByPossibleNames(null, ['a'])
88
+ ).resolves.toBeNull();
89
+ await expect(
90
+ (ticketsDataProvider as any).findChildFolderByPossibleNames({ hasChildren: false }, [])
91
+ ).resolves.toBeNull();
92
+ });
93
+
94
+ it('should prefer exact match over partial match and return the enriched folder', async () => {
95
+ const parent: any = {
96
+ id: 'p',
97
+ isFolder: true,
98
+ name: 'Parent',
99
+ hasChildren: true,
100
+ children: [
101
+ { id: 'c1', isFolder: true, name: 'Requirement - Test', hasChildren: false },
102
+ { id: 'c2', isFolder: true, name: 'requirement to test case', hasChildren: false },
103
+ ],
104
+ };
105
+
106
+ const ensureSpy = jest
107
+ .spyOn(ticketsDataProvider as any, 'ensureQueryChildren')
108
+ .mockImplementation(async (node: any) => node);
109
+
110
+ const result = await (ticketsDataProvider as any).findChildFolderByPossibleNames(parent, [
111
+ 'requirement - test',
112
+ 'req',
113
+ ]);
114
+ expect(result).toBe(parent.children[0]);
115
+ expect(ensureSpy).toHaveBeenCalled();
116
+ });
117
+
118
+ it('should return first partial match when no exact match exists', async () => {
119
+ const parent: any = {
120
+ id: 'p',
121
+ isFolder: true,
122
+ name: 'Parent',
123
+ hasChildren: true,
124
+ children: [
125
+ { id: 'c1', isFolder: true, name: 'Some Req Folder', hasChildren: false },
126
+ { id: 'c2', isFolder: true, name: 'Other', hasChildren: false },
127
+ ],
128
+ };
129
+
130
+ jest
131
+ .spyOn(ticketsDataProvider as any, 'ensureQueryChildren')
132
+ .mockImplementation(async (node: any) => node);
133
+
134
+ const result = await (ticketsDataProvider as any).findChildFolderByPossibleNames(parent, ['req']);
135
+ expect(result).toBe(parent.children[0]);
136
+ });
137
+
138
+ it('should BFS into nested folders and ignore non-folder children and visited duplicates', async () => {
139
+ const leaf: any = { id: 'leaf', isFolder: true, name: 'Deep Match', hasChildren: false, children: [] };
140
+ const parent: any = {
141
+ id: 'p',
142
+ isFolder: true,
143
+ name: 'Parent',
144
+ hasChildren: true,
145
+ children: [
146
+ { id: 'a', isFolder: true, name: 'A', hasChildren: true },
147
+ { id: 'b', isFolder: true, name: 'B', hasChildren: true },
148
+ ],
149
+ };
150
+
151
+ jest.spyOn(ticketsDataProvider as any, 'ensureQueryChildren').mockImplementation(async (node: any) => {
152
+ if (node?.id === 'a') {
153
+ return {
154
+ ...node,
155
+ children: [{ id: 'not-folder', isFolder: false, name: 'NF' }, leaf, leaf],
156
+ };
157
+ }
158
+ if (node?.id === 'b') {
159
+ return { ...node, children: [leaf] };
160
+ }
161
+ return node;
162
+ });
163
+
164
+ const found = await (ticketsDataProvider as any).findChildFolderByPossibleNames(parent, ['deep match']);
165
+ expect(found).toEqual(leaf);
166
+ });
167
+ });
168
+
169
+ describe('fetchWithAncestorFallback', () => {
170
+ it('should short-circuit when validator passes on the first candidate', async () => {
171
+ const root: any = { id: 'root', name: 'Root', hasChildren: false, children: [] };
172
+ const starting: any = { id: 'start', name: 'Start', hasChildren: false, children: [] };
173
+
174
+ jest.spyOn(ticketsDataProvider as any, 'ensureQueryChildren').mockImplementation(async (n: any) => n);
175
+ jest.spyOn(ticketsDataProvider as any, 'buildFallbackChain').mockResolvedValueOnce([starting, root]);
176
+
177
+ const fetcher = jest.fn().mockResolvedValueOnce({ ok: true });
178
+ const validator = jest.fn().mockReturnValueOnce(true);
179
+
180
+ const res = await (ticketsDataProvider as any).fetchWithAncestorFallback(
181
+ root,
182
+ starting,
183
+ fetcher,
184
+ 'ctx',
185
+ validator
186
+ );
187
+ expect(res.usedFolder).toBe(starting);
188
+ expect(fetcher).toHaveBeenCalledTimes(1);
189
+ expect(validator).toHaveBeenCalledWith({ ok: true });
190
+ });
191
+
192
+ it('should fall back through all candidates and return last attempt when validator never passes', async () => {
193
+ const root: any = { id: 'root', name: 'Root', hasChildren: false, children: [] };
194
+ const a: any = { id: 'a', name: 'A', hasChildren: false, children: [] };
195
+ const b: any = { id: 'b', name: 'B', hasChildren: false, children: [] };
196
+
197
+ jest.spyOn(ticketsDataProvider as any, 'ensureQueryChildren').mockImplementation(async (n: any) => n);
198
+ jest.spyOn(ticketsDataProvider as any, 'buildFallbackChain').mockResolvedValueOnce([a, b, root]);
199
+
200
+ const fetcher = jest
201
+ .fn()
202
+ .mockResolvedValueOnce(null)
203
+ .mockResolvedValueOnce({})
204
+ .mockResolvedValueOnce({ last: true });
205
+ const validator = jest.fn().mockReturnValue(false);
206
+
207
+ const res = await (ticketsDataProvider as any).fetchWithAncestorFallback(
208
+ root,
209
+ a,
210
+ fetcher,
211
+ 'ctx',
212
+ validator
213
+ );
214
+ expect(res.usedFolder).toBe(root);
215
+ expect(res.result).toEqual({ last: true });
216
+ expect(fetcher).toHaveBeenCalledTimes(3);
217
+ });
218
+ });
219
+
220
+ describe('FetchImageAsBase64', () => {
221
+ it('should fetch image as base64', async () => {
222
+ // Arrange
223
+ const mockUrl = 'https://example.com/image.jpg';
224
+ const mockBase64 = 'base64-encoded-image';
225
+ (TFSServices.fetchAzureDevOpsImageAsBase64 as jest.Mock).mockResolvedValueOnce(mockBase64);
226
+
227
+ // Act
228
+ const result = await ticketsDataProvider.FetchImageAsBase64(mockUrl);
229
+
230
+ // Assert
231
+ expect(TFSServices.fetchAzureDevOpsImageAsBase64).toHaveBeenCalledWith(mockUrl, mockToken, 'get', null);
232
+ expect(result).toBe(mockBase64);
233
+ });
234
+ });
235
+
236
+ describe('GetWorkItem', () => {
237
+ it('should fetch work item with correct URL', async () => {
238
+ // Arrange
239
+ const mockId = '123';
240
+ const mockWorkItem = { id: 123, fields: { 'System.Title': 'Test Work Item' } };
241
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockWorkItem);
242
+
243
+ // Act
244
+ const result = await ticketsDataProvider.GetWorkItem(mockProject, mockId);
245
+
246
+ // Assert
247
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
248
+ `${mockOrgUrl}${mockProject}/_apis/wit/workitems/${mockId}?$expand=All`,
249
+ mockToken
250
+ );
251
+ expect(result).toEqual(mockWorkItem);
252
+ });
253
+ });
254
+
255
+ describe('GetLinksByIds', () => {
256
+ it('should retrieve links by ids', async () => {
257
+ // Arrange
258
+ const mockIds = [1, 2];
259
+ const mockWorkItems = [
260
+ { id: 1, fields: { 'System.Title': 'Item 1' } },
261
+ { id: 2, fields: { 'System.Title': 'Item 2' } },
262
+ ];
263
+ const mockLinksMap = new Map();
264
+ mockLinksMap.set('1', { id: '1', rels: ['3'] });
265
+ mockLinksMap.set('2', { id: '2', rels: [] });
266
+
267
+ const mockRelatedItems = [{ id: 3, fields: { 'System.Title': 'Related Item' } }];
268
+ const mockTraceItem = { id: '1', title: 'Item 1', url: 'url', customerId: 'customer', links: [] };
269
+
270
+ jest.spyOn(ticketsDataProvider, 'PopulateWorkItemsByIds').mockResolvedValueOnce(mockWorkItems);
271
+ jest.spyOn(ticketsDataProvider, 'GetRelationsIds').mockResolvedValueOnce(mockLinksMap);
272
+ jest
273
+ .spyOn(ticketsDataProvider, 'GetParentLink')
274
+ .mockResolvedValueOnce(mockTraceItem)
275
+ .mockResolvedValueOnce({ id: '2', title: 'Item 2', url: 'url', customerId: 'customer', links: [] });
276
+ jest.spyOn(ticketsDataProvider, 'PopulateWorkItemsByIds').mockResolvedValueOnce(mockRelatedItems);
277
+ jest.spyOn(ticketsDataProvider, 'GetLinks').mockResolvedValueOnce([]);
278
+
279
+ // Act
280
+ const result = await ticketsDataProvider.GetLinksByIds(mockProject, mockIds);
281
+
282
+ // Assert
283
+ expect(result.length).toBe(2);
284
+ expect(ticketsDataProvider.PopulateWorkItemsByIds).toHaveBeenCalledWith(mockIds, mockProject);
285
+ expect(ticketsDataProvider.GetRelationsIds).toHaveBeenCalledWith(mockWorkItems);
286
+ });
287
+ });
288
+
289
+ describe('GetSharedQueries', () => {
290
+ it('should fetch STD shared queries with correct URL', async () => {
291
+ // Arrange
292
+ const mockPath = '';
293
+ const mockDocType = 'STD';
294
+ const mockQueries = { name: 'Query 1' } as any;
295
+ const mockBranchesResponse = {
296
+ reqToTest: { result: { reqTestTree: {} }, usedFolder: {} },
297
+ testToReq: { result: { testReqTree: {} }, usedFolder: {} },
298
+ mom: { result: { linkedMomTree: {} }, usedFolder: {} },
299
+ } as any;
300
+
301
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockQueries);
302
+ jest.spyOn(ticketsDataProvider as any, 'ensureQueryChildren').mockResolvedValueOnce(mockQueries);
303
+ jest
304
+ .spyOn(ticketsDataProvider as any, 'getDocTypeRoot')
305
+ .mockResolvedValueOnce({ root: mockQueries, found: true });
306
+ jest
307
+ .spyOn(ticketsDataProvider as any, 'fetchDocTypeBranches')
308
+ .mockResolvedValueOnce(mockBranchesResponse);
309
+
310
+ // Act
311
+ const result = await ticketsDataProvider.GetSharedQueries(mockProject, mockPath, mockDocType);
312
+
313
+ // Assert
314
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
315
+ `${mockOrgUrl}${mockProject}/_apis/wit/queries/Shared%20Queries?$depth=2&$expand=all`,
316
+ mockToken
317
+ );
318
+ expect(result).toEqual({
319
+ reqTestQueries: { reqTestTree: {}, testReqTree: {} },
320
+ linkedMomQueries: { linkedMomTree: {} },
321
+ });
322
+ });
323
+
324
+ it('should fetch SVD shared queries and call fetchAnyQueries', async () => {
325
+ // Arrange
326
+ const mockPath = 'Custom Path';
327
+ const mockDocType = 'SVD';
328
+ const mockQueries = { name: 'Query 1' } as any;
329
+ const mockBranchesResponse = {
330
+ systemOverview: { result: {}, usedFolder: {} },
331
+ knownBugs: { result: {}, usedFolder: {} },
332
+ } as any;
333
+
334
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockQueries);
335
+ jest.spyOn(ticketsDataProvider as any, 'ensureQueryChildren').mockResolvedValueOnce(mockQueries);
336
+ jest
337
+ .spyOn(ticketsDataProvider as any, 'getDocTypeRoot')
338
+ .mockResolvedValueOnce({ root: mockQueries, found: true });
339
+ jest
340
+ .spyOn(ticketsDataProvider as any, 'fetchDocTypeBranches')
341
+ .mockResolvedValueOnce(mockBranchesResponse);
342
+
343
+ // Act
344
+ const result = await ticketsDataProvider.GetSharedQueries(mockProject, mockPath, mockDocType);
345
+
346
+ // Assert
347
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
348
+ `${mockOrgUrl}${mockProject}/_apis/wit/queries/${mockPath}?$depth=2&$expand=all`,
349
+ mockToken
350
+ );
351
+ expect(result).toEqual({
352
+ systemOverviewQueryTree: {},
353
+ knownBugsQueryTree: {},
354
+ });
355
+ });
356
+
357
+ it('should handle errors', async () => {
358
+ // Arrange
359
+ const mockPath = '';
360
+ const mockError = new Error('API error');
361
+
362
+ (TFSServices.getItemContent as jest.Mock).mockImplementationOnce(() => {
363
+ return Promise.reject(mockError);
364
+ });
365
+
366
+ // Act & Assert
367
+ await expect(ticketsDataProvider.GetSharedQueries(mockProject, mockPath)).rejects.toThrow('API error');
368
+ expect(logger.error).toHaveBeenCalled();
369
+ });
370
+
371
+ it('should fall back to reqToTestResult.testReqTree when testToReqResult is missing (std)', async () => {
372
+ const mockQueries = { name: 'QueriesRoot' } as any;
373
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockQueries);
374
+ jest.spyOn(ticketsDataProvider as any, 'ensureQueryChildren').mockResolvedValueOnce(mockQueries);
375
+ jest
376
+ .spyOn(ticketsDataProvider as any, 'getDocTypeRoot')
377
+ .mockResolvedValueOnce({ root: mockQueries, found: true });
378
+
379
+ jest.spyOn(ticketsDataProvider as any, 'fetchDocTypeBranches').mockResolvedValueOnce({
380
+ reqToTest: {
381
+ result: {
382
+ reqTestTree: { a: 1 },
383
+ testReqTree: { fallback: true },
384
+ },
385
+ usedFolder: { name: 'req-to-test' },
386
+ },
387
+ // missing testToReq
388
+ mom: { result: { linkedMomTree: null }, usedFolder: { name: 'mom' } },
389
+ });
390
+
391
+ const res = await ticketsDataProvider.GetSharedQueries(mockProject, '', 'std');
392
+ expect(res).toEqual({
393
+ reqTestQueries: { reqTestTree: { a: 1 }, testReqTree: { fallback: true } },
394
+ linkedMomQueries: { linkedMomTree: null },
395
+ });
396
+ });
397
+
398
+ it('should fall back to openPcrToTest.TestToOpenPcrTree when testToOpenPcr branch is missing (str)', async () => {
399
+ const mockQueries = { name: 'QueriesRoot' } as any;
400
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockQueries);
401
+ jest.spyOn(ticketsDataProvider as any, 'ensureQueryChildren').mockResolvedValueOnce(mockQueries);
402
+ jest
403
+ .spyOn(ticketsDataProvider as any, 'getDocTypeRoot')
404
+ .mockResolvedValueOnce({ root: mockQueries, found: true });
405
+
406
+ jest.spyOn(ticketsDataProvider as any, 'fetchDocTypeBranches').mockResolvedValueOnce({
407
+ reqToTest: { result: { reqTestTree: null }, usedFolder: { name: 'req-to-test' } },
408
+ testToReq: { result: { testReqTree: null }, usedFolder: { name: 'test-to-req' } },
409
+ openPcrToTest: {
410
+ result: {
411
+ OpenPcrToTestTree: { ok: true },
412
+ TestToOpenPcrTree: { fromOpenPcr: true },
413
+ },
414
+ usedFolder: { name: 'open-pcr-to-test' },
415
+ },
416
+ // missing testToOpenPcr
417
+ });
418
+
419
+ const res = await ticketsDataProvider.GetSharedQueries(mockProject, '', 'str');
420
+ expect(res).toEqual({
421
+ reqTestTrees: { reqTestTree: null, testReqTree: null },
422
+ openPcrTestTrees: {
423
+ OpenPcrToTestTree: { ok: true },
424
+ TestToOpenPcrTree: { fromOpenPcr: true },
425
+ },
426
+ });
427
+ });
428
+
429
+ it('should execute std validators via fetchDocTypeBranches and accept fallback when first folder yields no results', async () => {
430
+ const queries: any = {
431
+ id: 'root',
432
+ name: 'Root',
433
+ isFolder: true,
434
+ hasChildren: true,
435
+ children: [
436
+ { id: 'req', name: 'Requirement - Test', isFolder: true, hasChildren: false },
437
+ { id: 'test', name: 'Test - Requirement', isFolder: true, hasChildren: false },
438
+ { id: 'mom', name: 'MOM', isFolder: true, hasChildren: false },
439
+ ],
440
+ };
441
+
442
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(queries);
443
+ jest.spyOn(ticketsDataProvider as any, 'ensureQueryChildren').mockImplementation(async (x: any) => x);
444
+ jest
445
+ .spyOn(ticketsDataProvider as any, 'getDocTypeRoot')
446
+ .mockResolvedValueOnce({ root: queries, found: true });
447
+
448
+ // Force two candidates so validators run with null and then with data
449
+ jest
450
+ .spyOn(ticketsDataProvider as any, 'buildFallbackChain')
451
+ .mockResolvedValueOnce([queries.children[0], queries])
452
+ .mockResolvedValueOnce([queries.children[1], queries])
453
+ .mockResolvedValueOnce([queries.children[2], queries]);
454
+
455
+ const fetchReqTestSpy = jest
456
+ .spyOn(ticketsDataProvider as any, 'fetchLinkedReqTestQueries')
457
+ .mockResolvedValueOnce(null)
458
+ .mockResolvedValueOnce({ reqTestTree: { isValidQuery: true } })
459
+ .mockResolvedValueOnce(null)
460
+ .mockResolvedValueOnce({ testReqTree: { isValidQuery: true } });
461
+
462
+ const fetchMomSpy = jest
463
+ .spyOn(ticketsDataProvider as any, 'fetchLinkedMomQueries')
464
+ .mockResolvedValueOnce(null)
465
+ .mockResolvedValueOnce({ linkedMomTree: { isValidQuery: true } });
466
+
467
+ const res = await ticketsDataProvider.GetSharedQueries(mockProject, '', 'std');
468
+
469
+ expect(fetchReqTestSpy).toHaveBeenCalled();
470
+ expect(fetchMomSpy).toHaveBeenCalled();
471
+ expect(res.reqTestQueries.reqTestTree).toEqual({ isValidQuery: true });
472
+ expect(res.reqTestQueries.testReqTree).toEqual({ isValidQuery: true });
473
+ expect(res.linkedMomQueries.linkedMomTree).toEqual({ isValidQuery: true });
474
+ });
475
+ });
476
+
477
+ describe('hasAnyQueryTree', () => {
478
+ it('should return true for objects with isValidQuery/wiql/queryType', () => {
479
+ expect((ticketsDataProvider as any).hasAnyQueryTree({ isValidQuery: true })).toBe(true);
480
+ expect((ticketsDataProvider as any).hasAnyQueryTree({ wiql: 'x' })).toBe(true);
481
+ expect((ticketsDataProvider as any).hasAnyQueryTree({ queryType: 'Flat' })).toBe(true);
482
+ });
483
+
484
+ it('should return true for roots/children containers and nested objects, otherwise false', () => {
485
+ expect((ticketsDataProvider as any).hasAnyQueryTree({ roots: [{}] })).toBe(true);
486
+ expect((ticketsDataProvider as any).hasAnyQueryTree({ children: [{}] })).toBe(true);
487
+ expect((ticketsDataProvider as any).hasAnyQueryTree({ a: { b: [{ wiql: 'y' }] } })).toBe(true);
488
+ expect((ticketsDataProvider as any).hasAnyQueryTree({ a: { b: [] } })).toBe(false);
489
+ });
490
+ });
491
+
492
+ describe('matchesAreaPathCondition', () => {
493
+ it('should require both source+target leaf matches when filters are provided', () => {
494
+ const wiql =
495
+ "SELECT * FROM WorkItemLinks WHERE Source.[System.AreaPath] = 'Test CMMI\\System' AND Target.[System.AreaPath] = 'Test CMMI\\Software'";
496
+ const ok = (ticketsDataProvider as any).matchesAreaPathCondition(wiql, 'system', 'software');
497
+ expect(ok).toBe(true);
498
+
499
+ const badTarget = (ticketsDataProvider as any).matchesAreaPathCondition(wiql, 'system', 'nope');
500
+ expect(badTarget).toBe(false);
501
+ });
502
+
503
+ it('should return false when filter is provided but area paths are not present for that owner', () => {
504
+ const wiql = "SELECT * FROM WorkItemLinks WHERE Target.[System.AreaPath] = 'A\\B\\Leaf'";
505
+ const ok = (ticketsDataProvider as any).matchesAreaPathCondition(wiql, 'leaf', 'leaf');
506
+ expect(ok).toBe(false);
507
+ });
508
+ });
509
+
510
+ describe('GetQueryResultsFromWiql', () => {
511
+ it('should handle OneHop query with table format', async () => {
512
+ // Arrange
513
+ const mockWiqlHref = 'https://example.com/wiql';
514
+ const mockTestCaseMap = new Map<number, Set<any>>();
515
+ const mockQueryResult = {
516
+ queryType: QueryType.OneHop,
517
+ columns: [],
518
+ workItemRelations: [{ source: null, target: { id: 1, url: 'url' } }],
519
+ };
520
+ const mockTableResult = {
521
+ sourceTargetsMap: new Map(),
522
+ sortingSourceColumnsMap: new Map(),
523
+ sortingTargetsColumnsMap: new Map(),
524
+ };
525
+
526
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockQueryResult);
527
+ jest
528
+ .spyOn(ticketsDataProvider as any, 'parseDirectLinkedQueryResultForTableFormat')
529
+ .mockResolvedValueOnce(mockTableResult);
530
+
531
+ // Act
532
+ const result = await ticketsDataProvider.GetQueryResultsFromWiql(mockWiqlHref, true, mockTestCaseMap);
533
+
534
+ // Assert
535
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(mockWiqlHref, mockToken);
536
+ expect(result).toEqual(mockTableResult);
537
+ });
538
+
539
+ it('should throw error when wiqlHref is empty', async () => {
540
+ // Arrange
541
+ const mockTestCaseMap = new Map<number, Set<any>>();
542
+
543
+ // Act & Assert
544
+ const result = await ticketsDataProvider.GetQueryResultsFromWiql('', false, mockTestCaseMap);
545
+ expect(logger.error).toHaveBeenCalled();
546
+ expect(result).toBeUndefined();
547
+ });
548
+ });
549
+
550
+ describe('parseDirectLinkedQueryResultForTableFormat', () => {
551
+ it('should throw when workItemRelations is an empty array', async () => {
552
+ await expect(
553
+ (ticketsDataProvider as any).parseDirectLinkedQueryResultForTableFormat(
554
+ { columns: [], workItemRelations: [], queryType: QueryType.OneHop } as any,
555
+ new Map()
556
+ )
557
+ ).rejects.toThrow('No related work items were found');
558
+ });
559
+
560
+ it('should build sourceTargetsMap, include root links, and dedupe testCase->related items', async () => {
561
+ const testCaseToRelatedWiMap = new Map<number, Set<any>>();
562
+ const workItemRelations = [
563
+ { source: null, target: { id: 1 } },
564
+ { source: { id: 1 }, target: { id: 2 } },
565
+ { source: { id: 1 }, target: { id: 2 } },
566
+ ];
567
+
568
+ const fetchSpy = jest
569
+ .spyOn(ticketsDataProvider as any, 'fetchWIForQueryResult')
570
+ .mockImplementation(async (...args: any[]) => {
571
+ const _rel = args[0];
572
+ const columnsToShowMap = args[1] as Map<string, string>;
573
+ // Validate CustomerRequirementId -> Customer ID mapping is prepared
574
+ expect(columnsToShowMap.get('CustomerRequirementId')).toBe('Customer ID');
575
+ const id = _rel?.target?.id ?? _rel?.source?.id;
576
+ if (id === 1) {
577
+ return { id: 1, fields: { 'System.WorkItemType': 'Test Case' } };
578
+ }
579
+ if (id === 2) {
580
+ return { id: 2, fields: { 'System.WorkItemType': 'Bug' } };
581
+ }
582
+ return { id, fields: { 'System.WorkItemType': 'Task' } };
583
+ });
584
+
585
+ const res = await (ticketsDataProvider as any).parseDirectLinkedQueryResultForTableFormat(
586
+ {
587
+ queryType: QueryType.OneHop,
588
+ columns: [{ referenceName: 'CustomerRequirementId', name: 'CustomerRequirementId' }],
589
+ workItemRelations,
590
+ } as any,
591
+ testCaseToRelatedWiMap
592
+ );
593
+
594
+ expect(fetchSpy).toHaveBeenCalled();
595
+ expect(res.sourceTargetsMap).toBeInstanceOf(Map);
596
+
597
+ const keys = Array.from(res.sourceTargetsMap.keys());
598
+ expect(keys.some((k: any) => k.id === 1)).toBe(true);
599
+
600
+ const relatedSet = testCaseToRelatedWiMap.get(1);
601
+ expect(relatedSet).toBeDefined();
602
+ expect(Array.from(relatedSet || [])).toHaveLength(1);
603
+ expect(Array.from(relatedSet || [])[0]).toEqual(expect.objectContaining({ id: 2 }));
604
+ });
605
+
606
+ it('should throw when relation.target is missing', async () => {
607
+ jest
608
+ .spyOn(ticketsDataProvider as any, 'fetchWIForQueryResult')
609
+ .mockResolvedValueOnce({ id: 1, fields: { 'System.WorkItemType': 'Test Case' } });
610
+
611
+ await expect(
612
+ (ticketsDataProvider as any).parseDirectLinkedQueryResultForTableFormat(
613
+ {
614
+ queryType: QueryType.OneHop,
615
+ columns: [],
616
+ workItemRelations: [{ source: { id: 1 }, target: null }],
617
+ } as any,
618
+ new Map()
619
+ )
620
+ ).rejects.toThrow('Target relation is missing');
621
+ });
622
+ });
623
+
624
+ describe('isFlatQueryAllowedByTypeOrId', () => {
625
+ it('should accept when allowedTypes is empty and WIQL references [System.WorkItemType]', async () => {
626
+ const wiql = "SELECT * FROM WorkItems WHERE [System.WorkItemType] = 'Bug'";
627
+ const res = await (ticketsDataProvider as any).isFlatQueryAllowedByTypeOrId({}, wiql, [], new Map());
628
+ expect(res).toBe(true);
629
+ });
630
+
631
+ it('should return false when allowedTypes is provided but no types are found and no ids exist', async () => {
632
+ const wiql = "SELECT * FROM WorkItems WHERE [System.Title] <> ''";
633
+ const res = await (ticketsDataProvider as any).isFlatQueryAllowedByTypeOrId(
634
+ {},
635
+ wiql,
636
+ ['Bug'],
637
+ new Map()
638
+ );
639
+ expect(res).toBe(false);
640
+ });
641
+
642
+ it('should reject when WIQL contains a type outside allowedTypes', async () => {
643
+ const wiql = "SELECT * FROM WorkItems WHERE [System.WorkItemType] IN ('Bug','Task')";
644
+ const res = await (ticketsDataProvider as any).isFlatQueryAllowedByTypeOrId({}, wiql, ['Bug'], new Map());
645
+ expect(res).toBe(false);
646
+ });
647
+ });
648
+
649
+ describe('matchesFlatAreaCondition', () => {
650
+ it('should return true when filter is empty', () => {
651
+ const wiql = 'SELECT * FROM WorkItems';
652
+ const res = (ticketsDataProvider as any).matchesFlatAreaCondition(wiql, '');
653
+ expect(res).toBe(true);
654
+ });
655
+
656
+ it('should return false when no [System.AreaPath] is present in WIQL', () => {
657
+ const wiql = "SELECT * FROM WorkItems WHERE [System.Title] <> ''";
658
+ const res = (ticketsDataProvider as any).matchesFlatAreaCondition(wiql, 'X');
659
+ expect(res).toBe(false);
660
+ });
661
+
662
+ it('should match by leaf segment of area path (case-insensitive)', () => {
663
+ const wiql = "SELECT * FROM WorkItems WHERE [System.AreaPath] = 'A\\B\\Leaf'";
664
+ const res = (ticketsDataProvider as any).matchesFlatAreaCondition(wiql, 'leaf');
665
+ expect(res).toBe(true);
666
+ });
667
+ });
668
+
669
+ describe('filterFieldsByColumns', () => {
670
+ it('should keep fields included by columns map and always include System.WorkItemType and System.Title', () => {
671
+ const item: any = {
672
+ fields: {
673
+ 'System.Title': 'T',
674
+ 'System.WorkItemType': 'Bug',
675
+ 'Custom.Keep': 1,
676
+ 'Custom.Drop': 2,
677
+ },
678
+ };
679
+ const columnsToFilterMap = new Map<string, string>([['Custom.Keep', 'Keep']]);
680
+ const resultedRefNameMap = new Map<string, string>();
681
+
682
+ (ticketsDataProvider as any).filterFieldsByColumns(item, columnsToFilterMap, resultedRefNameMap);
683
+
684
+ expect(Object.keys(item.fields).sort()).toEqual(['Custom.Keep', 'System.Title', 'System.WorkItemType']);
685
+ expect(resultedRefNameMap.get('Custom.Keep')).toBe('Keep');
686
+ expect(resultedRefNameMap.get('System.Title')).toBe('System.Title');
687
+ expect(resultedRefNameMap.get('System.WorkItemType')).toBe('System.WorkItemType');
688
+ });
689
+
690
+ it('should log and throw when item.fields is invalid', () => {
691
+ const item: any = { fields: undefined };
692
+ const columnsToFilterMap = new Map<string, string>();
693
+ const resultedRefNameMap = new Map<string, string>();
694
+
695
+ expect(() =>
696
+ (ticketsDataProvider as any).filterFieldsByColumns(item, columnsToFilterMap, resultedRefNameMap)
697
+ ).toThrow();
698
+ expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Cannot filter columns'));
699
+ });
700
+ });
701
+
702
+ describe('GetWorkItemTypeList', () => {
703
+ it('should fetch work item types and attempt icon download with fallback accepts', async () => {
704
+ (TFSServices.getItemContent as jest.Mock).mockReset();
705
+ if (!(TFSServices as any).fetchAzureDevOpsImageAsBase64) {
706
+ (TFSServices as any).fetchAzureDevOpsImageAsBase64 = jest.fn();
707
+ }
708
+ (TFSServices.fetchAzureDevOpsImageAsBase64 as jest.Mock).mockReset();
709
+
710
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({
711
+ value: [
712
+ {
713
+ name: 'Bug',
714
+ referenceName: 'Microsoft.VSTS.WorkItemTypes.Bug',
715
+ color: 'ff0000',
716
+ icon: { id: 'i', url: 'http://example.com/icon' },
717
+ states: [],
718
+ },
719
+ ],
720
+ });
721
+
722
+ // First accept fails, second succeeds
723
+ (TFSServices.fetchAzureDevOpsImageAsBase64 as jest.Mock)
724
+ .mockRejectedValueOnce(new Error('svg fail'))
725
+ .mockResolvedValueOnce('data:image/png;base64,xxx');
726
+
727
+ const res = await ticketsDataProvider.GetWorkItemTypeList(mockProject);
728
+ expect(res).toHaveLength(1);
729
+ expect(res[0]).toEqual(
730
+ expect.objectContaining({
731
+ name: 'Bug',
732
+ icon: expect.objectContaining({ dataUrl: 'data:image/png;base64,xxx' }),
733
+ })
734
+ );
735
+ expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Failed to download icon'));
736
+ });
737
+ });
738
+
739
+ describe('GetModeledQuery', () => {
740
+ it('should structure query list correctly', () => {
741
+ // Arrange
742
+ const mockQueryList = [
743
+ {
744
+ name: 'Query 1',
745
+ _links: { wiql: 'http://example.com/wiql1' },
746
+ id: 'q1',
747
+ },
748
+ {
749
+ name: 'Query 2',
750
+ _links: { wiql: null },
751
+ id: 'q2',
752
+ },
753
+ ];
754
+
755
+ // Act
756
+ const result = ticketsDataProvider.GetModeledQuery(mockQueryList);
757
+
758
+ // Assert
759
+ expect(result).toEqual([
760
+ { queryName: 'Query 1', wiql: 'http://example.com/wiql1', id: 'q1' },
761
+ { queryName: 'Query 2', wiql: null, id: 'q2' },
762
+ ]);
763
+ });
764
+ });
765
+
766
+ describe('PopulateWorkItemsByIds', () => {
767
+ it('should fetch work items in batches of 200', async () => {
768
+ // Arrange
769
+ const mockIds = Array.from({ length: 250 }, (_, i) => i + 1);
770
+ const mockResponse1 = { value: mockIds.slice(0, 200).map((id) => ({ id })) };
771
+ const mockResponse2 = { value: mockIds.slice(200).map((id) => ({ id })) };
772
+
773
+ (TFSServices.getItemContent as jest.Mock)
774
+ .mockResolvedValueOnce(mockResponse1)
775
+ .mockResolvedValueOnce(mockResponse2);
776
+
777
+ // Act
778
+ const result = await ticketsDataProvider.PopulateWorkItemsByIds(mockIds, mockProject);
779
+
780
+ // Assert
781
+ expect(TFSServices.getItemContent).toHaveBeenCalledTimes(2);
782
+ expect(result.length).toBe(250);
783
+ });
784
+
785
+ it('should handle errors and return empty array', async () => {
786
+ // Arrange
787
+ const mockIds = [1, 2, 3];
788
+ const mockError = new Error('API error');
789
+
790
+ (TFSServices.getItemContent as jest.Mock).mockRejectedValueOnce(mockError);
791
+
792
+ // Act
793
+ const result = await ticketsDataProvider.PopulateWorkItemsByIds(mockIds, mockProject);
794
+
795
+ // Assert
796
+ expect(result).toEqual([]);
797
+ expect(logger.error).toHaveBeenCalled();
798
+ });
799
+ });
800
+
801
+ describe('GetIterationsByTeamName', () => {
802
+ it('should fetch iterations with team name specified', async () => {
803
+ // Arrange
804
+ const mockTeamName = 'test-team';
805
+ const mockIterations = ['iteration1', 'iteration2'];
806
+
807
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockIterations);
808
+
809
+ // Act
810
+ const result = await ticketsDataProvider.GetIterationsByTeamName(mockProject, mockTeamName);
811
+
812
+ // Assert
813
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
814
+ `${mockOrgUrl}${mockProject}/${mockTeamName}/_apis/work/teamsettings/iterations`,
815
+ mockToken,
816
+ 'get'
817
+ );
818
+ expect(result).toEqual(mockIterations);
819
+ });
820
+
821
+ it('should fetch iterations without team name', async () => {
822
+ // Arrange
823
+ const mockTeamName = '';
824
+ const mockIterations = ['iteration1', 'iteration2'];
825
+
826
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockIterations);
827
+
828
+ // Act
829
+ const result = await ticketsDataProvider.GetIterationsByTeamName(mockProject, mockTeamName);
830
+
831
+ // Assert
832
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
833
+ `${mockOrgUrl}${mockProject}/_apis/work/teamsettings/iterations`,
834
+ mockToken,
835
+ 'get'
836
+ );
837
+ expect(result).toEqual(mockIterations);
838
+ });
839
+ });
840
+
841
+ describe('CreateNewWorkItem', () => {
842
+ it('should create work item with correct parameters', async () => {
843
+ // Arrange
844
+ const mockWiBody = [{ op: 'add', path: '/fields/System.Title', value: 'New Item' }];
845
+ const mockWiType = 'Bug';
846
+ const mockByPass = true;
847
+ const mockResponse = { id: 123, fields: { 'System.Title': 'New Item' } };
848
+
849
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockResponse);
850
+
851
+ // Act
852
+ const result = await ticketsDataProvider.CreateNewWorkItem(
853
+ mockProject,
854
+ mockWiBody,
855
+ mockWiType,
856
+ mockByPass
857
+ );
858
+
859
+ // Assert
860
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
861
+ `${mockOrgUrl}${mockProject}/_apis/wit/workitems/$${mockWiType}?bypassRules=true`,
862
+ mockToken,
863
+ 'POST',
864
+ mockWiBody,
865
+ {
866
+ 'Content-Type': 'application/json-patch+json',
867
+ }
868
+ );
869
+ expect(result).toEqual(mockResponse);
870
+ });
871
+ });
872
+
873
+ describe('UpdateWorkItem', () => {
874
+ it('should update work item with correct parameters', async () => {
875
+ // Arrange
876
+ const mockWiBody = [{ op: 'add', path: '/fields/System.Title', value: 'Updated Item' }];
877
+ const mockWorkItemId = 123;
878
+ const mockByPass = true;
879
+ const mockResponse = { id: 123, fields: { 'System.Title': 'Updated Item' } };
880
+
881
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockResponse);
882
+
883
+ // Act
884
+ const result = await ticketsDataProvider.UpdateWorkItem(
885
+ mockProject,
886
+ mockWiBody,
887
+ mockWorkItemId,
888
+ mockByPass
889
+ );
890
+
891
+ // Assert
892
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
893
+ `${mockOrgUrl}${mockProject}/_apis/wit/workitems/${mockWorkItemId}?bypassRules=true`,
894
+ mockToken,
895
+ 'patch',
896
+ mockWiBody,
897
+ {
898
+ 'Content-Type': 'application/json-patch+json',
899
+ }
900
+ );
901
+ expect(result).toEqual(mockResponse);
902
+ });
903
+ });
904
+
905
+ describe('GetWorkitemAttachments', () => {
906
+ it('should return attachments for work item', async () => {
907
+ // Arrange
908
+ const mockId = '123';
909
+ const mockWorkItem = {
910
+ relations: [
911
+ {
912
+ rel: 'AttachedFile',
913
+ url: 'https://example.com/attachment/1',
914
+ attributes: { name: 'file.txt' },
915
+ },
916
+ ],
917
+ };
918
+
919
+ // Mock the TFSServices.getItemContent directly to return our mock data
920
+ // This is what will be called by the new TicketsDataProvider instance
921
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockWorkItem);
922
+
923
+ // Act
924
+ const result = await ticketsDataProvider.GetWorkitemAttachments(mockProject, mockId);
925
+
926
+ // Assert
927
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
928
+ `${mockOrgUrl}${mockProject}/_apis/wit/workitems/${mockId}?$expand=All`,
929
+ mockToken
930
+ );
931
+ expect(result.length).toBe(1);
932
+
933
+ // Check that the downloadUrl was added correctly
934
+ expect(result[0]).toHaveProperty('downloadUrl', 'https://example.com/attachment/1/file.txt');
935
+ expect(result[0].rel).toBe('AttachedFile');
936
+ });
937
+
938
+ it('should return [] when relations are missing', async () => {
939
+ jest
940
+ .spyOn(ticketsDataProvider, 'GetWorkItem')
941
+ .mockResolvedValueOnce({ id: 1, relations: undefined } as any);
942
+ const res = await ticketsDataProvider.GetWorkitemAttachments(mockProject, '1');
943
+ expect(res).toEqual([]);
944
+ });
945
+
946
+ it('should handle work item with no relations', async () => {
947
+ // Arrange
948
+ const mockId = '123';
949
+ const mockWorkItem = { relations: null };
950
+
951
+ jest.spyOn(ticketsDataProvider, 'GetWorkItem').mockResolvedValueOnce(mockWorkItem);
952
+
953
+ // Act
954
+ const result = await ticketsDataProvider.GetWorkitemAttachments(mockProject, mockId);
955
+
956
+ // Assert
957
+ expect(result).toEqual([]);
958
+ });
959
+
960
+ it('should log and return [] when GetWorkItem throws', async () => {
961
+ jest.spyOn(TicketsDataProvider.prototype, 'GetWorkItem').mockRejectedValueOnce(new Error('boom'));
962
+ const res = await ticketsDataProvider.GetWorkitemAttachments(mockProject, '1');
963
+ expect(res).toEqual([]);
964
+ expect(logger.error).toHaveBeenCalled();
965
+ });
966
+
967
+ it('should filter out non-attachment relations', async () => {
968
+ // Arrange
969
+ const mockId = '123';
970
+ const mockWorkItem = {
971
+ relations: [
972
+ {
973
+ rel: 'Parent',
974
+ url: 'https://example.com/parent/1',
975
+ attributes: { name: 'parent' },
976
+ },
977
+ {
978
+ rel: 'AttachedFile',
979
+ url: 'https://example.com/attachment/1',
980
+ attributes: { name: 'file.txt' },
981
+ },
982
+ ],
983
+ };
984
+
985
+ // Mock TFSServices.getItemContent directly instead of GetWorkItem
986
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockWorkItem);
987
+
988
+ // Act
989
+ const result = await ticketsDataProvider.GetWorkitemAttachments(mockProject, mockId);
990
+
991
+ // Assert
992
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
993
+ `${mockOrgUrl}${mockProject}/_apis/wit/workitems/${mockId}?$expand=All`,
994
+ mockToken
995
+ );
996
+ expect(result.length).toBe(1);
997
+ expect(result[0].rel).toBe('AttachedFile');
998
+ // Only the AttachedFile relation should be in the result
999
+ expect(result.some((item) => item.rel === 'Parent')).toBe(false);
1000
+ });
1001
+ });
1002
+
1003
+ describe('GetWorkItemByUrl', () => {
1004
+ it('should fetch work item by URL', async () => {
1005
+ // Arrange
1006
+ const mockUrl = 'https://dev.azure.com/org/project/_apis/wit/workitems/123';
1007
+ const mockWorkItem = { id: 123, fields: { 'System.Title': 'Test' } };
1008
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockWorkItem);
1009
+
1010
+ // Act
1011
+ const result = await ticketsDataProvider.GetWorkItemByUrl(mockUrl);
1012
+
1013
+ // Assert
1014
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(mockUrl, mockToken);
1015
+ expect(result).toEqual(mockWorkItem);
1016
+ });
1017
+ });
1018
+
1019
+ describe('GetParentLink', () => {
1020
+ it('should return trace with work item info', async () => {
1021
+ // Arrange
1022
+ const mockWi = {
1023
+ id: '123',
1024
+ fields: {
1025
+ 'System.Title': 'Test Work Item',
1026
+ 'System.CustomerId': 'CUST-001',
1027
+ },
1028
+ };
1029
+
1030
+ // Act
1031
+ const result = await ticketsDataProvider.GetParentLink(mockProject, mockWi);
1032
+
1033
+ // Assert
1034
+ expect(result.id).toBe('123');
1035
+ expect(result.title).toBe('Test Work Item');
1036
+ expect(result.customerId).toBe('CUST-001');
1037
+ expect(result.url).toContain(mockProject);
1038
+ });
1039
+
1040
+ it('should handle work item without customerId', async () => {
1041
+ // Arrange
1042
+ const mockWi = {
1043
+ id: '123',
1044
+ fields: {
1045
+ 'System.Title': 'Test Work Item',
1046
+ },
1047
+ };
1048
+
1049
+ // Act
1050
+ const result = await ticketsDataProvider.GetParentLink(mockProject, mockWi);
1051
+
1052
+ // Assert
1053
+ expect(result.id).toBe('123');
1054
+ expect(result.customerId).toBeUndefined();
1055
+ });
1056
+
1057
+ it('should handle null work item', async () => {
1058
+ // Act
1059
+ const result = await ticketsDataProvider.GetParentLink(mockProject, null);
1060
+
1061
+ // Assert
1062
+ expect(result.id).toBeUndefined();
1063
+ });
1064
+ });
1065
+
1066
+ describe('GetRelationsIds', () => {
1067
+ it('should return a map from work items', async () => {
1068
+ // Arrange
1069
+ const mockIds = [
1070
+ {
1071
+ id: 1,
1072
+ relations: [{ rel: 'Parent', url: 'https://example.com/workitems/2' }],
1073
+ },
1074
+ ];
1075
+
1076
+ // Act
1077
+ const result = await ticketsDataProvider.GetRelationsIds(mockIds);
1078
+
1079
+ // Assert - result is a Map
1080
+ expect(result).toBeInstanceOf(Map);
1081
+ });
1082
+
1083
+ it('should handle empty array', async () => {
1084
+ // Arrange
1085
+ const mockIds: any[] = [];
1086
+
1087
+ // Act
1088
+ const result = await ticketsDataProvider.GetRelationsIds(mockIds);
1089
+
1090
+ // Assert
1091
+ expect(result.size).toBe(0);
1092
+ });
1093
+ });
1094
+
1095
+ describe('GetLinks', () => {
1096
+ it('should get links from work item relations', async () => {
1097
+ // Arrange
1098
+ const mockWi = {
1099
+ relations: [{ rel: 'Parent', url: 'https://example.com/workitems/2' }],
1100
+ };
1101
+ const mockLinks = [
1102
+ {
1103
+ id: '2',
1104
+ fields: {
1105
+ 'System.Title': 'Parent Item',
1106
+ 'System.Description': 'Parent Description',
1107
+ },
1108
+ },
1109
+ ];
1110
+
1111
+ // Act
1112
+ const result = await ticketsDataProvider.GetLinks(mockProject, mockWi, mockLinks);
1113
+
1114
+ // Assert
1115
+ expect(result).toHaveLength(1);
1116
+ expect(result[0].id).toBe('2');
1117
+ expect(result[0].title).toBe('Parent Item');
1118
+ expect(result[0].type).toBe('Parent');
1119
+ });
1120
+ });
1121
+
1122
+ describe('GetFieldsByType', () => {
1123
+ it('should fetch fields for a work item type', async () => {
1124
+ // Arrange
1125
+ const mockItemType = 'Bug';
1126
+ const mockResponse = {
1127
+ value: [
1128
+ { name: 'Priority', referenceName: 'Microsoft.VSTS.Common.Priority' },
1129
+ { name: 'Severity', referenceName: 'Microsoft.VSTS.Common.Severity' },
1130
+ { name: 'ID', referenceName: 'System.Id' },
1131
+ { name: 'Title', referenceName: 'System.Title' },
1132
+ ],
1133
+ };
1134
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockResponse);
1135
+
1136
+ // Act
1137
+ const result = await ticketsDataProvider.GetFieldsByType(mockProject, mockItemType);
1138
+
1139
+ // Assert
1140
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
1141
+ `${mockOrgUrl}${mockProject}/_apis/wit/workitemtypes/${mockItemType}/fields`,
1142
+ mockToken
1143
+ );
1144
+ // Should filter out ID and Title
1145
+ expect(result).toHaveLength(2);
1146
+ expect(result[0].text).toBe('Priority (Bug)');
1147
+ expect(result[0].key).toBe('Microsoft.VSTS.Common.Priority');
1148
+ });
1149
+
1150
+ it('should throw error when API fails', async () => {
1151
+ // Arrange
1152
+ const mockError = new Error('API Error');
1153
+ (TFSServices.getItemContent as jest.Mock).mockRejectedValueOnce(mockError);
1154
+
1155
+ // Act & Assert
1156
+ await expect(ticketsDataProvider.GetFieldsByType(mockProject, 'Bug')).rejects.toThrow();
1157
+ expect(logger.error).toHaveBeenCalled();
1158
+ });
1159
+ });
1160
+
1161
+ describe('GetQueryResultsFromWiql - additional cases', () => {
1162
+ it('should handle Tree query type', async () => {
1163
+ // Arrange
1164
+ const mockWiqlHref = 'https://example.com/wiql';
1165
+ const mockTestCaseMap = new Map<number, Set<any>>();
1166
+ const mockQueryResult = {
1167
+ queryType: 'tree',
1168
+ workItemRelations: [{ source: null, target: { id: 1, url: 'https://example.com/wi/1' }, rel: null }],
1169
+ };
1170
+ const mockWiResponse = {
1171
+ fields: { 'System.Title': 'Test', 'System.Description': 'Desc' },
1172
+ _links: { html: { href: 'https://example.com' } },
1173
+ };
1174
+
1175
+ (TFSServices.getItemContent as jest.Mock)
1176
+ .mockResolvedValueOnce(mockQueryResult)
1177
+ .mockResolvedValueOnce(mockWiResponse);
1178
+
1179
+ // Act
1180
+ const result = await ticketsDataProvider.GetQueryResultsFromWiql(mockWiqlHref, false, mockTestCaseMap);
1181
+
1182
+ // Assert
1183
+ expect(result).toBeDefined();
1184
+ });
1185
+
1186
+ it('should handle Flat query type with table format', async () => {
1187
+ // Arrange
1188
+ const mockWiqlHref = 'https://example.com/wiql';
1189
+ const mockTestCaseMap = new Map<number, Set<any>>();
1190
+ const mockQueryResult = {
1191
+ queryType: 'flat',
1192
+ columns: [{ referenceName: 'System.Title', name: 'Title' }],
1193
+ workItems: [{ id: 1, url: 'https://example.com/wi/1' }],
1194
+ };
1195
+ const mockWiResponse = {
1196
+ id: 1,
1197
+ fields: { 'System.Title': 'Test Item' },
1198
+ };
1199
+
1200
+ (TFSServices.getItemContent as jest.Mock)
1201
+ .mockResolvedValueOnce(mockQueryResult)
1202
+ .mockResolvedValueOnce(mockWiResponse);
1203
+
1204
+ // Act
1205
+ const result = await ticketsDataProvider.GetQueryResultsFromWiql(mockWiqlHref, true, mockTestCaseMap);
1206
+
1207
+ // Assert
1208
+ expect(result).toBeDefined();
1209
+ });
1210
+
1211
+ it('should handle Flat query type without table format', async () => {
1212
+ // Arrange
1213
+ const mockWiqlHref = 'https://example.com/wiql';
1214
+ const mockTestCaseMap = new Map<number, Set<any>>();
1215
+ const mockQueryResult = {
1216
+ queryType: 'flat',
1217
+ workItems: [{ id: 1, url: 'https://example.com/wi/1' }],
1218
+ };
1219
+ const mockWiResponse = {
1220
+ fields: { 'System.Title': 'Test', 'System.Description': 'Desc' },
1221
+ _links: { html: { href: 'https://example.com' } },
1222
+ };
1223
+
1224
+ (TFSServices.getItemContent as jest.Mock)
1225
+ .mockResolvedValueOnce(mockQueryResult)
1226
+ .mockResolvedValueOnce(mockWiResponse);
1227
+
1228
+ // Act
1229
+ const result = await ticketsDataProvider.GetQueryResultsFromWiql(mockWiqlHref, false, mockTestCaseMap);
1230
+
1231
+ // Assert
1232
+ expect(result).toBeDefined();
1233
+ });
1234
+
1235
+ it('should return undefined when queryType is not supported', async () => {
1236
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({ queryType: 'Unknown' });
1237
+ const res = await ticketsDataProvider.GetQueryResultsFromWiql(
1238
+ 'https://example.com/wiql',
1239
+ false,
1240
+ new Map()
1241
+ );
1242
+ expect(res).toBeUndefined();
1243
+ });
1244
+ });
1245
+
1246
+ describe('getRequirementTypeFieldRefs', () => {
1247
+ it('should prioritize known ref and dedupe candidates', async () => {
1248
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({
1249
+ value: [
1250
+ { name: 'Requirement Type', referenceName: 'Microsoft.VSTS.CMMI.RequirementType' },
1251
+ { name: 'Requirement_Type', referenceName: 'Custom.RequirementType' },
1252
+ { name: 'Requirement Type', referenceName: 'Microsoft.VSTS.CMMI.RequirementType' },
1253
+ { name: 'Other', referenceName: 'Custom.Other' },
1254
+ ],
1255
+ });
1256
+
1257
+ const res = await (ticketsDataProvider as any).getRequirementTypeFieldRefs('project1');
1258
+ expect(res[0]).toBe('Microsoft.VSTS.CMMI.RequirementType');
1259
+ expect(res).toContain('Custom.RequirementType');
1260
+ expect(res.filter((x: string) => x === 'Microsoft.VSTS.CMMI.RequirementType')).toHaveLength(1);
1261
+ });
1262
+
1263
+ it('should fall back to known ref when API call throws', async () => {
1264
+ (TFSServices.getItemContent as jest.Mock).mockRejectedValueOnce(new Error('boom'));
1265
+ const res = await (ticketsDataProvider as any).getRequirementTypeFieldRefs('project1');
1266
+ expect(res).toEqual(['Microsoft.VSTS.CMMI.RequirementType']);
1267
+ });
1268
+ });
1269
+
1270
+ describe('findQueryFolderByName', () => {
1271
+ it('should return null when input is invalid', async () => {
1272
+ await expect((ticketsDataProvider as any).findQueryFolderByName(null, 'x')).resolves.toBeNull();
1273
+ await expect((ticketsDataProvider as any).findQueryFolderByName({ id: 'r' }, '')).resolves.toBeNull();
1274
+ });
1275
+
1276
+ it('should BFS and return a folder by name; using ensureQueryChildren when node hasChildren', async () => {
1277
+ const root: any = {
1278
+ id: 'root',
1279
+ name: 'Root',
1280
+ isFolder: true,
1281
+ hasChildren: true,
1282
+ children: [
1283
+ { id: 'a', name: 'A', isFolder: true, hasChildren: true },
1284
+ { id: 'b', name: 'B', isFolder: true, hasChildren: false, children: [] },
1285
+ ],
1286
+ };
1287
+
1288
+ jest.spyOn(ticketsDataProvider as any, 'ensureQueryChildren').mockImplementation(async (node: any) => {
1289
+ if (node.id === 'a') {
1290
+ node.children = [{ id: 'target', name: 'TargetFolder', isFolder: true, hasChildren: false }];
1291
+ }
1292
+ return node;
1293
+ });
1294
+
1295
+ const found = await (ticketsDataProvider as any).findQueryFolderByName(root, 'targetfolder');
1296
+ expect(found).toEqual(expect.objectContaining({ id: 'target' }));
1297
+ });
1298
+
1299
+ it('should return null when folder is not found', async () => {
1300
+ const root: any = { id: 'root', name: 'Root', isFolder: true, hasChildren: false, children: [] };
1301
+ const found = await (ticketsDataProvider as any).findQueryFolderByName(root, 'missing');
1302
+ expect(found).toBeNull();
1303
+ });
1304
+ });
1305
+
1306
+ describe('findChildFolderByName', () => {
1307
+ it('should return null when parent or childName is invalid', async () => {
1308
+ await expect((ticketsDataProvider as any).findChildFolderByName(null, 'x')).resolves.toBeNull();
1309
+ await expect((ticketsDataProvider as any).findChildFolderByName({ id: 1 }, '')).resolves.toBeNull();
1310
+ });
1311
+
1312
+ it('should return null when parent has no children after enrichment', async () => {
1313
+ jest
1314
+ .spyOn(ticketsDataProvider as any, 'ensureQueryChildren')
1315
+ .mockResolvedValueOnce({ id: 'p', hasChildren: true, children: [] });
1316
+
1317
+ const res = await (ticketsDataProvider as any).findChildFolderByName({ id: 'p' }, 'child');
1318
+ expect(res).toBeNull();
1319
+ });
1320
+
1321
+ it('should return matching child folder by case-insensitive exact name', async () => {
1322
+ const parentWithChildren: any = {
1323
+ id: 'p',
1324
+ hasChildren: true,
1325
+ children: [
1326
+ { id: 'c1', isFolder: true, name: 'Match', hasChildren: false },
1327
+ { id: 'c2', isFolder: true, name: 'Other', hasChildren: false },
1328
+ ],
1329
+ };
1330
+ jest.spyOn(ticketsDataProvider as any, 'ensureQueryChildren').mockResolvedValueOnce(parentWithChildren);
1331
+
1332
+ const res = await (ticketsDataProvider as any).findChildFolderByName({ id: 'p' }, 'match');
1333
+ expect(res).toEqual(parentWithChildren.children[0]);
1334
+ });
1335
+ });
1336
+
1337
+ describe('buildFallbackChain + findPathToNode', () => {
1338
+ it('should use findPathToNode when startingFolder has id', async () => {
1339
+ const root: any = { id: 'root', name: 'Root', hasChildren: true, children: [] };
1340
+ const mid: any = { id: 'mid', name: 'Mid', hasChildren: true, children: [] };
1341
+ const start: any = { id: 'start', name: 'Start', hasChildren: false, children: [] };
1342
+ root.children = [mid];
1343
+ mid.children = [start];
1344
+
1345
+ jest.spyOn(ticketsDataProvider as any, 'ensureQueryChildren').mockImplementation(async (n: any) => n);
1346
+
1347
+ const path = await (ticketsDataProvider as any).findPathToNode(root, 'start');
1348
+ expect(path?.map((n: any) => n.id)).toEqual(['root', 'mid', 'start']);
1349
+
1350
+ const chain = await (ticketsDataProvider as any).buildFallbackChain(root, start);
1351
+ expect(chain.map((n: any) => n.id)).toEqual(['start', 'mid', 'root']);
1352
+ });
1353
+
1354
+ it('should fall back to startingFolder when findPathToNode returns null', async () => {
1355
+ const root: any = { id: 'root', name: 'Root', hasChildren: false, children: [] };
1356
+ const start: any = { id: 'start', name: 'Start', hasChildren: false, children: [] };
1357
+
1358
+ jest.spyOn(ticketsDataProvider as any, 'findPathToNode').mockResolvedValueOnce(null);
1359
+ const chain = await (ticketsDataProvider as any).buildFallbackChain(root, start);
1360
+ expect(chain.map((n: any) => n.id)).toEqual(['start', 'root']);
1361
+ });
1362
+
1363
+ it('should handle startingFolder without id and always include rootQueries', async () => {
1364
+ const root: any = { id: 'root', name: 'Root', hasChildren: false, children: [] };
1365
+ const start: any = { name: 'StartNoId', hasChildren: false, children: [] };
1366
+ const chain = await (ticketsDataProvider as any).buildFallbackChain(root, start);
1367
+ expect(chain).toHaveLength(2);
1368
+ expect(chain[0]).toBe(start);
1369
+ expect(chain[1]).toBe(root);
1370
+ });
1371
+
1372
+ it('should return null from findPathToNode when a cycle is detected', async () => {
1373
+ const node: any = { id: 'x', name: 'X', hasChildren: true, children: [] };
1374
+ node.children = [node];
1375
+ jest.spyOn(ticketsDataProvider as any, 'ensureQueryChildren').mockImplementation(async (n: any) => n);
1376
+ const path = await (ticketsDataProvider as any).findPathToNode(node, 'missing');
1377
+ expect(path).toBeNull();
1378
+ });
1379
+ });
1380
+
1381
+ describe('structureFetchedQueries', () => {
1382
+ it('should skip excluded folders', async () => {
1383
+ const res = await (ticketsDataProvider as any).structureFetchedQueries(
1384
+ { isFolder: true, name: 'SkipMe', hasChildren: true },
1385
+ false,
1386
+ null,
1387
+ ['Epic'],
1388
+ ['Feature'],
1389
+ undefined,
1390
+ undefined,
1391
+ false,
1392
+ ['skipme'],
1393
+ false
1394
+ );
1395
+ expect(res).toEqual({ tree1: null, tree2: null });
1396
+ });
1397
+
1398
+ it('should fetch children when hasChildren=true but children are missing', async () => {
1399
+ jest
1400
+ .spyOn(ticketsDataProvider as any, 'matchesSourceTargetConditionAsync')
1401
+ .mockResolvedValueOnce(true)
1402
+ .mockResolvedValueOnce(false);
1403
+ jest
1404
+ .spyOn(ticketsDataProvider as any, 'buildQueryNode')
1405
+ .mockImplementation((rq: any, parentId: any) => ({
1406
+ id: rq.id,
1407
+ pId: parentId,
1408
+ }));
1409
+
1410
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({
1411
+ id: 'root',
1412
+ name: 'Root',
1413
+ hasChildren: false,
1414
+ isFolder: false,
1415
+ queryType: 'oneHop',
1416
+ wiql: "SELECT * FROM WorkItemLinks WHERE Source.[System.WorkItemType] = 'Epic' AND Target.[System.WorkItemType] = 'Feature'",
1417
+ });
1418
+
1419
+ const res = await (ticketsDataProvider as any).structureFetchedQueries(
1420
+ { id: 'root', url: 'https://example.com/q', hasChildren: true, children: undefined },
1421
+ false,
1422
+ null,
1423
+ ['Epic'],
1424
+ ['Feature']
1425
+ );
1426
+
1427
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
1428
+ 'https://example.com/q?$depth=2&$expand=all',
1429
+ mockToken
1430
+ );
1431
+ expect(res.tree1).toEqual({ id: 'root', pId: 'root' });
1432
+ expect(res.tree2).toBeNull();
1433
+ });
1434
+
1435
+ it('should build tree nodes for flat queries when includeFlatQueries is enabled (tree1 only)', async () => {
1436
+ jest
1437
+ .spyOn(ticketsDataProvider as any, 'buildQueryNode')
1438
+ .mockImplementation((rq: any, parentId: any) => ({ id: rq.id, pId: parentId }));
1439
+
1440
+ const wiql =
1441
+ "SELECT * FROM WorkItems WHERE [System.WorkItemType] = 'Bug' AND [System.AreaPath] = 'A\\Leaf'";
1442
+ const res = await (ticketsDataProvider as any).structureFetchedQueries(
1443
+ { id: 'q', isFolder: false, hasChildren: false, queryType: 'flat', wiql },
1444
+ false,
1445
+ null,
1446
+ ['Bug'],
1447
+ ['Task'],
1448
+ 'leaf',
1449
+ 'missing',
1450
+ false,
1451
+ [],
1452
+ true
1453
+ );
1454
+
1455
+ expect(res.tree1).toEqual({ id: 'q', pId: null });
1456
+ expect(res.tree2).toBeNull();
1457
+ });
1458
+
1459
+ it('should build tree2 for reverse source/target in oneHop queries', async () => {
1460
+ jest
1461
+ .spyOn(ticketsDataProvider as any, 'matchesSourceTargetConditionAsync')
1462
+ .mockResolvedValueOnce(false)
1463
+ .mockResolvedValueOnce(true);
1464
+ jest
1465
+ .spyOn(ticketsDataProvider as any, 'buildQueryNode')
1466
+ .mockImplementation((rq: any, parentId: any) => ({ id: rq.id, pId: parentId }));
1467
+
1468
+ const wiql =
1469
+ "SELECT * FROM WorkItemLinks WHERE Source.[System.WorkItemType] = 'Feature' AND Target.[System.WorkItemType] = 'Epic'";
1470
+ const res = await (ticketsDataProvider as any).structureFetchedQueries(
1471
+ { id: 'q2', isFolder: false, hasChildren: false, queryType: 'oneHop', wiql },
1472
+ false,
1473
+ null,
1474
+ ['Epic'],
1475
+ ['Feature']
1476
+ );
1477
+
1478
+ expect(res.tree1).toBeNull();
1479
+ expect(res.tree2).toEqual({ id: 'q2', pId: null });
1480
+ });
1481
+
1482
+ describe('ID-only work item type fallback', () => {
1483
+ const projectHref = `${mockOrgUrl}MyProject/_apis/wit/wiql/123`;
1484
+ const allowedTypes = ['epic', 'feature', 'requirement'];
1485
+
1486
+ const makeLeafQuery = (overrides: any = {}) => ({
1487
+ id: 'q1',
1488
+ name: 'Query 1',
1489
+ isFolder: false,
1490
+ hasChildren: false,
1491
+ queryType: 'oneHop',
1492
+ wiql: '',
1493
+ _links: { wiql: { href: projectHref } },
1494
+ ...overrides,
1495
+ });
1496
+
1497
+ it('should include a tree query when Source has [System.Id] and no Source type filter, and the fetched work item type is allowed', async () => {
1498
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({
1499
+ fields: { 'System.WorkItemType': 'Epic' },
1500
+ });
1501
+
1502
+ const queryNode = makeLeafQuery({
1503
+ queryType: 'tree',
1504
+ wiql: `
1505
+ SELECT * FROM WorkItemLinks
1506
+ WHERE
1507
+ ([Source].[System.Id] = 123)
1508
+ AND ([Target].[System.WorkItemType] IN ('Requirement','Epic','Feature'))
1509
+ `,
1510
+ });
1511
+
1512
+ const res = await (ticketsDataProvider as any).structureFetchedQueries(
1513
+ queryNode,
1514
+ false,
1515
+ null,
1516
+ allowedTypes,
1517
+ [],
1518
+ undefined,
1519
+ undefined,
1520
+ true
1521
+ );
1522
+
1523
+ expect(res.tree1).not.toBeNull();
1524
+ expect(TFSServices.getItemContent).toHaveBeenCalledTimes(1);
1525
+ });
1526
+
1527
+ it('should exclude a tree query when Source has [System.Id] and no Source type filter, but the fetched work item type is not allowed', async () => {
1528
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({
1529
+ fields: { 'System.WorkItemType': 'Bug' },
1530
+ });
1531
+
1532
+ const queryNode = makeLeafQuery({
1533
+ queryType: 'tree',
1534
+ wiql: `
1535
+ SELECT * FROM WorkItemLinks
1536
+ WHERE
1537
+ ([Source].[System.Id] = 123)
1538
+ AND ([Target].[System.WorkItemType] IN ('Requirement','Epic','Feature'))
1539
+ `,
1540
+ });
1541
+
1542
+ const res = await (ticketsDataProvider as any).structureFetchedQueries(
1543
+ queryNode,
1544
+ false,
1545
+ null,
1546
+ allowedTypes,
1547
+ [],
1548
+ undefined,
1549
+ undefined,
1550
+ true
1551
+ );
1552
+
1553
+ expect(res.tree1).toBeNull();
1554
+ expect(TFSServices.getItemContent).toHaveBeenCalledTimes(1);
1555
+ });
1556
+
1557
+ it('should include a oneHop query when Source has [System.Id] and no Source type filter, and the fetched work item type is allowed', async () => {
1558
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({
1559
+ fields: { 'System.WorkItemType': 'Requirement' },
1560
+ });
1561
+
1562
+ const queryNode = makeLeafQuery({
1563
+ queryType: 'oneHop',
1564
+ wiql: `
1565
+ SELECT * FROM WorkItemLinks
1566
+ WHERE
1567
+ ([Source].[System.Id] = 123)
1568
+ AND ([Target].[System.WorkItemType] = 'Feature')
1569
+ `,
1570
+ });
1571
+
1572
+ const res = await (ticketsDataProvider as any).structureFetchedQueries(
1573
+ queryNode,
1574
+ false,
1575
+ null,
1576
+ allowedTypes,
1577
+ allowedTypes
1578
+ );
1579
+
1580
+ expect(res.tree1).not.toBeNull();
1581
+ // reverse evaluation should reuse the cached lookup
1582
+ expect(TFSServices.getItemContent).toHaveBeenCalledTimes(1);
1583
+ });
1584
+
1585
+ it('should include a flat query when [System.Id] exists and no [System.WorkItemType] filter exists, and the fetched work item type is allowed', async () => {
1586
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({
1587
+ fields: { 'System.WorkItemType': 'Feature' },
1588
+ });
1589
+
1590
+ const queryNode = makeLeafQuery({
1591
+ queryType: 'flat',
1592
+ wiql: `
1593
+ SELECT * FROM WorkItems
1594
+ WHERE [System.Id] = 123
1595
+ `,
1596
+ });
1597
+
1598
+ const res = await (ticketsDataProvider as any).structureFetchedQueries(
1599
+ queryNode,
1600
+ false,
1601
+ null,
1602
+ allowedTypes,
1603
+ [],
1604
+ undefined,
1605
+ undefined,
1606
+ false,
1607
+ [],
1608
+ true
1609
+ );
1610
+
1611
+ expect(res.tree1).not.toBeNull();
1612
+ expect(TFSServices.getItemContent).toHaveBeenCalledTimes(1);
1613
+ });
1614
+
1615
+ it('should not trigger the ID fallback when no [System.Id] filter exists', async () => {
1616
+ const queryNode = makeLeafQuery({
1617
+ queryType: 'tree',
1618
+ wiql: `
1619
+ SELECT * FROM WorkItemLinks
1620
+ WHERE
1621
+ ([Target].[System.WorkItemType] IN ('Requirement','Epic','Feature'))
1622
+ `,
1623
+ });
1624
+
1625
+ const res = await (ticketsDataProvider as any).structureFetchedQueries(
1626
+ queryNode,
1627
+ false,
1628
+ null,
1629
+ allowedTypes,
1630
+ [],
1631
+ undefined,
1632
+ undefined,
1633
+ true
1634
+ );
1635
+
1636
+ expect(res.tree1).toBeNull();
1637
+ expect(TFSServices.getItemContent).not.toHaveBeenCalled();
1638
+ });
1639
+ });
1640
+ });
1641
+
1642
+ describe('structureAllQueryPath', () => {
1643
+ it('should return sysOverview+knownBugs nodes for a flat Bug leaf query', async () => {
1644
+ const leaf: any = {
1645
+ id: 'q1',
1646
+ name: 'BugQuery',
1647
+ hasChildren: false,
1648
+ isFolder: false,
1649
+ queryType: 'flat',
1650
+ columns: [],
1651
+ wiql: "SELECT * FROM WorkItems WHERE [System.WorkItemType] = 'Bug'",
1652
+ _links: { wiql: { href: 'http://example.com/wiql' } },
1653
+ };
1654
+
1655
+ const res = await (ticketsDataProvider as any).structureAllQueryPath(leaf, 'p');
1656
+ expect(res.tree1).toEqual(expect.objectContaining({ id: 'q1', pId: 'p', isValidQuery: true }));
1657
+ expect(res.tree2).toEqual(expect.objectContaining({ id: 'q1', pId: 'p', isValidQuery: true }));
1658
+ });
1659
+
1660
+ it('should return sysOverview only for a flat non-bug leaf query', async () => {
1661
+ const leaf: any = {
1662
+ id: 'q2',
1663
+ name: 'OtherQuery',
1664
+ hasChildren: false,
1665
+ isFolder: false,
1666
+ queryType: 'flat',
1667
+ columns: [],
1668
+ wiql: "SELECT * FROM WorkItems WHERE [System.WorkItemType] = 'Task'",
1669
+ _links: { wiql: { href: 'http://example.com/wiql' } },
1670
+ };
1671
+
1672
+ const res = await (ticketsDataProvider as any).structureAllQueryPath(leaf, 'p');
1673
+ expect(res.tree1).toEqual(expect.objectContaining({ id: 'q2', pId: 'p', isValidQuery: true }));
1674
+ expect(res.tree2).toBeNull();
1675
+ });
1676
+
1677
+ it('should return null trees for a folder leaf', async () => {
1678
+ const leafFolder: any = { id: 'f', name: 'Folder', hasChildren: false, isFolder: true };
1679
+ const res = await (ticketsDataProvider as any).structureAllQueryPath(leafFolder, 'p');
1680
+ expect(res).toEqual({ tree1: null, tree2: null });
1681
+ });
1682
+
1683
+ it('should fetch query children from url when children are missing', async () => {
1684
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({
1685
+ id: 'q3',
1686
+ name: 'Fetched',
1687
+ hasChildren: false,
1688
+ isFolder: false,
1689
+ queryType: 'flat',
1690
+ columns: [],
1691
+ wiql: "SELECT * FROM WorkItems WHERE [System.WorkItemType] = 'Bug'",
1692
+ _links: { wiql: { href: 'http://example.com/wiql' } },
1693
+ });
1694
+
1695
+ const res = await (ticketsDataProvider as any).structureAllQueryPath({
1696
+ id: 'q3',
1697
+ url: 'https://example.com/q3',
1698
+ name: 'NeedFetch',
1699
+ hasChildren: true,
1700
+ children: undefined,
1701
+ });
1702
+
1703
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
1704
+ 'https://example.com/q3?$depth=2&$expand=all',
1705
+ mockToken
1706
+ );
1707
+ expect(res.tree1).toEqual(expect.objectContaining({ id: 'q3', pId: 'q3' }));
1708
+ });
1709
+ });
1710
+
1711
+ describe('parseTreeQueryResult', () => {
1712
+ it('should build roots, skip non-hierarchy links, and dedupe children', async () => {
1713
+ const initSpy = jest
1714
+ .spyOn(ticketsDataProvider as any, 'initTreeQueryResultItem')
1715
+ .mockImplementation(async (item: any, allItems: any) => {
1716
+ allItems[item.id] = {
1717
+ id: item.id,
1718
+ title: `T${item.id}`,
1719
+ description: '',
1720
+ htmlUrl: `h${item.id}`,
1721
+ children: [],
1722
+ };
1723
+ });
1724
+
1725
+ const workItemRelations = [
1726
+ { source: null, target: { id: 1, url: 'u1' }, rel: null },
1727
+ { source: null, target: { id: 1, url: 'u1' }, rel: null },
1728
+ {
1729
+ source: { id: 1, url: 'u1' },
1730
+ target: { id: 2, url: 'u2' },
1731
+ rel: 'System.LinkTypes.Hierarchy-Forward',
1732
+ },
1733
+ {
1734
+ source: { id: 1, url: 'u1' },
1735
+ target: { id: 2, url: 'u2' },
1736
+ rel: 'System.LinkTypes.Hierarchy-Forward',
1737
+ },
1738
+ {
1739
+ source: { id: 1, url: 'u1' },
1740
+ target: { id: 3, url: 'u3' },
1741
+ rel: 'System.LinkTypes.Related',
1742
+ },
1743
+ ];
1744
+
1745
+ const res = await (ticketsDataProvider as any).parseTreeQueryResult({ workItemRelations } as any);
1746
+
1747
+ expect(initSpy).toHaveBeenCalled();
1748
+ expect(res.roots).toHaveLength(1);
1749
+ expect(res.roots[0].id).toBe(1);
1750
+ expect(res.roots[0].children).toHaveLength(1);
1751
+ expect(res.roots[0].children[0].id).toBe(2);
1752
+ expect(Object.keys(res.allItems)).toEqual(expect.arrayContaining(['1', '2', '3']));
1753
+ });
1754
+
1755
+ it('should warn and initialize missing parent/child during hierarchy link processing when init no-ops first pass', async () => {
1756
+ const warnSpy = jest.spyOn(logger, 'warn');
1757
+
1758
+ const callCountById = new Map<number, number>();
1759
+ jest
1760
+ .spyOn(ticketsDataProvider as any, 'initTreeQueryResultItem')
1761
+ .mockImplementation(async (item: any, allItems: any) => {
1762
+ const count = (callCountById.get(item.id) || 0) + 1;
1763
+ callCountById.set(item.id, count);
1764
+ // First time: do nothing (simulate unexpected missing init)
1765
+ if (count === 1) return;
1766
+ // Second time: actually initialize
1767
+ allItems[item.id] = {
1768
+ id: item.id,
1769
+ title: `T${item.id}`,
1770
+ description: '',
1771
+ htmlUrl: `h${item.id}`,
1772
+ children: [],
1773
+ };
1774
+ });
1775
+
1776
+ const workItemRelations = [
1777
+ {
1778
+ source: { id: 10, url: 'u10' },
1779
+ target: { id: 11, url: 'u11' },
1780
+ rel: 'System.LinkTypes.Hierarchy-Forward',
1781
+ },
1782
+ ];
1783
+
1784
+ const res = await (ticketsDataProvider as any).parseTreeQueryResult({ workItemRelations } as any);
1785
+
1786
+ expect(warnSpy).toHaveBeenCalled();
1787
+ expect(res.roots).toHaveLength(0);
1788
+ expect(res.allItems[10]).toBeDefined();
1789
+ expect(res.allItems[11]).toBeDefined();
1790
+ expect(res.allItems[10].children).toHaveLength(1);
1791
+ expect(res.allItems[10].children[0].id).toBe(11);
1792
+ });
1793
+ });
1794
+
1795
+ describe('flattenTreeToWorkItems', () => {
1796
+ it('should flatten a roots tree into a list of work items', () => {
1797
+ const roots = [
1798
+ {
1799
+ id: 1,
1800
+ title: 'R1',
1801
+ description: 'D1',
1802
+ htmlUrl: 'h1',
1803
+ children: [{ id: 2, title: 'R2', description: 'D2', htmlUrl: 'h2', children: [] }],
1804
+ },
1805
+ ];
1806
+
1807
+ const res = (ticketsDataProvider as any).flattenTreeToWorkItems(roots);
1808
+ expect(res).toHaveLength(2);
1809
+ expect(res[0]).toEqual(expect.objectContaining({ id: 1, url: 'h1' }));
1810
+ expect(res[1]).toEqual(expect.objectContaining({ id: 2, url: 'h2' }));
1811
+ });
1812
+ });
1813
+
1814
+ describe('GetQueryResultsByWiqlHref', () => {
1815
+ it('should fetch and model query results', async () => {
1816
+ // Arrange
1817
+ const mockWiqlHref = 'https://example.com/wiql';
1818
+ const mockResults = {
1819
+ workItems: [{ id: 1, url: 'https://example.com/wi/1' }],
1820
+ };
1821
+ const mockWiResponse = {
1822
+ id: 1,
1823
+ fields: { 'System.Title': 'Test' },
1824
+ _links: { html: { href: 'https://example.com' } },
1825
+ };
1826
+
1827
+ (TFSServices.getItemContent as jest.Mock)
1828
+ .mockResolvedValueOnce(mockResults)
1829
+ .mockResolvedValueOnce(mockWiResponse);
1830
+
1831
+ // Act
1832
+ const result = await ticketsDataProvider.GetQueryResultsByWiqlHref(mockWiqlHref, mockProject);
1833
+
1834
+ // Assert
1835
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(mockWiqlHref, mockToken);
1836
+ });
1837
+ });
1838
+
1839
+ describe('GetWorkItemByUrl', () => {
1840
+ it('should fetch work item by URL', async () => {
1841
+ // Arrange
1842
+ const mockUrl = 'https://dev.azure.com/org/project/_apis/wit/workitems/123';
1843
+
1844
+ // Act
1845
+ const result = await ticketsDataProvider.GetWorkItemByUrl(mockUrl);
1846
+
1847
+ // Assert
1848
+ expect(TFSServices.getItemContent).toHaveBeenCalled();
1849
+ });
1850
+ });
1851
+
1852
+ describe('GetSharedQueries - docType branches', () => {
1853
+ it('should handle STR docType', async () => {
1854
+ // Arrange
1855
+ const mockQueries = {
1856
+ children: [{ name: 'STR', isFolder: true, children: [] }],
1857
+ };
1858
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValue(mockQueries);
1859
+
1860
+ // Act
1861
+ const result = await ticketsDataProvider.GetSharedQueries(mockProject, '', 'str');
1862
+
1863
+ // Assert
1864
+ expect(result).toBeDefined();
1865
+ });
1866
+
1867
+ it('should handle test-reporter docType', async () => {
1868
+ // Arrange
1869
+ const mockQueries = {
1870
+ children: [{ name: 'Test Reporter', isFolder: true, children: [] }],
1871
+ };
1872
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValue(mockQueries);
1873
+
1874
+ // Act
1875
+ const result = await ticketsDataProvider.GetSharedQueries(mockProject, '', 'test-reporter');
1876
+
1877
+ // Assert
1878
+ expect(result).toBeDefined();
1879
+ });
1880
+
1881
+ it('should handle SRS docType', async () => {
1882
+ // Arrange
1883
+ const mockQueries = {
1884
+ children: [{ name: 'SRS', isFolder: true, children: [] }],
1885
+ };
1886
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValue(mockQueries);
1887
+
1888
+ // Act
1889
+ const result = await ticketsDataProvider.GetSharedQueries(mockProject, '', 'srs');
1890
+
1891
+ // Assert
1892
+ expect(result).toBeDefined();
1893
+ });
1894
+
1895
+ it('should handle unknown docType', async () => {
1896
+ // Arrange
1897
+ const mockQueries = { children: [] };
1898
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValue(mockQueries);
1899
+
1900
+ // Act
1901
+ const result = await ticketsDataProvider.GetSharedQueries(mockProject, '', 'unknown');
1902
+
1903
+ // Assert
1904
+ expect(result).toBeUndefined();
1905
+ });
1906
+
1907
+ it('should handle error in GetSharedQueries', async () => {
1908
+ // Arrange
1909
+ (TFSServices.getItemContent as jest.Mock).mockRejectedValueOnce(new Error('API Error'));
1910
+
1911
+ // Act & Assert
1912
+ await expect(ticketsDataProvider.GetSharedQueries(mockProject, '', 'std')).rejects.toThrow('API Error');
1913
+ expect(logger.error).toHaveBeenCalled();
1914
+ });
1915
+ });
1916
+
1917
+ describe('PopulateWorkItemsByIds', () => {
1918
+ it('should populate work items by IDs', async () => {
1919
+ // Arrange
1920
+ const mockIds = [1, 2, 3];
1921
+ const mockResponse = {
1922
+ value: [
1923
+ { id: 1, fields: { 'System.Title': 'Item 1' } },
1924
+ { id: 2, fields: { 'System.Title': 'Item 2' } },
1925
+ { id: 3, fields: { 'System.Title': 'Item 3' } },
1926
+ ],
1927
+ };
1928
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce(mockResponse);
1929
+
1930
+ // Act
1931
+ const result = await ticketsDataProvider.PopulateWorkItemsByIds(mockIds, mockProject);
1932
+
1933
+ // Assert
1934
+ expect(result).toEqual(mockResponse.value);
1935
+ });
1936
+
1937
+ it('should handle empty IDs array', async () => {
1938
+ // Arrange
1939
+ const mockIds: number[] = [];
1940
+
1941
+ // Act
1942
+ const result = await ticketsDataProvider.PopulateWorkItemsByIds(mockIds, mockProject);
1943
+
1944
+ // Assert
1945
+ expect(result).toEqual([]);
1946
+ });
1947
+ });
1948
+
1949
+ describe('GetRelationsIds - edge cases', () => {
1950
+ it('should return a Map from work items with relations', async () => {
1951
+ // Arrange
1952
+ const mockIds = [
1953
+ {
1954
+ id: 1,
1955
+ relations: [{ rel: 'Parent', url: 'https://example.com/workitems/2' }],
1956
+ },
1957
+ ];
1958
+
1959
+ // Act
1960
+ const result = await ticketsDataProvider.GetRelationsIds(mockIds);
1961
+
1962
+ // Assert
1963
+ expect(result).toBeInstanceOf(Map);
1964
+ });
1965
+ });
1966
+
1967
+ describe('GetLinks - edge cases', () => {
1968
+ it('should handle empty relations', async () => {
1969
+ // Arrange
1970
+ const mockWi = { relations: [] };
1971
+ const mockLinks: any[] = [];
1972
+
1973
+ // Act
1974
+ const result = await ticketsDataProvider.GetLinks(mockProject, mockWi, mockLinks);
1975
+
1976
+ // Assert
1977
+ expect(result).toHaveLength(0);
1978
+ });
1979
+ });
1980
+
1981
+ describe('GetSharedQueries - path variations', () => {
1982
+ it('should use path in URL when provided', async () => {
1983
+ // Arrange
1984
+ const mockPath = 'My Queries/Test';
1985
+ const mockQueries = { children: [] };
1986
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValue(mockQueries);
1987
+
1988
+ // Act
1989
+ await ticketsDataProvider.GetSharedQueries(mockProject, mockPath, '');
1990
+
1991
+ // Assert
1992
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(expect.stringContaining(mockPath), mockToken);
1993
+ });
1994
+ });
1995
+
1996
+ describe('GetQueryResultById', () => {
1997
+ it('should fetch query result by ID', async () => {
1998
+ // Arrange
1999
+ const mockQueryId = 'query-123';
2000
+ const mockQuery = {
2001
+ _links: { wiql: { href: 'https://example.com/wiql' } },
2002
+ };
2003
+ const mockWiqlResult = { workItems: [{ id: 1 }] };
2004
+ const mockWiResponse = {
2005
+ fields: { 'System.Title': 'Test' },
2006
+ _links: { html: { href: 'https://example.com' } },
2007
+ };
2008
+
2009
+ (TFSServices.getItemContent as jest.Mock)
2010
+ .mockResolvedValueOnce(mockQuery)
2011
+ .mockResolvedValueOnce(mockWiqlResult)
2012
+ .mockResolvedValueOnce(mockWiResponse);
2013
+
2014
+ // Act
2015
+ const result = await ticketsDataProvider.GetQueryResultById(mockProject, mockQueryId);
2016
+
2017
+ // Assert
2018
+ expect(TFSServices.getItemContent).toHaveBeenCalled();
2019
+ });
2020
+ });
2021
+
2022
+ describe('GetModeledQueryResults', () => {
2023
+ it('should model Flat query results including System.Id and System.AssignedTo displayName', async () => {
2024
+ const results: any = {
2025
+ asOf: 'now',
2026
+ queryResultType: 'workItem',
2027
+ queryType: QueryType.Flat,
2028
+ workItems: [{ id: '1', url: 'https://example.com/wi/1' }],
2029
+ columns: [
2030
+ { name: 'ID', referenceName: 'System.Id', url: 'u1' },
2031
+ { name: 'Assigned To', referenceName: 'System.AssignedTo', url: 'u2' },
2032
+ { name: 'Custom', referenceName: 'Custom.Field', url: 'u3' },
2033
+ ],
2034
+ };
2035
+
2036
+ jest.spyOn(TicketsDataProvider.prototype, 'GetWorkItem').mockResolvedValueOnce({
2037
+ id: 1,
2038
+ url: 'https://example.com/wi/1',
2039
+ fields: {
2040
+ 'System.AssignedTo': { displayName: 'Bob' },
2041
+ 'Custom.Field': 'X',
2042
+ 'System.Id': 1,
2043
+ },
2044
+ relations: [{ rel: 'AttachedFile', url: 'https://example.com/a', attributes: { name: 'a.txt' } }],
2045
+ } as any);
2046
+
2047
+ const modeled = await ticketsDataProvider.GetModeledQueryResults(results, mockProject);
2048
+
2049
+ expect(modeled.queryType).toBe(QueryType.Flat);
2050
+ expect(modeled.workItems).toHaveLength(1);
2051
+ expect(modeled.workItems[0].attachments).toBeDefined();
2052
+ // System.Id branch
2053
+ expect(modeled.workItems[0].fields[0]).toEqual(expect.objectContaining({ name: 'ID', value: '1' }));
2054
+ // System.AssignedTo branch
2055
+ // Note: implementation does not set fields[i].name for AssignedTo branch
2056
+ expect(modeled.workItems[0].fields[1]).toEqual(expect.objectContaining({ value: 'Bob' }));
2057
+ // default field branch
2058
+ expect(modeled.workItems[0].fields[2]).toEqual(expect.objectContaining({ name: 'Custom', value: 'X' }));
2059
+ });
2060
+
2061
+ it('should model non-Flat query results using workItemRelations and set Source when present', async () => {
2062
+ const results: any = {
2063
+ asOf: 'now',
2064
+ queryResultType: 'workItemLink',
2065
+ queryType: QueryType.OneHop,
2066
+ workItemRelations: [
2067
+ {
2068
+ source: { id: 10 },
2069
+ target: { id: 20 },
2070
+ },
2071
+ ],
2072
+ columns: [
2073
+ { name: 'Assigned To', referenceName: 'System.AssignedTo', url: 'u2' },
2074
+ { name: 'Title', referenceName: 'System.Title', url: 'u3' },
2075
+ ],
2076
+ };
2077
+
2078
+ jest.spyOn(TicketsDataProvider.prototype, 'GetWorkItem').mockResolvedValueOnce({
2079
+ id: 20,
2080
+ url: 'https://example.com/wi/20',
2081
+ fields: {
2082
+ // Ensure AssignedTo branch falls through to the default path
2083
+ 'System.AssignedTo': null,
2084
+ 'System.Title': 'T20',
2085
+ },
2086
+ relations: null,
2087
+ } as any);
2088
+
2089
+ const modeled = await ticketsDataProvider.GetModeledQueryResults(results, mockProject);
2090
+
2091
+ expect(modeled.queryType).toBe(QueryType.OneHop);
2092
+ expect(modeled.workItems).toHaveLength(1);
2093
+ expect(modeled.workItems[0].Source).toBe(10);
2094
+ expect(modeled.workItems[0].url).toBe('https://example.com/wi/20');
2095
+ });
2096
+ });
2097
+
2098
+ describe('GetCategorizedRequirementsByType', () => {
2099
+ it('should categorize requirement items from queryResult.workItems and include priority=1 in precedence category', async () => {
2100
+ const wiqlHref = `${mockOrgUrl}project1/_apis/wit/wiql/123`;
2101
+
2102
+ // Other tests in this file may set a persistent mockResolvedValue on getItemContent.
2103
+ // clearAllMocks() does not reset implementations, so ensure a clean slate.
2104
+ (TFSServices.getItemContent as jest.Mock).mockReset();
2105
+
2106
+ (TFSServices.getItemContent as jest.Mock)
2107
+ // query result
2108
+ .mockResolvedValueOnce({ workItems: [{ id: 10 }, { id: 11 }, { id: 12 }, { id: 13 }] })
2109
+ // getRequirementTypeFieldRefs(project)
2110
+ .mockResolvedValueOnce({
2111
+ value: [
2112
+ { name: 'Requirement Type', referenceName: 'Microsoft.VSTS.CMMI.RequirementType' },
2113
+ { name: 'Requirement_Type', referenceName: 'Custom.RequirementType' },
2114
+ ],
2115
+ })
2116
+ // WI 10 requirement security, priority=1
2117
+ .mockResolvedValueOnce({
2118
+ id: 10,
2119
+ fields: {
2120
+ 'System.WorkItemType': 'Requirement',
2121
+ 'System.Title': 'R10',
2122
+ 'System.Description': 'D10',
2123
+ 'Microsoft.VSTS.CMMI.RequirementType': 'Security',
2124
+ 'Microsoft.VSTS.Common.Priority': 1,
2125
+ },
2126
+ _links: { html: { href: 'h10' } },
2127
+ })
2128
+ // WI 11 requirement unknown type -> Other Requirements
2129
+ .mockResolvedValueOnce({
2130
+ id: 11,
2131
+ fields: {
2132
+ 'System.WorkItemType': 'Requirement',
2133
+ 'System.Title': 'R11',
2134
+ 'System.Description': 'D11',
2135
+ 'Microsoft.VSTS.CMMI.RequirementType': 'UnknownType',
2136
+ },
2137
+ _links: { html: { href: 'h11' } },
2138
+ })
2139
+ // WI 12 not a requirement -> skipped
2140
+ .mockResolvedValueOnce({
2141
+ id: 12,
2142
+ fields: {
2143
+ 'System.WorkItemType': 'Bug',
2144
+ 'System.Title': 'B12',
2145
+ },
2146
+ _links: { html: { href: 'h12' } },
2147
+ })
2148
+ // WI 13 throws -> warn branch
2149
+ .mockRejectedValueOnce(new Error('boom'));
2150
+
2151
+ const res = await ticketsDataProvider.GetCategorizedRequirementsByType(wiqlHref);
2152
+
2153
+ expect(res.totalCount).toBe(4);
2154
+ expect(res.categories['Security and Privacy Requirements']).toHaveLength(1);
2155
+ expect(res.categories['Precedence and Criticality of Requirements']).toHaveLength(1);
2156
+ expect(res.categories['Other Requirements']).toHaveLength(1);
2157
+ expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Could not fetch work item 13'));
2158
+ });
2159
+
2160
+ it('should extract IDs from workItemRelations for OneHop queries', async () => {
2161
+ const wiqlHref = `${mockOrgUrl}project1/_apis/wit/wiql/rel`;
2162
+ (TFSServices.getItemContent as jest.Mock).mockReset();
2163
+
2164
+ (TFSServices.getItemContent as jest.Mock)
2165
+ .mockResolvedValueOnce({
2166
+ workItemRelations: [
2167
+ { source: { id: 10 }, target: { id: 20 } },
2168
+ { source: { id: 10 }, target: { id: 21 } },
2169
+ ],
2170
+ })
2171
+ .mockResolvedValueOnce({ value: [] })
2172
+ .mockResolvedValueOnce({
2173
+ id: 10,
2174
+ fields: { 'System.WorkItemType': 'Requirement', 'System.Title': 'R10' },
2175
+ _links: { html: { href: 'h10' } },
2176
+ })
2177
+ .mockResolvedValueOnce({
2178
+ id: 20,
2179
+ fields: { 'System.WorkItemType': 'Bug', 'System.Title': 'B20' },
2180
+ _links: { html: { href: 'h20' } },
2181
+ })
2182
+ .mockResolvedValueOnce({
2183
+ id: 21,
2184
+ fields: { 'System.WorkItemType': 'Requirement', 'System.Title': 'R21' },
2185
+ _links: { html: { href: 'h21' } },
2186
+ });
2187
+
2188
+ const res = await ticketsDataProvider.GetCategorizedRequirementsByType(wiqlHref);
2189
+ expect(res.totalCount).toBe(3);
2190
+ });
2191
+
2192
+ it('should return empty when query result has no workItems and no workItemRelations', async () => {
2193
+ const wiqlHref = `${mockOrgUrl}project1/_apis/wit/wiql/empty`;
2194
+ (TFSServices.getItemContent as jest.Mock).mockReset();
2195
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({});
2196
+
2197
+ const res = await ticketsDataProvider.GetCategorizedRequirementsByType(wiqlHref);
2198
+ expect(res).toEqual({ categories: {}, totalCount: 0 });
2199
+ expect(logger.warn).toHaveBeenCalledWith(
2200
+ expect.stringContaining('No work items found in query result')
2201
+ );
2202
+ });
2203
+ });
2204
+ });