@webex/plugin-meetings 3.11.0 → 3.12.0-mobius-socket.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 (175) hide show
  1. package/dist/aiEnableRequest/index.js +184 -0
  2. package/dist/aiEnableRequest/index.js.map +1 -0
  3. package/dist/aiEnableRequest/utils.js +36 -0
  4. package/dist/aiEnableRequest/utils.js.map +1 -0
  5. package/dist/annotation/index.js +14 -5
  6. package/dist/annotation/index.js.map +1 -1
  7. package/dist/breakouts/breakout.js +1 -1
  8. package/dist/breakouts/index.js +1 -1
  9. package/dist/config.js +7 -2
  10. package/dist/config.js.map +1 -1
  11. package/dist/constants.js +28 -6
  12. package/dist/constants.js.map +1 -1
  13. package/dist/hashTree/constants.js +3 -1
  14. package/dist/hashTree/constants.js.map +1 -1
  15. package/dist/hashTree/hashTree.js +18 -0
  16. package/dist/hashTree/hashTree.js.map +1 -1
  17. package/dist/hashTree/hashTreeParser.js +868 -419
  18. package/dist/hashTree/hashTreeParser.js.map +1 -1
  19. package/dist/hashTree/types.js +4 -2
  20. package/dist/hashTree/types.js.map +1 -1
  21. package/dist/hashTree/utils.js +10 -0
  22. package/dist/hashTree/utils.js.map +1 -1
  23. package/dist/index.js +11 -2
  24. package/dist/index.js.map +1 -1
  25. package/dist/interceptors/constant.js +12 -0
  26. package/dist/interceptors/constant.js.map +1 -0
  27. package/dist/interceptors/dataChannelAuthToken.js +290 -0
  28. package/dist/interceptors/dataChannelAuthToken.js.map +1 -0
  29. package/dist/interceptors/index.js +7 -0
  30. package/dist/interceptors/index.js.map +1 -1
  31. package/dist/interceptors/utils.js +27 -0
  32. package/dist/interceptors/utils.js.map +1 -0
  33. package/dist/interpretation/index.js +2 -2
  34. package/dist/interpretation/index.js.map +1 -1
  35. package/dist/interpretation/siLanguage.js +1 -1
  36. package/dist/locus-info/controlsUtils.js +5 -3
  37. package/dist/locus-info/controlsUtils.js.map +1 -1
  38. package/dist/locus-info/index.js +522 -131
  39. package/dist/locus-info/index.js.map +1 -1
  40. package/dist/locus-info/selfUtils.js +1 -0
  41. package/dist/locus-info/selfUtils.js.map +1 -1
  42. package/dist/locus-info/types.js.map +1 -1
  43. package/dist/media/MediaConnectionAwaiter.js +57 -1
  44. package/dist/media/MediaConnectionAwaiter.js.map +1 -1
  45. package/dist/media/properties.js +4 -2
  46. package/dist/media/properties.js.map +1 -1
  47. package/dist/meeting/in-meeting-actions.js +7 -1
  48. package/dist/meeting/in-meeting-actions.js.map +1 -1
  49. package/dist/meeting/index.js +1304 -928
  50. package/dist/meeting/index.js.map +1 -1
  51. package/dist/meeting/request.js +50 -0
  52. package/dist/meeting/request.js.map +1 -1
  53. package/dist/meeting/request.type.js.map +1 -1
  54. package/dist/meeting/util.js +133 -3
  55. package/dist/meeting/util.js.map +1 -1
  56. package/dist/meetings/index.js +117 -48
  57. package/dist/meetings/index.js.map +1 -1
  58. package/dist/member/index.js +10 -0
  59. package/dist/member/index.js.map +1 -1
  60. package/dist/member/util.js +10 -0
  61. package/dist/member/util.js.map +1 -1
  62. package/dist/metrics/constants.js +6 -1
  63. package/dist/metrics/constants.js.map +1 -1
  64. package/dist/multistream/mediaRequestManager.js +9 -60
  65. package/dist/multistream/mediaRequestManager.js.map +1 -1
  66. package/dist/multistream/remoteMediaManager.js +11 -0
  67. package/dist/multistream/remoteMediaManager.js.map +1 -1
  68. package/dist/multistream/sendSlotManager.js +116 -2
  69. package/dist/multistream/sendSlotManager.js.map +1 -1
  70. package/dist/reachability/index.js +18 -10
  71. package/dist/reachability/index.js.map +1 -1
  72. package/dist/reactions/reactions.type.js.map +1 -1
  73. package/dist/reconnection-manager/index.js +0 -1
  74. package/dist/reconnection-manager/index.js.map +1 -1
  75. package/dist/types/aiEnableRequest/index.d.ts +5 -0
  76. package/dist/types/aiEnableRequest/utils.d.ts +2 -0
  77. package/dist/types/config.d.ts +4 -0
  78. package/dist/types/constants.d.ts +23 -1
  79. package/dist/types/hashTree/constants.d.ts +1 -0
  80. package/dist/types/hashTree/hashTree.d.ts +7 -0
  81. package/dist/types/hashTree/hashTreeParser.d.ts +122 -14
  82. package/dist/types/hashTree/types.d.ts +3 -0
  83. package/dist/types/hashTree/utils.d.ts +6 -0
  84. package/dist/types/index.d.ts +1 -0
  85. package/dist/types/interceptors/constant.d.ts +5 -0
  86. package/dist/types/interceptors/dataChannelAuthToken.d.ts +43 -0
  87. package/dist/types/interceptors/index.d.ts +2 -1
  88. package/dist/types/interceptors/utils.d.ts +1 -0
  89. package/dist/types/locus-info/index.d.ts +60 -8
  90. package/dist/types/locus-info/types.d.ts +7 -0
  91. package/dist/types/media/MediaConnectionAwaiter.d.ts +10 -1
  92. package/dist/types/media/properties.d.ts +2 -1
  93. package/dist/types/meeting/in-meeting-actions.d.ts +6 -0
  94. package/dist/types/meeting/index.d.ts +72 -7
  95. package/dist/types/meeting/request.d.ts +16 -1
  96. package/dist/types/meeting/request.type.d.ts +5 -0
  97. package/dist/types/meeting/util.d.ts +31 -0
  98. package/dist/types/meetings/index.d.ts +4 -2
  99. package/dist/types/member/index.d.ts +1 -0
  100. package/dist/types/member/util.d.ts +5 -0
  101. package/dist/types/metrics/constants.d.ts +5 -0
  102. package/dist/types/multistream/mediaRequestManager.d.ts +0 -23
  103. package/dist/types/multistream/sendSlotManager.d.ts +23 -1
  104. package/dist/types/reactions/reactions.type.d.ts +1 -0
  105. package/dist/types/webinar/utils.d.ts +6 -0
  106. package/dist/webinar/index.js +438 -163
  107. package/dist/webinar/index.js.map +1 -1
  108. package/dist/webinar/utils.js +25 -0
  109. package/dist/webinar/utils.js.map +1 -0
  110. package/package.json +24 -23
  111. package/src/aiEnableRequest/README.md +84 -0
  112. package/src/aiEnableRequest/index.ts +170 -0
  113. package/src/aiEnableRequest/utils.ts +25 -0
  114. package/src/annotation/index.ts +27 -7
  115. package/src/config.ts +4 -0
  116. package/src/constants.ts +29 -1
  117. package/src/hashTree/constants.ts +1 -0
  118. package/src/hashTree/hashTree.ts +17 -0
  119. package/src/hashTree/hashTreeParser.ts +761 -260
  120. package/src/hashTree/types.ts +4 -0
  121. package/src/hashTree/utils.ts +9 -0
  122. package/src/index.ts +8 -1
  123. package/src/interceptors/constant.ts +6 -0
  124. package/src/interceptors/dataChannelAuthToken.ts +170 -0
  125. package/src/interceptors/index.ts +2 -1
  126. package/src/interceptors/utils.ts +16 -0
  127. package/src/interpretation/index.ts +2 -2
  128. package/src/locus-info/controlsUtils.ts +11 -0
  129. package/src/locus-info/index.ts +579 -113
  130. package/src/locus-info/selfUtils.ts +1 -0
  131. package/src/locus-info/types.ts +8 -0
  132. package/src/media/MediaConnectionAwaiter.ts +41 -1
  133. package/src/media/properties.ts +3 -1
  134. package/src/meeting/in-meeting-actions.ts +12 -0
  135. package/src/meeting/index.ts +389 -87
  136. package/src/meeting/request.ts +42 -0
  137. package/src/meeting/request.type.ts +6 -0
  138. package/src/meeting/util.ts +160 -2
  139. package/src/meetings/index.ts +157 -44
  140. package/src/member/index.ts +10 -0
  141. package/src/member/util.ts +12 -0
  142. package/src/metrics/constants.ts +6 -0
  143. package/src/multistream/mediaRequestManager.ts +4 -54
  144. package/src/multistream/remoteMediaManager.ts +13 -0
  145. package/src/multistream/sendSlotManager.ts +97 -3
  146. package/src/reachability/index.ts +9 -0
  147. package/src/reactions/reactions.type.ts +1 -0
  148. package/src/reconnection-manager/index.ts +0 -1
  149. package/src/webinar/index.ts +265 -6
  150. package/src/webinar/utils.ts +16 -0
  151. package/test/unit/spec/aiEnableRequest/index.ts +981 -0
  152. package/test/unit/spec/aiEnableRequest/utils.ts +130 -0
  153. package/test/unit/spec/annotation/index.ts +69 -7
  154. package/test/unit/spec/hashTree/hashTree.ts +66 -0
  155. package/test/unit/spec/hashTree/hashTreeParser.ts +2321 -175
  156. package/test/unit/spec/interceptors/dataChannelAuthToken.ts +210 -0
  157. package/test/unit/spec/interceptors/utils.ts +75 -0
  158. package/test/unit/spec/locus-info/controlsUtils.js +29 -0
  159. package/test/unit/spec/locus-info/index.js +1134 -55
  160. package/test/unit/spec/media/MediaConnectionAwaiter.ts +41 -1
  161. package/test/unit/spec/media/properties.ts +12 -3
  162. package/test/unit/spec/meeting/in-meeting-actions.ts +8 -2
  163. package/test/unit/spec/meeting/index.js +884 -152
  164. package/test/unit/spec/meeting/request.js +70 -0
  165. package/test/unit/spec/meeting/utils.js +438 -26
  166. package/test/unit/spec/meetings/index.js +653 -32
  167. package/test/unit/spec/member/index.js +28 -4
  168. package/test/unit/spec/member/util.js +65 -27
  169. package/test/unit/spec/multistream/mediaRequestManager.ts +2 -85
  170. package/test/unit/spec/multistream/remoteMediaManager.ts +30 -0
  171. package/test/unit/spec/multistream/sendSlotManager.ts +135 -36
  172. package/test/unit/spec/reachability/index.ts +23 -0
  173. package/test/unit/spec/reconnection-manager/index.js +4 -8
  174. package/test/unit/spec/webinar/index.ts +534 -37
  175. package/test/unit/spec/webinar/utils.ts +39 -0
@@ -1,10 +1,14 @@
1
1
  import HashTreeParser, {
2
2
  LocusInfoUpdateType,
3
+ MeetingEndedError,
3
4
  } from '@webex/plugin-meetings/src/hashTree/hashTreeParser';
4
5
  import HashTree from '@webex/plugin-meetings/src/hashTree/hashTree';
5
6
  import {expect} from '@webex/test-helper-chai';
6
7
  import sinon from 'sinon';
7
8
  import {assert} from '@webex/test-helper-chai';
9
+ import {EMPTY_HASH} from '@webex/plugin-meetings/src/hashTree/constants';
10
+
11
+ const visibleDataSetsUrl = 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/visibleDataSets';
8
12
 
