@envive-ai/react-hooks 0.3.64 → 0.3.65

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 (83) hide show
  1. package/dist/application/models/featureGates.cjs +1 -2
  2. package/dist/application/models/featureGates.d.cts +1 -2
  3. package/dist/application/models/featureGates.d.ts +1 -2
  4. package/dist/application/models/featureGates.js +1 -2
  5. package/dist/atoms/app/index.d.cts +6 -6
  6. package/dist/atoms/app/index.d.ts +1 -1
  7. package/dist/atoms/app/variant.d.cts +6 -6
  8. package/dist/atoms/app/variant.d.ts +6 -6
  9. package/dist/atoms/chat/chatState.d.cts +1 -1
  10. package/dist/atoms/chat/chatState.d.ts +19 -19
  11. package/dist/atoms/chat/form.d.cts +2 -2
  12. package/dist/atoms/chat/form.d.ts +3 -3
  13. package/dist/atoms/chat/index.d.cts +2 -2
  14. package/dist/atoms/chat/index.d.ts +2 -2
  15. package/dist/atoms/chat/lastMessage.d.cts +2 -2
  16. package/dist/atoms/chat/lastMessage.d.ts +2 -2
  17. package/dist/atoms/chat/messageQueue.d.cts +7 -7
  18. package/dist/atoms/chat/messageQueue.d.ts +7 -7
  19. package/dist/atoms/chat/performanceMetrics.d.cts +6 -6
  20. package/dist/atoms/chat/performanceMetrics.d.ts +6 -6
  21. package/dist/atoms/chat/renderedWidgetRefs.d.cts +3 -3
  22. package/dist/atoms/chat/renderedWidgetRefs.d.ts +3 -3
  23. package/dist/atoms/chat/replies.d.cts +3 -3
  24. package/dist/atoms/chat/replies.d.ts +3 -3
  25. package/dist/atoms/chat/suggestions.d.cts +3 -3
  26. package/dist/atoms/chat/suggestions.d.ts +3 -3
  27. package/dist/atoms/envive/enviveConfig.d.cts +13 -13
  28. package/dist/atoms/envive/enviveConfig.d.ts +14 -14
  29. package/dist/atoms/globalSearch/globalSearch.d.cts +5 -5
  30. package/dist/atoms/globalSearch/globalSearch.d.ts +5 -5
  31. package/dist/atoms/org/customerService.d.cts +6 -6
  32. package/dist/atoms/org/customerService.d.ts +6 -6
  33. package/dist/atoms/org/graphqlConfig.d.cts +4 -4
  34. package/dist/atoms/org/graphqlConfig.d.ts +4 -4
  35. package/dist/atoms/org/newOrgConfigAtom.d.cts +2 -2
  36. package/dist/atoms/org/newOrgConfigAtom.d.ts +2 -2
  37. package/dist/atoms/org/orgAnalyticsConfig.d.cts +4 -4
  38. package/dist/atoms/org/orgAnalyticsConfig.d.ts +4 -4
  39. package/dist/atoms/search/chatSearch.d.cts +17 -17
  40. package/dist/atoms/search/chatSearch.d.ts +17 -17
  41. package/dist/atoms/search/searchAPI.d.cts +13 -13
  42. package/dist/atoms/search/searchAPI.d.ts +13 -13
  43. package/dist/atoms/widget/chatPreviewLoading.d.cts +2 -2
  44. package/dist/atoms/widget/chatPreviewLoading.d.ts +2 -2
  45. package/dist/contexts/pageContext/pageContext.cjs +12 -24
  46. package/dist/contexts/pageContext/pageContext.js +12 -24
  47. package/dist/contexts/types.d.cts +1 -1
  48. package/dist/contexts/types.d.ts +1 -1
  49. package/dist/contexts/typesV3.cjs +8 -1
  50. package/dist/contexts/typesV3.d.cts +23 -6
  51. package/dist/contexts/typesV3.d.ts +23 -6
  52. package/dist/contexts/typesV3.js +8 -2
  53. package/dist/hooks/GrabAndScroll/useGrabAndScroll.d.cts +2 -2
  54. package/dist/hooks/utils.d.cts +1 -1
  55. package/dist/hooks/utils.d.ts +1 -1
  56. package/dist/services/enviveConfigService/enviveConfigService.cjs +21 -35
  57. package/dist/services/enviveConfigService/enviveConfigService.d.cts +1 -2
  58. package/dist/services/enviveConfigService/enviveConfigService.d.ts +1 -2
  59. package/dist/services/enviveConfigService/enviveConfigService.js +21 -35
  60. package/dist/services/enviveConfigService/fetchBootstrapConfig.cjs +15 -16
  61. package/dist/services/enviveConfigService/fetchBootstrapConfig.js +15 -16
  62. package/dist/services/enviveConfigService/fetchGraphQLConfig.cjs +1 -69
  63. package/dist/services/enviveConfigService/fetchGraphQLConfig.js +2 -69
  64. package/dist/services/featureFlagService/index.cjs +7 -5
  65. package/dist/services/featureFlagService/index.d.cts +2 -1
  66. package/dist/services/featureFlagService/index.d.ts +2 -1
  67. package/dist/services/featureFlagService/index.js +7 -5
  68. package/package.json +1 -1
  69. package/src/application/models/featureGates.ts +5 -8
  70. package/src/contexts/pageContext/__tests__/pageContext.test.tsx +4 -59
  71. package/src/contexts/pageContext/pageContext.tsx +20 -41
  72. package/src/contexts/typesV3.ts +22 -4
  73. package/src/services/enviveConfigService/__tests__/enviveConfigService.test.ts +72 -158
  74. package/src/services/enviveConfigService/__tests__/fetchBootstrapConfig.test.ts +111 -13
  75. package/src/services/enviveConfigService/__tests__/fetchGraphQLConfig.test.ts +161 -490
  76. package/src/services/enviveConfigService/enviveConfigService.ts +51 -87
  77. package/src/services/enviveConfigService/fetchBootstrapConfig.ts +74 -66
  78. package/src/services/enviveConfigService/fetchGraphQLConfig.ts +0 -120
  79. package/src/services/featureFlagService/__tests__/getFeatureFlagOverrides.test.ts +54 -0
  80. package/src/services/featureFlagService/index.ts +19 -6
  81. package/dist/application/models/graphql/queries/getWidgetConfigQuery.cjs +0 -42
  82. package/dist/application/models/graphql/queries/getWidgetConfigQuery.js +0 -41
  83. package/src/application/models/graphql/queries/getWidgetConfigQuery.ts +0 -55
