@webex/webex-core 3.12.0-task-refactor.1 → 3.12.0-webex-services-ready.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/config.js +7 -0
  2. package/dist/config.js.map +1 -1
  3. package/dist/credentials-config.js +12 -0
  4. package/dist/credentials-config.js.map +1 -1
  5. package/dist/interceptors/redirect.js +1 -1
  6. package/dist/interceptors/redirect.js.map +1 -1
  7. package/dist/lib/batcher.js +23 -7
  8. package/dist/lib/batcher.js.map +1 -1
  9. package/dist/lib/credentials/credentials.js +48 -4
  10. package/dist/lib/credentials/credentials.js.map +1 -1
  11. package/dist/lib/credentials/token.js +1 -1
  12. package/dist/lib/services/service-url.js +11 -1
  13. package/dist/lib/services/service-url.js.map +1 -1
  14. package/dist/lib/services/services.js +583 -94
  15. package/dist/lib/services/services.js.map +1 -1
  16. package/dist/lib/services-v2/services-v2.js +507 -41
  17. package/dist/lib/services-v2/services-v2.js.map +1 -1
  18. package/dist/lib/services-v2/types.js.map +1 -1
  19. package/dist/plugins/logger.js +1 -1
  20. package/dist/webex-core.js +2 -2
  21. package/dist/webex-core.js.map +1 -1
  22. package/package.json +13 -13
  23. package/src/config.js +7 -0
  24. package/src/credentials-config.js +13 -0
  25. package/src/interceptors/redirect.js +4 -1
  26. package/src/lib/batcher.js +25 -10
  27. package/src/lib/credentials/credentials.js +50 -3
  28. package/src/lib/services/service-url.js +9 -1
  29. package/src/lib/services/services.js +433 -6
  30. package/src/lib/services-v2/services-v2.ts +417 -2
  31. package/src/lib/services-v2/types.ts +5 -0
  32. package/test/integration/spec/services/service-catalog.js +16 -11
  33. package/test/integration/spec/services/services.js +58 -9
  34. package/test/integration/spec/services-v2/services-v2.js +49 -6
  35. package/test/unit/spec/credentials/credentials.js +133 -2
  36. package/test/unit/spec/lib/batcher.js +56 -0
  37. package/test/unit/spec/services/service-url.js +110 -0
  38. package/test/unit/spec/services/services.js +692 -12
  39. package/test/unit/spec/services-v2/services-v2.ts +539 -0
  40. package/test/unit/spec/webex-core.js +2 -0
  41. package/test/unit/spec/webex-internal-core.js +2 -0
