@webex/plugin-meetings 3.12.0-next.4 → 3.12.0-next.41

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.
Files changed (90) hide show
  1. package/AGENTS.md +9 -0
  2. package/dist/aiEnableRequest/index.js +15 -2
  3. package/dist/aiEnableRequest/index.js.map +1 -1
  4. package/dist/breakouts/breakout.js +6 -2
  5. package/dist/breakouts/breakout.js.map +1 -1
  6. package/dist/breakouts/index.js +1 -1
  7. package/dist/constants.js +1 -1
  8. package/dist/constants.js.map +1 -1
  9. package/dist/controls-options-manager/constants.js +11 -1
  10. package/dist/controls-options-manager/constants.js.map +1 -1
  11. package/dist/controls-options-manager/index.js +23 -21
  12. package/dist/controls-options-manager/index.js.map +1 -1
  13. package/dist/controls-options-manager/util.js +91 -0
  14. package/dist/controls-options-manager/util.js.map +1 -1
  15. package/dist/hashTree/constants.js +10 -1
  16. package/dist/hashTree/constants.js.map +1 -1
  17. package/dist/hashTree/hashTreeParser.js +554 -350
  18. package/dist/hashTree/hashTreeParser.js.map +1 -1
  19. package/dist/hashTree/utils.js +22 -0
  20. package/dist/hashTree/utils.js.map +1 -1
  21. package/dist/interceptors/locusRetry.js +23 -8
  22. package/dist/interceptors/locusRetry.js.map +1 -1
  23. package/dist/interpretation/index.js +1 -1
  24. package/dist/interpretation/siLanguage.js +1 -1
  25. package/dist/locus-info/index.js +274 -85
  26. package/dist/locus-info/index.js.map +1 -1
  27. package/dist/locus-info/types.js +16 -0
  28. package/dist/locus-info/types.js.map +1 -1
  29. package/dist/meeting/index.js +710 -499
  30. package/dist/meeting/index.js.map +1 -1
  31. package/dist/meeting/util.js +1 -0
  32. package/dist/meeting/util.js.map +1 -1
  33. package/dist/meetings/index.js +174 -77
  34. package/dist/meetings/index.js.map +1 -1
  35. package/dist/meetings/util.js +49 -5
  36. package/dist/meetings/util.js.map +1 -1
  37. package/dist/member/index.js +10 -0
  38. package/dist/member/index.js.map +1 -1
  39. package/dist/member/types.js.map +1 -1
  40. package/dist/member/util.js +3 -0
  41. package/dist/member/util.js.map +1 -1
  42. package/dist/types/controls-options-manager/constants.d.ts +6 -1
  43. package/dist/types/hashTree/constants.d.ts +1 -0
  44. package/dist/types/hashTree/hashTreeParser.d.ts +53 -15
  45. package/dist/types/hashTree/utils.d.ts +11 -0
  46. package/dist/types/interceptors/locusRetry.d.ts +4 -4
  47. package/dist/types/locus-info/index.d.ts +46 -6
  48. package/dist/types/locus-info/types.d.ts +17 -1
  49. package/dist/types/meeting/index.d.ts +64 -1
  50. package/dist/types/member/index.d.ts +1 -0
  51. package/dist/types/member/types.d.ts +1 -0
  52. package/dist/types/member/util.d.ts +1 -0
  53. package/dist/webinar/index.js +301 -226
  54. package/dist/webinar/index.js.map +1 -1
  55. package/package.json +22 -22
  56. package/src/aiEnableRequest/index.ts +16 -0
  57. package/src/breakouts/breakout.ts +2 -1
  58. package/src/constants.ts +1 -1
  59. package/src/controls-options-manager/constants.ts +14 -1
  60. package/src/controls-options-manager/index.ts +26 -19
  61. package/src/controls-options-manager/util.ts +81 -1
  62. package/src/hashTree/constants.ts +9 -0
  63. package/src/hashTree/hashTreeParser.ts +278 -160
  64. package/src/hashTree/utils.ts +17 -0
  65. package/src/interceptors/locusRetry.ts +25 -4
  66. package/src/locus-info/index.ts +274 -93
  67. package/src/locus-info/types.ts +19 -1
  68. package/src/meeting/index.ts +206 -22
  69. package/src/meeting/util.ts +1 -0
  70. package/src/meetings/index.ts +77 -43
  71. package/src/meetings/util.ts +56 -1
  72. package/src/member/index.ts +10 -0
  73. package/src/member/types.ts +1 -0
  74. package/src/member/util.ts +3 -0
  75. package/src/webinar/index.ts +75 -1
  76. package/test/unit/spec/aiEnableRequest/index.ts +86 -0
  77. package/test/unit/spec/breakouts/breakout.ts +7 -3
  78. package/test/unit/spec/controls-options-manager/index.js +114 -6
  79. package/test/unit/spec/controls-options-manager/util.js +165 -0
  80. package/test/unit/spec/hashTree/hashTreeParser.ts +996 -51
  81. package/test/unit/spec/hashTree/utils.ts +88 -1
  82. package/test/unit/spec/interceptors/locusRetry.ts +205 -4
  83. package/test/unit/spec/locus-info/index.js +397 -81
  84. package/test/unit/spec/meeting/index.js +271 -44
  85. package/test/unit/spec/meeting/utils.js +4 -0
  86. package/test/unit/spec/meetings/index.js +195 -13
  87. package/test/unit/spec/meetings/utils.js +137 -0
  88. package/test/unit/spec/member/index.js +7 -0
  89. package/test/unit/spec/member/util.js +24 -0
  90. package/test/unit/spec/webinar/index.ts +60 -0
