@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.
- package/bin/helpers/tfs.js +1 -8
- package/bin/helpers/tfs.js.map +1 -1
- package/bin/modules/TicketsDataProvider.d.ts +2 -0
- package/bin/modules/TicketsDataProvider.js +59 -9
- package/bin/modules/TicketsDataProvider.js.map +1 -1
- package/bin/tests/helpers/tfs.test.js +3 -5
- package/bin/tests/helpers/tfs.test.js.map +1 -1
- package/bin/tests/modules/ticketsDataProvider.historical.test.js +267 -4
- package/bin/tests/modules/ticketsDataProvider.historical.test.js.map +1 -1
- package/package.json +1 -1
- package/src/helpers/tfs.ts +1 -10
- package/src/modules/TicketsDataProvider.ts +81 -19
- package/src/tests/helpers/tfs.test.ts +3 -5
- package/src/tests/modules/ticketsDataProvider.historical.test.ts +341 -8
|
@@ -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 || '')
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
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: {
|
|
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
|
-
|
|
442
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
});
|