@webex/plugin-meetings 3.10.0-next.9 → 3.10.0-webex-services-ready.1

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 (73) hide show
  1. package/dist/breakouts/breakout.js +1 -1
  2. package/dist/breakouts/index.js +1 -1
  3. package/dist/constants.js +11 -3
  4. package/dist/constants.js.map +1 -1
  5. package/dist/hashTree/constants.js +20 -0
  6. package/dist/hashTree/constants.js.map +1 -0
  7. package/dist/hashTree/hashTree.js +515 -0
  8. package/dist/hashTree/hashTree.js.map +1 -0
  9. package/dist/hashTree/hashTreeParser.js +1266 -0
  10. package/dist/hashTree/hashTreeParser.js.map +1 -0
  11. package/dist/hashTree/types.js +21 -0
  12. package/dist/hashTree/types.js.map +1 -0
  13. package/dist/hashTree/utils.js +48 -0
  14. package/dist/hashTree/utils.js.map +1 -0
  15. package/dist/interpretation/index.js +1 -1
  16. package/dist/interpretation/siLanguage.js +1 -1
  17. package/dist/locus-info/index.js +511 -48
  18. package/dist/locus-info/index.js.map +1 -1
  19. package/dist/locus-info/types.js +7 -0
  20. package/dist/locus-info/types.js.map +1 -0
  21. package/dist/meeting/index.js +41 -15
  22. package/dist/meeting/index.js.map +1 -1
  23. package/dist/meeting/util.js +1 -0
  24. package/dist/meeting/util.js.map +1 -1
  25. package/dist/meetings/index.js +112 -70
  26. package/dist/meetings/index.js.map +1 -1
  27. package/dist/metrics/constants.js +3 -1
  28. package/dist/metrics/constants.js.map +1 -1
  29. package/dist/reachability/clusterReachability.js +44 -358
  30. package/dist/reachability/clusterReachability.js.map +1 -1
  31. package/dist/reachability/reachability.types.js +14 -1
  32. package/dist/reachability/reachability.types.js.map +1 -1
  33. package/dist/reachability/reachabilityPeerConnection.js +445 -0
  34. package/dist/reachability/reachabilityPeerConnection.js.map +1 -0
  35. package/dist/types/constants.d.ts +26 -21
  36. package/dist/types/hashTree/constants.d.ts +8 -0
  37. package/dist/types/hashTree/hashTree.d.ts +129 -0
  38. package/dist/types/hashTree/hashTreeParser.d.ts +260 -0
  39. package/dist/types/hashTree/types.d.ts +25 -0
  40. package/dist/types/hashTree/utils.d.ts +9 -0
  41. package/dist/types/locus-info/index.d.ts +91 -42
  42. package/dist/types/locus-info/types.d.ts +46 -0
  43. package/dist/types/meeting/index.d.ts +22 -9
  44. package/dist/types/meetings/index.d.ts +9 -2
  45. package/dist/types/metrics/constants.d.ts +2 -0
  46. package/dist/types/reachability/clusterReachability.d.ts +10 -88
  47. package/dist/types/reachability/reachability.types.d.ts +12 -1
  48. package/dist/types/reachability/reachabilityPeerConnection.d.ts +111 -0
  49. package/dist/webinar/index.js +1 -1
  50. package/package.json +22 -21
  51. package/src/constants.ts +13 -1
  52. package/src/hashTree/constants.ts +9 -0
  53. package/src/hashTree/hashTree.ts +463 -0
  54. package/src/hashTree/hashTreeParser.ts +1161 -0
  55. package/src/hashTree/types.ts +30 -0
  56. package/src/hashTree/utils.ts +42 -0
  57. package/src/locus-info/index.ts +556 -85
  58. package/src/locus-info/types.ts +48 -0
  59. package/src/meeting/index.ts +58 -26
  60. package/src/meeting/util.ts +1 -0
  61. package/src/meetings/index.ts +104 -51
  62. package/src/metrics/constants.ts +2 -0
  63. package/src/reachability/clusterReachability.ts +50 -347
  64. package/src/reachability/reachability.types.ts +15 -1
  65. package/src/reachability/reachabilityPeerConnection.ts +416 -0
  66. package/test/unit/spec/hashTree/hashTree.ts +655 -0
  67. package/test/unit/spec/hashTree/hashTreeParser.ts +1532 -0
  68. package/test/unit/spec/hashTree/utils.ts +103 -0
  69. package/test/unit/spec/locus-info/index.js +667 -1
  70. package/test/unit/spec/meeting/index.js +91 -20
  71. package/test/unit/spec/meeting/utils.js +77 -0
  72. package/test/unit/spec/meetings/index.js +71 -26
  73. package/test/unit/spec/reachability/clusterReachability.ts +281 -138
