@webex/internal-plugin-device 3.11.0 → 3.12.0-next.2

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.
@@ -20,10 +20,19 @@ describe('plugin-device', () => {
20
20
  let device;
21
21
 
22
22
  beforeEach(() => {
23
+ const fakeStorage = {};
23
24
  webex = new MockWebex({
24
25
  children: {
25
26
  device: Device,
26
27
  },
28
+ getWindow: () => ({
29
+ sessionStorage: {
30
+ setItem: (key, value) => {
31
+ fakeStorage[key] = value;
32
+ },
33
+ getItem: (key) => fakeStorage[key],
34
+ },
35
+ }),
27
36
  });
28
37
 
29
38
  const clonedDTO = cloneDeep(dto);
@@ -103,6 +112,54 @@ describe('plugin-device', () => {
103
112
  });
104
113
  });
105
114
  });
115
+
116
+ describe('when the config is changed', () => {
117
+ it("should unset the 'etag' if debug features are set", () => {
118
+ device.set('etag', 'etag-value');
119
+ device.config.debugFeatureTogglesKey = 'debug-feature-toggles';
120
+
121
+ webex.getWindow().sessionStorage.setItem(
122
+ 'debug-feature-toggles',
123
+ JSON.stringify({
124
+ test_feature: true,
125
+ })
126
+ );
127
+ assert.equal(device.etag, 'etag-value');
128
+
129
+ webex.trigger('change:config');
130
+ assert.isUndefined(device.etag);
131
+ });
132
+
133
+ it("should not unset the 'etag' if debug features are not set", () => {
134
+ device.set('etag', 'etag-value');
135
+ device.config.debugFeatureTogglesKey = 'debug-feature-toggles';
136
+
137
+ assert.equal(device.etag, 'etag-value');
138
+
139
+ webex.trigger('change:config');
140
+ assert.equal(device.etag, 'etag-value');
141
+ });
142
+
143
+ it("should only unset the 'etag' the first time the event is sent", () => {
144
+ device.set('etag', 'etag-value');
145
+ device.config.debugFeatureTogglesKey = 'debug-feature-toggles';
146
+
147
+ webex.getWindow().sessionStorage.setItem(
148
+ 'debug-feature-toggles',
149
+ JSON.stringify({
150
+ test_feature: true,
151
+ })
152
+ );
153
+ assert.equal(device.etag, 'etag-value');
154
+
155
+ webex.trigger('change:config');
156
+ assert.isUndefined(device.etag);
157
+
158
+ device.set('etag', 'etag-value');
159
+ webex.trigger('change:config');
160
+ assert.equal(device.etag, 'etag-value');
161
+ });
162
+ });
106
163
  });
107
164
 
108
165
  describe('derived properties', () => {
@@ -424,18 +481,34 @@ describe('plugin-device', () => {
424
481
 
425
482
  assert.calledOnce(device.processRegistrationSuccess);
426
483
  });
427
-
428
484
  });
429
485
 
