@webex/plugin-meetings 2.11.1 → 2.13.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.
@@ -72,6 +72,7 @@ import {
72
72
  STATS,
73
73
  VIDEO_RESOLUTIONS,
74
74
  VIDEO,
75
+ BNR_STATUS,
75
76
  HTTP_VERBS
76
77
  } from '../constants';
77
78
  import BEHAVIORAL_METRICS from '../metrics/constants';
@@ -914,6 +915,14 @@ export default class Meeting extends StatelessWebexPlugin {
914
915
  */
915
916
  this.meetingInfoFailureReason = undefined;
916
917
 
918
+ /**
919
+ * Indicates the current status of BNR in the meeting
920
+ * @type {BNR_STATUS}
921
+ * @private
922
+ * @memberof Meeting
923
+ */
924
+ this.bnrStatus = BNR_STATUS.NOT_ENABLED;
925
+
917
926
  this.setUpLocusInfoListeners();
918
927
  this.locusInfo.init(attrs.locus ? attrs.locus : {});
919
928
  this.hasJoinedOnce = false;
@@ -4482,18 +4491,23 @@ export default class Meeting extends StatelessWebexPlugin {
4482
4491
  * @public
4483
4492
  * @memberof Meeting
4484
4493
  */
4485
- updateAudio(options) {
4494
+ async updateAudio(options) {
4486
4495
  if (!this.canUpdateMedia()) {
4487
4496
  return this.enqueueMediaUpdate(MEDIA_UPDATE_TYPE.AUDIO, options);
4488
4497
  }
4489
4498
  const {sendAudio, receiveAudio, stream} = options;
4490
4499
  const {audioTransceiver} = this.mediaProperties.peerConnection;
4491
- const track = MeetingUtil.getTrack(stream).audioTrack;
4500
+ let track = MeetingUtil.getTrack(stream).audioTrack;
4492
4501
 
4493
4502
  if (typeof sendAudio !== 'boolean' || typeof receiveAudio !== 'boolean') {
4494
4503
  return Promise.reject(new ParameterError('Pass sendAudio and receiveAudio parameter'));
4495
4504
  }
4496
4505
 
4506
+ if (sendAudio && !this.isAudioMuted() && (this.bnrStatus === BNR_STATUS.ENABLED || this.bnrStatus === BNR_STATUS.SHOULD_ENABLE)) {
4507
+ track = await this.internal_enableBNR(track);
4508
+ this.bnrStatus = BNR_STATUS.ENABLED;
4509
+ }
4510
+
4497
4511
  return MeetingUtil.validateOptions({sendAudio, localStream: stream})
4498
4512
  .then(() => {
4499
4513
  let previousMediaDirection = {};
@@ -5750,6 +5764,38 @@ export default class Meeting extends StatelessWebexPlugin {
5750
5764
  }
5751
5765
  };
5752
5766
 
5767
+ /**
5768
+ * Internal API to return status of BNR
5769
+ * @returns {Boolean}
5770
+ * @public
5771
+ * @memberof Meeting
5772
+ */
5773
+ isBnrEnabled() {
5774
+ return this.bnrStatus === BNR_STATUS.ENABLED;
5775
+ }
5776
+
5777
+ /**
5778
+ * Internal API to obtain BNR enabled MediaStream
5779
+ * @returns {Promise<MediaStreamTrack>}
5780
+ * @private
5781
+ * @param {MedaiStreamTrack} audioTrack from updateAudio
5782
+ * @memberof Meeting
5783
+ */
5784
+ async internal_enableBNR(audioTrack) {
5785
+ try {
5786
+ LoggerProxy.logger.info('Meeting:index#internal_enableBNR. Internal enable BNR called');
5787
+ const bnrAudioTrack = await WebRTCMedia.Effects.BNR.enableBNR(audioTrack);
5788
+
5789
+ LoggerProxy.logger.info('Meeting:index#internal_enableBNR. BNR enabled track obtained from WebRTC & returned as stream');
5790
+
5791
+ return bnrAudioTrack;
5792
+ }
5793
+ catch (error) {
5794
+ LoggerProxy.logger.error('Meeting:index#internal_enableBNR.', error);
5795
+ throw error;
5796
+ }
5797
+ }
5798
+
5753
5799
  /**
5754
5800
  * enableBNR API
5755
5801
  * @returns {Promise<Boolean>}
@@ -5764,24 +5810,29 @@ export default class Meeting extends StatelessWebexPlugin {
5764
5810
  if (typeof this.mediaProperties === 'undefined' || typeof this.mediaProperties.audioTrack === 'undefined') {
5765
5811
  throw new Error("Meeting doesn't have an audioTrack attached");
5766
5812
  }
5767
- this.mediaProperties.audioTrack = await WebRTCMedia.Effects.BNR.enableBNR(this.mediaProperties.audioTrack);
5813
+ else if (this.isAudioMuted()) {
5814
+ throw new Error('Cannot enable BNR while meeting is muted');
5815
+ }
5816
+
5817
+
5818
+ this.bnrStatus = BNR_STATUS.SHOULD_ENABLE;
5819
+
5768
5820
  const audioStream = MediaUtil.createMediaStream([this.mediaProperties.audioTrack]);
5769
5821
 
5770
- LoggerProxy.logger.info('Meeting:index#enableBNR. BNR enabled track obtained from WebRTC & sent to updateAudio');
5822
+ LoggerProxy.logger.info('Meeting:index#enableBNR. MediaStream created from meeting & sent to updateAudio');
5771
5823
  await this.updateAudio({
5772
5824
  sendAudio: true,
5773
- receiveAudio: true,
5825
+ receiveAudio: this.mediaProperties.mediaDirection.receiveAudio,
5774
5826
  stream: audioStream
5775
5827
  });
5776
- this.isBnrEnabled = true;
5828
+ this.bnrStatus = BNR_STATUS.ENABLED;
5777
5829
  isSuccess = true;
5778
5830
  Metrics.sendBehavioralMetric(
5779
5831
  BEHAVIORAL_METRICS.ENABLE_BNR_SUCCESS,
5780
5832
  );
5781
5833
  }
5782
5834
  catch (error) {
5783
- LoggerProxy.logger.error(`Meeting:index#enableBNR. ${error}`);
5784
-
5835
+ this.bnrStatus = BNR_STATUS.NOT_ENABLED;
5785
5836
  Metrics.sendBehavioralMetric(
5786
5837
  BEHAVIORAL_METRICS.ENABLE_BNR_FAILURE,
5787
5838
  {
@@ -5789,7 +5840,7 @@ export default class Meeting extends StatelessWebexPlugin {
5789
5840
  stack: error.stack
5790
5841
  }
5791
5842
  );
5792
-
5843
+ LoggerProxy.logger.error('Meeting:index#enableBNR.', error);
5793
5844
  throw error;
5794
5845
  }
5795
5846
 
@@ -5807,19 +5858,27 @@ export default class Meeting extends StatelessWebexPlugin {
5807
5858
  let isSuccess = false;
5808
5859
 
5809
5860
  try {
5810
- if (typeof this.mediaProperties === 'undefined' || typeof this.mediaProperties.audioTrack === 'undefined') {
5861
+ if (!this.isBnrEnabled()) {
5862
+ throw new Error('Can not disable as BNR is not enabled');
5863
+ }
5864
+ else if (typeof this.mediaProperties === 'undefined' || typeof this.mediaProperties.audioTrack === 'undefined') {
5811
5865
  throw new Error("Meeting doesn't have an audioTrack attached");
5812
5866
  }
5813
- this.mediaProperties.audioTrack = WebRTCMedia.Effects.BNR.disableBNR(this.mediaProperties.audioTrack);
5814
- const audioStream = MediaUtil.createMediaStream([this.mediaProperties.audioTrack]);
5867
+ const audioTrack = WebRTCMedia.Effects.BNR.disableBNR(this.mediaProperties.audioTrack);
5868
+ const audioStream = MediaUtil.createMediaStream([audioTrack]);
5815
5869
 
5816
5870
  LoggerProxy.logger.info('Meeting:index#disableBNR. Raw media track obtained from WebRTC & sent to updateAudio');
5871
+
5872
+ this.bnrStatus = BNR_STATUS.SHOULD_DISABLE;
5873
+
5817
5874
  await this.updateAudio({
5818
5875
  sendAudio: true,
5819
- receiveAudio: true,
5876
+ receiveAudio: this.mediaProperties.mediaDirection.receiveAudio,
5820
5877
  stream: audioStream
5821
5878
  });
5822
- this.isBnrEnabled = false;
5879
+
5880
+ this.bnrStatus = BNR_STATUS.NOT_ENABLED;
5881
+
5823
5882
  isSuccess = true;
5824
5883
 
5825
5884
  Metrics.sendBehavioralMetric(
@@ -5827,6 +5886,7 @@ export default class Meeting extends StatelessWebexPlugin {
5827
5886
  );
5828
5887
  }
5829
5888
  catch (error) {
5889
+ this.bnrStatus = BNR_STATUS.ENABLED;
5830
5890
  LoggerProxy.logger.error(`Meeting:index#disableBNR. ${error}`);
5831
5891
 
5832
5892
  Metrics.sendBehavioralMetric(
@@ -205,10 +205,12 @@ export default class MeetingInfoV2 {
205
205
  if (directURI) options.directURI = directURI;
206
206
 
207
207
  return this.webex.request(options)
208
- .then(() => {
208
+ .then((response) => {
209
209
  Metrics.sendBehavioralMetric(
210
210
  BEHAVIORAL_METRICS.FETCH_MEETING_INFO_V1_SUCCESS
211
211
  );
212
+
213
+ return response;
212
214
  })
213
215
  .catch((err) => {
214
216
  if (err?.statusCode === 403) {
@@ -759,6 +759,26 @@ export default class Members extends StatelessWebexPlugin {
759
759
  return this.membersRequest.raiseOrLowerHandMember(options);
760
760
  }
761
761
 
762
+ /**
763
+ * Lower all hands of members in a meeting
764
+ * @param {String} requestingMemberId - id of the participant which requested the lower all hands
765
+ * @returns {Promise}
766
+ * @public
767
+ * @memberof Members
768
+ */
769
+ lowerAllHands(requestingMemberId) {
770
+ if (!this.locusUrl) {
771
+ return Promise.reject(new ParameterError('The associated locus url for this meetings members object must be defined.'));
772
+ }
773
+ if (!requestingMemberId) {
774
+ return Promise.reject(new ParameterError('The requestingMemberId must be defined to lower all hands in a meeting.'));
775
+ }
776
+ const options = MembersUtil.generateLowerAllHandsMemberOptions(requestingMemberId, this.locusUrl);
777
+
778
+ return this.membersRequest.lowerAllHandsMember(options);
779
+ }
780
+
781
+
762
782
  /**
763
783
  * Transfers the host to another member
764
784
  * @param {String} memberId
@@ -74,6 +74,16 @@ export default class MembersRequest extends StatelessWebexPlugin {
74
74
  return this.request(requestParams);
75
75
  }
76
76
 
77
+ lowerAllHandsMember(options) {
78
+ if (!options || !options.locusUrl || !options.requestingParticipantId) {
79
+ throw new ParameterError('requestingParticipantId must be defined, and the associated locus url for this meeting object must be defined.');
80
+ }
81
+
82
+ const requestParams = MembersUtil.getLowerAllHandsMemberRequestParams(options);
83
+
84
+ return this.request(requestParams);
85
+ }
86
+
77
87
  transferHostToMember(options) {
78
88
  if (!options || !options.locusUrl || !options.memberId || !options.moderator) {
79
89
  throw new ParameterError('memberId must be defined, the associated locus url, and the moderator for this meeting object must be defined.');
@@ -136,6 +136,11 @@ MembersUtil.generateRaiseHandMemberOptions = (memberId, status, locusUrl) => ({
136
136
  locusUrl
137
137
  });
138
138
 
139
+ MembersUtil.generateLowerAllHandsMemberOptions = (requestingParticipantId, locusUrl) => ({
140
+ requestingParticipantId,
141
+ locusUrl
142
+ });
143
+
139
144
  MembersUtil.getMuteMemberRequestParams = (options) => {
140
145
  const body = {
141
146
  audio: {
@@ -166,6 +171,22 @@ MembersUtil.getRaiseHandMemberRequestParams = (options) => {
166
171
  };
167
172
  };
168
173
 
174
+ MembersUtil.getLowerAllHandsMemberRequestParams = (options) => {
175
+ const body = {
176
+ hand: {
177
+ raised: false
178
+ },
179
+ requestingParticipantId: options.requestingParticipantId
180
+ };
181
+ const uri = `${options.locusUrl}/${CONTROLS}`;
182
+
183
+ return {
184
+ method: HTTP_VERBS.PATCH,
185
+ uri,
186
+ body
187
+ };
188
+ };
189
+
169
190
  MembersUtil.getTransferHostToMemberRequestParams = (options) => {
170
191
  const body = {
171
192
  role: {
@@ -456,13 +456,26 @@ describe('plugin-meetings', () => {
456
456
  });
457
457
  });
458
458
  describe('BNR', () => {
459
+ const fakeMediaTrack = () => ({
460
+ id: Date.now().toString(),
461
+ stop: () => {},
462
+ readyState: 'live',
463
+ enabled: true,
464
+ getSettings: () => ({
465
+ sampleRate: 48000
466
+ })
467
+ });
468
+
459
469
  class FakeMediaStream {
460
- constructor() {
470
+ constructor(tracks) {
461
471
  this.active = false;
462
472
  this.id = '5146425f-c240-48cc-b86b-27d422988fb7';
473
+ this.tracks = tracks;
463
474
  }
464
475
 
465
476
  addTrack = () => undefined;
477
+
478
+ getAudioTracks = () => this.tracks;
466
479
  }
467
480
 
468
481
  class FakeAudioContext {
@@ -523,6 +536,12 @@ describe('plugin-meetings', () => {
523
536
  this.readyState = 'live';
524
537
  this.contentHint = '';
525
538
  }
539
+
540
+ getSettings() {
541
+ return {
542
+ sampleRate: 48000
543
+ };
544
+ }
526
545
  }
527
546
  Object.defineProperty(global, 'MediaStream', {
528
547
  writable: true,
@@ -544,24 +563,21 @@ describe('plugin-meetings', () => {
544
563
  value: FakeMediaStreamTrack,
545
564
  });
546
565
 
547
- beforeEach(async () => {
566
+ beforeEach(() => {
548
567
  meeting.canUpdateMedia = sinon.stub().returns(true);
549
568
  MeetingUtil.validateOptions = sinon.stub().returns(Promise.resolve());
550
569
  MeetingUtil.updateTransceiver = sinon.stub();
551
- const fakeMediaTrack = () => ({
552
- stop: () => {},
553
- readyState: 'live',
554
- getSettings: () => ({
555
- sampleRate: 48000
556
- })
557
- });
558
570
 
559
571
  meeting.getMediaStreams = sinon.stub().returns(Promise.resolve());
560
572
  sinon.replace(meeting, 'addMedia', () => {
561
573
  sinon.stub(meeting.mediaProperties, 'audioTrack').value(fakeMediaTrack());
574
+ sinon.stub(meeting.mediaProperties, 'mediaDirection').value({
575
+ receiveAudio: true
576
+ });
562
577
  });
563
- await meeting.getMediaStreams();
564
- await meeting.addMedia();
578
+
579
+ // eslint-disable-next-line no-undef
580
+ MediaUtil.createMediaStream = sinon.stub().returns(new MediaStream([fakeMediaTrack()]));
565
581
  });
566
582
 
567
583
  describe('#enableBNR', () => {
@@ -578,22 +594,37 @@ describe('plugin-meetings', () => {
578
594
  });
579
595
 
580
596
  describe('after audio attached to meeting', () => {
597
+ beforeEach(async () => {
598
+ await meeting.getMediaStreams();
599
+ await meeting.addMedia();
600
+ });
601
+
581
602
  it('should return true for appropriate sample rate', async () => {
582
603
  const response = await meeting.enableBNR();
583
604
 
605
+ assert(Metrics.sendBehavioralMetric.calledOnce);
606
+ assert.calledWith(
607
+ Metrics.sendBehavioralMetric,
608
+ BEHAVIORAL_METRICS.ENABLE_BNR_SUCCESS,
609
+ );
610
+
584
611
  assert.equal(response, true);
585
612
  });
586
613
 
587
- it('should throw error for inappropriate sample rate and send error metrics', async () => {
588
- const fakeMediaTrack = () => ({
589
- stop: () => {},
590
- readyState: 'live',
591
- getSettings: () => ({
592
- sampleRate: 49000
593
- })
594
- });
614
+ it('should throw error if meeting audio is muted', async () => {
615
+ await meeting.getMediaStreams();
616
+ await meeting.addMedia();
617
+ const handleClientRequest = (meeting, mute) => {
618
+ meeting.mediaProperties.audioTrack.enabled = !mute;
595
619
 
596
- sinon.stub(meeting.mediaProperties, 'audioTrack').value(fakeMediaTrack());
620
+ return Promise.resolve();
621
+ };
622
+ const isMuted = () => !meeting.mediaProperties.audioTrack.enabled;
623
+
624
+ meeting.mediaId = 'mediaId';
625
+ meeting.audio = {handleClientRequest, isMuted};
626
+ meeting.locusInfo.parsedLocus = {self: {state: 'JOINED'}};
627
+ await meeting.muteAudio();
597
628
  await meeting.enableBNR().catch((err) => {
598
629
  assert(Metrics.sendBehavioralMetric.calledOnce);
599
630
  assert.calledWith(
@@ -603,29 +634,56 @@ describe('plugin-meetings', () => {
603
634
  stack: err.stack
604
635
  }
605
636
  );
606
- assert.equal(err.message, 'Sample rate of 49000 is not supported.');
637
+ assert.equal(err.message, 'Cannot enable BNR while meeting is muted');
607
638
  });
608
639
  });
609
640
 
610
- it('should send metrics for enable bnr success', async () => {
611
- const response = await meeting.enableBNR();
641
+ it('should throw error for inappropriate sample rate and send error metrics', async () => {
642
+ const fakeMediaTrack = () => ({
643
+ id: Date.now().toString(),
644
+ stop: () => {},
645
+ readyState: 'live',
646
+ getSettings: () => ({
647
+ sampleRate: 49000
648
+ })
649
+ });
612
650
 
613
- assert(Metrics.sendBehavioralMetric.calledOnce);
614
- assert.calledWith(
615
- Metrics.sendBehavioralMetric,
616
- BEHAVIORAL_METRICS.ENABLE_BNR_SUCCESS,
617
- );
618
- assert.equal(response, true);
651
+ sinon.stub(meeting.mediaProperties, 'audioTrack').value(fakeMediaTrack());
652
+
653
+ // eslint-disable-next-line no-undef
654
+ MediaUtil.createMediaStream = sinon.stub().returns(new MediaStream([fakeMediaTrack()]));
655
+
656
+ await meeting.enableBNR()
657
+ .then(() => {
658
+ assert.fail('The expected Error was not thrown.');
659
+ })
660
+ .catch((err) => {
661
+ assert(Metrics.sendBehavioralMetric.calledOnce);
662
+ assert.calledWith(
663
+ Metrics.sendBehavioralMetric,
664
+ BEHAVIORAL_METRICS.ENABLE_BNR_FAILURE, {
665
+ reason: err.message,
666
+ stack: err.stack
667
+ }
668
+ );
669
+ assert.equal(err.message, 'Sample rate of 49000 is not supported.');
670
+ });
619
671
  });
620
672
  });
621
673
  });
622
674
 
623
675
  describe('#disableBNR', () => {
676
+ beforeEach(async () => {
677
+ await meeting.getMediaStreams();
678
+ await meeting.addMedia();
679
+ });
680
+
624
681
  it('should have #disableBnr', () => {
625
682
  assert.exists(meeting.disableBNR);
626
683
  });
627
684
 
628
685
  it('should return true if bnr is disabled on bnr enabled track', async () => {
686
+ await meeting.enableBNR();
629
687
  const response = await meeting.disableBNR();
630
688
 
631
689
  assert.equal(response, true);
@@ -88,10 +88,14 @@ describe('plugin-meetings', () => {
88
88
 
89
89
  describe('#fetchMeetingInfo', () => {
90
90
  it('should fetch meeting info for the destination type', async () => {
91
+ const body = {meetingKey: '1234323'};
92
+ const requestResponse = {statusCode: 200, body};
93
+
91
94
  sinon.stub(MeetingInfoUtil, 'getDestinationType').returns(Promise.resolve({type: 'MEETING_ID', destination: '123456'}));
92
- sinon.stub(MeetingInfoUtil, 'getRequestBody').returns(Promise.resolve({meetingKey: '1234323'}));
95
+ sinon.stub(MeetingInfoUtil, 'getRequestBody').returns(Promise.resolve(body));
96
+ webex.request.resolves(requestResponse);
93
97
 
94
- await meetingInfo.fetchMeetingInfo({
98
+ const result = await meetingInfo.fetchMeetingInfo({
95
99
  type: _MEETING_ID_,
96
100
  destination: '1234323'
97
101
  });
@@ -99,28 +103,39 @@ describe('plugin-meetings', () => {
99
103
  assert.calledWith(webex.request, {
100
104
  method: 'POST', service: 'webex-appapi-service', resource: 'meetingInfo', body: {meetingKey: '1234323'}
101
105
  });
106
+ assert.deepEqual(result, requestResponse);
102
107
 
103
108
  MeetingInfoUtil.getDestinationType.restore();
104
109
  MeetingInfoUtil.getRequestBody.restore();
105
110
  });
111
+
106
112
  it('should fetch meeting info for the personal meeting room type', async () => {
113
+ const body = {meetingKey: '1234323'};
114
+ const requestResponse = {statusCode: 200, body};
115
+
107
116
  sinon.stub(MeetingInfoUtil, 'getDestinationType').returns(Promise.resolve({type: 'MEETING_ID', destination: '123456'}));
108
- sinon.stub(MeetingInfoUtil, 'getRequestBody').returns(Promise.resolve({meetingKey: '1234323'}));
117
+ sinon.stub(MeetingInfoUtil, 'getRequestBody').returns(Promise.resolve(body));
118
+ webex.request.resolves(requestResponse);
109
119
 
110
- await meetingInfo.fetchMeetingInfo({
120
+ const result = await meetingInfo.fetchMeetingInfo({
111
121
  type: _PERSONAL_ROOM_
112
122
  });
113
123
 
114
124
  assert.calledWith(webex.request, {
115
125
  method: 'POST', service: 'webex-appapi-service', resource: 'meetingInfo', body: {meetingKey: '1234323'}
116
126
  });
127
+ assert.deepEqual(result, requestResponse);
117
128
 
118
129
  MeetingInfoUtil.getDestinationType.restore();
119
130
  MeetingInfoUtil.getRequestBody.restore();
120
131
  });
121
132
 
122
133
  it('should fetch meeting info with provided password and captcha code', async () => {
123
- await meetingInfo.fetchMeetingInfo('1234323', _MEETING_ID_, 'abc', {id: '999', code: 'aabbcc11'});
134
+ const requestResponse = {statusCode: 200, body: {meetingKey: '1234323'}};
135
+
136
+ webex.request.resolves(requestResponse);
137
+
138
+ const result = await meetingInfo.fetchMeetingInfo('1234323', _MEETING_ID_, 'abc', {id: '999', code: 'aabbcc11'});
124
139
 
125
140
  assert.calledWith(webex.request, {
126
141
  method: 'POST',
@@ -134,6 +149,7 @@ describe('plugin-meetings', () => {
134
149
  captchaVerifyCode: 'aabbcc11'
135
150
  }
136
151
  });
152
+ assert.deepEqual(result, requestResponse);
137
153
  assert(Metrics.sendBehavioralMetric.calledOnce);
138
154
  assert.calledWith(
139
155
  Metrics.sendBehavioralMetric,
@@ -260,5 +260,56 @@ describe('plugin-meetings', () => {
260
260
  await checkValid(resultPromise, spies, memberId, true, url1);
261
261
  });
262
262
  });
263
+
264
+ describe('#lowerAllHands', () => {
265
+ const setup = (locusUrl) => {
266
+ const members = createMembers({url: locusUrl});
267
+
268
+ const spies = {
269
+ generateLowerAllHandsMemberOptions: sandbox.spy(MembersUtil, 'generateLowerAllHandsMemberOptions'),
270
+ lowerAllHandsMember: sandbox.spy(members.membersRequest, 'lowerAllHandsMember'),
271
+ };
272
+
273
+ return {members, spies};
274
+ };
275
+
276
+ const checkInvalid = async (resultPromise, expectedMessage, spies) => {
277
+ await assert.isRejected(resultPromise, ParameterError, expectedMessage);
278
+ assert.notCalled(spies.generateLowerAllHandsMemberOptions);
279
+ assert.notCalled(spies.lowerAllHandsMember);
280
+ };
281
+
282
+ const checkValid = async (resultPromise, spies, expectedRequestingMemberId, expectedLocusUrl) => {
283
+ await assert.isFulfilled(resultPromise);
284
+ assert.calledOnceWithExactly(spies.generateLowerAllHandsMemberOptions, expectedRequestingMemberId, expectedLocusUrl);
285
+ assert.calledOnceWithExactly(spies.lowerAllHandsMember, {requestingParticipantId: expectedRequestingMemberId, locusUrl: expectedLocusUrl});
286
+ assert.strictEqual(resultPromise, spies.lowerAllHandsMember.getCall(0).returnValue);
287
+ };
288
+
289
+ it('should not make a request if there is no requestingMemberId', async () => {
290
+ const {members, spies} = setup(url1);
291
+
292
+ const resultPromise = members.lowerAllHands();
293
+
294
+ await checkInvalid(resultPromise, 'The requestingMemberId must be defined to lower all hands in a meeting.', spies);
295
+ });
296
+
297
+ it('should not make a request if there is no locus url', async () => {
298
+ const {members, spies} = setup();
299
+
300
+ const resultPromise = members.lowerAllHands(uuid.v4());
301
+
302
+ await checkInvalid(resultPromise, 'The associated locus url for this meetings members object must be defined.', spies);
303
+ });
304
+
305
+ it('should make the correct request when called with requestingMemberId', async () => {
306
+ const requestingMemberId = uuid.v4();
307
+ const {members, spies} = setup(url1);
308
+
309
+ const resultPromise = members.lowerAllHands(requestingMemberId);
310
+
311
+ await checkValid(resultPromise, spies, requestingMemberId, url1);
312
+ });
313
+ });
263
314
  });
264
315
  });
@@ -3,9 +3,11 @@ import chai from 'chai';
3
3
  import uuid from 'uuid';
4
4
  import chaiAsPromised from 'chai-as-promised';
5
5
  import MockWebex from '@webex/test-helper-mock-webex';
6
- import Meetings from '@webex/plugin-meetings';
7
6
 
7
+ import Meetings from '@webex/plugin-meetings';
8
8
  import MembersRequest from '@webex/plugin-meetings/src/members/request';
9
+ import membersUtil from '@webex/plugin-meetings/src/members/util';
10
+ import ParameterError from '@webex/plugin-meetings/src/common/errors/parameter';
9
11
 
10
12
  const {assert} = chai;
11
13
 
@@ -118,5 +120,72 @@ describe('plugin-meetings', () => {
118
120
  assert.equal(requestParams.body.hand.raised, true);
119
121
  });
120
122
  });
123
+
124
+ describe('#lowerAllHands', () => {
125
+ const parameterErrorMessage = 'requestingParticipantId must be defined, and the associated locus url for this meeting object must be defined.';
126
+
127
+ const checkInvalid = async (functionParams) => {
128
+ assert.throws(() => membersRequest.lowerAllHandsMember(functionParams), ParameterError, parameterErrorMessage);
129
+ assert(membersRequest.request.notCalled);
130
+ assert(membersUtil.getLowerAllHandsMemberRequestParams.notCalled);
131
+ };
132
+
133
+ it('rejects if no options are passed in', async () => {
134
+ checkInvalid();
135
+ });
136
+
137
+ it('rejects if no locusUrl are passed in', async () => {
138
+ checkInvalid({requestingParticipantId: 'test'});
139
+ });
140
+
141
+ it('rejects if no requestingParticipantId are passed in', async () => {
142
+ checkInvalid({locusUrl: 'test'});
143
+ });
144
+
145
+ it('returns a promise', async () => {
146
+ const locusUrl = url1;
147
+ const memberId = 'test1';
148
+
149
+ const options = {
150
+ requestingParticipantId: memberId,
151
+ locusUrl,
152
+ };
153
+
154
+ assert.strictEqual(membersRequest.lowerAllHandsMember(options), membersRequest.request.getCall(0).returnValue);
155
+ });
156
+
157
+ it('sends a PATCH to the locus endpoint', async () => {
158
+ const locusUrl = url1;
159
+ const memberId = 'test1';
160
+
161
+ const options = {
162
+ requestingParticipantId: memberId,
163
+ locusUrl,
164
+ };
165
+
166
+
167
+ const getRequestParamsSpy = sandbox.spy(membersUtil, 'getLowerAllHandsMemberRequestParams');
168
+
169
+ await membersRequest.lowerAllHandsMember(options);
170
+
171
+ assert.calledOnceWithExactly(getRequestParamsSpy, {
172
+ requestingParticipantId: memberId,
173
+ locusUrl: url1
174
+ });
175
+
176
+ const requestParams = membersRequest.request.getCall(0).args[0];
177
+
178
+ assert.deepEqual(requestParams, {
179
+ method: 'PATCH',
180
+ uri: `${locusUrl}/controls`,
181
+ body: {
182
+ hand: {
183
+ raised: false
184
+ },
185
+ requestingParticipantId: memberId
186
+ }
187
+ });
188
+ });
189
+ });
121
190
  });
122
191
  });