@@ -18,6 +18,7 @@ import Trigger from '../common/events/trigger-proxy';
18
18
  import BEHAVIORAL_METRICS from '../metrics/constants';
19
19
  import Metrics from '../metrics';
20
20
  import {MEETING_KEY} from './meetings.types';
21
+ import {EndMeetingReason, LocusFullState} from '../locus-info/types';
21
22
 
22
23
  /**
23
24
  * Meetings Media Codec Missing Event
@@ -266,6 +267,35 @@ MeetingsUtil.getThisDevice = (newLocus: any, deviceUrl: string) => {
266
267
  return null;
267
268
  };
268
269
 
270
+ /**
271
+ * Checks if the fullState indicates the meeting has fully ended (not just a breakout move).
272
+ * @param {Object} fullState locus fullState data
273
+ * @returns {boolean}
274
+ */
275
+ MeetingsUtil.isWholeMeetingEnded = (fullState: LocusFullState): boolean => {
276
+ return (
277
+ fullState.state === LOCUS.STATE.INACTIVE &&
278
+ fullState.endMeetingReason !== EndMeetingReason.breakoutEnded
279
+ );
280
+ };
281
+
282
+ /**
283
+ * Checks if the self state in a locus indicates a breakout move or breakout end.
284
+ * Returns true when:
285
+ * - self state is LEFT with reason MOVED (regular breakout move), OR
286
+ * - fullState is INACTIVE with endMeetingReason BREAKOUT_ENDED (breakout session ended)
287
+ * @param {Object} locus locus data
288
+ * @returns {boolean}
289
+ */
290
+ MeetingsUtil.isSelfMovedOrBreakoutEnded = (locus: any): boolean => {
291
+ const isSelfLeftMoved = locus?.self?.state === _LEFT_ && locus?.self?.reason === _MOVED_;
292
+ const isBreakoutEnded =
293
+ locus?.fullState?.state === LOCUS.STATE.INACTIVE &&
294
+ locus?.fullState?.endMeetingReason === EndMeetingReason.breakoutEnded;
295
+
296
+ return isSelfLeftMoved || isBreakoutEnded;
297
+ };
298
+
269
299
  /**
270
300
  * get self device joined status from locus data
271
301
  * @param {Object} meeting current meeting data
@@ -294,7 +324,10 @@ MeetingsUtil.joinedOnThisDevice = (meeting: any, newLocus: any, deviceUrl: strin
294
324
  * @private
295
325
  */
