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

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.
@@ -3175,7 +3175,7 @@ describe('plugin-meetings', () => {
3175
3175
  const createMockParser = (state = 'active') => ({
3176
3176
  state,
3177
3177
  stop: sinon.stub(),
3178
- resume: sinon.stub(),
3178
+ resumeFromMessage: sinon.stub(),
3179
3179
  handleMessage: sinon.stub(),
3180
3180
  });
3181
3181
 
@@ -3258,7 +3258,7 @@ describe('plugin-meetings', () => {
3258
3258
  stateElementsMessage: message,
3259
3259
  });
3260
3260
 
3261
- assert.calledOnce(parserA.resume);
3261
+ assert.calledOnce(parserA.resumeFromMessage);
3262
3262
  assert.calledOnce(parserB.stop);
3263
3263
  });
3264
3264
 
@@ -3281,7 +3281,7 @@ describe('plugin-meetings', () => {
3281
3281
  stateElementsMessage: message,
3282
3282
  });
3283
3283
 
3284
- assert.notCalled(parserA.resume);
3284
+ assert.notCalled(parserA.resumeFromMessage);
3285
3285
  assert.notCalled(parserB.stop);
3286
3286
  });
3287
3287
 
@@ -3300,7 +3300,7 @@ describe('plugin-meetings', () => {
3300
3300
  stateElementsMessage: message,
3301
3301
  });
3302
3302
 
3303
- assert.notCalled(parserA.resume);
3303
+ assert.notCalled(parserA.resumeFromMessage);
3304
3304
  assert.notCalled(parserA.handleMessage);
3305
3305
  });
3306
3306
 
@@ -3453,6 +3453,156 @@ describe('plugin-meetings', () => {
3453
3453
  assert.calledOnce(locusInfo.sendClassicVsHashTreeMismatchMetric);
3454
3454
  assert.calledOnce(mockHashTreeParser.handleLocusUpdate);
3455
3455
  });
