@elisra-devops/docgen-data-provider 1.109.1 → 1.111.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.
@@ -21,6 +21,7 @@ describe('TFSServices', () => {
21
21
 
22
22
  beforeEach(() => {
23
23
  jest.clearAllMocks();
24
+ mockAxiosInstance.request.mockReset();
24
25
  // Mock Math.random to return a predictable value for tests with retry
25
26
  Math.random = jest.fn().mockReturnValue(0.5);
26
27
  });
@@ -191,7 +192,7 @@ describe('TFSServices', () => {
191
192
  url: url.replace(/ /g, '%20'),
192
193
  method: 'get',
193
194
  auth: { username: '', password: pat },
194
- timeout: 10000, // Verify the actual timeout value is 10000ms
195
+ timeout: 30000,
195
196
  })
196
197
  );
197
198
  });
@@ -228,7 +229,7 @@ describe('TFSServices', () => {
228
229
  timeoutError.name = 'TimeoutError';
229
230
  (timeoutError as any).code = 'ECONNABORTED';
230
231
 
231
- // Configure mock to simulate timeout (will retry 3 times by default)
232
+ // Timeout errors should get the standard retry budget.
232
233
  mockAxiosInstance.request
233
234
  .mockRejectedValueOnce(timeoutError)
234
235
  .mockRejectedValueOnce(timeoutError)
@@ -290,7 +291,7 @@ describe('TFSServices', () => {
290
291
 
291
292
  // Act & Assert
292
293
  await expect(TFSServices.getItemContent(url, pat)).rejects.toThrow('ENOTFOUND');
293
- expect(mockAxiosInstance.request).toHaveBeenCalledTimes(2); // Should retry DNS failures too
294
+ expect(mockAxiosInstance.request).toHaveBeenCalledTimes(3); // Initial + 2 retries
294
295
  });
295
296
 
296
297
  it('should handle spaces in URL by replacing them with %20', async () => {
@@ -581,21 +581,312 @@ describe('PipelinesDataProvider', () => {
581
581
  expect(result.value).toEqual([{ id: 1 }]);
582
582
  });
583
583
 
584
- it('should handle errors during pagination', async () => {
584
+ it('should continue paging for reversed release ranges until the lower release id is loaded', async () => {
585
+ const projectName = 'project1';
586
+ const definitionId = '456';
587
+
588
+ (TFSServices.getItemContentWithHeaders as jest.Mock)
589
+ .mockResolvedValueOnce({
590
+ data: { value: [{ id: 20 }, { id: 19 }] },
591
+ headers: { 'x-ms-continuationtoken': 'page-2' },
592
+ })
593
+ .mockResolvedValueOnce({
594
+ data: { value: [{ id: 14 }, { id: 13 }] },
595
+ headers: {},
596
+ });
597
+
598
+ const result = await pipelinesDataProvider.GetAllReleaseHistory(projectName, definitionId, {
599
+ fromId: 20,
600
+ toId: 14,
601
+ });
602
+
603
+ expect(result.value.map((r: any) => r.id)).toEqual([20, 19, 14, 13]);
604
+ expect(TFSServices.getItemContentWithHeaders).toHaveBeenCalledTimes(2);
605
+ });
606
+
607
+ it('should throw when release history pagination fails', async () => {
585
608
  // Arrange
586
609
  const projectName = 'project1';
587
610
  const definitionId = '456';
588
611
  (TFSServices.getItemContentWithHeaders as jest.Mock).mockRejectedValueOnce(new Error('API Error'));
589
612
 
590
- // Act
591
- const result = await pipelinesDataProvider.GetAllReleaseHistory(projectName, definitionId);
592
-
593
613
  // Assert
594
- expect(result).toEqual({ count: 0, value: [] });
614
+ await expect(pipelinesDataProvider.GetAllReleaseHistory(projectName, definitionId)).rejects.toThrow(
615
+ 'API Error'
616
+ );
595
617
  expect(logger.error).toHaveBeenCalled();
596
618
  });
597
619
  });
598
620
 
621
+ describe('findPreviousSuccessfulRelease', () => {
622
+ const releaseCandidate = (id: number, status = 'succeeded') => ({
623
+ id,
624
+ status: 'active',
625
+ environments: [{ status }],
626
+ releaseDefinition: { id: 456 },
627
+ });
628
+
629
+ it('should find previous successful release on a later Release API page', async () => {
630
+ const projectName = 'project1';
631
+ const definitionId = '456';
632
+
633
+ (TFSServices.getItemContentWithHeaders as jest.Mock)
634
+ .mockResolvedValueOnce({
635
+ data: { value: [] },
636
+ headers: { 'x-ms-continuationtoken': 'next-page' },
637
+ })
638
+ .mockResolvedValueOnce({
639
+ data: { value: [releaseCandidate(80)] },
640
+ headers: {},
641
+ });
642
+
643
+ const result = await pipelinesDataProvider.findPreviousSuccessfulRelease(
644
+ projectName,
645
+ definitionId,
646
+ 100
647
+ );
648
+
649
+ expect(result).toBe(80);
650
+ expect(TFSServices.getItemContentWithHeaders).toHaveBeenCalledWith(
651
+ expect.stringContaining('continuationToken=next-page'),
652
+ mockToken,
653
+ 'get',
654
+ null,
655
+ null
656
+ );
657
+ });
658
+
659
+ it('should query release history with expanded environments and ignore non-successful releases', async () => {
660
+ const projectName = 'project1';
661
+ const definitionId = '456';
662
+
663
+ (TFSServices.getItemContentWithHeaders as jest.Mock).mockResolvedValueOnce({
664
+ data: {
665
+ value: [
666
+ releaseCandidate(99, 'failed'),
667
+ releaseCandidate(98, 'rejected'),
668
+ releaseCandidate(97, 'succeeded'),
669
+ ],
670
+ },
671
+ headers: {},
672
+ });
673
+
674
+ const result = await pipelinesDataProvider.findPreviousSuccessfulRelease(
675
+ projectName,
676
+ definitionId,
677
+ 100
678
+ );
679
+
680
+ const url = (TFSServices.getItemContentWithHeaders as jest.Mock).mock.calls[0][0];
681
+ expect(url).toContain(`definitionId=${definitionId}`);
682
+ expect(url).toContain('queryOrder=descending');
683
+ expect(url).toContain('$top=200');
684
+ expect(url).toContain('$expand=environments');
685
+ expect(result).toBe(97);
686
+ });
687
+
688
+ it('should retry release discovery with api-version 6.0 when 7.1 is unsupported', async () => {
689
+ const unsupportedError: any = new Error('The requested resource does not support api-version 7.1');
690
+ unsupportedError.response = { status: 404 };
691
+
692
+ (TFSServices.getItemContentWithHeaders as jest.Mock)
693
+ .mockRejectedValueOnce(unsupportedError)
694
+ .mockResolvedValueOnce({
695
+ data: { value: [releaseCandidate(90)] },
696
+ headers: {},
697
+ });
698
+
699
+ const result = await pipelinesDataProvider.findPreviousSuccessfulRelease('project1', '456', 100);
700
+
701
+ expect(result).toBe(90);
702
+ expect((TFSServices.getItemContentWithHeaders as jest.Mock).mock.calls[0][0]).toContain(
703
+ 'api-version=7.1'
704
+ );
705
+ expect((TFSServices.getItemContentWithHeaders as jest.Mock).mock.calls[1][0]).toContain(
706
+ 'api-version=6.0'
707
+ );
708
+ });
709
+
710
+ it('should retry release discovery when unsupported api-version is reported in Axios response data', async () => {
711
+ const unsupportedError: any = new Error('Request failed with status code 404');
712
+ unsupportedError.response = {
713
+ status: 404,
714
+ data: { message: 'The requested resource does not support api-version 7.1' },
715
+ };
716
+
717
+ (TFSServices.getItemContentWithHeaders as jest.Mock)
718
+ .mockRejectedValueOnce(unsupportedError)
719
+ .mockResolvedValueOnce({
720
+ data: { value: [releaseCandidate(89)] },
721
+ headers: {},
722
+ });
723
+
724
+ const result = await pipelinesDataProvider.findPreviousSuccessfulRelease('project1', '456', 100);
725
+
726
+ expect(result).toBe(89);
727
+ expect((TFSServices.getItemContentWithHeaders as jest.Mock).mock.calls[1][0]).toContain(
728
+ 'api-version=6.0'
729
+ );
730
+ });
731
+
732
+ it('should not retry api-version 6.0 for ordinary invalid release definition errors', async () => {
733
+ const invalidDefinitionError: any = new Error('Release definition 456 was not found');
734
+ invalidDefinitionError.response = { status: 404 };
735
+ (TFSServices.getItemContentWithHeaders as jest.Mock).mockRejectedValueOnce(invalidDefinitionError);
736
+
737
+ await expect(
738
+ pipelinesDataProvider.findPreviousSuccessfulRelease('project1', '456', 100)
739
+ ).rejects.toThrow('Release definition 456 was not found');
740
+
741
+ expect(TFSServices.getItemContentWithHeaders).toHaveBeenCalledTimes(1);
742
+ expect((TFSServices.getItemContentWithHeaders as jest.Mock).mock.calls[0][0]).toContain(
743
+ 'api-version=7.1'
744
+ );
745
+ });
746
+
747
+ it('should throw release discovery permission errors without retrying api-version 6.0', async () => {
748
+ const permissionError: any = new Error('Forbidden');
749
+ permissionError.response = { status: 403 };
750
+ (TFSServices.getItemContentWithHeaders as jest.Mock).mockRejectedValueOnce(permissionError);
751
+
752
+ await expect(
753
+ pipelinesDataProvider.findPreviousSuccessfulRelease('project1', '456', 100)
754
+ ).rejects.toThrow('Forbidden');
755
+
756
+ expect(TFSServices.getItemContentWithHeaders).toHaveBeenCalledTimes(1);
757
+ expect((TFSServices.getItemContentWithHeaders as jest.Mock).mock.calls[0][0]).toContain(
758
+ 'api-version=7.1'
759
+ );
760
+ });
761
+
762
+ it('should throw when a later release discovery page fails', async () => {
763
+ (TFSServices.getItemContentWithHeaders as jest.Mock)
764
+ .mockResolvedValueOnce({
765
+ data: { value: [] },
766
+ headers: { 'x-ms-continuationtoken': 'next-page' },
767
+ })
768
+ .mockRejectedValueOnce(new Error('page failed'));
769
+
770
+ await expect(
771
+ pipelinesDataProvider.findPreviousSuccessfulRelease('project1', '456', 100)
772
+ ).rejects.toThrow('page failed');
773
+ });
774
+
775
+ it('should throw when previous release discovery exceeds the defensive page limit', async () => {
776
+ (TFSServices.getItemContentWithHeaders as jest.Mock).mockImplementation(() =>
777
+ Promise.resolve({
778
+ data: { value: [] },
779
+ headers: { 'x-ms-continuationtoken': 'next-page' },
780
+ })
781
+ );
782
+
783
+ await expect(
784
+ pipelinesDataProvider.findPreviousSuccessfulRelease('project1', '456', 100)
785
+ ).rejects.toThrow('Release discovery exceeded 50 pages');
786
+ });
787
+ });
788
+
789
+ describe('findLatestSuccessfulRelease', () => {
790
+ const releaseCandidate = (id: number, status = 'succeeded') => ({
791
+ id,
792
+ status: 'active',
793
+ environments: [{ status }],
794
+ releaseDefinition: { id: 456 },
795
+ });
796
+
797
+ it('should find latest successful release on a later Release API page', async () => {
798
+ (TFSServices.getItemContentWithHeaders as jest.Mock)
799
+ .mockResolvedValueOnce({
800
+ data: { value: [releaseCandidate(100, 'failed')] },
801
+ headers: { 'x-ms-continuationtoken': 'next-page' },
802
+ })
803
+ .mockResolvedValueOnce({
804
+ data: { value: [releaseCandidate(90)] },
805
+ headers: {},
806
+ });
807
+
808
+ const result = await pipelinesDataProvider.findLatestSuccessfulRelease('project1', '456');
809
+
810
+ expect(result).toBe(90);
811
+ expect(TFSServices.getItemContentWithHeaders).toHaveBeenCalledWith(
812
+ expect.stringContaining('continuationToken=next-page'),
813
+ mockToken,
814
+ 'get',
815
+ null,
816
+ null
817
+ );
818
+ });
819
+
820
+ it('should retry latest release discovery with api-version 6.0 only for unsupported api-version errors', async () => {
821
+ const unsupportedError: any = new Error('The requested resource does not support api-version 7.1');
822
+ unsupportedError.response = { status: 404 };
823
+
824
+ (TFSServices.getItemContentWithHeaders as jest.Mock)
825
+ .mockRejectedValueOnce(unsupportedError)
826
+ .mockResolvedValueOnce({
827
+ data: { value: [releaseCandidate(101)] },
828
+ headers: {},
829
+ });
830
+
831
+ const result = await pipelinesDataProvider.findLatestSuccessfulRelease('project1', '456');
832
+
833
+ expect(result).toBe(101);
834
+ expect((TFSServices.getItemContentWithHeaders as jest.Mock).mock.calls[0][0]).toContain(
835
+ 'api-version=7.1'
836
+ );
837
+ expect((TFSServices.getItemContentWithHeaders as jest.Mock).mock.calls[1][0]).toContain(
838
+ 'api-version=6.0'
839
+ );
840
+ });
841
+
842
+ it('should retry latest release discovery when unsupported api-version is reported in Axios response data', async () => {
843
+ const unsupportedError: any = new Error('Request failed with status code 404');
844
+ unsupportedError.response = {
845
+ status: 404,
846
+ data: { message: 'The requested resource does not support api-version 7.1' },
847
+ };
848
+
849
+ (TFSServices.getItemContentWithHeaders as jest.Mock)
850
+ .mockRejectedValueOnce(unsupportedError)
851
+ .mockResolvedValueOnce({
852
+ data: { value: [releaseCandidate(102)] },
853
+ headers: {},
854
+ });
855
+
856
+ const result = await pipelinesDataProvider.findLatestSuccessfulRelease('project1', '456');
857
+
858
+ expect(result).toBe(102);
859
+ expect((TFSServices.getItemContentWithHeaders as jest.Mock).mock.calls[1][0]).toContain(
860
+ 'api-version=6.0'
861
+ );
862
+ });
863
+
864
+ it('should not retry latest release discovery for ordinary 404 errors', async () => {
865
+ const invalidDefinitionError: any = new Error('Release definition 456 was not found');
866
+ invalidDefinitionError.response = { status: 404 };
867
+ (TFSServices.getItemContentWithHeaders as jest.Mock).mockRejectedValueOnce(invalidDefinitionError);
868
+
869
+ await expect(
870
+ pipelinesDataProvider.findLatestSuccessfulRelease('project1', '456')
871
+ ).rejects.toThrow('Release definition 456 was not found');
872
+
873
+ expect(TFSServices.getItemContentWithHeaders).toHaveBeenCalledTimes(1);
874
+ });
875
+
876
+ it('should throw when latest release discovery exceeds the defensive page limit', async () => {
877
+ (TFSServices.getItemContentWithHeaders as jest.Mock).mockImplementation(() =>
878
+ Promise.resolve({
879
+ data: { value: [] },
880
+ headers: { 'x-ms-continuationtoken': 'next-page' },
881
+ })
882
+ );
883
+
884
+ await expect(
885
+ pipelinesDataProvider.findLatestSuccessfulRelease('project1', '456')
886
+ ).rejects.toThrow('Release discovery exceeded 50 pages');
887
+ });
888
+ });
889
+
599
890
  describe('GetAllPipelines', () => {
600
891
  it('should fetch all pipelines for a project', async () => {
601
892
  // Arrange
@@ -877,12 +1168,40 @@ describe('PipelinesDataProvider', () => {
877
1168
  });
878
1169
 
879
1170
  describe('findPreviousPipeline', () => {
1171
+ const targetPipelineRun = {
1172
+ resources: {
1173
+ repositories: {
1174
+ '0': {
1175
+ self: {
1176
+ repository: { id: 'repo1' },
1177
+ version: 'target-sha',
1178
+ refName: 'refs/heads/main',
1179
+ },
1180
+ },
1181
+ },
1182
+ },
1183
+ } as unknown as PipelineRun;
1184
+
1185
+ const buildCandidate = (id: number, branchName: string, repoId = 'repo1') => ({
1186
+ id,
1187
+ status: 'completed',
1188
+ result: 'succeeded',
1189
+ sourceBranch: branchName,
1190
+ sourceVersion: `sha-${id}`,
1191
+ repository: { id: repoId },
1192
+ definition: { id: 123 },
1193
+ });
1194
+
880
1195
  it('should return undefined when no pipeline runs exist', async () => {
881
1196
  // Arrange
882
1197
  const teamProject = 'project1';
883
1198
  const pipelineId = '123';
884
1199
  const toPipelineRunId = 100;
885
1200
  const targetPipeline = {} as PipelineRun;
1201
+ (TFSServices.getItemContentWithHeaders as jest.Mock).mockResolvedValueOnce({
1202
+ data: { value: [] },
1203
+ headers: {},
1204
+ });
886
1205
  (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({});
887
1206
 
888
1207
  // Act
@@ -908,6 +1227,15 @@ describe('PipelinesDataProvider', () => {
908
1227
  },
909
1228
  } as any;
910
1229
 
1230
+ (TFSServices.getItemContentWithHeaders as jest.Mock)
1231
+ .mockResolvedValueOnce({
1232
+ data: { value: [] },
1233
+ headers: {},
1234
+ })
1235
+ .mockResolvedValueOnce({
1236
+ data: { value: [] },
1237
+ headers: {},
1238
+ });
911
1239
  (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({
912
1240
  value: [
913
1241
  { id: 100, result: 'succeeded' },
@@ -938,6 +1266,10 @@ describe('PipelinesDataProvider', () => {
938
1266
  const toPipelineRunId = 100;
939
1267
  const targetPipeline = {} as PipelineRun;
940
1268
 
1269
+ (TFSServices.getItemContentWithHeaders as jest.Mock).mockResolvedValueOnce({
1270
+ data: { value: [] },
1271
+ headers: {},
1272
+ });
941
1273
  (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({
942
1274
  value: [{ id: 99, result: 'succeeded' }],
943
1275
  });
@@ -955,6 +1287,7 @@ describe('PipelinesDataProvider', () => {
955
1287
 
956
1288
  expect(res).toBeUndefined();
957
1289
  expect(detailsSpy).not.toHaveBeenCalled();
1290
+ expect(TFSServices.getItemContentWithHeaders).not.toHaveBeenCalled();
958
1291
  });
959
1292
 
960
1293
  it('should skip when pipeline details do not include repositories', async () => {
@@ -1018,6 +1351,15 @@ describe('PipelinesDataProvider', () => {
1018
1351
  },
1019
1352
  };
1020
1353
 
1354
+ (TFSServices.getItemContentWithHeaders as jest.Mock)
1355
+ .mockResolvedValueOnce({
1356
+ data: { value: [] },
1357
+ headers: {},
1358
+ })
1359
+ .mockResolvedValueOnce({
1360
+ data: { value: [] },
1361
+ headers: {},
1362
+ });
1021
1363
  (TFSServices.getItemContent as jest.Mock)
1022
1364
  .mockResolvedValueOnce(mockRunHistory)
1023
1365
  .mockResolvedValueOnce(mockPipelineDetails);
@@ -1034,6 +1376,171 @@ describe('PipelinesDataProvider', () => {
1034
1376
  // Assert
1035
1377
  expect(result).toBe(99);
1036
1378
  });
1379
+
1380
+ it('should find previous successful build on a later Builds API page', async () => {
1381
+ (TFSServices.getItemContentWithHeaders as jest.Mock)
1382
+ .mockResolvedValueOnce({
1383
+ data: { value: [] },
1384
+ headers: { 'x-ms-continuationtoken': 'next-page' },
1385
+ })
1386
+ .mockResolvedValueOnce({
1387
+ data: { value: [buildCandidate(80, 'refs/heads/main')] },
1388
+ headers: {},
1389
+ });
1390
+
1391
+ const result = await pipelinesDataProvider.findPreviousPipeline(
1392
+ 'project1',
1393
+ '123',
1394
+ 100,
1395
+ targetPipelineRun,
1396
+ true
1397
+ );
1398
+
1399
+ expect(result).toBe(80);
1400
+ expect(TFSServices.getItemContentWithHeaders).toHaveBeenCalledWith(
1401
+ expect.stringContaining('continuationToken=next-page'),
1402
+ mockToken,
1403
+ 'get',
1404
+ null,
1405
+ null
1406
+ );
1407
+ });
1408
+
1409
+ it('should prefer a same-branch successful build before trying cross-branch fallback', async () => {
1410
+ (TFSServices.getItemContentWithHeaders as jest.Mock).mockResolvedValueOnce({
1411
+ data: { value: [buildCandidate(90, 'refs/heads/main')] },
1412
+ headers: {},
1413
+ });
1414
+
1415
+ const result = await pipelinesDataProvider.findPreviousPipeline(
1416
+ 'project1',
1417
+ '123',
1418
+ 100,
1419
+ targetPipelineRun,
1420
+ true
1421
+ );
1422
+
1423
+ expect(result).toBe(90);
1424
+ expect(TFSServices.getItemContentWithHeaders).toHaveBeenCalledTimes(1);
1425
+ expect((TFSServices.getItemContentWithHeaders as jest.Mock).mock.calls[0][0]).toContain(
1426
+ 'branchName=refs%2Fheads%2Fmain'
1427
+ );
1428
+ });
1429
+
1430
+ it('should fall back to a different branch only when same-branch discovery fails', async () => {
1431
+ (TFSServices.getItemContentWithHeaders as jest.Mock)
1432
+ .mockResolvedValueOnce({
1433
+ data: { value: [] },
1434
+ headers: {},
1435
+ })
1436
+ .mockResolvedValueOnce({
1437
+ data: { value: [buildCandidate(95, 'refs/heads/release')] },
1438
+ headers: {},
1439
+ });
1440
+
1441
+ const result = await pipelinesDataProvider.findPreviousPipeline(
1442
+ 'project1',
1443
+ '123',
1444
+ 100,
1445
+ targetPipelineRun,
1446
+ true
1447
+ );
1448
+
1449
+ expect(result).toBe(95);
1450
+ expect(TFSServices.getItemContentWithHeaders).toHaveBeenCalledTimes(2);
1451
+ expect((TFSServices.getItemContentWithHeaders as jest.Mock).mock.calls[0][0]).toContain(
1452
+ 'branchName=refs%2Fheads%2Fmain'
1453
+ );
1454
+ expect((TFSServices.getItemContentWithHeaders as jest.Mock).mock.calls[1][0]).not.toContain(
1455
+ 'branchName='
1456
+ );
1457
+ });
1458
+
1459
+ it('should query completed successful builds and ignore non-previous candidates', async () => {
1460
+ (TFSServices.getItemContentWithHeaders as jest.Mock)
1461
+ .mockResolvedValueOnce({
1462
+ data: { value: [buildCandidate(100, 'refs/heads/main'), buildCandidate(101, 'refs/heads/main')] },
1463
+ headers: {},
1464
+ })
1465
+ .mockResolvedValueOnce({
1466
+ data: { value: [] },
1467
+ headers: {},
1468
+ });
1469
+ (TFSServices.getItemContent as jest.Mock).mockResolvedValueOnce({});
1470
+
1471
+ const result = await pipelinesDataProvider.findPreviousPipeline(
1472
+ 'project1',
1473
+ '123',
1474
+ 100,
1475
+ targetPipelineRun,
1476
+ true
1477
+ );
1478
+
1479
+ const url = (TFSServices.getItemContentWithHeaders as jest.Mock).mock.calls[0][0];
1480
+ expect(url).toContain('definitions=123');
1481
+ expect(url).toContain('resultFilter=succeeded');
1482
+ expect(url).toContain('statusFilter=completed');
1483
+ expect(url).toContain('queryOrder=finishTimeDescending');
1484
+ expect(result).toBeUndefined();
1485
+ });
1486
+
1487
+ it('should throw and not try cross-branch fallback when same-branch Builds API fails', async () => {
1488
+ (TFSServices.getItemContentWithHeaders as jest.Mock).mockRejectedValueOnce(new Error('same branch failed'));
1489
+
1490
+ await expect(
1491
+ pipelinesDataProvider.findPreviousPipeline('project1', '123', 100, targetPipelineRun, true)
1492
+ ).rejects.toThrow('same branch failed');
1493
+
1494
+ expect(TFSServices.getItemContentWithHeaders).toHaveBeenCalledTimes(1);
1495
+ expect((TFSServices.getItemContentWithHeaders as jest.Mock).mock.calls[0][0]).toContain(
1496
+ 'branchName=refs%2Fheads%2Fmain'
1497
+ );
1498
+ expect(TFSServices.getItemContent).not.toHaveBeenCalled();
1499
+ });
1500
+
1501
+ it('should throw when cross-branch Builds API fallback fails after same-branch no-match', async () => {
1502
+ (TFSServices.getItemContentWithHeaders as jest.Mock)
1503
+ .mockResolvedValueOnce({
1504
+ data: { value: [] },
1505
+ headers: {},
1506
+ })
1507
+ .mockRejectedValueOnce(new Error('fallback failed'));
1508
+
1509
+ await expect(
1510
+ pipelinesDataProvider.findPreviousPipeline('project1', '123', 100, targetPipelineRun, true)
1511
+ ).rejects.toThrow('fallback failed');
1512
+
1513
+ expect(TFSServices.getItemContentWithHeaders).toHaveBeenCalledTimes(2);
1514
+ expect((TFSServices.getItemContentWithHeaders as jest.Mock).mock.calls[1][0]).not.toContain(
1515
+ 'branchName='
1516
+ );
1517
+ });
1518
+
1519
+ it('should throw when a later Builds API page fails', async () => {
1520
+ (TFSServices.getItemContentWithHeaders as jest.Mock)
1521
+ .mockResolvedValueOnce({
1522
+ data: { value: [] },
1523
+ headers: { 'x-ms-continuationtoken': 'next-page' },
1524
+ })
1525
+ .mockRejectedValueOnce(new Error('build page failed'));
1526
+
1527
+ await expect(
1528
+ pipelinesDataProvider.findPreviousPipeline('project1', '123', 100, targetPipelineRun, true)
1529
+ ).rejects.toThrow('build page failed');
1530
+ });
1531
+
1532
+ it('should throw when previous build discovery exceeds the defensive page limit', async () => {
1533
+ (TFSServices.getItemContentWithHeaders as jest.Mock).mockImplementation(() =>
1534
+ Promise.resolve({
1535
+ data: { value: [] },
1536
+ headers: { 'x-ms-continuationtoken': 'next-page' },
1537
+ })
1538
+ );
1539
+
1540
+ await expect(
1541
+ pipelinesDataProvider.findPreviousPipeline('project1', '123', 100, targetPipelineRun, true)
1542
+ ).rejects.toThrow('Pipeline discovery exceeded 50 pages');
1543
+ });
1037
1544
  });
1038
1545
 
1039
1546
  describe('private helper methods', () => {