296
326
  MeetingsUtil.isBreakoutLocusDTO = (newLocus: any) => {
297
- return newLocus?.controls?.breakout?.sessionType === BREAKOUTS.SESSION_TYPES.BREAKOUT;
327
+ return (
328
+ newLocus?.controls?.breakout?.sessionType === BREAKOUTS.SESSION_TYPES.BREAKOUT ||
329
+ !!newLocus?.info?.isBreakout
330
+ );
298
331
  };
299
332
 
300
333
  /**
@@ -310,4 +343,26 @@ MeetingsUtil.isValidBreakoutLocus = (locus: any) => {
310
343
 
311
344
  return isLocusAsBreakout && !inActiveStatus && selfJoined;
312
345
  };
346
+ /**
347
+ * check if the breakout locus is associated with the main locus by comparing the breakout control url or the replaces info in self device
348
+ * @param {Object} mainLocus main locus data
349
+ * @param {Object} breakoutLocus breakout locus data
350
+ * @returns {boolean}
351
+ * @private
352
+ */
353
+ MeetingsUtil.isMainAssociatedWithBreakout = (mainLocus: any, breakoutLocus: any) => {
354
+ if (
355
+ mainLocus.controls?.breakout?.url &&
356
+ mainLocus.controls?.breakout?.url === breakoutLocus.controls?.breakout?.url
357
+ ) {
358
+ return true;
359
+ }
360
+ const deviceUrl = breakoutLocus?.self?.deviceUrl;
361
+ const replaceInfo = MeetingsUtil.getThisDevice(breakoutLocus, deviceUrl)?.replaces?.[0];
362
+ if (replaceInfo?.locusUrl && replaceInfo.locusUrl === mainLocus.url) {
363
+ return true;
364
+ }
365
+
366
+ return false;
367
+ };
313
368
  export default MeetingsUtil;