3456
+
3457
+ describe('parser switch via API response', () => {
3458
+ const deviceUrl = 'http://device-url.com';
3459
+ const locusUrlA = 'http://locus-url-A.com';
3460
+ const locusUrlB = 'http://locus-url-B.com';
3461
+
3462
+ let HashTreeParserStub;
3463
+
3464
+ const createMockApiParser = (state = 'active') => ({
3465
+ state,
3466
+ stop: sinon.stub(),
3467
+ resumeFromApiResponse: sinon.stub(),
3468
+ handleLocusUpdate: sinon.stub(),
3469
+ initializeFromGetLociResponse: sinon.stub(),
3470
+ });
3471
+
3472
+ const createLocusWithReplaces = (url, replacedLocusUrl, replacedAt) => ({
3473
+ url,
3474
+ self: {
3475
+ devices: [{url: deviceUrl, replaces: [{locusUrl: replacedLocusUrl, replacedAt}]}],
3476
+ },
3477
+ });
3478
+
3479
+ const createLocusWithoutReplaces = (url) => ({
3480
+ url,
3481
+ self: {devices: [{url: deviceUrl}]},
3482
+ });
3483
+
3484
+ beforeEach(() => {
3485
+ locusInfo.webex.internal.device.url = deviceUrl;
3486
+ HashTreeParserStub = sinon
3487
+ .stub(HashTreeParserModule, 'default')
3488
+ .returns(createMockApiParser());
3489
+ });
3490
+
3491
+ it('should create a new parser and initialize it when no entry exists for the locusUrl', () => {
3492
+ // existing parser for a different url so hashTreeParsers.size > 0
3493
+ locusInfo.hashTreeParsers.set(locusUrlA, {parser: createMockApiParser(), initializedFromHashTree: true});
3494
+
3495
+ const locus = createLocusWithReplaces(locusUrlB, locusUrlA, '2026-01-01T00:00:00Z');
3496
+ sinon.stub(locusInfo, 'handleLocusDelta');
3497
+
3498
+ locusInfo.handleLocusAPIResponse(mockMeeting, {locus});
3499
+
3500
+ assert.isTrue(locusInfo.hashTreeParsers.has(locusUrlB));
3501
+ const newEntry = locusInfo.hashTreeParsers.get(locusUrlB);
3502
+ assert.isFalse(newEntry.initializedFromHashTree);
3503
+
3504
+ // the stub returns the mock, so initializeFromGetLociResponse should be called on it
3505
+ const createdParser = HashTreeParserStub.returnValues[0];
3506
+ assert.calledOnceWithExactly(createdParser.initializeFromGetLociResponse, locus);
3507
+ assert.notCalled(locusInfo.handleLocusDelta);
3508
+ });
3509
+
3510
+ it('should reactivate a stopped parser when replaces info is newer', () => {
3511
+ const parserA = createMockApiParser('stopped');
3512
+ const parserB = createMockApiParser('active');
3513
+ locusInfo.hashTreeParsers.set(locusUrlA, {parser: parserA, replacedAt: '2026-01-01T00:00:00Z', initializedFromHashTree: true});
3514
+ locusInfo.hashTreeParsers.set(locusUrlB, {parser: parserB, initializedFromHashTree: true});
3515
+
3516
+ const locus = createLocusWithReplaces(locusUrlA, locusUrlB, '2026-02-01T00:00:00Z');
3517
+
3518
+ locusInfo.handleLocusAPIResponse(mockMeeting, {locus});
3519
+
3520
+ assert.calledOnce(parserA.resumeFromApiResponse);
3521
+ assert.calledWithExactly(parserA.resumeFromApiResponse, locus);
3522
+ assert.calledOnce(parserB.stop);
3523
+ assert.equal(locusInfo.hashTreeParsers.get(locusUrlB).replacedAt, '2026-02-01T00:00:00Z');
3524
+ assert.isFalse(locusInfo.hashTreeParsers.get(locusUrlA).initializedFromHashTree);
3525
+ });
3526
+
3527
+ it('should not reactivate a stopped parser when replaces info is not newer', () => {
3528
+ const parserA = createMockApiParser('stopped');
3529
+ const parserB = createMockApiParser('active');
3530
+ locusInfo.hashTreeParsers.set(locusUrlA, {parser: parserA, replacedAt: '2026-03-01T00:00:00Z', initializedFromHashTree: true});
3531
+ locusInfo.hashTreeParsers.set(locusUrlB, {parser: parserB, initializedFromHashTree: true});
3532
+
3533
+ const locus = createLocusWithReplaces(locusUrlA, locusUrlB, '2026-01-01T00:00:00Z');
3534
+
3535
+ locusInfo.handleLocusAPIResponse(mockMeeting, {locus});
3536
+
3537
+ assert.notCalled(parserA.resumeFromApiResponse);
3538
+ assert.notCalled(parserB.stop);
3539
+ });
3540
+
3541
+ it('should not reactivate a stopped parser when no replaces info is available', () => {
3542
+ const parserA = createMockApiParser('stopped');
3543
+ locusInfo.hashTreeParsers.set(locusUrlA, {parser: parserA, initializedFromHashTree: true});
3544
+
3545
+ const locus = createLocusWithoutReplaces(locusUrlA);
3546
+
3547
+ locusInfo.handleLocusAPIResponse(mockMeeting, {locus});
3548
+
3549
+ assert.notCalled(parserA.resumeFromApiResponse);
3550
+ });
3551
+ });
3552
+ });
3553
+
3554
+ describe('#syncAllHashTreeDatasets', () => {
3555
+ it('should call syncAllDatasets on each parser that has an entry', async () => {
3556
+ const parser1 = {syncAllDatasets: sinon.stub().resolves()};
3557
+ const parser2 = {syncAllDatasets: sinon.stub().resolves()};
3558
+ locusInfo.hashTreeParsers.set('url1', {parser: parser1});
3559
+ locusInfo.hashTreeParsers.set('url2', {parser: parser2});
3560
+
3561
+ await locusInfo.syncAllHashTreeDatasets();
3562
+
3563
+ assert.calledOnce(parser1.syncAllDatasets);
3564
+ assert.calledOnce(parser2.syncAllDatasets);
3565
+ });
3566
+
3567
+ it('should skip parser entries without a parser object', async () => {
3568
+ const parser1 = {syncAllDatasets: sinon.stub().resolves()};
3569
+ locusInfo.hashTreeParsers.set('url1', {parser: parser1});
3570
+ locusInfo.hashTreeParsers.set('url2', {parser: undefined});
3571
+
3572
+ await locusInfo.syncAllHashTreeDatasets();
3573
+
3574
+ assert.calledOnce(parser1.syncAllDatasets);
3575
+ });
3576
+
3577
+ it('should await each parsers syncAllDatasets sequentially', async () => {
3578
+ const callOrder = [];
3579
+ const parser1 = {syncAllDatasets: sinon.stub().callsFake(() => {
3580
+ callOrder.push('start1');
3581
+ return new Promise((resolve) => {
3582
+ setTimeout(() => {
3583
+ callOrder.push('end1');
3584
+ resolve();
3585
+ }, 100);
3586
+ });
3587
+ })};
3588
+ const parser2 = {syncAllDatasets: sinon.stub().callsFake(() => {
3589
+ callOrder.push('start2');
3590
+ return Promise.resolve();
3591
+ })};
3592
+ locusInfo.hashTreeParsers.set('url1', {parser: parser1});
3593
+ locusInfo.hashTreeParsers.set('url2', {parser: parser2});
3594
+
3595
+ const clock = sinon.useFakeTimers();
3596
+ const promise = locusInfo.syncAllHashTreeDatasets();
3597
+ // parser1 started but parser2 not yet
3598
+ assert.deepEqual(callOrder, ['start1']);
3599
+
3600
+ await clock.tickAsync(100);
3601
+ await promise;
3602
+ // parser1 finished, then parser2 started and finished
3603
+ assert.deepEqual(callOrder, ['start1', 'end1', 'start2']);
3604
+ clock.restore();
3605
+ });
3456
3606
  });
