@webex/plugin-meetings 3.12.0-next.20 → 3.12.0-next.22

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.
@@ -567,6 +567,11 @@ describe('HashTreeParser', () => {
567
567
  },
568
568
  data: {info: {id: 'some-fake-locus-info'}},
569
569
  },
570
+ ],
571
+ });
572
+
573
+ assert.calledWith(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
574
+ updatedObjects: [
570
575
  {
571
576
  htMeta: {
572
577
  elementId: {
@@ -3775,7 +3780,7 @@ describe('HashTreeParser', () => {
3775
3780
  });
3776
3781
  });
3777
3782
 
3778
- describe('#resume', () => {
3783
+ describe('#resumeFromMessage', () => {
3779
3784
  const createResumeMessage = (visibleDataSets?, dataSets?) => ({
3780
3785
  locusUrl,
3781
3786
  visibleDataSetsUrl,
@@ -3802,7 +3807,7 @@ describe('HashTreeParser', () => {
3802
3807
 
3803
3808
  expect(parser.state).to.equal('stopped');
3804
3809
 
3805
- parser.resume(createResumeMessage());
3810
+ parser.resumeFromMessage(createResumeMessage());
3806
3811
 
3807
3812
  expect(parser.state).to.equal('active');
3808
3813
  });
@@ -3811,7 +3816,7 @@ describe('HashTreeParser', () => {
3811
3816
  const parser = createHashTreeParser();
3812
3817
  parser.stop();
3813
3818
 
3814
- parser.resume({
3819
+ parser.resumeFromMessage({
3815
3820
  locusUrl,
3816
3821
  visibleDataSetsUrl,
3817
3822
  dataSets: [createDataSet('main', 16, 2000)],
@@ -3830,7 +3835,7 @@ describe('HashTreeParser', () => {
3830
3835
  createDataSet('self', 2, 6000),
3831
3836
  ];
3832
3837
 
3833
- parser.resume(createResumeMessage(undefined, newDataSets));
3838
+ parser.resumeFromMessage(createResumeMessage(undefined, newDataSets));
3834
3839
 
3835
3840
  expect(Object.keys(parser.dataSets)).to.have.lengthOf(2);
3836
3841
  expect(parser.dataSets.main.leafCount).to.equal(8);
@@ -3852,7 +3857,7 @@ describe('HashTreeParser', () => {
3852
3857
  {name: 'self', url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self'},
3853
3858
  ];
3854
3859
 
3855
- parser.resume(createResumeMessage(visibleDataSets, dataSets));
3860
+ parser.resumeFromMessage(createResumeMessage(visibleDataSets, dataSets));
3856
3861
 
3857
3862
  expect(parser.dataSets.main.hashTree).to.be.instanceOf(HashTree);
3858
3863
  expect(parser.dataSets.self.hashTree).to.be.instanceOf(HashTree);
@@ -3866,7 +3871,7 @@ describe('HashTreeParser', () => {
3866
3871
  const handleMessageStub = sinon.stub(parser, 'handleMessage');
3867
3872
 
3868
3873
  const message = createResumeMessage();
3869
- parser.resume(message);
3874
+ parser.resumeFromMessage(message);
3870
3875
 
3871
3876
  assert.calledOnceWithExactly(handleMessageStub, message, 'on resume');
3872
3877
  });
@@ -3886,7 +3891,7 @@ describe('HashTreeParser', () => {
3886
3891
  {name: 'atd-unmuted', url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/atd-unmuted'},
3887
3892
  ];
3888
3893
 
3889
- parser.resume(createResumeMessage(visibleDataSets, dataSets));
3894
+ parser.resumeFromMessage(createResumeMessage(visibleDataSets, dataSets));
3890
3895
 
3891
3896
  expect(parser.visibleDataSets.some((vds) => vds.name === 'atd-unmuted')).to.be.false;
3892
3897
  expect(parser.visibleDataSets.some((vds) => vds.name === 'main')).to.be.true;
@@ -3894,6 +3899,67 @@ describe('HashTreeParser', () => {
3894
3899
  });
3895
3900
  });
3896
3901
 
3902
+ describe('#resumeFromApiResponse', () => {
3903
+ const exampleLocus = {
3904
+ participants: [],
3905
+ } as any;
3906
+
3907
+ it('should set state to active', async () => {
3908
+ const parser = createHashTreeParser();
3909
+ parser.stop();
3910
+
3911
+ expect(parser.state).to.equal('stopped');
3912
+
3913
+ sinon.stub(parser, 'initializeFromGetLociResponse').resolves();
3914
+
3915
+ await parser.resumeFromApiResponse(exampleLocus);
3916
+
3917
+ expect(parser.state).to.equal('active');
3918
+ });
3919
+
3920
+ it('should reset dataSets to empty', async () => {
3921
+ const parser = createHashTreeParser();
3922
+
3923
+ expect(Object.keys(parser.dataSets).length).to.be.greaterThan(0);
3924
+
3925
+ parser.stop();
3926
+
3927
+ sinon.stub(parser, 'initializeFromGetLociResponse').resolves();
3928
+
3929
+ await parser.resumeFromApiResponse(exampleLocus);
3930
+
3931
+ expect(parser.dataSets).to.deep.equal({});
3932
+ });
3933
+
3934
+ it('should call initializeFromGetLociResponse with the provided locus', async () => {
3935
+ const parser = createHashTreeParser();
3936
+ parser.stop();
3937
+
3938
+ const initStub = sinon.stub(parser, 'initializeFromGetLociResponse').resolves();
3939
+
3940
+ await parser.resumeFromApiResponse(exampleLocus);
3941
+
3942
+ assert.calledOnceWithExactly(initStub, exampleLocus);
3943
+ });
3944
+
3945
+ it('should propagate errors from initializeFromGetLociResponse', async () => {
3946
+ const parser = createHashTreeParser();
3947
+ parser.stop();
3948
+
3949
+ const error = new Error('initialization failed');
3950
+ const initStub = sinon.stub(parser, 'initializeFromGetLociResponse').rejects(error);
3951
+
3952
+ let caughtError: Error | undefined;
3953
+ try {
3954
+ await parser.resumeFromApiResponse(exampleLocus);
3955
+ } catch (e) {
3956
+ caughtError = e;
3957
+ }
3958
+
3959
+ expect(caughtError).to.equal(error);
3960
+ });
3961
+ });
3962
+
3897
3963
  describe('#handleLocusUpdate when stopped', () => {
3898
3964
  it('should return early without processing when parser is stopped', () => {
3899
3965
  const parser = createHashTreeParser();
@@ -3929,6 +3995,305 @@ describe('HashTreeParser', () => {
3929
3995
  });
3930
3996
  });
3931
3997
 
3998
+ describe('#syncAllDatasets', () => {
3999
+ it('should sync all datasets that have hash trees in priority order', async () => {
4000
+ const parser = createHashTreeParser();
4001
+
4002
+ // parser starts with main (leafCount=16) and self (leafCount=1) as visible datasets with hash trees
4003
+ // atd-unmuted has no hash tree (not visible)
4004
+ expect(parser.dataSets.main.hashTree).to.be.instanceOf(HashTree);
4005
+ expect(parser.dataSets.self.hashTree).to.be.instanceOf(HashTree);
4006
+
4007
+ const mainUrl = parser.dataSets.main.url;
4008
+ const selfUrl = parser.dataSets.self.url;
4009
+
4010
+ // Mock GET hashtree for main (leafCount > 1, so it does GET first)
4011
+ mockGetHashesFromLocusResponse(
4012
+ mainUrl,
4013
+ new Array(16).fill(EMPTY_HASH),
4014
+ createDataSet('main', 16, 1100)
4015
+ );
4016
+
4017
+ // Mock POST sync for main - return matching root hash so no further sync needed
4018
+ const mainSyncDataSet = createDataSet('main', 16, 1100);
4019
+ mainSyncDataSet.root = parser.dataSets.main.hashTree.getRootHash();
4020
+ mockSendSyncRequestResponse(mainUrl, {
4021
+ dataSets: [mainSyncDataSet],
4022
+ visibleDataSetsUrl,
4023
+ locusUrl,
4024
+ locusStateElements: [],
4025
+ });
4026
+
4027
+ // Mock POST sync for self (leafCount=1, skips GET hashtree)
4028
+ const selfSyncDataSet = createDataSet('self', 1, 2100);
4029
+ selfSyncDataSet.root = parser.dataSets.self.hashTree.getRootHash();
4030
+ mockSendSyncRequestResponse(selfUrl, {
4031
+ dataSets: [selfSyncDataSet],
4032
+ visibleDataSetsUrl,
4033
+ locusUrl,
4034
+ locusStateElements: [],
4035
+ });
4036
+
4037
+ await parser.syncAllDatasets();
4038
+
4039
+ // Verify GET hashtree was called for main only (not self, because leafCount=1)
4040
+ assert.calledWith(webexRequest, sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}));
4041
+ assert.neverCalledWith(webexRequest, sinon.match({method: 'GET', uri: `${selfUrl}/hashtree`}));
4042
+
4043
+ // Verify POST sync was called for both
4044
+ assert.calledWith(webexRequest, sinon.match({method: 'POST', uri: `${mainUrl}/sync`}));
4045
+ assert.calledWith(webexRequest, sinon.match({method: 'POST', uri: `${selfUrl}/sync`}));
4046
+
4047
+ // Verify main was synced before self (priority order)
4048
+ const mainSyncCallIndex = webexRequest.args.findIndex(
4049
+ (args) => args[0]?.method === 'GET' && args[0]?.uri === `${mainUrl}/hashtree`
4050
+ );
4051
+ const selfSyncCallIndex = webexRequest.args.findIndex(
4052
+ (args) => args[0]?.method === 'POST' && args[0]?.uri === `${selfUrl}/sync`
4053
+ );
4054
+ expect(mainSyncCallIndex).to.be.lessThan(selfSyncCallIndex);
4055
+
4056
+ // Verify isSyncAllInProgress is reset
4057
+ expect(parser.isSyncAllInProgress).to.be.false;
4058
+ });
4059
+
4060
+ it('should return immediately when state is stopped', async () => {
4061
+ const parser = createHashTreeParser();
4062
+ parser.stop();
4063
+
4064
+ await parser.syncAllDatasets();
4065
+
4066
+ // No sync requests should have been made (only the initial sync from constructor)
4067
+ // Reset history to clear constructor calls then verify
4068
+ const callCountBefore = webexRequest.callCount;
4069
+ await parser.syncAllDatasets();
4070
+ assert.equal(webexRequest.callCount, callCountBefore);
4071
+ });
4072
+
4073
+ it('should guard against concurrent calls', async () => {
4074
+ const parser = createHashTreeParser();
4075
+
4076
+ const mainUrl = parser.dataSets.main.url;
4077
+ const selfUrl = parser.dataSets.self.url;
4078
+
4079
+ // Use a deferred promise for the main sync to control timing
4080
+ let resolveMainSync;
4081
+ webexRequest
4082
+ .withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}))
4083
+ .returns(new Promise((resolve) => { resolveMainSync = resolve; }));
4084
+
4085
+ mockSendSyncRequestResponse(mainUrl, {
4086
+ dataSets: [createDataSet('main', 16, 1100)],
4087
+ visibleDataSetsUrl,
4088
+ locusUrl,
4089
+ locusStateElements: [],
4090
+ });
4091
+
4092
+ mockSendSyncRequestResponse(selfUrl, {
4093
+ dataSets: [createDataSet('self', 1, 2100)],
4094
+ visibleDataSetsUrl,
4095
+ locusUrl,
4096
+ locusStateElements: [],
4097
+ });
4098
+
4099
+ // Start first call
4100
+ const promise1 = parser.syncAllDatasets();
4101
+ // Start second call while first is in progress
4102
+ const promise2 = parser.syncAllDatasets();
4103
+
4104
+ // Resolve the pending request
4105
+ resolveMainSync({
4106
+ body: {
4107
+ hashes: new Array(16).fill(EMPTY_HASH),
4108
+ dataSet: createDataSet('main', 16, 1100),
4109
+ },
4110
+ });
4111
+
4112
+ await promise1;
4113
+ await promise2;
4114
+
4115
+ // GET hashtree for main should only be called once (second syncAllDatasets returned immediately)
4116
+ const getHashtreeCalls = webexRequest.args.filter(
4117
+ (args) => args[0]?.method === 'GET' && args[0]?.uri === `${mainUrl}/hashtree`
4118
+ );
4119
+ expect(getHashtreeCalls).to.have.lengthOf(1);
4120
+ });
4121
+
4122
+ it('should skip datasets that do not have a hash tree', async () => {
4123
+ // Create parser with metadata that only has main and self as visible (not atd-unmuted)
4124
+ const metadataWithoutAtd = {
4125
+ ...exampleMetadata,
4126
+ visibleDataSets: exampleMetadata.visibleDataSets.filter((ds) => ds.name !== 'atd-unmuted'),
4127
+ };
4128
+ const parser = createHashTreeParser(exampleInitialLocus, metadataWithoutAtd);
4129
+
4130
+ // atd-unmuted is in dataSets but has no hashTree (not visible)
4131
+ expect(parser.dataSets['atd-unmuted']).to.exist;
4132
+ expect(parser.dataSets['atd-unmuted'].hashTree).to.be.undefined;
4133
+
4134
+ const atdUrl = parser.dataSets['atd-unmuted'].url;
4135
+ const mainUrl = parser.dataSets.main.url;
4136
+ const selfUrl = parser.dataSets.self.url;
4137
+
4138
+ mockGetHashesFromLocusResponse(
4139
+ mainUrl,
4140
+ new Array(16).fill(EMPTY_HASH),
4141
+ createDataSet('main', 16, 1100)
4142
+ );
4143
+
4144
+ const mainSyncDs = createDataSet('main', 16, 1100);
4145
+ mainSyncDs.root = parser.dataSets.main.hashTree.getRootHash();
4146
+ mockSendSyncRequestResponse(mainUrl, {
4147
+ dataSets: [mainSyncDs],
4148
+ visibleDataSetsUrl,
4149
+ locusUrl,
4150
+ locusStateElements: [],
4151
+ });
4152
+
4153
+ const selfSyncDs = createDataSet('self', 1, 2100);
4154
+ selfSyncDs.root = parser.dataSets.self.hashTree.getRootHash();
4155
+ mockSendSyncRequestResponse(selfUrl, {
4156
+ dataSets: [selfSyncDs],
4157
+ visibleDataSetsUrl,
4158
+ locusUrl,
4159
+ locusStateElements: [],
4160
+ });
4161
+
4162
+ await parser.syncAllDatasets();
4163
+
4164
+ // No requests should have been made for atd-unmuted
4165
+ assert.neverCalledWith(webexRequest, sinon.match({uri: sinon.match(atdUrl)}));
4166
+ });
4167
+ });
4168
+
4169
+ describe('#handleMessage sync queue', () => {
4170
+ it('should deduplicate: not sync the same dataset twice when enqueued multiple times', async () => {
4171
+ const parser = createHashTreeParser();
4172
+
4173
+ const mainUrl = parser.dataSets.main.url;
4174
+
4175
+ // Setup mocks before triggering syncs
4176
+ mockGetHashesFromLocusResponse(
4177
+ mainUrl,
4178
+ new Array(16).fill(EMPTY_HASH),
4179
+ createDataSet('main', 16, 1101)
4180
+ );
4181
+
4182
+ const mainSyncDs = createDataSet('main', 16, 1101);
4183
+ mainSyncDs.root = parser.dataSets.main.hashTree.getRootHash();
4184
+ mockSendSyncRequestResponse(mainUrl, {
4185
+ dataSets: [mainSyncDs],
4186
+ visibleDataSetsUrl,
4187
+ locusUrl,
4188
+ locusStateElements: [],
4189
+ });
4190
+
4191
+ // Send two heartbeat messages (no locusStateElements) with different root hashes for main
4192
+ parser.handleMessage(createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'), 'first');
4193
+ parser.handleMessage(createHeartbeatMessage('main', 16, 1101, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa2'), 'second');
4194
+
4195
+ // The second call resets the timer. After 1000ms, only one sync fires.
4196
+ await clock.tickAsync(1000);
4197
+
4198
+ // Only one GET hashtree call should have been made for main
4199
+ const getHashtreeCalls = webexRequest.args.filter(
4200
+ (args) => args[0]?.method === 'GET' && args[0]?.uri === `${mainUrl}/hashtree`
4201
+ );
4202
+ expect(getHashtreeCalls).to.have.lengthOf(1);
4203
+ });
4204
+
4205
+ it('should stop processing the sync queue when parser is stopped mid-queue', async () => {
4206
+ const parser = createHashTreeParser();
4207
+
4208
+ const mainUrl = parser.dataSets.main.url;
4209
+ const selfUrl = parser.dataSets.self.url;
4210
+
4211
+ // Mock main GET hashtree with a deferred promise so we can control when it resolves
4212
+ let resolveMainHashtree;
4213
+ webexRequest
4214
+ .withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}))
4215
+ .callsFake(() => new Promise((resolve) => { resolveMainHashtree = resolve; }));
4216
+
4217
+ // Send a heartbeat message that triggers sync timers for both main and self
4218
+ parser.handleMessage(
4219
+ createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
4220
+ 'trigger main sync'
4221
+ );
4222
+ parser.handleMessage(
4223
+ createHeartbeatMessage('self', 1, 2100, 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1'),
4224
+ 'trigger self sync'
4225
+ );
4226
+
4227
+ // Fire the timers - main sync starts (calls GET hashtree, which blocks)
4228
+ await clock.tickAsync(1000);
4229
+
4230
+ // Stop the parser while main sync is in progress
4231
+ parser.stop();
4232
+
4233
+ // Resolve the pending main GET request
4234
+ resolveMainHashtree({
4235
+ body: {
4236
+ hashes: new Array(16).fill(EMPTY_HASH),
4237
+ dataSet: createDataSet('main', 16, 1100),
4238
+ },
4239
+ });
4240
+
4241
+ await clock.tickAsync(0);
4242
+
4243
+ // Self sync should NOT have been triggered because parser was stopped
4244
+ assert.neverCalledWith(webexRequest, sinon.match({method: 'POST', uri: `${selfUrl}/sync`}));
4245
+ assert.neverCalledWith(webexRequest, sinon.match({method: 'GET', uri: `${selfUrl}/hashtree`}));
4246
+ });
4247
+ });
4248
+
4249
+ describe('#stop sync queue', () => {
4250
+ it('should clear the syncQueue when stopped so remaining queued items are not processed', async () => {
4251
+ const parser = createHashTreeParser();
4252
+
4253
+ const mainUrl = parser.dataSets.main.url;
4254
+ const selfUrl = parser.dataSets.self.url;
4255
+
4256
+ // Mock main GET hashtree with a deferred promise so we can control when it resolves
4257
+ let resolveMainHashtree;
4258
+ webexRequest
4259
+ .withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}))
4260
+ .callsFake(() => new Promise((resolve) => { resolveMainHashtree = resolve; }));
4261
+
4262
+ // Enqueue syncs for both main and self by sending heartbeat messages
4263
+ parser.handleMessage(
4264
+ createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
4265
+ 'trigger main sync'
4266
+ );
4267
+ parser.handleMessage(
4268
+ createHeartbeatMessage('self', 1, 2100, 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1'),
4269
+ 'trigger self sync'
4270
+ );
4271
+
4272
+ // Fire the timers - main sync starts and blocks on GET hashtree
4273
+ await clock.tickAsync(1000);
4274
+
4275
+ // Verify that self is still in the queue (main is being processed, self is waiting)
4276
+ // Now stop the parser - this should clear the syncQueue
4277
+ parser.stop();
4278
+
4279
+ // Resolve the pending main GET request so the in-flight sync can finish
4280
+ resolveMainHashtree({
4281
+ body: {
4282
+ hashes: new Array(16).fill(EMPTY_HASH),
4283
+ dataSet: createDataSet('main', 16, 1100),
4284
+ },
4285
+ });
4286
+
4287
+ await clock.tickAsync(0);
4288
+
4289
+ // Self should never have been synced because stop() cleared the queue
4290
+ const selfGetCalls = webexRequest.args.filter(
4291
+ (args) => args[0]?.method === 'GET' && args[0]?.uri === `${selfUrl}/hashtree`
4292
+ );
4293
+ expect(selfGetCalls).to.have.lengthOf(0);
4294
+ });
4295
+ });
4296
+
3932
4297
  describe('#cleanUp', () => {
3933
4298
  it('should stop the parser, clear all timers and clear all dataSets', () => {
3934
4299
  const parser = createHashTreeParser();
@@ -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