@@ -27,6 +27,7 @@ export default class Member {
27
27
  isModerator: any;
28
28
  isModeratorAssignmentProhibited: any;
29
29
  isPresenterAssignmentProhibited: any;
30
+ isAttendeeAssignmentProhibited: any;
30
31
  isMutable: any;
31
32
  isNotAdmitted: any;
32
33
  isRecording: any;
@@ -292,6 +293,14 @@ export default class Member {
292
293
  */
293
294
  this.isPresenterAssignmentProhibited = null;
294
295
 
296
+ /**
297
+ * @instance
298
+ * @type {Boolean}
299
+ * @public
300
+ * @memberof Member
301
+ */
302
+ this.isAttendeeAssignmentProhibited = null;
303
+
295
304
  /**
296
305
  * @instance
297
306
  * @type {Boolean}
@@ -369,6 +378,7 @@ export default class Member {
369
378
  MemberUtil.isModeratorAssignmentProhibited(participant);
370
379
  this.isPresenterAssignmentProhibited =
371
380
  MemberUtil.isPresenterAssignmentProhibited(participant);
381
+ this.isAttendeeAssignmentProhibited = MemberUtil.isAttendeeAssignmentProhibited(participant);
372
382
  this.canApproveAIEnablement = MemberUtil.canApproveAIEnablement(participant);
373
383
  this.processStatus(participant);
374
384
  this.processRoles(participant);
@@ -103,6 +103,7 @@ export interface Participant {
103
103
  moderator: boolean; // Locus docs say this is deprecated and role control should be used instead
104
104
  moderatorAssignmentNotAllowed: boolean;
105
105
  presenterAssignmentNotAllowed: boolean;
106
+ attendeeAssignmentNotAllowed?: boolean;
106
107
  person: ParticipantPerson;
107
108
  resourceGuest: boolean;
108
109
  state: string; // probably one of MEETING_STATE.STATES
@@ -140,6 +140,9 @@ const MemberUtil = {
140
140
  isPresenterAssignmentProhibited: (participant: Participant) =>
141
141
  participant && participant.presenterAssignmentNotAllowed,
142
142
 
143
+ isAttendeeAssignmentProhibited: (participant: Participant) =>
144
+ !!(participant && participant.attendeeAssignmentNotAllowed),
145
+
143
146
  /**
144
147
  * checks to see if the participant id is the same as the passed id
145
148
  * there are multiple ids that can be used
@@ -154,12 +154,63 @@ const Webinar = WebexPlugin.extend({
154
154
  );
155
155
  },
156
156
 
157
+ /**
158
+ * Ensures practice-session token exists before registering the practice LLM channel.
159
+ * @param {object} meeting
160
+ * @returns {Promise<string|undefined>}
161
+ */
162
+ async ensurePracticeSessionDatachannelToken(meeting) {
163
+ // @ts-ignore
164
+ const isDataChannelTokenEnabled = await this.webex.internal.llm.isDataChannelTokenEnabled();
165
+
166
+ if (!isDataChannelTokenEnabled) {
167
+ return undefined;
168
+ }
169
+
170
+ // @ts-ignore
171
+ const cachedToken = this.webex.internal.llm.getDatachannelToken(
172
+ DataChannelTokenType.PracticeSession
173
+ );
174
+
175
+ if (cachedToken) {
176
+ return cachedToken;
177
+ }
178
+
179
+ try {
180
+ const refreshResponse = await meeting.refreshDataChannelToken();
181
+ const {datachannelToken, dataChannelTokenType} = refreshResponse?.body ?? {};
182
+
183
+ if (!datachannelToken) {
184
+ return undefined;
185
+ }
186
+
187
+ // @ts-ignore
188
+ this.webex.internal.llm.setDatachannelToken(
189
+ datachannelToken,
190
+ dataChannelTokenType || DataChannelTokenType.PracticeSession
191
+ );
192
+
193
+ return datachannelToken;
194
+ } catch (error) {
195
+ LoggerProxy.logger.warn(
196
+ `Webinar:index#ensurePracticeSessionDatachannelToken --> failed to proactively refresh practice-session token: ${
197
+ error?.message || String(error)
198
+ }`
199
+ );
200
+
201
+ return undefined;
202
+ }
203
+ },
204
+
157
205
  /**
158
206
  * Connects to low latency mercury and reconnects if the address has changed
159
207
  * It will also disconnect if called when the meeting has ended
160
208
  * @returns {Promise}
161
209
  */
162
210
  async updatePSDataChannel() {
211
+ this._updatePSDataChannelSequence = (this._updatePSDataChannelSequence || 0) + 1;
212
+ const invocationSequence = this._updatePSDataChannelSequence;
213
+
163
214
  const meeting = this.webex.meetings.getMeetingByType(_ID_, this.meetingId);
164
215
  const isPracticeSession = meeting?.isJoined() && this.isJoinPracticeSessionDataChannel();
165
216
 
@@ -174,7 +225,7 @@ const Webinar = WebexPlugin.extend({
174
225
  meeting?.locusInfo || {};
175
226
 
176
227
  // @ts-ignore
177
- const practiceSessionDatachannelToken = this.webex.internal.llm.getDatachannelToken(
228
+ let practiceSessionDatachannelToken = this.webex.internal.llm.getDatachannelToken(
178
229
  DataChannelTokenType.PracticeSession
179
230
  );
180
231
 
@@ -229,6 +280,29 @@ const Webinar = WebexPlugin.extend({
229
280
  this._pendingOnlineListener = null;
230
281
  }
231
282
 
283
+ const refreshedPracticeSessionToken = await this.ensurePracticeSessionDatachannelToken(meeting);
284
+
285
+ const latestPracticeSessionDatachannelUrl = get(
286
+ meeting,
287
+ 'locusInfo.info.practiceSessionDatachannelUrl'
288
+ );
289
+ const isStillPracticeSession = meeting?.isJoined() && this.isJoinPracticeSessionDataChannel();
290
+
291
+ // Skip stale invocations after async refresh to avoid reconnecting a session
292
+ // that was already updated/cleaned by a newer state transition.
293
+ if (
294
+ invocationSequence !== this._updatePSDataChannelSequence ||
295
+ !isStillPracticeSession ||
296
+ !latestPracticeSessionDatachannelUrl ||
297
+ latestPracticeSessionDatachannelUrl !== practiceSessionDatachannelUrl
298
+ ) {
299
+ return undefined;
300
+ }
301
+
302
+ if (refreshedPracticeSessionToken) {
303
+ practiceSessionDatachannelToken = refreshedPracticeSessionToken;
304
+ }
305
+
232
306
  // @ts-ignore - Fix type
233
307
  return this.webex.internal.llm
234
308
  .registerAndConnect(
@@ -55,6 +55,27 @@ describe('plugin-meetings', () => {
55
55
  });
56
56
  });
57
57
 
58
+ describe('#locusUrlUpdate', () => {
59
+ it('should update the locusUrl property', () => {
60
+ const testLocusUrl = 'https://locus-a.wbx2.com/locus/api/v1/loci/test-id';
61
+
62
+ aiEnableRequest.locusUrlUpdate(testLocusUrl);
63
+
64
+ assert.equal(aiEnableRequest.locusUrl, testLocusUrl);
65
+ });
66
+
67
+ it('should handle updating locusUrl multiple times', () => {
68
+ const firstUrl = 'https://locus-a.wbx2.com/locus/api/v1/loci/test-id-1';
69
+ const secondUrl = 'https://locus-a.wbx2.com/locus/api/v1/loci/test-id-2';
70
+
71
+ aiEnableRequest.locusUrlUpdate(firstUrl);
72
+ assert.equal(aiEnableRequest.locusUrl, firstUrl);
73
+
74
+ aiEnableRequest.locusUrlUpdate(secondUrl);
75
+ assert.equal(aiEnableRequest.locusUrl, secondUrl);
76
+ });
77
+ });
78
+
58
79
  describe('#selfParticipantIdUpdate', () => {
59
80
  it('should update the selfParticipantId property', () => {
60
81
  const testSelfParticipantId = 'participant-123';
@@ -254,6 +275,71 @@ describe('plugin-meetings', () => {
254
275
  sinon.assert.notCalled(triggerSpy);
255
276
  });
256
277
 
278
+ it('should not trigger event when locusUrl does not match', () => {
279
+ const testLocusUrl = 'https://locus-a.wbx2.com/locus/api/v1/loci/test-id';
280
+ const differentLocusUrl = 'https://locus-a.wbx2.com/locus/api/v1/loci/different-id';
281
+
282
+ aiEnableRequest.locusUrl = testLocusUrl;
283
+
284
+ // Reset the spy after setting locusUrl to avoid counting property change events
285
+ triggerSpy.resetHistory();
286
+
287
+ aiEnableRequest.listenToApprovalRequests();
288
+
289
+ const event = {
290
+ data: {
291
+ locusUrl: differentLocusUrl,
292
+ approval: {
293
+ resourceType: AI_ENABLE_REQUEST.RESOURCE_TYPE,
294
+ receivers: [{participantId: testSelfParticipantId}],
295
+ initiator: {participantId: testInitiatorId},
296
+ actionType: AI_ENABLE_REQUEST.ACTION_TYPE.REQUESTED,
297
+ url: testUrl,
298
+ },
299
+ },
300
+ };
301
+
302
+ webex.internal.mercury.emit(`event:${LOCUSEVENT.APPROVAL_REQUEST}`, event);
303
+
304
+ sinon.assert.notCalled(triggerSpy);
305
+ });
306
+
307
+ it('should trigger event when locusUrl matches', () => {
308
+ const testLocusUrl = 'https://locus-a.wbx2.com/locus/api/v1/loci/test-id';
309
+
310
+ aiEnableRequest.locusUrl = testLocusUrl;
311
+
312
+ // Reset the spy after setting locusUrl to avoid counting property change events
313
+ triggerSpy.resetHistory();
314
+
315
+ aiEnableRequest.listenToApprovalRequests();
316
+
317
+ const event = {
318
+ data: {
319
+ locusUrl: testLocusUrl,
320
+ approval: {
321
+ resourceType: AI_ENABLE_REQUEST.RESOURCE_TYPE,
322
+ receivers: [{participantId: testSelfParticipantId}],
323
+ initiator: {participantId: testInitiatorId},
324
+ actionType: AI_ENABLE_REQUEST.ACTION_TYPE.REQUESTED,
325
+ url: testUrl,
326
+ },
327
+ },
328
+ };
329
+
330
+ webex.internal.mercury.emit(`event:${LOCUSEVENT.APPROVAL_REQUEST}`, event);
331
+
332
+ sinon.assert.calledOnce(triggerSpy);
333
+ sinon.assert.calledWith(triggerSpy, AI_ENABLE_REQUEST.EVENTS.APPROVAL_REQUEST_ARRIVED, {
334
+ actionType: AI_ENABLE_REQUEST.ACTION_TYPE.REQUESTED,
335
+ isApprover: true,
336
+ isInitiator: false,
337
+ initiatorId: testInitiatorId,
338
+ approverId: testSelfParticipantId,
339
+ url: testUrl,
340
+ });
341
+ });
342
+
257
343
  it('should handle events with different action types', () => {
258
344
  aiEnableRequest.listenToApprovalRequests();
259
345
 
@@ -217,10 +217,14 @@ describe('plugin-meetings', () => {
217
217
  locusParticipantsUpdate: sinon.stub(),
218
218
  };
219
219
 
220
- const locusData = {some: 'data'};
220
+ const locusData = {participants: [{id: 'participant-1'}], sequence: {entries: [123]}};
221
221
  const result = breakout.parseRoster(locusData);
222
222
 
223
- assert.calledOnceWithExactly(breakout.members.locusParticipantsUpdate, locusData);
223
+ assert.calledOnceWithExactly(breakout.members.locusParticipantsUpdate, {
224
+ participants: [{id: 'participant-1'}],
225
+ isReplace: true,
226
+ });
227
+ assert.equal(breakout.breakoutRosterLocus, locusData);
224
228
  assert.equal(result, undefined);
225
229
  });
226
230
  it('not call locusParticipantsUpdate if sequence is expired', () => {
@@ -228,7 +232,7 @@ describe('plugin-meetings', () => {
228
232
  locusParticipantsUpdate: sinon.stub(),
229
233
  };
230
234
  breakout.isNeedHandleRoster = sinon.stub().returns(false);
231
- const locusData = {some: 'data'};
235
+ const locusData = {participants: [{id: 'participant-1'}], sequence: {entries: [123]}};
232
236
  breakout.parseRoster(locusData);
233
237
 
234
238
  assert.notCalled(breakout.members.locusParticipantsUpdate);
@@ -1,5 +1,6 @@
1
1
  import ControlsOptionsManager from '@webex/plugin-meetings/src/controls-options-manager';
2
2
  import Util from '@webex/plugin-meetings/src/controls-options-manager/util';
3
+ import ParameterError from '@webex/plugin-meetings/src/common/errors/parameter';
3
4
  import sinon from 'sinon';
4
5
  import {assert} from '@webex/test-helper-chai';
5
6
  import { HTTP_VERBS } from '@webex/plugin-meetings/src/constants';
@@ -76,6 +77,19 @@ describe('plugin-meetings', () => {
76
77
 
77
78
  assert.deepEqual(result, request.request.firstCall.returnValue);
78
79
  });
80
+
81
+ it('should send setMuteOnEntry to locusUrl without authorizingLocusUrl when in breakout', () => {
82
+ manager.setDisplayHints(['ENABLE_MUTE_ON_ENTRY']);
83
+ manager.mainLocusUrl = 'test/main';
84
+
85
+ const result = manager.setMuteOnEntry(true);
86
+
87
+ assert.calledWith(request.request, { uri: 'test/id/controls',
88
+ body: { muteOnEntry: { enabled: true } },
89
+ method: HTTP_VERBS.PATCH});
90
+
91
+ assert.deepEqual(result, request.request.firstCall.returnValue);
92
+ });
79
93
  });
80
94
 
81
95
  describe('setDisallowUnmute', () => {
@@ -118,6 +132,19 @@ describe('plugin-meetings', () => {
118
132
 
119
133
  assert.deepEqual(result, request.request.firstCall.returnValue);
120
134
  });
135
+
136
+ it('should send setDisallowUnmute to locusUrl without authorizingLocusUrl when in breakout', () => {
137
+ manager.setDisplayHints(['ENABLE_HARD_MUTE']);
138
+ manager.mainLocusUrl = 'test/main';
139
+
140
+ const result = manager.setDisallowUnmute(true);
141
+
142
+ assert.calledWith(request.request, { uri: 'test/id/controls',
143
+ body: { disallowUnmute: { enabled: true } },
144
+ method: HTTP_VERBS.PATCH});
145
+
146
+ assert.deepEqual(result, request.request.firstCall.returnValue);
147
+ });
121
148
  });
122
149
  });
123
150
 
@@ -138,6 +165,18 @@ describe('plugin-meetings', () => {
138
165
  });
139
166
  });
140
167
 
168
+ it('should reject with ParameterError when locusUrl is not set', () => {
169
+ const noLocusManager = new ControlsOptionsManager(request);
170
+
171
+ const result = noLocusManager.update({scope: 'audio', properties: {muted: true}});
172
+
173
+ assert.notCalled(request.request);
174
+ return assert.isRejected(result).then((err) => {
175
+ assert.instanceOf(err, ParameterError);
176
+ assert.match(err.message, /locusUrl.*must be defined/);
177
+ });
178
+ });
179
+
141
180
  it('should throw an error if the scope is not supported', () => {
142
181
  const scope = 'invalid';
143
182
 
@@ -203,7 +242,7 @@ describe('plugin-meetings', () => {
203
242
  });
204
243
  });
205
244
 
206
- it('should call request with mainLocusUrl and locusUrl as authorizingLocusUrl if mainLocusUrl is exist and not same with locusUrl', () => {
245
+ it('should send audio controls to locusUrl without authorizingLocusUrl and non-audio to mainLocusUrl with authorizingLocusUrl when in breakout', () => {
207
246
  const restorable = Util.canUpdate;
208
247
  Util.canUpdate = sinon.stub().returns(true);
209
248
  manager.mainLocusUrl = 'test/main';
@@ -213,15 +252,16 @@ describe('plugin-meetings', () => {
213
252
 
214
253
  return manager.update(audio, reactions)
215
254
  .then(() => {
255
+ // Audio controls go directly to current locusUrl (no cross-locus authorization)
216
256
  assert.calledWith(request.request, {
217
- uri: 'test/main/controls',
257
+ uri: 'test/id/controls',
218
258
  body: {
219
259
  audio: audio.properties,
220
- authorizingLocusUrl: 'test/id'
221
260
  },
222
261
  method: HTTP_VERBS.PATCH,
223
262
  });
224
263
 
264
+ // Non-audio controls go to mainLocusUrl with authorizingLocusUrl
225
265
  assert.calledWith(request.request, {
226
266
  uri: 'test/main/controls',
227
267
  body: {
@@ -234,6 +274,49 @@ describe('plugin-meetings', () => {
234
274
  Util.canUpdate = restorable;
235
275
  });
236
276
  });
277
+
278
+ it('should send audio controls to locusUrl without authorizingLocusUrl when in breakout', () => {
279
+ const restorable = Util.canUpdate;
280
+ Util.canUpdate = sinon.stub().returns(true);
281
+ manager.mainLocusUrl = 'test/main';
282
+
283
+ const audio = {scope: 'audio', properties: {muted: true, disallowUnmute: false}};
284
+
285
+ return manager.update(audio)
286
+ .then(() => {
287
+ assert.calledWith(request.request, {
288
+ uri: 'test/id/controls',
289
+ body: {
290
+ audio: audio.properties,
291
+ },
292
+ method: HTTP_VERBS.PATCH,
293
+ });
294
+
295
+ Util.canUpdate = restorable;
296
+ });
297
+ });
298
+
299
+ it('should send non-audio controls to mainLocusUrl with authorizingLocusUrl when in breakout', () => {
300
+ const restorable = Util.canUpdate;
301
+ Util.canUpdate = sinon.stub().returns(true);
302
+ manager.mainLocusUrl = 'test/main';
303
+
304
+ const reactions = {scope: 'reactions', properties: {enabled: true}};
305
+
306
+ return manager.update(reactions)
307
+ .then(() => {
308
+ assert.calledWith(request.request, {
309
+ uri: 'test/main/controls',
310
+ body: {
311
+ reactions: reactions.properties,
312
+ authorizingLocusUrl: 'test/id',
313
+ },
314
+ method: HTTP_VERBS.PATCH,
315
+ });
316
+
317
+ Util.canUpdate = restorable;
318
+ });
319
+ });
237
320
  });
238
321
 
239
322
  describe('Mute/Unmute All', () => {
@@ -252,6 +335,18 @@ describe('plugin-meetings', () => {
252
335
  })
253
336
  });
254
337
 
338
+ it('should reject with ParameterError when locusUrl is not set', () => {
339
+ const noLocusManager = new ControlsOptionsManager(request);
340
+
341
+ const result = noLocusManager.setMuteAll(true, true, true);
342
+
343
+ assert.notCalled(request.request);
344
+ return assert.isRejected(result).then((err) => {
345
+ assert.instanceOf(err, ParameterError);
346
+ assert.match(err.message, /locusUrl.*must be defined/);
347
+ });
348
+ });
349
+
255
350
  it('rejects when correct display hint is not present mutedEnabled=false', () => {
256
351
  const result = manager.setMuteAll(false, false, false);
257
352
 
@@ -340,14 +435,27 @@ describe('plugin-meetings', () => {
340
435
  assert.deepEqual(result, request.request.firstCall.returnValue);
341
436
  });
342
437
 
343
- it('request with mainLocusUrl and make locusUrl as authorizingLocusUrl if mainLocusUrl is exist and not same with locusUrl', () => {
438
+ it('should send setMuteAll to locusUrl without authorizingLocusUrl when in breakout', () => {
344
439
  manager.setDisplayHints(['MUTE_ALL', 'DISABLE_HARD_MUTE', 'DISABLE_MUTE_ON_ENTRY']);
345
440
  manager.mainLocusUrl = `test/main`;
346
441
 
347
442
  const result = manager.setMuteAll(true, true, true, ['attendee']);
348
443
 
349
- assert.calledWith(request.request, { uri: 'test/main/controls',
350
- body: { audio: { muted: true, disallowUnmute: true, muteOnEntry: true, roles: ['attendee'] }, authorizingLocusUrl: 'test/id' },
444
+ assert.calledWith(request.request, { uri: 'test/id/controls',
445
+ body: { audio: { muted: true, disallowUnmute: true, muteOnEntry: true, roles: ['attendee'] } },
446
+ method: HTTP_VERBS.PATCH});
447
+
448
+ assert.deepEqual(result, request.request.firstCall.returnValue);
449
+ });
450
+
451
+ it('should send setMuteAll with PANELIST role to locusUrl without authorizingLocusUrl when in breakout', () => {
452
+ manager.setDisplayHints(['MUTE_ALL', 'ENABLE_HARD_MUTE', 'ENABLE_MUTE_ON_ENTRY']);
453
+ manager.mainLocusUrl = `test/main`;
454
+
455
+ const result = manager.setMuteAll(true, true, true, ['PANELIST']);
456
+
457
+ assert.calledWith(request.request, { uri: 'test/id/controls',
458
+ body: { audio: { muted: true, disallowUnmute: true, muteOnEntry: true, roles: ['PANELIST'] } },
351
459
  method: HTTP_VERBS.PATCH});
352
460
 
353
461
  assert.deepEqual(result, request.request.firstCall.returnValue);