@envive-ai/react-hooks 0.3.61 → 0.3.63

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 (80) hide show
  1. package/dist/application/utils/elementObserver.d.cts +2 -2
  2. package/dist/application/utils/widgetTextRequestToApiRequest.cjs +1 -0
  3. package/dist/application/utils/widgetTextRequestToApiRequest.js +1 -1
  4. package/dist/atoms/app/index.d.cts +7 -7
  5. package/dist/atoms/app/index.d.ts +7 -7
  6. package/dist/atoms/app/variant.d.ts +6 -6
  7. package/dist/atoms/chat/chatState.d.cts +19 -19
  8. package/dist/atoms/chat/chatState.d.ts +19 -19
  9. package/dist/atoms/chat/form.d.cts +2 -2
  10. package/dist/atoms/chat/form.d.ts +2 -2
  11. package/dist/atoms/chat/index.d.cts +2 -2
  12. package/dist/atoms/chat/index.d.ts +3 -3
  13. package/dist/atoms/chat/lastMessage.d.cts +2 -2
  14. package/dist/atoms/chat/lastMessage.d.ts +2 -2
  15. package/dist/atoms/chat/messageQueue.d.cts +6 -6
  16. package/dist/atoms/chat/messageQueue.d.ts +7 -7
  17. package/dist/atoms/chat/performanceMetrics.d.cts +6 -6
  18. package/dist/atoms/chat/performanceMetrics.d.ts +6 -6
  19. package/dist/atoms/chat/renderedWidgetRefs.d.cts +2 -2
  20. package/dist/atoms/chat/renderedWidgetRefs.d.ts +3 -3
  21. package/dist/atoms/chat/replies.d.cts +3 -3
  22. package/dist/atoms/chat/replies.d.ts +3 -3
  23. package/dist/atoms/chat/suggestions.d.cts +2 -2
  24. package/dist/atoms/chat/suggestions.d.ts +2 -2
  25. package/dist/atoms/envive/enviveConfig.d.cts +14 -14
  26. package/dist/atoms/globalSearch/globalSearch.d.cts +5 -5
  27. package/dist/atoms/org/customerService.d.cts +6 -6
  28. package/dist/atoms/org/customerService.d.ts +6 -6
  29. package/dist/atoms/org/graphqlConfig.d.cts +4 -4
  30. package/dist/atoms/org/graphqlConfig.d.ts +4 -4
  31. package/dist/atoms/org/newOrgConfigAtom.d.cts +2 -2
  32. package/dist/atoms/org/newOrgConfigAtom.d.ts +2 -2
  33. package/dist/atoms/org/orgAnalyticsConfig.d.cts +4 -4
  34. package/dist/atoms/search/chatSearch.d.cts +17 -17
  35. package/dist/atoms/search/chatSearch.d.ts +17 -17
  36. package/dist/atoms/search/searchAPI.d.cts +13 -13
  37. package/dist/atoms/search/searchAPI.d.ts +13 -13
  38. package/dist/atoms/search/types.d.cts +1 -1
  39. package/dist/atoms/search/utils.d.cts +1 -1
  40. package/dist/atoms/widget/chatPreviewLoading.d.cts +2 -2
  41. package/dist/contexts/systemSettingsContext/systemSettingsContext.d.cts +2 -2
  42. package/dist/contexts/types.d.cts +1 -1
  43. package/dist/contexts/types.d.ts +1 -1
  44. package/dist/contexts/typesV3.d.cts +1 -1
  45. package/dist/hooks/GrabAndScroll/useGrabAndScroll.d.cts +2 -2
  46. package/dist/hooks/GrabAndScroll/useGrabAndScroll.d.ts +2 -2
  47. package/dist/hooks/SystemSettingsContext/useSystemSettingsContext.d.cts +2 -2
  48. package/dist/hooks/utils.d.cts +1 -1
  49. package/dist/hooks/utils.d.ts +1 -1
  50. package/dist/services/enviveConfigService/enviveConfigService.cjs +49 -3
  51. package/dist/services/enviveConfigService/enviveConfigService.d.cts +16 -2
  52. package/dist/services/enviveConfigService/enviveConfigService.d.ts +16 -2
  53. package/dist/services/enviveConfigService/enviveConfigService.js +49 -3
  54. package/dist/services/enviveConfigService/fetchBootstrapConfig.cjs +102 -0
  55. package/dist/services/enviveConfigService/fetchBootstrapConfig.js +102 -0
  56. package/dist/services/enviveConfigService/fetchGraphQLConfig.cjs +2 -1
  57. package/dist/services/enviveConfigService/fetchGraphQLConfig.js +2 -2
  58. package/dist/services/hardcopyService/hardcopyService.cjs +20 -4
  59. package/dist/services/hardcopyService/hardcopyService.d.cts +3 -2
  60. package/dist/services/hardcopyService/hardcopyService.d.ts +3 -2
  61. package/dist/services/hardcopyService/hardcopyService.js +20 -5
  62. package/dist/services/hardcopyService/index.cjs +1 -0
  63. package/dist/services/hardcopyService/index.d.cts +2 -2
  64. package/dist/services/hardcopyService/index.d.ts +2 -2
  65. package/dist/services/hardcopyService/index.js +2 -2
  66. package/dist/services/urlResolverWarmupService/index.cjs +3 -3
  67. package/dist/services/urlResolverWarmupService/index.d.cts +8 -2
  68. package/dist/services/urlResolverWarmupService/index.d.ts +8 -2
  69. package/dist/services/urlResolverWarmupService/index.js +3 -3
  70. package/package.json +1 -1
  71. package/src/hooks/Intersection/__tests__/useIntersection.test.ts +1 -3
  72. package/src/services/enviveConfigService/__tests__/enviveConfigService.test.ts +226 -0
  73. package/src/services/enviveConfigService/__tests__/fetchBootstrapConfig.test.ts +271 -0
  74. package/src/services/enviveConfigService/enviveConfigService.ts +124 -5
  75. package/src/services/enviveConfigService/fetchBootstrapConfig.ts +276 -0
  76. package/src/services/enviveConfigService/fetchGraphQLConfig.ts +7 -4
  77. package/src/services/hardcopyService/__tests__/hardcopyService.test.ts +60 -1
  78. package/src/services/hardcopyService/hardcopyService.ts +45 -3
  79. package/src/services/urlResolverWarmupService/__tests__/urlResolverWarmupService.test.ts +55 -0
  80. package/src/services/urlResolverWarmupService/index.ts +18 -1