430
486
  describe('deleteDevices()', () => {
487
+ let requestStub;
488
+ let clock;
489
+ let waitForLimitStub;
490
+
431
491
  const setup = (deviceType) => {
432
492
  device.config.defaults = {body: {deviceType}};
433
493
  };
434
- ['WEB', 'WEBCLIENT'].forEach(deviceType => {
435
- it(`should delete correct number of devices for ${deviceType}`, async () => {
436
- setup(deviceType);
437
- const response = {
438
- body: {
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
+
507
+ ['WEB', 'WEBCLIENT'].forEach((deviceType) => {
508
+ it(`should delete correct number of devices for ${deviceType}`, async () => {
509
+ setup(deviceType);
510
+ const response = {
511
+ body: {
439
512
  devices: [
440
513
  {url: 'url3', modificationTime: '2023-10-03T10:00:00Z', deviceType},
441
514
  {url: 'url4', modificationTime: '2023-10-04T10:00:00Z', deviceType: 'notweb'},
@@ -445,49 +518,428 @@ describe('plugin-device', () => {
445
518
  {url: 'url6', modificationTime: '2023-09-50T10:00:00Z', deviceType},
446
519
  {url: 'url7', modificationTime: '2023-09-30T10:00:00Z', deviceType},
447
520
  {url: 'url8', modificationTime: '2023-08-30T10:00:00Z', deviceType},
448
- ]
449
- }
521
+ ],
522
+ },
523
+ };
524
+
525
+ requestStub = sinon.stub(device, 'request');
526
+ requestStub.withArgs(sinon.match({method: 'GET'})).resolves(response);
527
+ requestStub.withArgs(sinon.match({method: 'DELETE'})).resolves();
528
+
529
+ await device.deleteDevices();
530
+
531
+ const expectedDeletions = ['url8', 'url7', 'url1'];
532
+
533
+ expectedDeletions.forEach((url) => {
534
+ assert(requestStub.calledWith(sinon.match({uri: url, method: 'DELETE'})));
535
+ });
536
+
537
+ const notDeletedUrls = ['url2', 'url3', 'url5', 'url6', 'url4'];
538
+ notDeletedUrls.forEach((url) => {
539
+ assert(requestStub.neverCalledWith(sinon.match({uri: url, method: 'DELETE'})));
540
+ });
541
+ });
542
+ });
543
+
544
+ it('does not delete when there are only 2 devices (below MIN_DEVICES_FOR_CLEANUP)', async () => {
545
+ setup('WEB');
546
+ const response = {
547
+ body: {
548
+ devices: [
549
+ {url: 'url1', modificationTime: '2023-10-01T10:00:00Z', deviceType: 'WEB'},
550
+ {url: 'url2', modificationTime: '2023-10-02T10:00:00Z', deviceType: 'WEB'},
551
+ ],
552
+ },
450
553
  };
451
- const requestStub = sinon.stub(device, 'request');
452
- requestStub.withArgs(sinon.match({method: 'GET'})).resolves(response);
453
- requestStub.withArgs(sinon.match({method: 'DELETE'})).resolves();
454
554
 
455
- await device.deleteDevices();
555
+ requestStub = sinon.stub(device, 'request');
556
+ requestStub.withArgs(sinon.match({method: 'GET'})).resolves(response);
557
+ requestStub.withArgs(sinon.match({method: 'DELETE'})).resolves();
558
+
559
+ await device.deleteDevices();
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
+ });
456
580
 
457
- const expectedDeletions = ['url8', 'url7', 'url1'];
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();
596
+ });
597
+
598
+ await device.deleteDevices();
458
599
 
459
- expectedDeletions.forEach(url => {
460
- assert(requestStub.calledWith(sinon.match({uri: url, method: 'DELETE'})));
600
+ // ceil(6/3) = 2 devices should be deleted
601
+ assert.equal(deleteOrder.length, 2);
461
602
  });
462
603
 
463
- const notDeletedUrls = ['url2', 'url3', 'url5', 'url6', 'url4'];
464
- notDeletedUrls.forEach(url => {
465
- assert(requestStub.neverCalledWith(sinon.match({uri: url, method: 'DELETE'})));
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);
466
813
  });
467
- });});
468
814
 
469
- it('does not delete when there are just 2 devices', async () => {
470
- setup('WEB');
471
- const response = {
472
- body: {
473
- devices: [
474
- {url: 'url1', modificationTime: '2023-10-01T10:00:00Z', deviceType: 'WEB'},
475
- {url: 'url2', modificationTime: '2023-10-02T10:00:00Z', deviceType: 'WEB'},
476
- ]
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);
477
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}};
478
905
  };
479
906
 
480
- const requestStub = sinon.stub(device, 'request');
481
- requestStub.withArgs(sinon.match({method: 'GET'})).resolves(response);
482
- requestStub.withArgs(sinon.match({method: 'DELETE'})).resolves();
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
+ });
483
928
 
484
- await device.deleteDevices();
485
- const notDeletedUrls = ['url1', 'url2'];
486
- notDeletedUrls.forEach(url => {
487
- assert(requestStub.neverCalledWith(sinon.match({uri: url, method: 'DELETE'})));
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);
488
941
  });
489
942
  });
490
- });
491
943
 