@@ -3,65 +3,15 @@ import { EnviveConfigService } from '../enviveConfigService';
3
3
  const mockFetch = vi.fn();
4
4
  globalThis.fetch = mockFetch;
5
5
 
6
- // getWidgetConfig is the single config call now: it returns org identity +
7
- // gates + experiment assignments alongside productsConfig (the separate
8
- // GET /v1/org/config fetch was removed). productsConfig has no colors, so
9
- // fetchUnifiedGraphQLConfig falls back to its mock config — fine here since
10
- // these tests only care about caching / fetch-count behaviour.
11
- function makeOkConfigFetch() {
12
- mockFetch.mockResolvedValueOnce({
13
- ok: true,
14
- json: () =>
15
- Promise.resolve({
16
- data: {
17
- me: {
18
- org: {
19
- id: 'org-1',
20
- shortName: 'test-org',
21
- displayName: 'Test Org',
22
- domain: 'test.com',
23
- createdAt: '2024-01-01T00:00:00.000Z',
24
- updatedAt: '2024-01-01T00:00:00.000Z',
25
- },
26
- getWidgetConfig: { productsConfig: {}, gates: [], experimentAssignments: [] },
27
- },
28
- },
29
- }),
30
- });
31
- }
32
-
33
- // getWidgetConfig response with the use_unified_config gate ON (plus a real
34
- // version pin) — the trigger for the /v1/session/bootstrap upgrade.
35
- function makeGateOnConfigFetch() {
36
- mockFetch.mockResolvedValueOnce({
37
- ok: true,
38
- json: () =>
39
- Promise.resolve({
40
- data: {
41
- me: {
42
- org: {
43
- id: 'org-1',
44
- shortName: 'test-org',
45
- displayName: 'Test Org',
46
- domain: 'test.com',
47
- createdAt: '2024-01-01T00:00:00.000Z',
48
- updatedAt: '2024-01-01T00:00:00.000Z',
49
- },
50
- getWidgetConfig: {
51
- productsConfig: {},
52
- gates: [{ name: 'use_unified_config', value: true }],
53
- experimentAssignments: [],
54
- resolution: { baseVersion: '1.0.0', baseVersionSource: 'merchant_layer' },
55
- },
56
- },
57
- },
58
- }),
59
- });
60
- }
61
-
62
- // /v1/session/bootstrap response (REST snake_case). Distinct org id so tests can
63
- // assert the config actually came from bootstrap, not the getWidgetConfig call.
64
- function makeBootstrapFetch() {
6
+ // /v1/session/bootstrap is the single session-config call now (getWidgetConfig
7
+ // and the separate GET /v1/org/config were removed). A valid response carries
8
+ // org identity + a NON-EMPTY gates array (the malformed-payload guard requires
9
+ // it) + widget_config. orgId is parameterised so cache tests can assert the
10
+ // config actually came back; urlResolving is null-able for the config-only path.
11
+ function makeBootstrapFetch(
12
+ orgId = 'org-1',
13
+ urlResolving: unknown = { variant_type: 'pdp', specific_details: {}, ready: true },
14
+ ) {
65
15
  mockFetch.mockResolvedValueOnce({
66
16
  ok: true,
67
17
  json: () =>
@@ -69,10 +19,10 @@ function makeBootstrapFetch() {
69
19
  org_config: {
70
20
  org: {
71
21
  org: {
72
- id: 'org-boot',
73
- short_name: 'boot-org',
74
- display_name: 'Boot Org',
75
- domain: 'boot.com',
22
+ id: orgId,
23
+ short_name: 'test-org',
24
+ display_name: 'Test Org',
25
+ domain: 'test.com',
76
26
  status: 'active',
77
27
  created_at: '2024-01-01T00:00:00.000Z',
78
28
  updated_at: '2024-01-01T00:00:00.000Z',
@@ -84,7 +34,7 @@ function makeBootstrapFetch() {
84
34
  },
85
35
  widget_config: {
86
36
  products_config: {},
87
- // Assignments come from widget_config (matches getWidgetConfig, ungated).
37
+ // Assignments come from widget_config (ungated).
88
38
  experiment_assignments: [
89
39
  {
90
40
  layer_name: 'ml',
@@ -95,7 +45,7 @@ function makeBootstrapFetch() {
95
45
  ],
96
46
  resolution: { base_version: '1.0.0', base_version_source: 'merchant_layer' },
97
47
  },
98
- url_resolving: { variant_type: 'pdp', specific_details: {}, ready: true },
48
+ url_resolving: urlResolving,
99
49
  }),
100
50
  });
101
51
  }
@@ -125,17 +75,18 @@ afterEach(() => {
125
75
  });
126
76
 
127
77
  describe('EnviveConfigService caching', () => {
128
- it('fetches the unified config once on a cold cache', async () => {
129
- makeOkConfigFetch();
78
+ it('fetches the session config once on a cold cache', async () => {
79
+ makeBootstrapFetch();
130
80
 
131
81
  const result = await makeService().getEnviveConfig();
132
82
 
133
83
  expect(mockFetch).toHaveBeenCalledTimes(1);
84
+ expect(mockFetch.mock.calls[0][0]).toContain('/v1/session/bootstrap');
134
85
  expect(result.org.org.id).toBe('org-1');
135
86
  });
136
87
 
137
88
  it('serves a warm cache to a new instance with identical params without hitting the network', async () => {
138
- makeOkConfigFetch();
89
+ makeBootstrapFetch();
139
90
  await makeService().getEnviveConfig();
140
91
  expect(mockFetch).toHaveBeenCalledTimes(1);
141
92
 
@@ -151,13 +102,13 @@ describe('EnviveConfigService caching', () => {
151
102
  vi.useFakeTimers();
152
103
  vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
153
104
 
154
- makeOkConfigFetch();
105
+ makeBootstrapFetch();
155
106
  await makeService().getEnviveConfig();
156
107
  expect(mockFetch).toHaveBeenCalledTimes(1);
157
108
 
158
109
  vi.setSystemTime(new Date('2026-01-01T00:05:01.000Z'));
159
110
 
160
- makeOkConfigFetch();
111
+ makeBootstrapFetch();
161
112
  await makeService().getEnviveConfig();
162
113
 
163
114
  expect(mockFetch).toHaveBeenCalledTimes(2);
@@ -168,11 +119,11 @@ describe('EnviveConfigService caching', () => {
168
119
  async param => {
169
120
  window.history.replaceState({}, '', `/?${param}=1.2.3`);
170
121
 
171
- makeOkConfigFetch();
122
+ makeBootstrapFetch();
172
123
  await makeService().getEnviveConfig();
173
124
  expect(mockFetch).toHaveBeenCalledTimes(1);
174
125
 
175
- makeOkConfigFetch();
126
+ makeBootstrapFetch();
176
127
  await makeService().getEnviveConfig();
177
128
 
178
129
  // No cache read/write happened, so a second instance refetches instead
@@ -183,33 +134,33 @@ describe('EnviveConfigService caching', () => {
183
134
 
184
135
  it('does not read a version-pinned cache entry after the override is removed', async () => {
185
136
  window.history.replaceState({}, '', '/?envive_config_version=1.2.3');
186
- makeOkConfigFetch();
137
+ makeBootstrapFetch();
187
138
  await makeService().getEnviveConfig();
188
139
 
189
140
  window.history.replaceState({}, '', '/');
190
- makeOkConfigFetch();
141
+ makeBootstrapFetch();
191
142
  await makeService().getEnviveConfig();
192
143
 
193
144
  expect(mockFetch).toHaveBeenCalledTimes(2);
194
145
  });
195
146
 
196
147
  it('keeps separate cache entries per userId', async () => {
197
- makeOkConfigFetch();
148
+ makeBootstrapFetch();
198
149
  await makeService({ userId: 'user-1' }).getEnviveConfig();
199
150
  expect(mockFetch).toHaveBeenCalledTimes(1);
200
151
 
201
- makeOkConfigFetch();
152
+ makeBootstrapFetch();
202
153
  await makeService({ userId: 'user-2' }).getEnviveConfig();
203
154
 
204
155
  expect(mockFetch).toHaveBeenCalledTimes(2);
205
156
  });
206
157
 
207
158
  it('keeps separate cache entries per namespace/source/baseUrl/apiKey', async () => {
208
- makeOkConfigFetch();
159
+ makeBootstrapFetch();
209
160
  await makeService({ namespace: 'ns-a' }).getEnviveConfig();
210
161
  expect(mockFetch).toHaveBeenCalledTimes(1);
211
162
 
212
- makeOkConfigFetch();
163
+ makeBootstrapFetch();
213
164
  await makeService({ namespace: 'ns-b' }).getEnviveConfig();
214
165
 
215
166
  expect(mockFetch).toHaveBeenCalledTimes(2);
@@ -220,7 +171,7 @@ describe('EnviveConfigService caching', () => {
220
171
  throw new Error('quota exceeded');
221
172
  });
222
173
 
223
- makeOkConfigFetch();
174
+ makeBootstrapFetch();
224
175
 
225
176
  const result = await makeService().getEnviveConfig();
226
177
 
@@ -232,7 +183,7 @@ describe('EnviveConfigService caching', () => {
232
183
  throw new Error('storage disabled');
233
184
  });
234
185
 
235
- makeOkConfigFetch();
186
+ makeBootstrapFetch();
236
187
 
237
188
  const result = await makeService().getEnviveConfig();
238
189
 
@@ -241,47 +192,34 @@ describe('EnviveConfigService caching', () => {
241
192
 
242
193
  it('fails open and refetches when a cached entry is corrupted JSON', async () => {
243
194
  const service = makeService();
244
- makeOkConfigFetch();
195
+ makeBootstrapFetch();
245
196
  await service.getEnviveConfig();
246
197
  const [key] = Object.keys(sessionStorage);
247
198
  sessionStorage.setItem(key, 'not json');
248
199
 
249
- makeOkConfigFetch();
200
+ makeBootstrapFetch();
250
201
  const result = await makeService().getEnviveConfig();
251
202
 
252
203
  expect(result.org.org.id).toBe('org-1');
253
204
  });
254
205
  });
255
206
 
256
- describe('EnviveConfigService bootstrap upgrade (use_unified_config)', () => {
257
- // The injection bundle supplies env + contextSource; without them the branch
258
- // is skipped entirely (see the "non-bundle caller" case below).
207
+ describe('EnviveConfigService bootstrap request', () => {
208
+ // The injection bundle supplies env + contextSource, which request bootstrap's
209
+ // url_resolving branch. Without them the branch is omitted (see the config-only
210
+ // case below), but bootstrap is still the config call either way.
259
211
  const bootstrapService = () => makeService({ env: 'dev', contextSource: 'app' });
260
212
 
261
- it('stays on getWidgetConfig (no bootstrap) when the gate is off', async () => {
262
- makeOkConfigFetch(); // gates: []
213
+ it('issues a single /v1/session/bootstrap call and returns its config', async () => {
214
+ makeBootstrapFetch('org-boot');
263
215
 
264
216
  const service = bootstrapService();
265
217
  const result = await service.getEnviveConfig();
266
218
 
267
219
  expect(mockFetch).toHaveBeenCalledTimes(1);
268
- expect(mockFetch.mock.calls[0][0]).toContain('/v1/graphql');
269
- expect(result.org.org.id).toBe('org-1');
270
- expect(service.takeBootstrapUrlResolving()).toBeNull();
271
- });
272
-
273
- it('calls /v1/session/bootstrap and uses ITS config when the gate is on', async () => {
274
- makeGateOnConfigFetch();
275
- makeBootstrapFetch();
276
-
277
- const service = bootstrapService();
278
- const result = await service.getEnviveConfig();
279
-
280
- expect(mockFetch).toHaveBeenCalledTimes(2);
281
- expect(mockFetch.mock.calls[1][0]).toContain('/v1/session/bootstrap');
282
- // config is bootstrap's, not the getWidgetConfig call used to read the gate.
220
+ expect(mockFetch.mock.calls[0][0]).toContain('/v1/session/bootstrap');
283
221
  expect(result.org.org.id).toBe('org-boot');
284
- expect(result.org.org.short_name).toBe('boot-org');
222
+ expect(result.org.org.short_name).toBe('test-org');
285
223
  // gates: snake group_name mapped to camel groupName.
286
224
  expect(result.gates).toContainEqual({
287
225
  name: 'use_unified_config',
@@ -300,7 +238,6 @@ describe('EnviveConfigService bootstrap upgrade (use_unified_config)', () => {
300
238
  });
301
239
 
302
240
  it('stashes bootstrap url_resolving (paired with its URL) for the warm-up to consume once', async () => {
303
- makeGateOnConfigFetch();
304
241
  makeBootstrapFetch();
305
242
 
306
243
  const service = bootstrapService();
@@ -315,96 +252,73 @@ describe('EnviveConfigService bootstrap upgrade (use_unified_config)', () => {
315
252
  expect(service.takeBootstrapUrlResolving()).toBeNull();
316
253
  });
317
254
 
318
- it('sends url_resolving context + gate names + version pin in the bootstrap body', async () => {
319
- makeGateOnConfigFetch();
255
+ it('sends the url_resolving context (no org identity), gate names, and namespace in the body', async () => {
320
256
  makeBootstrapFetch();
321
257
 
322
258
  await bootstrapService().getEnviveConfig();
323
259
 
324
- const [, options] = mockFetch.mock.calls[1];
260
+ const [, options] = mockFetch.mock.calls[0];
325
261
  const body = JSON.parse(options.body as string);
326
262
  expect(body.include_feature_gates).toContain('use_unified_config');
327
263
  // Scopes org_config.configs so bootstrap doesn't dump internal configs to the browser.
328
264
  expect(body.namespace).toBe('spiffy-react-components');
329
- expect(body.url_resolving.context).toMatchObject({
330
- org_id: 'org-1',
331
- org_short_name: 'test-org',
332
- source: 'app',
333
- env: 'dev',
334
- });
335
- // The version comes ONLY from the URL query param (none set here) — never from
336
- // getWidgetConfig's resolution, so bootstrap doesn't depend on the discarded
337
- // getWidgetConfig response.
265
+ // org identity is NOT sent — the server fills it from the authenticated org.
266
+ expect(body.url_resolving.context).toMatchObject({ source: 'app', env: 'dev' });
267
+ expect(body.url_resolving.context.org_id).toBeUndefined();
268
+ expect(body.url_resolving.context.org_short_name).toBeUndefined();
269
+ // No config-version override set here.
338
270
  expect(body.url_resolving.org_base_config_version).toBeUndefined();
339
271
  expect(body.url_resolving.override_config_version).toBeUndefined();
340
- // override-aware gates (FeatureFlagService.getFeatureFlags()) forwarded so
341
- // client gate overrides reach page-variant resolution.
342
- expect(body.url_resolving.feature_gates.use_unified_config).toBe(true);
272
+ // No client gate overrides set → empty map (server resolves the real gates).
273
+ expect(body.url_resolving.feature_gates).toEqual({});
343
274
  // the persistent chat session id is threaded (not an empty string).
344
275
  expect(body.url_resolving.context.chat_id).toBeTruthy();
345
276
  });
346
277
 
347
- it('reflects a ?<gate>=true override in the bootstrap feature_gates it sends', async () => {
348
- // Force a page-variant gate the server did NOT return — it must still reach
349
- // bootstrap's url_resolving via getFeatureFlags()'s override precedence.
278
+ it('forwards ?<gate>=true overrides in the bootstrap feature_gates it sends', async () => {
279
+ // Client overrides (query param) must reach bootstrap's url_resolving so they
280
+ // still drive page-variant selection; the server merges them over its gates.
350
281
  window.history.replaceState({}, '', '/?use_unified_config=true&is_empty_div_plp_enabled=true');
351
- makeOkConfigFetch(); // gates: [] — the ?use_unified_config=true override drives the bootstrap path
352
282
  makeBootstrapFetch();
353
283
 
354
284
  await bootstrapService().getEnviveConfig();
355
285
 
356
- const body = JSON.parse(mockFetch.mock.calls[1][1].body as string);
286
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body as string);
287
+ expect(body.url_resolving.feature_gates.use_unified_config).toBe(true);
357
288
  expect(body.url_resolving.feature_gates.is_empty_div_plp_enabled).toBe(true);
358
289
  });
359
290
 
360
- it('skips bootstrap for non-bundle callers (no env/contextSource) even when the gate is on', async () => {
361
- makeGateOnConfigFetch();
291
+ it('omits the url_resolving branch for non-bundle callers (no env/contextSource) but still fetches config', async () => {
292
+ makeBootstrapFetch('org-1', null);
362
293
 
363
- const result = await makeService().getEnviveConfig(); // no env/contextSource
294
+ const service = makeService(); // no env/contextSource
295
+ const result = await service.getEnviveConfig();
364
296
 
365
297
  expect(mockFetch).toHaveBeenCalledTimes(1);
298
+ expect(mockFetch.mock.calls[0][0]).toContain('/v1/session/bootstrap');
299
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body as string);
300
+ expect(body.url_resolving).toBeUndefined();
366
301
  expect(result.org.org.id).toBe('org-1');
302
+ expect(service.takeBootstrapUrlResolving()).toBeNull();
367
303
  });
368
304
 
369
- it('falls back to the getWidgetConfig config when bootstrap fails', async () => {
370
- makeGateOnConfigFetch();
305
+ it('throws (aborting injection) when bootstrap fails — there is no getWidgetConfig fallback', async () => {
371
306
  mockFetch.mockResolvedValueOnce({ ok: false, statusText: 'Bad Gateway' });
372
307
 
373
- const service = bootstrapService();
374
- const result = await service.getEnviveConfig();
375
-
376
- expect(mockFetch).toHaveBeenCalledTimes(2);
377
- // degraded to the proven path rather than aborting.
378
- expect(result.org.org.id).toBe('org-1');
379
- expect(service.takeBootstrapUrlResolving()).toBeNull();
308
+ await expect(bootstrapService().getEnviveConfig()).rejects.toThrow(
309
+ 'Bootstrap config request failed',
310
+ );
380
311
  });
381
312
 
382
- it('falls back to getWidgetConfig config when bootstrap returns a malformed 2xx payload', async () => {
383
- makeGateOnConfigFetch();
384
- // 2xx but missing required sections — must NOT replace the known-good config
385
- // with empty gates / mock products (which would disable the client session).
313
+ it('throws when bootstrap returns a malformed 2xx payload', async () => {
314
+ // 2xx but missing required sections — must NOT become a config with empty
315
+ // gates / mock products (which would disable the client session). No fallback
316
+ // left, so the throw propagates and injection aborts.
386
317
  mockFetch.mockResolvedValueOnce({
387
318
  ok: true,
388
319
  json: () => Promise.resolve({ org_config: {}, widget_config: null }),
389
320
  });
390
321
 
391
- const service = bootstrapService();
392
- const result = await service.getEnviveConfig();
393
-
394
- expect(mockFetch).toHaveBeenCalledTimes(2);
395
- expect(result.org.org.id).toBe('org-1'); // getWidgetConfig's, not bootstrap's empty payload
396
- expect(service.takeBootstrapUrlResolving()).toBeNull();
397
- });
398
-
399
- it('honors a ?use_unified_config=true override when the gate itself is off', async () => {
400
- window.history.replaceState({}, '', '/?use_unified_config=true');
401
- makeOkConfigFetch(); // gates: [] — the override, not the gate, forces bootstrap
402
- makeBootstrapFetch();
403
-
404
- const result = await bootstrapService().getEnviveConfig();
405
-
406
- expect(mockFetch).toHaveBeenCalledTimes(2);
407
- expect(mockFetch.mock.calls[1][0]).toContain('/v1/session/bootstrap');
408
- expect(result.org.org.id).toBe('org-boot');
322
+ await expect(bootstrapService().getEnviveConfig()).rejects.toThrow('missing required sections');
409
323
  });
410
324
  });
@@ -67,9 +67,9 @@ const baseParams: FetchBootstrapConfigParams = {
67
67
  source: 'app',
68
68
  namespace: 'spiffy-react-components',
69
69
  gateNames: ['use_unified_config', 'is_client_session_enabled'],
70
+ // url_resolving inputs (org identity is NOT sent — the server fills it from the
71
+ // authenticated org). Absent url ⇒ bootstrap omits the url_resolving branch.
70
72
  url: 'https://shop.example.com/products/thing',
71
- orgId: 'org-1',
72
- orgShortName: 'test-org',
73
73
  contextSource: 'app',
74
74
  env: 'prod',
75
75
  featureGates: { use_unified_config: true, is_empty_div_plp_enabled: true },
@@ -180,20 +180,38 @@ describe('fetchBootstrapConfig', () => {
180
180
  });
181
181
  expect(body.url_resolving).toMatchObject({
182
182
  url: 'https://shop.example.com/products/thing',
183
- // Override-aware gates forwarded so client overrides reach page-variant resolution.
183
+ // Override-only gates forwarded so client overrides reach page-variant resolution.
184
184
  feature_gates: { use_unified_config: true, is_empty_div_plp_enabled: true },
185
185
  });
186
- // No org_base_config_version — the version comes only from the URL query param
187
- // (override_config_version), never from getWidgetConfig's resolution.
186
+ // No org_base_config_version — the version comes only from the URL query param.
188
187
  expect(body.url_resolving.org_base_config_version).toBeUndefined();
188
+ // org identity is NOT sent — the server fills org_id/org_short_name from the
189
+ // authenticated org (bootstrap-only has no getWidgetConfig to source them from).
189
190
  expect(body.url_resolving.context).toEqual({
190
191
  user_id: 'user-1',
191
- org_id: 'org-1',
192
- org_short_name: 'test-org',
193
192
  chat_id: 'chat-9',
194
193
  source: 'app',
195
194
  env: 'prod',
196
195
  });
196
+ expect(body.url_resolving.context.org_id).toBeUndefined();
197
+ expect(body.url_resolving.context.org_short_name).toBeUndefined();
198
+ });
199
+
200
+ it('omits the url_resolving branch from the request when no url is supplied', async () => {
201
+ makeBootstrapResponse();
202
+
203
+ // A disabled session (envive_on=false) requests config only — no page to resolve.
204
+ const { url, contextSource, env, featureGates, ...configOnly } = baseParams;
205
+ await fetchBootstrapConfig(configOnly);
206
+
207
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body as string);
208
+ // Config inputs still sent; the whole url_resolving object is absent.
209
+ expect(body).toMatchObject({
210
+ user_id: 'user-1',
211
+ namespace: 'spiffy-react-components',
212
+ include_feature_gates: ['use_unified_config', 'is_client_session_enabled'],
213
+ });
214
+ expect(body.url_resolving).toBeUndefined();
197
215
  });
198
216
 
199
217
  it('threads a config-version query param into version + override_config_version', async () => {
@@ -207,7 +225,67 @@ describe('fetchBootstrapConfig', () => {
207
225
  expect(body.url_resolving.override_config_version).toBe('9.9.9');
208
226
  });
209
227
 
210
- it('throws on a non-ok response so the caller can fall back', async () => {
228
+ it('degrades a malformed products_config to mock WITHOUT losing org/gates/experiments', async () => {
229
+ // A 2xx with valid org/gates/widget_config but a products_config that throws
230
+ // in transformV3ProductsConfig (invalid floating-button position). With no
231
+ // getWidgetConfig fallback left, this catch is the SOLE thing keeping a bad
232
+ // products config from aborting injection — it must null colors/frontend only
233
+ // and preserve org identity + gates + experiment_assignments + resolution.
234
+ mockFetch.mockResolvedValueOnce({
235
+ ok: true,
236
+ json: () =>
237
+ Promise.resolve({
238
+ org_config: {
239
+ org: { org: { id: 'org-boot', short_name: 'boot-org' } },
240
+ gates: [{ name: 'use_unified_config', value: true, group_name: 'Treatment' }],
241
+ },
242
+ widget_config: {
243
+ products_config: {
244
+ v_three_config: {
245
+ values: {
246
+ colors: { values: { text_primary: '#000000' } },
247
+ ui_configs: {
248
+ floating_button: { position: 'top-center' }, // invalid → transform throws
249
+ look_and_feel: { typography: {} },
250
+ },
251
+ },
252
+ },
253
+ },
254
+ experiment_assignments: [
255
+ {
256
+ layer_name: 'ml',
257
+ namespace: 'ml',
258
+ allocated_experiment_name: 'exp_a',
259
+ group_name: 'A',
260
+ },
261
+ ],
262
+ resolution: { base_version: '2.0.0', base_version_source: 'merchant_layer' },
263
+ },
264
+ url_resolving: null,
265
+ }),
266
+ });
267
+
268
+ const { config } = await fetchBootstrapConfig(baseParams);
269
+
270
+ // org / gates / experiments survived the products-config failure.
271
+ expect(config.org.org.id).toBe('org-boot');
272
+ expect(config.gates).toContainEqual({
273
+ name: 'use_unified_config',
274
+ value: true,
275
+ groupName: 'Treatment',
276
+ });
277
+ expect(config.experiment_assignments).toEqual([
278
+ { layer_name: 'ml', namespace: 'ml', allocated_experiment_name: 'exp_a', group_name: 'A' },
279
+ ]);
280
+ // colors/frontend degraded to undefined, resolution still attached.
281
+ expect(config.orgConfig).toEqual({
282
+ colorsConfig: undefined,
283
+ frontendConfig: undefined,
284
+ resolution: { baseVersion: '2.0.0', baseVersionSource: 'merchant_layer' },
285
+ });
286
+ });
287
+
288
+ it('throws on a non-ok response so the caller aborts injection (no fallback)', async () => {
211
289
  mockFetch.mockResolvedValueOnce({ ok: false, statusText: 'Bad Gateway' });
212
290
 
213
291
  await expect(fetchBootstrapConfig(baseParams)).rejects.toThrow(
@@ -225,14 +303,13 @@ describe('fetchBootstrapConfig', () => {
225
303
  'partial org_config (no org.org)',
226
304
  { org_config: { org: {}, gates: okGates }, widget_config: okWidget },
227
305
  ],
228
- ['absent gates', { org_config: { org: { org: { id: 'o' } } }, widget_config: okWidget }],
229
306
  [
230
- 'empty gates',
231
- { org_config: { org: { org: { id: 'o' } }, gates: [] }, widget_config: okWidget },
307
+ 'absent gates (not an array)',
308
+ { org_config: { org: { org: { id: 'o' } } }, widget_config: okWidget },
232
309
  ],
233
310
  ['missing widget_config', { org_config: okOrg, widget_config: null }],
234
311
  ])(
235
- 'throws on a 2xx with %s so the caller can fall back to getWidgetConfig',
312
+ 'throws on a 2xx with %s so the caller aborts injection instead of using empty config',
236
313
  async (_label, payload) => {
237
314
  mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(payload) });
238
315
 
@@ -240,6 +317,27 @@ describe('fetchBootstrapConfig', () => {
240
317
  },
241
318
  );
242
319
 
320
+ it('accepts an EMPTY (but present) gates array as a valid all-disabled config', async () => {
321
+ // An empty gates array is a legitimate "everything off" response (no user to
322
+ // bucket, or every requested gate off). It must NOT be rejected as malformed:
323
+ // FeatureFlagService resolves every gate to false → the session disables
324
+ // gracefully (with the init event still firing), rather than aborting injection.
325
+ mockFetch.mockResolvedValueOnce({
326
+ ok: true,
327
+ json: () =>
328
+ Promise.resolve({
329
+ org_config: { org: { org: { id: 'org-boot', short_name: 'boot-org' } }, gates: [] },
330
+ widget_config: { products_config: {}, experiment_assignments: [] },
331
+ url_resolving: null,
332
+ }),
333
+ });
334
+
335
+ const { config } = await fetchBootstrapConfig(baseParams);
336
+
337
+ expect(config.org.org.id).toBe('org-boot');
338
+ expect(config.gates).toEqual([]);
339
+ });
340
+
243
341
  it('passes an AbortSignal to fetch (timeout wiring)', async () => {
244
342
  makeBootstrapResponse();
245
343
 
@@ -249,7 +347,7 @@ describe('fetchBootstrapConfig', () => {
249
347
  expect(options.signal).toBeInstanceOf(AbortSignal);
250
348
  });
251
349
 
252
- it('aborts and rejects when the request exceeds the timeout so the caller can fall back', async () => {
350
+ it('aborts and rejects when the request exceeds the timeout (injection aborts, no fallback)', async () => {
253
351
  vi.useFakeTimers();
254
352
  // Mimic real fetch: reject with an AbortError once its signal aborts.
255
353
  mockFetch.mockImplementationOnce(