@webex/internal-plugin-device 3.12.0-next.1 → 3.12.0-next.3

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.
@@ -484,9 +484,26 @@ describe('plugin-device', () => {
484
484
  });
485
485
 
486
486
  describe('deleteDevices()', () => {
487
+ let requestStub;
488
+ let clock;
489
+ let waitForLimitStub;
490
+
487
491
  const setup = (deviceType) => {
488
492
  device.config.defaults = {body: {deviceType}};
489
493
  };
494
+
495
+ beforeEach(() => {
496
+ waitForLimitStub = sinon.stub(device, '_waitForDeviceCountBelowLimit').resolves();
497
+ });
498
+
499
+ afterEach(() => {
500
+ sinon.restore();
501
+ if (clock) {
502
+ clock.restore();
503
+ clock = null;
504
+ }
505
+ });
506
+
490
507
  ['WEB', 'WEBCLIENT'].forEach((deviceType) => {
491
508
  it(`should delete correct number of devices for ${deviceType}`, async () => {
492
509
  setup(deviceType);
@@ -504,7 +521,8 @@ describe('plugin-device', () => {
504
521
  ],
505
522
  },
506
523
  };
507
- const requestStub = sinon.stub(device, 'request');
524
+
525
+ requestStub = sinon.stub(device, 'request');
508
526
  requestStub.withArgs(sinon.match({method: 'GET'})).resolves(response);
509
527
  requestStub.withArgs(sinon.match({method: 'DELETE'})).resolves();
510
528
 
@@ -523,7 +541,7 @@ describe('plugin-device', () => {
523
541
  });
524
542
  });
525
543
 
526
- it('does not delete when there are just 2 devices', async () => {
544
+ it('does not delete when there are only 2 devices (below MIN_DEVICES_FOR_CLEANUP)', async () => {
527
545
  setup('WEB');
528
546
  const response = {
529
547
  body: {
@@ -534,15 +552,392 @@ describe('plugin-device', () => {
534
552
  },
535
553
  };
536
554
 
537
- const requestStub = sinon.stub(device, 'request');
555
+ requestStub = sinon.stub(device, 'request');
538
556
  requestStub.withArgs(sinon.match({method: 'GET'})).resolves(response);
539
557
  requestStub.withArgs(sinon.match({method: 'DELETE'})).resolves();
540
558
 
541
559
  await device.deleteDevices();
542
- const notDeletedUrls = ['url1', 'url2'];
543
- notDeletedUrls.forEach((url) => {
544
- assert(requestStub.neverCalledWith(sinon.match({uri: url, method: 'DELETE'})));
560
+ // MIN_DEVICES_FOR_CLEANUP = 5; 2 devices is below the threshold, so nothing should be deleted
561
+ assert(requestStub.neverCalledWith(sinon.match({method: 'DELETE'})));
562
+ });
563
+
564
+ it('does not delete when device count equals MIN_DEVICES_FOR_CLEANUP (5 devices)', async () => {
565
+ setup('WEB');
566
+ const devices = Array.from({length: 5}, (_, i) => ({
567
+ url: `url${i}`,
568
+ modificationTime: `2023-10-0${i + 1}T10:00:00Z`,
569
+ deviceType: 'WEB',
570
+ }));
571
+
572
+ requestStub = sinon.stub(device, 'request');
573
+ requestStub.withArgs(sinon.match({method: 'GET'})).resolves({body: {devices}});
574
+ requestStub.withArgs(sinon.match({method: 'DELETE'})).resolves();
575
+
576
+ await device.deleteDevices();
577
+ // MIN_DEVICES_FOR_CLEANUP = 5; exactly at the threshold means no deletion
578
+ assert(requestStub.neverCalledWith(sinon.match({method: 'DELETE'})));
579
+ });
580
+
581
+ it('waits for all deletions to complete before proceeding', async () => {
582
+ setup('WEB');
583
+ const devices = Array.from({length: 6}, (_, i) => ({
584
+ url: `url${i}`,
585
+ modificationTime: `2023-10-0${i}T10:00:00Z`,
586
+ deviceType: 'WEB',
587
+ }));
588
+
589
+ requestStub = sinon.stub(device, 'request');
590
+ requestStub.withArgs(sinon.match({method: 'GET'})).resolves({body: {devices}});
591
+
592
+ const deleteOrder = [];
593
+ requestStub.withArgs(sinon.match({method: 'DELETE'})).callsFake((opts) => {
594
+ deleteOrder.push(opts.uri);
595
+ return Promise.resolve();
545
596
  });
597
+
598
+ await device.deleteDevices();
599
+
600
+ // ceil(6/3) = 2 devices should be deleted
601
+ assert.equal(deleteOrder.length, 2);
602
+ });
603
+
604
+ it('does not delete when there are zero devices', async () => {
605
+ setup('WEB');
606
+ requestStub = sinon.stub(device, 'request');
607
+ requestStub.withArgs(sinon.match({method: 'GET'})).resolves({body: {devices: []}});
608
+ requestStub.withArgs(sinon.match({method: 'DELETE'})).resolves();
609
+
610
+ await device.deleteDevices();
611
+
612
+ assert(requestStub.neverCalledWith(sinon.match({method: 'DELETE'})));
613
+ });
614
+
615
+ it('only deletes devices matching the current device type', async () => {
616
+ setup('WEB');
617
+ const devices = [
618
+ {url: 'web1', modificationTime: '2023-10-01T10:00:00Z', deviceType: 'WEB'},
619
+ {url: 'web2', modificationTime: '2023-10-02T10:00:00Z', deviceType: 'WEB'},
620
+ {url: 'web3', modificationTime: '2023-10-03T10:00:00Z', deviceType: 'WEB'},
621
+ {url: 'web4', modificationTime: '2023-10-04T10:00:00Z', deviceType: 'WEB'},
622
+ {url: 'web5', modificationTime: '2023-10-05T10:00:00Z', deviceType: 'WEB'},
623
+ {url: 'web6', modificationTime: '2023-10-06T10:00:00Z', deviceType: 'WEB'},
624
+ {url: 'desktop1', modificationTime: '2023-10-01T10:00:00Z', deviceType: 'DESKTOP'},
625
+ {url: 'mobile1', modificationTime: '2023-10-01T10:00:00Z', deviceType: 'MOBILE'},
626
+ ];
627
+
628
+ requestStub = sinon.stub(device, 'request');
629
+ requestStub.withArgs(sinon.match({method: 'GET'})).resolves({body: {devices}});
630
+ requestStub.withArgs(sinon.match({method: 'DELETE'})).resolves();
631
+
632
+ await device.deleteDevices();
633
+
634
+ // Only WEB devices considered: 6 total (> MIN_DEVICES_FOR_CLEANUP=5), ceil(6/3)=2 deleted (oldest: web1, web2)
635
+ assert(requestStub.calledWith(sinon.match({uri: 'web1', method: 'DELETE'})));
636
+ assert(requestStub.calledWith(sinon.match({uri: 'web2', method: 'DELETE'})));
637
+ assert(requestStub.neverCalledWith(sinon.match({uri: 'desktop1', method: 'DELETE'})));
638
+ assert(requestStub.neverCalledWith(sinon.match({uri: 'mobile1', method: 'DELETE'})));
639
+ });
640
+
641
+ it('rejects when fetching devices fails', async () => {
642
+ setup('WEB');
643
+ requestStub = sinon.stub(device, 'request');
644
+ requestStub.withArgs(sinon.match({method: 'GET'})).rejects(new Error('network error'));
645
+
646
+ await assert.isRejected(device.deleteDevices(), 'network error');
647
+ });
648
+
649
+ it('resolves when all deletion requests fail (best-effort)', async () => {
650
+ setup('WEB');
651
+ // Use 6 devices (> MIN_DEVICES_FOR_CLEANUP=5) to ensure deletion is attempted
652
+ const devices = [
653
+ {url: 'url1', modificationTime: '2023-10-01T10:00:00Z', deviceType: 'WEB'},
654
+ {url: 'url2', modificationTime: '2023-10-02T10:00:00Z', deviceType: 'WEB'},
655
+ {url: 'url3', modificationTime: '2023-10-03T10:00:00Z', deviceType: 'WEB'},
656
+ {url: 'url4', modificationTime: '2023-10-04T10:00:00Z', deviceType: 'WEB'},
657
+ {url: 'url5', modificationTime: '2023-10-05T10:00:00Z', deviceType: 'WEB'},
658
+ {url: 'url6', modificationTime: '2023-10-06T10:00:00Z', deviceType: 'WEB'},
659
+ ];
660
+
661
+ requestStub = sinon.stub(device, 'request');
662
+ requestStub.withArgs(sinon.match({method: 'GET'})).resolves({body: {devices}});
663
+ requestStub.withArgs(sinon.match({method: 'DELETE'})).rejects(new Error('delete failed'));
664
+
665
+ // Should resolve despite DELETE failures — best-effort cleanup must not block registration retry
666
+ await device.deleteDevices();
667
+ assert.calledWith(device.logger.warn, sinon.match(/deletions failed/));
668
+ });
669
+
670
+ it('resolves when only some deletion requests fail (partial failure)', async () => {
671
+ setup('WEB');
672
+ const devices = [
673
+ {url: 'url1', modificationTime: '2023-10-01T10:00:00Z', deviceType: 'WEB'},
674
+ {url: 'url2', modificationTime: '2023-10-02T10:00:00Z', deviceType: 'WEB'},
675
+ {url: 'url3', modificationTime: '2023-10-03T10:00:00Z', deviceType: 'WEB'},
676
+ {url: 'url4', modificationTime: '2023-10-04T10:00:00Z', deviceType: 'WEB'},
677
+ {url: 'url5', modificationTime: '2023-10-05T10:00:00Z', deviceType: 'WEB'},
678
+ {url: 'url6', modificationTime: '2023-10-06T10:00:00Z', deviceType: 'WEB'},
679
+ ];
680
+
681
+ requestStub = sinon.stub(device, 'request');
682
+ requestStub.withArgs(sinon.match({method: 'GET'})).resolves({body: {devices}});
683
+ // ceil(6/3) = 2 deletions; first succeeds, second fails
684
+ requestStub
685
+ .withArgs(sinon.match({method: 'DELETE'}))
686
+ .onFirstCall()
687
+ .resolves()
688
+ .onSecondCall()
689
+ .rejects(new Error('404 not found'));
690
+
691
+ await device.deleteDevices();
692
+ assert.calledWith(device.logger.warn, sinon.match(/deletions failed/));
693
+ });
694
+
695
+ it('calls _waitForDeviceCountBelowLimit with targetCount equal to preCount minus min(5, deletedCount)', async () => {
696
+ setup('WEB');
697
+ const devices = Array.from({length: 20}, (_, i) => ({
698
+ url: `url${i}`,
699
+ modificationTime: `2023-10-${String(i + 1).padStart(2, '0')}T10:00:00Z`,
700
+ deviceType: 'WEB',
701
+ }));
702
+
703
+ requestStub = sinon.stub(device, 'request');
704
+ requestStub.withArgs(sinon.match({method: 'GET'})).resolves({body: {devices}});
705
+ requestStub.withArgs(sinon.match({method: 'DELETE'})).resolves();
706
+
707
+ await device.deleteDevices();
708
+
709
+ // 20 WEB devices, ceil(20/3) = 7 deletions (>= 5), targetCount = 20 - min(5, 7) = 15
710
+ assert.calledWith(waitForLimitStub, 15, 0);
711
+ });
712
+
713
+ it('small-n: 6-device case — targetCount is reachable (ceil(6/3)=2 < 5, so wait for 6-2=4)', async () => {
714
+ setup('WEB');
715
+ const devices = Array.from({length: 6}, (_, i) => ({
716
+ url: `url${i}`,
717
+ modificationTime: `2023-10-0${i + 1}T10:00:00Z`,
718
+ deviceType: 'WEB',
719
+ }));
720
+
721
+ requestStub = sinon.stub(device, 'request');
722
+ requestStub.withArgs(sinon.match({method: 'GET'})).resolves({body: {devices}});
723
+ requestStub.withArgs(sinon.match({method: 'DELETE'})).resolves();
724
+
725
+ await device.deleteDevices();
726
+
727
+ // ceil(6/3) = 2 deletions (< 5), targetCount = 6 - min(5, 2) = 4
728
+ // With the old n-5 formula this was 1, which is unreachable and burned all 5 polls
729
+ assert.equal(requestStub.withArgs(sinon.match({method: 'DELETE'})).callCount, 2);
730
+ assert.calledWith(waitForLimitStub, 4, 0);
731
+ });
732
+
733
+ it('regression: 144-device case — deleteDevices passes targetCount=139 (144 - min(5, ceil(144/3)))', async () => {
734
+ setup('WEB');
735
+ const devices = Array.from({length: 144}, (_, i) => ({
736
+ url: `url${i}`,
737
+ modificationTime: new Date(Date.UTC(2020, 0, 1, 0, i)).toISOString(),
738
+ deviceType: 'WEB',
739
+ }));
740
+
741
+ requestStub = sinon.stub(device, 'request');
742
+ requestStub.withArgs(sinon.match({method: 'GET'})).resolves({body: {devices}});
743
+ requestStub.withArgs(sinon.match({method: 'DELETE'})).resolves();
744
+
745
+ await device.deleteDevices();
746
+
747
+ // ceil(144/3) = 48 deletions (>= 5), targetCount = 144 - min(5, 48) = 139
748
+ assert.equal(requestStub.withArgs(sinon.match({method: 'DELETE'})).callCount, 48);
749
+ assert.calledWith(waitForLimitStub, 139, 0);
750
+ });
751
+ });
752
+
753
+ describe('_waitForDeviceCountBelowLimit()', () => {
754
+ let clock;
755
+
756
+ const setup = (deviceType) => {
757
+ device.config.defaults = {body: {deviceType}};
758
+ };
759
+
760
+ beforeEach(() => {
761
+ clock = sinon.useFakeTimers();
762
+ });
763
+
764
+ afterEach(() => {
765
+ sinon.restore();
766
+ clock.restore();
767
+ });
768
+
769
+ it('resolves immediately when device count is below the limit on first check', async () => {
770
+ setup('WEB');
771
+ const devices = Array.from({length: 50}, (_, i) => ({
772
+ url: `url${i}`,
773
+ modificationTime: `2023-10-01T10:00:00Z`,
774
+ deviceType: 'WEB',
775
+ }));
776
+
777
+ sinon.stub(device, 'request')
778
+ .withArgs(sinon.match({method: 'GET'}))
779
+ .resolves({body: {devices}});
780
+
781
+ const promise = device._waitForDeviceCountBelowLimit(55, 0);
782
+ await clock.tickAsync(3000);
783
+ await promise;
784
+ });
785
+
786
+ it('polls multiple times until device count drops below the limit', async () => {
787
+ setup('WEB');
788
+ const makeDevices = (count) =>
789
+ Array.from({length: count}, (_, i) => ({
790
+ url: `url${i}`,
791
+ modificationTime: `2023-10-01T10:00:00Z`,
792
+ deviceType: 'WEB',
793
+ }));
794
+
795
+ const requestStub = sinon.stub(device, 'request');
796
+ requestStub.withArgs(sinon.match({method: 'GET'}))
797
+ .onFirstCall().resolves({body: {devices: makeDevices(102)}})
798
+ .onSecondCall().resolves({body: {devices: makeDevices(100)}})
799
+ .onThirdCall().resolves({body: {devices: makeDevices(68)}});
800
+
801
+ const promise = device._waitForDeviceCountBelowLimit(95, 0);
802
+
803
+ // First poll: 102 devices (above target 95), continue polling
804
+ await clock.tickAsync(3000);
805
+ // Second poll: 100 devices (still above target 95), continue polling
806
+ await clock.tickAsync(3000);
807
+ // Third poll: 68 devices (below target 95), resolve
808
+ await clock.tickAsync(3000);
809
+
810
+ await promise;
811
+
812
+ assert.equal(requestStub.withArgs(sinon.match({method: 'GET'})).callCount, 3);
813
+ });
814
+
815
+ it('gives up after max confirmation attempts and resolves anyway', async () => {
816
+ setup('WEB');
817
+ const makeDevices = (count) =>
818
+ Array.from({length: count}, (_, i) => ({
819
+ url: `url${i}`,
820
+ modificationTime: `2023-10-01T10:00:00Z`,
821
+ deviceType: 'WEB',
822
+ }));
823
+
824
+ const requestStub = sinon.stub(device, 'request');
825
+ requestStub.withArgs(sinon.match({method: 'GET'}))
826
+ .resolves({body: {devices: makeDevices(105)}});
827
+
828
+ const promise = device._waitForDeviceCountBelowLimit(100, 0);
829
+
830
+ // Tick through all 5 attempts (5 * 3000ms)
831
+ for (let i = 0; i < 5; i += 1) {
832
+ await clock.tickAsync(3000);
833
+ }
834
+
835
+ await promise;
836
+
837
+ assert(device.logger.warn.calledWith('device: max confirmation attempts reached, proceeding anyway'));
838
+ assert.equal(requestStub.withArgs(sinon.match({method: 'GET'})).callCount, 5);
839
+ });
840
+
841
+ it('resolves when count equals exactly 95 (5 below limit)', async () => {
842
+ setup('WEB');
843
+ const devices = Array.from({length: 95}, (_, i) => ({
844
+ url: `url${i}`,
845
+ modificationTime: `2023-10-01T10:00:00Z`,
846
+ deviceType: 'WEB',
847
+ }));
848
+
849
+ sinon.stub(device, 'request')
850
+ .withArgs(sinon.match({method: 'GET'}))
851
+ .resolves({body: {devices}});
852
+
853
+ const promise = device._waitForDeviceCountBelowLimit(95, 0);
854
+ await clock.tickAsync(3000);
855
+ await promise;
856
+ });
857
+
858
+ it('keeps polling when count is above the 5-below-limit threshold', async () => {
859
+ setup('WEB');
860
+ const makeDevices = (count) =>
861
+ Array.from({length: count}, (_, i) => ({
862
+ url: `url${i}`,
863
+ modificationTime: `2023-10-01T10:00:00Z`,
864
+ deviceType: 'WEB',
865
+ }));
866
+
867
+ const requestStub = sinon.stub(device, 'request');
868
+ requestStub.withArgs(sinon.match({method: 'GET'}))
869
+ .onFirstCall().resolves({body: {devices: makeDevices(100)}})
870
+ .onSecondCall().resolves({body: {devices: makeDevices(99)}})
871
+ .onThirdCall().resolves({body: {devices: makeDevices(95)}});
872
+
873
+ const promise = device._waitForDeviceCountBelowLimit(95, 0);
874
+ // First poll: 100 devices (still over the 95 threshold), continue polling
875
+ await clock.tickAsync(3000);
876
+ // Second poll: 99 devices (still over the 95 threshold), continue polling
877
+ await clock.tickAsync(3000);
878
+ // Third poll: 95 devices (at the safe threshold), resolve
879
+ await clock.tickAsync(3000);
880
+ await promise;
881
+
882
+ assert.equal(requestStub.withArgs(sinon.match({method: 'GET'})).callCount, 3);
883
+ });
884
+
885
+ it('resolves (best-effort) when the polling GET throws a transient error', async () => {
886
+ setup('WEB');
887
+
888
+ sinon.stub(device, 'request')
889
+ .withArgs(sinon.match({method: 'GET'}))
890
+ .rejects(new Error('transient network error'));
891
+
892
+ const promise = device._waitForDeviceCountBelowLimit(95, 0);
893
+ await clock.tickAsync(3000);
894
+ await promise;
895
+
896
+ assert(device.logger.warn.calledWith(
897
+ sinon.match('device: confirmation check 1 failed, proceeding anyway:')
898
+ ));
899
+ });
900
+ });
901
+
902
+ describe('_getDevicesOfCurrentType()', () => {
903
+ const setup = (deviceType) => {
904
+ device.config.defaults = {body: {deviceType}};
905
+ };
906
+
907
+ afterEach(() => {
908
+ sinon.restore();
909
+ });
910
+
911
+ it('filters devices by the current device type', async () => {
912
+ setup('WEB');
913
+ const allDevices = [
914
+ {url: 'web1', deviceType: 'WEB'},
915
+ {url: 'desktop1', deviceType: 'DESKTOP'},
916
+ {url: 'web2', deviceType: 'WEB'},
917
+ {url: 'mobile1', deviceType: 'MOBILE'},
918
+ ];
919
+
920
+ sinon.stub(device, 'request').resolves({body: {devices: allDevices}});
921
+
922
+ const result = await device._getDevicesOfCurrentType();
923
+
924
+ assert.equal(result.length, 2);
925
+ assert.equal(result[0].url, 'web1');
926
+ assert.equal(result[1].url, 'web2');
927
+ });
928
+
929
+ it('returns an empty array when no devices match', async () => {
930
+ setup('WEB');
931
+ const allDevices = [
932
+ {url: 'desktop1', deviceType: 'DESKTOP'},
933
+ {url: 'mobile1', deviceType: 'MOBILE'},
934
+ ];
935
+
936
+ sinon.stub(device, 'request').resolves({body: {devices: allDevices}});
937
+
938
+ const result = await device._getDevicesOfCurrentType();
939
+
940
+ assert.equal(result.length, 0);
546
941
  });
547
942
  });
548
943