@@ -0,0 +1,1532 @@
1
+ import HashTreeParser, {
2
+ LocusInfoUpdateType,
3
+ } from '@webex/plugin-meetings/src/hashTree/hashTreeParser';
4
+ import HashTree from '@webex/plugin-meetings/src/hashTree/hashTree';
5
+ import {expect} from '@webex/test-helper-chai';
6
+ import sinon from 'sinon';
7
+ import {assert} from '@webex/test-helper-chai';
8
+
9
+ const exampleInitialLocus = {
10
+ dataSets: [
11
+ {
12
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main',
13
+ root: '9bb9d5a911a74d53a915b4dfbec7329f',
14
+ version: 1000,
15
+ leafCount: 16,
16
+ name: 'main',
17
+ idleMs: 1000,
18
+ backoff: {maxMs: 1000, exponent: 2},
19
+ },
20
+ {
21
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
22
+ root: '5b8cc7ffda1346d2bfb1c0b60b8ab601',
23
+ version: 2000,
24
+ leafCount: 1,
25
+ name: 'self',
26
+ idleMs: 1000,
27
+ backoff: {maxMs: 1000, exponent: 2},
28
+ },
29
+ {
30
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/atd-unmuted',
31
+ root: '9279d2e149da43a1b8e2cd7cbf77f9f0',
32
+ version: 3000,
33
+ leafCount: 16,
34
+ name: 'atd-unmuted',
35
+ idleMs: 1000,
36
+ backoff: {maxMs: 1000, exponent: 2},
37
+ },
38
+ ],
39
+ locus: {
40
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f',
41
+ htMeta: {
42
+ elementId: {
43
+ type: 'locus',
44
+ id: 0,
45
+ version: 200,
46
+ },
47
+ dataSetNames: ['main'],
48
+ },
49
+ participants: [
50
+ {
51
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/11941033',
52
+ person: {},
53
+ htMeta: {
54
+ elementId: {
55
+ type: 'participant',
56
+ id: 14,
57
+ version: 300,
58
+ },
59
+ dataSetNames: ['atd-active', 'attendees', 'atd-unmuted'],
60
+ },
61
+ },
62
+ ],
63
+ self: {
64
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/11941033',
65
+ visibleDataSets: ['main', 'self', 'atd-unmuted'],
66
+ person: {},
67
+ htMeta: {
68
+ elementId: {
69
+ type: 'self',
70
+ id: 4,
71
+ version: 100,
72
+ },
73
+ dataSetNames: ['self'],
74
+ },
75
+ },
76
+ },
77
+ };
78
+
79
+ function createDataSet(name: string, leafCount: number, version = 1) {
80
+ return {
81
+ url: `https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/${name}`,
82
+ root: '0'.repeat(32),
83
+ version,
84
+ leafCount,
85
+ name,
86
+ idleMs: 1000,
87
+ backoff: {maxMs: 1000, exponent: 2},
88
+ };
89
+ }
90
+
91
+ // Helper function to setup a webexRequest mock for getAllDataSetsMetadata
92
+ function mockGetAllDataSetsMetadata(webexRequest: sinon.SinonStub, url: string, dataSets: any[]) {
93
+ webexRequest
94
+ .withArgs(
95
+ sinon.match({
96
+ method: 'GET',
97
+ uri: url,
98
+ })
99
+ )
100
+ .resolves({
101
+ body: {dataSets},
102
+ });
103
+ }
104
+
105
+ // Helper function to setup a webexRequest mock for sync requests
106
+ function mockSyncRequest(webexRequest: sinon.SinonStub, datasetUrl: string, response: any = null) {
107
+ const stub = webexRequest.withArgs(
108
+ sinon.match({
109
+ method: 'POST',
110
+ uri: `${datasetUrl}/sync`,
111
+ })
112
+ );
113
+
114
+ if (response === null) {
115
+ stub.resolves({body: {}});
116
+ } else {
117
+ stub.resolves({body: response});
118
+ }
119
+ }
120
+
121
+ describe('HashTreeParser', () => {
122
+ const visibleDataSetsUrl = 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/visibleDataSets';
123
+ const locusUrl = 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f';
124
+
125
+ let clock;
126
+ let webexRequest: sinon.SinonStub;
127
+ let callback: sinon.SinonStub;
128
+ let mathRandomStub: sinon.SinonStub;
129
+
130
+ beforeEach(() => {
131
+ clock = sinon.useFakeTimers();
132
+ webexRequest = sinon.stub();
133
+ callback = sinon.stub();
134
+ mathRandomStub = sinon.stub(Math, 'random').returns(0);
135
+ });
136
+ afterEach(() => {
137
+ clock.restore();
138
+ mathRandomStub.restore();
139
+ });
140
+
141
+ // Helper to create a HashTreeParser instance with common defaults
142
+ function createHashTreeParser(initialLocus: any = exampleInitialLocus) {
143
+ return new HashTreeParser({
144
+ initialLocus,
145
+ webexRequest,
146
+ locusInfoUpdateCallback: callback,
147
+ debugId: 'test',
148
+ });
149
+ }
150
+
151
+ // Helper to create a heartbeat message (without locusStateElements)
152
+ function createHeartbeatMessage(
153
+ dataSetName: string,
154
+ leafCount: number,
155
+ version: number,
156
+ rootHash: string
157
+ ) {
158
+ return {
159
+ dataSets: [
160
+ {
161
+ ...createDataSet(dataSetName, leafCount, version),
162
+ root: rootHash,
163
+ },
164
+ ],
165
+ visibleDataSetsUrl,
166
+ locusUrl,
167
+ };
168
+ }
169
+
170
+ // Helper to mock getHashesFromLocus response
171
+ function mockGetHashesFromLocusResponse(dataSetUrl: string, hashes: string[], dataSetInfo: any) {
172
+ webexRequest
173
+ .withArgs(
174
+ sinon.match({
175
+ method: 'GET',
176
+ uri: `${dataSetUrl}/hashtree`,
177
+ })
178
+ )
179
+ .resolves({
180
+ body: {
181
+ hashes,
182
+ dataSet: dataSetInfo,
183
+ },
184
+ });
185
+ }
186
+
187
+ // Helper to mock sendSyncRequestToLocus response
188
+ function mockSendSyncRequestResponse(dataSetUrl: string, response: any) {
189
+ webexRequest
190
+ .withArgs(
191
+ sinon.match({
192
+ method: 'POST',
193
+ uri: `${dataSetUrl}/sync`,
194
+ })
195
+ )
196
+ .resolves({
197
+ body: response,
198
+ });
199
+ }
200
+ it('should correctly initialize trees from initialLocus data', () => {
201
+ const parser = createHashTreeParser();
202
+
203
+ // Check that the correct number of trees are created
204
+ expect(Object.keys(parser.dataSets).length).to.equal(3);
205
+
206
+ // Verify the 'main' tree
207
+ const mainTree = parser.dataSets.main.hashTree;
208
+ expect(mainTree).to.be.instanceOf(HashTree);
209
+ const expectedMainLeaves = new Array(16).fill(null).map(() => ({}));
210
+ expectedMainLeaves[0 % 16] = {locus: {0: {type: 'locus', id: 0, version: 200}}};
211
+ expect(mainTree.leaves).to.deep.equal(expectedMainLeaves);
212
+ expect(mainTree.numLeaves).to.equal(16);
213
+
214
+ // Verify the 'self' tree
215
+ const selfTree = parser.dataSets.self.hashTree;
216
+ expect(selfTree).to.be.instanceOf(HashTree);
217
+ const expectedSelfLeaves = new Array(1).fill(null).map(() => ({}));
218
+ expectedSelfLeaves[4 % 1] = {self: {4: {type: 'self', id: 4, version: 100}}};
219
+ expect(selfTree.leaves).to.deep.equal(expectedSelfLeaves);
220
+ expect(selfTree.numLeaves).to.equal(1);
221
+
222
+ // Verify the 'atd-unmuted' tree
223
+ const atdUnmutedTree = parser.dataSets['atd-unmuted'].hashTree;
224
+ expect(atdUnmutedTree).to.be.instanceOf(HashTree);
225
+ const expectedAtdUnmutedLeaves = new Array(16).fill(null).map(() => ({}));
226
+ expectedAtdUnmutedLeaves[14 % 16] = {
227
+ participant: {14: {type: 'participant', id: 14, version: 300}},
228
+ };
229
+ expect(atdUnmutedTree.leaves).to.deep.equal(expectedAtdUnmutedLeaves);
230
+ expect(atdUnmutedTree.numLeaves).to.equal(16);
231
+
232
+ // Ensure no other trees were created
233
+ expect(parser.dataSets['atd-active']).to.be.undefined;
234
+ expect(parser.dataSets.attendees).to.be.undefined;
235
+ });
236
+
237
+ it('should handle datasets with no corresponding metadata found', () => {
238
+ const modifiedLocus = JSON.parse(JSON.stringify(exampleInitialLocus));
239
+ // Remove a participant to simulate missing data for 'atd-unmuted'
240
+ modifiedLocus.locus.participants = [];
241
+ // Add a new dataset that won't have corresponding metadata
242
+ modifiedLocus.dataSets.push({
243
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/empty-set',
244
+ root: 'f00f00f00f00f00f00f00f00f00f00f0',
245
+ version: 5000,
246
+ leafCount: 4,
247
+ name: 'empty-set',
248
+ });
249
+
250
+ const parser = createHashTreeParser(modifiedLocus);
251
+
252
+ expect(Object.keys(parser.dataSets).length).to.equal(4); // main, self, atd-unmuted (now empty), empty-set
253
+
254
+ // 'main' and 'self' should be populated as before
255
+ const mainTree = parser.dataSets.main.hashTree;
256
+ const expectedMainLeaves = new Array(16).fill(null).map(() => ({}));
257
+ expectedMainLeaves[0 % 16] = {locus: {0: {type: 'locus', id: 0, version: 200}}};
258
+ expect(mainTree.leaves).to.deep.equal(expectedMainLeaves);
259
+ expect(mainTree.numLeaves).to.equal(16);
260
+
261
+ const selfTree = parser.dataSets.self.hashTree;
262
+ const expectedSelfLeaves = new Array(1).fill(null).map(() => ({}));
263
+ expectedSelfLeaves[4 % 1] = {self: {4: {type: 'self', id: 4, version: 100}}};
264
+ expect(selfTree.leaves).to.deep.equal(expectedSelfLeaves);
265
+ expect(selfTree.numLeaves).to.equal(1);
266
+
267
+ // 'atd-unmuted' metadata was removed from locus, so leaves should be empty
268
+ const atdUnmutedTree = parser.dataSets['atd-unmuted'].hashTree;
269
+ expect(atdUnmutedTree).to.be.instanceOf(HashTree);
270
+ const expectedAtdUnmutedEmptyLeaves = new Array(16).fill(null).map(() => ({}));
271
+ expect(atdUnmutedTree.leaves).to.deep.equal(expectedAtdUnmutedEmptyLeaves);
272
+ expect(atdUnmutedTree.numLeaves).to.equal(16); // leafCount from dataSet definition
273
+
274
+ // 'empty-set' was added to dataSets but has no metadata in locus and is not among visibleDataSets
275
+ // so an entry for it should exist, but hashTree shouldn't be created
276
+ const emptySet = parser.dataSets['empty-set'];
277
+ expect(emptySet.hashTree).to.be.undefined;
278
+ });
279
+
280
+ // helper method, needed because both initializeFromMessage and initializeFromGetLociResponse
281
+ // do almost exactly the same thing
282
+ const testInitializationOfDatasetsAndHashTrees = async (testCallback) => {
283
+ // Create a parser with minimal initial data
284
+ const minimalInitialLocus = {
285
+ dataSets: [],
286
+ locus: {
287
+ self: {
288
+ visibleDataSets: ['main', 'self'],
289
+ },
290
+ },
291
+ };
292
+
293
+ const hashTreeParser = createHashTreeParser(minimalInitialLocus);
294
+
295
+ // Setup the datasets that will be returned from getAllDataSetsMetadata
296
+ const mainDataSet = createDataSet('main', 16, 1100);
297
+ const selfDataSet = createDataSet('self', 1, 2100);
298
+ const invisibleDataSet = createDataSet('invisible', 4, 4000);
299
+
300
+ mockGetAllDataSetsMetadata(webexRequest, visibleDataSetsUrl, [
301
+ mainDataSet,
302
+ selfDataSet,
303
+ invisibleDataSet,
304
+ ]);
305
+
306
+ // Mock sync requests for visible datasets with some updated objects
307
+ const mainSyncResponse = {
308
+ dataSets: [mainDataSet],
309
+ visibleDataSetsUrl,
310
+ locusUrl,
311
+ locusStateElements: [
312
+ {
313
+ htMeta: {
314
+ elementId: {
315
+ type: 'locus',
316
+ id: 1,
317
+ version: 210,
318
+ },
319
+ dataSetNames: ['main'],
320
+ },
321
+ data: {info: {id: 'some-fake-locus-info'}},
322
+ },
323
+ ],
324
+ };
325
+
326
+ const selfSyncResponse = {
327
+ dataSets: [selfDataSet],
328
+ visibleDataSetsUrl,
329
+ locusUrl,
330
+ locusStateElements: [
331
+ {
332
+ htMeta: {
333
+ elementId: {
334
+ type: 'self',
335
+ id: 2,
336
+ version: 110,
337
+ },
338
+ dataSetNames: ['self'],
339
+ },
340
+ data: {person: {name: 'fake self name'}},
341
+ },
342
+ ],
343
+ };
344
+
345
+ mockSyncRequest(webexRequest, mainDataSet.url, mainSyncResponse);
346
+ mockSyncRequest(webexRequest, selfDataSet.url, selfSyncResponse);
347
+
348
+ // call the callback that actually calls the function being tested
349
+ await testCallback(hashTreeParser);
350
+
351
+ // Verify getAllDataSetsMetadata was called with correct URL
352
+ assert.calledWith(
353
+ webexRequest,
354
+ sinon.match({
355
+ method: 'GET',
356
+ uri: visibleDataSetsUrl,
357
+ })
358
+ );
359
+
360
+ // Verify all datasets are added to dataSets
361
+ expect(hashTreeParser.dataSets.main).to.exist;
362
+ expect(hashTreeParser.dataSets.self).to.exist;
363
+ expect(hashTreeParser.dataSets.invisible).to.exist;
364
+
365
+ // Verify hash trees are created only for visible datasets
366
+ expect(hashTreeParser.dataSets.main.hashTree).to.be.instanceOf(HashTree);
367
+ expect(hashTreeParser.dataSets.self.hashTree).to.be.instanceOf(HashTree);
368
+ expect(hashTreeParser.dataSets.invisible.hashTree).to.be.undefined;
369
+
370
+ // Verify hash trees have correct leaf counts
371
+ expect(hashTreeParser.dataSets.main.hashTree.numLeaves).to.equal(16);
372
+ expect(hashTreeParser.dataSets.self.hashTree.numLeaves).to.equal(1);
373
+
374
+ // Verify sync requests were sent for visible datasets
375
+ assert.calledWith(
376
+ webexRequest,
377
+ sinon.match({
378
+ method: 'POST',
379
+ uri: `${mainDataSet.url}/sync`,
380
+ })
381
+ );
382
+ assert.calledWith(
383
+ webexRequest,
384
+ sinon.match({
385
+ method: 'POST',
386
+ uri: `${selfDataSet.url}/sync`,
387
+ })
388
+ );
389
+
390
+ // and no requests for hashes were sent
391
+ assert.neverCalledWith(
392
+ webexRequest,
393
+ sinon.match({
394
+ method: 'GET',
395
+ uri: `${mainDataSet.url}/hashtree`,
396
+ })
397
+ );
398
+ assert.neverCalledWith(
399
+ webexRequest,
400
+ sinon.match({
401
+ method: 'GET',
402
+ uri: `${selfDataSet.url}/hashtree`,
403
+ })
404
+ );
405
+
406
+ // Verify sync request was NOT sent for invisible dataset
407
+ assert.neverCalledWith(
408
+ webexRequest,
409
+ sinon.match({
410
+ method: 'POST',
411
+ uri: `${invisibleDataSet.url}/sync`,
412
+ })
413
+ );
414
+
415
+ // Verify callback was called with OBJECTS_UPDATED and correct updatedObjects list
416
+ assert.calledWith(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
417
+ updatedObjects: [
418
+ {
419
+ htMeta: {
420
+ elementId: {
421
+ type: 'locus',
422
+ id: 1,
423
+ version: 210,
424
+ },
425
+ dataSetNames: ['main'],
426
+ },
427
+ data: {info: {id: 'some-fake-locus-info'}},
428
+ },
429
+ {
430
+ htMeta: {
431
+ elementId: {
432
+ type: 'self',
433
+ id: 2,
434
+ version: 110,
435
+ },
436
+ dataSetNames: ['self'],
437
+ },
438
+ data: {person: {name: 'fake self name'}},
439
+ },
440
+ ],
441
+ });
442
+
443
+ // verify that sync timers are set for visible datasets
444
+ expect(hashTreeParser.dataSets.main.timer).to.not.be.undefined;
445
+ expect(hashTreeParser.dataSets.self.timer).to.not.be.undefined;
446
+ // and not for invisible dataset
447
+ expect(hashTreeParser.dataSets.invisible.timer).to.be.undefined;
448
+ };
449
+
450
+ describe('#initializeFromMessage', () => {
451
+ it('fetches datasets metadata and initializes hash trees for visible data sets', async () => {
452
+ await testInitializationOfDatasetsAndHashTrees(async (hashTreeParser: HashTreeParser) => {
453
+ await hashTreeParser.initializeFromMessage({
454
+ dataSets: [],
455
+ visibleDataSetsUrl,
456
+ locusUrl,
457
+ });
458
+ });
459
+ });
460
+ });
461
+
462
+ describe('#initializeFromGetLociResponse', () => {
463
+ it('does nothing if url for visibleDataSets is missing from locus', async () => {
464
+ const parser = createHashTreeParser({dataSets: [], locus: {}});
465
+
466
+ await parser.initializeFromGetLociResponse({participants: []});
467
+
468
+ assert.notCalled(webexRequest);
469
+ assert.notCalled(callback);
470
+ });
471
+ it('fetches datasets metadata and initializes hash trees for visible data sets', async () => {
472
+ await testInitializationOfDatasetsAndHashTrees(async (hashTreeParser: HashTreeParser) => {
473
+ await hashTreeParser.initializeFromGetLociResponse({
474
+ links: {
475
+ resources: {
476
+ visibleDataSets: {
477
+ url: visibleDataSetsUrl,
478
+ },
479
+ },
480
+ },
481
+ participants: [],
482
+ });
483
+ });
484
+ });
485
+ });
486
+
487
+ describe('#handleLocusUpdate', () => {
488
+ it('updates hash trees based on provided new locus', () => {
489
+ const parser = createHashTreeParser();
490
+
491
+ const mainPutItemsSpy = sinon
492
+ .spy(parser.dataSets.main.hashTree, 'putItems');
493
+ const selfPutItemsSpy = sinon
494
+ .spy(parser.dataSets.self.hashTree, 'putItems');
495
+ const atdUnmutedPutItemsSpy = sinon
496
+ .spy(parser.dataSets['atd-unmuted'].hashTree, 'putItems');
497
+
498
+ // Create a locus update with new htMeta information for some things
499
+ const locusUpdate = {
500
+ dataSets: [
501
+ createDataSet('main', 16, 1100),
502
+ createDataSet('self', 1, 2100),
503
+ createDataSet('atd-unmuted', 16, 3100),
504
+ ],
505
+ locus: {
506
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f',
507
+ htMeta: {
508
+ elementId: {
509
+ type: 'locus',
510
+ id: 0,
511
+ version: 210, // incremented version
512
+ },
513
+ dataSetNames: ['main'],
514
+ },
515
+ participants: [
516
+ {
517
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/11941033',
518
+ person: {},
519
+ htMeta: {
520
+ elementId: {
521
+ type: 'participant',
522
+ id: 14,
523
+ version: 310, // incremented version
524
+ },
525
+ dataSetNames: ['atd-unmuted'],
526
+ },
527
+ },
528
+ {
529
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/22222222',
530
+ person: {},
531
+ htMeta: {
532
+ elementId: {
533
+ type: 'participant',
534
+ id: 15,
535
+ version: 311, // new participant
536
+ },
537
+ dataSetNames: ['atd-unmuted'],
538
+ },
539
+ },
540
+ ],
541
+ self: {
542
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/11941033',
543
+ visibleDataSets: ['main', 'self', 'atd-unmuted'],
544
+ person: {},
545
+ htMeta: {
546
+ elementId: {
547
+ type: 'self',
548
+ id: 4,
549
+ version: 100, // same version
550
+ },
551
+ dataSetNames: ['self'],
552
+ },
553
+ },
554
+ },
555
+ };
556
+
557
+ // Call handleLocusUpdate
558
+ parser.handleLocusUpdate(locusUpdate);
559
+
560
+ // Verify putItems was called on main hash tree with correct data
561
+ assert.calledOnceWithExactly(mainPutItemsSpy, [{type: 'locus', id: 0, version: 210}]);
562
+
563
+ // Verify putItems was called on self hash tree with correct data
564
+ assert.calledOnceWithExactly(selfPutItemsSpy, [{type: 'self', id: 4, version: 100}]);
565
+
566
+ // Verify putItems was called on atd-unmuted hash tree with correct data (2 participants)
567
+ assert.calledOnceWithExactly(atdUnmutedPutItemsSpy, [
568
+ {type: 'participant', id: 14, version: 310},
569
+ {type: 'participant', id: 15, version: 311},
570
+ ]);
571
+
572
+ // check that the datasets metadata has been updated
573
+ expect(parser.dataSets.main.version).to.equal(1100);
574
+ expect(parser.dataSets.self.version).to.equal(2100);
575
+ expect(parser.dataSets['atd-unmuted'].version).to.equal(3100);
576
+
577
+ assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
578
+ updatedObjects: [
579
+ {
580
+ htMeta: {
581
+ elementId: {
582
+ type: 'locus',
583
+ id: 0,
584
+ version: 210,
585
+ },
586
+ dataSetNames: ['main'],
587
+ },
588
+ data: {
589
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f',
590
+ htMeta: {
591
+ elementId: {
592
+ type: 'locus',
593
+ id: 0,
594
+ version: 210,
595
+ },
596
+ dataSetNames: ['main'],
597
+ },
598
+ participants: [],
599
+ },
600
+ },
601
+ {
602
+ htMeta: {
603
+ elementId: {
604
+ type: 'participant',
605
+ id: 14,
606
+ version: 310,
607
+ },
608
+ dataSetNames: ['atd-unmuted'],
609
+ },
610
+ data: {
611
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/11941033',
612
+ person: {},
613
+ htMeta: {
614
+ elementId: {
615
+ type: 'participant',
616
+ id: 14,
617
+ version: 310,
618
+ },
619
+ dataSetNames: ['atd-unmuted'],
620
+ },
621
+ },
622
+ },
623
+ {
624
+ htMeta: {
625
+ elementId: {
626
+ type: 'participant',
627
+ id: 15,
628
+ version: 311,
629
+ },
630
+ dataSetNames: ['atd-unmuted'],
631
+ },
632
+ data: {
633
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/22222222',
634
+ person: {},
635
+ htMeta: {
636
+ elementId: {
637
+ type: 'participant',
638
+ id: 15,
639
+ version: 311,
640
+ },
641
+ dataSetNames: ['atd-unmuted'],
642
+ },
643
+ },
644
+ },
645
+ // self missing, because it had the same version, so no update
646
+ ],
647
+ });
648
+ });
649
+
650
+ it('handles unknown datasets gracefully', () => {
651
+ const parser = createHashTreeParser();
652
+
653
+ const mainPutItemsSpy = sinon.spy(parser.dataSets.main.hashTree, 'putItems');
654
+
655
+ // Create a locus update with data for an unknown dataset
656
+ const locusUpdate = {
657
+ dataSets: [createDataSet('main', 16)],
658
+ locus: {
659
+ htMeta: {
660
+ elementId: {
661
+ type: 'locus',
662
+ id: 0,
663
+ version: 201,
664
+ },
665
+ dataSetNames: ['main'],
666
+ },
667
+ someNewData: 'value',
668
+ unknownData: {
669
+ htMeta: {
670
+ elementId: {
671
+ type: 'UNKNOWN',
672
+ id: 99,
673
+ version: 999,
674
+ },
675
+ dataSetNames: ['unknown-dataset'], // dataset that doesn't exist
676
+ },
677
+ },
678
+ },
679
+ };
680
+
681
+ // Call handleLocusUpdate - should not throw
682
+ parser.handleLocusUpdate(locusUpdate);
683
+
684
+ // Verify putItems was still called for known dataset
685
+ assert.calledOnceWithExactly(mainPutItemsSpy, [{type: 'locus', id: 0, version: 201}]);
686
+
687
+ // Verify callback was called only for known dataset
688
+ assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
689
+ updatedObjects: [
690
+ {
691
+ htMeta: {
692
+ elementId: {
693
+ type: 'locus',
694
+ id: 0,
695
+ version: 201,
696
+ },
697
+ dataSetNames: ['main'],
698
+ },
699
+ data: {
700
+ someNewData: 'value',
701
+ htMeta: {
702
+ elementId: {
703
+ type: 'locus',
704
+ id: 0,
705
+ version: 201,
706
+ },
707
+ dataSetNames: ['main'],
708
+ },
709
+ },
710
+ },
711
+ ],
712
+ });
713
+ });
714
+ });
715
+
716
+ describe('#handleMessage', () => {
717
+ it('handles root hash heartbeat message correctly', async () => {
718
+ const parser = createHashTreeParser();
719
+
720
+ // Step 1: Send a normal message with locusStateElements to start the sync timer
721
+ const normalMessage = {
722
+ dataSets: [
723
+ {
724
+ ...createDataSet('main', 16, 1100),
725
+ root: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1', // different from our hash
726
+ },
727
+ ],
728
+ visibleDataSetsUrl,
729
+ locusUrl,
730
+ locusStateElements: [
731
+ {
732
+ htMeta: {
733
+ elementId: {
734
+ type: 'locus' as const,
735
+ id: 0,
736
+ version: 201,
737
+ },
738
+ dataSetNames: ['main'],
739
+ },
740
+ data: {someData: 'value'},
741
+ },
742
+ ],
743
+ };
744
+
745
+ await parser.handleMessage(normalMessage, 'initial message');
746
+
747
+ // Verify the timer was set (the sync algorithm should have started)
748
+ expect(parser.dataSets.main.timer).to.not.be.undefined;
749
+ const firstTimerDelay = parser.dataSets.main.idleMs; // 1000ms base + random backoff
750
+
751
+ // Step 2: Simulate half of the time passing
752
+ clock.tick(500);
753
+
754
+ // Verify no webex requests have been made yet
755
+ assert.notCalled(webexRequest);
756
+
757
+ // Step 3: Send a heartbeat message (no locusStateElements) with mismatched root hash
758
+ const heartbeatMessage = createHeartbeatMessage(
759
+ 'main',
760
+ 16,
761
+ 1101,
762
+ 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' // still different from our hash
763
+ );
764
+
765
+ await parser.handleMessage(heartbeatMessage, 'heartbeat message');
766
+
767
+ // Verify the timer was restarted (should still exist)
768
+ expect(parser.dataSets.main.timer).to.not.be.undefined;
769
+
770
+ // Step 4: Simulate more time passing (another 500ms) - total 1000ms from start
771
+ // This should NOT trigger the sync yet because the timer was restarted
772
+ clock.tick(500);
773
+
774
+ // Verify still no hash requests or sync requests were sent
775
+ assert.notCalled(webexRequest);
776
+
777
+ // Step 5: Mock the responses for the sync algorithm
778
+ const mainDataSetUrl = 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main';
779
+
780
+ // Mock getHashesFromLocus response
781
+ mockGetHashesFromLocusResponse(
782
+ mainDataSetUrl,
783
+ new Array(16).fill('00000000000000000000000000000000'),
784
+ createDataSet('main', 16, 1102)
785
+ );
786
+
787
+ // Mock sendSyncRequestToLocus response - use matching root hash so no new timer is started
788
+ const syncResponseDataSet = createDataSet('main', 16, 1103);
789
+ syncResponseDataSet.root = parser.dataSets.main.hashTree.getRootHash();
790
+ mockSendSyncRequestResponse(mainDataSetUrl, {
791
+ dataSets: [syncResponseDataSet],
792
+ visibleDataSetsUrl,
793
+ locusUrl,
794
+ locusStateElements: [],
795
+ });
796
+
797
+ // Step 6: Simulate the full delay passing (another 1000ms + 0ms backoff)
798
+ // We need to advance enough time for the restarted timer to expire
799
+ await clock.tickAsync(1000);
800
+
801
+ // Now verify that the sync algorithm ran:
802
+ // 1. First, getHashesFromLocus should have been called
803
+ assert.calledWith(
804
+ webexRequest,
805
+ sinon.match({
806
+ method: 'GET',
807
+ uri: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main/hashtree',
808
+ })
809
+ );
810
+
811
+ // 2. Then, sendSyncRequestToLocus should have been called
812
+ assert.calledWith(
813
+ webexRequest,
814
+ sinon.match({
815
+ method: 'POST',
816
+ uri: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main/sync',
817
+ })
818
+ );
819
+ });
820
+
821
+ it('handles normal updates to hash trees correctly - updates hash trees', async () => {
822
+ const parser = createHashTreeParser();
823
+
824
+ // Stub updateItems on hash trees
825
+ const mainUpdateItemsStub = sinon
826
+ .stub(parser.dataSets.main.hashTree, 'updateItems')
827
+ .returns([true]);
828
+ const selfUpdateItemsStub = sinon
829
+ .stub(parser.dataSets.self.hashTree, 'updateItems')
830
+ .returns([true]);
831
+ const atdUnmutedUpdateItemsStub = sinon
832
+ .stub(parser.dataSets['atd-unmuted'].hashTree, 'updateItems')
833
+ .returns([true, true]);
834
+
835
+ // Create a message with updates to multiple datasets
836
+ const message = {
837
+ dataSets: [
838
+ createDataSet('main', 16, 1100),
839
+ createDataSet('self', 1, 2100),
840
+ createDataSet('atd-unmuted', 16, 3100),
841
+ ],
842
+ visibleDataSetsUrl,
843
+ locusUrl,
844
+ locusStateElements: [
845
+ {
846
+ htMeta: {
847
+ elementId: {
848
+ type: 'locus' as const,
849
+ id: 0,
850
+ version: 201,
851
+ },
852
+ dataSetNames: ['main'],
853
+ },
854
+ data: {info: {id: 'updated-locus-info'}},
855
+ },
856
+ {
857
+ htMeta: {
858
+ elementId: {
859
+ type: 'self' as const,
860
+ id: 4,
861
+ version: 101,
862
+ },
863
+ dataSetNames: ['self'],
864
+ },
865
+ data: {person: {name: 'updated self name'}},
866
+ },
867
+ {
868
+ htMeta: {
869
+ elementId: {
870
+ type: 'participant' as const,
871
+ id: 14,
872
+ version: 301,
873
+ },
874
+ dataSetNames: ['atd-unmuted'],
875
+ },
876
+ data: {person: {name: 'participant name'}},
877
+ },
878
+ {
879
+ htMeta: {
880
+ elementId: {
881
+ type: 'participant' as const,
882
+ id: 15,
883
+ version: 302,
884
+ },
885
+ dataSetNames: ['atd-unmuted'],
886
+ },
887
+ data: {person: {name: 'another participant'}},
888
+ },
889
+ ],
890
+ };
891
+
892
+ await parser.handleMessage(message, 'normal update');
893
+
894
+ // Verify updateItems was called on main hash tree
895
+ assert.calledOnceWithExactly(mainUpdateItemsStub, [
896
+ {operation: 'update', item: {type: 'locus', id: 0, version: 201}},
897
+ ]);
898
+
899
+ // Verify updateItems was called on self hash tree
900
+ assert.calledOnceWithExactly(selfUpdateItemsStub, [
901
+ {operation: 'update', item: {type: 'self', id: 4, version: 101}},
902
+ ]);
903
+
904
+ // Verify updateItems was called on atd-unmuted hash tree with both participants
905
+ assert.calledOnceWithExactly(atdUnmutedUpdateItemsStub, [
906
+ {operation: 'update', item: {type: 'participant', id: 14, version: 301}},
907
+ {operation: 'update', item: {type: 'participant', id: 15, version: 302}},
908
+ ]);
909
+
910
+ // Verify callback was called with OBJECTS_UPDATED and all updated objects
911
+ assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
912
+ updatedObjects: [
913
+ {
914
+ htMeta: {
915
+ elementId: {type: 'self', id: 4, version: 101},
916
+ dataSetNames: ['self'],
917
+ },
918
+ data: {person: {name: 'updated self name'}},
919
+ },
920
+ {
921
+ htMeta: {
922
+ elementId: {type: 'locus', id: 0, version: 201},
923
+ dataSetNames: ['main'],
924
+ },
925
+ data: {info: {id: 'updated-locus-info'}},
926
+ },
927
+ // self updates appear twice, because they are processed twice in HashTreeParser.parseMessage()
928
+ // (first for checking for visibleDataSets changes and again with the rest of updates in the main part of parseMessage())
929
+ // this is only temporary until SPARK-744859 is done and having them twice here is not harmful
930
+ // so keeping it like this for now
931
+ {
932
+ htMeta: {
933
+ elementId: {type: 'self', id: 4, version: 101},
934
+ dataSetNames: ['self'],
935
+ },
936
+ data: {person: {name: 'updated self name'}},
937
+ },
938
+ {
939
+ htMeta: {
940
+ elementId: {type: 'participant', id: 14, version: 301},
941
+ dataSetNames: ['atd-unmuted'],
942
+ },
943
+ data: {person: {name: 'participant name'}},
944
+ },
945
+ {
946
+ htMeta: {
947
+ elementId: {type: 'participant', id: 15, version: 302},
948
+ dataSetNames: ['atd-unmuted'],
949
+ },
950
+ data: {person: {name: 'another participant'}},
951
+ },
952
+ ],
953
+ });
954
+ });
955
+
956
+ it('detects roster drop correctly', async () => {
957
+ const parser = createHashTreeParser();
958
+
959
+ // Stub updateItems to return true (indicating the change was applied)
960
+ sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
961
+
962
+ // Send a roster drop message (SELF object with no data)
963
+ const rosterDropMessage = {
964
+ dataSets: [createDataSet('self', 1, 2101)],
965
+ visibleDataSetsUrl,
966
+ locusUrl,
967
+ locusStateElements: [
968
+ {
969
+ htMeta: {
970
+ elementId: {
971
+ type: 'self' as const,
972
+ id: 4,
973
+ version: 102,
974
+ },
975
+ dataSetNames: ['self'],
976
+ },
977
+ data: undefined, // No data - this indicates roster drop
978
+ },
979
+ ],
980
+ };
981
+
982
+ await parser.handleMessage(rosterDropMessage, 'roster drop message');
983
+
984
+ // Verify callback was called with MEETING_ENDED
985
+ assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
986
+ updatedObjects: undefined,
987
+ });
988
+
989
+ // Verify that all timers were stopped (timer should be undefined after roster drop)
990
+ assert.equal(parser.dataSets.self.timer, undefined);
991
+ assert.equal(parser.dataSets.main.timer, undefined);
992
+ assert.equal(parser.dataSets['atd-unmuted'].timer, undefined);
993
+ });
994
+
995
+ describe('sync algorithm', () => {
996
+ it('runs correctly after a message is received', async () => {
997
+ const parser = createHashTreeParser();
998
+
999
+ // Create a message with updates and mismatched root hash
1000
+ const message = {
1001
+ dataSets: [
1002
+ {
1003
+ ...createDataSet('main', 16, 1100),
1004
+ },
1005
+ ],
1006
+ visibleDataSetsUrl,
1007
+ locusUrl,
1008
+ locusStateElements: [
1009
+ {
1010
+ htMeta: {
1011
+ elementId: {
1012
+ type: 'locus' as const,
1013
+ id: 0,
1014
+ version: 201,
1015
+ },
1016
+ dataSetNames: ['main'],
1017
+ },
1018
+ data: {info: {id: 'initial-update'}},
1019
+ },
1020
+ ],
1021
+ };
1022
+
1023
+ await parser.handleMessage(message, 'initial message');
1024
+
1025
+ // Verify callback was called with initial updates
1026
+ assert.calledOnce(callback);
1027
+ callback.resetHistory();
1028
+
1029
+ // Setup mocks for sync algorithm
1030
+ const mainDataSetUrl = parser.dataSets.main.url;
1031
+
1032
+ // Mock getHashesFromLocus response
1033
+ mockGetHashesFromLocusResponse(
1034
+ mainDataSetUrl,
1035
+ new Array(16).fill('00000000000000000000000000000000'),
1036
+ createDataSet('main', 16, 1101)
1037
+ );
1038
+
1039
+ // Mock sendSyncRequestToLocus response with matching root hash
1040
+ const mainSyncDataSet = createDataSet('main', 16, 1101);
1041
+ mainSyncDataSet.root = parser.dataSets.main.hashTree.getRootHash();
1042
+ mockSendSyncRequestResponse(mainDataSetUrl, {
1043
+ dataSets: [mainSyncDataSet],
1044
+ visibleDataSetsUrl,
1045
+ locusUrl,
1046
+ locusStateElements: [
1047
+ {
1048
+ htMeta: {
1049
+ elementId: {
1050
+ type: 'locus' as const,
1051
+ id: 1,
1052
+ version: 202,
1053
+ },
1054
+ dataSetNames: ['main'],
1055
+ },
1056
+ data: {info: {id: 'synced-locus'}},
1057
+ },
1058
+ ],
1059
+ });
1060
+
1061
+ // Simulate time passing to trigger sync algorithm (1000ms base + 0 backoff)
1062
+ await clock.tickAsync(1000);
1063
+
1064
+ // Verify that sync requests were sent for main dataset
1065
+ assert.calledWith(
1066
+ webexRequest,
1067
+ sinon.match({
1068
+ method: 'GET',
1069
+ uri: `${mainDataSetUrl}/hashtree`,
1070
+ })
1071
+ );
1072
+ assert.calledWith(
1073
+ webexRequest,
1074
+ sinon.match({
1075
+ method: 'POST',
1076
+ uri: `${mainDataSetUrl}/sync`,
1077
+ })
1078
+ );
1079
+
1080
+ // Verify that callback was called with synced objects
1081
+ assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
1082
+ updatedObjects: [
1083
+ {
1084
+ htMeta: {
1085
+ elementId: {type: 'locus', id: 1, version: 202},
1086
+ dataSetNames: ['main'],
1087
+ },
1088
+ data: {info: {id: 'synced-locus'}},
1089
+ },
1090
+ ],
1091
+ });
1092
+ });
1093
+ it('requests only mismatched hashes during sync', async () => {
1094
+ const parser = createHashTreeParser();
1095
+
1096
+ // Create a message with updates to trigger sync algorithm
1097
+ const message = {
1098
+ dataSets: [createDataSet('main', 16, 1100)],
1099
+ visibleDataSetsUrl,
1100
+ locusUrl,
1101
+ locusStateElements: [
1102
+ {
1103
+ htMeta: {
1104
+ elementId: {
1105
+ type: 'locus' as const,
1106
+ id: 0,
1107
+ version: 201,
1108
+ },
1109
+ dataSetNames: ['main'],
1110
+ },
1111
+ data: {info: {id: 'initial-update'}},
1112
+ },
1113
+ {
1114
+ htMeta: {
1115
+ elementId: {
1116
+ type: 'participant' as const,
1117
+ id: 3,
1118
+ version: 301,
1119
+ },
1120
+ dataSetNames: ['main'],
1121
+ },
1122
+ data: {id: 'participant with id=3'},
1123
+ },
1124
+ {
1125
+ htMeta: {
1126
+ elementId: {
1127
+ type: 'participant' as const,
1128
+ id: 4,
1129
+ version: 301,
1130
+ },
1131
+ dataSetNames: ['main'],
1132
+ },
1133
+ data: {id: 'participant with id=4'},
1134
+ },
1135
+ ],
1136
+ };
1137
+
1138
+ await parser.handleMessage(message, 'initial message');
1139
+
1140
+ callback.resetHistory();
1141
+
1142
+ // Setup the hash tree to have specific hashes for each leaf
1143
+ // We'll make leaf 0 and leaf 4 have mismatched hashes
1144
+ const hashTree = parser.dataSets.main.hashTree;
1145
+
1146
+ // Get the actual hashes for all leaves after the items were added
1147
+ const actualHashes = new Array(16);
1148
+ for (let i = 0; i < 16; i++) {
1149
+ actualHashes[i] = hashTree.leafHashes[i];
1150
+ }
1151
+
1152
+ // Mock getHashesFromLocus to return hashes where most match but 0 and 4 don't
1153
+ actualHashes[0] = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
1154
+ actualHashes[4] = 'cccccccccccccccccccccccccccccccc';
1155
+
1156
+ const mainDataSetUrl = parser.dataSets.main.url;
1157
+ mockGetHashesFromLocusResponse(
1158
+ mainDataSetUrl,
1159
+ actualHashes,
1160
+ createDataSet('main', 16, 1101)
1161
+ );
1162
+
1163
+ // Mock sendSyncRequestToLocus response with matching root hash
1164
+ const mainSyncDataSet = createDataSet('main', 16, 1101);
1165
+ mainSyncDataSet.root = hashTree.getRootHash();
1166
+ mockSendSyncRequestResponse(mainDataSetUrl, {
1167
+ dataSets: [mainSyncDataSet],
1168
+ visibleDataSetsUrl,
1169
+ locusUrl,
1170
+ locusStateElements: [],
1171
+ });
1172
+
1173
+ // Trigger the sync algorithm by advancing time
1174
+ await clock.tickAsync(1000);
1175
+
1176
+ // Verify getHashesFromLocus was called
1177
+ assert.calledWith(
1178
+ webexRequest,
1179
+ sinon.match({
1180
+ method: 'GET',
1181
+ uri: `${mainDataSetUrl}/hashtree`,
1182
+ })
1183
+ );
1184
+
1185
+ // Verify sendSyncRequestToLocus was called with only the mismatched leaf indices 0 and 4
1186
+ assert.calledWith(webexRequest, {
1187
+ method: 'POST',
1188
+ uri: `${mainDataSetUrl}/sync`,
1189
+ body: {
1190
+ dataSet: {
1191
+ name: 'main',
1192
+ leafCount: 16,
1193
+ root: '472801612a448c4e0ab74975ed9d7a2e'
1194
+ },
1195
+ leafDataEntries: [
1196
+ {leafIndex: 0, elementIds: [{type: 'locus', id: 0, version: 201}]},
1197
+ {leafIndex: 4, elementIds: [{type: 'participant', id: 4, version: 301}]},
1198
+ ],
1199
+ },
1200
+ });
1201
+ });
1202
+
1203
+ it('does not get the hashes if leafCount === 1', async () => {
1204
+ const parser = createHashTreeParser();
1205
+
1206
+ // Create a message with updates to self dataset
1207
+ const message = {
1208
+ dataSets: [createDataSet('self', 1, 2001)],
1209
+ visibleDataSetsUrl,
1210
+ locusUrl,
1211
+ locusStateElements: [
1212
+ {
1213
+ htMeta: {
1214
+ elementId: {
1215
+ type: 'self' as const,
1216
+ id: 4,
1217
+ version: 102,
1218
+ },
1219
+ dataSetNames: ['self'],
1220
+ },
1221
+ data: {id: 'updated self'},
1222
+ },
1223
+ ],
1224
+ };
1225
+
1226
+ await parser.handleMessage(message, 'message with self update');
1227
+
1228
+ callback.resetHistory();
1229
+
1230
+ // Trigger the sync algorithm by advancing time
1231
+ await clock.tickAsync(1000);
1232
+
1233
+ // self data set has only 1 leaf, so sync should skip the step of getting hashes
1234
+ assert.neverCalledWith(
1235
+ webexRequest,
1236
+ sinon.match({
1237
+ method: 'GET',
1238
+ uri: `${parser.dataSets.self.url}/hashtree`,
1239
+ })
1240
+ );
1241
+
1242
+ // Verify sendSyncRequestToLocus was called with the single leaf
1243
+ assert.calledWith(webexRequest, {
1244
+ method: 'POST',
1245
+ uri: `${parser.dataSets.self.url}/sync`,
1246
+ body: {
1247
+ dataSet: {
1248
+ name: 'self',
1249
+ leafCount: 1,
1250
+ root: '483ba32a5db954720b4c43ed528d8075'
1251
+ },
1252
+ leafDataEntries: [
1253
+ {leafIndex: 0, elementIds: [{type: 'self', id: 4, version: 102}]},
1254
+ ],
1255
+ },
1256
+ });
1257
+ });
1258
+ });
1259
+
1260
+ describe('handles visible data sets changes correctly', () => {
1261
+ it('handles addition of visible data set (one that does not require async initialization)', async () => {
1262
+ // Create a parser with visible datasets
1263
+ const parser = createHashTreeParser();
1264
+
1265
+ // Stub updateItems on self hash tree to return true
1266
+ sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
1267
+
1268
+ // Send a message with SELF object that has a new visibleDataSets list
1269
+ const message = {
1270
+ dataSets: [createDataSet('self', 1, 2100), createDataSet('attendees', 8, 4000)],
1271
+ visibleDataSetsUrl,
1272
+ locusUrl,
1273
+ locusStateElements: [
1274
+ {
1275
+ htMeta: {
1276
+ elementId: {
1277
+ type: 'self' as const,
1278
+ id: 4,
1279
+ version: 101,
1280
+ },
1281
+ dataSetNames: ['self'],
1282
+ },
1283
+ data: {
1284
+ visibleDataSets: ['main', 'self', 'atd-unmuted', 'attendees'], // added 'attendees'
1285
+ },
1286
+ },
1287
+ ],
1288
+ };
1289
+
1290
+ await parser.handleMessage(message, 'add visible dataset');
1291
+
1292
+ // Verify that 'attendees' was added to visibleDataSets
1293
+ assert.include(parser.visibleDataSets, 'attendees');
1294
+
1295
+ // Verify that a hash tree was created for 'attendees'
1296
+ assert.exists(parser.dataSets.attendees.hashTree);
1297
+ assert.equal(parser.dataSets.attendees.hashTree.numLeaves, 8);
1298
+
1299
+ // Verify callback was called with the self update (appears twice due to SPARK-744859)
1300
+ assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
1301
+ updatedObjects: [
1302
+ {
1303
+ htMeta: {
1304
+ elementId: {type: 'self', id: 4, version: 101},
1305
+ dataSetNames: ['self'],
1306
+ },
1307
+ data: {
1308
+ visibleDataSets: ['main', 'self', 'atd-unmuted', 'attendees'],
1309
+ },
1310
+ },
1311
+ {
1312
+ htMeta: {
1313
+ elementId: {type: 'self', id: 4, version: 101},
1314
+ dataSetNames: ['self'],
1315
+ },
1316
+ data: {
1317
+ visibleDataSets: ['main', 'self', 'atd-unmuted', 'attendees'],
1318
+ },
1319
+ },
1320
+ ],
1321
+ });
1322
+ });
1323
+
1324
+ it('handles addition of visible data set (one that requires async initialization)', async () => {
1325
+ // Create a parser with visible datasets
1326
+ const parser = createHashTreeParser();
1327
+
1328
+ // Stub updateItems on self hash tree to return true
1329
+ sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
1330
+
1331
+ // Send a message with SELF object that has a new visibleDataSets list (adding 'new-dataset')
1332
+ // but WITHOUT providing info about the new dataset in dataSets array
1333
+ const message = {
1334
+ dataSets: [createDataSet('self', 1, 2100)],
1335
+ visibleDataSetsUrl,
1336
+ locusUrl,
1337
+ locusStateElements: [
1338
+ {
1339
+ htMeta: {
1340
+ elementId: {
1341
+ type: 'self' as const,
1342
+ id: 4,
1343
+ version: 101,
1344
+ },
1345
+ dataSetNames: ['self'],
1346
+ },
1347
+ data: {
1348
+ visibleDataSets: ['main', 'self', 'atd-unmuted', 'new-dataset'],
1349
+ },
1350
+ },
1351
+ ],
1352
+ };
1353
+
1354
+ // Mock the async initialization of the new dataset
1355
+ const newDataSet = createDataSet('new-dataset', 4, 5000);
1356
+ mockGetAllDataSetsMetadata(webexRequest, visibleDataSetsUrl, [newDataSet]);
1357
+ mockSyncRequest(webexRequest, newDataSet.url, {
1358
+ dataSets: [newDataSet],
1359
+ visibleDataSetsUrl,
1360
+ locusUrl,
1361
+ locusStateElements: [],
1362
+ });
1363
+
1364
+ await parser.handleMessage(message, 'add new dataset requiring async init');
1365
+
1366
+ // immediately we don't have the dataset yet, so it should not be in visibleDataSets
1367
+ // and no hash tree should exist yet
1368
+ assert.isFalse(parser.visibleDataSets.includes('new-dataset'));
1369
+ assert.isUndefined(parser.dataSets['new-dataset']);
1370
+
1371
+ // Wait for the async initialization to complete (queued as microtask)
1372
+ await clock.tickAsync(0);
1373
+
1374
+ // The visibleDataSets is updated from the self object data
1375
+ assert.include(parser.visibleDataSets, 'new-dataset');
1376
+
1377
+ // Verify that a hash tree was created for 'new-dataset'
1378
+ assert.exists(parser.dataSets['new-dataset'].hashTree);
1379
+ assert.equal(parser.dataSets['new-dataset'].hashTree.numLeaves, 4);
1380
+
1381
+ // Verify getAllDataSetsMetadata was called for async initialization
1382
+ assert.calledWith(
1383
+ webexRequest,
1384
+ sinon.match({
1385
+ method: 'GET',
1386
+ uri: visibleDataSetsUrl,
1387
+ })
1388
+ );
1389
+
1390
+ // Verify sync request was sent for the new dataset
1391
+ assert.calledWith(
1392
+ webexRequest,
1393
+ sinon.match({
1394
+ method: 'POST',
1395
+ uri: `${newDataSet.url}/sync`,
1396
+ })
1397
+ );
1398
+ });
1399
+
1400
+ it('handles removal of visible data set', async () => {
1401
+ // Create a parser with visible datasets
1402
+ const parser = createHashTreeParser();
1403
+
1404
+ // Store the initial hash tree for atd-unmuted to verify it gets deleted
1405
+ const atdUnmutedHashTree = parser.dataSets['atd-unmuted'].hashTree;
1406
+ assert.exists(atdUnmutedHashTree);
1407
+
1408
+ // Stub getLeafData to return some items that will be marked as removed
1409
+ // It's called for each leaf (16 leaves), so return an array for leaf 14 and empty for others
1410
+ const getLeafDataStub = sinon.stub(atdUnmutedHashTree, 'getLeafData');
1411
+ getLeafDataStub.withArgs(14).returns([{type: 'participant', id: 14, version: 301}]);
1412
+ getLeafDataStub.returns([]);
1413
+
1414
+ // Stub updateItems on self hash tree to return true
1415
+ sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
1416
+
1417
+ // Send a message with SELF object that has removed 'atd-unmuted' from visibleDataSets
1418
+ const message = {
1419
+ dataSets: [createDataSet('self', 1, 2100)],
1420
+ visibleDataSetsUrl,
1421
+ locusUrl,
1422
+ locusStateElements: [
1423
+ {
1424
+ htMeta: {
1425
+ elementId: {
1426
+ type: 'self' as const,
1427
+ id: 4,
1428
+ version: 101,
1429
+ },
1430
+ dataSetNames: ['self'],
1431
+ },
1432
+ data: {
1433
+ visibleDataSets: ['main', 'self'], // removed 'atd-unmuted'
1434
+ },
1435
+ },
1436
+ ],
1437
+ };
1438
+
1439
+ await parser.handleMessage(message, 'remove visible dataset');
1440
+
1441
+ // Verify that 'atd-unmuted' was removed from visibleDataSets
1442
+ assert.notInclude(parser.visibleDataSets, 'atd-unmuted');
1443
+
1444
+ // Verify that the hash tree for 'atd-unmuted' was deleted
1445
+ assert.isUndefined(parser.dataSets['atd-unmuted'].hashTree);
1446
+
1447
+ // Verify that the timer was cleared
1448
+ assert.isUndefined(parser.dataSets['atd-unmuted'].timer);
1449
+
1450
+ // Verify callback was called with both the self update and the removed objects
1451
+ assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
1452
+ updatedObjects: [
1453
+ {
1454
+ htMeta: {
1455
+ elementId: {type: 'self', id: 4, version: 101},
1456
+ dataSetNames: ['self'],
1457
+ },
1458
+ data: {
1459
+ visibleDataSets: ['main', 'self'],
1460
+ },
1461
+ },
1462
+ {
1463
+ htMeta: {
1464
+ elementId: {type: 'participant', id: 14, version: 301},
1465
+ dataSetNames: ['atd-unmuted'],
1466
+ },
1467
+ data: null,
1468
+ },
1469
+ {
1470
+ htMeta: {
1471
+ elementId: {type: 'self', id: 4, version: 101}, // 2nd self because of SPARK-744859
1472
+ dataSetNames: ['self'],
1473
+ },
1474
+ data: {
1475
+ visibleDataSets: ['main', 'self'],
1476
+ },
1477
+ },
1478
+ ],
1479
+ });
1480
+ });
1481
+ it('ignores data if it is not in a visible data set', async () => {
1482
+ // Create a parser with attendees in datasets but not in visibleDataSets
1483
+ const parser = createHashTreeParser({
1484
+ dataSets: [
1485
+ ...exampleInitialLocus.dataSets,
1486
+ {
1487
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/attendees',
1488
+ root: '0'.repeat(32),
1489
+ version: 4000,
1490
+ leafCount: 8,
1491
+ name: 'attendees',
1492
+ idleMs: 1000,
1493
+ backoff: {maxMs: 1000, exponent: 2},
1494
+ },
1495
+ ],
1496
+ locus: {...exampleInitialLocus.locus},
1497
+ });
1498
+
1499
+ // Verify attendees is NOT in visibleDataSets
1500
+ assert.notInclude(parser.visibleDataSets, 'attendees');
1501
+
1502
+ // Send a message with attendees data
1503
+ const message = {
1504
+ dataSets: [createDataSet('attendees', 8, 4001)],
1505
+ visibleDataSetsUrl,
1506
+ locusUrl,
1507
+ locusStateElements: [
1508
+ {
1509
+ htMeta: {
1510
+ elementId: {
1511
+ type: 'participant' as const,
1512
+ id: 20,
1513
+ version: 303,
1514
+ },
1515
+ dataSetNames: ['attendees'],
1516
+ },
1517
+ data: {person: {name: 'participant in attendees'}},
1518
+ },
1519
+ ],
1520
+ };
1521
+
1522
+ await parser.handleMessage(message, 'message with non-visible dataset');
1523
+
1524
+ // Verify that no hash tree was created for attendees
1525
+ assert.isUndefined(parser.dataSets.attendees.hashTree);
1526
+
1527
+ // Verify callback was NOT called (no updates for non-visible datasets)
1528
+ assert.notCalled(callback);
1529
+ });
1530
+ });
1531
+ });
1532
+ });