492
944
  describe('#unregister()', () => {
493
945
  it('resolves immediately if the device is not registered', async () => {
@@ -563,11 +1015,17 @@ describe('plugin-device', () => {
563
1015
  it('calls delete devices when errors with User has excessive device registrations', async () => {
564
1016
  setup();
565
1017
  sinon.stub(device, 'canRegister').callsFake(() => Promise.resolve());
566
- const deleteDeviceSpy = sinon.stub(device, 'deleteDevices').callsFake(() => Promise.resolve());
1018
+ const deleteDeviceSpy = sinon
1019
+ .stub(device, 'deleteDevices')
1020
+ .callsFake(() => Promise.resolve());
567
1021
  const registerStub = sinon.stub(device, '_registerInternal');
568
-
569
- registerStub.onFirstCall().rejects({body: {message: 'User has excessive device registrations'}});
570
- registerStub.onSecondCall().callsFake(() => Promise.resolve({exampleKey: 'example response value',}));
1022
+
1023
+ registerStub
1024
+ .onFirstCall()
1025
+ .rejects({body: {message: 'User has excessive device registrations'}});
1026
+ registerStub
1027
+ .onSecondCall()
1028
+ .callsFake(() => Promise.resolve({exampleKey: 'example response value'}));
571
1029
 
572
1030
  const result = await device.register();
573
1031
 
@@ -582,8 +1040,12 @@ describe('plugin-device', () => {
582
1040
  setup();
583
1041
 
584
1042
  sinon.stub(device, 'canRegister').callsFake(() => Promise.resolve());
585
- const deleteDeviceSpy = sinon.stub(device, 'deleteDevices').callsFake(() => Promise.resolve());
586
- const registerStub = sinon.stub(device, '_registerInternal').rejects(new Error('some error'));
1043
+ const deleteDeviceSpy = sinon
1044
+ .stub(device, 'deleteDevices')
1045
+ .callsFake(() => Promise.resolve());
1046
+ const registerStub = sinon
1047
+ .stub(device, '_registerInternal')
1048
+ .rejects(new Error('some error'));
587
1049
 
588
1050
  try {
589
1051
  await device.register({deleteFlag: true});
@@ -633,7 +1095,7 @@ describe('plugin-device', () => {
633
1095
  resolve({
634
1096
  body: {
635
1097
  exampleKey: 'example response value',
636
- }
1098
+ },
637
1099
  });
638
1100
 
639
1101
  await resultPromise;
@@ -670,7 +1132,6 @@ describe('plugin-device', () => {
670
1132
  assert.calledOnce(device.processRegistrationSuccess);
671
1133
  });
672
1134
 
673
-
674
1135
  it('checks that submitInternalEvent gets called with internal.register.device.response on success', async () => {
675
1136
  setup();
676
1137
  sinon.stub(device, 'canRegister').callsFake(() => Promise.resolve());
@@ -822,7 +1283,7 @@ describe('plugin-device', () => {
822
1283
 
823
1284
  it('works when request returns 404 when already registered', async () => {
824
1285
  setup();
825
-
1286
+
826
1287
  sinon.stub(device, 'canRegister').callsFake(() => Promise.resolve());
827
1288
 
828
1289
  const requestStub = sinon.stub(device, 'request');
@@ -838,7 +1299,52 @@ describe('plugin-device', () => {
838
1299
  });
839
1300
  });
840
1301
 
1302
+ describe('getDebugFeatures()', () => {
1303
+ it('returns empty list if debugFeatureTogglesKey is not set', () => {
1304
+ assert.isUndefined(device.config.debugFeatureTogglesKey);
1305
+ const debugFeatures = device.getDebugFeatures();
1306
+
1307
+ assert.deepEqual(debugFeatures, []);
1308
+ });
1309
+
1310
+ it('returns empty list if no debug features in session storage', () => {
1311
+ device.config.debugFeatureTogglesKey = 'debug-feature-toggles';
1312
+ assert.isUndefined(webex.getWindow().sessionStorage.getItem('debug-feature-toggles'));
1313
+ const debugFeatures = device.getDebugFeatures();
1314
+
1315
+ assert.deepEqual(debugFeatures, []);
1316
+ });
1317
+
1318
+ it('returns debug features from session storage', () => {
1319
+ device.config.debugFeatureTogglesKey = 'debug-feature-toggles';
1320
+ webex.getWindow().sessionStorage.setItem(
1321
+ 'debug-feature-toggles',
1322
+ JSON.stringify({
1323
+ feature_to_debug_enable: true,
1324
+ feature_to_debug_disable: false,
1325
+ })
1326
+ );
1327
+ const debugFeatures = device.getDebugFeatures();
1328
+
1329
+ assert.equal(debugFeatures.length, 2);
1330
+
1331
+ assert.properties(debugFeatures[0], ['key', 'val', 'mutable', 'lastModified']);
1332
+ assert.equal(debugFeatures[0].key, 'feature_to_debug_enable');
1333
+ assert.equal(debugFeatures[0].val, 'true');
1334
+ assert.isTrue(debugFeatures[0].mutable);
1335
+ assert.isISODate(debugFeatures[0].lastModified);
1336
+
1337
+ assert.properties(debugFeatures[1], ['key', 'val', 'mutable', 'lastModified']);
1338
+ assert.equal(debugFeatures[1].key, 'feature_to_debug_disable');
1339
+ assert.equal(debugFeatures[1].val, 'false');
1340
+ assert.isTrue(debugFeatures[1].mutable);
1341
+ assert.isISODate(debugFeatures[1].lastModified);
1342
+ });
1343
+ });
1344
+
841
1345
  describe('#processRegistrationSuccess()', () => {
1346
+ const initialDTOFeatureCounts = {developer: 2, entitlement: 1, user: 1};
1347
+
842
1348
  const getClonedDTO = (overrides) => {
843
1349
  const clonedDTO = cloneDeep(dto);
844
1350
 
@@ -852,6 +1358,22 @@ describe('plugin-device', () => {
852
1358
  mutable: true,
853
1359
  lastModified: '2015-06-29T20:02:48.033Z',
854
1360
  },
1361
+ {
1362
+ key: 'feature_to_debug_enable',
1363
+ type: 'boolean',
1364
+ val: 'false',
1365
+ value: false,
1366
+ mutable: true,
1367
+ lastModified: '2015-06-29T20:02:48.033Z',
1368
+ },
1369
+ {
1370
+ key: 'feature_to_debug_disable',
1371
+ type: 'boolean',
1372
+ val: 'true',
1373
+ value: true,
1374
+ mutable: true,
1375
+ lastModified: '2015-06-29T20:02:48.033Z',
1376
+ },
855
1377
  ],
856
1378
  entitlement: [
857
1379
  {
@@ -875,16 +1397,21 @@ describe('plugin-device', () => {
875
1397
  return clonedDTO;
876
1398
  };
877
1399
 
1400
+ const checkFeatureTypeCounts = (expectedCounts) => {
1401
+ Object.entries(expectedCounts).forEach(([type, expectedCount]) => {
1402
+ assert.equal(device.features[type].length, expectedCount);
1403
+ });
1404
+ };
1405
+
878
1406
  const checkFeatureNotPresent = (type, key) => {
879
1407
  assert.isUndefined(device.features[type].get(key));
880
1408
  };
881
1409
 
882
1410
  const checkFeature = (type, key, expectedValue) => {
883
- assert.equal(device.features[type].length, 1);
884
1411
  assert.deepEqual(device.features[type].get(key).get('value'), expectedValue);
885
1412
  };
886
1413
 
887
- it('features are set correctly if etag not in headers', () => {
1414
+ it('features are set correctly if etag not in headers, no debug features', () => {
888
1415
  const clonedDTO = getClonedDTO();
889
1416
 
890
1417
  const response = {
@@ -894,13 +1421,61 @@ describe('plugin-device', () => {
894
1421
  headers: {},
895
1422
  };
896
1423
 
1424
+ checkFeatureTypeCounts(initialDTOFeatureCounts);
897
1425
  checkFeatureNotPresent('developer', '1');
1426
+ checkFeatureNotPresent('developer', 'feature_to_debug_enable');
1427
+ checkFeatureNotPresent('developer', 'feature_to_debug_disable');
1428
+ checkFeatureNotPresent('developer', 'feature_debug_only');
898
1429
  checkFeatureNotPresent('entitlement', '2');
899
1430
  checkFeatureNotPresent('user', '3');
900
1431
 
901
1432
  device.processRegistrationSuccess(response);
902
1433
 
1434
+ checkFeatureTypeCounts({developer: 3, entitlement: 1, user: 1});
903
1435
  checkFeature('developer', '1', true);
1436
+ checkFeature('developer', 'feature_to_debug_enable', false);
1437
+ checkFeature('developer', 'feature_to_debug_disable', true);
1438
+ checkFeatureNotPresent('developer', 'feature_debug_only');
1439
+ checkFeature('entitlement', '2', true);
1440
+ checkFeature('user', '3', true);
1441
+ });
1442
+
1443
+ it('features are set correctly if etag not in headers, debug features in session storage', () => {
1444
+ const clonedDTO = getClonedDTO();
1445
+
1446
+ const response = {
1447
+ body: {
1448
+ ...clonedDTO,
1449
+ },
1450
+ headers: {},
1451
+ };
1452
+
1453
+ device.config.debugFeatureTogglesKey = 'debug-feature-toggles';
1454
+
1455
+ webex.getWindow().sessionStorage.setItem(
1456
+ 'debug-feature-toggles',
1457
+ JSON.stringify({
1458
+ feature_to_debug_enable: true,
1459
+ feature_to_debug_disable: false,
1460
+ feature_debug_only: true,
1461
+ })
1462
+ );
1463
+
1464
+ checkFeatureTypeCounts(initialDTOFeatureCounts);
1465
+ checkFeatureNotPresent('developer', '1');
1466
+ checkFeatureNotPresent('developer', 'feature_to_debug_enable');
1467
+ checkFeatureNotPresent('developer', 'feature_to_debug_disable');
1468
+ checkFeatureNotPresent('developer', 'feature_debug_only');
1469
+ checkFeatureNotPresent('entitlement', '2');
1470
+ checkFeatureNotPresent('user', '3');
1471
+
1472
+ device.processRegistrationSuccess(response);
1473
+
1474
+ checkFeatureTypeCounts({developer: 4, entitlement: 1, user: 1});
1475
+ checkFeature('developer', '1', true);
1476
+ checkFeature('developer', 'feature_to_debug_enable', true);
1477
+ checkFeature('developer', 'feature_to_debug_disable', false);
1478
+ checkFeature('developer', 'feature_debug_only', true);
904
1479
  checkFeature('entitlement', '2', true);
905
1480
  checkFeature('user', '3', true);
906
1481
  });
@@ -919,12 +1494,17 @@ describe('plugin-device', () => {
919
1494
  },
920
1495
  };
921
1496
 
1497
+ checkFeatureTypeCounts(initialDTOFeatureCounts);
922
1498
  checkFeatureNotPresent('developer', '1');
1499
+ checkFeatureNotPresent('developer', 'feature_to_debug_enable');
1500
+ checkFeatureNotPresent('developer', 'feature_to_debug_disable');
1501
+ checkFeatureNotPresent('developer', 'feature_debug_only');
923
1502
  checkFeatureNotPresent('entitlement', '2');
924
1503
  checkFeatureNotPresent('user', '3');
925
1504
 
926
1505
  device.processRegistrationSuccess(response);
927
1506
 
1507
+ checkFeatureTypeCounts(initialDTOFeatureCounts.developer);
928
1508
  checkFeatureNotPresent('developer', '1');
929
1509
  checkFeature('entitlement', '2', true);
930
1510
  checkFeature('user', '3', true);
@@ -947,12 +1527,21 @@ describe('plugin-device', () => {
947
1527
  },
948
1528
  };
949
1529
 
1530
+ checkFeatureTypeCounts(initialDTOFeatureCounts);
950
1531
  checkFeatureNotPresent('developer', '1');
1532
+ checkFeatureNotPresent('developer', 'feature_to_debug_enable');
1533
+ checkFeatureNotPresent('developer', 'feature_to_debug_disable');
1534
+ checkFeatureNotPresent('developer', 'feature_debug_only');
951
1535
  checkFeatureNotPresent('entitlement', '2');
952
1536
  checkFeatureNotPresent('user', '3');
953
1537
 
954
1538
  device.processRegistrationSuccess(response);
955
1539
 
1540
+ checkFeatureTypeCounts({
1541
+ developer: initialDTOFeatureCounts.developer,
1542
+ entitlement: 1,
1543
+ user: 1,
1544
+ });
956
1545
  checkFeatureNotPresent('developer', '1');
957
1546
  checkFeature('entitlement', '2', true);
958
1547
  checkFeature('user', '3', true);
@@ -975,12 +1564,17 @@ describe('plugin-device', () => {
975
1564
  },
976
1565
  };
977
1566
 
1567
+ checkFeatureTypeCounts(initialDTOFeatureCounts);
978
1568
  checkFeatureNotPresent('developer', '1');
1569
+ checkFeatureNotPresent('developer', 'feature_to_debug_enable');
1570
+ checkFeatureNotPresent('developer', 'feature_to_debug_disable');
1571
+ checkFeatureNotPresent('developer', 'feature_debug_only');
979
1572
  checkFeatureNotPresent('entitlement', '2');
980
1573
  checkFeatureNotPresent('user', '3');
981
1574
 
982
1575
  device.processRegistrationSuccess(response);
983
1576
 
1577
+ checkFeatureTypeCounts({developer: 3, entitlement: 1, user: 1});
984
1578
  checkFeature('developer', '1', true);
985
1579
  checkFeature('entitlement', '2', true);
986
1580
  checkFeature('user', '3', true);
@@ -1029,6 +1623,7 @@ describe('plugin-device', () => {
1029
1623
  device.processRegistrationSuccess(newResponse);
1030
1624
 
1031
1625
  // only the entitlement and user features should have been changed to false
1626
+ checkFeatureTypeCounts({developer: 3, entitlement: 1, user: 1});
1032
1627
  checkFeature('developer', '1', true);
1033
1628
  checkFeature('entitlement', '2', false);
1034
1629
  checkFeature('user', '3', false);