3457
3607
 
3458
3608
  describe('#LocusDeltaEvents', () => {
@@ -5001,6 +5151,31 @@ describe('plugin-meetings', () => {
5001
5151
  );
5002
5152
  assert.notCalled(getTheLocusToUpdateStub);
5003
5153
  });
5154
+
5155
+ it('should call handleLocusAPIResponse for SDK_LOCUS_FROM_SYNC_MEETINGS when hash tree parsers exist', () => {
5156
+ const fakeLocusUrl = 'http://locus-url.com';
5157
+ const fakeLocus = {url: fakeLocusUrl, fullState: {state: 'ACTIVE'}};
5158
+ const mockHashTreeParser = {
5159
+ handleMessage: sinon.stub(),
5160
+ handleLocusUpdate: sinon.stub(),
5161
+ };
5162
+ locusInfo.hashTreeParsers.set(fakeLocusUrl, {
5163
+ parser: mockHashTreeParser,
5164
+ initializedFromHashTree: true,
5165
+ });
5166
+
5167
+ sinon.stub(locusInfo, 'handleLocusDelta');
5168
+
5169
+ locusInfo.parse(mockMeeting, {
5170
+ eventType: LOCUSEVENT.SDK_LOCUS_FROM_SYNC_MEETINGS,
5171
+ locus: fakeLocus,
5172
+ });
5173
+
5174
+ // should route through handleLocusAPIResponse which passes unwrapped LocusDTO to parser
5175
+ assert.calledOnce(mockHashTreeParser.handleLocusUpdate);
5176
+ assert.notCalled(mockHashTreeParser.handleMessage);
5177
+ assert.notCalled(locusInfo.handleLocusDelta);
5178
+ });
5004
5179
  });
5005
5180
  });
5006
5181
 
@@ -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: {
@@ -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', () => {
@@ -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';