@elisra-devops/docgen-data-provider 1.107.0 → 1.109.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.
@@ -56,6 +56,7 @@ type HistoricalSnapshotResult = {
56
56
  total: number;
57
57
  rows: HistoricalWorkItemSnapshot[];
58
58
  snapshotMap: Map<number, HistoricalWorkItemSnapshot>;
59
+ skippedWorkItemIds: number[];
59
60
  };
60
61
 
61
62
  const HISTORICAL_WIT_API_VERSIONS: Array<string | null> = ['7.1', '5.1', null];
@@ -1611,7 +1612,9 @@ export default class TicketsDataProvider {
1611
1612
  }
1612
1613
 
1613
1614
  private isTestCaseType(workItemType: string): boolean {
1614
- const normalized = String(workItemType || '').trim().toLowerCase();
1615
+ const normalized = String(workItemType || '')
1616
+ .trim()
1617
+ .toLowerCase();
1615
1618
  return normalized === 'test case' || normalized === 'testcase';
1616
1619
  }
1617
1620
 
@@ -1719,11 +1722,7 @@ export default class TicketsDataProvider {
1719
1722
  private normalizeHistoricalQueryPath(path: string): string {
1720
1723
  const rawPath = String(path || '').trim();
1721
1724
  const normalizedRoot = rawPath.toLowerCase();
1722
- if (
1723
- rawPath === '' ||
1724
- normalizedRoot === 'shared' ||
1725
- normalizedRoot === 'shared queries'
1726
- ) {
1725
+ if (rawPath === '' || normalizedRoot === 'shared' || normalizedRoot === 'shared queries') {
1727
1726
  return 'Shared%20Queries';
1728
1727
  }
1729
1728
 
@@ -1757,12 +1756,39 @@ export default class TicketsDataProvider {
1757
1756
  return root;
1758
1757
  }
1759
1758
 
1759
+ private historicalErrorMessage(error: any): string {
1760
+ return String(error?.response?.data?.message || error?.message || error || '').trim();
1761
+ }
1762
+
1763
+ private isHistoricalMissingWorkItemError(error: any, workItemId: number): boolean {
1764
+ const status = Number(error?.response?.status || error?.status || 0);
1765
+ const message = this.historicalErrorMessage(error);
1766
+ if (!message) {
1767
+ return false;
1768
+ }
1769
+
1770
+ const mentionsWorkItem = /\bwork\s*item\b/i.test(message);
1771
+ const mentionsId = message.includes(String(workItemId));
1772
+ const missingPattern = /does not exist(?: at time)?|not found|has been deleted|was deleted/i;
1773
+ const hasMissingIndicator = missingPattern.test(message);
1774
+
1775
+ if (!mentionsWorkItem || !mentionsId || !hasMissingIndicator) {
1776
+ return false;
1777
+ }
1778
+
1779
+ if (/at time/i.test(message)) {
1780
+ return true;
1781
+ }
1782
+
1783
+ return status === 404 || status === 410;
1784
+ }
1785
+
1760
1786
  private async fetchHistoricalWorkItemsBatch(
1761
1787
  project: string,
1762
1788
  ids: number[],
1763
1789
  asOf: string,
1764
1790
  apiVersion: string | null,
1765
- ): Promise<any[]> {
1791
+ ): Promise<{ items: any[]; skippedWorkItemIds: number[] }> {
1766
1792
  const workItemsBatchUrl = this.appendApiVersion(
1767
1793
  `${this.orgUrl}${project}/_apis/wit/workitemsbatch`,
1768
1794
  apiVersion,
@@ -1781,7 +1807,10 @@ export default class TicketsDataProvider {
1781
1807
  ),
1782
1808
  ),
1783
1809
  );
1784
- return batchResponses.flatMap((batch) => (Array.isArray(batch?.value) ? batch.value : []));
1810
+ return {
1811
+ items: batchResponses.flatMap((batch) => (Array.isArray(batch?.value) ? batch.value : [])),
1812
+ skippedWorkItemIds: [],
1813
+ };
1785
1814
  } catch (error: any) {
1786
1815
  logger.warn(
1787
1816
  `[historical-workitems] workitemsbatch failed${
@@ -1791,16 +1820,32 @@ export default class TicketsDataProvider {
1791
1820
  const asOfParam = encodeURIComponent(asOf);
1792
1821
  const responses = await Promise.all(
1793
1822
  ids.map((id) =>
1794
- this.limit(() => {
1823
+ this.limit(async () => {
1795
1824
  const itemUrl = this.appendApiVersion(
1796
1825
  `${this.orgUrl}${project}/_apis/wit/workitems/${id}?$expand=Relations&asOf=${asOfParam}`,
1797
1826
  apiVersion,
1798
1827
  );
1799
- return TFSServices.getItemContent(itemUrl, this.token);
1828
+ try {
1829
+ const item = await TFSServices.getItemContent(itemUrl, this.token);
1830
+ return { id, item, skipped: false };
1831
+ } catch (itemError: any) {
1832
+ if (this.isHistoricalMissingWorkItemError(itemError, id)) {
1833
+ logger.warn(
1834
+ `[historical-workitems] skipping work item ${id} for asOf ${asOf}: ${this.historicalErrorMessage(itemError)}`,
1835
+ );
1836
+ return { id, item: null, skipped: true };
1837
+ }
1838
+ throw itemError;
1839
+ }
1800
1840
  }),
1801
1841
  ),
1802
1842
  );
1803
- return responses.filter((item) => item && typeof item === 'object');
1843
+ return {
1844
+ items: responses
1845
+ .filter((entry) => !entry.skipped && entry.item && typeof entry.item === 'object')
1846
+ .map((entry) => entry.item),
1847
+ skippedWorkItemIds: responses.filter((entry) => entry.skipped).map((entry) => entry.id),
1848
+ };
1804
1849
  }
1805
1850
  }
1806
1851
 
@@ -1904,10 +1949,16 @@ export default class TicketsDataProvider {
1904
1949
  total: 0,
1905
1950
  rows: [],
1906
1951
  snapshotMap: new Map<number, HistoricalWorkItemSnapshot>(),
1952
+ skippedWorkItemIds: [],
1907
1953
  };
1908
1954
  }
1909
1955
 
1910
- const values = await this.fetchHistoricalWorkItemsBatch(project, ids, asOf, apiVersion);
1956
+ const { items: values, skippedWorkItemIds } = await this.fetchHistoricalWorkItemsBatch(
1957
+ project,
1958
+ ids,
1959
+ asOf,
1960
+ apiVersion,
1961
+ );
1911
1962
  const rows = values
1912
1963
  .map((workItem: any) => this.toHistoricalWorkItemSnapshot(project, workItem))
1913
1964
  .sort((a: HistoricalWorkItemSnapshot, b: HistoricalWorkItemSnapshot) => a.id - b.id);
@@ -1923,6 +1974,7 @@ export default class TicketsDataProvider {
1923
1974
  total: rows.length,
1924
1975
  rows,
1925
1976
  snapshotMap,
1977
+ skippedWorkItemIds,
1926
1978
  };
1927
1979
  }
1928
1980
 
@@ -1961,13 +2013,16 @@ export default class TicketsDataProvider {
1961
2013
  const normalizedPath = this.normalizeHistoricalQueryPath(path);
1962
2014
  // Azure DevOps WIT query tree endpoint enforces $depth range 0..2.
1963
2015
  const depth = 2;
1964
- const { result: root } = await this.withHistoricalApiVersionFallback('historical-queries-list', (apiVersion) => {
1965
- const url = this.appendApiVersion(
1966
- `${this.orgUrl}${project}/_apis/wit/queries/${normalizedPath}?$depth=${depth}&$expand=all`,
1967
- apiVersion,
1968
- );
1969
- return TFSServices.getItemContent(url, this.token);
1970
- });
2016
+ const { result: root } = await this.withHistoricalApiVersionFallback(
2017
+ 'historical-queries-list',
2018
+ (apiVersion) => {
2019
+ const url = this.appendApiVersion(
2020
+ `${this.orgUrl}${project}/_apis/wit/queries/${normalizedPath}?$depth=${depth}&$expand=all`,
2021
+ apiVersion,
2022
+ );
2023
+ return TFSServices.getItemContent(url, this.token);
2024
+ },
2025
+ );
1971
2026
  const normalizedRoot = this.normalizeHistoricalQueryRoot(root);
1972
2027
  const items = this.collectHistoricalQueries(normalizedRoot).filter((query) => query.id !== '');
1973
2028
  items.sort((a, b) => {
@@ -1988,6 +2043,7 @@ export default class TicketsDataProvider {
1988
2043
  queryName: snapshot.queryName,
1989
2044
  asOf: snapshot.asOf,
1990
2045
  total: snapshot.total,
2046
+ skippedWorkItemsCount: snapshot.skippedWorkItemIds.length,
1991
2047
  rows: snapshot.rows.map((row) => ({
1992
2048
  id: row.id,
1993
2049
  workItemType: row.workItemType,
@@ -2141,6 +2197,12 @@ export default class TicketsDataProvider {
2141
2197
  ...summary,
2142
2198
  updatedCount: summary.changedCount,
2143
2199
  },
2200
+ skippedWorkItems: {
2201
+ baselineCount: baseline.skippedWorkItemIds.length,
2202
+ compareToCount: compareTo.skippedWorkItemIds.length,
2203
+ totalDistinct: new Set<number>([...baseline.skippedWorkItemIds, ...compareTo.skippedWorkItemIds])
2204
+ .size,
2205
+ },
2144
2206
  rows,
2145
2207
  };
2146
2208
  }
@@ -415,7 +415,7 @@ describe('TFSServices', () => {
415
415
  method: 'post',
416
416
  auth: { username: '', password: pat },
417
417
  data,
418
- headers: { headers: { 'Content-Type': 'application/json' } },
418
+ headers: { 'Content-Type': 'application/json' },
419
419
  });
420
420
  });
421
421
 
@@ -438,10 +438,8 @@ describe('TFSServices', () => {
438
438
  method: 'post',
439
439
  data,
440
440
  headers: {
441
- headers: {
442
- 'Content-Type': 'application/json',
443
- Authorization: 'Bearer abc123',
444
- },
441
+ 'Content-Type': 'application/json',
442
+ Authorization: 'Bearer abc123',
445
443
  },
446
444
  });
447
445
  });
@@ -1,5 +1,6 @@
1
1
  import { TFSServices } from '../../helpers/tfs';
2
2
  import TicketsDataProvider from '../../modules/TicketsDataProvider';
3
+ import logger from '../../utils/logger';
3
4
 
4
5
  jest.mock('../../helpers/tfs');
5
6
  jest.mock('../../utils/logger', () => ({
@@ -47,7 +48,9 @@ describe('TicketsDataProvider historical queries', () => {
47
48
  const result = await provider.GetHistoricalQueries(project);
48
49
 
49
50
  expect(TFSServices.getItemContent).toHaveBeenCalledWith(
50
- expect.stringContaining(`/${project}/_apis/wit/queries/Shared%20Queries?$depth=2&$expand=all&api-version=7.1`),
51
+ expect.stringContaining(
52
+ `/${project}/_apis/wit/queries/Shared%20Queries?$depth=2&$expand=all&api-version=7.1`,
53
+ ),
51
54
  token,
52
55
  );
53
56
  expect(result).toEqual([
@@ -110,11 +113,15 @@ describe('TicketsDataProvider historical queries', () => {
110
113
 
111
114
  expect(result).toEqual([{ id: 'q-51', queryName: 'V5 Query', path: 'Shared Queries' }]);
112
115
  expect(TFSServices.getItemContent).toHaveBeenCalledWith(
113
- expect.stringContaining(`/${project}/_apis/wit/queries/Shared%20Queries?$depth=2&$expand=all&api-version=7.1`),
116
+ expect.stringContaining(
117
+ `/${project}/_apis/wit/queries/Shared%20Queries?$depth=2&$expand=all&api-version=7.1`,
118
+ ),
114
119
  token,
115
120
  );
116
121
  expect(TFSServices.getItemContent).toHaveBeenCalledWith(
117
- expect.stringContaining(`/${project}/_apis/wit/queries/Shared%20Queries?$depth=2&$expand=all&api-version=5.1`),
122
+ expect.stringContaining(
123
+ `/${project}/_apis/wit/queries/Shared%20Queries?$depth=2&$expand=all&api-version=5.1`,
124
+ ),
118
125
  token,
119
126
  );
120
127
  });
@@ -130,7 +137,9 @@ describe('TicketsDataProvider historical queries', () => {
130
137
  await provider.GetHistoricalQueries(project, 'Shared Queries');
131
138
 
132
139
  expect(TFSServices.getItemContent).toHaveBeenCalledWith(
133
- expect.stringContaining(`/${project}/_apis/wit/queries/Shared%20Queries?$depth=2&$expand=all&api-version=7.1`),
140
+ expect.stringContaining(
141
+ `/${project}/_apis/wit/queries/Shared%20Queries?$depth=2&$expand=all&api-version=7.1`,
142
+ ),
134
143
  token,
135
144
  );
136
145
  });
@@ -146,7 +155,11 @@ describe('TicketsDataProvider historical queries', () => {
146
155
  expect(String(data?.query || '')).toContain(`ASOF '${asOfIso}'`);
147
156
  return { workItems: [{ id: 101 }, { id: 102 }] };
148
157
  }
149
- if (url.includes('/_apis/wit/workitemsbatch') && url.includes('api-version=7.1') && method === 'post') {
158
+ if (
159
+ url.includes('/_apis/wit/workitemsbatch') &&
160
+ url.includes('api-version=7.1') &&
161
+ method === 'post'
162
+ ) {
150
163
  expect(data.asOf).toBe(asOfIso);
151
164
  return {
152
165
  value: [
@@ -222,7 +235,11 @@ describe('TicketsDataProvider historical queries', () => {
222
235
  expect(String(data?.query || '')).toContain(`ASOF '${asOfIso}'`);
223
236
  return { workItems: allIds.map((id) => ({ id })) };
224
237
  }
225
- if (url.includes('/_apis/wit/workitemsbatch') && url.includes('api-version=5.1') && method === 'post') {
238
+ if (
239
+ url.includes('/_apis/wit/workitemsbatch') &&
240
+ url.includes('api-version=5.1') &&
241
+ method === 'post'
242
+ ) {
226
243
  return {
227
244
  value: (Array.isArray(data?.ids) ? data.ids : []).map((id: number) => ({
228
245
  id,
@@ -268,7 +285,11 @@ describe('TicketsDataProvider historical queries', () => {
268
285
  expect(String(data?.query || '')).toContain(`ASOF '${asOfIso}'`);
269
286
  return { workItems: [{ id: 101 }, { id: 102 }] };
270
287
  }
271
- if (url.includes('/_apis/wit/workitemsbatch') && url.includes('api-version=7.1') && method === 'post') {
288
+ if (
289
+ url.includes('/_apis/wit/workitemsbatch') &&
290
+ url.includes('api-version=7.1') &&
291
+ method === 'post'
292
+ ) {
272
293
  throw {
273
294
  response: {
274
295
  status: 500,
@@ -322,6 +343,146 @@ describe('TicketsDataProvider historical queries', () => {
322
343
  expect(result.rows.map((row: any) => row.id)).toEqual([101, 102]);
323
344
  });
324
345
 
346
+ it('GetHistoricalQueryResults skips work items that do not exist at as-of time and logs warning', async () => {
347
+ const asOfIso = '2026-01-01T10:00:00.000Z';
348
+ (TFSServices.getItemContent as jest.Mock).mockImplementation(
349
+ async (url: string, _pat: string, method?: string, data?: any) => {
350
+ if (url.includes('/_apis/wit/queries/q-missing-asof') && url.includes('api-version=7.1')) {
351
+ return { name: 'Missing AsOf Q', wiql: 'SELECT [System.Id] FROM WorkItems' };
352
+ }
353
+ if (url.includes('/_apis/wit/wiql?') && url.includes('api-version=7.1') && method === 'post') {
354
+ expect(String(data?.query || '')).toContain(`ASOF '${asOfIso}'`);
355
+ return { workItems: [{ id: 101 }, { id: 102 }] };
356
+ }
357
+ if (url.includes('/_apis/wit/workitemsbatch') && method === 'post') {
358
+ throw {
359
+ response: {
360
+ status: 500,
361
+ data: { message: 'workitemsbatch failed' },
362
+ },
363
+ };
364
+ }
365
+ if (url.includes('/_apis/wit/workitems/101')) {
366
+ return {
367
+ id: 101,
368
+ rev: 3,
369
+ fields: {
370
+ 'System.WorkItemType': 'Requirement',
371
+ 'System.Title': 'Req 101',
372
+ 'System.State': 'Active',
373
+ 'System.AreaPath': 'Proj\\Area',
374
+ 'System.IterationPath': 'Proj\\Iter',
375
+ 'System.ChangedDate': '2025-12-30T10:00:00Z',
376
+ },
377
+ relations: [],
378
+ };
379
+ }
380
+ if (url.includes('/_apis/wit/workitems/102')) {
381
+ throw {
382
+ response: {
383
+ status: 404,
384
+ data: {
385
+ message:
386
+ 'The work item 102 does not exist at time 12/31/2025 11:56:00 PM. It might have been deleted.',
387
+ },
388
+ },
389
+ };
390
+ }
391
+ throw new Error(`unexpected URL: ${url}`);
392
+ },
393
+ );
394
+
395
+ const result = await provider.GetHistoricalQueryResults('q-missing-asof', project, asOfIso);
396
+
397
+ expect(result.total).toBe(1);
398
+ expect(result.skippedWorkItemsCount).toBe(1);
399
+ expect(result.rows.map((row: any) => row.id)).toEqual([101]);
400
+ expect(
401
+ (logger.warn as jest.Mock).mock.calls.some((call) =>
402
+ String(call[0]).includes('skipping work item 102'),
403
+ ),
404
+ ).toBe(true);
405
+ });
406
+
407
+ it('GetHistoricalQueryResults returns empty result when all work items are missing at as-of time', async () => {
408
+ const asOfIso = '2026-01-01T10:00:00.000Z';
409
+ (TFSServices.getItemContent as jest.Mock).mockImplementation(
410
+ async (url: string, _pat: string, method?: string, data?: any) => {
411
+ if (url.includes('/_apis/wit/queries/q-all-missing-asof') && url.includes('api-version=7.1')) {
412
+ return { name: 'All Missing AsOf Q', wiql: 'SELECT [System.Id] FROM WorkItems' };
413
+ }
414
+ if (url.includes('/_apis/wit/wiql?') && method === 'post') {
415
+ expect(String(data?.query || '')).toContain(`ASOF '${asOfIso}'`);
416
+ return { workItems: [{ id: 201 }, { id: 202 }] };
417
+ }
418
+ if (url.includes('/_apis/wit/workitemsbatch') && method === 'post') {
419
+ throw {
420
+ response: {
421
+ status: 500,
422
+ data: { message: 'workitemsbatch failed' },
423
+ },
424
+ };
425
+ }
426
+ if (url.includes('/_apis/wit/workitems/201') || url.includes('/_apis/wit/workitems/202')) {
427
+ const id = url.includes('/201') ? 201 : 202;
428
+ throw {
429
+ response: {
430
+ status: 404,
431
+ data: {
432
+ message: `The work item ${id} does not exist at time 12/31/2025 11:56:00 PM.`,
433
+ },
434
+ },
435
+ };
436
+ }
437
+ throw new Error(`unexpected URL: ${url}`);
438
+ },
439
+ );
440
+
441
+ const result = await provider.GetHistoricalQueryResults('q-all-missing-asof', project, asOfIso);
442
+
443
+ expect(result.total).toBe(0);
444
+ expect(result.rows).toEqual([]);
445
+ expect(result.skippedWorkItemsCount).toBe(2);
446
+ });
447
+
448
+ it('GetHistoricalQueryResults still throws for non-missing historical errors', async () => {
449
+ const asOfIso = '2026-01-01T10:00:00.000Z';
450
+ (TFSServices.getItemContent as jest.Mock).mockImplementation(
451
+ async (url: string, _pat: string, method?: string, data?: any) => {
452
+ if (url.includes('/_apis/wit/queries/q-auth-error') && url.includes('api-version=7.1')) {
453
+ return { name: 'Auth Error Q', wiql: 'SELECT [System.Id] FROM WorkItems' };
454
+ }
455
+ if (url.includes('/_apis/wit/wiql?') && method === 'post') {
456
+ expect(String(data?.query || '')).toContain(`ASOF '${asOfIso}'`);
457
+ return { workItems: [{ id: 901 }] };
458
+ }
459
+ if (url.includes('/_apis/wit/workitemsbatch') && method === 'post') {
460
+ throw {
461
+ response: {
462
+ status: 500,
463
+ data: { message: 'workitemsbatch failed' },
464
+ },
465
+ };
466
+ }
467
+ if (url.includes('/_apis/wit/workitems/901')) {
468
+ throw {
469
+ response: {
470
+ status: 401,
471
+ data: { message: 'Unauthorized' },
472
+ },
473
+ };
474
+ }
475
+ throw new Error(`unexpected URL: ${url}`);
476
+ },
477
+ );
478
+
479
+ await expect(provider.GetHistoricalQueryResults('q-auth-error', project, asOfIso)).rejects.toEqual(
480
+ expect.objectContaining({
481
+ response: expect.objectContaining({ status: 401 }),
482
+ }),
483
+ );
484
+ });
485
+
325
486
  it('GetHistoricalQueryResults falls back to WIQL-by-id when inline WIQL fails', async () => {
326
487
  const asOfIso = '2026-01-01T10:00:00.000Z';
327
488
  (TFSServices.getItemContent as jest.Mock).mockImplementation(
@@ -342,7 +503,11 @@ describe('TicketsDataProvider historical queries', () => {
342
503
  expect(url).toContain(`asOf=${encodeURIComponent(asOfIso)}`);
343
504
  return { workItems: [{ id: 3001 }] };
344
505
  }
345
- if (url.includes('/_apis/wit/workitemsbatch') && url.includes('api-version=7.1') && method === 'post') {
506
+ if (
507
+ url.includes('/_apis/wit/workitemsbatch') &&
508
+ url.includes('api-version=7.1') &&
509
+ method === 'post'
510
+ ) {
346
511
  return {
347
512
  value: [
348
513
  {
@@ -540,4 +705,172 @@ describe('TicketsDataProvider historical queries', () => {
540
705
  updatedCount: 2,
541
706
  });
542
707
  });
708
+
709
+ it('CompareHistoricalQueryResults supports missing work items on one side and reports Added/Deleted', async () => {
710
+ const baselineIso = '2025-12-20T00:00:00.000Z';
711
+ const compareIso = '2025-12-30T00:00:00.000Z';
712
+
713
+ (TFSServices.getItemContent as jest.Mock).mockImplementation(
714
+ async (url: string, _pat: string, method?: string, data?: any) => {
715
+ if (url.includes('/_apis/wit/queries/q-compare-missing-side') && url.includes('api-version=7.1')) {
716
+ return { name: 'Compare Missing Side', wiql: 'SELECT [System.Id] FROM WorkItems' };
717
+ }
718
+ if (url.includes('/_apis/wit/wiql?') && method === 'post') {
719
+ const query = String(data?.query || '');
720
+ if (query.includes(baselineIso)) {
721
+ return { workItems: [{ id: 1001 }, { id: 1002 }, { id: 1003 }] };
722
+ }
723
+ if (query.includes(compareIso)) {
724
+ return { workItems: [{ id: 1001 }, { id: 1002 }, { id: 1003 }] };
725
+ }
726
+ }
727
+ if (url.includes('/_apis/wit/workitemsbatch') && method === 'post') {
728
+ throw {
729
+ response: {
730
+ status: 500,
731
+ data: { message: 'workitemsbatch failed' },
732
+ },
733
+ };
734
+ }
735
+ if (url.includes('/_apis/wit/workitems/1001')) {
736
+ return {
737
+ id: 1001,
738
+ rev: 1,
739
+ fields: {
740
+ 'System.WorkItemType': 'Requirement',
741
+ 'System.Title': 'Stable Item',
742
+ 'System.State': 'Active',
743
+ 'System.ChangedDate': compareIso,
744
+ },
745
+ relations: [],
746
+ };
747
+ }
748
+ if (
749
+ url.includes(`asOf=${encodeURIComponent(baselineIso)}`) &&
750
+ url.includes('/_apis/wit/workitems/1002')
751
+ ) {
752
+ throw {
753
+ response: {
754
+ status: 404,
755
+ data: { message: 'The work item 1002 does not exist at time 12/20/2025 12:00:00 AM.' },
756
+ },
757
+ };
758
+ }
759
+ if (
760
+ url.includes(`asOf=${encodeURIComponent(compareIso)}`) &&
761
+ url.includes('/_apis/wit/workitems/1002')
762
+ ) {
763
+ return {
764
+ id: 1002,
765
+ rev: 4,
766
+ fields: {
767
+ 'System.WorkItemType': 'Bug',
768
+ 'System.Title': 'Added Later',
769
+ 'System.State': 'New',
770
+ 'System.ChangedDate': compareIso,
771
+ },
772
+ relations: [],
773
+ };
774
+ }
775
+ if (
776
+ url.includes(`asOf=${encodeURIComponent(baselineIso)}`) &&
777
+ url.includes('/_apis/wit/workitems/1003')
778
+ ) {
779
+ return {
780
+ id: 1003,
781
+ rev: 2,
782
+ fields: {
783
+ 'System.WorkItemType': 'Bug',
784
+ 'System.Title': 'Deleted Later',
785
+ 'System.State': 'Active',
786
+ 'System.ChangedDate': baselineIso,
787
+ },
788
+ relations: [],
789
+ };
790
+ }
791
+ if (
792
+ url.includes(`asOf=${encodeURIComponent(compareIso)}`) &&
793
+ url.includes('/_apis/wit/workitems/1003')
794
+ ) {
795
+ throw {
796
+ response: {
797
+ status: 404,
798
+ data: { message: 'The work item 1003 does not exist at time 12/30/2025 12:00:00 AM.' },
799
+ },
800
+ };
801
+ }
802
+ throw new Error(`unexpected URL: ${url}`);
803
+ },
804
+ );
805
+
806
+ const result = await provider.CompareHistoricalQueryResults(
807
+ 'q-compare-missing-side',
808
+ project,
809
+ baselineIso,
810
+ compareIso,
811
+ );
812
+
813
+ const byId = new Map<number, any>(result.rows.map((row: any) => [row.id, row]));
814
+ expect(byId.get(1001)?.compareStatus).toBe('No changes');
815
+ expect(byId.get(1002)?.compareStatus).toBe('Added');
816
+ expect(byId.get(1003)?.compareStatus).toBe('Deleted');
817
+ expect(result.skippedWorkItems).toEqual(
818
+ expect.objectContaining({ baselineCount: 1, compareToCount: 1, totalDistinct: 2 }),
819
+ );
820
+ });
821
+
822
+ it('CompareHistoricalQueryResults excludes work items missing at both dates and can return empty set', async () => {
823
+ const baselineIso = '2025-01-01T00:00:00.000Z';
824
+ const compareIso = '2025-01-02T00:00:00.000Z';
825
+
826
+ (TFSServices.getItemContent as jest.Mock).mockImplementation(
827
+ async (url: string, _pat: string, method?: string, data?: any) => {
828
+ if (url.includes('/_apis/wit/queries/q-compare-all-missing') && url.includes('api-version=7.1')) {
829
+ return { name: 'Compare All Missing', wiql: 'SELECT [System.Id] FROM WorkItems' };
830
+ }
831
+ if (url.includes('/_apis/wit/wiql?') && method === 'post') {
832
+ const query = String(data?.query || '');
833
+ if (query.includes(baselineIso) || query.includes(compareIso)) {
834
+ return { workItems: [{ id: 777 }] };
835
+ }
836
+ }
837
+ if (url.includes('/_apis/wit/workitemsbatch') && method === 'post') {
838
+ throw {
839
+ response: {
840
+ status: 500,
841
+ data: { message: 'workitemsbatch failed' },
842
+ },
843
+ };
844
+ }
845
+ if (url.includes('/_apis/wit/workitems/777')) {
846
+ throw {
847
+ response: {
848
+ status: 404,
849
+ data: { message: 'The work item 777 does not exist at time 01/01/2025 12:00:00 AM.' },
850
+ },
851
+ };
852
+ }
853
+ throw new Error(`unexpected URL: ${url}`);
854
+ },
855
+ );
856
+
857
+ const result = await provider.CompareHistoricalQueryResults(
858
+ 'q-compare-all-missing',
859
+ project,
860
+ baselineIso,
861
+ compareIso,
862
+ );
863
+
864
+ expect(result.rows).toEqual([]);
865
+ expect(result.summary).toEqual({
866
+ addedCount: 0,
867
+ deletedCount: 0,
868
+ changedCount: 0,
869
+ noChangeCount: 0,
870
+ updatedCount: 0,
871
+ });
872
+ expect(result.skippedWorkItems).toEqual(
873
+ expect.objectContaining({ baselineCount: 1, compareToCount: 1, totalDistinct: 1 }),
874
+ );
875
+ });
543
876
  });