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

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
@@ -91,6 +91,7 @@ describe('plugin-meetings', () => {
91
91
  locusInfo = {
92
92
  parse: sinon.stub().returns(true),
93
93
  updateMainSessionLocusCache: sinon.stub(),
94
+ syncAllHashTreeDatasets: sinon.stub(),
94
95
  };
95
96
  webex = new MockWebex({
96
97
  children: {
@@ -1285,10 +1286,10 @@ describe('plugin-meetings', () => {
1285
1286
  assert.exists(result.dispose);
1286
1287
  });
1287
1288
 
1288
- it('creates noise reduction effect with ST model', async () => {
1289
+ it('creates noise reduction effect with OFMV model', async () => {
1289
1290
  const result = await webex.meetings.createNoiseReductionEffect({
1290
1291
  audioContext: {},
1291
- model: 'st',
1292
+ model: 'ofmv',
1292
1293
  });
1293
1294
 
1294
1295
  assert.exists(result);
@@ -1300,7 +1301,7 @@ describe('plugin-meetings', () => {
1300
1301
  authToken: 'fake_token',
1301
1302
  mode: 'WORKLET',
1302
1303
  avoidSimd: false,
1303
- model: 'st',
1304
+ model: 'ofmv',
1304
1305
  });
1305
1306
  assert.exists(result.enable);
1306
1307
  assert.exists(result.disable);
@@ -1391,7 +1392,7 @@ describe('plugin-meetings', () => {
1391
1392
  it('should have #syncMeetings', () => {
1392
1393
  assert.exists(webex.meetings.syncMeetings);
1393
1394
  });
1394
- it('should do nothing and return a resolved promise if unverified guest', async () => {
1395
+ it('should skip getActiveMeetings but still call syncAllHashTreeDatasets if unverified guest', async () => {
1395
1396
  webex.meetings.request.getActiveMeetings = sinon.stub().returns(
1396
1397
  Promise.resolve({
1397
1398
  loci: [
@@ -1404,13 +1405,23 @@ describe('plugin-meetings', () => {
1404
1405
  webex.credentials.isUnverifiedGuest = true;
1405
1406
  LoggerProxy.logger.info = sinon.stub();
1406
1407
 
1408
+ const mockLocusInfo = {
1409
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
1410
+ };
1411
+ webex.meetings.meetingCollection.getAll = sinon.stub().returns({
1412
+ meeting1: {locusInfo: mockLocusInfo},
1413
+ meeting2: {locusInfo: undefined},
1414
+ meeting3: {},
1415
+ });
1416
+
1407
1417
  await webex.meetings.syncMeetings();
1408
1418
 
1409
1419
  assert.notCalled(webex.meetings.request.getActiveMeetings);
1410
1420
  assert.calledWith(
1411
1421
  LoggerProxy.logger.info,
1412
- 'Meetings:index#syncMeetings --> skipping meeting sync as unverified guest'
1422
+ 'Meetings:index#syncMeetings --> user is unverified guest, skipping calling Locus for meeting sync'
1413
1423
  );
1424
+ assert.calledOnce(mockLocusInfo.syncAllHashTreeDatasets);
1414
1425
  });
1415
1426
  describe('succesful requests', () => {
1416
1427
  beforeEach(() => {
@@ -1429,6 +1440,9 @@ describe('plugin-meetings', () => {
1429
1440
  webex.meetings.meetingCollection.getByKey = sinon.stub().returns({
1430
1441
  locusInfo,
1431
1442
  });
1443
+ webex.meetings.meetingCollection.getAll = sinon.stub().returns({
1444
+ meeting1: {locusInfo, locusUrl: url1},
1445
+ });
1432
1446
  });
1433
1447
  it('tests the sync meeting calls for existing meeting', async () => {
1434
1448
  await webex.meetings.syncMeetings();
@@ -1436,6 +1450,7 @@ describe('plugin-meetings', () => {
1436
1450
  assert.calledOnce(webex.meetings.meetingCollection.getByKey);
1437
1451
  assert.calledOnce(locusInfo.parse);
1438
1452
  assert.calledWith(webex.meetings.meetingCollection.getByKey, 'locusUrl', url1);
1453
+ assert.calledOnce(locusInfo.syncAllHashTreeDatasets);
1439
1454
  });
1440
1455
  });
1441
1456
  describe('when meeting is not returned', () => {
@@ -1474,7 +1489,7 @@ describe('plugin-meetings', () => {
1474
1489
  url: url1,
1475
1490
  },
1476
1491
  hashTreeMessage: undefined,
1477
- });
1492
+ }, sinon.match.func);
1478
1493
  });
1479
1494
  });
1480
1495
  describe('when destroying meeting is needed', () => {
@@ -1520,7 +1535,7 @@ describe('plugin-meetings', () => {
1520
1535
  it('destroy any meeting that has no active locus url if keepOnlyLocusMeetings is not defined', async () => {
1521
1536
  await webex.meetings.syncMeetings();
1522
1537
  assert.calledOnce(webex.meetings.request.getActiveMeetings);
1523
- assert.calledOnce(webex.meetings.meetingCollection.getAll);
1538
+ assert.calledTwice(webex.meetings.meetingCollection.getAll);
1524
1539
  assert.calledWith(destroySpy, meetingCollectionMeetings.noLongerValidLocusMeeting);
1525
1540
  assert.calledWith(destroySpy, meetingCollectionMeetings.otherNonLocusMeeting1);
1526
1541
  assert.calledWith(destroySpy, meetingCollectionMeetings.otherNonLocusMeeting2);
@@ -1532,7 +1547,7 @@ describe('plugin-meetings', () => {
1532
1547
  it('destroy any meeting that has no active locus url if keepOnlyLocusMeetings === true', async () => {
1533
1548
  await webex.meetings.syncMeetings({keepOnlyLocusMeetings: true});
1534
1549
  assert.calledOnce(webex.meetings.request.getActiveMeetings);
1535
- assert.calledOnce(webex.meetings.meetingCollection.getAll);
1550
+ assert.calledTwice(webex.meetings.meetingCollection.getAll);
1536
1551
  assert.calledWith(destroySpy, meetingCollectionMeetings.noLongerValidLocusMeeting);
1537
1552
  assert.calledWith(destroySpy, meetingCollectionMeetings.otherNonLocusMeeting1);
1538
1553
  assert.calledWith(destroySpy, meetingCollectionMeetings.otherNonLocusMeeting2);
@@ -1544,7 +1559,7 @@ describe('plugin-meetings', () => {
1544
1559
  it('destroy any LOCUS meetings that have no active locus url if keepOnlyLocusMeetings === false', async () => {
1545
1560
  await webex.meetings.syncMeetings({keepOnlyLocusMeetings: false});
1546
1561
  assert.calledOnce(webex.meetings.request.getActiveMeetings);
1547
- assert.calledOnce(webex.meetings.meetingCollection.getAll);
1562
+ assert.calledTwice(webex.meetings.meetingCollection.getAll);
1548
1563
  assert.calledWith(destroySpy, meetingCollectionMeetings.noLongerValidLocusMeeting);
1549
1564
  assert.callCount(destroySpy, 1);
1550
1565
 
@@ -1552,6 +1567,113 @@ describe('plugin-meetings', () => {
1552
1567
  });
1553
1568
  });
1554
1569
  });
1570
+
1571
+ describe('when globalMeetingId preserves breakout meetings', () => {
1572
+ let destroySpy;
1573
+ let cleanUpSpy;
1574
+
1575
+ beforeEach(() => {
1576
+ destroySpy = sinon.spy(webex.meetings, 'destroy');
1577
+ cleanUpSpy = sinon.stub(MeetingUtil, 'cleanUp').returns(Promise.resolve());
1578
+ });
1579
+
1580
+ afterEach(() => {
1581
+ cleanUpSpy.restore();
1582
+ });
1583
+
1584
+ it('should not destroy a meeting whose globalMeetingId matches an active locus', async () => {
1585
+ const meetingCollectionMeetings = {
1586
+ breakoutMeeting: {
1587
+ locusUrl: 'breakout-url',
1588
+ locusInfo: {
1589
+ info: {globalMeetingId: 'gmid-123'},
1590
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
1591
+ },
1592
+ sendCallAnalyzerMetrics: sinon.stub(),
1593
+ },
1594
+ };
1595
+
1596
+ webex.meetings.meetingCollection.getAll = sinon
1597
+ .stub()
1598
+ .returns(meetingCollectionMeetings);
1599
+ webex.meetings.request.getActiveMeetings = sinon.stub().resolves({
1600
+ loci: [{url: 'main-url', info: {globalMeetingId: 'gmid-123'}}],
1601
+ });
1602
+
1603
+ await webex.meetings.syncMeetings();
1604
+
1605
+ assert.notCalled(destroySpy);
1606
+ });
1607
+
1608
+ it('should destroy a meeting whose globalMeetingId does NOT match any active locus', async () => {
1609
+ const meetingCollectionMeetings = {
1610
+ breakoutMeeting: {
1611
+ locusUrl: 'breakout-url',
1612
+ locusInfo: {
1613
+ info: {globalMeetingId: 'gmid-other'},
1614
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
1615
+ },
1616
+ sendCallAnalyzerMetrics: sinon.stub(),
1617
+ },
1618
+ };
1619
+
1620
+ webex.meetings.meetingCollection.getAll = sinon
1621
+ .stub()
1622
+ .returns(meetingCollectionMeetings);
1623
+ webex.meetings.request.getActiveMeetings = sinon.stub().resolves({
1624
+ loci: [{url: 'main-url', info: {globalMeetingId: 'gmid-123'}}],
1625
+ });
1626
+
1627
+ await webex.meetings.syncMeetings();
1628
+
1629
+ assert.calledOnce(destroySpy);
1630
+ assert.calledWith(destroySpy, meetingCollectionMeetings.breakoutMeeting);
1631
+ });
1632
+ });
1633
+
1634
+ describe('syncAllHashTreeDatasets in syncMeetings', () => {
1635
+ it('should call syncAllHashTreeDatasets for multiple meetings, skipping those without locusInfo', async () => {
1636
+ const mockLocusInfo1 = {
1637
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
1638
+ };
1639
+ const mockLocusInfo2 = {
1640
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
1641
+ };
1642
+
1643
+ webex.meetings.request.getActiveMeetings = sinon.stub().resolves({loci: []});
1644
+ webex.meetings.meetingCollection.getAll = sinon.stub().returns({
1645
+ meeting1: {locusInfo: mockLocusInfo1},
1646
+ meeting2: {locusInfo: undefined},
1647
+ meeting3: {locusInfo: mockLocusInfo2},
1648
+ meeting4: {},
1649
+ });
1650
+
1651
+ await webex.meetings.syncMeetings({keepOnlyLocusMeetings: false});
1652
+
1653
+ assert.calledOnce(mockLocusInfo1.syncAllHashTreeDatasets);
1654
+ assert.calledOnce(mockLocusInfo2.syncAllHashTreeDatasets);
1655
+ });
1656
+
1657
+ it('should not call syncAllHashTreeDatasets when getActiveMeetings throws an error', async () => {
1658
+ const mockLocusInfo = {
1659
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
1660
+ };
1661
+
1662
+ webex.meetings.request.getActiveMeetings = sinon.stub().rejects(new Error('network error'));
1663
+ webex.meetings.meetingCollection.getAll = sinon.stub().returns({
1664
+ meeting1: {locusInfo: mockLocusInfo},
1665
+ });
1666
+
1667
+ try {
1668
+ await webex.meetings.syncMeetings();
1669
+ assert.fail('should have thrown');
1670
+ } catch (err) {
1671
+ assert.equal(err.message, 'network error');
1672
+ }
1673
+
1674
+ assert.notCalled(mockLocusInfo.syncAllHashTreeDatasets);
1675
+ });
1676
+ });
1555
1677
  });
1556
1678
  describe('#fetchStaticMeetingLink', () => {
1557
1679
  const conversationUrl = 'conv.fakeconversationurl.com';
@@ -2015,7 +2137,7 @@ describe('plugin-meetings', () => {
2015
2137
  },
2016
2138
  },
2017
2139
  hashTreeMessage: undefined,
2018
- });
2140
+ }, sinon.match.func);
2019
2141
  });
2020
2142
  it('should setup the meeting from a hash tree event', async () => {
2021
2143
  const selfData = {};
@@ -2049,7 +2171,7 @@ describe('plugin-meetings', () => {
2049
2171
  info: infoData,
2050
2172
  },
2051
2173
  hashTreeMessage,
2052
- });
2174
+ }, sinon.match.func);
2053
2175
  });
2054
2176
 
2055
2177
  it('should ignore hash tree event when created locus has INACTIVE fullState', async () => {
@@ -2129,7 +2251,7 @@ describe('plugin-meetings', () => {
2129
2251
  },
2130
2252
  },
2131
2253
  hashTreeMessage: undefined,
2132
- });
2254
+ }, sinon.match.func);
2133
2255
  });
2134
2256
 
2135
2257
  it('sends client event correctly on finally', async () => {
@@ -2205,7 +2327,7 @@ describe('plugin-meetings', () => {
2205
2327
  },
2206
2328
  },
2207
2329
  hashTreeMessage: undefined,
2208
- });
2330
+ }, sinon.match.func);
2209
2331
  });
2210
2332
 
2211
2333
  const generateFakeLocusData = (isUnifiedSpaceMeeting) => ({
@@ -2833,6 +2955,39 @@ describe('plugin-meetings', () => {
2833
2955
  checkCreateMeetingWithNoMeetingInfo(true, true);
2834
2956
  });
2835
2957
 
2958
+ it('does not emit meeting:added when meeting is destroyed due to missing meeting info', async () => {
2959
+ // Make destroy actually remove the meeting from the collection
2960
+ // so that getMeetingByType returns null in the finally block
2961
+ webex.meetings.destroy = sinon.stub().callsFake((meeting) => {
2962
+ webex.meetings.meetingCollection.delete(meeting.id);
2963
+ });
2964
+
2965
+ try {
2966
+ await webex.meetings.createMeeting(
2967
+ 'test destination',
2968
+ 'test type',
2969
+ undefined,
2970
+ undefined,
2971
+ undefined,
2972
+ true
2973
+ );
2974
+ assert.fail('should have thrown NoMeetingInfoError');
2975
+ } catch (err) {
2976
+ assert.instanceOf(err, NoMeetingInfoError);
2977
+ }
2978
+
2979
+ assert.calledOnce(webex.meetings.destroy);
2980
+
2981
+ // meeting:added should NOT have been triggered since the meeting was destroyed
2982
+ assert.neverCalledWith(
2983
+ TriggerProxy.trigger,
2984
+ sinon.match.any,
2985
+ sinon.match({function: 'createMeeting'}),
2986
+ 'meeting:added',
2987
+ sinon.match.any
2988
+ );
2989
+ });
2990
+
2836
2991
  it('creates the meeting avoiding meeting info fetch by passing type as DESTINATION_TYPE.ONE_ON_ONE_CALL', async () => {
2837
2992
  const meeting = await webex.meetings.createMeeting(
2838
2993
  'test destination',
@@ -3426,6 +3581,21 @@ describe('plugin-meetings', () => {
3426
3581
  'Meetings:index#isNeedHandleMainLocus --> self device left&moved in main locus with self joined status, not need to handle'
3427
3582
  );
3428
3583
  });
3584
+
3585
+ it('check breakout ended with self removed, return false', () => {
3586
+ webex.meetings.meetingCollection.getActiveBreakoutLocus = sinon.stub().returns(null);
3587
+ newLocus.self.state = 'LEFT';
3588
+ newLocus.self.reason = 'OTHER';
3589
+ newLocus.self.removed = true;
3590
+ newLocus.fullState = {state: 'INACTIVE', endMeetingReason: 'BREAKOUT_ENDED'};
3591
+ LoggerProxy.logger.log = sinon.stub();
3592
+ const result = webex.meetings.isNeedHandleMainLocus(meeting, newLocus);
3593
+ assert.equal(result, false);
3594
+ assert.calledWith(
3595
+ LoggerProxy.logger.log,
3596
+ 'Meetings:index#isNeedHandleMainLocus --> self moved main locus with self removed status or with device resource moved, not need to handle'
3597
+ );
3598
+ });
3429
3599
  });
3430
3600
 
3431
3601
  describe('#isNeedHandleLocusDTO', () => {
@@ -3486,6 +3656,18 @@ describe('plugin-meetings', () => {
3486
3656
  const result = webex.meetings.isNeedHandleLocusDTO(meeting, newLocus);
3487
3657
  assert.equal(result, false);
3488
3658
  });
3659
+ it('breakout session with breakout ended, return false', () => {
3660
+ newLocus.controls.breakout = {
3661
+ sessionType: 'BREAKOUT',
3662
+ };
3663
+ newLocus.self.state = 'LEFT';
3664
+ newLocus.self.reason = 'OTHER';
3665
+ newLocus.self.devices = [];
3666
+ newLocus.fullState = {state: 'INACTIVE', endMeetingReason: 'BREAKOUT_ENDED'};
3667
+ LoggerProxy.logger.log = sinon.stub();
3668
+ const result = webex.meetings.isNeedHandleLocusDTO(meeting, newLocus);
3669
+ assert.equal(result, false);
3670
+ });
3489
3671
  it('moved to lobby, return true', () => {
3490
3672
  newLocus.controls.breakout = {
3491
3673
  sessionType: 'MAIN',
@@ -128,6 +128,143 @@ describe('plugin-meetings', () => {
128
128
  };
129
129
  assert.equal(MeetingsUtil.isBreakoutLocusDTO(newLocus), false);
130
130
  });
131
+
132
+ it('returns true if newLocus.info.isBreakout is true', () => {
133
+ const newLocus = {
134
+ info: {
135
+ isBreakout: true,
136
+ },
137
+ };
138
+ assert.equal(MeetingsUtil.isBreakoutLocusDTO(newLocus), true);
139
+ });
140
+
141
+ it('returns false if newLocus.info.isBreakout is false', () => {
142
+ const newLocus = {
143
+ info: {
144
+ isBreakout: false,
145
+ },
146
+ };
147
+ assert.equal(MeetingsUtil.isBreakoutLocusDTO(newLocus), false);
148
+ });
149
+
150
+ it('returns true if both sessionType is BREAKOUT and info.isBreakout is true', () => {
151
+ const newLocus = {
152
+ controls: {
153
+ breakout: {
154
+ sessionType: 'BREAKOUT',
155
+ },
156
+ },
157
+ info: {
158
+ isBreakout: true,
159
+ },
160
+ };
161
+ assert.equal(MeetingsUtil.isBreakoutLocusDTO(newLocus), true);
162
+ });
163
+ });
164
+
165
+ describe('#isMainAssociatedWithBreakout', () => {
166
+ it('returns true when breakout control url matches main locus breakout url', () => {
167
+ const mainLocus = {
168
+ url: 'main-locus-url',
169
+ controls: {
170
+ breakout: {
171
+ url: 'breakout-control-url',
172
+ },
173
+ },
174
+ };
175
+ const breakoutLocus = {
176
+ controls: {
177
+ breakout: {
178
+ url: 'breakout-control-url',
179
+ },
180
+ },
181
+ };
182
+
183
+ assert.equal(MeetingsUtil.isMainAssociatedWithBreakout(mainLocus, breakoutLocus), true);
184
+ });
185
+
186
+ it('returns true when breakout self device replaces the main locus url', () => {
187
+ const mainLocus = {
188
+ url: 'main-locus-url',
189
+ controls: {},
190
+ };
191
+ const breakoutLocus = {
192
+ controls: {
193
+ breakout: {
194
+ url: 'other-breakout-url',
195
+ },
196
+ },
197
+ self: {
198
+ deviceUrl: 'device-url-1',
199
+ devices: [
200
+ {
201
+ url: 'device-url-1',
202
+ replaces: [{locusUrl: 'main-locus-url'}],
203
+ },
204
+ ],
205
+ },
206
+ };
207
+
208
+ assert.equal(MeetingsUtil.isMainAssociatedWithBreakout(mainLocus, breakoutLocus), true);
209
+ });
210
+
211
+ it('returns false when breakout locus is not associated with the main locus', () => {
212
+ const mainLocus = {
213
+ url: 'main-locus-url',
214
+ controls: {
215
+ breakout: {
216
+ url: 'breakout-control-url',
217
+ },
218
+ },
219
+ };
220
+ const breakoutLocus = {
221
+ controls: {
222
+ breakout: {
223
+ url: 'different-breakout-url',
224
+ },
225
+ },
226
+ self: {
227
+ deviceUrl: 'device-url-1',
228
+ devices: [
229
+ {
230
+ url: 'device-url-1',
231
+ replaces: [{locusUrl: 'another-main-locus-url'}],
232
+ },
233
+ ],
234
+ },
235
+ };
236
+
237
+ assert.equal(MeetingsUtil.isMainAssociatedWithBreakout(mainLocus, breakoutLocus), false);
238
+ });
239
+ });
240
+
241
+ describe('#isWholeMeetingEnded', () => {
242
+ [
243
+ {description: 'state is INACTIVE with no endMeetingReason', fullState: {state: 'INACTIVE'}, expected: true},
244
+ {description: 'state is INACTIVE with endMeetingReason OTHER', fullState: {state: 'INACTIVE', endMeetingReason: 'SOME_OTHER_REASON'}, expected: true},
245
+ {description: 'state is INACTIVE with endMeetingReason BREAKOUT_ENDED', fullState: {state: 'INACTIVE', endMeetingReason: 'BREAKOUT_ENDED'}, expected: false},
246
+ {description: 'state is not INACTIVE', fullState: {state: 'ACTIVE', endMeetingReason: 'SOME_OTHER_REASON'}, expected: false},
247
+ ].forEach(({description, fullState, expected}) => {
248
+ it(`returns ${expected} when ${description}`, () => {
249
+ assert.equal(MeetingsUtil.isWholeMeetingEnded(fullState), expected);
250
+ });
251
+ });
252
+ });
253
+
254
+ describe('#isSelfMovedOrBreakoutEnded', () => {
255
+ [
256
+ {description: 'locus is undefined', locus: undefined, expected: false},
257
+ {description: 'self state is JOINED', locus: {self: {state: 'JOINED', reason: 'OTHER'}}, expected: false},
258
+ {description: 'self state is LEFT with reason MOVED', locus: {self: {state: 'LEFT', reason: 'MOVED'}}, expected: true},
259
+ {description: 'fullState is INACTIVE with BREAKOUT_ENDED', locus: {self: {state: 'LEFT', reason: 'OTHER'}, fullState: {state: 'INACTIVE', endMeetingReason: 'BREAKOUT_ENDED'}}, expected: true},
260
+ {description: 'fullState is INACTIVE with different endMeetingReason', locus: {self: {state: 'LEFT', reason: 'OTHER'}, fullState: {state: 'INACTIVE', endMeetingReason: 'SOME_OTHER_REASON'}}, expected: false},
261
+ {description: 'fullState is missing', locus: {self: {state: 'LEFT', reason: 'OTHER'}}, expected: false},
262
+ {description: 'endMeetingReason is missing', locus: {self: {state: 'LEFT', reason: 'OTHER'}, fullState: {state: 'INACTIVE'}}, expected: false},
263
+ ].forEach(({description, locus, expected}) => {
264
+ it(`returns ${expected} when ${description}`, () => {
265
+ assert.equal(MeetingsUtil.isSelfMovedOrBreakoutEnded(locus), expected);
266
+ });
267
+ });
131
268
  });
132
269
 
133
270
  describe('#joinedOnThisDevice', () => {
@@ -59,6 +59,13 @@ describe('member', () => {
59
59
  assert.calledOnceWithExactly(MemberUtil.isPresenterAssignmentProhibited, participant);
60
60
  });
61
61
 
62
+ it('checks that processParticipant calls isAttendeeAssignmentProhibited', () => {
63
+ sinon.spy(MemberUtil, 'isAttendeeAssignmentProhibited');
64
+ member.processParticipant(participant);
65
+
66
+ assert.calledOnceWithExactly(MemberUtil.isAttendeeAssignmentProhibited, participant);
67
+ });
68
+
62
69
  it('checks that processParticipant calls canApproveAIEnablement', () => {
63
70
  sinon.spy(MemberUtil, 'canApproveAIEnablement');
64
71
  member.processParticipant(participant);
@@ -643,6 +643,30 @@ describe('plugin-meetings', () => {
643
643
  assert.isUndefined(MemberUtil.isPresenterAssignmentProhibited(participant));
644
644
  });
645
645
  });
646
+
647
+ describe('MemberUtil.isAttendeeAssignmentProhibited', () => {
648
+ it('returns true when attendeeAssignmentNotAllowed is true', () => {
649
+ const participant = {
650
+ attendeeAssignmentNotAllowed: true,
651
+ };
652
+
653
+ assert.isTrue(MemberUtil.isAttendeeAssignmentProhibited(participant));
654
+ });
655
+
656
+ it('returns false when attendeeAssignmentNotAllowed is false', () => {
657
+ const participant = {
658
+ attendeeAssignmentNotAllowed: false,
659
+ };
660
+
661
+ assert.isFalse(MemberUtil.isAttendeeAssignmentProhibited(participant));
662
+ });
663
+
664
+ it('returns false when attendeeAssignmentNotAllowed is undefined', () => {
665
+ const participant = {};
666
+
667
+ assert.isFalse(MemberUtil.isAttendeeAssignmentProhibited(participant));
668
+ });
669
+ });
646
670
  });
647
671
 
648
672
  describe('extractMediaStatus', () => {
@@ -33,6 +33,7 @@ describe('plugin-meetings', () => {
33
33
  webex.internal.llm = {
34
34
  getDatachannelToken: sinon.stub().returns(undefined),
35
35
  setDatachannelToken: sinon.stub(),
36
+ isDataChannelTokenEnabled: sinon.stub().resolves(false),
36
37
  isConnected: sinon.stub().returns(false),
37
38
  disconnectLLM: sinon.stub().resolves(),
38
39
  off: sinon.stub(),
@@ -267,6 +268,65 @@ describe('plugin-meetings', () => {
267
268
  webex.internal.voicea.updateSubchannelSubscriptions = sinon.stub();
268
269
  });
269
270
 
271
+ it('refreshes practice-session token before register when cached token is missing', async () => {
272
+ webex.internal.llm.isDataChannelTokenEnabled.resolves(true);
273
+ webex.internal.llm.getDatachannelToken = sinon.stub().callsFake((tokenType) => {
274
+ if (tokenType === DataChannelTokenType.PracticeSession) return undefined;
275
+
276
+ return undefined;
277
+ });
278
+ meeting.refreshDataChannelToken = sinon.stub().resolves({
279
+ body: {
280
+ datachannelToken: 'ps-token-from-refresh',
281
+ dataChannelTokenType: DataChannelTokenType.PracticeSession,
282
+ },
283
+ });
284
+
285
+ await webinar.updatePSDataChannel();
286
+
287
+ assert.calledOnceWithExactly(meeting.refreshDataChannelToken);
288
+ assert.calledWithExactly(
289
+ webex.internal.llm.setDatachannelToken,
290
+ 'ps-token-from-refresh',
291
+ DataChannelTokenType.PracticeSession
292
+ );
293
+ assert.calledWith(
294
+ webex.internal.llm.registerAndConnect,
295
+ 'locus-url',
296
+ 'dc-url',
297
+ 'ps-token-from-refresh',
298
+ LLM_PRACTICE_SESSION
299
+ );
300
+ });
301
+
302
+ it('does not reconnect if practice-session eligibility changes during async token refresh', async () => {
303
+ webex.internal.llm.isDataChannelTokenEnabled.resolves(true);
304
+ webex.internal.llm.getDatachannelToken = sinon.stub().returns(undefined);
305
+
306
+ let resolveRefresh;
307
+ meeting.refreshDataChannelToken = sinon.stub().returns(
308
+ new Promise((resolve) => {
309
+ resolveRefresh = resolve;
310
+ })
311
+ );
312
+
313
+ const updatePromise = webinar.updatePSDataChannel();
314
+
315
+ webinar.practiceSessionEnabled = false;
316
+
317
+ resolveRefresh({
318
+ body: {
319
+ datachannelToken: 'stale-ps-token',
320
+ dataChannelTokenType: DataChannelTokenType.PracticeSession,
321
+ },
322
+ });
323
+
324
+ const result = await updatePromise;
325
+
326
+ assert.isUndefined(result);
327
+ assert.notCalled(webex.internal.llm.registerAndConnect);
328
+ });
329
+
270
330
  it('no-ops when practice session join eligibility is false', async () => {
271
331
  webinar.practiceSessionEnabled = false;
272
332
  const cleanupPSDataChannelStub = sinon.stub(webinar, 'cleanupPSDataChannel').resolves();