@elisra-devops/docgen-data-provider 1.137.0 → 1.138.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.
@@ -269,9 +269,22 @@ describe('TicketsDataProvider historical queries', () => {
269
269
  String(call[0]).includes('api-version=5.1') &&
270
270
  String(call[2]).toLowerCase() === 'post',
271
271
  );
272
- expect(batchCalls).toHaveLength(2);
273
- expect(batchCalls[0][3].ids).toHaveLength(200);
274
- expect(batchCalls[1][3].ids).toHaveLength(5);
272
+ // Two chunks (200 + 5 ids), two batched passes per chunk (fields-only,
273
+ // then $expand=Relations-only) — never both in the same request, since
274
+ // ADO rejects that combination.
275
+ expect(batchCalls).toHaveLength(4);
276
+ batchCalls.forEach((call) => {
277
+ const payload = call[3];
278
+ expect(payload.fields && payload.$expand).toBeUndefined();
279
+ expect(payload.errorPolicy).toBe('Omit');
280
+ });
281
+ const fieldsCalls = batchCalls.filter((call) => Array.isArray(call[3]?.fields));
282
+ const relationsCalls = batchCalls.filter((call) => call[3]?.$expand === 'Relations');
283
+ expect(fieldsCalls).toHaveLength(2);
284
+ expect(relationsCalls).toHaveLength(2);
285
+ const byLength = (a: number, b: number) => a - b;
286
+ expect(fieldsCalls.map((call) => call[3].ids.length).sort(byLength)).toEqual([5, 200]);
287
+ expect(relationsCalls.map((call) => call[3].ids.length).sort(byLength)).toEqual([5, 200]);
275
288
  });
276
289
 
