@elisra-devops/docgen-data-provider 1.110.0 → 1.112.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.
@@ -18,6 +18,36 @@ describe('PipelinesDataProvider', () => {
18
18
  pipelinesDataProvider = new PipelinesDataProvider(mockOrgUrl, mockToken);
19
19
  });
20
20
 
21
+ describe('GetBuildWorkItems', () => {
22
+ it('should return work item references associated with a single build', async () => {
23
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValue({
24
+ value: [{ id: '1', url: 'https://example.test/wi/1' }],
25
+ });
26
+
27
+ const result = await pipelinesDataProvider.GetBuildWorkItems('project1', 123);
28
+
29
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
30
+ 'https://dev.azure.com/orgname/project1/_apis/build/builds/123/workitems?$top=2000&api-version=6.0',
31
+ mockToken,
32
+ 'get'
33
+ );
34
+ expect(result).toEqual([{ id: '1', url: 'https://example.test/wi/1' }]);
35
+ });
36
+
37
+ it('should encode project names in the build work items URL', async () => {
38
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValue({});
39
+
40
+ const result = await pipelinesDataProvider.GetBuildWorkItems('Project With Spaces', 123);
41
+
42
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
43
+ 'https://dev.azure.com/orgname/Project%20With%20Spaces/_apis/build/builds/123/workitems?$top=2000&api-version=6.0',
44
+ mockToken,
45
+ 'get'
46
+ );
47
+ expect(result).toEqual([]);
48
+ });
49
+ });
50
+
21
51
  describe('isMatchingPipeline', () => {
22
52
  // Create test method to access private method
23
53
  const invokeIsMatchingPipeline = (
@@ -581,21 +611,312 @@ describe('PipelinesDataProvider', () => {
581
611
  expect(result.value).toEqual([{ id: 1 }]);
582
612
  });
583
613
 
584
- it('should handle errors during pagination', async () => {
614
+ it('should continue paging for reversed release ranges until the lower release id is loaded', async () => {
615
+ const projectName = 'project1';
616
+ const definitionId = '456';
617
+
618
+ (TFSServices.getItemContentWithHeaders as jest.Mock)
619
+ .mockResolvedValueOnce({
620
+ data: { value: [{ id: 20 }, { id: 19 }] },
621
+ headers: { 'x-ms-continuationtoken': 'page-2' },
622
+ })
623
+ .mockResolvedValueOnce({
624
+ data: { value: [{ id: 14 }, { id: 13 }] },
625
+ headers: {},
626
+ });
627
+
628
+ const result = await pipelinesDataProvider.GetAllReleaseHistory(projectName, definitionId, {
629
+ fromId: 20,
630
+ toId: 14,
631
+ });
632
+
633
+ expect(result.value.map((r: any) => r.id)).toEqual([20, 19, 14, 13]);
634
+ expect(TFSServices.getItemContentWithHeaders).toHaveBeenCalledTimes(2);
635
+ });
636
+
637
+ it('should throw when release history pagination fails', async () => {
585
638
  // Arrange
586
639
  const projectName = 'project1';
587
640
  const definitionId = '456';
588
641
  (TFSServices.getItemContentWithHeaders as jest.Mock).mockRejectedValueOnce(new Error('API Error'));
589
642
 
590
- // Act
591
- const result = await pipelinesDataProvider.GetAllReleaseHistory(projectName, definitionId);
592
-
593
643
  // Assert
594
- expect(result).toEqual({ count: 0, value: [] });
644
+ await expect(pipelinesDataProvider.GetAllReleaseHistory(projectName, definitionId)).rejects.toThrow(
645
+ 'API Error'
646
+ );
595
647
  expect(logger.error).toHaveBeenCalled();
596
648
  });
597
649
  });
598
650
 
651
+ describe('findPreviousSuccessfulRelease', () => {
652
+ const releaseCandidate = (id: number, status = 'succeeded') => ({
653
+ id,
654
+ status: 'active',
655
+ environments: [{ status }],
656
+ releaseDefinition: { id: 456 },
657
+ });
658
+
659
+ it('should find previous successful release on a later Release API page', async () => {
660
+ const projectName = 'project1';
661
+ const definitionId = '456';
662
+
663
+ (TFSServices.getItemContentWithHeaders as jest.Mock)
664
+ .mockResolvedValueOnce({
665
+ data: { value: [] },
666
+ headers: { 'x-ms-continuationtoken': 'next-page' },
667
+ })
668
+ .mockResolvedValueOnce({
669
+ data: { value: [releaseCandidate(80)] },
670
+ headers: {},
671
+ });
672
+
673
+ const result = await pipelinesDataProvider.findPreviousSuccessfulRelease(
674
+ projectName,
675
+ definitionId,
676
+ 100
677
+ );
678
+
679
+ expect(result).toBe(80);
680
+ expect(TFSServices.getItemContentWithHeaders).toHaveBeenCalledWith(
681
+ expect.stringContaining('continuationToken=next-page'),
682
+ mockToken,
683
+ 'get',
684
+ null,
685
+ null
686
+ );
687
+ });
688
+
689
+ it('should query release history with expanded environments and ignore non-successful releases', async () => {
690
+ const projectName = 'project1';
691
+ const definitionId = '456';
692
+
693
+ (TFSServices.getItemContentWithHeaders as jest.Mock).mockResolvedValueOnce({
694
+ data: {
695
+ value: [
696
+ releaseCandidate(99, 'failed'),
697
+ releaseCandidate(98, 'rejected'),
698
+ releaseCandidate(97, 'succeeded'),
699
+ ],
700
+ },
701
+ headers: {},
702
+ });
703
+
704
+ const result = await pipelinesDataProvider.findPreviousSuccessfulRelease(
705
+ projectName,
706
+ definitionId,
707
+ 100
708
+ );
709
+
710
+ const url = (TFSServices.getItemContentWithHeaders as jest.Mock).mock.calls[0][0];
711
+ expect(url).toContain(`definitionId=${definitionId}`);
712
+ expect(url).toContain('queryOrder=descending');
713
+ expect(url).toContain('$top=200');
714
+ expect(url).toContain('$expand=environments');
715
+ expect(result).toBe(97);
716
+ });
717
+
718
+ it('should retry release discovery with api-version 6.0 when 7.1 is unsupported', async () => {
719
+ const unsupportedError: any = new Error('The requested resource does not support api-version 7.1');
720
+ unsupportedError.response = { status: 404 };
721
+
722
+ (TFSServices.getItemContentWithHeaders as jest.Mock)
723
+ .mockRejectedValueOnce(unsupportedError)
724
+ .mockResolvedValueOnce({
725
+ data: { value: [releaseCandidate(90)] },
726
+ headers: {},
727
+ });
728
+
729
+ const result = await pipelinesDataProvider.findPreviousSuccessfulRelease('project1', '456', 100);
730
+
731
+ expect(result).toBe(90);
732
+ expect((TFSServices.getItemContentWithHeaders as jest.Mock).mock.calls[0][0]).toContain(
733
+ 'api-version=7.1'
734
+ );
735
+ expect((TFSServices.getItemContentWithHeaders as jest.Mock).mock.calls[1][0]).toContain(
736
+ 'api-version=6.0'
737
+ );
738
+ });
739
+
740
+ it('should retry release discovery when unsupported api-version is reported in Axios response data', async () => {
741
+ const unsupportedError: any = new Error('Request failed with status code 404');
742
+ unsupportedError.response = {
743
+ status: 404,
744
+ data: { message: 'The requested resource does not support api-version 7.1' },
745
+ };
746
+
747
+ (TFSServices.getItemContentWithHeaders as jest.Mock)
748
+ .mockRejectedValueOnce(unsupportedError)
749
+ .mockResolvedValueOnce({
750
+ data: { value: [releaseCandidate(89)] },
751
+ headers: {},
752
+ });
753
+
754
+ const result = await pipelinesDataProvider.findPreviousSuccessfulRelease('project1', '456', 100);
755
+
756
+ expect(result).toBe(89);
757
+ expect((TFSServices.getItemContentWithHeaders as jest.Mock).mock.calls[1][0]).toContain(
758
+ 'api-version=6.0'
759
+ );
760
+ });
761
+
762
+ it('should not retry api-version 6.0 for ordinary invalid release definition errors', async () => {
763
+ const invalidDefinitionError: any = new Error('Release definition 456 was not found');
764
+ invalidDefinitionError.response = { status: 404 };
765
+ (TFSServices.getItemContentWithHeaders as jest.Mock).mockRejectedValueOnce(invalidDefinitionError);
766
+
767
+ await expect(
768
+ pipelinesDataProvider.findPreviousSuccessfulRelease('project1', '456', 100)
769
+ ).rejects.toThrow('Release definition 456 was not found');
770
+
771
+ expect(TFSServices.getItemContentWithHeaders).toHaveBeenCalledTimes(1);
772
+ expect((TFSServices.getItemContentWithHeaders as jest.Mock).mock.calls[0][0]).toContain(
773
+ 'api-version=7.1'
774
+ );
775
+ });
776
+
777
+ it('should throw release discovery permission errors without retrying api-version 6.0', async () => {
778
+ const permissionError: any = new Error('Forbidden');
779
+ permissionError.response = { status: 403 };
780
+ (TFSServices.getItemContentWithHeaders as jest.Mock).mockRejectedValueOnce(permissionError);
781
+
782
+ await expect(
783
+ pipelinesDataProvider.findPreviousSuccessfulRelease('project1', '456', 100)
784
+ ).rejects.toThrow('Forbidden');
785
+
786
+ expect(TFSServices.getItemContentWithHeaders).toHaveBeenCalledTimes(1);
787
+ expect((TFSServices.getItemContentWithHeaders as jest.Mock).mock.calls[0][0]).toContain(
788
+ 'api-version=7.1'
789
+ );
790
+ });
791
+
792
+ it('should throw when a later release discovery page fails', async () => {
793
+ (TFSServices.getItemContentWithHeaders as jest.Mock)
794
+ .mockResolvedValueOnce({
795
+ data: { value: [] },
796
+ headers: { 'x-ms-continuationtoken': 'next-page' },
797
+ })
798
+ .mockRejectedValueOnce(new Error('page failed'));
799
+
800
+ await expect(
801
+ pipelinesDataProvider.findPreviousSuccessfulRelease('project1', '456', 100)
802
+ ).rejects.toThrow('page failed');
803
+ });
804
+
805
+ it('should throw when previous release discovery exceeds the defensive page limit', async () => {
806
+ (TFSServices.getItemContentWithHeaders as jest.Mock).mockImplementation(() =>
807
+ Promise.resolve({
808
+ data: { value: [] },
809
+ headers: { 'x-ms-continuationtoken': 'next-page' },
810
+ })
811
+ );
812
+
813
+ await expect(
814
+ pipelinesDataProvider.findPreviousSuccessfulRelease('project1', '456', 100)
815
+ ).rejects.toThrow('Release discovery exceeded 50 pages');
816
+ });
817
+ });
818
+
819
+ describe('findLatestSuccessfulRelease', () => {
820
+ const releaseCandidate = (id: number, status = 'succeeded') => ({
821
+ id,
822
+ status: 'active',
823
+ environments: [{ status }],
824
+ releaseDefinition: { id: 456 },
825
+ });
826
+
827
+ it('should find latest successful release on a later Release API page', async () => {
828
+ (TFSServices.getItemContentWithHeaders as jest.Mock)
829
+ .mockResolvedValueOnce({
830
+ data: { value: [releaseCandidate(100, 'failed')] },
831
+ headers: { 'x-ms-continuationtoken': 'next-page' },
832
+ })
833
+ .mockResolvedValueOnce({
834
+ data: { value: [releaseCandidate(90)] },
835
+ headers: {},
836
+ });
837
+
838
+ const result = await pipelinesDataProvider.findLatestSuccessfulRelease('project1', '456');
839
+
840
+ expect(result).toBe(90);
841
+ expect(TFSServices.getItemContentWithHeaders).toHaveBeenCalledWith(
842
+ expect.stringContaining('continuationToken=next-page'),
843
+ mockToken,
844
+ 'get',
845
+ null,
846
+ null
847
+ );
848
+ });
849
+
850
+ it('should retry latest release discovery with api-version 6.0 only for unsupported api-version errors', async () => {
851
+ const unsupportedError: any = new Error('The requested resource does not support api-version 7.1');
852
+ unsupportedError.response = { status: 404 };
853
+
854
+ (TFSServices.getItemContentWithHeaders as jest.Mock)
855
+ .mockRejectedValueOnce(unsupportedError)
856
+ .mockResolvedValueOnce({
857
+ data: { value: [releaseCandidate(101)] },
858
+ headers: {},
859
+ });
860
+
861
+ const result = await pipelinesDataProvider.findLatestSuccessfulRelease('project1', '456');
862
+
863
+ expect(result).toBe(101);
864
+ expect((TFSServices.getItemContentWithHeaders as jest.Mock).mock.calls[0][0]).toContain(
865
+ 'api-version=7.1'
866
+ );
867
+ expect((TFSServices.getItemContentWithHeaders as jest.Mock).mock.calls[1][0]).toContain(
868
+ 'api-version=6.0'
869
+ );
870
+ });
871
+
872
+ it('should retry latest release discovery when unsupported api-version is reported in Axios response data', async () => {
873
+ const unsupportedError: any = new Error('Request failed with status code 404');
874
+ unsupportedError.response = {
875
+ status: 404,
876
+ data: { message: 'The requested resource does not support api-version 7.1' },
877
+ };
878
+
879
+ (TFSServices.getItemContentWithHeaders as jest.Mock)
880
+ .mockRejectedValueOnce(unsupportedError)
881
+ .mockResolvedValueOnce({
882
+ data: { value: [releaseCandidate(102)] },
883
+ headers: {},
884
+ });
885
+
886
+ const result = await pipelinesDataProvider.findLatestSuccessfulRelease('project1', '456');
887
+
888
+ expect(result).toBe(102);
889
+ expect((TFSServices.getItemContentWithHeaders as jest.Mock).mock.calls[1][0]).toContain(
890
+ 'api-version=6.0'
891
+ );
892
+ });
893
+
894
+ it('should not retry latest release discovery for ordinary 404 errors', async () => {
895
+ const invalidDefinitionError: any = new Error('Release definition 456 was not found');
896
+ invalidDefinitionError.response = { status: 404 };
897
+ (TFSServices.getItemContentWithHeaders as jest.Mock).mockRejectedValueOnce(invalidDefinitionError);
898
+
899
+ await expect(
900
+ pipelinesDataProvider.findLatestSuccessfulRelease('project1', '456')
901
+ ).rejects.toThrow('Release definition 456 was not found');
902
+
903
+ expect(TFSServices.getItemContentWithHeaders).toHaveBeenCalledTimes(1);
904
+ });
905
+
906
+ it('should throw when latest release discovery exceeds the defensive page limit', async () => {
907
+ (TFSServices.getItemContentWithHeaders as jest.Mock).mockImplementation(() =>
908
+ Promise.resolve({
909
+ data: { value: [] },
910
+ headers: { 'x-ms-continuationtoken': 'next-page' },
911
+ })
912
+ );
913
+
914
+ await expect(
915
+ pipelinesDataProvider.findLatestSuccessfulRelease('project1', '456')
916
+ ).rejects.toThrow('Release discovery exceeded 50 pages');
917
+ });
918
+ });
919
+
599
920
  describe('GetAllPipelines', () => {
600
921
  it('should fetch all pipelines for a project', async () => {
601
922
  // Arrange
@@ -877,12 +1198,40 @@ describe('PipelinesDataProvider', () => {
877
1198
  });
878
1199
 
879
1200
  describe('findPreviousPipeline', () => {
1201
+ const targetPipelineRun = {
1202
+ resources: {
1203
+ repositories: {
1204
+ '0': {
1205
+ self: {
1206
+ repository: { id: 'repo1' },
1207
+ version: 'target-sha',
1208
+ refName: 'refs/heads/main',
1209
+ },
1210
+ },
1211
+ },
1212
+ },
1213
+ } as unknown as PipelineRun;
1214
+
1215
+ const buildCandidate = (id: number, branchName: string, repoId = 'repo1') => ({
1216
+ id,
1217
+ status: 'completed',
1218
+ result: 'succeeded',
1219
+ sourceBranch: branchName,
1220
+ sourceVersion: `sha-${id}`,
1221
+ repository: { id: repoId },
1222
+ definition: { id: 123 },
1223
+ });
1224
+
880
1225
  it('should return undefined when no pipeline runs exist', async () => {
881
1226
  // Arrange
882
1227
  const teamProject = 'project1';
883
1228
  const pipelineId = '123';
884
1229
  const toPipelineRunId = 100;
885
1230
  const targetPipeline = {} as PipelineRun;
1231
+ (TFSServices.getItemContentWithHeaders as jest.Mock).mockResolvedValueOnce({
1232
+ data: { value: [] },
1233
+ headers: {},
1234
+ });
886
1235
  (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({});
887
1236
 
888
1237
  // Act
@@ -908,6 +1257,15 @@ describe('PipelinesDataProvider', () => {
908
1257
  },
909
1258
  } as any;
910
1259
 
1260
+ (TFSServices.getItemContentWithHeaders as jest.Mock)
1261
+ .mockResolvedValueOnce({
1262
+ data: { value: [] },
1263
+ headers: {},
1264
+ })
1265
+ .mockResolvedValueOnce({
1266
+ data: { value: [] },
1267
+ headers: {},
1268
+ });
911
1269
  (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({
912
1270
  value: [
913
1271
  { id: 100, result: 'succeeded' },
@@ -938,6 +1296,10 @@ describe('PipelinesDataProvider', () => {
938
1296
  const toPipelineRunId = 100;
939
1297
  const targetPipeline = {} as PipelineRun;
940
1298
 
1299
+ (TFSServices.getItemContentWithHeaders as jest.Mock).mockResolvedValueOnce({
1300
+ data: { value: [] },
1301
+ headers: {},
1302
+ });
941
1303
  (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({
942
1304
  value: [{ id: 99, result: 'succeeded' }],
943
1305
  });
@@ -955,6 +1317,7 @@ describe('PipelinesDataProvider', () => {
955
1317
 
956
1318
  expect(res).toBeUndefined();
957
1319
  expect(detailsSpy).not.toHaveBeenCalled();
1320
+ expect(TFSServices.getItemContentWithHeaders).not.toHaveBeenCalled();
958
1321
  });
959
1322
 
960
1323
  it('should skip when pipeline details do not include repositories', async () => {
@@ -1018,6 +1381,15 @@ describe('PipelinesDataProvider', () => {
1018
1381
  },
1019
1382
  };
1020
1383
 
1384
+ (TFSServices.getItemContentWithHeaders as jest.Mock)
1385
+ .mockResolvedValueOnce({
1386
+ data: { value: [] },
1387
+ headers: {},
1388
+ })
1389
+ .mockResolvedValueOnce({
1390
+ data: { value: [] },
1391
+ headers: {},
1392
+ });
1021
1393
  (TFSServices.getItemContent as jest.Mock)
1022
1394
  .mockResolvedValueOnce(mockRunHistory)
1023
1395
  .mockResolvedValueOnce(mockPipelineDetails);
@@ -1034,6 +1406,171 @@ describe('PipelinesDataProvider', () => {
1034
1406
  // Assert
1035
1407
  expect(result).toBe(99);
1036
1408
  });
1409
+
1410
+ it('should find previous successful build on a later Builds API page', async () => {
1411
+ (TFSServices.getItemContentWithHeaders as jest.Mock)
1412
+ .mockResolvedValueOnce({
1413
+ data: { value: [] },
1414
+ headers: { 'x-ms-continuationtoken': 'next-page' },
1415
+ })
1416
+ .mockResolvedValueOnce({
1417
+ data: { value: [buildCandidate(80, 'refs/heads/main')] },
1418
+ headers: {},
1419
+ });
1420
+
1421
+ const result = await pipelinesDataProvider.findPreviousPipeline(
1422
+ 'project1',
1423
+ '123',
1424
+ 100,
1425
+ targetPipelineRun,
1426
+ true
1427
+ );
1428
+
1429
+ expect(result).toBe(80);
1430
+ expect(TFSServices.getItemContentWithHeaders).toHaveBeenCalledWith(
1431
+ expect.stringContaining('continuationToken=next-page'),
1432
+ mockToken,
1433
+ 'get',
1434
+ null,
1435
+ null
1436
+ );
1437
+ });
1438
+
1439
+ it('should prefer a same-branch successful build before trying cross-branch fallback', async () => {
1440
+ (TFSServices.getItemContentWithHeaders as jest.Mock).mockResolvedValueOnce({
1441
+ data: { value: [buildCandidate(90, 'refs/heads/main')] },
1442
+ headers: {},
1443
+ });
1444
+
1445
+ const result = await pipelinesDataProvider.findPreviousPipeline(
1446
+ 'project1',
1447
+ '123',
1448
+ 100,
1449
+ targetPipelineRun,
1450
+ true
1451
+ );
1452
+
1453
+ expect(result).toBe(90);
1454
+ expect(TFSServices.getItemContentWithHeaders).toHaveBeenCalledTimes(1);
1455
+ expect((TFSServices.getItemContentWithHeaders as jest.Mock).mock.calls[0][0]).toContain(
1456
+ 'branchName=refs%2Fheads%2Fmain'
1457
+ );
1458
+ });
1459
+
1460
+ it('should fall back to a different branch only when same-branch discovery fails', async () => {
1461
+ (TFSServices.getItemContentWithHeaders as jest.Mock)
1462
+ .mockResolvedValueOnce({
1463
+ data: { value: [] },
1464
+ headers: {},
1465
+ })
1466
+ .mockResolvedValueOnce({
1467
+ data: { value: [buildCandidate(95, 'refs/heads/release')] },
1468
+ headers: {},
1469
+ });
1470
+
1471
+ const result = await pipelinesDataProvider.findPreviousPipeline(
1472
+ 'project1',
1473
+ '123',
1474
+ 100,
1475
+ targetPipelineRun,
1476
+ true
1477
+ );
1478
+
1479
+ expect(result).toBe(95);
1480
+ expect(TFSServices.getItemContentWithHeaders).toHaveBeenCalledTimes(2);
1481
+ expect((TFSServices.getItemContentWithHeaders as jest.Mock).mock.calls[0][0]).toContain(
1482
+ 'branchName=refs%2Fheads%2Fmain'
1483
+ );
1484
+ expect((TFSServices.getItemContentWithHeaders as jest.Mock).mock.calls[1][0]).not.toContain(
1485
+ 'branchName='
1486
+ );
1487
+ });
1488
+
1489
+ it('should query completed successful builds and ignore non-previous candidates', async () => {
1490
+ (TFSServices.getItemContentWithHeaders as jest.Mock)
1491
+ .mockResolvedValueOnce({
1492
+ data: { value: [buildCandidate(100, 'refs/heads/main'), buildCandidate(101, 'refs/heads/main')] },
1493
+ headers: {},
1494
+ })
1495
+ .mockResolvedValueOnce({
1496
+ data: { value: [] },
1497
+ headers: {},
1498
+ });
1499
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({});
1500
+
1501
+ const result = await pipelinesDataProvider.findPreviousPipeline(
1502
+ 'project1',
1503
+ '123',
1504
+ 100,
1505
+ targetPipelineRun,
1506
+ true
1507
+ );
1508
+
1509
+ const url = (TFSServices.getItemContentWithHeaders as jest.Mock).mock.calls[0][0];
1510
+ expect(url).toContain('definitions=123');
1511
+ expect(url).toContain('resultFilter=succeeded');
1512
+ expect(url).toContain('statusFilter=completed');
1513
+ expect(url).toContain('queryOrder=finishTimeDescending');
1514
+ expect(result).toBeUndefined();
1515
+ });
1516
+
1517
+ it('should throw and not try cross-branch fallback when same-branch Builds API fails', async () => {
1518
+ (TFSServices.getItemContentWithHeaders as jest.Mock).mockRejectedValueOnce(new Error('same branch failed'));
1519
+
1520
+ await expect(
1521
+ pipelinesDataProvider.findPreviousPipeline('project1', '123', 100, targetPipelineRun, true)
1522
+ ).rejects.toThrow('same branch failed');
1523
+
1524
+ expect(TFSServices.getItemContentWithHeaders).toHaveBeenCalledTimes(1);
1525
+ expect((TFSServices.getItemContentWithHeaders as jest.Mock).mock.calls[0][0]).toContain(
1526
+ 'branchName=refs%2Fheads%2Fmain'
1527
+ );
1528
+ expect(TFSServices.getItemContent).not.toHaveBeenCalled();
1529
+ });
1530
+
1531
+ it('should throw when cross-branch Builds API fallback fails after same-branch no-match', async () => {
1532
+ (TFSServices.getItemContentWithHeaders as jest.Mock)
1533
+ .mockResolvedValueOnce({
1534
+ data: { value: [] },
1535
+ headers: {},
1536
+ })
1537
+ .mockRejectedValueOnce(new Error('fallback failed'));
1538
+
1539
+ await expect(
1540
+ pipelinesDataProvider.findPreviousPipeline('project1', '123', 100, targetPipelineRun, true)
1541
+ ).rejects.toThrow('fallback failed');
1542
+
1543
+ expect(TFSServices.getItemContentWithHeaders).toHaveBeenCalledTimes(2);
1544
+ expect((TFSServices.getItemContentWithHeaders as jest.Mock).mock.calls[1][0]).not.toContain(
1545
+ 'branchName='
1546
+ );
1547
+ });
1548
+
1549
+ it('should throw when a later Builds API page fails', async () => {
1550
+ (TFSServices.getItemContentWithHeaders as jest.Mock)
1551
+ .mockResolvedValueOnce({
1552
+ data: { value: [] },
1553
+ headers: { 'x-ms-continuationtoken': 'next-page' },
1554
+ })
1555
+ .mockRejectedValueOnce(new Error('build page failed'));
1556
+
1557
+ await expect(
1558
+ pipelinesDataProvider.findPreviousPipeline('project1', '123', 100, targetPipelineRun, true)
1559
+ ).rejects.toThrow('build page failed');
1560
+ });
1561
+
1562
+ it('should throw when previous build discovery exceeds the defensive page limit', async () => {
1563
+ (TFSServices.getItemContentWithHeaders as jest.Mock).mockImplementation(() =>
1564
+ Promise.resolve({
1565
+ data: { value: [] },
1566
+ headers: { 'x-ms-continuationtoken': 'next-page' },
1567
+ })
1568
+ );
1569
+
1570
+ await expect(
1571
+ pipelinesDataProvider.findPreviousPipeline('project1', '123', 100, targetPipelineRun, true)
1572
+ ).rejects.toThrow('Pipeline discovery exceeded 50 pages');
1573
+ });
1037
1574
  });
1038
1575
 
1039
1576
  describe('private helper methods', () => {
@@ -1029,6 +1029,11 @@ describe('TicketsDataProvider', () => {
1029
1029
  icon: expect.objectContaining({ dataUrl: 'data:image/png;base64,xxx' }),
1030
1030
  }),
1031
1031
  );
1032
+ expect(TFSServices.getItemContent).toHaveBeenCalledWith(
1033
+ expect.stringContaining('/_apis/wit/workitemtypes?api-version=5.1'),
1034
+ expect.any(String),
1035
+ );
1036
+ expect((TFSServices.getItemContent as jest.Mock).mock.calls[0][0]).not.toContain('api-version=5.1:');
1032
1037
  expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Failed to download icon'));
1033
1038
  });
1034
1039
  });