@webex/plugin-meetings 3.12.0-next.5 → 3.12.0-next.51
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 +6 -2
- package/dist/breakouts/breakout.js.map +1 -1
- package/dist/breakouts/index.js +1 -1
- package/dist/config.js +1 -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 +646 -371
- 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 -86
- 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/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 +842 -521
- 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 +205 -77
- 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 +67 -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 +2 -1
- package/dist/metrics/constants.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 +1 -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 +83 -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 +70 -1
- package/dist/types/meeting/util.d.ts +8 -0
- package/dist/types/meetings/index.d.ts +20 -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 +1 -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 +2 -1
- package/src/config.ts +1 -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 +362 -174
- 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 -93
- package/src/locus-info/types.ts +25 -1
- package/src/media/properties.ts +1 -0
- package/src/meeting/in-meeting-actions.ts +4 -0
- package/src/meeting/index.ts +315 -26
- package/src/meeting/util.ts +20 -2
- package/src/meetings/index.ts +109 -43
- package/src/meetings/meetings.types.ts +19 -0
- package/src/meetings/request.ts +43 -0
- package/src/meetings/util.ts +80 -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 +1 -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 +7 -3
- 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 +1341 -140
- 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/meeting/in-meeting-actions.ts +2 -0
- package/test/unit/spec/meeting/index.js +836 -41
- 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 +309 -10
- package/test/unit/spec/meetings/request.js +141 -0
- package/test/unit/spec/meetings/utils.js +161 -0
- package/test/unit/spec/member/index.js +7 -0
- package/test/unit/spec/member/util.js +24 -0
- package/test/unit/spec/recording-controller/index.js +9 -8
- package/test/unit/spec/webinar/index.ts +141 -16
|
@@ -1,12 +1,16 @@
|
|
|
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 { some } from 'lodash';
|
|
12
|
+
import Metrics from '@webex/plugin-meetings/src/metrics';
|
|
13
|
+
import BEHAVIORAL_METRICS from '@webex/plugin-meetings/src/metrics/constants';
|
|
10
14
|
|
|
11
15
|
const visibleDataSetsUrl = 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/visibleDataSets';
|
|
12
16
|
|
|
@@ -151,16 +155,19 @@ describe('HashTreeParser', () => {
|
|
|
151
155
|
let webexRequest: sinon.SinonStub;
|
|
152
156
|
let callback: sinon.SinonStub;
|
|
153
157
|
let mathRandomStub: sinon.SinonStub;
|
|
158
|
+
let metricsStub: sinon.SinonStub;
|
|
154
159
|
|
|
155
160
|
beforeEach(() => {
|
|
156
161
|
clock = sinon.useFakeTimers();
|
|
157
162
|
webexRequest = sinon.stub();
|
|
158
163
|
callback = sinon.stub();
|
|
159
164
|
mathRandomStub = sinon.stub(Math, 'random').returns(0);
|
|
165
|
+
metricsStub = sinon.stub(Metrics, 'sendBehavioralMetric');
|
|
160
166
|
});
|
|
161
167
|
afterEach(() => {
|
|
162
168
|
clock.restore();
|
|
163
169
|
mathRandomStub.restore();
|
|
170
|
+
metricsStub.restore();
|
|
164
171
|
});
|
|
165
172
|
|
|
166
173
|
// Helper to create a HashTreeParser instance with common defaults
|
|
@@ -553,7 +560,7 @@ describe('HashTreeParser', () => {
|
|
|
553
560
|
);
|
|
554
561
|
|
|
555
562
|
// Verify callback was called with OBJECTS_UPDATED and correct updatedObjects list
|
|
556
|
-
assert.calledWith(callback, LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
563
|
+
assert.calledWith(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
557
564
|
updatedObjects: [
|
|
558
565
|
{
|
|
559
566
|
htMeta: {
|
|
@@ -566,6 +573,11 @@ describe('HashTreeParser', () => {
|
|
|
566
573
|
},
|
|
567
574
|
data: {info: {id: 'some-fake-locus-info'}},
|
|
568
575
|
},
|
|
576
|
+
],
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
assert.calledWith(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
580
|
+
updatedObjects: [
|
|
569
581
|
{
|
|
570
582
|
htMeta: {
|
|
571
583
|
elementId: {
|
|
@@ -596,6 +608,67 @@ describe('HashTreeParser', () => {
|
|
|
596
608
|
});
|
|
597
609
|
});
|
|
598
610
|
|
|
611
|
+
it('initializes "main" before "self" regardless of order from Locus', async () => {
|
|
612
|
+
const parser = createHashTreeParser({dataSets: [], locus: null}, null);
|
|
613
|
+
|
|
614
|
+
// Locus returns datasets in non-priority order: atd-active, main, self
|
|
615
|
+
const atdActiveDataSet = createDataSet('atd-active', 4, 500);
|
|
616
|
+
const mainDataSet = createDataSet('main', 16, 1100);
|
|
617
|
+
const selfDataSet = createDataSet('self', 1, 2100);
|
|
618
|
+
|
|
619
|
+
mockGetAllDataSetsMetadata(webexRequest, visibleDataSetsUrl, [
|
|
620
|
+
atdActiveDataSet,
|
|
621
|
+
mainDataSet,
|
|
622
|
+
selfDataSet,
|
|
623
|
+
]);
|
|
624
|
+
|
|
625
|
+
mockSyncRequest(webexRequest, selfDataSet.url);
|
|
626
|
+
mockSyncRequest(webexRequest, mainDataSet.url);
|
|
627
|
+
mockSyncRequest(webexRequest, atdActiveDataSet.url);
|
|
628
|
+
|
|
629
|
+
await parser.initializeFromMessage({
|
|
630
|
+
dataSets: [],
|
|
631
|
+
visibleDataSetsUrl,
|
|
632
|
+
locusUrl,
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
// Verify sync requests were sent in priority order: main, self, then atd-active
|
|
636
|
+
const syncCalls = webexRequest
|
|
637
|
+
.getCalls()
|
|
638
|
+
.filter((call) => call.args[0]?.method === 'POST' && call.args[0]?.uri?.endsWith('/sync'));
|
|
639
|
+
|
|
640
|
+
expect(syncCalls).to.have.lengthOf(3);
|
|
641
|
+
expect(syncCalls[0].args[0].uri).to.equal(`${mainDataSet.url}/sync`);
|
|
642
|
+
expect(syncCalls[1].args[0].uri).to.equal(`${selfDataSet.url}/sync`);
|
|
643
|
+
expect(syncCalls[2].args[0].uri).to.equal(`${atdActiveDataSet.url}/sync`);
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it('sends leafCount=1 with a single empty leaf for initialization sync, regardless of actual dataset leafCount', async () => {
|
|
647
|
+
const parser = createHashTreeParser({dataSets: [], locus: null}, null);
|
|
648
|
+
|
|
649
|
+
// Use a dataset with leafCount=16 to verify the initialization sync always uses leafCount=1
|
|
650
|
+
const mainDataSet = createDataSet('main', 16, 1100);
|
|
651
|
+
|
|
652
|
+
mockGetAllDataSetsMetadata(webexRequest, visibleDataSetsUrl, [mainDataSet]);
|
|
653
|
+
mockSyncRequest(webexRequest, mainDataSet.url);
|
|
654
|
+
|
|
655
|
+
await parser.initializeFromMessage({
|
|
656
|
+
dataSets: [],
|
|
657
|
+
visibleDataSetsUrl,
|
|
658
|
+
locusUrl,
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
assert.calledWith(webexRequest, {
|
|
662
|
+
method: 'POST',
|
|
663
|
+
uri: `${mainDataSet.url}/sync`,
|
|
664
|
+
qs: {rootHash: sinon.match.string},
|
|
665
|
+
body: {
|
|
666
|
+
leafCount: 1,
|
|
667
|
+
leafDataEntries: [{leafIndex: 0, elementIds: []}],
|
|
668
|
+
},
|
|
669
|
+
});
|
|
670
|
+
});
|
|
671
|
+
|
|
599
672
|
it('handles sync response that has locusStateElements undefined', async () => {
|
|
600
673
|
const minimalInitialLocus = {
|
|
601
674
|
dataSets: [],
|
|
@@ -636,8 +709,11 @@ describe('HashTreeParser', () => {
|
|
|
636
709
|
assert.notCalled(callback);
|
|
637
710
|
});
|
|
638
711
|
|
|
639
|
-
[
|
|
640
|
-
|
|
712
|
+
[
|
|
713
|
+
{errorCode: 404, expectedError: LocusNotFoundError},
|
|
714
|
+
{errorCode: 409, expectedError: MeetingEndedError},
|
|
715
|
+
].forEach(({errorCode, expectedError}) => {
|
|
716
|
+
it(`throws ${expectedError.name} if getting visible datasets returns ${errorCode}`, async () => {
|
|
641
717
|
const minimalInitialLocus = {
|
|
642
718
|
dataSets: [],
|
|
643
719
|
locus: null,
|
|
@@ -660,7 +736,6 @@ describe('HashTreeParser', () => {
|
|
|
660
736
|
)
|
|
661
737
|
.rejects(error);
|
|
662
738
|
|
|
663
|
-
// initializeFromMessage should throw MeetingEndedError
|
|
664
739
|
let thrownError;
|
|
665
740
|
try {
|
|
666
741
|
await parser.initializeFromMessage({
|
|
@@ -672,7 +747,7 @@ describe('HashTreeParser', () => {
|
|
|
672
747
|
thrownError = e;
|
|
673
748
|
}
|
|
674
749
|
|
|
675
|
-
expect(thrownError).to.be.instanceOf(
|
|
750
|
+
expect(thrownError).to.be.instanceOf(expectedError);
|
|
676
751
|
});
|
|
677
752
|
});
|
|
678
753
|
});
|
|
@@ -788,7 +863,7 @@ describe('HashTreeParser', () => {
|
|
|
788
863
|
expect(parser.dataSets.self.version).to.equal(2100);
|
|
789
864
|
expect(parser.dataSets['atd-unmuted'].version).to.equal(3100);
|
|
790
865
|
|
|
791
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
866
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
792
867
|
updatedObjects: [
|
|
793
868
|
{
|
|
794
869
|
htMeta: {
|
|
@@ -861,6 +936,116 @@ describe('HashTreeParser', () => {
|
|
|
861
936
|
});
|
|
862
937
|
});
|
|
863
938
|
|
|
939
|
+
it('handles updates to control entries correctly', () => {
|
|
940
|
+
const parser = createHashTreeParser();
|
|
941
|
+
|
|
942
|
+
const mainPutItemsSpy = sinon.spy(parser.dataSets.main.hashTree, 'putItems');
|
|
943
|
+
|
|
944
|
+
// Create a locus update with new htMeta information for some things
|
|
945
|
+
const locusUpdate = {
|
|
946
|
+
dataSets: [
|
|
947
|
+
createDataSet('main', 16, 1100),
|
|
948
|
+
],
|
|
949
|
+
locus: {
|
|
950
|
+
url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f',
|
|
951
|
+
htMeta: {
|
|
952
|
+
elementId: {
|
|
953
|
+
type: 'locus',
|
|
954
|
+
id: 0,
|
|
955
|
+
version: 200, // same version
|
|
956
|
+
},
|
|
957
|
+
dataSetNames: ['main'],
|
|
958
|
+
},
|
|
959
|
+
participants: [],
|
|
960
|
+
controls: {
|
|
961
|
+
lock: {
|
|
962
|
+
locked: true,
|
|
963
|
+
htMeta: {
|
|
964
|
+
elementId: {
|
|
965
|
+
type: 'ControlEntry',
|
|
966
|
+
id: 10100,
|
|
967
|
+
version: 100,
|
|
968
|
+
},
|
|
969
|
+
dataSetNames: ['main'],
|
|
970
|
+
},
|
|
971
|
+
},
|
|
972
|
+
stream: {
|
|
973
|
+
streaming: true,
|
|
974
|
+
htMeta: {
|
|
975
|
+
elementId: {
|
|
976
|
+
type: 'ControlEntry',
|
|
977
|
+
id: 10101,
|
|
978
|
+
version: 100,
|
|
979
|
+
},
|
|
980
|
+
dataSetNames: ['main'],
|
|
981
|
+
},
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
},
|
|
985
|
+
};
|
|
986
|
+
|
|
987
|
+
// Call handleLocusUpdate
|
|
988
|
+
parser.handleLocusUpdate(locusUpdate);
|
|
989
|
+
|
|
990
|
+
// Verify putItems was called on main hash tree with correct data
|
|
991
|
+
assert.calledOnceWithExactly(mainPutItemsSpy, [
|
|
992
|
+
{type: 'locus', id: 0, version: 200},
|
|
993
|
+
{type: 'ControlEntry', id: 10100, version: 100},
|
|
994
|
+
{type: 'ControlEntry', id: 10101, version: 100}
|
|
995
|
+
]);
|
|
996
|
+
|
|
997
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
998
|
+
updatedObjects: [
|
|
999
|
+
{
|
|
1000
|
+
htMeta: {
|
|
1001
|
+
elementId: {
|
|
1002
|
+
type: 'ControlEntry',
|
|
1003
|
+
id: 10100,
|
|
1004
|
+
version: 100,
|
|
1005
|
+
},
|
|
1006
|
+
dataSetNames: ['main'],
|
|
1007
|
+
},
|
|
1008
|
+
data: {
|
|
1009
|
+
lock: {
|
|
1010
|
+
locked: true,
|
|
1011
|
+
htMeta: {
|
|
1012
|
+
elementId: {
|
|
1013
|
+
type: 'ControlEntry',
|
|
1014
|
+
id: 10100,
|
|
1015
|
+
version: 100,
|
|
1016
|
+
},
|
|
1017
|
+
dataSetNames: ['main'],
|
|
1018
|
+
},
|
|
1019
|
+
},
|
|
1020
|
+
},
|
|
1021
|
+
},
|
|
1022
|
+
{
|
|
1023
|
+
htMeta: {
|
|
1024
|
+
elementId: {
|
|
1025
|
+
type: 'ControlEntry',
|
|
1026
|
+
id: 10101,
|
|
1027
|
+
version: 100,
|
|
1028
|
+
},
|
|
1029
|
+
dataSetNames: ['main'],
|
|
1030
|
+
},
|
|
1031
|
+
data: {
|
|
1032
|
+
stream: {
|
|
1033
|
+
streaming: true,
|
|
1034
|
+
htMeta: {
|
|
1035
|
+
elementId: {
|
|
1036
|
+
type: 'ControlEntry',
|
|
1037
|
+
id: 10101,
|
|
1038
|
+
version: 100,
|
|
1039
|
+
},
|
|
1040
|
+
dataSetNames: ['main'],
|
|
1041
|
+
},
|
|
1042
|
+
},
|
|
1043
|
+
},
|
|
1044
|
+
}
|
|
1045
|
+
],
|
|
1046
|
+
});
|
|
1047
|
+
});
|
|
1048
|
+
|
|
864
1049
|
it('handles unknown datasets gracefully', () => {
|
|
865
1050
|
const parser = createHashTreeParser();
|
|
866
1051
|
|
|
@@ -899,7 +1084,7 @@ describe('HashTreeParser', () => {
|
|
|
899
1084
|
assert.calledOnceWithExactly(mainPutItemsSpy, [{type: 'locus', id: 0, version: 201}]);
|
|
900
1085
|
|
|
901
1086
|
// Verify callback was called only for known dataset
|
|
902
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
1087
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
903
1088
|
updatedObjects: [
|
|
904
1089
|
{
|
|
905
1090
|
htMeta: {
|
|
@@ -999,7 +1184,7 @@ describe('HashTreeParser', () => {
|
|
|
999
1184
|
assert.calledOnceWithExactly(selfPutItemSpy, {type: 'metadata', id: 5, version: 51});
|
|
1000
1185
|
|
|
1001
1186
|
// Verify callback was called with metadata object and removed dataset objects
|
|
1002
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
1187
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
1003
1188
|
updatedObjects: [
|
|
1004
1189
|
// updated metadata object:
|
|
1005
1190
|
{
|
|
@@ -1160,7 +1345,7 @@ describe('HashTreeParser', () => {
|
|
|
1160
1345
|
assert.notCalled(atdUnmutedPutItemsSpy);
|
|
1161
1346
|
|
|
1162
1347
|
// Verify callback was called with the updated object
|
|
1163
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
1348
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
1164
1349
|
updatedObjects: [
|
|
1165
1350
|
{
|
|
1166
1351
|
htMeta: {
|
|
@@ -1388,7 +1573,7 @@ describe('HashTreeParser', () => {
|
|
|
1388
1573
|
]);
|
|
1389
1574
|
|
|
1390
1575
|
// Verify callback was called with OBJECTS_UPDATED and all updated objects
|
|
1391
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
1576
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
1392
1577
|
updatedObjects: [
|
|
1393
1578
|
{
|
|
1394
1579
|
htMeta: {
|
|
@@ -1453,9 +1638,7 @@ describe('HashTreeParser', () => {
|
|
|
1453
1638
|
parser.handleMessage(sentinelMessage, 'sentinel message');
|
|
1454
1639
|
|
|
1455
1640
|
// Verify callback was called with MEETING_ENDED
|
|
1456
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED
|
|
1457
|
-
updatedObjects: undefined,
|
|
1458
|
-
});
|
|
1641
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
|
|
1459
1642
|
|
|
1460
1643
|
// Verify that all timers were stopped
|
|
1461
1644
|
Object.values(parser.dataSets).forEach((ds: any) => {
|
|
@@ -1477,9 +1660,7 @@ describe('HashTreeParser', () => {
|
|
|
1477
1660
|
parser.handleMessage(sentinelMessage, 'sentinel message');
|
|
1478
1661
|
|
|
1479
1662
|
// Verify callback was called with MEETING_ENDED
|
|
1480
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED
|
|
1481
|
-
updatedObjects: undefined,
|
|
1482
|
-
});
|
|
1663
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
|
|
1483
1664
|
|
|
1484
1665
|
// Verify that all timers were stopped
|
|
1485
1666
|
Object.values(parser.dataSets).forEach((ds: any) => {
|
|
@@ -1575,7 +1756,7 @@ describe('HashTreeParser', () => {
|
|
|
1575
1756
|
);
|
|
1576
1757
|
|
|
1577
1758
|
// Verify that callback was called with synced objects
|
|
1578
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
1759
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
1579
1760
|
updatedObjects: [
|
|
1580
1761
|
{
|
|
1581
1762
|
htMeta: {
|
|
@@ -1588,12 +1769,10 @@ describe('HashTreeParser', () => {
|
|
|
1588
1769
|
});
|
|
1589
1770
|
});
|
|
1590
1771
|
|
|
1591
|
-
describe('emits MEETING_ENDED', () => {
|
|
1592
|
-
|
|
1593
|
-
it(`when /hashtree returns ${statusCode}`, async () => {
|
|
1772
|
+
describe('emits MEETING_ENDED when 409/2403004 is returned', () => {
|
|
1773
|
+
it('when /hashtree returns 409', async () => {
|
|
1594
1774
|
const parser = createHashTreeParser();
|
|
1595
1775
|
|
|
1596
|
-
// Send a message to trigger sync algorithm
|
|
1597
1776
|
const message = {
|
|
1598
1777
|
dataSets: [createDataSet('main', 16, 1100)],
|
|
1599
1778
|
visibleDataSetsUrl,
|
|
@@ -1618,12 +1797,9 @@ describe('HashTreeParser', () => {
|
|
|
1618
1797
|
|
|
1619
1798
|
const mainDataSetUrl = parser.dataSets.main.url;
|
|
1620
1799
|
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
error.
|
|
1624
|
-
if (statusCode === 409) {
|
|
1625
|
-
error.body = {errorCode: 2403004};
|
|
1626
|
-
}
|
|
1800
|
+
const error: any = new Error('Request failed with status 409');
|
|
1801
|
+
error.statusCode = 409;
|
|
1802
|
+
error.body = {errorCode: 2403004};
|
|
1627
1803
|
webexRequest
|
|
1628
1804
|
.withArgs(
|
|
1629
1805
|
sinon.match({
|
|
@@ -1633,13 +1809,120 @@ describe('HashTreeParser', () => {
|
|
|
1633
1809
|
)
|
|
1634
1810
|
.rejects(error);
|
|
1635
1811
|
|
|
1636
|
-
// Trigger sync by advancing time
|
|
1637
1812
|
await clock.tickAsync(1000);
|
|
1638
1813
|
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1814
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
|
|
1815
|
+
|
|
1816
|
+
Object.values(parser.dataSets).forEach((ds: any) => {
|
|
1817
|
+
assert.isUndefined(ds.timer);
|
|
1818
|
+
assert.isUndefined(ds.heartbeatWatchdogTimer);
|
|
1819
|
+
});
|
|
1820
|
+
|
|
1821
|
+
// Verify no sync failure metric was sent for end-meeting sentinel
|
|
1822
|
+
assert.notCalled(metricsStub);
|
|
1823
|
+
});
|
|
1824
|
+
|
|
1825
|
+
it('when /sync returns 409', async () => {
|
|
1826
|
+
const parser = createHashTreeParser();
|
|
1827
|
+
|
|
1828
|
+
const message = {
|
|
1829
|
+
dataSets: [createDataSet('main', 16, 1100)],
|
|
1830
|
+
visibleDataSetsUrl,
|
|
1831
|
+
locusUrl,
|
|
1832
|
+
locusStateElements: [
|
|
1833
|
+
{
|
|
1834
|
+
htMeta: {
|
|
1835
|
+
elementId: {
|
|
1836
|
+
type: 'locus' as const,
|
|
1837
|
+
id: 0,
|
|
1838
|
+
version: 201,
|
|
1839
|
+
},
|
|
1840
|
+
dataSetNames: ['main'],
|
|
1841
|
+
},
|
|
1842
|
+
data: {info: {id: 'initial-update'}},
|
|
1843
|
+
},
|
|
1844
|
+
],
|
|
1845
|
+
};
|
|
1846
|
+
|
|
1847
|
+
parser.handleMessage(message, 'initial message');
|
|
1848
|
+
callback.resetHistory();
|
|
1849
|
+
|
|
1850
|
+
const mainDataSetUrl = parser.dataSets.main.url;
|
|
1851
|
+
|
|
1852
|
+
mockGetHashesFromLocusResponse(
|
|
1853
|
+
mainDataSetUrl,
|
|
1854
|
+
new Array(16).fill('00000000000000000000000000000000'),
|
|
1855
|
+
createDataSet('main', 16, 1101)
|
|
1856
|
+
);
|
|
1857
|
+
|
|
1858
|
+
const error: any = new Error('Request failed with status 409');
|
|
1859
|
+
error.statusCode = 409;
|
|
1860
|
+
error.body = {errorCode: 2403004};
|
|
1861
|
+
webexRequest
|
|
1862
|
+
.withArgs(
|
|
1863
|
+
sinon.match({
|
|
1864
|
+
method: 'POST',
|
|
1865
|
+
uri: `${mainDataSetUrl}/sync`,
|
|
1866
|
+
})
|
|
1867
|
+
)
|
|
1868
|
+
.rejects(error);
|
|
1869
|
+
|
|
1870
|
+
await clock.tickAsync(1000);
|
|
1871
|
+
|
|
1872
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
|
|
1873
|
+
|
|
1874
|
+
Object.values(parser.dataSets).forEach((ds: any) => {
|
|
1875
|
+
assert.isUndefined(ds.timer);
|
|
1876
|
+
assert.isUndefined(ds.heartbeatWatchdogTimer);
|
|
1642
1877
|
});
|
|
1878
|
+
});
|
|
1879
|
+
});
|
|
1880
|
+
|
|
1881
|
+
describe('emits LOCUS_NOT_FOUND and stops parser when 404 is returned', () => {
|
|
1882
|
+
it('when /hashtree returns 404', async () => {
|
|
1883
|
+
const parser = createHashTreeParser();
|
|
1884
|
+
|
|
1885
|
+
const message = {
|
|
1886
|
+
dataSets: [createDataSet('main', 16, 1100)],
|
|
1887
|
+
visibleDataSetsUrl,
|
|
1888
|
+
locusUrl,
|
|
1889
|
+
locusStateElements: [
|
|
1890
|
+
{
|
|
1891
|
+
htMeta: {
|
|
1892
|
+
elementId: {
|
|
1893
|
+
type: 'locus' as const,
|
|
1894
|
+
id: 0,
|
|
1895
|
+
version: 201,
|
|
1896
|
+
},
|
|
1897
|
+
dataSetNames: ['main'],
|
|
1898
|
+
},
|
|
1899
|
+
data: {info: {id: 'initial-update'}},
|
|
1900
|
+
},
|
|
1901
|
+
],
|
|
1902
|
+
};
|
|
1903
|
+
|
|
1904
|
+
parser.handleMessage(message, 'initial message');
|
|
1905
|
+
callback.resetHistory();
|
|
1906
|
+
|
|
1907
|
+
const mainDataSetUrl = parser.dataSets.main.url;
|
|
1908
|
+
|
|
1909
|
+
const error: any = new Error('Request failed with status 404');
|
|
1910
|
+
error.statusCode = 404;
|
|
1911
|
+
webexRequest
|
|
1912
|
+
.withArgs(
|
|
1913
|
+
sinon.match({
|
|
1914
|
+
method: 'GET',
|
|
1915
|
+
uri: `${mainDataSetUrl}/hashtree`,
|
|
1916
|
+
})
|
|
1917
|
+
)
|
|
1918
|
+
.rejects(error);
|
|
1919
|
+
|
|
1920
|
+
await clock.tickAsync(1000);
|
|
1921
|
+
|
|
1922
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.LOCUS_NOT_FOUND});
|
|
1923
|
+
|
|
1924
|
+
// Verify parser is stopped
|
|
1925
|
+
expect(parser.state).to.equal('stopped');
|
|
1643
1926
|
|
|
1644
1927
|
// Verify all timers are stopped
|
|
1645
1928
|
Object.values(parser.dataSets).forEach((ds: any) => {
|
|
@@ -1648,10 +1931,9 @@ describe('HashTreeParser', () => {
|
|
|
1648
1931
|
});
|
|
1649
1932
|
});
|
|
1650
1933
|
|
|
1651
|
-
it(
|
|
1934
|
+
it('when /sync returns 404', async () => {
|
|
1652
1935
|
const parser = createHashTreeParser();
|
|
1653
1936
|
|
|
1654
|
-
// Send a message to trigger sync algorithm
|
|
1655
1937
|
const message = {
|
|
1656
1938
|
dataSets: [createDataSet('main', 16, 1100)],
|
|
1657
1939
|
visibleDataSetsUrl,
|
|
@@ -1676,19 +1958,14 @@ describe('HashTreeParser', () => {
|
|
|
1676
1958
|
|
|
1677
1959
|
const mainDataSetUrl = parser.dataSets.main.url;
|
|
1678
1960
|
|
|
1679
|
-
// Mock getHashesFromLocus to succeed
|
|
1680
1961
|
mockGetHashesFromLocusResponse(
|
|
1681
1962
|
mainDataSetUrl,
|
|
1682
1963
|
new Array(16).fill('00000000000000000000000000000000'),
|
|
1683
1964
|
createDataSet('main', 16, 1101)
|
|
1684
1965
|
);
|
|
1685
1966
|
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
error.statusCode = statusCode;
|
|
1689
|
-
if (statusCode === 409) {
|
|
1690
|
-
error.body = {errorCode: 2403004};
|
|
1691
|
-
}
|
|
1967
|
+
const error: any = new Error('Request failed with status 404');
|
|
1968
|
+
error.statusCode = 404;
|
|
1692
1969
|
webexRequest
|
|
1693
1970
|
.withArgs(
|
|
1694
1971
|
sinon.match({
|
|
@@ -1698,21 +1975,22 @@ describe('HashTreeParser', () => {
|
|
|
1698
1975
|
)
|
|
1699
1976
|
.rejects(error);
|
|
1700
1977
|
|
|
1701
|
-
// Trigger sync by advancing time
|
|
1702
1978
|
await clock.tickAsync(1000);
|
|
1703
1979
|
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1980
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.LOCUS_NOT_FOUND});
|
|
1981
|
+
|
|
1982
|
+
// Verify parser is stopped
|
|
1983
|
+
expect(parser.state).to.equal('stopped');
|
|
1708
1984
|
|
|
1709
1985
|
// Verify all timers are stopped
|
|
1710
1986
|
Object.values(parser.dataSets).forEach((ds: any) => {
|
|
1711
1987
|
assert.isUndefined(ds.timer);
|
|
1712
1988
|
assert.isUndefined(ds.heartbeatWatchdogTimer);
|
|
1713
1989
|
});
|
|
1990
|
+
|
|
1991
|
+
// Verify no sync failure metric was sent for end-meeting sentinel
|
|
1992
|
+
assert.notCalled(metricsStub);
|
|
1714
1993
|
});
|
|
1715
|
-
});
|
|
1716
1994
|
});
|
|
1717
1995
|
|
|
1718
1996
|
it('requests only mismatched hashes during sync', async () => {
|
|
@@ -1883,58 +2161,278 @@ describe('HashTreeParser', () => {
|
|
|
1883
2161
|
},
|
|
1884
2162
|
});
|
|
1885
2163
|
});
|
|
1886
|
-
});
|
|
1887
2164
|
|
|
1888
|
-
|
|
1889
|
-
it('handles addition of visible data set (one that does not require async initialization)', async () => {
|
|
1890
|
-
// Create a parser with visible datasets
|
|
2165
|
+
it('restarts the sync timer when sync response is empty so that a future sync can be triggered', async () => {
|
|
1891
2166
|
const parser = createHashTreeParser();
|
|
1892
2167
|
|
|
1893
|
-
//
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
// Send a message with Metadata object that has a new visibleDataSets list
|
|
1897
|
-
const message = {
|
|
1898
|
-
dataSets: [createDataSet('self', 1, 2100), createDataSet('attendees', 8, 4000)],
|
|
1899
|
-
visibleDataSetsUrl,
|
|
1900
|
-
locusUrl,
|
|
1901
|
-
locusStateElements: [
|
|
2168
|
+
// Send a heartbeat with a mismatched root hash to trigger runSyncAlgorithm
|
|
2169
|
+
const heartbeatMessage = {
|
|
2170
|
+
dataSets: [
|
|
1902
2171
|
{
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
type: 'metadata' as const,
|
|
1906
|
-
id: 5,
|
|
1907
|
-
version: 51,
|
|
1908
|
-
},
|
|
1909
|
-
dataSetNames: ['self'],
|
|
1910
|
-
},
|
|
1911
|
-
data: {
|
|
1912
|
-
visibleDataSets: [
|
|
1913
|
-
{
|
|
1914
|
-
name: 'main',
|
|
1915
|
-
url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main',
|
|
1916
|
-
},
|
|
1917
|
-
{
|
|
1918
|
-
name: 'self',
|
|
1919
|
-
url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
|
|
1920
|
-
},
|
|
1921
|
-
{
|
|
1922
|
-
name: 'atd-unmuted',
|
|
1923
|
-
url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/atd-unmuted',
|
|
1924
|
-
},
|
|
1925
|
-
{
|
|
1926
|
-
name: 'attendees',
|
|
1927
|
-
url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/attendees',
|
|
1928
|
-
},
|
|
1929
|
-
], // added 'attendees'
|
|
1930
|
-
},
|
|
2172
|
+
...createDataSet('main', 16, 1100),
|
|
2173
|
+
root: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1', // different from ours
|
|
1931
2174
|
},
|
|
1932
2175
|
],
|
|
2176
|
+
visibleDataSetsUrl,
|
|
2177
|
+
locusUrl,
|
|
1933
2178
|
};
|
|
1934
2179
|
|
|
1935
|
-
parser.handleMessage(
|
|
2180
|
+
parser.handleMessage(heartbeatMessage, 'heartbeat with mismatch');
|
|
1936
2181
|
|
|
1937
|
-
//
|
|
2182
|
+
// The sync timer should be set
|
|
2183
|
+
expect(parser.dataSets.main.timer).to.not.be.undefined;
|
|
2184
|
+
|
|
2185
|
+
// Mock responses for the first sync - return null (204/empty body)
|
|
2186
|
+
const mainDataSetUrl = parser.dataSets.main.url;
|
|
2187
|
+
mockGetHashesFromLocusResponse(
|
|
2188
|
+
mainDataSetUrl,
|
|
2189
|
+
new Array(16).fill('00000000000000000000000000000000'),
|
|
2190
|
+
{
|
|
2191
|
+
...createDataSet('main', 16, 1101),
|
|
2192
|
+
root: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', // still mismatched
|
|
2193
|
+
}
|
|
2194
|
+
);
|
|
2195
|
+
mockSendSyncRequestResponse(mainDataSetUrl, null);
|
|
2196
|
+
|
|
2197
|
+
// Advance time to fire the sync timer (idleMs=1000 + backoff=0)
|
|
2198
|
+
await clock.tickAsync(1000);
|
|
2199
|
+
|
|
2200
|
+
// Verify sync was triggered
|
|
2201
|
+
assert.calledWith(
|
|
2202
|
+
webexRequest,
|
|
2203
|
+
sinon.match({
|
|
2204
|
+
method: 'POST',
|
|
2205
|
+
uri: `${mainDataSetUrl}/sync`,
|
|
2206
|
+
})
|
|
2207
|
+
);
|
|
2208
|
+
|
|
2209
|
+
// After empty response, runSyncAlgorithm should have been called,
|
|
2210
|
+
// setting a new sync timer as a safety net
|
|
2211
|
+
expect(parser.dataSets.main.timer).to.not.be.undefined;
|
|
2212
|
+
|
|
2213
|
+
// Reset and set up mocks for the second sync
|
|
2214
|
+
webexRequest.resetHistory();
|
|
2215
|
+
mockGetHashesFromLocusResponse(
|
|
2216
|
+
mainDataSetUrl,
|
|
2217
|
+
new Array(16).fill('00000000000000000000000000000000'),
|
|
2218
|
+
{
|
|
2219
|
+
...createDataSet('main', 16, 1102),
|
|
2220
|
+
root: 'cccccccccccccccccccccccccccccccc', // still mismatched
|
|
2221
|
+
}
|
|
2222
|
+
);
|
|
2223
|
+
mockSendSyncRequestResponse(mainDataSetUrl, null);
|
|
2224
|
+
|
|
2225
|
+
// Advance time again to fire the second sync timer
|
|
2226
|
+
await clock.tickAsync(1000);
|
|
2227
|
+
|
|
2228
|
+
// Verify a second sync was triggered
|
|
2229
|
+
assert.calledWith(
|
|
2230
|
+
webexRequest,
|
|
2231
|
+
sinon.match({
|
|
2232
|
+
method: 'POST',
|
|
2233
|
+
uri: `${mainDataSetUrl}/sync`,
|
|
2234
|
+
})
|
|
2235
|
+
);
|
|
2236
|
+
});
|
|
2237
|
+
|
|
2238
|
+
it('updates dataSet.leafCount when hash tree is resized during sync so that the sync request has the correct leafCount', async () => {
|
|
2239
|
+
const parser = createHashTreeParser();
|
|
2240
|
+
|
|
2241
|
+
// Send a heartbeat with a mismatched root hash to trigger runSyncAlgorithm
|
|
2242
|
+
const heartbeatMessage = {
|
|
2243
|
+
dataSets: [
|
|
2244
|
+
{
|
|
2245
|
+
...createDataSet('main', 16, 1100),
|
|
2246
|
+
root: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1', // different from ours
|
|
2247
|
+
},
|
|
2248
|
+
],
|
|
2249
|
+
visibleDataSetsUrl,
|
|
2250
|
+
locusUrl,
|
|
2251
|
+
};
|
|
2252
|
+
|
|
2253
|
+
parser.handleMessage(heartbeatMessage, 'heartbeat with mismatch');
|
|
2254
|
+
|
|
2255
|
+
// The sync timer should be set
|
|
2256
|
+
expect(parser.dataSets.main.timer).to.not.be.undefined;
|
|
2257
|
+
|
|
2258
|
+
const mainDataSetUrl = parser.dataSets.main.url;
|
|
2259
|
+
const newLeafCount = 32;
|
|
2260
|
+
|
|
2261
|
+
// Mock getHashesFromLocus response with a DIFFERENT leafCount (32 instead of 16)
|
|
2262
|
+
mockGetHashesFromLocusResponse(
|
|
2263
|
+
mainDataSetUrl,
|
|
2264
|
+
new Array(newLeafCount).fill('00000000000000000000000000000000'),
|
|
2265
|
+
createDataSet('main', newLeafCount, 1101)
|
|
2266
|
+
);
|
|
2267
|
+
|
|
2268
|
+
// Mock the sync request - use matching root hash
|
|
2269
|
+
const syncResponseDataSet = createDataSet('main', newLeafCount, 1102);
|
|
2270
|
+
syncResponseDataSet.root = parser.dataSets.main.hashTree.getRootHash();
|
|
2271
|
+
mockSendSyncRequestResponse(mainDataSetUrl, {
|
|
2272
|
+
dataSets: [syncResponseDataSet],
|
|
2273
|
+
visibleDataSetsUrl,
|
|
2274
|
+
locusUrl,
|
|
2275
|
+
locusStateElements: [],
|
|
2276
|
+
});
|
|
2277
|
+
|
|
2278
|
+
// Advance time to fire the sync timer (idleMs=1000 + backoff=0)
|
|
2279
|
+
await clock.tickAsync(1000);
|
|
2280
|
+
|
|
2281
|
+
// Verify the sync request was sent with the NEW leafCount (32), not the old one (16)
|
|
2282
|
+
assert.calledWith(
|
|
2283
|
+
webexRequest,
|
|
2284
|
+
sinon.match({
|
|
2285
|
+
method: 'POST',
|
|
2286
|
+
uri: `${mainDataSetUrl}/sync`,
|
|
2287
|
+
body: sinon.match({
|
|
2288
|
+
leafCount: newLeafCount,
|
|
2289
|
+
}),
|
|
2290
|
+
})
|
|
2291
|
+
);
|
|
2292
|
+
});
|
|
2293
|
+
|
|
2294
|
+
it('sends HASH_TREE_SYNC_FAILURE metric when GET /hashtree request fails', async () => {
|
|
2295
|
+
const parser = createHashTreeParser();
|
|
2296
|
+
|
|
2297
|
+
// Send a heartbeat with a mismatched root hash to trigger runSyncAlgorithm
|
|
2298
|
+
const heartbeatMessage = {
|
|
2299
|
+
dataSets: [
|
|
2300
|
+
{
|
|
2301
|
+
...createDataSet('main', 16, 1100),
|
|
2302
|
+
root: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1',
|
|
2303
|
+
},
|
|
2304
|
+
],
|
|
2305
|
+
visibleDataSetsUrl,
|
|
2306
|
+
locusUrl,
|
|
2307
|
+
};
|
|
2308
|
+
|
|
2309
|
+
parser.handleMessage(heartbeatMessage, 'heartbeat with mismatch');
|
|
2310
|
+
|
|
2311
|
+
const mainDataSetUrl = parser.dataSets.main.url;
|
|
2312
|
+
const hashTreeError = new Error('server error') as any;
|
|
2313
|
+
hashTreeError.statusCode = 500;
|
|
2314
|
+
|
|
2315
|
+
webexRequest
|
|
2316
|
+
.withArgs(
|
|
2317
|
+
sinon.match({
|
|
2318
|
+
method: 'GET',
|
|
2319
|
+
uri: `${mainDataSetUrl}/hashtree`,
|
|
2320
|
+
})
|
|
2321
|
+
)
|
|
2322
|
+
.rejects(hashTreeError);
|
|
2323
|
+
|
|
2324
|
+
await clock.tickAsync(1000);
|
|
2325
|
+
|
|
2326
|
+
assert.calledOnceWithExactly(metricsStub, BEHAVIORAL_METRICS.HASH_TREE_SYNC_FAILURE, {
|
|
2327
|
+
debugId: 'test',
|
|
2328
|
+
dataSetName: 'main',
|
|
2329
|
+
request: 'GET /hashtree',
|
|
2330
|
+
statusCode: 500,
|
|
2331
|
+
reason: 'server error',
|
|
2332
|
+
});
|
|
2333
|
+
});
|
|
2334
|
+
|
|
2335
|
+
it('sends HASH_TREE_SYNC_FAILURE metric when POST /sync request fails', async () => {
|
|
2336
|
+
const parser = createHashTreeParser();
|
|
2337
|
+
|
|
2338
|
+
// Send a heartbeat with a mismatched root hash to trigger runSyncAlgorithm
|
|
2339
|
+
const heartbeatMessage = {
|
|
2340
|
+
dataSets: [
|
|
2341
|
+
{
|
|
2342
|
+
...createDataSet('main', 16, 1100),
|
|
2343
|
+
root: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1',
|
|
2344
|
+
},
|
|
2345
|
+
],
|
|
2346
|
+
visibleDataSetsUrl,
|
|
2347
|
+
locusUrl,
|
|
2348
|
+
};
|
|
2349
|
+
|
|
2350
|
+
parser.handleMessage(heartbeatMessage, 'heartbeat with mismatch');
|
|
2351
|
+
|
|
2352
|
+
const mainDataSetUrl = parser.dataSets.main.url;
|
|
2353
|
+
|
|
2354
|
+
// Mock getHashesFromLocus to succeed
|
|
2355
|
+
mockGetHashesFromLocusResponse(
|
|
2356
|
+
mainDataSetUrl,
|
|
2357
|
+
new Array(16).fill('00000000000000000000000000000000'),
|
|
2358
|
+
createDataSet('main', 16, 1101)
|
|
2359
|
+
);
|
|
2360
|
+
|
|
2361
|
+
// Mock sendSyncRequestToLocus to fail
|
|
2362
|
+
const syncError = new Error('sync failed') as any;
|
|
2363
|
+
syncError.statusCode = 500;
|
|
2364
|
+
|
|
2365
|
+
webexRequest
|
|
2366
|
+
.withArgs(
|
|
2367
|
+
sinon.match({
|
|
2368
|
+
method: 'POST',
|
|
2369
|
+
uri: `${mainDataSetUrl}/sync`,
|
|
2370
|
+
})
|
|
2371
|
+
)
|
|
2372
|
+
.rejects(syncError);
|
|
2373
|
+
|
|
2374
|
+
await clock.tickAsync(1000);
|
|
2375
|
+
|
|
2376
|
+
assert.calledOnceWithExactly(metricsStub, BEHAVIORAL_METRICS.HASH_TREE_SYNC_FAILURE, {
|
|
2377
|
+
debugId: 'test',
|
|
2378
|
+
dataSetName: 'main',
|
|
2379
|
+
request: 'POST /sync',
|
|
2380
|
+
statusCode: 500,
|
|
2381
|
+
reason: 'sync failed',
|
|
2382
|
+
});
|
|
2383
|
+
});
|
|
2384
|
+
});
|
|
2385
|
+
|
|
2386
|
+
describe('handles visible data sets changes correctly', () => {
|
|
2387
|
+
it('handles addition of visible data set (one that does not require async initialization)', async () => {
|
|
2388
|
+
// Create a parser with visible datasets
|
|
2389
|
+
const parser = createHashTreeParser();
|
|
2390
|
+
|
|
2391
|
+
// Stub updateItems on self hash tree to return true
|
|
2392
|
+
sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
|
|
2393
|
+
|
|
2394
|
+
// Send a message with Metadata object that has a new visibleDataSets list
|
|
2395
|
+
const message = {
|
|
2396
|
+
dataSets: [createDataSet('self', 1, 2100), createDataSet('attendees', 8, 4000)],
|
|
2397
|
+
visibleDataSetsUrl,
|
|
2398
|
+
locusUrl,
|
|
2399
|
+
locusStateElements: [
|
|
2400
|
+
{
|
|
2401
|
+
htMeta: {
|
|
2402
|
+
elementId: {
|
|
2403
|
+
type: 'metadata' as const,
|
|
2404
|
+
id: 5,
|
|
2405
|
+
version: 51,
|
|
2406
|
+
},
|
|
2407
|
+
dataSetNames: ['self'],
|
|
2408
|
+
},
|
|
2409
|
+
data: {
|
|
2410
|
+
visibleDataSets: [
|
|
2411
|
+
{
|
|
2412
|
+
name: 'main',
|
|
2413
|
+
url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/main',
|
|
2414
|
+
},
|
|
2415
|
+
{
|
|
2416
|
+
name: 'self',
|
|
2417
|
+
url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
|
|
2418
|
+
},
|
|
2419
|
+
{
|
|
2420
|
+
name: 'atd-unmuted',
|
|
2421
|
+
url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/atd-unmuted',
|
|
2422
|
+
},
|
|
2423
|
+
{
|
|
2424
|
+
name: 'attendees',
|
|
2425
|
+
url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/attendees',
|
|
2426
|
+
},
|
|
2427
|
+
], // added 'attendees'
|
|
2428
|
+
},
|
|
2429
|
+
},
|
|
2430
|
+
],
|
|
2431
|
+
};
|
|
2432
|
+
|
|
2433
|
+
parser.handleMessage(message, 'add visible dataset');
|
|
2434
|
+
|
|
2435
|
+
// Verify that 'attendees' was added to visibleDataSets
|
|
1938
2436
|
expect(parser.visibleDataSets.some((vds) => vds.name === 'attendees')).to.be.true;
|
|
1939
2437
|
|
|
1940
2438
|
// Verify that a hash tree was created for 'attendees'
|
|
@@ -1942,7 +2440,7 @@ describe('HashTreeParser', () => {
|
|
|
1942
2440
|
assert.equal(parser.dataSets.attendees.hashTree.numLeaves, 8);
|
|
1943
2441
|
|
|
1944
2442
|
// Verify callback was called with the metadata update (appears twice - processed once for visible dataset changes, once in main loop)
|
|
1945
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
2443
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
1946
2444
|
updatedObjects: [
|
|
1947
2445
|
{
|
|
1948
2446
|
htMeta: {
|
|
@@ -2062,7 +2560,99 @@ describe('HashTreeParser', () => {
|
|
|
2062
2560
|
await checkAsyncDatasetInitialization(parser, newDataSet);
|
|
2063
2561
|
});
|
|
2064
2562
|
|
|
2065
|
-
it('
|
|
2563
|
+
it('initializes new visible data sets in priority order', async () => {
|
|
2564
|
+
// Create a parser that only has "self" as visible (no "main")
|
|
2565
|
+
const initialLocusWithoutMain = {
|
|
2566
|
+
dataSets: [createDataSet('self', 1, 2000)],
|
|
2567
|
+
locus: {
|
|
2568
|
+
...exampleInitialLocus.locus,
|
|
2569
|
+
},
|
|
2570
|
+
};
|
|
2571
|
+
const metadataWithoutMain = {
|
|
2572
|
+
...exampleMetadata,
|
|
2573
|
+
visibleDataSets: [
|
|
2574
|
+
{
|
|
2575
|
+
name: 'self',
|
|
2576
|
+
url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
|
|
2577
|
+
},
|
|
2578
|
+
],
|
|
2579
|
+
};
|
|
2580
|
+
const parser = createHashTreeParser(initialLocusWithoutMain, metadataWithoutMain);
|
|
2581
|
+
|
|
2582
|
+
// Verify "main" is not visible initially
|
|
2583
|
+
expect(parser.visibleDataSets.some((vds) => vds.name === 'main')).to.be.false;
|
|
2584
|
+
|
|
2585
|
+
// Stub updateItems on self hash tree to return true
|
|
2586
|
+
sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
|
|
2587
|
+
|
|
2588
|
+
// Send a message that adds "main" and "atd-active" as new visible datasets.
|
|
2589
|
+
// Neither has info in dataSets, so both require async initialization.
|
|
2590
|
+
const newMainDataSet = createDataSet('main', 16, 6000);
|
|
2591
|
+
const newAtdActiveDataSet = createDataSet('atd-active', 4, 7000);
|
|
2592
|
+
|
|
2593
|
+
const message = {
|
|
2594
|
+
dataSets: [createDataSet('self', 1, 2100)],
|
|
2595
|
+
visibleDataSetsUrl,
|
|
2596
|
+
locusUrl,
|
|
2597
|
+
locusStateElements: [
|
|
2598
|
+
{
|
|
2599
|
+
htMeta: {
|
|
2600
|
+
elementId: {
|
|
2601
|
+
type: 'metadata' as const,
|
|
2602
|
+
id: 5,
|
|
2603
|
+
version: 51,
|
|
2604
|
+
},
|
|
2605
|
+
dataSetNames: ['self'],
|
|
2606
|
+
},
|
|
2607
|
+
data: {
|
|
2608
|
+
visibleDataSets: [
|
|
2609
|
+
{
|
|
2610
|
+
name: 'self',
|
|
2611
|
+
url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self',
|
|
2612
|
+
},
|
|
2613
|
+
// listed in non-priority order: atd-active before main
|
|
2614
|
+
{name: 'atd-active', url: newAtdActiveDataSet.url},
|
|
2615
|
+
{name: 'main', url: newMainDataSet.url},
|
|
2616
|
+
],
|
|
2617
|
+
},
|
|
2618
|
+
},
|
|
2619
|
+
],
|
|
2620
|
+
};
|
|
2621
|
+
|
|
2622
|
+
// Mock getAllVisibleDataSetsFromLocus to return both new datasets (in non-priority order)
|
|
2623
|
+
mockGetAllDataSetsMetadata(webexRequest, visibleDataSetsUrl, [
|
|
2624
|
+
newAtdActiveDataSet,
|
|
2625
|
+
newMainDataSet,
|
|
2626
|
+
]);
|
|
2627
|
+
mockSyncRequest(webexRequest, newMainDataSet.url);
|
|
2628
|
+
mockSyncRequest(webexRequest, newAtdActiveDataSet.url);
|
|
2629
|
+
|
|
2630
|
+
parser.handleMessage(message, 'add main and atd-active datasets');
|
|
2631
|
+
|
|
2632
|
+
// Wait for the async initialization (queueMicrotask) to complete
|
|
2633
|
+
await clock.tickAsync(0);
|
|
2634
|
+
|
|
2635
|
+
// Verify both datasets are initialized
|
|
2636
|
+
expect(parser.dataSets.main?.hashTree).to.exist;
|
|
2637
|
+
expect(parser.dataSets['atd-active']?.hashTree).to.exist;
|
|
2638
|
+
|
|
2639
|
+
// Verify sync requests were sent in priority order: "main" before "atd-active",
|
|
2640
|
+
// even though atd-active was listed first in both the message and the Locus response
|
|
2641
|
+
const syncCalls = webexRequest
|
|
2642
|
+
.getCalls()
|
|
2643
|
+
.filter(
|
|
2644
|
+
(call) =>
|
|
2645
|
+
call.args[0]?.method === 'POST' &&
|
|
2646
|
+
call.args[0]?.uri?.endsWith('/sync') &&
|
|
2647
|
+
(call.args[0]?.uri?.includes('/main/') || call.args[0]?.uri?.includes('/atd-active/'))
|
|
2648
|
+
);
|
|
2649
|
+
|
|
2650
|
+
expect(syncCalls).to.have.lengthOf(2);
|
|
2651
|
+
expect(syncCalls[0].args[0].uri).to.equal(`${newMainDataSet.url}/sync`);
|
|
2652
|
+
expect(syncCalls[1].args[0].uri).to.equal(`${newAtdActiveDataSet.url}/sync`);
|
|
2653
|
+
});
|
|
2654
|
+
|
|
2655
|
+
it('emits LOCUS_NOT_FOUND if async init of a new visible dataset fails with 404', async () => {
|
|
2066
2656
|
const parser = createHashTreeParser();
|
|
2067
2657
|
|
|
2068
2658
|
// Stub updateItems on self hash tree to return true
|
|
@@ -2127,10 +2717,8 @@ describe('HashTreeParser', () => {
|
|
|
2127
2717
|
// Wait for the async initialization (queueMicrotask) to complete
|
|
2128
2718
|
await clock.tickAsync(0);
|
|
2129
2719
|
|
|
2130
|
-
// Verify callback was called with
|
|
2131
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.
|
|
2132
|
-
updatedObjects: undefined,
|
|
2133
|
-
});
|
|
2720
|
+
// Verify callback was called with LOCUS_NOT_FOUND (404 means locus URL is stale, not necessarily meeting ended)
|
|
2721
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.LOCUS_NOT_FOUND});
|
|
2134
2722
|
});
|
|
2135
2723
|
|
|
2136
2724
|
it('handles removal of visible data set', async () => {
|
|
@@ -2193,7 +2781,7 @@ describe('HashTreeParser', () => {
|
|
|
2193
2781
|
assert.isUndefined(parser.dataSets['atd-unmuted'].timer);
|
|
2194
2782
|
|
|
2195
2783
|
// Verify callback was called with the metadata update and the removed objects (metadata appears twice - processed once for dataset changes, once in main loop)
|
|
2196
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
2784
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
2197
2785
|
updatedObjects: [
|
|
2198
2786
|
{
|
|
2199
2787
|
htMeta: {
|
|
@@ -2290,6 +2878,151 @@ describe('HashTreeParser', () => {
|
|
|
2290
2878
|
// Verify callback was NOT called (no updates for non-visible datasets)
|
|
2291
2879
|
assert.notCalled(callback);
|
|
2292
2880
|
});
|
|
2881
|
+
|
|
2882
|
+
it('reports update for object that moves from removed visible dataset to new visible dataset even if version is unchanged', async () => {
|
|
2883
|
+
// The purpose of this test is to verify that when an object
|
|
2884
|
+
// moves from one visible dataset to another without version change,
|
|
2885
|
+
// the parser still reports it as an update.
|
|
2886
|
+
// Locus has some additional signalling for this - the "view" property in htMeta.elementId.
|
|
2887
|
+
// When a view changes, the contents of the object may change even if version doesn't.
|
|
2888
|
+
// HashTreeParser doesn't use the "view" property, because it doesn't need to -
|
|
2889
|
+
// the same functionality is achieved thanks to the fact that a new visible data set means
|
|
2890
|
+
// a new hash tree is created, so HashTreeParser still detects the change as new
|
|
2891
|
+
// object is added to the new hash tree.
|
|
2892
|
+
|
|
2893
|
+
// Setup: parser with visible datasets "self" and "unjoined"
|
|
2894
|
+
const unjoinedDataSet = createDataSet('unjoined', 4, 3000);
|
|
2895
|
+
const selfDataSet = createDataSet('self', 1, 2000);
|
|
2896
|
+
|
|
2897
|
+
// start with Locus that has "info" in both "unjoined" and "main" datasets,
|
|
2898
|
+
// but only "unjoined" is visible.
|
|
2899
|
+
const initialLocus = {
|
|
2900
|
+
dataSets: [selfDataSet, unjoinedDataSet],
|
|
2901
|
+
locus: {
|
|
2902
|
+
url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f',
|
|
2903
|
+
links: {resources: {visibleDataSets: {url: visibleDataSetsUrl}}},
|
|
2904
|
+
// info object in "unjoined" dataset with version 500
|
|
2905
|
+
info: {
|
|
2906
|
+
htMeta: {
|
|
2907
|
+
elementId: {
|
|
2908
|
+
type: 'info',
|
|
2909
|
+
id: 42,
|
|
2910
|
+
version: 500,
|
|
2911
|
+
view: ['unjoined'], // not used by our code, but here for completeness - that's what real Locus would send
|
|
2912
|
+
},
|
|
2913
|
+
dataSetNames: ['main', 'unjoined'],
|
|
2914
|
+
},
|
|
2915
|
+
someField: 'some-initial-value',
|
|
2916
|
+
},
|
|
2917
|
+
self: {
|
|
2918
|
+
htMeta: {
|
|
2919
|
+
elementId: {
|
|
2920
|
+
type: 'self',
|
|
2921
|
+
id: 4,
|
|
2922
|
+
version: 100,
|
|
2923
|
+
},
|
|
2924
|
+
dataSetNames: ['self'],
|
|
2925
|
+
},
|
|
2926
|
+
},
|
|
2927
|
+
},
|
|
2928
|
+
};
|
|
2929
|
+
|
|
2930
|
+
const metadata = {
|
|
2931
|
+
htMeta: {
|
|
2932
|
+
elementId: {
|
|
2933
|
+
type: 'metadata',
|
|
2934
|
+
id: 5,
|
|
2935
|
+
version: 50,
|
|
2936
|
+
},
|
|
2937
|
+
dataSetNames: ['self'],
|
|
2938
|
+
},
|
|
2939
|
+
visibleDataSets: [
|
|
2940
|
+
{name: 'self', url: selfDataSet.url},
|
|
2941
|
+
{name: 'unjoined', url: unjoinedDataSet.url},
|
|
2942
|
+
],
|
|
2943
|
+
};
|
|
2944
|
+
|
|
2945
|
+
const parser = createHashTreeParser(initialLocus, metadata);
|
|
2946
|
+
|
|
2947
|
+
// Verify initial state: unjoined is visible and has the info object
|
|
2948
|
+
expect(parser.visibleDataSets.some((vds) => vds.name === 'unjoined')).to.be.true;
|
|
2949
|
+
assert.exists(parser.dataSets.unjoined.hashTree);
|
|
2950
|
+
assert.equal(parser.dataSets.unjoined.hashTree?.getItemVersion(42, 'info'), 500);
|
|
2951
|
+
|
|
2952
|
+
// Stub updateItems on self hash tree to return true for metadata update
|
|
2953
|
+
sinon.stub(parser.dataSets.self.hashTree, 'updateItems').returns([true]);
|
|
2954
|
+
|
|
2955
|
+
// Now send a message that:
|
|
2956
|
+
// 1. Changes visible datasets: removes "unjoined", adds "main"
|
|
2957
|
+
// 2. Contains the same info object (same id=42, same version=500) but we see the view from "main" dataset
|
|
2958
|
+
const mainDataSet = createDataSet('main', 16, 1000);
|
|
2959
|
+
|
|
2960
|
+
const message = {
|
|
2961
|
+
dataSets: [selfDataSet, mainDataSet],
|
|
2962
|
+
visibleDataSetsUrl,
|
|
2963
|
+
locusUrl,
|
|
2964
|
+
locusStateElements: [
|
|
2965
|
+
{
|
|
2966
|
+
htMeta: {
|
|
2967
|
+
elementId: {
|
|
2968
|
+
type: 'metadata' as const,
|
|
2969
|
+
id: 5,
|
|
2970
|
+
version: 51,
|
|
2971
|
+
},
|
|
2972
|
+
dataSetNames: ['self'],
|
|
2973
|
+
},
|
|
2974
|
+
data: {
|
|
2975
|
+
visibleDataSets: [
|
|
2976
|
+
{name: 'self', url: selfDataSet.url},
|
|
2977
|
+
{name: 'main', url: mainDataSet.url},
|
|
2978
|
+
// "unjoined" is no longer here
|
|
2979
|
+
],
|
|
2980
|
+
},
|
|
2981
|
+
},
|
|
2982
|
+
{
|
|
2983
|
+
htMeta: {
|
|
2984
|
+
elementId: {
|
|
2985
|
+
type: 'info' as const,
|
|
2986
|
+
id: 42,
|
|
2987
|
+
version: 500, // same version as before
|
|
2988
|
+
view: ['main'], // now points to "main" instead of "unjoined"
|
|
2989
|
+
},
|
|
2990
|
+
dataSetNames: ['main', 'unjoined'], // still in both datasets, but only "main" is visible now
|
|
2991
|
+
},
|
|
2992
|
+
data: {someNewField: 'some-value'},
|
|
2993
|
+
},
|
|
2994
|
+
],
|
|
2995
|
+
};
|
|
2996
|
+
|
|
2997
|
+
parser.handleMessage(message, 'visible dataset swap with same-version object');
|
|
2998
|
+
|
|
2999
|
+
// Verify "unjoined" is no longer visible and "main" is now visible
|
|
3000
|
+
expect(parser.visibleDataSets.some((vds) => vds.name === 'unjoined')).to.be.false;
|
|
3001
|
+
expect(parser.visibleDataSets.some((vds) => vds.name === 'main')).to.be.true;
|
|
3002
|
+
|
|
3003
|
+
// Verify the info object is now in the "main" hash tree
|
|
3004
|
+
assert.exists(parser.dataSets.main.hashTree);
|
|
3005
|
+
assert.equal(parser.dataSets.main.hashTree?.getItemVersion(42, 'info'), 500);
|
|
3006
|
+
|
|
3007
|
+
// The key assertion: callback should be called with the info object update even though
|
|
3008
|
+
// its version hasn't changed - because visible datasets changed (moved from unjoined to main)
|
|
3009
|
+
assert.calledOnce(callback);
|
|
3010
|
+
const callbackArgs = callback.firstCall.args[0];
|
|
3011
|
+
assert.equal(callbackArgs.updateType, LocusInfoUpdateType.OBJECTS_UPDATED);
|
|
3012
|
+
|
|
3013
|
+
// Should contain the info object update (with data)
|
|
3014
|
+
const infoUpdate = callbackArgs.updatedObjects.find(
|
|
3015
|
+
(obj) => obj.htMeta.elementId.type === 'info' && obj.htMeta.elementId.id === 42
|
|
3016
|
+
);
|
|
3017
|
+
assert.exists(infoUpdate);
|
|
3018
|
+
assert.deepEqual(infoUpdate.htMeta.elementId, {
|
|
3019
|
+
type: 'info',
|
|
3020
|
+
id: 42,
|
|
3021
|
+
version: 500,
|
|
3022
|
+
view: ['main'],
|
|
3023
|
+
});
|
|
3024
|
+
assert.deepEqual(infoUpdate.data, {someNewField: 'some-value'});
|
|
3025
|
+
});
|
|
2293
3026
|
});
|
|
2294
3027
|
|
|
2295
3028
|
describe('heartbeat watchdog', () => {
|
|
@@ -2666,56 +3399,126 @@ describe('HashTreeParser', () => {
|
|
|
2666
3399
|
// At 5500ms, 'main' watchdog fires and performSync runs immediately
|
|
2667
3400
|
await clock.tickAsync(1);
|
|
2668
3401
|
|
|
2669
|
-
// main sync should have triggered immediately (GET hashtree + POST sync)
|
|
3402
|
+
// main sync should have triggered immediately (GET hashtree + POST sync)
|
|
3403
|
+
assert.calledWith(
|
|
3404
|
+
webexRequest,
|
|
3405
|
+
sinon.match({
|
|
3406
|
+
method: 'GET',
|
|
3407
|
+
uri: `${parser.dataSets.main.url}/hashtree`,
|
|
3408
|
+
})
|
|
3409
|
+
);
|
|
3410
|
+
|
|
3411
|
+
webexRequest.resetHistory();
|
|
3412
|
+
|
|
3413
|
+
// At 7000ms, 'self' watchdog fires and performSync runs immediately
|
|
3414
|
+
await clock.tickAsync(1500);
|
|
3415
|
+
|
|
3416
|
+
// self sync should have also triggered (POST sync only, leafCount === 1)
|
|
3417
|
+
assert.calledWith(
|
|
3418
|
+
webexRequest,
|
|
3419
|
+
sinon.match({
|
|
3420
|
+
method: 'POST',
|
|
3421
|
+
uri: `${parser.dataSets.self.url}/sync`,
|
|
3422
|
+
})
|
|
3423
|
+
);
|
|
3424
|
+
});
|
|
3425
|
+
|
|
3426
|
+
it('does not set watchdog for data sets without a hash tree', async () => {
|
|
3427
|
+
const parser = createHashTreeParser();
|
|
3428
|
+
const heartbeatIntervalMs = 5000;
|
|
3429
|
+
|
|
3430
|
+
// 'atd-active' is in the initial locus but is not visible (no hash tree)
|
|
3431
|
+
// Send heartbeat mentioning a non-visible dataset
|
|
3432
|
+
const heartbeatMessage = {
|
|
3433
|
+
dataSets: [
|
|
3434
|
+
{
|
|
3435
|
+
...createDataSet('main', 16, 1100),
|
|
3436
|
+
root: parser.dataSets.main.hashTree.getRootHash(),
|
|
3437
|
+
},
|
|
3438
|
+
createDataSet('atd-active', 16, 4000),
|
|
3439
|
+
],
|
|
3440
|
+
visibleDataSetsUrl,
|
|
3441
|
+
locusUrl,
|
|
3442
|
+
heartbeatIntervalMs,
|
|
3443
|
+
};
|
|
3444
|
+
|
|
3445
|
+
parser.handleMessage(heartbeatMessage, 'heartbeat with non-visible dataset');
|
|
3446
|
+
|
|
3447
|
+
// Watchdog set for main (visible) but not for atd-active (no hash tree)
|
|
3448
|
+
expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
|
|
3449
|
+
expect(parser.dataSets['atd-active']?.heartbeatWatchdogTimer).to.be.undefined;
|
|
3450
|
+
});
|
|
3451
|
+
|
|
3452
|
+
it('restarts the watchdog timer after it fires so that future missed heartbeats still trigger syncs', async () => {
|
|
3453
|
+
const parser = createHashTreeParser();
|
|
3454
|
+
const heartbeatIntervalMs = 5000;
|
|
3455
|
+
|
|
3456
|
+
// Send initial heartbeat for 'main'
|
|
3457
|
+
const heartbeatMessage = {
|
|
3458
|
+
dataSets: [
|
|
3459
|
+
{
|
|
3460
|
+
...createDataSet('main', 16, 1100),
|
|
3461
|
+
root: parser.dataSets.main.hashTree.getRootHash(),
|
|
3462
|
+
},
|
|
3463
|
+
],
|
|
3464
|
+
visibleDataSetsUrl,
|
|
3465
|
+
locusUrl,
|
|
3466
|
+
heartbeatIntervalMs,
|
|
3467
|
+
};
|
|
3468
|
+
|
|
3469
|
+
parser.handleMessage(heartbeatMessage, 'initial heartbeat');
|
|
3470
|
+
expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
|
|
3471
|
+
|
|
3472
|
+
// Mock responses for performSync - return null (204/empty body)
|
|
3473
|
+
const mainDataSetUrl = parser.dataSets.main.url;
|
|
3474
|
+
mockGetHashesFromLocusResponse(
|
|
3475
|
+
mainDataSetUrl,
|
|
3476
|
+
new Array(16).fill('00000000000000000000000000000000'),
|
|
3477
|
+
createDataSet('main', 16, 1101)
|
|
3478
|
+
);
|
|
3479
|
+
mockSendSyncRequestResponse(mainDataSetUrl, null);
|
|
3480
|
+
|
|
3481
|
+
// Advance time past heartbeatIntervalMs to fire the watchdog
|
|
3482
|
+
await clock.tickAsync(heartbeatIntervalMs);
|
|
3483
|
+
|
|
3484
|
+
// Verify sync was triggered
|
|
2670
3485
|
assert.calledWith(
|
|
2671
3486
|
webexRequest,
|
|
2672
3487
|
sinon.match({
|
|
2673
3488
|
method: 'GET',
|
|
2674
|
-
uri: `${
|
|
3489
|
+
uri: `${mainDataSetUrl}/hashtree`,
|
|
2675
3490
|
})
|
|
2676
3491
|
);
|
|
2677
3492
|
|
|
3493
|
+
// The watchdog timer should have been restarted after firing
|
|
3494
|
+
expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
|
|
3495
|
+
|
|
3496
|
+
// Reset call history and set up new mock responses for the second sync
|
|
2678
3497
|
webexRequest.resetHistory();
|
|
3498
|
+
mockGetHashesFromLocusResponse(
|
|
3499
|
+
mainDataSetUrl,
|
|
3500
|
+
new Array(16).fill('00000000000000000000000000000000'),
|
|
3501
|
+
createDataSet('main', 16, 1102)
|
|
3502
|
+
);
|
|
3503
|
+
mockSendSyncRequestResponse(mainDataSetUrl, null);
|
|
2679
3504
|
|
|
2680
|
-
//
|
|
2681
|
-
await clock.tickAsync(
|
|
3505
|
+
// Advance time again to fire the watchdog a second time
|
|
3506
|
+
await clock.tickAsync(heartbeatIntervalMs);
|
|
2682
3507
|
|
|
2683
|
-
//
|
|
3508
|
+
// Verify a second sync was triggered
|
|
2684
3509
|
assert.calledWith(
|
|
2685
3510
|
webexRequest,
|
|
2686
3511
|
sinon.match({
|
|
2687
|
-
method: '
|
|
2688
|
-
uri: `${
|
|
3512
|
+
method: 'GET',
|
|
3513
|
+
uri: `${mainDataSetUrl}/hashtree`,
|
|
2689
3514
|
})
|
|
2690
3515
|
);
|
|
2691
|
-
});
|
|
2692
|
-
|
|
2693
|
-
it('does not set watchdog for data sets without a hash tree', async () => {
|
|
2694
|
-
const parser = createHashTreeParser();
|
|
2695
|
-
const heartbeatIntervalMs = 5000;
|
|
2696
|
-
|
|
2697
|
-
// 'atd-active' is in the initial locus but is not visible (no hash tree)
|
|
2698
|
-
// Send heartbeat mentioning a non-visible dataset
|
|
2699
|
-
const heartbeatMessage = {
|
|
2700
|
-
dataSets: [
|
|
2701
|
-
{
|
|
2702
|
-
...createDataSet('main', 16, 1100),
|
|
2703
|
-
root: parser.dataSets.main.hashTree.getRootHash(),
|
|
2704
|
-
},
|
|
2705
|
-
createDataSet('atd-active', 16, 4000),
|
|
2706
|
-
],
|
|
2707
|
-
visibleDataSetsUrl,
|
|
2708
|
-
locusUrl,
|
|
2709
|
-
heartbeatIntervalMs,
|
|
2710
|
-
};
|
|
2711
3516
|
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
// Watchdog set for main (visible) but not for atd-active (no hash tree)
|
|
3517
|
+
// And the watchdog should still be running
|
|
2715
3518
|
expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
|
|
2716
|
-
expect(parser.dataSets['atd-active']?.heartbeatWatchdogTimer).to.be.undefined;
|
|
2717
3519
|
});
|
|
2718
3520
|
});
|
|
3521
|
+
|
|
2719
3522
|
});
|
|
2720
3523
|
|
|
2721
3524
|
describe('#callLocusInfoUpdateCallback filtering', () => {
|
|
@@ -2812,7 +3615,7 @@ describe('HashTreeParser', () => {
|
|
|
2812
3615
|
parser.handleMessage(updateMessage, 'update with newer version');
|
|
2813
3616
|
|
|
2814
3617
|
// Callback should be called with the update
|
|
2815
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
3618
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
2816
3619
|
updatedObjects: [
|
|
2817
3620
|
{
|
|
2818
3621
|
htMeta: {
|
|
@@ -2883,7 +3686,7 @@ describe('HashTreeParser', () => {
|
|
|
2883
3686
|
parser.handleMessage(removalMessage, 'removal of non-existent object');
|
|
2884
3687
|
|
|
2885
3688
|
// Callback should be called with the removal
|
|
2886
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
3689
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
2887
3690
|
updatedObjects: [
|
|
2888
3691
|
{
|
|
2889
3692
|
htMeta: {
|
|
@@ -3018,7 +3821,7 @@ describe('HashTreeParser', () => {
|
|
|
3018
3821
|
parser.handleMessage(mixedMessage, 'mixed updates');
|
|
3019
3822
|
|
|
3020
3823
|
// Callback should be called with only the valid updates (participant 1 v110 and participant 3 v10)
|
|
3021
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
3824
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
|
|
3022
3825
|
updatedObjects: [
|
|
3023
3826
|
{
|
|
3024
3827
|
htMeta: {
|
|
@@ -3196,9 +3999,7 @@ describe('HashTreeParser', () => {
|
|
|
3196
3999
|
parser.handleMessage(sentinelMessage as any, 'sentinel message');
|
|
3197
4000
|
|
|
3198
4001
|
// Callback should be called with MEETING_ENDED
|
|
3199
|
-
assert.calledOnceWithExactly(callback, LocusInfoUpdateType.MEETING_ENDED
|
|
3200
|
-
updatedObjects: undefined,
|
|
3201
|
-
});
|
|
4002
|
+
assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
|
|
3202
4003
|
});
|
|
3203
4004
|
});
|
|
3204
4005
|
|
|
@@ -3404,7 +4205,7 @@ describe('HashTreeParser', () => {
|
|
|
3404
4205
|
});
|
|
3405
4206
|
});
|
|
3406
4207
|
|
|
3407
|
-
describe('#
|
|
4208
|
+
describe('#resumeFromMessage', () => {
|
|
3408
4209
|
const createResumeMessage = (visibleDataSets?, dataSets?) => ({
|
|
3409
4210
|
locusUrl,
|
|
3410
4211
|
visibleDataSetsUrl,
|
|
@@ -3431,7 +4232,7 @@ describe('HashTreeParser', () => {
|
|
|
3431
4232
|
|
|
3432
4233
|
expect(parser.state).to.equal('stopped');
|
|
3433
4234
|
|
|
3434
|
-
parser.
|
|
4235
|
+
parser.resumeFromMessage(createResumeMessage());
|
|
3435
4236
|
|
|
3436
4237
|
expect(parser.state).to.equal('active');
|
|
3437
4238
|
});
|
|
@@ -3440,7 +4241,7 @@ describe('HashTreeParser', () => {
|
|
|
3440
4241
|
const parser = createHashTreeParser();
|
|
3441
4242
|
parser.stop();
|
|
3442
4243
|
|
|
3443
|
-
parser.
|
|
4244
|
+
parser.resumeFromMessage({
|
|
3444
4245
|
locusUrl,
|
|
3445
4246
|
visibleDataSetsUrl,
|
|
3446
4247
|
dataSets: [createDataSet('main', 16, 2000)],
|
|
@@ -3459,7 +4260,7 @@ describe('HashTreeParser', () => {
|
|
|
3459
4260
|
createDataSet('self', 2, 6000),
|
|
3460
4261
|
];
|
|
3461
4262
|
|
|
3462
|
-
parser.
|
|
4263
|
+
parser.resumeFromMessage(createResumeMessage(undefined, newDataSets));
|
|
3463
4264
|
|
|
3464
4265
|
expect(Object.keys(parser.dataSets)).to.have.lengthOf(2);
|
|
3465
4266
|
expect(parser.dataSets.main.leafCount).to.equal(8);
|
|
@@ -3481,7 +4282,7 @@ describe('HashTreeParser', () => {
|
|
|
3481
4282
|
{name: 'self', url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self'},
|
|
3482
4283
|
];
|
|
3483
4284
|
|
|
3484
|
-
parser.
|
|
4285
|
+
parser.resumeFromMessage(createResumeMessage(visibleDataSets, dataSets));
|
|
3485
4286
|
|
|
3486
4287
|
expect(parser.dataSets.main.hashTree).to.be.instanceOf(HashTree);
|
|
3487
4288
|
expect(parser.dataSets.self.hashTree).to.be.instanceOf(HashTree);
|
|
@@ -3495,7 +4296,7 @@ describe('HashTreeParser', () => {
|
|
|
3495
4296
|
const handleMessageStub = sinon.stub(parser, 'handleMessage');
|
|
3496
4297
|
|
|
3497
4298
|
const message = createResumeMessage();
|
|
3498
|
-
parser.
|
|
4299
|
+
parser.resumeFromMessage(message);
|
|
3499
4300
|
|
|
3500
4301
|
assert.calledOnceWithExactly(handleMessageStub, message, 'on resume');
|
|
3501
4302
|
});
|
|
@@ -3515,7 +4316,7 @@ describe('HashTreeParser', () => {
|
|
|
3515
4316
|
{name: 'atd-unmuted', url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/atd-unmuted'},
|
|
3516
4317
|
];
|
|
3517
4318
|
|
|
3518
|
-
parser.
|
|
4319
|
+
parser.resumeFromMessage(createResumeMessage(visibleDataSets, dataSets));
|
|
3519
4320
|
|
|
3520
4321
|
expect(parser.visibleDataSets.some((vds) => vds.name === 'atd-unmuted')).to.be.false;
|
|
3521
4322
|
expect(parser.visibleDataSets.some((vds) => vds.name === 'main')).to.be.true;
|
|
@@ -3523,6 +4324,67 @@ describe('HashTreeParser', () => {
|
|
|
3523
4324
|
});
|
|
3524
4325
|
});
|
|
3525
4326
|
|
|
4327
|
+
describe('#resumeFromApiResponse', () => {
|
|
4328
|
+
const exampleLocus = {
|
|
4329
|
+
participants: [],
|
|
4330
|
+
} as any;
|
|
4331
|
+
|
|
4332
|
+
it('should set state to active', async () => {
|
|
4333
|
+
const parser = createHashTreeParser();
|
|
4334
|
+
parser.stop();
|
|
4335
|
+
|
|
4336
|
+
expect(parser.state).to.equal('stopped');
|
|
4337
|
+
|
|
4338
|
+
sinon.stub(parser, 'initializeFromGetLociResponse').resolves();
|
|
4339
|
+
|
|
4340
|
+
await parser.resumeFromApiResponse(exampleLocus);
|
|
4341
|
+
|
|
4342
|
+
expect(parser.state).to.equal('active');
|
|
4343
|
+
});
|
|
4344
|
+
|
|
4345
|
+
it('should reset dataSets to empty', async () => {
|
|
4346
|
+
const parser = createHashTreeParser();
|
|
4347
|
+
|
|
4348
|
+
expect(Object.keys(parser.dataSets).length).to.be.greaterThan(0);
|
|
4349
|
+
|
|
4350
|
+
parser.stop();
|
|
4351
|
+
|
|
4352
|
+
sinon.stub(parser, 'initializeFromGetLociResponse').resolves();
|
|
4353
|
+
|
|
4354
|
+
await parser.resumeFromApiResponse(exampleLocus);
|
|
4355
|
+
|
|
4356
|
+
expect(parser.dataSets).to.deep.equal({});
|
|
4357
|
+
});
|
|
4358
|
+
|
|
4359
|
+
it('should call initializeFromGetLociResponse with the provided locus', async () => {
|
|
4360
|
+
const parser = createHashTreeParser();
|
|
4361
|
+
parser.stop();
|
|
4362
|
+
|
|
4363
|
+
const initStub = sinon.stub(parser, 'initializeFromGetLociResponse').resolves();
|
|
4364
|
+
|
|
4365
|
+
await parser.resumeFromApiResponse(exampleLocus);
|
|
4366
|
+
|
|
4367
|
+
assert.calledOnceWithExactly(initStub, exampleLocus);
|
|
4368
|
+
});
|
|
4369
|
+
|
|
4370
|
+
it('should propagate errors from initializeFromGetLociResponse', async () => {
|
|
4371
|
+
const parser = createHashTreeParser();
|
|
4372
|
+
parser.stop();
|
|
4373
|
+
|
|
4374
|
+
const error = new Error('initialization failed');
|
|
4375
|
+
const initStub = sinon.stub(parser, 'initializeFromGetLociResponse').rejects(error);
|
|
4376
|
+
|
|
4377
|
+
let caughtError: Error | undefined;
|
|
4378
|
+
try {
|
|
4379
|
+
await parser.resumeFromApiResponse(exampleLocus);
|
|
4380
|
+
} catch (e) {
|
|
4381
|
+
caughtError = e;
|
|
4382
|
+
}
|
|
4383
|
+
|
|
4384
|
+
expect(caughtError).to.equal(error);
|
|
4385
|
+
});
|
|
4386
|
+
});
|
|
4387
|
+
|
|
3526
4388
|
describe('#handleLocusUpdate when stopped', () => {
|
|
3527
4389
|
it('should return early without processing when parser is stopped', () => {
|
|
3528
4390
|
const parser = createHashTreeParser();
|
|
@@ -3557,4 +4419,343 @@ describe('HashTreeParser', () => {
|
|
|
3557
4419
|
assert.notCalled(callback);
|
|
3558
4420
|
});
|
|
3559
4421
|
});
|
|
4422
|
+
|
|
4423
|
+
describe('#syncAllDatasets', () => {
|
|
4424
|
+
it('should sync all datasets that have hash trees in priority order', async () => {
|
|
4425
|
+
const parser = createHashTreeParser();
|
|
4426
|
+
|
|
4427
|
+
// parser starts with main (leafCount=16) and self (leafCount=1) as visible datasets with hash trees
|
|
4428
|
+
// atd-unmuted has no hash tree (not visible)
|
|
4429
|
+
expect(parser.dataSets.main.hashTree).to.be.instanceOf(HashTree);
|
|
4430
|
+
expect(parser.dataSets.self.hashTree).to.be.instanceOf(HashTree);
|
|
4431
|
+
|
|
4432
|
+
const mainUrl = parser.dataSets.main.url;
|
|
4433
|
+
const selfUrl = parser.dataSets.self.url;
|
|
4434
|
+
|
|
4435
|
+
// Mock GET hashtree for main (leafCount > 1, so it does GET first)
|
|
4436
|
+
mockGetHashesFromLocusResponse(
|
|
4437
|
+
mainUrl,
|
|
4438
|
+
new Array(16).fill(EMPTY_HASH),
|
|
4439
|
+
createDataSet('main', 16, 1100)
|
|
4440
|
+
);
|
|
4441
|
+
|
|
4442
|
+
// Mock POST sync for main - return matching root hash so no further sync needed
|
|
4443
|
+
const mainSyncDataSet = createDataSet('main', 16, 1100);
|
|
4444
|
+
mainSyncDataSet.root = parser.dataSets.main.hashTree.getRootHash();
|
|
4445
|
+
mockSendSyncRequestResponse(mainUrl, {
|
|
4446
|
+
dataSets: [mainSyncDataSet],
|
|
4447
|
+
visibleDataSetsUrl,
|
|
4448
|
+
locusUrl,
|
|
4449
|
+
locusStateElements: [],
|
|
4450
|
+
});
|
|
4451
|
+
|
|
4452
|
+
// Mock POST sync for self (leafCount=1, skips GET hashtree)
|
|
4453
|
+
const selfSyncDataSet = createDataSet('self', 1, 2100);
|
|
4454
|
+
selfSyncDataSet.root = parser.dataSets.self.hashTree.getRootHash();
|
|
4455
|
+
mockSendSyncRequestResponse(selfUrl, {
|
|
4456
|
+
dataSets: [selfSyncDataSet],
|
|
4457
|
+
visibleDataSetsUrl,
|
|
4458
|
+
locusUrl,
|
|
4459
|
+
locusStateElements: [],
|
|
4460
|
+
});
|
|
4461
|
+
|
|
4462
|
+
await parser.syncAllDatasets();
|
|
4463
|
+
|
|
4464
|
+
// Verify GET hashtree was called for main only (not self, because leafCount=1)
|
|
4465
|
+
assert.calledWith(webexRequest, sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}));
|
|
4466
|
+
assert.neverCalledWith(webexRequest, sinon.match({method: 'GET', uri: `${selfUrl}/hashtree`}));
|
|
4467
|
+
|
|
4468
|
+
// Verify POST sync was called for both
|
|
4469
|
+
assert.calledWith(webexRequest, sinon.match({method: 'POST', uri: `${mainUrl}/sync`}));
|
|
4470
|
+
assert.calledWith(webexRequest, sinon.match({method: 'POST', uri: `${selfUrl}/sync`}));
|
|
4471
|
+
|
|
4472
|
+
// Verify main was synced before self (priority order)
|
|
4473
|
+
const mainSyncCallIndex = webexRequest.args.findIndex(
|
|
4474
|
+
(args) => args[0]?.method === 'GET' && args[0]?.uri === `${mainUrl}/hashtree`
|
|
4475
|
+
);
|
|
4476
|
+
const selfSyncCallIndex = webexRequest.args.findIndex(
|
|
4477
|
+
(args) => args[0]?.method === 'POST' && args[0]?.uri === `${selfUrl}/sync`
|
|
4478
|
+
);
|
|
4479
|
+
expect(mainSyncCallIndex).to.be.lessThan(selfSyncCallIndex);
|
|
4480
|
+
|
|
4481
|
+
// Verify isSyncAllInProgress is reset
|
|
4482
|
+
expect(parser.isSyncAllInProgress).to.be.false;
|
|
4483
|
+
});
|
|
4484
|
+
|
|
4485
|
+
it('should return immediately when state is stopped', async () => {
|
|
4486
|
+
const parser = createHashTreeParser();
|
|
4487
|
+
parser.stop();
|
|
4488
|
+
|
|
4489
|
+
await parser.syncAllDatasets();
|
|
4490
|
+
|
|
4491
|
+
// No sync requests should have been made (only the initial sync from constructor)
|
|
4492
|
+
// Reset history to clear constructor calls then verify
|
|
4493
|
+
const callCountBefore = webexRequest.callCount;
|
|
4494
|
+
await parser.syncAllDatasets();
|
|
4495
|
+
assert.equal(webexRequest.callCount, callCountBefore);
|
|
4496
|
+
});
|
|
4497
|
+
|
|
4498
|
+
it('should guard against concurrent calls', async () => {
|
|
4499
|
+
const parser = createHashTreeParser();
|
|
4500
|
+
|
|
4501
|
+
const mainUrl = parser.dataSets.main.url;
|
|
4502
|
+
const selfUrl = parser.dataSets.self.url;
|
|
4503
|
+
|
|
4504
|
+
// Use a deferred promise for the main sync to control timing
|
|
4505
|
+
let resolveMainSync;
|
|
4506
|
+
webexRequest
|
|
4507
|
+
.withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}))
|
|
4508
|
+
.returns(new Promise((resolve) => { resolveMainSync = resolve; }));
|
|
4509
|
+
|
|
4510
|
+
mockSendSyncRequestResponse(mainUrl, {
|
|
4511
|
+
dataSets: [createDataSet('main', 16, 1100)],
|
|
4512
|
+
visibleDataSetsUrl,
|
|
4513
|
+
locusUrl,
|
|
4514
|
+
locusStateElements: [],
|
|
4515
|
+
});
|
|
4516
|
+
|
|
4517
|
+
mockSendSyncRequestResponse(selfUrl, {
|
|
4518
|
+
dataSets: [createDataSet('self', 1, 2100)],
|
|
4519
|
+
visibleDataSetsUrl,
|
|
4520
|
+
locusUrl,
|
|
4521
|
+
locusStateElements: [],
|
|
4522
|
+
});
|
|
4523
|
+
|
|
4524
|
+
// Start first call
|
|
4525
|
+
const promise1 = parser.syncAllDatasets();
|
|
4526
|
+
// Start second call while first is in progress
|
|
4527
|
+
const promise2 = parser.syncAllDatasets();
|
|
4528
|
+
|
|
4529
|
+
// Resolve the pending request
|
|
4530
|
+
resolveMainSync({
|
|
4531
|
+
body: {
|
|
4532
|
+
hashes: new Array(16).fill(EMPTY_HASH),
|
|
4533
|
+
dataSet: createDataSet('main', 16, 1100),
|
|
4534
|
+
},
|
|
4535
|
+
});
|
|
4536
|
+
|
|
4537
|
+
await promise1;
|
|
4538
|
+
await promise2;
|
|
4539
|
+
|
|
4540
|
+
// GET hashtree for main should only be called once (second syncAllDatasets returned immediately)
|
|
4541
|
+
const getHashtreeCalls = webexRequest.args.filter(
|
|
4542
|
+
(args) => args[0]?.method === 'GET' && args[0]?.uri === `${mainUrl}/hashtree`
|
|
4543
|
+
);
|
|
4544
|
+
expect(getHashtreeCalls).to.have.lengthOf(1);
|
|
4545
|
+
});
|
|
4546
|
+
|
|
4547
|
+
it('should skip datasets that do not have a hash tree', async () => {
|
|
4548
|
+
// Create parser with metadata that only has main and self as visible (not atd-unmuted)
|
|
4549
|
+
const metadataWithoutAtd = {
|
|
4550
|
+
...exampleMetadata,
|
|
4551
|
+
visibleDataSets: exampleMetadata.visibleDataSets.filter((ds) => ds.name !== 'atd-unmuted'),
|
|
4552
|
+
};
|
|
4553
|
+
const parser = createHashTreeParser(exampleInitialLocus, metadataWithoutAtd);
|
|
4554
|
+
|
|
4555
|
+
// atd-unmuted is in dataSets but has no hashTree (not visible)
|
|
4556
|
+
expect(parser.dataSets['atd-unmuted']).to.exist;
|
|
4557
|
+
expect(parser.dataSets['atd-unmuted'].hashTree).to.be.undefined;
|
|
4558
|
+
|
|
4559
|
+
const atdUrl = parser.dataSets['atd-unmuted'].url;
|
|
4560
|
+
const mainUrl = parser.dataSets.main.url;
|
|
4561
|
+
const selfUrl = parser.dataSets.self.url;
|
|
4562
|
+
|
|
4563
|
+
mockGetHashesFromLocusResponse(
|
|
4564
|
+
mainUrl,
|
|
4565
|
+
new Array(16).fill(EMPTY_HASH),
|
|
4566
|
+
createDataSet('main', 16, 1100)
|
|
4567
|
+
);
|
|
4568
|
+
|
|
4569
|
+
const mainSyncDs = createDataSet('main', 16, 1100);
|
|
4570
|
+
mainSyncDs.root = parser.dataSets.main.hashTree.getRootHash();
|
|
4571
|
+
mockSendSyncRequestResponse(mainUrl, {
|
|
4572
|
+
dataSets: [mainSyncDs],
|
|
4573
|
+
visibleDataSetsUrl,
|
|
4574
|
+
locusUrl,
|
|
4575
|
+
locusStateElements: [],
|
|
4576
|
+
});
|
|
4577
|
+
|
|
4578
|
+
const selfSyncDs = createDataSet('self', 1, 2100);
|
|
4579
|
+
selfSyncDs.root = parser.dataSets.self.hashTree.getRootHash();
|
|
4580
|
+
mockSendSyncRequestResponse(selfUrl, {
|
|
4581
|
+
dataSets: [selfSyncDs],
|
|
4582
|
+
visibleDataSetsUrl,
|
|
4583
|
+
locusUrl,
|
|
4584
|
+
locusStateElements: [],
|
|
4585
|
+
});
|
|
4586
|
+
|
|
4587
|
+
await parser.syncAllDatasets();
|
|
4588
|
+
|
|
4589
|
+
// No requests should have been made for atd-unmuted
|
|
4590
|
+
assert.neverCalledWith(webexRequest, sinon.match({uri: sinon.match(atdUrl)}));
|
|
4591
|
+
});
|
|
4592
|
+
});
|
|
4593
|
+
|
|
4594
|
+
describe('#handleMessage sync queue', () => {
|
|
4595
|
+
it('should deduplicate: not sync the same dataset twice when enqueued multiple times', async () => {
|
|
4596
|
+
const parser = createHashTreeParser();
|
|
4597
|
+
|
|
4598
|
+
const mainUrl = parser.dataSets.main.url;
|
|
4599
|
+
|
|
4600
|
+
// Setup mocks before triggering syncs
|
|
4601
|
+
mockGetHashesFromLocusResponse(
|
|
4602
|
+
mainUrl,
|
|
4603
|
+
new Array(16).fill(EMPTY_HASH),
|
|
4604
|
+
createDataSet('main', 16, 1101)
|
|
4605
|
+
);
|
|
4606
|
+
|
|
4607
|
+
const mainSyncDs = createDataSet('main', 16, 1101);
|
|
4608
|
+
mainSyncDs.root = parser.dataSets.main.hashTree.getRootHash();
|
|
4609
|
+
mockSendSyncRequestResponse(mainUrl, {
|
|
4610
|
+
dataSets: [mainSyncDs],
|
|
4611
|
+
visibleDataSetsUrl,
|
|
4612
|
+
locusUrl,
|
|
4613
|
+
locusStateElements: [],
|
|
4614
|
+
});
|
|
4615
|
+
|
|
4616
|
+
// Send two heartbeat messages (no locusStateElements) with different root hashes for main
|
|
4617
|
+
parser.handleMessage(createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'), 'first');
|
|
4618
|
+
parser.handleMessage(createHeartbeatMessage('main', 16, 1101, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa2'), 'second');
|
|
4619
|
+
|
|
4620
|
+
// The second call resets the timer. After 1000ms, only one sync fires.
|
|
4621
|
+
await clock.tickAsync(1000);
|
|
4622
|
+
|
|
4623
|
+
// Only one GET hashtree call should have been made for main
|
|
4624
|
+
const getHashtreeCalls = webexRequest.args.filter(
|
|
4625
|
+
(args) => args[0]?.method === 'GET' && args[0]?.uri === `${mainUrl}/hashtree`
|
|
4626
|
+
);
|
|
4627
|
+
expect(getHashtreeCalls).to.have.lengthOf(1);
|
|
4628
|
+
});
|
|
4629
|
+
|
|
4630
|
+
it('should stop processing the sync queue when parser is stopped mid-queue', async () => {
|
|
4631
|
+
const parser = createHashTreeParser();
|
|
4632
|
+
|
|
4633
|
+
const mainUrl = parser.dataSets.main.url;
|
|
4634
|
+
const selfUrl = parser.dataSets.self.url;
|
|
4635
|
+
|
|
4636
|
+
// Mock main GET hashtree with a deferred promise so we can control when it resolves
|
|
4637
|
+
let resolveMainHashtree;
|
|
4638
|
+
webexRequest
|
|
4639
|
+
.withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}))
|
|
4640
|
+
.callsFake(() => new Promise((resolve) => { resolveMainHashtree = resolve; }));
|
|
4641
|
+
|
|
4642
|
+
// Send a heartbeat message that triggers sync timers for both main and self
|
|
4643
|
+
parser.handleMessage(
|
|
4644
|
+
createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
|
|
4645
|
+
'trigger main sync'
|
|
4646
|
+
);
|
|
4647
|
+
parser.handleMessage(
|
|
4648
|
+
createHeartbeatMessage('self', 1, 2100, 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1'),
|
|
4649
|
+
'trigger self sync'
|
|
4650
|
+
);
|
|
4651
|
+
|
|
4652
|
+
// Fire the timers - main sync starts (calls GET hashtree, which blocks)
|
|
4653
|
+
await clock.tickAsync(1000);
|
|
4654
|
+
|
|
4655
|
+
// Stop the parser while main sync is in progress
|
|
4656
|
+
parser.stop();
|
|
4657
|
+
|
|
4658
|
+
// Resolve the pending main GET request
|
|
4659
|
+
resolveMainHashtree({
|
|
4660
|
+
body: {
|
|
4661
|
+
hashes: new Array(16).fill(EMPTY_HASH),
|
|
4662
|
+
dataSet: createDataSet('main', 16, 1100),
|
|
4663
|
+
},
|
|
4664
|
+
});
|
|
4665
|
+
|
|
4666
|
+
await clock.tickAsync(0);
|
|
4667
|
+
|
|
4668
|
+
// Self sync should NOT have been triggered because parser was stopped
|
|
4669
|
+
assert.neverCalledWith(webexRequest, sinon.match({method: 'POST', uri: `${selfUrl}/sync`}));
|
|
4670
|
+
assert.neverCalledWith(webexRequest, sinon.match({method: 'GET', uri: `${selfUrl}/hashtree`}));
|
|
4671
|
+
});
|
|
4672
|
+
});
|
|
4673
|
+
|
|
4674
|
+
describe('#stop sync queue', () => {
|
|
4675
|
+
it('should clear the syncQueue when stopped so remaining queued items are not processed', async () => {
|
|
4676
|
+
const parser = createHashTreeParser();
|
|
4677
|
+
|
|
4678
|
+
const mainUrl = parser.dataSets.main.url;
|
|
4679
|
+
const selfUrl = parser.dataSets.self.url;
|
|
4680
|
+
|
|
4681
|
+
// Mock main GET hashtree with a deferred promise so we can control when it resolves
|
|
4682
|
+
let resolveMainHashtree;
|
|
4683
|
+
webexRequest
|
|
4684
|
+
.withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}))
|
|
4685
|
+
.callsFake(() => new Promise((resolve) => { resolveMainHashtree = resolve; }));
|
|
4686
|
+
|
|
4687
|
+
// Enqueue syncs for both main and self by sending heartbeat messages
|
|
4688
|
+
parser.handleMessage(
|
|
4689
|
+
createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
|
|
4690
|
+
'trigger main sync'
|
|
4691
|
+
);
|
|
4692
|
+
parser.handleMessage(
|
|
4693
|
+
createHeartbeatMessage('self', 1, 2100, 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1'),
|
|
4694
|
+
'trigger self sync'
|
|
4695
|
+
);
|
|
4696
|
+
|
|
4697
|
+
// Fire the timers - main sync starts and blocks on GET hashtree
|
|
4698
|
+
await clock.tickAsync(1000);
|
|
4699
|
+
|
|
4700
|
+
// Verify that self is still in the queue (main is being processed, self is waiting)
|
|
4701
|
+
// Now stop the parser - this should clear the syncQueue
|
|
4702
|
+
parser.stop();
|
|
4703
|
+
|
|
4704
|
+
// Resolve the pending main GET request so the in-flight sync can finish
|
|
4705
|
+
resolveMainHashtree({
|
|
4706
|
+
body: {
|
|
4707
|
+
hashes: new Array(16).fill(EMPTY_HASH),
|
|
4708
|
+
dataSet: createDataSet('main', 16, 1100),
|
|
4709
|
+
},
|
|
4710
|
+
});
|
|
4711
|
+
|
|
4712
|
+
await clock.tickAsync(0);
|
|
4713
|
+
|
|
4714
|
+
// Self should never have been synced because stop() cleared the queue
|
|
4715
|
+
const selfGetCalls = webexRequest.args.filter(
|
|
4716
|
+
(args) => args[0]?.method === 'GET' && args[0]?.uri === `${selfUrl}/hashtree`
|
|
4717
|
+
);
|
|
4718
|
+
expect(selfGetCalls).to.have.lengthOf(0);
|
|
4719
|
+
});
|
|
4720
|
+
});
|
|
4721
|
+
|
|
4722
|
+
describe('#cleanUp', () => {
|
|
4723
|
+
it('should stop the parser, clear all timers and clear all dataSets', () => {
|
|
4724
|
+
const parser = createHashTreeParser();
|
|
4725
|
+
|
|
4726
|
+
// Send a message to set up sync timers via runSyncAlgorithm
|
|
4727
|
+
const message = {
|
|
4728
|
+
dataSets: [
|
|
4729
|
+
{
|
|
4730
|
+
...createDataSet('main', 16, 1100),
|
|
4731
|
+
root: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1',
|
|
4732
|
+
},
|
|
4733
|
+
],
|
|
4734
|
+
visibleDataSetsUrl,
|
|
4735
|
+
locusUrl,
|
|
4736
|
+
heartbeatIntervalMs: 5000,
|
|
4737
|
+
locusStateElements: [
|
|
4738
|
+
{
|
|
4739
|
+
htMeta: {
|
|
4740
|
+
elementId: {type: 'locus' as const, id: 0, version: 201},
|
|
4741
|
+
dataSetNames: ['main'],
|
|
4742
|
+
},
|
|
4743
|
+
data: {someData: 'value'},
|
|
4744
|
+
},
|
|
4745
|
+
],
|
|
4746
|
+
};
|
|
4747
|
+
|
|
4748
|
+
parser.handleMessage(message, 'setup timers');
|
|
4749
|
+
|
|
4750
|
+
// Verify timers were set by handleMessage
|
|
4751
|
+
expect(parser.dataSets.main.timer).to.not.be.undefined;
|
|
4752
|
+
expect(parser.dataSets.main.heartbeatWatchdogTimer).to.not.be.undefined;
|
|
4753
|
+
|
|
4754
|
+
parser.cleanUp();
|
|
4755
|
+
|
|
4756
|
+
expect(parser.state).to.equal('stopped');
|
|
4757
|
+
expect(parser.visibleDataSets).to.deep.equal([]);
|
|
4758
|
+
expect(parser.dataSets).to.deep.equal({});
|
|
4759
|
+
});
|
|
4760
|
+
});
|
|
3560
4761
|
});
|