9
13
  const exampleInitialLocus = {
10
14
  dataSets: [
@@ -46,6 +50,7 @@ const exampleInitialLocus = {
46
50
  },
47
51
  dataSetNames: ['main'],
48
52
  },
53
+ links: {resources: {visibleDataSets: {url: visibleDataSetsUrl}}},
49
54
  participants: [
50
55
  {
51
56
  url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/11941033',
@@ -62,7 +67,6 @@ const exampleInitialLocus = {
62
67
  ],
63
68
  self: {
64
69
  url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/11941033',
65
- visibleDataSets: ['main', 'self', 'atd-unmuted'],
66
70
  person: {},
67
71
  htMeta: {
68
72
  elementId: {
@@ -76,6 +80,28 @@ const exampleInitialLocus = {
76
80
  },
77
81
  };
78
82
 
83
+ const exampleMetadata = {
84
+ htMeta: {
85
+ elementId: {
86
+ type: 'metadata',
87
+ id: 5,
88
+ version: 50,
89
+ },
90
+ dataSetNames: ['self'],
91
+ },
92
+ visibleDataSets: [
93
+ {name: 'main', url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main'},
94
+ {
95
+ name: 'self',
96
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
97
+ },
98
+ {
99
+ name: 'atd-unmuted',
100
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/atd-unmuted',
101
+ },
102
+ ],
103
+ };
104
+
79
105
  function createDataSet(name: string, leafCount: number, version = 1) {
80
106
  return {
81
107
  url: `https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/${name}`,
@@ -119,7 +145,6 @@ function mockSyncRequest(webexRequest: sinon.SinonStub, datasetUrl: string, resp
119
145
  }
120
146
 
121
147
  describe('HashTreeParser', () => {
122
- const visibleDataSetsUrl = 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/visibleDataSets';
123
148
  const locusUrl = 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f';
124
149
 
125
150
  let clock;
@@ -139,12 +164,18 @@ describe('HashTreeParser', () => {
139
164
  });
140
165
 
141
166
  // Helper to create a HashTreeParser instance with common defaults
142
- function createHashTreeParser(initialLocus: any = exampleInitialLocus) {
167
+ function createHashTreeParser(
168
+ initialLocus: any = exampleInitialLocus,
169
+ metadata: any = exampleMetadata,
170
+ excludedDataSets?: string[]
171
+ ) {
143
172
  return new HashTreeParser({
144
173
  initialLocus,
174
+ metadata,
145
175
  webexRequest,
146
176
  locusInfoUpdateCallback: callback,
147
177
  debugId: 'test',
178
+ excludedDataSets,
148
179
  });
149
180
  }
150
181
 
@@ -197,9 +228,50 @@ describe('HashTreeParser', () => {
197
228
  body: response,
198
229
  });
199
230
  }
231
+
232
+ async function checkAsyncDatasetInitialization(
233
+ parser: HashTreeParser,
234
+ newDataSet: {name: string; leafCount: number; url: string}
235
+ ) {
236
+ // immediately we don't have the dataset yet, so it should not be in visibleDataSets
237
+ // and no hash tree should exist yet
238
+ expect(parser.visibleDataSets.some((vds) => vds.name === newDataSet.name)).to.be.false;
239
+ assert.isUndefined(parser.dataSets[newDataSet.name]);
240
+
241
+ // Wait for the async initialization to complete (queued as microtask)
242
+ await clock.tickAsync(0);
243
+
244
+ // The visibleDataSets is updated from the metadata object data
245
+ expect(parser.visibleDataSets.some((vds) => vds.name === newDataSet.name)).to.be.true;
246
+
247
+ // Verify that a hash tree was created for newDataSet
248
+ assert.exists(parser.dataSets[newDataSet.name].hashTree);
249
+ assert.equal(parser.dataSets[newDataSet.name].hashTree.numLeaves, newDataSet.leafCount);
250
+
251
+ // Verify getAllDataSetsMetadata was called for async initialization
252
+ assert.calledWith(
253
+ webexRequest,
254
+ sinon.match({
255
+ method: 'GET',
256
+ uri: visibleDataSetsUrl,
257
+ })
258
+ );
259
+
260
+ // Verify sync request was sent for the new dataset
261
+ assert.calledWith(
262
+ webexRequest,
263
+ sinon.match({
264
+ method: 'POST',
265
+ uri: `${newDataSet.url}/sync`,
266
+ })
267
+ );
268
+ }
200
269
  it('should correctly initialize trees from initialLocus data', () => {
201
270
  const parser = createHashTreeParser();
202
271
 
272
+ // verify that visibleDataSetsUrl is read out from inside locus
273
+ expect(parser.visibleDataSetsUrl).to.equal(visibleDataSetsUrl);
274
+
203
275
  // Check that the correct number of trees are created
204
276
  expect(Object.keys(parser.dataSets).length).to.equal(3);
205
277
 
@@ -215,7 +287,11 @@ describe('HashTreeParser', () => {
215
287
  const selfTree = parser.dataSets.self.hashTree;
216
288
  expect(selfTree).to.be.instanceOf(HashTree);
217
289
  const expectedSelfLeaves = new Array(1).fill(null).map(() => ({}));
218
- expectedSelfLeaves[4 % 1] = {self: {4: {type: 'self', id: 4, version: 100}}};
290
+ // Both self (id=4) and metadata (id=5) map to the same leaf (4%1=0, 5%1=0)
291
+ expectedSelfLeaves[0] = {
292
+ self: {4: {type: 'self', id: 4, version: 100}},
293
+ metadata: {5: {type: 'metadata', id: 5, version: 50}},
294
+ };
219
295
  expect(selfTree.leaves).to.deep.equal(expectedSelfLeaves);
220
296
  expect(selfTree.numLeaves).to.equal(1);
221
297
 
@@ -247,7 +323,7 @@ describe('HashTreeParser', () => {
247
323
  name: 'empty-set',
248
324
  });
249
325
 
250
- const parser = createHashTreeParser(modifiedLocus);
326
+ const parser = createHashTreeParser(modifiedLocus, exampleMetadata);
251
327
 
252
328
  expect(Object.keys(parser.dataSets).length).to.equal(4); // main, self, atd-unmuted (now empty), empty-set
253
329
 
@@ -260,7 +336,10 @@ describe('HashTreeParser', () => {
260
336
 
261
337
  const selfTree = parser.dataSets.self.hashTree;
262
338
  const expectedSelfLeaves = new Array(1).fill(null).map(() => ({}));
263
- expectedSelfLeaves[4 % 1] = {self: {4: {type: 'self', id: 4, version: 100}}};
339
+ expectedSelfLeaves[4 % 1] = {
340
+ self: {4: {type: 'self', id: 4, version: 100}},
341
+ metadata: {5: exampleMetadata.htMeta.elementId},
342
+ };
264
343
  expect(selfTree.leaves).to.deep.equal(expectedSelfLeaves);
265
344
  expect(selfTree.numLeaves).to.equal(1);
266
345
 
@@ -277,31 +356,100 @@ describe('HashTreeParser', () => {
277
356
  expect(emptySet.hashTree).to.be.undefined;
278
357
  });
279
358
 
359
+ it('should exclude datasets listed in excludedDataSets during initialization', () => {
360
+ const parser = createHashTreeParser(exampleInitialLocus, exampleMetadata, ['atd-unmuted']);
361
+
362
+ // 'atd-unmuted' should be excluded from visibleDataSets
363
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'atd-unmuted')).to.be.false;
364
+
365
+ // 'main' and 'self' should still be visible
366
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'main')).to.be.true;
367
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'self')).to.be.true;
368
+
369
+ // 'atd-unmuted' dataset entry should exist but without a hash tree (because it's not visible)
370
+ expect(parser.dataSets['atd-unmuted']).to.exist;
371
+ expect(parser.dataSets['atd-unmuted'].hashTree).to.be.undefined;
372
+
373
+ // 'main' and 'self' should have hash trees
374
+ expect(parser.dataSets.main.hashTree).to.be.instanceOf(HashTree);
375
+ expect(parser.dataSets.self.hashTree).to.be.instanceOf(HashTree);
376
+ });
377
+
378
+ it('should exclude datasets listed in excludedDataSets when adding new visible datasets', async () => {
379
+ // Create parser without 'atd-unmuted' in initial metadata visibleDataSets
380
+ const metadataWithoutAtdUnmuted = {
381
+ ...exampleMetadata,
382
+ visibleDataSets: exampleMetadata.visibleDataSets.filter((vds) => vds.name !== 'atd-unmuted'),
383
+ };
384
+ const parser = createHashTreeParser(exampleInitialLocus, metadataWithoutAtdUnmuted, [
385
+ 'atd-unmuted',
386
+ ]);
387
+
388
+ // 'atd-unmuted' should not be in visibleDataSets initially
389
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'atd-unmuted')).to.be.false;
390
+
391
+ // Now simulate initializeDataSets which calls addToVisibleDataSetsList
392
+ const atdUnmutedDataSet = createDataSet('atd-unmuted', 16, 3000);
393
+
394
+ mockGetAllDataSetsMetadata(webexRequest, visibleDataSetsUrl, [
395
+ createDataSet('main', 16, 1000),
396
+ createDataSet('self', 1, 2000),
397
+ atdUnmutedDataSet,
398
+ ]);
399
+
400
+ mockSyncRequest(
401
+ webexRequest,
402
+ 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/atd-unmuted'
403
+ );
404
+
405
+ const message = {
406
+ dataSets: [createDataSet('main', 16, 1000)],
407
+ visibleDataSetsUrl,
408
+ locusUrl,
409
+ };
410
+ await parser.initializeFromMessage(message);
411
+
412
+ // 'atd-unmuted' should still not be in visibleDataSets because it is excluded
413
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'atd-unmuted')).to.be.false;
414
+ // but 'main' and 'self' should be there
415
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'main')).to.be.true;
416
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'self')).to.be.true;
417
+ });
418
+
280
419
  // helper method, needed because both initializeFromMessage and initializeFromGetLociResponse
281
420
  // do almost exactly the same thing
282
421
  const testInitializationOfDatasetsAndHashTrees = async (testCallback) => {
283
422
  // Create a parser with minimal initial data
284
423
  const minimalInitialLocus = {
285
424
  dataSets: [],
286
- locus: {
287
- self: {
288
- visibleDataSets: ['main', 'self'],
425
+ locus: null,
426
+ };
427
+
428
+ const minimalMetadata = {
429
+ htMeta: {
430
+ elementId: {
431
+ type: 'metadata',
432
+ id: 5,
433
+ version: 50,
289
434
  },
435
+ dataSetNames: ['self'],
290
436
  },
437
+ visibleDataSets: [
438
+ {name: 'main', url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main'},
439
+ {
440
+ name: 'self',
441
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
442
+ },
443
+ ],
291
444
  };
292
445
 
293
- const hashTreeParser = createHashTreeParser(minimalInitialLocus);
446
+ const hashTreeParser = createHashTreeParser(minimalInitialLocus, minimalMetadata);
294
447
 
295
448
  // Setup the datasets that will be returned from getAllDataSetsMetadata
296
449
  const mainDataSet = createDataSet('main', 16, 1100);
297
450
  const selfDataSet = createDataSet('self', 1, 2100);
298
- const invisibleDataSet = createDataSet('invisible', 4, 4000);
299
451
 
300
- mockGetAllDataSetsMetadata(webexRequest, visibleDataSetsUrl, [
301
- mainDataSet,
302
- selfDataSet,
303
- invisibleDataSet,
304
- ]);
452
+ mockGetAllDataSetsMetadata(webexRequest, visibleDataSetsUrl, [mainDataSet, selfDataSet]);
305
453
 
306
454
  // Mock sync requests for visible datasets with some updated objects
307
455
  const mainSyncResponse = {
@@ -357,15 +505,16 @@ describe('HashTreeParser', () => {
357
505
  })
358
506
  );
359
507
 
360
- // Verify all datasets are added to dataSets
508
+ // verify that visibleDataSetsUrl is set on the parser
509
+ expect(hashTreeParser.visibleDataSetsUrl).to.equal(visibleDataSetsUrl);
510
+
511
+ // Verify all datasets returned from visibleDataSetsUrl are added to dataSets
361
512
  expect(hashTreeParser.dataSets.main).to.exist;
362
513
  expect(hashTreeParser.dataSets.self).to.exist;
363
- expect(hashTreeParser.dataSets.invisible).to.exist;
364
514
 
365
515
  // Verify hash trees are created only for visible datasets
366
516
  expect(hashTreeParser.dataSets.main.hashTree).to.be.instanceOf(HashTree);
367
517
  expect(hashTreeParser.dataSets.self.hashTree).to.be.instanceOf(HashTree);
368
- expect(hashTreeParser.dataSets.invisible.hashTree).to.be.undefined;
369
518
 
370
519
  // Verify hash trees have correct leaf counts
371
520
  expect(hashTreeParser.dataSets.main.hashTree.numLeaves).to.equal(16);
@@ -403,15 +552,6 @@ describe('HashTreeParser', () => {
403
552
  })
404
553
  );
405
554
 
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
555
  // Verify callback was called with OBJECTS_UPDATED and correct updatedObjects list
416
556
  assert.calledWith(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
417
557
  updatedObjects: [
@@ -443,8 +583,6 @@ describe('HashTreeParser', () => {
443
583
  // verify that sync timers are set for visible datasets
444
584
  expect(hashTreeParser.dataSets.main.timer).to.not.be.undefined;
445
585
  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
586
  };
449
587
 
450
588
  describe('#initializeFromMessage', () => {
@@ -457,11 +595,91 @@ describe('HashTreeParser', () => {
457
595
  });
458
596
  });
459
597
  });
598
+
599
+ it('handles sync response that has locusStateElements undefined', async () => {
600
+ const minimalInitialLocus = {
601
+ dataSets: [],
602
+ locus: null,
603
+ };
604
+
605
+ const parser = createHashTreeParser(minimalInitialLocus, null);
606
+
607
+ const mainDataSet = createDataSet('main', 16, 1100);
608
+
609
+ // Mock getAllVisibleDataSetsFromLocus to return the main dataset
610
+ mockGetAllDataSetsMetadata(webexRequest, visibleDataSetsUrl, [mainDataSet]);
611
+
612
+ // Mock the sync response to have locusStateElements: undefined
613
+ // This is what sendInitializationSyncRequestToLocus will receive and pass to parseMessage
614
+ mockSyncRequest(webexRequest, mainDataSet.url, {
615
+ dataSets: [mainDataSet],
616
+ visibleDataSetsUrl,
617
+ locusUrl,
618
+ locusStateElements: undefined,
619
+ });
620
+
621
+ // Trigger sendInitializationSyncRequestToLocus via initializeFromMessage
622
+ await parser.initializeFromMessage({
623
+ dataSets: [],
624
+ visibleDataSetsUrl,
625
+ locusUrl,
626
+ });
627
+
628
+ // Verify the hash tree was created for main dataset
629
+ expect(parser.dataSets.main.hashTree).to.be.instanceOf(HashTree);
630
+
631
+ // updateItems should NOT have been called because locusStateElements is undefined
632
+ const mainUpdateItemsStub = sinon.spy(parser.dataSets.main.hashTree, 'updateItems');
633
+ assert.notCalled(mainUpdateItemsStub);
634
+
635
+ // callback should not be called, because there are no updates
636
+ assert.notCalled(callback);
637
+ });
638
+
639
+ [404, 409].forEach((errorCode) => {
640
+ it(`emits MeetingEndedError if getting visible datasets returns ${errorCode}`, async () => {
641
+ const minimalInitialLocus = {
642
+ dataSets: [],
643
+ locus: null,
644
+ };
645
+
646
+ const parser = createHashTreeParser(minimalInitialLocus, null);
647
+
648
+ // Mock getAllVisibleDataSetsFromLocus to reject with the error code
649
+ const error: any = new Error(`Request failed with status ${errorCode}`);
650
+ error.statusCode = errorCode;
651
+ if (errorCode === 409) {
652
+ error.body = {errorCode: 2403004};
653
+ }
654
+ webexRequest
655
+ .withArgs(
656
+ sinon.match({
657
+ method: 'GET',
658
+ uri: visibleDataSetsUrl,
659
+ })
660
+ )
661
+ .rejects(error);
662
+
663
+ // initializeFromMessage should throw MeetingEndedError
664
+ let thrownError;
665
+ try {
666
+ await parser.initializeFromMessage({
667
+ dataSets: [],
668
+ visibleDataSetsUrl,
669
+ locusUrl,
670
+ });
671
+ } catch (e) {
672
+ thrownError = e;
673
+ }
674
+
675
+ expect(thrownError).to.be.instanceOf(MeetingEndedError);
676
+ });
677
+ });
460
678
  });
461
679
 
462
680
  describe('#initializeFromGetLociResponse', () => {
463
681
  it('does nothing if url for visibleDataSets is missing from locus', async () => {
464
- const parser = createHashTreeParser({dataSets: [], locus: {}});
682
+ const parser = createHashTreeParser({dataSets: [], locus: {}}, null);
465
683
 
466
684
  await parser.initializeFromGetLociResponse({participants: []});
467
685
 
@@ -488,12 +706,9 @@ describe('HashTreeParser', () => {
488
706
  it('updates hash trees based on provided new locus', () => {
489
707
  const parser = createHashTreeParser();
490
708
 
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');
709
+ const mainPutItemsSpy = sinon.spy(parser.dataSets.main.hashTree, 'putItems');
710
+ const selfPutItemsSpy = sinon.spy(parser.dataSets.self.hashTree, 'putItems');
711
+ const atdUnmutedPutItemsSpy = sinon.spy(parser.dataSets['atd-unmuted'].hashTree, 'putItems');
497
712
 
498
713
  // Create a locus update with new htMeta information for some things
499
714
  const locusUpdate = {
@@ -540,7 +755,6 @@ describe('HashTreeParser', () => {
540
755
  ],
541
756
  self: {
542
757
  url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/11941033',
543
- visibleDataSets: ['main', 'self', 'atd-unmuted'],
544
758
  person: {},
545
759
  htMeta: {
546
760
  elementId: {
@@ -647,6 +861,116 @@ describe('HashTreeParser', () => {
647
861
  });
648
862
  });
649
863
 
864
+ it('handles updates to control entries correctly', () => {
865
+ const parser = createHashTreeParser();
866
+
867
+ const mainPutItemsSpy = sinon.spy(parser.dataSets.main.hashTree, 'putItems');
868
+
869
+ // Create a locus update with new htMeta information for some things
870
+ const locusUpdate = {
871
+ dataSets: [
872
+ createDataSet('main', 16, 1100),
873
+ ],
874
+ locus: {
875
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f',
876
+ htMeta: {
877
+ elementId: {
878
+ type: 'locus',
879
+ id: 0,
880
+ version: 200, // same version
881
+ },
882
+ dataSetNames: ['main'],
883
+ },
884
+ participants: [],
885
+ controls: {
886
+ lock: {
887
+ locked: true,
888
+ htMeta: {
889
+ elementId: {
890
+ type: 'ControlEntry',
891
+ id: 10100,
892
+ version: 100,
893
+ },
894
+ dataSetNames: ['main'],
895
+ },
896
+ },
897
+ stream: {
898
+ streaming: true,
899
+ htMeta: {
900
+ elementId: {
901
+ type: 'ControlEntry',
902
+ id: 10101,
903
+ version: 100,
904
+ },
905
+ dataSetNames: ['main'],
906
+ },
907
+ }
908
+ }
909
+ },
910
+ };
911
+
912
+ // Call handleLocusUpdate
913
+ parser.handleLocusUpdate(locusUpdate);
914
+
915
+ // Verify putItems was called on main hash tree with correct data
916
+ assert.calledOnceWithExactly(mainPutItemsSpy, [
917
+ {type: 'locus', id: 0, version: 200},
918
+ {type: 'ControlEntry', id: 10100, version: 100},
919
+ {type: 'ControlEntry', id: 10101, version: 100}
920
+ ]);
921
+
922
+ assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
923
+ updatedObjects: [
924
+ {
925
+ htMeta: {
926
+ elementId: {
927
+ type: 'ControlEntry',
928
+ id: 10100,
929
+ version: 100,
930
+ },
931
+ dataSetNames: ['main'],
932
+ },
933
+ data: {
934
+ lock: {
935
+ locked: true,
936
+ htMeta: {
937
+ elementId: {
938
+ type: 'ControlEntry',
939
+ id: 10100,
940
+ version: 100,
941
+ },
942
+ dataSetNames: ['main'],
943
+ },
944
+ },
945
+ },
946
+ },
947
+ {
948
+ htMeta: {
949
+ elementId: {
950
+ type: 'ControlEntry',
951
+ id: 10101,
952
+ version: 100,
953
+ },
954
+ dataSetNames: ['main'],
955
+ },
956
+ data: {
957
+ stream: {
958
+ streaming: true,
959
+ htMeta: {
960
+ elementId: {
961
+ type: 'ControlEntry',
962
+ id: 10101,
963
+ version: 100,
964
+ },
965
+ dataSetNames: ['main'],
966
+ },
967
+ },
968
+ },
969
+ }
970
+ ],
971
+ });
972
+ });
973
+
650
974
  it('handles unknown datasets gracefully', () => {
651
975
  const parser = createHashTreeParser();
652
976
 
@@ -711,47 +1035,313 @@ describe('HashTreeParser', () => {
711
1035
  ],
712
1036
  });
713
1037
  });
714
- });
715
1038
 
716
- describe('#handleMessage', () => {
717
- it('handles root hash heartbeat message correctly', async () => {
1039
+ it('handles metadata updates with new version', async () => {
718
1040
  const parser = createHashTreeParser();
719
1041
 
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
1042
+ const selfPutItemSpy = sinon.spy(parser.dataSets.self.hashTree, 'putItem');
1043
+
1044
+ // Create a locus update with updated metadata
1045
+ const locusUpdate = {
1046
+ dataSets: [createDataSet('self', 1, 2100), createDataSet('attendees', 8, 4000)],
1047
+ locus: {
1048
+ links: {resources: {visibleDataSets: {url: visibleDataSetsUrl}}},
1049
+ participants: [
1050
+ {
1051
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/15',
1052
+ person: {},
1053
+ htMeta: {
1054
+ elementId: {
1055
+ type: 'participant',
1056
+ id: 15, // new participant
1057
+ version: 999,
1058
+ },
1059
+ dataSetNames: ['attendees'],
1060
+ },
1061
+ },
1062
+ ],
1063
+ },
1064
+ metadata: {
1065
+ htMeta: {
1066
+ elementId: {
1067
+ type: 'metadata',
1068
+ id: 5,
1069
+ version: 51, // incremented version
1070
+ },
1071
+ dataSetNames: ['self'],
726
1072
  },
727
- ],
1073
+ // new visibleDataSets: atd-unmuted removed, "attendees" and "new-dataset" added
1074
+ visibleDataSets: [
1075
+ {
1076
+ name: 'main',
1077
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main',
1078
+ },
1079
+ {
1080
+ name: 'self',
1081
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
1082
+ },
1083
+ {
1084
+ name: 'new-dataset', // this one is not in dataSets, so will require async initialization
1085
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/new-dataset',
1086
+ },
1087
+ {
1088
+ name: 'attendees', // this one is in dataSets, so should be processed immediately
1089
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/attendees',
1090
+ },
1091
+ ],
1092
+ },
1093
+ };
1094
+
1095
+ // Mock the async initialization of the new dataset
1096
+ const newDataSet = createDataSet('new-dataset', 4, 5000);
1097
+ mockGetAllDataSetsMetadata(webexRequest, visibleDataSetsUrl, [newDataSet]);
1098
+ mockSyncRequest(webexRequest, newDataSet.url, {
1099
+ dataSets: [newDataSet],
728
1100
  visibleDataSetsUrl,
729
1101
  locusUrl,
730
- locusStateElements: [
1102
+ locusStateElements: [],
1103
+ });
1104
+
1105
+ // Call handleLocusUpdate
1106
+ parser.handleLocusUpdate(locusUpdate);
1107
+
1108
+ // Verify putItem was called on self hash tree with metadata
1109
+ assert.calledOnceWithExactly(selfPutItemSpy, {type: 'metadata', id: 5, version: 51});
1110
+
1111
+ // Verify callback was called with metadata object and removed dataset objects
1112
+ assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
1113
+ updatedObjects: [
1114
+ // updated metadata object:
731
1115
  {
732
1116
  htMeta: {
733
1117
  elementId: {
734
- type: 'locus' as const,
735
- id: 0,
736
- version: 201,
1118
+ type: 'metadata',
1119
+ id: 5,
1120
+ version: 51,
737
1121
  },
738
- dataSetNames: ['main'],
1122
+ dataSetNames: ['self'],
1123
+ },
1124
+ data: {
1125
+ htMeta: {
1126
+ elementId: {
1127
+ type: 'metadata',
1128
+ id: 5,
1129
+ version: 51,
1130
+ },
1131
+ dataSetNames: ['self'],
1132
+ },
1133
+ visibleDataSets: [
1134
+ {
1135
+ name: 'main',
1136
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main',
1137
+ },
1138
+ {
1139
+ name: 'self',
1140
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
1141
+ },
1142
+ {
1143
+ name: 'new-dataset',
1144
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/new-dataset',
1145
+ },
1146
+ {
1147
+ name: 'attendees',
1148
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/attendees',
1149
+ },
1150
+ ],
739
1151
  },
740
- data: {someData: 'value'},
741
1152
  },
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
1153
+ // removed participant from a removed dataset 'atd-unmuted':
1154
+ {
1155
+ htMeta: {
1156
+ elementId: {
1157
+ type: 'participant',
1158
+ id: 14,
1159
+ version: 300,
1160
+ },
1161
+ dataSetNames: ['atd-unmuted'],
1162
+ },
1163
+ data: null,
1164
+ },
1165
+ // new participant from a new data set 'attendees':
1166
+ {
1167
+ htMeta: {
1168
+ elementId: {
1169
+ type: 'participant',
1170
+ id: 15,
1171
+ version: 999,
1172
+ },
1173
+ dataSetNames: ['attendees'],
1174
+ },
1175
+ data: {
1176
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/15',
1177
+ person: {},
1178
+ htMeta: {
1179
+ elementId: {
1180
+ type: 'participant',
1181
+ id: 15,
1182
+ version: 999,
1183
+ },
1184
+ dataSetNames: ['attendees'],
1185
+ },
1186
+ },
1187
+ },
1188
+ ],
1189
+ });
1190
+
1191
+ // verify also that an async initialization was done for
1192
+ await checkAsyncDatasetInitialization(parser, newDataSet);
1193
+ });
1194
+
1195
+ it('handles metadata updates with same version (no callback)', () => {
1196
+ const parser = createHashTreeParser();
1197
+
1198
+ const selfPutItemSpy = sinon.spy(parser.dataSets.self.hashTree, 'putItem');
1199
+
1200
+ // Create a locus update with metadata that has the same version and same visibleDataSets
1201
+ const locusUpdate = {
1202
+ dataSets: [createDataSet('self', 1, 2100)],
1203
+ locus: {},
1204
+ metadata: {
1205
+ htMeta: {
1206
+ elementId: {
1207
+ type: 'metadata',
1208
+ id: 5,
1209
+ version: 50, // same version as initial
1210
+ },
1211
+ dataSetNames: ['self'],
1212
+ },
1213
+ visibleDataSets: [
1214
+ {
1215
+ name: 'main',
1216
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main',
1217
+ },
1218
+ {
1219
+ name: 'self',
1220
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
1221
+ },
1222
+ {
1223
+ name: 'atd-unmuted',
1224
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/atd-unmuted',
1225
+ },
1226
+ ],
1227
+ },
1228
+ };
1229
+
1230
+ // Call handleLocusUpdate
1231
+ parser.handleLocusUpdate(locusUpdate);
1232
+
1233
+ // Verify putItem was called on self hash tree
1234
+ assert.calledOnceWithExactly(selfPutItemSpy, {type: 'metadata', id: 5, version: 50});
1235
+
1236
+ // Verify callback was NOT called because version didn't change
1237
+ assert.notCalled(callback);
1238
+ });
1239
+
1240
+ it('handles updates with no dataSets and metadata fields gracefully', () => {
1241
+ const parser = createHashTreeParser();
1242
+
1243
+ const mainPutItemsSpy = sinon.spy(parser.dataSets.main.hashTree, 'putItems');
1244
+ const selfPutItemsSpy = sinon.spy(parser.dataSets.self.hashTree, 'putItems');
1245
+ const atdUnmutedPutItemsSpy = sinon.spy(parser.dataSets['atd-unmuted'].hashTree, 'putItems');
1246
+
1247
+ // Create a locus update with no dataSets and no metadata
1248
+ const locusUpdate = {
1249
+ locus: {
1250
+ htMeta: {
1251
+ elementId: {
1252
+ type: 'locus',
1253
+ id: 0,
1254
+ version: 201,
1255
+ },
1256
+ dataSetNames: ['main'],
1257
+ },
1258
+ someData: 'value',
1259
+ },
1260
+ };
1261
+
1262
+ // Call handleLocusUpdate - should not throw
1263
+ parser.handleLocusUpdate(locusUpdate);
1264
+
1265
+ // Verify putItems was still called for the dataset referenced in locus
1266
+ assert.calledOnceWithExactly(mainPutItemsSpy, [{type: 'locus', id: 0, version: 201}]);
1267
+
1268
+ // Verify putItems was not called on other hash trees
1269
+ assert.notCalled(selfPutItemsSpy);
1270
+ assert.notCalled(atdUnmutedPutItemsSpy);
1271
+
1272
+ // Verify callback was called with the updated object
1273
+ assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
1274
+ updatedObjects: [
1275
+ {
1276
+ htMeta: {
1277
+ elementId: {
1278
+ type: 'locus',
1279
+ id: 0,
1280
+ version: 201,
1281
+ },
1282
+ dataSetNames: ['main'],
1283
+ },
1284
+ data: {
1285
+ someData: 'value',
1286
+ htMeta: {
1287
+ elementId: {
1288
+ type: 'locus',
1289
+ id: 0,
1290
+ version: 201,
1291
+ },
1292
+ dataSetNames: ['main'],
1293
+ },
1294
+ },
1295
+ },
1296
+ ],
1297
+ });
1298
+
1299
+ // Verify that dataset versions were NOT updated (no dataSets in the update)
1300
+ expect(parser.dataSets.main.version).to.equal(1000);
1301
+ expect(parser.dataSets.self.version).to.equal(2000);
1302
+ expect(parser.dataSets['atd-unmuted'].version).to.equal(3000);
1303
+ });
1304
+ });
1305
+
1306
+ describe('#handleMessage', () => {
1307
+ it('handles root hash heartbeat message correctly', async () => {
1308
+ const parser = createHashTreeParser();
1309
+
1310
+ // Step 1: Send a normal message with locusStateElements to start the sync timer
1311
+ const normalMessage = {
1312
+ dataSets: [
1313
+ {
1314
+ ...createDataSet('main', 16, 1100),
1315
+ root: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1', // different from our hash
1316
+ },
1317
+ ],
1318
+ visibleDataSetsUrl,
1319
+ locusUrl,
1320
+ locusStateElements: [
1321
+ {
1322
+ htMeta: {
1323
+ elementId: {
1324
+ type: 'locus' as const,
1325
+ id: 0,
1326
+ version: 201,
1327
+ },
1328
+ dataSetNames: ['main'],
1329
+ },
1330
+ data: {someData: 'value'},
1331
+ },
1332
+ ],
1333
+ };
1334
+
1335
+ parser.handleMessage(normalMessage, 'initial message');
1336
+
1337
+ // Verify the timer was set (the sync algorithm should have started)
1338
+ expect(parser.dataSets.main.timer).to.not.be.undefined;
1339
+ const firstTimerDelay = parser.dataSets.main.idleMs; // 1000ms base + random backoff
1340
+
1341
+ // Step 2: Simulate half of the time passing
1342
+ clock.tick(500);
1343
+
1344
+ // Verify no webex requests have been made yet
755
1345
  assert.notCalled(webexRequest);
756
1346
 
757
1347
  // Step 3: Send a heartbeat message (no locusStateElements) with mismatched root hash
@@ -762,7 +1352,7 @@ describe('HashTreeParser', () => {
762
1352
  'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' // still different from our hash
763
1353
  );
764
1354
 
765
- await parser.handleMessage(heartbeatMessage, 'heartbeat message');
1355
+ parser.handleMessage(heartbeatMessage, 'heartbeat message');
766
1356
 
767
1357
  // Verify the timer was restarted (should still exist)
768
1358
  expect(parser.dataSets.main.timer).to.not.be.undefined;
@@ -889,7 +1479,7 @@ describe('HashTreeParser', () => {
889
1479
  ],
890
1480
  };
891
1481
 
892
- await parser.handleMessage(message, 'normal update');
1482
+ parser.handleMessage(message, 'normal update');
893
1483
 
894
1484
  // Verify updateItems was called on main hash tree
895
1485
  assert.calledOnceWithExactly(mainUpdateItemsStub, [
@@ -910,13 +1500,6 @@ describe('HashTreeParser', () => {
910
1500
  // Verify callback was called with OBJECTS_UPDATED and all updated objects
911
1501
  assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
912
1502
  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
1503
  {
921
1504
  htMeta: {
922
1505
  elementId: {type: 'locus', id: 0, version: 201},
@@ -924,10 +1507,6 @@ describe('HashTreeParser', () => {
924
1507
  },
925
1508
  data: {info: {id: 'updated-locus-info'}},
926
1509
  },
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
1510
  {
932
1511
  htMeta: {
933
1512
  elementId: {type: 'self', id: 4, version: 101},
@@ -953,43 +1532,71 @@ describe('HashTreeParser', () => {
953
1532
  });
954
1533
  });
955
1534
 
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]);
1535
+ describe('handles sentinel messages correctly', () => {
1536
+ ['main', 'self', 'unjoined'].forEach((dataSetName) => {
1537
+ it('emits MEETING_ENDED for sentinel message with dataset ' + dataSetName, async () => {
1538
+ const parser = createHashTreeParser();
1539
+
1540
+ // Create a sentinel message: leafCount=1, root=EMPTY_HASH, version higher than current
1541
+ const sentinelMessage = createHeartbeatMessage(
1542
+ dataSetName,
1543
+ 1,
1544
+ parser.dataSets[dataSetName]?.version
1545
+ ? parser.dataSets[dataSetName].version + 1
1546
+ : 10000,
1547
+ EMPTY_HASH
1548
+ );
1549
+
1550
+ // If the dataset doesn't exist yet (e.g. 'unjoined'), create it
1551
+ if (!parser.dataSets[dataSetName]) {
1552
+ parser.dataSets[dataSetName] = {
1553
+ url: `https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/${dataSetName}`,
1554
+ name: dataSetName,
1555
+ version: 1,
1556
+ leafCount: 16,
1557
+ root: '0'.repeat(32),
1558
+ idleMs: 1000,
1559
+ backoff: {maxMs: 1000, exponent: 2},
1560
+ } as any;
1561
+ }
961
1562
 
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
- };
1563
+ parser.handleMessage(sentinelMessage, 'sentinel message');
981
1564
 
982
- await parser.handleMessage(rosterDropMessage, 'roster drop message');
1565
+ // Verify callback was called with MEETING_ENDED
1566
+ assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
1567
+ updatedObjects: undefined,
1568
+ });
983
1569
 
984
- // Verify callback was called with MEETING_ENDED
985
- assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
986
- updatedObjects: undefined,
1570
+ // Verify that all timers were stopped
1571
+ Object.values(parser.dataSets).forEach((ds: any) => {
1572
+ assert.isUndefined(ds.timer);
1573
+ assert.isUndefined(ds.heartbeatWatchdogTimer);
1574
+ });
1575
+ });
987
1576
  });
988
1577
 
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);
1578
+ it('emits MEETING_ENDED for sentinel message with unknown dataset', async () => {
1579
+ const parser = createHashTreeParser();
1580
+
1581
+ // 'unjoined' is a valid sentinel dataset name but is not tracked by the parser
1582
+ assert.isUndefined(parser.dataSets['unjoined']);
1583
+
1584
+ // Create a sentinel message for 'unjoined' dataset which the parser has never seen
1585
+ const sentinelMessage = createHeartbeatMessage('unjoined', 1, 10000, EMPTY_HASH);
1586
+
1587
+ parser.handleMessage(sentinelMessage, 'sentinel message');
1588
+
1589
+ // Verify callback was called with MEETING_ENDED
1590
+ assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
1591
+ updatedObjects: undefined,
1592
+ });
1593
+
1594
+ // Verify that all timers were stopped
1595
+ Object.values(parser.dataSets).forEach((ds: any) => {
1596
+ assert.isUndefined(ds.timer);
1597
+ assert.isUndefined(ds.heartbeatWatchdogTimer);
1598
+ });
1599
+ });
993
1600
  });
994
1601
 
995
1602
  describe('sync algorithm', () => {
@@ -1020,7 +1627,7 @@ describe('HashTreeParser', () => {
1020
1627
  ],
1021
1628
  };
1022
1629
 
1023
- await parser.handleMessage(message, 'initial message');
1630
+ parser.handleMessage(message, 'initial message');
1024
1631
 
1025
1632
  // Verify callback was called with initial updates
1026
1633
  assert.calledOnce(callback);
@@ -1090,6 +1697,134 @@ describe('HashTreeParser', () => {
1090
1697
  ],
1091
1698
  });
1092
1699
  });
1700
+
1701
+ describe('emits MEETING_ENDED', () => {
1702
+ [404, 409].forEach((statusCode) => {
1703
+ it(`when /hashtree returns ${statusCode}`, async () => {
1704
+ const parser = createHashTreeParser();
1705
+
1706
+ // Send a message to trigger sync algorithm
1707
+ const message = {
1708
+ dataSets: [createDataSet('main', 16, 1100)],
1709
+ visibleDataSetsUrl,
1710
+ locusUrl,
1711
+ locusStateElements: [
1712
+ {
1713
+ htMeta: {
1714
+ elementId: {
1715
+ type: 'locus' as const,
1716
+ id: 0,
1717
+ version: 201,
1718
+ },
1719
+ dataSetNames: ['main'],
1720
+ },
1721
+ data: {info: {id: 'initial-update'}},
1722
+ },
1723
+ ],
1724
+ };
1725
+
1726
+ parser.handleMessage(message, 'initial message');
1727
+ callback.resetHistory();
1728
+
1729
+ const mainDataSetUrl = parser.dataSets.main.url;
1730
+
1731
+ // Mock getHashesFromLocus to reject with the sentinel error
1732
+ const error: any = new Error(`Request failed with status ${statusCode}`);
1733
+ error.statusCode = statusCode;
1734
+ if (statusCode === 409) {
1735
+ error.body = {errorCode: 2403004};
1736
+ }
1737
+ webexRequest
1738
+ .withArgs(
1739
+ sinon.match({
1740
+ method: 'GET',
1741
+ uri: `${mainDataSetUrl}/hashtree`,
1742
+ })
1743
+ )
1744
+ .rejects(error);
1745
+
1746
+ // Trigger sync by advancing time
1747
+ await clock.tickAsync(1000);
1748
+
1749
+ // Verify callback was called with MEETING_ENDED
1750
+ assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
1751
+ updatedObjects: undefined,
1752
+ });
1753
+
1754
+ // Verify all timers are stopped
1755
+ Object.values(parser.dataSets).forEach((ds: any) => {
1756
+ assert.isUndefined(ds.timer);
1757
+ assert.isUndefined(ds.heartbeatWatchdogTimer);
1758
+ });
1759
+ });
1760
+
1761
+ it(`when /sync returns ${statusCode}`, async () => {
1762
+ const parser = createHashTreeParser();
1763
+
1764
+ // Send a message to trigger sync algorithm
1765
+ const message = {
1766
+ dataSets: [createDataSet('main', 16, 1100)],
1767
+ visibleDataSetsUrl,
1768
+ locusUrl,
1769
+ locusStateElements: [
1770
+ {
1771
+ htMeta: {
1772
+ elementId: {
1773
+ type: 'locus' as const,
1774
+ id: 0,
1775
+ version: 201,
1776
+ },
1777
+ dataSetNames: ['main'],
1778
+ },
1779
+ data: {info: {id: 'initial-update'}},
1780
+ },
1781
+ ],
1782
+ };
1783
+
1784
+ parser.handleMessage(message, 'initial message');
1785
+ callback.resetHistory();
1786
+
1787
+ const mainDataSetUrl = parser.dataSets.main.url;
1788
+
1789
+ // Mock getHashesFromLocus to succeed
1790
+ mockGetHashesFromLocusResponse(
1791
+ mainDataSetUrl,
1792
+ new Array(16).fill('00000000000000000000000000000000'),
1793
+ createDataSet('main', 16, 1101)
1794
+ );
1795
+
1796
+ // Mock sendSyncRequestToLocus to reject with the sentinel error
1797
+ const error: any = new Error(`Request failed with status ${statusCode}`);
1798
+ error.statusCode = statusCode;
1799
+ if (statusCode === 409) {
1800
+ error.body = {errorCode: 2403004};
1801
+ }
1802
+ webexRequest
1803
+ .withArgs(
1804
+ sinon.match({
1805
+ method: 'POST',
1806
+ uri: `${mainDataSetUrl}/sync`,
1807
+ })
1808
+ )
1809
+ .rejects(error);
1810
+
1811
+ // Trigger sync by advancing time
1812
+ await clock.tickAsync(1000);
1813
+
1814
+ // Verify callback was called with MEETING_ENDED
1815
+ assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
1816
+ updatedObjects: undefined,
1817
+ });
1818
+
1819
+ // Verify all timers are stopped
1820
+ Object.values(parser.dataSets).forEach((ds: any) => {
1821
+ assert.isUndefined(ds.timer);
1822
+ assert.isUndefined(ds.heartbeatWatchdogTimer);
1823
+ });
1824
+ });
1825
+ });
1826
+ });
1827
+
1093
1828
  it('requests only mismatched hashes during sync', async () => {
1094
1829
  const parser = createHashTreeParser();
1095
1830
 
@@ -1135,7 +1870,7 @@ describe('HashTreeParser', () => {
1135
1870
  ],
1136
1871
  };
1137
1872
 
1138
- await parser.handleMessage(message, 'initial message');
1873
+ parser.handleMessage(message, 'initial message');
1139
1874
 
1140
1875
  callback.resetHistory();
1141
1876
 
@@ -1179,6 +1914,9 @@ describe('HashTreeParser', () => {
1179
1914
  sinon.match({
1180
1915
  method: 'GET',
1181
1916
  uri: `${mainDataSetUrl}/hashtree`,
1917
+ qs: {
1918
+ rootHash: hashTree.getRootHash(),
1919
+ },
1182
1920
  })
1183
1921
  );
1184
1922
 
@@ -1186,6 +1924,7 @@ describe('HashTreeParser', () => {
1186
1924
  assert.calledWith(webexRequest, {
1187
1925
  method: 'POST',
1188
1926
  uri: `${mainDataSetUrl}/sync`,
1927
+ qs: {rootHash: hashTree.getRootHash()},
1189
1928
  body: {
1190
1929
  leafCount: 16,
1191
1930
  leafDataEntries: [
@@ -1219,7 +1958,7 @@ describe('HashTreeParser', () => {
1219
1958
  ],
1220
1959
  };
1221
1960
 
1222
- await parser.handleMessage(message, 'message with self update');
1961
+ parser.handleMessage(message, 'message with self update');
1223
1962
 
1224
1963
  callback.resetHistory();
1225
1964
 
@@ -1239,10 +1978,17 @@ describe('HashTreeParser', () => {
1239
1978
  assert.calledWith(webexRequest, {
1240
1979
  method: 'POST',
1241
1980
  uri: `${parser.dataSets.self.url}/sync`,
1981
+ qs: {rootHash: parser.dataSets.self.hashTree.getRootHash()},
1242
1982
  body: {
1243
1983
  leafCount: 1,
1244
1984
  leafDataEntries: [
1245
- {leafIndex: 0, elementIds: [{type: 'self', id: 4, version: 102}]},
1985
+ {
1986
+ leafIndex: 0,
1987
+ elementIds: [
1988
+ {type: 'self', id: 4, version: 102},
1989
+ {type: 'metadata', id: 5, version: 50},
1990
+ ],
1991
+ },
1246
1992
  ],
1247
1993
  },
1248
1994
  });
@@ -1257,7 +2003,7 @@ describe('HashTreeParser', () => {
1257
2003
  // Stub updateItems on self hash tree to return true
1258
2004
  sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
1259
2005
 
1260
- // Send a message with SELF object that has a new visibleDataSets list
2006
+ // Send a message with Metadata object that has a new visibleDataSets list
1261
2007
  const message = {
1262
2008
  dataSets: [createDataSet('self', 1, 2100), createDataSet('attendees', 8, 4000)],
1263
2009
  visibleDataSetsUrl,
@@ -1266,47 +2012,98 @@ describe('HashTreeParser', () => {
1266
2012
  {
1267
2013
  htMeta: {
1268
2014
  elementId: {
1269
- type: 'self' as const,
1270
- id: 4,
1271
- version: 101,
2015
+ type: 'metadata' as const,
2016
+ id: 5,
2017
+ version: 51,
1272
2018
  },
1273
2019
  dataSetNames: ['self'],
1274
2020
  },
1275
2021
  data: {
1276
- visibleDataSets: ['main', 'self', 'atd-unmuted', 'attendees'], // added 'attendees'
2022
+ visibleDataSets: [
2023
+ {
2024
+ name: 'main',
2025
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main',
2026
+ },
2027
+ {
2028
+ name: 'self',
2029
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
2030
+ },
2031
+ {
2032
+ name: 'atd-unmuted',
2033
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/atd-unmuted',
2034
+ },
2035
+ {
2036
+ name: 'attendees',
2037
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/attendees',
2038
+ },
2039
+ ], // added 'attendees'
1277
2040
  },
1278
2041
  },
1279
2042
  ],
1280
2043
  };
1281
2044
 
1282
- await parser.handleMessage(message, 'add visible dataset');
2045
+ parser.handleMessage(message, 'add visible dataset');
1283
2046
 
1284
2047
  // Verify that 'attendees' was added to visibleDataSets
1285
- assert.include(parser.visibleDataSets, 'attendees');
2048
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'attendees')).to.be.true;
1286
2049
 
1287
2050
  // Verify that a hash tree was created for 'attendees'
1288
2051
  assert.exists(parser.dataSets.attendees.hashTree);
1289
2052
  assert.equal(parser.dataSets.attendees.hashTree.numLeaves, 8);
1290
2053
 
1291
- // Verify callback was called with the self update (appears twice due to SPARK-744859)
2054
+ // Verify callback was called with the metadata update (appears twice - processed once for visible dataset changes, once in main loop)
1292
2055
  assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
1293
2056
  updatedObjects: [
1294
2057
  {
1295
2058
  htMeta: {
1296
- elementId: {type: 'self', id: 4, version: 101},
2059
+ elementId: {type: 'metadata', id: 5, version: 51},
1297
2060
  dataSetNames: ['self'],
1298
2061
  },
1299
2062
  data: {
1300
- visibleDataSets: ['main', 'self', 'atd-unmuted', 'attendees'],
2063
+ visibleDataSets: [
2064
+ {
2065
+ name: 'main',
2066
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main',
2067
+ },
2068
+ {
2069
+ name: 'self',
2070
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
2071
+ },
2072
+ {
2073
+ name: 'atd-unmuted',
2074
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/atd-unmuted',
2075
+ },
2076
+ {
2077
+ name: 'attendees',
2078
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/attendees',
2079
+ },
2080
+ ],
1301
2081
  },
1302
2082
  },
1303
2083
  {
1304
2084
  htMeta: {
1305
- elementId: {type: 'self', id: 4, version: 101},
2085
+ elementId: {type: 'metadata', id: 5, version: 51},
1306
2086
  dataSetNames: ['self'],
1307
2087
  },
1308
2088
  data: {
1309
- visibleDataSets: ['main', 'self', 'atd-unmuted', 'attendees'],
2089
+ visibleDataSets: [
2090
+ {
2091
+ name: 'main',
2092
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main',
2093
+ },
2094
+ {
2095
+ name: 'self',
2096
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
2097
+ },
2098
+ {
2099
+ name: 'atd-unmuted',
2100
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/atd-unmuted',
2101
+ },
2102
+ {
2103
+ name: 'attendees',
2104
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/attendees',
2105
+ },
2106
+ ],
1310
2107
  },
1311
2108
  },
1312
2109
  ],
@@ -1320,7 +2117,7 @@ describe('HashTreeParser', () => {
1320
2117
  // Stub updateItems on self hash tree to return true
1321
2118
  sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
1322
2119
 
1323
- // Send a message with SELF object that has a new visibleDataSets list (adding 'new-dataset')
2120
+ // Send a message with Metadata object that has a new visibleDataSets list (adding 'new-dataset')
1324
2121
  // but WITHOUT providing info about the new dataset in dataSets array
1325
2122
  const message = {
1326
2123
  dataSets: [createDataSet('self', 1, 2100)],
@@ -1330,14 +2127,31 @@ describe('HashTreeParser', () => {
1330
2127
  {
1331
2128
  htMeta: {
1332
2129
  elementId: {
1333
- type: 'self' as const,
1334
- id: 4,
1335
- version: 101,
2130
+ type: 'metadata' as const,
2131
+ id: 5,
2132
+ version: 51,
1336
2133
  },
1337
2134
  dataSetNames: ['self'],
1338
2135
  },
1339
2136
  data: {
1340
- visibleDataSets: ['main', 'self', 'atd-unmuted', 'new-dataset'],
2137
+ visibleDataSets: [
2138
+ {
2139
+ name: 'main',
2140
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main',
2141
+ },
2142
+ {
2143
+ name: 'self',
2144
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
2145
+ },
2146
+ {
2147
+ name: 'atd-unmuted',
2148
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/atd-unmuted',
2149
+ },
2150
+ {
2151
+ name: 'new-dataset',
2152
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/new-dataset',
2153
+ },
2154
+ ],
1341
2155
  },
1342
2156
  },
1343
2157
  ],
@@ -1353,40 +2167,80 @@ describe('HashTreeParser', () => {
1353
2167
  locusStateElements: [],
1354
2168
  });
1355
2169
 
1356
- await parser.handleMessage(message, 'add new dataset requiring async init');
2170
+ parser.handleMessage(message, 'add new dataset requiring async init');
1357
2171
 
1358
- // immediately we don't have the dataset yet, so it should not be in visibleDataSets
1359
- // and no hash tree should exist yet
1360
- assert.isFalse(parser.visibleDataSets.includes('new-dataset'));
1361
- assert.isUndefined(parser.dataSets['new-dataset']);
2172
+ await checkAsyncDatasetInitialization(parser, newDataSet);
2173
+ });
1362
2174
 
1363
- // Wait for the async initialization to complete (queued as microtask)
1364
- await clock.tickAsync(0);
2175
+ it('emits MEETING_ENDED if async init of a new visible dataset fails with 404', async () => {
2176
+ const parser = createHashTreeParser();
1365
2177
 
1366
- // The visibleDataSets is updated from the self object data
1367
- assert.include(parser.visibleDataSets, 'new-dataset');
2178
+ // Stub updateItems on self hash tree to return true
2179
+ sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
1368
2180
 
1369
- // Verify that a hash tree was created for 'new-dataset'
1370
- assert.exists(parser.dataSets['new-dataset'].hashTree);
1371
- assert.equal(parser.dataSets['new-dataset'].hashTree.numLeaves, 4);
2181
+ // Send a message with Metadata object that adds a new visible dataset
2182
+ const message = {
2183
+ dataSets: [createDataSet('self', 1, 2100)],
2184
+ visibleDataSetsUrl,
2185
+ locusUrl,
2186
+ locusStateElements: [
2187
+ {
2188
+ htMeta: {
2189
+ elementId: {
2190
+ type: 'metadata' as const,
2191
+ id: 5,
2192
+ version: 51,
2193
+ },
2194
+ dataSetNames: ['self'],
2195
+ },
2196
+ data: {
2197
+ visibleDataSets: [
2198
+ {
2199
+ name: 'main',
2200
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main',
2201
+ },
2202
+ {
2203
+ name: 'self',
2204
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
2205
+ },
2206
+ {
2207
+ name: 'atd-unmuted',
2208
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/atd-unmuted',
2209
+ },
2210
+ {
2211
+ name: 'new-dataset',
2212
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/new-dataset',
2213
+ },
2214
+ ],
2215
+ },
2216
+ },
2217
+ ],
2218
+ };
1372
2219
 
1373
- // Verify getAllDataSetsMetadata was called for async initialization
1374
- assert.calledWith(
1375
- webexRequest,
1376
- sinon.match({
1377
- method: 'GET',
1378
- uri: visibleDataSetsUrl,
1379
- })
1380
- );
2220
+ // Mock getAllDataSetsMetadata to reject with 404
2221
+ const error: any = new Error('Request failed with status 404');
2222
+ error.statusCode = 404;
2223
+ webexRequest
2224
+ .withArgs(
2225
+ sinon.match({
2226
+ method: 'GET',
2227
+ uri: visibleDataSetsUrl,
2228
+ })
2229
+ )
2230
+ .rejects(error);
2231
+
2232
+ parser.handleMessage(message, 'add new dataset triggering 404');
2233
+
2234
+ // The first callback call is from parseMessage with the metadata update
2235
+ callback.resetHistory();
1381
2236
 
1382
- // Verify sync request was sent for the new dataset
1383
- assert.calledWith(
1384
- webexRequest,
1385
- sinon.match({
1386
- method: 'POST',
1387
- uri: `${newDataSet.url}/sync`,
1388
- })
1389
- );
2237
+ // Wait for the async initialization (queueMicrotask) to complete
2238
+ await clock.tickAsync(0);
2239
+
2240
+ // Verify callback was called with MEETING_ENDED
2241
+ assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
2242
+ updatedObjects: undefined,
2243
+ });
1390
2244
  });
1391
2245
 
1392
2246
  it('handles removal of visible data set', async () => {
@@ -1406,7 +2260,7 @@ describe('HashTreeParser', () => {
1406
2260
  // Stub updateItems on self hash tree to return true
1407
2261
  sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
1408
2262
 
1409
- // Send a message with SELF object that has removed 'atd-unmuted' from visibleDataSets
2263
+ // Send a message with Metadata object that has removed 'atd-unmuted' from visibleDataSets
1410
2264
  const message = {
1411
2265
  dataSets: [createDataSet('self', 1, 2100)],
1412
2266
  visibleDataSetsUrl,
@@ -1415,23 +2269,32 @@ describe('HashTreeParser', () => {
1415
2269
  {
1416
2270
  htMeta: {
1417
2271
  elementId: {
1418
- type: 'self' as const,
1419
- id: 4,
1420
- version: 101,
2272
+ type: 'metadata' as const,
2273
+ id: 5,
2274
+ version: 51,
1421
2275
  },
1422
2276
  dataSetNames: ['self'],
1423
2277
  },
1424
2278
  data: {
1425
- visibleDataSets: ['main', 'self'], // removed 'atd-unmuted'
2279
+ visibleDataSets: [
2280
+ {
2281
+ name: 'main',
2282
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main',
2283
+ },
2284
+ {
2285
+ name: 'self',
2286
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
2287
+ },
2288
+ ], // removed 'atd-unmuted'
1426
2289
  },
1427
2290
  },
1428
2291
  ],
1429
2292
  };
1430
2293
 
1431
- await parser.handleMessage(message, 'remove visible dataset');
2294
+ parser.handleMessage(message, 'remove visible dataset');
1432
2295
 
1433
2296
  // Verify that 'atd-unmuted' was removed from visibleDataSets
1434
- assert.notInclude(parser.visibleDataSets, 'atd-unmuted');
2297
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'atd-unmuted')).to.be.false;
1435
2298
 
1436
2299
  // Verify that the hash tree for 'atd-unmuted' was deleted
1437
2300
  assert.isUndefined(parser.dataSets['atd-unmuted'].hashTree);
@@ -1439,16 +2302,25 @@ describe('HashTreeParser', () => {
1439
2302
  // Verify that the timer was cleared
1440
2303
  assert.isUndefined(parser.dataSets['atd-unmuted'].timer);
1441
2304
 
1442
- // Verify callback was called with both the self update and the removed objects
2305
+ // Verify callback was called with the metadata update and the removed objects (metadata appears twice - processed once for dataset changes, once in main loop)
1443
2306
  assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
1444
2307
  updatedObjects: [
1445
2308
  {
1446
2309
  htMeta: {
1447
- elementId: {type: 'self', id: 4, version: 101},
2310
+ elementId: {type: 'metadata', id: 5, version: 51},
1448
2311
  dataSetNames: ['self'],
1449
2312
  },
1450
2313
  data: {
1451
- visibleDataSets: ['main', 'self'],
2314
+ visibleDataSets: [
2315
+ {
2316
+ name: 'main',
2317
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main',
2318
+ },
2319
+ {
2320
+ name: 'self',
2321
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
2322
+ },
2323
+ ],
1452
2324
  },
1453
2325
  },
1454
2326
  {
@@ -1460,11 +2332,20 @@ describe('HashTreeParser', () => {
1460
2332
  },
1461
2333
  {
1462
2334
  htMeta: {
1463
- elementId: {type: 'self', id: 4, version: 101}, // 2nd self because of SPARK-744859
2335
+ elementId: {type: 'metadata', id: 5, version: 51},
1464
2336
  dataSetNames: ['self'],
1465
2337
  },
1466
2338
  data: {
1467
- visibleDataSets: ['main', 'self'],
2339
+ visibleDataSets: [
2340
+ {
2341
+ name: 'main',
2342
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main',
2343
+ },
2344
+ {
2345
+ name: 'self',
2346
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
2347
+ },
2348
+ ],
1468
2349
  },
1469
2350
  },
1470
2351
  ],
@@ -1489,7 +2370,7 @@ describe('HashTreeParser', () => {
1489
2370
  });
1490
2371
 
1491
2372
  // Verify attendees is NOT in visibleDataSets
1492
- assert.notInclude(parser.visibleDataSets, 'attendees');
2373
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'attendees')).to.be.false;
1493
2374
 
1494
2375
  // Send a message with attendees data
1495
2376
  const message = {
@@ -1511,7 +2392,7 @@ describe('HashTreeParser', () => {
1511
2392
  ],
1512
2393
  };
1513
2394
 
1514
- await parser.handleMessage(message, 'message with non-visible dataset');
2395
+ parser.handleMessage(message, 'message with non-visible dataset');
1515
2396
 
1516
2397
  // Verify that no hash tree was created for attendees
1517
2398
  assert.isUndefined(parser.dataSets.attendees.hashTree);
@@ -1520,5 +2401,1270 @@ describe('HashTreeParser', () => {
1520
2401
  assert.notCalled(callback);
1521
2402
  });
1522
2403
  });
2404
+
2405
+ describe('heartbeat watchdog', () => {
2406
+ it('initiates sync immediately only for the specific data set whose heartbeat watchdog fires', async () => {
2407
+ const parser = createHashTreeParser();
2408
+ const heartbeatIntervalMs = 5000;
2409
+
2410
+ // Send initial heartbeat message for 'main' only
2411
+ const heartbeatMessage = {
2412
+ dataSets: [
2413
+ {
2414
+ ...createDataSet('main', 16, 1100),
2415
+ root: parser.dataSets.main.hashTree.getRootHash(),
2416
+ },
2417
+ ],
2418
+ visibleDataSetsUrl,
2419
+ locusUrl,
2420
+ heartbeatIntervalMs,
2421
+ };
2422
+
2423
+ parser.handleMessage(heartbeatMessage, 'initial heartbeat');
2424
+
2425
+ // Verify only 'main' watchdog timer is set
2426
+ expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
2427
+ expect(parser.dataSets.self.heartbeatWatchdogTimer).to.be.undefined;
2428
+ expect(parser.dataSets['atd-unmuted'].heartbeatWatchdogTimer).to.be.undefined;
2429
+
2430
+ // Mock responses for performSync (GET hashtree then POST sync for leafCount > 1)
2431
+ const mainDataSetUrl = parser.dataSets.main.url;
2432
+ mockGetHashesFromLocusResponse(
2433
+ mainDataSetUrl,
2434
+ new Array(16).fill('00000000000000000000000000000000'),
2435
+ createDataSet('main', 16, 1101)
2436
+ );
2437
+ mockSendSyncRequestResponse(mainDataSetUrl, null);
2438
+
2439
+ // Advance time past heartbeatIntervalMs + backoff (Math.random returns 0, so backoff = 0)
2440
+ // performSync is called immediately when the watchdog fires - no additional delay
2441
+ await clock.tickAsync(heartbeatIntervalMs);
2442
+
2443
+ // Verify sync request was sent immediately for 'main' (GET hashtree + POST sync)
2444
+ assert.calledWith(
2445
+ webexRequest,
2446
+ sinon.match({
2447
+ method: 'GET',
2448
+ uri: `${mainDataSetUrl}/hashtree`,
2449
+ })
2450
+ );
2451
+
2452
+ // Verify no sync requests were sent for other datasets
2453
+ assert.neverCalledWith(
2454
+ webexRequest,
2455
+ sinon.match({
2456
+ method: 'POST',
2457
+ uri: `${parser.dataSets.self.url}/sync`,
2458
+ })
2459
+ );
2460
+ assert.neverCalledWith(
2461
+ webexRequest,
2462
+ sinon.match({
2463
+ method: 'GET',
2464
+ uri: `${parser.dataSets['atd-unmuted'].url}/hashtree`,
2465
+ })
2466
+ );
2467
+ });
2468
+
2469
+ it('calls POST sync directly for leafCount === 1 data sets', async () => {
2470
+ const parser = createHashTreeParser();
2471
+ const heartbeatIntervalMs = 5000;
2472
+
2473
+ // Send heartbeat for 'self' (leafCount === 1)
2474
+ const heartbeatMessage = {
2475
+ dataSets: [
2476
+ {
2477
+ ...createDataSet('self', 1, 2100),
2478
+ url: parser.dataSets.self.url,
2479
+ root: parser.dataSets.self.hashTree.getRootHash(),
2480
+ },
2481
+ ],
2482
+ visibleDataSetsUrl,
2483
+ locusUrl,
2484
+ heartbeatIntervalMs,
2485
+ };
2486
+
2487
+ parser.handleMessage(heartbeatMessage, 'self heartbeat');
2488
+
2489
+ // Mock sync response for self
2490
+ mockSendSyncRequestResponse(parser.dataSets.self.url, null);
2491
+
2492
+ // Advance time past watchdog delay
2493
+ await clock.tickAsync(heartbeatIntervalMs);
2494
+
2495
+ // For leafCount === 1, performSync skips GET hashtree and goes straight to POST sync
2496
+ assert.neverCalledWith(
2497
+ webexRequest,
2498
+ sinon.match({
2499
+ method: 'GET',
2500
+ uri: `${parser.dataSets.self.url}/hashtree`,
2501
+ })
2502
+ );
2503
+ assert.calledWith(
2504
+ webexRequest,
2505
+ sinon.match({
2506
+ method: 'POST',
2507
+ uri: `${parser.dataSets.self.url}/sync`,
2508
+ })
2509
+ );
2510
+ });
2511
+
2512
+ it('sets watchdog timers for each data set in the message', async () => {
2513
+ const parser = createHashTreeParser();
2514
+ const heartbeatIntervalMs = 5000;
2515
+
2516
+ // Send heartbeat with multiple datasets
2517
+ const heartbeatMessage = {
2518
+ dataSets: [
2519
+ {
2520
+ ...createDataSet('main', 16, 1100),
2521
+ root: parser.dataSets.main.hashTree.getRootHash(),
2522
+ },
2523
+ {
2524
+ ...createDataSet('self', 1, 2100),
2525
+ url: parser.dataSets.self.url,
2526
+ root: parser.dataSets.self.hashTree.getRootHash(),
2527
+ },
2528
+ ],
2529
+ visibleDataSetsUrl,
2530
+ locusUrl,
2531
+ heartbeatIntervalMs,
2532
+ };
2533
+
2534
+ parser.handleMessage(heartbeatMessage, 'multi-dataset heartbeat');
2535
+
2536
+ // Watchdog timers should be set for both datasets in the message
2537
+ expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
2538
+ expect(parser.dataSets.self.heartbeatWatchdogTimer).to.not.be.undefined;
2539
+ // But not for datasets not in the message
2540
+ expect(parser.dataSets['atd-unmuted'].heartbeatWatchdogTimer).to.be.undefined;
2541
+ });
2542
+
2543
+ it('resets the watchdog timer for a specific data set when a new heartbeat for it is received', async () => {
2544
+ const parser = createHashTreeParser();
2545
+ const heartbeatIntervalMs = 5000;
2546
+
2547
+ // Send first heartbeat for 'main'
2548
+ const heartbeat1 = {
2549
+ dataSets: [
2550
+ {
2551
+ ...createDataSet('main', 16, 1100),
2552
+ root: parser.dataSets.main.hashTree.getRootHash(),
2553
+ },
2554
+ ],
2555
+ visibleDataSetsUrl,
2556
+ locusUrl,
2557
+ heartbeatIntervalMs,
2558
+ };
2559
+
2560
+ parser.handleMessage(heartbeat1, 'first heartbeat');
2561
+
2562
+ const firstTimer = parser.dataSets.main.heartbeatWatchdogTimer;
2563
+ expect(firstTimer).to.not.be.undefined;
2564
+
2565
+ // Advance time to just before the watchdog would fire
2566
+ clock.tick(4000);
2567
+
2568
+ // Send second heartbeat for 'main' - this should reset the watchdog
2569
+ const heartbeat2 = {
2570
+ dataSets: [
2571
+ {
2572
+ ...createDataSet('main', 16, 1101),
2573
+ root: parser.dataSets.main.hashTree.getRootHash(),
2574
+ },
2575
+ ],
2576
+ visibleDataSetsUrl,
2577
+ locusUrl,
2578
+ heartbeatIntervalMs,
2579
+ };
2580
+
2581
+ parser.handleMessage(heartbeat2, 'second heartbeat');
2582
+
2583
+ const secondTimer = parser.dataSets.main.heartbeatWatchdogTimer;
2584
+ expect(secondTimer).to.not.be.undefined;
2585
+ expect(secondTimer).to.not.equal(firstTimer);
2586
+
2587
+ // Advance another 4000ms (total 8000ms from start, but only 4000ms since last heartbeat)
2588
+ // The watchdog should NOT fire yet
2589
+ await clock.tickAsync(4000);
2590
+
2591
+ // No sync requests should have been sent
2592
+ assert.notCalled(webexRequest);
2593
+ });
2594
+
2595
+ it('resets the watchdog timer when a normal message (with locusStateElements) is received', async () => {
2596
+ const parser = createHashTreeParser();
2597
+ const heartbeatIntervalMs = 5000;
2598
+
2599
+ // Send initial heartbeat to start the watchdog for 'main'
2600
+ const heartbeat = {
2601
+ dataSets: [
2602
+ {
2603
+ ...createDataSet('main', 16, 1100),
2604
+ root: parser.dataSets.main.hashTree.getRootHash(),
2605
+ },
2606
+ ],
2607
+ visibleDataSetsUrl,
2608
+ locusUrl,
2609
+ heartbeatIntervalMs,
2610
+ };
2611
+
2612
+ parser.handleMessage(heartbeat, 'initial heartbeat');
2613
+
2614
+ const firstTimer = parser.dataSets.main.heartbeatWatchdogTimer;
2615
+ expect(firstTimer).to.not.be.undefined;
2616
+
2617
+ // Advance time partially
2618
+ clock.tick(3000);
2619
+
2620
+ // Stub updateItems so the normal message is processed
2621
+ sinon.stub(parser.dataSets.main.hashTree, 'updateItems').returns([true]);
2622
+
2623
+ // Send a normal message (with locusStateElements) for 'main' - should also reset watchdog
2624
+ const normalMessage = {
2625
+ dataSets: [createDataSet('main', 16, 1101)],
2626
+ visibleDataSetsUrl,
2627
+ locusUrl,
2628
+ locusStateElements: [
2629
+ {
2630
+ htMeta: {
2631
+ elementId: {type: 'locus' as const, id: 0, version: 201},
2632
+ dataSetNames: ['main'],
2633
+ },
2634
+ data: {someData: 'value'},
2635
+ },
2636
+ ],
2637
+ heartbeatIntervalMs,
2638
+ };
2639
+
2640
+ parser.handleMessage(normalMessage, 'normal message');
2641
+
2642
+ const secondTimer = parser.dataSets.main.heartbeatWatchdogTimer;
2643
+ expect(secondTimer).to.not.be.undefined;
2644
+ expect(secondTimer).to.not.equal(firstTimer);
2645
+ });
2646
+
2647
+ it('does not set the watchdog timer when heartbeatIntervalMs is not set', async () => {
2648
+ const parser = createHashTreeParser();
2649
+
2650
+ // Send a heartbeat message without heartbeatIntervalMs
2651
+ const heartbeatMessage = createHeartbeatMessage(
2652
+ 'main',
2653
+ 16,
2654
+ 1100,
2655
+ parser.dataSets.main.hashTree.getRootHash()
2656
+ );
2657
+
2658
+ parser.handleMessage(heartbeatMessage, 'heartbeat without interval');
2659
+
2660
+ expect(parser.dataSets.main.heartbeatWatchdogTimer).to.be.undefined;
2661
+ });
2662
+
2663
+ it('stops all watchdog timers when meeting ends via sentinel message', async () => {
2664
+ const parser = createHashTreeParser();
2665
+ const heartbeatIntervalMs = 5000;
2666
+
2667
+ // Send heartbeat for multiple datasets
2668
+ const heartbeat = {
2669
+ dataSets: [
2670
+ {
2671
+ ...createDataSet('main', 16, 1100),
2672
+ root: parser.dataSets.main.hashTree.getRootHash(),
2673
+ },
2674
+ {
2675
+ ...createDataSet('self', 1, 2100),
2676
+ url: parser.dataSets.self.url,
2677
+ root: parser.dataSets.self.hashTree.getRootHash(),
2678
+ },
2679
+ ],
2680
+ visibleDataSetsUrl,
2681
+ locusUrl,
2682
+ heartbeatIntervalMs,
2683
+ };
2684
+
2685
+ parser.handleMessage(heartbeat, 'initial heartbeat');
2686
+
2687
+ expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
2688
+ expect(parser.dataSets.self.heartbeatWatchdogTimer).to.not.be.undefined;
2689
+
2690
+ // Send a sentinel END MEETING message
2691
+ const sentinelMessage = createHeartbeatMessage(
2692
+ 'main',
2693
+ 1,
2694
+ parser.dataSets.main.version + 1,
2695
+ EMPTY_HASH
2696
+ );
2697
+
2698
+ parser.handleMessage(sentinelMessage as any, 'sentinel message');
2699
+
2700
+ // All watchdog timers should have been stopped
2701
+ expect(parser.dataSets.main.heartbeatWatchdogTimer).to.be.undefined;
2702
+ expect(parser.dataSets.self.heartbeatWatchdogTimer).to.be.undefined;
2703
+ });
2704
+
2705
+ it("uses each data set's own backoff for its watchdog delay", async () => {
2706
+ // Create a parser where datasets have different backoff configs
2707
+ const initialLocus = {
2708
+ dataSets: [
2709
+ {
2710
+ ...createDataSet('main', 16, 1000),
2711
+ backoff: {maxMs: 500, exponent: 2},
2712
+ },
2713
+ {
2714
+ ...createDataSet('self', 1, 2000),
2715
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
2716
+ backoff: {maxMs: 2000, exponent: 3},
2717
+ },
2718
+ ],
2719
+ locus: {
2720
+ ...exampleInitialLocus.locus,
2721
+ },
2722
+ };
2723
+
2724
+ const metadata = {
2725
+ ...exampleMetadata,
2726
+ visibleDataSets: [
2727
+ {name: 'main', url: initialLocus.dataSets[0].url},
2728
+ {name: 'self', url: initialLocus.dataSets[1].url},
2729
+ ],
2730
+ };
2731
+
2732
+ const parser = createHashTreeParser(initialLocus, metadata);
2733
+ const heartbeatIntervalMs = 5000;
2734
+
2735
+ // Set Math.random to return 1 so that backoff = 1^exponent * maxMs = maxMs
2736
+ mathRandomStub.returns(1);
2737
+
2738
+ // Send heartbeat for both datasets
2739
+ const heartbeat = {
2740
+ dataSets: [
2741
+ {
2742
+ ...createDataSet('main', 16, 1100),
2743
+ backoff: {maxMs: 500, exponent: 2},
2744
+ root: parser.dataSets.main.hashTree.getRootHash(),
2745
+ },
2746
+ {
2747
+ ...createDataSet('self', 1, 2100),
2748
+ url: parser.dataSets.self.url,
2749
+ backoff: {maxMs: 2000, exponent: 3},
2750
+ root: parser.dataSets.self.hashTree.getRootHash(),
2751
+ },
2752
+ ],
2753
+ visibleDataSetsUrl,
2754
+ locusUrl,
2755
+ heartbeatIntervalMs,
2756
+ };
2757
+
2758
+ parser.handleMessage(heartbeat, 'heartbeat');
2759
+
2760
+ // 'main' watchdog delay = 5000 + 1^2 * 500 = 5500ms
2761
+ // 'self' watchdog delay = 5000 + 1^3 * 2000 = 7000ms
2762
+
2763
+ // Mock sync responses
2764
+ mockGetHashesFromLocusResponse(
2765
+ parser.dataSets.main.url,
2766
+ new Array(16).fill('00000000000000000000000000000000'),
2767
+ createDataSet('main', 16, 1101)
2768
+ );
2769
+ mockSendSyncRequestResponse(parser.dataSets.main.url, null);
2770
+ mockSendSyncRequestResponse(parser.dataSets.self.url, null);
2771
+
2772
+ // At 5499ms, neither watchdog should have fired
2773
+ await clock.tickAsync(5499);
2774
+ assert.notCalled(webexRequest);
2775
+
2776
+ // At 5500ms, 'main' watchdog fires and performSync runs immediately
2777
+ await clock.tickAsync(1);
2778
+
2779
+ // main sync should have triggered immediately (GET hashtree + POST sync)
2780
+ assert.calledWith(
2781
+ webexRequest,
2782
+ sinon.match({
2783
+ method: 'GET',
2784
+ uri: `${parser.dataSets.main.url}/hashtree`,
2785
+ })
2786
+ );
2787
+
2788
+ webexRequest.resetHistory();
2789
+
2790
+ // At 7000ms, 'self' watchdog fires and performSync runs immediately
2791
+ await clock.tickAsync(1500);
2792
+
2793
+ // self sync should have also triggered (POST sync only, leafCount === 1)
2794
+ assert.calledWith(
2795
+ webexRequest,
2796
+ sinon.match({
2797
+ method: 'POST',
2798
+ uri: `${parser.dataSets.self.url}/sync`,
2799
+ })
2800
+ );
2801
+ });
2802
+
2803
+ it('does not set watchdog for data sets without a hash tree', async () => {
2804
+ const parser = createHashTreeParser();
2805
+ const heartbeatIntervalMs = 5000;
2806
+
2807
+ // 'atd-active' is in the initial locus but is not visible (no hash tree)
2808
+ // Send heartbeat mentioning a non-visible dataset
2809
+ const heartbeatMessage = {
2810
+ dataSets: [
2811
+ {
2812
+ ...createDataSet('main', 16, 1100),
2813
+ root: parser.dataSets.main.hashTree.getRootHash(),
2814
+ },
2815
+ createDataSet('atd-active', 16, 4000),
2816
+ ],
2817
+ visibleDataSetsUrl,
2818
+ locusUrl,
2819
+ heartbeatIntervalMs,
2820
+ };
2821
+
2822
+ parser.handleMessage(heartbeatMessage, 'heartbeat with non-visible dataset');
2823
+
2824
+ // Watchdog set for main (visible) but not for atd-active (no hash tree)
2825
+ expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
2826
+ expect(parser.dataSets['atd-active']?.heartbeatWatchdogTimer).to.be.undefined;
2827
+ });
2828
+ });
2829
+ });
2830
+
2831
+ describe('#callLocusInfoUpdateCallback filtering', () => {
2832
+ // Helper to setup parser with initial objects and reset callback history
2833
+ function setupParserWithObjects(locusStateElements: any[]) {
2834
+ const parser = createHashTreeParser();
2835
+
2836
+ if (locusStateElements.length > 0) {
2837
+ // Determine which datasets to include based on the objects' dataSetNames
2838
+ const dataSetNames = new Set<string>();
2839
+ locusStateElements.forEach((element) => {
2840
+ element.htMeta?.dataSetNames?.forEach((name) => dataSetNames.add(name));
2841
+ });
2842
+
2843
+ const dataSets = [];
2844
+ if (dataSetNames.has('main')) dataSets.push(createDataSet('main', 16, 1100));
2845
+ if (dataSetNames.has('self')) dataSets.push(createDataSet('self', 1, 2100));
2846
+ if (dataSetNames.has('atd-unmuted')) dataSets.push(createDataSet('atd-unmuted', 16, 3100));
2847
+
2848
+ const setupMessage = {
2849
+ dataSets,
2850
+ visibleDataSetsUrl,
2851
+ locusUrl,
2852
+ locusStateElements,
2853
+ };
2854
+
2855
+ parser.handleMessage(setupMessage, 'setup');
2856
+ }
2857
+
2858
+ callback.resetHistory();
2859
+ return parser;
2860
+ }
2861
+
2862
+ it('filters out updates when a dataset has a higher version', () => {
2863
+ const parser = setupParserWithObjects([
2864
+ {
2865
+ htMeta: {
2866
+ elementId: {type: 'locus' as const, id: 5, version: 100},
2867
+ dataSetNames: ['main'],
2868
+ },
2869
+ data: {existingField: 'existing'},
2870
+ },
2871
+ ]);
2872
+
2873
+ // Try to update with an older version (90)
2874
+ const updateMessage = {
2875
+ dataSets: [createDataSet('main', 16, 1101)],
2876
+ visibleDataSetsUrl,
2877
+ locusUrl,
2878
+ locusStateElements: [
2879
+ {
2880
+ htMeta: {
2881
+ elementId: {type: 'locus' as const, id: 5, version: 90},
2882
+ dataSetNames: ['main'],
2883
+ },
2884
+ data: {someField: 'value'},
2885
+ },
2886
+ ],
2887
+ };
2888
+
2889
+ parser.handleMessage(updateMessage, 'update with older version');
2890
+
2891
+ // Callback should not be called because the update was filtered out
2892
+ assert.notCalled(callback);
2893
+ });
2894
+
2895
+ it('allows updates when version is newer than existing', () => {
2896
+ const parser = setupParserWithObjects([
2897
+ {
2898
+ htMeta: {
2899
+ elementId: {type: 'locus' as const, id: 5, version: 100},
2900
+ dataSetNames: ['main'],
2901
+ },
2902
+ data: {existingField: 'existing'},
2903
+ },
2904
+ ]);
2905
+
2906
+ // Try to update with a newer version (110)
2907
+ const updateMessage = {
2908
+ dataSets: [createDataSet('main', 16, 1101)],
2909
+ visibleDataSetsUrl,
2910
+ locusUrl,
2911
+ locusStateElements: [
2912
+ {
2913
+ htMeta: {
2914
+ elementId: {type: 'locus' as const, id: 5, version: 110},
2915
+ dataSetNames: ['main'],
2916
+ },
2917
+ data: {someField: 'new value'},
2918
+ },
2919
+ ],
2920
+ };
2921
+
2922
+ parser.handleMessage(updateMessage, 'update with newer version');
2923
+
2924
+ // Callback should be called with the update
2925
+ assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
2926
+ updatedObjects: [
2927
+ {
2928
+ htMeta: {
2929
+ elementId: {type: 'locus', id: 5, version: 110},
2930
+ dataSetNames: ['main'],
2931
+ },
2932
+ data: {someField: 'new value'},
2933
+ },
2934
+ ],
2935
+ });
2936
+ });
2937
+
2938
+ it('filters out removal when object still exists in any dataset', () => {
2939
+ const parser = setupParserWithObjects([
2940
+ {
2941
+ htMeta: {
2942
+ elementId: {type: 'participant' as const, id: 10, version: 50},
2943
+ dataSetNames: ['main', 'atd-unmuted'],
2944
+ },
2945
+ data: {name: 'participant'},
2946
+ },
2947
+ ]);
2948
+
2949
+ // Try to remove the object from main only (it still exists in atd-unmuted)
2950
+ const removalMessage = {
2951
+ dataSets: [createDataSet('main', 16, 1101)],
2952
+ visibleDataSetsUrl,
2953
+ locusUrl,
2954
+ locusStateElements: [
2955
+ {
2956
+ htMeta: {
2957
+ elementId: {type: 'participant' as const, id: 10, version: 50},
2958
+ dataSetNames: ['main'],
2959
+ },
2960
+ data: null, // removal
2961
+ },
2962
+ ],
2963
+ };
2964
+
2965
+ parser.handleMessage(removalMessage, 'removal from one dataset');
2966
+
2967
+ // Callback should not be called because object still exists in atd-unmuted
2968
+ assert.notCalled(callback);
2969
+ });
2970
+
2971
+ it('allows removal when object does not exist in any dataset', () => {
2972
+ const parser = setupParserWithObjects([]);
2973
+
2974
+ // Stub updateItems to return true (simulating that the removal was "applied")
2975
+ sinon.stub(parser.dataSets.main.hashTree, 'updateItems').returns([true]);
2976
+
2977
+ // Try to remove an object that doesn't exist anywhere
2978
+ const removalMessage = {
2979
+ dataSets: [createDataSet('main', 16, 1100)],
2980
+ visibleDataSetsUrl,
2981
+ locusUrl,
2982
+ locusStateElements: [
2983
+ {
2984
+ htMeta: {
2985
+ elementId: {type: 'participant' as const, id: 99, version: 10},
2986
+ dataSetNames: ['main'],
2987
+ },
2988
+ data: null, // removal
2989
+ },
2990
+ ],
2991
+ };
2992
+
2993
+ parser.handleMessage(removalMessage, 'removal of non-existent object');
2994
+
2995
+ // Callback should be called with the removal
2996
+ assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
2997
+ updatedObjects: [
2998
+ {
2999
+ htMeta: {
3000
+ elementId: {type: 'participant', id: 99, version: 10},
3001
+ dataSetNames: ['main'],
3002
+ },
3003
+ data: null,
3004
+ },
3005
+ ],
3006
+ });
3007
+ });
3008
+
3009
+ it('filters out removal when object exists in another dataset with newer version', () => {
3010
+ const parser = createHashTreeParser();
3011
+
3012
+ // Setup: Add object to main with version 40
3013
+ parser.handleMessage(
3014
+ {
3015
+ dataSets: [createDataSet('main', 16, 1100)],
3016
+ visibleDataSetsUrl,
3017
+ locusUrl,
3018
+ locusStateElements: [
3019
+ {
3020
+ htMeta: {
3021
+ elementId: {type: 'participant' as const, id: 10, version: 40},
3022
+ dataSetNames: ['main'],
3023
+ },
3024
+ data: {name: 'participant v40'},
3025
+ },
3026
+ ],
3027
+ },
3028
+ 'setup main'
3029
+ );
3030
+
3031
+ // Add object to atd-unmuted with version 50
3032
+ parser.handleMessage(
3033
+ {
3034
+ dataSets: [createDataSet('atd-unmuted', 16, 3100)],
3035
+ visibleDataSetsUrl,
3036
+ locusUrl,
3037
+ locusStateElements: [
3038
+ {
3039
+ htMeta: {
3040
+ elementId: {type: 'participant' as const, id: 10, version: 50},
3041
+ dataSetNames: ['atd-unmuted'],
3042
+ },
3043
+ data: {name: 'participant v50'},
3044
+ },
3045
+ ],
3046
+ },
3047
+ 'setup atd-unmuted'
3048
+ );
3049
+ callback.resetHistory();
3050
+
3051
+ // Try to remove with version 40 from main
3052
+ const removalMessage = {
3053
+ dataSets: [createDataSet('main', 16, 1101)],
3054
+ visibleDataSetsUrl,
3055
+ locusUrl,
3056
+ locusStateElements: [
3057
+ {
3058
+ htMeta: {
3059
+ elementId: {type: 'participant' as const, id: 10, version: 40},
3060
+ dataSetNames: ['main'],
3061
+ },
3062
+ data: null, // removal
3063
+ },
3064
+ ],
3065
+ };
3066
+
3067
+ parser.handleMessage(removalMessage, 'removal with older version');
3068
+
3069
+ // Callback should not be called because object still exists with newer version
3070
+ assert.notCalled(callback);
3071
+ });
3072
+
3073
+ it('filters mixed updates correctly - some pass, some filtered', () => {
3074
+ const parser = setupParserWithObjects([
3075
+ {
3076
+ htMeta: {
3077
+ elementId: {type: 'participant' as const, id: 1, version: 100},
3078
+ dataSetNames: ['main'],
3079
+ },
3080
+ data: {name: 'participant 1'},
3081
+ },
3082
+ {
3083
+ htMeta: {
3084
+ elementId: {type: 'participant' as const, id: 2, version: 50},
3085
+ dataSetNames: ['atd-unmuted'],
3086
+ },
3087
+ data: {name: 'participant 2'},
3088
+ },
3089
+ ]);
3090
+
3091
+ // Send mixed updates
3092
+ const mixedMessage = {
3093
+ dataSets: [createDataSet('main', 16, 1101)],
3094
+ visibleDataSetsUrl,
3095
+ locusUrl,
3096
+ locusStateElements: [
3097
+ {
3098
+ htMeta: {
3099
+ elementId: {type: 'participant' as const, id: 1, version: 110}, // newer version - should pass
3100
+ dataSetNames: ['main'],
3101
+ },
3102
+ data: {name: 'updated'},
3103
+ },
3104
+ {
3105
+ htMeta: {
3106
+ elementId: {type: 'participant' as const, id: 1, version: 90}, // older version - should be filtered
3107
+ dataSetNames: ['main'],
3108
+ },
3109
+ data: {name: 'old'},
3110
+ },
3111
+ {
3112
+ htMeta: {
3113
+ elementId: {type: 'participant' as const, id: 3, version: 10}, // new object - should pass
3114
+ dataSetNames: ['main'],
3115
+ },
3116
+ data: {name: 'new'},
3117
+ },
3118
+ {
3119
+ htMeta: {
3120
+ elementId: {type: 'participant' as const, id: 2, version: 50}, // removal but exists in atd-unmuted - should be filtered
3121
+ dataSetNames: ['main'],
3122
+ },
3123
+ data: null,
3124
+ },
3125
+ ],
3126
+ };
3127
+
3128
+ parser.handleMessage(mixedMessage, 'mixed updates');
3129
+
3130
+ // Callback should be called with only the valid updates (participant 1 v110 and participant 3 v10)
3131
+ assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED, {
3132
+ updatedObjects: [
3133
+ {
3134
+ htMeta: {
3135
+ elementId: {type: 'participant', id: 1, version: 110},
3136
+ dataSetNames: ['main'],
3137
+ },
3138
+ data: {name: 'updated'},
3139
+ },
3140
+ {
3141
+ htMeta: {
3142
+ elementId: {type: 'participant', id: 3, version: 10},
3143
+ dataSetNames: ['main'],
3144
+ },
3145
+ data: {name: 'new'},
3146
+ },
3147
+ ],
3148
+ });
3149
+ });
3150
+
3151
+ it('does not call callback when all updates are filtered out', () => {
3152
+ const parser = setupParserWithObjects([
3153
+ {
3154
+ htMeta: {
3155
+ elementId: {type: 'locus' as const, id: 5, version: 100},
3156
+ dataSetNames: ['main'],
3157
+ },
3158
+ data: {existingField: 'existing'},
3159
+ },
3160
+ ]);
3161
+
3162
+ // Try to update with older versions (all should be filtered)
3163
+ const updateMessage = {
3164
+ dataSets: [createDataSet('main', 16, 1101)],
3165
+ visibleDataSetsUrl,
3166
+ locusUrl,
3167
+ locusStateElements: [
3168
+ {
3169
+ htMeta: {
3170
+ elementId: {type: 'locus' as const, id: 5, version: 80},
3171
+ dataSetNames: ['main'],
3172
+ },
3173
+ data: {someField: 'value'},
3174
+ },
3175
+ {
3176
+ htMeta: {
3177
+ elementId: {type: 'locus' as const, id: 5, version: 90},
3178
+ dataSetNames: ['main'],
3179
+ },
3180
+ data: {someField: 'another value'},
3181
+ },
3182
+ ],
3183
+ };
3184
+
3185
+ parser.handleMessage(updateMessage, 'all filtered updates');
3186
+
3187
+ // Callback should not be called at all
3188
+ assert.notCalled(callback);
3189
+ });
3190
+
3191
+ it('checks all visible datasets when filtering', () => {
3192
+ const parser = createHashTreeParser();
3193
+
3194
+ // Setup: Add same object to multiple datasets with different versions
3195
+ parser.handleMessage(
3196
+ {
3197
+ dataSets: [createDataSet('main', 16, 1100)],
3198
+ visibleDataSetsUrl,
3199
+ locusUrl,
3200
+ locusStateElements: [
3201
+ {
3202
+ htMeta: {
3203
+ elementId: {type: 'participant' as const, id: 10, version: 100},
3204
+ dataSetNames: ['main'],
3205
+ },
3206
+ data: {name: 'v100'},
3207
+ },
3208
+ ],
3209
+ },
3210
+ 'setup main'
3211
+ );
3212
+
3213
+ parser.handleMessage(
3214
+ {
3215
+ dataSets: [createDataSet('self', 1, 2100)],
3216
+ visibleDataSetsUrl,
3217
+ locusUrl,
3218
+ locusStateElements: [
3219
+ {
3220
+ htMeta: {
3221
+ elementId: {type: 'participant' as const, id: 10, version: 120}, // highest
3222
+ dataSetNames: ['self'],
3223
+ },
3224
+ data: {name: 'v120'},
3225
+ },
3226
+ ],
3227
+ },
3228
+ 'setup self'
3229
+ );
3230
+
3231
+ parser.handleMessage(
3232
+ {
3233
+ dataSets: [createDataSet('atd-unmuted', 16, 3100)],
3234
+ visibleDataSetsUrl,
3235
+ locusUrl,
3236
+ locusStateElements: [
3237
+ {
3238
+ htMeta: {
3239
+ elementId: {type: 'participant' as const, id: 10, version: 110},
3240
+ dataSetNames: ['atd-unmuted'],
3241
+ },
3242
+ data: {name: 'v110'},
3243
+ },
3244
+ ],
3245
+ },
3246
+ 'setup atd-unmuted'
3247
+ );
3248
+ callback.resetHistory();
3249
+
3250
+ // Try to update with version 115 (newer than main and atd-unmuted, but older than self)
3251
+ const updateMessage = {
3252
+ dataSets: [createDataSet('main', 16, 1101)],
3253
+ visibleDataSetsUrl,
3254
+ locusUrl,
3255
+ locusStateElements: [
3256
+ {
3257
+ htMeta: {
3258
+ elementId: {type: 'participant' as const, id: 10, version: 115},
3259
+ dataSetNames: ['main'],
3260
+ },
3261
+ data: {name: 'update'},
3262
+ },
3263
+ ],
3264
+ };
3265
+
3266
+ parser.handleMessage(updateMessage, 'update with v115');
3267
+
3268
+ // Should be filtered out because self dataset has version 120
3269
+ assert.notCalled(callback);
3270
+ });
3271
+
3272
+ it('does not call callback for empty locusStateElements', () => {
3273
+ const parser = setupParserWithObjects([]);
3274
+
3275
+ const emptyMessage = {
3276
+ dataSets: [createDataSet('main', 16, 1100)],
3277
+ visibleDataSetsUrl,
3278
+ locusUrl,
3279
+ locusStateElements: [],
3280
+ };
3281
+
3282
+ parser.handleMessage(emptyMessage, 'empty elements');
3283
+
3284
+ assert.notCalled(callback);
3285
+ });
3286
+
3287
+ it('always calls callback for MEETING_ENDED regardless of filtering', () => {
3288
+ const parser = setupParserWithObjects([
3289
+ {
3290
+ htMeta: {
3291
+ elementId: {type: 'locus' as const, id: 0, version: 100},
3292
+ dataSetNames: ['main'],
3293
+ },
3294
+ data: {info: 'data'},
3295
+ },
3296
+ ]);
3297
+
3298
+ // Send a sentinel END MEETING message
3299
+ const sentinelMessage = createHeartbeatMessage(
3300
+ 'main',
3301
+ 1,
3302
+ parser.dataSets.main.version + 1,
3303
+ EMPTY_HASH
3304
+ );
3305
+
3306
+ parser.handleMessage(sentinelMessage as any, 'sentinel message');
3307
+
3308
+ // Callback should be called with MEETING_ENDED
3309
+ assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED, {
3310
+ updatedObjects: undefined,
3311
+ });
3312
+ });
3313
+ });
3314
+
3315
+ describe('#state', () => {
3316
+ it('should be initialized to active', () => {
3317
+ const parser = createHashTreeParser();
3318
+
3319
+ expect(parser.state).to.equal('active');
3320
+ });
3321
+ });
3322
+
3323
+ describe('#stop', () => {
3324
+ it('should set state to stopped', () => {
3325
+ const parser = createHashTreeParser();
3326
+
3327
+ parser.stop();
3328
+
3329
+ expect(parser.state).to.equal('stopped');
3330
+ });
3331
+
3332
+ it('should clear all hash trees', () => {
3333
+ const parser = createHashTreeParser();
3334
+
3335
+ expect(parser.dataSets.main.hashTree).to.be.instanceOf(HashTree);
3336
+ expect(parser.dataSets.self.hashTree).to.be.instanceOf(HashTree);
3337
+
3338
+ parser.stop();
3339
+
3340
+ expect(parser.dataSets.main.hashTree).to.be.undefined;
3341
+ expect(parser.dataSets.self.hashTree).to.be.undefined;
3342
+ expect(parser.dataSets['atd-unmuted'].hashTree).to.be.undefined;
3343
+ });
3344
+
3345
+ it('should clear visibleDataSets', () => {
3346
+ const parser = createHashTreeParser();
3347
+
3348
+ expect(parser.visibleDataSets).to.have.length.greaterThan(0);
3349
+
3350
+ parser.stop();
3351
+
3352
+ expect(parser.visibleDataSets).to.deep.equal([]);
3353
+ });
3354
+
3355
+ it('should stop all timers', () => {
3356
+ const parser = createHashTreeParser();
3357
+
3358
+ // manually set timers on data sets
3359
+ parser.dataSets.main.timer = setTimeout(() => {}, 10000);
3360
+ parser.dataSets.main.heartbeatWatchdogTimer = setTimeout(() => {}, 10000);
3361
+
3362
+ parser.stop();
3363
+
3364
+ expect(parser.dataSets.main.timer).to.be.undefined;
3365
+ expect(parser.dataSets.main.heartbeatWatchdogTimer).to.be.undefined;
3366
+ });
3367
+
3368
+ it('should not call locusInfoUpdateCallback when async initialization of a new visible dataset completes after stop()', async () => {
3369
+ const parser = createHashTreeParser();
3370
+
3371
+ // Stub updateItems on self hash tree to return true so the metadata update is applied
3372
+ sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
3373
+
3374
+ // Send a message with Metadata that adds a new visible dataset requiring async initialization
3375
+ // (the new dataset is NOT in parser.dataSets, so it will go through queueInitForNewVisibleDataSets)
3376
+ const message = {
3377
+ dataSets: [createDataSet('self', 1, 2100)],
3378
+ visibleDataSetsUrl,
3379
+ locusUrl,
3380
+ locusStateElements: [
3381
+ {
3382
+ htMeta: {
3383
+ elementId: {
3384
+ type: 'metadata' as const,
3385
+ id: 5,
3386
+ version: 51,
3387
+ },
3388
+ dataSetNames: ['self'],
3389
+ },
3390
+ data: {
3391
+ visibleDataSets: [
3392
+ {
3393
+ name: 'main',
3394
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main',
3395
+ },
3396
+ {
3397
+ name: 'self',
3398
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
3399
+ },
3400
+ {
3401
+ name: 'atd-unmuted',
3402
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/atd-unmuted',
3403
+ },
3404
+ {
3405
+ name: 'new-dataset',
3406
+ url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/new-dataset',
3407
+ },
3408
+ ],
3409
+ },
3410
+ },
3411
+ ],
3412
+ };
3413
+
3414
+ // Mock the async initialization - getAllVisibleDataSetsFromLocus and sync request
3415
+ const newDataSet = createDataSet('new-dataset', 4, 5000);
3416
+ mockGetAllDataSetsMetadata(webexRequest, visibleDataSetsUrl, [newDataSet]);
3417
+ mockSyncRequest(webexRequest, newDataSet.url, {
3418
+ dataSets: [newDataSet],
3419
+ visibleDataSetsUrl,
3420
+ locusUrl,
3421
+ locusStateElements: [
3422
+ {
3423
+ htMeta: {
3424
+ elementId: {type: 'participant' as const, id: 20, version: 100},
3425
+ dataSetNames: ['new-dataset'],
3426
+ },
3427
+ data: {person: {name: 'some participant'}},
3428
+ },
3429
+ ],
3430
+ });
3431
+
3432
+ // handleMessage triggers queueInitForNewVisibleDataSets (via queueMicrotask)
3433
+ parser.handleMessage(message, 'add new dataset then stop');
3434
+
3435
+ // callback is called once synchronously by handleMessage for the metadata update
3436
+ callback.resetHistory();
3437
+
3438
+ // Stop the parser before the async initialization completes
3439
+ parser.stop();
3440
+
3441
+ // Let the queued microtask and async initialization complete
3442
+ await clock.tickAsync(0);
3443
+
3444
+ // The callback should NOT have been called again after stop()
3445
+ assert.notCalled(callback);
3446
+
3447
+ // parseMessage should not have processed the sync response data,
3448
+ // so no hash tree should exist for new-dataset (stop() clears all hash trees)
3449
+ assert.isUndefined(parser.dataSets['new-dataset']?.hashTree);
3450
+ });
3451
+
3452
+ it('should not call locusInfoUpdateCallback when initializeFromMessage completes after stop()', async () => {
3453
+ const minimalInitialLocus = {
3454
+ dataSets: [],
3455
+ locus: null,
3456
+ };
3457
+ const parser = createHashTreeParser(minimalInitialLocus, null);
3458
+
3459
+ const mainDataSet = createDataSet('main', 16, 1100);
3460
+
3461
+ // Use a deferred promise so we can control when getAllVisibleDataSetsFromLocus resolves
3462
+ let resolveGetDataSets;
3463
+ webexRequest
3464
+ .withArgs(
3465
+ sinon.match({
3466
+ method: 'GET',
3467
+ uri: visibleDataSetsUrl,
3468
+ })
3469
+ )
3470
+ .returns(
3471
+ new Promise((resolve) => {
3472
+ resolveGetDataSets = resolve;
3473
+ })
3474
+ );
3475
+
3476
+ mockSyncRequest(webexRequest, mainDataSet.url, {
3477
+ dataSets: [mainDataSet],
3478
+ visibleDataSetsUrl,
3479
+ locusUrl,
3480
+ locusStateElements: [
3481
+ {
3482
+ htMeta: {
3483
+ elementId: {type: 'locus' as const, id: 1, version: 210},
3484
+ dataSetNames: ['main'],
3485
+ },
3486
+ data: {info: {id: 'some-locus-info'}},
3487
+ },
3488
+ ],
3489
+ });
3490
+
3491
+ // Start initializeFromMessage but don't await it
3492
+ const initPromise = parser.initializeFromMessage({
3493
+ dataSets: [],
3494
+ visibleDataSetsUrl,
3495
+ locusUrl,
3496
+ });
3497
+
3498
+ // Stop the parser before the GET response arrives
3499
+ parser.stop();
3500
+
3501
+ // Now resolve the pending GET request
3502
+ resolveGetDataSets({body: {dataSets: [mainDataSet]}});
3503
+
3504
+ // Wait for the initializeFromMessage to finish
3505
+ await initPromise;
3506
+
3507
+ // The callback should NOT have been called because the parser was stopped
3508
+ assert.notCalled(callback);
3509
+
3510
+ // Even though initializeDataSets may create a hash tree entry, parseMessage
3511
+ // should have returned [] without processing the sync response objects.
3512
+ // After stop(), hash trees are cleared, so verify that main has no hash tree.
3513
+ assert.isUndefined(parser.dataSets.main?.hashTree);
3514
+ });
3515
+ });
3516
+
3517
+ describe('#resume', () => {
3518
+ const createResumeMessage = (visibleDataSets?, dataSets?) => ({
3519
+ locusUrl,
3520
+ visibleDataSetsUrl,
3521
+ dataSets: dataSets || [
3522
+ createDataSet('main', 16, 2000),
3523
+ createDataSet('self', 1, 3000),
3524
+ ],
3525
+ locusStateElements: [
3526
+ {
3527
+ htMeta: {elementId: {type: 'metadata' as const, id: 5, version: 60}, dataSetNames: ['self']},
3528
+ data: {
3529
+ visibleDataSets: visibleDataSets || [
3530
+ {name: 'main', url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main'},
3531
+ {name: 'self', url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self'},
3532
+ ],
3533
+ },
3534
+ },
3535
+ ],
3536
+ });
3537
+
3538
+ it('should set state back to active', () => {
3539
+ const parser = createHashTreeParser();
3540
+ parser.stop();
3541
+
3542
+ expect(parser.state).to.equal('stopped');
3543
+
3544
+ parser.resume(createResumeMessage());
3545
+
3546
+ expect(parser.state).to.equal('active');
3547
+ });
3548
+
3549
+ it('should not resume if message is missing metadata with visibleDataSets', () => {
3550
+ const parser = createHashTreeParser();
3551
+ parser.stop();
3552
+
3553
+ parser.resume({
3554
+ locusUrl,
3555
+ visibleDataSetsUrl,
3556
+ dataSets: [createDataSet('main', 16, 2000)],
3557
+ locusStateElements: [],
3558
+ });
3559
+
3560
+ expect(parser.state).to.equal('stopped');
3561
+ });
3562
+
3563
+ it('should re-initialize dataSets from the message', () => {
3564
+ const parser = createHashTreeParser();
3565
+ parser.stop();
3566
+
3567
+ const newDataSets = [
3568
+ createDataSet('main', 8, 5000),
3569
+ createDataSet('self', 2, 6000),
3570
+ ];
3571
+
3572
+ parser.resume(createResumeMessage(undefined, newDataSets));
3573
+
3574
+ expect(Object.keys(parser.dataSets)).to.have.lengthOf(2);
3575
+ expect(parser.dataSets.main.leafCount).to.equal(8);
3576
+ expect(parser.dataSets.main.version).to.equal(5000);
3577
+ expect(parser.dataSets.self.leafCount).to.equal(2);
3578
+ });
3579
+
3580
+ it('should create hash trees only for visible data sets', () => {
3581
+ const parser = createHashTreeParser();
3582
+ parser.stop();
3583
+
3584
+ const dataSets = [
3585
+ createDataSet('main', 16, 2000),
3586
+ createDataSet('self', 1, 3000),
3587
+ createDataSet('atd-unmuted', 16, 4000),
3588
+ ];
3589
+ const visibleDataSets = [
3590
+ {name: 'main', url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main'},
3591
+ {name: 'self', url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self'},
3592
+ ];
3593
+
3594
+ parser.resume(createResumeMessage(visibleDataSets, dataSets));
3595
+
3596
+ expect(parser.dataSets.main.hashTree).to.be.instanceOf(HashTree);
3597
+ expect(parser.dataSets.self.hashTree).to.be.instanceOf(HashTree);
3598
+ expect(parser.dataSets['atd-unmuted'].hashTree).to.be.undefined;
3599
+ });
3600
+
3601
+ it('should call handleMessage with the resume message', () => {
3602
+ const parser = createHashTreeParser();
3603
+ parser.stop();
3604
+
3605
+ const handleMessageStub = sinon.stub(parser, 'handleMessage');
3606
+
3607
+ const message = createResumeMessage();
3608
+ parser.resume(message);
3609
+
3610
+ assert.calledOnceWithExactly(handleMessageStub, message, 'on resume');
3611
+ });
3612
+
3613
+ it('should set visibleDataSets from message metadata filtered by excludedDataSets', () => {
3614
+ const parser = createHashTreeParser(exampleInitialLocus, exampleMetadata, ['atd-unmuted']);
3615
+ parser.stop();
3616
+
3617
+ const dataSets = [
3618
+ createDataSet('main', 16, 2000),
3619
+ createDataSet('self', 1, 3000),
3620
+ createDataSet('atd-unmuted', 16, 4000),
3621
+ ];
3622
+ const visibleDataSets = [
3623
+ {name: 'main', url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main'},
3624
+ {name: 'self', url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self'},
3625
+ {name: 'atd-unmuted', url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/atd-unmuted'},
3626
+ ];
3627
+
3628
+ parser.resume(createResumeMessage(visibleDataSets, dataSets));
3629
+
3630
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'atd-unmuted')).to.be.false;
3631
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'main')).to.be.true;
3632
+ expect(parser.visibleDataSets.some((vds) => vds.name === 'self')).to.be.true;
3633
+ });
3634
+ });
3635
+
3636
+ describe('#handleLocusUpdate when stopped', () => {
3637
+ it('should return early without processing when parser is stopped', () => {
3638
+ const parser = createHashTreeParser();
3639
+ parser.stop();
3640
+
3641
+ parser.handleLocusUpdate({
3642
+ dataSets: [createDataSet('main', 16, 2000)],
3643
+ locus: {participants: []},
3644
+ });
3645
+
3646
+ assert.notCalled(callback);
3647
+ });
3648
+ });
3649
+
3650
+ describe('#handleMessage when stopped', () => {
3651
+ it('should return early without processing when parser is stopped', () => {
3652
+ const parser = createHashTreeParser();
3653
+ parser.stop();
3654
+
3655
+ parser.handleMessage({
3656
+ dataSets: [createDataSet('main', 16, 2000)],
3657
+ visibleDataSetsUrl,
3658
+ locusUrl,
3659
+ locusStateElements: [
3660
+ {
3661
+ htMeta: {elementId: {type: 'self' as const, id: 4, version: 200}, dataSetNames: ['self']},
3662
+ data: {id: 'new-self'},
3663
+ },
3664
+ ],
3665
+ });
3666
+
3667
+ assert.notCalled(callback);
3668
+ });
1523
3669
  });
1524
3670
  });