@@ -30,6 +30,76 @@ function makeOkConfigFetch() {
30
30
  });
31
31
  }
32
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() {
65
+ mockFetch.mockResolvedValueOnce({
66
+ ok: true,
67
+ json: () =>
68
+ Promise.resolve({
69
+ org_config: {
70
+ org: {
71
+ org: {
72
+ id: 'org-boot',
73
+ short_name: 'boot-org',
74
+ display_name: 'Boot Org',
75
+ domain: 'boot.com',
76
+ status: 'active',
77
+ created_at: '2024-01-01T00:00:00.000Z',
78
+ updated_at: '2024-01-01T00:00:00.000Z',
79
+ },
80
+ settings: {},
81
+ },
82
+ gates: [{ name: 'use_unified_config', value: true, group_name: 'Treatment' }],
83
+ resolution: { base_version: '1.0.0', base_version_source: 'merchant_layer' },
84
+ },
85
+ widget_config: {
86
+ products_config: {},
87
+ // Assignments come from widget_config (matches getWidgetConfig, ungated).
88
+ experiment_assignments: [
89
+ {
90
+ layer_name: 'ml',
91
+ namespace: 'ml',
92
+ allocated_experiment_name: 'exp_a',
93
+ group_name: 'A',
94
+ },
95
+ ],
96
+ resolution: { base_version: '1.0.0', base_version_source: 'merchant_layer' },
97
+ },
98
+ url_resolving: { variant_type: 'pdp', specific_details: {}, ready: true },
99
+ }),
100
+ });
101
+ }
102
+
33
103
  function makeService(
34
104
  overrides: Partial<ConstructorParameters<typeof EnviveConfigService>[0]> = {},
35
105
  ) {
@@ -182,3 +252,159 @@ describe('EnviveConfigService caching', () => {
182
252
  expect(result.org.org.id).toBe('org-1');
183
253
  });
184
254
  });
