@webex/plugin-meetings 3.12.0-next.6 → 3.12.0-next.60
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.
- package/AGENTS.md +9 -0
- package/dist/aiEnableRequest/index.js +15 -2
- package/dist/aiEnableRequest/index.js.map +1 -1
- package/dist/breakouts/breakout.js +8 -3
- package/dist/breakouts/breakout.js.map +1 -1
- package/dist/breakouts/index.js +26 -2
- package/dist/breakouts/index.js.map +1 -1
- package/dist/config.js +2 -0
- package/dist/config.js.map +1 -1
- package/dist/constants.js +6 -3
- package/dist/constants.js.map +1 -1
- package/dist/controls-options-manager/constants.js +11 -1
- package/dist/controls-options-manager/constants.js.map +1 -1
- package/dist/controls-options-manager/index.js +38 -24
- package/dist/controls-options-manager/index.js.map +1 -1
- package/dist/controls-options-manager/util.js +91 -0
- package/dist/controls-options-manager/util.js.map +1 -1
- package/dist/hashTree/constants.js +10 -1
- package/dist/hashTree/constants.js.map +1 -1
- package/dist/hashTree/hashTreeParser.js +716 -370
- package/dist/hashTree/hashTreeParser.js.map +1 -1
- package/dist/hashTree/utils.js +22 -0
- package/dist/hashTree/utils.js.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/interceptors/locusRetry.js +23 -8
- package/dist/interceptors/locusRetry.js.map +1 -1
- package/dist/interpretation/index.js +10 -1
- package/dist/interpretation/index.js.map +1 -1
- package/dist/interpretation/siLanguage.js +1 -1
- package/dist/locus-info/controlsUtils.js +4 -1
- package/dist/locus-info/controlsUtils.js.map +1 -1
- package/dist/locus-info/index.js +289 -87
- package/dist/locus-info/index.js.map +1 -1
- package/dist/locus-info/types.js +19 -0
- package/dist/locus-info/types.js.map +1 -1
- package/dist/media/index.js +3 -1
- package/dist/media/index.js.map +1 -1
- package/dist/media/properties.js +1 -0
- package/dist/media/properties.js.map +1 -1
- package/dist/meeting/in-meeting-actions.js +3 -1
- package/dist/meeting/in-meeting-actions.js.map +1 -1
- package/dist/meeting/index.js +907 -535
- package/dist/meeting/index.js.map +1 -1
- package/dist/meeting/util.js +19 -2
- package/dist/meeting/util.js.map +1 -1
- package/dist/meetings/index.js +231 -78
- package/dist/meetings/index.js.map +1 -1
- package/dist/meetings/meetings.types.js +6 -1
- package/dist/meetings/meetings.types.js.map +1 -1
- package/dist/meetings/request.js +39 -0
- package/dist/meetings/request.js.map +1 -1
- package/dist/meetings/util.js +79 -5
- package/dist/meetings/util.js.map +1 -1
- package/dist/member/index.js +10 -0
- package/dist/member/index.js.map +1 -1
- package/dist/member/types.js.map +1 -1
- package/dist/member/util.js +3 -0
- package/dist/member/util.js.map +1 -1
- package/dist/metrics/constants.js +4 -1
- package/dist/metrics/constants.js.map +1 -1
- package/dist/multistream/codec/constants.js +63 -0
- package/dist/multistream/codec/constants.js.map +1 -0
- package/dist/multistream/mediaRequestManager.js +62 -15
- package/dist/multistream/mediaRequestManager.js.map +1 -1
- package/dist/multistream/receiveSlot.js +9 -0
- package/dist/multistream/receiveSlot.js.map +1 -1
- package/dist/reactions/reactions.type.js.map +1 -1
- package/dist/recording-controller/index.js +1 -3
- package/dist/recording-controller/index.js.map +1 -1
- package/dist/types/config.d.ts +2 -0
- package/dist/types/constants.d.ts +2 -0
- package/dist/types/controls-options-manager/constants.d.ts +6 -1
- package/dist/types/controls-options-manager/index.d.ts +10 -0
- package/dist/types/hashTree/constants.d.ts +1 -0
- package/dist/types/hashTree/hashTreeParser.d.ts +92 -16
- package/dist/types/hashTree/utils.d.ts +11 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/interceptors/locusRetry.d.ts +4 -4
- package/dist/types/locus-info/index.d.ts +46 -6
- package/dist/types/locus-info/types.d.ts +21 -1
- package/dist/types/media/properties.d.ts +1 -0
- package/dist/types/meeting/in-meeting-actions.d.ts +2 -0
- package/dist/types/meeting/index.d.ts +87 -3
- package/dist/types/meeting/util.d.ts +8 -0
- package/dist/types/meetings/index.d.ts +30 -2
- package/dist/types/meetings/meetings.types.d.ts +15 -0
- package/dist/types/meetings/request.d.ts +14 -0
- package/dist/types/member/index.d.ts +1 -0
- package/dist/types/member/types.d.ts +1 -0
- package/dist/types/member/util.d.ts +1 -0
- package/dist/types/metrics/constants.d.ts +3 -0
- package/dist/types/multistream/codec/constants.d.ts +7 -0
- package/dist/types/multistream/mediaRequestManager.d.ts +22 -5
- package/dist/types/reactions/reactions.type.d.ts +3 -0
- package/dist/webinar/index.js +361 -235
- package/dist/webinar/index.js.map +1 -1
- package/package.json +22 -22
- package/src/aiEnableRequest/index.ts +16 -0
- package/src/breakouts/breakout.ts +3 -1
- package/src/breakouts/index.ts +31 -0
- package/src/config.ts +2 -0
- package/src/constants.ts +5 -1
- package/src/controls-options-manager/constants.ts +14 -1
- package/src/controls-options-manager/index.ts +47 -24
- package/src/controls-options-manager/util.ts +81 -1
- package/src/hashTree/constants.ts +9 -0
- package/src/hashTree/hashTreeParser.ts +429 -183
- package/src/hashTree/utils.ts +17 -0
- package/src/index.ts +5 -0
- package/src/interceptors/locusRetry.ts +25 -4
- package/src/interpretation/index.ts +25 -8
- package/src/locus-info/controlsUtils.ts +3 -1
- package/src/locus-info/index.ts +291 -97
- package/src/locus-info/types.ts +25 -1
- package/src/media/index.ts +3 -0
- package/src/media/properties.ts +1 -0
- package/src/meeting/in-meeting-actions.ts +4 -0
- package/src/meeting/index.ts +388 -33
- package/src/meeting/util.ts +20 -2
- package/src/meetings/index.ts +134 -44
- package/src/meetings/meetings.types.ts +19 -0
- package/src/meetings/request.ts +43 -0
- package/src/meetings/util.ts +97 -1
- package/src/member/index.ts +10 -0
- package/src/member/types.ts +1 -0
- package/src/member/util.ts +3 -0
- package/src/metrics/constants.ts +3 -0
- package/src/multistream/codec/constants.ts +58 -0
- package/src/multistream/mediaRequestManager.ts +119 -28
- package/src/multistream/receiveSlot.ts +18 -0
- package/src/reactions/reactions.type.ts +3 -0
- package/src/recording-controller/index.ts +1 -2
- package/src/webinar/index.ts +162 -21
- package/test/unit/spec/aiEnableRequest/index.ts +86 -0
- package/test/unit/spec/breakouts/breakout.ts +9 -3
- package/test/unit/spec/breakouts/index.ts +49 -0
- package/test/unit/spec/controls-options-manager/index.js +140 -29
- package/test/unit/spec/controls-options-manager/util.js +165 -0
- package/test/unit/spec/hashTree/hashTreeParser.ts +1508 -149
- package/test/unit/spec/hashTree/utils.ts +88 -1
- package/test/unit/spec/interceptors/locusRetry.ts +205 -4
- package/test/unit/spec/interpretation/index.ts +26 -4
- package/test/unit/spec/locus-info/controlsUtils.js +172 -57
- package/test/unit/spec/locus-info/index.js +475 -81
- package/test/unit/spec/media/index.ts +31 -0
- package/test/unit/spec/meeting/in-meeting-actions.ts +2 -0
- package/test/unit/spec/meeting/index.js +1131 -49
- package/test/unit/spec/meeting/muteState.js +3 -0
- package/test/unit/spec/meeting/utils.js +33 -0
- package/test/unit/spec/meetings/index.js +360 -10
- package/test/unit/spec/meetings/request.js +141 -0
- package/test/unit/spec/meetings/utils.js +189 -0
- package/test/unit/spec/member/index.js +7 -0
- package/test/unit/spec/member/util.js +24 -0
- package/test/unit/spec/multistream/mediaRequestManager.ts +501 -37
- package/test/unit/spec/recording-controller/index.js +9 -8
- package/test/unit/spec/webinar/index.ts +141 -16
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import HashTreeParser, {
|
|
2
2
|
LocusInfoUpdateType,
|
|
3
3
|
MeetingEndedError,
|
|
4
|
+
LocusNotFoundError,
|
|
4
5
|
} from '@webex/plugin-meetings/src/hashTree/hashTreeParser';
|
|
5
6
|
import HashTree from '@webex/plugin-meetings/src/hashTree/hashTree';
|
|
6
7
|
import {expect} from '@webex/test-helper-chai';
|
|
7
8
|
import sinon from 'sinon';
|
|
8
9
|
import {assert} from '@webex/test-helper-chai';
|
|
9
10
|
import {EMPTY_HASH} from '@webex/plugin-meetings/src/hashTree/constants';
|
|
11
|
+
import testUtils from '@webex/plugin-meetings/test/utils/testUtils';
|
|
12
|
+
import { some } from 'lodash';
|
|
13
|
+
import Metrics from '@webex/plugin-meetings/src/metrics';
|
|
14
|
+
import BEHAVIORAL_METRICS from '@webex/plugin-meetings/src/metrics/constants';
|
|
10
15
|
|
|
11
16
|
const visibleDataSetsUrl = 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/visibleDataSets';
|
|
12
17
|
|
|
@@ -151,16 +156,19 @@ describe('HashTreeParser', () => {
|
|
|
151
156
|
let webexRequest: sinon.SinonStub;
|
|
152
157
|
let callback: sinon.SinonStub;
|
|
153
158
|
let mathRandomStub: sinon.SinonStub;
|
|
159
|
+
let metricsStub: sinon.SinonStub;
|
|
154
160
|
|
|
155
161
|
beforeEach(() => {
|
|
156
162
|
clock = sinon.useFakeTimers();
|
|
157
163
|
webexRequest = sinon.stub();
|
|
158
164
|
callback = sinon.stub();
|
|
159
165
|
mathRandomStub = sinon.stub(Math, 'random').returns(0);
|
|
166
|
+
metricsStub = sinon.stub(Metrics, 'sendBehavioralMetric');
|
|
160
167
|
});
|
|
161
168
|
afterEach(() => {
|
|
162
169
|
clock.restore();
|
|
163
170
|
mathRandomStub.restore();
|
|
171
|
+
metricsStub.restore();
|
|
164
172
|
});
|
|
165
173
|
|
|
166
174
|
// Helper to create a HashTreeParser instance with common defaults
|
|
@@ -553,7 +561,7 @@ describe('HashTreeParser', () => {
|
|
|
553
561
|
);
|
|
554
562
|
|
|
555
563
|
// Verify callback was called with OBJECTS_UPDATED and correct updatedObjects list
|
|
556
|
-
assert.calledWith(callback, LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
564
|
+
assert.calledWith(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
557
565
|
updatedObjects: [
|
|
558
566
|
{
|
|
559
567
|
htMeta: {
|
|
@@ -566,6 +574,11 @@ describe('HashTreeParser', () => {
|
|
|
566
574
|
},
|
|
567
575
|
data: {info: {id: 'some-fake-locus-info'}},
|
|
568
576
|
},
|
|
577
|
+
],
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
assert.calledWith(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
581
|
+
updatedObjects: [
|
|
569
582
|
{
|
|
570
583
|
htMeta: {
|
|
571
584
|
elementId: {
|
|
@@ -596,6 +609,67 @@ describe('HashTreeParser', () => {
|
|
|
596
609
|
});
|
|
597
610
|
});
|
|
598
611
|
|
|
612
|
+
it('initializes "main" before "self" regardless of order from Locus', async () => {
|
|
613
|
+
const parser = createHashTreeParser({dataSets: [], locus: null}, null);
|
|
614
|
+
|
|
615
|
+
// Locus returns datasets in non-priority order: atd-active, main, self
|
|
616
|
+
const atdActiveDataSet = createDataSet('atd-active', 4, 500);
|
|
617
|
+
const mainDataSet = createDataSet('main', 16, 1100);
|
|
618
|
+
const selfDataSet = createDataSet('self', 1, 2100);
|
|
619
|
+
|
|
620
|
+
mockGetAllDataSetsMetadata(webexRequest, visibleDataSetsUrl, [
|
|
621
|
+
atdActiveDataSet,
|
|
622
|
+
mainDataSet,
|
|
623
|
+
selfDataSet,
|
|
624
|
+
]);
|
|
625
|
+
|
|
626
|
+
mockSyncRequest(webexRequest, selfDataSet.url);
|
|
627
|
+
mockSyncRequest(webexRequest, mainDataSet.url);
|
|
628
|
+
mockSyncRequest(webexRequest, atdActiveDataSet.url);
|
|
629
|
+
|
|
630
|
+
await parser.initializeFromMessage({
|
|
631
|
+
dataSets: [],
|
|
632
|
+
visibleDataSetsUrl,
|
|
633
|
+
locusUrl,
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
// Verify sync requests were sent in priority order: main, self, then atd-active
|
|
637
|
+
const syncCalls = webexRequest
|
|
638
|
+
.getCalls()
|
|
639
|
+
.filter((call) => call.args[0]?.method === 'POST' && call.args[0]?.uri?.endsWith('/sync'));
|
|
640
|
+
|
|
641
|
+
expect(syncCalls).to.have.lengthOf(3);
|
|
642
|
+
expect(syncCalls[0].args[0].uri).to.equal(`${mainDataSet.url}/sync`);
|
|
643
|
+
expect(syncCalls[1].args[0].uri).to.equal(`${selfDataSet.url}/sync`);
|
|
644
|
+
expect(syncCalls[2].args[0].uri).to.equal(`${atdActiveDataSet.url}/sync`);
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it('sends leafCount=1 with a single empty leaf for initialization sync, regardless of actual dataset leafCount', async () => {
|
|
648
|
+
const parser = createHashTreeParser({dataSets: [], locus: null}, null);
|
|
649
|
+
|
|
650
|
+
// Use a dataset with leafCount=16 to verify the initialization sync always uses leafCount=1
|
|
651
|
+
const mainDataSet = createDataSet('main', 16, 1100);
|
|
652
|
+
|
|
653
|
+
mockGetAllDataSetsMetadata(webexRequest, visibleDataSetsUrl, [mainDataSet]);
|
|
654
|
+
mockSyncRequest(webexRequest, mainDataSet.url);
|
|
655
|
+
|
|
656
|
+
await parser.initializeFromMessage({
|
|
657
|
+
dataSets: [],
|
|
658
|
+
visibleDataSetsUrl,
|
|
659
|
+
locusUrl,
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
assert.calledWith(webexRequest, {
|
|
663
|
+
method: 'POST',
|
|
664
|
+
uri: `${mainDataSet.url}/sync`,
|
|
665
|
+
qs: {rootHash: sinon.match.string},
|
|
666
|
+
body: {
|
|
667
|
+
leafCount: 1,
|
|
668
|
+
leafDataEntries: [{leafIndex: 0, elementIds: []}],
|
|
669
|
+
},
|
|
670
|
+
});
|
|
671
|
+
});
|
|
672
|
+
|
|
599
673
|
it('handles sync response that has locusStateElements undefined', async () => {
|
|
600
674
|
const minimalInitialLocus = {
|
|
601
675
|
dataSets: [],
|
|
@@ -636,8 +710,11 @@ describe('HashTreeParser', () => {
|
|
|
636
710
|
assert.notCalled(callback);
|
|
637
711
|
});
|
|
638
712
|
|
|
639
|
-
[
|
|
640
|
-
|
|
713
|
+
[
|
|
714
|
+
{errorCode: 404, expectedError: LocusNotFoundError},
|
|
715
|
+
{errorCode: 409, expectedError: MeetingEndedError},
|
|
716
|
+
].forEach(({errorCode, expectedError}) => {
|
|
717
|
+
it(`throws ${expectedError.name} if getting visible datasets returns ${errorCode}`, async () => {
|
|
641
718
|
const minimalInitialLocus = {
|
|
642
719
|
dataSets: [],
|
|
643
720
|
locus: null,
|
|
@@ -660,7 +737,6 @@ describe('HashTreeParser', () => {
|
|
|
660
737
|
)
|
|
661
738
|
.rejects(error);
|
|
662
739
|
|
|
663
|
-
// initializeFromMessage should throw MeetingEndedError
|
|
664
740
|
let thrownError;
|
|
665
741
|
try {
|
|
666
742
|
await parser.initializeFromMessage({
|
|
@@ -672,7 +748,7 @@ describe('HashTreeParser', () => {
|
|
|
672
748
|
thrownError = e;
|
|
673
749
|
}
|
|
674
750
|
|
|
675
|
-
expect(thrownError).to.be.instanceOf(
|
|
751
|
+
expect(thrownError).to.be.instanceOf(expectedError);
|
|
676
752
|
});
|
|
677
753
|
});
|
|
678
754
|
});
|
|
@@ -788,7 +864,7 @@ describe('HashTreeParser', () => {
|
|
|
788
864
|
expect(parser.dataSets.self.version).to.equal(2100);
|
|
789
865
|
expect(parser.dataSets['atd-unmuted'].version).to.equal(3100);
|
|
790
866
|
|
|
791
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
867
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
792
868
|
updatedObjects: [
|
|
793
869
|
{
|
|
794
870
|
htMeta: {
|
|
@@ -919,7 +995,7 @@ describe('HashTreeParser', () => {
|
|
|
919
995
|
{type: 'ControlEntry', id: 10101, version: 100}
|
|
920
996
|
]);
|
|
921
997
|
|
|
922
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
998
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
923
999
|
updatedObjects: [
|
|
924
1000
|
{
|
|
925
1001
|
htMeta: {
|
|
@@ -1009,7 +1085,7 @@ describe('HashTreeParser', () => {
|
|
|
1009
1085
|
assert.calledOnceWithExactly(mainPutItemsSpy, [{type: 'locus', id: 0, version: 201}]);
|
|
1010
1086
|
|
|
1011
1087
|
// Verify callback was called only for known dataset
|
|
1012
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
1088
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
1013
1089
|
updatedObjects: [
|
|
1014
1090
|
{
|
|
1015
1091
|
htMeta: {
|
|
@@ -1109,7 +1185,7 @@ describe('HashTreeParser', () => {
|
|
|
1109
1185
|
assert.calledOnceWithExactly(selfPutItemSpy, {type: 'metadata', id: 5, version: 51});
|
|
1110
1186
|
|
|
1111
1187
|
// Verify callback was called with metadata object and removed dataset objects
|
|
1112
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
1188
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
1113
1189
|
updatedObjects: [
|
|
1114
1190
|
// updated metadata object:
|
|
1115
1191
|
{
|
|
@@ -1270,7 +1346,7 @@ describe('HashTreeParser', () => {
|
|
|
1270
1346
|
assert.notCalled(atdUnmutedPutItemsSpy);
|
|
1271
1347
|
|
|
1272
1348
|
// Verify callback was called with the updated object
|
|
1273
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
1349
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
1274
1350
|
updatedObjects: [
|
|
1275
1351
|
{
|
|
1276
1352
|
htMeta: {
|
|
@@ -1498,7 +1574,7 @@ describe('HashTreeParser', () => {
|
|
|
1498
1574
|
]);
|
|
1499
1575
|
|
|
1500
1576
|
// Verify callback was called with OBJECTS_UPDATED and all updated objects
|
|
1501
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
1577
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
1502
1578
|
updatedObjects: [
|
|
1503
1579
|
{
|
|
1504
1580
|
htMeta: {
|
|
@@ -1563,9 +1639,7 @@ describe('HashTreeParser', () => {
|
|
|
1563
1639
|
parser.handleMessage(sentinelMessage, 'sentinel message');
|
|
1564
1640
|
|
|
1565
1641
|
// Verify callback was called with MEETING_ENDED
|
|
1566
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED
|
|
1567
|
-
updatedObjects: undefined,
|
|
1568
|
-
});
|
|
1642
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
|
|
1569
1643
|
|
|
1570
1644
|
// Verify that all timers were stopped
|
|
1571
1645
|
Object.values(parser.dataSets).forEach((ds: any) => {
|
|
@@ -1587,9 +1661,7 @@ describe('HashTreeParser', () => {
|
|
|
1587
1661
|
parser.handleMessage(sentinelMessage, 'sentinel message');
|
|
1588
1662
|
|
|
1589
1663
|
// Verify callback was called with MEETING_ENDED
|
|
1590
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED
|
|
1591
|
-
updatedObjects: undefined,
|
|
1592
|
-
});
|
|
1664
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
|
|
1593
1665
|
|
|
1594
1666
|
// Verify that all timers were stopped
|
|
1595
1667
|
Object.values(parser.dataSets).forEach((ds: any) => {
|
|
@@ -1685,7 +1757,7 @@ describe('HashTreeParser', () => {
|
|
|
1685
1757
|
);
|
|
1686
1758
|
|
|
1687
1759
|
// Verify that callback was called with synced objects
|
|
1688
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
1760
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
1689
1761
|
updatedObjects: [
|
|
1690
1762
|
{
|
|
1691
1763
|
htMeta: {
|
|
@@ -1698,12 +1770,10 @@ describe('HashTreeParser', () => {
|
|
|
1698
1770
|
});
|
|
1699
1771
|
});
|
|
1700
1772
|
|
|
1701
|
-
describe('emits MEETING_ENDED', () => {
|
|
1702
|
-
|
|
1703
|
-
it(`when /hashtree returns ${statusCode}`, async () => {
|
|
1773
|
+
describe('emits MEETING_ENDED when 409/2403004 is returned', () => {
|
|
1774
|
+
it('when /hashtree returns 409', async () => {
|
|
1704
1775
|
const parser = createHashTreeParser();
|
|
1705
1776
|
|
|
1706
|
-
// Send a message to trigger sync algorithm
|
|
1707
1777
|
const message = {
|
|
1708
1778
|
dataSets: [createDataSet('main', 16, 1100)],
|
|
1709
1779
|
visibleDataSetsUrl,
|
|
@@ -1728,12 +1798,9 @@ describe('HashTreeParser', () => {
|
|
|
1728
1798
|
|
|
1729
1799
|
const mainDataSetUrl = parser.dataSets.main.url;
|
|
1730
1800
|
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
error.
|
|
1734
|
-
if (statusCode === 409) {
|
|
1735
|
-
error.body = {errorCode: 2403004};
|
|
1736
|
-
}
|
|
1801
|
+
const error: any = new Error('Request failed with status 409');
|
|
1802
|
+
error.statusCode = 409;
|
|
1803
|
+
error.body = {errorCode: 2403004};
|
|
1737
1804
|
webexRequest
|
|
1738
1805
|
.withArgs(
|
|
1739
1806
|
sinon.match({
|
|
@@ -1743,13 +1810,120 @@ describe('HashTreeParser', () => {
|
|
|
1743
1810
|
)
|
|
1744
1811
|
.rejects(error);
|
|
1745
1812
|
|
|
1746
|
-
// Trigger sync by advancing time
|
|
1747
1813
|
await clock.tickAsync(1000);
|
|
1748
1814
|
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1815
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
|
|
1816
|
+
|
|
1817
|
+
Object.values(parser.dataSets).forEach((ds: any) => {
|
|
1818
|
+
assert.isUndefined(ds.timer);
|
|
1819
|
+
assert.isUndefined(ds.heartbeatWatchdogTimer);
|
|
1820
|
+
});
|
|
1821
|
+
|
|
1822
|
+
// Verify no sync failure metric was sent for end-meeting sentinel
|
|
1823
|
+
assert.notCalled(metricsStub);
|
|
1824
|
+
});
|
|
1825
|
+
|
|
1826
|
+
it('when /sync returns 409', async () => {
|
|
1827
|
+
const parser = createHashTreeParser();
|
|
1828
|
+
|
|
1829
|
+
const message = {
|
|
1830
|
+
dataSets: [createDataSet('main', 16, 1100)],
|
|
1831
|
+
visibleDataSetsUrl,
|
|
1832
|
+
locusUrl,
|
|
1833
|
+
locusStateElements: [
|
|
1834
|
+
{
|
|
1835
|
+
htMeta: {
|
|
1836
|
+
elementId: {
|
|
1837
|
+
type: 'locus' as const,
|
|
1838
|
+
id: 0,
|
|
1839
|
+
version: 201,
|
|
1840
|
+
},
|
|
1841
|
+
dataSetNames: ['main'],
|
|
1842
|
+
},
|
|
1843
|
+
data: {info: {id: 'initial-update'}},
|
|
1844
|
+
},
|
|
1845
|
+
],
|
|
1846
|
+
};
|
|
1847
|
+
|
|
1848
|
+
parser.handleMessage(message, 'initial message');
|
|
1849
|
+
callback.resetHistory();
|
|
1850
|
+
|
|
1851
|
+
const mainDataSetUrl = parser.dataSets.main.url;
|
|
1852
|
+
|
|
1853
|
+
mockGetHashesFromLocusResponse(
|
|
1854
|
+
mainDataSetUrl,
|
|
1855
|
+
new Array(16).fill('00000000000000000000000000000000'),
|
|
1856
|
+
createDataSet('main', 16, 1101)
|
|
1857
|
+
);
|
|
1858
|
+
|
|
1859
|
+
const error: any = new Error('Request failed with status 409');
|
|
1860
|
+
error.statusCode = 409;
|
|
1861
|
+
error.body = {errorCode: 2403004};
|
|
1862
|
+
webexRequest
|
|
1863
|
+
.withArgs(
|
|
1864
|
+
sinon.match({
|
|
1865
|
+
method: 'POST',
|
|
1866
|
+
uri: `${mainDataSetUrl}/sync`,
|
|
1867
|
+
})
|
|
1868
|
+
)
|
|
1869
|
+
.rejects(error);
|
|
1870
|
+
|
|
1871
|
+
await clock.tickAsync(1000);
|
|
1872
|
+
|
|
1873
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
|
|
1874
|
+
|
|
1875
|
+
Object.values(parser.dataSets).forEach((ds: any) => {
|
|
1876
|
+
assert.isUndefined(ds.timer);
|
|
1877
|
+
assert.isUndefined(ds.heartbeatWatchdogTimer);
|
|
1752
1878
|
});
|
|
1879
|
+
});
|
|
1880
|
+
});
|
|
1881
|
+
|
|
1882
|
+
describe('emits LOCUS_NOT_FOUND and stops parser when 404 is returned', () => {
|
|
1883
|
+
it('when /hashtree returns 404', async () => {
|
|
1884
|
+
const parser = createHashTreeParser();
|
|
1885
|
+
|
|
1886
|
+
const message = {
|
|
1887
|
+
dataSets: [createDataSet('main', 16, 1100)],
|
|
1888
|
+
visibleDataSetsUrl,
|
|
1889
|
+
locusUrl,
|
|
1890
|
+
locusStateElements: [
|
|
1891
|
+
{
|
|
1892
|
+
htMeta: {
|
|
1893
|
+
elementId: {
|
|
1894
|
+
type: 'locus' as const,
|
|
1895
|
+
id: 0,
|
|
1896
|
+
version: 201,
|
|
1897
|
+
},
|
|
1898
|
+
dataSetNames: ['main'],
|
|
1899
|
+
},
|
|
1900
|
+
data: {info: {id: 'initial-update'}},
|
|
1901
|
+
},
|
|
1902
|
+
],
|
|
1903
|
+
};
|
|
1904
|
+
|
|
1905
|
+
parser.handleMessage(message, 'initial message');
|
|
1906
|
+
callback.resetHistory();
|
|
1907
|
+
|
|
1908
|
+
const mainDataSetUrl = parser.dataSets.main.url;
|
|
1909
|
+
|
|
1910
|
+
const error: any = new Error('Request failed with status 404');
|
|
1911
|
+
error.statusCode = 404;
|
|
1912
|
+
webexRequest
|
|
1913
|
+
.withArgs(
|
|
1914
|
+
sinon.match({
|
|
1915
|
+
method: 'GET',
|
|
1916
|
+
uri: `${mainDataSetUrl}/hashtree`,
|
|
1917
|
+
})
|
|
1918
|
+
)
|
|
1919
|
+
.rejects(error);
|
|
1920
|
+
|
|
1921
|
+
await clock.tickAsync(1000);
|
|
1922
|
+
|
|
1923
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.LOCUS_NOT_FOUND});
|
|
1924
|
+
|
|
1925
|
+
// Verify parser is stopped
|
|
1926
|
+
expect(parser.state).to.equal('stopped');
|
|
1753
1927
|
|
|
1754
1928
|
// Verify all timers are stopped
|
|
1755
1929
|
Object.values(parser.dataSets).forEach((ds: any) => {
|
|
@@ -1758,10 +1932,9 @@ describe('HashTreeParser', () => {
|
|
|
1758
1932
|
});
|
|
1759
1933
|
});
|
|
1760
1934
|
|
|
1761
|
-
it(
|
|
1935
|
+
it('when /sync returns 404', async () => {
|
|
1762
1936
|
const parser = createHashTreeParser();
|
|
1763
1937
|
|
|
1764
|
-
// Send a message to trigger sync algorithm
|
|
1765
1938
|
const message = {
|
|
1766
1939
|
dataSets: [createDataSet('main', 16, 1100)],
|
|
1767
1940
|
visibleDataSetsUrl,
|
|
@@ -1786,19 +1959,14 @@ describe('HashTreeParser', () => {
|
|
|
1786
1959
|
|
|
1787
1960
|
const mainDataSetUrl = parser.dataSets.main.url;
|
|
1788
1961
|
|
|
1789
|
-
// Mock getHashesFromLocus to succeed
|
|
1790
1962
|
mockGetHashesFromLocusResponse(
|
|
1791
1963
|
mainDataSetUrl,
|
|
1792
1964
|
new Array(16).fill('00000000000000000000000000000000'),
|
|
1793
1965
|
createDataSet('main', 16, 1101)
|
|
1794
1966
|
);
|
|
1795
1967
|
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
error.statusCode = statusCode;
|
|
1799
|
-
if (statusCode === 409) {
|
|
1800
|
-
error.body = {errorCode: 2403004};
|
|
1801
|
-
}
|
|
1968
|
+
const error: any = new Error('Request failed with status 404');
|
|
1969
|
+
error.statusCode = 404;
|
|
1802
1970
|
webexRequest
|
|
1803
1971
|
.withArgs(
|
|
1804
1972
|
sinon.match({
|
|
@@ -1808,21 +1976,22 @@ describe('HashTreeParser', () => {
|
|
|
1808
1976
|
)
|
|
1809
1977
|
.rejects(error);
|
|
1810
1978
|
|
|
1811
|
-
// Trigger sync by advancing time
|
|
1812
1979
|
await clock.tickAsync(1000);
|
|
1813
1980
|
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1981
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.LOCUS_NOT_FOUND});
|
|
1982
|
+
|
|
1983
|
+
// Verify parser is stopped
|
|
1984
|
+
expect(parser.state).to.equal('stopped');
|
|
1818
1985
|
|
|
1819
1986
|
// Verify all timers are stopped
|
|
1820
1987
|
Object.values(parser.dataSets).forEach((ds: any) => {
|
|
1821
1988
|
assert.isUndefined(ds.timer);
|
|
1822
1989
|
assert.isUndefined(ds.heartbeatWatchdogTimer);
|
|
1823
1990
|
});
|
|
1991
|
+
|
|
1992
|
+
// Verify no sync failure metric was sent for end-meeting sentinel
|
|
1993
|
+
assert.notCalled(metricsStub);
|
|
1824
1994
|
});
|
|
1825
|
-
});
|
|
1826
1995
|
});
|
|
1827
1996
|
|
|
1828
1997
|
it('requests only mismatched hashes during sync', async () => {
|
|
@@ -1993,79 +2162,299 @@ describe('HashTreeParser', () => {
|
|
|
1993
2162
|
},
|
|
1994
2163
|
});
|
|
1995
2164
|
});
|
|
1996
|
-
});
|
|
1997
2165
|
|
|
1998
|
-
|
|
1999
|
-
it('handles addition of visible data set (one that does not require async initialization)', async () => {
|
|
2000
|
-
// Create a parser with visible datasets
|
|
2166
|
+
it('restarts the sync timer when sync response is empty so that a future sync can be triggered', async () => {
|
|
2001
2167
|
const parser = createHashTreeParser();
|
|
2002
2168
|
|
|
2003
|
-
//
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2169
|
+
// Send a heartbeat with a mismatched root hash to trigger runSyncAlgorithm
|
|
2170
|
+
const heartbeatMessage = {
|
|
2171
|
+
dataSets: [
|
|
2172
|
+
{
|
|
2173
|
+
...createDataSet('main', 16, 1100),
|
|
2174
|
+
root: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1', // different from ours
|
|
2175
|
+
},
|
|
2176
|
+
],
|
|
2009
2177
|
visibleDataSetsUrl,
|
|
2010
2178
|
locusUrl,
|
|
2011
|
-
|
|
2179
|
+
};
|
|
2180
|
+
|
|
2181
|
+
parser.handleMessage(heartbeatMessage, 'heartbeat with mismatch');
|
|
2182
|
+
|
|
2183
|
+
// The sync timer should be set
|
|
2184
|
+
expect(parser.dataSets.main.timer).to.not.be.undefined;
|
|
2185
|
+
|
|
2186
|
+
// Mock responses for the first sync - return null (204/empty body)
|
|
2187
|
+
const mainDataSetUrl = parser.dataSets.main.url;
|
|
2188
|
+
mockGetHashesFromLocusResponse(
|
|
2189
|
+
mainDataSetUrl,
|
|
2190
|
+
new Array(16).fill('00000000000000000000000000000000'),
|
|
2191
|
+
{
|
|
2192
|
+
...createDataSet('main', 16, 1101),
|
|
2193
|
+
root: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', // still mismatched
|
|
2194
|
+
}
|
|
2195
|
+
);
|
|
2196
|
+
mockSendSyncRequestResponse(mainDataSetUrl, null);
|
|
2197
|
+
|
|
2198
|
+
// Advance time to fire the sync timer (idleMs=1000 + backoff=0)
|
|
2199
|
+
await clock.tickAsync(1000);
|
|
2200
|
+
|
|
2201
|
+
// Verify sync was triggered
|
|
2202
|
+
assert.calledWith(
|
|
2203
|
+
webexRequest,
|
|
2204
|
+
sinon.match({
|
|
2205
|
+
method: 'POST',
|
|
2206
|
+
uri: `${mainDataSetUrl}/sync`,
|
|
2207
|
+
})
|
|
2208
|
+
);
|
|
2209
|
+
|
|
2210
|
+
// After empty response, runSyncAlgorithm should have been called,
|
|
2211
|
+
// setting a new sync timer as a safety net
|
|
2212
|
+
expect(parser.dataSets.main.timer).to.not.be.undefined;
|
|
2213
|
+
|
|
2214
|
+
// Reset and set up mocks for the second sync
|
|
2215
|
+
webexRequest.resetHistory();
|
|
2216
|
+
mockGetHashesFromLocusResponse(
|
|
2217
|
+
mainDataSetUrl,
|
|
2218
|
+
new Array(16).fill('00000000000000000000000000000000'),
|
|
2219
|
+
{
|
|
2220
|
+
...createDataSet('main', 16, 1102),
|
|
2221
|
+
root: 'cccccccccccccccccccccccccccccccc', // still mismatched
|
|
2222
|
+
}
|
|
2223
|
+
);
|
|
2224
|
+
mockSendSyncRequestResponse(mainDataSetUrl, null);
|
|
2225
|
+
|
|
2226
|
+
// Advance time again to fire the second sync timer
|
|
2227
|
+
await clock.tickAsync(1000);
|
|
2228
|
+
|
|
2229
|
+
// Verify a second sync was triggered
|
|
2230
|
+
assert.calledWith(
|
|
2231
|
+
webexRequest,
|
|
2232
|
+
sinon.match({
|
|
2233
|
+
method: 'POST',
|
|
2234
|
+
uri: `${mainDataSetUrl}/sync`,
|
|
2235
|
+
})
|
|
2236
|
+
);
|
|
2237
|
+
});
|
|
2238
|
+
|
|
2239
|
+
it('updates dataSet.leafCount when hash tree is resized during sync so that the sync request has the correct leafCount', async () => {
|
|
2240
|
+
const parser = createHashTreeParser();
|
|
2241
|
+
|
|
2242
|
+
// Send a heartbeat with a mismatched root hash to trigger runSyncAlgorithm
|
|
2243
|
+
const heartbeatMessage = {
|
|
2244
|
+
dataSets: [
|
|
2012
2245
|
{
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
type: 'metadata' as const,
|
|
2016
|
-
id: 5,
|
|
2017
|
-
version: 51,
|
|
2018
|
-
},
|
|
2019
|
-
dataSetNames: ['self'],
|
|
2020
|
-
},
|
|
2021
|
-
data: {
|
|
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'
|
|
2040
|
-
},
|
|
2246
|
+
...createDataSet('main', 16, 1100),
|
|
2247
|
+
root: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1', // different from ours
|
|
2041
2248
|
},
|
|
2042
2249
|
],
|
|
2250
|
+
visibleDataSetsUrl,
|
|
2251
|
+
locusUrl,
|
|
2043
2252
|
};
|
|
2044
2253
|
|
|
2045
|
-
parser.handleMessage(
|
|
2254
|
+
parser.handleMessage(heartbeatMessage, 'heartbeat with mismatch');
|
|
2046
2255
|
|
|
2047
|
-
//
|
|
2048
|
-
expect(parser.
|
|
2256
|
+
// The sync timer should be set
|
|
2257
|
+
expect(parser.dataSets.main.timer).to.not.be.undefined;
|
|
2049
2258
|
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
assert.equal(parser.dataSets.attendees.hashTree.numLeaves, 8);
|
|
2259
|
+
const mainDataSetUrl = parser.dataSets.main.url;
|
|
2260
|
+
const newLeafCount = 32;
|
|
2053
2261
|
|
|
2054
|
-
//
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2262
|
+
// Mock getHashesFromLocus response with a DIFFERENT leafCount (32 instead of 16)
|
|
2263
|
+
mockGetHashesFromLocusResponse(
|
|
2264
|
+
mainDataSetUrl,
|
|
2265
|
+
new Array(newLeafCount).fill('00000000000000000000000000000000'),
|
|
2266
|
+
createDataSet('main', newLeafCount, 1101)
|
|
2267
|
+
);
|
|
2268
|
+
|
|
2269
|
+
// Mock the sync request - use matching root hash
|
|
2270
|
+
const syncResponseDataSet = createDataSet('main', newLeafCount, 1102);
|
|
2271
|
+
syncResponseDataSet.root = parser.dataSets.main.hashTree.getRootHash();
|
|
2272
|
+
mockSendSyncRequestResponse(mainDataSetUrl, {
|
|
2273
|
+
dataSets: [syncResponseDataSet],
|
|
2274
|
+
visibleDataSetsUrl,
|
|
2275
|
+
locusUrl,
|
|
2276
|
+
locusStateElements: [],
|
|
2277
|
+
});
|
|
2278
|
+
|
|
2279
|
+
// Advance time to fire the sync timer (idleMs=1000 + backoff=0)
|
|
2280
|
+
await clock.tickAsync(1000);
|
|
2281
|
+
|
|
2282
|
+
// Verify the sync request was sent with the NEW leafCount (32), not the old one (16)
|
|
2283
|
+
assert.calledWith(
|
|
2284
|
+
webexRequest,
|
|
2285
|
+
sinon.match({
|
|
2286
|
+
method: 'POST',
|
|
2287
|
+
uri: `${mainDataSetUrl}/sync`,
|
|
2288
|
+
body: sinon.match({
|
|
2289
|
+
leafCount: newLeafCount,
|
|
2290
|
+
}),
|
|
2291
|
+
})
|
|
2292
|
+
);
|
|
2293
|
+
});
|
|
2294
|
+
|
|
2295
|
+
it('sends HASH_TREE_SYNC_FAILURE metric when GET /hashtree request fails', async () => {
|
|
2296
|
+
const parser = createHashTreeParser();
|
|
2297
|
+
|
|
2298
|
+
// Send a heartbeat with a mismatched root hash to trigger runSyncAlgorithm
|
|
2299
|
+
const heartbeatMessage = {
|
|
2300
|
+
dataSets: [
|
|
2301
|
+
{
|
|
2302
|
+
...createDataSet('main', 16, 1100),
|
|
2303
|
+
root: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1',
|
|
2304
|
+
},
|
|
2305
|
+
],
|
|
2306
|
+
visibleDataSetsUrl,
|
|
2307
|
+
locusUrl,
|
|
2308
|
+
};
|
|
2309
|
+
|
|
2310
|
+
parser.handleMessage(heartbeatMessage, 'heartbeat with mismatch');
|
|
2311
|
+
|
|
2312
|
+
const mainDataSetUrl = parser.dataSets.main.url;
|
|
2313
|
+
const hashTreeError = new Error('server error') as any;
|
|
2314
|
+
hashTreeError.statusCode = 500;
|
|
2315
|
+
|
|
2316
|
+
webexRequest
|
|
2317
|
+
.withArgs(
|
|
2318
|
+
sinon.match({
|
|
2319
|
+
method: 'GET',
|
|
2320
|
+
uri: `${mainDataSetUrl}/hashtree`,
|
|
2321
|
+
})
|
|
2322
|
+
)
|
|
2323
|
+
.rejects(hashTreeError);
|
|
2324
|
+
|
|
2325
|
+
await clock.tickAsync(1000);
|
|
2326
|
+
|
|
2327
|
+
assert.calledOnceWithExactly(metricsStub, BEHAVIORAL_METRICS.HASH_TREE_SYNC_FAILURE, {
|
|
2328
|
+
debugId: 'test',
|
|
2329
|
+
dataSetName: 'main',
|
|
2330
|
+
request: 'GET /hashtree',
|
|
2331
|
+
statusCode: 500,
|
|
2332
|
+
reason: 'server error',
|
|
2333
|
+
});
|
|
2334
|
+
});
|
|
2335
|
+
|
|
2336
|
+
it('sends HASH_TREE_SYNC_FAILURE metric when POST /sync request fails', async () => {
|
|
2337
|
+
const parser = createHashTreeParser();
|
|
2338
|
+
|
|
2339
|
+
// Send a heartbeat with a mismatched root hash to trigger runSyncAlgorithm
|
|
2340
|
+
const heartbeatMessage = {
|
|
2341
|
+
dataSets: [
|
|
2342
|
+
{
|
|
2343
|
+
...createDataSet('main', 16, 1100),
|
|
2344
|
+
root: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1',
|
|
2345
|
+
},
|
|
2346
|
+
],
|
|
2347
|
+
visibleDataSetsUrl,
|
|
2348
|
+
locusUrl,
|
|
2349
|
+
};
|
|
2350
|
+
|
|
2351
|
+
parser.handleMessage(heartbeatMessage, 'heartbeat with mismatch');
|
|
2352
|
+
|
|
2353
|
+
const mainDataSetUrl = parser.dataSets.main.url;
|
|
2354
|
+
|
|
2355
|
+
// Mock getHashesFromLocus to succeed
|
|
2356
|
+
mockGetHashesFromLocusResponse(
|
|
2357
|
+
mainDataSetUrl,
|
|
2358
|
+
new Array(16).fill('00000000000000000000000000000000'),
|
|
2359
|
+
createDataSet('main', 16, 1101)
|
|
2360
|
+
);
|
|
2361
|
+
|
|
2362
|
+
// Mock sendSyncRequestToLocus to fail
|
|
2363
|
+
const syncError = new Error('sync failed') as any;
|
|
2364
|
+
syncError.statusCode = 500;
|
|
2365
|
+
|
|
2366
|
+
webexRequest
|
|
2367
|
+
.withArgs(
|
|
2368
|
+
sinon.match({
|
|
2369
|
+
method: 'POST',
|
|
2370
|
+
uri: `${mainDataSetUrl}/sync`,
|
|
2371
|
+
})
|
|
2372
|
+
)
|
|
2373
|
+
.rejects(syncError);
|
|
2374
|
+
|
|
2375
|
+
await clock.tickAsync(1000);
|
|
2376
|
+
|
|
2377
|
+
assert.calledOnceWithExactly(metricsStub, BEHAVIORAL_METRICS.HASH_TREE_SYNC_FAILURE, {
|
|
2378
|
+
debugId: 'test',
|
|
2379
|
+
dataSetName: 'main',
|
|
2380
|
+
request: 'POST /sync',
|
|
2381
|
+
statusCode: 500,
|
|
2382
|
+
reason: 'sync failed',
|
|
2383
|
+
});
|
|
2384
|
+
});
|
|
2385
|
+
});
|
|
2386
|
+
|
|
2387
|
+
describe('handles visible data sets changes correctly', () => {
|
|
2388
|
+
it('handles addition of visible data set (one that does not require async initialization)', async () => {
|
|
2389
|
+
// Create a parser with visible datasets
|
|
2390
|
+
const parser = createHashTreeParser();
|
|
2391
|
+
|
|
2392
|
+
// Stub updateItems on self hash tree to return true
|
|
2393
|
+
sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
|
|
2394
|
+
|
|
2395
|
+
// Send a message with Metadata object that has a new visibleDataSets list
|
|
2396
|
+
const message = {
|
|
2397
|
+
dataSets: [createDataSet('self', 1, 2100), createDataSet('attendees', 8, 4000)],
|
|
2398
|
+
visibleDataSetsUrl,
|
|
2399
|
+
locusUrl,
|
|
2400
|
+
locusStateElements: [
|
|
2401
|
+
{
|
|
2402
|
+
htMeta: {
|
|
2403
|
+
elementId: {
|
|
2404
|
+
type: 'metadata' as const,
|
|
2405
|
+
id: 5,
|
|
2406
|
+
version: 51,
|
|
2407
|
+
},
|
|
2408
|
+
dataSetNames: ['self'],
|
|
2409
|
+
},
|
|
2410
|
+
data: {
|
|
2411
|
+
visibleDataSets: [
|
|
2412
|
+
{
|
|
2413
|
+
name: 'main',
|
|
2414
|
+
url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main',
|
|
2415
|
+
},
|
|
2416
|
+
{
|
|
2417
|
+
name: 'self',
|
|
2418
|
+
url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
|
|
2419
|
+
},
|
|
2420
|
+
{
|
|
2421
|
+
name: 'atd-unmuted',
|
|
2422
|
+
url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/atd-unmuted',
|
|
2423
|
+
},
|
|
2424
|
+
{
|
|
2425
|
+
name: 'attendees',
|
|
2426
|
+
url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/attendees',
|
|
2427
|
+
},
|
|
2428
|
+
], // added 'attendees'
|
|
2429
|
+
},
|
|
2430
|
+
},
|
|
2431
|
+
],
|
|
2432
|
+
};
|
|
2433
|
+
|
|
2434
|
+
parser.handleMessage(message, 'add visible dataset');
|
|
2435
|
+
|
|
2436
|
+
// Verify that 'attendees' was added to visibleDataSets
|
|
2437
|
+
expect(parser.visibleDataSets.some((vds) => vds.name === 'attendees')).to.be.true;
|
|
2438
|
+
|
|
2439
|
+
// Verify that a hash tree was created for 'attendees'
|
|
2440
|
+
assert.exists(parser.dataSets.attendees.hashTree);
|
|
2441
|
+
assert.equal(parser.dataSets.attendees.hashTree.numLeaves, 8);
|
|
2442
|
+
|
|
2443
|
+
// Verify callback was called with the metadata update (appears twice - processed once for visible dataset changes, once in main loop)
|
|
2444
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
2445
|
+
updatedObjects: [
|
|
2446
|
+
{
|
|
2447
|
+
htMeta: {
|
|
2448
|
+
elementId: {type: 'metadata', id: 5, version: 51},
|
|
2449
|
+
dataSetNames: ['self'],
|
|
2450
|
+
},
|
|
2451
|
+
data: {
|
|
2452
|
+
visibleDataSets: [
|
|
2453
|
+
{
|
|
2454
|
+
name: 'main',
|
|
2455
|
+
url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main',
|
|
2456
|
+
},
|
|
2457
|
+
{
|
|
2069
2458
|
name: 'self',
|
|
2070
2459
|
url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
|
|
2071
2460
|
},
|
|
@@ -2172,7 +2561,99 @@ describe('HashTreeParser', () => {
|
|
|
2172
2561
|
await checkAsyncDatasetInitialization(parser, newDataSet);
|
|
2173
2562
|
});
|
|
2174
2563
|
|
|
2175
|
-
it('
|
|
2564
|
+
it('initializes new visible data sets in priority order', async () => {
|
|
2565
|
+
// Create a parser that only has "self" as visible (no "main")
|
|
2566
|
+
const initialLocusWithoutMain = {
|
|
2567
|
+
dataSets: [createDataSet('self', 1, 2000)],
|
|
2568
|
+
locus: {
|
|
2569
|
+
...exampleInitialLocus.locus,
|
|
2570
|
+
},
|
|
2571
|
+
};
|
|
2572
|
+
const metadataWithoutMain = {
|
|
2573
|
+
...exampleMetadata,
|
|
2574
|
+
visibleDataSets: [
|
|
2575
|
+
{
|
|
2576
|
+
name: 'self',
|
|
2577
|
+
url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
|
|
2578
|
+
},
|
|
2579
|
+
],
|
|
2580
|
+
};
|
|
2581
|
+
const parser = createHashTreeParser(initialLocusWithoutMain, metadataWithoutMain);
|
|
2582
|
+
|
|
2583
|
+
// Verify "main" is not visible initially
|
|
2584
|
+
expect(parser.visibleDataSets.some((vds) => vds.name === 'main')).to.be.false;
|
|
2585
|
+
|
|
2586
|
+
// Stub updateItems on self hash tree to return true
|
|
2587
|
+
sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
|
|
2588
|
+
|
|
2589
|
+
// Send a message that adds "main" and "atd-active" as new visible datasets.
|
|
2590
|
+
// Neither has info in dataSets, so both require async initialization.
|
|
2591
|
+
const newMainDataSet = createDataSet('main', 16, 6000);
|
|
2592
|
+
const newAtdActiveDataSet = createDataSet('atd-active', 4, 7000);
|
|
2593
|
+
|
|
2594
|
+
const message = {
|
|
2595
|
+
dataSets: [createDataSet('self', 1, 2100)],
|
|
2596
|
+
visibleDataSetsUrl,
|
|
2597
|
+
locusUrl,
|
|
2598
|
+
locusStateElements: [
|
|
2599
|
+
{
|
|
2600
|
+
htMeta: {
|
|
2601
|
+
elementId: {
|
|
2602
|
+
type: 'metadata' as const,
|
|
2603
|
+
id: 5,
|
|
2604
|
+
version: 51,
|
|
2605
|
+
},
|
|
2606
|
+
dataSetNames: ['self'],
|
|
2607
|
+
},
|
|
2608
|
+
data: {
|
|
2609
|
+
visibleDataSets: [
|
|
2610
|
+
{
|
|
2611
|
+
name: 'self',
|
|
2612
|
+
url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
|
|
2613
|
+
},
|
|
2614
|
+
// listed in non-priority order: atd-active before main
|
|
2615
|
+
{name: 'atd-active', url: newAtdActiveDataSet.url},
|
|
2616
|
+
{name: 'main', url: newMainDataSet.url},
|
|
2617
|
+
],
|
|
2618
|
+
},
|
|
2619
|
+
},
|
|
2620
|
+
],
|
|
2621
|
+
};
|
|
2622
|
+
|
|
2623
|
+
// Mock getAllVisibleDataSetsFromLocus to return both new datasets (in non-priority order)
|
|
2624
|
+
mockGetAllDataSetsMetadata(webexRequest, visibleDataSetsUrl, [
|
|
2625
|
+
newAtdActiveDataSet,
|
|
2626
|
+
newMainDataSet,
|
|
2627
|
+
]);
|
|
2628
|
+
mockSyncRequest(webexRequest, newMainDataSet.url);
|
|
2629
|
+
mockSyncRequest(webexRequest, newAtdActiveDataSet.url);
|
|
2630
|
+
|
|
2631
|
+
parser.handleMessage(message, 'add main and atd-active datasets');
|
|
2632
|
+
|
|
2633
|
+
// Wait for the async initialization (queueMicrotask) to complete
|
|
2634
|
+
await clock.tickAsync(0);
|
|
2635
|
+
|
|
2636
|
+
// Verify both datasets are initialized
|
|
2637
|
+
expect(parser.dataSets.main?.hashTree).to.exist;
|
|
2638
|
+
expect(parser.dataSets['atd-active']?.hashTree).to.exist;
|
|
2639
|
+
|
|
2640
|
+
// Verify sync requests were sent in priority order: "main" before "atd-active",
|
|
2641
|
+
// even though atd-active was listed first in both the message and the Locus response
|
|
2642
|
+
const syncCalls = webexRequest
|
|
2643
|
+
.getCalls()
|
|
2644
|
+
.filter(
|
|
2645
|
+
(call) =>
|
|
2646
|
+
call.args[0]?.method === 'POST' &&
|
|
2647
|
+
call.args[0]?.uri?.endsWith('/sync') &&
|
|
2648
|
+
(call.args[0]?.uri?.includes('/main/') || call.args[0]?.uri?.includes('/atd-active/'))
|
|
2649
|
+
);
|
|
2650
|
+
|
|
2651
|
+
expect(syncCalls).to.have.lengthOf(2);
|
|
2652
|
+
expect(syncCalls[0].args[0].uri).to.equal(`${newMainDataSet.url}/sync`);
|
|
2653
|
+
expect(syncCalls[1].args[0].uri).to.equal(`${newAtdActiveDataSet.url}/sync`);
|
|
2654
|
+
});
|
|
2655
|
+
|
|
2656
|
+
it('emits LOCUS_NOT_FOUND if async init of a new visible dataset fails with 404', async () => {
|
|
2176
2657
|
const parser = createHashTreeParser();
|
|
2177
2658
|
|
|
2178
2659
|
// Stub updateItems on self hash tree to return true
|
|
@@ -2237,10 +2718,8 @@ describe('HashTreeParser', () => {
|
|
|
2237
2718
|
// Wait for the async initialization (queueMicrotask) to complete
|
|
2238
2719
|
await clock.tickAsync(0);
|
|
2239
2720
|
|
|
2240
|
-
// Verify callback was called with
|
|
2241
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.
|
|
2242
|
-
updatedObjects: undefined,
|
|
2243
|
-
});
|
|
2721
|
+
// Verify callback was called with LOCUS_NOT_FOUND (404 means locus URL is stale, not necessarily meeting ended)
|
|
2722
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.LOCUS_NOT_FOUND});
|
|
2244
2723
|
});
|
|
2245
2724
|
|
|
2246
2725
|
it('handles removal of visible data set', async () => {
|
|
@@ -2303,7 +2782,7 @@ describe('HashTreeParser', () => {
|
|
|
2303
2782
|
assert.isUndefined(parser.dataSets['atd-unmuted'].timer);
|
|
2304
2783
|
|
|
2305
2784
|
// Verify callback was called with the metadata update and the removed objects (metadata appears twice - processed once for dataset changes, once in main loop)
|
|
2306
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
2785
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
2307
2786
|
updatedObjects: [
|
|
2308
2787
|
{
|
|
2309
2788
|
htMeta: {
|
|
@@ -2400,37 +2879,182 @@ describe('HashTreeParser', () => {
|
|
|
2400
2879
|
// Verify callback was NOT called (no updates for non-visible datasets)
|
|
2401
2880
|
assert.notCalled(callback);
|
|
2402
2881
|
});
|
|
2403
|
-
});
|
|
2404
2882
|
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2883
|
+
it('reports update for object that moves from removed visible dataset to new visible dataset even if version is unchanged', async () => {
|
|
2884
|
+
// The purpose of this test is to verify that when an object
|
|
2885
|
+
// moves from one visible dataset to another without version change,
|
|
2886
|
+
// the parser still reports it as an update.
|
|
2887
|
+
// Locus has some additional signalling for this - the "view" property in htMeta.elementId.
|
|
2888
|
+
// When a view changes, the contents of the object may change even if version doesn't.
|
|
2889
|
+
// HashTreeParser doesn't use the "view" property, because it doesn't need to -
|
|
2890
|
+
// the same functionality is achieved thanks to the fact that a new visible data set means
|
|
2891
|
+
// a new hash tree is created, so HashTreeParser still detects the change as new
|
|
2892
|
+
// object is added to the new hash tree.
|
|
2893
|
+
|
|
2894
|
+
// Setup: parser with visible datasets "self" and "unjoined"
|
|
2895
|
+
const unjoinedDataSet = createDataSet('unjoined', 4, 3000);
|
|
2896
|
+
const selfDataSet = createDataSet('self', 1, 2000);
|
|
2897
|
+
|
|
2898
|
+
// start with Locus that has "info" in both "unjoined" and "main" datasets,
|
|
2899
|
+
// but only "unjoined" is visible.
|
|
2900
|
+
const initialLocus = {
|
|
2901
|
+
dataSets: [selfDataSet, unjoinedDataSet],
|
|
2902
|
+
locus: {
|
|
2903
|
+
url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f',
|
|
2904
|
+
links: {resources: {visibleDataSets: {url: visibleDataSetsUrl}}},
|
|
2905
|
+
// info object in "unjoined" dataset with version 500
|
|
2906
|
+
info: {
|
|
2907
|
+
htMeta: {
|
|
2908
|
+
elementId: {
|
|
2909
|
+
type: 'info',
|
|
2910
|
+
id: 42,
|
|
2911
|
+
version: 500,
|
|
2912
|
+
view: ['unjoined'], // not used by our code, but here for completeness - that's what real Locus would send
|
|
2913
|
+
},
|
|
2914
|
+
dataSetNames: ['main', 'unjoined'],
|
|
2915
|
+
},
|
|
2916
|
+
someField: 'some-initial-value',
|
|
2917
|
+
},
|
|
2918
|
+
self: {
|
|
2919
|
+
htMeta: {
|
|
2920
|
+
elementId: {
|
|
2921
|
+
type: 'self',
|
|
2922
|
+
id: 4,
|
|
2923
|
+
version: 100,
|
|
2924
|
+
},
|
|
2925
|
+
dataSetNames: ['self'],
|
|
2926
|
+
},
|
|
2927
|
+
},
|
|
2928
|
+
},
|
|
2929
|
+
};
|
|
2409
2930
|
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2931
|
+
const metadata = {
|
|
2932
|
+
htMeta: {
|
|
2933
|
+
elementId: {
|
|
2934
|
+
type: 'metadata',
|
|
2935
|
+
id: 5,
|
|
2936
|
+
version: 50,
|
|
2416
2937
|
},
|
|
2938
|
+
dataSetNames: ['self'],
|
|
2939
|
+
},
|
|
2940
|
+
visibleDataSets: [
|
|
2941
|
+
{name: 'self', url: selfDataSet.url},
|
|
2942
|
+
{name: 'unjoined', url: unjoinedDataSet.url},
|
|
2417
2943
|
],
|
|
2418
|
-
visibleDataSetsUrl,
|
|
2419
|
-
locusUrl,
|
|
2420
|
-
heartbeatIntervalMs,
|
|
2421
2944
|
};
|
|
2422
2945
|
|
|
2423
|
-
parser
|
|
2946
|
+
const parser = createHashTreeParser(initialLocus, metadata);
|
|
2424
2947
|
|
|
2425
|
-
// Verify
|
|
2426
|
-
expect(parser.
|
|
2427
|
-
|
|
2428
|
-
|
|
2948
|
+
// Verify initial state: unjoined is visible and has the info object
|
|
2949
|
+
expect(parser.visibleDataSets.some((vds) => vds.name === 'unjoined')).to.be.true;
|
|
2950
|
+
assert.exists(parser.dataSets.unjoined.hashTree);
|
|
2951
|
+
assert.equal(parser.dataSets.unjoined.hashTree?.getItemVersion(42, 'info'), 500);
|
|
2429
2952
|
|
|
2430
|
-
//
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2953
|
+
// Stub updateItems on self hash tree to return true for metadata update
|
|
2954
|
+
sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
|
|
2955
|
+
|
|
2956
|
+
// Now send a message that:
|
|
2957
|
+
// 1. Changes visible datasets: removes "unjoined", adds "main"
|
|
2958
|
+
// 2. Contains the same info object (same id=42, same version=500) but we see the view from "main" dataset
|
|
2959
|
+
const mainDataSet = createDataSet('main', 16, 1000);
|
|
2960
|
+
|
|
2961
|
+
const message = {
|
|
2962
|
+
dataSets: [selfDataSet, mainDataSet],
|
|
2963
|
+
visibleDataSetsUrl,
|
|
2964
|
+
locusUrl,
|
|
2965
|
+
locusStateElements: [
|
|
2966
|
+
{
|
|
2967
|
+
htMeta: {
|
|
2968
|
+
elementId: {
|
|
2969
|
+
type: 'metadata' as const,
|
|
2970
|
+
id: 5,
|
|
2971
|
+
version: 51,
|
|
2972
|
+
},
|
|
2973
|
+
dataSetNames: ['self'],
|
|
2974
|
+
},
|
|
2975
|
+
data: {
|
|
2976
|
+
visibleDataSets: [
|
|
2977
|
+
{name: 'self', url: selfDataSet.url},
|
|
2978
|
+
{name: 'main', url: mainDataSet.url},
|
|
2979
|
+
// "unjoined" is no longer here
|
|
2980
|
+
],
|
|
2981
|
+
},
|
|
2982
|
+
},
|
|
2983
|
+
{
|
|
2984
|
+
htMeta: {
|
|
2985
|
+
elementId: {
|
|
2986
|
+
type: 'info' as const,
|
|
2987
|
+
id: 42,
|
|
2988
|
+
version: 500, // same version as before
|
|
2989
|
+
view: ['main'], // now points to "main" instead of "unjoined"
|
|
2990
|
+
},
|
|
2991
|
+
dataSetNames: ['main', 'unjoined'], // still in both datasets, but only "main" is visible now
|
|
2992
|
+
},
|
|
2993
|
+
data: {someNewField: 'some-value'},
|
|
2994
|
+
},
|
|
2995
|
+
],
|
|
2996
|
+
};
|
|
2997
|
+
|
|
2998
|
+
parser.handleMessage(message, 'visible dataset swap with same-version object');
|
|
2999
|
+
|
|
3000
|
+
// Verify "unjoined" is no longer visible and "main" is now visible
|
|
3001
|
+
expect(parser.visibleDataSets.some((vds) => vds.name === 'unjoined')).to.be.false;
|
|
3002
|
+
expect(parser.visibleDataSets.some((vds) => vds.name === 'main')).to.be.true;
|
|
3003
|
+
|
|
3004
|
+
// Verify the info object is now in the "main" hash tree
|
|
3005
|
+
assert.exists(parser.dataSets.main.hashTree);
|
|
3006
|
+
assert.equal(parser.dataSets.main.hashTree?.getItemVersion(42, 'info'), 500);
|
|
3007
|
+
|
|
3008
|
+
// The key assertion: callback should be called with the info object update even though
|
|
3009
|
+
// its version hasn't changed - because visible datasets changed (moved from unjoined to main)
|
|
3010
|
+
assert.calledOnce(callback);
|
|
3011
|
+
const callbackArgs = callback.firstCall.args[0];
|
|
3012
|
+
assert.equal(callbackArgs.updateType, LocusInfoUpdateType.OBJECTS_UPDATED);
|
|
3013
|
+
|
|
3014
|
+
// Should contain the info object update (with data)
|
|
3015
|
+
const infoUpdate = callbackArgs.updatedObjects.find(
|
|
3016
|
+
(obj) => obj.htMeta.elementId.type === 'info' && obj.htMeta.elementId.id === 42
|
|
3017
|
+
);
|
|
3018
|
+
assert.exists(infoUpdate);
|
|
3019
|
+
assert.deepEqual(infoUpdate.htMeta.elementId, {
|
|
3020
|
+
type: 'info',
|
|
3021
|
+
id: 42,
|
|
3022
|
+
version: 500,
|
|
3023
|
+
view: ['main'],
|
|
3024
|
+
});
|
|
3025
|
+
assert.deepEqual(infoUpdate.data, {someNewField: 'some-value'});
|
|
3026
|
+
});
|
|
3027
|
+
});
|
|
3028
|
+
|
|
3029
|
+
describe('heartbeat watchdog', () => {
|
|
3030
|
+
it('initiates sync immediately only for the specific data set whose heartbeat watchdog fires', async () => {
|
|
3031
|
+
const parser = createHashTreeParser();
|
|
3032
|
+
const heartbeatIntervalMs = 5000;
|
|
3033
|
+
|
|
3034
|
+
// Send initial heartbeat message for 'main' only
|
|
3035
|
+
const heartbeatMessage = {
|
|
3036
|
+
dataSets: [
|
|
3037
|
+
{
|
|
3038
|
+
...createDataSet('main', 16, 1100),
|
|
3039
|
+
root: parser.dataSets.main.hashTree.getRootHash(),
|
|
3040
|
+
},
|
|
3041
|
+
],
|
|
3042
|
+
visibleDataSetsUrl,
|
|
3043
|
+
locusUrl,
|
|
3044
|
+
heartbeatIntervalMs,
|
|
3045
|
+
};
|
|
3046
|
+
|
|
3047
|
+
parser.handleMessage(heartbeatMessage, 'initial heartbeat');
|
|
3048
|
+
|
|
3049
|
+
// Verify only 'main' watchdog timer is set
|
|
3050
|
+
expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
|
|
3051
|
+
expect(parser.dataSets.self.heartbeatWatchdogTimer).to.be.undefined;
|
|
3052
|
+
expect(parser.dataSets['atd-unmuted'].heartbeatWatchdogTimer).to.be.undefined;
|
|
3053
|
+
|
|
3054
|
+
// Mock responses for performSync (GET hashtree then POST sync for leafCount > 1)
|
|
3055
|
+
const mainDataSetUrl = parser.dataSets.main.url;
|
|
3056
|
+
mockGetHashesFromLocusResponse(
|
|
3057
|
+
mainDataSetUrl,
|
|
2434
3058
|
new Array(16).fill('00000000000000000000000000000000'),
|
|
2435
3059
|
createDataSet('main', 16, 1101)
|
|
2436
3060
|
);
|
|
@@ -2449,6 +3073,12 @@ describe('HashTreeParser', () => {
|
|
|
2449
3073
|
})
|
|
2450
3074
|
);
|
|
2451
3075
|
|
|
3076
|
+
// Verify behavioral metric was sent for the watchdog expiration
|
|
3077
|
+
assert.calledWith(metricsStub, BEHAVIORAL_METRICS.HASH_TREE_HEARTBEAT_WATCHDOG_EXPIRED, {
|
|
3078
|
+
debugId: 'test',
|
|
3079
|
+
dataSetName: 'main',
|
|
3080
|
+
});
|
|
3081
|
+
|
|
2452
3082
|
// Verify no sync requests were sent for other datasets
|
|
2453
3083
|
assert.neverCalledWith(
|
|
2454
3084
|
webexRequest,
|
|
@@ -2492,6 +3122,12 @@ describe('HashTreeParser', () => {
|
|
|
2492
3122
|
// Advance time past watchdog delay
|
|
2493
3123
|
await clock.tickAsync(heartbeatIntervalMs);
|
|
2494
3124
|
|
|
3125
|
+
// Verify behavioral metric was sent for the watchdog expiration
|
|
3126
|
+
assert.calledWith(metricsStub, BEHAVIORAL_METRICS.HASH_TREE_HEARTBEAT_WATCHDOG_EXPIRED, {
|
|
3127
|
+
debugId: 'test',
|
|
3128
|
+
dataSetName: 'self',
|
|
3129
|
+
});
|
|
3130
|
+
|
|
2495
3131
|
// For leafCount === 1, performSync skips GET hashtree and goes straight to POST sync
|
|
2496
3132
|
assert.neverCalledWith(
|
|
2497
3133
|
webexRequest,
|
|
@@ -2825,7 +3461,77 @@ describe('HashTreeParser', () => {
|
|
|
2825
3461
|
expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
|
|
2826
3462
|
expect(parser.dataSets['atd-active']?.heartbeatWatchdogTimer).to.be.undefined;
|
|
2827
3463
|
});
|
|
3464
|
+
|
|
3465
|
+
it('restarts the watchdog timer after it fires so that future missed heartbeats still trigger syncs', async () => {
|
|
3466
|
+
const parser = createHashTreeParser();
|
|
3467
|
+
const heartbeatIntervalMs = 5000;
|
|
3468
|
+
|
|
3469
|
+
// Send initial heartbeat for 'main'
|
|
3470
|
+
const heartbeatMessage = {
|
|
3471
|
+
dataSets: [
|
|
3472
|
+
{
|
|
3473
|
+
...createDataSet('main', 16, 1100),
|
|
3474
|
+
root: parser.dataSets.main.hashTree.getRootHash(),
|
|
3475
|
+
},
|
|
3476
|
+
],
|
|
3477
|
+
visibleDataSetsUrl,
|
|
3478
|
+
locusUrl,
|
|
3479
|
+
heartbeatIntervalMs,
|
|
3480
|
+
};
|
|
3481
|
+
|
|
3482
|
+
parser.handleMessage(heartbeatMessage, 'initial heartbeat');
|
|
3483
|
+
expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
|
|
3484
|
+
|
|
3485
|
+
// Mock responses for performSync - return null (204/empty body)
|
|
3486
|
+
const mainDataSetUrl = parser.dataSets.main.url;
|
|
3487
|
+
mockGetHashesFromLocusResponse(
|
|
3488
|
+
mainDataSetUrl,
|
|
3489
|
+
new Array(16).fill('00000000000000000000000000000000'),
|
|
3490
|
+
createDataSet('main', 16, 1101)
|
|
3491
|
+
);
|
|
3492
|
+
mockSendSyncRequestResponse(mainDataSetUrl, null);
|
|
3493
|
+
|
|
3494
|
+
// Advance time past heartbeatIntervalMs to fire the watchdog
|
|
3495
|
+
await clock.tickAsync(heartbeatIntervalMs);
|
|
3496
|
+
|
|
3497
|
+
// Verify sync was triggered
|
|
3498
|
+
assert.calledWith(
|
|
3499
|
+
webexRequest,
|
|
3500
|
+
sinon.match({
|
|
3501
|
+
method: 'GET',
|
|
3502
|
+
uri: `${mainDataSetUrl}/hashtree`,
|
|
3503
|
+
})
|
|
3504
|
+
);
|
|
3505
|
+
|
|
3506
|
+
// The watchdog timer should have been restarted after firing
|
|
3507
|
+
expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
|
|
3508
|
+
|
|
3509
|
+
// Reset call history and set up new mock responses for the second sync
|
|
3510
|
+
webexRequest.resetHistory();
|
|
3511
|
+
mockGetHashesFromLocusResponse(
|
|
3512
|
+
mainDataSetUrl,
|
|
3513
|
+
new Array(16).fill('00000000000000000000000000000000'),
|
|
3514
|
+
createDataSet('main', 16, 1102)
|
|
3515
|
+
);
|
|
3516
|
+
mockSendSyncRequestResponse(mainDataSetUrl, null);
|
|
3517
|
+
|
|
3518
|
+
// Advance time again to fire the watchdog a second time
|
|
3519
|
+
await clock.tickAsync(heartbeatIntervalMs);
|
|
3520
|
+
|
|
3521
|
+
// Verify a second sync was triggered
|
|
3522
|
+
assert.calledWith(
|
|
3523
|
+
webexRequest,
|
|
3524
|
+
sinon.match({
|
|
3525
|
+
method: 'GET',
|
|
3526
|
+
uri: `${mainDataSetUrl}/hashtree`,
|
|
3527
|
+
})
|
|
3528
|
+
);
|
|
3529
|
+
|
|
3530
|
+
// And the watchdog should still be running
|
|
3531
|
+
expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
|
|
3532
|
+
});
|
|
2828
3533
|
});
|
|
3534
|
+
|
|
2829
3535
|
});
|
|
2830
3536
|
|
|
2831
3537
|
describe('#callLocusInfoUpdateCallback filtering', () => {
|
|
@@ -2922,7 +3628,7 @@ describe('HashTreeParser', () => {
|
|
|
2922
3628
|
parser.handleMessage(updateMessage, 'update with newer version');
|
|
2923
3629
|
|
|
2924
3630
|
// Callback should be called with the update
|
|
2925
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
3631
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
2926
3632
|
updatedObjects: [
|
|
2927
3633
|
{
|
|
2928
3634
|
htMeta: {
|
|
@@ -2993,7 +3699,7 @@ describe('HashTreeParser', () => {
|
|
|
2993
3699
|
parser.handleMessage(removalMessage, 'removal of non-existent object');
|
|
2994
3700
|
|
|
2995
3701
|
// Callback should be called with the removal
|
|
2996
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
3702
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
2997
3703
|
updatedObjects: [
|
|
2998
3704
|
{
|
|
2999
3705
|
htMeta: {
|
|
@@ -3128,7 +3834,7 @@ describe('HashTreeParser', () => {
|
|
|
3128
3834
|
parser.handleMessage(mixedMessage, 'mixed updates');
|
|
3129
3835
|
|
|
3130
3836
|
// Callback should be called with only the valid updates (participant 1 v110 and participant 3 v10)
|
|
3131
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
3837
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
3132
3838
|
updatedObjects: [
|
|
3133
3839
|
{
|
|
3134
3840
|
htMeta: {
|
|
@@ -3282,6 +3988,9 @@ describe('HashTreeParser', () => {
|
|
|
3282
3988
|
parser.handleMessage(emptyMessage, 'empty elements');
|
|
3283
3989
|
|
|
3284
3990
|
assert.notCalled(callback);
|
|
3991
|
+
assert.calledWith(metricsStub, BEHAVIORAL_METRICS.HASH_TREE_EMPTY_LOCUS_STATE_ELEMENTS, {
|
|
3992
|
+
debugId: 'test',
|
|
3993
|
+
});
|
|
3285
3994
|
});
|
|
3286
3995
|
|
|
3287
3996
|
it('always calls callback for MEETING_ENDED regardless of filtering', () => {
|
|
@@ -3306,9 +4015,7 @@ describe('HashTreeParser', () => {
|
|
|
3306
4015
|
parser.handleMessage(sentinelMessage as any, 'sentinel message');
|
|
3307
4016
|
|
|
3308
4017
|
// Callback should be called with MEETING_ENDED
|
|
3309
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED
|
|
3310
|
-
updatedObjects: undefined,
|
|
3311
|
-
});
|
|
4018
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
|
|
3312
4019
|
});
|
|
3313
4020
|
});
|
|
3314
4021
|
|
|
@@ -3514,7 +4221,7 @@ describe('HashTreeParser', () => {
|
|
|
3514
4221
|
});
|
|
3515
4222
|
});
|
|
3516
4223
|
|
|
3517
|
-
describe('#
|
|
4224
|
+
describe('#resumeFromMessage', () => {
|
|
3518
4225
|
const createResumeMessage = (visibleDataSets?, dataSets?) => ({
|
|
3519
4226
|
locusUrl,
|
|
3520
4227
|
visibleDataSetsUrl,
|
|
@@ -3541,7 +4248,7 @@ describe('HashTreeParser', () => {
|
|
|
3541
4248
|
|
|
3542
4249
|
expect(parser.state).to.equal('stopped');
|
|
3543
4250
|
|
|
3544
|
-
parser.
|
|
4251
|
+
parser.resumeFromMessage(createResumeMessage());
|
|
3545
4252
|
|
|
3546
4253
|
expect(parser.state).to.equal('active');
|
|
3547
4254
|
});
|
|
@@ -3550,7 +4257,7 @@ describe('HashTreeParser', () => {
|
|
|
3550
4257
|
const parser = createHashTreeParser();
|
|
3551
4258
|
parser.stop();
|
|
3552
4259
|
|
|
3553
|
-
parser.
|
|
4260
|
+
parser.resumeFromMessage({
|
|
3554
4261
|
locusUrl,
|
|
3555
4262
|
visibleDataSetsUrl,
|
|
3556
4263
|
dataSets: [createDataSet('main', 16, 2000)],
|
|
@@ -3569,7 +4276,7 @@ describe('HashTreeParser', () => {
|
|
|
3569
4276
|
createDataSet('self', 2, 6000),
|
|
3570
4277
|
];
|
|
3571
4278
|
|
|
3572
|
-
parser.
|
|
4279
|
+
parser.resumeFromMessage(createResumeMessage(undefined, newDataSets));
|
|
3573
4280
|
|
|
3574
4281
|
expect(Object.keys(parser.dataSets)).to.have.lengthOf(2);
|
|
3575
4282
|
expect(parser.dataSets.main.leafCount).to.equal(8);
|
|
@@ -3591,7 +4298,7 @@ describe('HashTreeParser', () => {
|
|
|
3591
4298
|
{name: 'self', url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self'},
|
|
3592
4299
|
];
|
|
3593
4300
|
|
|
3594
|
-
parser.
|
|
4301
|
+
parser.resumeFromMessage(createResumeMessage(visibleDataSets, dataSets));
|
|
3595
4302
|
|
|
3596
4303
|
expect(parser.dataSets.main.hashTree).to.be.instanceOf(HashTree);
|
|
3597
4304
|
expect(parser.dataSets.self.hashTree).to.be.instanceOf(HashTree);
|
|
@@ -3605,7 +4312,7 @@ describe('HashTreeParser', () => {
|
|
|
3605
4312
|
const handleMessageStub = sinon.stub(parser, 'handleMessage');
|
|
3606
4313
|
|
|
3607
4314
|
const message = createResumeMessage();
|
|
3608
|
-
parser.
|
|
4315
|
+
parser.resumeFromMessage(message);
|
|
3609
4316
|
|
|
3610
4317
|
assert.calledOnceWithExactly(handleMessageStub, message, 'on resume');
|
|
3611
4318
|
});
|
|
@@ -3625,7 +4332,7 @@ describe('HashTreeParser', () => {
|
|
|
3625
4332
|
{name: 'atd-unmuted', url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/atd-unmuted'},
|
|
3626
4333
|
];
|
|
3627
4334
|
|
|
3628
|
-
parser.
|
|
4335
|
+
parser.resumeFromMessage(createResumeMessage(visibleDataSets, dataSets));
|
|
3629
4336
|
|
|
3630
4337
|
expect(parser.visibleDataSets.some((vds) => vds.name === 'atd-unmuted')).to.be.false;
|
|
3631
4338
|
expect(parser.visibleDataSets.some((vds) => vds.name === 'main')).to.be.true;
|
|
@@ -3633,6 +4340,67 @@ describe('HashTreeParser', () => {
|
|
|
3633
4340
|
});
|
|
3634
4341
|
});
|
|
3635
4342
|
|
|
4343
|
+
describe('#resumeFromApiResponse', () => {
|
|
4344
|
+
const exampleLocus = {
|
|
4345
|
+
participants: [],
|
|
4346
|
+
} as any;
|
|
4347
|
+
|
|
4348
|
+
it('should set state to active', async () => {
|
|
4349
|
+
const parser = createHashTreeParser();
|
|
4350
|
+
parser.stop();
|
|
4351
|
+
|
|
4352
|
+
expect(parser.state).to.equal('stopped');
|
|
4353
|
+
|
|
4354
|
+
sinon.stub(parser, 'initializeFromGetLociResponse').resolves();
|
|
4355
|
+
|
|
4356
|
+
await parser.resumeFromApiResponse(exampleLocus);
|
|
4357
|
+
|
|
4358
|
+
expect(parser.state).to.equal('active');
|
|
4359
|
+
});
|
|
4360
|
+
|
|
4361
|
+
it('should reset dataSets to empty', async () => {
|
|
4362
|
+
const parser = createHashTreeParser();
|
|
4363
|
+
|
|
4364
|
+
expect(Object.keys(parser.dataSets).length).to.be.greaterThan(0);
|
|
4365
|
+
|
|
4366
|
+
parser.stop();
|
|
4367
|
+
|
|
4368
|
+
sinon.stub(parser, 'initializeFromGetLociResponse').resolves();
|
|
4369
|
+
|
|
4370
|
+
await parser.resumeFromApiResponse(exampleLocus);
|
|
4371
|
+
|
|
4372
|
+
expect(parser.dataSets).to.deep.equal({});
|
|
4373
|
+
});
|
|
4374
|
+
|
|
4375
|
+
it('should call initializeFromGetLociResponse with the provided locus', async () => {
|
|
4376
|
+
const parser = createHashTreeParser();
|
|
4377
|
+
parser.stop();
|
|
4378
|
+
|
|
4379
|
+
const initStub = sinon.stub(parser, 'initializeFromGetLociResponse').resolves();
|
|
4380
|
+
|
|
4381
|
+
await parser.resumeFromApiResponse(exampleLocus);
|
|
4382
|
+
|
|
4383
|
+
assert.calledOnceWithExactly(initStub, exampleLocus);
|
|
4384
|
+
});
|
|
4385
|
+
|
|
4386
|
+
it('should propagate errors from initializeFromGetLociResponse', async () => {
|
|
4387
|
+
const parser = createHashTreeParser();
|
|
4388
|
+
parser.stop();
|
|
4389
|
+
|
|
4390
|
+
const error = new Error('initialization failed');
|
|
4391
|
+
const initStub = sinon.stub(parser, 'initializeFromGetLociResponse').rejects(error);
|
|
4392
|
+
|
|
4393
|
+
let caughtError: Error | undefined;
|
|
4394
|
+
try {
|
|
4395
|
+
await parser.resumeFromApiResponse(exampleLocus);
|
|
4396
|
+
} catch (e) {
|
|
4397
|
+
caughtError = e;
|
|
4398
|
+
}
|
|
4399
|
+
|
|
4400
|
+
expect(caughtError).to.equal(error);
|
|
4401
|
+
});
|
|
4402
|
+
});
|
|
4403
|
+
|
|
3636
4404
|
describe('#handleLocusUpdate when stopped', () => {
|
|
3637
4405
|
it('should return early without processing when parser is stopped', () => {
|
|
3638
4406
|
const parser = createHashTreeParser();
|
|
@@ -3667,4 +4435,595 @@ describe('HashTreeParser', () => {
|
|
|
3667
4435
|
assert.notCalled(callback);
|
|
3668
4436
|
});
|
|
3669
4437
|
});
|
|
4438
|
+
|
|
4439
|
+
describe('#syncAllDatasets', () => {
|
|
4440
|
+
it('should sync all datasets that have hash trees in priority order', async () => {
|
|
4441
|
+
const parser = createHashTreeParser();
|
|
4442
|
+
|
|
4443
|
+
// parser starts with main (leafCount=16) and self (leafCount=1) as visible datasets with hash trees
|
|
4444
|
+
// atd-unmuted has no hash tree (not visible)
|
|
4445
|
+
expect(parser.dataSets.main.hashTree).to.be.instanceOf(HashTree);
|
|
4446
|
+
expect(parser.dataSets.self.hashTree).to.be.instanceOf(HashTree);
|
|
4447
|
+
|
|
4448
|
+
const mainUrl = parser.dataSets.main.url;
|
|
4449
|
+
const selfUrl = parser.dataSets.self.url;
|
|
4450
|
+
|
|
4451
|
+
// Mock GET hashtree for main (leafCount > 1, so it does GET first)
|
|
4452
|
+
mockGetHashesFromLocusResponse(
|
|
4453
|
+
mainUrl,
|
|
4454
|
+
new Array(16).fill(EMPTY_HASH),
|
|
4455
|
+
createDataSet('main', 16, 1100)
|
|
4456
|
+
);
|
|
4457
|
+
|
|
4458
|
+
// Mock POST sync for main - return matching root hash so no further sync needed
|
|
4459
|
+
const mainSyncDataSet = createDataSet('main', 16, 1100);
|
|
4460
|
+
mainSyncDataSet.root = parser.dataSets.main.hashTree.getRootHash();
|
|
4461
|
+
mockSendSyncRequestResponse(mainUrl, {
|
|
4462
|
+
dataSets: [mainSyncDataSet],
|
|
4463
|
+
visibleDataSetsUrl,
|
|
4464
|
+
locusUrl,
|
|
4465
|
+
locusStateElements: [],
|
|
4466
|
+
});
|
|
4467
|
+
|
|
4468
|
+
// Mock POST sync for self (leafCount=1, skips GET hashtree)
|
|
4469
|
+
const selfSyncDataSet = createDataSet('self', 1, 2100);
|
|
4470
|
+
selfSyncDataSet.root = parser.dataSets.self.hashTree.getRootHash();
|
|
4471
|
+
mockSendSyncRequestResponse(selfUrl, {
|
|
4472
|
+
dataSets: [selfSyncDataSet],
|
|
4473
|
+
visibleDataSetsUrl,
|
|
4474
|
+
locusUrl,
|
|
4475
|
+
locusStateElements: [],
|
|
4476
|
+
});
|
|
4477
|
+
|
|
4478
|
+
await parser.syncAllDatasets();
|
|
4479
|
+
|
|
4480
|
+
// Verify GET hashtree was called for main only (not self, because leafCount=1)
|
|
4481
|
+
assert.calledWith(webexRequest, sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}));
|
|
4482
|
+
assert.neverCalledWith(webexRequest, sinon.match({method: 'GET', uri: `${selfUrl}/hashtree`}));
|
|
4483
|
+
|
|
4484
|
+
// Verify POST sync was called for both
|
|
4485
|
+
assert.calledWith(webexRequest, sinon.match({method: 'POST', uri: `${mainUrl}/sync`}));
|
|
4486
|
+
assert.calledWith(webexRequest, sinon.match({method: 'POST', uri: `${selfUrl}/sync`}));
|
|
4487
|
+
|
|
4488
|
+
// Verify main was synced before self (priority order)
|
|
4489
|
+
const mainSyncCallIndex = webexRequest.args.findIndex(
|
|
4490
|
+
(args) => args[0]?.method === 'GET' && args[0]?.uri === `${mainUrl}/hashtree`
|
|
4491
|
+
);
|
|
4492
|
+
const selfSyncCallIndex = webexRequest.args.findIndex(
|
|
4493
|
+
(args) => args[0]?.method === 'POST' && args[0]?.uri === `${selfUrl}/sync`
|
|
4494
|
+
);
|
|
4495
|
+
expect(mainSyncCallIndex).to.be.lessThan(selfSyncCallIndex);
|
|
4496
|
+
|
|
4497
|
+
// Verify isSyncAllInProgress is reset
|
|
4498
|
+
expect(parser.isSyncAllInProgress).to.be.false;
|
|
4499
|
+
});
|
|
4500
|
+
|
|
4501
|
+
it('should return immediately when state is stopped', async () => {
|
|
4502
|
+
const parser = createHashTreeParser();
|
|
4503
|
+
parser.stop();
|
|
4504
|
+
|
|
4505
|
+
await parser.syncAllDatasets();
|
|
4506
|
+
|
|
4507
|
+
// No sync requests should have been made (only the initial sync from constructor)
|
|
4508
|
+
// Reset history to clear constructor calls then verify
|
|
4509
|
+
const callCountBefore = webexRequest.callCount;
|
|
4510
|
+
await parser.syncAllDatasets();
|
|
4511
|
+
assert.equal(webexRequest.callCount, callCountBefore);
|
|
4512
|
+
});
|
|
4513
|
+
|
|
4514
|
+
it('should guard against concurrent calls', async () => {
|
|
4515
|
+
const parser = createHashTreeParser();
|
|
4516
|
+
|
|
4517
|
+
const mainUrl = parser.dataSets.main.url;
|
|
4518
|
+
const selfUrl = parser.dataSets.self.url;
|
|
4519
|
+
|
|
4520
|
+
// Use a deferred promise for the main sync to control timing
|
|
4521
|
+
let resolveMainSync;
|
|
4522
|
+
webexRequest
|
|
4523
|
+
.withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}))
|
|
4524
|
+
.returns(new Promise((resolve) => { resolveMainSync = resolve; }));
|
|
4525
|
+
|
|
4526
|
+
mockSendSyncRequestResponse(mainUrl, {
|
|
4527
|
+
dataSets: [createDataSet('main', 16, 1100)],
|
|
4528
|
+
visibleDataSetsUrl,
|
|
4529
|
+
locusUrl,
|
|
4530
|
+
locusStateElements: [],
|
|
4531
|
+
});
|
|
4532
|
+
|
|
4533
|
+
mockSendSyncRequestResponse(selfUrl, {
|
|
4534
|
+
dataSets: [createDataSet('self', 1, 2100)],
|
|
4535
|
+
visibleDataSetsUrl,
|
|
4536
|
+
locusUrl,
|
|
4537
|
+
locusStateElements: [],
|
|
4538
|
+
});
|
|
4539
|
+
|
|
4540
|
+
// Start first call
|
|
4541
|
+
const promise1 = parser.syncAllDatasets();
|
|
4542
|
+
// Start second call while first is in progress
|
|
4543
|
+
const promise2 = parser.syncAllDatasets();
|
|
4544
|
+
|
|
4545
|
+
// Resolve the pending request
|
|
4546
|
+
resolveMainSync({
|
|
4547
|
+
body: {
|
|
4548
|
+
hashes: new Array(16).fill(EMPTY_HASH),
|
|
4549
|
+
dataSet: createDataSet('main', 16, 1100),
|
|
4550
|
+
},
|
|
4551
|
+
});
|
|
4552
|
+
|
|
4553
|
+
await promise1;
|
|
4554
|
+
await promise2;
|
|
4555
|
+
|
|
4556
|
+
// GET hashtree for main should only be called once (second syncAllDatasets returned immediately)
|
|
4557
|
+
const getHashtreeCalls = webexRequest.args.filter(
|
|
4558
|
+
(args) => args[0]?.method === 'GET' && args[0]?.uri === `${mainUrl}/hashtree`
|
|
4559
|
+
);
|
|
4560
|
+
expect(getHashtreeCalls).to.have.lengthOf(1);
|
|
4561
|
+
});
|
|
4562
|
+
|
|
4563
|
+
it('should skip datasets that do not have a hash tree', async () => {
|
|
4564
|
+
// Create parser with metadata that only has main and self as visible (not atd-unmuted)
|
|
4565
|
+
const metadataWithoutAtd = {
|
|
4566
|
+
...exampleMetadata,
|
|
4567
|
+
visibleDataSets: exampleMetadata.visibleDataSets.filter((ds) => ds.name !== 'atd-unmuted'),
|
|
4568
|
+
};
|
|
4569
|
+
const parser = createHashTreeParser(exampleInitialLocus, metadataWithoutAtd);
|
|
4570
|
+
|
|
4571
|
+
// atd-unmuted is in dataSets but has no hashTree (not visible)
|
|
4572
|
+
expect(parser.dataSets['atd-unmuted']).to.exist;
|
|
4573
|
+
expect(parser.dataSets['atd-unmuted'].hashTree).to.be.undefined;
|
|
4574
|
+
|
|
4575
|
+
const atdUrl = parser.dataSets['atd-unmuted'].url;
|
|
4576
|
+
const mainUrl = parser.dataSets.main.url;
|
|
4577
|
+
const selfUrl = parser.dataSets.self.url;
|
|
4578
|
+
|
|
4579
|
+
mockGetHashesFromLocusResponse(
|
|
4580
|
+
mainUrl,
|
|
4581
|
+
new Array(16).fill(EMPTY_HASH),
|
|
4582
|
+
createDataSet('main', 16, 1100)
|
|
4583
|
+
);
|
|
4584
|
+
|
|
4585
|
+
const mainSyncDs = createDataSet('main', 16, 1100);
|
|
4586
|
+
mainSyncDs.root = parser.dataSets.main.hashTree.getRootHash();
|
|
4587
|
+
mockSendSyncRequestResponse(mainUrl, {
|
|
4588
|
+
dataSets: [mainSyncDs],
|
|
4589
|
+
visibleDataSetsUrl,
|
|
4590
|
+
locusUrl,
|
|
4591
|
+
locusStateElements: [],
|
|
4592
|
+
});
|
|
4593
|
+
|
|
4594
|
+
const selfSyncDs = createDataSet('self', 1, 2100);
|
|
4595
|
+
selfSyncDs.root = parser.dataSets.self.hashTree.getRootHash();
|
|
4596
|
+
mockSendSyncRequestResponse(selfUrl, {
|
|
4597
|
+
dataSets: [selfSyncDs],
|
|
4598
|
+
visibleDataSetsUrl,
|
|
4599
|
+
locusUrl,
|
|
4600
|
+
locusStateElements: [],
|
|
4601
|
+
});
|
|
4602
|
+
|
|
4603
|
+
await parser.syncAllDatasets();
|
|
4604
|
+
|
|
4605
|
+
// No requests should have been made for atd-unmuted
|
|
4606
|
+
assert.neverCalledWith(webexRequest, sinon.match({uri: sinon.match(atdUrl)}));
|
|
4607
|
+
});
|
|
4608
|
+
});
|
|
4609
|
+
|
|
4610
|
+
describe('#handleMessage sync queue', () => {
|
|
4611
|
+
it('should deduplicate: not sync the same dataset twice when enqueued multiple times', async () => {
|
|
4612
|
+
const parser = createHashTreeParser();
|
|
4613
|
+
|
|
4614
|
+
const mainUrl = parser.dataSets.main.url;
|
|
4615
|
+
|
|
4616
|
+
// Setup mocks before triggering syncs
|
|
4617
|
+
mockGetHashesFromLocusResponse(
|
|
4618
|
+
mainUrl,
|
|
4619
|
+
new Array(16).fill(EMPTY_HASH),
|
|
4620
|
+
createDataSet('main', 16, 1101)
|
|
4621
|
+
);
|
|
4622
|
+
|
|
4623
|
+
const mainSyncDs = createDataSet('main', 16, 1101);
|
|
4624
|
+
mainSyncDs.root = parser.dataSets.main.hashTree.getRootHash();
|
|
4625
|
+
mockSendSyncRequestResponse(mainUrl, {
|
|
4626
|
+
dataSets: [mainSyncDs],
|
|
4627
|
+
visibleDataSetsUrl,
|
|
4628
|
+
locusUrl,
|
|
4629
|
+
locusStateElements: [],
|
|
4630
|
+
});
|
|
4631
|
+
|
|
4632
|
+
// Send two heartbeat messages (no locusStateElements) with different root hashes for main
|
|
4633
|
+
parser.handleMessage(createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'), 'first');
|
|
4634
|
+
parser.handleMessage(createHeartbeatMessage('main', 16, 1101, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa2'), 'second');
|
|
4635
|
+
|
|
4636
|
+
// The second call resets the timer. After 1000ms, only one sync fires.
|
|
4637
|
+
await clock.tickAsync(1000);
|
|
4638
|
+
|
|
4639
|
+
// Only one GET hashtree call should have been made for main
|
|
4640
|
+
const getHashtreeCalls = webexRequest.args.filter(
|
|
4641
|
+
(args) => args[0]?.method === 'GET' && args[0]?.uri === `${mainUrl}/hashtree`
|
|
4642
|
+
);
|
|
4643
|
+
expect(getHashtreeCalls).to.have.lengthOf(1);
|
|
4644
|
+
});
|
|
4645
|
+
|
|
4646
|
+
it('should stop processing the sync queue when parser is stopped mid-queue', async () => {
|
|
4647
|
+
const parser = createHashTreeParser();
|
|
4648
|
+
|
|
4649
|
+
const mainUrl = parser.dataSets.main.url;
|
|
4650
|
+
const selfUrl = parser.dataSets.self.url;
|
|
4651
|
+
|
|
4652
|
+
// Mock main GET hashtree with a deferred promise so we can control when it resolves
|
|
4653
|
+
let resolveMainHashtree;
|
|
4654
|
+
webexRequest
|
|
4655
|
+
.withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}))
|
|
4656
|
+
.callsFake(() => new Promise((resolve) => { resolveMainHashtree = resolve; }));
|
|
4657
|
+
|
|
4658
|
+
// Send a heartbeat message that triggers sync timers for both main and self
|
|
4659
|
+
parser.handleMessage(
|
|
4660
|
+
createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
|
|
4661
|
+
'trigger main sync'
|
|
4662
|
+
);
|
|
4663
|
+
parser.handleMessage(
|
|
4664
|
+
createHeartbeatMessage('self', 1, 2100, 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1'),
|
|
4665
|
+
'trigger self sync'
|
|
4666
|
+
);
|
|
4667
|
+
|
|
4668
|
+
// Fire the timers - main sync starts (calls GET hashtree, which blocks)
|
|
4669
|
+
await clock.tickAsync(1000);
|
|
4670
|
+
|
|
4671
|
+
// Stop the parser while main sync is in progress
|
|
4672
|
+
parser.stop();
|
|
4673
|
+
|
|
4674
|
+
// Resolve the pending main GET request
|
|
4675
|
+
resolveMainHashtree({
|
|
4676
|
+
body: {
|
|
4677
|
+
hashes: new Array(16).fill(EMPTY_HASH),
|
|
4678
|
+
dataSet: createDataSet('main', 16, 1100),
|
|
4679
|
+
},
|
|
4680
|
+
});
|
|
4681
|
+
|
|
4682
|
+
await clock.tickAsync(0);
|
|
4683
|
+
|
|
4684
|
+
// Self sync should NOT have been triggered because parser was stopped
|
|
4685
|
+
assert.neverCalledWith(webexRequest, sinon.match({method: 'POST', uri: `${selfUrl}/sync`}));
|
|
4686
|
+
assert.neverCalledWith(webexRequest, sinon.match({method: 'GET', uri: `${selfUrl}/hashtree`}));
|
|
4687
|
+
});
|
|
4688
|
+
});
|
|
4689
|
+
|
|
4690
|
+
describe('#stop sync queue', () => {
|
|
4691
|
+
it('should clear the syncQueue when stopped so remaining queued items are not processed', async () => {
|
|
4692
|
+
const parser = createHashTreeParser();
|
|
4693
|
+
|
|
4694
|
+
const mainUrl = parser.dataSets.main.url;
|
|
4695
|
+
const selfUrl = parser.dataSets.self.url;
|
|
4696
|
+
|
|
4697
|
+
// Mock main GET hashtree with a deferred promise so we can control when it resolves
|
|
4698
|
+
let resolveMainHashtree;
|
|
4699
|
+
webexRequest
|
|
4700
|
+
.withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}))
|
|
4701
|
+
.callsFake(() => new Promise((resolve) => { resolveMainHashtree = resolve; }));
|
|
4702
|
+
|
|
4703
|
+
// Enqueue syncs for both main and self by sending heartbeat messages
|
|
4704
|
+
parser.handleMessage(
|
|
4705
|
+
createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
|
|
4706
|
+
'trigger main sync'
|
|
4707
|
+
);
|
|
4708
|
+
parser.handleMessage(
|
|
4709
|
+
createHeartbeatMessage('self', 1, 2100, 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1'),
|
|
4710
|
+
'trigger self sync'
|
|
4711
|
+
);
|
|
4712
|
+
|
|
4713
|
+
// Fire the timers - main sync starts and blocks on GET hashtree
|
|
4714
|
+
await clock.tickAsync(1000);
|
|
4715
|
+
|
|
4716
|
+
// Verify that self is still in the queue (main is being processed, self is waiting)
|
|
4717
|
+
// Now stop the parser - this should clear the syncQueue
|
|
4718
|
+
parser.stop();
|
|
4719
|
+
|
|
4720
|
+
// Resolve the pending main GET request so the in-flight sync can finish
|
|
4721
|
+
resolveMainHashtree({
|
|
4722
|
+
body: {
|
|
4723
|
+
hashes: new Array(16).fill(EMPTY_HASH),
|
|
4724
|
+
dataSet: createDataSet('main', 16, 1100),
|
|
4725
|
+
},
|
|
4726
|
+
});
|
|
4727
|
+
|
|
4728
|
+
await clock.tickAsync(0);
|
|
4729
|
+
|
|
4730
|
+
// Self should never have been synced because stop() cleared the queue
|
|
4731
|
+
const selfGetCalls = webexRequest.args.filter(
|
|
4732
|
+
(args) => args[0]?.method === 'GET' && args[0]?.uri === `${selfUrl}/hashtree`
|
|
4733
|
+
);
|
|
4734
|
+
expect(selfGetCalls).to.have.lengthOf(0);
|
|
4735
|
+
});
|
|
4736
|
+
});
|
|
4737
|
+
|
|
4738
|
+
describe('#performSync abort controller', () => {
|
|
4739
|
+
it('should reuse an existing syncAbortController if one is already set on the dataset', async () => {
|
|
4740
|
+
const parser = createHashTreeParser();
|
|
4741
|
+
const mainUrl = parser.dataSets.main.url;
|
|
4742
|
+
|
|
4743
|
+
// Pre-set an AbortController on the dataset before sync starts
|
|
4744
|
+
const existingController = new AbortController();
|
|
4745
|
+
parser.dataSets.main.syncAbortController = existingController;
|
|
4746
|
+
|
|
4747
|
+
// Use a deferred promise for GET hashtree so we can inspect the controller mid-sync
|
|
4748
|
+
let resolveGetHashtree;
|
|
4749
|
+
webexRequest.withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`})).callsFake(
|
|
4750
|
+
() =>
|
|
4751
|
+
new Promise((resolve) => {
|
|
4752
|
+
resolveGetHashtree = resolve;
|
|
4753
|
+
})
|
|
4754
|
+
);
|
|
4755
|
+
|
|
4756
|
+
// Trigger sync for main
|
|
4757
|
+
parser.handleMessage(
|
|
4758
|
+
createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
|
|
4759
|
+
'trigger main sync'
|
|
4760
|
+
);
|
|
4761
|
+
|
|
4762
|
+
await clock.tickAsync(1000);
|
|
4763
|
+
|
|
4764
|
+
// While sync is in-flight, verify the controller is the same one we pre-set
|
|
4765
|
+
expect(parser.dataSets.main.syncAbortController).to.equal(existingController);
|
|
4766
|
+
|
|
4767
|
+
// Resolve GET hashtree with matching hashes (no sync needed)
|
|
4768
|
+
resolveGetHashtree({body: {}});
|
|
4769
|
+
await testUtils.flushPromises();
|
|
4770
|
+
|
|
4771
|
+
// After sync completes, syncAbortController is cleared in finally
|
|
4772
|
+
expect(parser.dataSets.main.syncAbortController).to.be.undefined;
|
|
4773
|
+
});
|
|
4774
|
+
|
|
4775
|
+
it('should abort the sync before /sync request when the controller is aborted during getHashesFromLocus', async () => {
|
|
4776
|
+
const parser = createHashTreeParser();
|
|
4777
|
+
const mainUrl = parser.dataSets.main.url;
|
|
4778
|
+
|
|
4779
|
+
// Use a deferred promise for GET hashtree so we can abort while it's pending
|
|
4780
|
+
let resolveGetHashtree;
|
|
4781
|
+
webexRequest.withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`})).callsFake(
|
|
4782
|
+
() =>
|
|
4783
|
+
new Promise((resolve) => {
|
|
4784
|
+
resolveGetHashtree = resolve;
|
|
4785
|
+
})
|
|
4786
|
+
);
|
|
4787
|
+
|
|
4788
|
+
// Mock POST sync - should NOT be called if abort works
|
|
4789
|
+
mockSendSyncRequestResponse(mainUrl, null);
|
|
4790
|
+
|
|
4791
|
+
// Trigger sync for main via heartbeat with mismatched root hash
|
|
4792
|
+
parser.handleMessage(
|
|
4793
|
+
createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
|
|
4794
|
+
'trigger main sync'
|
|
4795
|
+
);
|
|
4796
|
+
|
|
4797
|
+
// Fire the timer to start the sync
|
|
4798
|
+
await clock.tickAsync(1000);
|
|
4799
|
+
|
|
4800
|
+
// Now abort the controller while getHashesFromLocus is pending
|
|
4801
|
+
expect(parser.dataSets.main.syncAbortController).to.not.be.undefined;
|
|
4802
|
+
parser.dataSets.main.syncAbortController.abort();
|
|
4803
|
+
|
|
4804
|
+
// Resolve GET hashtree with mismatched hashes so the code would normally proceed to /sync
|
|
4805
|
+
resolveGetHashtree({
|
|
4806
|
+
body: {
|
|
4807
|
+
hashes: new Array(16).fill('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
|
|
4808
|
+
dataSet: createDataSet('main', 16, 1100),
|
|
4809
|
+
},
|
|
4810
|
+
});
|
|
4811
|
+
|
|
4812
|
+
await testUtils.flushPromises();
|
|
4813
|
+
|
|
4814
|
+
// POST sync should NOT have been called because the controller was aborted
|
|
4815
|
+
assert.neverCalledWith(webexRequest, sinon.match({method: 'POST', uri: `${mainUrl}/sync`}));
|
|
4816
|
+
});
|
|
4817
|
+
|
|
4818
|
+
it('should abort the sync before /sync request when the controller is aborted for leafCount === 1 datasets', async () => {
|
|
4819
|
+
const parser = createHashTreeParser();
|
|
4820
|
+
const selfUrl = parser.dataSets.self.url;
|
|
4821
|
+
|
|
4822
|
+
// Pre-set an already-aborted controller so performSync picks it up via ??
|
|
4823
|
+
const abortedController = new AbortController();
|
|
4824
|
+
abortedController.abort();
|
|
4825
|
+
parser.dataSets.self.syncAbortController = abortedController;
|
|
4826
|
+
|
|
4827
|
+
// Mock POST sync - should NOT be called
|
|
4828
|
+
mockSendSyncRequestResponse(selfUrl, null);
|
|
4829
|
+
|
|
4830
|
+
// Trigger sync for self via heartbeat with mismatched root hash
|
|
4831
|
+
parser.handleMessage(
|
|
4832
|
+
{
|
|
4833
|
+
dataSets: [
|
|
4834
|
+
{
|
|
4835
|
+
...createDataSet('self', 1, 2100),
|
|
4836
|
+
url: parser.dataSets.self.url,
|
|
4837
|
+
root: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1',
|
|
4838
|
+
},
|
|
4839
|
+
],
|
|
4840
|
+
visibleDataSetsUrl,
|
|
4841
|
+
locusUrl,
|
|
4842
|
+
},
|
|
4843
|
+
'trigger self sync'
|
|
4844
|
+
);
|
|
4845
|
+
|
|
4846
|
+
// Fire the timer to start the sync
|
|
4847
|
+
await clock.tickAsync(1000);
|
|
4848
|
+
|
|
4849
|
+
// GET hashtree should NOT have been called (leafCount === 1 skips it)
|
|
4850
|
+
assert.neverCalledWith(webexRequest, sinon.match({method: 'GET', uri: `${selfUrl}/hashtree`}));
|
|
4851
|
+
|
|
4852
|
+
// POST sync should NOT have been called because the controller was already aborted
|
|
4853
|
+
assert.neverCalledWith(webexRequest, sinon.match({method: 'POST', uri: `${selfUrl}/sync`}));
|
|
4854
|
+
});
|
|
4855
|
+
|
|
4856
|
+
it('should unconditionally clear syncAbortController in the finally block', async () => {
|
|
4857
|
+
const parser = createHashTreeParser();
|
|
4858
|
+
const mainUrl = parser.dataSets.main.url;
|
|
4859
|
+
|
|
4860
|
+
// Mock GET hashtree to return matching hashes (early return, no sync needed)
|
|
4861
|
+
webexRequest
|
|
4862
|
+
.withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}))
|
|
4863
|
+
.resolves({body: {}});
|
|
4864
|
+
|
|
4865
|
+
// Trigger sync for main
|
|
4866
|
+
parser.handleMessage(
|
|
4867
|
+
createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
|
|
4868
|
+
'trigger main sync'
|
|
4869
|
+
);
|
|
4870
|
+
|
|
4871
|
+
await clock.tickAsync(1000);
|
|
4872
|
+
|
|
4873
|
+
// After sync completes (even via early return), syncAbortController should be cleared
|
|
4874
|
+
expect(parser.dataSets.main.syncAbortController).to.be.undefined;
|
|
4875
|
+
});
|
|
4876
|
+
|
|
4877
|
+
it('should unconditionally clear syncAbortController even when sync throws an error', async () => {
|
|
4878
|
+
const parser = createHashTreeParser();
|
|
4879
|
+
const mainUrl = parser.dataSets.main.url;
|
|
4880
|
+
|
|
4881
|
+
// Mock GET hashtree to reject with a non-409 error
|
|
4882
|
+
webexRequest
|
|
4883
|
+
.withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}))
|
|
4884
|
+
.rejects({statusCode: 500, message: 'Internal Server Error'});
|
|
4885
|
+
|
|
4886
|
+
// Trigger sync for main
|
|
4887
|
+
parser.handleMessage(
|
|
4888
|
+
createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
|
|
4889
|
+
'trigger main sync'
|
|
4890
|
+
);
|
|
4891
|
+
|
|
4892
|
+
await clock.tickAsync(1000);
|
|
4893
|
+
|
|
4894
|
+
// After sync completes with error, syncAbortController should still be cleared
|
|
4895
|
+
expect(parser.dataSets.main.syncAbortController).to.be.undefined;
|
|
4896
|
+
});
|
|
4897
|
+
|
|
4898
|
+
it('should reuse a pre-existing abort controller and respect its aborted state', async () => {
|
|
4899
|
+
const parser = createHashTreeParser();
|
|
4900
|
+
const mainUrl = parser.dataSets.main.url;
|
|
4901
|
+
|
|
4902
|
+
// Pre-set an AbortController and abort it before sync starts
|
|
4903
|
+
const preAbortedController = new AbortController();
|
|
4904
|
+
preAbortedController.abort();
|
|
4905
|
+
parser.dataSets.main.syncAbortController = preAbortedController;
|
|
4906
|
+
|
|
4907
|
+
// Mock GET hashtree to return mismatched hashes
|
|
4908
|
+
mockGetHashesFromLocusResponse(
|
|
4909
|
+
mainUrl,
|
|
4910
|
+
new Array(16).fill('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
|
|
4911
|
+
createDataSet('main', 16, 1100)
|
|
4912
|
+
);
|
|
4913
|
+
|
|
4914
|
+
// Mock POST sync - should NOT be called
|
|
4915
|
+
mockSendSyncRequestResponse(mainUrl, null);
|
|
4916
|
+
|
|
4917
|
+
// Trigger sync for main
|
|
4918
|
+
parser.handleMessage(
|
|
4919
|
+
createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
|
|
4920
|
+
'trigger main sync'
|
|
4921
|
+
);
|
|
4922
|
+
|
|
4923
|
+
await clock.tickAsync(1000);
|
|
4924
|
+
|
|
4925
|
+
// POST sync should NOT have been called because the reused controller was already aborted
|
|
4926
|
+
assert.neverCalledWith(webexRequest, sinon.match({method: 'POST', uri: `${mainUrl}/sync`}));
|
|
4927
|
+
|
|
4928
|
+
// syncAbortController should be cleaned up
|
|
4929
|
+
expect(parser.dataSets.main.syncAbortController).to.be.undefined;
|
|
4930
|
+
});
|
|
4931
|
+
|
|
4932
|
+
it('should allow cancelPendingSyncsForDataSets to abort an in-flight sync via the shared controller', async () => {
|
|
4933
|
+
const parser = createHashTreeParser();
|
|
4934
|
+
const mainUrl = parser.dataSets.main.url;
|
|
4935
|
+
|
|
4936
|
+
// Use a deferred promise for GET hashtree
|
|
4937
|
+
let resolveGetHashtree;
|
|
4938
|
+
webexRequest.withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`})).callsFake(
|
|
4939
|
+
() =>
|
|
4940
|
+
new Promise((resolve) => {
|
|
4941
|
+
resolveGetHashtree = resolve;
|
|
4942
|
+
})
|
|
4943
|
+
);
|
|
4944
|
+
|
|
4945
|
+
mockSendSyncRequestResponse(mainUrl, null);
|
|
4946
|
+
|
|
4947
|
+
// Trigger sync for main
|
|
4948
|
+
parser.handleMessage(
|
|
4949
|
+
createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
|
|
4950
|
+
'trigger main sync'
|
|
4951
|
+
);
|
|
4952
|
+
|
|
4953
|
+
// Fire the timer to start sync
|
|
4954
|
+
await clock.tickAsync(1000);
|
|
4955
|
+
|
|
4956
|
+
// Verify controller is set
|
|
4957
|
+
expect(parser.dataSets.main.syncAbortController).to.not.be.undefined;
|
|
4958
|
+
|
|
4959
|
+
// Simulate a new heartbeat arriving that cancels the in-flight sync
|
|
4960
|
+
// (this is what happens in production via parseMessage -> cancelPendingSyncsForDataSets)
|
|
4961
|
+
parser.handleMessage(
|
|
4962
|
+
{
|
|
4963
|
+
dataSets: [
|
|
4964
|
+
{
|
|
4965
|
+
...createDataSet('main', 16, 1101),
|
|
4966
|
+
root: parser.dataSets.main.hashTree.getRootHash(), // matching hash so no new sync
|
|
4967
|
+
},
|
|
4968
|
+
],
|
|
4969
|
+
visibleDataSetsUrl,
|
|
4970
|
+
locusUrl,
|
|
4971
|
+
},
|
|
4972
|
+
'new heartbeat cancels sync'
|
|
4973
|
+
);
|
|
4974
|
+
|
|
4975
|
+
// Resolve the pending GET hashtree with mismatched hashes
|
|
4976
|
+
resolveGetHashtree({
|
|
4977
|
+
body: {
|
|
4978
|
+
hashes: new Array(16).fill('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
|
|
4979
|
+
dataSet: createDataSet('main', 16, 1101),
|
|
4980
|
+
},
|
|
4981
|
+
});
|
|
4982
|
+
|
|
4983
|
+
await testUtils.flushPromises();
|
|
4984
|
+
|
|
4985
|
+
// POST sync should NOT have been called because cancelPendingSyncsForDataSets aborted the controller
|
|
4986
|
+
assert.neverCalledWith(webexRequest, sinon.match({method: 'POST', uri: `${mainUrl}/sync`}));
|
|
4987
|
+
});
|
|
4988
|
+
});
|
|
4989
|
+
|
|
4990
|
+
describe('#cleanUp', () => {
|
|
4991
|
+
it('should stop the parser, clear all timers and clear all dataSets', () => {
|
|
4992
|
+
const parser = createHashTreeParser();
|
|
4993
|
+
|
|
4994
|
+
// Send a message to set up sync timers via runSyncAlgorithm
|
|
4995
|
+
const message = {
|
|
4996
|
+
dataSets: [
|
|
4997
|
+
{
|
|
4998
|
+
...createDataSet('main', 16, 1100),
|
|
4999
|
+
root: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1',
|
|
5000
|
+
},
|
|
5001
|
+
],
|
|
5002
|
+
visibleDataSetsUrl,
|
|
5003
|
+
locusUrl,
|
|
5004
|
+
heartbeatIntervalMs: 5000,
|
|
5005
|
+
locusStateElements: [
|
|
5006
|
+
{
|
|
5007
|
+
htMeta: {
|
|
5008
|
+
elementId: {type: 'locus' as const, id: 0, version: 201},
|
|
5009
|
+
dataSetNames: ['main'],
|
|
5010
|
+
},
|
|
5011
|
+
data: {someData: 'value'},
|
|
5012
|
+
},
|
|
5013
|
+
],
|
|
5014
|
+
};
|
|
5015
|
+
|
|
5016
|
+
parser.handleMessage(message, 'setup timers');
|
|
5017
|
+
|
|
5018
|
+
// Verify timers were set by handleMessage
|
|
5019
|
+
expect(parser.dataSets.main.timer).to.not.be.undefined;
|
|
5020
|
+
expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
|
|
5021
|
+
|
|
5022
|
+
parser.cleanUp();
|
|
5023
|
+
|
|
5024
|
+
expect(parser.state).to.equal('stopped');
|
|
5025
|
+
expect(parser.visibleDataSets).to.deep.equal([]);
|
|
5026
|
+
expect(parser.dataSets).to.deep.equal({});
|
|
5027
|
+
});
|
|
5028
|
+
});
|
|
3670
5029
|
});
|