@webex/internal-plugin-mobius-socket 0.0.0-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1787 @@
1
+ /*!
2
+ * Copyright (c) 2015-2020 Cisco Systems, Inc. See LICENSE file.
3
+ */
4
+
5
+ import {assert} from '@webex/test-helper-chai';
6
+ import Mercury, {
7
+ BadRequest,
8
+ NotAuthorized,
9
+ Forbidden,
10
+ UnknownResponse,
11
+ // NotFound,
12
+ config as mercuryConfig,
13
+ ConnectionError,
14
+ Socket,
15
+ } from '@webex/internal-plugin-mercury';
16
+ import sinon from 'sinon';
17
+ import MockWebex from '@webex/test-helper-mock-webex';
18
+ import MockWebSocket from '@webex/test-helper-mock-web-socket';
19
+ import uuid from 'uuid';
20
+ import FakeTimers from '@sinonjs/fake-timers';
21
+ import {skipInBrowser} from '@webex/test-helper-mocha';
22
+
23
+ import promiseTick from '../lib/promise-tick';
24
+
25
+ describe('plugin-mercury', () => {
26
+ describe('Mercury', () => {
27
+ let clock, mercury, mockWebSocket, socketOpenStub, webex;
28
+
29
+ const statusStartTypingMessage = JSON.stringify({
30
+ id: uuid.v4(),
31
+ data: {
32
+ eventType: 'status.start_typing',
33
+ actor: {
34
+ id: 'actorId',
35
+ },
36
+ conversationId: uuid.v4(),
37
+ },
38
+ timestamp: Date.now(),
39
+ trackingId: `suffix_${uuid.v4()}_${Date.now()}`,
40
+ });
41
+
42
+ beforeEach(() => {
43
+ clock = FakeTimers.install({now: Date.now()});
44
+ });
45
+
46
+ afterEach(() => {
47
+ clock.uninstall();
48
+ });
49
+
50
+ beforeEach(() => {
51
+ webex = new MockWebex({
52
+ children: {
53
+ mercury: Mercury,
54
+ },
55
+ });
56
+ webex.credentials = {
57
+ refresh: sinon.stub().returns(Promise.resolve()),
58
+ getUserToken: sinon.stub().returns(
59
+ Promise.resolve({
60
+ toString() {
61
+ return 'Bearer FAKE';
62
+ },
63
+ })
64
+ ),
65
+ };
66
+ webex.internal.device = {
67
+ register: sinon.stub().returns(Promise.resolve()),
68
+ refresh: sinon.stub().returns(Promise.resolve()),
69
+ webSocketUrl: 'ws://example.com',
70
+ getWebSocketUrl: sinon.stub().returns(Promise.resolve('ws://example-2.com')),
71
+ useServiceCatalogUrl: sinon
72
+ .stub()
73
+ .returns(Promise.resolve('https://service-catalog-url.com')),
74
+ };
75
+ webex.internal.services = {
76
+ convertUrlToPriorityHostUrl: sinon.stub().returns(Promise.resolve('ws://example-2.com')),
77
+ markFailedUrl: sinon.stub().returns(Promise.resolve()),
78
+ switchActiveClusterIds: sinon.stub(),
79
+ invalidateCache: sinon.stub(),
80
+ isValidHost: sinon.stub().returns(Promise.resolve(true)),
81
+ };
82
+ webex.internal.metrics.submitClientMetrics = sinon.stub();
83
+ webex.internal.newMetrics.callDiagnosticMetrics.setMercuryConnectedStatus = sinon.stub();
84
+ webex.trackingId = 'fakeTrackingId';
85
+ webex.config.mercury = mercuryConfig.mercury;
86
+
87
+ webex.logger = console;
88
+
89
+ mockWebSocket = new MockWebSocket();
90
+ sinon.stub(Socket, 'getWebSocketConstructor').returns(() => mockWebSocket);
91
+
92
+ const origOpen = Socket.prototype.open;
93
+
94
+ socketOpenStub = sinon.stub(Socket.prototype, 'open').callsFake(function (...args) {
95
+ const promise = Reflect.apply(origOpen, this, args);
96
+
97
+ process.nextTick(() => mockWebSocket.open());
98
+
99
+ return promise;
100
+ });
101
+
102
+ mercury = webex.internal.mercury;
103
+ mercury.defaultSessionId = 'mercury-default-session';
104
+ });
105
+
106
+ afterEach(async () => {
107
+ // Clean up Mercury connections and internal state
108
+ if (mercury) {
109
+ try {
110
+ await mercury.disconnectAll();
111
+ } catch (e) {
112
+ // Ignore cleanup errors
113
+ }
114
+ // Clear any remaining connection promises
115
+ if (mercury._connectPromises) {
116
+ mercury._connectPromises.clear();
117
+ }
118
+ }
119
+
120
+ // Ensure mock socket is properly closed
121
+ if (mockWebSocket && typeof mockWebSocket.close === 'function') {
122
+ try {
123
+ mockWebSocket.close();
124
+ } catch (e) {
125
+ // Ignore cleanup errors
126
+ }
127
+ }
128
+
129
+ if (socketOpenStub) {
130
+ socketOpenStub.restore();
131
+ }
132
+
133
+ if (Socket.getWebSocketConstructor.restore) {
134
+ Socket.getWebSocketConstructor.restore();
135
+ }
136
+
137
+ // Small delay to ensure all async operations complete
138
+ await new Promise(resolve => setTimeout(resolve, 10));
139
+ });
140
+
141
+ describe('#listen()', () => {
142
+ it('proxies to #connect()', () => {
143
+ const connectStub = sinon.stub(mercury, 'connect').callThrough();
144
+ return mercury.listen().then(() => {
145
+ assert.called(connectStub);
146
+ assert.calledWith(
147
+ webex.internal.newMetrics.callDiagnosticMetrics.setMercuryConnectedStatus,
148
+ true
149
+ );
150
+ });
151
+ });
152
+ });
153
+
154
+ describe('#stopListening()', () => {
155
+ it('proxies to #disconnect()', () => {
156
+ return mercury.connect().then(() => {
157
+ webex.internal.newMetrics.callDiagnosticMetrics.setMercuryConnectedStatus.resetHistory();
158
+ const disconnectStub = sinon.stub(mercury, 'disconnect').callThrough();
159
+
160
+ mercury.stopListening();
161
+ assert.called(disconnectStub);
162
+ mockWebSocket.emit('close', {code: 1000, reason: 'test'});
163
+ assert.calledWith(
164
+ webex.internal.newMetrics.callDiagnosticMetrics.setMercuryConnectedStatus,
165
+ false
166
+ );
167
+ });
168
+ });
169
+ });
170
+
171
+ describe('#connect()', () => {
172
+ it('lazily registers the device', () => {
173
+ webex.internal.device.registered = false;
174
+ assert.notCalled(webex.internal.device.register);
175
+ const promise = mercury.connect();
176
+
177
+ mockWebSocket.open();
178
+
179
+ return promise.then(() => {
180
+ assert.calledOnce(webex.internal.device.register);
181
+ });
182
+ });
183
+
184
+ it('connects to Mercury using default url', () => {
185
+ webex.internal.feature.updateFeature = sinon.stub();
186
+ const promise = mercury.connect();
187
+ const envelope = {
188
+ data: {
189
+ featureToggle: {
190
+ 'feature-name': true,
191
+ },
192
+ },
193
+ };
194
+ assert.isFalse(mercury.connected, 'Mercury is not connected');
195
+ assert.isTrue(mercury.connecting, 'Mercury is connecting');
196
+ mockWebSocket.open();
197
+
198
+ return promise.then(() => {
199
+ assert.isTrue(mercury.connected, 'Mercury is connected');
200
+ assert.isFalse(mercury.connecting, 'Mercury is not connecting');
201
+ assert.calledWith(socketOpenStub, sinon.match(/ws:\/\/example.com/), sinon.match.any);
202
+ mercury._emit('event:featureToggle_update', envelope);
203
+ assert.calledOnceWithExactly(
204
+ webex.internal.feature.updateFeature,
205
+ envelope.data.featureToggle
206
+ );
207
+ sinon.restore();
208
+ });
209
+ });
210
+
211
+ it('connects to Mercury but does not call updateFeature', () => {
212
+ webex.internal.feature.updateFeature = sinon.stub();
213
+ const promise = mercury.connect();
214
+ const envelope = {};
215
+
216
+ return promise.then(() => {
217
+ mercury._emit('event:featureToggle_update', envelope);
218
+ assert.notCalled(webex.internal.feature.updateFeature);
219
+ sinon.restore();
220
+ });
221
+ });
222
+ it('Mercury emit event:ActiveClusterStatusEvent, call services switchActiveClusterIds', () => {
223
+ const promise = mercury.connect();
224
+ const activeClusterEventEnvelope = {
225
+ data: {
226
+ activeClusters: {
227
+ wdm: 'wdm-cluster-id.com',
228
+ },
229
+ },
230
+ };
231
+ mockWebSocket.open();
232
+
233
+ return promise.then(() => {
234
+ mercury._emit('event:ActiveClusterStatusEvent', activeClusterEventEnvelope);
235
+ assert.calledOnceWithExactly(
236
+ webex.internal.services.switchActiveClusterIds,
237
+ activeClusterEventEnvelope.data.activeClusters
238
+ );
239
+ sinon.restore();
240
+ });
241
+ });
242
+ it('Mercury emit event:ActiveClusterStatusEvent with no data, not call services switchActiveClusterIds', () => {
243
+ webex.internal.feature.updateFeature = sinon.stub();
244
+ const promise = mercury.connect();
245
+ const envelope = {};
246
+
247
+ return promise.then(() => {
248
+ mercury._emit('event:ActiveClusterStatusEvent', envelope);
249
+ assert.notCalled(webex.internal.services.switchActiveClusterIds);
250
+ sinon.restore();
251
+ });
252
+ });
253
+ it('Mercury emit event:u2c.cache-invalidation, call services invalidateCache', () => {
254
+ const promise = mercury.connect();
255
+ const u2cInvalidateEventEnvelope = {
256
+ data: {
257
+ timestamp: '1759289614',
258
+ },
259
+ };
260
+
261
+ mockWebSocket.open();
262
+
263
+ return promise.then(() => {
264
+ mercury._emit('event:u2c.cache-invalidation', u2cInvalidateEventEnvelope);
265
+ assert.calledOnceWithExactly(
266
+ webex.internal.services.invalidateCache,
267
+ u2cInvalidateEventEnvelope.data.timestamp
268
+ );
269
+ sinon.restore();
270
+ });
271
+ });
272
+ it('Mercury emit event:u2c.cache-invalidation with no data, not call services switchActiveClusterIds', () => {
273
+ webex.internal.feature.updateFeature = sinon.stub();
274
+ const promise = mercury.connect();
275
+ const envelope = {};
276
+
277
+ return promise.then(() => {
278
+ mercury._emit('event:u2c.cache-invalidation', envelope);
279
+ assert.notCalled(webex.internal.services.invalidateCache);
280
+ sinon.restore();
281
+ });
282
+ });
283
+
284
+ describe('when `maxRetries` is set', () => {
285
+ const check = () => {
286
+ socketOpenStub.restore();
287
+ socketOpenStub = sinon.stub(Socket.prototype, 'open');
288
+ socketOpenStub.returns(Promise.reject(new ConnectionError()));
289
+ assert.notCalled(Socket.prototype.open);
290
+
291
+ const promise = mercury.connect();
292
+
293
+ return promiseTick(5)
294
+ .then(() => {
295
+ assert.calledOnce(Socket.prototype.open);
296
+
297
+ return promiseTick(5);
298
+ })
299
+ .then(() => {
300
+ clock.tick(mercury.config.backoffTimeReset);
301
+
302
+ return promiseTick(5);
303
+ })
304
+ .then(() => {
305
+ assert.calledTwice(Socket.prototype.open);
306
+ clock.tick(2 * mercury.config.backoffTimeReset);
307
+
308
+ return promiseTick(5);
309
+ })
310
+ .then(() => {
311
+ assert.calledThrice(Socket.prototype.open);
312
+ clock.tick(5 * mercury.config.backoffTimeReset);
313
+ return assert.isRejected(promise);
314
+ })
315
+ .then(() => {
316
+ assert.calledThrice(Socket.prototype.open);
317
+ });
318
+ };
319
+
320
+ // skipping due to apparent bug with lolex in all browsers but Chrome.
321
+ // if initial retries is zero and mercury has never connected max retries is used
322
+ skipInBrowser(it)('fails after `maxRetries` attempts', () => {
323
+ mercury.config.maxRetries = 2;
324
+ mercury.config.initialConnectionMaxRetries = 0;
325
+
326
+ return check();
327
+ });
328
+
329
+ // initial retries is non-zero so takes precedence over maxRetries when mercury has never connected
330
+ skipInBrowser(it)('fails after `initialConnectionMaxRetries` attempts', () => {
331
+ mercury.config.maxRetries = 0;
332
+ mercury.config.initialConnectionMaxRetries = 2;
333
+ return check();
334
+ });
335
+
336
+ // initial retries is non-zero so takes precedence over maxRetries when mercury has never connected
337
+ skipInBrowser(it)('fails after `initialConnectionMaxRetries` attempts', () => {
338
+ mercury.config.initialConnectionMaxRetries = 2;
339
+ mercury.config.maxRetries = 5;
340
+ return check();
341
+ });
342
+
343
+ // when mercury has connected maxRetries is used and the initialConnectionMaxRetries is ignored
344
+ skipInBrowser(it)('fails after `initialConnectionMaxRetries` attempts', () => {
345
+ mercury.config.initialConnectionMaxRetries = 5;
346
+ mercury.config.maxRetries = 2;
347
+ mercury.hasEverConnected = true;
348
+ return check();
349
+ });
350
+ });
351
+
352
+ it('can safely be called multiple times', () => {
353
+ const promise = Promise.all([
354
+ mercury.connect(),
355
+ mercury.connect(),
356
+ mercury.connect(),
357
+ mercury.connect(),
358
+ ]);
359
+
360
+ mockWebSocket.open();
361
+
362
+ return promise.then(() => {
363
+ assert.calledOnce(Socket.prototype.open);
364
+ });
365
+ });
366
+
367
+ // skipping due to apparent bug with lolex in all browsers but Chrome.
368
+ skipInBrowser(describe)('when the connection fails', () => {
369
+ it('backs off exponentially', () => {
370
+ socketOpenStub.restore();
371
+ socketOpenStub = sinon.stub(Socket.prototype, 'open');
372
+ socketOpenStub.returns(Promise.reject(new ConnectionError({code: 4001})));
373
+ // Note: onCall is zero-based
374
+ socketOpenStub.onCall(2).returns(Promise.resolve(new MockWebSocket()));
375
+ assert.notCalled(Socket.prototype.open);
376
+
377
+ const promise = mercury.connect();
378
+
379
+ return promiseTick(5)
380
+ .then(() => {
381
+ assert.calledOnce(Socket.prototype.open);
382
+
383
+ // I'm not sure why, but it's important the clock doesn't advance
384
+ // until a tick happens
385
+ return promiseTick(5);
386
+ })
387
+ .then(() => {
388
+ clock.tick(mercury.config.backoffTimeReset);
389
+
390
+ return promiseTick(5);
391
+ })
392
+ .then(() => {
393
+ assert.calledTwice(Socket.prototype.open);
394
+ clock.tick(2 * mercury.config.backoffTimeReset);
395
+
396
+ return promiseTick(5);
397
+ })
398
+ .then(() => {
399
+ assert.calledThrice(Socket.prototype.open);
400
+ clock.tick(5 * mercury.config.backoffTimeReset);
401
+
402
+ return promise;
403
+ })
404
+ .then(() => {
405
+ assert.calledThrice(Socket.prototype.open);
406
+ clock.tick(8 * mercury.config.backoffTimeReset);
407
+
408
+ return promiseTick(5);
409
+ })
410
+ .then(() => {
411
+ assert.calledThrice(Socket.prototype.open);
412
+ });
413
+ });
414
+
415
+ describe('with `BadRequest`', () => {
416
+ it('fails permanently', () => {
417
+ clock.uninstall();
418
+ socketOpenStub.restore();
419
+ socketOpenStub = sinon
420
+ .stub(Socket.prototype, 'open')
421
+ .returns(Promise.reject(new BadRequest({code: 4400})));
422
+
423
+ return assert.isRejected(mercury.connect());
424
+ });
425
+ });
426
+
427
+ describe('with `UnknownResponse`', () => {
428
+ it('triggers a device refresh', () => {
429
+ socketOpenStub.restore();
430
+ socketOpenStub = sinon.stub(Socket.prototype, 'open').returns(Promise.resolve());
431
+ socketOpenStub.onCall(0).returns(Promise.reject(new UnknownResponse({code: 4444})));
432
+ assert.notCalled(webex.credentials.refresh);
433
+ assert.notCalled(webex.internal.device.refresh);
434
+ const promise = mercury.connect();
435
+
436
+ return promiseTick(7).then(() => {
437
+ assert.notCalled(webex.credentials.refresh);
438
+ assert.called(webex.internal.device.refresh);
439
+ clock.tick(1000);
440
+
441
+ return promise;
442
+ });
443
+ });
444
+ });
445
+
446
+ describe('with `NotAuthorized`', () => {
447
+ it('triggers a token refresh', () => {
448
+ socketOpenStub.restore();
449
+ socketOpenStub = sinon.stub(Socket.prototype, 'open').returns(Promise.resolve());
450
+ socketOpenStub.onCall(0).returns(Promise.reject(new NotAuthorized({code: 4401})));
451
+ assert.notCalled(webex.credentials.refresh);
452
+ assert.notCalled(webex.internal.device.refresh);
453
+ const promise = mercury.connect();
454
+
455
+ return promiseTick(7).then(() => {
456
+ assert.called(webex.credentials.refresh);
457
+ assert.notCalled(webex.internal.device.refresh);
458
+ clock.tick(1000);
459
+
460
+ return promise;
461
+ });
462
+ });
463
+ });
464
+
465
+ describe('with `Forbidden`', () => {
466
+ it('fails permanently', () => {
467
+ clock.uninstall();
468
+ socketOpenStub.restore();
469
+ socketOpenStub = sinon
470
+ .stub(Socket.prototype, 'open')
471
+ .returns(Promise.reject(new Forbidden({code: 4403})));
472
+
473
+ return assert.isRejected(mercury.connect());
474
+ });
475
+ });
476
+
477
+ // describe(`with \`NotFound\``, () => {
478
+ // it(`triggers a device refresh`, () => {
479
+ // socketOpenStub.restore();
480
+ // socketOpenStub = sinon.stub(Socket.prototype, `open`).returns(Promise.resolve());
481
+ // socketOpenStub.onCall(0).returns(Promise.reject(new NotFound({code: 4404})));
482
+ // assert.notCalled(webex.credentials.refresh);
483
+ // assert.notCalled(webex.internal.device.refresh);
484
+ // const promise = mercury.connect();
485
+ // return promiseTick(6)
486
+ // .then(() => {
487
+ // assert.notCalled(webex.credentials.refresh);
488
+ // assert.called(webex.internal.device.refresh);
489
+ // clock.tick(1000);
490
+ // return assert.isFulfilled(promise);
491
+ // });
492
+ // });
493
+ // });
494
+
495
+ describe('when web-high-availability feature is enabled', () => {
496
+ it('marks current socket url as failed and get new one on Connection Error', () => {
497
+ webex.internal.feature.getFeature.returns(Promise.resolve(true));
498
+ socketOpenStub.restore();
499
+ socketOpenStub = sinon.stub(Socket.prototype, 'open').returns(Promise.resolve());
500
+ socketOpenStub.onCall(0).returns(Promise.reject(new ConnectionError({code: 4001})));
501
+ const promise = mercury.connect();
502
+
503
+ return promiseTick(7).then(() => {
504
+ assert.calledOnce(webex.internal.services.markFailedUrl);
505
+ clock.tick(1000);
506
+
507
+ return promise;
508
+ });
509
+ });
510
+ });
511
+ });
512
+
513
+ describe('when connected', () => {
514
+ it('resolves immediately', () =>
515
+ mercury.connect().then(() => {
516
+ assert.isTrue(mercury.connected, 'Mercury is connected');
517
+ assert.isFalse(mercury.connecting, 'Mercury is not connecting');
518
+ const promise = mercury.connect();
519
+
520
+ assert.isTrue(mercury.connected, 'Mercury is connected');
521
+ assert.isFalse(mercury.connecting, 'Mercury is not connecting');
522
+
523
+ return promise;
524
+ }));
525
+
526
+ // skipping due to apparent bug with lolex in all browsers but Chrome.
527
+ skipInBrowser(it)('does not continue attempting to connect', () => {
528
+ const promise = mercury.connect();
529
+
530
+ // Wait for the connection to be established before proceeding
531
+ mockWebSocket.open();
532
+
533
+ return promise.then(() =>
534
+ promiseTick(2)
535
+ .then(() => {
536
+ clock.tick(6 * webex.internal.mercury.config.backoffTimeReset);
537
+
538
+ return promiseTick(2);
539
+ })
540
+ .then(() => {
541
+ assert.calledOnce(Socket.prototype.open);
542
+ })
543
+ );
544
+ });
545
+ });
546
+
547
+ describe('when webSocketUrl is provided', () => {
548
+ it('connects to Mercury with provided url', () => {
549
+ const webSocketUrl = 'ws://providedurl.com';
550
+ const promise = mercury.connect(webSocketUrl);
551
+
552
+ assert.isFalse(mercury.connected, 'Mercury is not connected');
553
+ assert.isTrue(mercury.connecting, 'Mercury is connecting');
554
+ mockWebSocket.open();
555
+
556
+ return promise.then(() => {
557
+ assert.isTrue(mercury.connected, 'Mercury is connected');
558
+ assert.isFalse(mercury.connecting, 'Mercury is not connecting');
559
+ assert.calledWith(
560
+ Socket.prototype.open,
561
+ sinon.match(/ws:\/\/providedurl.com.*clientTimestamp[=]\d+/),
562
+ sinon.match.any
563
+ );
564
+ });
565
+ });
566
+ });
567
+ });
568
+
569
+ describe('Websocket proxy agent', () => {
570
+ afterEach(() => {
571
+ delete webex.config.defaultMercuryOptions;
572
+ });
573
+
574
+ it('connects to Mercury using proxy agent', () => {
575
+ const testProxyUrl = 'http://proxyurl.com:80';
576
+
577
+ webex.config.defaultMercuryOptions = {agent: {proxy: {href: testProxyUrl}}};
578
+ const promise = mercury.connect();
579
+
580
+ assert.isFalse(mercury.connected, 'Mercury is not connected');
581
+ assert.isTrue(mercury.connecting, 'Mercury is connecting');
582
+ mockWebSocket.open();
583
+
584
+ return promise.then(() => {
585
+ assert.isTrue(mercury.connected, 'Mercury is connected');
586
+ assert.isFalse(mercury.connecting, 'Mercury is not connecting');
587
+ assert.calledWith(
588
+ socketOpenStub,
589
+ sinon.match(/ws:\/\/example.com/),
590
+ sinon.match.has(
591
+ 'agent',
592
+ sinon.match.has('proxy', sinon.match.has('href', testProxyUrl))
593
+ )
594
+ );
595
+ });
596
+ });
597
+
598
+ it('connects to Mercury without proxy agent', () => {
599
+ const promise = mercury.connect();
600
+
601
+ assert.isFalse(mercury.connected, 'Mercury is not connected');
602
+ assert.isTrue(mercury.connecting, 'Mercury is connecting');
603
+ mockWebSocket.open();
604
+
605
+ return promise.then(() => {
606
+ assert.isTrue(mercury.connected, 'Mercury is connected');
607
+ assert.isFalse(mercury.connecting, 'Mercury is not connecting');
608
+ assert.calledWith(
609
+ socketOpenStub,
610
+ sinon.match(/ws:\/\/example.com/),
611
+ sinon.match({agent: undefined})
612
+ );
613
+ });
614
+ });
615
+ });
616
+
617
+ describe('#logout()', () => {
618
+ it('calls disconnectAll and logs', () => {
619
+ sinon.stub(mercury.logger, 'info');
620
+ sinon.stub(mercury, 'disconnectAll');
621
+ mercury.logout();
622
+ assert.called(mercury.disconnectAll);
623
+ assert.calledTwice(mercury.logger.info);
624
+
625
+ assert.calledWith(mercury.logger.info.getCall(0), 'Mercury: logout() called');
626
+ assert.isTrue(
627
+ mercury.logger.info
628
+ .getCall(1)
629
+ .args[0].startsWith('Mercury: debug_mercury_logging stack: ')
630
+ );
631
+ });
632
+
633
+ it('uses the config.beforeLogoutOptionsCloseReason to disconnect and will send code 3050 for logout', () => {
634
+ sinon.stub(mercury, 'disconnectAll');
635
+ mercury.config.beforeLogoutOptionsCloseReason = 'done (permanent)';
636
+ mercury.logout();
637
+ assert.calledWith(mercury.disconnectAll, {code: 3050, reason: 'done (permanent)'});
638
+ });
639
+
640
+ it('uses the config.beforeLogoutOptionsCloseReason to disconnect and will send code 3050 for logout if the reason is different than standard', () => {
641
+ sinon.stub(mercury, 'disconnectAll');
642
+ mercury.config.beforeLogoutOptionsCloseReason = 'test';
643
+ mercury.logout();
644
+ assert.calledWith(mercury.disconnectAll, {code: 3050, reason: 'test'});
645
+ });
646
+
647
+ it('uses the config.beforeLogoutOptionsCloseReason to disconnect and will send undefined for logout if the reason is same as standard', () => {
648
+ sinon.stub(mercury, 'disconnectAll');
649
+ mercury.config.beforeLogoutOptionsCloseReason = 'done (forced)';
650
+ mercury.logout();
651
+ assert.calledWith(mercury.disconnectAll, undefined);
652
+ });
653
+ });
654
+
655
+ describe('#disconnect()', () => {
656
+ it('disconnects the WebSocket', () =>
657
+ mercury
658
+ .connect()
659
+ .then(() => {
660
+ assert.isTrue(mercury.connected, 'Mercury is connected');
661
+ assert.isFalse(mercury.connecting, 'Mercury is not connecting');
662
+ const promise = mercury.disconnect();
663
+
664
+ mockWebSocket.emit('close', {
665
+ code: 1000,
666
+ reason: 'Done',
667
+ });
668
+
669
+ return promise;
670
+ })
671
+ .then(() => {
672
+ assert.isFalse(mercury.connected, 'Mercury is not connected');
673
+ assert.isFalse(mercury.connecting, 'Mercury is not connecting');
674
+ assert.isUndefined(mercury.mockWebSocket, 'Mercury does not have a mockWebSocket');
675
+ }));
676
+
677
+ it('disconnects the WebSocket with code 3050', () =>
678
+ mercury
679
+ .connect()
680
+ .then(() => {
681
+ assert.isTrue(mercury.connected, 'Mercury is connected');
682
+ assert.isFalse(mercury.connecting, 'Mercury is not connecting');
683
+ const promise = mercury.disconnect();
684
+
685
+ mockWebSocket.emit('close', {
686
+ code: 3050,
687
+ reason: 'done (permanent)',
688
+ });
689
+
690
+ return promise;
691
+ })
692
+ .then(() => {
693
+ assert.isFalse(mercury.connected, 'Mercury is not connected');
694
+ assert.isFalse(mercury.connecting, 'Mercury is not connecting');
695
+ assert.isUndefined(mercury.mockWebSocket, 'Mercury does not have a mockWebSocket');
696
+ }));
697
+
698
+ it('stops emitting message events', () => {
699
+ const spy = sinon.spy();
700
+
701
+ mercury.on('event:status.start_typing', spy);
702
+
703
+ return mercury
704
+ .connect()
705
+ .then(() => {
706
+ assert.isTrue(mercury.connected, 'Mercury is connected');
707
+ assert.isFalse(mercury.connecting, 'Mercury is not connecting');
708
+
709
+ assert.notCalled(spy);
710
+ mockWebSocket.readyState = 1;
711
+ mockWebSocket.emit('open');
712
+ mockWebSocket.emit('message', {data: statusStartTypingMessage});
713
+ })
714
+ .then(() => {
715
+ assert.calledOnce(spy);
716
+
717
+ const promise = mercury.disconnect();
718
+
719
+ mockWebSocket.readyState = 1;
720
+ mockWebSocket.emit('open');
721
+ mockWebSocket.emit('message', {data: statusStartTypingMessage});
722
+ mockWebSocket.emit('close', {
723
+ code: 1000,
724
+ reason: 'Done',
725
+ });
726
+ mockWebSocket.emit('message', {data: statusStartTypingMessage});
727
+
728
+ return promise;
729
+ })
730
+
731
+ .then(() => {
732
+ mockWebSocket.readyState = 1;
733
+ mockWebSocket.emit('open');
734
+ mockWebSocket.emit('message', {data: statusStartTypingMessage});
735
+ assert.calledOnce(spy);
736
+ });
737
+ });
738
+
739
+ describe('when there is a connection attempt inflight', () => {
740
+ it('stops the attempt when disconnect called', () => {
741
+ socketOpenStub.restore();
742
+ socketOpenStub = sinon.stub(Socket.prototype, 'open');
743
+ socketOpenStub.onCall(0).returns(
744
+ // Delay the opening of the socket so that disconnect is called while open
745
+ // is in progress
746
+ promiseTick(2 * webex.internal.mercury.config.backoffTimeReset)
747
+ // Pretend the socket opened successfully. Failing should be fine too but
748
+ // it generates more console output.
749
+ .then(() => Promise.resolve())
750
+ );
751
+ const promise = mercury.connect();
752
+
753
+ // Wait for the connect call to setup
754
+ return promiseTick(webex.internal.mercury.config.backoffTimeReset).then(() => {
755
+ // By this time backoffCall and mercury socket should be defined by the
756
+ // 'connect' call
757
+ assert.isDefined(mercury.backoffCalls.get('mercury-default-session'), 'Mercury backoffCall is not defined');
758
+ assert.isDefined(mercury.socket, 'Mercury socket is not defined');
759
+ // Calling disconnect will abort the backoffCall, close the socket, and
760
+ // reject the connect
761
+ mercury.disconnect();
762
+ assert.isUndefined(mercury.backoffCalls.get('mercury-default-session'), 'Mercury backoffCall is still defined');
763
+ // The socket will never be unset (which seems bad)
764
+ assert.isDefined(mercury.socket, 'Mercury socket is not defined');
765
+
766
+ return assert.isRejected(promise).then((error) => {
767
+ // connection did not fail, so no last error
768
+ assert.isUndefined(mercury.getLastError());
769
+ });
770
+ });
771
+ });
772
+
773
+ it('stops the attempt when backoffCall is undefined', () => {
774
+ socketOpenStub.restore();
775
+ socketOpenStub = sinon.stub(Socket.prototype, 'open');
776
+ socketOpenStub.returns(Promise.resolve());
777
+
778
+ let reason;
779
+
780
+ mercury.backoffCalls.clear();
781
+
782
+ const promise = mercury._attemptConnection(
783
+ 'ws://example.com',
784
+ 'mercury-default-session',
785
+ (_reason) => {
786
+ reason = _reason;
787
+ }
788
+ );
789
+
790
+ return promiseTick(webex.internal.mercury.config.backoffTimeReset).then(() => {
791
+ assert.equal(
792
+ reason.message,
793
+ `Mercury: prevent socket open when backoffCall no longer defined for ${mercury.defaultSessionId}`
794
+ );
795
+
796
+ // Ensure the promise was actually rejected (short-circuited)
797
+ return assert.isRejected(promise);
798
+ });
799
+ });
800
+
801
+ it('sets lastError when retrying', () => {
802
+ const realError = new Error('FORCED');
803
+
804
+ socketOpenStub.restore();
805
+ socketOpenStub = sinon.stub(Socket.prototype, 'open');
806
+ socketOpenStub.onCall(0).returns(Promise.reject(realError));
807
+ const promise = mercury.connect();
808
+
809
+ // Wait for the connect call to setup
810
+ return promiseTick(webex.internal.mercury.config.backoffTimeReset).then(() => {
811
+ // Calling disconnect will abort the backoffCall, close the socket, and
812
+ // reject the connect
813
+ mercury.disconnect();
814
+
815
+ return assert.isRejected(promise).then((error) => {
816
+ const lastError = mercury.getLastError();
817
+
818
+ assert.equal(error.message, `Mercury Connection Aborted for ${mercury.defaultSessionId}`);
819
+ assert.isDefined(lastError);
820
+ assert.equal(lastError, realError);
821
+ });
822
+ });
823
+ });
824
+ });
825
+ });
826
+
827
+ describe('#_emit()', () => {
828
+ it('emits Error-safe events and log the error with the call parameters', () => {
829
+ const error = 'error';
830
+ const event = {data: 'some data'};
831
+ mercury.on('break', () => {
832
+ throw error;
833
+ });
834
+ sinon.stub(mercury.logger, 'error');
835
+
836
+ return Promise.resolve(mercury._emit('break', event)).then((res) => {
837
+ assert.calledWith(
838
+ mercury.logger.error,
839
+ 'Mercury: error occurred in event handler:',
840
+ error,
841
+ ' with args: ',
842
+ ['break', event]
843
+ );
844
+ return res;
845
+ });
846
+ });
847
+ });
848
+
849
+ describe('#_applyOverrides()', () => {
850
+ const lastSeenActivityDate = 'Some date';
851
+ const lastReadableActivityDate = 'Some other date';
852
+
853
+ it('merges a single header field with data', () => {
854
+ const envelope = {
855
+ headers: {
856
+ 'data.activity.target.lastSeenActivityDate': lastSeenActivityDate,
857
+ },
858
+ data: {
859
+ activity: {},
860
+ },
861
+ };
862
+
863
+ mercury._applyOverrides(envelope);
864
+
865
+ assert.equal(envelope.data.activity.target.lastSeenActivityDate, lastSeenActivityDate);
866
+ });
867
+
868
+ it('merges a multiple header fields with data', () => {
869
+ const envelope = {
870
+ headers: {
871
+ 'data.activity.target.lastSeenActivityDate': lastSeenActivityDate,
872
+ 'data.activity.target.lastReadableActivityDate': lastReadableActivityDate,
873
+ },
874
+ data: {
875
+ activity: {},
876
+ },
877
+ };
878
+
879
+ mercury._applyOverrides(envelope);
880
+
881
+ assert.equal(envelope.data.activity.target.lastSeenActivityDate, lastSeenActivityDate);
882
+ assert.equal(
883
+ envelope.data.activity.target.lastReadableActivityDate,
884
+ lastReadableActivityDate
885
+ );
886
+ });
887
+
888
+ it('merges headers when Mercury messages arrive', () => {
889
+ const envelope = {
890
+ headers: {
891
+ 'data.activity.target.lastSeenActivityDate': lastSeenActivityDate,
892
+ },
893
+ data: {
894
+ activity: {},
895
+ },
896
+ };
897
+
898
+ mercury._applyOverrides(envelope);
899
+
900
+ assert.equal(envelope.data.activity.target.lastSeenActivityDate, lastSeenActivityDate);
901
+ });
902
+ });
903
+
904
+ describe('#_setTimeOffset', () => {
905
+ it('sets mercuryTimeOffset based on the difference between wsWriteTimestamp and now', () => {
906
+ const event = {
907
+ data: {
908
+ wsWriteTimestamp: Date.now() - 60000,
909
+ },
910
+ };
911
+ assert.isUndefined(mercury.mercuryTimeOffset);
912
+ mercury._setTimeOffset('mercury-default-session', event);
913
+ assert.isDefined(mercury.mercuryTimeOffset);
914
+ assert.isTrue(mercury.mercuryTimeOffset > 0);
915
+ });
916
+ it('handles negative offsets', () => {
917
+ const event = {
918
+ data: {
919
+ wsWriteTimestamp: Date.now() + 60000,
920
+ },
921
+ };
922
+ mercury._setTimeOffset('mercury-default-session', event);
923
+ assert.isTrue(mercury.mercuryTimeOffset < 0);
924
+ });
925
+ it('handles invalid wsWriteTimestamp', () => {
926
+ const invalidTimestamps = [null, -1, 'invalid', undefined];
927
+ invalidTimestamps.forEach((invalidTimestamp) => {
928
+ const event = {
929
+ data: {
930
+ wsWriteTimestamp: invalidTimestamp,
931
+ },
932
+ };
933
+ mercury._setTimeOffset('mercury-default-session', event);
934
+ assert.isUndefined(mercury.mercuryTimeOffset);
935
+ });
936
+ });
937
+ });
938
+
939
+ describe('#_prepareUrl()', () => {
940
+ beforeEach(() => {
941
+ webex.internal.device.webSocketUrl = 'ws://example.com';
942
+ });
943
+
944
+ it('uses device default webSocketUrl', () =>
945
+ webex.internal.mercury._prepareUrl().then((wsUrl) => assert.match(wsUrl, /example.com/)));
946
+ it('uses provided webSocketUrl', () =>
947
+ webex.internal.mercury
948
+ ._prepareUrl('ws://provided.com')
949
+ .then((wsUrl) => assert.match(wsUrl, /.*provided.com.*/)));
950
+ it('requests text-mode WebSockets', () =>
951
+ webex.internal.mercury
952
+ ._prepareUrl()
953
+ .then((wsUrl) => assert.match(wsUrl, /.*outboundWireFormat=text.*/)));
954
+
955
+ it('requests the buffer state message', () =>
956
+ webex.internal.mercury
957
+ ._prepareUrl()
958
+ .then((wsUrl) => assert.match(wsUrl, /.*bufferStates=true.*/)));
959
+
960
+ it('does not add conditional properties', () =>
961
+ webex.internal.mercury._prepareUrl().then((wsUrl) => {
962
+ assert.notMatch(wsUrl, /mercuryRegistrationStatus/);
963
+ assert.notMatch(wsUrl, /mercuryRegistrationStatus/);
964
+ assert.notMatch(wsUrl, /isRegistrationRefreshEnabled/);
965
+ assert.notMatch(wsUrl, /multipleConnections/);
966
+ }));
967
+
968
+ describe('when web-high-availability is enabled', () => {
969
+ it('uses webSocketUrl provided by device', () => {
970
+ webex.internal.device.useServiceCatalogUrl = sinon
971
+ .stub()
972
+ .returns(Promise.resolve('ws://example-2.com'));
973
+ webex.internal.feature.getFeature.onCall(0).returns(Promise.resolve(true));
974
+
975
+ return webex.internal.mercury
976
+ ._prepareUrl()
977
+ .then((wsUrl) => assert.match(wsUrl, /example-2.com/));
978
+ });
979
+ it('uses high priority url instead of provided webSocketUrl', () => {
980
+ webex.internal.feature.getFeature.onCall(0).returns(Promise.resolve(true));
981
+ webex.internal.services.convertUrlToPriorityHostUrl = sinon
982
+ .stub()
983
+ .returns(Promise.resolve('ws://example-2.com'));
984
+ return webex.internal.mercury
985
+ ._prepareUrl('ws://provided.com')
986
+ .then((wsUrl) => assert.match(wsUrl, /example-2.com/));
987
+ });
988
+ });
989
+
990
+ describe("when 'web-shared-socket' is enabled", () => {
991
+ beforeEach(() => {
992
+ webex.internal.feature.getFeature.returns(Promise.resolve(true));
993
+ });
994
+
995
+ it('requests shared socket support', () =>
996
+ webex.internal.mercury
997
+ ._prepareUrl()
998
+ .then((wsUrl) => assert.match(wsUrl, /isRegistrationRefreshEnabled=true/)));
999
+
1000
+ it('requests the registration banner', () =>
1001
+ webex.internal.mercury
1002
+ ._prepareUrl()
1003
+ .then((wsUrl) => assert.match(wsUrl, /mercuryRegistrationStatus=true/)));
1004
+
1005
+ it('does not request the buffer state message', () =>
1006
+ webex.internal.mercury._prepareUrl().then((wsUrl) => {
1007
+ assert.match(wsUrl, /mercuryRegistrationStatus=true/);
1008
+ assert.notMatch(wsUrl, /bufferStates/);
1009
+ }));
1010
+ });
1011
+
1012
+ describe('when using an ephemeral device', () => {
1013
+ beforeEach(() => {
1014
+ webex.config.device.ephemeral = true;
1015
+ });
1016
+
1017
+ it('indicates multiple connections may be coming from this user', () =>
1018
+ webex.internal.mercury
1019
+ ._prepareUrl()
1020
+ .then((wsUrl) => assert.match(wsUrl, /multipleConnections/)));
1021
+ });
1022
+ });
1023
+
1024
+ describe('ping pong latency event is forwarded', () => {
1025
+ it('should forward ping pong latency event', () => {
1026
+ const spy = sinon.spy();
1027
+
1028
+ mercury.on('ping-pong-latency', spy);
1029
+
1030
+ return mercury.connect().then(() => {
1031
+ assert.calledWith(spy, 0);
1032
+ assert.calledOnce(spy);
1033
+ });
1034
+ });
1035
+ });
1036
+
1037
+ describe('shutdown protocol', () => {
1038
+ describe('#_handleImminentShutdown()', () => {
1039
+ let connectWithBackoffStub;
1040
+ const sessionId = 'mercury-default-session';
1041
+
1042
+ beforeEach(() => {
1043
+ mercury.connected = true;
1044
+ mercury.sockets.set(sessionId, {
1045
+ url: 'ws://old-socket.com',
1046
+ removeAllListeners: sinon.stub(),
1047
+ });
1048
+ mercury.socket = mercury.sockets.get(sessionId);
1049
+ connectWithBackoffStub = sinon.stub(mercury, '_connectWithBackoff');
1050
+ connectWithBackoffStub.returns(Promise.resolve());
1051
+ sinon.stub(mercury, '_emit');
1052
+ });
1053
+
1054
+ afterEach(() => {
1055
+ connectWithBackoffStub.restore();
1056
+ mercury._emit.restore();
1057
+ mercury.sockets.clear();
1058
+ });
1059
+
1060
+ it('should be idempotent - no-op if already in progress', () => {
1061
+ // Simulate an existing switchover in progress by seeding the backoff map
1062
+ mercury._shutdownSwitchoverBackoffCalls.set(sessionId, {placeholder: true});
1063
+
1064
+ mercury._handleImminentShutdown(sessionId);
1065
+
1066
+ assert.notCalled(connectWithBackoffStub);
1067
+ });
1068
+
1069
+ it('should set switchover flags when called', () => {
1070
+ mercury._handleImminentShutdown(sessionId);
1071
+
1072
+ // With _connectWithBackoff stubbed, the backoff map entry may not be created here.
1073
+ // Assert that switchover initiation state was set and a shutdown switchover connect was requested.
1074
+ assert.isDefined(mercury._shutdownSwitchoverId);
1075
+
1076
+ assert.calledOnce(connectWithBackoffStub);
1077
+ const callArgs = connectWithBackoffStub.firstCall.args;
1078
+ assert.isUndefined(callArgs[0]); // webSocketUrl
1079
+ assert.equal(callArgs[1], sessionId); // sessionId
1080
+ assert.isObject(callArgs[2]); // context
1081
+ assert.isTrue(callArgs[2].isShutdownSwitchover);
1082
+ assert.isObject(callArgs[2].attemptOptions);
1083
+ assert.isTrue(callArgs[2].attemptOptions.isShutdownSwitchover);
1084
+ });
1085
+
1086
+ it('should call _connectWithBackoff with correct parameters', (done) => {
1087
+ mercury._handleImminentShutdown(sessionId);
1088
+
1089
+ process.nextTick(() => {
1090
+ assert.calledOnce(connectWithBackoffStub);
1091
+ const callArgs = connectWithBackoffStub.firstCall.args;
1092
+ assert.isUndefined(callArgs[0]); // webSocketUrl
1093
+ assert.equal(callArgs[1], sessionId); // sessionId
1094
+ assert.isObject(callArgs[2]); // context
1095
+ assert.isTrue(callArgs[2].isShutdownSwitchover);
1096
+ assert.isObject(callArgs[2].attemptOptions);
1097
+ assert.isTrue(callArgs[2].attemptOptions.isShutdownSwitchover);
1098
+ done();
1099
+ });
1100
+ });
1101
+
1102
+ it('should handle exceptions during switchover', () => {
1103
+ connectWithBackoffStub.restore();
1104
+ sinon.stub(mercury, '_connectWithBackoff').throws(new Error('Connection failed'));
1105
+
1106
+ mercury._handleImminentShutdown(sessionId);
1107
+
1108
+ // When an exception happens synchronously, the placeholder entry
1109
+ // should be removed from the map.
1110
+ const switchoverCall = mercury._shutdownSwitchoverBackoffCalls.get(sessionId);
1111
+ assert.isUndefined(switchoverCall);
1112
+ mercury._connectWithBackoff.restore();
1113
+ });
1114
+ });
1115
+
1116
+
1117
+ describe('#_onmessage() with shutdown message', () => {
1118
+ beforeEach(() => {
1119
+ sinon.stub(mercury, '_handleImminentShutdown');
1120
+ sinon.stub(mercury, '_emit');
1121
+ sinon.stub(mercury, '_setTimeOffset');
1122
+ });
1123
+
1124
+ afterEach(() => {
1125
+ mercury._handleImminentShutdown.restore();
1126
+ mercury._emit.restore();
1127
+ mercury._setTimeOffset.restore();
1128
+ });
1129
+
1130
+ it('should trigger _handleImminentShutdown on shutdown message', () => {
1131
+ const shutdownEvent = {
1132
+ data: {
1133
+ type: 'shutdown',
1134
+ },
1135
+ };
1136
+
1137
+ const result = mercury._onmessage(mercury.defaultSessionId, shutdownEvent);
1138
+
1139
+ assert.calledOnce(mercury._handleImminentShutdown);
1140
+ assert.calledWith(
1141
+ mercury._emit,
1142
+ mercury.defaultSessionId,
1143
+ 'event:mercury_shutdown_imminent',
1144
+ shutdownEvent.data
1145
+ );
1146
+ assert.instanceOf(result, Promise);
1147
+ });
1148
+
1149
+ it('should handle shutdown message without additional data gracefully', () => {
1150
+ const shutdownEvent = {
1151
+ data: {
1152
+ type: 'shutdown',
1153
+ },
1154
+ };
1155
+
1156
+ mercury._onmessage(mercury.defaultSessionId, shutdownEvent);
1157
+
1158
+ assert.calledOnce(mercury._handleImminentShutdown);
1159
+ });
1160
+
1161
+ it('should not trigger shutdown handling for non-shutdown messages', () => {
1162
+ const regularEvent = {
1163
+ data: {
1164
+ type: 'regular',
1165
+ data: {
1166
+ eventType: 'conversation.activity',
1167
+ },
1168
+ },
1169
+ };
1170
+
1171
+ mercury._onmessage(mercury.defaultSessionId, regularEvent);
1172
+
1173
+ assert.notCalled(mercury._handleImminentShutdown);
1174
+ });
1175
+ });
1176
+
1177
+ describe('#_onmessage() with missing data or eventType', () => {
1178
+ beforeEach(() => {
1179
+ sinon.stub(mercury, '_emit');
1180
+ sinon.stub(mercury, '_setTimeOffset');
1181
+ sinon.stub(mercury, '_applyOverrides');
1182
+ });
1183
+
1184
+ afterEach(() => {
1185
+ mercury._emit.restore();
1186
+ mercury._setTimeOffset.restore();
1187
+ mercury._applyOverrides.restore();
1188
+ });
1189
+
1190
+ it('should not throw when envelope.data is undefined', () => {
1191
+ const event = {
1192
+ data: {
1193
+ type: 'someType',
1194
+ // no nested data property
1195
+ },
1196
+ };
1197
+
1198
+ const result = mercury._onmessage(mercury.defaultSessionId, event);
1199
+
1200
+ assert.instanceOf(result, Promise);
1201
+ assert.calledWith(mercury._emit, mercury.defaultSessionId, 'event', event.data);
1202
+ });
1203
+
1204
+ it('should not throw when data.eventType is undefined', () => {
1205
+ const event = {
1206
+ data: {
1207
+ type: 'someType',
1208
+ data: {
1209
+ // no eventType property
1210
+ someField: 'value',
1211
+ },
1212
+ },
1213
+ };
1214
+
1215
+ const result = mercury._onmessage(mercury.defaultSessionId, event);
1216
+
1217
+ assert.instanceOf(result, Promise);
1218
+ assert.calledWith(mercury._emit, mercury.defaultSessionId, 'event', event.data);
1219
+ });
1220
+
1221
+ it('should emit generic event for messages without eventType (e.g. subscription responses)', () => {
1222
+ const event = {
1223
+ data: {
1224
+ id: 'msg-123',
1225
+ sequenceNumber: 5,
1226
+ data: {
1227
+ statusCode: 200,
1228
+ },
1229
+ },
1230
+ };
1231
+
1232
+ const result = mercury._onmessage(mercury.defaultSessionId, event);
1233
+
1234
+ assert.instanceOf(result, Promise);
1235
+ assert.calledOnce(mercury._emit);
1236
+ assert.calledWith(mercury._emit, mercury.defaultSessionId, 'event', event.data);
1237
+ });
1238
+
1239
+ it('should still process messages with a valid eventType', async () => {
1240
+ const event = {
1241
+ data: {
1242
+ data: {
1243
+ eventType: 'conversation.activity',
1244
+ },
1245
+ },
1246
+ };
1247
+
1248
+ await mercury._onmessage(mercury.defaultSessionId, event);
1249
+
1250
+ // Normal flow emits namespace-specific events after processing handlers.
1251
+ // The early-return guard only emits 'event', so asserting these proves the normal path was taken.
1252
+ assert.calledWith(mercury._emit, mercury.defaultSessionId, 'event:conversation', event.data);
1253
+ assert.calledWith(mercury._emit, mercury.defaultSessionId, 'event:conversation.activity', event.data);
1254
+ });
1255
+ });
1256
+
1257
+ describe('#_getEventHandlers()', () => {
1258
+ it('should return an empty array when eventType is undefined', () => {
1259
+ const result = mercury._getEventHandlers(undefined);
1260
+
1261
+ assert.deepEqual(result, []);
1262
+ });
1263
+
1264
+ it('should return an empty array when eventType is null', () => {
1265
+ const result = mercury._getEventHandlers(null);
1266
+
1267
+ assert.deepEqual(result, []);
1268
+ });
1269
+
1270
+ it('should return an empty array when eventType is an empty string', () => {
1271
+ const result = mercury._getEventHandlers('');
1272
+
1273
+ assert.deepEqual(result, []);
1274
+ });
1275
+
1276
+ it('should return an empty array when namespace is not registered', () => {
1277
+ const result = mercury._getEventHandlers('unknownNamespace.someEvent');
1278
+
1279
+ assert.deepEqual(result, []);
1280
+ });
1281
+ });
1282
+
1283
+ describe('#_onclose() with code 4001 (shutdown replacement)', () => {
1284
+ let mockSocket, anotherSocket;
1285
+
1286
+ beforeEach(() => {
1287
+ mockSocket = {
1288
+ url: 'ws://active-socket.com',
1289
+ removeAllListeners: sinon.stub(),
1290
+ };
1291
+ anotherSocket = {
1292
+ url: 'ws://old-socket.com',
1293
+ removeAllListeners: sinon.stub(),
1294
+ };
1295
+ mercury.socket = mockSocket;
1296
+ mercury.sockets.set(mercury.defaultSessionId, mockSocket);
1297
+ mercury.connected = true;
1298
+ sinon.stub(mercury, '_emit');
1299
+ sinon.stub(mercury, '_reconnect');
1300
+ sinon.stub(mercury, 'unset');
1301
+ });
1302
+
1303
+ afterEach(() => {
1304
+ mercury._emit.restore();
1305
+ mercury._reconnect.restore();
1306
+ mercury.unset.restore();
1307
+ });
1308
+
1309
+ it('should handle active socket close with 4001 - permanent failure', () => {
1310
+ const closeEvent = {
1311
+ code: 4001,
1312
+ reason: 'replaced during shutdown',
1313
+ };
1314
+
1315
+ mercury._onclose(mercury.defaultSessionId, closeEvent, mockSocket);
1316
+
1317
+ assert.calledWith(mercury._emit, mercury.defaultSessionId, 'offline.permanent', closeEvent);
1318
+ assert.notCalled(mercury._reconnect); // No reconnect for 4001 on active socket
1319
+ assert.isFalse(mercury.connected);
1320
+ });
1321
+
1322
+ it('should handle non-active socket close with 4001 - no reconnect needed', () => {
1323
+ const closeEvent = {
1324
+ code: 4001,
1325
+ reason: 'replaced during shutdown',
1326
+ };
1327
+
1328
+ mercury._onclose(mercury.defaultSessionId, closeEvent, anotherSocket);
1329
+
1330
+ assert.calledWith(mercury._emit, mercury.defaultSessionId, 'offline.replaced', closeEvent);
1331
+ assert.notCalled(mercury._reconnect);
1332
+ assert.isTrue(mercury.connected); // Should remain connected
1333
+ assert.notCalled(mercury.unset);
1334
+ });
1335
+
1336
+ it('should distinguish between active and non-active socket closes', () => {
1337
+ const closeEvent = {
1338
+ code: 4001,
1339
+ reason: 'replaced during shutdown',
1340
+ };
1341
+
1342
+ // Test non-active socket
1343
+ mercury._onclose(mercury.defaultSessionId, closeEvent, anotherSocket);
1344
+ assert.calledWith(mercury._emit, mercury.defaultSessionId, 'offline.replaced', closeEvent);
1345
+
1346
+ // Reset the spy call history
1347
+ mercury._emit.resetHistory();
1348
+
1349
+ // Test active socket
1350
+ mercury.sockets.set(mercury.defaultSessionId, mockSocket);
1351
+ mercury._onclose(mercury.defaultSessionId, closeEvent, mockSocket);
1352
+ assert.calledWith(mercury._emit, mercury.defaultSessionId, 'offline.permanent', closeEvent);
1353
+ });
1354
+
1355
+ it('should handle missing sourceSocket parameter (treats as non-active)', () => {
1356
+ const closeEvent = {
1357
+ code: 4001,
1358
+ reason: 'replaced during shutdown',
1359
+ };
1360
+
1361
+ mercury._onclose(mercury.defaultSessionId, closeEvent); // No sourceSocket parameter
1362
+
1363
+ // With simplified logic, undefined !== this.socket, so isActiveSocket = false
1364
+ assert.calledWith(mercury._emit, mercury.defaultSessionId, 'offline.replaced', closeEvent);
1365
+ assert.notCalled(mercury._reconnect);
1366
+ });
1367
+
1368
+ it('should clean up event listeners from non-active socket when it closes', () => {
1369
+ const closeEvent = {
1370
+ code: 4001,
1371
+ reason: 'replaced during shutdown',
1372
+ };
1373
+
1374
+ // Close non-active socket (not the active one)
1375
+ mercury._onclose(mercury.defaultSessionId, closeEvent, anotherSocket);
1376
+
1377
+ // Verify listeners were removed from the old socket
1378
+ // The _onclose method checks if sourceSocket !== this.socket (non-active)
1379
+ // and then calls removeAllListeners in the else branch
1380
+ assert.calledOnce(anotherSocket.removeAllListeners);
1381
+ });
1382
+
1383
+ it('should not clean up listeners from active socket listeners until close handler runs', () => {
1384
+ const closeEvent = {
1385
+ code: 4001,
1386
+ reason: 'replaced during shutdown',
1387
+ };
1388
+
1389
+ // Close active socket
1390
+ mercury._onclose(mercury.defaultSessionId, closeEvent, mockSocket);
1391
+
1392
+ // Verify listeners were removed from active socket
1393
+ assert.calledOnce(mockSocket.removeAllListeners);
1394
+ });
1395
+ });
1396
+
1397
+ describe('shutdown switchover with retry logic', () => {
1398
+ let connectWithBackoffStub;
1399
+ const sessionId = 'mercury-default-session';
1400
+
1401
+ beforeEach(() => {
1402
+ mercury.connected = true;
1403
+ mercury.sockets.set(sessionId, {
1404
+ url: 'ws://old-socket.com',
1405
+ removeAllListeners: sinon.stub(),
1406
+ });
1407
+ mercury.socket = mercury.sockets.get(sessionId);
1408
+ connectWithBackoffStub = sinon.stub(mercury, '_connectWithBackoff');
1409
+ sinon.stub(mercury, '_emit');
1410
+ });
1411
+
1412
+ afterEach(() => {
1413
+ connectWithBackoffStub.restore();
1414
+ mercury._emit.restore();
1415
+ mercury.sockets.clear();
1416
+ });
1417
+
1418
+ it('should call _connectWithBackoff with shutdown switchover context', (done) => {
1419
+ connectWithBackoffStub.returns(Promise.resolve());
1420
+
1421
+ mercury._handleImminentShutdown(sessionId);
1422
+
1423
+ process.nextTick(() => {
1424
+ assert.calledOnce(connectWithBackoffStub);
1425
+ const callArgs = connectWithBackoffStub.firstCall.args;
1426
+
1427
+ assert.isUndefined(callArgs[0]); // webSocketUrl
1428
+ assert.equal(callArgs[1], sessionId);
1429
+ assert.isObject(callArgs[2]);
1430
+ assert.isTrue(callArgs[2].isShutdownSwitchover);
1431
+ assert.isObject(callArgs[2].attemptOptions);
1432
+ assert.isTrue(callArgs[2].attemptOptions.isShutdownSwitchover);
1433
+ done();
1434
+ });
1435
+ });
1436
+
1437
+ it('should set _shutdownSwitchoverInProgress flag during switchover', () => {
1438
+ // With the new behavior, "in progress" is represented by the presence
1439
+ // of an entry in _shutdownSwitchoverBackoffCalls.
1440
+ // Since _connectWithBackoff is stubbed in this suite, simulate its side-effect
1441
+ // of seeding the backoff-call map entry.
1442
+ connectWithBackoffStub.callsFake(() => {
1443
+ mercury._shutdownSwitchoverBackoffCalls.set(sessionId, {placeholder: true});
1444
+ return new Promise(() => {}); // Never resolves
1445
+ });
1446
+
1447
+ mercury._handleImminentShutdown(sessionId);
1448
+
1449
+ const switchoverBackoffCall = mercury._shutdownSwitchoverBackoffCalls.get(sessionId);
1450
+ assert.isOk(switchoverBackoffCall);
1451
+ });
1452
+
1453
+ it('should emit success event when switchover completes', async () => {
1454
+ connectWithBackoffStub.callsFake((url, sid, context) => {
1455
+ if (context && context.attemptOptions && context.attemptOptions.onSuccess) {
1456
+ const mockSocket = {url: 'ws://new-socket.com'};
1457
+ context.attemptOptions.onSuccess(mockSocket, 'ws://new-socket.com');
1458
+ }
1459
+ return Promise.resolve();
1460
+ });
1461
+
1462
+ mercury._handleImminentShutdown(sessionId);
1463
+
1464
+ await promiseTick(50);
1465
+
1466
+ const emitCalls = mercury._emit.getCalls();
1467
+ const hasCompleteEvent = emitCalls.some(
1468
+ (call) =>
1469
+ call.args[0] === sessionId &&
1470
+ call.args[1] === 'event:mercury_shutdown_switchover_complete'
1471
+ );
1472
+
1473
+ assert.isTrue(hasCompleteEvent, 'Should emit switchover complete event');
1474
+ });
1475
+
1476
+ it('should emit failure event when switchover exhausts retries', async () => {
1477
+ const testError = new Error('Connection failed');
1478
+
1479
+ connectWithBackoffStub.returns(Promise.reject(testError));
1480
+
1481
+ mercury._handleImminentShutdown(sessionId);
1482
+ await promiseTick(50);
1483
+
1484
+ const emitCalls = mercury._emit.getCalls();
1485
+ const hasFailureEvent = emitCalls.some(
1486
+ (call) =>
1487
+ call.args[0] === sessionId &&
1488
+ call.args[1] === 'event:mercury_shutdown_switchover_failed' &&
1489
+ call.args[2] &&
1490
+ call.args[2].reason === testError
1491
+ );
1492
+
1493
+ assert.isTrue(hasFailureEvent, 'Should emit switchover failed event');
1494
+ });
1495
+
1496
+ it('should allow old socket to be closed by server after switchover failure', async () => {
1497
+ connectWithBackoffStub.returns(Promise.reject(new Error('Failed')));
1498
+
1499
+ mercury._handleImminentShutdown(sessionId);
1500
+ await promiseTick(50);
1501
+
1502
+ assert.equal(mercury.socket.removeAllListeners.callCount, 0);
1503
+ });
1504
+ });
1505
+
1506
+ describe('#_prepareAndOpenSocket()', () => {
1507
+ let mockSocket, prepareUrlStub, getUserTokenStub;
1508
+
1509
+ beforeEach(() => {
1510
+ mockSocket = {
1511
+ open: sinon.stub().returns(Promise.resolve()),
1512
+ };
1513
+ prepareUrlStub = sinon
1514
+ .stub(mercury, '_prepareUrl')
1515
+ .returns(Promise.resolve('ws://example.com'));
1516
+ getUserTokenStub = webex.credentials.getUserToken;
1517
+ getUserTokenStub.returns(
1518
+ Promise.resolve({
1519
+ toString: () => 'mock-token',
1520
+ })
1521
+ );
1522
+ });
1523
+
1524
+ afterEach(() => {
1525
+ prepareUrlStub.restore();
1526
+ });
1527
+
1528
+ it('should prepare URL and get user token', async () => {
1529
+ await mercury._prepareAndOpenSocket(mockSocket, 'ws://test.com', false);
1530
+
1531
+ assert.calledOnce(prepareUrlStub);
1532
+ assert.calledWith(prepareUrlStub, 'ws://test.com');
1533
+ assert.calledOnce(getUserTokenStub);
1534
+ });
1535
+
1536
+ it('should open socket with correct options for normal connection', async () => {
1537
+ await mercury._prepareAndOpenSocket(mockSocket, undefined, false);
1538
+
1539
+ assert.calledOnce(mockSocket.open);
1540
+ const callArgs = mockSocket.open.firstCall.args;
1541
+
1542
+ assert.equal(callArgs[0], 'ws://example.com');
1543
+ assert.isObject(callArgs[1]);
1544
+ assert.equal(callArgs[1].token, 'mock-token');
1545
+ assert.isDefined(callArgs[1].forceCloseDelay);
1546
+ assert.isDefined(callArgs[1].pingInterval);
1547
+ assert.isDefined(callArgs[1].pongTimeout);
1548
+ });
1549
+
1550
+ it('should log with correct prefix for normal connection', async () => {
1551
+ await mercury._prepareAndOpenSocket(mockSocket, undefined, false);
1552
+
1553
+ // The method should complete successfully - we're testing it runs without error
1554
+ // Actual log message verification is complex due to existing stubs in parent scope
1555
+ assert.calledOnce(mockSocket.open);
1556
+ });
1557
+
1558
+ it('should log with shutdown prefix for shutdown connection', async () => {
1559
+ await mercury._prepareAndOpenSocket(mockSocket, undefined, true);
1560
+
1561
+ // The method should complete successfully with shutdown flag
1562
+ assert.calledOnce(mockSocket.open);
1563
+ });
1564
+
1565
+ it('should merge custom mercury options when provided', async () => {
1566
+ webex.config.defaultMercuryOptions = {
1567
+ customOption: 'test-value',
1568
+ pingInterval: 99999,
1569
+ };
1570
+
1571
+ await mercury._prepareAndOpenSocket(mockSocket, undefined, false);
1572
+
1573
+ const callArgs = mockSocket.open.firstCall.args;
1574
+
1575
+ assert.equal(callArgs[1].customOption, 'test-value');
1576
+ assert.equal(callArgs[1].pingInterval, 99999); // Custom value overrides default
1577
+ });
1578
+
1579
+ it('should return the webSocketUrl after opening', async () => {
1580
+ const result = await mercury._prepareAndOpenSocket(mockSocket, undefined, false);
1581
+
1582
+ assert.equal(result, 'ws://example.com');
1583
+ });
1584
+
1585
+ it('should handle errors during socket open', async () => {
1586
+ mockSocket.open.returns(Promise.reject(new Error('Open failed')));
1587
+
1588
+ try {
1589
+ await mercury._prepareAndOpenSocket(mockSocket, undefined, false);
1590
+ assert.fail('Should have thrown an error');
1591
+ } catch (err) {
1592
+ assert.equal(err.message, 'Open failed');
1593
+ }
1594
+ });
1595
+ });
1596
+
1597
+ describe('#_attemptConnection() with shutdown switchover', () => {
1598
+ let prepareAndOpenSocketStub, callback;
1599
+ const sessionId = 'mercury-default-session';
1600
+
1601
+ beforeEach(() => {
1602
+ prepareAndOpenSocketStub = sinon
1603
+ .stub(mercury, '_prepareAndOpenSocket')
1604
+ .returns(Promise.resolve('ws://new-socket.com'));
1605
+ callback = sinon.stub();
1606
+ mercury._shutdownSwitchoverBackoffCalls.set(sessionId, {abort: sinon.stub()});
1607
+ mercury.socket = {url: 'ws://test.com'};
1608
+ mercury.connected = true;
1609
+ sinon.stub(mercury, '_emit');
1610
+ sinon.stub(mercury, '_attachSocketEventListeners');
1611
+ });
1612
+
1613
+ afterEach(() => {
1614
+ prepareAndOpenSocketStub.restore();
1615
+ mercury._emit.restore();
1616
+ mercury._attachSocketEventListeners.restore();
1617
+ mercury._shutdownSwitchoverBackoffCalls.clear();
1618
+ });
1619
+
1620
+ it('should not set socket reference before opening for shutdown switchover', async () => {
1621
+ const originalSocket = mercury.socket;
1622
+
1623
+ await mercury._attemptConnection('ws://test.com', sessionId, callback, {
1624
+ isShutdownSwitchover: true,
1625
+ onSuccess: (newSocket, url) => {
1626
+ assert.equal(mercury.socket, originalSocket);
1627
+ },
1628
+ });
1629
+
1630
+ assert.equal(mercury.socket, originalSocket);
1631
+ });
1632
+
1633
+ it('should call onSuccess callback with new socket and URL for shutdown', async () => {
1634
+ const onSuccessStub = sinon.stub();
1635
+
1636
+ await mercury._attemptConnection('ws://test.com', sessionId, callback, {
1637
+ isShutdownSwitchover: true,
1638
+ onSuccess: onSuccessStub,
1639
+ });
1640
+
1641
+ assert.calledOnce(onSuccessStub);
1642
+ assert.equal(onSuccessStub.firstCall.args[1], 'ws://new-socket.com');
1643
+ });
1644
+
1645
+ it('should emit shutdown switchover complete event', async () => {
1646
+ await mercury._attemptConnection('ws://test.com', sessionId, callback, {
1647
+ isShutdownSwitchover: true,
1648
+ onSuccess: (newSocket, url) => {
1649
+ mercury.socket = newSocket;
1650
+ mercury.connected = true;
1651
+ mercury._emit(
1652
+ sessionId,
1653
+ 'event:mercury_shutdown_switchover_complete',
1654
+ {url}
1655
+ );
1656
+ },
1657
+ });
1658
+
1659
+ assert.calledWith(
1660
+ mercury._emit,
1661
+ sessionId,
1662
+ 'event:mercury_shutdown_switchover_complete',
1663
+ sinon.match.has('url', 'ws://new-socket.com')
1664
+ );
1665
+ });
1666
+
1667
+ it('should use simpler error handling for shutdown switchover failures', async () => {
1668
+ prepareAndOpenSocketStub.returns(Promise.reject(new Error('Connection failed')));
1669
+
1670
+ await mercury
1671
+ ._attemptConnection('ws://test.com', sessionId, callback, {
1672
+ isShutdownSwitchover: true,
1673
+ })
1674
+ .catch(() => {});
1675
+
1676
+ assert.calledOnce(callback);
1677
+ assert.instanceOf(callback.firstCall.args[0], Error);
1678
+ });
1679
+
1680
+ it('should check _shutdownSwitchoverBackoffCall for shutdown connections', () => {
1681
+ mercury._shutdownSwitchoverBackoffCalls.clear();
1682
+
1683
+ const result = mercury._attemptConnection(
1684
+ 'ws://test.com',
1685
+ sessionId,
1686
+ callback,
1687
+ {isShutdownSwitchover: true}
1688
+ );
1689
+
1690
+ return result.catch((err) => {
1691
+ assert.instanceOf(err, Error);
1692
+ assert.match(err.message, /switchover backoff call/);
1693
+ });
1694
+ });
1695
+ });
1696
+
1697
+ describe('#_connectWithBackoff() with shutdown switchover', () => {
1698
+ const sessionId = 'mercury-default-session';
1699
+
1700
+ it('should use shutdown-specific parameters when called', () => {
1701
+ const connectWithBackoffStub = sinon
1702
+ .stub(mercury, '_connectWithBackoff')
1703
+ .returns(Promise.resolve());
1704
+
1705
+ mercury._handleImminentShutdown(sessionId);
1706
+
1707
+ assert.calledOnce(connectWithBackoffStub);
1708
+ const callArgs = connectWithBackoffStub.firstCall.args;
1709
+ assert.equal(callArgs[1], sessionId);
1710
+ assert.isObject(callArgs[2]);
1711
+ assert.isTrue(callArgs[2].isShutdownSwitchover);
1712
+
1713
+ connectWithBackoffStub.restore();
1714
+ });
1715
+
1716
+ it('should pass shutdown switchover options to _attemptConnection', () => {
1717
+ const attemptStub = sinon.stub(mercury, '_attemptConnection');
1718
+ attemptStub.callsFake((url, sid, cb) => cb());
1719
+
1720
+ const context = {
1721
+ isShutdownSwitchover: true,
1722
+ attemptOptions: {
1723
+ isShutdownSwitchover: true,
1724
+ onSuccess: () => {},
1725
+ },
1726
+ };
1727
+
1728
+ const promise = mercury._connectWithBackoff(undefined, sessionId, context);
1729
+
1730
+ return promise.then(() => {
1731
+ assert.calledOnce(attemptStub);
1732
+ const callArgs = attemptStub.firstCall.args;
1733
+ assert.equal(callArgs[1], sessionId);
1734
+ assert.isObject(callArgs[3]);
1735
+ assert.isTrue(callArgs[3].isShutdownSwitchover);
1736
+ attemptStub.restore();
1737
+ });
1738
+ });
1739
+
1740
+ it('should set and clear state flags appropriately', () => {
1741
+ sinon.stub(mercury, '_attemptConnection').callsFake((url, sid, cb) => cb());
1742
+
1743
+ mercury._shutdownSwitchoverBackoffCalls.set(sessionId, {placeholder: true});
1744
+
1745
+ const promise = mercury._connectWithBackoff(undefined, sessionId, {
1746
+ isShutdownSwitchover: true,
1747
+ attemptOptions: {isShutdownSwitchover: true, onSuccess: () => {}},
1748
+ });
1749
+
1750
+ return promise.then(() => {
1751
+ assert.isUndefined(mercury._shutdownSwitchoverBackoffCalls.get(sessionId));
1752
+ mercury._attemptConnection.restore();
1753
+ });
1754
+ });
1755
+ });
1756
+
1757
+ describe('#disconnect() with shutdown switchover in progress', () => {
1758
+ let abortStub;
1759
+ const sessionId = 'mercury-default-session';
1760
+
1761
+ beforeEach(() => {
1762
+ mercury.sockets.clear();
1763
+ mercury.sockets.set(sessionId, {
1764
+ close: sinon.stub().returns(Promise.resolve()),
1765
+ removeAllListeners: sinon.stub(),
1766
+ });
1767
+ abortStub = sinon.stub();
1768
+ mercury._shutdownSwitchoverBackoffCalls.set(sessionId, {abort: abortStub});
1769
+ });
1770
+
1771
+ it('should abort shutdown switchover backoff call on disconnect', async () => {
1772
+ await mercury.disconnect(undefined, sessionId);
1773
+
1774
+ assert.calledOnce(abortStub);
1775
+ });
1776
+
1777
+ it('should handle disconnect when no switchover is in progress', async () => {
1778
+ mercury._shutdownSwitchoverBackoffCalls.clear();
1779
+
1780
+ await mercury.disconnect(undefined, sessionId);
1781
+
1782
+ assert.calledOnce(mercury.sockets.get(sessionId).close);
1783
+ });
1784
+ });
1785
+ });
1786
+ });
1787
+ });