255
+
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).
259
+ const bootstrapService = () => makeService({ env: 'dev', contextSource: 'app' });
260
+
261
+ it('stays on getWidgetConfig (no bootstrap) when the gate is off', async () => {
262
+ makeOkConfigFetch(); // gates: []
263
+
264
+ const service = bootstrapService();
265
+ const result = await service.getEnviveConfig();
266
+
267
+ 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.
283
+ expect(result.org.org.id).toBe('org-boot');
284
+ expect(result.org.org.short_name).toBe('boot-org');
285
+ // gates: snake group_name mapped to camel groupName.
286
+ expect(result.gates).toContainEqual({
287
+ name: 'use_unified_config',
288
+ value: true,
289
+ groupName: 'Treatment',
290
+ });
291
+ // experiment_assignments pass through (already snake).
292
+ expect(result.experiment_assignments).toEqual([
293
+ { layer_name: 'ml', namespace: 'ml', allocated_experiment_name: 'exp_a', group_name: 'A' },
294
+ ]);
295
+ // resolution mapped snake -> camel.
296
+ expect(result.orgConfig?.resolution).toEqual({
297
+ baseVersion: '1.0.0',
298
+ baseVersionSource: 'merchant_layer',
299
+ });
300
+ });
301
+
302
+ it('stashes bootstrap url_resolving (paired with its URL) for the warm-up to consume once', async () => {
303
+ makeGateOnConfigFetch();
304
+ makeBootstrapFetch();
305
+
306
+ const service = bootstrapService();
307
+ await service.getEnviveConfig();
308
+
309
+ const taken = service.takeBootstrapUrlResolving();
310
+ expect(taken?.response).toEqual({ variant_type: 'pdp', specific_details: {}, ready: true });
311
+ // paired with the URL bootstrap resolved (the current, cleansed href) so the
312
+ // warm-up can guard against a SPA nav before seeding.
313
+ expect(taken?.url).toBe(window.location.href.toLowerCase().trim());
314
+ // handed off exactly once.
315
+ expect(service.takeBootstrapUrlResolving()).toBeNull();
316
+ });
317
+
318
+ it('sends url_resolving context + gate names + version pin in the bootstrap body', async () => {
319
+ makeGateOnConfigFetch();
320
+ makeBootstrapFetch();
321
+
322
+ await bootstrapService().getEnviveConfig();
323
+
324
+ const [, options] = mockFetch.mock.calls[1];
325
+ const body = JSON.parse(options.body as string);
326
+ expect(body.include_feature_gates).toContain('use_unified_config');
327
+ // Scopes org_config.configs so bootstrap doesn't dump internal configs to the browser.
328
+ 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.
338
+ expect(body.url_resolving.org_base_config_version).toBeUndefined();
339
+ 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);
343
+ // the persistent chat session id is threaded (not an empty string).
344
+ expect(body.url_resolving.context.chat_id).toBeTruthy();
345
+ });
346
+
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.
350
+ 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
+ makeBootstrapFetch();
353
+
354
+ await bootstrapService().getEnviveConfig();
355
+
356
+ const body = JSON.parse(mockFetch.mock.calls[1][1].body as string);
357
+ expect(body.url_resolving.feature_gates.is_empty_div_plp_enabled).toBe(true);
358
+ });
359
+
360
+ it('skips bootstrap for non-bundle callers (no env/contextSource) even when the gate is on', async () => {
361
+ makeGateOnConfigFetch();
362
+
363
+ const result = await makeService().getEnviveConfig(); // no env/contextSource
364
+
365
+ expect(mockFetch).toHaveBeenCalledTimes(1);
366
+ expect(result.org.org.id).toBe('org-1');
367
+ });
368
+
369
+ it('falls back to the getWidgetConfig config when bootstrap fails', async () => {
370
+ makeGateOnConfigFetch();
371
+ mockFetch.mockResolvedValueOnce({ ok: false, statusText: 'Bad Gateway' });
372
+
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();
380
+ });
381
+
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).
386
+ mockFetch.mockResolvedValueOnce({
387
+ ok: true,
388
+ json: () => Promise.resolve({ org_config: {}, widget_config: null }),
389
+ });
390
+
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');
409
+ });
410
+ });
@@ -0,0 +1,271 @@
1
+ import { type FetchBootstrapConfigParams, fetchBootstrapConfig } from '../fetchBootstrapConfig';
2
+
3
+ const mockFetch = vi.fn();
4
+ globalThis.fetch = mockFetch;
5
+
6
+ // A representative /v1/session/bootstrap response (REST snake_case). products_config
7
+ // is left empty so the shared transformV3ProductsConfig falls back to mock config —
8
+ // its internals are covered by fetchGraphQLConfig.test.ts; here we only assert the
9
+ // bootstrap-specific mapping (org/gates/assignments/resolution/url_resolving).
10
+ function makeBootstrapResponse() {
11
+ mockFetch.mockResolvedValueOnce({
12
+ ok: true,
13
+ json: () =>
14
+ Promise.resolve({
15
+ org_config: {
16
+ org: {
17
+ org: {
18
+ id: 'org-boot',
19
+ short_name: 'boot-org',
20
+ display_name: 'Boot Org',
21
+ domain: 'boot.com',
22
+ status: 'active',
23
+ created_at: '2024-01-01T00:00:00.000Z',
24
+ updated_at: '2024-02-02T00:00:00.000Z',
25
+ },
26
+ settings: {},
27
+ },
28
+ gates: [
29
+ { name: 'use_unified_config', value: true, group_name: 'Treatment', rule_id: 'r1' },
30
+ { name: 'is_client_session_enabled', value: false, group_name: null, rule_id: null },
31
+ ],
32
+ // Decoy: org_config.experiment_assignments is server-gated on
33
+ // unified_config_active and must NOT be the source. If the mapper reads
34
+ // this, the assertion below fails.
35
+ experiment_assignments: [
36
+ {
37
+ layer_name: 'DECOY_org_config',
38
+ namespace: 'ml',
39
+ allocated_experiment_name: 'should_not_be_read',
40
+ group_name: 'X',
41
+ },
42
+ ],
43
+ resolution: { base_version: '1.0.0', base_version_source: 'merchant_layer' },
44
+ },
45
+ widget_config: {
46
+ products_config: {},
47
+ // The correct source — mirrors getWidgetConfig (ungated).
48
+ experiment_assignments: [
49
+ {
50
+ layer_name: 'ml',
51
+ namespace: 'ml',
52
+ allocated_experiment_name: 'exp_a',
53
+ group_name: 'A',
54
+ },
55
+ ],
56
+ resolution: { base_version: '1.0.0', base_version_source: 'merchant_layer' },
57
+ },
58
+ url_resolving: { variant_type: 'pdp', specific_details: {}, ready: true },
59
+ }),
60
+ });
61
+ }
62
+
63
+ const baseParams: FetchBootstrapConfigParams = {
64
+ baseUrl: 'https://commerce-api.spiffy.ai',
65
+ apiKey: 'test-key',
66
+ userId: 'user-1',
67
+ source: 'app',
68
+ namespace: 'spiffy-react-components',
69
+ gateNames: ['use_unified_config', 'is_client_session_enabled'],
70
+ url: 'https://shop.example.com/products/thing',
71
+ orgId: 'org-1',
72
+ orgShortName: 'test-org',
73
+ contextSource: 'app',
74
+ env: 'prod',
75
+ featureGates: { use_unified_config: true, is_empty_div_plp_enabled: true },
76
+ };
77
+
78
+ beforeEach(() => {
79
+ vi.clearAllMocks();
80
+ window.history.replaceState({}, '', '/');
81
+ });
82
+
83
+ describe('fetchBootstrapConfig', () => {
84
+ it('POSTs to /v1/session/bootstrap with the bearer key', async () => {
85
+ makeBootstrapResponse();
86
+
87
+ await fetchBootstrapConfig(baseParams);
88
+
89
+ const [url, options] = mockFetch.mock.calls[0];
90
+ expect(url).toBe('https://commerce-api.spiffy.ai/v1/session/bootstrap');
91
+ expect(options.method).toBe('POST');
92
+ expect(options.headers.Authorization).toBe('Bearer test-key');
93
+ });
94
+
95
+ it('maps org identity, gates (group_name→groupName), assignments, and resolution', async () => {
96
+ makeBootstrapResponse();
97
+
98
+ const { config } = await fetchBootstrapConfig(baseParams);
99
+
100
+ expect(config.org.org).toEqual({
101
+ id: 'org-boot',
102
+ short_name: 'boot-org',
103
+ display_name: 'Boot Org',
104
+ domain: 'boot.com',
105
+ status: 'active',
106
+ created_at: '2024-01-01T00:00:00.000Z',
107
+ updated_at: '2024-02-02T00:00:00.000Z',
108
+ });
109
+ // snake group_name → camel groupName; null → undefined.
110
+ expect(config.gates).toEqual([
111
+ { name: 'use_unified_config', value: true, groupName: 'Treatment' },
112
+ { name: 'is_client_session_enabled', value: false, groupName: undefined },
113
+ ]);
114
+ // experiment_assignments already snake — pass straight through.
115
+ expect(config.experiment_assignments).toEqual([
116
+ { layer_name: 'ml', namespace: 'ml', allocated_experiment_name: 'exp_a', group_name: 'A' },
117
+ ]);
118
+ // resolution snake → camel, attached to orgConfig.
119
+ expect(config.orgConfig?.resolution).toEqual({
120
+ baseVersion: '1.0.0',
121
+ baseVersionSource: 'merchant_layer',
122
+ });
123
+ });
124
+
125
+ it('reads experiment_assignments from widget_config, not the server-gated org_config', async () => {
126
+ makeBootstrapResponse();
127
+
128
+ const { config } = await fetchBootstrapConfig(baseParams);
129
+
130
+ // org_config carried a DECOY; the value must come from widget_config so it
131
+ // matches getWidgetConfig (which is ungated by use_unified_config). Reading
132
+ // org_config would drop assignments whenever the gate is off server-side.
133
+ expect(config.experiment_assignments).toEqual([
134
+ { layer_name: 'ml', namespace: 'ml', allocated_experiment_name: 'exp_a', group_name: 'A' },
135
+ ]);
136
+ expect(config.experiment_assignments?.some(a => a.layer_name === 'DECOY_org_config')).toBe(
137
+ false,
138
+ );
139
+ });
140
+
141
+ it('returns the url_resolving payload verbatim for the warm-up to seed', async () => {
142
+ makeBootstrapResponse();
143
+
144
+ const { urlResolving } = await fetchBootstrapConfig(baseParams);
145
+
146
+ expect(urlResolving).toEqual({ variant_type: 'pdp', specific_details: {}, ready: true });
147
+ });
148
+
149
+ it('returns urlResolving null when bootstrap omitted the branch', async () => {
150
+ mockFetch.mockResolvedValueOnce({
151
+ ok: true,
152
+ json: () =>
153
+ Promise.resolve({
154
+ org_config: {
155
+ org: { org: { id: 'o' } },
156
+ gates: [{ name: 'use_unified_config', value: true }],
157
+ },
158
+ widget_config: { products_config: {} },
159
+ url_resolving: null,
160
+ }),
161
+ });
162
+
163
+ const { urlResolving } = await fetchBootstrapConfig(baseParams);
164
+
165
+ expect(urlResolving).toBeNull();
166
+ });
167
+
168
+ it('builds the url_resolving context and version split from params', async () => {
169
+ makeBootstrapResponse();
170
+
171
+ await fetchBootstrapConfig({ ...baseParams, chatId: 'chat-9' });
172
+
173
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body as string);
174
+ expect(body).toMatchObject({
175
+ user_id: 'user-1',
176
+ source: 'app',
177
+ // Scopes org_config.configs — must be sent or bootstrap dumps internal configs.
178
+ namespace: 'spiffy-react-components',
179
+ include_feature_gates: ['use_unified_config', 'is_client_session_enabled'],
180
+ });
181
+ expect(body.url_resolving).toMatchObject({
182
+ url: 'https://shop.example.com/products/thing',
183
+ // Override-aware gates forwarded so client overrides reach page-variant resolution.
184
+ feature_gates: { use_unified_config: true, is_empty_div_plp_enabled: true },
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.
188
+ expect(body.url_resolving.org_base_config_version).toBeUndefined();
189
+ expect(body.url_resolving.context).toEqual({
190
+ user_id: 'user-1',
191
+ org_id: 'org-1',
192
+ org_short_name: 'test-org',
193
+ chat_id: 'chat-9',
194
+ source: 'app',
195
+ env: 'prod',
196
+ });
197
+ });
198
+
199
+ it('threads a config-version query param into version + override_config_version', async () => {
200
+ window.history.replaceState({}, '', '/?envive_config_version=9.9.9');
201
+ makeBootstrapResponse();
202
+
203
+ await fetchBootstrapConfig(baseParams);
204
+
205
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body as string);
206
+ expect(body.version).toBe('9.9.9');
207
+ expect(body.url_resolving.override_config_version).toBe('9.9.9');
208
+ });
209
+
210
+ it('throws on a non-ok response so the caller can fall back', async () => {
211
+ mockFetch.mockResolvedValueOnce({ ok: false, statusText: 'Bad Gateway' });
212
+
213
+ await expect(fetchBootstrapConfig(baseParams)).rejects.toThrow(
214
+ 'Bootstrap config request failed',
215
+ );
216
+ });
217
+
218
+ // Each case isolates ONE missing/degraded required section (the others valid).
219
+ const okGates = [{ name: 'use_unified_config', value: true }];
220
+ const okOrg = { org: { org: { id: 'o' } }, gates: okGates };
221
+ const okWidget = { products_config: {} };
222
+ it.each([
223
+ ['missing org_config', { org_config: undefined, widget_config: okWidget }],
224
+ [
225
+ 'partial org_config (no org.org)',
226
+ { org_config: { org: {}, gates: okGates }, widget_config: okWidget },
227
+ ],
228
+ ['absent gates', { org_config: { org: { org: { id: 'o' } } }, widget_config: okWidget }],
229
+ [
230
+ 'empty gates',
231
+ { org_config: { org: { org: { id: 'o' } }, gates: [] }, widget_config: okWidget },
232
+ ],
233
+ ['missing widget_config', { org_config: okOrg, widget_config: null }],
234
+ ])(
235
+ 'throws on a 2xx with %s so the caller can fall back to getWidgetConfig',
236
+ async (_label, payload) => {
237
+ mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(payload) });
238
+
239
+ await expect(fetchBootstrapConfig(baseParams)).rejects.toThrow('missing required sections');
240
+ },
241
+ );
242
+
243
+ it('passes an AbortSignal to fetch (timeout wiring)', async () => {
244
+ makeBootstrapResponse();
245
+
246
+ await fetchBootstrapConfig(baseParams);
247
+
248
+ const [, options] = mockFetch.mock.calls[0];
249
+ expect(options.signal).toBeInstanceOf(AbortSignal);
250
+ });
251
+
252
+ it('aborts and rejects when the request exceeds the timeout so the caller can fall back', async () => {
253
+ vi.useFakeTimers();
254
+ // Mimic real fetch: reject with an AbortError once its signal aborts.
255
+ mockFetch.mockImplementationOnce(
256
+ (_url: string, opts: { signal: AbortSignal }) =>
257
+ new Promise((_resolve, reject) => {
258
+ opts.signal.addEventListener('abort', () =>
259
+ reject(new DOMException('The operation was aborted', 'AbortError')),
260
+ );
261
+ }),
262
+ );
263
+
264
+ const promise = fetchBootstrapConfig(baseParams);
265
+ const rejection = expect(promise).rejects.toThrow();
266
+ await vi.advanceTimersByTimeAsync(5000);
267
+ await rejection;
268
+
269
+ vi.useRealTimers();
270
+ });
271
+ });