277
290
  it('GetHistoricalQueryResults falls back to per-item retrieval when workitemsbatch fails', async () => {
@@ -873,4 +886,183 @@ describe('TicketsDataProvider historical queries', () => {
873
886
  expect.objectContaining({ baselineCount: 1, compareToCount: 1, totalDistinct: 1 }),
874
887
  );
875
888
  });
889
+
890
+ it('CompareHistoricalQueryResults merges relations from the separate $expand pass into relatedLinkCount', async () => {
891
+ const baselineIso = '2026-01-05T00:00:00.000Z';
892
+ const compareIso = '2026-01-10T00:00:00.000Z';
893
+
894
+ // Fields-only and relations-only responses are disjoint (as ADO actually
895
+ // returns them for each pass) to prove the merge-by-id, not just that a
896
+ // duplicated mock happens to satisfy both reads.
897
+ (TFSServices.getItemContent as jest.Mock).mockImplementation(
898
+ async (url: string, _pat: string, method?: string, data?: any) => {
899
+ if (url.includes('/_apis/wit/queries/q-merge-relations') && url.includes('api-version=7.1')) {
900
+ return { name: 'Merge Relations Q', wiql: 'SELECT [System.Id] FROM WorkItems' };
901
+ }
902
+ if (url.includes('/_apis/wit/wiql?') && method === 'post') {
903
+ return { workItems: [{ id: 501 }] };
904
+ }
905
+ if (url.includes('/_apis/wit/workitemsbatch') && method === 'post') {
906
+ expect(data.fields && data.$expand).toBeUndefined();
907
+ expect(data.errorPolicy).toBe('Omit');
908
+ const isRelationsPass = data.$expand === 'Relations';
909
+ const isBaseline = data.asOf === baselineIso;
910
+ if (isRelationsPass) {
911
+ return {
912
+ value: [{ id: 501, relations: isBaseline ? [{ id: 'l-1' }] : [{ id: 'l-1' }, { id: 'l-2' }] }],
913
+ };
914
+ }
915
+ return {
916
+ value: [
917
+ {
918
+ id: 501,
919
+ rev: isBaseline ? 1 : 2,
920
+ fields: {
921
+ 'System.WorkItemType': 'Test Case',
922
+ 'System.Title': 'Merge Case',
923
+ 'System.State': 'Active',
924
+ 'Microsoft.VSTS.TCM.Steps': '<steps>same</steps>',
925
+ 'System.ChangedDate': isBaseline ? baselineIso : compareIso,
926
+ },
927
+ },
928
+ ],
929
+ };
930
+ }
931
+ throw new Error(`unexpected URL: ${url}`);
932
+ },
933
+ );
934
+
935
+ const result = await provider.CompareHistoricalQueryResults(
936
+ 'q-merge-relations',
937
+ project,
938
+ baselineIso,
939
+ compareIso,
940
+ );
941
+
942
+ const row = result.rows.find((r: any) => r.id === 501);
943
+ expect(row?.compareStatus).toBe('Changed');
944
+ expect(row?.changedFields).toEqual(['Related Link Count']);
945
+ const diff = row?.differences.find((d: any) => d.field === 'Related Link Count');
946
+ expect(diff).toEqual({ field: 'Related Link Count', baseline: '1', compareTo: '2' });
947
+ });
948
+
949
+ it('GetHistoricalQueryResults treats a workitemsbatch errorPolicy Omit as skipped without a per-item fallback', async () => {
950
+ const asOfIso = '2026-01-15T00:00:00.000Z';
951
+
952
+ (TFSServices.getItemContent as jest.Mock).mockImplementation(
953
+ async (url: string, _pat: string, method?: string, data?: any) => {
954
+ if (url.includes('/_apis/wit/queries/q-omit') && url.includes('api-version=7.1')) {
955
+ return { name: 'Omit Q', wiql: 'SELECT [System.Id] FROM WorkItems' };
956
+ }
957
+ if (url.includes('/_apis/wit/wiql?') && method === 'post') {
958
+ return { workItems: [{ id: 601 }, { id: 602 }] };
959
+ }
960
+ if (url.includes('/_apis/wit/workitemsbatch') && method === 'post') {
961
+ expect(data.errorPolicy).toBe('Omit');
962
+ // Work item 602 is inaccessible/deleted and is omitted by ADO from
963
+ // `value` rather than failing the whole batch.
964
+ if (data.$expand === 'Relations') {
965
+ return { value: [{ id: 601, relations: [] }] };
966
+ }
967
+ return {
968
+ value: [
969
+ {
970
+ id: 601,
971
+ rev: 1,
972
+ fields: {
973
+ 'System.WorkItemType': 'Bug',
974
+ 'System.Title': 'Present Bug',
975
+ 'System.State': 'Active',
976
+ 'System.ChangedDate': asOfIso,
977
+ },
978
+ },
979
+ ],
980
+ };
981
+ }
982
+ // A call here would mean the per-item fallback ran despite the batch
983
+ // succeeding, which defeats the point of errorPolicy: 'Omit'.
984
+ throw new Error(`unexpected URL: ${url}`);
985
+ },
986
+ );
987
+
988
+ const result = await provider.GetHistoricalQueryResults('q-omit', project, asOfIso);
989
+
990
+ expect(result.total).toBe(1);
991
+ expect(result.skippedWorkItemsCount).toBe(1);
992
+ expect(result.rows.map((row: any) => row.id)).toEqual([601]);
993
+ expect(
994
+ (logger.warn as jest.Mock).mock.calls.some((call) => String(call[0]).includes('omitted 1')),
995
+ ).toBe(true);
996
+ });
997
+
998
+ it('GetHistoricalQueryResults drops only the specific field ADO names in TF51535, keeping Custom.TestPhase when only Elisra.TestPhase is undefined for this project', async () => {
999
+ const asOfIso = '2026-02-01T00:00:00.000Z';
1000
+ // 205 ids -> 2 chunks (200 + 5), both dispatched with the full field list
1001
+ // near-simultaneously — this proves the retry is decided per-call, not
1002
+ // from the shared override flag, since both chunks fail before either
1003
+ // one sets it.
1004
+ const allIds = Array.from({ length: 205 }, (_, idx) => idx + 1);
1005
+
1006
+ (TFSServices.getItemContent as jest.Mock).mockImplementation(
1007
+ async (url: string, _pat: string, method?: string, data?: any) => {
1008
+ if (url.includes('/_apis/wit/queries/q-unknown-field') && url.includes('api-version=7.1')) {
1009
+ return { name: 'Unknown Field Q', wiql: 'SELECT [System.Id] FROM WorkItems' };
1010
+ }
1011
+ if (url.includes('/_apis/wit/wiql?') && method === 'post') {
1012
+ return { workItems: allIds.map((id) => ({ id })) };
1013
+ }
1014
+ if (url.includes('/_apis/wit/workitemsbatch') && method === 'post') {
1015
+ if (data?.$expand === 'Relations') {
1016
+ return {
1017
+ value: (Array.isArray(data?.ids) ? data.ids : []).map((id: number) => ({ id, relations: [] })),
1018
+ };
1019
+ }
1020
+ if (Array.isArray(data?.fields) && data.fields.includes('Elisra.TestPhase')) {
1021
+ throw {
1022
+ response: {
1023
+ status: 400,
1024
+ data: { message: "TF51535: Cannot find field 'Elisra.TestPhase'." },
1025
+ },
1026
+ };
1027
+ }
1028
+ // Custom.TestPhase is the real field on this project and must
1029
+ // still be requested — only the unrecognized alias should be
1030
+ // dropped, not every optional field.
1031
+ expect(data.fields).toEqual(expect.arrayContaining(['Custom.TestPhase']));
1032
+ expect(data.fields).not.toEqual(expect.arrayContaining(['Elisra.TestPhase']));
1033
+ return {
1034
+ value: (Array.isArray(data?.ids) ? data.ids : []).map((id: number) => ({
1035
+ id,
1036
+ rev: 1,
1037
+ fields: {
1038
+ 'System.WorkItemType': 'Test Case',
1039
+ 'System.Title': `Test ${id}`,
1040
+ 'System.State': 'Active',
1041
+ 'System.ChangedDate': asOfIso,
1042
+ 'Custom.TestPhase': 'FAT',
1043
+ },
1044
+ })),
1045
+ };
1046
+ }
1047
+ throw new Error(`unexpected URL: ${url}`);
1048
+ },
1049
+ );
1050
+
1051
+ const result = await provider.GetHistoricalQueryResults('q-unknown-field', project, asOfIso);
1052
+
1053
+ // Every id came back — nothing fell to the 2xN per-item fallback, which
1054
+ // would have thrown on any call not covered by the handler above.
1055
+ expect(result.total).toBe(205);
1056
+ const fieldsCallsWithElisraAlias = (TFSServices.getItemContent as jest.Mock).mock.calls.filter(
1057
+ (call) => Array.isArray(call[3]?.fields) && call[3].fields.includes('Elisra.TestPhase'),
1058
+ );
1059
+ // Both chunks start with the full field list before the override is set,
1060
+ // so both legitimately fail once and both must retry — not just one.
1061
+ expect(fieldsCallsWithElisraAlias).toHaveLength(2);
1062
+ expect(
1063
+ (logger.warn as jest.Mock).mock.calls.some((call) =>
1064
+ String(call[0]).includes("field 'Elisra.TestPhase' not defined for this project"),
1065
+ ),
1066
+ ).toBe(true);
1067
+ });
876
1068
  });