@elisra-devops/docgen-data-provider 1.105.0 → 1.107.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.
@@ -0,0 +1,543 @@
1
+ import { TFSServices } from '../../helpers/tfs';
2
+ import TicketsDataProvider from '../../modules/TicketsDataProvider';
3
+
4
+ jest.mock('../../helpers/tfs');
5
+ jest.mock('../../utils/logger', () => ({
6
+ __esModule: true,
7
+ default: {
8
+ debug: jest.fn(),
9
+ info: jest.fn(),
10
+ warn: jest.fn(),
11
+ error: jest.fn(),
12
+ },
13
+ }));
14
+
15
+ describe('TicketsDataProvider historical queries', () => {
16
+ const orgUrl = 'https://dev.azure.com/org/';
17
+ const token = 'pat';
18
+ const project = 'team-project';
19
+ let provider: TicketsDataProvider;
20
+
21
+ beforeEach(() => {
22
+ jest.clearAllMocks();
23
+ provider = new TicketsDataProvider(orgUrl, token);
24
+ });
25
+
26
+ it('GetHistoricalQueries flattens shared query tree into a sorted list with explicit api-version', async () => {
27
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({
28
+ id: 'root',
29
+ name: 'Shared Queries',
30
+ isFolder: true,
31
+ children: [
32
+ {
33
+ id: 'folder-b',
34
+ name: 'B',
35
+ isFolder: true,
36
+ children: [{ id: 'q-2', name: 'Second Query', isFolder: false }],
37
+ },
38
+ {
39
+ id: 'folder-a',
40
+ name: 'A',
41
+ isFolder: true,
42
+ children: [{ id: 'q-1', name: 'First Query', isFolder: false }],
43
+ },
44
+ ],
45
+ });
46
+
47
+ const result = await provider.GetHistoricalQueries(project);
48
+
49
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
50
+ expect.stringContaining(`/${project}/_apis/wit/queries/Shared%20Queries?$depth=2&$expand=all&api-version=7.1`),
51
+ token,
52
+ );
53
+ expect(result).toEqual([
54
+ { id: 'q-1', queryName: 'First Query', path: 'Shared Queries/A' },
55
+ { id: 'q-2', queryName: 'Second Query', path: 'Shared Queries/B' },
56
+ ]);
57
+ });
58
+
59
+ it('GetHistoricalQueries supports legacy response shape and falls back to default api-version', async () => {
60
+ (TFSServices.getItemContent as jest.Mock).mockImplementation(async (url: string) => {
61
+ if (url.includes('api-version=7.1') || url.includes('api-version=5.1')) {
62
+ throw {
63
+ response: {
64
+ status: 400,
65
+ data: { message: 'The requested api-version is not supported.' },
66
+ },
67
+ };
68
+ }
69
+ if (url.includes('/_apis/wit/queries/Shared%20Queries')) {
70
+ return {
71
+ value: [
72
+ {
73
+ id: 'q-legacy',
74
+ name: 'Legacy Query',
75
+ isFolder: false,
76
+ },
77
+ ],
78
+ };
79
+ }
80
+ throw new Error(`unexpected URL: ${url}`);
81
+ });
82
+
83
+ const result = await provider.GetHistoricalQueries(project);
84
+
85
+ expect(result).toEqual([{ id: 'q-legacy', queryName: 'Legacy Query', path: 'Shared Queries' }]);
86
+ });
87
+
88
+ it('GetHistoricalQueries retries with 5.1 when 7.1 returns 500', async () => {
89
+ (TFSServices.getItemContent as jest.Mock).mockImplementation(async (url: string) => {
90
+ if (url.includes('api-version=7.1')) {
91
+ throw {
92
+ response: {
93
+ status: 500,
94
+ data: { message: 'Internal Server Error' },
95
+ },
96
+ };
97
+ }
98
+ if (url.includes('api-version=5.1')) {
99
+ return {
100
+ id: 'root',
101
+ name: 'Shared Queries',
102
+ isFolder: true,
103
+ children: [{ id: 'q-51', name: 'V5 Query', isFolder: false }],
104
+ };
105
+ }
106
+ throw new Error(`unexpected URL: ${url}`);
107
+ });
108
+
109
+ const result = await provider.GetHistoricalQueries(project);
110
+
111
+ expect(result).toEqual([{ id: 'q-51', queryName: 'V5 Query', path: 'Shared Queries' }]);
112
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
113
+ expect.stringContaining(`/${project}/_apis/wit/queries/Shared%20Queries?$depth=2&$expand=all&api-version=7.1`),
114
+ token,
115
+ );
116
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
117
+ expect.stringContaining(`/${project}/_apis/wit/queries/Shared%20Queries?$depth=2&$expand=all&api-version=5.1`),
118
+ token,
119
+ );
120
+ });
121
+
122
+ it('GetHistoricalQueries treats "Shared Queries" alias as shared root', async () => {
123
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({
124
+ id: 'root',
125
+ name: 'Shared Queries',
126
+ isFolder: true,
127
+ children: [],
128
+ });
129
+
130
+ await provider.GetHistoricalQueries(project, 'Shared Queries');
131
+
132
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
133
+ expect.stringContaining(`/${project}/_apis/wit/queries/Shared%20Queries?$depth=2&$expand=all&api-version=7.1`),
134
+ token,
135
+ );
136
+ });
137
+
138
+ it('GetHistoricalQueryResults executes WIQL with ASOF and returns as-of snapshot rows', async () => {
139
+ const asOfIso = '2026-01-01T10:00:00.000Z';
140
+ (TFSServices.getItemContent as jest.Mock).mockImplementation(
141
+ async (url: string, _pat: string, method?: string, data?: any) => {
142
+ if (url.includes('/_apis/wit/queries/q-1') && url.includes('api-version=7.1')) {
143
+ return { name: 'Historical Q', wiql: 'SELECT [System.Id] FROM WorkItems' };
144
+ }
145
+ if (url.includes('/_apis/wit/wiql?') && url.includes('api-version=7.1') && method === 'post') {
146
+ expect(String(data?.query || '')).toContain(`ASOF '${asOfIso}'`);
147
+ return { workItems: [{ id: 101 }, { id: 102 }] };
148
+ }
149
+ if (url.includes('/_apis/wit/workitemsbatch') && url.includes('api-version=7.1') && method === 'post') {
150
+ expect(data.asOf).toBe(asOfIso);
151
+ return {
152
+ value: [
153
+ {
154
+ id: 101,
155
+ rev: 3,
156
+ fields: {
157
+ 'System.WorkItemType': 'Requirement',
158
+ 'System.Title': 'Req title',
159
+ 'System.State': 'Active',
160
+ 'System.AreaPath': 'Proj\\Area',
161
+ 'System.IterationPath': 'Proj\\Iter',
162
+ 'System.ChangedDate': '2025-12-30T10:00:00Z',
163
+ },
164
+ relations: [],
165
+ },
166
+ {
167
+ id: 102,
168
+ rev: 8,
169
+ fields: {
170
+ 'System.WorkItemType': 'Bug',
171
+ 'System.Title': 'Bug title',
172
+ 'System.State': 'Closed',
173
+ 'System.AreaPath': 'Proj\\Area',
174
+ 'System.IterationPath': 'Proj\\Iter',
175
+ 'System.ChangedDate': '2025-12-31T10:00:00Z',
176
+ },
177
+ relations: [],
178
+ },
179
+ ],
180
+ };
181
+ }
182
+ throw new Error(`unexpected URL: ${url}`);
183
+ },
184
+ );
185
+
186
+ const result = await provider.GetHistoricalQueryResults('q-1', project, asOfIso);
187
+
188
+ const deprecatedWiqlByIdCallUsed = (TFSServices.getItemContent as jest.Mock).mock.calls.some((call) =>
189
+ String(call[0]).includes('/_apis/wit/wiql/q-1'),
190
+ );
191
+ expect(deprecatedWiqlByIdCallUsed).toBe(false);
192
+ expect(result.queryName).toBe('Historical Q');
193
+ expect(result.asOf).toBe(asOfIso);
194
+ expect(result.total).toBe(2);
195
+ expect(result.rows[0]).toEqual(
196
+ expect.objectContaining({
197
+ id: 101,
198
+ workItemType: 'Requirement',
199
+ title: 'Req title',
200
+ versionId: 3,
201
+ }),
202
+ );
203
+ });
204
+
205
+ it('GetHistoricalQueryResults falls back to api-version 5.1 and chunks workitemsbatch by 200 IDs', async () => {
206
+ const asOfIso = '2026-01-01T10:00:00.000Z';
207
+ const allIds = Array.from({ length: 205 }, (_, idx) => idx + 1);
208
+ (TFSServices.getItemContent as jest.Mock).mockImplementation(
209
+ async (url: string, _pat: string, method?: string, data?: any) => {
210
+ if (url.includes('/_apis/wit/queries/q-fallback') && url.includes('api-version=7.1')) {
211
+ throw {
212
+ response: {
213
+ status: 400,
214
+ data: { message: 'The requested api-version is not supported.' },
215
+ },
216
+ };
217
+ }
218
+ if (url.includes('/_apis/wit/queries/q-fallback') && url.includes('api-version=5.1')) {
219
+ return { name: 'Fallback Q', wiql: 'SELECT [System.Id] FROM WorkItems' };
220
+ }
221
+ if (url.includes('/_apis/wit/wiql?') && url.includes('api-version=5.1') && method === 'post') {
222
+ expect(String(data?.query || '')).toContain(`ASOF '${asOfIso}'`);
223
+ return { workItems: allIds.map((id) => ({ id })) };
224
+ }
225
+ if (url.includes('/_apis/wit/workitemsbatch') && url.includes('api-version=5.1') && method === 'post') {
226
+ return {
227
+ value: (Array.isArray(data?.ids) ? data.ids : []).map((id: number) => ({
228
+ id,
229
+ rev: 1,
230
+ fields: {
231
+ 'System.WorkItemType': 'Bug',
232
+ 'System.Title': `Bug ${id}`,
233
+ 'System.State': 'Active',
234
+ 'System.AreaPath': 'Proj\\Area',
235
+ 'System.IterationPath': 'Proj\\Iter',
236
+ 'System.ChangedDate': '2025-12-31T10:00:00Z',
237
+ },
238
+ relations: [],
239
+ })),
240
+ };
241
+ }
242
+ throw new Error(`unexpected URL: ${url}`);
243
+ },
244
+ );
245
+
246
+ const result = await provider.GetHistoricalQueryResults('q-fallback', project, asOfIso);
247
+
248
+ expect(result.total).toBe(205);
249
+ const batchCalls = (TFSServices.getItemContent as jest.Mock).mock.calls.filter(
250
+ (call) =>
251
+ String(call[0]).includes('/_apis/wit/workitemsbatch') &&
252
+ String(call[0]).includes('api-version=5.1') &&
253
+ String(call[2]).toLowerCase() === 'post',
254
+ );
255
+ expect(batchCalls).toHaveLength(2);
256
+ expect(batchCalls[0][3].ids).toHaveLength(200);
257
+ expect(batchCalls[1][3].ids).toHaveLength(5);
258
+ });
259
+
260
+ it('GetHistoricalQueryResults falls back to per-item retrieval when workitemsbatch fails', async () => {
261
+ const asOfIso = '2026-01-01T10:00:00.000Z';
262
+ (TFSServices.getItemContent as jest.Mock).mockImplementation(
263
+ async (url: string, _pat: string, method?: string, data?: any) => {
264
+ if (url.includes('/_apis/wit/queries/q-batch-fallback') && url.includes('api-version=7.1')) {
265
+ return { name: 'Batch Fallback Q', wiql: 'SELECT [System.Id] FROM WorkItems' };
266
+ }
267
+ if (url.includes('/_apis/wit/wiql?') && url.includes('api-version=7.1') && method === 'post') {
268
+ expect(String(data?.query || '')).toContain(`ASOF '${asOfIso}'`);
269
+ return { workItems: [{ id: 101 }, { id: 102 }] };
270
+ }
271
+ if (url.includes('/_apis/wit/workitemsbatch') && url.includes('api-version=7.1') && method === 'post') {
272
+ throw {
273
+ response: {
274
+ status: 500,
275
+ data: { message: 'workitemsbatch failed' },
276
+ },
277
+ };
278
+ }
279
+ if (url.includes('/_apis/wit/workitems/101') && url.includes('api-version=7.1')) {
280
+ expect(url).toContain('$expand=Relations');
281
+ expect(url).toContain(`asOf=${encodeURIComponent(asOfIso)}`);
282
+ expect(url).not.toContain('fields=');
283
+ return {
284
+ id: 101,
285
+ rev: 3,
286
+ fields: {
287
+ 'System.WorkItemType': 'Requirement',
288
+ 'System.Title': 'Req 101',
289
+ 'System.State': 'Active',
290
+ 'System.AreaPath': 'Proj\\Area',
291
+ 'System.IterationPath': 'Proj\\Iter',
292
+ 'System.ChangedDate': '2025-12-30T10:00:00Z',
293
+ },
294
+ relations: [],
295
+ };
296
+ }
297
+ if (url.includes('/_apis/wit/workitems/102') && url.includes('api-version=7.1')) {
298
+ expect(url).toContain('$expand=Relations');
299
+ expect(url).toContain(`asOf=${encodeURIComponent(asOfIso)}`);
300
+ expect(url).not.toContain('fields=');
301
+ return {
302
+ id: 102,
303
+ rev: 4,
304
+ fields: {
305
+ 'System.WorkItemType': 'Bug',
306
+ 'System.Title': 'Bug 102',
307
+ 'System.State': 'Closed',
308
+ 'System.AreaPath': 'Proj\\Area',
309
+ 'System.IterationPath': 'Proj\\Iter',
310
+ 'System.ChangedDate': '2025-12-31T10:00:00Z',
311
+ },
312
+ relations: [],
313
+ };
314
+ }
315
+ throw new Error(`unexpected URL: ${url}`);
316
+ },
317
+ );
318
+
319
+ const result = await provider.GetHistoricalQueryResults('q-batch-fallback', project, asOfIso);
320
+
321
+ expect(result.total).toBe(2);
322
+ expect(result.rows.map((row: any) => row.id)).toEqual([101, 102]);
323
+ });
324
+
325
+ it('GetHistoricalQueryResults falls back to WIQL-by-id when inline WIQL fails', async () => {
326
+ const asOfIso = '2026-01-01T10:00:00.000Z';
327
+ (TFSServices.getItemContent as jest.Mock).mockImplementation(
328
+ async (url: string, _pat: string, method?: string, data?: any) => {
329
+ if (url.includes('/_apis/wit/queries/q-inline-fallback') && url.includes('api-version=7.1')) {
330
+ return { name: 'Inline Fallback Q', wiql: 'SELECT [System.Id] FROM WorkItems' };
331
+ }
332
+ if (url.includes('/_apis/wit/wiql?') && url.includes('api-version=7.1') && method === 'post') {
333
+ expect(String(data?.query || '')).toContain(`ASOF '${asOfIso}'`);
334
+ throw {
335
+ response: {
336
+ status: 500,
337
+ data: { message: 'inline wiql failed' },
338
+ },
339
+ };
340
+ }
341
+ if (url.includes('/_apis/wit/wiql/q-inline-fallback') && url.includes('api-version=7.1')) {
342
+ expect(url).toContain(`asOf=${encodeURIComponent(asOfIso)}`);
343
+ return { workItems: [{ id: 3001 }] };
344
+ }
345
+ if (url.includes('/_apis/wit/workitemsbatch') && url.includes('api-version=7.1') && method === 'post') {
346
+ return {
347
+ value: [
348
+ {
349
+ id: 3001,
350
+ rev: 1,
351
+ fields: {
352
+ 'System.WorkItemType': 'Requirement',
353
+ 'System.Title': 'Req 3001',
354
+ 'System.State': 'Active',
355
+ 'System.AreaPath': 'Proj\\Area',
356
+ 'System.IterationPath': 'Proj\\Iter',
357
+ 'System.ChangedDate': '2025-12-31T10:00:00Z',
358
+ },
359
+ relations: [],
360
+ },
361
+ ],
362
+ };
363
+ }
364
+ throw new Error(`unexpected URL: ${url}`);
365
+ },
366
+ );
367
+
368
+ const result = await provider.GetHistoricalQueryResults('q-inline-fallback', project, asOfIso);
369
+
370
+ expect(result.total).toBe(1);
371
+ expect(result.rows[0]).toEqual(
372
+ expect.objectContaining({
373
+ id: 3001,
374
+ workItemType: 'Requirement',
375
+ title: 'Req 3001',
376
+ }),
377
+ );
378
+ });
379
+
380
+ it('CompareHistoricalQueryResults marks Added/Deleted/Changed/No changes using noise-control fields', async () => {
381
+ const baselineIso = '2025-12-22T17:08:00.000Z';
382
+ const compareIso = '2025-12-28T08:57:00.000Z';
383
+
384
+ const baselineBatch = {
385
+ value: [
386
+ {
387
+ id: 11,
388
+ rev: 2,
389
+ fields: {
390
+ 'System.WorkItemType': 'Requirement',
391
+ 'System.Title': 'Req A',
392
+ 'System.State': 'Active',
393
+ 'System.Description': 'Old desc',
394
+ 'Elisra.TestPhase': 'FAT',
395
+ 'System.ChangedDate': baselineIso,
396
+ },
397
+ relations: [],
398
+ },
399
+ {
400
+ id: 23,
401
+ rev: 1,
402
+ fields: {
403
+ 'System.WorkItemType': 'Test Case',
404
+ 'System.Title': 'Case B',
405
+ 'System.State': 'Active',
406
+ 'System.Description': 'Case desc',
407
+ 'Microsoft.VSTS.TCM.Steps': '<steps>1</steps>',
408
+ 'Elisra.TestPhase': 'FAT',
409
+ 'System.ChangedDate': baselineIso,
410
+ },
411
+ relations: [{ id: 'l-1' }],
412
+ },
413
+ {
414
+ id: 58,
415
+ rev: 2,
416
+ fields: {
417
+ 'System.WorkItemType': 'Bug',
418
+ 'System.Title': 'Deleted bug',
419
+ 'System.State': 'New',
420
+ 'System.Description': 'x',
421
+ 'System.ChangedDate': baselineIso,
422
+ },
423
+ relations: [],
424
+ },
425
+ {
426
+ id: 813,
427
+ rev: 3,
428
+ fields: {
429
+ 'System.WorkItemType': 'Bug',
430
+ 'System.Title': 'No Change bug',
431
+ 'System.State': 'Active',
432
+ 'System.Description': 'same',
433
+ 'System.ChangedDate': baselineIso,
434
+ },
435
+ relations: [],
436
+ },
437
+ ],
438
+ };
439
+
440
+ const compareBatch = {
441
+ value: [
442
+ {
443
+ id: 11,
444
+ rev: 20,
445
+ fields: {
446
+ 'System.WorkItemType': 'Requirement',
447
+ 'System.Title': 'Req A',
448
+ 'System.State': 'Active',
449
+ 'System.Description': 'New desc',
450
+ 'Elisra.TestPhase': 'FAT; ATP',
451
+ 'System.ChangedDate': compareIso,
452
+ },
453
+ relations: [],
454
+ },
455
+ {
456
+ id: 23,
457
+ rev: 3,
458
+ fields: {
459
+ 'System.WorkItemType': 'Test Case',
460
+ 'System.Title': 'Case B',
461
+ 'System.State': 'Active',
462
+ 'System.Description': 'Case desc',
463
+ 'Microsoft.VSTS.TCM.Steps': '<steps>2</steps>',
464
+ 'Elisra.TestPhase': 'FAT',
465
+ 'System.ChangedDate': compareIso,
466
+ },
467
+ relations: [{ id: 'l-1' }, { id: 'l-2' }],
468
+ },
469
+ {
470
+ id: 814,
471
+ rev: 1,
472
+ fields: {
473
+ 'System.WorkItemType': 'Bug',
474
+ 'System.Title': 'Added bug',
475
+ 'System.State': 'New',
476
+ 'System.Description': 'new',
477
+ 'System.ChangedDate': compareIso,
478
+ },
479
+ relations: [],
480
+ },
481
+ {
482
+ id: 813,
483
+ rev: 9,
484
+ fields: {
485
+ 'System.WorkItemType': 'Bug',
486
+ 'System.Title': 'No Change bug',
487
+ 'System.State': 'Active',
488
+ 'System.Description': 'same',
489
+ 'System.ChangedDate': compareIso,
490
+ },
491
+ relations: [],
492
+ },
493
+ ],
494
+ };
495
+
496
+ (TFSServices.getItemContent as jest.Mock).mockImplementation(
497
+ async (url: string, _pat: string, method?: string, data?: any) => {
498
+ if (url.includes('/_apis/wit/queries/q-compare') && url.includes('api-version=7.1')) {
499
+ return { name: 'Compare Query', wiql: 'SELECT [System.Id] FROM WorkItems' };
500
+ }
501
+ if (url.includes('/_apis/wit/wiql?') && url.includes('api-version=7.1') && method === 'post') {
502
+ const query = String(data?.query || '');
503
+ if (query.includes(baselineIso)) {
504
+ return { workItems: [{ id: 11 }, { id: 23 }, { id: 58 }, { id: 813 }] };
505
+ }
506
+ if (query.includes(compareIso)) {
507
+ return { workItems: [{ id: 11 }, { id: 23 }, { id: 814 }, { id: 813 }] };
508
+ }
509
+ }
510
+ if (url.includes('/_apis/wit/workitemsbatch') && method === 'post' && data?.asOf === baselineIso) {
511
+ return baselineBatch;
512
+ }
513
+ if (url.includes('/_apis/wit/workitemsbatch') && method === 'post' && data?.asOf === compareIso) {
514
+ return compareBatch;
515
+ }
516
+ throw new Error(`unexpected URL: ${url}`);
517
+ },
518
+ );
519
+
520
+ const result = await provider.CompareHistoricalQueryResults(
521
+ 'q-compare',
522
+ project,
523
+ baselineIso,
524
+ compareIso,
525
+ );
526
+
527
+ const byId = new Map<number, any>(result.rows.map((row: any) => [row.id, row]));
528
+ expect(byId.get(11)?.compareStatus).toBe('Changed');
529
+ expect(byId.get(11)?.changedFields).toEqual(expect.arrayContaining(['Description', 'Test Phase']));
530
+ expect(byId.get(23)?.compareStatus).toBe('Changed');
531
+ expect(byId.get(23)?.changedFields).toEqual(expect.arrayContaining(['Steps', 'Related Link Count']));
532
+ expect(byId.get(58)?.compareStatus).toBe('Deleted');
533
+ expect(byId.get(814)?.compareStatus).toBe('Added');
534
+ expect(byId.get(813)?.compareStatus).toBe('No changes');
535
+ expect(result.summary).toEqual({
536
+ addedCount: 1,
537
+ deletedCount: 1,
538
+ changedCount: 2,
539
+ noChangeCount: 1,
540
+ updatedCount: 2,
541
+ });
542
+ });
543
+ });