@@ -46,6 +46,7 @@ describe('webex-core', () => {
46
46
  services.initialize();
47
47
 
48
48
  // call the onReady callback
49
+ services.listenToOnce.getCall(0).args[2]();
49
50
  services.listenToOnce.getCall(1).args[2]();
50
51
  await waitForAsync();
51
52
 
@@ -59,6 +60,7 @@ describe('webex-core', () => {
59
60
  services.initialize();
60
61
 
61
62
  // call the onReady callback
63
+ services.listenToOnce.getCall(0).args[2]();
62
64
  services.listenToOnce.getCall(1).args[2]();
63
65
  await waitForAsync();
64
66
 
@@ -81,6 +83,7 @@ describe('webex-core', () => {
81
83
  services.initialize();
82
84
 
83
85
  // call the onReady callback
86
+ services.listenToOnce.getCall(0).args[2]();
84
87
  services.listenToOnce.getCall(1).args[2]();
85
88
 
86
89
  await waitForAsync();
@@ -114,6 +117,7 @@ describe('webex-core', () => {
114
117
  services.initialize();
115
118
 
116
119
  // call the onReady callback
120
+ services.listenToOnce.getCall(0).args[2]();
117
121
  services.listenToOnce.getCall(1).args[2]();
118
122
 
119
123
  await waitForAsync();
@@ -125,6 +129,292 @@ describe('webex-core', () => {
125
129
  );
126
130
  }
127
131
  );
132
+
133
+ it('sets services.ready to true when waitForCatalogInit is disabled (default)', () => {
134
+ services.listenToOnce = sinon.stub();
135
+ services.initialize();
136
+
137
+ // Fire the change:config handler which decides gated-vs-ungated and, in
138
+ // ungated mode, flips services.ready to true.
139
+ services.listenToOnce.getCall(0).args[2]();
140
+
141
+ assert.isTrue(services.ready, 'services.ready should be true so it does not block webex.ready');
142
+ });
143
+
144
+ it('listens on the "ready" event when waitForCatalogInit is disabled (default)', () => {
145
+ services.listenToOnce = sinon.stub();
146
+ services.initialize();
147
+
148
+ // Fire change:config to trigger the mode-specific listener registration.
149
+ services.listenToOnce.getCall(0).args[2]();
150
+
151
+ const [, event] = services.listenToOnce.getCall(1).args;
152
+ assert.equal(event, 'ready', 'default path listens on webex ready');
153
+ });
154
+ });
155
+
156
+ describe('#initialize (waitForCatalogInit=true)', () => {
157
+ beforeEach(() => {
158
+ services.webex.config = {
159
+ ...(services.webex.config || {}),
160
+ services: {waitForCatalogInit: true},
161
+ };
162
+ // Reset ready flag: the outer beforeEach constructs services which
163
+ // runs initialize() once with no config (ungated path, flips ready to
164
+ // true). We reset here so tests that call initialize() a second time
165
+ // with the flag enabled observe the gated behavior from a clean state.
166
+ services.ready = false;
167
+ });
168
+
169
+ it('leaves services.ready=false after initialize (does not flip until finalize)', () => {
170
+ services.listenToOnce = sinon.stub();
171
+ services.initialize();
172
+
173
+ assert.isFalse(services.ready, 'services.ready should block webex.ready until finalize');
174
+ });
175
+
176
+ it('listens on the "loaded" event (not "ready") to avoid deadlock', () => {
177
+ services.listenToOnce = sinon.stub();
178
+ services.initialize();
179
+
180
+ // Fire change:config to trigger the mode-specific listener registration.
181
+ services.listenToOnce.getCall(0).args[2]();
182
+
183
+ const [, event] = services.listenToOnce.getCall(1).args;
184
+ assert.equal(event, 'loaded', 'gated path must listen on loaded to avoid deadlock');
185
+ });
186
+
187
+ it('flips services.ready=true after init succeeds with credentials', async () => {
188
+ services.listenToOnce = sinon.stub();
189
+ services.initServiceCatalogs = sinon.stub().returns(Promise.resolve());
190
+ services.webex.credentials = {
191
+ supertoken: {access_token: 'token'},
192
+ };
193
+
194
+ services.initialize();
195
+ services.listenToOnce.getCall(0).args[2]();
196
+ services.listenToOnce.getCall(1).args[2]();
197
+ await waitForAsync();
198
+
199
+ assert.isTrue(services.ready);
200
+ });
201
+
202
+ it('flips services.ready=true after init succeeds without credentials (preauth path)', async () => {
203
+ services.listenToOnce = sinon.stub();
204
+ services.collectPreauthCatalog = sinon.stub().returns(Promise.resolve());
205
+
206
+ services.initialize();
207
+ services.listenToOnce.getCall(0).args[2]();
208
+ services.listenToOnce.getCall(1).args[2]();
209
+ await waitForAsync();
210
+
211
+ assert.isTrue(services.ready);
212
+ });
213
+
214
+ it('flips services.ready=true even when initServiceCatalogs errors, so webex.ready can fire', async () => {
215
+ services.listenToOnce = sinon.stub();
216
+ services.initServiceCatalogs = sinon.stub().returns(Promise.reject(new Error('boom')));
217
+ services.webex.credentials = {
218
+ supertoken: {access_token: 'token'},
219
+ };
220
+ services.logger.error = sinon.stub();
221
+
222
+ services.initialize();
223
+ services.listenToOnce.getCall(0).args[2]();
224
+ services.listenToOnce.getCall(1).args[2]();
225
+ await waitForAsync();
226
+
227
+ assert.isTrue(services.initFailed);
228
+ assert.isTrue(services.ready);
229
+ });
230
+
231
+ it('flips services.ready=true even when collectPreauthCatalog errors', async () => {
232
+ services.listenToOnce = sinon.stub();
233
+ services.collectPreauthCatalog = sinon.stub().returns(Promise.reject(new Error('boom')));
234
+ services.logger.error = sinon.stub();
235
+
236
+ services.initialize();
237
+ services.listenToOnce.getCall(0).args[2]();
238
+ services.listenToOnce.getCall(1).args[2]();
239
+ await waitForAsync();
240
+
241
+ assert.isTrue(services.initFailed);
242
+ assert.isTrue(services.ready);
243
+ });
244
+
245
+ it('flips services.ready=true when init times out', async () => {
246
+ const clock = sinon.useFakeTimers();
247
+ services.listenToOnce = sinon.stub();
248
+ services.initServiceCatalogs = sinon.stub().returns(new Promise(() => {}));
249
+ services.webex.credentials = {
250
+ supertoken: {access_token: 'token'},
251
+ };
252
+ services.logger.error = sinon.stub();
253
+
254
+ services.initialize();
255
+ services.listenToOnce.getCall(0).args[2]();
256
+ services.listenToOnce.getCall(1).args[2]();
257
+
258
+ await clock.tickAsync(15_001);
259
+ clock.restore();
260
+
261
+ assert.isTrue(services.initFailed);
262
+ assert.isTrue(services.ready);
263
+ sinon.assert.calledWithMatch(
264
+ services.logger.error,
265
+ /services: init timed out after 15000ms/
266
+ );
267
+ });
268
+
269
+ it('awaits an in-flight credentials refresh before flipping services.ready=true', async () => {
270
+ services.listenToOnce = sinon.stub();
271
+ services.initServiceCatalogs = sinon.stub().returns(Promise.resolve());
272
+
273
+ let onChangeIsRefreshing: (() => void) | undefined;
274
+ services.webex.credentials = {
275
+ supertoken: {access_token: 'token'},
276
+ isRefreshing: true,
277
+ once: sinon.stub().callsFake((event: string, cb: () => void) => {
278
+ if (event === 'change:isRefreshing') onChangeIsRefreshing = cb;
279
+ }),
280
+ };
281
+
282
+ services.initialize();
283
+ services.listenToOnce.getCall(0).args[2]();
284
+ services.listenToOnce.getCall(1).args[2]();
285
+ await waitForAsync();
286
+
287
+ assert.isFalse(services.ready, 'must not flip ready while refresh is in flight');
288
+ assert.isFunction(onChangeIsRefreshing, 'must subscribe to change:isRefreshing');
289
+ sinon.assert.calledWith(services.webex.credentials.once, 'change:isRefreshing');
290
+
291
+ onChangeIsRefreshing!();
292
+ await waitForAsync();
293
+
294
+ assert.isTrue(services.ready);
295
+ });
296
+
297
+ it('registers a change:canAuthorize listener when there is no supertoken (fresh-login path)', async () => {
298
+ services.listenToOnce = sinon.stub();
299
+ services.collectPreauthCatalog = sinon.stub().returns(Promise.resolve());
300
+ services.initServiceCatalogs = sinon.stub().returns(Promise.resolve());
301
+
302
+ services.initialize();
303
+ // initialize() creates a new catalog and replaces the WeakMap entry.
304
+ const currentCatalog = services._getCatalog();
305
+ services.listenToOnce.getCall(0).args[2]();
306
+ services.listenToOnce.getCall(1).args[2]();
307
+ await waitForAsync();
308
+
309
+ const freshLoginCall = services.listenToOnce
310
+ .getCalls()
311
+ .find((call: any) => call.args[1] === 'change:canAuthorize');
312
+ assert.isDefined(freshLoginCall, 'expected change:canAuthorize listener to be registered');
313
+
314
+ services.webex.canAuthorize = true;
315
+ currentCatalog.status.postauth.ready = false;
316
+ freshLoginCall!.args[2]();
317
+
318
+ assert.isTrue(
319
+ services.initServiceCatalogs.called,
320
+ 'expected postauth catalog init after canAuthorize flips'
321
+ );
322
+ });
323
+
324
+ it('does not re-init postauth catalog when canAuthorize fires but catalog is already ready', async () => {
325
+ services.listenToOnce = sinon.stub();
326
+ services.collectPreauthCatalog = sinon.stub().returns(Promise.resolve());
327
+ services.initServiceCatalogs = sinon.stub().returns(Promise.resolve());
328
+
329
+ services.initialize();
330
+ // initialize() creates a new catalog; grab the current one.
331
+ const currentCatalog = services._getCatalog();
332
+ services.listenToOnce.getCall(0).args[2]();
333
+ services.listenToOnce.getCall(1).args[2]();
334
+ await waitForAsync();
335
+
336
+ const freshLoginCall = services.listenToOnce
337
+ .getCalls()
338
+ .find((call: any) => call.args[1] === 'change:canAuthorize');
339
+
340
+ services.webex.canAuthorize = true;
341
+ currentCatalog.status.postauth.ready = true;
342
+ freshLoginCall!.args[2]();
343
+
344
+ assert.isFalse(
345
+ services.initServiceCatalogs.called,
346
+ 'must not re-fetch postauth catalog if it is already ready'
347
+ );
348
+ });
349
+
350
+ it('finalizes ready when cache is warm (skips network fetch)', async () => {
351
+ services.listenToOnce = sinon.stub();
352
+ services._loadCatalogFromCache = sinon.stub().returns(Promise.resolve(true));
353
+ services.initServiceCatalogs = sinon.stub().returns(Promise.resolve());
354
+ services.collectPreauthCatalog = sinon.stub().returns(Promise.resolve());
355
+
356
+ services.initialize();
357
+ services.listenToOnce.getCall(0).args[2]();
358
+ services.listenToOnce.getCall(1).args[2]();
359
+ await waitForAsync();
360
+ await waitForAsync();
361
+
362
+ assert.isTrue(services.ready);
363
+ assert.isFalse(
364
+ services.initServiceCatalogs.called,
365
+ 'must skip network fetch when cache is warm'
366
+ );
367
+ assert.isFalse(
368
+ services.collectPreauthCatalog.called,
369
+ 'must skip preauth fetch when cache is warm'
370
+ );
371
+ });
372
+ });
373
+
374
+ describe('#_finalizeReady', () => {
375
+ beforeEach(() => {
376
+ // Outer beforeEach construction already ran initialize() (ungated,
377
+ // sets ready=true). Reset so _finalizeReady's flip is observable.
378
+ services.ready = false;
379
+ });
380
+
381
+ it('sets ready=true immediately when credentials are not refreshing', async () => {
382
+ services.webex.credentials = {isRefreshing: false};
383
+ assert.isFalse(services.ready);
384
+
385
+ await services._finalizeReady();
386
+
387
+ assert.isTrue(services.ready);
388
+ });
389
+
390
+ it('sets ready=true when credentials are missing', async () => {
391
+ services.webex.credentials = undefined;
392
+
393
+ await services._finalizeReady();
394
+
395
+ assert.isTrue(services.ready);
396
+ });
397
+
398
+ it('awaits change:isRefreshing before setting ready=true', async () => {
399
+ let onChangeIsRefreshing: (() => void) | undefined;
400
+ services.webex.credentials = {
401
+ isRefreshing: true,
402
+ once: sinon.stub().callsFake((event: string, cb: () => void) => {
403
+ if (event === 'change:isRefreshing') onChangeIsRefreshing = cb;
404
+ }),
405
+ };
406
+
407
+ const settled = services._finalizeReady();
408
+ await waitForAsync();
409
+
410
+ assert.isFalse(services.ready, 'ready must not flip while refresh is in flight');
411
+ assert.isFunction(onChangeIsRefreshing);
412
+
413
+ onChangeIsRefreshing!();
414
+ await settled;
415
+
416
+ assert.isTrue(services.ready);
417
+ });
128
418
  });
129
419
 
130
420
  describe('#initServiceCatalogs', () => {
@@ -764,6 +1054,7 @@ describe('webex-core', () => {
764
1054
  );
765
1055
  });
766
1056
  });
1057
+
767
1058
  describe('#isValidHost', () => {
768
1059
  beforeEach(() => {
769
1060
  // Setting up a mock services list
@@ -813,5 +1104,253 @@ describe('webex-core', () => {
813
1104
  assert.isFalse(services.isValidHost([]));
814
1105
  });
815
1106
  });
1107
+
1108
+ describe('#isIntegrationEnvironment', () => {
1109
+ it('returns true when u2c URL contains "intb"', () => {
1110
+ services.webex.config = {
1111
+ services: {
1112
+ discovery: {
1113
+ u2c: 'https://u2c-intb.ciscospark.com/u2c/api/v1',
1114
+ },
1115
+ },
1116
+ };
1117
+ assert.isTrue(services.isIntegrationEnvironment());
1118
+ });
1119
+
1120
+ it('returns false when u2c URL does not contain "intb" (production)', () => {
1121
+ services.webex.config = {
1122
+ services: {
1123
+ discovery: {
1124
+ u2c: 'https://u2c.wbx2.com/u2c/api/v1',
1125
+ },
1126
+ },
1127
+ };
1128
+ assert.isFalse(services.isIntegrationEnvironment());
1129
+ });
1130
+
1131
+ it('returns false when u2c URL is for FedRAMP', () => {
1132
+ services.webex.config = {
1133
+ services: {
1134
+ discovery: {
1135
+ u2c: 'https://u2c.gov.ciscospark.com/u2c/api/v1',
1136
+ },
1137
+ },
1138
+ };
1139
+ assert.isFalse(services.isIntegrationEnvironment());
1140
+ });
1141
+
1142
+ it('returns false when u2c URL is undefined', () => {
1143
+ services.webex.config = {
1144
+ services: {
1145
+ discovery: {
1146
+ u2c: undefined,
1147
+ },
1148
+ },
1149
+ };
1150
+ assert.isFalse(services.isIntegrationEnvironment());
1151
+ });
1152
+
1153
+ it('returns false when config is not available', () => {
1154
+ services.webex.config = undefined;
1155
+ assert.isFalse(services.isIntegrationEnvironment());
1156
+ });
1157
+ });
1158
+
1159
+ describe('U2C catalog cache behavior (v2)', () => {
1160
+ const CATALOG_CACHE_KEY_V2 = 'services.v2.u2cHostMap';
1161
+ let windowBackup;
1162
+ let localStorageBackup;
1163
+
1164
+ const makeLocalStorageShim = () => {
1165
+ const store = new Map<string, string>();
1166
+ return {
1167
+ getItem: (k: string) => (store.has(k) ? store.get(k) : null),
1168
+ setItem: (k: string, v: string) => store.set(k, v),
1169
+ removeItem: (k: string) => store.delete(k),
1170
+ _store: store,
1171
+ };
1172
+ };
1173
+
1174
+ beforeEach(() => {
1175
+ // Stub window.localStorage
1176
+ windowBackup = global.window;
1177
+ if (!global.window) global.window = {} as Window & typeof globalThis;
1178
+ localStorageBackup = global.window.localStorage;
1179
+ global.window.localStorage = makeLocalStorageShim();
1180
+ // Enable U2C caching feature flag for tests that depend on cache writes/reads
1181
+ services.webex.config = services.webex.config || {};
1182
+ services.webex.config.calling = {...(services.webex.config.calling || {}), cacheU2C: true};
1183
+ // Ensure code under test uses our shim via util method
1184
+ sinon.stub(services, '_getLocalStorageSafe').returns(global.window.localStorage);
1185
+ // default current env
1186
+ services.webex.config = services.webex.config || {};
1187
+ services.webex.config.services = services.webex.config.services || {discovery: {}};
1188
+ services.webex.config.services.discovery.u2c =
1189
+ services.webex.config.services.discovery.u2c || 'https://u2c.wbx2.com/u2c/api/v1';
1190
+ services.webex.config.fedramp =
1191
+ typeof services.webex.config.fedramp === 'boolean'
1192
+ ? services.webex.config.fedramp
1193
+ : false;
1194
+ });
1195
+
1196
+ afterEach(() => {
1197
+ global.window.localStorage = localStorageBackup || undefined;
1198
+ if (!windowBackup) {
1199
+ delete global.window;
1200
+ } else {
1201
+ global.window = windowBackup;
1202
+ }
1203
+ // Restore util stub if present
1204
+ if (services._getLocalStorageSafe && services._getLocalStorageSafe.restore) {
1205
+ services._getLocalStorageSafe.restore();
1206
+ }
1207
+ });
1208
+
1209
+ it('stores selection metadata and env on cache write for preauth', async () => {
1210
+ // Arrange env
1211
+ services.webex.config.services.discovery.u2c = 'https://u2c.wbx2.com/u2c/api/v1';
1212
+ services.webex.config.fedramp = false;
1213
+
1214
+ // Act
1215
+ await services._cacheCatalog(
1216
+ 'preauth',
1217
+ {services: [], timestamp: Date.now().toString()},
1218
+ {selectionType: 'orgId', selectionValue: 'urn:EXAMPLE:org'}
1219
+ );
1220
+
1221
+ // Assert
1222
+ const raw = window.localStorage.getItem(CATALOG_CACHE_KEY_V2);
1223
+ assert.isString(raw);
1224
+ const parsed = JSON.parse(raw as string);
1225
+ assert.deepEqual(parsed.env, {
1226
+ fedramp: false,
1227
+ u2cDiscoveryUrl: 'https://u2c.wbx2.com/u2c/api/v1',
1228
+ });
1229
+ assert.isObject(parsed.preauth);
1230
+ assert.deepEqual(parsed.preauth.meta, {
1231
+ selectionType: 'orgId',
1232
+ selectionValue: 'urn:EXAMPLE:org',
1233
+ });
1234
+ });
1235
+
1236
+ it('warms preauth from cache when selection meta matches intended orgId', async () => {
1237
+ // Arrange current env and credentials
1238
+ services.webex.config.services.discovery.u2c = 'https://u2c.wbx2.com/u2c/api/v1';
1239
+ services.webex.config.fedramp = false;
1240
+ services.webex.credentials = {
1241
+ canAuthorize: true,
1242
+ getOrgId: sinon.stub().returns('urn:EXAMPLE:org'),
1243
+ };
1244
+ // Seed cache
1245
+ window.localStorage.setItem(
1246
+ CATALOG_CACHE_KEY_V2,
1247
+ JSON.stringify({
1248
+ cachedAt: Date.now(),
1249
+ env: {fedramp: false, u2cDiscoveryUrl: 'https://u2c.wbx2.com/u2c/api/v1'},
1250
+ preauth: {
1251
+ hostMap: {services: [], timestamp: '1'},
1252
+ meta: {selectionType: 'orgId', selectionValue: 'urn:EXAMPLE:org'},
1253
+ },
1254
+ })
1255
+ );
1256
+ // Spy updateServiceGroups
1257
+ const spy = sinon.spy(services._getCatalog(), 'updateServiceGroups');
1258
+
1259
+ // Act
1260
+ const warmed = await services._loadCatalogFromCache();
1261
+
1262
+ // Assert
1263
+ assert.isTrue(warmed);
1264
+ assert.isTrue(
1265
+ spy.calledWith('preauth', [], '1'),
1266
+ 'expected preauth to be warmed when selection matches'
1267
+ );
1268
+ spy.restore && spy.restore();
1269
+ });
1270
+
1271
+ it('does not warm preauth when selection meta is proximity mode', async () => {
1272
+ // Arrange env
1273
+ services.webex.config.services.discovery.u2c = 'https://u2c.wbx2.com/u2c/api/v1';
1274
+ services.webex.config.fedramp = false;
1275
+ window.localStorage.setItem(
1276
+ CATALOG_CACHE_KEY_V2,
1277
+ JSON.stringify({
1278
+ cachedAt: Date.now(),
1279
+ env: {fedramp: false, u2cDiscoveryUrl: 'https://u2c.wbx2.com/u2c/api/v1'},
1280
+ preauth: {
1281
+ hostMap: {services: [], timestamp: '1'},
1282
+ meta: {selectionType: 'mode', selectionValue: 'DEFAULT_BY_PROXIMITY'},
1283
+ },
1284
+ })
1285
+ );
1286
+ const spy = sinon.spy(services._getCatalog(), 'updateServiceGroups');
1287
+
1288
+ // Act
1289
+ const warmed = await services._loadCatalogFromCache();
1290
+
1291
+ // Assert: overall warm-up succeeds, but preauth is skipped
1292
+ assert.isTrue(warmed);
1293
+ assert.isFalse(
1294
+ spy.calledWith('preauth', sinon.match.any, sinon.match.any),
1295
+ 'expected preauth not to be warmed for proximity mode'
1296
+ );
1297
+ spy.restore && spy.restore();
1298
+ });
1299
+
1300
+ it('does not warm preauth when selection meta mismatches intended selection', async () => {
1301
+ // Arrange env and credentials
1302
+ services.webex.config.services.discovery.u2c = 'https://u2c.wbx2.com/u2c/api/v1';
1303
+ services.webex.config.fedramp = false;
1304
+ services.webex.credentials = {
1305
+ canAuthorize: true,
1306
+ getOrgId: sinon.stub().returns('urn:EXAMPLE:org'),
1307
+ };
1308
+ window.localStorage.setItem(
1309
+ CATALOG_CACHE_KEY_V2,
1310
+ JSON.stringify({
1311
+ cachedAt: Date.now(),
1312
+ env: {fedramp: false, u2cDiscoveryUrl: 'https://u2c.wbx2.com/u2c/api/v1'},
1313
+ preauth: {
1314
+ hostMap: {services: [], timestamp: '1'},
1315
+ meta: {selectionType: 'orgId', selectionValue: 'urn:DIFF:org'},
1316
+ },
1317
+ })
1318
+ );
1319
+ const spy = sinon.spy(services._getCatalog(), 'updateServiceGroups');
1320
+
1321
+ const warmed = await services._loadCatalogFromCache();
1322
+
1323
+ assert.isTrue(warmed);
1324
+ assert.isFalse(
1325
+ spy.calledWith('preauth', sinon.match.any, sinon.match.any),
1326
+ 'expected preauth not to be warmed on selection mismatch'
1327
+ );
1328
+ spy.restore && spy.restore();
1329
+ });
1330
+
1331
+ it('skips warm entirely when environment fingerprint mismatches', async () => {
1332
+ // Cached env differs from current env
1333
+ services.webex.config.services.discovery.u2c = 'https://u2c.current.com/u2c/api/v1';
1334
+ services.webex.config.fedramp = false;
1335
+ window.localStorage.setItem(
1336
+ CATALOG_CACHE_KEY_V2,
1337
+ JSON.stringify({
1338
+ cachedAt: Date.now(),
1339
+ env: {fedramp: false, u2cDiscoveryUrl: 'https://u2c.cached.com/u2c/api/v1'},
1340
+ preauth: {
1341
+ hostMap: {services: [], timestamp: '1'},
1342
+ meta: {selectionType: 'orgId', selectionValue: 'urn:EXAMPLE:org'},
1343
+ },
1344
+ })
1345
+ );
1346
+ const spy = sinon.spy(services._getCatalog(), 'updateServiceGroups');
1347
+
1348
+ const warmed = await services._loadCatalogFromCache();
1349
+
1350
+ assert.isFalse(warmed, 'env mismatch should skip warm and return false');
1351
+ assert.isFalse(spy.called, 'no group should be warmed on env mismatch');
1352
+ spy.restore && spy.restore();
1353
+ });
1354
+ });
816
1355
  });
817
1356
  });
@@ -397,6 +397,8 @@ describe('Webex', () => {
397
397
  return new Promise((resolve) => webex.once('loaded', resolve)).then(() => {
398
398
  assert.isFalse(webex.ready);
399
399
  assert.isFalse(webex.test.ready);
400
+ // Set services.ready to true since it now blocks webex.ready
401
+ webex.internal.services.ready = true;
400
402
  webex.test.ready = true;
401
403
  assert.isTrue(webex.test.ready);
402
404
  assert.isTrue(webex.ready);
@@ -82,6 +82,8 @@ describe('Webex', () => {
82
82
  assert.isFalse(webex.internal.test.ready);
83
83
  assert.isFalse(webex.internal.ready);
84
84
  assert.isFalse(webex.ready);
85
+ // Set services.ready to true since it now blocks webex.ready
86
+ webex.internal.services.ready = true;
85
87
  webex.internal.test.ready = true;
86
88
  assert.isTrue(webex.internal.ready);
87
89
  assert.isTrue(webex.ready);