@webex/plugin-meetings 3.12.0-next.21 → 3.12.0-next.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -567,6 +567,11 @@ describe('HashTreeParser', () => {
567
567
  },
568
568
  data: {info: {id: 'some-fake-locus-info'}},
569
569
  },
570
+ ],
571
+ });
572
+
573
+ assert.calledWith(callback, {updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
574
+ updatedObjects: [
570
575
  {
571
576
  htMeta: {
572
577
  elementId: {
@@ -3775,7 +3780,7 @@ describe('HashTreeParser', () => {
3775
3780
  });
3776
3781
  });
3777
3782
 
3778
- describe('#resume', () => {
3783
+ describe('#resumeFromMessage', () => {
3779
3784
  const createResumeMessage = (visibleDataSets?, dataSets?) => ({
3780
3785
  locusUrl,
3781
3786
  visibleDataSetsUrl,
@@ -3802,7 +3807,7 @@ describe('HashTreeParser', () => {
3802
3807
 
3803
3808
  expect(parser.state).to.equal('stopped');
3804
3809
 
3805
- parser.resume(createResumeMessage());
3810
+ parser.resumeFromMessage(createResumeMessage());
3806
3811
 
3807
3812
  expect(parser.state).to.equal('active');
3808
3813
  });
@@ -3811,7 +3816,7 @@ describe('HashTreeParser', () => {
3811
3816
  const parser = createHashTreeParser();
3812
3817
  parser.stop();
3813
3818
 
3814
- parser.resume({
3819
+ parser.resumeFromMessage({
3815
3820
  locusUrl,
3816
3821
  visibleDataSetsUrl,
3817
3822
  dataSets: [createDataSet('main', 16, 2000)],
@@ -3830,7 +3835,7 @@ describe('HashTreeParser', () => {
3830
3835
  createDataSet('self', 2, 6000),
3831
3836
  ];
3832
3837
 
3833
- parser.resume(createResumeMessage(undefined, newDataSets));
3838
+ parser.resumeFromMessage(createResumeMessage(undefined, newDataSets));
3834
3839
 
3835
3840
  expect(Object.keys(parser.dataSets)).to.have.lengthOf(2);
3836
3841
  expect(parser.dataSets.main.leafCount).to.equal(8);
@@ -3852,7 +3857,7 @@ describe('HashTreeParser', () => {
3852
3857
  {name: 'self', url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/participant/713e9f99/datasets/self'},
3853
3858
  ];
3854
3859
 
3855
- parser.resume(createResumeMessage(visibleDataSets, dataSets));
3860
+ parser.resumeFromMessage(createResumeMessage(visibleDataSets, dataSets));
3856
3861
 
3857
3862
  expect(parser.dataSets.main.hashTree).to.be.instanceOf(HashTree);
3858
3863
  expect(parser.dataSets.self.hashTree).to.be.instanceOf(HashTree);
@@ -3866,7 +3871,7 @@ describe('HashTreeParser', () => {
3866
3871
  const handleMessageStub = sinon.stub(parser, 'handleMessage');
3867
3872
 
3868
3873
  const message = createResumeMessage();
3869
- parser.resume(message);
3874
+ parser.resumeFromMessage(message);
3870
3875
 
3871
3876
  assert.calledOnceWithExactly(handleMessageStub, message, 'on resume');
3872
3877
  });
@@ -3886,7 +3891,7 @@ describe('HashTreeParser', () => {
3886
3891
  {name: 'atd-unmuted', url: 'https://locus-a.wbx2.com/locus/api/v1/loci/97d64a5f/datasets/atd-unmuted'},
3887
3892
  ];
3888
3893
 
3889
- parser.resume(createResumeMessage(visibleDataSets, dataSets));
3894
+ parser.resumeFromMessage(createResumeMessage(visibleDataSets, dataSets));
3890
3895
 
3891
3896
  expect(parser.visibleDataSets.some((vds) => vds.name === 'atd-unmuted')).to.be.false;
3892
3897
  expect(parser.visibleDataSets.some((vds) => vds.name === 'main')).to.be.true;
@@ -3894,6 +3899,67 @@ describe('HashTreeParser', () => {
3894
3899
  });
3895
3900
  });
3896
3901
 
3902
+ describe('#resumeFromApiResponse', () => {
3903
+ const exampleLocus = {
3904
+ participants: [],
3905
+ } as any;
3906
+
3907
+ it('should set state to active', async () => {
3908
+ const parser = createHashTreeParser();
3909
+ parser.stop();
3910
+
3911
+ expect(parser.state).to.equal('stopped');
3912
+
3913
+ sinon.stub(parser, 'initializeFromGetLociResponse').resolves();
3914
+
3915
+ await parser.resumeFromApiResponse(exampleLocus);
3916
+
3917
+ expect(parser.state).to.equal('active');
3918
+ });
3919
+
3920
+ it('should reset dataSets to empty', async () => {
3921
+ const parser = createHashTreeParser();
3922
+
3923
+ expect(Object.keys(parser.dataSets).length).to.be.greaterThan(0);
3924
+
3925
+ parser.stop();
3926
+
3927
+ sinon.stub(parser, 'initializeFromGetLociResponse').resolves();
3928
+
3929
+ await parser.resumeFromApiResponse(exampleLocus);
3930
+
3931
+ expect(parser.dataSets).to.deep.equal({});
3932
+ });
3933
+
3934
+ it('should call initializeFromGetLociResponse with the provided locus', async () => {
3935
+ const parser = createHashTreeParser();
3936
+ parser.stop();
3937
+
3938
+ const initStub = sinon.stub(parser, 'initializeFromGetLociResponse').resolves();
3939
+
3940
+ await parser.resumeFromApiResponse(exampleLocus);
3941
+
3942
+ assert.calledOnceWithExactly(initStub, exampleLocus);
3943
+ });
3944
+
3945
+ it('should propagate errors from initializeFromGetLociResponse', async () => {
3946
+ const parser = createHashTreeParser();
3947
+ parser.stop();
3948
+
3949
+ const error = new Error('initialization failed');
3950
+ const initStub = sinon.stub(parser, 'initializeFromGetLociResponse').rejects(error);
3951
+
3952
+ let caughtError: Error | undefined;
3953
+ try {
3954
+ await parser.resumeFromApiResponse(exampleLocus);
3955
+ } catch (e) {
3956
+ caughtError = e;
3957
+ }
3958
+
3959
+ expect(caughtError).to.equal(error);
3960
+ });
3961
+ });
3962
+
3897
3963
  describe('#handleLocusUpdate when stopped', () => {
3898
3964
  it('should return early without processing when parser is stopped', () => {
3899
3965
  const parser = createHashTreeParser();
@@ -3929,6 +3995,305 @@ describe('HashTreeParser', () => {
3929
3995
  });
3930
3996
  });
3931
3997
 
3998
+ describe('#syncAllDatasets', () => {
3999
+ it('should sync all datasets that have hash trees in priority order', async () => {
4000
+ const parser = createHashTreeParser();
4001
+
4002
+ // parser starts with main (leafCount=16) and self (leafCount=1) as visible datasets with hash trees
4003
+ // atd-unmuted has no hash tree (not visible)
4004
+ expect(parser.dataSets.main.hashTree).to.be.instanceOf(HashTree);
4005
+ expect(parser.dataSets.self.hashTree).to.be.instanceOf(HashTree);
4006
+
4007
+ const mainUrl = parser.dataSets.main.url;
4008
+ const selfUrl = parser.dataSets.self.url;
4009
+
4010
+ // Mock GET hashtree for main (leafCount > 1, so it does GET first)
4011
+ mockGetHashesFromLocusResponse(
4012
+ mainUrl,
4013
+ new Array(16).fill(EMPTY_HASH),
4014
+ createDataSet('main', 16, 1100)
4015
+ );
4016
+
4017
+ // Mock POST sync for main - return matching root hash so no further sync needed
4018
+ const mainSyncDataSet = createDataSet('main', 16, 1100);
4019
+ mainSyncDataSet.root = parser.dataSets.main.hashTree.getRootHash();
4020
+ mockSendSyncRequestResponse(mainUrl, {
4021
+ dataSets: [mainSyncDataSet],
4022
+ visibleDataSetsUrl,
4023
+ locusUrl,
4024
+ locusStateElements: [],
4025
+ });
4026
+
4027
+ // Mock POST sync for self (leafCount=1, skips GET hashtree)
4028
+ const selfSyncDataSet = createDataSet('self', 1, 2100);
4029
+ selfSyncDataSet.root = parser.dataSets.self.hashTree.getRootHash();
4030
+ mockSendSyncRequestResponse(selfUrl, {
4031
+ dataSets: [selfSyncDataSet],
4032
+ visibleDataSetsUrl,
4033
+ locusUrl,
4034
+ locusStateElements: [],
4035
+ });
4036
+
4037
+ await parser.syncAllDatasets();
4038
+
4039
+ // Verify GET hashtree was called for main only (not self, because leafCount=1)
4040
+ assert.calledWith(webexRequest, sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}));
4041
+ assert.neverCalledWith(webexRequest, sinon.match({method: 'GET', uri: `${selfUrl}/hashtree`}));
4042
+
4043
+ // Verify POST sync was called for both
4044
+ assert.calledWith(webexRequest, sinon.match({method: 'POST', uri: `${mainUrl}/sync`}));
4045
+ assert.calledWith(webexRequest, sinon.match({method: 'POST', uri: `${selfUrl}/sync`}));
4046
+
4047
+ // Verify main was synced before self (priority order)
4048
+ const mainSyncCallIndex = webexRequest.args.findIndex(
4049
+ (args) => args[0]?.method === 'GET' && args[0]?.uri === `${mainUrl}/hashtree`
4050
+ );
4051
+ const selfSyncCallIndex = webexRequest.args.findIndex(
4052
+ (args) => args[0]?.method === 'POST' && args[0]?.uri === `${selfUrl}/sync`
4053
+ );
4054
+ expect(mainSyncCallIndex).to.be.lessThan(selfSyncCallIndex);
4055
+
4056
+ // Verify isSyncAllInProgress is reset
4057
+ expect(parser.isSyncAllInProgress).to.be.false;
4058
+ });
4059
+
4060
+ it('should return immediately when state is stopped', async () => {
4061
+ const parser = createHashTreeParser();
4062
+ parser.stop();
4063
+
4064
+ await parser.syncAllDatasets();
4065
+
4066
+ // No sync requests should have been made (only the initial sync from constructor)
4067
+ // Reset history to clear constructor calls then verify
4068
+ const callCountBefore = webexRequest.callCount;
4069
+ await parser.syncAllDatasets();
4070
+ assert.equal(webexRequest.callCount, callCountBefore);
4071
+ });
4072
+
4073
+ it('should guard against concurrent calls', async () => {
4074
+ const parser = createHashTreeParser();
4075
+
4076
+ const mainUrl = parser.dataSets.main.url;
4077
+ const selfUrl = parser.dataSets.self.url;
4078
+
4079
+ // Use a deferred promise for the main sync to control timing
4080
+ let resolveMainSync;
4081
+ webexRequest
4082
+ .withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}))
4083
+ .returns(new Promise((resolve) => { resolveMainSync = resolve; }));
4084
+
4085
+ mockSendSyncRequestResponse(mainUrl, {
4086
+ dataSets: [createDataSet('main', 16, 1100)],
4087
+ visibleDataSetsUrl,
4088
+ locusUrl,
4089
+ locusStateElements: [],
4090
+ });
4091
+
4092
+ mockSendSyncRequestResponse(selfUrl, {
4093
+ dataSets: [createDataSet('self', 1, 2100)],
4094
+ visibleDataSetsUrl,
4095
+ locusUrl,
4096
+ locusStateElements: [],
4097
+ });
4098
+
4099
+ // Start first call
4100
+ const promise1 = parser.syncAllDatasets();
4101
+ // Start second call while first is in progress
4102
+ const promise2 = parser.syncAllDatasets();
4103
+
4104
+ // Resolve the pending request
4105
+ resolveMainSync({
4106
+ body: {
4107
+ hashes: new Array(16).fill(EMPTY_HASH),
4108
+ dataSet: createDataSet('main', 16, 1100),
4109
+ },
4110
+ });
4111
+
4112
+ await promise1;
4113
+ await promise2;
4114
+
4115
+ // GET hashtree for main should only be called once (second syncAllDatasets returned immediately)
4116
+ const getHashtreeCalls = webexRequest.args.filter(
4117
+ (args) => args[0]?.method === 'GET' && args[0]?.uri === `${mainUrl}/hashtree`
4118
+ );
4119
+ expect(getHashtreeCalls).to.have.lengthOf(1);
4120
+ });
4121
+
4122
+ it('should skip datasets that do not have a hash tree', async () => {
4123
+ // Create parser with metadata that only has main and self as visible (not atd-unmuted)
4124
+ const metadataWithoutAtd = {
4125
+ ...exampleMetadata,
4126
+ visibleDataSets: exampleMetadata.visibleDataSets.filter((ds) => ds.name !== 'atd-unmuted'),
4127
+ };
4128
+ const parser = createHashTreeParser(exampleInitialLocus, metadataWithoutAtd);
4129
+
4130
+ // atd-unmuted is in dataSets but has no hashTree (not visible)
4131
+ expect(parser.dataSets['atd-unmuted']).to.exist;
4132
+ expect(parser.dataSets['atd-unmuted'].hashTree).to.be.undefined;
4133
+
4134
+ const atdUrl = parser.dataSets['atd-unmuted'].url;
4135
+ const mainUrl = parser.dataSets.main.url;
4136
+ const selfUrl = parser.dataSets.self.url;
4137
+
4138
+ mockGetHashesFromLocusResponse(
4139
+ mainUrl,
4140
+ new Array(16).fill(EMPTY_HASH),
4141
+ createDataSet('main', 16, 1100)
4142
+ );
4143
+
4144
+ const mainSyncDs = createDataSet('main', 16, 1100);
4145
+ mainSyncDs.root = parser.dataSets.main.hashTree.getRootHash();
4146
+ mockSendSyncRequestResponse(mainUrl, {
4147
+ dataSets: [mainSyncDs],
4148
+ visibleDataSetsUrl,
4149
+ locusUrl,
4150
+ locusStateElements: [],
4151
+ });
4152
+
4153
+ const selfSyncDs = createDataSet('self', 1, 2100);
4154
+ selfSyncDs.root = parser.dataSets.self.hashTree.getRootHash();
4155
+ mockSendSyncRequestResponse(selfUrl, {
4156
+ dataSets: [selfSyncDs],
4157
+ visibleDataSetsUrl,
4158
+ locusUrl,
4159
+ locusStateElements: [],
4160
+ });
4161
+
4162
+ await parser.syncAllDatasets();
4163
+
4164
+ // No requests should have been made for atd-unmuted
4165
+ assert.neverCalledWith(webexRequest, sinon.match({uri: sinon.match(atdUrl)}));
4166
+ });
4167
+ });
4168
+
4169
+ describe('#handleMessage sync queue', () => {
4170
+ it('should deduplicate: not sync the same dataset twice when enqueued multiple times', async () => {
4171
+ const parser = createHashTreeParser();
4172
+
4173
+ const mainUrl = parser.dataSets.main.url;
4174
+
4175
+ // Setup mocks before triggering syncs
4176
+ mockGetHashesFromLocusResponse(
4177
+ mainUrl,
4178
+ new Array(16).fill(EMPTY_HASH),
4179
+ createDataSet('main', 16, 1101)
4180
+ );
4181
+
4182
+ const mainSyncDs = createDataSet('main', 16, 1101);
4183
+ mainSyncDs.root = parser.dataSets.main.hashTree.getRootHash();
4184
+ mockSendSyncRequestResponse(mainUrl, {
4185
+ dataSets: [mainSyncDs],
4186
+ visibleDataSetsUrl,
4187
+ locusUrl,
4188
+ locusStateElements: [],
4189
+ });
4190
+
4191
+ // Send two heartbeat messages (no locusStateElements) with different root hashes for main
4192
+ parser.handleMessage(createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'), 'first');
4193
+ parser.handleMessage(createHeartbeatMessage('main', 16, 1101, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa2'), 'second');
4194
+
4195
+ // The second call resets the timer. After 1000ms, only one sync fires.
4196
+ await clock.tickAsync(1000);
4197
+
4198
+ // Only one GET hashtree call should have been made for main
4199
+ const getHashtreeCalls = webexRequest.args.filter(
4200
+ (args) => args[0]?.method === 'GET' && args[0]?.uri === `${mainUrl}/hashtree`
4201
+ );
4202
+ expect(getHashtreeCalls).to.have.lengthOf(1);
4203
+ });
4204
+
4205
+ it('should stop processing the sync queue when parser is stopped mid-queue', async () => {
4206
+ const parser = createHashTreeParser();
4207
+
4208
+ const mainUrl = parser.dataSets.main.url;
4209
+ const selfUrl = parser.dataSets.self.url;
4210
+
4211
+ // Mock main GET hashtree with a deferred promise so we can control when it resolves
4212
+ let resolveMainHashtree;
4213
+ webexRequest
4214
+ .withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}))
4215
+ .callsFake(() => new Promise((resolve) => { resolveMainHashtree = resolve; }));
4216
+
4217
+ // Send a heartbeat message that triggers sync timers for both main and self
4218
+ parser.handleMessage(
4219
+ createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
4220
+ 'trigger main sync'
4221
+ );
4222
+ parser.handleMessage(
4223
+ createHeartbeatMessage('self', 1, 2100, 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1'),
4224
+ 'trigger self sync'
4225
+ );
4226
+
4227
+ // Fire the timers - main sync starts (calls GET hashtree, which blocks)
4228
+ await clock.tickAsync(1000);
4229
+
4230
+ // Stop the parser while main sync is in progress
4231
+ parser.stop();
4232
+
4233
+ // Resolve the pending main GET request
4234
+ resolveMainHashtree({
4235
+ body: {
4236
+ hashes: new Array(16).fill(EMPTY_HASH),
4237
+ dataSet: createDataSet('main', 16, 1100),
4238
+ },
4239
+ });
4240
+
4241
+ await clock.tickAsync(0);
4242
+
4243
+ // Self sync should NOT have been triggered because parser was stopped
4244
+ assert.neverCalledWith(webexRequest, sinon.match({method: 'POST', uri: `${selfUrl}/sync`}));
4245
+ assert.neverCalledWith(webexRequest, sinon.match({method: 'GET', uri: `${selfUrl}/hashtree`}));
4246
+ });
4247
+ });
4248
+
4249
+ describe('#stop sync queue', () => {
4250
+ it('should clear the syncQueue when stopped so remaining queued items are not processed', async () => {
4251
+ const parser = createHashTreeParser();
4252
+
4253
+ const mainUrl = parser.dataSets.main.url;
4254
+ const selfUrl = parser.dataSets.self.url;
4255
+
4256
+ // Mock main GET hashtree with a deferred promise so we can control when it resolves
4257
+ let resolveMainHashtree;
4258
+ webexRequest
4259
+ .withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}))
4260
+ .callsFake(() => new Promise((resolve) => { resolveMainHashtree = resolve; }));
4261
+
4262
+ // Enqueue syncs for both main and self by sending heartbeat messages
4263
+ parser.handleMessage(
4264
+ createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
4265
+ 'trigger main sync'
4266
+ );
4267
+ parser.handleMessage(
4268
+ createHeartbeatMessage('self', 1, 2100, 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1'),
4269
+ 'trigger self sync'
4270
+ );
4271
+
4272
+ // Fire the timers - main sync starts and blocks on GET hashtree
4273
+ await clock.tickAsync(1000);
4274
+
4275
+ // Verify that self is still in the queue (main is being processed, self is waiting)
4276
+ // Now stop the parser - this should clear the syncQueue
4277
+ parser.stop();
4278
+
4279
+ // Resolve the pending main GET request so the in-flight sync can finish
4280
+ resolveMainHashtree({
4281
+ body: {
4282
+ hashes: new Array(16).fill(EMPTY_HASH),
4283
+ dataSet: createDataSet('main', 16, 1100),
4284
+ },
4285
+ });
4286
+
4287
+ await clock.tickAsync(0);
4288
+
4289
+ // Self should never have been synced because stop() cleared the queue
4290
+ const selfGetCalls = webexRequest.args.filter(
4291
+ (args) => args[0]?.method === 'GET' && args[0]?.uri === `${selfUrl}/hashtree`
4292
+ );
4293
+ expect(selfGetCalls).to.have.lengthOf(0);
4294
+ });
4295
+ });
4296
+
3932
4297
  describe('#cleanUp', () => {
3933
4298
  it('should stop the parser, clear all timers and clear all dataSets', () => {
3934
4299
  const parser = createHashTreeParser();
@@ -36,6 +36,27 @@ describe('plugin-meetings', () => {
36
36
  uri: `https://locus-test.webex.com/locus/api/v1/loci/call`,
37
37
  body: 'foo'
38
38
  };
39
+
40
+ const hashTreeOptions = {
41
+ method: 'GET',
42
+ headers: {
43
+ trackingid: 'test',
44
+ 'retry-after': 1000,
45
+ },
46
+ uri: `https://locus-test.webex.com/locus/api/v1/loci/12345/session/abc/datasets/main/hashtree`,
47
+ body: undefined,
48
+ };
49
+
50
+ const syncOptions = {
51
+ method: 'POST',
52
+ headers: {
53
+ trackingid: 'test',
54
+ 'retry-after': 1000,
55
+ },
56
+ uri: `https://locus-test.webex.com/locus/api/v1/loci/12345/session/abc/datasets/main/sync`,
57
+ body: 'foo',
58
+ };
59
+
39
60
  const reason1 = new WebexHttpError.MethodNotAllowed({
40
61
  statusCode: 403,
41
62
  options: {
@@ -68,14 +89,194 @@ describe('plugin-meetings', () => {
68
89
  });
69
90
 
70
91
  it('calls handleRetryRequestLocusServiceError with correct retry time when locus service unavailable error', () => {
71
- interceptor.webex.request = sinon.stub().returns(Promise.resolve());
72
- const handleRetryStub = sinon.stub(interceptor, 'handleRetryRequestLocusServiceError');
92
+ interceptor.webex.request = sinon.stub().returns(Promise.resolve());
93
+ const handleRetryStub = sinon.stub(
94
+ interceptor,
95
+ 'handleRetryRequestLocusServiceError'
96
+ );
97
+ handleRetryStub.returns(Promise.resolve());
98
+
99
+ return interceptor.onResponseError(options, reason2).then(() => {
100
+ expect(handleRetryStub.calledWith(options, 1000)).to.be.true;
101
+ });
102
+ });
103
+
104
+ [429, 500, 502, 503, 504].forEach((statusCode) => {
105
+ it(`does not retry /hashtree requests on ${statusCode}`, () => {
106
+ const reason = new WebexHttpError.MethodNotAllowed({
107
+ statusCode,
108
+ options: {
109
+ headers: {trackingid: 'test', 'retry-after': 1000},
110
+ uri: hashTreeOptions.uri,
111
+ },
112
+ body: {error: `Fake ${statusCode}`},
113
+ });
114
+
115
+ const handleRetryStub = sinon.stub(
116
+ interceptor,
117
+ 'handleRetryRequestLocusServiceError'
118
+ );
119
+ handleRetryStub.returns(Promise.resolve());
120
+
121
+ return interceptor.onResponseError(hashTreeOptions, reason).then(
122
+ () => assert.fail('Expected promise to be rejected'),
123
+ (err) => {
124
+ expect(err).to.equal(reason);
125
+ expect(handleRetryStub.called).to.be.false;
126
+ handleRetryStub.restore();
127
+ }
128
+ );
129
+ });
130
+
131
+ it(`does not retry /sync requests on ${statusCode}`, () => {
132
+ const reason = new WebexHttpError.MethodNotAllowed({
133
+ statusCode,
134
+ options: {
135
+ headers: {trackingid: 'test', 'retry-after': 1000},
136
+ uri: syncOptions.uri,
137
+ },
138
+ body: {error: `Fake ${statusCode}`},
139
+ });
140
+
141
+ const handleRetryStub = sinon.stub(
142
+ interceptor,
143
+ 'handleRetryRequestLocusServiceError'
144
+ );
73
145
  handleRetryStub.returns(Promise.resolve());
74
146
 
75
- return interceptor.onResponseError(options, reason2).then(() => {
76
- expect(handleRetryStub.calledWith(options, 1000)).to.be.true;
147
+ return interceptor.onResponseError(syncOptions, reason).then(
148
+ () => assert.fail('Expected promise to be rejected'),
149
+ (err) => {
150
+ expect(err).to.equal(reason);
151
+ expect(handleRetryStub.called).to.be.false;
152
+ handleRetryStub.restore();
153
+ }
154
+ );
155
+ });
156
+ });
157
+
158
+ it('still retries other locus requests on 429', () => {
159
+ const reason429 = new WebexHttpError.MethodNotAllowed({
160
+ statusCode: 429,
161
+ options: {
162
+ headers: {trackingid: 'test', 'retry-after': 1000},
163
+ uri: options.uri,
164
+ },
165
+ body: {error: 'Too Many Requests'},
166
+ });
167
+
168
+ interceptor.webex.request = sinon.stub().returns(Promise.resolve());
169
+ const handleRetryStub = sinon.stub(
170
+ interceptor,
171
+ 'handleRetryRequestLocusServiceError'
172
+ );
173
+ handleRetryStub.returns(Promise.resolve());
77
174
 
175
+ return interceptor.onResponseError(options, reason429).then(() => {
176
+ expect(handleRetryStub.calledOnce).to.be.true;
177
+ handleRetryStub.restore();
178
+ });
179
+ });
180
+
181
+ it('still retries other locus requests on 503', () => {
182
+ interceptor.webex.request = sinon.stub().returns(Promise.resolve());
183
+ const handleRetryStub = sinon.stub(
184
+ interceptor,
185
+ 'handleRetryRequestLocusServiceError'
186
+ );
187
+ handleRetryStub.returns(Promise.resolve());
188
+
189
+ return interceptor.onResponseError(options, reason2).then(() => {
190
+ expect(handleRetryStub.calledOnce).to.be.true;
191
+ handleRetryStub.restore();
192
+ });
193
+ });
194
+
195
+ describe('URI parsing edge cases', () => {
196
+ const make503Reason = (uri) =>
197
+ new WebexHttpError.MethodNotAllowed({
198
+ statusCode: 503,
199
+ options: {headers: {trackingid: 'test', 'retry-after': 1000}, uri},
200
+ body: {error: 'Service Unavailable'},
201
+ });
202
+
203
+ const makeOptions = (uri) => ({
204
+ method: 'GET',
205
+ headers: {trackingid: 'test', 'retry-after': 1000},
206
+ uri,
207
+ body: undefined,
208
+ });
209
+
210
+ [
211
+ 'https://locus.webex.com/locus/api/v1/loci/123/session/abc/datasets/main/hashtree?rootHash=xyz',
212
+ 'https://locus.webex.com/locus/api/v1/loci/123/session/abc/datasets/main/sync?seq=5',
213
+ ].forEach((uri) => {
214
+ it(`skips retry even with query params: ${uri.split('/').pop()}`, () => {
215
+ const opts = makeOptions(uri);
216
+ const reason = make503Reason(uri);
217
+ const stub = sinon
218
+ .stub(interceptor, 'handleRetryRequestLocusServiceError')
219
+ .returns(Promise.resolve());
220
+
221
+ return interceptor.onResponseError(opts, reason).then(
222
+ () => assert.fail('Expected promise to be rejected'),
223
+ (err) => {
224
+ expect(err).to.equal(reason);
225
+ expect(stub.called).to.be.false;
226
+ stub.restore();
227
+ }
228
+ );
78
229
  });
230
+ });
231
+
232
+ [
233
+ 'https://locus.webex.com/locus/api/v1/loci/123/hashtree-v2',
234
+ 'https://locus.webex.com/locus/api/v1/loci/123/syncData',
235
+ 'https://locus.webex.com/locus/api/v1/loci/123/async',
236
+ 'https://locus.webex.com/locus/api/v1/loci/123/hashtree/metadata',
237
+ ].forEach((uri) => {
238
+ it(`still retries when path only partially matches: ${uri
239
+ .split('/')
240
+ .pop()}`, () => {
241
+ const opts = makeOptions(uri);
242
+ const reason = make503Reason(uri);
243
+ interceptor.webex.request = sinon.stub().returns(Promise.resolve());
244
+ const stub = sinon
245
+ .stub(interceptor, 'handleRetryRequestLocusServiceError')
246
+ .returns(Promise.resolve());
247
+
248
+ return interceptor.onResponseError(opts, reason).then(() => {
249
+ expect(stub.calledOnce).to.be.true;
250
+ stub.restore();
251
+ });
252
+ });
253
+ });
254
+
255
+ it('still retries when /hashtree is on a non-locus host', () => {
256
+ const uri = 'https://other-service.webex.com/api/v1/hashtree';
257
+ const opts = makeOptions(uri);
258
+ const reason = make503Reason(uri);
259
+
260
+ return interceptor.onResponseError(opts, reason).then(
261
+ () => assert.fail('Expected promise to be rejected'),
262
+ (err) => {
263
+ expect(err).to.equal(reason);
264
+ }
265
+ );
266
+ });
267
+
268
+ it('still retries when URI is malformed', () => {
269
+ const uri = 'not-a-valid-url';
270
+ const opts = makeOptions(uri);
271
+ const reason = make503Reason(uri);
272
+
273
+ return interceptor.onResponseError(opts, reason).then(
274
+ () => assert.fail('Expected promise to be rejected'),
275
+ (err) => {
276
+ expect(err).to.equal(reason);
277
+ }
278
+ );
279
+ });
79
280
  });
80
281
  });
81
282