@webex/plugin-meetings 3.12.0-next.2 → 3.12.0-next.21

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 (67) hide show
  1. package/dist/aiEnableRequest/index.js +1 -1
  2. package/dist/breakouts/breakout.js +1 -1
  3. package/dist/breakouts/index.js +1 -1
  4. package/dist/controls-options-manager/constants.js +11 -1
  5. package/dist/controls-options-manager/constants.js.map +1 -1
  6. package/dist/controls-options-manager/index.js +23 -21
  7. package/dist/controls-options-manager/index.js.map +1 -1
  8. package/dist/controls-options-manager/util.js +91 -0
  9. package/dist/controls-options-manager/util.js.map +1 -1
  10. package/dist/hashTree/constants.js +10 -1
  11. package/dist/hashTree/constants.js.map +1 -1
  12. package/dist/hashTree/hashTreeParser.js +56 -31
  13. package/dist/hashTree/hashTreeParser.js.map +1 -1
  14. package/dist/hashTree/utils.js +22 -0
  15. package/dist/hashTree/utils.js.map +1 -1
  16. package/dist/interpretation/index.js +1 -1
  17. package/dist/interpretation/siLanguage.js +1 -1
  18. package/dist/locus-info/index.js +51 -23
  19. package/dist/locus-info/index.js.map +1 -1
  20. package/dist/meeting/index.js +372 -292
  21. package/dist/meeting/index.js.map +1 -1
  22. package/dist/meeting/util.js +1 -0
  23. package/dist/meeting/util.js.map +1 -1
  24. package/dist/meetings/index.js +8 -9
  25. package/dist/meetings/index.js.map +1 -1
  26. package/dist/meetings/util.js +21 -2
  27. package/dist/meetings/util.js.map +1 -1
  28. package/dist/metrics/constants.js +5 -1
  29. package/dist/metrics/constants.js.map +1 -1
  30. package/dist/multistream/sendSlotManager.js +116 -2
  31. package/dist/multistream/sendSlotManager.js.map +1 -1
  32. package/dist/types/controls-options-manager/constants.d.ts +6 -1
  33. package/dist/types/hashTree/constants.d.ts +1 -0
  34. package/dist/types/hashTree/hashTreeParser.d.ts +12 -2
  35. package/dist/types/hashTree/utils.d.ts +11 -0
  36. package/dist/types/locus-info/index.d.ts +9 -5
  37. package/dist/types/meeting/index.d.ts +11 -0
  38. package/dist/types/metrics/constants.d.ts +4 -0
  39. package/dist/types/multistream/sendSlotManager.d.ts +23 -1
  40. package/dist/webinar/index.js +301 -226
  41. package/dist/webinar/index.js.map +1 -1
  42. package/package.json +16 -16
  43. package/src/controls-options-manager/constants.ts +14 -1
  44. package/src/controls-options-manager/index.ts +26 -19
  45. package/src/controls-options-manager/util.ts +81 -1
  46. package/src/hashTree/constants.ts +9 -0
  47. package/src/hashTree/hashTreeParser.ts +60 -36
  48. package/src/hashTree/utils.ts +17 -0
  49. package/src/locus-info/index.ts +56 -30
  50. package/src/meeting/index.ts +98 -11
  51. package/src/meeting/util.ts +1 -0
  52. package/src/meetings/index.ts +15 -16
  53. package/src/meetings/util.ts +26 -1
  54. package/src/metrics/constants.ts +5 -0
  55. package/src/multistream/sendSlotManager.ts +97 -3
  56. package/src/webinar/index.ts +75 -1
  57. package/test/unit/spec/controls-options-manager/index.js +114 -6
  58. package/test/unit/spec/controls-options-manager/util.js +165 -0
  59. package/test/unit/spec/hashTree/hashTreeParser.ts +441 -30
  60. package/test/unit/spec/hashTree/utils.ts +88 -1
  61. package/test/unit/spec/locus-info/index.js +75 -27
  62. package/test/unit/spec/meeting/index.js +54 -36
  63. package/test/unit/spec/meeting/utils.js +4 -0
  64. package/test/unit/spec/meetings/index.js +36 -3
  65. package/test/unit/spec/meetings/utils.js +108 -0
  66. package/test/unit/spec/multistream/sendSlotManager.ts +135 -36
  67. package/test/unit/spec/webinar/index.ts +60 -0
@@ -69,6 +69,7 @@ import JoinForbiddenError from '../common/errors/join-forbidden-error';
69
69
  import {HashTreeMessage} from '../hashTree/hashTreeParser';
70
70
  import {HashTreeObject} from '../hashTree/types';
71
71
  import {isSelf} from '../hashTree/utils';
72
+
72
73
  import {createLocusFromHashTreeMessage, findMeetingForHashTreeMessage} from '../locus-info';
73
74
 
74
75
  let mediaLogger;
@@ -435,14 +436,11 @@ export default class Meetings extends WebexPlugin {
435
436
  if (existingMeeting) {
436
437
  return existingMeeting;
437
438
  }
438
-
439
439
  if (data.eventType === LOCUSEVENT.HASH_TREE_DATA_UPDATED) {
440
440
  // need to check if maybe this event indicates a move to/from breakout
441
441
  const meetingForHashTreeMessage = findMeetingForHashTreeMessage(
442
- data.stateElementsMessage,
443
- this.meetingCollection,
444
- // @ts-ignore
445
- this.webex.internal.device.url
442
+ data?.stateElementsMessage,
443
+ this.meetingCollection
446
444
  );
447
445
 
448
446
  if (meetingForHashTreeMessage) {
@@ -492,7 +490,6 @@ export default class Meetings extends WebexPlugin {
492
490
  */
493
491
  private handleLocusEvent(data: LocusEvent, useRandomDelayForInfo = false) {
494
492
  let meeting = this.getCorrespondingMeetingByLocus(data);
495
-
496
493
  // @ts-ignore
497
494
  if (this.config.experimental.storeLocusHashTreeEventsForDebugging) {
498
495
  storeEventForDebugging('mercury', data);
@@ -604,7 +601,6 @@ export default class Meetings extends WebexPlugin {
604
601
  // @ts-ignore
605
602
  this.destroy(meeting, MEETING_REMOVED_REASON.LOCUS_DTO_SYNC_FAILED);
606
603
  }
607
-
608
604
  this.checkHandleBreakoutLocus(data.locus);
609
605
  })
610
606
  .catch((e) => {
@@ -1811,7 +1807,11 @@ export default class Meetings extends WebexPlugin {
1811
1807
  // For type LOCUS_ID we need to parse the locus object to get the information
1812
1808
  // about the caller and callee
1813
1809
  // Meeting Added event will be created in `handleLocusEvent`
1814
- if (type !== DESTINATION_TYPE.LOCUS_ID) {
1810
+ // Only emit MEETING_ADDED if the meeting still exists in the collection.
1811
+ // If fetchMeetingInfo failed and the meeting was destroyed in the catch block,
1812
+ // skip emitting to prevent orphaned meeting references on the consumer side.
1813
+ // @ts-ignore - getMeetingByType types value as object but accepts strings (same as handleLocusEvent)
1814
+ if (type !== DESTINATION_TYPE.LOCUS_ID && this.getMeetingByType(_ID_, meeting.id)) {
1815
1815
  if (!meeting.sipUri) {
1816
1816
  meeting.setSipUri(destination);
1817
1817
  }
@@ -1898,8 +1898,8 @@ export default class Meetings extends WebexPlugin {
1898
1898
 
1899
1899
  return this.request
1900
1900
  .getActiveMeetings()
1901
- .then((locusArray) => {
1902
- const activeLocusUrl = [];
1901
+ .then((locusArray: any) => {
1902
+ const activeLocusUrl: string[] = [];
1903
1903
 
1904
1904
  if (locusArray?.loci && locusArray.loci.length > 0) {
1905
1905
  const lociToUpdate = this.sortLocusArrayToUpdate(locusArray.loci);
@@ -1950,8 +1950,8 @@ export default class Meetings extends WebexPlugin {
1950
1950
  this.breakoutLocusForHandleLater = [];
1951
1951
  const lociToUpdate = [...mainLoci];
1952
1952
  breakoutLoci.forEach((breakoutLocus) => {
1953
- const associateMainLocus = mainLoci.find(
1954
- (mainLocus) => mainLocus.controls?.breakout?.url === breakoutLocus.controls?.breakout?.url
1953
+ const associateMainLocus = mainLoci.find((mainLocus) =>
1954
+ MeetingsUtil.isMainAssociatedWithBreakout(mainLocus, breakoutLocus)
1955
1955
  );
1956
1956
  const existCorrespondingMeeting = this.getCorrespondingMeetingByLocus({
1957
1957
  eventType: LOCUSEVENT.SDK_NO_EVENT,
@@ -1979,7 +1979,7 @@ export default class Meetings extends WebexPlugin {
1979
1979
  * @public
1980
1980
  * @memberof Meetings
1981
1981
  */
1982
- checkHandleBreakoutLocus(newCreatedLocus) {
1982
+ checkHandleBreakoutLocus(newCreatedLocus: any) {
1983
1983
  if (
1984
1984
  !newCreatedLocus ||
1985
1985
  !this.breakoutLocusForHandleLater ||
@@ -1990,9 +1990,8 @@ export default class Meetings extends WebexPlugin {
1990
1990
  if (MeetingsUtil.isBreakoutLocusDTO(newCreatedLocus)) {
1991
1991
  return;
1992
1992
  }
1993
- const existIndex = this.breakoutLocusForHandleLater.findIndex(
1994
- (breakoutLocus) =>
1995
- breakoutLocus.controls?.breakout?.url === newCreatedLocus.controls?.breakout?.url
1993
+ const existIndex = this.breakoutLocusForHandleLater.findIndex((breakoutLocus: any) =>
1994
+ MeetingsUtil.isMainAssociatedWithBreakout(newCreatedLocus, breakoutLocus)
1996
1995
  );
1997
1996
 
1998
1997
  if (existIndex < 0) {
@@ -294,7 +294,10 @@ MeetingsUtil.joinedOnThisDevice = (meeting: any, newLocus: any, deviceUrl: strin
294
294
  * @private
295
295
  */
296
296
  MeetingsUtil.isBreakoutLocusDTO = (newLocus: any) => {
297
- return newLocus?.controls?.breakout?.sessionType === BREAKOUTS.SESSION_TYPES.BREAKOUT;
297
+ return (
298
+ newLocus?.controls?.breakout?.sessionType === BREAKOUTS.SESSION_TYPES.BREAKOUT ||
299
+ !!newLocus?.info?.isBreakout
300
+ );
298
301
  };
299
302
 
300
303
  /**
@@ -310,4 +313,26 @@ MeetingsUtil.isValidBreakoutLocus = (locus: any) => {
310
313
 
311
314
  return isLocusAsBreakout && !inActiveStatus && selfJoined;
312
315
  };
316
+ /**
317
+ * check if the breakout locus is associated with the main locus by comparing the breakout control url or the replaces info in self device
318
+ * @param {Object} mainLocus main locus data
319
+ * @param {Object} breakoutLocus breakout locus data
320
+ * @returns {boolean}
321
+ * @private
322
+ */
323
+ MeetingsUtil.isMainAssociatedWithBreakout = (mainLocus: any, breakoutLocus: any) => {
324
+ if (
325
+ mainLocus.controls?.breakout?.url &&
326
+ mainLocus.controls?.breakout?.url === breakoutLocus.controls?.breakout?.url
327
+ ) {
328
+ return true;
329
+ }
330
+ const deviceUrl = breakoutLocus?.self?.deviceUrl;
331
+ const replaceInfo = MeetingsUtil.getThisDevice(breakoutLocus, deviceUrl)?.replaces?.[0];
332
+ if (replaceInfo?.locusUrl && replaceInfo.locusUrl === mainLocus.url) {
333
+ return true;
334
+ }
335
+
336
+ return false;
337
+ };
313
338
  export default MeetingsUtil;
@@ -91,6 +91,11 @@ const BEHAVIORAL_METRICS = {
91
91
  LOCUS_CLASSIC_VS_HASH_TREE_MISMATCH: 'js_sdk_locus_classic_vs_hash_tree_mismatch',
92
92
  LOCUS_HASH_TREE_UNSUPPORTED_OPERATION: 'js_sdk_locus_hash_tree_unsupported_operation',
93
93
  MEDIA_STILL_NOT_CONNECTED: 'js_sdk_media_still_not_connected',
94
+ DEPRECATED_SET_CODEC_PARAMETERS_USED: 'js_sdk_deprecated_set_codec_parameters_used',
95
+ DEPRECATED_DELETE_CODEC_PARAMETERS_USED: 'js_sdk_deprecated_delete_codec_parameters_used',
96
+ SET_CUSTOM_CODEC_PARAMETERS_USED: 'js_sdk_set_custom_codec_parameters_used',
97
+ MARK_CUSTOM_CODEC_PARAMETERS_FOR_DELETION_USED:
98
+ 'js_sdk_mark_custom_codec_parameters_for_deletion_used',
94
99
  };
95
100
 
96
101
  export {BEHAVIORAL_METRICS as default};
@@ -5,7 +5,11 @@ import {
5
5
  MultistreamRoapMediaConnection,
6
6
  NamedMediaGroup,
7
7
  StreamState,
8
+ MediaCodecMimeType,
9
+ CodecParameters,
8
10
  } from '@webex/internal-media-core';
11
+ import Metrics from '../metrics';
12
+ import BEHAVIORAL_METRICS from '../metrics/constants';
9
13
 
10
14
  /**
11
15
  * This class is used to manage the sendSlots for the given media types.
@@ -206,6 +210,8 @@ export default class SendSlotManager {
206
210
  }
207
211
 
208
212
  /**
213
+ * @deprecated Use {@link setCustomCodecParameters} instead, which requires specifying the codec MIME type.
214
+ *
209
215
  * This method is used to set the codec parameters for the sendSlot of the given mediaType
210
216
  * @param {MediaType} mediaType MediaType of the sendSlot for which the codec parameters needs to be set (AUDIO_MAIN/VIDEO_MAIN/AUDIO_SLIDES/VIDEO_SLIDES)
211
217
  * @param {Object} codecParameters
@@ -226,12 +232,19 @@ export default class SendSlotManager {
226
232
 
227
233
  await slot.setCodecParameters(codecParameters);
228
234
 
229
- this.LoggerProxy.logger.info(
230
- `SendSlotsManager->setCodecParameters#Set codec parameters for ${mediaType} to ${codecParameters}`
235
+ this.LoggerProxy.logger.warn(
236
+ 'SendSlotsManager->setCodecParameters --> [DEPRECATION WARNING]: setCodecParameters has been deprecated, use setCustomCodecParameters instead'
231
237
  );
238
+
239
+ Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.DEPRECATED_SET_CODEC_PARAMETERS_USED, {
240
+ mediaType,
241
+ codecParameters,
242
+ });
232
243
  }
233
244
 
234
245
  /**
246
+ * @deprecated Use {@link markCustomCodecParametersForDeletion} instead, which requires specifying the codec MIME type.
247
+ *
235
248
  * This method is used to delete the codec parameters for the sendSlot of the given mediaType
236
249
  * @param {MediaType} mediaType MediaType of the sendSlot for which the codec parameters needs to be deleted (AUDIO_MAIN/VIDEO_MAIN/AUDIO_SLIDES/VIDEO_SLIDES)
237
250
  * @param {Array<String>} parameters Array of keys of the codec parameters to be deleted
@@ -246,8 +259,89 @@ export default class SendSlotManager {
246
259
 
247
260
  await slot.deleteCodecParameters(parameters);
248
261
 
262
+ this.LoggerProxy.logger.warn(
263
+ 'SendSlotsManager->deleteCodecParameters --> [DEPRECATION WARNING]: deleteCodecParameters has been deprecated, use markCustomCodecParametersForDeletion instead'
264
+ );
265
+
266
+ Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.DEPRECATED_DELETE_CODEC_PARAMETERS_USED, {
267
+ mediaType,
268
+ parameters,
269
+ });
270
+ }
271
+
272
+ /**
273
+ * Sets custom codec parameters for the sendSlot of the given mediaType, scoped to a specific codec MIME type.
274
+ * Delegates to WCME's setCustomCodecParameters API.
275
+ * @param {MediaType} mediaType MediaType of the sendSlot
276
+ * @param {MediaCodecMimeType} codecMimeType The codec MIME type to apply parameters to (e.g. OPUS, H264, AV1)
277
+ * @param {CodecParameters} parameters Key-value pairs of codec parameters to set
278
+ * @returns {Promise<void>}
279
+ */
280
+ public async setCustomCodecParameters(
281
+ mediaType: MediaType,
282
+ codecMimeType: MediaCodecMimeType,
283
+ parameters: CodecParameters
284
+ ): Promise<void> {
285
+ const slot = this.slots.get(mediaType);
286
+
287
+ if (!slot) {
288
+ throw new Error(`Slot for ${mediaType} does not exist`);
289
+ }
290
+
291
+ try {
292
+ await slot.setCustomCodecParameters(codecMimeType, parameters);
293
+
294
+ this.LoggerProxy.logger.info(
295
+ `SendSlotsManager->setCustomCodecParameters#Set custom codec parameters for ${mediaType} (codec: ${codecMimeType}) to ${JSON.stringify(
296
+ parameters
297
+ )}`
298
+ );
299
+ } catch (error) {
300
+ this.LoggerProxy.logger.error(
301
+ `SendSlotsManager->setCustomCodecParameters#Failed to set custom codec parameters for ${mediaType} (codec: ${codecMimeType}): ${error}`
302
+ );
303
+ throw error;
304
+ } finally {
305
+ Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.SET_CUSTOM_CODEC_PARAMETERS_USED, {
306
+ mediaType,
307
+ codecMimeType,
308
+ parameters,
309
+ });
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Marks custom codec parameters for deletion on the sendSlot of the given mediaType, scoped to a specific codec MIME type.
315
+ * Delegates to WCME's markCustomCodecParametersForDeletion API.
316
+ * @param {MediaType} mediaType MediaType of the sendSlot
317
+ * @param {MediaCodecMimeType} codecMimeType The codec MIME type whose parameters should be deleted (e.g. OPUS, H264, AV1)
318
+ * @param {string[]} parameters Array of parameter keys to delete
319
+ * @returns {Promise<void>}
320
+ */
321
+ public async markCustomCodecParametersForDeletion(
322
+ mediaType: MediaType,
323
+ codecMimeType: MediaCodecMimeType,
324
+ parameters: string[]
325
+ ): Promise<void> {
326
+ const slot = this.slots.get(mediaType);
327
+
328
+ if (!slot) {
329
+ throw new Error(`Slot for ${mediaType} does not exist`);
330
+ }
331
+
332
+ await slot.markCustomCodecParametersForDeletion(codecMimeType, parameters);
333
+
249
334
  this.LoggerProxy.logger.info(
250
- `SendSlotsManager->deleteCodecParameters#Deleted the following codec parameters -> ${parameters} for ${mediaType}`
335
+ `SendSlotsManager->markCustomCodecParametersForDeletion#Marked codec parameters for deletion -> ${parameters} for ${mediaType} (codec: ${codecMimeType})`
336
+ );
337
+
338
+ Metrics.sendBehavioralMetric(
339
+ BEHAVIORAL_METRICS.MARK_CUSTOM_CODEC_PARAMETERS_FOR_DELETION_USED,
340
+ {
341
+ mediaType,
342
+ codecMimeType,
343
+ parameters,
344
+ }
251
345
  );
252
346
  }
253
347
 
@@ -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(
@@ -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);